1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as Core from "@/Test/Core";
4import * as cloudfunctions from "@distilled.cloud/gcp/cloudfunctions_v2";
5import * as crm from "@distilled.cloud/gcp/cloudresourcemanager_v3";
6import { describe, expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as HttpClient from "effect/http/HttpClient";
9import { dockerAvailable, expectProbe } from "../bindingHost.ts";
10import CloudFunctionsBindingsHost from "./fixtures/bindings-host.ts";
11import TargetFunction from "./fixtures/target-function.ts";
12
13const testOptions = { providers: GCP.providers() };
14const { test, beforeAll, afterAll } = Test.make(testOptions);
15const sharedStack = Core.scratchStack(testOptions, "CloudFunctionsBindings");
16
17let baseUrl: string;
18let hostAccount: string;
19let functionName: string;
20let project: string;
21
22/**
23 * 2nd gen functions accept only invoker roles on their own IAM policy, so
24 * both bindings grant on the project under an IAM Condition naming only the
25 * function: GetFunction `roles/cloudfunctions.viewer`, GenerateDownloadUrl
26 * `roles/cloudfunctions.developer` (narrowest role with
27 * `functions.sourceCodeGet`).
28 */
29const expectFunctionGrants = Effect.gen(function* () {
30 const policy = yield* crm.getIamPolicyProjects({
31 resource: `projects/${project}`,
32 body: { options: { requestedPolicyVersion: 3 } },
33 });
34 const condition = `resource.name == "${functionName}" || resource.name.startsWith("${functionName}/")`;
35 const grants = (policy.bindings ?? [])
36 .filter((binding) =>
37 (binding.members ?? []).includes(`serviceAccount:${hostAccount}`),
38 )
39 .map((binding) => ({
40 role: binding.role,
41 condition: binding.condition?.expression,
42 }))
43 .sort((a, b) => (a.role ?? "").localeCompare(b.role ?? ""));
44 expect(grants).toEqual([
45 { role: "roles/cloudfunctions.developer", condition },
46 { role: "roles/cloudfunctions.viewer", condition },
47 ]);
48});
49
50// The target function's gen2 build takes 2-4 minutes.
51describe.skipIf(!dockerAvailable || !!process.env.FAST)(
52 "CloudFunctions Bindings",
53 {
54 tags: [
55 "provider:gcp",
56 "provider:gcp:cloudfunctions",
57 "provider:gcp:run",
58 "live",
59 ],
60 },
61 () => {
62 beforeAll(
63 Effect.gen(function* () {
64 yield* sharedStack.destroy();
65 const out = yield* sharedStack.deploy(
66 Effect.gen(function* () {
67 const host = yield* CloudFunctionsBindingsHost;
68 const fn = yield* TargetFunction;
69 return {
70 uri: host.uri,
71 serviceAccount: host.serviceAccount,
72 name: fn.name,
73 project: fn.project,
74 };
75 }),
76 );
77 baseUrl = out.uri!;
78 hostAccount = out.serviceAccount!;
79 functionName = out.name;
80 project = out.project;
81 }),
82 { timeout: 1_200_000 },
83 );
84
85 afterAll(sharedStack.destroy(), { timeout: 600_000 });
86
87 describe("GetFunction", () => {
88 test.provider(
89 "reads the function as the host's service account, scoped to the function by condition",
90 (_stack) =>
91 Effect.gen(function* () {
92 const live =
93 yield* expectProbe<cloudfunctions.Cloudfunctions_Function>(
94 baseUrl,
95 "getFunction",
96 );
97 expect(live.name).toEqual(functionName);
98 expect(live.state).toEqual("ACTIVE");
99 expect(live.buildConfig?.entryPoint).toEqual("handler");
100 yield* expectFunctionGrants;
101 }),
102 {
103 tags: ["provider:gcp", "provider:gcp:cloudfunctions", "live"],
104 timeout: 600_000,
105 },
106 );
107 });
108
109 describe("GenerateDownloadUrl", () => {
110 test.provider(
111 "signs a source download URL as the host's service account, scoped to the function by condition",
112 (_stack) =>
113 Effect.gen(function* () {
114 const { downloadUrl } =
115 yield* expectProbe<cloudfunctions.GenerateDownloadUrlResponse>(
116 baseUrl,
117 "generateDownloadUrl",
118 );
119 // The signed URL serves the function's source archive (a zip).
120 const response = yield* HttpClient.get(downloadUrl ?? "");
121 expect(response.status).toEqual(200);
122 const bytes = new Uint8Array(yield* response.arrayBuffer);
123 expect(String.fromCharCode(bytes[0] ?? 0, bytes[1] ?? 0)).toEqual(
124 "PK",
125 );
126 yield* expectFunctionGrants;
127 }),
128 {
129 tags: ["provider:gcp", "provider:gcp:cloudfunctions", "live"],
130 timeout: 600_000,
131 },
132 );
133 });
134 },
135);