mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
310 lines
13 KiB
TypeScript
310 lines
13 KiB
TypeScript
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
|
import { describe, expect, test } from "vite-plus/test";
|
|
import {
|
|
appKeyOf,
|
|
formatFieldFilter,
|
|
isSystemFlow,
|
|
matchesQuery,
|
|
parseSearch,
|
|
sidebarPath,
|
|
siteOf,
|
|
tokenize,
|
|
widerQueries,
|
|
} from "./search";
|
|
|
|
function flow(url: string, extra: Partial<Flow> = {}): Flow {
|
|
return {
|
|
url,
|
|
method: "GET",
|
|
status: 200,
|
|
contentType: null,
|
|
clientBundleId: null,
|
|
clientPath: null,
|
|
...extra,
|
|
} as Flow;
|
|
}
|
|
|
|
// The same flows and terms as `search_matches_url_method_status_and_content_type` in
|
|
// `flows.rs`, so the live matcher and the database agree.
|
|
const stripe = flow("https://api.stripe.com/v1/charges?customer=cus_9", {
|
|
method: "POST",
|
|
contentType: "application/json; charset=utf-8",
|
|
});
|
|
const github = flow("https://api.github.com/repos/yaak", {
|
|
status: 404,
|
|
contentType: "application/json; charset=utf-8",
|
|
});
|
|
const logo = flow("https://images.ctfassets.net/logo.png", { contentType: "image/png" });
|
|
const health = flow("http://localhost:3000/health", { method: "HEAD", status: 503 });
|
|
const all = [stripe, github, logo, health];
|
|
|
|
const search = (q: string) => all.filter((f) => matchesQuery(f, { q }));
|
|
|
|
describe("matchesQuery", () => {
|
|
test("matches the host, the path and the query, any case", () => {
|
|
expect(search("STRIPE")).toEqual([stripe]);
|
|
expect(search("/repos/")).toEqual([github]);
|
|
expect(search("cus_9")).toEqual([stripe]);
|
|
});
|
|
|
|
test("matches the whole method", () => {
|
|
expect(search("head")).toEqual([health]);
|
|
expect(search("ead")).toEqual([]);
|
|
});
|
|
|
|
test("matches a status, its start, or its class", () => {
|
|
expect(search("404")).toEqual([github]);
|
|
expect(search("50")).toEqual([health]);
|
|
expect(search("2xx")).toEqual([stripe, logo]);
|
|
});
|
|
|
|
test("matches the content type", () => {
|
|
expect(search("png")).toEqual([logo]);
|
|
expect(search("json")).toEqual([stripe, github]);
|
|
});
|
|
|
|
test("needs every term to match", () => {
|
|
expect(search("json get")).toEqual([github]);
|
|
expect(search(" json 2xx ")).toEqual([stripe]);
|
|
expect(search("json 5xx")).toEqual([]);
|
|
expect(search("")).toEqual(all);
|
|
});
|
|
|
|
test("keeps flows under a host or path, as the sidebar places them", () => {
|
|
const under = (node: string, url: string) => matchesQuery(flow(url), { under: [node] });
|
|
expect(under("api.stripe.com", "https://api.stripe.com/v1/charges")).toBe(true);
|
|
expect(under("api.stripe.com", "http://api.stripe.com")).toBe(true);
|
|
// Another port is a host node of its own, as in the sidebar.
|
|
expect(under("api.stripe.com", "https://api.stripe.com:8443/v1")).toBe(false);
|
|
// Empty segments and default ports are left out, as the sidebar leaves them.
|
|
expect(under("example.com/v1/users", "https://example.com/v1//users")).toBe(true);
|
|
expect(under("x.com/a", "http://x.com:80/a")).toBe(true);
|
|
expect(under("api.stripe.com", "https://api.stripe.community/")).toBe(false);
|
|
expect(under("api.stripe.com/v1/charges", "https://api.stripe.com/v1/charges/ch_1?x")).toBe(
|
|
true,
|
|
);
|
|
expect(under("api.stripe.com/v1/charges", "https://api.stripe.com/v1/chargesx")).toBe(false);
|
|
expect(under("api.stripe.com:8443", "https://api.stripe.com/v1")).toBe(false);
|
|
// The sidebar lowercases hosts; paths keep their case.
|
|
expect(under("example.com/A", "http://EXAMPLE.com/A/b")).toBe(true);
|
|
expect(under("example.com/a", "http://example.com/A")).toBe(false);
|
|
});
|
|
|
|
test("keeps flows from an app, by bundle id or path", () => {
|
|
const chrome = flow("https://example.com/", {
|
|
clientBundleId: "com.google.Chrome",
|
|
clientPath: "/Applications/Google Chrome.app",
|
|
});
|
|
const curl = flow("https://example.com/", { clientPath: "/usr/bin/curl" });
|
|
const unknown = flow("https://example.com/");
|
|
const from = (app: string[]) => [chrome, curl, unknown].filter((f) => matchesQuery(f, { app }));
|
|
expect(from(["com.google.Chrome"])).toEqual([chrome]);
|
|
expect(from(["/usr/bin/curl"])).toEqual([curl]);
|
|
expect(from(["/Applications/Google Chrome.app"])).toEqual([]);
|
|
});
|
|
|
|
test("keeps flows from an app by name, or a program by the folder it ran in", () => {
|
|
// The flows `filters_by_app_name_folder_site_and_system` in `flows.rs` checks.
|
|
const slack = flow("https://edgeapi.slack.com/cache", {
|
|
clientName: "Slack",
|
|
clientBundleId: "com.tinyspeck.slackmacgap",
|
|
clientPath: "/Applications/Slack.app",
|
|
});
|
|
const node = flow("https://api.github.com:443/repos", {
|
|
clientName: "node",
|
|
clientPath: "/opt/homebrew/bin/node",
|
|
clientCwd: "/Users/me/src/fieldwork",
|
|
});
|
|
const daemon = flow("https://github.com/login", {
|
|
clientName: "nsurlsessiond",
|
|
clientPath: "/usr/libexec/nsurlsessiond",
|
|
clientCwd: "/",
|
|
});
|
|
const none = flow("http://notgithub.com/");
|
|
const flows = [slack, node, daemon, none];
|
|
const where = (query: Parameters<typeof matchesQuery>[1]) =>
|
|
flows.filter((f) => matchesQuery(f, query));
|
|
expect(where({ app: ["slack"] })).toEqual([slack]);
|
|
expect(where({ app: ["Fieldwork"] })).toEqual([node]);
|
|
expect(where({ app: ["/opt/homebrew/bin/node"] })).toEqual([node]);
|
|
expect(where({ under: ["*.github.com"] })).toEqual([node, daemon]);
|
|
expect(where({ under: ["api.github.com"] })).toEqual([node]);
|
|
expect(where({ system: true })).toEqual([daemon]);
|
|
expect(where({ system: false })).toEqual([slack, node, none]);
|
|
expect(flows.map(appKeyOf)).toEqual(["Slack", "fieldwork", "nsurlsessiond", null]);
|
|
});
|
|
|
|
test("combines terms with the sidebar's selection", () => {
|
|
expect(matchesQuery(stripe, { q: "post", under: ["api.stripe.com"] })).toBe(true);
|
|
expect(matchesQuery(stripe, { q: "post", under: ["api.github.com"] })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("sidebarPath", () => {
|
|
// The cases `sidebar_path_rules` in `flows.rs` checks, so the two agree.
|
|
test("is the lowercased host without its default port, then the non-empty segments", () => {
|
|
for (const [url, path] of [
|
|
["https://example.com/v1//users", "example.com/v1/users"],
|
|
["http://x.com:80/a", "x.com/a"],
|
|
["https://x.com:443/a?q=1#f", "x.com/a"],
|
|
["http://x.com:443/a", "x.com:443/a"],
|
|
["https://API.Stripe.com:8443/", "api.stripe.com:8443"],
|
|
["https://api.stripe.com", "api.stripe.com"],
|
|
["https://api.stripe.com?x=/y", "api.stripe.com"],
|
|
["http://[::1]:80/a/B", "[::1]/a/B"],
|
|
["/api", "/api"],
|
|
]) {
|
|
expect(sidebarPath(url ?? ""), url).toBe(path);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("parseSearch", () => {
|
|
test("is null for text that narrows nothing", () => {
|
|
expect(parseSearch("")).toBeNull();
|
|
expect(parseSearch(" ")).toBeNull();
|
|
expect(parseSearch("OR")).toBeNull();
|
|
});
|
|
|
|
test("keeps free words as terms, trimmed and collapsed", () => {
|
|
expect(parseSearch(" stripe post ")).toEqual({ q: "stripe post" });
|
|
});
|
|
|
|
test("reads apps, hosts and the paths below them", () => {
|
|
expect(parseSearch('@app:"Google Chrome" @app:Slack card')).toEqual({
|
|
q: "card",
|
|
app: ["Google Chrome", "Slack"],
|
|
});
|
|
expect(parseSearch("@app:fieldwork @host:api.stripe.com @path:/v1/payment_intents")).toEqual({
|
|
app: ["fieldwork"],
|
|
under: ["api.stripe.com/v1/payment_intents"],
|
|
});
|
|
expect(parseSearch("@host:*.github.com")).toEqual({ under: ["*.github.com"] });
|
|
expect(parseSearch("@host:github.com @path:/mountain-loop/yaak/*")).toEqual({
|
|
under: ["github.com/mountain-loop/yaak"],
|
|
});
|
|
// A path with no host matches anywhere in the URL.
|
|
expect(parseSearch("@path:/v1")).toEqual({ q: "/v1" });
|
|
// Each path stays with the host before it.
|
|
expect(parseSearch("@host:a.com @path:/x @host:b.com @path:/y")).toEqual({
|
|
under: ["a.com/x", "b.com/y"],
|
|
});
|
|
});
|
|
|
|
test("keeps each clause OR separates as a group of its own", () => {
|
|
// Two selected sidebar rows under different apps: Slack on a.com, or Chrome on b.com.
|
|
const text = "@app:Slack @host:a.com OR @app:Chrome @host:b.com";
|
|
const query = parseSearch(text);
|
|
expect(query).toEqual({
|
|
any: [
|
|
{ app: ["Slack"], under: ["a.com"] },
|
|
{ app: ["Chrome"], under: ["b.com"] },
|
|
],
|
|
});
|
|
const on = (clientName: string, host: string) =>
|
|
flow(`https://${host}/x`, { clientName, clientBundleId: `app.${clientName}` });
|
|
const match = (f: Flow) => query != null && matchesQuery(f, query);
|
|
expect(match(on("Slack", "a.com"))).toBe(true);
|
|
expect(match(on("Chrome", "b.com"))).toBe(true);
|
|
// Not every pairing of the two rows' apps and hosts.
|
|
expect(match(on("Slack", "b.com"))).toBe(false);
|
|
expect(match(on("Chrome", "a.com"))).toBe(false);
|
|
|
|
// Words bind to their clause, and hiding System applies to each group that does not ask.
|
|
expect(parseSearch("@host:a.com OR @is:system @app:trustd", true)).toEqual({
|
|
any: [
|
|
{ under: ["a.com"], system: false },
|
|
{ app: ["trustd"], system: true },
|
|
],
|
|
});
|
|
// A clause that narrows nothing takes in every flow.
|
|
expect(parseSearch("@app:Slack OR")).toEqual({ app: ["Slack"] });
|
|
expect(parseSearch("@app:Slack OR -@foo:bar")).toBeNull();
|
|
});
|
|
|
|
test("keeps or drops System, which hiding it drops unless the text asks", () => {
|
|
expect(parseSearch("@is:system")).toEqual({ system: true });
|
|
expect(parseSearch("-@is:system stripe")).toEqual({ q: "stripe", system: false });
|
|
expect(parseSearch("", true)).toEqual({ system: false });
|
|
expect(parseSearch("@is:system", true)).toEqual({ system: true });
|
|
});
|
|
|
|
test("keeps or drops HTTPS passed through undecrypted", () => {
|
|
expect(parseSearch("@is:tunnelled")).toEqual({ tunnelled: true });
|
|
expect(parseSearch("-@is:tunnelled stripe")).toEqual({ q: "stripe", tunnelled: false });
|
|
const tunnel = { url: "https://a.com", method: "CONNECT", tunnelled: true } as Flow;
|
|
const decrypted = { url: "https://a.com/x", method: "GET", tunnelled: false } as Flow;
|
|
expect(matchesQuery(tunnel, { tunnelled: true })).toBe(true);
|
|
expect(matchesQuery(decrypted, { tunnelled: true })).toBe(false);
|
|
expect(matchesQuery(decrypted, { tunnelled: false })).toBe(true);
|
|
});
|
|
|
|
test("round-trips what the sidebar writes", () => {
|
|
const text = [
|
|
formatFieldFilter("app", "Google Chrome"),
|
|
formatFieldFilter("host", "api.x.com"),
|
|
].join(" ");
|
|
expect(text).toBe('@app:"Google Chrome" @host:api.x.com');
|
|
expect(tokenize(text)).toEqual([
|
|
{ field: "app", value: "Google Chrome", negated: false },
|
|
{ field: "host", value: "api.x.com", negated: false },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("widerQueries", () => {
|
|
test("leaves out each term in turn", () => {
|
|
expect(widerQueries('@app:Slack @status:5xx "card declined"')).toEqual([
|
|
'@status:5xx "card declined"',
|
|
'@app:Slack "card declined"',
|
|
"@app:Slack @status:5xx",
|
|
]);
|
|
expect(widerQueries("@app:Slack")).toEqual([]);
|
|
});
|
|
|
|
test("widens one OR group at a time, keeping the groups", () => {
|
|
const text = "@app:Slack @host:absent.com OR @app:Chrome @host:absent.com";
|
|
expect(widerQueries(text)).toEqual([
|
|
"@host:absent.com OR @app:Chrome @host:absent.com",
|
|
"@app:Slack OR @app:Chrome @host:absent.com",
|
|
"@app:Slack @host:absent.com OR @host:absent.com",
|
|
"@app:Slack @host:absent.com OR @app:Chrome",
|
|
]);
|
|
// A group of one term is not emptied, which would take in every flow.
|
|
expect(widerQueries("@app:Slack OR @app:Chrome @host:b.com")).toEqual([
|
|
"@app:Slack OR @host:b.com",
|
|
"@app:Slack OR @app:Chrome",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("isSystemFlow", () => {
|
|
// The cases `tells_system_daemons_apart` in `flows.rs` checks, so the two agree.
|
|
test("is a daemon outside an app, under the system's folders or by name", () => {
|
|
const sys = (clientPath: string | null, clientBundleId: string | null = null) =>
|
|
isSystemFlow({ clientPath, clientBundleId });
|
|
expect(sys("/usr/libexec/nsurlsessiond")).toBe(true);
|
|
expect(sys("/System/Library/PrivateFrameworks/X.framework/geod")).toBe(true);
|
|
expect(sys("/somewhere/else/trustd")).toBe(true);
|
|
expect(sys("/usr/bin/curl")).toBe(false);
|
|
expect(sys("/opt/homebrew/bin/node")).toBe(false);
|
|
expect(sys("/System/Applications/Music.app", "com.apple.Music")).toBe(false);
|
|
expect(
|
|
sys(
|
|
"/System/Library/Frameworks/WebKit.framework/XPCServices/com.apple.WebKit.Networking.xpc/Contents/MacOS/com.apple.WebKit.Networking",
|
|
),
|
|
).toBe(false);
|
|
expect(sys(null)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("siteOf", () => {
|
|
test("is the registrable domain, or the host itself without one", () => {
|
|
expect(siteOf("api.github.com")).toBe("github.com");
|
|
expect(siteOf("www.example.co.uk")).toBe("example.co.uk");
|
|
expect(siteOf("api.stripe.com:8443")).toBe("stripe.com");
|
|
expect(siteOf("localhost:3000")).toBe("localhost");
|
|
expect(siteOf("149.154.175.50")).toBe("149.154.175.50");
|
|
});
|
|
});
|