diff --git a/apps/web/src/lib/server/__tests__/artifacts.test.ts b/apps/web/src/lib/server/__tests__/artifacts.test.ts index 35b4c9425c..9112733dac 100644 --- a/apps/web/src/lib/server/__tests__/artifacts.test.ts +++ b/apps/web/src/lib/server/__tests__/artifacts.test.ts @@ -9,6 +9,7 @@ import { import { getArtifactByPath, getArtifactBySessionPath, + getArtifactVersionsBySessionPath, validateArtifactPath, validateArtifactSize, } from '../artifacts'; @@ -95,6 +96,47 @@ describe.each(['task', 'session'] as const)( }, ); +describe('Session artifact helper authorization', () => { + const path = 'reports/private.pdf'; + let sessionId: string; + + beforeEach(async () => { + sessionId = (await sessionFactory.create()).id; + await db.insert(taskArtifacts).values( + [1, 2, 3].map((version) => ({ + sessionId, + path, + version, + uploaded: version < 3, + contentType: 'application/pdf', + size: 100, + })), + ); + }); + + it('rejects path and version reads without a human user', async () => { + const auth = { userId: null, isAdmin: false }; + + await expect( + getArtifactBySessionPath({ sessionId, path, auth }), + ).resolves.toBeNull(); + await expect( + getArtifactVersionsBySessionPath({ sessionId, path, auth }), + ).resolves.toEqual([]); + }); + + it('returns uploaded versions, latest first, for an authorized member', async () => { + const auth = { userId: (await userFactory.create()).id, isAdmin: false }; + + await expect( + getArtifactVersionsBySessionPath({ sessionId, path, auth }), + ).resolves.toMatchObject([{ version: 2 }, { version: 1 }]); + await expect( + getArtifactBySessionPath({ sessionId, path, auth }), + ).resolves.toMatchObject({ path, version: 2, uploaded: true }); + }); +}); + describe('validateArtifactPath', () => { it('should accept valid paths', () => { const validPaths = [ diff --git a/apps/web/src/lib/server/artifacts.ts b/apps/web/src/lib/server/artifacts.ts index 86065eda18..4075ddee43 100644 --- a/apps/web/src/lib/server/artifacts.ts +++ b/apps/web/src/lib/server/artifacts.ts @@ -13,6 +13,7 @@ import { validateTaskArtifactPath, } from '@roomote/types'; import { canReadTask } from './custom-automation-task-access'; +import { findReadableSession } from './sessions'; function withTypedArtifactType( artifact: T, @@ -34,6 +35,16 @@ type ArtifactAuth = { isAdmin: boolean; }; +async function canReadSessionArtifacts(auth: ArtifactAuth, sessionId: string) { + if (!auth.userId) return false; + return Boolean( + await findReadableSession( + { userId: auth.userId, isAdmin: auth.isAdmin }, + sessionId, + ), + ); +} + /** * Get an artifact by its ID. */ @@ -89,13 +100,14 @@ export async function getArtifactBySessionPath({ sessionId, path, version, - auth: _auth, + auth, }: { sessionId: string; path: string; version?: number; auth: ArtifactAuth; }) { + if (!(await canReadSessionArtifacts(auth, sessionId))) return null; const artifact = await getSessionArtifactByPath({ sessionId, path, version }); return artifact ? withTypedArtifactType(artifact) : null; } @@ -103,12 +115,13 @@ export async function getArtifactBySessionPath({ export async function getArtifactVersionsBySessionPath({ sessionId, path, - auth: _auth, + auth, }: { sessionId: string; path: string; auth: ArtifactAuth; }) { + if (!(await canReadSessionArtifacts(auth, sessionId))) return []; return db .select({ id: taskArtifacts.id,