mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-04-10 21:18:17 +03:00
* fix: prevent excessive disk usage from repo backups (#234) Legacy configs with backupBeforeSync: true but no explicit backupStrategy silently resolved to "always", creating full git bundles on every sync cycle. This caused repo-backups to grow to 17GB+ for users with many repositories. Changes: - Fix resolveBackupStrategy to map backupBeforeSync: true → "on-force-push" instead of "always", so legacy configs only backup when force-push is detected - Fix config mapper to always set backupStrategy explicitly ("on-force-push") preventing the backward-compat fallback from triggering - Lower default backupRetentionCount from 20 to 5 bundles per repo - Add time-based retention (backupRetentionDays, default 30 days) alongside count-based retention, with safety net to always keep at least 1 bundle - Add "high disk usage" warning on "Always Backup" UI option - Update docs and tests to reflect new defaults and behavior * fix: preserve legacy backupBeforeSync:false on UI round-trip and expose retention days P1: mapDbToUiConfig now checks backupBeforeSync === false before defaulting backupStrategy, preventing legacy "disabled" configs from silently becoming "on-force-push" after any auto-save round-trip. P3: Added "Snapshot retention days" input field to the backup settings UI, matching the documented setting in FORCE_PUSH_PROTECTION.md.
249 lines
7.7 KiB
TypeScript
249 lines
7.7 KiB
TypeScript
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
import type { Config } from "@/types/config";
|
|
import {
|
|
resolveBackupPaths,
|
|
resolveBackupStrategy,
|
|
shouldBackupForStrategy,
|
|
shouldBlockSyncForStrategy,
|
|
strategyNeedsDetection,
|
|
} from "@/lib/repo-backup";
|
|
|
|
describe("resolveBackupPaths", () => {
|
|
let originalBackupDirEnv: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
originalBackupDirEnv = process.env.PRE_SYNC_BACKUP_DIR;
|
|
delete process.env.PRE_SYNC_BACKUP_DIR;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalBackupDirEnv === undefined) {
|
|
delete process.env.PRE_SYNC_BACKUP_DIR;
|
|
} else {
|
|
process.env.PRE_SYNC_BACKUP_DIR = originalBackupDirEnv;
|
|
}
|
|
});
|
|
|
|
test("returns absolute paths when backupDirectory is relative", () => {
|
|
const config: Partial<Config> = {
|
|
userId: "user-123",
|
|
giteaConfig: {
|
|
backupDirectory: "data/repo-backups",
|
|
} as Config["giteaConfig"],
|
|
};
|
|
|
|
const { backupRoot, repoBackupDir } = resolveBackupPaths({
|
|
config,
|
|
owner: "RayLabsHQ",
|
|
repoName: "gitea-mirror",
|
|
});
|
|
|
|
expect(path.isAbsolute(backupRoot)).toBe(true);
|
|
expect(path.isAbsolute(repoBackupDir)).toBe(true);
|
|
expect(repoBackupDir).toBe(
|
|
path.join(backupRoot, "user-123", "RayLabsHQ", "gitea-mirror")
|
|
);
|
|
});
|
|
|
|
test("returns absolute paths when backupDirectory is already absolute", () => {
|
|
const config: Partial<Config> = {
|
|
userId: "user-123",
|
|
giteaConfig: {
|
|
backupDirectory: "/data/repo-backups",
|
|
} as Config["giteaConfig"],
|
|
};
|
|
|
|
const { backupRoot, repoBackupDir } = resolveBackupPaths({
|
|
config,
|
|
owner: "owner",
|
|
repoName: "repo",
|
|
});
|
|
|
|
expect(backupRoot).toBe("/data/repo-backups");
|
|
expect(path.isAbsolute(repoBackupDir)).toBe(true);
|
|
});
|
|
|
|
test("falls back to cwd-based path when no backupDirectory is set", () => {
|
|
const config: Partial<Config> = {
|
|
userId: "user-123",
|
|
giteaConfig: {} as Config["giteaConfig"],
|
|
};
|
|
|
|
const { backupRoot } = resolveBackupPaths({
|
|
config,
|
|
owner: "owner",
|
|
repoName: "repo",
|
|
});
|
|
|
|
expect(path.isAbsolute(backupRoot)).toBe(true);
|
|
expect(backupRoot).toBe(
|
|
path.resolve(process.cwd(), "data", "repo-backups")
|
|
);
|
|
});
|
|
|
|
test("uses PRE_SYNC_BACKUP_DIR env var when config has no backupDirectory", () => {
|
|
process.env.PRE_SYNC_BACKUP_DIR = "custom/backup/path";
|
|
|
|
const config: Partial<Config> = {
|
|
userId: "user-123",
|
|
giteaConfig: {} as Config["giteaConfig"],
|
|
};
|
|
|
|
const { backupRoot } = resolveBackupPaths({
|
|
config,
|
|
owner: "owner",
|
|
repoName: "repo",
|
|
});
|
|
|
|
expect(path.isAbsolute(backupRoot)).toBe(true);
|
|
expect(backupRoot).toBe(path.resolve("custom/backup/path"));
|
|
});
|
|
|
|
test("sanitizes owner and repoName in path segments", () => {
|
|
const config: Partial<Config> = {
|
|
userId: "user-123",
|
|
giteaConfig: {
|
|
backupDirectory: "/backups",
|
|
} as Config["giteaConfig"],
|
|
};
|
|
|
|
const { repoBackupDir } = resolveBackupPaths({
|
|
config,
|
|
owner: "org/with-slash",
|
|
repoName: "repo name!",
|
|
});
|
|
|
|
expect(repoBackupDir).toBe(
|
|
path.join("/backups", "user-123", "org_with-slash", "repo_name_")
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---- Backup strategy resolver tests ----
|
|
|
|
function makeConfig(overrides: Record<string, any> = {}): Partial<Config> {
|
|
return {
|
|
giteaConfig: {
|
|
url: "https://gitea.example.com",
|
|
token: "tok",
|
|
...overrides,
|
|
},
|
|
} as Partial<Config>;
|
|
}
|
|
|
|
const envKeysToClean = ["PRE_SYNC_BACKUP_STRATEGY", "PRE_SYNC_BACKUP_ENABLED"];
|
|
|
|
describe("resolveBackupStrategy", () => {
|
|
let savedEnv: Record<string, string | undefined> = {};
|
|
|
|
beforeEach(() => {
|
|
savedEnv = {};
|
|
for (const key of envKeysToClean) {
|
|
savedEnv[key] = process.env[key];
|
|
delete process.env[key];
|
|
}
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const [key, value] of Object.entries(savedEnv)) {
|
|
if (value === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
});
|
|
|
|
test("returns explicit backupStrategy when set", () => {
|
|
expect(resolveBackupStrategy(makeConfig({ backupStrategy: "always" }))).toBe("always");
|
|
expect(resolveBackupStrategy(makeConfig({ backupStrategy: "disabled" }))).toBe("disabled");
|
|
expect(resolveBackupStrategy(makeConfig({ backupStrategy: "on-force-push" }))).toBe("on-force-push");
|
|
expect(resolveBackupStrategy(makeConfig({ backupStrategy: "block-on-force-push" }))).toBe("block-on-force-push");
|
|
});
|
|
|
|
test("maps backupBeforeSync: true → 'on-force-push' (backward compat, prevents silent always-backup)", () => {
|
|
expect(resolveBackupStrategy(makeConfig({ backupBeforeSync: true }))).toBe("on-force-push");
|
|
});
|
|
|
|
test("maps backupBeforeSync: false → 'disabled' (backward compat)", () => {
|
|
expect(resolveBackupStrategy(makeConfig({ backupBeforeSync: false }))).toBe("disabled");
|
|
});
|
|
|
|
test("prefers explicit backupStrategy over backupBeforeSync", () => {
|
|
expect(
|
|
resolveBackupStrategy(
|
|
makeConfig({ backupStrategy: "on-force-push", backupBeforeSync: true }),
|
|
),
|
|
).toBe("on-force-push");
|
|
});
|
|
|
|
test("falls back to PRE_SYNC_BACKUP_STRATEGY env var", () => {
|
|
process.env.PRE_SYNC_BACKUP_STRATEGY = "block-on-force-push";
|
|
expect(resolveBackupStrategy(makeConfig({}))).toBe("block-on-force-push");
|
|
});
|
|
|
|
test("falls back to PRE_SYNC_BACKUP_ENABLED env var (legacy)", () => {
|
|
process.env.PRE_SYNC_BACKUP_ENABLED = "false";
|
|
expect(resolveBackupStrategy(makeConfig({}))).toBe("disabled");
|
|
});
|
|
|
|
test("defaults to 'on-force-push' when nothing is configured", () => {
|
|
expect(resolveBackupStrategy(makeConfig({}))).toBe("on-force-push");
|
|
});
|
|
|
|
test("handles empty giteaConfig gracefully", () => {
|
|
expect(resolveBackupStrategy({})).toBe("on-force-push");
|
|
});
|
|
});
|
|
|
|
describe("shouldBackupForStrategy", () => {
|
|
test("disabled → never backup", () => {
|
|
expect(shouldBackupForStrategy("disabled", false)).toBe(false);
|
|
expect(shouldBackupForStrategy("disabled", true)).toBe(false);
|
|
});
|
|
|
|
test("always → always backup", () => {
|
|
expect(shouldBackupForStrategy("always", false)).toBe(true);
|
|
expect(shouldBackupForStrategy("always", true)).toBe(true);
|
|
});
|
|
|
|
test("on-force-push → backup only when detected", () => {
|
|
expect(shouldBackupForStrategy("on-force-push", false)).toBe(false);
|
|
expect(shouldBackupForStrategy("on-force-push", true)).toBe(true);
|
|
});
|
|
|
|
test("block-on-force-push → backup only when detected", () => {
|
|
expect(shouldBackupForStrategy("block-on-force-push", false)).toBe(false);
|
|
expect(shouldBackupForStrategy("block-on-force-push", true)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("shouldBlockSyncForStrategy", () => {
|
|
test("only block-on-force-push + detected returns true", () => {
|
|
expect(shouldBlockSyncForStrategy("block-on-force-push", true)).toBe(true);
|
|
});
|
|
|
|
test("block-on-force-push without detection does not block", () => {
|
|
expect(shouldBlockSyncForStrategy("block-on-force-push", false)).toBe(false);
|
|
});
|
|
|
|
test("other strategies never block", () => {
|
|
expect(shouldBlockSyncForStrategy("disabled", true)).toBe(false);
|
|
expect(shouldBlockSyncForStrategy("always", true)).toBe(false);
|
|
expect(shouldBlockSyncForStrategy("on-force-push", true)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("strategyNeedsDetection", () => {
|
|
test("returns true for detection-based strategies", () => {
|
|
expect(strategyNeedsDetection("on-force-push")).toBe(true);
|
|
expect(strategyNeedsDetection("block-on-force-push")).toBe(true);
|
|
});
|
|
|
|
test("returns false for non-detection strategies", () => {
|
|
expect(strategyNeedsDetection("disabled")).toBe(false);
|
|
expect(strategyNeedsDetection("always")).toBe(false);
|
|
});
|
|
});
|