786 lines
24 KiB
TypeScript
786 lines
24 KiB
TypeScript
import {
|
|
captureEmails,
|
|
count,
|
|
createUser,
|
|
db,
|
|
del,
|
|
get,
|
|
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 } 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<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);
|
|
// Without the `@`, the key is an id.
|
|
assertEquals((await get(`/api/users/${user.username}`)).status, 404);
|
|
const me = await get<SelfUser>("/api/me", { auth: user.auth });
|
|
assertEquals(me.data.email, user.email);
|
|
assertFalse("passwordHash" in me.data);
|
|
});
|
|
|
|
test("PATCH /api/me: description, email and username only", async () => {
|
|
const user = await createUser();
|
|
const taken = await createUser();
|
|
const res = await patch<SelfUser>("/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<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?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<UserSuggestion[]>("/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<UserSuggestion[]>("/api/users?q=")).data, []);
|
|
// An underscore is a letter of the name, not a wildcard.
|
|
await createUser({ username: "zedx9" });
|
|
assertEquals(
|
|
(await get<UserSuggestion[]>("/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<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/: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<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/sessions: right password logs in, wrong one doesn't say which", async () => {
|
|
const user = await createUser();
|
|
const ok = await post<AuthResponse>("/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<AuthResponse>("/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<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", { 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<AuthTokens>("/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<MediaTokenResponse>("/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<UserSettings>("/api/me/settings", {
|
|
auth: ann.auth,
|
|
});
|
|
assertEquals(initial.data, DEFAULT_SETTINGS);
|
|
|
|
const changed = await patch<UserSettings>("/api/me/settings", {
|
|
auth: ann.auth,
|
|
json: { style: "kanagawa", digestWeekly: false },
|
|
});
|
|
assertEquals(changed.data, {
|
|
...DEFAULT_SETTINGS,
|
|
style: "kanagawa",
|
|
digestWeekly: false,
|
|
});
|
|
const scheme = await patch<UserSettings>("/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<UserSettings>("/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<UserSettings>("/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 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 res = await patch<SelfUser>("/api/me", {
|
|
auth: bob.auth,
|
|
json: { username: renamed },
|
|
});
|
|
assertEquals(res.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<PublicUser>(`/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<Dump>(`/api/users/@${name}/dumps/${dump.slug}`);
|
|
assertEquals([d.data.id, d.data.ownerUsername], [dump.id, renamed]);
|
|
const p = await get<Playlist>(
|
|
`/api/users/@${name}/playlists/${playlist.slug}`,
|
|
);
|
|
assertEquals([p.data.id, p.data.ownerUsername], [playlist.id, renamed]);
|
|
}
|
|
|
|
// Nobody can register or rename 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 patch("/api/me", { auth: other.auth, json: { username: old } }))
|
|
.status,
|
|
409,
|
|
);
|
|
// Nor to a current one.
|
|
assertEquals(
|
|
(await patch("/api/me", {
|
|
auth: other.auth,
|
|
json: { username: renamed },
|
|
})).status,
|
|
409,
|
|
);
|
|
// Signing in takes the current name only.
|
|
assertEquals(
|
|
(await post("/api/sessions", {
|
|
json: { username: old, password: PASSWORD },
|
|
})).status,
|
|
401,
|
|
);
|
|
});
|
|
|
|
test("renaming: once in 30 days, a case change aside, and a moderator is exempt", async () => {
|
|
const user = await createUser();
|
|
const first = uniqueName("first");
|
|
assertEquals(
|
|
(await patch("/api/me", { auth: user.auth, json: { username: first } }))
|
|
.status,
|
|
200,
|
|
);
|
|
// A change of case alone leaves no alias, so it isn't counted.
|
|
assertEquals(
|
|
(await patch<SelfUser>("/api/me", {
|
|
auth: user.auth,
|
|
json: { username: first.toUpperCase() },
|
|
})).data.username,
|
|
first.toUpperCase(),
|
|
);
|
|
const again = await patch("/api/me", {
|
|
auth: user.auth,
|
|
json: { username: uniqueName("second") },
|
|
});
|
|
assertEquals(again.status, 429);
|
|
assertEquals(
|
|
(await patch("/api/me", { auth: user.auth, json: { username: "a b" } }))
|
|
.status,
|
|
400,
|
|
);
|
|
|
|
// An admin renames anyone at any time, and themselves too.
|
|
const admin = await createUser({ role: "admin" });
|
|
const third = uniqueName("third");
|
|
assertEquals(
|
|
(await patch<PublicUser>(`/api/users/${user.id}/username`, {
|
|
auth: admin.auth,
|
|
json: { username: third },
|
|
})).data.username,
|
|
third,
|
|
);
|
|
assertEquals(
|
|
(await patch<PublicUser>(`/api/users/@${third}/username`, {
|
|
auth: admin.auth,
|
|
json: { username: first },
|
|
})).data.username,
|
|
first,
|
|
"a former name of one's own can be taken back",
|
|
);
|
|
for (let i = 0; i < 2; i++) {
|
|
assertEquals(
|
|
(await patch("/api/me", {
|
|
auth: admin.auth,
|
|
json: { username: uniqueName("admin") },
|
|
})).status,
|
|
200,
|
|
);
|
|
}
|
|
// 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<Dump>(`/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);
|
|
});
|