summaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-10 00:14:49 -0800
committerFuwn <[email protected]>2026-02-10 00:14:49 -0800
commit3bf0a85f912d39595f0f9d329ee316e1af79dd86 (patch)
tree34d8dce12a12d14a61774e47f2eaeed21bc7289e /apps
parentfix: reduce lint warnings from 34 to 0 (diff)
downloadasa.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.ts49
-rw-r--r--apps/web/lib/sanitize.test.ts47
-rw-r--r--apps/web/lib/validate-webhook-url.test.ts59
-rw-r--r--apps/web/package.json6
-rw-r--r--apps/web/vitest.config.ts13
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, "."),
+ },
+ },
+})