From 8cad964809c96576f7ee67b764b498a9747137f7 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 23:58:45 +0200 Subject: [PATCH] chore(test): cover processFriendRequestCreateEvent branches Extends the existing happy-path test (one case) with 14 more covering every branch of the receiver-side handler: missing payload, attribution mismatch, tombstoned sender (silent accept), recipient_not_found (including soft-deleted recipient), already-friends idempotency in both column orderings, same-direction pending re-delivery idempotency, declined-then-resent (status filter), reused replicated stub, profile hydration with URL resolution, explicit and fallback createdAt, and mixed-batch accept/reject isolation through processRelayEvents. --- .../federation.friendRequestCreate.test.ts | 455 +++++++++++++++++- 1 file changed, 453 insertions(+), 2 deletions(-) diff --git a/packages/server/src/routes/federation.friendRequestCreate.test.ts b/packages/server/src/routes/federation.friendRequestCreate.test.ts index e7bfffb3..527ae3d8 100644 --- a/packages/server/src/routes/federation.friendRequestCreate.test.ts +++ b/packages/server/src/routes/federation.friendRequestCreate.test.ts @@ -1,13 +1,13 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import Database from 'better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3'; -import { eq } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import * as schema from '../db/schema.js'; import { setWorkerId } from '../utils/snowflake.js'; -import type { FederationRelayEvent } from '@backspace/shared'; +import type { FederationRelayEvent, FederationFriendshipPayload } from '@backspace/shared'; setWorkerId(1); const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -43,6 +43,58 @@ beforeEach(() => { sendToUser.mockReset(); }); +function seedLocalUser(id: string, username: string): void { + testDb.insert(schema.users).values({ + id, + username, + passwordHash: 'x', + status: 'online', + isAdmin: 0, + createdAt: Date.now(), + } as typeof schema.users.$inferInsert).run(); +} + +function seedReplicatedUser(opts: { + id: string; + username: string; + homeUserId: string; + homeInstance: string; + isDeleted?: 0 | 1; +}): void { + testDb.insert(schema.users).values({ + id: opts.id, + username: opts.username, + passwordHash: '!federation-replicated', + status: 'offline', + isAdmin: 0, + homeInstance: opts.homeInstance, + homeUserId: opts.homeUserId, + isDeleted: opts.isDeleted ?? 0, + createdAt: Date.now(), + } as typeof schema.users.$inferInsert).run(); +} + +function makeEvent(opts: { + messageId?: string; + friendship?: Partial & { from: FederationFriendshipPayload['from']; to: FederationFriendshipPayload['to'] }; + omitFriendship?: boolean; +}): FederationRelayEvent { + const base: FederationRelayEvent = { + eventType: 'friend_request_create', + contextType: 'friend', + messageId: opts.messageId ?? 'msg-' + Math.random().toString(36).slice(2), + encryptionVersion: 0, + timestamp: 12345, + }; + if (opts.omitFriendship) return base; + base.friendship = { + createdAt: 12345, + status: 'pending', + ...opts.friendship, + } as FederationFriendshipPayload; + return base; +} + describe('processRelayEvents — friend_request_create from sender home (S2S friend-add wire format)', () => { it('accepts the event, creates the local request, hydrates the sender stub, broadcasts to recipient', async () => { // Seed local recipient @@ -114,3 +166,402 @@ describe('processRelayEvents — friend_request_create from sender home (S2S fri expect(evt.request.user?.displayName).toBe('Bob'); }); }); + +describe('processFriendRequestCreateEvent — branch coverage', () => { + it('rejects event with missing friendship payload', async () => { + const event = makeEvent({ messageId: 'no-payload', omitFriendship: true }); + const { processRelayEvents } = await import('./federation.js'); + const result = await processRelayEvents([event], 'https://orbit.test', 'https://orbit.test', testDb); + + expect(result.accepted).toEqual([]); + expect(result.rejected).toEqual([{ messageId: 'no-payload', reason: 'missing_friendship_payload' }]); + expect(testDb.select().from(schema.friendRequests).all()).toHaveLength(0); + expect(sendToUser).not.toHaveBeenCalled(); + }); + + it('rejects event when sender attribution does not match peer origin', async () => { + seedLocalUser('alice-id', 'alice'); + // Sender's homeInstance claims orbit.test, but peer signing the relay is impostor.test. + const event = makeEvent({ + messageId: 'attrib-mismatch', + friendship: { + from: { homeUserId: 'remote-bob', homeInstance: 'https://orbit.test' }, + to: { homeUserId: 'alice-id', homeInstance: 'https://home.test' }, + }, + }); + + const { processRelayEvents } = await import('./federation.js'); + const result = await processRelayEvents([event], 'https://impostor.test', 'https://impostor.test', testDb); + + expect(result.rejected).toEqual([{ messageId: 'attrib-mismatch', reason: 'attribution_mismatch' }]); + expect(result.accepted).toEqual([]); + expect(testDb.select().from(schema.friendRequests).all()).toHaveLength(0); + expect(testDb.select().from(schema.users).where(eq(schema.users.homeUserId, 'remote-bob')).all()).toHaveLength(0); + expect(sendToUser).not.toHaveBeenCalled(); + }); + + it('silently accepts when sender identity is tombstoned (deleted) on this instance', async () => { + seedLocalUser('alice-id', 'alice'); + // Pre-existing tombstoned record for the sender — resolveOrCreateReplicatedUser refuses to resurrect. + seedReplicatedUser({ + id: 'bob-tombstone', + username: 'remote-bob@orbit.test', + homeUserId: 'remote-bob', + homeInstance: 'orbit.test', + isDeleted: 1, + }); + + const event = makeEvent({ + messageId: 'tombstoned-sender', + friendship: { + from: { homeUserId: 'remote-bob', homeInstance: 'https://orbit.test' }, + to: { homeUserId: 'alice-id', homeInstance: 'https://home.test' }, + }, + }); + + const { processRelayEvents } = await import('./federation.js'); + const result = await processRelayEvents([event], 'https://orbit.test', 'https://orbit.test', testDb); + + expect(result.accepted).toEqual(['tombstoned-sender']); + expect(result.rejected).toEqual([]); + expect(testDb.select().from(schema.friendRequests).all()).toHaveLength(0); + expect(sendToUser).not.toHaveBeenCalled(); + }); + + it('rejects with recipient_not_found when the recipient is not a local user', async () => { + // No alice seeded; the to.homeUserId points to nothing local. + const event = makeEvent({ + messageId: 'no-recipient', + friendship: { + from: { homeUserId: 'remote-bob', homeInstance: 'https://orbit.test' }, + to: { homeUserId: 'unknown-recipient', homeInstance: 'https://home.test' }, + fromProfile: { username: 'bob' }, + }, + }); + + const { processRelayEvents } = await import('./federation.js'); + const result = await processRelayEvents([event], 'https://orbit.test', 'https://orbit.test', testDb); + + expect(result.rejected).toEqual([{ messageId: 'no-recipient', reason: 'recipient_not_found' }]); + expect(result.accepted).toEqual([]); + expect(testDb.select().from(schema.friendRequests).all()).toHaveLength(0); + expect(sendToUser).not.toHaveBeenCalled(); + }); + + it('rejects with recipient_not_found when the matched local user is soft-deleted', async () => { + // resolveLocalUser filters isDeleted=1, so a deleted recipient is treated as not found. + testDb.insert(schema.users).values({ + id: 'alice-deleted', + username: 'alice', + passwordHash: 'x', + status: 'offline', + isAdmin: 0, + isDeleted: 1, + createdAt: Date.now(), + } as typeof schema.users.$inferInsert).run(); + + const event = makeEvent({ + messageId: 'recipient-deleted', + friendship: { + from: { homeUserId: 'remote-bob', homeInstance: 'https://orbit.test' }, + to: { homeUserId: 'alice-deleted', homeInstance: 'https://home.test' }, + fromProfile: { username: 'bob' }, + }, + }); + + const { processRelayEvents } = await import('./federation.js'); + const result = await processRelayEvents([event], 'https://orbit.test', 'https://orbit.test', testDb); + + expect(result.rejected).toEqual([{ messageId: 'recipient-deleted', reason: 'recipient_not_found' }]); + expect(testDb.select().from(schema.friendRequests).all()).toHaveLength(0); + }); + + it('accepts idempotently when users are already friends (sender→recipient row)', async () => { + seedLocalUser('alice-id', 'alice'); + seedReplicatedUser({ + id: 'bob-stub', + username: 'remote-bob@orbit.test', + homeUserId: 'remote-bob', + homeInstance: 'orbit.test', + }); + testDb.insert(schema.friends).values({ + userId: 'bob-stub', + friendId: 'alice-id', + createdAt: Date.now(), + }).run(); + + const event = makeEvent({ + messageId: 'already-friends-fwd', + friendship: { + from: { homeUserId: 'remote-bob', homeInstance: 'https://orbit.test' }, + to: { homeUserId: 'alice-id', homeInstance: 'https://home.test' }, + fromProfile: { username: 'bob' }, + }, + }); + + const { processRelayEvents } = await import('./federation.js'); + const result = await processRelayEvents([event], 'https://orbit.test', 'https://orbit.test', testDb); + + expect(result.accepted).toEqual(['already-friends-fwd']); + expect(result.rejected).toEqual([]); + expect(testDb.select().from(schema.friendRequests).all()).toHaveLength(0); + expect(sendToUser).not.toHaveBeenCalled(); + }); + + it('accepts idempotently when users are already friends (recipient→sender row)', async () => { + seedLocalUser('alice-id', 'alice'); + seedReplicatedUser({ + id: 'bob-stub', + username: 'remote-bob@orbit.test', + homeUserId: 'remote-bob', + homeInstance: 'orbit.test', + }); + // Friendship persisted in the reverse column order — handler must still detect it. + testDb.insert(schema.friends).values({ + userId: 'alice-id', + friendId: 'bob-stub', + createdAt: Date.now(), + }).run(); + + const event = makeEvent({ + messageId: 'already-friends-rev', + friendship: { + from: { homeUserId: 'remote-bob', homeInstance: 'https://orbit.test' }, + to: { homeUserId: 'alice-id', homeInstance: 'https://home.test' }, + fromProfile: { username: 'bob' }, + }, + }); + + const { processRelayEvents } = await import('./federation.js'); + const result = await processRelayEvents([event], 'https://orbit.test', 'https://orbit.test', testDb); + + expect(result.accepted).toEqual(['already-friends-rev']); + expect(result.rejected).toEqual([]); + expect(testDb.select().from(schema.friendRequests).all()).toHaveLength(0); + expect(sendToUser).not.toHaveBeenCalled(); + }); + + it('accepts idempotently when a same-direction pending request already exists (re-delivery)', async () => { + seedLocalUser('alice-id', 'alice'); + seedReplicatedUser({ + id: 'bob-stub', + username: 'remote-bob@orbit.test', + homeUserId: 'remote-bob', + homeInstance: 'orbit.test', + }); + testDb.insert(schema.friendRequests).values({ + id: 'existing-req', + fromId: 'bob-stub', + toId: 'alice-id', + status: 'pending', + createdAt: 1000, + }).run(); + + const event = makeEvent({ + messageId: 'redelivery', + friendship: { + from: { homeUserId: 'remote-bob', homeInstance: 'https://orbit.test' }, + to: { homeUserId: 'alice-id', homeInstance: 'https://home.test' }, + fromProfile: { username: 'bob' }, + }, + }); + + const { processRelayEvents } = await import('./federation.js'); + const result = await processRelayEvents([event], 'https://orbit.test', 'https://orbit.test', testDb); + + expect(result.accepted).toEqual(['redelivery']); + expect(result.rejected).toEqual([]); + const reqs = testDb.select().from(schema.friendRequests).all(); + expect(reqs).toHaveLength(1); + expect(reqs[0]!.id).toBe('existing-req'); + expect(reqs[0]!.createdAt).toBe(1000); + expect(sendToUser).not.toHaveBeenCalled(); + }); + + it('accepts and creates the request when only a non-pending (declined) request exists from the same sender', async () => { + // Idempotency check is gated on status='pending', so a prior declined request must NOT block a fresh one. + seedLocalUser('alice-id', 'alice'); + seedReplicatedUser({ + id: 'bob-stub', + username: 'remote-bob@orbit.test', + homeUserId: 'remote-bob', + homeInstance: 'orbit.test', + }); + testDb.insert(schema.friendRequests).values({ + id: 'old-declined', + fromId: 'bob-stub', + toId: 'alice-id', + status: 'declined', + createdAt: 1000, + }).run(); + + const event = makeEvent({ + messageId: 'after-decline', + friendship: { + from: { homeUserId: 'remote-bob', homeInstance: 'https://orbit.test' }, + to: { homeUserId: 'alice-id', homeInstance: 'https://home.test' }, + fromProfile: { username: 'bob' }, + createdAt: 5000, + }, + }); + + const { processRelayEvents } = await import('./federation.js'); + const result = await processRelayEvents([event], 'https://orbit.test', 'https://orbit.test', testDb); + + expect(result.accepted).toEqual(['after-decline']); + expect(result.rejected).toEqual([]); + const pending = testDb.select().from(schema.friendRequests) + .where(and( + eq(schema.friendRequests.fromId, 'bob-stub'), + eq(schema.friendRequests.toId, 'alice-id'), + eq(schema.friendRequests.status, 'pending'), + )).all(); + expect(pending).toHaveLength(1); + expect(pending[0]!.createdAt).toBe(5000); + expect(sendToUser).toHaveBeenCalledOnce(); + }); + + it('reuses a pre-existing replicated sender on the happy path (no new stub)', async () => { + seedLocalUser('alice-id', 'alice'); + seedReplicatedUser({ + id: 'bob-stub', + username: 'remote-bob@orbit.test', + homeUserId: 'remote-bob', + homeInstance: 'orbit.test', + }); + + const event = makeEvent({ + messageId: 'reuse-stub', + friendship: { + from: { homeUserId: 'remote-bob', homeInstance: 'https://orbit.test' }, + to: { homeUserId: 'alice-id', homeInstance: 'https://home.test' }, + fromProfile: { username: 'bob' }, + }, + }); + + const { processRelayEvents } = await import('./federation.js'); + const result = await processRelayEvents([event], 'https://orbit.test', 'https://orbit.test', testDb); + + expect(result.accepted).toEqual(['reuse-stub']); + const stubs = testDb.select().from(schema.users) + .where(eq(schema.users.homeUserId, 'remote-bob')).all(); + expect(stubs).toHaveLength(1); + expect(stubs[0]!.id).toBe('bob-stub'); + + const reqs = testDb.select().from(schema.friendRequests).all(); + expect(reqs).toHaveLength(1); + expect(reqs[0]!.fromId).toBe('bob-stub'); + expect(reqs[0]!.toId).toBe('alice-id'); + }); + + it('hydrates profile snapshot fields onto the sender stub on creation', async () => { + seedLocalUser('alice-id', 'alice'); + + const event = makeEvent({ + messageId: 'hydrate-profile', + friendship: { + from: { homeUserId: 'remote-bob', homeInstance: 'https://orbit.test' }, + to: { homeUserId: 'alice-id', homeInstance: 'https://home.test' }, + fromProfile: { + username: 'bob', + displayName: 'Bob the Builder', + avatar: 'avatar-bob.png', + avatarColor: 'mint', + banner: 'banner-bob.png', + bio: 'I build things.', + }, + }, + }); + + const { processRelayEvents } = await import('./federation.js'); + const result = await processRelayEvents([event], 'https://orbit.test', 'https://orbit.test', testDb); + + expect(result.accepted).toEqual(['hydrate-profile']); + const stub = testDb.select().from(schema.users) + .where(eq(schema.users.homeUserId, 'remote-bob')).get(); + expect(stub).toBeDefined(); + expect(stub!.displayName).toBe('Bob the Builder'); + // Bare filenames must be resolved against the sender's home origin. + expect(stub!.avatar).toBe('https://orbit.test/api/uploads/avatar-bob.png'); + expect(stub!.banner).toBe('https://orbit.test/api/uploads/banner-bob.png'); + expect(stub!.avatarColor).toBe('mint'); + expect(stub!.bio).toBe('I build things.'); + + // The WS broadcast carries the hydrated profile. + const [, evt] = sendToUser.mock.calls[0]!; + expect(evt.request.user?.displayName).toBe('Bob the Builder'); + }); + + it('uses event.friendship.createdAt for the friend_request row when provided', async () => { + seedLocalUser('alice-id', 'alice'); + + const event = makeEvent({ + messageId: 'explicit-created-at', + friendship: { + from: { homeUserId: 'remote-bob', homeInstance: 'https://orbit.test' }, + to: { homeUserId: 'alice-id', homeInstance: 'https://home.test' }, + fromProfile: { username: 'bob' }, + createdAt: 4242, + }, + }); + + const { processRelayEvents } = await import('./federation.js'); + await processRelayEvents([event], 'https://orbit.test', 'https://orbit.test', testDb); + + const req = testDb.select().from(schema.friendRequests).get(); + expect(req?.createdAt).toBe(4242); + const [, evt] = sendToUser.mock.calls[0]!; + expect(evt.request.createdAt).toBe(4242); + }); + + it('falls back to a fresh timestamp when friendship.createdAt is missing', async () => { + seedLocalUser('alice-id', 'alice'); + const before = Date.now(); + + const event = makeEvent({ + messageId: 'no-created-at', + friendship: { + from: { homeUserId: 'remote-bob', homeInstance: 'https://orbit.test' }, + to: { homeUserId: 'alice-id', homeInstance: 'https://home.test' }, + fromProfile: { username: 'bob' }, + }, + }); + // Strip the default we set in makeEvent so the handler exercises its fallback. + delete (event.friendship as { createdAt?: number }).createdAt; + + const { processRelayEvents } = await import('./federation.js'); + await processRelayEvents([event], 'https://orbit.test', 'https://orbit.test', testDb); + + const req = testDb.select().from(schema.friendRequests).get(); + expect(req?.createdAt).toBeGreaterThanOrEqual(before); + expect(req?.createdAt).toBeLessThanOrEqual(Date.now()); + }); + + it('isolates per-event success/failure within a batch (mixed accepted/rejected)', async () => { + seedLocalUser('alice-id', 'alice'); + + const okEvent = makeEvent({ + messageId: 'batch-ok', + friendship: { + from: { homeUserId: 'remote-bob', homeInstance: 'https://orbit.test' }, + to: { homeUserId: 'alice-id', homeInstance: 'https://home.test' }, + fromProfile: { username: 'bob' }, + }, + }); + const badEvent = makeEvent({ + messageId: 'batch-bad', + friendship: { + from: { homeUserId: 'remote-eve', homeInstance: 'https://orbit.test' }, + to: { homeUserId: 'ghost', homeInstance: 'https://home.test' }, + fromProfile: { username: 'eve' }, + }, + }); + + const { processRelayEvents } = await import('./federation.js'); + const result = await processRelayEvents([okEvent, badEvent], 'https://orbit.test', 'https://orbit.test', testDb); + + expect(result.accepted).toEqual(['batch-ok']); + expect(result.rejected).toEqual([{ messageId: 'batch-bad', reason: 'recipient_not_found' }]); + expect(testDb.select().from(schema.friendRequests).all()).toHaveLength(1); + }); +});