| 1 | import * as Detective from "@/AWS/Detective"; |
| 2 | import * as Lambda from "@/AWS/Lambda"; |
| 3 | import * as Duration from "effect/Duration"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 7 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 8 | import path from "pathe"; |
| 9 | |
| 10 | const main = path.resolve(import.meta.dirname, "handler.ts"); |
| 11 | |
| 12 | export 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 | */ |
| 22 | const 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. |
| 43 | export 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 | ); |