|
| 1 | +import dotenv from "dotenv"; |
| 2 | +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; |
| 3 | +import { tmpdir } from "node:os"; |
| 4 | +import { join } from "node:path"; |
| 5 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; |
| 6 | +import { prepareEnvFile, readEnvVariable, trimSecret, upsertEnvLine } from "./envFile.js"; |
| 7 | +import { parseGracePeriod } from "./gracePeriod.js"; |
| 8 | + |
| 9 | +describe("upsertEnvLine", () => { |
| 10 | + it("appends, adding a missing newline", () => { |
| 11 | + expect(upsertEnvLine("", "S", "abc123")).toBe("S=abc123\n"); |
| 12 | + expect(upsertEnvLine("A=1", "S", "2")).toBe("A=1\nS=2\n"); |
| 13 | + }); |
| 14 | + |
| 15 | + it("replaces an existing value in place without touching a variable that shares its prefix", () => { |
| 16 | + expect(upsertEnvLine("A=1\nB=old\nBB=keep\n", "B", "new")).toBe("A=1\nB=new\nBB=keep\n"); |
| 17 | + expect(upsertEnvLine("export B=old\n", "B", "new")).toBe("B=new\n"); |
| 18 | + }); |
| 19 | + |
| 20 | + it("removes every assignment, so dotenv doesn't keep reading an old duplicate", () => { |
| 21 | + const out = upsertEnvLine("S=old1\nA=1\nS=old2\n", "S", "whsec_new"); |
| 22 | + expect(dotenv.parse(out)).toEqual({ S: "whsec_new", A: "1" }); |
| 23 | + }); |
| 24 | + |
| 25 | + it("removes a quoted multi-line value without leaving its continuation lines behind", () => { |
| 26 | + const out = upsertEnvLine('S="old\nOTHER=inside\nend"\nA=1\n', "S", "whsec_new"); |
| 27 | + expect(dotenv.parse(out)).toEqual({ S: "whsec_new", A: "1" }); |
| 28 | + }); |
| 29 | + |
| 30 | + it("leaves an assignment-shaped line inside another variable's quoted value alone", () => { |
| 31 | + expect( |
| 32 | + dotenv.parse(upsertEnvLine('OTHER="hello\nS=inside\nworld"\nS=old\n', "S", "new")) |
| 33 | + ).toEqual({ |
| 34 | + OTHER: "hello\nS=inside\nworld", |
| 35 | + S: "new", |
| 36 | + }); |
| 37 | + expect(dotenv.parse(upsertEnvLine('S=old\nA="before\nS=inside\nafter"\n', "S", "new"))).toEqual( |
| 38 | + { |
| 39 | + S: "new", |
| 40 | + A: "before\nS=inside\nafter", |
| 41 | + } |
| 42 | + ); |
| 43 | + }); |
| 44 | + |
| 45 | + it("quotes values that dotenv would otherwise read back truncated", () => { |
| 46 | + expect(dotenv.parse(upsertEnvLine("", "S", "has space#hash"))).toEqual({ S: "has space#hash" }); |
| 47 | + }); |
| 48 | +}); |
| 49 | + |
| 50 | +describe("env files on disk", () => { |
| 51 | + let dir: string; |
| 52 | + |
| 53 | + beforeEach(async () => { |
| 54 | + dir = await mkdtemp(join(tmpdir(), "webhook-secret-")); |
| 55 | + }); |
| 56 | + |
| 57 | + afterEach(async () => { |
| 58 | + delete process.env.TEST_WEBHOOK_SECRET_FROM_ENV; |
| 59 | + await rm(dir, { recursive: true, force: true }); |
| 60 | + }); |
| 61 | + |
| 62 | + it("reads the process env first, then .env files with later files winning", async () => { |
| 63 | + await writeFile(join(dir, ".env"), "S=base\nTEST_WEBHOOK_SECRET_FROM_ENV=file\n"); |
| 64 | + await writeFile(join(dir, ".env.local"), "S=local\n"); |
| 65 | + process.env.TEST_WEBHOOK_SECRET_FROM_ENV = "process"; |
| 66 | + expect(await readEnvVariable("TEST_WEBHOOK_SECRET_FROM_ENV", undefined, dir)).toBe("process"); |
| 67 | + expect(await readEnvVariable("S", undefined, dir)).toBe("local"); |
| 68 | + expect(await readEnvVariable("S", ".env", dir)).toBe("base"); |
| 69 | + expect(await readEnvVariable("MISSING", undefined, dir)).toBeUndefined(); |
| 70 | + }); |
| 71 | + |
| 72 | + it("rejects a destination in a missing directory before anything is rotated", async () => { |
| 73 | + await expect(prepareEnvFile("missing/secret.env", dir)).rejects.toThrow("doesn't exist"); |
| 74 | + await expect(prepareEnvFile(".env.local", dir)).resolves.toBe(join(dir, ".env.local")); |
| 75 | + }); |
| 76 | + |
| 77 | + it("rejects a symlinked destination that could point at a tracked or shared file", async () => { |
| 78 | + await mkdir(join(dir, "shared")); |
| 79 | + await writeFile(join(dir, "shared", "config.env"), ""); |
| 80 | + await symlink(join(dir, "shared", "config.env"), join(dir, ".env.local")); |
| 81 | + await expect(prepareEnvFile(".env.local", dir)).rejects.toThrow("symlink"); |
| 82 | + }); |
| 83 | +}); |
| 84 | + |
| 85 | +describe("parseGracePeriod", () => { |
| 86 | + it("accepts seconds and durations, and refuses anything past the 7 day cap", () => { |
| 87 | + expect(parseGracePeriod("0")).toBe(0); |
| 88 | + expect(parseGracePeriod("3600")).toBe(3600); |
| 89 | + expect(parseGracePeriod("24h")).toBe(86400); |
| 90 | + expect(parseGracePeriod("7d")).toBe(604800); |
| 91 | + expect(() => parseGracePeriod("8d")).toThrow("up to 7d"); |
| 92 | + expect(parseGracePeriod("1d12h")).toBe(129600); |
| 93 | + expect(() => parseGracePeriod("soon")).toThrow("up to 7d"); |
| 94 | + expect(() => parseGracePeriod("1h2h")).toThrow("up to 7d"); |
| 95 | + expect(parseGracePeriod("1hr30m")).toBe(5400); |
| 96 | + expect(() => parseGracePeriod("1h1hr")).toThrow("up to 7d"); |
| 97 | + }); |
| 98 | +}); |
| 99 | + |
| 100 | +describe("trimSecret", () => { |
| 101 | + it("drops the newline a pipe adds, so it isn't stored as part of the secret", () => { |
| 102 | + expect(trimSecret("whsec_abc\r\n")).toBe("whsec_abc"); |
| 103 | + }); |
| 104 | +}); |
0 commit comments