feat(federation): preserve effective displayName across profile_update relay
FederationProfileUpdatePayload gains `username`: the home user's canonical handle. Receiver applies displayName ?? username so stubs whose home user has no displayName show the real handle instead of getting clobbered to null. Mirrors the existing fallback in hydrateReplicatedUserProfile. Username itself is immutable on the home instance, so the receiver does not rewrite the stub's username column on profile_update.
This commit is contained in:
@@ -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<typeof drizzle<typeof schema>>;
|
||||
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.');
|
||||
});
|
||||
});
|
||||
@@ -5625,7 +5625,7 @@ async function downloadProfileAsset(
|
||||
}
|
||||
}
|
||||
|
||||
async function processProfileUpdateEvent(
|
||||
export async function processProfileUpdateEvent(
|
||||
event: FederationRelayEvent,
|
||||
sourceInstance: string,
|
||||
db: ReturnType<typeof getDb>,
|
||||
@@ -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,
|
||||
|
||||
@@ -362,6 +362,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
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<void> {
|
||||
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}`
|
||||
|
||||
Reference in New Issue
Block a user