From b37d3bfa299bce5b7c91c8c80691e0c83cabb5cc Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 18:42:05 +0200 Subject: [PATCH] fix(social): apply discover-equivalent filters to /api/social/search Tombstoned users, replicated federated stubs, and users with discoverable=0 were all surfacing in Add Friend search results. Add the three WHERE filters that /api/social/discover already applies. Federated users continue to be surfaced via the client-side cross-instance fan-out in socialStore.searchUsers. New tests: social.test.ts covers all five filter cases plus existing self-exclusion and displayName-match behaviours. --- packages/server/src/routes/social.test.ts | 161 ++++++++++++++++++++++ packages/server/src/routes/social.ts | 26 ++-- 2 files changed, 179 insertions(+), 8 deletions(-) create mode 100644 packages/server/src/routes/social.test.ts diff --git a/packages/server/src/routes/social.test.ts b/packages/server/src/routes/social.test.ts new file mode 100644 index 00000000..06b270db --- /dev/null +++ b/packages/server/src/routes/social.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { 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'; + +setWorkerId(1); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; + +const CALLER_ID = 'caller-user-id'; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('../utils/auth.js', () => ({ + authenticate: async (req: { userId?: string }) => { + req.userId = CALLER_ID; + }, +})); + +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToUser: vi.fn(), + sendToAdmins: vi.fn(), + sendToDmMembers: vi.fn(), + getAllOnlineUserIds: () => [], + }, +})); + +vi.mock('../utils/federationOutbox.js', () => ({ + appendMutationLog: vi.fn(), + queueOutboxEvent: vi.fn(), + buildFriendContextId: () => 'ctx', + getFriendEventTargets: () => [], +})); + +vi.mock('../utils/federationAuth.js', () => ({ + getOurOrigin: () => 'https://local.test', +})); + +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 sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sqlText.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +interface UserSeed { + id: string; + username: string; + displayName?: string | null; + isDeleted?: 0 | 1; + discoverable?: 0 | 1; + homeInstance?: string | null; + homeUserId?: string | null; +} + +function seedUser(u: UserSeed): void { + testDb.insert(schema.users).values({ + id: u.id, + username: u.username, + displayName: u.displayName ?? null, + passwordHash: 'x', + status: 'offline', + isAdmin: 0, + isDeleted: u.isDeleted ?? 0, + discoverable: u.discoverable ?? 1, + homeInstance: u.homeInstance ?? null, + homeUserId: u.homeUserId ?? null, + createdAt: Date.now(), + }).run(); +} + +async function buildApp(): Promise { + const app = Fastify({ logger: false }); + const { socialRoutes } = await import('./social.js'); + await app.register(socialRoutes); + await app.ready(); + return app; +} + +describe('GET /api/social/search — filter hygiene', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + // The caller themselves must exist so self-exclusion is meaningful. + seedUser({ id: CALLER_ID, username: 'caller' }); + app = await buildApp(); + }); + + async function search(q: string) { + const res = await app.inject({ method: 'GET', url: `/api/social/search?q=${encodeURIComponent(q)}` }); + expect(res.statusCode).toBe(200); + return JSON.parse(res.body) as Array<{ id: string; username: string }>; + } + + it('returns native, non-deleted, discoverable users that match the substring', async () => { + seedUser({ id: 'u1', username: 'alice', displayName: 'Alice' }); + const out = await search('ali'); + expect(out.map(u => u.id)).toContain('u1'); + }); + + it('hides tombstoned users (isDeleted=1)', async () => { + seedUser({ id: 'u1', username: 'alice', displayName: 'Alice', isDeleted: 1 }); + const out = await search('ali'); + expect(out.map(u => u.id)).not.toContain('u1'); + }); + + it('hides replicated federated stubs (homeInstance set)', async () => { + // Stub username matches the production form: @. + seedUser({ + id: 'stub1', + username: 'remote-id@nova.ddns.net', + displayName: null, + homeInstance: 'nova.ddns.net', + homeUserId: 'remote-id', + }); + const out = await search('nova'); + expect(out.map(u => u.id)).not.toContain('stub1'); + }); + + it('hides users with discoverable=0', async () => { + seedUser({ id: 'u1', username: 'alice', discoverable: 0 }); + const out = await search('ali'); + expect(out.map(u => u.id)).not.toContain('u1'); + }); + + it('excludes the caller from results', async () => { + // Caller is seeded in beforeEach with username 'caller'. + const out = await search('call'); + expect(out.map(u => u.id)).not.toContain(CALLER_ID); + }); + + it('matches displayName as well as username', async () => { + seedUser({ id: 'u1', username: 'a1b2c3', displayName: 'Wonderland' }); + const out = await search('wonder'); + expect(out.map(u => u.id)).toContain('u1'); + }); +}); diff --git a/packages/server/src/routes/social.ts b/packages/server/src/routes/social.ts index c3cdb200..4cf3e6d1 100644 --- a/packages/server/src/routes/social.ts +++ b/packages/server/src/routes/social.ts @@ -685,16 +685,26 @@ export async function socialRoutes(app: FastifyInstance): Promise { const pattern = `%${q}%`; - // Search by username or display name with partial matching, excluding current user + const conditions = [ + eq(schema.users.isDeleted, 0), + eq(schema.users.discoverable, 1), + ne(schema.users.id, request.userId), + // Exclude replicated federated stubs — federated users are surfaced + // via the client-side cross-instance fan-out in + // packages/web/src/stores/socialStore.ts (searchUsers), which dedupes + // by canonical identity. Returning stubs here would be a noisy + // duplicate source AND would leak domain-suffix substring matches + // (stubs are stored as @). + sql`(${schema.users.homeInstance} IS NULL OR ${schema.users.homeInstance} = '')`, + or( + like(schema.users.username, pattern), + like(schema.users.displayName, pattern), + )!, + ]; + const users = db.select() .from(schema.users) - .where(and( - or( - like(schema.users.username, pattern), - like(schema.users.displayName, pattern) - ), - ne(schema.users.id, request.userId) - )) + .where(and(...conditions)) .limit(10) .all();