test: add property-based tests with fast-check (#832)
This commit is contained in:
parent
7b3b7583c0
commit
e506047415
5 changed files with 154 additions and 3 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
10
bun.lock
10
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue