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

402 lines
12 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 {
openLocalSqliteAdoptionDatabase,
} = require('@qinglong/local-sqlite/adoption');
const {
LocalSecretConfigApplicationConflictError,
openLocalSqliteSecretConfigApplicationDatabase,
} = require('@qinglong/local-sqlite/secret-config-application');
const SUBJECT = Object.freeze({ type: 'user', id: 'local-owner' });
const FENCE = Object.freeze({ projectVersion: 1, bindingVersion: 1 });
const ADOPTION_MUTATION = '12345678-1234-4123-8123-123456789abc';
const APPLICATION_MUTATION = '87654321-4321-4123-8123-cba987654321';
const SECRET_MUTATION = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee';
function digest(character) {
return character.repeat(64);
}
function fixture(t) {
const directory = fs.mkdtempSync(
path.join(os.tmpdir(), 'ql3-secret-config-application-'),
);
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',
'secret-config-owner-binding', 'user', 'local-owner', 1)`,
)
.run();
client.close();
const adoption = await openLocalSqliteAdoptionDatabase({
databasePath,
profile: 'edge',
});
await adoption.publisher.publish({
mutationId: ADOPTION_MUTATION,
decisionId: '019f7200-0000-7000-8000-000000000001',
projectId: 'default',
profile: 'edge',
planDigest: digest('1'),
inventoryDigest: digest('2'),
decisionDigest: digest('3'),
receiptDigest: digest('4'),
authorizationFileDigest: digest('5'),
rowCount: 1,
skippedCount: 0,
subject: SUBJECT,
fence: FENCE,
audit: {
eventId: ADOPTION_MUTATION,
requestId: 'automation-adoption',
operationId: 'task.adopt',
projectId: 'default',
subject: SUBJECT,
authenticationId: 'local-console:review',
outcome: 'allowed',
reasons: ['project_role_allowed'],
fence: FENCE,
occurredAtMs: 100,
},
candidates: [
{
rowOrdinal: 1,
sourceDigest: digest('6'),
task: {
taskId: 'legacy-cron:1',
name: 'Legacy Task',
kind: 'command',
spec: {
schema: 'qinglong/command@v1',
config: {
command: { kind: 'argv', file: '/bin/echo', args: ['legacy'] },
},
},
labels: { source: 'legacy-adoption' },
enabled: true,
},
triggers: [
{
triggerId: 'legacy-cron:1:cron:1',
spec: {
schema: 'qinglong/cron@v1',
config: {
expression: '0 0 * * *',
timezone: 'UTC',
misfirePolicy: 'skip',
},
},
enabled: true,
},
],
},
],
confirmExternalAuthority() {},
createdAtMs: 100,
});
await adoption.close();
return databasePath;
}
function applicationCommand(confirmExternalAuthority = () => {}) {
return {
mutationId: APPLICATION_MUTATION,
projectId: 'default',
profile: 'edge',
secretConfigPlanDigest: digest('7'),
decisionDigest: digest('8'),
candidateSetDigest: digest('9'),
automationAdoptionSetDigest: digest('a'),
subject: SUBJECT,
fence: FENCE,
audit: {
eventId: APPLICATION_MUTATION,
requestId: 'secret-config-application',
operationId: 'secret-config.apply',
projectId: 'default',
subject: SUBJECT,
authenticationId: 'local-console:review',
outcome: 'allowed',
reasons: ['project_role_allowed'],
fence: FENCE,
occurredAtMs: 200,
},
secrets: [
{
ordinal: 1,
disposition: 'active_binding',
candidateDigest: digest('b'),
sourceSetDigest: digest('c'),
environmentName: 'LEGACY_TOKEN',
envelope: {
projectId: 'default',
name: 'legacy-db-env-bbbbbbbbbbbbbbbb',
version: 1,
mutationId: SECRET_MUTATION,
keyId: 'active-key',
algorithm: 'aes-256-gcm',
nonce: Buffer.alloc(12, 1).toString('base64url'),
ciphertext: Buffer.from('ciphertext').toString('base64url'),
authTag: Buffer.alloc(16, 2).toString('base64url'),
createdAtMs: 200,
},
audit: {
eventId: SECRET_MUTATION,
requestId: 'secret-config-application',
operationId: 'secret.create',
projectId: 'default',
subject: SUBJECT,
authenticationId: 'local-console:review',
outcome: 'allowed',
reasons: ['project_role_allowed'],
fence: FENCE,
occurredAtMs: 200,
},
},
],
appliedAtMs: 200,
confirmExternalAuthority,
};
}
test('atomically binds Secret, Task, dispatch, Trigger, schedule and replay ledger', async (t) => {
const databasePath = await preparedDatabase(t);
const database = await openLocalSqliteSecretConfigApplicationDatabase({
databasePath,
profile: 'edge',
});
const command = applicationCommand();
const inserted = await database.publisher.publish(command);
assert.equal(inserted.status, 'inserted');
assert.equal(inserted.application.receipt.activeBindingCount, 1);
assert.equal(inserted.application.receipt.taskCount, 1);
assert.equal(inserted.application.receipt.triggerCount, 1);
assert.equal((await database.publisher.publish(command)).status, 'existing');
await database.close();
const client = new DatabaseSync(databasePath, { readOnly: true });
assert.equal(
client
.prepare(
`SELECT "current_revision" AS revision FROM "QingLong3TaskDefinitions" WHERE "task_id" = 'legacy-cron:1'`,
)
.get().revision,
2,
);
assert.equal(
client
.prepare(
`SELECT "current_revision" AS revision FROM "QingLong3Triggers" WHERE "trigger_id" = 'legacy-cron:1:cron:1'`,
)
.get().revision,
2,
);
assert.equal(
client
.prepare(
`SELECT "trigger_revision" AS revision FROM "QingLong3LocalTriggerSchedules" WHERE "trigger_id" = 'legacy-cron:1:cron:1'`,
)
.get().revision,
2,
);
assert.equal(
client
.prepare(
`SELECT count(*) AS count FROM "QingLong3LocalSecretEnvelopes" WHERE "secret_name" = 'legacy-db-env-bbbbbbbbbbbbbbbb'`,
)
.get().count,
1,
);
assert.equal(
client
.prepare(
`SELECT count(*) AS count FROM "QingLong3LocalTaskExecutionRevisions" WHERE "task_id" = 'legacy-cron:1' AND "task_revision" LIKE 'qltd:v1:2:%'`,
)
.get().count,
1,
);
client.close();
});
test('rolls every database mutation back when the commit authority changes', async (t) => {
const databasePath = await preparedDatabase(t);
const database = await openLocalSqliteSecretConfigApplicationDatabase({
databasePath,
profile: 'edge',
});
let checks = 0;
await assert.rejects(
database.publisher.publish(
applicationCommand(() => {
checks += 1;
if (checks === 2) throw new Error('instance head drifted');
}),
),
/unavailable/,
);
await database.close();
const client = new DatabaseSync(databasePath, { readOnly: true });
assert.equal(
client
.prepare(
`SELECT count(*) AS count FROM "QingLong3SecretConfigApplications"`,
)
.get().count,
0,
);
assert.equal(
client
.prepare(
`SELECT "current_revision" AS revision FROM "QingLong3TaskDefinitions" WHERE "task_id" = 'legacy-cron:1'`,
)
.get().revision,
1,
);
assert.equal(
client
.prepare(
`SELECT count(*) AS count FROM "QingLong3LocalSecretEnvelopes" WHERE "secret_name" = 'legacy-db-env-bbbbbbbbbbbbbbbb'`,
)
.get().count,
0,
);
client.close();
});
test('fails closed on occupied Secret without advancing Task or Trigger heads', async (t) => {
const databasePath = await preparedDatabase(t);
const client = new DatabaseSync(databasePath);
client
.prepare(
`INSERT INTO "QingLong3LocalSecretEnvelopes" ("project_id", "secret_name", "version", "mutation_id", "key_id", "algorithm", "nonce", "ciphertext", "auth_tag", "created_at_ms") VALUES ('default', 'legacy-db-env-bbbbbbbbbbbbbbbb', 1, '11111111-2222-4333-8444-555555555555', 'other-key', 'aes-256-gcm', ?, ?, ?, 150)`,
)
.run(Buffer.alloc(12), Buffer.from('occupied'), Buffer.alloc(16));
client.close();
const database = await openLocalSqliteSecretConfigApplicationDatabase({
databasePath,
profile: 'edge',
});
await assert.rejects(
database.publisher.publish(applicationCommand()),
LocalSecretConfigApplicationConflictError,
);
await database.close();
const inspected = new DatabaseSync(databasePath, { readOnly: true });
assert.equal(
inspected
.prepare(
`SELECT "current_revision" AS revision FROM "QingLong3TaskDefinitions" WHERE "task_id" = 'legacy-cron:1'`,
)
.get().revision,
1,
);
assert.equal(
inspected
.prepare(
`SELECT count(*) AS count FROM "QingLong3SecretConfigApplications"`,
)
.get().count,
0,
);
inspected.close();
});
test('rejects incomplete Trigger provenance and rolls the streamed transaction back', async (t) => {
const databasePath = await preparedDatabase(t);
const client = new DatabaseSync(databasePath);
client.prepare(`DELETE FROM "QingLong3LegacyAdoptionTriggers"`).run();
client.close();
const database = await openLocalSqliteSecretConfigApplicationDatabase({
databasePath,
profile: 'edge',
});
await assert.rejects(
database.publisher.publish(applicationCommand()),
LocalSecretConfigApplicationConflictError,
);
await database.close();
const inspected = new DatabaseSync(databasePath, { readOnly: true });
assert.equal(
inspected
.prepare(
`SELECT "current_revision" AS revision FROM "QingLong3TaskDefinitions" WHERE "task_id" = 'legacy-cron:1'`,
)
.get().revision,
1,
);
assert.equal(
inspected
.prepare(
`SELECT count(*) AS count FROM "QingLong3SecretConfigApplicationTasks"`,
)
.get().count,
0,
);
inspected.close();
});
test('rejects replay after the durable Trigger schedule drifts', async (t) => {
const databasePath = await preparedDatabase(t);
const database = await openLocalSqliteSecretConfigApplicationDatabase({
databasePath,
profile: 'edge',
});
const command = applicationCommand();
await database.publisher.publish(command);
await database.close();
const client = new DatabaseSync(databasePath);
client
.prepare(
`UPDATE "QingLong3LocalTriggerSchedules" SET "trigger_revision" = 1 WHERE "trigger_id" = 'legacy-cron:1:cron:1'`,
)
.run();
client.close();
const reopened = await openLocalSqliteSecretConfigApplicationDatabase({
databasePath,
profile: 'edge',
});
await assert.rejects(
reopened.publisher.publish(command),
LocalSecretConfigApplicationConflictError,
);
await reopened.close();
});
test('rejects empty applications and Secret timestamps outside the application instant', async (t) => {
const databasePath = await preparedDatabase(t);
const database = await openLocalSqliteSecretConfigApplicationDatabase({
databasePath,
profile: 'edge',
});
assert.throws(
() => database.publisher.publish({ ...applicationCommand(), secrets: [] }),
LocalSecretConfigApplicationConflictError,
);
const command = applicationCommand();
command.secrets[0].envelope.createdAtMs = command.appliedAtMs - 1;
assert.throws(
() => database.publisher.publish(command),
LocalSecretConfigApplicationConflictError,
);
await database.close();
});