Files
gerbeur/api/test/auth.test.ts
khannurien 364c498a24
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
v3: dependencies upgraded and deno audit clean, every API response typed end to end, no explicit any left — and the site search stops handing out password hashes — deno audit reported 19 advisories (11 high); none remain. nodemailer 9 → 10.0.10 closes four of them (a quadratic address parser, two recipient-domain allow-list bypasses, resolveContent ignoring disableFileAccess/disableUrlAccess); 10's one break is Node ≥ 20, and 10.0.11 is a day too young for the minimum release age. react-router 8.0.1 → 8.4.0 closes the RSC-mode CSRF bypass. Refreshing the lockfile clears the transitive ones that came in through Vite, ESLint and Lingui: postcss, nanoid, picomatch, browserslist, brace-expansion and baseline-browser-mapping. Everything else moves to its latest too — Lingui 6.8 (its SWC plugin 6.7), React 19.3, Vite 8.3.1, react-hook-form 7.89, marked, jose, ESLint 10.11, typescript-eslint 8.70, the @types — and frimousse 0.3 → 0.4, whose declarations only add (custom emoji data resolvers, a cache). TypeScript stays on 6.0: typescript-eslint supports < 6.1, so 7.0 would stop the lint. allowScripts names @swc/core 1.16.2 and drops esbuild, which Vite 8 no longer installs.
Search: GET /api/search sent each matching user's full row — password hash and email included — to anyone, signed in or not, because searchUsersGlobal returned User[] and the route passed it through. It returns profiles only now, stripped where the rows are read, and the search test fails on the old code. Types alone wouldn't have caught it (a User is structurally a PublicUser), so the strip is explicit and tested.

Typed routes. Every JSON response is declared as APIResponse<T>, the way the typed routes already did, with the missing shapes named in api/model/interfaces.ts: PublicUser (no hash, no email) and SelfUser (your own profile, with email) plus toPublicUser/toSelfUser in place of ten hand-written destructurings, AuthTokens, AuthResponse, MediaTokenResponse, InviteResponse, AttachmentResponse, SearchResults, LinkPreview, UserSuggestion and Invitee. Request bodies are `unknown` and narrowed by the existing guards, or by a new fieldOf(obj, key) where a route reads one field — no more reading off Oak's `any` or casting to Record<string, unknown>. The password-reset routes answer `{ success: true, data: null }` rather than a bare `{ success: true }`, so every success fits the envelope; the frontend only reads `success`.

Socket protocol: routes/ws.ts had its own send() taking Record<string, unknown>, so nothing checked what it sent. It takes ServerToClientMessage now, which turned up two mismatches: `pong` was missing from the union although the server answers every client ping with one, and vote_ack carried a `success: true` no type declares and no client reads. The union has PongMessage and vote_ack lost the field.

No explicit any. The YouTube playlist parse reads ytInitialData as `unknown` through a small at(node, …path) that checks each step, instead of `type Json = any`. The backend test helpers type the response body as the server's envelope: request<T>() and get/post/put/patch/del<T>() name what `data` holds, res.data throws with the server's message when the call didn't succeed and res.error when it didn't fail, and each test says which API type it expects, so a test reading a field the route doesn't send no longer compiles. Assertions that a field is absent are `assertFalse("passwordHash" in …)` rather than reading a field the type says isn't there. The socket tests' messages are ServerToClientMessage, with next()/all() keyed by type so each returns that message's shape.

No double casts in src/ either. window.YT and onYouTubeIframeAPIReady are declared on Window; the test's fake player is a class forwarding to the spies the tests inspect. WSContext, FollowContext and PlayerContext export their defaults (defaultWSValue, defaultFollowValue, defaultPlayerValue), which the tests import instead of reading React's private _currentValue. handleMentionKeyDown takes a MentionKey — the key fields it reads — so the test builds a plain object; useUserDumpFeed declares sentinelRef as the callback ref it is. In src/test/setup.ts the IntersectionObserver fake implements IntersectionObserver, and report(isIntersecting, top?) builds complete entries in place of partial ones cast in five files; the Animation fake implements Animation; the canvas getContext stub goes in through defineProperty. The XHR fake's last request is optional behind a getter that fails if none was made, the keyframes are checked with matchers, and the roleless legacy session is built by leaving the role out. One test changes meaning slightly: "leaving the view…" relied on entries with no position at all, and now leaves and re-enters at the same height, which is what it describes. The one cast left is in api/test/ws.test.ts: deno.json's "dom" lib hides Deno's WebSocket overload that takes handshake headers, and dropping that lib would break the editor's view of src/; it's commented and names the real signature.

