From cdb1a57614068fcfefa145bc6df45c9def7ccc6a Mon Sep 17 00:00:00 2001 From: Elizabeth Hunt Date: Sun, 14 Dec 2025 22:43:24 -0800 Subject: Updates --- test/integrations.test.ts | 128 ++++++++++++++++++++++++++++++++++++++++++++++ test/storage.test.ts | 106 ++++++++++++++++++++++++++++++++++++++ test/token.test.ts | 72 ++++++++++++++++++++++++++ test/types.test.ts | 73 ++++++++++++++++++++++++++ 4 files changed, 379 insertions(+) create mode 100644 test/integrations.test.ts create mode 100644 test/storage.test.ts create mode 100644 test/token.test.ts create mode 100644 test/types.test.ts (limited to 'test') diff --git a/test/integrations.test.ts b/test/integrations.test.ts new file mode 100644 index 0000000..310945a --- /dev/null +++ b/test/integrations.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { verifyHCaptcha } from '../src/integrations/hcaptcha.js'; +import { sendNtfyNotification } from '../src/integrations/ntfy.js'; +import type { NtfyConfig, StoredRequest } from '../src/types/index.js'; + +describe('verifyHCaptcha', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('returns success boolean when response is ok', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValue({ + ok: true, + statusText: 'OK', + json: async () => ({ success: true }), + } as Response); + + const result = await verifyHCaptcha('token', 'secret'); + expect(result.left().present()).toBe(false); + expect(result.right().get()).toBe(true); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe('https://hcaptcha.com/siteverify'); + expect(init?.method).toBe('POST'); + expect(init?.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded' }); + expect(init?.body).toBeInstanceOf(URLSearchParams); + expect((init?.body as URLSearchParams).get('secret')).toBe('secret'); + expect((init?.body as URLSearchParams).get('response')).toBe('token'); + }); + + it('returns error when response is not ok', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValue({ + ok: false, + statusText: 'Bad Request', + } as Response); + + const result = await verifyHCaptcha('token', 'secret'); + expect(result.left().present()).toBe(true); + expect(result.left().get().message).toBe('hCaptcha verification failed: Bad Request'); + }); +}); + +describe('sendNtfyNotification', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('is a no-op when not enabled or misconfigured', async () => { + const fetchMock = vi.mocked(fetch); + + const config: NtfyConfig = { enabled: false }; + const request: StoredRequest = { + timestamp: 1, + uuid: 'uuid', + routeName: 'route1', + method: 'POST', + headers: {}, + body: {}, + }; + + const result = await sendNtfyNotification(config, request); + expect(result.left().present()).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('posts a notification to the configured server/topic', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValue({ ok: true, statusText: 'OK' } as Response); + + const config: NtfyConfig = { enabled: true, server: 'https://ntfy.example.com', topic: 'topic1' }; + const request: StoredRequest = { + timestamp: Date.parse('2020-01-01T00:00:00.000Z'), + uuid: 'uuid', + routeName: 'route1', + method: 'POST', + headers: {}, + body: {}, + }; + + const result = await sendNtfyNotification(config, request); + expect(result.left().present()).toBe(false); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe('https://ntfy.example.com/topic1'); + expect(init?.method).toBe('POST'); + expect(init?.headers).toEqual({ + Title: 'Webhook received: route1', + Tags: 'webhook,posthook', + Priority: '3', + }); + + expect(init?.body).toContain('Method: POST'); + expect(init?.body).toContain('Timestamp: 2020-01-01T00:00:00.000Z'); + expect(init?.body).toContain('UUID: uuid'); + }); + + it('returns an error when ntfy responds with non-2xx', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValue({ ok: false, statusText: 'Unauthorized' } as Response); + + const config: NtfyConfig = { enabled: true, server: 'https://ntfy.example.com', topic: 'topic1' }; + const request: StoredRequest = { + timestamp: 1, + uuid: 'uuid', + routeName: 'route1', + method: 'POST', + headers: {}, + body: {}, + }; + + const result = await sendNtfyNotification(config, request); + expect(result.left().present()).toBe(true); + expect(result.left().get().message).toBe('ntfy notification failed: Unauthorized'); + }); +}); diff --git a/test/storage.test.ts b/test/storage.test.ts new file mode 100644 index 0000000..7b64aa1 --- /dev/null +++ b/test/storage.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { mkdtemp, readFile, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { Storage } from '../src/storage/index.js'; +import { ContentType, type RouteConfig } from '../src/types/index.js'; + +describe('Storage', () => { + let dataDir: string; + + beforeEach(async () => { + dataDir = await mkdtemp(join(tmpdir(), 'posthook-test-')); + }); + + afterEach(async () => { + await rm(dataDir, { recursive: true, force: true }); + }); + + it('persists routes to routes.json and loads them on init', async () => { + const route: RouteConfig = { + name: 'route1', + contentType: ContentType.JSON, + hcaptchaProtected: false, + }; + + const storage1 = new Storage(dataDir); + expect((await storage1.init()).left().present()).toBe(false); + expect((await storage1.registerRoute(route)).left().present()).toBe(false); + + const storage2 = new Storage(dataDir); + expect((await storage2.init()).left().present()).toBe(false); + expect(storage2.getRoute('route1')).toEqual(route); + expect(storage2.listRoutes()).toEqual([route]); + }); + + it('rejects unsafe route names', async () => { + const storage = new Storage(dataDir); + expect((await storage.init()).left().present()).toBe(false); + + const result = await storage.registerRoute({ + name: '../bad', + contentType: ContentType.JSON, + hcaptchaProtected: false, + }); + + expect(result.left().present()).toBe(true); + expect(result.left().get().message).toBe('Invalid route name'); + }); + + it('stores a request and sanitizes uploaded filenames', async () => { + const route: RouteConfig = { + name: 'route1', + contentType: ContentType.JSON, + hcaptchaProtected: false, + }; + + const storage = new Storage(dataDir); + expect((await storage.init()).left().present()).toBe(false); + expect((await storage.registerRoute(route)).left().present()).toBe(false); + + const upload = { + fieldName: 'file', + filename: '../evil.txt', + contentType: 'text/plain', + size: 3, + data: new Uint8Array([1, 2, 3]), + }; + + const storeResult = await storage.storeRequest( + 'route1', + 'POST', + { 'content-type': 'application/json' }, + { hello: 'world' }, + [upload], + ); + + expect(storeResult.left().present()).toBe(false); + + const stored = storeResult.right().get(); + expect(stored.routeName).toBe('route1'); + expect(stored.files?.length).toBe(1); + + const storedFile = stored.files![0]; + expect(storedFile.filename).toMatch(/^0_/); + expect(storedFile.filename).not.toContain('..'); + expect(storedFile.filename).not.toContain('/'); + expect(storedFile.path).toBe(`files/${storedFile.filename}`); + + const requestDir = join(dataDir, 'route1', `${stored.timestamp}_${stored.uuid}`); + + const requestJson = JSON.parse(await readFile(join(requestDir, 'request.json'), 'utf-8')) as { + routeName: string; + files?: Array<{ filename: string }>; + }; + expect(requestJson.routeName).toBe('route1'); + expect(requestJson.files?.[0].filename).toBe(storedFile.filename); + + const bodyJson = JSON.parse(await readFile(join(requestDir, 'body.json'), 'utf-8')); + expect(bodyJson).toEqual({ hello: 'world' }); + + const savedBytes = await readFile(join(requestDir, storedFile.path)); + expect(savedBytes.length).toBe(3); + }); +}); diff --git a/test/token.test.ts b/test/token.test.ts new file mode 100644 index 0000000..060b591 --- /dev/null +++ b/test/token.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { TokenSigner } from '../src/token/index.js'; + +describe('TokenSigner', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2020-01-01T00:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('generates and validates a token for the expected route', () => { + const signer = new TokenSigner('test-secret', 30); + + const now = Date.now(); + const token = signer.generate('route1'); + + const result = signer.validate(token, 'route1'); + expect(result.left().present()).toBe(false); + expect(result.right().get()).toEqual({ routeName: 'route1', timestamp: now }); + }); + + it('rejects token when route does not match', () => { + const signer = new TokenSigner('test-secret', 30); + const token = signer.generate('route1'); + + const result = signer.validate(token, 'route2'); + expect(result.left().present()).toBe(true); + expect(result.left().get().message).toBe('Token route mismatch'); + }); + + it('rejects expired tokens', () => { + const signer = new TokenSigner('test-secret', 1); + const token = signer.generate('route1'); + + vi.advanceTimersByTime(2000); + + const result = signer.validate(token, 'route1'); + expect(result.left().present()).toBe(true); + expect(result.left().get().message).toBe('Token expired'); + }); + + it('rejects tokens with a bad signature', () => { + const signer = new TokenSigner('test-secret', 30); + const token = signer.generate('route1'); + + const decoded = Buffer.from(token, 'base64url').toString('utf-8'); + const dotIndex = decoded.lastIndexOf('.'); + const payload = decoded.substring(0, dotIndex); + const signature = decoded.substring(dotIndex + 1); + + const parsed = JSON.parse(payload) as { routeName: string; timestamp: number }; + const tamperedPayload = JSON.stringify({ ...parsed, routeName: 'route2' }); + const tamperedToken = Buffer.from(`${tamperedPayload}.${signature}`).toString('base64url'); + + const result = signer.validate(tamperedToken, 'route2'); + expect(result.left().present()).toBe(true); + expect(result.left().get().message).toBe('Invalid token signature'); + }); + + it('rejects invalid token format', () => { + const signer = new TokenSigner('test-secret', 30); + const invalidToken = Buffer.from('missing-dot').toString('base64url'); + + const result = signer.validate(invalidToken, 'route1'); + expect(result.left().present()).toBe(true); + expect(result.left().get().message).toBe('Invalid token format'); + }); +}); diff --git a/test/types.test.ts b/test/types.test.ts new file mode 100644 index 0000000..1823c15 --- /dev/null +++ b/test/types.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { ContentType, isRouteConfig, isSafeRouteName } from '../src/types/index.js'; + +describe('isSafeRouteName', () => { + it('accepts typical route names', () => { + expect(isSafeRouteName('route1')).toBe(true); + expect(isSafeRouteName('Route_1')).toBe(true); + expect(isSafeRouteName('route-1')).toBe(true); + }); + + it('enforces length and character rules', () => { + expect(isSafeRouteName('a'.repeat(64))).toBe(true); + expect(isSafeRouteName('a'.repeat(65))).toBe(false); + expect(isSafeRouteName('bad!name')).toBe(false); + }); + + it('rejects unsafe or ambiguous names', () => { + expect(isSafeRouteName('')).toBe(false); + expect(isSafeRouteName(' route')).toBe(false); + expect(isSafeRouteName('route ')).toBe(false); + expect(isSafeRouteName('.')).toBe(false); + expect(isSafeRouteName('..')).toBe(false); + expect(isSafeRouteName('foo/bar')).toBe(false); + expect(isSafeRouteName('foo\\bar')).toBe(false); + }); +}); + +describe('isRouteConfig', () => { + it('accepts a valid configuration', () => { + const config = { + name: 'route1', + contentType: ContentType.JSON, + hcaptchaProtected: false, + ntfy: { enabled: false }, + requireToken: true, + }; + + expect(isRouteConfig(config)).toBe(true); + }); + + it('requires an hCaptcha secret when protected', () => { + const config = { + name: 'route1', + contentType: ContentType.JSON, + hcaptchaProtected: true, + }; + + expect(isRouteConfig(config)).toBe(false); + }); + + it('validates ntfy config when enabled', () => { + const config = { + name: 'route1', + contentType: ContentType.JSON, + hcaptchaProtected: false, + ntfy: { enabled: true }, + }; + + expect(isRouteConfig(config)).toBe(false); + }); + + it('validates requireToken type when present', () => { + const config = { + name: 'route1', + contentType: ContentType.JSON, + hcaptchaProtected: false, + requireToken: 'yes', + }; + + expect(isRouteConfig(config)).toBe(false); + }); +}); -- cgit v1.2.3-70-g09d2