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.
This commit is contained in:
Jannis Braun
2026-04-26 23:58:45 +02:00
parent 8cae4c0b7c
commit 8cad964809
@@ -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<FederationFriendshipPayload> & { 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);
});
});