underpost 3.2.80 → 3.3.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/ghpkg.ci.yml +7 -1
- package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -16
- package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
- package/.github/workflows/release.cd.yml +1 -9
- package/CHANGELOG.md +291 -1
- package/CLI-HELP.md +174 -23
- package/README.md +5 -2
- package/bin/build.js +7 -5
- package/bin/deploy.js +19 -17
- package/deploy/lib/logging.sh +96 -0
- package/deploy/pwa-microservices-template/deploy.sh +72 -0
- package/deploy/release/deploy.sh +62 -0
- package/docker-compose.yml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -1
- package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +52 -0
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/manifests/deployment/playwright/deployment.yaml +1 -1
- package/manifests/mongodb/kustomization.yaml +4 -1
- package/manifests/mongodb/statefulset.yaml +4 -0
- package/manifests/mongodb/storage-class.yaml +9 -2
- package/package.json +19 -19
- package/scripts/audit-selinux.sh +64 -0
- package/scripts/coverall-test.sh +24 -0
- package/scripts/gpu-diag.sh +0 -0
- package/scripts/ip-info.sh +0 -0
- package/scripts/k3s-node-setup.sh +18 -15
- package/scripts/kubeadm-node-setup.sh +12 -23
- package/scripts/link-local-underpost-cli.sh +0 -0
- package/scripts/lxd-vm-setup.sh +0 -0
- package/scripts/maas-nat-firewalld.sh +0 -0
- package/scripts/nat-iptables.sh +12 -4
- package/scripts/rhel-grpc-setup.sh +0 -0
- package/scripts/rocky-kickstart.sh +25 -9
- package/scripts/test-monitor.sh +4 -3
- package/src/cli/baremetal.js +1 -2
- package/src/cli/cloud-init.js +1 -1
- package/src/cli/cluster.js +786 -96
- package/src/cli/db.js +11 -4
- package/src/cli/deploy.js +1698 -177
- package/src/cli/docker-compose.js +19 -178
- package/src/cli/env.js +1 -1
- package/src/cli/image.js +15 -7
- package/src/cli/index.js +245 -44
- package/src/cli/ipfs.js +82 -11
- package/src/cli/lxd.js +1 -1
- package/src/cli/monitor.js +2 -2
- package/src/cli/release.js +57 -22
- package/src/cli/repository.js +12 -10
- package/src/cli/run.js +2195 -427
- package/src/cli/secrets.js +969 -0
- package/src/cli/ssh.js +206 -105
- package/src/cli/system.js +26 -13
- package/src/cli/test.js +1 -1
- package/src/cli/vultr.js +583 -0
- package/src/cli/wireguard.js +2125 -0
- package/src/client-builder/client-build.js +102 -13
- package/src/client-builder/ssr.js +27 -73
- package/src/db/mongo/MongoBootstrap.js +295 -54
- package/src/db/mongo/MongooseDB.js +51 -32
- package/src/index.js +25 -1
- package/src/projects/underpost/catalog-underpost.js +4 -1
- package/src/server/backup.js +1 -1
- package/src/server/conf.js +1216 -168
- package/src/server/cri.js +70 -0
- package/src/server/cron.js +249 -51
- package/src/server/dns.js +100 -6
- package/src/server/environment.js +98 -0
- package/src/server/forward-proxy.js +549 -0
- package/src/server/middlewares.js +56 -1
- package/src/server/process.js +0 -1
- package/src/server/selinux.js +185 -0
- package/src/server/systemd.js +205 -0
- package/src/server/underpost-compression.js +186 -0
- package/src/server/underpost-gateway.js +1083 -0
- package/src/server/underpost-ingress.js +380 -0
- package/test/cluster-instances.test.js +435 -0
- package/test/deploy-node-placement.test.js +45 -0
- package/test/instance-traffic-plan.test.js +710 -0
- package/test/selinux.test.js +71 -0
- package/test/sops-secret-store.test.js +612 -0
- package/test/underpost-gateway.test.js +510 -0
- package/test/underpost-ingress.test.js +305 -0
- package/test/wireguard-edge.test.js +1177 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { expect } from 'chai';
|
|
4
|
+
import SELinuxService, {
|
|
5
|
+
runSELinuxCommands,
|
|
6
|
+
selinuxContainerSharedContextCommandsFactory,
|
|
7
|
+
selinuxEnforcingCommandsFactory,
|
|
8
|
+
selinuxFileContextCommandFactory,
|
|
9
|
+
selinuxRestoreconCommandFactory,
|
|
10
|
+
selinuxSshContextCommandsFactory,
|
|
11
|
+
selinuxSshPortCommandsFactory,
|
|
12
|
+
shellArgumentFactory,
|
|
13
|
+
} from '../src/server/selinux.js';
|
|
14
|
+
|
|
15
|
+
describe('SELinux utilities', () => {
|
|
16
|
+
it('provides one canonical class API with named factory aliases', () => {
|
|
17
|
+
expect(SELinuxService.selinuxEnforcingCommandsFactory).to.equal(selinuxEnforcingCommandsFactory);
|
|
18
|
+
expect(SELinuxService.runSELinuxCommands).to.equal(runSELinuxCommands);
|
|
19
|
+
expect(SELinuxService.shellArgumentFactory).to.equal(shellArgumentFactory);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('quotes shell arguments and restores policy contexts', () => {
|
|
23
|
+
expect(shellArgumentFactory("/home/o'hara/.ssh")).to.equal("'/home/o'\"'\"'hara/.ssh'");
|
|
24
|
+
const command = selinuxRestoreconCommandFactory(['/root/.ssh', '/etc/sudoers.d/90_admin']);
|
|
25
|
+
expect(command).to.include("restorecon -RF '/root/.ssh'");
|
|
26
|
+
expect(command).to.include("restorecon -RF '/etc/sudoers.d/90_admin'");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('shares container-mounted host paths with a persistent container_file_t mapping', () => {
|
|
30
|
+
const commands = selinuxContainerSharedContextCommandsFactory(['/etc/kubernetes', '/var/lib/etcd']);
|
|
31
|
+
expect(commands).to.have.length(3);
|
|
32
|
+
expect(commands[0]).to.include("semanage fcontext -a -t container_file_t '/etc/kubernetes(/.*)?'");
|
|
33
|
+
expect(commands[0]).to.include("semanage fcontext -m -t container_file_t '/etc/kubernetes(/.*)?'");
|
|
34
|
+
expect(commands[1]).to.include("container_file_t '/var/lib/etcd(/.*)?'");
|
|
35
|
+
expect(commands[2]).to.include("restorecon -RF '/etc/kubernetes'");
|
|
36
|
+
expect(() => selinuxContainerSharedContextCommandsFactory([])).to.throw(TypeError);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('requires both a path and a type for file context mappings', () => {
|
|
40
|
+
expect(() => selinuxFileContextCommandFactory('', { type: 'container_file_t' })).to.throw(TypeError);
|
|
41
|
+
expect(() => selinuxFileContextCommandFactory('/var/lib/etcd', {})).to.throw(TypeError);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('adds persistent mappings only for non-standard SSH homes', () => {
|
|
45
|
+
expect(selinuxSshContextCommandsFactory({ sshDirectory: '/home/admin/.ssh' })).to.have.length(1);
|
|
46
|
+
expect(selinuxSshContextCommandsFactory({ sshDirectory: '/srv/admin/.ssh' })[0]).to.include(
|
|
47
|
+
'semanage fcontext -a -t ssh_home_t',
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('restores requested contexts before activating Enforcing mode', () => {
|
|
52
|
+
const commands = selinuxEnforcingCommandsFactory({ restorePaths: ['/var/lib/containerd'] });
|
|
53
|
+
expect(commands[1]).to.include('touch /.autorelabel');
|
|
54
|
+
expect(commands[2]).to.include('restorecon');
|
|
55
|
+
expect(commands[3]).to.include('setenforce 1');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('maps only custom SSH ports and validates the port range', () => {
|
|
59
|
+
expect(selinuxSshPortCommandsFactory({ port: 22 })).to.deep.equal([]);
|
|
60
|
+
expect(selinuxSshPortCommandsFactory({ port: 2222 })[0]).to.include(
|
|
61
|
+
'semanage port -a -t ssh_port_t -p tcp 2222',
|
|
62
|
+
);
|
|
63
|
+
expect(() => selinuxSshPortCommandsFactory({ port: 70000 })).to.throw(RangeError);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('executes generated commands with an injected executor', () => {
|
|
67
|
+
const executed = [];
|
|
68
|
+
runSELinuxCommands(['one', 'two'], { execute: (command) => executed.push(command) });
|
|
69
|
+
expect(executed).to.deep.equal(['one', 'two']);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { expect } from 'chai';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import Underpost from '../src/index.js';
|
|
7
|
+
|
|
8
|
+
const sops = () => Underpost.secret.sops;
|
|
9
|
+
// Unique namespace so the fixture can never collide with (or clean up) a real
|
|
10
|
+
// encrypted store under the gitignored engine-private tree.
|
|
11
|
+
const TEST_NAMESPACE = 'sops-store-test-ns';
|
|
12
|
+
const TEST_DIR = `./engine-private/secrets/${TEST_NAMESPACE}`;
|
|
13
|
+
|
|
14
|
+
const encryptedFixture = `apiVersion: v1
|
|
15
|
+
kind: Secret
|
|
16
|
+
metadata:
|
|
17
|
+
name: fixture-secret
|
|
18
|
+
namespace: ${TEST_NAMESPACE}
|
|
19
|
+
type: Opaque
|
|
20
|
+
stringData:
|
|
21
|
+
password: ENC[AES256_GCM,data:Lm8xQ2vT,iv:3fB7yU1jH6sD0gW2cN8mZ4kP5qX9tA7rE1vL3oI6uY0=,tag:2cF5nQ8sW1zA4dG7jL0mB==,type:str]
|
|
22
|
+
sops:
|
|
23
|
+
age:
|
|
24
|
+
- recipient: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
|
|
25
|
+
enc: |
|
|
26
|
+
-----BEGIN AGE ENCRYPTED FILE-----
|
|
27
|
+
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqTndTOFRIRGxZUmZZNXFy
|
|
28
|
+
-----END AGE ENCRYPTED FILE-----
|
|
29
|
+
- recipient: age1w7yx5kq0h3n2t4mzr9vp8ldjc6fs0eguya3hx2nq7r5tvk9m4dlq8zwptn
|
|
30
|
+
enc: |
|
|
31
|
+
-----BEGIN AGE ENCRYPTED FILE-----
|
|
32
|
+
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwUXcyVGtMOXZSN21OY0Zq
|
|
33
|
+
-----END AGE ENCRYPTED FILE-----
|
|
34
|
+
encrypted_regex: ^(data|stringData)$
|
|
35
|
+
version: 3.10.2
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
describe('sops encrypted secret store', () => {
|
|
39
|
+
describe('key file resolution', () => {
|
|
40
|
+
const originalKeyFile = process.env.SOPS_AGE_KEY_FILE;
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
if (originalKeyFile === undefined) delete process.env.SOPS_AGE_KEY_FILE;
|
|
44
|
+
else process.env.SOPS_AGE_KEY_FILE = originalKeyFile;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('honors SOPS_AGE_KEY_FILE so key location has a single source of truth', () => {
|
|
48
|
+
process.env.SOPS_AGE_KEY_FILE = '/srv/keys/age.txt';
|
|
49
|
+
expect(sops().keyFile()).to.equal('/srv/keys/age.txt');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('falls back to the sops default key location', () => {
|
|
53
|
+
delete process.env.SOPS_AGE_KEY_FILE;
|
|
54
|
+
expect(sops().keyFile()).to.equal(`${os.homedir()}/.config/sops/age/keys.txt`);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('store layout', () => {
|
|
59
|
+
it('resolves a manifest to <store>/<namespace>/<name>.enc.yaml', () => {
|
|
60
|
+
expect(sops().manifestPath('postgres-secret', 'default')).to.equal(
|
|
61
|
+
'./engine-private/secrets/default/postgres-secret.enc.yaml',
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('defaults to the default namespace', () => {
|
|
66
|
+
expect(sops().manifestPath('mariadb-secret')).to.equal(
|
|
67
|
+
'./engine-private/secrets/default/mariadb-secret.enc.yaml',
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('presence detection and origin seed fallback', () => {
|
|
73
|
+
const storeRoot = './engine-private/secrets';
|
|
74
|
+
let createdStoreRoot = false;
|
|
75
|
+
|
|
76
|
+
before(() => {
|
|
77
|
+
createdStoreRoot = !fs.existsSync(storeRoot);
|
|
78
|
+
fs.ensureDirSync(TEST_DIR);
|
|
79
|
+
fs.writeFileSync(`${TEST_DIR}/fixture-secret.enc.yaml`, encryptedFixture, 'utf8');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
after(() => {
|
|
83
|
+
fs.removeSync(TEST_DIR);
|
|
84
|
+
// Only reclaim the store root when this suite created it, so a real
|
|
85
|
+
// encrypted store is never touched.
|
|
86
|
+
if (createdStoreRoot && fs.existsSync(storeRoot) && fs.readdirSync(storeRoot).length === 0)
|
|
87
|
+
fs.removeSync(storeRoot);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('detects an encrypted manifest that exists', () => {
|
|
91
|
+
expect(sops().has('fixture-secret', TEST_NAMESPACE)).to.equal(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('reports no manifest for a secret not yet onboarded', () => {
|
|
95
|
+
expect(sops().has('not-onboarded-secret', TEST_NAMESPACE)).to.equal(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('reports no manifest for a namespace with no store', () => {
|
|
99
|
+
expect(sops().has('fixture-secret', 'namespace-without-a-store')).to.equal(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('declines to apply when no encrypted manifest exists, leaving the caller its origin seed path', () => {
|
|
103
|
+
// Must return false rather than throw: this is the branch that keeps clusters
|
|
104
|
+
// not yet onboarded to the encrypted store seeding from plaintext credentials.
|
|
105
|
+
expect(sops().applyIfPresent('not-onboarded-secret', TEST_NAMESPACE)).to.equal(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('lists recipients from the plaintext sops metadata block without a private key', () => {
|
|
109
|
+
const lines = [];
|
|
110
|
+
const originalLog = console.log;
|
|
111
|
+
console.log = (line) => lines.push(line);
|
|
112
|
+
try {
|
|
113
|
+
sops().list();
|
|
114
|
+
} finally {
|
|
115
|
+
console.log = originalLog;
|
|
116
|
+
}
|
|
117
|
+
const entry = lines.find((line) => line.includes(`${TEST_NAMESPACE}/fixture-secret.enc.yaml`));
|
|
118
|
+
expect(entry).to.be.a('string');
|
|
119
|
+
expect(entry).to.include('age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p');
|
|
120
|
+
expect(entry).to.include('age1w7yx5kq0h3n2t4mzr9vp8ldjc6fs0eguya3hx2nq7r5tvk9m4dlq8zwptn');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('refuses to apply a namespace with no stored manifests', () => {
|
|
124
|
+
expect(() => sops().apply('namespace-without-a-store')).to.throw(/No encrypted secrets for namespace/);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('cluster initialization wiring', () => {
|
|
129
|
+
const clusterSource = fs.readFileSync(new URL('../src/cli/cluster.js', import.meta.url), 'utf8');
|
|
130
|
+
|
|
131
|
+
for (const [flag, secretName] of [
|
|
132
|
+
['mariadb', 'mariadb-secret'],
|
|
133
|
+
['mysql', 'mysql-secret'],
|
|
134
|
+
['postgresql', 'postgres-secret'],
|
|
135
|
+
]) {
|
|
136
|
+
it(`prefers the encrypted store for ${secretName} and keeps the origin seed path`, () => {
|
|
137
|
+
const start = clusterSource.indexOf(`if (options.${flag}) {`);
|
|
138
|
+
expect(start, `options.${flag} branch not found`).to.be.greaterThan(-1);
|
|
139
|
+
const branch = clusterSource.slice(start, start + 1200);
|
|
140
|
+
expect(branch).to.include(`Underpost.secret.sops.applyIfPresent('${secretName}', options.namespace)`);
|
|
141
|
+
expect(branch).to.include(`kubectl create secret generic ${secretName}`);
|
|
142
|
+
// The origin seed creation must stay guarded by the negated store lookup,
|
|
143
|
+
// never run unconditionally alongside the decrypted apply.
|
|
144
|
+
expect(branch).to.match(
|
|
145
|
+
new RegExp(
|
|
146
|
+
`if \\(!Underpost\\.secret\\.sops\\.applyIfPresent\\('${secretName}'[\\s\\S]{0,80}kubectl create secret generic ${secretName}`,
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
it('delegates host tooling install to UnderpostSecret instead of duplicating it', () => {
|
|
153
|
+
expect(clusterSource).to.include('Underpost.secret.sops.installTooling()');
|
|
154
|
+
// The binary install logic must live in exactly one place.
|
|
155
|
+
expect(clusterSource).to.not.include('releases/download/${SOPS_VERSION}');
|
|
156
|
+
expect(clusterSource).to.not.include('const SOPS_VERSION');
|
|
157
|
+
expect(clusterSource).to.not.include('const AGE_VERSION');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('pins the sops and age versions in the secrets module', () => {
|
|
161
|
+
const secretsSource = fs.readFileSync(new URL('../src/cli/secrets.js', import.meta.url), 'utf8');
|
|
162
|
+
expect(secretsSource).to.match(/const SOPS_VERSION = 'v\d+\.\d+\.\d+';/);
|
|
163
|
+
expect(secretsSource).to.match(/const AGE_VERSION = 'v\d+\.\d+\.\d+';/);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('decrypt-to-apply pipeline', () => {
|
|
168
|
+
const secretsSource = fs.readFileSync(new URL('../src/cli/secrets.js', import.meta.url), 'utf8');
|
|
169
|
+
|
|
170
|
+
it('streams decrypted output into kubectl without writing plaintext to disk', () => {
|
|
171
|
+
expect(secretsSource).to.include('sops --decrypt "${manifestPath}" ');
|
|
172
|
+
expect(secretsSource).to.include('| kubectl apply -f -');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('guards the decrypt pipe with pipefail so a decrypt failure cannot apply an empty stream', () => {
|
|
176
|
+
expect(secretsSource).to.include("bash -c 'set -o pipefail;");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('passes the age key as a file path, never as key material in the environment', () => {
|
|
180
|
+
expect(secretsSource).to.include('SOPS_AGE_KEY_FILE=');
|
|
181
|
+
expect(secretsSource).to.not.match(/SOPS_AGE_KEY=[^F]/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('re-keys under pipefail with logging suppressed', () => {
|
|
185
|
+
const start = secretsSource.indexOf(' rotate(recipient, options = {}) {');
|
|
186
|
+
expect(start, 'rotate() not found').to.be.greaterThan(-1);
|
|
187
|
+
const body = secretsSource.slice(start, secretsSource.indexOf('\n /**', start));
|
|
188
|
+
expect(body).to.include('set -o pipefail');
|
|
189
|
+
expect(body).to.include('sops --config');
|
|
190
|
+
expect(body).to.include('updatekeys --yes');
|
|
191
|
+
expect(body).to.include('disableLog: true');
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('creation-rule recipient parsing', () => {
|
|
196
|
+
const storeRoot = './engine-private/secrets';
|
|
197
|
+
const confPath = `${storeRoot}/.sops.yaml`;
|
|
198
|
+
let createdStoreRoot = false;
|
|
199
|
+
let savedConf = null;
|
|
200
|
+
|
|
201
|
+
const rule = (ageValue) =>
|
|
202
|
+
['creation_rules:', ' - path_regex: engine-private/secrets/.*\\.enc\\.yaml$', ` age: ${ageValue}`, ''].join(
|
|
203
|
+
'\n',
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
before(() => {
|
|
207
|
+
createdStoreRoot = !fs.existsSync(storeRoot);
|
|
208
|
+
fs.ensureDirSync(storeRoot);
|
|
209
|
+
if (fs.existsSync(confPath)) savedConf = fs.readFileSync(confPath, 'utf8');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
after(() => {
|
|
213
|
+
if (savedConf !== null) fs.writeFileSync(confPath, savedConf, 'utf8');
|
|
214
|
+
else fs.removeSync(confPath);
|
|
215
|
+
if (createdStoreRoot && fs.existsSync(storeRoot) && fs.readdirSync(storeRoot).length === 0)
|
|
216
|
+
fs.removeSync(storeRoot);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('reads a single-line recipient list', () => {
|
|
220
|
+
fs.writeFileSync(confPath, rule('age1aaa,age1bbb'), 'utf8');
|
|
221
|
+
expect(sops().creationRecipients()).to.deep.equal(['age1aaa', 'age1bbb']);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('reads the folded multi-line recipient form sops also accepts', () => {
|
|
225
|
+
fs.writeFileSync(confPath, rule('>-\n age1aaa,\n age1bbb'), 'utf8');
|
|
226
|
+
expect(sops().creationRecipients()).to.deep.equal(['age1aaa', 'age1bbb']);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('collapses a folded list to one canonical line on write', () => {
|
|
230
|
+
fs.writeFileSync(confPath, rule('>-\n age1aaa,\n age1bbb'), 'utf8');
|
|
231
|
+
sops().writeCreationRecipients(['age1ccc']);
|
|
232
|
+
const written = fs.readFileSync(confPath, 'utf8');
|
|
233
|
+
expect(written).to.include(' age: age1ccc');
|
|
234
|
+
expect(written).to.not.include('age1aaa');
|
|
235
|
+
expect(written).to.include('creation_rules:');
|
|
236
|
+
expect(sops().creationRecipients()).to.deep.equal(['age1ccc']);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('rejects a recipient that is not an age public key', () => {
|
|
240
|
+
fs.writeFileSync(confPath, rule('age1aaa'), 'utf8');
|
|
241
|
+
expect(() => sops().rotate('/etc/passwd')).to.throw(/not a valid Age public recipient/i);
|
|
242
|
+
expect(() => sops().rotate('')).to.throw(/requires --recipient/);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('emergency purge', () => {
|
|
247
|
+
it('archives rather than deletes unless forced', () => {
|
|
248
|
+
const secretsSource = fs.readFileSync(new URL('../src/cli/secrets.js', import.meta.url), 'utf8');
|
|
249
|
+
const start = secretsSource.indexOf(' purge(name, options = {}) {');
|
|
250
|
+
expect(start, 'purge() not found').to.be.greaterThan(-1);
|
|
251
|
+
const body = secretsSource.slice(start, secretsSource.indexOf('\n /**', start));
|
|
252
|
+
expect(body).to.include('kubectl delete secret ${name} -n ${namespace} --ignore-not-found');
|
|
253
|
+
expect(body).to.include('options.force');
|
|
254
|
+
expect(body).to.include('fs.moveSync');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('reports the plan without mutating anything on a dry run', () => {
|
|
258
|
+
const result = sops().purge('postgres-secret', { namespace: 'default', dryRun: true });
|
|
259
|
+
expect(result).to.include({ deleted: false, archived: '' });
|
|
260
|
+
expect(result.seedFallback).to.be.a('boolean');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('requires a secret name', () => {
|
|
264
|
+
expect(() => sops().purge('')).to.throw(/requires a secret name/);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe('CLI surface', () => {
|
|
269
|
+
const cliSource = fs.readFileSync(new URL('../src/cli/index.js', import.meta.url), 'utf8');
|
|
270
|
+
|
|
271
|
+
it('makes the platform argument optional so --install-tools needs no platform', () => {
|
|
272
|
+
expect(cliSource).to.include("argument(\n '[platform]'");
|
|
273
|
+
expect(cliSource).to.include('if (options.installTools) return Underpost.secret.sops.installTooling();');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('exposes the emergency flags', () => {
|
|
277
|
+
for (const flag of ['--install-tools', '--rotate', '--recipient <age-public-key>', '--prune-recipients'])
|
|
278
|
+
expect(cliSource).to.include(flag);
|
|
279
|
+
expect(cliSource).to.include("'--purge <secret-name>'");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('routes rotate and purge through the selected platform', () => {
|
|
283
|
+
expect(cliSource).to.include('Underpost.secret[platform].rotate(options.recipient, options)');
|
|
284
|
+
expect(cliSource).to.include('Underpost.secret[platform].purge(options.purge, options)');
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe('fail-closed manifest validation', () => {
|
|
289
|
+
const storeRoot = './engine-private/secrets';
|
|
290
|
+
const NS = 'sops-validate-ns';
|
|
291
|
+
const dir = `${storeRoot}/${NS}`;
|
|
292
|
+
let createdStoreRoot = false;
|
|
293
|
+
|
|
294
|
+
const encrypted = (name, namespace) =>
|
|
295
|
+
[
|
|
296
|
+
'apiVersion: v1',
|
|
297
|
+
'kind: Secret',
|
|
298
|
+
'metadata:',
|
|
299
|
+
` name: ${name}`,
|
|
300
|
+
...(namespace ? [` namespace: ${namespace}`] : []),
|
|
301
|
+
'type: Opaque',
|
|
302
|
+
'stringData:',
|
|
303
|
+
' password: ENC[AES256_GCM,data:Lm8x,iv:3fB7,tag:2cF5,type:str]',
|
|
304
|
+
'sops:',
|
|
305
|
+
' age:',
|
|
306
|
+
' - recipient: age1aaa',
|
|
307
|
+
'',
|
|
308
|
+
].join('\n');
|
|
309
|
+
|
|
310
|
+
before(() => {
|
|
311
|
+
createdStoreRoot = !fs.existsSync(storeRoot);
|
|
312
|
+
fs.ensureDirSync(dir);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
after(() => {
|
|
316
|
+
fs.removeSync(dir);
|
|
317
|
+
if (createdStoreRoot && fs.existsSync(storeRoot) && fs.readdirSync(storeRoot).length === 0)
|
|
318
|
+
fs.removeSync(storeRoot);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('accepts a well-formed encrypted Secret', () => {
|
|
322
|
+
const path = `${dir}/good.enc.yaml`;
|
|
323
|
+
fs.writeFileSync(path, encrypted('good', NS), 'utf8');
|
|
324
|
+
expect(() => sops().assertManifest(path, { name: 'good', namespace: NS })).to.not.throw();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('refuses a manifest that was never encrypted', () => {
|
|
328
|
+
const path = `${dir}/plain.enc.yaml`;
|
|
329
|
+
fs.writeFileSync(path, 'kind: Secret\nmetadata:\n name: plain\nstringData:\n password: hunter2\n', 'utf8');
|
|
330
|
+
expect(() => sops().assertManifest(path, { name: 'plain' })).to.throw(/not encrypted/);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('refuses a non-Secret resource', () => {
|
|
334
|
+
const path = `${dir}/cm.enc.yaml`;
|
|
335
|
+
fs.writeFileSync(path, encrypted('cm', NS).replace('kind: Secret', 'kind: ConfigMap'), 'utf8');
|
|
336
|
+
expect(() => sops().assertManifest(path, { name: 'cm' })).to.throw(/is a ConfigMap, not a Secret/);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('refuses a manifest whose metadata.name does not match its filename', () => {
|
|
340
|
+
const path = `${dir}/postgres-secret.enc.yaml`;
|
|
341
|
+
fs.writeFileSync(path, encrypted('some-other-secret', NS), 'utf8');
|
|
342
|
+
expect(() => sops().assertManifest(path, { name: 'postgres-secret' })).to.throw(/secretKeyRef .* unresolved/);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('refuses a manifest targeting a different namespace', () => {
|
|
346
|
+
const path = `${dir}/elsewhere.enc.yaml`;
|
|
347
|
+
fs.writeFileSync(path, encrypted('elsewhere', 'other-ns'), 'utf8');
|
|
348
|
+
expect(() => sops().assertManifest(path, { name: 'elsewhere', namespace: NS })).to.throw(/metadata.namespace/);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('raises rather than seeding when a present manifest is invalid', () => {
|
|
352
|
+
// The security-critical distinction: absent -> seed fallback, corrupt -> hard failure.
|
|
353
|
+
const path = `${dir}/corrupt-secret.enc.yaml`;
|
|
354
|
+
fs.writeFileSync(path, 'kind: Secret\nmetadata:\n name: corrupt-secret\n', 'utf8');
|
|
355
|
+
expect(() => sops().applyIfPresent('corrupt-secret', NS)).to.throw(/not encrypted/);
|
|
356
|
+
expect(sops().applyIfPresent('absent-secret', NS)).to.equal(false);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
describe('key file identity context', () => {
|
|
361
|
+
const originalKeyFile = process.env.SOPS_AGE_KEY_FILE;
|
|
362
|
+
const originalSudoUser = process.env.SUDO_USER;
|
|
363
|
+
const originalXdg = process.env.XDG_CONFIG_HOME;
|
|
364
|
+
|
|
365
|
+
afterEach(() => {
|
|
366
|
+
for (const [key, value] of [
|
|
367
|
+
['SOPS_AGE_KEY_FILE', originalKeyFile],
|
|
368
|
+
['SUDO_USER', originalSudoUser],
|
|
369
|
+
['XDG_CONFIG_HOME', originalXdg],
|
|
370
|
+
])
|
|
371
|
+
if (value === undefined) delete process.env[key];
|
|
372
|
+
else process.env[key] = value;
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('prefers XDG_CONFIG_HOME over the home-relative default', () => {
|
|
376
|
+
delete process.env.SOPS_AGE_KEY_FILE;
|
|
377
|
+
process.env.XDG_CONFIG_HOME = '/srv/xdg';
|
|
378
|
+
expect(sops().keyFile()).to.equal('/srv/xdg/sops/age/keys.txt');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('lists the invoking user key path as a candidate under sudo', () => {
|
|
382
|
+
delete process.env.SOPS_AGE_KEY_FILE;
|
|
383
|
+
process.env.SUDO_USER = 'operator';
|
|
384
|
+
expect(sops().keyFileCandidates()).to.include('/home/operator/.config/sops/age/keys.txt');
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('names the uid and every candidate when the key is missing', () => {
|
|
388
|
+
process.env.SOPS_AGE_KEY_FILE = '/nonexistent/keys.txt';
|
|
389
|
+
expect(() => sops().assertKeyFile()).to.throw(/Age private key not found.*uid/);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('refuses a group- or world-readable private key', () => {
|
|
393
|
+
const keyPath = `${os.tmpdir()}/underpost-age-test-key.txt`;
|
|
394
|
+
fs.writeFileSync(keyPath, 'AGE-SECRET-KEY-1TEST\n', 'utf8');
|
|
395
|
+
fs.chmodSync(keyPath, 0o644);
|
|
396
|
+
process.env.SOPS_AGE_KEY_FILE = keyPath;
|
|
397
|
+
try {
|
|
398
|
+
expect(() => sops().assertKeyFile()).to.throw(/group\/world accessible/);
|
|
399
|
+
fs.chmodSync(keyPath, 0o600);
|
|
400
|
+
expect(sops().assertKeyFile()).to.equal(keyPath);
|
|
401
|
+
} finally {
|
|
402
|
+
fs.removeSync(keyPath);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
describe('store adoption by a host that did not create it', () => {
|
|
408
|
+
// The production trap: a host pulls engine-private, generates its own Age key, and every
|
|
409
|
+
// inherited manifest is sealed to somebody else's recipient. sops reports that from inside a
|
|
410
|
+
// decrypt pipe as "no identity matched any of the recipients", naming neither file nor remedy.
|
|
411
|
+
const storeRoot = './engine-private/secrets';
|
|
412
|
+
const NS = 'sops-adoption-ns';
|
|
413
|
+
const dir = `${storeRoot}/${NS}`;
|
|
414
|
+
const FOREIGN = 'age1mq5jhnym3w2cgexypl5law8my77uvqt2pxaxdqfs8gs0eqcltseq27nquw';
|
|
415
|
+
const LOCAL = 'age1myykjrfvjg55hddhetqxs4kkpe9mzjd8yae87c8d2c335kghgquqsgrl8q';
|
|
416
|
+
const originalKeyFile = process.env.SOPS_AGE_KEY_FILE;
|
|
417
|
+
// A present, correctly permissioned key file carrying no usable identity: assertKeyFile passes,
|
|
418
|
+
// so the adoption check is what the caller actually hits, exactly as on the production host.
|
|
419
|
+
const keyPath = `${os.tmpdir()}/underpost-adoption-test-key.txt`;
|
|
420
|
+
let createdStoreRoot = false;
|
|
421
|
+
|
|
422
|
+
const sealedTo = (name, recipient) =>
|
|
423
|
+
[
|
|
424
|
+
'apiVersion: v1',
|
|
425
|
+
'kind: Secret',
|
|
426
|
+
'metadata:',
|
|
427
|
+
` name: ${name}`,
|
|
428
|
+
` namespace: ${NS}`,
|
|
429
|
+
'type: Opaque',
|
|
430
|
+
'stringData:',
|
|
431
|
+
' password: ENC[AES256_GCM,data:Lm8x,iv:3fB7,tag:2cF5,type:str]',
|
|
432
|
+
'sops:',
|
|
433
|
+
' age:',
|
|
434
|
+
` - recipient: ${recipient}`,
|
|
435
|
+
'',
|
|
436
|
+
].join('\n');
|
|
437
|
+
|
|
438
|
+
before(() => {
|
|
439
|
+
createdStoreRoot = !fs.existsSync(storeRoot);
|
|
440
|
+
fs.ensureDirSync(dir);
|
|
441
|
+
fs.writeFileSync(`${dir}/mariadb-secret.enc.yaml`, sealedTo('mariadb-secret', FOREIGN), 'utf8');
|
|
442
|
+
fs.writeFileSync(keyPath, '# no identity here\n', 'utf8');
|
|
443
|
+
fs.chmodSync(keyPath, 0o600);
|
|
444
|
+
process.env.SOPS_AGE_KEY_FILE = keyPath;
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
after(() => {
|
|
448
|
+
if (originalKeyFile === undefined) delete process.env.SOPS_AGE_KEY_FILE;
|
|
449
|
+
else process.env.SOPS_AGE_KEY_FILE = originalKeyFile;
|
|
450
|
+
fs.removeSync(keyPath);
|
|
451
|
+
fs.removeSync(dir);
|
|
452
|
+
if (createdStoreRoot && fs.existsSync(storeRoot) && fs.readdirSync(storeRoot).length === 0)
|
|
453
|
+
fs.removeSync(storeRoot);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('holds no recipients when the key file is absent or carries no identity', () => {
|
|
457
|
+
expect(sops().localRecipients()).to.deep.equal([]);
|
|
458
|
+
process.env.SOPS_AGE_KEY_FILE = '/nonexistent/underpost-adoption-test/keys.txt';
|
|
459
|
+
try {
|
|
460
|
+
expect(sops().localRecipients()).to.deep.equal([]);
|
|
461
|
+
} finally {
|
|
462
|
+
process.env.SOPS_AGE_KEY_FILE = keyPath;
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('decides decryptability by recipient-set intersection, without a decrypt attempt', () => {
|
|
467
|
+
const path = `${dir}/mariadb-secret.enc.yaml`;
|
|
468
|
+
expect(sops().decryptable(path, [FOREIGN])).to.equal(true);
|
|
469
|
+
expect(sops().decryptable(path, [LOCAL])).to.equal(false);
|
|
470
|
+
expect(sops().decryptable(path, [LOCAL, FOREIGN])).to.equal(true);
|
|
471
|
+
expect(sops().decryptable(path, [])).to.equal(false);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('names the manifest, its recipients, and every remedy instead of failing inside sops', () => {
|
|
475
|
+
let error;
|
|
476
|
+
try {
|
|
477
|
+
sops().assertDecryptable(sops().manifests(NS));
|
|
478
|
+
} catch (thrown) {
|
|
479
|
+
error = thrown;
|
|
480
|
+
}
|
|
481
|
+
expect(error, 'expected assertDecryptable to throw').to.be.an('error');
|
|
482
|
+
expect(error.message).to.include(`${NS}/mariadb-secret`);
|
|
483
|
+
expect(error.message).to.include(FOREIGN);
|
|
484
|
+
expect(error.message).to.include('underpost secret sops --rotate');
|
|
485
|
+
expect(error.message).to.include('underpost run sops-setup --force');
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('raises the adoption error before any manifest reaches kubectl', () => {
|
|
489
|
+
expect(() => sops().apply(NS)).to.throw(/sealed to Age recipients this host does not hold/);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('raises rather than sliding back to the origin seed path for a present-but-unreadable manifest', () => {
|
|
493
|
+
expect(() => sops().applyIfPresent('mariadb-secret', NS)).to.throw(/does not hold/);
|
|
494
|
+
expect(sops().applyIfPresent('absent-secret', NS)).to.equal(false);
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
describe('creation-rule recipient registration', () => {
|
|
499
|
+
const storeRoot = './engine-private/secrets';
|
|
500
|
+
const confPath = `${storeRoot}/.sops.yaml`;
|
|
501
|
+
const LOCAL = 'age1myykjrfvjg55hddhetqxs4kkpe9mzjd8yae87c8d2c335kghgquqsgrl8q';
|
|
502
|
+
let createdStoreRoot = false;
|
|
503
|
+
let savedConf = null;
|
|
504
|
+
|
|
505
|
+
before(() => {
|
|
506
|
+
createdStoreRoot = !fs.existsSync(storeRoot);
|
|
507
|
+
fs.ensureDirSync(storeRoot);
|
|
508
|
+
if (fs.existsSync(confPath)) savedConf = fs.readFileSync(confPath, 'utf8');
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
after(() => {
|
|
512
|
+
if (savedConf !== null) fs.writeFileSync(confPath, savedConf, 'utf8');
|
|
513
|
+
else fs.removeSync(confPath);
|
|
514
|
+
if (createdStoreRoot && fs.existsSync(storeRoot) && fs.readdirSync(storeRoot).length === 0)
|
|
515
|
+
fs.removeSync(storeRoot);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('adds the local recipient to an inherited rule without revoking anyone', () => {
|
|
519
|
+
fs.writeFileSync(
|
|
520
|
+
confPath,
|
|
521
|
+
['creation_rules:', ' - path_regex: .*\\.enc\\.yaml$', ' age: age1foreign', ''].join('\n'),
|
|
522
|
+
'utf8',
|
|
523
|
+
);
|
|
524
|
+
expect(sops().ensureCreationRecipient(LOCAL)).to.equal(true);
|
|
525
|
+
expect(sops().creationRecipients()).to.deep.equal(['age1foreign', LOCAL]);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('is a no-op once the recipient is listed', () => {
|
|
529
|
+
expect(sops().ensureCreationRecipient(LOCAL)).to.equal(false);
|
|
530
|
+
expect(sops().creationRecipients()).to.deep.equal(['age1foreign', LOCAL]);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it('leaves a rule that declares no age recipients alone', () => {
|
|
534
|
+
fs.writeFileSync(
|
|
535
|
+
confPath,
|
|
536
|
+
['creation_rules:', ' - path_regex: .*\\.enc\\.yaml$', ' pgp: ABCDEF', ''].join('\n'),
|
|
537
|
+
'utf8',
|
|
538
|
+
);
|
|
539
|
+
expect(sops().ensureCreationRecipient(LOCAL)).to.equal(false);
|
|
540
|
+
expect(fs.readFileSync(confPath, 'utf8')).to.include('pgp: ABCDEF');
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it('registers the host during init so a pulled store cannot be encrypted to write-only', () => {
|
|
544
|
+
const secretsSource = fs.readFileSync(new URL('../src/cli/secrets.js', import.meta.url), 'utf8');
|
|
545
|
+
const start = secretsSource.indexOf(' init() {');
|
|
546
|
+
const body = secretsSource.slice(start, secretsSource.indexOf('\n /**', start));
|
|
547
|
+
expect(body).to.include('ensureCreationRecipient(recipient)');
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
describe('sops-setup onboarding reports', () => {
|
|
552
|
+
const runSource = fs.readFileSync(new URL('../src/cli/run.js', import.meta.url), 'utf8');
|
|
553
|
+
const start = runSource.indexOf(" 'sops-setup': (path = '', options = DEFAULT_OPTION) => {");
|
|
554
|
+
const body = runSource.slice(start, runSource.indexOf('\n /**', start));
|
|
555
|
+
|
|
556
|
+
it('does not report a manifest it cannot decrypt as onboarded', () => {
|
|
557
|
+
expect(start, 'sops-setup runner not found').to.be.greaterThan(-1);
|
|
558
|
+
expect(body).to.include('Underpost.secret.sops.decryptable(');
|
|
559
|
+
expect(body).to.include('sealed to an Age recipient this host does not hold');
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it('warns when --force replaces a stored credential with a generated one', () => {
|
|
563
|
+
expect(body).to.include('generated while replacing the stored manifest');
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
describe('rotation safeguards', () => {
|
|
568
|
+
const secretsSource = fs.readFileSync(new URL('../src/cli/secrets.js', import.meta.url), 'utf8');
|
|
569
|
+
|
|
570
|
+
it('refuses to revoke recipients without an explicit confirmation', () => {
|
|
571
|
+
expect(secretsSource).to.include('Refusing to revoke ');
|
|
572
|
+
expect(secretsSource).to.include('!options.force');
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('verifies each manifest carries the new recipient after updatekeys', () => {
|
|
576
|
+
expect(secretsSource).to.include('is still sealed to ');
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('supports retaining named recipients through a prune', () => {
|
|
580
|
+
expect(secretsSource).to.include('options.keepRecipients');
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it('refuses to plan a rotation the local key could never perform', () => {
|
|
584
|
+
// updatekeys has to decrypt each data key first, so a host that cannot read the store cannot
|
|
585
|
+
// rotate it — including on a dry run, where a reported plan would be pure misdirection.
|
|
586
|
+
const start = secretsSource.indexOf(' rotate(recipient, options = {}) {');
|
|
587
|
+
const body = secretsSource.slice(start, secretsSource.indexOf('\n /**', start));
|
|
588
|
+
expect(body).to.include('assertDecryptable(manifests)');
|
|
589
|
+
expect(body.indexOf('assertDecryptable(manifests)')).to.be.lessThan(body.indexOf('if (options.dryRun)'));
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
describe('encrypt write safety', () => {
|
|
594
|
+
const secretsSource = fs.readFileSync(new URL('../src/cli/secrets.js', import.meta.url), 'utf8');
|
|
595
|
+
|
|
596
|
+
it('stages and moves rather than redirecting straight onto the target', () => {
|
|
597
|
+
const start = secretsSource.indexOf(' encrypt(plaintextPath, namespace');
|
|
598
|
+
const body = secretsSource.slice(start, secretsSource.indexOf('\n /**', start));
|
|
599
|
+
expect(body).to.include('.staged');
|
|
600
|
+
expect(body).to.include('fs.moveSync');
|
|
601
|
+
expect(body).to.include('assertManifest');
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('refuses to double-encrypt an already-encrypted source', () => {
|
|
605
|
+
expect(secretsSource).to.include('already carries sops metadata');
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('refuses to clobber an existing manifest without force', () => {
|
|
609
|
+
expect(secretsSource).to.include('already exists. Edit it with');
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
});
|