inspector/clients/web/src/utils/uriTemplate.test.ts
cliffhall 9caac43da4 fix: accept only well-formed UTF-8 sequences, and validate before previewing
Addresses Copilot's round-18 review on #2035. Two suppressed comments, both
holes in my own recent fixes rather than in the original code.

1. Round 16's grouping keyed off the length-announcing lead byte alone, so
   it accepted sequences RFC 3629 forbids -- and the comment claiming they
   fell back to per-triplet counting was simply wrong. Measured on 05bbfe50:

     x{+v:1}  "%C0%80x"        =>  "x%C0%80"        overlong
              "%E0%80%80x"     =>  "x%E0%80%80"     overlong
              "%ED%A0%80x"     =>  "x%ED%A0%80"     UTF-16 surrogate
              "%F4%90%80%80x"  =>  "x%F4%90%80%80"  past U+10FFFF

   The lead-byte ranges are now the well-formed ones -- C2-DF, E0 only with
   A0-BF, ED only with 80-9F, F0 only with 90-BF, F4 only with 80-8F -- so
   each of the above falls back to one triplet, which is what the fallback
   was always documented to do. The legal boundary cases (%E0%A0%80,
   %ED%9F%BF, %F4%8F%BF%BF) are tested alongside, so tightening the ranges
   cannot quietly start splitting valid characters.

2. The preview never ran the read's template validation. It expands
   expression by expression, so `x://{a}}/{b}` with both fields filled
   previewed `x://1}/2` -- a URI whose stray `}` makes every read refuse.

   Validation moved into an exported `templateError` that both callers
   consult, so they cannot drift. It subsumes the narrower rule round 12
   added here ("leave the invalid part standing"), which still let the REST
   of such a template expand: the refusal is per template, not per
   expression, so the preview now shows the template verbatim -- literals
   unencoded included, since half-normalizing an unreadable template invents
   a string the server never published. Two existing tests asserted the
   weaker behavior and now assert this one.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-17 01:40:50 -04:00

151 lines
5.9 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { previewUriTemplate } from "./uriTemplate";
describe("previewUriTemplate", () => {
it("shows the template verbatim before anything is entered", () => {
expect(previewUriTemplate("file:///users/{userId}/profile", {})).toBe(
"file:///users/{userId}/profile",
);
});
it("substitutes only the expressions that are filled", () => {
expect(
previewUriTemplate("db://{tableName}/rows/{rowId}", {
tableName: "users",
rowId: "",
}),
).toBe("db://users/rows/{rowId}");
});
it("encodes the filled values the same way expansion does", () => {
expect(
previewUriTemplate("foobar://events/{topic}", { topic: "foo/bar" }),
).toBe("foobar://events/foo%2Fbar");
});
it("expands a partially-filled multi-name expression, as submitting would", () => {
// RFC 6570 drops the undefined names rather than the whole expression, so
// showing `{?one,two}` here would promise a URI the submit does not send.
expect(previewUriTemplate("x://a{?one,two}", { one: "1" })).toBe(
"x://a?one=1",
);
});
it("keeps a multi-name expression whole while none of its names is filled", () => {
expect(previewUriTemplate("x://a{?one,two}", {})).toBe("x://a{?one,two}");
});
it("expands each query expression independently, matching the submit", () => {
// Not `?one=1&two=2` -- see the expander's tests: the SDK's own matcher
// rejects that for this template. The preview must show what will be sent.
expect(
previewUriTemplate("x://a{?one}{?two}", { one: "1", two: "2" }),
).toBe("x://a?one=1?two=2");
});
it("restores a deferred expression that follows a resolved query expression", () => {
expect(previewUriTemplate("x://a{?one}{?two}", { one: "1" })).toBe(
"x://a?one=1{?two}",
);
});
it("falls back to the raw template when the SDK cannot parse it", () => {
expect(previewUriTemplate("x://a/{oops", {})).toBe("x://a/{oops");
});
});
describe("previewUriTemplate - multi-name expressions", () => {
it("applies the same correction the real expansion does", () => {
// Must match expandUriTemplate("x://{a,b}", ...) exactly, or the preview
// would promise a URI that submitting does not send.
expect(previewUriTemplate("x://{a,b}", { a: "foo/bar", b: "q" })).toBe(
"x://foo%2Fbar,q",
);
});
});
describe("preview with Object.prototype-colliding names", () => {
it("leaves a blank {?toString} standing rather than treating it as filled", () => {
// A bare `defined[name] !== undefined` finds Object.prototype.toString and
// would expand the expression instead of showing the placeholder.
expect(previewUriTemplate("x://a{?toString}", { toString: "" })).toBe(
"x://a{?toString}",
);
});
it("expands it once it really has a value", () => {
expect(previewUriTemplate("x://a{?toString}", { toString: "v" })).toBe(
"x://a?toString=v",
);
});
});
describe("previewUriTemplate - literals", () => {
// The preview promises the URI that submitting would send, so it applies the
// same RFC 6570 3.1 literal encoding the wire does.
it("percent-encodes a non-ASCII literal", () => {
expect(previewUriTemplate("café/{var}", { var: "value" })).toBe(
"caf%C3%A9/value",
);
});
it("encodes the literal even while an expression is still unfilled", () => {
expect(previewUriTemplate("café/{var}", {})).toBe("caf%C3%A9/{var}");
});
it("shows a template the read would refuse exactly as the server wrote it", () => {
// Not even the literal is encoded: an unreadable template is displayed
// verbatim rather than half-normalized into something never published.
expect(previewUriTemplate("x://café/{oops", {})).toBe("x://café/{oops");
});
});
describe("previewUriTemplate - it must never throw or over-promise", () => {
// Both of these were free when the preview expanded a rewritten template
// through the lenient `expandUriTemplate`; assembling part by part has to
// reinstate them explicitly.
it("falls back to the raw template when a value cannot be encoded", () => {
// An unpaired surrogate has no UTF-8 encoding, so `encodeURIComponent`
// raises URIError -- and this runs during render, where an escaping throw
// unmounts the panel rather than disabling its button. A paste delivers it.
expect(previewUriTemplate("x://{v}", { v: "\ud800" })).toBe("x://{v}");
});
it("leaves an invalid expression standing rather than expanding it", () => {
// `{a,}` keeps `a` in its varspecs, so expanding it would preview a URI
// for a template whose submission is refused outright.
expect(previewUriTemplate("x://{a,}", { a: "1" })).toBe("x://{a,}");
expect(previewUriTemplate("x://{id:abc}", { "id:abc": "1" })).toBe(
"x://{id:abc}",
);
});
it.each([
["an invalid varspec", "x://{a,}/{b}"],
["a stray closing brace", "x://{a}}/{b}"],
])(
"does not expand the valid expressions around %s either",
(_label, template) => {
// The refusal is per TEMPLATE, not per expression: every read of these
// is refused, so previewing `x://{a,}/2` or `x://1}/2` would promise a
// URI this form can never send. An earlier revision did exactly that.
expect(previewUriTemplate(template, { a: "1", b: "2" })).toBe(template);
},
);
});
describe("previewUriTemplate - the value ceiling", () => {
it("falls back before encoding an oversized value", () => {
// Without this the guard was half a guard: the read was refused while the
// preview still encoded the same value on every keystroke, so a large
// paste froze the UI anyway.
expect(previewUriTemplate("x://{v}", { v: "a".repeat(1_000_001) })).toBe(
"x://{v}",
);
});
it("still previews a value at the limit", () => {
const value = "a".repeat(1_000_000);
expect(previewUriTemplate("x://{v}", { v: value })).toBe(`x://${value}`);
});
});