test(qa): add e2e, integration, unit, and load coverage
Swadhin Biswas committed 7 months ago
1c29e63
1 parent 28f7954
commit 1c29e63
54 files changed
+4519 -1
Showing 54 changed files+4519-1
tests/e2e/accessibility.spec.tstests/e2e/admin-panel.spec.tstests/e2e/api-smoke.spec.tstests/e2e/auth-login.spec.tstests/e2e/auth-register.spec.tstests/e2e/blame-view.spec.tstests/e2e/compare-view.spec.tstests/e2e/create-repo.spec.tstests/e2e/dashboard.spec.tstests/e2e/fork-repo.spec.tstests/e2e/issues.spec.tstests/e2e/labels-milestones.spec.tstests/e2e/mobile-nav.spec.tstests/e2e/notifications.spec.tstests/e2e/org-management.spec.tstests/e2e/pull-requests.spec.tstests/e2e/releases.spec.tstests/e2e/search.spec.tstests/e2e/theme-toggle.spec.tstests/e2e/webhook-setup.spec.tstests/integration/admin-stats-route.test.tstests/integration/auth-login-route.test.tstests/integration/auth-me-route.test.tstests/integration/auth-register-route.test.tstests/integration/issues-releases-route.test.tstests/integration/notifications-route.test.tstests/integration/org-members-route.test.tstests/integration/search-route.test.tstests/integration/user-route.test.tstests/load/README.mdtests/load/k6-api-lifecycle.jstests/load/k6-load.jstests/load/k6-smoke.jstests/load/k6-spike.jstests/load/k6-stress.jstests/unit/air-gapped.test.tstests/unit/api-helpers.test.tstests/unit/ci-formats.test.tstests/unit/cn.test.tstests/unit/codeowners.test.tstests/unit/email.test.tstests/unit/env-validation.test.tstests/unit/markdown.test.tstests/unit/notification-routing.test.tstests/unit/notifications-route.test.tstests/unit/openapi-notifications-parity.test.tstests/unit/permissions.test.tstests/unit/runner-registration-token.test.tstests/unit/sanitize.test.tstests/unit/security-policies.test.tstests/unit/suggested-changes.test.tstests/unit/url-validator.test.tstests/unit/validation.test.tstests/unit/workflow-secret-crypto.test.ts
▼tests/e2e/accessibility.spec.ts
+257-0
| @@ -0,0 +1,257 @@ | ||
1 | +/** | |
2 | + * Accessibility E2E Tests — WCAG 2.1 AA compliance | |
3 | + * | |
4 | + * Uses @axe-core/playwright to automatically detect accessibility violations | |
5 | + * across key pages. Covers: | |
6 | + * - WCAG 2.4.1: Skip navigation / bypass blocks | |
7 | + * - WCAG 1.1.1: Non-text content (alt text, aria-labels) | |
8 | + * - WCAG 1.3.1: Info and relationships (semantic HTML, ARIA roles) | |
9 | + * - WCAG 1.4.3: Contrast (minimum 4.5:1) | |
10 | + * - WCAG 2.1.1: Keyboard navigable | |
11 | + * - WCAG 4.1.2: Name, role, value (ARIA attributes) | |
12 | + */ | |
13 | +import AxeBuilder from "@axe-core/playwright"; | |
14 | +import { expect, test, type Page } from "@playwright/test"; | |
15 | + | |
16 | +interface AxeViolation { | |
17 | + id: string; | |
18 | + impact?: string; | |
19 | + description: string; | |
20 | + nodes: unknown[]; | |
21 | +} | |
22 | + | |
23 | +// Pages to test for accessibility | |
24 | +const PUBLIC_PAGES = [ | |
25 | + { name: "Home", path: "/" }, | |
26 | + { name: "Explore", path: "/explore" }, | |
27 | + { name: "Login", path: "/login" }, | |
28 | + { name: "Register", path: "/register" }, | |
29 | + { name: "Documentation", path: "/docs/" }, | |
30 | +]; | |
31 | + | |
32 | +/** | |
33 | + * Run axe-core on a page and return the results. | |
34 | + * Tags filter ensures we check WCAG 2.1 AA rules. | |
35 | + */ | |
36 | +async function runAxe(page: Page) { | |
37 | + return new AxeBuilder({ page }) | |
38 | + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) | |
39 | + .exclude(".toaster") // Exclude transient toast notifications | |
40 | + .analyze(); | |
41 | +} | |
42 | + | |
43 | +test.describe("Accessibility — WCAG 2.1 AA compliance", () => { | |
44 | + for (const { name, path } of PUBLIC_PAGES) { | |
45 | + test(`${name} page (${path}) has no critical accessibility violations`, async ({ | |
46 | + page, | |
47 | + }) => { | |
48 | + await page.goto(path, { waitUntil: "networkidle" }); | |
49 | + | |
50 | + const results = await runAxe(page); | |
51 | + | |
52 | + // Filter to critical and serious violations only | |
53 | + const critical = (results.violations as AxeViolation[]).filter( | |
54 | + (v: AxeViolation) => v.impact === "critical" || v.impact === "serious", | |
55 | + ); | |
56 | + | |
57 | + if (critical.length > 0) { | |
58 | + const summary = critical | |
59 | + .map( | |
60 | + (v: AxeViolation) => | |
61 | + `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} occurrences)`, | |
62 | + ) | |
63 | + .join("\n"); | |
64 | + console.error(`Accessibility violations on ${name}:\n${summary}`); | |
65 | + } | |
66 | + | |
67 | + expect( | |
68 | + critical, | |
69 | + `Expected no critical/serious a11y violations on ${name}`, | |
70 | + ).toHaveLength(0); | |
71 | + }); | |
72 | + } | |
73 | + | |
74 | + test("Skip-to-content link is present and functional", async ({ page }) => { | |
75 | + await page.goto("/", { waitUntil: "networkidle" }); | |
76 | + | |
77 | + // The skip link should exist | |
78 | + const skipLink = page.locator('a[href="#main-content"]'); | |
79 | + await expect(skipLink).toBeAttached(); | |
80 | + | |
81 | + // Should be visually hidden by default | |
82 | + const box = await skipLink.boundingBox(); | |
83 | + // sr-only makes it 1x1px or off-screen — either way not visible | |
84 | + expect(box === null || box.width <= 1 || box.height <= 1).toBeTruthy(); | |
85 | + | |
86 | + // Focus the skip link and verify it becomes visible | |
87 | + await skipLink.focus(); | |
88 | + await expect(skipLink).toBeFocused(); | |
89 | + | |
90 | + // After focus, the sr-only class should be overridden by focus:not-sr-only | |
91 | + const focusedBox = await skipLink.boundingBox(); | |
92 | + expect(focusedBox).not.toBeNull(); | |
93 | + if (focusedBox) { | |
94 | + expect(focusedBox.width).toBeGreaterThan(10); | |
95 | + expect(focusedBox.height).toBeGreaterThan(10); | |
96 | + } | |
97 | + | |
98 | + // Main content target should exist | |
99 | + const mainContent = page.locator("#main-content"); | |
100 | + await expect(mainContent).toBeAttached(); | |
101 | + await expect(mainContent).toHaveAttribute("role", "main"); | |
102 | + }); | |
103 | + | |
104 | + test("Header has proper ARIA landmarks and roles", async ({ page }) => { | |
105 | + await page.goto("/", { waitUntil: "networkidle" }); | |
106 | + | |
107 | + // Banner role on header | |
108 | + const header = page.locator('header[role="banner"]'); | |
109 | + await expect(header).toBeAttached(); | |
110 | + | |
111 | + // Main navigation | |
112 | + const mainNav = page.locator('nav[aria-label="Main navigation"]'); | |
113 | + await expect(mainNav).toBeAttached(); | |
114 | + | |
115 | + // Logo has accessible label | |
116 | + const logoLink = page.locator('a[aria-label="OpenCodeHub home"]'); | |
117 | + await expect(logoLink).toBeAttached(); | |
118 | + | |
119 | + // Search region | |
120 | + const searchRegion = page.locator('[role="search"]'); | |
121 | + await expect(searchRegion).toBeAttached(); | |
122 | + }); | |
123 | + | |
124 | + test("Footer has proper semantic structure", async ({ page }) => { | |
125 | + await page.goto("/", { waitUntil: "networkidle" }); | |
126 | + | |
127 | + // Footer with contentinfo role | |
128 | + const footer = page.locator('footer[role="contentinfo"]'); | |
129 | + await expect(footer).toBeAttached(); | |
130 | + | |
131 | + // Footer navigation | |
132 | + const footerNav = page.locator('nav[aria-label="Footer navigation"]'); | |
133 | + await expect(footerNav).toBeAttached(); | |
134 | + | |
135 | + // External link has proper labeling | |
136 | + const githubLink = page.locator( | |
137 | + 'a[aria-label="GitHub repository (opens in new tab)"]', | |
138 | + ); | |
139 | + await expect(githubLink).toBeAttached(); | |
140 | + await expect(githubLink).toHaveAttribute("target", "_blank"); | |
141 | + await expect(githubLink).toHaveAttribute("rel", /noopener/); | |
142 | + }); | |
143 | + | |
144 | + test("Dropdown menus have proper ARIA attributes", async ({ page }) => { | |
145 | + await page.goto("/", { waitUntil: "networkidle" }); | |
146 | + | |
147 | + // Create menu button | |
148 | + const createBtn = page.locator('#create-menu button[aria-haspopup="true"]'); | |
149 | + // May not be visible if user not logged in — check if present | |
150 | + const createBtnCount = await createBtn.count(); | |
151 | + | |
152 | + if (createBtnCount > 0) { | |
153 | + await expect(createBtn).toHaveAttribute("aria-expanded", "false"); | |
154 | + await expect(createBtn).toHaveAttribute( | |
155 | + "aria-controls", | |
156 | + "create-dropdown", | |
157 | + ); | |
158 | + | |
159 | + // The dropdown should have role="menu" | |
160 | + const createDropdown = page.locator('#create-dropdown[role="menu"]'); | |
161 | + await expect(createDropdown).toBeAttached(); | |
162 | + | |
163 | + // Menu items should have role="menuitem" | |
164 | + const menuItems = createDropdown.locator('[role="menuitem"]'); | |
165 | + expect(await menuItems.count()).toBeGreaterThan(0); | |
166 | + } | |
167 | + }); | |
168 | + | |
169 | + test("Mobile menu button has proper ARIA attributes", async ({ page }) => { | |
170 | + // Set mobile viewport | |
171 | + await page.setViewportSize({ width: 375, height: 812 }); | |
172 | + await page.goto("/", { waitUntil: "networkidle" }); | |
173 | + | |
174 | + const mobileBtn = page.locator("#mobile-menu-btn"); | |
175 | + await expect(mobileBtn).toBeVisible(); | |
176 | + await expect(mobileBtn).toHaveAttribute("aria-label", "Menu"); | |
177 | + await expect(mobileBtn).toHaveAttribute("aria-expanded", "false"); | |
178 | + | |
179 | + // Click to open | |
180 | + await mobileBtn.click(); | |
181 | + await expect(mobileBtn).toHaveAttribute("aria-expanded", "true"); | |
182 | + | |
183 | + // Mobile nav should be visible | |
184 | + const mobileNav = page.locator("#mobile-nav"); | |
185 | + await expect(mobileNav).toBeVisible(); | |
186 | + await expect(mobileNav).toHaveAttribute("role", "dialog"); | |
187 | + }); | |
188 | + | |
189 | + test("Keyboard navigation: Escape closes open dropdowns", async ({ | |
190 | + page, | |
191 | + }) => { | |
192 | + await page.goto("/", { waitUntil: "networkidle" }); | |
193 | + | |
194 | + // Mobile viewport to test mobile menu | |
195 | + await page.setViewportSize({ width: 375, height: 812 }); | |
196 | + | |
197 | + const mobileBtn = page.locator("#mobile-menu-btn"); | |
198 | + await mobileBtn.click(); | |
199 | + | |
200 | + const mobileNav = page.locator("#mobile-nav"); | |
201 | + await expect(mobileNav).toBeVisible(); | |
202 | + | |
203 | + // Press Escape | |
204 | + await page.keyboard.press("Escape"); | |
205 | + await expect(mobileNav).not.toBeVisible(); | |
206 | + await expect(mobileBtn).toHaveAttribute("aria-expanded", "false"); | |
207 | + }); | |
208 | + | |
209 | + test("All images have alt text", async ({ page }) => { | |
210 | + await page.goto("/", { waitUntil: "networkidle" }); | |
211 | + | |
212 | + const images = page.locator("img"); | |
213 | + const count = await images.count(); | |
214 | + | |
215 | + for (let i = 0; i < count; i++) { | |
216 | + const img = images.nth(i); | |
217 | + const alt = await img.getAttribute("alt"); | |
218 | + const src = await img.getAttribute("src"); | |
219 | + expect( | |
220 | + alt !== null && alt !== undefined, | |
221 | + `Image ${src} is missing alt attribute`, | |
222 | + ).toBeTruthy(); | |
223 | + } | |
224 | + }); | |
225 | + | |
226 | + test("Interactive elements are keyboard focusable", async ({ page }) => { | |
227 | + await page.goto("/", { waitUntil: "networkidle" }); | |
228 | + | |
229 | + // Tab through the page and ensure we can reach key elements | |
230 | + await page.keyboard.press("Tab"); | |
231 | + // First tab should hit the skip-to-content link | |
232 | + const skipLink = page.locator('a[href="#main-content"]'); | |
233 | + await expect(skipLink).toBeFocused(); | |
234 | + | |
235 | + // Continue tabbing — should reach the logo | |
236 | + await page.keyboard.press("Tab"); | |
237 | + const logo = page.locator('a[aria-label="OpenCodeHub home"]'); | |
238 | + await expect(logo).toBeFocused(); | |
239 | + }); | |
240 | + | |
241 | + test("Page has exactly one main landmark", async ({ page }) => { | |
242 | + await page.goto("/", { waitUntil: "networkidle" }); | |
243 | + | |
244 | + const mainElements = page.locator("main, [role='main']"); | |
245 | + // Should have exactly 1 main landmark (our <main id="main-content" role="main">) | |
246 | + const count = await mainElements.count(); | |
247 | + // It's the same element, so count should be 1 | |
248 | + expect(count).toBe(1); | |
249 | + }); | |
250 | + | |
251 | + test("HTML lang attribute is set", async ({ page }) => { | |
252 | + await page.goto("/", { waitUntil: "networkidle" }); | |
253 | + | |
254 | + const lang = await page.locator("html").getAttribute("lang"); | |
255 | + expect(lang).toBe("en"); | |
256 | + }); | |
257 | +}); | |
0 | 258 | |
▼tests/e2e/admin-panel.spec.ts
+29-0
| @@ -0,0 +1,29 @@ | ||
1 | +import { expect, test } from "@playwright/test"; | |
2 | + | |
3 | +test.describe("Admin Panel", () => { | |
4 | + test("admin page requires auth", async ({ page }) => { | |
5 | + await page.goto("/admin"); | |
6 | + const url = page.url(); | |
7 | + // Should redirect to login or show forbidden | |
8 | + expect( | |
9 | + url.includes("/login") || | |
10 | + (await page | |
11 | + .locator("text=Forbidden") | |
12 | + .or(page.locator("text=denied")) | |
13 | + .first() | |
14 | + .isVisible() | |
15 | + .catch(() => false)) || | |
16 | + (await page | |
17 | + .locator("text=Login") | |
18 | + .first() | |
19 | + .isVisible() | |
20 | + .catch(() => false)), | |
21 | + ).toBeTruthy(); | |
22 | + }); | |
23 | + | |
24 | + test("admin API requires admin role", async ({ request }) => { | |
25 | + const res = await request.get("/api/admin/stats"); | |
26 | + // Should be 401 or 403 | |
27 | + expect([401, 403]).toContain(res.status()); | |
28 | + }); | |
29 | +}); | |
0 | 30 | |
▼tests/e2e/api-smoke.spec.ts
+54-0
| @@ -0,0 +1,54 @@ | ||
1 | +import { expect, test } from "@playwright/test"; | |
2 | + | |
3 | +test.describe("API Smoke Tests", () => { | |
4 | + test("search API returns 200 for valid query", async ({ request }) => { | |
5 | + const res = await request.get("/api/search?q=test"); | |
6 | + expect(res.status()).toBe(200); | |
7 | + const json = await res.json(); | |
8 | + expect(json.results).toBeDefined(); | |
9 | + expect(Array.isArray(json.results)).toBe(true); | |
10 | + }); | |
11 | + | |
12 | + test("search API returns empty for short query", async ({ request }) => { | |
13 | + const res = await request.get("/api/search?q=a"); | |
14 | + expect(res.status()).toBe(200); | |
15 | + const json = await res.json(); | |
16 | + expect(json.results).toEqual([]); | |
17 | + }); | |
18 | + | |
19 | + test("auth me API returns 401 without token", async ({ request }) => { | |
20 | + const res = await request.get("/api/auth/me"); | |
21 | + expect(res.status()).toBe(401); | |
22 | + }); | |
23 | + | |
24 | + test("user API returns 401 without token", async ({ request }) => { | |
25 | + const res = await request.get("/api/user"); | |
26 | + expect(res.status()).toBe(401); | |
27 | + }); | |
28 | + | |
29 | + test("notifications API returns 401 without token", async ({ request }) => { | |
30 | + const res = await request.get("/api/notifications"); | |
31 | + expect(res.status()).toBe(401); | |
32 | + }); | |
33 | + | |
34 | + test("login API rejects empty body", async ({ request }) => { | |
35 | + const res = await request.post("/api/auth/login", { | |
36 | + data: {}, | |
37 | + headers: { "Content-Type": "application/json" }, | |
38 | + }); | |
39 | + expect([400, 401]).toContain(res.status()); | |
40 | + }); | |
41 | + | |
42 | + test("register API rejects invalid data", async ({ request }) => { | |
43 | + const res = await request.post("/api/auth/register", { | |
44 | + data: { username: "a" }, // too short, missing fields | |
45 | + headers: { "Content-Type": "application/json" }, | |
46 | + }); | |
47 | + expect([400, 422]).toContain(res.status()); | |
48 | + }); | |
49 | + | |
50 | + test("CSRF token endpoint exists", async ({ request }) => { | |
51 | + const res = await request.get("/api/auth/csrf-token"); | |
52 | + expect([200, 404]).toContain(res.status()); | |
53 | + }); | |
54 | +}); | |
0 | 55 | |
▼tests/e2e/auth-login.spec.ts
+52-0
| @@ -0,0 +1,52 @@ | ||
1 | +import { expect, test } from "@playwright/test"; | |
2 | + | |
3 | +test.describe("Authentication - Login", () => { | |
4 | + test("login page loads", async ({ page }) => { | |
5 | + await page.goto("/login"); | |
6 | + await expect(page.locator("form")).toBeVisible(); | |
7 | + await expect( | |
8 | + page | |
9 | + .locator( | |
10 | + 'input[name="login"], input[name="username"], input[type="text"]', | |
11 | + ) | |
12 | + .first(), | |
13 | + ).toBeVisible(); | |
14 | + await expect( | |
15 | + page.locator('input[name="password"], input[type="password"]').first(), | |
16 | + ).toBeVisible(); | |
17 | + }); | |
18 | + | |
19 | + test("shows error for invalid credentials", async ({ page }) => { | |
20 | + await page.goto("/login"); | |
21 | + await page.fill( | |
22 | + 'input[name="login"], input[name="username"], input[type="text"]', | |
23 | + "nonexistent_user", | |
24 | + ); | |
25 | + await page.fill( | |
26 | + 'input[name="password"], input[type="password"]', | |
27 | + "wrongpassword", | |
28 | + ); | |
29 | + await page.click('button[type="submit"]'); | |
30 | + await expect( | |
31 | + page | |
32 | + .locator("text=Invalid") | |
33 | + .or(page.locator("text=incorrect")) | |
34 | + .or(page.locator("[role=alert]")) | |
35 | + .first(), | |
36 | + ).toBeVisible({ timeout: 5000 }); | |
37 | + }); | |
38 | + | |
39 | + test("has link to register page", async ({ page }) => { | |
40 | + await page.goto("/login"); | |
41 | + const registerLink = page.locator('a[href*="register"]'); | |
42 | + await expect(registerLink).toBeVisible(); | |
43 | + }); | |
44 | + | |
45 | + test("has link to forgot password", async ({ page }) => { | |
46 | + await page.goto("/login"); | |
47 | + const forgotLink = page.locator('a[href*="forgot"], a[href*="reset"]'); | |
48 | + if (await forgotLink.isVisible()) { | |
49 | + await expect(forgotLink).toBeVisible(); | |
50 | + } | |
51 | + }); | |
52 | +}); | |
0 | 53 | |
▼tests/e2e/auth-register.spec.ts
+30-0
| @@ -0,0 +1,30 @@ | ||
1 | +import { expect, test } from "@playwright/test"; | |
2 | + | |
3 | +test.describe("Authentication - Register", () => { | |
4 | + test("register page loads", async ({ page }) => { | |
5 | + await page.goto("/register"); | |
6 | + await expect(page.locator("form")).toBeVisible(); | |
7 | + }); | |
8 | + | |
9 | + test("shows validation errors for empty form", async ({ page }) => { | |
10 | + await page.goto("/register"); | |
11 | + await page.click('button[type="submit"]'); | |
12 | + await expect( | |
13 | + page.locator("text=required").or(page.locator("[role=alert]")).first(), | |
14 | + ).toBeVisible({ timeout: 5000 }); | |
15 | + }); | |
16 | + | |
17 | + test("register with valid data", async ({ page }) => { | |
18 | + const ts = Date.now(); | |
19 | + await page.goto("/register"); | |
20 | + await page.fill('input[name="username"]', `e2euser${ts}`); | |
21 | + await page.fill('input[name="email"]', `e2e${ts}@test.com`); | |
22 | + await page.fill('input[name="password"]', `Test1234!`); | |
23 | + if (await page.locator('input[name="displayName"]').isVisible()) { | |
24 | + await page.fill('input[name="displayName"]', `E2E User ${ts}`); | |
25 | + } | |
26 | + await page.click('button[type="submit"]'); | |
27 | + // Should redirect to dashboard or show success | |
28 | + await page.waitForURL(/\/(dashboard|explore|\/)/, { timeout: 10000 }); | |
29 | + }); | |
30 | +}); | |
0 | 31 | |
▼tests/e2e/blame-view.spec.ts
+10-0
| @@ -0,0 +1,10 @@ | ||
1 | +import { expect, test } from "@playwright/test"; | |
2 | + | |
3 | +test.describe("Blame View", () => { | |
4 | + test("blame page pattern exists", async ({ page }) => { | |
5 | + // Navigate to a blame URL pattern - may 404 if no repos exist | |
6 | + await page.goto("/test/test-repo/blame/main/README.md"); | |
7 | + const status = await page.locator("body").isVisible(); | |
8 | + expect(status).toBe(true); | |
9 | + }); | |
10 | +}); | |
0 | 11 | |
▼tests/e2e/compare-view.spec.ts
+9-0
| @@ -0,0 +1,9 @@ | ||
1 | +import { expect, test } from "@playwright/test"; | |
2 | + | |
3 | +test.describe("Compare View", () => { | |
4 | + test("compare page pattern exists", async ({ page }) => { | |
5 | + await page.goto("/test/test-repo/compare"); | |
6 | + const status = await page.locator("body").isVisible(); | |
7 | + expect(status).toBe(true); | |
8 | + }); | |
9 | +}); | |
0 | 10 | |
▼tests/e2e/create-repo.spec.ts
+26-0
| @@ -0,0 +1,26 @@ | ||
1 | +import { expect, test } from "@playwright/test"; | |
2 | + | |
3 | +test.describe("Create Repository", () => { | |
4 | + test("new repo page requires auth", async ({ page }) => { | |
5 | + await page.goto("/new"); | |
6 | + // Should either show the form (if logged in) or redirect to login | |
7 | + const url = page.url(); | |
8 | + const hasForm = await page | |
9 | + .locator("form") | |
10 | + .isVisible() | |
11 | + .catch(() => false); | |
12 | + expect(url.includes("/login") || hasForm).toBeTruthy(); | |
13 | + }); | |
14 | + | |
15 | + test("new repo page shows required fields", async ({ page }) => { | |
16 | + // Navigate directly - may redirect if not authed | |
17 | + await page.goto("/new"); | |
18 | + if (page.url().includes("/login")) { | |
19 | + // Expected for unauthenticated user | |
20 | + return; | |
21 | + } | |
22 | + await expect( | |
23 | + page.locator('input[name="name"], input[name="repoName"]').first(), | |
24 | + ).toBeVisible(); | |
25 | + }); | |
26 | +}); | |
0 | 27 | |
▼tests/e2e/dashboard.spec.ts
+35-0
| @@ -0,0 +1,35 @@ | ||
1 | +import { expect, test } from "@playwright/test"; | |
2 | + | |
3 | +test.describe("Dashboard", () => { | |
4 | + test("homepage loads and shows branding", async ({ page }) => { | |
5 | + await page.goto("/"); | |
6 | + await expect(page).toHaveTitle(/OpenCodeHub/i); | |
7 | + }); | |
8 | + | |
9 | + test("explore page loads", async ({ page }) => { | |
10 | + await page.goto("/explore"); | |
11 | + await expect(page.locator("body")).toBeVisible(); | |
12 | + // Should show repositories or search | |
13 | + await expect( | |
14 | + page | |
15 | + .locator("text=Explore") | |
16 | + .or(page.locator("text=Repositories")) | |
17 | + .or(page.locator("text=explore")) | |
18 | + .first(), | |
19 | + ).toBeVisible({ timeout: 5000 }); | |
20 | + }); | |
21 | + | |
22 | + test("navigation bar is present", async ({ page }) => { | |
23 | + await page.goto("/"); | |
24 | + const nav = page.locator("nav, header"); | |
25 | + await expect(nav.first()).toBeVisible(); | |
26 | + }); | |
27 | + | |
28 | + test("footer is present", async ({ page }) => { | |
29 | + await page.goto("/"); | |
30 | + const footer = page.locator("footer"); | |
31 | + if (await footer.isVisible()) { | |
32 | + await expect(footer).toBeVisible(); | |
33 | + } | |
34 | + }); | |
35 | +}); | |
0 | 36 | |
▼tests/e2e/fork-repo.spec.ts
+16-0
| @@ -0,0 +1,16 @@ | ||
1 | +import { expect, test } from "@playwright/test"; | |
2 | + | |
3 | +test.describe("Fork Repository", () => { | |
4 | + test("fork requires authentication", async ({ page }) => { | |
5 | + await page.goto("/test/test-repo"); | |
6 | + // Fork button should require auth | |
7 | + const forkBtn = page.locator("button:has-text('Fork'), a:has-text('Fork')"); | |
8 | + if (await forkBtn.isVisible().catch(() => false)) { | |
9 | + await forkBtn.click(); | |
10 | + // Should redirect to login | |
11 | + await page.waitForTimeout(1000); | |
12 | + const url = page.url(); | |
13 | + expect(url.includes("/login") || url.includes("/fork")).toBeTruthy(); | |
14 | + } | |
15 | + }); | |
16 | +}); | |
0 | 17 | |
▼tests/e2e/issues.spec.ts
+25-0
| @@ -0,0 +1,25 @@ | ||
1 | +import { expect, test } from "@playwright/test"; | |
2 | + | |
3 | +test.describe("Issues", () => { | |
4 | + test("issues page loads for a repo", async ({ page }) => { | |
5 | + // Navigate to a generic issues URL pattern | |
6 | + const response = await page.goto("/explore"); | |
7 | + await expect(page.locator("body")).toBeVisible(); | |
8 | + }); | |
9 | + | |
10 | + test("new issue page requires auth", async ({ page }) => { | |
11 | + // Attempting to create an issue without auth should redirect or show error | |
12 | + await page.goto("/test/test-repo/issues/new"); | |
13 | + const url = page.url(); | |
14 | + const hasError = await page | |
15 | + .locator("text=not found") | |
16 | + .or(page.locator("text=login")) | |
17 | + .first() | |
18 | + .isVisible() | |
19 | + .catch(() => false); | |
20 | + // Either redirected to login or shows not found (expected) | |
21 | + expect( | |
22 | + url.includes("/login") || hasError || page.url().includes("404"), | |
23 | + ).toBeTruthy(); | |
24 | + }); | |
25 | +}); | |
0 | 26 | |
▼tests/e2e/labels-milestones.spec.ts
+13-0
| @@ -0,0 +1,13 @@ | ||
1 | +import { expect, test } from "@playwright/test"; | |
2 | + | |
3 | +test.describe("Labels and Milestones", () => { | |
4 | + test("labels page pattern", async ({ page }) => { | |
5 | + await page.goto("/test/test-repo/labels"); | |
6 | + await expect(page.locator("body")).toBeVisible(); | |
7 | + }); | |
8 | + | |
9 | + test("milestones page pattern", async ({ page }) => { | |
10 | + await page.goto("/test/test-repo/milestones"); | |
11 | + await expect(page.locator("body")).toBeVisible(); | |
12 | + }); | |
13 | +}); | |
0 | 14 | |
▼tests/e2e/mobile-nav.spec.ts
+50-0
| @@ -0,0 +1,50 @@ | ||
1 | +import { expect, test } from "@playwright/test"; | |
2 | + | |
3 | +test.describe("Mobile Navigation", () => { | |
4 | + test.use({ viewport: { width: 375, height: 667 } }); // iPhone SE | |
5 | + | |
6 | + test("mobile menu button is visible", async ({ page }) => { | |
7 | + await page.goto("/"); | |
8 | + const menuBtn = page.locator( | |
9 | + 'button[aria-label*="menu"], button[aria-label*="Menu"], [data-mobile-menu], button.md\\:hidden, button.lg\\:hidden', | |
10 | + ); | |
11 | + await expect(menuBtn.first()).toBeVisible({ timeout: 5000 }); | |
12 | + }); | |
13 | + | |
14 | + test("clicking menu button shows navigation", async ({ page }) => { | |
15 | + await page.goto("/"); | |
16 | + const menuBtn = page | |
17 | + .locator( | |
18 | + 'button[aria-label*="menu"], button[aria-label*="Menu"], [data-mobile-menu], button.md\\:hidden, button.lg\\:hidden', | |
19 | + ) | |
20 | + .first(); | |
21 | + | |
22 | + if (await menuBtn.isVisible()) { | |
23 | + await menuBtn.click(); | |
24 | + await page.waitForTimeout(500); | |
25 | + // Navigation links should become visible | |
26 | + const navLinks = page.locator( | |
27 | + "nav a, [role=navigation] a, [data-mobile-nav] a", | |
28 | + ); | |
29 | + await expect(navLinks.first()).toBeVisible({ timeout: 3000 }); | |
30 | + } | |
31 | + }); | |
32 | + | |
33 | + test("page is responsive at mobile width", async ({ page }) => { | |
34 | + await page.goto("/"); | |
35 | + // No horizontal scrollbar | |
36 | + const bodyWidth = await page.evaluate(() => document.body.scrollWidth); | |
37 | + const viewportWidth = await page.evaluate(() => window.innerWidth); | |
38 | + expect(bodyWidth).toBeLessThanOrEqual(viewportWidth + 5); | |
39 | + }); | |
40 | + | |
41 | + test("login page is mobile-friendly", async ({ page }) => { | |
42 | + await page.goto("/login"); | |
43 | + await expect(page.locator("form")).toBeVisible(); | |
44 | + // Form should fit within viewport | |
45 | + const formBox = await page.locator("form").boundingBox(); | |
46 | + if (formBox) { | |
47 | + expect(formBox.width).toBeLessThanOrEqual(375); | |
48 | + } | |
49 | + }); | |
50 | +}); | |
0 | 51 | |
▼tests/e2e/notifications.spec.ts
+23-0
| @@ -0,0 +1,23 @@ | ||
1 | +import { expect, test } from "@playwright/test"; | |
2 | + | |
3 | +test.describe("Notifications", () => { | |
4 | + test("notifications API requires auth", async ({ request }) => { | |
5 | + const res = await request.get("/api/notifications"); | |
6 | + expect(res.status()).toBe(401); | |
7 | + }); | |
8 | + | |
9 | + test("notifications page requires auth", async ({ page }) => { | |
10 | + await page.goto("/notifications"); | |
11 | + const url = page.url(); | |
12 | + expect( | |
13 | + url.includes("/login") || | |
14 | + (await page | |
15 | + .locator("text=login") | |
16 | + .or(page.locator("text=Sign in")) | |
17 | + .first() | |
18 | + .isVisible() | |
19 | + .catch(() => false)) || | |
20 | + page.url().includes("404"), | |
21 | + ).toBeTruthy(); | |
22 | + }); | |
23 | +}); | |
0 | 24 | |
▼tests/e2e/org-management.spec.ts
+23-0
| @@ -0,0 +1,23 @@ | ||
1 | +import { expect, test } from "@playwright/test"; | |
2 | + | |
3 | +test.describe("Organization Management", () => { | |
4 | + test("org creation requires auth", async ({ page }) => { | |
5 | + await page.goto("/orgs/new"); | |
6 | + const url = page.url(); | |
7 | + expect( | |
8 | + url.includes("/login") || | |
9 | + (await page | |
10 | + .locator("text=login") | |
11 | + .or(page.locator("text=Sign in")) | |
12 | + .first() | |
13 | + .isVisible() | |
14 | + .catch(() => false)) || | |
15 | + page.url().includes("404"), | |
16 | + ).toBeTruthy(); | |
17 | + }); | |
18 | + | |
19 | + test("org members API requires auth", async ({ request }) => { | |
20 | + const res = await request.get("/api/orgs/test-org/members"); | |
21 | + expect([401, 403, 404]).toContain(res.status()); | |
22 | + }); | |
23 | +}); | |
0 | 24 | |
▼tests/e2e/pull-requests.spec.ts
+22-0
| @@ -0,0 +1,22 @@ | ||
1 | +import { expect, test } from "@playwright/test"; | |
2 | + | |
3 | +test.describe("Pull Requests", () => { | |
4 | + test("PR list page pattern exists", async ({ page }) => { | |
5 | + await page.goto("/explore"); | |
6 | + await expect(page.locator("body")).toBeVisible(); | |
7 | + }); | |
8 | + | |
9 | + test("PR creation requires auth", async ({ page }) => { | |
10 | + await page.goto("/test/test-repo/compare"); | |
11 | + const url = page.url(); | |
12 | + // Should redirect to login or show 404 | |
13 | + expect( | |
14 | + url.includes("/login") || | |
15 | + url.includes("404") || | |
16 | + (await page | |
17 | + .locator("text=not found") | |
18 | + .isVisible() | |
19 | + .catch(() => true)), | |
20 | + ).toBeTruthy(); | |
21 | + }); | |
22 | +}); | |
0 | 23 | |
▼tests/e2e/releases.spec.ts
+15-0
| @@ -0,0 +1,15 @@ | ||
1 | +import { expect, test } from "@playwright/test"; | |
2 | + | |
3 | +test.describe("Releases", () => { | |
4 | + test("releases page pattern loads", async ({ page }) => { | |
5 | + await page.goto("/explore"); | |
6 | + await expect(page.locator("body")).toBeVisible(); | |
7 | + }); | |
8 | + | |
9 | + test("releases API returns data", async ({ request }) => { | |
10 | + // Public repo releases should be accessible | |
11 | + const res = await request.get("/api/repos/test/test-repo/releases"); | |
12 | + // Could be 200 (with empty array) or 404 if repo doesn't exist | |
13 | + expect([200, 404]).toContain(res.status()); | |
14 | + }); | |
15 | +}); | |
0 | 16 | |
▼tests/e2e/search.spec.ts
+37-0
| @@ -0,0 +1,37 @@ | ||
1 | +import { expect, test } from "@playwright/test"; | |
2 | + | |
3 | +test.describe("Search", () => { | |
4 | + test("search page or search bar exists", async ({ page }) => { | |
5 | + await page.goto("/"); | |
6 | + const searchInput = page.locator( | |
7 | + 'input[type="search"], input[placeholder*="Search"], input[placeholder*="search"], [role="search"] input', | |
8 | + ); | |
9 | + // Search might be in nav or on explore page | |
10 | + if ( | |
11 | + !(await searchInput | |
12 | + .first() | |
13 | + .isVisible() | |
14 | + .catch(() => false)) | |
15 | + ) { | |
16 | + await page.goto("/explore"); | |
17 | + } | |
18 | + await expect(searchInput.first()).toBeVisible({ timeout: 5000 }); | |
19 | + }); | |
20 | + | |
21 | + test("search returns results for common term", async ({ page }) => { | |
22 | + await page.goto("/explore"); | |
23 | + const searchInput = page | |
24 | + .locator( | |
25 | + 'input[type="search"], input[placeholder*="Search"], input[placeholder*="search"]', | |
26 | + ) | |
27 | + .first(); | |
28 | + if (await searchInput.isVisible()) { | |
29 | + await searchInput.fill("test"); | |
30 | + await searchInput.press("Enter"); | |
31 | + // Wait for results to load | |
32 | + await page.waitForTimeout(1000); | |
33 | + // Page should still be visible (no crash) | |
34 | + await expect(page.locator("body")).toBeVisible(); | |
35 | + } | |
36 | + }); | |
37 | +}); | |
0 | 38 | |
▼tests/e2e/theme-toggle.spec.ts
+52-0
| @@ -0,0 +1,52 @@ | ||
1 | +import { expect, test } from "@playwright/test"; | |
2 | + | |
3 | +test.describe("Theme Toggle", () => { | |
4 | + test("theme toggle button exists", async ({ page }) => { | |
5 | + await page.goto("/"); | |
6 | + const themeBtn = page.locator( | |
7 | + 'button[aria-label*="theme"], button[aria-label*="Theme"], [data-theme-toggle], button:has(svg.lucide-sun), button:has(svg.lucide-moon)', | |
8 | + ); | |
9 | + await expect(themeBtn.first()).toBeVisible({ timeout: 5000 }); | |
10 | + }); | |
11 | + | |
12 | + test("clicking theme toggle changes theme", async ({ page }) => { | |
13 | + await page.goto("/"); | |
14 | + const html = page.locator("html"); | |
15 | + const initialClass = await html.getAttribute("class"); | |
16 | + | |
17 | + const themeBtn = page | |
18 | + .locator( | |
19 | + 'button[aria-label*="theme"], button[aria-label*="Theme"], [data-theme-toggle], button:has(svg.lucide-sun), button:has(svg.lucide-moon)', | |
20 | + ) | |
21 | + .first(); | |
22 | + | |
23 | + if (await themeBtn.isVisible()) { | |
24 | + await themeBtn.click(); | |
25 | + await page.waitForTimeout(500); | |
26 | + const newClass = await html.getAttribute("class"); | |
27 | + // Theme class should change (dark ↔ light) | |
28 | + expect(newClass).not.toBe(initialClass); | |
29 | + } | |
30 | + }); | |
31 | + | |
32 | + test("theme persists on reload", async ({ page }) => { | |
33 | + await page.goto("/"); | |
34 | + const themeBtn = page | |
35 | + .locator( | |
36 | + 'button[aria-label*="theme"], button[aria-label*="Theme"], [data-theme-toggle], button:has(svg.lucide-sun), button:has(svg.lucide-moon)', | |
37 | + ) | |
38 | + .first(); | |
39 | + | |
40 | + if (await themeBtn.isVisible()) { | |
41 | + await themeBtn.click(); | |
42 | + await page.waitForTimeout(500); | |
43 | + const classAfterClick = await page.locator("html").getAttribute("class"); | |
44 | + | |
45 | + await page.reload(); | |
46 | + await page.waitForTimeout(500); | |
47 | + const classAfterReload = await page.locator("html").getAttribute("class"); | |
48 | + | |
49 | + expect(classAfterReload).toBe(classAfterClick); | |
50 | + } | |
51 | + }); | |
52 | +}); | |
0 | 53 | |
▼tests/e2e/webhook-setup.spec.ts
+23-0
| @@ -0,0 +1,23 @@ | ||
1 | +import { expect, test } from "@playwright/test"; | |
2 | + | |
3 | +test.describe("Webhook Setup", () => { | |
4 | + test("webhook settings require auth", async ({ page }) => { | |
5 | + await page.goto("/test/test-repo/settings/webhooks"); | |
6 | + const url = page.url(); | |
7 | + expect( | |
8 | + url.includes("/login") || | |
9 | + page.url().includes("404") || | |
10 | + (await page | |
11 | + .locator("text=not found") | |
12 | + .or(page.locator("text=denied")) | |
13 | + .first() | |
14 | + .isVisible() | |
15 | + .catch(() => false)), | |
16 | + ).toBeTruthy(); | |
17 | + }); | |
18 | + | |
19 | + test("webhook API requires auth", async ({ request }) => { | |
20 | + const res = await request.get("/api/repos/test/test-repo/webhooks"); | |
21 | + expect([401, 403, 404]).toContain(res.status()); | |
22 | + }); | |
23 | +}); | |
0 | 24 | |
▼tests/integration/admin-stats-route.test.ts
+140-0
| @@ -0,0 +1,140 @@ | ||
1 | +/** | |
2 | + * Integration tests for GET /api/admin/stats | |
3 | + */ | |
4 | +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
5 | + | |
6 | +/* ─── hoisted mocks ─── */ | |
7 | +const mocks = vi.hoisted(() => ({ | |
8 | + selectMock: vi.fn(), | |
9 | + fakeSchema: { | |
10 | + repositories: { languages: "languages" }, | |
11 | + users: {}, | |
12 | + pullRequests: { state: "state" }, | |
13 | + commits: { stats: "stats", authorDate: "authorDate" }, | |
14 | + issues: { state: "state" }, | |
15 | + activities: { userId: "userId", createdAt: "createdAt" }, | |
16 | + releases: {}, | |
17 | + }, | |
18 | +})); | |
19 | + | |
20 | +vi.mock("@/db", () => ({ | |
21 | + getDatabase: () => mockDb, | |
22 | + schema: mocks.fakeSchema, | |
23 | +})); | |
24 | + | |
25 | +vi.mock("@/lib/errors", () => ({ | |
26 | + withErrorHandler: (fn: any) => fn, | |
27 | +})); | |
28 | + | |
29 | +vi.mock("drizzle-orm", () => ({ | |
30 | + count: vi.fn(() => "count_expr"), | |
31 | + desc: vi.fn(), | |
32 | + eq: vi.fn(), | |
33 | + gte: vi.fn(), | |
34 | +})); | |
35 | + | |
36 | +/* ─── helpers ─── */ | |
37 | +function makeDb() { | |
38 | + const chainable = { | |
39 | + from: vi.fn().mockReturnThis(), | |
40 | + where: vi.fn().mockReturnThis(), | |
41 | + limit: vi.fn().mockResolvedValue([{ count: 5 }]), | |
42 | + }; | |
43 | + return { | |
44 | + select: vi.fn().mockReturnValue(chainable), | |
45 | + selectDistinct: vi.fn().mockReturnValue({ | |
46 | + from: vi.fn().mockReturnValue({ | |
47 | + where: vi.fn().mockResolvedValue([{ userId: "usr_1" }]), | |
48 | + }), | |
49 | + }), | |
50 | + query: { | |
51 | + users: { | |
52 | + findMany: vi | |
53 | + .fn() | |
54 | + .mockResolvedValue([ | |
55 | + { username: "alice", createdAt: new Date(), avatarUrl: null }, | |
56 | + ]), | |
57 | + }, | |
58 | + activities: { | |
59 | + findMany: vi.fn().mockResolvedValue([ | |
60 | + { | |
61 | + id: "act_1", | |
62 | + type: "PUSH", | |
63 | + action: "pushed", | |
64 | + targetType: "branch", | |
65 | + createdAt: new Date(), | |
66 | + user: { username: "alice" }, | |
67 | + repository: { name: "my-repo" }, | |
68 | + }, | |
69 | + ]), | |
70 | + }, | |
71 | + }, | |
72 | + }; | |
73 | +} | |
74 | + | |
75 | +let mockDb: ReturnType<typeof makeDb>; | |
76 | +const readJson = (r: Response) => r.json(); | |
77 | + | |
78 | +function ctx(isAdmin: boolean) { | |
79 | + return { | |
80 | + locals: { | |
81 | + user: isAdmin | |
82 | + ? { id: "usr_1", username: "admin", isAdmin: true } | |
83 | + : { id: "usr_2", username: "user", isAdmin: false }, | |
84 | + }, | |
85 | + } as any; | |
86 | +} | |
87 | + | |
88 | +/* ─── import route ─── */ | |
89 | +import { GET } from "@/pages/api/admin/stats"; | |
90 | + | |
91 | +/* ─── tests ─── */ | |
92 | +describe("GET /api/admin/stats", () => { | |
93 | + beforeEach(() => { | |
94 | + vi.clearAllMocks(); | |
95 | + mockDb = makeDb(); | |
96 | + }); | |
97 | + | |
98 | + it("returns 200 with stats for admin user", async () => { | |
99 | + const res = await GET(ctx(true)); | |
100 | + expect(res.status).toBe(200); | |
101 | + const json = await readJson(res); | |
102 | + expect(json.totalRepos).toBeDefined(); | |
103 | + expect(json.totalUsers).toBeDefined(); | |
104 | + expect(json.systemStatus).toBeDefined(); | |
105 | + expect(json.systemStatus.cpuLoad).toBeGreaterThanOrEqual(0); | |
106 | + expect(json.systemStatus.memoryUsage).toBeGreaterThanOrEqual(0); | |
107 | + }); | |
108 | + | |
109 | + it("returns 403 for non-admin user", async () => { | |
110 | + const res = await GET(ctx(false)); | |
111 | + expect(res.status).toBe(403); | |
112 | + }); | |
113 | + | |
114 | + it("returns 403 when no user", async () => { | |
115 | + const res = await GET({ locals: {} } as any); | |
116 | + expect(res.status).toBe(403); | |
117 | + }); | |
118 | + | |
119 | + it("returns quickStats", async () => { | |
120 | + const res = await GET(ctx(true)); | |
121 | + const json = await readJson(res); | |
122 | + expect(json.quickStats).toBeDefined(); | |
123 | + expect(typeof json.quickStats.commitsToday).toBe("number"); | |
124 | + expect(typeof json.quickStats.activeUsers).toBe("number"); | |
125 | + }); | |
126 | + | |
127 | + it("returns languages stats", async () => { | |
128 | + const res = await GET(ctx(true)); | |
129 | + const json = await readJson(res); | |
130 | + expect(json.languages).toBeDefined(); | |
131 | + expect(Array.isArray(json.languages)).toBe(true); | |
132 | + }); | |
133 | + | |
134 | + it("returns recentActivity", async () => { | |
135 | + const res = await GET(ctx(true)); | |
136 | + const json = await readJson(res); | |
137 | + expect(json.recentActivity).toBeDefined(); | |
138 | + expect(Array.isArray(json.recentActivity)).toBe(true); | |
139 | + }); | |
140 | +}); | |
0 | 141 | |
▼tests/integration/auth-login-route.test.ts
+197-0
| @@ -0,0 +1,197 @@ | ||
1 | +/** | |
2 | + * Integration tests for POST /api/auth/login | |
3 | + */ | |
4 | +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
5 | + | |
6 | +/* ─── hoisted mocks ─── */ | |
7 | +const mocks = vi.hoisted(() => ({ | |
8 | + applyRateLimitMock: vi.fn().mockResolvedValue(null), | |
9 | + verifyPasswordMock: vi.fn().mockResolvedValue(true), | |
10 | + verify2FATokenMock: vi.fn().mockReturnValue(true), | |
11 | + createSessionMock: vi.fn().mockResolvedValue({ | |
12 | + id: "sess_1", | |
13 | + userId: "usr_1", | |
14 | + token: "sesstoken", | |
15 | + userAgent: "vitest", | |
16 | + ipAddress: "127.0.0.1", | |
17 | + expiresAt: new Date(Date.now() + 86400000).toISOString(), | |
18 | + createdAt: new Date().toISOString(), | |
19 | + }), | |
20 | + createTokenMock: vi.fn().mockResolvedValue("jwt_token_xyz"), | |
21 | + insertMock: vi | |
22 | + .fn() | |
23 | + .mockReturnValue({ values: vi.fn().mockResolvedValue([]) }), | |
24 | + updateMock: vi.fn().mockReturnValue({ | |
25 | + set: vi.fn().mockReturnValue({ | |
26 | + where: vi.fn().mockResolvedValue([]), | |
27 | + }), | |
28 | + }), | |
29 | + fakeSchema: { users: "users", sessions: "sessions" }, | |
30 | +})); | |
31 | + | |
32 | +/* ─── module mocks ─── */ | |
33 | +vi.mock("@/db", () => ({ | |
34 | + getDatabase: () => mockDb, | |
35 | + schema: mocks.fakeSchema, | |
36 | +})); | |
37 | + | |
38 | +vi.mock("@/db/schema", () => ({ | |
39 | + users: "users", | |
40 | + sessions: "sessions", | |
41 | +})); | |
42 | + | |
43 | +vi.mock("@/middleware/rate-limit", () => ({ | |
44 | + applyRateLimit: mocks.applyRateLimitMock, | |
45 | +})); | |
46 | + | |
47 | +vi.mock("@/lib/auth", () => ({ | |
48 | + verifyPassword: mocks.verifyPasswordMock, | |
49 | + verify2FAToken: mocks.verify2FATokenMock, | |
50 | + createSession: mocks.createSessionMock, | |
51 | + createToken: mocks.createTokenMock, | |
52 | +})); | |
53 | + | |
54 | +vi.mock("@/lib/logger", () => ({ | |
55 | + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, | |
56 | +})); | |
57 | + | |
58 | +vi.mock("@/lib/errors", () => ({ | |
59 | + withErrorHandler: (fn: any) => fn, | |
60 | +})); | |
61 | + | |
62 | +vi.mock("drizzle-orm", () => ({ | |
63 | + eq: vi.fn((...a: any[]) => a), | |
64 | +})); | |
65 | + | |
66 | +/* ─── helpers ─── */ | |
67 | +function makeDb() { | |
68 | + return { | |
69 | + query: { | |
70 | + users: { | |
71 | + findFirst: vi.fn().mockResolvedValue({ | |
72 | + id: "usr_1", | |
73 | + username: "alice", | |
74 | + email: "[email protected]", | |
75 | + passwordHash: "hashed_pw", | |
76 | + isActive: true, | |
77 | + twoFactorEnabled: false, | |
78 | + twoFactorSecret: null, | |
79 | + displayName: "Alice", | |
80 | + avatarUrl: null, | |
81 | + isAdmin: false, | |
82 | + }), | |
83 | + }, | |
84 | + }, | |
85 | + insert: mocks.insertMock, | |
86 | + update: mocks.updateMock, | |
87 | + }; | |
88 | +} | |
89 | + | |
90 | +let mockDb: ReturnType<typeof makeDb>; | |
91 | +const readJson = (r: Response) => r.json(); | |
92 | + | |
93 | +function makeRequest(body: Record<string, unknown>) { | |
94 | + return { | |
95 | + request: new Request("http://localhost/api/auth/login", { | |
96 | + method: "POST", | |
97 | + headers: { "Content-Type": "application/json" }, | |
98 | + body: JSON.stringify(body), | |
99 | + }), | |
100 | + cookies: { set: vi.fn(), delete: vi.fn() }, | |
101 | + } as any; | |
102 | +} | |
103 | + | |
104 | +/* ─── import route ─── */ | |
105 | +import { POST } from "@/pages/api/auth/login"; | |
106 | + | |
107 | +/* ─── tests ─── */ | |
108 | +describe("POST /api/auth/login", () => { | |
109 | + beforeEach(() => { | |
110 | + vi.clearAllMocks(); | |
111 | + mockDb = makeDb(); | |
112 | + }); | |
113 | + | |
114 | + it("returns 200 with token for valid credentials", async () => { | |
115 | + const res = await POST( | |
116 | + makeRequest({ login: "alice", password: "pass1234" }), | |
117 | + ); | |
118 | + expect(res.status).toBe(200); | |
119 | + const json = await readJson(res); | |
120 | + expect(json.user.username).toBe("alice"); | |
121 | + expect(json.token).toBe("jwt_token_xyz"); | |
122 | + }); | |
123 | + | |
124 | + it("returns 401 when user not found", async () => { | |
125 | + mockDb.query.users.findFirst.mockResolvedValue(null); | |
126 | + const res = await POST(makeRequest({ login: "nobody", password: "pass" })); | |
127 | + expect(res.status).toBe(401); | |
128 | + }); | |
129 | + | |
130 | + it("returns 401 when account disabled", async () => { | |
131 | + mockDb.query.users.findFirst.mockResolvedValue({ | |
132 | + id: "usr_1", | |
133 | + username: "alice", | |
134 | + isActive: false, | |
135 | + passwordHash: "hashed", | |
136 | + }); | |
137 | + const res = await POST(makeRequest({ login: "alice", password: "pass" })); | |
138 | + expect(res.status).toBe(401); | |
139 | + }); | |
140 | + | |
141 | + it("returns 401 when password is wrong", async () => { | |
142 | + mocks.verifyPasswordMock.mockResolvedValue(false); | |
143 | + const res = await POST(makeRequest({ login: "alice", password: "wrong" })); | |
144 | + expect(res.status).toBe(401); | |
145 | + }); | |
146 | + | |
147 | + it("returns requiresTwoFactor when 2FA enabled but no code", async () => { | |
148 | + mockDb.query.users.findFirst.mockResolvedValue({ | |
149 | + id: "usr_1", | |
150 | + username: "alice", | |
151 | + passwordHash: "hashed", | |
152 | + isActive: true, | |
153 | + twoFactorEnabled: true, | |
154 | + twoFactorSecret: "secret", | |
155 | + }); | |
156 | + const res = await POST(makeRequest({ login: "alice", password: "pass" })); | |
157 | + expect(res.status).toBe(200); | |
158 | + const json = await readJson(res); | |
159 | + expect(json.requiresTwoFactor).toBe(true); | |
160 | + }); | |
161 | + | |
162 | + it("returns 401 when 2FA code is invalid", async () => { | |
163 | + mockDb.query.users.findFirst.mockResolvedValue({ | |
164 | + id: "usr_1", | |
165 | + username: "alice", | |
166 | + passwordHash: "hashed", | |
167 | + isActive: true, | |
168 | + twoFactorEnabled: true, | |
169 | + twoFactorSecret: "secret", | |
170 | + }); | |
171 | + mocks.verify2FATokenMock.mockReturnValue(false); | |
172 | + const res = await POST( | |
173 | + makeRequest({ login: "alice", password: "pass", totpCode: "000000" }), | |
174 | + ); | |
175 | + expect(res.status).toBe(401); | |
176 | + }); | |
177 | + | |
178 | + it("returns rate limit response when rate limited", async () => { | |
179 | + mocks.applyRateLimitMock.mockResolvedValue( | |
180 | + new Response(JSON.stringify({ error: "Rate limited" }), { status: 429 }), | |
181 | + ); | |
182 | + const res = await POST(makeRequest({ login: "alice", password: "pass" })); | |
183 | + expect(res.status).toBe(429); | |
184 | + }); | |
185 | + | |
186 | + it("returns 401 when user has no passwordHash", async () => { | |
187 | + mockDb.query.users.findFirst.mockResolvedValue({ | |
188 | + id: "usr_1", | |
189 | + username: "alice", | |
190 | + passwordHash: null, | |
191 | + isActive: true, | |
192 | + twoFactorEnabled: false, | |
193 | + }); | |
194 | + const res = await POST(makeRequest({ login: "alice", password: "pass" })); | |
195 | + expect(res.status).toBe(401); | |
196 | + }); | |
197 | +}); | |
0 | 198 | |
▼tests/integration/auth-me-route.test.ts
+172-0
| @@ -0,0 +1,172 @@ | ||
1 | +/** | |
2 | + * Integration tests for GET / PATCH / DELETE /api/auth/me | |
3 | + */ | |
4 | +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
5 | + | |
6 | +/* ─── hoisted mocks ─── */ | |
7 | +const mocks = vi.hoisted(() => ({ | |
8 | + getUserFromRequestMock: vi.fn().mockResolvedValue({ | |
9 | + userId: "usr_1", | |
10 | + username: "alice", | |
11 | + email: "[email protected]", | |
12 | + }), | |
13 | + updateMock: vi.fn().mockReturnValue({ | |
14 | + set: vi.fn().mockReturnValue({ | |
15 | + where: vi.fn().mockResolvedValue([]), | |
16 | + }), | |
17 | + }), | |
18 | + deleteMock: vi.fn().mockReturnValue({ | |
19 | + where: vi.fn().mockResolvedValue([]), | |
20 | + }), | |
21 | + logAuditMock: vi.fn().mockResolvedValue(undefined), | |
22 | + getRequestMetaMock: vi | |
23 | + .fn() | |
24 | + .mockReturnValue({ ip: "127.0.0.1", userAgent: "vitest" }), | |
25 | + fakeSchema: { users: "users" }, | |
26 | +})); | |
27 | + | |
28 | +/* ─── module mocks ─── */ | |
29 | +vi.mock("@/db", () => ({ | |
30 | + getDatabase: () => mockDb, | |
31 | + schema: mocks.fakeSchema, | |
32 | +})); | |
33 | + | |
34 | +vi.mock("@/db/schema", () => ({ | |
35 | + users: "users", | |
36 | +})); | |
37 | + | |
38 | +vi.mock("@/lib/auth", () => ({ | |
39 | + getUserFromRequest: mocks.getUserFromRequestMock, | |
40 | +})); | |
41 | + | |
42 | +vi.mock("@/lib/audit", () => ({ | |
43 | + logAudit: mocks.logAuditMock, | |
44 | + getRequestMeta: mocks.getRequestMetaMock, | |
45 | +})); | |
46 | + | |
47 | +vi.mock("@/lib/logger", () => ({ | |
48 | + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, | |
49 | +})); | |
50 | + | |
51 | +vi.mock("@/lib/errors", () => ({ | |
52 | + withErrorHandler: (fn: any) => fn, | |
53 | +})); | |
54 | + | |
55 | +vi.mock("drizzle-orm", () => ({ | |
56 | + eq: vi.fn((...a: any[]) => a), | |
57 | +})); | |
58 | + | |
59 | +/* ─── helpers ─── */ | |
60 | +const fakeUser = { | |
61 | + id: "usr_1", | |
62 | + username: "alice", | |
63 | + email: "[email protected]", | |
64 | + displayName: "Alice", | |
65 | + bio: "Developer", | |
66 | + avatarUrl: null, | |
67 | + location: "NYC", | |
68 | + website: "", | |
69 | + company: "ACME", | |
70 | + isAdmin: false, | |
71 | + emailVerified: true, | |
72 | + twoFactorEnabled: false, | |
73 | + createdAt: new Date(), | |
74 | +}; | |
75 | + | |
76 | +function makeDb() { | |
77 | + return { | |
78 | + query: { | |
79 | + users: { | |
80 | + findFirst: vi.fn().mockResolvedValue(fakeUser), | |
81 | + }, | |
82 | + }, | |
83 | + update: mocks.updateMock, | |
84 | + delete: mocks.deleteMock, | |
85 | + }; | |
86 | +} | |
87 | + | |
88 | +let mockDb: ReturnType<typeof makeDb>; | |
89 | +const readJson = (r: Response) => r.json(); | |
90 | + | |
91 | +function ctx(method: string, body?: Record<string, unknown>) { | |
92 | + const init: RequestInit = { | |
93 | + method, | |
94 | + headers: { "Content-Type": "application/json" }, | |
95 | + }; | |
96 | + if (body) init.body = JSON.stringify(body); | |
97 | + return { | |
98 | + request: new Request("http://localhost/api/auth/me", init), | |
99 | + cookies: { set: vi.fn(), delete: vi.fn() }, | |
100 | + } as any; | |
101 | +} | |
102 | + | |
103 | +/* ─── import routes ─── */ | |
104 | +import { DELETE, GET, PATCH } from "@/pages/api/auth/me"; | |
105 | + | |
106 | +/* ─── tests ─── */ | |
107 | +describe("GET /api/auth/me", () => { | |
108 | + beforeEach(() => { | |
109 | + vi.clearAllMocks(); | |
110 | + mockDb = makeDb(); | |
111 | + }); | |
112 | + | |
113 | + it("returns 200 with user profile", async () => { | |
114 | + const res = await GET(ctx("GET")); | |
115 | + expect(res.status).toBe(200); | |
116 | + const json = await readJson(res); | |
117 | + expect(json.username).toBe("alice"); | |
118 | + expect(json.email).toBe("[email protected]"); | |
119 | + }); | |
120 | + | |
121 | + it("returns 401 when not authenticated", async () => { | |
122 | + mocks.getUserFromRequestMock.mockResolvedValue(null); | |
123 | + const res = await GET(ctx("GET")); | |
124 | + expect(res.status).toBe(401); | |
125 | + }); | |
126 | + | |
127 | + it("returns 404 when user no longer exists", async () => { | |
128 | + mockDb.query.users.findFirst.mockResolvedValue(null); | |
129 | + const res = await GET(ctx("GET")); | |
130 | + expect(res.status).toBe(404); | |
131 | + }); | |
132 | +}); | |
133 | + | |
134 | +describe("PATCH /api/auth/me", () => { | |
135 | + beforeEach(() => { | |
136 | + vi.clearAllMocks(); | |
137 | + mockDb = makeDb(); | |
138 | + }); | |
139 | + | |
140 | + it("returns 200 when profile updated", async () => { | |
141 | + const res = await PATCH(ctx("PATCH", { displayName: "Alice W" })); | |
142 | + expect(res.status).toBe(200); | |
143 | + const json = await readJson(res); | |
144 | + expect(json.message).toContain("updated"); | |
145 | + }); | |
146 | + | |
147 | + it("returns 401 when not authenticated", async () => { | |
148 | + mocks.getUserFromRequestMock.mockResolvedValue(null); | |
149 | + const res = await PATCH(ctx("PATCH", { displayName: "x" })); | |
150 | + expect(res.status).toBe(401); | |
151 | + }); | |
152 | +}); | |
153 | + | |
154 | +describe("DELETE /api/auth/me", () => { | |
155 | + beforeEach(() => { | |
156 | + vi.clearAllMocks(); | |
157 | + mockDb = makeDb(); | |
158 | + }); | |
159 | + | |
160 | + it("returns 200 and deletes account", async () => { | |
161 | + const res = await DELETE(ctx("DELETE")); | |
162 | + expect(res.status).toBe(200); | |
163 | + const json = await readJson(res); | |
164 | + expect(json.message).toContain("deleted"); | |
165 | + }); | |
166 | + | |
167 | + it("returns 401 when not authenticated", async () => { | |
168 | + mocks.getUserFromRequestMock.mockResolvedValue(null); | |
169 | + const res = await DELETE(ctx("DELETE")); | |
170 | + expect(res.status).toBe(401); | |
171 | + }); | |
172 | +}); | |
0 | 173 | |
▼tests/integration/auth-register-route.test.ts
+208-0
| @@ -0,0 +1,208 @@ | ||
1 | +/** | |
2 | + * Integration tests for POST /api/auth/register | |
3 | + */ | |
4 | +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
5 | + | |
6 | +/* ─── hoisted mocks ─── */ | |
7 | +const mocks = vi.hoisted(() => ({ | |
8 | + applyRateLimitMock: vi.fn().mockResolvedValue(null), | |
9 | + hashPasswordMock: vi.fn().mockResolvedValue("hashed_pw_123"), | |
10 | + createSessionMock: vi.fn().mockResolvedValue({ | |
11 | + id: "sess_1", | |
12 | + userId: "usr_new", | |
13 | + token: "sesstoken", | |
14 | + userAgent: "vitest", | |
15 | + ipAddress: null, | |
16 | + expiresAt: new Date(Date.now() + 86400000).toISOString(), | |
17 | + createdAt: new Date().toISOString(), | |
18 | + }), | |
19 | + createTokenMock: vi.fn().mockResolvedValue("jwt_register_token"), | |
20 | + generateIdMock: vi.fn().mockReturnValue("usr_new"), | |
21 | + isValidUsernameMock: vi.fn().mockReturnValue(true), | |
22 | + isValidEmailMock: vi.fn().mockReturnValue(true), | |
23 | + insertMock: vi | |
24 | + .fn() | |
25 | + .mockReturnValue({ values: vi.fn().mockResolvedValue([]) }), | |
26 | + fakeSchema: { users: "users", sessions: "sessions" }, | |
27 | +})); | |
28 | + | |
29 | +/* ─── module mocks ─── */ | |
30 | +vi.mock("@/db", () => ({ | |
31 | + getDatabase: () => mockDb, | |
32 | + schema: mocks.fakeSchema, | |
33 | +})); | |
34 | + | |
35 | +vi.mock("@/db/schema", () => ({ | |
36 | + users: "users", | |
37 | + sessions: "sessions", | |
38 | +})); | |
39 | + | |
40 | +vi.mock("@/middleware/rate-limit", () => ({ | |
41 | + applyRateLimit: mocks.applyRateLimitMock, | |
42 | +})); | |
43 | + | |
44 | +vi.mock("@/lib/auth", () => ({ | |
45 | + hashPassword: mocks.hashPasswordMock, | |
46 | + createSession: mocks.createSessionMock, | |
47 | + createToken: mocks.createTokenMock, | |
48 | +})); | |
49 | + | |
50 | +vi.mock("@/lib/utils", () => ({ | |
51 | + generateId: mocks.generateIdMock, | |
52 | + isValidUsername: mocks.isValidUsernameMock, | |
53 | + isValidEmail: mocks.isValidEmailMock, | |
54 | + now: () => new Date(), | |
55 | +})); | |
56 | + | |
57 | +vi.mock("@/lib/validation", () => ({ | |
58 | + RegisterUserSchema: { | |
59 | + safeParse: vi.fn().mockReturnValue({ success: true }), | |
60 | + }, | |
61 | +})); | |
62 | + | |
63 | +vi.mock("@/lib/logger", () => ({ | |
64 | + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, | |
65 | +})); | |
66 | + | |
67 | +vi.mock("@/lib/errors", () => ({ | |
68 | + withErrorHandler: (fn: any) => fn, | |
69 | +})); | |
70 | + | |
71 | +vi.mock("drizzle-orm", () => ({ | |
72 | + eq: vi.fn((...a: any[]) => a), | |
73 | +})); | |
74 | + | |
75 | +/* ─── helpers ─── */ | |
76 | +function makeDb() { | |
77 | + return { | |
78 | + query: { | |
79 | + users: { | |
80 | + findFirst: vi.fn().mockResolvedValue(null), // no existing user | |
81 | + }, | |
82 | + }, | |
83 | + insert: mocks.insertMock, | |
84 | + }; | |
85 | +} | |
86 | + | |
87 | +let mockDb: ReturnType<typeof makeDb>; | |
88 | +const readJson = (r: Response) => r.json(); | |
89 | + | |
90 | +function makeRequest(body: Record<string, unknown>) { | |
91 | + return { | |
92 | + request: new Request("http://localhost/api/auth/register", { | |
93 | + method: "POST", | |
94 | + headers: { "Content-Type": "application/json" }, | |
95 | + body: JSON.stringify(body), | |
96 | + }), | |
97 | + cookies: { set: vi.fn(), delete: vi.fn() }, | |
98 | + } as any; | |
99 | +} | |
100 | + | |
101 | +/* ─── import route ─── */ | |
102 | +import { POST } from "@/pages/api/auth/register"; | |
103 | + | |
104 | +/* ─── tests ─── */ | |
105 | +describe("POST /api/auth/register", () => { | |
106 | + beforeEach(() => { | |
107 | + vi.clearAllMocks(); | |
108 | + mockDb = makeDb(); | |
109 | + delete process.env.ENABLE_REGISTRATION; | |
110 | + }); | |
111 | + | |
112 | + it("returns 201 with token for valid registration", async () => { | |
113 | + const res = await POST( | |
114 | + makeRequest({ | |
115 | + username: "newuser", | |
116 | + email: "[email protected]", | |
117 | + password: "password123", | |
118 | + displayName: "New User", | |
119 | + }), | |
120 | + ); | |
121 | + expect(res.status).toBe(201); | |
122 | + const json = await readJson(res); | |
123 | + expect(json.user.username).toBe("newuser"); | |
124 | + expect(json.token).toBe("jwt_register_token"); | |
125 | + }); | |
126 | + | |
127 | + it("returns 400 when registration is disabled", async () => { | |
128 | + process.env.ENABLE_REGISTRATION = "false"; | |
129 | + const res = await POST( | |
130 | + makeRequest({ | |
131 | + username: "newuser", | |
132 | + email: "[email protected]", | |
133 | + password: "password123", | |
134 | + }), | |
135 | + ); | |
136 | + expect(res.status).toBe(400); | |
137 | + }); | |
138 | + | |
139 | + it("returns 409 when username already taken", async () => { | |
140 | + mockDb.query.users.findFirst.mockResolvedValue({ | |
141 | + id: "usr_1", | |
142 | + username: "newuser", | |
143 | + email: "[email protected]", | |
144 | + }); | |
145 | + const res = await POST( | |
146 | + makeRequest({ | |
147 | + username: "newuser", | |
148 | + email: "[email protected]", | |
149 | + password: "password123", | |
150 | + }), | |
151 | + ); | |
152 | + expect(res.status).toBe(409); | |
153 | + }); | |
154 | + | |
155 | + it("returns 409 when email already registered", async () => { | |
156 | + mockDb.query.users.findFirst.mockResolvedValue({ | |
157 | + id: "usr_1", | |
158 | + username: "other", | |
159 | + email: "[email protected]", | |
160 | + }); | |
161 | + const res = await POST( | |
162 | + makeRequest({ | |
163 | + username: "newuser", | |
164 | + email: "[email protected]", | |
165 | + password: "password123", | |
166 | + }), | |
167 | + ); | |
168 | + expect(res.status).toBe(409); | |
169 | + }); | |
170 | + | |
171 | + it("returns 400 when username format invalid", async () => { | |
172 | + mocks.isValidUsernameMock.mockReturnValue(false); | |
173 | + const res = await POST( | |
174 | + makeRequest({ | |
175 | + username: "bad user!", | |
176 | + email: "[email protected]", | |
177 | + password: "password123", | |
178 | + }), | |
179 | + ); | |
180 | + expect(res.status).toBe(400); | |
181 | + }); | |
182 | + | |
183 | + it("returns 400 when email format invalid", async () => { | |
184 | + mocks.isValidEmailMock.mockReturnValue(false); | |
185 | + const res = await POST( | |
186 | + makeRequest({ | |
187 | + username: "newuser", | |
188 | + email: "not-an-email", | |
189 | + password: "password123", | |
190 | + }), | |
191 | + ); | |
192 | + expect(res.status).toBe(400); | |
193 | + }); | |
194 | + | |
195 | + it("returns rate limit response when rate limited", async () => { | |
196 | + mocks.applyRateLimitMock.mockResolvedValue( | |
197 | + new Response(JSON.stringify({ error: "Rate limited" }), { status: 429 }), | |
198 | + ); | |
199 | + const res = await POST( | |
200 | + makeRequest({ | |
201 | + username: "newuser", | |
202 | + email: "[email protected]", | |
203 | + password: "password123", | |
204 | + }), | |
205 | + ); | |
206 | + expect(res.status).toBe(429); | |
207 | + }); | |
208 | +}); | |
0 | 209 | |
▼tests/integration/issues-releases-route.test.ts
+322-0
| @@ -0,0 +1,322 @@ | ||
1 | +/** | |
2 | + * Integration tests for POST /api/repos/[owner]/[repo]/issues and GET/POST /api/repos/[owner]/[repo]/releases | |
3 | + */ | |
4 | +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
5 | + | |
6 | +/* ─── hoisted mocks ─── */ | |
7 | +const mocks = vi.hoisted(() => ({ | |
8 | + getUserFromRequestMock: vi.fn().mockResolvedValue({ | |
9 | + userId: "usr_1", | |
10 | + username: "alice", | |
11 | + email: "[email protected]", | |
12 | + }), | |
13 | + canReadRepoMock: vi.fn().mockResolvedValue(true), | |
14 | + canWriteRepoMock: vi.fn().mockResolvedValue(true), | |
15 | + generateIdMock: vi.fn().mockReturnValue("issue_new"), | |
16 | + logActivityMock: vi.fn().mockResolvedValue(undefined), | |
17 | + autoLinkCrossRepoIssuesMock: vi.fn().mockResolvedValue(undefined), | |
18 | + insertMock: vi | |
19 | + .fn() | |
20 | + .mockReturnValue({ values: vi.fn().mockResolvedValue([]) }), | |
21 | + updateMock: vi.fn().mockReturnValue({ | |
22 | + set: vi.fn().mockReturnValue({ | |
23 | + where: vi.fn().mockResolvedValue([]), | |
24 | + }), | |
25 | + }), | |
26 | + selectMock: vi.fn(), | |
27 | + fakeSchema: { | |
28 | + users: { username: "username" }, | |
29 | + repositories: { | |
30 | + ownerId: "ownerId", | |
31 | + name: "name", | |
32 | + openIssueCount: "openIssueCount", | |
33 | + id: "id", | |
34 | + }, | |
35 | + issues: { repositoryId: "repositoryId", number: "number" }, | |
36 | + releases: { | |
37 | + repositoryId: "repositoryId", | |
38 | + id: "id", | |
39 | + createdAt: "createdAt", | |
40 | + }, | |
41 | + tags: { repositoryId: "repositoryId", name: "name" }, | |
42 | + }, | |
43 | +})); | |
44 | + | |
45 | +vi.mock("@/db", () => ({ | |
46 | + getDatabase: () => mockDb, | |
47 | + schema: mocks.fakeSchema, | |
48 | +})); | |
49 | + | |
50 | +vi.mock("@/db/schema", () => ({ | |
51 | + issues: mocks.fakeSchema.issues, | |
52 | + repositories: mocks.fakeSchema.repositories, | |
53 | +})); | |
54 | + | |
55 | +vi.mock("@/lib/auth", () => ({ | |
56 | + getUserFromRequest: mocks.getUserFromRequestMock, | |
57 | +})); | |
58 | + | |
59 | +vi.mock("@/lib/permissions", () => ({ | |
60 | + canReadRepo: mocks.canReadRepoMock, | |
61 | + canWriteRepo: mocks.canWriteRepoMock, | |
62 | +})); | |
63 | + | |
64 | +vi.mock("@/lib/utils", () => ({ | |
65 | + generateId: mocks.generateIdMock, | |
66 | + now: () => new Date(), | |
67 | +})); | |
68 | + | |
69 | +vi.mock("@/lib/activity", () => ({ | |
70 | + logActivity: mocks.logActivityMock, | |
71 | +})); | |
72 | + | |
73 | +vi.mock("@/lib/cross-repo-issues", () => ({ | |
74 | + autoLinkCrossRepoIssues: mocks.autoLinkCrossRepoIssuesMock, | |
75 | +})); | |
76 | + | |
77 | +vi.mock("@/lib/errors", () => ({ | |
78 | + withErrorHandler: (fn: any) => fn, | |
79 | +})); | |
80 | + | |
81 | +vi.mock("@/lib/logger", () => ({ | |
82 | + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, | |
83 | +})); | |
84 | + | |
85 | +vi.mock("@/lib/email", () => ({ | |
86 | + sendIssueEmail: vi.fn().mockResolvedValue(undefined), | |
87 | +})); | |
88 | + | |
89 | +vi.mock("drizzle-orm", () => ({ | |
90 | + eq: vi.fn((...a: any[]) => a), | |
91 | + and: vi.fn((...a: any[]) => a), | |
92 | + desc: vi.fn(), | |
93 | + sql: vi.fn(), | |
94 | +})); | |
95 | + | |
96 | +/* ─── helpers ─── */ | |
97 | +const fakeOwner = { | |
98 | + id: "usr_owner", | |
99 | + username: "owner", | |
100 | + email: "[email protected]", | |
101 | +}; | |
102 | +const fakeRepo = { | |
103 | + id: "repo_1", | |
104 | + name: "my-repo", | |
105 | + ownerId: "usr_owner", | |
106 | + isPrivate: false, | |
107 | + openIssueCount: 3, | |
108 | +}; | |
109 | + | |
110 | +function makeDb() { | |
111 | + return { | |
112 | + query: { | |
113 | + users: { findFirst: vi.fn().mockResolvedValue(fakeOwner) }, | |
114 | + repositories: { findFirst: vi.fn().mockResolvedValue(fakeRepo) }, | |
115 | + issues: { | |
116 | + findFirst: vi.fn().mockResolvedValue({ id: "issue_1", number: 5 }), | |
117 | + }, | |
118 | + releases: { | |
119 | + findFirst: vi.fn().mockResolvedValue({ | |
120 | + id: "rel_1", | |
121 | + name: "v1.0", | |
122 | + repositoryId: "repo_1", | |
123 | + isDraft: false, | |
124 | + }), | |
125 | + }, | |
126 | + tags: { | |
127 | + findFirst: vi.fn().mockResolvedValue({ id: "tag_1", name: "v1.0" }), | |
128 | + }, | |
129 | + }, | |
130 | + insert: mocks.insertMock, | |
131 | + update: mocks.updateMock, | |
132 | + select: mocks.selectMock.mockReturnValue({ | |
133 | + from: vi.fn().mockReturnValue({ | |
134 | + where: vi.fn().mockReturnValue({ | |
135 | + orderBy: vi.fn().mockResolvedValue([ | |
136 | + { id: "rel_1", name: "v1.0", isDraft: false }, | |
137 | + { id: "rel_2", name: "v1.1-beta", isDraft: true }, | |
138 | + ]), | |
139 | + }), | |
140 | + }), | |
141 | + }), | |
142 | + }; | |
143 | +} | |
144 | + | |
145 | +let mockDb: ReturnType<typeof makeDb>; | |
146 | +const readJson = (r: Response) => r.json(); | |
147 | + | |
148 | +/* ─── import routes ─── */ | |
149 | +import { POST as createIssue } from "@/pages/api/repos/[owner]/[repo]/issues/index"; | |
150 | +import { | |
151 | + POST as createRelease, | |
152 | + GET as listReleases, | |
153 | +} from "@/pages/api/repos/[owner]/[repo]/releases/index"; | |
154 | + | |
155 | +/* ─── Issue tests ─── */ | |
156 | +describe("POST /api/repos/[owner]/[repo]/issues", () => { | |
157 | + beforeEach(() => { | |
158 | + vi.clearAllMocks(); | |
159 | + mockDb = makeDb(); | |
160 | + }); | |
161 | + | |
162 | + it("returns 201 when issue created", async () => { | |
163 | + const res = await createIssue({ | |
164 | + request: new Request("http://localhost/api/repos/owner/my-repo/issues", { | |
165 | + method: "POST", | |
166 | + headers: { "Content-Type": "application/json" }, | |
167 | + body: JSON.stringify({ | |
168 | + title: "Bug report", | |
169 | + body: "Something is broken", | |
170 | + }), | |
171 | + }), | |
172 | + params: { owner: "owner", repo: "my-repo" }, | |
173 | + } as any); | |
174 | + expect(res.status).toBe(201); | |
175 | + const json = await readJson(res); | |
176 | + expect(json.title).toBe("Bug report"); | |
177 | + expect(json.number).toBe(6); | |
178 | + }); | |
179 | + | |
180 | + it("returns 401 when not authenticated", async () => { | |
181 | + mocks.getUserFromRequestMock.mockResolvedValue(null); | |
182 | + const res = await createIssue({ | |
183 | + request: new Request("http://localhost/api/repos/owner/my-repo/issues", { | |
184 | + method: "POST", | |
185 | + headers: { "Content-Type": "application/json" }, | |
186 | + body: JSON.stringify({ title: "Bug" }), | |
187 | + }), | |
188 | + params: { owner: "owner", repo: "my-repo" }, | |
189 | + } as any); | |
190 | + expect(res.status).toBe(401); | |
191 | + }); | |
192 | + | |
193 | + it("returns 404 when owner not found", async () => { | |
194 | + mockDb.query.users.findFirst.mockResolvedValue(null); | |
195 | + const res = await createIssue({ | |
196 | + request: new Request("http://localhost/api/repos/no-one/my-repo/issues", { | |
197 | + method: "POST", | |
198 | + headers: { "Content-Type": "application/json" }, | |
199 | + body: JSON.stringify({ title: "Bug" }), | |
200 | + }), | |
201 | + params: { owner: "no-one", repo: "my-repo" }, | |
202 | + } as any); | |
203 | + expect(res.status).toBe(404); | |
204 | + }); | |
205 | + | |
206 | + it("returns 404 when repo not found", async () => { | |
207 | + mockDb.query.repositories.findFirst.mockResolvedValue(null); | |
208 | + const res = await createIssue({ | |
209 | + request: new Request("http://localhost/api/repos/owner/no-repo/issues", { | |
210 | + method: "POST", | |
211 | + headers: { "Content-Type": "application/json" }, | |
212 | + body: JSON.stringify({ title: "Bug" }), | |
213 | + }), | |
214 | + params: { owner: "owner", repo: "no-repo" }, | |
215 | + } as any); | |
216 | + expect(res.status).toBe(404); | |
217 | + }); | |
218 | + | |
219 | + it("returns 404 when user has no read permission", async () => { | |
220 | + mocks.canReadRepoMock.mockResolvedValue(false); | |
221 | + const res = await createIssue({ | |
222 | + request: new Request("http://localhost/api/repos/owner/my-repo/issues", { | |
223 | + method: "POST", | |
224 | + headers: { "Content-Type": "application/json" }, | |
225 | + body: JSON.stringify({ title: "Bug" }), | |
226 | + }), | |
227 | + params: { owner: "owner", repo: "my-repo" }, | |
228 | + } as any); | |
229 | + expect(res.status).toBe(404); | |
230 | + }); | |
231 | +}); | |
232 | + | |
233 | +/* ─── Release tests ─── */ | |
234 | +describe("GET /api/repos/[owner]/[repo]/releases", () => { | |
235 | + beforeEach(() => { | |
236 | + vi.clearAllMocks(); | |
237 | + mockDb = makeDb(); | |
238 | + }); | |
239 | + | |
240 | + it("returns 200 with releases list", async () => { | |
241 | + const res = await listReleases({ | |
242 | + params: { owner: "owner", repo: "my-repo" }, | |
243 | + request: new Request("http://localhost/api/repos/owner/my-repo/releases"), | |
244 | + } as any); | |
245 | + expect(res.status).toBe(200); | |
246 | + }); | |
247 | + | |
248 | + it("filters drafts for non-writers", async () => { | |
249 | + mocks.canWriteRepoMock.mockResolvedValue(false); | |
250 | + const res = await listReleases({ | |
251 | + params: { owner: "owner", repo: "my-repo" }, | |
252 | + request: new Request("http://localhost/api/repos/owner/my-repo/releases"), | |
253 | + } as any); | |
254 | + expect(res.status).toBe(200); | |
255 | + const json = await readJson(res); | |
256 | + const drafts = json.filter((r: any) => r.isDraft); | |
257 | + expect(drafts.length).toBe(0); | |
258 | + }); | |
259 | + | |
260 | + it("returns 404 when repo not found", async () => { | |
261 | + mockDb.query.repositories.findFirst.mockResolvedValue(null); | |
262 | + const res = await listReleases({ | |
263 | + params: { owner: "owner", repo: "no-repo" }, | |
264 | + request: new Request("http://localhost/api/repos/owner/no-repo/releases"), | |
265 | + } as any); | |
266 | + expect(res.status).toBe(404); | |
267 | + }); | |
268 | +}); | |
269 | + | |
270 | +describe("POST /api/repos/[owner]/[repo]/releases", () => { | |
271 | + beforeEach(() => { | |
272 | + vi.clearAllMocks(); | |
273 | + mockDb = makeDb(); | |
274 | + }); | |
275 | + | |
276 | + it("returns 200 when release created", async () => { | |
277 | + const res = await createRelease({ | |
278 | + params: { owner: "owner", repo: "my-repo" }, | |
279 | + request: new Request( | |
280 | + "http://localhost/api/repos/owner/my-repo/releases", | |
281 | + { | |
282 | + method: "POST", | |
283 | + headers: { "Content-Type": "application/json" }, | |
284 | + body: JSON.stringify({ tagName: "v1.0", name: "Release 1.0" }), | |
285 | + }, | |
286 | + ), | |
287 | + } as any); | |
288 | + expect(res.status).toBe(200); | |
289 | + }); | |
290 | + | |
291 | + it("returns 401 when not authenticated", async () => { | |
292 | + mocks.getUserFromRequestMock.mockResolvedValue(null); | |
293 | + const res = await createRelease({ | |
294 | + params: { owner: "owner", repo: "my-repo" }, | |
295 | + request: new Request( | |
296 | + "http://localhost/api/repos/owner/my-repo/releases", | |
297 | + { | |
298 | + method: "POST", | |
299 | + headers: { "Content-Type": "application/json" }, | |
300 | + body: JSON.stringify({ tagName: "v1.0", name: "Release 1.0" }), | |
301 | + }, | |
302 | + ), | |
303 | + } as any); | |
304 | + expect(res.status).toBe(401); | |
305 | + }); | |
306 | + | |
307 | + it("returns 403 when user cannot write", async () => { | |
308 | + mocks.canWriteRepoMock.mockResolvedValue(false); | |
309 | + const res = await createRelease({ | |
310 | + params: { owner: "owner", repo: "my-repo" }, | |
311 | + request: new Request( | |
312 | + "http://localhost/api/repos/owner/my-repo/releases", | |
313 | + { | |
314 | + method: "POST", | |
315 | + headers: { "Content-Type": "application/json" }, | |
316 | + body: JSON.stringify({ tagName: "v1.0", name: "Release 1.0" }), | |
317 | + }, | |
318 | + ), | |
319 | + } as any); | |
320 | + expect(res.status).toBe(403); | |
321 | + }); | |
322 | +}); | |
0 | 323 | |
▼tests/integration/notifications-route.test.ts
+177-0
| @@ -0,0 +1,177 @@ | ||
1 | +/** | |
2 | + * Integration tests for GET /api/notifications | |
3 | + */ | |
4 | +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
5 | + | |
6 | +/* ─── hoisted mocks ─── */ | |
7 | +const mocks = vi.hoisted(() => ({ | |
8 | + getUserFromRequestMock: vi.fn().mockResolvedValue({ | |
9 | + userId: "usr_1", | |
10 | + username: "alice", | |
11 | + }), | |
12 | + scoreNotificationPriorityMock: vi.fn().mockReturnValue({ | |
13 | + score: 5, | |
14 | + priority: "medium", | |
15 | + isBlocking: false, | |
16 | + }), | |
17 | + getRoutingDecisionMock: vi.fn().mockReturnValue({ | |
18 | + channels: ["in_app"], | |
19 | + primaryChannel: "in_app", | |
20 | + }), | |
21 | + channelEnabledMock: vi.fn().mockReturnValue(true), | |
22 | + computePersonalizationBoostMock: vi.fn().mockReturnValue(0), | |
23 | + fakeSchema: { | |
24 | + notifications: "notifications", | |
25 | + notificationPreferences: { | |
26 | + userId: "userId", | |
27 | + repositoryId: "repositoryId", | |
28 | + }, | |
29 | + }, | |
30 | +})); | |
31 | + | |
32 | +vi.mock("@/db", () => ({ | |
33 | + getDatabase: () => mockDb, | |
34 | + schema: mocks.fakeSchema, | |
35 | +})); | |
36 | + | |
37 | +vi.mock("@/db/schema", () => ({ | |
38 | + notifications: "notifications", | |
39 | +})); | |
40 | + | |
41 | +vi.mock("@/lib/auth", () => ({ | |
42 | + getUserFromRequest: mocks.getUserFromRequestMock, | |
43 | +})); | |
44 | + | |
45 | +vi.mock("@/lib/notification-priority", () => ({ | |
46 | + scoreNotificationPriority: mocks.scoreNotificationPriorityMock, | |
47 | +})); | |
48 | + | |
49 | +vi.mock("@/lib/notification-routing", () => ({ | |
50 | + getRoutingDecision: mocks.getRoutingDecisionMock, | |
51 | + channelEnabled: mocks.channelEnabledMock, | |
52 | + computePersonalizationBoost: mocks.computePersonalizationBoostMock, | |
53 | +})); | |
54 | + | |
55 | +vi.mock("drizzle-orm", () => ({ | |
56 | + eq: vi.fn(), | |
57 | + desc: vi.fn(), | |
58 | + and: vi.fn((...args: any[]) => args), | |
59 | + or: vi.fn((...args: any[]) => args), | |
60 | + isNull: vi.fn(), | |
61 | +})); | |
62 | + | |
63 | +/* ─── helpers ─── */ | |
64 | +const fakeNotification = { | |
65 | + id: "notif_1", | |
66 | + userId: "usr_1", | |
67 | + type: "comment", | |
68 | + reason: "mention", | |
69 | + isRead: false, | |
70 | + isArchived: false, | |
71 | + createdAt: new Date().toISOString(), | |
72 | + actor: { id: "usr_2", username: "bob", displayName: "Bob", avatarUrl: null }, | |
73 | + repository: { id: "repo_1", name: "my-repo", slug: "my-repo" }, | |
74 | +}; | |
75 | + | |
76 | +function makeDb() { | |
77 | + return { | |
78 | + query: { | |
79 | + notifications: { | |
80 | + findMany: vi.fn().mockResolvedValue([fakeNotification]), | |
81 | + }, | |
82 | + notificationPreferences: { | |
83 | + findMany: vi.fn().mockResolvedValue([]), | |
84 | + }, | |
85 | + }, | |
86 | + }; | |
87 | +} | |
88 | + | |
89 | +let mockDb: ReturnType<typeof makeDb>; | |
90 | +const readJson = (r: Response) => r.json(); | |
91 | + | |
92 | +function ctx(params = "") { | |
93 | + const reqUrl = `http://localhost/api/notifications${params}`; | |
94 | + return { | |
95 | + request: new Request(reqUrl), | |
96 | + url: new URL(reqUrl), | |
97 | + } as any; | |
98 | +} | |
99 | + | |
100 | +/* ─── import route ─── */ | |
101 | +import { GET } from "@/pages/api/notifications/index"; | |
102 | + | |
103 | +/* ─── tests ─── */ | |
104 | +describe("GET /api/notifications", () => { | |
105 | + beforeEach(() => { | |
106 | + vi.clearAllMocks(); | |
107 | + mockDb = makeDb(); | |
108 | + }); | |
109 | + | |
110 | + it("returns 200 with notifications for authenticated user", async () => { | |
111 | + const res = await GET(ctx()); | |
112 | + expect(res.status).toBe(200); | |
113 | + const json = await readJson(res); | |
114 | + expect(json.notifications).toBeDefined(); | |
115 | + expect(json.notifications.length).toBe(1); | |
116 | + expect(json.unreadCount).toBeDefined(); | |
117 | + }); | |
118 | + | |
119 | + it("returns 401 when not authenticated", async () => { | |
120 | + mocks.getUserFromRequestMock.mockResolvedValue(null); | |
121 | + const res = await GET(ctx()); | |
122 | + expect(res.status).toBe(401); | |
123 | + }); | |
124 | + | |
125 | + it("enriches notifications with priority", async () => { | |
126 | + const res = await GET(ctx()); | |
127 | + const json = await readJson(res); | |
128 | + expect(json.notifications[0].priority).toBe("medium"); | |
129 | + expect(json.notifications[0].priorityScore).toBeGreaterThanOrEqual(0); | |
130 | + }); | |
131 | + | |
132 | + it("enriches with routing info", async () => { | |
133 | + const res = await GET(ctx()); | |
134 | + const json = await readJson(res); | |
135 | + expect(json.notifications[0].routeChannels).toEqual(["in_app"]); | |
136 | + expect(json.notifications[0].primaryRouteChannel).toBe("in_app"); | |
137 | + }); | |
138 | + | |
139 | + it("filters by channel when param provided", async () => { | |
140 | + mocks.channelEnabledMock.mockReturnValue(false); | |
141 | + const res = await GET(ctx("?channel=email")); | |
142 | + const json = await readJson(res); | |
143 | + expect(json.notifications.length).toBe(0); | |
144 | + }); | |
145 | + | |
146 | + it("supports prioritize param", async () => { | |
147 | + // add two notifs with different scores | |
148 | + const high = { ...fakeNotification, id: "notif_h" }; | |
149 | + const low = { ...fakeNotification, id: "notif_l" }; | |
150 | + mockDb.query.notifications.findMany | |
151 | + .mockResolvedValueOnce([low, high]) | |
152 | + .mockResolvedValueOnce([low, high]) // history | |
153 | + .mockResolvedValueOnce([low, high]); // unread count | |
154 | + | |
155 | + mocks.scoreNotificationPriorityMock | |
156 | + .mockReturnValueOnce({ score: 1, priority: "low", isBlocking: false }) | |
157 | + .mockReturnValueOnce({ score: 10, priority: "high", isBlocking: true }); | |
158 | + | |
159 | + const res = await GET(ctx("?prioritize=true")); | |
160 | + expect(res.status).toBe(200); | |
161 | + }); | |
162 | + | |
163 | + it("returns routing metadata", async () => { | |
164 | + const res = await GET(ctx("?personalize=true")); | |
165 | + const json = await readJson(res); | |
166 | + expect(json.routing.personalized).toBe(true); | |
167 | + expect(json.routing.channelFilter).toBeNull(); | |
168 | + }); | |
169 | + | |
170 | + it("handles empty notifications", async () => { | |
171 | + mockDb.query.notifications.findMany.mockResolvedValue([]); | |
172 | + const res = await GET(ctx()); | |
173 | + const json = await readJson(res); | |
174 | + expect(json.notifications).toEqual([]); | |
175 | + expect(json.unreadCount).toBe(0); | |
176 | + }); | |
177 | +}); | |
0 | 178 | |
▼tests/integration/org-members-route.test.ts
+121-0
| @@ -0,0 +1,121 @@ | ||
1 | +/** | |
2 | + * Integration tests for GET /api/orgs/[org]/members | |
3 | + */ | |
4 | +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
5 | + | |
6 | +/* ─── hoisted mocks ─── */ | |
7 | +const mocks = vi.hoisted(() => ({ | |
8 | + canAdminOrgMock: vi.fn().mockResolvedValue(true), | |
9 | + fakeSchema: { | |
10 | + organizations: { name: "name" }, | |
11 | + organizationMembers: { organizationId: "organizationId" }, | |
12 | + }, | |
13 | +})); | |
14 | + | |
15 | +vi.mock("@/db", () => ({ | |
16 | + getDatabase: () => mockDb, | |
17 | + schema: mocks.fakeSchema, | |
18 | +})); | |
19 | + | |
20 | +vi.mock("@/lib/permissions", () => ({ | |
21 | + canAdminOrg: mocks.canAdminOrgMock, | |
22 | +})); | |
23 | + | |
24 | +vi.mock("@/lib/errors", () => ({ | |
25 | + withErrorHandler: (fn: any) => fn, | |
26 | +})); | |
27 | + | |
28 | +vi.mock("drizzle-orm", () => ({ | |
29 | + eq: vi.fn((...a: any[]) => a), | |
30 | + and: vi.fn((...a: any[]) => a), | |
31 | +})); | |
32 | + | |
33 | +/* ─── helpers ─── */ | |
34 | +const fakeOrg = { id: "org_1", name: "acme" }; | |
35 | +const fakeMembers = [ | |
36 | + { | |
37 | + id: "mem_1", | |
38 | + userId: "usr_1", | |
39 | + role: "admin", | |
40 | + user: { id: "usr_1", username: "alice" }, | |
41 | + }, | |
42 | + { | |
43 | + id: "mem_2", | |
44 | + userId: "usr_2", | |
45 | + role: "member", | |
46 | + user: { id: "usr_2", username: "bob" }, | |
47 | + }, | |
48 | +]; | |
49 | + | |
50 | +function makeDb() { | |
51 | + return { | |
52 | + query: { | |
53 | + organizations: { | |
54 | + findFirst: vi.fn().mockResolvedValue(fakeOrg), | |
55 | + }, | |
56 | + organizationMembers: { | |
57 | + findMany: vi.fn().mockResolvedValue(fakeMembers), | |
58 | + }, | |
59 | + }, | |
60 | + }; | |
61 | +} | |
62 | + | |
63 | +let mockDb: ReturnType<typeof makeDb>; | |
64 | +const readJson = (r: Response) => r.json(); | |
65 | + | |
66 | +function ctx(org: string, user?: { id: string; isAdmin: boolean }) { | |
67 | + return { | |
68 | + params: { org }, | |
69 | + locals: { | |
70 | + user: user || { id: "usr_1", isAdmin: false }, | |
71 | + }, | |
72 | + } as any; | |
73 | +} | |
74 | + | |
75 | +/* ─── import route ─── */ | |
76 | +import { GET } from "@/pages/api/orgs/[org]/members"; | |
77 | + | |
78 | +/* ─── tests ─── */ | |
79 | +describe("GET /api/orgs/[org]/members", () => { | |
80 | + beforeEach(() => { | |
81 | + vi.clearAllMocks(); | |
82 | + mockDb = makeDb(); | |
83 | + }); | |
84 | + | |
85 | + it("returns 200 with members list", async () => { | |
86 | + const res = await GET(ctx("acme")); | |
87 | + expect(res.status).toBe(200); | |
88 | + const json = await readJson(res); | |
89 | + expect(json.members).toHaveLength(2); | |
90 | + expect(json.members[0].user.username).toBe("alice"); | |
91 | + }); | |
92 | + | |
93 | + it("returns 401 when not authenticated", async () => { | |
94 | + const res = await GET({ params: { org: "acme" }, locals: {} } as any); | |
95 | + expect(res.status).toBe(401); | |
96 | + }); | |
97 | + | |
98 | + it("returns 400 when org param missing", async () => { | |
99 | + const res = await GET(ctx(undefined as any)); | |
100 | + expect(res.status).toBe(400); | |
101 | + }); | |
102 | + | |
103 | + it("returns 404 when organization not found", async () => { | |
104 | + mockDb.query.organizations.findFirst.mockResolvedValue(null); | |
105 | + const res = await GET(ctx("nonexistent")); | |
106 | + expect(res.status).toBe(404); | |
107 | + }); | |
108 | + | |
109 | + it("returns 403 when user cannot admin org", async () => { | |
110 | + mocks.canAdminOrgMock.mockResolvedValue(false); | |
111 | + const res = await GET(ctx("acme")); | |
112 | + expect(res.status).toBe(403); | |
113 | + }); | |
114 | + | |
115 | + it("handles empty members list", async () => { | |
116 | + mockDb.query.organizationMembers.findMany.mockResolvedValue([]); | |
117 | + const res = await GET(ctx("acme")); | |
118 | + const json = await readJson(res); | |
119 | + expect(json.members).toEqual([]); | |
120 | + }); | |
121 | +}); | |
0 | 122 | |
▼tests/integration/search-route.test.ts
+178-0
| @@ -0,0 +1,178 @@ | ||
1 | +/** | |
2 | + * Integration tests for GET /api/search | |
3 | + */ | |
4 | +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
5 | + | |
6 | +/* ─── hoisted mocks ─── */ | |
7 | +const mocks = vi.hoisted(() => ({ | |
8 | + fakeSchema: { | |
9 | + repositories: { | |
10 | + name: "name", | |
11 | + description: "description", | |
12 | + starCount: "starCount", | |
13 | + }, | |
14 | + users: { username: "username", displayName: "displayName" }, | |
15 | + issues: { title: "title", body: "body" }, | |
16 | + pullRequests: { title: "title", body: "body" }, | |
17 | + workflows: { name: "name" }, | |
18 | + }, | |
19 | +})); | |
20 | + | |
21 | +vi.mock("@/db", () => ({ | |
22 | + getDatabase: () => mockDb, | |
23 | + schema: mocks.fakeSchema, | |
24 | +})); | |
25 | + | |
26 | +vi.mock("@/lib/errors", () => ({ | |
27 | + withErrorHandler: (fn: any) => fn, | |
28 | +})); | |
29 | + | |
30 | +vi.mock("drizzle-orm", () => ({ | |
31 | + like: vi.fn(), | |
32 | + or: vi.fn((...args: any[]) => args), | |
33 | + eq: vi.fn(), | |
34 | + desc: vi.fn(), | |
35 | +})); | |
36 | + | |
37 | +/* ─── helpers ─── */ | |
38 | +function makeDb() { | |
39 | + return { | |
40 | + query: { | |
41 | + repositories: { | |
42 | + findMany: vi.fn().mockResolvedValue([ | |
43 | + { | |
44 | + name: "my-project", | |
45 | + description: "A project", | |
46 | + owner: { username: "alice" }, | |
47 | + starCount: 10, | |
48 | + }, | |
49 | + ]), | |
50 | + }, | |
51 | + users: { | |
52 | + findMany: vi | |
53 | + .fn() | |
54 | + .mockResolvedValue([{ username: "alice", displayName: "Alice" }]), | |
55 | + }, | |
56 | + issues: { | |
57 | + findMany: vi.fn().mockResolvedValue([ | |
58 | + { | |
59 | + title: "Bug fix", | |
60 | + number: 1, | |
61 | + repository: { name: "my-project", owner: { username: "alice" } }, | |
62 | + }, | |
63 | + ]), | |
64 | + }, | |
65 | + pullRequests: { | |
66 | + findMany: vi.fn().mockResolvedValue([ | |
67 | + { | |
68 | + title: "Feature PR", | |
69 | + number: 2, | |
70 | + repository: { name: "my-project", owner: { username: "alice" } }, | |
71 | + }, | |
72 | + ]), | |
73 | + }, | |
74 | + workflows: { | |
75 | + findMany: vi.fn().mockResolvedValue([ | |
76 | + { | |
77 | + name: "CI Build", | |
78 | + repository: { name: "my-project", owner: { username: "alice" } }, | |
79 | + }, | |
80 | + ]), | |
81 | + }, | |
82 | + }, | |
83 | + }; | |
84 | +} | |
85 | + | |
86 | +let mockDb: ReturnType<typeof makeDb>; | |
87 | +const readJson = (r: Response) => r.json(); | |
88 | + | |
89 | +function ctx(query: string) { | |
90 | + return { | |
91 | + request: new Request( | |
92 | + `http://localhost/api/search?q=${encodeURIComponent(query)}`, | |
93 | + ), | |
94 | + } as any; | |
95 | +} | |
96 | + | |
97 | +/* ─── import route ─── */ | |
98 | +import { GET } from "@/pages/api/search"; | |
99 | + | |
100 | +/* ─── tests ─── */ | |
101 | +describe("GET /api/search", () => { | |
102 | + beforeEach(() => { | |
103 | + vi.clearAllMocks(); | |
104 | + mockDb = makeDb(); | |
105 | + }); | |
106 | + | |
107 | + it("returns results for valid query", async () => { | |
108 | + const res = await GET(ctx("project")); | |
109 | + expect(res.status).toBe(200); | |
110 | + const json = await readJson(res); | |
111 | + expect(json.results.length).toBeGreaterThan(0); | |
112 | + expect(json.results.some((r: any) => r.type === "repository")).toBe(true); | |
113 | + expect(json.results.some((r: any) => r.type === "user")).toBe(true); | |
114 | + expect(json.results.some((r: any) => r.type === "issue")).toBe(true); | |
115 | + expect(json.results.some((r: any) => r.type === "pr")).toBe(true); | |
116 | + expect(json.results.some((r: any) => r.type === "workflow")).toBe(true); | |
117 | + }); | |
118 | + | |
119 | + it("returns empty results for short query", async () => { | |
120 | + const res = await GET(ctx("a")); | |
121 | + expect(res.status).toBe(200); | |
122 | + const json = await readJson(res); | |
123 | + expect(json.results).toEqual([]); | |
124 | + }); | |
125 | + | |
126 | + it("returns empty results when no query", async () => { | |
127 | + const res = await GET({ | |
128 | + request: new Request("http://localhost/api/search"), | |
129 | + } as any); | |
130 | + expect(res.status).toBe(200); | |
131 | + const json = await readJson(res); | |
132 | + expect(json.results).toEqual([]); | |
133 | + }); | |
134 | + | |
135 | + it("formats repository results correctly", async () => { | |
136 | + mockDb.query.users.findMany.mockResolvedValue([]); | |
137 | + mockDb.query.issues.findMany.mockResolvedValue([]); | |
138 | + mockDb.query.pullRequests.findMany.mockResolvedValue([]); | |
139 | + mockDb.query.workflows.findMany.mockResolvedValue([]); | |
140 | + | |
141 | + const res = await GET(ctx("project")); | |
142 | + expect(res.status).toBe(200); | |
143 | + const json = await readJson(res); | |
144 | + const repoResult = json.results.find((r: any) => r.type === "repository"); | |
145 | + expect(repoResult).toBeDefined(); | |
146 | + expect(repoResult.title).toBe("alice/my-project"); | |
147 | + expect(repoResult.url).toBe("/alice/my-project"); | |
148 | + expect(repoResult.icon).toBe("repo"); | |
149 | + }); | |
150 | + | |
151 | + it("formats user results correctly", async () => { | |
152 | + mockDb.query.repositories.findMany.mockResolvedValue([]); | |
153 | + mockDb.query.issues.findMany.mockResolvedValue([]); | |
154 | + mockDb.query.pullRequests.findMany.mockResolvedValue([]); | |
155 | + mockDb.query.workflows.findMany.mockResolvedValue([]); | |
156 | + | |
157 | + const res = await GET(ctx("alice")); | |
158 | + expect(res.status).toBe(200); | |
159 | + const json = await readJson(res); | |
160 | + const userResult = json.results.find((r: any) => r.type === "user"); | |
161 | + expect(userResult).toBeDefined(); | |
162 | + expect(userResult.subtitle).toBe("@alice"); | |
163 | + expect(userResult.url).toBe("/alice"); | |
164 | + }); | |
165 | + | |
166 | + it("handles empty DB results gracefully", async () => { | |
167 | + mockDb.query.repositories.findMany.mockResolvedValue([]); | |
168 | + mockDb.query.users.findMany.mockResolvedValue([]); | |
169 | + mockDb.query.issues.findMany.mockResolvedValue([]); | |
170 | + mockDb.query.pullRequests.findMany.mockResolvedValue([]); | |
171 | + mockDb.query.workflows.findMany.mockResolvedValue([]); | |
172 | + | |
173 | + const res = await GET(ctx("nonexistent")); | |
174 | + expect(res.status).toBe(200); | |
175 | + const json = await readJson(res); | |
176 | + expect(json.results).toEqual([]); | |
177 | + }); | |
178 | +}); | |
0 | 179 | |
▼tests/integration/user-route.test.ts
+133-0
| @@ -0,0 +1,133 @@ | ||
1 | +/** | |
2 | + * Integration tests for GET /api/user | |
3 | + */ | |
4 | +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
5 | + | |
6 | +/* ─── hoisted mocks ─── */ | |
7 | +const mocks = vi.hoisted(() => ({ | |
8 | + getUserFromRequestMock: vi.fn().mockResolvedValue({ | |
9 | + userId: "usr_1", | |
10 | + username: "alice", | |
11 | + email: "[email protected]", | |
12 | + }), | |
13 | + fakeSchema: { users: "users" }, | |
14 | +})); | |
15 | + | |
16 | +vi.mock("@/db", () => ({ | |
17 | + getDatabase: () => mockDb, | |
18 | + schema: mocks.fakeSchema, | |
19 | +})); | |
20 | + | |
21 | +vi.mock("@/db/schema/users", () => ({ | |
22 | + users: { id: "id" }, | |
23 | +})); | |
24 | + | |
25 | +vi.mock("@/lib/auth", () => ({ | |
26 | + getUserFromRequest: mocks.getUserFromRequestMock, | |
27 | +})); | |
28 | + | |
29 | +vi.mock("@/lib/errors", () => ({ | |
30 | + withErrorHandler: (fn: any) => fn, | |
31 | +})); | |
32 | + | |
33 | +vi.mock("drizzle-orm", () => ({ | |
34 | + eq: vi.fn((...a: any[]) => a), | |
35 | +})); | |
36 | + | |
37 | +/* ─── helpers ─── */ | |
38 | +const fakeUser = { | |
39 | + id: "usr_1", | |
40 | + username: "alice", | |
41 | + email: "[email protected]", | |
42 | + displayName: "Alice", | |
43 | + avatarUrl: null, | |
44 | + bio: "dev", | |
45 | + location: "NYC", | |
46 | + website: "", | |
47 | + company: "ACME", | |
48 | + isAdmin: false, | |
49 | + createdAt: new Date(), | |
50 | +}; | |
51 | + | |
52 | +function makeDb() { | |
53 | + return { | |
54 | + query: { | |
55 | + users: { | |
56 | + findFirst: vi.fn().mockResolvedValue(fakeUser), | |
57 | + }, | |
58 | + }, | |
59 | + }; | |
60 | +} | |
61 | + | |
62 | +let mockDb: ReturnType<typeof makeDb>; | |
63 | +const readJson = (r: Response) => r.json(); | |
64 | + | |
65 | +function ctx() { | |
66 | + return { | |
67 | + request: new Request("http://localhost/api/user"), | |
68 | + } as any; | |
69 | +} | |
70 | + | |
71 | +/* ─── import route ─── */ | |
72 | +import { GET } from "@/pages/api/user/index"; | |
73 | + | |
74 | +/* ─── tests ─── */ | |
75 | +describe("GET /api/user", () => { | |
76 | + beforeEach(() => { | |
77 | + vi.clearAllMocks(); | |
78 | + mockDb = makeDb(); | |
79 | + }); | |
80 | + | |
81 | + it("returns 200 with user profile", async () => { | |
82 | + const res = await GET(ctx()); | |
83 | + expect(res.status).toBe(200); | |
84 | + const json = await readJson(res); | |
85 | + expect(json.username).toBe("alice"); | |
86 | + expect(json.email).toBe("[email protected]"); | |
87 | + expect(json.id).toBe("usr_1"); | |
88 | + expect(json.isAdmin).toBe(false); | |
89 | + }); | |
90 | + | |
91 | + it("returns 401 when not authenticated", async () => { | |
92 | + mocks.getUserFromRequestMock.mockResolvedValue(null); | |
93 | + const res = await GET(ctx()); | |
94 | + expect(res.status).toBe(401); | |
95 | + }); | |
96 | + | |
97 | + it("returns 401 when user not found in db", async () => { | |
98 | + mockDb.query.users.findFirst.mockResolvedValue(null); | |
99 | + const res = await GET(ctx()); | |
100 | + expect(res.status).toBe(401); | |
101 | + }); | |
102 | + | |
103 | + it("does not include passwordHash in response", async () => { | |
104 | + mockDb.query.users.findFirst.mockResolvedValue({ | |
105 | + ...fakeUser, | |
106 | + passwordHash: "secret_hash", | |
107 | + }); | |
108 | + const res = await GET(ctx()); | |
109 | + const json = await readJson(res); | |
110 | + expect(json.passwordHash).toBeUndefined(); | |
111 | + }); | |
112 | + | |
113 | + it("includes all expected fields", async () => { | |
114 | + const res = await GET(ctx()); | |
115 | + const json = await readJson(res); | |
116 | + const expectedFields = [ | |
117 | + "id", | |
118 | + "username", | |
119 | + "email", | |
120 | + "displayName", | |
121 | + "avatarUrl", | |
122 | + "bio", | |
123 | + "location", | |
124 | + "website", | |
125 | + "company", | |
126 | + "isAdmin", | |
127 | + "createdAt", | |
128 | + ]; | |
129 | + for (const field of expectedFields) { | |
130 | + expect(json).toHaveProperty(field); | |
131 | + } | |
132 | + }); | |
133 | +}); | |
0 | 134 | |
▼tests/load/README.md
+74-0
| @@ -0,0 +1,74 @@ | ||
1 | +# Load Testing | |
2 | + | |
3 | +k6-based load tests for OpenCodeHub. These scripts validate performance under various load conditions. | |
4 | + | |
5 | +## Prerequisites | |
6 | + | |
7 | +Install [k6](https://k6.io/docs/get-started/installation/): | |
8 | + | |
9 | +```bash | |
10 | +# macOS | |
11 | +brew install k6 | |
12 | + | |
13 | +# Linux (Debian/Ubuntu) | |
14 | +sudo gpg -k | |
15 | +sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D68 | |
16 | +echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list | |
17 | +sudo apt-get update && sudo apt-get install k6 | |
18 | + | |
19 | +# Docker | |
20 | +docker run --rm -i grafana/k6 run - <script.js | |
21 | +``` | |
22 | + | |
23 | +## Test Scenarios | |
24 | + | |
25 | +| Script | Purpose | Users | Target | | |
26 | +| --------------------- | -------------- | -------- | ----------- | | |
27 | +| `k6-smoke.js` | Sanity check | 5 | P95 < 500ms | | |
28 | +| `k6-load.js` | Sustained load | 100 | P99 < 1s | | |
29 | +| `k6-stress.js` | Beyond limits | 500 | P99 < 3s | | |
30 | +| `k6-spike.js` | Sudden burst | 10 → 500 | P95 < 3s | | |
31 | +| `k6-api-lifecycle.js` | API flows | 50 | P99 < 1s | | |
32 | + | |
33 | +## Running | |
34 | + | |
35 | +```bash | |
36 | +# Start the app first | |
37 | +npm run build && npm run preview | |
38 | + | |
39 | +# Smoke test (quick) | |
40 | +k6 run tests/load/k6-smoke.js | |
41 | + | |
42 | +# Load test (100 users, 11 minutes) | |
43 | +k6 run tests/load/k6-load.js | |
44 | + | |
45 | +# Stress test (500 users, 16 minutes) | |
46 | +k6 run tests/load/k6-stress.js | |
47 | + | |
48 | +# Spike test (burst to 500) | |
49 | +k6 run tests/load/k6-spike.js | |
50 | + | |
51 | +# API lifecycle (authenticated) | |
52 | +k6 run tests/load/k6-api-lifecycle.js --env AUTH_TOKEN=your_token | |
53 | + | |
54 | +# Custom base URL | |
55 | +k6 run tests/load/k6-load.js --env BASE_URL=https://staging.example.com | |
56 | +``` | |
57 | + | |
58 | +## Dashboard Output | |
59 | + | |
60 | +Export to InfluxDB/Grafana for visual dashboards: | |
61 | + | |
62 | +```bash | |
63 | +k6 run --out influxdb=http://localhost:8086/k6 tests/load/k6-load.js | |
64 | +``` | |
65 | + | |
66 | +## Thresholds | |
67 | + | |
68 | +Tests will **fail** if thresholds are not met: | |
69 | + | |
70 | +- **Smoke**: P95 < 500ms, <1% error rate | |
71 | +- **Load**: P99 < 1s, <5% error rate | |
72 | +- **Stress**: P99 < 3s, <10% error rate, <50 5xx errors | |
73 | +- **Spike**: P95 < 3s, <15% error rate during spike | |
74 | +- **API**: P99 < 1s, <5% error rate | |
0 | 75 | |
▼tests/load/k6-api-lifecycle.js
+88-0
| @@ -0,0 +1,88 @@ | ||
1 | +/** | |
2 | + * k6 API Lifecycle Test — authenticated user flows | |
3 | + * Tests: issue CRUD, repository browse, search | |
4 | + * Run: k6 run tests/load/k6-api-lifecycle.js --env AUTH_TOKEN=<token> | |
5 | + */ | |
6 | +import { check, group, sleep } from "k6"; | |
7 | +import http from "k6/http"; | |
8 | +import { Counter, Trend } from "k6/metrics"; | |
9 | + | |
10 | +const BASE_URL = __ENV.BASE_URL || "http://localhost:4321"; | |
11 | +const AUTH_TOKEN = __ENV.AUTH_TOKEN || ""; | |
12 | + | |
13 | +const apiLatency = new Trend("api_latency", true); | |
14 | +const apiErrors = new Counter("api_errors"); | |
15 | + | |
16 | +export const options = { | |
17 | + stages: [ | |
18 | + { duration: "1m", target: 20 }, | |
19 | + { duration: "3m", target: 50 }, | |
20 | + { duration: "2m", target: 50 }, | |
21 | + { duration: "1m", target: 0 }, | |
22 | + ], | |
23 | + thresholds: { | |
24 | + api_latency: ["p(95)<500", "p(99)<1000"], | |
25 | + api_errors: ["count<20"], | |
26 | + http_req_failed: ["rate<0.05"], | |
27 | + }, | |
28 | +}; | |
29 | + | |
30 | +function apiGet(path) { | |
31 | + const headers = AUTH_TOKEN ? { Authorization: `Bearer ${AUTH_TOKEN}` } : {}; | |
32 | + return http.get(`${BASE_URL}${path}`, { headers }); | |
33 | +} | |
34 | + | |
35 | +function apiPost(path, body) { | |
36 | + const headers = { | |
37 | + "Content-Type": "application/json", | |
38 | + ...(AUTH_TOKEN ? { Authorization: `Bearer ${AUTH_TOKEN}` } : {}), | |
39 | + }; | |
40 | + return http.post(`${BASE_URL}${path}`, JSON.stringify(body), { headers }); | |
41 | +} | |
42 | + | |
43 | +export default function () { | |
44 | + // Scenario 1: Browse repositories | |
45 | + group("Browse Repos", () => { | |
46 | + const res = apiGet("/api/repos?page=1&limit=20"); | |
47 | + check(res, { "repos list 2xx": (r) => r.status < 400 }); | |
48 | + apiLatency.add(res.timings.duration); | |
49 | + if (res.status >= 400) apiErrors.add(1); | |
50 | + sleep(0.5); | |
51 | + }); | |
52 | + | |
53 | + // Scenario 2: Search | |
54 | + group("Search", () => { | |
55 | + const res = apiGet("/api/search?q=test&type=repos"); | |
56 | + check(res, { "search 2xx": (r) => r.status < 400 }); | |
57 | + apiLatency.add(res.timings.duration); | |
58 | + if (res.status >= 400) apiErrors.add(1); | |
59 | + sleep(0.5); | |
60 | + }); | |
61 | + | |
62 | + // Scenario 3: Get repo details (use a known test repo or random) | |
63 | + group("Repo Detail", () => { | |
64 | + const res = apiGet("/api/repos/admin/test-repo"); | |
65 | + // 404 is acceptable in load testing — we just care about latency | |
66 | + check(res, { "repo detail ≤ 404": (r) => r.status <= 404 }); | |
67 | + apiLatency.add(res.timings.duration); | |
68 | + sleep(0.3); | |
69 | + }); | |
70 | + | |
71 | + // Scenario 4: Issues list | |
72 | + group("Issues List", () => { | |
73 | + const res = apiGet("/api/repos/admin/test-repo/issues?page=1"); | |
74 | + check(res, { "issues ≤ 404": (r) => r.status <= 404 }); | |
75 | + apiLatency.add(res.timings.duration); | |
76 | + sleep(0.5); | |
77 | + }); | |
78 | + | |
79 | + // Scenario 5: Metrics endpoint | |
80 | + group("Metrics", () => { | |
81 | + const res = apiGet("/api/metrics"); | |
82 | + check(res, { "metrics 2xx": (r) => r.status < 400 }); | |
83 | + apiLatency.add(res.timings.duration); | |
84 | + sleep(0.3); | |
85 | + }); | |
86 | + | |
87 | + sleep(1); | |
88 | +} | |
0 | 89 | |
▼tests/load/k6-load.js
+67-0
| @@ -0,0 +1,67 @@ | ||
1 | +/** | |
2 | + * k6 Load Test — sustained load: 100 concurrent users | |
3 | + * Target: P99 < 1s at 100 users | |
4 | + * Run: k6 run tests/load/k6-load.js | |
5 | + */ | |
6 | +import { check, group, sleep } from "k6"; | |
7 | +import http from "k6/http"; | |
8 | +import { Counter, Trend } from "k6/metrics"; | |
9 | + | |
10 | +const BASE_URL = __ENV.BASE_URL || "http://localhost:4321"; | |
11 | + | |
12 | +const slowRequests = new Counter("slow_requests"); | |
13 | +const apiDuration = new Trend("api_request_duration", true); | |
14 | + | |
15 | +export const options = { | |
16 | + stages: [ | |
17 | + { duration: "1m", target: 50 }, // Ramp up to 50 | |
18 | + { duration: "3m", target: 100 }, // Ramp to 100 | |
19 | + { duration: "5m", target: 100 }, // Sustain 100 | |
20 | + { duration: "2m", target: 0 }, // Ramp down | |
21 | + ], | |
22 | + thresholds: { | |
23 | + http_req_duration: ["p(95)<800", "p(99)<1000"], | |
24 | + http_req_failed: ["rate<0.05"], | |
25 | + slow_requests: ["count<100"], | |
26 | + }, | |
27 | +}; | |
28 | + | |
29 | +export default function () { | |
30 | + group("Browse Repositories", () => { | |
31 | + const res = http.get(`${BASE_URL}/explore`); | |
32 | + check(res, { "explore 2xx": (r) => r.status < 400 }); | |
33 | + if (res.timings.duration > 1000) slowRequests.add(1); | |
34 | + apiDuration.add(res.timings.duration); | |
35 | + sleep(0.5); | |
36 | + }); | |
37 | + | |
38 | + group("API Health", () => { | |
39 | + const res = http.get(`${BASE_URL}/api/health`); | |
40 | + check(res, { "health ok": (r) => r.status === 200 }); | |
41 | + apiDuration.add(res.timings.duration); | |
42 | + sleep(0.3); | |
43 | + }); | |
44 | + | |
45 | + group("Homepage", () => { | |
46 | + const res = http.get(`${BASE_URL}/`); | |
47 | + check(res, { "home 2xx": (r) => r.status < 400 }); | |
48 | + if (res.timings.duration > 1000) slowRequests.add(1); | |
49 | + apiDuration.add(res.timings.duration); | |
50 | + sleep(0.5); | |
51 | + }); | |
52 | + | |
53 | + group("Static Assets", () => { | |
54 | + const res = http.get(`${BASE_URL}/favicon.svg`); | |
55 | + check(res, { "favicon ok": (r) => r.status < 400 }); | |
56 | + sleep(0.2); | |
57 | + }); | |
58 | + | |
59 | + group("Sign-in Page", () => { | |
60 | + const res = http.get(`${BASE_URL}/auth/login`); | |
61 | + check(res, { "login page 2xx": (r) => r.status < 400 }); | |
62 | + apiDuration.add(res.timings.duration); | |
63 | + sleep(0.5); | |
64 | + }); | |
65 | + | |
66 | + sleep(1); | |
67 | +} | |
0 | 68 | |
▼tests/load/k6-smoke.js
+47-0
| @@ -0,0 +1,47 @@ | ||
1 | +/** | |
2 | + * k6 Smoke Test — basic sanity check under minimal load | |
3 | + * Run: k6 run tests/load/k6-smoke.js | |
4 | + */ | |
5 | +import { check, sleep } from "k6"; | |
6 | +import http from "k6/http"; | |
7 | + | |
8 | +const BASE_URL = __ENV.BASE_URL || "http://localhost:4321"; | |
9 | + | |
10 | +export const options = { | |
11 | + stages: [ | |
12 | + { duration: "30s", target: 5 }, | |
13 | + { duration: "1m", target: 5 }, | |
14 | + { duration: "30s", target: 0 }, | |
15 | + ], | |
16 | + thresholds: { | |
17 | + http_req_duration: ["p(95)<500", "p(99)<1000"], | |
18 | + http_req_failed: ["rate<0.01"], | |
19 | + }, | |
20 | +}; | |
21 | + | |
22 | +export default function () { | |
23 | + // 1. Homepage | |
24 | + const homeRes = http.get(`${BASE_URL}/`); | |
25 | + check(homeRes, { | |
26 | + "homepage status 200": (r) => r.status === 200, | |
27 | + "homepage time < 500ms": (r) => r.timings.duration < 500, | |
28 | + }); | |
29 | + | |
30 | + sleep(1); | |
31 | + | |
32 | + // 2. Health endpoint | |
33 | + const healthRes = http.get(`${BASE_URL}/api/health`); | |
34 | + check(healthRes, { | |
35 | + "health status 200": (r) => r.status === 200, | |
36 | + }); | |
37 | + | |
38 | + sleep(0.5); | |
39 | + | |
40 | + // 3. Explore/public repos | |
41 | + const exploreRes = http.get(`${BASE_URL}/explore`); | |
42 | + check(exploreRes, { | |
43 | + "explore status 200": (r) => r.status === 200, | |
44 | + }); | |
45 | + | |
46 | + sleep(1); | |
47 | +} | |
0 | 48 | |
▼tests/load/k6-spike.js
+41-0
| @@ -0,0 +1,41 @@ | ||
1 | +/** | |
2 | + * k6 Spike Test — sudden traffic burst | |
3 | + * Simulates viral event or DDoS-like burst | |
4 | + * Run: k6 run tests/load/k6-spike.js | |
5 | + */ | |
6 | +import { check, sleep } from "k6"; | |
7 | +import http from "k6/http"; | |
8 | +import { Counter } from "k6/metrics"; | |
9 | + | |
10 | +const BASE_URL = __ENV.BASE_URL || "http://localhost:4321"; | |
11 | + | |
12 | +const serverErrors = new Counter("server_errors_5xx"); | |
13 | + | |
14 | +export const options = { | |
15 | + stages: [ | |
16 | + { duration: "30s", target: 10 }, // Warm up | |
17 | + { duration: "30s", target: 500 }, // SPIKE! | |
18 | + { duration: "1m", target: 500 }, // Sustain spike | |
19 | + { duration: "30s", target: 10 }, // Cool down | |
20 | + { duration: "1m", target: 10 }, // Recovery | |
21 | + { duration: "30s", target: 0 }, // Ramp down | |
22 | + ], | |
23 | + thresholds: { | |
24 | + http_req_duration: ["p(95)<3000"], | |
25 | + http_req_failed: ["rate<0.15"], // Expect some failures during spike | |
26 | + }, | |
27 | +}; | |
28 | + | |
29 | +export default function () { | |
30 | + const res = http.get(`${BASE_URL}/`); | |
31 | + check(res, { | |
32 | + "status ok": (r) => r.status < 500, | |
33 | + "not timeout": (r) => r.timings.duration < 10000, | |
34 | + }); | |
35 | + if (res.status >= 500) serverErrors.add(1); | |
36 | + sleep(0.5); | |
37 | + | |
38 | + const healthRes = http.get(`${BASE_URL}/api/health`); | |
39 | + check(healthRes, { "health alive": (r) => r.status === 200 }); | |
40 | + sleep(0.5); | |
41 | +} | |
0 | 42 | |
▼tests/load/k6-stress.js
+60-0
| @@ -0,0 +1,60 @@ | ||
1 | +/** | |
2 | + * k6 Stress Test — push beyond limits: 500 concurrent users | |
3 | + * Target: P99 < 3s at 500 users, graceful degradation | |
4 | + * Run: k6 run tests/load/k6-stress.js | |
5 | + */ | |
6 | +import { check, group, sleep } from "k6"; | |
7 | +import http from "k6/http"; | |
8 | +import { Counter } from "k6/metrics"; | |
9 | + | |
10 | +const BASE_URL = __ENV.BASE_URL || "http://localhost:4321"; | |
11 | + | |
12 | +const serverErrors = new Counter("server_errors_5xx"); | |
13 | + | |
14 | +export const options = { | |
15 | + stages: [ | |
16 | + { duration: "2m", target: 100 }, | |
17 | + { duration: "3m", target: 250 }, | |
18 | + { duration: "3m", target: 500 }, | |
19 | + { duration: "5m", target: 500 }, // Sustain peak | |
20 | + { duration: "2m", target: 100 }, | |
21 | + { duration: "1m", target: 0 }, | |
22 | + ], | |
23 | + thresholds: { | |
24 | + http_req_duration: ["p(95)<2000", "p(99)<3000"], | |
25 | + http_req_failed: ["rate<0.10"], | |
26 | + server_errors_5xx: ["count<50"], | |
27 | + }, | |
28 | +}; | |
29 | + | |
30 | +export default function () { | |
31 | + group("Browse", () => { | |
32 | + const res = http.get(`${BASE_URL}/explore`); | |
33 | + check(res, { "explore 2xx": (r) => r.status < 400 }); | |
34 | + if (res.status >= 500) serverErrors.add(1); | |
35 | + sleep(0.5); | |
36 | + }); | |
37 | + | |
38 | + group("API Health", () => { | |
39 | + const res = http.get(`${BASE_URL}/api/health`); | |
40 | + check(res, { "health ok": (r) => r.status === 200 }); | |
41 | + if (res.status >= 500) serverErrors.add(1); | |
42 | + sleep(0.3); | |
43 | + }); | |
44 | + | |
45 | + group("Homepage", () => { | |
46 | + const res = http.get(`${BASE_URL}/`); | |
47 | + check(res, { "home ok": (r) => r.status < 400 }); | |
48 | + if (res.status >= 500) serverErrors.add(1); | |
49 | + sleep(0.5); | |
50 | + }); | |
51 | + | |
52 | + group("Login Page", () => { | |
53 | + const res = http.get(`${BASE_URL}/auth/login`); | |
54 | + check(res, { "login 2xx": (r) => r.status < 400 }); | |
55 | + if (res.status >= 500) serverErrors.add(1); | |
56 | + sleep(0.5); | |
57 | + }); | |
58 | + | |
59 | + sleep(1); | |
60 | +} | |
0 | 61 | |
▼tests/unit/air-gapped.test.ts
+27-0
| @@ -0,0 +1,27 @@ | ||
1 | +import { describe, expect, it, vi } from "vitest"; | |
2 | +import { assertOnlineFeature, getAirGappedMessage, isAirGappedMode } from "@/lib/air-gapped"; | |
3 | + | |
4 | +describe("air-gapped mode", () => { | |
5 | + it("returns false by default", () => { | |
6 | + vi.stubEnv("AIR_GAPPED_MODE", ""); | |
7 | + expect(isAirGappedMode()).toBe(false); | |
8 | + }); | |
9 | + | |
10 | + it("returns true for truthy values", () => { | |
11 | + vi.stubEnv("AIR_GAPPED_MODE", "true"); | |
12 | + expect(isAirGappedMode()).toBe(true); | |
13 | + | |
14 | + vi.stubEnv("AIR_GAPPED_MODE", "1"); | |
15 | + expect(isAirGappedMode()).toBe(true); | |
16 | + | |
17 | + vi.stubEnv("AIR_GAPPED_MODE", "yes"); | |
18 | + expect(isAirGappedMode()).toBe(true); | |
19 | + }); | |
20 | + | |
21 | + it("throws when feature requires online mode", () => { | |
22 | + vi.stubEnv("AIR_GAPPED_MODE", "true"); | |
23 | + expect(() => assertOnlineFeature("External CI")).toThrow( | |
24 | + getAirGappedMessage("External CI") | |
25 | + ); | |
26 | + }); | |
27 | +}); | |
0 | 28 | |
▼tests/unit/api-helpers.test.ts
+208-0
| @@ -0,0 +1,208 @@ | ||
1 | +import { | |
2 | + badRequest, | |
3 | + conflict, | |
4 | + created, | |
5 | + forbidden, | |
6 | + getPagination, | |
7 | + noContent, | |
8 | + notFound, | |
9 | + paginationMeta, | |
10 | + parseQuery, | |
11 | + rateLimited, | |
12 | + serverError, | |
13 | + success, | |
14 | + unauthorized, | |
15 | + validationError, | |
16 | +} from "@/lib/api"; | |
17 | +import { describe, expect, it } from "vitest"; | |
18 | +import { z } from "zod"; | |
19 | + | |
20 | +describe("success", () => { | |
21 | + it("returns 200 JSON response", async () => { | |
22 | + const res = success({ name: "test" }); | |
23 | + expect(res.status).toBe(200); | |
24 | + const body = await res.json(); | |
25 | + expect(body.success).toBe(true); | |
26 | + expect(body.data.name).toBe("test"); | |
27 | + }); | |
28 | + | |
29 | + it("includes meta when provided", async () => { | |
30 | + const meta = { page: 1, perPage: 10, total: 100 }; | |
31 | + const res = success([1, 2, 3], meta); | |
32 | + const body = await res.json(); | |
33 | + expect(body.meta).toBeDefined(); | |
34 | + expect(body.meta.page).toBe(1); | |
35 | + }); | |
36 | +}); | |
37 | + | |
38 | +describe("created", () => { | |
39 | + it("returns 201 JSON response", async () => { | |
40 | + const res = created({ id: "abc123" }); | |
41 | + expect(res.status).toBe(201); | |
42 | + const body = await res.json(); | |
43 | + expect(body.success).toBe(true); | |
44 | + }); | |
45 | +}); | |
46 | + | |
47 | +describe("noContent", () => { | |
48 | + it("returns 204 with no body", () => { | |
49 | + const res = noContent(); | |
50 | + expect(res.status).toBe(204); | |
51 | + }); | |
52 | +}); | |
53 | + | |
54 | +describe("error responses", () => { | |
55 | + it("badRequest returns 400", async () => { | |
56 | + const res = badRequest("Invalid input"); | |
57 | + expect(res.status).toBe(400); | |
58 | + const body = await res.json(); | |
59 | + expect(body.success).toBe(false); | |
60 | + expect(body.error.message).toBe("Invalid input"); | |
61 | + }); | |
62 | + | |
63 | + it("unauthorized returns 401", async () => { | |
64 | + const res = unauthorized(); | |
65 | + expect(res.status).toBe(401); | |
66 | + }); | |
67 | + | |
68 | + it("unauthorized with custom message", async () => { | |
69 | + const res = unauthorized("Login required"); | |
70 | + expect(res.status).toBe(401); | |
71 | + const body = await res.json(); | |
72 | + expect(body.error.message).toBe("Login required"); | |
73 | + }); | |
74 | + | |
75 | + it("forbidden returns 403", async () => { | |
76 | + const res = forbidden(); | |
77 | + expect(res.status).toBe(403); | |
78 | + }); | |
79 | + | |
80 | + it("notFound returns 404", async () => { | |
81 | + const res = notFound("Repo not found"); | |
82 | + expect(res.status).toBe(404); | |
83 | + const body = await res.json(); | |
84 | + expect(body.error.message).toBe("Repo not found"); | |
85 | + }); | |
86 | + | |
87 | + it("conflict returns 409", async () => { | |
88 | + const res = conflict("Already exists"); | |
89 | + expect(res.status).toBe(409); | |
90 | + const body = await res.json(); | |
91 | + expect(body.error.message).toBe("Already exists"); | |
92 | + }); | |
93 | + | |
94 | + it("serverError returns 500", async () => { | |
95 | + const res = serverError(); | |
96 | + expect(res.status).toBe(500); | |
97 | + }); | |
98 | + | |
99 | + it("rateLimited returns 429", async () => { | |
100 | + const res = rateLimited(60); | |
101 | + expect(res.status).toBe(429); | |
102 | + expect(res.headers.get("Retry-After")).toBe("60"); | |
103 | + }); | |
104 | + | |
105 | + it("validationError returns 400 with zod errors", async () => { | |
106 | + const schema = z.object({ name: z.string().min(1) }); | |
107 | + const result = schema.safeParse({ name: "" }); | |
108 | + if (!result.success) { | |
109 | + const res = validationError(result.error); | |
110 | + expect(res.status).toBe(400); | |
111 | + const body = await res.json(); | |
112 | + expect(body.success).toBe(false); | |
113 | + } | |
114 | + }); | |
115 | +}); | |
116 | + | |
117 | +describe("parseQuery", () => { | |
118 | + it("parses valid query parameters", () => { | |
119 | + const schema = z.object({ | |
120 | + page: z.coerce.number().default(1), | |
121 | + search: z.string().optional(), | |
122 | + }); | |
123 | + const url = new URL("http://localhost?page=3&search=hello"); | |
124 | + const result = parseQuery(url, schema); | |
125 | + expect("data" in result).toBe(true); | |
126 | + if ("data" in result) { | |
127 | + expect(result.data.page).toBe(3); | |
128 | + expect(result.data.search).toBe("hello"); | |
129 | + } | |
130 | + }); | |
131 | + | |
132 | + it("returns error for invalid query", () => { | |
133 | + const schema = z.object({ | |
134 | + page: z.coerce.number().int().min(1), | |
135 | + }); | |
136 | + const url = new URL("http://localhost?page=-5"); | |
137 | + const result = parseQuery(url, schema); | |
138 | + expect("error" in result).toBe(true); | |
139 | + }); | |
140 | + | |
141 | + it("uses defaults when query params are missing", () => { | |
142 | + const schema = z.object({ | |
143 | + page: z.coerce.number().default(1), | |
144 | + perPage: z.coerce.number().default(20), | |
145 | + }); | |
146 | + const url = new URL("http://localhost"); | |
147 | + const result = parseQuery(url, schema); | |
148 | + expect("data" in result).toBe(true); | |
149 | + if ("data" in result) { | |
150 | + expect(result.data.page).toBe(1); | |
151 | + expect(result.data.perPage).toBe(20); | |
152 | + } | |
153 | + }); | |
154 | +}); | |
155 | + | |
156 | +describe("getPagination", () => { | |
157 | + it("returns default pagination for empty URL", () => { | |
158 | + const url = new URL("http://localhost"); | |
159 | + const result = getPagination(url); | |
160 | + expect(result.page).toBe(1); | |
161 | + expect(result.perPage).toBeGreaterThan(0); | |
162 | + expect(result.offset).toBe(0); | |
163 | + }); | |
164 | + | |
165 | + it("parses page and perPage from query", () => { | |
166 | + const url = new URL("http://localhost?page=3&per_page=50"); | |
167 | + const result = getPagination(url); | |
168 | + expect(result.page).toBe(3); | |
169 | + expect(result.perPage).toBe(50); | |
170 | + expect(result.offset).toBe(100); | |
171 | + }); | |
172 | + | |
173 | + it("clamps perPage to max", () => { | |
174 | + const url = new URL("http://localhost?per_page=9999"); | |
175 | + const result = getPagination(url, 20, 100); | |
176 | + expect(result.perPage).toBeLessThanOrEqual(100); | |
177 | + }); | |
178 | + | |
179 | + it("defaults to page 1 for invalid page", () => { | |
180 | + const url = new URL("http://localhost?page=abc"); | |
181 | + const result = getPagination(url); | |
182 | + expect(result.page).toBe(1); | |
183 | + }); | |
184 | +}); | |
185 | + | |
186 | +describe("paginationMeta", () => { | |
187 | + it("calculates correct pagination metadata", () => { | |
188 | + const pagination = { page: 2, perPage: 10, offset: 10 }; | |
189 | + const meta = paginationMeta(95, pagination); | |
190 | + expect(meta?.page).toBe(2); | |
191 | + expect(meta?.perPage).toBe(10); | |
192 | + expect(meta?.total).toBe(95); | |
193 | + expect(meta?.totalPages).toBe(10); | |
194 | + }); | |
195 | + | |
196 | + it("handles 0 total items", () => { | |
197 | + const pagination = { page: 1, perPage: 10, offset: 0 }; | |
198 | + const meta = paginationMeta(0, pagination); | |
199 | + expect(meta?.total).toBe(0); | |
200 | + expect(meta?.totalPages).toBe(0); | |
201 | + }); | |
202 | + | |
203 | + it("handles single page", () => { | |
204 | + const pagination = { page: 1, perPage: 10, offset: 0 }; | |
205 | + const meta = paginationMeta(5, pagination); | |
206 | + expect(meta?.totalPages).toBe(1); | |
207 | + }); | |
208 | +}); | |
0 | 209 | |
▼tests/unit/ci-formats.test.ts
+118-0
| @@ -0,0 +1,118 @@ | ||
1 | +import type { UnifiedWorkflow } from "@/lib/ci-formats"; | |
2 | +import { toGitHubActions, toGitLabCI } from "@/lib/ci-formats"; | |
3 | +import { describe, expect, it } from "vitest"; | |
4 | + | |
5 | +const sampleWorkflow: UnifiedWorkflow = { | |
6 | + name: "CI Pipeline", | |
7 | + triggers: ["push", "pull_request"], | |
8 | + env: { NODE_ENV: "test" }, | |
9 | + jobs: [ | |
10 | + { | |
11 | + name: "build", | |
12 | + runsOn: "ubuntu-latest", | |
13 | + steps: [ | |
14 | + { name: "Checkout", uses: "actions/checkout@v4" }, | |
15 | + { | |
16 | + name: "Setup Node", | |
17 | + uses: "actions/setup-node@v4", | |
18 | + with: { "node-version": "20" }, | |
19 | + }, | |
20 | + { name: "Install deps", run: "npm ci" }, | |
21 | + { name: "Build", run: "npm run build" }, | |
22 | + ], | |
23 | + }, | |
24 | + { | |
25 | + name: "test", | |
26 | + runsOn: "ubuntu-latest", | |
27 | + needs: ["build"], | |
28 | + steps: [ | |
29 | + { name: "Checkout", uses: "actions/checkout@v4" }, | |
30 | + { name: "Run tests", run: "npm test" }, | |
31 | + ], | |
32 | + }, | |
33 | + ], | |
34 | +}; | |
35 | + | |
36 | +describe("toGitHubActions", () => { | |
37 | + it("generates valid YAML string", () => { | |
38 | + const result = toGitHubActions(sampleWorkflow); | |
39 | + expect(typeof result).toBe("string"); | |
40 | + expect(result.length).toBeGreaterThan(0); | |
41 | + }); | |
42 | + | |
43 | + it("includes workflow name", () => { | |
44 | + const result = toGitHubActions(sampleWorkflow); | |
45 | + expect(result).toContain("CI Pipeline"); | |
46 | + }); | |
47 | + | |
48 | + it("includes job definitions", () => { | |
49 | + const result = toGitHubActions(sampleWorkflow); | |
50 | + expect(result).toContain("build"); | |
51 | + expect(result).toContain("test"); | |
52 | + }); | |
53 | + | |
54 | + it("includes steps", () => { | |
55 | + const result = toGitHubActions(sampleWorkflow); | |
56 | + expect(result).toContain("npm ci"); | |
57 | + expect(result).toContain("npm run build"); | |
58 | + expect(result).toContain("npm test"); | |
59 | + }); | |
60 | + | |
61 | + it("includes uses references", () => { | |
62 | + const result = toGitHubActions(sampleWorkflow); | |
63 | + expect(result).toContain("actions/checkout@v4"); | |
64 | + expect(result).toContain("actions/setup-node@v4"); | |
65 | + }); | |
66 | + | |
67 | + it("handles minimal workflow", () => { | |
68 | + const minimal: UnifiedWorkflow = { | |
69 | + name: "Minimal", | |
70 | + triggers: ["push"], | |
71 | + jobs: [ | |
72 | + { | |
73 | + name: "hello", | |
74 | + steps: [{ name: "Say hello", run: 'echo "hello"' }], | |
75 | + }, | |
76 | + ], | |
77 | + }; | |
78 | + const result = toGitHubActions(minimal); | |
79 | + expect(result).toContain("Minimal"); | |
80 | + expect(result).toContain("echo"); | |
81 | + }); | |
82 | +}); | |
83 | + | |
84 | +describe("toGitLabCI", () => { | |
85 | + it("generates valid YAML string", () => { | |
86 | + const result = toGitLabCI(sampleWorkflow); | |
87 | + expect(typeof result).toBe("string"); | |
88 | + expect(result.length).toBeGreaterThan(0); | |
89 | + }); | |
90 | + | |
91 | + it("includes stage/job definitions", () => { | |
92 | + const result = toGitLabCI(sampleWorkflow); | |
93 | + expect(result).toContain("build"); | |
94 | + expect(result).toContain("test"); | |
95 | + }); | |
96 | + | |
97 | + it("includes script commands", () => { | |
98 | + const result = toGitLabCI(sampleWorkflow); | |
99 | + expect(result).toContain("npm ci"); | |
100 | + expect(result).toContain("npm run build"); | |
101 | + expect(result).toContain("npm test"); | |
102 | + }); | |
103 | + | |
104 | + it("handles minimal workflow", () => { | |
105 | + const minimal: UnifiedWorkflow = { | |
106 | + name: "Minimal", | |
107 | + triggers: ["push"], | |
108 | + jobs: [ | |
109 | + { | |
110 | + name: "hello", | |
111 | + steps: [{ name: "Say hello", run: 'echo "hello"' }], | |
112 | + }, | |
113 | + ], | |
114 | + }; | |
115 | + const result = toGitLabCI(minimal); | |
116 | + expect(result).toContain("echo"); | |
117 | + }); | |
118 | +}); | |
0 | 119 | |
▼tests/unit/cn.test.ts
+44-0
| @@ -0,0 +1,44 @@ | ||
1 | +import { cn } from "@/lib/cn"; | |
2 | +import { describe, expect, it } from "vitest"; | |
3 | + | |
4 | +describe("cn", () => { | |
5 | + it("merges simple class names", () => { | |
6 | + expect(cn("foo", "bar")).toBe("foo bar"); | |
7 | + }); | |
8 | + | |
9 | + it("handles conditional classes", () => { | |
10 | + expect(cn("base", true && "active", false && "hidden")).toBe("base active"); | |
11 | + }); | |
12 | + | |
13 | + it("handles undefined and null", () => { | |
14 | + expect(cn("base", undefined, null, "end")).toBe("base end"); | |
15 | + }); | |
16 | + | |
17 | + it("handles empty string", () => { | |
18 | + expect(cn("")).toBe(""); | |
19 | + }); | |
20 | + | |
21 | + it("handles no arguments", () => { | |
22 | + expect(cn()).toBe(""); | |
23 | + }); | |
24 | + | |
25 | + it("merges tailwind classes correctly", () => { | |
26 | + // twMerge should handle conflicting tailwind classes | |
27 | + const result = cn("px-2 py-1", "px-4"); | |
28 | + expect(result).toContain("px-4"); | |
29 | + expect(result).not.toContain("px-2"); | |
30 | + }); | |
31 | + | |
32 | + it("handles array input", () => { | |
33 | + const result = cn(["foo", "bar"]); | |
34 | + expect(result).toContain("foo"); | |
35 | + expect(result).toContain("bar"); | |
36 | + }); | |
37 | + | |
38 | + it("handles object input", () => { | |
39 | + const result = cn({ active: true, hidden: false, visible: true }); | |
40 | + expect(result).toContain("active"); | |
41 | + expect(result).toContain("visible"); | |
42 | + expect(result).not.toContain("hidden"); | |
43 | + }); | |
44 | +}); | |
0 | 45 | |
▼tests/unit/codeowners.test.ts
+157-0
| @@ -0,0 +1,157 @@ | ||
1 | +import { | |
2 | + CODEOWNERS_PATHS, | |
3 | + findOwnersForFile, | |
4 | + findOwnersForFiles, | |
5 | + parseCodeOwners, | |
6 | + validateCodeOwners, | |
7 | +} from "@/lib/codeowners"; | |
8 | +import { describe, expect, it } from "vitest"; | |
9 | + | |
10 | +describe("CODEOWNERS_PATHS", () => { | |
11 | + it("contains expected paths", () => { | |
12 | + expect(CODEOWNERS_PATHS).toContain("CODEOWNERS"); | |
13 | + expect(CODEOWNERS_PATHS).toContain(".github/CODEOWNERS"); | |
14 | + expect(CODEOWNERS_PATHS).toContain("docs/CODEOWNERS"); | |
15 | + }); | |
16 | +}); | |
17 | + | |
18 | +describe("parseCodeOwners", () => { | |
19 | + it("parses simple ownership rules", () => { | |
20 | + const content = ` | |
21 | +# This is a comment | |
22 | +*.js @frontend-team | |
23 | +/docs/* @docs-team | |
24 | +*.ts @typescript-team | |
25 | + `.trim(); | |
26 | + | |
27 | + const result = parseCodeOwners(content); | |
28 | + expect(result.rules).toBeDefined(); | |
29 | + expect(result.rules.length).toBe(3); | |
30 | + }); | |
31 | + | |
32 | + it("handles empty content", () => { | |
33 | + const result = parseCodeOwners(""); | |
34 | + expect(result.rules).toBeDefined(); | |
35 | + expect(result.rules.length).toBe(0); | |
36 | + }); | |
37 | + | |
38 | + it("ignores comment lines", () => { | |
39 | + const content = ` | |
40 | +# Comment only | |
41 | +# Another comment | |
42 | + `.trim(); | |
43 | + | |
44 | + const result = parseCodeOwners(content); | |
45 | + expect(result.rules.length).toBe(0); | |
46 | + }); | |
47 | + | |
48 | + it("ignores blank lines", () => { | |
49 | + const content = ` | |
50 | +*.js @owner1 | |
51 | + | |
52 | +*.ts @owner2 | |
53 | + `; | |
54 | + | |
55 | + const result = parseCodeOwners(content); | |
56 | + expect(result.rules.length).toBe(2); | |
57 | + }); | |
58 | + | |
59 | + it("parses multiple owners per rule", () => { | |
60 | + const content = "*.js @owner1 @owner2 [email protected]"; | |
61 | + const result = parseCodeOwners(content); | |
62 | + expect(result.rules.length).toBe(1); | |
63 | + expect(result.rules[0].owners.length).toBeGreaterThanOrEqual(2); | |
64 | + }); | |
65 | +}); | |
66 | + | |
67 | +describe("findOwnersForFile", () => { | |
68 | + const content = ` | |
69 | +*.js @js-team | |
70 | +*.ts @ts-team | |
71 | +/docs/* @docs-team | |
72 | +src/lib/* @core-team | |
73 | + `.trim(); | |
74 | + | |
75 | + const codeOwners = parseCodeOwners(content); | |
76 | + | |
77 | + it("matches .js files", () => { | |
78 | + const owners = findOwnersForFile(codeOwners, "app.js"); | |
79 | + expect(owners).toContain("@js-team"); | |
80 | + }); | |
81 | + | |
82 | + it("matches .ts files", () => { | |
83 | + const owners = findOwnersForFile(codeOwners, "index.ts"); | |
84 | + expect(owners).toContain("@ts-team"); | |
85 | + }); | |
86 | + | |
87 | + it("matches docs directory", () => { | |
88 | + const owners = findOwnersForFile(codeOwners, "docs/readme.md"); | |
89 | + expect(owners).toContain("@docs-team"); | |
90 | + }); | |
91 | + | |
92 | + it("matches src/lib directory", () => { | |
93 | + const owners = findOwnersForFile(codeOwners, "src/lib/utils.ts"); | |
94 | + // Should get both @core-team (from src/lib/*) and @ts-team (from *.ts) | |
95 | + // Last matching pattern wins in CODEOWNERS | |
96 | + expect(owners.length).toBeGreaterThan(0); | |
97 | + }); | |
98 | + | |
99 | + it("returns empty for unmatched files", () => { | |
100 | + const owners = findOwnersForFile(codeOwners, "Makefile"); | |
101 | + // No patterns match Makefile — returns empty | |
102 | + expect(Array.isArray(owners)).toBe(true); | |
103 | + }); | |
104 | +}); | |
105 | + | |
106 | +describe("findOwnersForFiles", () => { | |
107 | + const content = ` | |
108 | +*.js @js-team | |
109 | +*.py @python-team | |
110 | + `.trim(); | |
111 | + | |
112 | + const codeOwners = parseCodeOwners(content); | |
113 | + | |
114 | + it("returns combined owners for multiple files", () => { | |
115 | + const owners = findOwnersForFiles(codeOwners, ["app.js", "script.py"]); | |
116 | + expect(owners).toContain("@js-team"); | |
117 | + expect(owners).toContain("@python-team"); | |
118 | + }); | |
119 | + | |
120 | + it("deduplicates owners", () => { | |
121 | + const owners = findOwnersForFiles(codeOwners, ["a.js", "b.js"]); | |
122 | + const jsTeamCount = owners.filter((o) => o === "@js-team").length; | |
123 | + expect(jsTeamCount).toBe(1); | |
124 | + }); | |
125 | + | |
126 | + it("handles empty file list", () => { | |
127 | + const owners = findOwnersForFiles(codeOwners, []); | |
128 | + expect(owners.length).toBe(0); | |
129 | + }); | |
130 | +}); | |
131 | + | |
132 | +describe("validateCodeOwners", () => { | |
133 | + it("validates correct CODEOWNERS content", () => { | |
134 | + const content = "*.js @frontend\n/docs/* @docs-team"; | |
135 | + const result = validateCodeOwners(content); | |
136 | + expect(result.valid).toBe(true); | |
137 | + expect(result.errors.length).toBe(0); | |
138 | + }); | |
139 | + | |
140 | + it("reports error for rules without owners", () => { | |
141 | + const content = "*.js\n*.ts @ts-team"; | |
142 | + const result = validateCodeOwners(content); | |
143 | + // A rule without owners should trigger an error/warning | |
144 | + expect(result.errors.length + result.warnings.length).toBeGreaterThan(0); | |
145 | + }); | |
146 | + | |
147 | + it("handles empty content", () => { | |
148 | + const result = validateCodeOwners(""); | |
149 | + expect(result.valid).toBe(true); | |
150 | + expect(result.errors.length).toBe(0); | |
151 | + }); | |
152 | + | |
153 | + it("handles comment-only content", () => { | |
154 | + const result = validateCodeOwners("# just comments\n# more comments"); | |
155 | + expect(result.valid).toBe(true); | |
156 | + }); | |
157 | +}); | |
0 | 158 | |
▼tests/unit/email.test.ts
+44-0
| @@ -0,0 +1,44 @@ | ||
1 | +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
2 | + | |
3 | +vi.mock("@/lib/logger", () => ({ | |
4 | + logger: { | |
5 | + info: vi.fn(), | |
6 | + warn: vi.fn(), | |
7 | + error: vi.fn(), | |
8 | + debug: vi.fn(), | |
9 | + }, | |
10 | +})); | |
11 | + | |
12 | +// Mock nodemailer | |
13 | +vi.mock("nodemailer", () => ({ | |
14 | + default: { | |
15 | + createTransport: vi.fn(() => ({ | |
16 | + sendMail: vi.fn().mockResolvedValue({ messageId: "test-123" }), | |
17 | + })), | |
18 | + }, | |
19 | + createTransport: vi.fn(() => ({ | |
20 | + sendMail: vi.fn().mockResolvedValue({ messageId: "test-123" }), | |
21 | + })), | |
22 | +})); | |
23 | + | |
24 | +describe("email", () => { | |
25 | + beforeEach(() => { | |
26 | + vi.clearAllMocks(); | |
27 | + }); | |
28 | + | |
29 | + it("exports isSmtpConfigured function", async () => { | |
30 | + const mod = await import("@/lib/email"); | |
31 | + expect(typeof mod.isSmtpConfigured).toBe("function"); | |
32 | + }); | |
33 | + | |
34 | + it("exports sendEmail function", async () => { | |
35 | + const mod = await import("@/lib/email"); | |
36 | + expect(typeof mod.sendEmail).toBe("function"); | |
37 | + }); | |
38 | + | |
39 | + it("isSmtpConfigured returns boolean", async () => { | |
40 | + const { isSmtpConfigured } = await import("@/lib/email"); | |
41 | + const result = isSmtpConfigured(); | |
42 | + expect(typeof result).toBe("boolean"); | |
43 | + }); | |
44 | +}); | |
0 | 45 | |
▼tests/unit/env-validation.test.ts
+29-0
| @@ -0,0 +1,29 @@ | ||
1 | +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
2 | + | |
3 | +vi.mock("@/lib/logger", () => ({ | |
4 | + logger: { | |
5 | + info: vi.fn(), | |
6 | + warn: vi.fn(), | |
7 | + error: vi.fn(), | |
8 | + debug: vi.fn(), | |
9 | + }, | |
10 | +})); | |
11 | + | |
12 | +describe("env-validation", () => { | |
13 | + beforeEach(() => { | |
14 | + vi.clearAllMocks(); | |
15 | + }); | |
16 | + | |
17 | + it("validates environment without crashing", async () => { | |
18 | + const { validateEnvironment } = await import("@/lib/env-validation"); | |
19 | + // Pass false to avoid process.exit on error | |
20 | + const result = validateEnvironment(false); | |
21 | + expect(typeof result).toBe("boolean"); | |
22 | + }); | |
23 | + | |
24 | + it("returns boolean result", async () => { | |
25 | + const { validateEnvironment } = await import("@/lib/env-validation"); | |
26 | + const result = validateEnvironment(false); | |
27 | + expect(typeof result).toBe("boolean"); | |
28 | + }); | |
29 | +}); | |
0 | 30 | |
▼tests/unit/markdown.test.ts
+124-0
| @@ -0,0 +1,124 @@ | ||
1 | +import { renderMarkdown } from "@/lib/markdown"; | |
2 | +import { describe, expect, it } from "vitest"; | |
3 | + | |
4 | +describe("renderMarkdown", () => { | |
5 | + it("renders simple text", async () => { | |
6 | + const html = await renderMarkdown("Hello world"); | |
7 | + expect(html).toContain("Hello world"); | |
8 | + }); | |
9 | + | |
10 | + it("renders headings", async () => { | |
11 | + const html = await renderMarkdown("# Title\n## Subtitle"); | |
12 | + expect(html).toContain("<h1"); | |
13 | + expect(html).toContain("Title"); | |
14 | + expect(html).toContain("<h2"); | |
15 | + expect(html).toContain("Subtitle"); | |
16 | + }); | |
17 | + | |
18 | + it("renders bold and italic", async () => { | |
19 | + const html = await renderMarkdown("**bold** and *italic*"); | |
20 | + expect(html).toContain("<strong>bold</strong>"); | |
21 | + expect(html).toContain("<em>italic</em>"); | |
22 | + }); | |
23 | + | |
24 | + it("renders links", async () => { | |
25 | + const html = await renderMarkdown("[OpenCodeHub](https://example.com)"); | |
26 | + expect(html).toContain("<a"); | |
27 | + expect(html).toContain("https://example.com"); | |
28 | + }); | |
29 | + | |
30 | + it("renders code blocks", async () => { | |
31 | + const html = await renderMarkdown("```js\nconst x = 1;\n```"); | |
32 | + expect(html).toContain("<code"); | |
33 | + expect(html).toContain("const x = 1;"); | |
34 | + }); | |
35 | + | |
36 | + it("renders inline code", async () => { | |
37 | + const html = await renderMarkdown("Use `npm install` to install"); | |
38 | + expect(html).toContain("<code"); | |
39 | + expect(html).toContain("npm install"); | |
40 | + }); | |
41 | + | |
42 | + it("renders unordered lists", async () => { | |
43 | + const html = await renderMarkdown("- Item 1\n- Item 2\n- Item 3"); | |
44 | + expect(html).toContain("<ul"); | |
45 | + expect(html).toContain("<li"); | |
46 | + expect(html).toContain("Item 1"); | |
47 | + }); | |
48 | + | |
49 | + it("renders ordered lists", async () => { | |
50 | + const html = await renderMarkdown("1. First\n2. Second"); | |
51 | + expect(html).toContain("<ol"); | |
52 | + expect(html).toContain("First"); | |
53 | + }); | |
54 | + | |
55 | + it("renders GFM tables", async () => { | |
56 | + const md = ` | |
57 | +| Column A | Column B | | |
58 | +|----------|----------| | |
59 | +| Val 1 | Val 2 | | |
60 | + `.trim(); | |
61 | + const html = await renderMarkdown(md); | |
62 | + expect(html).toContain("<table"); | |
63 | + expect(html).toContain("Column A"); | |
64 | + expect(html).toContain("Val 1"); | |
65 | + }); | |
66 | + | |
67 | + it("renders GFM task lists", async () => { | |
68 | + const md = "- [x] Done\n- [ ] Todo"; | |
69 | + const html = await renderMarkdown(md); | |
70 | + expect(html).toContain("Done"); | |
71 | + expect(html).toContain("Todo"); | |
72 | + }); | |
73 | + | |
74 | + it("renders GFM strikethrough", async () => { | |
75 | + const html = await renderMarkdown("~~deleted~~"); | |
76 | + expect(html).toContain("<del"); | |
77 | + expect(html).toContain("deleted"); | |
78 | + }); | |
79 | + | |
80 | + it("sanitizes dangerous html", async () => { | |
81 | + const html = await renderMarkdown('<script>alert("xss")</script>'); | |
82 | + expect(html).not.toContain("<script"); | |
83 | + }); | |
84 | + | |
85 | + it("removes dangerous attributes", async () => { | |
86 | + const html = await renderMarkdown('<img onerror="alert(1)" src="x">'); | |
87 | + expect(html).not.toContain("onerror"); | |
88 | + }); | |
89 | + | |
90 | + it("handles empty string", async () => { | |
91 | + const html = await renderMarkdown(""); | |
92 | + expect(typeof html).toBe("string"); | |
93 | + }); | |
94 | + | |
95 | + it("handles whitespace-only content", async () => { | |
96 | + const html = await renderMarkdown(" \n\n "); | |
97 | + expect(typeof html).toBe("string"); | |
98 | + }); | |
99 | + | |
100 | + it("renders cross-repo issue references", async () => { | |
101 | + const html = await renderMarkdown("See owner/repo#123 for details"); | |
102 | + // The remarkCrossRepoLinks plugin should convert owner/repo#123 to a link | |
103 | + expect(html).toContain("owner/repo#123"); | |
104 | + }); | |
105 | + | |
106 | + it("renders blockquotes", async () => { | |
107 | + const html = await renderMarkdown("> This is a quote"); | |
108 | + expect(html).toContain("<blockquote"); | |
109 | + expect(html).toContain("This is a quote"); | |
110 | + }); | |
111 | + | |
112 | + it("renders horizontal rules", async () => { | |
113 | + const html = await renderMarkdown("---"); | |
114 | + expect(html).toContain("<hr"); | |
115 | + }); | |
116 | + | |
117 | + it("renders images", async () => { | |
118 | + const html = await renderMarkdown( | |
119 | + "", | |
120 | + ); | |
121 | + expect(html).toContain("<img"); | |
122 | + expect(html).toContain("alt text"); | |
123 | + }); | |
124 | +}); | |
0 | 125 | |
▼tests/unit/notification-routing.test.ts
+120-0
| @@ -0,0 +1,120 @@ | ||
1 | +import { | |
2 | + channelEnabled, | |
3 | + computePersonalizationBoost, | |
4 | + getRoutingDecision, | |
5 | +} from "@/lib/notification-routing"; | |
6 | +import { describe, expect, it } from "vitest"; | |
7 | + | |
8 | +describe("getRoutingDecision", () => { | |
9 | + it("returns default channels when no preferences", () => { | |
10 | + const decision = getRoutingDecision("push", []); | |
11 | + expect(decision.channels).toBeDefined(); | |
12 | + expect(Array.isArray(decision.channels)).toBe(true); | |
13 | + expect(decision.channels.length).toBeGreaterThan(0); | |
14 | + expect(decision.primaryChannel).toBeDefined(); | |
15 | + }); | |
16 | + | |
17 | + it("returns default channels for null event type", () => { | |
18 | + const decision = getRoutingDecision(null, []); | |
19 | + expect(decision.channels).toBeDefined(); | |
20 | + expect(decision.channels.length).toBeGreaterThan(0); | |
21 | + }); | |
22 | + | |
23 | + it("returns default channels for undefined event type", () => { | |
24 | + const decision = getRoutingDecision(undefined, []); | |
25 | + expect(decision.channels).toBeDefined(); | |
26 | + }); | |
27 | + | |
28 | + it("includes in_app channel by default", () => { | |
29 | + const decision = getRoutingDecision("issue_comment", []); | |
30 | + expect(decision.channels).toContain("in_app"); | |
31 | + }); | |
32 | + | |
33 | + it("respects user preferences when provided", () => { | |
34 | + const prefs = [ | |
35 | + { | |
36 | + id: "1", | |
37 | + userId: "u1", | |
38 | + repositoryId: null, | |
39 | + eventType: "push", | |
40 | + inApp: true, | |
41 | + email: false, | |
42 | + slack: false, | |
43 | + browserPush: false, | |
44 | + createdAt: new Date(), | |
45 | + updatedAt: new Date(), | |
46 | + }, | |
47 | + ] as any[]; | |
48 | + const decision = getRoutingDecision("push", prefs); | |
49 | + expect(decision.channels).toContain("in_app"); | |
50 | + }); | |
51 | + | |
52 | + it("returns a valid primaryChannel", () => { | |
53 | + const decision = getRoutingDecision("review_request", []); | |
54 | + expect(decision.channels).toContain(decision.primaryChannel); | |
55 | + }); | |
56 | +}); | |
57 | + | |
58 | +describe("channelEnabled", () => { | |
59 | + it("returns true when channel is in decision", () => { | |
60 | + const decision = { | |
61 | + channels: ["in_app", "email"] as any[], | |
62 | + primaryChannel: "in_app" as any, | |
63 | + }; | |
64 | + expect(channelEnabled("in_app", decision)).toBe(true); | |
65 | + expect(channelEnabled("email", decision)).toBe(true); | |
66 | + }); | |
67 | + | |
68 | + it("returns false when channel is not in decision", () => { | |
69 | + const decision = { | |
70 | + channels: ["in_app"] as any[], | |
71 | + primaryChannel: "in_app" as any, | |
72 | + }; | |
73 | + expect(channelEnabled("email", decision)).toBe(false); | |
74 | + expect(channelEnabled("slack", decision)).toBe(false); | |
75 | + }); | |
76 | +}); | |
77 | + | |
78 | +describe("computePersonalizationBoost", () => { | |
79 | + it("returns 0 for empty input", () => { | |
80 | + const boost = computePersonalizationBoost({ | |
81 | + eventType: "push", | |
82 | + readByType: {}, | |
83 | + unreadByType: {}, | |
84 | + baselineReadRatio: 0.5, | |
85 | + }); | |
86 | + expect(typeof boost).toBe("number"); | |
87 | + }); | |
88 | + | |
89 | + it("returns a positive boost for frequently-read event types", () => { | |
90 | + const boost = computePersonalizationBoost({ | |
91 | + eventType: "push", | |
92 | + readByType: { push: 50 }, | |
93 | + unreadByType: { push: 5 }, | |
94 | + baselineReadRatio: 0.5, | |
95 | + }); | |
96 | + // User reads push events a lot — boost should be positive (higher priority) | |
97 | + expect(boost).toBeGreaterThanOrEqual(0); | |
98 | + }); | |
99 | + | |
100 | + it("returns zero or negative boost for mostly-unread event types", () => { | |
101 | + const boost = computePersonalizationBoost({ | |
102 | + eventType: "star", | |
103 | + readByType: { star: 1 }, | |
104 | + unreadByType: { star: 50 }, | |
105 | + baselineReadRatio: 0.5, | |
106 | + }); | |
107 | + expect(boost).toBeLessThanOrEqual(0); | |
108 | + }); | |
109 | + | |
110 | + it("handles unknown event type gracefully", () => { | |
111 | + const boost = computePersonalizationBoost({ | |
112 | + eventType: "unknown_event", | |
113 | + readByType: {}, | |
114 | + unreadByType: {}, | |
115 | + baselineReadRatio: 0.5, | |
116 | + }); | |
117 | + expect(typeof boost).toBe("number"); | |
118 | + expect(Number.isFinite(boost)).toBe(true); | |
119 | + }); | |
120 | +}); | |
0 | 121 | |
▼tests/unit/notifications-route.test.ts
+2-1
| @@ -47,6 +47,7 @@ describe("notifications list route", () => { | ||
47 | 47 | getUserFromRequestMock.mockResolvedValue({ userId: "user-1" }); |
48 | 48 | mockDb.query.notifications.findMany |
49 | 49 | .mockResolvedValueOnce([{ id: "notif-1", type: "ci_failed" }]) |
50 | + .mockResolvedValueOnce([{ id: "history-1", type: "ci_failed", isRead: false }]) | |
50 | 51 | .mockResolvedValueOnce([{ id: "notif-2" }, { id: "notif-3" }]); |
51 | 52 | |
52 | 53 | const response = await listNotificationsGet({ |
| @@ -60,7 +61,7 @@ describe("notifications list route", () => { | ||
60 | 61 | expect(body?.success).toBe(true); |
61 | 62 | expect(body?.data?.notifications).toHaveLength(1); |
62 | 63 | expect(body?.data?.unreadCount).toBe(2); |
63 | - expect(mockDb.query.notifications.findMany).toHaveBeenCalledTimes(2); | |
64 | + expect(mockDb.query.notifications.findMany).toHaveBeenCalledTimes(3); | |
64 | 65 | }); |
65 | 66 | |
66 | 67 | it("sorts by priority score when prioritize=true", async () => { |
67 | 68 | |
▼tests/unit/openapi-notifications-parity.test.ts
+20-0
| @@ -0,0 +1,20 @@ | ||
1 | +import { describe, expect, it } from "vitest"; | |
2 | +import { openApiSpec } from "@/lib/openapi"; | |
3 | + | |
4 | +describe("openapi notifications parity", () => { | |
5 | + it("includes notification prioritization, blocking, and digest operations", () => { | |
6 | + const paths = openApiSpec.paths as Record<string, unknown>; | |
7 | + | |
8 | + expect(paths["/notifications"]).toBeTruthy(); | |
9 | + expect(paths["/notifications/blocking/summary"]).toBeTruthy(); | |
10 | + expect(paths["/notifications/blocking/escalations"]).toBeTruthy(); | |
11 | + | |
12 | + expect(paths["/user/notification-digests/test"]).toBeTruthy(); | |
13 | + expect(paths["/user/notification-digests/analytics"]).toBeTruthy(); | |
14 | + expect(paths["/user/notification-digests/dead-letter"]).toBeTruthy(); | |
15 | + expect(paths["/user/notification-digests/dead-letter/retry"]).toBeTruthy(); | |
16 | + | |
17 | + expect(paths["/admin/plugins"]).toBeTruthy(); | |
18 | + expect(paths["/admin/plugins/{name}"]).toBeTruthy(); | |
19 | + }); | |
20 | +}); | |
0 | 21 | |
▼tests/unit/permissions.test.ts
+162-0
| @@ -0,0 +1,162 @@ | ||
1 | +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
2 | + | |
3 | +// Mock database | |
4 | +const mockDb = { | |
5 | + query: { | |
6 | + repositoryCollaborators: { findFirst: vi.fn() }, | |
7 | + organizationMembers: { findFirst: vi.fn(), findMany: vi.fn() }, | |
8 | + customRoles: { findFirst: vi.fn() }, | |
9 | + organizations: { findFirst: vi.fn() }, | |
10 | + }, | |
11 | +}; | |
12 | + | |
13 | +vi.mock("@/db", () => ({ | |
14 | + getDatabase: () => mockDb, | |
15 | + schema: { | |
16 | + repositoryCollaborators: {}, | |
17 | + organizationMembers: {}, | |
18 | + customRoles: {}, | |
19 | + organizations: {}, | |
20 | + }, | |
21 | +})); | |
22 | + | |
23 | +// Now import the module under test | |
24 | +const { | |
25 | + getRepoPermission, | |
26 | + getOrgPermission, | |
27 | + canReadRepo, | |
28 | + canWriteRepo, | |
29 | + canAdminRepo, | |
30 | + canAdminOrg, | |
31 | +} = await import("@/lib/permissions"); | |
32 | + | |
33 | +beforeEach(() => { | |
34 | + vi.clearAllMocks(); | |
35 | + mockDb.query.repositoryCollaborators.findFirst.mockResolvedValue(null); | |
36 | + mockDb.query.organizationMembers.findFirst.mockResolvedValue(null); | |
37 | + mockDb.query.organizationMembers.findMany.mockResolvedValue([]); | |
38 | + mockDb.query.customRoles.findFirst.mockResolvedValue(null); | |
39 | + mockDb.query.organizations.findFirst.mockResolvedValue(null); | |
40 | +}); | |
41 | + | |
42 | +describe("getRepoPermission", () => { | |
43 | + const publicRepo = { | |
44 | + id: "r1", | |
45 | + ownerId: "u1", | |
46 | + visibility: "public", | |
47 | + } as any; | |
48 | + | |
49 | + const privateRepo = { | |
50 | + id: "r2", | |
51 | + ownerId: "u1", | |
52 | + visibility: "private", | |
53 | + } as any; | |
54 | + | |
55 | + it("returns admin for site admin", async () => { | |
56 | + const result = await getRepoPermission("u999", privateRepo, { | |
57 | + isAdmin: true, | |
58 | + }); | |
59 | + expect(result).toBe("admin"); | |
60 | + }); | |
61 | + | |
62 | + it("returns admin for repo owner", async () => { | |
63 | + const result = await getRepoPermission("u1", publicRepo); | |
64 | + expect(result).toBe("admin"); | |
65 | + }); | |
66 | + | |
67 | + it("returns read for public repo (unauthenticated)", async () => { | |
68 | + const result = await getRepoPermission(undefined, publicRepo); | |
69 | + expect(result).toBe("read"); | |
70 | + }); | |
71 | + | |
72 | + it("returns none for private repo (unauthenticated)", async () => { | |
73 | + const result = await getRepoPermission(undefined, privateRepo); | |
74 | + expect(result).toBe("none"); | |
75 | + }); | |
76 | + | |
77 | + it("returns collaborator permission when found", async () => { | |
78 | + mockDb.query.repositoryCollaborators.findFirst.mockResolvedValue({ | |
79 | + permission: "write", | |
80 | + }); | |
81 | + const result = await getRepoPermission("u2", privateRepo); | |
82 | + expect(result).toBe("write"); | |
83 | + }); | |
84 | +}); | |
85 | + | |
86 | +describe("canReadRepo", () => { | |
87 | + const publicRepo = { id: "r1", ownerId: "u1", visibility: "public" } as any; | |
88 | + const privateRepo = { id: "r2", ownerId: "u1", visibility: "private" } as any; | |
89 | + | |
90 | + it("allows read on public repo", async () => { | |
91 | + expect(await canReadRepo(undefined, publicRepo)).toBe(true); | |
92 | + }); | |
93 | + | |
94 | + it("denies read on private repo for unauthenticated user", async () => { | |
95 | + expect(await canReadRepo(undefined, privateRepo)).toBe(false); | |
96 | + }); | |
97 | + | |
98 | + it("allows read for owner", async () => { | |
99 | + expect(await canReadRepo("u1", privateRepo)).toBe(true); | |
100 | + }); | |
101 | + | |
102 | + it("allows read for admin", async () => { | |
103 | + expect(await canReadRepo("u999", privateRepo, { isAdmin: true })).toBe( | |
104 | + true, | |
105 | + ); | |
106 | + }); | |
107 | +}); | |
108 | + | |
109 | +describe("canWriteRepo", () => { | |
110 | + const repo = { id: "r1", ownerId: "u1", visibility: "private" } as any; | |
111 | + | |
112 | + it("allows write for owner", async () => { | |
113 | + expect(await canWriteRepo("u1", repo)).toBe(true); | |
114 | + }); | |
115 | + | |
116 | + it("denies write for unauthenticated", async () => { | |
117 | + expect(await canWriteRepo(undefined, repo)).toBe(false); | |
118 | + }); | |
119 | + | |
120 | + it("denies write for reader-only collaborator", async () => { | |
121 | + mockDb.query.repositoryCollaborators.findFirst.mockResolvedValue({ | |
122 | + permission: "read", | |
123 | + }); | |
124 | + expect(await canWriteRepo("u2", repo)).toBe(false); | |
125 | + }); | |
126 | + | |
127 | + it("allows write for write collaborator", async () => { | |
128 | + mockDb.query.repositoryCollaborators.findFirst.mockResolvedValue({ | |
129 | + permission: "write", | |
130 | + }); | |
131 | + expect(await canWriteRepo("u2", repo)).toBe(true); | |
132 | + }); | |
133 | +}); | |
134 | + | |
135 | +describe("canAdminRepo", () => { | |
136 | + const repo = { id: "r1", ownerId: "u1", visibility: "private" } as any; | |
137 | + | |
138 | + it("allows admin for owner", async () => { | |
139 | + expect(await canAdminRepo("u1", repo)).toBe(true); | |
140 | + }); | |
141 | + | |
142 | + it("allows admin for site admin", async () => { | |
143 | + expect(await canAdminRepo("u999", repo, { isAdmin: true })).toBe(true); | |
144 | + }); | |
145 | + | |
146 | + it("denies admin for write collaborator", async () => { | |
147 | + mockDb.query.repositoryCollaborators.findFirst.mockResolvedValue({ | |
148 | + permission: "write", | |
149 | + }); | |
150 | + expect(await canAdminRepo("u2", repo)).toBe(false); | |
151 | + }); | |
152 | +}); | |
153 | + | |
154 | +describe("canAdminOrg", () => { | |
155 | + it("allows for site admin", async () => { | |
156 | + expect(await canAdminOrg("u999", "org1", { isAdmin: true })).toBe(true); | |
157 | + }); | |
158 | + | |
159 | + it("denies for unauthenticated", async () => { | |
160 | + expect(await canAdminOrg(undefined, "org1")).toBe(false); | |
161 | + }); | |
162 | +}); | |
0 | 163 | |
▼tests/unit/runner-registration-token.test.ts
+27-0
| @@ -0,0 +1,27 @@ | ||
1 | +import { describe, expect, it } from "vitest"; | |
2 | +import { | |
3 | + buildRunnerRegistrationTokenName, | |
4 | + createRunnerRegistrationSecret, | |
5 | + getRunnerRegistrationTokenTtlDays, | |
6 | + isRunnerRegistrationTokenName, | |
7 | +} from "@/lib/runner-registration-token"; | |
8 | + | |
9 | +describe("runner registration tokens", () => { | |
10 | + it("builds and recognizes registration token names", () => { | |
11 | + const tokenName = buildRunnerRegistrationTokenName("token-123"); | |
12 | + expect(tokenName).toBe("ACTIONS_RUNNER_TOKEN:token-123"); | |
13 | + expect(isRunnerRegistrationTokenName(tokenName)).toBe(true); | |
14 | + expect(isRunnerRegistrationTokenName("ACTIONS_RUNNER_TOKEN")).toBe(true); | |
15 | + expect(isRunnerRegistrationTokenName("OTHER_TOKEN")).toBe(false); | |
16 | + }); | |
17 | + | |
18 | + it("generates reasonably strong secrets", () => { | |
19 | + const secret = createRunnerRegistrationSecret(); | |
20 | + expect(secret.length).toBeGreaterThanOrEqual(48); | |
21 | + }); | |
22 | + | |
23 | + it("uses positive TTL defaults", () => { | |
24 | + delete process.env.RUNNER_REGISTRATION_TOKEN_TTL_DAYS; | |
25 | + expect(getRunnerRegistrationTokenTtlDays()).toBeGreaterThan(0); | |
26 | + }); | |
27 | +}); | |
0 | 28 | |
▼tests/unit/sanitize.test.ts
+113-0
| @@ -0,0 +1,113 @@ | ||
1 | +import { escapeHtml, sanitizeHtml } from "@/lib/sanitize"; | |
2 | +import { describe, expect, it } from "vitest"; | |
3 | + | |
4 | +describe("sanitizeHtml", () => { | |
5 | + it("removes script tags", () => { | |
6 | + expect(sanitizeHtml('<script>alert("xss")</script>')).not.toContain( | |
7 | + "<script", | |
8 | + ); | |
9 | + }); | |
10 | + | |
11 | + it("removes script tags with attributes", () => { | |
12 | + expect(sanitizeHtml('<script src="evil.js"></script>')).not.toContain( | |
13 | + "<script", | |
14 | + ); | |
15 | + }); | |
16 | + | |
17 | + it("removes event handler attributes", () => { | |
18 | + const result = sanitizeHtml('<img src="x" onerror="alert(1)" />'); | |
19 | + expect(result).not.toContain("onerror"); | |
20 | + }); | |
21 | + | |
22 | + it("removes onclick handlers", () => { | |
23 | + const result = sanitizeHtml('<button onclick="steal()">Click</button>'); | |
24 | + expect(result).not.toContain("onclick"); | |
25 | + }); | |
26 | + | |
27 | + it("removes javascript: URIs", () => { | |
28 | + const result = sanitizeHtml('<a href="javascript:alert(1)">link</a>'); | |
29 | + expect(result).not.toContain("javascript:"); | |
30 | + }); | |
31 | + | |
32 | + it("removes iframe tags", () => { | |
33 | + const result = sanitizeHtml('<iframe src="http://evil.com"></iframe>'); | |
34 | + expect(result).not.toContain("<iframe"); | |
35 | + }); | |
36 | + | |
37 | + it("removes object tags", () => { | |
38 | + const result = sanitizeHtml('<object data="malware.swf"></object>'); | |
39 | + expect(result).not.toContain("<object"); | |
40 | + }); | |
41 | + | |
42 | + it("removes embed tags", () => { | |
43 | + const result = sanitizeHtml('<embed src="malware.swf" />'); | |
44 | + expect(result).not.toContain("<embed"); | |
45 | + }); | |
46 | + | |
47 | + it("removes form tags", () => { | |
48 | + const result = sanitizeHtml( | |
49 | + '<form action="http://evil.com"><input /></form>', | |
50 | + ); | |
51 | + expect(result).not.toContain("<form"); | |
52 | + }); | |
53 | + | |
54 | + it("allows safe tags", () => { | |
55 | + const safe = "<p>Hello <strong>world</strong></p>"; | |
56 | + const result = sanitizeHtml(safe); | |
57 | + expect(result).toContain("<p>"); | |
58 | + expect(result).toContain("<strong>"); | |
59 | + }); | |
60 | + | |
61 | + it("handles empty string", () => { | |
62 | + expect(sanitizeHtml("")).toBe(""); | |
63 | + }); | |
64 | + | |
65 | + it("handles plain text", () => { | |
66 | + expect(sanitizeHtml("Hello world")).toBe("Hello world"); | |
67 | + }); | |
68 | + | |
69 | + it("handles nested malicious tags", () => { | |
70 | + const result = sanitizeHtml( | |
71 | + "<div><script><script>alert(1)</script></script></div>", | |
72 | + ); | |
73 | + expect(result).not.toContain("<script"); | |
74 | + }); | |
75 | +}); | |
76 | + | |
77 | +describe("escapeHtml", () => { | |
78 | + it("escapes ampersands", () => { | |
79 | + expect(escapeHtml("foo & bar")).toBe("foo & bar"); | |
80 | + }); | |
81 | + | |
82 | + it("escapes less-than", () => { | |
83 | + expect(escapeHtml("a < b")).toBe("a < b"); | |
84 | + }); | |
85 | + | |
86 | + it("escapes greater-than", () => { | |
87 | + expect(escapeHtml("a > b")).toBe("a > b"); | |
88 | + }); | |
89 | + | |
90 | + it("escapes double quotes", () => { | |
91 | + expect(escapeHtml('say "hello"')).toBe("say "hello""); | |
92 | + }); | |
93 | + | |
94 | + it("escapes single quotes", () => { | |
95 | + const result = escapeHtml("it's"); | |
96 | + expect(result).toContain("&#"); | |
97 | + }); | |
98 | + | |
99 | + it("handles empty string", () => { | |
100 | + expect(escapeHtml("")).toBe(""); | |
101 | + }); | |
102 | + | |
103 | + it("handles already safe text", () => { | |
104 | + expect(escapeHtml("Hello world")).toBe("Hello world"); | |
105 | + }); | |
106 | + | |
107 | + it("escapes all special chars combined", () => { | |
108 | + const result = escapeHtml("<script>\"alert('xss')&</script>"); | |
109 | + expect(result).not.toContain("<"); | |
110 | + expect(result).not.toContain(">"); | |
111 | + expect(result).toContain("&"); | |
112 | + }); | |
113 | +}); | |
0 | 114 | |
▼tests/unit/security-policies.test.ts
+37-0
| @@ -0,0 +1,37 @@ | ||
1 | +import { describe, expect, it } from "vitest"; | |
2 | +import { | |
3 | + evaluateLicensePolicy, | |
4 | + evaluateSecretPolicy, | |
5 | +} from "@/lib/security-policies"; | |
6 | + | |
7 | +const basePolicy = { | |
8 | + enforcementMode: "block" as const, | |
9 | + secretBlockedTypes: ["aws_access_key"], | |
10 | + secretMinSeverity: "HIGH" as const, | |
11 | + licenseAllowedTypes: ["permissive"], | |
12 | + licenseBlockedLicenses: ["GPL-3.0"], | |
13 | + isEnabled: true, | |
14 | +}; | |
15 | + | |
16 | +describe("security policy evaluation", () => { | |
17 | + it("flags blocked secret type", () => { | |
18 | + const result = evaluateSecretPolicy(basePolicy, "aws_access_key", "LOW"); | |
19 | + expect(result.violated).toBe(true); | |
20 | + }); | |
21 | + | |
22 | + it("flags secret by severity threshold", () => { | |
23 | + const result = evaluateSecretPolicy(basePolicy, "generic", "CRITICAL"); | |
24 | + expect(result.violated).toBe(true); | |
25 | + }); | |
26 | + | |
27 | + it("flags blocked license name", () => { | |
28 | + const result = evaluateLicensePolicy(basePolicy, "copyleft", "LICENSE-GPL-3.0", "License: GPL-3.0"); | |
29 | + expect(result.violated).toBe(true); | |
30 | + }); | |
31 | + | |
32 | + it("flags disallowed license type", () => { | |
33 | + const result = evaluateLicensePolicy(basePolicy, "copyleft", "LICENSE-MPL-2.0", "License: MPL-2.0"); | |
34 | + expect(result.violated).toBe(true); | |
35 | + }); | |
36 | +}); | |
37 | + | |
0 | 38 | |
▼tests/unit/suggested-changes.test.ts
+68-0
| @@ -0,0 +1,68 @@ | ||
1 | +import { parseSuggestionFromComment } from "@/lib/suggested-changes"; | |
2 | +import { describe, expect, it } from "vitest"; | |
3 | + | |
4 | +describe("parseSuggestionFromComment", () => { | |
5 | + it("extracts suggestion from a comment body", () => { | |
6 | + const body = ` | |
7 | +Some text before. | |
8 | + | |
9 | +\`\`\`suggestion | |
10 | +const x = 42; | |
11 | +\`\`\` | |
12 | + | |
13 | +Some text after. | |
14 | + `.trim(); | |
15 | + | |
16 | + const result = parseSuggestionFromComment(body); | |
17 | + expect(result).toBeDefined(); | |
18 | + expect(result).toContain("const x = 42;"); | |
19 | + }); | |
20 | + | |
21 | + it("returns null for comment without suggestion", () => { | |
22 | + const body = "This is a regular comment without any suggestion block."; | |
23 | + const result = parseSuggestionFromComment(body); | |
24 | + expect(result).toBeNull(); | |
25 | + }); | |
26 | + | |
27 | + it("returns null for empty string", () => { | |
28 | + expect(parseSuggestionFromComment("")).toBeNull(); | |
29 | + }); | |
30 | + | |
31 | + it("extracts multi-line suggestion", () => { | |
32 | + const body = ` | |
33 | +Review comment. | |
34 | + | |
35 | +\`\`\`suggestion | |
36 | +function hello() { | |
37 | + return "world"; | |
38 | +} | |
39 | +\`\`\` | |
40 | + `.trim(); | |
41 | + | |
42 | + const result = parseSuggestionFromComment(body); | |
43 | + expect(result).toBeDefined(); | |
44 | + expect(result).toContain("function hello()"); | |
45 | + expect(result).toContain('return "world"'); | |
46 | + }); | |
47 | + | |
48 | + it("handles suggestion at start of body", () => { | |
49 | + const body = `\`\`\`suggestion | |
50 | +fix this | |
51 | +\`\`\``; | |
52 | + | |
53 | + const result = parseSuggestionFromComment(body); | |
54 | + expect(result).toBeDefined(); | |
55 | + expect(result).toContain("fix this"); | |
56 | + }); | |
57 | + | |
58 | + it("does not confuse regular code blocks", () => { | |
59 | + const body = ` | |
60 | +\`\`\`javascript | |
61 | +const x = 1; | |
62 | +\`\`\` | |
63 | + `.trim(); | |
64 | + | |
65 | + const result = parseSuggestionFromComment(body); | |
66 | + expect(result).toBeNull(); | |
67 | + }); | |
68 | +}); | |
0 | 69 | |
▼tests/unit/url-validator.test.ts
+100-0
| @@ -0,0 +1,100 @@ | ||
1 | +import { isAllowedWebhookUrlSync } from "@/lib/url-validator"; | |
2 | +import { describe, expect, it } from "vitest"; | |
3 | + | |
4 | +describe("isAllowedWebhookUrlSync", () => { | |
5 | + describe("blocks private/reserved IPs", () => { | |
6 | + it("blocks localhost", () => { | |
7 | + expect(isAllowedWebhookUrlSync("http://localhost/hook")).toBe(false); | |
8 | + }); | |
9 | + | |
10 | + it("blocks localhost with port", () => { | |
11 | + expect(isAllowedWebhookUrlSync("http://localhost:8080/hook")).toBe(false); | |
12 | + }); | |
13 | + | |
14 | + it("blocks 127.0.0.1", () => { | |
15 | + expect(isAllowedWebhookUrlSync("http://127.0.0.1/hook")).toBe(false); | |
16 | + }); | |
17 | + | |
18 | + it("blocks 10.x.x.x range", () => { | |
19 | + expect(isAllowedWebhookUrlSync("http://10.0.0.1/hook")).toBe(false); | |
20 | + }); | |
21 | + | |
22 | + it("blocks 192.168.x.x range", () => { | |
23 | + expect(isAllowedWebhookUrlSync("http://192.168.1.1/hook")).toBe(false); | |
24 | + }); | |
25 | + | |
26 | + it("blocks 172.16.x.x range", () => { | |
27 | + expect(isAllowedWebhookUrlSync("http://172.16.0.1/hook")).toBe(false); | |
28 | + }); | |
29 | + | |
30 | + it("blocks 169.254.x.x metadata range", () => { | |
31 | + expect( | |
32 | + isAllowedWebhookUrlSync("http://169.254.169.254/latest/meta-data"), | |
33 | + ).toBe(false); | |
34 | + }); | |
35 | + | |
36 | + it("blocks 0.0.0.0", () => { | |
37 | + expect(isAllowedWebhookUrlSync("http://0.0.0.0/hook")).toBe(false); | |
38 | + }); | |
39 | + | |
40 | + it("blocks [::1] (IPv6 loopback)", () => { | |
41 | + expect(isAllowedWebhookUrlSync("http://[::1]/hook")).toBe(false); | |
42 | + }); | |
43 | + }); | |
44 | + | |
45 | + describe("blocks non-http(s) schemes", () => { | |
46 | + it("blocks ftp scheme", () => { | |
47 | + expect(isAllowedWebhookUrlSync("ftp://example.com/hook")).toBe(false); | |
48 | + }); | |
49 | + | |
50 | + it("blocks file scheme", () => { | |
51 | + expect(isAllowedWebhookUrlSync("file:///etc/passwd")).toBe(false); | |
52 | + }); | |
53 | + | |
54 | + it("blocks javascript scheme", () => { | |
55 | + expect(isAllowedWebhookUrlSync("javascript:alert(1)")).toBe(false); | |
56 | + }); | |
57 | + | |
58 | + it("blocks data scheme", () => { | |
59 | + expect( | |
60 | + isAllowedWebhookUrlSync("data:text/html,<script>alert(1)</script>"), | |
61 | + ).toBe(false); | |
62 | + }); | |
63 | + }); | |
64 | + | |
65 | + describe("allows valid public URLs", () => { | |
66 | + it("allows https public URL", () => { | |
67 | + expect(isAllowedWebhookUrlSync("https://example.com/webhook")).toBe(true); | |
68 | + }); | |
69 | + | |
70 | + it("allows http public URL", () => { | |
71 | + expect(isAllowedWebhookUrlSync("http://example.com/webhook")).toBe(true); | |
72 | + }); | |
73 | + | |
74 | + it("allows public IP", () => { | |
75 | + expect(isAllowedWebhookUrlSync("http://8.8.8.8/hook")).toBe(true); | |
76 | + }); | |
77 | + | |
78 | + it("allows URL with port", () => { | |
79 | + expect( | |
80 | + isAllowedWebhookUrlSync("https://hooks.example.com:9000/webhook"), | |
81 | + ).toBe(true); | |
82 | + }); | |
83 | + | |
84 | + it("allows URL with path", () => { | |
85 | + expect( | |
86 | + isAllowedWebhookUrlSync("https://api.example.com/v1/hooks/receive"), | |
87 | + ).toBe(true); | |
88 | + }); | |
89 | + }); | |
90 | + | |
91 | + describe("edge cases", () => { | |
92 | + it("rejects empty string", () => { | |
93 | + expect(isAllowedWebhookUrlSync("")).toBe(false); | |
94 | + }); | |
95 | + | |
96 | + it("rejects invalid URL", () => { | |
97 | + expect(isAllowedWebhookUrlSync("not-a-url")).toBe(false); | |
98 | + }); | |
99 | + }); | |
100 | +}); | |
0 | 101 | |
▼tests/unit/validation.test.ts
+268-0
| @@ -0,0 +1,268 @@ | ||
1 | +import { | |
2 | + BranchProtectionSchema, | |
3 | + CreateIssueSchema, | |
4 | + CreatePullRequestSchema, | |
5 | + CreateRepositorySchema, | |
6 | + GeneralConfigSchema, | |
7 | + RegisterUserSchema, | |
8 | + StorageConfigSchema, | |
9 | + WebhookConfigSchema, | |
10 | +} from "@/lib/validation"; | |
11 | +import { describe, expect, it } from "vitest"; | |
12 | + | |
13 | +describe("RegisterUserSchema", () => { | |
14 | + it("accepts a valid registration", () => { | |
15 | + const result = RegisterUserSchema.safeParse({ | |
16 | + username: "testuser", | |
17 | + email: "[email protected]", | |
18 | + password: "StrongP@ss123", | |
19 | + displayName: "Test User", | |
20 | + }); | |
21 | + expect(result.success).toBe(true); | |
22 | + }); | |
23 | + | |
24 | + it("rejects missing username", () => { | |
25 | + const result = RegisterUserSchema.safeParse({ | |
26 | + email: "[email protected]", | |
27 | + password: "StrongP@ss123", | |
28 | + }); | |
29 | + expect(result.success).toBe(false); | |
30 | + }); | |
31 | + | |
32 | + it("rejects invalid email", () => { | |
33 | + const result = RegisterUserSchema.safeParse({ | |
34 | + username: "testuser", | |
35 | + email: "notanemail", | |
36 | + password: "StrongP@ss123", | |
37 | + }); | |
38 | + expect(result.success).toBe(false); | |
39 | + }); | |
40 | + | |
41 | + it("rejects short password", () => { | |
42 | + const result = RegisterUserSchema.safeParse({ | |
43 | + username: "testuser", | |
44 | + email: "[email protected]", | |
45 | + password: "abc", | |
46 | + }); | |
47 | + expect(result.success).toBe(false); | |
48 | + }); | |
49 | + | |
50 | + it("rejects empty username", () => { | |
51 | + const result = RegisterUserSchema.safeParse({ | |
52 | + username: "", | |
53 | + email: "[email protected]", | |
54 | + password: "StrongP@ss123", | |
55 | + }); | |
56 | + expect(result.success).toBe(false); | |
57 | + }); | |
58 | +}); | |
59 | + | |
60 | +describe("CreateRepositorySchema", () => { | |
61 | + it("accepts minimal valid repo", () => { | |
62 | + const result = CreateRepositorySchema.safeParse({ | |
63 | + name: "my-repo", | |
64 | + }); | |
65 | + expect(result.success).toBe(true); | |
66 | + }); | |
67 | + | |
68 | + it("accepts full valid repo", () => { | |
69 | + const result = CreateRepositorySchema.safeParse({ | |
70 | + name: "my-repo", | |
71 | + description: "A test repository", | |
72 | + visibility: "public", | |
73 | + defaultBranch: "main", | |
74 | + }); | |
75 | + expect(result.success).toBe(true); | |
76 | + }); | |
77 | + | |
78 | + it("rejects missing name", () => { | |
79 | + const result = CreateRepositorySchema.safeParse({ | |
80 | + description: "A test repository", | |
81 | + }); | |
82 | + expect(result.success).toBe(false); | |
83 | + }); | |
84 | + | |
85 | + it("rejects empty name", () => { | |
86 | + const result = CreateRepositorySchema.safeParse({ name: "" }); | |
87 | + expect(result.success).toBe(false); | |
88 | + }); | |
89 | + | |
90 | + it("rejects invalid visibility", () => { | |
91 | + const result = CreateRepositorySchema.safeParse({ | |
92 | + name: "my-repo", | |
93 | + visibility: "invalid-mode", | |
94 | + }); | |
95 | + expect(result.success).toBe(false); | |
96 | + }); | |
97 | +}); | |
98 | + | |
99 | +describe("CreateIssueSchema", () => { | |
100 | + it("accepts a valid issue", () => { | |
101 | + const result = CreateIssueSchema.safeParse({ | |
102 | + title: "Bug report", | |
103 | + body: "Something is broken", | |
104 | + }); | |
105 | + expect(result.success).toBe(true); | |
106 | + }); | |
107 | + | |
108 | + it("rejects empty title", () => { | |
109 | + const result = CreateIssueSchema.safeParse({ | |
110 | + title: "", | |
111 | + body: "Something is broken", | |
112 | + }); | |
113 | + expect(result.success).toBe(false); | |
114 | + }); | |
115 | + | |
116 | + it("rejects missing title", () => { | |
117 | + const result = CreateIssueSchema.safeParse({ | |
118 | + body: "Something is broken", | |
119 | + }); | |
120 | + expect(result.success).toBe(false); | |
121 | + }); | |
122 | + | |
123 | + it("sanitizes HTML in body", () => { | |
124 | + const result = CreateIssueSchema.safeParse({ | |
125 | + title: "XSS test", | |
126 | + body: '<script>alert("xss")</script>normal text', | |
127 | + }); | |
128 | + expect(result.success).toBe(true); | |
129 | + if (result.success) { | |
130 | + expect(result.data.body).not.toContain("<script"); | |
131 | + } | |
132 | + }); | |
133 | +}); | |
134 | + | |
135 | +describe("CreatePullRequestSchema", () => { | |
136 | + it("accepts a valid pull request", () => { | |
137 | + const result = CreatePullRequestSchema.safeParse({ | |
138 | + title: "New feature", | |
139 | + body: "Adds cool stuff", | |
140 | + head: "feature-branch", | |
141 | + base: "main", | |
142 | + }); | |
143 | + expect(result.success).toBe(true); | |
144 | + }); | |
145 | + | |
146 | + it("rejects missing head branch", () => { | |
147 | + const result = CreatePullRequestSchema.safeParse({ | |
148 | + title: "New feature", | |
149 | + base: "main", | |
150 | + }); | |
151 | + expect(result.success).toBe(false); | |
152 | + }); | |
153 | + | |
154 | + it("rejects missing base branch", () => { | |
155 | + const result = CreatePullRequestSchema.safeParse({ | |
156 | + title: "New feature", | |
157 | + head: "feature-branch", | |
158 | + }); | |
159 | + expect(result.success).toBe(false); | |
160 | + }); | |
161 | + | |
162 | + it("rejects missing title", () => { | |
163 | + const result = CreatePullRequestSchema.safeParse({ | |
164 | + head: "feature", | |
165 | + base: "main", | |
166 | + }); | |
167 | + expect(result.success).toBe(false); | |
168 | + }); | |
169 | +}); | |
170 | + | |
171 | +describe("BranchProtectionSchema", () => { | |
172 | + it("accepts valid protection rules", () => { | |
173 | + const result = BranchProtectionSchema.safeParse({ | |
174 | + pattern: "main", | |
175 | + active: true, | |
176 | + requiresPr: true, | |
177 | + requiredApprovals: 2, | |
178 | + }); | |
179 | + expect(result.success).toBe(true); | |
180 | + }); | |
181 | + | |
182 | + it("accepts minimal pattern", () => { | |
183 | + const result = BranchProtectionSchema.safeParse({ | |
184 | + pattern: "main", | |
185 | + }); | |
186 | + expect(result.success).toBe(true); | |
187 | + }); | |
188 | + | |
189 | + it("rejects missing pattern", () => { | |
190 | + const result = BranchProtectionSchema.safeParse({ | |
191 | + active: true, | |
192 | + }); | |
193 | + expect(result.success).toBe(false); | |
194 | + }); | |
195 | + | |
196 | + it("rejects negative approvals", () => { | |
197 | + const result = BranchProtectionSchema.safeParse({ | |
198 | + pattern: "main", | |
199 | + requiredApprovals: -1, | |
200 | + }); | |
201 | + expect(result.success).toBe(false); | |
202 | + }); | |
203 | +}); | |
204 | + | |
205 | +describe("WebhookConfigSchema", () => { | |
206 | + it("accepts valid webhook config", () => { | |
207 | + const result = WebhookConfigSchema.safeParse({ | |
208 | + url: "https://example.com/webhook", | |
209 | + events: ["push", "pull_request"], | |
210 | + secret: "mysecret", | |
211 | + active: true, | |
212 | + }); | |
213 | + expect(result.success).toBe(true); | |
214 | + }); | |
215 | + | |
216 | + it("rejects missing url", () => { | |
217 | + const result = WebhookConfigSchema.safeParse({ | |
218 | + events: ["push"], | |
219 | + }); | |
220 | + expect(result.success).toBe(false); | |
221 | + }); | |
222 | + | |
223 | + it("rejects private IP urls (SSRF protection)", () => { | |
224 | + const result = WebhookConfigSchema.safeParse({ | |
225 | + url: "http://127.0.0.1:8080/hook", | |
226 | + events: ["push"], | |
227 | + }); | |
228 | + // Should fail SSRF refine | |
229 | + expect(result.success).toBe(false); | |
230 | + }); | |
231 | + | |
232 | + it("rejects localhost urls", () => { | |
233 | + const result = WebhookConfigSchema.safeParse({ | |
234 | + url: "http://localhost:3000/hook", | |
235 | + events: ["push"], | |
236 | + }); | |
237 | + expect(result.success).toBe(false); | |
238 | + }); | |
239 | +}); | |
240 | + | |
241 | +describe("StorageConfigSchema", () => { | |
242 | + it("accepts local storage config", () => { | |
243 | + const result = StorageConfigSchema.safeParse({ | |
244 | + type: "local", | |
245 | + basePath: "/data/storage", | |
246 | + }); | |
247 | + expect(result.success).toBe(true); | |
248 | + }); | |
249 | + | |
250 | + it("accepts s3 storage config", () => { | |
251 | + const result = StorageConfigSchema.safeParse({ | |
252 | + type: "s3", | |
253 | + bucket: "my-bucket", | |
254 | + region: "us-east-1", | |
255 | + }); | |
256 | + expect(result.success).toBe(true); | |
257 | + }); | |
258 | +}); | |
259 | + | |
260 | +describe("GeneralConfigSchema", () => { | |
261 | + it("accepts valid general config", () => { | |
262 | + const result = GeneralConfigSchema.safeParse({ | |
263 | + siteName: "My CodeHub", | |
264 | + allowSignups: true, | |
265 | + }); | |
266 | + expect(result.success).toBe(true); | |
267 | + }); | |
268 | +}); | |
0 | 269 | |
▼tests/unit/workflow-secret-crypto.test.ts
+25-0
| @@ -0,0 +1,25 @@ | ||
1 | +import { describe, expect, it } from "vitest"; | |
2 | +import { | |
3 | + decryptWorkflowSecret, | |
4 | + encryptWorkflowSecret, | |
5 | + isEncryptedWorkflowSecret, | |
6 | +} from "@/lib/workflow-secret-crypto"; | |
7 | + | |
8 | +describe("workflow secret crypto", () => { | |
9 | + it("encrypts and decrypts values", () => { | |
10 | + process.env.WORKFLOW_SECRET_ENCRYPTION_KEY = | |
11 | + "workflow-secret-encryption-key-for-tests-32+"; | |
12 | + const plain = "runner-registration-token-123"; | |
13 | + const encrypted = encryptWorkflowSecret(plain); | |
14 | + | |
15 | + expect(isEncryptedWorkflowSecret(encrypted)).toBe(true); | |
16 | + expect(encrypted).not.toContain(plain); | |
17 | + expect(decryptWorkflowSecret(encrypted)).toBe(plain); | |
18 | + }); | |
19 | + | |
20 | + it("supports legacy plaintext values for backward compatibility", () => { | |
21 | + const plain = "legacy-plaintext-secret"; | |
22 | + expect(isEncryptedWorkflowSecret(plain)).toBe(false); | |
23 | + expect(decryptWorkflowSecret(plain)).toBe(plain); | |
24 | + }); | |
25 | +}); | |
0 | 26 | |