|
1 | 1 | import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; |
2 | | -import { resolveAuth } from "./flags.js"; |
| 2 | +import { resolveAuth, detectApiKeyHeaders } from "./flags.js"; |
3 | 3 | import { saveProfile, loadAuthStore, removeProfile, maskToken } from "./config.js"; |
| 4 | +import { parseHeaderFlag } from "./headers.js"; |
4 | 5 | import { mkdtemp, rm } from "node:fs/promises"; |
5 | 6 | import { tmpdir } from "node:os"; |
6 | 7 | import { join } from "node:path"; |
@@ -158,6 +159,158 @@ describe("auth config (profile persistence)", () => { |
158 | 159 | }); |
159 | 160 | }); |
160 | 161 |
|
| 162 | +const specDualHeader: OpenAPISpec = { |
| 163 | + ...minimalSpec, |
| 164 | + components: { |
| 165 | + securitySchemes: { |
| 166 | + appKey: { type: "apiKey", in: "header", name: "X-VTEX-API-AppKey" }, |
| 167 | + appToken: { type: "apiKey", in: "header", name: "X-VTEX-API-AppToken" }, |
| 168 | + }, |
| 169 | + }, |
| 170 | +}; |
| 171 | + |
| 172 | +describe("parseHeaderFlag", () => { |
| 173 | + it("parses Name: Value", () => { |
| 174 | + expect(parseHeaderFlag("X-Api-Key: abc123")).toEqual({ name: "X-Api-Key", value: "abc123" }); |
| 175 | + }); |
| 176 | + |
| 177 | + it("trims whitespace", () => { |
| 178 | + expect(parseHeaderFlag(" X-Key : val ")).toEqual({ name: "X-Key", value: "val" }); |
| 179 | + }); |
| 180 | + |
| 181 | + it("preserves colons inside value (e.g. URLs)", () => { |
| 182 | + expect(parseHeaderFlag("Referer: https://example.com:8080/x")).toEqual({ |
| 183 | + name: "Referer", |
| 184 | + value: "https://example.com:8080/x", |
| 185 | + }); |
| 186 | + }); |
| 187 | + |
| 188 | + it("returns null for missing colon", () => { |
| 189 | + expect(parseHeaderFlag("NoColonHere")).toBeNull(); |
| 190 | + }); |
| 191 | + |
| 192 | + it("returns null for empty name", () => { |
| 193 | + expect(parseHeaderFlag(": value")).toBeNull(); |
| 194 | + }); |
| 195 | + |
| 196 | + it("rejects CR/LF in value (header injection)", () => { |
| 197 | + expect(parseHeaderFlag("X-Key: bad\r\nEvil: yes")).toBeNull(); |
| 198 | + expect(parseHeaderFlag("X-Key: bad\nEvil: yes")).toBeNull(); |
| 199 | + expect(parseHeaderFlag("X-Key: bad\rEvil: yes")).toBeNull(); |
| 200 | + }); |
| 201 | + |
| 202 | + it("rejects invalid characters in header name", () => { |
| 203 | + expect(parseHeaderFlag("X Key: v")).toBeNull(); |
| 204 | + expect(parseHeaderFlag("X\tKey: v")).toBeNull(); |
| 205 | + expect(parseHeaderFlag("X:Key: v")).toEqual({ name: "X", value: "Key: v" }); |
| 206 | + }); |
| 207 | +}); |
| 208 | + |
| 209 | +describe("detectApiKeyHeaders", () => { |
| 210 | + it("returns all apiKey header names", () => { |
| 211 | + const names = detectApiKeyHeaders(specDualHeader); |
| 212 | + expect(names).toEqual(["X-VTEX-API-AppKey", "X-VTEX-API-AppToken"]); |
| 213 | + }); |
| 214 | + |
| 215 | + it("returns empty array when no schemes", () => { |
| 216 | + expect(detectApiKeyHeaders(minimalSpec)).toEqual([]); |
| 217 | + }); |
| 218 | +}); |
| 219 | + |
| 220 | +describe("resolveAuth with multi-header", () => { |
| 221 | + let tmpDir: string; |
| 222 | + |
| 223 | + beforeEach(async () => { |
| 224 | + tmpDir = await mkdtemp(join(tmpdir(), "tocli-auth-multiheader-")); |
| 225 | + vi.stubEnv("XDG_CONFIG_HOME", tmpDir); |
| 226 | + }); |
| 227 | + |
| 228 | + afterEach(async () => { |
| 229 | + vi.unstubAllEnvs(); |
| 230 | + await rm(tmpDir, { recursive: true, force: true }); |
| 231 | + }); |
| 232 | + |
| 233 | + it("returns headers auth when --header flags are provided", async () => { |
| 234 | + const auth = await resolveAuth( |
| 235 | + { headers: { "X-VTEX-API-AppKey": "key1", "X-VTEX-API-AppToken": "tok1" } }, |
| 236 | + specDualHeader, |
| 237 | + {} |
| 238 | + ); |
| 239 | + expect(auth.type).toBe("headers"); |
| 240 | + expect(auth.headers).toEqual({ |
| 241 | + "X-VTEX-API-AppKey": "key1", |
| 242 | + "X-VTEX-API-AppToken": "tok1", |
| 243 | + }); |
| 244 | + }); |
| 245 | + |
| 246 | + it("resolves env vars inside header values", async () => { |
| 247 | + const auth = await resolveAuth( |
| 248 | + { headers: { "X-VTEX-API-AppKey": "$VTEX_KEY", "X-VTEX-API-AppToken": "${VTEX_TOKEN}" } }, |
| 249 | + specDualHeader, |
| 250 | + { VTEX_KEY: "resolved-key", VTEX_TOKEN: "resolved-tok" } |
| 251 | + ); |
| 252 | + expect(auth.headers).toEqual({ |
| 253 | + "X-VTEX-API-AppKey": "resolved-key", |
| 254 | + "X-VTEX-API-AppToken": "resolved-tok", |
| 255 | + }); |
| 256 | + }); |
| 257 | + |
| 258 | + it("--header takes priority over --token", async () => { |
| 259 | + const auth = await resolveAuth( |
| 260 | + { headers: { "X-Custom": "v" }, token: "sk-1" }, |
| 261 | + minimalSpec, |
| 262 | + {} |
| 263 | + ); |
| 264 | + expect(auth.type).toBe("headers"); |
| 265 | + }); |
| 266 | + |
| 267 | + it("loads headers profile from disk", async () => { |
| 268 | + await saveProfile("vtex", { |
| 269 | + type: "headers", |
| 270 | + value: "", |
| 271 | + headers: { "X-VTEX-API-AppKey": "stored-key", "X-VTEX-API-AppToken": "stored-tok" }, |
| 272 | + }); |
| 273 | + const auth = await resolveAuth({ profile: "vtex" }, specDualHeader, {}); |
| 274 | + expect(auth.type).toBe("headers"); |
| 275 | + expect(auth.headers).toEqual({ |
| 276 | + "X-VTEX-API-AppKey": "stored-key", |
| 277 | + "X-VTEX-API-AppToken": "stored-tok", |
| 278 | + }); |
| 279 | + }); |
| 280 | + |
| 281 | + it("empty headers object falls through to other resolution", async () => { |
| 282 | + const auth = await resolveAuth({ headers: {}, token: "sk-1" }, minimalSpec, {}); |
| 283 | + expect(auth.type).toBe("bearer"); |
| 284 | + expect(auth.value).toBe("sk-1"); |
| 285 | + }); |
| 286 | + |
| 287 | + it("warns to stderr when $VAR in header resolves to empty", async () => { |
| 288 | + const stderr = vi.spyOn(console, "error").mockImplementation(() => {}); |
| 289 | + const auth = await resolveAuth( |
| 290 | + { headers: { "X-VTEX-API-AppKey": "$MISSING_VAR" } }, |
| 291 | + specDualHeader, |
| 292 | + {} |
| 293 | + ); |
| 294 | + expect(auth.headers?.["X-VTEX-API-AppKey"]).toBe(""); |
| 295 | + const logged = stderr.mock.calls.map((c) => String(c[0])).join("\n"); |
| 296 | + expect(logged).toMatch(/Warning.*MISSING_VAR.*unset/); |
| 297 | + expect(logged).toContain("X-VTEX-API-AppKey"); |
| 298 | + stderr.mockRestore(); |
| 299 | + }); |
| 300 | + |
| 301 | + it("warns when $VAR resolves to empty string", async () => { |
| 302 | + const stderr = vi.spyOn(console, "error").mockImplementation(() => {}); |
| 303 | + await resolveAuth( |
| 304 | + { headers: { "X-Key": "$EMPTY_VAR" } }, |
| 305 | + specDualHeader, |
| 306 | + { EMPTY_VAR: "" } |
| 307 | + ); |
| 308 | + const logged = stderr.mock.calls.map((c) => String(c[0])).join("\n"); |
| 309 | + expect(logged).toMatch(/Warning.*EMPTY_VAR.*empty/); |
| 310 | + stderr.mockRestore(); |
| 311 | + }); |
| 312 | +}); |
| 313 | + |
161 | 314 | describe("maskToken", () => { |
162 | 315 | it("masks long tokens", () => { |
163 | 316 | expect(maskToken("sk-1234567890abcdef")).toBe("sk-1...cdef"); |
|
0 commit comments