diff --git a/src/github-action/dotenv-parse-alignment.test.ts b/src/github-action/dotenv-parse-alignment.test.ts new file mode 100644 index 0000000..535f66d --- /dev/null +++ b/src/github-action/dotenv-parse-alignment.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { parseDotenv as parseApp } from "@/lib/dotenv-parse"; +import { parseDotenv as parseAction } from "./dotenv-parse"; + +/** Keeps GitHub Action parsing aligned with vault UI / server (shared contract). */ +describe("dotenv-parse parity (action vs app)", () => { + const fixtures = [ + "", + "\n# c\n\nA=1\n", + "B=\"x y\"\nC='z'\r\n", + "EMPTY=\nD=tail", + "no=good\n=bad\nKEY_ONLY", + ]; + + it.each(fixtures)("matches for fixture %#", (content) => { + expect(parseAction(content)).toEqual(parseApp(content)); + }); +}); diff --git a/src/lib/dotenv-parse.test.ts b/src/lib/dotenv-parse.test.ts new file mode 100644 index 0000000..3b07364 --- /dev/null +++ b/src/lib/dotenv-parse.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { appendDotenvKey, parseDotenv, removeDotenvKey } from "./dotenv-parse"; + +describe("parseDotenv", () => { + it("ignores blanks and # comments", () => { + expect(parseDotenv("\n \n# x=1\nFOO=bar\n")).toEqual([ + { key: "FOO", value: "bar" }, + ]); + }); + + it("handles CRLF line endings", () => { + expect(parseDotenv("A=1\r\nB=2\r\n")).toEqual([ + { key: "A", value: "1" }, + { key: "B", value: "2" }, + ]); + }); + + it("strips optional single or double quotes around values", () => { + expect(parseDotenv("X=\"a b\"\nY='c'")).toEqual([ + { key: "X", value: "a b" }, + { key: "Y", value: "c" }, + ]); + }); + + it("skips lines without = or with empty key", () => { + expect(parseDotenv("=nope\nnoequals\nOK=1")).toEqual([ + { key: "OK", value: "1" }, + ]); + }); + + it("parses unquoted values that contain spaces", () => { + expect(parseDotenv("MSG=hello world")).toEqual([ + { key: "MSG", value: "hello world" }, + ]); + }); +}); + +describe("removeDotenvKey", () => { + it("removes assignment lines for the key and preserves comments and blanks", () => { + const src = "# keep\n\nAPI=old\n\nOTHER=1\n"; + expect(removeDotenvKey(src, "API")).toBe("# keep\n\n\nOTHER=1\n"); + }); + + it("removes every matching assignment line", () => { + expect(removeDotenvKey("A=1\nA=2\nB=3", "A")).toBe("B=3"); + }); +}); + +describe("appendDotenvKey", () => { + it("appends a new line and rejects duplicate keys", () => { + const base = "EXISTING=1\n"; + expect(appendDotenvKey(base, "NEW", "v")).toEqual({ + ok: true, + content: "EXISTING=1\nNEW=v", + }); + expect(appendDotenvKey("EXISTING=1\n", "EXISTING", "x")).toEqual({ + ok: false, + error: '"EXISTING" is already defined', + }); + }); + + it("trims trailing whitespace on the file before appending", () => { + expect(appendDotenvKey("A=1\n \n", "B", "2")).toEqual({ + ok: true, + content: "A=1\nB=2", + }); + }); + + it("quotes values that contain spaces or # and round-trips when no inner quotes", () => { + const r = appendDotenvKey("", "K", "a b # c"); + expect(r.ok).toBe(true); + if (r.ok) { + expect(parseDotenv(r.content)).toEqual([{ key: "K", value: "a b # c" }]); + } + }); + + it("escapes embedded double quotes in the serialized line", () => { + const r = appendDotenvKey("", "K", 'say "hi"'); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.content).toBe('K="say \\"hi\\""'); + } + }); + + it("rejects invalid keys and multiline values with clear errors", () => { + expect(appendDotenvKey("", "", "v")).toEqual({ + ok: false, + error: "Enter a variable name", + }); + expect(appendDotenvKey("", "a=b", "v")).toEqual({ + ok: false, + error: 'Key cannot contain "="', + }); + expect(appendDotenvKey("", "#x", "v")).toEqual({ + ok: false, + error: "Key cannot start with #", + }); + expect(appendDotenvKey("", "K", "a\nb")).toEqual({ + ok: false, + error: "Value cannot contain line breaks", + }); + }); +}); diff --git a/src/lib/key-token.test.ts b/src/lib/key-token.test.ts new file mode 100644 index 0000000..0832603 --- /dev/null +++ b/src/lib/key-token.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { decodeObjectKeyToken, encodeObjectKeyToken } from "./key-token"; + +describe("encodeObjectKeyToken / decodeObjectKeyToken", () => { + it("round-trips arbitrary UTF-8 keys", () => { + const key = "team/prod/.env"; + const token = encodeObjectKeyToken(key); + expect(token).not.toMatch(/[+/=]/); + expect(decodeObjectKeyToken(token)).toBe(key); + }); + + it("round-trips keys with slashes, plus signs, and unicode", () => { + const key = "vault/a+b/café/密钥"; + expect(decodeObjectKeyToken(encodeObjectKeyToken(key))).toBe(key); + }); + + it("produces stable output for the same input", () => { + const k = "x/y"; + expect(encodeObjectKeyToken(k)).toBe(encodeObjectKeyToken(k)); + }); +});