diff --git a/src/config.ts b/src/config.ts index d640264..14b9281 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,5 @@ import type { ForgejoPluginConfig } from "./types.js"; +import { FORGEJO_TOKEN_REF_FORMAT_MESSAGE, isPaperclipSecretRef } from "./secret-ref.js"; function optionalString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; @@ -39,6 +40,10 @@ export function validateConfig(raw: Record): { ok: boolean; err errors.push("forgejoBaseUrl, forgejoTokenRef, forgejoOwner, and forgejoRepo must all be configured together."); } + if (config.forgejoTokenRef && !isPaperclipSecretRef(config.forgejoTokenRef)) { + errors.push(FORGEJO_TOKEN_REF_FORMAT_MESSAGE); + } + if (!config.defaultCompanyId) { warnings.push("defaultCompanyId is not set; the reconciliation job cannot backfill unsynced issues across a company."); } diff --git a/src/forgejo-client.ts b/src/forgejo-client.ts index 49d9aa5..2b7b7d7 100644 --- a/src/forgejo-client.ts +++ b/src/forgejo-client.ts @@ -1,5 +1,6 @@ import type { PluginContext } from "@paperclipai/plugin-sdk"; import { readConfig } from "./config.js"; +import { assertForgejoTokenRef } from "./secret-ref.js"; import type { ForgejoIssuePayload, ForgejoIssueRecord } from "./types.js"; function joinUrl(baseUrl: string, path: string): string { @@ -15,7 +16,17 @@ export async function createForgejoIssue( throw new Error("Forgejo outbound sync is not fully configured."); } - const token = await ctx.secrets.resolve(config.forgejoTokenRef); + const secretRef = assertForgejoTokenRef(config.forgejoTokenRef); + let token: string; + try { + token = await ctx.secrets.resolve(secretRef); + } catch (error) { + throw new Error( + "Failed to resolve forgejoTokenRef. Use a Paperclip company secret UUID and re-save the field through the secret picker if it currently contains a visible secret name.", + { cause: error } + ); + } + const response = await ctx.http.fetch( joinUrl(config.forgejoBaseUrl, `/api/v1/repos/${encodeURIComponent(config.forgejoOwner)}/${encodeURIComponent(config.forgejoRepo)}/issues`), { diff --git a/src/manifest.ts b/src/manifest.ts index 67ed3b2..56b0001 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -38,7 +38,7 @@ const manifest: PaperclipPluginManifestV1 = { type: "string", title: "Forgejo Token Secret Ref", format: "secret-ref", - description: "Secret reference for outbound Forgejo API authentication." + description: "Paperclip company secret reference UUID for outbound Forgejo API authentication." }, forgejoOwner: { type: "string", diff --git a/src/secret-ref.ts b/src/secret-ref.ts new file mode 100644 index 0000000..4257908 --- /dev/null +++ b/src/secret-ref.ts @@ -0,0 +1,17 @@ +const PAPERCLIP_SECRET_REF_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export const FORGEJO_TOKEN_REF_FORMAT_MESSAGE = + "forgejoTokenRef must be a Paperclip secret reference UUID. Re-save the field through the Paperclip secret picker or paste the secret UUID, not the visible secret name."; + +export function isPaperclipSecretRef(value: string): boolean { + return PAPERCLIP_SECRET_REF_RE.test(value.trim()); +} + +export function assertForgejoTokenRef(secretRef: string | undefined): string { + if (!secretRef || !isPaperclipSecretRef(secretRef)) { + throw new Error(FORGEJO_TOKEN_REF_FORMAT_MESSAGE); + } + + return secretRef.trim(); +} diff --git a/tests/forgejo-client.spec.ts b/tests/forgejo-client.spec.ts new file mode 100644 index 0000000..a851b02 --- /dev/null +++ b/tests/forgejo-client.spec.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from "vitest"; +import { validateConfig } from "../src/config.js"; +import { createForgejoIssue } from "../src/forgejo-client.js"; + +describe("forgejo client", () => { + it("resolves a Paperclip-managed secret ref and sends the Forgejo token", async () => { + const secretRef = "11111111-1111-4111-8111-111111111111"; + const resolveSecret = vi.fn(async () => "forgejo-token"); + const fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({ + id: 42, + number: 7, + html_url: "https://forgejo.example/acme/repo/issues/7", + url: "https://forgejo.example/api/v1/repos/acme/repo/issues/7" + }) + })); + + const result = await createForgejoIssue( + { + config: { + get: async () => ({ + forgejoBaseUrl: "https://forgejo.example", + forgejoTokenRef: secretRef, + forgejoOwner: "acme", + forgejoRepo: "repo" + }) + }, + secrets: { resolve: resolveSecret }, + http: { fetch } + } as never, + { + title: "Example issue", + body: "body" + } + ); + + expect(resolveSecret).toHaveBeenCalledWith(secretRef); + expect(fetch).toHaveBeenCalledWith( + "https://forgejo.example/api/v1/repos/acme/repo/issues", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + authorization: "token forgejo-token" + }) + }) + ); + expect(result).toEqual({ + id: 42, + number: 7, + url: "https://forgejo.example/acme/repo/issues/7", + apiUrl: "https://forgejo.example/api/v1/repos/acme/repo/issues/7" + }); + }); + + it("rejects visible secret names with an actionable, sanitized error", async () => { + const invalidRef = "forgejo-ake-paperclip-forgejo-issue-plugin-issues"; + const resolveSecret = vi.fn(); + const fetch = vi.fn(); + + expect(validateConfig({ + forgejoBaseUrl: "https://forgejo.example", + forgejoTokenRef: invalidRef, + forgejoOwner: "acme", + forgejoRepo: "repo" + })).toEqual({ + ok: false, + errors: [ + "forgejoTokenRef must be a Paperclip secret reference UUID. Re-save the field through the Paperclip secret picker or paste the secret UUID, not the visible secret name." + ], + warnings: [ + "defaultCompanyId is not set; the reconciliation job cannot backfill unsynced issues across a company.", + "syncIssueLabel is not set; the plugin will use the default label \"forgejo-sync\"." + ] + }); + + await expect(createForgejoIssue( + { + config: { + get: async () => ({ + forgejoBaseUrl: "https://forgejo.example", + forgejoTokenRef: invalidRef, + forgejoOwner: "acme", + forgejoRepo: "repo" + }) + }, + secrets: { resolve: resolveSecret }, + http: { fetch } + } as never, + { + title: "Example issue", + body: "body" + } + )).rejects.toThrow( + "forgejoTokenRef must be a Paperclip secret reference UUID. Re-save the field through the Paperclip secret picker or paste the secret UUID, not the visible secret name." + ); + + expect(resolveSecret).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); +});