diff --git a/.npmrc b/.npmrc index 43c97e719..94005ac25 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ package-lock=false +confirm-modules-purge=false \ No newline at end of file diff --git a/Dockerfile.dev b/Dockerfile.dev index 06e2f00d3..7e92fb163 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -14,6 +14,10 @@ RUN pnpm install --frozen-lockfile # Uncomment the following line to disable telemetry at run time ENV NEXT_TELEMETRY_DISABLED=1 +# Pre-create .next so the anonymous volume masking it (see docker-compose.yml) +# initializes owned by node, not root — otherwise next dev hits EACCES on mkdir. +RUN mkdir -p /app/.next + # do not run as root user RUN chown -R node:node /app USER node diff --git a/bin/inject-encrypted-results.ts b/bin/inject-encrypted-results.ts new file mode 100644 index 000000000..3ac262a0e --- /dev/null +++ b/bin/inject-encrypted-results.ts @@ -0,0 +1,139 @@ +/* eslint-disable no-console */ +/** + * DEV-ONLY: inject encrypted results into a job, mimicking what the Trusted Output App + * POSTs to /api/job/[jobId]/results — without the org-API auth dance. Lets you exercise + * the reviewer-decrypt → approve(re-wrap) → researcher-decrypt flow locally. + * + * Prereq: the job's reviewer(s) (enclave org users) must already have a key — generate it + * in the UI at /account/keys first, so getOrgPublicKeys returns a recipient to encrypt for. + * + * Run inside the container: + * docker exec mgmnt-app pnpm exec tsx bin/inject-encrypted-results.ts + */ +import { db } from '@/database' +import { storeStudyEncryptedLogFile, storeStudyEncryptedResultsFile } from '@/server/storage' +import { ResultsWriter } from 'si-encryption/job-results/writer' + +// Inline (not @/server/db/queries) — that module pulls in the Action framework → Clerk, +// which tsx can't import standalone. Mirrors getOrgPublicKeys. +async function orgPublicKeys(orgId: string): Promise<{ publicKey: ArrayBuffer; fingerprint: string }[]> { + const rows = await db + .selectFrom('orgUser') + .innerJoin('userPublicKey', 'userPublicKey.userId', 'orgUser.userId') + .select(['userPublicKey.publicKey', 'userPublicKey.fingerprint']) + .where('orgUser.orgId', '=', orgId) + .execute() + const keys = rows.map(({ publicKey, fingerprint }) => { + const ab = new ArrayBuffer(publicKey.byteLength) + new Uint8Array(ab).set(publicKey) + return { publicKey: ab, fingerprint } + }) + // Seed data has placeholder keys (literal text bytes, not SPKI DER) that throw when wrapped. + // Pre-validate with si-encryption's import params and skip the duds. + const valid: typeof keys = [] + for (const k of keys) { + try { + await crypto.subtle.importKey('spki', k.publicKey, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, [ + 'encrypt', + ]) + valid.push(k) + } catch { + console.warn( + ` skipping invalid public key (fingerprint ${k.fingerprint}) — not SPKI DER, likely seed placeholder`, + ) + } + } + return valid +} + +const toArrayBuffer = (s: string): ArrayBuffer => { + const buf = Buffer.from(s, 'utf-8') + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) +} + +async function buildZip( + files: Array<{ name: string; content: string }>, + keys: { publicKey: ArrayBuffer; fingerprint: string }[], +): Promise { + const writer = new ResultsWriter(keys) + for (const f of files) await writer.addFile(f.name, toArrayBuffer(f.content)) + const zip = await writer.generate() + return new File([zip], 'encrypted.zip', { type: 'application/zip' }) +} + +async function main() { + const jobId = process.argv[2] + if (!jobId) throw new Error('usage: tsx bin/inject-encrypted-results.ts ') + + const info = await db + .selectFrom('studyJob') + .innerJoin('study', 'study.id', 'studyJob.studyId') + .innerJoin('org', 'org.id', 'study.orgId') + .select(['studyJob.id as studyJobId', 'study.id as studyId', 'study.orgId as orgId', 'org.slug as orgSlug']) + .where('studyJob.id', '=', jobId) + .executeTakeFirstOrThrow(() => new Error(`job ${jobId} not found`)) + + // Encrypt for the enclave org's reviewer keys — exactly what TOA does. The reviewer + // decrypts with their private key, then re-wraps for researchers at approve. + const reviewerKeys = await orgPublicKeys(info.orgId) + if (!reviewerKeys.length) { + throw new Error( + `org ${info.orgSlug} has no public keys — log in as the reviewer and generate one at /account/keys first`, + ) + } + console.info(`Encrypting for ${reviewerKeys.length} reviewer key(s) in org ${info.orgSlug}`) + + // Idempotent: don't stack duplicate result rows if re-run against the same job. + const alreadyHasResults = await db + .selectFrom('studyJobFile') + .select('id') + .where('studyJobId', '=', jobId) + .where('fileType', '=', 'ENCRYPTED-RESULT') + .executeTakeFirst() + + if (alreadyHasResults) { + console.info(`Job already has encrypted results — skipping store (delete study_job_file rows to re-inject).`) + } else { + // One results file + one logs file, matching the real enclave helper (one file per upload). + const resultsZip = await buildZip([{ name: 'results.csv', content: 'group,count\nA,42\nB,17\n' }], reviewerKeys) + const logZip = await buildZip([{ name: 'run.log', content: 'job started\njob finished ok\n' }], reviewerKeys) + await storeStudyEncryptedResultsFile(info, resultsZip) + await storeStudyEncryptedLogFile(info, logZip, 'ENCRYPTED-CODE-RUN-LOG') + } + + // A results-stage job needs code approved first, else the proposal review buttons stay visible. + // Inject CODE-APPROVED before RUN-COMPLETE so the latest status stays RUN-COMPLETE. + const statuses = await db + .selectFrom('jobStatusChange') + .select(['status', 'createdAt']) + .where('studyJobId', '=', jobId) + .execute() + const has = (s: string) => statuses.some((r) => r.status === s) + const runComplete = statuses.find((r) => r.status === 'RUN-COMPLETE') + + if (!has('CODE-APPROVED')) { + // 1s before an existing RUN-COMPLETE, or 2s in the past on a fresh job so it + // reliably precedes the RUN-COMPLETE inserted just below. + const ts = runComplete + ? new Date(new Date(runComplete.createdAt).getTime() - 1000) + : new Date(Date.now() - 2000) + await db + .insertInto('jobStatusChange') + .values({ status: 'CODE-APPROVED', studyJobId: jobId, createdAt: ts }) + .execute() + } + if (!runComplete) { + await db.insertInto('jobStatusChange').values({ status: 'RUN-COMPLETE', studyJobId: jobId }).execute() + } + // Code approved ⇒ proposal-level review is done; study is back to APPROVED. + await db.updateTable('study').set({ status: 'APPROVED' }).where('id', '=', info.studyId).execute() + + console.info(`✓ injected encrypted results + logs for job ${jobId} (CODE-APPROVED → RUN-COMPLETE, study APPROVED)`) + console.info(` Now: review as the reviewer, decrypt with the reviewer PEM, Approve in the Study Status card.`) + await db.destroy() +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/docker-compose.yml b/docker-compose.yml index 321f25c23..9f1d11176 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -127,7 +127,10 @@ services: volumes: - ./:/app/ - node_modules:/app/node_modules - restart: unless-stopped + # Mask the host's .next so the container owns its own Turbopack cache. + # Sharing it over the ./ bind mount poisons the cache across host/container. + - /app/.next + restart: 'no' networks: - mgmnt-app ports: diff --git a/package.json b/package.json index 3ea2d09bc..9cceff708 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "author": "Nathan Stitt", "main": "index.js", "type": "module", - "packageManager": "pnpm@11.3.0", + "packageManager": "pnpm@11.7.0+sha512.19cc852c120c7125760f2443ee6be0ca5b40f9f50598de1a09a1f177503e010e57c23c77646e01e761de59bf874fb22a3398c33ab9691fc13eb946b6f0f4d620", "engines": { "node": ">=22", "pnpm": ">=11" @@ -113,7 +113,7 @@ "react-phone-number-input": "^3.4.16", "remeda": "^2.33.6", "server-only": "^0.0.1", - "si-encryption": "github:safeinsights/encryption#2e35d3bb56f02bfc0e6d5cd9d83c54637ed0ac20", + "si-encryption": "github:safeinsights/encryption#3cb802df301d02813f8f67261c34144686b8540a", "uuid": "^14.0.0", "validator": "^13.15.26", "yjs": "^13.6.30", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7ea3d1a6..3cfb65b81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,7 @@ overrides: '@types/react': 19.2.14 esbuild: ^0.28.1 form-data: ^4.0.6 + hono: ^4.12.25 minimatch: ^10.2.1 picomatch: ^4.0.4 postcss: ^8.5.10 @@ -223,8 +224,8 @@ importers: specifier: ^0.0.1 version: 0.0.1 si-encryption: - specifier: github:safeinsights/encryption#2e35d3bb56f02bfc0e6d5cd9d83c54637ed0ac20 - version: https://codeload.github.com/safeinsights/encryption/tar.gz/2e35d3bb56f02bfc0e6d5cd9d83c54637ed0ac20 + specifier: github:safeinsights/encryption#3cb802df301d02813f8f67261c34144686b8540a + version: https://codeload.github.com/safeinsights/encryption/tar.gz/3cb802df301d02813f8f67261c34144686b8540a uuid: specifier: ^14.0.0 version: 14.0.0 @@ -6124,8 +6125,8 @@ packages: resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} engines: {node: '>= 0.4'} - si-encryption@https://codeload.github.com/safeinsights/encryption/tar.gz/2e35d3bb56f02bfc0e6d5cd9d83c54637ed0ac20: - resolution: {gitHosted: true, integrity: sha512-P0G+iFlGfqxr8HaPNmYixChwfV99gEqDfuDN2Ft1AQ0DYABaBy32fncswHa0mxgQQRVpFwQzth7j0o8yWGgcCA==, tarball: https://codeload.github.com/safeinsights/encryption/tar.gz/2e35d3bb56f02bfc0e6d5cd9d83c54637ed0ac20} + si-encryption@https://codeload.github.com/safeinsights/encryption/tar.gz/3cb802df301d02813f8f67261c34144686b8540a: + resolution: {gitHosted: true, integrity: sha512-0X/z+VaQP+vehyGBrrHUJHVFyjJO2687t3gKHnMZUqzLMBfUykpSRjVZ7CGoFGwtKcm270hhQ8gQyW6ABtMOsw==, tarball: https://codeload.github.com/safeinsights/encryption/tar.gz/3cb802df301d02813f8f67261c34144686b8540a} version: 0.0.1 side-channel-list@1.0.1: @@ -12418,8 +12419,8 @@ snapshots: magicast@0.5.3: dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 source-map-js: 1.2.1 mailgun.js@12.9.0(debug@4.4.3): @@ -13949,7 +13950,7 @@ snapshots: shell-quote@1.8.4: {} - si-encryption@https://codeload.github.com/safeinsights/encryption/tar.gz/2e35d3bb56f02bfc0e6d5cd9d83c54637ed0ac20: + si-encryption@https://codeload.github.com/safeinsights/encryption/tar.gz/3cb802df301d02813f8f67261c34144686b8540a: dependencies: '@zip.js/zip.js': 2.8.26 debug: 4.4.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2d600c827..589b4131a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,6 +14,10 @@ overrides: '@types/react': 19.2.14 esbuild: ^0.28.1 form-data: ^4.0.6 + # hono <4.12.25: GHSA-88fw-hqm2-52qc (CVE-2026-54290, HIGH) — CORS middleware reflects any + # Origin with credentials when origin defaults to wildcard. Dev-only transitive (@pandacss/dev + # → @pandacss/mcp → @modelcontextprotocol/sdk → @hono/node-server), but Trivy scans dev deps. + hono: ^4.12.25 minimatch: ^10.2.1 picomatch: ^4.0.4 postcss: ^8.5.10 diff --git a/src/app/[orgSlug]/dashboard/page.test.tsx b/src/app/[orgSlug]/dashboard/page.test.tsx index 58454622b..47a9247cf 100644 --- a/src/app/[orgSlug]/dashboard/page.test.tsx +++ b/src/app/[orgSlug]/dashboard/page.test.tsx @@ -12,7 +12,6 @@ import OrgDashboardPage from './page' vi.mock('@/server/actions/org.actions', async () => { return { getOrgFromSlugAction: vi.fn(), - getReviewerPublicKeyAction: vi.fn(), } }) diff --git a/src/app/[orgSlug]/study/[studyId]/review/job-review-buttons.test.tsx b/src/app/[orgSlug]/study/[studyId]/review/job-review-buttons.test.tsx index 06b721f7d..618dd147c 100644 --- a/src/app/[orgSlug]/study/[studyId]/review/job-review-buttons.test.tsx +++ b/src/app/[orgSlug]/study/[studyId]/review/job-review-buttons.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, Mock, vi } from 'vitest' -import { insertTestStudyJobData, mockSessionWithTestData, renderWithProviders } from '@/tests/unit.helpers' +import { db, insertTestStudyJobData, mockSessionWithTestData, renderWithProviders } from '@/tests/unit.helpers' import { JobReviewButtons } from './job-review-buttons' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { latestJobForStudy } from '@/server/db/queries' @@ -10,7 +10,7 @@ vi.spyOn(actions, 'approveStudyJobFilesAction') vi.spyOn(actions, 'rejectStudyJobFilesAction') vi.mock('@/server/storage', () => ({ - storeApprovedJobFile: vi.fn(), + fetchFileContents: vi.fn(), })) describe('Study Results Approve/Reject buttons', async () => { @@ -73,7 +73,58 @@ describe('Study Results Approve/Reject buttons', async () => { }) it('can approve results', async () => { - await clickNTest('Approve', actions.approveStudyJobFilesAction as Mock, 'FILES-APPROVED') + // Approve re-wraps each file's AES key for the lab researchers. Use real keys (so + // wrapAesKey accepts the SPKI) and reuse the keyed session user as researcher so no + // fake-key user pollutes the recipient set. The selected file must be a real row. + const { org, user } = await mockSessionWithTestData({ orgType: 'enclave', useRealKeys: true }) + const { latestJobWithStatus: job } = await insertTestStudyJobData({ + org, + studyStatus: 'PENDING-REVIEW', + researcherId: user.id, + }) + + const row = await db + .insertInto('studyJobFile') + .values({ + studyJobId: job.id, + name: 'test.csv', + path: `results/encrypted/test.csv`, + fileType: 'ENCRYPTED-RESULT', + }) + .returning('id') + .executeTakeFirstOrThrow() + + const decryptedResults = [ + { + path: 'test.csv', + contents: new TextEncoder().encode('test123').buffer, + sourceId: row.id, + fileType: 'APPROVED-RESULT' as FileType, + rawAesKey: new ArrayBuffer(32), + }, + ] + + await act(async () => { + renderWithProviders() + }) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Approve' })) + }) + + await waitFor(async () => { + expect(actions.approveStudyJobFilesAction).toHaveBeenCalled() + const latestJob = await latestJobForStudy(job.studyId) + expect(latestJob.statusChanges.find((sc) => sc.status === 'FILES-APPROVED')).not.toBeUndefined() + }) + + // The re-wrapped researcher key was persisted against the real file row. + const wrappedKeys = await db + .selectFrom('studyJobFileRecipientKey') + .select('fingerprint') + .where('studyJobFileId', '=', row.id) + .execute() + expect(wrappedKeys.length).toBeGreaterThan(0) }) it('can reject results', async () => { diff --git a/src/app/[orgSlug]/study/[studyId]/review/job-review-buttons.tsx b/src/app/[orgSlug]/study/[studyId]/review/job-review-buttons.tsx index 1713c08c3..35d54a691 100644 --- a/src/app/[orgSlug]/study/[studyId]/review/job-review-buttons.tsx +++ b/src/app/[orgSlug]/study/[studyId]/review/job-review-buttons.tsx @@ -8,6 +8,7 @@ import { Routes } from '@/lib/routes' import { JobFileInfo, MinimalJobInfo } from '@/lib/types' import { approveStudyJobFilesAction, rejectStudyJobFilesAction } from '@/server/actions/study-job.actions' import type { LatestJobForStudy } from '@/server/db/queries' +import { buildSharedFiles } from '@/lib/re-wrap-results' import { Button, Group, Text, useMantineTheme } from '@mantine/core' import { CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react/dist/ssr' import dayjs from 'dayjs' @@ -42,7 +43,10 @@ export const JobReviewButtons = ({ } if (status === 'FILES-APPROVED') { - await approveStudyJobFilesAction({ orgSlug, jobInfo, jobFiles: decryptedResults }) + // Re-wrap each approved file's AES key for the lab researchers, client-side. + // Only the wrapped keys are sent — never the raw key or plaintext. + const sharedFiles = await buildSharedFiles(job.studyId, decryptedResults) + await approveStudyJobFilesAction({ orgSlug, jobInfo, sharedFiles }) } if (status === 'FILES-REJECTED') { diff --git a/src/app/[orgSlug]/study/[studyId]/review/study-results-redesign.test.tsx b/src/app/[orgSlug]/study/[studyId]/review/study-results-redesign.test.tsx index bbf0fc664..a7f0f7154 100644 --- a/src/app/[orgSlug]/study/[studyId]/review/study-results-redesign.test.tsx +++ b/src/app/[orgSlug]/study/[studyId]/review/study-results-redesign.test.tsx @@ -14,7 +14,6 @@ import { latestJobForStudy } from '@/server/db/queries' import { ResultsWriter } from 'si-encryption/job-results/writer' import { fingerprintKeyData, pemToArrayBuffer } from 'si-encryption/util' import { type FileType } from '@/database/types' -import { type JobFile } from '@/lib/types' // OTTER-538: focused tests for the redesigned StudyResults panel. // The redesign (1) shows the RUN-COMPLETE secondary text on RUN-COMPLETE, (2) hides @@ -23,13 +22,16 @@ import { type JobFile } from '@/lib/types' // JobStatusHelpText for terminal non-COMPLETE statuses so an errored or rejected // job doesn't claim it was "successfully processed". -const mockedApprovedJobFiles: JobFile[] = [] - vi.mock('@/server/actions/study-job.actions', () => ({ - fetchApprovedJobFilesAction: vi.fn(() => mockedApprovedJobFiles), fetchEncryptedJobFilesAction: vi.fn(() => []), + fetchSharedFileIdsAction: vi.fn(() => []), })) +const toArrayBuffer = (str: string): ArrayBuffer => { + const buf = Buffer.from(str, 'utf-8') + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) +} + const RUN_COMPLETE_SECONDARY_TEXT = 'The code was successfully processed! Review results and security logs (if available) to decide if these can be released to the researcher.' @@ -66,7 +68,7 @@ describe('StudyResultsRedesign', () => { renderWithProviders() expect(screen.getByText(RUN_COMPLETE_SECONDARY_TEXT)).toBeInTheDocument() - expect(screen.getByPlaceholderText('Enter your Reviewer key to access encrypted content.')).toBeInTheDocument() + expect(screen.getByPlaceholderText('Enter your Results Key to access encrypted content.')).toBeInTheDocument() expect(screen.queryByText(/Enter Reviewer Key to view/i)).not.toBeInTheDocument() }) @@ -99,32 +101,34 @@ describe('StudyResultsRedesign', () => { const writer = new ResultsWriter([{ publicKey, fingerprint }]) const csv = `title\nhello world` - const csvBlob = Buffer.from(csv, 'utf-8') - const arrayBuf = csvBlob.buffer.slice(csvBlob.byteOffset, csvBlob.byteOffset + csvBlob.length) - await writer.addFile('test.data', arrayBuf) + await writer.addFile('test.data', toArrayBuffer(csv)) const zip = await writer.generate() - const encryptedFile = { - blob: new Blob([zip as BlobPart]), - sourceId: '123', - fileType: 'ENCRYPTED-RESULT' as FileType, - metadata: [{ path: 'test.data', bytes: 4 }], - } - vi.mocked(fetchEncryptedJobFilesAction).mockResolvedValue([encryptedFile]) - const { study, job: rawJob } = await insertTestStudyJobData({ org, jobStatus: 'RUN-COMPLETE', }) - await db + const path = `test-org/${study.id}/${rawJob.id}/results/encrypted-results.zip` + const row = await db .insertInto('studyJobFile') .values({ studyJobId: rawJob.id, - name: 'results.zip', - path: `test-org/${study.id}/${rawJob.id}/results/results.zip`, + name: 'test.data', + path, fileType: 'ENCRYPTED-RESULT', }) - .execute() + .returning('id') + .executeTakeFirstOrThrow() + + vi.mocked(fetchEncryptedJobFilesAction).mockResolvedValue([ + { + studyJobFileId: row.id, + fileType: 'ENCRYPTED-RESULT' as FileType, + name: 'test.data', + encryptedBody: await zip.arrayBuffer(), + recipientKeys: {} as Record, + }, + ]) const job = await latestJobForStudy(study.id) renderWithProviders() @@ -136,7 +140,7 @@ describe('StudyResultsRedesign', () => { expect(screen.queryByRole('button', { name: /^Reject$/ })).not.toBeInTheDocument() const privateKey = await readTestSupportFile('private_key.pem') - const input = screen.getByPlaceholderText('Enter your Reviewer key to access encrypted content.') + const input = screen.getByPlaceholderText('Enter your Results Key to access encrypted content.') fireEvent.change(input, { target: { value: privateKey } }) fireEvent.click(screen.getByRole('button', { name: 'Decrypt Files' })) diff --git a/src/app/[orgSlug]/study/[studyId]/review/study-results-redesign.tsx b/src/app/[orgSlug]/study/[studyId]/review/study-results-redesign.tsx index 79128c471..f4883e1a5 100644 --- a/src/app/[orgSlug]/study/[studyId]/review/study-results-redesign.tsx +++ b/src/app/[orgSlug]/study/[studyId]/review/study-results-redesign.tsx @@ -59,9 +59,9 @@ export const StudyResultsRedesign: FC<{ diff --git a/src/app/[orgSlug]/study/[studyId]/review/study-results.test.tsx b/src/app/[orgSlug]/study/[studyId]/review/study-results.test.tsx index 8d135b3b0..398a8aa01 100644 --- a/src/app/[orgSlug]/study/[studyId]/review/study-results.test.tsx +++ b/src/app/[orgSlug]/study/[studyId]/review/study-results.test.tsx @@ -9,26 +9,63 @@ import { } from '@/tests/unit.helpers' import { fireEvent, waitFor, screen } from '@testing-library/react' import { StudyResults } from './study-results' -import { fetchApprovedJobFilesAction, fetchEncryptedJobFilesAction } from '@/server/actions/study-job.actions' +import { fetchEncryptedJobFilesAction, fetchSharedFileIdsAction } from '@/server/actions/study-job.actions' import { latestJobForStudy } from '@/server/db/queries' import { ResultsWriter } from 'si-encryption/job-results/writer' import { fingerprintKeyData, pemToArrayBuffer } from 'si-encryption/util' import { type FileType, type StudyJobStatus, type StudyStatus } from '@/database/types' -import { type JobFile } from '@/lib/types' - -const mockedApprovedJobFiles: JobFile[] = [ - { - contents: new TextEncoder().encode('title\nhello world').buffer as ArrayBuffer, - path: 'approved.csv', - fileType: 'APPROVED-RESULT' as FileType, - }, -] vi.mock('@/server/actions/study-job.actions', () => ({ - fetchApprovedJobFilesAction: vi.fn(() => mockedApprovedJobFiles), fetchEncryptedJobFilesAction: vi.fn(() => []), + fetchSharedFileIdsAction: vi.fn(() => []), })) +const toArrayBuffer = (str: string): ArrayBuffer => { + const buf = Buffer.from(str, 'utf-8') + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) +} + +type MinimalJob = { id: string } + +async function seedEncryptedFile( + job: MinimalJob, + { name, fileType, content }: { name: string; fileType: FileType; content: string }, +) { + const publicKey = pemToArrayBuffer(await readTestSupportFile('public_key.pem')) + const fingerprint = await fingerprintKeyData(publicKey) + const writer = new ResultsWriter([{ publicKey, fingerprint }]) + await writer.addFile(name, toArrayBuffer(content)) + const zip = await writer.generate() + + const path = `test-org/${job.id}/results/encrypted-results.zip` + const row = await db + .insertInto('studyJobFile') + .values({ studyJobId: job.id, name, path, fileType }) + .returning('id') + .executeTakeFirstOrThrow() + + return { + studyJobFileId: row.id, + fileType, + name, + encryptedBody: await zip.arrayBuffer(), + recipientKeys: {} as Record, + } +} + +async function insertEncryptedRow(job: MinimalJob, { name, fileType }: { name: string; fileType: FileType }) { + return db + .insertInto('studyJobFile') + .values({ + studyJobId: job.id, + name, + path: `test-org/${job.id}/results/encrypted/${name}`, + fileType, + }) + .returning('id') + .executeTakeFirstOrThrow() +} + describe('View Study Results', () => { let org: Org @@ -56,79 +93,44 @@ describe('View Study Results', () => { ).toBeDefined() }) - it('renders the results if the job has been approved', async () => { + it('renders the shared result file once the job has been approved', async () => { const { study, latestJobWithStatus: rawJob } = await insertTestStudyJobData({ org, studyStatus: 'APPROVED', jobStatus: 'FILES-APPROVED', }) - await db - .insertInto('studyJobFile') - .values({ - studyJobId: rawJob.id, - name: 'approved.csv', - path: `test-org/${study.id}/${rawJob.id}/results/approved/approved.csv`, - fileType: 'APPROVED-RESULT', - }) - .execute() + const shared = await insertEncryptedRow(rawJob, { name: 'approved.csv', fileType: 'ENCRYPTED-RESULT' }) + vi.mocked(fetchSharedFileIdsAction).mockResolvedValue([shared.id]) const job = await latestJobForStudy(study.id) renderWithProviders() + // One row per file; the shared file shows by name (content needs decryption). await waitFor(() => { - expect(screen.getByTestId('download-link')).toBeDefined() + expect(screen.getByText('approved.csv')).toBeDefined() }) - // approved files render once per file in the unified table — not duplicated by the legacy JobResults section - expect(screen.getAllByTestId('download-link')).toHaveLength(1) + expect(screen.getAllByText('approved.csv')).toHaveLength(1) }) - it('does not duplicate rows when multiple APPROVED-RESULT files share a fileType', async () => { + it('does not duplicate rows when multiple result files are shared', async () => { const { study, latestJobWithStatus: rawJob } = await insertTestStudyJobData({ org, studyStatus: 'APPROVED', jobStatus: 'FILES-APPROVED', }) - await db - .insertInto('studyJobFile') - .values([ - { - studyJobId: rawJob.id, - name: 'results.csv', - path: `test-org/${study.id}/${rawJob.id}/results/approved/results.csv`, - fileType: 'APPROVED-RESULT', - }, - { - studyJobId: rawJob.id, - name: 'results2.csv', - path: `test-org/${study.id}/${rawJob.id}/results/approved/results2.csv`, - fileType: 'APPROVED-RESULT', - }, - ]) - .execute() - - vi.mocked(fetchApprovedJobFilesAction).mockResolvedValue([ - { - contents: new TextEncoder().encode('a').buffer as ArrayBuffer, - path: 'results.csv', - fileType: 'APPROVED-RESULT' as FileType, - }, - { - contents: new TextEncoder().encode('b').buffer as ArrayBuffer, - path: 'results2.csv', - fileType: 'APPROVED-RESULT' as FileType, - }, - ]) + const first = await insertEncryptedRow(rawJob, { name: 'results.csv', fileType: 'ENCRYPTED-RESULT' }) + const second = await insertEncryptedRow(rawJob, { name: 'results2.csv', fileType: 'ENCRYPTED-RESULT' }) + vi.mocked(fetchSharedFileIdsAction).mockResolvedValue([first.id, second.id]) const job = await latestJobForStudy(study.id) renderWithProviders() await waitFor(() => { - expect(screen.getAllByTestId('download-link')).toHaveLength(2) + expect(screen.getAllByText('results.csv')).toHaveLength(1) + expect(screen.getAllByText('results2.csv')).toHaveLength(1) }) - expect(screen.getAllByText('results.csv')).toHaveLength(1) - expect(screen.getAllByText('results2.csv')).toHaveLength(1) }) it('renders the form to unlock results', async () => { @@ -137,42 +139,27 @@ describe('View Study Results', () => { }) it('shows no logs message and hides decrypt UI when JOB-ERRORED has no encrypted logs', async () => { - const { study } = await insertTestStudyJobData({ - org, - jobStatus: 'JOB-ERRORED', - }) + const { study } = await insertTestStudyJobData({ org, jobStatus: 'JOB-ERRORED' }) const job = await latestJobForStudy(study.id) renderWithProviders() expect(screen.getByText(/While logs are not available at this time/)).toBeDefined() - expect(screen.queryByPlaceholderText('Enter your Reviewer key to access encrypted content.')).toBeNull() + expect(screen.queryByPlaceholderText('Enter your Results Key to access encrypted content.')).toBeNull() expect(screen.queryByText(/Review the error logs/)).toBeNull() expect(screen.queryByText('Job ID:')).toBeNull() }) it('shows error message and decrypt UI when JOB-ERRORED has encrypted logs', async () => { - const { study, job } = await insertTestStudyJobData({ - org, - jobStatus: 'JOB-ERRORED', - }) - - await db - .insertInto('studyJobFile') - .values({ - studyJobId: job.id, - name: 'encrypted-logs.zip', - path: `test-org/${study.id}/${job.id}/results/encrypted-logs.zip`, - fileType: 'ENCRYPTED-PACKAGING-ERROR-LOG', - }) - .execute() + const { study, job } = await insertTestStudyJobData({ org, jobStatus: 'JOB-ERRORED' }) + await insertEncryptedRow(job, { name: 'packaging-error.log', fileType: 'ENCRYPTED-PACKAGING-ERROR-LOG' }) const latestJob = await latestJobForStudy(study.id) renderWithProviders() expect(screen.getByText(/Review the error logs before these can be shared with the researcher/)).toBeDefined() expect(screen.getByText('Job ID:')).toBeDefined() - expect(screen.getByPlaceholderText('Enter your Reviewer key to access encrypted content.')).toBeDefined() + expect(screen.getByPlaceholderText('Enter your Results Key to access encrypted content.')).toBeDefined() expect(screen.queryByText(/While logs are not available at this time/)).toBeNull() }) @@ -185,49 +172,17 @@ describe('View Study Results', () => { return originalCreateObjectURL ? originalCreateObjectURL.call(URL, blob) : 'blob://mock' }) - const publicKey = pemToArrayBuffer(await readTestSupportFile('public_key.pem')) - const fingerprint = await fingerprintKeyData(publicKey) - const writer = new ResultsWriter([{ publicKey, fingerprint }]) - const csv = `title\nhello world` - const csvBlob = Buffer.from(csv, 'utf-8') - const arrayBuf = csvBlob.buffer.slice(csvBlob.byteOffset, csvBlob.byteOffset + csvBlob.length) - - await writer.addFile('test.data', arrayBuf) - const zip = await writer.generate() - - const file = { - blob: new Blob([zip as BlobPart]), - sourceId: '123', - fileType: 'ENCRYPTED-RESULT' as FileType, - metadata: [{ path: 'test.data', bytes: 4 }], - } + const { study, job: rawJob } = await insertTestStudyJobData({ org, jobStatus: 'RUN-COMPLETE' }) + const file = await seedEncryptedFile(rawJob, { name: 'test.data', fileType: 'ENCRYPTED-RESULT', content: csv }) vi.mocked(fetchEncryptedJobFilesAction).mockResolvedValue([file]) - const { study, job: rawJob } = await insertTestStudyJobData({ - org, - jobStatus: 'RUN-COMPLETE', - }) - - await db - .insertInto('studyJobFile') - .values({ - studyJobId: rawJob.id, - name: 'results.zip', - path: `test-org/${study.id}/${rawJob.id}/results/results.zip`, - fileType: 'ENCRYPTED-RESULT', - }) - .execute() - const job = await latestJobForStudy(study.id) renderWithProviders() - const input = screen.getByPlaceholderText('Enter your Reviewer key to access encrypted content.') - - const privateKey = await readTestSupportFile('private_key.pem') - - fireEvent.change(input, { target: { value: privateKey } }) + const input = screen.getByPlaceholderText('Enter your Results Key to access encrypted content.') + fireEvent.change(input, { target: { value: await readTestSupportFile('private_key.pem') } }) fireEvent.click(screen.getByRole('button', { name: 'Decrypt Files' })) await waitFor(() => { diff --git a/src/app/[orgSlug]/study/[studyId]/review/study-results.tsx b/src/app/[orgSlug]/study/[studyId]/review/study-results.tsx index 163d15d7f..8a4771a2f 100644 --- a/src/app/[orgSlug]/study/[studyId]/review/study-results.tsx +++ b/src/app/[orgSlug]/study/[studyId]/review/study-results.tsx @@ -40,6 +40,7 @@ export const StudyResults: FC<{ {job && ( { setDecryptedResults(files) @@ -65,6 +66,7 @@ export const StudyResults: FC<{ { setDecryptedResults(files) diff --git a/src/app/[orgSlug]/study/[studyId]/review/study-review-buttons.tsx b/src/app/[orgSlug]/study/[studyId]/review/study-review-buttons.tsx index 3792e0069..df85702bd 100644 --- a/src/app/[orgSlug]/study/[studyId]/review/study-review-buttons.tsx +++ b/src/app/[orgSlug]/study/[studyId]/review/study-review-buttons.tsx @@ -9,6 +9,7 @@ import { rejectStudyProposalAction, type SelectedStudy, } from '@/server/actions/study.actions' +import { buildSharedFiles } from '@/lib/re-wrap-results' import { Button, Group, Stack } from '@mantine/core' import { useRouter } from 'next/navigation' import { FC, useState } from 'react' @@ -32,13 +33,16 @@ export const StudyReviewButtons: FC<{ study: SelectedStudy; approvedFiles?: JobF isSuccess, variables: pendingStatus, } = useMutation({ - mutationFn: (status: StudyStatus) => { + mutationFn: async (status: StudyStatus) => { if (status === 'APPROVED') { + // Re-wrap approved result files for the lab researchers, client-side; the server + // receives only wrapped keys, never plaintext. + const sharedFiles = approvedFiles?.length ? await buildSharedFiles(study.id, approvedFiles) : undefined return approveStudyProposalAction({ orgSlug, studyId: study.id, useTestImage, - jobFiles: approvedFiles, + sharedFiles, }) } return rejectStudyProposalAction({ orgSlug, studyId: study.id }) diff --git a/src/app/account/invitation/[inviteId]/create-account.action.test.ts b/src/app/account/invitation/[inviteId]/create-account.action.test.ts index ce9c75ac7..1fc9323bb 100644 --- a/src/app/account/invitation/[inviteId]/create-account.action.test.ts +++ b/src/app/account/invitation/[inviteId]/create-account.action.test.ts @@ -187,7 +187,7 @@ describe('Create Account Actions', () => { ) }) - it('onJoinTeamAccountAction returns needsReviewerKey true for enclave org without existing key', async () => { + it('onJoinTeamAccountAction returns needsUserKey true for enclave org without existing key', async () => { const labOrg = await insertTestOrg({ slug: faker.string.alpha(10), type: 'lab' }) const { user } = await insertTestUser({ org: labOrg }) @@ -205,10 +205,10 @@ describe('Create Account Actions', () => { .executeTakeFirstOrThrow() const result = actionResult(await onJoinTeamAccountAction({ inviteId: invite.id })) - expect(result.needsReviewerKey).toBe(true) + expect(result.needsUserKey).toBe(true) }) - it('onJoinTeamAccountAction returns needsReviewerKey false for enclave org with existing key', async () => { + it('onJoinTeamAccountAction returns needsUserKey false for enclave org with existing key', async () => { const { user } = await insertTestUser({ org }) const enclaveOrg = await insertTestOrg({ slug: faker.string.alpha(10) }) @@ -225,11 +225,14 @@ describe('Create Account Actions', () => { .executeTakeFirstOrThrow() const result = actionResult(await onJoinTeamAccountAction({ inviteId: invite.id })) - expect(result.needsReviewerKey).toBe(false) + expect(result.needsUserKey).toBe(false) }) - it('onJoinTeamAccountAction returns needsReviewerKey false for lab org', async () => { - const { user } = await insertTestUser({ org }) + it('onJoinTeamAccountAction returns needsUserKey true for lab org without existing key', async () => { + // insertTestUser only auto-creates a key for enclave-org users, so seed this user in a + // lab org to keep them key-less and exercise the lab researcher gate. + const existingLabOrg = await insertTestOrg({ slug: faker.string.alpha(10), type: 'lab' }) + const { user } = await insertTestUser({ org: existingLabOrg }) const labOrg = await insertTestOrg({ slug: faker.string.alpha(10), type: 'lab' }) @@ -245,7 +248,7 @@ describe('Create Account Actions', () => { .executeTakeFirstOrThrow() const result = actionResult(await onJoinTeamAccountAction({ inviteId: invite.id })) - expect(result.needsReviewerKey).toBe(false) + expect(result.needsUserKey).toBe(true) }) it('onRevokeInviteAction removes invite', async () => { diff --git a/src/app/account/invitation/[inviteId]/create-account.action.ts b/src/app/account/invitation/[inviteId]/create-account.action.ts index 4b5cdcf57..190aead0b 100644 --- a/src/app/account/invitation/[inviteId]/create-account.action.ts +++ b/src/app/account/invitation/[inviteId]/create-account.action.ts @@ -2,10 +2,11 @@ import { Action, ActionFailure, z } from '@/server/actions/action' import { updateClerkUserMetadata } from '@/server/clerk' -import { getReviewerPublicKey } from '@/server/db/queries' +import { getUserPublicKey } from '@/server/db/queries' import { onUserAcceptInvite } from '@/server/events' import { extractClerkCodeAndMessage, isClerkApiError } from '@/lib/errors' import { clerkClient } from '@clerk/nextjs/server' +import { orgNeedsKey } from '@/lib/types' export const onPendingUserLoginAction = new Action('onPendingUserLoginAction') .params(z.object({ inviteId: z.string() })) @@ -144,7 +145,7 @@ export const onJoinTeamAccountAction = new Action('onJoinTeamAccountAction') .where('claimedByUserId', 'is', null) .executeTakeFirst() - // The client-side RequireReviewerKey guard depends on Clerk's useUser() metadata, + // The client-side RequireUserKey guard depends on Clerk's useUser() metadata, // which may be stale right after this server-side update. We check here so callers // can redirect to the key generation page immediately. const org = await db @@ -153,9 +154,9 @@ export const onJoinTeamAccountAction = new Action('onJoinTeamAccountAction') .where('org.id', '=', invite.orgId) .executeTakeFirstOrThrow() - const needsReviewerKey = org.type === 'enclave' && !(await getReviewerPublicKey(siUser.id)) + const needsUserKey = orgNeedsKey(org) && !(await getUserPublicKey(siUser.id)) - return { ...siUser, needsReviewerKey } + return { ...siUser, needsUserKey } }) export const onCreateAccountAction = new Action('onCreateAccountAction') diff --git a/src/app/account/invitation/[inviteId]/join-team/page.tsx b/src/app/account/invitation/[inviteId]/join-team/page.tsx index fa53dfd0c..71230ec9e 100644 --- a/src/app/account/invitation/[inviteId]/join-team/page.tsx +++ b/src/app/account/invitation/[inviteId]/join-team/page.tsx @@ -60,7 +60,7 @@ const AddTeam: FC = ({ params }) => { // short delay to ensure the token is propagated before navigation await new Promise((resolve) => setTimeout(resolve, 500)) - if (result?.needsReviewerKey) { + if (result?.needsUserKey) { router.push(Routes.accountKeys) } else { router.push(Routes.orgDashboard({ orgSlug: org!.slug })) diff --git a/src/app/account/keys/generate-keys.test.tsx b/src/app/account/keys/generate-keys.test.tsx index 1b83c8a74..139229a72 100644 --- a/src/app/account/keys/generate-keys.test.tsx +++ b/src/app/account/keys/generate-keys.test.tsx @@ -1,4 +1,4 @@ -import { setReviewerPublicKeyAction } from '@/server/actions/user-keys.actions' +import { setUserPublicKeyAction } from '@/server/actions/user-keys.actions' import { mockClerkSession, renderWithProviders } from '@/tests/unit.helpers' import { useUser } from '@clerk/nextjs' import { UseUserReturn } from '@clerk/types' @@ -12,11 +12,11 @@ vi.mock('si-encryption/util/keypair', () => ({ })) vi.mock('@/server/actions/user-keys.actions', () => ({ - setReviewerPublicKeyAction: vi.fn(), + setUserPublicKeyAction: vi.fn(), })) -describe('Reviewer keypair generation', () => { - it('should generate a reviewer key pair and display private key for copying', async () => { +describe('Results Keypair generation', () => { + it('should generate a Results Key pair and display private key for copying', async () => { vi.mocked(useUser).mockReturnValue({ user: { firstName: 'Tester', @@ -42,7 +42,7 @@ describe('Reviewer keypair generation', () => { await waitFor(() => { expect(vi.mocked(generateKeyPair)).toHaveBeenCalled() - expect(screen.getByText('Reviewer key', { selector: 'h1' })).toBeDefined() + expect(screen.getByText('Results Key', { selector: 'h1' })).toBeDefined() }) // Simulate copy button click @@ -58,7 +58,7 @@ describe('Reviewer keypair generation', () => { fireEvent.click(dashboardButton) await waitFor(() => { - expect(screen.getByText('Make sure you have securely saved your reviewer key.')).toBeDefined() + expect(screen.getByText('Make sure you have securely saved your Results Key.')).toBeDefined() }) fireEvent.click(screen.getByRole('button', { name: 'Yes, go to dashboard' })) @@ -66,10 +66,9 @@ describe('Reviewer keypair generation', () => { // Wait for state updates await waitFor(() => { // Verify that setMemberUserPublicKey was called - expect(setReviewerPublicKeyAction).toHaveBeenCalledWith( + expect(setUserPublicKeyAction).toHaveBeenCalledWith( expect.objectContaining({ publicKey: mockKeys.exportedPublicKey, - fingerprint: mockKeys.fingerprint, }), ) }) diff --git a/src/app/account/keys/generate-keys.tsx b/src/app/account/keys/generate-keys.tsx index e95211a2c..626719fad 100644 --- a/src/app/account/keys/generate-keys.tsx +++ b/src/app/account/keys/generate-keys.tsx @@ -3,7 +3,7 @@ import { useMutation } from '@/common' import { reportMutationError } from '@/components/errors' import { AppModal } from '@/components/modals/app-modal' -import { setReviewerPublicKeyAction, updateReviewerPublicKeyAction } from '@/server/actions/user-keys.actions' +import { setUserPublicKeyAction, updateUserPublicKeyAction } from '@/server/actions/user-keys.actions' import { Button, Code, @@ -63,19 +63,19 @@ export const GenerateKeys: FC = ({ isRegenerating = false }) if (keys) { return ( - Reviewer key + Results Key - Store reviewer key + Store Results Key - For security reasons, this role requires you to create a reviewer key that is unique to - you. You will use this reviewer key to access encrypted results. Please copy and store + For security reasons, this role requires you to create a Results Key that is unique to + you. You will use this Results Key to access encrypted results. Please copy and store this key in a safe location. - Copy and store your reviewer key + Copy and store your Results Key = ({ isRegenerating = false }) {keys.privateKey} - Note: Please store your reviewer key securely, such as in your password manager. + Note: Please store your Results Key securely, such as in your password manager. @@ -145,32 +145,30 @@ const ConfirmationModal: FC<{ onClose: () => void; isOpen: boolean; keys: Keys; const router = useRouter() const searchParams = useSearchParams() - const { mutate: saveReviewerKey, isPending: isSavingKey } = useMutation({ + const { mutate: saveUserKey, isPending: isSavingKey } = useMutation({ mutationFn: () => { if (isRegenerating) { - return updateReviewerPublicKeyAction({ + return updateUserPublicKeyAction({ publicKey: keys.binaryPublicKey, - fingerprint: keys.fingerprint, }) } else { - return setReviewerPublicKeyAction({ + return setUserPublicKeyAction({ publicKey: keys.binaryPublicKey, - fingerprint: keys.fingerprint, }) } }, - onError: reportMutationError('Failed to save reviewer key'), + onError: reportMutationError('Failed to save Results Key'), onSuccess() { router.push(safeRedirectUrl(searchParams.get('redirect_url'), Routes.home)) }, }) return ( - + - Make sure you have securely saved your reviewer key. + Make sure you have securely saved your Results Key. - Note: SafeInsights does not store your reviewer key. If you lose your key, you won’t be able + Note: SafeInsights does not store your Results Key. If you lose your key, you won’t be able to access study results and will need to generate a new key. @@ -180,7 +178,7 @@ const ConfirmationModal: FC<{ onClose: () => void; isOpen: boolean; keys: Keys; - diff --git a/src/app/account/keys/page.tsx b/src/app/account/keys/page.tsx index a8f8da2a5..3ad038845 100644 --- a/src/app/account/keys/page.tsx +++ b/src/app/account/keys/page.tsx @@ -1,11 +1,11 @@ import { actionResult } from '@/lib/utils' -import { reviewerKeyExistsAction } from '@/server/actions/user-keys.actions' +import { userKeyExistsAction } from '@/server/actions/user-keys.actions' import { GenerateKeys } from './generate-keys' export const dynamic = 'force-dynamic' export default async function KeysPage() { - const hasKey = actionResult(await reviewerKeyExistsAction()) + const hasKey = actionResult(await userKeyExistsAction()) return } diff --git a/src/app/account/signin/mfa.tsx b/src/app/account/signin/mfa.tsx index ba37ab0aa..c9f7069d3 100644 --- a/src/app/account/signin/mfa.tsx +++ b/src/app/account/signin/mfa.tsx @@ -68,7 +68,7 @@ export const RequestMFA: FC<{ mfa: MFAState }> = ({ mfa }) => { try { const result = actionResult(await onUserSignInAction()) await auth.getToken({ skipCache: true }) - if (result?.redirectToReviewerKey) { + if (result?.redirectToKeyGeneration) { router.push(Routes.accountKeys) } else { let redirectUrl = safeRedirectUrl(searchParams.get('redirect_url'), Routes.dashboard) @@ -82,7 +82,7 @@ export const RequestMFA: FC<{ mfa: MFAState }> = ({ mfa }) => { }), ) - if (joinResult?.needsReviewerKey) { + if (joinResult?.needsUserKey) { redirectUrl = Routes.accountKeys as Route } else { const { slug } = actionResult(await getOrgInfoForInviteAction({ inviteId })) diff --git a/src/app/account/signin/sign-in-form.tsx b/src/app/account/signin/sign-in-form.tsx index ac0281d75..353a9a6a9 100644 --- a/src/app/account/signin/sign-in-form.tsx +++ b/src/app/account/signin/sign-in-form.tsx @@ -87,7 +87,7 @@ export const SignInForm: FC<{ await onComplete(false) const result = actionResult(await onUserSignInAction()) await getToken({ skipCache: true }) - if (result?.redirectToReviewerKey) { + if (result?.redirectToKeyGeneration) { router.push(Routes.accountKeys as Route) } else { router.push(validatedRedirect) diff --git a/src/app/api/job/[jobId]/results/route.test.ts b/src/app/api/job/[jobId]/results/route.test.ts index 4e7bc6cb7..3b03530ab 100644 --- a/src/app/api/job/[jobId]/results/route.test.ts +++ b/src/app/api/job/[jobId]/results/route.test.ts @@ -54,6 +54,37 @@ test.skipIf(!s3Available)('uploading results', async () => { expect(contents).toBeInstanceOf(Blob) }) +// Guards the stale-shared-key case: once a job is RUN-COMPLETE its encrypted results (and the +// AES keys the manifest/researcher rows are wrapped against) are frozen. A re-post must be +// rejected rather than overwrite the blob under already-shared keys. Re-runs use a NEW job. +test('rejects a second results upload once the job is already complete', async () => { + const org = await insertTestOrg() + const { jobIds } = await insertTestStudyData({ org }) + const jobId = jobIds[0] + + const post = () => { + const formData = new FormData() + formData.append('result', new File([new Uint8Array([1, 2, 3])], 'r.txt', { type: 'text/plain' })) + return apiHandler.POST(new Request('http://localhost', { method: 'POST', body: formData }), { + params: Promise.resolve({ jobId }), + }) + } + + expect((await post()).ok).toBe(true) + + const second = await post() + expect(second.status).toBe(422) + + // The rejected re-post must not have created a duplicate ENCRYPTED-RESULT row. + const rows = await db + .selectFrom('studyJobFile') + .select('id') + .where('studyJobId', '=', jobId) + .where('fileType', '=', 'ENCRYPTED-RESULT') + .execute() + expect(rows).toHaveLength(1) +}) + test.skipIf(!s3Available)('uploading logs', async () => { const org = await insertTestOrg() const logContents = 'long line one\nlog line two\n' diff --git a/src/app/dl/results/[jobId]/[fileName]/route.ts b/src/app/dl/results/[jobId]/[fileName]/route.ts index 4df307fe9..4054918a9 100644 --- a/src/app/dl/results/[jobId]/[fileName]/route.ts +++ b/src/app/dl/results/[jobId]/[fileName]/route.ts @@ -3,6 +3,9 @@ import { urlForFile } from '@/server/storage' import { getStudyJobFileOfType, getInfoForStudyJobId } from '@/server/db/queries' import { canViewStudyJob } from '@/server/auth' +// LEGACY ONLY: serves the plaintext APPROVED-RESULT copy from before results were encrypted for +// researchers. New jobs don't create APPROVED-RESULT rows, so only pre-encryption studies reach +// this. Delete with pathForStudyJobResults + leftover plaintext S3 objects once legacy ages out. export const GET = async (_: Request, { params }: { params: Promise<{ jobId: string; fileName: string }> }) => { const { jobId, fileName } = await params diff --git a/src/app/reviewer-key/layout.tsx b/src/app/user-key/layout.tsx similarity index 74% rename from src/app/reviewer-key/layout.tsx rename to src/app/user-key/layout.tsx index b4bfed4ea..515614206 100644 --- a/src/app/reviewer-key/layout.tsx +++ b/src/app/user-key/layout.tsx @@ -1,6 +1,6 @@ import { UserLayout } from '@/components/layout/user-layout' import { actionResult } from '@/lib/utils' -import { reviewerKeyExistsAction } from '@/server/actions/user-keys.actions' +import { userKeyExistsAction } from '@/server/actions/user-keys.actions' import { redirect } from 'next/navigation' export const dynamic = 'force-dynamic' @@ -8,7 +8,7 @@ export const dynamic = 'force-dynamic' type Props = { children: React.ReactNode } export default async function ReviewerLayout({ children }: Props) { - const hasKey = actionResult(await reviewerKeyExistsAction()) + const hasKey = actionResult(await userKeyExistsAction()) if (!hasKey) { redirect('/account/keys') diff --git a/src/app/reviewer-key/page.tsx b/src/app/user-key/page.tsx similarity index 100% rename from src/app/reviewer-key/page.tsx rename to src/app/user-key/page.tsx diff --git a/src/app/reviewer-key/regenerate-key-view.stories.tsx b/src/app/user-key/regenerate-key-view.stories.tsx similarity index 88% rename from src/app/reviewer-key/regenerate-key-view.stories.tsx rename to src/app/user-key/regenerate-key-view.stories.tsx index bbf48d358..d59e816ca 100644 --- a/src/app/reviewer-key/regenerate-key-view.stories.tsx +++ b/src/app/user-key/regenerate-key-view.stories.tsx @@ -3,13 +3,13 @@ import { useDisclosure } from '@mantine/hooks' import { pageBackgroundArgTypes } from '~ladle/backgrounds' import { RegenerateKeyView } from './regenerate-key-view' -// The reviewer-key page-view. RegenerateKeyView is presentational; the container derives the +// The user-key page-view. RegenerateKeyView is presentational; the container derives the // dashboard crumb from the session and wires navigation. These stories supply inline crumb // targets and drive the confirm modal with a local useDisclosure (no router/session needed). // // Note: this screen only renders the "key already exists" state. The "no key yet" case never // reaches this component — the route layout redirects to /account/keys when no key exists. -const meta = { title: 'Pages / Reviewer key', argTypes: pageBackgroundArgTypes } +const meta = { title: 'Pages / Results Key', argTypes: pageBackgroundArgTypes } export default meta export const KeyAlreadyExists: Story = () => { diff --git a/src/app/reviewer-key/regenerate-key-view.tsx b/src/app/user-key/regenerate-key-view.tsx similarity index 78% rename from src/app/reviewer-key/regenerate-key-view.tsx rename to src/app/user-key/regenerate-key-view.tsx index 132cfd602..ad420eab7 100644 --- a/src/app/reviewer-key/regenerate-key-view.tsx +++ b/src/app/user-key/regenerate-key-view.tsx @@ -5,7 +5,7 @@ import { Button, Divider, Group, Paper, Stack, Text, Title } from '@mantine/core import { AppModal } from '@/components/modals/app-modal' import { PageBreadcrumbs } from '@/components/page-breadcrumbs' -// Presentational reviewer-key screen. It owns the breadcrumbs + "Reviewer key details" card +// Presentational user-key screen. It owns the breadcrumbs + "Results Key details" card // (lost-key copy, destructive note, regenerate button) and the confirm modal. Kept in its OWN // file — free of useSession (Clerk), useRouter and Routes navigation — so it renders in // isolation (e.g. Ladle). The RegenerateKey container (./regenerate-key) derives the dashboard @@ -29,28 +29,28 @@ export const RegenerateKeyView: FC = ({ }) => { return ( - - Reviewer key + + Results Key - Reviewer key details + Results Key details - Reviewer key already exists + Results Key already exists - You have already generated a reviewer key. For security reasons, SafeInsights does not store + You have already generated a Results Key. For security reasons, SafeInsights does not store or display it again. Lost key? - If you have lost your reviewer key, you will need to generate a new one. + If you have lost your Results Key, you will need to generate a new one. - Note: If you generate a new reviewer key, you will no longer have access to any study - results associated with your previous key. This action cannot be undone. + Note: If you generate a new Results Key, you will no longer have access to any study results + associated with your previous key. This action cannot be undone. @@ -78,7 +78,7 @@ const GenerateNewKeyModal: FC<{ - Generating a new reviewer key will permanently remove access to study results tied to your old key. + Generating a new Results Key will permanently remove access to study results tied to your old key. This action cannot be undone. diff --git a/src/app/reviewer-key/regenerate-key.test.tsx b/src/app/user-key/regenerate-key.test.tsx similarity index 85% rename from src/app/reviewer-key/regenerate-key.test.tsx rename to src/app/user-key/regenerate-key.test.tsx index 67b73e96a..81e95de56 100644 --- a/src/app/reviewer-key/regenerate-key.test.tsx +++ b/src/app/user-key/regenerate-key.test.tsx @@ -10,11 +10,11 @@ vi.mock('si-encryption/util/keypair', () => ({ })) vi.mock('@/server/actions/user-keys.actions', () => ({ - updateReviewerPublicKeyAction: vi.fn(), + updateUserPublicKeyAction: vi.fn(), })) -describe('Reviewer keypair regeneration', () => { - it('should regenerate a reviewer key pair and update public key', async () => { +describe('Results Keypair regeneration', () => { + it('should regenerate a Results Key pair and update public key', async () => { mockClerkSession({ clerkUserId: 'user-id', orgSlug: 'dev', @@ -32,7 +32,7 @@ describe('Reviewer keypair regeneration', () => { renderWithProviders() await waitFor(() => { - expect(screen.getByText('Reviewer key', { selector: 'h1' })).toBeDefined() + expect(screen.getByText('Results Key', { selector: 'h1' })).toBeDefined() }) // Open the modal requesting regeneration diff --git a/src/app/reviewer-key/regenerate-key.tsx b/src/app/user-key/regenerate-key.tsx similarity index 100% rename from src/app/reviewer-key/regenerate-key.tsx rename to src/app/user-key/regenerate-key.tsx diff --git a/src/components/encrypted-files-panel.test.tsx b/src/components/encrypted-files-panel.test.tsx index b80553d06..72f671757 100644 --- a/src/components/encrypted-files-panel.test.tsx +++ b/src/components/encrypted-files-panel.test.tsx @@ -9,17 +9,76 @@ import { } from '@/tests/unit.helpers' import { fireEvent, waitFor, screen } from '@testing-library/react' import { EncryptedFilesPanel } from './encrypted-files-panel' -import { fetchApprovedJobFilesAction, fetchEncryptedJobFilesAction } from '@/server/actions/study-job.actions' +import { fetchEncryptedJobFilesAction, fetchSharedFileIdsAction } from '@/server/actions/study-job.actions' import { latestJobForStudy } from '@/server/db/queries' import { ResultsWriter } from 'si-encryption/job-results/writer' import { fingerprintKeyData, pemToArrayBuffer } from 'si-encryption/util' import { type FileType } from '@/database/types' vi.mock('@/server/actions/study-job.actions', () => ({ - fetchApprovedJobFilesAction: vi.fn(() => []), fetchEncryptedJobFilesAction: vi.fn(() => []), + fetchSharedFileIdsAction: vi.fn(() => []), })) +const toArrayBuffer = (str: string): ArrayBuffer => { + const buf = Buffer.from(str, 'utf-8') + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) +} + +type MinimalJob = { id: string } + +// Encrypt one results artifact the way TOA would (prod whole-zip + embedded manifest), persist the +// study_job_file row, and return the entry the fetchEncryptedJobFilesAction mock serves. The +// reviewer is a manifest recipient, so recipientKeys is empty — they decrypt with their own key. +async function seedArtifact( + job: MinimalJob, + { fileType, subdir, files }: { fileType: FileType; subdir: string; files: { name: string; content: string }[] }, +) { + const publicKey = pemToArrayBuffer(await readTestSupportFile('public_key.pem')) + const fingerprint = await fingerprintKeyData(publicKey) + const writer = new ResultsWriter([{ publicKey, fingerprint }]) + for (const f of files) await writer.addFile(f.name, toArrayBuffer(f.content)) + const zip = await writer.generate() + + const path = `test-org/${job.id}/results/${subdir}/encrypted-results.zip` + const row = await db + .insertInto('studyJobFile') + .values({ studyJobId: job.id, name: 'encrypted-results.zip', path, fileType }) + .returning('id') + .executeTakeFirstOrThrow() + + return { + studyJobFileId: row.id, + fileType, + name: 'encrypted-results.zip', + encryptedBody: await zip.arrayBuffer(), + recipientKeys: {} as Record, + } +} + +// A display-only encrypted-artifact row (no real ciphertext) for lock/shared states. +async function insertEncryptedRow(job: MinimalJob, { fileType, subdir }: { fileType: FileType; subdir: string }) { + return db + .insertInto('studyJobFile') + .values({ + studyJobId: job.id, + name: 'encrypted-results.zip', + path: `test-org/${job.id}/results/${subdir}/encrypted-results.zip`, + fileType, + }) + .returning('id') + .executeTakeFirstOrThrow() +} + +const READER_KEY_PLACEHOLDER = 'Enter your Results Key to access encrypted content.' + +async function enterKeyAndDecrypt() { + fireEvent.change(screen.getByPlaceholderText(READER_KEY_PLACEHOLDER), { + target: { value: await readTestSupportFile('private_key.pem') }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Decrypt Files' })) +} + describe('EncryptedFilesPanel', () => { let org: Org @@ -29,241 +88,93 @@ describe('EncryptedFilesPanel', () => { }) it('returns null when no encrypted files exist', async () => { - const { latestJobWithStatus: job } = await insertTestStudyJobData({ - org, - jobStatus: 'RUN-COMPLETE', - }) + const { latestJobWithStatus: job } = await insertTestStudyJobData({ org, jobStatus: 'RUN-COMPLETE' }) - const { container } = renderWithProviders() + const { container } = renderWithProviders( + , + ) expect(container.querySelector('form')).toBeNull() expect(screen.queryByText('Decrypt Files')).toBeNull() }) - it('shows file rows with lock icon and decrypt form when encrypted files exist', async () => { - const { study, job } = await insertTestStudyJobData({ - org, - jobStatus: 'RUN-COMPLETE', - }) - - await db - .insertInto('studyJobFile') - .values({ - studyJobId: job.id, - name: 'results.zip', - path: `test-org/${study.id}/${job.id}/results/results.zip`, - fileType: 'ENCRYPTED-RESULT', - }) - .execute() + it('shows a locked artifact row and the decrypt form when an encrypted artifact exists', async () => { + const { study, job } = await insertTestStudyJobData({ org, jobStatus: 'RUN-COMPLETE' }) + await insertEncryptedRow(job, { fileType: 'ENCRYPTED-RESULT', subdir: 'encrypted' }) const latestJob = await latestJobForStudy(study.id) - renderWithProviders() + renderWithProviders() - // File row is visible in the table expect(screen.getByText('Results')).toBeDefined() - expect(screen.getByText('results.zip')).toBeDefined() + expect(screen.getByLabelText('Encrypted')).toBeDefined() - // No View or Download actions for locked files + // Locked → no View/Download expect(screen.queryByRole('button', { name: 'View' })).toBeNull() expect(screen.queryByTestId('download-link')).toBeNull() - // Decrypt form is present - expect(screen.getByPlaceholderText('Enter your Reviewer key to access encrypted content.')).toBeDefined() + expect(screen.getByPlaceholderText(READER_KEY_PLACEHOLDER)).toBeDefined() expect(screen.getByRole('button', { name: 'Decrypt Files' })).toBeDefined() }) - it('shows file names and sizes from metadata before decryption', async () => { - const { study, job } = await insertTestStudyJobData({ - org, - jobStatus: 'RUN-COMPLETE', - }) - - await db - .insertInto('studyJobFile') - .values({ - studyJobId: job.id, - name: 'results.zip', - path: `test-org/${study.id}/${job.id}/results/results.zip`, - fileType: 'ENCRYPTED-RESULT', - }) - .execute() - - vi.mocked(fetchEncryptedJobFilesAction).mockResolvedValue([ - { - blob: new Blob(), - sourceId: '123', - fileType: 'ENCRYPTED-RESULT', - metadata: [{ path: 'results.csv', bytes: 2048 }], - }, - ]) + it('shows one locked row per encrypted artifact (results + logs)', async () => { + const { study, job } = await insertTestStudyJobData({ org, jobStatus: 'RUN-COMPLETE' }) + await insertEncryptedRow(job, { fileType: 'ENCRYPTED-RESULT', subdir: 'encrypted' }) + await insertEncryptedRow(job, { fileType: 'ENCRYPTED-SECURITY-SCAN-LOG', subdir: 'encrypted-logs' }) const latestJob = await latestJobForStudy(study.id) - renderWithProviders() + renderWithProviders() await waitFor(() => { - expect(screen.getByText('results.csv')).toBeDefined() - expect(screen.getByText('2.0 KB')).toBeDefined() + expect(screen.getByText('Results')).toBeDefined() + expect(screen.getByText('Security Scan Log')).toBeDefined() }) - - // Still locked — no View or Download - expect(screen.queryByRole('button', { name: 'View' })).toBeNull() - expect(screen.queryByTestId('download-link')).toBeNull() }) - it('shows one row per file when an encrypted archive contains multiple files', async () => { - const { study, job } = await insertTestStudyJobData({ - org, - jobStatus: 'RUN-COMPLETE', + it('decrypts the artifact and shows its inner files with View and Download', async () => { + const { study, job } = await insertTestStudyJobData({ org, jobStatus: 'RUN-COMPLETE' }) + const artifact = await seedArtifact(job, { + fileType: 'ENCRYPTED-RESULT', + subdir: 'encrypted', + files: [{ name: 'results.csv', content: 'name,age\nAlice,30' }], }) - - await db - .insertInto('studyJobFile') - .values({ - studyJobId: job.id, - name: 'results.zip', - path: `test-org/${study.id}/${job.id}/results/results.zip`, - fileType: 'ENCRYPTED-RESULT', - }) - .execute() - - vi.mocked(fetchEncryptedJobFilesAction).mockResolvedValue([ - { - blob: new Blob(), - sourceId: '123', - fileType: 'ENCRYPTED-RESULT', - metadata: [ - { path: 'first.csv', bytes: 1024 }, - { path: 'second.csv', bytes: 2048 }, - ], - }, - ]) - - const latestJob = await latestJobForStudy(study.id) - renderWithProviders() - - await waitFor(() => { - expect(screen.getByText('first.csv')).toBeDefined() - expect(screen.getByText('second.csv')).toBeDefined() - expect(screen.getByText('1.0 KB')).toBeDefined() - expect(screen.getByText('2.0 KB')).toBeDefined() - }) - - // Aggregated "N files" placeholder should not appear - expect(screen.queryByText('2 files')).toBeNull() - }) - - it('decrypts and shows file table with View and Download', async () => { - const publicKey = pemToArrayBuffer(await readTestSupportFile('public_key.pem')) - const fingerprint = await fingerprintKeyData(publicKey) - const writer = new ResultsWriter([{ publicKey, fingerprint }]) - - const csv = 'name,age\nAlice,30' - const csvBuf = Buffer.from(csv, 'utf-8') - const arrayBuf = csvBuf.buffer.slice(csvBuf.byteOffset, csvBuf.byteOffset + csvBuf.length) - - await writer.addFile('results.csv', arrayBuf) - const zip = await writer.generate() - - const file = { - blob: new Blob([zip as BlobPart]), - sourceId: '123', - fileType: 'ENCRYPTED-RESULT' as FileType, - metadata: [{ path: 'results.csv', bytes: 15 }], - } - - vi.mocked(fetchEncryptedJobFilesAction).mockResolvedValue([file]) - - const { study, job } = await insertTestStudyJobData({ - org, - jobStatus: 'RUN-COMPLETE', - }) - - await db - .insertInto('studyJobFile') - .values({ - studyJobId: job.id, - name: 'results.zip', - path: `test-org/${study.id}/${job.id}/results/results.zip`, - fileType: 'ENCRYPTED-RESULT', - }) - .execute() + vi.mocked(fetchEncryptedJobFilesAction).mockResolvedValue([artifact]) const onFilesApproved = vi.fn() const latestJob = await latestJobForStudy(study.id) - renderWithProviders() + renderWithProviders() - const input = screen.getByPlaceholderText('Enter your Reviewer key to access encrypted content.') - const privateKey = await readTestSupportFile('private_key.pem') - - fireEvent.change(input, { target: { value: privateKey } }) - fireEvent.click(screen.getByRole('button', { name: 'Decrypt Files' })) + await enterKeyAndDecrypt() await waitFor(() => { - expect(screen.getByText('Results')).toBeDefined() + expect(screen.getByText('results.csv')).toBeDefined() expect(screen.getByRole('button', { name: 'View' })).toBeDefined() expect(screen.getByTestId('download-link')).toBeDefined() expect(onFilesApproved).toHaveBeenLastCalledWith([ expect.objectContaining({ path: 'results.csv', fileType: 'APPROVED-RESULT', - sourceId: '123', + sourceId: artifact.studyJobFileId, }), ]) }) }) - it('decrypts an archive with multiple files and shows one row per file', async () => { - const publicKey = pemToArrayBuffer(await readTestSupportFile('public_key.pem')) - const fingerprint = await fingerprintKeyData(publicKey) - const writer = new ResultsWriter([{ publicKey, fingerprint }]) - - const csvA = 'name,age\nAlice,30' - const csvABuf = Buffer.from(csvA, 'utf-8') - const arrayBufA = csvABuf.buffer.slice(csvABuf.byteOffset, csvABuf.byteOffset + csvABuf.length) - - const csvB = 'city,state\nDenver,CO' - const csvBBuf = Buffer.from(csvB, 'utf-8') - const arrayBufB = csvBBuf.buffer.slice(csvBBuf.byteOffset, csvBBuf.byteOffset + csvBBuf.length) - - await writer.addFile('first.csv', arrayBufA) - await writer.addFile('second.csv', arrayBufB) - const zip = await writer.generate() - - const file = { - blob: new Blob([zip as BlobPart]), - sourceId: '123', - fileType: 'ENCRYPTED-RESULT' as FileType, - metadata: [ - { path: 'first.csv', bytes: arrayBufA.byteLength }, - { path: 'second.csv', bytes: arrayBufB.byteLength }, + it('decrypts an artifact with multiple inner files and shows one row each', async () => { + const { study, job } = await insertTestStudyJobData({ org, jobStatus: 'RUN-COMPLETE' }) + const artifact = await seedArtifact(job, { + fileType: 'ENCRYPTED-RESULT', + subdir: 'encrypted', + files: [ + { name: 'first.csv', content: 'name,age\nAlice,30' }, + { name: 'second.csv', content: 'city,state\nDenver,CO' }, ], - } - - vi.mocked(fetchEncryptedJobFilesAction).mockResolvedValue([file]) - - const { study, job } = await insertTestStudyJobData({ - org, - jobStatus: 'RUN-COMPLETE', }) - - await db - .insertInto('studyJobFile') - .values({ - studyJobId: job.id, - name: 'results.zip', - path: `test-org/${study.id}/${job.id}/results/results.zip`, - fileType: 'ENCRYPTED-RESULT', - }) - .execute() + vi.mocked(fetchEncryptedJobFilesAction).mockResolvedValue([artifact]) const onFilesApproved = vi.fn() const latestJob = await latestJobForStudy(study.id) - renderWithProviders() + renderWithProviders() - const input = screen.getByPlaceholderText('Enter your Reviewer key to access encrypted content.') - const privateKey = await readTestSupportFile('private_key.pem') - - fireEvent.change(input, { target: { value: privateKey } }) - fireEvent.click(screen.getByRole('button', { name: 'Decrypt Files' })) + await enterKeyAndDecrypt() await waitFor(() => { expect(screen.getByText('first.csv')).toBeDefined() @@ -282,55 +193,59 @@ describe('EncryptedFilesPanel', () => { }) }) - it('opens modal with CSV content rendered as table', async () => { - const publicKey = pemToArrayBuffer(await readTestSupportFile('public_key.pem')) - const fingerprint = await fingerprintKeyData(publicKey) - const writer = new ResultsWriter([{ publicKey, fingerprint }]) - - const csv = 'name,age\nAlice,30' - const csvBuf = Buffer.from(csv, 'utf-8') - const arrayBuf = csvBuf.buffer.slice(csvBuf.byteOffset, csvBuf.byteOffset + csvBuf.length) - - await writer.addFile('results.csv', arrayBuf) - const zip = await writer.generate() + it('shares decrypted logs alongside results (all-or-nothing)', async () => { + const { study, job } = await insertTestStudyJobData({ org, jobStatus: 'RUN-COMPLETE' }) + const resultArtifact = await seedArtifact(job, { + fileType: 'ENCRYPTED-RESULT', + subdir: 'encrypted', + files: [{ name: 'results.csv', content: 'name,age\nAlice,30' }], + }) + const logArtifact = await seedArtifact(job, { + fileType: 'ENCRYPTED-CODE-RUN-LOG', + subdir: 'encrypted-logs', + files: [{ name: 'run.log', content: 'job started\njob finished ok' }], + }) + vi.mocked(fetchEncryptedJobFilesAction).mockResolvedValue([resultArtifact, logArtifact]) - const file = { - blob: new Blob([zip as BlobPart]), - sourceId: '123', - fileType: 'ENCRYPTED-RESULT' as FileType, - metadata: [{ path: 'results.csv', bytes: 15 }], - } + const onFilesApproved = vi.fn() + const latestJob = await latestJobForStudy(study.id) + renderWithProviders() - vi.mocked(fetchEncryptedJobFilesAction).mockResolvedValue([file]) + await enterKeyAndDecrypt() - const { study, job } = await insertTestStudyJobData({ - org, - jobStatus: 'RUN-COMPLETE', + await waitFor(() => { + expect(onFilesApproved).toHaveBeenLastCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + path: 'results.csv', + fileType: 'APPROVED-RESULT', + sourceId: resultArtifact.studyJobFileId, + }), + expect.objectContaining({ + path: 'run.log', + fileType: 'APPROVED-CODE-RUN-LOG', + sourceId: logArtifact.studyJobFileId, + }), + ]), + ) }) + }) - await db - .insertInto('studyJobFile') - .values({ - studyJobId: job.id, - name: 'results.zip', - path: `test-org/${study.id}/${job.id}/results/results.zip`, - fileType: 'ENCRYPTED-RESULT', - }) - .execute() + it('opens a modal with CSV content rendered as a table', async () => { + const { study, job } = await insertTestStudyJobData({ org, jobStatus: 'RUN-COMPLETE' }) + const artifact = await seedArtifact(job, { + fileType: 'ENCRYPTED-RESULT', + subdir: 'encrypted', + files: [{ name: 'results.csv', content: 'name,age\nAlice,30' }], + }) + vi.mocked(fetchEncryptedJobFilesAction).mockResolvedValue([artifact]) const latestJob = await latestJobForStudy(study.id) - renderWithProviders() - - const input = screen.getByPlaceholderText('Enter your Reviewer key to access encrypted content.') - const privateKey = await readTestSupportFile('private_key.pem') - - fireEvent.change(input, { target: { value: privateKey } }) - fireEvent.click(screen.getByRole('button', { name: 'Decrypt Files' })) + renderWithProviders() - await waitFor(() => { - expect(screen.getByRole('button', { name: 'View' })).toBeDefined() - }) + await enterKeyAndDecrypt() + await waitFor(() => expect(screen.getByRole('button', { name: 'View' })).toBeDefined()) fireEvent.click(screen.getByRole('button', { name: 'View' })) await waitFor(() => { @@ -341,55 +256,22 @@ describe('EncryptedFilesPanel', () => { }) }) - it('opens modal with text content rendered as code block for log files', async () => { - const publicKey = pemToArrayBuffer(await readTestSupportFile('public_key.pem')) - const fingerprint = await fingerprintKeyData(publicKey) - const writer = new ResultsWriter([{ publicKey, fingerprint }]) - + it('opens a modal with text content rendered as a code block for log files', async () => { + const { study, job } = await insertTestStudyJobData({ org, jobStatus: 'RUN-COMPLETE' }) const logContent = 'Security scan complete: no issues found.' - const contentBuf = Buffer.from(logContent, 'utf-8') - const arrayBuf = contentBuf.buffer.slice(contentBuf.byteOffset, contentBuf.byteOffset + contentBuf.length) - - await writer.addFile('scan-log.txt', arrayBuf) - const zip = await writer.generate() - - const file = { - blob: new Blob([zip as BlobPart]), - sourceId: '123', - fileType: 'ENCRYPTED-SECURITY-SCAN-LOG' as FileType, - metadata: [{ path: 'scan-log.txt', bytes: 40 }], - } - - vi.mocked(fetchEncryptedJobFilesAction).mockResolvedValue([file]) - - const { study, job } = await insertTestStudyJobData({ - org, - jobStatus: 'RUN-COMPLETE', + const artifact = await seedArtifact(job, { + fileType: 'ENCRYPTED-SECURITY-SCAN-LOG', + subdir: 'encrypted-logs', + files: [{ name: 'scan-log.txt', content: logContent }], }) - - await db - .insertInto('studyJobFile') - .values({ - studyJobId: job.id, - name: 'scan-log.zip', - path: `test-org/${study.id}/${job.id}/results/scan-log.zip`, - fileType: 'ENCRYPTED-SECURITY-SCAN-LOG', - }) - .execute() + vi.mocked(fetchEncryptedJobFilesAction).mockResolvedValue([artifact]) const latestJob = await latestJobForStudy(study.id) - renderWithProviders() + renderWithProviders() - const input = screen.getByPlaceholderText('Enter your Reviewer key to access encrypted content.') - const privateKey = await readTestSupportFile('private_key.pem') - - fireEvent.change(input, { target: { value: privateKey } }) - fireEvent.click(screen.getByRole('button', { name: 'Decrypt Files' })) - - await waitFor(() => { - expect(screen.getByRole('button', { name: 'View' })).toBeDefined() - }) + await enterKeyAndDecrypt() + await waitFor(() => expect(screen.getByRole('button', { name: 'View' })).toBeDefined()) fireEvent.click(screen.getByRole('button', { name: 'View' })) await waitFor(() => { @@ -399,53 +281,20 @@ describe('EncryptedFilesPanel', () => { }) it('opens image preview modal when clicking View on a PNG result', async () => { - const publicKey = pemToArrayBuffer(await readTestSupportFile('public_key.pem')) - const fingerprint = await fingerprintKeyData(publicKey) - const writer = new ResultsWriter([{ publicKey, fingerprint }]) - - const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]) - const pngBuf = pngBytes.buffer.slice(pngBytes.byteOffset, pngBytes.byteOffset + pngBytes.length) - - await writer.addFile('plot.png', pngBuf) - const zip = await writer.generate() - - const file = { - blob: new Blob([zip as BlobPart]), - sourceId: '123', - fileType: 'ENCRYPTED-RESULT' as FileType, - metadata: [{ path: 'plot.png', bytes: 4 }], - } - - vi.mocked(fetchEncryptedJobFilesAction).mockResolvedValue([file]) - - const { study, job } = await insertTestStudyJobData({ - org, - jobStatus: 'RUN-COMPLETE', + const { study, job } = await insertTestStudyJobData({ org, jobStatus: 'RUN-COMPLETE' }) + const artifact = await seedArtifact(job, { + fileType: 'ENCRYPTED-RESULT', + subdir: 'encrypted', + files: [{ name: 'plot.png', content: 'fake-png-bytes' }], }) - - await db - .insertInto('studyJobFile') - .values({ - studyJobId: job.id, - name: 'results.zip', - path: `test-org/${study.id}/${job.id}/results/results.zip`, - fileType: 'ENCRYPTED-RESULT', - }) - .execute() + vi.mocked(fetchEncryptedJobFilesAction).mockResolvedValue([artifact]) const latestJob = await latestJobForStudy(study.id) - renderWithProviders() - - const input = screen.getByPlaceholderText('Enter your Reviewer key to access encrypted content.') - const privateKey = await readTestSupportFile('private_key.pem') + renderWithProviders() - fireEvent.change(input, { target: { value: privateKey } }) - fireEvent.click(screen.getByRole('button', { name: 'Decrypt Files' })) - - await waitFor(() => { - expect(screen.getByRole('button', { name: 'View' })).toBeDefined() - }) + await enterKeyAndDecrypt() + await waitFor(() => expect(screen.getByRole('button', { name: 'View' })).toBeDefined()) fireEvent.click(screen.getByRole('button', { name: 'View' })) await waitFor(() => { @@ -454,175 +303,71 @@ describe('EncryptedFilesPanel', () => { }) }) - it('shows a green check for shared files and a red "not shared" X for withheld files after approval', async () => { - const { study, job } = await insertTestStudyJobData({ - org, - jobStatus: 'FILES-APPROVED', - }) + it('shows a green "shared with researcher" check on an approved artifact', async () => { + const { study, job } = await insertTestStudyJobData({ org, jobStatus: 'FILES-APPROVED' }) + const shared = await insertEncryptedRow(job, { fileType: 'ENCRYPTED-RESULT', subdir: 'encrypted' }) - await db - .insertInto('studyJobFile') - .values([ - { - studyJobId: job.id, - name: 'results.zip', - path: `test-org/${study.id}/${job.id}/results/results.zip`, - fileType: 'ENCRYPTED-RESULT', - }, - { - studyJobId: job.id, - name: 'first.csv', - path: `test-org/${study.id}/${job.id}/results/approved/first.csv`, - fileType: 'APPROVED-RESULT', - }, - ]) - .execute() - - // The original archive contained two files; only first.csv was shared. - vi.mocked(fetchEncryptedJobFilesAction).mockResolvedValue([ - { - blob: new Blob(), - sourceId: '123', - fileType: 'ENCRYPTED-RESULT', - metadata: [ - { path: 'first.csv', bytes: 1024 }, - { path: 'second.csv', bytes: 2048 }, - ], - }, - ]) - - vi.mocked(fetchApprovedJobFilesAction).mockResolvedValue([ - { contents: new ArrayBuffer(10), path: 'first.csv', fileType: 'APPROVED-RESULT' }, - ]) + // All-or-nothing: once approved every artifact is shared (getSharedFileIdsForJob returns all). + vi.mocked(fetchSharedFileIdsAction).mockResolvedValue([shared.id]) const latestJob = await latestJobForStudy(study.id) - renderWithProviders() + renderWithProviders() await waitFor(() => { - expect(screen.getByText('first.csv')).toBeDefined() - expect(screen.getByLabelText('second.csv not shared with researcher')).toBeDefined() + expect(screen.getByText('Results')).toBeDefined() + expect(screen.getByLabelText('Shared with researcher')).toBeDefined() }) - - // Withheld file has no View/Download — only the shared file does - expect(screen.getAllByRole('button', { name: 'View' })).toHaveLength(1) - expect(screen.getAllByTestId('download-link')).toHaveLength(1) }) - it('shows a red "not shared" X (not a lock icon) for an entire log type withheld after approval', async () => { - const { study, job } = await insertTestStudyJobData({ - org, - jobStatus: 'FILES-APPROVED', - }) - - await db - .insertInto('studyJobFile') - .values([ - { - studyJobId: job.id, - name: 'first.csv', - path: `test-org/${study.id}/${job.id}/results/approved/first.csv`, - fileType: 'APPROVED-RESULT', - }, - { - studyJobId: job.id, - name: 'scan-log.zip', - path: `test-org/${study.id}/${job.id}/results/scan-log.zip`, - fileType: 'ENCRYPTED-SECURITY-SCAN-LOG', - }, - ]) - .execute() - - vi.mocked(fetchEncryptedJobFilesAction).mockResolvedValue([ - { - blob: new Blob(), - sourceId: '123', - fileType: 'ENCRYPTED-RESULT', - metadata: [{ path: 'first.csv', bytes: 1024 }], - }, - { - blob: new Blob(), - sourceId: '456', - fileType: 'ENCRYPTED-SECURITY-SCAN-LOG', - metadata: [{ path: 'scan-log.txt', bytes: 40 }], - }, - ]) - - vi.mocked(fetchApprovedJobFilesAction).mockResolvedValue([ - { contents: new ArrayBuffer(10), path: 'first.csv', fileType: 'APPROVED-RESULT' }, - ]) + it('shows a lock (no shared check) while a job is still under review', async () => { + const { study, job } = await insertTestStudyJobData({ org, jobStatus: 'RUN-COMPLETE' }) + await insertEncryptedRow(job, { fileType: 'ENCRYPTED-RESULT', subdir: 'encrypted' }) const latestJob = await latestJobForStudy(study.id) - renderWithProviders() + renderWithProviders() - await waitFor(() => { - expect(screen.getByLabelText('scan-log.txt not shared with researcher')).toBeDefined() - }) + await waitFor(() => expect(screen.getByText('Results')).toBeDefined()) + expect(screen.queryByLabelText('Shared with researcher')).toBeNull() + expect(screen.getByLabelText('Encrypted')).toBeDefined() }) - it('renders a placeholder row rather than silently dropping files when metadata is unavailable for an approved job', async () => { - const { study, job } = await insertTestStudyJobData({ - org, - jobStatus: 'FILES-APPROVED', - }) - - await db - .insertInto('studyJobFile') - .values({ - studyJobId: job.id, - name: 'results.zip', - path: `test-org/${study.id}/${job.id}/results/results.zip`, - fileType: 'ENCRYPTED-RESULT', - }) - .execute() - - // Encrypted-file metadata never arrives (fetch in flight or failed and swallowed by Sentry), - // and there are no approved files yet — metaList and approvedFilesForType are both empty. + // Researcher path: visibility and the decrypt form are gated on the user's own wrapped-key set + // (what fetchEncryptedJobFilesAction returns), not the job-wide artifact list. + it('renders nothing for a researcher with no wrapped keys, even when the job has artifacts', async () => { + const { study, job } = await insertTestStudyJobData({ org, jobStatus: 'FILES-APPROVED' }) + const shared = await insertEncryptedRow(job, { fileType: 'ENCRYPTED-RESULT', subdir: 'encrypted' }) + // Job is approved/shared at the job level, but this researcher holds no key for it. vi.mocked(fetchEncryptedJobFilesAction).mockResolvedValue([]) - vi.mocked(fetchApprovedJobFilesAction).mockResolvedValue([]) + vi.mocked(fetchSharedFileIdsAction).mockResolvedValue([shared.id]) const latestJob = await latestJobForStudy(study.id) - renderWithProviders() + const { container } = renderWithProviders( + , + ) - // The group still renders a placeholder row instead of an empty table; without metadata we - // can't enumerate withheld files, so no "not shared" indicator is shown yet. - await waitFor(() => { - expect(screen.getByText('results.zip')).toBeDefined() - }) - expect(screen.queryByLabelText(/not shared with researcher/)).toBeNull() + await waitFor(() => expect(vi.mocked(fetchEncryptedJobFilesAction)).toHaveBeenCalled()) + // No form to nowhere, and no false green "shared" check on an artifact they can't decrypt. + expect(container.querySelector('form')).toBeNull() + expect(screen.queryByLabelText('Shared with researcher')).toBeNull() + expect(screen.queryByText('Results')).toBeNull() }) - it('does not show any "not shared" indicator while a job is still under review', async () => { - const { study, job } = await insertTestStudyJobData({ - org, - jobStatus: 'RUN-COMPLETE', + it('shows the decrypt form (no shared check) for a researcher who holds a wrapped key', async () => { + const { study, job } = await insertTestStudyJobData({ org, jobStatus: 'FILES-APPROVED' }) + const artifact = await seedArtifact(job, { + fileType: 'ENCRYPTED-RESULT', + subdir: 'encrypted', + files: [{ name: 'results.csv', content: 'name,age\nAlice,30' }], }) - - await db - .insertInto('studyJobFile') - .values({ - studyJobId: job.id, - name: 'results.zip', - path: `test-org/${study.id}/${job.id}/results/results.zip`, - fileType: 'ENCRYPTED-RESULT', - }) - .execute() - - vi.mocked(fetchEncryptedJobFilesAction).mockResolvedValue([ - { - blob: new Blob(), - sourceId: '123', - fileType: 'ENCRYPTED-RESULT', - metadata: [{ path: 'results.csv', bytes: 2048 }], - }, - ]) + vi.mocked(fetchEncryptedJobFilesAction).mockResolvedValue([artifact]) + vi.mocked(fetchSharedFileIdsAction).mockResolvedValue([artifact.studyJobFileId]) const latestJob = await latestJobForStudy(study.id) - renderWithProviders() - - await waitFor(() => { - expect(screen.getByText('results.csv')).toBeDefined() - }) + renderWithProviders() - expect(screen.queryByLabelText(/not shared with researcher/)).toBeNull() + await waitFor(() => expect(screen.getByText('Results')).toBeDefined()) + // Green "shared with researcher" is a reviewer-facing signal — researchers never see it. + expect(screen.queryByLabelText('Shared with researcher')).toBeNull() + expect(screen.getByPlaceholderText(READER_KEY_PLACEHOLDER)).toBeDefined() }) }) diff --git a/src/components/encrypted-files-panel.tsx b/src/components/encrypted-files-panel.tsx index 406176cb4..eb24d6b39 100644 --- a/src/components/encrypted-files-panel.tsx +++ b/src/components/encrypted-files-panel.tsx @@ -1,28 +1,28 @@ import { FilePreviewModal } from '@/components/modals/file-preview-modal' import { ImagePreviewModal } from '@/components/modals/image-preview-modal' -import { InfoTooltip } from '@/components/tooltip' import { DownloadBlobLink } from '@/components/download-blob-link' import { useEncryptedFilesPanel, type UnifiedFileRow } from '@/hooks/use-encrypted-files-panel' import { decodeFileContents, imageMimeType } from '@/lib/file-content-helpers' import { formatBytes } from '@/lib/format' import type { JobFile, JobFileInfo } from '@/lib/types' import type { LatestJobForStudy } from '@/server/db/queries' -import { Button, Checkbox, Group, Stack, Table, Text, Textarea } from '@mantine/core' -import { CheckCircleIcon, InfoIcon, LockIcon, XCircleIcon } from '@phosphor-icons/react/dist/ssr' +import { PrivateKeyForm } from '@/components/private-key-form' +import { Button, Stack, Table } from '@mantine/core' +import { CheckCircleIcon, LockIcon, LockOpenIcon } from '@phosphor-icons/react/dist/ssr' import { FC } from 'react' type EncryptedFilesPanelProps = { job: LatestJobForStudy onFilesApproved: (files: JobFileInfo[]) => void - hideKeyLabel?: boolean hideTableUntilDecrypted?: boolean + isReviewer: boolean } export const EncryptedFilesPanel: FC = ({ job, onFilesApproved, - hideKeyLabel = false, hideTableUntilDecrypted = false, + isReviewer, }) => { const { fileRows, @@ -35,11 +35,11 @@ export const EncryptedFilesPanel: FC = ({ viewingFile, openFileViewer, closeFileViewer, - encryptedFileTypesLabel, - selectedPaths, - toggleFile, - } = useEncryptedFilesPanel({ job, onFilesApproved }) + } = useEncryptedFilesPanel({ job, onFilesApproved, isReviewer }) + // No decryptable rows for this user (a researcher with no wrapped keys yet — late joiner, or + // pre-renewal). Render nothing rather than a form to nowhere. Future: an honest "no results + // shared with you yet" empty state + the renewal re-wrap request affordance lives here. if (!hasFileRows) { return null } @@ -48,36 +48,15 @@ export const EncryptedFilesPanel: FC = ({ return ( - {showTable && ( - - )} - {shouldShowForm && ( -
- -