morphit/apps/indexer/test/handlers/release.test.ts

765 lines
23 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import handler from '$indexer/handlers/release';
import { fakeConfig, makeCtx, mockBlurt } from '../testutils/context';
import { makeMockClient } from '../testutils/mockClient';
const OFFICIAL_PUBKEY = 'BLT6CVC6C3PgmMe5xDtxFXJvGHaLnUTtcsK1ghHomDqLPWW7yeMp9';
function validPayload() {
return {
version: '0.3.0',
hash_manifest: {
// SRI format: sha256-<43-base64-chars>=
'index.html': 'sha256-' + 'a'.repeat(43) + '=',
'app.js': 'sha256-' + 'b'.repeat(43) + '='
},
endpoints: {
blurt_rpc: ['https://rpc.blurt.blog'],
morphit_relay: ['https://relay.morphit.io']
},
signature: 'aBcDeF=='
};
}
const accountWithOfficialKey = {
name: 'morphit',
posting: {
weight_threshold: 1,
account_auths: [] as const,
key_auths: [[OFFICIAL_PUBKEY, 1]] as const
},
active: {
weight_threshold: 1,
account_auths: [] as const,
key_auths: [] as const
},
owner: {
weight_threshold: 1,
account_auths: [] as const,
key_auths: [] as const
},
memo_key: OFFICIAL_PUBKEY
};
const accountWithDifferentKey = {
...accountWithOfficialKey,
posting: {
...accountWithOfficialKey.posting,
key_auths: [['BLTrotated-key-not-the-pinned-one', 1]] as const
}
};
describe('release handler', () => {
it('records valid=true when signer, pubkey, and payload all match', async () => {
const mock = makeMockClient([{ match: 'INSERT INTO releases' }]);
const blurt = mockBlurt({
getAccount: async () => accountWithOfficialKey
});
const r = await handler(
makeCtx({
signer: 'morphit',
payload: validPayload(),
blurt,
config: fakeConfig({
officialPostingPubkey: OFFICIAL_PUBKEY,
officialAccountName: 'morphit'
})
}),
mock.client
);
// Applied → ok:true so the audit row survives savepoint release.
expect(r).toEqual({ ok: true });
// Eighth param of the INSERT is the `valid` boolean — verify true.
const q = mock.queries[0]!;
expect(q.params[7]).toBe(true);
});
it('records valid=false when signer is not the official account', async () => {
const mock = makeMockClient([{ match: 'INSERT INTO releases' }]);
// getAccount is never called because check 1 fails first — pass
// a blurt proxy that would throw to prove that.
const blurt = mockBlurt({});
const r = await handler(
makeCtx({
signer: 'eve',
payload: validPayload(),
blurt,
config: fakeConfig({ officialAccountName: 'morphit' })
}),
mock.client
);
expect(r).toEqual({ ok: true });
const q = mock.queries[0]!;
expect(q.params[7]).toBe(false);
});
it('records valid=false when the chain pubkey differs from the pinned value', async () => {
const mock = makeMockClient([{ match: 'INSERT INTO releases' }]);
const blurt = mockBlurt({
getAccount: async () => accountWithDifferentKey
});
const r = await handler(
makeCtx({
signer: 'morphit',
payload: validPayload(),
blurt,
config: fakeConfig({
officialPostingPubkey: OFFICIAL_PUBKEY,
officialAccountName: 'morphit'
})
}),
mock.client
);
expect(r).toEqual({ ok: true });
expect(mock.queries[0]!.params[7]).toBe(false);
});
it('records valid=false when the signer account has no single posting key', async () => {
const mock = makeMockClient([{ match: 'INSERT INTO releases' }]);
const blurt = mockBlurt({
getAccount: async () => null // account vanished (impossible but tests the null branch)
});
const r = await handler(
makeCtx({
signer: 'morphit',
payload: validPayload(),
blurt,
config: fakeConfig({
officialPostingPubkey: OFFICIAL_PUBKEY,
officialAccountName: 'morphit'
})
}),
mock.client
);
expect(r).toEqual({ ok: true });
expect(mock.queries[0]!.params[7]).toBe(false);
});
it('rejects structurally malformed payload (no row written)', async () => {
const mock = makeMockClient();
const blurt = mockBlurt({});
const r = await handler(
makeCtx({
signer: 'morphit',
payload: { not: 'a release' },
blurt
}),
mock.client
);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toBe('version_not_string');
expect(mock.queries).toHaveLength(0);
});
it('rejects non-semver version string', async () => {
const mock = makeMockClient();
const blurt = mockBlurt({});
const r = await handler(
makeCtx({
signer: 'morphit',
payload: { ...validPayload(), version: 'v3' },
blurt
}),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'version_not_semver' });
});
// Finding L regression: 4KB cap on hash_manifest and endpoints.
// Critically, this check runs at validation time — before chain
// interaction — so a payload past the cap never triggers a
// signature-verify RPC round trip.
it('rejects hash_manifest exceeding 4KB serialized', async () => {
const mock = makeMockClient();
const blurt = mockBlurt({});
const r = await handler(
makeCtx({
signer: 'morphit',
payload: {
...validPayload(),
hash_manifest: { padding: 'x'.repeat(4100) }
},
blurt
}),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'hash_manifest_too_large' });
expect(mock.queries).toHaveLength(0);
});
it('rejects endpoints exceeding 4KB serialized', async () => {
const mock = makeMockClient();
const blurt = mockBlurt({});
const r = await handler(
makeCtx({
signer: 'morphit',
payload: {
...validPayload(),
endpoints: { padding: 'x'.repeat(4100) }
},
blurt
}),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'endpoints_too_large' });
expect(mock.queries).toHaveLength(0);
});
it('propagates chain errors so the block rolls back and retries', async () => {
// If getAccount throws (RPC unreachable mid-tick), the handler
// re-throws so the poller rolls the block back. We'd rather
// retry next tick than commit an unverified valid=true.
const mock = makeMockClient();
const blurt = mockBlurt({
getAccount: async () => {
throw new Error('chain unreachable');
}
});
await expect(
handler(
makeCtx({
signer: 'morphit',
payload: validPayload(),
blurt,
config: fakeConfig({
officialPostingPubkey: OFFICIAL_PUBKEY,
officialAccountName: 'morphit'
})
}),
mock.client
)
).rejects.toThrow('chain unreachable');
// No row was written before the throw.
expect(mock.queries).toHaveLength(0);
});
});
// ─── Part 106 — treasury chain-pin handler tests ─────────────────────
//
// These tests exercise the handler's structural validation of the
// optional `treasury` block, AND prove byte-for-byte parity with
// the frontend validator (now @morphit/release-schema).
// Any payload that one accepts the other must accept; any payload
// one rejects the other must reject with the same reason name.
import { validateReleasePayload } from '@morphit/release-schema';
const VALID_BTC_ADDR = 'bc1q' + 'a'.repeat(38);
const VALID_XMR_ADDR = '4' + 'A'.repeat(94);
// A 64-hex string used ONLY in tests to feed the validator a
// payload that contains a viewkey field — to verify that the
// validator silently strips it (Part 107 invariant). Not a
// real key; nothing here is a real key. Named to make the
// test intent unambiguous.
const VALID_XMR_VK_LOOKING = 'a'.repeat(64);
function payloadWithTreasury(treasury: unknown) {
return { ...validPayload(), treasury };
}
// cp556 — distribution-anchor fixtures.
const VALID_SOURCE_SHA256 = 'a'.repeat(64); // lowercase hex
const VALID_GPG_FPR = 'DEADBEEF'.repeat(5); // 40 hex (v4 fingerprint)
const VALID_IPFS_CID_V0 = 'Qm' + 'a'.repeat(44); // base58btc, 46 chars
function payloadWithDistribution(distribution: unknown) {
return { ...validPayload(), distribution };
}
describe('release handler — Part 106 + 107 treasury validation', () => {
const validTreasury = {
btc: { address: VALID_BTC_ADDR, satoshis: 416 },
// Part 107: NO viewkey field in canonical chain-pinned shape.
xmr: { address: VALID_XMR_ADDR, piconero: '781250000' }
};
it('accepts a payload with a valid treasury block (no viewkey)', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
signer: 'morphit',
payload: payloadWithTreasury(validTreasury),
blurt: mockBlurt({ getAccount: async () => accountWithOfficialKey }),
config: fakeConfig({
officialPostingPubkey: OFFICIAL_PUBKEY,
officialAccountName: 'morphit'
})
}),
mock.client
);
expect(r).toEqual({ ok: true });
const insertQuery = mock.queries.at(-1);
expect(insertQuery).toBeDefined();
expect(insertQuery!.params[10]).not.toBeNull();
const persisted = JSON.parse(insertQuery!.params[10] as string);
expect(persisted.btc.address).toBe(VALID_BTC_ADDR);
expect(persisted.xmr.address).toBe(VALID_XMR_ADDR);
expect(persisted.xmr.piconero).toBe('781250000');
// CRITICAL Part 107 invariant: the persisted row MUST NOT
// contain a viewkey field, even if a buggy payload tried to
// include one. This is the privacy guarantee.
expect('viewkey' in persisted.xmr).toBe(false);
});
it('Part 107: payload with viewkey present → silently stripped, NOT persisted', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
signer: 'morphit',
payload: payloadWithTreasury({
btc: null,
xmr: {
address: VALID_XMR_ADDR,
viewkey: VALID_XMR_VK_LOOKING, // present, would-be malicious
piconero: '781250000'
}
}),
blurt: mockBlurt({ getAccount: async () => accountWithOfficialKey }),
config: fakeConfig({
officialPostingPubkey: OFFICIAL_PUBKEY,
officialAccountName: 'morphit'
})
}),
mock.client
);
// Accepts the payload (we don't reject — Part 107 reasons:
// don't break parsing of legacy/malicious release ops; just
// strip the field).
expect(r).toEqual({ ok: true });
const insertQuery = mock.queries.at(-1);
expect(insertQuery).toBeDefined();
expect(insertQuery!.params[10]).not.toBeNull();
const persisted = JSON.parse(insertQuery!.params[10] as string);
// Privacy invariant: viewkey MUST NOT have been persisted.
expect('viewkey' in persisted.xmr).toBe(false);
expect(persisted.xmr.address).toBe(VALID_XMR_ADDR);
expect(persisted.xmr.piconero).toBe('781250000');
});
it('accepts a payload without a treasury block (back-compat)', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
signer: 'morphit',
payload: validPayload(),
blurt: mockBlurt({ getAccount: async () => accountWithOfficialKey }),
config: fakeConfig({
officialPostingPubkey: OFFICIAL_PUBKEY,
officialAccountName: 'morphit'
})
}),
mock.client
);
expect(r).toEqual({ ok: true });
const insertQuery = mock.queries.at(-1);
expect(insertQuery!.params[10]).toBeNull();
});
it('records valid=false with treasury_btc_address_not_mainnet on testnet BTC', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
signer: 'morphit',
payload: payloadWithTreasury({
btc: { address: 'tb1q' + 'a'.repeat(38), satoshis: 416 },
xmr: null
}),
blurt: mockBlurt({ getAccount: async () => accountWithOfficialKey }),
config: fakeConfig({
officialPostingPubkey: OFFICIAL_PUBKEY,
officialAccountName: 'morphit'
})
}),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'treasury_btc_address_not_mainnet' });
expect(mock.queries).toHaveLength(0);
});
it('cp372: accepts a treasury with a chain-pinned blurt base (persisted)', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
signer: 'morphit',
payload: payloadWithTreasury({
btc: { address: VALID_BTC_ADDR, satoshis: 416 },
xmr: { address: VALID_XMR_ADDR, piconero: '781250000' },
blurt: { base: 62.5 }
}),
blurt: mockBlurt({ getAccount: async () => accountWithOfficialKey }),
config: fakeConfig({
officialPostingPubkey: OFFICIAL_PUBKEY,
officialAccountName: 'morphit'
})
}),
mock.client
);
expect(r).toEqual({ ok: true });
const persisted = JSON.parse(mock.queries.at(-1)!.params[10] as string);
expect(persisted.blurt.base).toBe(62.5);
});
it('cp372: treasury without blurt persists byte-identically (no blurt key)', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
signer: 'morphit',
payload: payloadWithTreasury({
btc: { address: VALID_BTC_ADDR, satoshis: 416 },
xmr: null
}),
blurt: mockBlurt({ getAccount: async () => accountWithOfficialKey }),
config: fakeConfig({
officialPostingPubkey: OFFICIAL_PUBKEY,
officialAccountName: 'morphit'
})
}),
mock.client
);
expect(r).toEqual({ ok: true });
const persisted = JSON.parse(mock.queries.at(-1)!.params[10] as string);
expect('blurt' in persisted).toBe(false);
});
it('cp372: rejects 0 blurt base (treasury_blurt_base_invalid)', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
signer: 'morphit',
payload: payloadWithTreasury({ btc: null, xmr: null, blurt: { base: 0 } }),
blurt: mockBlurt({ getAccount: async () => accountWithOfficialKey }),
config: fakeConfig({
officialPostingPubkey: OFFICIAL_PUBKEY,
officialAccountName: 'morphit'
})
}),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'treasury_blurt_base_invalid' });
expect(mock.queries).toHaveLength(0);
});
it('cp372: rejects blurt base over the sanity ceiling (treasury_blurt_base_too_large)', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
signer: 'morphit',
payload: payloadWithTreasury({ btc: null, xmr: null, blurt: { base: 10_000_001 } }),
blurt: mockBlurt({ getAccount: async () => accountWithOfficialKey }),
config: fakeConfig({
officialPostingPubkey: OFFICIAL_PUBKEY,
officialAccountName: 'morphit'
})
}),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'treasury_blurt_base_too_large' });
expect(mock.queries).toHaveLength(0);
});
it('rejects testnet XMR address (treasury_xmr_address_not_mainnet)', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
signer: 'morphit',
payload: payloadWithTreasury({
btc: null,
xmr: { address: '9' + 'A'.repeat(94), piconero: '1' }
}),
blurt: mockBlurt({ getAccount: async () => accountWithOfficialKey }),
config: fakeConfig({
officialPostingPubkey: OFFICIAL_PUBKEY,
officialAccountName: 'morphit'
})
}),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'treasury_xmr_address_not_mainnet' });
expect(mock.queries).toHaveLength(0);
});
it('rejects 0-satoshi BTC (treasury_btc_satoshis_invalid)', async () => {
const mock = makeMockClient();
const r = await handler(
makeCtx({
signer: 'morphit',
payload: payloadWithTreasury({
btc: { address: VALID_BTC_ADDR, satoshis: 0 },
xmr: null
}),
blurt: mockBlurt({ getAccount: async () => accountWithOfficialKey }),
config: fakeConfig({
officialPostingPubkey: OFFICIAL_PUBKEY,
officialAccountName: 'morphit'
})
}),
mock.client
);
expect(r).toEqual({ ok: false, reason: 'treasury_btc_satoshis_invalid' });
expect(mock.queries).toHaveLength(0);
});
});
// ─── Part 106 — INDEXER ↔ FRONTEND validator parity ──────────────────
//
// The indexer's structural validator (in handlers/release.ts) and
// the frontend's validator (now @morphit/release-schema)
// MUST agree on every payload, with matching reason names. This
// test runs a battery of payloads through both validators and
// confirms identical accept/reject outcomes.
//
// Why this matters: a divergence means either (a) the indexer
// stores a row the frontend rejects, breaking the chain-direct
// trust path, or (b) the frontend trusts a row the indexer
// considers invalid, breaking the federated invariant. Both
// cases let an attacker exploit the gap.
//
// We run via the FRONTEND validator (which mirrors the indexer's
// rules in releaseValidate.ts:validateTreasury). Any payload that
// passes here MUST pass the indexer's handler — verified above
// in the per-handler tests, and re-verified in the smoke at
// apps/indexer/scripts/release-validator-smoke.ts.
describe('release validator parity — frontend ↔ indexer', () => {
const cases: Array<{ name: string; payload: unknown; expect: 'ok' | string }> = [
{ name: 'no treasury → ok', payload: validPayload(), expect: 'ok' },
{ name: 'treasury=null → ok', payload: payloadWithTreasury(null), expect: 'ok' },
{
name: 'btc only (mainnet bech32) → ok',
payload: payloadWithTreasury({
btc: { address: VALID_BTC_ADDR, satoshis: 416 },
xmr: null
}),
expect: 'ok'
},
{
name: 'xmr only (primary 4..., no viewkey) → ok',
payload: payloadWithTreasury({
btc: null,
xmr: { address: VALID_XMR_ADDR, piconero: '1' }
}),
expect: 'ok'
},
{
name: 'xmr subaddress (8..., no viewkey) → ok',
payload: payloadWithTreasury({
btc: null,
xmr: {
address: '8' + 'A'.repeat(94),
piconero: '1'
}
}),
expect: 'ok'
},
{
name: 'Part 107: xmr WITH viewkey field (legacy/hostile) → ok, silently stripped',
payload: payloadWithTreasury({
btc: null,
xmr: {
address: VALID_XMR_ADDR,
viewkey: VALID_XMR_VK_LOOKING,
piconero: '1'
}
}),
expect: 'ok'
},
{
name: 'btc testnet → treasury_btc_address_not_mainnet',
payload: payloadWithTreasury({
btc: { address: 'tb1q' + 'a'.repeat(38), satoshis: 416 },
xmr: null
}),
expect: 'treasury_btc_address_not_mainnet'
},
{
name: 'btc 0 satoshis → treasury_btc_satoshis_invalid',
payload: payloadWithTreasury({
btc: { address: VALID_BTC_ADDR, satoshis: 0 },
xmr: null
}),
expect: 'treasury_btc_satoshis_invalid'
},
{
name: 'btc 1.5 satoshis → treasury_btc_satoshis_invalid',
payload: payloadWithTreasury({
btc: { address: VALID_BTC_ADDR, satoshis: 1.5 },
xmr: null
}),
expect: 'treasury_btc_satoshis_invalid'
},
{
name: 'xmr testnet (9...) → treasury_xmr_address_not_mainnet',
payload: payloadWithTreasury({
btc: null,
xmr: { address: '9' + 'A'.repeat(94), piconero: '1' }
}),
expect: 'treasury_xmr_address_not_mainnet'
},
{
name: 'xmr stagenet (5...) → treasury_xmr_address_not_mainnet',
payload: payloadWithTreasury({
btc: null,
xmr: { address: '5' + 'A'.repeat(94), piconero: '1' }
}),
expect: 'treasury_xmr_address_not_mainnet'
},
{
name: 'xmr piconero "0" → treasury_xmr_piconero_invalid',
payload: payloadWithTreasury({
btc: null,
xmr: { address: VALID_XMR_ADDR, piconero: '0' }
}),
expect: 'treasury_xmr_piconero_invalid'
},
{
name: 'xmr piconero "1.5" → treasury_xmr_piconero_invalid',
payload: payloadWithTreasury({
btc: null,
xmr: { address: VALID_XMR_ADDR, piconero: '1.5' }
}),
expect: 'treasury_xmr_piconero_invalid'
},
{
name: 'treasury as array → treasury_not_object',
payload: payloadWithTreasury([]),
expect: 'treasury_not_object'
},
{
name: 'treasury as string → treasury_not_object',
payload: payloadWithTreasury('treasury'),
expect: 'treasury_not_object'
},
// cp556 — decentralized-distribution anchor parity.
{ name: 'distribution=null → ok', payload: payloadWithDistribution(null), expect: 'ok' },
{
name: 'distribution minimal (sha + fpr) → ok',
payload: payloadWithDistribution({
source_sha256: VALID_SOURCE_SHA256,
gpg_fingerprint: VALID_GPG_FPR
}),
expect: 'ok'
},
{
name: 'distribution full (sha + fpr + cid + mirrors) → ok',
payload: payloadWithDistribution({
source_sha256: VALID_SOURCE_SHA256,
gpg_fingerprint: VALID_GPG_FPR,
ipfs_cid: VALID_IPFS_CID_V0,
mirrors: ['https://codeberg.org/agorise/morphit', 'https://ipfs.io/ipfs/' + VALID_IPFS_CID_V0]
}),
expect: 'ok'
},
{
// v1.9.x — stable IPNS "always latest" pointer.
name: 'distribution with ipns_name (k51…) → ok',
payload: payloadWithDistribution({
source_sha256: VALID_SOURCE_SHA256,
gpg_fingerprint: VALID_GPG_FPR,
ipfs_cid: VALID_IPFS_CID_V0,
ipns_name: 'k51qzi5uqu5dja8jme7xnwh50160jsfsvuoifc1ehfip3ybv0vkpxy9caigzj4'
}),
expect: 'ok'
},
{
name: 'distribution bad ipns_name → distribution_ipns_name_invalid',
payload: payloadWithDistribution({
source_sha256: VALID_SOURCE_SHA256,
gpg_fingerprint: VALID_GPG_FPR,
ipns_name: 'not-a-valid-ipns-name'
}),
expect: 'distribution_ipns_name_invalid'
},
{
name: 'distribution 64-hex (v5) fingerprint → ok',
payload: payloadWithDistribution({
source_sha256: VALID_SOURCE_SHA256,
gpg_fingerprint: 'abcdef01'.repeat(8) // 64 hex
}),
expect: 'ok'
},
{
name: 'distribution as array → distribution_not_object',
payload: payloadWithDistribution([]),
expect: 'distribution_not_object'
},
{
name: 'distribution missing source_sha256 → distribution_source_sha256_invalid',
payload: payloadWithDistribution({ gpg_fingerprint: VALID_GPG_FPR }),
expect: 'distribution_source_sha256_invalid'
},
{
name: 'distribution UPPERCASE sha256 → distribution_source_sha256_invalid',
payload: payloadWithDistribution({
source_sha256: 'A'.repeat(64),
gpg_fingerprint: VALID_GPG_FPR
}),
expect: 'distribution_source_sha256_invalid'
},
{
name: 'distribution short fingerprint (39) → distribution_gpg_fingerprint_invalid',
payload: payloadWithDistribution({
source_sha256: VALID_SOURCE_SHA256,
gpg_fingerprint: 'a'.repeat(39)
}),
expect: 'distribution_gpg_fingerprint_invalid'
},
{
name: 'distribution bad ipfs_cid → distribution_ipfs_cid_invalid',
payload: payloadWithDistribution({
source_sha256: VALID_SOURCE_SHA256,
gpg_fingerprint: VALID_GPG_FPR,
ipfs_cid: 'not-a-cid'
}),
expect: 'distribution_ipfs_cid_invalid'
},
{
name: 'distribution mirrors not array → distribution_mirrors_not_array',
payload: payloadWithDistribution({
source_sha256: VALID_SOURCE_SHA256,
gpg_fingerprint: VALID_GPG_FPR,
mirrors: 'https://codeberg.org/agorise/morphit'
}),
expect: 'distribution_mirrors_not_array'
},
{
name: 'distribution non-https mirror → distribution_mirror_invalid',
payload: payloadWithDistribution({
source_sha256: VALID_SOURCE_SHA256,
gpg_fingerprint: VALID_GPG_FPR,
mirrors: ['http://codeberg.org/agorise/morphit']
}),
expect: 'distribution_mirror_invalid'
},
{
// v1.9.6 (Ken) — mirror cap bumped 8 → 10 (gitea.com + framagit.org).
name: 'distribution 10 mirrors (at the cap) → ok',
payload: payloadWithDistribution({
source_sha256: VALID_SOURCE_SHA256,
gpg_fingerprint: VALID_GPG_FPR,
mirrors: Array.from({ length: 10 }, (_, i) => `https://m${i}.example.org/x`)
}),
expect: 'ok'
},
{
name: 'distribution too many mirrors (11 > cap 10) → distribution_mirror_invalid',
payload: payloadWithDistribution({
source_sha256: VALID_SOURCE_SHA256,
gpg_fingerprint: VALID_GPG_FPR,
mirrors: Array.from({ length: 11 }, (_, i) => `https://m${i}.example.org/x`)
}),
expect: 'distribution_mirror_invalid'
}
];
for (const c of cases) {
it(c.name, () => {
const r = validateReleasePayload(c.payload);
if (c.expect === 'ok') {
expect(r.ok).toBe(true);
} else {
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toBe(c.expect);
}
});
}
});