teqo-backup/tests/int/faceSubjectIndex.int.spec.ts
Francisco Solla 9e9864f420 feat(C234): busca por selfie no álbum público — índice A/C consentido, selfie on-device
- /fotos/encontre + POST /api/fotos/selfie: só o vetor 128-d, sem imagem, sem score e
  sem nomes de terceiros; fail-closed pela flag photoAlbum.selfieSearchEnabled (default
  false) e pelos Consents por chave estável (busca-selfie-fotos/busca-selfie-indice, não
  seedados — a superfície nasce fechada até o aval jurídico/DPIA).
- Índice A/C do gate (PR #1370): só quem aderiu é indexado; descriptors do acervo são
  transitórios. Collection faceSubject (admin-only, sem vínculo com Contact), vínculos
  matchedPhotos e marker faces.checkedKey no archivePhoto (modelo + enrolledAt).
- Engine @vladmandic/face-api nos dois lados: browser lazy com modelos same-origin
  (copy-face-vision-assets no prebuild/predev/Docker) e CLI node-wasm sem tfjs-node.
  CLIs ops-only faces:enroll / faces:index --apply|--verify com guardas C231/C232.
- Opt-out self-service por reconhecimento (leave-index) + canal de remoção do C233;
  rate limit próprio (namespace selfie:<hash>) e bounded body extraído do beacon.
- UI portada classe-a-classe do design estendido (paridade certificada, tier primário);
  e2e frontendFotosSelfie (manifest+curado+projeto, engine stubada) e canário do engine
  real; runbook §C234, contrato em AGENTS-public.md e changelog.
- Migration aditiva add_face_subject (SQL revisado); hard-stops aprovados na sessão.
2026-09-27 20:04:41 -03:00

249 lines
9.1 KiB
TypeScript

// @vitest-environment node
import { rm } from 'node:fs/promises'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
vi.mock('next/cache', () => ({
revalidatePath: () => undefined,
revalidateTag: vi.fn(),
unstable_cache: (fn: unknown) => fn,
}))
import { ARCHIVE_PHOTO_SLUG } from '@/lib/archivePhoto'
import { FACE_INDEX_CONSENT_KEY } from '@/lib/campaignConsentKeys'
import { FACE_SEARCH_DESCRIPTOR_LENGTH, FACE_SEARCH_MODEL } from '@/lib/faceSearch'
import config from '@/payload.config'
import { getConsentByKey } from '@/utilities/campaignConsent'
import { removeFaceSubjectFromIndex } from '@/utilities/faceSubjects/faceSubjectEnrollment'
import {
indexArchivePhotoFaces,
listArchivePhotoFaceIndexQueue,
} from '@/utilities/faceSubjects/faceSubjectPhotoIndex'
import {
getFaceSubjectMatchedPhotoIds,
listSearchableFaceSubjects,
} from '@/utilities/faceSubjects/faceSubjectReads'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import {
createFaceTestPhoto,
enrollFaceTestSubject,
FACE_INDEX_LEASE_KEY,
FACE_SPECS_LEASE_KEY,
faceTestVector,
} from '../helpers/faceIntFixtures'
import {
acquireTestDatabaseLease,
ensureLeasedConsent,
PHOTO_ALBUM_LEASE_KEY,
withExclusiveTestDatabaseLease,
} from '../helpers/testDatabaseLease'
// C234 — the subject/index boundary over the real Payload `teqo_test`: the
// enrollment write, the batch's transactional link replacement, the marker
// that makes the run resumable, the opt-out that erases the descriptor and the
// A/C rule that only enrolled subjects are ever searched.
let payload: Payload
const createdFlickrIds = new Set<string>()
const createdSubjectIds = new Set<number>()
const tempDirs: string[] = []
installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
const CHANNEL = 'https://jorge.solla.example.org/remocao'
const createApprovedPhoto = () =>
createFaceTestPhoto({
payload,
approved: true,
prefix: 'c234',
trackedFlickrIds: createdFlickrIds,
tempDirs,
})
const enrollFixtureSubject = (label: string, vector: number[]) =>
enrollFaceTestSubject({ payload, label, vector, trackedSubjectIds: createdSubjectIds })
const markerOf = async (
id: number,
): Promise<{ checkedAt?: string | null; checkedKey?: string | null }> => {
const doc = await payload.findByID({
collection: ARCHIVE_PHOTO_SLUG,
id,
depth: 0,
overrideAccess: true,
})
return doc.faces ?? {}
}
describe('face subject enrollment and archive index (C234)', () => {
let specsLease: Awaited<ReturnType<typeof acquireTestDatabaseLease>>
beforeAll(async () => {
payload = await getPayload({ config: await config })
specsLease = await acquireTestDatabaseLease(payload, FACE_SPECS_LEASE_KEY)
await ensureLeasedConsent(payload, {
consentKey: FACE_INDEX_CONSENT_KEY,
leaseKey: FACE_INDEX_LEASE_KEY,
})
await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
payload.updateGlobal({
slug: 'photoAlbum',
data: { published: true, removalChannelUrl: CHANNEL },
overrideAccess: true,
}),
)
})
afterAll(async () => {
for (const flickrId of createdFlickrIds) {
await payload.delete({
collection: ARCHIVE_PHOTO_SLUG,
where: { flickrId: { equals: flickrId } },
overrideAccess: true,
})
}
for (const id of createdSubjectIds) {
await payload.delete({ collection: 'faceSubject', id, overrideAccess: true })
}
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
await specsLease.release()
})
it('enrolls a subject with the current model and consent snapshot', async () => {
const consent = await getConsentByKey(payload, FACE_INDEX_CONSENT_KEY)
const id = await enrollFixtureSubject('Pessoa de teste', faceTestVector(0))
const stored = await payload.findByID({
collection: 'faceSubject',
id,
depth: 0,
overrideAccess: true,
})
expect(stored.status).toBe('active')
expect(stored.model).toBe(FACE_SEARCH_MODEL)
expect(stored.consentHash).toBe(consent?.contentHash)
expect(stored.vector).toHaveLength(FACE_SEARCH_DESCRIPTOR_LENGTH)
expect(stored.enrolledAt).toBeTruthy()
const searchable = await listSearchableFaceSubjects(payload)
expect(searchable.map((subject) => subject.id)).toContain(id)
})
it('links an approved photo to the enrolled subject and stamps the marker', async () => {
const owner = await enrollFixtureSubject('Dona da foto', faceTestVector(0))
const photo = await createApprovedPhoto()
const queue = await listArchivePhotoFaceIndexQueue({ payload })
expect(queue.eligibleSubjects).toBeGreaterThanOrEqual(1)
expect(queue.items.map((item) => item.id)).toContain(photo.id)
const result = await indexArchivePhotoFaces({
payload,
item: queue.items.find((item) => item.id === photo.id)!,
indexKey: queue.indexKey,
analyze: async () => [faceTestVector(0)],
})
expect(result).toEqual({ status: 'indexed', matchedSubjects: expect.arrayContaining([owner]) })
expect(await getFaceSubjectMatchedPhotoIds(payload, owner)).toContain(photo.id)
const marker = await markerOf(photo.id)
expect(marker.checkedKey).toBe(queue.indexKey)
expect(marker.checkedAt).toBeTruthy()
// The marker now spares the photo on the next run.
const nextQueue = await listArchivePhotoFaceIndexQueue({ payload })
expect(nextQueue.items.map((item) => item.id)).not.toContain(photo.id)
const refreshed = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
expect(refreshed.items.map((item) => item.id)).toContain(photo.id)
})
it('removes a link that stopped matching and re-adds it when the face matches again', async () => {
const subject = await enrollFixtureSubject('Rosto mutável', faceTestVector(0))
const photo = await createApprovedPhoto()
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
const indexed = await indexArchivePhotoFaces({
payload,
item: queue.items.find((item) => item.id === photo.id)!,
indexKey: queue.indexKey,
analyze: async () => [faceTestVector(0)],
})
expect(indexed.status).toBe('indexed')
expect(await getFaceSubjectMatchedPhotoIds(payload, subject)).toContain(photo.id)
const removed = await indexArchivePhotoFaces({
payload,
item: queue.items.find((item) => item.id === photo.id)!,
indexKey: queue.indexKey,
analyze: async () => [faceTestVector(1)],
})
expect(removed.status).toBe('indexed')
expect(await getFaceSubjectMatchedPhotoIds(payload, subject)).not.toContain(photo.id)
await indexArchivePhotoFaces({
payload,
item: queue.items.find((item) => item.id === photo.id)!,
indexKey: queue.indexKey,
analyze: async () => [faceTestVector(0)],
})
expect(await getFaceSubjectMatchedPhotoIds(payload, subject)).toContain(photo.id)
})
it('changes the index key on a new enrollment so every photo is reprocessed', async () => {
const queue = await listArchivePhotoFaceIndexQueue({ payload })
await enrollFixtureSubject('Nova adesão', faceTestVector(0.5))
const nextQueue = await listArchivePhotoFaceIndexQueue({ payload })
expect(nextQueue.indexKey).not.toBe(queue.indexKey)
})
it('erases the descriptor and the links on opt-out', async () => {
const subject = await enrollFixtureSubject('Sai do índice', faceTestVector(0))
const photo = await createApprovedPhoto()
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
await indexArchivePhotoFaces({
payload,
item: queue.items.find((item) => item.id === photo.id)!,
indexKey: queue.indexKey,
analyze: async () => [faceTestVector(0)],
})
expect(await getFaceSubjectMatchedPhotoIds(payload, subject)).toContain(photo.id)
await removeFaceSubjectFromIndex({ payload, id: subject })
const stored = await payload.findByID({
collection: 'faceSubject',
id: subject,
depth: 0,
overrideAccess: true,
})
expect(stored.status).toBe('removed')
expect(stored.vector).toBeNull()
expect(stored.removedAt).toBeTruthy()
expect(await getFaceSubjectMatchedPhotoIds(payload, subject)).toEqual([])
const searchable = await listSearchableFaceSubjects(payload)
expect(searchable.map((candidate) => candidate.id)).not.toContain(subject)
})
it('fails a photo with no stored file instead of writing a link', async () => {
const subject = await enrollFixtureSubject('Sem arquivo', faceTestVector(0))
const queue = await listArchivePhotoFaceIndexQueue({ payload, refresh: true })
const result = await indexArchivePhotoFaces({
payload,
item: { id: 999_999_999, filename: null, checkedKey: null },
indexKey: queue.indexKey,
analyze: async () => [faceTestVector(0)],
})
expect(result).toEqual({ status: 'failed', stage: 'download', error: expect.any(String) })
expect(await getFaceSubjectMatchedPhotoIds(payload, subject)).toEqual([])
})
})