1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as Core from "@/Test/Core";
4import * as firebaseappcheck from "@distilled.cloud/gcp/firebaseappcheck_v1";
5import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
6import { describe, expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import { dockerAvailable, expectProbe } from "../bindingHost.ts";
9import { currentProject, runLifecycle } from "./common.ts";
10import AppCheckBindingsHost, { Local } from "./fixtures/bindings-host.ts";
11
12const testOptions = { providers: GCP.providers() };
13const { test, beforeAll, afterAll } = Test.make(testOptions);
14const sharedStack = Core.scratchStack(testOptions, "AppCheckBindings");
15
16let baseUrl: string;
17let hostAccount: string;
18let debugTokenName: string;
19let appName: string;
20
21const jwtClaims = (token: string) =>
22 JSON.parse(
23 Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"),
24 ) as { sub?: string; aud?: string[] };
25
26// App Check needs a Firebase project with the App Check API enabled (see
27// ./common.ts); set GCP_TEST_FIREBASE_APP_ID to run.
28describe.skipIf(!dockerAvailable || !runLifecycle)(
29 "FirebaseAppCheck Bindings",
30 {
31 tags: [
32 "provider:gcp",
33 "provider:gcp:firebaseappcheck",
34 "provider:gcp:run",
35 "live",
36 ],
37 },
38 () => {
39 beforeAll(
40 Effect.gen(function* () {
41 yield* sharedStack.destroy();
42 const out = yield* sharedStack.deploy(
43 Effect.gen(function* () {
44 const host = yield* AppCheckBindingsHost;
45 const debug = yield* Local;
46 return {
47 uri: host.uri,
48 serviceAccount: host.serviceAccount,
49 debugToken: debug.name,
50 app: debug.app,
51 };
52 }),
53 );
54 baseUrl = out.uri!;
55 hostAccount = out.serviceAccount!;
56 debugTokenName = out.debugToken;
57 appName = out.app;
58 }),
59 { timeout: 900_000 },
60 );
61
62 afterAll(sharedStack.destroy(), { timeout: 600_000 });
63
64 describe("ExchangeDebugToken", () => {
65 test.provider(
66 "mints an App Check token for the app, with no IAM grant",
67 (_stack) =>
68 Effect.gen(function* () {
69 const out =
70 yield* expectProbe<firebaseappcheck.GoogleFirebaseAppcheckV1AppCheckToken>(
71 baseUrl,
72 "exchangeDebugToken",
73 );
74 expect(out.ttl).toEqual(expect.any(String));
75 const claims = jwtClaims(out.token ?? "");
76 // `sub` is the app id: the last segment of `projects/p/apps/{id}`.
77 expect(claims.sub).toEqual(appName.split("/").pop());
78
79 // The debug token the exchange used is still registered.
80 const debug = yield* firebaseappcheck.getProjectsAppsDebugTokens({
81 name: debugTokenName,
82 });
83 expect(debug.displayName).toEqual("alchemy-exchange");
84
85 // exchangeDebugToken is not IAM-gated: the binding grants nothing.
86 const project = yield* currentProject;
87 const policy = yield* resourcemanager.getIamPolicyProjects({
88 resource: `projects/${project}`,
89 body: { options: { requestedPolicyVersion: 3 } },
90 });
91 const roles = (policy.bindings ?? []).filter((binding) =>
92 (binding.members ?? []).includes(`serviceAccount:${hostAccount}`),
93 );
94 expect(roles).toEqual([]);
95 }),
96 {
97 tags: ["provider:gcp", "provider:gcp:firebaseappcheck", "live"],
98 timeout: 600_000,
99 },
100 );
101 });
102 },
103);