Update game data
Refresh schedules, gear, and the rest of the SplatNet data.
diff --git a/test/admin/access.test.mjs b/test/admin/access.test.mjs index ee8296d..37ce5a9 100644 --- a/test/admin/access.test.mjs +++ b/test/admin/access.test.mjs @@ -4,19 +4,44 @@ import { generateKeyPair, SignJWT } from 'jose'; import { verifyAccess } from '../../workers/updater/src/admin/access.mjs'; const { publicKey, privateKey } = await generateKeyPair('RS256'); -const env = { ADMIN_HOSTNAME: 'admin.example.test', ACCESS_TEAM_DOMAIN: 'example.cloudflareaccess.com', ACCESS_AUD: 'admin-audience' }; -async function token({ audience = env.ACCESS_AUD, issuer = 'https://' + env.ACCESS_TEAM_DOMAIN, expires = '5m' } = {}) { - return new SignJWT({ email: 'admin@example.test' }).setProtectedHeader({ alg: 'RS256' }).setSubject('user-id').setIssuer(issuer).setAudience(audience).setExpirationTime(expires).sign(privateKey); +const env = { + ADMIN_HOSTNAME: 'admin.example.test', + ACCESS_TEAM_DOMAIN: 'example.cloudflareaccess.com', + ACCESS_AUD: 'admin-audience', +}; +async function token({ + audience = env.ACCESS_AUD, + issuer = 'https://' + env.ACCESS_TEAM_DOMAIN, + expires = '5m', +} = {}) { + return new SignJWT({ email: 'admin@example.test' }) + .setProtectedHeader({ alg: 'RS256' }) + .setSubject('user-id') + .setIssuer(issuer) + .setAudience(audience) + .setExpirationTime(expires) + .sign(privateKey); } function request(jwt, host = env.ADMIN_HOSTNAME) { - return new Request(`https://${host}/admin/`, { headers: jwt ? { 'Cf-Access-Jwt-Assertion': jwt } : {} }); + return new Request(`https://${host}/admin/`, { + headers: jwt ? { 'Cf-Access-Jwt-Assertion': jwt } : {}, + }); } test('validates Access signature, issuer, audience, expiration and configured hostname', async () => { - assert.deepEqual(await verifyAccess(request(await token()), env, publicKey), { email: 'admin@example.test' }); - for (const options of [{ audience: 'different-app' }, { issuer: 'https://other.cloudflareaccess.com' }, { expires: '0s' }]) + assert.deepEqual(await verifyAccess(request(await token()), env, publicKey), { + email: 'admin@example.test', + }); + for (const options of [ + { audience: 'different-app' }, + { issuer: 'https://other.cloudflareaccess.com' }, + { expires: '0s' }, + ]) assert.equal(await verifyAccess(request(await token(options)), env, publicKey), null); - assert.equal(await verifyAccess(request(await token(), 'other.workers.dev'), env, publicKey), null); + assert.equal( + await verifyAccess(request(await token(), 'other.workers.dev'), env, publicKey), + null, + ); const wrong = await generateKeyPair('RS256'); assert.equal(await verifyAccess(request(await token()), env, wrong.publicKey), null); }); diff --git a/test/admin/log.test.mjs b/test/admin/log.test.mjs index 7993b09..6f155bc 100644 --- a/test/admin/log.test.mjs +++ b/test/admin/log.test.mjs @@ -14,15 +14,34 @@ test('bounds retained logs, redacts secrets, and preserves normal console output assert.equal(capture.snapshot.omitted, 6); assert.equal(capture.snapshot.lines.at(-1).text, '[redacted] Bearer [redacted]'); assert.equal(consoleLog.mock.callCount(), 206); - } finally { mock.restoreAll(); } + } finally { + mock.restoreAll(); + } }); test('concurrent run contexts do not capture each other or unrelated messages', async () => { mock.method(console, 'info', () => {}); try { - const a = createRunLog(), b = createRunLog(); - await Promise.all([a.run(async () => { await Promise.resolve(); logMessage('info', 'a'); }), b.run(async () => { logMessage('info', 'b'); })]); + const a = createRunLog(), + b = createRunLog(); + await Promise.all([ + a.run(async () => { + await Promise.resolve(); + logMessage('info', 'a'); + }), + b.run(async () => { + logMessage('info', 'b'); + }), + ]); logMessage('info', 'outside'); - assert.deepEqual(a.snapshot.lines.map(l => l.text), ['a']); - assert.deepEqual(b.snapshot.lines.map(l => l.text), ['b']); - } finally { mock.restoreAll(); } + assert.deepEqual( + a.snapshot.lines.map((l) => l.text), + ['a'], + ); + assert.deepEqual( + b.snapshot.lines.map((l) => l.text), + ['b'], + ); + } finally { + mock.restoreAll(); + } }); diff --git a/workers/updater/admin.spec.mjs b/workers/updater/admin.spec.mjs index 946499e..8681ff8 100644 --- a/workers/updater/admin.spec.mjs +++ b/workers/updater/admin.spec.mjs @@ -4,12 +4,21 @@ import { verifyAccess } from './src/admin/access.mjs'; vi.mock('./src/admin/access.mjs', () => ({ verifyAccess: vi.fn() })); afterEach(() => vi.resetAllMocks()); const url = 'https://admin.example.test'; -const post = (mode = 'both', origin = url) => new Request(url + '/admin/api/run', { method: 'POST', headers: { Origin: origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ mode }) }); +const post = (mode = 'both', origin = url) => + new Request(url + '/admin/api/run', { + method: 'POST', + headers: { Origin: origin, 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode }), + }); it('requires Access before exposing the panel, status or actions', async () => { verifyAccess.mockResolvedValue(null); const get = vi.fn(); - for (const request of [new Request(url + '/admin/'), new Request(url + '/admin/api/status'), post()]) + for (const request of [ + new Request(url + '/admin/'), + new Request(url + '/admin/api/status'), + post(), + ]) expect((await adminRequest(request, {}, get)).status).toBe(401); expect(get).not.toHaveBeenCalled(); }); @@ -22,7 +31,10 @@ it('rejects cross-origin requests and unknown modes before scheduling work', asy }); it('returns an accepted run immediately and reports overlap without starting another', async () => { verifyAccess.mockResolvedValue({ email: 'admin@example.test' }); - const startManual = vi.fn(async mode => ({ ok: true, run: { id: 'one', mode, status: 'queued' } })); + const startManual = vi.fn(async (mode) => ({ + ok: true, + run: { id: 'one', mode, status: 'queued' }, + })); expect((await adminRequest(post('social'), {}, () => ({ startManual }))).status).toBe(202); expect(startManual).toHaveBeenCalledWith('social'); startManual.mockResolvedValue({ ok: false, busy: true }); diff --git a/workers/updater/preview/server.mjs b/workers/updater/preview/server.mjs index 0663d9d..48934aa 100644 --- a/workers/updater/preview/server.mjs +++ b/workers/updater/preview/server.mjs @@ -6,45 +6,148 @@ import { randomUUID } from 'node:crypto'; const port = Number(process.env.ADMIN_PREVIEW_PORT || 8788); const hour = Math.floor(Date.now() / 3600000) * 3600000; const state = { - preview: true, user: { email: 'Local preview' }, paused: false, busy: false, - hourlyAt: hour + 3600000 + 10000, retryAt: null, pendingManual: null, - lastManualRun: { logs: { lines: [{ at: hour - 1800000, level: 'info', text: 'Starting social cycle' }, { at: hour - 1757700, level: 'error', text: 'Browser screenshot timed out. The post was not sent.' }], omitted: 0 }, id: 'previous', mode: 'social', ok: false, status: 'failed', startedAt: hour - 1800000, runMs: 42300, error: 'Browser screenshot timed out. The post was not sent.' }, - lastRun: { mode: 'both', ok: true, startedAt: hour + 10000, runMs: 18200, updaters: { ok: true }, social: { ok: true } }, + preview: true, + user: { email: 'Local preview' }, + paused: false, + busy: false, + hourlyAt: hour + 3600000 + 10000, + retryAt: null, + pendingManual: null, + lastManualRun: { + logs: { + lines: [ + { at: hour - 1800000, level: 'info', text: 'Starting social cycle' }, + { + at: hour - 1757700, + level: 'error', + text: 'Browser screenshot timed out. The post was not sent.', + }, + ], + omitted: 0, + }, + id: 'previous', + mode: 'social', + ok: false, + status: 'failed', + startedAt: hour - 1800000, + runMs: 42300, + error: 'Browser screenshot timed out. The post was not sent.', + }, + lastRun: { + mode: 'both', + ok: true, + startedAt: hour + 10000, + runMs: 18200, + updaters: { ok: true }, + social: { ok: true }, + }, }; createServer(async (request, response) => { response.setHeader('Cache-Control', 'no-store'); const url = new URL(request.url, `http://127.0.0.1:${port}`); - function json(body, status = 200) { response.writeHead(status, { 'Content-Type': 'application/json' }); response.end(JSON.stringify(body)); } + function json(body, status = 200) { + response.writeHead(status, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify(body)); + } try { if (request.method === 'GET' && ['/', '/admin', '/admin/'].includes(url.pathname)) { response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - return response.end((await readFile(new URL('../src/admin/page.html', import.meta.url), 'utf8')).replaceAll('__NONCE__', 'preview')); + return response.end( + (await readFile(new URL('../src/admin/page.html', import.meta.url), 'utf8')).replaceAll( + '__NONCE__', + 'preview', + ), + ); } if (request.method === 'GET' && url.pathname === '/admin/api/status') return json(state); if (request.method === 'POST' && url.pathname === '/admin/api/run') { - if (request.headers.origin !== `http://${request.headers.host}`) return json({ error: 'Invalid origin.' }, 403); + if (request.headers.origin !== `http://${request.headers.host}`) + return json({ error: 'Invalid origin.' }, 403); if (state.busy) return json({ error: 'A run is already active.' }, 409); let body = ''; - for await (const chunk of request) { body += chunk; if (body.length > 1024) return json({ error: 'Request too large.' }, 413); } + for await (const chunk of request) { + body += chunk; + if (body.length > 1024) return json({ error: 'Request too large.' }, 413); + } const { mode } = JSON.parse(body); if (!['data', 'social', 'both'].includes(mode)) return json({ error: 'Unknown mode.' }, 400); const run = { id: randomUUID(), mode, status: 'queued', requestedAt: Date.now() }; - state.busy = true; state.pendingManual = run; - const logs = { lines: [{ at: Date.now(), level: 'info', text: `Starting ${mode} run` }], omitted: 0 }; + state.busy = true; + state.pendingManual = run; + const logs = { + lines: [{ at: Date.now(), level: 'info', text: `Starting ${mode} run` }], + omitted: 0, + }; state.activeRun = { mode, startedAt: Date.now(), logs }; - setTimeout(() => logs.lines.push({ at: Date.now(), level: 'info', text: mode === 'social' ? '[Social] Checking post checkpoints…' : '[Updater] [Schedules] Updating data…' }), 1500); - setTimeout(() => logs.lines.push({ at: Date.now(), level: 'info', text: mode === 'social' ? '[Social] Preparing a due Bluesky post…' : '[Updater] [Schedules] Done.' }), 3200); - setTimeout(() => logs.lines.push({ at: Date.now(), level: 'info', text: '[Preview] Simulated work completed.' }), 5000); - setTimeout(() => { run.status = 'running'; run.startedAt = Date.now(); }, 700); + setTimeout( + () => + logs.lines.push({ + at: Date.now(), + level: 'info', + text: + mode === 'social' + ? '[Social] Checking post checkpoints…' + : '[Updater] [Schedules] Updating data…', + }), + 1500, + ); + setTimeout( + () => + logs.lines.push({ + at: Date.now(), + level: 'info', + text: + mode === 'social' + ? '[Social] Preparing a due Bluesky post…' + : '[Updater] [Schedules] Done.', + }), + 3200, + ); + setTimeout( + () => + logs.lines.push({ + at: Date.now(), + level: 'info', + text: '[Preview] Simulated work completed.', + }), + 5000, + ); setTimeout(() => { - state.lastManualRun = { ...run, logs, ok: true, status: 'succeeded', finishedAt: Date.now(), runMs: Date.now() - run.startedAt, - updaters: mode === 'social' ? { ok: true, skipped: true } : { ok: true, updaters: ['Schedules', 'Timeline', 'CoopSchedules', 'Merchandises'].map(name => ({ name, ok: true })) }, - social: mode === 'data' ? { ok: true, skipped: true } : { ok: true, posts: [{ name: 'Schedule', ok: true, simulated: true }] }, + run.status = 'running'; + run.startedAt = Date.now(); + }, 700); + setTimeout(() => { + state.lastManualRun = { + ...run, + logs, + ok: true, + status: 'succeeded', + finishedAt: Date.now(), + runMs: Date.now() - run.startedAt, + updaters: + mode === 'social' + ? { ok: true, skipped: true } + : { + ok: true, + updaters: ['Schedules', 'Timeline', 'CoopSchedules', 'Merchandises'].map( + (name) => ({ name, ok: true }), + ), + }, + social: + mode === 'data' + ? { ok: true, skipped: true } + : { ok: true, posts: [{ name: 'Schedule', ok: true, simulated: true }] }, }; - state.busy = false; state.pendingManual = null; state.activeRun = null; + state.busy = false; + state.pendingManual = null; + state.activeRun = null; }, 6000); return json({ ok: true, run }, 202); } json({ error: 'Not found.' }, 404); - } catch { json({ error: 'Invalid preview request.' }, 400); } -}).listen(port, '127.0.0.1', () => console.log(`Admin preview: http://127.0.0.1:${port}/admin/ (simulated runs only)`)); + } catch { + json({ error: 'Invalid preview request.' }, 400); + } +}).listen(port, '127.0.0.1', () => + console.log(`Admin preview: http://127.0.0.1:${port}/admin/ (simulated runs only)`), +); diff --git a/workers/updater/src/admin/access.mjs b/workers/updater/src/admin/access.mjs index 9b5d677..d5fc84f 100644 --- a/workers/updater/src/admin/access.mjs +++ b/workers/updater/src/admin/access.mjs @@ -7,19 +7,23 @@ export async function verifyAccess(request, env, resolveKey) { if (!domain || !env.ACCESS_AUD || new URL(request.url).hostname !== env.ADMIN_HOSTNAME) return null; let token = request.headers.get('Cf-Access-Jwt-Assertion'); - if (!token) - return null; + if (!token) return null; try { let issuer = new URL(`https://${domain}`); - if (issuer.hostname !== domain || !domain.endsWith('.cloudflareaccess.com')) - return null; + if (issuer.hostname !== domain || !domain.endsWith('.cloudflareaccess.com')) return null; if (!resolveKey) { if (!keySets.has(domain)) - keySets.set(domain, createRemoteJWKSet(new URL('/cdn-cgi/access/certs', issuer), { timeoutDuration: 5000 })); + keySets.set( + domain, + createRemoteJWKSet(new URL('/cdn-cgi/access/certs', issuer), { timeoutDuration: 5000 }), + ); resolveKey = keySets.get(domain); } let { payload } = await jwtVerify(token, resolveKey, { - issuer: issuer.origin, audience: env.ACCESS_AUD, algorithms: ['RS256'], requiredClaims: ['exp', 'sub'], + issuer: issuer.origin, + audience: env.ACCESS_AUD, + algorithms: ['RS256'], + requiredClaims: ['exp', 'sub'], }); return { email: payload.email ?? 'Authenticated administrator' }; } catch { diff --git a/workers/updater/src/admin/page.html b/workers/updater/src/admin/page.html index cf73845..9835e07 100644 --- a/workers/updater/src/admin/page.html +++ b/workers/updater/src/admin/page.html @@ -1,121 +1,742 @@ - + -
- - - -Refresh schedules, gear, and the rest of the SplatNet data.
Send any due Bluesky posts using the latest published data.
Update game data, then send any social posts that are due.
Successful posts are remembered. Retrying a cycle skips posts already sent.
-Sign in again -Loading recent runs…
Refresh schedules, gear, and the rest of the SplatNet data.
+ +Send any due Bluesky posts using the latest published data.
+ +Update game data, then send any social posts that are due.
+ ++ Successful posts are remembered. Retrying a cycle skips + posts already sent. +
+ + Sign in again +Loading recent runs…