Files
gitea-mirror/tests/e2e/helpers.ts
T
ARUNAVO RAY 1b60a0334a feat: read only API keys and an MCP endpoint over the REST API (#478)
- Read only API keys: an Access choice when creating a key, stored in
  the key metadata; the middleware refuses writes and most auth calls
  for them with 403 API_KEY_READ_ONLY.
- POST /api/mcp: stateless MCP endpoint with 8 tools that call the REST
  API over loopback with the caller's key. Needs x-api-key, a cookie
  alone gets 401. MCP_INTERNAL_URL overrides the loopback address.
- mcp/: gitea-mirror-mcp, a zero dependency stdio bridge to /api/mcp for
  Claude Desktop and Cursor, with a CI workflow that publishes it on an
  mcp-v* tag.
- e2e spec for keys, the endpoint and the bridge; docs for the endpoint,
  the bridge and read only keys.
2026-10-26 16:07:18 +05:30

682 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Shared helpers for E2E tests.
*
* Exports constants, the GiteaAPI wrapper, auth helpers (sign-up / sign-in),
* the saveConfig helper, and a generic waitFor polling utility.
*/
import {
expect,
request as playwrightRequest,
type Page,
type APIRequestContext,
} from "@playwright/test";
// ─── Constants ───────────────────────────────────────────────────────────────
export const APP_URL = process.env.APP_URL || "http://localhost:4321";
export const GITEA_URL = process.env.GITEA_URL || "http://localhost:3333";
export const FAKE_GITHUB_URL =
process.env.FAKE_GITHUB_URL || "http://localhost:4580";
export const GIT_SERVER_URL =
process.env.GIT_SERVER_URL || "http://localhost:4590";
export const GITEA_ADMIN_USER = "e2e_admin";
export const GITEA_ADMIN_PASS = "e2eAdminPass123!";
export const GITEA_ADMIN_EMAIL = "admin@e2e-test.local";
export const APP_USER_EMAIL = "e2e@test.local";
export const APP_USER_PASS = "E2eTestPass123!";
export const APP_USER_NAME = "e2e-tester";
export const GITEA_MIRROR_ORG = "github-mirrors";
// ─── waitFor ─────────────────────────────────────────────────────────────────
/** Retry a function until it returns truthy or timeout is reached. */
export async function waitFor(
fn: () => Promise<boolean>,
{
timeout = 60_000,
interval = 2_000,
label = "condition",
}: { timeout?: number; interval?: number; label?: string } = {},
): Promise<void> {
const deadline = Date.now() + timeout;
let lastErr: Error | undefined;
while (Date.now() < deadline) {
try {
if (await fn()) return;
} catch (e) {
lastErr = e instanceof Error ? e : new Error(String(e));
}
await new Promise((r) => setTimeout(r, interval));
}
throw new Error(
`waitFor("${label}") timed out after ${timeout}ms` +
(lastErr ? `: ${lastErr.message}` : ""),
);
}
// ─── GiteaAPI ────────────────────────────────────────────────────────────────
/**
* Direct HTTP helper for talking to Gitea's API.
*
* Uses a manually-created APIRequestContext so it can be shared across
* beforeAll / afterAll / individual tests without hitting Playwright's
* "fixture from beforeAll cannot be reused" restriction.
*/
export class GiteaAPI {
private token = "";
private ctx: APIRequestContext | null = null;
constructor(private baseUrl: string) {}
/** Lazily create (and cache) a Playwright APIRequestContext. */
private async getCtx(): Promise<APIRequestContext> {
if (!this.ctx) {
this.ctx = await playwrightRequest.newContext({
baseURL: this.baseUrl,
});
}
return this.ctx;
}
/** Dispose of the underlying context – call in afterAll. */
async dispose(): Promise<void> {
if (this.ctx) {
await this.ctx.dispose();
this.ctx = null;
}
}
/** Create the admin user via Gitea's sign-up form (first user becomes admin). */
async ensureAdminUser(): Promise<void> {
const ctx = await this.getCtx();
// Check if admin already exists by trying basic-auth
try {
const resp = await ctx.get(`/api/v1/user`, {
headers: {
Authorization: `Basic ${btoa(`${GITEA_ADMIN_USER}:${GITEA_ADMIN_PASS}`)}`,
},
failOnStatusCode: false,
});
if (resp.ok()) {
console.log("[GiteaAPI] Admin user already exists");
return;
}
} catch {
// Expected on first run
}
// Register through the form – first user auto-becomes admin
console.log("[GiteaAPI] Creating admin via sign-up form...");
const signUpResp = await ctx.post(`/user/sign_up`, {
form: {
user_name: GITEA_ADMIN_USER,
password: GITEA_ADMIN_PASS,
retype: GITEA_ADMIN_PASS,
email: GITEA_ADMIN_EMAIL,
},
failOnStatusCode: false,
maxRedirects: 5,
});
console.log(`[GiteaAPI] Sign-up response status: ${signUpResp.status()}`);
// Verify
const check = await ctx.get(`/api/v1/user`, {
headers: {
Authorization: `Basic ${btoa(`${GITEA_ADMIN_USER}:${GITEA_ADMIN_PASS}`)}`,
},
failOnStatusCode: false,
});
if (!check.ok()) {
throw new Error(
`Failed to verify admin user after creation (status ${check.status()})`,
);
}
console.log("[GiteaAPI] Admin user verified");
}
/** Generate a Gitea API token for the admin user. */
async createToken(): Promise<string> {
if (this.token) return this.token;
const ctx = await this.getCtx();
const tokenName = `e2e-token-${Date.now()}`;
const resp = await ctx.post(`/api/v1/users/${GITEA_ADMIN_USER}/tokens`, {
headers: {
Authorization: `Basic ${btoa(`${GITEA_ADMIN_USER}:${GITEA_ADMIN_PASS}`)}`,
"Content-Type": "application/json",
},
data: {
name: tokenName,
scopes: [
"read:user",
"write:user",
"read:organization",
"write:organization",
"read:repository",
"write:repository",
"read:issue",
"write:issue",
"read:misc",
"write:misc",
"read:admin",
"write:admin",
],
},
});
expect(
resp.ok(),
`Failed to create Gitea token: ${resp.status()}`,
).toBeTruthy();
const data = await resp.json();
this.token = data.sha1 || data.token;
console.log(`[GiteaAPI] Created token: ${tokenName}`);
return this.token;
}
/** Create an organization in Gitea. */
async ensureOrg(orgName: string): Promise<void> {
const ctx = await this.getCtx();
const token = await this.createToken();
// Check if org exists
const check = await ctx.get(`/api/v1/orgs/${orgName}`, {
headers: { Authorization: `token ${token}` },
failOnStatusCode: false,
});
if (check.ok()) {
console.log(`[GiteaAPI] Org ${orgName} already exists`);
return;
}
const resp = await ctx.post(`/api/v1/orgs`, {
headers: {
Authorization: `token ${token}`,
"Content-Type": "application/json",
},
data: {
username: orgName,
full_name: orgName,
description: "E2E test mirror organization",
visibility: "public",
},
});
expect(resp.ok(), `Failed to create org: ${resp.status()}`).toBeTruthy();
console.log(`[GiteaAPI] Created org: ${orgName}`);
}
/** List repos in a Gitea org. */
async listOrgRepos(orgName: string): Promise<any[]> {
const ctx = await this.getCtx();
const token = await this.createToken();
const resp = await ctx.get(`/api/v1/orgs/${orgName}/repos`, {
headers: { Authorization: `token ${token}` },
failOnStatusCode: false,
});
if (!resp.ok()) return [];
return resp.json();
}
/** List repos for the admin user. */
async listUserRepos(): Promise<any[]> {
const ctx = await this.getCtx();
const token = await this.createToken();
const resp = await ctx.get(`/api/v1/users/${GITEA_ADMIN_USER}/repos`, {
headers: { Authorization: `token ${token}` },
failOnStatusCode: false,
});
if (!resp.ok()) return [];
return resp.json();
}
/** Get a specific repo. */
async getRepo(owner: string, name: string): Promise<any | null> {
const ctx = await this.getCtx();
const token = await this.createToken();
const resp = await ctx.get(`/api/v1/repos/${owner}/${name}`, {
headers: { Authorization: `token ${token}` },
failOnStatusCode: false,
});
if (!resp.ok()) return null;
return resp.json();
}
/** List branches for a repo. */
async listBranches(owner: string, name: string): Promise<any[]> {
const ctx = await this.getCtx();
const token = await this.createToken();
const resp = await ctx.get(`/api/v1/repos/${owner}/${name}/branches`, {
headers: { Authorization: `token ${token}` },
failOnStatusCode: false,
});
if (!resp.ok()) return [];
return resp.json();
}
/** List tags for a repo. */
async listTags(owner: string, name: string): Promise<any[]> {
const ctx = await this.getCtx();
const token = await this.createToken();
const resp = await ctx.get(`/api/v1/repos/${owner}/${name}/tags`, {
headers: { Authorization: `token ${token}` },
failOnStatusCode: false,
});
if (!resp.ok()) return [];
return resp.json();
}
/** List commits for a repo (on default branch). */
async listCommits(
owner: string,
name: string,
opts?: { sha?: string; limit?: number },
): Promise<any[]> {
const ctx = await this.getCtx();
const token = await this.createToken();
const params = new URLSearchParams();
if (opts?.sha) params.set("sha", opts.sha);
if (opts?.limit) params.set("limit", String(opts.limit));
const qs = params.toString() ? `?${params.toString()}` : "";
const resp = await ctx.get(
`/api/v1/repos/${owner}/${name}/commits${qs}`,
{
headers: { Authorization: `token ${token}` },
failOnStatusCode: false,
},
);
if (!resp.ok()) return [];
return resp.json();
}
/** Get a single branch (includes the commit SHA). */
async getBranch(
owner: string,
name: string,
branch: string,
): Promise<any | null> {
const ctx = await this.getCtx();
const token = await this.createToken();
const resp = await ctx.get(
`/api/v1/repos/${owner}/${name}/branches/${branch}`,
{
headers: { Authorization: `token ${token}` },
failOnStatusCode: false,
},
);
if (!resp.ok()) return null;
return resp.json();
}
/** Get file content from a repo. */
async getFileContent(
owner: string,
name: string,
filePath: string,
ref?: string,
): Promise<string | null> {
const ctx = await this.getCtx();
const token = await this.createToken();
const refQuery = ref ? `?ref=${encodeURIComponent(ref)}` : "";
const resp = await ctx.get(
`/api/v1/repos/${owner}/${name}/raw/${filePath}${refQuery}`,
{
headers: { Authorization: `token ${token}` },
failOnStatusCode: false,
},
);
if (!resp.ok()) return null;
return resp.text();
}
/** Get a commit by SHA. */
async getCommit(
owner: string,
name: string,
sha: string,
): Promise<any | null> {
const ctx = await this.getCtx();
const token = await this.createToken();
const resp = await ctx.get(
`/api/v1/repos/${owner}/${name}/git/commits/${sha}`,
{
headers: { Authorization: `token ${token}` },
failOnStatusCode: false,
},
);
if (!resp.ok()) return null;
return resp.json();
}
/** Trigger mirror sync for a repo via the Gitea API directly. */
async triggerMirrorSync(owner: string, name: string): Promise<boolean> {
const ctx = await this.getCtx();
const token = await this.createToken();
const resp = await ctx.post(
`/api/v1/repos/${owner}/${name}/mirror-sync`,
{
headers: { Authorization: `token ${token}` },
failOnStatusCode: false,
},
);
return resp.ok() || resp.status() === 200;
}
/** Delete a repo on Gitea directly (the app is not told). */
async deleteRepo(owner: string, name: string): Promise<boolean> {
const ctx = await this.getCtx();
const token = await this.createToken();
const resp = await ctx.delete(`/api/v1/repos/${owner}/${name}`, {
headers: { Authorization: `token ${token}` },
failOnStatusCode: false,
});
return resp.ok() || resp.status() === 404;
}
getTokenValue(): string {
return this.token;
}
}
// ─── App auth helpers ────────────────────────────────────────────────────────
/**
* POST to a Better Auth endpoint, waiting out its rate limit. Sign-in and
* sign-up allow 3 requests per 10 seconds in production, which specs that
* run back to back can exceed; a 429 names the wait in X-Retry-After.
*/
async function postAuth(
request: APIRequestContext,
path: string,
data: Record<string, unknown>,
): Promise<Awaited<ReturnType<APIRequestContext["post"]>>> {
for (let attempt = 1; ; attempt++) {
const resp = await request.post(`${APP_URL}/api/auth${path}`, {
data,
failOnStatusCode: false,
});
if (resp.status() !== 429 || attempt === 3) return resp;
const waitSeconds = Number(resp.headers()["x-retry-after"]) || 10;
console.log(`[App] ${path} rate limited, retrying in ${waitSeconds}s`);
await new Promise((r) => setTimeout(r, (Math.min(waitSeconds, 15) + 0.5) * 1000));
}
}
/**
* Sign up + sign in to the gitea-mirror app using the Better Auth REST API
* and return the session cookie string.
*/
export async function getAppSessionCookies(
request: APIRequestContext,
): Promise<string> {
// 1. Try sign-in first (user may already exist from a previous test / run)
const signInResp = await postAuth(request, "/sign-in/email", {
email: APP_USER_EMAIL,
password: APP_USER_PASS,
});
if (signInResp.ok()) {
const cookies = extractSetCookies(signInResp);
if (cookies) {
console.log("[App] Signed in (existing user)");
return cookies;
}
}
// 2. Register
const signUpResp = await postAuth(request, "/sign-up/email", {
name: APP_USER_NAME,
email: APP_USER_EMAIL,
password: APP_USER_PASS,
});
const signUpStatus = signUpResp.status();
console.log(`[App] Sign-up response: ${signUpStatus}`);
// After sign-up Better Auth may already set a session cookie
const signUpCookies = extractSetCookies(signUpResp);
if (signUpCookies) {
console.log("[App] Got session from sign-up response");
return signUpCookies;
}
// 3. Sign in after registration
const postRegSignIn = await postAuth(request, "/sign-in/email", {
email: APP_USER_EMAIL,
password: APP_USER_PASS,
});
if (!postRegSignIn.ok()) {
const body = await postRegSignIn.text();
throw new Error(
`Sign-in after registration failed (${postRegSignIn.status()}): ${body}`,
);
}
const cookies = extractSetCookies(postRegSignIn);
if (!cookies) {
throw new Error("Sign-in succeeded but no session cookie was returned");
}
console.log("[App] Signed in (after registration)");
return cookies;
}
/**
* Extract session cookies from a response's `set-cookie` headers.
*/
export function extractSetCookies(
resp: Awaited<ReturnType<APIRequestContext["post"]>>,
): string {
const raw = resp
.headersArray()
.filter((h) => h.name.toLowerCase() === "set-cookie");
if (raw.length === 0) return "";
const pairs: string[] = [];
for (const header of raw) {
const nv = header.value.split(";")[0].trim();
if (nv) pairs.push(nv);
}
return pairs.join("; ");
}
/**
* Sign in via the browser UI so the browser context gets session cookies.
*/
export async function signInViaBrowser(page: Page): Promise<string> {
const signInResp = await postAuth(page.request, "/sign-in/email", {
email: APP_USER_EMAIL,
password: APP_USER_PASS,
});
if (!signInResp.ok()) {
const signUpResp = await postAuth(page.request, "/sign-up/email", {
name: APP_USER_NAME,
email: APP_USER_EMAIL,
password: APP_USER_PASS,
});
console.log(`[Browser] Sign-up status: ${signUpResp.status()}`);
const retryResp = await postAuth(page.request, "/sign-in/email", {
email: APP_USER_EMAIL,
password: APP_USER_PASS,
});
if (!retryResp.ok()) {
console.log(`[Browser] Sign-in retry failed: ${retryResp.status()}`);
}
}
await page.goto(`${APP_URL}/`);
await page.waitForLoadState("networkidle");
const url = page.url();
console.log(`[Browser] After sign-in, URL: ${url}`);
const cookies = await page.context().cookies();
return cookies.map((c) => `${c.name}=${c.value}`).join("; ");
}
// ─── Config helper ───────────────────────────────────────────────────────────
/** Save app config via the API. */
export async function saveConfig(
request: APIRequestContext,
giteaToken: string,
cookies: string,
overrides: Record<string, any> = {},
): Promise<void> {
const giteaConfigDefaults = {
url: GITEA_URL,
username: GITEA_ADMIN_USER,
token: giteaToken,
organization: GITEA_MIRROR_ORG,
visibility: "public",
starredReposOrg: "github-stars",
preserveOrgStructure: false,
mirrorStrategy: "single-org",
backupStrategy: "disabled",
blockSyncOnBackupFailure: false,
};
const configPayload = {
githubConfig: {
username: "e2e-test-user",
token: "fake-github-token-for-e2e",
privateRepositories: false,
mirrorStarred: true,
},
giteaConfig: { ...giteaConfigDefaults, ...(overrides.giteaConfig || {}) },
scheduleConfig: {
enabled: false,
interval: 3600,
},
cleanupConfig: {
enabled: false,
retentionDays: 86400,
deleteIfNotInGitHub: false,
orphanedRepoAction: "skip",
dryRun: true,
},
mirrorOptions: {
mirrorReleases: false,
mirrorLFS: false,
mirrorMetadata: false,
metadataComponents: {
issues: false,
pullRequests: false,
labels: false,
milestones: false,
wiki: false,
},
},
advancedOptions: {
skipForks: false,
starredCodeOnly: false,
},
};
const resp = await request.post(`${APP_URL}/api/config`, {
data: configPayload,
headers: {
"Content-Type": "application/json",
Cookie: cookies,
},
failOnStatusCode: false,
});
const status = resp.status();
console.log(`[App] Save config response: ${status}`);
if (status >= 400) {
const body = await resp.text();
console.log(`[App] Config error body: ${body}`);
}
expect(status, "Config save should not return server error").toBeLessThan(
500,
);
}
// ─── Dashboard / repo helpers ────────────────────────────────────────────────
/**
* Fetch the list of repository IDs from the app's dashboard API.
* Optionally filter to repos with a given status.
*/
export async function getRepositoryIds(
request: APIRequestContext,
cookies: string,
opts?: { status?: string },
): Promise<{ ids: string[]; repos: any[] }> {
const dashResp = await request.get(`${APP_URL}/api/dashboard`, {
headers: { Cookie: cookies },
failOnStatusCode: false,
});
if (!dashResp.ok()) return { ids: [], repos: [] };
const dashData = await dashResp.json();
const repos: any[] = dashData.repositories ?? dashData.repos ?? [];
const filtered = opts?.status
? repos.filter((r: any) => r.status === opts.status)
: repos;
return {
ids: filtered.map((r: any) => r.id),
repos: filtered,
};
}
/**
* Trigger mirror jobs for the given repository IDs via the app API,
* then wait for a specified delay for async processing.
*/
export async function triggerMirrorJobs(
request: APIRequestContext,
cookies: string,
repositoryIds: string[],
waitMs = 30_000,
): Promise<number> {
const mirrorResp = await request.post(`${APP_URL}/api/job/mirror-repo`, {
headers: {
"Content-Type": "application/json",
Cookie: cookies,
},
data: { repositoryIds },
failOnStatusCode: false,
});
const status = mirrorResp.status();
if (waitMs > 0) {
await new Promise((r) => setTimeout(r, waitMs));
}
return status;
}
/**
* Trigger sync-repo (re-sync already-mirrored repos) for the given
* repository IDs, then wait for processing.
*/
export async function triggerSyncRepo(
request: APIRequestContext,
cookies: string,
repositoryIds: string[],
waitMs = 25_000,
): Promise<number> {
const syncResp = await request.post(`${APP_URL}/api/job/sync-repo`, {
headers: {
"Content-Type": "application/json",
Cookie: cookies,
},
data: { repositoryIds },
failOnStatusCode: false,
});
const status = syncResp.status();
if (waitMs > 0) {
await new Promise((r) => setTimeout(r, waitMs));
}
return status;
}