puls-dev 0.3.6 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +11 -11
  2. package/dist/bin/install-shell.js +5 -6
  3. package/dist/bin/puls.js +10 -3
  4. package/dist/core/config.d.ts +4 -0
  5. package/dist/core/decorators.d.ts +4 -0
  6. package/dist/core/decorators.js +2 -0
  7. package/dist/core/parallel.test.js +4 -3
  8. package/dist/core/resource.d.ts +2 -1
  9. package/dist/core/resource.js +4 -2
  10. package/dist/core/stack.d.ts +4 -0
  11. package/dist/core/stack.js +8 -8
  12. package/dist/providers/aws/acm.test.d.ts +1 -0
  13. package/dist/providers/aws/acm.test.js +167 -0
  14. package/dist/providers/aws/cloudfront.test.d.ts +1 -0
  15. package/dist/providers/aws/cloudfront.test.js +170 -0
  16. package/dist/providers/aws/fargate.test.d.ts +1 -0
  17. package/dist/providers/aws/fargate.test.js +244 -0
  18. package/dist/providers/aws/rds.test.d.ts +1 -0
  19. package/dist/providers/aws/rds.test.js +219 -0
  20. package/dist/providers/aws/sqs.test.d.ts +1 -0
  21. package/dist/providers/aws/sqs.test.js +181 -0
  22. package/dist/providers/cloudflare/api.d.ts +15 -0
  23. package/dist/providers/cloudflare/api.js +199 -0
  24. package/dist/providers/cloudflare/index.d.ts +14 -0
  25. package/dist/providers/cloudflare/index.js +19 -0
  26. package/dist/providers/cloudflare/kv.d.ts +20 -0
  27. package/dist/providers/cloudflare/kv.js +69 -0
  28. package/dist/providers/cloudflare/kv.test.d.ts +1 -0
  29. package/dist/providers/cloudflare/kv.test.js +134 -0
  30. package/dist/providers/cloudflare/r2.d.ts +14 -0
  31. package/dist/providers/cloudflare/r2.js +57 -0
  32. package/dist/providers/cloudflare/r2.test.d.ts +1 -0
  33. package/dist/providers/cloudflare/r2.test.js +132 -0
  34. package/dist/providers/cloudflare/worker.d.ts +28 -0
  35. package/dist/providers/cloudflare/worker.js +172 -0
  36. package/dist/providers/cloudflare/worker.test.d.ts +1 -0
  37. package/dist/providers/cloudflare/worker.test.js +220 -0
  38. package/dist/providers/cloudflare/zone.d.ts +42 -0
  39. package/dist/providers/cloudflare/zone.js +280 -0
  40. package/dist/providers/cloudflare/zone.test.d.ts +1 -0
  41. package/dist/providers/cloudflare/zone.test.js +284 -0
  42. package/dist/providers/firebase/auth.test.d.ts +1 -0
  43. package/dist/providers/firebase/auth.test.js +145 -0
  44. package/dist/providers/firebase/hosting.test.js +7 -6
  45. package/dist/providers/firebase/storage.test.d.ts +1 -0
  46. package/dist/providers/firebase/storage.test.js +148 -0
  47. package/package.json +6 -2
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # Pulsdev.io
2
2
 
3
- **Intent-driven infrastructure-as-code. Describe what you want Puls figures out create, update, or skip.**
3
+ **Intent-driven infrastructure-as-code. Describe what you want- Puls figures out create, update, or skip.**
4
4
 
