test: add property-based tests with fast-check (#832)

This commit is contained in:
Nico 2026-04-25 09:35:28 +02:00 committed by GitHub
parent 7b3b7583c0
commit e506047415
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 154 additions and 3 deletions

View file

@ -1,10 +1,23 @@
import { describe, expect, test } from "vitest";
import path from "node:path";
import fc from "fast-check";
import { describe, expect, test } from "vitest";
import { fromAny } from "@total-typescript/shoehorn";
import { createBackupOptions, processPattern } from "../backup.helpers";
type BackupScheduleInput = Parameters<typeof createBackupOptions>[0];
const safePatternSegmentArb = fc
.array(fc.constantFrom("a", "b", "c", "x", "y", "z", "0", "1", "2", "-", "_", ".", " "), {
minLength: 1,
maxLength: 12,
})
.map((chars) => chars.join(""))
.filter((segment) => segment.trim() !== "" && segment !== "." && segment !== "..");
const safeRelativePatternArb = fc
.array(safePatternSegmentArb, { minLength: 1, maxLength: 5 })
.map((segments) => segments.join("/"));
const createSchedule = (overrides: Partial<BackupScheduleInput> = {}): BackupScheduleInput =>
fromAny({
shortId: "sched-1234",
@ -134,6 +147,31 @@ describe("executeBackup - include / exclude patterns", () => {
]);
});
test("anchors generated include patterns under the volume path", () => {
const volumePath = "/var/lib/zerobyte/volumes/vol123/_data";
fc.assert(
fc.property(safeRelativePatternArb, fc.boolean(), fc.boolean(), (pattern, anchored, negated) => {
const rawPattern = `${negated ? "!" : ""}${anchored ? "/" : ""}${pattern}`;
const expected = path.join(volumePath, pattern);
expect(processPattern(rawPattern, volumePath, true)).toBe(negated ? `!${expected}` : expected);
}),
);
});
test("rejects generated include patterns that escape the volume root", () => {
const volumePath = "/volume/root";
fc.assert(
fc.property(safeRelativePatternArb, fc.boolean(), (pattern, negated) => {
const escapingPattern = `${negated ? "!" : ""}${"../".repeat(8)}${pattern}`;
expect(() => processPattern(escapingPattern, volumePath, true)).toThrow("Include pattern escapes volume root");
}),
);
});
test("keeps selected include paths separate from include patterns", () => {
const volumePath = "/var/lib/zerobyte/volumes/vol123/_data";
const schedule = createSchedule({

View file

@ -1,6 +1,16 @@
import path from "node:path";
import fc from "fast-check";
import { describe, expect, test } from "vitest";
import { isPathWithin, normalizeAbsolutePath } from "@zerobyte/core/utils";
const safePathSegmentArb = fc
.array(fc.constantFrom("a", "b", "c", "x", "y", "z", "0", "1", "2", "-", "_", ".", " "), {
minLength: 1,
maxLength: 12,
})
.map((chars) => chars.join(""))
.filter((segment) => segment.trim() !== "" && segment !== "." && segment !== "..");
describe("normalizeAbsolutePath", () => {
test("handles undefined and empty inputs", () => {
expect(normalizeAbsolutePath()).toBe("/");
@ -36,6 +46,19 @@ describe("normalizeAbsolutePath", () => {
expect(normalizeAbsolutePath("/..")).toBe("/");
expect(normalizeAbsolutePath("/foo/../../bar")).toBe("/bar");
});
test("is idempotent and always returns a normalized absolute path", () => {
fc.assert(
fc.property(fc.string({ maxLength: 200 }), (input) => {
const normalized = normalizeAbsolutePath(input);
expect(normalized.startsWith("/")).toBe(true);
expect(normalized).not.toContain("\\");
expect(normalized === "/" || !normalized.endsWith("/")).toBe(true);
expect(normalizeAbsolutePath(normalized)).toBe(normalized);
}),
);
});
});
describe("isPathWithin", () => {
@ -48,4 +71,15 @@ describe("isPathWithin", () => {
expect(isPathWithin("/var/lib/zerobyte/data", "/var/lib/zerobyte/database")).toBe(false);
expect(isPathWithin("/var/lib/zerobyte/data", "/var/lib/zerobyte/data/../ssh")).toBe(false);
});
test("matches descendants created under the same normalized base", () => {
fc.assert(
fc.property(fc.string({ maxLength: 80 }), fc.array(safePathSegmentArb, { maxLength: 5 }), (base, segments) => {
const normalizedBase = normalizeAbsolutePath(base);
const descendant = path.posix.join(normalizedBase, ...segments);
expect(isPathWithin(base, descendant)).toBe(true);
}),
);
});
});

View file

@ -88,8 +88,10 @@
"@types/react-dom": "^19.2.3",
"@types/semver": "^7.7.1",
"@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "^1.0.0",
"dotenv-cli": "^11.0.0",
"drizzle-kit": "^1.0.0-beta.21",
"fast-check": "^4.7.0",
"msw": "^2.12.14",
"nitro": "^3.0.1-alpha.2",
"tailwindcss": "^4.2.2",
@ -1786,7 +1788,7 @@
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
"fast-check": ["fast-check@4.7.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
@ -2438,7 +2440,7 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
"pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="],
"qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="],
@ -3090,6 +3092,8 @@
"eciesjs/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"effect/fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
"encoding-sniffer/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
@ -3276,6 +3280,8 @@
"docs/vite/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"effect/fast-check/pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
"fumadocs-core/tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"fumadocs-mdx/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],

View file

@ -119,6 +119,7 @@
"babel-plugin-react-compiler": "^1.0.0",
"dotenv-cli": "^11.0.0",
"drizzle-kit": "^1.0.0-beta.21",
"fast-check": "^4.7.0",
"msw": "^2.12.14",
"nitro": "^3.0.1-alpha.2",
"tailwindcss": "^4.2.2",

View file

@ -1,6 +1,60 @@
import fc from "fast-check";
import { describe, expect, test } from "vitest";
import { validateCustomResticParams } from "../validate-custom-params";
const supportedFlagsWithoutValues = [
"--verbose",
"-v",
"--no-scan",
"--skip-if-unchanged",
"--exclude-caches",
"--force",
"--use-fs-snapshot",
"--ignore-ctime",
"--ignore-inode",
"--with-atime",
"--no-cache",
"--cleanup-cache",
"--no-lock",
] as const;
const positiveIntegerFlags = ["--read-concurrency", "--limit-upload", "--limit-download", "--pack-size"] as const;
const deniedFlags = [
"--password-command",
"--password-file",
"--password",
"-p",
"--repository",
"--repository-file",
"-r",
"--option",
"-o",
"--key-hint",
"--tls-client-cert",
"--cacert",
"--repo",
] as const;
const validCustomParamArb = fc.oneof(
fc.constantFrom(...supportedFlagsWithoutValues),
fc
.tuple(fc.constantFrom(...positiveIntegerFlags), fc.integer({ min: 1, max: 100_000 }), fc.boolean())
.map(([flag, value, inline]) => (inline ? `${flag}=${value}` : `${flag} ${value}`)),
fc
.tuple(fc.integer({ min: 1, max: 100_000 }), fc.constantFrom("", "K", "M", "G", "T", "KiB", "MiB"), fc.boolean())
.map(([value, suffix, inline]) =>
inline ? `--exclude-larger-than=${value}${suffix}` : `--exclude-larger-than ${value}${suffix}`,
),
);
const flagValueArb = fc
.array(fc.constantFrom("a", "b", "c", "x", "y", "z", "0", "1", "2", "-", "_", "."), {
minLength: 1,
maxLength: 16,
})
.map((chars) => chars.join(""))
.filter((value) => !value.startsWith("-"));
describe("validateCustomResticParams", () => {
test("accepts supported flags and values", () => {
const result = validateCustomResticParams([
@ -41,4 +95,22 @@ describe("validateCustomResticParams", () => {
expect(result).toBe('Flag "--read-concurrency" requires a value');
});
test("accepts generated combinations of supported flags", () => {
fc.assert(
fc.property(fc.array(validCustomParamArb, { maxLength: 10 }), (params) => {
expect(validateCustomResticParams(params)).toBeNull();
}),
);
});
test("rejects generated denied flags", () => {
fc.assert(
fc.property(fc.constantFrom(...deniedFlags), flagValueArb, fc.boolean(), (flag, value, inline) => {
const param = inline ? `${flag}=${value}` : `${flag} ${value}`;
expect(validateCustomResticParams([param])).toContain(`Flag "${flag}" is not permitted`);
}),
);
});
});