1import * as Detective from "@/AWS/Detective";
2import * as Lambda from "@/AWS/Lambda";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { HttpServerRequest } from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import path from "pathe";
9
10const main = path.resolve(import.meta.dirname, "handler.ts");
11
12export class DetectiveTestFunction extends Lambda.Function<Lambda.Function>()(
13 "DetectiveTestFunction",
14) {}
15
16/**
17 * Routes answer `{ …fields }` on success or `{ errorTag }` when the
18 * operation fails with a TYPED error — the test asserts the tag is in a
19 * route-specific allowlist, which proves both the binding wiring and the
20 * IAM grant. An untyped error crashes into a 500 instead.
21 */
22const errorTagged = <A, E extends { _tag: string }, R>(
23 effect: Effect.Effect<A, E, R>,
24): Effect.Effect<A | { errorTag: string; errorMessage?: string }, never, R> =>
25 effect.pipe(
26 Effect.map((a): A | { errorTag: string; errorMessage?: string } => a),
27 Effect.catch((e) =>
28 Effect.succeed({
29 errorTag: e._tag,
30 errorMessage:
31 (e as { Message?: string }).Message ??
32 (e as { message?: string }).message,
33 }),
34 ),
35 );
36
37// One binding per Detective capability. Write/member/org-admin operations
38// with irreversible or cross-account side effects (UpdateDatasourcePackages
39// enables billed ingest; CreateMembers invites foreign accounts;
40// Enable/DisableOrganizationAdminAccount require the org management
41// account) are bound — proving init + IAM deploy — but not exercised
42// against live state.
43export default DetectiveTestFunction.make(
44 {
45 main,
46 functionUrl: true,
47 timeout: Duration.seconds(30),
48 },
49 Effect.gen(function* () {
50 // The behavior graph every graph-scoped binding is bound to. Detective
51 // allows one graph per account/region — the test account keeps none
52 // deployed between runs (Graph.test.ts is capture-and-restore safe).
53 const graph = yield* Detective.Graph("BindingsGraph", {
54 tags: { fixture: "detective-bindings" },
55 });
56
57 // Investigations
58 const startInvestigation = yield* Detective.StartInvestigation(graph);
59 const getInvestigation = yield* Detective.GetInvestigation(graph);
60 const listInvestigations = yield* Detective.ListInvestigations(graph);
61 const listIndicators = yield* Detective.ListIndicators(graph);
62 const updateInvestigationState =
63 yield* Detective.UpdateInvestigationState(graph);
64
65 // Data source packages
66 const listDatasourcePackages =
67 yield* Detective.ListDatasourcePackages(graph);
68 const updateDatasourcePackages =
69 yield* Detective.UpdateDatasourcePackages(graph);
70 const batchGetGraphMemberDatasources =
71 yield* Detective.BatchGetGraphMemberDatasources(graph);
72
73 // Member administration
74 const listMembers = yield* Detective.ListMembers(graph);
75 const getMembers = yield* Detective.GetMembers(graph);
76 const createMembers = yield* Detective.CreateMembers(graph);
77 const deleteMembers = yield* Detective.DeleteMembers(graph);
78 const startMonitoringMember = yield* Detective.StartMonitoringMember(graph);
79
80 // Member-account invitation flow (account-level)
81 const listInvitations = yield* Detective.ListInvitations();
82 const acceptInvitation = yield* Detective.AcceptInvitation();
83 const rejectInvitation = yield* Detective.RejectInvitation();
84 const disassociateMembership = yield* Detective.DisassociateMembership();
85 const batchGetMembershipDatasources =
86 yield* Detective.BatchGetMembershipDatasources();
87
88 // Organization administration
89 const describeOrganizationConfiguration =
90 yield* Detective.DescribeOrganizationConfiguration(graph);
91 const updateOrganizationConfiguration =
92 yield* Detective.UpdateOrganizationConfiguration(graph);
93 const listOrganizationAdminAccounts =
94 yield* Detective.ListOrganizationAdminAccounts();
95 const enableOrganizationAdminAccount =
96 yield* Detective.EnableOrganizationAdminAccount();
97 const disableOrganizationAdminAccount =
98 yield* Detective.DisableOrganizationAdminAccount();
99
100 const bound = {
101 startInvestigation,
102 getInvestigation,
103 listInvestigations,
104 listIndicators,
105 updateInvestigationState,
106 listDatasourcePackages,
107 updateDatasourcePackages,
108 batchGetGraphMemberDatasources,
109 listMembers,
110 getMembers,
111 createMembers,
112 deleteMembers,
113 startMonitoringMember,
114 listInvitations,
115 acceptInvitation,
116 rejectInvitation,
117 disassociateMembership,
118 batchGetMembershipDatasources,
119 describeOrganizationConfiguration,
120 updateOrganizationConfiguration,
121 listOrganizationAdminAccounts,
122 enableOrganizationAdminAccount,
123 disableOrganizationAdminAccount,
124 };
125
126 return {
127 fetch: Effect.gen(function* () {
128 const request = yield* HttpServerRequest;
129 const url = new URL(request.originalUrl);
130 const pathname = url.pathname;
131
132 if (request.method === "GET" && pathname === "/bindings") {
133 return yield* HttpServerResponse.json({
134 bound: Object.keys(bound),
135 });
136 }
137
138 // Graph-scoped reads — the GraphArn is injected from the binding.
139 if (request.method === "GET" && pathname === "/members") {
140 const { MemberDetails } = yield* listMembers();
141 return yield* HttpServerResponse.json({
142 count: (MemberDetails ?? []).length,
143 });
144 }
145
146 if (request.method === "GET" && pathname === "/member-status") {
147 // A well-formed account id that is not a member: Detective
148 // answers via UnprocessedAccounts (no side effects).
149 const { MemberDetails, UnprocessedAccounts } = yield* getMembers({
150 AccountIds: [url.searchParams.get("account") ?? "123456789012"],
151 });
152 return yield* HttpServerResponse.json({
153 members: (MemberDetails ?? []).length,
154 unprocessed: (UnprocessedAccounts ?? []).length,
155 });
156 }
157
158 if (request.method === "GET" && pathname === "/datasources") {
159 const { DatasourcePackages } = yield* listDatasourcePackages();
160 return yield* HttpServerResponse.json({
161 packages: Object.keys(DatasourcePackages ?? {}),
162 });
163 }
164
165 if (
166 request.method === "GET" &&
167 pathname === "/graph-member-datasources"
168 ) {
169 const result = yield* errorTagged(
170 batchGetGraphMemberDatasources({
171 AccountIds: [url.searchParams.get("account") ?? "123456789012"],
172 }).pipe(
173 Effect.map((r) => ({
174 memberDatasources: (r.MemberDatasources ?? []).length,
175 unprocessed: (r.UnprocessedAccounts ?? []).length,
176 })),
177 ),
178 );
179 return yield* HttpServerResponse.json(result);
180 }
181
182 if (request.method === "GET" && pathname === "/investigations") {
183 const { InvestigationDetails } = yield* listInvestigations();
184 return yield* HttpServerResponse.json({
185 count: (InvestigationDetails ?? []).length,
186 });
187 }
188
189 // Investigation write: a brand-new graph has no ingested data yet,
190 // so Detective typically rejects the entity with a typed
191 // ValidationException — either outcome proves the binding.
192 if (request.method === "POST" && pathname === "/investigate") {
193 const entity = url.searchParams.get("entity");
194 if (!entity) {
195 return yield* HttpServerResponse.json(
196 { error: "missing entity" },
197 { status: 400 },
198 );
199 }
200 const result = yield* errorTagged(
201 startInvestigation({
202 EntityArn: entity,
203 ScopeStartTime: new Date(Date.now() - 24 * 60 * 60 * 1000),
204 ScopeEndTime: new Date(),
205 }).pipe(
206 Effect.map((r) => ({ investigationId: r.InvestigationId })),
207 ),
208 );
209 return yield* HttpServerResponse.json(result);
210 }
211
212 // Account-level reads (member-account side of the handshake).
213 if (request.method === "GET" && pathname === "/invitations") {
214 const { Invitations } = yield* listInvitations();
215 return yield* HttpServerResponse.json({
216 count: (Invitations ?? []).length,
217 });
218 }
219
220 if (
221 request.method === "GET" &&
222 pathname === "/membership-datasources"
223 ) {
224 const graphArn = url.searchParams.get("graphArn");
225 if (!graphArn) {
226 return yield* HttpServerResponse.json(
227 { error: "missing graphArn" },
228 { status: 400 },
229 );
230 }
231 const result = yield* errorTagged(
232 batchGetMembershipDatasources({ GraphArns: [graphArn] }).pipe(
233 Effect.map((r) => ({
234 membershipDatasources: (r.MembershipDatasources ?? []).length,
235 unprocessedGraphs: (r.UnprocessedGraphs ?? []).length,
236 })),
237 ),
238 );
239 return yield* HttpServerResponse.json(result);
240 }
241
242 // Org administration: this account is a standalone (non-delegated)
243 // account, so Detective answers with a typed error — asserting the
244 // tag proves the binding + IAM wiring end-to-end.
245 if (request.method === "GET" && pathname === "/org-config") {
246 const result = yield* errorTagged(
247 describeOrganizationConfiguration().pipe(
248 Effect.map((r) => ({ autoEnable: r.AutoEnable ?? false })),
249 ),
250 );
251 return yield* HttpServerResponse.json(result);
252 }
253
254 if (request.method === "GET" && pathname === "/org-admins") {
255 const result = yield* errorTagged(
256 listOrganizationAdminAccounts().pipe(
257 Effect.map((r) => ({
258 administrators: (r.Administrators ?? []).length,
259 })),
260 ),
261 );
262 return yield* HttpServerResponse.json(result);
263 }
264
265 return yield* HttpServerResponse.json(
266 { error: "Not found", method: request.method, pathname },
267 { status: 404 },
268 );
269 }).pipe(Effect.orDie),
270 };
271 }).pipe(
272 Effect.provide(
273 Layer.mergeAll(
274 Detective.AcceptInvitationHttp,
275 Detective.BatchGetGraphMemberDatasourcesHttp,
276 Detective.BatchGetMembershipDatasourcesHttp,
277 Detective.CreateMembersHttp,
278 Detective.DeleteMembersHttp,
279 Detective.DescribeOrganizationConfigurationHttp,
280 Detective.DisableOrganizationAdminAccountHttp,
281 Detective.DisassociateMembershipHttp,
282 Detective.EnableOrganizationAdminAccountHttp,
283 Detective.GetInvestigationHttp,
284 Detective.GetMembersHttp,
285 Detective.ListDatasourcePackagesHttp,
286 Detective.ListIndicatorsHttp,
287 Detective.ListInvestigationsHttp,
288 Detective.ListInvitationsHttp,
289 Detective.ListMembersHttp,
290 Detective.ListOrganizationAdminAccountsHttp,
291 Detective.RejectInvitationHttp,
292 Detective.StartInvestigationHttp,
293 Detective.StartMonitoringMemberHttp,
294 Detective.UpdateDatasourcePackagesHttp,
295 Detective.UpdateInvestigationStateHttp,
296 Detective.UpdateOrganizationConfigurationHttp,
297 ),
298 ),
299 ),
300);