From 2e7b545a1b86530ad67e9c42aec82156761748a4 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Apr 2026 13:33:18 +0200 Subject: [PATCH] feat: mentor workspace files end-to-end with secure presign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds generateMentorObjectKey helper producing /mentorship/-. Replaces the client-supplied bucket/objectKey on workspaceUploadFile with an HMAC-signed upload token that binds bucket, objectKey, uploader, and a 1h expiry — paths can no longer be forged from the client. Adds workspaceGetUploadUrl, workspaceGetFiles, workspaceGetFileDownloadUrl, workspaceDeleteFile procedures with mentor-or-team-member auth. Builds and wires it into the mentor workspace Files tab and the applicant /applicant/mentor page. Replaces the file-promotion-panel mock array with a real workspaceGetFiles query. Tests cover token sign/verify (5), key construction (5), and end-to-end procedure flow including auth + tampered tokens (7). Spec: docs/superpowers/specs/2026-04-28-mentor-round-readiness-design.md §F.1 Plan: docs/superpowers/plans/2026-04-28-pr2-mentor-workspace-files.md Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(applicant)/applicant/mentor/page.tsx | 9 + .../mentor/workspace/[projectId]/page.tsx | 25 +-- .../mentor/file-promotion-panel.tsx | 9 +- .../mentor/workspace-files-panel.tsx | 193 ++++++++++++++++++ src/lib/mentor-upload-token.ts | 51 +++++ src/lib/minio.ts | 15 ++ src/server/routers/mentor.ts | 170 +++++++++++++-- src/server/services/mentor-workspace.ts | 44 ++++ tests/unit/mentor-key-construction.test.ts | 31 +++ tests/unit/mentor-upload-token.test.ts | 60 ++++++ tests/unit/mentor-workspace-files.test.ts | 122 +++++++++++ 11 files changed, 699 insertions(+), 30 deletions(-) create mode 100644 src/components/mentor/workspace-files-panel.tsx create mode 100644 src/lib/mentor-upload-token.ts create mode 100644 tests/unit/mentor-key-construction.test.ts create mode 100644 tests/unit/mentor-upload-token.test.ts create mode 100644 tests/unit/mentor-workspace-files.test.ts diff --git a/src/app/(applicant)/applicant/mentor/page.tsx b/src/app/(applicant)/applicant/mentor/page.tsx index 34abaec..289bf71 100644 --- a/src/app/(applicant)/applicant/mentor/page.tsx +++ b/src/app/(applicant)/applicant/mentor/page.tsx @@ -11,6 +11,7 @@ import { } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' import { MentorChat } from '@/components/shared/mentor-chat' +import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel' import { MessageSquare, UserCircle, @@ -133,6 +134,14 @@ export default function ApplicantMentorPage() { )} + + {/* Files */} + {dashboardData?.project?.mentorAssignment?.id && ( + + )} ) } diff --git a/src/app/(mentor)/mentor/workspace/[projectId]/page.tsx b/src/app/(mentor)/mentor/workspace/[projectId]/page.tsx index eb149a8..dfc2473 100644 --- a/src/app/(mentor)/mentor/workspace/[projectId]/page.tsx +++ b/src/app/(mentor)/mentor/workspace/[projectId]/page.tsx @@ -9,6 +9,7 @@ import { Skeleton } from '@/components/ui/skeleton' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { WorkspaceChat } from '@/components/mentor/workspace-chat' import { FilePromotionPanel } from '@/components/mentor/file-promotion-panel' +import { WorkspaceFilesPanel } from '@/components/mentor/workspace-files-panel' import { ArrowLeft, MessageSquare, FileText, Upload } from 'lucide-react' import { toast } from 'sonner' @@ -102,20 +103,16 @@ export default function MentorWorkspaceDetailPage() { - - - Workspace Files - - Files shared in the mentor workspace - - - - -

- File listing feature coming soon -

-
-
+ {assignment ? ( + + ) : ( + + + +

Loading workspace…

+
+
+ )}
diff --git a/src/components/mentor/file-promotion-panel.tsx b/src/components/mentor/file-promotion-panel.tsx index 1b66592..34cb1f4 100644 --- a/src/components/mentor/file-promotion-panel.tsx +++ b/src/components/mentor/file-promotion-panel.tsx @@ -32,8 +32,11 @@ export function FilePromotionPanel({ mentorAssignmentId }: FilePromotionPanelPro const [selectedSlot, setSelectedSlot] = useState('') const utils = trpc.useUtils() - // Mock workspace files - in real implementation, would fetch from workspaceGetFiles - const workspaceFiles: any[] = [] // Placeholder + const { data: workspaceFiles = [], isLoading: filesLoading } = + trpc.mentor.workspaceGetFiles.useQuery( + { mentorAssignmentId }, + { enabled: !!mentorAssignmentId }, + ) const promoteMutation = trpc.mentor.workspacePromoteFile.useMutation({ onSuccess: () => { @@ -56,7 +59,7 @@ export function FilePromotionPanel({ mentorAssignmentId }: FilePromotionPanelPro }) } - const isLoading = false // Placeholder + const isLoading = filesLoading if (isLoading) { return ( diff --git a/src/components/mentor/workspace-files-panel.tsx b/src/components/mentor/workspace-files-panel.tsx new file mode 100644 index 0000000..fb8cd9a --- /dev/null +++ b/src/components/mentor/workspace-files-panel.tsx @@ -0,0 +1,193 @@ +'use client' + +import { useState, useRef } from 'react' +import { trpc } from '@/lib/trpc/client' +import { toast } from 'sonner' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { Input } from '@/components/ui/input' +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { FileText, Upload, Download, Trash2, MessageSquare } from 'lucide-react' +import { formatDistanceToNow } from 'date-fns' + +interface Props { + mentorAssignmentId: string + /** Set true on the applicant side to label uploads as "Team upload" — purely cosmetic. */ + asApplicant?: boolean +} + +function formatSize(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] +} + +export function WorkspaceFilesPanel({ mentorAssignmentId, asApplicant }: Props) { + const utils = trpc.useUtils() + const inputRef = useRef(null) + const [uploading, setUploading] = useState(false) + const [description, setDescription] = useState('') + + const { data: files, isLoading } = trpc.mentor.workspaceGetFiles.useQuery( + { mentorAssignmentId }, + { enabled: !!mentorAssignmentId } + ) + + const presign = trpc.mentor.workspaceGetUploadUrl.useMutation() + const recordUpload = trpc.mentor.workspaceUploadFile.useMutation({ + onSuccess: () => { + utils.mentor.workspaceGetFiles.invalidate({ mentorAssignmentId }) + setDescription('') + toast.success('File uploaded') + }, + }) + const downloadMutation = trpc.mentor.workspaceGetFileDownloadUrl.useMutation() + const deleteMutation = trpc.mentor.workspaceDeleteFile.useMutation({ + onSuccess: () => { + utils.mentor.workspaceGetFiles.invalidate({ mentorAssignmentId }) + toast.success('File deleted') + }, + onError: (e) => toast.error(e.message), + }) + + const handleFileSelected = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + e.target.value = '' + setUploading(true) + try { + const { uploadUrl, uploadToken } = await presign.mutateAsync({ + mentorAssignmentId, + fileName: file.name, + mimeType: file.type || 'application/octet-stream', + size: file.size, + }) + const putRes = await fetch(uploadUrl, { + method: 'PUT', + body: file, + headers: { 'Content-Type': file.type || 'application/octet-stream' }, + }) + if (!putRes.ok) throw new Error(`Upload failed: HTTP ${putRes.status}`) + await recordUpload.mutateAsync({ uploadToken, description: description || undefined }) + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Upload failed') + } finally { + setUploading(false) + } + } + + const handleDownload = async (mentorFileId: string) => { + try { + const { url } = await downloadMutation.mutateAsync({ mentorFileId }) + window.open(url, '_blank') + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Download failed') + } + } + + if (isLoading) { + return ( + + Workspace Files + + + + + + ) + } + + return ( + + + Workspace Files + + {asApplicant + ? 'Files shared with your mentor in this workspace.' + : 'Files you and the team have shared in this workspace.'} + + + +
+ setDescription(e.target.value)} + placeholder="Optional description for the next upload" + className="flex-1" + /> + + +
+ + {files && files.length === 0 && ( +
+ + No files in this workspace yet. +
+ )} + +
    + {(files ?? []).map((f) => ( +
  • + +
    +
    {f.fileName}
    +
    + {f.uploadedBy.name ?? f.uploadedBy.email} · {formatSize(f.size)} ·{' '} + {formatDistanceToNow(new Date(f.createdAt), { addSuffix: true })} + {f._count.comments > 0 && ( + + + {f._count.comments} + + )} +
    + {f.description && ( +
    {f.description}
    + )} +
    + + + + + + + + Delete this file? + + This removes the file from MinIO and the workspace. Comments on the file are deleted with it. + + + + Cancel + deleteMutation.mutate({ mentorFileId: f.id })}> + Delete + + + + +
  • + ))} +
