diff --git a/packages/server/src/routes/federation.profileUpdate.test.ts b/packages/server/src/routes/federation.profileUpdate.test.ts new file mode 100644 index 00000000..d2b408a1 --- /dev/null +++ b/packages/server/src/routes/federation.profileUpdate.test.ts @@ -0,0 +1,124 @@ +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'; +import type { FederationRelayEvent } from '@backspace/shared'; + +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('../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); + // Stub seeded with displayName='pbtest3' (set previously by hydrateReplicatedUserProfile) + testDb.insert(schema.users).values({ + id: 'stub-1', + username: 'pbtest3@orbit.ddns.net', + displayName: 'pbtest3', + passwordHash: '!federation-replicated', + status: 'offline', + isAdmin: 0, + homeInstance: 'orbit.ddns.net', + homeUserId: 'home-1', + profileUpdatedAt: 1000, + createdAt: Date.now(), + }).run(); +}); + +describe('processProfileUpdateEvent — displayName fallback', () => { + it('falls back to username when home displayName is null', async () => { + const fed = await import('./federation.js'); + const event: FederationRelayEvent = { + eventType: 'profile_update', + contextType: 'profile', + messageId: 'm1', + encryptionVersion: 0, + timestamp: Date.now(), + profileUpdate: { + homeUserId: 'home-1', + homeInstance: 'orbit.ddns.net', + profileUpdatedAt: 2000, + username: 'pbtest3', + displayName: null, + avatar: null, + banner: null, + accentColor: null, + avatarColor: null, + bio: null, + }, + }; + const accepted: string[] = []; + const rejected: Array<{ messageId: string; reason: string }> = []; + await fed.processProfileUpdateEvent(event, 'orbit.ddns.net', testDb, accepted, rejected); + + expect(rejected).toEqual([]); + expect(accepted).toEqual(['m1']); + const row = testDb.select().from(schema.users).where(eq(schema.users.id, 'stub-1')).get(); + expect(row!.displayName).toBe('pbtest3'); // fell back to username, not clobbered to null + }); + + it('uses home displayName when provided (not the fallback)', async () => { + const fed = await import('./federation.js'); + const event: FederationRelayEvent = { + eventType: 'profile_update', + contextType: 'profile', + messageId: 'm2', + encryptionVersion: 0, + timestamp: Date.now(), + profileUpdate: { + homeUserId: 'home-1', + homeInstance: 'orbit.ddns.net', + profileUpdatedAt: 3000, + username: 'pbtest3', + displayName: 'Peter B.', + avatar: null, + banner: null, + accentColor: null, + avatarColor: null, + bio: null, + }, + }; + await fed.processProfileUpdateEvent(event, 'orbit.ddns.net', testDb, [], []); + const row = testDb.select().from(schema.users).where(eq(schema.users.id, 'stub-1')).get(); + expect(row!.displayName).toBe('Peter B.'); + }); +}); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index f4d188e8..5b5d2ebe 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -5625,7 +5625,7 @@ async function downloadProfileAsset( } } -async function processProfileUpdateEvent( +export async function processProfileUpdateEvent( event: FederationRelayEvent, sourceInstance: string, db: ReturnType, @@ -5717,10 +5717,17 @@ async function processProfileUpdateEvent( deleteUploadFile(oldBanner); } - // Authoritative overwrite — home instance is always right + // Authoritative overwrite — home instance is always right. + // displayName falls back to the home user's canonical username when null, + // mirroring hydrateReplicatedUserProfile so stubs whose home user has no + // displayName show the real handle instead of getting clobbered to null. + // (The username field on the wire is the home's canonical handle, not the + // stub's local-part; usernames are immutable on the home instance, so we + // never rewrite the stub's username column here.) + const effectiveDisplayName = payload.displayName ?? payload.username ?? null; db.update(schema.users) .set({ - displayName: payload.displayName, + displayName: effectiveDisplayName, avatar: resolvedAvatar, banner: resolvedBanner, accentColor: payload.accentColor, diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index 9d82e6a1..5a1f4b18 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -362,6 +362,7 @@ export async function userRoutes(app: FastifyInstance): Promise { homeUserId: preUpdateUser.id, homeInstance: origin, profileUpdatedAt: preUpdateUser.profileUpdatedAt ?? Date.now(), + username: preUpdateUser.username, displayName: preUpdateUser.displayName, avatar: preUpdateUser.avatar && !preUpdateUser.avatar.startsWith('http') ? `${origin}/api/uploads/${preUpdateUser.avatar}` @@ -524,6 +525,7 @@ export async function userRoutes(app: FastifyInstance): Promise { homeUserId: updatedUser!.id, homeInstance: origin, profileUpdatedAt: updatedUser!.profileUpdatedAt ?? Date.now(), + username: updatedUser!.username, displayName: updatedUser!.displayName, avatar: updatedUser!.avatar && !updatedUser!.avatar.startsWith('http') ? `${origin}/api/uploads/${updatedUser!.avatar}` diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 43096358..64b87c3a 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -972,6 +972,12 @@ export interface FederationProfileUpdatePayload { homeUserId: string; homeInstance: string; profileUpdatedAt: number; + // Home user's canonical username (without @domain suffix). Receivers apply + // `displayName ?? username` so stubs whose home user has no displayName show + // the real handle instead of getting clobbered to null. Username itself is + // immutable on the home instance — receivers do NOT rewrite the stub's + // username column on profile_update. + username: string; displayName: string | null; avatar: string | null; banner: string | null;