From 7c8de29effc3a24c6c5c6ae89770bcd7f21772ef Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 10 May 2026 19:14:48 +0200 Subject: [PATCH] test(federation): lock kick authority behavior in member_remove receiver --- .../server/src/routes/federation.kick.test.ts | 331 ++++++++++++++++++ packages/server/src/routes/federation.ts | 2 +- 2 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/routes/federation.kick.test.ts diff --git a/packages/server/src/routes/federation.kick.test.ts b/packages/server/src/routes/federation.kick.test.ts new file mode 100644 index 00000000..a13d0c8e --- /dev/null +++ b/packages/server/src/routes/federation.kick.test.ts @@ -0,0 +1,331 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { eq, and } from 'drizzle-orm'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; +import type { FederationRelayEvent, FederationRelayParticipant } from '@backspace/shared'; + +setWorkerId(4); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('../config.js', () => ({ + config: { + domain: 'local.test', + port: 3000, + host: '0.0.0.0', + jwtSecret: 'test-secret-12345678901234567890123456789012', + maxUploadSize: 100 * 1024 * 1024, + registrationOpen: true, + uploadDir: '/tmp/bs-fed-kick-test', + }, +})); + +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToUser: vi.fn(), + sendToSpace: vi.fn(), + sendToDmMembers: vi.fn(), + sendToAdmins: vi.fn(), + getAllOnlineUserIds: () => [], + evictFederatedCallsForHost: vi.fn(), + federatedCalls: new Map(), + isUserOnline: vi.fn(), + lateBindFederatedCall: vi.fn(), + }, +})); + +import { connectionManager } from '../ws/handler.js'; + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of files) { + const sql = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + for (const stmt of sql.split(/-->\s*statement-breakpoint/)) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +const OWNER_ORIGIN = 'https://owner.test'; +const ATTACKER_ORIGIN = 'https://attacker.test'; +const RANDOM_ORIGIN = 'https://random.test'; + +const FEDERATED_ID = 'fed-channel-kick-1'; +const CHANNEL_ID = 'ch-kick-local-1'; + +const OWNER_USER_ID = 'owner-user-stub'; +const OWNER_HOME_USER_ID = 'home-owner-1'; + +const VICTIM_USER_ID = 'victim-user-stub'; +const VICTIM_HOME_USER_ID = 'home-victim-1'; +const VICTIM_HOME_INSTANCE = 'https://victim.test'; + +const LEAVER_USER_ID = 'leaver-user-stub'; +const LEAVER_HOME_USER_ID = 'home-leaver-1'; +const LEAVER_HOME_INSTANCE = RANDOM_ORIGIN; + +/** + * Seed a federated group DM channel where: + * - Owner is a replicated user from `OWNER_ORIGIN` + * - A victim member is a replicated user from `VICTIM_HOME_INSTANCE` (used for kick tests) + * - A leaver member is a replicated user from `LEAVER_HOME_INSTANCE` (used for the leave test) + * + * `ownerHomeInstance` is stored as the full origin string (`https://owner.test`) + * to match the kick authority check, which compares `sourceInstance` to + * `ownerHomeInstance` via strict string equality (federation.ts:4350). + */ +function seedChannelAndMembers(): void { + const now = Date.now(); + testDb.insert(schema.users).values({ + id: OWNER_USER_ID, + username: `owner@owner.test`, + displayName: 'owner', + passwordHash: '!federation-replicated', + status: 'offline', + isAdmin: 0, + homeInstance: OWNER_ORIGIN, + homeUserId: OWNER_HOME_USER_ID, + createdAt: now, + }).run(); + + testDb.insert(schema.users).values({ + id: VICTIM_USER_ID, + username: `victim@victim.test`, + displayName: 'victim', + passwordHash: '!federation-replicated', + status: 'offline', + isAdmin: 0, + homeInstance: VICTIM_HOME_INSTANCE, + homeUserId: VICTIM_HOME_USER_ID, + createdAt: now, + }).run(); + + testDb.insert(schema.users).values({ + id: LEAVER_USER_ID, + username: `leaver@random.test`, + displayName: 'leaver', + passwordHash: '!federation-replicated', + status: 'offline', + isAdmin: 0, + homeInstance: LEAVER_HOME_INSTANCE, + homeUserId: LEAVER_HOME_USER_ID, + createdAt: now, + }).run(); + + testDb.insert(schema.dmChannels).values({ + id: CHANNEL_ID, + federatedId: FEDERATED_ID, + ownerId: OWNER_USER_ID, + ownerHomeUserId: OWNER_HOME_USER_ID, + ownerHomeInstance: OWNER_ORIGIN, + name: 'group', + icon: null, + metadataUpdatedAt: 1000, + createdAt: now, + }).run(); + + testDb.insert(schema.dmMembers).values({ dmChannelId: CHANNEL_ID, userId: OWNER_USER_ID, closed: 0 }).run(); + testDb.insert(schema.dmMembers).values({ dmChannelId: CHANNEL_ID, userId: VICTIM_USER_ID, closed: 0 }).run(); + testDb.insert(schema.dmMembers).values({ dmChannelId: CHANNEL_ID, userId: LEAVER_USER_ID, closed: 0 }).run(); +} + +function ownerActor(): FederationRelayParticipant { + return { + homeUserId: OWNER_HOME_USER_ID, + homeInstance: OWNER_ORIGIN, + profile: { username: 'owner', displayName: 'owner' }, + }; +} + +function victimActor(): FederationRelayParticipant { + return { + homeUserId: VICTIM_HOME_USER_ID, + homeInstance: VICTIM_HOME_INSTANCE, + profile: { username: 'victim', displayName: 'victim' }, + }; +} + +function leaverActor(): FederationRelayParticipant { + return { + homeUserId: LEAVER_HOME_USER_ID, + homeInstance: LEAVER_HOME_INSTANCE, + profile: { username: 'leaver', displayName: 'leaver' }, + }; +} + +function buildKickEvent(messageId = 'evt-kick-1'): FederationRelayEvent { + return { + eventType: 'member_remove', + contextType: 'dm', + messageId, + federatedId: FEDERATED_ID, + encryptionVersion: 0, + timestamp: Date.now(), + membership: { + user: victimActor(), + removedBy: ownerActor(), + reason: 'kick', + }, + }; +} + +function buildLeaveEvent(messageId = 'evt-leave-1'): FederationRelayEvent { + return { + eventType: 'member_remove', + contextType: 'dm', + messageId, + federatedId: FEDERATED_ID, + encryptionVersion: 0, + timestamp: Date.now(), + membership: { + user: leaverActor(), + removedBy: leaverActor(), + reason: 'leave', + }, + }; +} + +beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + vi.mocked(connectionManager.sendToDmMembers).mockReset(); +}); + +describe('processMemberRemoveEvent — kick authority', () => { + it('accepts a kick from the owner instance, deletes the member, inserts a system message, and broadcasts dm_member_removed', async () => { + seedChannelAndMembers(); + const fed = await import('./federation.js'); + const event = buildKickEvent(); + + const accepted: string[] = []; + const rejected: Array<{ messageId: string; reason: string }> = []; + fed.processMemberRemoveEvent(event, OWNER_ORIGIN, testDb, accepted, rejected); + + expect(rejected).toEqual([]); + expect(accepted).toEqual([event.messageId]); + + // Victim's dm_members row was deleted + const victimRow = testDb.select().from(schema.dmMembers) + .where(and(eq(schema.dmMembers.dmChannelId, CHANNEL_ID), eq(schema.dmMembers.userId, VICTIM_USER_ID))) + .get(); + expect(victimRow).toBeUndefined(); + + // System message inserted, tagged with source for dedup, with reason=kick + const sysRows = testDb.select().from(schema.dmMessages) + .where(eq(schema.dmMessages.dmChannelId, CHANNEL_ID)) + .all(); + expect(sysRows).toHaveLength(1); + const sys = sysRows[0]!; + expect(sys.type).toBe('system'); + expect(sys.sourceInstance).toBe(OWNER_ORIGIN); + expect(sys.sourceMessageId).toBe(event.messageId); + const parsed = JSON.parse(sys.content!); + expect(parsed.event).toBe('member_removed'); + expect(parsed.reason).toBe('kick'); + expect(parsed.targetUserId).toBe(VICTIM_USER_ID); + + // Two broadcasts fire: one for the system message, one for dm_member_removed + const sendSpy = vi.mocked(connectionManager.sendToDmMembers); + const removalCalls = sendSpy.mock.calls.filter(c => (c[1] as { type?: string }).type === 'dm_member_removed'); + expect(removalCalls).toHaveLength(1); + const removalPayload = removalCalls[0]![1] as { type: string; dmChannelId: string; userId: string }; + expect(removalPayload).toEqual({ + type: 'dm_member_removed', + dmChannelId: CHANNEL_ID, + userId: VICTIM_USER_ID, + }); + }); + + it('rejects a kick from a non-owner instance and performs no mutation (current behavior: reason=unauthorized_source)', async () => { + seedChannelAndMembers(); + const fed = await import('./federation.js'); + const event = buildKickEvent('evt-kick-attacker'); + + const accepted: string[] = []; + const rejected: Array<{ messageId: string; reason: string }> = []; + fed.processMemberRemoveEvent(event, ATTACKER_ORIGIN, testDb, accepted, rejected); + + expect(accepted).toEqual([]); + // Receiver currently emits `unauthorized_source` for non-owner kicks; the + // group-DM-polish plan describes this rejection as `attribution_mismatch`. + // The lock-in goal here is current behavior, so we assert the actual + // string. Tighten this if the receiver is changed to align with the plan + // wording — see federation.ts:processMemberRemoveEvent line ~4350. + expect(rejected).toEqual([{ messageId: event.messageId, reason: 'unauthorized_source' }]); + + // Victim's dm_members row is STILL present (no mutation) + const victimRow = testDb.select().from(schema.dmMembers) + .where(and(eq(schema.dmMembers.dmChannelId, CHANNEL_ID), eq(schema.dmMembers.userId, VICTIM_USER_ID))) + .get(); + expect(victimRow).toBeDefined(); + + // No system message inserted + const sysRows = testDb.select().from(schema.dmMessages) + .where(eq(schema.dmMessages.dmChannelId, CHANNEL_ID)) + .all(); + expect(sysRows).toHaveLength(0); + + // No broadcast emitted at all + expect(vi.mocked(connectionManager.sendToDmMembers)).not.toHaveBeenCalled(); + }); + + it('accepts a self-leave from any source instance (not the owner instance) and removes the leaver', async () => { + seedChannelAndMembers(); + const fed = await import('./federation.js'); + const event = buildLeaveEvent(); + + const accepted: string[] = []; + const rejected: Array<{ messageId: string; reason: string }> = []; + // sourceInstance == leaver's home instance (RANDOM_ORIGIN), which is NOT + // the owner's home instance — verifyAttribution still passes because the + // leaving user belongs to the source. + fed.processMemberRemoveEvent(event, RANDOM_ORIGIN, testDb, accepted, rejected); + + expect(rejected).toEqual([]); + expect(accepted).toEqual([event.messageId]); + + // Leaver's dm_members row was deleted + const leaverRow = testDb.select().from(schema.dmMembers) + .where(and(eq(schema.dmMembers.dmChannelId, CHANNEL_ID), eq(schema.dmMembers.userId, LEAVER_USER_ID))) + .get(); + expect(leaverRow).toBeUndefined(); + + // System message inserted with reason=leave + const sysRows = testDb.select().from(schema.dmMessages) + .where(eq(schema.dmMessages.dmChannelId, CHANNEL_ID)) + .all(); + expect(sysRows).toHaveLength(1); + const parsed = JSON.parse(sysRows[0]!.content!); + expect(parsed.event).toBe('member_removed'); + expect(parsed.reason).toBe('leave'); + expect(parsed.targetUserId).toBe(LEAVER_USER_ID); + + // dm_member_removed broadcast fires + const sendSpy = vi.mocked(connectionManager.sendToDmMembers); + const removalCalls = sendSpy.mock.calls.filter(c => (c[1] as { type?: string }).type === 'dm_member_removed'); + expect(removalCalls).toHaveLength(1); + expect(removalCalls[0]![1]).toEqual({ + type: 'dm_member_removed', + dmChannelId: CHANNEL_ID, + userId: LEAVER_USER_ID, + }); + }); +}); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 0b68e753..de00a300 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -4299,7 +4299,7 @@ function processMemberAddEvent( accepted.push(event.messageId); } -function processMemberRemoveEvent( +export function processMemberRemoveEvent( event: FederationRelayEvent, sourceInstance: string, db: ReturnType,