Files
qinglong/packages/ql3-runtime-core/test/pluginPackageSecretBindingTransitionPlan.test.cjs

362 lines
11 KiB
JavaScript

const assert = require('node:assert/strict');
const { test } = require('node:test');
const {
createPluginPackageSecretBinding,
} = require('@qinglong/runtime-core/plugin-package-secret-binding');
const {
createPluginPackageSecretBindingTransitionPlan,
normalizePluginPackageSecretBindingTransitionPlan,
} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-plan');
const {
createPluginPackageResourceGeneration,
} = require('@qinglong/runtime-core/plugin-package-resource-generation');
const { createSecretRef } = require('@qinglong/runtime-core/secret-reference');
function manifest(secrets) {
return {
apiVersion: 'qinglong.io/v1alpha1',
kind: 'Package',
metadata: {
name: 'example-monitor',
displayName: 'Example Monitor',
version: '1.0.0',
description: 'Secret transition fixture',
license: 'Apache-2.0',
},
spec: {
compatibility: {
qinglong: '>=3.0.0-0 <4.0.0',
architectures: ['arm64'],
deploymentProfiles: ['edge'],
},
runtimes: [],
resources: {
memory: { recommended: '32Mi' },
disk: { install: '4Mi', working: '8Mi' },
},
permissions: {
network: { allowedHosts: [] },
secrets,
tools: ['secret.use'],
},
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
},
};
}
function secret(name, version) {
return createSecretRef({ projectId: 'project-1', name, version });
}
const previousManifest = manifest([
{ name: 'OPTIONAL_TOKEN', required: false },
{ name: 'TOKEN', required: true },
]);
const previousGeneration = createPluginPackageResourceGeneration({
installationId: 'install-1',
projectId: 'project-1',
packageName: 'example-monitor',
lockDigest: 'a'.repeat(64),
generation: 1,
previousActiveLockDigest: null,
contentDigest: 'b'.repeat(64),
contents: previousManifest.spec.contents,
});
const previousBinding = createPluginPackageSecretBinding({
generation: previousGeneration,
manifest: previousManifest,
assignments: [
{ name: 'OPTIONAL_TOKEN', secretRef: null },
{ name: 'TOKEN', secretRef: secret('runtime-token', 2) },
],
authority: {
kind: 'local-owner-confirmation',
evidenceDigest: 'c'.repeat(64),
},
boundAtMs: 90,
});
const previousTarget = previousBinding.target;
function nextGeneration(overrides = {}) {
return createPluginPackageResourceGeneration({
installationId: 'install-2',
projectId: 'project-1',
packageName: 'example-monitor',
lockDigest: 'd'.repeat(64),
generation: 2,
previousActiveLockDigest: previousBinding.target.lockDigest,
contentDigest: 'e'.repeat(64),
contents: previousManifest.spec.contents,
...overrides,
});
}
function transition(assignments, overrides = {}) {
return createPluginPackageSecretBindingTransitionPlan({
previousTarget,
previousBinding,
previousAttemptGeneration: overrides.previousAttemptGeneration ?? 1,
nextGeneration: nextGeneration(overrides.generation),
nextManifest: overrides.manifest ?? previousManifest,
assignments,
plannedAtMs: 100,
});
}
function postgresJsonbRoundTrip(value) {
if (Array.isArray(value)) {
return value.map(postgresJsonbRoundTrip);
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entry]) => [key, postgresJsonbRoundTrip(entry)]),
);
}
return value;
}
test('derives an exact carry-forward for an unchanged next generation', () => {
const value = transition([
{ name: 'OPTIONAL_TOKEN', secretRef: null },
{ name: 'TOKEN', secretRef: secret('runtime-token', 2) },
]);
assert.equal(value.kind, 'carry-forward');
assert.equal(value.previousActiveLockDigest, 'a'.repeat(64));
assert.deepEqual(
value.changes.map(({ name, requirement, reference }) => ({
name,
requirement,
reference,
})),
[
{
name: 'OPTIONAL_TOKEN',
requirement: 'unchanged',
reference: 'unchanged',
},
{
name: 'TOKEN',
requirement: 'unchanged',
reference: 'unchanged',
},
],
);
assert.deepEqual(
normalizePluginPackageSecretBindingTransitionPlan(value),
value,
);
});
test('normalizes a transition after PostgreSQL jsonb reorders object keys', () => {
const value = transition([
{ name: 'OPTIONAL_TOKEN', secretRef: null },
{ name: 'TOKEN', secretRef: secret('runtime-token', 3) },
]);
assert.deepEqual(
normalizePluginPackageSecretBindingTransitionPlan(
postgresJsonbRoundTrip(value),
),
value,
);
});
test('distinguishes forward rotation from rebind and version rollback', () => {
const rotated = transition([
{ name: 'OPTIONAL_TOKEN', secretRef: null },
{ name: 'TOKEN', secretRef: secret('runtime-token', 3) },
]);
assert.equal(rotated.kind, 'rotate');
assert.equal(rotated.changes[1].reference, 'rotated');
const rebound = transition([
{ name: 'OPTIONAL_TOKEN', secretRef: null },
{ name: 'TOKEN', secretRef: secret('replacement-token', 1) },
]);
assert.equal(rebound.kind, 'rebind');
assert.equal(rebound.changes[1].reference, 'rebound');
const rolledBack = transition([
{ name: 'OPTIONAL_TOKEN', secretRef: null },
{ name: 'TOKEN', secretRef: secret('runtime-token', 1) },
]);
assert.equal(rolledBack.kind, 'rebind');
assert.equal(rolledBack.changes[1].reference, 'rebound');
});
test('classifies removal or unbinding as revocation', () => {
const nextManifest = manifest([{ name: 'OPTIONAL_TOKEN', required: false }]);
const value = transition([{ name: 'OPTIONAL_TOKEN', secretRef: null }], {
manifest: nextManifest,
});
assert.equal(value.kind, 'revoke');
assert.deepEqual(value.changes[1], {
name: 'TOKEN',
requirement: 'removed',
reference: 'revoked',
previous: { required: true, secretRef: secret('runtime-token', 2) },
next: null,
});
});
test('represents final requirement removal without inventing an empty binding', () => {
const value = transition([], { manifest: manifest([]) });
assert.equal(value.kind, 'revoke');
assert.equal(value.nextBindingPlan, null);
assert.equal(value.nextTarget.generation, 2);
assert.equal(value.changes.length, 2);
});
test('treats requirement additions and optional binding as rebind', () => {
const nextManifest = manifest([
{ name: 'EXTRA_TOKEN', required: false },
{ name: 'OPTIONAL_TOKEN', required: false },
{ name: 'TOKEN', required: true },
]);
const value = transition(
[
{ name: 'EXTRA_TOKEN', secretRef: secret('extra-token', 1) },
{ name: 'OPTIONAL_TOKEN', secretRef: null },
{ name: 'TOKEN', secretRef: secret('runtime-token', 2) },
],
{ manifest: nextManifest },
);
assert.equal(value.kind, 'rebind');
assert.equal(value.changes[0].requirement, 'added');
assert.equal(value.changes[0].reference, 'bound');
});
test('supports adding the first Secret after an active generation without a binding', () => {
const value = createPluginPackageSecretBindingTransitionPlan({
previousTarget,
previousBinding: null,
previousAttemptGeneration: 1,
nextGeneration: nextGeneration(),
nextManifest: manifest([{ name: 'TOKEN', required: true }]),
assignments: [{ name: 'TOKEN', secretRef: secret('runtime-token', 1) }],
plannedAtMs: 100,
});
assert.equal(value.kind, 'rebind');
assert.equal(value.previousBinding, null);
assert.deepEqual(value.previousTarget, previousTarget);
assert.deepEqual(value.changes, [
{
name: 'TOKEN',
requirement: 'added',
reference: 'bound',
previous: null,
next: { required: true, secretRef: secret('runtime-token', 1) },
},
]);
assert.deepEqual(
normalizePluginPackageSecretBindingTransitionPlan(value),
value,
);
});
test('uses durable attempt generations and rejects skipped or detached targets', () => {
const assignments = [
{ name: 'OPTIONAL_TOKEN', secretRef: null },
{ name: 'TOKEN', secretRef: secret('runtime-token', 2) },
];
assert.throws(
() => transition(assignments, { generation: { generation: 3 } }),
/immediate durable attempt generation/,
);
const retried = transition(assignments, {
generation: { generation: 3 },
previousAttemptGeneration: 2,
});
assert.equal(retried.nextTarget.generation, 3);
assert.equal(retried.previousAttemptGeneration, 2);
assert.throws(
() =>
transition(assignments, {
generation: { previousActiveLockDigest: 'f'.repeat(64) },
}),
/does not name the previous active lock/,
);
assert.throws(
() =>
transition(assignments, { generation: { installationId: 'install-1' } }),
/immediate durable attempt generation/,
);
});
test('fails closed when classification, content or digest is rewritten', () => {
const value = transition([
{ name: 'OPTIONAL_TOKEN', secretRef: null },
{ name: 'TOKEN', secretRef: secret('runtime-token', 3) },
]);
assert.throws(
() =>
normalizePluginPackageSecretBindingTransitionPlan({
...value,
kind: 'carry-forward',
}),
/classification/,
);
assert.throws(
() =>
normalizePluginPackageSecretBindingTransitionPlan({
...value,
changes: value.changes.slice(1),
}),
/classification/,
);
assert.throws(
() =>
normalizePluginPackageSecretBindingTransitionPlan({
...value,
transitionDigest: 'f'.repeat(64),
}),
/digest/,
);
});
test('rejects extensible or accessor-bearing creation input', () => {
const assignments = [
{ name: 'OPTIONAL_TOKEN', secretRef: null },
{ name: 'TOKEN', secretRef: secret('runtime-token', 2) },
];
const input = {
previousTarget,
previousBinding,
previousAttemptGeneration: 1,
nextGeneration: nextGeneration(),
nextManifest: previousManifest,
assignments,
plannedAtMs: 100,
};
assert.throws(
() =>
createPluginPackageSecretBindingTransitionPlan({ ...input, extra: true }),
/shape is invalid/,
);
const accessor = { ...input };
Object.defineProperty(accessor, 'plannedAtMs', {
enumerable: true,
get() {
throw new Error('must not execute');
},
});
assert.throws(
() => createPluginPackageSecretBindingTransitionPlan(accessor),
/data properties/,
);
});
test('exports the transition contract only through its explicit subpath', () => {
assert.equal(
require('../dist').createPluginPackageSecretBindingTransitionPlan,
undefined,
);
assert.equal(
require('@qinglong/runtime-core/plugin-package-secret-binding-transition-plan')
.createPluginPackageSecretBindingTransitionPlan,
createPluginPackageSecretBindingTransitionPlan,
);
});