t3-code-android-nightly/.repos/alchemy-effect/packages/alchemy/test/AWS/SecurityLake/SecurityLake.test.ts
Julius Marminge 6f9cea00ae
chore(refs): sync Effect and Alchemy references to 4.0.1 and beta.80 (#16170)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:22:30 -07:00

309 lines
12 KiB
TypeScript

import * as AWS from "@/AWS";
import { Role } from "@/AWS/IAM/Role.ts";
import {
AwsLogSource,
CustomLogSource,
DataLake,
ExceptionSubscription,
Subscriber,
SubscriberNotification,
} from "@/AWS/SecurityLake";
import * as Test from "@/Test/Alchemy";
import * as securitylake from "@distilled.cloud/aws/securitylake";
import { expect } from "alchemy-test";
import * as Effect from "effect/Effect";
import * as Result from "effect/Result";
import * as Schedule from "effect/Schedule";
const { test } = Test.make({ providers: AWS.providers() });
// ---------------------------------------------------------------------------
// Ungated typed-error probes: prove the distilled error union carries the tags
// the providers' read/delete paths depend on, at near-zero cost. Security Lake
// lifecycle itself is account-wide and heavy, so it is gated below.
// ---------------------------------------------------------------------------
test.provider(
"getSubscriber on a nonexistent subscriber fails with a typed tag",
() =>
Effect.gen(function* () {
const error = yield* Effect.flip(
securitylake.getSubscriber({
// valid UUID shape, never allocated
subscriberId: "00000000-0000-4000-8000-000000000000",
}),
);
// Onboarded accounts return ResourceNotFoundException; accounts that
// never enabled Security Lake reject every subscriber API with the
// (patched-in) UnauthorizedException wire error.
expect(["ResourceNotFoundException", "UnauthorizedException"]).toContain(
error._tag,
);
}),
{ tags: ["provider:aws", "provider:aws:securitylake", "live"] },
);
test.provider(
"listDataLakes returns data lakes or a typed not-onboarded rejection",
() =>
Effect.gen(function* () {
const result = yield* Effect.result(securitylake.listDataLakes({}));
if (Result.isSuccess(result)) {
expect(Array.isArray(result.success.dataLakes ?? [])).toBe(true);
} else {
// Accounts that never onboarded Security Lake reject listDataLakes
// with AccessDeniedException.
expect(result.failure._tag).toBe("AccessDeniedException");
}
}),
{ tags: ["provider:aws", "provider:aws:securitylake", "live"] },
);
test.provider(
"getDataLakeExceptionSubscription returns the subscription or a typed rejection",
() =>
Effect.gen(function* () {
const result = yield* Effect.result(
securitylake.getDataLakeExceptionSubscription({}),
);
if (Result.isSuccess(result)) {
// No subscription configured — every field is absent.
expect(typeof (result.success.notificationEndpoint ?? "")).toBe(
"string",
);
} else {
// Accounts that never onboarded Security Lake (or have no
// subscription) reject with one of the typed tags the
// ExceptionSubscription provider's read/delete paths depend on.
expect([
"AccessDeniedException",
"ResourceNotFoundException",
"UnauthorizedException",
]).toContain(result.failure._tag);
}
}),
{ tags: ["provider:aws", "provider:aws:securitylake", "live"] },
);
test.provider(
"deleteSubscriberNotification on a nonexistent subscriber fails with a typed tag",
() =>
Effect.gen(function* () {
const error = yield* Effect.flip(
securitylake.deleteSubscriberNotification({
subscriberId: "00000000-0000-4000-8000-000000000000",
}),
);
expect([
"AccessDeniedException",
"ResourceNotFoundException",
"UnauthorizedException",
]).toContain(error._tag);
}),
{ tags: ["provider:aws", "provider:aws:securitylake", "live"] },
);
// ---------------------------------------------------------------------------
// Gated live lifecycle. Enabling Security Lake onboards the whole account in
// the Region: it creates S3 buckets, registers them with Lake Formation, and
// configures the Glue metastore. The buckets are retained after delete (AWS
// behavior). Run with AWS_TEST_SECURITYLAKE=1 on an account you are willing to
// onboard/offboard.
// ---------------------------------------------------------------------------
const assertDataLakeGone = securitylake.listDataLakes({}).pipe(
Effect.flatMap((response) =>
(response.dataLakes ?? []).length === 0
? Effect.void
: Effect.fail(new Error("data lake still present")),
),
Effect.retry({
schedule: Schedule.max([Schedule.fixed("5 seconds"), Schedule.recurs(12)]),
}),
);
test.provider.skipIf(!process.env.AWS_TEST_SECURITYLAKE)(
"lifecycle: enable data lake, add ROUTE53 source + subscriber, update, destroy",
(stack) =>
Effect.gen(function* () {
const { accountId, region } = yield* AWS.AWSEnvironment.current;
// Never stomp an existing Security Lake deployment.
const preexisting = yield* securitylake.listDataLakes({});
if ((preexisting.dataLakes ?? []).length > 0) {
yield* Effect.logInfo(
"Security Lake already enabled in this account — skipping destructive lifecycle test",
);
return;
}
yield* stack.destroy();
const deployOnce = (subscriberDescription: string, withExtras: boolean) =>
stack.deploy(
Effect.gen(function* () {
const metastoreRole = yield* Role("MetastoreRole", {
assumeRolePolicyDocument: {
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Principal: { Service: "lambda.amazonaws.com" },
Action: ["sts:AssumeRole"],
},
],
},
managedPolicyArns: [
"arn:aws:iam::aws:policy/service-role/AmazonSecurityLakeMetastoreManager",
],
});
const lake = yield* DataLake("Lake", {
configurations: [
{
region,
lifecycleConfiguration: { expiration: { days: "30 days" } },
},
],
metaStoreManagerRoleArn: metastoreRole.roleArn,
tags: { fixture: "securitylake" },
});
const source = yield* AwsLogSource("Route53Source", {
sourceName: "ROUTE53",
regions: lake.regions,
});
const subscriber = yield* Subscriber("Consumer", {
subscriberIdentity: {
principal: accountId,
externalId: "alchemy-securitylake-test",
},
subscriberDescription,
// Reference the log source's output so destroy ordering tears
// the subscriber down before the source and data lake.
sources: [{ awsLogSource: { sourceName: source.sourceName } }],
accessTypes: ["S3"],
tags: { fixture: "securitylake" },
});
if (!withExtras) {
return { lake, source, subscriber, extras: undefined };
}
// Second wave (the data lake already exists by now): the
// exception subscription singleton, an SQS subscriber
// notification, and a custom log source with its crawler role.
const exceptions = yield* ExceptionSubscription("Exceptions", {
subscriptionProtocol: "email",
notificationEndpoint: "securitylake-test@example.com",
exceptionTimeToLive: "30 days",
});
const crawlerRole = yield* Role("CrawlerRole", {
assumeRolePolicyDocument: {
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Principal: { Service: "glue.amazonaws.com" },
Action: ["sts:AssumeRole"],
},
],
},
managedPolicyArns: [
"arn:aws:iam::aws:policy/service-role/AWSGlueServiceRole",
],
});
const custom = yield* CustomLogSource("CustomSource", {
sourceName: "alchemy-securitylake-test-custom",
eventClasses: ["FILE_ACTIVITY"],
crawlerConfiguration: { roleArn: crawlerRole.roleArn },
providerIdentity: {
principal: accountId,
externalId: "alchemy-securitylake-custom",
},
});
const notification = yield* SubscriberNotification("Notify", {
subscriberId: subscriber.subscriberId,
sqs: true,
});
return {
lake,
source,
subscriber,
extras: { exceptions, custom, notification },
};
}),
);
// Create.
const { lake, source, subscriber } = yield* deployOnce("v1", false);
expect(lake.dataLakeArn).toContain(":securitylake:");
expect(lake.regions).toContain(region);
expect(source.sourceName).toBe("ROUTE53");
expect(source.regions).toContain(region);
expect(subscriber.subscriberId).toBeDefined();
expect(subscriber.s3BucketArn).toBeDefined();
// Out-of-band verification via distilled.
const lakes = yield* securitylake.listDataLakes({});
expect(lakes.dataLakes?.some((l) => l.region === region)).toBe(true);
const observedSubscriber = yield* securitylake.getSubscriber({
subscriberId: subscriber.subscriberId,
});
expect(observedSubscriber.subscriber?.subscriberArn).toBe(
subscriber.subscriberArn,
);
// Update in place (subscriber description) + add the second wave of
// resources (exception subscription, custom source, notification).
const second = yield* deployOnce("v2", true);
expect(second.subscriber.subscriberId).toBe(subscriber.subscriberId);
const updated = yield* securitylake.getSubscriber({
subscriberId: subscriber.subscriberId,
});
expect(updated.subscriber?.subscriberDescription).toBe("v2");
const extras = second.extras!;
expect(extras.exceptions.subscriptionProtocol).toBe("email");
expect(extras.exceptions.exceptionTimeToLive).toBe(30);
expect(extras.custom.sourceName).toBe("alchemy-securitylake-test-custom");
expect(extras.notification.subscriberEndpoint).toBeDefined();
// Out-of-band verification via distilled.
const exceptionSubscription =
yield* securitylake.getDataLakeExceptionSubscription({});
expect(exceptionSubscription.notificationEndpoint).toBe(
"securitylake-test@example.com",
);
const logSources = yield* securitylake.listLogSources({});
expect(
(logSources.sources ?? []).some((entry) =>
(entry.sources ?? []).some(
(source) =>
source.customLogSource?.sourceName ===
"alchemy-securitylake-test-custom",
),
),
).toBe(true);
const notified = yield* securitylake.getSubscriber({
subscriberId: subscriber.subscriberId,
});
expect(notified.subscriber?.subscriberEndpoint).toBeDefined();
// Destroy — offboards the account; buckets are retained by AWS design.
yield* stack.destroy();
const goneSubscriber = yield* Effect.flip(
securitylake.getSubscriber({
subscriberId: subscriber.subscriberId,
}),
);
expect(goneSubscriber._tag).toBe("ResourceNotFoundException");
yield* assertDataLakeGone;
}),
{
tags: [
"provider:aws",
"provider:aws:iam",
"provider:aws:securitylake",
"live",
],
timeout: 1_200_000,
},
);