180 lines
5.5 KiB
TypeScript
180 lines
5.5 KiB
TypeScript
import { describe, it, expect, mock, afterEach } from "bun:test";
|
|
import { executeGraphQL, X3RequestError } from "../src/graphql/client.js";
|
|
import type { X3Config } from "../src/config.js";
|
|
|
|
function makeConfig(overrides: Partial<X3Config> = {}): X3Config {
|
|
return {
|
|
url: "https://example.com/api",
|
|
endpoint: "SEED",
|
|
clientId: "test-client-id",
|
|
secret: "test-secret-key-for-hs256",
|
|
user: "TEST_USER",
|
|
tokenLifetime: 600,
|
|
mode: "authenticated",
|
|
tlsRejectUnauthorized: true,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("executeGraphQL — mutation guard", () => {
|
|
it("rejects queries starting with 'mutation'", () => {
|
|
expect(
|
|
executeGraphQL(makeConfig(), 'mutation { createItem(input: {}) { id } }'),
|
|
).rejects.toThrow("read-only");
|
|
});
|
|
|
|
it("rejects case-insensitive 'MUTATION'", () => {
|
|
expect(
|
|
executeGraphQL(makeConfig(), "MUTATION { deleteItem(id: 1) { ok } }"),
|
|
).rejects.toThrow("read-only");
|
|
});
|
|
|
|
it("rejects mutation with leading whitespace", () => {
|
|
expect(
|
|
executeGraphQL(makeConfig(), " mutation { updateItem { id } }"),
|
|
).rejects.toThrow("read-only");
|
|
});
|
|
|
|
it("rejects mixed-case 'Mutation'", () => {
|
|
expect(
|
|
executeGraphQL(makeConfig(), "Mutation { foo { bar } }"),
|
|
).rejects.toThrow("read-only");
|
|
});
|
|
|
|
it("throws an X3RequestError with statusCode 0", async () => {
|
|
try {
|
|
await executeGraphQL(makeConfig(), "mutation { foo }");
|
|
expect.unreachable("should have thrown");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(X3RequestError);
|
|
expect((err as X3RequestError).statusCode).toBe(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("executeGraphQL — valid queries", () => {
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
it("does not reject a standard query", async () => {
|
|
globalThis.fetch = mock(() =>
|
|
Promise.resolve(
|
|
new Response(JSON.stringify({ data: { test: true } }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
) as typeof fetch;
|
|
|
|
const result = await executeGraphQL(makeConfig(), "{ xtremX3MasterData { company { edges { node { id } } } } }");
|
|
expect(result.data).toEqual({ test: true });
|
|
});
|
|
|
|
it("does not reject a query with 'query' keyword", async () => {
|
|
globalThis.fetch = mock(() =>
|
|
Promise.resolve(
|
|
new Response(JSON.stringify({ data: { items: [] } }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
) as typeof fetch;
|
|
|
|
const result = await executeGraphQL(
|
|
makeConfig(),
|
|
"query { xtremX3Stock { stockDetail { edges { node { id } } } } }",
|
|
);
|
|
expect(result.data).toEqual({ items: [] });
|
|
});
|
|
});
|
|
|
|
describe("executeGraphQL — HTTP error handling", () => {
|
|
const originalFetch = globalThis.fetch;
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
it("throws X3RequestError on 401", async () => {
|
|
globalThis.fetch = mock(() =>
|
|
Promise.resolve(new Response("Unauthorized", { status: 401 })),
|
|
) as typeof fetch;
|
|
|
|
try {
|
|
await executeGraphQL(makeConfig(), "{ test }");
|
|
expect.unreachable("should have thrown");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(X3RequestError);
|
|
expect((err as X3RequestError).statusCode).toBe(401);
|
|
expect((err as X3RequestError).message).toContain("Authentication failed");
|
|
}
|
|
});
|
|
|
|
it("throws X3RequestError on 500 with structured body", async () => {
|
|
const errorBody = {
|
|
$message: "Division by zero",
|
|
$source: "X3RUNTIME",
|
|
};
|
|
globalThis.fetch = mock(() =>
|
|
Promise.resolve(
|
|
new Response(JSON.stringify(errorBody), {
|
|
status: 500,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
) as typeof fetch;
|
|
|
|
try {
|
|
await executeGraphQL(makeConfig(), "{ test }");
|
|
expect.unreachable("should have thrown");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(X3RequestError);
|
|
expect((err as X3RequestError).statusCode).toBe(500);
|
|
expect((err as X3RequestError).message).toContain("Division by zero");
|
|
expect((err as X3RequestError).message).toContain("X3RUNTIME");
|
|
}
|
|
});
|
|
|
|
it("throws X3RequestError on 400 with GraphQL errors", async () => {
|
|
const errorBody = {
|
|
errors: [{ message: "Field 'foo' not found" }],
|
|
};
|
|
globalThis.fetch = mock(() =>
|
|
Promise.resolve(
|
|
new Response(JSON.stringify(errorBody), {
|
|
status: 400,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
) as typeof fetch;
|
|
|
|
try {
|
|
await executeGraphQL(makeConfig(), "{ foo }");
|
|
expect.unreachable("should have thrown");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(X3RequestError);
|
|
expect((err as X3RequestError).statusCode).toBe(400);
|
|
expect((err as X3RequestError).message).toContain("Field 'foo' not found");
|
|
}
|
|
});
|
|
|
|
it("throws X3RequestError on network failure", async () => {
|
|
globalThis.fetch = mock(() =>
|
|
Promise.reject(new Error("ECONNREFUSED")),
|
|
) as typeof fetch;
|
|
|
|
try {
|
|
await executeGraphQL(makeConfig(), "{ test }");
|
|
expect.unreachable("should have thrown");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(X3RequestError);
|
|
expect((err as X3RequestError).statusCode).toBe(0);
|
|
expect((err as X3RequestError).message).toContain("Network error");
|
|
expect((err as X3RequestError).message).toContain("ECONNREFUSED");
|
|
}
|
|
});
|
|
});
|