980 lines
33 KiB
TypeScript
980 lines
33 KiB
TypeScript
import {
|
|
app,
|
|
count,
|
|
createUser,
|
|
db,
|
|
del,
|
|
formWithFile,
|
|
get,
|
|
insertComment,
|
|
insertDump,
|
|
insertPlaylist,
|
|
PASSWORD,
|
|
patch,
|
|
PNG,
|
|
post,
|
|
put,
|
|
resetDb,
|
|
test,
|
|
type TestUser,
|
|
} from "../helpers.ts";
|
|
import { assert, assertEquals, assertExists, assertFalse } from "@std/assert";
|
|
import type { AuthTokens } from "../../../shared/user.ts";
|
|
import type { Comment } from "../../../shared/comment.ts";
|
|
import type { Dump } from "../../../shared/dump.ts";
|
|
import type { Playlist } from "../../../shared/playlist.ts";
|
|
import type { ServerToClientMessage } from "../../../shared/ws.ts";
|
|
|
|
// deno.json's "dom" lib types WebSocket's second argument as protocols only,
|
|
// hiding Deno's own overload, which takes handshake headers.
|
|
const DenoWebSocket = WebSocket as unknown as new (
|
|
url: string,
|
|
options: { headers: HeadersInit },
|
|
) => WebSocket;
|
|
|
|
type Msg = ServerToClientMessage;
|
|
|
|
type MsgOf<T extends Msg["type"]> = Extract<Msg, { type: T }>;
|
|
|
|
interface Client {
|
|
ws: WebSocket;
|
|
messages: Msg[];
|
|
/** Resolves with the next unread message of `type` (and matching `where`). */
|
|
next<T extends Msg["type"]>(
|
|
type: T,
|
|
where?: (m: MsgOf<T>) => boolean,
|
|
): Promise<MsgOf<T>>;
|
|
/** Every message of `type` received so far. */
|
|
all<T extends Msg["type"]>(type: T): MsgOf<T>[];
|
|
send(data: unknown): void;
|
|
/** Round-trips a ping, so anything the server sent before it has arrived. */
|
|
sync(): Promise<void>;
|
|
closed: Promise<CloseEvent>;
|
|
}
|
|
|
|
async function withServer(
|
|
fn: (
|
|
connect: (user?: TestUser, origin?: string) => Promise<Client>,
|
|
) => Promise<void>,
|
|
): Promise<void> {
|
|
resetDb();
|
|
// Oak's own server, not app.handle(): an upgraded request has no response
|
|
// for handle() to return. Its listen() promise never settles after the
|
|
// abort, hence the sanitizers being off for these tests.
|
|
const controller = new AbortController();
|
|
const port = await new Promise<number>((resolve) => {
|
|
app.addEventListener("listen", (e) => resolve(e.port), { once: true });
|
|
app.listen({ hostname: "127.0.0.1", port: 0, signal: controller.signal });
|
|
});
|
|
const clients: Client[] = [];
|
|
|
|
const connect = (user?: TestUser, origin = "http://allowed.test") => {
|
|
const query = user ? `?token=${user.token}` : "";
|
|
const ws = new DenoWebSocket(
|
|
`ws://127.0.0.1:${port}/ws${query}`,
|
|
{ headers: { origin } },
|
|
);
|
|
const messages: Msg[] = [];
|
|
const read = new Set<Msg>();
|
|
const waiters: (() => void)[] = [];
|
|
ws.onmessage = (e) => {
|
|
messages.push(JSON.parse(e.data));
|
|
waiters.splice(0).forEach((w) => w());
|
|
};
|
|
const closed = new Promise<CloseEvent>((resolve) => {
|
|
ws.addEventListener("close", (e) => resolve(e));
|
|
});
|
|
const client: Client = {
|
|
ws,
|
|
messages,
|
|
closed,
|
|
async next<T extends Msg["type"]>(
|
|
type: T,
|
|
where: (m: MsgOf<T>) => boolean = () => true,
|
|
) {
|
|
const deadline = Date.now() + 3000;
|
|
while (true) {
|
|
const hit = messages.find((m): m is MsgOf<T> =>
|
|
!read.has(m) && m.type === type && where(m as MsgOf<T>)
|
|
);
|
|
if (hit) {
|
|
read.add(hit);
|
|
return hit;
|
|
}
|
|
if (Date.now() > deadline) {
|
|
throw new Error(
|
|
`No ${type} message; got ${
|
|
messages.map((m) => m.type).join(", ")
|
|
}`,
|
|
);
|
|
}
|
|
await Promise.race([
|
|
new Promise<void>((r) => waiters.push(r)),
|
|
new Promise((r) => setTimeout(r, 50)),
|
|
]);
|
|
}
|
|
},
|
|
all: <T extends Msg["type"]>(type: T) =>
|
|
messages.filter((m): m is MsgOf<T> => m.type === type),
|
|
send: (data) =>
|
|
ws.send(typeof data === "string" ? data : JSON.stringify(data)),
|
|
async sync() {
|
|
client.send({ type: "ping" });
|
|
await client.next("pong");
|
|
},
|
|
};
|
|
clients.push(client);
|
|
return new Promise<Client>((resolve, reject) => {
|
|
ws.onopen = async () => {
|
|
await client.next("welcome");
|
|
resolve(client);
|
|
};
|
|
ws.onerror = () => reject(new Error("socket error"));
|
|
});
|
|
};
|
|
|
|
try {
|
|
await fn(connect);
|
|
} finally {
|
|
for (const c of clients) {
|
|
if (
|
|
c.ws.readyState === WebSocket.OPEN ||
|
|
c.ws.readyState === WebSocket.CONNECTING
|
|
) {
|
|
c.ws.close();
|
|
}
|
|
}
|
|
await Promise.all(clients.map((c) => c.closed));
|
|
// Let the server see the closes and drop the clients from its registry.
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
controller.abort();
|
|
}
|
|
}
|
|
|
|
function wsTest(name: string, fn: Parameters<typeof withServer>[0]) {
|
|
Deno.test({
|
|
name,
|
|
sanitizeOps: false,
|
|
sanitizeResources: false,
|
|
fn: () => withServer(fn),
|
|
});
|
|
}
|
|
|
|
test("GET /ws: refuses other origins, and plain requests from allowed ones", async () => {
|
|
assertEquals(
|
|
(await get("/ws", { headers: { Origin: "http://evil.test" } })).status,
|
|
403,
|
|
);
|
|
assertEquals((await get("/ws")).status, 403);
|
|
assertEquals(
|
|
(await get("/ws", { headers: { Origin: "http://allowed.test" } })).status,
|
|
426,
|
|
);
|
|
});
|
|
|
|
wsTest("welcome: anonymous and signed-in state", async (connect) => {
|
|
const user = await createUser({ createdAt: "2024-01-01T00:00:00.000Z" });
|
|
const { id: dumpId } = insertDump({ userId: user.id });
|
|
db.prepare(
|
|
`INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`,
|
|
)
|
|
.run(dumpId, user.id, new Date().toISOString());
|
|
|
|
const anon = await connect();
|
|
const [welcome] = anon.all("welcome");
|
|
assertEquals(welcome.users, []);
|
|
assertEquals(welcome.myVotes, []);
|
|
assertEquals(welcome.unreadChatCount, 0);
|
|
assertEquals(welcome.chatReadAt, undefined);
|
|
|
|
const me = await connect(user);
|
|
const [mine] = me.all("welcome");
|
|
assertEquals(mine.myVotes, [dumpId]);
|
|
assertEquals(mine.users.map((u) => u.username), [user.username]);
|
|
assertEquals(mine.chatReadAt, "2024-01-01T00:00:00.000Z");
|
|
// The anonymous client hears about the arrival.
|
|
const presence = await anon.next(
|
|
"presence_update",
|
|
(m) => m.users.length === 1,
|
|
);
|
|
assertEquals(presence.users[0].userId, user.id);
|
|
});
|
|
|
|
wsTest(
|
|
"presence: one entry per user however many tabs, gone on close",
|
|
async (connect) => {
|
|
const user = await createUser();
|
|
const watcher = await connect();
|
|
const tab1 = await connect(user);
|
|
const tab2 = await connect(user);
|
|
assertEquals(tab2.all("welcome")[0].users.length, 1);
|
|
tab1.ws.close();
|
|
await tab1.closed;
|
|
tab2.ws.close();
|
|
await tab2.closed;
|
|
await watcher.next("presence_update", (m) => m.users.length === 0);
|
|
},
|
|
);
|
|
|
|
wsTest(
|
|
"a refused origin never connects; a revoked token connects anonymously",
|
|
async (connect) => {
|
|
let refused = false;
|
|
await connect(undefined, "http://evil.test").catch(() => (refused = true));
|
|
assert(refused);
|
|
|
|
const user = await createUser();
|
|
db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`)
|
|
.run(new Date(Date.now() + 60_000).toISOString(), user.id);
|
|
const client = await connect(user);
|
|
assertEquals(client.all("welcome")[0].users, []);
|
|
client.send({ type: "vote_cast", dumpId: "x" });
|
|
assertEquals(
|
|
(await client.next("error")).message,
|
|
"Authentication required",
|
|
);
|
|
},
|
|
);
|
|
|
|
wsTest(
|
|
"votes: ack to the voter, count to everyone, errors carry context",
|
|
async (connect) => {
|
|
const owner = await createUser();
|
|
const voter = await createUser();
|
|
const { id: dumpId } = insertDump({ userId: owner.id });
|
|
const watcher = await connect();
|
|
const v = await connect(voter);
|
|
|
|
v.send({ type: "vote_cast", dumpId });
|
|
assertEquals(await v.next("vote_ack"), {
|
|
type: "vote_ack",
|
|
dumpId,
|
|
action: "cast",
|
|
voteCount: 1,
|
|
});
|
|
const update = await watcher.next("votes_update");
|
|
assertEquals([update.voteCount, update.voterId, update.action], [
|
|
1,
|
|
voter.id,
|
|
"cast",
|
|
]);
|
|
|
|
v.send({ type: "vote_cast", dumpId });
|
|
const dup = await v.next("error");
|
|
assertEquals([dup.message, dup.dumpId, dup.action], [
|
|
"Already voted",
|
|
dumpId,
|
|
"cast",
|
|
]);
|
|
|
|
v.send({ type: "vote_remove", dumpId });
|
|
assertEquals((await v.next("vote_ack")).voteCount, 0);
|
|
v.send({ type: "vote_remove", dumpId });
|
|
assertEquals((await v.next("error")).message, "Vote not found");
|
|
|
|
// The owner was notified of the upvote, live.
|
|
assertEquals(
|
|
count(
|
|
`SELECT COUNT(*) as n FROM notifications WHERE user_id = ?;`,
|
|
owner.id,
|
|
),
|
|
1,
|
|
);
|
|
|
|
const anon = await connect();
|
|
anon.send({ type: "vote_cast", dumpId });
|
|
assertEquals((await anon.next("error")).message, "Authentication required");
|
|
},
|
|
);
|
|
|
|
wsTest(
|
|
"votes on a private dump: others get a 404, the count stays with the owner",
|
|
async (connect) => {
|
|
const owner = await createUser();
|
|
const other = await createUser();
|
|
const { id: dumpId } = insertDump({ userId: owner.id, isPrivate: true });
|
|
const o = await connect(owner);
|
|
const x = await connect(other);
|
|
x.send({ type: "vote_cast", dumpId });
|
|
assertEquals((await x.next("error")).message, "Dump not found");
|
|
o.send({ type: "vote_cast", dumpId });
|
|
await o.next("vote_ack");
|
|
await o.next("votes_update");
|
|
await x.sync();
|
|
assertEquals(x.all("votes_update"), []);
|
|
},
|
|
);
|
|
|
|
wsTest("comment likes over the socket", async (connect) => {
|
|
const author = await createUser();
|
|
const liker = await createUser();
|
|
const { id: dumpId } = insertDump({ userId: author.id });
|
|
const commentId = insertComment({ dumpId, userId: author.id });
|
|
const a = await connect(author);
|
|
const l = await connect(liker);
|
|
l.send({ type: "comment_like_cast", commentId });
|
|
assertEquals((await l.next("comment_like_ack")).likeCount, 1);
|
|
assertEquals((await a.next("comment_likes_update")).likerId, liker.id);
|
|
// The like notification reaches the author live.
|
|
const note = await a.next("notification_created");
|
|
assertEquals(note.notification.type, "comment_liked");
|
|
l.send({ type: "comment_like_remove", commentId });
|
|
assertEquals((await l.next("comment_like_ack")).likeCount, 0);
|
|
l.send({ type: "comment_like_cast", commentId: crypto.randomUUID() });
|
|
assertEquals((await l.next("error")).message, "Comment not found");
|
|
});
|
|
|
|
wsTest(
|
|
"chat: members hear messages, anonymous sockets don't",
|
|
async (connect) => {
|
|
const alice = await createUser();
|
|
const bob = await createUser();
|
|
const a = await connect(alice);
|
|
const b = await connect(bob);
|
|
const anon = await connect();
|
|
|
|
a.send({ type: "chat_send", body: " hello ", nonce: "n1" });
|
|
const mine = await a.next("chat_message");
|
|
assertEquals(mine.nonce, "n1");
|
|
assertEquals(mine.message.body, "hello");
|
|
const theirs = await b.next("chat_message");
|
|
assertEquals(theirs.message.id, mine.message.id);
|
|
|
|
b.send({ type: "chat_send", body: "reply", replyToId: mine.message.id });
|
|
const reply = await a.next(
|
|
"chat_message",
|
|
(m) => m.message.body === "reply",
|
|
);
|
|
assertEquals(reply.message.replyToBody, "hello");
|
|
|
|
await anon.sync();
|
|
assertEquals(anon.all("chat_message"), []);
|
|
anon.send({ type: "chat_send", body: "hi", nonce: "anon" });
|
|
const err = await anon.next("error");
|
|
assertEquals([err.scope, err.nonce, err.message], [
|
|
"chat",
|
|
"anon",
|
|
"Authentication required",
|
|
]);
|
|
},
|
|
);
|
|
|
|
wsTest("chat: bad sends are reported with their nonce", async (connect) => {
|
|
const user = await createUser();
|
|
const c = await connect(user);
|
|
c.send({ type: "chat_send", body: " ", nonce: "blank" });
|
|
assertEquals((await c.next("error")).nonce, "blank");
|
|
c.send({ type: "chat_send", body: 42, nonce: "number" });
|
|
assertEquals((await c.next("error")).message, "Message cannot be empty");
|
|
c.send({ type: "chat_send", body: "x".repeat(2001), nonce: "long" });
|
|
assertEquals(
|
|
(await c.next("error", (m) => m.nonce === "long")).scope,
|
|
"chat",
|
|
);
|
|
// A nonce over 64 characters isn't echoed.
|
|
c.send({ type: "chat_send", body: " ", nonce: "n".repeat(65) });
|
|
assertEquals((await c.next("error")).nonce, undefined);
|
|
});
|
|
|
|
wsTest(
|
|
"chat: edits and deletes by the author or a moderator only",
|
|
async (connect) => {
|
|
const author = await createUser();
|
|
const other = await createUser();
|
|
const mod = await createUser({ role: "moderator" });
|
|
const a = await connect(author);
|
|
const o = await connect(other);
|
|
const m = await connect(mod);
|
|
|
|
a.send({ type: "chat_send", body: "original" });
|
|
const { message } = await a.next("chat_message");
|
|
|
|
o.send({ type: "chat_edit", id: message.id, body: "hacked" });
|
|
assertEquals(
|
|
(await o.next("error")).message,
|
|
"Not authorized to edit this message",
|
|
);
|
|
o.send({ type: "chat_delete", id: message.id });
|
|
assertEquals(
|
|
(await o.next("error")).message,
|
|
"Not authorized to delete this message",
|
|
);
|
|
|
|
a.send({ type: "chat_edit", id: message.id, body: "edited" });
|
|
assertEquals((await o.next("chat_message_updated")).message.body, "edited");
|
|
|
|
// A moderator demoted since connecting loses the power at once.
|
|
db.prepare(`UPDATE users SET role = 'user' WHERE id = ?;`).run(mod.id);
|
|
m.send({ type: "chat_delete", id: message.id });
|
|
assertEquals((await m.next("error")).scope, "chat");
|
|
db.prepare(`UPDATE users SET role = 'moderator' WHERE id = ?;`).run(mod.id);
|
|
m.send({ type: "chat_delete", id: message.id });
|
|
assertEquals((await a.next("chat_message_deleted")).id, message.id);
|
|
|
|
a.send({ type: "chat_edit", id: 5, body: "x" });
|
|
assertEquals((await a.next("error")).message, "Invalid message");
|
|
},
|
|
);
|
|
|
|
wsTest("chat: a burst of writes is rate limited", async (connect) => {
|
|
const user = await createUser();
|
|
const c = await connect(user);
|
|
for (let i = 0; i < 9; i++) {
|
|
c.send({ type: "chat_send", body: `m${i}`, nonce: `${i}` });
|
|
}
|
|
const err = await c.next("error");
|
|
assertEquals(err.nonce, "8");
|
|
assertEquals(err.message, "You're sending messages too fast — wait a moment");
|
|
await c.sync();
|
|
assertEquals(c.all("chat_message").length, 8);
|
|
});
|
|
|
|
wsTest(
|
|
"chat: mentions notify, unless the chat is already open",
|
|
async (connect) => {
|
|
const alice = await createUser();
|
|
const bob = await createUser();
|
|
const carol = await createUser();
|
|
const a = await connect(alice);
|
|
const b = await connect(bob);
|
|
b.send({ type: "chat_focus", open: true });
|
|
await b.sync();
|
|
a.send({
|
|
type: "chat_send",
|
|
body: `hey @${bob.username} @${carol.username}`,
|
|
});
|
|
await a.next("chat_message");
|
|
const notified = db.prepare(
|
|
`SELECT user_id FROM notifications WHERE type = 'user_mentioned';`,
|
|
).all().map((r) => r.user_id);
|
|
assertEquals(notified, [carol.id]);
|
|
},
|
|
);
|
|
|
|
wsTest(
|
|
"chat: @everyone notifies all who aren't reading, when its author may say it",
|
|
async (connect) => {
|
|
const mod = await createUser({ role: "moderator" });
|
|
const user = await createUser();
|
|
const named = await createUser();
|
|
const reader = await createUser();
|
|
const m = await connect(mod);
|
|
const u = await connect(user);
|
|
const r = await connect(reader);
|
|
r.send({ type: "chat_focus", open: true });
|
|
await r.sync();
|
|
const mentioned = () =>
|
|
Object.fromEntries(
|
|
(db.prepare(
|
|
`SELECT user_id, data FROM notifications WHERE type = 'user_mentioned';`,
|
|
).all() as { user_id: string; data: string }[])
|
|
.map((row) => [row.user_id, !!JSON.parse(row.data).everyone]),
|
|
);
|
|
|
|
// A user's @everyone reaches no one.
|
|
u.send({ type: "chat_send", body: "@everyone hi" });
|
|
const plain = (await m.next("chat_message")).message;
|
|
assertEquals(plain.mentionsEveryone, undefined);
|
|
assertEquals(mentioned(), {});
|
|
|
|
// A moderator's reaches all but its author and the reader, and a user
|
|
// named too is told by name.
|
|
m.send({
|
|
type: "chat_send",
|
|
body: `@Everyone, and @${named.username}`,
|
|
});
|
|
const byMod = (x: { message: { userId: string } }) =>
|
|
x.message.userId === mod.id;
|
|
const sent = (await u.next("chat_message", byMod)).message;
|
|
assertEquals(sent.mentionsEveryone, true);
|
|
const expected = { [user.id]: true, [named.id]: false };
|
|
assertEquals(mentioned(), expected);
|
|
|
|
// Fixing a typo reaches no one new, not even a newcomer.
|
|
const late = await createUser();
|
|
m.send({ type: "chat_edit", id: sent.id, body: "@everyone, and all" });
|
|
await u.next("chat_message_updated");
|
|
assertEquals(mentioned(), expected);
|
|
|
|
// A moderator's edit can't make a user's message reach everyone.
|
|
m.send({ type: "chat_edit", id: plain.id, body: "@everyone edited" });
|
|
const edited = (await u.next("chat_message_updated")).message;
|
|
assertEquals(edited.mentionsEveryone, undefined);
|
|
assertEquals(mentioned(), expected);
|
|
|
|
// An edit that adds it notifies, the newcomer included.
|
|
m.send({ type: "chat_send", body: "soon" });
|
|
const draft = (await u.next("chat_message", byMod)).message;
|
|
m.send({ type: "chat_edit", id: draft.id, body: "now, @everyone" });
|
|
await u.next("chat_message_updated");
|
|
assertEquals(
|
|
count(
|
|
`SELECT COUNT(*) AS n FROM notifications WHERE data LIKE ?;`,
|
|
`%${draft.id}%`,
|
|
),
|
|
3,
|
|
);
|
|
assertEquals(mentioned()[late.id], true);
|
|
},
|
|
);
|
|
|
|
wsTest("chat: typing and read markers", async (connect) => {
|
|
const alice = await createUser();
|
|
const bob = await createUser();
|
|
const a = await connect(alice);
|
|
const b1 = await connect(bob);
|
|
const b2 = await connect(bob);
|
|
|
|
a.send({ type: "chat_typing", typing: true });
|
|
const typing = await b1.next(
|
|
"chat_typing_update",
|
|
(m) => m.users.length === 1,
|
|
);
|
|
assertEquals(typing.users[0].userId, alice.id);
|
|
// Sending clears it.
|
|
a.send({ type: "chat_send", body: "done" });
|
|
await b1.next("chat_typing_update", (m) => m.users.length === 0);
|
|
const { message } = await b1.next("chat_message");
|
|
|
|
b1.send({ type: "chat_read", at: message.createdAt });
|
|
// Both of Bob's sockets drop the badge together.
|
|
for (const b of [b1, b2]) {
|
|
const update = await b.next("chat_read_update");
|
|
assertEquals([update.readAt, update.unread], [message.createdAt, 0]);
|
|
}
|
|
await a.sync();
|
|
assertEquals(a.all("chat_read_update"), []);
|
|
});
|
|
|
|
wsTest(
|
|
"garbage frames are ignored and the socket stays up",
|
|
async (connect) => {
|
|
const user = await createUser();
|
|
const c = await connect(user);
|
|
for (
|
|
const frame of [
|
|
"not json",
|
|
"null",
|
|
"42",
|
|
"[]",
|
|
`{"no":"type"}`,
|
|
`{"type":7}`,
|
|
`{"type":"unknown"}`,
|
|
`{"type":"vote_cast"}`,
|
|
`{"type":"chat_read","at":"nonsense"}`,
|
|
]
|
|
) {
|
|
c.send(frame);
|
|
}
|
|
c.ws.send(new Uint8Array([1, 2, 3]));
|
|
await c.sync();
|
|
assertEquals(c.ws.readyState, WebSocket.OPEN);
|
|
},
|
|
);
|
|
|
|
wsTest("REST writes broadcast to the right audience", async (connect) => {
|
|
const owner = await createUser();
|
|
const other = await createUser();
|
|
const o = await connect(owner);
|
|
const x = await connect(other);
|
|
|
|
// A public file dump arrives for everyone; a private one for its owner's
|
|
// other tabs alone.
|
|
const pub = await post<Dump>("/api/dumps", {
|
|
auth: owner.auth,
|
|
form: formWithFile(PNG),
|
|
});
|
|
assertEquals((await x.next("dump_created")).dump.id, pub.data.id);
|
|
const secret = await post<Dump>("/api/dumps", {
|
|
auth: owner.auth,
|
|
form: formWithFile(PNG, "p.png", "image/png", { isPrivate: "true" }),
|
|
});
|
|
await o.next("dump_created", (m) => m.dump.id === secret.data.id);
|
|
await x.sync();
|
|
assertEquals(x.all("dump_created").length, 1);
|
|
|
|
// A private dump's edits and deletion reach its owner alone.
|
|
await put(`/api/dumps/${secret.data.id}`, {
|
|
auth: owner.auth,
|
|
json: { title: "Still secret" },
|
|
});
|
|
await o.next("dump_updated", (m) => m.dump.title === "Still secret");
|
|
await del(`/api/dumps/${secret.data.id}`, { auth: owner.auth });
|
|
await o.next("dump_deleted", (m) => m.dumpId === secret.data.id);
|
|
await x.sync();
|
|
assertEquals(x.all("dump_updated"), []);
|
|
assertEquals(x.all("dump_deleted"), []);
|
|
|
|
// Going private is a deletion for everyone else, and an update for the
|
|
// owner, who still has it; going public is an arrival for everyone else.
|
|
await put(`/api/dumps/${pub.data.id}`, {
|
|
auth: owner.auth,
|
|
json: { isPrivate: true },
|
|
});
|
|
assertEquals((await x.next("dump_deleted")).dumpId, pub.data.id);
|
|
assert((await o.next("dump_updated")).dump.isPrivate);
|
|
await put(`/api/dumps/${pub.data.id}`, {
|
|
auth: owner.auth,
|
|
json: { isPrivate: false },
|
|
});
|
|
await x.next("dump_created", (m) => m.dump.id === pub.data.id);
|
|
await o.sync();
|
|
assertEquals(o.all("dump_deleted").length, 1);
|
|
|
|
// Comments on a private dump reach its owner alone.
|
|
const priv = insertDump({ userId: owner.id, isPrivate: true });
|
|
const shh = await post<Comment>(`/api/dumps/${priv.id}/comments`, {
|
|
auth: owner.auth,
|
|
json: { body: "shh" },
|
|
});
|
|
await o.next("comment_created", (m) => m.comment.body === "shh");
|
|
await patch(`/api/comments/${shh.data.id}`, {
|
|
auth: owner.auth,
|
|
json: { body: "shhh" },
|
|
});
|
|
await o.next("comment_updated", (m) => m.comment.body === "shhh");
|
|
await del(`/api/comments/${shh.data.id}`, { auth: owner.auth });
|
|
await o.next("comment_deleted", (m) => m.commentId === shh.data.id);
|
|
await post(`/api/dumps/${pub.data.id}/comments`, {
|
|
auth: owner.auth,
|
|
json: { body: "hi" },
|
|
});
|
|
const created = await x.next("comment_created");
|
|
assertEquals(created.comment.body, "hi");
|
|
await x.sync();
|
|
assertEquals(x.all("comment_created").length, 1);
|
|
assertEquals(x.all("comment_updated"), []);
|
|
assertEquals(x.all("comment_deleted"), []);
|
|
|
|
// A private playlist's events go to its owner alone.
|
|
const pl = insertPlaylist({ userId: owner.id, isPublic: false });
|
|
await post(`/api/playlists/${pl.id}/dumps/${priv.id}`, { auth: owner.auth });
|
|
await o.next("playlist_dumps_updated");
|
|
await x.sync();
|
|
assertEquals(x.all("playlist_dumps_updated"), []);
|
|
});
|
|
|
|
wsTest(
|
|
"dump_related_changed reaches those who may see both ends of a link",
|
|
async (connect) => {
|
|
const owner = await createUser();
|
|
const other = await createUser();
|
|
const src = insertDump({ userId: owner.id, title: "src" });
|
|
const target = insertDump({ userId: other.id, title: "target" });
|
|
const secret = insertDump({
|
|
userId: other.id,
|
|
title: "secret",
|
|
isPrivate: true,
|
|
});
|
|
const o = await connect(owner);
|
|
const x = await connect(other);
|
|
const anon = await connect();
|
|
|
|
// A comment linking a public dump and a private one: the latter's poster
|
|
// alone hears of it.
|
|
const links =
|
|
`http://gerbeur.test${target.path} http://gerbeur.test${secret.path}`;
|
|
const comment = await post<Comment>(`/api/dumps/${src.id}/comments`, {
|
|
auth: owner.auth,
|
|
json: { body: links },
|
|
});
|
|
for (const c of [o, anon]) {
|
|
assertEquals((await c.next("dump_related_changed")).dumpIds, [
|
|
src.id,
|
|
target.id,
|
|
]);
|
|
}
|
|
const both = (await x.next("dump_related_changed")).dumpIds;
|
|
assertEquals(both[0], src.id);
|
|
assertEquals(new Set(both.slice(1)), new Set([target.id, secret.id]));
|
|
|
|
// An edit that changes no link says nothing; a deletion drops them.
|
|
await patch(`/api/comments/${comment.data.id}`, {
|
|
auth: owner.auth,
|
|
json: { body: `${links} again` },
|
|
});
|
|
await o.next("comment_updated");
|
|
assertEquals(o.all("dump_related_changed").length, 1);
|
|
await del(`/api/comments/${comment.data.id}`, { auth: owner.auth });
|
|
assertEquals((await anon.next("dump_related_changed")).dumpIds, [
|
|
src.id,
|
|
target.id,
|
|
]);
|
|
await o.next("dump_related_changed");
|
|
await x.next("dump_related_changed");
|
|
|
|
// A private dump's links are its poster's alone, until it turns public.
|
|
const hidden = insertDump({ userId: owner.id, isPrivate: true });
|
|
await put(`/api/dumps/${hidden.id}`, {
|
|
auth: owner.auth,
|
|
json: { comment: `http://gerbeur.test${target.path}` },
|
|
});
|
|
assertEquals((await o.next("dump_related_changed")).dumpIds, [
|
|
hidden.id,
|
|
target.id,
|
|
]);
|
|
await x.sync();
|
|
assertEquals(x.all("dump_related_changed").length, 2);
|
|
await put(`/api/dumps/${hidden.id}`, {
|
|
auth: owner.auth,
|
|
json: { isPrivate: false },
|
|
});
|
|
assertEquals((await x.next("dump_related_changed")).dumpIds, [
|
|
hidden.id,
|
|
target.id,
|
|
]);
|
|
},
|
|
);
|
|
|
|
wsTest("a password change closes the user's sockets", async (connect) => {
|
|
const user = await createUser();
|
|
const c = await connect(user);
|
|
await new Promise((r) => setTimeout(r, 1100)); // see auth.test.ts
|
|
const res = await put<AuthTokens>("/api/me/password", {
|
|
auth: user.auth,
|
|
json: { currentPassword: PASSWORD, newPassword: "a-new-password" },
|
|
});
|
|
assertEquals(res.status, 200);
|
|
const closed = await c.closed;
|
|
assertEquals(closed.reason, "Password changed");
|
|
// Reconnecting with the old token is anonymous now.
|
|
const again = await connect(user);
|
|
assertEquals(again.all("welcome")[0].users, []);
|
|
assertExists(res.data.token);
|
|
assertFalse(res.data.token === user.token);
|
|
});
|
|
|
|
wsTest(
|
|
"playlist_dumps_updated lists only what each viewer can see",
|
|
async (connect) => {
|
|
const owner = await createUser();
|
|
const author = await createUser();
|
|
const other = await createUser();
|
|
const pl = insertPlaylist({ userId: owner.id });
|
|
const pub = insertDump({ userId: owner.id });
|
|
const mine = insertDump({ userId: owner.id, isPrivate: true });
|
|
const theirs = insertDump({ userId: author.id, isPrivate: true });
|
|
// Rows straight in: the dumps are private now, as if they went private after
|
|
// being added.
|
|
for (const [i, d] of [pub, mine, theirs].entries()) {
|
|
db.prepare(
|
|
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at)
|
|
VALUES (?, ?, ?, ?);`,
|
|
).run(pl.id, d.id, i, new Date().toISOString());
|
|
}
|
|
const o = await connect(owner);
|
|
const a = await connect(author);
|
|
const x = await connect(other);
|
|
const anon = await connect();
|
|
|
|
const added = insertDump({ userId: owner.id });
|
|
await post(`/api/playlists/${pl.id}/dumps/${added.id}`, {
|
|
auth: owner.auth,
|
|
});
|
|
// The owner sees their own private dump but not the author's; the author,
|
|
// not being the owner, sees neither private one; nor does anyone else.
|
|
assertEquals((await o.next("playlist_dumps_updated")).dumpIds, [
|
|
added.id,
|
|
pub.id,
|
|
mine.id,
|
|
]);
|
|
for (const c of [a, x, anon]) {
|
|
const msg = await c.next("playlist_dumps_updated");
|
|
assertEquals(msg.dumpIds, [added.id, pub.id]);
|
|
}
|
|
|
|
// A change to its details carries the count of what each may see, too.
|
|
await put(`/api/playlists/${pl.id}`, {
|
|
auth: owner.auth,
|
|
json: { title: "Renamed" },
|
|
});
|
|
assertEquals((await o.next("playlist_updated")).playlist.dumpCount, 3);
|
|
for (const c of [a, x, anon]) {
|
|
assertEquals((await c.next("playlist_updated")).playlist.dumpCount, 2);
|
|
}
|
|
await put(`/api/playlists/${pl.id}`, {
|
|
auth: owner.auth,
|
|
json: { isPublic: false },
|
|
});
|
|
await put(`/api/playlists/${pl.id}`, {
|
|
auth: owner.auth,
|
|
json: { isPublic: true },
|
|
});
|
|
assertEquals(
|
|
(await o.next("playlist_updated", (m) => m.playlist.isPublic)).playlist
|
|
.dumpCount,
|
|
3,
|
|
);
|
|
for (const c of [a, x, anon]) {
|
|
assertEquals((await c.next("playlist_created")).playlist.dumpCount, 2);
|
|
}
|
|
},
|
|
);
|
|
|
|
wsTest(
|
|
"a new playlist cover is on disk by the time it's announced",
|
|
async (connect) => {
|
|
const owner = await createUser();
|
|
const pl = insertPlaylist({ userId: owner.id });
|
|
const c = await connect();
|
|
const cover = new Uint8Array([...PNG, 9, 9, 9]);
|
|
const upload = put(`/api/playlists/${pl.id}/cover`, {
|
|
auth: owner.auth,
|
|
form: formWithFile(cover),
|
|
});
|
|
// Fetch the image the moment the socket says it changed, as a client would.
|
|
const msg = await c.next("playlist_updated");
|
|
const served = await get(
|
|
`/api/playlists/${pl.id}/cover?v=${msg.playlist.updatedAt}`,
|
|
);
|
|
assertEquals(served.status, 200);
|
|
assertEquals(served.bytes, cover);
|
|
assertEquals((await upload).status, 200);
|
|
},
|
|
);
|
|
|
|
wsTest(
|
|
"replacing a dump's file is broadcast like any edit",
|
|
async (connect) => {
|
|
const owner = await createUser();
|
|
const other = await createUser();
|
|
const { id } = insertDump({ userId: owner.id, fileName: "a.txt" });
|
|
const x = await connect(other);
|
|
const replace = (fields: Record<string, string>) =>
|
|
put(`/api/dumps/${id}/file`, {
|
|
auth: owner.auth,
|
|
form: formWithFile(PNG, "b.png", "image/png", fields),
|
|
});
|
|
await replace({ title: "New title" });
|
|
assertEquals((await x.next("dump_updated")).dump.title, "New title");
|
|
await replace({ isPrivate: "true" });
|
|
assertEquals((await x.next("dump_deleted")).dumpId, id);
|
|
await replace({ isPrivate: "false" });
|
|
assertEquals((await x.next("dump_created")).dump.id, id);
|
|
},
|
|
);
|
|
|
|
wsTest(
|
|
"a playlist gone private leaves the others' views without its new details",
|
|
async (connect) => {
|
|
const owner = await createUser();
|
|
const other = await createUser();
|
|
const pl = insertPlaylist({ userId: owner.id, title: "Open" });
|
|
const o = await connect(owner);
|
|
const x = await connect(other);
|
|
const anon = await connect();
|
|
|
|
await put<Playlist>(`/api/playlists/${pl.id}`, {
|
|
auth: owner.auth,
|
|
json: { title: "Secret plans", isPublic: false },
|
|
});
|
|
const mine = await o.next("playlist_updated");
|
|
assertEquals(mine.playlist.title, "Secret plans");
|
|
for (const c of [x, anon]) {
|
|
assertEquals((await c.next("playlist_deleted")).playlistId, pl.id);
|
|
await c.sync();
|
|
assertEquals(c.all("playlist_updated"), []);
|
|
}
|
|
|
|
// Going public again is an arrival for the others.
|
|
await put(`/api/playlists/${pl.id}`, {
|
|
auth: owner.auth,
|
|
json: { isPublic: true },
|
|
});
|
|
await o.next("playlist_updated", (m) => m.playlist.isPublic);
|
|
for (const c of [x, anon]) {
|
|
const back = await c.next("playlist_created");
|
|
assertEquals(back.playlist.title, "Secret plans");
|
|
}
|
|
},
|
|
);
|
|
|
|
wsTest(
|
|
"a dump deleted or turned private is taken out of its playlists live",
|
|
async (connect) => {
|
|
const owner = await createUser();
|
|
const other = await createUser();
|
|
const pl = insertPlaylist({ userId: owner.id });
|
|
const keep = insertDump({ userId: owner.id });
|
|
const flip = insertDump({ userId: owner.id });
|
|
const gone = insertDump({ userId: owner.id });
|
|
for (const [i, d] of [keep, flip, gone].entries()) {
|
|
db.prepare(
|
|
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at)
|
|
VALUES (?, ?, ?, ?);`,
|
|
).run(pl.id, d.id, i, new Date().toISOString());
|
|
}
|
|
const o = await connect(owner);
|
|
const x = await connect(other);
|
|
|
|
await put(`/api/dumps/${flip.id}`, {
|
|
auth: owner.auth,
|
|
json: { isPrivate: true },
|
|
});
|
|
const hidden = await x.next("playlist_dumps_updated");
|
|
assertEquals(hidden.dumpIds, [keep.id, gone.id]);
|
|
assertEquals(
|
|
(await o.next("playlist_dumps_updated")).dumpIds,
|
|
[keep.id, flip.id, gone.id],
|
|
);
|
|
|
|
await put(`/api/dumps/${flip.id}`, {
|
|
auth: owner.auth,
|
|
json: { isPrivate: false },
|
|
});
|
|
assertEquals(
|
|
(await x.next("playlist_dumps_updated")).dumpIds,
|
|
[keep.id, flip.id, gone.id],
|
|
);
|
|
|
|
await del(`/api/dumps/${gone.id}`, { auth: owner.auth });
|
|
const after = await x.next(
|
|
"playlist_dumps_updated",
|
|
(m) => m.dumpIds.length === 2,
|
|
);
|
|
assertEquals(after.dumpIds, [keep.id, flip.id]);
|
|
|
|
// An edit that keeps the visibility leaves the playlists alone.
|
|
await put(`/api/dumps/${keep.id}`, {
|
|
auth: owner.auth,
|
|
json: { title: "Renamed" },
|
|
});
|
|
await x.next("dump_updated");
|
|
await x.sync();
|
|
assertEquals(x.all("playlist_dumps_updated").length, 3);
|
|
},
|
|
);
|
|
|
|
wsTest(
|
|
"reading the notifications clears the bell on every socket",
|
|
async (connect) => {
|
|
const user = await createUser();
|
|
const other = await createUser();
|
|
const a = await connect(user);
|
|
const b = await connect(user);
|
|
const x = await connect(other);
|
|
await post("/api/me/notifications/read", { auth: user.auth });
|
|
await a.next("notifications_read");
|
|
await b.next("notifications_read");
|
|
await x.sync();
|
|
assertEquals(x.all("notifications_read"), []);
|
|
},
|
|
);
|
|
|
|
wsTest("category changes are announced to everyone", async (connect) => {
|
|
const admin = await createUser({ role: "admin" });
|
|
const anon = await connect();
|
|
const created = await post<{ id: string }>("/api/categories", {
|
|
auth: admin.auth,
|
|
json: { kind: "dump", slug: "jazz", name: "Jazz" },
|
|
});
|
|
await anon.next("categories_changed");
|
|
await put(`/api/categories/${created.data.id}`, {
|
|
auth: admin.auth,
|
|
json: { name: "Jazz & co" },
|
|
});
|
|
await anon.next("categories_changed");
|
|
await del(`/api/categories/${created.data.id}`, { auth: admin.auth });
|
|
await anon.next("categories_changed");
|
|
});
|