5
5
  [Live Documentation](https://pulsdev.io/) | [GitHub Actions](docs/github-actions.md) | Matrix|Gitter: **pulsdev.io** ([Join](https://matrix.to/#/#pulsdevio:gitter.im))
6
6
 
7
7
  > [!IMPORTANT]
8
8
  > **Active Pre-1.0 Development**
9
- > `pulsdev.io` is under active development. APIs and features are evolving we welcome feedback, bug reports, and contributions!
9
+ > `pulsdev.io` is under active development. APIs and features are evolving- we welcome feedback, bug reports, and contributions!
10
10
 
11
11
  ```typescript
12
12
  @Deploy({ proxmox: CONFIG.STAGING })
@@ -20,7 +20,7 @@ class GameInfra extends Stack {
20
20
  }
21
21
  ```
22
22
 
23
- No state files. No plan step. Runs against real APIs idempotent by default.
23
+ No state files. No plan step. Runs against real APIs- idempotent by default.
24
24
 
25
25
  ---
26
26
 
@@ -34,7 +34,7 @@ Declare resource → Discovery fires immediately (async)
34
34
  → deploy() awaits discovery, diffs, acts
35
35
  ```
36
36
 
37
- Running the same stack twice is always safe existing resources are detected and skipped or updated in place.
37
+ Running the same stack twice is always safe- existing resources are detected and skipped or updated in place.
38
38
 
39
39
  ---
40
40
 
@@ -44,7 +44,7 @@ Running the same stack twice is always safe — existing resources are detected
44
44
  npm install puls-dev
45
45
  ```
46
46
 
47
- **One-time shell setup** so you never have to type `npx puls` again:
47
+ **One-time shell setup**- so you never have to type `npx puls` again:
48
48
 
49
49
  ```bash
50
50
  npx puls install-shell
@@ -57,7 +57,7 @@ This adds a `puls` launcher to `~/.puls/bin` and wires it into your shell config
57
57
  ## CLI
58
58
 
59
59
  ```bash
60
- puls plan infra/stack.ts # dry-run prints what would change, no API writes
60
+ puls plan infra/stack.ts # dry-run- prints what would change, no API writes
61
61
  puls deploy infra/stack.ts # apply the stack
62
62
  puls destroy infra/stack.ts # tear down the stack
63
63
  puls diff infra/stack.ts # compare declared intent vs live cloud state
@@ -67,7 +67,7 @@ puls install-shell # one-time shell setup
67
67
  puls uninstall-shell # remove shell integration
68
68
  ```
69
69
 
70
- Always run `plan` before `deploy` it activates dry-run mode automatically.
70
+ Always run `plan` before `deploy`- it activates dry-run mode automatically.
71
71
 
72
72
  ---
73
73
 
@@ -155,7 +155,7 @@ class StagingInfra extends Stack {
155
155
 
156
156
  ### Drift detection
157
157
 
158
- `Stack.diff()` compares every declared resource against its live cloud state no API writes, structured output:
158
+ `Stack.diff()` compares every declared resource against its live cloud state- no API writes, structured output:
159
159
 
160
160
  ```bash
161
161
  puls diff infra/production.ts
@@ -211,7 +211,7 @@ class Infra extends Stack {
211
211
  }
212
212
  ```
213
213
 
214
- Outputs resolve lazily downstream resources unblock the moment their dependency finishes deploying.
214
+ Outputs resolve lazily- downstream resources unblock the moment their dependency finishes deploying.
215
215
 
216
216
  ### Dry run / plan
217
217
 
@@ -220,7 +220,7 @@ Outputs resolve lazily — downstream resources unblock the moment their depende
220
220
  class MyStack extends Stack { ... }
221
221
  ```
222
222
 
223
- Or via the CLI: `puls plan infra/stack.ts` no config change required.
223
+ Or via the CLI: `puls plan infra/stack.ts`- no config change required.
224
224
 
225
225
  ### Protected resources
226
226
 
@@ -240,7 +240,7 @@ db = Proxmox.VM("ix-db01")...; // Puls will refuse to modify or destroy this
240
240
  | `@Destroy` | Tear down all resources in the stack |
241
241
  | `@DryRun` | Shorthand for `@Deploy({ dryRun: true })` |
242
242
  | `@Protected` | Block changes/destruction of that resource |
243
- | `@Check` | Inventory query lists all live resources across providers |
243
+ | `@Check` | Inventory query- lists all live resources across providers |
244
244
 
245
245
  ---
246
246
 
@@ -95,11 +95,11 @@ export function uninstallShell() {
95
95
  fs.rmdirSync(path.join(home, ".puls"));
96
96
  }
97
97
  catch {
98
- // Non-empty dirs left behind (user may have other files) that's fine
98
+ // Non-empty dirs left behind (user may have other files)- that's fine
99
99
  }
100
100
  }
