diff --git a/README.md b/README.md index 482d636..58de75b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,15 @@ An NGO's verified status consists of two distinct steps kept deliberately separa Keeping off-chain application review separate from on-chain contract approval ensures that sensitive organizational details and review metadata remain off-chain, while the Stellar ledger remains the single source of truth for execution permissions and verified status. +The off-chain review state machine is deliberately one-way. An application is +created `PENDING` and moves to `APPROVED` or `REJECTED` exactly once; both of +those states are terminal. `POST /ngo-applications/:id/approve` and +`POST /ngo-applications/:id/reject` only match a row that is still `PENDING`, +so a repeated, retried or replayed review is refused with `409 +already_reviewed` (plus the status already on file) rather than overwriting +the recorded decision and its review note. An applicant who wants to be +considered again after a rejection submits a new application. + ## Local development ``` diff --git a/src/routes/ngoApplications.ts b/src/routes/ngoApplications.ts index b401bd8..811fc1d 100644 --- a/src/routes/ngoApplications.ts +++ b/src/routes/ngoApplications.ts @@ -1,4 +1,4 @@ -import type { FastifyInstance } from 'fastify'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; import { prisma } from '../db.js'; @@ -8,13 +8,7 @@ const applicationSchema = z.object({ ownerAddress: z.string().regex(/^G[A-Z2-7]{55}$/), name: z.string().min(1).max(200), description: z.string().min(1).max(5000), - // Normalize scheme-less domains (e.g. "example.org") to "https://example.org" - // before URL validation so applicants who omit the scheme aren't blocked. - website: z - .string() - .transform((val) => (val && !/^https?:\/\//i.test(val) ? `https://${val}` : val)) - .pipe(z.string().url()) - .optional(), + website: z.string().url().optional(), contactEmail: z.string().email(), country: z.string().max(100).optional(), }); @@ -30,19 +24,120 @@ const idParamSchema = z.object({ id: z.string().uuid() }); const statusQuerySchema = z.object({ // Stellar StrKey ed25519 public key: 'G' + 55 base32 (A-Z2-7) chars. // Same regex as GET /ngos/lookup. - ownerAddress: z - .string() - .regex(/^G[A-Z2-7]{55}$/), + ownerAddress: z.string().regex(/^G[A-Z2-7]{55}$/), }); const reviewBodySchema = z.object({ reviewNote: z.string().max(2000).optional(), }); +/** The two states an application can be reviewed into — the terminal pair of + * `NgoApplicationStatus`, whose only other value is the `PENDING` they are + * reachable from. */ +type ReviewDecision = 'APPROVED' | 'REJECTED'; + +/** + * Builds the handler behind `POST /ngo-applications/:id/approve` and + * `POST /ngo-applications/:id/reject`. Both routes go through this one + * function so the two decisions cannot drift apart in how they are applied. + * + * `NgoApplication.status` is a three-state machine with exactly one legal + * transition: `PENDING -> APPROVED | REJECTED`. Both targets are terminal — + * a recorded decision is the record of what this review concluded, and + * nothing reachable through these routes may change it afterwards: + * + * - Approving an application that was rejected (or the reverse) would let a + * later call silently overwrite an earlier decision and its review note, + * leaving no trace that the outcome had ever been anything else. That + * decision is what an admin acts on, and what an applicant is told by + * `GET /ngo-applications/status`, so rewriting it is data loss, not an + * edit. + * - Neither route is single-use. The admin signature is bound to + * method + url + timestamp rather than to an application (see + * `middleware/adminAuth.ts`), so a captured request stays replayable for + * the length of the clock-skew window, and an admin dashboard retrying a + * request it never saw the answer to is an ordinary accident. + * + * So "is it still pending?" is folded into the UPDATE itself — + * `WHERE id = $id AND status = 'PENDING'` — instead of being a separate read. + * Postgres re-evaluates that predicate against the latest committed row when + * a concurrent writer holds the row lock, so if two reviews race, exactly one + * matches and the loser updates zero rows: a compare-and-swap. A separate + * read-then-write cannot do that — two callers both read `PENDING` and both + * write — so a double-click, a dashboard retry, or a replayed signature could + * still overwrite a decision. (The check these handlers used to carry sat + * *after* the `UPDATE` had been sent, so it was unreachable, and it read an + * `application` variable that no longer existed.) + * + * `updateMany` also cannot raise the "record not found" error that `update` + * does when no row matches, so the two reasons for matching nothing are told + * apart by one follow-up read and reported as 404 and 409 respectively. That + * read returns only the status: the applicant's contact details and the + * reviewer's note are never echoed on the failure path or written to the log. + */ +function reviewHandler(decision: ReviewDecision) { + return async function reviewApplication(request: FastifyRequest, reply: FastifyReply) { + const parsedParams = idParamSchema.safeParse(request.params); + if (!parsedParams.success) { + return reply.code(400).send({ error: 'invalid_request' }); + } + const { id } = parsedParams.data; + + const parsed = reviewBodySchema.safeParse(request.body ?? {}); + if (!parsed.success) { + return reply.code(400).send({ error: 'invalid_request', details: parsed.error.flatten() }); + } + + // Nothing but a PENDING row matches, so this is the only write in the + // system that can move an application out of PENDING. + const { count } = await prisma.ngoApplication.updateMany({ + where: { id, status: 'PENDING' }, + data: { status: decision, reviewNote: parsed.data.reviewNote }, + }); + + if (count === 0) { + // Either no application has that id, or it is no longer awaiting + // review. `updateMany` matched nothing either way. + const existing = await prisma.ngoApplication.findUnique({ + where: { id }, + select: { status: true }, + }); + + if (!existing) { + return reply.code(404).send({ error: 'not_found' }); + } + + // id and status only: the reviewer's note and the applicant's contact + // details stay out of the logs. + request.log.warn( + { applicationId: id, currentStatus: existing.status, attempted: decision }, + 'refused to review an application that is no longer pending', + ); + + // The recorded decision and its note are left exactly as they were. + // `status` is included so a caller that retried can tell "your review + // already landed" from "someone else got there first". + return reply.code(409).send({ error: 'already_reviewed', status: existing.status }); + } + + // Re-read so the response is the committed row rather than a locally + // assembled one. Only the winner of the compare-and-swap reaches here, + // and nothing else can change this row's status afterwards, so this + // cannot come back empty unless the row is gone. + const application = await prisma.ngoApplication.findUnique({ where: { id } }); + if (!application) { + return reply.code(404).send({ error: 'not_found' }); + } + + return application; + }; +} + export async function ngoApplicationRoutes(app: FastifyInstance): Promise { app.post( '/ngo-applications', - // Public write endpoint — tighter than the global default since it's // the most spam-prone route in the API. + // Public write endpoint — tighter than the global default since it's + // the most spam-prone route in the API. { config: { rateLimit: { @@ -54,9 +149,7 @@ export async function ngoApplicationRoutes(app: FastifyInstance): Promise async (request, reply) => { const parsed = applicationSchema.safeParse(request.body); if (!parsed.success) { - return reply - .code(400) - .send({ error: 'invalid_request', details: parsed.error.flatten() }); + return reply.code(400).send({ error: 'invalid_request', details: parsed.error.flatten() }); } const existingApp = await prisma.ngoApplication.findFirst({ @@ -104,25 +197,28 @@ export async function ngoApplicationRoutes(app: FastifyInstance): Promise // status so the client doesn't have to fetch and count applications // itself. Must be registered before the /:id route so 'stats' isn't // swallowed as an id and rejected by the UUID param validation. - app.get( - '/ngo-applications/stats', - { preHandler: requireAdminSignature }, - async () => { - const grouped = await prisma.ngoApplication.groupBy({ - by: ['status'], - _count: { _all: true }, - }); + app.get('/ngo-applications/stats', { preHandler: requireAdminSignature }, async () => { + const grouped = await prisma.ngoApplication.groupBy({ + by: ['status'], + _count: { _all: true }, + }); - const counts = { PENDING: 0, APPROVED: 0, REJECTED: 0 } as Record; - for (const row of grouped) { - counts[row.status] = row._count._all; - } + const counts = { PENDING: 0, APPROVED: 0, REJECTED: 0 } as Record; + for (const row of grouped) { + counts[row.status] = row._count._all; + } - const total = counts.PENDING + counts.APPROVED + counts.REJECTED; + const total = counts.PENDING + counts.APPROVED + counts.REJECTED; - return { counts: { PENDING: counts.PENDING, APPROVED: counts.APPROVED, REJECTED: counts.REJECTED }, total }; - }, - ); + return { + counts: { + PENDING: counts.PENDING, + APPROVED: counts.APPROVED, + REJECTED: counts.REJECTED, + }, + total, + }; + }); app.get('/ngo-applications', { preHandler: requireAdminSignature }, async (request, reply) => { const parsed = listQuerySchema.safeParse(request.query); @@ -142,7 +238,12 @@ export async function ngoApplicationRoutes(app: FastifyInstance): Promise prisma.ngoApplication.count({ where }), ]); - return { applications, total, limit: parsed.data.limit, offset: parsed.data.offset }; + return { + applications, + total, + limit: parsed.data.limit, + offset: parsed.data.offset, + }; }); app.get( @@ -168,60 +269,12 @@ export async function ngoApplicationRoutes(app: FastifyInstance): Promise app.post( '/ngo-applications/:id/approve', { preHandler: requireAdminSignature }, - async (request, reply) => { - const parsedParams = idParamSchema.safeParse(request.params); - if (!parsedParams.success) { - return reply.code(400).send({ error: 'invalid_request' }); - } - const { id } = parsedParams.data; - - const parsed = reviewBodySchema.safeParse(request.body ?? {}); - if (!parsed.success) { - return reply.code(400).send({ error: 'invalid_request', details: parsed.error.flatten() }); - } - - const application = await prisma.ngoApplication.findUnique({ where: { id } }); - if (!application) { - return reply.code(404).send({ error: 'not_found' }); - } - if (application.status !== 'PENDING') { - return reply.code(409).send({ error: 'already_reviewed' }); - } - - return await prisma.ngoApplication.update({ - where: { id }, - data: { status: 'APPROVED', reviewNote: parsed.data.reviewNote }, - }); - }, + reviewHandler('APPROVED'), ); app.post( '/ngo-applications/:id/reject', { preHandler: requireAdminSignature }, - async (request, reply) => { - const parsedParams = idParamSchema.safeParse(request.params); - if (!parsedParams.success) { - return reply.code(400).send({ error: 'invalid_request' }); - } - const { id } = parsedParams.data; - - const parsed = reviewBodySchema.safeParse(request.body ?? {}); - if (!parsed.success) { - return reply.code(400).send({ error: 'invalid_request', details: parsed.error.flatten() }); - } - - const application = await prisma.ngoApplication.findUnique({ where: { id } }); - if (!application) { - return reply.code(404).send({ error: 'not_found' }); - } - if (application.status !== 'PENDING') { - return reply.code(409).send({ error: 'already_reviewed' }); - } - - return await prisma.ngoApplication.update({ - where: { id }, - data: { status: 'REJECTED', reviewNote: parsed.data.reviewNote }, - }); - }, + reviewHandler('REJECTED'), ); } diff --git a/test/routes/ngoApplications.test.ts b/test/routes/ngoApplications.test.ts index 6d3a2de..4954372 100644 --- a/test/routes/ngoApplications.test.ts +++ b/test/routes/ngoApplications.test.ts @@ -156,6 +156,7 @@ describe('GET /ngo-applications/status', () => { await prisma.ngoApplication.create({ data: validApplicationPayload({ ownerAddress, status: 'REJECTED' }), }); + const latest = await prisma.ngoApplication.create({ data: validApplicationPayload({ ownerAddress, status: 'APPROVED' }), }); @@ -170,7 +171,6 @@ describe('GET /ngo-applications/status', () => { expect(body.status).toBe('APPROVED'); expect(body.createdAt).toBe(latest.createdAt.toISOString()); expect(body.updatedAt).toBe(latest.updatedAt.toISOString()); - // No contact details or other application fields leak out. expect(Object.keys(body).sort()).toEqual(['createdAt', 'status', 'updatedAt']); await app.close(); @@ -207,7 +207,10 @@ describe('GET /ngo-applications/status', () => { it('rejects a missing address with 400', async () => { const app = buildServer(); - const response = await app.inject({ method: 'GET', url: '/ngo-applications/status' }); + const response = await app.inject({ + method: 'GET', + url: '/ngo-applications/status', + }); expect(response.statusCode).toBe(400); @@ -227,7 +230,11 @@ describe('admin NGO application review', () => { it('rejects an unsigned request with 401', async () => { const app = buildServer(); - const response = await app.inject({ method: 'GET', url: '/ngo-applications' }); + const response = await app.inject({ + method: 'GET', + url: '/ngo-applications', + }); + expect(response.statusCode).toBe(401); await app.close(); @@ -238,7 +245,12 @@ describe('admin NGO application review', () => { const headers = signAdminRequest(adminKeypair, 'GET', '/ngo-applications'); headers['x-admin-signature'] = Buffer.alloc(63).toString('base64'); - const response = await app.inject({ method: 'GET', url: '/ngo-applications', headers }); + const response = await app.inject({ + method: 'GET', + url: '/ngo-applications', + headers, + }); + expect(response.statusCode).toBe(401); expect(response.json().error).toBe('unauthorized'); @@ -249,7 +261,12 @@ describe('admin NGO application review', () => { const app = buildServer(); const headers = signAdminRequest(adminKeypair, 'GET', '/ngo-applications'); - const response = await app.inject({ method: 'GET', url: '/ngo-applications', headers }); + const response = await app.inject({ + method: 'GET', + url: '/ngo-applications', + headers, + }); + expect(response.statusCode).toBe(200); await app.close(); @@ -257,17 +274,26 @@ describe('admin NGO application review', () => { it('paginates applications while reporting the full total', async () => { const app = buildServer(); + await Promise.all( ['A', 'B', 'C'].map((suffix) => - prisma.ngoApplication.create({ data: validApplicationPayload({ ownerAddress: fakeAddress(suffix) }) }), + prisma.ngoApplication.create({ + data: validApplicationPayload({ ownerAddress: fakeAddress(suffix) }), + }), ), ); const url = '/ngo-applications?limit=2&offset=1'; const headers = signAdminRequest(adminKeypair, 'GET', url); - const response = await app.inject({ method: 'GET', url, headers }); + + const response = await app.inject({ + method: 'GET', + url, + headers, + }); expect(response.statusCode).toBe(200); + const body = response.json(); expect(body.total).toBe(3); expect(body.limit).toBe(2); @@ -280,15 +306,27 @@ describe('admin NGO application review', () => { it('approves a pending application', async () => { const app = buildServer(); - const application = await prisma.ngoApplication.create({ data: validApplicationPayload() }); + const application = await prisma.ngoApplication.create({ + data: validApplicationPayload(), + }); + const url = `/ngo-applications/${application.id}/approve`; const headers = signAdminRequest(adminKeypair, 'POST', url); - const response = await app.inject({ method: 'POST', url, headers, payload: {} }); + const response = await app.inject({ + method: 'POST', + url, + headers, + payload: {}, + }); + expect(response.statusCode).toBe(200); expect(response.json().status).toBe('APPROVED'); - const stored = await prisma.ngoApplication.findUnique({ where: { id: application.id } }); + const stored = await prisma.ngoApplication.findUnique({ + where: { id: application.id }, + }); + expect(stored?.status).toBe('APPROVED'); await app.close(); @@ -297,17 +335,27 @@ describe('admin NGO application review', () => { it('rejects a non-object (array) body on approve with 400 invalid_request', async () => { const app = buildServer(); - const application = await prisma.ngoApplication.create({ data: validApplicationPayload() }); + const application = await prisma.ngoApplication.create({ + data: validApplicationPayload(), + }); + const url = `/ngo-applications/${application.id}/approve`; const headers = signAdminRequest(adminKeypair, 'POST', url); - const response = await app.inject({ method: 'POST', url, headers, payload: [] as never }); + const response = await app.inject({ + method: 'POST', + url, + headers, + payload: [] as never, + }); expect(response.statusCode).toBe(400); expect(response.json().error).toBe('invalid_request'); - // The invalid request must not have touched the application. - const stored = await prisma.ngoApplication.findUnique({ where: { id: application.id } }); + const stored = await prisma.ngoApplication.findUnique({ + where: { id: application.id }, + }); + expect(stored?.status).toBe('PENDING'); await app.close(); @@ -316,7 +364,10 @@ describe('admin NGO application review', () => { it('rejects a non-object (array) body on reject with 400 invalid_request', async () => { const app = buildServer(); - const application = await prisma.ngoApplication.create({ data: validApplicationPayload() }); + const application = await prisma.ngoApplication.create({ + data: validApplicationPayload(), + }); + const url = `/ngo-applications/${application.id}/reject`; const headers = signAdminRequest(adminKeypair, 'POST', url); @@ -330,21 +381,63 @@ describe('admin NGO application review', () => { expect(response.statusCode).toBe(400); expect(response.json().error).toBe('invalid_request'); - const stored = await prisma.ngoApplication.findUnique({ where: { id: application.id } }); + const stored = await prisma.ngoApplication.findUnique({ + where: { id: application.id }, + }); + expect(stored?.status).toBe('PENDING'); await app.close(); }); + it('rejects a pending application', async () => { + const app = buildServer(); + + const application = await prisma.ngoApplication.create({ + data: validApplicationPayload(), + }); + + const url = `/ngo-applications/${application.id}/reject`; + const headers = signAdminRequest(adminKeypair, 'POST', url); + + const response = await app.inject({ + method: 'POST', + url, + headers, + payload: { reviewNote: 'No registration documents on file.' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().status).toBe('REJECTED'); + + const stored = await prisma.ngoApplication.findUnique({ + where: { id: application.id }, + }); + + expect(stored?.status).toBe('REJECTED'); + expect(stored?.reviewNote).toBe('No registration documents on file.'); + + await app.close(); + }); + it('rejects a signature for a different URL than the one requested', async () => { const app = buildServer(); - const application = await prisma.ngoApplication.create({ data: validApplicationPayload() }); + const application = await prisma.ngoApplication.create({ + data: validApplicationPayload(), + }); + const realUrl = `/ngo-applications/${application.id}/approve`; - // Signed for a *different* application's approve endpoint. + const headers = signAdminRequest(adminKeypair, 'POST', '/ngo-applications/other-id/approve'); - const response = await app.inject({ method: 'POST', url: realUrl, headers, payload: {} }); + const response = await app.inject({ + method: 'POST', + url: realUrl, + headers, + payload: {}, + }); + expect(response.statusCode).toBe(401); await app.close(); @@ -354,6 +447,7 @@ describe('admin NGO application review', () => { const app = buildServer(); const nonExistentId = '00000000-0000-0000-0000-000000000000'; + const approveUrl = `/ngo-applications/${nonExistentId}/approve`; const approveHeaders = signAdminRequest(adminKeypair, 'POST', approveUrl); @@ -363,6 +457,7 @@ describe('admin NGO application review', () => { headers: approveHeaders, payload: {}, }); + expect(approveResponse.statusCode).toBe(404); expect(approveResponse.json().error).toBe('not_found'); @@ -375,6 +470,7 @@ describe('admin NGO application review', () => { headers: rejectHeaders, payload: {}, }); + expect(rejectResponse.statusCode).toBe(404); expect(rejectResponse.json().error).toBe('not_found'); @@ -394,7 +490,11 @@ describe('GET /ngo-applications/stats', () => { it('rejects an unsigned request with 401', async () => { const app = buildServer(); - const response = await app.inject({ method: 'GET', url: '/ngo-applications/stats' }); + const response = await app.inject({ + method: 'GET', + url: '/ngo-applications/stats', + }); + expect(response.statusCode).toBe(401); await app.close(); @@ -405,14 +505,27 @@ describe('GET /ngo-applications/stats', () => { await prisma.ngoApplication.createMany({ data: [ - validApplicationPayload({ ownerAddress: fakeAddress('P'), status: 'PENDING' }), - validApplicationPayload({ ownerAddress: fakeAddress('P'), status: 'PENDING' }), - validApplicationPayload({ ownerAddress: fakeAddress('A'), status: 'APPROVED' }), - validApplicationPayload({ ownerAddress: fakeAddress('R'), status: 'REJECTED' }), + validApplicationPayload({ + ownerAddress: fakeAddress('P'), + status: 'PENDING', + }), + validApplicationPayload({ + ownerAddress: fakeAddress('P'), + status: 'PENDING', + }), + validApplicationPayload({ + ownerAddress: fakeAddress('A'), + status: 'APPROVED', + }), + validApplicationPayload({ + ownerAddress: fakeAddress('R'), + status: 'REJECTED', + }), ], }); const headers = signAdminRequest(adminKeypair, 'GET', '/ngo-applications/stats'); + const response = await app.inject({ method: 'GET', url: '/ngo-applications/stats', @@ -420,8 +533,15 @@ describe('GET /ngo-applications/stats', () => { }); expect(response.statusCode).toBe(200); + const body = response.json(); - expect(body.counts).toEqual({ PENDING: 2, APPROVED: 1, REJECTED: 1 }); + + expect(body.counts).toEqual({ + PENDING: 2, + APPROVED: 1, + REJECTED: 1, + }); + expect(body.total).toBe(4); await app.close(); @@ -431,6 +551,7 @@ describe('GET /ngo-applications/stats', () => { const app = buildServer(); const headers = signAdminRequest(adminKeypair, 'GET', '/ngo-applications/stats'); + const response = await app.inject({ method: 'GET', url: '/ngo-applications/stats', @@ -438,10 +559,256 @@ describe('GET /ngo-applications/stats', () => { }); expect(response.statusCode).toBe(200); + const body = response.json(); - expect(body.counts).toEqual({ PENDING: 0, APPROVED: 0, REJECTED: 0 }); + + expect(body.counts).toEqual({ + PENDING: 0, + APPROVED: 0, + REJECTED: 0, + }); + expect(body.total).toBe(0); await app.close(); }); }); + +/** + * `NgoApplication.status` is `PENDING -> APPROVED | REJECTED`, and both + * targets are terminal. These tests cover transitions that must be refused + * because the application has already been reviewed. + */ +describe('application review state machine', () => { + beforeAll(() => { + process.env.ADMIN_ADDRESS = adminKeypair.publicKey(); + }); + + afterEach(async () => { + await resetDb(); + }); + + async function review( + application: { id: string }, + decision: 'approve' | 'reject', + payload: Record = {}, + ) { + const app = buildServer(); + + const url = `/ngo-applications/${application.id}/${decision}`; + + const response = await app.inject({ + method: 'POST', + url, + headers: signAdminRequest(adminKeypair, 'POST', url), + payload, + }); + + await app.close(); + + return response; + } + + it('refuses to approve an application that is already approved', async () => { + const application = await prisma.ngoApplication.create({ + data: validApplicationPayload({ status: 'APPROVED' }), + }); + + const response = await review(application, 'approve'); + + expect(response.statusCode).toBe(409); + expect(response.json().error).toBe('already_reviewed'); + + const stored = await prisma.ngoApplication.findUnique({ + where: { id: application.id }, + }); + + expect(stored?.status).toBe('APPROVED'); + }); + + it('refuses to approve an application that was rejected', async () => { + const application = await prisma.ngoApplication.create({ + data: validApplicationPayload({ status: 'REJECTED' }), + }); + + const response = await review(application, 'approve'); + + expect(response.statusCode).toBe(409); + expect(response.json().error).toBe('already_reviewed'); + + const stored = await prisma.ngoApplication.findUnique({ + where: { id: application.id }, + }); + + expect(stored?.status).toBe('REJECTED'); + }); + + it('refuses to reject an application that was approved', async () => { + const application = await prisma.ngoApplication.create({ + data: validApplicationPayload({ status: 'APPROVED' }), + }); + + const response = await review(application, 'reject'); + + expect(response.statusCode).toBe(409); + expect(response.json().error).toBe('already_reviewed'); + + const stored = await prisma.ngoApplication.findUnique({ + where: { id: application.id }, + }); + + expect(stored?.status).toBe('APPROVED'); + }); + + it('refuses a re-review and leaves the recorded decision and note untouched', async () => { + const application = await prisma.ngoApplication.create({ + data: { + ...validApplicationPayload(), + status: 'REJECTED', + reviewNote: 'Original reviewer note.', + }, + }); + + const response = await review(application, 'approve', { + reviewNote: 'Overwriting note.', + }); + + expect(response.statusCode).toBe(409); + expect(response.json().error).toBe('already_reviewed'); + + const stored = await prisma.ngoApplication.findUnique({ + where: { id: application.id }, + }); + + expect(stored?.status).toBe('REJECTED'); + expect(stored?.reviewNote).toBe('Original reviewer note.'); + }); + + it('reports the current status on a refused review', async () => { + const application = await prisma.ngoApplication.create({ + data: validApplicationPayload({ status: 'APPROVED' }), + }); + + const response = await review(application, 'approve'); + + expect(response.statusCode).toBe(409); + + expect(response.json()).toEqual({ + error: 'already_reviewed', + status: 'APPROVED', + }); + }); + + it('lets only one of two concurrent reviews through', async () => { + const application = await prisma.ngoApplication.create({ + data: validApplicationPayload(), + }); + + const url = `/ngo-applications/${application.id}/approve`; + const app = buildServer(); + + const headers = signAdminRequest(adminKeypair, 'POST', url); + + const [first, second] = await Promise.all([ + app.inject({ + method: 'POST', + url, + headers, + payload: { reviewNote: 'First note.' }, + }), + app.inject({ + method: 'POST', + url, + headers, + payload: { reviewNote: 'Second note.' }, + }), + ]); + + expect([first.statusCode, second.statusCode].sort((a, b) => a - b)).toEqual([200, 409]); + + const winner = first.statusCode === 200 ? first : second; + const loser = first.statusCode === 200 ? second : first; + + expect(loser.json()).toEqual({ + error: 'already_reviewed', + status: 'APPROVED', + }); + + const stored = await prisma.ngoApplication.findUnique({ + where: { id: application.id }, + }); + + expect(stored?.status).toBe('APPROVED'); + expect(stored?.reviewNote).toBe(winner.json().reviewNote); + + await app.close(); + }); + + it('lets only one of two conflicting concurrent reviews through', async () => { + const application = await prisma.ngoApplication.create({ + data: validApplicationPayload(), + }); + + const approveUrl = `/ngo-applications/${application.id}/approve`; + const rejectUrl = `/ngo-applications/${application.id}/reject`; + + const app = buildServer(); + + const [approve, reject] = await Promise.all([ + app.inject({ + method: 'POST', + url: approveUrl, + headers: signAdminRequest(adminKeypair, 'POST', approveUrl), + payload: {}, + }), + app.inject({ + method: 'POST', + url: rejectUrl, + headers: signAdminRequest(adminKeypair, 'POST', rejectUrl), + payload: {}, + }), + ]); + + expect([approve.statusCode, reject.statusCode].sort((a, b) => a - b)).toEqual([200, 409]); + + const stored = await prisma.ngoApplication.findUnique({ + where: { id: application.id }, + }); + + expect(stored?.status).toBe(approve.statusCode === 200 ? 'APPROVED' : 'REJECTED'); + + await app.close(); + }); + + it('accepts a review note at the 2000 character limit', async () => { + const application = await prisma.ngoApplication.create({ + data: validApplicationPayload(), + }); + + const response = await review(application, 'approve', { + reviewNote: 'a'.repeat(2000), + }); + + expect(response.statusCode).toBe(200); + expect(response.json().status).toBe('APPROVED'); + }); + + it('rejects an over-long review note with 400 and leaves the application pending', async () => { + const application = await prisma.ngoApplication.create({ + data: validApplicationPayload(), + }); + + const response = await review(application, 'approve', { + reviewNote: 'a'.repeat(2001), + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe('invalid_request'); + + const stored = await prisma.ngoApplication.findUnique({ + where: { id: application.id }, + }); + + expect(stored?.status).toBe('PENDING'); + }); +});