diff --git a/README.md b/README.md index 8096ea6..7a90edd 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Everything is built on [Hono](https://hono.dev), making it lightweight and fast. ## What it does -The worker exposes three main services: +The worker exposes four main services: - **Versions Service** (`/versions/v1`) The source of truth for FOSSBilling updates. It fetches release data from GitHub, caches it for performance, and helps instances decide if they need to update. @@ -19,6 +19,10 @@ The worker exposes three main services: generated HTTPS API client; it must not bind or migrate `DB_EXTENSIONS`. See [`src/services/extensions/v2/README.md`](src/services/extensions/v2/README.md). +- **Previews** (`/previews/v1`) + Resolves FOSSBilling preview builds — the current main preview and per-PR/per-commit builds produced by FOSSBilling/FOSSBilling's GitHub Actions workflows. Read-only; GitHub Actions and R2 are the sources of truth, not this service. + See [`src/services/previews/v1/README.md`](src/services/previews/v1/README.md). + ## Architecture We've structured the app to separate the core logic from the specific runtime environment (Cloudflare, Node, etc.). @@ -41,8 +45,9 @@ Each service documents its own endpoints and behaviour: | Central Alerts | `/central-alerts/v1` | [`src/services/central-alerts/v1/README.md`](src/services/central-alerts/v1/README.md) | | Stats | `/stats/v1` | [`src/services/stats/v1/README.md`](src/services/stats/v1/README.md) | | Extensions | `/extensions/v1`, `/extensions/v2` | [`src/services/extensions/v2/README.md`](src/services/extensions/v2/README.md) | +| Previews | `/previews/v1` | [`src/services/previews/v1/README.md`](src/services/previews/v1/README.md) | -Extensions v2 also publishes a live OpenAPI document at `/extensions/v2/openapi.json` and a reference UI at `/extensions/v2/docs`. +Extensions v2 and Previews v1 also publish a live OpenAPI document (`/extensions/v2/openapi.json`, `/previews/v1/openapi.json`) and a reference UI (`/extensions/v2/docs`, `/previews/v1/docs`). ## Configuration @@ -57,6 +62,7 @@ We use [Cloudflare D1](https://developers.cloudflare.com/d1/) and [KV](https://d Migrations are owned by extensions v2 and applied only from this repository — see [its README](src/services/extensions/v2/README.md#database) for the migration and adoption procedure. - **KV Namespace** (`CACHE_KV`): Caches GitHub API responses so we don't hit rate limits. - **KV Namespace** (`AUTH_KV`): Stores the `UPDATE_TOKEN` value for `/versions/v1/update`. +- **R2 Bucket** (`DOWNLOAD_BUCKET`): Backs `/previews/v1/main` — see [`src/services/previews/v1/README.md`](src/services/previews/v1/README.md) and the comment in `wrangler.jsonc` for which bucket this points at and why. ### Environment Variables diff --git a/package-lock.json b/package-lock.json index 03f739b..87beb0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1477,9 +1477,9 @@ } }, "node_modules/@hono/zod-openapi": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@hono/zod-openapi/-/zod-openapi-1.5.2.tgz", - "integrity": "sha512-FPlspM6+qObGoMfux+0SSE0of0tGO+BVo3havhKzZMxRyz/ql89kuCd/7POd70aW3T4kz/aVL6gNZ6sVd0ItUQ==", + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@hono/zod-openapi/-/zod-openapi-1.5.3.tgz", + "integrity": "sha512-hG+2wh72WK4z59Cn6UQUb5Ohx9HINzCbZ6oEwqvtAkOe9b2Oc5N9/jPjUzhF49lgSo73s2EoHyJx+gGEmpkv5Q==", "license": "MIT", "dependencies": { "@asteasolutions/zod-to-openapi": "^8.5.0", diff --git a/src/app/index.ts b/src/app/index.ts index 4716414..fd5641f 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -4,6 +4,7 @@ import { HTTPException } from "hono/http-exception"; import centralAlertsV1 from "../services/central-alerts/v1"; import extensionsV1 from "../services/extensions/v1"; import extensionsV2 from "../services/extensions/v2"; +import previewsV1 from "../services/previews/v1"; import versionsV1 from "../services/versions/v1"; import statsV1 from "../services/stats/v1"; import { platformMiddleware } from "../lib/middleware"; @@ -23,6 +24,7 @@ app.use("*", async (c, next) => { app.route("/central-alerts/v1", centralAlertsV1); app.route("/extensions/v1", extensionsV1); app.route("/extensions/v2", extensionsV2); +app.route("/previews/v1", previewsV1); app.route("/versions/v1", versionsV1); app.route("/stats/v1", statsV1); diff --git a/src/services/previews/v1/README.md b/src/services/previews/v1/README.md new file mode 100644 index 0000000..5cbb4d1 --- /dev/null +++ b/src/services/previews/v1/README.md @@ -0,0 +1,87 @@ +# Previews Service + +**Base Path:** `/previews/v1` + +Read-only lookup of FOSSBilling preview builds. GitHub Actions is the source +of truth for PR/commit previews - `FOSSBilling/FOSSBilling`'s `ci.yml` +uploads one artifact per commit, named `FOSSBilling-preview-{short_sha}.zip` +(`archive: false`, so the zip itself is the artifact - no extra wrapping), +for every PR build, non-main branch push, and main push. This service +resolves by querying that exact name rather than listing every preview +artifact and filtering. The `main` preview's `download_url`/`digest` are +answered from R2 instead, sourced from `digest`/`commit-sha` custom object +metadata the same CI job sets on the R2 upload - kept separate from the +GitHub-artifact path because the R2 zip and the GitHub artifact zip for a +given commit are two independently-built files (a `cp` of the same bytes, +in the current CI job, but not guaranteed to stay that way), so whichever +one is reported as the digest has to match the bytes `main` actually +serves. `GET /main` does still cross-reference that commit's GitHub Actions +artifact for enrichment - see Resource Model below - but only as +best-effort, never as a dependency. + +There is no publish/write endpoint: nothing pushes data into this service, +it only resolves and redirects. + +## Endpoints + +Endpoints are not listed here. The service publishes its own contract: + +- **OpenAPI document:** `GET /previews/v1/openapi.json` +- **Reference UI:** `GET /previews/v1/docs` + +## Resource Model + +- `GET /main` and `GET /pr/{number}` are **pointers** - they always resolve + to whatever is current. +- `GET /commit/{sha}` is a **fixed point** - one commit, one build, + permanently addressable (until GitHub's artifact retention expires it). +- `pr/{number}`'s handler resolves the PR to its head SHA + (`GET /pulls/{number}`) and delegates to the same resolver `commit/{sha}` + uses - one GitHub-facing code path, not two. +- `download_url` differs in kind depending on the resource. `main`'s is the + permanent public `download.fossbilling.org` URL, embedded directly, since + it never expires. `pr`/`commit`'s is self-referential - it points back at + their own `/download` sub-route rather than GitHub's actual signed URL, + because that URL expires in ~60s and can't be baked into a response with + any longer cache lifetime; `/download` resolves the real one live on + each hit. +- `source` on `/main` stays `"r2"` regardless of whether the GitHub Actions + enrichment below resolves - it describes where `download_url`/`digest` + come from, which never changes. +- `main`'s `run_id`/`artifact_id`/`created_at`/`expires_at` are enrichment, + resolved from that commit's GitHub Actions artifact (the same lookup + `commit/{sha}` uses) purely for shape parity with the PR/commit response, + so a client reading either doesn't have to special-case field + availability. It's best-effort and never load-bearing: a miss (no known + artifact yet, the artifact aged out of GitHub's 14-day retention, GitHub + unavailable) just leaves those four fields `null` - it's never the reason + a request to `/main` fails, since `download_url`/`digest` are R2-sourced + and don't depend on it. + +## Notes + +- Responses are cached in `CACHE_KV`, only for successful lookups - a + not-yet-built PR or a transient GitHub error always re-resolves on the + next request. `GET /pr/{number}` (`preview:pr:{number}`, also used by + `/pr/{number}/download` to avoid re-resolving what the metadata route + already cached) and `GET /main` (`preview:main`) use the 60s default, + matching how often a moving pointer can realistically change. + `GET /commit/{sha}` (`preview:commit:{sha}`, likewise shared with + `/commit/{sha}/download`) uses 3600s instead - a commit's build never + changes once it exists, so there's no correctness reason to re-check it + every minute. That 3600s is capped at the artifact's own remaining + GitHub retention (minus a small safety margin for the cache write + itself), so a lookup resolved near the end of an artifact's 14-day life + is never cached longer than the artifact actually exists. Within roughly + the final minute of that life the capped value falls under KV's 60s + minimum TTL, so those requests (and any more before the artifact expires + or a request refreshes it) are just served live instead of cached - a + short burst of extra GitHub calls right at the end, never stale data. +- `GET /pr/{number}/download` and `GET /commit/{sha}/download` always + resolve GitHub's signed redirect URL live, never cached - it expires in + about a minute, and Cloudflare KV's 60s minimum TTL leaves no safe margin + to cache it without risking handing out an already-expired URL. +- `GITHUB_TOKEN` is required for GitHub API access (shared with + `versions/v1`). +- `DOWNLOAD_BUCKET` (R2 binding) backs `/main` - see `wrangler.jsonc` for the + bucket this points at and why. diff --git a/src/services/previews/v1/cache.ts b/src/services/previews/v1/cache.ts new file mode 100644 index 0000000..fb03e9d --- /dev/null +++ b/src/services/previews/v1/cache.ts @@ -0,0 +1,53 @@ +import { GithubLookupResult } from "./github/artifacts"; + +// Default for anything that moves (main, pr/{number}) - previews churn +// often (a new commit on a PR supersedes the last build within minutes), +// so a short TTL keeps CACHE_KV useful without serving meaningfully stale +// data - matches download-worker's existing choice for the same trade-off. +// Callers addressing something immutable (a fixed commit/artifact) pass a +// longer ttlSeconds explicitly - see routes/commit.ts and routes/respond.ts. +export const DEFAULT_CACHE_TTL_SECONDS = 60; + +// Cloudflare KV's own floor - a shorter expirationTtl is a 400 at the API +// level, not just an app-level policy choice. +const KV_MIN_TTL_SECONDS = 60; + +// Only "found" results are cached. "not_found"/"unavailable" always +// re-resolve, so a transient GitHub hiccup or a not-yet-built PR doesn't +// get stuck negative for the TTL window. +// +// ttlSeconds may be a function of the resolved data instead of a fixed +// number - see routes/commit.ts, which caps the cache lifetime at the +// artifact's own remaining GitHub retention so a lookup resolved just +// before expiry doesn't outlive it and keep serving a 200 after GitHub +// itself has started 404ing. If the computed TTL is under KV's 60s floor, +// the result is returned but not cached at all - better to re-resolve +// live for the rest of that final minute than to either violate the floor +// or round up and cache something past its real expiry. +export async function cachedLookup( + kv: KVNamespace, + key: string, + resolve: () => Promise>, + ttlSeconds: number | ((data: T) => number) = DEFAULT_CACHE_TTL_SECONDS +): Promise> { + const cached = await kv.get(key); + if (cached !== null) { + try { + return { status: "found", data: JSON.parse(cached) as T }; + } catch { + // Corrupt cache entry - fall through to a fresh resolve. + } + } + + const result = await resolve(); + if (result.status === "found") { + const ttl = + typeof ttlSeconds === "function" ? ttlSeconds(result.data) : ttlSeconds; + if (ttl >= KV_MIN_TTL_SECONDS) { + await kv.put(key, JSON.stringify(result.data), { + expirationTtl: ttl + }); + } + } + return result; +} diff --git a/src/services/previews/v1/github/artifacts.ts b/src/services/previews/v1/github/artifacts.ts new file mode 100644 index 0000000..8e73f03 --- /dev/null +++ b/src/services/previews/v1/github/artifacts.ts @@ -0,0 +1,266 @@ +import { request as ghRequest } from "@octokit/request"; +import { + classifyGitHubError, + GitHubError, + NotFoundError +} from "../../../../lib/github-errors"; +import { logWarn } from "../../../../lib/logger"; + +const REPO_OWNER = "FOSSBilling"; +const REPO_NAME = "FOSSBilling"; +const ARTIFACT_NAME_PREFIX = "FOSSBilling-preview-"; + +// FOSSBilling/FOSSBilling's ci.yml uploads one artifact per commit for +// every PR build, non-main branch push, and main push - named after the +// short form of $GITHUB_SHA at build time (archive: false, so the file's +// own basename becomes the artifact name). Expects an already-lowercased +// sha - see findPreviewArtifactByCommitSha, which is the only caller. +function artifactNameForSha(shaLower: string): string { + return `${ARTIFACT_NAME_PREFIX}${shaLower.slice(0, 7)}.zip`; +} + +export interface PreviewArtifact { + runId: number; + artifactId: number; + commitSha: string; + digest: string | null; + sizeBytes: number; + createdAt: string; + expiresAt: string; +} + +export type GithubLookupResult = + | { status: "found"; data: T } + | { status: "not_found" } + | { status: "unavailable"; error: GitHubError }; + +interface RawArtifact { + id: number; + name?: string; + size_in_bytes: number; + created_at: string | null; + expires_at: string | null; + expired: boolean; + digest: string | null; + workflow_run?: { + id: number; + head_sha: string; + } | null; +} + +interface ArtifactMatch { + artifact: RawArtifact; + runId: number; + headSha: string; +} + +function unavailable( + context: string, + error: unknown, + url: string +): GithubLookupResult { + const githubError = classifyGitHubError(error, url); + logWarn("previews", `${context} unavailable`, { + message: githubError.message, + httpStatus: githubError.httpStatus + }); + return { status: "unavailable", error: githubError }; +} + +async function listArtifacts( + githubToken: string, + name: string | undefined, + page: number = 1 +): Promise { + const result = await ghRequest( + "GET /repos/{owner}/{repo}/actions/artifacts", + { + owner: REPO_OWNER, + repo: REPO_NAME, + ...(name ? { name } : {}), + per_page: 100, + page, + headers: { Authorization: `Bearer ${githubToken}` } + } + ); + return result.data.artifacts as RawArtifact[]; +} + +// A circuit breaker, not a correctness bound. This is the fallback's +// only source of truth for fork PRs - imposing a real cutoff here would +// just trade the original false-not-found bug for a smaller version of +// itself, missing a genuine match that happens to sit past page N. Every +// GitHub Actions artifact expires after 14 days regardless of type, so a +// repo's total artifact count is inherently finite even for very active +// repos; this exists only to guarantee termination if the API ever +// doesn't behave as expected (e.g. never returns a short page), not +// because 5,000 artifacts is a realistic amount to actually page through. +const MAX_FALLBACK_PAGES = 50; + +// The fallback path (see findPreviewArtifactByCommitSha) can't filter +// server-side by name, so a repo with more than one page of live preview +// artifacts would silently miss a genuine match sitting on page 2+ with a +// single unpaginated call. Pages through until GitHub returns a page +// short of per_page - the real "no more results" signal - or a match is +// found, whichever happens first. +async function findInFallbackPages( + githubToken: string, + shaLower: string +): Promise { + for (let page = 1; page <= MAX_FALLBACK_PAGES; page++) { + const artifacts = await listArtifacts(githubToken, undefined, page); + const match = matchArtifact( + artifacts.filter((artifact) => + artifact.name?.startsWith(ARTIFACT_NAME_PREFIX) + ), + shaLower + ); + if (match) return match; + if (artifacts.length < 100) break; // last page + } + return null; +} + +// Newest non-expired artifact whose triggering run's real head commit +// matches shaLower. shaLower may be a short (7+ char) prefix, so this is +// a startsWith rather than an exact match. +function matchArtifact( + artifacts: RawArtifact[], + shaLower: string +): ArtifactMatch | null { + let match: ArtifactMatch | null = null; + + for (const artifact of artifacts) { + const workflowRun = artifact.workflow_run; + if (artifact.expired || !workflowRun) continue; + if (!workflowRun.head_sha.toLowerCase().startsWith(shaLower)) continue; + if ( + !match || + (artifact.created_at ?? "") > (match.artifact.created_at ?? "") + ) { + match = { + artifact, + runId: workflowRun.id, + headSha: workflowRun.head_sha + }; + } + } + + return match; +} + +function toPreviewArtifact(match: ArtifactMatch): PreviewArtifact { + return { + runId: match.runId, + artifactId: match.artifact.id, + commitSha: match.headSha, + digest: match.artifact.digest ?? null, + sizeBytes: match.artifact.size_in_bytes, + createdAt: match.artifact.created_at ?? "", + expiresAt: match.artifact.expires_at ?? "" + }; +} + +// Resolves the preview artifact for a commit. Tries the exact artifact +// name first (artifactNameForSha) - correct and cheap for push-triggered +// builds (main, branch pushes, and same-repo PRs, which take the push +// path since preview-build-pr is fork-only), where $GITHUB_SHA in CI is +// the actual pushed commit. +// +// Falls back to paging through every preview artifact (findInFallbackPages) +// and matching by the triggering run's real head_sha if that misses. This +// is what makes fork PRs resolve correctly: GitHub's pull_request event +// makes $GITHUB_SHA the ephemeral merge commit rather than the PR's real +// head commit (see +// https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request), +// so preview-build-pr names its artifact after a SHA this service never +// asks about - only the run's own head_sha metadata (populated by GitHub +// independently of what the job saw as $GITHUB_SHA) still says which +// commit it actually is. Can't filter this scan server-side by name (no +// exact name to filter by), so it has to page through results instead of +// trusting a single page holds the match. +export async function findPreviewArtifactByCommitSha( + githubToken: string, + sha: string +): Promise> { + const shaLower = sha.toLowerCase(); + const url = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/actions/artifacts`; + try { + const exact = await listArtifacts( + githubToken, + artifactNameForSha(shaLower) + ); + let match = matchArtifact(exact, shaLower); + + if (!match) { + match = await findInFallbackPages(githubToken, shaLower); + } + + if (!match) { + return { status: "not_found" }; + } + + return { status: "found", data: toPreviewArtifact(match) }; + } catch (error) { + return unavailable("Preview artifact lookup", error, url); + } +} + +export async function resolvePullRequestHeadSha( + githubToken: string, + prNumber: number +): Promise> { + const url = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/pulls/${prNumber}`; + try { + const result = await ghRequest( + "GET /repos/{owner}/{repo}/pulls/{pull_number}", + { + owner: REPO_OWNER, + repo: REPO_NAME, + pull_number: prNumber, + headers: { Authorization: `Bearer ${githubToken}` } + } + ); + return { status: "found", data: result.data.head.sha }; + } catch (error) { + const githubError = classifyGitHubError(error, url); + if (githubError instanceof NotFoundError) return { status: "not_found" }; + return unavailable("Pull request lookup", error, url); + } +} + +// Resolves an artifact's live, short-lived download URL. Mirrors +// download-worker/src/preview.ts's getArtifactDownloadUrl - GitHub answers +// with a 302 to a signed, temporary URL rather than the file itself. +export async function getArtifactDownloadUrl( + githubToken: string, + artifactId: number +): Promise> { + const url = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/actions/artifacts/${artifactId}/zip`; + try { + const result = await ghRequest( + "GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}/{archive_format}", + { + owner: REPO_OWNER, + repo: REPO_NAME, + artifact_id: artifactId, + archive_format: "zip", + request: { redirect: "manual" }, + headers: { Authorization: `Bearer ${githubToken}` } + } + ); + + if (result.status === 302 && result.headers.location) { + return { status: "found", data: result.headers.location }; + } + return unavailable( + "Artifact download redirect", + new Error(`Unexpected status ${result.status}`), + url + ); + } catch (error) { + const githubError = classifyGitHubError(error, url); + if (githubError instanceof NotFoundError) return { status: "not_found" }; + return unavailable("Artifact download redirect", error, url); + } +} diff --git a/src/services/previews/v1/index.ts b/src/services/previews/v1/index.ts new file mode 100644 index 0000000..5a4012d --- /dev/null +++ b/src/services/previews/v1/index.ts @@ -0,0 +1,57 @@ +import { OpenAPIHono } from "@hono/zod-openapi"; +import { Scalar } from "@scalar/hono-api-reference"; +import { cors } from "hono/cors"; +import { trimTrailingSlash } from "hono/trailing-slash"; +import { registerMainRoutes } from "./routes/main"; +import { registerPrRoutes } from "./routes/pr"; +import { registerCommitRoutes } from "./routes/commit"; + +const previewsV1 = new OpenAPIHono<{ Bindings: CloudflareBindings }>({ + defaultHook: (result, c) => { + if (!result.success) { + return c.json( + { + error: { + message: "Invalid request", + code: "VALIDATION_ERROR", + details: result.error.issues + } + }, + 422 + ); + } + } +}); + +previewsV1.use("/*", cors({ origin: "*" })); +previewsV1.use("/*", trimTrailingSlash()); + +registerMainRoutes(previewsV1); +registerPrRoutes(previewsV1); +registerCommitRoutes(previewsV1); + +previewsV1.doc31("/openapi.json", { + openapi: "3.1.0", + info: { + title: "FOSSBilling Previews API (v1)", + version: "1.0.0", + description: + "Read-only lookup of FOSSBilling preview builds - the current main preview and per-PR/per-commit builds produced by FOSSBilling/FOSSBilling's GitHub Actions workflows." + }, + servers: [{ url: "/previews/v1" }] +}); + +previewsV1.get( + "/docs", + Scalar({ + url: "/previews/v1/openapi.json", + pageTitle: "FOSSBilling Previews API (v1)", + agent: { disabled: true }, + documentDownloadType: "none", + hideClientButton: true, + hideModels: true, + telemetry: false + }) +); + +export default previewsV1; diff --git a/src/services/previews/v1/r2.ts b/src/services/previews/v1/r2.ts new file mode 100644 index 0000000..4da7ba1 --- /dev/null +++ b/src/services/previews/v1/r2.ts @@ -0,0 +1,32 @@ +// The object FOSSBilling/FOSSBilling's ci.yml `upload-preview` job syncs to +// R2 on every main push - the same path served publicly at +// https://download.fossbilling.org/FOSSBilling-preview.zip. +const MAIN_PREVIEW_KEY = "FOSSBilling-preview.zip"; +const MAIN_PREVIEW_DOWNLOAD_URL = + "https://download.fossbilling.org/FOSSBilling-preview.zip"; + +export interface MainPreviewObject { + commitSha: string | null; + digest: string | null; + sizeBytes: number; + lastModified: string; + downloadUrl: string; +} + +// `digest`/`commit-sha` are custom metadata FOSSBilling/FOSSBilling's +// ci.yml sets explicitly on the R2 upload (`digest` already carries the +// "sha256:" prefix - see that repo's ci.yml `upload-preview` job). +export async function getMainPreviewObject( + bucket: R2Bucket +): Promise { + const object = await bucket.head(MAIN_PREVIEW_KEY); + if (!object) return null; + + return { + commitSha: object.customMetadata?.["commit-sha"] ?? null, + digest: object.customMetadata?.digest ?? null, + sizeBytes: object.size, + lastModified: object.uploaded.toISOString(), + downloadUrl: MAIN_PREVIEW_DOWNLOAD_URL + }; +} diff --git a/src/services/previews/v1/resolve.ts b/src/services/previews/v1/resolve.ts new file mode 100644 index 0000000..1f74f56 --- /dev/null +++ b/src/services/previews/v1/resolve.ts @@ -0,0 +1,37 @@ +import { ArtifactPreview } from "./schemas/previews"; +import { + findPreviewArtifactByCommitSha, + GithubLookupResult +} from "./github/artifacts"; + +export type PreviewLookupResult = GithubLookupResult; + +// download_url always points at the canonical /commit/{full_sha}/download +// route, using the fully-resolved SHA rather than whatever prefix or PR +// number the caller looked it up by. A PR's head SHA moves as new commits +// land; a specific commit's build does not, so that's the one stable link +// to hand back regardless of which route resolved it. +export async function resolveArtifactPreview( + githubToken: string, + sha: string, + prNumber: number | null +): Promise { + const found = await findPreviewArtifactByCommitSha(githubToken, sha); + if (found.status !== "found") return found; + + const { data } = found; + const preview: ArtifactPreview = { + commit_sha: data.commitSha, + short_sha: data.commitSha.slice(0, 7), + pr_number: prNumber, + run_id: data.runId, + artifact_id: data.artifactId, + digest: data.digest, + size_bytes: data.sizeBytes, + created_at: data.createdAt, + expires_at: data.expiresAt, + download_url: `/previews/v1/commit/${data.commitSha}/download`, + source: "actions_artifact" + }; + return { status: "found", data: preview }; +} diff --git a/src/services/previews/v1/routes/app.ts b/src/services/previews/v1/routes/app.ts new file mode 100644 index 0000000..0a77e42 --- /dev/null +++ b/src/services/previews/v1/routes/app.ts @@ -0,0 +1,5 @@ +import { OpenAPIHono } from "@hono/zod-openapi"; + +export type PreviewsV1App = OpenAPIHono<{ + Bindings: CloudflareBindings; +}>; diff --git a/src/services/previews/v1/routes/commit.ts b/src/services/previews/v1/routes/commit.ts new file mode 100644 index 0000000..eaf2667 --- /dev/null +++ b/src/services/previews/v1/routes/commit.ts @@ -0,0 +1,131 @@ +import { createRoute } from "@hono/zod-openapi"; +import { + ArtifactPreview, + ArtifactPreviewResponseSchema, + CommitShaParamSchema, + errorResponse +} from "../schemas/previews"; +import { resolveArtifactPreview } from "../resolve"; +import { cachedLookup } from "../cache"; +import { respondWithDownloadRedirect, respondWithLookup } from "./respond"; +import { PreviewsV1App } from "./app"; + +// A commit's build never changes once it exists, unlike main/pr's moving +// pointers - safe to cache far longer than the 60s default, well within +// GitHub's 14-day artifact retention. Cuts repeat-download GitHub calls by +// 60x for the same commit within an hour. +const COMMIT_CACHE_TTL_SECONDS = 3600; + +const cacheKeyForSha = (sha: string) => `preview:commit:${sha.toLowerCase()}`; + +// Subtracted from the computed TTL so the value we write already accounts +// for the round-trip between computing it here and cache.ts's kv.put() +// actually landing - without this, a lookup resolved with e.g. exactly +// 60s of real retention left could still get written with a TTL that +// technically outlives the artifact by however long that write took. +const WRITE_SAFETY_MARGIN_SECONDS = 5; + +// Caps the cache lifetime at the artifact's own remaining GitHub retention +// - a lookup resolved shortly before an artifact expires must not be +// cached for the full 3600s, or /commit/{sha} would keep serving a 200 +// with stale metadata for up to an hour after GitHub itself starts +// 404ing (which respondWithDownloadRedirect's live resolution already +// would). Within the final ~65s of an artifact's life this comes out +// under cache.ts's 60s KV floor, so that request (and any others until +// the artifact naturally falls out of GitHub's own list) is served live +// instead of cached - a short burst of extra GitHub calls right at the +// end of an artifact's life, never stale data. +function ttlForArtifact(artifact: ArtifactPreview): number { + const remainingSeconds = Math.floor( + (new Date(artifact.expires_at).getTime() - Date.now()) / 1000 + ); + // expires_at was empty/unparseable (toPreviewArtifact falls back to "" + // when GitHub's own value is null) - no real signal to cap against, so + // don't let a NaN here silently defeat caching on every request forever. + if (Number.isNaN(remainingSeconds)) { + return COMMIT_CACHE_TTL_SECONDS; + } + return Math.min( + COMMIT_CACHE_TTL_SECONDS, + remainingSeconds - WRITE_SAFETY_MARGIN_SECONDS + ); +} + +export function registerCommitRoutes(app: PreviewsV1App): void { + const commitRoute = createRoute({ + method: "get", + path: "/commit/{sha}", + tags: ["Previews"], + summary: "Preview build for a specific commit", + request: { params: CommitShaParamSchema }, + responses: { + 200: { + content: { + "application/json": { schema: ArtifactPreviewResponseSchema } + }, + description: "The preview build for that commit" + }, + 404: errorResponse("No preview artifact exists for that commit"), + 422: errorResponse("sha param failed validation"), + 429: errorResponse("GitHub API rate limit exceeded"), + 500: errorResponse("Unexpected error"), + 503: errorResponse("GitHub is temporarily unavailable") + } + }); + + app.openapi(commitRoute, async (c) => { + const { sha } = c.req.valid("param"); + const githubToken = c.env.GITHUB_TOKEN; + + const result = await cachedLookup( + c.env.CACHE_KV, + cacheKeyForSha(sha), + () => resolveArtifactPreview(githubToken, sha, null), + ttlForArtifact + ); + + return respondWithLookup( + c, + result, + `No preview artifact exists for commit ${sha}.` + ); + }); + + const commitDownloadRoute = createRoute({ + method: "get", + path: "/commit/{sha}/download", + tags: ["Previews"], + summary: "Download the preview build for a specific commit", + request: { params: CommitShaParamSchema }, + responses: { + 302: { description: "Redirect to GitHub's live artifact download URL" }, + 404: errorResponse("No preview artifact exists for that commit"), + 422: errorResponse("sha param failed validation"), + 429: errorResponse("GitHub API rate limit exceeded"), + 500: errorResponse("Unexpected error"), + 503: errorResponse("GitHub is temporarily unavailable") + } + }); + + app.openapi(commitDownloadRoute, async (c) => { + const { sha } = c.req.valid("param"); + const githubToken = c.env.GITHUB_TOKEN; + + // Shares the metadata route's cache entry for which artifact to + // download - only the signed URL itself (resolved inside + // respondWithDownloadRedirect, on its own short-lived cache) has to be + // re-checked often, since that's the part that actually expires. + const artifact = await cachedLookup( + c.env.CACHE_KV, + cacheKeyForSha(sha), + () => resolveArtifactPreview(githubToken, sha, null), + ttlForArtifact + ); + return respondWithDownloadRedirect( + c, + githubToken, + artifact, + `No preview artifact exists for commit ${sha}.` + ); + }); +} diff --git a/src/services/previews/v1/routes/errors.ts b/src/services/previews/v1/routes/errors.ts new file mode 100644 index 0000000..9e9c5a0 --- /dev/null +++ b/src/services/previews/v1/routes/errors.ts @@ -0,0 +1,22 @@ +import { GitHubError, RateLimitError } from "../../../../lib/github-errors"; + +// A GitHub outage/rate-limit is a 503 (retry later); anything else +// unexpected from classifyGitHubError is a 500. +export function statusFromGithubError(error: GitHubError): 429 | 503 | 500 { + if (error instanceof RateLimitError) return 429; + if (error.httpStatus !== undefined && error.httpStatus >= 500) return 503; + return 500; +} + +export function githubErrorBody(error: GitHubError, fallbackMessage: string) { + return { + error: { + message: error.message || fallbackMessage, + code: error.errorCode ?? "GITHUB_ERROR" + } + }; +} + +export function notFoundBody(message: string) { + return { error: { message, code: "NOT_FOUND" } }; +} diff --git a/src/services/previews/v1/routes/main.ts b/src/services/previews/v1/routes/main.ts new file mode 100644 index 0000000..292166a --- /dev/null +++ b/src/services/previews/v1/routes/main.ts @@ -0,0 +1,146 @@ +import { createRoute } from "@hono/zod-openapi"; +import { Context } from "hono"; +import { + MainPreview, + MainPreviewResponseSchema, + errorResponse +} from "../schemas/previews"; +import { getMainPreviewObject } from "../r2"; +import { findPreviewArtifactByCommitSha } from "../github/artifacts"; +import { notFoundBody } from "./errors"; +import { PreviewsV1App } from "./app"; + +const MAIN_CACHE_KEY = "preview:main"; +const MAIN_CACHE_TTL_SECONDS = 60; + +// Enrichment only - run_id/artifact_id/created_at/expires_at come from +// that commit's GitHub Actions artifact when resolvable. A miss for any +// reason (no commit_sha yet, artifact expired, GitHub unavailable) just +// leaves them null; it never fails or degrades the response, since +// download_url/digest below are R2-sourced and don't depend on this. +async function resolveArtifactFields( + githubToken: string, + commitSha: string | null +): Promise< + Pick +> { + const empty = { + run_id: null, + artifact_id: null, + created_at: null, + expires_at: null + }; + if (!commitSha) return empty; + + const artifact = await findPreviewArtifactByCommitSha(githubToken, commitSha); + if (artifact.status !== "found") return empty; + + return { + run_id: artifact.data.runId, + artifact_id: artifact.data.artifactId, + created_at: artifact.data.createdAt, + expires_at: artifact.data.expiresAt + }; +} + +// Shared by /main and /main/download - both need the same cache-then-R2 +// lookup, just to different ends (the full body vs. only download_url). +async function resolveMainPreview( + c: Context<{ Bindings: CloudflareBindings }> +): Promise { + const cached = await c.env.CACHE_KV.get(MAIN_CACHE_KEY); + if (cached) { + try { + return JSON.parse(cached) as MainPreview; + } catch { + // Corrupt cache entry - fall through to a fresh R2 lookup, matching + // cachedLookup()'s handling of the same situation. + } + } + + const object = await getMainPreviewObject(c.env.DOWNLOAD_BUCKET); + if (!object) return null; + + const artifactFields = await resolveArtifactFields( + c.env.GITHUB_TOKEN, + object.commitSha + ); + + const result: MainPreview = { + commit_sha: object.commitSha, + short_sha: object.commitSha?.slice(0, 7) ?? null, + pr_number: null, + ...artifactFields, + digest: object.digest, + size_bytes: object.sizeBytes, + last_modified: object.lastModified, + download_url: object.downloadUrl, + source: "r2" + }; + + await c.env.CACHE_KV.put(MAIN_CACHE_KEY, JSON.stringify(result), { + expirationTtl: MAIN_CACHE_TTL_SECONDS + }); + + return result; +} + +export function registerMainRoutes(app: PreviewsV1App): void { + const mainRoute = createRoute({ + method: "get", + path: "/main", + tags: ["Previews"], + summary: "Current main preview", + responses: { + 200: { + content: { + "application/json": { schema: MainPreviewResponseSchema } + }, + description: "The current main preview build" + }, + 404: errorResponse("No main preview has been published yet"), + 500: errorResponse("R2 lookup failed") + } + }); + + app.openapi(mainRoute, async (c) => { + const result = await resolveMainPreview(c); + if (!result) { + return c.json( + notFoundBody("No main preview has been published yet"), + 404 + ); + } + return c.json({ result }, 200); + }); + + // Unlike /pr/{number}/download and /commit/{sha}/download, main's + // download_url is a fixed, permanent path (download.fossbilling.org) + // rather than a live, short-lived signed URL - so this is a plain + // redirect once existence is confirmed, not a fresh resolution on every + // hit. Exists for uniform addressing: every resource under /previews/v1 + // has a /download sub-route, so callers never need to special-case main + // to reach a download link instead of reading it out of the JSON body. + const mainDownloadRoute = createRoute({ + method: "get", + path: "/main/download", + tags: ["Previews"], + summary: "Download the current main preview", + responses: { + 302: { description: "Redirect to the main preview download URL" }, + 404: errorResponse("No main preview has been published yet"), + 500: errorResponse("R2 lookup failed") + } + }); + + app.openapi(mainDownloadRoute, async (c) => { + const result = await resolveMainPreview(c); + if (!result) { + return c.json( + notFoundBody("No main preview has been published yet"), + 404 + ); + } + return c.redirect(result.download_url, 302); + }); +} diff --git a/src/services/previews/v1/routes/pr.ts b/src/services/previews/v1/routes/pr.ts new file mode 100644 index 0000000..af9ee04 --- /dev/null +++ b/src/services/previews/v1/routes/pr.ts @@ -0,0 +1,99 @@ +import { createRoute } from "@hono/zod-openapi"; +import { + ArtifactPreviewResponseSchema, + errorResponse, + PrNumberParamSchema +} from "../schemas/previews"; +import { resolvePullRequestHeadSha } from "../github/artifacts"; +import { PreviewLookupResult, resolveArtifactPreview } from "../resolve"; +import { cachedLookup } from "../cache"; +import { respondWithDownloadRedirect, respondWithLookup } from "./respond"; +import { PreviewsV1App } from "./app"; + +// Resolves a PR number to its artifact preview by first finding the head +// SHA, then delegating to the same commit-based resolver /commit/{sha} +// uses - one GitHub-facing code path handles both routes. +async function resolvePrPreview( + githubToken: string, + prNumber: number +): Promise { + const head = await resolvePullRequestHeadSha(githubToken, prNumber); + if (head.status !== "found") return head; + return resolveArtifactPreview(githubToken, head.data, prNumber); +} + +const notFoundMessage = (prNumber: number) => + `No pull request #${prNumber} was found, or it has no preview build yet.`; + +export function registerPrRoutes(app: PreviewsV1App): void { + const prRoute = createRoute({ + method: "get", + path: "/pr/{number}", + tags: ["Previews"], + summary: "Current preview build for a pull request", + request: { params: PrNumberParamSchema }, + responses: { + 200: { + content: { + "application/json": { schema: ArtifactPreviewResponseSchema } + }, + description: "The current preview build for that pull request" + }, + 404: errorResponse("No such pull request, or it has no preview build"), + 422: errorResponse("number param failed validation"), + 429: errorResponse("GitHub API rate limit exceeded"), + 500: errorResponse("Unexpected error"), + 503: errorResponse("GitHub is temporarily unavailable") + } + }); + + app.openapi(prRoute, async (c) => { + const { number } = c.req.valid("param"); + const githubToken = c.env.GITHUB_TOKEN; + + const result = await cachedLookup( + c.env.CACHE_KV, + `preview:pr:${number}`, + () => resolvePrPreview(githubToken, number) + ); + + return respondWithLookup(c, result, notFoundMessage(number)); + }); + + const prDownloadRoute = createRoute({ + method: "get", + path: "/pr/{number}/download", + tags: ["Previews"], + summary: "Download the current preview build for a pull request", + request: { params: PrNumberParamSchema }, + responses: { + 302: { description: "Redirect to GitHub's live artifact download URL" }, + 404: errorResponse("No such pull request, or it has no preview build"), + 422: errorResponse("number param failed validation"), + 429: errorResponse("GitHub API rate limit exceeded"), + 500: errorResponse("Unexpected error"), + 503: errorResponse("GitHub is temporarily unavailable") + } + }); + + app.openapi(prDownloadRoute, async (c) => { + const { number } = c.req.valid("param"); + const githubToken = c.env.GITHUB_TOKEN; + + // Shares the metadata route's cache entry - see the equivalent comment + // in routes/commit.ts. Without this, every download hit would cost 3 + // GitHub API calls (PR->SHA, SHA->artifact, then the redirect) instead + // of the 1 that's actually unavoidable. + const artifact = await cachedLookup( + c.env.CACHE_KV, + `preview:pr:${number}`, + () => resolvePrPreview(githubToken, number) + ); + return respondWithDownloadRedirect( + c, + githubToken, + artifact, + notFoundMessage(number) + ); + }); +} diff --git a/src/services/previews/v1/routes/respond.ts b/src/services/previews/v1/routes/respond.ts new file mode 100644 index 0000000..e1c6c0a --- /dev/null +++ b/src/services/previews/v1/routes/respond.ts @@ -0,0 +1,65 @@ +import { Context } from "hono"; +import { getArtifactDownloadUrl } from "../github/artifacts"; +import { PreviewLookupResult } from "../resolve"; +import { githubErrorBody, notFoundBody, statusFromGithubError } from "./errors"; + +// Shared by /commit/{sha} and /pr/{number}: both resolve to a +// PreviewLookupResult and only differ in their not-found message. +export function respondWithLookup( + c: Context, + result: PreviewLookupResult, + notFoundMessage: string +) { + if (result.status === "found") { + return c.json({ result: result.data }, 200); + } + if (result.status === "not_found") { + return c.json(notFoundBody(notFoundMessage), 404); + } + return c.json( + githubErrorBody(result.error, "Failed to look up the preview artifact"), + statusFromGithubError(result.error) + ); +} + +// Shared by /commit/{sha}/download and /pr/{number}/download. Always +// resolved live, never cached - GitHub's signed URL expires in ~60s, and +// KV enforces a hard 60s minimum TTL, so there's no safe margin available +// to cache it without risking handing out an already-expired URL. See +// preview:redirect caching's revert in git history for why that was tried +// and abandoned. +export async function respondWithDownloadRedirect( + c: Context, + githubToken: string, + artifact: PreviewLookupResult, + notFoundMessage: string +) { + if (artifact.status === "not_found") { + return c.json(notFoundBody(notFoundMessage), 404); + } + if (artifact.status === "unavailable") { + return c.json( + githubErrorBody(artifact.error, "Failed to look up the preview artifact"), + statusFromGithubError(artifact.error) + ); + } + + const redirect = await getArtifactDownloadUrl( + githubToken, + artifact.data.artifact_id + ); + if (redirect.status === "not_found") { + return c.json(notFoundBody("The preview artifact has expired."), 404); + } + if (redirect.status === "unavailable") { + return c.json( + githubErrorBody( + redirect.error, + "Failed to resolve the artifact download URL" + ), + statusFromGithubError(redirect.error) + ); + } + + return c.redirect(redirect.data, 302); +} diff --git a/src/services/previews/v1/schemas/previews.ts b/src/services/previews/v1/schemas/previews.ts new file mode 100644 index 0000000..2a16d33 --- /dev/null +++ b/src/services/previews/v1/schemas/previews.ts @@ -0,0 +1,151 @@ +import { z } from "@hono/zod-openapi"; + +export const ErrorResponseSchema = z + .object({ + error: z.object({ + message: z.string(), + code: z.string(), + // Only present on 422s - index.ts's defaultHook attaches the zod + // validation issues here for VALIDATION_ERROR responses. + details: z + .array( + z.unknown().openapi({ + type: ["string", "number", "boolean", "object", "array", "null"] + }) + ) + .optional() + }) + }) + .openapi("Error"); + +// Every non-2xx response in this service carries ErrorResponseSchema and +// differs only by description, mirroring extensions/v2's schemas/common.ts. +export const errorResponse = (description: string) => + ({ + content: { "application/json": { schema: ErrorResponseSchema } }, + description + }) as const; + +export const PrNumberParamSchema = z.object({ + number: z.coerce + .number() + .int() + .positive() + .openapi({ + param: { name: "number", in: "path" }, + example: 123 + }) +}); + +// Full or abbreviated (7+ char) hex commit SHA - GitHub accepts either as a +// git ref, and workflow_run.head_sha in the artifacts API is always the full +// 40-char form, so a short SHA here is matched as a prefix by the resolver. +export const CommitShaParamSchema = z.object({ + sha: z + .string() + .regex(/^[0-9a-f]{7,40}$/i, { message: "must be a hex commit SHA" }) + .openapi({ + param: { name: "sha", in: "path" }, + example: "a1b2c3d" + }) +}); + +const MainPreviewSchema = z + .object({ + commit_sha: z.string().nullable().openapi({ + description: + "Commit that produced the current main preview, from R2 object custom metadata. null if the object predates that metadata being set - GitHub Actions enrichment below is skipped in that case too, since there's no commit to look it up by." + }), + short_sha: z.string().nullable(), + pr_number: z.number().nullable().openapi({ + description: + "Always null - main is never associated with a pull request. Present only for shape parity with the PR/commit response." + }), + // Enrichment from that commit's GitHub Actions artifact, when + // resolvable - null if the commit has no known artifact (e.g. expired + // past GitHub's 14-day retention) or GitHub is unavailable. Never + // blocks or degrades the response: download_url/digest below are the + // load-bearing, R2-sourced fields and don't depend on this resolving. + run_id: z.number().nullable().openapi({ + description: + "GitHub Actions run that produced this commit's preview artifact. null if that artifact isn't resolvable (not yet built, aged out of GitHub's 14-day retention, or GitHub unavailable) - this is best-effort enrichment, never required for the response to succeed." + }), + artifact_id: z.number().nullable().openapi({ + description: + "GitHub Actions artifact ID for this commit's build. Same best-effort enrichment as run_id - null under the same conditions." + }), + created_at: z.string().nullable().openapi({ + description: + "When the enrichment artifact was created. Same best-effort enrichment as run_id - null under the same conditions." + }), + expires_at: z.string().nullable().openapi({ + description: + "When the enrichment artifact ages out of GitHub's retention. Same best-effort enrichment as run_id - null under the same conditions." + }), + digest: z.string().nullable().openapi({ + description: + "SHA-256 digest (sha256:) of the R2-hosted zip, from R2 object custom metadata. null if the object predates that metadata being set." + }), + size_bytes: z.number(), + last_modified: z.string(), + download_url: z.string().openapi({ + description: + "Permanent public download URL (download.fossbilling.org). Unlike the PR/commit equivalent, this never expires and is safe to embed directly rather than resolve through a redirect." + }), + source: z.literal("r2").openapi({ + description: + 'Always "r2" - describes where download_url/digest come from, independent of whether the GitHub Actions enrichment above resolved.' + }) + }) + .openapi("MainPreview", { + description: + "Current main preview. download_url/digest are R2-sourced and always present once main has been published; run_id/artifact_id/created_at/expires_at are best-effort GitHub Actions enrichment that may be null." + }); + +export const MainPreviewResponseSchema = z + .object({ result: MainPreviewSchema }) + .openapi("MainPreviewResponse"); + +const ArtifactPreviewSchema = z + .object({ + commit_sha: z.string(), + short_sha: z.string(), + pr_number: z.number().nullable().openapi({ + description: + "Set only when resolved via /pr/{number} - a direct /commit/{sha} lookup has no way to know which PR (if any) built that commit, and reports null." + }), + run_id: z.number(), + artifact_id: z.number().openapi({ + description: + "GitHub Actions artifact ID - what /download resolves to a live signed URL." + }), + digest: z.string().nullable().openapi({ + description: + "GitHub's own SHA-256 digest (sha256:) for this artifact - the exact bytes served by the /download route." + }), + size_bytes: z.number(), + created_at: z.string(), + expires_at: z.string().openapi({ + description: + "When this artifact ages out of GitHub's 14-day retention. After this, /download starts returning 404 even if this metadata is still cached." + }), + download_url: z.string().openapi({ + description: + "Self-referential - points at this service's own /commit/{sha}/download, not GitHub's actual signed URL (which expires in ~60s and can't be cached). Always the canonical commit URL, even when resolved via /pr/{number}, since a PR's head SHA moves as new commits land but a commit's build does not." + }), + source: z.literal("actions_artifact").openapi({ + description: + 'Always "actions_artifact" - distinguishes this from main\'s R2-sourced response.' + }) + }) + .openapi("ArtifactPreview", { + description: + "Preview build for a specific commit or pull request, resolved from a GitHub Actions artifact." + }); + +export const ArtifactPreviewResponseSchema = z + .object({ result: ArtifactPreviewSchema }) + .openapi("ArtifactPreviewResponse"); + +export type MainPreview = z.infer; +export type ArtifactPreview = z.infer; diff --git a/test/services/previews/v1/cache.test.ts b/test/services/previews/v1/cache.test.ts new file mode 100644 index 0000000..4c6b9ca --- /dev/null +++ b/test/services/previews/v1/cache.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { env } from "cloudflare:workers"; +import { cachedLookup } from "../../../../src/services/previews/v1/cache"; + +describe("previews/v1 cachedLookup", () => { + beforeEach(async () => { + await env.CACHE_KV.delete("test-key"); + }); + + it("defaults to a 60s TTL", async () => { + const putSpy = vi.spyOn(env.CACHE_KV, "put"); + + await cachedLookup(env.CACHE_KV, "test-key", async () => ({ + status: "found", + data: "value" + })); + + expect(putSpy).toHaveBeenCalledWith("test-key", JSON.stringify("value"), { + expirationTtl: 60 + }); + putSpy.mockRestore(); + }); + + it("accepts a longer TTL for immutable data", async () => { + const putSpy = vi.spyOn(env.CACHE_KV, "put"); + + await cachedLookup( + env.CACHE_KV, + "test-key", + async () => ({ status: "found", data: "value" }), + 3600 + ); + + expect(putSpy).toHaveBeenCalledWith("test-key", JSON.stringify("value"), { + expirationTtl: 3600 + }); + putSpy.mockRestore(); + }); + + it("does not cache not_found results", async () => { + const putSpy = vi.spyOn(env.CACHE_KV, "put"); + + const result = await cachedLookup(env.CACHE_KV, "test-key", async () => ({ + status: "not_found" + })); + + expect(result.status).toBe("not_found"); + expect(putSpy).not.toHaveBeenCalled(); + putSpy.mockRestore(); + }); + + it("serves a cache hit without calling resolve again", async () => { + const resolve = vi.fn().mockResolvedValue({ status: "found", data: "v1" }); + + await cachedLookup(env.CACHE_KV, "test-key", resolve); + const second = await cachedLookup(env.CACHE_KV, "test-key", resolve); + + expect(second).toEqual({ status: "found", data: "v1" }); + expect(resolve).toHaveBeenCalledTimes(1); + }); + + it("falls back to a fresh resolve on a corrupt cache entry", async () => { + await env.CACHE_KV.put("test-key", "not valid json{"); + const resolve = vi.fn().mockResolvedValue({ status: "found", data: "v1" }); + + const result = await cachedLookup(env.CACHE_KV, "test-key", resolve); + + expect(result).toEqual({ status: "found", data: "v1" }); + expect(resolve).toHaveBeenCalledTimes(1); + }); + + it("computes the TTL from the resolved data when given a function", async () => { + const putSpy = vi.spyOn(env.CACHE_KV, "put"); + const ttlFor = vi.fn((data: { value: string }) => + data.value === "value" ? 120 : 60 + ); + + await cachedLookup( + env.CACHE_KV, + "test-key", + async () => ({ status: "found", data: { value: "value" } }), + ttlFor + ); + + expect(ttlFor).toHaveBeenCalledWith({ value: "value" }); + expect(putSpy).toHaveBeenCalledWith( + "test-key", + JSON.stringify({ value: "value" }), + { expirationTtl: 120 } + ); + putSpy.mockRestore(); + }); + + it("skips caching entirely when the computed TTL is under KV's 60s floor", async () => { + const putSpy = vi.spyOn(env.CACHE_KV, "put"); + + const result = await cachedLookup( + env.CACHE_KV, + "test-key", + async () => ({ status: "found", data: "value" }), + () => 30 + ); + + expect(result).toEqual({ status: "found", data: "value" }); + expect(putSpy).not.toHaveBeenCalled(); + putSpy.mockRestore(); + }); +}); diff --git a/test/services/previews/v1/commit.test.ts b/test/services/previews/v1/commit.test.ts new file mode 100644 index 0000000..edbdb87 --- /dev/null +++ b/test/services/previews/v1/commit.test.ts @@ -0,0 +1,415 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + createExecutionContext, + waitOnExecutionContext +} from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import app from "../../../../src/app"; +import { MockGitHubRequest } from "../../../utils/test-types"; +import { suppressConsole } from "../../../utils/mock-helpers"; + +vi.mock("@octokit/request", async () => + (await import("../../../mocks/octokit")).octokitRequestMock() +); + +import { request as ghRequest } from "@octokit/request"; + +const SHA = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + +const SAMPLE_ARTIFACTS = { + total_count: 1, + artifacts: [ + { + id: 555, + size_in_bytes: 12345, + created_at: "2026-08-13T10:00:00Z", + expires_at: "2026-08-27T10:00:00Z", + expired: false, + digest: "sha256:deadbeef", + workflow_run: { id: 999, head_sha: SHA } + } + ] +}; + +async function get(path: string) { + const ctx = createExecutionContext(); + const res = await app.request(path, {}, env, ctx); + await waitOnExecutionContext(ctx); + return res; +} + +let restoreConsole: (() => void) | null = null; + +describe("Previews API v1 - GET /previews/v1/commit/:sha", () => { + beforeEach(async () => { + restoreConsole = suppressConsole(); + await env.CACHE_KV.delete(`preview:commit:${SHA.toLowerCase()}`); + vi.clearAllMocks(); + }); + + afterEach(() => { + restoreConsole?.(); + restoreConsole = null; + }); + + it("returns the matching artifact's metadata", async () => { + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( + async (route: string) => { + if (route === "GET /repos/{owner}/{repo}/actions/artifacts") { + return { data: SAMPLE_ARTIFACTS }; + } + throw new Error(`Unexpected route: ${route}`); + } + ); + + const res = await get(`/previews/v1/commit/${SHA}`); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { + commit_sha: string; + short_sha: string; + pr_number: number | null; + run_id: number; + artifact_id: number; + digest: string | null; + download_url: string; + source: string; + }; + }; + expect(body.result.commit_sha).toBe(SHA); + expect(body.result.short_sha).toBe(SHA.slice(0, 7)); + expect(body.result.pr_number).toBeNull(); + expect(body.result.run_id).toBe(999); + expect(body.result.artifact_id).toBe(555); + expect(body.result.digest).toBe("sha256:deadbeef"); + expect(body.result.download_url).toBe( + `/previews/v1/commit/${SHA}/download` + ); + expect(body.result.source).toBe("actions_artifact"); + + // Regression check: FOSSBilling/FOSSBilling's ci.yml names each + // artifact after the commit's short SHA rather than sharing one name + // across every run - querying the wrong name silently returns nothing. + expect(ghRequest).toHaveBeenCalledWith( + "GET /repos/{owner}/{repo}/actions/artifacts", + expect.objectContaining({ + name: `FOSSBilling-preview-${SHA.slice(0, 7)}.zip` + }) + ); + }); + + it("matches on a short SHA prefix", async () => { + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( + async () => ({ data: SAMPLE_ARTIFACTS }) + ); + + const res = await get(`/previews/v1/commit/${SHA.slice(0, 7)}`); + expect(res.status).toBe(200); + const body = (await res.json()) as { result: { commit_sha: string } }; + expect(body.result.commit_sha).toBe(SHA); + }); + + it("ignores expired artifacts", async () => { + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( + async () => ({ + data: { + total_count: 1, + artifacts: [{ ...SAMPLE_ARTIFACTS.artifacts[0], expired: true }] + } + }) + ); + + const res = await get(`/previews/v1/commit/${SHA}`); + expect(res.status).toBe(404); + }); + + it("404s when no artifact matches the commit", async () => { + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( + async () => ({ data: { total_count: 0, artifacts: [] } }) + ); + + const res = await get(`/previews/v1/commit/${SHA}`); + expect(res.status).toBe(404); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("NOT_FOUND"); + }); + + it("422s on a malformed sha", async () => { + const res = await get("/previews/v1/commit/not-a-sha"); + expect(res.status).toBe(422); + }); + + it("returns 503 when GitHub is unavailable", async () => { + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation(async () => { + throw Object.assign(new Error("Service Unavailable"), { + status: 502 + }); + }); + + const res = await get(`/previews/v1/commit/${SHA}`); + expect(res.status).toBe(503); + }); + + it("follows the redirect for /commit/:sha/download", async () => { + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( + async (route: string) => { + if (route === "GET /repos/{owner}/{repo}/actions/artifacts") { + return { data: SAMPLE_ARTIFACTS }; + } + if ( + route === + "GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}/{archive_format}" + ) { + return { + status: 302, + headers: { location: "https://example.com/signed-download" } + }; + } + throw new Error(`Unexpected route: ${route}`); + } + ); + + const res = await get(`/previews/v1/commit/${SHA}/download`); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe( + "https://example.com/signed-download" + ); + }); + + it("shares the metadata route's cache instead of re-listing artifacts on every download", async () => { + let artifactsListCalls = 0; + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( + async (route: string) => { + if (route === "GET /repos/{owner}/{repo}/actions/artifacts") { + artifactsListCalls++; + return { data: SAMPLE_ARTIFACTS }; + } + if ( + route === + "GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}/{archive_format}" + ) { + return { + status: 302, + headers: { location: "https://example.com/signed-download" } + }; + } + throw new Error(`Unexpected route: ${route}`); + } + ); + + await get(`/previews/v1/commit/${SHA}`); + const res = await get(`/previews/v1/commit/${SHA}/download`); + + expect(res.status).toBe(302); + // The artifact lookup ran once (warming the cache on the first + // request) - the download request reused it rather than listing + // artifacts again just to find the same artifact_id. + expect(artifactsListCalls).toBe(1); + }); + + it("caches the commit lookup for longer than the default 60s", async () => { + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( + async () => ({ data: SAMPLE_ARTIFACTS }) + ); + const putSpy = vi.spyOn(env.CACHE_KV, "put"); + + await get(`/previews/v1/commit/${SHA}`); + + expect(putSpy).toHaveBeenCalledWith( + `preview:commit:${SHA.toLowerCase()}`, + expect.any(String), + { expirationTtl: 3600 } + ); + putSpy.mockRestore(); + }); + + it("caps the cache TTL at the artifact's own remaining GitHub retention", async () => { + // Expires in ~500s - well under the 3600s default, so the capped + // value (not 3600) must be what's actually written. + const expiresAt = new Date(Date.now() + 500_000).toISOString(); + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( + async () => ({ + data: { + total_count: 1, + artifacts: [ + { ...SAMPLE_ARTIFACTS.artifacts[0], expires_at: expiresAt } + ] + } + }) + ); + const putSpy = vi.spyOn(env.CACHE_KV, "put"); + + await get(`/previews/v1/commit/${SHA}`); + + expect(putSpy).toHaveBeenCalledTimes(1); + const ttl = (putSpy.mock.calls[0][2] as { expirationTtl: number }) + .expirationTtl; + expect(ttl).toBeGreaterThan(400); + expect(ttl).toBeLessThanOrEqual(500); + putSpy.mockRestore(); + }); + + it("skips caching when the artifact expires within KV's 60s minimum TTL", async () => { + const expiresAt = new Date(Date.now() + 30_000).toISOString(); + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( + async () => ({ + data: { + total_count: 1, + artifacts: [ + { ...SAMPLE_ARTIFACTS.artifacts[0], expires_at: expiresAt } + ] + } + }) + ); + const putSpy = vi.spyOn(env.CACHE_KV, "put"); + + const res = await get(`/previews/v1/commit/${SHA}`); + + expect(res.status).toBe(200); + expect(putSpy).not.toHaveBeenCalled(); + putSpy.mockRestore(); + }); + + it("resolves an uppercase SHA by querying the lowercased artifact name", async () => { + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( + async () => ({ data: SAMPLE_ARTIFACTS }) + ); + + const res = await get(`/previews/v1/commit/${SHA.toUpperCase()}`); + + expect(res.status).toBe(200); + expect(ghRequest).toHaveBeenCalledWith( + "GET /repos/{owner}/{repo}/actions/artifacts", + expect.objectContaining({ + name: `FOSSBilling-preview-${SHA.slice(0, 7)}.zip` + }) + ); + }); + + it("falls back to a broad scan when the exact artifact name misses (fork PR merge-SHA mismatch)", async () => { + // Simulates a fork PR: CI named the artifact after the pull_request + // event's ephemeral merge commit ("deadbeef..."), not the PR's real + // head SHA (SHA) - so the exact-name query for SHA's derived name + // returns nothing, and only a name-less scan (filtered by the run's + // real head_sha) finds it. + const mergeShaArtifact = { + id: 777, + name: "FOSSBilling-preview-deadbee.zip", + size_in_bytes: 99, + created_at: "2026-08-13T11:00:00Z", + expires_at: "2026-08-27T11:00:00Z", + expired: false, + digest: "sha256:fromfork", + workflow_run: { id: 888, head_sha: SHA } + }; + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( + async (route: string, params?: { name?: string }) => { + if (route !== "GET /repos/{owner}/{repo}/actions/artifacts") { + throw new Error(`Unexpected route: ${route}`); + } + if (params?.name) { + // The exact-name fast path - misses. + return { data: { total_count: 0, artifacts: [] } }; + } + // The fallback broad scan. + return { data: { total_count: 1, artifacts: [mergeShaArtifact] } }; + } + ); + + const res = await get(`/previews/v1/commit/${SHA}`); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { artifact_id: number; digest: string | null }; + }; + expect(body.result.artifact_id).toBe(777); + expect(body.result.digest).toBe("sha256:fromfork"); + expect(ghRequest).toHaveBeenCalledTimes(2); + }); + + it("pages through the fallback scan past the old 5-page cap, then stops as soon as it finds a match", async () => { + // Regression check: an earlier version of the fallback stopped after + // 5 pages (500 artifacts) as a hard cutoff, which would have reported + // this commit not_found even though its artifact genuinely exists - + // just on page 6. A repo with more than 500 live preview artifacts + // isn't hypothetical for an active project; the fallback is the + // source of truth for fork PRs and can't trade correctness for a + // fixed cutoff the way the fast exact-name path can. + const fullPage = (offset: number) => + Array.from({ length: 100 }, (_, i) => ({ + id: offset + i, + name: `FOSSBilling-preview-other${offset + i}.zip`, + size_in_bytes: 1, + created_at: "2026-08-01T00:00:00Z", + expires_at: "2026-08-15T00:00:00Z", + expired: false, + digest: null, + workflow_run: { + id: 1, + head_sha: "0000000000000000000000000000000000000" + } + })); + const page6Match = { + id: 9000, + name: "FOSSBilling-preview-deadbee.zip", + size_in_bytes: 99, + created_at: "2026-08-13T11:00:00Z", + expires_at: "2026-08-27T11:00:00Z", + expired: false, + digest: "sha256:page6", + workflow_run: { id: 888, head_sha: SHA } + }; + let fallbackCalls = 0; + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( + async (route: string, params?: { name?: string; page?: number }) => { + if (route !== "GET /repos/{owner}/{repo}/actions/artifacts") { + throw new Error(`Unexpected route: ${route}`); + } + if (params?.name) { + return { data: { total_count: 0, artifacts: [] } }; + } + fallbackCalls++; + const page = params?.page ?? 1; + if (page <= 5) { + return { data: { artifacts: fullPage(page * 1000) } }; + } + if (page === 6) { + return { data: { artifacts: [page6Match] } }; + } + throw new Error(`Unexpected page: ${page}`); + } + ); + + const res = await get(`/previews/v1/commit/${SHA}`); + + expect(res.status).toBe(200); + const body = (await res.json()) as { result: { artifact_id: number } }; + expect(body.result.artifact_id).toBe(9000); + // Exact-name miss + 6 fallback pages - stops on page 6 rather than + // continuing to page 7. + expect(fallbackCalls).toBe(6); + expect(ghRequest).toHaveBeenCalledTimes(7); + }); + + it("falls back to the default TTL ceiling when expires_at can't be parsed", async () => { + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( + async () => ({ + data: { + total_count: 1, + artifacts: [{ ...SAMPLE_ARTIFACTS.artifacts[0], expires_at: null }] + } + }) + ); + const putSpy = vi.spyOn(env.CACHE_KV, "put"); + + const res = await get(`/previews/v1/commit/${SHA}`); + + expect(res.status).toBe(200); + expect(putSpy).toHaveBeenCalledWith( + `preview:commit:${SHA.toLowerCase()}`, + expect.any(String), + { expirationTtl: 3600 } + ); + putSpy.mockRestore(); + }); +}); diff --git a/test/services/previews/v1/main.test.ts b/test/services/previews/v1/main.test.ts new file mode 100644 index 0000000..d6569dc --- /dev/null +++ b/test/services/previews/v1/main.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + createExecutionContext, + waitOnExecutionContext +} from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import app from "../../../../src/app"; +import { MockGitHubRequest } from "../../../utils/test-types"; +import { suppressConsole } from "../../../utils/mock-helpers"; + +vi.mock("@octokit/request", async () => + (await import("../../../mocks/octokit")).octokitRequestMock() +); + +import { request as ghRequest } from "@octokit/request"; + +const MAIN_PREVIEW_KEY = "FOSSBilling-preview.zip"; +const COMMIT_SHA = "abc1234567890abc1234567890abc1234567890"; + +const SAMPLE_ARTIFACTS = { + total_count: 1, + artifacts: [ + { + id: 555, + size_in_bytes: 12345, + created_at: "2026-08-13T10:00:00Z", + expires_at: "2026-08-27T10:00:00Z", + expired: false, + digest: "sha256:deadbeef", + workflow_run: { id: 999, head_sha: COMMIT_SHA } + } + ] +}; + +async function get(path: string) { + const ctx = createExecutionContext(); + const res = await app.request(path, {}, env, ctx); + await waitOnExecutionContext(ctx); + return res; +} + +let restoreConsole: (() => void) | null = null; + +describe("Previews API v1 - GET /previews/v1/main", () => { + beforeEach(async () => { + restoreConsole = suppressConsole(); + await env.CACHE_KV.delete("preview:main"); + await env.DOWNLOAD_BUCKET.delete(MAIN_PREVIEW_KEY); + vi.clearAllMocks(); + }); + + afterEach(() => { + restoreConsole?.(); + restoreConsole = null; + }); + + it("returns 404 when no main preview has been published", async () => { + const res = await get("/previews/v1/main"); + expect(res.status).toBe(404); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("NOT_FOUND"); + }); + + it("returns the R2 object's metadata, including the sha256 digest", async () => { + await env.DOWNLOAD_BUCKET.put(MAIN_PREVIEW_KEY, "test archive contents", { + customMetadata: { + digest: + "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + "commit-sha": COMMIT_SHA + } + }); + + const res = await get("/previews/v1/main"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { + commit_sha: string | null; + short_sha: string | null; + digest: string | null; + size_bytes: number; + download_url: string; + source: string; + }; + }; + + expect(body.result.commit_sha).toBe(COMMIT_SHA); + expect(body.result.short_sha).toBe("abc1234"); + expect(body.result.digest).toBe( + "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + ); + expect(body.result.size_bytes).toBe("test archive contents".length); + expect(body.result.download_url).toBe( + "https://download.fossbilling.org/FOSSBilling-preview.zip" + ); + expect(body.result.source).toBe("r2"); + }); + + it("reports a null digest and commit_sha when the object has no custom metadata", async () => { + await env.DOWNLOAD_BUCKET.put(MAIN_PREVIEW_KEY, "test archive contents"); + + const res = await get("/previews/v1/main"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { + commit_sha: string | null; + digest: string | null; + run_id: number | null; + }; + }; + expect(body.result.commit_sha).toBeNull(); + expect(body.result.digest).toBeNull(); + // No commit_sha means there's nothing to look up an artifact by. + expect(body.result.run_id).toBeNull(); + expect(ghRequest).not.toHaveBeenCalled(); + }); + + it("enriches with that commit's GitHub Actions artifact when resolvable", async () => { + await env.DOWNLOAD_BUCKET.put(MAIN_PREVIEW_KEY, "test archive contents", { + customMetadata: { "commit-sha": COMMIT_SHA } + }); + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( + async () => ({ data: SAMPLE_ARTIFACTS }) + ); + + const res = await get("/previews/v1/main"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { + pr_number: number | null; + run_id: number | null; + artifact_id: number | null; + created_at: string | null; + expires_at: string | null; + source: string; + }; + }; + expect(body.result.pr_number).toBeNull(); + expect(body.result.run_id).toBe(999); + expect(body.result.artifact_id).toBe(555); + expect(body.result.created_at).toBe("2026-08-13T10:00:00Z"); + expect(body.result.expires_at).toBe("2026-08-27T10:00:00Z"); + // download_url/digest stay R2-sourced regardless of the enrichment. + expect(body.result.source).toBe("r2"); + }); + + it("still succeeds with null enrichment fields when GitHub is unavailable", async () => { + await env.DOWNLOAD_BUCKET.put(MAIN_PREVIEW_KEY, "test archive contents", { + customMetadata: { "commit-sha": COMMIT_SHA } + }); + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation(async () => { + throw Object.assign(new Error("Service Unavailable"), { + status: 502 + }); + }); + + const res = await get("/previews/v1/main"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { run_id: number | null; digest: string | null }; + }; + expect(body.result.run_id).toBeNull(); + }); + + it("still succeeds with null enrichment fields when the commit has no known artifact", async () => { + await env.DOWNLOAD_BUCKET.put(MAIN_PREVIEW_KEY, "test archive contents", { + customMetadata: { "commit-sha": COMMIT_SHA } + }); + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( + async () => ({ data: { total_count: 0, artifacts: [] } }) + ); + + const res = await get("/previews/v1/main"); + expect(res.status).toBe(200); + const body = (await res.json()) as { result: { run_id: number | null } }; + expect(body.result.run_id).toBeNull(); + }); + + it("serves the second request from CACHE_KV without re-reading R2 or GitHub", async () => { + await env.DOWNLOAD_BUCKET.put(MAIN_PREVIEW_KEY, "v1", { + customMetadata: { "commit-sha": "111" } + }); + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( + async () => ({ data: { total_count: 0, artifacts: [] } }) + ); + + const first = await get("/previews/v1/main"); + expect( + ((await first.json()) as { result: { commit_sha: string } }).result + .commit_sha + ).toBe("111"); + + // Overwrite the R2 object directly - a cache hit should still serve the + // first response's data rather than reflecting this change. + await env.DOWNLOAD_BUCKET.put(MAIN_PREVIEW_KEY, "v2", { + customMetadata: { "commit-sha": "222" } + }); + const second = await get("/previews/v1/main"); + const secondBody = (await second.json()) as { + result: { commit_sha: string }; + }; + expect(secondBody.result.commit_sha).toBe("111"); + // 2, not 1: findPreviewArtifactByCommitSha's exact-name query misses + // (no artifact was mocked), so it falls back to a second, broader + // query before giving up - both happen on the first /main request + // only, since the second is served entirely from cache. + expect(ghRequest).toHaveBeenCalledTimes(2); + }); + + it("falls back to R2 instead of erroring on a corrupt cache entry", async () => { + await env.DOWNLOAD_BUCKET.put(MAIN_PREVIEW_KEY, "test archive contents", { + customMetadata: { "commit-sha": COMMIT_SHA } + }); + await env.CACHE_KV.put("preview:main", "not valid json{"); + + const res = await get("/previews/v1/main"); + expect(res.status).toBe(200); + const body = (await res.json()) as { result: { commit_sha: string } }; + expect(body.result.commit_sha).toBe(COMMIT_SHA); + }); +}); + +describe("Previews API v1 - GET /previews/v1/main/download", () => { + beforeEach(async () => { + restoreConsole = suppressConsole(); + await env.CACHE_KV.delete("preview:main"); + await env.DOWNLOAD_BUCKET.delete(MAIN_PREVIEW_KEY); + vi.clearAllMocks(); + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( + async () => ({ data: { total_count: 0, artifacts: [] } }) + ); + }); + + afterEach(() => { + restoreConsole?.(); + restoreConsole = null; + }); + + it("returns 404 when no main preview has been published", async () => { + const res = await get("/previews/v1/main/download"); + expect(res.status).toBe(404); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("NOT_FOUND"); + }); + + it("redirects to the permanent main preview download URL", async () => { + await env.DOWNLOAD_BUCKET.put(MAIN_PREVIEW_KEY, "test archive contents"); + + const res = await get("/previews/v1/main/download"); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe( + "https://download.fossbilling.org/FOSSBilling-preview.zip" + ); + }); + + it("shares the metadata route's cache instead of re-reading R2", async () => { + await env.DOWNLOAD_BUCKET.put(MAIN_PREVIEW_KEY, "test archive contents"); + const headSpy = vi.spyOn(env.DOWNLOAD_BUCKET, "head"); + + await get("/previews/v1/main"); + const res = await get("/previews/v1/main/download"); + + expect(res.status).toBe(302); + // The R2 HEAD ran once (warming the cache on the first request) - the + // download request reused it rather than reading R2 again. + expect(headSpy).toHaveBeenCalledTimes(1); + headSpy.mockRestore(); + }); +}); diff --git a/test/services/previews/v1/pr.test.ts b/test/services/previews/v1/pr.test.ts new file mode 100644 index 0000000..3a987a8 --- /dev/null +++ b/test/services/previews/v1/pr.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + createExecutionContext, + waitOnExecutionContext +} from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import app from "../../../../src/app"; +import { MockGitHubRequest } from "../../../utils/test-types"; +import { suppressConsole } from "../../../utils/mock-helpers"; + +vi.mock("@octokit/request", async () => + (await import("../../../mocks/octokit")).octokitRequestMock() +); + +import { request as ghRequest } from "@octokit/request"; + +const PR_NUMBER = 123; +const SHA = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + +const SAMPLE_ARTIFACTS = { + total_count: 1, + artifacts: [ + { + id: 555, + size_in_bytes: 12345, + created_at: "2026-08-13T10:00:00Z", + expires_at: "2026-08-27T10:00:00Z", + expired: false, + digest: "sha256:deadbeef", + workflow_run: { id: 999, head_sha: SHA } + } + ] +}; + +function mockGithub(routes: Record) { + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( + async (route: string) => { + if (route in routes) return routes[route]; + throw new Error(`Unexpected route: ${route}`); + } + ); +} + +async function get(path: string) { + const ctx = createExecutionContext(); + const res = await app.request(path, {}, env, ctx); + await waitOnExecutionContext(ctx); + return res; +} + +let restoreConsole: (() => void) | null = null; + +describe("Previews API v1 - GET /previews/v1/pr/:number", () => { + beforeEach(async () => { + restoreConsole = suppressConsole(); + await env.CACHE_KV.delete(`preview:pr:${PR_NUMBER}`); + vi.clearAllMocks(); + }); + + afterEach(() => { + restoreConsole?.(); + restoreConsole = null; + }); + + it("resolves the PR to its head SHA, then to that commit's artifact", async () => { + mockGithub({ + "GET /repos/{owner}/{repo}/pulls/{pull_number}": { + data: { head: { sha: SHA } } + }, + "GET /repos/{owner}/{repo}/actions/artifacts": { data: SAMPLE_ARTIFACTS } + }); + + const res = await get(`/previews/v1/pr/${PR_NUMBER}`); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { + commit_sha: string; + pr_number: number | null; + download_url: string; + }; + }; + expect(body.result.commit_sha).toBe(SHA); + expect(body.result.pr_number).toBe(PR_NUMBER); + // Always canonicalized to the fixed /commit/{sha} resource, not + // /pr/{number} - see resolve.ts. + expect(body.result.download_url).toBe( + `/previews/v1/commit/${SHA}/download` + ); + }); + + it("404s when the pull request does not exist", async () => { + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation(async () => { + throw Object.assign(new Error("Not Found"), { status: 404 }); + }); + + const res = await get(`/previews/v1/pr/${PR_NUMBER}`); + expect(res.status).toBe(404); + }); + + it("404s when the PR exists but has no preview artifact yet", async () => { + mockGithub({ + "GET /repos/{owner}/{repo}/pulls/{pull_number}": { + data: { head: { sha: SHA } } + }, + "GET /repos/{owner}/{repo}/actions/artifacts": { + data: { total_count: 0, artifacts: [] } + } + }); + + const res = await get(`/previews/v1/pr/${PR_NUMBER}`); + expect(res.status).toBe(404); + }); + + it("422s on a non-numeric PR number", async () => { + const res = await get("/previews/v1/pr/not-a-number"); + expect(res.status).toBe(422); + }); + + it("follows the redirect for /pr/:number/download", async () => { + mockGithub({ + "GET /repos/{owner}/{repo}/pulls/{pull_number}": { + data: { head: { sha: SHA } } + }, + "GET /repos/{owner}/{repo}/actions/artifacts": { + data: SAMPLE_ARTIFACTS + }, + "GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}/{archive_format}": + { + status: 302, + headers: { location: "https://example.com/signed-download" } + } + }); + + const res = await get(`/previews/v1/pr/${PR_NUMBER}/download`); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe( + "https://example.com/signed-download" + ); + }); + + it("shares the metadata route's cache instead of re-resolving the PR on every download", async () => { + let pullsCalls = 0; + let artifactsListCalls = 0; + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( + async (route: string) => { + if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") { + pullsCalls++; + return { data: { head: { sha: SHA } } }; + } + if (route === "GET /repos/{owner}/{repo}/actions/artifacts") { + artifactsListCalls++; + return { data: SAMPLE_ARTIFACTS }; + } + if ( + route === + "GET /repos/{owner}/{repo}/actions/artifacts/{artifact_id}/{archive_format}" + ) { + return { + status: 302, + headers: { location: "https://example.com/signed-download" } + }; + } + throw new Error(`Unexpected route: ${route}`); + } + ); + + await get(`/previews/v1/pr/${PR_NUMBER}`); + const res = await get(`/previews/v1/pr/${PR_NUMBER}/download`); + + expect(res.status).toBe(302); + // Both the PR->SHA resolution and the artifact lookup ran once, + // warming the cache on the first request - the download request + // reused that instead of re-resolving the PR from scratch. + expect(pullsCalls).toBe(1); + expect(artifactsListCalls).toBe(1); + }); + + it("caches the PR lookup at the default 60s, unlike commit's longer TTL", async () => { + mockGithub({ + "GET /repos/{owner}/{repo}/pulls/{pull_number}": { + data: { head: { sha: SHA } } + }, + "GET /repos/{owner}/{repo}/actions/artifacts": { data: SAMPLE_ARTIFACTS } + }); + const putSpy = vi.spyOn(env.CACHE_KV, "put"); + + await get(`/previews/v1/pr/${PR_NUMBER}`); + + expect(putSpy).toHaveBeenCalledWith( + `preview:pr:${PR_NUMBER}`, + expect.any(String), + { expirationTtl: 60 } + ); + putSpy.mockRestore(); + }); + + it("resolves a fork PR whose artifact was named from the merge SHA, not the head SHA", async () => { + // ci.yml's pull_request-triggered job (fork PRs only) names its + // artifact after $GITHUB_SHA, which GitHub sets to the ephemeral + // pull_request merge commit rather than the PR's real head commit - + // see the comment on findPreviewArtifactByCommitSha. The exact-name + // query built from the real head SHA (SHA) therefore misses, and only + // the fallback scan (matched by the run's real head_sha, unaffected by + // what name the artifact was given) finds it. + const mergeShaArtifact = { + id: 777, + name: "FOSSBilling-preview-deadbee.zip", + size_in_bytes: 99, + created_at: "2026-08-13T11:00:00Z", + expires_at: "2026-08-27T11:00:00Z", + expired: false, + digest: "sha256:fromfork", + workflow_run: { id: 888, head_sha: SHA } + }; + (vi.mocked(ghRequest) as MockGitHubRequest).mockImplementation( + async (route: string, params?: { name?: string }) => { + if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") { + return { data: { head: { sha: SHA } } }; + } + if (route === "GET /repos/{owner}/{repo}/actions/artifacts") { + if (params?.name) { + return { data: { total_count: 0, artifacts: [] } }; + } + return { data: { total_count: 1, artifacts: [mergeShaArtifact] } }; + } + throw new Error(`Unexpected route: ${route}`); + } + ); + + const res = await get(`/previews/v1/pr/${PR_NUMBER}`); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { artifact_id: number; pr_number: number | null }; + }; + expect(body.result.artifact_id).toBe(777); + expect(body.result.pr_number).toBe(PR_NUMBER); + }); +}); diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index f161eef..a732555 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,9 +1,10 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --env-interface=CloudflareBindings` (hash: 8a84d14f01f95073fe2873a2e9b82b24) -// Runtime types generated with workerd@1.20260730.1 2026-06-24 nodejs_compat +// Generated by Wrangler by running `wrangler types --env-interface=CloudflareBindings` (hash: 3699481541fb6006a52b433d336dc475) +// Runtime types generated with workerd@1.20260801.1 2026-06-24 nodejs_compat interface __BaseEnv_CloudflareBindings { AUTH_KV: KVNamespace; CACHE_KV: KVNamespace; + DOWNLOAD_BUCKET: R2Bucket; DB_CENTRAL_ALERTS: D1Database; DB_EXTENSIONS: D1Database; PROFILE_CREATION_RATE_LIMITER: RateLimit; @@ -10342,7 +10343,7 @@ type AIGatewayHeaders = { [key: string]: string | number | boolean | object; }; type AIGatewayUniversalRequest = { - provider: AIGatewayProviders | string; + provider: AIGatewayProviders | string; // eslint-disable-line endpoint: string; headers: Partial; query: unknown; @@ -10359,7 +10360,7 @@ declare abstract class AiGateway { extraHeaders?: object; signal?: AbortSignal; }): Promise; - getUrl(provider?: AIGatewayProviders | string): Promise; + getUrl(provider?: AIGatewayProviders | string): Promise; // eslint-disable-line } // Copyright (c) 2022-2025 Cloudflare, Inc. // Licensed under the Apache 2.0 license found in the LICENSE file or at: diff --git a/wrangler.jsonc b/wrangler.jsonc index 86c277f..de21497 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -47,6 +47,12 @@ "id": "0771957093ae481b9cd974ffa83f8263" } ], + "r2_buckets": [ + { + "binding": "DOWNLOAD_BUCKET", + "bucket_name": "fossbilling-download" + } + ], "ratelimits": [ { "name": "PROFILE_CREATION_RATE_LIMITER",