UNPKG

underpost

Version:

Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc

613 lines (526 loc) 26.5 kB
'use strict'; import { expect } from 'chai'; import fs from 'fs-extra'; import os from 'os'; import Underpost from '../src/index.js'; const sops = () => Underpost.secret.sops; // Unique namespace so the fixture can never collide with (or clean up) a real // encrypted store under the gitignored engine-private tree. const TEST_NAMESPACE = 'sops-store-test-ns'; const TEST_DIR = `./engine-private/secrets/${TEST_NAMESPACE}`; const encryptedFixture = `apiVersion: v1 kind: Secret metadata: name: fixture-secret namespace: ${TEST_NAMESPACE} type: Opaque stringData: password: ENC[AES256_GCM,data:Lm8xQ2vT,iv:3fB7yU1jH6sD0gW2cN8mZ4kP5qX9tA7rE1vL3oI6uY0=,tag:2cF5nQ8sW1zA4dG7jL0mB==,type:str] sops: age: - recipient: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p enc: | -----BEGIN AGE ENCRYPTED FILE----- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqTndTOFRIRGxZUmZZNXFy -----END AGE ENCRYPTED FILE----- - recipient: age1w7yx5kq0h3n2t4mzr9vp8ldjc6fs0eguya3hx2nq7r5tvk9m4dlq8zwptn enc: | -----BEGIN AGE ENCRYPTED FILE----- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwUXcyVGtMOXZSN21OY0Zq -----END AGE ENCRYPTED FILE----- encrypted_regex: ^(data|stringData)$ version: 3.10.2 `; describe('sops encrypted secret store', () => { describe('key file resolution', () => { const originalKeyFile = process.env.SOPS_AGE_KEY_FILE; afterEach(() => { if (originalKeyFile === undefined) delete process.env.SOPS_AGE_KEY_FILE; else process.env.SOPS_AGE_KEY_FILE = originalKeyFile; }); it('honors SOPS_AGE_KEY_FILE so key location has a single source of truth', () => { process.env.SOPS_AGE_KEY_FILE = '/srv/keys/age.txt'; expect(sops().keyFile()).to.equal('/srv/keys/age.txt'); }); it('falls back to the sops default key location', () => { delete process.env.SOPS_AGE_KEY_FILE; expect(sops().keyFile()).to.equal(`${os.homedir()}/.config/sops/age/keys.txt`); }); }); describe('store layout', () => { it('resolves a manifest to <store>/<namespace>/<name>.enc.yaml', () => { expect(sops().manifestPath('postgres-secret', 'default')).to.equal( './engine-private/secrets/default/postgres-secret.enc.yaml', ); }); it('defaults to the default namespace', () => { expect(sops().manifestPath('mariadb-secret')).to.equal( './engine-private/secrets/default/mariadb-secret.enc.yaml', ); }); }); describe('presence detection and origin seed fallback', () => { const storeRoot = './engine-private/secrets'; let createdStoreRoot = false; before(() => { createdStoreRoot = !fs.existsSync(storeRoot); fs.ensureDirSync(TEST_DIR); fs.writeFileSync(`${TEST_DIR}/fixture-secret.enc.yaml`, encryptedFixture, 'utf8'); }); after(() => { fs.removeSync(TEST_DIR); // Only reclaim the store root when this suite created it, so a real // encrypted store is never touched. if (createdStoreRoot && fs.existsSync(storeRoot) && fs.readdirSync(storeRoot).length === 0) fs.removeSync(storeRoot); }); it('detects an encrypted manifest that exists', () => { expect(sops().has('fixture-secret', TEST_NAMESPACE)).to.equal(true); }); it('reports no manifest for a secret not yet onboarded', () => { expect(sops().has('not-onboarded-secret', TEST_NAMESPACE)).to.equal(false); }); it('reports no manifest for a namespace with no store', () => { expect(sops().has('fixture-secret', 'namespace-without-a-store')).to.equal(false); }); it('declines to apply when no encrypted manifest exists, leaving the caller its origin seed path', () => { // Must return false rather than throw: this is the branch that keeps clusters // not yet onboarded to the encrypted store seeding from plaintext credentials. expect(sops().applyIfPresent('not-onboarded-secret', TEST_NAMESPACE)).to.equal(false); }); it('lists recipients from the plaintext sops metadata block without a private key', () => { const lines = []; const originalLog = console.log; console.log = (line) => lines.push(line); try { sops().list(); } finally { console.log = originalLog; } const entry = lines.find((line) => line.includes(`${TEST_NAMESPACE}/fixture-secret.enc.yaml`)); expect(entry).to.be.a('string'); expect(entry).to.include('age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p'); expect(entry).to.include('age1w7yx5kq0h3n2t4mzr9vp8ldjc6fs0eguya3hx2nq7r5tvk9m4dlq8zwptn'); }); it('refuses to apply a namespace with no stored manifests', () => { expect(() => sops().apply('namespace-without-a-store')).to.throw(/No encrypted secrets for namespace/); }); }); describe('cluster initialization wiring', () => { const clusterSource = fs.readFileSync(new URL('../src/cli/cluster.js', import.meta.url), 'utf8'); for (const [flag, secretName] of [ ['mariadb', 'mariadb-secret'], ['mysql', 'mysql-secret'], ['postgresql', 'postgres-secret'], ]) { it(`prefers the encrypted store for ${secretName} and keeps the origin seed path`, () => { const start = clusterSource.indexOf(`if (options.${flag}) {`); expect(start, `options.${flag} branch not found`).to.be.greaterThan(-1); const branch = clusterSource.slice(start, start + 1200); expect(branch).to.include(`Underpost.secret.sops.applyIfPresent('${secretName}', options.namespace)`); expect(branch).to.include(`kubectl create secret generic ${secretName}`); // The origin seed creation must stay guarded by the negated store lookup, // never run unconditionally alongside the decrypted apply. expect(branch).to.match( new RegExp( `if \\(!Underpost\\.secret\\.sops\\.applyIfPresent\\('${secretName}'[\\s\\S]{0,80}kubectl create secret generic ${secretName}`, ), ); }); } it('delegates host tooling install to UnderpostSecret instead of duplicating it', () => { expect(clusterSource).to.include('Underpost.secret.sops.installTooling()'); // The binary install logic must live in exactly one place. expect(clusterSource).to.not.include('releases/download/${SOPS_VERSION}'); expect(clusterSource).to.not.include('const SOPS_VERSION'); expect(clusterSource).to.not.include('const AGE_VERSION'); }); it('pins the sops and age versions in the secrets module', () => { const secretsSource = fs.readFileSync(new URL('../src/cli/secrets.js', import.meta.url), 'utf8'); expect(secretsSource).to.match(/const SOPS_VERSION = 'v\d+\.\d+\.\d+';/); expect(secretsSource).to.match(/const AGE_VERSION = 'v\d+\.\d+\.\d+';/); }); }); describe('decrypt-to-apply pipeline', () => { const secretsSource = fs.readFileSync(new URL('../src/cli/secrets.js', import.meta.url), 'utf8'); it('streams decrypted output into kubectl without writing plaintext to disk', () => { expect(secretsSource).to.include('sops --decrypt "${manifestPath}" '); expect(secretsSource).to.include('| kubectl apply -f -'); }); it('guards the decrypt pipe with pipefail so a decrypt failure cannot apply an empty stream', () => { expect(secretsSource).to.include("bash -c 'set -o pipefail;"); }); it('passes the age key as a file path, never as key material in the environment', () => { expect(secretsSource).to.include('SOPS_AGE_KEY_FILE='); expect(secretsSource).to.not.match(/SOPS_AGE_KEY=[^F]/); }); it('re-keys under pipefail with logging suppressed', () => { const start = secretsSource.indexOf(' rotate(recipient, options = {}) {'); expect(start, 'rotate() not found').to.be.greaterThan(-1); const body = secretsSource.slice(start, secretsSource.indexOf('\n /**', start)); expect(body).to.include('set -o pipefail'); expect(body).to.include('sops --config'); expect(body).to.include('updatekeys --yes'); expect(body).to.include('disableLog: true'); }); }); describe('creation-rule recipient parsing', () => { const storeRoot = './engine-private/secrets'; const confPath = `${storeRoot}/.sops.yaml`; let createdStoreRoot = false; let savedConf = null; const rule = (ageValue) => ['creation_rules:', ' - path_regex: engine-private/secrets/.*\\.enc\\.yaml$', ` age: ${ageValue}`, ''].join( '\n', ); before(() => { createdStoreRoot = !fs.existsSync(storeRoot); fs.ensureDirSync(storeRoot); if (fs.existsSync(confPath)) savedConf = fs.readFileSync(confPath, 'utf8'); }); after(() => { if (savedConf !== null) fs.writeFileSync(confPath, savedConf, 'utf8'); else fs.removeSync(confPath); if (createdStoreRoot && fs.existsSync(storeRoot) && fs.readdirSync(storeRoot).length === 0) fs.removeSync(storeRoot); }); it('reads a single-line recipient list', () => { fs.writeFileSync(confPath, rule('age1aaa,age1bbb'), 'utf8'); expect(sops().creationRecipients()).to.deep.equal(['age1aaa', 'age1bbb']); }); it('reads the folded multi-line recipient form sops also accepts', () => { fs.writeFileSync(confPath, rule('>-\n age1aaa,\n age1bbb'), 'utf8'); expect(sops().creationRecipients()).to.deep.equal(['age1aaa', 'age1bbb']); }); it('collapses a folded list to one canonical line on write', () => { fs.writeFileSync(confPath, rule('>-\n age1aaa,\n age1bbb'), 'utf8'); sops().writeCreationRecipients(['age1ccc']); const written = fs.readFileSync(confPath, 'utf8'); expect(written).to.include(' age: age1ccc'); expect(written).to.not.include('age1aaa'); expect(written).to.include('creation_rules:'); expect(sops().creationRecipients()).to.deep.equal(['age1ccc']); }); it('rejects a recipient that is not an age public key', () => { fs.writeFileSync(confPath, rule('age1aaa'), 'utf8'); expect(() => sops().rotate('/etc/passwd')).to.throw(/not a valid Age public recipient/i); expect(() => sops().rotate('')).to.throw(/requires --recipient/); }); }); describe('emergency purge', () => { it('archives rather than deletes unless forced', () => { const secretsSource = fs.readFileSync(new URL('../src/cli/secrets.js', import.meta.url), 'utf8'); const start = secretsSource.indexOf(' purge(name, options = {}) {'); expect(start, 'purge() not found').to.be.greaterThan(-1); const body = secretsSource.slice(start, secretsSource.indexOf('\n /**', start)); expect(body).to.include('kubectl delete secret ${name} -n ${namespace} --ignore-not-found'); expect(body).to.include('options.force'); expect(body).to.include('fs.moveSync'); }); it('reports the plan without mutating anything on a dry run', () => { const result = sops().purge('postgres-secret', { namespace: 'default', dryRun: true }); expect(result).to.include({ deleted: false, archived: '' }); expect(result.seedFallback).to.be.a('boolean'); }); it('requires a secret name', () => { expect(() => sops().purge('')).to.throw(/requires a secret name/); }); }); describe('CLI surface', () => { const cliSource = fs.readFileSync(new URL('../src/cli/index.js', import.meta.url), 'utf8'); it('makes the platform argument optional so --install-tools needs no platform', () => { expect(cliSource).to.include("argument(\n '[platform]'"); expect(cliSource).to.include('if (options.installTools) return Underpost.secret.sops.installTooling();'); }); it('exposes the emergency flags', () => { for (const flag of ['--install-tools', '--rotate', '--recipient <age-public-key>', '--prune-recipients']) expect(cliSource).to.include(flag); expect(cliSource).to.include("'--purge <secret-name>'"); }); it('routes rotate and purge through the selected platform', () => { expect(cliSource).to.include('Underpost.secret[platform].rotate(options.recipient, options)'); expect(cliSource).to.include('Underpost.secret[platform].purge(options.purge, options)'); }); }); describe('fail-closed manifest validation', () => { const storeRoot = './engine-private/secrets'; const NS = 'sops-validate-ns'; const dir = `${storeRoot}/${NS}`; let createdStoreRoot = false; const encrypted = (name, namespace) => [ 'apiVersion: v1', 'kind: Secret', 'metadata:', ` name: ${name}`, ...(namespace ? [` namespace: ${namespace}`] : []), 'type: Opaque', 'stringData:', ' password: ENC[AES256_GCM,data:Lm8x,iv:3fB7,tag:2cF5,type:str]', 'sops:', ' age:', ' - recipient: age1aaa', '', ].join('\n'); before(() => { createdStoreRoot = !fs.existsSync(storeRoot); fs.ensureDirSync(dir); }); after(() => { fs.removeSync(dir); if (createdStoreRoot && fs.existsSync(storeRoot) && fs.readdirSync(storeRoot).length === 0) fs.removeSync(storeRoot); }); it('accepts a well-formed encrypted Secret', () => { const path = `${dir}/good.enc.yaml`; fs.writeFileSync(path, encrypted('good', NS), 'utf8'); expect(() => sops().assertManifest(path, { name: 'good', namespace: NS })).to.not.throw(); }); it('refuses a manifest that was never encrypted', () => { const path = `${dir}/plain.enc.yaml`; fs.writeFileSync(path, 'kind: Secret\nmetadata:\n name: plain\nstringData:\n password: hunter2\n', 'utf8'); expect(() => sops().assertManifest(path, { name: 'plain' })).to.throw(/not encrypted/); }); it('refuses a non-Secret resource', () => { const path = `${dir}/cm.enc.yaml`; fs.writeFileSync(path, encrypted('cm', NS).replace('kind: Secret', 'kind: ConfigMap'), 'utf8'); expect(() => sops().assertManifest(path, { name: 'cm' })).to.throw(/is a ConfigMap, not a Secret/); }); it('refuses a manifest whose metadata.name does not match its filename', () => { const path = `${dir}/postgres-secret.enc.yaml`; fs.writeFileSync(path, encrypted('some-other-secret', NS), 'utf8'); expect(() => sops().assertManifest(path, { name: 'postgres-secret' })).to.throw(/secretKeyRef .* unresolved/); }); it('refuses a manifest targeting a different namespace', () => { const path = `${dir}/elsewhere.enc.yaml`; fs.writeFileSync(path, encrypted('elsewhere', 'other-ns'), 'utf8'); expect(() => sops().assertManifest(path, { name: 'elsewhere', namespace: NS })).to.throw(/metadata.namespace/); }); it('raises rather than seeding when a present manifest is invalid', () => { // The security-critical distinction: absent -> seed fallback, corrupt -> hard failure. const path = `${dir}/corrupt-secret.enc.yaml`; fs.writeFileSync(path, 'kind: Secret\nmetadata:\n name: corrupt-secret\n', 'utf8'); expect(() => sops().applyIfPresent('corrupt-secret', NS)).to.throw(/not encrypted/); expect(sops().applyIfPresent('absent-secret', NS)).to.equal(false); }); }); describe('key file identity context', () => { const originalKeyFile = process.env.SOPS_AGE_KEY_FILE; const originalSudoUser = process.env.SUDO_USER; const originalXdg = process.env.XDG_CONFIG_HOME; afterEach(() => { for (const [key, value] of [ ['SOPS_AGE_KEY_FILE', originalKeyFile], ['SUDO_USER', originalSudoUser], ['XDG_CONFIG_HOME', originalXdg], ]) if (value === undefined) delete process.env[key]; else process.env[key] = value; }); it('prefers XDG_CONFIG_HOME over the home-relative default', () => { delete process.env.SOPS_AGE_KEY_FILE; process.env.XDG_CONFIG_HOME = '/srv/xdg'; expect(sops().keyFile()).to.equal('/srv/xdg/sops/age/keys.txt'); }); it('lists the invoking user key path as a candidate under sudo', () => { delete process.env.SOPS_AGE_KEY_FILE; process.env.SUDO_USER = 'operator'; expect(sops().keyFileCandidates()).to.include('/home/operator/.config/sops/age/keys.txt'); }); it('names the uid and every candidate when the key is missing', () => { process.env.SOPS_AGE_KEY_FILE = '/nonexistent/keys.txt'; expect(() => sops().assertKeyFile()).to.throw(/Age private key not found.*uid/); }); it('refuses a group- or world-readable private key', () => { const keyPath = `${os.tmpdir()}/underpost-age-test-key.txt`; fs.writeFileSync(keyPath, 'AGE-SECRET-KEY-1TEST\n', 'utf8'); fs.chmodSync(keyPath, 0o644); process.env.SOPS_AGE_KEY_FILE = keyPath; try { expect(() => sops().assertKeyFile()).to.throw(/group\/world accessible/); fs.chmodSync(keyPath, 0o600); expect(sops().assertKeyFile()).to.equal(keyPath); } finally { fs.removeSync(keyPath); } }); }); describe('store adoption by a host that did not create it', () => { // The production trap: a host pulls engine-private, generates its own Age key, and every // inherited manifest is sealed to somebody else's recipient. sops reports that from inside a // decrypt pipe as "no identity matched any of the recipients", naming neither file nor remedy. const storeRoot = './engine-private/secrets'; const NS = 'sops-adoption-ns'; const dir = `${storeRoot}/${NS}`; const FOREIGN = 'age1mq5jhnym3w2cgexypl5law8my77uvqt2pxaxdqfs8gs0eqcltseq27nquw'; const LOCAL = 'age1myykjrfvjg55hddhetqxs4kkpe9mzjd8yae87c8d2c335kghgquqsgrl8q'; const originalKeyFile = process.env.SOPS_AGE_KEY_FILE; // A present, correctly permissioned key file carrying no usable identity: assertKeyFile passes, // so the adoption check is what the caller actually hits, exactly as on the production host. const keyPath = `${os.tmpdir()}/underpost-adoption-test-key.txt`; let createdStoreRoot = false; const sealedTo = (name, recipient) => [ 'apiVersion: v1', 'kind: Secret', 'metadata:', ` name: ${name}`, ` namespace: ${NS}`, 'type: Opaque', 'stringData:', ' password: ENC[AES256_GCM,data:Lm8x,iv:3fB7,tag:2cF5,type:str]', 'sops:', ' age:', ` - recipient: ${recipient}`, '', ].join('\n'); before(() => { createdStoreRoot = !fs.existsSync(storeRoot); fs.ensureDirSync(dir); fs.writeFileSync(`${dir}/mariadb-secret.enc.yaml`, sealedTo('mariadb-secret', FOREIGN), 'utf8'); fs.writeFileSync(keyPath, '# no identity here\n', 'utf8'); fs.chmodSync(keyPath, 0o600); process.env.SOPS_AGE_KEY_FILE = keyPath; }); after(() => { if (originalKeyFile === undefined) delete process.env.SOPS_AGE_KEY_FILE; else process.env.SOPS_AGE_KEY_FILE = originalKeyFile; fs.removeSync(keyPath); fs.removeSync(dir); if (createdStoreRoot && fs.existsSync(storeRoot) && fs.readdirSync(storeRoot).length === 0) fs.removeSync(storeRoot); }); it('holds no recipients when the key file is absent or carries no identity', () => { expect(sops().localRecipients()).to.deep.equal([]); process.env.SOPS_AGE_KEY_FILE = '/nonexistent/underpost-adoption-test/keys.txt'; try { expect(sops().localRecipients()).to.deep.equal([]); } finally { process.env.SOPS_AGE_KEY_FILE = keyPath; } }); it('decides decryptability by recipient-set intersection, without a decrypt attempt', () => { const path = `${dir}/mariadb-secret.enc.yaml`; expect(sops().decryptable(path, [FOREIGN])).to.equal(true); expect(sops().decryptable(path, [LOCAL])).to.equal(false); expect(sops().decryptable(path, [LOCAL, FOREIGN])).to.equal(true); expect(sops().decryptable(path, [])).to.equal(false); }); it('names the manifest, its recipients, and every remedy instead of failing inside sops', () => { let error; try { sops().assertDecryptable(sops().manifests(NS)); } catch (thrown) { error = thrown; } expect(error, 'expected assertDecryptable to throw').to.be.an('error'); expect(error.message).to.include(`${NS}/mariadb-secret`); expect(error.message).to.include(FOREIGN); expect(error.message).to.include('underpost secret sops --rotate'); expect(error.message).to.include('underpost run sops-setup --force'); }); it('raises the adoption error before any manifest reaches kubectl', () => { expect(() => sops().apply(NS)).to.throw(/sealed to Age recipients this host does not hold/); }); it('raises rather than sliding back to the origin seed path for a present-but-unreadable manifest', () => { expect(() => sops().applyIfPresent('mariadb-secret', NS)).to.throw(/does not hold/); expect(sops().applyIfPresent('absent-secret', NS)).to.equal(false); }); }); describe('creation-rule recipient registration', () => { const storeRoot = './engine-private/secrets'; const confPath = `${storeRoot}/.sops.yaml`; const LOCAL = 'age1myykjrfvjg55hddhetqxs4kkpe9mzjd8yae87c8d2c335kghgquqsgrl8q'; let createdStoreRoot = false; let savedConf = null; before(() => { createdStoreRoot = !fs.existsSync(storeRoot); fs.ensureDirSync(storeRoot); if (fs.existsSync(confPath)) savedConf = fs.readFileSync(confPath, 'utf8'); }); after(() => { if (savedConf !== null) fs.writeFileSync(confPath, savedConf, 'utf8'); else fs.removeSync(confPath); if (createdStoreRoot && fs.existsSync(storeRoot) && fs.readdirSync(storeRoot).length === 0) fs.removeSync(storeRoot); }); it('adds the local recipient to an inherited rule without revoking anyone', () => { fs.writeFileSync( confPath, ['creation_rules:', ' - path_regex: .*\\.enc\\.yaml$', ' age: age1foreign', ''].join('\n'), 'utf8', ); expect(sops().ensureCreationRecipient(LOCAL)).to.equal(true); expect(sops().creationRecipients()).to.deep.equal(['age1foreign', LOCAL]); }); it('is a no-op once the recipient is listed', () => { expect(sops().ensureCreationRecipient(LOCAL)).to.equal(false); expect(sops().creationRecipients()).to.deep.equal(['age1foreign', LOCAL]); }); it('leaves a rule that declares no age recipients alone', () => { fs.writeFileSync( confPath, ['creation_rules:', ' - path_regex: .*\\.enc\\.yaml$', ' pgp: ABCDEF', ''].join('\n'), 'utf8', ); expect(sops().ensureCreationRecipient(LOCAL)).to.equal(false); expect(fs.readFileSync(confPath, 'utf8')).to.include('pgp: ABCDEF'); }); it('registers the host during init so a pulled store cannot be encrypted to write-only', () => { const secretsSource = fs.readFileSync(new URL('../src/cli/secrets.js', import.meta.url), 'utf8'); const start = secretsSource.indexOf(' init() {'); const body = secretsSource.slice(start, secretsSource.indexOf('\n /**', start)); expect(body).to.include('ensureCreationRecipient(recipient)'); }); }); describe('sops-setup onboarding reports', () => { const runSource = fs.readFileSync(new URL('../src/cli/run.js', import.meta.url), 'utf8'); const start = runSource.indexOf(" 'sops-setup': (path = '', options = DEFAULT_OPTION) => {"); const body = runSource.slice(start, runSource.indexOf('\n /**', start)); it('does not report a manifest it cannot decrypt as onboarded', () => { expect(start, 'sops-setup runner not found').to.be.greaterThan(-1); expect(body).to.include('Underpost.secret.sops.decryptable('); expect(body).to.include('sealed to an Age recipient this host does not hold'); }); it('warns when --force replaces a stored credential with a generated one', () => { expect(body).to.include('generated while replacing the stored manifest'); }); }); describe('rotation safeguards', () => { const secretsSource = fs.readFileSync(new URL('../src/cli/secrets.js', import.meta.url), 'utf8'); it('refuses to revoke recipients without an explicit confirmation', () => { expect(secretsSource).to.include('Refusing to revoke '); expect(secretsSource).to.include('!options.force'); }); it('verifies each manifest carries the new recipient after updatekeys', () => { expect(secretsSource).to.include('is still sealed to '); }); it('supports retaining named recipients through a prune', () => { expect(secretsSource).to.include('options.keepRecipients'); }); it('refuses to plan a rotation the local key could never perform', () => { // updatekeys has to decrypt each data key first, so a host that cannot read the store cannot // rotate it — including on a dry run, where a reported plan would be pure misdirection. const start = secretsSource.indexOf(' rotate(recipient, options = {}) {'); const body = secretsSource.slice(start, secretsSource.indexOf('\n /**', start)); expect(body).to.include('assertDecryptable(manifests)'); expect(body.indexOf('assertDecryptable(manifests)')).to.be.lessThan(body.indexOf('if (options.dryRun)')); }); }); describe('encrypt write safety', () => { const secretsSource = fs.readFileSync(new URL('../src/cli/secrets.js', import.meta.url), 'utf8'); it('stages and moves rather than redirecting straight onto the target', () => { const start = secretsSource.indexOf(' encrypt(plaintextPath, namespace'); const body = secretsSource.slice(start, secretsSource.indexOf('\n /**', start)); expect(body).to.include('.staged'); expect(body).to.include('fs.moveSync'); expect(body).to.include('assertManifest'); }); it('refuses to double-encrypt an already-encrypted source', () => { expect(secretsSource).to.include('already carries sops metadata'); }); it('refuses to clobber an existing manifest without force', () => { expect(secretsSource).to.include('already exists. Edit it with'); }); }); });