import { INestApplication } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; import { SearchProvider } from './search.provider'; /** * Trash keeps content out of the search index itself (issue #195): the * vector rows are cleared on page/pond trash and rebuilt on restore, and * the query-side deleted_at guards stay as an INDEPENDENT second layer — * proven by writing a vector back onto a trashed page and asserting the * query still returns nothing. */ describe.skipIf(!hasTestDb)('search index vs. trash (e2e, issue #195)', () => { let app: INestApplication; let prisma: PrismaClient; const suffix = uniqueSuffix(); const password = 'search trash pass 1'; const ids: Record = {}; const cookies: Record = {}; let pondId: string; let pageId: string; let childId: string; const needle = `zzsearchtrash${suffix.replaceAll('-', '')}`; const api = () => request(app.getHttpServer()); const vectorOf = async (id: string): Promise => { const rows = await prisma.$queryRaw<{ v: string | null }[]>` SELECT search_vector::text AS v FROM page_content_cache WHERE page_id = ${id}`; return rows[0]?.v ?? null; }; const hits = async (): Promise => { const res = await api() .get(`/api/v1/search?q=${needle}`) .set('Cookie', cookies.owner!) .expect(200); return res.body as unknown[]; }; async function seedContent(id: string, text: string): Promise { await prisma.pageContentCache.upsert({ where: { pageId: id }, create: { pageId: id, plainText: text, markdown: text, html: `

${text}

`, outline: [] }, update: { plainText: text, markdown: text, html: `

${text}

` }, }); await app.get(SearchProvider).indexPage(id); } beforeAll(async () => { prisma = createTestPrisma(); await prisma.rateLimit.deleteMany({}); app = await createTestApp(); const users = app.get(UsersService); for (const handle of ['owner', 'admin'] as const) { const username = `st-${handle}-${suffix}`; const user = await users.createUser({ username, email: `${username}@example.org`, displayName: `Search ${handle}`, password, locale: 'en', }); ids[handle] = user.id; await users.markEmailVerified(user.id); if (handle === 'admin') { await prisma.user.update({ where: { id: user.id }, data: { isSiteAdmin: true } }); } cookies[handle] = sessionCookieOf( await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: username, password }) .expect(200), ); } await api() .put(`/api/v1/admin/quotas/user/${ids.owner!}/additional_ponds`) .set('Cookie', cookies.admin!) .send({ value: 5 }) .expect(200); const pond = await api() .post('/api/v1/ponds') .set('Cookie', cookies.owner!) .send({ name: `Search Trash Pond ${suffix}` }) .expect(201); pondId = pond.body.id; const page = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', cookies.owner!) .send({ title: `Search Trash Page ${suffix}` }) .expect(201); pageId = page.body.id; const child = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', cookies.owner!) .send({ title: `Search Trash Child ${suffix}`, parentId: pageId }) .expect(201); childId = child.body.id; await seedContent(pageId, `parent text ${needle}`); await seedContent(childId, `child text ${needle}`); }); afterAll(async () => { const all = Object.values(ids); await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: all } } }); await prisma.auditEntry.deleteMany({ where: { actorId: { in: all } } }); const ponds = await prisma.pond.findMany({ where: { ownerId: { in: all } }, select: { id: true }, }); const pondIds = ponds.map((p) => p.id); await prisma.page.deleteMany({ where: { pondId: { in: pondIds } } }); await prisma.pond.deleteMany({ where: { id: { in: pondIds } } }); await prisma.watch.deleteMany({ where: { userId: { in: all } } }); await prisma.session.deleteMany({ where: { userId: { in: all } } }); await prisma.userIdentity.deleteMany({ where: { userId: { in: all } } }); await prisma.user.deleteMany({ where: { id: { in: all } } }); await prisma.$disconnect(); await app.close(); }); it('clears the vector rows on subtree trash and rebuilds them on restore', async () => { expect(await vectorOf(pageId)).toContain(needle.toLowerCase()); expect((await hits()).length).toBeGreaterThan(0); await api() .delete(`/api/v1/pages/${pageId}?mode=subtree`) .set('Cookie', cookies.owner!) .expect(204); // Layer 1: the index rows themselves hold nothing. expect(await vectorOf(pageId)).toBeNull(); expect(await vectorOf(childId)).toBeNull(); expect(await hits()).toEqual([]); await api().post(`/api/v1/pages/${pageId}/restore`).set('Cookie', cookies.owner!).expect(201); expect(await vectorOf(pageId)).toContain(needle.toLowerCase()); // The child stays trashed — and stays out of the index. expect(await vectorOf(childId)).toBeNull(); expect((await hits()).length).toBe(1); await api().post(`/api/v1/pages/${childId}/restore`).set('Cookie', cookies.owner!).expect(201); expect((await hits()).length).toBe(2); }); it('keeps the query-side guard as an independent second layer', async () => { await api().delete(`/api/v1/pages/${childId}`).set('Cookie', cookies.owner!).expect(204); expect(await vectorOf(childId)).toBeNull(); // Simulate a future code path that forgot to clear the vector. await prisma.$executeRaw` UPDATE page_content_cache SET search_vector = to_tsvector('simple', plain_text) WHERE page_id = ${childId}`; expect(await vectorOf(childId)).not.toBeNull(); // The deleted_at join still hides it. expect((await hits()).length).toBe(1); await api().post(`/api/v1/pages/${childId}/restore`).set('Cookie', cookies.owner!).expect(201); }); it('clears every page vector on pond trash and reindexes live pages on restore', async () => { // One page goes into the page trash first — it must stay out after // the pond comes back. await api().delete(`/api/v1/pages/${childId}`).set('Cookie', cookies.owner!).expect(204); await api().delete(`/api/v1/ponds/${pondId}`).set('Cookie', cookies.owner!).expect(204); expect(await vectorOf(pageId)).toBeNull(); expect(await vectorOf(childId)).toBeNull(); await api().post(`/api/v1/ponds/${pondId}/restore`).set('Cookie', cookies.admin!).expect(201); expect(await vectorOf(pageId)).toContain(needle.toLowerCase()); expect(await vectorOf(childId)).toBeNull(); expect((await hits()).length).toBe(1); }); });