Order Zero Phase 1: keyed read-only PropLine verification endpoint
Adds GET /api/internal/propline-verify (internal-key gated, read-only) so Phase 1 can run WHERE THE KEY LIVES. Touches no cache, no ledger, no grade; the live adapter and the live ruler are untouched. Breadth reuses proplineAdapter.fetchRaw -- the exact live request -- so what it measures is what the pipeline actually receives. Reports per sport (never pooled): books/prop from the feed vs after our own ALLOWED_BOOKS, props made INVISIBLE by that filter, reference-book presence, DFS presence reported separately, and consensus eligibility. Consensus eligibility is deliberately strict: >=2 REFERENCE books posting BOTH sides at the SAME line. A one-sided quote cannot be de-vigged, and two books at different lines are not the same market -- counting either would overstate how much of the slate can carry a real ruler. Probes the documented-but-unverified endpoints (/sports, /context, /odds/closing, /movement, /results, /exports/resolved-props for four sport keys) and classifies works/partial/no, with 403 = tier-gated and 200-but- empty = partial rather than works. Key safety is the other locked property: the key goes via axios params, never string-interpolated, and every emitted string passes scrubKeys() which removes the literal key AND any surviving apiKey= query value. A test asserts a thrown transport error carrying the key cannot escape. 13 unit tests, hermetic (no network, no key). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* proplineVerify — Order Zero Phase 1 unit tests.
|
||||
*
|
||||
* Hermetic: no network, no key. Locks the two properties that make this tool
|
||||
* trustworthy — (1) the key can never escape in any emitted string, and (2)
|
||||
* consensus eligibility counts only reference books posting BOTH sides at the
|
||||
* SAME line (a one-sided quote cannot be de-vigged, so it cannot rule).
|
||||
*/
|
||||
|
||||
const { __internals } = require('../../src/services/proplineVerify');
|
||||
const { analyseBreadth, scrubKeys, probe, summarise, REFERENCE_CANDIDATES, DFS_PLATFORMS } = __internals;
|
||||
|
||||
const ALLOWED = new Set(['draftkings', 'fanduel', 'betmgm', 'betrivers', 'pinnacle']);
|
||||
|
||||
/** Build a PropLine-shaped event. books = { bookKey: [{player, point, over, under}] } */
|
||||
function event(id, books) {
|
||||
return {
|
||||
id,
|
||||
home_team: 'Home', away_team: 'Away',
|
||||
bookmakers: Object.entries(books).map(([key, quotes]) => ({
|
||||
key,
|
||||
markets: [{
|
||||
key: 'batter_hits',
|
||||
outcomes: quotes.flatMap((q) => [
|
||||
...(q.over != null ? [{ name: 'Over', description: q.player, point: q.point, price: q.over }] : []),
|
||||
...(q.under != null ? [{ name: 'Under', description: q.player, point: q.point, price: q.under }] : []),
|
||||
]),
|
||||
}],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('proplineVerify — key safety', () => {
|
||||
const realKeys = process.env.PROPLINE_API_KEY_1;
|
||||
afterEach(() => { process.env.PROPLINE_API_KEY_1 = realKeys; });
|
||||
|
||||
it('scrubs the configured key out of any string', () => {
|
||||
process.env.PROPLINE_API_KEY_1 = 'SUPERSECRETKEY123';
|
||||
const leak = 'GET https://api.prop-line.com/v1/x?apiKey=SUPERSECRETKEY123&markets=a failed';
|
||||
const out = scrubKeys(leak);
|
||||
expect(out).not.toContain('SUPERSECRETKEY123');
|
||||
expect(out).toContain('<redacted>');
|
||||
});
|
||||
|
||||
it('scrubs an apiKey query value even when the key is not configured', () => {
|
||||
delete process.env.PROPLINE_API_KEY_1;
|
||||
expect(scrubKeys('?apiKey=abc123xyz&z=1')).toBe('?apiKey=<redacted>&z=1');
|
||||
});
|
||||
|
||||
it('probe never throws and never emits the key on a transport error', async () => {
|
||||
process.env.PROPLINE_API_KEY_1 = 'SUPERSECRETKEY123';
|
||||
const httpGet = async () => { throw new Error('connect ECONNREFUSED apiKey=SUPERSECRETKEY123'); };
|
||||
const r = await probe('/x', {}, 'note', httpGet);
|
||||
expect(r.verdict).toBe('no');
|
||||
expect(JSON.stringify(r)).not.toContain('SUPERSECRETKEY123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('proplineVerify — probe verdicts', () => {
|
||||
beforeEach(() => { process.env.PROPLINE_API_KEY_1 = 'k'; });
|
||||
|
||||
const withStatus = (status, data, headers = {}) => async () => ({ status, data, headers });
|
||||
|
||||
it('200 with data = works', async () => {
|
||||
expect((await probe('/x', {}, '', withStatus(200, [{ a: 1 }]))).verdict).toBe('works');
|
||||
});
|
||||
it('200 with an empty body = partial, not works', async () => {
|
||||
expect((await probe('/x', {}, '', withStatus(200, []))).verdict).toBe('partial');
|
||||
});
|
||||
it('403 = no (tier-gated for us)', async () => {
|
||||
expect((await probe('/x', {}, '', withStatus(403, { detail: 'upgrade' }))).verdict).toBe('no');
|
||||
});
|
||||
it('404 = partial (reachable, nothing on file)', async () => {
|
||||
expect((await probe('/x', {}, '', withStatus(404, { detail: 'none' }))).verdict).toBe('partial');
|
||||
});
|
||||
it('counts CSV rows excluding the header', async () => {
|
||||
const r = await probe('/x', {}, '', withStatus(200, 'h1,h2\na,b\nc,d\n'));
|
||||
expect(r.csv_rows).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('proplineVerify — breadth', () => {
|
||||
it('reports the cost of our own allow-list, and props made invisible by it', () => {
|
||||
const raw = [event('e1', {
|
||||
draftkings: [{ player: 'A', point: 1.5, over: -110, under: -110 }],
|
||||
novig: [{ player: 'A', point: 1.5, over: -104, under: -104 }],
|
||||
kalshi: [{ player: 'A', point: 1.5, over: -103, under: -105 }],
|
||||
// B exists at three books, NONE of them admitted -> invisible to VYNDR
|
||||
smarkets: [{ player: 'B', point: 0.5, over: 120, under: -140 }],
|
||||
prizepicks: [{ player: 'B', point: 0.5, over: -119, under: -119 }],
|
||||
underdog: [{ player: 'B', point: 0.5, over: -118, under: -118 }],
|
||||
})];
|
||||
const r = analyseBreadth(raw, ALLOWED);
|
||||
expect(r.props).toBe(2);
|
||||
expect(r.feed.mean_books_per_prop).toBe(3);
|
||||
expect(r.after_allow_list.mean_books_per_prop).toBe(0.5);
|
||||
expect(r.invisible_props).toBe(1);
|
||||
expect(r.invisible_pct).toBe(50);
|
||||
});
|
||||
|
||||
it('consensus eligibility requires TWO-SIDED quotes at the SAME line', () => {
|
||||
const raw = [event('e1', {
|
||||
// Prop A: two reference books, both two-sided, SAME line -> eligible
|
||||
novig: [{ player: 'A', point: 1.5, over: -104, under: -104 }],
|
||||
kalshi: [{ player: 'A', point: 1.5, over: -103, under: -105 }],
|
||||
// Prop B: two reference books but DIFFERENT lines -> NOT eligible
|
||||
smarkets: [{ player: 'B', point: 0.5, over: 100, under: -120 }],
|
||||
matchbook: [{ player: 'B', point: 1.5, over: 200, under: -240 }],
|
||||
// Prop C: two reference books, same line, but one is ONE-SIDED -> NOT eligible
|
||||
polymarket: [{ player: 'C', point: 2.5, over: 150, under: -180 }],
|
||||
bovada: [{ player: 'C', point: 2.5, over: 145 }],
|
||||
})];
|
||||
const r = analyseBreadth(raw, ALLOWED);
|
||||
expect(r.props).toBe(3);
|
||||
expect(r.consensus_eligible_n2).toBe(1);
|
||||
expect(r.consensus_eligible_n3).toBe(0);
|
||||
});
|
||||
|
||||
it('DFS platforms are reported separately and are never reference candidates', () => {
|
||||
for (const dfs of DFS_PLATFORMS) expect(REFERENCE_CANDIDATES).not.toContain(dfs);
|
||||
const raw = [event('e1', {
|
||||
prizepicks: [{ player: 'A', point: 1.5, over: -119, under: -119 }],
|
||||
underdog: [{ player: 'A', point: 1.5, over: -118, under: -118 }],
|
||||
sleeper: [{ player: 'A', point: 1.5, over: -120, under: -120 }],
|
||||
})];
|
||||
const r = analyseBreadth(raw, ALLOWED);
|
||||
// 100% DFS coverage must NOT create consensus eligibility.
|
||||
expect(r.dfs_presence_EXCLUDED_FROM_CONSENSUS.prizepicks.pct).toBe(100);
|
||||
expect(r.consensus_eligible_n2).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores game-line outcomes that carry no player description', () => {
|
||||
const raw = [{
|
||||
id: 'e1',
|
||||
bookmakers: [{ key: 'draftkings', markets: [{ key: 'h2h', outcomes: [{ name: 'Home', price: -130 }] }] }],
|
||||
}];
|
||||
expect(analyseBreadth(raw, ALLOWED).props).toBe(0);
|
||||
});
|
||||
|
||||
it('summarise never returns object internals for a null body', () => {
|
||||
expect(summarise(null)).toBe('null');
|
||||
expect(summarise([{ a: 1 }])).toContain('array(1)');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user