diff --git a/apps/api/src/services/query-service.test.ts b/apps/api/src/services/query-service.test.ts index 201456e..20acda2 100644 --- a/apps/api/src/services/query-service.test.ts +++ b/apps/api/src/services/query-service.test.ts @@ -1,10 +1,33 @@ -import { describe, expect, it } from "vitest"; -import { UnsafeScrapeUrlError } from "../lib/scrape-url-safety.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const registryExecuteMock = vi.fn(); +const loggerErrorMock = vi.fn(); + +vi.mock("../providers/index.js", () => ({ + registry: { + execute: (...args: unknown[]) => registryExecuteMock(...args) + } +})); + +vi.mock("../lib/logger.js", () => ({ + logger: { + error: (...args: unknown[]) => loggerErrorMock(...args) + } +})); describe("executeQuery", () => { + beforeEach(() => { + registryExecuteMock.mockReset(); + loggerErrorMock.mockReset(); + vi.resetModules(); + }); + it("rejects unsafe scrape URLs at the service boundary", async () => { process.env.X402_PAY_TO_ADDRESS = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; - const { executeQuery } = await import("./query-service.js"); + const [{ executeQuery }, { UnsafeScrapeUrlError }] = await Promise.all([ + import("./query-service.js"), + import("../lib/scrape-url-safety.js") + ]); await expect( executeQuery({ @@ -14,4 +37,40 @@ describe("executeQuery", () => { }) ).rejects.toBeInstanceOf(UnsafeScrapeUrlError); }); + + it("logs provider failures with safe metadata only", async () => { + process.env.X402_PAY_TO_ADDRESS = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + const providerError = new Error( + 'upstream failed query="super secret question" url=https://secret.example.test/search payment-response=proof_123 Authorization:Bearer token_abc privateKey=wallet_secret' + ); + registryExecuteMock.mockRejectedValueOnce(providerError); + + const { executeQuery } = await import("./query-service.js"); + + await expect( + executeQuery({ + mode: "search", + provider: "search.live", + q: "super secret question" + }) + ).rejects.toThrow(providerError.message); + + expect(loggerErrorMock).toHaveBeenCalledTimes(1); + + const [payload, message] = loggerErrorMock.mock.calls[0]; + const serializedPayload = JSON.stringify(payload); + + expect(message).toBe("provider execution failed"); + expect(payload).toMatchObject({ + providerId: "search.live", + mode: "search", + errorClass: "Error" + }); + expect(payload.errorMessage).toContain("[redacted-url]"); + expect(serializedPayload).not.toContain("super secret question"); + expect(serializedPayload).not.toContain("https://secret.example.test/search"); + expect(serializedPayload).not.toContain("proof_123"); + expect(serializedPayload).not.toContain("token_abc"); + expect(serializedPayload).not.toContain("wallet_secret"); + }); }); diff --git a/apps/api/src/services/query-service.ts b/apps/api/src/services/query-service.ts index 43264e1..df24cfc 100644 --- a/apps/api/src/services/query-service.ts +++ b/apps/api/src/services/query-service.ts @@ -3,6 +3,37 @@ import { registry } from "../providers/index.js"; import { nanoid } from "nanoid"; import { QueryResult } from "@query402/shared"; import { validateScrapeUrl } from "../lib/scrape-url-safety.js"; +import { logger } from "../lib/logger.js"; + +function getErrorClass(error: unknown): string { + if (error instanceof Error && error.name) { + return error.name; + } + + return typeof error; +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + return String(error); +} + +function sanitizeErrorMessage(message: string): string { + return message + .replace( + /\b(url|targetUrl)\b\s*[:=]\s*("[^"]*"|'[^']*'|[^,;]+)/gi, + "$1=[redacted-url]" + ) + .replace(/\b(payment-response|x-payment-response|authorization)\b\s*[:=]\s*([^\s,;]+)/gi, "$1=[redacted]") + .replace( + /\b(query|queryOrUrl|q|secret|api[_ -]?key|token|private[_ -]?key|privateKey|seed)\b\s*[:=]\s*("[^"]*"|'[^']*'|[^,;]+)/gi, + "$1=[redacted]" + ) + .replace(/https?:\/\/\S+/gi, "[redacted-url]"); +} export async function executeQuery(params: { mode: "search" | "news" | "scrape"; @@ -24,7 +55,23 @@ export async function executeQuery(params: { // Registry handles provider matching, circuit breaking, timeouts, and fallbacks const start = Date.now(); - const execution = await registry.execute(params.mode, params.provider, safeInput); + + let execution; + try { + execution = await registry.execute(params.mode, params.provider, safeInput); + } catch (error) { + logger.error( + { + providerId: params.provider, + mode: params.mode, + errorClass: getErrorClass(error), + errorMessage: sanitizeErrorMessage(getErrorMessage(error)) + }, + "provider execution failed" + ); + throw error; + } + const latencyMs = Date.now() - start; return {