Files
polhunter/test/run.js
T
martbost 523d57624e PolHunter v0.2: the hunt engine
Hand-off sign-in from InstantAdPay (lib/sso.js: HMAC token, five minutes, single use; no sign-up,
no mailer), missions with per-member per-visit proof codes (lib/missions.js: the embed is
answered only from the mission's own origin, only after the dwell; hiding place rotates by day
and member), weighted-low rewards with a daily cap that queues rather than refuses
(lib/rewards.js), the faucet sender on ethers with a low-balance alert (lib/faucet.js), the
one-line embed for the sites (public/embed.js), the hunter board, the public ledger, an
admin page, and the site's own look. Telegram is behind the OUTBOUND gate like everything else.
13-check end-to-end test in test/run.js.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 14:17:44 -05:00

81 lines
6.4 KiB
JavaScript

// End-to-end on a local boot: hand-off sign-in, a mission, the embed's origin and dwell locks,
// the code, the grant, the daily cap queue. No faucet, no outbound. Expected values stated.
'use strict';
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const PORT = 8899, DIR = path.join(__dirname, '..', 'data-test');
fs.rmSync(DIR, { recursive: true, force: true });
const env = Object.assign({}, process.env, { PORT: String(PORT), DATA_DIR: DIR, HUNT_SSO_SECRET: 'x'.repeat(48), ADMIN_KEY: 'adminkey123', SIGNUPS: 'open', SITE_URL: 'http://127.0.0.1:' + PORT });
delete env.CURTAIN; delete env.OUTBOUND; delete env.HUNT_WALLET_KEY; delete env.HUNT_RPC;
const child = spawn(process.execPath, [path.join(__dirname, '..', 'server.js')], { env, stdio: ['ignore', 'pipe', 'pipe'] });
let fails = 0;
const eq = (a, b, m) => { const ok = JSON.stringify(a) === JSON.stringify(b); console.log((ok ? ' ok ' : ' FAIL ') + m + (ok ? '' : ' -> got ' + JSON.stringify(a) + ' want ' + JSON.stringify(b))); if (!ok) fails++; };
const B = 'http://127.0.0.1:' + PORT;
let jar = '';
const call = async (p, opt = {}) => {
const res = await fetch(B + p, Object.assign({ redirect: 'manual' }, opt, { headers: Object.assign({ Cookie: jar, 'Content-Type': 'application/json' }, opt.headers || {}) }));
const sc = res.headers.get('set-cookie'); if (sc && /ph\.sid=/.test(sc)) jar = sc.split(';')[0];
let body = null; try { body = await res.json(); } catch (e) {}
return { status: res.status, body, headers: res.headers };
};
const admin = (p, opt = {}) => call(p, Object.assign(opt, { headers: { 'X-Admin-Key': 'adminkey123' } }));
const sleep = ms => new Promise(r => setTimeout(r, ms));
(async () => {
await sleep(1200);
process.env.HUNT_SSO_SECRET = env.HUNT_SSO_SECRET; const sso = require('../lib/sso');
const h = await call('/health'); eq([h.body.outbound, h.body.signups, h.body.faucet, h.body.sso], [false, true, false, true], 'posture: outbound off, signups open, faucet off, sso on');
// a mission whose host is this test server
const mk = await admin('/api/admin/mission', { method: 'POST', body: JSON.stringify({ id: 'test-1', site: 'Test site', name: 'Find it', brief: 'Open the page and find your code.', url: B + '/index.html', dwell: 5, slots: 3, budget: 0 }) });
eq([mk.status, mk.body.missions.length], [200, 1], 'admin creates a mission');
// sign-in by hand-off
const bad = await call('/auth?t=nonsense'); eq(bad.status, 400, 'a bad hand-off token is refused');
const tok = sso.mint({ memberId: 42, email: 'hunter@example.com', wallet: '0x' + 'ab'.repeat(20), username: 'hunter42' });
const a = await call('/auth?t=' + tok); eq([a.status, a.headers.get('location')], [302, '/app'], 'a good hand-off signs in and lands on the board');
const again = await call('/auth?t=' + tok); eq(again.status, 400, 'the same hand-off token cannot be replayed');
const board = await call('/api/my/board'); eq([board.body.me.memberId, board.body.missions.length, board.body.missions[0].done], [42, 1, false], 'board shows the mission, not done');
// start: token + url
const st = await call('/api/my/start', { method: 'POST', body: JSON.stringify({ missionId: 'test-1' }) });
eq([st.status, /[?&]ph=[a-f0-9]{32}/.test(st.body.url)], [200, true], 'start issues a token on the mission link');
const t = st.body.token;
// the embed's locks
const wrongOrigin = await fetch(B + '/api/embed/code?t=' + t, { headers: { Origin: 'https://evil.example' } });
eq(wrongOrigin.status, 403, 'embed: wrong origin is refused');
const early = await (await fetch(B + '/api/embed/code?t=' + t, { headers: { Origin: 'http://127.0.0.1:' + PORT } })).json();
eq(typeof early.wait, 'number', 'embed: right origin before the dwell is told to wait');
await sleep(5500);
const code = await (await fetch(B + '/api/embed/code?t=' + t, { headers: { Origin: 'http://127.0.0.1:' + PORT } })).json();
eq([/^[A-F0-9]{6}$/.test(code.code), code.slot >= 0 && code.slot < 3], [true, true], 'embed: after the dwell, a 6-char code and a slot 0..2');
// claim
const wrong = await call('/api/my/submit', { method: 'POST', body: JSON.stringify({ token: t, code: 'ZZZZZZ' }) }); eq(wrong.status, 400, 'a wrong code is refused');
const ok = await call('/api/my/submit', { method: 'POST', body: JSON.stringify({ token: t, code: code.code }) });
eq([ok.status, ok.body.pol >= 0.05 && ok.body.pol <= 1, ok.body.queued], [200, true, false], 'the right code pays a drip in range, not queued');
const twice = await call('/api/my/submit', { method: 'POST', body: JSON.stringify({ token: t, code: code.code }) }); eq(twice.status, 400, 'the same mission cannot be claimed twice');
const b2 = await call('/api/my/board'); eq([b2.body.missions[0].done, b2.body.drips.length, b2.body.drips[0].status], [true, 1, 'due'], 'board: done, one drip due (faucet off, so it waits)');
// the daily cap: with the cap set below one drip, the next find queues
await admin('/api/admin/settings', { method: 'POST', body: JSON.stringify({ dailyCapPol: 0.01 }) });
await admin('/api/admin/mission', { method: 'POST', body: JSON.stringify({ id: 'test-2', site: 'Test site', name: 'Second', brief: 'x', url: B + '/index.html', dwell: 5 }) });
const s2 = await call('/api/my/start', { method: 'POST', body: JSON.stringify({ missionId: 'test-2' }) }); await sleep(5500);
const c2 = await (await fetch(B + '/api/embed/code?t=' + s2.body.token, { headers: { Origin: 'http://127.0.0.1:' + PORT } })).json();
const q = await call('/api/my/submit', { method: 'POST', body: JSON.stringify({ token: s2.body.token, code: c2.code }) });
eq([q.status, q.body.queued], [200, true], 'over the daily cap, the find is queued rather than refused');
// the draw is weighted low
const rewards = require('../lib/rewards'); const draws = Array.from({ length: 4000 }, () => rewards.draw(0.05, 1));
const median = draws.sort((x, y) => x - y)[2000]; eq([draws.every(d => d >= 0.05 && d <= 1), median < 0.35], [true, true], 'the draw stays in range and its median sits low (' + median + ')');
// nothing outward: no telegram env, no faucet env
const led = await call('/api/ledger'); eq(led.body.totals.paid, 0, 'nothing has been paid, because no faucet is configured');
console.log(fails ? '\nFAILURES: ' + fails : '\nALL POLHUNTER CHECKS PASS');
child.kill(); fs.rmSync(DIR, { recursive: true, force: true }); process.exit(fails ? 1 : 0);
})().catch(e => { console.error('CRASH', e); child.kill(); process.exit(1); });