From 07b6d85591c384b5fa31d4a3909efa1ddaa2953e Mon Sep 17 00:00:00 2001 From: Brad DerManouelian Date: Sat, 28 Mar 2026 14:08:19 -0500 Subject: [PATCH 1/2] feat(coderepo): Add Gitea support and enhance repository configuration - Introduced Gitea as a supported code repository provider, including configuration options for personal access tokens and server URLs. - Updated documentation to reflect Gitea integration and provide clear instructions for users. - Enhanced SSRF protection by implementing DNS resolution checks for Gitea URLs. - Added tests for Gitea repository adapter to ensure proper functionality and integration. - Improved user interface to display Gitea options in the code repository settings. --- docs/docs/features.md | 2 +- docs/docs/user-guide/llm-quickscript.md | 10 +- .../admin/code-repositories/columns.tsx | 1 + .../CodeRepositoryConfigForm.tsx | 91 ++++-- .../code-repositories/CodeRepositoryModal.tsx | 3 +- .../adapters/GitRepoAdapter.test.ts | 113 ++++++- .../integrations/adapters/GitRepoAdapter.ts | 90 +++++- .../adapters/GiteaRepoAdapter.test.ts | 282 ++++++++++++++++++ .../integrations/adapters/GiteaRepoAdapter.ts | 116 +++++++ testplanit/lib/openapi/zenstack-openapi.json | 3 +- testplanit/messages/en-US.json | 11 +- testplanit/messages/es-ES.json | 11 +- testplanit/messages/fr-FR.json | 11 +- testplanit/prisma/schema.prisma | 1 + testplanit/schema.zmodel | 51 ++-- testplanit/utils/ssrf.test.ts | 94 +++++- testplanit/utils/ssrf.ts | 40 ++- 17 files changed, 856 insertions(+), 74 deletions(-) create mode 100644 testplanit/lib/integrations/adapters/GiteaRepoAdapter.test.ts create mode 100644 testplanit/lib/integrations/adapters/GiteaRepoAdapter.ts diff --git a/docs/docs/features.md b/docs/docs/features.md index 34badefa..311d7561 100644 --- a/docs/docs/features.md +++ b/docs/docs/features.md @@ -96,7 +96,7 @@ TestPlanIt is a comprehensive test management platform designed to help teams pl ### LLM Integration - **Test case generation** - Generate test cases from requirements using AI -- **QuickScript AI generation** - Convert manual test cases into automation scripts with AI, optionally informed by your code repository +- **QuickScript AI generation** - Convert manual test cases into automation scripts with AI, optionally informed by your code repository (GitHub, GitLab, Bitbucket, Azure DevOps, Gitea/Forgejo/Gogs) - **Enhance Writing** - Get AI recommendations to improve writing for any rich text field - **Magic Select** - AI-assisted test case selection for quickly building test runs - **Auto Tag** - Automatically suggest and apply tags to test cases, test runs, and sessions using AI analysis diff --git a/docs/docs/user-guide/llm-quickscript.md b/docs/docs/user-guide/llm-quickscript.md index b85d62eb..e62dd33f 100644 --- a/docs/docs/user-guide/llm-quickscript.md +++ b/docs/docs/user-guide/llm-quickscript.md @@ -27,7 +27,15 @@ Connecting a code repository gives the AI access to your actual source files, en ### 1. Add a Code Repository -Navigate to **Administration > Code Repositories** and add a connection to your repository (GitHub, GitLab, Bitbucket, or Azure DevOps). You'll need to provide credentials with read access to the repository. +Navigate to **Administration > Code Repositories** and add a connection to your repository. Supported providers: + +- **GitHub** — github.com or GitHub Enterprise +- **GitLab** — gitlab.com or self-hosted +- **Bitbucket Cloud** — bitbucket.org +- **Azure DevOps** — dev.azure.com +- **Gitea / Forgejo / Gogs** — self-hosted Git servers with a compatible `/api/v1/` REST API + +You'll need to provide credentials with read access to the repository. ### 2. Configure QuickScript Settings diff --git a/testplanit/app/[locale]/admin/code-repositories/columns.tsx b/testplanit/app/[locale]/admin/code-repositories/columns.tsx index 903ae2f9..40610886 100644 --- a/testplanit/app/[locale]/admin/code-repositories/columns.tsx +++ b/testplanit/app/[locale]/admin/code-repositories/columns.tsx @@ -25,6 +25,7 @@ const providerLabel: Record = { GITLAB: "GitLab", BITBUCKET: "Bitbucket", AZURE_DEVOPS: "Azure DevOps", + GITEA: "Gitea / Forgejo / Gogs", }; interface ColumnActions { diff --git a/testplanit/components/admin/code-repositories/CodeRepositoryConfigForm.tsx b/testplanit/components/admin/code-repositories/CodeRepositoryConfigForm.tsx index 98323576..6b2799a2 100644 --- a/testplanit/components/admin/code-repositories/CodeRepositoryConfigForm.tsx +++ b/testplanit/components/admin/code-repositories/CodeRepositoryConfigForm.tsx @@ -7,6 +7,8 @@ import { } from "@/components/ui/form"; import { HelpPopover } from "@/components/ui/help-popover"; import { Input } from "@/components/ui/input"; +import { ShieldAlert } from "lucide-react"; +import { useTranslations } from "next-intl"; import { UseFormReturn } from "react-hook-form"; interface FieldConfig { @@ -16,6 +18,7 @@ interface FieldConfig { type?: string; // "password" for sensitive fields isCredential: boolean; // true = goes in credentials object; false = goes in settings helpKey?: string; + isUrl?: boolean; // true = show HTTP plaintext warning when value starts with http:// } // Field definitions per provider -- mirrors IntegrationConfigForm.tsx providerFields pattern @@ -66,6 +69,7 @@ const providerFields: Record = { placeholder: "https://gitlab.com", isCredential: false, helpKey: "codeRepository.baseUrl", + isUrl: true, }, ], BITBUCKET: [ @@ -114,6 +118,7 @@ const providerFields: Record = { placeholder: "https://dev.azure.com/myorg", isCredential: false, helpKey: "codeRepository.organizationUrl", + isUrl: true, }, { name: "project", @@ -130,6 +135,38 @@ const providerFields: Record = { helpKey: "codeRepository.azureRepoId", }, ], + GITEA: [ + { + name: "personalAccessToken", + label: "Personal Access Token", + type: "password", + isCredential: true, + placeholder: "...", + helpKey: "codeRepository.giteaToken", + }, + { + name: "baseUrl", + label: "Server URL", + placeholder: "https://gitea.example.com", + isCredential: false, + helpKey: "codeRepository.giteaBaseUrl", + isUrl: true, + }, + { + name: "owner", + label: "Owner", + placeholder: "myorg", + isCredential: false, + helpKey: "codeRepository.giteaOwner", + }, + { + name: "repo", + label: "Repository", + placeholder: "my-repo", + isCredential: false, + helpKey: "codeRepository.giteaRepo", + }, + ], }; interface CodeRepositoryConfigFormProps { @@ -142,6 +179,7 @@ export function CodeRepositoryConfigForm({ form, }: CodeRepositoryConfigFormProps) { const fields = providerFields[provider] ?? []; + const t = useTranslations("admin.codeRepositories"); return (
@@ -155,26 +193,39 @@ export function CodeRepositoryConfigForm({ key={field.name} control={form.control} name={formFieldName} - render={({ field: formField }) => ( - - - {field.label} - - - - - - - - )} + render={({ field: formField }) => { + const showHttpWarning = + field.isUrl && + typeof formField.value === "string" && + formField.value.startsWith("http://"); + + return ( + + + {field.label} + + + + + + {showHttpWarning && ( +

+ + {t("httpWarning")} +

+ )} + +
+ ); + }} /> ); })} diff --git a/testplanit/components/admin/code-repositories/CodeRepositoryModal.tsx b/testplanit/components/admin/code-repositories/CodeRepositoryModal.tsx index 0f400635..44295a9f 100644 --- a/testplanit/components/admin/code-repositories/CodeRepositoryModal.tsx +++ b/testplanit/components/admin/code-repositories/CodeRepositoryModal.tsx @@ -38,11 +38,12 @@ const PROVIDERS = [ { value: "GITLAB", label: "GitLab" }, { value: "BITBUCKET", label: "Bitbucket Cloud" }, { value: "AZURE_DEVOPS", label: "Azure DevOps" }, + { value: "GITEA", label: "Gitea / Forgejo / Gogs" }, ] as const; const formSchema = z.object({ name: z.string().min(1, "Name is required"), - provider: z.enum(["GITHUB", "GITLAB", "BITBUCKET", "AZURE_DEVOPS"]), + provider: z.enum(["GITHUB", "GITLAB", "BITBUCKET", "AZURE_DEVOPS", "GITEA"]), credentials: z.record(z.string(), z.string()).optional(), settings: z.record(z.string(), z.string()).optional(), isActive: z.boolean().optional(), diff --git a/testplanit/lib/integrations/adapters/GitRepoAdapter.test.ts b/testplanit/lib/integrations/adapters/GitRepoAdapter.test.ts index a5a157fb..d6358355 100644 --- a/testplanit/lib/integrations/adapters/GitRepoAdapter.test.ts +++ b/testplanit/lib/integrations/adapters/GitRepoAdapter.test.ts @@ -1,11 +1,38 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { AzureDevOpsRepoAdapter } from "./AzureDevOpsRepoAdapter"; import { BitbucketRepoAdapter } from "./BitbucketRepoAdapter"; +import { GiteaRepoAdapter } from "./GiteaRepoAdapter"; import { GitHubRepoAdapter } from "./GitHubRepoAdapter"; import { GitLabRepoAdapter } from "./GitLabRepoAdapter"; // Stub fetch so adapters don't error -vi.stubGlobal("fetch", vi.fn()); +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +// Mock DNS resolution to avoid real lookups in tests +vi.mock("~/utils/ssrf", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + assertSsrfSafeResolved: vi.fn().mockResolvedValue(undefined), + }; +}); + +function makeResponse( + data: any, + status = 200, + headers: Record = {} +) { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? "OK" : "Error", + headers: new Headers(headers), + json: () => Promise.resolve(data), + text: () => Promise.resolve(typeof data === "string" ? data : JSON.stringify(data)), + url: "https://api.github.com/repos/test/test", + }; +} // The factory uses require() which fails in Vitest's ESM context. // Instead of testing the factory directly, test that each adapter can be @@ -53,6 +80,19 @@ describe("GitRepoAdapter adapter instantiation", () => { expect((adapter as any).project).toBe("myproject"); }); + it("instantiates GiteaRepoAdapter with credentials and settings", () => { + const adapter = new GiteaRepoAdapter(creds, { + baseUrl: "https://gitea.example.com", + owner: "testorg", + repo: "testrepo", + }); + expect(adapter).toBeInstanceOf(GiteaRepoAdapter); + expect((adapter as any).personalAccessToken).toBe("test-token"); + expect((adapter as any).baseUrl).toBe("https://gitea.example.com"); + expect((adapter as any).owner).toBe("testorg"); + expect((adapter as any).repo).toBe("testrepo"); + }); + it("handles null settings gracefully", () => { const adapter = new GitHubRepoAdapter(creds, null); expect(adapter).toBeInstanceOf(GitHubRepoAdapter); @@ -73,6 +113,7 @@ describe("GitRepoAdapter adapter instantiation", () => { new GitLabRepoAdapter(creds, {}), new BitbucketRepoAdapter({ username: "u", appPassword: "p" }, {}), new AzureDevOpsRepoAdapter(creds, {}), + new GiteaRepoAdapter(creds, { baseUrl: "https://gitea.example.com" }), ]; for (const adapter of adapters) { @@ -83,3 +124,71 @@ describe("GitRepoAdapter adapter instantiation", () => { } }); }); + +describe("GitRepoAdapter redirect protection", () => { + let adapter: GitHubRepoAdapter; + + beforeEach(() => { + vi.clearAllMocks(); + adapter = new GitHubRepoAdapter( + { personalAccessToken: "test-token" }, + { owner: "testorg", repo: "testrepo" } + ); + (adapter as any).rateLimitDelay = 0; + (adapter as any).lastRequestTime = 0; + }); + + it("follows safe redirects with SSRF validation", async () => { + // First response: redirect + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 301, + statusText: "Moved", + headers: new Headers({ Location: "https://api.github.com/repos/testorg/testrepo-new" }), + url: "https://api.github.com/repos/testorg/testrepo", + json: () => Promise.resolve({}), + text: () => Promise.resolve(""), + }); + // Second response: actual data after redirect + mockFetch.mockResolvedValueOnce(makeResponse({ default_branch: "main" })); + + const branch = await adapter.getDefaultBranch(); + + expect(branch).toBe("main"); + expect(mockFetch).toHaveBeenCalledTimes(2); + // Second fetch should use redirect: "error" to prevent chains + expect(mockFetch.mock.calls[1][1]).toEqual( + expect.objectContaining({ redirect: "error" }) + ); + }); + + it("rejects redirect with no Location header", async () => { + // Disable retries so the redirect error propagates directly + (adapter as any).maxRetries = 0; + + mockFetch.mockResolvedValue({ + ok: false, + status: 302, + statusText: "Found", + headers: new Headers({}), + url: "https://api.github.com/repos/testorg/testrepo", + json: () => Promise.resolve({}), + text: () => Promise.resolve(""), + }); + + await expect(adapter.getDefaultBranch()).rejects.toThrow( + "Redirect (302) with no Location header" + ); + }); + + it("uses redirect: manual to prevent automatic redirect following", async () => { + mockFetch.mockResolvedValueOnce(makeResponse({ default_branch: "main" })); + + await adapter.getDefaultBranch(); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ redirect: "manual" }) + ); + }); +}); diff --git a/testplanit/lib/integrations/adapters/GitRepoAdapter.ts b/testplanit/lib/integrations/adapters/GitRepoAdapter.ts index 63cad563..fdc7e5d2 100644 --- a/testplanit/lib/integrations/adapters/GitRepoAdapter.ts +++ b/testplanit/lib/integrations/adapters/GitRepoAdapter.ts @@ -4,7 +4,7 @@ * Does NOT extend BaseAdapter (which is for issue tracking, not git file fetching). */ -import { isSsrfSafe } from "~/utils/ssrf"; +import { assertSsrfSafeResolved, isSsrfSafe } from "~/utils/ssrf"; export interface RepoFileEntry { path: string; @@ -99,21 +99,25 @@ export abstract class GitRepoAdapter { try { const safeUrl = this.sanitizeUrl(url); + // Resolve DNS and verify the IP is not private (closes DNS rebinding) + await assertSsrfSafeResolved(safeUrl); const response = await fetch(safeUrl, { ...options, signal: controller.signal, + redirect: "manual", // prevent redirect-based SSRF bypass }); + // If the server redirects, validate the target before following + if (response.status >= 300 && response.status < 400) { + return this.followSafeRedirect(response, options, controller.signal, "json"); + } + // Track rate limit state from response headers for adaptive throttling + this.trackRateLimitHeaders(response); const remaining = response.headers.get("X-RateLimit-Remaining"); const reset = response.headers.get("X-RateLimit-Reset"); const retryAfter = response.headers.get("Retry-After"); - if (remaining !== null) this.rateLimitRemaining = parseInt(remaining); - if (reset !== null) this.rateLimitResetAt = parseInt(reset); - // Retry-After (secondary rate limits) overrides the reset time - if (retryAfter !== null) - this.rateLimitResetAt = Math.floor(Date.now() / 1000) + parseInt(retryAfter); if (!response.ok) { // Handle rate limiting: 429 is always a rate limit; 403 with @@ -183,19 +187,22 @@ export abstract class GitRepoAdapter { try { const safeUrl = this.sanitizeUrl(url); + await assertSsrfSafeResolved(safeUrl); + const response = await fetch(safeUrl, { ...options, signal: controller.signal, + redirect: "manual", }); + if (response.status >= 300 && response.status < 400) { + return this.followSafeRedirect(response, options, controller.signal, "text"); + } + // Track rate limit state from response headers + this.trackRateLimitHeaders(response); const remaining = response.headers.get("X-RateLimit-Remaining"); - const reset = response.headers.get("X-RateLimit-Reset"); const retryAfter = response.headers.get("Retry-After"); - if (remaining !== null) this.rateLimitRemaining = parseInt(remaining); - if (reset !== null) this.rateLimitResetAt = parseInt(reset); - if (retryAfter !== null) - this.rateLimitResetAt = Math.floor(Date.now() / 1000) + parseInt(retryAfter); if (!response.ok) { const isRateLimited = @@ -245,6 +252,63 @@ export abstract class GitRepoAdapter { return result; } + /** + * Follow a single redirect after validating the Location URL against SSRF rules. + * Only one hop is allowed — a second redirect throws. + */ + private async followSafeRedirect( + response: Response, + options: RequestInit, + signal: AbortSignal, + mode: "json" | "text" + ): Promise { + const location = response.headers.get("Location"); + if (!location) { + throw new Error( + `Redirect (${response.status}) with no Location header` + ); + } + + // Resolve relative redirects against the original request URL + const redirectUrl = this.sanitizeUrl( + new URL(location, response.url).href + ); + await assertSsrfSafeResolved(redirectUrl); + + const redirectResponse = await fetch(redirectUrl, { + ...options, + signal, + redirect: "error", // no further redirects + }); + + this.trackRateLimitHeaders(redirectResponse); + + if (!redirectResponse.ok) { + const errorText = await redirectResponse.text().catch(() => ""); + throw new Error( + `HTTP ${redirectResponse.status} ${redirectResponse.statusText}: ${errorText.slice(0, 200)}` + ); + } + + return mode === "json" + ? (await redirectResponse.json()) as T + : (await redirectResponse.text()) as T; + } + + /** + * Extract rate-limit headers from a response and update internal state. + */ + private trackRateLimitHeaders(response: Response): void { + const remaining = response.headers.get("X-RateLimit-Remaining"); + const reset = response.headers.get("X-RateLimit-Reset"); + const retryAfter = response.headers.get("Retry-After"); + if (remaining !== null) this.rateLimitRemaining = parseInt(remaining); + if (reset !== null) this.rateLimitResetAt = parseInt(reset); + if (retryAfter !== null) + this.rateLimitResetAt = + Math.floor(Date.now() / 1000) + parseInt(retryAfter); + } + protected async applyRateLimit(): Promise { const now = Date.now(); @@ -331,6 +395,10 @@ export function createGitRepoAdapter( const { AzureDevOpsRepoAdapter } = require("./AzureDevOpsRepoAdapter"); return new AzureDevOpsRepoAdapter(credentials, settings); } + case "GITEA": { + const { GiteaRepoAdapter } = require("./GiteaRepoAdapter"); + return new GiteaRepoAdapter(credentials, settings); + } default: throw new Error(`Unknown git provider: ${provider}`); } diff --git a/testplanit/lib/integrations/adapters/GiteaRepoAdapter.test.ts b/testplanit/lib/integrations/adapters/GiteaRepoAdapter.test.ts new file mode 100644 index 00000000..2dbd8998 --- /dev/null +++ b/testplanit/lib/integrations/adapters/GiteaRepoAdapter.test.ts @@ -0,0 +1,282 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GiteaRepoAdapter } from "./GiteaRepoAdapter"; + +// Mock fetch globally +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +// Mock DNS resolution to avoid real lookups in tests +vi.mock("~/utils/ssrf", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + assertSsrfSafeResolved: vi.fn().mockResolvedValue(undefined), + }; +}); + +function makeResponse( + data: any, + status = 200, + headers: Record = {} +) { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? "OK" : "Error", + headers: new Headers(headers), + json: () => Promise.resolve(data), + text: () => Promise.resolve(typeof data === "string" ? data : JSON.stringify(data)), + url: "https://gitea.example.com", + }; +} + +describe("GiteaRepoAdapter", () => { + let adapter: GiteaRepoAdapter; + + beforeEach(() => { + vi.clearAllMocks(); + adapter = new GiteaRepoAdapter( + { personalAccessToken: "test-token-123" }, + { baseUrl: "https://gitea.example.com", owner: "myorg", repo: "myrepo" } + ); + // Speed up tests by eliminating rate limit delays + (adapter as any).rateLimitDelay = 0; + (adapter as any).lastRequestTime = 0; + }); + + describe("constructor", () => { + it("stores credentials and settings", () => { + expect((adapter as any).personalAccessToken).toBe("test-token-123"); + expect((adapter as any).owner).toBe("myorg"); + expect((adapter as any).repo).toBe("myrepo"); + expect((adapter as any).baseUrl).toBe("https://gitea.example.com"); + }); + + it("strips trailing slash from baseUrl", () => { + const a = new GiteaRepoAdapter( + { personalAccessToken: "tok" }, + { baseUrl: "https://gitea.example.com/", owner: "o", repo: "r" } + ); + expect((a as any).baseUrl).toBe("https://gitea.example.com"); + }); + + it("throws with null settings since baseUrl is required", () => { + expect(() => new GiteaRepoAdapter( + { personalAccessToken: "tok" }, + null + )).toThrow(); + }); + }); + + describe("getDefaultBranch", () => { + it("returns the default branch from Gitea API", async () => { + mockFetch.mockResolvedValueOnce( + makeResponse({ default_branch: "main" }) + ); + + const branch = await adapter.getDefaultBranch(); + + expect(branch).toBe("main"); + expect(mockFetch).toHaveBeenCalledWith( + "https://gitea.example.com/api/v1/repos/myorg/myrepo", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "token test-token-123", + }), + }) + ); + }); + }); + + describe("listAllFiles", () => { + it("resolves branch to tree SHA and lists files", async () => { + // First call: get branch info + mockFetch.mockResolvedValueOnce( + makeResponse({ + commit: { commit: { tree: { sha: "tree-sha-abc" } }, sha: "commit-sha" }, + }) + ); + // Second call: get tree + mockFetch.mockResolvedValueOnce( + makeResponse({ + tree: [ + { path: "src/index.ts", type: "blob", size: 100 }, + { path: "src/utils", type: "tree", size: 0 }, + { path: "src/utils/helper.ts", type: "blob", size: 50 }, + ], + truncated: false, + }) + ); + + const result = await adapter.listAllFiles("main"); + + expect(result.files).toHaveLength(2); // Only blobs + expect(result.files[0].path).toBe("src/index.ts"); + expect(result.files[0].size).toBe(100); + expect(result.files[1].path).toBe("src/utils/helper.ts"); + expect(result.truncated).toBe(false); + }); + + it("falls back to commit SHA if tree SHA not available", async () => { + mockFetch.mockResolvedValueOnce( + makeResponse({ + commit: { sha: "fallback-sha" }, + }) + ); + mockFetch.mockResolvedValueOnce( + makeResponse({ + tree: [{ path: "README.md", type: "blob", size: 200 }], + }) + ); + + const result = await adapter.listAllFiles("main"); + + expect(result.files).toHaveLength(1); + // Verify the tree endpoint was called with the fallback SHA + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/git/trees/fallback-sha"), + expect.any(Object) + ); + }); + + it("reports truncated when Gitea returns truncated: true", async () => { + mockFetch.mockResolvedValueOnce( + makeResponse({ + commit: { commit: { tree: { sha: "abc" } } }, + }) + ); + mockFetch.mockResolvedValueOnce( + makeResponse({ + tree: [{ path: "src/index.ts", type: "blob", size: 100 }], + truncated: true, + }) + ); + + const result = await adapter.listAllFiles("main"); + expect(result.truncated).toBe(true); + }); + + it("stops pagination when entries are fewer than page size", async () => { + mockFetch.mockResolvedValueOnce( + makeResponse({ + commit: { commit: { tree: { sha: "abc" } } }, + }) + ); + // Single page with fewer than 100 entries + mockFetch.mockResolvedValueOnce( + makeResponse({ + tree: [ + { path: "a.ts", type: "blob", size: 10 }, + { path: "b.ts", type: "blob", size: 20 }, + ], + }) + ); + + const result = await adapter.listAllFiles("main"); + + expect(result.files).toHaveLength(2); + // Should only make 2 fetch calls (branch + 1 tree page) + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("throws when branch cannot be resolved to a tree SHA", async () => { + mockFetch.mockResolvedValueOnce( + makeResponse({ commit: {} }) + ); + + await expect(adapter.listAllFiles("main")).rejects.toThrow( + "Could not resolve branch to a tree SHA" + ); + }); + + it("encodes owner and repo in URL", async () => { + const a = new GiteaRepoAdapter( + { personalAccessToken: "tok" }, + { baseUrl: "https://gitea.example.com", owner: "my org", repo: "my repo" } + ); + (a as any).rateLimitDelay = 0; + + mockFetch.mockResolvedValueOnce( + makeResponse({ + commit: { commit: { tree: { sha: "abc" } } }, + }) + ); + mockFetch.mockResolvedValueOnce( + makeResponse({ tree: [] }) + ); + + await a.listAllFiles("main"); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/repos/my%20org/my%20repo/branches/"), + expect.any(Object) + ); + }); + }); + + describe("getFileContent", () => { + it("fetches raw file content from Gitea API", async () => { + mockFetch.mockResolvedValueOnce( + makeResponse("console.log('hello')") + ); + + const result = await adapter.getFileContent("src/index.ts", "main"); + expect(result).toBe("console.log('hello')"); + }); + + it("includes ref parameter in URL", async () => { + mockFetch.mockResolvedValueOnce(makeResponse("content")); + + await adapter.getFileContent("src/index.ts", "feat/branch"); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("?ref=feat%2Fbranch"), + expect.any(Object) + ); + }); + }); + + describe("testConnection", () => { + it("returns success with default branch", async () => { + mockFetch.mockResolvedValueOnce( + makeResponse({ default_branch: "develop" }) + ); + + const result = await adapter.testConnection(); + + expect(result.success).toBe(true); + expect(result.defaultBranch).toBe("develop"); + }); + + it("returns error on failure", async () => { + mockFetch.mockResolvedValueOnce( + makeResponse({ message: "Not Found" }, 404) + ); + + const result = await adapter.testConnection(); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + }); + + describe("authentication", () => { + it("sends token in Authorization header", async () => { + mockFetch.mockResolvedValueOnce( + makeResponse({ default_branch: "main" }) + ); + + await adapter.testConnection(); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "token test-token-123", + Accept: "application/json", + }), + }) + ); + }); + }); +}); diff --git a/testplanit/lib/integrations/adapters/GiteaRepoAdapter.ts b/testplanit/lib/integrations/adapters/GiteaRepoAdapter.ts new file mode 100644 index 00000000..10040109 --- /dev/null +++ b/testplanit/lib/integrations/adapters/GiteaRepoAdapter.ts @@ -0,0 +1,116 @@ +/** + * Git repository adapter for Gitea, Forgejo, and Gogs. + * All three platforms expose a compatible /api/v1/ REST API. + */ +import { + GitRepoAdapter, ListFilesResult, RepoFileEntry, TestConnectionResult +} from "./GitRepoAdapter"; + +const MAX_FILES = 10000; + +export class GiteaRepoAdapter extends GitRepoAdapter { + private personalAccessToken: string; + private owner: string; + private repo: string; + private baseUrl: string; + + constructor( + credentials: Record, + settings: Record | null | undefined + ) { + super(); + this.personalAccessToken = credentials.personalAccessToken; + this.owner = settings?.owner ?? ""; + this.repo = settings?.repo ?? ""; + this.baseUrl = (settings?.baseUrl ?? "").replace(/\/$/, ""); + this.baseUrl = this.sanitizeUrl(this.baseUrl); + } + + private get authHeaders() { + return { + Authorization: `token ${this.personalAccessToken}`, + Accept: "application/json", + }; + } + + async getDefaultBranch(): Promise { + const data = await this.makeRequest( + `${this.baseUrl}/api/v1/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(this.repo)}`, + { headers: this.authHeaders } + ); + return data.default_branch; + } + + async listAllFiles(branch: string): Promise { + // Step 1: Resolve branch to tree SHA + const branchData = await this.makeRequest( + `${this.baseUrl}/api/v1/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(this.repo)}/branches/${encodeURIComponent(branch)}`, + { headers: this.authHeaders } + ); + const treeSha: string = branchData.commit?.commit?.tree?.sha + ?? branchData.commit?.sha; + + if (!treeSha) { + throw new Error("Could not resolve branch to a tree SHA"); + } + + // Step 2: Fetch recursive tree (paginated) + const files: RepoFileEntry[] = []; + let page = 1; + let truncated = false; + + while (files.length < MAX_FILES) { + const treeData = await this.makeRequest( + `${this.baseUrl}/api/v1/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(this.repo)}/git/trees/${treeSha}?recursive=true&per_page=100&page=${page}`, + { headers: this.authHeaders } + ); + + if (treeData.truncated) { + truncated = true; + } + + const entries: any[] = treeData.tree ?? []; + if (entries.length === 0) break; + + const fileEntries = entries + .filter((item: any) => item.type === "blob") + .map((item: any) => ({ + path: item.path as string, + size: (item.size as number) ?? 0, + type: "file" as const, + })); + files.push(...fileEntries); + + // Gitea returns total_count when paginated; stop when we've got all pages + const totalCount = treeData.total_count; + if (totalCount !== undefined && files.length >= totalCount) break; + + // If this page returned fewer than requested, we're done + if (entries.length < 100) break; + + page++; + } + + return { files: files.slice(0, MAX_FILES), truncated }; + } + + async getFileContent(path: string, branch: string): Promise { + // Gitea raw endpoint returns file content directly as text + return this.makeTextRequest( + `${this.baseUrl}/api/v1/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(this.repo)}/raw/${path}?ref=${encodeURIComponent(branch)}`, + { headers: this.authHeaders } + ); + } + + async testConnection(): Promise { + try { + const data = await this.makeRequest( + `${this.baseUrl}/api/v1/repos/${encodeURIComponent(this.owner)}/${encodeURIComponent(this.repo)}`, + { headers: this.authHeaders } + ); + return { success: true, defaultBranch: data.default_branch }; + } catch (err: any) { + return { success: false, error: err.message }; + } + } +} diff --git a/testplanit/lib/openapi/zenstack-openapi.json b/testplanit/lib/openapi/zenstack-openapi.json index 2e9f2f57..dc9bc548 100644 --- a/testplanit/lib/openapi/zenstack-openapi.json +++ b/testplanit/lib/openapi/zenstack-openapi.json @@ -598,7 +598,8 @@ "GITHUB", "GITLAB", "BITBUCKET", - "AZURE_DEVOPS" + "AZURE_DEVOPS", + "GITEA" ] }, "LlmProvider": { diff --git a/testplanit/messages/en-US.json b/testplanit/messages/en-US.json index 5d154c0a..9c262195 100644 --- a/testplanit/messages/en-US.json +++ b/testplanit/messages/en-US.json @@ -3199,7 +3199,8 @@ "repositoryCreated": "Repository created", "saveFailed": "Failed to save repository", "namePlaceholder": "My Repository", - "selectProvider": "Select provider" + "selectProvider": "Select provider", + "httpWarning": "This URL uses HTTP. Credentials will be sent in plaintext. Use HTTPS for secure connections." }, "exportTemplates": { "title": "QuickScript Templates", @@ -4854,7 +4855,7 @@ }, "codeRepository": { "name": "## Repository Name\nA friendly display name for this repository connection.\n\n- Used to identify it in project code context settings.\n- Does not need to match the repository name on the provider.", - "provider": "## Git Provider\nThe platform hosting this repository.\n\n- **GitHub** — github.com (cloud or Enterprise)\n- **GitLab** — gitlab.com or self-hosted\n- **Bitbucket Cloud** — bitbucket.org (cloud only)\n- **Azure DevOps** — dev.azure.com", + "provider": "## Git Provider\nThe platform hosting this repository.\n\n- **GitHub** — github.com (cloud or Enterprise)\n- **GitLab** — gitlab.com or self-hosted\n- **Bitbucket Cloud** — bitbucket.org (cloud only)\n- **Azure DevOps** — dev.azure.com\n- **Gitea / Forgejo / Gogs** — self-hosted Git servers with compatible APIs", "githubToken": "## Personal Access Token\nA GitHub token with **Contents: Read** permission.\n\nGenerate one at: GitHub → Profile Settings → Developer settings → Personal access tokens → Fine-grained tokens.", "owner": "## Owner\nThe GitHub organization or user account that owns the repository.\n\n- Example: `myorg` or `myusername`\n- This is the first path segment in your repository URL on github.com", "repo": "## Repository\nThe repository name, without the owner prefix.\n\n- Example: `my-repo` (not `myorg/my-repo`)\n- This is the second path segment in your repository URL on github.com", @@ -4868,7 +4869,11 @@ "azureToken": "## Personal Access Token\nAn Azure DevOps PAT with **Code (Read)** permission.\n\nCreate one at: Azure DevOps → User settings → Personal access tokens.", "organizationUrl": "## Organization URL\nYour Azure DevOps organization URL.\n\n- Example: `https://dev.azure.com/myorg`", "project": "## Project Name\nThe name of the Azure DevOps project containing the repository.\n\n- Found as the third path segment in your Azure DevOps URL after the organization name", - "azureRepoId": "## Repository Name or ID\nThe repository name or GUID within the Azure DevOps project.\n\n- Example: `my-repo`" + "azureRepoId": "## Repository Name or ID\nThe repository name or GUID within the Azure DevOps project.\n\n- Example: `my-repo`", + "giteaToken": "## Personal Access Token\nA token with repository read permissions.\n\nGenerate one at: Site Administration → Applications, or User Settings → Applications.\n\n- Works with Gitea, Forgejo, Gogs, and any server exposing a compatible API.", + "giteaBaseUrl": "## Server URL\nThe base URL of your self-hosted Git server.\n\n- Example: `https://gitea.example.com`\n- Must include the protocol (https:// recommended)\n- Works with Gitea, Forgejo, Gogs, and other servers with a Gitea-compatible `/api/v1/` REST API", + "giteaOwner": "## Owner\nThe organization or user account that owns the repository.\n\n- Example: `myorg` or `myusername`\n- This is the first path segment in your repository URL", + "giteaRepo": "## Repository\nThe repository name, without the owner prefix.\n\n- Example: `my-repo` (not `myorg/my-repo`)" }, "promptConfig": { "name": "## Configuration Name\nA unique, descriptive name for this prompt configuration.\n\n- Used to identify the configuration when assigning it to a project (e.g. \"GPT-4o Prompts\", \"Conservative Prompts\").", diff --git a/testplanit/messages/es-ES.json b/testplanit/messages/es-ES.json index fd63ae08..7aa54991 100644 --- a/testplanit/messages/es-ES.json +++ b/testplanit/messages/es-ES.json @@ -3199,7 +3199,8 @@ "repositoryCreated": "Repositorio creado", "saveFailed": "No se pudo guardar el repositorio", "namePlaceholder": "Mi repositorio", - "selectProvider": "Seleccionar proveedor" + "selectProvider": "Seleccionar proveedor", + "httpWarning": "Esta URL utiliza HTTP. Las credenciales se enviarán en texto plano. Utilice HTTPS para conexiones seguras." }, "exportTemplates": { "title": "Plantillas de QuickScript", @@ -4854,7 +4855,7 @@ }, "codeRepository": { "name": "## Nombre del repositorio\nUn nombre para mostrar descriptivo para esta conexión de repositorio.\n\n- Se utiliza para identificarlo en la configuración del contexto del código del proyecto.\n- No necesita coincidir con el nombre del repositorio en el proveedor.", - "provider": "## Proveedor de Git\nLa plataforma que aloja este repositorio.\n\n- **GitHub** — github.com (nube o Enterprise)\n- **GitLab** — gitlab.com o autoalojado\n- **Bitbucket Cloud** — bitbucket.org (solo en la nube)\n- **Azure DevOps** — dev.azure.com", + "provider": "## Proveedor de Git\nLa plataforma que aloja este repositorio.\n\n- **GitHub** — github.com (cloud o Enterprise)\n- **GitLab** — gitlab.com o autoalojado\n- **Bitbucket Cloud** — bitbucket.org (solo en la nube)\n- **Azure DevOps** — dev.azure.com\n- **Gitea / Forgejo / Gogs** — servidores Git autoalojados con API compatibles", "githubToken": "## Token de acceso personal\nUn token de GitHub con permiso de **Contenido: Lectura**.\n\nGenere uno en: GitHub → Configuración de perfil → Configuración de desarrollador → Tokens de acceso personal → Tokens de grano fino.", "owner": "## Propietario\nLa organización de GitHub o la cuenta de usuario que posee el repositorio.\n\n- Ejemplo: `myorg` o `myusername`\n- Este es el primer segmento de ruta en la URL de su repositorio en github.com", "repo": "## Repositorio\nEl nombre del repositorio, sin el prefijo del propietario.\n\n- Ejemplo: `my-repo` (no `myorg/my-repo`)\n- Este es el segundo segmento de ruta en la URL de su repositorio en github.com", @@ -4868,7 +4869,11 @@ "azureToken": "## Token de acceso personal\nUn PAT de Azure DevOps con permiso **Código (lectura)**.\n\nCree uno en: Azure DevOps → Configuración de usuario → Tokens de acceso personal.", "organizationUrl": "## URL de la organización\nLa URL de su organización de Azure DevOps.\n\n- Ejemplo: `https://dev.azure.com/myorg`", "project": "## Nombre del proyecto\nEl nombre del proyecto de Azure DevOps que contiene el repositorio.\n\n- Se encuentra como el tercer segmento de ruta en la URL de Azure DevOps después del nombre de la organización", - "azureRepoId": "## Nombre o ID del repositorio\nEl nombre del repositorio o GUID dentro del proyecto de Azure DevOps.\n\n- Ejemplo: `my-repo`" + "azureRepoId": "## Nombre o ID del repositorio\nEl nombre del repositorio o GUID dentro del proyecto de Azure DevOps.\n\n- Ejemplo: `my-repo`", + "giteaToken": "## Token de acceso personal\nUn token con permisos de lectura de repositorio.\n\nGenere uno en: Administración del sitio → Aplicaciones, o Configuración de usuario → Aplicaciones.\n\n- Funciona con Gitea, Forgejo, Gogs y cualquier servidor que exponga una API compatible.", + "giteaBaseUrl": "## URL del servidor\nLa URL base de tu servidor Git autohospedado.\n\n- Ejemplo: `https://gitea.example.com`\n- Debe incluir el protocolo (https:// recomendado)\n- Funciona con Gitea, Forgejo, Gogs y otros servidores con una API REST `/api/v1/` compatible con Gitea", + "giteaOwner": "## Propietario\nLa organización o cuenta de usuario propietaria del repositorio.\n\n- Ejemplo: `myorg` o `myusername`\n- Este es el primer segmento de ruta en la URL de su repositorio.", + "giteaRepo": "## Repositorio\nEl nombre del repositorio, sin el prefijo del propietario.\n\n- Ejemplo: `my-repo` (no `myorg/my-repo`)" }, "promptConfig": { "name": "## Nombre de configuración\nUn nombre descriptivo único para esta configuración de solicitud.\n\n- Se utiliza para identificar la configuración al asignarla a un proyecto (por ejemplo, \"Solicitantes GPT-4o\", \"Solicitantes conservadores\").", diff --git a/testplanit/messages/fr-FR.json b/testplanit/messages/fr-FR.json index 036bc1d8..b3653544 100644 --- a/testplanit/messages/fr-FR.json +++ b/testplanit/messages/fr-FR.json @@ -3199,7 +3199,8 @@ "repositoryCreated": "Dépôt créé", "saveFailed": "Échec de l'enregistrement du dépôt", "namePlaceholder": "Mon dépôt", - "selectProvider": "Sélectionner le fournisseur" + "selectProvider": "Sélectionner le fournisseur", + "httpWarning": "Cette URL utilise le protocole HTTP. Vos identifiants seront envoyés en clair. Utilisez HTTPS pour une connexion sécurisée." }, "exportTemplates": { "title": "Modèles QuickScript", @@ -4854,7 +4855,7 @@ }, "codeRepository": { "name": "## Nom du dépôt\nNom convivial pour cette connexion au dépôt.\n\n- Utilisé pour l'identifier dans les paramètres contextuels du code du projet.\n- Ne doit pas nécessairement correspondre au nom du dépôt chez le fournisseur.", - "provider": "## Fournisseur Git\nLa plateforme hébergeant ce dépôt.\n\n- **GitHub** — github.com (cloud ou Enterprise)\n- **GitLab** — gitlab.com ou auto-hébergé\n- **Bitbucket Cloud** — bitbucket.org (cloud uniquement)\n- **Azure DevOps** — dev.azure.com", + "provider": "## Fournisseur Git\nLa plateforme hébergeant ce dépôt.\n\n- **GitHub** — github.com (cloud ou Enterprise)\n- **GitLab** — gitlab.com ou auto-hébergé\n- **Bitbucket Cloud** — bitbucket.org (cloud uniquement)\n- **Azure DevOps** — dev.azure.com\n- **Gitea / Forgejo / Gogs** — Serveurs Git auto-hébergés avec API compatibles", "githubToken": "## Jeton d'accès personnel\nUn jeton GitHub avec l'autorisation **Contenu : Lecture**.\n\nGénérez-en un ici : GitHub → Paramètres du profil → Paramètres développeur → Jetons d'accès personnels → Jetons à granularité fine.", "owner": "## Propriétaire\nL'organisation GitHub ou le compte utilisateur propriétaire du dépôt.\n\n- Exemple : `myorg` ou `myusername`\n- Il s'agit du premier segment du chemin d'accès dans l'URL de votre dépôt sur github.com", "repo": "## Dépôt\nLe nom du dépôt, sans le préfixe du propriétaire.\n\n- Exemple : `my-repo` (et non `myorg/my-repo`)\n- Il s'agit du deuxième segment du chemin dans l'URL de votre dépôt sur github.com", @@ -4868,7 +4869,11 @@ "azureToken": "## Jeton d'accès personnel\nUn jeton d'accès personnel Azure DevOps avec l'autorisation **Lecture du code**.\n\nCréez-en un à l'adresse : Azure DevOps → Paramètres utilisateur → Jetons d'accès personnels.", "organizationUrl": "## URL de l'organisation\nURL de votre organisation Azure DevOps.\n\n- Exemple : `https://dev.azure.com/myorg`", "project": "## Nom du projet\nLe nom du projet Azure DevOps contenant le dépôt.\n\n- Se trouve comme troisième segment de chemin dans votre URL Azure DevOps après le nom de l'organisation.", - "azureRepoId": "## Nom ou ID du dépôt\nLe nom ou le GUID du dépôt dans le projet Azure DevOps.\n\n- Exemple : `my-repo`" + "azureRepoId": "## Nom ou ID du dépôt\nLe nom ou le GUID du dépôt dans le projet Azure DevOps.\n\n- Exemple : `my-repo`", + "giteaToken": "## Jeton d'accès personnel\nJeton disposant des autorisations de lecture du dépôt.\n\nGénérez-en un dans : Administration du site → Applications ou Paramètres utilisateur → Applications.\n\n- Compatible avec Gitea, Forgejo, Gogs et tout serveur exposant une API compatible.", + "giteaBaseUrl": "## URL du serveur\nL'URL de base de votre serveur Git auto-hébergé.\n\n- Exemple : `https://gitea.example.com`\n- Doit inclure le protocole (https:// recommandé)\n- Fonctionne avec Gitea, Forgejo, Gogs et autres serveurs disposant d'une API REST `/api/v1/` compatible avec Gitea", + "giteaOwner": "## Propriétaire\nL'organisation ou le compte utilisateur propriétaire du dépôt.\n\n- Exemple : `myorg` ou `myusername`\n- Premier segment du chemin d'accès dans l'URL de votre dépôt", + "giteaRepo": "## Dépôt\nLe nom du dépôt, sans le préfixe du propriétaire.\n\n- Exemple : `my-repo` (et non `myorg/my-repo`)" }, "promptConfig": { "name": "## Nom de la configuration\nUn nom unique et descriptif pour cette configuration d'invite.\n\n- Utilisé pour identifier la configuration lors de son attribution à un projet (par exemple « Invites GPT-4o », « Invites conservatrices »).", diff --git a/testplanit/prisma/schema.prisma b/testplanit/prisma/schema.prisma index de428b45..0017b047 100644 --- a/testplanit/prisma/schema.prisma +++ b/testplanit/prisma/schema.prisma @@ -166,6 +166,7 @@ enum CodeRepositoryProvider { GITLAB BITBUCKET AZURE_DEVOPS + GITEA } enum ApplicationArea { diff --git a/testplanit/schema.zmodel b/testplanit/schema.zmodel index af59c9ad..f9aab89c 100644 --- a/testplanit/schema.zmodel +++ b/testplanit/schema.zmodel @@ -1244,20 +1244,20 @@ enum DuplicateScanResultStatus { } model DuplicateScanResult { - id Int @id @default(autoincrement()) - projectId Int - project Projects @relation(fields: [projectId], references: [id], onDelete: Cascade) - caseAId Int - caseA RepositoryCases @relation("DuplicateCaseA", fields: [caseAId], references: [id], onDelete: Cascade) - caseBId Int - caseB RepositoryCases @relation("DuplicateCaseB", fields: [caseBId], references: [id], onDelete: Cascade) - score Float + id Int @id @default(autoincrement()) + projectId Int + project Projects @relation(fields: [projectId], references: [id], onDelete: Cascade) + caseAId Int + caseA RepositoryCases @relation("DuplicateCaseA", fields: [caseAId], references: [id], onDelete: Cascade) + caseBId Int + caseB RepositoryCases @relation("DuplicateCaseB", fields: [caseBId], references: [id], onDelete: Cascade) + score Float matchedFields String[] detectionMethod String @default("fuzzy") status DuplicateScanResultStatus @default(PENDING) - scanJobId String? - isDeleted Boolean @default(false) - createdAt DateTime @default(now()) @db.Timestamptz(6) + scanJobId String? + isDeleted Boolean @default(false) + createdAt DateTime @default(now()) @db.Timestamptz(6) @@unique([caseAId, caseBId, scanJobId]) @@index([projectId, status, isDeleted]) @@ -1318,16 +1318,16 @@ enum StepSequenceMatchStatus { } model StepSequenceMatch { - id Int @id @default(autoincrement()) - projectId Int - project Projects @relation(fields: [projectId], references: [id], onDelete: Cascade) - fingerprint String - stepCount Int - status StepSequenceMatchStatus @default(PENDING) - scanJobId String? - isDeleted Boolean @default(false) - createdAt DateTime @default(now()) @db.Timestamptz(6) - members StepSequenceMatchCase[] + id Int @id @default(autoincrement()) + projectId Int + project Projects @relation(fields: [projectId], references: [id], onDelete: Cascade) + fingerprint String + stepCount Int + status StepSequenceMatchStatus @default(PENDING) + scanJobId String? + isDeleted Boolean @default(false) + createdAt DateTime @default(now()) @db.Timestamptz(6) + members StepSequenceMatchCase[] @@unique([projectId, fingerprint, scanJobId]) @@index([projectId, status, isDeleted]) @@ -1369,14 +1369,14 @@ model StepSequenceMatch { } model StepSequenceMatchCase { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) matchId Int - match StepSequenceMatch @relation(fields: [matchId], references: [id], onDelete: Cascade) + match StepSequenceMatch @relation(fields: [matchId], references: [id], onDelete: Cascade) caseId Int - case RepositoryCases @relation(fields: [caseId], references: [id], onDelete: Cascade) + case RepositoryCases @relation(fields: [caseId], references: [id], onDelete: Cascade) startStepId Int endStepId Int - isDeleted Boolean @default(false) + isDeleted Boolean @default(false) @@unique([matchId, caseId]) @@index([caseId]) @@ -2545,6 +2545,7 @@ enum CodeRepositoryProvider { GITLAB BITBUCKET AZURE_DEVOPS + GITEA @@deny('all', !auth()) @@allow('update', auth().access == 'ADMIN') diff --git a/testplanit/utils/ssrf.test.ts b/testplanit/utils/ssrf.test.ts index f6890541..08913d01 100644 --- a/testplanit/utils/ssrf.test.ts +++ b/testplanit/utils/ssrf.test.ts @@ -1,5 +1,13 @@ -import { describe, expect, it } from "vitest"; -import { isSsrfSafe } from "./ssrf"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockLookup = vi.hoisted(() => vi.fn()); + +vi.mock("node:dns/promises", () => ({ + default: { lookup: mockLookup }, + lookup: mockLookup, +})); + +import { assertSsrfSafeResolved, isSsrfSafe } from "./ssrf"; describe("isSsrfSafe", () => { describe("blocks localhost", () => { @@ -65,6 +73,10 @@ describe("isSsrfSafe", () => { it("blocks fd00:: (unique local)", () => { expect(isSsrfSafe("https://[fd12::1]/api")).toBe(false); }); + + it("blocks fe80:: (link-local)", () => { + expect(isSsrfSafe("https://[fe80::1]/api")).toBe(false); + }); }); describe("blocks non-HTTP protocols", () => { @@ -97,6 +109,10 @@ describe("isSsrfSafe", () => { it("allows Azure DevOps URLs", () => { expect(isSsrfSafe("https://dev.azure.com/myorg")).toBe(true); }); + + it("allows self-hosted Gitea URLs", () => { + expect(isSsrfSafe("https://gitea.mycompany.com/api/v1")).toBe(true); + }); }); describe("handles invalid input", () => { @@ -109,3 +125,77 @@ describe("isSsrfSafe", () => { }); }); }); + +describe("assertSsrfSafeResolved", () => { + beforeEach(() => { + mockLookup.mockReset(); + }); + + describe("blocks DNS rebinding attacks", () => { + it("throws when hostname resolves to loopback", async () => { + mockLookup.mockResolvedValueOnce({ address: "127.0.0.1", family: 4 } as any); + + await expect( + assertSsrfSafeResolved("https://evil.example.com/api") + ).rejects.toThrow("hostname resolves to a private or internal address"); + }); + + it("throws when hostname resolves to private 10.x.x.x", async () => { + mockLookup.mockResolvedValueOnce({ address: "10.0.0.1", family: 4 } as any); + + await expect( + assertSsrfSafeResolved("https://evil.example.com/api") + ).rejects.toThrow("hostname resolves to a private or internal address"); + }); + + it("throws when hostname resolves to 192.168.x.x", async () => { + mockLookup.mockResolvedValueOnce({ address: "192.168.1.1", family: 4 } as any); + + await expect( + assertSsrfSafeResolved("https://evil.example.com/api") + ).rejects.toThrow("hostname resolves to a private or internal address"); + }); + + it("throws when hostname resolves to AWS metadata IP", async () => { + mockLookup.mockResolvedValueOnce({ address: "169.254.169.254", family: 4 } as any); + + await expect( + assertSsrfSafeResolved("https://evil.example.com/api") + ).rejects.toThrow("hostname resolves to a private or internal address"); + }); + }); + + describe("allows safe resolved addresses", () => { + it("passes when hostname resolves to a public IP", async () => { + mockLookup.mockResolvedValueOnce({ address: "140.82.121.4", family: 4 } as any); + + await expect( + assertSsrfSafeResolved("https://github.com/api") + ).resolves.not.toThrow(); + }); + }); + + describe("skips DNS lookup for raw IPs", () => { + it("skips lookup for IPv4 addresses", async () => { + await assertSsrfSafeResolved("https://140.82.121.4/api"); + + expect(mockLookup).not.toHaveBeenCalled(); + }); + + it("skips lookup for IPv6 addresses", async () => { + await assertSsrfSafeResolved("https://[2606:4700::1]/api"); + + expect(mockLookup).not.toHaveBeenCalled(); + }); + }); + + describe("handles DNS failures", () => { + it("throws on DNS resolution failure", async () => { + mockLookup.mockRejectedValueOnce(new Error("ENOTFOUND")); + + await expect( + assertSsrfSafeResolved("https://nonexistent.example.com/api") + ).rejects.toThrow("DNS resolution failed"); + }); + }); +}); diff --git a/testplanit/utils/ssrf.ts b/testplanit/utils/ssrf.ts index d3a92a76..6eba9110 100644 --- a/testplanit/utils/ssrf.ts +++ b/testplanit/utils/ssrf.ts @@ -1,3 +1,5 @@ +import { lookup } from "node:dns/promises"; + // Private IP ranges that must be blocked to prevent SSRF attacks const PRIVATE_RANGES: RegExp[] = [ // IPv4 loopback @@ -15,8 +17,14 @@ const PRIVATE_RANGES: RegExp[] = [ // IPv6 unique local /^fc/i, /^fd/i, + // IPv6 link-local + /^fe80:/i, ]; +function isPrivateIp(ip: string): boolean { + return PRIVATE_RANGES.some((r) => r.test(ip)); +} + /** * Returns true if the URL is safe to make a server-side request to. * Blocks localhost, loopback addresses, and private IP ranges. @@ -34,7 +42,7 @@ export function isSsrfSafe(url: string): boolean { if (hostname === "localhost") return false; // Block if hostname is a private/loopback IP - if (PRIVATE_RANGES.some((r) => r.test(hostname))) return false; + if (isPrivateIp(hostname)) return false; // Only allow http/https if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { @@ -47,3 +55,33 @@ export function isSsrfSafe(url: string): boolean { return false; } } + +/** + * Resolve a URL's hostname via DNS and verify the resolved IP is not private. + * This closes the DNS rebinding gap where a public hostname resolves to a + * private/internal IP address. + * + * Call this immediately before fetch() to minimize the TOCTOU window. + * Throws if the resolved address is private or the hostname cannot be resolved. + */ +export async function assertSsrfSafeResolved(url: string): Promise { + const parsed = new URL(url); + const hostname = parsed.hostname.replace(/^\[|\]$/g, ""); + + // Skip DNS lookup for raw IP addresses — already checked by isSsrfSafe() + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) || hostname.includes(":")) { + return; + } + + try { + const { address } = await lookup(hostname); + if (isPrivateIp(address)) { + throw new Error( + "Request blocked: hostname resolves to a private or internal address" + ); + } + } catch (err: any) { + if (err.message?.includes("Request blocked")) throw err; + throw new Error(`DNS resolution failed for ${hostname}: ${err.message}`); + } +} From 1b74aca2bd24b3d517d60f44ae40df7d9a941da9 Mon Sep 17 00:00:00 2001 From: Brad DerManouelian Date: Sat, 28 Mar 2026 17:20:46 -0500 Subject: [PATCH 2/2] fix(GiteaRepoAdapter): Correct tree SHA retrieval logic - Updated the logic for retrieving the tree SHA in the Gitea repository adapter to ensure compatibility with different commit structures. This change allows for fallback to the commit ID if the tree SHA is not available, improving robustness in handling Gitea responses. --- testplanit/lib/integrations/adapters/GiteaRepoAdapter.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/testplanit/lib/integrations/adapters/GiteaRepoAdapter.ts b/testplanit/lib/integrations/adapters/GiteaRepoAdapter.ts index 10040109..1cddb639 100644 --- a/testplanit/lib/integrations/adapters/GiteaRepoAdapter.ts +++ b/testplanit/lib/integrations/adapters/GiteaRepoAdapter.ts @@ -48,6 +48,7 @@ export class GiteaRepoAdapter extends GitRepoAdapter { { headers: this.authHeaders } ); const treeSha: string = branchData.commit?.commit?.tree?.sha + ?? branchData.commit?.id ?? branchData.commit?.sha; if (!treeSha) {