test(federation): lock kick authority behavior in member_remove receiver
This commit is contained in:
@@ -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<typeof drizzle<typeof schema>>;
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4299,7 +4299,7 @@ function processMemberAddEvent(
|
|||||||
accepted.push(event.messageId);
|
accepted.push(event.messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function processMemberRemoveEvent(
|
export function processMemberRemoveEvent(
|
||||||
event: FederationRelayEvent,
|
event: FederationRelayEvent,
|
||||||
sourceInstance: string,
|
sourceInstance: string,
|
||||||
db: ReturnType<typeof getDb>,
|
db: ReturnType<typeof getDb>,
|
||||||
|
|||||||
Reference in New Issue
Block a user