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:
31
tests/unit/mentor-key-construction.test.ts
Normal file
31
tests/unit/mentor-key-construction.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
60
tests/unit/mentor-upload-token.test.ts
Normal file
60
tests/unit/mentor-upload-token.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
122
tests/unit/mentor-workspace-files.test.ts
Normal file
122
tests/unit/mentor-workspace-files.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user