diff --git a/apps/api/src/evidence-forms/evidence-forms.controller.spec.ts b/apps/api/src/evidence-forms/evidence-forms.controller.spec.ts index 4277a92cc..45c683dba 100644 --- a/apps/api/src/evidence-forms/evidence-forms.controller.spec.ts +++ b/apps/api/src/evidence-forms/evidence-forms.controller.spec.ts @@ -1,9 +1,16 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException } from '@nestjs/common'; import { EvidenceFormsController } from './evidence-forms.controller'; import { EvidenceFormsService } from './evidence-forms.service'; +import { ActingUserResolver } from '../auth/acting-user.service'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; import { PermissionGuard } from '../auth/permission.guard'; -import type { AuthContext as AuthContextType } from '../auth/types'; +import type { + AuthContext as AuthContextType, + AuthenticatedRequest, +} from '../auth/types'; + +jest.mock('@db', () => ({ db: {} })); jest.mock('../auth/auth.server', () => ({ auth: { api: { getSession: jest.fn() } }, @@ -61,10 +68,17 @@ describe('EvidenceFormsController', () => { userRoles: ['admin'], }; + const mockActingUser = { + resolve: jest.fn(), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [EvidenceFormsController], - providers: [{ provide: EvidenceFormsService, useValue: mockService }], + providers: [ + { provide: EvidenceFormsService, useValue: mockService }, + { provide: ActingUserResolver, useValue: mockActingUser }, + ], }) .overrideGuard(HybridAuthGuard) .useValue(mockGuard) @@ -298,26 +312,71 @@ describe('EvidenceFormsController', () => { }); describe('uploadSubmission', () => { - it('should call service.uploadSubmission with correct params', async () => { + it('should call service.uploadSubmission with the session userId', async () => { const body = { fileUrl: 'https://example.com/file.pdf' }; const mockResult = { id: 'sub_upload' }; mockService.uploadSubmission.mockResolvedValue(mockResult); + mockActingUser.resolve.mockResolvedValue({ + userId: 'user_1', + source: 'session', + }); + const req = {} as AuthenticatedRequest; const result = await controller.uploadSubmission( 'org_1', - mockAuthContext, 'security-awareness', body, + req, ); expect(result).toEqual(mockResult); + expect(mockActingUser.resolve).toHaveBeenCalledWith(req, 'org_1'); expect(service.uploadSubmission).toHaveBeenCalledWith({ organizationId: 'org_1', formType: 'security-awareness', - authContext: mockAuthContext, + userId: 'user_1', payload: body, }); }); + + it('should attribute to the org owner when called with API key auth', async () => { + const body = { fileUrl: 'https://example.com/file.pdf' }; + const mockResult = { id: 'sub_upload' }; + mockService.uploadSubmission.mockResolvedValue(mockResult); + mockActingUser.resolve.mockResolvedValue({ + userId: 'owner_user', + source: 'org-owner-fallback', + callerLabel: 'via API key "CI"', + }); + + const req = {} as AuthenticatedRequest; + await controller.uploadSubmission('org_1', 'meeting', body, req); + + expect(service.uploadSubmission).toHaveBeenCalledWith({ + organizationId: 'org_1', + formType: 'meeting', + userId: 'owner_user', + payload: body, + }); + }); + + it('should throw BadRequestException when no owner can be resolved', async () => { + mockActingUser.resolve.mockResolvedValue({ + userId: null, + source: 'org-owner-fallback', + callerLabel: 'via API key', + }); + + await expect( + controller.uploadSubmission( + 'org_1', + 'meeting', + { fileUrl: 'x' }, + {} as AuthenticatedRequest, + ), + ).rejects.toBeInstanceOf(BadRequestException); + expect(service.uploadSubmission).not.toHaveBeenCalled(); + }); }); describe('reviewSubmission', () => { diff --git a/apps/api/src/evidence-forms/evidence-forms.controller.ts b/apps/api/src/evidence-forms/evidence-forms.controller.ts index cec36356d..2597065fd 100644 --- a/apps/api/src/evidence-forms/evidence-forms.controller.ts +++ b/apps/api/src/evidence-forms/evidence-forms.controller.ts @@ -1,10 +1,15 @@ +import { ActingUserResolver } from '@/auth/acting-user.service'; import { AuthContext, OrganizationId } from '@/auth/auth-context.decorator'; import { HybridAuthGuard } from '@/auth/hybrid-auth.guard'; import { PermissionGuard } from '@/auth/permission.guard'; import { RequirePermission } from '@/auth/require-permission.decorator'; -import type { AuthContext as AuthContextType } from '@/auth/types'; +import type { + AuthContext as AuthContextType, + AuthenticatedRequest, +} from '@/auth/types'; import { AuditRead } from '@/audit/skip-audit-log.decorator'; import { + BadRequestException, Body, Controller, Delete, @@ -14,6 +19,7 @@ import { Patch, Post, Query, + Req, Res, UseGuards, } from '@nestjs/common'; @@ -32,7 +38,10 @@ import { EvidenceFormsService } from './evidence-forms.service'; required: false, }) export class EvidenceFormsController { - constructor(private readonly evidenceFormsService: EvidenceFormsService) {} + constructor( + private readonly evidenceFormsService: EvidenceFormsService, + private readonly actingUser: ActingUserResolver, + ) {} @Get() @RequirePermission('evidence', 'read') @@ -213,18 +222,25 @@ export class EvidenceFormsController { @ApiOperation({ summary: 'Upload a file as an evidence submission', description: - 'Upload a PDF or image file and create a submission for the given form type, bypassing form-specific validation', + 'Upload a PDF or image file and create a submission for the given form type, bypassing form-specific validation. ' + + "Accepts session, API key, or service token auth. For API key / service token callers without an explicit user attribution, the submission is attributed to the org's oldest owner.", }) async uploadSubmission( @OrganizationId() organizationId: string, - @AuthContext() authContext: AuthContextType, @Param('formType') formType: string, @Body() body: unknown, + @Req() req: AuthenticatedRequest, ) { + const acting = await this.actingUser.resolve(req, organizationId); + if (!acting.userId) { + throw new BadRequestException( + 'Cannot attribute this submission — your organization must have at least one user with the "owner" role.', + ); + } return this.evidenceFormsService.uploadSubmission({ organizationId, formType, - authContext, + userId: acting.userId, payload: body, }); } diff --git a/apps/api/src/evidence-forms/evidence-forms.service.ts b/apps/api/src/evidence-forms/evidence-forms.service.ts index b1589ed90..b2ed8afb8 100644 --- a/apps/api/src/evidence-forms/evidence-forms.service.ts +++ b/apps/api/src/evidence-forms/evidence-forms.service.ts @@ -648,7 +648,7 @@ export class EvidenceFormsService { async uploadSubmission(params: { organizationId: string; formType: string; - authContext: AuthContext; + userId: string; payload: unknown; }) { const parsedType = evidenceFormTypeSchema.safeParse(params.formType); @@ -656,7 +656,7 @@ export class EvidenceFormsService { throw new BadRequestException('Unsupported form type'); } - const userId = this.requireJwtUser(params.authContext); + const { userId } = params; const parsed = uploadSubmissionBodySchema.safeParse(params.payload); if (!parsed.success) {