Error statuses follow one rule: APIException takes a status and derives the code from it (BAD_REQUEST 400, UNAUTHORIZED 401, FORBIDDEN 403, NOT_FOUND 404, CONFLICT 409, RATE_LIMITED 429, SERVER_ERROR 500, BAD_GATEWAY 502), so a code can't contradict its status. That fixed real bugs: a wrong current password on change-password answered 401, on which authFetch signs the user out; editing or deleting someone else's comment or chat message answered 401 rather than 403; one invalid dump edit answered 422. A taken username is 409, like a taken email. Along the way: the database is created from schema.sql by connection.ts when the file doesn't exist, so every task works on a fresh checkout (dev and serve never ran db/init.ts, which is gone); the startup purge compared ISO dates with SQLite's datetime() as text and kept anything from the current UTC day, and now compares ISO cutoffs; the seeded admin's hash comes from lib/password.ts (split out of jwt.ts, which needs the JWT secret) rather than a copy of its format; avatar, thumbnail and playlist-cover uploads go through replaceUploadedFile, which writes beside the old file and renames over it before the record announces it (a failed avatar write lost the old avatar; a thumbnail was announced before it existed); upload.ts derives its size message from MAX_IMAGE_SIZE_BYTES and stops re-exporting the upload folders; ALLOWED_IMAGE_MIMES, which duplicated detectImageMime, is gone; the SPA fallback no longer calls next() after answering; a reply must answer a comment on the same dump (a foreign or unknown parent was a 500 or a cross-dump thread). 211 backend and 628 frontend tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
63 lines
2.0 KiB
TypeScript
63 lines
2.0 KiB
TypeScript
import "../setup.ts";
|
|
import { assertEquals } from "@std/assert";
|
|
import { SignJWT } from "@panva/jose";
|
|
import {
|
|
createJWT,
|
|
createMediaToken,
|
|
createPasswordResetToken,
|
|
verifyJWT,
|
|
verifyMediaToken,
|
|
verifyPasswordResetToken,
|
|
} from "../../lib/jwt.ts";
|
|
|
|
const KEY = new TextEncoder().encode("test-secret-test-secret-test-secret");
|
|
|
|
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);
|
|
});
|