Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/sdk/validation/responseValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,25 @@ import { LibrusResponseValidationError } from "../models/errors.js";

const MAX_ISSUES = 3;

/**
* Valibot's default issue messages embed the raw received value, e.g.
* `Invalid type: Expected number but received "hunter2-SECRET"`. The validated
* payload can carry secrets (passwords, tokens, child PII) that the Librus
* server echoes back, so the received portion must never reach a thrown error.
* Rebuild each message from the `Invalid <label>:` prefix and `issue.expected`
* only — both are schema-derived and contain no payload data.
*/
function redactReceivedValue(issue: v.BaseIssue<unknown>): string {
const label =
issue.message.match(/^(Invalid [^:]+):/)?.[1] ?? "Invalid value";
return issue.expected ? `${label}: Expected ${issue.expected}` : label;
}

function summarizeIssues(issues: v.BaseIssue<unknown>[]): string[] {
return issues.slice(0, MAX_ISSUES).map((issue) => {
const path = v.getDotPath(issue);
return path ? `${path}: ${issue.message}` : issue.message;
const message = redactReceivedValue(issue);
return path ? `${path}: ${message}` : message;
});
}

Expand Down
37 changes: 37 additions & 0 deletions test/requestTimeout.combineAbortSignals.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import fc from "fast-check";
import { describe, expect, it } from "vitest";

import { combineAbortSignals } from "../src/sdk/requestTimeout.js";

const FAST_CHECK_SETTINGS = { numRuns: 200, seed: 20260404 } as const;

describe("combineAbortSignals", () => {
it("returns timeout signal directly when upstream is undefined", () => {
const tc = new AbortController();
Expand Down Expand Up @@ -93,3 +96,37 @@ describe("combineAbortSignals", () => {
cleanup();
});
});

describe("combineAbortSignals — property", () => {
it("aborts exactly once with the first reason for any abort ordering", () => {
fc.assert(
fc.property(
fc.constantFrom("timeout", "upstream"),
fc.string(),
fc.string(),
(firstSource, firstReason, secondReason) => {
const tc = new AbortController();
const up = new AbortController();
const { signal, cleanup } = combineAbortSignals(tc.signal, up.signal);

let abortCount = 0;
signal.addEventListener("abort", () => {
abortCount += 1;
});

const [first, second] =
firstSource === "timeout" ? [tc, up] : [up, tc];
first.abort(firstReason);
second.abort(secondReason);

expect(signal.aborted).toBe(true);
expect(abortCount).toBe(1);
expect(signal.reason).toBe(firstReason);

cleanup();
},
),
FAST_CHECK_SETTINGS,
);
});
});
18 changes: 18 additions & 0 deletions test/responseValidation.truncation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,22 @@ describe("parseApiResponse — issue truncation", () => {

expect(err.details?.endpoint).toBe(ENDPOINT);
});

it("never leaks received payload values into issue summaries", () => {
const secretSchema = v.object({
password: v.number(),
token: v.number(),
});
const err = throwingParse(secretSchema, {
password: "hunter2-SECRET",
token: "Bearer-LEAK",
});
const issues = err.details?.issues ?? [];

expect(issues.length).toBeGreaterThan(0);
for (const issue of issues) {
expect(issue).not.toContain("hunter2-SECRET");
expect(issue).not.toContain("Bearer-LEAK");
}
});
});