101
101
  else {
102
- console.log(` Launcher not found at ${launcherPath} nothing to remove.`);
102
+ console.log(` Launcher not found at ${launcherPath}- nothing to remove.`);
103
103
  }
104
104
  // 2. Remove PATH line from shell config
105
105
  const detected = detectShellConfig();
@@ -109,12 +109,12 @@ export function uninstallShell() {
109
109
  }
110
110
  const { configFile } = detected;
111
111
  if (!fs.existsSync(configFile)) {
112
- console.log(` Shell config not found at ${configFile} nothing to clean up.`);
112
+ console.log(` Shell config not found at ${configFile}- nothing to clean up.`);
113
113
  return;
114
114
  }
115
115
  const content = fs.readFileSync(configFile, "utf8");
116
116
  if (!content.includes(MARKER)) {
117
- console.log(` Shell config at ${configFile} has no puls entry nothing to remove.`);
117
+ console.log(` Shell config at ${configFile} has no puls entry- nothing to remove.`);
118
118
  return;
119
119
  }
120
120
  // Remove the marker line and the PATH line that follows it
@@ -127,8 +127,7 @@ export function uninstallShell() {
127
127
  return { out: acc.out, skip: false }; // skip the PATH line
128
128
  return { out: [...acc.out, line], skip: false };
129
129
  }, { out: [], skip: false })
130
- .out
131
- .join("\n")
130
+ .out.join("\n")
132
131
  .replace(/\n{3,}/g, "\n\n"); // collapse triple+ blank lines
133
132
  fs.writeFileSync(configFile, cleaned, "utf8");
134
133
  console.log(`✅ Removed puls PATH entry from ${configFile}`);
package/dist/bin/puls.js CHANGED
@@ -48,7 +48,7 @@ Options:
48
48
  --help Print this help and exit
49
49
 
50
50
  Examples:
51
- npx puls install-shell # one-time setup then just use "puls" directly
51
+ npx puls install-shell # one-time setup- then just use "puls" directly
52
52
  puls plan infra/staging.ts
53
53
  puls deploy infra/staging.ts --parallel
54
54
  puls destroy infra/staging.ts
@@ -84,12 +84,19 @@ if (values.help || positionals.length === 0) {
84
84
  process.exit(0);
85
85
  }
86
86
  const [command, userFile] = positionals;
87
- const COMMANDS = ["plan", "deploy", "destroy", "diff", "install-shell", "uninstall-shell"];
87
+ const COMMANDS = [
88
+ "plan",
89
+ "deploy",
90
+ "destroy",
91
+ "diff",
92
+ "install-shell",
93
+ "uninstall-shell",
94
+ ];
88
95
  if (!COMMANDS.includes(command)) {
89
96
  console.error(`Error: Unknown command "${command}". Run "puls --help" for usage.`);
90
97
  process.exit(1);
91
98
  }
92
- // Shell management commands run directly no stack file needed
99
+ // Shell management commands run directly- no stack file needed
93
100
  if (command === "install-shell") {
94
101
  installShell();
95
102
  process.exit(0);
@@ -36,6 +36,10 @@ export interface GlobalConfig {
36
36
  region?: string;
37
37
  sshUser?: string;
38
38
  };
39
+ cloudflare?: {
40
+ token: string;
41
+ accountId?: string;
42
+ };
39
43
  };
40
44
  }
41
45
  declare class ConfigManager {
@@ -19,6 +19,10 @@ type ProviderOpts = {
19
19
  verifySsl?: boolean;
20
20
  sshUser?: string;
21
21
  };
22
+ cloudflare?: {
23
+ token: string;
24
+ accountId?: string;
25
+ };
22
26
  };
23
27
  export declare function Protected(target: any, propertyKey: string): void;
