diff --git a/README.md b/README.md index bd80d26..1735484 100644 --- a/README.md +++ b/README.md @@ -432,6 +432,59 @@ Options: | Schema engine | custom (zero dependencies) | | Diff engine | custom recursive differ | +## Environment Variables + +| Variable | Default | Description | +| -------- | ------- | ----------- | +| `STAGING_TOKEN` | — | Auth token interpolated into `${STAGING_TOKEN}` in your config | +| `PROD_TOKEN` | — | Auth token interpolated into `${PROD_TOKEN}` in your config | +| `APIDRIFT_TIMEOUT_MS` | `10000` | HTTP request timeout in milliseconds. Increase for slow APIs. | + +**Bash / macOS / Linux:** +```bash +export APIDRIFT_TIMEOUT_MS=30000 +apidrift snapshot --tag v1.0 --env staging +``` + +**PowerShell (Windows):** +```powershell +$env:APIDRIFT_TIMEOUT_MS = "30000" +apidrift snapshot --tag v1.0 --env staging +``` + +--- + +## Troubleshooting + +### Requests time out on slow APIs + +If you see `ECONNABORTED` or `ETIMEDOUT` errors, your API is responding slower than the default 10-second timeout. Set `APIDRIFT_TIMEOUT_MS` to a larger value: + +```bash +APIDRIFT_TIMEOUT_MS=60000 apidrift snapshot --tag v1.0 --env staging +``` + +### `Authorization` header shows `Bearer ` (empty token) + +This means your `${STAGING_TOKEN}` or `${PROD_TOKEN}` env var is not set. Verify: + +```bash +# Bash / macOS / Linux +echo $STAGING_TOKEN + +# PowerShell (Windows) +echo $env:STAGING_TOKEN +``` + +If empty, create a `.env` file in your project directory: + +```bash +STAGING_TOKEN=your_actual_token_here +PROD_TOKEN=your_actual_token_here +``` + +Make sure `.env` is listed in your `.gitignore` so tokens are never committed. + --- ## License diff --git a/src/commands/init.js b/src/commands/init.js index c6d5cb9..32645a0 100644 --- a/src/commands/init.js +++ b/src/commands/init.js @@ -28,6 +28,21 @@ export async function runInit() { initial: false, }); if (!overwrite) { + console.log(chalk.yellow("ℹ Keeping existing apidrift.config.json")); + console.log(""); + console.log(" Your current config was not modified. Next steps:"); + console.log( + ` 1. Make sure you have a ${chalk.cyan(".env")} file or environment variables set for your tokens:` + ); + console.log(` ${chalk.gray("STAGING_TOKEN=your_token_here")}`); + console.log(` ${chalk.gray("PROD_TOKEN=your_token_here")}`); + console.log( + ` 2. Add ${chalk.cyan(".env")} to your ${chalk.cyan(".gitignore")} to keep tokens out of Git` + ); + console.log( + ` 3. Run: ${chalk.cyan("apidrift snapshot --tag v1.0 --env staging")}` + ); + console.log(""); return; } } diff --git a/src/core/fetcher.js b/src/core/fetcher.js index 4997523..9804e00 100644 --- a/src/core/fetcher.js +++ b/src/core/fetcher.js @@ -7,6 +7,19 @@ const RETRY_DEFAULTS = { maxDelay: 8000, }; +/** + * HTTP request timeout in milliseconds. + * Override via the APIDRIFT_TIMEOUT_MS environment variable. + * Defaults to 10000 (10 seconds) if unset or if the value is not a positive integer. + */ +const DEFAULT_TIMEOUT_MS = 10000; +const TIMEOUT_MS = (() => { + const raw = process.env.APIDRIFT_TIMEOUT_MS; + if (!raw) return DEFAULT_TIMEOUT_MS; + const parsed = Number(raw); + return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS; +})(); + let didWarnEmptyAuth = false; function isRetryable(err) { @@ -29,7 +42,7 @@ async function fetchWithRetry( config = RETRY_DEFAULTS, ) { try { - return await axios({ ...options, url, timeout: 10000 }); + return await axios({ ...options, url, timeout: TIMEOUT_MS }); } catch (err) { if (attempt < config.retries && isRetryable(err)) { const delay = Math.min( diff --git a/src/storage/snapshotStore.js b/src/storage/snapshotStore.js index 0af020d..a34a7e4 100644 --- a/src/storage/snapshotStore.js +++ b/src/storage/snapshotStore.js @@ -7,14 +7,69 @@ const SNAP_DIR = path.join(os.homedir(), ".apidrift", "snapshots"); if (!fs.existsSync(SNAP_DIR)) fs.mkdirSync(SNAP_DIR, { recursive: true }); +/** + * Sanitize a snapshot tag so it is safe to use as a filename on all platforms. + * + * Rules: + * - Only A-Z, a-z, 0-9, `.`, `_`, and `-` are allowed. + * - Every other character (including `:`, `/`, `\`, and space) is replaced with `_`. + * - Leading/trailing dots and underscores are stripped to prevent hidden-file + * names and confusing output. + * - The empty string is rejected. + * + * Tags that are already safe (e.g. `v1.0.13`, `prod-users`) pass through unchanged. + * + * @param {string} tag - Raw tag supplied by the user. + * @returns {string} Sanitized tag string. + * @throws {Error} If the resulting sanitized name is empty. + */ +export function sanitizeTag(tag) { + // Replace every character that is NOT in the safe set with '_' + let sanitized = String(tag) + .replace(/[^A-Za-z0-9._-]/g, "_") + // Collapse consecutive underscores for readability (optional, keeps names clean) + .replace(/_+/g, "_") + // Remove leading/trailing underscores and dots + .replace(/^[._]+|[._]+$/g, ""); + + if (!sanitized) { + throw new Error( + `Invalid snapshot tag "${tag}": tag must contain at least one alphanumeric character.` + ); + } + + // Windows reserved device names check: CON, PRN, AUX, NUL, COM1-9, LPT1-9 + if (/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(sanitized)) { + sanitized = `_${sanitized}`; + } + + return sanitized; +} + export function saveSnapshot(tag, data) { - const file = path.join(SNAP_DIR, `${tag}.json`); + const safe = sanitizeTag(tag); + const file = path.join(SNAP_DIR, `${safe}.json`); + if (fs.existsSync(file)) { + try { + const existing = JSON.parse(fs.readFileSync(file, "utf-8")); + if (existing && existing.tag !== tag) { + throw new Error( + `Snapshot file collision: The file for tag "${tag}" already exists and contains a different tag "${existing.tag}".` + ); + } + } catch (err) { + if (err.message.includes("Snapshot file collision")) { + throw err; + } + } + } fs.writeFileSync(file, JSON.stringify(data, null, 2)); return file; } export function loadSnapshot(tag) { - const file = path.join(SNAP_DIR, `${tag}.json`); + const safe = sanitizeTag(tag); + const file = path.join(SNAP_DIR, `${safe}.json`); if (!fs.existsSync(file)) { console.error(`Snapshot "${tag}" not found. Run: apidrift list`); process.exit(1); @@ -26,5 +81,6 @@ export function listSnapshots() { return fs .readdirSync(SNAP_DIR) .filter((f) => f.endsWith(".json")) - .map((f) => f.replace(".json", "")); + .map((f) => f.replace(/\.json$/, "")) + .sort(); } diff --git a/tests/storage/snapshotStore.test.js b/tests/storage/snapshotStore.test.js new file mode 100644 index 0000000..7aeac82 --- /dev/null +++ b/tests/storage/snapshotStore.test.js @@ -0,0 +1,161 @@ +import fs from "fs"; +import { jest } from "@jest/globals"; +import { + sanitizeTag, + saveSnapshot, + loadSnapshot, + listSnapshots, +} from "../../src/storage/snapshotStore.js"; + +describe("snapshotStore", () => { + let existsSpy; + let writeSpy; + let readSpy; + let readdirSpy; + + beforeEach(() => { + jest.clearAllMocks(); + existsSpy = jest.spyOn(fs, "existsSync").mockImplementation(() => false); + writeSpy = jest.spyOn(fs, "writeFileSync").mockImplementation(() => {}); + readSpy = jest.spyOn(fs, "readFileSync").mockImplementation(() => ""); + readdirSpy = jest.spyOn(fs, "readdirSync").mockImplementation(() => []); + }); + + afterEach(() => { + existsSpy.mockRestore(); + writeSpy.mockRestore(); + readSpy.mockRestore(); + readdirSpy.mockRestore(); + }); + + describe("sanitizeTag", () => { + test("passes safe tags through unchanged", () => { + expect(sanitizeTag("v1.0.13")).toBe("v1.0.13"); + expect(sanitizeTag("prod-users")).toBe("prod-users"); + expect(sanitizeTag("my_tag")).toBe("my_tag"); + }); + + test("replaces invalid characters with underscores", () => { + expect(sanitizeTag("v1.0:beta")).toBe("v1.0_beta"); + expect(sanitizeTag("my tag")).toBe("my_tag"); + expect(sanitizeTag("tag/sub")).toBe("tag_sub"); + expect(sanitizeTag("tag\\sub")).toBe("tag_sub"); + }); + + test("collapses consecutive underscores", () => { + expect(sanitizeTag("my__tag")).toBe("my_tag"); + expect(sanitizeTag("my:::tag")).toBe("my_tag"); + }); + + test("removes leading/trailing underscores and dots", () => { + expect(sanitizeTag("_my_tag_")).toBe("my_tag"); + expect(sanitizeTag(".my_tag.")).toBe("my_tag"); + expect(sanitizeTag("_.my_tag._")).toBe("my_tag"); + }); + + test("throws error for empty or invalid tags", () => { + expect(() => sanitizeTag("")).toThrow(); + expect(() => sanitizeTag("/")).toThrow(); + expect(() => sanitizeTag(":::")).toThrow(); + }); + + test("prefixes Windows reserved device names case-insensitively", () => { + expect(sanitizeTag("CON")).toBe("_CON"); + expect(sanitizeTag("con")).toBe("_con"); + expect(sanitizeTag("PRN")).toBe("_PRN"); + expect(sanitizeTag("AUX")).toBe("_AUX"); + expect(sanitizeTag("NUL")).toBe("_NUL"); + expect(sanitizeTag("COM1")).toBe("_COM1"); + expect(sanitizeTag("com9")).toBe("_com9"); + expect(sanitizeTag("LPT1")).toBe("_LPT1"); + expect(sanitizeTag("lpt9")).toBe("_lpt9"); + }); + + test("does not prefix Windows reserved device names if they are part of a larger name", () => { + expect(sanitizeTag("CONTENT")).toBe("CONTENT"); + expect(sanitizeTag("COM10")).toBe("COM10"); + expect(sanitizeTag("LPT")).toBe("LPT"); + }); + }); + + describe("saveSnapshot", () => { + test("writes successfully when file does not exist", () => { + existsSpy.mockReturnValue(false); + const data = { tag: "v1", env: "test" }; + + saveSnapshot("v1", data); + + expect(writeSpy).toHaveBeenCalledWith( + expect.any(String), + JSON.stringify(data, null, 2) + ); + }); + + test("writes successfully when file exists and tag is the same", () => { + existsSpy.mockReturnValue(true); + readSpy.mockReturnValue(JSON.stringify({ tag: "v1", env: "old" })); + const data = { tag: "v1", env: "test" }; + + saveSnapshot("v1", data); + + expect(writeSpy).toHaveBeenCalledWith( + expect.any(String), + JSON.stringify(data, null, 2) + ); + }); + + test("throws collision error when file exists and tag is different", () => { + existsSpy.mockReturnValue(true); + readSpy.mockReturnValue(JSON.stringify({ tag: "v1-old", env: "old" })); + const data = { tag: "v1-new", env: "test" }; + + expect(() => saveSnapshot("v1-new", data)).toThrow( + /Snapshot file collision/ + ); + expect(writeSpy).not.toHaveBeenCalled(); + }); + }); + + describe("loadSnapshot", () => { + test("loads successfully when file exists", () => { + existsSpy.mockReturnValue(true); + const mockData = { tag: "v1", data: "test" }; + readSpy.mockReturnValue(JSON.stringify(mockData)); + + const result = loadSnapshot("v1"); + + expect(result).toEqual(mockData); + }); + + test("logs error and exits process when file does not exist", () => { + existsSpy.mockReturnValue(false); + const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + const processExitSpy = jest.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); + + expect(() => loadSnapshot("v1")).toThrow("process.exit"); + + expect(consoleErrorSpy).toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(1); + + consoleErrorSpy.mockRestore(); + processExitSpy.mockRestore(); + }); + }); + + describe("listSnapshots", () => { + test("returns alphabetically sorted list of snapshot tags with .json removed", () => { + readdirSpy.mockReturnValue([ + "c.json", + "a.json", + "b.json.json", + "README.md", // Should be ignored + ]); + + const result = listSnapshots(); + + expect(result).toEqual(["a", "b.json", "c"]); + }); + }); +});