import { captureEmails, count, createUser, db, del, get, insertCategory, insertDump, insertPlaylist, PASSWORD, patch, post, put, test, uniqueName, } from "../helpers.ts"; import type { AuthResponse, AuthTokens, Invitee, MediaTokenResponse, PublicUser, SelfUser, UserSuggestion, } from "../../../shared/user.ts"; import { assert, assertEquals, assertFalse, assertNotEquals, } 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 { Playlist } from "../../../shared/playlist.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 @name in any case or id, without private fields", async () => { const user = await createUser(); for ( const path of [ `/api/users/@${user.username}`, `/api/users/@${user.username.toUpperCase()}`, `/api/users/${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); // Without the `@`, the key is an id. assertEquals((await get(`/api/users/${user.username}`)).status, 404); const me = await get("/api/me", { auth: user.auth }); assertEquals(me.data.email, user.email); assertFalse("passwordHash" in me.data); }); test("PATCH /api/me: description and email only", async () => { const user = await createUser(); const taken = await createUser(); const res = await patch("/api/me", { auth: user.auth, json: { description: "about me", email: "new@example.test" }, }); assertEquals(res.status, 200); assertEquals(res.data.description, "about me"); assertEquals(res.data.email, "new@example.test"); assertFalse("passwordHash" in res.data); // A role or password in the body is ignored, not applied. const hash = () => (db.prepare(`SELECT password_hash FROM users WHERE id = ?;`).get( user.id, ) as { password_hash: string }).password_hash; const before = hash(); await patch("/api/me", { auth: user.auth, json: { role: "admin", password: "whatever-long" }, }); const row = db.prepare(`SELECT role FROM users WHERE id = ?;`).get( user.id, ) as { role: string }; assertEquals(row.role, "user"); assertEquals(hash(), before); assertEquals( (await patch("/api/me", { auth: user.auth, json: { email: taken.email }, })).status, 409, ); assertEquals( (await patch("/api/me", { auth: user.auth, json: { email: "bad" } })) .status, 400, ); assertEquals( (await patch("/api/me", { auth: user.auth, json: { description: "x".repeat(2001) }, })).status, 400, ); }); test("PATCH /api/users/:key/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?q=: 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?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?q=")).data, []); // An underscore is a letter of the name, not a wildcard. await createUser({ username: "zedx9" }); assertEquals( (await get("/api/users?q=zed_9")).data.map((u) => u.username ), ["zed_9"], ); }); test("GET /api/users/:key/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/:key/dumps and /upvoted 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", "upvoted"]) { 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); }); test("GET /api/users/:key/dumps and /upvoted filter by category", async () => { const owner = await createUser(); const music = insertCategory({ slug: "music" }); const tagged = insertDump({ userId: owner.id, title: "tagged", createdAt: "2024-01-01T00:00:00Z", }); const priv = insertDump({ userId: owner.id, title: "priv", isPrivate: true, createdAt: "2024-01-02T00:00:00Z", }); insertDump({ userId: owner.id, title: "untagged" }); for (const d of [tagged, priv]) { db.prepare( `INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`, ).run(d.id, music); } db.prepare( `INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?), (?, ?, ?), (?, ?, ?);`, ).run( tagged.id, owner.id, "2024-01-01T00:00:00Z", priv.id, owner.id, "2024-01-02T00:00:00Z", insertDump({ userId: owner.id, title: "voted" }).id, owner.id, "2024-01-03T00:00:00Z", ); for (const sub of ["dumps", "upvoted"]) { const titles = async ( query: string, opts?: { auth: { Authorization: string } }, ) => (await get>( `/api/users/@${owner.username}/${sub}${query}`, opts, )).data.items.map((d) => d.title); assertEquals(await titles("?category=music"), ["tagged"], sub); assertEquals( await titles("?category=music", { auth: owner.auth }), ["priv", "tagged"], sub, ); // An unknown category filters everything out rather than nothing. assertEquals(await titles("?category=nope"), [], sub); } }); // ── Login and register ─────────────────────────────────────────────────────── test("POST /api/sessions: right password logs in, wrong one doesn't say which", async () => { const user = await createUser(); const ok = await post("/api/sessions", { 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/sessions", { json: { username: user.username, password: "nope" }, }); const unknown = await post("/api/sessions", { 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/sessions", { json: { username: user.username } })) .status, 400, ); }); test("POST /api/sessions: 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/sessions", { 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/sessions", { json: { username: user.username, password: PASSWORD }, })).status, 429, ); }); test("POST /api/users: an invite opens one account", async () => { using mail = captureEmails(); const inviter = await createUser(); const inviteToken = invite(inviter.id); const res = await post("/api/users", { 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", { json: { username: "second_reg", password: "longenough", email: "second@example.test", inviteToken, }, }); assertEquals(again.status, 409); }); test("POST /api/users: 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", { json }); assertEquals(res.status, 400, JSON.stringify(json)); assert(message.test(res.error.message), res.error.message); } const unknown = await post("/api/users", { 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: 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", { json: { username: taken.username.toUpperCase(), password: "longenough", email: "fresh@example.test", inviteToken, }, }); assertEquals(byName.status, 409); const byEmail = await post("/api/users", { 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: 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", { json: { username: "late_comer", password: "longenough", email: "late@example.test", inviteToken, }, }); assertEquals(res.status, 404); }); test("POST /api/users: 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", { 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 put("/api/me/password", { auth: user.auth, json: { currentPassword: "nope", newPassword: "brand-new-pass" }, }); assertEquals(wrong.status, 403); const short = await put("/api/me/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 put("/api/me/password", { auth: user.auth, json: { currentPassword: PASSWORD, newPassword: "brand-new-pass" }, }); assertEquals(ok.status, 200); assertEquals((await get("/api/me", { auth: user.auth })).status, 401); assertEquals(await authenticateMedia(user.mediaToken), null); const fresh = { Authorization: `Bearer ${ok.data.token}` }; assertEquals((await get("/api/me", { auth: fresh })).status, 200); assertEquals(await authenticateMedia(ok.data.mediaToken), user.id); const login = await post("/api/sessions", { 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/password-resets", { 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/password-resets", { json: { email: user.email }, }); await post("/api/password-resets", { 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 put(`/api/password-resets/${token}`, { json: { 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 put(`/api/password-resets/${token}`, { json: { newPassword: "short" }, }); assertEquals(short.status, 400); const ok = await put(`/api/password-resets/${token}`, { json: { newPassword: "reset-password-1" }, }); assertEquals(ok.status, 200); const login = await post("/api/sessions", { json: { username: user.username, password: "reset-password-1" }, }); assertEquals(login.status, 200); const reuse = await put(`/api/password-resets/${token}`, { json: { 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 put(`/api/password-resets/${unstored}`, { json: { newPassword: "whatever-123" }, })).status, 404, ); for (const token of ["garbage", user.token, user.mediaToken]) { assertEquals( (await put(`/api/password-resets/${token}`, { json: { newPassword: "whatever-123" }, })).status, 400, ); } assertEquals( (await put(`/api/password-resets/${unstored}`, { json: {} })).status, 400, ); }); test("GET /api/me/media-token issues a media token for the caller", async () => { const user = await createUser(); const res = await get("/api/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/me/settings", { auth: ann.auth, }); assertEquals(initial.data, DEFAULT_SETTINGS); const changed = await patch("/api/me/settings", { auth: ann.auth, json: { style: "kanagawa", digestWeekly: false }, }); assertEquals(changed.data, { ...DEFAULT_SETTINGS, style: "kanagawa", digestWeekly: false, }); const scheme = await patch("/api/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/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/me/settings")).status, 401); for ( const json of [ { style: "comic" }, { colorScheme: "sepia" }, { digestMonthly: "no" }, { theme: "smooth" }, [], ] ) { const res = await patch("/api/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/me/settings", { auth: ann.auth, json: { style: null }, }); assertEquals(reset.data.style, null); }); // ── Usernames and paths ────────────────────────────────────────────────────── test("renaming: the old name redirects for good, and nobody else can take it", async () => { const admin = await createUser({ role: "admin" }); const bob = await createUser(); const old = bob.username; const renamed = uniqueName("robert"); const dump = insertDump({ userId: bob.id, title: "Never gonna" }); const playlist = insertPlaylist({ userId: bob.id, title: "Sunday mix" }); const rename = (key: string, username: string) => patch(`/api/users/${key}/username`, { auth: admin.auth, json: { username }, }); assertEquals((await rename(bob.id, renamed)).data.username, renamed); // The old name, in any case, still answers the user, under the new one. for (const name of [old, old.toUpperCase(), renamed]) { assertEquals( (await get(`/api/users/@${name}`)).data.username, renamed, name, ); } // Their dumps and playlists follow them to the new name. for (const name of [old, renamed]) { const d = await get(`/api/users/@${name}/dumps/${dump.slug}`); assertEquals([d.data.id, d.data.ownerUsername], [dump.id, renamed]); const p = await get( `/api/users/@${name}/playlists/${playlist.slug}`, ); assertEquals([p.data.id, p.data.ownerUsername], [playlist.id, renamed]); } // Nobody can register or be renamed to the old name, in any case. const other = await createUser(); const register = await post("/api/users", { json: { username: old.toUpperCase(), password: PASSWORD, email: `${uniqueName()}@example.test`, inviteToken: invite(other.id), }, }); assertEquals(register.status, 409); assertEquals((await rename(other.id, old)).status, 409); // Nor to a current one. assertEquals((await rename(other.id, renamed)).status, 409); // Nor to everyone, in any case, which the chat's @everyone takes. assertEquals((await rename(other.id, "Everyone")).status, 409); assertEquals( (await post("/api/users", { json: { username: "everyone", password: PASSWORD, email: `${uniqueName()}@example.test`, inviteToken: invite(other.id), }, })).status, 409, ); // Signing in takes the current name only. assertEquals( (await post("/api/sessions", { json: { username: old, password: PASSWORD }, })).status, 401, ); }); test("renaming: only by user:manage, at any time, and never by oneself", async () => { const user = await createUser(); // PATCH /api/me refuses a name, and changes nothing else with it. const self = await patch("/api/me", { auth: user.auth, json: { username: uniqueName("self"), description: "hi" }, }); assertEquals(self.status, 400); const me = await get("/api/me", { auth: user.auth }); assertEquals(me.data.username, user.username); assertNotEquals(me.data.description, "hi"); // An admin renames anyone at any time, themselves too. const admin = await createUser({ role: "admin" }); const rename = (key: string, username: string) => patch(`/api/users/${key}/username`, { auth: admin.auth, json: { username }, }); const first = uniqueName("first"); const second = uniqueName("second"); assertEquals((await rename(user.id, first)).data.username, first); assertEquals((await rename(user.id, second)).data.username, second); // A change of case alone leaves no alias. assertEquals( (await rename(`@${second}`, second.toUpperCase())).data.username, second.toUpperCase(), ); assertEquals( (await rename(`@${first}`, first)).data.username, first, "a former name of one's own can be taken back", ); assertEquals((await rename(user.id, "a b")).status, 400); const adminName = uniqueName("admin"); assertEquals((await rename(admin.id, adminName)).data.username, adminName); // Others may not. const moderator = await createUser({ role: "moderator" }); assertEquals( (await patch(`/api/users/${user.id}/username`, { auth: moderator.auth, json: { username: uniqueName() }, })).status, 403, ); }); test("GET /api/users/:key/dumps/:slug and /playlists/:slug: by path, privacy kept", async () => { const owner = await createUser(); const other = await createUser(); const dump = insertDump({ userId: owner.id, title: "Hello there" }); const secret = insertDump({ userId: owner.id, title: "Secret", isPrivate: true, }); const hidden = insertPlaylist({ userId: owner.id, title: "Hidden", isPublic: false, }); assertEquals(dump.slug, "hello-there"); assertEquals( (await get(`/api/users/@${owner.username}/dumps/hello-there`)).data .id, dump.id, ); assertEquals( (await get(`/api/users/${owner.id}/dumps/hello-there`)).status, 200, ); // Another user's space holds none of it. assertEquals( (await get(`/api/users/@${other.username}/dumps/hello-there`)).status, 404, ); const secretPath = `/api/users/@${owner.username}/dumps/${secret.slug}`; assertEquals((await get(secretPath)).status, 404); assertEquals((await get(secretPath, { auth: other.auth })).status, 404); assertEquals((await get(secretPath, { auth: owner.auth })).status, 200); const hiddenPath = `/api/users/@${owner.username}/playlists/${hidden.slug}`; assertEquals((await get(hiddenPath)).status, 404); assertEquals((await get(hiddenPath, { auth: owner.auth })).status, 200); });