24
28
  export declare function ForceConfigCheck(target: any, propertyKey: string): void;
@@ -15,6 +15,8 @@ function applyConfig(opts) {
15
15
  Config.set({ providers: { aws: { region: opts.region } } });
16
16
  if (opts.proxmox)
17
17
  Config.set({ providers: { proxmox: opts.proxmox } });
18
+ if (opts.cloudflare)
19
+ Config.set({ providers: { cloudflare: opts.cloudflare } });
18
20
  if (opts.firebase) {
19
21
  const sa = JSON.parse(readFileSync(opts.firebase, "utf8"));
20
22
  Config.set({
@@ -201,15 +201,16 @@ describe("Parallel Resource Deployment Unit Tests", () => {
201
201
  assert.strictEqual(logs.includes("start:r2"), false);
202
202
  });
203
203
  test("decorator option propagation sets configuration values", async () => {
204
- // Clear parallel flag
205
204
  Config.set({ parallel: false });
206
- // We define a decorated simple stack
207
205
  let SimpleDecoStack = class SimpleDecoStack extends Stack {
208
206
  };
209
207
  SimpleDecoStack = __decorate([
210
208
  Deploy({ parallel: true })
211
209
  ], SimpleDecoStack);
212
- // Verify decorator correctly updated global configuration to true
213
210
  assert.strictEqual(Config.isParallelActive(), true);
211
+ // @Deploy fires an async stack.deploy() that escapes this test's scope.
212
+ // Waiting here lets it complete before the test runner serializes results,
213
+ // preventing IPC stream corruption on the way back to the parent process.
214
+ await new Promise(resolve => setTimeout(resolve, 50));
214
215
  });
215
216
  });
@@ -29,7 +29,7 @@ export declare abstract class BaseBuilder {
29
29
  * naming convention).
30
30
  *
31
31
  * Outputs that depend on live API response fields (e.g. `out.host`) won't be
32
- * resolved automatically chain `.adoptOutput(key, value)` for each one you
32
+ * resolved automatically- chain `.adoptOutput(key, value)` for each one you
33
33
  * need for cross-stack wiring.
34
34
  */
35
35
  adoptId(id: string): this;
@@ -51,6 +51,7 @@ export declare abstract class BaseBuilder {
51
51
  * (no field-level diff available).
52
52
  */
53
53
  getDiff(_existing: any): FieldDiff[];
54
+ forceConfigCheck?(): void;
54
55
  adoptOutput(key: string, value: any): this;
55
56
  dryRun(enabled?: boolean): this;
56
57
  beforeDeploy(callback: () => Promise<void> | void): this;
@@ -39,7 +39,7 @@ export class BaseBuilder {
39
39
  * naming convention).
40
40
  *
41
41
  * Outputs that depend on live API response fields (e.g. `out.host`) won't be
42
- * resolved automatically chain `.adoptOutput(key, value)` for each one you
42
+ * resolved automatically- chain `.adoptOutput(key, value)` for each one you
43
43
  * need for cross-stack wiring.
44
44
  */
45
45
  adoptId(id) {
@@ -178,7 +178,9 @@ export class BaseBuilder {
178
178
  }
179
179
  async destroy() {
180
180
  const dryRun = this.isDryRunActive();
181
- const adoptedSuffix = this._adoptedId ? ` [adopted id=${this._adoptedId}]` : "";
181
+ const adoptedSuffix = this._adoptedId
182
+ ? ` [adopted id=${this._adoptedId}]`
183
+ : "";
182
184
  console.log(`\n🗑️ Destroying "${this.name}"${adoptedSuffix}...`);
183
185
  console.log(` ✅ [${dryRun ? "PLAN" : "OK"}] Resource "${this.name}" marked for destruction.`);
184
186
  await this.destroySidecars();
@@ -17,6 +17,10 @@ export declare abstract class Stack {
17
17
  * }
18
18
  */
19
19
  static from<T extends Stack>(cls: new (...args: any[]) => T, region?: string): T;
20
+ beforeDeploy?(): Promise<void> | void;
21
+ afterDeploy?(outputs: Record<string, any>): Promise<void> | void;
22
+ beforeDestroy?(): Promise<void> | void;
23
+ afterDestroy?(outputs: Record<string, any>): Promise<void> | void;
20
24
  /**
21
25
  * Compares every declared resource against its live cloud state without
22
26
  * making any API writes. Returns a structured `StackDiff` and prints a
@@ -237,7 +237,7 @@ export class Stack {
237
237
  return withRedactedConsole(secrets, async () => {
238
238
  console.log(`\n🏗️ Deploying Stack: ${this.constructor.name}`);
239
239
  // Stack-level beforeDeploy hook
240
- if (typeof this.beforeDeploy === "function") {
240
+ if (this.beforeDeploy) {
241
241
  console.log(` ⚡ Running Stack-level beforeDeploy hook...`);
242
242
  await this.beforeDeploy();
243
243
  }
@@ -255,8 +255,8 @@ export class Stack {
255
255
  if (isProtected)
256
256
  val.protect();
257
257
  const forceConfigCheck = Reflect.getMetadata("forceConfigCheck", this, prop);
258
- if (forceConfigCheck && typeof val.forceConfigCheck === "function") {
259
- val.forceConfigCheck();
258
+ if (forceConfigCheck) {
259
+ val.forceConfigCheck?.();
260
260
  }
261
261
  }
262
262
  else if (Array.isArray(val)) {
@@ -267,8 +267,8 @@ export class Stack {
267
267
  resources.push({ prop, resource: item });
268
268
  if (isProtected)
269
269
  item.protect();
270
- if (forceConfigCheck && typeof item.forceConfigCheck === "function") {
271
- item.forceConfigCheck();
270
+ if (forceConfigCheck) {
271
+ item.forceConfigCheck?.();
272
272
  }
273
273
  }
274
274
  }
@@ -373,7 +373,7 @@ export class Stack {
373
373
  }
374
374
  printOutputs(this.constructor.name, outputs);
375
375
  // Stack-level afterDeploy hook
376
- if (typeof this.afterDeploy === "function") {
376
+ if (this.afterDeploy) {
377
377
  console.log(` ⚡ Running Stack-level afterDeploy hook...`);
378
378
  await this.afterDeploy(outputs);
379
379
  }
@@ -395,7 +395,7 @@ export class Stack {
395
395
  return withRedactedConsole(secrets, async () => {
396
396
  console.log(`\n💥 Tearing down Stack: ${this.constructor.name}`);
397
397
  // Stack-level beforeDestroy hook
398
- if (typeof this.beforeDestroy === "function") {
398
+ if (this.beforeDestroy) {
399
399
  console.log(` ⚡ Running Stack-level beforeDestroy hook...`);
400
400
  await this.beforeDestroy();
401
401
  }
@@ -507,7 +507,7 @@ export class Stack {
507
507
  }
508
508
  printOutputs(this.constructor.name, outputs);
509
509
  // Stack-level afterDestroy hook
510
- if (typeof this.afterDestroy === "function") {
510
+ if (this.afterDestroy) {
511
511
  console.log(` ⚡ Running Stack-level afterDestroy hook...`);
512
512
  await this.afterDestroy(outputs);
513
513
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,167 @@
1
+ import { test, describe, beforeEach, afterEach, mock } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { ACMClient } from '@aws-sdk/client-acm';
4
+ import { Route53Client } from '@aws-sdk/client-route-53';
5
+ import { ACMCertificateBuilder } from './acm.js';
6
+ import { Config } from '../../core/config.js';
7
+ describe('ACMCertificateBuilder Unit Tests', () => {
8
+ let originalAcmSend;
9
+ let originalR53Send;
10
+ let acmCalls = [];
11
+ let r53Calls = [];
12
+ let mockAcmResponses = {};
13
+ let mockR53Responses = {};
14
+ function stubSend(calls, responses) {
15
+ return async function (command) {
16
+ const commandName = command.constructor.name;
17
+ calls.push({ commandName, input: command.input });
18
+ const handler = responses[commandName];
19
+ if (handler) {
20
+ if (typeof handler === 'function')
21
+ return handler(command.input);
22
+ if (handler instanceof Error)
23
+ throw handler;
24
+ return handler;
25
+ }
26
+ return {};
27
+ };
28
+ }
29
+ beforeEach(() => {
30
+ Config.set({ dryRun: false, providers: { aws: { region: 'us-east-1' } } });
31
+ acmCalls = [];
32
+ r53Calls = [];
33
+ mockAcmResponses = {};
34
+ mockR53Responses = {};
35
+ originalAcmSend = ACMClient.prototype.send;
36
+ originalR53Send = Route53Client.prototype.send;
37
+ ACMClient.prototype.send = stubSend(acmCalls, mockAcmResponses);
38
+ Route53Client.prototype.send = stubSend(r53Calls, mockR53Responses);
39
+ });
40
+ afterEach(() => {
41
+ ACMClient.prototype.send = originalAcmSend;
42
+ Route53Client.prototype.send = originalR53Send;
43
+ mock.restoreAll();
44
+ });
45
+ test('returns null when no matching certificate is found', async () => {
46
+ mockAcmResponses['ListCertificatesCommand'] = { CertificateSummaryList: [] };
47
+ const builder = new ACMCertificateBuilder('example.com');
48
+ const result = await builder.discoveryPromise;
49
+ assert.strictEqual(result, null);
50
+ assert.strictEqual(acmCalls[0].commandName, 'ListCertificatesCommand');
51
+ });
52
+ test('discovers existing wildcard certificate by domain name', async () => {
53
+ mockAcmResponses['ListCertificatesCommand'] = {
54
+ CertificateSummaryList: [
55
+ {
56
+ DomainName: '*.example.com',
57
+ CertificateArn: 'arn:aws:acm:us-east-1:123:certificate/abc',
58
+ },
59
+ ],
60
+ };
61
+ const builder = new ACMCertificateBuilder('example.com', true);
62
+ const result = await builder.discoveryPromise;
63
+ assert.ok(result);
64
+ assert.strictEqual(builder.resolvedArn, 'arn:aws:acm:us-east-1:123:certificate/abc');
65
+ });
66
+ test('returns existing cert without requesting a new one', async () => {
67
+ mockAcmResponses['ListCertificatesCommand'] = {
68
+ CertificateSummaryList: [
69
+ {
70
+ DomainName: '*.example.com',
71
+ CertificateArn: 'arn:aws:acm:us-east-1:123:certificate/abc',
72
+ Status: 'ISSUED',
73
+ },
74
+ ],
75
+ };
76
+ const builder = new ACMCertificateBuilder('example.com');
77
+ const result = await builder.deploy();
78
+ assert.strictEqual(result.arn, 'arn:aws:acm:us-east-1:123:certificate/abc');
79
+ assert.ok(!acmCalls.some((c) => c.commandName === 'RequestCertificateCommand'));
80
+ });
81
+ test('performs dry-run without requesting a certificate', async () => {
82
+ Config.set({ dryRun: true, providers: { aws: { region: 'us-east-1' } } });
83
+ mockAcmResponses['ListCertificatesCommand'] = { CertificateSummaryList: [] };
84
+ const builder = new ACMCertificateBuilder('example.com');
85
+ const result = await builder.deploy();
86
+ assert.ok(result.arn.includes('DRYRUN'));
87
+ assert.ok(!acmCalls.some((c) => c.commandName === 'RequestCertificateCommand'));
88
+ });
89
+ test('requests wildcard cert and writes DNS validation records to Route53', async () => {
90
+ mockAcmResponses['ListCertificatesCommand'] = { CertificateSummaryList: [] };
91
+ mockAcmResponses['RequestCertificateCommand'] = {
92
+ CertificateArn: 'arn:aws:acm:us-east-1:123:certificate/new-cert',
93
+ };
94
+ let describeCallCount = 0;
95
+ mockAcmResponses['DescribeCertificateCommand'] = () => {
96
+ describeCallCount++;
97
+ if (describeCallCount === 1) {
98
+ // First call: validation records ready
99
+ return {
100
+ Certificate: {
101
+ Status: 'PENDING_VALIDATION',
102
+ DomainValidationOptions: [
103
+ {
104
+ ResourceRecord: {
105
+ Name: '_abc.example.com',
106
+ Value: '_xyz.acm-validations.aws.',
107
+ },
108
+ },
109
+ ],
110
+ },
111
+ };
112
+ }
113
+ // Second call: ISSUED
114
+ return { Certificate: { Status: 'ISSUED' } };
115
+ };
116
+ mockR53Responses['ChangeResourceRecordSetsCommand'] = {};
117
+ // Fast-forward poll timers
118
+ mock.method(global, 'setTimeout', (fn) => fn());
119
+ const builder = new ACMCertificateBuilder('example.com', true);
120
+ builder.forZone({ zoneId: 'Z123456', zoneName: 'example.com' });
121
+ const result = await builder.deploy();
122
+ assert.strictEqual(result.arn, 'arn:aws:acm:us-east-1:123:certificate/new-cert');
123
+ const requestCall = acmCalls.find((c) => c.commandName === 'RequestCertificateCommand');
124
+ assert.ok(requestCall);
125
+ assert.strictEqual(requestCall.input.DomainName, '*.example.com');
126
+ assert.strictEqual(requestCall.input.ValidationMethod, 'DNS');
127
+ const r53Call = r53Calls.find((c) => c.commandName === 'ChangeResourceRecordSetsCommand');
128
+ assert.ok(r53Call);
129
+ assert.strictEqual(r53Call.input.HostedZoneId, 'Z123456');
130
+ const changes = r53Call.input.ChangeBatch.Changes;
131
+ assert.strictEqual(changes.length, 1);
132
+ assert.strictEqual(changes[0].Action, 'UPSERT');
133
+ assert.ok(changes[0].ResourceRecordSet.Name.includes('_abc'));
134
+ });
135
+ test('deduplicates validation CNAME records before writing to Route53', async () => {
136
+ mockAcmResponses['ListCertificatesCommand'] = { CertificateSummaryList: [] };
137
+ mockAcmResponses['RequestCertificateCommand'] = {
138
+ CertificateArn: 'arn:aws:acm:us-east-1:123:certificate/new-cert',
139
+ };
140
+ let describeCount = 0;
141
+ mockAcmResponses['DescribeCertificateCommand'] = () => {
142
+ describeCount++;
143
+ if (describeCount === 1) {
144
+ return {
145
+ Certificate: {
146
+ Status: 'PENDING_VALIDATION',
147
+ // wildcard + apex SANs produce the same CNAME - duplicated
148
+ DomainValidationOptions: [
149
+ { ResourceRecord: { Name: '_dup.example.com', Value: '_val.aws.' } },
150
+ { ResourceRecord: { Name: '_dup.example.com', Value: '_val.aws.' } },
151
+ ],
152
+ },
153
+ };
154
+ }
155
+ return { Certificate: { Status: 'ISSUED' } };
156
+ };
157
+ mockR53Responses['ChangeResourceRecordSetsCommand'] = {};
158
+ mock.method(global, 'setTimeout', (fn) => fn());
159
+ const builder = new ACMCertificateBuilder('example.com', true);
160
+ builder.forZone({ zoneId: 'Z123456', zoneName: 'example.com' });
161
+ await builder.deploy();
162
+ const r53Call = r53Calls.find((c) => c.commandName === 'ChangeResourceRecordSetsCommand');
163
+ assert.ok(r53Call);
164
+ // Should be deduplicated to 1 record
165
+ assert.strictEqual(r53Call.input.ChangeBatch.Changes.length, 1);
166
+ });
167
+ });
@@ -0,0 +1 @@
1
+ export {};