diff --git a/src/db/sqlite/connection.ts b/src/db/sqlite/connection.ts index 7b8ce131..40699eb6 100644 --- a/src/db/sqlite/connection.ts +++ b/src/db/sqlite/connection.ts @@ -253,6 +253,7 @@ function createSchema(d: DatabaseSync): void { server_user_id TEXT NOT NULL, install_id TEXT NOT NULL, capability TEXT NOT NULL, + muted TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (server_user_id, install_id) @@ -710,6 +711,11 @@ function hasColumn(d: DatabaseSync, table: string, column: string): boolean { } function runMigrations(d: DatabaseSync): void { + // Conversations muted on that phone (GRYT-1689). Only dev servers ever had the table without it. + if (!hasColumn(d, "push_devices", "muted")) { + d.exec("ALTER TABLE push_devices ADD COLUMN muted TEXT NOT NULL DEFAULT '[]'"); + } + const cols = d.prepare("PRAGMA table_info(users)").all() as { name: string }[]; const colNames = new Set(cols.map((c) => c.name)); diff --git a/src/db/sqlite/pushDevices.test.ts b/src/db/sqlite/pushDevices.test.ts index 7702cb85..1b5c8556 100644 --- a/src/db/sqlite/pushDevices.test.ts +++ b/src/db/sqlite/pushDevices.test.ts @@ -20,7 +20,7 @@ describe("push devices", () => { it("keeps one row per install and replaces its capability", () => { savePushDevice("u1", "install-1", cap("a")); savePushDevice("u1", "install-1", cap("b")); - assert.deepEqual(listPushDevices("u1"), [{ installId: "install-1", capability: cap("b") }]); + assert.deepEqual(listPushDevices("u1"), [{ installId: "install-1", capability: cap("b"), muted: new Set() }]); removePushDevice("u1", "install-1"); assert.deepEqual(listPushDevices("u1"), []); }); diff --git a/src/db/sqlite/pushDevices.ts b/src/db/sqlite/pushDevices.ts index 33325fb4..0e831ec5 100644 --- a/src/db/sqlite/pushDevices.ts +++ b/src/db/sqlite/pushDevices.ts @@ -4,29 +4,55 @@ import { getSqliteDb, toIso } from "./connection"; export interface PushDevice { installId: string; capability: string; + /** Conversations muted on that phone, which never reach the relay (GRYT-1689). */ + muted: ReadonlySet; } /** More than this and the oldest goes. Nobody has ten phones; a reinstall loop might. */ export const MAX_PUSH_DEVICES_PER_USER = 10; -export function savePushDevice(serverUserId: string, installId: string, capability: string): void { +/** The phone checks in on every connect. One silent this long is probably in a drawer. */ +export const PUSH_DEVICE_STALE_DAYS = 30; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function parseMuted(raw: string): ReadonlySet { + try { + const value = JSON.parse(raw) as unknown; + return new Set(Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : []); + } catch { + return new Set(); + } +} + +export function savePushDevice( + serverUserId: string, + installId: string, + capability: string, + muted: readonly string[] = [], + now = new Date(), +): void { const db = getSqliteDb(); - const now = toIso(new Date()); + const at = toIso(now); db.prepare( - `INSERT INTO push_devices (server_user_id, install_id, capability, created_at, updated_at) VALUES (?, ?, ?, ?, ?) - ON CONFLICT(server_user_id, install_id) DO UPDATE SET capability = excluded.capability, updated_at = excluded.updated_at`, - ).run(serverUserId, installId, capability, now, now); + `INSERT INTO push_devices (server_user_id, install_id, capability, muted, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(server_user_id, install_id) DO UPDATE SET capability = excluded.capability, muted = excluded.muted, updated_at = excluded.updated_at`, + ).run(serverUserId, installId, capability, JSON.stringify(muted), at, at); db.prepare( `DELETE FROM push_devices WHERE server_user_id = ? AND install_id NOT IN ( SELECT install_id FROM push_devices WHERE server_user_id = ? ORDER BY updated_at DESC, install_id LIMIT ?)`, ).run(serverUserId, serverUserId, MAX_PUSH_DEVICES_PER_USER); } -export function listPushDevices(serverUserId: string): PushDevice[] { - const rows = getSqliteDb() - .prepare(`SELECT install_id, capability FROM push_devices WHERE server_user_id = ?`) - .all(serverUserId) as { install_id: string; capability: string }[]; - return rows.map((r) => ({ installId: r.install_id, capability: r.capability })); +/** Only phones that checked in lately. The rest are dropped here, one account at a time. */ +export function listPushDevices(serverUserId: string, now = new Date()): PushDevice[] { + const db = getSqliteDb(); + const cutoff = toIso(new Date(now.getTime() - PUSH_DEVICE_STALE_DAYS * DAY_MS)); + db.prepare(`DELETE FROM push_devices WHERE server_user_id = ? AND updated_at < ?`).run(serverUserId, cutoff); + const rows = db + .prepare(`SELECT install_id, capability, muted FROM push_devices WHERE server_user_id = ?`) + .all(serverUserId) as { install_id: string; capability: string; muted: string }[]; + return rows.map((r) => ({ installId: r.install_id, capability: r.capability, muted: parseMuted(r.muted) })); } export function removePushDevice(serverUserId: string, installId: string): void { diff --git a/src/db/sqlite/users.ts b/src/db/sqlite/users.ts index 39c0bbd2..e34584ed 100644 --- a/src/db/sqlite/users.ts +++ b/src/db/sqlite/users.ts @@ -288,6 +288,8 @@ export async function getAllAvatarFileIds(): Promise> { export async function setUserInactive(serverUserId: string): Promise { const db = getSqliteDb(); db.prepare(`UPDATE users SET is_active = 0 WHERE server_user_id = ?`).run(serverUserId); + // Leaving, a kick and a ban all land here. A phone that comes back registers again. + db.prepare(`DELETE FROM push_devices WHERE server_user_id = ?`).run(serverUserId); } /** `carried` hands the guest's row to the account; `merged` folds it into the row diff --git a/src/services/push.test.ts b/src/services/push.test.ts index d06b8042..9c36dab2 100644 --- a/src/services/push.test.ts +++ b/src/services/push.test.ts @@ -57,14 +57,14 @@ describe("who is at a screen", () => { describe("pushing", () => { it("sends the capability in the header and only the kind in the body", async () => { - const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A }] }); + const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set() }] }); h.pusher.notify({}, ["u1"], "dm", "conv"); await flush(); assert.deepEqual(h.calls, [{ url: "https://push.test/v1/push", auth: `Bearer ${CAP_A}`, body: JSON.stringify({ kind: "dm" }) }]); }); it("skips somebody who is at a screen, and wakes them once they put the phone away", async () => { - const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A }] }); + const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set() }] }); h.pusher.notify({ s: client("u1") }, ["u1"], "mention", "conv"); await flush(); assert.equal(h.calls.length, 0); @@ -74,7 +74,7 @@ describe("pushing", () => { }); it("buzzes once per phone per conversation in fifteen seconds", async () => { - const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A }, { installId: "tablet-12", capability: CAP_B }] }); + const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set() }, { installId: "tablet-12", capability: CAP_B, muted: new Set() }] }); h.pusher.notify({}, ["u1"], "dm", "conv"); h.pusher.notify({}, ["u1", "u1"], "dm", "conv"); h.pusher.notify({}, ["u1"], "dm", "other"); @@ -86,7 +86,7 @@ describe("pushing", () => { it("forgets a capability the relay calls gone or unknown", async () => { for (const status of [404, 410]) { - const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A }] }, status); + const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set() }] }, status); h.pusher.notify({}, ["u1"], "dm", "conv"); await flush(); await flush(); @@ -95,7 +95,7 @@ describe("pushing", () => { }); it("keeps it when the relay is only busy", async () => { - const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A }] }, 502); + const h = harness({ u1: [{ installId: "phone-1234", capability: CAP_A, muted: new Set() }] }, 502); h.pusher.notify({}, ["u1"], "dm", "conv"); await flush(); await flush(); diff --git a/src/services/push.ts b/src/services/push.ts index aad24e11..81b688b2 100644 --- a/src/services/push.ts +++ b/src/services/push.ts @@ -84,6 +84,7 @@ export function createPusher(deps: PusherDeps) { continue; } for (const device of devices) { + if (device.muted.has(conversationId)) continue; const key = `${device.capability}:${conversationId}`; if (now - (lastSent.get(key) ?? 0) < QUIET_MS) continue; lastSent.set(key, now); @@ -118,6 +119,12 @@ export function pushNotify(clientsInfo: Clients, serverUserIds: Iterable shared.notify(clientsInfo, serverUserIds, kind, conversationId); } +/** Tests only: forget the throttle and read the relay setting again. */ +export function resetPushState(): void { + shared = null; + relay = undefined; +} + export function pushEnabled(): boolean { return configuredRelay() !== null; } diff --git a/src/socket/handlers/push.ts b/src/socket/handlers/push.ts index 81e731a7..91a77a12 100644 --- a/src/socket/handlers/push.ts +++ b/src/socket/handlers/push.ts @@ -15,6 +15,15 @@ type Reply = { ok: true } | { ok: false; error: string }; type Ack = (reply: Reply) => void; const INSTALL_ID = /^[A-Za-z0-9_-]{8,64}$/; +const MAX_MUTED = 1000; + +/** Conversation ids the phone muted. Null when it is not a short list of short strings. */ +function mutedFrom(raw: unknown): string[] | null { + if (raw === undefined) return []; + if (!Array.isArray(raw) || raw.length > MAX_MUTED) return null; + if (!raw.every((id) => typeof id === "string" && id.length > 0 && id.length <= 128)) return null; + return [...new Set(raw as string[])]; +} const RL_REGISTER: RateLimitRule = { limit: 20, windowMs: 60_000, scorePerAction: 1, maxScore: 12, scoreDecayMs: 3_000 }; export function registerPushHandlers(ctx: HandlerContext): EventHandlerMap { @@ -25,7 +34,10 @@ export function registerPushHandlers(ctx: HandlerContext): EventHandlerMap { } return { - "push:register": async (payload: { accessToken: string; installId?: unknown; capability?: unknown }, ack: Ack) => { + "push:register": async ( + payload: { accessToken: string; installId?: unknown; capability?: unknown; muted?: unknown }, + ack: Ack, + ) => { ack = typeof ack === "function" ? ack : () => {}; try { if (!pushEnabled()) return ack({ ok: false, error: "push_off" }); @@ -34,9 +46,11 @@ export function registerPushHandlers(ctx: HandlerContext): EventHandlerMap { || typeof payload.capability !== "string" || !CAPABILITY_SHAPE.test(payload.capability)) { return ack({ ok: false, error: "invalid_payload" }); } + const muted = mutedFrom(payload.muted); + if (!muted) return ack({ ok: false, error: "invalid_payload" }); const auth = await requireAuth(socket, payload); if (!auth) return ack({ ok: false, error: "unauthorized" }); - savePushDevice(auth.tokenPayload.serverUserId, payload.installId, payload.capability); + savePushDevice(auth.tokenPayload.serverUserId, payload.installId, payload.capability, muted); ack({ ok: true }); } catch (err) { consola.error("push:register failed", err); diff --git a/src/socket/handlers/pushAudience.test.ts b/src/socket/handlers/pushAudience.test.ts new file mode 100644 index 00000000..87722755 --- /dev/null +++ b/src/socket/handlers/pushAudience.test.ts @@ -0,0 +1,366 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, beforeEach, describe, it } from "node:test"; + +import type { Permission } from "../../constants/permissions"; +import { blockUser } from "../../db/sqlite/blocks"; +import { upsertServerChannel, upsertServerSidebarItem } from "../../db/sqlite/channels"; +import { createPermissionScope, replacePermissionRules, setChannelPermissionScope } from "../../db/sqlite/channelScopes"; +import { getSqliteDb, initSqlite } from "../../db/sqlite/connection"; +import { listPushDevices } from "../../db/sqlite/pushDevices"; +import { createRoleDefinition } from "../../db/sqlite/roleDefinitions"; +import { createServerConfigIfNotExists, setServerRole } from "../../db/sqlite/servers"; +import { setUserInactive, upsertUser } from "../../db/sqlite/users"; +import { resetChannelPermissionCache } from "../../services/channelPermissions"; +import { resetPushState } from "../../services/push"; +import type { Clients } from "../../types"; +import { generateAccessToken } from "../../utils/jwt"; +import { resetRateLimits } from "../../utils/rateLimiter"; +import { resetChannelIdCache } from "../utils/conversationAccess"; +import { refreshClientPermissions } from "../utils/standing"; +import { registerChatHandlers } from "./chat"; +import { registerDirectMessageHandlers } from "./dm"; +import { registerPushHandlers } from "./push"; +import type { EventHandlerMap, HandlerContext } from "./types"; + +/** + * Who a message wakes, end to end through the real handlers, with a local server + * standing in for the relay (GRYT-1656, GRYT-1689). + */ + +const HOST = "push.test:5001"; +const OPEN = "general"; +const STAFF = "staff"; +const KEPT_OUT = "push-outsider"; + +/* The relay the server talks to. Each test reads what reached it. */ +let relay: Server; +const hits: { capability: string; body: string }[] = []; +const answers = new Map(); + +let dir: string; + +const clientsInfo: Clients = {}; +const sockets = new Map boolean }>(); +const io = { to: () => ({ emit() {} }), emit() {}, sockets: { sockets } }; + +interface Member { + name: string; + clientId: string; + serverUserId: string; + grytUserId: string; + accessToken: string; + capability: string; + handlers: EventHandlerMap; + emitted: { event: string; payload: unknown }[]; +} + +const members = new Map(); +let seq = 0; + +/* manage_messages keeps the spam filter out of it, which a run of mentions trips. */ +const PERMISSIONS: Permission[] = [ + "read_messages", "send_messages", "send_direct_messages", "view_members", "mention_everyone", "manage_messages", +]; + +function capabilityFor(n: number): string { + return `p_${String(n).padStart(2, "0")}${"x".repeat(41)}`; +} + +async function member(name: string, roleId: string): Promise { + seq += 1; + const clientId = `push-socket-${seq}`; + const grytUserId = `push-account-${name}`; + const user = await upsertUser(grytUserId, name); + await setServerRole(user.server_user_id, roleId); + const emitted: Member["emitted"] = []; + const emit = (event: string, payload?: unknown) => (emitted.push({ event, payload }), true); + const socket = { + id: clientId, + handshake: { headers: { host: HOST }, address: "127.0.0.1" }, + emit, + join() {}, + leave() {}, + to: () => ({ emit() {} }), + }; + sockets.set(clientId, { emit }); + clientsInfo[clientId] = { serverUserId: user.server_user_id, grytUserId, nickname: name, isAFK: false } as Clients[string]; + await refreshClientPermissions(clientsInfo, clientId); + const ctx = { + io, socket, clientId, serverId: "push-test", clientsInfo, sfuClient: null, + getClientIp: () => `10.1.0.${seq}`, clientAddressIsOwn: () => true, + } as unknown as HandlerContext; + const m: Member = { + name, clientId, grytUserId, + serverUserId: user.server_user_id, + accessToken: generateAccessToken({ grytUserId, serverUserId: user.server_user_id, nickname: name, serverHost: HOST, tokenVersion: 0 }), + capability: capabilityFor(seq), + handlers: { ...registerChatHandlers(ctx), ...registerDirectMessageHandlers(ctx), ...registerPushHandlers(ctx) }, + emitted, + }; + members.set(m.capability, m); + return m; +} + +/** Through the handler, as the phone does it. */ +async function register(m: Member, muted?: string[]): Promise { + const reply = await new Promise((resolve) => + m.handlers["push:register"]({ accessToken: m.accessToken, installId: `install-${m.name}`, capability: m.capability, muted }, resolve), + ); + assert.deepEqual(reply, { ok: true }, `${m.name} could not register`); +} + +function away(m: Member, how: "phone" | "afk" | "gone" = "phone"): void { + if (how === "gone") delete clientsInfo[m.clientId]; + else clientsInfo[m.clientId] = { ...clientsInfo[m.clientId], appInBackground: how === "phone", isAFK: how === "afk" } as Clients[string]; +} + +function back(m: Member): void { + clientsInfo[m.clientId] = { + ...(clientsInfo[m.clientId] ?? { serverUserId: m.serverUserId, grytUserId: m.grytUserId, nickname: m.name }), + appInBackground: false, + isAFK: false, + } as Clients[string]; +} + +/** Pushes are fire and forget, so wait until the relay has been quiet for a moment. */ +async function woken(): Promise { + let seen = -1; + while (seen !== hits.length) { + seen = hits.length; + await new Promise((r) => setTimeout(r, 60)); + } + return hits.map((h) => `${members.get(h.capability)?.name ?? "?"}:${JSON.parse(h.body).kind}`).sort(); +} + +async function send(from: Member, conversationId: string, text: string): Promise { + await from.handlers["chat:send"]({ accessToken: from.accessToken, conversationId, text }); + const refused = from.emitted.find((e) => e.event === "chat:error"); + assert.equal(refused, undefined, `${from.name}'s message was refused: ${JSON.stringify(refused?.payload)}`); +} + +async function openDm(from: Member, to: Member): Promise { + from.emitted.length = 0; + await from.handlers["dm:open"]({ accessToken: from.accessToken, targetServerUserId: to.serverUserId }); + const opened = from.emitted.find((e) => e.event === "dm:opened")?.payload as { conversation_id?: string } | undefined; + assert.ok(opened?.conversation_id, `no DM between ${from.name} and ${to.name}`); + return opened.conversation_id; +} + +let alice: Member; +let bob: Member; +let carol: Member; +let dave: Member; +let erin: Member; + +before(async () => { + relay = createServer((req, res) => { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + const capability = String(req.headers.authorization ?? "").replace("Bearer ", ""); + hits.push({ capability, body }); + res.writeHead(answers.get(capability) ?? 202); + res.end(); + }); + }); + await new Promise((r) => relay.listen(0, "127.0.0.1", r)); + process.env.GRYT_PUSH_RELAY_URL = `http://localhost:${(relay.address() as AddressInfo).port}`; + + dir = mkdtempSync(join(tmpdir(), "gryt-push-")); + process.env.DATA_DIR = dir; + await initSqlite(); + await createServerConfigIfNotExists(); + await upsertServerChannel({ channelId: OPEN, name: "General", type: "text", position: 10 }); + await upsertServerChannel({ channelId: STAFF, name: "Staff", type: "text", position: 20 }); + await upsertServerSidebarItem({ itemId: "sb-general", kind: "channel", position: 10, channelId: OPEN }); + await upsertServerSidebarItem({ itemId: "sb-staff", kind: "channel", position: 20, channelId: STAFF }); + + await createRoleDefinition("push-member", { name: "push-member", rank: 50, permissions: PERMISSIONS }); + await createRoleDefinition(KEPT_OUT, { name: "push-outsider", rank: 10, permissions: PERMISSIONS }); + const staffOnly = await createPermissionScope({ name: "Staff only", isTemplate: true }); + await replacePermissionRules(staffOnly, [{ roleId: KEPT_OUT, permission: "read_messages", effect: "deny" }]); + await setChannelPermissionScope(STAFF, staffOnly); + resetChannelPermissionCache(); + resetChannelIdCache(); + + alice = await member("Alice", "push-member"); + bob = await member("Bob", KEPT_OUT); + carol = await member("Carol", "push-member"); + dave = await member("Dave", "push-member"); + erin = await member("Erin", "push-member"); +}); + +after(() => { + relay.close(); + delete process.env.DATA_DIR; + rmSync(dir, { recursive: true, force: true }); +}); + +let text = 0; +/** Fresh words each time, so the spam filter never sees the same line twice. */ +const line = (s: string) => `${s} (${++text})`; + +beforeEach(async () => { + resetPushState(); + resetRateLimits(); + for (const m of [alice, bob, carol, dave, erin]) { + back(m); + m.emitted.length = 0; + await register(m); + } + answers.clear(); + hits.length = 0; +}); + +describe("mentions", () => { + it("wake somebody whose phone is in the background, with only the kind", async () => { + away(carol); + await send(alice, OPEN, line("@Carol look at this")); + assert.deepEqual(await woken(), ["Carol:mention"]); + assert.deepEqual(JSON.parse(hits[0].body), { kind: "mention" }, "the relay got more than the kind"); + }); + + it("don't wake somebody at a screen", async () => { + await send(alice, OPEN, line("@Carol are you here")); + assert.deepEqual(await woken(), []); + }); + + it("wake somebody whose desktop has gone idle", async () => { + away(carol, "afk"); + await send(alice, OPEN, line("@Carol idle?")); + assert.deepEqual(await woken(), ["Carol:mention"]); + }); + + it("wake somebody with no socket at all", async () => { + away(carol, "gone"); + await send(alice, OPEN, line("@Carol offline")); + assert.deepEqual(await woken(), ["Carol:mention"]); + }); + + it("don't wake somebody who can't read the channel", async () => { + away(bob); + away(carol); + await send(alice, STAFF, line("@Bob @Carol staff only")); + assert.deepEqual(await woken(), ["Carol:mention"], "Bob was woken for a channel he can't see"); + }); + + it("don't wake the sender for mentioning themselves", async () => { + away(alice); + await send(alice, OPEN, line("@Alice note to self")); + assert.deepEqual(await woken(), []); + }); + + it("don't wake anybody for @everyone or @here", async () => { + for (const m of [bob, carol, dave, erin]) away(m); + await send(alice, OPEN, line("@everyone and @here, hello")); + assert.deepEqual(await woken(), []); + }); + + it("don't wake somebody who blocked the sender", async () => { + await blockUser(erin.grytUserId, alice.grytUserId); + away(erin); + await send(alice, OPEN, line("@Erin hey")); + assert.deepEqual(await woken(), []); + }); + + it("don't wake a phone that muted the channel", async () => { + await register(carol, [OPEN]); + away(carol); + await send(alice, OPEN, line("@Carol muted here")); + assert.deepEqual(await woken(), []); + }); + + it("still wake a phone that muted some other channel", async () => { + await register(carol, [STAFF]); + away(carol); + await send(alice, OPEN, line("@Carol but not here")); + assert.deepEqual(await woken(), ["Carol:mention"]); + }); +}); + +describe("direct messages", () => { + it("wake the other person and never the sender", async () => { + const dm = await openDm(alice, dave); + away(alice); + away(dave); + await send(alice, dm, line("hi Dave")); + assert.deepEqual(await woken(), ["Dave:dm"]); + assert.deepEqual(JSON.parse(hits[0].body), { kind: "dm" }); + }); + + it("buzz once for a burst in the same conversation", async () => { + const dm = await openDm(carol, dave); + away(dave); + await send(carol, dm, line("one")); + await send(carol, dm, line("two")); + await send(carol, dm, line("three")); + assert.deepEqual(await woken(), ["Dave:dm"]); + }); + + it("don't wake a phone that muted the conversation", async () => { + const dm = await openDm(erin, dave); + await register(dave, [dm]); + away(dave); + await send(erin, dm, line("muted DM")); + assert.deepEqual(await woken(), []); + }); +}); + +describe("taking it back", () => { + it("stops after push:unregister", async () => { + const reply = await new Promise((resolve) => + carol.handlers["push:unregister"]({ accessToken: carol.accessToken, installId: "install-Carol" }, resolve), + ); + assert.deepEqual(reply, { ok: true }); + away(carol); + await send(alice, OPEN, line("@Carol after unregistering")); + assert.deepEqual(await woken(), []); + }); + + it("forgets a phone the relay calls gone", async () => { + answers.set(carol.capability, 410); + away(carol); + await send(alice, OPEN, line("@Carol gone phone")); + assert.deepEqual(await woken(), ["Carol:mention"]); + assert.deepEqual(listPushDevices(carol.serverUserId), [], "the server kept a capability the relay dropped"); + }); + + it("stops for a phone that hasn't checked in for a month", async () => { + getSqliteDb() + .prepare(`UPDATE push_devices SET updated_at = ? WHERE server_user_id = ?`) + .run(new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString(), carol.serverUserId); + away(carol); + await send(alice, OPEN, line("@Carol in a drawer")); + assert.deepEqual(await woken(), []); + assert.equal(getSqliteDb().prepare(`SELECT COUNT(*) AS n FROM push_devices WHERE server_user_id = ?`).get(carol.serverUserId)?.n, 0); + }); + + it("drops every phone of somebody who left, was kicked or was banned", async () => { + await setUserInactive(carol.serverUserId); + assert.deepEqual(listPushDevices(carol.serverUserId), []); + getSqliteDb().prepare(`UPDATE users SET is_active = 1 WHERE server_user_id = ?`).run(carol.serverUserId); + }); +}); + +describe("push:register", () => { + it("refuses a muted list that isn't a short list of ids", async () => { + for (const muted of ["general", [1, 2], Array.from({ length: 1001 }, (_, i) => `c${i}`), ["x".repeat(129)]]) { + const reply = await new Promise((resolve) => + carol.handlers["push:register"]({ accessToken: carol.accessToken, installId: "install-Carol", capability: carol.capability, muted }, resolve), + ); + assert.deepEqual(reply, { ok: false, error: "invalid_payload" }, JSON.stringify(muted).slice(0, 40)); + } + }); + + it("refuses a capability that isn't one", async () => { + const reply = await new Promise((resolve) => + carol.handlers["push:register"]({ accessToken: carol.accessToken, installId: "install-Carol", capability: "https://evil.example" }, resolve), + ); + assert.deepEqual(reply, { ok: false, error: "invalid_payload" }); + }); +});