1/**
2 * Wave 3 — Site→Router hostname binding.
3 *
4 * A router-attached site's `domain: { name, router }` declaration alone must
5 * be sufficient for the hostname to be fully provisioned: the site binds its
6 * concrete hostnames onto the Router's distribution (alias), managed
7 * certificate (SAN — replacement on change), and Route 53 record set.
8 *
9 * The ungated tests compile real compositions (no cloud calls — resource
10 * registration only) and assert on the binding rows the engine collects,
11 * which is exactly what the providers receive in `reconcile` and what the
12 * planner diffs (`diffBindings` plans an update whenever a row changes).
13 *
14 * The live suite is gated behind AWS_TEST_HOSTED_ZONE=<zone-name> because it
15 * needs a real Route 53 hosted zone (the shared testing account has none).
16 */
17import * as AWS from "@/AWS";
18import { Certificate, CertificateProvider } from "@/AWS/ACM/Certificate.ts";
19import * as Stack from "@/Stack";
20import { Stage } from "@/Stage";
21import { inMemoryState } from "@/State";
22import * as Test from "@/Test/Alchemy";
23import * as acm from "@distilled.cloud/aws/acm";
24import * as cloudfront from "@distilled.cloud/aws/cloudfront";
25import { Credentials } from "@distilled.cloud/aws/Credentials";
26import type { RegionName } from "@distilled.cloud/aws/Region";
27import * as route53 from "@distilled.cloud/aws/route-53";
28import { describe, expect } from "alchemy-test";
29import * as Effect from "effect/Effect";
30import * as Layer from "effect/Layer";
31import * as Redacted from "effect/Redacted";
32import * as Schedule from "effect/Schedule";
33import * as FetchHttpClient from "effect/http/FetchHttpClient";
34import { fileURLToPath } from "node:url";
35
36const { test } = Test.make({ providers: AWS.providers() });
37
38// Anchor the fixture to the repo root regardless of the runner's cwd.
39const fixtureDir = fileURLToPath(
40 new URL("../../../../../examples/aws-static-site/site", import.meta.url),
41);
42
43interface BindingRow {
44 sid: string;
45 data: any;
46}
47
48/**
49 * Compile a composition (registration only — no plan, no apply, no cloud
50 * calls) and return the binding rows the engine collected, keyed by target
51 * FQN. Mirrors the compile harness in test/binding-stability.test.ts.
52 */
53const compileStack = (
54 build: Effect.Effect<any, any, any>,
55): Effect.Effect<
56 { bindings: Record<string, BindingRow[]>; resourceFqns: string[] },
57 never,
58 never
59> =>
60 Effect.scoped(
61 (build as Effect.Effect<any, any, never>).pipe(
62 Stack.make({
63 name: "wave3-hostname-binding",
64 providers: Layer.empty,
65 state: inMemoryState(),
66 } as any),
67 Effect.map((compiled: any) => ({
68 bindings: compiled.bindings as Record<string, BindingRow[]>,
69 resourceFqns: Object.keys(compiled.resources) as string[],
70 })),
71 ),
72 ).pipe(Effect.provideService(Stage, "test")) as Effect.Effect<
73 { bindings: Record<string, BindingRow[]>; resourceFqns: string[] },
74 never,
75 never
76 >;
77
78const siteRowsOf = (bindings: Record<string, BindingRow[]>) =>
79 Object.entries(bindings).flatMap(([fqn, rows]) =>
80 rows
81 .filter((row) => row.sid.startsWith("AWS.Website.Site"))
82 .map((row) => ({ fqn, ...row })),
83 );
84
85describe(
86 "AWS.Website Router hostname binding (composition)",
87 { tags: ["provider:aws", "provider:aws:website", "live"] },
88 () => {
89 test(
90 "attached site's domain declaration binds concrete hostnames onto the Router's distribution, certificate, and record set",
91 Effect.gen(function* () {
92 const compiled = yield* compileStack(
93 Effect.gen(function* () {
94 const router = yield* AWS.Website.Router("Router", {
95 domain: {
96 name: "router.example.com",
97 hostedZoneId: "Z1234567890ABC",
98 },
99 });
100 yield* AWS.Website.StaticSite("DocsSite", {
101 path: fixtureDir,
102 domain: {
103 name: "docs.example.com",
104 // The wildcard alias is a host pattern — nothing concrete to
105 // register, so it must NOT appear in the bound hostnames.
106 aliases: ["*.preview.example.com", "assets.example.com"],
107 redirects: ["old.example.com"],
108 router,
109 path: "/docs",
110 },
111 });
112 return {};
113 }),
114 );
115
116 const expected = [
117 "docs.example.com",
118 "assets.example.com",
119 "old.example.com",
120 ];
121
122 const distributionRow = (
123 compiled.bindings["Router/Distribution"] ?? []
124 ).find((row) => row.sid === "AWS.Website.Site(DocsSite)");
125 expect(distributionRow?.data.aliases).toEqual(expected);
126
127 const certificateRow = (
128 compiled.bindings["Router/Certificate"] ?? []
129 ).find((row) => row.sid === "AWS.Website.Site(DocsSite)");
130 expect(certificateRow?.data.subjectAlternativeNames).toEqual(expected);
131
132 const recordsRow = (
133 compiled.bindings["Router/SiteAliasRecords"] ?? []
134 ).find((row) => row.sid === "AWS.Website.Site(DocsSite)");
135 expect(recordsRow?.data.names).toEqual(expected);
136
137 // The Router creates the record-set bind target alongside its own
138 // per-hostname alias records.
139 expect(compiled.resourceFqns).toContain("Router/SiteAliasRecords");
140 }),
141 );
142
143 test(
144 "removing the attached site removes its binding rows",
145 Effect.gen(function* () {
146 const compiled = yield* compileStack(
147 Effect.gen(function* () {
148 yield* AWS.Website.Router("Router", {
149 domain: {
150 name: "router.example.com",
151 hostedZoneId: "Z1234567890ABC",
152 },
153 });
154 return {};
155 }),
156 );
157 expect(siteRowsOf(compiled.bindings)).toEqual([]);
158 }),
159 );
160
161 test(
162 "path-only and wildcard-only attachments bind nothing",
163 Effect.gen(function* () {
164 const compiled = yield* compileStack(
165 Effect.gen(function* () {
166 const router = yield* AWS.Website.Router("Router", {
167 domain: {
168 name: "router.example.com",
169 hostedZoneId: "Z1234567890ABC",
170 },
171 });
172 yield* AWS.Website.StaticSite("PathOnlySite", {
173 path: fixtureDir,
174 domain: { router, path: "/path-only" },
175 });
176 yield* AWS.Website.StaticSite("WildcardSite", {
177 path: fixtureDir,
178 domain: { name: "*.example.com", router, path: "/wild" },
179 });
180 return {};
181 }),
182 );
183 expect(siteRowsOf(compiled.bindings)).toEqual([]);
184 }),
185 );
186
187 test(
188 "a Router without a domain exposes no bind targets (no certificate to cover aliases)",
189 Effect.gen(function* () {
190 const compiled = yield* compileStack(
191 Effect.gen(function* () {
192 const router = yield* AWS.Website.Router("Router", {});
193 yield* AWS.Website.StaticSite("DocsSite", {
194 path: fixtureDir,
195 domain: { name: "docs.example.com", router },
196 });
197 return { router };
198 }),
199 );
200 expect(siteRowsOf(compiled.bindings)).toEqual([]);
201 }),
202 );
203
204 test(
205 "a user-provided cert Router binds aliases and records but not the certificate",
206 Effect.gen(function* () {
207 const compiled = yield* compileStack(
208 Effect.gen(function* () {
209 const router = yield* AWS.Website.Router("Router", {
210 domain: {
211 name: "router.example.com",
212 hostedZoneId: "Z1234567890ABC",
213 cert: "arn:aws:acm:us-east-1:123456789012:certificate/abc",
214 },
215 });
216 yield* AWS.Website.StaticSite("DocsSite", {
217 path: fixtureDir,
218 domain: { name: "docs.example.com", router },
219 });
220 return {};
221 }),
222 );
223 const rows = siteRowsOf(compiled.bindings);
224 expect(rows.map((row) => row.fqn).sort()).toEqual([
225 "Router/Distribution",
226 "Router/SiteAliasRecords",
227 ]);
228 }),
229 );
230
231 test(
232 "cross-stack router refs fall back to KV host-matching only (no bindings, no error)",
233 Effect.gen(function* () {
234 const compiled = yield* compileStack(
235 Effect.gen(function* () {
236 // A hand-built structural slice, as a cross-stack consumer would
237 // assemble from another stack's outputs — carries no bindTargets.
238 const routerRef = {
239 kvStoreArn:
240 "arn:aws:cloudfront::123456789012:key-value-store/9f6a",
241 kvNamespace: "9f6a",
242 distributionId: "E1234567890ABC",
243 distributionArn:
244 "arn:aws:cloudfront::123456789012:distribution/E1234567890ABC",
245 url: "https://d111111abcdef8.cloudfront.net",
246 };
247 yield* AWS.Website.StaticSite("DocsSite", {
248 path: fixtureDir,
249 domain: { name: "docs.example.com", router: routerRef },
250 });
251 return {};
252 }),
253 );
254 expect(siteRowsOf(compiled.bindings)).toEqual([]);
255 // The KV route registration (host-matching) still happens.
256 expect(compiled.resourceFqns).toContain("DocsSite/RoutesUpdate");
257 }),
258 );
259
260 test(
261 // Plan-safety: ACM SANs are immutable, so a change in the BOUND SAN set
262 // must plan a certificate REPLACEMENT (create-first swap), not an
263 // in-place update. Prop/alias deltas on the Distribution ride the
264 // engine's default binding diff (any changed row plans an update).
265 "a bound SAN delta plans a certificate replacement",
266 Effect.gen(function* () {
267 const provider = yield* Certificate.Provider;
268 const olds = {
269 domainName: "router.example.com",
270 subjectAlternativeNames: [] as string[],
271 hostedZoneId: "Z1234567890ABC",
272 };
273 const base = {
274 id: "Certificate",
275 fqn: "Router/Certificate",
276 instanceId: "wave3instance",
277 olds,
278 news: olds,
279 output: undefined,
280 };
281
282 // Adding a bound hostname → replace.
283 const added = yield* provider.diff!({
284 ...base,
285 oldBindings: [],
286 newBindings: [
287 {
288 sid: "AWS.Website.Site(DocsSite)",
289 data: { subjectAlternativeNames: ["docs.example.com"] },
290 },
291 ],
292 });
293 expect(added?.action).toBe("replace");
294
295 // Removing it → replace.
296 const removed = yield* provider.diff!({
297 ...base,
298 oldBindings: [
299 {
300 sid: "AWS.Website.Site(DocsSite)",
301 data: { subjectAlternativeNames: ["docs.example.com"] },
302 },
303 ],
304 newBindings: [],
305 });
306 expect(removed?.action).toBe("replace");
307
308 // Identical bound SANs → no provider-forced action (engine noops).
309 const unchanged = yield* provider.diff!({
310 ...base,
311 oldBindings: [
312 {
313 sid: "AWS.Website.Site(DocsSite)",
314 data: { subjectAlternativeNames: ["docs.example.com"] },
315 },
316 ],
317 newBindings: [
318 {
319 sid: "AWS.Website.Site(DocsSite)",
320 data: { subjectAlternativeNames: ["docs.example.com"] },
321 },
322 ],
323 });
324 expect(unchanged).toBeUndefined();
325 }).pipe(Effect.provide(certificateProviderForDiff())),
326 { tags: ["provider:aws:acm"] },
327 );
328 },
329);
330
331/**
332 * The Certificate provider with dummy ambient services: `diff` is a pure
333 * SAN comparison (no network), but the provider layer and its lifecycle
334 * effects are typed against the AWS environment, so satisfy it with inert
335 * stand-ins. Composed as ONE layer so the test provides exactly once.
336 */
337function certificateProviderForDiff() {
338 const ambient = Layer.mergeAll(
339 FetchHttpClient.layer,
340 Layer.succeed(Stack.Stack, {
341 name: "wave3-hostname-binding",
342 stage: "test",
343 resources: {},
344 bindings: {},
345 actions: {},
346 }),
347 Layer.succeed(Stage, "test"),
348 Layer.succeed(
349 Credentials,
350 Effect.succeed({
351 accessKeyId: Redacted.make("test"),
352 secretAccessKey: Redacted.make("test"),
353 sessionToken: undefined,
354 region: "us-east-1" as RegionName,
355 }),
356 ),
357 );
358 return Layer.mergeAll(
359 CertificateProvider().pipe(Layer.provide(ambient)),
360 ambient,
361 );
362}
363
364// ---------------------------------------------------------------------------
365// Live verification — gated on AWS_TEST_HOSTED_ZONE=<zone-name> (e.g.
366// "alchemy-test.example.com"). The shared testing account currently has NO
367// hosted zone, so this cannot run there; it is implemented for an account
368// that has one. The suite also folds in wave-2's deferred gated checks
369// (cloudfrontUrl:false 301 and redirect 301), which need the same zone.
370// ---------------------------------------------------------------------------
371
372const testZone = process.env.AWS_TEST_HOSTED_ZONE;
373
374describe.skipIf(!testZone)(
375 "AWS.Website Router hostname binding (live)",
376 {
377 tags: [
378 "provider:aws",
379 "provider:aws:acm",
380 "provider:aws:cloudfront",
381 "provider:aws:route53",
382 "provider:aws:website",
383 "live",
384 ],
385 },
386 () => {
387 test.provider(
388 "attached site's declaration alone provisions alias + SAN + DNS and serves over HTTPS",
389 (stack) =>
390 Effect.gen(function* () {
391 yield* stack.destroy();
392
393 const zoneName = testZone!.replace(/\.$/, "");
394 const zone = yield* route53
395 .listHostedZonesByName({ DNSName: `${zoneName}.` })
396 .pipe(
397 Effect.map((response) =>
398 (response.HostedZones ?? []).find(
399 (candidate) => candidate.Name === `${zoneName}.`,
400 ),
401 ),
402 );
403 if (!zone?.Id) {
404 return yield* Effect.die(
405 `AWS_TEST_HOSTED_ZONE=${zoneName} does not resolve to a hosted zone in this account`,
406 );
407 }
408 const hostedZoneId = zone.Id.replace(/^\/hostedzone\//, "");
409 const routerHost = `wave3-router.${zoneName}`;
410 const siteHost = `wave3-docs.${zoneName}`;
411 const redirectHost = `wave3-old.${zoneName}`;
412
413 const routerOnly = Effect.gen(function* () {
414 const router = yield* AWS.Website.Router("Router", {
415 domain: { name: routerHost, hostedZoneId },
416 // Wave-2 deferred gated check: default-domain requests 301 to
417 // the canonical domain.
418 cloudfrontUrl: false,
419 invalidation: { paths: "all", wait: true },
420 });
421 return { router };
422 });
423
424 const withSite = Effect.gen(function* () {
425 const { router } = yield* routerOnly;
426 const site = yield* AWS.Website.StaticSite("DocsSite", {
427 path: fixtureDir,
428 forceDestroy: true,
429 domain: {
430 name: siteHost,
431 // Wave-2 deferred gated check: redirect hostnames 301 with
432 // path + query preserved.
433 redirects: [redirectHost],
434 router,
435 },
436 });
437 return { router, site };
438 });
439
440 const deployed = yield* stack.deploy(withSite);
441 const distributionId = deployed.router.distribution
442 .distributionId as string;
443
444 // Distribution aliases: the router's own hostname plus the site's
445 // bound hostnames (canonical + redirect).
446 const config = yield* cloudfront.getDistributionConfig({
447 Id: distributionId,
448 });
449 const aliases = config.DistributionConfig?.Aliases?.Items ?? [];
450 expect(aliases).toContain(routerHost);
451 expect(aliases).toContain(siteHost);
452 expect(aliases).toContain(redirectHost);
453
454 // Certificate: issued, SANs cover the bound hostnames.
455 const certificateArn = (deployed.router.certificate as any)
456 .certificateArn as string;
457 const certificate = yield* acm.describeCertificate({
458 CertificateArn: certificateArn,
459 });
460 expect(certificate.Certificate?.Status).toBe("ISSUED");
461 const sans = certificate.Certificate?.SubjectAlternativeNames ?? [];
462 expect(sans).toContain(siteHost);
463 expect(sans).toContain(redirectHost);
464
465 // Route 53: A-alias records exist for the bound hostnames.
466 const recordFor = (name: string) =>
467 route53
468 .listResourceRecordSets({
469 HostedZoneId: hostedZoneId,
470 StartRecordName: `${name}.`,
471 StartRecordType: "A",
472 MaxItems: 5,
473 })
474 .pipe(
475 Effect.map((response) =>
476 (response.ResourceRecordSets ?? []).find(
477 (recordSet) =>
478 recordSet.Name === `${name}.` && recordSet.Type === "A",
479 ),
480 ),
481 );
482 expect(yield* recordFor(siteHost)).toBeDefined();
483 expect(yield* recordFor(redirectHost)).toBeDefined();
484
485 // Manual-redirect fetch (the platform HttpClient follows redirects,
486 // which would hide the 301s under test). Retries ride out DNS/cert/
487 // edge propagation on freshly-created hostnames.
488 const fetchManual = (url: string) =>
489 Effect.tryPromise(async (signal) => {
490 const response = await fetch(url, {
491 signal,
492 redirect: "manual",
493 cache: "no-store",
494 headers: { "cache-control": "no-cache" },
495 });
496 return {
497 status: response.status,
498 location: response.headers.get("location"),
499 };
500 });
501 const expectResponse = (
502 url: string,
503 check: (response: {
504 status: number;
505 location: string | null;
506 }) => boolean,
507 ) =>
508 fetchManual(url).pipe(
509 Effect.flatMap((response) =>
510 check(response)
511 ? Effect.succeed(response)
512 : Effect.fail(
513 new Error(
514 `unexpected response from ${url}: ${response.status} -> ${response.location}`,
515 ),
516 ),
517 ),
518 Effect.retry({
519 schedule: Schedule.exponential("2 seconds"),
520 times: 12,
521 }),
522 );
523
524 // HTTPS: the site serves on its own hostname (edge + DNS + TLS).
525 yield* expectResponse(
526 `https://${siteHost}/`,
527 (response) => response.status === 200,
528 );
529
530 // Redirect hostname 301s to the canonical site hostname, path and
531 // query preserved.
532 yield* expectResponse(
533 `https://${redirectHost}/some/path?q=1`,
534 (response) =>
535 response.status === 301 &&
536 response.location === `https://${siteHost}/some/path?q=1`,
537 );
538
539 // cloudfrontUrl:false — default-domain requests 301 to the
540 // canonical router domain.
541 const defaultDomain = deployed.router.distribution
542 .domainName as string;
543 yield* expectResponse(
544 `https://${defaultDomain}/x?y=2`,
545 (response) =>
546 response.status === 301 &&
547 response.location === `https://${routerHost}/x?y=2`,
548 );
549
550 // Removal path: redeploy without the site — the bound hostnames must
551 // leave the distribution aliases, the certificate is replaced
552 // (create-first) with the shrunk SAN set, and the DNS records are
553 // garbage-collected from the record set.
554 const shrunk = yield* stack.deploy(routerOnly);
555 const shrunkConfig = yield* cloudfront.getDistributionConfig({
556 Id: shrunk.router.distribution.distributionId as string,
557 });
558 const shrunkAliases =
559 shrunkConfig.DistributionConfig?.Aliases?.Items ?? [];
560 expect(shrunkAliases).toContain(routerHost);
561 expect(shrunkAliases).not.toContain(siteHost);
562 expect(shrunkAliases).not.toContain(redirectHost);
563 const shrunkCertificateArn = (shrunk.router.certificate as any)
564 .certificateArn as string;
565 expect(shrunkCertificateArn).not.toBe(certificateArn);
566 expect(yield* recordFor(siteHost)).toBeUndefined();
567 expect(yield* recordFor(redirectHost)).toBeUndefined();
568
569 yield* stack.destroy();
570 }),
571 // Two full CloudFront distribution deployments + two ACM issuances +
572 // disable→wait→delete on destroy. Same order of budget as the Router
573 // live lifecycle test (which needed 2400s), plus the cert swap.
574 { timeout: 3_000_000 },
575 );
576 },
577);