diff --git a/plugins/importer-insomnia/src/templates.ts b/plugins/importer-insomnia/src/templates.ts index c8241623e..169fd8700 100644 --- a/plugins/importer-insomnia/src/templates.ts +++ b/plugins/importer-insomnia/src/templates.ts @@ -24,13 +24,13 @@ function decodeArgument(value: string): string | null { function parseArgs(input: string): string[] | null { const args: string[] = []; const token = - /\s*(?:'((?:[^'\\]|\\['"\\nrt])*)'|"((?:[^"\\]|\\['"\\nrt])*)"|(-?\d+(?:\.\d+)?))\s*/y; + /\s*(?:'((?:[^'\\]|\\['"\\nrt])*)'|"((?:[^"\\]|\\['"\\nrt])*)"|(-?\d+(?:\.\d+)?)|(true|false))\s*/y; let offset = 0; while (offset < input.length) { token.lastIndex = offset; const match = token.exec(input); if (!match) return input.trim() === "" ? [] : null; - const literal = (match[1] ?? match[2] ?? match[3]!).replace( + const literal = (match[1] ?? match[2] ?? match[3] ?? match[4]!).replace( /\\(['"\\nrt])/g, (_, char: string) => ({ n: "\n", r: "\r", t: "\t" })[char] ?? char, ); @@ -46,16 +46,129 @@ function parseArgs(input: string): string[] | null { return args; } -function convertTag(name: string, input: string, requestIds: Set): string | null { - const args = parseArgs(input); - if (args == null) return null; - if (name === "uuid" && args.length === 1 && ["v1", "v4"].includes(args[0]!)) { - return `uuid.${args[0]}()`; +/** + * Insomnia's dropdown arguments fall back to the first option of their list when a tag leaves + * them out, so every converter below mirrors that first option as its default. + */ + +function convertUuid(args: string[]): string | null { + // Version 4 is listed first, so a bare tag generates one. + if (args.length === 0) return "uuid.v4()"; + if (args.length === 1 && ["v1", "v4"].includes(args[0]!)) return `uuid.${args[0]}()`; + return null; +} + +function convertNow(args: string[]): string | null { + if (args.length > 2) return null; + const [kind = "iso-8601", format = ""] = args; + switch (kind.toLowerCase()) { + case "iso-8601": + return "timestamp.iso8601()"; + case "millis": + case "ms": + return "timestamp.unixMillis()"; + case "unix": + case "seconds": + case "s": + return "timestamp.unix()"; + case "custom": + // The format string is passed through untranslated. Insomnia formats with date-fns + // today, like Yaak does, but older versions used Moment and those token sets differ + // (eg. Moment's DD/YYYY against date-fns' dd/yyyy). A token that no longer means the + // same thing shows up as a tag the user can correct. + return `timestamp.format(format=${argument(format)})`; + default: + return null; } - if (name === "faker" && args.length === 1 && args[0] === "randomUUID") { - return "uuid.v4()"; +} + +/** The legacy timestamp tag takes no arguments and returns milliseconds. */ +function convertTimestamp(args: string[]): string | null { + return args.length === 0 ? "timestamp.unixMillis()" : null; +} + +const base64Encodings: Record = { + normal: "base64", + url: "base64url", + // Insomnia's hex mode reads/writes hex rather than text. Yaak has no such mode, so this + // falls back to plain base64, which at least imports as a tag instead of raw text. + hex: "base64", +}; + +function convertBase64(args: string[]): string | null { + if (args.length < 2 || args.length > 3) return null; + // Assumed: exports written before the Kind argument existed pass (action, value), so a + // lone second argument that names no kind is the value. + const legacy = args.length === 2 && !Object.hasOwn(base64Encodings, args[1]!); + const encoding = base64Encodings[legacy ? "normal" : args[1]!]; + const value = legacy ? args[1]! : (args[2] ?? ""); + if (encoding == null) return null; + if (args[0] === "encode") { + return `base64.encode(encoding='${encoding}', value=${argument(value)})`; } - if (name !== "response" || args.length < 3 || args.length > 5) return null; + // Yaak's decoder reads both alphabets, so the kind doesn't change the call. + if (args[0] === "decode") return `base64.decode(value=${argument(value)})`; + return null; +} + +const hashAlgorithms = ["md5", "sha1", "sha256", "sha512"]; + +function convertHash(args: string[]): string | null { + if (args.length > 3) return null; + const [algorithm = "md5", encoding = "hex", value = ""] = args; + // Insomnia's own tag falls back to SHA-256 for algorithms outside its list. + const name = hashAlgorithms.includes(algorithm.toLowerCase()) + ? algorithm.toLowerCase() + : "sha256"; + // Insomnia offers only hex and base64, the same two digests Yaak does. + const digest = encoding === "base64" ? "base64" : "hex"; + return `hash.${name}(input=${argument(value)}, encoding='${digest}')`; +} + +function convertCookie(args: string[]): string | null { + if (args.length !== 2) return null; + const [url = "", name = ""] = args; + if (name === "") return null; + const domain = cookieDomain(url); + const filter = domain === "" ? "" : `, domain=${argument(domain)}`; + return `cookie.value(name=${argument(name)}${filter})`; +} + +/** Insomnia matches cookies against a full URL, while Yaak filters the jar by domain. */ +function cookieDomain(url: string): string { + try { + return new URL(url).hostname; + } catch { + // Not a URL we can read, usually because it holds a template tag. Keep the text so the + // user can see and fix it rather than dropping the filter. + return url.trim(); + } +} + +function convertPrompt(args: string[]): string | null { + if (args.length > 6) return null; + const [title = "", label = "", defaultValue = "", storageKey = "", mask = "false"] = args; + // Insomnia requires a title and shows the label above the input, falling back to the title. + if (title === "" && label === "") return null; + const parts = [`label=${argument(label === "" ? title : label)}`]; + const masked = mask === "true"; + if (storageKey !== "" && !masked) { + // Insomnia keeps a stored value only until the app closes. Yaak's nearest option is to + // store it forever, which is close enough for plain values. Masked values are left + // unstored, which is Yaak's default, so a password never outlives the session it was + // typed in. Storing needs a namespace, and the workspace is what Yaak's editor defaults to. + // oxlint-disable-next-line no-template-curly-in-string -- Yaak template syntax + const namespace = argument("${[ctx.workspace()]}"); + parts.push("store='forever'", `namespace=${namespace}`, `key=${argument(storageKey)}`); + } + if (title !== "") parts.push(`title=${argument(title)}`); + if (defaultValue !== "") parts.push(`defaultValue=${argument(defaultValue)}`); + if (masked) parts.push("password=true"); + return `prompt.text(${parts.join(", ")})`; +} + +function convertResponse(args: string[], requestIds: Set): string | null { + if (args.length < 2 || args.length > 5) return null; const [attribute, request, filter = "", behavior = "never", maxAge = "0"] = args; if (!request || !requestIds.has(convertId(request))) return null; const behaviors: Record = { @@ -81,9 +194,37 @@ function convertTag(name: string, input: string, requestIds: Set): strin if (attribute === "header" && filter !== "") { return `response.header(${common}, header=${argument(filter.trim())})`; } + // Insomnia's url attribute has no counterpart, since Yaak has no response URL function. return null; } +function convertTag(name: string, input: string, requestIds: Set): string | null { + const args = parseArgs(input); + if (args == null) return null; + switch (name) { + case "base64": + return convertBase64(args); + case "cookie": + return convertCookie(args); + case "faker": + return args.length === 1 && args[0] === "randomUUID" ? "uuid.v4()" : null; + case "hash": + return convertHash(args); + case "now": + return convertNow(args); + case "prompt": + return convertPrompt(args); + case "response": + return convertResponse(args, requestIds); + case "timestamp": + return convertTimestamp(args); + case "uuid": + return convertUuid(args); + default: + return null; + } +} + export function convertTemplateSyntax(obj: T, requestIds = new Set()): T { if (typeof obj === "string") { return obj diff --git a/plugins/importer-insomnia/tests/templates.test.ts b/plugins/importer-insomnia/tests/templates.test.ts index efb35c0ef..0357e89d7 100644 --- a/plugins/importer-insomnia/tests/templates.test.ts +++ b/plugins/importer-insomnia/tests/templates.test.ts @@ -32,6 +32,17 @@ describe("Insomnia templates", () => { "{% response 'body', 'req_login', '/root/token', 'always', 60 %}", "{% response 'body', 'req_login', '', 'never', 0 %}", "{% response 'body', 'req_login', '$.token', 'when-expired', 0 %}", + "{% response 'url', 'req_login', '', 'never', 0 %}", + "{% os 'arch' %}", + "{% file '/tmp/token.txt' %}", + "{% jsonpath '{}', '$.a' %}", + "{% request 'url' %}", + "{% now 'quarter' %}", + "{% uuid 'v4', 'extra' %}", + "{% base64 'encode' %}", + "{% cookie 'https://example.com' %}", + "{% prompt %}", + "{% timestamp 'ms' %}", ])("preserves unsupported or malformed syntax: %s", (input) => { expect(convert(input)).toBe(input); }); @@ -74,6 +85,138 @@ describe("Insomnia templates", () => { ).toContain(`header=${encoded("x-'quoted,header")}`); }); + test("converts a bare UUID tag to v4, the version Insomnia lists first", () => { + expect(convert("{% uuid %}")).toBe("${[ uuid.v4() ]}"); + }); + + test.each([ + ["{% now %}", "timestamp.iso8601()"], + ["{% now 'iso-8601' %}", "timestamp.iso8601()"], + ["{% now 'ISO-8601' %}", "timestamp.iso8601()"], + ["{% now 'unix' %}", "timestamp.unix()"], + ["{% now 'seconds' %}", "timestamp.unix()"], + ["{% now 's' %}", "timestamp.unix()"], + ["{% now 'millis' %}", "timestamp.unixMillis()"], + ["{% now 'ms' %}", "timestamp.unixMillis()"], + ["{% timestamp %}", "timestamp.unixMillis()"], + ])("converts %s", (input, output) => { + expect(convert(input)).toBe(`\${[ ${output} ]}`); + }); + + test("passes custom date formats through without translating tokens", () => { + const format = "YYYY-MM-DD HH:mm:ss"; + expect(convert(`{% now 'custom', '${format}' %}`)).toBe( + `\${[ timestamp.format(format=${encoded(format)}) ]}`, + ); + expect(convert("{% now 'custom' %}")).toBe(`\${[ timestamp.format(format=${encoded("")}) ]}`); + }); + + test.each([ + ["{% base64 'encode', 'normal', 'hello' %}", "base64.encode(encoding='base64', value="], + ["{% base64 'encode', 'url', 'hello' %}", "base64.encode(encoding='base64url', value="], + // Yaak has no hex mode, so it imports as the plain alphabet. + ["{% base64 'encode', 'hex', 'hello' %}", "base64.encode(encoding='base64', value="], + ["{% base64 'encode', 'hello' %}", "base64.encode(encoding='base64', value="], + ["{% base64 'decode', 'normal', 'hello' %}", "base64.decode(value="], + ["{% base64 'decode', 'hello' %}", "base64.decode(value="], + ])("converts %s", (input, prefix) => { + expect(convert(input)).toBe(`\${[ ${prefix}${encoded("hello")}) ]}`); + }); + + test("defaults an omitted base64 value to the empty string", () => { + expect(convert("{% base64 'encode', 'normal' %}")).toBe( + `\${[ base64.encode(encoding='base64', value=${encoded("")}) ]}`, + ); + }); + + test("converts variables nested inside tag arguments", () => { + expect(convert("{% base64 'encode', 'normal', '{{ _.secret }}' %}")).toBe( + `\${[ base64.encode(encoding='base64', value=${encoded("${[ secret ]}")}) ]}`, + ); + }); + + test.each(["md5", "sha1", "sha256", "sha512"])("converts the %s hash", (algorithm) => { + expect(convert(`{% hash '${algorithm}', 'hex', 'text' %}`)).toBe( + `\${[ hash.${algorithm}(input=${encoded("text")}, encoding='hex') ]}`, + ); + }); + + test("keeps the base64 digest encoding", () => { + expect(convert("{% hash 'sha256', 'base64', 'text' %}")).toBe( + `\${[ hash.sha256(input=${encoded("text")}, encoding='base64') ]}`, + ); + }); + + test("defaults omitted hash arguments to MD5 and hex, like Insomnia's dropdowns", () => { + expect(convert("{% hash %}")).toBe(`\${[ hash.md5(input=${encoded("")}, encoding='hex') ]}`); + expect(convert("{% hash 'sha1' %}")).toBe( + `\${[ hash.sha1(input=${encoded("")}, encoding='hex') ]}`, + ); + }); + + test("falls back to SHA-256 for algorithms outside Insomnia's list, as Insomnia does", () => { + expect(convert("{% hash 'sha384', 'hex', 'text' %}")).toBe( + `\${[ hash.sha256(input=${encoded("text")}, encoding='hex') ]}`, + ); + }); + + test("converts a cookie URL to the domain Yaak filters by", () => { + expect(convert("{% cookie 'https://api.example.com/login', 'session' %}")).toBe( + `\${[ cookie.value(name=${encoded("session")}, domain=${encoded("api.example.com")}) ]}`, + ); + }); + + test("keeps an unreadable cookie URL so it stays visible in the tag", () => { + expect(convert("{% cookie '{{ _.base_url }}', 'session' %}")).toBe( + `\${[ cookie.value(name=${encoded("session")}, domain=${encoded("${[ base_url ]}")}) ]}`, + ); + }); + + test("searches the whole jar when the cookie URL is empty", () => { + expect(convert("{% cookie '', 'session' %}")).toBe( + `\${[ cookie.value(name=${encoded("session")}) ]}`, + ); + }); + + test("converts a prompt with only a title", () => { + expect(convert("{% prompt 'Password' %}")).toBe( + `\${[ prompt.text(label=${encoded("Password")}, title=${encoded("Password")}) ]}`, + ); + }); + + test("converts every prompt argument with a Yaak counterpart", () => { + expect(convert("{% prompt 'Login', 'Username', 'admin', 'user-key', false, true %}")).toBe( + `\${[ prompt.text(label=${encoded("Username")}, store='forever', ` + + `namespace=${encoded("${[ctx.workspace()]}")}, key=${encoded("user-key")}, ` + + `title=${encoded("Login")}, defaultValue=${encoded("admin")}) ]}`, + ); + }); + + test("never stores a masked prompt, even with a storage key", () => { + expect(convert("{% prompt 'Login', 'Password', 'hunter2', 'pw-key', true, true %}")).toBe( + `\${[ prompt.text(label=${encoded("Password")}, title=${encoded("Login")}, ` + + `defaultValue=${encoded("hunter2")}, password=true) ]}`, + ); + }); + + test("masks a prompt without a storage key", () => { + expect(convert("{% prompt 'Login', 'Password', '', '', true %}")).toBe( + `\${[ prompt.text(label=${encoded("Password")}, title=${encoded("Login")}, password=true) ]}`, + ); + }); + + test("leaves the prompt unmasked and unstored by default", () => { + expect(convert("{% prompt 'Login', 'Password', '', '', false, true %}")).toBe( + `\${[ prompt.text(label=${encoded("Password")}, title=${encoded("Login")}) ]}`, + ); + }); + + test("converts the two-argument response form", () => { + expect(convert("{% response 'raw', 'req_login' %}")).toBe( + `\${[ response.body.raw(request=${encoded("GENERATE_ID::req_login")}, behavior='never', ttl='0') ]}`, + ); + }); + test("converts nested resource fields without changing the input", () => { const input = { authentication: { token: "{{ _['token'] }}" },