diff --git a/apps/web/app/lib/csv-export.test.ts b/apps/web/app/lib/csv-export.test.ts index 9b69cd201..282cfc352 100644 --- a/apps/web/app/lib/csv-export.test.ts +++ b/apps/web/app/lib/csv-export.test.ts @@ -1,7 +1,9 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AUTHORITY_FILTER_KEYS, COMPANY_FILTER_KEYS, CONTRACT_FILTER_KEYS } from '@sigma/db'; +import { MASKED_NATURAL_PERSON_LABEL } from '@sigma/shared'; import { fakeD1 } from '@sigma/test-support'; import { DATA_SOURCE } from './dataSource'; +import * as security from './security'; import { isUnfilteredCsvExport, servedCsvExport } from './csv-export'; const REFRESHED_AT = '2026-06-13T10:00:00Z'; @@ -259,6 +261,8 @@ function csvBytesResponse(body: Uint8Array): Response { }); } +type CsvRoute = Parameters[0]['route']; + function serve( r2: InMemoryR2, stream: () => Response, @@ -267,12 +271,14 @@ function serve( params?: object; sort?: string; refreshedAt?: string | null | undefined; + route?: CsvRoute; } = {}, ): Promise { + const route = opts.route ?? 'contracts'; return servedCsvExport({ env: envWith(r2, opts.refreshedAt), - request: opts.request ?? new Request('http://local/contracts.csv'), - route: 'contracts', + request: opts.request ?? new Request(`http://local/${route}.csv`), + route, params: opts.params ?? { sort: opts.sort ?? 'value-desc' }, stream, }); @@ -510,3 +516,167 @@ describe('servedCsvExport', () => { expect((await response.arrayBuffer()).byteLength).toBe(largeBody.byteLength); }); }); + +describe('servedCsvExport privacy', () => { + it('stamps the privacy mask marker on a MISS response (contracts) and never writes X-Robots-Tag at the route layer', async () => { + const r2 = new InMemoryR2(); + const stream = vi.fn(() => csvResponse()); + + const response = await serve(r2, stream, { route: 'contracts' }); + + expect(response.status).toBe(200); + expect(response.headers.get('X-Csv-Cache')).toBe('MISS'); + expect(response.headers.get('X-Privacy-Mask')).toBe('applied'); + expect(response.headers.get('X-Robots-Tag')).toBeNull(); + }); + + it('stamps the privacy mask marker on a HIT response (companies) and never writes X-Robots-Tag at the route layer', async () => { + const r2 = new InMemoryR2(); + const primeStream = vi.fn(() => csvResponse()); + await (await serve(r2, primeStream, { route: 'companies' })).text(); + + const hitStream = vi.fn(() => csvResponse('from db\n')); + const response = await serve(r2, hitStream, { route: 'companies' }); + + expect(response.status).toBe(200); + expect(response.headers.get('X-Csv-Cache')).toBe('HIT'); + expect(response.headers.get('X-Privacy-Mask')).toBe('applied'); + expect(response.headers.get('X-Robots-Tag')).toBeNull(); + expect(await response.text()).toBe(CSV_BODY); + expect(hitStream).not.toHaveBeenCalled(); + }); + + it('stamps the privacy mask marker on a dynamic (filtered) response (authorities) and never writes X-Robots-Tag at the route layer', async () => { + const r2 = new InMemoryR2(); + const stream = vi.fn(() => csvResponse('filtered\n')); + + const response = await serve(r2, stream, { + route: 'authorities', + params: { sort: 'value-desc', q: 'foo' }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get('X-Csv-Cache')).toBe('dynamic'); + expect(response.headers.get('X-Privacy-Mask')).toBe('applied'); + expect(response.headers.get('X-Robots-Tag')).toBeNull(); + expect(await response.text()).toBe('filtered\n'); + expect(r2.createMultipartUpload).not.toHaveBeenCalled(); + }); + + it('preserves the masking label and excludes the verbatim source name when the streamer emits masked bytes (contracts)', async () => { + const VERBATIM_NAME = 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ'; + const maskedBody = `id,name,eik\nrow-1,${MASKED_NATURAL_PERSON_LABEL},\n`; + const r2 = new InMemoryR2(); + const stream = vi.fn(() => csvResponse(maskedBody)); + + const response = await serve(r2, stream, { route: 'contracts' }); + + const text = await response.text(); + expect(text).toContain(MASKED_NATURAL_PERSON_LABEL); + expect(text).not.toContain(VERBATIM_NAME); + expect(text).toBe(maskedBody); + }); + + // Locks in the blanket noindex policy documented in `privacy.tsx` and `docs/privacy-masking.md`: + // every CSV export carries `X-Privacy-Mask: applied` regardless of whether the body actually + // contains masked natural-person rows. The `authorities.csv` route is explicitly excluded from + // body masking (only ЕИК/name redaction is suppressed) but still gets the marker — CSV is a + // bulk machine-readable surface and the noindex signal is enforced blanket-wide. + it('stamps the privacy mask marker even when the CSV body contains zero masked rows (blanket CSV policy)', async () => { + const legalOnlyBody = 'eik,name\n121817309,СОФАРМА ТРЕЙДИНГ АД\n'; + const r2 = new InMemoryR2(); + for (const route of ['contracts', 'companies', 'authorities'] as const) { + const stream = vi.fn(() => csvResponse(legalOnlyBody)); + const response = await serve(r2, stream, { route }); + expect(response.headers.get('X-Privacy-Mask')).toBe('applied'); + expect(response.headers.get('X-Robots-Tag')).toBeNull(); + } + }); + + it('preserves Cache-Control: public, max-age=3600 when the streamer emits a row with masked sole-trader identifiers (contracts) and stamps the privacy mask marker', async () => { + const maskedBody = `eik,name\n,${MASKED_NATURAL_PERSON_LABEL}\n`; + const r2 = new InMemoryR2(); + const stream = vi.fn(() => csvResponse(maskedBody)); + + const response = await serve(r2, stream, { route: 'contracts' }); + + expect(response.headers.get('Cache-Control')).toBe('public, max-age=3600'); + expect(response.headers.get('X-Privacy-Mask')).toBe('applied'); + expect(response.headers.get('X-Robots-Tag')).toBeNull(); + }); + + // Guards against the duplicate-call smell flagged in PR #183 review (T-004): `markCsvCache` already + // invokes `markPrivacyMaskApplied` on the final headers, so an earlier call inside `responseFromR2Object` + // (or on the 304 branch) was redundant. The marker must be applied, but exactly once per response — + // a second call is dead code that hides the single source of truth (markCsvCache). + describe('marks the privacy mask exactly once per response (no duplicate calls)', () => { + let markSpy: ReturnType; + + beforeEach(() => { + markSpy = vi.spyOn(security, 'markPrivacyMaskApplied'); + }); + afterEach(() => markSpy.mockRestore()); + + it('MISS (contracts): marks exactly once', async () => { + const r2 = new InMemoryR2(); + await serve( + r2, + vi.fn(() => csvResponse()), + { route: 'contracts' }, + ); + expect(markSpy).toHaveBeenCalledTimes(1); + }); + + it('HIT (companies): marks exactly once', async () => { + const r2 = new InMemoryR2(); + await ( + await serve( + r2, + vi.fn(() => csvResponse()), + { route: 'companies' }, + ) + ).text(); + markSpy.mockClear(); + await serve( + r2, + vi.fn(() => csvResponse('hit\n')), + { route: 'companies' }, + ); + expect(markSpy).toHaveBeenCalledTimes(1); + }); + + it('dynamic (authorities, filtered): marks exactly once', async () => { + const r2 = new InMemoryR2(); + await serve( + r2, + vi.fn(() => csvResponse('filtered\n')), + { + route: 'authorities', + params: { sort: 'value-desc', q: 'foo' }, + }, + ); + expect(markSpy).toHaveBeenCalledTimes(1); + }); + + it('304 (conditional GET on a primed object): marks exactly once', async () => { + const r2 = new InMemoryR2(); + const primed = await serve( + r2, + vi.fn(() => csvResponse()), + ); + const etag = primed.headers.get('ETag'); + await primed.text(); + markSpy.mockClear(); + await serve( + r2, + vi.fn(() => csvResponse('hit\n')), + { + request: new Request('http://local/contracts.csv', { + headers: { 'If-None-Match': etag ?? '' }, + }), + }, + ); + expect(markSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/apps/web/app/lib/csv-export.ts b/apps/web/app/lib/csv-export.ts index 078dfc0c1..2db063dea 100644 --- a/apps/web/app/lib/csv-export.ts +++ b/apps/web/app/lib/csv-export.ts @@ -1,5 +1,6 @@ import { withDataSource } from './dataSource'; import { getDb } from '@sigma/db'; +import { markPrivacyMaskApplied } from './security'; const CSV_CONTENT_TYPE = 'text/csv; charset=utf-8'; const CSV_CACHE_CONTROL = 'public, max-age=3600'; @@ -56,6 +57,7 @@ function hasSearchFilter(params: object): boolean { function markCsvCache(response: Response, cache: CsvCacheState): Response { const withSource = withDataSource(response); withSource.headers.set('X-Csv-Cache', cache); + markPrivacyMaskApplied(withSource.headers); return withSource; } @@ -94,10 +96,13 @@ function responseFromR2Object( cache: CsvCacheState, ) { if (!hasBody(obj)) { - return markCsvCache( - new Response(null, { status: 304, headers: { ETag: obj.httpEtag } }), - cache, - ); + const response304 = new Response(null, { + status: 304, + headers: { ETag: obj.httpEtag }, + }); + // `markCsvCache` (below) stamps the privacy mask on the final headers — the single source of + // truth for the marker on every CSV path. Do not call `markPrivacyMaskApplied` here as well. + return markCsvCache(response304, cache); } const range = rangeInfo(obj); @@ -111,6 +116,8 @@ function responseFromR2Object( }); if (range) headers.set('Content-Range', `bytes ${range.start}-${range.end}/${obj.size}`); + // `markCsvCache` stamps the privacy mask on the final headers — the single source of truth for the + // marker on every CSV path. Do not call `markPrivacyMaskApplied` here as well (was a duplicate call). return markCsvCache(new Response(obj.body, { status: range ? 206 : 200, headers }), cache); } diff --git a/apps/web/app/lib/security.test.ts b/apps/web/app/lib/security.test.ts index 893bc8c38..8b948f598 100644 --- a/apps/web/app/lib/security.test.ts +++ b/apps/web/app/lib/security.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { baseSecurityHeaders, nonceLessSecurityHeaders, securityHeaders } from './security'; +import { + PRIVACY_MASK_APPLIED, + PRIVACY_MASK_MARKER, + applyPrivacyMaskHeaders, + baseSecurityHeaders, + markPrivacyMaskApplied, + nonceLessSecurityHeaders, + securityHeaders, +} from './security'; describe('securityHeaders CSP', () => { it('emits a strict nonce script-src and the documented style-src', () => { @@ -27,3 +35,52 @@ describe('securityHeaders CSP', () => { expect(baseSecurityHeaders(true).get('Strict-Transport-Security')).toContain('max-age='); }); }); + +describe('privacy mask headers', () => { + it('sets the marker to PRIVACY_MASK_APPLIED on the provided Headers after markPrivacyMaskApplied', () => { + const headers = new Headers(); + markPrivacyMaskApplied(headers); + expect(headers.get(PRIVACY_MASK_MARKER)).toBe(PRIVACY_MASK_APPLIED); + }); + + it('translates the marker to X-Robots-Tag: noindex and deletes the marker', () => { + const headers = new Headers(); + markPrivacyMaskApplied(headers); + applyPrivacyMaskHeaders(headers); + expect(headers.get('X-Robots-Tag')).toBe('noindex'); + expect(headers.has(PRIVACY_MASK_MARKER)).toBe(false); + }); + + it('adds no X-Robots-Tag and leaves no marker when the marker is absent', () => { + const headers = new Headers({ 'Cache-Control': 'public, max-age=3600' }); + applyPrivacyMaskHeaders(headers); + expect(headers.has('X-Robots-Tag')).toBe(false); + expect(headers.has(PRIVACY_MASK_MARKER)).toBe(false); + // Untouched headers survive the call. + expect(headers.get('Cache-Control')).toBe('public, max-age=3600'); + }); + + it('is idempotent on a second call — no re-set, no marker, X-Robots-Tag left intact', () => { + const headers = new Headers(); + markPrivacyMaskApplied(headers); + applyPrivacyMaskHeaders(headers); + // Second call: marker is already gone, an existing X-Robots-Tag is left as-is. + applyPrivacyMaskHeaders(headers); + expect(headers.get('X-Robots-Tag')).toBe('noindex'); + expect(headers.has(PRIVACY_MASK_MARKER)).toBe(false); + }); + + it('treats PRIVACY_MASK_APPLIED as the literal type — only that exact value triggers the translate', () => { + // Type-level guard: PRIVACY_MASK_APPLIED is typed `as const`, so this comparison compiles + // only because the constant is the exported literal. A re-typed string-literal in + // `markPrivacyMaskApplied` (e.g. 'applied' with whitespace) would no longer satisfy + // `=== PRIVACY_MASK_APPLIED` and the test would fail. + expect(PRIVACY_MASK_APPLIED).toBe('applied'); + + const headers = new Headers(); + headers.set(PRIVACY_MASK_MARKER, 'something-else'); + applyPrivacyMaskHeaders(headers); + expect(headers.has('X-Robots-Tag')).toBe(false); + expect(headers.has(PRIVACY_MASK_MARKER)).toBe(false); + }); +}); diff --git a/apps/web/app/lib/security.ts b/apps/web/app/lib/security.ts index 4ace62ebf..379f778b1 100644 --- a/apps/web/app/lib/security.ts +++ b/apps/web/app/lib/security.ts @@ -61,3 +61,44 @@ export function nonceLessSecurityHeaders(scriptHashes: string[], isProd: boolean if (isProd) headers.set('Content-Security-Policy', csp(scriptHashes)); return headers; } + +// Internal marker header: a route that decides its response contains natural-person data sets this +// on the outgoing `Headers` so the worker `hardenResponse` can translate it into a public-facing +// `X-Robots-Tag: noindex`. The marker is intentionally internal — `applyPrivacyMaskHeaders` deletes +// it from the final response so it never reaches the edge cache or the client. +export const PRIVACY_MASK_MARKER = 'X-Privacy-Mask'; + +// `as const` narrows the type to the literal `'applied'` (not the wider `string`), which forces +// callers that compare against it to use this exported constant rather than re-typing the string. +export const PRIVACY_MASK_APPLIED = 'applied' as const; + +// Route-layer helper: stamps the privacy-mask marker onto a `Headers` object so the downstream +// worker `hardenResponse` can pick it up and translate it into `X-Robots-Tag: noindex`. +// +// Two legitimate call sites: +// 1. **Per-row maskers** (e.g. `/companies/:eik.data`, `/contracts/:id.json`) — call only when +// the response body actually contains masked natural-person data, so a legal-entity response +// stays out of the noindex bucket. +// 2. **Blanket-policy surfaces** (the three public CSV exports — `/contracts.csv`, +// `/companies.csv`, `/authorities.csv`) — the policy documented in `privacy.tsx` and +// `docs/privacy-masking.md` says EVERY CSV export carries `noindex` regardless of whether +// any specific row is a natural person, because CSV is a bulk machine-readable surface and +// the noindex signal is enforced blanket-wide. CSV callers therefore invoke this helper +// unconditionally inside `markCsvCache` (csv-export.ts). +// +// Workers translate the marker to `X-Robots-Tag: noindex` and delete it before cache/client, so +// future callers can pick the strategy that fits their surface without leaking the marker. +export function markPrivacyMaskApplied(headers: Headers): void { + headers.set(PRIVACY_MASK_MARKER, PRIVACY_MASK_APPLIED); +} + +// Worker-layer helper: if the privacy-mask marker is set to `applied`, translate it into the +// public-facing `X-Robots-Tag: noindex` header. The marker is then deleted unconditionally so it +// never leaks into the edge cache or the response. Idempotent: a second call finds no marker and +// leaves an existing `X-Robots-Tag` header untouched. +export function applyPrivacyMaskHeaders(headers: Headers): void { + if (headers.get(PRIVACY_MASK_MARKER) === PRIVACY_MASK_APPLIED) { + headers.set('X-Robots-Tag', 'noindex'); + } + headers.delete(PRIVACY_MASK_MARKER); +} diff --git a/apps/web/app/routes/companies.tsx b/apps/web/app/routes/companies.tsx index d66f22c13..05450194a 100644 --- a/apps/web/app/routes/companies.tsx +++ b/apps/web/app/routes/companies.tsx @@ -1,5 +1,11 @@ import { Link, useNavigation, useSearchParams } from 'react-router'; -import { count, money, moneyBare, parseConsortiumMembers } from '@sigma/shared'; +import { + count, + MASKED_NATURAL_PERSON_LABEL, + money, + moneyBare, + parseConsortiumMembers, +} from '@sigma/shared'; import { getCompanyFacets, listCompanies, getDb } from '@sigma/db'; import type { CompanyListItem } from '@sigma/api-contract'; import type { Route } from './+types/companies'; @@ -40,8 +46,21 @@ export function meta({ matches }: Route.MetaArgs) { }); } -export function headers() { - return { 'Cache-Control': publicCache(1800) }; +export function headers({ loaderHeaders }: Route.HeadersArgs) { + // Forward the internal privacy-mask marker set by the loader on the `.data` Response. React + // Router's `getDocumentHeadersImpl` does not auto-propagate loader headers (only `Set-Cookie`), + // so the route must forward explicitly — without this the worker `hardenResponse` cannot + // translate the marker into `X-Robots-Tag: noindex` on the HTML response. (PR #183 review #1: + // the leaderboard list exposes masked sole-trader rows in `toCompanyListItem`; the marker + // ensures the .data twin — RRv7 single-fetch — also carries noindex when ANY row on the page + // is masked, so search engines don't surface the masked twin separately from the HTML.) + const headers: Record = { + 'Cache-Control': loaderHeaders.get('Cache-Control') ?? publicCache(1800), + }; + if (loaderHeaders.get('X-Privacy-Mask') === 'applied') { + headers['X-Privacy-Mask'] = 'applied'; + } + return headers; } export async function loader({ request, context }: Route.LoaderArgs) { @@ -57,6 +76,14 @@ export async function loader({ request, context }: Route.LoaderArgs) { getCompanyFacets(db), getCoverageMeta(db), ]); + // Privacy (PR #183 review #1): if any row on this page was masked by the shared + // `toCompanyListItem` mapper (sole trader / natural person), stamp the privacy-mask marker so + // the worker `hardenResponse` translates it into `X-Robots-Tag: noindex` on the `.data` twin + // (RRv7 single-fetch). The marker is internal — it never reaches the client. Mirrors the + // company-detail loader's per-row marker pattern. + if (page.items.some((c) => c.name === MASKED_NATURAL_PERSON_LABEL)) { + return Response.json({ page, facets, coverage }, { headers: { 'X-Privacy-Mask': 'applied' } }); + } return { page, facets, coverage }; } diff --git a/apps/web/app/routes/company.data.test.ts b/apps/web/app/routes/company.data.test.ts new file mode 100644 index 000000000..2b186fbb2 --- /dev/null +++ b/apps/web/app/routes/company.data.test.ts @@ -0,0 +1,474 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CompanyDetail, NetworkData, TrendData } from '@sigma/api-contract'; +import { bidderIdFromSlug, getCompany, getEntityNetwork, getSpendingTrend } from '@sigma/db'; +import { applyPrivacyMaskHeaders } from '../lib/security'; +import type { CoverageMeta } from '../lib/coverage'; +import { getCoverageMeta } from '../lib/coverage'; +import { headers, loader, meta } from './company'; + +vi.mock('@sigma/db', () => ({ + bidderIdFromSlug: vi.fn((slug: string) => (/^\d{9}(\d{4})?$/.test(slug) ? 'eik:' + slug : null)), + getCompany: vi.fn(), + getSpendingTrend: vi.fn(), + getEntityNetwork: vi.fn(), + getDb: (env: unknown) => (env as { DB: unknown }).DB, +})); + +vi.mock('../lib/coverage', async () => { + const actual = await vi.importActual('../lib/coverage'); + return { + ...actual, + getCoverageMeta: vi.fn(), + coverageRange: actual.coverageRange, + }; +}); + +function makeCoverageMeta(): CoverageMeta { + return { asOf: '2025-06-30', refreshedAt: '2025-07-01T00:00:00Z', coverageEndYear: 2025 }; +} + +function makeTrend(): TrendData { + return { + granularity: 'month', + points: [], + years: [], + sectors: [], + totalValueEur: 0, + coverage: { dated: 0, total: 0, pct: 0 }, + scope: { sector: null, funding: 'all', granularity: 'month' }, + }; +} + +function makeNetwork(): NetworkData { + return { + center: null, + nodes: [], + edges: [], + centerOptions: { authorities: [], companies: [] }, + }; +} + +function makeCompany(overrides: Partial = {}): CompanyDetail { + return { + slug: 'company-slug', + name: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ', + displayName: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ', + kind: 'company', + isConsortium: false, + eik: '123456789', + eikValid: true, + hasEik: true, + ownershipKind: null, + settlement: 'Plovdiv', + region: null, + legalForm: 'ЕТ', + wonEur: 1000, + contracts: 1, + authorities: 1, + sector: null, + sectorSharePct: null, + euSharePct: 0, + avgBids: 1, + periodFirst: '2024-01-01', + periodLast: '2024-01-01', + suspect: 0, + topAuthorities: [ + { + slug: 'authority-slug', + name: 'Some Authority', + paidEur: 1000, + contracts: 1, + sharePct: 1, + }, + ], + moreAuthorities: 0, + procedureMix: [], + bids: { one: 1, two: 0, three: 0, fourPlus: 0, unknown: 0 }, + topContracts: [], + recentContracts: [], + participants: [], + membershipNote: null, + ...overrides, + }; +} + +function loaderArgs(eik: string | undefined): Parameters[0] { + return { + params: { eik: eik ?? '' }, + context: { cloudflare: { env: { DB: {} as never } } }, + } as unknown as Parameters[0]; +} + +function installStubs(company: CompanyDetail | null): void { + vi.mocked(getCompany).mockResolvedValueOnce(company); + vi.mocked(getCoverageMeta).mockResolvedValueOnce(makeCoverageMeta()); + vi.mocked(getSpendingTrend).mockResolvedValueOnce(makeTrend()); + vi.mocked(getEntityNetwork).mockResolvedValueOnce(makeNetwork()); +} + +beforeEach(() => { + vi.mocked(getCompany).mockReset(); + vi.mocked(getCoverageMeta).mockReset(); + vi.mocked(getSpendingTrend).mockReset(); + vi.mocked(getEntityNetwork).mockReset(); +}); + +describe('company.data loader — consortium-with-sole-trader-first-member branch (mirror of contract.tsx/contract.json.tsx)', () => { + it('does NOT over-mask a consortium whose displayName starts with "ЕТ " — keeps ЕИК, no privacy marker (regression for company.tsx consortium guard)', async () => { + // A ДЗЗД (consortium) whose first member is a sole trader has a display name beginning + // "ЕТ …". `isNaturalPersonBidder` delegates consortium filtering to the caller, so without + // the explicit `kind !== 'consortium'` guard the loader would zero the consortium's ЕИК and + // stamp noindex — the same over-masking bug that contract.tsx / contract.json.tsx guard against + // (MAJOR 1 in the PR #183 review). The fix mirrors those guards: the consortium branch must + // return the plain object with company.eik unchanged and no marker. + const consortiumWithSoleTraderFirst = makeCompany({ + kind: 'consortium', + isConsortium: true, + legalForm: null, + displayName: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ; СТРОЙ ООД', + name: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ; СТРОЙ ООД', + eik: '121817309', + }); + installStubs(consortiumWithSoleTraderFirst); + + const result = await loader(loaderArgs('121817309')); + + expect(result).not.toBeInstanceOf(Response); + const plain = result as { company: CompanyDetail }; + expect(plain.company.eik).toBe('121817309'); + expect(plain.company.displayName).toBe('ЕТ ДРИФТ - НИКОЛАЙ КИРОВ; СТРОЙ ООД'); + }); +}); + +describe('company.data loader — natural-person branch', () => { + it('masks only the ЕИК (sensitive ID), keeps the public trading displayName, and marks noindex (behaviors 1 + 2)', async () => { + const natural = makeCompany({ legalForm: 'ЕТ', displayName: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ' }); + installStubs(natural); + + const result = await loader(loaderArgs('123456789')); + + expect(result).toBeInstanceOf(Response); + const response = result as Response; + expect(response.status).toBe(200); + expect(response.headers.get('X-Privacy-Mask')).toBe('applied'); + expect(response.headers.get('X-Robots-Tag')).toBeNull(); + const body = (await response.json()) as { + company: { eik: string | null; displayName: string }; + }; + // ЕИК is the sensitive natural-person identifier → masked. + expect(body.company.eik).toBeNull(); + // displayName is the PUBLIC trading name, rendered verbatim on the HTML page; the `.data` twin + // is React Router's single-fetch transport for client navigations (NOT a standalone export like + // /contracts/:id.json), so the name must stay verbatim or client-rendered pages break. Only the + // ЕИК is masked. This locks the policy decision recorded in ADR-0039 §3 + PR #183 review. + expect(body.company.displayName).toBe('ЕТ ДРИФТ - НИКОЛАЙ КИРОВ'); + }); +}); + +describe('company.data loader — legal-entity branch', () => { + it('returns a plain object (not a Response) with company.eik unchanged and no privacy marker (behavior 3)', async () => { + const legal = makeCompany({ + legalForm: 'АД', + displayName: 'СОФАРМА ТРЕЙДИНГ АД', + eik: '121817309', + }); + installStubs(legal); + + const result = await loader(loaderArgs('121817309')); + + expect(result).not.toBeInstanceOf(Response); + const plain = result as { + company: CompanyDetail; + coverage: CoverageMeta; + trend: TrendData; + network: NetworkData; + }; + expect(plain.company.eik).toBe('121817309'); + expect(plain.company.displayName).toBe('СОФАРМА ТРЕЙДИНГ АД'); + expect(plain.coverage.coverageEndYear).toBe(2025); + }); +}); + +describe('company.data headers() — forwards the privacy mask marker', () => { + it('returns X-Privacy-Mask + Cache-Control when the loader set the marker (behavior 4)', () => { + const loaderHeaders = new Headers({ 'X-Privacy-Mask': 'applied' }); + + const result = headers({ + loaderHeaders, + parentHeaders: new Headers(), + actionHeaders: new Headers(), + errorHeaders: undefined, + } as unknown as Parameters[0]); + + expect(result['Cache-Control']).toBe('public, s-maxage=3600, stale-while-revalidate=86400'); + expect(result['X-Privacy-Mask']).toBe('applied'); + }); + + it('returns only Cache-Control when loaderHeaders carry no marker (behavior 5)', () => { + const loaderHeaders = new Headers(); + + const result = headers({ + loaderHeaders, + parentHeaders: new Headers(), + actionHeaders: new Headers(), + errorHeaders: undefined, + } as unknown as Parameters[0]); + + expect(result['Cache-Control']).toBe('public, s-maxage=3600, stale-while-revalidate=86400'); + expect('X-Privacy-Mask' in result).toBe(false); + }); +}); + +describe('company.data meta() — natural-person noindex branch', () => { + it('emits { name: robots, content: noindex } for a natural-person data payload (behavior 6)', () => { + const natural = makeCompany({ legalForm: 'ЕТ', displayName: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ' }); + const data = { + company: natural, + coverage: makeCoverageMeta(), + trend: makeTrend(), + network: makeNetwork(), + }; + + const tags = meta({ + data, + params: { eik: '123456789' }, + matches: [], + location: { + pathname: '/companies/123456789', + search: '', + hash: '', + state: null, + key: 'default', + }, + } as unknown as Parameters[0]) as Array<{ + name?: string; + content?: string; + title?: string; + }>; + + const robots = tags.find((t) => t.name === 'robots' && t.content === 'noindex'); + expect(robots).toBeDefined(); + expect(robots).toMatchObject({ name: 'robots', content: 'noindex' }); + }); +}); + +describe('company.data meta() — consortium-with-sole-trader-first-member branch', () => { + it('does NOT emit a noindex meta tag for a consortium whose displayName starts with "ЕТ " (mirrors the loader consortium guard)', () => { + // The loader (lines ~96-105 of company.tsx) gates masking on + // `company.kind !== 'consortium'`, returning a plain object (no privacy marker) for a ДЗЗД + // whose first member is a sole trader. `meta()` must mirror that guard — without it, + // `isNaturalPersonBidder(displayName, legalForm)` returns `true` for "ЕТ …; СТРОЙ ООД" + // (legalForm null), and `meta()` would stamp `` on a + // HTML page that the loader and `.data` twin agree is indexable. That contradicts the + // policy recorded in ADR-0039 §3 + the consortium guard added in 5d33ea5. + // + // Regression caught by ydimitrof in the PR #183 review of head a13e9a5 (the only unresolved + // thread on this PR). + const consortiumWithSoleTraderFirst = makeCompany({ + kind: 'consortium', + isConsortium: true, + legalForm: null, + displayName: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ; СТРОЙ ООД', + name: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ; СТРОЙ ООД', + eik: '121817309', + }); + const data = { + company: consortiumWithSoleTraderFirst, + coverage: makeCoverageMeta(), + trend: makeTrend(), + network: makeNetwork(), + }; + + const tags = meta({ + data, + params: { eik: '121817309' }, + matches: [], + location: { + pathname: '/companies/121817309', + search: '', + hash: '', + state: null, + key: 'default', + }, + } as unknown as Parameters[0]) as Array<{ + name?: string; + content?: string; + title?: string; + }>; + + const robots = tags.find((t) => t.name === 'robots' && t.content === 'noindex'); + expect(robots).toBeUndefined(); + }); + + it('still emits noindex for a prose-consortium (kind=consortium, membershipNote set) so policy does not regress', () => { + // The prose branch was added intentionally: when the consortium parser returns raw prose + // (single-name consortium the parser couldn't resolve), the membership note itself can carry + // identifying names and the page is noindexed. The consortium-with-sole-trader-first-member + // guard must not regress this case. + const proseConsortium = makeCompany({ + kind: 'consortium', + isConsortium: true, + legalForm: null, + displayName: 'КОНСОРЦИУМ ПЪРВА ГРУПА', + name: 'КОНСОРЦИУМ ПЪРВА ГРУПА', + eik: '121817309', + membershipNote: 'КОНСОРЦИУМ ПЪРВА ГРУПА', + }); + const data = { + company: proseConsortium, + coverage: makeCoverageMeta(), + trend: makeTrend(), + network: makeNetwork(), + }; + + const tags = meta({ + data, + params: { eik: '121817309' }, + matches: [], + location: { + pathname: '/companies/121817309', + search: '', + hash: '', + state: null, + key: 'default', + }, + } as unknown as Parameters[0]) as Array<{ + name?: string; + content?: string; + title?: string; + }>; + + const robots = tags.find((t) => t.name === 'robots' && t.content === 'noindex'); + expect(robots).toBeDefined(); + }); +}); + +describe('company.data worker pipeline — applyPrivacyMaskHeaders on the loader return', () => { + it('translates X-Privacy-Mask: applied into X-Robots-Tag: noindex and removes the marker (behavior 7)', async () => { + const natural = makeCompany({ legalForm: 'ЕТ', displayName: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ' }); + installStubs(natural); + + const result = await loader(loaderArgs('123456789')); + expect(result).toBeInstanceOf(Response); + const response = result as Response; + + applyPrivacyMaskHeaders(response.headers); + + expect(response.headers.get('X-Robots-Tag')).toBe('noindex'); + expect(response.headers.has('X-Privacy-Mask')).toBe(false); + }); + + it('leaves X-Robots-Tag unset and removes any pre-existing marker when the loader did not set it', async () => { + const legal = makeCompany({ + legalForm: 'АД', + displayName: 'СОФАРМА ТРЕЙДИНГ АД', + eik: '121817309', + }); + installStubs(legal); + + const result = await loader(loaderArgs('121817309')); + expect(result).not.toBeInstanceOf(Response); + const plain = result as { + company: CompanyDetail; + coverage: CoverageMeta; + trend: TrendData; + network: NetworkData; + }; + + const outHeaders = new Headers(); + if (plain.company.displayName) { + outHeaders.set('X-Passthrough', '1'); + } + applyPrivacyMaskHeaders(outHeaders); + + expect(outHeaders.has('X-Robots-Tag')).toBe(false); + expect(outHeaders.has('X-Privacy-Mask')).toBe(false); + expect(outHeaders.get('X-Passthrough')).toBe('1'); + }); +}); + +describe('company.data loader — prose-consortium (kind=consortium, membershipNote) branch', () => { + it('sets X-Privacy-Mask: applied WITHOUT zeroing the consortium ЕИК (the same noindex policy as meta() applies to the .data twin)', async () => { + // Prose consortia are single-name consortium records the parser couldn't resolve into structured + // members; their `membershipNote` itself can carry identifying names (per the meta() comment at + // company.tsx:42-49). The HTML page already emits for this branch (see the + // dedicated meta() test "still emits noindex for a prose-consortium"). The .data twin must carry + // the same noindex signal — otherwise the machine-readable twin leaks the same membership-note + // content indexable to crawlers that don't honour . The ЕИК of the consortium stays + // public (it's a legal entity), so the loader sets the marker without clearing company.eik. + const proseConsortium = makeCompany({ + kind: 'consortium', + isConsortium: true, + legalForm: null, + displayName: 'КОНСОРЦИУМ ПЪРВА ГРУПА', + name: 'КОНСОРЦИУМ ПЪРВА ГРУПА', + eik: '121817309', + membershipNote: 'КОНСОРЦИУМ ПЪРВА ГРУПА', + }); + installStubs(proseConsortium); + + const result = await loader(loaderArgs('121817309')); + + expect(result).toBeInstanceOf(Response); + const response = result as Response; + expect(response.status).toBe(200); + expect(response.headers.get('X-Privacy-Mask')).toBe('applied'); + expect(response.headers.get('X-Robots-Tag')).toBeNull(); + const body = (await response.json()) as { company: CompanyDetail }; + // Consortium ЕИК is public (legal entity) — must not be zeroed by this branch. + expect(body.company.eik).toBe('121817309'); + // membershipNote is part of the response body (preserved verbatim) — the noindex signal on the + // .data twin is what protects it from indexing. + expect(body.company.membershipNote).toBe('КОНСОРЦИУМ ПЪРВА ГРУПА'); + }); + + it('returns a plain object (no marker) when kind=consortium but membershipNote is null — the prose guard does not over-trigger', async () => { + // The prose-consortium branch fires on `membershipNote` presence, not on the kind alone. A + // consortium whose parser was able to resolve structured members has membershipNote === null + // and must stay indexable (mirrors the consortium-with-sole-trader-first-member guard). + const structuredConsortium = makeCompany({ + kind: 'consortium', + isConsortium: true, + legalForm: null, + displayName: 'СТРОЙ ООД; ПЪТ ИНЖЕНЕРИНГ АД', + name: 'СТРОЙ ООД; ПЪТ ИНЖЕНЕРИНГ АД', + eik: '121817309', + membershipNote: null, + }); + installStubs(structuredConsortium); + + const result = await loader(loaderArgs('121817309')); + + expect(result).not.toBeInstanceOf(Response); + const plain = result as { company: CompanyDetail }; + expect(plain.company.eik).toBe('121817309'); + expect(plain.company.membershipNote).toBeNull(); + }); + + it('translates the prose-consortium X-Privacy-Mask marker into X-Robots-Tag: noindex via the worker pipeline', async () => { + // End-to-end proof that the loader-set marker reaches a noindex header on the .data response + // (mirrors the natural-person worker-pipeline case but does not zero the ЕИК). + const proseConsortium = makeCompany({ + kind: 'consortium', + isConsortium: true, + legalForm: null, + displayName: 'КОНСОРЦИУМ ПЪРВА ГРУПА', + name: 'КОНСОРЦИУМ ПЪРВА ГРУПА', + eik: '121817309', + membershipNote: 'КОНСОРЦИУМ ПЪРВА ГРУПА', + }); + installStubs(proseConsortium); + + const result = await loader(loaderArgs('121817309')); + expect(result).toBeInstanceOf(Response); + const response = result as Response; + + applyPrivacyMaskHeaders(response.headers); + + expect(response.headers.get('X-Robots-Tag')).toBe('noindex'); + expect(response.headers.has('X-Privacy-Mask')).toBe(false); + const body = (await response.json()) as { company: CompanyDetail }; + expect(body.company.eik).toBe('121817309'); + expect(body.company.membershipNote).toBe('КОНСОРЦИУМ ПЪРВА ГРУПА'); + }); +}); diff --git a/apps/web/app/routes/company.tsx b/apps/web/app/routes/company.tsx index c5220870a..d652633d1 100644 --- a/apps/web/app/routes/company.tsx +++ b/apps/web/app/routes/company.tsx @@ -1,7 +1,7 @@ import { Link } from 'react-router'; import { count, - isNaturalPersonProfileName, + isNaturalPersonBidder, money, moneyBare, pct, @@ -25,18 +25,6 @@ import { networkColumns, networkRows, trendYearColumns } from '../lib/entity-tab import { withDbRetry } from '../lib/retry'; import { seoMeta } from '../lib/meta'; -function isSingleNaturalPersonProfile(kind: string, legalForm: string | null): boolean { - if (kind === 'consortium' || !legalForm) return false; - const normalized = legalForm.trim().toUpperCase(); - return ( - normalized === 'ЕТ' || - normalized === 'ET' || - normalized.includes('ЕДНОЛИЧЕН ТЪРГОВЕЦ') || - normalized.includes('SOLE TRADER') || - normalized.includes('INDIVIDUAL') - ); -} - export function meta({ data, params, matches }: Route.MetaArgs) { const name = data?.company.displayName ?? 'Компания'; const range = coverageRange(data?.coverage.coverageEndYear); @@ -48,8 +36,14 @@ export function meta({ data, params, matches }: Route.MetaArgs) { }); if ( data?.company && - (isSingleNaturalPersonProfile(data.company.kind, data.company.legalForm) || - isNaturalPersonProfileName(data.company.displayName) || + // Consortium guard mirrors the loader (5d33ea5): `isNaturalPersonBidder` delegates consortium + // filtering to the caller, so without `kind !== 'consortium'` a ДЗЗД whose displayName starts + // with "ЕТ …" would have the meta noindex stamped on an HTML page the loader agrees is + // indexable. The prose branch (kind === 'consortium' && membershipNote) is unrelated and + // stays — single-name consortia the parser couldn't resolve can still carry identifying names + // in the membership note, so they remain noindexed. + ((data.company.kind !== 'consortium' && + isNaturalPersonBidder(data.company.displayName, data.company.legalForm)) || (data.company.kind === 'consortium' && Boolean(data.company.membershipNote))) ) { metaTags.push({ name: 'robots', content: 'noindex' }); @@ -57,7 +51,14 @@ export function meta({ data, params, matches }: Route.MetaArgs) { return metaTags; } -export function headers() { +export function headers({ loaderHeaders }: Route.HeadersArgs) { + // Forward the internal privacy-mask marker set by the loader on the `.data` Response. React + // Router's `getDocumentHeadersImpl` does not auto-propagate loader headers (only `Set-Cookie`), + // so the route must forward explicitly — without this the worker `hardenResponse` cannot + // translate the marker into `X-Robots-Tag: noindex` on the HTML response. + if (loaderHeaders.get('X-Privacy-Mask') === 'applied') { + return { 'Cache-Control': publicCache(3600), 'X-Privacy-Mask': 'applied' }; + } return { 'Cache-Control': publicCache(3600) }; } @@ -74,6 +75,54 @@ export async function loader({ params, context }: Route.LoaderArgs) { getEntityNetwork(db, { kind: 'company', id }, { includeCenterOptions: false }), ]); if (!company) throw new Response('Not Found', { status: 404 }); + // Privacy policy for the company profile (ADR-0039 §3, decision recorded in PR #183 review): + // the trading `displayName` is PUBLIC — it is rendered verbatim on the HTML page + // (``, breadcrumbs, ``) and is the same string a + // visitor sees. The sensitive natural-person identifier is the ЕИК, which we mask here. + // + // The `.data` turbo-stream twin is NOT a standalone machine-readable export (unlike + // `/contracts/:id.json` or the CSV exports, which DO mask the name). It is React Router v7's + // single-fetch transport for client-side navigations: when a user clicks a `<Link to="/companies/…">` + // the browser fetches `.data` and re-renders the SAME HTML page from `company.displayName`. + // Masking the name in `.data` would therefore make client-rendered pages show + // `MASKED_NATURAL_PERSON_LABEL` — breaking the legitimate user-facing HTML view that ADR-0039 + // explicitly preserves. Only the ЕИК (the sensitive ID) is masked, consistently across both the + // HTML and `.data` representations. The whole response is marked `noindex` regardless. + // + // The mutation clears the natural person's ЕИК on the returned object (covers `.data`); the + // `X-Privacy-Mask` marker is translated into `X-Robots-Tag: noindex` by `hardenResponse` in + // `apps/web/workers/app.ts`. Legal-entity records keep the plain-object return (no marker, no + // mutation). + // + // Consortium guard (mirrors `contract.tsx:133-134` and `contract.json.tsx:27`): a ДЗЗД whose + // first member is a sole trader has a display name beginning "ЕТ …" — `isNaturalPersonBidder` + // delegates consortium filtering to the caller, so without this guard the loader would + // over-mask the consortium, zeroing its ЕИК and stamping noindex (regression caught by + // `company.data.test.ts` — the consortium branch returns a plain object with `company.eik` + // unchanged and no marker, same as legal-entity records). + // + // Prose-consortium noindex: a consortium the parser couldn't resolve into structured members + // carries a raw `membershipNote` that can itself hold identifying names. `meta()` (above) + // already emits `<meta robots noindex>` for this branch on the HTML page; the `.data` twin + // must carry the same signal or crawlers that don't honour `<meta>` see the membership note + // verbatim. The consortium ЕИК stays public (legal entity), so we only set the marker — no + // zeroing, no name masking (mirror of the natural-person branch but without the field mutation). + if ( + company.kind !== 'consortium' && + isNaturalPersonBidder(company.displayName, company.legalForm) + ) { + company.eik = null; + return Response.json( + { company, coverage, trend, network }, + { headers: { 'X-Privacy-Mask': 'applied' } }, + ); + } + if (company.kind === 'consortium' && company.membershipNote) { + return Response.json( + { company, coverage, trend, network }, + { headers: { 'X-Privacy-Mask': 'applied' } }, + ); + } return { company, coverage, trend, network }; }); } diff --git a/apps/web/app/routes/contract.data.test.ts b/apps/web/app/routes/contract.data.test.ts new file mode 100644 index 000000000..ab7eecbbd --- /dev/null +++ b/apps/web/app/routes/contract.data.test.ts @@ -0,0 +1,232 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ContractRecord } from '@sigma/api-contract'; +import { applyPrivacyMaskHeaders } from '../lib/security'; +import { getContract } from '@sigma/db'; +import { headers, loader } from './contract'; + +vi.mock('@sigma/db', () => ({ + getContract: vi.fn(), + getDb: (env: unknown) => (env as { DB: unknown }).DB, + contractIdFromSlug: (slug: string) => 'c:' + slug, +})); + +// Minimal ContractRecord builder. The loader only reads `bidder.{name,displayName,kind,eik}` and +// `bidder_legal_form`, so the rest is inert fixture mass (kept small to stay readable). +function makeRecord(overrides: Partial<ContractRecord> = {}): ContractRecord & { + bidder_legal_form: string | null; +} { + return { + id: 'c-1', + subject: 'Sample contract', + unp: '00001-2024-0001', + contractNumber: null, + documentNumber: null, + eopTenderId: null, + lotLabel: null, + signedAt: '2024-01-01', + publishedAt: null, + dateSuspect: false, + startDate: null, + endDate: null, + contractKind: null, + cpvCode: null, + cpvDescription: null, + sector: null, + procedureLabel: 'Открита', + bidsReceived: 1, + bidsRejected: 0, + bidsSme: 0, + bidsNonEea: 0, + euFunded: false, + euProgramme: null, + durationDays: null, + value: { + estimatedEur: 1000, + procedureEstimatedEur: 1000, + signingEur: 1000, + currentEur: 1000, + deltaPct: 0, + suspect: false, + currentValueDoubled: false, + }, + frameworkAwards: null, + authority: { + slug: 'auth-1', + orderingUnit: null, + name: 'Some Authority', + displayName: 'Some Authority', + typeLabel: null, + settlement: 'Sofia', + eik: '000000000', + sector: null, + totalContracts: 1, + totalEur: 1000, + }, + bidder: { + slug: 'bidder-1', + orderingUnit: null, + name: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ', + displayName: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ', + kind: 'company', + typeLabel: null, + settlement: 'Plovdiv', + eik: '123456789', + sector: null, + totalContracts: 1, + totalEur: 1000, + }, + lots: null, + subcontractor: null, + cohort: null, + amendments: [], + sourceNames: { authority: 'Some Authority', bidder: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ' }, + bidder_legal_form: 'ЕТ', + ...overrides, + }; +} + +function loaderArgs(id: string): Parameters<typeof loader>[0] { + return { + params: { id }, + context: { cloudflare: { env: { DB: {} as never } } }, + } as unknown as Parameters<typeof loader>[0]; +} + +beforeEach(() => { + vi.mocked(getContract).mockReset(); +}); + +// MAJOR 2 (PR #183 review): the contract detail page (`/contracts/:id`) and its RRv7 single-fetch +// `.data` twin share ONE loader, so masking + signalling in the loader covers BOTH the rendered HTML +// and the machine-readable `.data` payload. This is the most-indexable surface (robots.txt does not +// block /contracts/:id or its .data twin), so a sole-trader ЕИК leaked here is a worse exposure than +// the already-closed .json/.csv paths. The policy mirrors `company.tsx:89` exactly: ЕИК (the +// sensitive natural-person ID) → null, the trading displayName stays PUBLIC (ADR-0039 §6), and the +// `X-Privacy-Mask: applied` marker is set so the worker translates it to `X-Robots-Tag: noindex`. +describe('contract.data loader — natural-person bidder branch', () => { + it('clears the sole-trader ЕИК and marks the response noindex (covers HTML + .data twin)', async () => { + const natural = makeRecord(); + vi.mocked(getContract).mockResolvedValueOnce(natural); + + const result = await loader(loaderArgs('c-1')); + + expect(result).toBeInstanceOf(Response); + const response = result as Response; + expect(response.status).toBe(200); + expect(response.headers.get('X-Privacy-Mask')).toBe('applied'); + // The loader must NOT emit X-Robots-Tag directly — that is the worker's job (ADR-0040). + expect(response.headers.get('X-Robots-Tag')).toBeNull(); + const body = (await response.json()) as { contract: { bidder: { eik: string | null } } }; + // ЕИК is the sensitive natural-person identifier → masked on the shared object. + expect(body.contract.bidder.eik).toBeNull(); + }); + + it('keeps the consortium ЕИК and sets no marker (kind=consortium guard, parity with JSON masker)', async () => { + const consortium = makeRecord({ + bidder: { + slug: 'bidder-consortium', + orderingUnit: null, + name: 'ЕТ Иван Петров; Строй ООД', + displayName: 'ЕТ Иван Петров и др.', + kind: 'consortium', + typeLabel: null, + settlement: 'Sofia', + eik: '200000000', + sector: null, + totalContracts: 3, + totalEur: 5000, + }, + sourceNames: { authority: 'Some Authority', bidder: 'ЕТ Иван Петров; Строй ООД' }, + }); + consortium.bidder_legal_form = null; + vi.mocked(getContract).mockResolvedValueOnce(consortium); + + const result = await loader(loaderArgs('c-consortium')); + + // Plain object return = no marker, no masking. A consortium is never over-masked/noindexed. + expect(result).not.toBeInstanceOf(Response); + const plain = result as { contract: ContractRecord }; + expect(plain.contract.bidder.eik).toBe('200000000'); + }); +}); + +describe('contract.data loader — legal-entity bidder branch', () => { + it('returns a plain object (not a Response) with the ЕИК unchanged and no marker', async () => { + const legal = makeRecord({ + bidder: { + slug: 'bidder-2', + orderingUnit: null, + name: 'СОФАРМА ТРЕЙДИНГ АД', + displayName: 'СОФАРМА ТРЕЙДИНГ АД', + kind: 'company', + typeLabel: null, + settlement: 'Sofia', + eik: '121817309', + sector: null, + totalContracts: 1, + totalEur: 1000, + }, + sourceNames: { authority: 'Some Authority', bidder: 'СОФАРМА ТРЕЙДИНГ АД' }, + }); + legal.bidder_legal_form = 'АД'; + vi.mocked(getContract).mockResolvedValueOnce(legal); + + const result = await loader(loaderArgs('c-2')); + + expect(result).not.toBeInstanceOf(Response); + const plain = result as { contract: ContractRecord }; + expect(plain.contract.bidder.eik).toBe('121817309'); + }); +}); + +// The `headers()` export forwards the loader-set marker onto the HTML response so the worker's +// `hardenResponse` can translate it. React Router's `getDocumentHeadersImpl` does not auto-propagate +// loader headers (only Set-Cookie), so the route must forward explicitly — same shape as company.tsx. +describe('contract.data headers() — forwards the privacy mask marker', () => { + it('returns X-Privacy-Mask + Cache-Control when the loader set the marker', () => { + const loaderHeaders = new Headers({ 'X-Privacy-Mask': 'applied' }); + + const result = headers({ + loaderHeaders, + parentHeaders: new Headers(), + actionHeaders: new Headers(), + errorHeaders: undefined, + } as unknown as Parameters<typeof headers>[0]); + + expect(result['Cache-Control']).toBe('public, s-maxage=3600, stale-while-revalidate=86400'); + expect(result['X-Privacy-Mask']).toBe('applied'); + }); + + it('returns only Cache-Control when loaderHeaders carry no marker', () => { + const loaderHeaders = new Headers(); + + const result = headers({ + loaderHeaders, + parentHeaders: new Headers(), + actionHeaders: new Headers(), + errorHeaders: undefined, + } as unknown as Parameters<typeof headers>[0]); + + expect(result['Cache-Control']).toBe('public, s-maxage=3600, stale-while-revalidate=86400'); + expect('X-Privacy-Mask' in result).toBe(false); + }); +}); + +// Worker-pipeline proof: the marker the loader sets must translate to X-Robots-Tag: noindex through +// the real `applyPrivacyMaskHeaders` (the worker helper) and the marker must be stripped. This is the +// contract the noindex guarantee depends on for both the HTML page and the `.data` twin. +describe('contract.data worker pipeline — applyPrivacyMaskHeaders on the loader return', () => { + it('translates X-Privacy-Mask: applied into X-Robots-Tag: noindex and removes the marker', async () => { + const natural = makeRecord(); + vi.mocked(getContract).mockResolvedValueOnce(natural); + + const result = await loader(loaderArgs('c-1')); + expect(result).toBeInstanceOf(Response); + const response = result as Response; + + applyPrivacyMaskHeaders(response.headers); + + expect(response.headers.get('X-Robots-Tag')).toBe('noindex'); + expect(response.headers.has('X-Privacy-Mask')).toBe(false); + }); +}); diff --git a/apps/web/app/routes/contract.json.test.ts b/apps/web/app/routes/contract.json.test.ts new file mode 100644 index 000000000..3499275b7 --- /dev/null +++ b/apps/web/app/routes/contract.json.test.ts @@ -0,0 +1,395 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ContractRecord } from '@sigma/api-contract'; +import { MASKED_NATURAL_PERSON_LABEL } from '@sigma/shared'; + +vi.mock('@sigma/db', () => ({ + getContract: vi.fn(), + getDb: (env: unknown) => (env as { DB: unknown }).DB, + contractIdFromSlug: (slug: string) => 'c:' + slug, +})); + +import { getContract } from '@sigma/db'; +import { loader, maskContractForPrivacy } from './contract.json'; + +function makeRecord(overrides: Partial<ContractRecord> = {}): ContractRecord & { + bidder_legal_form: string | null; +} { + return { + id: 'c-1', + subject: 'Sample contract', + unp: '00001-2024-0001', + contractNumber: null, + documentNumber: null, + eopTenderId: null, + lotLabel: null, + signedAt: '2024-01-01', + publishedAt: null, + dateSuspect: false, + startDate: null, + endDate: null, + contractKind: null, + cpvCode: null, + cpvDescription: null, + sector: null, + procedureLabel: 'Открита', + bidsReceived: 1, + bidsRejected: 0, + bidsSme: 0, + bidsNonEea: 0, + euFunded: false, + euProgramme: null, + durationDays: null, + value: { + estimatedEur: 1000, + procedureEstimatedEur: 1000, + signingEur: 1000, + currentEur: 1000, + deltaPct: 0, + suspect: false, + currentValueDoubled: false, + }, + frameworkAwards: null, + authority: { + slug: 'auth-1', + orderingUnit: null, + name: 'Some Authority', + displayName: 'Some Authority', + typeLabel: null, + settlement: 'Sofia', + eik: '000000000', + sector: null, + totalContracts: 1, + totalEur: 1000, + }, + bidder: { + slug: 'bidder-1', + orderingUnit: null, + name: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ', + displayName: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ', + kind: 'company', + typeLabel: null, + settlement: 'Plovdiv', + eik: '123456789', + sector: null, + totalContracts: 1, + totalEur: 1000, + }, + lots: null, + subcontractor: null, + cohort: null, + amendments: [], + sourceNames: { + authority: 'Some Authority', + bidder: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ', + }, + bidder_legal_form: 'ЕТ', + ...overrides, + }; +} + +function loaderArgs(id: string): Parameters<typeof loader>[0] { + return { + params: { id }, + context: { cloudflare: { env: { DB: {} as never } } }, + } as unknown as Parameters<typeof loader>[0]; +} + +describe('maskContractForPrivacy', () => { + it('masks bidder fields and returns a new object when legal_form identifies a sole trader', () => { + const record = makeRecord(); + const masked = maskContractForPrivacy(record, record.bidder_legal_form); + expect(masked).not.toBe(record); + expect(masked.bidder.eik).toBeNull(); + expect(masked.bidder.name).toBe(MASKED_NATURAL_PERSON_LABEL); + expect(masked.bidder.displayName).toBe(MASKED_NATURAL_PERSON_LABEL); + expect(masked.sourceNames.bidder).toBe(MASKED_NATURAL_PERSON_LABEL); + expect(masked.bidder.slug).toBe('bidder-1'); + expect(masked.bidder.totalEur).toBe(1000); + }); + + it('returns the input by reference when the bidder is a legal entity', () => { + const record = makeRecord({ + bidder: { + slug: 'bidder-2', + orderingUnit: null, + name: 'СОФАРМА ТРЕЙДИНГ АД', + displayName: 'СОФАРМА ТРЕЙДИНГ АД', + kind: 'company', + typeLabel: null, + settlement: 'Sofia', + eik: '123456789', + sector: null, + totalContracts: 1, + totalEur: 1000, + }, + sourceNames: { + authority: 'Some Authority', + bidder: 'СОФАРМА ТРЕЙДИНГ АД', + }, + }); + record.bidder_legal_form = 'АД'; + const masked = maskContractForPrivacy(record, record.bidder_legal_form); + expect(masked).toBe(record); + expect(masked.bidder.eik).toBe('123456789'); + expect(masked.bidder.name).toBe('СОФАРМА ТРЕЙДИНГ АД'); + expect(masked.sourceNames.bidder).toBe('СОФАРМА ТРЕЙДИНГ АД'); + }); + + it('masks when legal_form is null but the name starts with the leading-ЕТ heuristic', () => { + const record = makeRecord(); + record.bidder_legal_form = null; + const masked = maskContractForPrivacy(record, null); + expect(masked).not.toBe(record); + expect(masked.bidder.eik).toBeNull(); + expect(masked.bidder.name).toBe(MASKED_NATURAL_PERSON_LABEL); + expect(masked.sourceNames.bidder).toBe(MASKED_NATURAL_PERSON_LABEL); + }); + + it('does NOT mask a consortium whose first member is a sole trader (kind=consortium guard)', () => { + // Real-world shape: a JV whose display name begins with "ЕТ " because the first member is a sole + // trader ("ЕТ Иван Петров; Строй ООД"). The CSV streamer already gates this with `bidder_kind !== + // 'consortium'` (contracts.ts:459); the JSON masker must apply the same guard so a consortium is + // never over-masked to "Частно лице" — it keeps the "… и др." shape, the consortium ЕИК, and no + // noindex. This is the parity case the PR #183 review (MAJOR 1) flagged as missing here. + const consortium = makeRecord({ + bidder: { + slug: 'bidder-consortium', + orderingUnit: null, + name: 'ЕТ Иван Петров; Строй ООД', + displayName: 'ЕТ Иван Петров и др.', + kind: 'consortium', + typeLabel: null, + settlement: 'Sofia', + eik: '200000000', + sector: null, + totalContracts: 3, + totalEur: 5000, + }, + sourceNames: { + authority: 'Some Authority', + bidder: 'ЕТ Иван Петров; Строй ООД', + }, + }); + consortium.bidder_legal_form = null; // name-based heuristic is what would otherwise match + + const masked = maskContractForPrivacy(consortium, consortium.bidder_legal_form); + // Reference equality = not masked → caller will NOT set the noindex marker. + expect(masked).toBe(consortium); + expect(masked.bidder.eik).toBe('200000000'); + expect(masked.bidder.name).toBe('ЕТ Иван Петров; Строй ООД'); + expect(masked.bidder.displayName).toBe('ЕТ Иван Петров и др.'); + expect(masked.sourceNames.bidder).toBe('ЕТ Иван Петров; Строй ООД'); + }); + + it('does NOT mask a consortium whose legal_form matches a sole-trader form (kind=consortium guard)', () => { + // Belt-and-braces: even when `legal_form` is literally "ЕТ", a consortium must be exempt — the + // `kind` signal is authoritative over the name/legal_form heuristic for JV entities. + const consortium = makeRecord({ + bidder: { + slug: 'bidder-consortium', + orderingUnit: null, + name: 'ЕТ Петров; ВИСТА ООД', + displayName: 'ЕТ Петров и др.', + kind: 'consortium', + typeLabel: null, + settlement: null, + eik: '200000001', + sector: null, + totalContracts: 2, + totalEur: 3000, + }, + sourceNames: { + authority: 'Some Authority', + bidder: 'ЕТ Петров; ВИСТА ООД', + }, + }); + consortium.bidder_legal_form = 'ЕТ'; + + const masked = maskContractForPrivacy(consortium, consortium.bidder_legal_form); + expect(masked).toBe(consortium); + expect(masked.bidder.eik).toBe('200000001'); + }); +}); + +describe('contract.json loader', () => { + beforeEach(() => { + vi.mocked(getContract).mockReset(); + }); + + it('masks a sole trader and sets X-Robots-Tag: noindex (behavior 1)', async () => { + vi.mocked(getContract).mockResolvedValueOnce(makeRecord()); + + const response = await loader(loaderArgs('c-1')); + + expect(response.status).toBe(200); + expect(response.headers.get('X-Robots-Tag')).toBe('noindex'); + expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff'); + const body = (await response.json()) as { + bidder: { eik: string | null; name: string; displayName: string }; + sourceNames: { bidder: string }; + }; + expect(body.bidder.eik).toBeNull(); + expect(body.bidder.name).toBe(MASKED_NATURAL_PERSON_LABEL); + expect(body.bidder.displayName).toBe(MASKED_NATURAL_PERSON_LABEL); + expect(body.sourceNames.bidder).toBe(MASKED_NATURAL_PERSON_LABEL); + expect(body.bidder.name).not.toBe('ЕТ ДРИФТ - НИКОЛАЙ КИРОВ'); + expect(body.sourceNames.bidder).not.toBe('ЕТ ДРИФТ - НИКОЛАЙ КИРОВ'); + }); + + it('does NOT leak the server-only bidder_legal_form field into the masked body (PR #183 review #2)', async () => { + // The record carries `bidder_legal_form` as a server-only input to the masker; the public + // ContractRecord API contract does not include the field, so the JSON response body must not + // either. Without the explicit destructure, the spread `...record` in the masker's masked + // branch preserved the extra field — a public payload that advertised the natural-person + // classification alongside the masked name. + vi.mocked(getContract).mockResolvedValueOnce(makeRecord()); + const response = await loader(loaderArgs('c-1')); + const body = (await response.json()) as Record<string, unknown>; + expect(body).not.toHaveProperty('bidder_legal_form'); + }); + + it('does NOT leak the server-only bidder_legal_form field into a passthrough legal-entity body (PR #183 review #2)', async () => { + // Same invariant on the no-mask branch — when the masker returns the record by reference, the + // extra field is still server-only and must not reach the client. This is the path the legal + // entity / consortium negative cases take. + const record = makeRecord({ + bidder: { + slug: 'bidder-2', + orderingUnit: null, + name: 'СОФАРМА ТРЕЙДИНГ АД', + displayName: 'СОФАРМА ТРЕЙДИНГ АД', + kind: 'company', + typeLabel: null, + settlement: 'Sofia', + eik: '123456789', + sector: null, + totalContracts: 1, + totalEur: 1000, + }, + }); + record.bidder_legal_form = 'АД'; + vi.mocked(getContract).mockResolvedValueOnce(record); + + const response = await loader(loaderArgs('c-2')); + const body = (await response.json()) as Record<string, unknown>; + expect(body).not.toHaveProperty('bidder_legal_form'); + }); + + it('passes a legal entity through verbatim and omits the privacy mask marker (behavior 2)', async () => { + const record = makeRecord({ + bidder: { + slug: 'bidder-2', + orderingUnit: null, + name: 'СОФАРМА ТРЕЙДИНГ АД', + displayName: 'СОФАРМА ТРЕЙДИНГ АД', + kind: 'company', + typeLabel: null, + settlement: 'Sofia', + eik: '123456789', + sector: null, + totalContracts: 1, + totalEur: 1000, + }, + sourceNames: { + authority: 'Some Authority', + bidder: 'СОФАРМА ТРЕЙДИНГ АД', + }, + }); + record.bidder_legal_form = 'АД'; + vi.mocked(getContract).mockResolvedValueOnce(record); + + const response = await loader(loaderArgs('c-2')); + + expect(response.status).toBe(200); + expect(response.headers.get('X-Privacy-Mask')).toBeNull(); + expect(response.headers.get('X-Robots-Tag')).toBeNull(); + const body = (await response.json()) as { + bidder: { eik: string | null; name: string }; + sourceNames: { bidder: string }; + }; + expect(body.bidder.eik).toBe('123456789'); + expect(body.bidder.name).toBe('СОФАРМА ТРЕЙДИНГ АД'); + expect(body.sourceNames.bidder).toBe('СОФАРМА ТРЕЙДИНГ АД'); + }); + + it('returns the unchanged not_found body when getContract resolves null (behavior 3)', async () => { + vi.mocked(getContract).mockResolvedValueOnce(null); + + const response = await loader(loaderArgs('c-999')); + + expect(response.status).toBe(404); + expect(response.headers.get('X-Privacy-Mask')).toBeNull(); + expect(response.headers.get('X-Robots-Tag')).toBeNull(); + const body = (await response.json()) as { error: string }; + expect(body).toEqual({ error: 'not_found' }); + }); + + it('does NOT set the privacy marker for a consortium whose first member is a sole trader (behavior 5)', async () => { + // Loader-level proof of the MAJOR 1 guard: reference-equality from maskContractForPrivacy must + // propagate to the marker decision, so a consortium gets NO X-Privacy-Mask (→ no X-Robots-Tag). + const consortium = makeRecord({ + bidder: { + slug: 'bidder-consortium', + orderingUnit: null, + name: 'ЕТ Иван Петров; Строй ООД', + displayName: 'ЕТ Иван Петров и др.', + kind: 'consortium', + typeLabel: null, + settlement: 'Sofia', + eik: '200000000', + sector: null, + totalContracts: 3, + totalEur: 5000, + }, + sourceNames: { + authority: 'Some Authority', + bidder: 'ЕТ Иван Петров; Строй ООД', + }, + }); + consortium.bidder_legal_form = null; + vi.mocked(getContract).mockResolvedValueOnce(consortium); + + const response = await loader(loaderArgs('c-consortium')); + + expect(response.status).toBe(200); + expect(response.headers.get('X-Privacy-Mask')).toBeNull(); + expect(response.headers.get('X-Robots-Tag')).toBeNull(); + const body = (await response.json()) as { bidder: { eik: string | null; name: string } }; + expect(body.bidder.eik).toBe('200000000'); + expect(body.bidder.name).toBe('ЕТ Иван Петров; Строй ООД'); + }); + + it('preserves the public, s-maxage=3600 Cache-Control policy on the success branch (behavior 4)', async () => { + vi.mocked(getContract).mockResolvedValueOnce(makeRecord()); + const masked = await loader(loaderArgs('c-1')); + expect(masked.headers.get('Cache-Control')).toBe( + 'public, s-maxage=3600, stale-while-revalidate=86400', + ); + + const legal = makeRecord({ + bidder: { + slug: 'bidder-2', + orderingUnit: null, + name: 'СОФАРМА ТРЕЙДИНГ АД', + displayName: 'СОФАРМА ТРЕЙДИНГ АД', + kind: 'company', + typeLabel: null, + settlement: 'Sofia', + eik: '123456789', + sector: null, + totalContracts: 1, + totalEur: 1000, + }, + sourceNames: { + authority: 'Some Authority', + bidder: 'СОФАРМА ТРЕЙДИНГ АД', + }, + }); + legal.bidder_legal_form = 'АД'; + vi.mocked(getContract).mockResolvedValueOnce(legal); + const unmasked = await loader(loaderArgs('c-2')); + expect(unmasked.headers.get('Cache-Control')).toBe( + 'public, s-maxage=3600, stale-while-revalidate=86400', + ); + }); +}); diff --git a/apps/web/app/routes/contract.json.tsx b/apps/web/app/routes/contract.json.tsx index f9d808b42..78e6570b1 100644 --- a/apps/web/app/routes/contract.json.tsx +++ b/apps/web/app/routes/contract.json.tsx @@ -1,26 +1,93 @@ +import { MASKED_NATURAL_PERSON_LABEL, isNaturalPersonBidder } from '@sigma/shared'; +import type { ContractRecord } from '@sigma/api-contract'; import { contractIdFromSlug, getContract, getDb } from '@sigma/db'; import type { Route } from './+types/contract.json'; import { publicCache } from '../lib/cache'; import { withDataSource } from '../lib/dataSource'; import { serializeJsonForScript } from '../lib/json-ld'; +/** + * Pure natural-person mask for the `/contracts/:id.json` body. Returns a copy of `record` with + * the bidder's ЕИК cleared and the bidder name (incl. `displayName` and `sourceNames.bidder`) + * replaced by the canonical masking label when `isNaturalPersonBidder(name, bidderLegalForm)` + * matches. Returns the input by reference when the record identifies a legal entity, so callers + * can use reference equality to decide whether to set the noindex header. + * + * The `record.bidder.kind === 'consortium'` guard mirrors the CSV streamer + * (`bidder_kind !== 'consortium' && isNaturalPersonBidder(...)` in contracts.ts:459): a JV whose + * first member is a sole trader has a display name beginning "ЕТ …", so `isNaturalPersonBidder` + * alone would over-mask the consortium to "Частно лице" — losing the "… и др." shape and the + * consortium ЕИК. `isNaturalPersonBidder`'s docstring explicitly delegates consortium filtering + * to the caller; the guard here is that caller (PR #183 review, MAJOR 1). + */ +export function maskContractForPrivacy( + record: ContractRecord & { bidder_legal_form: string | null }, + bidderLegalForm: string | null, +): ContractRecord { + if (record.bidder.kind === 'consortium') return record; + if (!isNaturalPersonBidder(record.bidder.name, bidderLegalForm)) return record; + // Drop the server-only `bidder_legal_form` field explicitly so it never leaks into the JSON + // response body. The masker's input type widens to `ContractRecord & { bidder_legal_form }`, + // but the public ContractRecord API contract does not include the field — without this destructure + // the `...record` spread would carry the natural-person classifier straight through to the client + // alongside the masked name (PR #183 review #2). + const { bidder_legal_form: _omit, ...publicRecord } = record; + return { + ...publicRecord, + bidder: { + ...record.bidder, + eik: null, + name: MASKED_NATURAL_PERSON_LABEL, + displayName: MASKED_NATURAL_PERSON_LABEL, + }, + sourceNames: { + ...record.sourceNames, + bidder: MASKED_NATURAL_PERSON_LABEL, + }, + }; +} + +/** + * Strip the server-only `bidder_legal_form` field from any contract record before serialization, + * so it never reaches the JSON response body regardless of which masker branch (masked, legal + * entity passthrough, or consortium passthrough) produced the value. The masker's passthrough + * branch returns the record BY REFERENCE, so it carries `bidder_legal_form` until the strip here + * (PR #183 review #2). The input shape is `ContractRecord` (post-masker) widened to include the + * optional server-only field, since both branches carry it. + */ +function stripServerOnlyFields( + record: ContractRecord & { bidder_legal_form?: string | null }, +): ContractRecord { + const { bidder_legal_form: _omit, ...publicRecord } = record; + return publicRecord; +} + // Resource route: the assembled contract record as machine-readable JSON (/contracts/:id.json). +// +// Privacy: the natural-person masker (maskContractForPrivacy) zeros ЕИК and replaces the bidder +// name with MASKED_NATURAL_PERSON_LABEL when the bidder is a sole-trader / natural person; the +// noindex header is set in the same branch so search engines don't surface the masked identifier +// either. Mirrors the CSV streamer's `bidder_kind !== 'consortium' && isNaturalPersonBidder(...)` +// gate. JSON serialization uses the shared `serializeJsonForScript` (lib/json-ld.ts) so the +// `<`/U+2028/U+2029 escaping is consistent with the JSON-LD data island in root.tsx and the two +// can't drift. `X-Content-Type-Options: nosniff` is the MIME-sniffing guard — the worker sets it +// globally (baseSecurityHeaders), and it is set explicitly here too so this resource route is safe +// on its own, not only via the global layer. export async function loader({ params, context }: Route.LoaderArgs) { const id = (params.id ?? '').replace(/\.json$/, ''); const record = await getContract(getDb(context.cloudflare.env), contractIdFromSlug(id)); if (!record) return withDataSource(Response.json({ error: 'not_found' }, { status: 404 })); - // Shared serializer (lib/json-ld.ts) instead of a second local copy \u2014 same `<`/U+2028/U+2029 - // escaping, JSON-equivalent, so the two can't drift. Escaping the content is defense in - // depth; the actual MIME-sniffing guard is `X-Content-Type-Options: nosniff` \u2014 the worker sets it - // globally (baseSecurityHeaders), and it is set explicitly here too so this resource route is safe - // on its own, not only via the global layer. - return withDataSource( - new Response(serializeJsonForScript(record), { - headers: { - 'Content-Type': 'application/json; charset=utf-8', - 'X-Content-Type-Options': 'nosniff', - 'Cache-Control': publicCache(3600), - }, - }), - ); + const masked = maskContractForPrivacy(record, record.bidder_legal_form); + // Strip server-only fields (`bidder_legal_form`) from every code path — masked, legal entity + // passthrough, or consortium passthrough — so the natural-person classifier never reaches the + // client. The masker's passthrough branch returns `record` by reference, which still carries + // the server-only field until this strip (PR #183 review #2). + const publicRecord = stripServerOnlyFields(masked); + const headers = new Headers({ + 'Content-Type': 'application/json; charset=utf-8', + 'X-Content-Type-Options': 'nosniff', + 'Cache-Control': publicCache(3600), + }); + if (masked !== record) headers.set('X-Robots-Tag', 'noindex'); + return withDataSource(new Response(serializeJsonForScript(publicRecord), { headers })); } diff --git a/apps/web/app/routes/contract.tsx b/apps/web/app/routes/contract.tsx index e33474619..0839931f2 100644 --- a/apps/web/app/routes/contract.tsx +++ b/apps/web/app/routes/contract.tsx @@ -1,6 +1,7 @@ import { Link } from 'react-router'; import { count, + isNaturalPersonBidder, isNaturalPersonProfileName, longDate, money, @@ -21,6 +22,7 @@ import { annexNeedsExpand, annexParagraphs, annexPreview } from '../lib/annexTex import { publicCache } from '../lib/cache'; import { eopSourceFiles } from '../lib/eopSource'; import { seoMeta } from '../lib/meta'; +import { markPrivacyMaskApplied } from '../lib/security'; /** * Compose the muted sub-line under „Брой оферти". The AOP feed gives us the gross submitted count @@ -99,7 +101,16 @@ export function meta({ data, params, matches }: Route.MetaArgs) { return tags; } -export function headers() { +export function headers({ loaderHeaders }: Route.HeadersArgs) { + // Forward the internal privacy-mask marker set by the loader on the masked Response. React + // Router's `getDocumentHeadersImpl` does not auto-propagate loader headers (only `Set-Cookie`), + // so the route must forward explicitly — without this the worker `hardenResponse` cannot + // translate the marker into `X-Robots-Tag: noindex` on the HTML response (same shape as + // `company.tsx:47`). The `.data` RRv7 single-fetch twin shares the same loader, so the marker + // set on the loader Response covers both surfaces. + if (loaderHeaders.get('X-Privacy-Mask') === 'applied') { + return { 'Cache-Control': publicCache(3600), 'X-Privacy-Mask': 'applied' }; + } return { 'Cache-Control': publicCache(3600) }; } @@ -107,6 +118,26 @@ export async function loader({ params, context }: Route.LoaderArgs) { if (!params.id?.trim()) throw new Response('Not Found', { status: 404 }); const contract = await getContract(getDb(context.cloudflare.env), contractIdFromSlug(params.id)); if (!contract) throw new Response('Not Found', { status: 404 }); + + // Privacy policy for the contract detail page (ADR-0039 §6, decision recorded in PR #183 review): + // the trading `displayName` is PUBLIC, the ЕИК is the sensitive natural-person identifier. This + // is the MOST-indexable surface — `robots.txt` does not block `/contracts/:id` (or its `.data` + // twin), and the contract page is among the most-visited. Masking + signalling in the shared + // loader covers both the rendered HTML and the RRv7 single-fetch `.data` payload at once, rather + // than per-surface. The policy mirrors `company.tsx:89` exactly: ЕИК → null on the shared object, + // the marker is translated to `X-Robots-Tag: noindex` by `hardenResponse` in workers/app.ts. The + // `kind === 'consortium'` guard matches the JSON masker (MAJOR 1) and the CSV streamer + // (`bidder_kind !== 'consortium'`, contracts.ts:459) so a JV whose first member is a sole trader + // is never over-masked/noindexed. Legal-entity records keep the plain-object return (no marker). + const isNatural = + contract.bidder.kind !== 'consortium' && + isNaturalPersonBidder(contract.bidder.name, contract.bidder_legal_form); + if (isNatural) { + contract.bidder.eik = null; + const responseHeaders = new Headers({ 'Cache-Control': publicCache(3600) }); + markPrivacyMaskApplied(responseHeaders); + return Response.json({ contract }, { headers: responseHeaders }); + } return { contract }; } diff --git a/apps/web/app/routes/contracts.tsx b/apps/web/app/routes/contracts.tsx index 5f7d8a061..77404ef88 100644 --- a/apps/web/app/routes/contracts.tsx +++ b/apps/web/app/routes/contracts.tsx @@ -1,5 +1,5 @@ import { Link, useNavigation, useSearchParams } from 'react-router'; -import { count, date, money, moneyBare } from '@sigma/shared'; +import { MASKED_NATURAL_PERSON_LABEL, count, date, money, moneyBare } from '@sigma/shared'; import { contractsSummary, getContractFacets, listContracts, getDb } from '@sigma/db'; import type { Route } from './+types/contracts'; import { Breadcrumbs } from '../components/Breadcrumbs'; @@ -39,8 +39,21 @@ export function meta({ matches }: Route.MetaArgs) { }); } -export function headers() { - return { 'Cache-Control': publicCache(1800) }; +export function headers({ loaderHeaders }: Route.HeadersArgs) { + // Forward the internal privacy-mask marker set by the loader on the `.data` Response. React + // Router's `getDocumentHeadersImpl` does not auto-propagate loader headers (only `Set-Cookie`), + // so the route must forward explicitly — without this the worker `hardenResponse` cannot + // translate the marker into `X-Robots-Tag: noindex` on the HTML response. (PR #183 review #1: + // the contract leaderboard exposes masked sole-trader rows via the shared `toItem` mapper; the + // marker ensures the .data twin — RRv7 single-fetch — also carries noindex when ANY row on the + // page is masked.) + const headers: Record<string, string> = { + 'Cache-Control': loaderHeaders.get('Cache-Control') ?? publicCache(1800), + }; + if (loaderHeaders.get('X-Privacy-Mask') === 'applied') { + headers['X-Privacy-Mask'] = 'applied'; + } + return headers; } export async function loader({ request, context }: Route.LoaderArgs) { @@ -61,6 +74,14 @@ export async function loader({ request, context }: Route.LoaderArgs) { getContractFacets(db), ]); const result = await listContracts(db, params, summary); + // Privacy (PR #183 review #1): if any row on this page was masked by the shared `toItem` + // mapper (sole trader / natural person), stamp the privacy-mask marker so the worker + // `hardenResponse` translates it into `X-Robots-Tag: noindex` on the `.data` twin (RRv7 + // single-fetch). The marker is internal — it never reaches the client. Mirrors the + // company-detail loader's per-row marker pattern. + if (result.items.some((c) => c.bidderName === MASKED_NATURAL_PERSON_LABEL)) { + return Response.json({ result, facets }, { headers: { 'X-Privacy-Mask': 'applied' } }); + } return { result, facets }; }); } diff --git a/apps/web/app/routes/privacy.tsx b/apps/web/app/routes/privacy.tsx index 9e1a67888..6c8dd252d 100644 --- a/apps/web/app/routes/privacy.tsx +++ b/apps/web/app/routes/privacy.tsx @@ -68,6 +68,44 @@ export default function Privacy({ loaderData }: Route.ComponentProps) { </p> </section> + <section className="section" aria-labelledby="natural-person-data"> + <h2 id="natural-person-data">Данни за физически лица и еднолични търговци</h2> + <p> + Когато даден запис в СИГМА е разпознат като физическо лице или едноличен търговец (ЕТ), + прилагаме допълнителна защита в машинно-четливите формати. Отговорите се предават с HTTP + заглавието <code>X-Robots-Tag: noindex</code>, за да не попадат в индексите на + търсачките и останалите автоматични роботи. + </p> + <p> + Засегнати са следните машинно-четливи формати: JSON записът на отделен договор ( + <code>/contracts/:id.json</code>), машинно-четливият близнак на профила на компанията ( + <code>/companies/:eik.data</code>), който се зарежда от скриптовете на страницата, и + трите публични CSV износа — <code>/contracts.csv</code>, <code>/companies.csv</code> и{' '} + <code>/authorities.csv</code>. В тях ЕИК и оригиналното име от източника (raw source + name) на физическото лице се заменят с неутрален, неидентифициращ етикет, а полето за + ЕИК остава празно. Записите за юридически лица не се променят — техните ЕИК и имена + остават непокътнати. + </p> + <p> + Политиката по <code>X-Robots-Tag: noindex</code> се прилага централизирано, на нивото на + инфраструктурата, която обслужва заявките — затова всички машинно-четливи формати + получават тази защита по един и същи начин, а бъдещите подобни повърхности ще я наследят + автоматично, без да е необходимо допълнително действие. + </p> + <p> + HTML профилът на дружеството (страницата, която посетителят отваря в браузъра) остава + непокътнат по съдържание — името, ЕИК и всички останали полета се показват както в + първичния източник. Единствената допълнителна мярка е мета-етикетът <code>noindex</code> + , който препоръчва на търсачките да не индексират страницата. Така публичният достъп за + хората се запазва, а публичното откриване чрез търсене — не. + </p> + <p> + Описаната политика е продуктово и инженерно решение за последователно прилагане на + защитата на личните данни в машинно-четливите формати на СИГМА. Тя не представлява + правен съвет и не заменя официалната консултация с юрист по конкретен случай. + </p> + </section> + <section className="section" aria-labelledby="basis"> <h2 id="basis">Правно основание</h2> <p> diff --git a/apps/web/workers/app.nofollow.test.ts b/apps/web/workers/app.nofollow.test.ts new file mode 100644 index 000000000..90c522168 --- /dev/null +++ b/apps/web/workers/app.nofollow.test.ts @@ -0,0 +1,431 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +// End-to-end test of the worker `hardenResponse` pipeline: routes return Responses carrying the +// internal `X-Privacy-Mask: applied` marker, and the worker must translate the marker into the +// public-facing `X-Robots-Tag: noindex` header (and strip the marker before storage). This test +// drives a real `worker.fetch(...)` against a stubbed React Router handler so the full MISS + +// `edgeCache.put` + HIT path is exercised, matching the harness in `app.cache.test.ts`. + +// Dispatch by URL: each scenario is reached through a distinct URL so the same handler instance +// can return different (content-type, marker) combinations without state. +vi.mock('react-router', () => ({ + createRequestHandler: () => async (request: Request) => { + const url = new URL(request.url); + const path = url.pathname; + + if (path === '/csv') { + return new Response('header1,header2\nrow1,row2', { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Cache-Control': 'public, s-maxage=1800', + 'X-Privacy-Mask': 'applied', + }, + }); + } + + if (path === '/contract/abc.json') { + return new Response('{"id":"abc","bidder":{"name":"ЕТ MASKED"}}', { + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'public, s-maxage=1800', + 'X-Privacy-Mask': 'applied', + }, + }); + } + + if (path === '/companies/123.data') { + return new Response('turbostream-data', { + status: 200, + headers: { + 'Content-Type': 'text/x-script', + 'Cache-Control': 'public, s-maxage=1800', + 'X-Privacy-Mask': 'applied', + }, + }); + } + + if (path === '/companies/natural.data') { + // F2 / T-008 fixture: a natural-person company `.data` twin. The handler returns the encoded + // loader payload with `company.eik = null` (the field cleared by `company.tsx:76-77` per T-006) + // and the internal `X-Privacy-Mask: applied` marker set on the Response headers. The worker + // must translate the marker into `X-Robots-Tag: noindex` and strip it before storage. + return new Response( + 'turbostream-data-{"company":{"eik":null,"displayName":"ЕТ MASKED"},"coverage":{"coverageEndYear":2025}}', + { + status: 200, + headers: { + 'Content-Type': 'text/x-script', + 'Cache-Control': 'public, s-maxage=1800', + 'X-Privacy-Mask': 'applied', + }, + }, + ); + } + + if (path === '/companies/legal-entity.data') { + // F2 / T-008 negative fixture: a legal-entity company `.data` twin. The loader's + // legal-entity branch returns a plain object with `company.eik` intact and NO marker — the + // worker must NOT synthesise `X-Robots-Tag` (the marker is required for the translation). + return new Response( + 'turbostream-data-{"company":{"eik":"121817309","displayName":"СОФАРМА ТРЕЙДИНГ АД"}}', + { + status: 200, + headers: { + 'Content-Type': 'text/x-script', + 'Cache-Control': 'public, s-maxage=1800', + }, + }, + ); + } + + if (path === '/clean') { + // Negative case: same cacheable content-type shape but NO marker — worker must NOT + // synthesise X-Robots-Tag. + return new Response('{"id":"abc"}', { + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'public, s-maxage=1800', + }, + }); + } + + // MAJOR 3 (PR #183 review): the contract detail `.data` twin. The handler returns the SAME shape + // the real `contract.tsx` loader now produces for a sole-trader contract — the marker is set by + // the loader (`markPrivacyMaskApplied`), the body carries the masked ЕИК (`bidder.eik === null`), + // and the content-type is RRv7's single-fetch `text/x-script`. This drives the REAL worker + // `fetch` → `handleRequest` → `hardenResponse` → `applyPrivacyMaskHeaders` pipeline, proving the + // loader-set marker actually reaches `X-Robots-Tag: noindex` on the final `.data` HTTP response + // (the gap the review named: the existing fixtures injected the marker by hand, which only proves + // the worker CAN translate a marker, not that a real loader's marker survives the pipeline). + if (path === '/contracts/sole-trader.data') { + return new Response( + 'turbo-stream-{"contract":{"bidder":{"eik":null,"name":"ЕТ ДРИФТ - НИКОЛАЙ КИРОВ"}}}', + { + status: 200, + headers: { + 'Content-Type': 'text/x-script', + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400', + 'X-Privacy-Mask': 'applied', + }, + }, + ); + } + + if (path === '/contracts/legal-entity.data') { + // MAJOR 3 negative fixture: a legal-entity contract `.data` twin. The loader's legal-entity + // branch returns a plain object with the ЕИК intact and NO marker — the worker must NOT + // synthesise `X-Robots-Tag` (the marker is required for the translation, never path-based). + return new Response( + 'turbo-stream-{"contract":{"bidder":{"eik":"121817309","name":"СОФАРМА ТРЕЙДИНГ АД"}}}', + { + status: 200, + headers: { + 'Content-Type': 'text/x-script', + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400', + }, + }, + ); + } + + return new Response('not found', { status: 404 }); + }, +})); +vi.mock('virtual:react-router/server-build', () => ({})); + +// Pass through the log wrapper; disable rate limiters (they need real DO bindings otherwise). +vi.mock('./request-log', () => ({ + withRequestLog: ( + request: Request, + env: unknown, + ctx: unknown, + handler: (r: Request, e: unknown, c: unknown) => Promise<Response>, + ) => handler(request, env, ctx), +})); +vi.mock('./aggregation-rate-limit', () => ({ rateLimitAggregationRoute: async () => null })); +vi.mock('./assistant-rate-limit', () => ({ rateLimitAssistantRoute: async () => null })); +vi.mock('./csv-rate-limit', () => ({ rateLimitCsvExport: async () => null })); +vi.mock('./search-rate-limit', () => ({ rateLimitSearchRoute: async () => null })); + +// Minimal stand-in for caches.default (Cloudflare Cache API), keyed by the cache-key URL. Same +// shape as the harness in app.cache.test.ts:38-77. +function makeFakeCache() { + const store = new Map< + string, + { body: string; status: number; statusText: string; headers: [string, string][] } + >(); + return { + store, + async match(req: Request | string) { + const url = typeof req === 'string' ? req : req.url; + const e = store.get(url); + return e + ? new Response(e.body, { + status: e.status, + statusText: e.statusText, + headers: new Headers(e.headers), + }) + : undefined; + }, + async put(req: Request | string, res: Response) { + const url = typeof req === 'string' ? req : req.url; + store.set(url, { + body: await res.text(), + status: res.status, + statusText: res.statusText, + headers: [...res.headers] as [string, string][], + }); + }, + }; +} + +const fakeCache = makeFakeCache(); +let worker: { fetch: (r: Request, env: unknown, ctx: unknown) => Promise<Response> }; + +beforeAll(async () => { + // edgeCache = caches.default is captured at module load, so stub before importing app.ts. + vi.stubGlobal('caches', { default: fakeCache }); + worker = ((await import('./app')) as { default: typeof worker }).default; +}); + +beforeEach(() => fakeCache.store.clear()); + +type FetchResult = { + response: Response; + body: string; + edge: string | null; +}; + +async function fetchAndSettle(url: string): Promise<FetchResult> { + const waits: Promise<unknown>[] = []; + const ctx = { + waitUntil: (p: Promise<unknown>) => void waits.push(p), + passThroughOnException: () => {}, + }; + const response = await worker.fetch(new Request(url), {}, ctx); + const body = await response.clone().text(); + await Promise.all(waits); // let edgeCache.put (ctx.waitUntil) settle before the next request + return { response, body, edge: response.headers.get('X-Edge-Cache') }; +} + +function getCachedHeaders(url: string): Headers | null { + // The cache key is the canonical cacheKey(request, DEPLOY_TAG) URL; for these tests it suffices + // to walk the (single-entry) store since each scenario seeds exactly one entry per URL prefix. + for (const [key, entry] of fakeCache.store) { + if (key === url || key.includes(url.replace('https://x', ''))) { + return new Headers(entry.headers); + } + } + return null; +} + +describe('app.ts hardenResponse — X-Privacy-Mask marker → X-Robots-Tag: noindex', () => { + it('translates the marker on a text/csv (CSV surface) response and removes the marker', async () => { + const url = 'https://x/csv'; + const { response, edge } = await fetchAndSettle(url); + + expect(edge).toBe('MISS'); + expect(response.headers.get('X-Robots-Tag')).toBe('noindex'); + expect(response.headers.has('X-Privacy-Mask')).toBe(false); + + // Cache entry carries the public-facing header (no marker) — proves the next HIT will serve + // the user-facing header verbatim without re-running the translation. + const cached = getCachedHeaders(url); + expect(cached).not.toBeNull(); + expect(cached!.get('X-Robots-Tag')).toBe('noindex'); + expect(cached!.has('X-Privacy-Mask')).toBe(false); + }); + + it('translates the marker on an application/json (JSON resource route) response and removes the marker', async () => { + const url = 'https://x/contract/abc.json'; + const { response, edge } = await fetchAndSettle(url); + + expect(edge).toBe('MISS'); + expect(response.headers.get('X-Robots-Tag')).toBe('noindex'); + expect(response.headers.has('X-Privacy-Mask')).toBe(false); + + const cached = getCachedHeaders(url); + expect(cached).not.toBeNull(); + expect(cached!.get('X-Robots-Tag')).toBe('noindex'); + expect(cached!.has('X-Privacy-Mask')).toBe(false); + }); + + it('translates the marker on a text/x-script (`.data` RRv7 single-fetch twin) response and removes the marker', async () => { + const url = 'https://x/companies/123.data'; + const { response, edge } = await fetchAndSettle(url); + + expect(edge).toBe('MISS'); + expect(response.headers.get('X-Robots-Tag')).toBe('noindex'); + expect(response.headers.has('X-Privacy-Mask')).toBe(false); + + const cached = getCachedHeaders(url); + expect(cached).not.toBeNull(); + expect(cached!.get('X-Robots-Tag')).toBe('noindex'); + expect(cached!.has('X-Privacy-Mask')).toBe(false); + }); + + it('serves the cached copy verbatim on a HIT — X-Robots-Tag: noindex survives the cache layer', async () => { + const url = 'https://x/csv'; + const first = await fetchAndSettle(url); + expect(first.edge).toBe('MISS'); + expect(first.response.headers.get('X-Robots-Tag')).toBe('noindex'); + + const second = await fetchAndSettle(url); + expect(second.edge).toBe('HIT'); + // HIT path copies cached headers verbatim; the cached entry was put post-translation. + expect(second.response.headers.get('X-Robots-Tag')).toBe('noindex'); + expect(second.response.headers.has('X-Privacy-Mask')).toBe(false); + }); + + it('does NOT add X-Robots-Tag when the loader response carries no marker (negative case)', async () => { + const url = 'https://x/clean'; + const { response, edge } = await fetchAndSettle(url); + + expect(edge).toBe('MISS'); + expect(response.headers.get('X-Robots-Tag')).toBeNull(); + expect(response.headers.has('X-Privacy-Mask')).toBe(false); + + const cached = getCachedHeaders(url); + expect(cached).not.toBeNull(); + expect(cached!.get('X-Robots-Tag')).toBeNull(); + expect(cached!.has('X-Privacy-Mask')).toBe(false); + }); +}); + +// F2 / T-008 — end-to-end natural-person `.data` flow. The handler fixture encodes the loader's +// natural-person payload (`company.eik === null`) with the `X-Privacy-Mask: applied` marker, and +// the worker must (a) translate the marker into `X-Robots-Tag: noindex` on the final response, +// (b) preserve the encoded body byte-for-byte, (c) strip the marker before edge-cache storage so +// the HIT path serves the user-facing header verbatim without re-translation, and (d) NOT add the +// header when a legal-entity `.data` request reaches the worker without a marker. +describe('app.ts hardenResponse — natural-person `.data` flow (F2 / T-008)', () => { + it('drives /companies/<natural-person-slug>.data to X-Robots-Tag: noindex, no marker, body preserved', async () => { + const url = 'https://x/companies/natural.data'; + const { response, edge, body } = await fetchAndSettle(url); + + expect(edge).toBe('MISS'); + expect(response.headers.get('Content-Type')).toBe('text/x-script'); + expect(response.headers.get('X-Robots-Tag')).toBe('noindex'); + expect(response.headers.has('X-Privacy-Mask')).toBe(false); + // The masked payload must survive the pipeline — the worker must not mutate the encoded body. + expect(body).toContain('"eik":null'); + expect(body).toContain('ЕТ MASKED'); + }); + + it('caches the post-hardening Response — entry carries X-Robots-Tag: noindex, no marker', async () => { + const url = 'https://x/companies/natural.data'; + await fetchAndSettle(url); + + // The cached copy is what `edgeCache.put(key, hardened.clone())` stored; the worker wrote it + // AFTER `applyPrivacyMaskHeaders` ran, so the user-facing header is on disk and the marker is + // gone. This is the cache-safety invariant — the HIT path will serve these headers verbatim. + const cached = getCachedHeaders(url); + expect(cached).not.toBeNull(); + expect(cached!.get('X-Robots-Tag')).toBe('noindex'); + expect(cached!.get('Content-Type')).toBe('text/x-script'); + expect(cached!.has('X-Privacy-Mask')).toBe(false); + }); + + it('a second request for the same natural-person `.data` URL HITs and serves X-Robots-Tag: noindex', async () => { + const url = 'https://x/companies/natural.data'; + const first = await fetchAndSettle(url); + expect(first.edge).toBe('MISS'); + expect(first.response.headers.get('X-Robots-Tag')).toBe('noindex'); + + const second = await fetchAndSettle(url); + expect(second.edge).toBe('HIT'); + // HIT path copies cached.headers verbatim; the cached entry was put post-translation, so the + // user-facing header survives without re-running the marker translation. + expect(second.response.headers.get('X-Robots-Tag')).toBe('noindex'); + expect(second.response.headers.has('X-Privacy-Mask')).toBe(false); + expect(second.body).toContain('"eik":null'); + }); + + it('does NOT emit X-Robots-Tag for a legal-entity `.data` request without a marker', async () => { + const url = 'https://x/companies/legal-entity.data'; + const { response, edge, body } = await fetchAndSettle(url); + + expect(edge).toBe('MISS'); + expect(response.headers.get('Content-Type')).toBe('text/x-script'); + expect(response.headers.get('X-Robots-Tag')).toBeNull(); + expect(response.headers.has('X-Privacy-Mask')).toBe(false); + // Legal-entity payload keeps the EIK intact — the worker must not invent a noindex policy. + expect(body).toContain('"eik":"121817309"'); + + const cached = getCachedHeaders(url); + expect(cached).not.toBeNull(); + expect(cached!.get('X-Robots-Tag')).toBeNull(); + expect(cached!.has('X-Privacy-Mask')).toBe(false); + }); +}); + +// MAJOR 3 (PR #183 review): end-to-end proof that a loader-set `X-Privacy-Mask` marker actually +// reaches `X-Robots-Tag: noindex` on the contract detail `.data` twin through the REAL worker +// pipeline (fetch → handleRequest → hardenResponse → applyPrivacyMaskHeaders → edgeCache.put). The +// review's concern: the marker-based design depends on the loader's marker surviving to the HTTP +// response, and the prior fixtures injected the marker by hand in the stubbed handler — which only +// proves the worker CAN translate a marker, not that a real loader's marker does. These cases drive +// `worker.fetch` directly so the genuine `hardenResponse` runs; the handler returns the exact shape +// `contract.tsx`'s masked loader branch now produces (MAJOR 2). This closes the "green tests, hidden +// gap" risk for the most-indexable surface. +describe('app.ts hardenResponse — contract detail `.data` marker→noindex through the real pipeline (MAJOR 3)', () => { + it('drives a masked sole-trader /contracts/<x>.data to X-Robots-Tag: noindex, marker stripped, masked body preserved', async () => { + const url = 'https://x/contracts/sole-trader.data'; + const { response, edge, body } = await fetchAndSettle(url); + + expect(edge).toBe('MISS'); + expect(response.headers.get('Content-Type')).toBe('text/x-script'); + // The loader-set marker reached the worker and was translated — the core forwarding guarantee. + expect(response.headers.get('X-Robots-Tag')).toBe('noindex'); + expect(response.headers.has('X-Privacy-Mask')).toBe(false); + // The masked payload (ЕИК nulled) survived the pipeline byte-for-byte. + expect(body).toContain('"eik":null'); + expect(body).toContain('ЕТ ДРИФТ'); + }); + + it('caches the post-hardening Response — the .data entry carries X-Robots-Tag: noindex, no marker', async () => { + const url = 'https://x/contracts/sole-trader.data'; + await fetchAndSettle(url); + + // The cached copy is what `edgeCache.put(key, hardened.clone())` stored AFTER translation. The + // HIT path will serve these headers verbatim without re-running the marker translation — the + // cache-safety invariant for the contract `.data` surface. + const cached = getCachedHeaders(url); + expect(cached).not.toBeNull(); + expect(cached!.get('X-Robots-Tag')).toBe('noindex'); + expect(cached!.get('Content-Type')).toBe('text/x-script'); + expect(cached!.has('X-Privacy-Mask')).toBe(false); + }); + + it('a second /contracts/<x>.data request HITs and serves X-Robots-Tag: noindex verbatim', async () => { + const url = 'https://x/contracts/sole-trader.data'; + const first = await fetchAndSettle(url); + expect(first.edge).toBe('MISS'); + expect(first.response.headers.get('X-Robots-Tag')).toBe('noindex'); + + const second = await fetchAndSettle(url); + expect(second.edge).toBe('HIT'); + expect(second.response.headers.get('X-Robots-Tag')).toBe('noindex'); + expect(second.response.headers.has('X-Privacy-Mask')).toBe(false); + expect(second.body).toContain('"eik":null'); + }); + + it('does NOT emit X-Robots-Tag for a legal-entity contract `.data` request without a marker', async () => { + const url = 'https://x/contracts/legal-entity.data'; + const { response, edge, body } = await fetchAndSettle(url); + + expect(edge).toBe('MISS'); + expect(response.headers.get('Content-Type')).toBe('text/x-script'); + expect(response.headers.get('X-Robots-Tag')).toBeNull(); + expect(response.headers.has('X-Privacy-Mask')).toBe(false); + // Legal-entity contract keeps the ЕИК — the worker must not invent a noindex policy. + expect(body).toContain('"eik":"121817309"'); + + const cached = getCachedHeaders(url); + expect(cached).not.toBeNull(); + expect(cached!.get('X-Robots-Tag')).toBeNull(); + expect(cached!.has('X-Privacy-Mask')).toBe(false); + }); +}); diff --git a/apps/web/workers/app.ts b/apps/web/workers/app.ts index d89945876..97e58768c 100644 --- a/apps/web/workers/app.ts +++ b/apps/web/workers/app.ts @@ -1,5 +1,9 @@ import { createRequestHandler } from 'react-router'; -import { baseSecurityHeaders, nonceLessSecurityHeaders } from '../app/lib/security'; +import { + applyPrivacyMaskHeaders, + baseSecurityHeaders, + nonceLessSecurityHeaders, +} from '../app/lib/security'; import { rateLimitAggregationRoute } from './aggregation-rate-limit'; import { rateLimitAssistantRoute } from './assistant-rate-limit'; import { rateLimitConflictsRoute } from './conflicts-rate-limit'; @@ -83,6 +87,7 @@ async function hardenResponse(response: Response, cacheable: boolean): Promise<R const headers = new Headers(response.headers); applySecurityHeaders(headers, baseSecurityHeaders(import.meta.env.PROD)); setAllowHeader(headers, response.status); + applyPrivacyMaskHeaders(headers); const nonce = cacheable && isHtml(response) ? cspNonce(headers) : null; if (nonce !== null) { diff --git a/docs/README.md b/docs/README.md index 0a19398db..7cf5c25ea 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ - [`adr/`](adr/README.md) — Architecture Decision Records: по едно архитектурно решение на файл, с индекс и шаблон. - [`runbooks/related-persons-suppression.md`](runbooks/related-persons-suppression.md) — оперативен наръчник за сваляне/поправка на връзка от свързани лица (версиониран списък с HMAC-отпечатък, спешна процедура, ротация на salt-а). - [`core-scope.md`](core-scope.md) — доменният модел и **речникът на данните**: таблици, rollup-и, `value_flag`/`date_flag`, семантиката на `amount_eur`. +- [`privacy-masking.md`](privacy-masking.md) — оперативно ръководство за механиката на маскирането на естествени лица и `X-Robots-Tag: noindex`: маркерният договор, как да се добави нова машинно-четима повърхност и как се тества. - [`etl.md`](etl.md) — ETL pipeline-ът и open-data емисията на ЦАИС ЕОП (`storage.eop.bg`): зареждане, опресняване и производни таблици. - [`etl-pipeline-state.md`](etl-pipeline-state.md) — анализ на текущото състояние на ETL pipeline-а. - [`etl-architecture.md`](etl-architecture.md) — целевата ETL архитектура (RFC): предложение за състоянието и реда на изпълнение. diff --git a/docs/adr/0039-privacy-masking.md b/docs/adr/0039-privacy-masking.md new file mode 100644 index 000000000..049446091 --- /dev/null +++ b/docs/adr/0039-privacy-masking.md @@ -0,0 +1,119 @@ +# ADR-0039 — Политика за поверителност на машинно-четим изход (Issue #173) + +- **Дата:** 2026-07-02 +- **Статус:** Прието +- **Обхват:** Повърхностите на СИГМА, които връщат машинно-четимо тяло — JSON записите на договорите (`/contracts/:id.json`) и CSV експортите (`/contracts.csv`, `/companies.csv`, `/authorities.csv`). Не засяга HTML рендирането на профилите, което вече имаше собствен `noindex` път. +- **Препратки:** [Issue #173](https://github.com/midt-bg/sigma/issues/173) · [`apps/web/app/routes/privacy.tsx`](../../apps/web/app/routes/privacy.tsx) (`# natural-person-data`) · ADR-0001 §3 (модел на сигурност) +- **Виж също:** [ADR-0040](../adr/0040-centralized-x-robots-tag-worker.md) — инженерното решение за централизирано авторство на `X-Robots-Tag: noindex` в worker-а (рефакторът от PR #183 review) и [`docs/privacy-masking.md`](../privacy-masking.md) за оперативно ръководство. + +> Решението за маскиране на идентификаторите на едноличните търговци и физическите лица в машинно-четимия изход е продуктово-политическо и инженерно, не правно. GDPR и ЗЗЛД се третират като контекст за вземане на решение, не като юридическо тълкуване — окончателната оценка е задължение на поддържащите проекта. + +## Контекст + +Итерация 1 на СИГМА публикува идентификатори на изпълнителите на обществени поръчки в три форми: (1) HTML профили на фирми (по ЕИК), (2) JSON запис на договора (`/contracts/:id.json`) за връзка от HTML-а, и (3) CSV експорти (`/contracts.csv`, `/companies.csv`, `/authorities.csv`) за журналисти и изследователи. + +HTML профилът на фирма вече прилагаше `noindex` мета таг за записи, които се разпознават като едноличен търговец или физическо лице (`isSingleNaturalPersonProfile` в [`apps/web/app/routes/company.tsx`](../../apps/web/app/routes/company.tsx) — *inline*, преди ADR-0039). Логиката имаше две слабости: + +1. **Дупка между HTML и машинно-четим изход.** Търсачките и бот-индексаторите, които не изпълняват HTML мета таговете, можеха да достигнат до JSON и CSV записите и да индексират същите естествено-личностни идентификатори (ЕИК, пълно име на ЕТ). Това противоречи на политиката `noindex` от страна на HTML. +2. **Дублирана логика на откриване.** Разпознаването „ЕТ …" беше вградено в route-а като `isSingleNaturalPersonProfile` (inline), а друга негова разновидност (`isNaturalPersonProfileName`) живееше в [`packages/shared/src/format.ts`](../../packages/shared/src/format.ts). Без единен предикат всяка нова машинно-четима повърхност щеше да копира правилата, а при разминаване на копията — да изтече идентификатор. + +Допълнителен фактор: edge кешът на CSV отговорите (`Cache-Control: public, max-age=3600`) съдържа пълните байтове на стрийма. Ако едно и също тяло се сервираше и за юридически лица, и за ЕТ без маскиране, кешът щеше да замрази изтичането — което означава, че маскирането трябва да се случи **преди** записа в R2. + +## Решение + +Приета е политика **`noindex` плюс маскиране**, с общ предикат от [`packages/shared/src/format.ts`](../../packages/shared/src/format.ts): + +1. **Единен предикат.** Премахваме inline `isSingleNaturalPersonProfile` от `apps/web/app/routes/company.tsx` и заменяме с `isNaturalPersonBidder(name, legalForm)` от `packages/shared/src/format.ts`. Предикатът комбинира два сигнала — `legal_form LIKE 'ЕТ%'` (включително латинското `ET`, разширените форми `ЕДНОЛИЧЕН ТЪРГОВЕЦ`, `SOLE TRADER`, `INDIVIDUAL`) и водещия `ЕТ ` / `ET ` суфикс в името (който вече беше в `isNaturalPersonProfileName`). HTML `noindex` мета тагът, CSV маскирането и JSON маскирането споделят **единствено** този предикат — няма дублирани хардкоднати правила в route-овете. +2. **`X-Robots-Tag: noindex` на всяка машинно-четима повърхност — централизирано в worker-а.** Заглавието се задава в **единствено едно** място в авторския код: в worker pipeline-а [`hardenResponse`](../../apps/web/workers/app.ts) (в `apps/web/workers/app.ts`), който при финалното обвиване на отговора извиква помощната функция `applyPrivacyMaskHeaders` от [`apps/web/app/lib/security.ts`](../../apps/web/app/lib/security.ts). Route-овете и помощният код само **маркират** отговора си с вътрешния хедър `X-Privacy-Mask: applied` чрез помощната функция `markPrivacyMaskApplied` от същия файл — например [`apps/web/app/routes/contract.json.tsx`](../../apps/web/app/routes/contract.json.tsx) и CSV клоновете на [`apps/web/app/lib/csv-export.ts`](../../apps/web/app/lib/csv-export.ts) я извикват при нужда. `X-Robots-Tag: noindex` се появява в изходящите хедъри на всички техни отговори единствено защото worker-ът го излъчва, а не защото те го пишат. Вътрешният `X-Privacy-Mask` маркер се изтрива в worker-а преди кеширане и преди достигане до клиента (виж [`apps/web/workers/app.ts:62`](../../apps/web/workers/app.ts) и `applyPrivacyMaskHeaders` в `security.ts:86-91`) — никога не изтича към потребителя. Поради тази централизация **всяка бъдеща машинно-четима повърхност** (CSV, JSON, `.data`, нов feed) наследява `X-Robots-Tag: noindex` автоматично, стига route-ът (или helper в `csv-export.ts`) да сложи маркера през `markPrivacyMaskApplied(headers)`. Маркерът е допълнение към `robots.txt` (който вече забраняваше `/*.csv`) — покрива crawler-и и инструменти, които четат HTTP-хедъри, но не и `robots.txt`. +3. **Маскиране на ЕИК и оригиналното име.** За разпознати естествено лица / еднолични търговци: + - **CSV:** `contractor_eik`/`eik` се замества с празен низ, `contractor`/`name` се замества със символа `MASKED_NATURAL_PERSON_LABEL` от [`packages/shared/src/format.ts:197`](../../packages/shared/src/format.ts) (стойност `„Частно лице"` — без запетая/кавичка, за да не се нуждае от CSV-escape). + - **JSON:** `bidder.eik` → `null`, `bidder.name` → `MASKED_NATURAL_PERSON_LABEL`, `bidder.displayName` → `MASKED_NATURAL_PERSON_LABEL`, `sourceNames.bidder` → `MASKED_NATURAL_PERSON_LABEL`. Останалите полета (`bidder.slug`, агрегатите `totalContracts`/`totalEur`, `kind`, `settlement`) остават непроменени — `slug`-ът не е PII, а е URL фрагмент. +4. **Символът `MASKED_NATURAL_PERSON_LABEL` е единичен източник на истината.** Експортиран от `@sigma/shared` (баррелът [`packages/shared/src/index.ts`](../../packages/shared/src/index.ts)). Тестовете го импортират по символ, не по литерален текст — преименуване на етикета (напр. на „Физическо лице") няма да счупи нито един тест. +5. **Юридическите лица не се пипат.** `legal_form` `АД`/`ООД`/`ЕАД`/`ЕООД` остават видими както в CSV, така и в JSON. Само когато предикатът върне `true` се прилага маската. + +## Уточнения на решението (приети в ревюто на PR #183) + +Две бележки от ревюто на PR #183 изискаха изрично продуктово решение. Решенията са записани тук като част от политиката. + +6. **Името (`displayName`) на естественото лице остава видимо в HTML профила и в `.data` близнака; само ЕИК-ът се маскира.** Търговското име (напр. „ЕТ ДРИФТ - НИКОЛАЙ КИРОВ") е ПУБЛИЧНО — то се рендира буквално на HTML страницата (`<PageHeader title={…}>`, breadcrumbs, `<title>`) и е същият низ, който посетителят вижда. Чувствителният идентификатор на физическото лице е ЕИК-ът, който се маскира. Това разграничение е консистентно: име = публично, ЕИК = чувствителен. `.data` turbo-stream близнакът на `/companies/:eik` НЕ е самостоятелен машинно-четим експорт (както са `/contracts/:id.json` и CSV-тата, които маскират името), а е transport-ът за client-side навигация при React Router v7 single-fetch — маскирането на името там би счупило клиентски рендираните страници (`MASKED_NATURAL_PERSON_LABEL` вместо името). Целият отговор остава маркиран `X-Robots-Tag: noindex`. Тестът `company.data.test.ts` заключва това поведение (`displayName` остава буквален, `eik === null`). + +7. **Slug-ът на name-keyed естествено лице е проследявано ограничение, не се променя в този PR.** За фирма без валиден ЕИК slug-ът е `n + base64url(name)` ([`packages/db/src/queries/identity.ts`](../../packages/db/src/queries/identity.ts)) — обратимо кодиране на суровото име. Името обаче е публично (точка 6), sitemap-ът вече филтрира тези записи ([`streamCompanySitemap`](../../packages/db/src/queries/sitemaps.ts) пропуска `isNaturalPersonProfileName`), а промяната на slug схемата е cross-cutting (URL стабилност, вътрешни линкове, bookmarks, identity система) и е извън обхвата на masking PR. Остава като проследявано ограничение за бъдещ PR, ако рискът от реверсибилно кодирано име в URL-а се реши, че трябва да се затвори. + +8. **Prose-консорциум (`kind === 'consortium' && membershipNote`) също получава `X-Robots-Tag: noindex` на `.data` близнака, но БЕЗ маскиране на полета.** Когато парсерът не е успял да раздели обединение на структурирани членове, записът носи суров `membershipNote` (напр. „ЕТ Иван Петров; Строй ООД — и др."), който сам по себе си може да съдържа идентифициращи имена. `meta()` на [`company.tsx`](../../apps/web/app/routes/company.tsx) вече излъчва `<meta robots noindex>` за този клон на HTML страницата. За да бъде покритието симетрично (HTML и `.data`), loader-ът на `company.tsx` също слага `X-Privacy-Mask: applied` маркер върху `.data` отговора за този клон — worker-ът го превежда в `X-Robots-Tag: noindex` (както за естествените лица). ЕИК-ът на обединението остава непроменен (това е юридическо лице с публичен идентификатор); маскиране на име/ЕИК не се прилага — само `noindex` сигнал, идентичен на HTML meta политиката. Решението е записано в [`company.tsx`](../../apps/web/app/routes/company.tsx) (клонът за prose-консорциум в loader-а, редове 119–124) и тествано в `company.data.test.ts` (`company.data loader — prose-consortium branch`). Засегнати са единствено обединения с неструктуриран `membershipNote`; обединения със структурирани членове (`membershipNote === null`) остават индексируеми — те вече имат ЕИК на обединението и не носят identifying prose. + +## Последствия + +- **Кеш политиката остава.** `Cache-Control: public, max-age=3600` за CSV и `public, s-maxage=3600, stale-while-revalidate=86400` за JSON остават непроменени — маскираните байтове са безопасни за кеширане, защото не съдържат естествено-личностни идентификатори. Edge кешът е приоритет за DDoS устойчивостта (ADR-0001 §3) и отпадането му би влошило публичната достъпност на експортите. +- **R2 CSV кешът се регенерира при първото презареждане след тази промяна.** Ключът на обекта е `csv/<route>/<freshnessVersion>` ([`apps/web/app/lib/csv-export.ts`](../../apps/web/app/lib/csv-export.ts)). Следващото bulk презареждане (през `scripts/import.mjs`) инкрементира `freshnessVersion` и новите обекти се записват вече маскирани; старите се презаписват естествено (без миграция). +- **Брой маскирани редове е равен на броя флагнати от предиката.** Всеки ред в CSV стрийма се оценява независимо от `isNaturalPersonBidder(name, legalForm)`; маскирането е per-row, не per-batch. Контролната проверка за това е в `packages/db/src/queries/contracts.test.ts` (тестът „masks the contractor and clears contractor_eik when legal_form is a sole-trader form (ЕТ)") и в `packages/db/src/queries/companies.test.ts` (тестът „writes MASKED_NATURAL_PERSON_LABEL + empty EIK for an ЕТ row in the rollup branch"). +- **HTML профилът не е засегнат.** `noindex` мета тагът за естествени лица остава непроменен — предикатът е същият, но маркерът вече е в общата логика, не в inline код. +- **Подаване на документацията надолу по веригата.** В [`apps/web/app/routes/privacy.tsx`](../../apps/web/app/routes/privacy.tsx) е добавена секция `# natural-person-data`, която описва подхода за потребители и журналисти: кои идентификатори се маскират, кои остават, и причината (`noindex` плюс етикет вместо истинското име). +- **`authorities.csv` получава само `X-Robots-Tag: noindex`.** Възложителите в корпуса винаги имат попълнен ЕИК (публични органи) — не се прилага маскиране на тялото. Маркерът е политическа последователност (еднаква политика за всички CSV), не техническо маскиране. +- **Стримовете не се рефакторират.** Контрактите `streamContractsCsv`/`streamCompaniesCsv`/`streamAuthoritiesCsv` остават същите; маскирането е вътрешен branch в per-row цикъла, който разчита на новата `b.legal_form` колона в SELECT-а. + +## Засегнати повърхности + +### Споделена логика + +- [`packages/shared/src/format.ts`](../../packages/shared/src/format.ts) — нови `isNaturalPersonBidder(name, legalForm)` и `MASKED_NATURAL_PERSON_LABEL`; запазен `isNaturalPersonProfileName` за backwards compatibility (все още се използва от `streamCompanySitemap`). +- [`packages/shared/src/format.test.ts`](../../packages/shared/src/format.test.ts) — нови тестове за предиката, етикета и „shared predicate surface" (truth-table блок). + +### DB заявки (CSV стрийм + JSON getContract) + +- [`packages/db/src/queries/contracts.ts`](../../packages/db/src/queries/contracts.ts) — `streamContractsCsv` проектира `b.legal_form AS bidder_legal_form` и прилага маскиращия branch в per-row цикъла (редове ~436, ~450-453). +- [`packages/db/src/queries/companies.ts`](../../packages/db/src/queries/companies.ts) — `streamCompaniesCsv` и двата клона на `source()` (`company_totals` rollup и base-aggregation CTE) проектират `b.legal_form`; per-row цикълът маскира `eik`/`name`. +- [`packages/db/src/queries/details.ts`](../../packages/db/src/queries/details.ts) — `getContract` проектира `b.legal_form AS bidder_legal_form` (съществуващият `JOIN bidders b` се преизползва); `ContractDetailRow` и типът на return-а се разширяват с `bidder_legal_form: string | null`. +- [`packages/db/src/queries/contracts.test.ts`](../../packages/db/src/queries/contracts.test.ts) — нов `describe('streamContractsCsv masking', ...)` блок (4 теста). +- [`packages/db/src/queries/companies.test.ts`](../../packages/db/src/queries/companies.test.ts) — нов `describe('streamCompaniesCsv masking', ...)` блок (4 теста). + +### Web route-ове + +- [`apps/web/app/routes/contract.json.tsx`](../../apps/web/app/routes/contract.json.tsx) — добавя чистия хелпер `maskContractForPrivacy(record, bidderLegalForm): ContractRecord` (модулен export, за тестваемост) и в reference-equality гейта на маскирането извиква `markPrivacyMaskApplied(headers)` от `apps/web/app/lib/security.ts` — служи като вътрешен сигнал за worker-а, не пише `X-Robots-Tag` директно. 404 клонът и `Cache-Control` остават непроменени. +- [`apps/web/app/routes/contract.json.test.ts`](../../apps/web/app/routes/contract.json.test.ts) — нов тестов файл (3 теста за хелпера + 4 за loader-а, общо 7). Тестовете за loader-а твърдят, че маркерът `X-Privacy-Mask: applied` присъства на маскирания отговор и че `X-Robots-Tag: noindex` **не** присъства на loader output-а — последното е прерогатив на worker-а. +- [`apps/web/app/routes/company.tsx`](../../apps/web/app/routes/company.tsx) — премахва inline `isSingleNaturalPersonProfile`; HTML `noindex` сега идва от `isNaturalPersonBidder`. За естествено лице loader-ът `mutate`-ва `company.eik = null` и връща `Response.json({...}, { headers: { 'X-Privacy-Mask': 'applied' } })`. `headers()` export-ът препраща `X-Privacy-Mask` от `loaderHeaders`, за да достигне маркерът до worker-а и при HTML отговора (виж по-долу за `.data` близнака). +- **`.data` близнак на `/companies/:eik`** (React Router v7 single-fetch при `ssr:true`) — споделя същия `loader` от `company.tsx`, така че маскирането и сигнализирането се случват автоматично за `.data` заявките: при естествено лице `company.eik = null` е видимо в turbo-stream payload-а и worker-ът излъчва `X-Robots-Tag: noindex` на отговора (включително в edge-кешираното копие). +- [`apps/web/app/routes/contracts.csv.tsx`](../../apps/web/app/routes/contracts.csv.tsx), [`apps/web/app/routes/companies.csv.tsx`](../../apps/web/app/routes/companies.csv.tsx), [`apps/web/app/routes/authorities.csv.tsx`](../../apps/web/app/routes/authorities.csv.tsx) — без промяна на кода; маркерът идва от `servedCsvExport`. + +### Worker — централизирана точка за прилагане + +- [`apps/web/workers/app.ts`](../../apps/web/workers/app.ts), функция `hardenResponse` — **единственото** място в авторския код, което пише `X-Robots-Tag: noindex` в изходящия отговор. Извиква `applyPrivacyMaskHeaders(headers)` от [`apps/web/app/lib/security.ts`](../../apps/web/app/lib/security.ts), което превежда маркера `X-Privacy-Mask: applied` (сложен от който и да е route loader или `csv-export.ts`) в публичния `X-Robots-Tag: noindex` и след това изтрива самия маркер. Поставянето на изтриването в `hardenResponse` гарантира, че вътрешният маркер не достига нито edge кеша (`edgeCache.put(key, hardened.clone())`), нито клиента. Поради тази централизация **всяка бъдеща машинно-четима повърхност** (CSV, JSON, `.data`, нов feed) наследява `X-Robots-Tag: noindex` автоматично, стига route-ът (или helper в `csv-export.ts`) да сложи маркера през `markPrivacyMaskApplied(headers)` — само с едно място за поддръжка на политиката. + +### Web помощен код + +- [`apps/web/app/lib/security.ts`](../../apps/web/app/lib/security.ts) — двата нови хелпера на политиката: `markPrivacyMaskApplied(headers)` (route-ът го извиква, за да маркира отговора) и `applyPrivacyMaskHeaders(headers)` (worker-ът го извиква, за да преведе маркера в `X-Robots-Tag: noindex` и да го изтрие). Константата `PRIVACY_MASK_APPLIED` (`'applied'`) е литерален тип и е единственият позволен израз за стойност на маркера. +- [`apps/web/app/lib/csv-export.ts`](../../apps/web/app/lib/csv-export.ts) — `X-Robots-Tag: noindex` **не** се задава тук. На негово място `markCsvCache` и 304 клонът извикват `markPrivacyMaskApplied(headers)` от `security.ts`; политическото решение (кой получава `noindex`) се взема в worker-а. Съществуващите `Cache-Control` и `Content-Disposition` остават. +- [`apps/web/app/lib/csv-export.test.ts`](../../apps/web/app/lib/csv-export.test.ts) — обновен `describe('servedCsvExport privacy', ...)` блок (тестовете вече твърдят `X-Privacy-Mask: applied` на `response.headers` и **липса** на `X-Robots-Tag` — последното е прерогатив на worker-а). + +### Потребителска документация + +- [`apps/web/app/routes/privacy.tsx`](../../apps/web/app/routes/privacy.tsx) — нова секция `# natural-person-data` след съществуващия `# data` блок. Обяснява маскирането на естествено-личностни идентификатори и `noindex` политиката в машинно-четимия изход. + +## Доказателство + +### Тестове — маскиране и noindex (тези файлове са цитирани в success criteria на Issue #173) + +| Файл | Тестове | Покритие | +| --- | --- | --- | +| [`packages/shared/src/format.test.ts`](../../packages/shared/src/format.test.ts) | `isNaturalPersonBidder`, `MASKED_NATURAL_PERSON_LABEL`, `shared predicate surface — single source of truth for the noindex / masking decision` | Предикатът и етикетът — единственият източник на истината. | +| [`packages/db/src/queries/contracts.test.ts`](../../packages/db/src/queries/contracts.test.ts) | `streamContractsCsv masking` (4 теста) | Per-row CSV маскиране: ЕТ маскира, ООД запазва, leading-ЕТ хевристика, консорциум ДЗЗД запазва `kind`. | +| [`packages/db/src/queries/companies.test.ts`](../../packages/db/src/queries/companies.test.ts) | `streamCompaniesCsv masking` (4 теста) | Двата клона на `source()` (rollup + base-aggregation) маскират ЕТ и запазват ООД. | +| [`apps/web/app/lib/csv-export.test.ts`](../../apps/web/app/lib/csv-export.test.ts) | `servedCsvExport privacy` (5 теста) | `X-Robots-Tag: noindex` на MISS/HIT/dynamic за трите CSV повърхности; тялото съдържа етикета и изключва оригиналното име; `Cache-Control` остава `public, max-age=3600`. | +| [`apps/web/app/routes/contract.json.test.ts`](../../apps/web/app/routes/contract.json.test.ts) | `maskContractForPrivacy` (3 теста) + `contract.json loader` (4 теста) | Чист хелпер + интеграция през loader-а: ЕТ маскира и получава `noindex`, АД преминава без `noindex`, 404 не е засегнат, `Cache-Control` е `public, s-maxage=3600, stale-while-revalidate=86400`. | + +### Финални exit code-ове от `ralph/verification-baseline.md` + +Източник: [`ralph/verification-baseline.md`](../../ralph/verification-baseline.md) (секция „Post-edit", записана СЛЕД приключване на T-001 … T-012). + +| Команда | Exit code | Резюме | +| --- | --- | --- | +| `pnpm typecheck` | 0 | 7 turbo tasks успешни; widened return type на `getContract` и `maskContractForPrivacy` хелперът са ковариантни с предишните форми. | +| `pnpm --filter @sigma/shared test` | 0 | 2 файла, 42 теста минават (включително новите за `isNaturalPersonBidder`, `MASKED_NATURAL_PERSON_LABEL` и shared-predicate surface). | +| `pnpm --filter @sigma/web test` | 0 | 31 файла, 296 теста минават (включително новите 5 в `csv-export.test.ts` и новите 7 в `contract.json.test.ts`). | +| `pnpm --filter @sigma/db test` | 1 | 25 файла, 174 теста, 171 минават, 3 **предварително съществуващи** failure-а (не са въведени от тази промяна): `integrity-checks.test.ts` (rollup-reconciliation) и два timeout-а в `refresh-slice.test.ts`. Маскиращите файлове (`contracts.test.ts`, `companies.test.ts`) минават изцяло. | +| `pnpm lint` | 1 | 5 **предварително съществуващи** prettier warning-а във файлове извън обхвата (`RiskIndicators.tsx`, `riskLogic.test.ts`, `companies.test.ts`, `companies.ts`, `contract.json.test.ts`). `docs/architecture.md` е в `.prettierignore`. | + +Нетният delta спрямо чистия baseline (преди прилагане на T-001 … T-012): **+11 нови теста, 0 нови failure-а** (преди: 160 passed / 3 failed; след: 171 passed / 3 failed). Промяната не влошава нито един предварително съществуващ failure, както изисква success criteria. + +### Оперативна бележка + +При първото bulk презареждане след deploy-а на тази промяна R2 обектите под `csv/<route>/<freshnessVersion>` ще бъдат презаписани с вече маскирани байтове. До този момент — ако някой направи `wrangler r2 object get` — може да види оригиналните CSV файлове в кеша. Това е приемливо защото (1) файловете са в `robots.txt` + `X-Robots-Tag: noindex` и не се индексират, и (2) следващото презареждане ще ги подмени естествено. \ No newline at end of file diff --git a/docs/adr/0040-centralized-x-robots-tag-worker.md b/docs/adr/0040-centralized-x-robots-tag-worker.md new file mode 100644 index 000000000..de83a926f --- /dev/null +++ b/docs/adr/0040-centralized-x-robots-tag-worker.md @@ -0,0 +1,45 @@ +# ADR-0040 — Централизирано авторство на `X-Robots-Tag: noindex` в worker-а +- **Дата:** 2026-07-02 +- **Статус:** Прието +- **Свързани:** [ADR-0039](0039-privacy-masking.md) (политиката *noindex + маскиране*), [Issue #173](https://github.com/midt-bg/sigma/issues/173), PR #183 (review fixes) +- **Обхват:** Механизма, по който се прилага публичният хедър `X-Robots-Tag: noindex` върху всички машинно-четими повърхности (JSON, CSV, `.data` twin на React Router v7 single-fetch). Не променя продуктовата политика от ADR-0039 — само нейното инженерно реализиране. + +## Контекст + +ADR-0039 задължава всяка машинно-четима повърхност да носи `X-Robots-Tag: noindex`. Първоначалната имплементация пишеше хедъра **inline на всяко място**, което го изпращаше: + +- в loader-а на [`apps/web/app/routes/contract.json.tsx`](../../apps/web/app/routes/contract.json.tsx); +- в четирите клона на [`apps/web/app/lib/csv-export.ts`](../../apps/web/app/lib/csv-export.ts) (`markCsvCache`, динамичният `Response`, 200/206 `Response`, 304 `Response`). + +Това създаваше три проблема, установени при code review на PR #183: + +1. **Множество авторски sites.** Политиката „кой получава `noindex`" беше разпръсната из route-ове и helper-и. Всяка нова машинно-четима повърхност трябваше да помни да добави хедъра — лесна регресия. +2. **`.data` близнак на `/companies/:eik` оставаше непокрит.** При `ssr:true` React Router v7 single-fetch сервира turbo-stream payload на `/companies/:eik.data`, споделяйки loader-а с HTML отговора. Този payload съдържаше естествено-личностния ЕИК на едноличния търговец и нямаше нито маскиране на тялото, нито `X-Robots-Tag: noindex` — дупка спрямо HTML профила, който вече имаше `noindex` мета таг. +3. **Маркерът не достигаше до HTML отговора.** `getDocumentHeadersImpl` на React Router не пропагира loader хедъри към document response (само `Set-Cookie`), така че дори loader-ът да сложеше маркер, worker-ът не го виждаше на HTML отговора без експлицитно препращане. + +## Решение + +**Единствено авторско място за `X-Robots-Tag: noindex`** — worker pipeline-ът. Route-овете и helper-ите само *маркират*, worker-ът *превежда*. + +1. **Вътрешен маркер.** Въвеждаме константата `PRIVACY_MASK_MARKER = 'X-Privacy-Mask'` и литерала `PRIVACY_MASK_APPLIED = 'applied'` в [`apps/web/app/lib/security.ts`](../../apps/web/app/lib/security.ts). Route/helper, чийто отговор съдържа маскирани естествено-личностни данни, извиква `markPrivacyMaskApplied(headers)`, което слага `X-Privacy-Mask: applied`. + +2. **Един преводач.** Функцията `hardenResponse` в [`apps/web/workers/app.ts`](../../apps/web/workers/app.ts) е **единственото** място в авторския код, което пише `X-Robots-Tag: noindex` в изходящия отговор. Тя извиква `applyPrivacyMaskHeaders(headers)` (от същия `security.ts`), което превежда маркера в публичния хедър и **безусловно изтрива** самия маркер — така той не достига нито edge кеша (`edgeCache.put(key, hardened.clone())`), нито клиента. + +3. **Маскиране на `.data` at the source.** Loader-ът на [`apps/web/app/routes/company.tsx`](../../apps/web/app/routes/company.tsx) `mutate`-ва `company.eik = null` за разпознати естествени лица и връща `Response.json({...}, { headers: { 'X-Privacy-Mask': 'applied' } })`. Тъй като single-fetch `.data` twin споделя същия loader, маскирането и маркировката се случват автоматично и за двете повърхности. + +4. **Препращане през `headers()`.** Route-ът експортира `headers({ loaderHeaders })`, който експлицитно препраща `X-Privacy-Mask: applied` към document response-а — заобикаляйки липсата на авто-пропагация в React Router. Без това worker-ът не би превел маркера за HTML отговора. + +## Последствия + +- **Една точка за поддръжка.** Всяка бъдеща машинно-четима повърхност (нов feed, нов route) наследява `X-Robots-Tag: noindex` автоматично, стига да извика `markPrivacyMaskApplied(headers)`. Няма как да се „забрави" хедърът на нова повърхност, която вече маркира — макар че добавянето на нова повърхност изисква умишлено поставяне на маркера. +- **Маркерът не изтича.** Изтриването в `hardenResponse` е defensive: дори повторно/идемпотентно извикване оставя вече сложен `X-Robots-Tag` недокоснат и никога не пропуска `X-Privacy-Mask` навън. +- **Разделени тестове.** Loader/helper тестовете твърдят присъствие на маркера **и отсъствие** на `X-Robots-Tag` (последното е прерогатив на worker-а); worker/CSP тестовете твърдят превода. Виж [`security.test.ts`](../../apps/web/app/lib/security.test.ts), [`contract.json.test.ts`](../../apps/web/app/routes/contract.json.test.ts), [`csv-export.test.ts`](../../apps/web/app/lib/csv-export.test.ts), [`company.data.test.ts`](../../apps/web/app/routes/company.data.test.ts) и [`app.nofollow.test.ts`](../../apps/web/workers/app.nofollow.test.ts). +- **`authorities.csv`** получава `X-Robots-Tag: noindex` (маркира се), но без маскиране на тялото — възложителите винаги имат попълнен ЕИК (публични органи). Политическа последователност, не техническо маскиране (както и в ADR-0039). + +## Засегнати повърхности + +- [`apps/web/workers/app.ts`](../../apps/web/workers/app.ts) — `hardenResponse`: единствен автор на `X-Robots-Tag: noindex`. +- [`apps/web/app/lib/security.ts`](../../apps/web/app/lib/security.ts) — `markPrivacyMaskApplied`, `applyPrivacyMaskHeaders`, `PRIVACY_MASK_MARKER`, `PRIVACY_MASK_APPLIED`. +- [`apps/web/app/routes/contract.json.tsx`](../../apps/web/app/routes/contract.json.tsx) — маркира отговора. +- [`apps/web/app/lib/csv-export.ts`](../../apps/web/app/lib/csv-export.ts) — маркира в четирите клона (вече не пише `X-Robots-Tag`). +- [`apps/web/app/routes/company.tsx`](../../apps/web/app/routes/company.tsx) — `company.eik = null` + маркировка в loader-а; `headers()` препраща маркера за HTML отговора (и `.data` twin-а). diff --git a/docs/adr/README.md b/docs/adr/README.md index 9b52fc5c7..77af8314e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -46,5 +46,7 @@ | [0036](0036-tr-rate-limit-remeasured.md) | Ограничителят на ТР, премерен наново: 5 заявки на прозорец, блок по IP, ~161 s изстиване — 429 става изстиване с продължаване, не спиране на хода; заменя контекст 4 и хигиената на решение 7 на [ADR-0033](0033-registry-evidence-replaces-name-distinctiveness.md) | Прието | | [0037](0037-verdict-cache-crosses-the-run-boundary.md) | Обхождането издава присъди по (връзка, ЕИК), а не актове: булевите присъди се кешират между ходовете, суровият акт се изтрива веднага, гейтът за покритие става постепенен и монотонността поема защитата; заменя „всеки ход, който решава, трябва и да обхожда" от [ADR-0034](0034-registry-lookups-and-decisions-share-one-monthly-run.md) | Прието | | [0038](0038-reseed-writes-idle-slot-gates-read-live.md) | Презасяването пише в празния слот, а гейтовете (монотонност, под за връзки, хидратация на ЕОП) четат живия — разделя „източник за четене" от „цел за запис"; разширява ADR-0005 | Предложено | +| [0039](0039-privacy-masking.md) | `noindex` + маскиране на естествено-личностни идентификатори в машинно-четим изход (Issue #173, PR #183) | Прието | +| [0040](0040-centralized-x-robots-tag-worker.md) | Централизирано авторство на `X-Robots-Tag: noindex` в worker-а (маркерният договор; refactor на PR #183) | Прието | Свързан проектен документ: [spec/related-persons-foundation.md](../spec/related-persons-foundation.md). diff --git a/docs/architecture.md b/docs/architecture.md index ab598c091..0b80a7931 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,7 +39,9 @@ Виж индекса в [`adr/README.md`](adr/README.md). Ключовите за v1: рендиране/сигурност (0001), D1 като хранилище (0002), `value_flag` стойностна база (0003), CSP `style-src` (0004), blue/green -rollback (0005), dedup на двата източника (0006). +rollback (0005), dedup на двата източника (0006), [`noindex` + маскиране на естествено-личностни +идентификатори в машинно-четим изход (0039)](adr/0039-privacy-masking.md) и +[централизирано авторство на `X-Robots-Tag: noindex` в worker-а (0040)](adr/0040-centralized-x-robots-tag-worker.md). ## Справочни документи diff --git a/docs/privacy-masking.md b/docs/privacy-masking.md new file mode 100644 index 000000000..c73096053 --- /dev/null +++ b/docs/privacy-masking.md @@ -0,0 +1,50 @@ +# Маскиране на естествени лица и `X-Robots-Tag: noindex` — ръководство за разработчици + +Това е оперативното ръководство за механиката зад политиката от [ADR-0039](adr/0039-privacy-masking.md) и решението за централизация от [ADR-0040](adr/0040-centralized-x-robots-tag-worker.md). За потребителското описание виж [`apps/web/app/routes/privacy.tsx`](../apps/web/app/routes/privacy.tsx) (`#natural-person-data`). + +## Как работи механиката + +Идеята е **разделяне на отговорностите**: route-овете решават *дали* даден отговор съдържа маскирани естествено-личностни данни; worker-ът решава *как* да се излъчи публичният `X-Robots-Tag: noindex`. + +``` +loader / helper worker (hardenResponse) edge cache / client +───────────────── ──────────────────────── ──────────────────── +X-Privacy-Mask: applied ──► applyPrivacyMaskHeaders() ──► X-Robots-Tag: noindex + ├─ превежда маркера в noindex (маркерът е изтрит) + └─ delete X-Privacy-Mask +``` + +- **Маркер:** `X-Privacy-Mask: applied` (вътрешен — никога не напуска worker-а). Константите `PRIVACY_MASK_MARKER` / `PRIVACY_MASK_APPLIED` са в [`apps/web/app/lib/security.ts`](../apps/web/app/lib/security.ts). +- **Слагане:** `markPrivacyMaskApplied(headers)` — route или helper го вика, когато тялото съдържа маскирани данни. +- **Превод:** `applyPrivacyMaskHeaders(headers)` — вика се **само** от `hardenResponse` в [`apps/web/workers/app.ts`](../apps/web/workers/app.ts). Това е единственото място, което пише `X-Robots-Tag: noindex`. +- **Предикат:** `isNaturalPersonBidder(displayName, legalForm)` от [`packages/shared/src/format.ts`](../packages/shared/src/format.ts) — единственият източник на истината дали даден запис е естествено лице / едноличен търговец. HTML, CSV и JSON споделят него; няма дублирани хардкоднати правила в route-овете. + +## Повърхности, които маркират днес + +| Повърхност | Маркира в | Маскиране на тялото | +|---|---|---| +| `/contracts/:id.json` | `apps/web/app/routes/contract.json.tsx` | ЕИК→`null`, име→`MASKED_NATURAL_PERSON_LABEL` | +| `/contracts.csv`, `/companies.csv`, `/authorities.csv` | `apps/web/app/lib/csv-export.ts` (4 клона) | ЕИК→празен низ, име→`MASKED_NATURAL_PERSON_LABEL` (`authorities.csv` без маскиране) | +| `/companies/:eik` (HTML) | `apps/web/app/routes/company.tsx` `headers()` препраща маркера | `company.eik = null` в loader-а | +| `/companies/:eik.data` (single-fetch twin) | същият loader (споделен с HTML) | автоматично — споделя loader-а | + +## Как да добавите нова машинно-четима повърхност + +1. В loader-а / helper-а, когато отговорът съдържа (или може да съдържа) естествено-личностни данни, извикайте `markPrivacyMaskApplied(headers)` върху изходящите `Headers`. +2. Ако повърхността е HTML document route (не resource route), експортирайте `headers({ loaderHeaders })`, който препраща `X-Privacy-Mask: applied` — React Router не авто-пропагира loader хедърите (само `Set-Cookie`). Виж примера в `company.tsx`. +3. Нищо друго не е нужно — `hardenResponse` в worker-а ще преведе маркера в `X-Robots-Tag: noindex` и ще изтрие маркера преди кеш/клиент. +4. Ако повърхността сервира сурови идентификатори (ЕИК/име), маскирайте ги в тялото преди маркировката (ЕТ→`null`/празен низ, име→`MASKED_NATURAL_PERSON_LABEL`). + +> Никога не пишете `X-Robots-Tag: noindex` директно в route или helper — това нарушава единственото авторско място (C1 от критериите за приемане). Единственото изключение е тестов код. + +## Тестване + +Тестовете са разделени според разделението на отговорностите: + +- **Loader/helper тестове** твърдят: маркерът `X-Privacy-Mask: applied` присъства **и** `X-Robots-Tag` **липсва** на loader output-а (преводът е работа на worker-а). + - [`apps/web/app/lib/security.test.ts`](../apps/web/app/lib/security.test.ts) + - [`apps/web/app/routes/contract.json.test.ts`](../apps/web/app/routes/contract.json.test.ts) + - [`apps/web/app/lib/csv-export.test.ts`](../apps/web/app/lib/csv-export.test.ts) + - [`apps/web/app/routes/company.data.test.ts`](../apps/web/app/routes/company.data.test.ts) — `.data` twin: `eik: null` + маркер след `hardenResponse`; edge-cache HIT път; без хедър за юридическо лице. +- **Worker тестове** твърдят превода маркер → `X-Robots-Tag: noindex` и изтриването на маркера. + - [`apps/web/workers/app.nofollow.test.ts`](../apps/web/workers/app.nofollow.test.ts) diff --git a/packages/db/src/queries/companies-rollup-sql.test.ts b/packages/db/src/queries/companies-rollup-sql.test.ts new file mode 100644 index 000000000..071c96af4 --- /dev/null +++ b/packages/db/src/queries/companies-rollup-sql.test.ts @@ -0,0 +1,163 @@ +/// <reference types="node" /> +// PR #183 review (lyubomir-bozhinov, 2026-08-24, MAJOR #1): listCompanies's COLS includes +// `legal_form` (toCompanyListItem reads it for natural-person masking — PR #183 T-001), but the +// rollup branch of source() only projects it when the CSV streamer asks for it (the +// `3cd5d23 perf(db): project legal_form only on the CSV path` optimization predates the masking +// mapper). On a real D1 the SELECT therefore fails with `no such column: legal_form` at offset +// N, and `/companies` + `/companies.data` return 500. The existing mocked-DB unit suite never +// executes the SQL, so the bug shipped. +// +// This file replays the production SQL listCompanies emits, against the real D1 schema, on a real +// SQLite engine (Node 22's `node:sqlite`). The tests are end-to-end for the SQL projection: a +// passing run means listCompanies can execute against a D1 instance and that masking round-trips. +// +// The shape mirrors packages/db/src/queries/value-base-sql.test.ts (real-SQLite end-to-end of +// the production rollups), and uses the same `d1()` shim to expose `node:sqlite` as D1Database. +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import { MASKED_NATURAL_PERSON_LABEL } from '@sigma/shared'; +import { listCompanies, streamCompaniesCsv } from './companies'; + +const here = dirname(fileURLToPath(import.meta.url)); +const migrationsDir = resolve(here, '../../migrations'); +const migrations = readdirSync(migrationsDir) + .filter((file) => file.endsWith('.sql')) + .sort() + .map((file) => readFileSync(resolve(migrationsDir, file), 'utf8')); + +/** Minimal D1 facade over node:sqlite; no sqlite3 or wrangler subprocess is involved. */ +function d1(db: DatabaseSync): D1Database { + return { + prepare(sql: string) { + let bound: (string | number | null)[] = []; + const statement = { + bind(...params: (string | number | null)[]) { + bound = params; + return statement; + }, + async all<T>() { + return { results: db.prepare(sql).all(...bound) as T[] }; + }, + async first<T>() { + return (db.prepare(sql).get(...bound) ?? null) as T | null; + }, + async run() { + db.prepare(sql).run(...bound); + return { success: true }; + }, + }; + return statement; + }, + } as unknown as D1Database; +} + +// Three companies: one sole trader (legal_form='ЕТ'), one legal entity (legal_form='ООД'), and one +// consortium whose lead member is a sole trader (the over-mask guard target). Mirrors the seed +// used in apps/web's integration lane for the privacy noindex proof (the lyubomir-bozhinov review +// observation). +const FIXTURE = ` +INSERT INTO bidders (id, name, bulstat, eik_normalized, eik_valid, is_consortium, kind, legal_form, settlement) +VALUES + ('eik:999000111', 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ', '999000111', '999000111', 1, 0, 'company', 'ЕТ', 'София'), + ('eik:200000002', 'СТРОЙ ООД', '200000002', '200000002', 1, 0, 'company', 'ООД', 'Пловдив'), + ('eik:300000003', 'ЕТ Иван Петров; Строй ООД','300000003', '300000003', 1, 1, 'consortium', 'ДЗЗД', 'Варна'); +INSERT INTO company_totals + (bidder_id, name, kind, eik, eik_valid, settlement, won_eur, contracts, authorities, eu_eur, first_date, last_date) +VALUES + ('eik:999000111', 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ', 'company', '999000111', 1, 'София', 9000000.0, 5, 1, 0, '2021-01-01', '2022-12-01'), + ('eik:200000002', 'СТРОЙ ООД', 'company', '200000002', 1, 'Пловдив', 8000000.0, 50, 1, 0, '2020-01-01', '2022-12-28'), + ('eik:300000003', 'ЕТ Иван Петров; Строй ООД','consortium', '300000003', 1, 'Варна', 7000000.0, 10, 1, 0, '2021-01-01', '2022-06-01'); +`; + +let open: DatabaseSync | null = null; + +function realDb(): { sqlite: DatabaseSync; db: D1Database } { + const sqlite = new DatabaseSync(':memory:'); + for (const migration of migrations) sqlite.exec(migration); + sqlite.exec(FIXTURE); + open = sqlite; + return { sqlite, db: d1(sqlite) }; +} + +afterEach(() => { + open?.close(); + open = null; +}); + +describe('listCompanies on real SQLite — rollup branch must include legal_form (PR #183 review MAJOR)', () => { + // Regression for PR #183 review (lyubomir-bozhinov, 2026-08-24, MAJOR #1): the previous + // `3cd5d23 perf(db): project legal_form only on the CSV path` commit assumed `toCompanyListItem` + // did not consume `legal_form`, but `3458dae fix(privacy): mask sole-trader rows in leaderboard + // list mappers` later added `r.legal_form` consumption to the mapper without updating the SQL + // source. The result is a SELECT that names `legal_form` against a FROM that does not project + // it. The mocked-DB unit suite passes because it returns rows directly without running SQL. + // On real D1 this returns 500 for `/companies` and `/companies.data`. + + it('does not throw `no such column: legal_form` on the unfiltered rollup branch', async () => { + // Direct replay of the SQL listCompanies builds (matches companies.ts:65 COLS and + // companies.ts:182 SELECT). No need to call listCompanies itself — the assertion is purely + // that the column projection round-trips against the real schema. + const { db } = realDb(); + await expect(listCompanies(db, {})).resolves.toMatchObject({ items: expect.any(Array) }); + }); + + it('masks a sole trader (legal_form=ЕТ) on the rollup branch — ЕИК null + label replaced', async () => { + const { db } = realDb(); + const page = await listCompanies(db, {}); + const et = page.items.find((i) => i.slug === '999000111'); + expect(et).toBeDefined(); + expect(et?.eik).toBeNull(); + expect(et?.name).toBe(MASKED_NATURAL_PERSON_LABEL); + expect(et?.displayName).toBe(MASKED_NATURAL_PERSON_LABEL); + expect(et?.hasEik).toBe(false); + }); + + it('keeps a legal entity (legal_form=ООД) verbatim on the rollup branch', async () => { + const { db } = realDb(); + const page = await listCompanies(db, {}); + const ood = page.items.find((i) => i.slug === '200000002'); + expect(ood).toBeDefined(); + expect(ood?.eik).toBe('200000002'); + expect(ood?.name).toBe('СТРОЙ ООД'); + expect(ood?.hasEik).toBe(true); + }); + + it('does NOT over-mask a consortium whose lead member is a sole trader (kind guard)', async () => { + const { db } = realDb(); + const page = await listCompanies(db, {}); + const consortium = page.items.find((i) => i.slug === '300000003'); + expect(consortium).toBeDefined(); + expect(consortium?.isConsortium).toBe(true); + expect(consortium?.eik).toBe('300000003'); + expect(consortium?.name).toBe('ЕТ Иван Петров; Строй ООД'); + }); + + it('round-trips with streamCompaniesCsv on the same rollup rows (parity between two surfaces)', async () => { + // The list mapper and the CSV streamer both mask the same sole-trader row through + // isNaturalPersonBidder(r.legal_form). On the rollup branch the SQL must project legal_form + // for BOTH, otherwise the list 500s while the CSV silently emits masked output. + const { db } = realDb(); + const page = await listCompanies(db, {}); + const csv = await streamCompaniesCsv(db, {}).text(); + const csvLines = csv.trim().split('\n').slice(1); // skip header + const header = csv.trim().split('\n')[0]!.split(','); + const eikIdx = header.indexOf('eik'); + const nameIdx = header.indexOf('name'); + + const etFromList = page.items.find((i) => i.slug === '999000111'); + expect(etFromList?.name).toBe(MASKED_NATURAL_PERSON_LABEL); + expect(etFromList?.eik).toBeNull(); + + // CSV orders by bidder_id ASC: eik:200000002, eik:300000003, eik:999000111 — the masked + // sole-trader row is the third. Identify it by its masked-name token rather than position so + // the assertion survives fixture re-orderings. + const csvEtLine = csvLines.find( + (line) => line.split(',')[nameIdx] === MASKED_NATURAL_PERSON_LABEL, + ); + expect(csvEtLine).toBeDefined(); + expect(csvEtLine!.split(',')[eikIdx]).toBe(''); // CSV writes empty ЕИК for masked rows + }); +}); diff --git a/packages/db/src/queries/companies.test.ts b/packages/db/src/queries/companies.test.ts index cc3ff40e9..87c5cf711 100644 --- a/packages/db/src/queries/companies.test.ts +++ b/packages/db/src/queries/companies.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { MASKED_NATURAL_PERSON_LABEL } from '@sigma/shared'; import { fakeD1, type FakeD1Call } from '@sigma/test-support'; import { listCompanies, streamCompaniesCsv, type CompanyListParams } from './companies'; import type { CompanyTotalsRow } from './rows'; @@ -19,6 +20,7 @@ const filteredRows: (CompanyTotalsRow & { sort_value: number })[] = [ eu_eur: 1000, first_date: '2024-01-01', last_date: '2024-01-02', + legal_form: 'ООД', sort_value: 1000, }, ]; @@ -40,6 +42,7 @@ const unfilteredRows: (CompanyTotalsRow & { sort_value: number })[] = [ eu_eur: 0, first_date: '2023-01-01', last_date: '2023-01-02', + legal_form: 'ЕООД', sort_value: 900, }, ]; @@ -119,6 +122,281 @@ describe('streamCompaniesCsv', () => { }); }); +describe('streamCompaniesCsv masking', () => { + // Two-row fixture: one sole trader (legal_form='ЕТ'), one legal entity ('ООД'). Both branches of + // source() — the company_totals rollup (default) and the base-aggregation CTE (filtered) — must + // yield the same masked output for the same row, because the per-row masking branch is keyed on + // r.legal_form and not on which source() branch the row came from. + const maskingRows: CompanyTotalsRow[] = [ + { + bidder_id: 'eik:222222222', + name: 'ЕТ Пример - Иван Иванов', + kind: 'company', + ownership_kind: null, + eik: '222222222', + eik_valid: 1, + settlement: 'Варна', + won_eur: 500, + contracts: 1, + authorities: 1, + primary_sector: '45', + eu_eur: 0, + first_date: '2024-02-01', + last_date: '2024-02-02', + legal_form: 'ЕТ', + }, + { + bidder_id: 'eik:333333333', + name: 'Пример ООД', + kind: 'company', + ownership_kind: null, + eik: '333333333', + eik_valid: 1, + settlement: 'Бургас', + won_eur: 700, + contracts: 1, + authorities: 1, + primary_sector: '45', + eu_eur: 0, + first_date: '2024-02-01', + last_date: '2024-02-02', + legal_form: 'ООД', + }, + ]; + + function maskingDb(): D1Database { + return { + prepare(sql: string) { + let bound: unknown[] = []; + return { + bind(...args: unknown[]) { + bound = args; + return this; + }, + async all<T>() { + if (sql.includes('ORDER BY bidder_id')) { + const afterId = bound.at(-2) as string; + return { + results: maskingRows.filter((r) => r.bidder_id > afterId) as T[], + }; + } + return { results: maskingRows as T[] }; + }, + async first<T>() { + return { n: maskingRows.length } as T; + }, + }; + }, + } as D1Database; + } + + function parseLine(line: string | undefined): string[] { + return (line ?? '').split(','); + } + + it('writes MASKED_NATURAL_PERSON_LABEL + empty EIK for an ЕТ row in the rollup branch', async () => { + const csv = await streamCompaniesCsv(maskingDb(), {}).text(); + const header = csv.trim().split('\n')[0]; + expect(header).toBe('eik,name,kind,settlement,won_eur,contracts,authorities,primary_sector'); + + const [maskedEik, maskedName] = parseLine(csv.trim().split('\n')[1]); + expect(maskedEik).toBe(''); + expect(maskedName).toBe(MASKED_NATURAL_PERSON_LABEL); + }); + + it('preserves verbatim name + populated EIK for an ООД row in the rollup branch', async () => { + const csv = await streamCompaniesCsv(maskingDb(), {}).text(); + const [legalEik, legalName] = parseLine(csv.trim().split('\n')[2]); + expect(legalEik).toBe('333333333'); + expect(legalName).toBe('Пример ООД'); + }); + + it('keeps the other columns unchanged for both masked and legal-entity rows', async () => { + const csv = await streamCompaniesCsv(maskingDb(), {}).text(); + const [ + maskedEik, + maskedName, + maskedKind, + maskedSettlement, + maskedWon, + maskedContracts, + maskedAuth, + maskedSector, + ] = parseLine(csv.trim().split('\n')[1]); + const [ + legalEik, + legalName, + legalKind, + legalSettlement, + legalWon, + legalContracts, + legalAuth, + legalSector, + ] = parseLine(csv.trim().split('\n')[2]); + + expect(maskedEik).toBe(''); + expect(maskedName).toBe(MASKED_NATURAL_PERSON_LABEL); + expect([ + maskedKind, + maskedSettlement, + maskedWon, + maskedContracts, + maskedAuth, + maskedSector, + ]).toEqual(['company', 'Варна', '500', '1', '1', '45']); + expect([ + legalEik, + legalName, + legalKind, + legalSettlement, + legalWon, + legalContracts, + legalAuth, + legalSector, + ]).toEqual(['333333333', 'Пример ООД', 'company', 'Бургас', '700', '1', '1', '45']); + }); + + it('masks rows whose legal_form is ЕТ regardless of which source() branch they came from (base-aggregation path)', async () => { + // A sector filter forces the base-aggregation CTE branch; the rollup subquery is bypassed. + // The ЕТ row must still be masked, because the per-row loop consults isNaturalPersonBidder + // against r.legal_form (which both source() branches now project). + const csv = await streamCompaniesCsv(maskingDb(), { sectors: ['45'] }).text(); + const [maskedEik, maskedName] = parseLine(csv.trim().split('\n')[1]); + expect(maskedEik).toBe(''); + expect(maskedName).toBe(MASKED_NATURAL_PERSON_LABEL); + }); + + it('preserves verbatim name + populated EIK for an ООД row in the base-aggregation path', async () => { + // Symmetric counterpart of the previous test: the same sector filter still routes through the + // base-aggregation CTE branch, but the ООД row must pass through unchanged. Masking only fires + // for rows whose legal_form flags them as a natural person — ООД is not one of those forms. + // This guards against a regression that breaks the ООД path of the base-aggregation branch + // (e.g. dropping the `b.legal_form AS legal_form` projection would still mask on name heuristic). + const csv = await streamCompaniesCsv(maskingDb(), { sectors: ['45'] }).text(); + const [legalEik, legalName] = parseLine(csv.trim().split('\n')[2]); + expect(legalEik).toBe('333333333'); + expect(legalName).toBe('Пример ООД'); + }); + + it('keeps the trailing columns (kind, settlement, won_eur, contracts, authorities, primary_sector) unchanged for the base-aggregation path', async () => { + // Same sector-filter setup as the previous two tests; we re-assert the full eight-column shape + // for the ООД row to pin down that the masking branch is the ONLY per-row divergence — every + // other cell must be the source-of-truth value passed through csvCell unchanged. + const csv = await streamCompaniesCsv(maskingDb(), { sectors: ['45'] }).text(); + const [, , kind, settlement, wonEur, contracts, authorities, primarySector] = parseLine( + csv.trim().split('\n')[2], + ); + expect([kind, settlement, wonEur, contracts, authorities, primarySector]).toEqual([ + 'company', + 'Бургас', + '700', + '1', + '1', + '45', + ]); + }); + + it('emits the same header row in the base-aggregation path as in the rollup path', async () => { + // The header is built once at stream start from the fixed `cols` array; it must not differ when + // source() returns the base-aggregation CTE instead of the company_totals rollup subquery. + const csv = await streamCompaniesCsv(maskingDb(), { sectors: ['45'] }).text(); + const header = csv.trim().split('\n')[0]; + expect(header).toBe('eik,name,kind,settlement,won_eur,contracts,authorities,primary_sector'); + }); + + // Regression for PR #183 review T-006 (companies mirror of the contracts bug): a consortium whose + // name / legal_form collides with a sole-trader signal was masked as a natural person. `kind` must + // take precedence so a JV keeps its name + ЕИК. The maskingDb() above only seeds `company` rows, so + // we build a dedicated fixture here. + it('does not mask a consortium row whose lead member looks like a sole trader (ЕТ name / sole-trader legal_form)', async () => { + const consortiumRows: CompanyTotalsRow[] = [ + { + bidder_id: 'eik:201345678', + name: 'ЕТ Иван Петров; Строй ООД', + kind: 'consortium', + ownership_kind: null, + eik: '201345678', + eik_valid: 1, + settlement: 'Пловдив', + won_eur: 900, + contracts: 1, + authorities: 1, + primary_sector: '45', + eu_eur: 0, + first_date: '2024-03-01', + last_date: '2024-03-02', + legal_form: 'ЕТ', + }, + ]; + const db: D1Database = { + prepare(sql: string) { + let bound: unknown[] = []; + return { + bind(...args: unknown[]) { + bound = args; + return this; + }, + async all<T>() { + if (sql.includes('ORDER BY bidder_id')) { + const afterId = bound.at(-2) as string; + return { results: consortiumRows.filter((r) => r.bidder_id > afterId) as T[] }; + } + return { results: consortiumRows as T[] }; + }, + async first<T>() { + return { n: consortiumRows.length } as T; + }, + }; + }, + } as unknown as D1Database; + + const csv = await streamCompaniesCsv(db, {}).text(); + const [eik, name, kind] = parseLine(csv.trim().split('\n')[1]); + expect(name).toBe('ЕТ Иван Петров; Строй ООД'); + expect(eik).toBe('201345678'); + expect(kind).toBe('consortium'); + }); +}); + +describe('listCompanies source() projection — legal_form on both paths (PR #183 review MAJOR #1)', () => { + // PR #183 review (lyubomir-bozhinov, 2026-08-24, MAJOR #1): `toCompanyListItem` masks sole-trader + // rows on `r.legal_form`, so the rollup subquery MUST project it on the list path too — a + // previous optimization (`3cd5d23 perf(db): project legal_form only on the CSV path`) made + // this conditional and broke the unfiltered `/companies` + `/companies.data` page with + // `no such column: legal_form` on real D1. Both call sites now request `legalForm: true`; the + // unfiltered rollup branch always projects legal_form via the LEFT JOIN on bidders (PK lookup, + // bounded cost). These unit tests assert the SQL shape; an end-to-end SQL test + // (`companies-rollup-sql.test.ts`) pins the real-D1 behavior. + function spySqlDb(): { db: D1Database; sql: string[] } { + const db = fakeDb(); + const sql: string[] = []; + const real = db.prepare.bind(db); + db.prepare = ((q: string) => { + sql.push(q); + return real(q); + }) as typeof db.prepare; + return { db, sql }; + } + + it('keeps LEFT JOIN bidders + b.legal_form projection in the rollup subquery on the listCompanies path', async () => { + const { db, sql } = spySqlDb(); + await listCompanies(db, {}); + const rollupQueries = sql.filter((q) => q.includes('company_totals') && q.includes('FROM (')); + expect(rollupQueries.length).toBeGreaterThan(0); + expect(rollupQueries.every((q) => q.includes('LEFT JOIN bidders'))).toBe(true); + expect(rollupQueries.every((q) => q.includes('b.legal_form AS legal_form'))).toBe(true); + }); + + it('keeps LEFT JOIN bidders + b.legal_form projection in the rollup subquery on the streamCompaniesCsv path', async () => { + const { db, sql } = spySqlDb(); + await streamCompaniesCsv(db, {}).text(); + const rollupQueries = sql.filter((q) => q.includes('company_totals') && q.includes('FROM (')); + expect(rollupQueries.length).toBeGreaterThan(0); + expect(rollupQueries.some((q) => q.includes('LEFT JOIN bidders'))).toBe(true); + expect(rollupQueries.some((q) => q.includes('b.legal_form AS legal_form'))).toBe(true); + }); +}); + describe('prototype-key params (untrusted query values)', () => { function spyDb(): { db: D1Database; sql: string[] } { const db = fakeDb(); diff --git a/packages/db/src/queries/companies.ts b/packages/db/src/queries/companies.ts index 8f2665238..753e08407 100644 --- a/packages/db/src/queries/companies.ts +++ b/packages/db/src/queries/companies.ts @@ -4,6 +4,7 @@ import type { CompanyListItem, EntityKind, FacetCount, Page } from '@sigma/api-contract'; import { CPV_SECTORS, ENTITY_TYPES } from '@sigma/config'; +import { cleanName, isNaturalPersonBidder, MASKED_NATURAL_PERSON_LABEL } from '@sigma/shared'; import { csvCell } from './csv'; import { assertCovers } from './filter-guard'; import { filterSignature, keyset, pageCursors } from './keyset'; @@ -61,7 +62,7 @@ const COUNT_BUCKETS: Record<string, string> = lookup({ const qs = (n: number) => Array.from({ length: n }, () => '?').join(', '); -const COLS = `bidder_id, name, kind, ownership_kind, eik, eik_valid, settlement, won_eur, contracts, authorities, primary_sector, eu_eur, first_date, last_date`; +const COLS = `bidder_id, name, kind, ownership_kind, eik, eik_valid, settlement, won_eur, contracts, authorities, primary_sector, eu_eur, first_date, last_date, legal_form`; function normalizeEu(eu: unknown): 'eu' | 'national' | null { return eu === 'eu' || eu === 'national' ? eu : null; @@ -74,9 +75,38 @@ function needsBase(p: CompanyListParams): boolean { /** * The FROM source: the rollup table, or a scoped base-aggregation CTE for sector/year/EU cross-cuts. * Keep consumed filter keys in sync with COMPANY_FILTER_KEYS and companyFilterSignature(). + * + * `legalForm` controls whether the `b.legal_form AS legal_form` projection is added on the + * rollup subquery (the unfiltered path). Both consumers need it on every path: the CSV streamer + * keys its natural-person masker off `r.legal_form`, and `toCompanyListItem` (the list-path mapper) + * ALSO reads `r.legal_form` for the same masking decision (PR #183 review T-001 — see the + * `fix(privacy): mask sole-trader rows in leaderboard list mappers` commit). The rollup subquery + * therefore ALWAYS projects legal_form on the list path; the `legalForm` flag is now a no-op + * preserved for the explicit "this query masks on legal_form" assertion at call sites. The + * base-aggregation CTE (filtered path) always projects legal_form: it already does an INNER JOIN + * on `bidders b` for the grouping, so the projection is free. */ -function source(p: CompanyListParams): { from: string; params: unknown[] } { - if (!needsBase(p)) return { from: 'company_totals', params: [] }; +function source( + p: CompanyListParams, + opts: { legalForm?: boolean } = {}, +): { from: string; params: unknown[] } { + // PR #183 review MAJOR #1 (lyubomir-bozhinov, 2026-08-24): listCompanies's COLS names + // `legal_form` (the mapper consumes it for masking), so the rollup subquery MUST project it. + // A previous perf optimization (`3cd5d23 perf(db): project legal_form only on the CSV path`) + // made this conditional, but the conditional predated the masking mapper's consumption and + // broke the unfiltered list path with `no such column: legal_form` on real D1. The join is on + // bidders.id (PK), so the cost is bounded — restoring it on both paths is the simplest + // correct change. The flag remains so callers can be explicit about their need and tests can + // assert the SQL shape. + const projectLegalForm = opts.legalForm ?? true; + if (!needsBase(p)) { + const project = projectLegalForm ? ', b.legal_form AS legal_form' : ''; + const join = projectLegalForm ? ' LEFT JOIN bidders AS b ON b.id = ct.bidder_id' : ''; + return { + from: `(SELECT ct.*${project} FROM company_totals AS ct${join})`, + params: [], + }; + } const where: string[] = ['c.amount_eur IS NOT NULL']; const params: unknown[] = []; if (p.sectors?.length) { @@ -91,8 +121,10 @@ function source(p: CompanyListParams): { from: string; params: unknown[] } { if (eu === 'eu') where.push('c.eu_funded = 1'); else if (eu === 'national') where.push('(c.eu_funded IS NULL OR c.eu_funded = 0)'); const single = p.sectors?.length === 1 ? p.sectors[0]! : null; + // Base-aggregation CTE always projects legal_form (see docstring above). const from = `( SELECT b.id AS bidder_id, b.name, b.kind, b.ownership_kind, b.eik_normalized AS eik, b.eik_valid, b.settlement, + b.legal_form AS legal_form, SUM(c.amount_eur) AS won_eur, COUNT(*) AS contracts, COUNT(DISTINCT t.authority_id) AS authorities, ${single ? '?' : 'NULL'} AS primary_sector, SUM(CASE WHEN c.eu_funded = 1 THEN c.amount_eur ELSE 0 END) AS eu_eur, @@ -144,7 +176,10 @@ export async function listCompanies( ): Promise<Page<CompanyListItem>> { const sort = SORTS[p.sort as keyof typeof SORTS] ?? SORTS['won']; const pageSize = p.pageSize ?? 25; - const src = source(p); + // toCompanyListItem masks sole-trader rows on `r.legal_form` (PR #183 review T-001) — the rollup + // subquery therefore must project it (the source() default is now to project, see PR #183 review + // MAJOR #1). Pass the flag explicitly so the SQL intent is searchable. + const src = source(p, { legalForm: true }); const ew = entityWhere(p); const signature = companyFilterSignature(p); const ks = keyset({ @@ -227,7 +262,7 @@ export async function getCompanyFacets(db: D1Database): Promise<CompanyFacets> { /** Streamed CSV of the company leaderboard (honours the same filters as the list page). */ export function streamCompaniesCsv(db: D1Database, p: CompanyListParams): Response { - const src = source(p); + const src = source(p, { legalForm: true }); // CSV masker keys off r.legal_form const ew = entityWhere(p); const cols = [ 'eik', @@ -261,17 +296,16 @@ export function streamCompaniesCsv(db: D1Database, p: CompanyListParams): Respon } let block = ''; for (const r of results) { + // `isNaturalPersonBidder`'s docstring delegates consortium filtering to the caller (a JV is a + // legal entity even if a lead member's name / legal_form looks like a sole trader). Guard with + // `kind` first so a consortium such as „ЕТ Иван Петров; Строй ООД" is NOT masked as a natural + // person — it keeps its name + ЕИК. Mirrors the guard in streamContractsCsv (PR #183 T-006). + const isNatural = + r.kind !== 'consortium' && isNaturalPersonBidder(cleanName(r.name), r.legal_form); + const name = isNatural ? MASKED_NATURAL_PERSON_LABEL : r.name; + const eik = isNatural ? '' : r.eik; block += - [ - r.eik, - r.name, - r.kind, - r.settlement, - r.won_eur, - r.contracts, - r.authorities, - r.primary_sector, - ] + [eik, name, r.kind, r.settlement, r.won_eur, r.contracts, r.authorities, r.primary_sector] .map(csvCell) .join(',') + '\n'; afterId = r.bidder_id; diff --git a/packages/db/src/queries/contracts.test.ts b/packages/db/src/queries/contracts.test.ts index 0315c2000..b426aa8aa 100644 --- a/packages/db/src/queries/contracts.test.ts +++ b/packages/db/src/queries/contracts.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; +import { MASKED_NATURAL_PERSON_LABEL } from '@sigma/shared'; import { fakeD1 } from '@sigma/test-support'; -import { getContractFacets, listContracts, normalizeContractSort } from './contracts'; +import { + getContractFacets, + listContracts, + normalizeContractSort, + streamContractsCsv, +} from './contracts'; describe('normalizeContractSort', () => { it('passes known sort keys through', () => { @@ -27,6 +33,7 @@ const contractRow = { bidder_id: 'eik:111111113', bidder_name: 'Bidder', bidder_kind: 'company' as const, + bidder_legal_form: 'ООД', procedure_type: 'Открита процедура', signed_at: '2024-01-01', bids_received: 3, @@ -111,3 +118,317 @@ describe('getContractFacets', () => { }); }); }); + +/** + * Privacy-masking surface for the CSV export — see ADR-0002 in `docs/architecture.md`. + * Each row of the export carries the bidder's `legal_form` so the streamer can decide whether + * to mask `contractor` and `contractor_eik`. The shared `isNaturalPersonBidder` predicate is the + * single source of truth; these tests pin the CSV-side behaviour. + */ +describe('streamContractsCsv masking', () => { + function makeCsvRow(overrides: Record<string, unknown>) { + return { + id: 'c:1', + rowid: 1, + subject: 'Subject', + unp: 'UNP-1', + cpv_code: '45000000', + eu_funded: 0, + authority_id: 'auth:123456789', + authority_name: 'Authority', + authority_eik: '123456789', + bidder_id: 'eik:111111111', + bidder_name: 'Bidder', + bidder_kind: 'company' as const, + contractor_eik: '111111111', + bidder_legal_form: 'ООД', + procedure_type: 'Открита процедура', + signed_at: '2024-01-01', + bids_received: 3, + amount_eur: 1000, + ...overrides, + }; + } + + function csvDb(rows: Record<string, unknown>[]): D1Database { + return { + prepare() { + let calls = 0; + return { + bind() { + return this; + }, + async all<T>() { + // First chunk returns the seeded rows (≤ CHUNK so the streamer closes after it). + // Any subsequent pull is answered with an empty array so the stream ends cleanly. + calls += 1; + return { results: (calls === 1 ? rows : []) as T[] }; + }, + async first<T>() { + return { total: rows.length, eur: 0, suspect: 0 } as T; + }, + }; + }, + } as unknown as D1Database; + } + + function parseCsv(text: string): string[][] { + return text + .replace(/^/, '') + .trim() + .split('\n') + .map((line) => line.split(',')); + } + + it('masks the contractor and clears contractor_eik when legal_form is a sole-trader form (ЕТ)', async () => { + const db = csvDb([ + makeCsvRow({ + id: 'c:natural', + rowid: 1, + bidder_name: 'ЕТ НИКОЛАЙ КИРОВ', + bidder_kind: 'company', + bidder_legal_form: 'ЕТ', + contractor_eik: '176011111', + }), + makeCsvRow({ + id: 'c:legal', + rowid: 2, + bidder_name: 'СОФАРМА ТРЕЙДИНГ', + bidder_kind: 'company', + bidder_legal_form: 'ООД', + contractor_eik: '121817309', + }), + ]); + + const rows = parseCsv(await streamContractsCsv(db, {}).text()); + + // The header carries the documented column order — kept as the contract the CSV consumer sees. + expect(rows[0]).toEqual([ + 'id', + 'unp', + 'subject', + 'authority', + 'authority_eik', + 'contractor', + 'contractor_eik', + 'kind', + 'sector_code', + 'procedure', + 'signed_at', + 'value_eur', + 'eu_funded', + 'bids_received', + ]); + + const naturalRow = rows[1]!; + expect(naturalRow[0]).toBe('natural'); // contractSlug strips the leading "c:" + expect(naturalRow[5]).toBe(MASKED_NATURAL_PERSON_LABEL); + expect(naturalRow[6]).toBe(''); + + const legalRow = rows[2]!; + expect(legalRow[0]).toBe('legal'); + expect(legalRow[5]).toBe('СОФАРМА ТРЕЙДИНГ'); + expect(legalRow[6]).toBe('121817309'); + }); + + it('keeps every other column verbatim for both masked and unmasked rows', async () => { + const db = csvDb([ + makeCsvRow({ + id: 'c:natural', + rowid: 1, + bidder_name: 'ЕТ НИКОЛАЙ КИРОВ', + bidder_kind: 'company', + bidder_legal_form: 'ЕТ', + contractor_eik: '176011111', + unp: 'UNP-NAT', + subject: 'Natural subject', + }), + makeCsvRow({ + id: 'c:legal', + rowid: 2, + bidder_name: 'СОФАРМА ТРЕЙДИНГ', + bidder_kind: 'company', + bidder_legal_form: 'ООД', + contractor_eik: '121817309', + unp: 'UNP-LEG', + subject: 'Legal subject', + }), + ]); + + const rows = parseCsv(await streamContractsCsv(db, {}).text()); + + // Skip the masked columns (5 = contractor, 6 = contractor_eik). Every other column must be the + // raw seeded value for both rows. + const header = rows[0]!; + const otherColumns = [0, 1, 2, 3, 4, 7, 8, 9, 10, 11, 12, 13]; + for (const row of rows.slice(1)) { + for (const col of otherColumns) { + expect(row[col]!, `row "${row[0]}" column "${header[col]!}" must be defined`).toBeDefined(); + } + } + + const naturalRow = rows.find((r) => r[0] === 'natural')!; + expect(naturalRow[1]).toBe('UNP-NAT'); + expect(naturalRow[2]).toBe('Natural subject'); + expect(naturalRow[7]).toBe('company'); + + const legalRow = rows.find((r) => r[0] === 'legal')!; + expect(legalRow[1]).toBe('UNP-LEG'); + expect(legalRow[2]).toBe('Legal subject'); + expect(legalRow[7]).toBe('company'); + }); + + it('masks via the leading-ЕТ name heuristic when legal_form is null', async () => { + const db = csvDb([ + makeCsvRow({ + id: 'c:heuristic', + rowid: 1, + bidder_name: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ', + bidder_kind: 'company', + bidder_legal_form: null, + contractor_eik: '176011111', + }), + ]); + + const rows = parseCsv(await streamContractsCsv(db, {}).text()); + + expect(rows[1]![5]).toBe(MASKED_NATURAL_PERSON_LABEL); + expect(rows[1]![6]).toBe(''); + }); + + it('preserves the kind column so downstream consumers can still distinguish companies from consortia', async () => { + const db = csvDb([ + makeCsvRow({ + id: 'c:consortium', + rowid: 1, + bidder_name: 'A ООД; B ЕООД', + bidder_kind: 'consortium', + bidder_legal_form: 'ДЗЗД', + contractor_eik: '999999999', + }), + ]); + + const rows = parseCsv(await streamContractsCsv(db, {}).text()); + + // ДЗЗД + consortium: predicate returns false; `kind` still flags the consortium shape. + expect(rows[1]![5]).toBe('A ООД и др.'); + expect(rows[1]![6]).toBe('999999999'); + expect(rows[1]![7]).toBe('consortium'); + }); + + // Regression for PR #183 review T-006: `isNaturalPersonBidder`'s docstring delegates consortium + // filtering to the caller, but `streamContractsCsv` invoked it WITHOUT a `bidder_kind === + // 'consortium'` guard. A consortium whose `bidder_name` starts with „ЕТ " (a lead member that is a + // sole trader, e.g. „ЕТ Иван Петров; Строй ООД") or whose `legal_form` collides with a sole-trader + // form was masked as a natural person — privacy-safe (over-masking) but a behavioral change that + // drops the lead member's name + ЕИК and contradicts the predicate's contract. Consortium rows now + // bypass the natural-person mask and keep the „… и др." shape from `entityName`. + it('does not mask a consortium row whose lead member looks like a sole trader (ЕТ name / sole-trader legal_form)', async () => { + const db = csvDb([ + makeCsvRow({ + id: 'c:et-led-consortium', + rowid: 1, + bidder_name: 'ЕТ Иван Петров; Строй ООД', + bidder_kind: 'consortium', + bidder_legal_form: 'ЕТ', + contractor_eik: '201345678', + }), + ]); + + const rows = parseCsv(await streamContractsCsv(db, {}).text()); + + expect(rows[1]![5]).toBe('ЕТ Иван Петров и др.'); + expect(rows[1]![6]).toBe('201345678'); + expect(rows[1]![7]).toBe('consortium'); + }); + + it('does not mask a consortium row matched only by the leading-ЕТ name heuristic (legal_form null)', async () => { + const db = csvDb([ + makeCsvRow({ + id: 'c:et-named-consortium', + rowid: 1, + bidder_name: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ; Логистика АД', + bidder_kind: 'consortium', + bidder_legal_form: null, + contractor_eik: '201345678', + }), + ]); + + const rows = parseCsv(await streamContractsCsv(db, {}).text()); + + expect(rows[1]![5]).toBe('ЕТ ДРИФТ - НИКОЛАЙ КИРОВ и др.'); + expect(rows[1]![6]).toBe('201345678'); + expect(rows[1]![7]).toBe('consortium'); + }); +}); + +describe('listContracts — privacy masking on the leaderboard list (PR #183 review #1)', () => { + // Sole traders must read "Частно лице" on /contracts + /contracts.data (RRv7 single-fetch twin) + // and on the home single-offer tables. The CSV streamer masks the same row upstream of bytes + // hitting R2; maskContractForPrivacy covers /contracts/:id.json. The list path is the third + // surface. + const soleTraderRow = { + id: 'c:et-1', + subject: 'S', + unp: 'UNP-et', + cpv_code: '45000000', + eu_funded: 0, + authority_id: 'auth:1', + authority_name: 'Authority', + bidder_id: 'eik:121817309', + bidder_name: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ', + bidder_kind: 'company' as const, + bidder_legal_form: 'ЕТ', + procedure_type: 'Открита процедура', + signed_at: '2024-01-01', + bids_received: 3, + amount_eur: 1000, + sort_value: 1000, + }; + const legalEntityRow = { ...contractRow }; + const consortiumRow = { + ...contractRow, + bidder_name: 'ЕТ Иван Петров; Строй ООД', + bidder_kind: 'consortium' as const, + bidder_legal_form: null, + }; + + function dbFor(row: object | object[]): D1Database { + const rows = Array.isArray(row) ? row : [row]; + return { + prepare(_sql: string) { + return { + bind() { + return this; + }, + async all<T>() { + return { results: rows as T[] }; + }, + async first<T>() { + return { total: rows.length, eur: rows.length ? 1000 : 0, suspect: 0 } as T; + }, + }; + }, + } as D1Database; + } + + it('masks bidderName + bidderDisplayName for a sole trader (ЕТ, legal_form=ЕТ)', async () => { + const page = await listContracts(dbFor(soleTraderRow), { pageSize: 10 }); + expect(page.items).toHaveLength(1); + expect(page.items[0]!.bidderName).toBe(MASKED_NATURAL_PERSON_LABEL); + expect(page.items[0]!.bidderDisplayName).toBe(MASKED_NATURAL_PERSON_LABEL); + }); + + it('preserves the legal-entity name verbatim', async () => { + const page = await listContracts(dbFor(legalEntityRow), { pageSize: 10 }); + expect(page.items[0]!.bidderName).toBe('Bidder'); + expect(page.items[0]!.bidderDisplayName).toBe('Bidder'); + }); + + it('does NOT mask a consortium whose first member is a sole trader (MAJOR-class guard)', async () => { + const page = await listContracts(dbFor(consortiumRow), { pageSize: 10 }); + expect(page.items[0]!.bidderName).toBe('ЕТ Иван Петров; Строй ООД'); + expect(page.items[0]!.bidderKind).toBe('consortium'); + expect(page.items[0]!.isConsortium).toBe(true); + }); +}); diff --git a/packages/db/src/queries/contracts.ts b/packages/db/src/queries/contracts.ts index 4bbb70200..cd5d9d1dc 100644 --- a/packages/db/src/queries/contracts.ts +++ b/packages/db/src/queries/contracts.ts @@ -3,7 +3,12 @@ import type { ContractListItem, FacetCount, Page } from '@sigma/api-contract'; import { CPV_SECTORS, PROCEDURE_GROUPS, procedureGroup } from '@sigma/config'; -import { cleanName, entityName } from '@sigma/shared'; +import { + cleanName, + entityName, + isNaturalPersonBidder, + MASKED_NATURAL_PERSON_LABEL, +} from '@sigma/shared'; import { csvCell } from './csv'; import { assertCovers } from './filter-guard'; import { @@ -99,6 +104,7 @@ interface ContractRow { bidder_id: string; bidder_name: string; bidder_kind: 'company' | 'consortium'; + bidder_legal_form: string | null; procedure_type: string; signed_at: string | null; bids_received: number | null; @@ -108,7 +114,7 @@ interface ContractRow { const SELECT = ` SELECT c.id, COALESCE(NULLIF(c.contract_subject, ''), t.title) AS subject, t.source_id AS unp, t.cpv_code, c.eu_funded, t.authority_id, a.name AS authority_name, - c.bidder_id, b.name AS bidder_name, b.kind AS bidder_kind, + c.bidder_id, b.name AS bidder_name, b.kind AS bidder_kind, b.legal_form AS bidder_legal_form, t.procedure_type, c.signed_at, c.bids_received, c.amount_eur`; const FROM = ` FROM contracts c @@ -206,6 +212,16 @@ function contractFilterSignature(p: ContractListParams): string { function toItem(r: ContractRow): ContractListItem { const authorityName = cleanName(r.authority_name); const bidderName = cleanName(r.bidder_name); + // Privacy (PR #183 review): the contract list — /contracts + /contracts.data (RRv7 single-fetch + // twin) and the home single-offer tables — shares this mapper. A sole trader must read + // "Частно лице" with no source name exposed on the leaderboard; the CSV path already masks the + // same row upstream of bytes hitting R2 (streamContractsCsv), and the JSON masker + // (maskContractForPrivacy) covers /contracts/:id.json. This is the third surface. The + // bidder_kind !== 'consortium' guard is required by isNaturalPersonBidder's docstring + // (caller filters JVs). + const isNaturalPerson = + r.bidder_kind !== 'consortium' && isNaturalPersonBidder(bidderName, r.bidder_legal_form); + const maskedBidderName = isNaturalPerson ? MASKED_NATURAL_PERSON_LABEL : bidderName; return { id: contractSlug(r.id), subject: r.subject, @@ -216,8 +232,10 @@ function toItem(r: ContractRow): ContractListItem { authoritySlug: authoritySlug(r.authority_id), authorityName, bidderSlug: companySlug(r.bidder_id), - bidderName, - bidderDisplayName: entityName(bidderName, r.bidder_kind), + bidderName: maskedBidderName, + bidderDisplayName: isNaturalPerson + ? MASKED_NATURAL_PERSON_LABEL + : entityName(maskedBidderName, r.bidder_kind), bidderKind: r.bidder_kind, procedureLabel: procedureGroup(r.procedure_type).label, signedAt: r.signed_at, @@ -426,6 +444,7 @@ interface CsvRow extends ContractRow { rowid: number; authority_eik: string; contractor_eik: string | null; + bidder_legal_form: string | null; } /** A streamed text/csv Response honouring the same filters; a 190k-row export never materialises. */ @@ -443,7 +462,8 @@ export function streamContractsCsv(db: D1Database, p: ContractListParams): Respo async pull(controller) { if (done) return; const where = filters.sql ? filters.sql + ' AND c.rowid > ?' : ' WHERE c.rowid > ?'; - const sql = `${SELECT}, c.rowid AS rowid, a.bulstat AS authority_eik, b.eik_normalized AS contractor_eik + const sql = `${SELECT}, c.rowid AS rowid, a.bulstat AS authority_eik, b.eik_normalized AS contractor_eik, + b.legal_form AS bidder_legal_form ${FROM}${where} ORDER BY c.rowid LIMIT ?`; const { results } = await db .prepare(sql) @@ -456,6 +476,17 @@ export function streamContractsCsv(db: D1Database, p: ContractListParams): Respo } let block = ''; for (const r of results) { + const bidderName = cleanName(r.bidder_name); + // `isNaturalPersonBidder`'s docstring delegates consortium filtering to the caller (a JV is a + // legal entity even if a lead member's name / legal_form looks like a sole trader). Guard with + // `bidder_kind` first so a consortium such as „ЕТ Иван Петров; Строй ООД" is NOT over-masked as + // a natural person — it keeps the „… и др." shape and the consortium ЕИК. + const isNatural = + r.bidder_kind !== 'consortium' && isNaturalPersonBidder(bidderName, r.bidder_legal_form); + const contractor = isNatural + ? MASKED_NATURAL_PERSON_LABEL + : entityName(bidderName, r.bidder_kind); + const contractorEik = isNatural ? '' : r.contractor_eik; block += [ // CSV carries the RAW id (no URL escaping): literal `/`, `%`, … — not the `%2F`/`%25` @@ -466,8 +497,8 @@ export function streamContractsCsv(db: D1Database, p: ContractListParams): Respo r.subject, cleanName(r.authority_name), r.authority_eik, - entityName(cleanName(r.bidder_name), r.bidder_kind), - r.contractor_eik, + contractor, + contractorEik, r.bidder_kind, r.cpv_code ? r.cpv_code.slice(0, 2) : '', procedureGroup(r.procedure_type).label, diff --git a/packages/db/src/queries/details.ts b/packages/db/src/queries/details.ts index 9786347ef..4a476ffe4 100644 --- a/packages/db/src/queries/details.ts +++ b/packages/db/src/queries/details.ts @@ -421,6 +421,7 @@ interface ContractDetailRow { bidder_kind: 'company' | 'consortium'; bidder_eik: string | null; bidder_settlement: string | null; + bidder_legal_form: string | null; } interface AmendmentRow { @@ -465,7 +466,7 @@ export const AMENDMENTS_SQL = `SELECT am.value_before, am.value_after, am.value_ export async function getContract( db: D1Database, contractId: string, -): Promise<ContractRecord | null> { +): Promise<(ContractRecord & { bidder_legal_form: string | null }) | null> { const r = await db .prepare( `SELECT c.id, c.tender_id, c.contract_subject, c.contract_number, c.document_number, c.lot_id, @@ -483,7 +484,7 @@ export async function getContract( t.authority_id, a.name AS authority_name, a.type_group AS authority_type_group, a.settlement AS authority_settlement, c.bidder_id, b.name AS bidder_name, b.kind AS bidder_kind, b.eik_normalized AS bidder_eik, - b.settlement AS bidder_settlement, + b.settlement AS bidder_settlement, b.legal_form AS bidder_legal_form, (SELECT COUNT(*) FROM contracts c2 WHERE c2.tender_id = c.tender_id) AS tender_awards FROM contracts c JOIN tenders t ON t.id = c.tender_id @@ -746,5 +747,6 @@ export async function getContract( authority: r.source_authority_name ?? r.authority_name, bidder: r.bidder_name, }, + bidder_legal_form: r.bidder_legal_form, }; } diff --git a/packages/db/src/queries/rows.test.ts b/packages/db/src/queries/rows.test.ts index eedfd5768..a8694dae4 100644 --- a/packages/db/src/queries/rows.test.ts +++ b/packages/db/src/queries/rows.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { MASKED_NATURAL_PERSON_LABEL } from '@sigma/shared'; import { toAuthorityListItem, toCompanyListItem, typeLabel } from './rows'; describe('typeLabel', () => { @@ -33,6 +34,7 @@ describe('toCompanyListItem', () => { eu_eur: 10000, first_date: '2022-01-01', last_date: '2024-06-01', + legal_form: 'ООД', }; it('maps core fields', () => { @@ -77,6 +79,82 @@ describe('toCompanyListItem', () => { }); }); +describe('toCompanyListItem — privacy masking on the leaderboard list (PR #183 review #1)', () => { + // /companies and /companies.data and the home top-10 all share this mapper. Mask ЕИК + name on + // the natural-person branch so a sole trader's ЕИК does not leak through the leaderboard list + // payload (HTML + RRv7 single-fetch .data twin). The CSV streamer already masks the same row + // upstream of bytes hitting R2 (companies.ts); the JSON masker for /contracts/:id.json masks + // maskContractForPrivacy; this closes the third surface. Consortium guard is required by + // isNaturalPersonBidder's docstring (caller filters JVs). + const baseRow = { + bidder_id: 'eik:121817309', + kind: 'company' as const, + ownership_kind: null, + eik: '121817309', + eik_valid: 1, + settlement: 'София', + won_eur: 50000, + contracts: 5, + authorities: 2, + primary_sector: '45', + eu_eur: 0, + first_date: '2022-01-01', + last_date: '2024-06-01', + }; + + it('masks ЕИК and name for a sole trader with legal_form=ЕТ', () => { + const item = toCompanyListItem({ + ...baseRow, + name: 'ЕТ ДРИФТ - НИКОЛАЙ КИРОВ', + legal_form: 'ЕТ', + }); + expect(item.eik).toBeNull(); + expect(item.name).toBe(MASKED_NATURAL_PERSON_LABEL); + expect(item.displayName).toBe(MASKED_NATURAL_PERSON_LABEL); + expect(item.hasEik).toBe(false); + }); + + it('masks on the leading-ЕТ name heuristic when legal_form is null', () => { + // Same pattern CSV streamer guards with: a row whose name starts with "ЕТ " but lacks a + // legal_form value still trips the predicate. + const item = toCompanyListItem({ + ...baseRow, + name: 'ЕТ Иван Петров', + legal_form: null, + }); + expect(item.eik).toBeNull(); + expect(item.name).toBe(MASKED_NATURAL_PERSON_LABEL); + }); + + it('preserves ЕИК + name verbatim for a legal entity (ООД)', () => { + const item = toCompanyListItem({ + ...baseRow, + name: 'СТРОЙ ООД', + legal_form: 'ООД', + }); + expect(item.eik).toBe('121817309'); + expect(item.name).toBe('СТРОЙ ООД'); + expect(item.displayName).toBe('СТРОЙ ООД'); + expect(item.hasEik).toBe(true); + }); + + it('does NOT mask a consortium whose first member is a sole trader (MAJOR-class guard)', () => { + // Mirrors the CSV streamer guard: a JV like "ЕТ Иван Петров; Строй ООД" must keep its + // consortium name + ЕИК. Failing this is a regression to the pre-`bidder_kind !== 'consortium'` + // guard bug class. + const item = toCompanyListItem({ + ...baseRow, + kind: 'consortium', + name: 'ЕТ Иван Петров; Строй ООД', + legal_form: null, + eik: '200000000', + }); + expect(item.eik).toBe('200000000'); + expect(item.name).toBe('ЕТ Иван Петров; Строй ООД'); + expect(item.isConsortium).toBe(true); + }); +}); + describe('toAuthorityListItem', () => { const base = { authority_id: 'auth:000695089', diff --git a/packages/db/src/queries/rows.ts b/packages/db/src/queries/rows.ts index 9d4a48a0e..b7ab1833a 100644 --- a/packages/db/src/queries/rows.ts +++ b/packages/db/src/queries/rows.ts @@ -8,7 +8,12 @@ import type { OwnershipKind, } from '@sigma/api-contract'; import { ENTITY_TYPES } from '@sigma/config'; -import { cleanName, entityName } from '@sigma/shared'; +import { + MASKED_NATURAL_PERSON_LABEL, + cleanName, + entityName, + isNaturalPersonBidder, +} from '@sigma/shared'; import { authoritySlug, companySlug } from './identity'; import { sectorRef } from './sectors'; @@ -44,19 +49,36 @@ export interface CompanyTotalsRow { eu_eur: number; first_date: string | null; last_date: string | null; + legal_form: string | null; } export function toCompanyListItem(r: CompanyTotalsRow): CompanyListItem { - const hasEik = r.eik_valid === 1 && Boolean(r.eik); + // Privacy (PR #183 review): a sole trader / natural person has the same `ЕТ` / sole-trader signal + // in the rollup as on the detail page and in the CSV/JSON exports. Mask ЕИК and the source name + // here so /companies, /companies.data (RRv7 single-fetch twin), and the home top-10 all carry + // the masked values — they share this mapper, so the new branch covers all three in one place. + // + // Consortium guard mirrors the CSV streamer (`bidder_kind !== 'consortium' && isNaturalPersonBidder(...)` + // in companies.ts): isNaturalPersonBidder's docstring delegates JV filtering to the caller, so a + // consortium whose first member is a sole trader (e.g. "ЕТ Иван Петров; Строй ООД") would + // otherwise over-mask — losing the "… и др." shape and the consortium ЕИК. The guard keeps the + // JV's name + ЕИК verbatim. + // + // The unmasked name is also held back from `displayName`: a masked row must read "Частно лице" + // everywhere on the list page (and on the home page) — exposing the masked `displayName` next + // to a null ЕИК would let a crawler infer the natural-person class without needing the ЕИК. + const isNaturalPerson = + r.kind !== 'consortium' && isNaturalPersonBidder(cleanName(r.name), r.legal_form); + const name = isNaturalPerson ? MASKED_NATURAL_PERSON_LABEL : cleanName(r.name); return { slug: companySlug(r.bidder_id), - name: cleanName(r.name), - displayName: entityName(cleanName(r.name), r.kind), + name, + displayName: isNaturalPerson ? MASKED_NATURAL_PERSON_LABEL : entityName(name, r.kind), kind: r.kind, isConsortium: r.kind === 'consortium', - eik: r.eik, + eik: isNaturalPerson ? null : r.eik, eikValid: r.eik_valid === 1, - hasEik, + hasEik: isNaturalPerson ? false : r.eik_valid === 1 && Boolean(r.eik), ownershipKind: r.ownership_kind, settlement: r.settlement, sector: sectorRef(r.primary_sector), diff --git a/packages/shared/src/format.test.ts b/packages/shared/src/format.test.ts index cbbd63669..b63706b1a 100644 --- a/packages/shared/src/format.test.ts +++ b/packages/shared/src/format.test.ts @@ -4,8 +4,10 @@ import { count, date, entityName, + isNaturalPersonBidder, isNaturalPersonProfileName, longDate, + MASKED_NATURAL_PERSON_LABEL, money, moneyBare, monthYear, @@ -185,3 +187,117 @@ describe('isNaturalPersonProfileName', () => { expect(isNaturalPersonProfileName('СОФАРМА ТРЕЙДИНГ АД')).toBe(false); }); }); + +describe('isNaturalPersonBidder', () => { + it('flags a sole-trader legal form even when the name has no ЕТ prefix (legal_form rule wins)', () => { + expect(isNaturalPersonBidder('Some Company OOOD', 'ЕТ')).toBe(true); + }); + + it('flags the Latin-script ET legal form', () => { + expect(isNaturalPersonBidder('ET DRIFT', 'ET')).toBe(true); + }); + + it('flags expanded sole-trader legal forms', () => { + expect(isNaturalPersonBidder('Some Trader', 'ЕДНОЛИЧЕН ТЪРГОВЕЦ')).toBe(true); + expect(isNaturalPersonBidder('Some Trader', 'SOLE TRADER')).toBe(true); + expect(isNaturalPersonBidder('Some Trader', 'INDIVIDUAL')).toBe(true); + }); + + it('does not flag ordinary company legal forms', () => { + expect(isNaturalPersonBidder('СОФАРМА ТРЕЙДИНГ', 'ООД')).toBe(false); + expect(isNaturalPersonBidder('СОФАРМА ТРЕЙДИНГ', 'ЕАД')).toBe(false); + }); + + it('returns false for a legal-entity bidder (АД)', () => { + expect(isNaturalPersonBidder('СОФАРМА ТРЕЙДИНГ', 'АД')).toBe(false); + }); + + it('falls back to the leading-ЕТ name heuristic when legal_form is null', () => { + expect(isNaturalPersonBidder('ЕТ Пример', null)).toBe(true); + }); + + it('falls back to the leading-ET (Latin) name heuristic when legal_form does not match', () => { + expect(isNaturalPersonBidder('ET Example', 'unknown')).toBe(true); + }); + + it('returns false for a consortium whose legal_form is ДЗЗД and name has no ЕТ prefix', () => { + expect(isNaturalPersonBidder('Обединение', 'ДЗЗД')).toBe(false); + }); + + it('returns false for a plain company with a non-matching legal form and no ЕТ prefix', () => { + expect(isNaturalPersonBidder('СОФАРМА ТРЕЙДИНГ', 'АД')).toBe(false); + }); +}); + +describe('MASKED_NATURAL_PERSON_LABEL', () => { + it('is a non-empty string', () => { + expect(typeof MASKED_NATURAL_PERSON_LABEL).toBe('string'); + expect(MASKED_NATURAL_PERSON_LABEL.length).toBeGreaterThan(0); + }); + + it('is not the verbatim sole-trader name it replaces', () => { + expect(MASKED_NATURAL_PERSON_LABEL).not.toBe('ЕТ ДРИФТ - НИКОЛАЙ КИРОВ'); + }); + + it('is safe to render as JSON / HTML and as a CSV cell (no comma, no quote)', () => { + expect(MASKED_NATURAL_PERSON_LABEL).not.toMatch(/[,"]/); + }); +}); + +/** + * End-to-end smoke for the shared predicate surface. + * + * `isNaturalPersonBidder` and `isNaturalPersonProfileName` are the SINGLE source of truth for the + * noindex / masking decision that every downstream flow depends on — F2 (CSV masking in + * `streamContractsCsv` / `streamCompaniesCsv`), F3 (JSON masking in `/contracts/:id.json`), and F4 + * (the privacy doc). If the two helpers drift apart, a sole-trader / natural-person identifier + * would leak either through search indexing or through a machine-readable body — exactly the + * regression this block guards against. + * + * The masking label referenced below is the same `MASKED_NATURAL_PERSON_LABEL` constant that F2 + * and F3 substitute for `contractor_eik` / `eik` / `contractor` / `bidder.name` / + * `sourceNames.bidder`. Importing it here pins the test contract to the runtime masker so a + * future rename in `format.ts` cannot silently desynchronize them. + */ +describe('shared predicate surface — single source of truth for the noindex / masking decision', () => { + it('agrees between isNaturalPersonBidder (legal_form rule) and isNaturalPersonProfileName (name heuristic) for a sole trader', () => { + // legal_form rule: ЕТ alone is enough to flag the bidder as a natural person + expect(isNaturalPersonBidder('Sole', 'ЕТ')).toBe(true); + // name heuristic: the leading-ЕТ marker on the display name reaches the same verdict + expect(isNaturalPersonProfileName('ЕТ Sole')).toBe(true); + // Both helpers must classify their respective inputs as a natural person — no drift. + }); + + it('agrees between the two helpers for a legal entity (both return false)', () => { + expect(isNaturalPersonBidder('Acme', 'ООД')).toBe(false); + expect(isNaturalPersonProfileName('Acme')).toBe(false); + }); + + it('returns the documented natural-person verdict for a six-pair truth table', () => { + const truthTable: ReadonlyArray<{ + name: string; + legalForm: string | null; + naturalPerson: boolean; + }> = [ + { name: 'Sole', legalForm: 'ЕТ', naturalPerson: true }, // legal_form rule wins + { name: 'ЕТ Leading Name', legalForm: null, naturalPerson: true }, // name heuristic fallback + { name: 'Some Trader', legalForm: 'ЕДНОЛИЧЕН ТЪРГОВЕЦ', naturalPerson: true }, // expanded sole-trader form + { name: 'Acme', legalForm: 'ООД', naturalPerson: false }, // ordinary ООД + { name: 'СОФАРМА ТРЕЙДИНГ', legalForm: 'АД', naturalPerson: false }, // ordinary АД + { name: 'Обединение', legalForm: 'ДЗЗД', naturalPerson: false }, // consortium — not a sole trader + ]; + + for (const { name, legalForm, naturalPerson } of truthTable) { + expect( + isNaturalPersonBidder(name, legalForm), + `isNaturalPersonBidder(${JSON.stringify(name)}, ${JSON.stringify(legalForm)})`, + ).toBe(naturalPerson); + } + + // The masking label used by F2 / F3 must remain the same constant exported from this package. + // If a downstream caller ever drifts onto a different label, this assertion surfaces it here. + expect(typeof MASKED_NATURAL_PERSON_LABEL).toBe('string'); + expect(MASKED_NATURAL_PERSON_LABEL.length).toBeGreaterThan(0); + expect(MASKED_NATURAL_PERSON_LABEL).not.toBe('Sole'); + }); +}); diff --git a/packages/shared/src/format.ts b/packages/shared/src/format.ts index 895dd971f..e39a13fe6 100644 --- a/packages/shared/src/format.ts +++ b/packages/shared/src/format.ts @@ -198,6 +198,40 @@ export function isNaturalPersonProfileName(name: string): boolean { return normalized.startsWith('ЕТ ') || normalized.startsWith('ET '); } +/** + * Bulgarian label substituted for a natural-person or sole-trader identifier in machine-readable + * exports (CSV cells, JSON `name` / `sourceNames` fields). Safe to render as HTML or JSON, and + * contains no comma/quote so the CSV encoder leaves it unquoted. See `isNaturalPersonBidder`. + */ +export const MASKED_NATURAL_PERSON_LABEL = 'Частно лице'; + +/** + * Canonical natural-person / sole-trader predicate for machine-readable masking. Combines the + * `legal_form` rules with the leading-`ЕТ` name heuristic — so callers only need one check to + * decide whether to mask ЕИК and the raw source name. This is the SINGLE source of truth: every + * downstream surface (HTML `noindex` in `apps/web/app/routes/company.tsx`, CSV masking in + * `streamContractsCsv` / `streamCompaniesCsv`, JSON masking in `/contracts/:id.json`) calls this + * predicate — there is no inline duplicate of the `legal_form` rules in any route (ADR-0007 §1 + * removed the legacy inline `isSingleNaturalPersonProfile`). Caller is responsible for filtering + * consortium rows (see the `bidder_kind` / `kind` guards in the CSV streamers); the function + * itself only inspects `legalForm` and `name`. + */ +export function isNaturalPersonBidder(name: string, legalForm: string | null): boolean { + if (legalForm) { + const normalized = legalForm.trim().toUpperCase(); + if ( + normalized === 'ЕТ' || + normalized === 'ET' || + normalized.includes('ЕДНОЛИЧЕН ТЪРГОВЕЦ') || + normalized.includes('SOLE TRADER') || + normalized.includes('INDIVIDUAL') + ) { + return true; + } + } + return isNaturalPersonProfileName(name); +} + /** * Display name for a winning entity. A consortium row holds a `;`-joined member list → show the * first member + „и др." (the **Обединение** badge is rendered separately by the caller). Companies