mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
1510 lines
48 KiB
TypeScript
1510 lines
48 KiB
TypeScript
import { expect, it } from "vitest";
|
|
import {
|
|
analysisViewCaptureEvidence,
|
|
analysisViewLayoutEvidence,
|
|
environmentManifestCaptureFixture,
|
|
} from "../../../tests/fixtures/analysisView.js";
|
|
import {
|
|
analysisViewRequestSchema,
|
|
projectAnalysisView,
|
|
type AnalysisViewRequest,
|
|
} from "./analysisView.js";
|
|
|
|
type ManifestView = Extract<
|
|
AnalysisViewRequest,
|
|
{ kind: "environment-manifest" }
|
|
>;
|
|
type Rules = ManifestView["template_rules"];
|
|
const ALL_RULES: Rules = [
|
|
"after-uid-literal",
|
|
"opaque-token",
|
|
"sibling-variance",
|
|
];
|
|
const RECORDED_VALUES = [
|
|
"probe folder",
|
|
"Main Org",
|
|
"isGrafanaAdmin",
|
|
"Annotation added",
|
|
"probe@example.test",
|
|
"console.log",
|
|
"<!doctype",
|
|
"JFIF",
|
|
"accesscontrol=true",
|
|
"dash-db",
|
|
];
|
|
|
|
const parentOf = (
|
|
capture = environmentManifestCaptureFixture(),
|
|
parameters: unknown = { sensitive_value_count: 0 },
|
|
) => {
|
|
const evidence = analysisViewCaptureEvidence(capture);
|
|
return {
|
|
evidenceId: evidence.evidence_id,
|
|
operation: evidence.operation,
|
|
normalizedResult: evidence.normalized_result,
|
|
subject: evidence.subject,
|
|
limitations: [],
|
|
parameters,
|
|
};
|
|
};
|
|
|
|
const view = (rules: Rules = [], includeQuery = true): ManifestView => ({
|
|
kind: "environment-manifest",
|
|
template_rules: rules,
|
|
include_query_parameter_names: includeQuery,
|
|
});
|
|
|
|
const manifest = (
|
|
rules: Rules = [],
|
|
capture = environmentManifestCaptureFixture(),
|
|
includeQuery = true,
|
|
) => {
|
|
const result = projectAnalysisView(
|
|
parentOf(capture),
|
|
view(rules, includeQuery),
|
|
);
|
|
if (!result.ok || result.value.kind !== "environment-manifest")
|
|
throw new Error("Expected an environment manifest");
|
|
return result.value;
|
|
};
|
|
|
|
const line = (entry: { method: string; path_template: string }): string =>
|
|
`${entry.method} ${entry.path_template}`;
|
|
|
|
/** Replace the fixture's records with synthetic request lines on one or more origins. */
|
|
const syntheticCapture = (
|
|
samples: readonly (readonly [
|
|
method: string,
|
|
url: string,
|
|
mimeType?: string | null,
|
|
postText?: string,
|
|
postMimeType?: string,
|
|
])[],
|
|
) => {
|
|
const capture = environmentManifestCaptureFixture();
|
|
capture.total_records = samples.length;
|
|
capture.records = samples.map(
|
|
(
|
|
[method, url, mimeType, postText, postMimeType = "application/json"],
|
|
ordinal,
|
|
) => ({
|
|
...capture.records[0]!,
|
|
ordinal,
|
|
reported: {
|
|
request: {
|
|
method,
|
|
url,
|
|
...(postText === undefined
|
|
? {}
|
|
: { postData: { mimeType: postMimeType, text: postText } }),
|
|
},
|
|
response: {
|
|
status: 200,
|
|
content: {
|
|
mimeType: mimeType === undefined ? "application/json" : mimeType,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
return capture;
|
|
};
|
|
|
|
it("keeps every template identical to the api-surface grouping when no opt-in rule is selected", () => {
|
|
const parent = parentOf();
|
|
const result = projectAnalysisView(parent, view());
|
|
if (!result.ok || result.value.kind !== "environment-manifest")
|
|
throw new Error("Expected an environment manifest");
|
|
expect(projectAnalysisView(parent, view())).toEqual(result);
|
|
const projected = result.value;
|
|
const surface = projectAnalysisView(parent, {
|
|
kind: "api-surface",
|
|
offset: 0,
|
|
limit: 100,
|
|
});
|
|
if (!surface.ok || surface.value.kind !== "api-surface")
|
|
throw new Error("Expected an API surface");
|
|
expect(projected).toMatchObject({
|
|
origin: "http://grafana.example",
|
|
name: "grafana-example",
|
|
parent_operation: "inspect_web_network_capture",
|
|
parent_evidence_id: parent.evidenceId,
|
|
artifact: { path: "/fixtures/environment-manifest.har" },
|
|
coverage: { status: "complete-within-view", examined: 22, total: 22 },
|
|
templated_groups: 6,
|
|
view: {
|
|
kind: "environment-manifest",
|
|
template_rules: [],
|
|
include_query_parameter_names: true,
|
|
},
|
|
});
|
|
expect(projected.view_digest).toMatch(/^[0-9a-f]{64}$/u);
|
|
for (const tool of projected.tools)
|
|
expect(tool.path_template).toBe(tool.api_surface_template);
|
|
const primaryGroups = surface.value.groups
|
|
.filter((group) => group.origin === projected.origin)
|
|
.map(line)
|
|
.sort();
|
|
expect(
|
|
[
|
|
...projected.tools.map(line),
|
|
...projected.excluded
|
|
.filter((entry) => entry.origin === projected.origin)
|
|
.map(line),
|
|
].sort(),
|
|
).toEqual(primaryGroups);
|
|
expect(projected.tools.map((tool) => tool.name)).toEqual([
|
|
"get_annotations",
|
|
"post_annotations",
|
|
"delete_dashboards_uid_acb4v6",
|
|
"get_dashboards_uid_acb4v6",
|
|
"get_dashboards_uid_acb4v6_versions",
|
|
"get_datasources_uid_dg1jo96tern5sb_health",
|
|
"get_folders",
|
|
"post_folders",
|
|
"get_folders_fg1jo91e57e2ob",
|
|
"put_folders_fg1jo91e57e2ob",
|
|
"get_folders_general",
|
|
"get_org",
|
|
"get_plugins_grafana_lokiexplore_app_settings",
|
|
"get_plugins_grafana_pyroscope_app_settings",
|
|
"get_plugins_grafana_testdata_datasource_settings",
|
|
"get_search",
|
|
"delete_teams_by_id",
|
|
"get_teams_by_id",
|
|
"get_teams_members_by_id",
|
|
"get_user",
|
|
"get_users_by_id",
|
|
"get_users_orgs_by_id",
|
|
]);
|
|
expect(
|
|
projected.tools.find((tool) => tool.name === "get_users_by_id"),
|
|
).toEqual({
|
|
name: "get_users_by_id",
|
|
method: "GET",
|
|
path_template: "/api/users/{numeric}",
|
|
api_surface_template: "/api/users/{numeric}",
|
|
params: [
|
|
{
|
|
index: 2,
|
|
name: "id",
|
|
rule: "numeric",
|
|
basis: "inferred",
|
|
observed_values: ["2", "3"],
|
|
},
|
|
],
|
|
query_parameter_names: [],
|
|
body_shape: null,
|
|
statuses: [200],
|
|
content_types: ["application/json"],
|
|
record_ordinals: [5, 6],
|
|
});
|
|
for (const value of RECORDED_VALUES)
|
|
expect(JSON.stringify(projected)).not.toContain(value);
|
|
});
|
|
|
|
it("templates uid literals, opaque tokens and identifier-like siblings when every rule is selected", () => {
|
|
const projected = manifest(ALL_RULES);
|
|
expect(manifest(ALL_RULES)).toEqual(projected);
|
|
const templates = projected.tools.map((tool) => tool.path_template);
|
|
expect(templates).toEqual(
|
|
expect.arrayContaining([
|
|
"/api/dashboards/uid/{uid}",
|
|
"/api/folders/{uid}",
|
|
"/api/plugins/{plugin_id}/settings",
|
|
"/api/datasources/uid/{uid}/health",
|
|
]),
|
|
);
|
|
expect(projected.tools).toHaveLength(19);
|
|
expect(projected.templated_groups).toBe(13);
|
|
const byName = new Map(projected.tools.map((tool) => [tool.name, tool]));
|
|
expect(byName.get("get_folders_by_uid")).toMatchObject({
|
|
method: "GET",
|
|
path_template: "/api/folders/{uid}",
|
|
api_surface_template: "/api/folders/fg1jo91e57e2ob",
|
|
params: [
|
|
{
|
|
index: 2,
|
|
name: "uid",
|
|
rule: "opaque-token",
|
|
observed_values: ["fg1jo91e57e2ob", "general"],
|
|
},
|
|
],
|
|
query_parameter_names: ["accesscontrol"],
|
|
record_ordinals: [12, 13],
|
|
});
|
|
expect(byName.get("get_plugins_settings_by_plugin_id")).toMatchObject({
|
|
api_surface_template: "/api/plugins/grafana-testdata-datasource/settings",
|
|
params: [
|
|
{
|
|
index: 2,
|
|
name: "plugin_id",
|
|
rule: "sibling-variance",
|
|
observed_values: [
|
|
"grafana-lokiexplore-app",
|
|
"grafana-pyroscope-app",
|
|
"grafana-testdata-datasource",
|
|
],
|
|
},
|
|
],
|
|
record_ordinals: [15, 16, 17],
|
|
});
|
|
expect(byName.get("delete_dashboards_by_uid")).toMatchObject({
|
|
method: "DELETE",
|
|
params: [{ index: 3, name: "uid", rule: "after-uid-literal" }],
|
|
});
|
|
expect(byName.get("put_folders_by_uid")).toMatchObject({
|
|
body_shape: {
|
|
type: "object",
|
|
properties: { overwrite: "boolean", title: "string" },
|
|
},
|
|
});
|
|
expect(projected.limitations).toContain(
|
|
"Opt-in template rules after-uid-literal, opaque-token, sibling-variance merge recorded literals by shape; merged parameters may conflate distinct resources.",
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"after-uid-literal",
|
|
["/api/dashboards/uid/{uid}", "/api/datasources/uid/{uid}/health"],
|
|
["/api/folders/fg1jo91e57e2ob", "/api/folders/general"],
|
|
],
|
|
[
|
|
"opaque-token",
|
|
["/api/folders/{uid}", "/api/datasources/uid/{uid}/health"],
|
|
["/api/dashboards/uid/acb4v6", "/api/folders/general"],
|
|
],
|
|
[
|
|
"sibling-variance",
|
|
["/api/plugins/{plugin_id}/settings"],
|
|
[
|
|
"/api/dashboards/uid/acb4v6",
|
|
"/api/datasources/uid/dg1jo96tern5sb/health",
|
|
// `general` is not identifier-like, so on its own the rule leaves both folder literals alone;
|
|
// with opaque-token selected the token's parameter absorbs it instead.
|
|
"/api/folders/fg1jo91e57e2ob",
|
|
"/api/folders/general",
|
|
],
|
|
],
|
|
] as const)(
|
|
"applies %s on its own and leaves the other literals in place",
|
|
(rule, templated, literal) => {
|
|
const templates = manifest([rule]).tools.map((tool) => tool.path_template);
|
|
expect(templates).toEqual(
|
|
expect.arrayContaining([...templated, ...literal]),
|
|
);
|
|
const params = manifest([rule]).tools.flatMap((tool) => tool.params);
|
|
expect(
|
|
params.every((param) => param.rule === rule || param.rule === "numeric"),
|
|
).toBe(true);
|
|
},
|
|
);
|
|
|
|
it("derives value-free body shapes, observed query names and record pointers", () => {
|
|
const projected = manifest();
|
|
const byName = new Map(projected.tools.map((tool) => [tool.name, tool]));
|
|
expect(byName.get("post_annotations")?.body_shape).toEqual({
|
|
type: "object",
|
|
properties: {
|
|
dashboardUID: "string",
|
|
panelId: "number",
|
|
tags: "array",
|
|
text: "string",
|
|
time: "number",
|
|
},
|
|
});
|
|
expect(byName.get("post_folders")?.body_shape).toEqual({
|
|
type: "object",
|
|
properties: { title: "string" },
|
|
});
|
|
expect(byName.get("get_folders")?.body_shape).toBeNull();
|
|
expect(byName.get("get_search")?.query_parameter_names).toEqual([
|
|
"query",
|
|
"type",
|
|
]);
|
|
expect(byName.get("get_folders")?.query_parameter_names).toEqual([
|
|
"limit",
|
|
"page",
|
|
]);
|
|
expect(byName.get("get_annotations")?.query_parameter_names).toEqual([
|
|
"dashboardUID",
|
|
"limit",
|
|
]);
|
|
expect(projected.records).toHaveLength(24);
|
|
expect(projected.records.map((record) => record.ordinal)).toEqual(
|
|
Array.from({ length: 24 }, (_, index) => index + 3),
|
|
);
|
|
expect(projected.records[0]).toEqual({
|
|
ordinal: 3,
|
|
method: "GET",
|
|
path: "/api/org",
|
|
status: 200,
|
|
content_type: "application/json",
|
|
});
|
|
for (const record of projected.records)
|
|
expect(Object.keys(record).sort()).toEqual([
|
|
"content_type",
|
|
"method",
|
|
"ordinal",
|
|
"path",
|
|
"status",
|
|
]);
|
|
const silent = manifest([], environmentManifestCaptureFixture(), false);
|
|
expect(
|
|
silent.tools.every((tool) => tool.query_parameter_names.length === 0),
|
|
).toBe(true);
|
|
expect(silent.limitations.join(" ")).not.toContain("query parameter names");
|
|
expect(silent.view.include_query_parameter_names).toBe(false);
|
|
});
|
|
|
|
it("excludes other origins, pages, assets and non-JSON groups with reasons and fixed unknowns", () => {
|
|
const projected = manifest();
|
|
expect(projected.excluded).toEqual([
|
|
{
|
|
method: "GET",
|
|
origin: "http://grafana.example",
|
|
path_template: "/avatar/{hex}",
|
|
reason: "ui-page-or-asset",
|
|
},
|
|
{
|
|
method: "GET",
|
|
origin: "http://grafana.example",
|
|
path_template: "/dashboards",
|
|
reason: "ui-page-or-asset",
|
|
},
|
|
{
|
|
method: "GET",
|
|
origin: "http://grafana.example",
|
|
path_template: "/public/build/app.js",
|
|
reason: "ui-page-or-asset",
|
|
},
|
|
{
|
|
method: "GET",
|
|
origin: "https://grafana.example.com",
|
|
path_template: "/api/plugins/announcements",
|
|
reason: "other-origin",
|
|
},
|
|
]);
|
|
expect(projected.unknowns.map((unknown) => unknown.kind)).toEqual([
|
|
"authorization-model",
|
|
"response-semantics",
|
|
"state-transition",
|
|
]);
|
|
expect(
|
|
projected.unknowns.every((unknown) => unknown.statement.length > 0),
|
|
).toBe(true);
|
|
const mixed = manifest(
|
|
[],
|
|
syntheticCapture([
|
|
[
|
|
"GET",
|
|
"https://app.test/download/report.bin",
|
|
"application/octet-stream",
|
|
],
|
|
["GET", "https://app.test/api/export", "text/csv"],
|
|
["GET", "https://app.test/notes/readme", "text/markdown"],
|
|
["GET", "https://app.test/health", null],
|
|
["GET", "https://app.test/api/items/9", "application/vnd.api+json"],
|
|
["GET", "invalid URL"],
|
|
["GET", "https://app.test/status", " ; charset=utf-8"],
|
|
]),
|
|
);
|
|
expect(mixed.tools.map(line)).toEqual([
|
|
"GET /api/export",
|
|
"GET /api/items/{numeric}",
|
|
"GET /notes/readme",
|
|
]);
|
|
expect(
|
|
mixed.excluded.map((entry) => `${entry.path_template}:${entry.reason}`),
|
|
).toEqual([
|
|
"/download/report.bin:non-json",
|
|
"/health:non-json",
|
|
"/status:non-json",
|
|
]);
|
|
expect(mixed.limitations).toContain(
|
|
"1 retained records without a usable request line are not represented in the manifest.",
|
|
);
|
|
expect(mixed.records.map((record) => record.content_type)).toEqual([
|
|
"text/csv",
|
|
"text/markdown",
|
|
"application/vnd.api+json",
|
|
]);
|
|
});
|
|
|
|
it("names tools deterministically from literals, strips dotted labels and disambiguates collisions", () => {
|
|
const projected = manifest(
|
|
[],
|
|
syntheticCapture([
|
|
[
|
|
"GET",
|
|
"https://k8s.test/apis/dashboard.grafana.app/v1beta1/namespaces/default/dashboards/adf1",
|
|
],
|
|
["GET", "https://k8s.test/api/a.b/c"],
|
|
["GET", "https://k8s.test/api/a.c/c"],
|
|
[
|
|
"PATCH",
|
|
"https://k8s.test/api/x/1/y/2",
|
|
"application/json",
|
|
'{"name":"n"}',
|
|
],
|
|
["GET", `https://k8s.test/api/${"segment-".repeat(12)}end`],
|
|
["GET", "https://k8s.test/api"],
|
|
["get", "https://k8s.test/api/lower"],
|
|
]),
|
|
);
|
|
const names = projected.tools.map((tool) => tool.name);
|
|
expect(names).toEqual(
|
|
expect.arrayContaining([
|
|
"get_dashboard_dashboards_adf1",
|
|
"get_a_c",
|
|
"get_a_c_2",
|
|
"patch_x_y_by_id_id_2",
|
|
"get",
|
|
"get_lower",
|
|
]),
|
|
);
|
|
expect(new Set(names).size).toBe(names.length);
|
|
for (const name of names) {
|
|
expect(name.length).toBeLessThanOrEqual(60);
|
|
expect(name).toMatch(/^[a-z0-9]+(?:_[a-z0-9]+)*$/u);
|
|
}
|
|
const patch = projected.tools.find((tool) => tool.method === "PATCH");
|
|
expect(patch).toMatchObject({
|
|
path_template: "/api/x/{numeric}/y/{numeric}",
|
|
params: [
|
|
{ index: 2, name: "id", observed_values: ["1"] },
|
|
{ index: 4, name: "id_2", observed_values: ["2"] },
|
|
],
|
|
body_shape: { type: "object", properties: { name: "string" } },
|
|
});
|
|
expect(
|
|
manifest([], syntheticCapture([["GET", "https://k8s.test/api/a.b/c"]]))
|
|
.tools,
|
|
).toEqual(
|
|
manifest([], syntheticCapture([["GET", "https://k8s.test/api/a.b/c"]]))
|
|
.tools,
|
|
);
|
|
});
|
|
|
|
it("merges identifier-like siblings at index two or later and never folds stop words", () => {
|
|
const projected = manifest(
|
|
["sibling-variance"],
|
|
syntheticCapture([
|
|
["GET", "https://s.test/api/users/7/search"],
|
|
["GET", "https://s.test/api/users/7/settings"],
|
|
["GET", "https://s.test/api/items/a1"],
|
|
["GET", "https://s.test/api/items/b2"],
|
|
["GET", "https://s.test/api/things/x"],
|
|
["GET", "https://s.test/api/things/y"],
|
|
["GET", "https://s.test/api/widgets/red-one"],
|
|
["GET", "https://s.test/api/widgets/blue-two"],
|
|
["GET", "https://s.test/api/widgets/green-three"],
|
|
["GET", "https://s.test/api/alpha"],
|
|
["GET", "https://s.test/api/beta1"],
|
|
["GET", "https://s.test/api/items/a1/versions"],
|
|
["GET", "https://s.test/api/items/a1/children"],
|
|
]),
|
|
);
|
|
expect(projected.tools.map((tool) => tool.path_template)).toEqual([
|
|
"/api/alpha",
|
|
"/api/beta1",
|
|
"/api/items/a1/children",
|
|
"/api/items/a1/versions",
|
|
"/api/items/{item_id}",
|
|
"/api/things/x",
|
|
"/api/things/y",
|
|
"/api/users/{numeric}/search",
|
|
"/api/users/{numeric}/settings",
|
|
"/api/widgets/{widget_id}",
|
|
]);
|
|
expect(
|
|
projected.tools.find(
|
|
(tool) => tool.path_template === "/api/items/{item_id}",
|
|
),
|
|
).toMatchObject({
|
|
name: "get_items_by_item_id",
|
|
params: [
|
|
{
|
|
name: "item_id",
|
|
rule: "sibling-variance",
|
|
observed_values: ["a1", "b2"],
|
|
},
|
|
],
|
|
record_ordinals: [2, 3],
|
|
});
|
|
expect(
|
|
projected.tools.find(
|
|
(tool) => tool.path_template === "/api/widgets/{widget_id}",
|
|
)?.params[0]?.observed_values,
|
|
).toEqual(["blue-two", "green-three", "red-one"]);
|
|
});
|
|
|
|
it("templates two adjacent literals after a plural literal as a pair when three pairs vary in both positions", () => {
|
|
const projected = manifest(
|
|
["sibling-pair-variance"],
|
|
syntheticCapture([
|
|
[
|
|
"GET",
|
|
"https://g.test/api/v1/repos/lighthouse-rota/lamp-room-log/issues",
|
|
],
|
|
["GET", "https://g.test/api/v1/repos/granite-ledger/ledger-core/issues"],
|
|
[
|
|
"GET",
|
|
"https://g.test/api/v1/repos/blue-heron-kitchen/recipe-book/issues",
|
|
],
|
|
[
|
|
"GET",
|
|
"https://g.test/api/v1/repos/lighthouse-rota/tide-tables/issues/4",
|
|
],
|
|
[
|
|
"GET",
|
|
"https://g.test/api/v1/repos/granite-ledger/audit-trail/issues/9",
|
|
],
|
|
[
|
|
"GET",
|
|
"https://g.test/api/v1/repos/saltmarsh-depot/crate-tracker/issues/2",
|
|
],
|
|
["GET", "https://g.test/api/v1/teams/one/alpha/members"],
|
|
["GET", "https://g.test/api/v1/teams/two/beta/members"],
|
|
["GET", "https://g.test/api/v1/orgs/acme/x1/hooks"],
|
|
["GET", "https://g.test/api/v1/orgs/acme/x2/hooks"],
|
|
["GET", "https://g.test/api/v1/orgs/acme/x3/hooks"],
|
|
]),
|
|
);
|
|
expect(projected.tools.map((tool) => tool.path_template)).toEqual([
|
|
"/api/v1/orgs/acme/x1/hooks",
|
|
"/api/v1/orgs/acme/x2/hooks",
|
|
"/api/v1/orgs/acme/x3/hooks",
|
|
"/api/v1/repos/{repo_namespace}/{repo_name}/issues",
|
|
"/api/v1/repos/{repo_namespace}/{repo_name}/issues/{numeric}",
|
|
"/api/v1/teams/one/alpha/members",
|
|
"/api/v1/teams/two/beta/members",
|
|
]);
|
|
expect(
|
|
projected.tools.find(
|
|
(tool) =>
|
|
tool.path_template ===
|
|
"/api/v1/repos/{repo_namespace}/{repo_name}/issues",
|
|
),
|
|
).toMatchObject({
|
|
name: "get_repos_issues_by_repo_namespace_repo_name",
|
|
params: [
|
|
{
|
|
name: "repo_namespace",
|
|
rule: "sibling-pair-variance",
|
|
observed_values: [
|
|
"blue-heron-kitchen",
|
|
"granite-ledger",
|
|
"lighthouse-rota",
|
|
],
|
|
},
|
|
{
|
|
name: "repo_name",
|
|
rule: "sibling-pair-variance",
|
|
observed_values: ["lamp-room-log", "ledger-core", "recipe-book"],
|
|
},
|
|
],
|
|
record_ordinals: [0, 1, 2],
|
|
});
|
|
expect(
|
|
projected.tools
|
|
.find((tool) => tool.path_template.endsWith("/issues/{numeric}"))
|
|
?.params.map((param) => [param.name, param.rule]),
|
|
).toEqual([
|
|
["repo_namespace", "sibling-pair-variance"],
|
|
["repo_name", "sibling-pair-variance"],
|
|
["id", "numeric"],
|
|
]);
|
|
});
|
|
|
|
it("keeps a trailing pair literal because its second half names a sub-resource", () => {
|
|
const projected = manifest(
|
|
["sibling-pair-variance"],
|
|
syntheticCapture([
|
|
["GET", "https://g.test/api/v1/users/alice/followers"],
|
|
["GET", "https://g.test/api/v1/users/bob/following"],
|
|
["GET", "https://g.test/api/v1/users/carol/starred"],
|
|
]),
|
|
);
|
|
expect(projected.tools.map((tool) => tool.path_template)).toEqual([
|
|
"/api/v1/users/alice/followers",
|
|
"/api/v1/users/bob/following",
|
|
"/api/v1/users/carol/starred",
|
|
]);
|
|
});
|
|
|
|
it("merges pairs before sibling-variance so the two rules compose", () => {
|
|
const routes = syntheticCapture([
|
|
["GET", "https://g.test/api/repos/alice-1/common/issues"],
|
|
["GET", "https://g.test/api/repos/bob-2/common/issues"],
|
|
["GET", "https://g.test/api/repos/carol-3/other/issues"],
|
|
["GET", "https://g.test/api/repos/dave-4/third/issues"],
|
|
]);
|
|
for (const rules of [
|
|
["sibling-pair-variance"],
|
|
["sibling-variance", "sibling-pair-variance"],
|
|
] as const)
|
|
expect(
|
|
manifest([...rules], routes).tools.map((tool) => tool.path_template),
|
|
).toEqual(["/api/repos/{repo_namespace}/{repo_name}/issues"]);
|
|
});
|
|
|
|
it("keeps each pair parameter's role when sibling-variance later templates the segment before the pair", () => {
|
|
const projected = manifest(
|
|
["sibling-variance", "sibling-pair-variance"],
|
|
syntheticCapture([
|
|
["GET", "https://g.test/api/root/group1s/alice-1/common/issues"],
|
|
["GET", "https://g.test/api/root/group1s/bob-2/ledger/issues"],
|
|
["GET", "https://g.test/api/root/group1s/carol-3/books/issues"],
|
|
["GET", "https://g.test/api/root/group2s/dave-4/x1/issues"],
|
|
["GET", "https://g.test/api/root/group2s/erin-5/x2/issues"],
|
|
["GET", "https://g.test/api/root/group2s/finn-6/x3/issues"],
|
|
]),
|
|
);
|
|
expect(projected.tools).toHaveLength(1);
|
|
expect(
|
|
projected.tools[0]?.params.map((param) => [param.name, param.rule]),
|
|
).toEqual([
|
|
["root_id", "sibling-variance"],
|
|
["namespace", "sibling-pair-variance"],
|
|
["name", "sibling-pair-variance"],
|
|
]);
|
|
});
|
|
|
|
it("never folds a literal sibling into a format-rule parameter", () => {
|
|
const projected = manifest(
|
|
ALL_RULES,
|
|
syntheticCapture([
|
|
["GET", "https://t.test/api/teams/1"],
|
|
["GET", "https://t.test/api/teams/2"],
|
|
["GET", "https://t.test/api/teams/search"],
|
|
["GET", "https://t.test/api/teams/mine"],
|
|
]),
|
|
);
|
|
expect(projected.tools.map(line)).toEqual([
|
|
"GET /api/teams/mine",
|
|
"GET /api/teams/search",
|
|
"GET /api/teams/{numeric}",
|
|
]);
|
|
expect(projected.tools.map((tool) => tool.name)).toEqual([
|
|
"get_teams_mine",
|
|
"get_teams_search",
|
|
"get_teams_by_id",
|
|
]);
|
|
expect(projected.tools[2]).toMatchObject({
|
|
api_surface_template: "/api/teams/{numeric}",
|
|
params: [
|
|
{ index: 2, name: "id", rule: "numeric", observed_values: ["1", "2"] },
|
|
],
|
|
record_ordinals: [0, 1],
|
|
});
|
|
expect(projected.templated_groups).toBe(1);
|
|
});
|
|
|
|
it("folds a literal sibling recorded before the templated token into the same parameter", () => {
|
|
const literalFirst = [
|
|
["GET", "https://g.test/api/folders/general?accesscontrol=true"],
|
|
["GET", "https://g.test/api/folders/fg1jo91e57e2ob"],
|
|
] as const;
|
|
const projected = manifest(ALL_RULES, syntheticCapture(literalFirst));
|
|
expect(projected.tools).toHaveLength(1);
|
|
expect(projected.tools[0]).toMatchObject({
|
|
name: "get_folders_by_uid",
|
|
path_template: "/api/folders/{uid}",
|
|
api_surface_template: "/api/folders/general",
|
|
params: [
|
|
{
|
|
index: 2,
|
|
name: "uid",
|
|
rule: "opaque-token",
|
|
observed_values: ["fg1jo91e57e2ob", "general"],
|
|
},
|
|
],
|
|
query_parameter_names: ["accesscontrol"],
|
|
record_ordinals: [0, 1],
|
|
});
|
|
expect(projected.templated_groups).toBe(1);
|
|
const tokenFirst = manifest(
|
|
ALL_RULES,
|
|
syntheticCapture([literalFirst[1], literalFirst[0]]),
|
|
).tools[0]!;
|
|
expect(tokenFirst.api_surface_template).toBe("/api/folders/fg1jo91e57e2ob");
|
|
const { api_surface_template: _literal, ...literalTool } =
|
|
projected.tools[0]!;
|
|
const { api_surface_template: _token, ...tokenTool } = tokenFirst;
|
|
expect(literalTool).toEqual(tokenTool);
|
|
});
|
|
|
|
it("derives body shapes only from JSON-typed request bodies and rejects repeated rules", () => {
|
|
const projected = manifest(
|
|
[],
|
|
syntheticCapture([
|
|
[
|
|
"POST",
|
|
"https://b.test/api/notes",
|
|
"application/json",
|
|
'{"text":"x"}',
|
|
"text/plain",
|
|
],
|
|
[
|
|
"PUT",
|
|
"https://b.test/api/notes",
|
|
"application/json",
|
|
'{"text":"x"}',
|
|
"application/vnd.api+json",
|
|
],
|
|
]),
|
|
);
|
|
expect(projected.tools.map((tool) => [tool.method, tool.body_shape])).toEqual(
|
|
[
|
|
["POST", null],
|
|
["PUT", { type: "object", properties: { text: "string" } }],
|
|
],
|
|
);
|
|
expect(
|
|
analysisViewRequestSchema.safeParse({
|
|
kind: "environment-manifest",
|
|
template_rules: ["opaque-token", "opaque-token"],
|
|
}).success,
|
|
).toBe(false);
|
|
expect(
|
|
analysisViewRequestSchema.parse({ kind: "environment-manifest" }),
|
|
).toEqual({
|
|
kind: "environment-manifest",
|
|
template_rules: [],
|
|
include_query_parameter_names: true,
|
|
});
|
|
});
|
|
|
|
it("selects the origin with the most JSON-like records and names it from the host", () => {
|
|
const projected = manifest(
|
|
[],
|
|
syntheticCapture([
|
|
["GET", "https://pages.test/", "text/html"],
|
|
["GET", "https://pages.test/about", "text/html"],
|
|
["GET", "https://pages.test/team", "text/html"],
|
|
["GET", "https://api.example.test:8443/v1/items", "application/json"],
|
|
[
|
|
"POST",
|
|
"https://api.example.test:8443/v1/items",
|
|
"application/json",
|
|
"[1,2]",
|
|
],
|
|
]),
|
|
);
|
|
expect(projected).toMatchObject({
|
|
origin: "https://api.example.test:8443",
|
|
name: "api-example-test-8443",
|
|
templated_groups: 0,
|
|
});
|
|
expect(projected.tools.map(line)).toEqual([
|
|
"GET /v1/items",
|
|
"POST /v1/items",
|
|
]);
|
|
expect(projected.tools[1]?.body_shape).toBeNull();
|
|
expect(projected.excluded.map((entry) => entry.reason)).toEqual([
|
|
"other-origin",
|
|
"other-origin",
|
|
"other-origin",
|
|
]);
|
|
const empty = manifest(
|
|
[],
|
|
syntheticCapture([
|
|
["GET", "invalid URL"],
|
|
["GET", "data:,x"],
|
|
]),
|
|
);
|
|
expect(empty).toMatchObject({ origin: "", name: "", tools: [], records: [] });
|
|
expect(empty.coverage).toMatchObject({
|
|
status: "complete-within-view",
|
|
total: 0,
|
|
});
|
|
expect(empty.limitations).toContain(
|
|
"No retained record had a parseable origin; the manifest names no primary origin.",
|
|
);
|
|
});
|
|
|
|
it("refuses non-HAR formats, sensitive parents, unsupported parents and malformed results", () => {
|
|
const capture = environmentManifestCaptureFixture();
|
|
for (const format of ["mitmproxy", "saz"] as const) {
|
|
capture.format = format;
|
|
expect(projectAnalysisView(parentOf(capture), view())).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisUnsupportedTargetError" },
|
|
});
|
|
}
|
|
for (const parameters of [{ sensitive_value_count: 1 }, {}, null])
|
|
expect(
|
|
projectAnalysisView(
|
|
parentOf(environmentManifestCaptureFixture(), parameters),
|
|
view(ALL_RULES),
|
|
),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisUnsupportedTargetError",
|
|
reason: "sensitive_values_unretained",
|
|
},
|
|
});
|
|
const layout = analysisViewLayoutEvidence();
|
|
expect(
|
|
projectAnalysisView(
|
|
{
|
|
evidenceId: layout.evidence_id,
|
|
operation: layout.operation,
|
|
normalizedResult: layout.normalized_result,
|
|
limitations: [],
|
|
},
|
|
view(),
|
|
),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisUnsupportedTargetError" },
|
|
});
|
|
const cyclic: { self?: unknown } = {};
|
|
cyclic.self = cyclic;
|
|
for (const normalizedResult of [{}, cyclic])
|
|
expect(
|
|
projectAnalysisView(
|
|
{
|
|
evidenceId: `ev_${"a".repeat(64)}`,
|
|
operation: "inspect_web_network_capture",
|
|
normalizedResult,
|
|
limitations: [],
|
|
},
|
|
view(),
|
|
),
|
|
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
|
|
});
|
|
|
|
it("refuses a path whose templated representation exceeds projection capacity", () => {
|
|
const capture = syntheticCapture([
|
|
["GET", `https://api.example.test${"/1".repeat(800_000)}`],
|
|
]);
|
|
expect(projectAnalysisView(parentOf(capture), view())).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "AnalysisResourceConstraintError", resource: "memory" },
|
|
});
|
|
});
|
|
|
|
it("preserves every own body key while publishing only its observed type", () => {
|
|
const projected = manifest(
|
|
[],
|
|
syntheticCapture([
|
|
[
|
|
"POST",
|
|
"https://b.test/api/items",
|
|
"application/json",
|
|
'{"__proto__":{"admin":true},"constructor":"c","name":1}',
|
|
],
|
|
]),
|
|
);
|
|
const shape = projected.tools[0]?.body_shape;
|
|
expect(shape).toEqual({
|
|
type: "object",
|
|
properties: {
|
|
["__proto__"]: "object",
|
|
constructor: "string",
|
|
name: "number",
|
|
},
|
|
});
|
|
expect(Object.getPrototypeOf(shape?.properties)).toBe(Object.prototype);
|
|
expect(Object.hasOwn(shape!.properties, "__proto__")).toBe(true);
|
|
expect(JSON.stringify(projected)).not.toContain("admin");
|
|
});
|
|
|
|
it("keeps a stop word literal after a uid literal instead of templating it", () => {
|
|
const projected = manifest(
|
|
["after-uid-literal"],
|
|
syntheticCapture([
|
|
["GET", "https://s.test/api/items/uid/abc"],
|
|
["GET", "https://s.test/api/items/uid/search"],
|
|
]),
|
|
);
|
|
expect(projected.tools.map(line)).toEqual([
|
|
"GET /api/items/uid/search",
|
|
"GET /api/items/uid/{uid}",
|
|
]);
|
|
expect(projected.tools[1]?.params[0]?.observed_values).toEqual(["abc"]);
|
|
});
|
|
|
|
it("reads only the top-level key types of a deeply nested request body", () => {
|
|
const nested = `${"[".repeat(2_500)}1${"]".repeat(2_500)}`;
|
|
const projected = manifest(
|
|
[],
|
|
syntheticCapture([
|
|
[
|
|
"POST",
|
|
"https://s.test/api/folders",
|
|
"application/json",
|
|
`{"title":"t","tree":${nested},"count":2,"meta":{"deep":${nested}}}`,
|
|
],
|
|
]),
|
|
);
|
|
expect(projected.tools[0]?.body_shape).toEqual({
|
|
type: "object",
|
|
properties: {
|
|
count: "number",
|
|
meta: "object",
|
|
title: "string",
|
|
tree: "array",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("assigns collision suffixes in sequence and keeps every name unique", () => {
|
|
const projected = manifest(
|
|
[],
|
|
syntheticCapture(
|
|
Array.from(
|
|
{ length: 300 },
|
|
(_, index) =>
|
|
["GET", `https://s.test/api/item.${String(index)}`] as const,
|
|
),
|
|
),
|
|
);
|
|
const names = projected.tools.map((tool) => tool.name);
|
|
expect(new Set(names).size).toBe(300);
|
|
expect([...names].sort()).toEqual(
|
|
[
|
|
"get_item",
|
|
...Array.from(
|
|
{ length: 299 },
|
|
(_, index) => `get_item_${String(index + 2)}`,
|
|
),
|
|
].sort(),
|
|
);
|
|
});
|
|
|
|
it("does not compare routes beyond the sibling-variance segment limit and says so", () => {
|
|
const tail = Array.from(
|
|
{ length: 36 },
|
|
(_, index) => `s${String(index)}`,
|
|
).join("/");
|
|
const projected = manifest(
|
|
["sibling-variance"],
|
|
syntheticCapture([
|
|
["GET", `https://s.test/api/items/a1/${tail}`],
|
|
["GET", `https://s.test/api/items/b2/${tail}`],
|
|
["GET", "https://s.test/api/things/c3"],
|
|
["GET", "https://s.test/api/things/d4"],
|
|
]),
|
|
);
|
|
expect(projected.tools.map((tool) => tool.path_template)).toEqual([
|
|
`/api/items/a1/${tail}`,
|
|
`/api/items/b2/${tail}`,
|
|
"/api/things/{thing_id}",
|
|
]);
|
|
expect(projected.limitations).toContain(
|
|
"Routes with more than 32 segments are not compared by sibling-variance; they keep their grouped templates.",
|
|
);
|
|
});
|
|
|
|
it("shares collision suffixes across bases that differ only past the name limit", () => {
|
|
const stem = "x".repeat(70);
|
|
const projected = manifest(
|
|
[],
|
|
syntheticCapture(
|
|
Array.from(
|
|
{ length: 200 },
|
|
(_, index) =>
|
|
["GET", `https://s.test/api/${stem}${String(index)}`] as const,
|
|
),
|
|
),
|
|
);
|
|
const names = projected.tools.map((tool) => tool.name);
|
|
expect(new Set(names).size).toBe(200);
|
|
expect(names.every((name) => name.length <= 60)).toBe(true);
|
|
expect(names.filter((name) => /_\d+$/u.test(name))).toHaveLength(199);
|
|
});
|
|
|
|
it("folds many literal siblings into one opt-in parameter", () => {
|
|
const projected = manifest(
|
|
["opaque-token", "sibling-variance"],
|
|
syntheticCapture([
|
|
["GET", "https://s.test/api/items/abc123def456ghi"],
|
|
...Array.from(
|
|
{ length: 300 },
|
|
(_, index) =>
|
|
["GET", `https://s.test/api/items/name-${String(index)}`] as const,
|
|
),
|
|
]),
|
|
);
|
|
expect(projected.tools).toHaveLength(1);
|
|
expect(projected.tools[0]?.params[0]?.observed_values).toHaveLength(301);
|
|
});
|
|
|
|
it("treats a +json suffix as JSON only on application media types", () => {
|
|
const projected = manifest(
|
|
[],
|
|
syntheticCapture([
|
|
["GET", "https://s.test/api/items", "application/vnd.api+json"],
|
|
["GET", "https://s.test/gallery/1", "image/example+json"],
|
|
["GET", "https://s.test/notes/1", "text/example+json"],
|
|
[
|
|
"POST",
|
|
"https://s.test/api/items",
|
|
"application/json",
|
|
'{"title":"t"}',
|
|
"text/example+json",
|
|
],
|
|
]),
|
|
);
|
|
expect(projected.tools.map(line)).toEqual([
|
|
"GET /api/items",
|
|
"POST /api/items",
|
|
]);
|
|
expect(
|
|
projected.excluded.map((entry) => [entry.path_template, entry.reason]),
|
|
).toEqual([
|
|
["/gallery/{numeric}", "ui-page-or-asset"],
|
|
["/notes/{numeric}", "non-json"],
|
|
]);
|
|
expect(projected.tools[1]?.body_shape).toBeNull();
|
|
});
|
|
|
|
it("keeps a write answered with no body as a tool whatever its content type, but not one only redirected", () => {
|
|
const capture = syntheticCapture([
|
|
["GET", "https://s.test/items/1", "application/json"],
|
|
["GET", "https://s.test/items/2", "application/json"],
|
|
["DELETE", "https://s.test/items/1", "text/html"],
|
|
["DELETE", "https://s.test/items/2", "text/html"],
|
|
["POST", "https://s.test/forms/1", "text/html"],
|
|
["PUT", "https://s.test/pages/1", "text/html"],
|
|
]);
|
|
const answer = (index: number, status: number, text?: string): void => {
|
|
const record = capture.records[index]!;
|
|
record.reported = {
|
|
...(record.reported as Record<string, unknown>),
|
|
response: {
|
|
status,
|
|
content: {
|
|
mimeType: "text/html",
|
|
...(text === undefined ? {} : { text }),
|
|
},
|
|
},
|
|
};
|
|
};
|
|
answer(2, 204);
|
|
answer(3, 200, "");
|
|
// A redirect is no answer, and a missing text may be a body the capture did not keep.
|
|
answer(4, 302, "");
|
|
answer(5, 200);
|
|
const projected = manifest([], capture);
|
|
expect(projected.tools.map(line)).toEqual([
|
|
"DELETE /items/{numeric}",
|
|
"GET /items/{numeric}",
|
|
]);
|
|
expect(
|
|
projected.excluded.map((entry) => [entry.path_template, entry.reason]),
|
|
).toEqual([
|
|
["/forms/{numeric}", "ui-page-or-asset"],
|
|
["/pages/{numeric}", "ui-page-or-asset"],
|
|
]);
|
|
});
|
|
|
|
it("keeps opaque or redacted empty response text unknown when selecting bodiless writes", () => {
|
|
const capture = syntheticCapture([
|
|
["POST", "https://s.test/opaque/1", "text/html"],
|
|
["POST", "https://s.test/hidden/1", "text/html"],
|
|
["POST", "https://s.test/base64/1", "text/html"],
|
|
]);
|
|
for (const [index, record] of capture.records.entries()) {
|
|
record.reported = {
|
|
...(record.reported as Record<string, unknown>),
|
|
response: {
|
|
status: 200,
|
|
content: {
|
|
mimeType: "text/html",
|
|
text: "",
|
|
...(index === 0 ? { encoding: "opaque" } : {}),
|
|
...(index === 2 ? { encoding: "base64" } : {}),
|
|
},
|
|
},
|
|
};
|
|
if (index === 1)
|
|
record.redactions = [
|
|
{
|
|
pointer: "/response/content",
|
|
reason: "explicit-sensitive-value",
|
|
scope: "property-name",
|
|
},
|
|
];
|
|
if (index === 2)
|
|
record.binary_fields = [
|
|
{
|
|
pointer: "/response/content/text",
|
|
representation: "har-base64-content",
|
|
state: "retained",
|
|
content_base64: "",
|
|
bytes: 0,
|
|
sha256:
|
|
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
},
|
|
];
|
|
}
|
|
const projected = manifest([], capture);
|
|
expect(projected.tools.map(line)).toEqual(["POST /base64/{numeric}"]);
|
|
expect(projected.excluded.map((entry) => entry.path_template)).toEqual([
|
|
"/hidden/{numeric}",
|
|
"/opaque/{numeric}",
|
|
]);
|
|
});
|
|
|
|
it("refuses a parent whose subject is absent or names another path or digest", () => {
|
|
const parent = parentOf();
|
|
const refusal = {
|
|
ok: false,
|
|
error: {
|
|
_tag: "AnalysisInputError",
|
|
issues: [{ path: ["source", "subject"], reason: "invalid_value" }],
|
|
},
|
|
};
|
|
const { subject, ...withoutSubject } = parent;
|
|
expect(projectAnalysisView(withoutSubject, view())).toMatchObject(refusal);
|
|
expect(
|
|
projectAnalysisView({ ...withoutSubject, subject: null }, view()),
|
|
).toMatchObject(refusal);
|
|
if (subject == null) throw new Error("Fixture evidence carries a subject");
|
|
expect(
|
|
projectAnalysisView(
|
|
{
|
|
...parent,
|
|
subject: { ...subject, local_path: "/elsewhere/capture.har" },
|
|
},
|
|
view(),
|
|
),
|
|
).toMatchObject(refusal);
|
|
expect(
|
|
projectAnalysisView(
|
|
{
|
|
...parent,
|
|
subject: {
|
|
...subject,
|
|
digest: { ...subject.digest, sha256: "0".repeat(64) },
|
|
},
|
|
},
|
|
view(),
|
|
),
|
|
).toMatchObject(refusal);
|
|
expect(projectAnalysisView(parent, view()).ok).toBe(true);
|
|
});
|
|
|
|
it("merges only the identifier-like siblings and keeps the other literals as their own tools", () => {
|
|
const projected = manifest(
|
|
["sibling-variance"],
|
|
syntheticCapture([
|
|
["GET", "https://s.test/api/items/a1"],
|
|
["GET", "https://s.test/api/items/b2"],
|
|
["GET", "https://s.test/api/items/mine"],
|
|
["GET", "https://s.test/api/items/search"],
|
|
["GET", "https://s.test/api/widgets/red-one"],
|
|
["GET", "https://s.test/api/widgets/blue-two"],
|
|
["GET", "https://s.test/api/widgets/green-three"],
|
|
["GET", "https://s.test/api/widgets/shared"],
|
|
]),
|
|
);
|
|
expect(projected.tools.map((tool) => tool.path_template)).toEqual([
|
|
"/api/items/mine",
|
|
"/api/items/search",
|
|
"/api/items/{item_id}",
|
|
"/api/widgets/shared",
|
|
"/api/widgets/{widget_id}",
|
|
]);
|
|
expect(
|
|
projected.tools.find(
|
|
(tool) => tool.path_template === "/api/items/{item_id}",
|
|
)?.params[0]?.observed_values,
|
|
).toEqual(["a1", "b2"]);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
coded: ["a1", "b2"],
|
|
hyphenated: ["red-one", "blue-two", "green-three"],
|
|
eligible: ["a1", "b2", "red-one", "blue-two", "green-three"],
|
|
literal: ["mine", "search"],
|
|
},
|
|
{
|
|
coded: ["a1"],
|
|
hyphenated: ["red-one", "blue-two", "green-three"],
|
|
eligible: ["a1", "red-one", "blue-two", "green-three"],
|
|
literal: ["mine", "search"],
|
|
},
|
|
{
|
|
coded: ["a1", "b2"],
|
|
hyphenated: ["red-one", "blue-two"],
|
|
eligible: ["a1", "b2"],
|
|
literal: ["mine", "search", "red-one", "blue-two"],
|
|
},
|
|
])(
|
|
"combines qualifying sibling cohorts without promoting a short hyphenated cohort: $coded / $hyphenated",
|
|
({ coded, hyphenated, eligible, literal }) => {
|
|
const values = [...coded, ...hyphenated, "mine", "search"];
|
|
const projected = manifest(
|
|
["sibling-variance"],
|
|
syntheticCapture(
|
|
values.map(
|
|
(value) => ["GET", `https://s.test/api/items/${value}`] as const,
|
|
),
|
|
),
|
|
);
|
|
expect(
|
|
projected.tools.find(
|
|
(tool) => tool.path_template === "/api/items/{item_id}",
|
|
)?.params[0]?.observed_values,
|
|
).toEqual([...eligible].sort());
|
|
expect(
|
|
projected.tools
|
|
.filter((tool) => tool.params.length === 0)
|
|
.map((tool) => tool.path_template)
|
|
.sort(),
|
|
).toEqual(literal.map((value) => `/api/items/${value}`).sort());
|
|
expect(projected.records).toHaveLength(values.length);
|
|
},
|
|
);
|
|
|
|
it("templates a literal that another method's opt-in parameter observed at the same position, at any depth", () => {
|
|
const routes = syntheticCapture([
|
|
["GET", "https://k.test/api/v1/collections/0RZ51AB"],
|
|
["GET", "https://k.test/api/v1/collections/0RZ52CD"],
|
|
["GET", "https://k.test/api/v1/collections/0RZ53EF"],
|
|
["PATCH", "https://k.test/api/v1/collections/0RZ53EF", null, "{}"],
|
|
["DELETE", "https://k.test/api/v1/collections/0RZ53EF", null],
|
|
["GET", "https://k.test/api/v1/collections/0RZ52CD/books"],
|
|
["GET", "https://k.test/api/v1/collections/layout"],
|
|
["POST", "https://k.test/api/v1/collections/search", null, "{}"],
|
|
]);
|
|
const shared = manifest(["sibling-variance", "prefix-variance"], routes);
|
|
expect(shared.tools.map(line)).toEqual([
|
|
"GET /api/v1/collections/layout",
|
|
"POST /api/v1/collections/search",
|
|
"DELETE /api/v1/collections/{collection_id}",
|
|
"GET /api/v1/collections/{collection_id}",
|
|
"PATCH /api/v1/collections/{collection_id}",
|
|
"GET /api/v1/collections/{collection_id}/books",
|
|
]);
|
|
expect(shared.tools.find((tool) => tool.method === "PATCH")).toMatchObject({
|
|
name: "patch_collections_by_collection_id",
|
|
api_surface_template: "/api/v1/collections/0RZ53EF",
|
|
params: [
|
|
{
|
|
index: 3,
|
|
name: "collection_id",
|
|
rule: "prefix-variance",
|
|
basis: "inferred",
|
|
observed_values: ["0RZ53EF"],
|
|
},
|
|
],
|
|
record_ordinals: [3],
|
|
});
|
|
expect(
|
|
shared.tools.find((tool) => tool.path_template.endsWith("/books"))?.name,
|
|
).toBe("get_collections_books_by_collection_id");
|
|
expect(manifest(["sibling-variance"], routes).tools.map(line)).toEqual([
|
|
"GET /api/v1/collections/0RZ52CD/books",
|
|
"DELETE /api/v1/collections/0RZ53EF",
|
|
"PATCH /api/v1/collections/0RZ53EF",
|
|
"GET /api/v1/collections/layout",
|
|
"POST /api/v1/collections/search",
|
|
"GET /api/v1/collections/{collection_id}",
|
|
]);
|
|
});
|
|
|
|
it("templates an unobserved literal only when it carries a digit and is spelled like an observed value", () => {
|
|
const projected = manifest(
|
|
["sibling-variance", "prefix-variance"],
|
|
syntheticCapture([
|
|
["GET", "https://k.test/api/readlists/0RZ51KSZKA53J"],
|
|
["GET", "https://k.test/api/readlists/0RZ56NR37AC1T"],
|
|
["PATCH", "https://k.test/api/readlists/0RZ576R5KA57B", null, "{}"],
|
|
["PUT", "https://k.test/api/readlists/0rz576r5ka57b", null, "{}"],
|
|
["DELETE", "https://k.test/api/readlists/ABCDEFGHIJ", null],
|
|
["POST", "https://k.test/api/readlists/Metrics::12", null, "{}"],
|
|
["GET", "https://k.test/api/items/a1"],
|
|
["GET", "https://k.test/api/items/b2"],
|
|
["PUT", "https://k.test/api/items/v2", null, "{}"],
|
|
]),
|
|
);
|
|
expect(projected.tools.map(line)).toEqual([
|
|
"PUT /api/items/v2",
|
|
"GET /api/items/{item_id}",
|
|
"PUT /api/readlists/0rz576r5ka57b",
|
|
"DELETE /api/readlists/ABCDEFGHIJ",
|
|
"POST /api/readlists/Metrics::12",
|
|
"GET /api/readlists/{readlist_id}",
|
|
"PATCH /api/readlists/{readlist_id}",
|
|
]);
|
|
});
|
|
|
|
it("merges tools whose routes now match and keeps the parameter a tool templated by its own rule", () => {
|
|
const routes = syntheticCapture([
|
|
["GET", "https://b.test/api/boards/b-2/cards/c-3"],
|
|
["GET", "https://b.test/api/boards/b-1"],
|
|
["GET", "https://b.test/api/boards/b-2"],
|
|
["GET", "https://b.test/api/boards/b-1/cards/c-1"],
|
|
["GET", "https://b.test/api/boards/b-1/cards/c-2"],
|
|
["GET", "https://b.test/api/subjects/red-one"],
|
|
["GET", "https://b.test/api/subjects/blue-two"],
|
|
["GET", "https://b.test/api/subjects/green-three"],
|
|
["GET", "https://b.test/api/subjects/red-one/versions/1"],
|
|
["GET", "https://b.test/api/subjects/blue-two/versions/2"],
|
|
]);
|
|
const projected = manifest(["sibling-variance", "prefix-variance"], routes);
|
|
expect(projected.tools.map(line)).toEqual([
|
|
"GET /api/boards/{board_id}",
|
|
"GET /api/boards/{board_id}/cards/{card_id}",
|
|
"GET /api/subjects/{subject_id}",
|
|
"GET /api/subjects/{subject_id}/versions/{numeric}",
|
|
]);
|
|
const params = (template: string) =>
|
|
projected.tools
|
|
.find((tool) => tool.path_template === template)
|
|
?.params.map((param) => [param.name, param.rule, param.observed_values]);
|
|
expect(params("/api/boards/{board_id}/cards/{card_id}")).toEqual([
|
|
["board_id", "prefix-variance", ["b-1", "b-2"]],
|
|
["card_id", "sibling-variance", ["c-1", "c-2", "c-3"]],
|
|
]);
|
|
expect(params("/api/subjects/{subject_id}/versions/{numeric}")).toEqual([
|
|
["subject_id", "prefix-variance", ["blue-two", "red-one"]],
|
|
["id", "numeric", ["1", "2"]],
|
|
]);
|
|
expect(manifest(["sibling-variance"], routes).tools.map(line)).toEqual([
|
|
"GET /api/boards/b-1/cards/{card_id}",
|
|
"GET /api/boards/b-2/cards/c-3",
|
|
"GET /api/boards/{board_id}",
|
|
"GET /api/subjects/blue-two/versions/{numeric}",
|
|
"GET /api/subjects/red-one/versions/{numeric}",
|
|
"GET /api/subjects/{subject_id}",
|
|
]);
|
|
});
|
|
|
|
it("shares no format-rule position and compares no route beyond the segment limit", () => {
|
|
const tail = Array.from(
|
|
{ length: 36 },
|
|
(_, index) => `s${String(index)}`,
|
|
).join("/");
|
|
const projected = manifest(
|
|
["sibling-variance", "prefix-variance"],
|
|
syntheticCapture([
|
|
["GET", "https://t.test/api/teams/1"],
|
|
["GET", "https://t.test/api/teams/2"],
|
|
["PATCH", "https://t.test/api/teams/t3", null, "{}"],
|
|
["GET", "https://t.test/api/items/a1"],
|
|
["GET", "https://t.test/api/items/b2"],
|
|
["GET", `https://t.test/api/items/b2/${tail}`],
|
|
]),
|
|
);
|
|
expect(projected.tools.map(line)).toEqual([
|
|
`GET /api/items/b2/${tail}`,
|
|
"GET /api/items/{item_id}",
|
|
"PATCH /api/teams/t3",
|
|
"GET /api/teams/{numeric}",
|
|
]);
|
|
expect(projected.limitations).toContain(
|
|
"Routes with more than 32 segments are not compared by prefix-variance; they keep their grouped templates.",
|
|
);
|
|
});
|
|
|
|
it("shares both halves of a templated pair and keeps each half's name", () => {
|
|
const projected = manifest(
|
|
["sibling-pair-variance", "prefix-variance"],
|
|
syntheticCapture([
|
|
["GET", "https://g.test/api/repos/lighthouse-rota/lamp-log/issues"],
|
|
["GET", "https://g.test/api/repos/granite-ledger/ledger-core/issues"],
|
|
["GET", "https://g.test/api/repos/blue-heron/recipe-book/issues"],
|
|
[
|
|
"POST",
|
|
"https://g.test/api/repos/granite-ledger/ledger-core/issues",
|
|
null,
|
|
"{}",
|
|
],
|
|
["DELETE", "https://g.test/api/repos/blue-heron/recipe-book", null],
|
|
]),
|
|
);
|
|
expect(projected.tools.map(line)).toEqual([
|
|
"DELETE /api/repos/{repo_namespace}/{repo_name}",
|
|
"GET /api/repos/{repo_namespace}/{repo_name}/issues",
|
|
"POST /api/repos/{repo_namespace}/{repo_name}/issues",
|
|
]);
|
|
expect(
|
|
projected.tools[0]?.params.map((param) => [param.name, param.rule]),
|
|
).toEqual([
|
|
["repo_namespace", "prefix-variance"],
|
|
["repo_name", "prefix-variance"],
|
|
]);
|
|
});
|
|
|
|
it("templates a digit-free literal only as a member of a fixed-length identifier family", () => {
|
|
const projected = manifest(
|
|
["sibling-variance", "prefix-variance"],
|
|
syntheticCapture([
|
|
["GET", "https://n.test/api/workflows/yDr9n16753TZr7Zc"],
|
|
["GET", "https://n.test/api/workflows/5LPA94ML1gYbgAQx"],
|
|
["PATCH", "https://n.test/api/workflows/FkMwxmXNGaGVsXFG", null, "{}"],
|
|
["DELETE", "https://n.test/api/workflows/listForAccounts", null],
|
|
["PUT", "https://n.test/api/workflows/fkmw-xmxn-gagv-x", null, "{}"],
|
|
["GET", "https://n.test/api/tags/a1"],
|
|
["GET", "https://n.test/api/tags/b22"],
|
|
["DELETE", "https://n.test/api/tags/cc", null],
|
|
["GET", "https://n.test/api/labels/ab12cd34"],
|
|
["GET", "https://n.test/api/labels/ef56gh78"],
|
|
["DELETE", "https://n.test/api/labels/abcdefgh", null],
|
|
]),
|
|
);
|
|
expect(projected.tools.map(line)).toEqual([
|
|
"DELETE /api/labels/{label_id}",
|
|
"GET /api/labels/{label_id}",
|
|
"DELETE /api/tags/cc",
|
|
"GET /api/tags/{tag_id}",
|
|
"PUT /api/workflows/fkmw-xmxn-gagv-x",
|
|
"DELETE /api/workflows/listForAccounts",
|
|
"GET /api/workflows/{workflow_id}",
|
|
"PATCH /api/workflows/{workflow_id}",
|
|
]);
|
|
});
|
|
|
|
it("shares a literal with the parameter that observed it when parameters of different rules share a position", () => {
|
|
const projected = manifest(
|
|
["opaque-token", "sibling-variance", "prefix-variance"],
|
|
syntheticCapture([
|
|
["GET", "https://i.test/api/items/aaaa12bb34cc/details"],
|
|
["GET", "https://i.test/api/items/a1"],
|
|
["GET", "https://i.test/api/items/b2"],
|
|
["PATCH", "https://i.test/api/items/b2", null, "{}"],
|
|
]),
|
|
);
|
|
// The token parameter is earlier, but only the sibling parameter observed b2.
|
|
expect(projected.tools.map(line)).toEqual([
|
|
"GET /api/items/{item_id}",
|
|
"PATCH /api/items/{item_id}",
|
|
"GET /api/items/{uid}/details",
|
|
]);
|
|
expect(
|
|
projected.tools.map((tool) =>
|
|
tool.params.map((param) => [param.name, param.rule]),
|
|
),
|
|
).toEqual([
|
|
[["item_id", "sibling-variance"]],
|
|
[["item_id", "prefix-variance"]],
|
|
[["uid", "opaque-token"]],
|
|
]);
|
|
});
|
|
|
|
it("templates a digit-free literal in a fixed-length family that one opt-in parameter observed once", () => {
|
|
const projected = manifest(
|
|
["opaque-token", "prefix-variance"],
|
|
syntheticCapture([
|
|
["GET", "https://w.test/api/workflows/ydr9n16753tzr7zc"],
|
|
["PATCH", "https://w.test/api/workflows/fkmwxmxngagvsxfg", null, "{}"],
|
|
["DELETE", "https://w.test/api/workflows/notifications", null],
|
|
]),
|
|
);
|
|
expect(projected.tools.map(line)).toEqual([
|
|
"DELETE /api/workflows/notifications",
|
|
"GET /api/workflows/{uid}",
|
|
"PATCH /api/workflows/{uid}",
|
|
]);
|
|
});
|