Files
sencho/backend/src/__tests__/secrets.test.ts
T
Anso 0f0b22c51a feat(fleet): Fleet Secrets tab with env-var bundles (v1 MVP) (#965)
* feat(fleet): add Fleet Secrets tab with versioned env-var bundles (Skipper+)

Centralized, encrypted-at-rest secret bundles that can be pushed to labeled
nodes' stacks. Each save bumps a monotonic version; each push records a
per-node-per-version row in `secret_pushes` plus an entry in `audit_log`.
Conflict detection shows added/changed/unchanged/removed (informational)
diffs before write. Overlay merge preserves keys missing from the bundle.

- Adds `secrets`, `secret_versions`, `secret_pushes` tables.
- New `SecretsService` reuses CryptoService for AES-256-GCM, NodeLabelService
  for selectors, and direct fetch + Bearer for outbound calls to remote nodes.
- New `secretsRouter` with 9 endpoints under `/api/secrets`, gated by
  `requirePaid`. Mounted after the auth gate.
- Audit summary patterns added for the new routes.
- New Fleet › Secrets tab with bundle list, editor sheet (key=value rows,
  versions tab), and push wizard (selector, target stack, env file picker,
  per-node diff preview, results pills).
- Documentation: docs/features/fleet-secrets.mdx + docs.json nav entry.
- 26 Vitest cases cover parser, encryption, versioning, push aggregation,
  tier gating.

* fix(fleet): use const for rawValue in env parser

ESLint prefer-const flagged the let declaration as a CI-blocking error;
the variable is never reassigned.
2026-05-07 06:03:39 -04:00

402 lines
15 KiB
TypeScript

/**
* Tests for Fleet Secrets v1 MVP:
* - parseEnv / serializeEnv round-trip
* - applyOverlay / computeDiff
* - encrypt round-trip via CryptoService
* - DatabaseService secret + version + push CRUD
* - SecretsService versioning, importFromStack, executePush aggregation
* - Route guards (requirePaid 403, push lock 409)
* - getAuditSummary patterns for /secrets routes
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import path from 'path';
import fs from 'fs';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
import { getAuditSummary } from '../utils/audit-summaries';
import { parseEnv, serializeEnv, applyOverlay, computeDiff } from '../services/SecretsService';
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let SecretsService: typeof import('../services/SecretsService').SecretsService;
let CryptoService: typeof import('../services/CryptoService').CryptoService;
function authToken(username: string, role: string = 'admin', tv?: number): string {
const payload: Record<string, unknown> = { username, role };
if (tv !== undefined) payload.tv = tv;
return jwt.sign(payload, TEST_JWT_SECRET, { expiresIn: '1m' });
}
function adminToken(): string {
const db = DatabaseService.getInstance();
const user = db.getUserByUsername(TEST_USERNAME)!;
return authToken(TEST_USERNAME, 'admin', user.token_version);
}
function clearSecretsTables(): void {
const db = DatabaseService.getInstance().getDb();
db.prepare('DELETE FROM secret_pushes').run();
db.prepare('DELETE FROM secret_versions').run();
db.prepare('DELETE FROM secrets').run();
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ SecretsService } = await import('../services/SecretsService'));
({ CryptoService } = await import('../services/CryptoService'));
const { LicenseService } = await import('../services/LicenseService');
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
({ app } = await import('../index'));
});
afterAll(() => {
vi.restoreAllMocks();
cleanupTestDb(tmpDir);
});
beforeEach(() => {
clearSecretsTables();
});
// ---- Pure functions ----
describe('parseEnv / serializeEnv', () => {
it('parses simple key=value lines', () => {
const kv = parseEnv('FOO=bar\nBAZ=qux\n');
expect(kv).toEqual({ FOO: 'bar', BAZ: 'qux' });
});
it('drops blank lines and # comments', () => {
const kv = parseEnv('# comment\n\nFOO=bar\n# another\n');
expect(kv).toEqual({ FOO: 'bar' });
});
it('strips matching outer double quotes and decodes escapes', () => {
const kv = parseEnv('FOO="hello world"\nBAR="multi\\nline"\n');
expect(kv.FOO).toBe('hello world');
expect(kv.BAR).toBe('multi\nline');
});
it('strips matching outer single quotes literally', () => {
const kv = parseEnv("FOO='raw \\n value'\n");
expect(kv.FOO).toBe('raw \\n value');
});
it('handles empty values', () => {
const kv = parseEnv('EMPTY=\nNON_EMPTY=x\n');
expect(kv.EMPTY).toBe('');
expect(kv.NON_EMPTY).toBe('x');
});
it('strips trailing inline comments on bare values', () => {
const kv = parseEnv('FOO=bar # trailing\n');
expect(kv.FOO).toBe('bar');
});
it('drops malformed keys', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const kv = parseEnv('1BAD=oops\nGOOD=ok\n');
expect(kv).toEqual({ GOOD: 'ok' });
warnSpy.mockRestore();
});
it('round-trips serialize → parse', () => {
const original = { FOO: 'simple', SPACED: 'two words', QUOTED: 'has "quote"', EMPTY: '' };
const serialized = serializeEnv(original);
const parsed = parseEnv(serialized);
expect(parsed).toEqual(original);
});
it('returns empty string for empty map', () => {
expect(serializeEnv({})).toBe('');
});
});
describe('applyOverlay / computeDiff', () => {
it('preserves keys absent from overlay (overlay merge)', () => {
const merged = applyOverlay({ KEEP: 'x', REPLACE: 'old' }, { REPLACE: 'new', NEW: 'fresh' });
expect(merged).toEqual({ KEEP: 'x', REPLACE: 'new', NEW: 'fresh' });
});
it('classifies all four diff statuses', () => {
const diff = computeDiff(
{ KEEP: 'same', REPLACE: 'old', GONE: 'orphan' },
{ KEEP: 'same', REPLACE: 'new', NEW: 'fresh' },
);
const byKey = Object.fromEntries(diff.map(e => [e.key, e]));
expect(byKey.KEEP.status).toBe('unchanged');
expect(byKey.REPLACE.status).toBe('changed');
expect(byKey.REPLACE.before).toBe('old');
expect(byKey.REPLACE.after).toBe('new');
expect(byKey.NEW.status).toBe('added');
expect(byKey.GONE.status).toBe('removed');
expect(byKey.GONE.before).toBe('orphan');
});
});
// ---- Encryption round-trip ----
describe('encryption via CryptoService', () => {
it('round-trips a JSON-encoded KV map', () => {
const kv = { DB_URL: 'postgres://localhost/app', API_KEY: 'abc123' };
const cipher = CryptoService.getInstance().encrypt(JSON.stringify(kv));
expect(cipher.startsWith('enc:')).toBe(true);
const plain = CryptoService.getInstance().decrypt(cipher);
expect(JSON.parse(plain)).toEqual(kv);
});
});
// ---- DatabaseService secret accessors ----
describe('DatabaseService secret accessors', () => {
it('creates a secret with v1 and bumps to v2 on update', () => {
const db = DatabaseService.getInstance();
const created = db.createSecretWithVersion({
name: 'creds',
description: 'app creds',
encryptedPayload: 'enc:dummy',
keyCount: 2,
createdBy: TEST_USERNAME,
note: '',
});
expect(created.version).toBe(1);
const row = db.getSecret(created.id)!;
expect(row.current_version).toBe(1);
const updated = db.updateSecretWithVersion({
secretId: created.id,
description: 'updated',
encryptedPayload: 'enc:dummy2',
keyCount: 3,
createdBy: TEST_USERNAME,
note: 'added DEBUG',
});
expect(updated.version).toBe(2);
const refreshed = db.getSecret(created.id)!;
expect(refreshed.current_version).toBe(2);
expect(refreshed.description).toBe('updated');
const versions = db.listSecretVersions(created.id);
expect(versions.map(v => v.version)).toEqual([2, 1]);
});
it('cascades versions and pushes on delete', () => {
const db = DatabaseService.getInstance();
const created = db.createSecretWithVersion({
name: 'to-delete',
description: '',
encryptedPayload: 'enc:x',
keyCount: 1,
createdBy: TEST_USERNAME,
note: '',
});
db.insertSecretPushes([{
secret_id: created.id,
version: 1,
push_id: 'push-1',
node_id: 1,
stack_name: 'demo',
env_file_basename: '.env',
status: 'ok',
error: '',
added_count: 1,
changed_count: 0,
unchanged_count: 0,
pushed_by: TEST_USERNAME,
pushed_at: Date.now(),
}]);
expect(db.deleteSecret(created.id)).toBe(true);
expect(db.getSecret(created.id)).toBeUndefined();
expect(db.listSecretVersions(created.id)).toEqual([]);
expect(db.listSecretPushes(created.id)).toEqual([]);
});
});
// ---- SecretsService versioning + decryption ----
describe('SecretsService', () => {
it('encrypts on create and decrypts on read', () => {
const svc = SecretsService.getInstance();
const { id } = svc.create({
name: 'app-secrets',
description: 'app',
kv: { DB_URL: 'postgres://x', API_KEY: 'abc' },
user: TEST_USERNAME,
});
const kv = svc.getDecryptedKv(id);
expect(kv).toEqual({ DB_URL: 'postgres://x', API_KEY: 'abc' });
// Stored payload must be encrypted, not plaintext.
const versionRow = DatabaseService.getInstance().getCurrentSecretVersion(id)!;
expect(versionRow.encrypted_payload.startsWith('enc:')).toBe(true);
expect(versionRow.encrypted_payload).not.toContain('postgres://x');
});
it('rejects invalid env keys at create', () => {
const svc = SecretsService.getInstance();
expect(() => svc.create({
name: 'bad-keys',
kv: { '1BAD': 'value' },
user: TEST_USERNAME,
})).toThrow(/Invalid env key/);
});
it('listVersions returns newest first', () => {
const svc = SecretsService.getInstance();
const { id } = svc.create({ name: 'v-list', kv: { A: '1' }, user: TEST_USERNAME });
svc.update(id, { kv: { A: '2', B: '3' }, user: TEST_USERNAME, note: 'add B' });
svc.update(id, { kv: { A: '4' }, user: TEST_USERNAME });
const versions = svc.listVersions(id);
expect(versions.map(v => v.version)).toEqual([3, 2, 1]);
expect(versions[1].note).toBe('add B');
});
});
// ---- importFromStack against the local node ----
describe('SecretsService.importFromStack (local node)', () => {
beforeEach(() => {
const composeDir = process.env.COMPOSE_DIR!;
const stackDir = path.join(composeDir, 'demo');
fs.mkdirSync(stackDir, { recursive: true });
fs.writeFileSync(path.join(stackDir, '.env'), 'DB_URL=postgres://x\nAPI_KEY=abc\n');
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n app:\n image: nginx\n');
});
it('reads the local stack .env into a KV map', async () => {
const db = DatabaseService.getInstance();
const localNode = db.getNodes().find(n => n.type === 'local')!;
const kv = await SecretsService.getInstance().importFromStack(localNode.id, 'demo', '.env');
expect(kv).toEqual({ DB_URL: 'postgres://x', API_KEY: 'abc' });
});
});
// ---- executePush against the local node ----
describe('SecretsService.executePush (local node)', () => {
beforeEach(() => {
const composeDir = process.env.COMPOSE_DIR!;
const stackDir = path.join(composeDir, 'targetstack');
fs.mkdirSync(stackDir, { recursive: true });
fs.writeFileSync(path.join(stackDir, '.env'), 'EXISTING=keep\nAPI_KEY=old\n');
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n app:\n image: nginx\n');
});
it('overlays bundle keys onto the existing .env and records audit rows', async () => {
const db = DatabaseService.getInstance();
const localNode = db.getNodes().find(n => n.type === 'local')!;
const svc = SecretsService.getInstance();
const { id } = svc.create({
name: 'push-bundle',
kv: { API_KEY: 'new', DEBUG: '1' },
user: TEST_USERNAME,
});
const result = await svc.executePush(
id,
{ type: 'nodes', ids: [localNode.id] },
'targetstack',
'.env',
TEST_USERNAME,
);
expect(result.results).toHaveLength(1);
expect(result.results[0].status).toBe('ok');
expect(result.results[0].added).toBe(1); // DEBUG
expect(result.results[0].changed).toBe(1); // API_KEY
const composeDir = process.env.COMPOSE_DIR!;
const envText = fs.readFileSync(path.join(composeDir, 'targetstack', '.env'), 'utf-8');
const kv = parseEnv(envText);
// Overlay: existing EXISTING preserved, API_KEY replaced, DEBUG added.
expect(kv.EXISTING).toBe('keep');
expect(kv.API_KEY).toBe('new');
expect(kv.DEBUG).toBe('1');
const pushes = db.listSecretPushes(id);
expect(pushes).toHaveLength(1);
expect(pushes[0].status).toBe('ok');
expect(pushes[0].push_id).toBe(result.pushId);
});
it('marks a node failed when the env file is not declared and continues', async () => {
const db = DatabaseService.getInstance();
const localNode = db.getNodes().find(n => n.type === 'local')!;
const svc = SecretsService.getInstance();
const { id } = svc.create({ name: 'push-bad-file', kv: { X: '1' }, user: TEST_USERNAME });
const result = await svc.executePush(
id,
{ type: 'nodes', ids: [localNode.id] },
'targetstack',
'nonexistent.env',
TEST_USERNAME,
);
expect(result.results[0].status).toBe('failed');
expect(result.results[0].error).toMatch(/not found/);
});
});
// ---- getAuditSummary patterns ----
describe('getAuditSummary for secrets routes', () => {
it('resolves create / update / delete', () => {
expect(getAuditSummary('POST', '/secrets')).toBe('Created secret');
expect(getAuditSummary('PUT', '/secrets/42')).toBe('Updated secret: 42');
expect(getAuditSummary('DELETE', '/secrets/42')).toBe('Deleted secret: 42');
});
it('resolves push and import wildcards', () => {
expect(getAuditSummary('POST', '/secrets/42/import-from-stack')).toBe('Imported env into secret: 42');
expect(getAuditSummary('POST', '/secrets/42/push/preview')).toBe('Previewed secret push: 42');
expect(getAuditSummary('POST', '/secrets/42/push')).toBe('Pushed secret: 42');
});
});
// ---- Route guards via supertest ----
describe('Routes /api/secrets tier gating and lock', () => {
it('returns 403 when license is community', async () => {
const { LicenseService } = await import('../services/LicenseService');
// Use mockReturnValueOnce so the outer beforeAll spy keeps returning 'paid' for sibling tests.
// requirePaid only consults getTier once per request via effectiveTier(req).
const inst = LicenseService.getInstance();
const tierSpy = vi.spyOn(inst, 'getTier');
tierSpy.mockReturnValueOnce('community');
const res = await request(app)
.get('/api/secrets')
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
});
it('rejects unauthenticated requests', async () => {
const res = await request(app).get('/api/secrets');
expect(res.status).toBe(401);
});
it('returns 200 when paid', async () => {
const res = await request(app)
.get('/api/secrets')
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it('rejects malformed body on POST /secrets', async () => {
const res = await request(app)
.post('/api/secrets')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ name: 'x', kv: 'not-an-object' });
expect(res.status).toBe(400);
});
});