diff --git a/.env.example b/.env.example index 239f191a..26b49ddb 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,11 @@ HIDDEN_REPOSITORIES= NEW_PROJECT_TEMPLATE_REPOSITORY=shapehq/starter-openapi PROXY_API_MAXIMUM_FILE_SIZE_IN_MEGABYTES = 10 PROXY_API_TIMEOUT_IN_SECONDS = 30 + +# Project Source Provider: "github" or "azure-devops" (default: github) +PROJECT_SOURCE_PROVIDER=github + +# GitHub Configuration (required if PROJECT_SOURCE_PROVIDER=github) GITHUB_WEBHOOK_SECRET=preshared secret also put in app configuration in GitHub GITHUB_WEBHOK_REPOSITORY_ALLOWLIST= GITHUB_WEBHOK_REPOSITORY_DISALLOWLIST= @@ -22,5 +27,13 @@ GITHUB_CLIENT_ID=GitHub App client ID GITHUB_CLIENT_SECRET=GitHub App client secret GITHUB_APP_ID=123456 GITHUB_PRIVATE_KEY_BASE_64=base 64 encoded version of the private key - see README.md for more info + +# Azure DevOps Configuration (required if PROJECT_SOURCE_PROVIDER=azure-devops) +# Uses Microsoft Entra ID (Azure AD) for authentication +AZURE_ENTRA_ID_CLIENT_ID=Microsoft Entra ID App Registration client ID +AZURE_ENTRA_ID_CLIENT_SECRET=Microsoft Entra ID App Registration client secret +AZURE_ENTRA_ID_TENANT_ID=Microsoft Entra ID tenant/directory ID +AZURE_DEVOPS_ORGANIZATION=your-azure-devops-organization-name + ENCRYPTION_PUBLIC_KEY_BASE_64=base 64 encoded version of the public key ENCRYPTION_PRIVATE_KEY_BASE_64=base 64 encoded version of the private key diff --git a/__test__/common/azure-devops/AzureDevOpsClient.test.ts b/__test__/common/azure-devops/AzureDevOpsClient.test.ts new file mode 100644 index 00000000..bbf2cdc2 --- /dev/null +++ b/__test__/common/azure-devops/AzureDevOpsClient.test.ts @@ -0,0 +1,418 @@ +import { jest } from "@jest/globals" +import AzureDevOpsClient from "@/common/azure-devops/AzureDevOpsClient" +import { AzureDevOpsError } from "@/common/azure-devops/AzureDevOpsError" + +const originalFetch = global.fetch + +function createMockTokenDataSource(accessToken = "test-token") { + return { + async getOAuthToken() { + return { accessToken } + } + } +} + +function mockFetchResponse(data: unknown, status = 200, headers: Record = {}) { + return jest.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? "OK" : "Error", + headers: { + get: (name: string) => headers[name.toLowerCase()] || null + }, + json: () => Promise.resolve(data), + text: () => Promise.resolve(typeof data === "string" ? data : JSON.stringify(data)), + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)) + }) +} + +afterEach(() => { + global.fetch = originalFetch +}) + +describe("getRepositories", () => { + test("It calls the correct API endpoint", async () => { + let fetchedUrl: string | undefined + global.fetch = jest.fn().mockImplementation((url: string | URL | Request) => { + fetchedUrl = url.toString() + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ value: [], count: 0 }) + }) + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + await sut.getRepositories() + + expect(fetchedUrl).toContain("https://dev.azure.com/my-org/_apis/git/repositories") + expect(fetchedUrl).toContain("api-version=7.1") + }) + + test("It includes the Bearer token in the Authorization header", async () => { + let capturedHeaders: HeadersInit | undefined + global.fetch = jest.fn().mockImplementation((_url: string, options?: RequestInit) => { + capturedHeaders = options?.headers + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ value: [], count: 0 }) + }) + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource("my-access-token") + }) + await sut.getRepositories() + + expect(capturedHeaders).toBeDefined() + expect((capturedHeaders as Record).Authorization).toEqual("Bearer my-access-token") + }) + + test("It returns the repositories from the response", async () => { + global.fetch = mockFetchResponse({ + value: [ + { id: "repo-1", name: "foo-openapi", webUrl: "https://test", project: { id: "p1", name: "proj" } } + ], + count: 1 + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + const repos = await sut.getRepositories() + + expect(repos).toHaveLength(1) + expect(repos[0].name).toEqual("foo-openapi") + }) +}) + +describe("getRefs", () => { + test("It calls the correct API endpoint with repository ID", async () => { + let fetchedUrl: string | undefined + global.fetch = jest.fn().mockImplementation((url: string | URL | Request) => { + fetchedUrl = url.toString() + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ value: [], count: 0 }) + }) + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + await sut.getRefs("repo-123") + + expect(fetchedUrl).toContain("/_apis/git/repositories/repo-123/refs") + }) + + test("It returns the refs from the response", async () => { + global.fetch = mockFetchResponse({ + value: [ + { name: "refs/heads/main", objectId: "abc123" }, + { name: "refs/tags/v1.0", objectId: "def456" } + ], + count: 2 + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + const refs = await sut.getRefs("repo-123") + + expect(refs).toHaveLength(2) + expect(refs[0].name).toEqual("refs/heads/main") + }) +}) + +describe("getItems", () => { + test("It calls the correct API endpoint with scope path and version", async () => { + let fetchedUrl: string | undefined + global.fetch = jest.fn().mockImplementation((url: string | URL | Request) => { + fetchedUrl = url.toString() + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ value: [], count: 0 }) + }) + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + await sut.getItems("repo-123", "/docs", "main") + + expect(fetchedUrl).toContain("/_apis/git/repositories/repo-123/items") + expect(fetchedUrl).toContain("scopePath=%2Fdocs") + expect(fetchedUrl).toContain("versionDescriptor.version=main") + expect(fetchedUrl).toContain("recursionLevel=OneLevel") + }) + + test("It returns empty array when request fails", async () => { + global.fetch = mockFetchResponse("Not found", 404) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + const items = await sut.getItems("repo-123", "/", "main") + + expect(items).toEqual([]) + }) +}) + +describe("getFileContent", () => { + test("It returns text content for non-image files", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: () => Promise.resolve("openapi: 3.0.0") + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + const content = await sut.getFileContent("repo-123", "openapi.yml", "main") + + expect(content).toEqual("openapi: 3.0.0") + }) + + test("It returns ArrayBuffer for image files", async () => { + const mockArrayBuffer = new ArrayBuffer(16) + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + arrayBuffer: () => Promise.resolve(mockArrayBuffer) + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + const content = await sut.getFileContent("repo-123", "icon.png", "main") + + expect(content).toBe(mockArrayBuffer) + }) + + test("It uses octet-stream Accept header for image files", async () => { + let capturedHeaders: HeadersInit | undefined + global.fetch = jest.fn().mockImplementation((_url: string, options?: RequestInit) => { + capturedHeaders = options?.headers + return Promise.resolve({ + ok: true, + status: 200, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)) + }) + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + await sut.getFileContent("repo-123", "logo.jpg", "main") + + expect((capturedHeaders as Record).Accept).toEqual("application/octet-stream") + }) + + test("It uses text/plain Accept header for non-image files", async () => { + let capturedHeaders: HeadersInit | undefined + global.fetch = jest.fn().mockImplementation((_url: string, options?: RequestInit) => { + capturedHeaders = options?.headers + return Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve("content") + }) + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + await sut.getFileContent("repo-123", "openapi.yml", "main") + + expect((capturedHeaders as Record).Accept).toEqual("text/plain") + }) + + test("It returns null when response is not ok", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 404 + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + const content = await sut.getFileContent("repo-123", "missing.yml", "main") + + expect(content).toBeNull() + }) + + test("It returns null when fetch throws an error", async () => { + global.fetch = jest.fn().mockRejectedValue(new Error("Network error")) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + const content = await sut.getFileContent("repo-123", "openapi.yml", "main") + + expect(content).toBeNull() + }) +}) + +describe("Error handling", () => { + test("It throws AzureDevOpsError with isAuthError=true for 401 responses", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + text: () => Promise.resolve("Authentication failed") + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + + try { + await sut.getRepositories() + fail("Expected error to be thrown") + } catch (e) { + expect(e).toBeInstanceOf(AzureDevOpsError) + expect((e as AzureDevOpsError).isAuthError).toBe(true) + expect((e as AzureDevOpsError).status).toBe(401) + } + }) + + test("It throws AzureDevOpsError with isAuthError=true for 403 responses", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 403, + statusText: "Forbidden", + text: () => Promise.resolve("Access denied") + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + + try { + await sut.getRepositories() + fail("Expected error to be thrown") + } catch (e) { + expect(e).toBeInstanceOf(AzureDevOpsError) + expect((e as AzureDevOpsError).isAuthError).toBe(true) + expect((e as AzureDevOpsError).status).toBe(403) + } + }) + + test("It throws AzureDevOpsError with isAuthError=true for 302 redirect to signin", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 302, + statusText: "Found", + headers: { + get: (name: string) => name.toLowerCase() === "location" ? "https://login.microsoftonline.com/_signin" : null + } + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + + try { + await sut.getRepositories() + fail("Expected error to be thrown") + } catch (e) { + expect(e).toBeInstanceOf(AzureDevOpsError) + expect((e as AzureDevOpsError).isAuthError).toBe(true) + expect((e as AzureDevOpsError).status).toBe(302) + } + }) + + test("It throws AzureDevOpsError with isAuthError=false for 302 redirect to non-auth URL", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 302, + statusText: "Found", + headers: { + get: (name: string) => name.toLowerCase() === "location" ? "https://dev.azure.com/other-page" : null + } + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + + try { + await sut.getRepositories() + fail("Expected error to be thrown") + } catch (e) { + expect(e).toBeInstanceOf(AzureDevOpsError) + expect((e as AzureDevOpsError).isAuthError).toBe(false) + expect((e as AzureDevOpsError).status).toBe(302) + } + }) + + test("It throws AzureDevOpsError with isAuthError=false for other error responses", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + text: () => Promise.resolve("Server error") + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + + try { + await sut.getRepositories() + fail("Expected error to be thrown") + } catch (e) { + expect(e).toBeInstanceOf(AzureDevOpsError) + expect((e as AzureDevOpsError).isAuthError).toBe(false) + expect((e as AzureDevOpsError).status).toBe(500) + } + }) +}) + +describe("Image file detection", () => { + const imageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".svg", ".ico"] + + test.each(imageExtensions)("It treats %s files as images", async (ext) => { + let capturedHeaders: HeadersInit | undefined + global.fetch = jest.fn().mockImplementation((_url: string, options?: RequestInit) => { + capturedHeaders = options?.headers + return Promise.resolve({ + ok: true, + status: 200, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)) + }) + }) + + const sut = new AzureDevOpsClient({ + organization: "my-org", + oauthTokenDataSource: createMockTokenDataSource() + }) + await sut.getFileContent("repo-123", `image${ext}`, "main") + + expect((capturedHeaders as Record).Accept).toEqual("application/octet-stream") + }) +}) diff --git a/__test__/common/azure-devops/AzureDevOpsError.test.ts b/__test__/common/azure-devops/AzureDevOpsError.test.ts new file mode 100644 index 00000000..ea58011e --- /dev/null +++ b/__test__/common/azure-devops/AzureDevOpsError.test.ts @@ -0,0 +1,72 @@ +import { AzureDevOpsError } from "@/common/azure-devops/AzureDevOpsError" + +test("It sets the error name to AzureDevOpsError", () => { + const error = new AzureDevOpsError("Test error", 400, false) + expect(error.name).toEqual("AzureDevOpsError") +}) + +test("It stores the error message", () => { + const error = new AzureDevOpsError("Something went wrong", 500, false) + expect(error.message).toEqual("Something went wrong") +}) + +test("It stores the HTTP status code", () => { + const error = new AzureDevOpsError("Unauthorized", 401, true) + expect(error.status).toEqual(401) +}) + +test("It stores the isAuthError flag when true", () => { + const error = new AzureDevOpsError("Auth failed", 401, true) + expect(error.isAuthError).toBe(true) +}) + +test("It stores the isAuthError flag when false", () => { + const error = new AzureDevOpsError("Not found", 404, false) + expect(error.isAuthError).toBe(false) +}) + +test("It is an instance of Error", () => { + const error = new AzureDevOpsError("Test error", 400, false) + expect(error).toBeInstanceOf(Error) +}) + +test("It is an instance of AzureDevOpsError", () => { + const error = new AzureDevOpsError("Test error", 400, false) + expect(error).toBeInstanceOf(AzureDevOpsError) +}) + +test("It can be caught as an Error", () => { + let caught: Error | undefined + try { + throw new AzureDevOpsError("Test", 500, false) + } catch (e) { + caught = e as Error + } + expect(caught).toBeDefined() + expect(caught?.message).toEqual("Test") +}) + +test("It works correctly with instanceof after being thrown", () => { + let isAzureDevOpsError = false + try { + throw new AzureDevOpsError("Test", 401, true) + } catch (e) { + isAzureDevOpsError = e instanceof AzureDevOpsError + } + expect(isAzureDevOpsError).toBe(true) +}) + +test("It preserves status and isAuthError after being thrown", () => { + let status: number | undefined + let isAuthError: boolean | undefined + try { + throw new AzureDevOpsError("Unauthorized", 401, true) + } catch (e) { + if (e instanceof AzureDevOpsError) { + status = e.status + isAuthError = e.isAuthError + } + } + expect(status).toEqual(401) + expect(isAuthError).toBe(true) +}) diff --git a/__test__/common/azure-devops/OAuthTokenRefreshingAzureDevOpsClient.test.ts b/__test__/common/azure-devops/OAuthTokenRefreshingAzureDevOpsClient.test.ts new file mode 100644 index 00000000..4cda361e --- /dev/null +++ b/__test__/common/azure-devops/OAuthTokenRefreshingAzureDevOpsClient.test.ts @@ -0,0 +1,246 @@ +import OAuthTokenRefreshingAzureDevOpsClient from "@/common/azure-devops/OAuthTokenRefreshingAzureDevOpsClient" +import { AzureDevOpsError } from "@/common/azure-devops/AzureDevOpsError" +import IAzureDevOpsClient from "@/common/azure-devops/IAzureDevOpsClient" + +function createMockClient(overrides: Partial = {}): IAzureDevOpsClient { + return { + async getRepositories() { + return [] + }, + async getRefs() { + return [] + }, + async getItems() { + return [] + }, + async getFileContent() { + return null + }, + ...overrides + } +} + +test("It forwards a request to getRepositories", async () => { + let didForwardRequest = false + const sut = new OAuthTokenRefreshingAzureDevOpsClient({ + oauthTokenDataSource: { + async getOAuthToken() { + return { accessToken: "foo", refreshToken: "bar" } + } + }, + oauthTokenRefresher: { + async refreshOAuthToken() { + return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" } + } + }, + client: createMockClient({ + async getRepositories() { + didForwardRequest = true + return [{ id: "1", name: "test", webUrl: "https://test", project: { id: "1", name: "proj" } }] + } + }) + }) + await sut.getRepositories() + expect(didForwardRequest).toBeTruthy() +}) + +test("It forwards a request to getRefs", async () => { + let forwardedRepoId: string | undefined + const sut = new OAuthTokenRefreshingAzureDevOpsClient({ + oauthTokenDataSource: { + async getOAuthToken() { + return { accessToken: "foo", refreshToken: "bar" } + } + }, + oauthTokenRefresher: { + async refreshOAuthToken() { + return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" } + } + }, + client: createMockClient({ + async getRefs(repositoryId) { + forwardedRepoId = repositoryId + return [{ name: "refs/heads/main", objectId: "abc123" }] + } + }) + }) + await sut.getRefs("repo-123") + expect(forwardedRepoId).toEqual("repo-123") +}) + +test("It forwards a request to getItems", async () => { + let forwardedParams: { repoId?: string, path?: string, version?: string } = {} + const sut = new OAuthTokenRefreshingAzureDevOpsClient({ + oauthTokenDataSource: { + async getOAuthToken() { + return { accessToken: "foo", refreshToken: "bar" } + } + }, + oauthTokenRefresher: { + async refreshOAuthToken() { + return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" } + } + }, + client: createMockClient({ + async getItems(repositoryId, scopePath, version) { + forwardedParams = { repoId: repositoryId, path: scopePath, version } + return [] + } + }) + }) + await sut.getItems("repo-123", "/", "main") + expect(forwardedParams).toEqual({ repoId: "repo-123", path: "/", version: "main" }) +}) + +test("It forwards a request to getFileContent", async () => { + let forwardedParams: { repoId?: string, path?: string, version?: string } = {} + const sut = new OAuthTokenRefreshingAzureDevOpsClient({ + oauthTokenDataSource: { + async getOAuthToken() { + return { accessToken: "foo", refreshToken: "bar" } + } + }, + oauthTokenRefresher: { + async refreshOAuthToken() { + return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" } + } + }, + client: createMockClient({ + async getFileContent(repositoryId, path, version) { + forwardedParams = { repoId: repositoryId, path, version } + return "file content" + } + }) + }) + await sut.getFileContent("repo-123", "openapi.yml", "main") + expect(forwardedParams).toEqual({ repoId: "repo-123", path: "openapi.yml", version: "main" }) +}) + +test("It retries with a refreshed OAuth token when receiving an auth error", async () => { + let didRefreshOAuthToken = false + let didRespondWithAuthError = false + const sut = new OAuthTokenRefreshingAzureDevOpsClient({ + oauthTokenDataSource: { + async getOAuthToken() { + return { accessToken: "foo", refreshToken: "bar" } + } + }, + oauthTokenRefresher: { + async refreshOAuthToken() { + didRefreshOAuthToken = true + return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" } + } + }, + client: createMockClient({ + async getRepositories() { + if (!didRespondWithAuthError) { + didRespondWithAuthError = true + throw new AzureDevOpsError("Unauthorized", 401, true) + } + return [] + } + }) + }) + await sut.getRepositories() + expect(didRefreshOAuthToken).toBeTruthy() +}) + +test("It only retries a request once when receiving auth errors", async () => { + let requestCount = 0 + const sut = new OAuthTokenRefreshingAzureDevOpsClient({ + oauthTokenDataSource: { + async getOAuthToken() { + return { accessToken: "foo", refreshToken: "bar" } + } + }, + oauthTokenRefresher: { + async refreshOAuthToken() { + return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" } + } + }, + client: createMockClient({ + async getRepositories() { + requestCount += 1 + throw new AzureDevOpsError("Unauthorized", 401, true) + } + }) + }) + // When receiving the second auth error the call should fail. + await expect(sut.getRepositories()).rejects.toThrow("Unauthorized") + // We expect two requests: + // 1. The initial request that failed after which we refreshed the OAuth token. + // 2. The second request that failed after which we gave up. + expect(requestCount).toEqual(2) +}) + +test("It does not refresh an OAuth token when the initial request was successful", async () => { + let didRefreshOAuthToken = false + const sut = new OAuthTokenRefreshingAzureDevOpsClient({ + oauthTokenDataSource: { + async getOAuthToken() { + return { accessToken: "foo", refreshToken: "bar" } + } + }, + oauthTokenRefresher: { + async refreshOAuthToken() { + didRefreshOAuthToken = true + return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" } + } + }, + client: createMockClient({ + async getRepositories() { + return [] + } + }) + }) + await sut.getRepositories() + expect(didRefreshOAuthToken).toBeFalsy() +}) + +test("It does not refresh OAuth token for non-auth errors", async () => { + let didRefreshOAuthToken = false + const sut = new OAuthTokenRefreshingAzureDevOpsClient({ + oauthTokenDataSource: { + async getOAuthToken() { + return { accessToken: "foo", refreshToken: "bar" } + } + }, + oauthTokenRefresher: { + async refreshOAuthToken() { + didRefreshOAuthToken = true + return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" } + } + }, + client: createMockClient({ + async getRepositories() { + throw new AzureDevOpsError("Not Found", 404, false) + } + }) + }) + await expect(sut.getRepositories()).rejects.toThrow("Not Found") + expect(didRefreshOAuthToken).toBeFalsy() +}) + +test("It does not refresh OAuth token for non-AzureDevOpsError errors", async () => { + let didRefreshOAuthToken = false + const sut = new OAuthTokenRefreshingAzureDevOpsClient({ + oauthTokenDataSource: { + async getOAuthToken() { + return { accessToken: "foo", refreshToken: "bar" } + } + }, + oauthTokenRefresher: { + async refreshOAuthToken() { + didRefreshOAuthToken = true + return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" } + } + }, + client: createMockClient({ + async getRepositories() { + throw new Error("Some random error") + } + }) + }) + await expect(sut.getRepositories()).rejects.toThrow("Some random error") + expect(didRefreshOAuthToken).toBeFalsy() +}) diff --git a/__test__/common/blob/AzureDevOpsBlobProvider.test.ts b/__test__/common/blob/AzureDevOpsBlobProvider.test.ts new file mode 100644 index 00000000..6de34118 --- /dev/null +++ b/__test__/common/blob/AzureDevOpsBlobProvider.test.ts @@ -0,0 +1,147 @@ +import AzureDevOpsBlobProvider from "@/common/blob/AzureDevOpsBlobProvider" +import IAzureDevOpsClient from "@/common/azure-devops/IAzureDevOpsClient" + +function createMockClient(overrides: Partial = {}): IAzureDevOpsClient { + return { + async getRepositories() { + return [] + }, + async getRefs() { + return [] + }, + async getItems() { + return [] + }, + async getFileContent() { + return null + }, + ...overrides + } +} + +test("It returns null when repository is not found", async () => { + const sut = new AzureDevOpsBlobProvider({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "other-repo", + webUrl: "https://dev.azure.com/org/proj/_git/other-repo", + project: { id: "proj-1", name: "proj" } + }] + } + }) + }) + const result = await sut.getFileContent("org", "foo-openapi", "openapi.yml", "main") + expect(result).toBeNull() +}) + +test("It returns null when file content is not found", async () => { + const sut = new AzureDevOpsBlobProvider({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + webUrl: "https://dev.azure.com/org/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getFileContent() { + return null + } + }) + }) + const result = await sut.getFileContent("org", "foo-openapi", "openapi.yml", "main") + expect(result).toBeNull() +}) + +test("It returns text content as string", async () => { + const sut = new AzureDevOpsBlobProvider({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + webUrl: "https://dev.azure.com/org/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getFileContent() { + return "openapi: 3.0.0\ninfo:\n title: Test API" + } + }) + }) + const result = await sut.getFileContent("org", "foo-openapi", "openapi.yml", "main") + expect(result).toEqual("openapi: 3.0.0\ninfo:\n title: Test API") +}) + +test("It converts ArrayBuffer content to Blob", async () => { + const testData = new TextEncoder().encode("binary content") + const sut = new AzureDevOpsBlobProvider({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + webUrl: "https://dev.azure.com/org/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getFileContent() { + return testData.buffer + } + }) + }) + const result = await sut.getFileContent("org", "foo-openapi", "icon.png", "main") + expect(result).toBeInstanceOf(Blob) +}) + +test("It passes correct parameters to getFileContent", async () => { + let passedParams: { repoId?: string, path?: string, ref?: string } = {} + const sut = new AzureDevOpsBlobProvider({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-123", + name: "foo-openapi", + webUrl: "https://dev.azure.com/org/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getFileContent(repositoryId, path, version) { + passedParams = { repoId: repositoryId, path, ref: version } + return "content" + } + }) + }) + await sut.getFileContent("org", "foo-openapi", "openapi.yml", "main") + expect(passedParams).toEqual({ + repoId: "repo-123", + path: "openapi.yml", + ref: "main" + }) +}) + +test("It ignores the owner parameter since organization is configured globally", async () => { + let didCallGetRepositories = false + const sut = new AzureDevOpsBlobProvider({ + client: createMockClient({ + async getRepositories() { + didCallGetRepositories = true + return [{ + id: "repo-1", + name: "foo-openapi", + webUrl: "https://dev.azure.com/org/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getFileContent() { + return "content" + } + }) + }) + // Pass a different owner - it should still find the repo + await sut.getFileContent("different-org", "foo-openapi", "openapi.yml", "main") + expect(didCallGetRepositories).toBeTruthy() +}) diff --git a/__test__/common/blob/GitHubBlobProvider.test.ts b/__test__/common/blob/GitHubBlobProvider.test.ts new file mode 100644 index 00000000..fb364d93 --- /dev/null +++ b/__test__/common/blob/GitHubBlobProvider.test.ts @@ -0,0 +1,118 @@ +import { jest } from "@jest/globals" +import GitHubBlobProvider from "@/common/blob/GitHubBlobProvider" +import { IGitHubClient, GetRepositoryContentRequest } from "@/common/github" + +// Mock fetch globally for Blob conversion test +const originalFetch = global.fetch + +function createMockClient(overrides: Partial = {}): IGitHubClient { + return { + async graphql() { + return {} + }, + async getRepositoryContent() { + return { downloadURL: "https://example.com/file" } + }, + async getPullRequestFiles() { + return [] + }, + async getPullRequestComments() { + return [] + }, + async addCommentToPullRequest() {}, + async updatePullRequestComment() {}, + ...overrides + } +} + +beforeEach(() => { + global.fetch = jest.fn().mockResolvedValue({ + blob: () => Promise.resolve(new Blob(["test content"])) + }) +}) + +afterEach(() => { + global.fetch = originalFetch +}) + +test("It delegates to gitHubClient.getRepositoryContent", async () => { + let forwardedRequest: GetRepositoryContentRequest | undefined + const sut = new GitHubBlobProvider({ + gitHubClient: createMockClient({ + async getRepositoryContent(request) { + forwardedRequest = request + return { downloadURL: "https://example.com/file" } + } + }) + }) + await sut.getFileContent("owner", "repo", "path/to/file.yml", "abc123") + expect(forwardedRequest).toEqual({ + repositoryOwner: "owner", + repositoryName: "repo", + path: "path/to/file.yml", + ref: "abc123" + }) +}) + +test("It fetches blob from downloadURL", async () => { + let fetchedURL: string | undefined + global.fetch = jest.fn().mockImplementation((url: string | URL | Request) => { + fetchedURL = url.toString() + return Promise.resolve({ + blob: () => Promise.resolve(new Blob(["test content"])) + }) + }) + + const sut = new GitHubBlobProvider({ + gitHubClient: createMockClient({ + async getRepositoryContent() { + return { downloadURL: "https://raw.githubusercontent.com/owner/repo/file.yml" } + } + }) + }) + await sut.getFileContent("owner", "repo", "file.yml", "main") + expect(fetchedURL).toEqual("https://raw.githubusercontent.com/owner/repo/file.yml") +}) + +test("It returns Blob from the fetched content", async () => { + const testBlob = new Blob(["test content"], { type: "application/octet-stream" }) + global.fetch = jest.fn().mockResolvedValue({ + blob: () => Promise.resolve(testBlob) + }) + + const sut = new GitHubBlobProvider({ + gitHubClient: createMockClient({ + async getRepositoryContent() { + return { downloadURL: "https://example.com/file" } + } + }) + }) + const result = await sut.getFileContent("owner", "repo", "file.yml", "main") + expect(result).toBe(testBlob) +}) + +test("It returns null when getRepositoryContent throws an error", async () => { + const sut = new GitHubBlobProvider({ + gitHubClient: createMockClient({ + async getRepositoryContent() { + throw new Error("Not found") + } + }) + }) + const result = await sut.getFileContent("owner", "repo", "file.yml", "main") + expect(result).toBeNull() +}) + +test("It returns null when fetch throws an error", async () => { + global.fetch = jest.fn().mockRejectedValue(new Error("Network error")) + + const sut = new GitHubBlobProvider({ + gitHubClient: createMockClient({ + async getRepositoryContent() { + return { downloadURL: "https://example.com/file" } + } + }) + }) + const result = await sut.getFileContent("owner", "repo", "file.yml", "main") + expect(result).toBeNull() +}) diff --git a/__test__/projects/AzureDevOpsProjectDataSource.test.ts b/__test__/projects/AzureDevOpsProjectDataSource.test.ts new file mode 100644 index 00000000..bebaa02c --- /dev/null +++ b/__test__/projects/AzureDevOpsProjectDataSource.test.ts @@ -0,0 +1,74 @@ +import { AzureDevOpsProjectDataSource } from "@/features/projects/data" +import { noopEncryptionService, base64RemoteConfigEncoder } from "./testUtils" + +test("It loads repositories from data source", async () => { + let didLoadRepositories = false + const sut = new AzureDevOpsProjectDataSource({ + repositoryNameSuffix: "-openapi", + repositoryDataSource: { + async getRepositories() { + didLoadRepositories = true + return [] + } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder + }) + await sut.getProjects() + expect(didLoadRepositories).toBeTruthy() +}) + +test("It generates correct Azure DevOps URLs", async () => { + const sut = new AzureDevOpsProjectDataSource({ + repositoryNameSuffix: "-openapi", + repositoryDataSource: { + async getRepositories() { + return [{ + owner: "myorg", + name: "foo-openapi", + webUrl: "https://dev.azure.com/myorg/myproject/_git/foo-openapi", + defaultBranchRef: { name: "main" }, + branches: [{ + name: "main", + files: [{ name: "openapi.yml" }] + }], + tags: [] + }] + } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder + }) + const projects = await sut.getProjects() + expect(projects[0].url).toEqual("https://dev.azure.com/myorg/myproject/_git/foo-openapi") + expect(projects[0].ownerUrl).toEqual("https://dev.azure.com/myorg") + expect(projects[0].versions[0].url).toEqual("https://dev.azure.com/myorg/myproject/_git/foo-openapi?version=GBmain") + expect(projects[0].versions[0].specifications[0].url).toEqual("/api/blob/myorg/foo-openapi/openapi.yml?ref=main") + expect(projects[0].versions[0].specifications[0].editURL).toEqual("https://dev.azure.com/myorg/myproject/_git/foo-openapi?path=/openapi.yml&version=GBmain&_a=contents") +}) + +test("It uses branch name as ref for Azure DevOps blob URLs", async () => { + const sut = new AzureDevOpsProjectDataSource({ + repositoryNameSuffix: "-openapi", + repositoryDataSource: { + async getRepositories() { + return [{ + owner: "myorg", + name: "foo-openapi", + webUrl: "https://dev.azure.com/myorg/myproject/_git/foo-openapi", + defaultBranchRef: { name: "main" }, + branches: [{ + name: "feature/test", + files: [{ name: "openapi.yml" }] + }], + tags: [] + }] + } + }, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder + }) + const projects = await sut.getProjects() + // Azure DevOps uses branch name as ref, not commit SHA like GitHub + expect(projects[0].versions[0].specifications[0].url).toEqual("/api/blob/myorg/foo-openapi/openapi.yml?ref=feature/test") +}) diff --git a/__test__/projects/AzureDevOpsRepositoryDataSource.test.ts b/__test__/projects/AzureDevOpsRepositoryDataSource.test.ts new file mode 100644 index 00000000..0338d049 --- /dev/null +++ b/__test__/projects/AzureDevOpsRepositoryDataSource.test.ts @@ -0,0 +1,424 @@ +import { AzureDevOpsRepositoryDataSource } from "@/features/projects/data" +import IAzureDevOpsClient, { AzureDevOpsRepository, AzureDevOpsRef, AzureDevOpsItem } from "@/common/azure-devops/IAzureDevOpsClient" + +function createMockClient(overrides: Partial = {}): IAzureDevOpsClient { + return { + async getRepositories(): Promise { + return [] + }, + async getRefs(): Promise { + return [] + }, + async getItems(): Promise { + return [] + }, + async getFileContent(): Promise { + return null + }, + ...overrides + } +} + +test("It loads repositories from data source", async () => { + let didLoadRepositories = false + const sut = new AzureDevOpsRepositoryDataSource({ + client: createMockClient({ + async getRepositories() { + didLoadRepositories = true + return [] + } + }), + organization: "myorg", + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".framna-docs.yml" + }) + await sut.getRepositories() + expect(didLoadRepositories).toBeTruthy() +}) + +test("It filters repositories by suffix", async () => { + const sut = new AzureDevOpsRepositoryDataSource({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + defaultBranch: "refs/heads/main", + webUrl: "https://dev.azure.com/myorg/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }, { + id: "repo-2", + name: "bar-service", + defaultBranch: "refs/heads/main", + webUrl: "https://dev.azure.com/myorg/proj/_git/bar-service", + project: { id: "proj-1", name: "proj" } + }] + }, + async getRefs(repositoryId) { + if (repositoryId === "repo-1") { + return [{ + name: "refs/heads/main", + objectId: "abc123" + }] + } + return [] + }, + async getItems() { + return [{ + path: "/openapi.yml", + gitObjectType: "blob" + }] + } + }), + organization: "myorg", + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".framna-docs.yml" + }) + const repositories = await sut.getRepositories() + expect(repositories.length).toEqual(1) + expect(repositories[0].name).toEqual("foo-openapi") +}) + +test("It maps repositories to the domain model", async () => { + const sut = new AzureDevOpsRepositoryDataSource({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + defaultBranch: "refs/heads/main", + webUrl: "https://dev.azure.com/myorg/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getRefs() { + return [{ + name: "refs/heads/main", + objectId: "abc123" + }, { + name: "refs/tags/1.0", + objectId: "def456" + }] + }, + async getItems() { + return [{ + path: "/openapi.yml", + gitObjectType: "blob" + }] + } + }), + organization: "myorg", + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".framna-docs.yml" + }) + const repositories = await sut.getRepositories() + expect(repositories).toEqual([{ + name: "foo-openapi", + owner: "myorg", + webUrl: "https://dev.azure.com/myorg/proj/_git/foo-openapi", + defaultBranchRef: { + id: "abc123", + name: "main" + }, + configYml: undefined, + configYaml: undefined, + branches: [{ + id: "abc123", + name: "main", + files: [{ name: "openapi.yml" }] + }], + tags: [{ + id: "def456", + name: "1.0", + files: [{ name: "openapi.yml" }] + }] + }]) +}) + +test("It separates branches from tags by ref prefix", async () => { + const sut = new AzureDevOpsRepositoryDataSource({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + defaultBranch: "refs/heads/main", + webUrl: "https://dev.azure.com/myorg/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getRefs() { + return [ + { name: "refs/heads/main", objectId: "abc123" }, + { name: "refs/heads/develop", objectId: "abc124" }, + { name: "refs/tags/v1.0", objectId: "def456" }, + { name: "refs/tags/v2.0", objectId: "def457" } + ] + }, + async getItems() { + return [{ path: "/openapi.yml", gitObjectType: "blob" }] + } + }), + organization: "myorg", + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".framna-docs.yml" + }) + const repositories = await sut.getRepositories() + expect(repositories[0].branches.length).toEqual(2) + expect(repositories[0].tags.length).toEqual(2) + expect(repositories[0].branches.map(b => b.name)).toEqual(["main", "develop"]) + expect(repositories[0].tags.map(t => t.name)).toEqual(["v1.0", "v2.0"]) +}) + +test("It strips refs/heads/ and refs/tags/ prefixes from ref names", async () => { + const sut = new AzureDevOpsRepositoryDataSource({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + defaultBranch: "refs/heads/feature/test", + webUrl: "https://dev.azure.com/myorg/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getRefs() { + return [ + { name: "refs/heads/feature/test", objectId: "abc123" }, + { name: "refs/tags/release/v1.0", objectId: "def456" } + ] + }, + async getItems() { + return [{ path: "/openapi.yml", gitObjectType: "blob" }] + } + }), + organization: "myorg", + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".framna-docs.yml" + }) + const repositories = await sut.getRepositories() + expect(repositories[0].branches[0].name).toEqual("feature/test") + expect(repositories[0].tags[0].name).toEqual("release/v1.0") + expect(repositories[0].defaultBranchRef.name).toEqual("feature/test") +}) + +test("It fetches config file with .yml extension", async () => { + const sut = new AzureDevOpsRepositoryDataSource({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + defaultBranch: "refs/heads/main", + webUrl: "https://dev.azure.com/myorg/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getRefs() { + return [{ name: "refs/heads/main", objectId: "abc123" }] + }, + async getItems() { + return [{ path: "/openapi.yml", gitObjectType: "blob" }] + }, + async getFileContent(_repoId, path) { + if (path === ".framna-docs.yml") { + return "name: Test Project" + } + return null + } + }), + organization: "myorg", + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".framna-docs.yml" + }) + const repositories = await sut.getRepositories() + expect(repositories[0].configYml).toEqual({ text: "name: Test Project" }) +}) + +test("It fetches config file with .yaml extension", async () => { + const sut = new AzureDevOpsRepositoryDataSource({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + defaultBranch: "refs/heads/main", + webUrl: "https://dev.azure.com/myorg/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getRefs() { + return [{ name: "refs/heads/main", objectId: "abc123" }] + }, + async getItems() { + return [{ path: "/openapi.yml", gitObjectType: "blob" }] + }, + async getFileContent(_repoId, path) { + if (path === ".framna-docs.yaml") { + return "name: Test Project" + } + return null + } + }), + organization: "myorg", + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".framna-docs.yaml" + }) + const repositories = await sut.getRepositories() + expect(repositories[0].configYaml).toEqual({ text: "name: Test Project" }) +}) + +test("It strips file extension from config filename before querying", async () => { + const queriedPaths: string[] = [] + const sut = new AzureDevOpsRepositoryDataSource({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + defaultBranch: "refs/heads/main", + webUrl: "https://dev.azure.com/myorg/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getRefs() { + return [{ name: "refs/heads/main", objectId: "abc123" }] + }, + async getItems() { + return [{ path: "/openapi.yml", gitObjectType: "blob" }] + }, + async getFileContent(_repoId, path) { + queriedPaths.push(path) + return null + } + }), + organization: "myorg", + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".framna-docs.yml" + }) + await sut.getRepositories() + expect(queriedPaths).toContain(".framna-docs.yml") + expect(queriedPaths).toContain(".framna-docs.yaml") +}) + +test("It only includes blob items as files", async () => { + const sut = new AzureDevOpsRepositoryDataSource({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + defaultBranch: "refs/heads/main", + webUrl: "https://dev.azure.com/myorg/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getRefs() { + return [{ name: "refs/heads/main", objectId: "abc123" }] + }, + async getItems() { + return [ + { path: "/openapi.yml", gitObjectType: "blob" }, + { path: "/docs", gitObjectType: "tree" }, + { path: "/schema.json", gitObjectType: "blob" } + ] + } + }), + organization: "myorg", + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".framna-docs.yml" + }) + const repositories = await sut.getRepositories() + expect(repositories[0].branches[0].files.length).toEqual(2) + expect(repositories[0].branches[0].files.map(f => f.name)).toEqual(["openapi.yml", "schema.json"]) +}) + +test("It defaults to main branch when defaultBranch is not set", async () => { + const sut = new AzureDevOpsRepositoryDataSource({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + webUrl: "https://dev.azure.com/myorg/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getRefs() { + return [{ name: "refs/heads/main", objectId: "abc123" }] + }, + async getItems() { + return [{ path: "/openapi.yml", gitObjectType: "blob" }] + } + }), + organization: "myorg", + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".framna-docs.yml" + }) + const repositories = await sut.getRepositories() + expect(repositories[0].defaultBranchRef.name).toEqual("main") +}) + +test("It returns null for repositories that fail to enrich", async () => { + const sut = new AzureDevOpsRepositoryDataSource({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + defaultBranch: "refs/heads/main", + webUrl: "https://dev.azure.com/myorg/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getRefs() { + throw new Error("API Error") + }, + async getItems() { + return [] + } + }), + organization: "myorg", + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".framna-docs.yml" + }) + const repositories = await sut.getRepositories() + expect(repositories.length).toEqual(0) +}) + +test("It returns null for refs that fail to enrich", async () => { + let callCount = 0 + const sut = new AzureDevOpsRepositoryDataSource({ + client: createMockClient({ + async getRepositories() { + return [{ + id: "repo-1", + name: "foo-openapi", + defaultBranch: "refs/heads/main", + webUrl: "https://dev.azure.com/myorg/proj/_git/foo-openapi", + project: { id: "proj-1", name: "proj" } + }] + }, + async getRefs() { + return [ + { name: "refs/heads/main", objectId: "abc123" }, + { name: "refs/heads/broken", objectId: "broken123" } + ] + }, + async getItems(_repoId, _path, version) { + callCount++ + if (version === "broken") { + throw new Error("API Error") + } + return [{ path: "/openapi.yml", gitObjectType: "blob" }] + } + }), + organization: "myorg", + repositoryNameSuffix: "-openapi", + projectConfigurationFilename: ".framna-docs.yml" + }) + const repositories = await sut.getRepositories() + expect(callCount).toEqual(2) + expect(repositories[0].branches.length).toEqual(1) + expect(repositories[0].branches[0].name).toEqual("main") +}) diff --git a/__test__/projects/GitHubProjectDataSource.test.ts b/__test__/projects/GitHubProjectDataSource.test.ts index 12438c76..266c4ef2 100644 --- a/__test__/projects/GitHubProjectDataSource.test.ts +++ b/__test__/projects/GitHubProjectDataSource.test.ts @@ -1,29 +1,5 @@ import { GitHubProjectDataSource } from "@/features/projects/data" -import RemoteConfig from "@/features/projects/domain/RemoteConfig" - -/** - * Simple encryption service for testing. Does nothing. - */ -const noopEncryptionService = { - encrypt: function (data: string): string { - return data - }, - decrypt: function (encryptedDataBase64: string): string { - return encryptedDataBase64 - } -} - -/** - * Simple encoder for testing - */ -const base64RemoteConfigEncoder = { - encode: function (remoteConfig: RemoteConfig): string { - return Buffer.from(JSON.stringify(remoteConfig)).toString("base64") - }, - decode: function (encodedString: string): RemoteConfig { - return JSON.parse(Buffer.from(encodedString, "base64").toString()) - } -} +import { noopEncryptionService, base64RemoteConfigEncoder } from "./testUtils" test("It loads repositories from data source", async () => { let didLoadRepositories = false @@ -42,401 +18,7 @@ test("It loads repositories from data source", async () => { expect(didLoadRepositories).toBeTruthy() }) -test("It maps projects including branches and tags", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml" - }] - }], - tags: [{ - id: "12345678", - name: "1.0", - files: [{ - name: "openapi.yml" - }] - }] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects).toEqual([{ - id: "acme-foo", - name: "foo", - displayName: "foo", - url: "https://github.com/acme/foo-openapi", - versions: [{ - id: "main", - name: "main", - specifications: [{ - id: "openapi.yml", - name: "openapi.yml", - url: "/api/blob/acme/foo-openapi/openapi.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/main/openapi.yml", - isDefault: false - }], - url: "https://github.com/acme/foo-openapi/tree/main", - isDefault: true - }, { - id: "1.0", - name: "1.0", - specifications: [{ - id: "openapi.yml", - name: "openapi.yml", - url: "/api/blob/acme/foo-openapi/openapi.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/1.0/openapi.yml", - isDefault: false - }], - url: "https://github.com/acme/foo-openapi/tree/1.0", - isDefault: false - }], - owner: "acme", - ownerUrl: "https://github.com/acme" - }]) -}) - -test("It removes suffix from project name", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml" - }] - }], - tags: [{ - id: "12345678", - name: "1.0", - files: [{ - name: "openapi.yml" - }] - }] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].id).toEqual("acme-foo") - expect(projects[0].name).toEqual("foo") - expect(projects[0].displayName).toEqual("foo") -}) - -test("It supports multiple OpenAPI specifications on a branch", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "foo-service.yml", - }, { - name: "bar-service.yml", - }, { - name: "baz-service.yml", - }] - }], - tags: [{ - id: "12345678", - name: "1.0", - files: [{ - name: "openapi.yml" - }] - }] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects).toEqual([{ - id: "acme-foo", - name: "foo", - displayName: "foo", - url: "https://github.com/acme/foo-openapi", - versions: [{ - id: "main", - name: "main", - specifications: [{ - id: "bar-service.yml", - name: "bar-service.yml", - url: "/api/blob/acme/foo-openapi/bar-service.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/main/bar-service.yml", - isDefault: false - }, { - id: "baz-service.yml", - name: "baz-service.yml", - url: "/api/blob/acme/foo-openapi/baz-service.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/main/baz-service.yml", - isDefault: false - }, - { - id: "foo-service.yml", - name: "foo-service.yml", - url: "/api/blob/acme/foo-openapi/foo-service.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/main/foo-service.yml", - isDefault: false - }], - url: "https://github.com/acme/foo-openapi/tree/main", - isDefault: true - }, { - id: "1.0", - name: "1.0", - specifications: [{ - id: "openapi.yml", - name: "openapi.yml", - url: "/api/blob/acme/foo-openapi/openapi.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/1.0/openapi.yml", - isDefault: false - }], - url: "https://github.com/acme/foo-openapi/tree/1.0", - isDefault: false - }], - owner: "acme", - ownerUrl: "https://github.com/acme" - }]) -}) - -test("It filters away projects with no versions", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - branches: [], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects.length).toEqual(0) -}) - -test("It filters away branches with no specifications", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "bugfix", - files: [{ - name: "README.md", - }] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions.length).toEqual(1) -}) - -test("It filters away tags with no specifications", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "foo-service.yml", - }] - }], - tags: [{ - id: "12345678", - name: "1.0", - files: [{ - name: "openapi.yml" - }] - }, { - id: "12345678", - name: "0.1", - files: [{ - name: "README.md" - }] - }] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions.length).toEqual(2) -}) - -test("It reads image from configuration file with .yml extension", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYml: { - text: "image: icon.png" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].imageURL).toEqual("/api/blob/acme/foo-openapi/icon.png?ref=12345678") -}) - -test("It reads display name from configuration file with .yml extension", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].id).toEqual("acme-foo") - expect(projects[0].name).toEqual("foo") - expect(projects[0].displayName).toEqual("Hello World") -}) - -test("It reads image from configuration file with .yaml extension", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: "image: icon.png" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].imageURL).toEqual("/api/blob/acme/foo-openapi/icon.png?ref=12345678") -}) - -test("It reads display name from configuration file with .yaml extension", async () => { +test("It generates correct GitHub URLs", async () => { const sut = new GitHubProjectDataSource({ repositoryNameSuffix: "-openapi", repositoryDataSource: { @@ -445,736 +27,13 @@ test("It reads display name from configuration file with .yaml extension", async owner: "acme", name: "foo-openapi", defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].id).toEqual("acme-foo") - expect(projects[0].name).toEqual("foo") - expect(projects[0].displayName).toEqual("Hello World") -}) - -test("It sorts projects alphabetically", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "cathrine-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }], - tags: [] - }, { - owner: "acme", - name: "bobby-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }], - tags: [] - }, { - owner: "acme", - name: "anne-openapi", - defaultBranchRef: { - id: "12345678", + id: "abc123", name: "main" }, - configYaml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].name).toEqual("anne") - expect(projects[1].name).toEqual("bobby") - expect(projects[2].name).toEqual("cathrine") -}) - -test("It sorts versions alphabetically", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "anne", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "bobby", - files: [{ - name: "openapi.yml", - }] - }], - tags: [{ - id: "12345678", - name: "cathrine", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "1.0", - files: [{ - name: "openapi.yml", - }] - }] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions[0].name).toEqual("1.0") - expect(projects[0].versions[1].name).toEqual("anne") - expect(projects[0].versions[2].name).toEqual("bobby") - expect(projects[0].versions[3].name).toEqual("cathrine") -}) - -test("It prioritizes main, master, develop, and development branch names when sorting verisons", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "anne", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "develop", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "development", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "master", - files: [{ - name: "openapi.yml", - }] - }], - tags: [{ - id: "12345678", - name: "1.0", - files: [{ - name: "openapi.yml", - }] - }] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions[0].name).toEqual("main") - expect(projects[0].versions[1].name).toEqual("master") - expect(projects[0].versions[2].name).toEqual("develop") - expect(projects[0].versions[3].name).toEqual("development") - expect(projects[0].versions[4].name).toEqual("1.0") - expect(projects[0].versions[5].name).toEqual("anne") -}) - -test("It sorts file specifications alphabetically", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "anne", - files: [{ - name: "z-openapi.yml", - }, { - name: "a-openapi.yml", - }, { - name: "1-openapi.yml", - }] - }], - tags: [{ - id: "12345678", - name: "cathrine", - files: [{ - name: "o-openapi.yml", - }, { - name: "2-openapi.yml", - }] - }] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions[0].specifications[0].name).toEqual("1-openapi.yml") - expect(projects[0].versions[0].specifications[1].name).toEqual("a-openapi.yml") - expect(projects[0].versions[0].specifications[2].name).toEqual("z-openapi.yml") - expect(projects[0].versions[1].specifications[0].name).toEqual("2-openapi.yml") - expect(projects[0].versions[1].specifications[1].name).toEqual("o-openapi.yml") -}) - -test("It maintains remote version specification ordering from config", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: ` - name: Hello World - remoteVersions: - - name: Bar - specifications: - - id: some-spec - name: Zac - url: https://example.com/zac.yml - - id: another-spec - name: Bob - url: https://example.com/bob.yml - ` - }, - branches: [], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions[0].specifications[0].name).toEqual("Zac") - expect(projects[0].versions[0].specifications[1].name).toEqual("Bob") -}) - -test("It identifies the default branch in returned versions", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "development" - }, - configYaml: { - text: "name: Hello World" - }, - branches: [{ - id: "12345678", - name: "anne", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "main", - files: [{ - name: "openapi.yml", - }] - }, { - id: "12345678", - name: "development", - files: [{ - name: "openapi.yml", - }] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - const defaultVersionNames = projects[0] - .versions - .filter(e => e.isDefault) - .map(e => e.name) - expect(defaultVersionNames).toEqual(["development"]) -}) - -test("It adds remote versions from the project configuration", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: ` - remoteVersions: - - name: Anne - specifications: - - name: Huey - url: https://example.com/huey.yml - - name: Dewey - url: https://example.com/dewey.yml - - name: Bobby - specifications: - - name: Louie - url: https://example.com/louie.yml - ` - }, - branches: [], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions).toEqual([{ - id: "anne", - name: "Anne", - isDefault: false, - specifications: [{ - id: "huey", - name: "Huey", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/huey.yml" })}`, - isDefault: false - }, { - id: "dewey", - name: "Dewey", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/dewey.yml" })}`, - isDefault: false - }] - }, { - id: "bobby", - name: "Bobby", - isDefault: false, - specifications: [{ - id: "louie", - name: "Louie", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/louie.yml" })}`, - isDefault: false - }] - }]) -}) - -test("It modifies ID of remote version if the ID already exists", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "bar" - }, - configYaml: { - text: ` - remoteVersions: - - name: Bar - specifications: - - name: Baz - url: https://example.com/baz.yml - - name: Bar - specifications: - - name: Hello - url: https://example.com/hello.yml - ` - }, - branches: [{ - id: "12345678", - name: "bar", - files: [{ - name: "openapi.yml" - }] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions).toEqual([{ - id: "bar", - name: "bar", - url: "https://github.com/acme/foo-openapi/tree/bar", - isDefault: true, - specifications: [{ - id: "openapi.yml", - name: "openapi.yml", - url: "/api/blob/acme/foo-openapi/openapi.yml?ref=12345678", - editURL: "https://github.com/acme/foo-openapi/edit/bar/openapi.yml", - isDefault: false - }] - }, { - id: "bar1", - name: "Bar", - isDefault: false, - specifications: [{ - id: "baz", - name: "Baz", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}`, - isDefault: false - }] - }, { - id: "bar2", - name: "Bar", - isDefault: false, - specifications: [{ - id: "hello", - name: "Hello", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/hello.yml" })}`, - isDefault: false - }] - }]) -}) - -test("It lets users specify the ID of a remote version", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "bar" - }, - configYaml: { - text: ` - remoteVersions: - - id: some-version - name: Bar - specifications: - - name: Baz - url: https://example.com/baz.yml - ` - }, - branches: [], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions).toEqual([{ - id: "some-version", - name: "Bar", - isDefault: false, - specifications: [{ - id: "baz", - name: "Baz", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}`, - isDefault: false - }] - }]) -}) - -test("It lets users specify the ID of a remote specification", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "bar" - }, - configYaml: { - text: ` - remoteVersions: - - name: Bar - specifications: - - id: some-spec - name: Baz - url: https://example.com/baz.yml - ` - }, - branches: [], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - expect(projects[0].versions).toEqual([{ - id: "bar", - name: "Bar", - isDefault: false, - specifications: [{ - id: "some-spec", - name: "Baz", - url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/baz.yml" })}`, - isDefault: false - }] - }]) -}) - -test("It sets isDefault on the correct specification based on defaultSpecificationName in config", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYml: { - text: ` - defaultSpecificationName: bar-service.yml - remoteVersions: - - name: Bar - specifications: - - id: some-spec - name: Baz - url: https://example.com/baz.yml - ` - }, - branches: [{ - id: "12345678", - name: "main", - files: [ - { name: "foo-service.yml" }, - { name: "bar-service.yml" }, - { name: "baz-service.yml" } - ] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - const specs = projects[0].versions[0].specifications - expect(specs.find(s => s.name === "bar-service.yml")!.isDefault).toBe(true) - expect(specs.find(s => s.name === "foo-service.yml")!.isDefault).toBe(false) - expect(specs.find(s => s.name === "baz-service.yml")!.isDefault).toBe(false) - expect(projects[0].versions[1].specifications.find(s => s.name === "Baz")!.isDefault).toBe(false) -}) - -test("It sets a remote specification as the default if specified", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYaml: { - text: ` - defaultSpecificationName: Baz - remoteVersions: - - name: Bar - specifications: - - id: some-spec - name: Baz - url: https://example.com/baz.yml - - id: another-spec - name: Qux - url: https://example.com/qux.yml - ` - }, - branches: [], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - const remoteSpecs = projects[0].versions[0].specifications - expect(remoteSpecs.find(s => s.id === "some-spec")!.isDefault).toBe(true) - expect(remoteSpecs.find(s => s.id === "another-spec")!.isDefault).toBe(false) -}) - - -test("It sets isDefault to false for all specifications if defaultSpecificationName is not set", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYml: { - text: `` - }, - branches: [{ - id: "12345678", - name: "main", - files: [ - { name: "foo-service.yml" }, - { name: "bar-service.yml" }, - { name: "baz-service.yml" } - ] - }], - tags: [] - }] - } - }, - encryptionService: noopEncryptionService, - remoteConfigEncoder: base64RemoteConfigEncoder - }) - const projects = await sut.getProjects() - const specs = projects[0].versions[0].specifications - expect(specs.every(s => s.isDefault === false)).toBe(true) -}) - -test("It silently ignores defaultSpecificationName if no matching spec is found", async () => { - const sut = new GitHubProjectDataSource({ - repositoryNameSuffix: "-openapi", - repositoryDataSource: { - async getRepositories() { - return [{ - owner: "acme", - name: "foo-openapi", - defaultBranchRef: { - id: "12345678", - name: "main" - }, - configYml: { - text: `defaultSpecificationName: non-existent.yml` - }, branches: [{ - id: "12345678", + id: "abc123", name: "main", - files: [ - { name: "foo-service.yml" }, - { name: "bar-service.yml" }, - { name: "baz-service.yml" } - ] + files: [{ name: "openapi.yml" }] }], tags: [] }] @@ -1184,6 +43,9 @@ test("It silently ignores defaultSpecificationName if no matching spec is found" remoteConfigEncoder: base64RemoteConfigEncoder }) const projects = await sut.getProjects() - const specs = projects[0].versions[0].specifications - expect(specs.every(s => s.isDefault === false)).toBe(true) + expect(projects[0].url).toEqual("https://github.com/acme/foo-openapi") + expect(projects[0].ownerUrl).toEqual("https://github.com/acme") + expect(projects[0].versions[0].url).toEqual("https://github.com/acme/foo-openapi/tree/main") + expect(projects[0].versions[0].specifications[0].url).toEqual("/api/blob/acme/foo-openapi/openapi.yml?ref=abc123") + expect(projects[0].versions[0].specifications[0].editURL).toEqual("https://github.com/acme/foo-openapi/edit/main/openapi.yml") }) diff --git a/__test__/projects/ProjectMapper.test.ts b/__test__/projects/ProjectMapper.test.ts new file mode 100644 index 00000000..07860930 --- /dev/null +++ b/__test__/projects/ProjectMapper.test.ts @@ -0,0 +1,487 @@ +import ProjectMapper, { type URLBuilders, type RepositoryWithRefs, type RepositoryRef } from "@/features/projects/domain/ProjectMapper" +import { noopEncryptionService, base64RemoteConfigEncoder } from "./testUtils" + +// Simple URL builders for testing - uses predictable patterns +const testURLBuilders: URLBuilders = { + getImageRef(repository: RepositoryWithRefs): string { + return repository.defaultBranchRef.id! + }, + getBlobRef(ref: RepositoryRef): string { + return ref.id! + }, + getOwnerUrl(owner: string): string { + return `https://example.com/${owner}` + }, + getProjectUrl(repository: RepositoryWithRefs): string { + return `https://example.com/${repository.owner}/${repository.name}` + }, + getVersionUrl(repository: RepositoryWithRefs, ref: RepositoryRef): string { + return `https://example.com/${repository.owner}/${repository.name}/tree/${ref.name}` + }, + getSpecEditUrl(repository: RepositoryWithRefs, ref: RepositoryRef, fileName: string): string { + return `https://example.com/${repository.owner}/${repository.name}/edit/${ref.name}/${fileName}` + } +} + +function createMapper(repositoryNameSuffix = "-openapi") { + return new ProjectMapper({ + repositoryNameSuffix, + urlBuilders: testURLBuilders, + encryptionService: noopEncryptionService, + remoteConfigEncoder: base64RemoteConfigEncoder + }) +} + +function createRepository(overrides: Partial = {}): RepositoryWithRefs { + return { + owner: "acme", + name: "foo-openapi", + defaultBranchRef: { id: "12345678", name: "main" }, + branches: [{ + id: "12345678", + name: "main", + files: [{ name: "openapi.yml" }] + }], + tags: [], + ...overrides + } +} + +test("It removes suffix from project name", () => { + const mapper = createMapper("-openapi") + const project = mapper.mapRepositoryToProject(createRepository()) + expect(project.id).toEqual("acme-foo") + expect(project.name).toEqual("foo") + expect(project.displayName).toEqual("foo") +}) + +test("It maps branches and tags to versions", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [{ + id: "12345678", + name: "main", + files: [{ name: "openapi.yml" }] + }], + tags: [{ + id: "87654321", + name: "1.0", + files: [{ name: "openapi.yml" }] + }] + })) + expect(project.versions.length).toEqual(2) + expect(project.versions.map(v => v.name)).toContain("main") + expect(project.versions.map(v => v.name)).toContain("1.0") +}) + +test("It supports multiple OpenAPI specifications on a branch", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [{ + id: "12345678", + name: "main", + files: [ + { name: "foo-service.yml" }, + { name: "bar-service.yml" }, + { name: "baz-service.yml" } + ] + }] + })) + expect(project.versions[0].specifications.length).toEqual(3) +}) + +test("It filters away branches with no specifications", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [ + { id: "1", name: "main", files: [{ name: "openapi.yml" }] }, + { id: "2", name: "bugfix", files: [{ name: "README.md" }] } + ] + })) + expect(project.versions.length).toEqual(1) + expect(project.versions[0].name).toEqual("main") +}) + +test("It filters away tags with no specifications", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [{ id: "1", name: "main", files: [{ name: "openapi.yml" }] }], + tags: [ + { id: "2", name: "1.0", files: [{ name: "openapi.yml" }] }, + { id: "3", name: "0.1", files: [{ name: "README.md" }] } + ] + })) + expect(project.versions.length).toEqual(2) +}) + +test("It reads image from configuration file with .yml extension", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + configYml: { text: "image: icon.png" } + })) + expect(project.imageURL).toEqual("/api/blob/acme/foo-openapi/icon.png?ref=12345678") +}) + +test("It reads display name from configuration file with .yml extension", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + configYml: { text: "name: Hello World" } + })) + expect(project.displayName).toEqual("Hello World") +}) + +test("It reads image from configuration file with .yaml extension", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + configYaml: { text: "image: icon.png" } + })) + expect(project.imageURL).toEqual("/api/blob/acme/foo-openapi/icon.png?ref=12345678") +}) + +test("It reads display name from configuration file with .yaml extension", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + configYaml: { text: "name: Hello World" } + })) + expect(project.displayName).toEqual("Hello World") +}) + +test("It sorts versions alphabetically", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [ + { id: "1", name: "anne", files: [{ name: "openapi.yml" }] }, + { id: "2", name: "bobby", files: [{ name: "openapi.yml" }] } + ], + tags: [ + { id: "3", name: "cathrine", files: [{ name: "openapi.yml" }] }, + { id: "4", name: "1.0", files: [{ name: "openapi.yml" }] } + ] + })) + expect(project.versions[0].name).toEqual("1.0") + expect(project.versions[1].name).toEqual("anne") + expect(project.versions[2].name).toEqual("bobby") + expect(project.versions[3].name).toEqual("cathrine") +}) + +test("It prioritizes main, master, develop, and development branch names when sorting versions", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [ + { id: "1", name: "anne", files: [{ name: "openapi.yml" }] }, + { id: "2", name: "develop", files: [{ name: "openapi.yml" }] }, + { id: "3", name: "main", files: [{ name: "openapi.yml" }] }, + { id: "4", name: "development", files: [{ name: "openapi.yml" }] }, + { id: "5", name: "master", files: [{ name: "openapi.yml" }] } + ], + tags: [{ id: "6", name: "1.0", files: [{ name: "openapi.yml" }] }] + })) + expect(project.versions[0].name).toEqual("main") + expect(project.versions[1].name).toEqual("master") + expect(project.versions[2].name).toEqual("develop") + expect(project.versions[3].name).toEqual("development") + expect(project.versions[4].name).toEqual("1.0") + expect(project.versions[5].name).toEqual("anne") +}) + +test("It sorts file specifications alphabetically", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [{ + id: "1", + name: "main", + files: [ + { name: "z-openapi.yml" }, + { name: "a-openapi.yml" }, + { name: "1-openapi.yml" } + ] + }] + })) + expect(project.versions[0].specifications[0].name).toEqual("1-openapi.yml") + expect(project.versions[0].specifications[1].name).toEqual("a-openapi.yml") + expect(project.versions[0].specifications[2].name).toEqual("z-openapi.yml") +}) + +test("It maintains remote version specification ordering from config", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [], + tags: [], + configYaml: { + text: ` + name: Hello World + remoteVersions: + - name: Bar + specifications: + - id: some-spec + name: Zac + url: https://example.com/zac.yml + - id: another-spec + name: Bob + url: https://example.com/bob.yml + ` + } + })) + expect(project.versions[0].specifications[0].name).toEqual("Zac") + expect(project.versions[0].specifications[1].name).toEqual("Bob") +}) + +test("It identifies the default branch in returned versions", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + defaultBranchRef: { id: "1", name: "development" }, + branches: [ + { id: "1", name: "anne", files: [{ name: "openapi.yml" }] }, + { id: "2", name: "main", files: [{ name: "openapi.yml" }] }, + { id: "3", name: "development", files: [{ name: "openapi.yml" }] } + ] + })) + const defaultVersionNames = project.versions.filter(v => v.isDefault).map(v => v.name) + expect(defaultVersionNames).toEqual(["development"]) +}) + +test("It adds remote versions from the project configuration", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [], + tags: [], + configYaml: { + text: ` + remoteVersions: + - name: Anne + specifications: + - name: Huey + url: https://example.com/huey.yml + - name: Dewey + url: https://example.com/dewey.yml + - name: Bobby + specifications: + - name: Louie + url: https://example.com/louie.yml + ` + } + })) + expect(project.versions).toEqual([{ + id: "anne", + name: "Anne", + isDefault: false, + specifications: [{ + id: "huey", + name: "Huey", + url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/huey.yml" })}`, + isDefault: false + }, { + id: "dewey", + name: "Dewey", + url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/dewey.yml" })}`, + isDefault: false + }] + }, { + id: "bobby", + name: "Bobby", + isDefault: false, + specifications: [{ + id: "louie", + name: "Louie", + url: `/api/remotes/${base64RemoteConfigEncoder.encode({ url: "https://example.com/louie.yml" })}`, + isDefault: false + }] + }]) +}) + +test("It modifies ID of remote version if the ID already exists", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + defaultBranchRef: { id: "12345678", name: "bar" }, + branches: [{ + id: "12345678", + name: "bar", + files: [{ name: "openapi.yml" }] + }], + tags: [], + configYaml: { + text: ` + remoteVersions: + - name: Bar + specifications: + - name: Baz + url: https://example.com/baz.yml + - name: Bar + specifications: + - name: Hello + url: https://example.com/hello.yml + ` + } + })) + expect(project.versions[0].id).toEqual("bar") + expect(project.versions[1].id).toEqual("bar1") + expect(project.versions[2].id).toEqual("bar2") +}) + +test("It lets users specify the ID of a remote version", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [], + tags: [], + configYaml: { + text: ` + remoteVersions: + - id: some-version + name: Bar + specifications: + - name: Baz + url: https://example.com/baz.yml + ` + } + })) + expect(project.versions[0].id).toEqual("some-version") +}) + +test("It lets users specify the ID of a remote specification", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [], + tags: [], + configYaml: { + text: ` + remoteVersions: + - name: Bar + specifications: + - id: some-spec + name: Baz + url: https://example.com/baz.yml + ` + } + })) + expect(project.versions[0].specifications[0].id).toEqual("some-spec") +}) + +test("It sets isDefault on the correct specification based on defaultSpecificationName in config", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + configYml: { text: "defaultSpecificationName: bar-service.yml" }, + branches: [{ + id: "12345678", + name: "main", + files: [ + { name: "foo-service.yml" }, + { name: "bar-service.yml" }, + { name: "baz-service.yml" } + ] + }] + })) + const specs = project.versions[0].specifications + expect(specs.find(s => s.name === "bar-service.yml")!.isDefault).toBe(true) + expect(specs.find(s => s.name === "foo-service.yml")!.isDefault).toBe(false) + expect(specs.find(s => s.name === "baz-service.yml")!.isDefault).toBe(false) +}) + +test("It sets a remote specification as the default if specified", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [], + tags: [], + configYaml: { + text: ` + defaultSpecificationName: Baz + remoteVersions: + - name: Bar + specifications: + - id: some-spec + name: Baz + url: https://example.com/baz.yml + - id: another-spec + name: Qux + url: https://example.com/qux.yml + ` + } + })) + const remoteSpecs = project.versions[0].specifications + expect(remoteSpecs.find(s => s.id === "some-spec")!.isDefault).toBe(true) + expect(remoteSpecs.find(s => s.id === "another-spec")!.isDefault).toBe(false) +}) + +test("It sets isDefault to false for all specifications if defaultSpecificationName is not set", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [{ + id: "12345678", + name: "main", + files: [ + { name: "foo-service.yml" }, + { name: "bar-service.yml" }, + { name: "baz-service.yml" } + ] + }] + })) + const specs = project.versions[0].specifications + expect(specs.every(s => s.isDefault === false)).toBe(true) +}) + +test("It silently ignores defaultSpecificationName if no matching spec is found", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + configYml: { text: "defaultSpecificationName: non-existent.yml" }, + branches: [{ + id: "12345678", + name: "main", + files: [ + { name: "foo-service.yml" }, + { name: "bar-service.yml" } + ] + }] + })) + const specs = project.versions[0].specifications + expect(specs.every(s => s.isDefault === false)).toBe(true) +}) + +test("It generates URLs using the provided URL builders", () => { + const mapper = createMapper() + const project = mapper.mapRepositoryToProject(createRepository({ + branches: [{ + id: "branch-id-123", + name: "main", + files: [{ name: "openapi.yml" }] + }] + })) + expect(project.url).toEqual("https://example.com/acme/foo-openapi") + expect(project.ownerUrl).toEqual("https://example.com/acme") + expect(project.versions[0].url).toEqual("https://example.com/acme/foo-openapi/tree/main") + expect(project.versions[0].specifications[0].editURL).toEqual("https://example.com/acme/foo-openapi/edit/main/openapi.yml") + expect(project.versions[0].specifications[0].url).toEqual("/api/blob/acme/foo-openapi/openapi.yml?ref=branch-id-123") +}) + +test("mapRepositories filters out projects with no versions", () => { + const mapper = createMapper() + const projects = mapper.mapRepositories([ + createRepository({ + name: "with-specs-openapi", + branches: [{ id: "1", name: "main", files: [{ name: "openapi.yml" }] }] + }), + createRepository({ + name: "without-specs-openapi", + branches: [{ id: "2", name: "main", files: [{ name: "README.md" }] }] + }) + ]) + expect(projects.length).toEqual(1) + expect(projects[0].name).toEqual("with-specs") +}) + +test("mapRepositories sorts projects alphabetically by name", () => { + const mapper = createMapper() + const projects = mapper.mapRepositories([ + createRepository({ + name: "zebra-openapi", + branches: [{ id: "1", name: "main", files: [{ name: "openapi.yml" }] }] + }), + createRepository({ + name: "alpha-openapi", + branches: [{ id: "2", name: "main", files: [{ name: "openapi.yml" }] }] + }), + createRepository({ + name: "middle-openapi", + branches: [{ id: "3", name: "main", files: [{ name: "openapi.yml" }] }] + }) + ]) + expect(projects.map(p => p.name)).toEqual(["alpha", "middle", "zebra"]) +}) diff --git a/__test__/projects/testUtils.ts b/__test__/projects/testUtils.ts new file mode 100644 index 00000000..014e2640 --- /dev/null +++ b/__test__/projects/testUtils.ts @@ -0,0 +1,25 @@ +import RemoteConfig from "@/features/projects/domain/RemoteConfig" + +/** + * Simple encryption service for testing. Does nothing. + */ +export const noopEncryptionService = { + encrypt: function (data: string): string { + return data + }, + decrypt: function (encryptedDataBase64: string): string { + return encryptedDataBase64 + } +} + +/** + * Simple encoder for testing + */ +export const base64RemoteConfigEncoder = { + encode: function (remoteConfig: RemoteConfig): string { + return Buffer.from(JSON.stringify(remoteConfig)).toString("base64") + }, + decode: function (encodedString: string): RemoteConfig { + return JSON.parse(Buffer.from(encodedString, "base64").toString()) + } +} diff --git a/src/app/api/blob/[owner]/[repository]/[...path]/route.ts b/src/app/api/blob/[owner]/[repository]/[...path]/route.ts index 1dc58c90..6b7c9ada 100644 --- a/src/app/api/blob/[owner]/[repository]/[...path]/route.ts +++ b/src/app/api/blob/[owner]/[repository]/[...path]/route.ts @@ -1,30 +1,32 @@ import { NextRequest, NextResponse } from "next/server" -import { session, userGitHubClient } from "@/composition" +import { session, blobProvider } from "@/composition" import { makeUnauthenticatedAPIErrorResponse } from "@/common" -export async function GET(req: NextRequest, { params }: { params: Promise<{ owner: string; repository: string; path: string[] }> }) { +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ owner: string; repository: string; path: string[] }> } +) { const isAuthenticated = await session.getIsAuthenticated() if (!isAuthenticated) { return makeUnauthenticatedAPIErrorResponse() } const { path: paramsPath, owner, repository } = await params const path = paramsPath.join("/") - const item = await userGitHubClient.getRepositoryContent({ - repositoryOwner: owner, - repositoryName: repository, - path: path, - ref: req.nextUrl.searchParams.get("ref") ?? undefined - }) - const url = new URL(item.downloadURL) - const imageRegex = /\.(jpg|jpeg|png|webp|avif|gif)$/; - const file = await fetch(url).then(r => r.blob()) + const ref = req.nextUrl.searchParams.get("ref") ?? "main" + + const content = await blobProvider.getFileContent(owner, repository, path, ref) + if (content === null) { + return NextResponse.json({ error: `File not found: ${path}` }, { status: 404 }) + } + const headers = new Headers() - if (new RegExp(imageRegex).exec(path)) { + const imageRegex = /\.(jpg|jpeg|png|webp|avif|gif)$/ + if (imageRegex.test(path)) { const cacheExpirationInSeconds = 60 * 60 * 24 * 30 // 30 days - headers.set("Content-Type", "image/*"); + headers.set("Content-Type", "image/*") headers.set("Cache-Control", `max-age=${cacheExpirationInSeconds}`) } else { - headers.set("Content-Type", "text/plain"); + headers.set("Content-Type", "text/plain") } - return new NextResponse(file, { status: 200, headers }) + return new NextResponse(content, { status: 200, headers }) } diff --git a/src/app/api/hooks/github/route.ts b/src/app/api/hooks/github/route.ts index b1b42444..0724c892 100644 --- a/src/app/api/hooks/github/route.ts +++ b/src/app/api/hooks/github/route.ts @@ -2,6 +2,12 @@ import { NextRequest, NextResponse } from "next/server" import { gitHubHookHandler } from "@/composition" export const POST = async (req: NextRequest): Promise => { + if (!gitHubHookHandler) { + return NextResponse.json( + { error: "GitHub webhooks not available" }, + { status: 404 } + ) + } await gitHubHookHandler.handle(req) return NextResponse.json({ status: "OK" }) } \ No newline at end of file diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx index 43a9281c..8d7a8667 100644 --- a/src/app/auth/signin/page.tsx +++ b/src/app/auth/signin/page.tsx @@ -3,12 +3,13 @@ import { Box, Button, Stack, Typography } from "@mui/material" import { signIn } from "@/composition" import { env } from "@/common" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" -import { faGithub } from "@fortawesome/free-brands-svg-icons" +import { faGithub, faMicrosoft } from "@fortawesome/free-brands-svg-icons" import SignInTexts from "@/features/auth/view/SignInTexts" import MessageLinkFooter from "@/common/ui/MessageLinkFooter" const SITE_NAME = env.getOrThrow("FRAMNA_DOCS_TITLE") const HELP_URL = env.get("FRAMNA_DOCS_HELP_URL") +const PROJECT_SOURCE_PROVIDER = env.get("PROJECT_SOURCE_PROVIDER") || "github" // Force page to be rendered dynamically to ensure we read the correct values for the environment variables. export const dynamic = "force-dynamic" @@ -74,7 +75,7 @@ const SignInColumn = () => { }}> {title} - + {HELP_URL && ( @@ -89,20 +90,25 @@ const SignInColumn = () => { ) } -const SignInWithGitHub = () => { +const SignInButton = () => { + const isAzureDevOps = PROJECT_SOURCE_PROVIDER === "azure-devops" + const providerId = isAzureDevOps ? "microsoft-entra-id" : "github" + const providerName = isAzureDevOps ? "Microsoft" : "GitHub" + const providerIcon = isAzureDevOps ? faMicrosoft : faGithub + return (
{ "use server" - await signIn("github", { redirectTo: "/" }) + await signIn(providerId, { redirectTo: "/" }) }} >
diff --git a/src/common/azure-devops/AzureDevOpsClient.ts b/src/common/azure-devops/AzureDevOpsClient.ts new file mode 100644 index 00000000..af49ab9b --- /dev/null +++ b/src/common/azure-devops/AzureDevOpsClient.ts @@ -0,0 +1,135 @@ +import IAzureDevOpsClient, { + AzureDevOpsRepository, + AzureDevOpsRef, + AzureDevOpsItem +} from "./IAzureDevOpsClient" +import { AzureDevOpsError } from "./AzureDevOpsError" + +interface IOAuthTokenDataSource { + getOAuthToken(): Promise<{ accessToken: string }> +} + +type AzureDevOpsApiResponse = { + value: T[] + count: number +} + +export default class AzureDevOpsClient implements IAzureDevOpsClient { + private readonly organization: string + private readonly oauthTokenDataSource: IOAuthTokenDataSource + private readonly apiVersion = "7.1" + + constructor(config: { + organization: string + oauthTokenDataSource: IOAuthTokenDataSource + }) { + this.organization = config.organization + this.oauthTokenDataSource = config.oauthTokenDataSource + } + + private async fetch(endpoint: string): Promise { + const oauthToken = await this.oauthTokenDataSource.getOAuthToken() + const url = `https://dev.azure.com/${this.organization}${endpoint}` + const separator = endpoint.includes("?") ? "&" : "?" + const fullUrl = `${url}${separator}api-version=${this.apiVersion}` + + const response = await fetch(fullUrl, { + headers: { + Authorization: `Bearer ${oauthToken.accessToken}`, + Accept: "application/json" + }, + // Don't follow redirects - Azure DevOps returns 302 for auth failures + redirect: "manual" + }) + + // Check for redirect (302) - Azure DevOps redirects to login on auth failure + if (response.status === 302) { + const location = response.headers.get("location") || "" + // Check if redirecting to a sign-in page (auth error) + const isAuthRedirect = location.includes("/_signin") || location.includes("/login") + throw new AzureDevOpsError( + `Azure DevOps API redirect (302) to: ${location}`, + 302, + isAuthRedirect // only trigger token refresh for auth redirects + ) + } + + // Check for authentication errors (401/403) + if (response.status === 401 || response.status === 403) { + const text = await response.text() + throw new AzureDevOpsError( + `Azure DevOps API authentication error: ${response.status} ${response.statusText} - ${text.substring(0, 200)}`, + response.status, + true // isAuthError - trigger token refresh + ) + } + + if (!response.ok) { + const text = await response.text() + throw new AzureDevOpsError( + `Azure DevOps API error: ${response.status} ${response.statusText} - ${text.substring(0, 200)}`, + response.status, + false + ) + } + + return await response.json() as T + } + + async getRepositories(): Promise { + const response = await this.fetch>( + "/_apis/git/repositories" + ) + return response.value + } + + async getRefs(repositoryId: string): Promise { + const response = await this.fetch>( + `/_apis/git/repositories/${repositoryId}/refs` + ) + return response.value + } + + async getItems(repositoryId: string, scopePath: string, version: string): Promise { + try { + const response = await this.fetch>( + `/_apis/git/repositories/${repositoryId}/items?scopePath=${encodeURIComponent(scopePath)}&recursionLevel=OneLevel&versionDescriptor.version=${encodeURIComponent(version)}` + ) + return response.value + } catch { + return [] + } + } + + private isImageFile(path: string): boolean { + const imageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".svg", ".ico"] + const lowerPath = path.toLowerCase() + return imageExtensions.some(ext => lowerPath.endsWith(ext)) + } + + async getFileContent(repositoryId: string, path: string, version: string): Promise { + try { + const oauthToken = await this.oauthTokenDataSource.getOAuthToken() + const url = `https://dev.azure.com/${this.organization}/_apis/git/repositories/${repositoryId}/items?path=${encodeURIComponent(path)}&versionDescriptor.version=${encodeURIComponent(version)}&api-version=${this.apiVersion}` + + const isImage = this.isImageFile(path) + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${oauthToken.accessToken}`, + Accept: isImage ? "application/octet-stream" : "text/plain" + } + }) + + if (!response.ok) { + return null + } + + if (isImage) { + return await response.arrayBuffer() + } + return await response.text() + } catch { + return null + } + } +} diff --git a/src/common/azure-devops/AzureDevOpsError.ts b/src/common/azure-devops/AzureDevOpsError.ts new file mode 100644 index 00000000..ea64aca0 --- /dev/null +++ b/src/common/azure-devops/AzureDevOpsError.ts @@ -0,0 +1,18 @@ +/** + * Error thrown by Azure DevOps API client when requests fail. + * Includes HTTP status code for proper error handling. + */ +export class AzureDevOpsError extends Error { + readonly status: number + readonly isAuthError: boolean + + constructor(message: string, status: number, isAuthError: boolean) { + super(message) + this.name = "AzureDevOpsError" + this.status = status + this.isAuthError = isAuthError + // Restore prototype chain - required for instanceof to work with Error subclasses in TypeScript + Object.setPrototypeOf(this, AzureDevOpsError.prototype) + } +} + diff --git a/src/common/azure-devops/IAzureDevOpsClient.ts b/src/common/azure-devops/IAzureDevOpsClient.ts new file mode 100644 index 00000000..9cb7137d --- /dev/null +++ b/src/common/azure-devops/IAzureDevOpsClient.ts @@ -0,0 +1,27 @@ +export type AzureDevOpsRepository = { + readonly id: string + readonly name: string + readonly defaultBranch?: string + readonly webUrl: string + readonly project: { + readonly id: string + readonly name: string + } +} + +export type AzureDevOpsRef = { + readonly name: string // e.g., "refs/heads/main" + readonly objectId: string +} + +export type AzureDevOpsItem = { + readonly path: string + readonly gitObjectType: "blob" | "tree" +} + +export default interface IAzureDevOpsClient { + getRepositories(): Promise + getRefs(repositoryId: string): Promise + getItems(repositoryId: string, scopePath: string, version: string): Promise + getFileContent(repositoryId: string, path: string, version: string): Promise +} diff --git a/src/common/azure-devops/OAuthTokenRefreshingAzureDevOpsClient.ts b/src/common/azure-devops/OAuthTokenRefreshingAzureDevOpsClient.ts new file mode 100644 index 00000000..79f4bec0 --- /dev/null +++ b/src/common/azure-devops/OAuthTokenRefreshingAzureDevOpsClient.ts @@ -0,0 +1,76 @@ +import IAzureDevOpsClient, { + AzureDevOpsRepository, + AzureDevOpsRef, + AzureDevOpsItem +} from "./IAzureDevOpsClient" +import { AzureDevOpsError } from "./AzureDevOpsError" + +type OAuthToken = { accessToken: string, refreshToken?: string } + +interface IOAuthTokenDataSource { + getOAuthToken(): Promise +} + +interface IOAuthTokenRefresher { + refreshOAuthToken(oauthToken: OAuthToken): Promise +} + +/** + * Wraps an Azure DevOps client and automatically refreshes OAuth tokens + * when authentication errors occur. + */ +export default class OAuthTokenRefreshingAzureDevOpsClient implements IAzureDevOpsClient { + private readonly oauthTokenDataSource: IOAuthTokenDataSource + private readonly oauthTokenRefresher: IOAuthTokenRefresher + private readonly client: IAzureDevOpsClient + + constructor(config: { + oauthTokenDataSource: IOAuthTokenDataSource + oauthTokenRefresher: IOAuthTokenRefresher + client: IAzureDevOpsClient + }) { + this.oauthTokenDataSource = config.oauthTokenDataSource + this.oauthTokenRefresher = config.oauthTokenRefresher + this.client = config.client + } + + async getRepositories(): Promise { + return await this.send(async () => { + return await this.client.getRepositories() + }) + } + + async getRefs(repositoryId: string): Promise { + return await this.send(async () => { + return await this.client.getRefs(repositoryId) + }) + } + + async getItems(repositoryId: string, scopePath: string, version: string): Promise { + return await this.send(async () => { + return await this.client.getItems(repositoryId, scopePath, version) + }) + } + + async getFileContent(repositoryId: string, path: string, version: string): Promise { + return await this.send(async () => { + return await this.client.getFileContent(repositoryId, path, version) + }) + } + + private async send(fn: () => Promise): Promise { + const oauthToken = await this.oauthTokenDataSource.getOAuthToken() + try { + return await fn() + } catch (e) { + // Check if this is an authentication error that we can recover from + if (e instanceof AzureDevOpsError && e.isAuthError) { + // Refresh access token and try the request one last time + await this.oauthTokenRefresher.refreshOAuthToken(oauthToken) + return await fn() + } + // Not an error we can handle so forward it + throw e + } + } +} diff --git a/src/common/azure-devops/index.ts b/src/common/azure-devops/index.ts new file mode 100644 index 00000000..4e7bffd3 --- /dev/null +++ b/src/common/azure-devops/index.ts @@ -0,0 +1,5 @@ +export { default as AzureDevOpsClient } from "./AzureDevOpsClient" +export { default as OAuthTokenRefreshingAzureDevOpsClient } from "./OAuthTokenRefreshingAzureDevOpsClient" +export { AzureDevOpsError } from "./AzureDevOpsError" +export type { default as IAzureDevOpsClient } from "./IAzureDevOpsClient" +export * from "./IAzureDevOpsClient" diff --git a/src/common/blob/AzureDevOpsBlobProvider.ts b/src/common/blob/AzureDevOpsBlobProvider.ts new file mode 100644 index 00000000..c72336f7 --- /dev/null +++ b/src/common/blob/AzureDevOpsBlobProvider.ts @@ -0,0 +1,28 @@ +import { IAzureDevOpsClient } from "@/common/azure-devops" +import IBlobProvider from "./IBlobProvider" + +export default class AzureDevOpsBlobProvider implements IBlobProvider { + private readonly client: IAzureDevOpsClient + + constructor(params: { client: IAzureDevOpsClient }) { + this.client = params.client + } + + // owner is ignored - Azure DevOps organization is configured globally + async getFileContent(_owner: string, repository: string, path: string, ref: string): Promise { + const repositories = await this.client.getRepositories() + const repo = repositories.find(r => r.name === repository) + if (!repo) { + return null + } + const content = await this.client.getFileContent(repo.id, path, ref) + if (content === null) { + return null + } + // Convert ArrayBuffer to Blob for binary content + if (content instanceof ArrayBuffer) { + return new Blob([content]) + } + return content + } +} diff --git a/src/common/blob/GitHubBlobProvider.ts b/src/common/blob/GitHubBlobProvider.ts new file mode 100644 index 00000000..e00e9eb6 --- /dev/null +++ b/src/common/blob/GitHubBlobProvider.ts @@ -0,0 +1,24 @@ +import { IGitHubClient } from "@/common/github" +import IBlobProvider from "./IBlobProvider" + +export default class GitHubBlobProvider implements IBlobProvider { + private readonly gitHubClient: IGitHubClient + + constructor(params: { gitHubClient: IGitHubClient }) { + this.gitHubClient = params.gitHubClient + } + + async getFileContent(owner: string, repository: string, path: string, ref: string): Promise { + try { + const item = await this.gitHubClient.getRepositoryContent({ + repositoryOwner: owner, + repositoryName: repository, + path, + ref + }) + return await fetch(new URL(item.downloadURL)).then(r => r.blob()) + } catch { + return null + } + } +} diff --git a/src/common/blob/IBlobProvider.ts b/src/common/blob/IBlobProvider.ts new file mode 100644 index 00000000..2aeb35d0 --- /dev/null +++ b/src/common/blob/IBlobProvider.ts @@ -0,0 +1,3 @@ +export default interface IBlobProvider { + getFileContent(owner: string, repository: string, path: string, ref: string): Promise +} diff --git a/src/common/blob/index.ts b/src/common/blob/index.ts new file mode 100644 index 00000000..b3805189 --- /dev/null +++ b/src/common/blob/index.ts @@ -0,0 +1,3 @@ +export { default as GitHubBlobProvider } from "./GitHubBlobProvider" +export { default as AzureDevOpsBlobProvider } from "./AzureDevOpsBlobProvider" +export type { default as IBlobProvider } from "./IBlobProvider" diff --git a/src/composition.ts b/src/composition.ts index 10f4e504..c7f7af2f 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -1,6 +1,8 @@ import { Pool } from "pg" import NextAuth from "next-auth" +import type { Provider } from "next-auth/providers" import GithubProvider from "next-auth/providers/github" +import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id" import PostgresAdapter from "@auth/pg-adapter" import RedisKeyedMutexFactory from "@/common/mutex/RedisKeyedMutexFactory" import RedisKeyValueStore from "@/common/key-value-store/RedisKeyValueStore" @@ -15,18 +17,24 @@ import { SessionMutexFactory, listFromCommaSeparatedString } from "@/common" +import { AzureDevOpsClient, OAuthTokenRefreshingAzureDevOpsClient } from "@/common/azure-devops" +import { GitHubBlobProvider, AzureDevOpsBlobProvider, IBlobProvider } from "@/common/blob" import { GitHubLoginDataSource, GitHubProjectDataSource, - GitHubRepositoryDataSource + GitHubRepositoryDataSource, + AzureDevOpsRepositoryDataSource, + AzureDevOpsProjectDataSource } from "@/features/projects/data" import { CachingProjectDataSource, FilteringGitHubRepositoryDataSource, - ProjectRepository + ProjectRepository, + IProjectDataSource } from "@/features/projects/domain" import { - GitHubOAuthTokenRefresher + GitHubOAuthTokenRefresher, + AzureDevOpsOAuthTokenRefresher } from "@/features/auth/data" import { AuthjsAccountsOAuthTokenRepository, @@ -39,7 +47,8 @@ import { OAuthTokenRepository, OAuthTokenSessionValidator, PersistingOAuthTokenRefresher, - UserDataCleanUpLogOutHandler + UserDataCleanUpLogOutHandler, + IOAuthTokenRefresher } from "@/features/auth/domain" import { GitHubHookHandler @@ -54,14 +63,31 @@ import { RepoRestrictedGitHubClient } from "./common/github/RepoRestrictedGitHub import RsaEncryptionService from "./features/encrypt/EncryptionService" import RemoteConfigEncoder from "./features/projects/domain/RemoteConfigEncoder" -const gitHubAppCredentials = { +// Provider configuration +const projectSourceProvider = env.get("PROJECT_SOURCE_PROVIDER") || "github" +const isGitHubProvider = projectSourceProvider === "github" +const isAzureDevOpsProvider = projectSourceProvider === "azure-devops" + +// Microsoft's registered Application ID for Azure DevOps +const AZURE_DEVOPS_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798" + +// GitHub credentials (only loaded if using GitHub provider) +const gitHubAppCredentials = isGitHubProvider ? { appId: env.getOrThrow("GITHUB_APP_ID"), clientId: env.getOrThrow("GITHUB_CLIENT_ID"), clientSecret: env.getOrThrow("GITHUB_CLIENT_SECRET"), privateKey: Buffer .from(env.getOrThrow("GITHUB_PRIVATE_KEY_BASE_64"), "base64") .toString("utf-8") -} +} : null + +// Azure DevOps credentials (only loaded if using Azure DevOps provider) +const azureDevOpsCredentials = isAzureDevOpsProvider ? { + clientId: env.getOrThrow("AZURE_ENTRA_ID_CLIENT_ID"), + clientSecret: env.getOrThrow("AZURE_ENTRA_ID_CLIENT_SECRET"), + tenantId: env.getOrThrow("AZURE_ENTRA_ID_TENANT_ID"), + organization: env.getOrThrow("AZURE_DEVOPS_ORGANIZATION") +} : null const pool = new Pool({ host: env.getOrThrow("POSTGRESQL_HOST"), @@ -75,13 +101,48 @@ const pool = new Pool({ const db = new PostgreSQLDB({ pool }) +// The NextAuth provider ID differs from our config value +const authProviderName = isAzureDevOpsProvider ? "microsoft-entra-id" : "github" + const oauthTokenRepository = new FallbackOAuthTokenRepository({ - primaryRepository: new OAuthTokenRepository({ db, provider: "github" }), - secondaryRepository: new AuthjsAccountsOAuthTokenRepository({ db, provider: "github" }) + primaryRepository: new OAuthTokenRepository({ db, provider: authProviderName }), + secondaryRepository: new AuthjsAccountsOAuthTokenRepository({ db, provider: authProviderName }) }) const logInHandler = new LogInHandler({ oauthTokenRepository }) +// Build auth providers based on configuration +function getAuthProviders(): Provider[] { + if (isGitHubProvider && gitHubAppCredentials) { + return [ + GithubProvider({ + clientId: gitHubAppCredentials.clientId, + clientSecret: gitHubAppCredentials.clientSecret, + authorization: { + params: { + scope: "repo" + } + } + }) + ] + } else if (isAzureDevOpsProvider && azureDevOpsCredentials) { + return [ + MicrosoftEntraID({ + clientId: azureDevOpsCredentials.clientId, + clientSecret: azureDevOpsCredentials.clientSecret, + issuer: `https://login.microsoftonline.com/${azureDevOpsCredentials.tenantId}/v2.0`, + authorization: { + params: { + // Request Azure DevOps API access + offline_access for refresh tokens + scope: `openid profile email offline_access ${AZURE_DEVOPS_RESOURCE_ID}/.default` + } + } + }) + ] + } + throw new Error(`Unsupported PROJECT_SOURCE_PROVIDER: ${projectSourceProvider}`) +} + export const { signIn, auth, handlers: authHandlers } = NextAuth({ adapter: PostgresAdapter(pool), secret: env.getOrThrow("NEXTAUTH_SECRET"), @@ -93,17 +154,7 @@ export const { signIn, auth, handlers: authHandlers } = NextAuth({ pages: { signIn: "/auth/signin" }, - providers: [ - GithubProvider({ - clientId: env.getOrThrow("GITHUB_CLIENT_ID"), - clientSecret: env.getOrThrow("GITHUB_CLIENT_SECRET"), - authorization: { - params: { - scope: "repo" - } - } - }) - ], + providers: getAuthProviders(), session: { strategy: "database" }, @@ -135,6 +186,20 @@ const oauthTokenDataSource = new OAuthTokenDataSource({ repository: oauthTokenRepository }) +// Build OAuth token refresher based on provider +function getOAuthTokenRefresher(): IOAuthTokenRefresher { + if (isGitHubProvider && gitHubAppCredentials) { + return new GitHubOAuthTokenRefresher(gitHubAppCredentials) + } else if (isAzureDevOpsProvider && azureDevOpsCredentials) { + return new AzureDevOpsOAuthTokenRefresher({ + clientId: azureDevOpsCredentials.clientId, + clientSecret: azureDevOpsCredentials.clientSecret, + tenantId: azureDevOpsCredentials.tenantId + }) + } + throw new Error(`Unsupported PROJECT_SOURCE_PROVIDER: ${projectSourceProvider}`) +} + const oauthTokenRefresher = new LockingOAuthTokenRefresher({ mutexFactory: new SessionMutexFactory({ baseKey: "mutexLockingOAuthTokenRefresher", @@ -144,25 +209,50 @@ const oauthTokenRefresher = new LockingOAuthTokenRefresher({ oauthTokenRefresher: new PersistingOAuthTokenRefresher({ userIdReader: session, oauthTokenRepository, - oauthTokenRefresher: new GitHubOAuthTokenRefresher(gitHubAppCredentials) + oauthTokenRefresher: getOAuthTokenRefresher() }) }) -const gitHubClient = new GitHubClient({ +// GitHub-specific clients (only used for GitHub provider) +const gitHubClient = isGitHubProvider && gitHubAppCredentials ? new GitHubClient({ ...gitHubAppCredentials, oauthTokenDataSource -}) +}) : null -const repoRestrictedGitHubClient = new RepoRestrictedGitHubClient({ +const repoRestrictedGitHubClient = gitHubClient ? new RepoRestrictedGitHubClient({ repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX"), gitHubClient -}) +}) : null -export const userGitHubClient = new OAuthTokenRefreshingGitHubClient({ +export const userGitHubClient = repoRestrictedGitHubClient ? new OAuthTokenRefreshingGitHubClient({ gitHubClient: repoRestrictedGitHubClient, oauthTokenDataSource, oauthTokenRefresher -}) +}) : null + +// Azure DevOps client (only used for Azure DevOps provider) +const baseAzureDevOpsClient = isAzureDevOpsProvider && azureDevOpsCredentials ? new AzureDevOpsClient({ + organization: azureDevOpsCredentials.organization, + oauthTokenDataSource +}) : null + +export const azureDevOpsClient = baseAzureDevOpsClient ? new OAuthTokenRefreshingAzureDevOpsClient({ + client: baseAzureDevOpsClient, + oauthTokenDataSource, + oauthTokenRefresher +}) : null + +// Blob provider for fetching file content +function getBlobProvider(): IBlobProvider { + if (userGitHubClient) { + return new GitHubBlobProvider({ gitHubClient: userGitHubClient }) + } else if (azureDevOpsClient) { + return new AzureDevOpsBlobProvider({ client: azureDevOpsClient }) + } + throw new Error(`No blob provider available for PROJECT_SOURCE_PROVIDER: ${projectSourceProvider}`) +} + +export const blobProvider = getBlobProvider() export const blockingSessionValidator = new OAuthTokenSessionValidator({ oauthTokenDataSource @@ -185,23 +275,43 @@ export const encryptionService = new RsaEncryptionService({ export const remoteConfigEncoder = new RemoteConfigEncoder(encryptionService) -export const projectDataSource = new CachingProjectDataSource({ - dataSource: new GitHubProjectDataSource({ - repositoryDataSource: new FilteringGitHubRepositoryDataSource({ - hiddenRepositories: listFromCommaSeparatedString(env.get("HIDDEN_REPOSITORIES")), - dataSource: new GitHubRepositoryDataSource({ - loginsDataSource: new GitHubLoginDataSource({ - graphQlClient: userGitHubClient - }), - graphQlClient: userGitHubClient, +// Build project data source based on provider +function getProjectDataSource(): IProjectDataSource { + if (isGitHubProvider && userGitHubClient) { + return new GitHubProjectDataSource({ + repositoryDataSource: new FilteringGitHubRepositoryDataSource({ + hiddenRepositories: listFromCommaSeparatedString(env.get("HIDDEN_REPOSITORIES")), + dataSource: new GitHubRepositoryDataSource({ + loginsDataSource: new GitHubLoginDataSource({ + graphQlClient: userGitHubClient + }), + graphQlClient: userGitHubClient, + repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX"), + projectConfigurationFilename: env.getOrThrow("FRAMNA_DOCS_PROJECT_CONFIGURATION_FILENAME") + }) + }), + repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX"), + encryptionService: encryptionService, + remoteConfigEncoder: remoteConfigEncoder + }) + } else if (isAzureDevOpsProvider && azureDevOpsClient && azureDevOpsCredentials) { + return new AzureDevOpsProjectDataSource({ + repositoryDataSource: new AzureDevOpsRepositoryDataSource({ + client: azureDevOpsClient, + organization: azureDevOpsCredentials.organization, repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX"), projectConfigurationFilename: env.getOrThrow("FRAMNA_DOCS_PROJECT_CONFIGURATION_FILENAME") - }) - }), - repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX"), - encryptionService: encryptionService, - remoteConfigEncoder: remoteConfigEncoder - }), + }), + repositoryNameSuffix: env.getOrThrow("REPOSITORY_NAME_SUFFIX"), + encryptionService: encryptionService, + remoteConfigEncoder: remoteConfigEncoder + }) + } + throw new Error(`Unsupported PROJECT_SOURCE_PROVIDER: ${projectSourceProvider}`) +} + +export const projectDataSource = new CachingProjectDataSource({ + dataSource: getProjectDataSource(), repository: projectRepository }) @@ -211,7 +321,8 @@ export const logOutHandler = new ErrorIgnoringLogOutHandler( ]) ) -export const gitHubHookHandler = new GitHubHookHandler({ +// GitHub webhook handler (only available for GitHub provider) +export const gitHubHookHandler = isGitHubProvider && gitHubClient ? new GitHubHookHandler({ secret: env.getOrThrow("GITHUB_WEBHOOK_SECRET"), pullRequestEventHandler: new FilteringPullRequestEventHandler({ filter: new RepositoryNameEventFilter({ @@ -230,4 +341,4 @@ export const gitHubHookHandler = new GitHubHookHandler({ }) }) }) -}) \ No newline at end of file +}) : null diff --git a/src/features/auth/data/AzureDevOpsOAuthTokenRefresher.ts b/src/features/auth/data/AzureDevOpsOAuthTokenRefresher.ts new file mode 100644 index 00000000..20d2e719 --- /dev/null +++ b/src/features/auth/data/AzureDevOpsOAuthTokenRefresher.ts @@ -0,0 +1,78 @@ +import { UnauthorizedError } from "@/common" +import { OAuthToken, IOAuthTokenRefresher } from "../domain" + +// Microsoft's registered Application ID for Azure DevOps +const AZURE_DEVOPS_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798" + +export interface AzureDevOpsOAuthTokenRefresherConfig { + readonly clientId: string + readonly clientSecret: string + readonly tenantId: string +} + +type TokenResponse = { + access_token?: string + refresh_token?: string + error?: string + error_description?: string +} + +/** + * Refreshes OAuth tokens using Microsoft Entra ID (Azure AD) token endpoint. + */ +export default class AzureDevOpsOAuthTokenRefresher implements IOAuthTokenRefresher { + private readonly config: AzureDevOpsOAuthTokenRefresherConfig + + constructor(config: AzureDevOpsOAuthTokenRefresherConfig) { + this.config = config + } + + async refreshOAuthToken(oldOAuthToken: OAuthToken): Promise { + if (!oldOAuthToken.refreshToken) { + throw new Error("Cannot refresh OAuth token as it has no refresh token") + } + + // Use Microsoft Entra ID token endpoint + const url = `https://login.microsoftonline.com/${this.config.tenantId}/oauth2/v2.0/token` + const body = new URLSearchParams({ + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + grant_type: "refresh_token", + refresh_token: oldOAuthToken.refreshToken, + // Request Azure DevOps API access + scope: `${AZURE_DEVOPS_RESOURCE_ID}/.default offline_access` + }) + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: body.toString() + }) + + if (response.status !== 200) { + throw new UnauthorizedError( + `Failed refreshing access token with HTTP status ${response.status}: ${response.statusText}` + ) + } + + const data = await response.json() as TokenResponse + + if (data.error) { + throw new UnauthorizedError(data.error_description || data.error) + } + + const accessToken = data.access_token + const refreshToken = data.refresh_token + + if (!accessToken || accessToken.length <= 0) { + throw new UnauthorizedError("Refreshing access token did not produce a valid access token") + } + + return { + accessToken, + refreshToken: refreshToken || oldOAuthToken.refreshToken + } + } +} diff --git a/src/features/auth/data/index.ts b/src/features/auth/data/index.ts index a1b0236b..fe22534f 100644 --- a/src/features/auth/data/index.ts +++ b/src/features/auth/data/index.ts @@ -1 +1,2 @@ export { default as GitHubOAuthTokenRefresher } from "./GitHubOAuthTokenRefresher" +export { default as AzureDevOpsOAuthTokenRefresher } from "./AzureDevOpsOAuthTokenRefresher" diff --git a/src/features/auth/domain/log-in/LogInHandler.ts b/src/features/auth/domain/log-in/LogInHandler.ts index c8865775..7b09e5ed 100644 --- a/src/features/auth/domain/log-in/LogInHandler.ts +++ b/src/features/auth/domain/log-in/LogInHandler.ts @@ -13,15 +13,15 @@ export default class LogInHandler implements ILogInHandler { if (!account) { return false } - if (account.provider === "github") { - return await this.handleLogInForGitHubUser({ user, account }) + if (account.provider === "github" || account.provider === "microsoft-entra-id") { + return await this.handleLogInForOAuthUser({ user, account }) } else { console.error("Unhandled account provider: " + account.provider) return false } } - private async handleLogInForGitHubUser({ user, account }: { user: IUser, account: IAccount }) { + private async handleLogInForOAuthUser({ user, account }: { user: IUser, account: IAccount }) { if (!user.id) { return false } diff --git a/src/features/projects/data/AzureDevOpsProjectDataSource.ts b/src/features/projects/data/AzureDevOpsProjectDataSource.ts new file mode 100644 index 00000000..6a0249bd --- /dev/null +++ b/src/features/projects/data/AzureDevOpsProjectDataSource.ts @@ -0,0 +1,57 @@ +import { IEncryptionService } from "@/features/encrypt/EncryptionService" +import { + Project, + IProjectDataSource +} from "../domain" +import IAzureDevOpsRepositoryDataSource, { + AzureDevOpsRepositoryWithRefs, + AzureDevOpsRepositoryRef +} from "../domain/IAzureDevOpsRepositoryDataSource" +import ProjectMapper, { type URLBuilders } from "../domain/ProjectMapper" +import { IRemoteConfigEncoder } from "../domain/RemoteConfigEncoder" + +const azureDevOpsURLBuilders: URLBuilders = { + getImageRef(repository: AzureDevOpsRepositoryWithRefs): string { + return repository.defaultBranchRef.name + }, + getBlobRef(ref: AzureDevOpsRepositoryRef): string { + return ref.name + }, + getOwnerUrl(owner: string): string { + return `https://dev.azure.com/${owner}` + }, + getProjectUrl(repository: AzureDevOpsRepositoryWithRefs): string { + return repository.webUrl + }, + getVersionUrl(repository: AzureDevOpsRepositoryWithRefs, ref: AzureDevOpsRepositoryRef): string { + return `${repository.webUrl}?version=GB${ref.name}` + }, + getSpecEditUrl(repository: AzureDevOpsRepositoryWithRefs, ref: AzureDevOpsRepositoryRef, fileName: string): string { + return `${repository.webUrl}?path=/${fileName}&version=GB${ref.name}&_a=contents` + } +} + +export default class AzureDevOpsProjectDataSource implements IProjectDataSource { + private readonly repositoryDataSource: IAzureDevOpsRepositoryDataSource + private readonly projectMapper: ProjectMapper + + constructor(config: { + repositoryDataSource: IAzureDevOpsRepositoryDataSource + repositoryNameSuffix: string + encryptionService: IEncryptionService + remoteConfigEncoder: IRemoteConfigEncoder + }) { + this.repositoryDataSource = config.repositoryDataSource + this.projectMapper = new ProjectMapper({ + repositoryNameSuffix: config.repositoryNameSuffix, + urlBuilders: azureDevOpsURLBuilders, + encryptionService: config.encryptionService, + remoteConfigEncoder: config.remoteConfigEncoder + }) + } + + async getProjects(): Promise { + const repositories = await this.repositoryDataSource.getRepositories() + return this.projectMapper.mapRepositories(repositories) + } +} diff --git a/src/features/projects/data/AzureDevOpsRepositoryDataSource.ts b/src/features/projects/data/AzureDevOpsRepositoryDataSource.ts new file mode 100644 index 00000000..c28cf80e --- /dev/null +++ b/src/features/projects/data/AzureDevOpsRepositoryDataSource.ts @@ -0,0 +1,124 @@ +import IAzureDevOpsRepositoryDataSource, { + AzureDevOpsRepositoryWithRefs, + AzureDevOpsRepositoryRef +} from "../domain/IAzureDevOpsRepositoryDataSource" +import { IAzureDevOpsClient, AzureDevOpsRepository, AzureDevOpsRef } from "@/common/azure-devops" + +export default class AzureDevOpsRepositoryDataSource implements IAzureDevOpsRepositoryDataSource { + private readonly client: IAzureDevOpsClient + private readonly organization: string + private readonly repositoryNameSuffix: string + private readonly projectConfigurationFilename: string + + constructor(config: { + client: IAzureDevOpsClient + organization: string + repositoryNameSuffix: string + projectConfigurationFilename: string + }) { + this.client = config.client + this.organization = config.organization + this.repositoryNameSuffix = config.repositoryNameSuffix + this.projectConfigurationFilename = config.projectConfigurationFilename.replace(/\.ya?ml$/, "") + } + + async getRepositories(): Promise { + const allRepos = await this.client.getRepositories() + + // Filter repos by naming convention + const matchingRepos = allRepos.filter(repo => + repo.name.endsWith(this.repositoryNameSuffix) + ) + + // Fetch details for each matching repo + const results = await Promise.all( + matchingRepos.map(repo => this.enrichRepository(repo)) + ) + + return results.filter((repo): repo is AzureDevOpsRepositoryWithRefs => repo !== null) + } + + private async enrichRepository(repo: AzureDevOpsRepository): Promise { + try { + const refs = await this.client.getRefs(repo.id) + + // Separate branches and tags + const branchRefs = refs.filter(ref => ref.name.startsWith("refs/heads/")) + const tagRefs = refs.filter(ref => ref.name.startsWith("refs/tags/")) + + // Get default branch name + const defaultBranchName = repo.defaultBranch?.replace("refs/heads/", "") || "main" + const defaultBranchRef = branchRefs.find(ref => + ref.name === `refs/heads/${defaultBranchName}` + ) + + // Fetch files for each branch/tag + const branches = await Promise.all( + branchRefs.map(ref => this.enrichRef(repo.id, ref)) + ) + const tags = await Promise.all( + tagRefs.map(ref => this.enrichRef(repo.id, ref)) + ) + + // Fetch config files from default branch + const configYml = await this.fetchConfigFile(repo.id, defaultBranchName, ".yml") + const configYaml = await this.fetchConfigFile(repo.id, defaultBranchName, ".yaml") + + return { + name: repo.name, + owner: this.organization, + webUrl: repo.webUrl, + defaultBranchRef: { + id: defaultBranchRef?.objectId || "", + name: defaultBranchName + }, + configYml, + configYaml, + branches: branches.filter((b): b is AzureDevOpsRepositoryRef => b !== null), + tags: tags.filter((t): t is AzureDevOpsRepositoryRef => t !== null) + } + } catch { + return null + } + } + + private async enrichRef( + repositoryId: string, + ref: AzureDevOpsRef + ): Promise { + try { + // Extract branch/tag name from full ref path + const name = ref.name + .replace("refs/heads/", "") + .replace("refs/tags/", "") + + // Get root files for this ref + const items = await this.client.getItems(repositoryId, "/", name) + const files = items + .filter(item => item.gitObjectType === "blob") + .map(item => ({ name: item.path.replace("/", "") })) + + return { + id: ref.objectId, + name, + files + } + } catch { + return null + } + } + + private async fetchConfigFile( + repositoryId: string, + branchName: string, + extension: string + ): Promise<{ text: string } | undefined> { + const path = `${this.projectConfigurationFilename}${extension}` + const content = await this.client.getFileContent(repositoryId, path, branchName) + + if (content) { + return { text: content } + } + return undefined + } +} diff --git a/src/features/projects/data/GitHubProjectDataSource.ts b/src/features/projects/data/GitHubProjectDataSource.ts index 17086513..110a4174 100644 --- a/src/features/projects/data/GitHubProjectDataSource.ts +++ b/src/features/projects/data/GitHubProjectDataSource.ts @@ -1,25 +1,37 @@ import { IEncryptionService } from "@/features/encrypt/EncryptionService" import { Project, - Version, - IProjectConfig, IProjectDataSource, - ProjectConfigParser, - ProjectConfigRemoteVersion, - IGitHubRepositoryDataSource, - GitHubRepository, - GitHubRepositoryRef, - ProjectConfigRemoteSpecification + IGitHubRepositoryDataSource } from "../domain" -import RemoteConfig from "../domain/RemoteConfig" +import ProjectMapper, { type URLBuilders, type RepositoryWithRefs, type RepositoryRef } from "../domain/ProjectMapper" import { IRemoteConfigEncoder } from "../domain/RemoteConfigEncoder" +const gitHubURLBuilders: URLBuilders = { + getImageRef(repository: RepositoryWithRefs): string { + return repository.defaultBranchRef.id! + }, + getBlobRef(ref: RepositoryRef): string { + return ref.id! + }, + getOwnerUrl(owner: string): string { + return `https://github.com/${owner}` + }, + getProjectUrl(repository: RepositoryWithRefs): string { + return `https://github.com/${repository.owner}/${repository.name}` + }, + getVersionUrl(repository: RepositoryWithRefs, ref: RepositoryRef): string { + return `https://github.com/${repository.owner}/${repository.name}/tree/${ref.name}` + }, + getSpecEditUrl(repository: RepositoryWithRefs, ref: RepositoryRef, fileName: string): string { + return `https://github.com/${repository.owner}/${repository.name}/edit/${ref.name}/${fileName}` + } +} + export default class GitHubProjectDataSource implements IProjectDataSource { private readonly repositoryDataSource: IGitHubRepositoryDataSource - private readonly repositoryNameSuffix: string - private readonly encryptionService: IEncryptionService - private readonly remoteConfigEncoder: IRemoteConfigEncoder - + private readonly projectMapper: ProjectMapper + constructor(config: { repositoryDataSource: IGitHubRepositoryDataSource repositoryNameSuffix: string @@ -27,236 +39,16 @@ export default class GitHubProjectDataSource implements IProjectDataSource { remoteConfigEncoder: IRemoteConfigEncoder }) { this.repositoryDataSource = config.repositoryDataSource - this.repositoryNameSuffix = config.repositoryNameSuffix - this.encryptionService = config.encryptionService - this.remoteConfigEncoder = config.remoteConfigEncoder - } - - async getProjects(): Promise { - const repositories = await this.repositoryDataSource.getRepositories() - return repositories.map(repository => { - return this.mapProject(repository) - }) - .filter((project: Project) => { - return project.versions.length > 0 - }) - .sort((a: Project, b: Project) => { - return a.name.localeCompare(b.name) - }) - } - - private mapProject(repository: GitHubRepository): Project { - const config = this.getConfig(repository) - let imageURL: string | undefined - if (config && config.image) { - imageURL = this.getGitHubBlobURL({ - ownerName: repository.owner, - repositoryName: repository.name, - path: config.image, - ref: repository.defaultBranchRef.id - }) - } - const versions = this.sortVersions( - this.addRemoteVersions( - this.getVersions(repository), - config?.remoteVersions || [] - ), - repository.defaultBranchRef.name - ).filter(version => { - return version.specifications.length > 0 - }) - .map(version => this.setDefaultSpecification(version, config?.defaultSpecificationName)) - const defaultName = repository.name.replace(new RegExp(this.repositoryNameSuffix + "$"), "") - return { - id: `${repository.owner}-${defaultName}`, - owner: repository.owner, - name: defaultName, - displayName: config?.name || defaultName, - versions, - imageURL: imageURL, - ownerUrl: `https://github.com/${repository.owner}`, - url: `https://github.com/${repository.owner}/${repository.name}` - } - } - - private getConfig(repository: GitHubRepository): IProjectConfig | null { - const yml = repository.configYml || repository.configYaml - if (!yml || !yml.text || yml.text.length == 0) { - return null - } - const parser = new ProjectConfigParser() - return parser.parse(yml.text) - } - - private getVersions(repository: GitHubRepository): Version[] { - const branchVersions = repository.branches.map(branch => { - const isDefaultRef = branch.name == repository.defaultBranchRef.name - return this.mapVersionFromRef({ - ownerName: repository.owner, - repositoryName: repository.name, - ref: branch, - isDefaultRef - }) - }) - const tagVersions = repository.tags.map(tag => { - return this.mapVersionFromRef({ - ownerName: repository.owner, - repositoryName: repository.name, - ref: tag - }) + this.projectMapper = new ProjectMapper({ + repositoryNameSuffix: config.repositoryNameSuffix, + urlBuilders: gitHubURLBuilders, + encryptionService: config.encryptionService, + remoteConfigEncoder: config.remoteConfigEncoder }) - return branchVersions.concat(tagVersions) } - - private mapVersionFromRef({ - ownerName, - repositoryName, - ref, - isDefaultRef - }: { - ownerName: string - repositoryName: string - ref: GitHubRepositoryRef - isDefaultRef?: boolean - }): Version { - const specifications = ref.files.filter(file => { - return this.isOpenAPISpecification(file.name) - }).map(file => { - return { - id: file.name, - name: file.name, - url: this.getGitHubBlobURL({ - ownerName, - repositoryName, - path: file.name, - ref: ref.id - }), - editURL: `https://github.com/${ownerName}/${repositoryName}/edit/${ref.name}/${file.name}`, - isDefault: false // initial value - } - }).sort((a, b) => a.name.localeCompare(b.name)) - return { - id: ref.name, - name: ref.name, - specifications: specifications, - url: `https://github.com/${ownerName}/${repositoryName}/tree/${ref.name}`, - isDefault: isDefaultRef || false - } - } - - private isOpenAPISpecification(filename: string) { - return !filename.startsWith(".") && ( - filename.endsWith(".yml") || filename.endsWith(".yaml") - ) - } - - private getGitHubBlobURL({ - ownerName, - repositoryName, - path, - ref - }: { - ownerName: string - repositoryName: string - path: string - ref: string - }): string { - return `/api/blob/${ownerName}/${repositoryName}/${path}?ref=${ref}` - } - - private addRemoteVersions( - existingVersions: Version[], - remoteVersions: ProjectConfigRemoteVersion[] - ): Version[] { - const versions = [...existingVersions] - const versionIds = versions.map(e => e.id) - for (const remoteVersion of remoteVersions) { - const baseVersionId = this.makeURLSafeID( - (remoteVersion.id || remoteVersion.name).toLowerCase() - ) - // If the version ID exists then we suffix it with a number to ensure unique versions. - // E.g. if "foo" already exists, we make it "foo1". - const existingVersionIdCount = versionIds.filter(e => e == baseVersionId).length - const versionId = baseVersionId + (existingVersionIdCount > 0 ? existingVersionIdCount : "") - const specifications = remoteVersion.specifications.map(e => { - const remoteConfig: RemoteConfig = { - url: e.url, - auth: this.tryDecryptAuth(e) - }; - const encodedRemoteConfig = this.remoteConfigEncoder.encode(remoteConfig); - - return { - id: this.makeURLSafeID((e.id || e.name).toLowerCase()), - name: e.name, - url: `/api/remotes/${encodedRemoteConfig}`, - isDefault: false // initial value - }; - }) - versions.push({ - id: versionId, - name: remoteVersion.name, - specifications, - isDefault: false - }) - versionIds.push(baseVersionId) - } - return versions - } - - private sortVersions(versions: Version[], defaultBranchName: string): Version[] { - const candidateDefaultBranches = [ - defaultBranchName, "main", "master", "develop", "development", "trunk" - ] - // Reverse them so the top-priority branches end up at the top of the list. - .reverse() - const copiedVersions = [...versions].sort((a, b) => { - return a.name.localeCompare(b.name) - }) - // Move the top-priority branches to the top of the list. - for (const candidateDefaultBranch of candidateDefaultBranches) { - const defaultBranchIndex = copiedVersions.findIndex(version => { - return version.name === candidateDefaultBranch - }) - if (defaultBranchIndex !== -1) { - const branchVersion = copiedVersions[defaultBranchIndex] - copiedVersions.splice(defaultBranchIndex, 1) - copiedVersions.splice(0, 0, branchVersion) - } - } - return copiedVersions - } - - private makeURLSafeID(str: string): string { - return str - .replace(/ /g, "-") - .replace(/[^A-Za-z0-9-]/g, "") - } - - private tryDecryptAuth(projectConfigRemoteSpec: ProjectConfigRemoteSpecification): { type: string, username: string, password: string } | undefined { - if (!projectConfigRemoteSpec.auth) { - return undefined - } - - try { - return { - type: projectConfigRemoteSpec.auth.type, - username: this.encryptionService.decrypt(projectConfigRemoteSpec.auth.encryptedUsername), - password: this.encryptionService.decrypt(projectConfigRemoteSpec.auth.encryptedPassword) - } - } catch (error) { - console.info(`Failed to decrypt remote specification auth for ${projectConfigRemoteSpec.name} (${projectConfigRemoteSpec.url}). Perhaps a different public key was used?:`, error); - return undefined - } - } - - private setDefaultSpecification(version: Version, defaultSpecificationName?: string): Version { - return { - ...version, - specifications: version.specifications.map(spec => ({ - ...spec, - isDefault: spec.name === defaultSpecificationName - })) - } + async getProjects(): Promise { + const repositories = await this.repositoryDataSource.getRepositories() + return this.projectMapper.mapRepositories(repositories) } } diff --git a/src/features/projects/data/index.ts b/src/features/projects/data/index.ts index 748a41b9..f548c169 100644 --- a/src/features/projects/data/index.ts +++ b/src/features/projects/data/index.ts @@ -3,3 +3,5 @@ export * from "./GitHubProjectDataSource" export { default as useProjectSelection } from "./useProjectSelection" export { default as GitHubLoginDataSource } from "./GitHubLoginDataSource" export { default as GitHubRepositoryDataSource } from "./GitHubRepositoryDataSource" +export { default as AzureDevOpsRepositoryDataSource } from "./AzureDevOpsRepositoryDataSource" +export { default as AzureDevOpsProjectDataSource } from "./AzureDevOpsProjectDataSource" diff --git a/src/features/projects/domain/IAzureDevOpsRepositoryDataSource.ts b/src/features/projects/domain/IAzureDevOpsRepositoryDataSource.ts new file mode 100644 index 00000000..122ff1cc --- /dev/null +++ b/src/features/projects/domain/IAzureDevOpsRepositoryDataSource.ts @@ -0,0 +1,29 @@ +export type AzureDevOpsRepositoryWithRefs = { + readonly name: string + readonly owner: string // organization + readonly defaultBranchRef: { + readonly id: string + readonly name: string + } + readonly webUrl: string + readonly configYml?: { + readonly text: string + } + readonly configYaml?: { + readonly text: string + } + readonly branches: AzureDevOpsRepositoryRef[] + readonly tags: AzureDevOpsRepositoryRef[] +} + +export type AzureDevOpsRepositoryRef = { + readonly id: string + readonly name: string + readonly files: { + readonly name: string + }[] +} + +export default interface IAzureDevOpsRepositoryDataSource { + getRepositories(): Promise +} diff --git a/src/features/projects/domain/ProjectMapper.ts b/src/features/projects/domain/ProjectMapper.ts new file mode 100644 index 00000000..25c3f8a9 --- /dev/null +++ b/src/features/projects/domain/ProjectMapper.ts @@ -0,0 +1,276 @@ +import { IEncryptionService } from "@/features/encrypt/EncryptionService" +import { + Project, + Version, + IProjectConfig, + ProjectConfigParser, + ProjectConfigRemoteVersion, + ProjectConfigRemoteSpecification +} from "." +import RemoteConfig from "./RemoteConfig" +import { IRemoteConfigEncoder } from "./RemoteConfigEncoder" + +type ConfigYml = { text: string } | null | undefined + +/** + * Common repository ref type + */ +export type RepositoryRef = { + readonly id?: string + readonly name: string + readonly files: { readonly name: string }[] +} + +/** + * Common repository type that both GitHub and Azure DevOps repositories satisfy. + * Provider-specific fields should be added via intersection types in the data sources. + */ +export type RepositoryWithRefs = { + readonly name: string + readonly owner: string + readonly defaultBranchRef: { + readonly id?: string + readonly name: string + } + readonly configYml?: { readonly text: string } + readonly configYaml?: { readonly text: string } + readonly branches: RepositoryRef[] + readonly tags: RepositoryRef[] +} + +/** + * URL builders for provider-specific URL generation. + * Generic parameter T allows providers to use extended repository types. + */ +export type URLBuilders = { + /** Returns the ref to use for image URLs (e.g., defaultBranchRef.id or defaultBranchRef.name) */ + getImageRef(repository: T): string + /** Returns the ref to use for blob URLs (e.g., ref.id or ref.name) */ + getBlobRef(ref: RepositoryRef): string + /** Returns the owner URL (e.g., https://github.com/owner) */ + getOwnerUrl(owner: string): string + /** Returns the project URL */ + getProjectUrl(repository: T): string + /** Returns the version URL */ + getVersionUrl(repository: T, ref: RepositoryRef): string + /** Returns the specification edit URL */ + getSpecEditUrl(repository: T, ref: RepositoryRef, fileName: string): string +} + +export interface IProjectMapper { + mapRepositories(repositories: T[]): Project[] +} + +export default class ProjectMapper implements IProjectMapper { + private readonly repositoryNameSuffix: string + private readonly urlBuilders: URLBuilders + private readonly encryptionService: IEncryptionService + private readonly remoteConfigEncoder: IRemoteConfigEncoder + + constructor(config: { + repositoryNameSuffix: string + urlBuilders: URLBuilders + encryptionService: IEncryptionService + remoteConfigEncoder: IRemoteConfigEncoder + }) { + this.repositoryNameSuffix = config.repositoryNameSuffix + this.urlBuilders = config.urlBuilders + this.encryptionService = config.encryptionService + this.remoteConfigEncoder = config.remoteConfigEncoder + } + + mapRepositories(repositories: T[]): Project[] { + return repositories + .map(repository => this.mapRepositoryToProject(repository)) + .filter(project => project.versions.length > 0) + .sort((a, b) => a.name.localeCompare(b.name)) + } + + mapRepositoryToProject(repository: T): Project { + const config = this.parseConfig(repository.configYml, repository.configYaml) + let imageURL: string | undefined + if (config && config.image) { + imageURL = getBlobURL( + repository.owner, + repository.name, + config.image, + this.urlBuilders.getImageRef(repository) + ) + } + + const versions = this.sortVersions( + this.addRemoteVersions( + this.getVersions(repository), + config?.remoteVersions || [] + ), + repository.defaultBranchRef.name + ) + .filter(version => version.specifications.length > 0) + .map(version => this.setDefaultSpecification(version, config?.defaultSpecificationName)) + + const defaultName = repository.name.replace(new RegExp(this.repositoryNameSuffix + "$"), "") + return { + id: `${repository.owner}-${defaultName}`, + owner: repository.owner, + name: defaultName, + displayName: config?.name || defaultName, + versions, + imageURL: imageURL, + ownerUrl: this.urlBuilders.getOwnerUrl(repository.owner), + url: this.urlBuilders.getProjectUrl(repository) + } + } + + private parseConfig(configYml: ConfigYml, configYaml: ConfigYml): IProjectConfig | null { + const yml = configYml || configYaml + if (!yml || !yml.text || yml.text.length == 0) { + return null + } + const parser = new ProjectConfigParser() + return parser.parse(yml.text) + } + + private getVersions(repository: T): Version[] { + const branchVersions = repository.branches.map(branch => { + const isDefaultRef = branch.name === repository.defaultBranchRef.name + return this.mapVersionFromRef(repository, branch, isDefaultRef) + }) + const tagVersions = repository.tags.map(tag => { + return this.mapVersionFromRef(repository, tag, false) + }) + return branchVersions.concat(tagVersions) + } + + private mapVersionFromRef( + repository: T, + ref: RepositoryRef, + isDefaultRef: boolean + ): Version { + const specifications = ref.files + .filter(file => isOpenAPISpecification(file.name)) + .map(file => ({ + id: file.name, + name: file.name, + url: getBlobURL(repository.owner, repository.name, file.name, this.urlBuilders.getBlobRef(ref)), + editURL: this.urlBuilders.getSpecEditUrl(repository, ref, file.name), + isDefault: false + })) + .sort((a, b) => a.name.localeCompare(b.name)) + + return { + id: ref.name, + name: ref.name, + specifications: specifications, + url: this.urlBuilders.getVersionUrl(repository, ref), + isDefault: isDefaultRef + } + } + + private sortVersions(versions: Version[], defaultBranchName: string): Version[] { + const candidateDefaultBranches = [ + defaultBranchName, "main", "master", "develop", "development", "trunk" + ] + // Reverse them so the top-priority branches end up at the top of the list. + .reverse() + const copiedVersions = [...versions].sort((a, b) => { + return a.name.localeCompare(b.name) + }) + // Move the top-priority branches to the top of the list. + for (const candidateDefaultBranch of candidateDefaultBranches) { + const defaultBranchIndex = copiedVersions.findIndex(version => { + return version.name === candidateDefaultBranch + }) + if (defaultBranchIndex !== -1) { + const branchVersion = copiedVersions[defaultBranchIndex] + copiedVersions.splice(defaultBranchIndex, 1) + copiedVersions.splice(0, 0, branchVersion) + } + } + return copiedVersions + } + + private setDefaultSpecification(version: Version, defaultSpecificationName?: string): Version { + return { + ...version, + specifications: version.specifications.map(spec => ({ + ...spec, + isDefault: spec.name === defaultSpecificationName + })) + } + } + + private addRemoteVersions( + existingVersions: Version[], + remoteVersions: ProjectConfigRemoteVersion[] + ): Version[] { + const versions = [...existingVersions] + const versionIds = versions.map(e => e.id) + for (const remoteVersion of remoteVersions) { + const baseVersionId = makeURLSafeID( + (remoteVersion.id || remoteVersion.name).toLowerCase() + ) + // If the version ID exists then we suffix it with a number to ensure unique versions. + // E.g. if "foo" already exists, we make it "foo1". + const existingVersionIdCount = versionIds.filter(e => e == baseVersionId).length + const versionId = baseVersionId + (existingVersionIdCount > 0 ? existingVersionIdCount : "") + const specifications = remoteVersion.specifications.map(e => { + const remoteConfig: RemoteConfig = { + url: e.url, + auth: this.tryDecryptAuth(e) + } + + const encodedRemoteConfig = this.remoteConfigEncoder.encode(remoteConfig) + + return { + id: makeURLSafeID((e.id || e.name).toLowerCase()), + name: e.name, + url: `/api/remotes/${encodedRemoteConfig}`, + isDefault: false + } + }) + versions.push({ + id: versionId, + name: remoteVersion.name, + specifications, + isDefault: false + }) + versionIds.push(baseVersionId) + } + return versions + } + + private tryDecryptAuth( + projectConfigRemoteSpec: ProjectConfigRemoteSpecification + ): { type: string, username: string, password: string } | undefined { + if (!projectConfigRemoteSpec.auth) { + return undefined + } + + try { + return { + type: projectConfigRemoteSpec.auth.type, + username: this.encryptionService.decrypt(projectConfigRemoteSpec.auth.encryptedUsername), + password: this.encryptionService.decrypt(projectConfigRemoteSpec.auth.encryptedPassword) + } + } catch (error) { + console.info(`Failed to decrypt remote specification auth for ${projectConfigRemoteSpec.name} (${projectConfigRemoteSpec.url}). Perhaps a different public key was used?:`, error) + return undefined + } + } +} + +function isOpenAPISpecification(filename: string): boolean { + return !filename.startsWith(".") && ( + filename.endsWith(".yml") || filename.endsWith(".yaml") + ) +} + +function makeURLSafeID(str: string): string { + return str + .replace(/ /g, "-") + .replace(/[^A-Za-z0-9-]/g, "") +} + +export function getBlobURL(owner: string, repository: string, path: string, ref: string): string { + return `/api/blob/${owner}/${repository}/${path}?ref=${ref}` +}