import { captureEmails, count, createUser, db, del, get, insertDump, PASSWORD, patch, post, test, } from "../helpers.ts"; import type { AuthResponse, AuthTokens, Invitee, MediaTokenResponse, PublicUser, SelfUser, UserSuggestion, } from "../../../shared/user.ts"; import { assert, assertEquals, assertFalse } from "@std/assert"; import { createPasswordResetToken, verifyJWT, verifyMediaToken, } from "../../lib/jwt.ts"; import { authenticateMedia } from "../../lib/auth.ts"; import type { Dump } from "../../../shared/dump.ts"; import type { PaginatedData } from "../../../shared/api.ts"; import { DEFAULT_SETTINGS, type UserSettings, } from "../../../shared/settings.ts"; function invite(inviterId: string, createdAt = new Date()): string { const token = crypto.randomUUID(); db.prepare( `INSERT INTO invites (token, inviter_id, created_at) VALUES (?, ?, ?);`, ).run(token, inviterId, createdAt.toISOString()); return token; } test("public profile: by username or id, without private fields", async () => { const user = await createUser(); for ( const path of [`/api/users/${user.username}`, `/api/users/by-id/${user.id}`] ) { const res = await get(path); assertEquals(res.status, 200, path); assertEquals(res.data.username, user.username); assertFalse("email" in res.data); assertFalse("passwordHash" in res.data); } assertEquals((await get(`/api/users/no_one_here`)).status, 404); const me = await get("/api/users/me", { auth: user.auth }); assertEquals(me.data.email, user.email); assertFalse("passwordHash" in me.data); }); test("PATCH /api/users/me: description and email only", async () => { const user = await createUser(); const taken = await createUser(); const res = await patch("/api/users/me", { auth: user.auth, json: { description: "about me", email: "new@example.test" }, }); assertEquals(res.status, 200); assertEquals(res.data.description, "about me"); assertFalse("email" in res.data); assertEquals( (await get("/api/users/me", { auth: user.auth })).data.email, "new@example.test", ); // A role, username or password in the body is ignored, not applied. await patch("/api/users/me", { auth: user.auth, json: { role: "admin", username: "hijack", password: "whatever-long" }, }); const row = db.prepare(`SELECT role, username FROM users WHERE id = ?;`).get( user.id, ) as { role: string; username: string; }; assertEquals(row, { role: "user", username: user.username }); assertEquals( (await patch("/api/users/me", { auth: user.auth, json: { email: taken.email }, })).status, 409, ); assertEquals( (await patch("/api/users/me", { auth: user.auth, json: { email: "bad" } })) .status, 400, ); assertEquals( (await patch("/api/users/me", { auth: user.auth, json: { description: "x".repeat(2001) }, })).status, 400, ); }); test("PATCH /api/users/:id/role: admins only, never their own", async () => { const admin = await createUser({ role: "admin" }); const mod = await createUser({ role: "moderator" }); const user = await createUser(); assertEquals( (await patch(`/api/users/${user.id}/role`, { auth: mod.auth, json: { role: "admin" }, })) .status, 403, ); assertEquals( (await patch(`/api/users/${admin.id}/role`, { auth: admin.auth, json: { role: "user" }, })) .status, 400, ); assertEquals( (await patch(`/api/users/${user.id}/role`, { auth: admin.auth, json: { role: "god" }, })) .status, 400, ); const res = await patch(`/api/users/${user.id}/role`, { auth: admin.auth, json: { role: "moderator" }, }); assertEquals(res.status, 200); assertEquals(res.data.role, "moderator"); assertFalse("email" in res.data); // The promoted user's existing token now carries moderator powers. const { id: dumpId } = insertDump({ userId: admin.id }); assertEquals( (await del(`/api/dumps/${dumpId}`, { auth: user.auth })).status, 200, ); assertEquals( (await patch(`/api/users/${crypto.randomUUID()}/role`, { auth: admin.auth, json: { role: "user" }, })).status, 404, ); }); test("GET /api/users/search: prefix match for mentions, capped at 8", async () => { for (let i = 0; i < 10; i++) await createUser({ username: `zed_${i}` }); await createUser({ username: "other_zed" }); const res = await get("/api/users/search?q=zed"); assertEquals(res.data.length, 8); assertEquals(res.data[0], { id: res.data[0].id, username: "zed_0", avatarMime: null, }); assertEquals((await get("/api/users/search?q=")).data, []); // An underscore is a letter of the name, not a wildcard. await createUser({ username: "zedx9" }); assertEquals( (await get("/api/users/search?q=zed_9")).data.map((u) => u.username ), ["zed_9"], ); }); test("GET /api/users/:username/invitees: the invite tree below a user", async () => { const root = await createUser(); const child = await createUser({ invitedBy: root.id, createdAt: "2024-01-01T00:00:00Z", }); const grandchild = await createUser({ invitedBy: child.id, createdAt: "2024-01-02T00:00:00Z", }); const tree = (await get(`/api/users/${root.username}/invitees`)).data; assertEquals( tree.map(( n: { username: string; invitedById: string }, ) => [n.username, n.invitedById]), [[child.username, root.id], [grandchild.username, child.id]], ); const profile = await get(`/api/users/${child.username}`); assertEquals(profile.data.invitedByUsername, root.username); }); test("GET /api/users/:username/dumps and /votes respect privacy", async () => { const owner = await createUser(); const other = await createUser(); const pub = insertDump({ userId: owner.id, title: "pub" }); const priv = insertDump({ userId: owner.id, title: "priv", isPrivate: true }); for (const d of [pub, priv]) { db.prepare( `INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`, ) .run(d.id, owner.id, new Date().toISOString()); } for (const sub of ["dumps", "votes"]) { const path = `/api/users/${owner.username}/${sub}`; const total = async (opts?: { auth: { Authorization: string } }) => (await get>(path, opts)).data.items.length; assertEquals(await total(), 1, sub); assertEquals(await total({ auth: other.auth }), 1, sub); assertEquals( await total({ auth: owner.auth }), 2, sub, ); } assert((await get(`/api/users/nobody_at_all/dumps`)).status === 404); }); // ── Login and register ─────────────────────────────────────────────────────── test("POST /api/users/login: right password logs in, wrong one doesn't say which", async () => { const user = await createUser(); const ok = await post("/api/users/login", { json: { username: user.username, password: PASSWORD }, }); assertEquals(ok.status, 200); assertEquals(ok.data.user.id, user.id); assertFalse("passwordHash" in ok.data.user); assertEquals((await verifyJWT(ok.data.token))?.userId, user.id); assertEquals( (await verifyMediaToken(ok.data.mediaToken))?.userId, user.id, ); const wrong = await post("/api/users/login", { json: { username: user.username, password: "nope" }, }); const unknown = await post("/api/users/login", { json: { username: "no_such_user_here", password: "nope" }, }); assertEquals(wrong.status, 401); assertEquals(unknown.status, 401); assertEquals(wrong.error.message, unknown.error.message); assertEquals( (await post("/api/users/login", { json: { username: user.username } })) .status, 400, ); }); test("POST /api/users/login: rate limited per account, case-insensitively", async () => { const user = await createUser(); const statuses: number[] = []; for (let i = 0; i < 11; i++) { const username = i % 2 ? user.username.toUpperCase() : user.username; statuses.push( (await post("/api/users/login", { json: { username, password: "wrong" }, })).status, ); } assertEquals(statuses.slice(0, 10), Array(10).fill(401)); assertEquals(statuses[10], 429); // Even the right password is held back now. assertEquals( (await post("/api/users/login", { json: { username: user.username, password: PASSWORD }, })).status, 429, ); }); test("POST /api/users/register: an invite opens one account", async () => { using mail = captureEmails(); const inviter = await createUser(); const inviteToken = invite(inviter.id); const res = await post("/api/users/register", { json: { username: "newbie_reg", password: "longenough", email: "newbie@example.test", inviteToken, }, }); assertEquals(res.status, 201); assertEquals(res.data.user.username, "newbie_reg"); assertEquals(res.data.user.role, "user"); assertFalse("passwordHash" in res.data.user); assert(res.data.token); const row = db.prepare( `SELECT invited_by FROM users WHERE username = 'newbie_reg';`, ).get() as { invited_by: string }; assertEquals(row.invited_by, inviter.id); await new Promise((r) => setTimeout(r, 0)); assertEquals(mail.sent.map((m) => m.to), ["newbie@example.test"]); assert(mail.sent[0].html.includes("newbie_reg")); const again = await post("/api/users/register", { json: { username: "second_reg", password: "longenough", email: "second@example.test", inviteToken, }, }); assertEquals(again.status, 409); }); test("POST /api/users/register: validation and unknown invites", async () => { const inviter = await createUser(); const inviteToken = invite(inviter.id); const base = { username: "valid_name", password: "longenough", email: "v@example.test", inviteToken, }; const cases: [Record, RegExp][] = [ [{ ...base, email: "nope" }, /email/], [{ ...base, username: "has space" }, /Username/], [{ ...base, username: "x".repeat(33) }, /Username/], [{ ...base, password: "short" }, /at least 8/], [{ ...base, password: "x".repeat(129) }, /at most 128/], [{ ...base, inviteToken: 42 }, /Invalid request/], ]; for (const [json, message] of cases) { const res = await post("/api/users/register", { json }); assertEquals(res.status, 400, JSON.stringify(json)); assert(message.test(res.error.message), res.error.message); } const unknown = await post("/api/users/register", { json: { ...base, inviteToken: "made-up" }, }); assertEquals(unknown.status, 404); // None of the failures used the invite up. assertEquals( count(`SELECT COUNT(*) as n FROM invites WHERE used_at IS NULL;`), 1, ); }); test("POST /api/users/register: a taken name or email hands the invite back", async () => { const inviter = await createUser(); const taken = await createUser(); const inviteToken = invite(inviter.id); const byName = await post("/api/users/register", { json: { username: taken.username.toUpperCase(), password: "longenough", email: "fresh@example.test", inviteToken, }, }); assertEquals(byName.status, 409); const byEmail = await post("/api/users/register", { json: { username: "fresh_name", password: "longenough", email: taken.email.toUpperCase(), inviteToken, }, }); assertEquals(byEmail.status, 409); assertEquals( count( `SELECT COUNT(*) as n FROM invites WHERE token = ? AND used_at IS NULL;`, inviteToken, ), 1, ); }); test("POST /api/users/register: an invite older than 7 days is refused", async () => { const inviter = await createUser(); const inviteToken = invite( inviter.id, new Date(Date.now() - 8 * 24 * 60 * 60 * 1000), ); const res = await post("/api/users/register", { json: { username: "late_comer", password: "longenough", email: "late@example.test", inviteToken, }, }); assertEquals(res.status, 404); }); test("POST /api/users/register: parallel registrations can't share an invite", async () => { const inviter = await createUser(); const inviteToken = invite(inviter.id); const results = await Promise.all( [1, 2, 3].map((i) => post("/api/users/register", { json: { username: `racer_${i}`, password: "longenough", email: `racer${i}@example.test`, inviteToken, }, }) ), ); assertEquals(results.filter((r) => r.status === 201).length, 1); assertEquals( count(`SELECT COUNT(*) as n FROM users WHERE invited_by = ?;`, inviter.id), 1, ); }); // ── Password changes ───────────────────────────────────────────────────────── test("change-password: needs the current one, revokes old tokens, returns new ones", async () => { const user = await createUser(); const wrong = await post("/api/users/me/change-password", { auth: user.auth, json: { currentPassword: "nope", newPassword: "brand-new-pass" }, }); assertEquals(wrong.status, 403); const short = await post("/api/users/me/change-password", { auth: user.auth, json: { currentPassword: PASSWORD, newPassword: "short" }, }); assertEquals(short.status, 400); // iat has one-second resolution: make sure the old token is from an earlier // second than the change. await new Promise((r) => setTimeout(r, 1100)); const ok = await post("/api/users/me/change-password", { auth: user.auth, json: { currentPassword: PASSWORD, newPassword: "brand-new-pass" }, }); assertEquals(ok.status, 200); assertEquals((await get("/api/users/me", { auth: user.auth })).status, 401); assertEquals(await authenticateMedia(user.mediaToken), null); const fresh = { Authorization: `Bearer ${ok.data.token}` }; assertEquals((await get("/api/users/me", { auth: fresh })).status, 200); assertEquals(await authenticateMedia(ok.data.mediaToken), user.id); const login = await post("/api/users/login", { json: { username: user.username, password: "brand-new-pass" }, }); assertEquals(login.status, 200); }); test("request-password-reset: always 200, stores nothing without SMTP", async () => { const user = await createUser(); for (const email of [user.email, "nobody@example.test", "", undefined]) { const res = await post("/api/users/request-password-reset", { json: { email }, }); assertEquals(res.status, 200); } assertEquals(count(`SELECT COUNT(*) as n FROM password_reset_tokens;`), 0); }); test("request-password-reset: emails a link that resets the password", async () => { using mail = captureEmails(); const user = await createUser(); await post("/api/users/request-password-reset", { json: { email: user.email }, }); await post("/api/users/request-password-reset", { json: { email: "nobody@example.test" }, }); await new Promise((r) => setTimeout(r, 0)); assertEquals(mail.sent.length, 1); assertEquals(mail.sent[0].to, user.email); const token = decodeURIComponent( mail.sent[0].text.match(/token=([^\s]+)/)![1], ); const res = await post("/api/users/reset-password", { json: { token, newPassword: "from-the-email" }, }); assertEquals(res.status, 200); }); test("reset-password: a stored token works once", async () => { const user = await createUser(); const token = await createPasswordResetToken(user.id); db.prepare( `INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?);`, ).run(token, user.id, new Date(Date.now() + 3600_000).toISOString()); const short = await post("/api/users/reset-password", { json: { token, newPassword: "short" }, }); assertEquals(short.status, 400); const ok = await post("/api/users/reset-password", { json: { token, newPassword: "reset-password-1" }, }); assertEquals(ok.status, 200); const login = await post("/api/users/login", { json: { username: user.username, password: "reset-password-1" }, }); assertEquals(login.status, 200); const reuse = await post("/api/users/reset-password", { json: { token, newPassword: "reset-password-2" }, }); assertEquals(reuse.status, 409); }); test("reset-password: an unstored, forged or wrong-purpose token fails", async () => { const user = await createUser(); const unstored = await createPasswordResetToken(user.id); assertEquals( (await post("/api/users/reset-password", { json: { token: unstored, newPassword: "whatever-123" }, })).status, 404, ); for (const token of ["garbage", user.token, user.mediaToken]) { assertEquals( (await post("/api/users/reset-password", { json: { token, newPassword: "whatever-123" }, })).status, 400, ); } assertEquals( (await post("/api/users/reset-password", { json: { token: 1 } })).status, 400, ); }); test("GET /api/users/me/media-token issues a media token for the caller", async () => { const user = await createUser(); const res = await get("/api/users/me/media-token", { auth: user.auth, }); assertEquals(res.status, 200); assertEquals(await authenticateMedia(res.data.mediaToken), user.id); }); // ── Settings ───────────────────────────────────────────────────────────────── test("settings: defaults until changed, a patch keeps the rest, each user their own", async () => { const ann = await createUser(); const bob = await createUser(); const initial = await get("/api/users/me/settings", { auth: ann.auth, }); assertEquals(initial.data, DEFAULT_SETTINGS); const changed = await patch("/api/users/me/settings", { auth: ann.auth, json: { style: "kanagawa", digestWeekly: false }, }); assertEquals(changed.data, { ...DEFAULT_SETTINGS, style: "kanagawa", digestWeekly: false, }); const scheme = await patch("/api/users/me/settings", { auth: ann.auth, json: { colorScheme: "dark" }, }); assertEquals(scheme.data.style, "kanagawa"); assertEquals(scheme.data.colorScheme, "dark"); assertEquals(scheme.data.digestWeekly, false); const bobs = await get("/api/users/me/settings", { auth: bob.auth, }); assertEquals(bobs.data, DEFAULT_SETTINGS); // Deleting the account takes its settings along. db.prepare(`DELETE FROM users WHERE id = ?;`).run(ann.id); assertEquals(count(`SELECT COUNT(*) as n FROM user_settings;`), 0); }); test("settings: signed in only, and a bad value or unknown key is refused", async () => { const ann = await createUser(); assertEquals((await get("/api/users/me/settings")).status, 401); for ( const json of [ { style: "comic" }, { colorScheme: "sepia" }, { digestMonthly: "no" }, { theme: "smooth" }, [], ] ) { const res = await patch("/api/users/me/settings", { auth: ann.auth, json }); assertEquals(res.status, 400, JSON.stringify(json)); } // Null hands the style back to the instance's. const reset = await patch("/api/users/me/settings", { auth: ann.auth, json: { style: null }, }); assertEquals(reset.data.style, null); });