diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/client/App.test.tsx | 122 | ||||
| -rw-r--r-- | tests/client/utils.test.ts | 26 | ||||
| -rw-r--r-- | tests/server/main.test.ts | 192 |
3 files changed, 340 insertions, 0 deletions
diff --git a/tests/client/App.test.tsx b/tests/client/App.test.tsx new file mode 100644 index 0000000..960f383 --- /dev/null +++ b/tests/client/App.test.tsx @@ -0,0 +1,122 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import App from '../../src/client/App'; + +describe('App', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('shows qr code after successful start call', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + sessionId: 'session-1', + qrCodeDataUrl: 'data:image/png;base64,qr', + }), + }); + + render(<App />); + fireEvent.click( + screen.getByRole('button', { name: /sign in with digi-id/i }) + ); + + await waitFor(() => { + expect( + screen.getByRole('heading', { name: /scan the qr code/i }) + ).toBeInTheDocument(); + }); + expect(screen.getByAltText('Digi-ID QR Code')).toBeInTheDocument(); + }); + + it('transitions to success after polling confirms authentication', async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + sessionId: 'session-2', + qrCodeDataUrl: 'data:image/png;base64,qr', + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + status: 'success', + address: 'D123456789', + }), + }); + + render(<App />); + fireEvent.click( + screen.getByRole('button', { name: /sign in with digi-id/i }) + ); + + await waitFor(() => { + expect( + screen.getByText(/waiting for authentication/i) + ).toBeInTheDocument(); + }); + + await waitFor( + () => { + expect( + screen.getByText(/authentication successful/i) + ).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + expect(screen.getByText('D123456789')).toBeInTheDocument(); + }); + + it('shows start errors in the initial state', async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({ message: 'Backend unavailable' }), + }); + + render(<App />); + fireEvent.click( + screen.getByRole('button', { name: /sign in with digi-id/i }) + ); + + await waitFor(() => { + expect( + screen.getByText(/failed to initiate digi-id: backend unavailable/i) + ).toBeInTheDocument(); + }); + }); + + it('can cancel waiting flow and return to initial view', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + sessionId: 'session-3', + qrCodeDataUrl: 'data:image/png;base64,qr', + }), + }); + + render(<App />); + fireEvent.click( + screen.getByRole('button', { name: /sign in with digi-id/i }) + ); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: /cancel/i }) + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect( + screen.getByRole('button', { name: /sign in with digi-id/i }) + ).toBeInTheDocument(); + }); +}); diff --git a/tests/client/utils.test.ts b/tests/client/utils.test.ts new file mode 100644 index 0000000..946ae53 --- /dev/null +++ b/tests/client/utils.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { getDigiByteAddressType } from '../../src/client/utils'; + +describe('getDigiByteAddressType', () => { + it('returns unknown for empty values', () => { + expect(getDigiByteAddressType(undefined)).toBe('Unknown'); + expect(getDigiByteAddressType(null)).toBe('Unknown'); + expect(getDigiByteAddressType('')).toBe('Unknown'); + }); + + it('detects segwit addresses', () => { + expect(getDigiByteAddressType('dgb1xyz')).toBe('SegWit (Bech32)'); + }); + + it('detects script addresses', () => { + expect(getDigiByteAddressType('Sxyz')).toBe('Script (P2SH)'); + }); + + it('detects legacy addresses', () => { + expect(getDigiByteAddressType('Dxyz')).toBe('Legacy (P2PKH)'); + }); + + it('returns unknown for unsupported prefixes', () => { + expect(getDigiByteAddressType('abc123')).toBe('Unknown'); + }); +}); diff --git a/tests/server/main.test.ts b/tests/server/main.test.ts new file mode 100644 index 0000000..0a5aee3 --- /dev/null +++ b/tests/server/main.test.ts @@ -0,0 +1,192 @@ +// @vitest-environment node + +import inject from 'light-my-request'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createApp } from '../../src/server/main'; +import qrcode from 'qrcode'; +import { generateDigiIDUri, verifyDigiIDCallback } from 'digiid-ts'; + +vi.mock('qrcode', () => ({ + default: { + toDataURL: vi.fn(async () => 'data:image/png;base64,qr'), + }, +})); + +vi.mock('digiid-ts', () => ({ + generateDigiIDUri: vi.fn( + ({ nonce }: { nonce: string }) => `digiid://auth?x=${nonce}` + ), + verifyDigiIDCallback: vi.fn(async () => undefined), +})); + +const mockedToDataUrl = vi.mocked(qrcode.toDataURL); +const mockedGenerateDigiIDUri = vi.mocked(generateDigiIDUri); +const mockedVerifyDigiIDCallback = vi.mocked(verifyDigiIDCallback); + +describe('server api', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedToDataUrl.mockResolvedValue('data:image/png;base64,qr'); + mockedGenerateDigiIDUri.mockImplementation( + ({ nonce }: { nonce: string }) => `digiid://auth?x=${nonce}` + ); + mockedVerifyDigiIDCallback.mockResolvedValue(undefined); + }); + + it('creates session and returns qr code', async () => { + const app = createApp({ + publicUrl: 'http://localhost:3001', + nodeEnv: 'test', + }); + + const response = await inject(app, { + method: 'GET', + url: '/api/digiid/start', + }); + const body = response.json(); + + expect(response.statusCode).toBe(200); + expect(body.sessionId).toMatch(/^[a-f0-9]{32}$/); + expect(body.qrCodeDataUrl).toBe('data:image/png;base64,qr'); + expect(mockedGenerateDigiIDUri).toHaveBeenCalledTimes(1); + expect(mockedToDataUrl).toHaveBeenCalledTimes(1); + }); + + it('returns 500 when public url is missing', async () => { + const app = createApp({ publicUrl: undefined, nodeEnv: 'test' }); + + const response = await inject(app, { + method: 'GET', + url: '/api/digiid/start', + }); + const body = response.json(); + + expect(response.statusCode).toBe(500); + expect(body.error).toContain('PUBLIC_URL'); + }); + + it('marks session as success when callback verifies', async () => { + const app = createApp({ + publicUrl: 'http://localhost:3001', + nodeEnv: 'test', + }); + + const start = await inject(app, { + method: 'GET', + url: '/api/digiid/start', + }); + const sessionId = start.json().sessionId as string; + const nonce = mockedGenerateDigiIDUri.mock.calls[0][0].nonce; + const callbackUri = `digiid://auth?x=${nonce}`; + + const callback = await inject(app, { + method: 'POST', + url: '/api/digiid/callback', + headers: { 'content-type': 'application/json' }, + payload: { + address: 'Dabc123456789', + uri: callbackUri, + signature: 'signature', + }, + }); + const status = await inject(app, { + method: 'GET', + url: `/api/digiid/status/${sessionId}`, + }); + const statusBody = status.json(); + + expect(callback.statusCode).toBe(200); + expect(mockedVerifyDigiIDCallback).toHaveBeenCalledWith( + { address: 'Dabc123456789', uri: callbackUri, signature: 'signature' }, + { + expectedCallbackUrl: 'http://localhost:3001/api/digiid/callback', + expectedNonce: nonce, + } + ); + expect(status.statusCode).toBe(200); + expect(statusBody.status).toBe('success'); + expect(statusBody.address).toBe('Dabc123456789'); + }); + + it('marks session as failed when callback verification fails', async () => { + mockedVerifyDigiIDCallback.mockRejectedValueOnce( + new Error('Invalid signature') + ); + const app = createApp({ + publicUrl: 'http://localhost:3001', + nodeEnv: 'test', + }); + + const start = await inject(app, { + method: 'GET', + url: '/api/digiid/start', + }); + const sessionId = start.json().sessionId as string; + const nonce = mockedGenerateDigiIDUri.mock.calls[0][0].nonce; + const callbackUri = `digiid://auth?x=${nonce}`; + + await inject(app, { + method: 'POST', + url: '/api/digiid/callback', + headers: { 'content-type': 'application/json' }, + payload: { + address: 'Dabc123456789', + uri: callbackUri, + signature: 'signature', + }, + }); + const status = await inject(app, { + method: 'GET', + url: `/api/digiid/status/${sessionId}`, + }); + const statusBody = status.json(); + + expect(status.statusCode).toBe(200); + expect(statusBody.status).toBe('failed'); + expect(statusBody.error).toContain('Invalid signature'); + }); + + it('enforces max in-memory sessions', async () => { + const app = createApp({ + publicUrl: 'http://localhost:3001', + maxSessions: 1, + nodeEnv: 'test', + }); + + const first = await inject(app, { + method: 'GET', + url: '/api/digiid/start', + }); + const second = await inject(app, { + method: 'GET', + url: '/api/digiid/start', + }); + + expect(first.statusCode).toBe(200); + expect(second.statusCode).toBe(503); + }); + + it('expires sessions after ttl', async () => { + const app = createApp({ + publicUrl: 'http://localhost:3001', + sessionTtlMs: 1, + nodeEnv: 'test', + }); + + const start = await inject(app, { + method: 'GET', + url: '/api/digiid/start', + }); + const sessionId = start.json().sessionId as string; + + await new Promise((resolve) => setTimeout(resolve, 5)); + const status = await inject(app, { + method: 'GET', + url: `/api/digiid/status/${sessionId}`, + }); + const statusBody = status.json(); + + expect(status.statusCode).toBe(404); + expect(statusBody.status).toBe('not_found'); + }); +}); |
