diff --git a/bun.lockb b/bun.lockb index c99d9ddd..24ba4279 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package-lock.json b/package-lock.json index 2fd6d91b..64bf312a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,6 +82,7 @@ "eslint-plugin-prettier": "^5.2.6", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", + "fast-check": "^4.9.0", "globals": "^15.15.0", "jsdom": "^29.1.1", "prettier": "^3.7.3", @@ -6844,6 +6845,29 @@ "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "license": "MIT" }, + "node_modules/fast-check": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -8539,6 +8563,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", diff --git a/package.json b/package.json index 51083c1a..df1e3647 100644 --- a/package.json +++ b/package.json @@ -91,10 +91,10 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/dompurify": "^3.2.0", "@types/node": "^22.16.5", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", - "@types/dompurify": "^3.2.0", "@types/testing-library__jest-dom": "^5.14.9", "@vitejs/plugin-react": "^5.0.4", "eslint": "^9.32.0", @@ -102,6 +102,7 @@ "eslint-plugin-prettier": "^5.2.6", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", + "fast-check": "^4.9.0", "globals": "^15.15.0", "jsdom": "^29.1.1", "prettier": "^3.7.3", diff --git a/tests/unit/api/actor.properties.test.ts b/tests/unit/api/actor.properties.test.ts new file mode 100644 index 00000000..30ce6649 --- /dev/null +++ b/tests/unit/api/actor.properties.test.ts @@ -0,0 +1,158 @@ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { + assertActorAuthorized, + assertDelegationCanBeIssued, + type MailboxDelegation, +} from "../../../src/server/api/auth/delegation"; +import { distinctAddressPairArbitrary, instantMsArbitrary } from "./arbitraries"; + +const NUM_RUNS = 150; +const ACTIONS = ["read", "write", "delete", "settle", "refund"]; +const RESOURCES = ["mailbox", "postage", "receipt", "policy"]; + +describe("assertActorAuthorized (property)", () => { + it("the resource owner is always authorized, with or without a delegation", () => { + fc.assert( + fc.property(distinctAddressPairArbitrary, ([owner]) => { + expect(assertActorAuthorized(owner, owner)).toBe(owner); + }), + { numRuns: NUM_RUNS }, + ); + }); + + it("a bare actor with no delegation is never authorized to act as a different owner", () => { + fc.assert( + fc.property(distinctAddressPairArbitrary, ([owner, actor]) => { + expect(() => assertActorAuthorized(actor, owner)).toThrowError( + expect.objectContaining({ status: 403 }), + ); + }), + { numRuns: NUM_RUNS }, + ); + }); + + it("matches the exact revoked/expiry/scope decision table for a single delegation", () => { + const scenarioArbitrary = distinctAddressPairArbitrary.chain(([owner, delegate]) => + fc.record({ + owner: fc.constant(owner), + delegate: fc.constant(delegate), + allowedActions: fc.uniqueArray(fc.constantFrom(...ACTIONS), { + minLength: 1, + maxLength: ACTIONS.length, + }), + resourceScope: fc.uniqueArray(fc.constantFrom(...RESOURCES), { + minLength: 1, + maxLength: RESOURCES.length, + }), + issuedAtMs: instantMsArbitrary, + expiresAtMs: instantMsArbitrary, + revoked: fc.boolean(), + action: fc.constantFrom(...ACTIONS), + resource: fc.constantFrom(...RESOURCES), + nowMs: instantMsArbitrary, + }), + ); + + fc.assert( + fc.property( + scenarioArbitrary, + ({ + owner, + delegate, + allowedActions, + resourceScope, + issuedAtMs, + expiresAtMs, + revoked, + action, + resource, + nowMs, + }) => { + const delegation: MailboxDelegation = { + grantor: owner, + delegate, + allowedActions, + resourceScope, + issuedAt: new Date(issuedAtMs).toISOString(), + expiresAt: new Date(expiresAtMs).toISOString(), + revoked, + }; + + const expectedAuthorized = + !revoked && + nowMs >= issuedAtMs && + nowMs < expiresAtMs && + allowedActions.includes(action) && + resourceScope.includes(resource); + + const authorization = { + action, + resource, + delegations: [delegation], + now: new Date(nowMs), + }; + + if (expectedAuthorized) { + expect(assertActorAuthorized(delegate, owner, authorization)).toBe(delegate); + } else { + expect(() => assertActorAuthorized(delegate, owner, authorization)).toThrowError( + expect.objectContaining({ status: 403 }), + ); + } + }, + ), + { numRuns: NUM_RUNS }, + ); + }); +}); + +describe("assertDelegationCanBeIssued (property)", () => { + it("matches the exact grantor/expiry/non-empty-scope decision table", () => { + fc.assert( + fc.property( + distinctAddressPairArbitrary, + fc.boolean(), + instantMsArbitrary, + instantMsArbitrary, + fc.array(fc.constantFrom(...ACTIONS), { maxLength: ACTIONS.length }), + fc.array(fc.constantFrom(...RESOURCES), { maxLength: RESOURCES.length }), + ( + [grantor, other], + actorIsGrantor, + issuedAtMs, + expiresAtMs, + allowedActions, + resourceScope, + ) => { + const actor = actorIsGrantor ? grantor : other; + const delegation: MailboxDelegation = { + grantor, + delegate: other, + allowedActions, + resourceScope, + issuedAt: new Date(issuedAtMs).toISOString(), + expiresAt: new Date(expiresAtMs).toISOString(), + revoked: false, + }; + + const expectedOk = + actor === grantor && + expiresAtMs > issuedAtMs && + allowedActions.length > 0 && + resourceScope.length > 0; + + if (expectedOk) { + expect(assertDelegationCanBeIssued(actor, delegation)).toBe(delegation); + } else { + expect(() => assertDelegationCanBeIssued(actor, delegation)).toThrowError( + expect.objectContaining({ status: 403 }), + ); + } + }, + ), + { numRuns: NUM_RUNS }, + ); + }); +}); diff --git a/tests/unit/api/arbitraries.ts b/tests/unit/api/arbitraries.ts new file mode 100644 index 00000000..dbcb8a54 --- /dev/null +++ b/tests/unit/api/arbitraries.ts @@ -0,0 +1,204 @@ +import fc from "fast-check"; + +/** + * Shared fast-check arbitraries for the Stealth API domain: Stellar + * addresses, 32-byte hashes, stroop/i128 amount strings, timestamps, + * mailbox policies, postage, and receipts. Mirrors the exact validation + * rules in `src/server/api/domain.ts` so "valid" arbitraries always parse + * and "invalid" arbitraries always fail their corresponding schema. + */ + +// --------------------------------------------------------------------------- +// Stellar addresses (`^G[A-Z2-7]{55}$` after trim + uppercase) +// --------------------------------------------------------------------------- + +const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".split(""); +const BASE32_EXCLUDED_DIGITS = "0189".split(""); + +/** Canonical, already-normalized Stellar G-address. */ +export const stellarAddressArbitrary: fc.Arbitrary = fc + .array(fc.constantFrom(...BASE32_ALPHABET), { minLength: 55, maxLength: 55 }) + .map((chars) => `G${chars.join("")}`); + +/** A valid address wrapped in whitespace and/or lowercased, exercising `.trim().toUpperCase()`. */ +export const stellarAddressUnnormalizedArbitrary: fc.Arbitrary = fc + .tuple(stellarAddressArbitrary, fc.boolean(), fc.constantFrom("", " ", "\t", " \n")) + .map( + ([address, lowercase, padding]) => + `${padding}${lowercase ? address.toLowerCase() : address}${padding}`, + ); + +/** Structurally invalid Stellar addresses: wrong prefix, wrong length, or disallowed characters. */ +export const invalidStellarAddressArbitrary: fc.Arbitrary = fc.oneof( + stellarAddressArbitrary.map((address) => `M${address.slice(1)}`), + stellarAddressArbitrary.map((address) => address.slice(0, -1)), + stellarAddressArbitrary.map((address) => `${address}A`), + fc + .tuple( + stellarAddressArbitrary, + fc.nat({ max: 54 }), + fc.constantFrom(...BASE32_EXCLUDED_DIGITS, "!", "_", "*"), + ) + .map(([address, offset, badChar]) => { + const target = 1 + offset; + return address.slice(0, target) + badChar + address.slice(target + 1); + }), + fc.constant(""), + fc.constant("not-an-address"), +); + +// --------------------------------------------------------------------------- +// 32-byte hashes (`^[a-f0-9]{64}$` after trim + lowercase) +// --------------------------------------------------------------------------- + +const HEX_ALPHABET = "0123456789abcdef".split(""); + +export const hash32Arbitrary: fc.Arbitrary = fc + .array(fc.constantFrom(...HEX_ALPHABET), { minLength: 64, maxLength: 64 }) + .map((chars) => chars.join("")); + +/** A valid hash rendered in mixed case, exercising `.toLowerCase()` normalization. */ +export const hash32UppercaseArbitrary: fc.Arbitrary = hash32Arbitrary.map((hash) => + hash.toUpperCase(), +); + +export const invalidHash32Arbitrary: fc.Arbitrary = fc.oneof( + hash32Arbitrary.map((hash) => hash.slice(0, -1)), + hash32Arbitrary.map((hash) => `${hash}a`), + fc + .tuple(hash32Arbitrary, fc.nat({ max: 63 }), fc.constantFrom("g", "z", "!", "_", " ")) + .map(([hash, offset, badChar]) => hash.slice(0, offset) + badChar + hash.slice(offset + 1)), + fc.constant(""), +); + +// --------------------------------------------------------------------------- +// Stroop / i128 amount strings (`^(0|[1-9]\d*)$`, bounded by 2^127 - 1) +// --------------------------------------------------------------------------- + +export const I128_MAX = 2n ** 127n - 1n; + +export const stroopAmountArbitrary: fc.Arbitrary = fc + .bigInt({ min: 0n, max: I128_MAX }) + .map((value) => value.toString()); + +export const invalidStroopAmountArbitrary: fc.Arbitrary = fc.oneof( + fc.bigInt({ min: -I128_MAX, max: -1n }).map((value) => value.toString()), + fc.constant((I128_MAX + 1n).toString()), + fc.bigInt({ min: 0n, max: I128_MAX }).map((value) => `0${value.toString()}`), + fc.constant("1.5"), + fc.constant("abc"), + fc.constant(""), + fc.constant("1 23"), + fc.constant("-0"), +); + +// --------------------------------------------------------------------------- +// Timestamps +// --------------------------------------------------------------------------- + +const MIN_INSTANT = new Date("2000-01-01T00:00:00.000Z").getTime(); +const MAX_INSTANT = new Date("2100-01-01T00:00:00.000Z").getTime(); + +/** An arbitrary point in time, as milliseconds since epoch. */ +export const instantMsArbitrary: fc.Arbitrary = fc.integer({ + min: MIN_INSTANT, + max: MAX_INSTANT, +}); + +/** A UTC, `Z`-suffixed ISO-8601 timestamp — valid for `postage.createdAt` / idempotency records. */ +export const utcTimestampArbitrary: fc.Arbitrary = instantMsArbitrary.map((ms) => + new Date(ms).toISOString(), +); + +/** + * The same instant re-rendered with an explicit numeric offset instead of `Z`. + * Valid for receipt fields (`{ offset: true }`) but rejected by the plain + * `z.string().datetime()` used for `postage.createdAt`. + */ +export const offsetTimestampArbitrary: fc.Arbitrary = fc + .tuple(instantMsArbitrary, fc.integer({ min: -720, max: 720 })) + .map(([instantMs, offsetMinutes]) => { + const sign = offsetMinutes < 0 ? "-" : "+"; + const abs = Math.abs(offsetMinutes); + const hh = String(Math.floor(abs / 60)).padStart(2, "0"); + const mm = String(abs % 60).padStart(2, "0"); + const localMs = instantMs + offsetMinutes * 60_000; + const local = new Date(localMs).toISOString().replace("Z", `${sign}${hh}:${mm}`); + return local; + }); + +// --------------------------------------------------------------------------- +// Composite domain records +// --------------------------------------------------------------------------- + +export const mailboxPolicyArbitrary = fc.record({ + allowUnknown: fc.boolean(), + minimumPostage: stroopAmountArbitrary, + requireVerified: fc.boolean(), +}); + +export const postageStatusArbitrary: fc.Arbitrary<"pending" | "settled" | "refunded"> = + fc.constantFrom("pending", "settled", "refunded"); + +export const senderRuleArbitrary: fc.Arbitrary<"default" | "allow" | "block"> = fc.constantFrom( + "default", + "allow", + "block", +); + +/** Two independently generated (and, for all practical purposes, distinct) Stellar addresses. */ +export const distinctAddressPairArbitrary: fc.Arbitrary<[string, string]> = fc + .tuple(stellarAddressArbitrary, stellarAddressArbitrary) + .filter(([a, b]) => a !== b); + +export const postageArbitrary = fc + .tuple( + stroopAmountArbitrary, + utcTimestampArbitrary, + hash32Arbitrary, + hash32Arbitrary, + distinctAddressPairArbitrary, + postageStatusArbitrary, + ) + .map(([amount, createdAt, messageId, paymentHash, [recipient, sender], status]) => ({ + amount, + createdAt, + messageId, + paymentHash, + recipient, + sender, + status, + })); + +/** + * A receipt whose `deliveredAt`/`readAt` pair always satisfies the + * `receiptSchema` ordering + future-skew invariants relative to `nowMs`. + * `readAt` is `null` (unread) with 50% probability. + */ +export function validReceiptArbitrary(nowMs: number, maxFutureSkewMs: number) { + return fc + .tuple( + hash32Arbitrary, + distinctAddressPairArbitrary, + fc.integer({ min: -365 * 24 * 60 * 60 * 1000, max: maxFutureSkewMs }), + fc.option(fc.integer({ min: 0, max: 30 * 24 * 60 * 60 * 1000 }), { nil: null }), + ) + .map(([messageId, [recipient, sender], deliveredOffsetMs, readExtraMs]) => { + const deliveredAtMs = nowMs + deliveredOffsetMs; + const deliveredAt = new Date(deliveredAtMs).toISOString(); + + if (readExtraMs === null) { + return { deliveredAt, messageId, readAt: null, recipient, sender }; + } + + const maxAllowedMs = nowMs + maxFutureSkewMs; + const readAtMs = Math.min(deliveredAtMs + readExtraMs, maxAllowedMs); + return { + deliveredAt, + messageId, + readAt: new Date(readAtMs).toISOString(), + recipient, + sender, + }; + }); +} diff --git a/tests/unit/api/domain.properties.test.ts b/tests/unit/api/domain.properties.test.ts new file mode 100644 index 00000000..e588af45 --- /dev/null +++ b/tests/unit/api/domain.properties.test.ts @@ -0,0 +1,285 @@ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { + createReceiptSchema, + hash32Schema, + mailboxPolicySchema, + postageSchema, + stellarAddressSchema, + stroopAmountSchema, +} from "../../../src/server/api/domain"; +import { + distinctAddressPairArbitrary, + hash32Arbitrary, + hash32UppercaseArbitrary, + I128_MAX, + instantMsArbitrary, + invalidHash32Arbitrary, + invalidStellarAddressArbitrary, + invalidStroopAmountArbitrary, + mailboxPolicyArbitrary, + offsetTimestampArbitrary, + postageArbitrary, + stellarAddressArbitrary, + stellarAddressUnnormalizedArbitrary, + stroopAmountArbitrary, + validReceiptArbitrary, +} from "./arbitraries"; + +// Bound property-test cost so the whole file stays well within CI's default +// per-test timeout even though each run exercises many generated inputs. +const NUM_RUNS = 200; + +describe("stellarAddressSchema (property)", () => { + it("accepts every generated canonical G-address", () => { + fc.assert( + fc.property(stellarAddressArbitrary, (address) => { + expect(stellarAddressSchema.parse(address)).toBe(address); + }), + { numRuns: NUM_RUNS }, + ); + }); + + it("normalizes whitespace and case without changing the accepted value", () => { + fc.assert( + fc.property(stellarAddressUnnormalizedArbitrary, (raw) => { + const parsed = stellarAddressSchema.parse(raw); + expect(parsed).toBe(raw.trim().toUpperCase()); + expect(parsed).toMatch(/^G[A-Z2-7]{55}$/); + }), + { numRuns: NUM_RUNS }, + ); + }); + + it("rejects every generated malformed address", () => { + fc.assert( + fc.property(invalidStellarAddressArbitrary, (address) => { + expect(stellarAddressSchema.safeParse(address).success).toBe(false); + }), + { numRuns: NUM_RUNS }, + ); + }); +}); + +describe("hash32Schema (property)", () => { + it("accepts every generated 64-char hex hash", () => { + fc.assert( + fc.property(hash32Arbitrary, (hash) => { + expect(hash32Schema.parse(hash)).toBe(hash); + }), + { numRuns: NUM_RUNS }, + ); + }); + + it("normalizes mixed-case hex to lowercase", () => { + fc.assert( + fc.property(hash32UppercaseArbitrary, (hash) => { + expect(hash32Schema.parse(hash)).toBe(hash.toLowerCase()); + }), + { numRuns: NUM_RUNS }, + ); + }); + + it("rejects every generated malformed hash", () => { + fc.assert( + fc.property(invalidHash32Arbitrary, (hash) => { + expect(hash32Schema.safeParse(hash).success).toBe(false); + }), + { numRuns: NUM_RUNS }, + ); + }); +}); + +describe("stroopAmountSchema (property)", () => { + it("accepts every non-negative integer string up to the i128 boundary", () => { + fc.assert( + fc.property(stroopAmountArbitrary, (amount) => { + expect(stroopAmountSchema.parse(amount)).toBe(amount); + expect(BigInt(amount)).toBeLessThanOrEqual(I128_MAX); + }), + { numRuns: NUM_RUNS }, + ); + }); + + it("accepts the exact i128 boundary and rejects one past it", () => { + expect(stroopAmountSchema.safeParse(I128_MAX.toString()).success).toBe(true); + expect(stroopAmountSchema.safeParse((I128_MAX + 1n).toString()).success).toBe(false); + }); + + it("rejects every generated invalid amount (negative, overflow, leading zero, non-numeric)", () => { + fc.assert( + fc.property(invalidStroopAmountArbitrary, (amount) => { + expect(stroopAmountSchema.safeParse(amount).success).toBe(false); + }), + { numRuns: NUM_RUNS }, + ); + }); +}); + +describe("mailboxPolicySchema (property)", () => { + it("round-trips every generated valid policy", () => { + fc.assert( + fc.property(mailboxPolicyArbitrary, (policy) => { + expect(mailboxPolicySchema.parse(policy)).toEqual(policy); + }), + { numRuns: NUM_RUNS }, + ); + }); +}); + +describe("postageSchema (property)", () => { + it("accepts every generated valid postage record", () => { + fc.assert( + fc.property(postageArbitrary, (postage) => { + expect(postageSchema.parse(postage)).toEqual(postage); + }), + { numRuns: NUM_RUNS }, + ); + }); + + it("rejects postage.createdAt rendered with a numeric offset instead of Z", () => { + fc.assert( + fc.property(postageArbitrary, offsetTimestampArbitrary, (postage, offsetCreatedAt) => { + const withOffset = { ...postage, createdAt: offsetCreatedAt }; + expect(postageSchema.safeParse(withOffset).success).toBe(false); + }), + { numRuns: NUM_RUNS }, + ); + }); +}); + +describe("receiptSchema timestamp ordering and future bounds (property)", () => { + const NOW_MS = new Date("2026-01-01T00:00:00.000Z").getTime(); + const MAX_FUTURE_SKEW_MS = 5 * 60 * 1000; + const schema = createReceiptSchema({ + maxFutureSkewMs: MAX_FUTURE_SKEW_MS, + now: () => new Date(NOW_MS), + }); + + it("accepts every generated receipt honoring ordering and future-skew invariants", () => { + fc.assert( + fc.property(validReceiptArbitrary(NOW_MS, MAX_FUTURE_SKEW_MS), (receipt) => { + expect(schema.safeParse(receipt).success).toBe(true); + }), + { numRuns: NUM_RUNS }, + ); + }); + + it("also accepts offset-rendered timestamps representing the same valid instants", () => { + fc.assert( + fc.property(validReceiptArbitrary(NOW_MS, MAX_FUTURE_SKEW_MS), (receipt) => { + const asOffset = { + ...receipt, + deliveredAt: toOffsetForm(receipt.deliveredAt), + readAt: receipt.readAt === null ? null : toOffsetForm(receipt.readAt), + }; + expect(schema.safeParse(asOffset).success).toBe(true); + }), + { numRuns: NUM_RUNS }, + ); + }); + + it("rejects readAt strictly before deliveredAt, for any generated gap", () => { + fc.assert( + fc.property( + hash32Arbitrary, + distinctAddressPairArbitrary, + fc.integer({ min: -60_000, max: MAX_FUTURE_SKEW_MS }), + fc.integer({ min: 1, max: 60_000 }), + (messageId, [recipient, sender], deliveredOffsetMs, gapMs) => { + const deliveredAtMs = NOW_MS + deliveredOffsetMs; + const readAtMs = deliveredAtMs - gapMs; + const result = schema.safeParse({ + deliveredAt: new Date(deliveredAtMs).toISOString(), + messageId, + readAt: new Date(readAtMs).toISOString(), + recipient, + sender, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((issue) => issue.path.join(".") === "readAt")).toBe( + true, + ); + } + }, + ), + { numRuns: NUM_RUNS }, + ); + }); + + it("rejects deliveredAt beyond the future-skew tolerance, and accepts exactly at the boundary", () => { + fc.assert( + fc.property( + hash32Arbitrary, + distinctAddressPairArbitrary, + fc.integer({ min: 1, max: 10 * 365 * 24 * 60 * 60 * 1000 }), + (messageId, [recipient, sender], overshootMs) => { + const tooFarMs = NOW_MS + MAX_FUTURE_SKEW_MS + overshootMs; + const rejected = schema.safeParse({ + deliveredAt: new Date(tooFarMs).toISOString(), + messageId, + readAt: null, + recipient, + sender, + }); + expect(rejected.success).toBe(false); + }, + ), + { numRuns: NUM_RUNS }, + ); + + const boundaryMs = NOW_MS + MAX_FUTURE_SKEW_MS; + const atBoundary = schema.safeParse({ + deliveredAt: new Date(boundaryMs).toISOString(), + messageId: "a".repeat(64), + readAt: null, + recipient: `G${"A".repeat(55)}`, + sender: `G${"B".repeat(55)}`, + }); + expect(atBoundary.success).toBe(true); + + const pastBoundary = schema.safeParse({ + deliveredAt: new Date(boundaryMs + 1).toISOString(), + messageId: "a".repeat(64), + readAt: null, + recipient: `G${"A".repeat(55)}`, + sender: `G${"B".repeat(55)}`, + }); + expect(pastBoundary.success).toBe(false); + }); +}); + +describe("timestamp round-trips (property)", () => { + it("every generated UTC instant survives an ISO string round-trip", () => { + fc.assert( + fc.property(instantMsArbitrary, (ms) => { + const iso = new Date(ms).toISOString(); + expect(Date.parse(iso)).toBe(ms); + }), + { numRuns: NUM_RUNS }, + ); + }); + + it("offset-rendered timestamps parse to the same instant as their UTC form", () => { + fc.assert( + fc.property(instantMsArbitrary, fc.integer({ min: -720, max: 720 }), (ms, offsetMinutes) => { + const sign = offsetMinutes < 0 ? "-" : "+"; + const abs = Math.abs(offsetMinutes); + const hh = String(Math.floor(abs / 60)).padStart(2, "0"); + const mm = String(abs % 60).padStart(2, "0"); + const localMs = ms + offsetMinutes * 60_000; + const local = new Date(localMs).toISOString().replace("Z", `${sign}${hh}:${mm}`); + expect(Date.parse(local)).toBe(ms); + }), + { numRuns: NUM_RUNS }, + ); + }); +}); + +function toOffsetForm(iso: string): string { + const ms = Date.parse(iso); + const offsetMinutes = 60; // fixed +01:00 rendering + return new Date(ms + offsetMinutes * 60_000).toISOString().replace("Z", "+01:00"); +} diff --git a/tests/unit/api/idempotency-service.properties.test.ts b/tests/unit/api/idempotency-service.properties.test.ts new file mode 100644 index 00000000..6016a6ea --- /dev/null +++ b/tests/unit/api/idempotency-service.properties.test.ts @@ -0,0 +1,144 @@ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { MemoryApiRepository } from "../../../src/server/api/memory-repository"; +import { + computeRequestDigest, + hashIdempotencyKey, + withIdempotency, +} from "../../../src/server/api/idempotency-service"; +import { distinctAddressPairArbitrary } from "./arbitraries"; + +const NUM_RUNS = 100; +const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"]; +const ROUTES = [ + "POST /messages", + "GET /messages/:id", + "POST /postage", + "POST /receipts", + "PUT /policy", +]; + +const jsonPrimitiveArbitrary = fc.oneof(fc.string(), fc.integer(), fc.boolean(), fc.constant(null)); +const jsonObjectArbitrary = fc.dictionary( + fc.string({ minLength: 1, maxLength: 8 }), + jsonPrimitiveArbitrary, + { maxKeys: 8 }, +); + +describe("hashIdempotencyKey (property)", () => { + it("is deterministic and always a 64-char hex sha256 digest", () => { + fc.assert( + fc.property( + distinctAddressPairArbitrary, + fc.constantFrom(...METHODS), + fc.constantFrom(...ROUTES), + fc.string(), + ([actor], method, route, rawKey) => { + const first = hashIdempotencyKey(actor, method, route, rawKey); + const second = hashIdempotencyKey(actor, method, route, rawKey); + expect(second).toBe(first); + expect(first).toMatch(/^[a-f0-9]{64}$/); + }, + ), + { numRuns: NUM_RUNS }, + ); + }); + + it("changing the actor, method, route, or raw key each change the resulting hash", () => { + fc.assert( + fc.property( + distinctAddressPairArbitrary, + fc.constantFrom(...METHODS), + fc.constantFrom(...METHODS), + fc.constantFrom(...ROUTES), + fc.constantFrom(...ROUTES), + fc.string(), + fc.string(), + ([actorA, actorB], methodA, methodB, routeA, routeB, keyA, keyB) => { + fc.pre(methodA !== methodB); + fc.pre(routeA !== routeB); + fc.pre(keyA !== keyB); + + const base = hashIdempotencyKey(actorA, methodA, routeA, keyA); + expect(hashIdempotencyKey(actorB, methodA, routeA, keyA)).not.toBe(base); + expect(hashIdempotencyKey(actorA, methodB, routeA, keyA)).not.toBe(base); + expect(hashIdempotencyKey(actorA, methodA, routeB, keyA)).not.toBe(base); + expect(hashIdempotencyKey(actorA, methodA, routeA, keyB)).not.toBe(base); + }, + ), + { numRuns: NUM_RUNS }, + ); + }); +}); + +describe("computeRequestDigest (property)", () => { + it("is independent of an object's key insertion order", () => { + fc.assert( + fc.property(jsonObjectArbitrary, (obj) => { + const reversed = Object.fromEntries([...Object.entries(obj)].reverse()); + expect(computeRequestDigest(reversed)).toBe(computeRequestDigest(obj)); + }), + { numRuns: NUM_RUNS }, + ); + }); +}); + +describe("withIdempotency (property)", () => { + it("runs the operation exactly once per key, replaying the identical result thereafter", async () => { + await fc.assert( + fc.asyncProperty( + distinctAddressPairArbitrary, + fc.constantFrom(...METHODS), + fc.constantFrom(...ROUTES), + fc.string(), + jsonObjectArbitrary, + fc.integer({ min: 200, max: 299 }), + async ([actor], method, route, rawKey, rawBody, status) => { + const repository = new MemoryApiRepository(); + const scope = { actor, method, route, rawKey }; + let calls = 0; + const operation = async () => { + calls += 1; + return { status, body: { ok: true, calls } }; + }; + + const first = await withIdempotency(repository, scope, rawBody, operation); + const second = await withIdempotency(repository, scope, rawBody, operation); + + expect(calls).toBe(1); + expect(first.replayed).toBe(false); + expect(second.replayed).toBe(true); + expect(second.status).toBe(first.status); + expect(second.body).toEqual(first.body); + }, + ), + { numRuns: 40 }, + ); + }); + + it("rejects a replay under the same key with a different payload as idempotency_mismatch", async () => { + await fc.assert( + fc.asyncProperty( + distinctAddressPairArbitrary, + fc.constantFrom(...METHODS), + fc.constantFrom(...ROUTES), + fc.string(), + jsonObjectArbitrary, + jsonObjectArbitrary, + async ([actor], method, route, rawKey, bodyA, bodyB) => { + fc.pre(computeRequestDigest(bodyA) !== computeRequestDigest(bodyB)); + const repository = new MemoryApiRepository(); + const scope = { actor, method, route, rawKey }; + + await withIdempotency(repository, scope, bodyA, async () => ({ status: 200, body: {} })); + + await expect( + withIdempotency(repository, scope, bodyB, async () => ({ status: 200, body: {} })), + ).rejects.toMatchObject({ code: "idempotency_mismatch" }); + }, + ), + { numRuns: 40 }, + ); + }); +}); diff --git a/tests/unit/api/memory-repository.properties.test.ts b/tests/unit/api/memory-repository.properties.test.ts new file mode 100644 index 00000000..82650dbe --- /dev/null +++ b/tests/unit/api/memory-repository.properties.test.ts @@ -0,0 +1,115 @@ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { MemoryApiRepository } from "../../../src/server/api/memory-repository"; +import { + distinctAddressPairArbitrary, + mailboxPolicyArbitrary, + postageArbitrary, + senderRuleArbitrary, +} from "./arbitraries"; + +const NUM_RUNS = 100; + +const pendingPostageArbitrary = postageArbitrary.map((postage) => ({ + ...postage, + status: "pending" as const, +})); + +describe("MemoryApiRepository policy storage (property)", () => { + it("round-trips every generated policy exactly", async () => { + await fc.assert( + fc.asyncProperty( + distinctAddressPairArbitrary, + mailboxPolicyArbitrary, + async ([owner], policy) => { + const repository = new MemoryApiRepository(); + await repository.setPolicy(owner, policy); + await expect(repository.getPolicy(owner)).resolves.toEqual(policy); + }, + ), + { numRuns: NUM_RUNS }, + ); + }); + + it("isolates stored policy from later mutation of the caller's object", async () => { + await fc.assert( + fc.asyncProperty( + distinctAddressPairArbitrary, + mailboxPolicyArbitrary, + async ([owner], policy) => { + const repository = new MemoryApiRepository(); + const mutable = { ...policy }; + await repository.setPolicy(owner, mutable); + + mutable.allowUnknown = !mutable.allowUnknown; + mutable.requireVerified = !mutable.requireVerified; + mutable.minimumPostage = "999999999"; + + await expect(repository.getPolicy(owner)).resolves.toEqual(policy); + }, + ), + { numRuns: NUM_RUNS }, + ); + }); +}); + +describe("MemoryApiRepository sender rules (property)", () => { + it("round-trips every generated sender rule, isolated per owner/sender pair", async () => { + await fc.assert( + fc.asyncProperty( + distinctAddressPairArbitrary, + senderRuleArbitrary, + async ([owner, sender], rule) => { + const repository = new MemoryApiRepository(); + await expect(repository.getSenderRule(owner, sender)).resolves.toBe("default"); + + await repository.setSenderRule(owner, sender, rule); + await expect(repository.getSenderRule(owner, sender)).resolves.toBe(rule); + + await repository.setSenderRule(owner, sender, "default"); + await expect(repository.getSenderRule(owner, sender)).resolves.toBe("default"); + }, + ), + { numRuns: NUM_RUNS }, + ); + }); +}); + +describe("MemoryApiRepository postage storage (property)", () => { + it("round-trips every generated postage record exactly", async () => { + await fc.assert( + fc.asyncProperty(postageArbitrary, async (postage) => { + const repository = new MemoryApiRepository(); + await repository.setPostage(postage); + await expect(repository.getPostage(postage.messageId)).resolves.toEqual(postage); + }), + { numRuns: NUM_RUNS }, + ); + }); + + it("transitionPostage under concurrency: exactly one caller wins per messageId", async () => { + await fc.assert( + fc.asyncProperty( + pendingPostageArbitrary, + fc.integer({ min: 2, max: 10 }), + async (postage, concurrency) => { + const repository = new MemoryApiRepository(); + await repository.setPostage(postage); + + const results = await Promise.all( + Array.from({ length: concurrency }, () => + repository.transitionPostage(postage.messageId, "pending", "settled"), + ), + ); + + const applied = results.filter((result) => result.outcome === "applied"); + const conflicted = results.filter((result) => result.outcome === "conflict"); + expect(applied).toHaveLength(1); + expect(conflicted).toHaveLength(concurrency - 1); + }, + ), + { numRuns: 30 }, + ); + }); +}); diff --git a/tests/unit/api/policy-service.properties.test.ts b/tests/unit/api/policy-service.properties.test.ts new file mode 100644 index 00000000..692f8c84 --- /dev/null +++ b/tests/unit/api/policy-service.properties.test.ts @@ -0,0 +1,144 @@ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { MemoryApiRepository } from "../../../src/server/api/memory-repository"; +import { evaluateMailboxPolicy } from "../../../src/server/api/policy-service"; +import { + distinctAddressPairArbitrary, + mailboxPolicyArbitrary, + senderRuleArbitrary, + stroopAmountArbitrary, +} from "./arbitraries"; + +const NUM_RUNS = 150; + +const KNOWN_REASONS = [ + "sender_allowed", + "sender_blocked", + "unknown_senders_disabled", + "verification_required", + "insufficient_postage", + "policy_satisfied", +] as const; + +describe("evaluateMailboxPolicy (property)", () => { + it("always resolves to one of the six known reasons, consistent with `allowed`", async () => { + await fc.assert( + fc.asyncProperty( + distinctAddressPairArbitrary, + mailboxPolicyArbitrary, + senderRuleArbitrary, + stroopAmountArbitrary, + fc.boolean(), + async ([owner, sender], policy, rule, postage, verified) => { + const repository = new MemoryApiRepository(); + await repository.setPolicy(owner, policy); + if (rule !== "default") await repository.setSenderRule(owner, sender, rule); + + const result = await evaluateMailboxPolicy(repository, { + owner, + postage, + sender, + verified, + }); + + expect(KNOWN_REASONS).toContain(result.reason); + expect(result.allowed).toBe( + result.reason === "sender_allowed" || result.reason === "policy_satisfied", + ); + }, + ), + { numRuns: NUM_RUNS }, + ); + }); + + it("an explicit allow rule always short-circuits, regardless of policy, postage, or verification", async () => { + await fc.assert( + fc.asyncProperty( + distinctAddressPairArbitrary, + mailboxPolicyArbitrary, + stroopAmountArbitrary, + fc.boolean(), + async ([owner, sender], policy, postage, verified) => { + const repository = new MemoryApiRepository(); + await repository.setPolicy(owner, policy); + await repository.setSenderRule(owner, sender, "allow"); + + const result = await evaluateMailboxPolicy(repository, { + owner, + postage, + sender, + verified, + }); + + expect(result).toMatchObject({ allowed: true, reason: "sender_allowed" }); + }, + ), + { numRuns: NUM_RUNS }, + ); + }); + + it("an explicit block rule always rejects, regardless of policy, postage, or verification", async () => { + await fc.assert( + fc.asyncProperty( + distinctAddressPairArbitrary, + mailboxPolicyArbitrary, + stroopAmountArbitrary, + fc.boolean(), + async ([owner, sender], policy, postage, verified) => { + const repository = new MemoryApiRepository(); + await repository.setPolicy(owner, policy); + await repository.setSenderRule(owner, sender, "block"); + + const result = await evaluateMailboxPolicy(repository, { + owner, + postage, + sender, + verified, + }); + + expect(result).toMatchObject({ allowed: false, reason: "sender_blocked" }); + }, + ), + { numRuns: NUM_RUNS }, + ); + }); + + it("the default-rule decision matches allowUnknown/requireVerified/minimumPostage exactly", async () => { + await fc.assert( + fc.asyncProperty( + distinctAddressPairArbitrary, + mailboxPolicyArbitrary, + stroopAmountArbitrary, + fc.boolean(), + async ([owner, sender], policy, postage, verified) => { + const repository = new MemoryApiRepository(); + await repository.setPolicy(owner, policy); + // Sender rule left at its "default" fallback (never explicitly set). + + const result = await evaluateMailboxPolicy(repository, { + owner, + postage, + sender, + verified, + }); + + let expectedReason: (typeof KNOWN_REASONS)[number]; + if (!policy.allowUnknown) { + expectedReason = "unknown_senders_disabled"; + } else if (policy.requireVerified && !verified) { + expectedReason = "verification_required"; + } else if (BigInt(postage) < BigInt(policy.minimumPostage)) { + expectedReason = "insufficient_postage"; + } else { + expectedReason = "policy_satisfied"; + } + + expect(result.reason).toBe(expectedReason); + expect(result.allowed).toBe(expectedReason === "policy_satisfied"); + }, + ), + { numRuns: NUM_RUNS }, + ); + }); +}); diff --git a/tests/unit/api/postage-service.properties.test.ts b/tests/unit/api/postage-service.properties.test.ts new file mode 100644 index 00000000..6fbad3e5 --- /dev/null +++ b/tests/unit/api/postage-service.properties.test.ts @@ -0,0 +1,131 @@ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { createApiContext } from "../../../src/server/api/context"; +import { MemoryApiRepository } from "../../../src/server/api/memory-repository"; +import { submitPostage } from "../../../src/server/api/postage-service"; +import { distinctAddressPairArbitrary, hash32Arbitrary, I128_MAX } from "./arbitraries"; + +const NUM_RUNS = 60; +const NOW = new Date("2026-06-14T12:00:00.000Z"); + +/** A (minimumPostage, amount) pair with amount strictly below the minimum. */ +const belowMinimumArbitrary = fc.bigInt({ min: 1n, max: I128_MAX }).chain((minimumPostage) => + fc.record({ + minimumPostage: fc.constant(minimumPostage), + amount: fc.bigInt({ min: 0n, max: minimumPostage - 1n }), + }), +); + +/** A (minimumPostage, amount) pair with amount at or above the minimum. */ +const atOrAboveMinimumArbitrary = fc.bigInt({ min: 0n, max: I128_MAX }).chain((minimumPostage) => + fc.record({ + minimumPostage: fc.constant(minimumPostage), + amount: fc.bigInt({ min: minimumPostage, max: I128_MAX }), + }), +); + +describe("submitPostage (property)", () => { + it("always rejects an amount strictly below the mailbox minimum with 422", async () => { + await fc.assert( + fc.asyncProperty( + distinctAddressPairArbitrary, + hash32Arbitrary, + hash32Arbitrary, + belowMinimumArbitrary, + async ([recipient, sender], messageId, paymentHash, { minimumPostage, amount }) => { + const repository = new MemoryApiRepository(); + await repository.setPolicy(recipient, { + allowUnknown: true, + minimumPostage: minimumPostage.toString(), + requireVerified: false, + }); + + await expect( + submitPostage( + createApiContext(repository), + { amount: amount.toString(), messageId, paymentHash, recipient, sender }, + NOW, + ), + ).rejects.toMatchObject({ status: 422 }); + }, + ), + { numRuns: NUM_RUNS }, + ); + }); + + it("always accepts an amount at or above the mailbox minimum, recording it as pending", async () => { + await fc.assert( + fc.asyncProperty( + distinctAddressPairArbitrary, + hash32Arbitrary, + hash32Arbitrary, + atOrAboveMinimumArbitrary, + async ([recipient, sender], messageId, paymentHash, { minimumPostage, amount }) => { + const repository = new MemoryApiRepository(); + await repository.setPolicy(recipient, { + allowUnknown: true, + minimumPostage: minimumPostage.toString(), + requireVerified: false, + }); + + const result = await submitPostage( + createApiContext(repository), + { amount: amount.toString(), messageId, paymentHash, recipient, sender }, + NOW, + ); + + expect(result).toMatchObject({ + amount: amount.toString(), + createdAt: NOW.toISOString(), + status: "pending", + }); + }, + ), + { numRuns: NUM_RUNS }, + ); + }); + + it("rejects any resubmission under the same messageId as a 409 conflict, regardless of the new payload", async () => { + await fc.assert( + fc.asyncProperty( + distinctAddressPairArbitrary, + hash32Arbitrary, + hash32Arbitrary, + hash32Arbitrary, + fc.bigInt({ min: 0n, max: I128_MAX }), + fc.bigInt({ min: 0n, max: I128_MAX }), + async ([recipient, sender], messageId, paymentHashA, paymentHashB, amountA, amountB) => { + const repository = new MemoryApiRepository(); + await repository.setPolicy(recipient, { + allowUnknown: true, + minimumPostage: "0", + requireVerified: false, + }); + const context = createApiContext(repository); + + await submitPostage( + context, + { amount: amountA.toString(), messageId, paymentHash: paymentHashA, recipient, sender }, + NOW, + ); + + await expect( + submitPostage( + context, + { + amount: amountB.toString(), + messageId, + paymentHash: paymentHashB, + recipient, + sender, + }, + NOW, + ), + ).rejects.toMatchObject({ status: 409 }); + }, + ), + { numRuns: NUM_RUNS }, + ); + }); +}); diff --git a/tests/unit/api/receipt-service.properties.test.ts b/tests/unit/api/receipt-service.properties.test.ts new file mode 100644 index 00000000..61e0bc48 --- /dev/null +++ b/tests/unit/api/receipt-service.properties.test.ts @@ -0,0 +1,153 @@ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { MemoryApiRepository } from "../../../src/server/api/memory-repository"; +import { + assertReceiptParticipant, + createDeliveryReceipt, + markReceiptRead, +} from "../../../src/server/api/receipt-service"; +import { + distinctAddressPairArbitrary, + hash32Arbitrary, + instantMsArbitrary, + stellarAddressArbitrary, +} from "./arbitraries"; + +const NUM_RUNS = 60; + +describe("createDeliveryReceipt (property)", () => { + it("is idempotent: replays the first delivery receipt regardless of a later `now`", async () => { + await fc.assert( + fc.asyncProperty( + hash32Arbitrary, + distinctAddressPairArbitrary, + instantMsArbitrary, + instantMsArbitrary, + async (messageId, [recipient, sender], nowAMs, nowBMs) => { + const repository = new MemoryApiRepository(); + const input = { messageId, recipient, sender }; + + const first = await createDeliveryReceipt(repository, input, new Date(nowAMs)); + const second = await createDeliveryReceipt(repository, input, new Date(nowBMs)); + + expect(second).toEqual(first); + expect(first.deliveredAt).toBe(new Date(nowAMs).toISOString()); + }, + ), + { numRuns: NUM_RUNS }, + ); + }); + + it("rejects a second delivery receipt for the same messageId with different participants", async () => { + await fc.assert( + fc.asyncProperty( + hash32Arbitrary, + distinctAddressPairArbitrary, + distinctAddressPairArbitrary, + instantMsArbitrary, + async (messageId, [recipientA, senderA], [recipientB, senderB], nowMs) => { + fc.pre(recipientA !== recipientB || senderA !== senderB); + const repository = new MemoryApiRepository(); + await createDeliveryReceipt( + repository, + { messageId, recipient: recipientA, sender: senderA }, + new Date(nowMs), + ); + + await expect( + createDeliveryReceipt( + repository, + { messageId, recipient: recipientB, sender: senderB }, + new Date(nowMs), + ), + ).rejects.toMatchObject({ status: 409 }); + }, + ), + { numRuns: NUM_RUNS }, + ); + }); + + it("survives N concurrent duplicate deliveries, all resolving to one winning timestamp", async () => { + await fc.assert( + fc.asyncProperty( + hash32Arbitrary, + distinctAddressPairArbitrary, + instantMsArbitrary, + fc.integer({ min: 2, max: 8 }), + async (messageId, [recipient, sender], nowMs, concurrency) => { + const repository = new MemoryApiRepository(); + const input = { messageId, recipient, sender }; + + const results = await Promise.all( + Array.from({ length: concurrency }, (_, i) => + createDeliveryReceipt(repository, input, new Date(nowMs + i)), + ), + ); + + const distinctDeliveredAt = new Set(results.map((receipt) => receipt.deliveredAt)); + expect(distinctDeliveredAt.size).toBe(1); + }, + ), + { numRuns: 20 }, + ); + }); +}); + +describe("assertReceiptParticipant (property)", () => { + it("accepts both participants and rejects any other generated address", () => { + fc.assert( + fc.property( + hash32Arbitrary, + distinctAddressPairArbitrary, + stellarAddressArbitrary, + (messageId, [recipient, sender], other) => { + fc.pre(other !== recipient && other !== sender); + const receipt = { + deliveredAt: new Date().toISOString(), + messageId, + readAt: null, + recipient, + sender, + }; + + expect(() => assertReceiptParticipant(receipt, recipient)).not.toThrow(); + expect(() => assertReceiptParticipant(receipt, sender)).not.toThrow(); + expect(() => assertReceiptParticipant(receipt, other)).toThrowError( + expect.objectContaining({ status: 403 }), + ); + }, + ), + { numRuns: NUM_RUNS }, + ); + }); +}); + +describe("markReceiptRead (property)", () => { + it("records the read timestamp once; later calls with a different `now` replay the first", async () => { + await fc.assert( + fc.asyncProperty( + hash32Arbitrary, + distinctAddressPairArbitrary, + instantMsArbitrary, + instantMsArbitrary, + instantMsArbitrary, + async (messageId, [recipient, sender], deliveredMs, readAMs, readBMs) => { + const repository = new MemoryApiRepository(); + await createDeliveryReceipt( + repository, + { messageId, recipient, sender }, + new Date(deliveredMs), + ); + + const first = await markReceiptRead(repository, messageId, recipient, new Date(readAMs)); + const second = await markReceiptRead(repository, messageId, recipient, new Date(readBMs)); + + expect(second.readAt).toBe(first.readAt); + expect(first.readAt).toBe(new Date(readAMs).toISOString()); + }, + ), + { numRuns: NUM_RUNS }, + ); + }); +}); diff --git a/tests/unit/api/response.properties.test.ts b/tests/unit/api/response.properties.test.ts new file mode 100644 index 00000000..31fa97b8 --- /dev/null +++ b/tests/unit/api/response.properties.test.ts @@ -0,0 +1,90 @@ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { MAX_CORRELATION_ID_LENGTH, validateCorrelationId } from "../../../src/server/api/response"; + +const NUM_RUNS = 150; + +const TOKEN_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._~-".split(""); + +const validTokenArbitrary: fc.Arbitrary = fc + .array(fc.constantFrom(...TOKEN_CHARS), { minLength: 1, maxLength: MAX_CORRELATION_ID_LENGTH }) + .map((chars) => chars.join("")); + +describe("validateCorrelationId (property)", () => { + it("accepts every generated token within the length bound verbatim", () => { + fc.assert( + fc.property(validTokenArbitrary, (token) => { + expect(validateCorrelationId(token)).toBe(token); + }), + { numRuns: NUM_RUNS }, + ); + }); + + it("trims surrounding whitespace before validating", () => { + fc.assert( + fc.property( + validTokenArbitrary, + fc.constantFrom("", " ", "\t", " ", "\n "), + (token, padding) => { + expect(validateCorrelationId(`${padding}${token}${padding}`)).toBe(token); + }, + ), + { numRuns: NUM_RUNS }, + ); + }); + + it("rejects tokens exactly one character past the maximum length, and accepts exactly at it", () => { + fc.assert( + fc.property( + fc.array(fc.constantFrom(...TOKEN_CHARS), { + minLength: MAX_CORRELATION_ID_LENGTH, + maxLength: MAX_CORRELATION_ID_LENGTH, + }), + fc.constantFrom(...TOKEN_CHARS), + (chars, extra) => { + const atBoundary = chars.join(""); + const overBoundary = atBoundary + extra; + expect(validateCorrelationId(atBoundary)).toBe(atBoundary); + expect(validateCorrelationId(overBoundary)).toBeUndefined(); + }, + ), + { numRuns: NUM_RUNS }, + ); + }); + + it("rejects any token containing a disallowed character", () => { + const badChars = [",", "/", "@", "\\", "é"]; + + fc.assert( + fc.property( + validTokenArbitrary, + fc.nat(), + fc.constantFrom(...badChars), + (token, seed, badChar) => { + const index = seed % (token.length + 1); + const withBadChar = token.slice(0, index) + badChar + token.slice(index); + expect(validateCorrelationId(withBadChar)).toBeUndefined(); + }, + ), + { numRuns: NUM_RUNS }, + ); + }); + + it("rejects null, undefined, empty, and whitespace-only values", () => { + expect(validateCorrelationId(null)).toBeUndefined(); + expect(validateCorrelationId(undefined)).toBeUndefined(); + expect(validateCorrelationId("")).toBeUndefined(); + + const whitespaceArbitrary = fc + .array(fc.constantFrom(" ", "\t", "\n"), { minLength: 1, maxLength: 20 }) + .map((chars) => chars.join("")); + + fc.assert( + fc.property(whitespaceArbitrary, (ws) => { + expect(validateCorrelationId(ws)).toBeUndefined(); + }), + { numRuns: NUM_RUNS }, + ); + }); +});