Files
gerbeur/api/test/routes/users.test.ts

839 lines
27 KiB
TypeScript

import {
captureEmails,
count,
createUser,
db,
del,
get,
insertCategory,
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,
assertNotEquals,
} 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}`,
// As another instance writes them.
`/api/users/@${user.username}@gerbeur.test`,
]
) {
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);
assertEquals(
(await get(`/api/users/@${user.username}@elsewhere.test`)).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 and email 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);
});
test("GET /api/users/:key/dumps and /upvoted filter by category", async () => {
const owner = await createUser();
const music = insertCategory({ slug: "music" });
const tagged = insertDump({
userId: owner.id,
title: "tagged",
createdAt: "2024-01-01T00:00:00Z",
});
const priv = insertDump({
userId: owner.id,
title: "priv",
isPrivate: true,
createdAt: "2024-01-02T00:00:00Z",
});
insertDump({ userId: owner.id, title: "untagged" });
for (const d of [tagged, priv]) {
db.prepare(
`INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`,
).run(d.id, music);
}
db.prepare(
`INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?), (?, ?, ?), (?, ?, ?);`,
).run(
tagged.id,
owner.id,
"2024-01-01T00:00:00Z",
priv.id,
owner.id,
"2024-01-02T00:00:00Z",
insertDump({ userId: owner.id, title: "voted" }).id,
owner.id,
"2024-01-03T00:00:00Z",
);
for (const sub of ["dumps", "upvoted"]) {
const titles = async (
query: string,
opts?: { auth: { Authorization: string } },
) =>
(await get<PaginatedData<Dump>>(
`/api/users/@${owner.username}/${sub}${query}`,
opts,
)).data.items.map((d) => d.title);
assertEquals(await titles("?category=music"), ["tagged"], sub);
assertEquals(
await titles("?category=music", { auth: owner.auth }),
["priv", "tagged"],
sub,
);
// An unknown category filters everything out rather than nothing.
assertEquals(await titles("?category=nope"), [], sub);
}
});
// ── 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 admin = await createUser({ role: "admin" });
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 rename = (key: string, username: string) =>
patch<PublicUser>(`/api/users/${key}/username`, {
auth: admin.auth,
json: { username },
});
assertEquals((await rename(bob.id, renamed)).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 be renamed 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 rename(other.id, old)).status, 409);
// Nor to a current one.
assertEquals((await rename(other.id, renamed)).status, 409);
// Nor to everyone, in any case, which the chat's @everyone takes.
assertEquals((await rename(other.id, "Everyone")).status, 409);
assertEquals(
(await post("/api/users", {
json: {
username: "everyone",
password: PASSWORD,
email: `${uniqueName()}@example.test`,
inviteToken: invite(other.id),
},
})).status,
409,
);
// Signing in takes the current name only.
assertEquals(
(await post("/api/sessions", {
json: { username: old, password: PASSWORD },
})).status,
401,
);
});
test("renaming: only by user:manage, at any time, and never by oneself", async () => {
const user = await createUser();
// PATCH /api/me refuses a name, and changes nothing else with it.
const self = await patch("/api/me", {
auth: user.auth,
json: { username: uniqueName("self"), description: "hi" },
});
assertEquals(self.status, 400);
const me = await get<SelfUser>("/api/me", { auth: user.auth });
assertEquals(me.data.username, user.username);
assertNotEquals(me.data.description, "hi");
// An admin renames anyone at any time, themselves too.
const admin = await createUser({ role: "admin" });
const rename = (key: string, username: string) =>
patch<PublicUser>(`/api/users/${key}/username`, {
auth: admin.auth,
json: { username },
});
const first = uniqueName("first");
const second = uniqueName("second");
assertEquals((await rename(user.id, first)).data.username, first);
assertEquals((await rename(user.id, second)).data.username, second);
// A change of case alone leaves no alias.
assertEquals(
(await rename(`@${second}`, second.toUpperCase())).data.username,
second.toUpperCase(),
);
assertEquals(
(await rename(`@${first}`, first)).data.username,
first,
"a former name of one's own can be taken back",
);
assertEquals((await rename(user.id, "a b")).status, 400);
const adminName = uniqueName("admin");
assertEquals((await rename(admin.id, adminName)).data.username, adminName);
// 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);
});