1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import { assert, it } from "@effect/vitest";
3import { SharedAccountRequest, SharedAccountError } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as Cause from "effect/Cause";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";
9import * as TestClock from "effect/testing/TestClock";
10import { HttpClient, HttpClientResponse } from "effect/http";
11import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
12import * as SharedAccounts from "./SharedAccounts.ts";
13import * as SharedAccountClient from "./SharedAccountClient.ts";
14
15const decodeRequest = Schema.decodeEffect(Schema.fromJsonString(SharedAccountRequest));
16const isSharedAccountError = Schema.is(SharedAccountError);
17
18const jwt = (expiresAt: number) =>
19 `eyJhbGciOiJub25lIn0.${Buffer.from(JSON.stringify({ exp: expiresAt / 1000 })).toString("base64url")}.signature`;
20const fixture = Effect.gen(function* () {
21 const bytes = new Map<string, Uint8Array>();
22 const store = ServerSecretStore.ServerSecretStore.of({
23 get: (key) => Effect.sync(() => Option.fromUndefinedOr(bytes.get(key))),
24 set: (key, value) =>
25 Effect.sync(() => {
26 bytes.set(key, value);
27 }),
28 remove: (key) =>
29 Effect.sync(() => {
30 bytes.delete(key);
31 }),
32 create: () => Effect.die("unused"),
33 getOrCreateRandom: () => Effect.die("unused"),
34 });
35 const hub = yield* SharedAccounts.SharedAccounts.pipe(
36 Effect.provide(SharedAccounts.layer),
37 Effect.provideService(ServerSecretStore.ServerSecretStore, store),
38 );
39 const imported = yield* hub.execute({
40 type: "import",
41 label: "Personal",
42 credentials: {
43 driver: "codex",
44 accountId: "account-a",
45 accessToken: jwt(1),
46 refreshToken: "refresh-a",
47 idToken: "id-a",
48 expiresAt: 1,
49 },
50 });
51 if (imported.type !== "imported") return yield* Effect.die("Expected an account");
52 let refreshes = 0;
53 let offline = false;
54 let loseCommitResponse = false;
55 let corruptResponse = false;
56 let expireClaim = false;
57 const http = HttpClient.make((request) =>
58 Effect.gen(function* () {
59 if (offline) return HttpClientResponse.fromWeb(request, new Response(null, { status: 503 }));
60 if (corruptResponse)
61 return HttpClientResponse.fromWeb(request, new Response("review-access-secret-sentinel"));
62 if (request.url === "https://auth.openai.com/oauth/token") {
63 refreshes++;
64 assert.strictEqual(request.body._tag, "Uint8Array");
65 if (request.body._tag !== "Uint8Array") return yield* Effect.die("Expected JSON");
66 assert.strictEqual(
67 JSON.parse(new TextDecoder().decode(request.body.body)).refresh_token,
68 "refresh-a",
69 );
70 return HttpClientResponse.fromWeb(
71 request,
72 Response.json({
73 access_token: jwt(600_000),
74 refresh_token: "refresh-b",
75 id_token: "id-b",
76 }),
77 );
78 }
79 assert.strictEqual(request.headers.authorization, "Bearer machine-token");
80 assert.strictEqual(request.url, "https://shared.example.test/api/shared/accounts");
81 if (request.body._tag !== "Uint8Array") return yield* Effect.die("Expected JSON");
82 const input = yield* decodeRequest(new TextDecoder().decode(request.body.body)).pipe(
83 Effect.orDie,
84 );
85 const response = yield* hub
86 .execute(input)
87 .pipe(Effect.catchTags({ SharedAccountError: (error) => Effect.succeed(error) }));
88 if (!isSharedAccountError(response) && response.type === "refresh" && expireClaim) {
89 expireClaim = false;
90 return HttpClientResponse.fromWeb(request, Response.json({ ...response, expiresAt: 0 }));
91 }
92 if (input.type === "commit" && loseCommitResponse) {
93 loseCommitResponse = false;
94 return HttpClientResponse.fromWeb(request, new Response(null, { status: 503 }));
95 }
96 return HttpClientResponse.fromWeb(
97 request,
98 Response.json(response, { status: isSharedAccountError(response) ? 404 : 200 }),
99 );
100 }),
101 );
102 const layer = SharedAccountClient.layer.pipe(
103 Layer.provide(Layer.succeed(SharedAccounts.SharedAccounts, hub)),
104 Layer.provide(Layer.succeed(ServerSecretStore.ServerSecretStore, store)),
105 Layer.provide(Layer.succeed(HttpClient.HttpClient, http)),
106 );
107 const restart = SharedAccountClient.SharedAccountClient.pipe(Effect.provide(layer));
108 const client = yield* restart;
109 yield* client.configure({ url: "https://shared.example.test/", token: "machine-token" });
110 return {
111 id: imported.account.id,
112 client,
113 restart,
114 hub,
115 refreshes: () => refreshes,
116 goOffline: () => {
117 offline = true;
118 },
119 loseResponse: () => {
120 loseCommitResponse = true;
121 },
122 corruptResponse: () => {
123 corruptResponse = true;
124 },
125 expireClaim: () => {
126 expireClaim = true;
127 },
128 hasJournal: () => [...bytes.keys()].some((key) => key.startsWith("shared-account-refresh-")),
129 };
130}).pipe(Effect.provide(NodeCrypto.layer));
131
132it.effect(
133 "refreshes once and recovers a committed rotation after losing the response and restarting",
134 () =>
135 Effect.gen(function* () {
136 const f = yield* fixture;
137 f.loseResponse();
138 assert.strictEqual((yield* f.client.access(f.id).pipe(Effect.flip)).reason, "connection");
139 assert.isTrue(f.hasJournal());
140 const restarted = yield* f.restart;
141 assert.strictEqual((yield* restarted.access(f.id)).accessToken, jwt(600_000));
142 assert.strictEqual(f.refreshes(), 1);
143 assert.isFalse(f.hasJournal());
144 }).pipe(Effect.provide(NodeCrypto.layer)),
145);
146
147it.effect(
148 "uses cached access while disconnected, but never expires or force-refreshes from the cache",
149 () =>
150 Effect.gen(function* () {
151 const f = yield* fixture;
152 const access = yield* f.client.access(f.id);
153 f.goOffline();
154 assert.deepStrictEqual(yield* f.client.access(f.id), access);
155 assert.strictEqual(
156 (yield* f.client.access(f.id, true).pipe(Effect.flip)).reason,
157 "connection",
158 );
159 yield* TestClock.adjust("11 minutes");
160 assert.strictEqual((yield* f.client.access(f.id).pipe(Effect.flip)).reason, "connection");
161 }).pipe(Effect.provide(NodeCrypto.layer)),
162);
163
164it.effect("does not revive an account removed on the shared server from its cached token", () =>
165 Effect.gen(function* () {
166 const f = yield* fixture;
167 yield* f.client.access(f.id);
168 yield* f.hub.execute({ type: "remove", id: f.id });
169 assert.strictEqual((yield* f.client.access(f.id).pipe(Effect.flip)).reason, "missing");
170 }).pipe(Effect.provide(NodeCrypto.layer)),
171);
172
173it.effect("redacts a malformed credential response from the failure trace", () =>
174 Effect.gen(function* () {
175 const f = yield* fixture;
176 f.corruptResponse();
177 const result = yield* f.client.execute({ type: "list" }).pipe(Effect.exit);
178 if (result._tag !== "Failure") return yield* Effect.die("Expected a malformed response error");
179 const trace = Cause.pretty(result.cause);
180 assert.isFalse(trace.includes("review-access"));
181 assert.isFalse(trace.includes("machine-token"));
182 }).pipe(Effect.provide(NodeCrypto.layer)),
183);
184
185it.effect("rejects an expired refresh claim before contacting the token endpoint", () =>
186 Effect.gen(function* () {
187 const f = yield* fixture;
188 f.expireClaim();
189 assert.strictEqual((yield* f.client.access(f.id).pipe(Effect.flip)).reason, "stale");
190 assert.strictEqual(f.refreshes(), 0);
191 }).pipe(Effect.provide(NodeCrypto.layer)),
192);