From e246671873371dd1f1fba5f5fd5f7d9c246cd0a1 Mon Sep 17 00:00:00 2001 From: Saty <5752427+SatyPardus@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:44:18 +0200 Subject: [PATCH] Arcade credits and games, account deletion, password reset, feed and map - Credits are booked from what the database records (merged functions, peer-confirmed ones, accepted no-decompile claims, submissions that went on, reviews that agreed with the outcome), once each; play money for coin flip, dice and slots (server-side CSPRNG, session only). - Admins delete accounts (kept as deleted_ when they have work) and reset passwords. - /api/feed (public events) and /api/map (every function by address, one state letter each) for the main page. Co-Authored-By: Claude Opus 5.5 --- src/server/accounts.js | 46 ++++++++++++ src/server/app.js | 98 +++++++++++++++++++++++- src/server/credits.js | 166 +++++++++++++++++++++++++++++++++++++++++ test/server.test.js | 57 ++++++++++++++ 4 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 src/server/credits.js diff --git a/src/server/accounts.js b/src/server/accounts.js index d0051de..adbeeb2 100644 --- a/src/server/accounts.js +++ b/src/server/accounts.js @@ -264,6 +264,52 @@ export class Accounts { }); } + /** + * Deletes an account. One with work on record (submissions, reviews, symbols) stays as + * "deleted_" so the history keeps its author; one without is removed. Either way it can no + * longer log in and its claims are released. + */ + deleteUser(name, adminId) { + return this.store.tx(() => { + const u = this.requireUser(name); + if (u.role === 'admin' && u.status === 'active' && this.activeAdmins() <= 1) throw new AccountError(409, 'refusing to delete the last active admin'); + if (u.id === adminId) throw new AccountError(409, 'you cannot delete your own account here'); + const has = (sql) => !!this.store.get(sql, u.id); + const work = + has('SELECT 1 FROM attempts WHERE user_id = ? LIMIT 1') || + has('SELECT 1 FROM batches WHERE user_id = ? LIMIT 1') || + has('SELECT 1 FROM peer_reviews WHERE reviewer_id = ? LIMIT 1') || + has('SELECT 1 FROM reviews WHERE reviewer_id = ? LIMIT 1') || + has('SELECT 1 FROM symbols WHERE created_by = ? LIMIT 1'); + this.store.run( + `UPDATE tasks SET state = CASE WHEN best_attempt IS NULL THEN 'open' ELSE 'needs_cleanup' END, lease_user = NULL, lease_expires = NULL, updated_at = ? WHERE lease_user = ? AND state = 'leased'`, + now(), u.id, + ); + for (const t of ['sessions', 'api_tokens', 'region_claims']) this.store.run(`DELETE FROM ${t} WHERE user_id = ?`, u.id); + this.store.run('DELETE FROM peer_leases WHERE reviewer_id = ?', u.id); + if (work) { + const newName = `deleted_${u.id}`; + this.store.run(`UPDATE users SET name = ?, status = 'revoked', password_hash = NULL, revoked_at = ? WHERE id = ?`, newName, now(), u.id); + this.store.event('user_deleted', adminId, { name: u.name, kept: newName }); + return { name: u.name, deleted: true, keptAs: newName }; + } + for (const t of ['credit_ledger', 'game_rounds']) this.store.run(`DELETE FROM ${t} WHERE user_id = ?`, u.id); + this.store.run('UPDATE events SET user_id = NULL WHERE user_id = ?', u.id); + this.store.run('UPDATE users SET approved_by = NULL WHERE approved_by = ?', u.id); + this.store.run('DELETE FROM users WHERE id = ?', u.id); + this.store.event('user_deleted', adminId, { name: u.name }); + return { name: u.name, deleted: true, keptAs: null }; + }); + } + + /** A new random password for an account (shown once to the admin); its sessions end. */ + async resetPassword(name, adminId) { + const pw = crypto.randomBytes(12).toString('base64url'); + await this.setPassword(name, pw); + this.store.event('password_reset', adminId, { name }); + return { name, password: pw }; + } + activeAdmins() { return this.store.get(`SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND status = 'active'`).n; } diff --git a/src/server/app.js b/src/server/app.js index 6be2809..e635da1 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -22,6 +22,7 @@ import { reviewerStats } from './reviewers.js'; import { inferUnits } from './grouping.js'; import { Router, HttpError, readJson, sendJson, RateLimiter } from './http.js'; import { Regions } from './regions.js'; +import { Credits, SLOT_TABLE } from './credits.js'; import { PEImage } from '../shared/pe.js'; import { loadProject, loadToolchainLock } from '../shared/project.js'; import { DEFAULT_LIMITS, lintSubmission } from '../shared/lint.js'; @@ -91,7 +92,8 @@ export function openServer(dataDir, { log = console } = {}) { const ghidra = new ServerGhidra({ config, store, originals, log }); merger.onMerged = (commit) => ghidra.scheduleRefresh(commit); const checks = new CheckQueue({ store, repo, verifier, config, log }); - return { config, store, repo, project, tasks, regions, verifier, merger, getPe, accounts, peers, originals, ghidra, checks }; + const credits = new Credits({ store }); + return { config, store, repo, project, tasks, regions, credits, verifier, merger, getPe, accounts, peers, originals, ghidra, checks }; } export function createApp(ctx) { @@ -524,6 +526,96 @@ export function createApp(ctx) { return { ...tasks.heartbeatAll(user), regions: ctx.regions.heartbeat(user) }; }); + // --- the arcade: game credits (credits.js). Play money; humans play, from the website. + + router.on('GET', '/api/credits/me', ({ req }) => { + const user = auth(req); + return { ...ctx.credits.wallet(user.id), slots: SLOT_TABLE }; + }); + router.on('POST', '/api/credits/bailout', ({ req }) => { + const user = auth(req, { session: true }); + return ctx.credits.bailout(user.id); + }); + router.on('GET', '/api/credits/leaderboard', () => cached('credits-board', 20_000, () => ctx.credits.leaderboard())); + const gameLimiter = new RateLimiter(600, 600_000); + router.on('POST', '/api/games/:game', async ({ req, params }) => { + const user = auth(req, { session: true }); + gameLimiter.check(user.id); + const body = await readJson(req, 4096); + return ctx.credits.play(user.id, String(params.game), body); + }); + + // --- the main page: a live feed of what happens, and a map of every function + + const FEED_TYPES = ['submit', 'merged', 'auto_accepted', 'region_claim', 'game_win', 'user_approved', 'no_decompile_claim', 'skip_accepted']; + router.on('GET', '/api/feed', ({ query }) => { + const after = Number(query.get('after') ?? 0) || 0; + // The first view is cached; later polls ask for what came after (a primary key range). + const load = () => { + const rows = store.all( + `SELECT e.id, e.at, e.type, e.data, u.name FROM events e LEFT JOIN users u ON u.id = e.user_id + WHERE e.id > ? AND e.type IN (${FEED_TYPES.map(() => '?').join(',')}) ORDER BY e.id DESC LIMIT 40`, + after, ...FEED_TYPES, + ); + // Only the fields a public feed needs, never notes or code. + const pick = (type, d) => { + if (type === 'submit') return { functions: d.tasks }; + if (type === 'merged') return { functions: d.functions, commit: String(d.commit ?? '').slice(0, 10) }; + if (type === 'region_claim') return { key: d.key, kind: d.kind, functions: d.leased, shared: d.shared }; + if (type === 'game_win') return { game: d.game, bet: d.bet, payout: d.payout }; + if (type === 'no_decompile_claim') return { functions: d.functions }; + if (type === 'auto_accepted') return {}; + return {}; + }; + return rows.map((r) => ({ id: r.id, at: r.at, type: r.type, user: r.name ?? null, ...pick(r.type, JSON.parse(r.data ?? '{}')) })); + }; + return after > 0 ? load() : cached('feed', 5_000, load); + }); + + // Every function in address order, one letter each: d byte-identical, c peer-confirmed, s needs no + // decompilation, f on its way to main, l claimed, b sent back, o open; plus its source file. + const mapData = () => + cached('map', 60_000, () => { + const v = store.currentVersion(); + if (!v) return { functions: 0, states: '', units: [], unitOf: [], ids: [] }; + const rows = store.all( + `SELECT t.symbol_id, t.state, s.unit, a.size, fs.status, fs.confirmed FROM tasks t JOIN symbols s ON s.id = t.symbol_id + JOIN symbol_addresses a ON a.symbol_id = s.id AND a.version_id = ? + LEFT JOIN func_status fs ON fs.symbol_id = s.id ORDER BY a.addr`, + v.id, + ); + const code = (r) => { + if (r.state === 'skipped') return 's'; + if (r.state === 'done') return r.status === 'exact' || r.status === 'match_unresolved' ? 'd' : 'c'; + if (['verifying', 'peer_review', 'in_review', 'approved', 'merging'].includes(r.state)) return 'f'; + if (r.state === 'leased') return 'l'; + if (r.state === 'needs_cleanup' || r.state === 'hard') return 'b'; + return 'o'; + }; + const units = []; + const index = new Map(); + const unitOf = rows.map((r) => { + const u = r.unit ?? '(not placed yet)'; + if (!index.has(u)) index.set(u, units.push(u) - 1); + return index.get(u); + }); + return { functions: rows.length, states: rows.map(code).join(''), units, unitOf, ids: rows.map((r) => r.symbol_id), sizes: rows.map((r) => r.size ?? 0) }; + }); + router.on('GET', '/api/map', () => { + const m = mapData(); + return { functions: m.functions, states: m.states, units: m.units, unitOf: m.unitOf }; + }); + router.on('GET', '/api/map/:i', ({ params }) => { + const m = mapData(); + const i = intParam(params.i); + if (i < 0 || i >= m.functions) throw new HttpError(404, 'no such function'); + const s = store.get( + `SELECT s.id, s.name, s.unit, t.state, u.name AS holder FROM symbols s JOIN tasks t ON t.symbol_id = s.id LEFT JOIN users u ON u.id = t.lease_user AND t.state = 'leased' WHERE s.id = ?`, + m.ids[i], + ); + return { index: i, symbol: s?.id, name: s?.name ?? null, unit: s?.unit ?? null, state: s?.state, holder: s?.holder ?? null, size: m.sizes[i] }; + }); + // --- regions: a source file or class, claimed and worked on as a whole (regions.js) router.on('GET', '/api/regions', ({ req, query }) => { @@ -853,6 +945,8 @@ export function createApp(ctx) { // Pause or allow an account's peer reviews (e.g. rubber-stamping); its existing verdicts stay. 'block-reviews': (name, admin) => setReviewBlock(name, 1, admin), 'allow-reviews': (name, admin) => setReviewBlock(name, 0, admin), + delete: (name, admin) => accounts.deleteUser(name, admin.id), + 'reset-password': (name, admin) => accounts.resetPassword(name, admin.id), }; const setReviewBlock = (name, blocked, admin) => { const u = accounts.byName(name); @@ -1006,6 +1100,8 @@ export async function startServer(dataDir, { role = 'all' } = {}) { let server = null; if (role !== 'worker') { const sweeper = setInterval(() => { try { ctx.tasks.sweepExpired(); ctx.regions.sweepExpired(); } catch (e) { console.error(`lease sweep: ${e.message}`); } }, 60_000); + const booker = setInterval(() => { try { ctx.credits.sync(); } catch (e) { console.error(`credits: ${e.message}`); } }, 30_000); + stops.push(() => clearInterval(booker)); stops.push(() => clearInterval(sweeper)); server = createApp(ctx); await new Promise((r) => server.listen(ctx.config.listen.port, ctx.config.listen.host, r)); diff --git a/src/server/credits.js b/src/server/credits.js new file mode 100644 index 0000000..58ecfdc --- /dev/null +++ b/src/server/credits.js @@ -0,0 +1,166 @@ +// Game credits: play money for the arcade, earned with work that reached main and reviews that +// agreed with the outcome. They have no value outside the site. +// +// Earnings are derived from what the database already records (merged attempts, decided peer +// reviews, accepted "no decompile" claims), so nothing in the pipeline has to remember to pay: a +// sync every half minute books whatever is new, once per (user, reason, ref). + +import crypto from 'node:crypto'; +import { HttpError } from './http.js'; +import { now } from './db.js'; + +export const AWARDS = { + identical: 10, // a function merged byte-identical + confirmed: 6, // a function merged after five peers confirmed its logic + skip: 3, // an accepted "no decompile" claim + submitted: 1, // a submitted function that compiled and went on (peer review or approved) + review: 2, // a peer verdict that agreed with how the function was decided +}; + +const MAX_BET = 1000; +const BAILOUT = 25; // once a day for anyone below 5 credits + +// Slots: reel symbols with weights out of 100, paying for three of a kind (and two gears). +const REEL = [ + { s: '⚙️', w: 30, three: 10 }, + { s: '🔩', w: 25, three: 15 }, + { s: '💾', w: 18, three: 25 }, + { s: '🐞', w: 15, three: 30 }, + { s: '👾', w: 8, three: 100 }, + { s: '7️⃣', w: 4, three: 500 }, +]; + +export class Credits { + constructor({ store }) { + this.store = store; + } + + balance(userId) { + return this.store.get('SELECT COALESCE(SUM(amount), 0) AS n FROM credit_ledger WHERE user_id = ?', userId).n; + } + + book(userId, amount, reason, ref) { + return Number(this.store.run('INSERT OR IGNORE INTO credit_ledger (user_id, amount, reason, ref, created_at) VALUES (?, ?, ?, ?, ?)', userId, amount, reason, ref, now()).changes); + } + + /** Books every earning that is not booked yet. Returns how many entries were added. */ + sync() { + let n = 0; + this.store.tx(() => { + const missing = (reason, refExpr) => `NOT EXISTS (SELECT 1 FROM credit_ledger l WHERE l.user_id = a.user_id AND l.reason = '${reason}' AND l.ref = ${refExpr})`; + // Merged code: byte-identical or peer-confirmed. + for (const r of this.store.all( + `SELECT a.id, a.user_id, json_extract(a.result_json, '$.feedback.target.status') AS status FROM attempts a + WHERE a.state = 'merged' AND a.kind = 'code' AND ${missing('identical', "'attempt:' || a.id")} AND ${missing('confirmed', "'attempt:' || a.id")}`, + )) { + const identical = r.status === 'exact' || r.status === 'match_unresolved'; + n += this.book(r.user_id, identical ? AWARDS.identical : AWARDS.confirmed, identical ? 'identical' : 'confirmed', `attempt:${r.id}`); + } + // Accepted "no decompile" claims. + for (const r of this.store.all(`SELECT a.id, a.user_id FROM attempts a WHERE a.kind = 'skip' AND a.state IN ('merged', 'skipped', 'approved') AND ${missing('skip', "'attempt:' || a.id")}`)) { + n += this.book(r.user_id, AWARDS.skip, 'skip', `attempt:${r.id}`); + } + // Submitted code that compiled and went on. + for (const r of this.store.all( + `SELECT a.id, a.user_id FROM attempts a WHERE a.kind = 'code' AND a.state IN ('peer_review', 'in_review', 'approved', 'merging', 'merged') AND ${missing('submitted', "'attempt:' || a.id")}`, + )) { + n += this.book(r.user_id, AWARDS.submitted, 'submitted', `attempt:${r.id}`); + } + // Reviews that agreed with the outcome: "same" on what merged, "different" on what peers sent back. + for (const r of this.store.all( + `SELECT p.id, p.reviewer_id AS user_id FROM peer_reviews p JOIN attempts t ON t.id = p.attempt_id + WHERE ((p.verdict = 'same' AND t.state IN ('merged', 'approved', 'merging')) OR (p.verdict = 'different' AND t.state IN ('needs_cleanup', 'hard', 'returned') AND + (SELECT COUNT(*) FROM peer_reviews q WHERE q.attempt_id = t.id AND q.verdict = 'different') >= 3)) + AND NOT EXISTS (SELECT 1 FROM credit_ledger l WHERE l.user_id = p.reviewer_id AND l.reason = 'review' AND l.ref = 'review:' || p.id)`, + )) { + n += this.book(r.user_id, AWARDS.review, 'review', `review:${r.id}`); + } + }); + return n; + } + + /** What the arcade shows a player: balance, recent entries, whether the daily bailout is open. */ + wallet(userId) { + const balance = this.balance(userId); + const earned = this.store.all(`SELECT reason, SUM(amount) AS n, COUNT(*) AS c FROM credit_ledger WHERE user_id = ? AND amount > 0 AND reason NOT LIKE 'game:%' GROUP BY reason`, userId); + const recent = this.store.all('SELECT id, game, bet, payout, detail, created_at FROM game_rounds WHERE user_id = ? ORDER BY id DESC LIMIT 15', userId).map((r) => ({ ...r, detail: JSON.parse(r.detail ?? '{}') })); + return { balance, earned: Object.fromEntries(earned.map((e) => [e.reason, { credits: e.n, times: e.c }])), recent, bailout: balance < 5 && !this.bailoutTaken(userId), awards: AWARDS, maxBet: MAX_BET }; + } + + bailoutTaken(userId) { + return !!this.store.get(`SELECT 1 FROM credit_ledger WHERE user_id = ? AND reason = 'bailout' AND ref = ?`, userId, new Date().toISOString().slice(0, 10)); + } + + bailout(userId) { + return this.store.tx(() => { + if (this.balance(userId) >= 5) throw new HttpError(409, 'the bailout is for players below 5 credits'); + if (!this.book(userId, BAILOUT, 'bailout', new Date().toISOString().slice(0, 10))) throw new HttpError(409, 'already taken today; come back tomorrow (or merge a function)'); + return { balance: this.balance(userId), granted: BAILOUT }; + }); + } + + /** One round of a game. The outcome comes from the server's CSPRNG; the bet is booked first. */ + play(userId, game, params = {}) { + const bet = Math.floor(Number(params.bet)); + if (!Number.isFinite(bet) || bet < 1 || bet > MAX_BET) throw new HttpError(400, `bet 1 to ${MAX_BET} credits`); + const outcome = GAMES[game]; + if (!outcome) throw new HttpError(404, `no game ${game}; try ${Object.keys(GAMES).join(', ')}`); + return this.store.tx(() => { + if (this.balance(userId) < bet) throw new HttpError(409, 'not enough credits'); + const { payout, detail } = outcome(bet, params); + const id = Number(this.store.run('INSERT INTO game_rounds (user_id, game, bet, payout, detail, created_at) VALUES (?, ?, ?, ?, ?, ?)', userId, game, bet, payout, JSON.stringify(detail), now()).lastInsertRowid); + this.book(userId, -bet, `game:${game}`, `round:${id}:bet`); + if (payout) this.book(userId, payout, `game:${game}`, `round:${id}:win`); + if (payout >= bet * 10 && payout >= 100) this.store.event('game_win', userId, { game, bet, payout }); + return { round: id, game, bet, payout, detail, balance: this.balance(userId) }; + }); + } + + leaderboard(limit = 15) { + const rich = this.store.all( + `SELECT u.name, SUM(l.amount) AS balance, SUM(CASE WHEN l.amount > 0 AND l.reason NOT LIKE 'game:%' AND l.reason != 'bailout' THEN l.amount ELSE 0 END) AS earned + FROM credit_ledger l JOIN users u ON u.id = l.user_id WHERE u.status = 'active' GROUP BY u.id ORDER BY balance DESC LIMIT ?`, + limit, + ); + const wins = this.store.all( + `SELECT u.name, r.game, r.bet, r.payout, r.created_at FROM game_rounds r JOIN users u ON u.id = r.user_id WHERE r.created_at > ? AND r.payout > r.bet ORDER BY r.payout DESC LIMIT 10`, + now() - 86_400_000, + ); + return { rich, wins }; + } +} + +const roll = (n) => crypto.randomInt(n); + +export const GAMES = { + // Heads or tails at 1.95x. + coinflip(bet, { side }) { + if (side !== 'heads' && side !== 'tails') throw new HttpError(400, 'side: heads or tails'); + const result = roll(2) ? 'heads' : 'tails'; + return { payout: result === side ? Math.floor(bet * 1.95) : 0, detail: { side, result } }; + }, + // Roll 1-100; win when the roll is below the target. The payout follows the odds (3% edge). + dice(bet, { under }) { + const target = Math.floor(Number(under)); + if (!(target >= 3 && target <= 96)) throw new HttpError(400, 'under: a target from 3 to 96'); + const result = roll(100) + 1; + const multiplier = Math.floor((97 / (target - 1)) * 100) / 100; + return { payout: result < target ? Math.floor(bet * multiplier) : 0, detail: { under: target, result, multiplier } }; + }, + // Three reels; three of a kind pays per symbol, two gears on the first reels pay the bet back. + slots(bet) { + const total = REEL.reduce((t, x) => t + x.w, 0); + const spin = () => { + let r = roll(total); + for (const x of REEL) if ((r -= x.w) < 0) return x; + return REEL[0]; + }; + const reels = [spin(), spin(), spin()]; + let payout = 0; + if (reels[0] === reels[1] && reels[1] === reels[2]) payout = bet * reels[0].three; + else if (reels[0].s === '⚙️' && reels[1].s === '⚙️') payout = bet; + return { payout, detail: { reels: reels.map((x) => x.s) } }; + }, +}; + +export const SLOT_TABLE = REEL.map(({ s, three }) => ({ symbol: s, three })); diff --git a/test/server.test.js b/test/server.test.js index 7c445df..0fe7e95 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -1119,3 +1119,60 @@ test('the server does not compile checks unless remoteChecks is on', async () => assert.match(r.body.error, /td toolchain install/); ctx.config.remoteChecks = true; }); + +test('credits: merged work and agreeing reviews pay once; games book bet and payout', async () => { + await activeUser('crauthor'); + await activeUser('crpeer'); + const author = ctx.accounts.byName('crauthor'); + const peer = ctx.accounts.byName('crpeer'); + const v = ctx.store.currentVersion(); + const id = ctx.store.ensureSymbol({ kind: 'function', versionId: v.id, addr: 0x402900, size: 8, unit: 'src/credits.cpp' }).symbol.id; + ctx.tasks.generate(); + const task = ctx.store.get('SELECT id FROM tasks WHERE symbol_id = ?', id).id; + const attempt = Number(ctx.store.run(`INSERT INTO attempts (task_id, user_id, base_commit, files_json, state, result_json, created_at) VALUES (?, ?, ?, '{}', 'merged', ?, ?)`, task, author.id, ctx.repo.head(), JSON.stringify({ feedback: { target: { status: 'mismatch' } } }), now()).lastInsertRowid); + ctx.store.run(`INSERT INTO peer_reviews (attempt_id, reviewer_id, verdict, reason, created_at) VALUES (?, ?, 'same', 'the same calls and returns', ?)`, attempt, peer.id, now()); + ctx.credits.sync(); + ctx.credits.sync(); + assert.equal(ctx.credits.balance(author.id), 6 + 1, 'peer-confirmed merge plus the submission, booked once'); + assert.equal(ctx.credits.balance(peer.id), 2, 'an agreeing review'); + + const cookie = await login('crauthor'); + const play = (game, body) => request('POST', `/api/games/${game}`, body, { cookie }); + assert.equal((await play('coinflip', { bet: 100, side: 'heads' })).status, 409, 'cannot bet more than the balance'); + assert.equal((await play('coinflip', { bet: 3, side: 'edge' })).status, 400); + const r = await play('coinflip', { bet: 3, side: 'heads' }); + assert.equal(r.status, 200, JSON.stringify(r.body)); + assert.equal(r.body.balance, 7 - 3 + r.body.payout); + assert.equal(ctx.credits.balance(author.id), r.body.balance); + const d = await play('dice', { bet: 1, under: 50 }); + assert.equal(d.status, 200); + assert.ok(d.body.detail.result >= 1 && d.body.detail.result <= 100); + const s = await play('slots', { bet: 1 }); + assert.equal(s.body.detail.reels.length, 3); + const token = await activeUser('crbot'); + assert.equal((await call('POST', '/api/games/slots', { bet: 1 }, token)).status, 403, 'games are for people on the website, not agent tokens'); + ctx.store.run(`UPDATE attempts SET state = 'rejected' WHERE id = ?`, attempt); + ctx.store.run(`UPDATE tasks SET state = 'done' WHERE id = ?`, task); +}); + +test('deleting an account: kept as deleted_ when it has work, removed otherwise', async () => { + await ctx.accounts.createUser('deladmin', 'admin', PASSWORD); + const admin = await login('deladmin'); + await activeUser('delworker'); + await activeUser('delidle'); + const worker = ctx.accounts.byName('delworker'); + const task = ctx.store.get(`SELECT id FROM tasks LIMIT 1`).id; + ctx.store.run(`INSERT INTO attempts (task_id, user_id, base_commit, files_json, state, created_at) VALUES (?, ?, ?, '{}', 'rejected', ?)`, task, worker.id, ctx.repo.head(), now()); + const del = (name) => request('POST', `/api/admin/users/${name}/delete`, {}, { cookie: admin }); + const kept = await del('delworker'); + assert.equal(kept.status, 200, JSON.stringify(kept.body)); + assert.equal(kept.body.keptAs, `deleted_${worker.id}`); + assert.equal(ctx.accounts.byName(`deleted_${worker.id}`).status, 'revoked'); + assert.equal(ctx.accounts.byName(`deleted_${worker.id}`).password_hash, null); + const gone = await del('delidle'); + assert.equal(gone.body.keptAs, null); + assert.equal(ctx.accounts.byName('delidle'), null); + const reset = await request('POST', '/api/admin/users/deladmin/reset-password', {}, { cookie: admin }); + assert.equal(reset.status, 200); + assert.ok(reset.body.password.length >= 12); +});