1import * as AWS from "@/AWS";
2import { Bucket } from "@/AWS/S3";
3import { AccessPoint, AccessPointPolicy } from "@/AWS/S3Control";
4import * as Output from "@/Output";
5import * as Test from "@/Test/Alchemy";
6import * as s3control from "@distilled.cloud/aws/s3-control";
7import { expect } from "alchemy-test";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: AWS.providers() });
13
14const ACCOUNT_ID = "391965393224";
15
16const findPolicy = (name: string) =>
17 s3control.getAccessPointPolicy({ AccountId: ACCOUNT_ID, Name: name }).pipe(
18 Effect.map((r) => r.Policy),
19 Effect.catchTag(["NoSuchAccessPointPolicy", "NoSuchAccessPoint"], () =>
20 Effect.succeed(undefined),
21 ),
22 );
23
24class AccessPointStillExists extends Data.TaggedError(
25 "AccessPointStillExists",
26)<{ readonly name: string }> {}
27
28// The policy's access point (and transitively its bucket) must be destroyed
29// too — a destroy that only removes the policy would orphan them.
30const assertAccessPointDeleted = (name: string) =>
31 s3control.getAccessPoint({ AccountId: ACCOUNT_ID, Name: name }).pipe(
32 Effect.flatMap(() => Effect.fail(new AccessPointStillExists({ name }))),
33 Effect.catchTag("NoSuchAccessPoint", () => Effect.void),
34 Effect.retry({
35 while: (e) => e._tag === "AccessPointStillExists",
36 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
37 }),
38 );
39
40test.provider(
41 "create, update, delete access point policy",
42 (stack) =>
43 Effect.gen(function* () {
44 yield* stack.destroy();
45
46 const singleStatementStack = Effect.gen(function* () {
47 const bucket = yield* Bucket("PolicyBucket", { forceDestroy: true });
48 const accessPoint = yield* AccessPoint("PolicyAp", {
49 bucket: bucket.bucketName,
50 });
51 yield* AccessPointPolicy("PolicyApPolicy", {
52 accessPointName: accessPoint.accessPointName,
53 // typed PolicyDocument (not a raw JSON string)
54 policy: {
55 Version: "2012-10-17",
56 Statement: [
57 {
58 Effect: "Allow",
59 Principal: { AWS: `arn:aws:iam::${ACCOUNT_ID}:root` },
60 Action: ["s3:GetObject"],
61 Resource: Output.interpolate`${accessPoint.accessPointArn}/object/*`,
62 },
63 ],
64 },
65 });
66 return { accessPoint };
67 });
68
69 const deployed = yield* stack.deploy(singleStatementStack);
70
71 // out-of-band verification via distilled
72 const policy = yield* findPolicy(deployed.accessPoint.accessPointName);
73 expect(policy).toBeDefined();
74 const parsed = JSON.parse(policy!) as {
75 Statement: { Action: string | string[] }[];
76 };
77 expect(parsed.Statement).toHaveLength(1);
78 // AWS normalizes stored policies (single-element arrays collapse to
79 // plain strings), so compare the normalized form.
80 const actions = parsed.Statement[0].Action;
81 expect(Array.isArray(actions) ? actions : [actions]).toEqual([
82 "s3:GetObject",
83 ]);
84
85 // re-deploy the identical PolicyDocument — must be a clean no-op:
86 // the stored policy is byte-for-byte unchanged afterwards.
87 yield* stack.deploy(singleStatementStack);
88 const redeployedPolicy = yield* findPolicy(
89 deployed.accessPoint.accessPointName,
90 );
91 expect(redeployedPolicy).toBe(policy);
92
93 // update the policy document in place
94 yield* stack.deploy(
95 Effect.gen(function* () {
96 const bucket = yield* Bucket("PolicyBucket", { forceDestroy: true });
97 const accessPoint = yield* AccessPoint("PolicyAp", {
98 bucket: bucket.bucketName,
99 });
100 yield* AccessPointPolicy("PolicyApPolicy", {
101 accessPointName: accessPoint.accessPointName,
102 policy: {
103 Version: "2012-10-17",
104 Statement: [
105 {
106 Effect: "Allow",
107 Principal: { AWS: `arn:aws:iam::${ACCOUNT_ID}:root` },
108 Action: ["s3:GetObject"],
109 Resource: Output.interpolate`${accessPoint.accessPointArn}/object/*`,
110 },
111 {
112 Effect: "Allow",
113 Principal: { AWS: `arn:aws:iam::${ACCOUNT_ID}:root` },
114 Action: ["s3:ListBucket"],
115 Resource: accessPoint.accessPointArn,
116 },
117 ],
118 },
119 });
120 return { accessPoint };
121 }),
122 );
123
124 const updatedPolicy = yield* findPolicy(
125 deployed.accessPoint.accessPointName,
126 );
127 const updatedParsed = JSON.parse(updatedPolicy!) as {
128 Statement: unknown[];
129 };
130 expect(updatedParsed.Statement).toHaveLength(2);
131
132 yield* stack.destroy();
133 // policy (and its access point) are gone after destroy
134 const afterDestroy = yield* findPolicy(
135 deployed.accessPoint.accessPointName,
136 );
137 expect(afterDestroy).toBeUndefined();
138 yield* assertAccessPointDeleted(deployed.accessPoint.accessPointName);
139 }),
140 {
141 tags: ["provider:aws", "provider:aws:s3", "provider:aws:s3control", "live"],
142 timeout: 120_000,
143 },
144);