fix: refuse a template that cannot expand, instead of reading the raw one

Ports the two behaviors PR #2033 (the parallel attempt at #1919, now closed
as a duplicate) got right and this branch did not.

1. An expression declaring no variable is a malformed template, not one
   with a member to skip. RFC 6570 requires at least one varspec per
   expression and admits no empty member, so `{}`, `{,}`, `{a,}`, `{?}`
   and `{*}` (an explode modifier is not a name) now make the template
   invalid. Skipping them was the more dangerous reading: `x://{}`
   expanded to `x://` while the form rendered no inputs, so its
   "everything required is filled" check was vacuously true and it
   submitted a URI that is not the template the server published.

2. The read is withheld when expansion fails. `expandUriTemplate`'s
   raw-template fallback exists for the preview, which runs during render
   -- but the submit path used it too, so an invalid template was read
   with its braces intact and the server answered with a confusing "not
   found" for a defect that is not the user's. `tryExpandUriTemplate`
   returns the URI or the reason as a value the caller cannot mistake for
   one; the panel gates Read Resource on it and prints the reason.
   This also covers a value that cannot be encoded -- an unpaired
   surrogate has no UTF-8 encoding, so `encodeURIComponent` throws
   `URIError` on it, and a text input can hold one via paste.

`requiredGroups` now skips an expression naming no variable: an empty
group can never be satisfied, so it would gate the form a second time on
a condition nothing can meet, and the "any one of" hint built from it
would name no fields. The accurate reason is the malformed template,
which the expansion gate reports.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-16 22:32:27 -04:00
commit 1ad3edd96d

View file

@ -6,6 +6,7 @@ import {
parseUriTemplate,
requiredGroups,
templateVariables,
tryExpandUriTemplate,
} from "@inspector/core/mcp/uriTemplate.js";
describe("parseUriTemplate", () => {
@ -506,3 +507,65 @@ describe("variable names that collide with Object.prototype", () => {
expect(expandUriTemplate("x://a{?__proto__}", {})).toBe("x://a");
});
});
describe("an expression that declares no variable", () => {
// RFC 6570 requires at least one varspec per expression and admits no empty
// member, so each of these is a malformed template rather than one with a
// member to skip. Skipping is the dangerous reading: `x://{}` would expand to
// `x://` while the form rendered no inputs, so its "everything required is
// filled" check is vacuously true and it submits a URI that is not the
// template the server published.
it.each([
["an empty expression", "x://{}"],
["only a separator", "x://{,}"],
["a missing member", "x://{a,}"],
["an operator and no name", "x://a{?}"],
// `*` is an explode modifier, not a name -- it is stripped before the
// emptiness test, so this must be rejected the same way.
["only an explode modifier", "x://{*}"],
])("strict rejects %s", (_label, template) => {
expect(() => expandUriTemplateStrict(template, { a: "1" })).toThrow(
/Invalid RFC 6570 varspec/,
);
});
it("declares no required group for it, so only the expansion gate reports it", () => {
// An empty group could never be satisfied, and the form's "any one of"
// message built from it would name no fields -- the accurate reason is the
// malformed template, which tryExpandUriTemplate gives.
expect(requiredGroups("x://{}")).toEqual([]);
expect(tryExpandUriTemplate("x://{}", {}).error).toMatch(
/Invalid RFC 6570 varspec/,
);
});
it("still reports the names a partly-empty expression does declare", () => {
expect(templateVariables("x://{a,}").map((v) => v.name)).toEqual(["a"]);
});
it("leaves the template unexpanded on the display path", () => {
expect(expandUriTemplate("x://{}", {})).toBe("x://{}");
});
});
describe("tryExpandUriTemplate", () => {
it("returns the URI and no error when the template expands", () => {
expect(
tryExpandUriTemplate("foobar://events/{topic}", { topic: "foo/bar" }),
).toEqual({ uri: "foobar://events/foo%2Fbar" });
});
it("returns the reason instead of a URI for a malformed template", () => {
const result = tryExpandUriTemplate("x://{id:abc}", { id: "7" });
expect(result.uri).toBeUndefined();
expect(result.error).toMatch(/Invalid RFC 6570 varspec/);
});
it("reports a value that cannot be encoded rather than throwing", () => {
// A lone surrogate has no UTF-8 encoding, so encodeURIComponent throws
// URIError on it -- and a text input can hold one via paste.
const result = tryExpandUriTemplate("x://{v}", { v: "\ud800" });
expect(result.uri).toBeUndefined();
expect(result.error).toBeTruthy();
});
});