Files
qinglong/packages/ql3-local-sqlite/test/adoptionPublisher.test.cjs
T

267 lines
7.8 KiB
JavaScript

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 { migrateLocalSqlitePath } = require('../dist/migration/migration');
const {
LocalLegacyAdoptionAuthorizationFenceConflictError,
LocalLegacyAdoptionConflictError,
openLocalSqliteAdoptionDatabase,
} = require('@qinglong/local-sqlite/adoption');
const MUTATION_ID = '12345678-1234-4123-8123-123456789abc';
const DECISION_ID = '019f7200-0000-7000-8000-000000000001';
const SUBJECT = Object.freeze({ type: 'user', id: 'local-owner' });
const DIGEST = 'a'.repeat(64);
function fixture(t) {
const directory = fs.mkdtempSync(
path.join(os.tmpdir(), 'ql3-adoption-publisher-'),
);
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
return path.join(directory, 'qinglong3.sqlite');
}
async function preparedDatabase(t) {
const databasePath = fixture(t);
await migrateLocalSqlitePath({ databasePath, profile: 'edge' });
const client = new DatabaseSync(databasePath);
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', 'local-owner', 1, 'active', 'owner',
'test-owner-binding', 'user', 'local-owner', 1)`,
)
.run();
client.close();
return databasePath;
}
function candidate(index, taskId = `legacy-cron:${index}`) {
return Object.freeze({
rowOrdinal: index,
sourceDigest: String(index).padStart(64, '0'),
task: Object.freeze({
taskId,
name: `Legacy Task ${index}`,
kind: 'command',
spec: Object.freeze({
schema: 'qinglong/command@v1',
config: Object.freeze({
command: Object.freeze({
kind: 'argv',
file: '/bin/echo',
args: Object.freeze([String(index)]),
}),
}),
}),
labels: Object.freeze({ source: 'legacy-adoption' }),
enabled: true,
}),
triggers: Object.freeze([
Object.freeze({
triggerId: `${taskId}:cron:1`,
spec: Object.freeze({
schema: 'qinglong/cron@v1',
config: Object.freeze({
expression: `${index} 0 * * *`,
timezone: 'UTC',
misfirePolicy: 'skip',
}),
}),
enabled: true,
}),
]),
});
}
function command(candidates, overrides = {}) {
return {
mutationId: MUTATION_ID,
decisionId: DECISION_ID,
projectId: 'default',
profile: 'edge',
planDigest: DIGEST,
inventoryDigest: 'b'.repeat(64),
decisionDigest: 'c'.repeat(64),
receiptDigest: 'd'.repeat(64),
authorizationFileDigest: 'e'.repeat(64),
rowCount: candidates.length,
skippedCount: 0,
subject: SUBJECT,
fence: Object.freeze({ projectVersion: 1, bindingVersion: 1 }),
audit: Object.freeze({
eventId: MUTATION_ID,
requestId: 'legacy-adoption-test',
operationId: 'task.adopt',
projectId: 'default',
subject: SUBJECT,
authenticationId: 'local-console:adoption-review',
outcome: 'allowed',
reasons: Object.freeze(['project_role_allowed']),
fence: Object.freeze({ projectVersion: 1, bindingVersion: 1 }),
occurredAtMs: 100,
}),
candidates,
confirmExternalAuthority() {},
createdAtMs: 100,
...overrides,
};
}
test('publishes tasks, execution facts, triggers, audit and ledger atomically', async (t) => {
const databasePath = await preparedDatabase(t);
const adoption = await openLocalSqliteAdoptionDatabase({
databasePath,
profile: 'edge',
});
const input = command([candidate(1), candidate(2)]);
const inserted = await adoption.publisher.publish(input);
assert.equal(inserted.status, 'inserted');
assert.equal(inserted.adoption.adoptedTaskCount, 2);
assert.equal(inserted.adoption.adoptedTriggerCount, 2);
assert.match(inserted.adoption.publicationDigest, /^[0-9a-f]{64}$/);
assert.equal((await adoption.publisher.publish(input)).status, 'existing');
await adoption.close();
const client = new DatabaseSync(databasePath, { readOnly: true });
assert.equal(
client
.prepare('SELECT COUNT(*) AS count FROM "QingLong3TaskDefinitions"')
.get().count,
2,
);
assert.equal(
client.prepare('SELECT COUNT(*) AS count FROM "QingLong3Triggers"').get()
.count,
2,
);
assert.equal(
client
.prepare(
'SELECT COUNT(*) AS count FROM "QingLong3LocalTriggerSchedules" WHERE "next_fire_at_ms" IS NULL',
)
.get().count,
2,
);
assert.equal(
client
.prepare(
'SELECT COUNT(*) AS count FROM "QingLong3LocalTaskExecutionRevisions"',
)
.get().count,
2,
);
assert.deepEqual(
{
...client
.prepare(
`SELECT "operation_id" AS operationId, "outcome" AS outcome
FROM "QingLong3SecurityAuditEvents" WHERE "event_id" = ?`,
)
.get(MUTATION_ID),
},
{ operationId: 'task.adopt', outcome: 'allowed' },
);
client.close();
});
test('rolls the complete publication back on a later candidate conflict', async (t) => {
const databasePath = await preparedDatabase(t);
const adoption = await openLocalSqliteAdoptionDatabase({
databasePath,
profile: 'edge',
});
await assert.rejects(
adoption.publisher.publish(
command([candidate(1, 'duplicate-task'), candidate(2, 'duplicate-task')]),
),
LocalLegacyAdoptionConflictError,
);
await adoption.close();
const client = new DatabaseSync(databasePath, { readOnly: true });
for (const table of [
'QingLong3TaskDefinitions',
'QingLong3Triggers',
'QingLong3LocalTriggerSchedules',
'QingLong3LegacyAdoptions',
'QingLong3SecurityAuditEvents',
]) {
assert.equal(
client.prepare(`SELECT COUNT(*) AS count FROM "${table}"`).get().count,
0,
table,
);
}
client.close();
});
test('awaits the final external authority check and rolls back on rejection', async (t) => {
const databasePath = await preparedDatabase(t);
const adoption = await openLocalSqliteAdoptionDatabase({
databasePath,
profile: 'edge',
});
let checked = false;
const authorityFailure = new Error('external authority changed');
await assert.rejects(
adoption.publisher.publish(
command([candidate(1)], {
async confirmExternalAuthority() {
await Promise.resolve();
checked = true;
throw authorityFailure;
},
}),
),
{
name: 'LocalLegacyAdoptionUnavailableError',
code: 'LOCAL_LEGACY_ADOPTION_UNAVAILABLE',
cause: authorityFailure,
},
);
assert.equal(checked, true);
await adoption.close();
const client = new DatabaseSync(databasePath, { readOnly: true });
for (const table of [
'QingLong3TaskDefinitions',
'QingLong3Triggers',
'QingLong3LocalTriggerSchedules',
'QingLong3LegacyAdoptions',
'QingLong3SecurityAuditEvents',
]) {
assert.equal(
client.prepare(`SELECT COUNT(*) AS count FROM "${table}"`).get().count,
0,
table,
);
}
client.close();
});
test('rejects stale authorization fences before any adoption mutation', async (t) => {
const databasePath = await preparedDatabase(t);
const client = new DatabaseSync(databasePath);
client.exec(
`UPDATE "QingLong3Projects" SET "version" = 2, "updated_at_ms" = 2
WHERE "id" = 'default'`,
);
client.close();
const adoption = await openLocalSqliteAdoptionDatabase({
databasePath,
profile: 'edge',
});
await assert.rejects(
adoption.publisher.publish(command([candidate(1)])),
LocalLegacyAdoptionAuthorizationFenceConflictError,
);
await adoption.close();
});