1import * as GCP from "@/GCP";
2import { GcpEnvironment } from "@/GCP/Environment";
3import * as Test from "@/Test/Alchemy";
4import * as Core from "@/Test/Core";
5import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
6import * as firebasedataconnect from "@distilled.cloud/gcp/firebasedataconnect_v1";
7import { describe, expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import {
10 callProbe,
11 dockerAvailable,
12 type ProbeOutcome,
13} from "../bindingHost.ts";
14import DataConnectBindingsHost, {
15 App,
16 Queries,
17} from "./fixtures/bindings-host.ts";
18
19const testOptions = { providers: GCP.providers() };
20const { test, beforeAll, afterAll } = Test.make(testOptions);
21const sharedStack = Core.scratchStack(testOptions, "DataConnectBindings");
22
23let baseUrl: string;
24let serviceName: string;
25let connectorName: string;
26let hostAccount: string;
27
28/**
29 * Roles the host's service account holds on the project. Data Connect has
30 * no resource-level IAM, so every binding grants on the project.
31 */
32const hostProjectRoles = Effect.gen(function* () {
33 const { project } = yield* GcpEnvironment.current;
34 const policy = yield* resourcemanager.getIamPolicyProjects({
35 resource: `projects/${project}`,
36 body: { options: { requestedPolicyVersion: 3 } },
37 });
38 return (policy.bindings ?? [])
39 .filter((binding) =>
40 (binding.members ?? []).includes(`serviceAccount:${hostAccount}`),
41 )
42 .map((binding) => ({
43 role: binding.role,
44 condition: binding.condition?.expression,
45 }))
46 .sort((a, b) => (a.role ?? "").localeCompare(b.role ?? ""));
47});
48
49const expectedRoles = [
50 { role: "roles/firebasedataconnect.dataAdmin", condition: undefined },
51 { role: "roles/firebasedataconnect.dataViewer", condition: undefined },
52];
53
54/** Tag of a direct (deployer-credential) call, for comparison. */
55const tagOf = <A, E extends { _tag: string }, R>(
56 effect: Effect.Effect<A, E, R>,
57) =>
58 effect.pipe(
59 Effect.map(() => "ok"),
60 Effect.catch((error) => Effect.succeed(error._tag)),
61 );
62
63/**
64 * The test service's datasource is unlinked (no Cloud SQL), so data-plane
65 * calls that pass IAM reach the service and are rejected with
66 * `BadRequest: MISSING_DATASOURCE`. A missing grant surfaces as
67 * `Forbidden`/`PermissionDenied` (which `callProbe` retries until it
68 * propagates, then returns).
69 */
70const expectAuthorized = (outcome: ProbeOutcome<unknown>) => {
71 if (outcome.ok) return "ok";
72 expect(outcome.error._tag).toEqual("BadRequest");
73 expect(outcome.error.message ?? "").toContain("MISSING_DATASOURCE");
74 return outcome.error._tag;
75};
76
77describe.skipIf(!dockerAvailable)(
78 "FirebaseDataConnect Bindings",
79 {
80 tags: [
81 "provider:gcp",
82 "provider:gcp:firebasedataconnect",
83 "provider:gcp:run",
84 "live",
85 ],
86 },
87 () => {
88 beforeAll(
89 Effect.gen(function* () {
90 yield* sharedStack.destroy();
91 const out = yield* sharedStack.deploy(
92 Effect.gen(function* () {
93 const host = yield* DataConnectBindingsHost;
94 const service = yield* App;
95 const connector = yield* Queries;
96 return {
97 uri: host.uri,
98 serviceAccount: host.serviceAccount,
99 service: service.name,
100 connector: connector.name,
101 };
102 }),
103 );
104 baseUrl = out.uri!;
105 hostAccount = out.serviceAccount!;
106 serviceName = out.service;
107 connectorName = out.connector;
108 }),
109 { timeout: 900_000 },
110 );
111
112 afterAll(sharedStack.destroy(), { timeout: 600_000 });
113
114 describe("ExecuteGraphql", () => {
115 test.provider(
116 "executes GraphQL on the service as the host's service account",
117 (_stack) =>
118 Effect.gen(function* () {
119 const outcome = yield* callProbe(baseUrl, "executeGraphql");
120 const tag = expectAuthorized(outcome);
121 const direct = yield* tagOf(
122 firebasedataconnect.executeGraphqlProjectsLocationsServices({
123 name: serviceName,
124 body: { query: "{ __typename }" },
125 }),
126 );
127 expect(tag).toEqual(direct);
128 expect(yield* hostProjectRoles).toEqual(expectedRoles);
129 }),
130 {
131 tags: ["provider:gcp", "provider:gcp:firebasedataconnect", "live"],
132 timeout: 600_000,
133 },
134 );
135 });
136
137 describe("ExecuteGraphqlRead", () => {
138 test.provider(
139 "executes read-only GraphQL on the service as the host's service account",
140 (_stack) =>
141 Effect.gen(function* () {
142 const outcome = yield* callProbe(baseUrl, "executeGraphqlRead");
143 const tag = expectAuthorized(outcome);
144 const direct = yield* tagOf(
145 firebasedataconnect.executeGraphqlReadProjectsLocationsServices({
146 name: serviceName,
147 body: { query: "{ __typename }" },
148 }),
149 );
150 expect(tag).toEqual(direct);
151 expect(yield* hostProjectRoles).toEqual(expectedRoles);
152 }),
153 {
154 tags: ["provider:gcp", "provider:gcp:firebasedataconnect", "live"],
155 timeout: 600_000,
156 },
157 );
158 });
159
160 describe("ExecuteQuery", () => {
161 test.provider(
162 "executes the connector's query as the host's service account",
163 (_stack) =>
164 Effect.gen(function* () {
165 const outcome = yield* callProbe(baseUrl, "executeQuery");
166 const tag = expectAuthorized(outcome);
167 const direct = yield* tagOf(
168 firebasedataconnect.executeQueryProjectsLocationsServicesConnectors(
169 {
170 name: connectorName,
171 body: { operationName: "ListAlchemyNotes" },
172 },
173 ),
174 );
175 expect(tag).toEqual(direct);
176 expect(yield* hostProjectRoles).toEqual(expectedRoles);
177 }),
178 {
179 tags: ["provider:gcp", "provider:gcp:firebasedataconnect", "live"],
180 timeout: 600_000,
181 },
182 );
183 });
184
185 describe("ExecuteMutation", () => {
186 test.provider(
187 "executes the connector's mutation as the host's service account",
188 (_stack) =>
189 Effect.gen(function* () {
190 const outcome = yield* callProbe(baseUrl, "executeMutation");
191 const tag = expectAuthorized(outcome);
192 const direct = yield* tagOf(
193 firebasedataconnect.executeMutationProjectsLocationsServicesConnectors(
194 {
195 name: connectorName,
196 body: {
197 operationName: "CreateAlchemyNote",
198 variables: { title: "hello" },
199 },
200 },
201 ),
202 );
203 expect(tag).toEqual(direct);
204 expect(yield* hostProjectRoles).toEqual(expectedRoles);
205 }),
206 {
207 tags: ["provider:gcp", "provider:gcp:firebasedataconnect", "live"],
208 timeout: 600_000,
209 },
210 );
211 });
212 },
213);