@grada-run/grada 0.0.1 → 0.33.0
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.
- package/.github/workflows/deploy-docs.yml +37 -0
- package/.github/workflows/e2e.yml +73 -0
- package/.github/workflows/iac-validation.yml +303 -0
- package/.github/workflows/publish.yml +68 -0
- package/.github/workflows/sync-bedrock-models.yml +57 -0
- package/.github/workflows/test.yml +43 -0
- package/.muserules +31 -0
- package/LICENSE +21 -0
- package/README.md +193 -3
- package/apps/docs/.astro/collections/docs.schema.json +644 -0
- package/apps/docs/.astro/content-assets.mjs +4 -0
- package/apps/docs/.astro/content-modules.mjs +4 -0
- package/apps/docs/.astro/content.d.ts +179 -0
- package/apps/docs/.astro/data-store.json +1 -0
- package/apps/docs/.astro/dev.json +14 -0
- package/apps/docs/.astro/settings.json +5 -0
- package/apps/docs/.astro/types.d.ts +2 -0
- package/apps/docs/astro.config.mjs +97 -0
- package/apps/docs/package.json +17 -0
- package/apps/docs/src/content/docs/adrs/0001-s3-native-state-locking.md +37 -0
- package/apps/docs/src/content/docs/adrs/0002-eject-mechanism-pure-iac.md +39 -0
- package/apps/docs/src/content/docs/adrs/0003-sync-ai-context-strategy.md +48 -0
- package/apps/docs/src/content/docs/adrs/0004-iac-driven-diagnostic-context.md +37 -0
- package/apps/docs/src/content/docs/adrs/0005-ecs-fargate-alb-runtime-target.md +38 -0
- package/apps/docs/src/content/docs/adrs/0006-github-oidc-no-stored-keys.md +37 -0
- package/apps/docs/src/content/docs/adrs/0007-framework-detection-with-fallback.md +37 -0
- package/apps/docs/src/content/docs/adrs/0008-secrets-names-in-git-values-in-aws.md +37 -0
- package/apps/docs/src/content/docs/adrs/0009-regenerate-with-backup-on-rerun.md +37 -0
- package/apps/docs/src/content/docs/adrs/0010-advisory-only-security-scans.md +37 -0
- package/apps/docs/src/content/docs/cli/add.md +85 -0
- package/apps/docs/src/content/docs/cli/apply.md +35 -0
- package/apps/docs/src/content/docs/cli/db.md +200 -0
- package/apps/docs/src/content/docs/cli/destroy.md +35 -0
- package/apps/docs/src/content/docs/cli/diagnose.md +37 -0
- package/apps/docs/src/content/docs/cli/doctor.md +28 -0
- package/apps/docs/src/content/docs/cli/domain.md +57 -0
- package/apps/docs/src/content/docs/cli/drift.md +40 -0
- package/apps/docs/src/content/docs/cli/eject.md +33 -0
- package/apps/docs/src/content/docs/cli/exec.md +49 -0
- package/apps/docs/src/content/docs/cli/gc.md +37 -0
- package/apps/docs/src/content/docs/cli/init.md +72 -0
- package/apps/docs/src/content/docs/cli/logs.md +39 -0
- package/apps/docs/src/content/docs/cli/rollback.md +51 -0
- package/apps/docs/src/content/docs/cli/secrets.md +73 -0
- package/apps/docs/src/content/docs/cli/sleep.md +53 -0
- package/apps/docs/src/content/docs/cli/status.md +34 -0
- package/apps/docs/src/content/docs/cli/sync-ai.md +27 -0
- package/apps/docs/src/content/docs/guides/architecture.md +87 -0
- package/apps/docs/src/content/docs/guides/aws-credentials.md +72 -0
- package/apps/docs/src/content/docs/guides/background-workers.md +45 -0
- package/apps/docs/src/content/docs/guides/cicd-pipeline.md +64 -0
- package/apps/docs/src/content/docs/guides/database-connections.md +64 -0
- package/apps/docs/src/content/docs/guides/docker-compose.md +37 -0
- package/apps/docs/src/content/docs/guides/dockerfiles.md +46 -0
- package/apps/docs/src/content/docs/guides/ephemeral-pr-previews.md +39 -0
- package/apps/docs/src/content/docs/guides/examples.md +50 -0
- package/apps/docs/src/content/docs/guides/frameworks.md +88 -0
- package/apps/docs/src/content/docs/guides/headless.md +87 -0
- package/apps/docs/src/content/docs/guides/quickstart.md +52 -0
- package/apps/docs/src/content/docs/guides/rerun-init.md +43 -0
- package/apps/docs/src/content/docs/guides/secrets-management.md +83 -0
- package/apps/docs/src/content/docs/guides/understanding-your-bill.md +63 -0
- package/apps/docs/src/content/docs/index.mdx +103 -0
- package/apps/docs/src/content/docs/migrations/astro-vercel-to-aws.md +55 -0
- package/apps/docs/src/content/docs/migrations/heroku-procfile-to-aws.md +41 -0
- package/apps/docs/src/content/docs/migrations/nextjs-vercel-to-aws.md +51 -0
- package/apps/docs/src/content/docs/migrations/sveltekit-vercel-to-aws.md +63 -0
- package/apps/docs/src/content/docs/roadmap.md +99 -0
- package/apps/docs/src/content/docs/testing-strategy.md +37 -0
- package/apps/docs/src/content.config.ts +7 -0
- package/apps/docs/src/custom.css +14 -0
- package/apps/docs/tsconfig.json +6 -0
- package/bin/cli.js +140 -0
- package/package.json +107 -7
- package/scripts/sync-bedrock-models.js +22 -0
- package/scripts/test-iac.js +261 -0
- package/specs/add-redis-sqs-bedrock.md +128 -0
- package/specs/add-storage-dynamodb.md +106 -0
- package/specs/bedrock-model-catalog.md +131 -0
- package/specs/ci-pipeline.md +17 -0
- package/specs/cost-transparency.md +115 -0
- package/specs/custom-domains-and-ses.md +153 -0
- package/specs/database-suite-expansion.md +151 -0
- package/specs/db-connect.md +69 -0
- package/specs/db-lifecycle-migrations.md +159 -0
- package/specs/dependency-aware-init.md +176 -0
- package/specs/deployment-safety.md +170 -0
- package/specs/diagnose.md +16 -0
- package/specs/docs-hub.md +16 -0
- package/specs/dx-polish.md +46 -0
- package/specs/e2e-testing.md +50 -0
- package/specs/exec.md +25 -0
- package/specs/finops-cron-drift.md +161 -0
- package/specs/gc.md +26 -0
- package/specs/integration-suite.md +16 -0
- package/specs/logs.md +32 -0
- package/specs/rollback-live-polling.md +40 -0
- package/specs/secrets-pull-audit.md +51 -0
- package/specs/serverless-lambda-target.md +133 -0
- package/specs/status.md +31 -0
- package/specs/telemetry-and-spawn-hardening.md +69 -0
- package/specs/telemetry-hardening.md +35 -0
- package/specs/test-suite-deduplication.md +42 -0
- package/src/commands/add.js +1111 -0
- package/src/commands/apply.js +214 -0
- package/src/commands/db/backup.js +229 -0
- package/src/commands/db/connect.js +304 -0
- package/src/commands/db/enable-vector.js +344 -0
- package/src/commands/db/import.js +604 -0
- package/src/commands/db/migrate.js +477 -0
- package/src/commands/db/restore.js +361 -0
- package/src/commands/db.js +87 -0
- package/src/commands/destroy.js +217 -0
- package/src/commands/diagnose.js +460 -0
- package/src/commands/doctor.js +109 -0
- package/src/commands/domain.js +685 -0
- package/src/commands/drift.js +243 -0
- package/src/commands/eject.js +130 -0
- package/src/commands/exec.js +222 -0
- package/src/commands/gc.js +250 -0
- package/src/commands/init.js +649 -0
- package/src/commands/logs.js +256 -0
- package/src/commands/rollback.js +323 -0
- package/src/commands/secrets.js +485 -0
- package/src/commands/sleep.js +347 -0
- package/src/commands/status.js +309 -0
- package/src/commands/sync-ai.js +115 -0
- package/src/commands/wake.js +337 -0
- package/src/core/parser.js +128 -0
- package/src/core/telemetry.js +244 -0
- package/src/data/bedrock-models.json +896 -0
- package/src/utils/addons.js +126 -0
- package/src/utils/ai-rules.js +59 -0
- package/src/utils/args.js +91 -0
- package/src/utils/aws.js +178 -0
- package/src/utils/backup.js +69 -0
- package/src/utils/bedrock-catalog.js +511 -0
- package/src/utils/capabilities.js +500 -0
- package/src/utils/command.js +78 -0
- package/src/utils/db-tunnel.js +164 -0
- package/src/utils/detector.js +298 -0
- package/src/utils/dockerCompose.js +65 -0
- package/src/utils/domains.js +73 -0
- package/src/utils/ecs-runner.js +289 -0
- package/src/utils/ecs.js +92 -0
- package/src/utils/frameworks.js +55 -0
- package/src/utils/generator.js +527 -0
- package/src/utils/hcl.js +426 -0
- package/src/utils/lambda-ecr.js +185 -0
- package/src/utils/prompts.js +278 -0
- package/src/utils/rds.js +131 -0
- package/src/utils/resolvers.js +174 -0
- package/src/utils/sleep-state.js +140 -0
- package/src/utils/sleep-targets.js +139 -0
- package/src/utils/system.js +42 -0
- package/src/utils/terraform.js +70 -0
- package/src/utils/visualizer.js +381 -0
- package/src/utils/warnings.js +49 -0
- package/templates/README.md +150 -0
- package/templates/docker/django.Dockerfile +40 -0
- package/templates/docker/go.Dockerfile +23 -0
- package/templates/docker/nestjs.Dockerfile +33 -0
- package/templates/docker/nextjs.Dockerfile +55 -0
- package/templates/docker/node.Dockerfile +24 -0
- package/templates/docker/nuxt.Dockerfile +47 -0
- package/templates/docker/python.Dockerfile +38 -0
- package/templates/docker/rails.Dockerfile +59 -0
- package/templates/docker/static.Dockerfile +32 -0
- package/templates/docker/svelte.Dockerfile +52 -0
- package/templates/github/deploy-lambda.yml +120 -0
- package/templates/github/deploy.yml +138 -0
- package/templates/github/drift.yml +112 -0
- package/templates/github/preview-lambda.yml +86 -0
- package/templates/github/preview.yml +69 -0
- package/templates/github/teardown.yml +43 -0
- package/templates/terraform/addons/bedrock.tf +34 -0
- package/templates/terraform/addons/cron-lambda.tf +78 -0
- package/templates/terraform/addons/cron.tf +101 -0
- package/templates/terraform/addons/dynamodb.tf +73 -0
- package/templates/terraform/addons/redis.tf +64 -0
- package/templates/terraform/addons/s3.tf +143 -0
- package/templates/terraform/addons/ses.tf +73 -0
- package/templates/terraform/addons/sqs.tf +67 -0
- package/templates/terraform/backend.tf +22 -0
- package/templates/terraform/cloudfront-lambda.tf +80 -0
- package/templates/terraform/cloudfront.tf +80 -0
- package/templates/terraform/database-aurora-postgresql.tf +92 -0
- package/templates/terraform/database-mysql.tf +72 -0
- package/templates/terraform/database.tf +71 -0
- package/templates/terraform/main-lambda.tf +229 -0
- package/templates/terraform/main.tf +296 -0
- package/templates/terraform/network.tf +95 -0
- package/templates/terraform/oidc.tf +64 -0
- package/templates/terraform/secrets.tf +31 -0
- package/templates/terraform/worker.tf +69 -0
- package/tests/__snapshots__/generator.test.js.snap +9633 -0
- package/tests/add.test.js +2018 -0
- package/tests/ai.test.js +91 -0
- package/tests/apply.test.js +446 -0
- package/tests/args.test.js +86 -0
- package/tests/aws.test.js +244 -0
- package/tests/capabilities.test.js +304 -0
- package/tests/cli.test.js +29 -0
- package/tests/command.test.js +99 -0
- package/tests/commands-import.test.js +74 -0
- package/tests/db.test.js +2673 -0
- package/tests/destroy.test.js +365 -0
- package/tests/detector.test.js +79 -0
- package/tests/diagnose.test.js +765 -0
- package/tests/doctor.test.js +195 -0
- package/tests/domain.test.js +882 -0
- package/tests/drift.test.js +228 -0
- package/tests/e2e/helpers.js +122 -0
- package/tests/e2e/tier0.e2e.test.js +142 -0
- package/tests/e2e/tier1.live.e2e.test.js +118 -0
- package/tests/ecs.test.js +130 -0
- package/tests/eject.test.js +65 -0
- package/tests/exec.test.js +366 -0
- package/tests/gc.test.js +466 -0
- package/tests/generator.test.js +794 -0
- package/tests/headless.test.js +491 -0
- package/tests/helpers/clack.js +103 -0
- package/tests/helpers/console.js +41 -0
- package/tests/helpers/telemetry.js +37 -0
- package/tests/helpers/tmpdir.js +36 -0
- package/tests/lambda-ecr.test.js +185 -0
- package/tests/logs.test.js +436 -0
- package/tests/parser.test.js +160 -0
- package/tests/rds.test.js +244 -0
- package/tests/resolvers.test.js +279 -0
- package/tests/rollback.test.js +670 -0
- package/tests/secrets.test.js +729 -0
- package/tests/sleep-wake.test.js +998 -0
- package/tests/status.test.js +356 -0
- package/tests/system.test.js +70 -0
- package/tests/telemetry.test.js +520 -0
- package/tests/terraform.test.js +84 -0
- package/tests/visualizer.test.js +480 -0
- package/vitest.config.js +10 -0
- package/vitest.e2e.tier0.config.js +8 -0
- package/vitest.e2e.tier1.config.js +8 -0
- package/index.js +0 -2
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
// NOTE: helper imports must stay above src imports: vi.mock factories run
|
|
3
|
+
// during module evaluation and need the factories initialized.
|
|
4
|
+
import { clackPromptsMockFactory } from './helpers/clack.js';
|
|
5
|
+
import { telemetryMockFactory } from './helpers/telemetry.js';
|
|
6
|
+
import { createTmpDirTracker } from './helpers/tmpdir.js';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import os from 'os';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
11
|
+
import {
|
|
12
|
+
findResourceBlock,
|
|
13
|
+
findNestedBlock,
|
|
14
|
+
replaceBlock,
|
|
15
|
+
upsertAttribute,
|
|
16
|
+
removeAttribute,
|
|
17
|
+
getAttributeValue,
|
|
18
|
+
findArrayBounds,
|
|
19
|
+
enclosingBraceBounds,
|
|
20
|
+
} from '../src/utils/hcl.js';
|
|
21
|
+
import {
|
|
22
|
+
normalizeDomain,
|
|
23
|
+
isValidDomain,
|
|
24
|
+
normalizeZoneId,
|
|
25
|
+
isValidFromEmail,
|
|
26
|
+
parseDomainTf,
|
|
27
|
+
} from '../src/utils/domains.js';
|
|
28
|
+
import {
|
|
29
|
+
parseDomainArgs,
|
|
30
|
+
runDomain,
|
|
31
|
+
renderDomainTfRoute53,
|
|
32
|
+
renderDomainTfExternalPending,
|
|
33
|
+
appendValidationResource,
|
|
34
|
+
patchCloudFrontDomain,
|
|
35
|
+
unpatchCloudFrontDomain,
|
|
36
|
+
isCloudFrontWired,
|
|
37
|
+
ensureUsEast1Provider,
|
|
38
|
+
} from '../src/commands/domain.js';
|
|
39
|
+
import { trackEvent, flushTelemetry } from '../src/core/telemetry.js';
|
|
40
|
+
import { confirm, outro } from '@clack/prompts';
|
|
41
|
+
|
|
42
|
+
vi.mock('@clack/prompts', () => clackPromptsMockFactory());
|
|
43
|
+
|
|
44
|
+
vi.mock('../src/core/telemetry.js', (importOriginal) => telemetryMockFactory(importOriginal));
|
|
45
|
+
|
|
46
|
+
const tmp = createTmpDirTracker();
|
|
47
|
+
let exitSpy;
|
|
48
|
+
let logSpy;
|
|
49
|
+
|
|
50
|
+
function makeTmp() {
|
|
51
|
+
return tmp.makeTmp('domain-test-');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const CLOUDFRONT_TF = [
|
|
55
|
+
'resource "aws_cloudfront_distribution" "cdn" {',
|
|
56
|
+
' enabled = true',
|
|
57
|
+
' is_ipv6_enabled = true',
|
|
58
|
+
' wait_for_deployment = false',
|
|
59
|
+
'',
|
|
60
|
+
' origin {',
|
|
61
|
+
' domain_name = aws_lb.main.dns_name',
|
|
62
|
+
' origin_id = "ALBOrigin"',
|
|
63
|
+
'',
|
|
64
|
+
' custom_origin_config {',
|
|
65
|
+
' http_port = 80',
|
|
66
|
+
' origin_protocol_policy = "http-only"',
|
|
67
|
+
' }',
|
|
68
|
+
' }',
|
|
69
|
+
'',
|
|
70
|
+
' default_cache_behavior {',
|
|
71
|
+
' allowed_methods = ["GET", "HEAD"]',
|
|
72
|
+
' cached_methods = ["GET", "HEAD"]',
|
|
73
|
+
' target_origin_id = "ALBOrigin"',
|
|
74
|
+
' viewer_protocol_policy = "redirect-to-https"',
|
|
75
|
+
' }',
|
|
76
|
+
'',
|
|
77
|
+
' restrictions {',
|
|
78
|
+
' geo_restriction {',
|
|
79
|
+
' restriction_type = "none"',
|
|
80
|
+
' }',
|
|
81
|
+
' }',
|
|
82
|
+
'',
|
|
83
|
+
' viewer_certificate {',
|
|
84
|
+
' # Gives the user a free HTTPS *.cloudfront.net domain out of the box',
|
|
85
|
+
' cloudfront_default_certificate = true',
|
|
86
|
+
' }',
|
|
87
|
+
'}',
|
|
88
|
+
'',
|
|
89
|
+
'output "cloudfront_url" {',
|
|
90
|
+
' value = "https://${aws_cloudfront_distribution.cdn.domain_name}"',
|
|
91
|
+
'}',
|
|
92
|
+
'',
|
|
93
|
+
].join('\n');
|
|
94
|
+
|
|
95
|
+
function writeCloudfrontTf(dir, content = CLOUDFRONT_TF) {
|
|
96
|
+
fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
|
|
97
|
+
fs.writeFileSync(path.join(dir, 'terraform', 'cloudfront.tf'), content);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const MAIN_TF_NO_ALIAS = [
|
|
101
|
+
'provider "aws" {',
|
|
102
|
+
' region = "us-west-2"',
|
|
103
|
+
'}',
|
|
104
|
+
'',
|
|
105
|
+
].join('\n');
|
|
106
|
+
|
|
107
|
+
function writeMainTf(dir, content = MAIN_TF_NO_ALIAS) {
|
|
108
|
+
fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
|
|
109
|
+
fs.writeFileSync(path.join(dir, 'terraform', 'main.tf'), content);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function capturedOutput() {
|
|
113
|
+
return stripVTControlCharacters(logSpy.mock.calls.map((args) => String(args[0])).join('\n'));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
beforeEach(() => {
|
|
117
|
+
tmp.reset();
|
|
118
|
+
vi.clearAllMocks();
|
|
119
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
|
|
120
|
+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
afterEach(() => {
|
|
124
|
+
exitSpy.mockRestore();
|
|
125
|
+
logSpy.mockRestore();
|
|
126
|
+
tmp.cleanup();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('hcl: findResourceBlock', () => {
|
|
130
|
+
it('finds resource bounds and slices the exact block', () => {
|
|
131
|
+
const bounds = findResourceBlock(CLOUDFRONT_TF, 'aws_cloudfront_distribution', 'cdn');
|
|
132
|
+
expect(bounds).not.toBeNull();
|
|
133
|
+
const block = CLOUDFRONT_TF.slice(bounds.openIdx, bounds.closeIdx + 1);
|
|
134
|
+
expect(block.startsWith('{')).toBe(true);
|
|
135
|
+
expect(block).toContain('viewer_certificate');
|
|
136
|
+
expect(block.endsWith('}')).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('returns null for missing resources and unterminated blocks', () => {
|
|
140
|
+
expect(findResourceBlock(CLOUDFRONT_TF, 'aws_cloudfront_distribution', 'nope')).toBeNull();
|
|
141
|
+
expect(findResourceBlock('resource "aws_x" "y" {', 'aws_x', 'y')).toBeNull();
|
|
142
|
+
expect(findResourceBlock('', 'aws_x', 'y')).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('ignores braces inside strings and comments', () => {
|
|
146
|
+
const content = [
|
|
147
|
+
'resource "aws_x" "y" {',
|
|
148
|
+
' # a comment with { brace',
|
|
149
|
+
' value = "not a { block } end"',
|
|
150
|
+
' /* block comment } { */',
|
|
151
|
+
' real = true',
|
|
152
|
+
'}',
|
|
153
|
+
'trailing = "{"',
|
|
154
|
+
'',
|
|
155
|
+
].join('\n');
|
|
156
|
+
const bounds = findResourceBlock(content, 'aws_x', 'y');
|
|
157
|
+
expect(content.slice(bounds.openIdx, bounds.closeIdx + 1)).toContain('real = true');
|
|
158
|
+
expect(content[bounds.closeIdx + 1]).toBe('\n');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('hcl: findNestedBlock / replaceBlock', () => {
|
|
163
|
+
const block = CLOUDFRONT_TF.slice(
|
|
164
|
+
findResourceBlock(CLOUDFRONT_TF, 'aws_cloudfront_distribution', 'cdn').openIdx,
|
|
165
|
+
findResourceBlock(CLOUDFRONT_TF, 'aws_cloudfront_distribution', 'cdn').closeIdx + 1
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
it('finds a nested block by keyword', () => {
|
|
169
|
+
const viewer = findNestedBlock(block, 'viewer_certificate');
|
|
170
|
+
expect(viewer).not.toBeNull();
|
|
171
|
+
expect(block.slice(viewer.startIdx, viewer.openIdx + 1)).toContain('viewer_certificate {');
|
|
172
|
+
expect(block.slice(viewer.openIdx, viewer.closeIdx + 1)).toContain('cloudfront_default_certificate');
|
|
173
|
+
expect(findNestedBlock(block, 'aliases')).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('skips same-named identifiers inside deeper blocks and strings', () => {
|
|
177
|
+
const tricky = '{\n note = "viewer_certificate { fake"\n viewer_certificate {\n ok = true\n }\n}\n';
|
|
178
|
+
const viewer = findNestedBlock(tricky, 'viewer_certificate');
|
|
179
|
+
expect(tricky.slice(viewer.openIdx, viewer.closeIdx + 1)).toContain('ok = true');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('replaceBlock splices over the given bounds', () => {
|
|
183
|
+
const viewer = findNestedBlock(block, 'viewer_certificate');
|
|
184
|
+
const replaced = replaceBlock(block, viewer, ' viewer_certificate {\n x = 1\n }');
|
|
185
|
+
expect(replaced).toContain('x = 1');
|
|
186
|
+
expect(replaced).not.toContain('cloudfront_default_certificate');
|
|
187
|
+
expect(replaceBlock(block, null, 'x')).toBe(block);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('hcl: upsert/remove/get attribute', () => {
|
|
192
|
+
const block = '{\n enabled = true\n tags = {\n Name = "x"\n }\n}\n';
|
|
193
|
+
|
|
194
|
+
it('reads top-level attribute values including multi-line lists', () => {
|
|
195
|
+
expect(getAttributeValue(block, 'enabled')).toBe('true');
|
|
196
|
+
expect(getAttributeValue(block, 'Name')).toBeNull();
|
|
197
|
+
expect(getAttributeValue('{\n aliases = [\n "a.com",\n "b.com",\n ]\n}\n', 'aliases'))
|
|
198
|
+
.toContain('"a.com"');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('upserts in place and returns identical content when unchanged', () => {
|
|
202
|
+
const updated = upsertAttribute(block, 'enabled', 'false');
|
|
203
|
+
expect(updated).toContain('enabled = false');
|
|
204
|
+
expect(updated).not.toContain('enabled = true');
|
|
205
|
+
expect(upsertAttribute(block, 'enabled', 'true')).toBe(block);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('inserts missing attributes before the closing brace', () => {
|
|
209
|
+
const updated = upsertAttribute(block, 'aliases', '["a.com"]');
|
|
210
|
+
expect(updated).toContain(' aliases = ["a.com"]\n}');
|
|
211
|
+
expect(upsertAttribute('no braces here', 'a', 'b')).toBe('no braces here');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('removes attributes with their full multi-line values', () => {
|
|
215
|
+
const withAliases = '{\n enabled = true\n aliases = [\n "a.com",\n ]\n}\n';
|
|
216
|
+
const updated = removeAttribute(withAliases, 'aliases');
|
|
217
|
+
expect(updated).toBe('{\n enabled = true\n}\n');
|
|
218
|
+
expect(removeAttribute(block, 'missing')).toBe(block);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('hcl: findArrayBounds / enclosingBraceBounds', () => {
|
|
223
|
+
it('finds environment arrays inside task definitions', () => {
|
|
224
|
+
const content = [
|
|
225
|
+
'resource "aws_ecs_task_definition" "app" {',
|
|
226
|
+
' container_definitions = jsonencode([',
|
|
227
|
+
' {',
|
|
228
|
+
' environment = [',
|
|
229
|
+
' { "name": "A", "value": "1" }',
|
|
230
|
+
' ]',
|
|
231
|
+
' }',
|
|
232
|
+
' ])',
|
|
233
|
+
'}',
|
|
234
|
+
'',
|
|
235
|
+
].join('\n');
|
|
236
|
+
const bounds = findArrayBounds(content, 'environment');
|
|
237
|
+
expect(content.slice(bounds.openIdx, bounds.closeIdx + 1)).toContain('"name": "A"');
|
|
238
|
+
expect(findArrayBounds(content, 'missing')).toBeNull();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('finds the enclosing object for interpolation-heavy values', () => {
|
|
242
|
+
const text = '[{ name = "X", value = "redis://${aws_rg.redis.port}" }]';
|
|
243
|
+
const idx = text.indexOf('"X"');
|
|
244
|
+
const bounds = enclosingBraceBounds(text, idx);
|
|
245
|
+
expect(text.slice(bounds.openIdx, bounds.closeIdx + 1)).toContain('redis://');
|
|
246
|
+
expect(enclosingBraceBounds('[]', 1)).toBeNull();
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('domains: normalization and validation', () => {
|
|
251
|
+
it('normalizes domains (trim, trailing dot, lowercase)', () => {
|
|
252
|
+
expect(normalizeDomain(' Example.COM. ')).toBe('example.com');
|
|
253
|
+
expect(normalizeDomain('a.B-c.io')).toBe('a.b-c.io');
|
|
254
|
+
expect(normalizeDomain(null)).toBe('');
|
|
255
|
+
expect(normalizeDomain(42)).toBe('');
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('validates standard and wildcard domains', () => {
|
|
259
|
+
expect(isValidDomain('example.com')).toBe(true);
|
|
260
|
+
expect(isValidDomain('*.example.com')).toBe(true);
|
|
261
|
+
expect(isValidDomain('a.b-c.io')).toBe(true);
|
|
262
|
+
expect(isValidDomain('Example.COM.')).toBe(true);
|
|
263
|
+
expect(isValidDomain('not a domain')).toBe(false);
|
|
264
|
+
expect(isValidDomain('no-tld')).toBe(false);
|
|
265
|
+
expect(isValidDomain('*.com')).toBe(false);
|
|
266
|
+
expect(isValidDomain('*.*.example.com')).toBe(false);
|
|
267
|
+
expect(isValidDomain('')).toBe(false);
|
|
268
|
+
expect(isValidDomain('a'.repeat(250) + '.com')).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('normalizes zone IDs with optional /hostedzone/ prefix', () => {
|
|
272
|
+
expect(normalizeZoneId('Z1234567890ABC')).toBe('Z1234567890ABC');
|
|
273
|
+
expect(normalizeZoneId('/hostedzone/Z1234567890ABC')).toBe('Z1234567890ABC');
|
|
274
|
+
expect(normalizeZoneId(' Z1 ')).toBe('Z1');
|
|
275
|
+
expect(normalizeZoneId('z-lowercase')).toBeNull();
|
|
276
|
+
expect(normalizeZoneId('ABC')).toBeNull();
|
|
277
|
+
expect(normalizeZoneId('')).toBeNull();
|
|
278
|
+
expect(normalizeZoneId(null)).toBeNull();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('validates sender addresses against the SES domain', () => {
|
|
282
|
+
expect(isValidFromEmail('noreply@example.com', 'example.com')).toBe(true);
|
|
283
|
+
expect(isValidFromEmail('News@Mail.Example.com', 'example.com')).toBe(true);
|
|
284
|
+
expect(isValidFromEmail('a@sub.example.com', 'example.com')).toBe(true);
|
|
285
|
+
expect(isValidFromEmail('a@other.com', 'example.com')).toBe(false);
|
|
286
|
+
expect(isValidFromEmail('a@notexample.com', 'example.com')).toBe(false);
|
|
287
|
+
expect(isValidFromEmail('not-an-email', 'example.com')).toBe(false);
|
|
288
|
+
expect(isValidFromEmail('noreply@*.example.com', 'example.com')).toBe(false);
|
|
289
|
+
expect(isValidFromEmail('noreply@example.com', '*.example.com')).toBe(false);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('parses domain.tf into structured data', () => {
|
|
293
|
+
const route53 = renderDomainTfRoute53({ domain: 'example.com', zoneId: 'Z123' });
|
|
294
|
+
expect(parseDomainTf(route53)).toEqual({ domain: 'example.com', mode: 'route53', zoneId: 'Z123' });
|
|
295
|
+
const pending = renderDomainTfExternalPending({ domain: 'Example.COM.' });
|
|
296
|
+
expect(parseDomainTf(pending)).toEqual({ domain: 'example.com', mode: 'external-pending', zoneId: null });
|
|
297
|
+
const active = appendValidationResource(pending);
|
|
298
|
+
expect(parseDomainTf(active)).toEqual({ domain: 'example.com', mode: 'external-active', zoneId: null });
|
|
299
|
+
expect(parseDomainTf('')).toBeNull();
|
|
300
|
+
expect(parseDomainTf(null)).toBeNull();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('parses legacy deploy-stack markers', () => {
|
|
304
|
+
const legacy = '# deploy-stack:domain-mode=external-pending\n# deploy-stack:zone-id=Z123\n'
|
|
305
|
+
+ 'resource "x" "y" {\n domain_name = "Example.COM."\n}\n';
|
|
306
|
+
expect(parseDomainTf(legacy)).toEqual({ domain: 'example.com', mode: 'external-pending', zoneId: 'Z123' });
|
|
307
|
+
// route53 resolves through the structural fallback (as before).
|
|
308
|
+
const legacyRoute53 = '# deploy-stack:domain-mode=route53\n# deploy-stack:zone-id=Z123\n'
|
|
309
|
+
+ 'resource "aws_route53_record" "cdn_alias_a" {}\n';
|
|
310
|
+
expect(parseDomainTf(legacyRoute53)).toEqual({ domain: null, mode: 'route53', zoneId: 'Z123' });
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('migrates a legacy pending marker to grada active on activation', () => {
|
|
314
|
+
const legacy = '# deploy-stack:domain-mode=external-pending\n# Managed by `deploy-stack domain`.\n';
|
|
315
|
+
const active = appendValidationResource(legacy);
|
|
316
|
+
expect(active).toContain('# grada:domain-mode=external-active');
|
|
317
|
+
expect(active).not.toContain('deploy-stack:domain-mode');
|
|
318
|
+
expect(parseDomainTf(active).mode).toBe('external-active');
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
describe('parseDomainArgs', () => {
|
|
323
|
+
it('parses subcommands, positionals, and flags', () => {
|
|
324
|
+
expect(parseDomainArgs(['domain', 'add', 'example.com', '--zone-id', 'Z1'])).toMatchObject({
|
|
325
|
+
subcommand: 'add', domain: 'example.com', zoneId: 'Z1', activate: false, force: false,
|
|
326
|
+
});
|
|
327
|
+
expect(parseDomainArgs(['domain', 'add', 'example.com', '--zone-id=Z1', '--activate', '--force'])).toMatchObject({
|
|
328
|
+
zoneId: 'Z1', activate: true, force: true,
|
|
329
|
+
});
|
|
330
|
+
expect(parseDomainArgs(['domain', 'verify'])).toMatchObject({ subcommand: 'verify' });
|
|
331
|
+
expect(parseDomainArgs(['domain', 'remove', '--yes'])).toMatchObject({ subcommand: 'remove', yes: true });
|
|
332
|
+
expect(parseDomainArgs(['domain', 'status'])).toMatchObject({ subcommand: 'status' });
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('hardens against non-array input', () => {
|
|
336
|
+
expect(parseDomainArgs(null).subcommand).toBeUndefined();
|
|
337
|
+
expect(parseDomainArgs('add').subcommand).toBeUndefined();
|
|
338
|
+
expect(parseDomainArgs([]).subcommand).toBeUndefined();
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
describe('domain.tf builders', () => {
|
|
343
|
+
it('renders Route 53 mode with validation, cert validation, and alias records', () => {
|
|
344
|
+
const rendered = renderDomainTfRoute53({ domain: 'example.com', zoneId: 'Z123' });
|
|
345
|
+
expect(rendered).toContain('# grada:domain-mode=route53');
|
|
346
|
+
expect(rendered).toContain('# grada:zone-id=Z123');
|
|
347
|
+
expect(rendered).not.toContain('provider "aws"');
|
|
348
|
+
expect(rendered).toContain('resource "aws_acm_certificate" "domain"');
|
|
349
|
+
expect(rendered).toContain('= aws.us_east_1');
|
|
350
|
+
expect(rendered).toContain('create_before_destroy = true');
|
|
351
|
+
expect(rendered).toContain('for_each = terraform.workspace == "default" ? { for dvo in aws_acm_certificate.domain[0].domain_validation_options');
|
|
352
|
+
expect(rendered).toContain('validation_record_fqdns = [for record in aws_route53_record.domain_validation : record.fqdn]');
|
|
353
|
+
expect(rendered).toContain('resource "aws_route53_record" "cdn_alias_a"');
|
|
354
|
+
expect(rendered).toContain('resource "aws_route53_record" "cdn_alias_aaaa"');
|
|
355
|
+
expect(rendered).toContain('evaluate_target_health = false');
|
|
356
|
+
expect(rendered).toContain('aws_cloudfront_distribution.cdn.hosted_zone_id');
|
|
357
|
+
expect(rendered).toContain('output "custom_domain_url"');
|
|
358
|
+
expect(rendered).toContain('output "acm_certificate_arn"');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('renders external-pending mode without validation or alias records', () => {
|
|
362
|
+
const rendered = renderDomainTfExternalPending({ domain: 'example.com' });
|
|
363
|
+
expect(rendered).toContain('# grada:domain-mode=external-pending');
|
|
364
|
+
expect(rendered).not.toContain('provider "aws"');
|
|
365
|
+
expect(rendered).toContain('resource "aws_acm_certificate" "domain"');
|
|
366
|
+
expect(rendered).not.toContain('aws_acm_certificate_validation');
|
|
367
|
+
expect(rendered).not.toContain('cdn_alias_a');
|
|
368
|
+
expect(rendered).toContain('output "acm_validation_records"');
|
|
369
|
+
expect(rendered).toContain('output "custom_domain_cname_target"');
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('appendValidationResource transitions pending to active idempotently', () => {
|
|
373
|
+
const pending = renderDomainTfExternalPending({ domain: 'example.com' });
|
|
374
|
+
const active = appendValidationResource(pending);
|
|
375
|
+
expect(active).toContain('# grada:domain-mode=external-active');
|
|
376
|
+
expect(active).toContain('resource "aws_acm_certificate_validation" "domain"');
|
|
377
|
+
expect(active).toContain('output "custom_domain_url"');
|
|
378
|
+
expect(active).toContain('https://example.com');
|
|
379
|
+
expect(appendValidationResource(active)).toBe(active);
|
|
380
|
+
const route53 = renderDomainTfRoute53({ domain: 'example.com', zoneId: 'Z1' });
|
|
381
|
+
expect(appendValidationResource(route53)).toBe(route53);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
describe('us_east_1 provider backfill', () => {
|
|
386
|
+
it('appends the alias provider to main.tf on domain add when missing', async () => {
|
|
387
|
+
const dir = makeTmp();
|
|
388
|
+
writeCloudfrontTf(dir);
|
|
389
|
+
writeMainTf(dir);
|
|
390
|
+
const result = await runDomain({ cwd: dir, subcommand: 'add', domain: 'example.com', zoneId: 'Z1' });
|
|
391
|
+
expect(result.ok).toBe(true);
|
|
392
|
+
const mainTf = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
|
|
393
|
+
expect(mainTf).toContain('alias = "us_east_1"');
|
|
394
|
+
expect(mainTf.match(/alias\s*=\s*"us_east_1"/g)).toHaveLength(1);
|
|
395
|
+
// domain.tf keeps only the provider reference, not the configuration.
|
|
396
|
+
const domainTf = fs.readFileSync(path.join(dir, 'terraform', 'domain.tf'), 'utf-8');
|
|
397
|
+
expect(domainTf).not.toContain('provider "aws"');
|
|
398
|
+
expect(domainTf).toContain('= aws.us_east_1');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('leaves main.tf untouched on add when the alias already exists', async () => {
|
|
402
|
+
const dir = makeTmp();
|
|
403
|
+
writeCloudfrontTf(dir);
|
|
404
|
+
writeMainTf(dir, `${MAIN_TF_NO_ALIAS}\nprovider "aws" {\n alias = "us_east_1"\n region = "us-east-1"\n}\n`);
|
|
405
|
+
const before = fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8');
|
|
406
|
+
const result = await runDomain({ cwd: dir, subcommand: 'add', domain: 'example.com', zoneId: 'Z1' });
|
|
407
|
+
expect(result.ok).toBe(true);
|
|
408
|
+
expect(fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8')).toBe(before);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('backfills main.tf on domain remove before deleting domain.tf', async () => {
|
|
412
|
+
const dir = makeTmp();
|
|
413
|
+
writeCloudfrontTf(dir);
|
|
414
|
+
writeMainTf(dir);
|
|
415
|
+
await runDomain({ cwd: dir, subcommand: 'add', domain: 'example.com', zoneId: 'Z1' });
|
|
416
|
+
// Simulate a project that added the domain before the provider moved.
|
|
417
|
+
writeMainTf(dir);
|
|
418
|
+
const result = await runDomain({ cwd: dir, subcommand: 'remove', yes: true });
|
|
419
|
+
expect(result).toMatchObject({ ok: true, removed: true });
|
|
420
|
+
expect(fs.existsSync(path.join(dir, 'terraform', 'domain.tf'))).toBe(false);
|
|
421
|
+
expect(fs.readFileSync(path.join(dir, 'terraform', 'main.tf'), 'utf-8')).toContain('alias = "us_east_1"');
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('ships the alias provider in the generated main.tf template', () => {
|
|
425
|
+
const template = fs.readFileSync(new URL('../templates/terraform/main.tf', import.meta.url), 'utf-8');
|
|
426
|
+
expect(template).toContain('alias = "us_east_1"');
|
|
427
|
+
expect(template).toContain('region = "us-east-1"');
|
|
428
|
+
expect(template).toContain('# tflint-ignore: terraform_unused_declarations\nprovider "aws" {\n alias = "us_east_1"');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('is a no-op when main.tf is missing', () => {
|
|
432
|
+
const dir = makeTmp();
|
|
433
|
+
const mainTfPath = path.join(dir, 'terraform', 'main.tf');
|
|
434
|
+
expect(ensureUsEast1Provider(mainTfPath)).toBe(false);
|
|
435
|
+
expect(fs.existsSync(mainTfPath)).toBe(false);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('appends with blank-line separation and reports true when missing', () => {
|
|
439
|
+
const dir = makeTmp();
|
|
440
|
+
writeMainTf(dir);
|
|
441
|
+
const mainTfPath = path.join(dir, 'terraform', 'main.tf');
|
|
442
|
+
expect(ensureUsEast1Provider(mainTfPath)).toBe(true);
|
|
443
|
+
const mainTf = fs.readFileSync(mainTfPath, 'utf-8');
|
|
444
|
+
expect(mainTf).toContain('}\n\n# tflint-ignore: terraform_unused_declarations\nprovider "aws" {\n alias = "us_east_1"');
|
|
445
|
+
expect(mainTf.endsWith('}\n')).toBe(true);
|
|
446
|
+
expect(ensureUsEast1Provider(mainTfPath)).toBe(false);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('detects whitespace-variant alias declarations', () => {
|
|
450
|
+
const dir = makeTmp();
|
|
451
|
+
writeMainTf(dir, 'provider "aws" {\n\talias\t=\t"us_east_1"\n}\n');
|
|
452
|
+
const mainTfPath = path.join(dir, 'terraform', 'main.tf');
|
|
453
|
+
expect(ensureUsEast1Provider(mainTfPath)).toBe(false);
|
|
454
|
+
expect(fs.readFileSync(mainTfPath, 'utf-8')).not.toContain('region = "us-east-1"');
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
describe('CloudFront patching', () => {
|
|
459
|
+
it('patches conditional aliases and viewer_certificate', () => {
|
|
460
|
+
const patched = patchCloudFrontDomain(CLOUDFRONT_TF, 'example.com');
|
|
461
|
+
expect(patched).toContain('aliases = terraform.workspace == "default" ? ["example.com"] : []');
|
|
462
|
+
expect(patched).toContain('acm_certificate_arn = terraform.workspace == "default" ? aws_acm_certificate_validation.domain[0].certificate_arn : null');
|
|
463
|
+
expect(patched).toContain('ssl_support_method = terraform.workspace == "default" ? "sni-only" : null');
|
|
464
|
+
expect(patched).toContain('minimum_protocol_version = terraform.workspace == "default" ? "TLSv1.2_2021" : "TLSv1"');
|
|
465
|
+
expect(patched).toContain('cloudfront_default_certificate = terraform.workspace != "default"');
|
|
466
|
+
expect(patched).toContain('origin {');
|
|
467
|
+
expect(patchCloudFrontDomain(patched, 'example.com')).toBe(patched);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('merges with user-added aliases without clobbering', () => {
|
|
471
|
+
const once = patchCloudFrontDomain(CLOUDFRONT_TF, 'example.com');
|
|
472
|
+
const twice = patchCloudFrontDomain(once, 'www.example.com');
|
|
473
|
+
expect(twice).toContain('aliases = terraform.workspace == "default" ? ["example.com", "www.example.com"] : []');
|
|
474
|
+
expect(twice.match(/example\.com/g).length).toBeGreaterThanOrEqual(2);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('scopes strictly to the "cdn" distribution', () => {
|
|
478
|
+
const withStorage = `${CLOUDFRONT_TF}\nresource "aws_cloudfront_distribution" "storage_cdn" {\n viewer_certificate {\n cloudfront_default_certificate = true\n }\n}\n`;
|
|
479
|
+
const patched = patchCloudFrontDomain(withStorage, 'example.com');
|
|
480
|
+
expect(patched.match(/acm_certificate_arn/g)).toHaveLength(1);
|
|
481
|
+
expect(patched).toContain('resource "aws_cloudfront_distribution" "storage_cdn"');
|
|
482
|
+
const storageBlock = patched.slice(patched.indexOf('"storage_cdn"'));
|
|
483
|
+
expect(storageBlock).toContain('cloudfront_default_certificate = true');
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('returns content unchanged when the distribution is missing', () => {
|
|
487
|
+
expect(patchCloudFrontDomain('provider "aws" {}\n', 'example.com')).toBe('provider "aws" {}\n');
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it('unpatches one domain while preserving other aliases', () => {
|
|
491
|
+
const patched = patchCloudFrontDomain(patchCloudFrontDomain(CLOUDFRONT_TF, 'example.com'), 'www.example.com');
|
|
492
|
+
const removed = unpatchCloudFrontDomain(patched, 'example.com');
|
|
493
|
+
expect(removed).toContain('aliases = terraform.workspace == "default" ? ["www.example.com"] : []');
|
|
494
|
+
expect(removed).toContain('cloudfront_default_certificate = true');
|
|
495
|
+
expect(removed).not.toContain('acm_certificate_arn');
|
|
496
|
+
const empty = unpatchCloudFrontDomain(removed, 'www.example.com');
|
|
497
|
+
expect(empty).not.toContain('aliases');
|
|
498
|
+
expect(empty).toContain('cloudfront_default_certificate = true');
|
|
499
|
+
expect(unpatchCloudFrontDomain(CLOUDFRONT_TF, 'example.com')).toContain('cloudfront_default_certificate');
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('isCloudFrontWired detects the wired state', () => {
|
|
503
|
+
expect(isCloudFrontWired(CLOUDFRONT_TF, 'example.com')).toBe(false);
|
|
504
|
+
expect(isCloudFrontWired(patchCloudFrontDomain(CLOUDFRONT_TF, 'example.com'), 'example.com')).toBe(true);
|
|
505
|
+
expect(isCloudFrontWired(patchCloudFrontDomain(CLOUDFRONT_TF, 'example.com'), 'other.com')).toBe(false);
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
describe('workspace guards', () => {
|
|
510
|
+
it('scopes Route 53 domain.tf resources to the default workspace', () => {
|
|
511
|
+
const rendered = renderDomainTfRoute53({ domain: 'example.com', zoneId: 'Z123' });
|
|
512
|
+
const guards = rendered.split('= terraform.workspace == "default" ? 1 : 0').length - 1;
|
|
513
|
+
expect(guards).toBe(4);
|
|
514
|
+
expect(rendered).toContain('for_each = terraform.workspace == "default" ? { for dvo in aws_acm_certificate.domain[0].domain_validation_options');
|
|
515
|
+
expect(rendered).toContain('certificate_arn = aws_acm_certificate.domain[0].arn');
|
|
516
|
+
expect(rendered).toContain('value = terraform.workspace == "default" ? aws_acm_certificate.domain[0].arn : null');
|
|
517
|
+
expect(rendered).toContain('output "custom_domain_url"');
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it('scopes external-pending outputs to the default workspace', () => {
|
|
521
|
+
const rendered = renderDomainTfExternalPending({ domain: 'example.com' });
|
|
522
|
+
expect(rendered).toContain('count = terraform.workspace == "default" ? 1 : 0');
|
|
523
|
+
expect(rendered).toContain('value = terraform.workspace == "default" ? [for dvo in aws_acm_certificate.domain[0].domain_validation_options');
|
|
524
|
+
expect(rendered).toContain('] : []');
|
|
525
|
+
// Static outputs stay unguarded.
|
|
526
|
+
expect(rendered).toContain('value = aws_cloudfront_distribution.cdn.domain_name');
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it('appends a guarded validation resource on verify', () => {
|
|
530
|
+
const active = appendValidationResource(renderDomainTfExternalPending({ domain: 'example.com' }));
|
|
531
|
+
expect(active).toContain('count = terraform.workspace == "default" ? 1 : 0');
|
|
532
|
+
expect(active).toContain('certificate_arn = aws_acm_certificate.domain[0].arn');
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('patches workspace-conditional aliases and viewer certificate', () => {
|
|
536
|
+
const patched = patchCloudFrontDomain(CLOUDFRONT_TF, 'example.com');
|
|
537
|
+
expect(patched).toContain('aliases = terraform.workspace == "default" ? ["example.com"] : []');
|
|
538
|
+
expect(patched).toContain('cloudfront_default_certificate = terraform.workspace != "default"');
|
|
539
|
+
expect(patched).toContain('acm_certificate_arn = terraform.workspace == "default" ? aws_acm_certificate_validation.domain[0].certificate_arn : null');
|
|
540
|
+
expect(patched).toContain('ssl_support_method = terraform.workspace == "default" ? "sni-only" : null');
|
|
541
|
+
expect(patched).toContain('minimum_protocol_version = terraform.workspace == "default" ? "TLSv1.2_2021" : "TLSv1"');
|
|
542
|
+
expect(patchCloudFrontDomain(patched, 'example.com')).toBe(patched);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('merges into and unpatches from the conditional true branch', () => {
|
|
546
|
+
const twice = patchCloudFrontDomain(patchCloudFrontDomain(CLOUDFRONT_TF, 'example.com'), 'www.example.com');
|
|
547
|
+
expect(twice).toContain('? ["example.com", "www.example.com"] : []');
|
|
548
|
+
const removed = unpatchCloudFrontDomain(twice, 'example.com');
|
|
549
|
+
expect(removed).toContain('? ["www.example.com"] : []');
|
|
550
|
+
expect(removed).not.toContain('aliases = ["default"]');
|
|
551
|
+
const empty = unpatchCloudFrontDomain(removed, 'www.example.com');
|
|
552
|
+
expect(empty).not.toContain('aliases');
|
|
553
|
+
expect(empty).toContain('cloudfront_default_certificate = true');
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
describe('domain: guards and validation order', () => {
|
|
558
|
+
it('fails unknown and missing subcommands with UNKNOWN_DOMAIN_SUBCOMMAND', async () => {
|
|
559
|
+
const unknown = await runDomain({ subcommand: 'frobnicate' });
|
|
560
|
+
expect(unknown.ok).toBe(false);
|
|
561
|
+
expect(unknown.reason).toBe('unknown-domain-subcommand');
|
|
562
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
563
|
+
expect(trackEvent).toHaveBeenCalledWith('domain_run', expect.objectContaining({
|
|
564
|
+
success: false, error_code: 'UNKNOWN_DOMAIN_SUBCOMMAND',
|
|
565
|
+
}));
|
|
566
|
+
expect(capturedOutput()).toContain('Usage:');
|
|
567
|
+
|
|
568
|
+
vi.clearAllMocks();
|
|
569
|
+
const missing = await runDomain({ cwd: makeTmp() });
|
|
570
|
+
expect(missing.ok).toBe(false);
|
|
571
|
+
expect(missing.reason).toBe('unknown-domain-subcommand');
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it('reports a closed-enum subcommand without echoing user input', async () => {
|
|
575
|
+
await runDomain({ subcommand: 'frobnicate' });
|
|
576
|
+
expect(trackEvent).toHaveBeenCalledWith('domain_run', expect.objectContaining({
|
|
577
|
+
success: false, error_code: 'UNKNOWN_DOMAIN_SUBCOMMAND', subcommand: 'unknown',
|
|
578
|
+
}));
|
|
579
|
+
for (const [, props] of vi.mocked(trackEvent).mock.calls) {
|
|
580
|
+
expect(JSON.stringify(props)).not.toContain('frobnicate');
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
vi.clearAllMocks();
|
|
584
|
+
await runDomain({ cwd: makeTmp() });
|
|
585
|
+
expect(trackEvent).toHaveBeenCalledWith('domain_run', expect.objectContaining({
|
|
586
|
+
success: false, error_code: 'UNKNOWN_DOMAIN_SUBCOMMAND', subcommand: 'none',
|
|
587
|
+
}));
|
|
588
|
+
|
|
589
|
+
for (const bad of [null, '', ' ', 42, ['add']]) {
|
|
590
|
+
vi.clearAllMocks();
|
|
591
|
+
await runDomain({ cwd: makeTmp(), subcommand: bad });
|
|
592
|
+
expect(trackEvent).toHaveBeenCalledWith('domain_run', expect.objectContaining({
|
|
593
|
+
success: false, subcommand: 'none',
|
|
594
|
+
}));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
vi.clearAllMocks();
|
|
598
|
+
await runDomain({ cwd: makeTmp(), subcommand: ' Delete ' });
|
|
599
|
+
expect(trackEvent).toHaveBeenCalledWith('domain_run', expect.objectContaining({
|
|
600
|
+
success: false, subcommand: 'unknown',
|
|
601
|
+
}));
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('validates flags before filesystem guards', async () => {
|
|
605
|
+
const badDomain = await runDomain({ cwd: makeTmp(), subcommand: 'add', domain: 'not a domain' });
|
|
606
|
+
expect(badDomain.ok).toBe(false);
|
|
607
|
+
expect(badDomain.reason).toBe('invalid-domain');
|
|
608
|
+
expect(trackEvent).toHaveBeenCalledWith('domain_run', expect.objectContaining({
|
|
609
|
+
error_code: 'INVALID_DOMAIN',
|
|
610
|
+
}));
|
|
611
|
+
|
|
612
|
+
vi.clearAllMocks();
|
|
613
|
+
const badZone = await runDomain({ cwd: makeTmp(), subcommand: 'add', domain: 'example.com', zoneId: 'nope' });
|
|
614
|
+
expect(badZone.ok).toBe(false);
|
|
615
|
+
expect(badZone.reason).toBe('invalid-zone-id');
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it('fails through PROJECT_NOT_INITIALIZED on unresolvable cwd', async () => {
|
|
619
|
+
const cwdSpy = vi.spyOn(process, 'cwd').mockImplementation(() => { throw new Error('deleted'); });
|
|
620
|
+
try {
|
|
621
|
+
const result = await runDomain({ subcommand: 'status' });
|
|
622
|
+
expect(result).toEqual({ ok: false, reason: 'project-not-initialized' });
|
|
623
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
624
|
+
} finally {
|
|
625
|
+
cwdSpy.mockRestore();
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it('fails with TERRAFORM_NOT_INITIALIZED when cloudfront.tf is missing', async () => {
|
|
630
|
+
const result = await runDomain({ cwd: makeTmp(), subcommand: 'add', domain: 'example.com' });
|
|
631
|
+
expect(result.ok).toBe(false);
|
|
632
|
+
expect(result.reason).toBe('terraform-not-initialized');
|
|
633
|
+
expect(trackEvent).toHaveBeenCalledWith('domain_run', expect.objectContaining({
|
|
634
|
+
error_code: 'TERRAFORM_NOT_INITIALIZED',
|
|
635
|
+
}));
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
it('hardens runDomain against non-object input', async () => {
|
|
639
|
+
for (const bad of [null, 42, 'add', []]) {
|
|
640
|
+
const result = await runDomain(bad);
|
|
641
|
+
expect(result.ok).toBe(false);
|
|
642
|
+
expect(result.reason).toBe('unknown-domain-subcommand');
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
describe('domain add', () => {
|
|
648
|
+
it('writes Route 53 domain.tf and patches CloudFront in one step', async () => {
|
|
649
|
+
const dir = makeTmp();
|
|
650
|
+
writeCloudfrontTf(dir);
|
|
651
|
+
const result = await runDomain({ cwd: dir, subcommand: 'add', domain: 'Example.COM', zoneId: 'Z123' });
|
|
652
|
+
expect(result.ok).toBe(true);
|
|
653
|
+
expect(result.mode).toBe('route53');
|
|
654
|
+
expect(result.domain).toBe('example.com');
|
|
655
|
+
const domainTf = fs.readFileSync(path.join(dir, 'terraform', 'domain.tf'), 'utf-8');
|
|
656
|
+
expect(domainTf).toContain('# grada:domain-mode=route53');
|
|
657
|
+
expect(domainTf).toContain('domain_name = "example.com"');
|
|
658
|
+
const cloudfront = fs.readFileSync(path.join(dir, 'terraform', 'cloudfront.tf'), 'utf-8');
|
|
659
|
+
expect(cloudfront).toContain('aliases = terraform.workspace == "default" ? ["example.com"] : []');
|
|
660
|
+
expect(cloudfront).toContain('acm_certificate_arn');
|
|
661
|
+
expect(trackEvent).toHaveBeenCalledWith('domain_run', {
|
|
662
|
+
projectName: expect.any(String), subcommand: 'add', mode: 'route53', success: true,
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it('stages external-pending domain.tf without touching CloudFront', async () => {
|
|
667
|
+
const dir = makeTmp();
|
|
668
|
+
writeCloudfrontTf(dir);
|
|
669
|
+
const result = await runDomain({ cwd: dir, subcommand: 'add', domain: 'example.com' });
|
|
670
|
+
expect(result.ok).toBe(true);
|
|
671
|
+
expect(result.mode).toBe('external-pending');
|
|
672
|
+
expect(result.cloudfrontPatched).toBe(false);
|
|
673
|
+
const domainTf = fs.readFileSync(path.join(dir, 'terraform', 'domain.tf'), 'utf-8');
|
|
674
|
+
expect(domainTf).toContain('# grada:domain-mode=external-pending');
|
|
675
|
+
expect(fs.readFileSync(path.join(dir, 'terraform', 'cloudfront.tf'), 'utf-8')).toBe(CLOUDFRONT_TF);
|
|
676
|
+
expect(capturedOutput()).toContain('domain verify');
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it('supports --activate for one-step external activation', async () => {
|
|
680
|
+
const dir = makeTmp();
|
|
681
|
+
writeCloudfrontTf(dir);
|
|
682
|
+
const result = await runDomain({ cwd: dir, subcommand: 'add', domain: 'example.com', activate: true });
|
|
683
|
+
expect(result.ok).toBe(true);
|
|
684
|
+
expect(result.mode).toBe('external-active');
|
|
685
|
+
expect(fs.readFileSync(path.join(dir, 'terraform', 'domain.tf'), 'utf-8'))
|
|
686
|
+
.toContain('# grada:domain-mode=external-active');
|
|
687
|
+
expect(fs.readFileSync(path.join(dir, 'terraform', 'cloudfront.tf'), 'utf-8')).toContain('aliases = terraform.workspace == "default" ? ["example.com"] : []');
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it('refuses to overwrite without --force and replaces cleanly with it', async () => {
|
|
691
|
+
const dir = makeTmp();
|
|
692
|
+
writeCloudfrontTf(dir);
|
|
693
|
+
await runDomain({ cwd: dir, subcommand: 'add', domain: 'old.com', zoneId: 'Z1' });
|
|
694
|
+
const rerun = await runDomain({ cwd: dir, subcommand: 'add', domain: 'new.com', zoneId: 'Z1' });
|
|
695
|
+
expect(rerun.ok).toBe(false);
|
|
696
|
+
expect(rerun.reason).toBe('domain-already-configured');
|
|
697
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
698
|
+
|
|
699
|
+
const forced = await runDomain({ cwd: dir, subcommand: 'add', domain: 'new.com', zoneId: 'Z1', force: true });
|
|
700
|
+
expect(forced.ok).toBe(true);
|
|
701
|
+
const cloudfront = fs.readFileSync(path.join(dir, 'terraform', 'cloudfront.tf'), 'utf-8');
|
|
702
|
+
expect(cloudfront).toContain('aliases = terraform.workspace == "default" ? ["new.com"] : []');
|
|
703
|
+
expect(cloudfront).not.toContain('old.com');
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it('fails when cloudfront.tf lacks the cdn distribution', async () => {
|
|
707
|
+
const dir = makeTmp();
|
|
708
|
+
writeCloudfrontTf(dir, 'provider "aws" {}\n');
|
|
709
|
+
const result = await runDomain({ cwd: dir, subcommand: 'add', domain: 'example.com', zoneId: 'Z1' });
|
|
710
|
+
expect(result.ok).toBe(false);
|
|
711
|
+
expect(result.reason).toBe('cloudfront-resource-not-found');
|
|
712
|
+
// domain.tf was still written so the user can fix cloudfront.tf and verify.
|
|
713
|
+
expect(fs.existsSync(path.join(dir, 'terraform', 'domain.tf'))).toBe(true);
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
describe('domain verify', () => {
|
|
718
|
+
it('fails with DOMAIN_NOT_CONFIGURED when domain.tf is missing', async () => {
|
|
719
|
+
const dir = makeTmp();
|
|
720
|
+
writeCloudfrontTf(dir);
|
|
721
|
+
const result = await runDomain({ cwd: dir, subcommand: 'verify' });
|
|
722
|
+
expect(result.ok).toBe(false);
|
|
723
|
+
expect(result.reason).toBe('domain-not-configured');
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it('transitions external-pending to active and patches CloudFront', async () => {
|
|
727
|
+
const dir = makeTmp();
|
|
728
|
+
writeCloudfrontTf(dir);
|
|
729
|
+
await runDomain({ cwd: dir, subcommand: 'add', domain: 'example.com' });
|
|
730
|
+
const result = await runDomain({ cwd: dir, subcommand: 'verify' });
|
|
731
|
+
expect(result).toMatchObject({ ok: true, mode: 'external-active' });
|
|
732
|
+
expect(fs.readFileSync(path.join(dir, 'terraform', 'domain.tf'), 'utf-8'))
|
|
733
|
+
.toContain('resource "aws_acm_certificate_validation" "domain"');
|
|
734
|
+
expect(fs.readFileSync(path.join(dir, 'terraform', 'cloudfront.tf'), 'utf-8')).toContain('aliases = terraform.workspace == "default" ? ["example.com"] : []');
|
|
735
|
+
expect(capturedOutput()).toContain('npx grada-run apply');
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it('treats activate as an alias and active modes as idempotent no-ops', async () => {
|
|
739
|
+
const dir = makeTmp();
|
|
740
|
+
writeCloudfrontTf(dir);
|
|
741
|
+
await runDomain({ cwd: dir, subcommand: 'add', domain: 'example.com', zoneId: 'Z1' });
|
|
742
|
+
const viaAlias = await runDomain({ cwd: dir, subcommand: 'activate' });
|
|
743
|
+
expect(viaAlias).toMatchObject({ ok: true, mode: 'route53', alreadyActive: true });
|
|
744
|
+
const again = await runDomain({ cwd: dir, subcommand: 'verify' });
|
|
745
|
+
expect(again.alreadyActive).toBe(true);
|
|
746
|
+
const cloudfront = fs.readFileSync(path.join(dir, 'terraform', 'cloudfront.tf'), 'utf-8');
|
|
747
|
+
expect(cloudfront.match(/example\.com/g)).toHaveLength(1);
|
|
748
|
+
});
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
describe('domain status', () => {
|
|
752
|
+
it('reports unconfigured projects without failing', async () => {
|
|
753
|
+
const dir = makeTmp();
|
|
754
|
+
writeCloudfrontTf(dir);
|
|
755
|
+
const result = await runDomain({ cwd: dir, subcommand: 'status' });
|
|
756
|
+
expect(result).toEqual({ ok: true, subcommand: 'status', configured: false });
|
|
757
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
758
|
+
expect(capturedOutput()).toContain('No custom domain configured');
|
|
759
|
+
expect(outro).toHaveBeenCalledWith(expect.stringContaining('No custom domain configured'));
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
it('shows mode, wiring state, and the DNS table from outputs', async () => {
|
|
763
|
+
const dir = makeTmp();
|
|
764
|
+
writeCloudfrontTf(dir);
|
|
765
|
+
await runDomain({ cwd: dir, subcommand: 'add', domain: 'example.com' });
|
|
766
|
+
const getOutputs = vi.fn(async () => ({
|
|
767
|
+
acm_validation_records: { value: [{ name: '_abc.example.com', type: 'CNAME', value: '_xyz.acm-validations.aws.' }] },
|
|
768
|
+
custom_domain_cname_target: { value: 'd123.cloudfront.net' },
|
|
769
|
+
}));
|
|
770
|
+
const result = await runDomain({ cwd: dir, subcommand: 'status', getOutputs });
|
|
771
|
+
expect(result).toMatchObject({ ok: true, configured: true, mode: 'external-pending', wired: false });
|
|
772
|
+
expect(getOutputs).toHaveBeenCalledWith(path.join(dir, 'terraform'));
|
|
773
|
+
const output = capturedOutput();
|
|
774
|
+
expect(output).toContain('example.com');
|
|
775
|
+
expect(output).toContain('external-pending');
|
|
776
|
+
expect(output).toContain('not wired yet');
|
|
777
|
+
expect(output).toContain('_abc.example.com');
|
|
778
|
+
expect(output).toContain('d123.cloudfront.net');
|
|
779
|
+
|
|
780
|
+
await runDomain({ cwd: dir, subcommand: 'verify' });
|
|
781
|
+
const wiredResult = await runDomain({ cwd: dir, subcommand: 'status', getOutputs: async () => ({}) });
|
|
782
|
+
expect(wiredResult.wired).toBe(true);
|
|
783
|
+
expect(capturedOutput()).toContain('wired');
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it('prints full DNS record names and values without truncation', async () => {
|
|
787
|
+
const dir = makeTmp();
|
|
788
|
+
writeCloudfrontTf(dir);
|
|
789
|
+
await runDomain({ cwd: dir, subcommand: 'add', domain: 'example.com' });
|
|
790
|
+
const longName = '_a4eb99e0dcf66697ca96b16f62897925.very-long-subdomain-name.example.com';
|
|
791
|
+
const longValue = '_b5fc88f1adg77708db07c27f73908036.long-validation-value.acm-validations.aws.';
|
|
792
|
+
const getOutputs = vi.fn(async () => ({
|
|
793
|
+
acm_validation_records: { value: [{ name: longName, type: 'CNAME', value: longValue }] },
|
|
794
|
+
}));
|
|
795
|
+
await runDomain({ cwd: dir, subcommand: 'status', getOutputs });
|
|
796
|
+
const output = capturedOutput();
|
|
797
|
+
expect(output).toContain(longName);
|
|
798
|
+
expect(output).toContain(longValue);
|
|
799
|
+
expect(output).not.toContain('…');
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
it('closes with an outro reflecting the wired state', async () => {
|
|
803
|
+
const dir = makeTmp();
|
|
804
|
+
writeCloudfrontTf(dir);
|
|
805
|
+
await runDomain({ cwd: dir, subcommand: 'add', domain: 'example.com' });
|
|
806
|
+
vi.mocked(outro).mockClear();
|
|
807
|
+
await runDomain({ cwd: dir, subcommand: 'status', getOutputs: async () => ({}) });
|
|
808
|
+
expect(vi.mocked(outro)).toHaveBeenCalledWith(expect.stringContaining('Run npx grada-run domain verify once DNS records are in place.'));
|
|
809
|
+
|
|
810
|
+
await runDomain({ cwd: dir, subcommand: 'verify' });
|
|
811
|
+
vi.mocked(outro).mockClear();
|
|
812
|
+
await runDomain({ cwd: dir, subcommand: 'status', getOutputs: async () => ({}) });
|
|
813
|
+
expect(vi.mocked(outro)).toHaveBeenCalledWith(expect.stringContaining('Domain is active on CloudFront.'));
|
|
814
|
+
});
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
describe('domain remove', () => {
|
|
818
|
+
it('fails with DOMAIN_NOT_CONFIGURED when nothing is configured', async () => {
|
|
819
|
+
const dir = makeTmp();
|
|
820
|
+
const result = await runDomain({ cwd: dir, subcommand: 'remove', yes: true });
|
|
821
|
+
expect(result.ok).toBe(false);
|
|
822
|
+
expect(result.reason).toBe('domain-not-configured');
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
it('removes domain.tf and restores the default certificate with --yes', async () => {
|
|
826
|
+
const dir = makeTmp();
|
|
827
|
+
writeCloudfrontTf(dir);
|
|
828
|
+
await runDomain({ cwd: dir, subcommand: 'add', domain: 'example.com', zoneId: 'Z1' });
|
|
829
|
+
const result = await runDomain({ cwd: dir, subcommand: 'remove', yes: true });
|
|
830
|
+
expect(result).toMatchObject({ ok: true, removed: true, domain: 'example.com' });
|
|
831
|
+
expect(fs.existsSync(path.join(dir, 'terraform', 'domain.tf'))).toBe(false);
|
|
832
|
+
const cloudfront = fs.readFileSync(path.join(dir, 'terraform', 'cloudfront.tf'), 'utf-8');
|
|
833
|
+
expect(cloudfront).not.toContain('aliases');
|
|
834
|
+
expect(cloudfront).toContain('cloudfront_default_certificate = true');
|
|
835
|
+
expect(trackEvent).toHaveBeenCalledWith('domain_run', expect.objectContaining({
|
|
836
|
+
subcommand: 'remove', success: true,
|
|
837
|
+
}));
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
it('requires --yes in headless mode', async () => {
|
|
841
|
+
const dir = makeTmp();
|
|
842
|
+
writeCloudfrontTf(dir);
|
|
843
|
+
await runDomain({ cwd: dir, subcommand: 'add', domain: 'example.com', zoneId: 'Z1' });
|
|
844
|
+
const result = await runDomain({ cwd: dir, subcommand: 'remove', isHeadless: true });
|
|
845
|
+
expect(result.ok).toBe(false);
|
|
846
|
+
expect(result.reason).toBe('confirmation-required');
|
|
847
|
+
expect(fs.existsSync(path.join(dir, 'terraform', 'domain.tf'))).toBe(true);
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
it('prompts interactively and handles cancel/decline cleanly', async () => {
|
|
851
|
+
const dir = makeTmp();
|
|
852
|
+
writeCloudfrontTf(dir);
|
|
853
|
+
await runDomain({ cwd: dir, subcommand: 'add', domain: 'example.com', zoneId: 'Z1' });
|
|
854
|
+
|
|
855
|
+
vi.mocked(confirm).mockResolvedValueOnce(Symbol('clack:cancel'));
|
|
856
|
+
const cancelled = await runDomain({ cwd: dir, subcommand: 'remove', isHeadless: false });
|
|
857
|
+
expect(cancelled).toEqual({ ok: false, reason: 'cancelled' });
|
|
858
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
859
|
+
expect(fs.existsSync(path.join(dir, 'terraform', 'domain.tf'))).toBe(true);
|
|
860
|
+
|
|
861
|
+
vi.mocked(confirm).mockResolvedValueOnce(false);
|
|
862
|
+
const declined = await runDomain({ cwd: dir, subcommand: 'remove', isHeadless: false });
|
|
863
|
+
expect(declined.reason).toBe('cancelled');
|
|
864
|
+
|
|
865
|
+
vi.mocked(confirm).mockResolvedValueOnce(true);
|
|
866
|
+
const removed = await runDomain({ cwd: dir, subcommand: 'remove', isHeadless: false });
|
|
867
|
+
expect(removed.ok).toBe(true);
|
|
868
|
+
expect(fs.existsSync(path.join(dir, 'terraform', 'domain.tf'))).toBe(false);
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
it('tolerates a missing cloudfront.tf on remove', async () => {
|
|
872
|
+
const dir = makeTmp();
|
|
873
|
+
fs.mkdirSync(path.join(dir, 'terraform'), { recursive: true });
|
|
874
|
+
fs.writeFileSync(
|
|
875
|
+
path.join(dir, 'terraform', 'domain.tf'),
|
|
876
|
+
renderDomainTfExternalPending({ domain: 'example.com' })
|
|
877
|
+
);
|
|
878
|
+
const result = await runDomain({ cwd: dir, subcommand: 'remove', yes: true });
|
|
879
|
+
expect(result.ok).toBe(true);
|
|
880
|
+
expect(fs.existsSync(path.join(dir, 'terraform', 'domain.tf'))).toBe(false);
|
|
881
|
+
});
|
|
882
|
+
});
|