Also: the notifications test answered a Response-typed promise with a plain object, which failed `tsc -b`; it uses the fetch stub's deferred() now. ESLint didn't read the `deno-lint-ignore no-explicit-any` comments, so `eslint .` failed on three files; those anys are gone. `deno fmt` over api/ and src/ (not the compiled src/locales/*.js); the reflow left all 332 message ids intact, and the .po files only have their source line references updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:05:29 +00:00

466 lines
16 KiB
TypeScript

// Tokens, passwords and the checks that turn a token into a user.
import { count, createUser, db, get, PASSWORD, post, test } from "./helpers.ts";
import type {
AuthResponse,
AuthTokens,
MediaTokenResponse,
} from "../model/interfaces.ts";
import {
assert,
assertEquals,
assertFalse,
assertNotEquals,
} from "@std/assert";
import { SignJWT } from "@panva/jose";
import {
createJWT,
createMediaToken,
createPasswordResetToken,
hashPassword,
verifyJWT,
verifyMediaToken,
verifyPassword,
verifyPasswordResetToken,
} from "../lib/jwt.ts";
import { authenticate, authenticateMedia } from "../lib/auth.ts";
const KEY = new TextEncoder().encode("test-secret-test-secret-test-secret");
Deno.test("hashPassword/verifyPassword round-trip with a random salt", async () => {
const a = await hashPassword("hunter22");
const b = await hashPassword("hunter22");
assertNotEquals(a, b);
assert(await verifyPassword("hunter22", a));
assertFalse(await verifyPassword("hunter23", a));
// A malformed stored hash never verifies.
assertFalse(await verifyPassword("hunter22", "not-a-hash"));
assertFalse(await verifyPassword("hunter22", ""));
});
Deno.test("session, media and reset tokens don't stand in for each other", async () => {
const userId = crypto.randomUUID();
const session = await createJWT({ userId, username: "u", role: "user" });
const media = await createMediaToken(userId);
const reset = await createPasswordResetToken(userId);
assertEquals((await verifyJWT(session))?.userId, userId);
assertEquals(await verifyJWT(media), null);
assertEquals(await verifyJWT(reset), null);
assertEquals((await verifyMediaToken(media))?.userId, userId);
assertEquals(await verifyMediaToken(session), null);
assertEquals(await verifyMediaToken(reset), null);
assertEquals((await verifyPasswordResetToken(reset))?.userId, userId);
assertEquals(await verifyPasswordResetToken(session), null);
assertEquals(await verifyPasswordResetToken(media), null);
});
Deno.test("verifyJWT: refuses a bad signature, an expired token, garbage", async () => {
const forged = await new SignJWT({
userId: "x",
username: "x",
role: "admin",
})
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("1h")
.sign(new TextEncoder().encode("some-other-secret"));
assertEquals(await verifyJWT(forged), null);
const expired = await new SignJWT({
userId: "x",
username: "x",
role: "user",
})
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime(Math.floor(Date.now() / 1000) - 10)
.sign(KEY);
assertEquals(await verifyJWT(expired), null);
const badRole = await new SignJWT({ userId: "x", username: "x", role: "god" })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("1h")
.sign(KEY);
assertEquals(await verifyJWT(badRole), null);
assertEquals(await verifyJWT("not.a.jwt"), null);
});
test("authenticate: the role comes from the database, not the token", async () => {
const user = await createUser({ role: "user" });
db.prepare(`UPDATE users SET role = 'admin' WHERE id = ?;`).run(user.id);
assertEquals((await authenticate(user.token))?.role, "admin");
});
test("authenticate: a deleted user's token is refused", async () => {
const user = await createUser();
db.prepare(`DELETE FROM users WHERE id = ?;`).run(user.id);
assertEquals(await authenticate(user.token), null);
assertEquals((await get("/api/users/me", { auth: user.auth })).status, 401);
});
test("authenticate: tokens issued before tokens_valid_after are revoked", async () => {
const user = await createUser();
const future = new Date(Date.now() + 60_000).toISOString();
db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run(
future,
user.id,
);
assertEquals(await authenticate(user.token), null);
assertEquals(await authenticateMedia(user.mediaToken), null);
// A token issued at the cutoff second or later passes.
const past = new Date(Date.now() - 60_000).toISOString();
db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run(
past,
user.id,
);
assertEquals((await authenticate(user.token))?.userId, user.id);
});
test("authenticateMedia: takes a media token or a legacy session token", async () => {
const user = await createUser();
assertEquals(await authenticateMedia(user.mediaToken), user.id);
assertEquals(await authenticateMedia(user.token), user.id);
assertEquals(await authenticateMedia("garbage"), null);
});
test("optional auth: a missing or placeholder token is anonymous, a bad one is 401", async () => {
const owner = await createUser();
// A public listing that reads optional auth.
const path = `/api/users/${owner.username}/dumps`;
assertEquals((await get(path)).status, 200);
for (const placeholder of ["Bearer undefined", "Bearer null", "Bearer "]) {
assertEquals(
(await get(path, { headers: { Authorization: placeholder } })).status,
200,
placeholder,
);
}
assertEquals(
(await get(path, { headers: { Authorization: "Basic abc" } })).status,
200,
);
const bad = await get(path, { headers: { Authorization: "Bearer nope" } });
assertEquals(bad.status, 401);
assertEquals(bad.error.code, "UNAUTHORIZED");
});
test("required auth: missing, malformed and forged tokens are 401", async () => {
assertEquals((await get("/api/users/me")).status, 401);
assertEquals(
(await get("/api/users/me", { headers: { Authorization: "Token x" } }))
.status,
401,
);
const media = (await createUser()).mediaToken;
assertEquals(
(await get("/api/users/me", {
headers: { Authorization: `Bearer ${media}` },
})).status,
401,
);
});
// ── 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<AuthResponse>("/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,
);
});
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("POST /api/users/register: an invite opens one account", async () => {
const inviter = await createUser();
const inviteToken = invite(inviter.id);
const res = await post<AuthResponse>("/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);
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<string, unknown>, 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,
password: "longenough",
email: "fresh@example.test",
inviteToken,
},
});
assertEquals(byName.status, 400);
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, 401);
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<AuthTokens>("/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("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<MediaTokenResponse>("/api/users/me/media-token", {
auth: user.auth,
});
assertEquals(res.status, 200);
assertEquals(await authenticateMedia(res.data.mediaToken), user.id);
});