1import * as AWS from "@/AWS";
2import { AWSEnvironment } from "@/AWS/Environment.ts";
3import { Link, Sink } from "@/AWS/OAM";
4import * as Test from "@/Test/Alchemy";
5import * as oam from "@distilled.cloud/aws/oam";
6import { describe, expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as Result from "effect/Result";
9import * as Schedule from "effect/Schedule";
10import { makeOamTestLease } from "./TestLease.ts";
11
12const { test, beforeAll, afterAll } = Test.make({ providers: AWS.providers() });
13const serviceLease = makeOamTestLease();
14
15beforeAll(serviceLease.acquire, { timeout: 3_600_000 });
16afterAll(serviceLease.release);
17
18const assertSinkGone = (sinkArn: string) =>
19 oam.getSink({ Identifier: sinkArn }).pipe(
20 Effect.flatMap(() =>
21 Effect.fail(new Error(`sink ${sinkArn} still exists`)),
22 ),
23 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
24 Effect.retry({
25 while: (e) => e instanceof Error,
26 schedule: Schedule.max([
27 Schedule.fixed("2 seconds"),
28 Schedule.recurs(10),
29 ]),
30 }),
31 );
32
33// Policy validation resolves principals, so the source account must be a
34// REAL account — we use the caller's own. Nothing ever links to this sink
35// (same-account links are rejected); the policy only exercises put/get/diff.
36const sinkPolicy = (accountId: string, resourceTypes: string[]) => ({
37 Version: "2012-10-17",
38 Statement: [
39 {
40 Effect: "Allow",
41 Principal: { AWS: [accountId] },
42 Action: ["oam:CreateLink", "oam:UpdateLink"],
43 Resource: "*",
44 Condition: {
45 "ForAllValues:StringEquals": { "oam:ResourceTypes": resourceTypes },
46 },
47 },
48 ],
49});
50
51// AWS enforces ONE sink per account per region, so every test that deploys a
52// sink must run sequentially — including the same-account Link probe, which
53// shares the lifecycle test's sink instead of deploying its own.
54describe.sequential(
55 "AWS.OAM.Sink",
56 { tags: ["provider:aws", "provider:aws:oam", "live"] },
57 () => {
58 test.provider(
59 "sink lifecycle (policy + tags) and same-account link rejection",
60 (stack) =>
61 Effect.gen(function* () {
62 const { accountId } = yield* AWSEnvironment.current;
63 yield* stack.destroy();
64
65 const deploySink = (props: {
66 policy: Record<string, any>;
67 tags: Record<string, string>;
68 }) =>
69 stack.deploy(
70 Effect.gen(function* () {
71 const sink = yield* Sink("MonitoringSink", props);
72 return {
73 sinkName: sink.sinkName,
74 sinkArn: sink.sinkArn,
75 sinkId: sink.sinkId,
76 };
77 }),
78 );
79
80 const created = yield* deploySink({
81 policy: sinkPolicy(accountId, ["AWS::CloudWatch::Metric"]),
82 tags: { purpose: "alchemy-test" },
83 });
84 expect(created.sinkArn).toContain(":sink/");
85 expect(created.sinkArn).toContain(created.sinkId);
86
87 // Out-of-band: the sink, its policy, and its tags are all live.
88 const observed = yield* oam.getSink({ Identifier: created.sinkArn });
89 expect(observed.Name).toBe(created.sinkName);
90 const policy = yield* oam.getSinkPolicy({
91 SinkIdentifier: created.sinkArn,
92 });
93 expect(policy.Policy).toContain("oam:CreateLink");
94 expect(policy.Policy).toContain("AWS::CloudWatch::Metric");
95 const tags = yield* oam.listTagsForResource({
96 ResourceArn: created.sinkArn,
97 });
98 expect(tags.Tags?.purpose).toBe("alchemy-test");
99 expect(tags.Tags?.["alchemy::id"]).toBe("MonitoringSink");
100
101 // Same-account link create-path probe: OAM rejects a link whose sink
102 // lives in the caller's own account with a typed
103 // InvalidParameterException. This proves the createLink wire shape
104 // (the full Link lifecycle needs a second account — gated below).
105 const linkAttempt = yield* Effect.result(
106 oam.createLink({
107 LabelTemplate: "$AccountName",
108 ResourceTypes: ["AWS::CloudWatch::Metric"],
109 SinkIdentifier: created.sinkArn,
110 }),
111 );
112 expect(Result.isFailure(linkAttempt)).toBe(true);
113 if (Result.isFailure(linkAttempt)) {
114 const failure = linkAttempt.failure;
115 expect(failure._tag).toBe("InvalidParameterException");
116 if (failure._tag === "InvalidParameterException") {
117 expect(failure.message ?? "").toContain("same account");
118 }
119 }
120
121 // Update: widen the sink policy and change tags; the sink itself
122 // (name/arn/id) is stable.
123 const updated = yield* deploySink({
124 policy: sinkPolicy(accountId, [
125 "AWS::CloudWatch::Metric",
126 "AWS::Logs::LogGroup",
127 ]),
128 tags: { purpose: "alchemy-test", updated: "true" },
129 });
130 expect(updated.sinkArn).toBe(created.sinkArn);
131 const updatedPolicy = yield* oam.getSinkPolicy({
132 SinkIdentifier: created.sinkArn,
133 });
134 expect(updatedPolicy.Policy).toContain("AWS::Logs::LogGroup");
135 const updatedTags = yield* oam.listTagsForResource({
136 ResourceArn: created.sinkArn,
137 });
138 expect(updatedTags.Tags?.updated).toBe("true");
139
140 yield* stack.destroy();
141 yield* assertSinkGone(created.sinkArn);
142 }),
143 { timeout: 180_000 },
144 );
145
146 // Full Link lifecycle requires a sink in a DIFFERENT account whose policy
147 // authorizes this one. Point AWS_TEST_OAM_SINK_ARN at such a sink to run.
148 test.provider.skipIf(!process.env.AWS_TEST_OAM_SINK_ARN)(
149 "link lifecycle against a cross-account sink",
150 (stack) =>
151 Effect.gen(function* () {
152 const sinkIdentifier = process.env.AWS_TEST_OAM_SINK_ARN!;
153 yield* stack.destroy();
154
155 const deployLink = (resourceTypes: string[]) =>
156 stack.deploy(
157 Effect.gen(function* () {
158 const link = yield* Link("CrossAccountLink", {
159 labelTemplate: "$AccountName",
160 resourceTypes,
161 sinkIdentifier,
162 tags: { purpose: "alchemy-test" },
163 });
164 return { linkArn: link.linkArn, label: link.label };
165 }),
166 );
167
168 const created = yield* deployLink(["AWS::CloudWatch::Metric"]);
169 const live = yield* oam.getLink({ Identifier: created.linkArn });
170 expect(live.ResourceTypes).toEqual(["AWS::CloudWatch::Metric"]);
171
172 // Update mutates resource types in place (no replacement).
173 const updated = yield* deployLink([
174 "AWS::CloudWatch::Metric",
175 "AWS::Logs::LogGroup",
176 ]);
177 expect(updated.linkArn).toBe(created.linkArn);
178 const after = yield* oam.getLink({ Identifier: created.linkArn });
179 expect([...(after.ResourceTypes ?? [])].sort()).toEqual([
180 "AWS::CloudWatch::Metric",
181 "AWS::Logs::LogGroup",
182 ]);
183
184 yield* stack.destroy();
185 const gone = yield* Effect.result(
186 oam.getLink({ Identifier: created.linkArn }),
187 );
188 expect(Result.isFailure(gone)).toBe(true);
189 }),
190 { timeout: 180_000 },
191 );
192 },
193);