From e50604741598097986a0c4366662470ab12eadf8 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Sat, 25 Apr 2026 09:35:28 +0200 Subject: [PATCH] test: add property-based tests with fast-check (#832) --- .../__tests__/backups.patterns.test.ts | 40 ++++++++++- app/utils/__tests__/path.test.ts | 34 +++++++++ bun.lock | 10 ++- package.json | 1 + .../__tests__/validate-custom-params.test.ts | 72 +++++++++++++++++++ 5 files changed, 154 insertions(+), 3 deletions(-) diff --git a/app/server/modules/backups/__tests__/backups.patterns.test.ts b/app/server/modules/backups/__tests__/backups.patterns.test.ts index b0c471dd..dd6d44c4 100644 --- a/app/server/modules/backups/__tests__/backups.patterns.test.ts +++ b/app/server/modules/backups/__tests__/backups.patterns.test.ts @@ -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[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 => 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({ diff --git a/app/utils/__tests__/path.test.ts b/app/utils/__tests__/path.test.ts index 7a8a2778..a3114720 100644 --- a/app/utils/__tests__/path.test.ts +++ b/app/utils/__tests__/path.test.ts @@ -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); + }), + ); + }); }); diff --git a/bun.lock b/bun.lock index cac6299c..ab238f54 100644 --- a/bun.lock +++ b/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=="], diff --git a/package.json b/package.json index a273041a..377d402e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/core/src/restic/helpers/__tests__/validate-custom-params.test.ts b/packages/core/src/restic/helpers/__tests__/validate-custom-params.test.ts index 410a7c36..49a0c96c 100644 --- a/packages/core/src/restic/helpers/__tests__/validate-custom-params.test.ts +++ b/packages/core/src/restic/helpers/__tests__/validate-custom-params.test.ts @@ -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`); + }), + ); + }); });