mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
196 lines
8.4 KiB
TypeScript
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/);
|
|
});
|
|
});
|