Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 64 additions & 5 deletions apps/api/src/evidence-forms/evidence-forms.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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() } },
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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', () => {
Expand Down
26 changes: 21 additions & 5 deletions apps/api/src/evidence-forms/evidence-forms.controller.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -14,6 +19,7 @@ import {
Patch,
Post,
Query,
Req,
Res,
UseGuards,
} from '@nestjs/common';
Expand All @@ -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')
Expand Down Expand Up @@ -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,
});
}
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/evidence-forms/evidence-forms.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,15 +648,15 @@ export class EvidenceFormsService {
async uploadSubmission(params: {
organizationId: string;
formType: string;
authContext: AuthContext;
userId: string;
payload: unknown;
}) {
const parsedType = evidenceFormTypeSchema.safeParse(params.formType);
if (!parsedType.success) {
throw new BadRequestException('Unsupported form type');
}

const userId = this.requireJwtUser(params.authContext);
const { userId } = params;

const parsed = uploadSubmissionBodySchema.safeParse(params.payload);
if (!parsed.success) {
Expand Down
Loading