diff --git a/apps/docs/docs/app/overview.mdx b/apps/docs/docs/app/overview.mdx index 028f84fd..a27b4c2d 100644 --- a/apps/docs/docs/app/overview.mdx +++ b/apps/docs/docs/app/overview.mdx @@ -18,6 +18,6 @@ The `accensa-app` repository houses the Merchant Back-Office and documentation f - **Reconciliation:** Provides real-time tracking and reconciliation of payment streams and settled vaults. - **Reference Implementation:** Serves as a perfect reference implementation for developers wanting to build complex UI components that interact natively with Stellar smart contracts. -For an overview of how `accensa-app` relates to the other repositories, see the [Architecture](/docs/architecture) page — including the distinction between receipt verification (this repo) and x402 payment verification (the facilitator). +For an overview of how `accensa-app` relates to the other repositories, see the [Architecture](../architecture) page — including the distinction between receipt verification (this repo) and x402 payment verification (the facilitator). Explore the sub-sections here for guides on navigating the dashboard and configuring your merchant environment. diff --git a/apps/docs/docs/facilitator/overview.mdx b/apps/docs/docs/facilitator/overview.mdx index d71635ea..5b4710a8 100644 --- a/apps/docs/docs/facilitator/overview.mdx +++ b/apps/docs/docs/facilitator/overview.mdx @@ -19,4 +19,4 @@ The `x402-facilitator-stellar` repository is the crucial off-chain middleware th Because it is designed modularly, any project building payments on Stellar can fork this repository to handle their own high-throughput indexing and Webhook dispatching. -For an overview of how the facilitator relates to the other repositories — including the distinction between x402 payment verification (this component) and receipt verification (`accensa-app`) — see the [Architecture](/docs/architecture) page. +For an overview of how the facilitator relates to the other repositories — including the distinction between x402 payment verification (this component) and receipt verification (`accensa-app`) — see the [Architecture](../architecture) page. diff --git a/apps/docs/docs/introduction.mdx b/apps/docs/docs/introduction.mdx index 6251d384..e12b65ad 100644 --- a/apps/docs/docs/introduction.mdx +++ b/apps/docs/docs/introduction.mdx @@ -18,7 +18,7 @@ The Accensa ecosystem is split across three primary repositories: 2. **`accensa-contracts`**: Soroban smart contracts (e.g., `ReceiptAnchor`, `RefundVault`) that provide the on-chain trust layer for verifiable receipts and trustless refunds. 3. **`x402-facilitator-stellar`**: The standard HTTP 402 facilitator. Sellers run this to automatically handle the x402 payment flow, index resource prices, and handle Stellar payments from AI agents. -See the [Architecture](/docs/architecture) page for a detailed breakdown of what each repository does and does not do, including the distinction between receipt verification and x402 payment verification. +See the [Architecture](./architecture) page for a detailed breakdown of what each repository does and does not do, including the distinction between receipt verification and x402 payment verification. ## The Problem diff --git a/apps/web/src/app/api/disputes/[id]/messages/route.test.ts b/apps/web/src/app/api/disputes/[id]/messages/route.test.ts index adfaec92..c63be6db 100644 --- a/apps/web/src/app/api/disputes/[id]/messages/route.test.ts +++ b/apps/web/src/app/api/disputes/[id]/messages/route.test.ts @@ -1,6 +1,14 @@ -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { GET, POST } from './route'; -import { __resetDisputeChatStore } from '@/lib/dispute-chat-store'; +import { __resetDisputeChatStore, listMessages } from '@/lib/dispute-chat-store'; + +const { mockBroadcastDisputeChatMessage } = vi.hoisted(() => ({ + mockBroadcastDisputeChatMessage: vi.fn(), +})); + +vi.mock('@/lib/dispute-chat-events', () => ({ + broadcastDisputeChatMessage: mockBroadcastDisputeChatMessage, +})); function params(id: string) { return { params: Promise.resolve({ id }) }; @@ -8,6 +16,7 @@ function params(id: string) { describe('disputes/[id]/messages route', () => { beforeEach(() => { + vi.clearAllMocks(); __resetDisputeChatStore(); }); @@ -75,4 +84,146 @@ describe('disputes/[id]/messages route', () => { const { messages } = await getResponse.json(); expect(messages).toEqual([]); }); + + it('returns 400 when the request body is not valid JSON and does not append or broadcast', async () => { + const request = new Request('http://test/api/disputes/d1/messages', { + method: 'POST', + body: 'invalid-json{', + headers: { 'Content-Type': 'application/json' }, + }); + const response = await POST(request, params('d1')); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe('Request body must be JSON'); + expect(mockBroadcastDisputeChatMessage).not.toHaveBeenCalled(); + expect(listMessages('d1')).toEqual([]); + }); + + it('rejects with 400 payloads missing ciphertext, missing iv, or having a numeric iv via the encrypted payload guard', async () => { + const invalidPayloads = [ + { ciphertext: 'test-cipher' }, // missing iv + { iv: 'test-iv' }, // missing ciphertext + { ciphertext: 'test-cipher', iv: 12345 }, // numeric iv + ]; + + for (const invalidPayload of invalidPayloads) { + const request = new Request('http://test/api/disputes/d1/messages', { + method: 'POST', + body: JSON.stringify({ role: 'buyer', payload: invalidPayload }), + }); + const response = await POST(request, params('d1')); + expect(response.status).toBe(400); + const data = await response.json(); + expect(data.error).toBe( + 'payload must be an encrypted { ciphertext, iv } produced client-side', + ); + } + expect(mockBroadcastDisputeChatMessage).not.toHaveBeenCalled(); + expect(listMessages('d1')).toEqual([]); + }); + + it('broadcasts exactly once with the dispute id from the path and the 201 response message', async () => { + const request = new Request('http://test/api/disputes/d-path-id/messages', { + method: 'POST', + body: JSON.stringify({ + role: 'buyer', + payload: { ciphertext: 'cipher123', iv: 'iv123' }, + }), + }); + const response = await POST(request, params('d-path-id')); + expect(response.status).toBe(201); + const { message } = await response.json(); + + expect(mockBroadcastDisputeChatMessage).toHaveBeenCalledTimes(1); + expect(mockBroadcastDisputeChatMessage).toHaveBeenCalledWith('d-path-id', message); + }); + + it('ignores client-supplied id and createdAt and assigns server-generated UUID and ISO timestamp', async () => { + const clientSuppliedId = 'client-id-override'; + const clientSuppliedCreatedAt = '2020-01-01T00:00:00.000Z'; + + const request = new Request('http://test/api/disputes/d1/messages', { + method: 'POST', + body: JSON.stringify({ + id: clientSuppliedId, + createdAt: clientSuppliedCreatedAt, + role: 'buyer', + payload: { ciphertext: 'cipher-secure', iv: 'iv-secure' }, + }), + }); + const response = await POST(request, params('d1')); + expect(response.status).toBe(201); + const { message } = await response.json(); + + expect(message.id).not.toBe(clientSuppliedId); + expect(message.createdAt).not.toBe(clientSuppliedCreatedAt); + // Standard RFC 4122 UUID v4 regex + expect(message.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + expect(new Date(message.createdAt).toISOString()).toBe(message.createdAt); + + const stored = listMessages('d1')[0]; + expect(stored.id).toBe(message.id); + expect(stored.createdAt).toBe(message.createdAt); + }); + + it('stores omitted attachment as null and preserves a supplied attachment unchanged', async () => { + // 1. Omitted attachment + const omittedReq = new Request('http://test/api/disputes/d1/messages', { + method: 'POST', + body: JSON.stringify({ + role: 'buyer', + payload: { ciphertext: 'c1', iv: 'i1' }, + }), + }); + const omittedRes = await POST(omittedReq, params('d1')); + expect(omittedRes.status).toBe(201); + const { message: omittedMsg } = await omittedRes.json(); + expect(omittedMsg.attachment).toBeNull(); + expect(listMessages('d1')[0].attachment).toBeNull(); + + // 2. Supplied attachment + const attachmentData = { + name: 'invoice.pdf', + contentType: 'application/pdf', + dataUrl: 'data:application/pdf;base64,aGVsbG8=', + }; + const suppliedReq = new Request('http://test/api/disputes/d1/messages', { + method: 'POST', + body: JSON.stringify({ + role: 'merchant', + payload: { ciphertext: 'c2', iv: 'i2' }, + attachment: attachmentData, + }), + }); + const suppliedRes = await POST(suppliedReq, params('d1')); + expect(suppliedRes.status).toBe(201); + const { message: suppliedMsg } = await suppliedRes.json(); + expect(suppliedMsg.attachment).toEqual(attachmentData); + expect(listMessages('d1')[1].attachment).toEqual(attachmentData); + }); + + it('reads salt and messages for the path dispute id on GET', async () => { + const postRes = await POST( + new Request('http://test/api/disputes/d-get-test/messages', { + method: 'POST', + body: JSON.stringify({ + role: 'buyer', + payload: { ciphertext: 'c-get', iv: 'i-get' }, + }), + }), + params('d-get-test'), + ); + const { message: postedMessage } = await postRes.json(); + + const getRes = await GET( + new Request('http://test/api/disputes/d-get-test/messages'), + params('d-get-test'), + ); + const data = await getRes.json(); + expect(data.messages).toEqual([postedMessage]); + expect(typeof data.salt).toBe('string'); + expect(data.salt.length).toBeGreaterThan(0); + }); }); diff --git a/apps/web/src/app/api/merchant/profile/route.test.ts b/apps/web/src/app/api/merchant/profile/route.test.ts index dad63516..0e335c7e 100644 --- a/apps/web/src/app/api/merchant/profile/route.test.ts +++ b/apps/web/src/app/api/merchant/profile/route.test.ts @@ -10,6 +10,7 @@ const { mockUpdateMerchantProfile, mockGetCachedMerchantFromRequest, mockRevalidateTag, + mockRecordMerchantConfigChange, } = vi.hoisted(() => { const merchant = { id: 1, address: 'GABC' }; // A minimal postgres client: PATCH also records the config change, whose @@ -26,6 +27,7 @@ const { mockUpdateMerchantProfile: vi.fn(), mockGetCachedMerchantFromRequest: vi.fn(), mockRevalidateTag: vi.fn(), + mockRecordMerchantConfigChange: vi.fn().mockResolvedValue(undefined), }; }); @@ -53,6 +55,10 @@ vi.mock('next/cache', () => ({ revalidateTag: mockRevalidateTag, })); +vi.mock('@/lib/merchant-config', () => ({ + recordMerchantConfigChange: mockRecordMerchantConfigChange, +})); + describe('/api/merchant/profile GET', () => { beforeEach(() => { vi.clearAllMocks(); @@ -77,9 +83,13 @@ describe('/api/merchant/profile GET', () => { }); describe('/api/merchant/profile PATCH', () => { - const patchRequest = (body: unknown) => + const patchRequest = (body: unknown, role?: 'admin' | 'viewer') => new Request('http://localhost/api/merchant/profile', { method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + ...(role ? { 'x-accensa-role': role } : {}), + }, body: JSON.stringify(body), }); @@ -139,4 +149,67 @@ describe('/api/merchant/profile PATCH', () => { expect(tag).toBe(`merchant-profile-${MERCHANT.address}`); expect(tag).not.toBe('merchant-profile-someone-else'); }); + + test('rejects a viewer-role PATCH with 403 without modifying the profile or recording history', async () => { + const res = await PATCH( + patchRequest({ webhookUrl: 'https://merchant.example/hook' }, 'viewer'), + ); + + expect(res.status).toBe(403); + const data = await res.json(); + expect(data.error).toBe( + 'Forbidden: viewer sessions cannot modify merchant settings', + ); + expect(mockUpdateMerchantProfile).not.toHaveBeenCalled(); + expect(mockRecordMerchantConfigChange).not.toHaveBeenCalled(); + }); + + test('validates the request body before evaluating the role guard', async () => { + const res = await PATCH(patchRequest({ webhookUrl: 'not a url' }, 'viewer')); + + expect(res.status).toBe(400); + expect(mockUpdateMerchantProfile).not.toHaveBeenCalled(); + expect(mockRecordMerchantConfigChange).not.toHaveBeenCalled(); + }); + + test('records an audit history event for each changed profile field when patched by admin', async () => { + const contractA = `C${'A'.repeat(55)}`; + const contractB = `C${'B'.repeat(55)}`; + const updateBody = { + webhookUrl: 'https://merchant.example/hook', + refundVaultId: contractA, + assetContractIds: [contractA, contractB], + }; + + mockUpdateMerchantProfile.mockResolvedValue({ + ...MERCHANT, + ...updateBody, + }); + + const res = await PATCH(patchRequest(updateBody, 'admin')); + + expect(res.status).toBe(200); + expect(mockRecordMerchantConfigChange).toHaveBeenCalledTimes(3); + expect(mockRecordMerchantConfigChange).toHaveBeenCalledWith(clientStub, { + merchantId: MERCHANT.id, + field: 'webhookUrl', + before: null, + after: 'https://merchant.example/hook', + source: 'profile_patch', + }); + expect(mockRecordMerchantConfigChange).toHaveBeenCalledWith(clientStub, { + merchantId: MERCHANT.id, + field: 'refundVaultId', + before: null, + after: contractA, + source: 'profile_patch', + }); + expect(mockRecordMerchantConfigChange).toHaveBeenCalledWith(clientStub, { + merchantId: MERCHANT.id, + field: 'assetContractIds', + before: null, + after: `${contractA},${contractB}`, + source: 'profile_patch', + }); + }); }); diff --git a/apps/web/src/components/settings/TotpSetupModal.test.tsx b/apps/web/src/components/settings/TotpSetupModal.test.tsx new file mode 100644 index 00000000..d16bc8cc --- /dev/null +++ b/apps/web/src/components/settings/TotpSetupModal.test.tsx @@ -0,0 +1,217 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TotpSetupModal } from './TotpSetupModal'; + +const generateData = { + secret: 'JBSWY3DPEHPK3PXP', + qrCodeUrl: 'data:image/png;base64,mockQrCodeData', + backupCodes: ['1111-2222', '3333-4444', '5555-6666', '7777-8888'], +}; + +describe('TotpSetupModal', () => { + const fetchMock = vi.fn(); + const defaultEmail = 'merchant@example.com'; + + beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockImplementation(async (input) => { + const url = typeof input === 'string' ? input : input instanceof Request ? input.url : ''; + if (url.includes('/api/auth/totp/generate')) { + return new Response(JSON.stringify(generateData), { status: 200 }); + } + return new Response(JSON.stringify({ success: true }), { status: 200 }); + }); + }); + + it('renders nothing when closed', () => { + const onClose = vi.fn(); + render(); + + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + expect( + screen.queryByText('Set up Two-Factor Authentication'), + ).not.toBeInTheDocument(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('starts at step 1 on open and requests the secret and QR code to populate the view', async () => { + const onClose = vi.fn(); + render(); + + expect( + screen.getByRole('heading', { name: 'Set up Two-Factor Authentication' }), + ).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith('/api/auth/totp/generate', { method: 'POST' }); + + expect(await screen.findByText(generateData.secret)).toBeInTheDocument(); + const qrImage = screen.getByAltText(`TOTP QR Code for ${defaultEmail}`); + expect(qrImage).toBeInTheDocument(); + expect(qrImage).toHaveAttribute('src', generateData.qrCodeUrl); + + const input = screen.getByPlaceholderText('000000'); + expect(input).toBeInTheDocument(); + expect(input).toHaveValue(''); + }); + + it('surfaces an error and stays on step 1 when verification fails', async () => { + fetchMock.mockImplementation(async (input) => { + const url = typeof input === 'string' ? input : input instanceof Request ? input.url : ''; + if (url.includes('/api/auth/totp/generate')) { + return new Response(JSON.stringify(generateData), { status: 200 }); + } + return new Response(JSON.stringify({ success: false }), { status: 200 }); + }); + + const onClose = vi.fn(); + render(); + + await screen.findByText(generateData.secret); + + const input = screen.getByPlaceholderText('000000'); + fireEvent.change(input, { target: { value: '000111' } }); + + const verifyButton = screen.getByRole('button', { name: 'Verify' }); + fireEvent.click(verifyButton); + + expect(await screen.findByText('Invalid token. Please try again.')).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: 'Set up Two-Factor Authentication' }), + ).toBeInTheDocument(); + expect(screen.queryByText('Save Backup Codes')).not.toBeInTheDocument(); + }); + + it('advances to step 2 and displays backup codes on successful verification', async () => { + const onClose = vi.fn(); + render(); + + await screen.findByText(generateData.secret); + + const input = screen.getByPlaceholderText('000000'); + fireEvent.change(input, { target: { value: '123456' } }); + + const verifyButton = screen.getByRole('button', { name: 'Verify' }); + fireEvent.click(verifyButton); + + expect( + await screen.findByRole('heading', { name: 'Save Backup Codes' }), + ).toBeInTheDocument(); + for (const code of generateData.backupCodes) { + expect(screen.getByText(code)).toBeInTheDocument(); + } + expect( + screen.queryByText('Set up Two-Factor Authentication'), + ).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'I have saved these codes' }), + ).toBeInTheDocument(); + }); + + it('disables the verify button while verification is in flight (isVerifying)', async () => { + let resolveVerify!: (res: Response) => void; + fetchMock.mockImplementation(async (input) => { + const url = typeof input === 'string' ? input : input instanceof Request ? input.url : ''; + if (url.includes('/api/auth/totp/generate')) { + return new Response(JSON.stringify(generateData), { status: 200 }); + } + return new Promise((resolve) => { + resolveVerify = resolve; + }); + }); + + const onClose = vi.fn(); + render(); + + await screen.findByText(generateData.secret); + + const input = screen.getByPlaceholderText('000000'); + const verifyButton = screen.getByRole('button', { name: 'Verify' }); + + // Initially disabled when input length < 6 + expect(verifyButton).toBeDisabled(); + + fireEvent.change(input, { target: { value: '123456' } }); + expect(verifyButton).toBeEnabled(); + + // Click verify: request is in flight + fireEvent.click(verifyButton); + expect(verifyButton).toBeDisabled(); + + // Resolve the in-flight request + resolveVerify(new Response(JSON.stringify({ success: true }), { status: 200 })); + expect( + await screen.findByRole('heading', { name: 'Save Backup Codes' }), + ).toBeInTheDocument(); + }); + + it('dismisses the modal via Cancel button on step 1', async () => { + const onClose = vi.fn(); + render(); + + await screen.findByText(generateData.secret); + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + fireEvent.click(cancelButton); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('dismisses the modal via finish button on step 2', async () => { + const onClose = vi.fn(); + render(); + + await screen.findByText(generateData.secret); + fireEvent.change(screen.getByPlaceholderText('000000'), { target: { value: '123456' } }); + fireEvent.click(screen.getByRole('button', { name: 'Verify' })); + + const finishButton = await screen.findByRole('button', { + name: 'I have saved these codes', + }); + fireEvent.click(finishButton); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('resets state when closed so reopening starts fresh at step 1 without stale token or error', async () => { + fetchMock.mockImplementation(async (input) => { + const url = typeof input === 'string' ? input : input instanceof Request ? input.url : ''; + if (url.includes('/api/auth/totp/generate')) { + return new Response(JSON.stringify(generateData), { status: 200 }); + } + return new Response(JSON.stringify({ success: false }), { status: 200 }); + }); + + const onClose = vi.fn(); + const { rerender } = render( + , + ); + + await screen.findByText(generateData.secret); + + // Enter wrong token and verify to trigger error + const input = screen.getByPlaceholderText('000000'); + fireEvent.change(input, { target: { value: '999999' } }); + fireEvent.click(screen.getByRole('button', { name: 'Verify' })); + + expect(await screen.findByText('Invalid token. Please try again.')).toBeInTheDocument(); + + // Close the modal + rerender(); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + + // Allow the close-reset timeout (0ms) to run + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Reopen the modal + rerender(); + + // Must be on step 1 with fresh state + expect( + await screen.findByRole('heading', { name: 'Set up Two-Factor Authentication' }), + ).toBeInTheDocument(); + expect(screen.queryByText('Invalid token. Please try again.')).not.toBeInTheDocument(); + const freshInput = screen.getByPlaceholderText('000000'); + expect(freshInput).toHaveValue(''); + }); +});