diff --git a/apps/cli/src/domain/body.test.ts b/apps/cli/src/domain/body.test.ts new file mode 100644 index 0000000..5aa83d8 --- /dev/null +++ b/apps/cli/src/domain/body.test.ts @@ -0,0 +1,192 @@ +import type { EventStreamPayload } from "@barestash/shared"; +import { describe, expect, it } from "vitest"; + +import { + isBodyMetadata, + transformBody, + transformStreamBody, + transformStreamPayload, +} from "./body.js"; + +function encodeBase64(value: string | Uint8Array): string { + return Buffer.from(value).toString("base64"); +} + +function streamPayload( + contentType: string, + data: string, + headers: EventStreamPayload["request"]["headers"] = {}, +): EventStreamPayload { + return { + id: "evt_body" as EventStreamPayload["id"], + endpoint_id: "ep_body" as EventStreamPayload["endpoint_id"], + received_at: "2026-07-12T12:00:00.000Z", + request: { + method: "POST", + path: "/webhook", + query: {}, + headers: { + "content-type": contentType, + ...headers, + }, + body_size: Buffer.from(data, "base64").byteLength, + body_sha256: "test-sha256", + }, + body: { + encoding: "base64", + data, + }, + }; +} + +function expectBodyMetadata( + value: unknown, + contentType: string, + size: number, +): void { + expect(isBodyMetadata(value)).toBe(true); + + if (!isBodyMetadata(value)) { + throw new Error("Expected synthetic body metadata."); + } + + expect(value.content_type).toBe(contentType); + expect(value.size).toBe(size); +} + +describe("transformBody", () => { + it.each([ + "application/json", + "application/problem+json", + ])("parses %s bodies as JSON", (contentType) => { + expect( + transformBody( + Buffer.from(JSON.stringify({ accepted: true })), + `${contentType}; charset=utf-8`, + ), + ).toEqual({ accepted: true }); + }); + + it("falls back to text for malformed JSON", () => { + expect(transformBody(Buffer.from('{"event":'), "application/json")).toBe( + '{"event":', + ); + }); + + it.each([ + "text/plain", + "application/x-www-form-urlencoded", + ])("decodes %s bodies as text", (contentType) => { + expect(transformBody(Buffer.from("hello=world"), contentType)).toBe( + "hello=world", + ); + }); + + it.each([ + "application/json", + "text/plain", + ])("returns metadata for invalid UTF-8 in direct %s bodies", (contentType) => { + expectBodyMetadata( + transformBody(new Uint8Array([0xff, 0xfe]), contentType), + contentType, + 2, + ); + }); + + it("returns metadata for empty bodies", () => { + expectBodyMetadata( + transformBody(new Uint8Array(), "text/plain"), + "text/plain", + 0, + ); + }); + + it("returns metadata for multipart bodies", () => { + const contentType = "multipart/form-data; boundary=barestash"; + + expectBodyMetadata( + transformBody(Buffer.from("--barestash--"), contentType), + contentType, + 13, + ); + }); + + it("returns metadata for binary bodies", () => { + expectBodyMetadata( + transformBody(new Uint8Array([0, 1, 2, 255]), "application/octet-stream"), + "application/octet-stream", + 4, + ); + }); +}); + +describe("stream body transformation", () => { + it("decodes and parses base64 JSON bodies", () => { + const payload = streamPayload( + "application/json", + encodeBase64(JSON.stringify({ streamed: true })), + ); + + expect(transformStreamBody(payload)).toEqual({ streamed: true }); + }); + + it("falls back to the original base64 for invalid UTF-8 text", () => { + const data = encodeBase64(new Uint8Array([0xff, 0xfe])); + const payload = streamPayload("text/plain", data); + + expect(transformStreamBody(payload)).toBe(data); + }); + + it("returns metadata for empty, multipart, and binary stream bodies", () => { + const cases = [ + streamPayload("text/plain", ""), + streamPayload( + "multipart/form-data; boundary=barestash", + encodeBase64("--barestash--"), + ), + streamPayload( + "application/octet-stream", + encodeBase64(new Uint8Array([0, 1, 2, 255])), + ), + ]; + + expectBodyMetadata(transformStreamBody(cases[0]), "text/plain", 0); + expectBodyMetadata( + transformStreamBody(cases[1]), + "multipart/form-data; boundary=barestash", + 13, + ); + expectBodyMetadata( + transformStreamBody(cases[2]), + "application/octet-stream", + 4, + ); + }); + + it("redacts sensitive headers while preserving safe headers", () => { + const payload = streamPayload( + "application/json", + encodeBase64(JSON.stringify({ ok: true })), + { + authorization: "Bearer test-secret", + "stripe-signature": "test-signature", + "x-barestash-secret": "test-endpoint-secret", + "x-request-id": "req_test", + }, + ); + + expect(transformStreamPayload(payload)).toEqual({ + ...payload, + request: { + ...payload.request, + headers: { + authorization: "[REDACTED]", + "content-type": "application/json", + "stripe-signature": "[REDACTED]", + "x-request-id": "req_test", + }, + }, + body: { ok: true }, + }); + }); +}); diff --git a/apps/cli/src/domain/body.ts b/apps/cli/src/domain/body.ts index e7304c5..74a1ab4 100644 --- a/apps/cli/src/domain/body.ts +++ b/apps/cli/src/domain/body.ts @@ -56,14 +56,26 @@ export function isBodyMetadata(body: unknown): body is BodyMetadata { ); } -export function transformBody(bytes: Uint8Array, contentType: string): unknown { +function transformBodyBytes( + bytes: Uint8Array, + contentType: string, + invalidTextFallback: (metadata: BodyMetadata) => unknown, +): unknown { + const metadata = bodyMetadata(contentType, bytes.byteLength); + if (bytes.byteLength === 0 || isMultipartContentType(contentType)) { - return bodyMetadata(contentType, bytes.byteLength); + return metadata; } const text = decodeUtf8(bytes); + const isJson = isJsonContentType(contentType); + const isText = isTextContentType(contentType); - if (isJsonContentType(contentType) && text !== null) { + if (text === null) { + return isJson || isText ? invalidTextFallback(metadata) : metadata; + } + + if (isJson) { try { return JSON.parse(text) as unknown; } catch { @@ -71,11 +83,15 @@ export function transformBody(bytes: Uint8Array, contentType: string): unknown { } } - if (isTextContentType(contentType) && text !== null) { + if (isText) { return text; } - return bodyMetadata(contentType, bytes.byteLength); + return metadata; +} + +export function transformBody(bytes: Uint8Array, contentType: string): unknown { + return transformBodyBytes(bytes, contentType, (metadata) => metadata); } function decodeBase64(value: string): Uint8Array { @@ -86,33 +102,7 @@ export function transformStreamBody(payload: EventStreamPayload): unknown { const contentType = payload.request.headers["content-type"] ?? ""; const bytes = decodeBase64(payload.body.data); - if (bytes.byteLength === 0 || isMultipartContentType(contentType)) { - return bodyMetadata(contentType, bytes.byteLength); - } - - const text = decodeUtf8(bytes); - - if (text === null) { - if (isJsonContentType(contentType) || isTextContentType(contentType)) { - return payload.body.data; - } - - return bodyMetadata(contentType, bytes.byteLength); - } - - if (isJsonContentType(contentType)) { - try { - return JSON.parse(text) as unknown; - } catch { - return text; - } - } - - if (isTextContentType(contentType)) { - return text; - } - - return bodyMetadata(contentType, bytes.byteLength); + return transformBodyBytes(bytes, contentType, () => payload.body.data); } export function transformStreamPayload(payload: EventStreamPayload): unknown { diff --git a/apps/cli/src/domain/config.test.ts b/apps/cli/src/domain/config.test.ts new file mode 100644 index 0000000..dbb66c9 --- /dev/null +++ b/apps/cli/src/domain/config.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { parseConfig, resolveConfigPath, serializeConfig } from "./config.js"; + +describe("resolveConfigPath", () => { + it("prefers BARESTASH_CONFIG_FILE over every platform default", () => { + expect( + resolveConfigPath( + { + BARESTASH_CONFIG_FILE: "/override/barestash.json", + XDG_CONFIG_HOME: "/xdg", + APPDATA: "C:/AppData", + }, + "win32", + "/home/tester", + ), + ).toBe("/override/barestash.json"); + }); + + it("prefers a non-empty XDG_CONFIG_HOME over platform defaults", () => { + expect( + resolveConfigPath({ XDG_CONFIG_HOME: "/xdg" }, "darwin", "/Users/tester"), + ).toBe("/xdg/barestash/config.json"); + }); + + it("treats an empty XDG_CONFIG_HOME as unset", () => { + expect( + resolveConfigPath({ XDG_CONFIG_HOME: "" }, "darwin", "/Users/tester"), + ).toBe("/Users/tester/Library/Application Support/barestash/config.json"); + }); + + it("uses the macOS application support directory", () => { + expect(resolveConfigPath({}, "darwin", "/Users/tester")).toBe( + "/Users/tester/Library/Application Support/barestash/config.json", + ); + }); + + it("uses APPDATA on Windows", () => { + expect( + resolveConfigPath( + { APPDATA: "C:/Users/tester/AppData/Roaming" }, + "win32", + "C:/Users/tester", + ), + ).toBe("C:/Users/tester/AppData/Roaming/barestash/config.json"); + }); + + it("falls back to the user profile when APPDATA is unavailable on Windows", () => { + expect(resolveConfigPath({}, "win32", "C:/Users/tester")).toBe( + "C:/Users/tester/AppData/Roaming/barestash/config.json", + ); + }); + + it("falls back to the conventional Linux config directory", () => { + expect(resolveConfigPath({}, "linux", "/home/tester")).toBe( + "/home/tester/.config/barestash/config.json", + ); + }); +}); + +describe("config serialization", () => { + it.each([ + null, + "", + " ", + "{", + "null", + '"not-an-object"', + ])("parses empty or malformed config as an empty object: %j", (text) => { + expect(parseConfig(text)).toEqual({}); + }); + + it("round trips token and endpoint config with a trailing newline", () => { + const config = { + token: "test-token", + default_endpoint: "ep_test", + }; + const serialized = serializeConfig(config); + + expect(serialized.endsWith("\n")).toBe(true); + expect(parseConfig(serialized)).toEqual(config); + }); +}); diff --git a/apps/cli/src/infrastructure/config/file-config-store.test.ts b/apps/cli/src/infrastructure/config/file-config-store.test.ts new file mode 100644 index 0000000..a040cb8 --- /dev/null +++ b/apps/cli/src/infrastructure/config/file-config-store.test.ts @@ -0,0 +1,86 @@ +import { access, chmod, mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { FileConfigStore } from "./file-config-store.js"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => + rm(path, { + force: true, + recursive: true, + }), + ), + ); +}); + +async function temporaryStore(): Promise<{ + configPath: string; + store: FileConfigStore; +}> { + const directory = await mkdtemp(join(tmpdir(), "barestash-config-test-")); + const configPath = join(directory, "nested", "config.json"); + temporaryDirectories.push(directory); + + return { + configPath, + store: new FileConfigStore({ + env: { + BARESTASH_CONFIG_FILE: configPath, + }, + platformName: process.platform, + homeDirectory: directory, + }), + }; +} + +describe("FileConfigStore", () => { + it("reads an absent config as empty", async () => { + const { store } = await temporaryStore(); + + await expect(store.read()).resolves.toEqual({}); + }); + + it("creates parent directories, writes config, and reads it back", async () => { + const { configPath, store } = await temporaryStore(); + const config = { + token: "test-token", + default_endpoint: "ep_test", + }; + + await store.write(config); + + await expect(store.read()).resolves.toEqual(config); + await expect(readFile(configPath, "utf8")).resolves.toBe( + `${JSON.stringify(config, null, 2)}\n`, + ); + }); + + it("restricts the config file to the current user on POSIX platforms", async () => { + const { configPath, store } = await temporaryStore(); + + await store.write({ token: "test-token" }); + + if (process.platform !== "win32") { + await chmod(configPath, 0o644); + await store.write({ token: "updated-test-token" }); + + expect((await stat(configPath)).mode & 0o777).toBe(0o600); + } + }); + + it("deletes the config file and tolerates repeated deletion", async () => { + const { configPath, store } = await temporaryStore(); + await store.write({ token: "test-token" }); + + await store.delete(); + await store.delete(); + + await expect(access(configPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(store.read()).resolves.toEqual({}); + }); +});