1import { InstanceId } from "@/InstanceId.ts";
2import { createPhysicalName } from "@/PhysicalName.ts";
3import { Stack, type StackSpec } from "@/Stack.ts";
4import { Stage } from "@/Stage.ts";
5import { describe, expect, it } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8
9type StackShape = Omit<StackSpec, "output">;
10
11const stack: StackShape = {
12 name: "my-stack",
13 stage: "dev",
14 resources: {},
15 bindings: {},
16 actions: {},
17};
18
19const environment = (
20 spec: StackShape = stack,
21 instanceId = "0123456789abcdef0123456789abcdef",
22) =>
23 Layer.mergeAll(
24 Layer.succeed(Stack, spec),
25 Layer.succeed(Stage, spec.stage),
26 Layer.succeed(InstanceId, instanceId),
27 );
28
29const provide = <A, E>(
30 effect: Effect.Effect<A, E, Stack | Stage | InstanceId>,
31 instanceId?: string,
32) => effect.pipe(Effect.provide(environment(stack, instanceId)));
33
34describe("createPhysicalName", { tags: ["unit", "local"] }, () => {
35 it.effect("keeps short names untruncated", () =>
36 provide(
37 Effect.gen(function* () {
38 const name = yield* createPhysicalName({ id: "api", maxLength: 64 });
39 expect(name.length).toBeLessThanOrEqual(64);
40 expect(name.startsWith("my-stack-api-dev-")).toBe(true);
41 }),
42 ),
43 );
44
45 it.effect("is deterministic for the same inputs", () =>
46 provide(
47 Effect.gen(function* () {
48 const longId = "a".repeat(80);
49 const first = yield* createPhysicalName({ id: longId, maxLength: 64 });
50 const second = yield* createPhysicalName({ id: longId, maxLength: 64 });
51 expect(first).toBe(second);
52 expect(first.length).toBe(64);
53 }),
54 ),
55 );
56
57 it.effect(
58 "preserves suffix uniqueness when a long id truncates away the suffix (task-role vs execution-role)",
59 () =>
60 provide(
61 Effect.gen(function* () {
62 // Pathological logical id: long enough that `${stack}-${id}-…` alone
63 // exceeds IAM's 64-char role-name limit, so the distinguishing
64 // `-task-role` / `-execution-role` tail falls entirely inside the
65 // truncated region. Both names share the same InstanceId (same
66 // resource), which is exactly the collision scenario.
67 const longId = "my-very-long-container-platform-service-logical-id";
68 const taskRole = yield* createPhysicalName({
69 id: `${longId}-task-role`,
70 maxLength: 64,
71 });
72 const executionRole = yield* createPhysicalName({
73 id: `${longId}-execution-role`,
74 maxLength: 64,
75 });
76 expect(taskRole.length).toBe(64);
77 expect(executionRole.length).toBe(64);
78 expect(taskRole).not.toBe(executionRole);
79 }),
80 ),
81 );
82
83 it.effect(
84 "distinguishes every colliding suffix pair used by role helpers",
85 () =>
86 provide(
87 Effect.gen(function* () {
88 const longId = "z".repeat(100);
89 const suffixes = [
90 "task-role",
91 "execution-role",
92 "job-role",
93 "instance-role",
94 "access-role",
95 ];
96 const names = yield* Effect.forEach(suffixes, (suffix) =>
97 createPhysicalName({ id: `${longId}-${suffix}`, maxLength: 64 }),
98 );
99 expect(new Set(names).size).toBe(suffixes.length);
100 for (const name of names) {
101 expect(name.length).toBe(64);
102 }
103 }),
104 ),
105 );
106
107 it.effect("different instance ids still produce different names", () =>
108 Effect.gen(function* () {
109 const longId = "b".repeat(80);
110 const first = yield* provide(
111 createPhysicalName({ id: longId, maxLength: 64 }),
112 "0123456789abcdef0123456789abcdef",
113 );
114 const second = yield* provide(
115 createPhysicalName({ id: longId, maxLength: 64 }),
116 "fedcba9876543210fedcba9876543210",
117 );
118 expect(first).not.toBe(second);
119 }),
120 );
121
122 it.effect(
123 "keeps the full hash under tight limits (DAX-style maxLength 20)",
124 () =>
125 provide(
126 Effect.gen(function* () {
127 // maxLength 20 with the default 16-char instance suffix leaves no
128 // room for prefix + hash + suffix; the hash must survive in full
129 // (the suffix shrinks) or same-resource names collide again.
130 const longId = "c".repeat(60);
131 const names = yield* Effect.forEach(["alpha", "beta"], (suffix) =>
132 createPhysicalName({
133 id: `${longId}-${suffix}`,
134 maxLength: 20,
135 lowercase: true,
136 }),
137 );
138 expect(names[0]!.length).toBe(20);
139 expect(names[1]!.length).toBe(20);
140 expect(names[0]).not.toBe(names[1]);
141 }),
142 ),
143 );
144
145 it.effect(
146 "keeps uniqueness with a shortened suffixLength (Canary-style 21/8)",
147 () =>
148 provide(
149 Effect.gen(function* () {
150 const longId = "d".repeat(60);
151 const names = yield* Effect.forEach(["alpha", "beta"], (suffix) =>
152 createPhysicalName({
153 id: `${longId}-${suffix}`,
154 maxLength: 21,
155 suffixLength: 8,
156 lowercase: true,
157 }),
158 );
159 expect(names[0]!.length).toBe(21);
160 expect(names[0]).not.toBe(names[1]);
161 }),
162 ),
163 );
164
165 it.effect(
166 "prepends a safe prefix when the name collides with a forbidden prefix",
167 () =>
168 Effect.gen(function* () {
169 // A stack named `AWS-…` (e.g. file-namespaced test stacks) collides
170 // with service-reserved prefixes like S3 Tables' / ResourceGroups' `aws`.
171 const awsStack: StackShape = { ...stack, name: "AWS-S3Tables-Test" };
172 const name = yield* createPhysicalName({
173 id: "bucket",
174 maxLength: 63,
175 lowercase: true,
176 forbiddenPrefixes: ["xn--", "sthree-", "amzn-s3-demo-", "aws"],
177 }).pipe(Effect.provide(environment(awsStack)));
178 expect(name.startsWith("x-aws-s3tables-test-")).toBe(true);
179 expect(name.length).toBeLessThanOrEqual(63);
180 }),
181 );
182
183 it.effect("non-colliding names are unaffected by forbiddenPrefixes", () =>
184 provide(
185 Effect.gen(function* () {
186 const withOption = yield* createPhysicalName({
187 id: "api",
188 forbiddenPrefixes: ["aws"],
189 });
190 const withoutOption = yield* createPhysicalName({ id: "api" });
191 expect(withOption).toBe(withoutOption);
192 }),
193 ),
194 );
195
196 it.effect("lowercase truncated names stay DNS-safe", () =>
197 provide(
198 Effect.gen(function* () {
199 const name = yield* createPhysicalName({
200 id: `MyMixedCase_${"x".repeat(80)}`,
201 maxLength: 63,
202 lowercase: true,
203 });
204 expect(name.length).toBe(63);
205 expect(name).toMatch(/^[a-z0-9-]+$/);
206 }),
207 ),
208 );
209});