feat: mentor workspace files end-to-end with secure presign

Adds generateMentorObjectKey helper producing
<projectName>/mentorship/<timestamp>-<file>. 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 <WorkspaceFilesPanel> 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) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-28 13:33:18 +02:00
parent dd48db5eea
commit 2e7b545a1b
11 changed files with 699 additions and 30 deletions

View File

@@ -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() {
</CardContent>
</Card>
)}
{/* Files */}
{dashboardData?.project?.mentorAssignment?.id && (
<WorkspaceFilesPanel
mentorAssignmentId={dashboardData.project.mentorAssignment.id}
asApplicant
/>
)}
</div>
)
}

View File

@@ -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() {
</TabsContent>
<TabsContent value="files" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Workspace Files</CardTitle>
<CardDescription>
Files shared in the mentor workspace
</CardDescription>
</CardHeader>
<CardContent className="text-center py-8">
<FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">
File listing feature coming soon
</p>
</CardContent>
</Card>
{assignment ? (
<WorkspaceFilesPanel mentorAssignmentId={assignment.id} />
) : (
<Card>
<CardContent className="text-center py-8">
<FileText className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">Loading workspace</p>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="promotion" className="mt-6">

View File

@@ -32,8 +32,11 @@ export function FilePromotionPanel({ mentorAssignmentId }: FilePromotionPanelPro
const [selectedSlot, setSelectedSlot] = useState<string>('')
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 (

View File

@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<Card>
<CardHeader><CardTitle>Workspace Files</CardTitle></CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle>Workspace Files</CardTitle>
<CardDescription>
{asApplicant
? 'Files shared with your mentor in this workspace.'
: 'Files you and the team have shared in this workspace.'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description for the next upload"
className="flex-1"
/>
<Button
disabled={uploading}
onClick={() => inputRef.current?.click()}
className="shrink-0"
>
<Upload className="mr-2 h-4 w-4" />
{uploading ? 'Uploading…' : 'Upload file'}
</Button>
<input ref={inputRef} type="file" hidden onChange={handleFileSelected} />
</div>
{files && files.length === 0 && (
<div className="text-center py-8 text-sm text-muted-foreground">
<FileText className="h-10 w-10 mx-auto mb-2 opacity-40" />
No files in this workspace yet.
</div>
)}
<ul className="divide-y">
{(files ?? []).map((f) => (
<li key={f.id} className="flex items-center gap-3 py-3">
<FileText className="h-5 w-5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{f.fileName}</div>
<div className="text-xs text-muted-foreground">
{f.uploadedBy.name ?? f.uploadedBy.email} · {formatSize(f.size)} ·{' '}
{formatDistanceToNow(new Date(f.createdAt), { addSuffix: true })}
{f._count.comments > 0 && (
<span className="ml-2 inline-flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{f._count.comments}
</span>
)}
</div>
{f.description && (
<div className="text-xs text-muted-foreground mt-1">{f.description}</div>
)}
</div>
<Button variant="ghost" size="icon" onClick={() => handleDownload(f.id)}>
<Download className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="text-destructive hover:text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this file?</AlertDialogTitle>
<AlertDialogDescription>
This removes the file from MinIO and the workspace. Comments on the file are deleted with it.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => deleteMutation.mutate({ mentorFileId: f.id })}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</li>
))}
</ul>
</CardContent>
</Card>
)
}

View File

@@ -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
}

View File

@@ -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')
}

View File

@@ -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
*/

View File

@@ -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<void>,
): Promise<void> {
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 } })
}

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { generateMentorObjectKey } from '../../src/lib/minio'
describe('generateMentorObjectKey', () => {
it('produces a path under <projectName>/mentorship/<timestamp>-<file>', () => {
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)
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})