diff --git a/packages/server/src/routes/federation.resolveOrCreate.test.ts b/packages/server/src/routes/federation.resolveOrCreate.test.ts new file mode 100644 index 00000000..e6c550c0 --- /dev/null +++ b/packages/server/src/routes/federation.resolveOrCreate.test.ts @@ -0,0 +1,86 @@ +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 * as schema from '../db/schema.js'; +import { eq } from 'drizzle-orm'; + +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, +})); + +let _sf = 1; +vi.mock('../utils/snowflake.js', () => ({ + generateSnowflake: () => String(_sf++), + setWorkerId: vi.fn(), +})); + +// federation.ts also imports connectionManager/ws — stub minimal surface so +// the route module loads at test time. The function under test doesn't touch any of these. +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(), + }, +})); + +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); + } + } +} + +beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + _sf = 1; +}); + +describe('resolveOrCreateReplicatedUser — stub username', () => { + it('creates stub with realname@domain when hint provides username', async () => { + const { resolveOrCreateReplicatedUser } = await import('./federation.js'); + const created = resolveOrCreateReplicatedUser( + '310002371434024960', + 'orbit.ddns.net', + testDb, + { username: 'pbtest3' }, + ); + expect(created).not.toBeNull(); + expect(created!.username).toBe('pbtest3@orbit.ddns.net'); + expect(created!.homeUserId).toBe('310002371434024960'); + expect(created!.homeInstance).toBe('orbit.ddns.net'); + }); + + it('falls back to homeUserId@domain when no hint provided', async () => { + const { resolveOrCreateReplicatedUser } = await import('./federation.js'); + const created = resolveOrCreateReplicatedUser( + '310002371434024960', + 'orbit.ddns.net', + testDb, + ); + expect(created!.username).toBe('310002371434024960@orbit.ddns.net'); + }); +}); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 6173d5ba..f4d188e8 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -3181,9 +3181,13 @@ export function resolveOrCreateReplicatedUser( return null; } - // Use the snowflake-style homeUserId as the local part; append the - // domain so the username is globally unique and human-readable. - const baseUsername = `${homeUserId}@${domain}`.toLowerCase(); + // Use the home user's real username when the caller passes a hint (the wire + // profile snapshot from friend_request_create / friend_add / DM relay carries + // it). This makes the local stub's `username` human-readable, so client-side + // `parseFederatedUsername(username).baseName` returns the real handle. Falls + // back to the snowflake-id scheme when no hint is available (legacy paths). + const localPart = (hints?.username ?? homeUserId).toLowerCase(); + const baseUsername = `${localPart}@${domain}`.toLowerCase(); // Guard against the (unlikely) case where this username already // exists — e.g. a prior partial replication or manual creation. @@ -3192,11 +3196,11 @@ export function resolveOrCreateReplicatedUser( let attempt = 0; while (collision) { attempt++; - username = `${homeUserId}_${attempt}@${domain}`.toLowerCase(); + username = `${localPart}_${attempt}@${domain}`.toLowerCase(); collision = db.select().from(schema.users).where(eq(schema.users.username, username)).get(); if (attempt > 10) { // Extremely unlikely; use a random suffix to break out - username = `${homeUserId}_${randomBytes(4).toString('hex')}@${domain}`.toLowerCase(); + username = `${localPart}_${randomBytes(4).toString('hex')}@${domain}`.toLowerCase(); break; } }