aboutsummaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
authorElizabeth Hunt <me@liz.coffee>2025-12-14 22:43:24 -0800
committerElizabeth Hunt <me@liz.coffee>2025-12-14 22:43:24 -0800
commitcdb1a57614068fcfefa145bc6df45c9def7ccc6a (patch)
tree92cadbecda8658c143b7625d5925e3411976a892 /test
parent6d318665a08c0d4564d8de23cc39425d2c0bac49 (diff)
downloadposthook-cdb1a57614068fcfefa145bc6df45c9def7ccc6a.tar.gz
posthook-cdb1a57614068fcfefa145bc6df45c9def7ccc6a.zip
Updates
Diffstat (limited to 'test')
-rw-r--r--test/integrations.test.ts128
-rw-r--r--test/storage.test.ts106
-rw-r--r--test/token.test.ts72
-rw-r--r--test/types.test.ts73
4 files changed, 379 insertions, 0 deletions
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);
+ });
+});