| 1 | import { describe, it } from "@effect/vitest" |
| 2 | import { |
| 3 | Context, |
| 4 | Effect, |
| 5 | Inspectable, |
| 6 | Option, |
| 7 | Redactable, |
| 8 | Redacted, |
| 9 | Result, |
| 10 | Schema, |
| 11 | SchemaGetter, |
| 12 | SchemaIssue, |
| 13 | SchemaParser |
| 14 | } from "effect" |
| 15 | import { format, formatJson } from "effect/Formatter" |
| 16 | import { assertFalse, assertTrue, deepStrictEqual, strictEqual } from "./utils/assert.ts" |
| 17 | |
| 18 | class SensitiveData implements Redactable.Redactable { |
| 19 | constructor(private secret: string) {} |
| 20 | |
| 21 | f(s: string) { |
| 22 | this.secret += s |
| 23 | } |
| 24 | |
| 25 | toString() { |
| 26 | return this.secret |
| 27 | } |
| 28 | |
| 29 | toJSON() { |
| 30 | return { secret: this.secret } |
| 31 | } |
| 32 | |
| 33 | [Redactable.symbolRedactable]() { |
| 34 | return { secret: "[REDACTED]" } |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | const data = new SensitiveData("my-secret-key") |
| 39 | |
| 40 | describe("Formatter", () => { |
| 41 | describe("format", () => { |
| 42 | it("null", () => { |
| 43 | strictEqual(format(null), `null`) |
| 44 | }) |
| 45 | |
| 46 | it("undefined", () => { |
| 47 | strictEqual(format(undefined), `undefined`) |
| 48 | }) |
| 49 | |
| 50 | it("string", () => { |
| 51 | strictEqual(format("a"), `"a"`) |
| 52 | }) |
| 53 | |
| 54 | it("number", () => { |
| 55 | strictEqual(format(123), `123`) |
| 56 | }) |
| 57 | |
| 58 | it("boolean", () => { |
| 59 | strictEqual(format(true), `true`) |
| 60 | }) |
| 61 | |
| 62 | it("symbol", () => { |
| 63 | strictEqual(format(Symbol("a")), `Symbol(a)`) |
| 64 | }) |
| 65 | |
| 66 | it("bigint", () => { |
| 67 | strictEqual(format(BigInt(123)), `123n`) |
| 68 | }) |
| 69 | |
| 70 | it("custom toString method", () => { |
| 71 | strictEqual(format({ toString: () => "custom" }), `custom`) |
| 72 | }) |
| 73 | |
| 74 | it("handles throwing property getters", () => { |
| 75 | const value = Object.defineProperty({ safe: 1 }, "unsafe", { |
| 76 | enumerable: true, |
| 77 | get() { |
| 78 | throw new Error("getter defect") |
| 79 | } |
| 80 | }) |
| 81 | |
| 82 | strictEqual(format(value), `{"safe":1,"unsafe":"[property access threw]"}`) |
| 83 | }) |
| 84 | |
| 85 | it("handles hostile Proxies", () => { |
| 86 | const { proxy, revoke } = Proxy.revocable({}, {}) |
| 87 | revoke() |
| 88 | |
| 89 | strictEqual(format(proxy), `[inspection threw]`) |
| 90 | }) |
| 91 | |
| 92 | it("does not expose a value when redaction throws", () => { |
| 93 | const value = { |
| 94 | toString: () => "secret", |
| 95 | [Redactable.symbolRedactable]() { |
| 96 | throw new Error("redaction defect") |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | strictEqual(format(value), `[inspection threw]`) |
| 101 | }) |
| 102 | |
| 103 | it("array", () => { |
| 104 | strictEqual(format([1, 2, 3n]), `[1,2,3n]`) |
| 105 | }) |
| 106 | |
| 107 | it("circular array", () => { |
| 108 | const arr: any = [1] |
| 109 | arr.push(arr) |
| 110 | strictEqual(format(arr), `[1,[Circular]]`) |
| 111 | }) |
| 112 | |
| 113 | it("circular Map contents", () => { |
| 114 | const obj: any = { a: 1 } |
| 115 | const map = new Map([["obj", obj]]) |
| 116 | obj.map = map |
| 117 | strictEqual(format(map), `Map([["obj",{"a":1,"map":[Circular]}]])`) |
| 118 | }) |
| 119 | |
| 120 | it("circular Set contents", () => { |
| 121 | const obj: any = { a: 1 } |
| 122 | const set = new Set([obj]) |
| 123 | obj.set = set |
| 124 | strictEqual(format(set), `Set([{"a":1,"set":[Circular]}])`) |
| 125 | }) |
| 126 | |
| 127 | it("object", () => { |
| 128 | strictEqual(format({ a: 1 }), `{"a":1}`) |
| 129 | strictEqual(format({ a: 1, b: 2 }), `{"a":1,"b":2}`) |
| 130 | strictEqual(format({ [Symbol.for("a")]: 1 }), `{Symbol(a):1}`) |
| 131 | strictEqual(format({ a: 1, b: [1, 2, 3n] }), `{"a":1,"b":[1,2,3n]}`) |
| 132 | }) |
| 133 | |
| 134 | it("circular object", () => { |
| 135 | const obj: any = { a: 1 } |
| 136 | obj.b = obj |
| 137 | strictEqual(format(obj), `{"a":1,"b":[Circular]}`) |
| 138 | }) |
| 139 | |
| 140 | it("preserves repeated non-circular references", () => { |
| 141 | const shared = { value: 1 } |
| 142 | strictEqual(format({ first: shared, second: shared }), `{"first":{"value":1},"second":{"value":1}}`) |
| 143 | }) |
| 144 | |
| 145 | it("object with null prototype", () => { |
| 146 | strictEqual(format(Object.create(null)), `{}`) |
| 147 | strictEqual(format(Object.create(null, { a: { value: 1 } })), `{"a":1}`) |
| 148 | }) |
| 149 | |
| 150 | it("function", () => { |
| 151 | strictEqual( |
| 152 | format(() => {}), |
| 153 | `() => {}` |
| 154 | ) |
| 155 | strictEqual( |
| 156 | format(() => { |
| 157 | return 1 |
| 158 | }), |
| 159 | `() => {\n\t\t\t\treturn 1;\n\t\t\t}` |
| 160 | ) |
| 161 | }) |
| 162 | |
| 163 | it("Error", () => { |
| 164 | strictEqual(format(new Error("a")), `Error: a`) |
| 165 | strictEqual(format(new Error("a", { cause: "b" })), `Error: a (cause: "b")`) |
| 166 | strictEqual(format(new Error("a", { cause: 0 })), `Error: a (cause: 0)`) |
| 167 | strictEqual(format(new Error("a", { cause: false })), `Error: a (cause: false)`) |
| 168 | strictEqual(format(new Error("a", { cause: "" })), `Error: a (cause: "")`) |
| 169 | strictEqual(format(new Error("a", { cause: null })), `Error: a (cause: null)`) |
| 170 | strictEqual(format(new Error("a", { cause: 0n })), `Error: a (cause: 0n)`) |
| 171 | strictEqual(format(new Error("a", { cause: Number.NaN })), `Error: a (cause: NaN)`) |
| 172 | strictEqual(format(new Error("a", { cause: undefined })), `Error: a`) |
| 173 | }) |
| 174 | |
| 175 | it("Date", () => { |
| 176 | strictEqual(format(new Date(0)), `1970-01-01T00:00:00.000Z`) |
| 177 | strictEqual(format(new Date("invalid")), `Invalid Date`) |
| 178 | }) |
| 179 | |
| 180 | it("RegExp", () => { |
| 181 | strictEqual(format(/a/), `/a/`) |
| 182 | }) |
| 183 | |
| 184 | it("Set", () => { |
| 185 | strictEqual(format(new Set([1, 2, 3])), `Set([1,2,3])`) |
| 186 | }) |
| 187 | |
| 188 | it("Map", () => { |
| 189 | strictEqual(format(new Map([["a", 1], ["b", 2]])), `Map([["a",1],["b",2]])`) |
| 190 | }) |
| 191 | |
| 192 | it("FormData", () => { |
| 193 | const formData = new FormData() |
| 194 | formData.append("a", "1") |
| 195 | strictEqual(format(formData), `FormData([["a","1"]])`) |
| 196 | }) |
| 197 | |
| 198 | it("Uint8Array", () => { |
| 199 | const uint8Array = new Uint8Array([1, 2, 3]) |
| 200 | strictEqual(format(uint8Array), `Uint8Array([1,2,3])`) |
| 201 | }) |
| 202 | |
| 203 | it("Redacted", () => { |
| 204 | strictEqual(format(Redacted.make("a")), `<redacted>`) |
| 205 | }) |
| 206 | |
| 207 | it("Option", () => { |
| 208 | strictEqual( |
| 209 | format(Option.some(1)), |
| 210 | `some(1)` |
| 211 | ) |
| 212 | strictEqual( |
| 213 | format(Option.none()), |
| 214 | `none()` |
| 215 | ) |
| 216 | }) |
| 217 | |
| 218 | it("Schema.Class", () => { |
| 219 | class A extends Schema.Class<A>("A")({ |
| 220 | a: Schema.String |
| 221 | }) {} |
| 222 | strictEqual(format(new A({ a: "a" })), `A({"a":"a"})`) |
| 223 | }) |
| 224 | |
| 225 | it("Schema.Error", () => { |
| 226 | class E extends Schema.Error<E>("E")({ |
| 227 | a: Schema.String |
| 228 | }) {} |
| 229 | strictEqual(format(new E({ a: "a" })), `E`) |
| 230 | }) |
| 231 | |
| 232 | it("Context.Service", () => { |
| 233 | const MyService = Context.Service<{ readonly value: number }>("MyService") |
| 234 | strictEqual(format(MyService).includes(`"key": "MyService"`), true) |
| 235 | }) |
| 236 | |
| 237 | describe("whitespace", () => { |
| 238 | it("object", () => { |
| 239 | strictEqual(format({ a: 1 }, { space: 2 }), `{"a":1}`) |
| 240 | strictEqual( |
| 241 | format({ a: 1, b: 2 }, { space: 2 }), |
| 242 | `{ |
| 243 | "a": 1, |
| 244 | "b": 2 |
| 245 | }` |
| 246 | ) |
| 247 | strictEqual( |
| 248 | format({ a: 1, b: [1, 2, 3n] }, { space: 2 }), |
| 249 | `{ |
| 250 | "a": 1, |
| 251 | "b": [ |
| 252 | 1, |
| 253 | 2, |
| 254 | 3n |
| 255 | ] |
| 256 | }` |
| 257 | ) |
| 258 | strictEqual(format({ [Symbol.for("a")]: 1 }, { space: 2 }), `{Symbol(a):1}`) |
| 259 | }) |
| 260 | |
| 261 | it("circular object", () => { |
| 262 | const obj: any = { a: 1 } |
| 263 | obj.b = obj |
| 264 | strictEqual( |
| 265 | format(obj, { space: 2 }), |
| 266 | `{ |
| 267 | "a": 1, |
| 268 | "b": [Circular] |
| 269 | }` |
| 270 | ) |
| 271 | }) |
| 272 | |
| 273 | it("object with null prototype", () => { |
| 274 | strictEqual(format(Object.create(null), { space: 2 }), `{}`) |
| 275 | strictEqual( |
| 276 | format(Object.create(null, { a: { value: 1 } }), { space: 2 }), |
| 277 | `{"a":1}` |
| 278 | ) |
| 279 | }) |
| 280 | }) |
| 281 | |
| 282 | it("should redact sensitive data", () => { |
| 283 | strictEqual(format(data), `{"secret":"[REDACTED]"}`) |
| 284 | strictEqual(format({ a: data }), `{"a":{"secret":"[REDACTED]"}}`) |
| 285 | }) |
| 286 | |
| 287 | it("redacts before specialized representations", () => { |
| 288 | const array = Object.assign(["secret"], { |
| 289 | [Redactable.symbolRedactable]: () => ["[REDACTED]"] |
| 290 | }) |
| 291 | const date = Object.assign(new Date(0), { |
| 292 | [Redactable.symbolRedactable]: () => "[REDACTED]" |
| 293 | }) |
| 294 | |
| 295 | strictEqual(format(array), `["[REDACTED]"]`) |
| 296 | strictEqual(format(date), `"[REDACTED]"`) |
| 297 | }) |
| 298 | |
| 299 | it("preserves formatting options for redacted representations", () => { |
| 300 | let toStringCalls = 0 |
| 301 | const value = { |
| 302 | [Redactable.symbolRedactable]: () => ({ |
| 303 | a: 1, |
| 304 | b: 2, |
| 305 | toString() { |
| 306 | toStringCalls++ |
| 307 | return "custom" |
| 308 | } |
| 309 | }) |
| 310 | } |
| 311 | |
| 312 | assertTrue(format(value, { space: 2, ignoreToString: true }).startsWith(`{\n "a": 1,\n "b": 2,`)) |
| 313 | strictEqual(toStringCalls, 0) |
| 314 | }) |
| 315 | |
| 316 | it("tracks circular references through redacted representations", () => { |
| 317 | const value: Redactable.Redactable = { |
| 318 | [Redactable.symbolRedactable]: () => ({ value }) |
| 319 | } |
| 320 | |
| 321 | strictEqual(format(value), `{"value":[Circular]}`) |
| 322 | }) |
| 323 | }) |
| 324 | |
| 325 | describe("formatJson", () => { |
| 326 | it("returns valid JSON for undefined input", () => { |
| 327 | strictEqual(formatJson(undefined), `null`) |
| 328 | }) |
| 329 | |
| 330 | it("should omit circular references", () => { |
| 331 | const obj: any = { a: 1 } |
| 332 | obj.self = obj |
| 333 | strictEqual(formatJson(obj), `{"a":1}`) |
| 334 | }) |
| 335 | |
| 336 | it("preserves repeated non-circular references", () => { |
| 337 | const shared = { a: 1 } |
| 338 | strictEqual(formatJson({ left: shared, right: shared }), `{"left":{"a":1},"right":{"a":1}}`) |
| 339 | }) |
| 340 | |
| 341 | it("should stringify BigInt values", () => { |
| 342 | strictEqual(formatJson(123n), `"123n"`) |
| 343 | strictEqual(formatJson({ value: 123n }), `{"value":"123n"}`) |
| 344 | strictEqual(formatJson([1n, 2n]), `["1n","2n"]`) |
| 345 | }) |
| 346 | |
| 347 | it("should serialize Error objects with name, message, and enumerable fields", () => { |
| 348 | strictEqual(formatJson(new Error("boom")), `{"name":"Error","message":"boom"}`) |
| 349 | strictEqual(formatJson({ error: new Error("boom") }), `{"error":{"name":"Error","message":"boom"}}`) |
| 350 | |
| 351 | const nodeErr = Object.assign(new Error("ENOENT: no such file or directory"), { |
| 352 | errno: -2, |
| 353 | code: "ENOENT", |
| 354 | syscall: "open", |
| 355 | path: "/tmp/foo" |
| 356 | }) |
| 357 | strictEqual( |
| 358 | formatJson(nodeErr), |
| 359 | `{"errno":-2,"code":"ENOENT","syscall":"open","path":"/tmp/foo","name":"Error","message":"ENOENT: no such file or directory"}` |
| 360 | ) |
| 361 | |
| 362 | const circularErr = new Error("boom") |
| 363 | ;(circularErr as any).self = circularErr |
| 364 | strictEqual(formatJson(circularErr), `{"name":"Error","message":"boom"}`) |
| 365 | }) |
| 366 | |
| 367 | it("should keep structured serialization for Errors that define toJSON", () => { |
| 368 | class Tagged extends Error { |
| 369 | toJSON() { |
| 370 | return { _tag: "Tagged", message: "boom" } |
| 371 | } |
| 372 | } |
| 373 | strictEqual(formatJson(new Tagged("boom")), `{"_tag":"Tagged","message":"boom"}`) |
| 374 | }) |
| 375 | |
| 376 | it("should redact sensitive data", () => { |
| 377 | const date = Object.assign(new Date(0), { |
| 378 | [Redactable.symbolRedactable]: () => "[REDACTED]" |
| 379 | }) |
| 380 | |
| 381 | strictEqual(formatJson(data), `{"secret":"[REDACTED]"}`) |
| 382 | strictEqual(formatJson({ a: data }), `{"a":{"secret":"[REDACTED]"}}`) |
| 383 | strictEqual(formatJson([data]), `[{"secret":"[REDACTED]"}]`) |
| 384 | strictEqual(formatJson(date), `"[REDACTED]"`) |
| 385 | }) |
| 386 | }) |
| 387 | |
| 388 | describe("Inspectable.toJson", () => { |
| 389 | it("redacts before toJSON", () => { |
| 390 | deepStrictEqual( |
| 391 | Inspectable.toJson(data), |
| 392 | { secret: "[REDACTED]" } |
| 393 | ) |
| 394 | }) |
| 395 | |
| 396 | it("preserves plain objects as structured values", () => { |
| 397 | const value: any = { count: 1n } |
| 398 | value.self = value |
| 399 | |
| 400 | strictEqual(Inspectable.toJson(value), value) |
| 401 | }) |
| 402 | }) |
| 403 | |
| 404 | describe("Inspectable.toStringUnknown", () => { |
| 405 | it("should stringify BigInt values", () => { |
| 406 | strictEqual(Inspectable.toStringUnknown(123n), `123n`) |
| 407 | strictEqual( |
| 408 | Inspectable.toStringUnknown({ value: 123n }), |
| 409 | `{ |
| 410 | "value": "123n" |
| 411 | }` |
| 412 | ) |
| 413 | }) |
| 414 | }) |
| 415 | |
| 416 | describe("SchemaIssue reportInput", () => { |
| 417 | const formatIssue = SchemaIssue.makeFormatterDefault() |
| 418 | |
| 419 | it("supports every value-bearing issue constructor", () => { |
| 420 | const input = { value: "secret" } |
| 421 | const options = { reportInput: true } as const |
| 422 | const invalidValue = new SchemaIssue.InvalidValue() |
| 423 | const union = Schema.Union([Schema.String, Schema.Number]).ast |
| 424 | const issues: ReadonlyArray<SchemaIssue.Issue> = [ |
| 425 | new SchemaIssue.InvalidType(Schema.String.ast, input, options), |
| 426 | new SchemaIssue.InvalidValue(undefined, input, options), |
| 427 | new SchemaIssue.UnexpectedKey(Schema.String.ast, input, options), |
| 428 | new SchemaIssue.Forbidden(undefined, input, options), |
| 429 | new SchemaIssue.OneOf(union, [Schema.String.ast], input, options), |
| 430 | new SchemaIssue.Filter(Schema.isMinLength(1), invalidValue, input, options), |
| 431 | new SchemaIssue.Encoding(Schema.String.ast, invalidValue, input, options), |
| 432 | new SchemaIssue.Composite(Schema.String.ast, [invalidValue], input, options), |
| 433 | new SchemaIssue.AnyOf(union, [invalidValue], input, options) |
| 434 | ] |
| 435 | |
| 436 | for (const issue of issues) { |
| 437 | assertTrue(SchemaIssue.hasInput(issue)) |
| 438 | strictEqual(issue.input, input) |
| 439 | assertTrue(Object.keys(issue).includes("input")) |
| 440 | } |
| 441 | |
| 442 | assertFalse(SchemaIssue.hasInput(new SchemaIssue.InvalidType(Schema.String.ast))) |
| 443 | assertFalse(SchemaIssue.hasInput(new SchemaIssue.Pointer([], invalidValue))) |
| 444 | assertFalse(SchemaIssue.hasInput(new SchemaIssue.MissingKey(undefined))) |
| 445 | }) |
| 446 | |
| 447 | it("reports input only when enabled", () => { |
| 448 | const input = { secret: "value" } |
| 449 | const disabled = SchemaParser.decodeUnknownResult(Schema.String)(input) |
| 450 | |
| 451 | assertTrue(Result.isFailure(disabled)) |
| 452 | assertTrue(disabled.failure._tag === "InvalidType") |
| 453 | assertFalse(SchemaIssue.hasInput(disabled.failure)) |
| 454 | strictEqual(formatIssue(disabled.failure), "Expected string") |
| 455 | |
| 456 | const enabled = SchemaParser.decodeUnknownResult(Schema.String)(input, { reportInput: true }) |
| 457 | assertTrue(Result.isFailure(enabled)) |
| 458 | assertTrue(enabled.failure._tag === "InvalidType") |
| 459 | assertTrue(SchemaIssue.hasInput(enabled.failure)) |
| 460 | strictEqual(enabled.failure.input, input) |
| 461 | assertTrue(Object.keys(enabled.failure).includes("input")) |
| 462 | assertTrue(JSON.stringify(enabled.failure).includes("secret")) |
| 463 | deepStrictEqual(Object.getOwnPropertyDescriptor(enabled.failure, "input"), { |
| 464 | value: input, |
| 465 | enumerable: true, |
| 466 | writable: true, |
| 467 | configurable: true |
| 468 | }) |
| 469 | strictEqual(formatIssue(enabled.failure), `Expected string, got {"secret":"value"}`) |
| 470 | }) |
| 471 | |
| 472 | it("distinguishes undefined input from missing input", () => { |
| 473 | const undefinedResult = SchemaParser.decodeUnknownResult(Schema.String)(undefined, { reportInput: true }) |
| 474 | assertTrue(Result.isFailure(undefinedResult)) |
| 475 | assertTrue(SchemaIssue.hasInput(undefinedResult.failure)) |
| 476 | strictEqual(undefinedResult.failure.input, undefined) |
| 477 | strictEqual(formatIssue(undefinedResult.failure), "Expected string, got undefined") |
| 478 | |
| 479 | const missingResult = SchemaParser.decodeUnknownResult(Schema.Struct({ value: Schema.String }))( |
| 480 | {}, |
| 481 | { reportInput: true } |
| 482 | ) |
| 483 | assertTrue(Result.isFailure(missingResult)) |
| 484 | assertTrue(missingResult.failure._tag === "Composite") |
| 485 | const pointer = missingResult.failure.issues[0] |
| 486 | assertTrue(pointer._tag === "Pointer") |
| 487 | assertFalse(SchemaIssue.hasInput(pointer)) |
| 488 | assertTrue(pointer.issue._tag === "MissingKey") |
| 489 | assertFalse(SchemaIssue.hasInput(pointer.issue)) |
| 490 | strictEqual(formatIssue(pointer.issue), "Missing key") |
| 491 | }) |
| 492 | |
| 493 | it("reports local inputs for structs", () => { |
| 494 | const input = { value: "not a number", extra: "secret" } |
| 495 | const result = SchemaParser.decodeUnknownResult(Schema.Struct({ value: Schema.Number }))(input, { |
| 496 | errors: "all", |
| 497 | onExcessProperty: "error", |
| 498 | reportInput: true |
| 499 | }) |
| 500 | |
| 501 | assertTrue(Result.isFailure(result)) |
| 502 | assertTrue(result.failure._tag === "Composite") |
| 503 | strictEqual(result.failure.input, input) |
| 504 | |
| 505 | const extraPointer = result.failure.issues[0] |
| 506 | assertTrue(extraPointer._tag === "Pointer") |
| 507 | assertFalse(SchemaIssue.hasInput(extraPointer)) |
| 508 | assertTrue(extraPointer.issue._tag === "UnexpectedKey") |
| 509 | strictEqual(extraPointer.issue.input, "secret") |
| 510 | |
| 511 | const valuePointer = result.failure.issues[1] |
| 512 | assertTrue(valuePointer._tag === "Pointer") |
| 513 | assertFalse(SchemaIssue.hasInput(valuePointer)) |
| 514 | assertTrue(valuePointer.issue._tag === "InvalidType") |
| 515 | strictEqual(valuePointer.issue.input, "not a number") |
| 516 | }) |
| 517 | |
| 518 | it("reports filter input on parser-created issues", () => { |
| 519 | const result = SchemaParser.decodeUnknownResult(Schema.String.check(Schema.isMinLength(1)))("", { |
| 520 | reportInput: true |
| 521 | }) |
| 522 | |
| 523 | assertTrue(Result.isFailure(result)) |
| 524 | assertTrue(result.failure._tag === "Composite") |
| 525 | const filter = result.failure.issues[0] |
| 526 | assertTrue(filter._tag === "Filter") |
| 527 | strictEqual(filter.input, "") |
| 528 | assertTrue(filter.issue._tag === "InvalidValue") |
| 529 | strictEqual(filter.issue.input, "") |
| 530 | strictEqual(formatIssue(filter), `Expected a value with a length of at least 1, got ""`) |
| 531 | }) |
| 532 | |
| 533 | it("allows user-provided transformations to report their input", () => { |
| 534 | const schema = Schema.String.pipe( |
| 535 | Schema.decode({ |
| 536 | decode: SchemaGetter.transformEffect((input, options) => |
| 537 | Effect.fail(new SchemaIssue.InvalidValue(undefined, input, options)) |
| 538 | ), |
| 539 | encode: SchemaGetter.passthrough() |
| 540 | }) |
| 541 | ) |
| 542 | const result = SchemaParser.decodeUnknownResult(schema)("secret", { reportInput: true }) |
| 543 | |
| 544 | assertTrue(Result.isFailure(result)) |
| 545 | assertTrue(result.failure._tag === "Encoding") |
| 546 | assertTrue(result.failure.issue._tag === "InvalidValue") |
| 547 | strictEqual(result.failure.issue.input, "secret") |
| 548 | strictEqual(formatIssue(result.failure), `Invalid data "secret"`) |
| 549 | }) |
| 550 | |
| 551 | it("reports input from effectful checks", () => { |
| 552 | const schema = Schema.String.pipe( |
| 553 | Schema.decode({ |
| 554 | decode: SchemaGetter.checkEffect(() => Effect.succeed(false)), |
| 555 | encode: SchemaGetter.passthrough() |
| 556 | }) |
| 557 | ) |
| 558 | const result = SchemaParser.decodeUnknownResult(schema)("secret", { reportInput: true }) |
| 559 | |
| 560 | assertTrue(Result.isFailure(result)) |
| 561 | assertTrue(result.failure._tag === "Encoding") |
| 562 | assertTrue(result.failure.issue._tag === "InvalidValue") |
| 563 | strictEqual(result.failure.issue.input, "secret") |
| 564 | strictEqual(formatIssue(result.failure), `Invalid data "secret"`) |
| 565 | }) |
| 566 | |
| 567 | it("does not infer input through a user-provided pointer", () => { |
| 568 | const inner = new SchemaIssue.InvalidValue() |
| 569 | const pointer = new SchemaIssue.Pointer(["value"], inner) |
| 570 | const schema = Schema.String.check(Schema.makeFilter(() => pointer)) |
| 571 | const result = SchemaParser.decodeUnknownResult(schema)("secret", { reportInput: true }) |
| 572 | |
| 573 | assertTrue(Result.isFailure(result)) |
| 574 | assertTrue(result.failure._tag === "Composite") |
| 575 | const filter = result.failure.issues[0] |
| 576 | assertTrue(filter._tag === "Filter") |
| 577 | strictEqual(filter.input, "secret") |
| 578 | strictEqual(filter.issue, pointer) |
| 579 | assertFalse(SchemaIssue.hasInput(pointer)) |
| 580 | assertFalse(SchemaIssue.hasInput(inner)) |
| 581 | }) |
| 582 | |
| 583 | it("reports input for union wrappers", () => { |
| 584 | const anyOf = SchemaParser.decodeUnknownResult(Schema.Union([Schema.Literal("a"), Schema.Literal("b")]))( |
| 585 | "c", |
| 586 | { reportInput: true } |
| 587 | ) |
| 588 | assertTrue(Result.isFailure(anyOf)) |
| 589 | assertTrue(anyOf.failure._tag === "AnyOf") |
| 590 | strictEqual(anyOf.failure.input, "c") |
| 591 | strictEqual(formatIssue(anyOf.failure), `Expected "a" | "b", got "c"`) |
| 592 | |
| 593 | const oneOf = SchemaParser.decodeUnknownResult( |
| 594 | Schema.Union([Schema.Literal("a"), Schema.Literal("a")], { mode: "oneOf" }) |
| 595 | )("a", { reportInput: true }) |
| 596 | assertTrue(Result.isFailure(oneOf)) |
| 597 | assertTrue(oneOf.failure._tag === "OneOf") |
| 598 | strictEqual(oneOf.failure.input, "a") |
| 599 | strictEqual(formatIssue(oneOf.failure), `Expected exactly one member to match the input "a"`) |
| 600 | }) |
| 601 | |
| 602 | it("runtime reportInput overrides preset options and reaches nested schemas", () => { |
| 603 | const enabledResult = SchemaParser.decodeUnknownResult(Schema.String, { reportInput: false })(1, { |
| 604 | reportInput: true |
| 605 | }) |
| 606 | assertTrue(Result.isFailure(enabledResult)) |
| 607 | strictEqual(enabledResult.failure.input, 1) |
| 608 | |
| 609 | const disabledResult = SchemaParser.decodeUnknownResult(Schema.String, { reportInput: true })(1, { |
| 610 | reportInput: false |
| 611 | }) |
| 612 | assertTrue(Result.isFailure(disabledResult)) |
| 613 | assertFalse(SchemaIssue.hasInput(disabledResult.failure)) |
| 614 | |
| 615 | const nested = Schema.Struct({ value: Schema.String }) |
| 616 | const nestedInput = { value: 1 } |
| 617 | const nestedResult = SchemaParser.decodeUnknownResult(nested)(nestedInput, { reportInput: true }) |
| 618 | assertTrue(Result.isFailure(nestedResult)) |
| 619 | assertTrue(nestedResult.failure._tag === "Composite") |
| 620 | strictEqual(nestedResult.failure.input, nestedInput) |
| 621 | const pointer = nestedResult.failure.issues[0] |
| 622 | assertTrue(pointer._tag === "Pointer") |
| 623 | assertTrue(SchemaIssue.hasInput(pointer.issue)) |
| 624 | strictEqual(pointer.issue.input, 1) |
| 625 | }) |
| 626 | |
| 627 | it.effect("distinguishes present undefined from absent input in forbidden", () => |
| 628 | Effect.gen(function*() { |
| 629 | const getter = SchemaGetter.forbidden<never, undefined>(() => "not allowed") |
| 630 | assertTrue(getter._tag === "TransformOptionalEffect") |
| 631 | const present = yield* getter.transform(Option.some(undefined), { reportInput: true }).pipe(Effect.flip) |
| 632 | assertTrue(present._tag === "Forbidden") |
| 633 | assertTrue(SchemaIssue.hasInput(present)) |
| 634 | strictEqual(present.input, undefined) |
| 635 | strictEqual(formatIssue(present), "not allowed") |
| 636 | |
| 637 | const absent = yield* getter.transform(Option.none(), { reportInput: true }).pipe(Effect.flip) |
| 638 | assertTrue(absent._tag === "Forbidden") |
| 639 | assertFalse(SchemaIssue.hasInput(absent)) |
| 640 | })) |
| 641 | |
| 642 | it("formats reported inputs with the historical templates", () => { |
| 643 | const options = { reportInput: true } as const |
| 644 | const invalidValue = new SchemaIssue.InvalidValue() |
| 645 | const union = Schema.Union([Schema.String, Schema.Number]).ast |
| 646 | |
| 647 | strictEqual(formatIssue(new SchemaIssue.InvalidType(Schema.String.ast, 1, options)), "Expected string, got 1") |
| 648 | strictEqual(formatIssue(new SchemaIssue.InvalidValue(undefined, 1, options)), "Invalid data 1") |
| 649 | strictEqual( |
| 650 | formatIssue(new SchemaIssue.UnexpectedKey(Schema.String.ast, 1, options)), |
| 651 | "Unexpected key with value 1" |
| 652 | ) |
| 653 | strictEqual( |
| 654 | formatIssue(new SchemaIssue.OneOf(union, [Schema.String.ast], 1, options)), |
| 655 | "Expected exactly one member to match the input 1" |
| 656 | ) |
| 657 | strictEqual(formatIssue(new SchemaIssue.Forbidden(undefined, 1, options)), "Forbidden operation") |
| 658 | strictEqual( |
| 659 | formatIssue(new SchemaIssue.Filter(Schema.isMinLength(1), invalidValue, 1, options)), |
| 660 | "Expected a value with a length of at least 1, got 1" |
| 661 | ) |
| 662 | strictEqual(formatIssue(new SchemaIssue.AnyOf(union, [], 1, options)), "Expected string | number, got 1") |
| 663 | }) |
| 664 | |
| 665 | it("does not inherit input from wrappers", () => { |
| 666 | const options = { reportInput: true } as const |
| 667 | const inner = new SchemaIssue.InvalidValue() |
| 668 | |
| 669 | strictEqual( |
| 670 | formatIssue(new SchemaIssue.Encoding(Schema.String.ast, inner, "secret", options)), |
| 671 | "Expected a valid value" |
| 672 | ) |
| 673 | strictEqual( |
| 674 | formatIssue(new SchemaIssue.Composite(Schema.String.ast, [inner], "secret", options)), |
| 675 | "Expected a valid value" |
| 676 | ) |
| 677 | strictEqual( |
| 678 | formatIssue( |
| 679 | new SchemaIssue.Filter( |
| 680 | Schema.isMinLength(1), |
| 681 | new SchemaIssue.InvalidValue(undefined, "secret", options) |
| 682 | ) |
| 683 | ), |
| 684 | "Expected a value with a length of at least 1" |
| 685 | ) |
| 686 | }) |
| 687 | |
| 688 | it("uses expected annotations only for InvalidValue", () => { |
| 689 | strictEqual(formatIssue(new SchemaIssue.InvalidValue({ expected: "a valid value" })), "Expected a valid value") |
| 690 | strictEqual( |
| 691 | formatIssue(new SchemaIssue.InvalidValue({ expected: "a valid value" }, "secret", { reportInput: true })), |
| 692 | `Expected a valid value, got "secret"` |
| 693 | ) |
| 694 | strictEqual( |
| 695 | formatIssue( |
| 696 | new SchemaIssue.InvalidValue( |
| 697 | { expected: "ignored", message: "custom message" }, |
| 698 | "secret", |
| 699 | { reportInput: true } |
| 700 | ) |
| 701 | ), |
| 702 | "custom message" |
| 703 | ) |
| 704 | strictEqual( |
| 705 | formatIssue(new SchemaIssue.Forbidden({ expected: "a permitted operation" }, "secret", { reportInput: true })), |
| 706 | "Forbidden operation" |
| 707 | ) |
| 708 | const filter = new SchemaIssue.Filter( |
| 709 | Schema.isMinLength(1), |
| 710 | new SchemaIssue.InvalidValue({ expected: "a custom value" }, "secret", { reportInput: true }) |
| 711 | ) |
| 712 | strictEqual(formatIssue(filter), `Expected a custom value, got "secret"`) |
| 713 | deepStrictEqual( |
| 714 | SchemaIssue.makeFormatterStandardSchemaV1({ checkHook: () => undefined })(filter).issues, |
| 715 | [{ message: `Expected a custom value, got "secret"`, path: [] }] |
| 716 | ) |
| 717 | }) |
| 718 | |
| 719 | it("uses expected annotations in built-ins", () => { |
| 720 | const disabled = SchemaParser.decodeUnknownResult(Schema.URLFromString)("invalid") |
| 721 | assertTrue(Result.isFailure(disabled)) |
| 722 | strictEqual(formatIssue(disabled.failure), "Expected a valid URL string") |
| 723 | |
| 724 | const enabled = SchemaParser.decodeUnknownResult(Schema.URLFromString)("invalid", { reportInput: true }) |
| 725 | assertTrue(Result.isFailure(enabled)) |
| 726 | strictEqual(formatIssue(enabled.failure), `Expected a valid URL string, got "invalid"`) |
| 727 | }) |
| 728 | |
| 729 | it("keeps Redacted input redacted", () => { |
| 730 | const input = Redacted.make("secret", { label: "password" }) |
| 731 | const result = SchemaParser.decodeUnknownResult(Schema.Redacted(Schema.Number))(input, { reportInput: true }) |
| 732 | |
| 733 | assertTrue(Result.isFailure(result)) |
| 734 | assertTrue(result.failure._tag === "Composite") |
| 735 | const pointer = result.failure.issues[0] |
| 736 | assertTrue(pointer._tag === "Pointer") |
| 737 | assertTrue(pointer.issue._tag === "InvalidValue") |
| 738 | strictEqual(pointer.issue.input, input) |
| 739 | strictEqual(formatIssue(result.failure), "Invalid data <redacted:password>\n at [\"value\"]") |
| 740 | }) |
| 741 | |
| 742 | it("preserves custom messages and hooks", () => { |
| 743 | const options = { reportInput: true } as const |
| 744 | const invalidType = new SchemaIssue.InvalidType( |
| 745 | Schema.String.annotate({ message: "custom message" }).ast, |
| 746 | "secret", |
| 747 | options |
| 748 | ) |
| 749 | strictEqual(formatIssue(invalidType), "custom message") |
| 750 | |
| 751 | const leafFormatter = SchemaIssue.makeFormatterStandardSchemaV1({ leafHook: () => "redacted leaf" }) |
| 752 | deepStrictEqual(leafFormatter(invalidType).issues, [{ message: "redacted leaf", path: [] }]) |
| 753 | |
| 754 | const filter = new SchemaIssue.Filter(Schema.isMinLength(1), new SchemaIssue.InvalidValue(), "secret", options) |
| 755 | const checkFormatter = SchemaIssue.makeFormatterStandardSchemaV1({ checkHook: () => "redacted check" }) |
| 756 | deepStrictEqual(checkFormatter(filter).issues, [{ message: "redacted check", path: [] }]) |
| 757 | }) |
| 758 | |
| 759 | it("reports input in Standard Schema messages without adding an input field", () => { |
| 760 | const standardSchema = Schema.toStandardSchemaV1(Schema.String, { |
| 761 | parseOptions: { reportInput: true } |
| 762 | }) |
| 763 | const result = standardSchema["~standard"].validate(1) |
| 764 | if (result instanceof Promise) { |
| 765 | throw new Error("Expected synchronous validation") |
| 766 | } |
| 767 | deepStrictEqual(result, { |
| 768 | issues: [{ message: "Expected string, got 1", path: [] }] |
| 769 | }) |
| 770 | }) |
| 771 | }) |
| 772 | }) |