mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 03:18:09 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
inspectLegacySqlitePath,
|
||||
prepareLocalSqliteActivation,
|
||||
stageLocalSqliteAdoption,
|
||||
} = require('@qinglong/local-admin');
|
||||
const {
|
||||
bootstrapLocalAdoptedProfileStorage,
|
||||
} = require('@qinglong/local-admin/adopted-profile');
|
||||
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-adopted-profile-'),
|
||||
);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const value = {
|
||||
directory,
|
||||
sourcePath: path.join(directory, 'database.sqlite'),
|
||||
targetPath: path.join(directory, 'qinglong3.sqlite'),
|
||||
recoveryPath: path.join(directory, 'database.pre-ql3.sqlite'),
|
||||
manifestPath: path.join(directory, 'qinglong3-adoption.json'),
|
||||
activationPath: path.join(directory, 'qinglong3-activation.json'),
|
||||
};
|
||||
const source = new DatabaseSync(value.sourcePath);
|
||||
source.exec(`
|
||||
CREATE TABLE "Auths" (id INTEGER PRIMARY KEY, type TEXT, info TEXT);
|
||||
CREATE TABLE "Crontabs" (
|
||||
id INTEGER PRIMARY KEY, command TEXT NOT NULL, schedule TEXT
|
||||
);
|
||||
CREATE TABLE "Envs" (
|
||||
id INTEGER PRIMARY KEY, name TEXT, value TEXT
|
||||
);
|
||||
INSERT INTO "Crontabs" (id, command, schedule)
|
||||
VALUES (1, 'echo legacy', '0 0 * * *');
|
||||
`);
|
||||
source.close();
|
||||
return value;
|
||||
}
|
||||
|
||||
async function prepare(t, profile = 'edge') {
|
||||
const value = fixture(t);
|
||||
const plan = inspectLegacySqlitePath({
|
||||
sourcePath: value.sourcePath,
|
||||
profile,
|
||||
});
|
||||
const adoption = await stageLocalSqliteAdoption({
|
||||
...value,
|
||||
profile,
|
||||
expectedPlanDigest: plan.planDigest,
|
||||
});
|
||||
const activation = await prepareLocalSqliteActivation({
|
||||
...value,
|
||||
expectedManifestDigest: adoption.manifestDigest,
|
||||
});
|
||||
return { ...value, activation };
|
||||
}
|
||||
|
||||
test('runtime adopted composition does not load executable migration SQL', () => {
|
||||
const script = `
|
||||
require(${JSON.stringify(path.resolve(__dirname, '../dist/adopted-profile/localAdoptedProfile.js'))});
|
||||
const loaded = Object.keys(require.cache)
|
||||
.filter((entry) => /[\\/]local-sqlite[\\/]dist[\\/](?:migration|migrations[\\/])/.test(entry));
|
||||
process.stdout.write(JSON.stringify(loaded));
|
||||
`;
|
||||
const result = spawnSync(process.execPath, ['-e', script], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.deepEqual(JSON.parse(result.stdout), []);
|
||||
});
|
||||
|
||||
test('disabled adopted composition never inspects activation paths', async () => {
|
||||
const storageAudits = [];
|
||||
const adoptionAudits = [];
|
||||
const result = await bootstrapLocalAdoptedProfileStorage({
|
||||
enabled: false,
|
||||
profile: 'edge',
|
||||
sourcePath: 'invalid',
|
||||
targetPath: 'invalid',
|
||||
recoveryPath: 'invalid',
|
||||
manifestPath: 'invalid',
|
||||
activationPath: 'invalid',
|
||||
expectedActivationDigest: 'invalid',
|
||||
audit: (record) => storageAudits.push(record),
|
||||
adoptionAudit: (record) => adoptionAudits.push(record),
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'disabled');
|
||||
assert.deepEqual(
|
||||
storageAudits.map(({ state }) => state),
|
||||
['disabled'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
adoptionAudits.map(({ state }) => state),
|
||||
['disabled'],
|
||||
);
|
||||
assert.equal(await result.stop(), 'stopped');
|
||||
assert.deepEqual(
|
||||
adoptionAudits.map(({ state }) => state),
|
||||
['disabled', 'stopped'],
|
||||
);
|
||||
});
|
||||
|
||||
test('starts target storage only while the legacy source remains fenced', async (t) => {
|
||||
const value = await prepare(t, 'edge');
|
||||
const storageAudits = [];
|
||||
const adoptionAudits = [];
|
||||
const result = await bootstrapLocalAdoptedProfileStorage({
|
||||
enabled: true,
|
||||
profile: 'edge',
|
||||
...value,
|
||||
expectedActivationDigest: value.activation.activationDigest,
|
||||
busyTimeoutMs: 100,
|
||||
audit: (record) => storageAudits.push(record),
|
||||
adoptionAudit: (record) => adoptionAudits.push(record),
|
||||
});
|
||||
|
||||
assert.equal(result.status, 'adopted_storage_ready');
|
||||
assert.equal(result.evidence.contractName, 'local-control-core');
|
||||
assert.deepEqual(await result.startupRecovery.inspectCandidates(), {
|
||||
candidates: [],
|
||||
truncated: false,
|
||||
});
|
||||
assert.deepEqual(
|
||||
adoptionAudits.map(({ state }) => state),
|
||||
['fence_acquired', 'storage_ready'],
|
||||
);
|
||||
const legacyWriter = new DatabaseSync(value.sourcePath, { timeout: 100 });
|
||||
assert.throws(
|
||||
() =>
|
||||
legacyWriter
|
||||
.prepare('INSERT INTO "Crontabs" (id, command) VALUES (?, ?)')
|
||||
.run(2, 'echo blocked'),
|
||||
(error) => error && error.errstr === 'database is locked',
|
||||
);
|
||||
|
||||
assert.equal(await result.stop(), 'stopped');
|
||||
assert.equal(await result.stop(), 'stopped');
|
||||
legacyWriter
|
||||
.prepare('INSERT INTO "Crontabs" (id, command) VALUES (?, ?)')
|
||||
.run(2, 'echo released');
|
||||
legacyWriter.close();
|
||||
assert.deepEqual(
|
||||
adoptionAudits.map(({ state }) => state),
|
||||
['fence_acquired', 'storage_ready', 'stopped'],
|
||||
);
|
||||
assert.ok(storageAudits.some(({ state }) => state === 'storage_ready'));
|
||||
assert.ok(storageAudits.some(({ state }) => state === 'stopped'));
|
||||
});
|
||||
|
||||
test('source drift fails activation and releases the temporary fence', async (t) => {
|
||||
const value = await prepare(t, 'standalone');
|
||||
const source = new DatabaseSync(value.sourcePath);
|
||||
source
|
||||
.prepare('INSERT INTO "Crontabs" (id, command) VALUES (?, ?)')
|
||||
.run(2, 'echo late');
|
||||
source.close();
|
||||
const adoptionAudits = [];
|
||||
|
||||
await assert.rejects(
|
||||
bootstrapLocalAdoptedProfileStorage({
|
||||
enabled: true,
|
||||
profile: 'standalone',
|
||||
...value,
|
||||
expectedActivationDigest: value.activation.activationDigest,
|
||||
busyTimeoutMs: 100,
|
||||
audit() {},
|
||||
adoptionAudit: (record) => adoptionAudits.push(record),
|
||||
}),
|
||||
/legacy source identity or catalog changed after staging/,
|
||||
);
|
||||
assert.deepEqual(
|
||||
adoptionAudits.map(({ state }) => state),
|
||||
['failed'],
|
||||
);
|
||||
const writer = new DatabaseSync(value.sourcePath, { timeout: 100 });
|
||||
writer
|
||||
.prepare('INSERT INTO "Crontabs" (id, command) VALUES (?, ?)')
|
||||
.run(3, 'echo no leaked fence');
|
||||
writer.close();
|
||||
});
|
||||
|
||||
test('fails before readiness when the target path changes while storage opens', async (t) => {
|
||||
const value = await prepare(t, 'edge');
|
||||
const replacementPath = path.join(value.directory, 'replacement.sqlite');
|
||||
const adoptionAudits = [];
|
||||
|
||||
await assert.rejects(
|
||||
bootstrapLocalAdoptedProfileStorage({
|
||||
enabled: true,
|
||||
profile: 'edge',
|
||||
...value,
|
||||
expectedActivationDigest: value.activation.activationDigest,
|
||||
busyTimeoutMs: 100,
|
||||
audit(record) {
|
||||
if (record.state !== 'storage_ready') return;
|
||||
fs.copyFileSync(value.targetPath, replacementPath);
|
||||
fs.renameSync(replacementPath, value.targetPath);
|
||||
},
|
||||
adoptionAudit: (record) => adoptionAudits.push(record),
|
||||
}),
|
||||
/target database identity does not match the activation/,
|
||||
);
|
||||
assert.deepEqual(
|
||||
adoptionAudits.map(({ state }) => state),
|
||||
['fence_acquired', 'failed'],
|
||||
);
|
||||
const writer = new DatabaseSync(value.sourcePath, { timeout: 100 });
|
||||
writer
|
||||
.prepare('INSERT INTO "Crontabs" (id, command) VALUES (?, ?)')
|
||||
.run(2, 'echo no leaked fence');
|
||||
writer.close();
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
bootstrapEdgeAdoptedStorage,
|
||||
} = require('@qinglong/local-admin/adopted-profile/edge');
|
||||
const {
|
||||
bootstrapStandaloneAdoptedStorage,
|
||||
} = require('@qinglong/local-admin/adopted-profile/standalone');
|
||||
|
||||
for (const [profile, bootstrap] of [
|
||||
['edge', bootstrapEdgeAdoptedStorage],
|
||||
['standalone', bootstrapStandaloneAdoptedStorage],
|
||||
]) {
|
||||
test(`adopted ${profile} subpath fixes the Profile while remaining default-off`, async () => {
|
||||
const storage = [];
|
||||
const adoption = [];
|
||||
const result = await bootstrap({
|
||||
enabled: false,
|
||||
sourcePath: 'invalid',
|
||||
targetPath: 'invalid',
|
||||
recoveryPath: 'invalid',
|
||||
manifestPath: 'invalid',
|
||||
activationPath: 'invalid',
|
||||
expectedActivationDigest: 'invalid',
|
||||
audit: (record) => storage.push(record),
|
||||
adoptionAudit: (record) => adoption.push(record),
|
||||
});
|
||||
assert.equal(result.status, 'disabled');
|
||||
assert.equal(result.profile, profile);
|
||||
assert.equal(storage[0].profile, profile);
|
||||
assert.equal(adoption[0].profile, profile);
|
||||
await result.stop();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { test } = require('node:test');
|
||||
|
||||
test('keeps adopted activation authority unloaded until the capability is enabled', () => {
|
||||
const entrypoint = path.resolve(
|
||||
__dirname,
|
||||
'../dist/adopted-profile/localAdoptedProfile.js',
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
`require(${JSON.stringify(entrypoint)});
|
||||
const loaded = Object.keys(require.cache).filter((file) =>
|
||||
/[\\/]ql3-local-admin[\\/]dist[\\/](?:runtime|legacy-adoption[\\/])/.test(file),
|
||||
);
|
||||
process.stdout.write(JSON.stringify(loaded));`,
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.deepEqual(JSON.parse(result.stdout), []);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,191 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LocalSqliteAdoptionError,
|
||||
inspectLegacyCrontabAdoptionDiagnostics,
|
||||
inspectLegacySqlitePath,
|
||||
issueReviewedLegacyCrontabAdoptionDecisionAuthorizationFile,
|
||||
verifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFile,
|
||||
} = require('..');
|
||||
const {
|
||||
LegacyCrontabDecisionIssuerKeyringFileProvider,
|
||||
provisionLegacyCrontabDecisionIssuerKeyring,
|
||||
} = require('../dist/legacy-adoption/legacyCrontabDecisionIssuer');
|
||||
|
||||
const NOW_MS = 1_760_000_000_000;
|
||||
const DECISION_ID = '019a2b3c-4d5e-7f60-8123-456789abcdef';
|
||||
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-issuer-'));
|
||||
fs.chmodSync(directory, 0o700);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const sourcePath = path.join(directory, 'database.sqlite');
|
||||
const source = new DatabaseSync(sourcePath);
|
||||
source.exec(`
|
||||
CREATE TABLE "Auths" (id INTEGER PRIMARY KEY, type TEXT, info TEXT);
|
||||
CREATE TABLE "Crontabs" (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT,
|
||||
command TEXT NOT NULL,
|
||||
schedule TEXT
|
||||
);
|
||||
CREATE TABLE "Envs" (id INTEGER PRIMARY KEY, name TEXT, value TEXT);
|
||||
INSERT INTO "Crontabs" (id, name, command, schedule)
|
||||
VALUES (1, 'Reviewed task', 'task /scripts/reviewed.sh', '0 0 * * *');
|
||||
`);
|
||||
source.close();
|
||||
return {
|
||||
directory,
|
||||
sourcePath,
|
||||
authorizationPath: path.join(directory, 'decision.ndjson'),
|
||||
issuerKeyringPath: path.join(directory, 'decision-issuer.keyring'),
|
||||
};
|
||||
}
|
||||
|
||||
function review(value) {
|
||||
const plan = inspectLegacySqlitePath({
|
||||
sourcePath: value.sourcePath,
|
||||
profile: 'edge',
|
||||
legacyTimezone: 'UTC',
|
||||
});
|
||||
const page = inspectLegacyCrontabAdoptionDiagnostics({
|
||||
sourcePath: value.sourcePath,
|
||||
profile: 'edge',
|
||||
legacyTimezone: 'UTC',
|
||||
expectedPlanDigest: plan.planDigest,
|
||||
limit: 16,
|
||||
});
|
||||
assert.equal(page.diagnostics.length, 1);
|
||||
assert.equal(page.diagnostics[0].classification, 'lossless');
|
||||
return {
|
||||
plan,
|
||||
decisions: [
|
||||
{
|
||||
rowOrdinal: page.diagnostics[0].rowOrdinal,
|
||||
sourceDigest: page.diagnostics[0].sourceDigest,
|
||||
disposition: 'adopt',
|
||||
reason: 'reviewed_lossless',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function reviewer(assurance = 'local_console') {
|
||||
return Object.freeze({
|
||||
subject: Object.freeze({ type: 'user', id: 'local-owner' }),
|
||||
authenticationId: 'local-console:credential-v1',
|
||||
authenticatedAtMs: NOW_MS - 1_000,
|
||||
expiresAtMs: NOW_MS + 5 * 60 * 1_000,
|
||||
assurance,
|
||||
});
|
||||
}
|
||||
|
||||
test('issues one bounded authorization from an authenticated capability and dedicated keyring', async (t) => {
|
||||
const value = fixture(t);
|
||||
const { plan, decisions } = review(value);
|
||||
await provisionLegacyCrontabDecisionIssuerKeyring(value.issuerKeyringPath);
|
||||
let authenticationCalls = 0;
|
||||
let confirmationCalls = 0;
|
||||
const result =
|
||||
await issueReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({
|
||||
sourcePath: value.sourcePath,
|
||||
profile: 'edge',
|
||||
legacyTimezone: 'UTC',
|
||||
expectedPlanDigest: plan.planDigest,
|
||||
decisionId: DECISION_ID,
|
||||
authorizationPath: value.authorizationPath,
|
||||
issuerKeyringPath: value.issuerKeyringPath,
|
||||
decisions,
|
||||
async authenticateReviewer() {
|
||||
authenticationCalls += 1;
|
||||
return reviewer();
|
||||
},
|
||||
confirmIssuerAuthority() {
|
||||
confirmationCalls += 1;
|
||||
},
|
||||
lifetimeMs: 2 * 60 * 1_000,
|
||||
clock: () => NOW_MS,
|
||||
});
|
||||
|
||||
assert.equal(authenticationCalls, 1);
|
||||
assert.equal(confirmationCalls, 4);
|
||||
assert.equal(result.receipt.reviewer.subject.id, 'local-owner');
|
||||
assert.equal(result.receipt.issuedAtMs, NOW_MS);
|
||||
assert.equal(result.receipt.expiresAtMs, NOW_MS + 2 * 60 * 1_000);
|
||||
assert.match(result.file.keyId, /^qladk-/);
|
||||
assert.equal(fs.statSync(value.authorizationPath).mode & 0o777, 0o600);
|
||||
|
||||
const verified =
|
||||
await verifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({
|
||||
sourcePath: value.sourcePath,
|
||||
profile: 'edge',
|
||||
legacyTimezone: 'UTC',
|
||||
expectedPlanDigest: plan.planDigest,
|
||||
expectedDecisionId: DECISION_ID,
|
||||
authorizationPath: value.authorizationPath,
|
||||
keyProvider: new LegacyCrontabDecisionIssuerKeyringFileProvider(
|
||||
value.issuerKeyringPath,
|
||||
),
|
||||
observedAtMs: NOW_MS + 1_000,
|
||||
});
|
||||
assert.equal(verified.file.fileDigest, result.file.fileDigest);
|
||||
});
|
||||
|
||||
test('rejects a self-reported weak reviewer before reading any issuer key', async (t) => {
|
||||
const value = fixture(t);
|
||||
const { plan, decisions } = review(value);
|
||||
await assert.rejects(
|
||||
issueReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({
|
||||
sourcePath: value.sourcePath,
|
||||
profile: 'edge',
|
||||
legacyTimezone: 'UTC',
|
||||
expectedPlanDigest: plan.planDigest,
|
||||
decisionId: DECISION_ID,
|
||||
authorizationPath: value.authorizationPath,
|
||||
issuerKeyringPath: value.issuerKeyringPath,
|
||||
decisions,
|
||||
authenticateReviewer: () => reviewer('single_factor'),
|
||||
confirmIssuerAuthority() {},
|
||||
clock: () => NOW_MS,
|
||||
}),
|
||||
LocalSqliteAdoptionError,
|
||||
);
|
||||
assert.equal(fs.existsSync(value.authorizationPath), false);
|
||||
});
|
||||
|
||||
test('rechecks issuer authority immediately before no-replace publication', async (t) => {
|
||||
const value = fixture(t);
|
||||
const { plan, decisions } = review(value);
|
||||
await provisionLegacyCrontabDecisionIssuerKeyring(value.issuerKeyringPath);
|
||||
let confirmationCalls = 0;
|
||||
await assert.rejects(
|
||||
issueReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({
|
||||
sourcePath: value.sourcePath,
|
||||
profile: 'edge',
|
||||
legacyTimezone: 'UTC',
|
||||
expectedPlanDigest: plan.planDigest,
|
||||
decisionId: DECISION_ID,
|
||||
authorizationPath: value.authorizationPath,
|
||||
issuerKeyringPath: value.issuerKeyringPath,
|
||||
decisions,
|
||||
authenticateReviewer: () => reviewer(),
|
||||
confirmIssuerAuthority() {
|
||||
confirmationCalls += 1;
|
||||
if (confirmationCalls === 4) throw new Error('authority drift');
|
||||
},
|
||||
clock: () => NOW_MS,
|
||||
}),
|
||||
LocalSqliteAdoptionError,
|
||||
);
|
||||
assert.equal(confirmationCalls, 4);
|
||||
assert.equal(fs.existsSync(value.authorizationPath), false);
|
||||
assert.deepEqual(
|
||||
fs.readdirSync(value.directory).filter((entry) => entry.includes('.tmp')),
|
||||
[],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LegacyCrontabDecisionIssuerKeyringConflictError,
|
||||
LegacyCrontabDecisionIssuerKeyringFileProvider,
|
||||
LegacyCrontabDecisionIssuerKeyringUnavailableError,
|
||||
MAX_LEGACY_CRONTAB_DECISION_ISSUER_KEYS,
|
||||
provisionLegacyCrontabDecisionIssuerKeyring,
|
||||
rotateLegacyCrontabDecisionIssuerKeyring,
|
||||
} = require('../dist/legacy-adoption/legacyCrontabDecisionIssuerKeyring');
|
||||
|
||||
function fixture(t) {
|
||||
const root = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-adoption-issuer-keyring-'),
|
||||
);
|
||||
fs.chmodSync(root, 0o700);
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return { root, filePath: path.join(root, 'issuer.keyring') };
|
||||
}
|
||||
|
||||
test('provisions one private dedicated key and rotates with exact replay fences', async (t) => {
|
||||
const { filePath } = fixture(t);
|
||||
const initial = await provisionLegacyCrontabDecisionIssuerKeyring(filePath);
|
||||
assert.equal(initial.schemaVersion, 1);
|
||||
assert.equal(
|
||||
initial.kind,
|
||||
'qinglong3-legacy-crontab-decision-issuer-keyring-summary',
|
||||
);
|
||||
assert.equal(initial.keyCount, 1);
|
||||
assert.match(initial.activeKeyId, /^qladk-/);
|
||||
assert.match(initial.keyringDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal(fs.statSync(filePath).mode & 0o777, 0o600);
|
||||
|
||||
const provider = new LegacyCrontabDecisionIssuerKeyringFileProvider(filePath);
|
||||
const active = await provider.active();
|
||||
assert.equal(active.keyId, initial.activeKeyId);
|
||||
assert.equal(active.key.byteLength, 32);
|
||||
|
||||
const rotated = await rotateLegacyCrontabDecisionIssuerKeyring({
|
||||
filePath,
|
||||
expectedActiveKeyId: initial.activeKeyId,
|
||||
expectedKeyringDigest: initial.keyringDigest,
|
||||
});
|
||||
assert.equal(rotated.keyCount, 2);
|
||||
assert.notEqual(rotated.activeKeyId, initial.activeKeyId);
|
||||
assert.notEqual(rotated.keyringDigest, initial.keyringDigest);
|
||||
assert.equal((await provider.active()).keyId, rotated.activeKeyId);
|
||||
assert.equal(
|
||||
(await provider.resolve(initial.activeKeyId)).keyId,
|
||||
active.keyId,
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
rotateLegacyCrontabDecisionIssuerKeyring({
|
||||
filePath,
|
||||
expectedActiveKeyId: initial.activeKeyId,
|
||||
expectedKeyringDigest: initial.keyringDigest,
|
||||
}),
|
||||
LegacyCrontabDecisionIssuerKeyringConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('never replaces an existing keyring and leaves another rotation lock intact', async (t) => {
|
||||
const { filePath } = fixture(t);
|
||||
const initial = await provisionLegacyCrontabDecisionIssuerKeyring(filePath);
|
||||
const before = fs.readFileSync(filePath);
|
||||
await assert.rejects(
|
||||
provisionLegacyCrontabDecisionIssuerKeyring(filePath),
|
||||
LegacyCrontabDecisionIssuerKeyringConflictError,
|
||||
);
|
||||
assert.deepEqual(fs.readFileSync(filePath), before);
|
||||
|
||||
const lockPath = `${filePath}.lock`;
|
||||
fs.writeFileSync(lockPath, '', { mode: 0o600, flag: 'wx' });
|
||||
await assert.rejects(
|
||||
rotateLegacyCrontabDecisionIssuerKeyring({
|
||||
filePath,
|
||||
expectedActiveKeyId: initial.activeKeyId,
|
||||
expectedKeyringDigest: initial.keyringDigest,
|
||||
}),
|
||||
LegacyCrontabDecisionIssuerKeyringUnavailableError,
|
||||
);
|
||||
assert.equal(fs.existsSync(lockPath), true);
|
||||
});
|
||||
|
||||
test('fails closed for broad files, tampering, symlinks and parent replacement', async (t) => {
|
||||
const { root, filePath } = fixture(t);
|
||||
await provisionLegacyCrontabDecisionIssuerKeyring(filePath);
|
||||
const provider = new LegacyCrontabDecisionIssuerKeyringFileProvider(filePath);
|
||||
|
||||
fs.chmodSync(filePath, 0o644);
|
||||
await assert.rejects(
|
||||
provider.active(),
|
||||
LegacyCrontabDecisionIssuerKeyringUnavailableError,
|
||||
);
|
||||
fs.chmodSync(filePath, 0o600);
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
manifest.keys[manifest.activeKeyId] = 'not-canonical-key-material';
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(manifest)}\n`, { mode: 0o600 });
|
||||
await assert.rejects(
|
||||
provider.active(),
|
||||
LegacyCrontabDecisionIssuerKeyringUnavailableError,
|
||||
);
|
||||
|
||||
const other = path.join(root, 'other.keyring');
|
||||
await provisionLegacyCrontabDecisionIssuerKeyring(other);
|
||||
const link = path.join(root, 'link.keyring');
|
||||
fs.symlinkSync(other, link);
|
||||
await assert.rejects(
|
||||
new LegacyCrontabDecisionIssuerKeyringFileProvider(link).active(),
|
||||
LegacyCrontabDecisionIssuerKeyringUnavailableError,
|
||||
);
|
||||
|
||||
const moved = `${root}-moved`;
|
||||
fs.renameSync(root, moved);
|
||||
fs.mkdirSync(root, { mode: 0o700 });
|
||||
t.after(() => fs.rmSync(moved, { recursive: true, force: true }));
|
||||
await assert.rejects(
|
||||
provider.active(),
|
||||
LegacyCrontabDecisionIssuerKeyringUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
test('retains at most eight verification keys', async (t) => {
|
||||
const { filePath } = fixture(t);
|
||||
let summary = await provisionLegacyCrontabDecisionIssuerKeyring(filePath);
|
||||
while (summary.keyCount < MAX_LEGACY_CRONTAB_DECISION_ISSUER_KEYS) {
|
||||
summary = await rotateLegacyCrontabDecisionIssuerKeyring({
|
||||
filePath,
|
||||
expectedActiveKeyId: summary.activeKeyId,
|
||||
expectedKeyringDigest: summary.keyringDigest,
|
||||
});
|
||||
}
|
||||
assert.equal(summary.keyCount, MAX_LEGACY_CRONTAB_DECISION_ISSUER_KEYS);
|
||||
await assert.rejects(
|
||||
rotateLegacyCrontabDecisionIssuerKeyring({
|
||||
filePath,
|
||||
expectedActiveKeyId: summary.activeKeyId,
|
||||
expectedKeyringDigest: summary.keyringDigest,
|
||||
}),
|
||||
LegacyCrontabDecisionIssuerKeyringUnavailableError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LegacyCrontabAdoptionDecisionReviewFileError,
|
||||
withPrivateLegacyCrontabAdoptionDecisionReviewFile,
|
||||
} = require('../dist/legacy-adoption/legacyCrontabDecisionIssuer');
|
||||
|
||||
const DECISION_ID = '019a2b3c-4d5e-7f60-8123-456789abcdef';
|
||||
const PLAN_DIGEST = 'a'.repeat(64);
|
||||
const INVENTORY_DIGEST = 'b'.repeat(64);
|
||||
const DECISION = Object.freeze({
|
||||
rowOrdinal: 1,
|
||||
sourceDigest: 'c'.repeat(64),
|
||||
disposition: 'adopt',
|
||||
reason: 'reviewed_lossless',
|
||||
});
|
||||
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-review-file-'));
|
||||
fs.chmodSync(directory, 0o700);
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const filePath = path.join(directory, 'review.ndjson');
|
||||
const records = [
|
||||
{
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-legacy-crontab-decision-review-file-header',
|
||||
decisionId: DECISION_ID,
|
||||
profile: 'edge',
|
||||
planDigest: PLAN_DIGEST,
|
||||
inventoryDigest: INVENTORY_DIGEST,
|
||||
},
|
||||
{
|
||||
schemaVersion: 1,
|
||||
kind: 'qinglong3-legacy-crontab-decision-review-file-row',
|
||||
decision: DECISION,
|
||||
},
|
||||
];
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
`${records.map((record) => JSON.stringify(record)).join('\n')}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
return { directory, filePath };
|
||||
}
|
||||
|
||||
function options(filePath) {
|
||||
return {
|
||||
filePath,
|
||||
expectedDecisionId: DECISION_ID,
|
||||
expectedProfile: 'edge',
|
||||
expectedPlanDigest: PLAN_DIGEST,
|
||||
expectedInventoryDigest: INVENTORY_DIGEST,
|
||||
};
|
||||
}
|
||||
|
||||
test('streams repeatable decisions from one authenticated private descriptor', async (t) => {
|
||||
const value = fixture(t);
|
||||
const result = await withPrivateLegacyCrontabAdoptionDecisionReviewFile(
|
||||
options(value.filePath),
|
||||
(scope) => {
|
||||
assert.equal(scope.evidence.decisionCount, 1);
|
||||
assert.match(scope.evidence.fileDigest, /^[0-9a-f]{64}$/);
|
||||
assert.deepEqual([...scope.decisions], [DECISION]);
|
||||
assert.deepEqual([...scope.decisions], [DECISION]);
|
||||
scope.confirmIdentity();
|
||||
return scope.evidence.fileDigest;
|
||||
},
|
||||
);
|
||||
assert.match(result, /^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test('fails closed on private-path violations and in-flight replacement', async (t) => {
|
||||
const value = fixture(t);
|
||||
fs.chmodSync(value.filePath, 0o640);
|
||||
await assert.rejects(
|
||||
withPrivateLegacyCrontabAdoptionDecisionReviewFile(
|
||||
options(value.filePath),
|
||||
() => undefined,
|
||||
),
|
||||
LegacyCrontabAdoptionDecisionReviewFileError,
|
||||
);
|
||||
fs.chmodSync(value.filePath, 0o600);
|
||||
|
||||
const linkPath = path.join(value.directory, 'review-link.ndjson');
|
||||
fs.symlinkSync(value.filePath, linkPath);
|
||||
await assert.rejects(
|
||||
withPrivateLegacyCrontabAdoptionDecisionReviewFile(
|
||||
options(linkPath),
|
||||
() => undefined,
|
||||
),
|
||||
LegacyCrontabAdoptionDecisionReviewFileError,
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
withPrivateLegacyCrontabAdoptionDecisionReviewFile(
|
||||
options(value.filePath),
|
||||
() => {
|
||||
const replacement = path.join(value.directory, 'replacement.ndjson');
|
||||
fs.copyFileSync(value.filePath, replacement);
|
||||
fs.chmodSync(replacement, 0o600);
|
||||
fs.renameSync(replacement, value.filePath);
|
||||
},
|
||||
),
|
||||
LegacyCrontabAdoptionDecisionReviewFileError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects widened records and mismatched review identity', async (t) => {
|
||||
const value = fixture(t);
|
||||
const widened = fs
|
||||
.readFileSync(value.filePath, 'utf8')
|
||||
.replace(
|
||||
'"reason":"reviewed_lossless"',
|
||||
'"reason":"reviewed_lossless","override":true',
|
||||
);
|
||||
fs.writeFileSync(value.filePath, widened, { mode: 0o600 });
|
||||
await assert.rejects(
|
||||
withPrivateLegacyCrontabAdoptionDecisionReviewFile(
|
||||
options(value.filePath),
|
||||
() => undefined,
|
||||
),
|
||||
LegacyCrontabAdoptionDecisionReviewFileError,
|
||||
);
|
||||
|
||||
const fresh = fixture(t);
|
||||
await assert.rejects(
|
||||
withPrivateLegacyCrontabAdoptionDecisionReviewFile(
|
||||
{ ...options(fresh.filePath), expectedInventoryDigest: 'd'.repeat(64) },
|
||||
() => undefined,
|
||||
),
|
||||
LegacyCrontabAdoptionDecisionReviewFileError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LocalIdentityCredentialAdministrationAuthorizationError,
|
||||
createLocalIdentityCredentialAdministrationService,
|
||||
} = require('@qinglong/local-admin/identity-credential-administration');
|
||||
|
||||
const MUTATION_ID = '82000000-0000-4000-8000-000000000001';
|
||||
const SUBJECT = Object.freeze({ type: 'agent', id: 'agent-planner' });
|
||||
const PRINCIPAL = Object.freeze({
|
||||
subject: { type: 'user', id: 'owner-user' },
|
||||
authenticationId: 'local_identity_admin:test',
|
||||
authenticatedAtMs: 0,
|
||||
expiresAtMs: 120_000,
|
||||
assurance: 'local_console',
|
||||
});
|
||||
|
||||
function projectPolicy(role = 'owner') {
|
||||
return {
|
||||
async resolve() {
|
||||
return {
|
||||
project: {
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
slug: 'default',
|
||||
status: 'active',
|
||||
version: 1,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
},
|
||||
binding: {
|
||||
projectId: 'default',
|
||||
subject: PRINCIPAL.subject,
|
||||
version: 1,
|
||||
state: 'active',
|
||||
role,
|
||||
mutationId: 'owner-binding',
|
||||
changedBy: PRINCIPAL.subject,
|
||||
createdAtMs: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
async append() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('replays a committed credential after time advances without rereading Identity state', async () => {
|
||||
let nowMs = 1_000;
|
||||
let identityReads = 0;
|
||||
let stored;
|
||||
const repository = {
|
||||
async resolveAuthorityProjectId() {
|
||||
return 'default';
|
||||
},
|
||||
async resolveIdentity() {
|
||||
identityReads += 1;
|
||||
return {
|
||||
subject: SUBJECT,
|
||||
status: 'active',
|
||||
version: 1,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
};
|
||||
},
|
||||
async resolveIdentityMutation() {
|
||||
return null;
|
||||
},
|
||||
async appendAuthorizedIdentity() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async inspectAuthorizedIdentity() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async resolveCredentialMutation() {
|
||||
return stored ?? null;
|
||||
},
|
||||
async appendAuthorizedCredential(command) {
|
||||
if (!stored) {
|
||||
stored = Object.freeze({
|
||||
projectId: command.authorization.projectId,
|
||||
credential: Object.freeze({ ...command.credential }),
|
||||
mutation: Object.freeze({ ...command.mutation }),
|
||||
delivery: command.delivery,
|
||||
audit: command.audit,
|
||||
});
|
||||
return Object.freeze({ status: 'inserted', ...stored });
|
||||
}
|
||||
assert.deepEqual(command.credential, stored.credential);
|
||||
assert.deepEqual(command.mutation, stored.mutation);
|
||||
assert.deepEqual(command.delivery, stored.delivery);
|
||||
return Object.freeze({ status: 'existing', ...stored });
|
||||
},
|
||||
async inspectAuthorizedCredential() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async resolveDeliveryAcknowledgement() {
|
||||
return null;
|
||||
},
|
||||
async appendAuthorizedDeliveryAcknowledgement() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async record() {},
|
||||
};
|
||||
const service = createLocalIdentityCredentialAdministrationService(
|
||||
projectPolicy(),
|
||||
repository,
|
||||
{ now: () => nowMs },
|
||||
);
|
||||
const request = {
|
||||
projectId: 'default',
|
||||
operation: 'issue',
|
||||
credentialId: 'agent-planner-primary',
|
||||
target: SUBJECT,
|
||||
expectedCurrentVersion: 0,
|
||||
pepperKeyId: 'owner-v1',
|
||||
secretDigest: 'a'.repeat(64),
|
||||
deliveryDigest: 'b'.repeat(64),
|
||||
notBeforeAtMs: 1_000,
|
||||
expiresAtMs: 61_000,
|
||||
mutationId: MUTATION_ID,
|
||||
requestId: 'identity-credential-replay',
|
||||
principal: PRINCIPAL,
|
||||
};
|
||||
|
||||
assert.equal((await service.changeCredential(request)).status, 'inserted');
|
||||
nowMs = 30_000;
|
||||
assert.equal((await service.changeCredential(request)).status, 'existing');
|
||||
assert.equal(identityReads, 1);
|
||||
assert.equal(stored.mutation.createdAtMs, 1_000);
|
||||
assert.equal(stored.credential.notBeforeAtMs, 1_000);
|
||||
});
|
||||
|
||||
test('inspects current versions only after Owner authorization', async () => {
|
||||
const audits = [];
|
||||
const identity = Object.freeze({
|
||||
subject: SUBJECT,
|
||||
status: 'active',
|
||||
version: 7,
|
||||
createdAtMs: 100,
|
||||
updatedAtMs: 700,
|
||||
});
|
||||
const credential = Object.freeze({
|
||||
credentialId: 'agent-planner-primary',
|
||||
version: 5,
|
||||
pepperKeyId: 'owner-v1',
|
||||
state: 'active',
|
||||
subject: SUBJECT,
|
||||
subjectStatus: 'active',
|
||||
secretDigest: 'a'.repeat(64),
|
||||
createdAtMs: 500,
|
||||
notBeforeAtMs: 500,
|
||||
expiresAtMs: 60_500,
|
||||
});
|
||||
const repository = {
|
||||
async resolveAuthorityProjectId() {
|
||||
return 'default';
|
||||
},
|
||||
async resolveIdentity() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async resolveIdentityMutation() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async appendAuthorizedIdentity() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async inspectAuthorizedIdentity(command) {
|
||||
assert.equal(command.audit.operationId, 'identity.inspect');
|
||||
assert.equal(command.authorization.actor.id, 'owner-user');
|
||||
return { identity, audit: command.audit };
|
||||
},
|
||||
async resolveCredentialMutation() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async appendAuthorizedCredential() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async inspectAuthorizedCredential(command) {
|
||||
assert.equal(command.audit.operationId, 'credential.inspect');
|
||||
assert.equal(command.credentialId, credential.credentialId);
|
||||
return { credential, audit: command.audit };
|
||||
},
|
||||
async resolveDeliveryAcknowledgement() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async appendAuthorizedDeliveryAcknowledgement() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
async record(audit) {
|
||||
audits.push(audit);
|
||||
},
|
||||
};
|
||||
const owner = createLocalIdentityCredentialAdministrationService(
|
||||
projectPolicy(),
|
||||
repository,
|
||||
{ now: () => 1_000 },
|
||||
);
|
||||
const identityResult = await owner.inspectIdentity({
|
||||
projectId: 'default',
|
||||
target: SUBJECT,
|
||||
auditEventId: '82000000-0000-4000-8000-000000000002',
|
||||
requestId: 'identity-inspect',
|
||||
principal: PRINCIPAL,
|
||||
});
|
||||
const credentialResult = await owner.inspectCredential({
|
||||
projectId: 'default',
|
||||
credentialId: credential.credentialId,
|
||||
auditEventId: '82000000-0000-4000-8000-000000000003',
|
||||
requestId: 'credential-inspect',
|
||||
principal: PRINCIPAL,
|
||||
});
|
||||
assert.equal(identityResult.identity.version, 7);
|
||||
assert.equal(credentialResult.credential.version, 5);
|
||||
|
||||
const nonOwner = createLocalIdentityCredentialAdministrationService(
|
||||
projectPolicy('admin'),
|
||||
repository,
|
||||
{ now: () => 1_000 },
|
||||
);
|
||||
await assert.rejects(
|
||||
nonOwner.inspectIdentity({
|
||||
projectId: 'default',
|
||||
target: SUBJECT,
|
||||
auditEventId: '82000000-0000-4000-8000-000000000004',
|
||||
requestId: 'identity-inspect-denied',
|
||||
principal: PRINCIPAL,
|
||||
}),
|
||||
LocalIdentityCredentialAdministrationAuthorizationError,
|
||||
);
|
||||
assert.equal(audits.length, 1);
|
||||
assert.equal(audits[0].operationId, 'identity.inspect');
|
||||
assert.equal(audits[0].outcome, 'denied');
|
||||
|
||||
const foreignProjectOwner =
|
||||
createLocalIdentityCredentialAdministrationService(
|
||||
projectPolicy(),
|
||||
repository,
|
||||
{ now: () => 1_000 },
|
||||
);
|
||||
await assert.rejects(
|
||||
foreignProjectOwner.inspectCredential({
|
||||
projectId: 'secondary',
|
||||
credentialId: credential.credentialId,
|
||||
auditEventId: '82000000-0000-4000-8000-000000000005',
|
||||
requestId: 'credential-inspect-foreign-project',
|
||||
principal: PRINCIPAL,
|
||||
}),
|
||||
LocalIdentityCredentialAdministrationAuthorizationError,
|
||||
);
|
||||
assert.equal(audits.length, 2);
|
||||
assert.deepEqual(audits[1].reasons, ['instance_authority_project_required']);
|
||||
});
|
||||
@@ -0,0 +1,708 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
PLUGIN_PACKAGE_ACTIVATION_INTENT_SCHEMA,
|
||||
PluginPackageActivationConflictError,
|
||||
PluginPackageActivationUnavailableError,
|
||||
} = require('@qinglong/runtime-core/plugin-package-activation');
|
||||
const {
|
||||
createPluginPackageResourceGeneration,
|
||||
} = require('@qinglong/runtime-core/plugin-package-resource-generation');
|
||||
const {
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
planPluginPackageInstall,
|
||||
} = require('@qinglong/runtime-core/plugin-package');
|
||||
const {
|
||||
createPluginPackageInstall,
|
||||
createPluginPackageLock,
|
||||
pluginPackageInstallActionDigest,
|
||||
pluginPackageInstallCreate,
|
||||
pluginPackageInstallPlanDigest,
|
||||
} = require('@qinglong/runtime-core/plugin-package-install');
|
||||
const {
|
||||
createApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
const {
|
||||
createPluginPackageInstallProposal,
|
||||
} = require('@qinglong/runtime-core/plugin-package-proposal');
|
||||
const {
|
||||
PluginPackageRecoveryCoordinator,
|
||||
} = require('@qinglong/runtime-core/plugin-package-recovery');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('@qinglong/local-sqlite/plugin-package-install');
|
||||
const {
|
||||
LocalSqliteApprovalRequestRepository,
|
||||
} = require('@qinglong/local-sqlite/approved-action');
|
||||
const {
|
||||
LocalSqliteApprovedActionExecutionRepository,
|
||||
} = require('@qinglong/local-sqlite/approved-action-execution');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallProposalRepository,
|
||||
} = require('@qinglong/local-sqlite/plugin-package-proposal');
|
||||
const {
|
||||
migrateLocalSqliteDatabase,
|
||||
} = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
LocalPluginPackageActivationPublisher,
|
||||
isLocalPluginPackageActivePointerName,
|
||||
} = require('../dist/plugin-package/pluginPackageActivation');
|
||||
const {
|
||||
createLocalPluginPackageInstallationCoordinator,
|
||||
} = require('../dist/plugin-package/pluginPackageInstallation');
|
||||
|
||||
function sha(value) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function harness(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-package-active-'));
|
||||
const stagingRoot = path.join(root, 'staging');
|
||||
const activationRoot = path.join(root, 'activation');
|
||||
fs.mkdirSync(stagingRoot, { mode: 0o700 });
|
||||
fs.mkdirSync(activationRoot, { mode: 0o700 });
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return { root, stagingRoot, activationRoot };
|
||||
}
|
||||
|
||||
function createStage(stagingRoot, overrides = {}) {
|
||||
const lockDigest = overrides.lockDigest ?? 'a'.repeat(64);
|
||||
const contentDigest = overrides.contentDigest ?? 'b'.repeat(64);
|
||||
const material = Buffer.from(overrides.material ?? 'bounded package content');
|
||||
const entryPath = 'package.json';
|
||||
const blob = `0000-${sha(entryPath)}.blob`;
|
||||
const stageDirectory = path.join(stagingRoot, lockDigest);
|
||||
const blobDirectory = path.join(stageDirectory, 'blobs');
|
||||
fs.mkdirSync(stageDirectory, { mode: 0o700 });
|
||||
fs.mkdirSync(blobDirectory, { mode: 0o700 });
|
||||
fs.writeFileSync(path.join(blobDirectory, blob), material, { mode: 0o600 });
|
||||
const receipt = {
|
||||
schema: 'qinglong/plugin-package-stage-receipt@v1',
|
||||
lockDigest,
|
||||
inspection: { lockDigest, contentDigest },
|
||||
entries: [
|
||||
{
|
||||
path: entryPath,
|
||||
bytes: material.byteLength,
|
||||
digest: sha(material),
|
||||
blob,
|
||||
},
|
||||
],
|
||||
};
|
||||
const serialized = `${JSON.stringify(receipt)}\n`;
|
||||
fs.writeFileSync(path.join(stageDirectory, 'receipt.json'), serialized, {
|
||||
mode: 0o600,
|
||||
});
|
||||
return {
|
||||
lockDigest,
|
||||
contentDigest,
|
||||
stageDirectory,
|
||||
blobPath: path.join(blobDirectory, blob),
|
||||
evidenceDigest: sha(serialized),
|
||||
};
|
||||
}
|
||||
|
||||
function intent(stage, overrides = {}) {
|
||||
const installationId = overrides.installationId ?? 'install-001';
|
||||
const targetGeneration = overrides.targetGeneration ?? 1;
|
||||
const previousActiveLockDigest = overrides.previousActiveLockDigest ?? null;
|
||||
const resourceGeneration = createPluginPackageResourceGeneration({
|
||||
installationId,
|
||||
projectId: 'default',
|
||||
packageName: 'example-monitor',
|
||||
lockDigest: stage.lockDigest,
|
||||
generation: targetGeneration,
|
||||
previousActiveLockDigest,
|
||||
contentDigest: stage.contentDigest,
|
||||
contents: {
|
||||
tasks: ['tasks/example.yaml'],
|
||||
workflows: [],
|
||||
prompts: [],
|
||||
tools: [],
|
||||
},
|
||||
});
|
||||
return Object.freeze({
|
||||
schema: PLUGIN_PACKAGE_ACTIVATION_INTENT_SCHEMA,
|
||||
installationId,
|
||||
projectId: 'default',
|
||||
packageName: 'example-monitor',
|
||||
lockDigest: stage.lockDigest,
|
||||
targetGeneration,
|
||||
previousActiveLockDigest,
|
||||
stageRef: `local-stage:${stage.lockDigest}`,
|
||||
stageReceiptDigest: overrides.stageReceiptDigest ?? 'c'.repeat(64),
|
||||
stageEvidenceDigest: stage.evidenceDigest,
|
||||
contentDigest: stage.contentDigest,
|
||||
resourceGeneration: overrides.resourceGeneration ?? resourceGeneration,
|
||||
intentDigest: overrides.intentDigest ?? 'd'.repeat(64),
|
||||
});
|
||||
}
|
||||
|
||||
function activationLockPath(activationRoot, value) {
|
||||
const key = createHash('sha256')
|
||||
.update('qinglong/plugin-package-active-pointer-key@v1\0', 'utf8')
|
||||
.update(value.projectId, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(value.packageName, 'utf8')
|
||||
.digest('hex');
|
||||
return path.join(activationRoot, `.${key}.lock`);
|
||||
}
|
||||
|
||||
function installAction() {
|
||||
const manifest = {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: 'example-monitor',
|
||||
displayName: 'Example Monitor',
|
||||
version: '1.2.0',
|
||||
description: 'One bounded package',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['edge'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '16Mi' },
|
||||
disk: { install: '4Mi', working: '16Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: [],
|
||||
},
|
||||
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||
},
|
||||
};
|
||||
const environment = {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
runtimes: [],
|
||||
availableMemoryBytes: 128 * 1024 * 1024,
|
||||
availableDiskBytes: 256 * 1024 * 1024,
|
||||
};
|
||||
const plan = planPluginPackageInstall(manifest, environment);
|
||||
return {
|
||||
lockId: 'lock-install-001',
|
||||
projectId: 'default',
|
||||
manifest,
|
||||
plan,
|
||||
environment,
|
||||
source: {
|
||||
kind: 'offline',
|
||||
locator: `offline:sha256:${'a'.repeat(64)}`,
|
||||
artifactDigest: 'a'.repeat(64),
|
||||
artifactBytes: 2048,
|
||||
contentDigest: 'b'.repeat(64),
|
||||
},
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function installLock() {
|
||||
const action = installAction();
|
||||
return createPluginPackageLock({
|
||||
...action,
|
||||
approval: {
|
||||
requestId: 'approval-install-001',
|
||||
requestVersion: 3,
|
||||
dispatchId: 'dispatch-install-001',
|
||||
actionDigest: pluginPackageInstallActionDigest(action),
|
||||
previewDigest: pluginPackageInstallPlanDigest(action.plan),
|
||||
approvedBy: { type: 'user', id: 'owner-001' },
|
||||
approvedAtMs: 100,
|
||||
expiresAtMs: 10_000,
|
||||
fence: { projectVersion: 1, bindingVersion: 1 },
|
||||
},
|
||||
createdAtMs: 200,
|
||||
});
|
||||
}
|
||||
|
||||
test('publishes one durable exact pointer and replays without advancing time', async (t) => {
|
||||
const directories = harness(t);
|
||||
const stage = createStage(directories.stagingRoot);
|
||||
let nowCalls = 0;
|
||||
const publisher = new LocalPluginPackageActivationPublisher({
|
||||
stagingRoot: directories.stagingRoot,
|
||||
activationRoot: directories.activationRoot,
|
||||
now() {
|
||||
nowCalls += 1;
|
||||
return 500;
|
||||
},
|
||||
});
|
||||
const value = intent(stage);
|
||||
assert.equal(
|
||||
await publisher.findActiveResourceGeneration('default', 'example-monitor'),
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(await publisher.inspect(value), {
|
||||
status: 'not_published',
|
||||
});
|
||||
const published = await publisher.publish(value);
|
||||
assert.equal(published.intentDigest, value.intentDigest);
|
||||
assert.equal(published.generation, 1);
|
||||
assert.equal(published.activatedAtMs, 500);
|
||||
assert.deepEqual(await publisher.inspect(value), {
|
||||
status: 'published',
|
||||
receipt: published,
|
||||
});
|
||||
assert.deepEqual(await publisher.publish(value), published);
|
||||
assert.deepEqual(
|
||||
await publisher.findActiveResourceGeneration('default', 'example-monitor'),
|
||||
value.resourceGeneration,
|
||||
);
|
||||
await assert.rejects(
|
||||
publisher.findActiveResourceGeneration('default', 'Example_Monitor'),
|
||||
TypeError,
|
||||
);
|
||||
assert.equal(nowCalls, 1);
|
||||
const files = fs.readdirSync(directories.activationRoot);
|
||||
assert.equal(files.length, 1);
|
||||
assert.equal(isLocalPluginPackageActivePointerName(files[0]), true);
|
||||
assert.equal(
|
||||
fs.statSync(path.join(directories.activationRoot, files[0])).mode & 0o777,
|
||||
0o600,
|
||||
);
|
||||
});
|
||||
|
||||
test('replaces only the exact previous active lock generation', async (t) => {
|
||||
const directories = harness(t);
|
||||
const firstStage = createStage(directories.stagingRoot);
|
||||
const publisher = new LocalPluginPackageActivationPublisher({
|
||||
stagingRoot: directories.stagingRoot,
|
||||
activationRoot: directories.activationRoot,
|
||||
now: () => 500,
|
||||
});
|
||||
const first = intent(firstStage);
|
||||
await publisher.publish(first);
|
||||
|
||||
const secondStage = createStage(directories.stagingRoot, {
|
||||
lockDigest: 'e'.repeat(64),
|
||||
contentDigest: 'f'.repeat(64),
|
||||
material: 'replacement package content',
|
||||
});
|
||||
const second = intent(secondStage, {
|
||||
installationId: 'install-002',
|
||||
targetGeneration: 2,
|
||||
previousActiveLockDigest: first.lockDigest,
|
||||
intentDigest: '1'.repeat(64),
|
||||
});
|
||||
const secondReceipt = await publisher.publish(second);
|
||||
assert.equal(secondReceipt.generation, 2);
|
||||
assert.equal((await publisher.inspect(second)).status, 'published');
|
||||
assert.deepEqual(
|
||||
await publisher.findActiveResourceGeneration('default', 'example-monitor'),
|
||||
second.resourceGeneration,
|
||||
);
|
||||
|
||||
const stale = intent(
|
||||
createStage(directories.stagingRoot, {
|
||||
lockDigest: '2'.repeat(64),
|
||||
contentDigest: '3'.repeat(64),
|
||||
material: 'stale package content',
|
||||
}),
|
||||
{
|
||||
installationId: 'install-003',
|
||||
targetGeneration: 3,
|
||||
previousActiveLockDigest: first.lockDigest,
|
||||
intentDigest: '4'.repeat(64),
|
||||
},
|
||||
);
|
||||
await assert.rejects(
|
||||
publisher.publish(stale),
|
||||
PluginPackageActivationConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('never removes a publication lock owned by another publisher', async (t) => {
|
||||
const directories = harness(t);
|
||||
const stage = createStage(directories.stagingRoot);
|
||||
let nowCalls = 0;
|
||||
const publisher = new LocalPluginPackageActivationPublisher({
|
||||
stagingRoot: directories.stagingRoot,
|
||||
activationRoot: directories.activationRoot,
|
||||
now() {
|
||||
nowCalls += 1;
|
||||
return 500;
|
||||
},
|
||||
});
|
||||
const value = intent(stage);
|
||||
const lockPath = activationLockPath(directories.activationRoot, value);
|
||||
fs.writeFileSync(lockPath, 'another-publisher\n', { mode: 0o600 });
|
||||
|
||||
await assert.rejects(
|
||||
publisher.publish(value),
|
||||
PluginPackageActivationUnavailableError,
|
||||
);
|
||||
assert.equal(fs.readFileSync(lockPath, 'utf8'), 'another-publisher\n');
|
||||
assert.equal(nowCalls, 0);
|
||||
});
|
||||
|
||||
test('fails closed when staged evidence or an active pointer is tampered', async (t) => {
|
||||
const directories = harness(t);
|
||||
const stage = createStage(directories.stagingRoot);
|
||||
const publisher = new LocalPluginPackageActivationPublisher({
|
||||
stagingRoot: directories.stagingRoot,
|
||||
activationRoot: directories.activationRoot,
|
||||
now: () => 500,
|
||||
});
|
||||
const value = intent(stage);
|
||||
fs.writeFileSync(stage.blobPath, 'tampered', { mode: 0o600 });
|
||||
await assert.rejects(
|
||||
publisher.publish(value),
|
||||
PluginPackageActivationConflictError,
|
||||
);
|
||||
|
||||
fs.rmSync(stage.stageDirectory, { recursive: true });
|
||||
const restored = createStage(directories.stagingRoot);
|
||||
const restoredIntent = intent(restored);
|
||||
await publisher.publish(restoredIntent);
|
||||
const pointer = fs
|
||||
.readdirSync(directories.activationRoot)
|
||||
.find(isLocalPluginPackageActivePointerName);
|
||||
const pointerPath = path.join(directories.activationRoot, pointer);
|
||||
const oldPointer = JSON.parse(fs.readFileSync(pointerPath, 'utf8'));
|
||||
oldPointer.schema = 'qinglong/plugin-package-active-pointer@v1';
|
||||
fs.writeFileSync(pointerPath, `${JSON.stringify(oldPointer)}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
await assert.rejects(
|
||||
publisher.inspect(restoredIntent),
|
||||
PluginPackageActivationConflictError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects symlinked roots and keeps authority behind an explicit subpath', (t) => {
|
||||
const directories = harness(t);
|
||||
const linked = path.join(directories.root, 'linked');
|
||||
fs.symlinkSync(directories.stagingRoot, linked);
|
||||
assert.throws(
|
||||
() =>
|
||||
new LocalPluginPackageActivationPublisher({
|
||||
stagingRoot: linked,
|
||||
activationRoot: directories.activationRoot,
|
||||
now: () => 500,
|
||||
}),
|
||||
TypeError,
|
||||
);
|
||||
assert.equal(require('..').LocalPluginPackageActivationPublisher, undefined);
|
||||
assert.equal(
|
||||
require('@qinglong/local-admin/package-activation')
|
||||
.LocalPluginPackageActivationPublisher,
|
||||
LocalPluginPackageActivationPublisher,
|
||||
);
|
||||
});
|
||||
|
||||
test('composes approval, SQLite lock persistence, stage and POSIX activation end to end', async (t) => {
|
||||
const admittedAtMs = Date.now();
|
||||
const actionInput = installAction();
|
||||
const directories = harness(t);
|
||||
const lock = createPluginPackageLock({
|
||||
...actionInput,
|
||||
approval: {
|
||||
requestId: 'approval-install-001',
|
||||
requestVersion: 3,
|
||||
dispatchId: 'dispatch-install-001',
|
||||
actionDigest: pluginPackageInstallActionDigest(actionInput),
|
||||
previewDigest: pluginPackageInstallPlanDigest(actionInput.plan),
|
||||
approvedBy: { type: 'user', id: 'owner-001' },
|
||||
approvedAtMs: admittedAtMs - 200,
|
||||
expiresAtMs: admittedAtMs + 60_000,
|
||||
fence: { projectVersion: 1, bindingVersion: 1 },
|
||||
},
|
||||
createdAtMs: admittedAtMs,
|
||||
});
|
||||
const stage = createStage(directories.stagingRoot, {
|
||||
lockDigest: lock.lockDigest,
|
||||
contentDigest: lock.source.contentDigest,
|
||||
});
|
||||
const client = new DatabaseSync(':memory:');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
t.after(() => client.close());
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings"
|
||||
("project_id","subject_type","subject_id","version","state","role",
|
||||
"mutation_id","changed_by_type","changed_by_id","created_at_ms")
|
||||
VALUES ('default','user','owner-001',1,'active','owner',
|
||||
'grant-owner-1','user','owner-001',0)`,
|
||||
)
|
||||
.run();
|
||||
const requester = { type: 'user', id: 'owner-001' };
|
||||
const consumer = { type: 'system', id: 'package-dispatcher' };
|
||||
const fence = { projectVersion: 1, bindingVersion: 1 };
|
||||
const action = {
|
||||
permission: 'package.manage',
|
||||
actionType: 'plugin_package.install',
|
||||
actionRef: 'proposal:example-monitor-v1',
|
||||
actionDigest: lock.actionDigest,
|
||||
previewDigest: lock.planDigest,
|
||||
};
|
||||
const proposal = createPluginPackageInstallProposal({
|
||||
actionRef: action.actionRef,
|
||||
actionInput,
|
||||
proposedBy: requester,
|
||||
proposalFence: fence,
|
||||
createdAtMs: admittedAtMs - 400,
|
||||
});
|
||||
const audit = (
|
||||
eventId,
|
||||
requestId,
|
||||
operationId,
|
||||
subject,
|
||||
authenticationId,
|
||||
outcome,
|
||||
reasons,
|
||||
occurredAtMs,
|
||||
) => ({
|
||||
eventId,
|
||||
requestId,
|
||||
operationId,
|
||||
projectId: 'default',
|
||||
subject,
|
||||
authenticationId,
|
||||
outcome,
|
||||
reasons,
|
||||
fence,
|
||||
occurredAtMs,
|
||||
});
|
||||
const approvals = new LocalSqliteApprovalRequestRepository(client);
|
||||
await new LocalSqlitePluginPackageInstallProposalRepository(
|
||||
client,
|
||||
).createProposal({
|
||||
proposal,
|
||||
audit: audit(
|
||||
'10000000-0000-4000-8000-000000000200',
|
||||
action.actionRef,
|
||||
'plugin_package.propose',
|
||||
requester,
|
||||
'auth-owner',
|
||||
'allowed',
|
||||
['package_proposal'],
|
||||
proposal.createdAtMs,
|
||||
),
|
||||
});
|
||||
await approvals.create({
|
||||
request: createApprovalRequest({
|
||||
id: lock.approval.requestId,
|
||||
projectId: 'default',
|
||||
action,
|
||||
risk: 'high',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: requester,
|
||||
requestedAtMs: admittedAtMs - 300,
|
||||
expiresAtMs: lock.approval.expiresAtMs,
|
||||
requestFence: fence,
|
||||
}),
|
||||
audit: audit(
|
||||
'10000000-0000-4000-8000-000000000201',
|
||||
'http-package-1',
|
||||
'approval.request',
|
||||
requester,
|
||||
'auth-owner',
|
||||
'approval_required',
|
||||
['package_review'],
|
||||
admittedAtMs - 300,
|
||||
),
|
||||
});
|
||||
await approvals.decide({
|
||||
requestId: lock.approval.requestId,
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-install-001',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
principal: {
|
||||
subject: requester,
|
||||
authenticationId: 'auth-owner-step-up',
|
||||
authenticatedAtMs: admittedAtMs - 250,
|
||||
expiresAtMs: lock.approval.expiresAtMs,
|
||||
assurance: 'local_console',
|
||||
},
|
||||
decidedAtMs: lock.approval.approvedAtMs,
|
||||
authorizationFence: fence,
|
||||
audit: audit(
|
||||
'10000000-0000-4000-8000-000000000202',
|
||||
'http-package-1',
|
||||
'approval.decide',
|
||||
requester,
|
||||
'auth-owner-step-up',
|
||||
'allowed',
|
||||
['role_grant'],
|
||||
lock.approval.approvedAtMs,
|
||||
),
|
||||
});
|
||||
const consumed = await approvals.consume({
|
||||
requestId: lock.approval.requestId,
|
||||
expectedVersion: 2,
|
||||
consumptionId: 'consume-install-001',
|
||||
dispatchId: lock.approval.dispatchId,
|
||||
action,
|
||||
requestedBy: requester,
|
||||
consumedBy: consumer,
|
||||
consumedAtMs: admittedAtMs - 100,
|
||||
authorizationFence: fence,
|
||||
audit: audit(
|
||||
'10000000-0000-4000-8000-000000000203',
|
||||
'dispatch-cycle-1',
|
||||
'approval.consume',
|
||||
consumer,
|
||||
'auth-package-dispatcher',
|
||||
'allowed',
|
||||
['role_grant'],
|
||||
admittedAtMs - 100,
|
||||
),
|
||||
});
|
||||
const executions = new LocalSqliteApprovedActionExecutionRepository(client);
|
||||
const claimed = await executions.claimExecution({
|
||||
dispatchId: consumed.dispatch.id,
|
||||
owner: 'package_dispatcher',
|
||||
leaseToken: 'lease-install-001',
|
||||
nowMs: admittedAtMs - 50,
|
||||
leaseDurationMs: 60_000,
|
||||
});
|
||||
assert.equal(claimed.status, 'claimed');
|
||||
const started = await executions.startExecution({
|
||||
dispatchId: consumed.dispatch.id,
|
||||
approvalRequestId: consumed.dispatch.approvalRequestId,
|
||||
actionDigest: consumed.dispatch.action.actionDigest,
|
||||
owner: 'package_dispatcher',
|
||||
leaseToken: 'lease-install-001',
|
||||
expectedVersion: claimed.snapshot.execution.version,
|
||||
startedAtMs: admittedAtMs,
|
||||
});
|
||||
const repository = new LocalSqlitePluginPackageInstallRepository(client);
|
||||
const staged = [];
|
||||
const coordinator = createLocalPluginPackageInstallationCoordinator({
|
||||
repository,
|
||||
publisher: new LocalPluginPackageActivationPublisher({
|
||||
stagingRoot: directories.stagingRoot,
|
||||
activationRoot: directories.activationRoot,
|
||||
now: () => admittedAtMs + 300,
|
||||
}),
|
||||
});
|
||||
const options = {
|
||||
lock,
|
||||
proposalDigest: proposal.proposalDigest,
|
||||
execution: started.execution,
|
||||
installationId: 'install-001',
|
||||
createMutationId: 'mutation-create',
|
||||
createdAtMs: admittedAtMs,
|
||||
stageMutationId: 'mutation-stage',
|
||||
stagedAtMs: admittedAtMs + 100,
|
||||
activationStartedMutationId: 'mutation-activate',
|
||||
activationCommittedMutationId: 'mutation-commit',
|
||||
activationFailedMutationId: 'mutation-fail',
|
||||
activationStartedAtMs: admittedAtMs + 200,
|
||||
activationObservedAtMs: admittedAtMs + 301,
|
||||
admissionAudit: audit(
|
||||
'10000000-0000-4000-8000-000000000204',
|
||||
lock.approval.dispatchId,
|
||||
'plugin_package.admit',
|
||||
consumer,
|
||||
'auth-package-dispatcher',
|
||||
'allowed',
|
||||
['approved_action'],
|
||||
admittedAtMs,
|
||||
),
|
||||
};
|
||||
const stageProvider = {
|
||||
async stage(value) {
|
||||
staged.push(value.lockDigest);
|
||||
return {
|
||||
stageRef: `local-stage:${value.lockDigest}`,
|
||||
artifactDigest: value.source.artifactDigest,
|
||||
manifestDigest: value.manifestDigest,
|
||||
contentDigest: value.source.contentDigest,
|
||||
evidenceDigest: stage.evidenceDigest,
|
||||
};
|
||||
},
|
||||
};
|
||||
const active = await coordinator.install(options, stageProvider);
|
||||
assert.equal(active.state, 'active');
|
||||
assert.equal(active.activeLockDigest, lock.lockDigest);
|
||||
assert.deepEqual(await repository.findLock(lock.lockDigest), lock);
|
||||
assert.equal(
|
||||
(await repository.findAdmissionReceipt(lock.approval.dispatchId))
|
||||
.lockDigest,
|
||||
lock.lockDigest,
|
||||
);
|
||||
assert.deepEqual(staged, [lock.lockDigest]);
|
||||
|
||||
const replay = await coordinator.install(options, {
|
||||
async stage() {
|
||||
throw new Error('an active exact replay must not stage again');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(replay, active);
|
||||
assert.deepEqual(staged, [lock.lockDigest]);
|
||||
});
|
||||
|
||||
test('recovers a durable queued SQLite install through the POSIX publisher without approval replay', async (t) => {
|
||||
const directories = harness(t);
|
||||
const lock = installLock();
|
||||
const stage = createStage(directories.stagingRoot, {
|
||||
lockDigest: lock.lockDigest,
|
||||
contentDigest: lock.source.contentDigest,
|
||||
});
|
||||
const client = new DatabaseSync(':memory:');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
t.after(() => client.close());
|
||||
const repository = new LocalSqlitePluginPackageInstallRepository(client);
|
||||
const queued = createPluginPackageInstall(lock, {
|
||||
installationId: 'recovery-install-001',
|
||||
mutationId: 'recovery-create',
|
||||
occurredAtMs: 201,
|
||||
});
|
||||
await repository.create(pluginPackageInstallCreate(lock, queued, null));
|
||||
let stageCalls = 0;
|
||||
const coordinator = new PluginPackageRecoveryCoordinator({
|
||||
repository,
|
||||
stageProvider: {
|
||||
async stage(value) {
|
||||
stageCalls += 1;
|
||||
return {
|
||||
stageRef: `local-stage:${value.lockDigest}`,
|
||||
artifactDigest: value.source.artifactDigest,
|
||||
manifestDigest: value.manifestDigest,
|
||||
contentDigest: value.source.contentDigest,
|
||||
evidenceDigest: stage.evidenceDigest,
|
||||
};
|
||||
},
|
||||
},
|
||||
publisher: new LocalPluginPackageActivationPublisher({
|
||||
stagingRoot: directories.stagingRoot,
|
||||
activationRoot: directories.activationRoot,
|
||||
now: () => 500,
|
||||
}),
|
||||
now: () => 250,
|
||||
});
|
||||
|
||||
const cycle = await coordinator.recover({ pageSize: 1, maxPages: 2 });
|
||||
|
||||
assert.equal(cycle.settled, 1);
|
||||
assert.equal(cycle.safeToAdmit, true);
|
||||
assert.equal(stageCalls, 1);
|
||||
const active = await repository.find(lock.projectId, lock.packageName);
|
||||
assert.equal(active.state, 'active');
|
||||
assert.equal(active.activeLockDigest, lock.lockDigest);
|
||||
assert.deepEqual(await repository.listRecoveryPage({ limit: 1 }), {
|
||||
records: [],
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createLocalPluginPackageApprovedActionDispatcher,
|
||||
LOCAL_PLUGIN_PACKAGE_DISPATCH_BATCH_LIMITS,
|
||||
} = require('@qinglong/local-admin/package-approved-action');
|
||||
const {
|
||||
LocalSqliteOperationAuthority,
|
||||
} = require('@qinglong/local-sqlite/operation-authority');
|
||||
|
||||
test('composes caller-driven edge and standalone dispatchers behind one SQLite authority', async (t) => {
|
||||
const client = new DatabaseSync(':memory:');
|
||||
t.after(() => client.close());
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
let id = 0;
|
||||
const limits = [];
|
||||
for (const profile of ['edge', 'standalone']) {
|
||||
const dispatcher = createLocalPluginPackageApprovedActionDispatcher({
|
||||
authority,
|
||||
profile,
|
||||
owner: `package_dispatcher_${profile}`,
|
||||
clock: () => 100,
|
||||
createId: () => `dispatcher-id-${++id}`,
|
||||
});
|
||||
dispatcher.repository.listDueExecutions = async (query) => {
|
||||
limits.push(query.limit);
|
||||
return { executions: [], truncated: false };
|
||||
};
|
||||
assert.deepEqual(await dispatcher.dispatchBatch(), {
|
||||
scanned: 0,
|
||||
claimed: 0,
|
||||
started: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
blocked: 0,
|
||||
retrying: 0,
|
||||
deferred: 0,
|
||||
recoveryRequired: 0,
|
||||
alreadyTerminal: 0,
|
||||
unavailable: 0,
|
||||
truncated: false,
|
||||
});
|
||||
}
|
||||
assert.deepEqual(limits, [
|
||||
LOCAL_PLUGIN_PACKAGE_DISPATCH_BATCH_LIMITS.edge,
|
||||
LOCAL_PLUGIN_PACKAGE_DISPATCH_BATCH_LIMITS.standalone,
|
||||
]);
|
||||
assert.equal(
|
||||
require('..').createLocalPluginPackageApprovedActionDispatcher,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createProjectToolDefinitionSnapshot,
|
||||
projectToolDefinitionSnapshotContribution,
|
||||
} = require('@qinglong/runtime-core/project-tool-definition-snapshot');
|
||||
const {
|
||||
activateInstall,
|
||||
pluginPackageTaskReconciliationFixture,
|
||||
} = require('../../../test/contracts/pluginPackageTaskReconciliationRepositoryContract.cjs');
|
||||
const {
|
||||
LocalSqliteOperationAuthority,
|
||||
} = require('@qinglong/local-sqlite/operation-authority');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('@qinglong/local-sqlite/plugin-package-install');
|
||||
const {
|
||||
LocalSqlitePluginPackageMaterializedRevisionRepository,
|
||||
} = require('@qinglong/local-sqlite/plugin-package-materialized-revision');
|
||||
const {
|
||||
LocalSqlitePluginPackageTaskReconciliationRepository,
|
||||
} = require('@qinglong/local-sqlite/plugin-package-task-reconciliation');
|
||||
const {
|
||||
LocalSqliteProjectToolDefinitionSnapshotRepository,
|
||||
} = require('@qinglong/local-sqlite/project-tool-definition-snapshot');
|
||||
const {
|
||||
migrateLocalSqliteDatabase,
|
||||
} = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
createLocalPluginPackageLifecycleService,
|
||||
} = require('@qinglong/local-admin/package-lifecycle');
|
||||
|
||||
const OWNER = Object.freeze({ type: 'user', id: 'owner-lifecycle' });
|
||||
|
||||
function principal(at) {
|
||||
return {
|
||||
subject: OWNER,
|
||||
authenticationId: 'owner-lifecycle-console',
|
||||
authenticatedAtMs: at - 1,
|
||||
expiresAtMs: at + 60_000,
|
||||
assurance: 'local_console',
|
||||
};
|
||||
}
|
||||
|
||||
async function harness(t) {
|
||||
const fixture = pluginPackageTaskReconciliationFixture('local-admin-life', {
|
||||
profile: 'edge',
|
||||
});
|
||||
const client = new DatabaseSync(':memory:');
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3Projects"
|
||||
(id, name, slug, status, version, created_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, 'active', 1, 1, 1)`,
|
||||
)
|
||||
.run(fixture.projectId, fixture.projectId, fixture.projectId);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings" (
|
||||
project_id, subject_type, subject_id, version, state, role,
|
||||
mutation_id, changed_by_type, changed_by_id, created_at_ms
|
||||
) VALUES (?, 'user', ?, 1, 'active', 'owner', ?, 'user', ?, 1)`,
|
||||
)
|
||||
.run(fixture.projectId, OWNER.id, 'grant-local-admin-life', OWNER.id);
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
t.after(() => authority.close());
|
||||
const installs = new LocalSqlitePluginPackageInstallRepository(authority);
|
||||
const materialized =
|
||||
new LocalSqlitePluginPackageMaterializedRevisionRepository(
|
||||
authority,
|
||||
fixture.registry,
|
||||
);
|
||||
const reconciliations =
|
||||
new LocalSqlitePluginPackageTaskReconciliationRepository(
|
||||
authority,
|
||||
fixture.registry,
|
||||
);
|
||||
const snapshots =
|
||||
new LocalSqliteProjectToolDefinitionSnapshotRepository(authority);
|
||||
await activateInstall(installs, fixture);
|
||||
await materialized.publish(fixture.revision);
|
||||
await reconciliations.reconcile(fixture.revision, {
|
||||
async findActiveResourceGeneration() {
|
||||
return fixture.revision.generation;
|
||||
},
|
||||
});
|
||||
await snapshots.publish(
|
||||
createProjectToolDefinitionSnapshot({
|
||||
projectId: fixture.projectId,
|
||||
contributions: [
|
||||
projectToolDefinitionSnapshotContribution(
|
||||
fixture.revision,
|
||||
fixture.registry,
|
||||
),
|
||||
],
|
||||
}),
|
||||
);
|
||||
let time = 50_000;
|
||||
const service = createLocalPluginPackageLifecycleService({
|
||||
authority,
|
||||
now: () => time++,
|
||||
});
|
||||
return { fixture, client, service, time: () => time };
|
||||
}
|
||||
|
||||
function execution(impact, ordinal, principalValue, confirmAuthorization) {
|
||||
return {
|
||||
impact,
|
||||
approvalRequestId: `local-life-approval-${ordinal}`,
|
||||
decisionId: `local-life-decision-${ordinal}`,
|
||||
consumptionId: `local-life-consumption-${ordinal}`,
|
||||
dispatchId: `local-life-dispatch-${ordinal}`,
|
||||
approvalAuditEventId: `91000000-0000-4000-8000-${String(
|
||||
ordinal * 10 + 1,
|
||||
).padStart(12, '0')}`,
|
||||
decisionAuditEventId: `91000000-0000-4000-8000-${String(
|
||||
ordinal * 10 + 2,
|
||||
).padStart(12, '0')}`,
|
||||
consumptionAuditEventId: `91000000-0000-4000-8000-${String(
|
||||
ordinal * 10 + 3,
|
||||
).padStart(12, '0')}`,
|
||||
reasonCode: 'reviewed',
|
||||
principal: principalValue,
|
||||
confirmAuthorization,
|
||||
};
|
||||
}
|
||||
|
||||
test('plans and executes replay-safe local lifecycle without another package or authority', async (t) => {
|
||||
const value = await harness(t);
|
||||
const owner = principal(value.time());
|
||||
const disable = await value.service.plan(
|
||||
'disable',
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
owner,
|
||||
);
|
||||
assert.equal(disable.expected.disposition, 'active');
|
||||
assert.ok(disable.resourceCounts.tasks > 0);
|
||||
|
||||
let confirmations = 0;
|
||||
const disableCommand = execution(disable, 1, owner, () => {
|
||||
confirmations += 1;
|
||||
});
|
||||
const disabled = await value.service.execute(disableCommand);
|
||||
assert.equal(disabled.status, 'created');
|
||||
assert.equal(disabled.approval.state, 'consumed');
|
||||
assert.equal(disabled.receipt.lifecycle.disposition, 'disabled');
|
||||
assert.equal(disabled.receipt.capability.status, 'withdrawn');
|
||||
assert.equal(
|
||||
disabled.receipt.capability.taskTransitions.length,
|
||||
disable.taskIds.length,
|
||||
);
|
||||
assert.ok(confirmations >= 3);
|
||||
|
||||
const replayed = await value.service.execute(disableCommand);
|
||||
assert.equal(replayed.status, 'existing');
|
||||
assert.equal(replayed.receipt.receiptDigest, disabled.receipt.receiptDigest);
|
||||
assert.equal(
|
||||
value.client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3PluginPackageLifecycleEvents"`,
|
||||
)
|
||||
.get().count,
|
||||
1,
|
||||
);
|
||||
|
||||
const enable = await value.service.plan(
|
||||
'enable',
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
owner,
|
||||
);
|
||||
const enabled = await value.service.execute(
|
||||
execution(enable, 2, owner, () => undefined),
|
||||
);
|
||||
assert.equal(enabled.receipt.lifecycle.disposition, 'active');
|
||||
assert.equal(enabled.receipt.capability.status, 'restored');
|
||||
assert.equal(
|
||||
enabled.receipt.capability.taskTransitions.length,
|
||||
disable.taskIds.length,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects lifecycle plan before storage mutation for an unbound local User', async (t) => {
|
||||
const value = await harness(t);
|
||||
const outsider = {
|
||||
...principal(value.time()),
|
||||
subject: { type: 'user', id: 'outsider' },
|
||||
};
|
||||
await assert.rejects(
|
||||
value.service.plan(
|
||||
'disable',
|
||||
value.fixture.projectId,
|
||||
value.fixture.packageName,
|
||||
outsider,
|
||||
),
|
||||
/not authorized by current Project policy/,
|
||||
);
|
||||
assert.equal(
|
||||
value.client
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM "QingLong3PluginPackageLifecycleEvents"`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
createLocalPluginPackageManagementService,
|
||||
} = require('@qinglong/local-admin/package-management');
|
||||
const {
|
||||
LocalSqliteOperationAuthority,
|
||||
} = require('@qinglong/local-sqlite/operation-authority');
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('@qinglong/local-sqlite/plugin-package-install');
|
||||
const {
|
||||
migrateLocalSqliteDatabase,
|
||||
} = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
planPluginPackageInstall,
|
||||
} = require('@qinglong/runtime-core/plugin-package');
|
||||
|
||||
const OWNER = Object.freeze({ type: 'user', id: 'usr_owner' });
|
||||
const CONSUMER = Object.freeze({
|
||||
subject: { type: 'system', id: 'local_package_dispatcher' },
|
||||
authenticationId: 'local-package-dispatcher-auth',
|
||||
});
|
||||
|
||||
function actionInput() {
|
||||
const manifest = {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: 'example-monitor',
|
||||
displayName: 'Example Monitor',
|
||||
version: '1.2.0',
|
||||
description: 'One bounded package',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['edge'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '16Mi' },
|
||||
disk: { install: '4Mi', working: '16Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: [],
|
||||
},
|
||||
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||
},
|
||||
};
|
||||
const environment = {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
runtimes: [],
|
||||
availableMemoryBytes: 128 * 1024 * 1024,
|
||||
availableDiskBytes: 256 * 1024 * 1024,
|
||||
};
|
||||
return {
|
||||
lockId: 'proposal-monitor-v1',
|
||||
projectId: 'default',
|
||||
manifest,
|
||||
plan: planPluginPackageInstall(manifest, environment),
|
||||
environment,
|
||||
source: {
|
||||
kind: 'offline',
|
||||
locator: `offline:sha256:${'a'.repeat(64)}`,
|
||||
artifactDigest: 'a'.repeat(64),
|
||||
artifactBytes: 2_048,
|
||||
contentDigest: 'b'.repeat(64),
|
||||
},
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function principal(now, assurance = 'single_factor') {
|
||||
return {
|
||||
subject: OWNER,
|
||||
authenticationId: `auth-owner-${assurance}`,
|
||||
authenticatedAtMs: now - 100,
|
||||
expiresAtMs: now + 100_000,
|
||||
assurance,
|
||||
};
|
||||
}
|
||||
|
||||
test('runs authenticated local proposal, self-confirmation, consumption and admission end to end', async (t) => {
|
||||
const client = new DatabaseSync(':memory:');
|
||||
t.after(() => client.close());
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings"
|
||||
("project_id","subject_type","subject_id","version","state","role",
|
||||
"mutation_id","changed_by_type","changed_by_id","created_at_ms")
|
||||
VALUES ('default','user','usr_owner',1,'active','owner',
|
||||
'grant-owner-1','user','usr_owner',0)`,
|
||||
)
|
||||
.run();
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
let now = Date.now();
|
||||
const requestedAtMs = now;
|
||||
let generatedId = 0;
|
||||
const service = createLocalPluginPackageManagementService({
|
||||
authority,
|
||||
profile: 'edge',
|
||||
consumer: CONSUMER,
|
||||
dispatcher: {
|
||||
owner: 'local_package_dispatcher_1',
|
||||
clock: () => now,
|
||||
createId: () => `dispatcher-id-${++generatedId}`,
|
||||
},
|
||||
now: () => now,
|
||||
});
|
||||
const proposalRequest = {
|
||||
actionRef: 'proposal:monitor-v1',
|
||||
approvalRequestId: 'approval-monitor-v1',
|
||||
proposalAuditEventId: '10000000-0000-4000-8000-000000000001',
|
||||
approvalAuditEventId: '10000000-0000-4000-8000-000000000002',
|
||||
requestedAtMs,
|
||||
actionInput: actionInput(),
|
||||
principal: principal(now),
|
||||
};
|
||||
const proposed = await service.propose(proposalRequest);
|
||||
assert.equal(proposed.proposalStatus, 'created');
|
||||
assert.equal(proposed.approvalStatus, 'created');
|
||||
assert.equal(proposed.approvalRequest.decisionMode, 'human_confirmation');
|
||||
const replayed = await service.propose(proposalRequest);
|
||||
assert.equal(replayed.proposalStatus, 'existing');
|
||||
assert.equal(replayed.approvalStatus, 'existing');
|
||||
|
||||
now = Date.now();
|
||||
const decided = await service.decide({
|
||||
approvalRequestId: 'approval-monitor-v1',
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-monitor-v1',
|
||||
auditEventId: '10000000-0000-4000-8000-000000000003',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
decidedAtMs: now,
|
||||
principal: principal(now, 'local_console'),
|
||||
});
|
||||
assert.equal(decided.request.state, 'approved');
|
||||
|
||||
now = Date.now();
|
||||
const consumed = await service.consume({
|
||||
approvalRequestId: 'approval-monitor-v1',
|
||||
expectedVersion: 2,
|
||||
consumptionId: 'consume-monitor-v1',
|
||||
dispatchId: 'dispatch-monitor-v1',
|
||||
auditEventId: '10000000-0000-4000-8000-000000000004',
|
||||
consumedAtMs: now,
|
||||
});
|
||||
assert.equal(consumed.request.state, 'consumed');
|
||||
|
||||
now = Date.now();
|
||||
const dispatched = await service.dispatch();
|
||||
assert.equal(dispatched.scanned, 1);
|
||||
assert.equal(dispatched.started, 1);
|
||||
assert.equal(dispatched.succeeded, 1);
|
||||
const installation = await new LocalSqlitePluginPackageInstallRepository(
|
||||
authority,
|
||||
).find('default', 'example-monitor');
|
||||
assert.equal(installation.state, 'queued');
|
||||
assert.equal(installation.targetGeneration, 1);
|
||||
|
||||
const inspected = await service.inspect(
|
||||
'proposal:monitor-v1',
|
||||
'approval-monitor-v1',
|
||||
);
|
||||
assert.equal(inspected.proposal.actionRef, 'proposal:monitor-v1');
|
||||
assert.equal(inspected.approvalRequest.state, 'consumed');
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT count(*) AS count
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "operation_id" IN (
|
||||
'plugin_package.propose',
|
||||
'approval.request',
|
||||
'approval.decide',
|
||||
'approval.consume',
|
||||
'plugin_package.admit'
|
||||
)`,
|
||||
)
|
||||
.get().count,
|
||||
5,
|
||||
);
|
||||
assert.equal(
|
||||
require('..').createLocalPluginPackageManagementService,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects weak decisions and expired management commands before mutation', async (t) => {
|
||||
const client = new DatabaseSync(':memory:');
|
||||
t.after(() => client.close());
|
||||
client.exec('PRAGMA foreign_keys = ON');
|
||||
await migrateLocalSqliteDatabase(client);
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3ProjectRoleBindings"
|
||||
("project_id","subject_type","subject_id","version","state","role",
|
||||
"mutation_id","changed_by_type","changed_by_id","created_at_ms")
|
||||
VALUES ('default','user','usr_owner',1,'active','owner',
|
||||
'grant-owner-1','user','usr_owner',0)`,
|
||||
)
|
||||
.run();
|
||||
const authority = new LocalSqliteOperationAuthority(client);
|
||||
let now = Date.now();
|
||||
const requestedAtMs = now;
|
||||
const service = createLocalPluginPackageManagementService({
|
||||
authority,
|
||||
profile: 'edge',
|
||||
consumer: CONSUMER,
|
||||
dispatcher: {
|
||||
owner: 'local_package_dispatcher_2',
|
||||
clock: () => now,
|
||||
createId: () => 'dispatcher-id-fixed',
|
||||
},
|
||||
approvalLifetimeMs: 1_000,
|
||||
now: () => now,
|
||||
});
|
||||
await service.propose({
|
||||
actionRef: 'proposal:monitor-weak-v1',
|
||||
approvalRequestId: 'approval-monitor-weak-v1',
|
||||
proposalAuditEventId: '20000000-0000-4000-8000-000000000001',
|
||||
approvalAuditEventId: '20000000-0000-4000-8000-000000000002',
|
||||
requestedAtMs,
|
||||
actionInput: actionInput(),
|
||||
principal: principal(now),
|
||||
});
|
||||
now = requestedAtMs + 100;
|
||||
await assert.rejects(
|
||||
service.decide({
|
||||
approvalRequestId: 'approval-monitor-weak-v1',
|
||||
expectedVersion: 1,
|
||||
decisionId: 'decision-monitor-weak-v1',
|
||||
auditEventId: '20000000-0000-4000-8000-000000000003',
|
||||
decision: 'approved',
|
||||
reasonCode: 'reviewed',
|
||||
decidedAtMs: now,
|
||||
principal: principal(now),
|
||||
}),
|
||||
{ code: 'APPROVAL_HUMAN_DECISION_REQUIRED' },
|
||||
);
|
||||
now = requestedAtMs + 1_000;
|
||||
await assert.rejects(
|
||||
service.propose({
|
||||
actionRef: 'proposal:expired-v1',
|
||||
approvalRequestId: 'approval-expired-v1',
|
||||
proposalAuditEventId: '20000000-0000-4000-8000-000000000004',
|
||||
approvalAuditEventId: '20000000-0000-4000-8000-000000000005',
|
||||
requestedAtMs,
|
||||
actionInput: actionInput(),
|
||||
principal: principal(now),
|
||||
}),
|
||||
{ code: 'PLUGIN_PACKAGE_MANAGEMENT_REQUEST_INVALID' },
|
||||
);
|
||||
assert.equal(
|
||||
client
|
||||
.prepare(
|
||||
`SELECT count(*) AS count
|
||||
FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE "operation_id" = 'approval.decide'`,
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,574 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { generateKeyPairSync } = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
|
||||
LocalPluginPackagePublisherTrustConfigurationError,
|
||||
LocalPluginPackagePublisherTrustConflictError,
|
||||
assertLocalPluginPackagePublisherKeyPublicationAllowed,
|
||||
confirmLocalPluginPackagePublisherKeyRevocation,
|
||||
inspectLocalPluginPackagePublisherTrust,
|
||||
localPluginPackagePublisherKeyRevocationImpactDigest,
|
||||
publishLocalPluginPackagePublisherTrust,
|
||||
proposeLocalPluginPackagePublisherKeyRevocation,
|
||||
retireLocalPluginPackagePublisherKey,
|
||||
} = require('@qinglong/local-admin/package-publisher-trust');
|
||||
|
||||
function key(keyId, notBeforeMs = 0, notAfterMs = 1_000) {
|
||||
const { publicKey } = generateKeyPairSync('ed25519');
|
||||
return {
|
||||
publisher: 'packages.example.com',
|
||||
keyId,
|
||||
publicKeyPem: publicKey.export({ format: 'pem', type: 'spki' }),
|
||||
notBeforeMs,
|
||||
notAfterMs,
|
||||
};
|
||||
}
|
||||
|
||||
function trust(keys) {
|
||||
return {
|
||||
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
|
||||
keys,
|
||||
};
|
||||
}
|
||||
|
||||
function fixture(t) {
|
||||
const unresolved = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-publisher-trust-'),
|
||||
);
|
||||
const trustRoot = fs.realpathSync(unresolved);
|
||||
fs.chmodSync(trustRoot, 0o700);
|
||||
t.after(() => fs.rmSync(trustRoot, { recursive: true, force: true }));
|
||||
return trustRoot;
|
||||
}
|
||||
|
||||
test('provisions and overlap-rotates one immutable trust chain', async (t) => {
|
||||
const trustRoot = fixture(t);
|
||||
const first = key('release-1');
|
||||
const second = key('release-2', 50, 2_000);
|
||||
let fences = 0;
|
||||
|
||||
const provisioned = await publishLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
mode: 'provision',
|
||||
expectedGeneration: 0,
|
||||
mutationId: 'trust-provision-v1',
|
||||
occurredAtMs: 100,
|
||||
trust: trust([first]),
|
||||
beforePublish() {
|
||||
fences += 1;
|
||||
},
|
||||
});
|
||||
assert.equal(provisioned.status, 'published');
|
||||
assert.equal(provisioned.generation, 1);
|
||||
assert.equal(
|
||||
fs.statSync(path.join(trustRoot, 'current.json')).mode & 0o777,
|
||||
0o600,
|
||||
);
|
||||
assert.deepEqual(
|
||||
inspectLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
observedAtMs: 100,
|
||||
}),
|
||||
{
|
||||
generation: 1,
|
||||
keyCount: 1,
|
||||
activeKeyCount: 1,
|
||||
snapshotCount: 1,
|
||||
retirementCount: 0,
|
||||
pendingRetirementCount: 0,
|
||||
revocationCount: 0,
|
||||
pendingRevocationCount: 0,
|
||||
quarantinedLockCount: 0,
|
||||
recoveryRequired: false,
|
||||
pendingGeneration: null,
|
||||
pendingMutationId: null,
|
||||
unresolvedTransactions: 0,
|
||||
trustDigest: provisioned.trustDigest,
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await publishLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
mode: 'provision',
|
||||
expectedGeneration: 0,
|
||||
mutationId: 'trust-provision-v1',
|
||||
occurredAtMs: 100,
|
||||
trust: trust([first]),
|
||||
beforePublish() {
|
||||
fences += 1;
|
||||
},
|
||||
})
|
||||
).status,
|
||||
'existing',
|
||||
);
|
||||
|
||||
const rotated = await publishLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
mode: 'rotate',
|
||||
expectedGeneration: 1,
|
||||
mutationId: 'trust-rotate-v2',
|
||||
occurredAtMs: 100,
|
||||
trust: trust([second, first]),
|
||||
beforePublish() {
|
||||
fences += 1;
|
||||
},
|
||||
});
|
||||
assert.equal(rotated.status, 'published');
|
||||
assert.equal(rotated.generation, 2);
|
||||
assert.equal(rotated.keyCount, 2);
|
||||
assert.equal(fences, 3);
|
||||
assert.deepEqual(
|
||||
JSON.parse(
|
||||
fs.readFileSync(path.join(trustRoot, 'current.json'), 'utf8'),
|
||||
).keys.map((item) => item.keyId),
|
||||
['release-1', 'release-2'],
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
publishLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
mode: 'rotate',
|
||||
expectedGeneration: 2,
|
||||
mutationId: 'trust-remove-v3',
|
||||
occurredAtMs: 100,
|
||||
trust: trust([second]),
|
||||
}),
|
||||
LocalPluginPackagePublisherTrustConfigurationError,
|
||||
);
|
||||
});
|
||||
|
||||
test('exact replay promotes a snapshot left durable before current', async (t) => {
|
||||
const trustRoot = fixture(t);
|
||||
const first = key('release-1');
|
||||
const command = {
|
||||
trustRoot,
|
||||
mode: 'provision',
|
||||
expectedGeneration: 0,
|
||||
mutationId: 'trust-crash-v1',
|
||||
occurredAtMs: 100,
|
||||
trust: trust([first]),
|
||||
};
|
||||
await assert.rejects(
|
||||
publishLocalPluginPackagePublisherTrust({
|
||||
...command,
|
||||
afterSnapshotPublished() {
|
||||
throw new Error('simulated current promotion failure');
|
||||
},
|
||||
}),
|
||||
/simulated current promotion failure/,
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(trustRoot, 'current.json')), false);
|
||||
assert.deepEqual(
|
||||
inspectLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
observedAtMs: 100,
|
||||
}),
|
||||
{
|
||||
generation: 0,
|
||||
keyCount: 0,
|
||||
activeKeyCount: 0,
|
||||
snapshotCount: 1,
|
||||
retirementCount: 0,
|
||||
pendingRetirementCount: 0,
|
||||
revocationCount: 0,
|
||||
pendingRevocationCount: 0,
|
||||
quarantinedLockCount: 0,
|
||||
recoveryRequired: true,
|
||||
pendingGeneration: 1,
|
||||
pendingMutationId: 'trust-crash-v1',
|
||||
unresolvedTransactions: 0,
|
||||
trustDigest: null,
|
||||
},
|
||||
);
|
||||
|
||||
const recovered = await publishLocalPluginPackagePublisherTrust(command);
|
||||
assert.equal(recovered.status, 'recovered');
|
||||
assert.equal(recovered.generation, 1);
|
||||
assert.equal(fs.existsSync(path.join(trustRoot, 'current.json')), true);
|
||||
});
|
||||
|
||||
test('rejects broad roots, unknown files and non-overlap rotation', async (t) => {
|
||||
const trustRoot = fixture(t);
|
||||
const first = key('release-1');
|
||||
await publishLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
mode: 'provision',
|
||||
expectedGeneration: 0,
|
||||
mutationId: 'trust-provision-v1',
|
||||
occurredAtMs: 100,
|
||||
trust: trust([first]),
|
||||
});
|
||||
fs.writeFileSync(path.join(trustRoot, 'unknown'), '', { mode: 0o600 });
|
||||
assert.throws(
|
||||
() =>
|
||||
inspectLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
observedAtMs: 100,
|
||||
}),
|
||||
/unknown entries/,
|
||||
);
|
||||
fs.unlinkSync(path.join(trustRoot, 'unknown'));
|
||||
const overflow = Array.from({ length: 33 }, (_, index) =>
|
||||
path.join(
|
||||
trustRoot,
|
||||
`retirement-${index.toString(16).padStart(64, '0')}.json`,
|
||||
),
|
||||
);
|
||||
for (const filePath of overflow) {
|
||||
fs.writeFileSync(filePath, '', { mode: 0o600 });
|
||||
}
|
||||
assert.throws(
|
||||
() =>
|
||||
inspectLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
observedAtMs: 100,
|
||||
}),
|
||||
/unbounded or unknown entries/,
|
||||
);
|
||||
for (const filePath of overflow) fs.unlinkSync(filePath);
|
||||
const revocationOverflow = Array.from({ length: 33 }, (_, index) =>
|
||||
path.join(
|
||||
trustRoot,
|
||||
`revocation-${index.toString(16).padStart(64, '0')}.json`,
|
||||
),
|
||||
);
|
||||
for (const filePath of revocationOverflow) {
|
||||
fs.writeFileSync(filePath, '', { mode: 0o600 });
|
||||
}
|
||||
assert.throws(
|
||||
() =>
|
||||
inspectLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
observedAtMs: 100,
|
||||
}),
|
||||
/unbounded or unknown entries/,
|
||||
);
|
||||
for (const filePath of revocationOverflow) fs.unlinkSync(filePath);
|
||||
fs.chmodSync(trustRoot, 0o755);
|
||||
assert.throws(
|
||||
() =>
|
||||
inspectLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
observedAtMs: 100,
|
||||
}),
|
||||
/owner-only/,
|
||||
);
|
||||
});
|
||||
|
||||
test('retires only an unreferenced key and exact replay recovers durable evidence', async (t) => {
|
||||
const trustRoot = fixture(t);
|
||||
const first = key('release-1');
|
||||
const second = key('release-2');
|
||||
await publishLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
mode: 'provision',
|
||||
expectedGeneration: 0,
|
||||
mutationId: 'trust-provision-v1',
|
||||
occurredAtMs: 100,
|
||||
trust: trust([first]),
|
||||
});
|
||||
await publishLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
mode: 'rotate',
|
||||
expectedGeneration: 1,
|
||||
mutationId: 'trust-rotate-v2',
|
||||
occurredAtMs: 100,
|
||||
trust: trust([first, second]),
|
||||
});
|
||||
const command = {
|
||||
trustRoot,
|
||||
expectedGeneration: 2,
|
||||
mutationId: 'trust-retire-v3',
|
||||
occurredAtMs: 100,
|
||||
publisher: first.publisher,
|
||||
keyId: first.keyId,
|
||||
proveRetirement() {
|
||||
return {
|
||||
catalogEntryCount: 0,
|
||||
bundleCount: 0,
|
||||
matchingEntryCount: 0,
|
||||
unresolvedTransactions: 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
await assert.rejects(
|
||||
retireLocalPluginPackagePublisherKey({
|
||||
...command,
|
||||
afterReceiptPublished() {
|
||||
throw new Error('simulated retirement snapshot failure');
|
||||
},
|
||||
}),
|
||||
/simulated retirement snapshot failure/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertLocalPluginPackagePublisherKeyPublicationAllowed({
|
||||
trustRoot,
|
||||
publisher: first.publisher,
|
||||
keyId: first.keyId,
|
||||
}),
|
||||
LocalPluginPackagePublisherTrustConflictError,
|
||||
);
|
||||
assert.doesNotThrow(() =>
|
||||
assertLocalPluginPackagePublisherKeyPublicationAllowed({
|
||||
trustRoot,
|
||||
publisher: second.publisher,
|
||||
keyId: second.keyId,
|
||||
}),
|
||||
);
|
||||
const pending = inspectLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
observedAtMs: 100,
|
||||
});
|
||||
assert.equal(pending.recoveryRequired, true);
|
||||
assert.equal(pending.pendingRetirementCount, 1);
|
||||
assert.equal(pending.retirementCount, 0);
|
||||
|
||||
const recovered = await retireLocalPluginPackagePublisherKey(command);
|
||||
assert.equal(recovered.status, 'recovered');
|
||||
assert.equal(recovered.generation, 3);
|
||||
assert.equal(recovered.keyCount, 1);
|
||||
assert.equal(
|
||||
(await retireLocalPluginPackagePublisherKey(command)).status,
|
||||
'existing',
|
||||
);
|
||||
assert.deepEqual(
|
||||
JSON.parse(
|
||||
fs.readFileSync(path.join(trustRoot, 'current.json'), 'utf8'),
|
||||
).keys.map((item) => item.keyId),
|
||||
['release-2'],
|
||||
);
|
||||
});
|
||||
|
||||
test('retirement intent blocks publication while catalog coverage remains', async (t) => {
|
||||
const trustRoot = fixture(t);
|
||||
const first = key('release-1');
|
||||
const second = key('release-2');
|
||||
await publishLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
mode: 'provision',
|
||||
expectedGeneration: 0,
|
||||
mutationId: 'trust-provision-v1',
|
||||
occurredAtMs: 100,
|
||||
trust: trust([first]),
|
||||
});
|
||||
await publishLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
mode: 'rotate',
|
||||
expectedGeneration: 1,
|
||||
mutationId: 'trust-rotate-v2',
|
||||
occurredAtMs: 100,
|
||||
trust: trust([first, second]),
|
||||
});
|
||||
const command = {
|
||||
trustRoot,
|
||||
expectedGeneration: 2,
|
||||
mutationId: 'trust-retire-blocked-v3',
|
||||
occurredAtMs: 100,
|
||||
publisher: first.publisher,
|
||||
keyId: first.keyId,
|
||||
};
|
||||
await assert.rejects(
|
||||
retireLocalPluginPackagePublisherKey({
|
||||
...command,
|
||||
proveRetirement() {
|
||||
return {
|
||||
catalogEntryCount: 1,
|
||||
bundleCount: 1,
|
||||
matchingEntryCount: 1,
|
||||
unresolvedTransactions: 0,
|
||||
};
|
||||
},
|
||||
}),
|
||||
/still block retirement/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertLocalPluginPackagePublisherKeyPublicationAllowed({
|
||||
trustRoot,
|
||||
publisher: first.publisher,
|
||||
keyId: first.keyId,
|
||||
}),
|
||||
/blocked by a durable lifecycle mutation/,
|
||||
);
|
||||
await assert.rejects(
|
||||
retireLocalPluginPackagePublisherKey({
|
||||
...command,
|
||||
proveRetirement() {
|
||||
return {
|
||||
catalogEntryCount: 0,
|
||||
bundleCount: 0,
|
||||
matchingEntryCount: 0,
|
||||
unresolvedTransactions: 1,
|
||||
};
|
||||
},
|
||||
}),
|
||||
/still block retirement/,
|
||||
);
|
||||
const recovered = await retireLocalPluginPackagePublisherKey({
|
||||
...command,
|
||||
proveRetirement() {
|
||||
return {
|
||||
catalogEntryCount: 0,
|
||||
bundleCount: 0,
|
||||
matchingEntryCount: 0,
|
||||
unresolvedTransactions: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.equal(recovered.status, 'recovered');
|
||||
});
|
||||
|
||||
test('blocks a compromised signer at proposal and requires dual-control confirmation', async (t) => {
|
||||
const trustRoot = fixture(t);
|
||||
const first = key('release-1');
|
||||
const second = key('release-2');
|
||||
await publishLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
mode: 'provision',
|
||||
expectedGeneration: 0,
|
||||
mutationId: 'trust-provision-v1',
|
||||
occurredAtMs: 100,
|
||||
trust: trust([first]),
|
||||
});
|
||||
await publishLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
mode: 'rotate',
|
||||
expectedGeneration: 1,
|
||||
mutationId: 'trust-rotate-v2',
|
||||
occurredAtMs: 100,
|
||||
trust: trust([first, second]),
|
||||
});
|
||||
const impactedLockDigests = ['a'.repeat(64)];
|
||||
const impact = {
|
||||
catalogEntryCount: 1,
|
||||
bundleCount: 1,
|
||||
matchingEntryCount: 1,
|
||||
unresolvedTransactions: 1,
|
||||
impactedLockDigests,
|
||||
impactDigest: localPluginPackagePublisherKeyRevocationImpactDigest({
|
||||
publisher: first.publisher,
|
||||
keyId: first.keyId,
|
||||
catalogEntryCount: 1,
|
||||
bundleCount: 1,
|
||||
matchingEntryCount: 1,
|
||||
unresolvedTransactions: 1,
|
||||
impactedLockDigests,
|
||||
}),
|
||||
};
|
||||
const proposalCommand = {
|
||||
trustRoot,
|
||||
expectedGeneration: 2,
|
||||
mutationId: 'trust-revoke-v3',
|
||||
occurredAtMs: 200,
|
||||
publisher: first.publisher,
|
||||
keyId: first.keyId,
|
||||
proposerSubjectId: 'owner-a',
|
||||
impact,
|
||||
};
|
||||
const proposed = await proposeLocalPluginPackagePublisherKeyRevocation(
|
||||
proposalCommand,
|
||||
);
|
||||
assert.equal(proposed.status, 'proposed');
|
||||
assert.equal(proposed.runtimeAction, 'stop_required');
|
||||
assert.equal(
|
||||
(await proposeLocalPluginPackagePublisherKeyRevocation(proposalCommand))
|
||||
.status,
|
||||
'existing',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertLocalPluginPackagePublisherKeyPublicationAllowed({
|
||||
trustRoot,
|
||||
publisher: first.publisher,
|
||||
keyId: first.keyId,
|
||||
}),
|
||||
/blocked by a durable lifecycle mutation/,
|
||||
);
|
||||
const pending = inspectLocalPluginPackagePublisherTrust({
|
||||
trustRoot,
|
||||
observedAtMs: 200,
|
||||
});
|
||||
assert.equal(pending.pendingRevocationCount, 1);
|
||||
assert.equal(pending.quarantinedLockCount, 1);
|
||||
assert.equal(pending.recoveryRequired, true);
|
||||
|
||||
await assert.rejects(
|
||||
confirmLocalPluginPackagePublisherKeyRevocation({
|
||||
trustRoot,
|
||||
expectedGeneration: 2,
|
||||
mutationId: 'trust-revoke-v3',
|
||||
confirmedAtMs: 300,
|
||||
publisher: first.publisher,
|
||||
keyId: first.keyId,
|
||||
proposerSubjectId: 'owner-a',
|
||||
confirmerSubjectId: 'owner-a',
|
||||
authorizationMode: 'dual_control',
|
||||
reasonCode: 'confirmed_key_compromise',
|
||||
expectedImpactDigest: impact.impactDigest,
|
||||
confirmAuthorization() {},
|
||||
}),
|
||||
/distinct Owner/,
|
||||
);
|
||||
let authorizationFences = 0;
|
||||
const confirmation = {
|
||||
trustRoot,
|
||||
expectedGeneration: 2,
|
||||
mutationId: 'trust-revoke-v3',
|
||||
confirmedAtMs: 300,
|
||||
publisher: first.publisher,
|
||||
keyId: first.keyId,
|
||||
proposerSubjectId: 'owner-a',
|
||||
confirmerSubjectId: 'owner-b',
|
||||
authorizationMode: 'dual_control',
|
||||
reasonCode: 'confirmed_key_compromise',
|
||||
expectedImpactDigest: impact.impactDigest,
|
||||
confirmAuthorization() {
|
||||
authorizationFences += 1;
|
||||
},
|
||||
};
|
||||
await assert.rejects(
|
||||
confirmLocalPluginPackagePublisherKeyRevocation({
|
||||
...confirmation,
|
||||
afterSnapshotPublished() {
|
||||
throw new Error('simulated revocation promotion failure');
|
||||
},
|
||||
}),
|
||||
/simulated revocation promotion failure/,
|
||||
);
|
||||
const recovered = await confirmLocalPluginPackagePublisherKeyRevocation(
|
||||
confirmation,
|
||||
);
|
||||
assert.equal(recovered.status, 'recovered');
|
||||
assert.equal(recovered.generation, 3);
|
||||
assert.equal(recovered.keyCount, 1);
|
||||
assert.equal(recovered.quarantinedLockCount, 1);
|
||||
assert.equal(recovered.runtimeAction, 'restart_required');
|
||||
assert.equal(
|
||||
(await confirmLocalPluginPackagePublisherKeyRevocation(confirmation))
|
||||
.status,
|
||||
'existing',
|
||||
);
|
||||
assert.equal(authorizationFences, 3);
|
||||
assert.deepEqual(
|
||||
JSON.parse(
|
||||
fs.readFileSync(path.join(trustRoot, 'current.json'), 'utf8'),
|
||||
).keys.map((item) => item.keyId),
|
||||
['release-2'],
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertLocalPluginPackagePublisherKeyPublicationAllowed({
|
||||
trustRoot,
|
||||
publisher: first.publisher,
|
||||
keyId: first.keyId,
|
||||
}),
|
||||
/blocked by a durable lifecycle mutation/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
pluginPackageContentTreeDigest,
|
||||
} = require('@qinglong/runtime-core/plugin-package-bundle');
|
||||
const {
|
||||
createPluginPackageResourceGenerationFromReferences,
|
||||
} = require('@qinglong/runtime-core/plugin-package-resource-generation');
|
||||
const {
|
||||
InvalidLocalPluginPackageResourceSourceError,
|
||||
LocalPluginPackageResourceByteSource,
|
||||
} = require('../dist/plugin-package/pluginPackageResourceMaterialization');
|
||||
|
||||
const LOCK_DIGEST = 'a'.repeat(64);
|
||||
|
||||
function digest(value) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function blobName(index, entryPath) {
|
||||
return `${String(index).padStart(4, '0')}-${digest(entryPath)}.blob`;
|
||||
}
|
||||
|
||||
async function stageFixture() {
|
||||
const root = await fs.realpath(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), 'ql3-resource-source-')),
|
||||
);
|
||||
await fs.chmod(root, 0o700);
|
||||
const stage = path.join(root, LOCK_DIGEST);
|
||||
const blobs = path.join(stage, 'blobs');
|
||||
await fs.mkdir(stage, { mode: 0o700 });
|
||||
await fs.mkdir(blobs, { mode: 0o700 });
|
||||
|
||||
const materials = new Map([
|
||||
['package.json', Buffer.from('{"apiVersion":"qinglong.io/v1alpha1"}')],
|
||||
[
|
||||
'tasks/collect.json',
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
schema: 'qinglong/plugin-package-task-resource@v1',
|
||||
id: 'collect',
|
||||
}),
|
||||
),
|
||||
],
|
||||
]);
|
||||
const entries = [...materials.entries()].map(
|
||||
([entryPath, material], index) => ({
|
||||
path: entryPath,
|
||||
bytes: material.byteLength,
|
||||
digest: digest(material),
|
||||
blob: blobName(index, entryPath),
|
||||
}),
|
||||
);
|
||||
const contentDigest = pluginPackageContentTreeDigest(
|
||||
entries.slice(1).map(({ path: entryPath, bytes, digest }) => ({
|
||||
path: entryPath,
|
||||
bytes,
|
||||
digest,
|
||||
})),
|
||||
);
|
||||
const generation = createPluginPackageResourceGenerationFromReferences({
|
||||
installationId: 'install-001',
|
||||
projectId: 'project-001',
|
||||
packageName: 'example-monitor',
|
||||
lockDigest: LOCK_DIGEST,
|
||||
generation: 1,
|
||||
previousActiveLockDigest: null,
|
||||
contentDigest,
|
||||
resources: [{ kind: 'task', path: 'tasks/collect.json' }],
|
||||
});
|
||||
const inspectionEntries = entries.map(
|
||||
({ path: entryPath, bytes, digest }) => ({
|
||||
path: entryPath,
|
||||
bytes,
|
||||
digest,
|
||||
}),
|
||||
);
|
||||
const receipt = {
|
||||
schema: 'qinglong/plugin-package-stage-receipt@v1',
|
||||
lockDigest: LOCK_DIGEST,
|
||||
inspection: {
|
||||
mediaType: 'application/vnd.qinglong.package.v1+tar',
|
||||
lockDigest: LOCK_DIGEST,
|
||||
packageName: generation.packageName,
|
||||
packageVersion: '1.0.0',
|
||||
artifactBytes: 4096,
|
||||
artifactDigest: 'b'.repeat(64),
|
||||
manifestDigest: entries[0].digest,
|
||||
contentBytes: entries[1].bytes,
|
||||
contentDigest,
|
||||
entries: inspectionEntries,
|
||||
signature: {
|
||||
publisher: 'example.test',
|
||||
keyId: 'key-001',
|
||||
signatureDigest: 'c'.repeat(64),
|
||||
keyNotBeforeMs: 0,
|
||||
keyNotAfterMs: 10_000,
|
||||
verifiedAtMs: 100,
|
||||
},
|
||||
},
|
||||
entries,
|
||||
};
|
||||
for (const [index, entry] of entries.entries()) {
|
||||
const file = path.join(blobs, entry.blob);
|
||||
await fs.writeFile(file, materials.get(entry.path), { mode: 0o600 });
|
||||
await fs.chmod(file, 0o600);
|
||||
assert.equal(index < 10_000, true);
|
||||
}
|
||||
const receiptPath = path.join(stage, 'receipt.json');
|
||||
await fs.writeFile(receiptPath, JSON.stringify(receipt), { mode: 0o600 });
|
||||
await fs.chmod(receiptPath, 0o600);
|
||||
return { root, stage, blobs, materials, entries, generation };
|
||||
}
|
||||
|
||||
async function cleanup(root) {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
test('opens one private stage session and verifies each exact blob on demand', async () => {
|
||||
const fixture = await stageFixture();
|
||||
try {
|
||||
const source = new LocalPluginPackageResourceByteSource({
|
||||
stagingRoot: fixture.root,
|
||||
});
|
||||
const reader = await source.open(fixture.generation);
|
||||
assert.deepEqual(
|
||||
await reader.read('package.json', 64 * 1024),
|
||||
fixture.materials.get('package.json'),
|
||||
);
|
||||
assert.deepEqual(
|
||||
await reader.read('tasks/collect.json', 1024 * 1024),
|
||||
fixture.materials.get('tasks/collect.json'),
|
||||
);
|
||||
await assert.rejects(
|
||||
reader.read('tasks/collect.json', 1024 * 1024),
|
||||
/duplicated/,
|
||||
);
|
||||
await reader.close();
|
||||
await assert.rejects(reader.read('package.json', 64 * 1024), /closed/);
|
||||
} finally {
|
||||
await cleanup(fixture.root);
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed on blob tampering, extras and an over-tight caller bound', async () => {
|
||||
const tampered = await stageFixture();
|
||||
try {
|
||||
const source = new LocalPluginPackageResourceByteSource({
|
||||
stagingRoot: tampered.root,
|
||||
});
|
||||
const reader = await source.open(tampered.generation);
|
||||
await fs.writeFile(
|
||||
path.join(tampered.blobs, tampered.entries[1].blob),
|
||||
Buffer.alloc(tampered.entries[1].bytes, 0x78),
|
||||
);
|
||||
await assert.rejects(
|
||||
reader.read('tasks/collect.json', 1024 * 1024),
|
||||
InvalidLocalPluginPackageResourceSourceError,
|
||||
);
|
||||
} finally {
|
||||
await cleanup(tampered.root);
|
||||
}
|
||||
|
||||
const extra = await stageFixture();
|
||||
try {
|
||||
await fs.writeFile(path.join(extra.blobs, 'extra'), 'x', { mode: 0o600 });
|
||||
const source = new LocalPluginPackageResourceByteSource({
|
||||
stagingRoot: extra.root,
|
||||
});
|
||||
await assert.rejects(
|
||||
source.open(extra.generation),
|
||||
/incomplete or contains extras/,
|
||||
);
|
||||
} finally {
|
||||
await cleanup(extra.root);
|
||||
}
|
||||
|
||||
const bounded = await stageFixture();
|
||||
try {
|
||||
const source = new LocalPluginPackageResourceByteSource({
|
||||
stagingRoot: bounded.root,
|
||||
});
|
||||
const reader = await source.open(bounded.generation);
|
||||
await assert.rejects(
|
||||
reader.read('tasks/collect.json', 1),
|
||||
/exceeds its requested bound/,
|
||||
);
|
||||
} finally {
|
||||
await cleanup(bounded.root);
|
||||
}
|
||||
});
|
||||
|
||||
test('publishes the local adapter only through its explicit subpath', () => {
|
||||
assert.equal(require('..').LocalPluginPackageResourceByteSource, undefined);
|
||||
assert.equal(
|
||||
require('@qinglong/local-admin/package-resource-materialization')
|
||||
.LocalPluginPackageResourceByteSource,
|
||||
LocalPluginPackageResourceByteSource,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,402 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash, generateKeyPairSync, sign } = require('node:crypto');
|
||||
const {
|
||||
chmod,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
realpath,
|
||||
rm,
|
||||
symlink,
|
||||
unlink,
|
||||
writeFile,
|
||||
} = require('node:fs/promises');
|
||||
const { tmpdir } = require('node:os');
|
||||
const { join } = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
PLUGIN_PACKAGE_SIGNATURE_SCHEMA,
|
||||
PluginPackagePublisherTrustRegistry,
|
||||
pluginPackageContentTreeDigest,
|
||||
pluginPackagePublisherSignaturePayload,
|
||||
} = require('@qinglong/runtime-core/plugin-package-bundle');
|
||||
const {
|
||||
createPluginPackageLock,
|
||||
pluginPackageInstallActionDigest,
|
||||
pluginPackageInstallPlanDigest,
|
||||
serializePluginPackageManifest,
|
||||
} = require('@qinglong/runtime-core/plugin-package-install');
|
||||
const {
|
||||
PLUGIN_PACKAGE_API_VERSION,
|
||||
PLUGIN_PACKAGE_KIND,
|
||||
planPluginPackageInstall,
|
||||
} = require('@qinglong/runtime-core/plugin-package');
|
||||
const {
|
||||
InvalidPluginPackageStagingError,
|
||||
PluginPackageStagingUnavailableError,
|
||||
stagePluginPackageFromFile,
|
||||
} = require('../dist/plugin-package/pluginPackageStaging');
|
||||
|
||||
const PUBLISHER = 'packages.example.com';
|
||||
const KEY_ID = 'release-2026';
|
||||
|
||||
function manifest() {
|
||||
return {
|
||||
apiVersion: PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: 'example-monitor',
|
||||
displayName: 'Example Monitor',
|
||||
version: '1.2.0',
|
||||
description: 'Collects one report',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: ['arm64'],
|
||||
deploymentProfiles: ['edge'],
|
||||
},
|
||||
runtimes: [{ name: 'python', version: '>=3.10.0 <4.0.0' }],
|
||||
resources: {
|
||||
memory: { recommended: '32Mi' },
|
||||
disk: { install: '8Mi', working: '32Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: [],
|
||||
},
|
||||
contents: {
|
||||
tasks: ['tasks/collect.yaml'],
|
||||
workflows: [],
|
||||
prompts: [],
|
||||
tools: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function environment() {
|
||||
return {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
runtimes: [{ name: 'python', version: '3.12.4' }],
|
||||
availableMemoryBytes: 256 * 1024 * 1024,
|
||||
availableDiskBytes: 512 * 1024 * 1024,
|
||||
};
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function octal(value, bytes) {
|
||||
return Buffer.from(`${value.toString(8).padStart(bytes - 1, '0')}\0`);
|
||||
}
|
||||
|
||||
function tarHeader(path, bytes) {
|
||||
const header = Buffer.alloc(512);
|
||||
Buffer.from(path).copy(header, 0);
|
||||
Buffer.from('0000644\0').copy(header, 100);
|
||||
Buffer.from('0000000\0').copy(header, 108);
|
||||
Buffer.from('0000000\0').copy(header, 116);
|
||||
octal(bytes, 12).copy(header, 124);
|
||||
Buffer.from('00000000000\0').copy(header, 136);
|
||||
header.fill(0x20, 148, 156);
|
||||
Buffer.from('0').copy(header, 156);
|
||||
Buffer.from('ustar\0').copy(header, 257);
|
||||
Buffer.from('00').copy(header, 263);
|
||||
const checksum = header.reduce((total, byte) => total + byte, 0);
|
||||
Buffer.from(`${checksum.toString(8).padStart(6, '0')}\0 `).copy(header, 148);
|
||||
return header;
|
||||
}
|
||||
|
||||
function tar(entries) {
|
||||
const parts = [];
|
||||
for (const entry of entries) {
|
||||
parts.push(tarHeader(entry.path, entry.body.byteLength), entry.body);
|
||||
const padding = (512 - (entry.body.byteLength % 512)) % 512;
|
||||
if (padding > 0) parts.push(Buffer.alloc(padding));
|
||||
}
|
||||
parts.push(Buffer.alloc(1024));
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
function packageFixture() {
|
||||
const packageManifest = manifest();
|
||||
const manifestBody = Buffer.from(
|
||||
serializePluginPackageManifest(packageManifest),
|
||||
);
|
||||
const taskBody = Buffer.from(
|
||||
'apiVersion: qinglong.io/v1\nkind: Task\nmetadata:\n name: collect\n',
|
||||
);
|
||||
const artifact = tar([
|
||||
{ path: 'package.json', body: manifestBody },
|
||||
{ path: 'tasks/collect.yaml', body: taskBody },
|
||||
]);
|
||||
const installEnvironment = environment();
|
||||
const plan = planPluginPackageInstall(packageManifest, installEnvironment);
|
||||
const artifactDigest = sha256(artifact);
|
||||
const source = {
|
||||
kind: 'offline',
|
||||
locator: `offline:sha256:${artifactDigest}`,
|
||||
artifactDigest,
|
||||
artifactBytes: artifact.byteLength,
|
||||
contentDigest: pluginPackageContentTreeDigest([
|
||||
{
|
||||
path: 'tasks/collect.yaml',
|
||||
bytes: taskBody.byteLength,
|
||||
digest: sha256(taskBody),
|
||||
},
|
||||
]),
|
||||
};
|
||||
const actionInput = {
|
||||
lockId: 'lock-stage-001',
|
||||
projectId: 'project-001',
|
||||
manifest: packageManifest,
|
||||
plan,
|
||||
environment: installEnvironment,
|
||||
source,
|
||||
architecture: 'arm64',
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: 1,
|
||||
};
|
||||
const lock = createPluginPackageLock({
|
||||
...actionInput,
|
||||
approval: {
|
||||
requestId: 'approval-001',
|
||||
requestVersion: 1,
|
||||
dispatchId: 'dispatch-001',
|
||||
actionDigest: pluginPackageInstallActionDigest(actionInput),
|
||||
previewDigest: pluginPackageInstallPlanDigest(plan),
|
||||
approvedBy: { type: 'user', id: 'owner-001' },
|
||||
approvedAtMs: 100,
|
||||
expiresAtMs: 1_000,
|
||||
fence: { projectVersion: 3, bindingVersion: 4 },
|
||||
},
|
||||
createdAtMs: 200,
|
||||
});
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
||||
const trust = new PluginPackagePublisherTrustRegistry([
|
||||
{
|
||||
publisher: PUBLISHER,
|
||||
keyId: KEY_ID,
|
||||
publicKeyPem: publicKey.export({ format: 'pem', type: 'spki' }),
|
||||
notBeforeMs: 100,
|
||||
notAfterMs: 1_000,
|
||||
},
|
||||
]);
|
||||
const signature = {
|
||||
schema: PLUGIN_PACKAGE_SIGNATURE_SCHEMA,
|
||||
publisher: PUBLISHER,
|
||||
keyId: KEY_ID,
|
||||
signature: sign(
|
||||
null,
|
||||
pluginPackagePublisherSignaturePayload(lock, PUBLISHER, KEY_ID),
|
||||
privateKey,
|
||||
).toString('base64url'),
|
||||
};
|
||||
return { artifact, lock, packageManifest, signature, trust };
|
||||
}
|
||||
|
||||
async function filesystemFixture(t) {
|
||||
const unresolved = await mkdtemp(join(tmpdir(), 'ql3-package-stage-'));
|
||||
const base = await realpath(unresolved);
|
||||
const stagingRoot = join(base, 'staging');
|
||||
const bundlePath = join(base, 'package.qlpkg');
|
||||
await mkdir(stagingRoot, { mode: 0o700 });
|
||||
await chmod(stagingRoot, 0o700);
|
||||
t.after(() => rm(base, { recursive: true, force: true }));
|
||||
return { base, bundlePath, stagingRoot };
|
||||
}
|
||||
|
||||
async function writePrivateBundle(bundlePath, artifact) {
|
||||
await writeFile(bundlePath, artifact, { mode: 0o600 });
|
||||
await chmod(bundlePath, 0o600);
|
||||
}
|
||||
|
||||
function stageOptions(filesystem, value, overrides = {}) {
|
||||
return {
|
||||
bundlePath: filesystem.bundlePath,
|
||||
stagingRoot: filesystem.stagingRoot,
|
||||
lock: value.lock,
|
||||
manifest: value.packageManifest,
|
||||
signature: value.signature,
|
||||
trust: value.trust,
|
||||
observedAtMs: 500,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('stages a verified bundle as private opaque blobs and one receipt', async (t) => {
|
||||
const filesystem = await filesystemFixture(t);
|
||||
const value = packageFixture();
|
||||
await writePrivateBundle(filesystem.bundlePath, value.artifact);
|
||||
const staged = await stagePluginPackageFromFile(
|
||||
stageOptions(filesystem, value),
|
||||
);
|
||||
|
||||
assert.equal(staged.status, 'staged');
|
||||
assert.equal(staged.stageRef, `local-stage:${value.lock.lockDigest}`);
|
||||
assert.equal(
|
||||
staged.directory,
|
||||
join(filesystem.stagingRoot, value.lock.lockDigest),
|
||||
);
|
||||
assert.match(staged.receiptDigest, /^[0-9a-f]{64}$/);
|
||||
assert.equal((await lstat(staged.directory)).mode & 0o777, 0o700);
|
||||
const rootEntries = await readdir(staged.directory);
|
||||
assert.deepEqual(rootEntries.sort(), ['blobs', 'receipt.json']);
|
||||
const blobNames = (await readdir(join(staged.directory, 'blobs'))).sort();
|
||||
assert.equal(blobNames.length, 2);
|
||||
assert.equal(
|
||||
blobNames.every((name) => /^[0-9]{4}-[0-9a-f]{64}\.blob$/.test(name)),
|
||||
true,
|
||||
);
|
||||
assert.equal(rootEntries.includes('tasks'), false);
|
||||
assert.equal(
|
||||
(await lstat(join(staged.directory, 'receipt.json'))).mode & 0o777,
|
||||
0o600,
|
||||
);
|
||||
for (const blob of blobNames) {
|
||||
assert.equal(
|
||||
(await lstat(join(staged.directory, 'blobs', blob))).mode & 0o777,
|
||||
0o600,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('replays one exact stage without reopening the deleted source bundle', async (t) => {
|
||||
const filesystem = await filesystemFixture(t);
|
||||
const value = packageFixture();
|
||||
await writePrivateBundle(filesystem.bundlePath, value.artifact);
|
||||
const first = await stagePluginPackageFromFile(
|
||||
stageOptions(filesystem, value),
|
||||
);
|
||||
await unlink(filesystem.bundlePath);
|
||||
const replay = await stagePluginPackageFromFile(
|
||||
stageOptions(filesystem, value),
|
||||
);
|
||||
assert.equal(replay.status, 'existing');
|
||||
assert.equal(replay.receiptDigest, first.receiptDigest);
|
||||
assert.deepEqual(replay.inspection, first.inspection);
|
||||
});
|
||||
|
||||
test('fails closed when an existing opaque blob or receipt is changed', async (t) => {
|
||||
const filesystem = await filesystemFixture(t);
|
||||
const value = packageFixture();
|
||||
await writePrivateBundle(filesystem.bundlePath, value.artifact);
|
||||
const staged = await stagePluginPackageFromFile(
|
||||
stageOptions(filesystem, value),
|
||||
);
|
||||
const blobDirectory = join(staged.directory, 'blobs');
|
||||
const [blob] = await readdir(blobDirectory);
|
||||
await writeFile(join(blobDirectory, blob), Buffer.from('tampered'));
|
||||
await assert.rejects(
|
||||
stagePluginPackageFromFile(stageOptions(filesystem, value)),
|
||||
InvalidPluginPackageStagingError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a canonical-looking receipt detached from the locked artifact', async (t) => {
|
||||
const filesystem = await filesystemFixture(t);
|
||||
const value = packageFixture();
|
||||
await writePrivateBundle(filesystem.bundlePath, value.artifact);
|
||||
const staged = await stagePluginPackageFromFile(
|
||||
stageOptions(filesystem, value),
|
||||
);
|
||||
const receiptPath = join(staged.directory, 'receipt.json');
|
||||
const receipt = JSON.parse(await readFile(receiptPath, 'utf8'));
|
||||
receipt.inspection.artifactDigest = 'f'.repeat(64);
|
||||
await writeFile(receiptPath, `${JSON.stringify(receipt)}\n`, { mode: 0o600 });
|
||||
await chmod(receiptPath, 0o600);
|
||||
await assert.rejects(
|
||||
stagePluginPackageFromFile(stageOptions(filesystem, value)),
|
||||
InvalidPluginPackageStagingError,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects broad bundle permissions and final-component symbolic links', async (t) => {
|
||||
const filesystem = await filesystemFixture(t);
|
||||
const value = packageFixture();
|
||||
await writePrivateBundle(filesystem.bundlePath, value.artifact);
|
||||
await chmod(filesystem.bundlePath, 0o644);
|
||||
await assert.rejects(
|
||||
stagePluginPackageFromFile(stageOptions(filesystem, value)),
|
||||
InvalidPluginPackageStagingError,
|
||||
);
|
||||
assert.deepEqual(await readdir(filesystem.stagingRoot), []);
|
||||
|
||||
const target = join(filesystem.base, 'target.qlpkg');
|
||||
await writePrivateBundle(target, value.artifact);
|
||||
await unlink(filesystem.bundlePath);
|
||||
await symlink(target, filesystem.bundlePath);
|
||||
await assert.rejects(
|
||||
stagePluginPackageFromFile(stageOptions(filesystem, value)),
|
||||
InvalidPluginPackageStagingError,
|
||||
);
|
||||
assert.deepEqual(await readdir(filesystem.stagingRoot), []);
|
||||
});
|
||||
|
||||
test('does not create a transaction for an untrusted signature', async (t) => {
|
||||
const filesystem = await filesystemFixture(t);
|
||||
const value = packageFixture();
|
||||
await writePrivateBundle(filesystem.bundlePath, value.artifact);
|
||||
await assert.rejects(
|
||||
stagePluginPackageFromFile(
|
||||
stageOptions(filesystem, value, {
|
||||
observedAtMs: 1_000,
|
||||
}),
|
||||
),
|
||||
/publisher signature is not trusted/,
|
||||
);
|
||||
assert.deepEqual(await readdir(filesystem.stagingRoot), []);
|
||||
});
|
||||
|
||||
test('removes its bounded temporary transaction after bundle failure', async (t) => {
|
||||
const filesystem = await filesystemFixture(t);
|
||||
const value = packageFixture();
|
||||
const tampered = Buffer.from(value.artifact);
|
||||
tampered[512] ^= 1;
|
||||
await writePrivateBundle(filesystem.bundlePath, tampered);
|
||||
await assert.rejects(
|
||||
stagePluginPackageFromFile(stageOptions(filesystem, value)),
|
||||
PluginPackageStagingUnavailableError,
|
||||
);
|
||||
assert.deepEqual(await readdir(filesystem.stagingRoot), []);
|
||||
});
|
||||
|
||||
test('fails closed on stale transactions, unknown root entries and broad roots', async (t) => {
|
||||
const filesystem = await filesystemFixture(t);
|
||||
const value = packageFixture();
|
||||
await writePrivateBundle(filesystem.bundlePath, value.artifact);
|
||||
const stale = join(filesystem.stagingRoot, `.qlpkg-${'a'.repeat(32)}`);
|
||||
await mkdir(stale, { mode: 0o700 });
|
||||
await assert.rejects(
|
||||
stagePluginPackageFromFile(stageOptions(filesystem, value)),
|
||||
InvalidPluginPackageStagingError,
|
||||
);
|
||||
await rm(stale, { recursive: true });
|
||||
await writeFile(join(filesystem.stagingRoot, 'unknown'), '');
|
||||
await assert.rejects(
|
||||
stagePluginPackageFromFile(stageOptions(filesystem, value)),
|
||||
InvalidPluginPackageStagingError,
|
||||
);
|
||||
await unlink(join(filesystem.stagingRoot, 'unknown'));
|
||||
await chmod(filesystem.stagingRoot, 0o755);
|
||||
await assert.rejects(
|
||||
stagePluginPackageFromFile(stageOptions(filesystem, value)),
|
||||
InvalidPluginPackageStagingError,
|
||||
);
|
||||
});
|
||||
|
||||
test('publishes staging authority only through the explicit local-admin subpath', () => {
|
||||
const root = require('..');
|
||||
const subpath = require('@qinglong/local-admin/package-staging');
|
||||
assert.equal(root.stagePluginPackageFromFile, undefined);
|
||||
assert.equal(subpath.stagePluginPackageFromFile, stagePluginPackageFromFile);
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { test } = require('node:test');
|
||||
const {
|
||||
LocalSecretAuthorizationFenceConflictError,
|
||||
} = require('@qinglong/runtime-core/local-secret-administration');
|
||||
const {
|
||||
LocalSecretMutationConflictError,
|
||||
} = require('@qinglong/runtime-core/local-secret');
|
||||
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
|
||||
const {
|
||||
openLocalSqliteRuntimeDatabase,
|
||||
} = require('@qinglong/local-sqlite/runtime');
|
||||
const {
|
||||
LocalSecretKeyringFileProvider,
|
||||
provisionLocalSecretKeyring,
|
||||
} = require('@qinglong/local-secret');
|
||||
const {
|
||||
LocalSecretAdministrationAuthenticationError,
|
||||
LocalSecretAdministrationAuthorizationError,
|
||||
LocalSecretAdministrationUnavailableError,
|
||||
createLocalSecretAdministrationService,
|
||||
} = require('@qinglong/local-admin/secret-administration');
|
||||
|
||||
const NOW = 1_760_000_000_000;
|
||||
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-secret-admin-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
return {
|
||||
directory,
|
||||
profile: 'edge',
|
||||
databasePath: path.join(directory, 'qinglong3.sqlite'),
|
||||
keyringPath: path.join(directory, 'secret-keyring.json'),
|
||||
};
|
||||
}
|
||||
|
||||
function principal(overrides = {}) {
|
||||
return {
|
||||
subject: { type: 'user', id: 'user-owner' },
|
||||
authenticationId: 'local-console-1',
|
||||
authenticatedAtMs: NOW - 1_000,
|
||||
expiresAtMs: NOW + 60_000,
|
||||
assurance: 'local_console',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function request(mutationId, overrides = {}) {
|
||||
return {
|
||||
projectId: 'default',
|
||||
name: 'TOKEN',
|
||||
plaintext: 'never-persist-this-plaintext',
|
||||
mutationId,
|
||||
requestId: `request-${mutationId.slice(-4)}`,
|
||||
expectedCurrentVersion: 0,
|
||||
principal: principal(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function openStore(t) {
|
||||
const value = fixture(t);
|
||||
await migrateLocalSqlitePath(value);
|
||||
await provisionLocalSecretKeyring(value.keyringPath);
|
||||
const runtime = await openLocalSqliteRuntimeDatabase(value);
|
||||
t.after(() => runtime.close());
|
||||
const keys = new LocalSecretKeyringFileProvider(value.keyringPath);
|
||||
return { ...value, runtime, keys };
|
||||
}
|
||||
|
||||
async function bind(runtime, role, version = 1, state = 'active') {
|
||||
return runtime.projectPolicy.append({
|
||||
expectedCurrentVersion: version - 1,
|
||||
binding: {
|
||||
projectId: 'default',
|
||||
subject: { type: 'user', id: 'user-owner' },
|
||||
version,
|
||||
state,
|
||||
...(state === 'active' ? { role } : {}),
|
||||
mutationId: `binding-${version}`,
|
||||
changedBy: { type: 'system', id: 'owner-bootstrap' },
|
||||
createdAtMs: NOW + version,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function service(value, overrides = {}) {
|
||||
return createLocalSecretAdministrationService(
|
||||
overrides.projectPolicy ?? value.runtime.projectPolicy,
|
||||
overrides.mutations ?? value.runtime.localSecretAdministration,
|
||||
overrides.audit ?? value.runtime.securityAudit,
|
||||
overrides.keys ?? value.keys,
|
||||
{ now: () => NOW, nonceFactory: () => Buffer.alloc(12, 7) },
|
||||
);
|
||||
}
|
||||
|
||||
test('owner writes one encrypted envelope and allowed audit atomically', async (t) => {
|
||||
const value = await openStore(t);
|
||||
assert.equal(
|
||||
(await value.runtime.projectPolicy.resolve('default', principal().subject))
|
||||
.binding,
|
||||
undefined,
|
||||
);
|
||||
await bind(value.runtime, 'owner');
|
||||
const mutationId = '00000000-0000-4000-8000-000000000001';
|
||||
const result = await service(value).put(request(mutationId));
|
||||
assert.equal(result.status, 'inserted');
|
||||
assert.equal(result.version, 1);
|
||||
|
||||
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
const secret = database
|
||||
.prepare(
|
||||
`SELECT version, ciphertext FROM "QingLong3LocalSecretEnvelopes"
|
||||
WHERE mutation_id = ?`,
|
||||
)
|
||||
.get(mutationId);
|
||||
const audit = database
|
||||
.prepare(
|
||||
`SELECT operation_id, outcome, subject_id, fence_project_version,
|
||||
fence_binding_version
|
||||
FROM "QingLong3SecurityAuditEvents" WHERE event_id = ?`,
|
||||
)
|
||||
.get(mutationId);
|
||||
assert.equal(secret.version, 1);
|
||||
assert.equal(
|
||||
Buffer.from(secret.ciphertext).includes(
|
||||
Buffer.from('never-persist-this-plaintext'),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ ...audit },
|
||||
{
|
||||
operation_id: 'secret.create',
|
||||
outcome: 'allowed',
|
||||
subject_id: 'user-owner',
|
||||
fence_project_version: 1,
|
||||
fence_binding_version: 1,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('denial is audited before key access and ownerless default stays closed', async (t) => {
|
||||
const value = await openStore(t);
|
||||
let keyReads = 0;
|
||||
const keys = {
|
||||
async active() {
|
||||
keyReads += 1;
|
||||
throw new Error('must not run');
|
||||
},
|
||||
async resolve() {
|
||||
keyReads += 1;
|
||||
throw new Error('must not run');
|
||||
},
|
||||
};
|
||||
const mutationId = '00000000-0000-4000-8000-000000000002';
|
||||
await assert.rejects(
|
||||
service(value, { keys }).put(request(mutationId)),
|
||||
LocalSecretAdministrationAuthorizationError,
|
||||
);
|
||||
assert.equal(keyReads, 0);
|
||||
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.equal(
|
||||
database
|
||||
.prepare(
|
||||
`SELECT outcome FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE event_id = ?`,
|
||||
)
|
||||
.get(mutationId).outcome,
|
||||
'denied',
|
||||
);
|
||||
assert.equal(
|
||||
database
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS count FROM "QingLong3LocalSecretEnvelopes"',
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('weak authentication is audited without Policy or key access', async (t) => {
|
||||
const value = await openStore(t);
|
||||
let policyReads = 0;
|
||||
let keyReads = 0;
|
||||
const projectPolicy = {
|
||||
async resolve() {
|
||||
policyReads += 1;
|
||||
throw new Error('must not run');
|
||||
},
|
||||
append: value.runtime.projectPolicy.append,
|
||||
};
|
||||
const keys = {
|
||||
async active() {
|
||||
keyReads += 1;
|
||||
throw new Error('must not run');
|
||||
},
|
||||
async resolve() {
|
||||
keyReads += 1;
|
||||
throw new Error('must not run');
|
||||
},
|
||||
};
|
||||
const mutationId = '00000000-0000-4000-8000-000000000003';
|
||||
await assert.rejects(
|
||||
service(value, { projectPolicy, keys }).put(
|
||||
request(mutationId, {
|
||||
principal: principal({ assurance: 'single_factor' }),
|
||||
}),
|
||||
),
|
||||
LocalSecretAdministrationAuthenticationError,
|
||||
);
|
||||
assert.equal(policyReads, 0);
|
||||
assert.equal(keyReads, 0);
|
||||
});
|
||||
|
||||
test('revocation after Policy decision is fenced inside the write transaction', async (t) => {
|
||||
const value = await openStore(t);
|
||||
await bind(value.runtime, 'admin');
|
||||
let revoked = false;
|
||||
const projectPolicy = {
|
||||
async resolve(projectId, subject) {
|
||||
const snapshot = await value.runtime.projectPolicy.resolve(
|
||||
projectId,
|
||||
subject,
|
||||
);
|
||||
if (!revoked) {
|
||||
revoked = true;
|
||||
await bind(value.runtime, undefined, 2, 'revoked');
|
||||
}
|
||||
return snapshot;
|
||||
},
|
||||
append: (...args) => value.runtime.projectPolicy.append(...args),
|
||||
};
|
||||
const mutationId = '00000000-0000-4000-8000-000000000004';
|
||||
await assert.rejects(
|
||||
service(value, { projectPolicy }).put(request(mutationId)),
|
||||
LocalSecretAuthorizationFenceConflictError,
|
||||
);
|
||||
const database = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.equal(
|
||||
database
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS count FROM "QingLong3LocalSecretEnvelopes"',
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
database
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count FROM "QingLong3SecurityAuditEvents"
|
||||
WHERE event_id = ?`,
|
||||
)
|
||||
.get(mutationId).count,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('audit insertion failure rolls the encrypted envelope back', async (t) => {
|
||||
const value = await openStore(t);
|
||||
await bind(value.runtime, 'owner');
|
||||
const database = new DatabaseSync(value.databasePath);
|
||||
database.exec(`
|
||||
CREATE TRIGGER reject_secret_audit
|
||||
BEFORE INSERT ON "QingLong3SecurityAuditEvents"
|
||||
WHEN NEW.operation_id IN ('secret.create', 'secret.rotate')
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'audit unavailable');
|
||||
END
|
||||
`);
|
||||
database.close();
|
||||
|
||||
await assert.rejects(
|
||||
service(value).put(request('00000000-0000-4000-8000-000000000005')),
|
||||
LocalSecretAdministrationUnavailableError,
|
||||
);
|
||||
const check = new DatabaseSync(value.databasePath, { readOnly: true });
|
||||
try {
|
||||
assert.equal(
|
||||
check
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS count FROM "QingLong3LocalSecretEnvelopes"',
|
||||
)
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
check
|
||||
.prepare('SELECT COUNT(*) AS count FROM "QingLong3SecurityAuditEvents"')
|
||||
.get().count,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
check.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('semantic replay returns no plaintext and conflicting replay fails closed', async (t) => {
|
||||
const value = await openStore(t);
|
||||
await bind(value.runtime, 'owner');
|
||||
const mutationId = '00000000-0000-4000-8000-000000000006';
|
||||
const admin = service(value);
|
||||
assert.equal((await admin.put(request(mutationId))).status, 'inserted');
|
||||
assert.equal((await admin.put(request(mutationId))).status, 'existing');
|
||||
await assert.rejects(
|
||||
admin.put(request(mutationId, { plaintext: 'different' })),
|
||||
LocalSecretMutationConflictError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LocalSecurityAuditQueryAuthorizationError,
|
||||
LocalSecurityAuditQueryConfigurationError,
|
||||
createLocalSecurityAuditQueryService,
|
||||
} = require('@qinglong/local-admin/security-audit-query');
|
||||
const {
|
||||
LocalSecurityAuditQueryAuthorizationFenceConflictError,
|
||||
} = require('@qinglong/runtime-core/local-security-audit-query');
|
||||
|
||||
const PRINCIPAL = Object.freeze({
|
||||
subject: Object.freeze({ type: 'user', id: 'owner-user' }),
|
||||
authenticationId: 'local_security_audit:test',
|
||||
authenticatedAtMs: 1_000,
|
||||
expiresAtMs: 61_000,
|
||||
assurance: 'local_console',
|
||||
});
|
||||
|
||||
function projectPolicy(role = 'owner') {
|
||||
return {
|
||||
async resolve(projectId, subject) {
|
||||
return {
|
||||
project: {
|
||||
id: projectId,
|
||||
name: 'Default',
|
||||
slug: 'default',
|
||||
status: 'active',
|
||||
version: 4,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
},
|
||||
binding: {
|
||||
projectId,
|
||||
subject,
|
||||
version: 7,
|
||||
state: 'active',
|
||||
role,
|
||||
mutationId: 'owner-binding',
|
||||
changedBy: subject,
|
||||
createdAtMs: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
async append() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function request(overrides = {}) {
|
||||
return {
|
||||
authorityProjectId: 'default',
|
||||
query: {
|
||||
limit: 2,
|
||||
before: {
|
||||
occurredAtMs: 10_000,
|
||||
eventId: '91000000-0000-4000-8000-000000000001',
|
||||
},
|
||||
filter: {
|
||||
projectId: 'project-alpha',
|
||||
subject: { type: 'agent', id: 'planner' },
|
||||
outcome: 'denied',
|
||||
},
|
||||
},
|
||||
auditEventId: '92000000-0000-4000-8000-000000000001',
|
||||
requestId: 'audit-query-1',
|
||||
principal: PRINCIPAL,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('authorizes an instance Owner and preserves bounded filter/cursor semantics', async () => {
|
||||
let command;
|
||||
const repository = {
|
||||
async listAuthorized(value) {
|
||||
command = value;
|
||||
return {
|
||||
records: [
|
||||
{
|
||||
eventId: '93000000-0000-4000-8000-000000000001',
|
||||
requestId: 'denied-request',
|
||||
operationId: 'tool.invoke',
|
||||
projectId: 'project-alpha',
|
||||
subject: { type: 'agent', id: 'planner' },
|
||||
authenticationId: 'private-authentication-id',
|
||||
outcome: 'denied',
|
||||
reasons: ['permission_missing'],
|
||||
fence: { projectVersion: 2, bindingVersion: 3 },
|
||||
occurredAtMs: 9_999,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
audit: value.audit,
|
||||
};
|
||||
},
|
||||
async record() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
const service = createLocalSecurityAuditQueryService(
|
||||
projectPolicy(),
|
||||
repository,
|
||||
{ now: () => 2_000 },
|
||||
);
|
||||
const result = await service.list(request());
|
||||
assert.equal(result.records.length, 1);
|
||||
assert.deepEqual(command.query, request().query);
|
||||
assert.deepEqual(command.authorization, {
|
||||
authorityProjectId: 'default',
|
||||
actor: PRINCIPAL.subject,
|
||||
fence: { projectVersion: 4, bindingVersion: 7 },
|
||||
});
|
||||
assert.deepEqual(command.audit, {
|
||||
eventId: '92000000-0000-4000-8000-000000000001',
|
||||
requestId: 'audit-query-1',
|
||||
operationId: 'security.audit.list',
|
||||
projectId: 'default',
|
||||
subject: PRINCIPAL.subject,
|
||||
authenticationId: PRINCIPAL.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: ['instance_authority_security_audit_query'],
|
||||
fence: { projectVersion: 4, bindingVersion: 7 },
|
||||
occurredAtMs: 2_000,
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects pages above the Edge/Standalone local cap before repository access', async () => {
|
||||
let accessed = false;
|
||||
const service = createLocalSecurityAuditQueryService(
|
||||
projectPolicy(),
|
||||
{
|
||||
async listAuthorized() {
|
||||
accessed = true;
|
||||
throw new Error('must not run');
|
||||
},
|
||||
async record() {
|
||||
accessed = true;
|
||||
},
|
||||
},
|
||||
{ now: () => 2_000 },
|
||||
);
|
||||
await assert.rejects(
|
||||
service.list(
|
||||
request({
|
||||
query: { limit: 65, filter: {} },
|
||||
}),
|
||||
),
|
||||
LocalSecurityAuditQueryConfigurationError,
|
||||
);
|
||||
assert.equal(accessed, false);
|
||||
});
|
||||
|
||||
test('records and rejects a non-Owner without exposing audit rows', async () => {
|
||||
const audits = [];
|
||||
let listed = false;
|
||||
const service = createLocalSecurityAuditQueryService(
|
||||
projectPolicy('viewer'),
|
||||
{
|
||||
async listAuthorized() {
|
||||
listed = true;
|
||||
throw new Error('must not run');
|
||||
},
|
||||
async record(audit) {
|
||||
audits.push(audit);
|
||||
},
|
||||
},
|
||||
{ now: () => 2_000 },
|
||||
);
|
||||
await assert.rejects(
|
||||
service.list(request({ query: { limit: 1, filter: {} } })),
|
||||
LocalSecurityAuditQueryAuthorizationError,
|
||||
);
|
||||
assert.equal(listed, false);
|
||||
assert.equal(audits.length, 1);
|
||||
assert.equal(audits[0].outcome, 'denied');
|
||||
assert.deepEqual(audits[0].reasons, ['permission_missing']);
|
||||
});
|
||||
|
||||
test('preserves the final credential/policy TOCTOU fence conflict', async () => {
|
||||
const service = createLocalSecurityAuditQueryService(
|
||||
projectPolicy(),
|
||||
{
|
||||
async listAuthorized() {
|
||||
throw new LocalSecurityAuditQueryAuthorizationFenceConflictError();
|
||||
},
|
||||
async record() {},
|
||||
},
|
||||
{ now: () => 2_000 },
|
||||
);
|
||||
await assert.rejects(
|
||||
service.list(request({ query: { limit: 1, filter: {} } })),
|
||||
LocalSecurityAuditQueryAuthorizationFenceConflictError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
LocalSecurityAuditRetentionAuthorizationError,
|
||||
LocalSecurityAuditRetentionConfigurationError,
|
||||
createLocalSecurityAuditRetentionService,
|
||||
} = require('@qinglong/local-admin/security-audit-retention');
|
||||
const {
|
||||
LocalSecurityAuditRetentionAuthorizationFenceConflictError,
|
||||
MIN_LOCAL_SECURITY_AUDIT_RETENTION_MS,
|
||||
} = require('@qinglong/runtime-core/local-security-audit-retention');
|
||||
|
||||
const NOW = 4_000_000_000;
|
||||
const PRINCIPAL = Object.freeze({
|
||||
subject: Object.freeze({ type: 'user', id: 'owner-user' }),
|
||||
authenticationId: 'local_security_audit:test',
|
||||
authenticatedAtMs: NOW - 1_000,
|
||||
expiresAtMs: NOW + 60_000,
|
||||
assurance: 'local_console',
|
||||
});
|
||||
|
||||
function projectPolicy(role = 'owner') {
|
||||
return {
|
||||
async resolve(projectId, subject) {
|
||||
return {
|
||||
project: {
|
||||
id: projectId,
|
||||
name: 'Default',
|
||||
slug: 'default',
|
||||
status: 'active',
|
||||
version: 4,
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
},
|
||||
binding: {
|
||||
projectId,
|
||||
subject,
|
||||
version: 7,
|
||||
state: 'active',
|
||||
role,
|
||||
mutationId: 'owner-binding',
|
||||
changedBy: subject,
|
||||
createdAtMs: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
async append() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function request(overrides = {}) {
|
||||
return {
|
||||
authorityProjectId: 'default',
|
||||
retentionMs: MIN_LOCAL_SECURITY_AUDIT_RETENTION_MS,
|
||||
eligibleBeforeMs: NOW - MIN_LOCAL_SECURITY_AUDIT_RETENTION_MS,
|
||||
limit: 64,
|
||||
mutationId: 'a1000000-0000-4000-8000-000000000001',
|
||||
requestId: 'audit-compact-1',
|
||||
failureAuditEventId: 'a1000000-0000-4000-8000-000000000002',
|
||||
principal: PRINCIPAL,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('authorizes an instance Owner and binds the exact retention command', async () => {
|
||||
let command;
|
||||
const repository = {
|
||||
async resolveCompaction() {
|
||||
return null;
|
||||
},
|
||||
async compactAuthorized(value) {
|
||||
command = value;
|
||||
return {
|
||||
status: 'inserted',
|
||||
record: {
|
||||
mutationId: value.mutationId,
|
||||
requestId: value.requestId,
|
||||
authorityProjectId: value.authorization.authorityProjectId,
|
||||
retentionMs: value.retentionMs,
|
||||
eligibleBeforeMs: value.eligibleBeforeMs,
|
||||
batchLimit: value.limit,
|
||||
deletedCount: 0,
|
||||
deletedPayloadBytes: 0,
|
||||
first: null,
|
||||
last: null,
|
||||
recordsDigest: 'a'.repeat(64),
|
||||
createdAtMs: value.audit.occurredAtMs,
|
||||
},
|
||||
audit: value.audit,
|
||||
};
|
||||
},
|
||||
async record() {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
const service = createLocalSecurityAuditRetentionService(
|
||||
projectPolicy(),
|
||||
repository,
|
||||
{ now: () => NOW },
|
||||
);
|
||||
const result = await service.compact(request());
|
||||
assert.equal(result.status, 'inserted');
|
||||
assert.deepEqual(command.authorization, {
|
||||
authorityProjectId: 'default',
|
||||
actor: PRINCIPAL.subject,
|
||||
fence: { projectVersion: 4, bindingVersion: 7 },
|
||||
});
|
||||
assert.deepEqual(command.audit, {
|
||||
eventId: request().mutationId,
|
||||
requestId: request().requestId,
|
||||
operationId: 'security.audit.compact',
|
||||
projectId: 'default',
|
||||
subject: PRINCIPAL.subject,
|
||||
authenticationId: PRINCIPAL.authenticationId,
|
||||
outcome: 'allowed',
|
||||
reasons: ['instance_authority_security_audit_compaction'],
|
||||
fence: { projectVersion: 4, bindingVersion: 7 },
|
||||
occurredAtMs: NOW,
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects an unsafe retention fence and oversized batch before repository access', async () => {
|
||||
let accessed = false;
|
||||
const service = createLocalSecurityAuditRetentionService(
|
||||
projectPolicy(),
|
||||
{
|
||||
async resolveCompaction() {
|
||||
accessed = true;
|
||||
},
|
||||
async compactAuthorized() {
|
||||
accessed = true;
|
||||
},
|
||||
async record() {
|
||||
accessed = true;
|
||||
},
|
||||
},
|
||||
{ now: () => NOW },
|
||||
);
|
||||
await assert.rejects(
|
||||
service.compact(
|
||||
request({
|
||||
eligibleBeforeMs: NOW - MIN_LOCAL_SECURITY_AUDIT_RETENTION_MS + 1,
|
||||
}),
|
||||
),
|
||||
LocalSecurityAuditRetentionConfigurationError,
|
||||
);
|
||||
await assert.rejects(
|
||||
service.compact(request({ limit: 513 })),
|
||||
LocalSecurityAuditRetentionConfigurationError,
|
||||
);
|
||||
assert.equal(accessed, false);
|
||||
});
|
||||
|
||||
test('records denial with the failure identity for a non-Owner', async () => {
|
||||
const audits = [];
|
||||
let compacted = false;
|
||||
const service = createLocalSecurityAuditRetentionService(
|
||||
projectPolicy('viewer'),
|
||||
{
|
||||
async resolveCompaction() {
|
||||
return null;
|
||||
},
|
||||
async compactAuthorized() {
|
||||
compacted = true;
|
||||
throw new Error('must not run');
|
||||
},
|
||||
async record(audit) {
|
||||
audits.push(audit);
|
||||
},
|
||||
},
|
||||
{ now: () => NOW },
|
||||
);
|
||||
await assert.rejects(
|
||||
service.compact(request()),
|
||||
LocalSecurityAuditRetentionAuthorizationError,
|
||||
);
|
||||
assert.equal(compacted, false);
|
||||
assert.equal(audits.length, 1);
|
||||
assert.equal(audits[0].eventId, request().failureAuditEventId);
|
||||
assert.equal(audits[0].outcome, 'denied');
|
||||
assert.deepEqual(audits[0].reasons, ['permission_missing']);
|
||||
});
|
||||
|
||||
test('preserves the final credential and authority fence conflict', async () => {
|
||||
const service = createLocalSecurityAuditRetentionService(
|
||||
projectPolicy(),
|
||||
{
|
||||
async resolveCompaction() {
|
||||
return null;
|
||||
},
|
||||
async compactAuthorized() {
|
||||
throw new LocalSecurityAuditRetentionAuthorizationFenceConflictError();
|
||||
},
|
||||
async record() {},
|
||||
},
|
||||
{ now: () => NOW },
|
||||
);
|
||||
await assert.rejects(
|
||||
service.compact(request()),
|
||||
LocalSecurityAuditRetentionAuthorizationFenceConflictError,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user