Files
gitea-mirror/tests/e2e/04-force-push.spec.ts
ARUNAVO RAY 98da7065e0 feat: smart force-push protection with backup strategies (#206)
* 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
2026-03-02 15:48:59 +05:30

865 lines
29 KiB
TypeScript
Raw Permalink 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.

/**
* 04 Force-push simulation and backup verification.
*
* This is the critical test that proves data loss can happen from a
* force-push on the source repo, and verifies that the backup system
* (when enabled) preserves the old state.
*
* Scenario:
* 1. Confirm my-project is already mirrored with known commits / content
* 2. Record the pre-force-push state (branch SHAs, commit messages, file content)
* 3. Rewrite history in the source bare repo (simulate a force-push)
* 4. Trigger Gitea mirror-sync WITHOUT backup
* 5. Verify Gitea now reflects the rewritten history — old commits are GONE
* 6. Restore the source repo, re-mirror, then enable backup
* 7. Force-push again and sync WITH backup enabled
* 8. Verify backup activity was recorded (snapshot attempted before sync)
*
* The source bare repos live on the host filesystem at
* tests/e2e/git-repos/<owner>/<name>.git and are served read-only into the
* git-server container. Because the bind-mount is :ro in docker-compose,
* we modify the repos on the host and Gitea's dumb-HTTP clone picks up
* the changes on the next fetch.
*
* Prerequisites: 02-mirror-workflow.spec.ts must have run first so that
* my-project is already mirrored into Gitea.
*/
import { execSync } from "node:child_process";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { test, expect } from "@playwright/test";
import {
APP_URL,
GITEA_URL,
GITEA_MIRROR_ORG,
GiteaAPI,
getAppSessionCookies,
saveConfig,
waitFor,
getRepositoryIds,
triggerSyncRepo,
} from "./helpers";
// ─── Paths ───────────────────────────────────────────────────────────────────
const E2E_DIR = resolve(dirname(fileURLToPath(import.meta.url)));
const GIT_REPOS_DIR = join(E2E_DIR, "git-repos");
const MY_PROJECT_BARE = join(GIT_REPOS_DIR, "e2e-test-user", "my-project.git");
// ─── Git helpers ─────────────────────────────────────────────────────────────
/** Run a git command in a given directory. */
function git(args: string, cwd: string): string {
try {
return execSync(`git ${args}`, {
cwd,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
env: {
...process.env,
GIT_AUTHOR_NAME: "Force Push Bot",
GIT_AUTHOR_EMAIL: "force-push@test.local",
GIT_COMMITTER_NAME: "Force Push Bot",
GIT_COMMITTER_EMAIL: "force-push@test.local",
},
}).trim();
} catch (err: any) {
const stderr = err.stderr?.toString() ?? "";
const stdout = err.stdout?.toString() ?? "";
throw new Error(
`git ${args} failed in ${cwd}:\n${stderr || stdout || err.message}`,
);
}
}
/**
* Get the SHA of a ref in a bare repository.
* Uses `git rev-parse` so it works for branches and tags.
*/
function getRefSha(bareRepo: string, ref: string): string {
return git(`rev-parse ${ref}`, bareRepo);
}
/**
* Clone the bare repo to a temporary working copy, execute a callback that
* mutates the working copy, then force-push back to the bare repo and
* update server-info for dumb-HTTP serving.
*/
function mutateSourceRepo(
bareRepo: string,
tmpName: string,
mutate: (workDir: string) => void,
): void {
const tmpDir = join(GIT_REPOS_DIR, ".work-force-push", tmpName);
rmSync(tmpDir, { recursive: true, force: true });
mkdirSync(join(GIT_REPOS_DIR, ".work-force-push"), { recursive: true });
try {
// Clone from the bare repo
git(`clone "${bareRepo}" "${tmpDir}"`, GIT_REPOS_DIR);
git("config user.name 'Force Push Bot'", tmpDir);
git("config user.email 'force-push@test.local'", tmpDir);
// Let the caller rewrite history
mutate(tmpDir);
// Force-push all refs back to the bare repo
git(`push --force --all "${bareRepo}"`, tmpDir);
git(`push --force --tags "${bareRepo}"`, tmpDir);
// Update server-info so the dumb-HTTP server picks up the new refs
git("update-server-info", bareRepo);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
}
/** Helper to clean up the temporary working directory. */
function cleanupWorkDir(): void {
const workDir = join(GIT_REPOS_DIR, ".work-force-push");
rmSync(workDir, { recursive: true, force: true });
}
// ─── Tests ───────────────────────────────────────────────────────────────────
test.describe("E2E: Force-push simulation", () => {
let giteaApi: GiteaAPI;
let appCookies = "";
/** SHA of the main branch BEFORE we force-push. */
let originalMainSha = "";
/** The commit message of the HEAD commit before force-push. */
let originalHeadMessage = "";
/** Content of README.md before force-push. */
let originalReadmeContent = "";
/** Number of commits on main before force-push. */
let originalCommitCount = 0;
test.beforeAll(async () => {
giteaApi = new GiteaAPI(GITEA_URL);
try {
await giteaApi.createToken();
} catch {
console.log("[ForcePush] Could not create Gitea token");
}
});
test.afterAll(async () => {
cleanupWorkDir();
await giteaApi.dispose();
});
// ── F0: Preconditions ────────────────────────────────────────────────────
test("F0: Confirm my-project is mirrored and record its state", async ({
request,
}) => {
// Verify the source bare repo exists on the host
expect(
existsSync(MY_PROJECT_BARE),
`Bare repo should exist at ${MY_PROJECT_BARE}`,
).toBeTruthy();
// Verify it is mirrored in Gitea
const repo = await giteaApi.getRepo(GITEA_MIRROR_ORG, "my-project");
expect(repo, "my-project should exist in Gitea").toBeTruthy();
console.log(
`[ForcePush] my-project in Gitea: mirror=${repo.mirror}, ` +
`default_branch=${repo.default_branch}`,
);
// Record the current state of main in Gitea
const mainBranch = await giteaApi.getBranch(
GITEA_MIRROR_ORG,
"my-project",
"main",
);
expect(mainBranch, "main branch should exist").toBeTruthy();
originalMainSha = mainBranch.commit.id;
originalHeadMessage =
mainBranch.commit.message?.trim() ?? "(unknown message)";
console.log(
`[ForcePush] Original main HEAD: ${originalMainSha.substring(0, 12)} ` +
`"${originalHeadMessage}"`,
);
// Record commit count
const commits = await giteaApi.listCommits(GITEA_MIRROR_ORG, "my-project", {
limit: 50,
});
originalCommitCount = commits.length;
console.log(
`[ForcePush] Original commit count on main: ${originalCommitCount}`,
);
// Record README content
const readme = await giteaApi.getFileContent(
GITEA_MIRROR_ORG,
"my-project",
"README.md",
);
originalReadmeContent = readme ?? "";
expect(originalReadmeContent).toContain("My Project");
console.log(
`[ForcePush] Original README length: ${originalReadmeContent.length} chars`,
);
// Also verify the source bare repo matches
const sourceSha = getRefSha(MY_PROJECT_BARE, "refs/heads/main");
console.log(
`[ForcePush] Source bare main SHA: ${sourceSha.substring(0, 12)}`,
);
// They may differ slightly if Gitea hasn't synced the very latest, but
// the important thing is that both exist.
});
// ── F1: Rewrite history on the source repo ───────────────────────────────
test("F1: Force-push rewritten history to source repo", async () => {
const shaBeforeRewrite = getRefSha(MY_PROJECT_BARE, "refs/heads/main");
console.log(
`[ForcePush] Source main before rewrite: ${shaBeforeRewrite.substring(0, 12)}`,
);
mutateSourceRepo(MY_PROJECT_BARE, "my-project-rewrite", (workDir) => {
// We're on the main branch.
// Rewrite history: remove the last commit (the LICENSE commit) via
// reset --hard HEAD~1, then add a completely different commit.
git("checkout main", workDir);
// Record what HEAD is for logging
const headBefore = git("log --oneline -1", workDir);
console.log(`[ForcePush] Working copy HEAD before reset: ${headBefore}`);
// Hard reset to remove the last commit (this drops "Add MIT license")
git("reset --hard HEAD~1", workDir);
const headAfterReset = git("log --oneline -1", workDir);
console.log(`[ForcePush] After reset HEAD~1: ${headAfterReset}`);
// Write a replacement commit with different content (simulates someone
// rewriting history with different changes)
writeFileSync(
join(workDir, "README.md"),
"# My Project\n\nThis README was FORCE-PUSHED.\n\nOriginal history has been rewritten.\n",
);
writeFileSync(
join(workDir, "FORCE_PUSH_MARKER.txt"),
`Force-pushed at ${new Date().toISOString()}\n`,
);
git("add -A", workDir);
execSync('git commit -m "FORCE PUSH: Rewritten history"', {
cwd: workDir,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
env: {
...process.env,
GIT_AUTHOR_NAME: "Force Push Bot",
GIT_AUTHOR_EMAIL: "force-push@test.local",
GIT_AUTHOR_DATE: "2024-06-15T12:00:00+00:00",
GIT_COMMITTER_NAME: "Force Push Bot",
GIT_COMMITTER_EMAIL: "force-push@test.local",
GIT_COMMITTER_DATE: "2024-06-15T12:00:00+00:00",
},
});
const headAfterRewrite = git("log --oneline -3", workDir);
console.log(`[ForcePush] After rewrite (last 3):\n${headAfterRewrite}`);
});
const shaAfterRewrite = getRefSha(MY_PROJECT_BARE, "refs/heads/main");
console.log(
`[ForcePush] Source main after rewrite: ${shaAfterRewrite.substring(0, 12)}`,
);
// The SHA must have changed — this proves the force-push happened
expect(
shaAfterRewrite,
"Source repo main SHA should change after force-push",
).not.toBe(originalMainSha);
// Verify the old SHA is no longer reachable on main
const logOutput = git("log --oneline main", MY_PROJECT_BARE);
expect(
logOutput,
"Rewritten history should NOT contain the old head commit",
).toContain("FORCE PUSH");
});
// ── F2: Sync to Gitea WITHOUT backup ─────────────────────────────────────
test("F2: Disable backup and sync force-pushed repo to Gitea", async ({
request,
}) => {
appCookies = await getAppSessionCookies(request);
const giteaToken = giteaApi.getTokenValue();
expect(giteaToken).toBeTruthy();
// Ensure backup is disabled for this test
await saveConfig(request, giteaToken, appCookies, {
giteaConfig: {
backupStrategy: "disabled",
blockSyncOnBackupFailure: false,
},
});
console.log("[ForcePush] Backup disabled for unprotected sync test");
// Trigger Gitea's mirror-sync directly via the Gitea API.
// This is more reliable than going through the app for this test because
// the app's sync-repo endpoint involves extra processing. We want to test
// the raw effect of Gitea pulling the rewritten refs.
const synced = await giteaApi.triggerMirrorSync(
GITEA_MIRROR_ORG,
"my-project",
);
console.log(`[ForcePush] Gitea mirror-sync triggered: ${synced}`);
// Wait for Gitea to pull the new refs from the git-server
console.log("[ForcePush] Waiting for Gitea to pull rewritten refs...");
await new Promise((r) => setTimeout(r, 15_000));
});
// ── F3: Verify Gitea reflects the rewritten history ──────────────────────
test("F3: Verify Gitea has the force-pushed content (old history GONE)", async () => {
// Poll until Gitea picks up the new HEAD
await waitFor(
async () => {
const branch = await giteaApi.getBranch(
GITEA_MIRROR_ORG,
"my-project",
"main",
);
if (!branch) return false;
return branch.commit.id !== originalMainSha;
},
{
timeout: 60_000,
interval: 5_000,
label: "Gitea main branch updates to new SHA",
},
);
// Read the new state
const newMainBranch = await giteaApi.getBranch(
GITEA_MIRROR_ORG,
"my-project",
"main",
);
expect(newMainBranch).toBeTruthy();
const newSha = newMainBranch.commit.id;
const newMsg = newMainBranch.commit.message?.trim() ?? "";
console.log(
`[ForcePush] New main HEAD: ${newSha.substring(0, 12)} "${newMsg}"`,
);
// The SHA MUST be different from the original
expect(
newSha,
"Gitea main SHA should have changed after force-push sync",
).not.toBe(originalMainSha);
// The new commit message should be the force-pushed one
expect(newMsg).toContain("FORCE PUSH");
// Verify the force-push marker file now exists in Gitea
const markerContent = await giteaApi.getFileContent(
GITEA_MIRROR_ORG,
"my-project",
"FORCE_PUSH_MARKER.txt",
);
expect(
markerContent,
"FORCE_PUSH_MARKER.txt should appear after sync",
).toBeTruthy();
console.log(
`[ForcePush] Marker file present: ${markerContent?.substring(0, 40)}...`,
);
// Verify the README was overwritten
const newReadme = await giteaApi.getFileContent(
GITEA_MIRROR_ORG,
"my-project",
"README.md",
);
expect(newReadme).toContain("FORCE-PUSHED");
expect(newReadme).not.toBe(originalReadmeContent);
console.log("[ForcePush] README.md confirms overwritten content");
// Verify the LICENSE file is GONE (it was in the dropped commit)
const licenseContent = await giteaApi.getFileContent(
GITEA_MIRROR_ORG,
"my-project",
"LICENSE",
);
expect(
licenseContent,
"LICENSE should be GONE after force-push removed that commit",
).toBeNull();
console.log("[ForcePush] ✗ LICENSE file is GONE — data loss confirmed");
// Verify the old commit SHA is no longer accessible
const oldCommit = await giteaApi.getCommit(
GITEA_MIRROR_ORG,
"my-project",
originalMainSha,
);
// Gitea may or may not GC the unreachable commit immediately, so this
// is informational rather than a hard assertion.
if (oldCommit) {
console.log(
`[ForcePush] Old commit ${originalMainSha.substring(0, 12)} is ` +
`still in Gitea's object store (not yet GC'd)`,
);
} else {
console.log(
`[ForcePush] Old commit ${originalMainSha.substring(0, 12)} is ` +
`no longer accessible — data loss complete`,
);
}
// Check commit count changed
const newCommits = await giteaApi.listCommits(
GITEA_MIRROR_ORG,
"my-project",
{ limit: 50 },
);
console.log(
`[ForcePush] Commit count: was ${originalCommitCount}, now ${newCommits.length}`,
);
// The rewrite dropped one commit and added one, so the count should differ
// or at minimum the commit list should not contain the old head message.
const commitMessages = newCommits.map(
(c: any) => c.commit?.message?.trim() ?? "",
);
expect(
commitMessages.some((m: string) => m.includes("FORCE PUSH")),
"New commit list should contain the force-pushed commit",
).toBeTruthy();
console.log(
"\n[ForcePush] ════════════════════════════════════════════════════",
);
console.log(
"[ForcePush] CONFIRMED: Force-push without backup = DATA LOSS",
);
console.log(
"[ForcePush] The LICENSE file and original HEAD commit are gone.",
);
console.log(
"[ForcePush] ════════════════════════════════════════════════════\n",
);
});
// ── F4: Restore source, re-mirror, then test WITH backup ─────────────────
test("F4: Restore source repo to a good state and re-mirror", async ({
request,
}) => {
// To test the backup path we need a clean slate. Re-create the original
// my-project content in the source repo so it has known good history.
mutateSourceRepo(MY_PROJECT_BARE, "my-project-restore", (workDir) => {
git("checkout main", workDir);
// Remove the force-push marker
try {
execSync("rm -f FORCE_PUSH_MARKER.txt", { cwd: workDir });
} catch {
// may not exist
}
// Restore README
writeFileSync(
join(workDir, "README.md"),
"# My Project\n\nA sample project for E2E testing.\n\n" +
"## Features\n- Greeting module\n- Math utilities\n",
);
// Restore LICENSE
writeFileSync(
join(workDir, "LICENSE"),
"MIT License\n\nCopyright (c) 2024 E2E Test\n",
);
git("add -A", workDir);
execSync(
'git commit -m "Restore original content after force-push test"',
{
cwd: workDir,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
env: {
...process.env,
GIT_AUTHOR_NAME: "E2E Test Bot",
GIT_AUTHOR_EMAIL: "e2e-bot@test.local",
GIT_COMMITTER_NAME: "E2E Test Bot",
GIT_COMMITTER_EMAIL: "e2e-bot@test.local",
},
},
);
const newHead = git("log --oneline -1", workDir);
console.log(`[ForcePush] Restored source HEAD: ${newHead}`);
});
// Sync Gitea to pick up the restored state
const synced = await giteaApi.triggerMirrorSync(
GITEA_MIRROR_ORG,
"my-project",
);
console.log(`[ForcePush] Gitea mirror-sync for restore: ${synced}`);
await new Promise((r) => setTimeout(r, 15_000));
// Verify Gitea has the restored content
await waitFor(
async () => {
const readme = await giteaApi.getFileContent(
GITEA_MIRROR_ORG,
"my-project",
"README.md",
);
return readme !== null && readme.includes("Features");
},
{
timeout: 60_000,
interval: 5_000,
label: "Gitea picks up restored content",
},
);
const license = await giteaApi.getFileContent(
GITEA_MIRROR_ORG,
"my-project",
"LICENSE",
);
expect(license, "LICENSE should be restored").toBeTruthy();
console.log("[ForcePush] Gitea restored to good state");
// Record the new "good" SHA for the next force-push test
const restoredBranch = await giteaApi.getBranch(
GITEA_MIRROR_ORG,
"my-project",
"main",
);
originalMainSha = restoredBranch.commit.id;
console.log(
`[ForcePush] Restored main SHA: ${originalMainSha.substring(0, 12)}`,
);
});
// ── F5: Force-push AGAIN, this time with backup enabled ──────────────────
test("F5: Enable backup, force-push, and sync", async ({ request }) => {
if (!appCookies) {
appCookies = await getAppSessionCookies(request);
}
const giteaToken = giteaApi.getTokenValue();
// Enable backup with "always" strategy
await saveConfig(request, giteaToken, appCookies, {
giteaConfig: {
backupStrategy: "always",
blockSyncOnBackupFailure: false, // don't block — we want to see both backup + sync happen
backupRetentionCount: 5,
backupDirectory: "data/repo-backups",
},
});
console.log("[ForcePush] Backup enabled (strategy=always) for protected sync test");
// Force-push again
mutateSourceRepo(MY_PROJECT_BARE, "my-project-rewrite2", (workDir) => {
git("checkout main", workDir);
writeFileSync(
join(workDir, "README.md"),
"# My Project\n\nSECOND FORCE-PUSH — backup should have preserved old state.\n",
);
writeFileSync(
join(workDir, "SECOND_FORCE_PUSH.txt"),
`Second force-push at ${new Date().toISOString()}\n`,
);
// Remove LICENSE again to simulate destructive rewrite
try {
execSync("rm -f LICENSE", { cwd: workDir });
} catch {
// may not exist
}
git("add -A", workDir);
execSync('git commit -m "SECOND FORCE PUSH: backup should catch this"', {
cwd: workDir,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
env: {
...process.env,
GIT_AUTHOR_NAME: "Force Push Bot",
GIT_AUTHOR_EMAIL: "force-push@test.local",
GIT_COMMITTER_NAME: "Force Push Bot",
GIT_COMMITTER_EMAIL: "force-push@test.local",
},
});
});
console.log("[ForcePush] Second force-push applied to source repo");
// Use the app's sync-repo to trigger the sync (this goes through
// syncGiteaRepoEnhanced which runs the backup code path)
const { ids: repoIds } = await getRepositoryIds(request, appCookies);
// Find the my-project repo ID
const dashResp = await request.get(`${APP_URL}/api/dashboard`, {
headers: { Cookie: appCookies },
failOnStatusCode: false,
});
let myProjectId = "";
if (dashResp.ok()) {
const data = await dashResp.json();
const repos: any[] = data.repositories ?? [];
const myProj = repos.find((r: any) => r.name === "my-project");
if (myProj) myProjectId = myProj.id;
}
if (myProjectId) {
console.log(
`[ForcePush] Triggering app sync-repo for my-project (${myProjectId})`,
);
const status = await triggerSyncRepo(
request,
appCookies,
[myProjectId],
25_000,
);
console.log(`[ForcePush] App sync-repo response: ${status}`);
} else {
// Fallback: trigger via Gitea API directly
console.log(
"[ForcePush] Could not find my-project ID, using Gitea API directly",
);
await giteaApi.triggerMirrorSync(GITEA_MIRROR_ORG, "my-project");
await new Promise((r) => setTimeout(r, 15_000));
}
});
// ── F6: Verify Gitea picked up the second force-push ─────────────────────
test("F6: Verify Gitea reflects second force-push", async () => {
await waitFor(
async () => {
const branch = await giteaApi.getBranch(
GITEA_MIRROR_ORG,
"my-project",
"main",
);
if (!branch) return false;
return branch.commit.id !== originalMainSha;
},
{
timeout: 60_000,
interval: 5_000,
label: "Gitea main branch updates after second force-push",
},
);
const newBranch = await giteaApi.getBranch(
GITEA_MIRROR_ORG,
"my-project",
"main",
);
const newSha = newBranch.commit.id;
console.log(
`[ForcePush] After 2nd force-push: main=${newSha.substring(0, 12)}, ` +
`msg="${newBranch.commit.message?.trim()}"`,
);
expect(newSha).not.toBe(originalMainSha);
// Verify the second force-push marker
const marker = await giteaApi.getFileContent(
GITEA_MIRROR_ORG,
"my-project",
"SECOND_FORCE_PUSH.txt",
);
expect(marker, "Second force-push marker should exist").toBeTruthy();
// LICENSE should be gone again
const license = await giteaApi.getFileContent(
GITEA_MIRROR_ORG,
"my-project",
"LICENSE",
);
expect(license, "LICENSE gone again after 2nd force-push").toBeNull();
console.log("[ForcePush] Second force-push verified in Gitea");
});
// ── F7: Verify backup activity was logged for the second force-push ──────
test("F7: Verify backup activity was recorded for protected sync", 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(
`[ForcePush] Could not fetch activities: ${activitiesResp.status()}`,
);
return;
}
const activities = await activitiesResp.json();
const jobs: any[] = Array.isArray(activities)
? activities
: (activities.jobs ?? activities.activities ?? []);
// Filter to backup/snapshot entries for my-project
const backupJobs = jobs.filter(
(j: any) =>
(j.repositoryName === "my-project" ||
j.repositoryName === "my-project") &&
(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(
`[ForcePush] Backup activity for my-project: ${backupJobs.length} entries`,
);
for (const j of backupJobs) {
console.log(
`[ForcePush] • [${j.status}] ${j.message ?? ""} | ${(j.details ?? "").substring(0, 100)}`,
);
}
// The backup system should have been invoked and must succeed.
expect(
backupJobs.length,
"At least one backup/snapshot activity should exist for my-project " +
"when backupStrategy is 'always'",
).toBeGreaterThan(0);
// Check whether any backups actually succeeded
const successfulBackups = backupJobs.filter(
(j: any) =>
j.status === "syncing" ||
j.message?.includes("Snapshot created") ||
j.details?.includes("Pre-sync snapshot created"),
);
const failedBackups = backupJobs.filter(
(j: any) =>
j.status === "failed" &&
(j.message?.includes("Snapshot failed") ||
j.details?.includes("snapshot failed")),
);
if (successfulBackups.length > 0) {
console.log(
`[ForcePush] ✓ ${successfulBackups.length} backup(s) SUCCEEDED — ` +
`old state was preserved in bundle`,
);
}
if (failedBackups.length > 0) {
console.log(
`[ForcePush] ⚠ ${failedBackups.length} backup(s) FAILED`,
);
// Extract and log the first failure reason for visibility
const firstFailure = failedBackups[0];
console.log(
`[ForcePush] Failure reason: ${firstFailure.details?.substring(0, 200)}`,
);
}
console.log(
"[ForcePush] ════════════════════════════════════════════════════",
);
if (successfulBackups.length > 0) {
console.log(
"[ForcePush] RESULT: Backup system PROTECTED against force-push",
);
} else {
console.log("[ForcePush] RESULT: Backup system was INVOKED but FAILED.");
}
console.log(
"[ForcePush] ════════════════════════════════════════════════════\n",
);
// Fail the test if any backups failed
expect(
failedBackups.length,
`Expected all backups to succeed, but ${failedBackups.length} backup(s) failed. ` +
`First failure: ${failedBackups[0]?.details || "unknown error"}`,
).toBe(0);
});
// ── F8: Restore source repo for subsequent test suites ───────────────────
test("F8: Restore source repo to clean state for other tests", async () => {
mutateSourceRepo(MY_PROJECT_BARE, "my-project-final-restore", (workDir) => {
git("checkout main", workDir);
// Remove force-push artifacts
try {
execSync("rm -f FORCE_PUSH_MARKER.txt SECOND_FORCE_PUSH.txt", {
cwd: workDir,
});
} catch {
// ignore
}
// Restore content
writeFileSync(
join(workDir, "README.md"),
"# My Project\n\nA sample project for E2E testing.\n\n" +
"## Features\n- Greeting module\n- Math utilities\n",
);
writeFileSync(
join(workDir, "LICENSE"),
"MIT License\n\nCopyright (c) 2024 E2E Test\n",
);
git("add -A", workDir);
execSync(
'git commit --allow-empty -m "Final restore after force-push tests"',
{
cwd: workDir,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
env: {
...process.env,
GIT_AUTHOR_NAME: "E2E Test Bot",
GIT_AUTHOR_EMAIL: "e2e-bot@test.local",
GIT_COMMITTER_NAME: "E2E Test Bot",
GIT_COMMITTER_EMAIL: "e2e-bot@test.local",
},
},
);
});
// Sync Gitea
await giteaApi.triggerMirrorSync(GITEA_MIRROR_ORG, "my-project");
await new Promise((r) => setTimeout(r, 10_000));
// Verify restoration
const license = await giteaApi.getFileContent(
GITEA_MIRROR_ORG,
"my-project",
"LICENSE",
);
if (license) {
console.log("[ForcePush] Source repo restored for subsequent tests");
} else {
console.log(
"[ForcePush] Warning: restoration may not have synced yet (Gitea async)",
);
}
});
});