Validation: the dump validators refuse the nulls their shared types don't allow (a null title or note on creation, a null URL on edit); a playlist title of spaces is refused and titles are stored trimmed; usernames and emails are checked by the same USERNAME_RE and EMAIL_RE in shared/validation.ts on sign-up, on PATCH /api/users/me (which checked other limits) and in the sign-up form. Mentions are read by shared/mention.ts's mentionedUsernames, which skips code and URLs as Markdown shows them: https://mastodon.social/@bob or a `@bob` in code notified bob; the editor's suggestions derive their trigger from the same pattern instead of a copy. Following an unknown user is a 404 rather than a foreign-key 500, and following a private playlist a 404 rather than a 403 that confirmed it exists. `ws` and `emojibase` are reserved category slugs, since the server answers them before the app. GET /api/users/me no longer turns every error into a 500. Dead code goes: adminOnlyMiddleware, verifyEmailTransport, listUsers, deleteUser with disconnectUser and the force_logout message only it sent, PATCH /api/notifications/:id/read (with markOneRead), isSlugAvailable, the unreachable 500 in fetchComment, and the `if (!ctx.state.user)` checks behind authMiddleware. 214 backend and 627 frontend tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
518 lines
16 KiB
TypeScript
518 lines
16 KiB
TypeScript
import {
|
|
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";
|
|
|
|
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<PublicUser>(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<SelfUser>("/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<PublicUser>("/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<SelfUser>("/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<PublicUser>(`/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<UserSuggestion[]>("/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<UserSuggestion[]>("/api/users/search?q=")).data, []);
|
|
// An underscore is a letter of the name, not a wildcard.
|
|
await createUser({ username: "zedx9" });
|
|
assertEquals(
|
|
(await get<UserSuggestion[]>("/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<Invitee[]>(`/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<PublicUser>(`/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<PaginatedData<Dump>>(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<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,
|
|
);
|
|
});
|
|
|
|
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.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<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);
|
|
});
|