diff --git a/src/lib/key-token.test.ts b/src/lib/key-token.test.ts new file mode 100644 index 0000000..29ce979 --- /dev/null +++ b/src/lib/key-token.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { decodeObjectKeyToken, encodeObjectKeyToken } from "./key-token"; + +describe("encodeObjectKeyToken / decodeObjectKeyToken", () => { + it("round-trips a typical S3 key with slashes", () => { + const key = "vault/my-collection/secrets/.env"; + expect(decodeObjectKeyToken(encodeObjectKeyToken(key))).toBe(key); + }); + + it("uses URL-safe alphabet (no +, /, or trailing padding)", () => { + const token = encodeObjectKeyToken("a/b/c"); + expect(token).not.toMatch(/[+/=]/); + }); + + it("round-trips UTF-8 paths", () => { + const key = "prefix/café/日本語"; + expect(decodeObjectKeyToken(encodeObjectKeyToken(key))).toBe(key); + }); + + it("round-trips keys that decode to lengths not divisible by 4 before padding", () => { + const key = "x"; + expect(decodeObjectKeyToken(encodeObjectKeyToken(key))).toBe(key); + }); +}); diff --git a/src/lib/paths.test.ts b/src/lib/paths.test.ts new file mode 100644 index 0000000..fa3358c --- /dev/null +++ b/src/lib/paths.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + assertKeyUnderRoot, + assertSafeRelativePath, + assertValidCollectionSlug, + fullObjectKey, + splitObjectKeyAfterRoot, +} from "./paths"; + +const PREV_ROOT = process.env.S3_ROOT_PREFIX; + +describe("assertValidCollectionSlug", () => { + it("accepts a single segment slug", () => { + expect(() => assertValidCollectionSlug("my-app")).not.toThrow(); + }); + + it("rejects empty, dot segments, slashes, and traversal", () => { + for (const bad of ["", ".", "..", "a/b", "a..b"]) { + expect(() => assertValidCollectionSlug(bad)).toThrow( + "Invalid collection", + ); + } + }); +}); + +describe("assertSafeRelativePath", () => { + it("accepts normal relative paths", () => { + expect(() => assertSafeRelativePath(".env")).not.toThrow(); + expect(() => assertSafeRelativePath("dir/file.txt")).not.toThrow(); + }); + + it("rejects absolute paths and traversal segments", () => { + for (const bad of ["", "/etc/passwd", "..", "a/../b", "a/."]) { + expect(() => assertSafeRelativePath(bad)).toThrow("Invalid object path"); + } + }); +}); + +describe("fullObjectKey and splitObjectKeyAfterRoot", () => { + beforeEach(() => { + process.env.S3_ROOT_PREFIX = "vault/"; + }); + + afterEach(() => { + if (PREV_ROOT === undefined) delete process.env.S3_ROOT_PREFIX; + else process.env.S3_ROOT_PREFIX = PREV_ROOT; + }); + + it("builds keys under the configured root and splits them back", () => { + const key = fullObjectKey("app", "secrets/.env"); + expect(key).toBe("vault/app/secrets/.env"); + expect(splitObjectKeyAfterRoot(key)).toEqual({ + slug: "app", + relativePath: "secrets/.env", + }); + }); +}); + +describe("assertKeyUnderRoot", () => { + beforeEach(() => { + process.env.S3_ROOT_PREFIX = "vault/"; + }); + + afterEach(() => { + if (PREV_ROOT === undefined) delete process.env.S3_ROOT_PREFIX; + else process.env.S3_ROOT_PREFIX = PREV_ROOT; + }); + + it("allows keys under the root prefix", () => { + expect(() => assertKeyUnderRoot("vault/app/file")).not.toThrow(); + }); + + it("rejects keys outside the root and traversal", () => { + expect(() => assertKeyUnderRoot("other/app/file")).toThrow( + "Object key outside configured root", + ); + expect(() => assertKeyUnderRoot("vault/../evil")).toThrow( + "Invalid object key", + ); + }); +});