mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-03-14 06:23:01 +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
865 lines
29 KiB
TypeScript
865 lines
29 KiB
TypeScript
/**
|
||
* 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)",
|
||
);
|
||
}
|
||
});
|
||
});
|