1import * as AWS from "@/AWS";
2import { Hub } from "@/AWS/SecurityHub/Hub.ts";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import * as securityhub from "@distilled.cloud/aws/securityhub";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import { makeSecurityHubTestLease } from "./TestLease.ts";
9
10const { test, beforeAll, afterAll } = Test.make({ providers: AWS.providers() });
11const testLease = makeSecurityHubTestLease();
12
13beforeAll(testLease.acquire, { timeout: 240_000 });
14afterAll(testLease.release);
15
16// `describeHub` throws `InvalidAccessException` when the account is not
17// subscribed to Security Hub.
18const describeHub = securityhub.describeHub({}).pipe(
19 Effect.map((hub) => hub as securityhub.DescribeHubResponse | undefined),
20 Effect.catchTag("InvalidAccessException", () => Effect.succeed(undefined)),
21 Effect.catchTag("ResourceNotFoundException", () => Effect.succeed(undefined)),
22);
23
24// The Security Hub Hub is an account/region singleton. This test only runs when
25// the account is not already subscribed — it must never disable a Hub the user
26// already operates (capture-and-restore safety).
27test.provider(
28 "lifecycle: enable Security Hub, update config, disable",
29 (stack) =>
30 Effect.gen(function* () {
31 // Destroy OUR previous resources first — a crashed prior run leaves the
32 // test's own Hub enabled, which must not be mistaken for a foreign one.
33 yield* stack.destroy();
34
35 const preexisting = yield* describeHub;
36 if (preexisting) {
37 yield* Effect.logInfo(
38 `Security Hub already enabled (${preexisting.HubArn}) — skipping destructive lifecycle test`,
39 );
40 return;
41 }
42
43 // Create — enable without default standards to keep the test cheap/fast.
44 const created = yield* stack.deploy(
45 Effect.gen(function* () {
46 return yield* Hub("Hub", {
47 enableDefaultStandards: false,
48 controlFindingGenerator: "SECURITY_CONTROL",
49 tags: { env: "test" },
50 });
51 }),
52 );
53 expect(created.hubArn).toContain(":hub/default");
54
55 // Out-of-band verification.
56 const live = yield* securityhub.describeHub({});
57 expect(live.HubArn).toBe(created.hubArn);
58 const tags = yield* securityhub.listTagsForResource({
59 ResourceArn: created.hubArn,
60 });
61 expect(tags.Tags?.["env"]).toBe("test");
62 expect(tags.Tags?.["alchemy::id"]).toBe("Hub");
63
64 // Canonical list() coverage.
65 const provider = yield* Provider.findProvider(Hub);
66 const all = yield* provider.list();
67 expect(all.some((h) => h.hubArn === created.hubArn)).toBe(true);
68
69 // Update — flip auto-enable-controls + retag in place.
70 yield* stack.deploy(
71 Effect.gen(function* () {
72 return yield* Hub("Hub", {
73 enableDefaultStandards: false,
74 autoEnableControls: false,
75 controlFindingGenerator: "SECURITY_CONTROL",
76 tags: { env: "prod" },
77 });
78 }),
79 );
80 const afterUpdate = yield* securityhub.describeHub({});
81 expect(afterUpdate.AutoEnableControls).toBe(false);
82 const updatedTags = yield* securityhub.listTagsForResource({
83 ResourceArn: created.hubArn,
84 });
85 expect(updatedTags.Tags?.["env"]).toBe("prod");
86
87 // Destroy — Security Hub is disabled and the account is unsubscribed.
88 yield* stack.destroy();
89 const after = yield* describeHub;
90 expect(after).toBeUndefined();
91 }),
92 {
93 tags: ["provider:aws", "provider:aws:securityhub", "live"],
94 timeout: 180_000,
95 },
96);