Skip to content
Source
Sign inSign up
Open History to see the last commit

AntigravityAuth.test.ts

587 lines22.8 KB

1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Layer from "effect/Layer";
4import { assert, it } from "@effect/vitest";
5import { ProviderInstanceId, ProviderSetupError, type ProviderAuthState } from "@t3tools/contracts";
6import * as Deferred from "effect/Deferred";
7import * as Effect from "effect/Effect";
8import * as Exit from "effect/Exit";
9import * as Fiber from "effect/Fiber";
10import * as Option from "effect/Option";
11import * as Scope from "effect/Scope";
12import * as Stream from "effect/Stream";
13import * as TestClock from "effect/testing/TestClock";
14import * as AcpErrors from "effect-acp/errors";
15import type * as AcpSchema from "effect-acp/compat";
16​
17import {
18 makeAntigravityAuth,
19 type AntigravityAuth,
20 type AntigravityAuthRuntime,
21} from "./AntigravityAuth.ts";
22import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
23​
24const instanceId = ProviderInstanceId.make("antigravity-auth-test");
25const owner = "t3-auth-session-owner";
26const otherOwner = "t3-auth-session-other";
27const authorizationUrl =
28 "https://accounts.google.com/o/oauth2/v2/auth?response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A51234%2F&state=test-state";
29const callbackUrl = "http://127.0.0.1:51234/?state=test-state&code=test-code";
30​
31const initialized = {
32 protocolVersion: 1,
33 authMethods: [{ id: "oauth-personal", name: "Log in with Google" }],
34 agentCapabilities: { auth: { logout: {} } },
35} satisfies AcpSchema.InitializeResponse;
36const started: AcpSessionRuntime.AcpSessionRuntimeStartResult = {
37 sessionId: "native-session",
38 initializeResult: initialized,
39 sessionSetupResult: {
40 sessionId: "native-session",
41 models: {
42 currentModelId: "gemini-test",
43 availableModels: [{ modelId: "gemini-test", name: "Gemini test" }],
44 },
45 },
46 modelConfigId: "model",
47};
48​
49const phase = (auth: AntigravityAuth, value: ProviderAuthState["phase"], sessionId = owner) =>
50 auth.controller.subscribe(sessionId).pipe(
51 Stream.filter((state) => state.phase === value),
52 Stream.runHead,
53 Effect.map(Option.getOrThrow),
54 );
55​
56const makeHarness = Effect.fn("makeAuthTestHarness")(function* (
57 options: {
58 readonly interactive?: boolean;
59 readonly authorizationUrls?: ReadonlyArray<string>;
60 readonly supportsLogout?: boolean;
61 readonly beforeInitialize?: Effect.Effect<void>;
62 readonly forwardCallback?: Effect.Effect<void, ProviderSetupError>;
63 } = {},
64) {
65 const authenticated = yield* Deferred.make<void, AcpErrors.AcpError>();
66 const discovered = yield* Deferred.make<void, AcpErrors.AcpError>();
67 const closed = yield* Deferred.make<void>();
68 const authenticateCalled = yield* Deferred.make<void>();
69 const events: string[] = [];
70 let receiveAuthorizationUrl:
71 | ((url: string) => Effect.Effect<void, AcpErrors.AcpError>)
72 | undefined;
73 let forwarded = 0;
74 let catalog = ["previous-account-model"];
75 const auth = yield* makeAntigravityAuth({
76 instanceId,
77 makeRuntime: (input) =>
78 Effect.gen(function* () {
79 receiveAuthorizationUrl = input.onAuthorizationUrl;
80 events.push("process-open");
81 yield* Effect.addFinalizer(() =>
82 Effect.gen(function* () {
83 events.push("process-close");
84 yield* Deferred.succeed(closed, undefined);
85 }),
86 );
87 // Like AcpSessionRuntime, `start` reuses one memoized initialize handshake.
88 const initialize = yield* Effect.cached(
89 Effect.gen(function* () {
90 events.push("initialize");
91 yield* options.beforeInitialize ?? Effect.void;
92 return options.supportsLogout === false
93 ? { ...initialized, agentCapabilities: {} }
94 : initialized;
95 }),
96 );
97 return {
98 initialize: () => initialize,
99 start: () =>
100 Effect.gen(function* () {
101 yield* initialize;
102 events.push("authenticate");
103 yield* Deferred.succeed(authenticateCalled, undefined);
104 if (options.interactive !== false && input.onAuthorizationUrl) {
105 for (const url of options.authorizationUrls ?? [authorizationUrl]) {
106 yield* input.onAuthorizationUrl(url);
107 }
108 }
109 yield* Deferred.await(authenticated);
110 events.push("session-new");
111 yield* Deferred.await(discovered);
112 return started;
113 }),
114 request: (method) =>
115 Effect.sync(() => {
116 events.push(method);
117 return {};
118 }),
119 } satisfies AntigravityAuthRuntime;
120 }),
121 onAuthenticated: () =>
122 Effect.sync(() => {
123 catalog = ["gemini-test"];
124 events.push("catalog-published");
125 }),
126 onSignedOut: Effect.sync(() => {
127 catalog = [];
128 events.push("catalog-cleared");
129 }),
130 forwardCallback: () =>
131 options.forwardCallback ??
132 Effect.sync(() => {
133 forwarded += 1;
134 }),
135 });
136 return {
137 auth,
138 authenticated,
139 discovered,
140 closed,
141 authenticateCalled,
142 events,
143 catalog: () => catalog,
144 forwarded: () => forwarded,
145 receiveAuthorizationUrl: (url: string) =>
146 Effect.suspend(() =>
147 receiveAuthorizationUrl
148 ? receiveAuthorizationUrl(url)
149 : Effect.die("Authorization URL receiver is not ready."),
150 ),
151 };
152});
153​
154it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("AntigravityAuth", (it) => {
155 it.effect("accepts a delayed duplicate after callback completion starts", () =>
156 Effect.gen(function* () {
157 const harness = yield* makeHarness();
158 const state = yield* harness.auth.controller.start(owner);
159 yield* phase(harness.auth, "waiting");
160 yield* harness.auth.controller.complete(owner, {
161 flowId: state.flowId!,
162 callbackUrl,
163 });
164​
165 yield* harness.receiveAuthorizationUrl(authorizationUrl);
166​
167 yield* Deferred.succeed(harness.authenticated, undefined);
168 yield* Deferred.succeed(harness.discovered, undefined);
169 yield* phase(harness.auth, "succeeded");
170 }),
171 );
172​
173 it.effect("rejects a different second authorization URL", () =>
174 Effect.gen(function* () {
175 const harness = yield* makeHarness({
176 authorizationUrls: [authorizationUrl, `${authorizationUrl}&scope=another-request`],
177 });
178 yield* harness.auth.controller.start(owner);
179​
180 const failed = yield* phase(harness.auth, "failed");
181 assert.isNull(failed.authorizationUrl);
182 assert.deepEqual(harness.catalog(), ["previous-account-model"]);
183 }),
184 );
185​
186 it.effect("keeps a remote flow private and waits for native auth and catalog discovery", () =>
187 Effect.gen(function* () {
188 const harness = yield* makeHarness();
189 const state = yield* harness.auth.controller.start(owner);
190 assert.isNotNull(state.flowId);
191 const waiting = yield* phase(harness.auth, "waiting");
192 assert.equal(waiting.authorizationUrl, authorizationUrl);
193​
194 const other = yield* phase(harness.auth, "waiting", otherOwner);
195 assert.isNull(other.authorizationUrl);
196 assert.isNull(other.flowId);
197 const stolen = yield* harness.auth.controller
198 .complete(otherOwner, { flowId: state.flowId!, callbackUrl })
199 .pipe(Effect.exit);
200 assert.isTrue(Exit.isFailure(stolen));
201 assert.equal(harness.forwarded(), 0);
202​
203 const verifying = yield* harness.auth.controller.complete(owner, {
204 flowId: state.flowId!,
205 callbackUrl,
206 });
207 assert.equal(verifying.phase, "verifying");
208 assert.equal(harness.forwarded(), 1);
209 assert.deepEqual(harness.catalog(), ["previous-account-model"]);
210​
211 yield* Deferred.succeed(harness.authenticated, undefined);
212 yield* Deferred.succeed(harness.discovered, undefined);
213 const succeeded = yield* phase(harness.auth, "succeeded");
214 assert.deepEqual(harness.catalog(), ["gemini-test"]);
215 assert.isNull(succeeded.authorizationUrl);
216 assert.isNull(succeeded.expiresAt);
217 assert.equal(harness.events.at(-1), "process-close");
218 }),
219 );
220​
221 it.effect(
222 "distinguishes a post-authentication session failure without exposing its payload",
223 () =>
224 Effect.gen(function* () {
225 const harness = yield* makeHarness();
226 yield* harness.auth.controller.start(owner);
227 yield* phase(harness.auth, "waiting");
228 yield* Deferred.succeed(harness.authenticated, undefined);
229 yield* Deferred.fail(
230 harness.discovered,
231 new AcpErrors.AcpRequestError({
232 code: -32603,
233 errorMessage: `Internal error ${callbackUrl}`,
234 method: "session/new",
235 }),
236 );
237 const failed = yield* phase(harness.auth, "failed");
238 assert.equal(
239 failed.message,
240 "Antigravity authenticated, but could not initialize a session or load models.",
241 );
242 assert.isNull(failed.authorizationUrl);
243 assert.deepEqual(harness.catalog(), ["previous-account-model"]);
244 yield* Deferred.await(harness.closed);
245 }),
246 );
247​
248 it.effect("does not call callback HTTP success a successful Google sign-in", () =>
249 Effect.gen(function* () {
250 const harness = yield* makeHarness();
251 const state = yield* harness.auth.controller.start(owner);
252 yield* phase(harness.auth, "waiting");
253 yield* harness.auth.controller.complete(owner, { flowId: state.flowId!, callbackUrl });
254 yield* Deferred.fail(
255 harness.authenticated,
256 AcpErrors.AcpRequestError.internalError(`access_denied ${callbackUrl}`),
257 );
258 const failed = yield* phase(harness.auth, "failed");
259 assert.include(failed.message ?? "", "not approved");
260 assert.notInclude(failed.message ?? "", "test-code");
261 assert.isNull(failed.authorizationUrl);
262 assert.deepEqual(harness.catalog(), ["previous-account-model"]);
263 yield* Deferred.await(harness.closed);
264 }),
265 );
266​
267 it.effect("tells the user when the agent cannot reach Google", () =>
268 Effect.gen(function* () {
269 const harness = yield* makeHarness({ interactive: false });
270 yield* harness.auth.controller.start(owner);
271 yield* Deferred.await(harness.authenticateCalled);
272​
273 yield* TestClock.adjust("30 seconds");
274 const stalled = yield* harness.auth.controller.subscribe(owner).pipe(
275 Stream.filter((state) => state.message?.includes("Broken IPv6") === true),
276 Stream.runHead,
277 Effect.map(Option.getOrThrow),
278 );
279 assert.equal(stalled.phase, "starting");
280​
281 yield* TestClock.adjust("270 seconds");
282 const failed = yield* phase(harness.auth, "failed");
283 assert.include(failed.message ?? "", "could not reach Google");
284 }),
285 );
286​
287 it.effect("does not blame the network while the runtime is still starting", () =>
288 Effect.gen(function* () {
289 const initializing = yield* Deferred.make<void>();
290 const ready = yield* Deferred.make<void>();
291 const harness = yield* makeHarness({
292 interactive: false,
293 beforeInitialize: Deferred.succeed(initializing, undefined).pipe(
294 Effect.andThen(Deferred.await(ready)),
295 ),
296 });
297 const hasHint = harness.auth.controller.subscribe(owner).pipe(
298 Stream.runHead,
299 Effect.map((state) => Option.getOrThrow(state).message?.includes("Broken IPv6") === true),
300 );
301 yield* harness.auth.controller.start(owner);
302 yield* Deferred.await(initializing);
303​
304 // A one-file runtime can take about a minute to unpack before it answers initialize.
305 yield* TestClock.adjust("60 seconds");
306 assert.isFalse(yield* hasHint);
307​
308 yield* Deferred.succeed(ready, undefined);
309 yield* Deferred.await(harness.authenticateCalled);
310 yield* TestClock.adjust("29 seconds");
311 assert.isFalse(yield* hasHint);
312​
313 yield* TestClock.adjust("1 second");
314 const stalled = yield* harness.auth.controller.subscribe(owner).pipe(
315 Stream.filter((state) => state.message?.includes("Broken IPv6") === true),
316 Stream.runHead,
317 Effect.map(Option.getOrThrow),
318 );
319 assert.equal(stalled.phase, "starting");
320 }),
321 );
322​
323 it.effect("does not blame the network when the runtime never answers initialize", () =>
324 Effect.gen(function* () {
325 const initializing = yield* Deferred.make<void>();
326 const harness = yield* makeHarness({
327 beforeInitialize: Deferred.succeed(initializing, undefined).pipe(
328 Effect.andThen(Effect.never),
329 ),
330 });
331 yield* harness.auth.controller.start(owner);
332 yield* Deferred.await(initializing);
333 const blamesNetwork = /reach(ed)? Google|network|ipv6/i;
334​
335 yield* TestClock.adjust("90 seconds");
336 const stalled = yield* harness.auth.controller.subscribe(owner).pipe(
337 Stream.filter((state) => state.message !== "Starting Google sign-in."),
338 Stream.runHead,
339 Effect.map(Option.getOrThrow),
340 );
341 assert.equal(stalled.phase, "starting");
342 assert.notMatch(stalled.message ?? "", blamesNetwork);
343 assert.match(stalled.message ?? "", /not started|stuck/i);
344​
345 yield* TestClock.adjust("210 seconds");
346 const failed = yield* phase(harness.auth, "failed");
347 assert.notMatch(failed.message ?? "", blamesNetwork);
348 assert.match(failed.message ?? "", /not respond|may not run/i);
349 }),
350 );
351​
352 it.effect("keeps the expired message once the Google link was shown", () =>
353 Effect.gen(function* () {
354 const harness = yield* makeHarness();
355 yield* harness.auth.controller.start(owner);
356 yield* phase(harness.auth, "waiting");
357​
358 yield* TestClock.adjust("300 seconds");
359 const failed = yield* phase(harness.auth, "failed");
360 assert.equal(failed.message, "Google sign-in expired. Start sign-in again.");
361 }),
362 );
363​
364 it.effect("accepts direct local or cached completion without a callback RPC", () =>
365 Effect.gen(function* () {
366 const harness = yield* makeHarness({ interactive: false });
367 yield* harness.auth.controller.start(owner);
368 yield* Deferred.succeed(harness.authenticated, undefined);
369 yield* Deferred.succeed(harness.discovered, undefined);
370 yield* phase(harness.auth, "succeeded");
371 assert.equal(harness.forwarded(), 0);
372 assert.deepEqual(harness.catalog(), ["gemini-test"]);
373 }),
374 );
375​
376 it.effect("rejects mismatched callbacks without sending any HTTP request", () =>
377 Effect.gen(function* () {
378 const harness = yield* makeHarness();
379 const state = yield* harness.auth.controller.start(owner);
380 yield* phase(harness.auth, "waiting");
381 for (const invalidUrl of [
382 callbackUrl.replace("51234", "51235"),
383 callbackUrl.replace("test-state", "wrong-state"),
384 callbackUrl.replace("/?", "/other?"),
385 `${callbackUrl}&state=test-state`,
386 ]) {
387 const result = yield* harness.auth.controller
388 .complete(owner, { flowId: state.flowId!, callbackUrl: invalidUrl })
389 .pipe(Effect.exit);
390 assert.isTrue(Exit.isFailure(result));
391 }
392 assert.equal(harness.forwarded(), 0);
393 assert.equal((yield* phase(harness.auth, "waiting")).authorizationUrl, authorizationUrl);
394 yield* harness.auth.controller.cancel(owner, state.flowId!);
395 }),
396 );
397​
398 it.effect("fails the flow when delivery fails after the requesting client disconnects", () =>
399 Effect.gen(function* () {
400 const deliveryGate = yield* Deferred.make<void>();
401 const harness = yield* makeHarness({
402 forwardCallback: Deferred.await(deliveryGate).pipe(
403 Effect.andThen(
404 Effect.fail(
405 new ProviderSetupError({
406 instanceId,
407 operation: "complete",
408 detail: "loopback refused",
409 }),
410 ),
411 ),
412 ),
413 });
414 const state = yield* harness.auth.controller.start(owner);
415 yield* phase(harness.auth, "waiting");
416 const request = yield* harness.auth.controller
417 .complete(owner, { flowId: state.flowId!, callbackUrl })
418 .pipe(Effect.forkScoped);
419 yield* phase(harness.auth, "verifying");
420 yield* Fiber.interrupt(request);
421 yield* Deferred.succeed(deliveryGate, undefined);
422 const failed = yield* phase(harness.auth, "failed");
423 assert.include(failed.message ?? "", "Could not deliver");
424 assert.isTrue(harness.events.includes("process-close"));
425 }),
426 );
427​
428 it.effect("cancel closes the owned process without forwarding a denial", () =>
429 Effect.gen(function* () {
430 const harness = yield* makeHarness();
431 const state = yield* harness.auth.controller.start(owner);
432 yield* phase(harness.auth, "waiting");
433 const wrongOwner = yield* harness.auth.controller
434 .cancel(otherOwner, state.flowId!)
435 .pipe(Effect.exit);
436 assert.isTrue(Exit.isFailure(wrongOwner));
437 const cancelled = yield* harness.auth.controller.cancel(owner, state.flowId!);
438 assert.equal(cancelled.phase, "cancelled");
439 assert.isNull(cancelled.authorizationUrl);
440 assert.equal(harness.forwarded(), 0);
441 yield* Deferred.await(harness.closed);
442 assert.deepEqual(harness.catalog(), ["previous-account-model"]);
443 }),
444 );
445​
446 it.effect("survives subscriber disconnect and does not replace a competing client's flow", () =>
447 Effect.gen(function* () {
448 const harness = yield* makeHarness();
449 const first = yield* harness.auth.controller.start(owner);
450 yield* phase(harness.auth, "waiting");
451 const second = yield* harness.auth.controller.start(owner);
452 assert.equal(first.flowId, second.flowId);
453 const competing = yield* harness.auth.controller.start(otherOwner).pipe(Effect.exit);
454 assert.isTrue(Exit.isFailure(competing));
455 const resumed = yield* phase(harness.auth, "waiting");
456 assert.equal(resumed.flowId, first.flowId);
457 assert.deepEqual(harness.events, ["process-open", "initialize", "authenticate"]);
458 yield* harness.auth.controller.cancel(owner, first.flowId!);
459 }),
460 );
461​
462 it.effect("sign-out closes admission and every process before fresh native logout", () =>
463 Effect.gen(function* () {
464 const harness = yield* makeHarness();
465 const processScope = yield* Scope.make();
466 yield* Effect.addFinalizer(() => Scope.close(processScope, Exit.void));
467 const stop = Effect.gen(function* () {
468 harness.events.push("chat-close");
469 yield* Scope.close(processScope, Exit.void);
470 });
471 yield* harness.auth.withProcess(stop, Effect.void).pipe(Scope.provide(processScope));
472 const stopSessions = Effect.gen(function* () {
473 harness.events.push("sessions-stop");
474 const denied = yield* harness.auth
475 .withProcess(
476 Effect.void,
477 Effect.sync(() => harness.events.push("late-process")),
478 )
479 .pipe(Effect.scoped, Effect.exit);
480 assert.isTrue(Exit.isFailure(denied));
481 });
482 const result = yield* harness.auth.controller.logout(stopSessions);
483 assert.equal(result.phase, "idle");
484 assert.deepEqual(harness.events, [
485 "sessions-stop",
486 "chat-close",
487 "process-open",
488 "initialize",
489 "logout",
490 "catalog-cleared",
491 "process-close",
492 ]);
493 assert.deepEqual(harness.catalog(), []);
494 yield* harness.auth.withProcess(Effect.void, Effect.void).pipe(Effect.scoped);
495 }),
496 );
497​
498 it.effect(
499 "sign-out interrupts startup without interrupting its caller after startup returns",
500 () =>
501 Effect.gen(function* () {
502 const harness = yield* makeHarness();
503 const entering = yield* Deferred.make<void>();
504 const continueStartup = yield* Deferred.make<void>();
505 const processScope = yield* Scope.make();
506 yield* Effect.addFinalizer(() => Scope.close(processScope, Exit.void));
507 const task = Effect.gen(function* () {
508 yield* Deferred.succeed(entering, undefined);
509 yield* Deferred.await(continueStartup);
510 harness.events.push("late-spawn");
511 });
512 const startup = yield* harness.auth
513 .withProcess(Scope.close(processScope, Exit.void), task)
514 .pipe(Scope.provide(processScope), Effect.forkScoped);
515 yield* Deferred.await(entering);
516 yield* harness.auth.controller.logout(Effect.void);
517 yield* Deferred.succeed(continueStartup, undefined);
518 assert.isTrue(Exit.isFailure(yield* Fiber.await(startup)));
519 assert.notInclude(harness.events, "late-spawn");
520 assert.include(harness.events, "logout");
521 }),
522 );
523​
524 it.effect("failed session stopping still closes owned processes and skips native logout", () =>
525 Effect.gen(function* () {
526 const harness = yield* makeHarness();
527 const processScope = yield* Scope.make();
528 yield* Effect.addFinalizer(() => Scope.close(processScope, Exit.void));
529 const stop = Effect.gen(function* () {
530 harness.events.push("chat-close");
531 yield* Scope.close(processScope, Exit.void);
532 });
533 yield* harness.auth.withProcess(stop, Effect.void).pipe(Scope.provide(processScope));
534 const result = yield* harness.auth.controller
535 .logout(
536 Effect.fail(
537 new ProviderSetupError({
538 instanceId,
539 operation: "stopSessions",
540 detail: "Stop failed.",
541 }),
542 ),
543 )
544 .pipe(Effect.exit);
545 assert.isTrue(Exit.isFailure(result));
546 assert.deepEqual(harness.events, ["chat-close"]);
547 assert.deepEqual(harness.catalog(), ["previous-account-model"]);
548 }),
549 );
550​
551 it.effect("finishes sign-out when the requesting client disconnects", () =>
552 Effect.gen(function* () {
553 const harness = yield* makeHarness();
554 const stopping = yield* Deferred.make<void>();
555 const continueStop = yield* Deferred.make<void>();
556 const request = yield* harness.auth.controller
557 .logout(
558 Effect.gen(function* () {
559 yield* Deferred.succeed(stopping, undefined);
560 yield* Deferred.await(continueStop);
561 }),
562 )
563 .pipe(Effect.forkScoped);
564 yield* Deferred.await(stopping);
565 yield* Fiber.interrupt(request);
566 yield* Deferred.succeed(continueStop, undefined);
567 const result = yield* harness.auth.controller.subscribe(owner).pipe(
568 Stream.filter((state) => state.message === "Signed out of Google."),
569 Stream.runHead,
570 Effect.map(Option.getOrThrow),
571 );
572 assert.equal(result.phase, "idle");
573 assert.deepEqual(harness.catalog(), []);
574 assert.include(harness.events, "logout");
575 }),
576 );
577​
578 it.effect("does not call logout unless the official process advertises it", () =>
579 Effect.gen(function* () {
580 const harness = yield* makeHarness({ supportsLogout: false });
581 const result = yield* harness.auth.controller.logout(Effect.void).pipe(Effect.exit);
582 assert.isTrue(Exit.isFailure(result));
583 assert.deepEqual(harness.events, ["process-open", "initialize", "process-close"]);
584 assert.deepEqual(harness.catalog(), ["previous-account-model"]);
585 }),
586 );
587});