Files
qinglong/packages/ql3-local-admin/test/adoption.test.cjs
T

1474 lines
46 KiB
JavaScript

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 { spawnSync } = require('node:child_process');
const { DatabaseSync } = require('node:sqlite');
const { test } = require('node:test');
const {
LocalSqliteAdoptionError,
acquireLocalSqliteActivation,
createReviewedLegacyCrontabAdoptionDecisionReceipt,
inspectLegacyCrontabAdoptionDiagnostics,
inspectLegacySqlitePath,
prepareLocalSqliteActivation,
publishReviewedLegacyCrontabAdoption,
publishReviewedLegacyCrontabAdoptionDecisionAuthorizationFile,
stageLocalSqliteAdoption,
verifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFile,
verifyReviewedLegacyCrontabAdoptionDecisionReceipt,
verifyLocalSqliteAdoption,
} = require('..');
const { migrateLocalSqlitePath } = require('@qinglong/local-sqlite/migration');
const {
visitLegacyCrontabAdoptionInspections,
} = require('../dist/legacy-adoption/legacyCrontabAdoption');
const REVIEWED_AT_MS = 1_760_000_000_000;
const REVIEWER = Object.freeze({
subject: Object.freeze({ type: 'user', id: 'local-owner' }),
authenticationId: 'local-console:adoption-review',
authenticatedAtMs: REVIEWED_AT_MS - 1_000,
expiresAtMs: REVIEWED_AT_MS + 60 * 60 * 1_000,
assurance: 'local_console',
});
const AUTHORIZATION_KEY_ID = 'qlsk-adoption-review-test';
const AUTHORIZATION_KEY = Buffer.alloc(32, 0x5a);
function authorizationKeyProvider(key = AUTHORIZATION_KEY) {
return Object.freeze({
async active() {
return {
keyId: AUTHORIZATION_KEY_ID,
key: Uint8Array.from(key),
};
},
async resolve(keyId) {
return keyId === AUTHORIZATION_KEY_ID
? {
keyId,
key: Uint8Array.from(key),
}
: null;
},
});
}
function decisionFor(diagnostic) {
if (diagnostic.classification === 'lossless') {
return {
rowOrdinal: diagnostic.rowOrdinal,
sourceDigest: diagnostic.sourceDigest,
disposition: 'adopt',
reason: 'reviewed_lossless',
};
}
if (diagnostic.classification === 'requires_shell_compatibility') {
return {
rowOrdinal: diagnostic.rowOrdinal,
sourceDigest: diagnostic.sourceDigest,
disposition: 'adopt_shell_compatibility',
reason: 'reviewed_shell_compatibility',
};
}
return {
rowOrdinal: diagnostic.rowOrdinal,
sourceDigest: diagnostic.sourceDigest,
disposition: 'skip',
reason:
diagnostic.classification === 'malformed'
? 'malformed_source'
: 'unsupported_semantics',
};
}
function sha256(filePath) {
return createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
}
function fixture(t) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-adoption-'));
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, command TEXT NOT NULL, schedule TEXT
);
CREATE TABLE "Envs" (
id INTEGER PRIMARY KEY, name TEXT, value TEXT
);
CREATE TABLE "PluginOwnedState" (
id INTEGER PRIMARY KEY, payload TEXT NOT NULL
);
INSERT INTO "Crontabs" (id, command, schedule)
VALUES (1, 'echo preserved', '0 0 * * *');
INSERT INTO "PluginOwnedState" (id, payload)
VALUES (1, '{"preserved":true}');
`);
source.close();
return {
directory,
sourcePath,
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'),
};
}
test('produces a stable reviewed plan without changing the legacy source', (t) => {
const value = fixture(t);
const before = sha256(value.sourcePath);
const first = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'edge',
});
const second = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'edge',
});
assert.equal(first.planDigest, second.planDigest);
assert.equal(first.schemaVersion, 2);
assert.match(first.planDigest, /^[0-9a-f]{64}$/);
assert.equal(first.tasks.rowCount, 1);
assert.equal(first.tasks.classifications.requires_manual_action, 1);
assert.equal(first.tasks.mutationReady, false);
assert.deepEqual(first.catalog.tableNames, [
'Auths',
'Crontabs',
'Envs',
'PluginOwnedState',
]);
assert.equal(sha256(value.sourcePath), before);
});
test('classifies legacy tasks with bounded secret-safe diagnostic pages', (t) => {
const value = fixture(t);
const source = new DatabaseSync(value.sourcePath);
source.exec(`
DELETE FROM "Crontabs";
INSERT INTO "Crontabs" (id, command, schedule) VALUES
(1, 'task /scripts/lossless.sh', '0 0 * * *'),
(2, 'echo embedded-secret-value', '1 0 * * *'),
(3, 'task /scripts/manual.sh', '@boot'),
(4, '', '2 0 * * *');
`);
source.close();
const plan = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
});
assert.deepEqual(plan.tasks.classifications, {
lossless: 1,
requires_shell_compatibility: 1,
requires_manual_action: 1,
malformed: 1,
});
assert.equal(plan.tasks.timezone, 'UTC');
const first = inspectLegacyCrontabAdoptionDiagnostics({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
limit: 2,
});
assert.equal(first.reviewedPlanDigest, plan.planDigest);
assert.equal(first.truncated, true);
assert.deepEqual(
first.diagnostics.map(({ rowOrdinal, classification, triggerCount }) => ({
rowOrdinal,
classification,
triggerCount,
})),
[
{ rowOrdinal: 1, classification: 'lossless', triggerCount: 1 },
{
rowOrdinal: 2,
classification: 'requires_shell_compatibility',
triggerCount: 1,
},
],
);
assert.equal(JSON.stringify(first).includes('embedded-secret-value'), false);
assert.equal(JSON.stringify(first).includes('/scripts/lossless.sh'), false);
const second = inspectLegacyCrontabAdoptionDiagnostics({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
afterRowOrdinal: first.next.rowOrdinal,
limit: 2,
});
assert.equal(second.truncated, false);
assert.deepEqual(
second.diagnostics.map(({ classification, reasons }) => ({
classification,
reasons,
})),
[
{
classification: 'requires_manual_action',
reasons: ['schedule_boot_unsupported'],
},
{
classification: 'malformed',
reasons: ['command_invalid'],
},
],
);
});
test('keeps canonical adoption candidates internal and gates unmapped legacy semantics', (t) => {
const value = fixture(t);
const source = new DatabaseSync(value.sourcePath);
source.exec(`
ALTER TABLE "Crontabs" ADD COLUMN "name" TEXT;
ALTER TABLE "Crontabs" ADD COLUMN "isPinned" INTEGER;
ALTER TABLE "Crontabs" ADD COLUMN "sub_id" INTEGER;
DELETE FROM "Crontabs";
`);
const insert = source.prepare(
'INSERT INTO "Crontabs" (id, name, command, schedule, isPinned, sub_id) VALUES (?, ?, ?, ?, ?, ?)',
);
insert.run(1, 'Pinned task', 'task /scripts/pinned.sh', '0 0 * * *', 1, null);
insert.run(2, 'Subscribed task', 'task /scripts/sub.sh', '1 0 * * *', 0, 7);
insert.run(
3,
'invalid\u0001name',
'task /scripts/invalid.sh',
'2 0 * * *',
0,
null,
);
const inspections = [];
const inventory = visitLegacyCrontabAdoptionInspections(
source,
'UTC',
(inspection) => inspections.push(inspection),
);
source.close();
assert.deepEqual(inventory.classifications, {
lossless: 1,
requires_shell_compatibility: 0,
requires_manual_action: 1,
malformed: 1,
});
assert.equal(inspections[0].candidate.task.name, 'Pinned task');
assert.deepEqual(inspections[0].candidate.task.labels, {
'qinglong.io/legacy-pinned': 'true',
});
assert.equal(inspections[0].candidate.triggers.length, 1);
assert.equal(inspections[1].candidate, undefined);
assert.deepEqual(inspections[1].diagnostic.reasons, [
'subscription_binding_requires_mapping',
]);
assert.equal(inspections[2].candidate, undefined);
assert.deepEqual(inspections[2].diagnostic.reasons, ['legacy_field_invalid']);
assert.equal(
JSON.stringify(inspections[0].diagnostic).includes('pinned.sh'),
false,
);
assert.equal(
Object.hasOwn(require('..'), 'visitLegacyCrontabAdoptionInspections'),
false,
);
});
test('creates and verifies one complete strong-auth decision receipt', (t) => {
const value = fixture(t);
const source = new DatabaseSync(value.sourcePath);
source.exec(`
DELETE FROM "Crontabs";
INSERT INTO "Crontabs" (id, command, schedule) VALUES
(1, 'task /scripts/lossless.sh', '0 0 * * *'),
(2, 'echo shell-compatibility', '1 0 * * *'),
(3, 'task /scripts/manual.sh', '@boot'),
(4, '', '2 0 * * *');
`);
source.close();
const plan = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
});
const diagnosticPage = inspectLegacyCrontabAdoptionDiagnostics({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
});
const decisions = diagnosticPage.diagnostics.map(decisionFor);
const receipt = createReviewedLegacyCrontabAdoptionDecisionReceipt({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
decisionId: '018f0f5e-7b2d-7000-8000-000000000001',
reviewer: REVIEWER,
issuedAtMs: REVIEWED_AT_MS,
expiresAtMs: REVIEWED_AT_MS + 10 * 60 * 1_000,
decisions,
});
assert.equal(receipt.planDigest, plan.planDigest);
assert.equal(receipt.inventoryDigest, plan.tasks.inventoryDigest);
assert.deepEqual(receipt.decisions.dispositions, {
adopt: 1,
adopt_shell_compatibility: 1,
skip: 2,
});
assert.equal(receipt.decisions.rowCount, 4);
assert.match(receipt.decisions.decisionDigest, /^[0-9a-f]{64}$/);
assert.match(receipt.receiptDigest, /^[0-9a-f]{64}$/);
assert.equal(JSON.stringify(receipt).includes('/scripts/'), false);
assert.deepEqual(
verifyReviewedLegacyCrontabAdoptionDecisionReceipt({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
receipt: JSON.parse(JSON.stringify(receipt)),
decisions,
observedAtMs: REVIEWED_AT_MS + 1,
}),
receipt,
);
});
test('publishes and verifies one private authenticated decision file', async (t) => {
const value = fixture(t);
const source = new DatabaseSync(value.sourcePath);
source
.prepare('UPDATE "Crontabs" SET command = ? WHERE id = 1')
.run('task /scripts/authenticated.sh');
source.close();
const plan = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
});
const page = inspectLegacyCrontabAdoptionDiagnostics({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
});
const decisions = page.diagnostics.map(decisionFor);
const authorizationDirectory = path.join(value.directory, 'authorization');
fs.mkdirSync(authorizationDirectory, { mode: 0o700 });
const authorizationPath = path.join(
authorizationDirectory,
'018f0f5e-7b2d-7000-8000-000000000011.ndjson',
);
const options = {
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
decisionId: '018f0f5e-7b2d-7000-8000-000000000011',
reviewer: REVIEWER,
issuedAtMs: REVIEWED_AT_MS,
expiresAtMs: REVIEWED_AT_MS + 10 * 60 * 1_000,
decisions,
authorizationPath,
keyProvider: authorizationKeyProvider(),
};
const published =
await publishReviewedLegacyCrontabAdoptionDecisionAuthorizationFile(
options,
);
assert.equal(published.file.decisionCount, 1);
assert.equal(published.file.keyId, AUTHORIZATION_KEY_ID);
assert.match(published.file.fileDigest, /^[0-9a-f]{64}$/);
assert.equal(fs.statSync(authorizationPath).mode & 0o777, 0o600);
const material = fs.readFileSync(authorizationPath, 'utf8');
assert.equal(material.includes('/scripts/'), false);
assert.equal(material.includes('authenticated.sh'), false);
const verified =
await verifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
expectedDecisionId: options.decisionId,
authorizationPath,
keyProvider: authorizationKeyProvider(),
observedAtMs: REVIEWED_AT_MS + 1,
});
assert.deepEqual(verified, published);
await assert.rejects(
publishReviewedLegacyCrontabAdoptionDecisionAuthorizationFile(options),
/decision authorization publication failed/,
);
});
test('publishes one reviewed legacy task set into the target atomically', async (t) => {
const value = fixture(t);
const source = new DatabaseSync(value.sourcePath);
source
.prepare('UPDATE "Crontabs" SET command = ? WHERE id = 1')
.run('task /scripts/published.sh');
source.close();
const plan = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
});
const page = inspectLegacyCrontabAdoptionDiagnostics({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
});
const decisionId = '018f0f5e-7b2d-7000-8000-000000000015';
const authorizationDirectory = path.join(
value.directory,
'publication-authorization',
);
fs.mkdirSync(authorizationDirectory, { mode: 0o700 });
const authorizationPath = path.join(
authorizationDirectory,
`${decisionId}.ndjson`,
);
await publishReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
decisionId,
reviewer: REVIEWER,
issuedAtMs: REVIEWED_AT_MS,
expiresAtMs: REVIEWED_AT_MS + 10 * 60 * 1_000,
decisions: page.diagnostics.map(decisionFor),
authorizationPath,
keyProvider: authorizationKeyProvider(),
});
await migrateLocalSqlitePath({
databasePath: value.targetPath,
profile: 'edge',
});
const target = new DatabaseSync(value.targetPath);
target.exec(`
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',
'adoption-owner-binding', 'user', 'local-owner', 1
);
`);
target.close();
const mutationId = '12345678-1234-4123-8123-123456789abd';
let reviewerAuthorityChecks = 0;
const publicationOptions = {
sourcePath: value.sourcePath,
targetPath: value.targetPath,
authorizationPath,
profile: 'edge',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
expectedDecisionId: decisionId,
projectId: 'default',
mutationId,
requestId: 'legacy-adoption-publication-test',
keyProvider: authorizationKeyProvider(),
observedAtMs: REVIEWED_AT_MS + 1,
async confirmReviewerAuthority(reviewer) {
await Promise.resolve();
assert.deepEqual(reviewer, REVIEWER);
reviewerAuthorityChecks += 1;
},
};
const inserted = await publishReviewedLegacyCrontabAdoption(
publicationOptions,
);
assert.equal(inserted.status, 'inserted');
assert.equal(inserted.adoption.adoptedTaskCount, 1);
assert.equal(inserted.adoption.adoptedTriggerCount, 1);
assert.equal(reviewerAuthorityChecks, 2);
assert.equal(
(await publishReviewedLegacyCrontabAdoption(publicationOptions)).status,
'existing',
);
assert.equal(reviewerAuthorityChecks, 4);
const stored = new DatabaseSync(value.targetPath, { readOnly: true });
assert.deepEqual(
{
...stored
.prepare(
`SELECT task."task_id" AS taskId, revision."name" AS name,
revision."enabled" AS enabled
FROM "QingLong3TaskDefinitions" AS task
JOIN "QingLong3TaskDefinitionRevisions" AS revision
ON revision."project_id" = task."project_id"
AND revision."task_id" = task."task_id"
AND revision."revision" = task."current_revision"`,
)
.get(),
},
{ taskId: 'legacy-cron:1', name: 'Legacy Crontab 1', enabled: 1 },
);
assert.equal(
stored
.prepare('SELECT COUNT(*) AS count FROM "QingLong3LegacyAdoptions"')
.get().count,
1,
);
stored.close();
});
test('authenticated decision file rejects wrong keys, expiry and tampering', async (t) => {
const value = fixture(t);
const source = new DatabaseSync(value.sourcePath);
source
.prepare('UPDATE "Crontabs" SET command = ? WHERE id = 1')
.run('task /scripts/stable-authorization.sh');
source.close();
const plan = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'standalone',
legacyTimezone: 'UTC',
});
const page = inspectLegacyCrontabAdoptionDiagnostics({
sourcePath: value.sourcePath,
profile: 'standalone',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
});
const authorizationDirectory = path.join(value.directory, 'authorization');
fs.mkdirSync(authorizationDirectory, { mode: 0o700 });
const authorizationPath = path.join(authorizationDirectory, 'review.ndjson');
const decisionId = '018f0f5e-7b2d-7000-8000-000000000012';
await publishReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({
sourcePath: value.sourcePath,
profile: 'standalone',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
decisionId,
reviewer: REVIEWER,
issuedAtMs: REVIEWED_AT_MS,
expiresAtMs: REVIEWED_AT_MS + 10 * 60 * 1_000,
decisions: page.diagnostics.map(decisionFor),
authorizationPath,
keyProvider: authorizationKeyProvider(),
});
const verification = {
sourcePath: value.sourcePath,
profile: 'standalone',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
expectedDecisionId: decisionId,
authorizationPath,
};
await assert.rejects(
verifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({
...verification,
keyProvider: authorizationKeyProvider(Buffer.alloc(32, 0x6b)),
observedAtMs: REVIEWED_AT_MS + 1,
}),
/decision authorization verification failed/,
);
await assert.rejects(
verifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({
...verification,
keyProvider: authorizationKeyProvider(),
observedAtMs: REVIEWED_AT_MS + 10 * 60 * 1_000,
}),
/decision authorization verification failed/,
);
const tampered = fs
.readFileSync(authorizationPath, 'utf8')
.replace('reviewed_lossless', 'operator_excluded');
fs.writeFileSync(authorizationPath, tampered, { mode: 0o600 });
fs.chmodSync(authorizationPath, 0o600);
await assert.rejects(
verifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({
...verification,
keyProvider: authorizationKeyProvider(),
observedAtMs: REVIEWED_AT_MS + 1,
}),
/decision authorization verification failed/,
);
});
test('authenticated decision file requires private parent and file modes', async (t) => {
const value = fixture(t);
const plan = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
});
const page = inspectLegacyCrontabAdoptionDiagnostics({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
});
const base = {
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
decisionId: '018f0f5e-7b2d-7000-8000-000000000013',
reviewer: REVIEWER,
issuedAtMs: REVIEWED_AT_MS,
expiresAtMs: REVIEWED_AT_MS + 10 * 60 * 1_000,
decisions: page.diagnostics.map(decisionFor),
keyProvider: authorizationKeyProvider(),
};
const broadDirectory = path.join(value.directory, 'broad-authorization');
fs.mkdirSync(broadDirectory, { mode: 0o755 });
fs.chmodSync(broadDirectory, 0o755);
await assert.rejects(
publishReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({
...base,
authorizationPath: path.join(broadDirectory, 'review.ndjson'),
}),
/decision authorization publication failed/,
);
const privateDirectory = path.join(value.directory, 'private-authorization');
fs.mkdirSync(privateDirectory, { mode: 0o700 });
const authorizationPath = path.join(privateDirectory, 'review.ndjson');
await publishReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({
...base,
authorizationPath,
});
fs.chmodSync(authorizationPath, 0o644);
await assert.rejects(
verifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
expectedDecisionId: base.decisionId,
authorizationPath,
keyProvider: authorizationKeyProvider(),
observedAtMs: REVIEWED_AT_MS + 1,
}),
/decision authorization verification failed/,
);
});
test('authenticated decision file rejects same-inode semantic rewrites during verification', async (t) => {
const value = fixture(t);
const source = new DatabaseSync(value.sourcePath);
source
.prepare('UPDATE "Crontabs" SET command = ? WHERE id = 1')
.run('task /scripts/identity-fence.sh');
source.close();
const plan = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
});
const page = inspectLegacyCrontabAdoptionDiagnostics({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
});
const authorizationDirectory = path.join(value.directory, 'authorization');
fs.mkdirSync(authorizationDirectory, { mode: 0o700 });
const authorizationPath = path.join(authorizationDirectory, 'review.ndjson');
const decisionId = '018f0f5e-7b2d-7000-8000-000000000014';
await publishReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
decisionId,
reviewer: REVIEWER,
issuedAtMs: REVIEWED_AT_MS,
expiresAtMs: REVIEWED_AT_MS + 10 * 60 * 1_000,
decisions: page.diagnostics.map(decisionFor),
authorizationPath,
keyProvider: authorizationKeyProvider(),
});
const mutatingProvider = authorizationKeyProvider();
await assert.rejects(
verifyReviewedLegacyCrontabAdoptionDecisionAuthorizationFile({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
expectedDecisionId: decisionId,
authorizationPath,
keyProvider: {
active: () => mutatingProvider.active(),
async resolve(keyId) {
const lines = fs.readFileSync(authorizationPath, 'utf8').split('\n');
const row = JSON.parse(lines[1]);
row.decision = {
sourceDigest: row.decision.sourceDigest,
rowOrdinal: row.decision.rowOrdinal,
disposition: row.decision.disposition,
reason: row.decision.reason,
};
lines[1] = JSON.stringify(row);
const rewritten = lines.join('\n');
assert.equal(
Buffer.byteLength(rewritten),
fs.statSync(authorizationPath).size,
);
fs.writeFileSync(authorizationPath, rewritten, { mode: 0o600 });
fs.utimesSync(
authorizationPath,
new Date(REVIEWED_AT_MS - 10_000),
new Date(REVIEWED_AT_MS - 10_000),
);
return mutatingProvider.resolve(keyId);
},
},
observedAtMs: REVIEWED_AT_MS + 1,
}),
/decision authorization verification failed/,
);
});
test('decision receipt fails closed for incomplete, widened and weak review', (t) => {
const value = fixture(t);
const source = new DatabaseSync(value.sourcePath);
source
.prepare('UPDATE "Crontabs" SET command = ? WHERE id = 1')
.run('task /scripts/reviewed.sh');
source.close();
const plan = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'standalone',
legacyTimezone: 'UTC',
});
const page = inspectLegacyCrontabAdoptionDiagnostics({
sourcePath: value.sourcePath,
profile: 'standalone',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
});
const decision = decisionFor(page.diagnostics[0]);
const base = {
sourcePath: value.sourcePath,
profile: 'standalone',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
decisionId: '018f0f5e-7b2d-7000-8000-000000000002',
reviewer: REVIEWER,
issuedAtMs: REVIEWED_AT_MS,
expiresAtMs: REVIEWED_AT_MS + 10 * 60 * 1_000,
};
assert.throws(
() =>
createReviewedLegacyCrontabAdoptionDecisionReceipt({
...base,
decisions: [],
}),
/decision receipt creation failed/,
);
assert.throws(
() =>
createReviewedLegacyCrontabAdoptionDecisionReceipt({
...base,
decisions: [decision, decision],
}),
/decision receipt creation failed/,
);
assert.throws(
() =>
createReviewedLegacyCrontabAdoptionDecisionReceipt({
...base,
decisions: [
{
...decision,
sourceDigest: '0'.repeat(64),
},
],
}),
/decision receipt creation failed/,
);
assert.throws(
() =>
createReviewedLegacyCrontabAdoptionDecisionReceipt({
...base,
reviewer: { ...REVIEWER, assurance: 'single_factor' },
decisions: [decision],
}),
/decision receipt creation failed/,
);
assert.throws(
() =>
createReviewedLegacyCrontabAdoptionDecisionReceipt({
...base,
reviewer: {
...REVIEWER,
authenticatedAtMs: REVIEWED_AT_MS - 5 * 60 * 1_000 - 1,
},
decisions: [decision],
}),
/decision receipt creation failed/,
);
assert.throws(
() =>
createReviewedLegacyCrontabAdoptionDecisionReceipt({
...base,
expiresAtMs: REVIEWED_AT_MS + 30 * 60 * 1_000 + 1,
decisions: [decision],
}),
/decision receipt creation failed/,
);
assert.throws(
() =>
createReviewedLegacyCrontabAdoptionDecisionReceipt({
...base,
decisions: [
{
...decision,
disposition: 'adopt_shell_compatibility',
reason: 'reviewed_shell_compatibility',
},
],
}),
/decision receipt creation failed/,
);
});
test('decision receipt rejects tampering, expiry and source drift', (t) => {
const value = fixture(t);
const source = new DatabaseSync(value.sourcePath);
source
.prepare('UPDATE "Crontabs" SET command = ? WHERE id = 1')
.run('task /scripts/stable.sh');
source.close();
const plan = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
});
const page = inspectLegacyCrontabAdoptionDiagnostics({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
});
const decisions = page.diagnostics.map(decisionFor);
const receipt = createReviewedLegacyCrontabAdoptionDecisionReceipt({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
decisionId: '018f0f5e-7b2d-7000-8000-000000000003',
reviewer: REVIEWER,
issuedAtMs: REVIEWED_AT_MS,
expiresAtMs: REVIEWED_AT_MS + 10 * 60 * 1_000,
decisions,
});
const tampered = JSON.parse(JSON.stringify(receipt));
tampered.decisions.dispositions.skip += 1;
assert.throws(
() =>
verifyReviewedLegacyCrontabAdoptionDecisionReceipt({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
receipt: tampered,
decisions,
observedAtMs: REVIEWED_AT_MS + 1,
}),
/decision receipt verification failed/,
);
assert.throws(
() =>
verifyReviewedLegacyCrontabAdoptionDecisionReceipt({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
receipt,
decisions,
observedAtMs: receipt.expiresAtMs,
}),
/decision receipt verification failed/,
);
const changed = new DatabaseSync(value.sourcePath);
changed
.prepare('UPDATE "Crontabs" SET schedule = ? WHERE id = 1')
.run('5 0 * * *');
changed.close();
assert.throws(
() =>
verifyReviewedLegacyCrontabAdoptionDecisionReceipt({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
receipt,
decisions,
observedAtMs: REVIEWED_AT_MS + 1,
}),
/source no longer matches the reviewed plan/,
);
});
test('requires an explicit timezone and canonicalizes multiple cron triggers', (t) => {
const value = fixture(t);
const source = new DatabaseSync(value.sourcePath);
source.exec('ALTER TABLE "Crontabs" ADD COLUMN extra_schedules TEXT');
source
.prepare(
`UPDATE "Crontabs"
SET command = ?, extra_schedules = ?
WHERE id = 1`,
)
.run(
'task /scripts/timezone.sh',
JSON.stringify([{ schedule: '1 0 * * *' }, { schedule: '2 0 * * *' }]),
);
source.close();
const withoutTimezone = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'standalone',
});
const withoutPage = inspectLegacyCrontabAdoptionDiagnostics({
sourcePath: value.sourcePath,
profile: 'standalone',
expectedPlanDigest: withoutTimezone.planDigest,
});
assert.equal(
withoutPage.diagnostics[0].classification,
'requires_manual_action',
);
assert.deepEqual(withoutPage.diagnostics[0].reasons, ['timezone_required']);
assert.equal(withoutPage.diagnostics[0].triggerCount, 3);
assert.equal(withoutPage.diagnostics[0].triggerSpecDigests, undefined);
const withTimezone = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'standalone',
legacyTimezone: 'Etc/UTC',
});
const withPage = inspectLegacyCrontabAdoptionDiagnostics({
sourcePath: value.sourcePath,
profile: 'standalone',
legacyTimezone: 'UTC',
expectedPlanDigest: withTimezone.planDigest,
});
assert.equal(withTimezone.tasks.timezone, 'UTC');
assert.equal(withPage.diagnostics[0].classification, 'lossless');
assert.equal(withPage.diagnostics[0].triggerCount, 3);
assert.equal(withPage.diagnostics[0].triggerSpecDigests.length, 3);
assert.notEqual(withoutTimezone.planDigest, withTimezone.planDigest);
});
test('binds Crontabs row content into the reviewed plan digest', async (t) => {
const value = fixture(t);
const reviewed = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
});
const source = new DatabaseSync(value.sourcePath);
source
.prepare('UPDATE "Crontabs" SET command = ? WHERE id = 1')
.run('echo changed-without-schema-drift');
source.close();
const changed = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
});
assert.equal(changed.catalog.digest, reviewed.catalog.digest);
assert.notEqual(
changed.tasks.inventoryDigest,
reviewed.tasks.inventoryDigest,
);
assert.notEqual(changed.planDigest, reviewed.planDigest);
await assert.rejects(
stageLocalSqliteAdoption({
...value,
profile: 'edge',
legacyTimezone: 'UTC',
expectedPlanDigest: reviewed.planDigest,
}),
/source no longer matches the reviewed plan/,
);
assert.equal(fs.existsSync(value.targetPath), false);
assert.equal(fs.existsSync(value.recoveryPath), false);
assert.equal(fs.existsSync(value.manifestPath), false);
});
test('reports malformed legacy JSON and rejects unbounded diagnostic pages', (t) => {
const value = fixture(t);
const source = new DatabaseSync(value.sourcePath);
source.exec('ALTER TABLE "Crontabs" ADD COLUMN extra_schedules TEXT');
source
.prepare('UPDATE "Crontabs" SET extra_schedules = ? WHERE id = 1')
.run('{not-json');
source.close();
const plan = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
});
const page = inspectLegacyCrontabAdoptionDiagnostics({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
});
assert.equal(page.diagnostics[0].classification, 'malformed');
assert.equal(
page.diagnostics[0].reasons.includes('extra_schedules_invalid'),
true,
);
assert.throws(
() =>
inspectLegacyCrontabAdoptionDiagnostics({
sourcePath: value.sourcePath,
profile: 'edge',
legacyTimezone: 'UTC',
expectedPlanDigest: plan.planDigest,
limit: 129,
}),
/task diagnostics failed/,
);
});
test('runtime activation entrypoint excludes migration SQL and adoption review', () => {
const script = `
require(${JSON.stringify(path.resolve(__dirname, '../dist/runtime.js'))});
const migration = Object.keys(require.cache)
.filter((entry) => /[\\/]local-sqlite[\\/]dist[\\/](?:migration|migrations[\\/])/.test(entry));
const classifier = Object.keys(require.cache)
.filter((entry) => entry.endsWith('/local-admin/dist/legacy-adoption/legacyCrontabAdoption.js'));
const decisionReceipt = Object.keys(require.cache)
.filter((entry) => entry.endsWith('/local-admin/dist/legacy-adoption/legacyCrontabDecisionReceipt.js'));
const decisionAuthorization = Object.keys(require.cache)
.filter((entry) => entry.endsWith('/local-admin/dist/legacy-adoption/legacyCrontabDecisionAuthorizationFile.js'));
process.stdout.write(JSON.stringify({ migration, classifier, decisionReceipt, decisionAuthorization }));
`;
const result = spawnSync(process.execPath, ['-e', script], {
encoding: 'utf8',
});
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(JSON.parse(result.stdout), {
migration: [],
classifier: [],
decisionReceipt: [],
decisionAuthorization: [],
});
});
test('stages a restorable side-by-side database and preserves unknown data', async (t) => {
const value = fixture(t);
const sourceDigest = sha256(value.sourcePath);
const plan = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'edge',
});
const manifest = await stageLocalSqliteAdoption({
...value,
profile: 'edge',
expectedPlanDigest: plan.planDigest,
clock: () => 1_750_000_000_000,
});
assert.equal(manifest.state, 'staged');
assert.equal(manifest.schemaVersion, 2);
assert.equal(manifest.tasks.inventoryDigest, plan.tasks.inventoryDigest);
assert.equal(manifest.createdAtMs, 1_750_000_000_000);
assert.match(manifest.manifestDigest, /^[0-9a-f]{64}$/);
assert.equal(sha256(value.sourcePath), sourceDigest);
assert.equal(fs.statSync(value.recoveryPath).mode & 0o777, 0o600);
assert.equal(fs.statSync(value.targetPath).mode & 0o777, 0o600);
assert.equal(fs.statSync(value.manifestPath).mode & 0o777, 0o600);
const recovery = new DatabaseSync(value.recoveryPath, { readOnly: true });
assert.equal(
recovery
.prepare(
`SELECT 1 FROM sqlite_schema
WHERE type = 'table' AND name = 'QingLong3SchemaMigrations'`,
)
.get(),
undefined,
);
recovery.close();
const target = new DatabaseSync(value.targetPath, { readOnly: true });
const pluginRow = target
.prepare('SELECT id, payload FROM "PluginOwnedState"')
.get();
assert.equal(pluginRow.id, 1);
assert.equal(pluginRow.payload, '{"preserved":true}');
assert.equal(
target.prepare('SELECT COUNT(*) AS count FROM "Runs"').get().count,
0,
);
target.close();
assert.deepEqual(await verifyLocalSqliteAdoption(value), manifest);
await assert.rejects(
stageLocalSqliteAdoption({
...value,
profile: 'edge',
expectedPlanDigest: plan.planDigest,
}),
/target already exists/,
);
});
test('rejects a stale plan before creating any adoption output', async (t) => {
const value = fixture(t);
const plan = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'standalone',
});
const source = new DatabaseSync(value.sourcePath);
source.exec('CREATE TABLE "LateLegacyWrite" (id INTEGER PRIMARY KEY)');
source.close();
await assert.rejects(
stageLocalSqliteAdoption({
...value,
profile: 'standalone',
expectedPlanDigest: plan.planDigest,
}),
/source no longer matches the reviewed plan/,
);
assert.equal(fs.existsSync(value.targetPath), false);
assert.equal(fs.existsSync(value.recoveryPath), false);
assert.equal(fs.existsSync(value.manifestPath), false);
});
test('rejects non-legacy, already adopted, and symlink sources', (t) => {
const value = fixture(t);
const incompletePath = path.join(value.directory, 'incomplete.sqlite');
const incomplete = new DatabaseSync(incompletePath);
incomplete.exec('CREATE TABLE "Crontabs" (id INTEGER PRIMARY KEY)');
incomplete.close();
assert.throws(
() =>
inspectLegacySqlitePath({ sourcePath: incompletePath, profile: 'edge' }),
LocalSqliteAdoptionError,
);
const source = new DatabaseSync(value.sourcePath);
source.exec('CREATE TABLE "Runs" (id TEXT PRIMARY KEY)');
source.close();
assert.throws(
() =>
inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'edge',
}),
/conflicting 3.0 object Runs/,
);
const symlinkPath = path.join(value.directory, 'database-link.sqlite');
fs.symlinkSync(value.sourcePath, symlinkPath);
assert.throws(
() => inspectLegacySqlitePath({ sourcePath: symlinkPath, profile: 'edge' }),
/regular file/,
);
});
test('fails verification after staged bytes or manifest fields drift', async (t) => {
const value = fixture(t);
const plan = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'edge',
});
await stageLocalSqliteAdoption({
...value,
profile: 'edge',
expectedPlanDigest: plan.planDigest,
});
fs.appendFileSync(value.recoveryPath, Buffer.from([0]));
await assert.rejects(
verifyLocalSqliteAdoption(value),
/staged database digest does not match/,
);
});
test('rejects an extensible manifest even when its digest is recomputed', async (t) => {
const value = fixture(t);
const plan = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'edge',
});
await stageLocalSqliteAdoption({
...value,
profile: 'edge',
expectedPlanDigest: plan.planDigest,
});
const manifest = JSON.parse(fs.readFileSync(value.manifestPath, 'utf8'));
manifest.recovery.unreviewed = true;
delete manifest.manifestDigest;
manifest.manifestDigest = createHash('sha256')
.update(JSON.stringify(manifest))
.digest('hex');
fs.writeFileSync(value.manifestPath, `${JSON.stringify(manifest)}\n`, {
mode: 0o600,
});
await assert.rejects(
verifyLocalSqliteAdoption(value),
/manifest recovery evidence is invalid/,
);
});
test('prepares activation only while the source still matches the staged snapshot', async (t) => {
const value = fixture(t);
const plan = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'edge',
});
const adoption = await stageLocalSqliteAdoption({
...value,
profile: 'edge',
expectedPlanDigest: plan.planDigest,
});
const activation = await prepareLocalSqliteActivation({
...value,
expectedManifestDigest: adoption.manifestDigest,
clock: () => 1_760_000_000_000,
});
assert.equal(activation.state, 'prepared');
assert.equal(activation.createdAtMs, 1_760_000_000_000);
assert.equal(activation.adoptionManifestDigest, adoption.manifestDigest);
assert.match(activation.targetPathDigest, /^[0-9a-f]{64}$/);
assert.equal(
activation.targetDevice,
fs.statSync(value.targetPath).dev.toString(),
);
assert.equal(
activation.targetInode,
fs.statSync(value.targetPath).ino.toString(),
);
assert.equal(fs.statSync(value.activationPath).mode & 0o777, 0o600);
await assert.rejects(
prepareLocalSqliteActivation({
...value,
expectedManifestDigest: adoption.manifestDigest,
}),
/activation already exists/,
);
});
test('rejects activation when legacy data changed after staging', async (t) => {
const value = fixture(t);
const plan = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'edge',
});
const adoption = await stageLocalSqliteAdoption({
...value,
profile: 'edge',
expectedPlanDigest: plan.planDigest,
});
const source = new DatabaseSync(value.sourcePath);
source
.prepare('INSERT INTO "PluginOwnedState" (id, payload) VALUES (?, ?)')
.run(2, '{"late":true}');
source.close();
await assert.rejects(
prepareLocalSqliteActivation({
...value,
expectedManifestDigest: adoption.manifestDigest,
}),
/legacy source content changed after staging/,
);
assert.equal(fs.existsSync(value.activationPath), false);
});
test('holds a legacy write fence for the complete activated lifetime', async (t) => {
const value = fixture(t);
const plan = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'standalone',
});
const adoption = await stageLocalSqliteAdoption({
...value,
profile: 'standalone',
expectedPlanDigest: plan.planDigest,
});
const activation = await prepareLocalSqliteActivation({
...value,
expectedManifestDigest: adoption.manifestDigest,
});
const fence = await acquireLocalSqliteActivation({
...value,
expectedActivationDigest: activation.activationDigest,
busyTimeoutMs: 100,
});
assert.equal(fence.state, 'fenced');
const legacyWriter = new DatabaseSync(value.sourcePath, { timeout: 100 });
assert.throws(
() =>
legacyWriter
.prepare(
'INSERT INTO "Crontabs" (id, command, schedule) VALUES (?, ?, ?)',
)
.run(2, 'echo fenced', '1 1 * * *'),
(error) => error && error.errstr === 'database is locked',
);
assert.equal(await fence.release(), 'released');
assert.equal(await fence.release(), 'released');
legacyWriter
.prepare('INSERT INTO "Crontabs" (id, command, schedule) VALUES (?, ?, ?)')
.run(2, 'echo released', '1 1 * * *');
assert.equal(
legacyWriter.prepare('SELECT COUNT(*) AS count FROM "Crontabs"').get()
.count,
2,
);
legacyWriter.close();
});
test('reacquires an activated target after legitimate QingLong 3 writes', async (t) => {
const value = fixture(t);
const plan = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'standalone',
});
const adoption = await stageLocalSqliteAdoption({
...value,
profile: 'standalone',
expectedPlanDigest: plan.planDigest,
});
const activation = await prepareLocalSqliteActivation({
...value,
expectedManifestDigest: adoption.manifestDigest,
});
const firstFence = await acquireLocalSqliteActivation({
...value,
expectedActivationDigest: activation.activationDigest,
});
await firstFence.release();
const target = new DatabaseSync(value.targetPath);
target
.prepare('INSERT INTO "Crontabs" (id, command, schedule) VALUES (?, ?, ?)')
.run(2, 'echo ql3', '2 2 * * *');
target.close();
await assert.rejects(
verifyLocalSqliteAdoption(value),
/staged database digest does not match/,
);
const restartedFence = await acquireLocalSqliteActivation({
...value,
expectedActivationDigest: activation.activationDigest,
});
assert.equal(restartedFence.state, 'fenced');
assert.equal(await restartedFence.release(), 'released');
});
test('reacquires an activated target after an additive feature migration', async (t) => {
const value = fixture(t);
const plan = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'edge',
});
const adoption = await stageLocalSqliteAdoption({
...value,
profile: 'edge',
expectedPlanDigest: plan.planDigest,
});
const activation = await prepareLocalSqliteActivation({
...value,
expectedManifestDigest: adoption.manifestDigest,
});
const target = new DatabaseSync(value.targetPath);
target.exec('CREATE TABLE "QingLong3OptionalFeature" (id TEXT PRIMARY KEY)');
target.close();
const fence = await acquireLocalSqliteActivation({
...value,
expectedActivationDigest: activation.activationDigest,
});
assert.equal(fence.state, 'fenced');
assert.equal(await fence.release(), 'released');
});
test('rejects an activated target whose reviewed schema baseline regressed', async (t) => {
const value = fixture(t);
const plan = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'edge',
});
const adoption = await stageLocalSqliteAdoption({
...value,
profile: 'edge',
expectedPlanDigest: plan.planDigest,
});
const activation = await prepareLocalSqliteActivation({
...value,
expectedManifestDigest: adoption.manifestDigest,
});
const target = new DatabaseSync(value.targetPath);
target.exec('DROP TABLE "PluginOwnedState"');
target.close();
await assert.rejects(
acquireLocalSqliteActivation({
...value,
expectedActivationDigest: activation.activationDigest,
}),
/target readiness evidence has drifted/,
);
});
test('rejects a ready target file that replaced the activated inode', async (t) => {
const value = fixture(t);
const plan = inspectLegacySqlitePath({
sourcePath: value.sourcePath,
profile: 'edge',
});
const adoption = await stageLocalSqliteAdoption({
...value,
profile: 'edge',
expectedPlanDigest: plan.planDigest,
});
const activation = await prepareLocalSqliteActivation({
...value,
expectedManifestDigest: adoption.manifestDigest,
});
const replacementPath = path.join(value.directory, 'replacement.sqlite');
fs.copyFileSync(value.targetPath, replacementPath);
fs.renameSync(replacementPath, value.targetPath);
await assert.rejects(
acquireLocalSqliteActivation({
...value,
expectedActivationDigest: activation.activationDigest,
}),
/target database identity does not match the activation/,
);
});