/* ============================================================ Session 67 — the model price must never leave the server for an unentitled viewer, and the FAIR leg must never be gated. `GET /api/snapshot/:sport` is PUBLIC. Session 66 gated model_odds on /api/analyze; this endpoint bypassed that gate and served the model price (and p_win, and an invertible ev_pct) to anonymous callers on every graded row. These tests lock the fix shut. ============================================================ */ const { stripModelPrice, MODEL_FIELDS, MARKET_FIELDS, entitledToModelPrice } = require('../../src/utils/snapshotGating'); const { resolveTierFromRequest } = require('../../src/utils/requestTier'); // A real shape, from a live /api/snapshot/mlb row. const ROW = Object.freeze({ player_name: 'Josh Bell', stat_type: 'hits', line: 0.5, direction: 'over', grade: 'B', book_odds: -210, fair_odds: -173, fair_prob: 0.633, overround: 0.068, devig_method: 'multiplicative', model_odds: -311, p_win: 0.757, ev_pct: 11.7, value: false, takeable: false, projection: 0.8, archetype: 'DRIVER', }); describe('stripModelPrice — free/anonymous never receive the model price', () => { it('removes EVERY model-derived field for free', () => { const [out] = stripModelPrice([ROW], 'free'); for (const f of MODEL_FIELDS) expect(out[f]).toBeUndefined(); }); it('removes them for anonymous / unknown / garbage tiers (fails closed)', () => { for (const tier of [undefined, null, '', 'anonymous', 'nonsense', 'FREE ']) { const [out] = stripModelPrice([ROW], tier); expect(out.model_odds).toBeUndefined(); expect(out.p_win).toBeUndefined(); } }); it('strips ev_pct too — it is INVERTIBLE back to p_win', () => { // ev is a function of (p_win, book_odds); book_odds is public, so leaving // ev behind hands over the model price in a different base. const [out] = stripModelPrice([ROW], 'free'); expect(out.ev_pct).toBeUndefined(); expect(MODEL_FIELDS).toContain('ev_pct'); }); it('KEEPS every market field — the fair leg is never the paywall', () => { const [out] = stripModelPrice([ROW], 'free'); for (const f of MARKET_FIELDS) expect(out[f]).toBe(ROW[f]); expect(out.fair_odds).toBe(-173); }); it('flags the lock so a gated price is not mistaken for a missing one', () => { const [out] = stripModelPrice([ROW], 'free'); expect(out.model_price_locked).toBe(true); }); it('does NOT flag a lock on a row with no price story to lock onto', () => { const [out] = stripModelPrice([{ ...ROW, book_odds: null, fair_odds: null }], 'free'); expect(out.model_price_locked).toBeUndefined(); }); it('analyst and desk receive the full triplet, untouched', () => { for (const tier of ['analyst', 'desk']) { const [out] = stripModelPrice([ROW], tier); expect(out.model_odds).toBe(-311); expect(out.p_win).toBe(0.757); expect(out.ev_pct).toBe(11.7); expect(out.model_price_locked).toBeUndefined(); } expect(entitledToModelPrice('analyst')).toBe(true); expect(entitledToModelPrice('free')).toBe(false); }); it('never mutates the input rows', () => { const input = [{ ...ROW }]; stripModelPrice(input, 'free'); expect(input[0].model_odds).toBe(-311); }); it('survives junk rows without throwing', () => { expect(() => stripModelPrice([null, undefined, 'x', 7], 'free')).not.toThrow(); expect(stripModelPrice(null, 'free')).toBeNull(); }); }); describe('resolveTierFromRequest — a public endpoint that still gates', () => { const noSupabase = { getSupabaseServiceClient: () => null }; it('anonymous (no header) is free', async () => { expect(await resolveTierFromRequest({ headers: {} }, noSupabase)).toBe('free'); }); it('a malformed or empty bearer is free', async () => { expect(await resolveTierFromRequest({ headers: { authorization: 'Basic x' } }, noSupabase)).toBe('free'); expect(await resolveTierFromRequest({ headers: { authorization: 'Bearer ' } }, noSupabase)).toBe('free'); }); it('FAILS CLOSED — a thrown resolver yields free, never an entitled tier', async () => { const boom = { getSupabaseServiceClient: () => { throw new Error('supabase down'); } }; expect(await resolveTierFromRequest({ headers: { authorization: 'Bearer abc' } }, boom)).toBe('free'); }); it('an invalid token is free', async () => { const bad = { getSupabaseServiceClient: () => ({ auth: { getUser: async () => ({ data: null, error: new Error('bad token') }) }, }), }; expect(await resolveTierFromRequest({ headers: { authorization: 'Bearer abc' } }, bad)).toBe('free'); }); it('a valid token resolves the real tier', async () => { const good = { getSupabaseServiceClient: () => ({ auth: { getUser: async () => ({ data: { user: { id: 'u1' } }, error: null }) }, from: () => ({ select: () => ({ eq: () => ({ single: async () => ({ data: { tier: 'desk' } }) }) }) }), }), }; expect(await resolveTierFromRequest({ headers: { authorization: 'Bearer good' } }, good)).toBe('desk'); }); }); describe('the route wiring', () => { const fs = require('fs'); const path = require('path'); const src = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'routes', 'snapshot.js'), 'utf8'); it('gates BOTH response paths (snapshot and the grades fallback)', () => { const gated = src.match(/grades: gate\(/g) || []; expect(gated.length).toBe(2); }); it('never shared-caches a response that varies by entitlement', () => { // A CDN handing a paid payload to an anonymous viewer would defeat the gate. expect(src).toMatch(/req\.headers\.authorization \? 'private, max-age=30' : 'public, max-age=30'/); // Scoped to the /:sport handler — /summary carries counts only (no price // data), so it stays legitimately public-cacheable. const bySport = src.slice(src.indexOf("router.get('/:sport'")); expect(bySport).not.toMatch(/res\.set\('Cache-Control', 'public, max-age=30'\)/); }); it('the browser proxy forwards the bearer token', () => { const proxy = fs.readFileSync( path.join(__dirname, '..', '..', 'web', 'src', 'app', 'api', 'snapshot', '[sport]', 'route.ts'), 'utf8', ); expect(proxy).toMatch(/Authorization: auth/); expect(proxy).toMatch(/private, max-age=30/); }); });