All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 5m4s
CI / Build container images (pull_request) Successful in 2m47s
CI / Auth e2e pack (pull_request) Successful in 7m44s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 1m22s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 5m9s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m53s
CI / Import/export fidelity gate (push) Successful in 53s
Trashing a page (promote and subtree modes) clears the affected search vectors, restoring rebuilds them; pond trash clears every page vector of the pond, pond restore reindexes only the live pages (pages trashed inside stay out); the GDPR pseudonymization's personal-pond trash does the same. reindexAll now converges to the invariant (clears trashed, rebuilds live), and a one-off migration backfills vectors of already-trashed content. The query-side deleted_at guards stay untouched as the independent second layer - the test proves both layers separately, including writing a vector back onto a trashed page (simulating a future path that forgot the clear) and asserting the query still hides it. New provider methods removePond/reindexPond behind the SearchProvider seam. Refs #195 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
177 lines
7.1 KiB
TypeScript
177 lines
7.1 KiB
TypeScript
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<string, string> = {};
|
|
const cookies: Record<string, string> = {};
|
|
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<string | null> => {
|
|
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<unknown[]> => {
|
|
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<void> {
|
|
await prisma.pageContentCache.upsert({
|
|
where: { pageId: id },
|
|
create: { pageId: id, plainText: text, markdown: text, html: `<p>${text}</p>`, outline: [] },
|
|
update: { plainText: text, markdown: text, html: `<p>${text}</p>` },
|
|
});
|
|
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);
|
|
});
|
|
});
|