Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 62 additions & 3 deletions apps/api/src/services/query-service.test.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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");
});
});
49 changes: 48 additions & 1 deletion apps/api/src/services/query-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand Down