Files
gerbeur/api/test/auth.test.ts
khannurien 25601edaf8
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 46s
v3: the backend gets a test suite, and the bugs it turned up are fixed — deno task test runs 186 tests over api/ in about 18s, against a throwaway database and uploads directory (GERBEUR_DB_PATH and GERBEUR_UPLOADS_DIR now override the defaults; api/test/setup.ts sets them before any app module loads), with requests sent through the whole middleware stack in-process via app.handle, outbound fetches and DNS stubbed, and the socket tests on a real Oak listener since an upgraded request has no response for handle() to return. It covers auth (token purposes, revocation after a password change, live roles, login rate limiting, invites claimed once even under parallel registrations, password resets), dumps end to end (create, privacy, by-url matching on the canonical key, backlinks, categories, edits, file replacement, thumbnails, refresh, delete), file serving headers and ranges, attachments, avatars, comments and likes, playlists, follows and feeds, notifications and mentions, categories, search, chat cursors and read markers, link previews and the SSRF guard on every redirect hop, the Bandcamp and YouTube resolvers, migrations (a fresh schema is left untouched; the data rewrites in 0004/0005 and 0010–0013 do what they say), CORS, OG tags, static caching, and the WebSocket protocol. Fixed along the way: an unknown category id on a new dump returned 400 but kept the dump row, and for a file dump deleted the file while keeping the row; an edit or file replacement with one was half-applied before the 400 — categories are now checked before anything is written. A suffix range (bytes=-3) was read as an end offset and got a 416 instead of the last three bytes. Reordering a playlist accepted a list naming a member twice. verifyPassword threw on a stored hash without a salt instead of answering false. And a request with no Accept-Encoding, which RFC 9110 lets a server treat as accepting anything, was sent the brotli sibling; curl and bots now get the plain file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 12:31:05 +00:00

459 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 {
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.body.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("/api/users/login", {
json: { username: user.username, password: PASSWORD },
});
assertEquals(ok.status, 200);
assertEquals(ok.body.data.user.id, user.id);
assertEquals(ok.body.data.user.passwordHash, undefined);
assertEquals((await verifyJWT(ok.body.data.token))?.userId, user.id);
assertEquals(
(await verifyMediaToken(ok.body.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.body.error.message, unknown.body.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("/api/users/register", {
json: {
username: "newbie_reg",
password: "longenough",
email: "newbie@example.test",
inviteToken,
},
});
assertEquals(res.status, 201);
assertEquals(res.body.data.user.username, "newbie_reg");
assertEquals(res.body.data.user.role, "user");
assertEquals(res.body.data.user.passwordHash, undefined);
assert(res.body.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.body.error.message), res.body.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("/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.body.data.token}` };
assertEquals((await get("/api/users/me", { auth: fresh })).status, 200);
assertEquals(await authenticateMedia(ok.body.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("/api/users/me/media-token", { auth: user.auth });
assertEquals(res.status, 200);
assertEquals(await authenticateMedia(res.body.data.mediaToken), user.id);
});