+
+
+ ) +} diff --git a/src/lib/mentor-upload-token.ts b/src/lib/mentor-upload-token.ts new file mode 100644 index 0000000..06465e4 --- /dev/null +++ b/src/lib/mentor-upload-token.ts @@ -0,0 +1,51 @@ +import { createHmac, timingSafeEqual } from 'crypto' + +export type MentorUploadPayload = { + mentorAssignmentId: string + uploaderUserId: string + fileName: string + mimeType: string + size: number + bucket: string + objectKey: string + /** Unix seconds. Token is rejected after this. */ + exp: number +} + +function getSecret(): string { + const s = process.env.NEXTAUTH_SECRET + if (!s) throw new Error('NEXTAUTH_SECRET is not set; cannot sign mentor upload tokens') + return s +} + +function hmac(payloadB64: string): string { + return createHmac('sha256', getSecret()).update(payloadB64).digest('hex') +} + +export function signMentorUploadToken(payload: MentorUploadPayload): string { + const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url') + const sig = hmac(payloadB64) + return `${payloadB64}.${sig}` +} + +export function verifyMentorUploadToken(token: string): MentorUploadPayload { + const parts = token.split('.') + if (parts.length !== 2) throw new Error('Invalid mentor upload token: malformed') + const [payloadB64, sig] = parts + const expected = hmac(payloadB64) + const a = Buffer.from(sig, 'hex') + const b = Buffer.from(expected, 'hex') + if (a.length !== b.length || !timingSafeEqual(a, b)) { + throw new Error('Invalid mentor upload token: signature mismatch') + } + let payload: MentorUploadPayload + try { + payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf-8')) + } catch { + throw new Error('Invalid mentor upload token: payload not parseable') + } + if (typeof payload.exp !== 'number' || payload.exp < Math.floor(Date.now() / 1000)) { + throw new Error('Invalid mentor upload token: expired') + } + return payload +} diff --git a/src/lib/minio.ts b/src/lib/minio.ts index 8ebd826..fd4bd36 100644 --- a/src/lib/minio.ts +++ b/src/lib/minio.ts @@ -149,3 +149,18 @@ export function generateObjectKey( return `${sanitizedProject}/${sanitizedRound}/${timestamp}-${sanitizedFile}` } +/** + * Generate a unique object key for a mentor-workspace file. + * + * Structure: {ProjectName}/mentorship/{timestamp}-{fileName} + * + * Mirrors generateObjectKey but pins the round-name slot to "mentorship" + * so all mentor workspace files for a project live under one folder. + */ +export function generateMentorObjectKey( + projectTitle: string, + fileName: string, +): string { + return generateObjectKey(projectTitle, fileName, 'mentorship') +} + diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts index 440ccda..400456d 100644 --- a/src/server/routers/mentor.ts +++ b/src/server/routers/mentor.ts @@ -1,7 +1,7 @@ import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, mentorProcedure, adminProcedure, protectedProcedure } from '../trpc' -import { MentorAssignmentMethod } from '@prisma/client' +import { MentorAssignmentMethod, type PrismaClient } from '@prisma/client' import { getAIMentorSuggestions, getRoundRobinMentor, @@ -20,8 +20,49 @@ import { uploadFile as workspaceUploadFile, addFileComment as workspaceAddFileComment, promoteFile as workspacePromoteFile, + getFiles as workspaceGetFilesService, + deleteFile as workspaceDeleteFileService, } from '../services/mentor-workspace' import { triggerInProgressOnActivity } from '../services/round-engine' +import { + generateMentorObjectKey, + getPresignedUrl, + BUCKET_NAME, + deleteObject, +} from '@/lib/minio' +import { + signMentorUploadToken, + verifyMentorUploadToken, +} from '@/lib/mentor-upload-token' + +/** + * Throws TRPCError if the given user is neither the assigned mentor + * nor a team member of the project linked to the assignment. + * Returns the loaded MentorAssignment + Project on success. + */ +async function assertWorkspaceAccess( + prisma: PrismaClient, + userId: string, + mentorAssignmentId: string, +) { + const assignment = await prisma.mentorAssignment.findUnique({ + where: { id: mentorAssignmentId }, + include: { project: { select: { id: true, title: true } } }, + }) + if (!assignment) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Mentor assignment not found' }) + } + if (!assignment.workspaceEnabled) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Workspace is not enabled' }) + } + if (assignment.mentorId === userId) return assignment + const teamMembership = await prisma.teamMember.findFirst({ + where: { projectId: assignment.projectId, userId }, + select: { id: true }, + }) + if (teamMembership) return assignment + throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this workspace' }) +} export const mentorRouter = router({ /** @@ -1372,36 +1413,139 @@ export const mentorRouter = router({ }), /** - * Upload a file to a workspace + * Issue a presigned upload URL + signed token for a mentor-workspace file. + * The token binds the bucket, objectKey, and uploader so the client cannot + * forge a path; workspaceUploadFile reads the token, never the + * client-supplied path. */ - workspaceUploadFile: mentorProcedure + workspaceGetUploadUrl: protectedProcedure .input( z.object({ mentorAssignmentId: z.string(), fileName: z.string().min(1).max(255), - mimeType: z.string(), - size: z.number().int().min(0), - bucket: z.string(), - objectKey: z.string(), + mimeType: z.string().min(1).max(200), + size: z.number().int().min(0).max(500 * 1024 * 1024), + }) + ) + .mutation(async ({ ctx, input }) => { + const assignment = await assertWorkspaceAccess( + ctx.prisma, ctx.user.id, input.mentorAssignmentId, + ) + const objectKey = generateMentorObjectKey(assignment.project.title, input.fileName) + const uploadUrl = await getPresignedUrl(BUCKET_NAME, objectKey, 'PUT', 3600) + const exp = Math.floor(Date.now() / 1000) + 3600 + const uploadToken = signMentorUploadToken({ + mentorAssignmentId: assignment.id, + uploaderUserId: ctx.user.id, + fileName: input.fileName, + mimeType: input.mimeType, + size: input.size, + bucket: BUCKET_NAME, + objectKey, + exp, + }) + return { uploadUrl, uploadToken, bucket: BUCKET_NAME, objectKey } + }), + + /** + * Record a workspace file upload. Requires a valid uploadToken issued by + * workspaceGetUploadUrl — the token contains the server-built bucket, + * objectKey, and uploader binding. The client cannot pass a path directly. + */ + workspaceUploadFile: protectedProcedure + .input( + z.object({ + uploadToken: z.string(), description: z.string().max(2000).optional(), }) ) .mutation(async ({ ctx, input }) => { + let payload + try { + payload = verifyMentorUploadToken(input.uploadToken) + } catch (e) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: e instanceof Error ? e.message : 'Invalid upload token', + }) + } + if (payload.uploaderUserId !== ctx.user.id) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Upload token does not belong to the current user', + }) + } + await assertWorkspaceAccess(ctx.prisma, ctx.user.id, payload.mentorAssignmentId) return workspaceUploadFile( { - workspaceId: input.mentorAssignmentId, + workspaceId: payload.mentorAssignmentId, uploadedByUserId: ctx.user.id, - fileName: input.fileName, - mimeType: input.mimeType, - size: input.size, - bucket: input.bucket, - objectKey: input.objectKey, + fileName: payload.fileName, + mimeType: payload.mimeType, + size: payload.size, + bucket: payload.bucket, + objectKey: payload.objectKey, description: input.description, }, ctx.prisma, ) }), + /** + * List files in a workspace. Authorized for the assigned mentor or any + * project team member. + */ + workspaceGetFiles: protectedProcedure + .input(z.object({ mentorAssignmentId: z.string() })) + .query(async ({ ctx, input }) => { + await assertWorkspaceAccess(ctx.prisma, ctx.user.id, input.mentorAssignmentId) + return workspaceGetFilesService(input.mentorAssignmentId, ctx.prisma) + }), + + /** + * Issue a short-lived presigned GET URL to download a workspace file. + */ + workspaceGetFileDownloadUrl: protectedProcedure + .input(z.object({ mentorFileId: z.string() })) + .mutation(async ({ ctx, input }) => { + const file = await ctx.prisma.mentorFile.findUnique({ + where: { id: input.mentorFileId }, + select: { bucket: true, objectKey: true, fileName: true, mentorAssignmentId: true }, + }) + if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' }) + await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId) + const url = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900, + { downloadFileName: file.fileName }) + return { url } + }), + + /** + * Delete a workspace file (uploader or assigned mentor only). + */ + workspaceDeleteFile: protectedProcedure + .input(z.object({ mentorFileId: z.string() })) + .mutation(async ({ ctx, input }) => { + const file = await ctx.prisma.mentorFile.findUnique({ + where: { id: input.mentorFileId }, + select: { mentorAssignmentId: true }, + }) + if (!file) throw new TRPCError({ code: 'NOT_FOUND', message: 'File not found' }) + await assertWorkspaceAccess(ctx.prisma, ctx.user.id, file.mentorAssignmentId) + try { + await workspaceDeleteFileService( + { mentorFileId: input.mentorFileId, userId: ctx.user.id }, + ctx.prisma, + deleteObject, + ) + } catch (e) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: e instanceof Error ? e.message : 'Delete failed', + }) + } + return { success: true } + }), + /** * Add a comment to a workspace file */ diff --git a/src/server/services/mentor-workspace.ts b/src/server/services/mentor-workspace.ts index 8f61f16..73957d4 100644 --- a/src/server/services/mentor-workspace.ts +++ b/src/server/services/mentor-workspace.ts @@ -312,3 +312,47 @@ export async function promoteFile( } } } + +/** + * List files for a workspace, newest first, with comment counts and uploader. + */ +export async function getFiles( + workspaceId: string, + prisma: PrismaClient, +) { + return prisma.mentorFile.findMany({ + where: { mentorAssignmentId: workspaceId }, + orderBy: { createdAt: 'desc' }, + include: { + uploadedBy: { select: { id: true, name: true, email: true } }, + _count: { select: { comments: true } }, + }, + }) +} + +/** + * Delete a file. Caller must be either the uploader OR the assigned mentor. + * Removes the MinIO object and the DB row + cascade-deletes comments. + */ +export async function deleteFile( + params: { mentorFileId: string; userId: string }, + prisma: PrismaClient, + removeStorageObject: (bucket: string, key: string) => Promise, +): Promise { + const file = await prisma.mentorFile.findUnique({ + where: { id: params.mentorFileId }, + include: { mentorAssignment: { select: { mentorId: true } } }, + }) + if (!file) throw new Error('File not found') + const isUploader = file.uploadedByUserId === params.userId + const isMentor = file.mentorAssignment.mentorId === params.userId + if (!isUploader && !isMentor) { + throw new Error('Only the uploader or the assigned mentor can delete this file') + } + try { + await removeStorageObject(file.bucket, file.objectKey) + } catch (err) { + console.error('[mentor-workspace] failed to delete storage object', file.objectKey, err) + } + await prisma.mentorFile.delete({ where: { id: params.mentorFileId } }) +} diff --git a/tests/unit/mentor-key-construction.test.ts b/tests/unit/mentor-key-construction.test.ts new file mode 100644 index 0000000..7f2ce2d --- /dev/null +++ b/tests/unit/mentor-key-construction.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { generateMentorObjectKey } from '../../src/lib/minio' + +describe('generateMentorObjectKey', () => { + it('produces a path under /mentorship/-', () => { + const key = generateMentorObjectKey('Revamp Flips', 'meeting-notes.pdf') + expect(key).toMatch(/^Revamp_Flips\/mentorship\/\d+-meeting-notes\.pdf$/) + }) + + it('sanitizes special characters in the project title', () => { + const key = generateMentorObjectKey('Côté & Bro 2026!', 'file.pdf') + expect(key.startsWith('Ct_Bro_2026/mentorship/')).toBe(true) + }) + + it('sanitizes special characters in the file name', () => { + const key = generateMentorObjectKey('Project', 'rapport final 2026 — version 2.docx') + expect(key).toMatch(/^Project\/mentorship\/\d+-rapport_final_2026___version_2\.docx$/) + }) + + it('falls back to "unnamed" for an empty project title', () => { + const key = generateMentorObjectKey('', 'doc.pdf') + expect(key.startsWith('unnamed/mentorship/')).toBe(true) + }) + + it('uses a different timestamp for sequential calls in different milliseconds', async () => { + const a = generateMentorObjectKey('P', 'a.pdf') + await new Promise((r) => setTimeout(r, 5)) + const b = generateMentorObjectKey('P', 'a.pdf') + expect(a).not.toEqual(b) + }) +}) diff --git a/tests/unit/mentor-upload-token.test.ts b/tests/unit/mentor-upload-token.test.ts new file mode 100644 index 0000000..b6ef28f --- /dev/null +++ b/tests/unit/mentor-upload-token.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, beforeAll } from 'vitest' +import { + signMentorUploadToken, verifyMentorUploadToken, + type MentorUploadPayload, +} from '../../src/lib/mentor-upload-token' + +const samplePayload: MentorUploadPayload = { + mentorAssignmentId: 'ma-123', + uploaderUserId: 'user-456', + fileName: 'doc.pdf', + mimeType: 'application/pdf', + size: 12345, + bucket: 'mopc-files', + objectKey: 'Project/mentorship/123-doc.pdf', + exp: Math.floor(Date.now() / 1000) + 3600, +} + +beforeAll(() => { + process.env.NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET || 'test-secret-123' +}) + +describe('mentor upload token', () => { + it('round-trips a payload', () => { + const token = signMentorUploadToken(samplePayload) + expect(typeof token).toBe('string') + expect(token.split('.').length).toBe(2) // payload.signature + + const verified = verifyMentorUploadToken(token) + expect(verified.mentorAssignmentId).toBe('ma-123') + expect(verified.objectKey).toBe('Project/mentorship/123-doc.pdf') + }) + + it('rejects a tampered payload', () => { + const token = signMentorUploadToken(samplePayload) + const [, sig] = token.split('.') + const tamperedPayload = Buffer.from(JSON.stringify({ ...samplePayload, size: 999999 })).toString('base64url') + const tampered = `${tamperedPayload}.${sig}` + expect(() => verifyMentorUploadToken(tampered)).toThrow(/signature/i) + }) + + it('rejects a token with a different signature', () => { + const token = signMentorUploadToken(samplePayload) + const [payload] = token.split('.') + const bad = `${payload}.0000000000000000000000000000000000000000000000000000000000000000` + expect(() => verifyMentorUploadToken(bad)).toThrow(/signature/i) + }) + + it('rejects an expired token', () => { + const expired = signMentorUploadToken({ + ...samplePayload, + exp: Math.floor(Date.now() / 1000) - 60, + }) + expect(() => verifyMentorUploadToken(expired)).toThrow(/expired/i) + }) + + it('rejects a malformed token', () => { + expect(() => verifyMentorUploadToken('not-a-token')).toThrow() + expect(() => verifyMentorUploadToken('one-segment')).toThrow() + }) +}) diff --git a/tests/unit/mentor-workspace-files.test.ts b/tests/unit/mentor-workspace-files.test.ts new file mode 100644 index 0000000..b38a8cc --- /dev/null +++ b/tests/unit/mentor-workspace-files.test.ts @@ -0,0 +1,122 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { prisma, createCaller } from '../setup' +import { + createTestUser, createTestProgram, createTestProject, cleanupTestData, uid, +} from '../helpers' +import { mentorRouter } from '../../src/server/routers/mentor' +import { signMentorUploadToken } from '../../src/lib/mentor-upload-token' + +describe('mentor.workspace files end-to-end', () => { + let programId: string + let mentor: { id: string; email: string; role: 'MENTOR' } + let outsider: { id: string; email: string; role: 'JURY_MEMBER' } + let assignmentId: string + const userIds: string[] = [] + + beforeAll(async () => { + process.env.NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET || 'test-secret-123' + const program = await createTestProgram({ name: `mentor-files-${uid()}` }) + programId = program.id + const project = await createTestProject(programId, { title: 'Test Project' }) + + const m = await createTestUser('MENTOR') + userIds.push(m.id) + mentor = { id: m.id, email: m.email, role: 'MENTOR' } + const o = await createTestUser('JURY_MEMBER') + userIds.push(o.id) + outsider = { id: o.id, email: o.email, role: 'JURY_MEMBER' } + + const assignment = await prisma.mentorAssignment.create({ + data: { + id: uid('ma'), projectId: project.id, mentorId: m.id, method: 'MANUAL', + workspaceEnabled: true, + }, + }) + assignmentId = assignment.id + }) + + afterAll(async () => { + await cleanupTestData(programId, userIds) + }) + + it('issues an upload URL + signed token to the assigned mentor', async () => { + const caller = createCaller(mentorRouter, mentor) + const result = await caller.workspaceGetUploadUrl({ + mentorAssignmentId: assignmentId, + fileName: 'plan.pdf', + mimeType: 'application/pdf', + size: 1024, + }) + expect(typeof result.uploadUrl).toBe('string') + expect(result.uploadUrl).toContain('plan.pdf') + expect(typeof result.uploadToken).toBe('string') + expect(result.objectKey).toMatch(/^Test_Project\/mentorship\/\d+-plan\.pdf$/) + }) + + it('rejects upload-url request from a user who is neither mentor nor team member', async () => { + const caller = createCaller(mentorRouter, outsider) + await expect( + caller.workspaceGetUploadUrl({ + mentorAssignmentId: assignmentId, fileName: 'x.pdf', mimeType: 'application/pdf', size: 1, + }) + ).rejects.toThrow(/not a member|FORBIDDEN/i) + }) + + it('records a file when given a valid token', async () => { + const caller = createCaller(mentorRouter, mentor) + const presign = await caller.workspaceGetUploadUrl({ + mentorAssignmentId: assignmentId, fileName: 'a.pdf', mimeType: 'application/pdf', size: 99, + }) + const file = await caller.workspaceUploadFile({ + uploadToken: presign.uploadToken, + description: 'first file', + }) + expect(file.fileName).toBe('a.pdf') + expect(file.objectKey).toBe(presign.objectKey) + expect(file.bucket).toBeTruthy() + }) + + it('rejects workspaceUploadFile with a token whose uploader differs from the caller', async () => { + const forged = signMentorUploadToken({ + mentorAssignmentId: assignmentId, + uploaderUserId: 'someone-else', + fileName: 'x.pdf', mimeType: 'application/pdf', size: 1, + bucket: 'mopc-files', objectKey: 'a/mentorship/0-x.pdf', + exp: Math.floor(Date.now() / 1000) + 60, + }) + const caller = createCaller(mentorRouter, mentor) + await expect(caller.workspaceUploadFile({ uploadToken: forged })).rejects.toThrow(/does not belong/i) + }) + + it('lists files for the assigned mentor, sorted newest first', async () => { + const caller = createCaller(mentorRouter, mentor) + const a = await caller.workspaceGetUploadUrl({ + mentorAssignmentId: assignmentId, fileName: 'b.pdf', mimeType: 'application/pdf', size: 50, + }) + await caller.workspaceUploadFile({ uploadToken: a.uploadToken }) + const files = await caller.workspaceGetFiles({ mentorAssignmentId: assignmentId }) + expect(files.length).toBeGreaterThanOrEqual(2) + expect(new Date(files[0].createdAt).getTime()).toBeGreaterThanOrEqual( + new Date(files[1].createdAt).getTime(), + ) + }) + + it('refuses workspaceGetFiles to outsiders', async () => { + const caller = createCaller(mentorRouter, outsider) + await expect( + caller.workspaceGetFiles({ mentorAssignmentId: assignmentId }) + ).rejects.toThrow(/FORBIDDEN|not a member/i) + }) + + it('deletes a file the user uploaded', async () => { + const caller = createCaller(mentorRouter, mentor) + const p = await caller.workspaceGetUploadUrl({ + mentorAssignmentId: assignmentId, fileName: 'kill.pdf', mimeType: 'application/pdf', size: 10, + }) + const file = await caller.workspaceUploadFile({ uploadToken: p.uploadToken }) + const result = await caller.workspaceDeleteFile({ mentorFileId: file.id }) + expect(result.success).toBe(true) + const after = await prisma.mentorFile.findUnique({ where: { id: file.id } }) + expect(after).toBeNull() + }) +})