diff options
| author | Fuwn <[email protected]> | 2026-02-10 00:14:49 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-10 00:14:49 -0800 |
| commit | 3bf0a85f912d39595f0f9d329ee316e1af79dd86 (patch) | |
| tree | 34d8dce12a12d14a61774e47f2eaeed21bc7289e /apps | |
| parent | fix: reduce lint warnings from 34 to 0 (diff) | |
| download | asa.news-3bf0a85f912d39595f0f9d329ee316e1af79dd86.tar.xz asa.news-3bf0a85f912d39595f0f9d329ee316e1af79dd86.zip | |
feat: add vitest tests and GitHub Actions CI
24 tests covering webhook URL validation (SSRF), API key generation/
hashing, and HTML sanitization. CI workflow runs lint, typecheck
(build), and test on push/PR to main.
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/web/lib/api-key.test.ts | 49 | ||||
| -rw-r--r-- | apps/web/lib/sanitize.test.ts | 47 | ||||
| -rw-r--r-- | apps/web/lib/validate-webhook-url.test.ts | 59 | ||||
| -rw-r--r-- | apps/web/package.json | 6 | ||||
| -rw-r--r-- | apps/web/vitest.config.ts | 13 |
5 files changed, 172 insertions, 2 deletions
diff --git a/apps/web/lib/api-key.test.ts b/apps/web/lib/api-key.test.ts new file mode 100644 index 0000000..21b3937 --- /dev/null +++ b/apps/web/lib/api-key.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest" +import { generateApiKey, hashApiKey } from "./api-key" + +describe("generateApiKey", () => { + it("generates a key with the asa_ prefix", () => { + const { fullKey } = generateApiKey() + expect(fullKey.startsWith("asa_")).toBe(true) + }) + + it("generates a 44-character key", () => { + const { fullKey } = generateApiKey() + expect(fullKey.length).toBe(44) + }) + + it("returns a key prefix that is the first 8 characters", () => { + const { fullKey, keyPrefix } = generateApiKey() + expect(keyPrefix).toBe(fullKey.slice(0, 8)) + }) + + it("returns a hash that matches hashing the full key", () => { + const { fullKey, keyHash } = generateApiKey() + expect(keyHash).toBe(hashApiKey(fullKey)) + }) + + it("generates unique keys", () => { + const first = generateApiKey() + const second = generateApiKey() + expect(first.fullKey).not.toBe(second.fullKey) + }) +}) + +describe("hashApiKey", () => { + it("returns a 64-character hex string", () => { + const hash = hashApiKey("asa_test") + expect(hash).toMatch(/^[0-9a-f]{64}$/) + }) + + it("is deterministic", () => { + const hashA = hashApiKey("asa_test_key") + const hashB = hashApiKey("asa_test_key") + expect(hashA).toBe(hashB) + }) + + it("produces different hashes for different keys", () => { + const hashA = hashApiKey("asa_key_one") + const hashB = hashApiKey("asa_key_two") + expect(hashA).not.toBe(hashB) + }) +}) diff --git a/apps/web/lib/sanitize.test.ts b/apps/web/lib/sanitize.test.ts new file mode 100644 index 0000000..266b8af --- /dev/null +++ b/apps/web/lib/sanitize.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest" +import { sanitizeEntryContent } from "./sanitize" + +describe("sanitizeEntryContent", () => { + it("allows safe html tags", () => { + const input = "<p>hello <strong>world</strong></p>" + expect(sanitizeEntryContent(input)).toBe(input) + }) + + it("strips script tags", () => { + const input = '<p>safe</p><script>alert("xss")</script>' + expect(sanitizeEntryContent(input)).toBe("<p>safe</p>") + }) + + it("strips event handlers", () => { + const input = '<p onclick="alert(1)">click me</p>' + expect(sanitizeEntryContent(input)).toBe("<p>click me</p>") + }) + + it("allows img tags with safe attributes", () => { + const input = '<img src="https://example.com/img.jpg" alt="photo">' + const result = sanitizeEntryContent(input) + expect(result).toContain("src=") + expect(result).toContain("alt=") + }) + + it("strips iframe tags", () => { + const input = '<iframe src="https://evil.com"></iframe>' + expect(sanitizeEntryContent(input)).toBe("") + }) + + it("strips javascript: urls from links", () => { + const input = '<a href="javascript:alert(1)">click</a>' + const result = sanitizeEntryContent(input) + expect(result).not.toContain("javascript:") + }) + + it("allows https links", () => { + const input = '<a href="https://example.com">link</a>' + expect(sanitizeEntryContent(input)).toBe(input) + }) + + it("preserves code blocks", () => { + const input = "<pre><code>const x = 1</code></pre>" + expect(sanitizeEntryContent(input)).toBe(input) + }) +}) diff --git a/apps/web/lib/validate-webhook-url.test.ts b/apps/web/lib/validate-webhook-url.test.ts new file mode 100644 index 0000000..3375133 --- /dev/null +++ b/apps/web/lib/validate-webhook-url.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest" +import { validateWebhookUrl } from "./validate-webhook-url" + +describe("validateWebhookUrl", () => { + it("rejects empty urls", async () => { + const result = await validateWebhookUrl("") + expect(result.valid).toBe(false) + }) + + it("rejects non-https urls", async () => { + const result = await validateWebhookUrl("http://example.com/webhook") + expect(result.valid).toBe(false) + if (!result.valid) { + expect(result.error).toContain("https") + } + }) + + it("rejects invalid url format", async () => { + const result = await validateWebhookUrl("not-a-url") + expect(result.valid).toBe(false) + }) + + it("rejects localhost", async () => { + const result = await validateWebhookUrl("https://localhost/webhook") + expect(result.valid).toBe(false) + }) + + it("rejects ipv6 loopback", async () => { + const result = await validateWebhookUrl("https://[::1]/webhook") + expect(result.valid).toBe(false) + }) + + it("rejects private ipv4 addresses", async () => { + const privateAddresses = [ + "https://10.0.0.1/webhook", + "https://172.16.0.1/webhook", + "https://192.168.1.1/webhook", + "https://127.0.0.1/webhook", + ] + + for (const address of privateAddresses) { + const result = await validateWebhookUrl(address) + expect(result.valid).toBe(false) + } + }) + + it("accepts valid public https urls", async () => { + const result = await validateWebhookUrl("https://example.com/webhook") + expect(result.valid).toBe(true) + }) + + it("trims whitespace from urls", async () => { + const result = await validateWebhookUrl(" https://example.com/webhook ") + expect(result.valid).toBe(true) + if (result.valid) { + expect(result.url).toBe("https://example.com/webhook") + } + }) +}) diff --git a/apps/web/package.json b/apps/web/package.json index b548ff3..0b5aa4c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build --webpack", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "vitest run" }, "dependencies": { "@asa-news/shared": "workspace:*", @@ -45,6 +46,7 @@ "eslint": "^9", "eslint-config-next": "16.1.6", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.0.18" } } diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000..c0446b5 --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vitest/config" +import { resolve } from "path" + +export default defineConfig({ + test: { + include: ["lib/**/*.test.ts"], + }, + resolve: { + alias: { + "@": resolve(__dirname, "."), + }, + }, +}) |