Files
2026-08-28 00:56:44 +02:00

196 lines
8.4 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createHash } from 'node:crypto';
const calls: Array<{ bin: string; args: string[] }> = [];
let failSync = false;
/** Seeded into the fake fork clone as the plugin's existing registry entry (update case). */
let existingEntry: unknown = null;
let lastCloneDir = '';
// submit's git()/gh() run execFileSync with encoding:'utf8', which returns STRINGS — the
// mock must too, or `.trim()` explodes on a Buffer.
vi.mock('node:child_process', () => ({
execFileSync: (bin: string, args: string[]) => {
calls.push({ bin, args });
if (bin === 'gh' && args[0] === 'api') return 'someone';
if (bin === 'gh' && args[0] === 'repo' && args[1] === 'sync' && failSync) throw new Error('fork has diverged');
if (bin === 'gh' && args[0] === 'repo' && args[1] === 'clone') {
lastCloneDir = args[3];
if (existingEntry !== null) {
const p = path.join(lastCloneDir, 'registry', 'plugins', `${(existingEntry as { id: string }).id}.json`);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(existingEntry, null, 2));
}
return '';
}
if (bin === 'gh' && args[0] === 'pr' && args[1] === 'create') return 'https://github.com/liketrek/TREK-Plugins/pull/999';
return '';
},
}));
const { submitEntry } = await import('../src/cli/submit.js');
const { generateKeypair } = await import('../src/cli/sign.js');
const { verifyArtifact } = await import('../src/cli/sign.js');
const V1_BYTES = Buffer.from('old release asset bytes');
const sha = (b: Buffer) => createHash('sha256').update(b).digest('hex');
describe('submit — fork sync and retro-signing', () => {
let tmp: string;
let keyPath: string;
let publicKey: string;
beforeEach(() => {
calls.length = 0;
failSync = false;
existingEntry = null;
lastCloneDir = '';
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'submit-'));
keyPath = path.join(tmp, 'signing.key');
publicKey = generateKeypair(keyPath).publicKey;
vi.stubGlobal('fetch', async (url: string) => {
if (String(url).includes('v1.0.0')) return { ok: true, arrayBuffer: async () => V1_BYTES } as unknown as Response;
return { ok: false, status: 404 } as Response;
});
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
vi.unstubAllGlobals();
});
const freshEntry = (signed = false) => ({
id: 'sync-plug',
name: 'Sync Plug',
...(signed ? { authorPublicKey: publicKey } : {}),
versions: [{
version: '1.1.0', downloadUrl: 'https://github.com/x/y/releases/download/v1.1.0/plugin.zip',
sha256: 'deadbeef', ...(signed ? { signature: 'fresh-sig' } : {}),
}],
});
it('fast-forwards the fork from the registry before branching', async () => {
await submitEntry(freshEntry(), {});
const sync = calls.find((c) => c.bin === 'gh' && c.args[0] === 'repo' && c.args[1] === 'sync');
expect(sync?.args).toContain('someone/TREK-Plugins');
expect(sync?.args).toContain('--source');
expect(sync?.args).toContain('liketrek/TREK-Plugins');
});
it('a diverged fork warns but does not stop the submit', async () => {
failSync = true;
const { prUrl } = await submitEntry(freshEntry(), {});
expect(prUrl).toContain('/pull/999');
});
it('retro-signs unsigned older versions on the first signed update', async () => {
existingEntry = {
id: 'sync-plug', name: 'Sync Plug',
versions: [{ version: '1.0.0', downloadUrl: 'https://github.com/x/y/releases/download/v1.0.0/plugin.zip', sha256: sha(V1_BYTES) }],
};
await submitEntry(freshEntry(true), { signKeyPath: keyPath, keep: true });
const written = JSON.parse(fs.readFileSync(path.join(lastCloneDir, 'registry', 'plugins', 'sync-plug.json'), 'utf8')) as {
versions: Array<{ version: string; signature?: string }>;
};
const v1 = written.versions.find((v) => v.version === '1.0.0')!;
expect(v1.signature).toBeDefined();
expect(verifyArtifact(V1_BYTES, v1.signature!, publicKey)).toBe(true);
fs.rmSync(lastCloneDir, { recursive: true, force: true });
});
it('refuses a first signed update when no key is available to retro-sign the older versions', async () => {
existingEntry = {
id: 'sync-plug', name: 'Sync Plug',
versions: [{ version: '1.0.0', downloadUrl: 'https://github.com/x/y/releases/download/v1.0.0/plugin.zip', sha256: sha(V1_BYTES) }],
};
await expect(submitEntry(freshEntry(true), {})).rejects.toThrow(/--sign/);
});
});
describe('submit — key rotation', () => {
let tmp: string;
let oldKeyPath: string;
let oldPublicKey: string;
let newKeyPath: string;
let newPublicKey: string;
beforeEach(() => {
calls.length = 0;
failSync = false;
lastCloneDir = '';
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'submit-rotate-'));
oldKeyPath = path.join(tmp, 'old.key');
oldPublicKey = generateKeypair(oldKeyPath).publicKey;
newKeyPath = path.join(tmp, 'new.key');
newPublicKey = generateKeypair(newKeyPath).publicKey;
existingEntry = {
id: 'sync-plug', name: 'Sync Plug', authorPublicKey: oldPublicKey,
versions: [{ version: '1.0.0', downloadUrl: 'https://github.com/x/y/releases/download/v1.0.0/plugin.zip', sha256: sha(V1_BYTES), signature: 'old-key-sig' }],
};
vi.stubGlobal('fetch', async (url: string) => {
if (String(url).includes('v1.0.0')) return { ok: true, arrayBuffer: async () => V1_BYTES } as unknown as Response;
return { ok: false, status: 404 } as Response;
});
});
afterEach(() => {
existingEntry = null;
fs.rmSync(tmp, { recursive: true, force: true });
if (lastCloneDir) fs.rmSync(lastCloneDir, { recursive: true, force: true });
vi.unstubAllGlobals();
});
const freshWithNewKey = () => ({
id: 'sync-plug', name: 'Sync Plug', authorPublicKey: newPublicKey,
versions: [{
version: '1.1.0', downloadUrl: 'https://github.com/x/y/releases/download/v1.1.0/plugin.zip',
sha256: 'deadbeef', signature: 'new-key-sig',
}],
});
const writtenEntry = () => JSON.parse(fs.readFileSync(path.join(lastCloneDir, 'registry', 'plugins', 'sync-plug.json'), 'utf8')) as {
authorPublicKey?: string;
versions: Array<{ version: string; signature?: string }>;
};
it('still REFUSES an update under a different key without allowKeyChange', async () => {
await expect(submitEntry(freshWithNewKey(), { signKeyPath: newKeyPath })).rejects.toThrow(/original key/i);
});
it('with allowKeyChange: adopts the new key and RE-SIGNS the older versions with it', async () => {
await submitEntry(freshWithNewKey(), { signKeyPath: newKeyPath, allowKeyChange: true, keep: true });
const written = writtenEntry();
expect(written.authorPublicKey).toBe(newPublicKey);
const v1 = written.versions.find((v) => v.version === '1.0.0')!;
expect(verifyArtifact(V1_BYTES, v1.signature!, newPublicKey)).toBe(true);
});
it('a rotation PR says so — title and body ask for the allow-key-change label', async () => {
await submitEntry(freshWithNewKey(), { signKeyPath: newKeyPath, allowKeyChange: true, keep: true });
const pr = calls.find((c) => c.bin === 'gh' && c.args[0] === 'pr' && c.args[1] === 'create')!;
const title = pr.args[pr.args.indexOf('--title') + 1];
const body = pr.args[pr.args.indexOf('--body') + 1];
expect(title).toMatch(/key rotation/i);
expect(body).toMatch(/allow-key-change/);
expect(body).toMatch(/re-trust/i);
});
it('rotateOnly: replaces the entry wholesale (no new version) with a rotation PR', async () => {
const rotated = {
id: 'sync-plug', name: 'Sync Plug', authorPublicKey: newPublicKey,
versions: [{ version: '1.0.0', downloadUrl: 'https://github.com/x/y/releases/download/v1.0.0/plugin.zip', sha256: sha(V1_BYTES), signature: 'new-key-sig-10' }],
};
await submitEntry(rotated, { keep: true, rotateOnly: true });
const written = writtenEntry();
expect(written.authorPublicKey).toBe(newPublicKey);
expect(written.versions).toHaveLength(1);
expect(written.versions[0].signature).toBe('new-key-sig-10');
const pr = calls.find((c) => c.bin === 'gh' && c.args[0] === 'pr' && c.args[1] === 'create')!;
const title = pr.args[pr.args.indexOf('--title') + 1];
const body = pr.args[pr.args.indexOf('--body') + 1];
expect(title).toMatch(/rotate/i);
expect(body).toMatch(/allow-key-change/);
});
});