304 lines
15 KiB
TypeScript
304 lines
15 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
|
|
|
describe.each(['electric_cherry_bank.cfg', 'electric_cherry_moderation.cfg'])(
|
|
'private config %s',
|
|
(filename) => {
|
|
let fixture: Fixture;
|
|
const servers = ['t5_zombie_theater_1', 't6_zombie_town_1'];
|
|
const secret = Buffer.from('set tws_bank_token "ec_bank_private_regression_secret"\n');
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
fixture = await newFixture(servers);
|
|
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
|
await seedServerRegistry();
|
|
const { destOfKind } = await import('../../src/lib/server/config.js');
|
|
for (const server of servers) {
|
|
await mkdir(destOfKind(server, 'config'), { recursive: true });
|
|
await writeFile(join(destOfKind(server, 'config'), filename), secret);
|
|
}
|
|
});
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
await fixture.cleanup();
|
|
});
|
|
|
|
async function evidenceIsPrivate() {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
expect(db().prepare('SELECT 1 FROM file_blobs WHERE content=?').all(secret)).toEqual(
|
|
[]
|
|
);
|
|
expect(
|
|
db().prepare('SELECT 1 FROM deployment_files WHERE rel_path=?').all(filename)
|
|
).toEqual([]);
|
|
}
|
|
|
|
describe.each(servers)('private bank config on %s', (server) => {
|
|
it('does not read, preview, stage, or capture the generated file while repairing real drift', async () => {
|
|
const drift = await import('../../src/lib/server/fleet/drift.js');
|
|
const { destOfKind } = await import('../../src/lib/server/config.js');
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
|
const { requestRestart } =
|
|
await import('../../src/lib/server/provisioning/requests.js');
|
|
const { applyStagedFilesForOperation } =
|
|
await import('../../src/lib/server/fleet/staged-files.js');
|
|
const backend = await import('../../src/lib/server/backends/files.js');
|
|
const reads = vi.spyOn(backend, 'readFile');
|
|
expect(await drift.scanServerDrift(server)).toEqual([]);
|
|
expect((await drift.previewDrift([server])).files).toEqual([]);
|
|
expect((await drift.prepareDriftCampaign(server)).files).toEqual([]);
|
|
expect(
|
|
(await drift.runDriftDeployment({ servers: [server] })).deploymentId
|
|
).toBeNull();
|
|
const dest = destOfKind(server, 'config');
|
|
await writeFile(join(dest, 'stray.cfg'), 'unwanted');
|
|
// The exemption is for the generated path, not every matching basename.
|
|
await mkdir(join(dest, 'nested'));
|
|
await writeFile(join(dest, 'nested', filename), 'unwanted nested file');
|
|
for (const name of ['modified.gsc', 'missing.gsc']) {
|
|
const artifact = createScript({
|
|
name,
|
|
releaseVersion: '1.0.0',
|
|
targetRelPath: name,
|
|
kind: 'script',
|
|
scopeType: 'game',
|
|
scopeValue: server.slice(0, 2),
|
|
content: Buffer.from('promoted')
|
|
});
|
|
db()
|
|
.prepare('UPDATE scripts SET current_version_id=? WHERE id=?')
|
|
.run(artifact.version.id, artifact.script.id);
|
|
}
|
|
await writeFile(join(destOfKind(server, 'script'), 'modified.gsc'), 'modified');
|
|
const plan = await drift.previewDrift([server]);
|
|
expect(plan.files.map((file) => file.relPath).sort()).toEqual([
|
|
'missing.gsc',
|
|
'modified.gsc',
|
|
`nested/${filename}`,
|
|
'stray.cfg'
|
|
]);
|
|
const result = await drift.runDriftDeployment({
|
|
servers: [server],
|
|
expectedFingerprint: plan.fingerprint
|
|
});
|
|
const operation = requestRestart(server, {
|
|
kind: 'campaign',
|
|
id: result.deploymentId!
|
|
});
|
|
applyStagedFilesForOperation(operation.id, server);
|
|
expect(await drift.scanServerDrift(server)).toEqual([]);
|
|
expect(await readFile(join(dest, filename))).toEqual(secret);
|
|
expect(reads.mock.calls.some(([path]) => path === join(dest, filename))).toBe(
|
|
false
|
|
);
|
|
await evidenceIsPrivate();
|
|
});
|
|
|
|
it('filters old prepared bank evidence while retaining legitimate campaign changes', async () => {
|
|
const { prepareDriftCampaign, createDriftRebuildCampaign } =
|
|
await import('../../src/lib/server/fleet/drift.js');
|
|
const { beginDeployment } =
|
|
await import('../../src/lib/server/provisioning/history.js');
|
|
const { destOfKind } = await import('../../src/lib/server/config.js');
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
await writeFile(join(destOfKind(server, 'config'), 'stray.cfg'), 'unwanted');
|
|
const prepared = await prepareDriftCampaign(server);
|
|
const privateFile = {
|
|
...prepared.files[0],
|
|
before: secret,
|
|
file: { ...prepared.files[0].file, relPath: filename }
|
|
};
|
|
prepared.files.push(privateFile);
|
|
prepared.plan.files.push(privateFile.file);
|
|
const operation = beginDeployment(server, 'rebuild', 'watchdog');
|
|
const id = createDriftRebuildCampaign(server, operation, 'old-incident', prepared);
|
|
expect(
|
|
db()
|
|
.prepare('SELECT rel_path FROM deployment_files WHERE deployment_id=?')
|
|
.all(id)
|
|
).toEqual([{ rel_path: 'stray.cfg' }]);
|
|
expect(
|
|
createDriftRebuildCampaign(server, operation, 'bank-only', {
|
|
...prepared,
|
|
files: [privateFile]
|
|
})
|
|
).toBeNull();
|
|
await evidenceIsPrivate();
|
|
});
|
|
|
|
it('rejects new and persisted private deletions before activation or evidence capture', async () => {
|
|
const staged = await import('../../src/lib/server/fleet/staged-files.js');
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const { requestRestart } =
|
|
await import('../../src/lib/server/provisioning/requests.js');
|
|
const { applyActivationFiles } =
|
|
await import('../../src/lib/server/provisioning/activation-files.js');
|
|
const { captureRecipePlan } =
|
|
await import('../../src/lib/server/fleet/launch-recipes.js');
|
|
const { destOfKind } = await import('../../src/lib/server/config.js');
|
|
db()
|
|
.prepare(
|
|
"INSERT INTO deployments(id,action,started_at,triggered_by) VALUES('old','drift',datetime('now'),'ui')"
|
|
)
|
|
.run();
|
|
db()
|
|
.prepare(
|
|
"INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state) VALUES('old',?,1,'pending')"
|
|
)
|
|
.run(server);
|
|
const change = {
|
|
sourceKind: 'campaign' as const,
|
|
sourceId: 'old',
|
|
server,
|
|
kind: 'config' as const,
|
|
relPath: filename,
|
|
content: null,
|
|
inventorySource: 'drift:old'
|
|
};
|
|
expect(() => staged.queueFileChange(change)).toThrow('Private bank configuration');
|
|
db()
|
|
.prepare(
|
|
"INSERT INTO staged_file_changes(source_kind,source_id,server,kind,rel_path,inventory_source) VALUES('campaign','old',?,'config',?,'drift:old')"
|
|
)
|
|
.run(server, filename);
|
|
const operation = requestRestart(server, { kind: 'campaign', id: 'old' });
|
|
expect(() => staged.applyStagedFilesForOperation(operation.id, server)).toThrow(
|
|
'Private bank configuration'
|
|
);
|
|
const recipe = captureRecipePlan(
|
|
server.startsWith('t6') ? 't6' : 't5',
|
|
server.startsWith('t6') ? 'town' : 'theater'
|
|
);
|
|
expect(() => applyActivationFiles(recipe, operation.id, true)).toThrow(
|
|
'Private bank configuration'
|
|
);
|
|
expect(await readFile(join(destOfKind(server, 'config'), filename))).toEqual(
|
|
secret
|
|
);
|
|
await evidenceIsPrivate();
|
|
});
|
|
});
|
|
|
|
it('rejects the reserved upload name and redacts legacy receipts before packing their bytes', async () => {
|
|
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
|
const { packFileJson } = await import('../../src/lib/server/db/file-blobs.js');
|
|
expect(() =>
|
|
createScript({
|
|
name: 'private',
|
|
kind: 'config',
|
|
targetRelPath: filename,
|
|
scopeType: 'game',
|
|
scopeValue: 't5',
|
|
content: secret
|
|
})
|
|
).toThrow('reserved');
|
|
const packed = packFileJson({
|
|
before: [{ path: `configs/${filename}`, contentBase64: secret.toString('base64') }]
|
|
});
|
|
expect(packed.hashes.size).toBe(0);
|
|
expect(JSON.stringify(packed.value)).not.toContain(secret.toString('base64'));
|
|
await evidenceIsPrivate();
|
|
});
|
|
|
|
it('blocks legacy private campaign diffs and blob downloads', async () => {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const { putFileBlob, getFileBlob } =
|
|
await import('../../src/lib/server/db/file-blobs.js');
|
|
db()
|
|
.prepare(
|
|
"INSERT INTO deployments(id,action,started_at,triggered_by) VALUES('legacy','drift',datetime('now'),'ui')"
|
|
)
|
|
.run();
|
|
const hash = putFileBlob(secret)!;
|
|
const file = db()
|
|
.prepare(
|
|
"INSERT INTO deployment_files(deployment_id,server,verb,rel_path,content_before_hash) VALUES('legacy',?,'removed',?,?)"
|
|
)
|
|
.run(servers[0], filename, hash);
|
|
for (const route of [
|
|
await import('../../src/routes/api/campaigns/[id]/file/[fileId]/+server.js'),
|
|
await import('../../src/routes/api/deployments/[id]/file/[fileId]/+server.js')
|
|
]) {
|
|
await expect(
|
|
route.GET({ params: { id: 'legacy', fileId: String(file.lastInsertRowid) } })
|
|
).rejects.toMatchObject({ status: 404 });
|
|
}
|
|
expect(() => getFileBlob(hash)).toThrow('Private file content');
|
|
});
|
|
|
|
it('blocks old host commands and redacts late private read receipts from older agents', async () => {
|
|
const hosts = await import('../../src/lib/server/hosts/index.js');
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const host = hosts.createHost({ name: 'Legacy private command host' });
|
|
const credential = hosts.enrollHost(
|
|
hosts.createEnrollment(host.id).token,
|
|
{}
|
|
).credential;
|
|
const payload = {
|
|
files: [{ path: `configs/${filename}`, contentBase64: secret.toString('base64') }]
|
|
};
|
|
expect(() => hosts.queueHostOperation(host.id, 'files.stage', payload)).toThrow(
|
|
'Private bank'
|
|
);
|
|
const stage = hosts.queueHostOperation(host.id, 'files.stage', { files: [] });
|
|
db()
|
|
.prepare('UPDATE ec_host_operations SET payload=? WHERE id=?')
|
|
.run(JSON.stringify(payload), stage.id);
|
|
expect(() => hosts.getHostOperationPayload(host.id, stage.id)).toThrow('Private bank');
|
|
const healthyRead = hosts.queueHostOperation(host.id, 'files.read', {
|
|
path: 'configs/stray.cfg'
|
|
});
|
|
expect(
|
|
hosts.pendingHostOperationRows(host.id).map((operation) => operation.id)
|
|
).toContain(healthyRead.id);
|
|
expect(
|
|
hosts.pendingHostOperationRows(host.id).map((operation) => operation.id)
|
|
).not.toContain(stage.id);
|
|
const read = hosts.queueHostOperation(host.id, 'files.read', {
|
|
path: 'configs/stray.cfg'
|
|
});
|
|
db()
|
|
.prepare('UPDATE ec_host_operations SET payload=?,target_path=? WHERE id=?')
|
|
.run(
|
|
JSON.stringify({ path: `configs/${filename}` }),
|
|
`configs/${filename}`,
|
|
read.id
|
|
);
|
|
hosts.reportHostOperationStatus(host.id, read.id, {
|
|
state: 'completed',
|
|
result: { contentBase64: secret.toString('base64') }
|
|
});
|
|
expect(hosts.getHostOperation(read.id)?.result).toEqual({ redacted: true });
|
|
await evidenceIsPrivate();
|
|
|
|
// Even an old operation reference cannot authorize downloading captured credentials.
|
|
const { putFileBlob, referenceBlobs } =
|
|
await import('../../src/lib/server/db/file-blobs.js');
|
|
const hash = putFileBlob(secret)!;
|
|
referenceBlobs('operation', stage.id, [hash]);
|
|
db()
|
|
.prepare(
|
|
"INSERT INTO deployments(id,action,started_at,triggered_by) VALUES('legacy','drift',datetime('now'),'ui')"
|
|
)
|
|
.run();
|
|
db()
|
|
.prepare(
|
|
"INSERT INTO deployment_files(deployment_id,server,verb,rel_path,content_before_hash) VALUES('legacy',?,'removed',?,?)"
|
|
)
|
|
.run(servers[0], filename, hash);
|
|
const { GET } = await import('../../src/routes/api/agent/blobs/[sha256]/+server.js');
|
|
expect(() =>
|
|
GET({
|
|
request: new Request('https://test', {
|
|
headers: { Authorization: `Bearer ${credential}` }
|
|
}),
|
|
params: { sha256: hash }
|
|
})
|
|
).toThrow();
|
|
});
|
|
}
|
|
);
|