grada-run 0.0.2 → 0.32.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/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 +84 -0
- package/apps/docs/src/content/docs/cli/apply.md +32 -0
- package/apps/docs/src/content/docs/cli/db.md +200 -0
- package/apps/docs/src/content/docs/cli/destroy.md +31 -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 +29 -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 +75 -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 +97 -0
- package/apps/docs/src/content/docs/testing-strategy.md +32 -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 +105 -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/deploy-stack-to-grada-run-rebrand.md +45 -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/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/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 +127 -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 +126 -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 +65 -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 +2037 -0
- package/tests/ai.test.js +94 -0
- package/tests/apply.test.js +488 -0
- package/tests/args.test.js +86 -0
- package/tests/aws.test.js +244 -0
- package/tests/capabilities.test.js +307 -0
- package/tests/cli.test.js +29 -0
- package/tests/command.test.js +100 -0
- package/tests/commands-import.test.js +74 -0
- package/tests/db.test.js +2704 -0
- package/tests/destroy.test.js +391 -0
- package/tests/detector.test.js +79 -0
- package/tests/diagnose.test.js +779 -0
- package/tests/doctor.test.js +202 -0
- package/tests/domain.test.js +899 -0
- package/tests/drift.test.js +243 -0
- package/tests/ecs.test.js +130 -0
- package/tests/eject.test.js +65 -0
- package/tests/exec.test.js +380 -0
- package/tests/gc.test.js +496 -0
- package/tests/generator.test.js +794 -0
- package/tests/headless.test.js +562 -0
- package/tests/lambda-ecr.test.js +185 -0
- package/tests/logs.test.js +447 -0
- package/tests/parser.test.js +160 -0
- package/tests/rds.test.js +244 -0
- package/tests/resolvers.test.js +282 -0
- package/tests/rollback.test.js +692 -0
- package/tests/secrets.test.js +752 -0
- package/tests/sleep-wake.test.js +1016 -0
- package/tests/status.test.js +370 -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 +496 -0
- package/vitest.config.js +9 -0
- package/index.js +0 -2
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { pushSecrets, pullSecrets, auditSecrets, resolveSecretsRegion } from '../src/commands/secrets.js';
|
|
5
|
+
import { trackEvent } from '../src/core/telemetry.js';
|
|
6
|
+
|
|
7
|
+
// 1. Use vi.hoisted() so these variables are available when vi.mock() runs at the top of the file
|
|
8
|
+
const { mockSend, MockSecretsManagerClient, MockUpdateSecretCommand, MockGetSecretValueCommand } = vi.hoisted(() => {
|
|
9
|
+
const sendFn = vi.fn().mockResolvedValue({});
|
|
10
|
+
return {
|
|
11
|
+
mockSend: sendFn,
|
|
12
|
+
MockSecretsManagerClient: vi.fn(function (config) {
|
|
13
|
+
this.config = config;
|
|
14
|
+
this.send = sendFn;
|
|
15
|
+
}),
|
|
16
|
+
MockUpdateSecretCommand: vi.fn(function (input) {
|
|
17
|
+
Object.assign(this, input);
|
|
18
|
+
}),
|
|
19
|
+
MockGetSecretValueCommand: vi.fn(function (input) {
|
|
20
|
+
Object.assign(this, input);
|
|
21
|
+
})
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const { mockConfirm, mockOutro, mockSpinnerStart, mockSpinnerStop } = vi.hoisted(() => ({
|
|
26
|
+
mockConfirm: vi.fn(),
|
|
27
|
+
mockOutro: vi.fn(),
|
|
28
|
+
mockSpinnerStart: vi.fn(),
|
|
29
|
+
mockSpinnerStop: vi.fn(),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const { mockEcsSend, MockECSClient, MockUpdateServiceCommand } = vi.hoisted(() => {
|
|
33
|
+
const ecsSendFn = vi.fn().mockResolvedValue({});
|
|
34
|
+
return {
|
|
35
|
+
mockEcsSend: ecsSendFn,
|
|
36
|
+
MockECSClient: vi.fn(function (config) {
|
|
37
|
+
this.config = config;
|
|
38
|
+
this.send = ecsSendFn;
|
|
39
|
+
}),
|
|
40
|
+
MockUpdateServiceCommand: vi.fn(function (input) {
|
|
41
|
+
Object.assign(this, input);
|
|
42
|
+
}),
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// 2. Inject the hoisted mocks into the AWS SDK
|
|
47
|
+
vi.mock('@aws-sdk/client-secrets-manager', () => ({
|
|
48
|
+
SecretsManagerClient: MockSecretsManagerClient,
|
|
49
|
+
UpdateSecretCommand: MockUpdateSecretCommand,
|
|
50
|
+
GetSecretValueCommand: MockGetSecretValueCommand
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
vi.mock('@aws-sdk/client-ecs', () => ({
|
|
54
|
+
ECSClient: MockECSClient,
|
|
55
|
+
UpdateServiceCommand: MockUpdateServiceCommand,
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
// 3. Mock @clack/prompts to prevent hangs on user input during testing
|
|
59
|
+
vi.mock('@clack/prompts', () => ({
|
|
60
|
+
spinner: vi.fn(() => ({ start: mockSpinnerStart, stop: mockSpinnerStop, message: vi.fn() })),
|
|
61
|
+
confirm: (...args) => mockConfirm(...args),
|
|
62
|
+
outro: (...args) => mockOutro(...args),
|
|
63
|
+
intro: vi.fn(),
|
|
64
|
+
isCancel: (value) => typeof value === 'symbol',
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
// 4. Mock telemetry to prevent real network calls during testing
|
|
68
|
+
vi.mock('../src/core/telemetry.js', () => {
|
|
69
|
+
const trackEvent = vi.fn();
|
|
70
|
+
const flushTelemetry = vi.fn().mockResolvedValue();
|
|
71
|
+
// Mirrors the real trackSuccess delegation so success-path assertions
|
|
72
|
+
// keep observing trackEvent (the real helper is unit-tested separately).
|
|
73
|
+
const trackSuccess = vi.fn(async (event, properties) => {
|
|
74
|
+
trackEvent(event, { ...properties, success: true });
|
|
75
|
+
await flushTelemetry();
|
|
76
|
+
});
|
|
77
|
+
const trackFailure = vi.fn(async (event, properties) => {
|
|
78
|
+
trackEvent(event, { ...properties, success: false });
|
|
79
|
+
await flushTelemetry();
|
|
80
|
+
});
|
|
81
|
+
return { trackEvent, flushTelemetry, trackSuccess, trackFailure };
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('Secrets Push Command', () => {
|
|
85
|
+
const originalCwd = process.cwd();
|
|
86
|
+
const testDir = path.join(originalCwd, 'tests', '.tmp-secrets-env');
|
|
87
|
+
|
|
88
|
+
beforeEach(async () => {
|
|
89
|
+
// Create a fake project directory and step into it
|
|
90
|
+
await fs.mkdir(path.join(testDir, 'terraform'), { recursive: true });
|
|
91
|
+
process.chdir(testDir);
|
|
92
|
+
mockConfirm.mockReset();
|
|
93
|
+
mockConfirm.mockResolvedValue(false);
|
|
94
|
+
mockEcsSend.mockReset();
|
|
95
|
+
mockEcsSend.mockResolvedValue({});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
afterEach(async () => {
|
|
99
|
+
// Step back out, clean up the files, and reset mocks
|
|
100
|
+
process.chdir(originalCwd);
|
|
101
|
+
await fs.rm(testDir, { recursive: true, force: true });
|
|
102
|
+
vi.clearAllMocks();
|
|
103
|
+
mockSend.mockReset();
|
|
104
|
+
mockSend.mockResolvedValue({}); // Reset to default success state
|
|
105
|
+
mockEcsSend.mockReset();
|
|
106
|
+
mockEcsSend.mockResolvedValue({});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('reads .env, pushes to AWS, and writes secret_keys.json', async () => {
|
|
110
|
+
// Setup: Create a fake .env and a fake backend.tf (to test region extraction)
|
|
111
|
+
await fs.writeFile('.env', 'GITHUB_TOKEN=ghp_12345\nDB_PASS=supersecret');
|
|
112
|
+
await fs.writeFile(path.join('terraform', 'backend.tf'), 'region = "us-east-2"');
|
|
113
|
+
|
|
114
|
+
// Execute the CLI command
|
|
115
|
+
await pushSecrets('.env', 'my-project');
|
|
116
|
+
|
|
117
|
+
// Assert 1: Did we initialize the AWS client with the correct region from backend.tf?
|
|
118
|
+
expect(MockSecretsManagerClient).toHaveBeenCalledWith({ region: 'us-east-2' });
|
|
119
|
+
|
|
120
|
+
// Assert 2: Did we package the exact right payload for AWS?
|
|
121
|
+
expect(MockUpdateSecretCommand).toHaveBeenCalledWith({
|
|
122
|
+
SecretId: 'my-project-secrets',
|
|
123
|
+
SecretString: JSON.stringify({ GITHUB_TOKEN: 'ghp_12345', DB_PASS: 'supersecret' })
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Assert 3: Did we write the keys to the local JSON file for Terraform to use?
|
|
127
|
+
const keysPath = path.join('terraform', 'secret_keys.json');
|
|
128
|
+
const keysContent = await fs.readFile(keysPath, 'utf-8');
|
|
129
|
+
expect(JSON.parse(keysContent)).toEqual(['GITHUB_TOKEN', 'DB_PASS']);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('handles a missing AWS vault (ResourceNotFoundException) gracefully', async () => {
|
|
133
|
+
// Setup: GetSecretValue finds no vault (new vault path), then UpdateSecret also fails
|
|
134
|
+
mockSend.mockRejectedValueOnce({ name: 'ResourceNotFoundException', message: 'Vault missing' });
|
|
135
|
+
mockSend.mockRejectedValueOnce({ name: 'ResourceNotFoundException', message: 'Vault missing' });
|
|
136
|
+
await fs.writeFile('.env', 'API_KEY=123');
|
|
137
|
+
|
|
138
|
+
// We must mock process.exit so the test runner doesn't crash when the CLI tries to exit
|
|
139
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
140
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
141
|
+
|
|
142
|
+
// Execute
|
|
143
|
+
await pushSecrets('.env', 'my-project');
|
|
144
|
+
|
|
145
|
+
// Assert: Ensure the CLI caught the error and attempted a clean exit
|
|
146
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
147
|
+
|
|
148
|
+
exitSpy.mockRestore();
|
|
149
|
+
consoleSpy.mockRestore();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('gracefully falls back to .env when envFilePath is undefined or omitted', async () => {
|
|
153
|
+
// Create default .env file
|
|
154
|
+
await fs.writeFile('.env', 'DATABASE_URL=postgres://localhost:5432/db');
|
|
155
|
+
await fs.writeFile(path.join('terraform', 'backend.tf'), 'region = "us-east-1"');
|
|
156
|
+
|
|
157
|
+
// Call pushSecrets with undefined/omitted argument
|
|
158
|
+
await pushSecrets(undefined, 'my-project');
|
|
159
|
+
|
|
160
|
+
// Verify it resolved .env properly and sent secrets
|
|
161
|
+
expect(MockUpdateSecretCommand).toHaveBeenCalledWith({
|
|
162
|
+
SecretId: 'my-project-secrets',
|
|
163
|
+
SecretString: JSON.stringify({ DATABASE_URL: 'postgres://localhost:5432/db' })
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('gracefully falls back to .env when envFilePath is passed as an object or invalid type', async () => {
|
|
168
|
+
// Simulates Commander passing an options object as the first parameter
|
|
169
|
+
await fs.writeFile('.env', 'STRIPE_KEY=sk_test_12345');
|
|
170
|
+
await fs.writeFile(path.join('terraform', 'backend.tf'), 'region = "us-east-1"');
|
|
171
|
+
|
|
172
|
+
// Call pushSecrets with an object
|
|
173
|
+
await pushSecrets({}, 'my-project');
|
|
174
|
+
|
|
175
|
+
expect(MockUpdateSecretCommand).toHaveBeenCalledWith({
|
|
176
|
+
SecretId: 'my-project-secrets',
|
|
177
|
+
SecretString: JSON.stringify({ STRIPE_KEY: 'sk_test_12345' })
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('guards against CI injection and defaults to .env if the filename lacks a standard extension', async () => {
|
|
182
|
+
// Setup default .env file
|
|
183
|
+
await fs.writeFile('.env', 'CI_INJECTION_GUARD=success');
|
|
184
|
+
await fs.writeFile(path.join('terraform', 'backend.tf'), 'region = "us-east-1"');
|
|
185
|
+
|
|
186
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
187
|
+
|
|
188
|
+
// Execute with "event" (simulating a GitHub Actions ${{ github.event_name }} bug)
|
|
189
|
+
await pushSecrets('event', 'my-project');
|
|
190
|
+
|
|
191
|
+
// Assert warning was printed and it successfully read from .env instead
|
|
192
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('does not look like a standard secrets file'));
|
|
193
|
+
expect(MockUpdateSecretCommand).toHaveBeenCalledWith({
|
|
194
|
+
SecretId: 'my-project-secrets',
|
|
195
|
+
SecretString: JSON.stringify({ CI_INJECTION_GUARD: 'success' })
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
consoleSpy.mockRestore();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('keeps the GitHub commit guidance and skips ECS restart when keys changed', async () => {
|
|
202
|
+
await fs.writeFile('.env', 'NEW_KEY=abc\nOTHER=xyz');
|
|
203
|
+
await fs.writeFile(path.join('terraform', 'backend.tf'), 'region = "us-east-1"');
|
|
204
|
+
// Existing vault holds a different key set (addition + deletion)
|
|
205
|
+
mockSend.mockResolvedValueOnce({ SecretString: JSON.stringify({ OLD_KEY: '1', OTHER: 'old' }) });
|
|
206
|
+
mockSend.mockResolvedValueOnce({});
|
|
207
|
+
|
|
208
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
209
|
+
|
|
210
|
+
const result = await pushSecrets('.env', 'my-project');
|
|
211
|
+
|
|
212
|
+
expect(MockGetSecretValueCommand).toHaveBeenCalledWith({ SecretId: 'my-project-secrets' });
|
|
213
|
+
expect(MockUpdateSecretCommand).toHaveBeenCalledWith({
|
|
214
|
+
SecretId: 'my-project-secrets',
|
|
215
|
+
SecretString: JSON.stringify({ NEW_KEY: 'abc', OTHER: 'xyz' })
|
|
216
|
+
});
|
|
217
|
+
// Keys changed: instruct the user to commit secret_keys.json, do not prompt for ECS restart
|
|
218
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Commit this file and push to GitHub'));
|
|
219
|
+
expect(mockConfirm).not.toHaveBeenCalled();
|
|
220
|
+
expect(MockECSClient).not.toHaveBeenCalled();
|
|
221
|
+
expect(mockEcsSend).not.toHaveBeenCalled();
|
|
222
|
+
expect(result).toEqual(expect.objectContaining({ keysChanged: true }));
|
|
223
|
+
|
|
224
|
+
consoleSpy.mockRestore();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('prompts for and triggers a rolling ECS restart when only values changed', async () => {
|
|
228
|
+
await fs.writeFile('.env', 'API_KEY=newvalue\nDB_PASS=newsecret');
|
|
229
|
+
await fs.writeFile(path.join('terraform', 'backend.tf'), 'region = "us-east-1"');
|
|
230
|
+
// Existing vault holds the identical key set with older values
|
|
231
|
+
mockSend.mockResolvedValueOnce({ SecretString: JSON.stringify({ API_KEY: 'oldvalue', DB_PASS: 'oldsecret' }) });
|
|
232
|
+
mockSend.mockResolvedValueOnce({});
|
|
233
|
+
mockConfirm.mockResolvedValueOnce(true);
|
|
234
|
+
|
|
235
|
+
const result = await pushSecrets('.env', 'my-project');
|
|
236
|
+
|
|
237
|
+
expect(MockGetSecretValueCommand).toHaveBeenCalledWith({ SecretId: 'my-project-secrets' });
|
|
238
|
+
expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({
|
|
239
|
+
message: 'Keys are unchanged. Trigger a rolling ECS restart to apply new values immediately?',
|
|
240
|
+
}));
|
|
241
|
+
expect(MockECSClient).toHaveBeenCalledWith({ region: 'us-east-1' });
|
|
242
|
+
expect(MockUpdateServiceCommand).toHaveBeenCalledWith({
|
|
243
|
+
cluster: 'my-project-cluster',
|
|
244
|
+
service: 'my-project-service',
|
|
245
|
+
forceNewDeployment: true,
|
|
246
|
+
});
|
|
247
|
+
expect(mockEcsSend).toHaveBeenCalledTimes(1);
|
|
248
|
+
expect(mockSpinnerStart).toHaveBeenCalledWith('Triggering rolling ECS restart...');
|
|
249
|
+
expect(mockOutro).toHaveBeenCalledWith(expect.stringContaining('Rolling restart initiated'));
|
|
250
|
+
expect(result).toEqual(expect.objectContaining({ keysChanged: false, restarted: true }));
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('skips the ECS restart when keys are identical but the user declines', async () => {
|
|
254
|
+
await fs.writeFile('.env', 'API_KEY=newvalue');
|
|
255
|
+
mockSend.mockResolvedValueOnce({ SecretString: JSON.stringify({ API_KEY: 'oldvalue' }) });
|
|
256
|
+
mockSend.mockResolvedValueOnce({});
|
|
257
|
+
mockConfirm.mockResolvedValueOnce(false);
|
|
258
|
+
|
|
259
|
+
const result = await pushSecrets('.env', 'my-project');
|
|
260
|
+
|
|
261
|
+
expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({
|
|
262
|
+
message: 'Keys are unchanged. Trigger a rolling ECS restart to apply new values immediately?',
|
|
263
|
+
}));
|
|
264
|
+
expect(mockEcsSend).not.toHaveBeenCalled();
|
|
265
|
+
expect(result).toEqual(expect.objectContaining({ keysChanged: false }));
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('treats a new vault (ResourceNotFoundException on fetch) as keys changed', async () => {
|
|
269
|
+
await fs.writeFile('.env', 'FRESH_KEY=1');
|
|
270
|
+
mockSend.mockRejectedValueOnce({ name: 'ResourceNotFoundException', message: 'missing' });
|
|
271
|
+
mockSend.mockResolvedValueOnce({});
|
|
272
|
+
|
|
273
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
274
|
+
|
|
275
|
+
const result = await pushSecrets('.env', 'my-project');
|
|
276
|
+
|
|
277
|
+
expect(MockUpdateSecretCommand).toHaveBeenCalledWith({
|
|
278
|
+
SecretId: 'my-project-secrets',
|
|
279
|
+
SecretString: JSON.stringify({ FRESH_KEY: '1' })
|
|
280
|
+
});
|
|
281
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Commit this file and push to GitHub'));
|
|
282
|
+
expect(mockConfirm).not.toHaveBeenCalled();
|
|
283
|
+
expect(mockEcsSend).not.toHaveBeenCalled();
|
|
284
|
+
expect(result).toEqual(expect.objectContaining({ keysChanged: true }));
|
|
285
|
+
|
|
286
|
+
consoleSpy.mockRestore();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('creates an empty .env file when missing and the user confirms', async () => {
|
|
290
|
+
const originalCI = process.env.CI;
|
|
291
|
+
delete process.env.CI;
|
|
292
|
+
mockConfirm.mockResolvedValueOnce(true);
|
|
293
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
294
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
await pushSecrets('.env', 'my-project');
|
|
298
|
+
|
|
299
|
+
expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({
|
|
300
|
+
message: expect.stringContaining('Would you like to create an empty .env file'),
|
|
301
|
+
}));
|
|
302
|
+
const content = await fs.readFile('.env', 'utf-8');
|
|
303
|
+
expect(content).toBe('# Add your environment variables here\n');
|
|
304
|
+
expect(MockSecretsManagerClient).not.toHaveBeenCalled();
|
|
305
|
+
expect(trackEvent).toHaveBeenCalledWith('secrets_pushed', expect.objectContaining({ reason: 'created_empty_file' }));
|
|
306
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
307
|
+
} finally {
|
|
308
|
+
consoleSpy.mockRestore();
|
|
309
|
+
exitSpy.mockRestore();
|
|
310
|
+
if (originalCI === undefined) delete process.env.CI;
|
|
311
|
+
else process.env.CI = originalCI;
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('does nothing when the user declines creating the .env file', async () => {
|
|
316
|
+
const originalCI = process.env.CI;
|
|
317
|
+
delete process.env.CI;
|
|
318
|
+
mockConfirm.mockResolvedValueOnce(false);
|
|
319
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
320
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
await pushSecrets('.env', 'my-project');
|
|
324
|
+
|
|
325
|
+
expect(mockConfirm).toHaveBeenCalled();
|
|
326
|
+
await expect(fs.readFile('.env', 'utf-8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
327
|
+
expect(MockSecretsManagerClient).not.toHaveBeenCalled();
|
|
328
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
329
|
+
} finally {
|
|
330
|
+
consoleSpy.mockRestore();
|
|
331
|
+
exitSpy.mockRestore();
|
|
332
|
+
if (originalCI === undefined) delete process.env.CI;
|
|
333
|
+
else process.env.CI = originalCI;
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('treats prompt cancellation like declining', async () => {
|
|
338
|
+
const originalCI = process.env.CI;
|
|
339
|
+
delete process.env.CI;
|
|
340
|
+
mockConfirm.mockResolvedValueOnce(Symbol('cancel'));
|
|
341
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
342
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
await pushSecrets('.env', 'my-project');
|
|
346
|
+
|
|
347
|
+
await expect(fs.readFile('.env', 'utf-8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
348
|
+
expect(MockSecretsManagerClient).not.toHaveBeenCalled();
|
|
349
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
350
|
+
} finally {
|
|
351
|
+
consoleSpy.mockRestore();
|
|
352
|
+
exitSpy.mockRestore();
|
|
353
|
+
if (originalCI === undefined) delete process.env.CI;
|
|
354
|
+
else process.env.CI = originalCI;
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('exits 1 without prompting when headless and the .env file is missing', async () => {
|
|
359
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
360
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
await pushSecrets('.env', 'my-project', { isHeadless: true });
|
|
364
|
+
|
|
365
|
+
expect(mockConfirm).not.toHaveBeenCalled();
|
|
366
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('In automated environments'));
|
|
367
|
+
expect(trackEvent).toHaveBeenCalledWith('secrets_pushed', expect.objectContaining({ reason: 'missing_env_headless' }));
|
|
368
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
369
|
+
await expect(fs.readFile('.env', 'utf-8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
370
|
+
} finally {
|
|
371
|
+
exitSpy.mockRestore();
|
|
372
|
+
consoleSpy.mockRestore();
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('exits 1 without prompting when CI is set even without the headless flag', async () => {
|
|
377
|
+
const originalCI = process.env.CI;
|
|
378
|
+
process.env.CI = 'true';
|
|
379
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
380
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
await pushSecrets('.env', 'my-project');
|
|
384
|
+
|
|
385
|
+
expect(mockConfirm).not.toHaveBeenCalled();
|
|
386
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
387
|
+
} finally {
|
|
388
|
+
exitSpy.mockRestore();
|
|
389
|
+
consoleSpy.mockRestore();
|
|
390
|
+
if (originalCI === undefined) delete process.env.CI;
|
|
391
|
+
else process.env.CI = originalCI;
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
describe('Secrets Pull Command', () => {
|
|
397
|
+
const originalCwd = process.cwd();
|
|
398
|
+
const testDir = path.join(originalCwd, 'tests', '.tmp-secrets-pull-audit');
|
|
399
|
+
|
|
400
|
+
beforeEach(async () => {
|
|
401
|
+
await fs.mkdir(path.join(testDir, 'terraform'), { recursive: true });
|
|
402
|
+
process.chdir(testDir);
|
|
403
|
+
mockConfirm.mockReset();
|
|
404
|
+
mockConfirm.mockResolvedValue(true);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
afterEach(async () => {
|
|
408
|
+
process.chdir(originalCwd);
|
|
409
|
+
await fs.rm(testDir, { recursive: true, force: true });
|
|
410
|
+
vi.clearAllMocks();
|
|
411
|
+
mockSend.mockReset();
|
|
412
|
+
mockSend.mockResolvedValue({});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('creates .env from remote payload when local file is missing', async () => {
|
|
416
|
+
mockSend.mockResolvedValueOnce({ SecretString: JSON.stringify({ API_KEY: 'abc', DB_PASS: 'xyz' }) });
|
|
417
|
+
await fs.writeFile(path.join('terraform', 'backend.tf'), 'region = "us-east-2"');
|
|
418
|
+
|
|
419
|
+
const result = await pullSecrets('.env', 'my-project', { isHeadless: true });
|
|
420
|
+
|
|
421
|
+
expect(MockGetSecretValueCommand).toHaveBeenCalledWith({ SecretId: 'my-project-secrets' });
|
|
422
|
+
expect(MockSecretsManagerClient).toHaveBeenCalledWith({ region: 'us-east-2' });
|
|
423
|
+
expect(mockConfirm).not.toHaveBeenCalled();
|
|
424
|
+
|
|
425
|
+
const content = await fs.readFile('.env', 'utf-8');
|
|
426
|
+
expect(content).toContain('API_KEY="abc"');
|
|
427
|
+
expect(content).toContain('DB_PASS="xyz"');
|
|
428
|
+
expect(result.synced).toBe(2);
|
|
429
|
+
expect(mockSpinnerStart).toHaveBeenCalledWith('Fetching secrets from AWS...');
|
|
430
|
+
expect(mockOutro).toHaveBeenCalledWith(expect.stringContaining('Successfully synced 2 secrets to .env'));
|
|
431
|
+
expect(trackEvent).toHaveBeenCalledWith('secrets_pull', expect.objectContaining({ variablesCount: 2 }));
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('asks to overwrite and applies remote values when confirm returns true', async () => {
|
|
435
|
+
mockSend.mockResolvedValueOnce({ SecretString: JSON.stringify({ API_KEY: 'remote', NEW_KEY: 'new' }) });
|
|
436
|
+
await fs.writeFile('.env', 'API_KEY=local\nLOCAL_ONLY=keep');
|
|
437
|
+
mockConfirm.mockResolvedValueOnce(true);
|
|
438
|
+
|
|
439
|
+
await pullSecrets('.env', 'my-project');
|
|
440
|
+
|
|
441
|
+
expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({
|
|
442
|
+
message: expect.stringContaining('Overwrite local values with remote'),
|
|
443
|
+
}));
|
|
444
|
+
const content = await fs.readFile('.env', 'utf-8');
|
|
445
|
+
expect(content).toContain('API_KEY="remote"');
|
|
446
|
+
expect(content).toContain('NEW_KEY="new"');
|
|
447
|
+
expect(content).toContain('LOCAL_ONLY="keep"');
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('preserves local values for conflicts when confirm returns false', async () => {
|
|
451
|
+
mockSend.mockResolvedValueOnce({ SecretString: JSON.stringify({ API_KEY: 'remote', NEW_KEY: 'new' }) });
|
|
452
|
+
await fs.writeFile('.env', 'API_KEY=local\nLOCAL_ONLY=keep');
|
|
453
|
+
mockConfirm.mockResolvedValueOnce(false);
|
|
454
|
+
|
|
455
|
+
const result = await pullSecrets('.env', 'my-project');
|
|
456
|
+
|
|
457
|
+
expect(result.overwritten).toBe(false);
|
|
458
|
+
const content = await fs.readFile('.env', 'utf-8');
|
|
459
|
+
expect(content).toContain('API_KEY="local"');
|
|
460
|
+
expect(content).toContain('NEW_KEY="new"');
|
|
461
|
+
expect(content).toContain('LOCAL_ONLY="keep"');
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('auto-overwrites conflicts in headless mode without prompting', async () => {
|
|
465
|
+
mockSend.mockResolvedValueOnce({ SecretString: JSON.stringify({ API_KEY: 'remote' }) });
|
|
466
|
+
await fs.writeFile('.env', 'API_KEY=local');
|
|
467
|
+
|
|
468
|
+
await pullSecrets('.env', 'my-project', { isHeadless: true });
|
|
469
|
+
|
|
470
|
+
expect(mockConfirm).not.toHaveBeenCalled();
|
|
471
|
+
const content = await fs.readFile('.env', 'utf-8');
|
|
472
|
+
expect(content).toContain('API_KEY="remote"');
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('defaults to .env when file argument is omitted', async () => {
|
|
476
|
+
mockSend.mockResolvedValueOnce({ SecretString: JSON.stringify({ A: '1' }) });
|
|
477
|
+
|
|
478
|
+
await pullSecrets(undefined, 'my-project', { isHeadless: true });
|
|
479
|
+
|
|
480
|
+
const content = await fs.readFile('.env', 'utf-8');
|
|
481
|
+
expect(content).toContain('A="1"');
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('handles missing remote vault by suggesting secrets push', async () => {
|
|
485
|
+
mockSend.mockRejectedValueOnce({ name: 'ResourceNotFoundException', message: 'missing' });
|
|
486
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
487
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
488
|
+
|
|
489
|
+
await pullSecrets('.env', 'my-project', { isHeadless: true });
|
|
490
|
+
|
|
491
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
492
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('secrets push'));
|
|
493
|
+
|
|
494
|
+
exitSpy.mockRestore();
|
|
495
|
+
consoleSpy.mockRestore();
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
describe('Secrets Audit Command', () => {
|
|
500
|
+
const originalCwd = process.cwd();
|
|
501
|
+
const testDir = path.join(originalCwd, 'tests', '.tmp-secrets-pull-audit');
|
|
502
|
+
|
|
503
|
+
beforeEach(async () => {
|
|
504
|
+
await fs.mkdir(path.join(testDir, 'terraform'), { recursive: true });
|
|
505
|
+
process.chdir(testDir);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
afterEach(async () => {
|
|
509
|
+
process.chdir(originalCwd);
|
|
510
|
+
await fs.rm(testDir, { recursive: true, force: true });
|
|
511
|
+
vi.clearAllMocks();
|
|
512
|
+
mockSend.mockReset();
|
|
513
|
+
mockSend.mockResolvedValue({});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('reports missing, mismatched, and untracked keys with drift count', async () => {
|
|
517
|
+
mockSend.mockResolvedValueOnce({ SecretString: JSON.stringify({ SAME: '1', CHANGED: 'remote', ONLY_REMOTE: 'x' }) });
|
|
518
|
+
await fs.writeFile('.env', 'SAME=1\nCHANGED=local\nONLY_LOCAL=y');
|
|
519
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
520
|
+
|
|
521
|
+
const result = await auditSecrets('.env', 'my-project');
|
|
522
|
+
|
|
523
|
+
expect(MockGetSecretValueCommand).toHaveBeenCalledWith({ SecretId: 'my-project-secrets' });
|
|
524
|
+
expect(mockSpinnerStart).toHaveBeenCalledWith('Auditing local environment against AWS...');
|
|
525
|
+
expect(result).toEqual({
|
|
526
|
+
missingLocally: ['ONLY_REMOTE'],
|
|
527
|
+
mismatched: ['CHANGED'],
|
|
528
|
+
untrackedLocally: ['ONLY_LOCAL'],
|
|
529
|
+
driftCount: 3,
|
|
530
|
+
});
|
|
531
|
+
expect(trackEvent).toHaveBeenCalledWith('secrets_audit', expect.objectContaining({ driftCount: 3 }));
|
|
532
|
+
expect(mockOutro).toHaveBeenCalledWith(expect.stringContaining('3 drifted variable(s)'));
|
|
533
|
+
|
|
534
|
+
consoleSpy.mockRestore();
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it('reports in-sync when local matches remote', async () => {
|
|
538
|
+
mockSend.mockResolvedValueOnce({ SecretString: JSON.stringify({ A: '1' }) });
|
|
539
|
+
await fs.writeFile('.env', 'A=1');
|
|
540
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
541
|
+
|
|
542
|
+
const result = await auditSecrets('.env', 'my-project');
|
|
543
|
+
|
|
544
|
+
expect(result.driftCount).toBe(0);
|
|
545
|
+
expect(trackEvent).toHaveBeenCalledWith('secrets_audit', expect.objectContaining({ driftCount: 0 }));
|
|
546
|
+
|
|
547
|
+
consoleSpy.mockRestore();
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('treats a missing local file as fully missing locally', async () => {
|
|
551
|
+
mockSend.mockResolvedValueOnce({ SecretString: JSON.stringify({ A: '1', B: '2' }) });
|
|
552
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
553
|
+
|
|
554
|
+
const result = await auditSecrets('.env', 'my-project');
|
|
555
|
+
|
|
556
|
+
expect(result.missingLocally).toEqual(['A', 'B']);
|
|
557
|
+
expect(result.driftCount).toBe(2);
|
|
558
|
+
|
|
559
|
+
consoleSpy.mockRestore();
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it('handles missing remote vault by suggesting secrets push', async () => {
|
|
563
|
+
mockSend.mockRejectedValueOnce({ name: 'ResourceNotFoundException', message: 'missing' });
|
|
564
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
565
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
566
|
+
|
|
567
|
+
await auditSecrets('.env', 'my-project');
|
|
568
|
+
|
|
569
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
570
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('secrets push'));
|
|
571
|
+
|
|
572
|
+
exitSpy.mockRestore();
|
|
573
|
+
consoleSpy.mockRestore();
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
describe('Secrets Region Resolution', () => {
|
|
578
|
+
const originalCwd = process.cwd();
|
|
579
|
+
const testDir = path.join(originalCwd, 'tests', '.tmp-secrets-region');
|
|
580
|
+
let savedAwsRegion;
|
|
581
|
+
let savedAwsDefaultRegion;
|
|
582
|
+
|
|
583
|
+
beforeEach(async () => {
|
|
584
|
+
await fs.mkdir(path.join(testDir, 'terraform'), { recursive: true });
|
|
585
|
+
process.chdir(testDir);
|
|
586
|
+
savedAwsRegion = process.env.AWS_REGION;
|
|
587
|
+
savedAwsDefaultRegion = process.env.AWS_DEFAULT_REGION;
|
|
588
|
+
delete process.env.AWS_REGION;
|
|
589
|
+
delete process.env.AWS_DEFAULT_REGION;
|
|
590
|
+
mockConfirm.mockReset();
|
|
591
|
+
mockConfirm.mockResolvedValue(false);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
afterEach(async () => {
|
|
595
|
+
process.chdir(originalCwd);
|
|
596
|
+
await fs.rm(testDir, { recursive: true, force: true });
|
|
597
|
+
if (savedAwsRegion === undefined) delete process.env.AWS_REGION;
|
|
598
|
+
else process.env.AWS_REGION = savedAwsRegion;
|
|
599
|
+
if (savedAwsDefaultRegion === undefined) delete process.env.AWS_DEFAULT_REGION;
|
|
600
|
+
else process.env.AWS_DEFAULT_REGION = savedAwsDefaultRegion;
|
|
601
|
+
vi.clearAllMocks();
|
|
602
|
+
mockSend.mockReset();
|
|
603
|
+
mockSend.mockResolvedValue({});
|
|
604
|
+
mockEcsSend.mockReset();
|
|
605
|
+
mockEcsSend.mockResolvedValue({});
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('prefers the explicit region option over env and files', async () => {
|
|
609
|
+
process.env.AWS_REGION = 'eu-west-1';
|
|
610
|
+
await fs.writeFile(path.join('terraform', 'backend.tf'), 'region = "ap-south-1"');
|
|
611
|
+
expect(await resolveSecretsRegion({ region: 'us-west-2' })).toBe('us-west-2');
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it('falls back through AWS_REGION then AWS_DEFAULT_REGION', async () => {
|
|
615
|
+
process.env.AWS_REGION = 'eu-west-1';
|
|
616
|
+
process.env.AWS_DEFAULT_REGION = 'ap-south-1';
|
|
617
|
+
expect(await resolveSecretsRegion({})).toBe('eu-west-1');
|
|
618
|
+
delete process.env.AWS_REGION;
|
|
619
|
+
expect(await resolveSecretsRegion({})).toBe('ap-south-1');
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it('reads the region from terraform/backend.tf and ignores placeholders', async () => {
|
|
623
|
+
await fs.writeFile(path.join('terraform', 'backend.tf'), 'region = "eu-central-1"');
|
|
624
|
+
expect(await resolveSecretsRegion({})).toBe('eu-central-1');
|
|
625
|
+
await fs.writeFile(path.join('terraform', 'backend.tf'), 'region = "{{REGION}}"');
|
|
626
|
+
expect(await resolveSecretsRegion({})).toBe('us-east-2');
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it('falls back to us-east-2 and trims blank values', async () => {
|
|
630
|
+
expect(await resolveSecretsRegion({})).toBe('us-east-2');
|
|
631
|
+
process.env.AWS_REGION = ' ';
|
|
632
|
+
expect(await resolveSecretsRegion({})).toBe('us-east-2');
|
|
633
|
+
expect(await resolveSecretsRegion({ region: ' eu-west-1 ' })).toBe('eu-west-1');
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it('pushSecrets builds the SDK client with the resolved default region', async () => {
|
|
637
|
+
await fs.writeFile('.env', 'API_KEY=abc');
|
|
638
|
+
await pushSecrets('.env', 'my-project');
|
|
639
|
+
expect(MockSecretsManagerClient).toHaveBeenCalledWith({ region: 'us-east-2' });
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it('pushSecrets honors an explicit region option', async () => {
|
|
643
|
+
await fs.writeFile('.env', 'API_KEY=abc');
|
|
644
|
+
await pushSecrets('.env', 'my-project', { region: 'eu-west-1' });
|
|
645
|
+
expect(MockSecretsManagerClient).toHaveBeenCalledWith({ region: 'eu-west-1' });
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it('pullSecrets builds the SDK client with the resolved default region', async () => {
|
|
649
|
+
mockSend.mockResolvedValueOnce({ SecretString: JSON.stringify({ API_KEY: 'abc' }) });
|
|
650
|
+
await pullSecrets('.env', 'my-project', { isHeadless: true });
|
|
651
|
+
expect(MockSecretsManagerClient).toHaveBeenCalledWith({ region: 'us-east-2' });
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it('auditSecrets builds the SDK client with the resolved default region', async () => {
|
|
655
|
+
mockSend.mockResolvedValueOnce({ SecretString: JSON.stringify({ A: '1' }) });
|
|
656
|
+
await fs.writeFile('.env', 'A=1');
|
|
657
|
+
await auditSecrets('.env', 'my-project');
|
|
658
|
+
expect(MockSecretsManagerClient).toHaveBeenCalledWith({ region: 'us-east-2' });
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it('pushSecrets builds the ECS client with the resolved default region on restart', async () => {
|
|
662
|
+
await fs.writeFile('.env', 'API_KEY=newvalue');
|
|
663
|
+
mockSend.mockResolvedValueOnce({ SecretString: JSON.stringify({ API_KEY: 'oldvalue' }) });
|
|
664
|
+
mockSend.mockResolvedValueOnce({});
|
|
665
|
+
mockConfirm.mockResolvedValueOnce(true);
|
|
666
|
+
|
|
667
|
+
const result = await pushSecrets('.env', 'my-project');
|
|
668
|
+
|
|
669
|
+
expect(result).toEqual(expect.objectContaining({ restarted: true }));
|
|
670
|
+
expect(MockECSClient).toHaveBeenCalledWith({ region: 'us-east-2' });
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it('tracks ENV_FILE_MISSING when headless and the .env file is absent', async () => {
|
|
674
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
675
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
676
|
+
|
|
677
|
+
try {
|
|
678
|
+
await pushSecrets('.env', 'my-project', { isHeadless: true });
|
|
679
|
+
expect(trackEvent).toHaveBeenCalledWith('secrets_pushed', expect.objectContaining({
|
|
680
|
+
success: false,
|
|
681
|
+
error_code: 'ENV_FILE_MISSING',
|
|
682
|
+
}));
|
|
683
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
684
|
+
} finally {
|
|
685
|
+
exitSpy.mockRestore();
|
|
686
|
+
consoleSpy.mockRestore();
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it('tracks REMOTE_SECRET_MALFORMED when the remote payload is not JSON', async () => {
|
|
691
|
+
mockSend.mockResolvedValueOnce({ SecretString: 'not-json{{{' });
|
|
692
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { });
|
|
693
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
694
|
+
|
|
695
|
+
try {
|
|
696
|
+
await pullSecrets('.env', 'my-project', { isHeadless: true });
|
|
697
|
+
expect(trackEvent).toHaveBeenCalledWith('secrets_pull', expect.objectContaining({
|
|
698
|
+
success: false,
|
|
699
|
+
error_code: 'REMOTE_SECRET_MALFORMED',
|
|
700
|
+
}));
|
|
701
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
702
|
+
} finally {
|
|
703
|
+
exitSpy.mockRestore();
|
|
704
|
+
consoleSpy.mockRestore();
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
describe('Secrets Push on --target lambda projects', () => {
|
|
710
|
+
const originalCwd = process.cwd();
|
|
711
|
+
const testDir = path.join(originalCwd, 'tests', '.tmp-secrets-lambda-env');
|
|
712
|
+
|
|
713
|
+
beforeEach(async () => {
|
|
714
|
+
await fs.mkdir(path.join(testDir, 'terraform'), { recursive: true });
|
|
715
|
+
await fs.writeFile(
|
|
716
|
+
path.join(testDir, 'terraform', 'main.tf'),
|
|
717
|
+
'locals {\n app_name = "myapp${local.env_suffix}"\n}\nresource "aws_lambda_function" "app" {}\n'
|
|
718
|
+
);
|
|
719
|
+
process.chdir(testDir);
|
|
720
|
+
mockConfirm.mockReset();
|
|
721
|
+
mockConfirm.mockResolvedValue(false);
|
|
722
|
+
mockEcsSend.mockReset();
|
|
723
|
+
mockEcsSend.mockResolvedValue({});
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
afterEach(async () => {
|
|
727
|
+
process.chdir(originalCwd);
|
|
728
|
+
await fs.rm(testDir, { recursive: true, force: true });
|
|
729
|
+
vi.clearAllMocks();
|
|
730
|
+
mockSend.mockReset();
|
|
731
|
+
mockSend.mockResolvedValue({});
|
|
732
|
+
mockEcsSend.mockReset();
|
|
733
|
+
mockEcsSend.mockResolvedValue({});
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it('skips the ECS restart prompt when keys are unchanged', async () => {
|
|
737
|
+
await fs.writeFile('.env', 'API_KEY=new-value');
|
|
738
|
+
mockSend.mockResolvedValueOnce({ SecretString: JSON.stringify({ API_KEY: 'old-value' }) });
|
|
739
|
+
mockSend.mockResolvedValueOnce({});
|
|
740
|
+
|
|
741
|
+
const result = await pushSecrets('.env', 'myapp');
|
|
742
|
+
|
|
743
|
+
expect(result).toEqual({ keysChanged: false, restarted: false });
|
|
744
|
+
expect(mockConfirm).not.toHaveBeenCalled();
|
|
745
|
+
expect(mockEcsSend).not.toHaveBeenCalled();
|
|
746
|
+
expect(trackEvent).toHaveBeenCalledWith('secrets_pushed', expect.objectContaining({
|
|
747
|
+
keys_changed: false,
|
|
748
|
+
ecs_restart: false,
|
|
749
|
+
success: true,
|
|
750
|
+
}));
|
|
751
|
+
});
|
|
752
|
+
});
|