diff --git a/packages/backend/src/apps/gathersg/__tests__/auth/schema.test.ts b/packages/backend/src/apps/gathersg/__tests__/auth/schema.test.ts index edfa3d9d2..10b429a3c 100644 --- a/packages/backend/src/apps/gathersg/__tests__/auth/schema.test.ts +++ b/packages/backend/src/apps/gathersg/__tests__/auth/schema.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest' -import type { SafeParseError } from 'zod' import schema from '../../auth/schema' @@ -64,6 +63,19 @@ describe('gathersg auth schema', () => { }) expect(result.success).toBe(true) }) + + it('accepts Workflow updatedBy with createdBy', () => { + const result = schema.safeParse({ + updatedBy: { + name: 'Workflow', + }, + createdBy: { + email: 'creator@example.com', + name: 'Creator', + }, + }) + expect(result.success).toBe(true) + }) }) describe('invalid cases - missing email', () => { @@ -83,12 +95,16 @@ describe('gathersg auth schema', () => { }, }) expect(result.success).toBe(false) - if (!result.success) { - const { issues } = (result as SafeParseError).error - expect( - issues.some((i) => i.message.includes('createdBy.email is required')), - ).toBe(true) - } + }) + + it('rejects createdBy with email null when not FormSG', () => { + const result = schema.safeParse({ + createdBy: { + name: 'Regular User', + email: null, + }, + }) + expect(result.success).toBe(false) }) it('rejects when both updatedBy and createdBy exist but updatedBy has no email', () => { @@ -104,6 +120,20 @@ describe('gathersg auth schema', () => { expect(result.success).toBe(false) }) + it('rejects when both updatedBy and createdBy exist but updatedBy email is null', () => { + const result = schema.safeParse({ + updatedBy: { + name: 'Updater', + email: null, + }, + createdBy: { + email: 'creator@example.com', + name: 'Creator', + }, + }) + expect(result.success).toBe(false) + }) + it('rejects empty object (neither updatedBy nor createdBy)', () => { const result = schema.safeParse({}) expect(result.success).toBe(false) @@ -143,6 +173,19 @@ describe('gathersg auth schema', () => { expect(result.success).toBe(false) }) + it('rejects FormSG createdBy with submissionId null', () => { + const result = schema.safeParse({ + createdBy: { + name: 'FormSG', + }, + formsg: { + formId: 'form-123', + submissionId: null, + }, + }) + expect(result.success).toBe(false) + }) + it('rejects FormSG createdBy with empty formId', () => { const result = schema.safeParse({ createdBy: { @@ -240,4 +283,64 @@ describe('gathersg auth schema', () => { expect(result.success).toBe(false) }) }) + + describe('Workflow invalid case', () => { + it('rejects updatedBy with name "Workflow" when email is present', () => { + const result = schema.safeParse({ + updatedBy: { + name: 'Workflow', + email: 'workflow@example.com', + }, + }) + expect(result.success).toBe(false) + }) + + it('rejects Workflow updatedBy when email is present even with valid createdBy', () => { + const result = schema.safeParse({ + updatedBy: { + name: 'Workflow', + email: 'workflow@example.com', + }, + createdBy: { + email: 'creator@example.com', + name: 'Creator', + }, + }) + expect(result.success).toBe(false) + }) + + it('rejects Workflow updatedBy without createdBy', () => { + const result = schema.safeParse({ + updatedBy: { + name: 'Workflow', + }, + }) + expect(result.success).toBe(false) + }) + + it('rejects Workflow updatedBy when createdBy is missing email', () => { + const result = schema.safeParse({ + updatedBy: { + name: 'Workflow', + }, + createdBy: { + name: 'Creator', + }, + }) + expect(result.success).toBe(false) + }) + + it('rejects Workflow updatedBy when createdBy email is null', () => { + const result = schema.safeParse({ + updatedBy: { + name: 'Workflow', + }, + createdBy: { + email: null, + name: 'Creator', + }, + }) + expect(result.success).toBe(false) + }) + }) }) diff --git a/packages/backend/src/apps/gathersg/auth/schema.ts b/packages/backend/src/apps/gathersg/auth/schema.ts index fad0e7a6e..f537ae3e1 100644 --- a/packages/backend/src/apps/gathersg/auth/schema.ts +++ b/packages/backend/src/apps/gathersg/auth/schema.ts @@ -9,14 +9,15 @@ const schema = z .object({ updatedBy: z .object({ - email: z.string().min(1), + // does not have email when update is done by an instant workflow + email: z.string().min(1).nullish(), name: z.string().min(1), }) .nullish(), createdBy: z .object({ - email: z.string().min(1).optional(), - name: z.string().min(1).optional(), + email: z.string().min(1).nullish(), + name: z.string().min(1).nullish(), }) .nullish(), formsg: z @@ -30,13 +31,25 @@ const schema = z (data) => { const { updatedBy, createdBy, formsg } = data || {} - // if both updatedBy and createdBy exist, only check updatedBy.email - if (updatedBy && createdBy) { - return !!updatedBy.email - } - - // if updatedBy exists, check for updatedBy.email + // if updatedBy exists, check updatedBy first if (updatedBy) { + if (updatedBy.name === 'Workflow') { + /** + * SPECIAL CASE + * if the workflow has an update step before calling the webhook, + * the webhook will return with: + * { + * updatedBy: { + * name: 'Workflow', + * }, + * } + * + * Best effort check that createdBy must contain both email and name + * to know that its not an API creating the case and an instant workflow + * calling Plumber again. + */ + return !updatedBy.email && !!createdBy?.email && !!createdBy?.name + } return !!updatedBy.email }