mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-03-13 22:12:54 +03:00
* feat: smart force-push protection with backup strategies (#187) Replace blunt `backupBeforeSync` boolean with `backupStrategy` enum offering four modes: disabled, always, on-force-push (default), and block-on-force-push. This dramatically reduces backup storage for large mirror collections by only creating snapshots when force-pushes are actually detected. Detection works by comparing branch SHAs between Gitea and GitHub APIs before each sync — no git cloning required. Fail-open design ensures detection errors never block sync. Key changes: - Add force-push detection module (branch SHA comparison via APIs) - Add backup strategy resolver with backward-compat migration - Add pending-approval repo status with approve/dismiss UI + API - Add block-on-force-push mode requiring manual approval - Fix checkAncestry to only treat 404 as confirmed force-push (transient errors skip branch instead of false-positive blocking) - Fix approve-sync to bypass detection gate (skipForcePushDetection) - Fix backup execution to not be hard-gated by deprecated flag - Persist backupStrategy through config-mapper round-trip * fix: resolve four bugs in smart force-push protection P0: Approve flow re-blocks itself — approve-sync now calls syncGiteaRepoEnhanced with skipForcePushDetection: true so the detection+block gate is bypassed on approved syncs. P1: backupStrategy not persisted — added to both directions of the config-mapper. Don't inject a default in the mapper; let resolveBackupStrategy handle fallback so legacy backupBeforeSync still works for E2E tests and existing configs. P1: Backup hard-gated by deprecated backupBeforeSync — added force flag to createPreSyncBundleBackup; strategy-driven callers and approve-sync pass force: true to bypass the legacy guard. P1: checkAncestry false positives — now only returns false for 404/422 (confirmed force-push). Transient errors (rate limits, 500s) are rethrown so detectForcePush skips that branch (fail-open). * test(e2e): migrate backup tests from backupBeforeSync to backupStrategy Update E2E tests to use the new backupStrategy enum ("always", "disabled") instead of the deprecated backupBeforeSync boolean. * docs: add backup strategy UI screenshot * refactor(ui): move Destructive Update Protection to GitHub config tab Relocates the backup strategy section from GiteaConfigForm to GitHubConfigForm since it protects against GitHub-side force-pushes. Adds ShieldAlert icon to match other section header patterns. * docs: add force-push protection documentation and Beta badge Add docs/FORCE_PUSH_PROTECTION.md covering detection mechanism, backup strategies, API usage, and troubleshooting. Link it from README features list and support section. Mark the feature as Beta in the UI with an outline badge. * fix(ui): match Beta badge style to Git LFS badge
306 lines
10 KiB
TypeScript
306 lines
10 KiB
TypeScript
/**
|
||
* 03 – Backup configuration tests.
|
||
*
|
||
* Exercises the pre-sync backup system by toggling config flags through
|
||
* the app API and triggering re-syncs on repos that were already mirrored
|
||
* by the 02-mirror-workflow suite.
|
||
*
|
||
* What is tested:
|
||
* B1. Enable backupStrategy: "always" in config
|
||
* B2. Confirm mirrored repos exist in Gitea (precondition)
|
||
* B3. Trigger a re-sync with backup enabled — verify the backup code path
|
||
* runs (snapshot activity entries appear in the activity log)
|
||
* B4. Inspect activity log for snapshot-related entries
|
||
* B5. Enable blockSyncOnBackupFailure and verify the flag is persisted
|
||
* B6. Disable backup (backupStrategy: "disabled") and verify config resets cleanly
|
||
*/
|
||
|
||
import { test, expect } from "@playwright/test";
|
||
import {
|
||
APP_URL,
|
||
GITEA_URL,
|
||
GITEA_MIRROR_ORG,
|
||
GiteaAPI,
|
||
getAppSessionCookies,
|
||
saveConfig,
|
||
getRepositoryIds,
|
||
triggerSyncRepo,
|
||
} from "./helpers";
|
||
|
||
test.describe("E2E: Backup configuration", () => {
|
||
let giteaApi: GiteaAPI;
|
||
let appCookies = "";
|
||
|
||
test.beforeAll(async () => {
|
||
giteaApi = new GiteaAPI(GITEA_URL);
|
||
try {
|
||
await giteaApi.createToken();
|
||
} catch {
|
||
console.log(
|
||
"[Backup] Could not create Gitea token; tests may be limited",
|
||
);
|
||
}
|
||
});
|
||
|
||
test.afterAll(async () => {
|
||
await giteaApi.dispose();
|
||
});
|
||
|
||
// ── B1 ─────────────────────────────────────────────────────────────────────
|
||
|
||
test("Step B1: Enable backup in config", async ({ request }) => {
|
||
appCookies = await getAppSessionCookies(request);
|
||
|
||
const giteaToken = giteaApi.getTokenValue();
|
||
expect(giteaToken, "Gitea token required").toBeTruthy();
|
||
|
||
// Save config with backup strategy set to "always"
|
||
await saveConfig(request, giteaToken, appCookies, {
|
||
giteaConfig: {
|
||
backupStrategy: "always",
|
||
blockSyncOnBackupFailure: false,
|
||
backupRetentionCount: 5,
|
||
backupDirectory: "data/repo-backups",
|
||
},
|
||
});
|
||
|
||
// Verify config was saved
|
||
const configResp = await request.get(`${APP_URL}/api/config`, {
|
||
headers: { Cookie: appCookies },
|
||
failOnStatusCode: false,
|
||
});
|
||
expect(configResp.status()).toBeLessThan(500);
|
||
|
||
if (configResp.ok()) {
|
||
const configData = await configResp.json();
|
||
const giteaCfg = configData.giteaConfig ?? configData.gitea ?? {};
|
||
console.log(
|
||
`[Backup] Config saved: backupStrategy=${giteaCfg.backupStrategy}, blockOnFailure=${giteaCfg.blockSyncOnBackupFailure}`,
|
||
);
|
||
}
|
||
});
|
||
|
||
// ── B2 ─────────────────────────────────────────────────────────────────────
|
||
|
||
test("Step B2: Verify mirrored repos exist in Gitea before backup test", async () => {
|
||
// We need repos to already be mirrored from the 02-mirror-workflow suite
|
||
const orgRepos = await giteaApi.listOrgRepos(GITEA_MIRROR_ORG);
|
||
console.log(
|
||
`[Backup] Repos in ${GITEA_MIRROR_ORG}: ${orgRepos.length} (${orgRepos.map((r: any) => r.name).join(", ")})`,
|
||
);
|
||
|
||
if (orgRepos.length === 0) {
|
||
console.log(
|
||
"[Backup] WARNING: No repos in Gitea yet. Backup test will verify " +
|
||
"job creation but not bundle creation.",
|
||
);
|
||
}
|
||
});
|
||
|
||
// ── B3 ─────────────────────────────────────────────────────────────────────
|
||
|
||
test("Step B3: Trigger re-sync with backup enabled", async ({ request }) => {
|
||
if (!appCookies) {
|
||
appCookies = await getAppSessionCookies(request);
|
||
}
|
||
|
||
// Fetch mirrored repository IDs (sync-repo requires them)
|
||
const { ids: repositoryIds, repos } = await getRepositoryIds(
|
||
request,
|
||
appCookies,
|
||
{ status: "mirrored" },
|
||
);
|
||
|
||
// Also include repos with "success" status
|
||
if (repositoryIds.length === 0) {
|
||
const { ids: successIds } = await getRepositoryIds(
|
||
request,
|
||
appCookies,
|
||
{ status: "success" },
|
||
);
|
||
repositoryIds.push(...successIds);
|
||
}
|
||
|
||
// Fall back to all repos if no mirrored/success repos
|
||
if (repositoryIds.length === 0) {
|
||
const { ids: allIds } = await getRepositoryIds(request, appCookies);
|
||
repositoryIds.push(...allIds);
|
||
}
|
||
|
||
console.log(
|
||
`[Backup] Found ${repositoryIds.length} repos to re-sync: ` +
|
||
repos.map((r: any) => r.name).join(", "),
|
||
);
|
||
|
||
expect(
|
||
repositoryIds.length,
|
||
"Need at least one repo to test backup",
|
||
).toBeGreaterThan(0);
|
||
|
||
// Trigger sync-repo — this calls syncGiteaRepoEnhanced which checks
|
||
// shouldCreatePreSyncBackup and creates bundles before syncing
|
||
const status = await triggerSyncRepo(
|
||
request,
|
||
appCookies,
|
||
repositoryIds,
|
||
25_000,
|
||
);
|
||
console.log(`[Backup] Sync-repo response: ${status}`);
|
||
expect(status, "Sync-repo should accept request").toBeLessThan(500);
|
||
});
|
||
|
||
// ── B4 ─────────────────────────────────────────────────────────────────────
|
||
|
||
test("Step B4: Verify backup-related activity in logs", async ({
|
||
request,
|
||
}) => {
|
||
if (!appCookies) {
|
||
appCookies = await getAppSessionCookies(request);
|
||
}
|
||
|
||
const activitiesResp = await request.get(`${APP_URL}/api/activities`, {
|
||
headers: { Cookie: appCookies },
|
||
failOnStatusCode: false,
|
||
});
|
||
|
||
if (!activitiesResp.ok()) {
|
||
console.log(
|
||
`[Backup] Could not fetch activities: ${activitiesResp.status()}`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
const activities = await activitiesResp.json();
|
||
const jobs: any[] = Array.isArray(activities)
|
||
? activities
|
||
: (activities.jobs ?? activities.activities ?? []);
|
||
|
||
// Look for backup / snapshot related messages
|
||
const backupJobs = jobs.filter(
|
||
(j: any) =>
|
||
j.message?.toLowerCase().includes("snapshot") ||
|
||
j.message?.toLowerCase().includes("backup") ||
|
||
j.details?.toLowerCase().includes("snapshot") ||
|
||
j.details?.toLowerCase().includes("backup") ||
|
||
j.details?.toLowerCase().includes("bundle"),
|
||
);
|
||
|
||
console.log(
|
||
`[Backup] Backup-related activity entries: ${backupJobs.length}`,
|
||
);
|
||
for (const j of backupJobs.slice(0, 10)) {
|
||
console.log(
|
||
`[Backup] • ${j.repositoryName ?? "?"}: ${j.status} — ${j.message ?? ""} | ${(j.details ?? "").substring(0, 120)}`,
|
||
);
|
||
}
|
||
|
||
// We expect at least some backup-related entries if repos were mirrored
|
||
const orgRepos = await giteaApi.listOrgRepos(GITEA_MIRROR_ORG);
|
||
if (orgRepos.length > 0) {
|
||
// With repos in Gitea, the backup system should have tried to create
|
||
// snapshots. All snapshots should succeed.
|
||
expect(
|
||
backupJobs.length,
|
||
"Expected at least one backup/snapshot activity entry when " +
|
||
"backupStrategy is 'always' and repos exist in Gitea",
|
||
).toBeGreaterThan(0);
|
||
|
||
// Check for any failed backups
|
||
const failedBackups = backupJobs.filter(
|
||
(j: any) =>
|
||
j.status === "failed" &&
|
||
(j.message?.toLowerCase().includes("snapshot") ||
|
||
j.details?.toLowerCase().includes("snapshot")),
|
||
);
|
||
expect(
|
||
failedBackups.length,
|
||
`Expected all backups to succeed, but ${failedBackups.length} backup(s) failed. ` +
|
||
`Failed: ${failedBackups.map((j: any) => `${j.repositoryName}: ${j.details?.substring(0, 100)}`).join("; ")}`,
|
||
).toBe(0);
|
||
|
||
console.log(
|
||
`[Backup] Confirmed: backup system was invoked for ${backupJobs.length} repos`,
|
||
);
|
||
}
|
||
|
||
// Dump all recent jobs for debugging visibility
|
||
console.log(`[Backup] All recent jobs (last 20):`);
|
||
for (const j of jobs.slice(0, 20)) {
|
||
console.log(
|
||
`[Backup] - [${j.status}] ${j.repositoryName ?? "?"}: ${j.message ?? ""} ` +
|
||
`${j.details ? `(${j.details.substring(0, 80)})` : ""}`,
|
||
);
|
||
}
|
||
});
|
||
|
||
// ── B5 ─────────────────────────────────────────────────────────────────────
|
||
|
||
test("Step B5: Enable blockSyncOnBackupFailure and verify behavior", async ({
|
||
request,
|
||
}) => {
|
||
if (!appCookies) {
|
||
appCookies = await getAppSessionCookies(request);
|
||
}
|
||
|
||
const giteaToken = giteaApi.getTokenValue();
|
||
|
||
// Update config to block sync on backup failure
|
||
await saveConfig(request, giteaToken, appCookies, {
|
||
giteaConfig: {
|
||
backupStrategy: "always",
|
||
blockSyncOnBackupFailure: true,
|
||
backupRetentionCount: 5,
|
||
backupDirectory: "data/repo-backups",
|
||
},
|
||
});
|
||
console.log("[Backup] Config updated: blockSyncOnBackupFailure=true");
|
||
|
||
// Verify the flag persisted
|
||
const configResp = await request.get(`${APP_URL}/api/config`, {
|
||
headers: { Cookie: appCookies },
|
||
failOnStatusCode: false,
|
||
});
|
||
if (configResp.ok()) {
|
||
const configData = await configResp.json();
|
||
const giteaCfg = configData.giteaConfig ?? configData.gitea ?? {};
|
||
expect(giteaCfg.blockSyncOnBackupFailure).toBe(true);
|
||
console.log(
|
||
`[Backup] Verified: blockSyncOnBackupFailure=${giteaCfg.blockSyncOnBackupFailure}`,
|
||
);
|
||
}
|
||
});
|
||
|
||
// ── B6 ─────────────────────────────────────────────────────────────────────
|
||
|
||
test("Step B6: Disable backup and verify config resets", async ({
|
||
request,
|
||
}) => {
|
||
if (!appCookies) {
|
||
appCookies = await getAppSessionCookies(request);
|
||
}
|
||
|
||
const giteaToken = giteaApi.getTokenValue();
|
||
|
||
// Disable backup
|
||
await saveConfig(request, giteaToken, appCookies, {
|
||
giteaConfig: {
|
||
backupStrategy: "disabled",
|
||
blockSyncOnBackupFailure: false,
|
||
},
|
||
});
|
||
|
||
const configResp = await request.get(`${APP_URL}/api/config`, {
|
||
headers: { Cookie: appCookies },
|
||
failOnStatusCode: false,
|
||
});
|
||
if (configResp.ok()) {
|
||
const configData = await configResp.json();
|
||
const giteaCfg = configData.giteaConfig ?? configData.gitea ?? {};
|
||
console.log(
|
||
`[Backup] After disable: backupStrategy=${giteaCfg.backupStrategy}`,
|
||
);
|
||
}
|
||
console.log("[Backup] Backup configuration test complete");
|
||
});
|
||
});
|