Files
qinglong/packages/ql3-local-admin/test/pluginPackageResourceMaterialization.test.cjs

206 lines
6.2 KiB
JavaScript

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,
);
});