mirror of
https://github.com/MichMich/MagicMirror.git
synced 2026-04-25 23:32:10 +00:00
### What's the problem? The `selfSignedCert` option passes an undici `Agent` as `dispatcher` to `fetch()`. But Node's built-in `fetch()` and undici@8's `Agent` use different internal handler APIs - passing them together throws: ``` invalid onRequestStart method ``` ### What's the fix? When `selfSignedCert` is enabled (i.e. a `dispatcher` is set), use undici's own `fetch()` instead of the global one. For all other requests, keep using `globalThis.fetch`. ```js const fetchFn = requestOptions.dispatcher ? undiciFetch : globalThis.fetch; ``` ### Why not just always use undici's fetch? That would fix the crash - but it would break some tests. MSW (Mock Service Worker), which is used in our test suite to intercept HTTP requests, only hooks into `globalThis.fetch`. Undici's fetch bypasses those interceptors entirely, so tests would start making real network requests instead of getting the mocked responses. We could rewrite all tests to use undici-compatible mocking instead - but that would be a massive change for no real benefit. ---- Fixes #4093
472 lines
12 KiB
JavaScript
472 lines
12 KiB
JavaScript
const { http, HttpResponse } = require("msw");
|
|
const { setupServer } = require("msw/node");
|
|
const HTTPFetcher = require("#http_fetcher");
|
|
|
|
const TEST_URL = "http://test.example.com/data";
|
|
let server;
|
|
let fetcher;
|
|
|
|
beforeAll(() => {
|
|
server = setupServer();
|
|
server.listen({ onUnhandledRequest: "error" });
|
|
});
|
|
|
|
afterAll(() => {
|
|
server.close();
|
|
});
|
|
|
|
afterEach(() => {
|
|
server.resetHandlers();
|
|
if (fetcher) {
|
|
fetcher.clearTimer();
|
|
fetcher = null;
|
|
}
|
|
});
|
|
|
|
describe("HTTPFetcher", () => {
|
|
|
|
describe("Basic fetch operations", () => {
|
|
it("should emit response event on successful fetch", async () => {
|
|
const responseData = "test data";
|
|
server.use(
|
|
http.get(TEST_URL, () => {
|
|
return HttpResponse.text(responseData);
|
|
})
|
|
);
|
|
|
|
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
|
|
|
|
const responsePromise = new Promise((resolve) => {
|
|
fetcher.on("response", (response) => {
|
|
resolve(response);
|
|
});
|
|
});
|
|
|
|
fetcher.startPeriodicFetch();
|
|
const response = await responsePromise;
|
|
|
|
expect(response.ok).toBe(true);
|
|
expect(response.status).toBe(200);
|
|
const text = await response.text();
|
|
expect(text).toBe(responseData);
|
|
});
|
|
|
|
it("should emit error event on network failure", async () => {
|
|
server.use(
|
|
http.get(TEST_URL, () => {
|
|
return HttpResponse.error();
|
|
})
|
|
);
|
|
|
|
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
|
|
|
|
const errorPromise = new Promise((resolve) => {
|
|
fetcher.on("error", (errorInfo) => {
|
|
resolve(errorInfo);
|
|
});
|
|
});
|
|
|
|
fetcher.startPeriodicFetch();
|
|
const errorInfo = await errorPromise;
|
|
|
|
expect(errorInfo).toHaveProperty("errorType", "NETWORK_ERROR");
|
|
expect(errorInfo).toHaveProperty("translationKey", "MODULE_ERROR_NO_CONNECTION");
|
|
expect(errorInfo).toHaveProperty("url", TEST_URL);
|
|
});
|
|
|
|
it("should emit error event on timeout", async () => {
|
|
server.use(
|
|
http.get(TEST_URL, async () => {
|
|
// Simulate a slow server that never responds
|
|
await new Promise((resolve) => setTimeout(resolve, 60000));
|
|
return HttpResponse.text("too late");
|
|
})
|
|
);
|
|
|
|
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000, timeout: 100 });
|
|
|
|
const errorPromise = new Promise((resolve) => {
|
|
fetcher.on("error", (errorInfo) => {
|
|
resolve(errorInfo);
|
|
});
|
|
});
|
|
|
|
fetcher.startPeriodicFetch();
|
|
const errorInfo = await errorPromise;
|
|
|
|
expect(errorInfo.errorType).toBe("NETWORK_ERROR");
|
|
expect(errorInfo.message).toContain("timeout");
|
|
expect(errorInfo.message).toContain("100ms");
|
|
});
|
|
});
|
|
|
|
describe("HTTPFetcher - HTTP status code handling", () => {
|
|
describe("401/403 errors (Auth failures)", () => {
|
|
it("should emit error with AUTH_FAILURE for 401", async () => {
|
|
server.use(
|
|
http.get(TEST_URL, () => {
|
|
return new HttpResponse(null, { status: 401 });
|
|
})
|
|
);
|
|
|
|
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
|
|
|
|
const errorPromise = new Promise((resolve) => {
|
|
fetcher.on("error", (errorInfo) => {
|
|
resolve(errorInfo);
|
|
});
|
|
});
|
|
|
|
fetcher.startPeriodicFetch();
|
|
const errorInfo = await errorPromise;
|
|
|
|
expect(errorInfo.status).toBe(401);
|
|
expect(errorInfo.errorType).toBe("AUTH_FAILURE");
|
|
expect(errorInfo.translationKey).toBe("MODULE_ERROR_UNAUTHORIZED");
|
|
});
|
|
|
|
it("should emit error with AUTH_FAILURE for 403", async () => {
|
|
server.use(
|
|
http.get(TEST_URL, () => {
|
|
return new HttpResponse(null, { status: 403 });
|
|
})
|
|
);
|
|
|
|
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
|
|
|
|
const errorPromise = new Promise((resolve) => {
|
|
fetcher.on("error", (errorInfo) => {
|
|
resolve(errorInfo);
|
|
});
|
|
});
|
|
|
|
fetcher.startPeriodicFetch();
|
|
const errorInfo = await errorPromise;
|
|
|
|
expect(errorInfo.status).toBe(403);
|
|
expect(errorInfo.errorType).toBe("AUTH_FAILURE");
|
|
});
|
|
});
|
|
|
|
describe("429 errors (Rate limiting)", () => {
|
|
it("should emit error with RATE_LIMITED for 429", async () => {
|
|
server.use(
|
|
http.get(TEST_URL, () => {
|
|
return new HttpResponse(null, {
|
|
status: 429,
|
|
headers: { "Retry-After": "120" }
|
|
});
|
|
})
|
|
);
|
|
|
|
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
|
|
|
|
const errorPromise = new Promise((resolve) => {
|
|
fetcher.on("error", (errorInfo) => {
|
|
resolve(errorInfo);
|
|
});
|
|
});
|
|
|
|
fetcher.startPeriodicFetch();
|
|
const errorInfo = await errorPromise;
|
|
|
|
expect(errorInfo.status).toBe(429);
|
|
expect(errorInfo.errorType).toBe("RATE_LIMITED");
|
|
expect(errorInfo.retryAfter).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("should parse Retry-After header in seconds", async () => {
|
|
server.use(
|
|
http.get(TEST_URL, () => {
|
|
return new HttpResponse(null, {
|
|
status: 429,
|
|
headers: { "Retry-After": "300" }
|
|
});
|
|
})
|
|
);
|
|
|
|
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
|
|
|
|
const errorPromise = new Promise((resolve) => {
|
|
fetcher.on("error", (errorInfo) => {
|
|
resolve(errorInfo);
|
|
});
|
|
});
|
|
|
|
fetcher.startPeriodicFetch();
|
|
const errorInfo = await errorPromise;
|
|
|
|
// 300 seconds = 300000 ms
|
|
expect(errorInfo.retryAfter).toBe(300000);
|
|
});
|
|
});
|
|
|
|
describe("5xx errors (Server errors)", () => {
|
|
it("should emit error with SERVER_ERROR for 500", async () => {
|
|
server.use(
|
|
http.get(TEST_URL, () => {
|
|
return new HttpResponse(null, { status: 500 });
|
|
})
|
|
);
|
|
|
|
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
|
|
|
|
const errorPromise = new Promise((resolve) => {
|
|
fetcher.on("error", (errorInfo) => {
|
|
resolve(errorInfo);
|
|
});
|
|
});
|
|
|
|
fetcher.startPeriodicFetch();
|
|
const errorInfo = await errorPromise;
|
|
|
|
expect(errorInfo.status).toBe(500);
|
|
expect(errorInfo.errorType).toBe("SERVER_ERROR");
|
|
});
|
|
|
|
it("should emit error with SERVER_ERROR for 503", async () => {
|
|
server.use(
|
|
http.get(TEST_URL, () => {
|
|
return new HttpResponse(null, { status: 503 });
|
|
})
|
|
);
|
|
|
|
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
|
|
|
|
const errorPromise = new Promise((resolve) => {
|
|
fetcher.on("error", (errorInfo) => {
|
|
resolve(errorInfo);
|
|
});
|
|
});
|
|
|
|
fetcher.startPeriodicFetch();
|
|
const errorInfo = await errorPromise;
|
|
|
|
expect(errorInfo.status).toBe(503);
|
|
expect(errorInfo.errorType).toBe("SERVER_ERROR");
|
|
});
|
|
});
|
|
|
|
describe("4xx errors (Client errors)", () => {
|
|
it("should emit error with CLIENT_ERROR for 404", async () => {
|
|
server.use(
|
|
http.get(TEST_URL, () => {
|
|
return new HttpResponse(null, { status: 404 });
|
|
})
|
|
);
|
|
|
|
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
|
|
|
|
const errorPromise = new Promise((resolve) => {
|
|
fetcher.on("error", (errorInfo) => {
|
|
resolve(errorInfo);
|
|
});
|
|
});
|
|
|
|
fetcher.startPeriodicFetch();
|
|
const errorInfo = await errorPromise;
|
|
|
|
expect(errorInfo.status).toBe(404);
|
|
expect(errorInfo.errorType).toBe("CLIENT_ERROR");
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("HTTPFetcher - Authentication", () => {
|
|
it("should include Basic auth header when configured", async () => {
|
|
let receivedHeaders = null;
|
|
|
|
server.use(
|
|
http.get(TEST_URL, ({ request }) => {
|
|
receivedHeaders = Object.fromEntries(request.headers);
|
|
return HttpResponse.text("ok");
|
|
})
|
|
);
|
|
|
|
fetcher = new HTTPFetcher(TEST_URL, {
|
|
reloadInterval: 60000,
|
|
auth: {
|
|
method: "basic",
|
|
user: "testuser",
|
|
pass: "testpass"
|
|
}
|
|
});
|
|
|
|
const responsePromise = new Promise((resolve) => {
|
|
fetcher.on("response", resolve);
|
|
});
|
|
|
|
fetcher.startPeriodicFetch();
|
|
await responsePromise;
|
|
|
|
const expectedAuth = `Basic ${Buffer.from("testuser:testpass").toString("base64")}`;
|
|
expect(receivedHeaders.authorization).toBe(expectedAuth);
|
|
});
|
|
|
|
it("should include Bearer auth header when configured", async () => {
|
|
let receivedHeaders = null;
|
|
|
|
server.use(
|
|
http.get(TEST_URL, ({ request }) => {
|
|
receivedHeaders = Object.fromEntries(request.headers);
|
|
return HttpResponse.text("ok");
|
|
})
|
|
);
|
|
|
|
fetcher = new HTTPFetcher(TEST_URL, {
|
|
reloadInterval: 60000,
|
|
auth: {
|
|
method: "bearer",
|
|
pass: "my-token-123"
|
|
}
|
|
});
|
|
|
|
const responsePromise = new Promise((resolve) => {
|
|
fetcher.on("response", resolve);
|
|
});
|
|
|
|
fetcher.startPeriodicFetch();
|
|
await responsePromise;
|
|
|
|
expect(receivedHeaders.authorization).toBe("Bearer my-token-123");
|
|
});
|
|
});
|
|
|
|
describe("Custom headers", () => {
|
|
it("should include custom headers in request", async () => {
|
|
let receivedHeaders = null;
|
|
|
|
server.use(
|
|
http.get(TEST_URL, ({ request }) => {
|
|
receivedHeaders = Object.fromEntries(request.headers);
|
|
return HttpResponse.text("ok");
|
|
})
|
|
);
|
|
|
|
fetcher = new HTTPFetcher(TEST_URL, {
|
|
reloadInterval: 60000,
|
|
headers: {
|
|
"X-Custom-Header": "custom-value",
|
|
Accept: "application/json"
|
|
}
|
|
});
|
|
|
|
const responsePromise = new Promise((resolve) => {
|
|
fetcher.on("response", resolve);
|
|
});
|
|
|
|
fetcher.startPeriodicFetch();
|
|
await responsePromise;
|
|
|
|
expect(receivedHeaders["x-custom-header"]).toBe("custom-value");
|
|
expect(receivedHeaders.accept).toBe("application/json");
|
|
});
|
|
});
|
|
|
|
describe("Timer management", () => {
|
|
it("should not set timer in test mode", async () => {
|
|
server.use(
|
|
http.get(TEST_URL, () => {
|
|
return HttpResponse.text("ok");
|
|
})
|
|
);
|
|
|
|
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 100 });
|
|
|
|
const responsePromise = new Promise((resolve) => {
|
|
fetcher.on("response", resolve);
|
|
});
|
|
|
|
fetcher.startPeriodicFetch();
|
|
await responsePromise;
|
|
|
|
// Timer should NOT be set in test mode (mmTestMode=true)
|
|
expect(fetcher.reloadTimer).toBeNull();
|
|
});
|
|
|
|
it("should clear timer when clearTimer is called", () => {
|
|
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 100 });
|
|
|
|
// Manually set a timer to test clearing
|
|
fetcher.reloadTimer = setTimeout(() => {}, 10000);
|
|
expect(fetcher.reloadTimer).not.toBeNull();
|
|
|
|
fetcher.clearTimer();
|
|
|
|
expect(fetcher.reloadTimer).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("fetch() method", () => {
|
|
it("should emit response event when called", async () => {
|
|
const responseData = "direct fetch data";
|
|
server.use(
|
|
http.get(TEST_URL, () => {
|
|
return HttpResponse.text(responseData);
|
|
})
|
|
);
|
|
|
|
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
|
|
|
|
const responsePromise = new Promise((resolve) => {
|
|
fetcher.on("response", resolve);
|
|
});
|
|
|
|
await fetcher.fetch();
|
|
const response = await responsePromise;
|
|
|
|
expect(response.ok).toBe(true);
|
|
const text = await response.text();
|
|
expect(text).toBe(responseData);
|
|
});
|
|
|
|
it("should emit error event on network error", async () => {
|
|
server.use(
|
|
http.get(TEST_URL, () => {
|
|
return HttpResponse.error();
|
|
})
|
|
);
|
|
|
|
fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 });
|
|
|
|
const errorPromise = new Promise((resolve) => {
|
|
fetcher.on("error", resolve);
|
|
});
|
|
|
|
await fetcher.fetch();
|
|
const errorInfo = await errorPromise;
|
|
|
|
expect(errorInfo.errorType).toBe("NETWORK_ERROR");
|
|
});
|
|
});
|
|
|
|
describe("selfSignedCert dispatcher", () => {
|
|
const { Agent } = require("undici");
|
|
|
|
it("should set rejectUnauthorized=false when selfSignedCert is true", () => {
|
|
fetcher = new HTTPFetcher(TEST_URL, {
|
|
reloadInterval: 60000,
|
|
selfSignedCert: true
|
|
});
|
|
|
|
const options = fetcher.getRequestOptions();
|
|
|
|
expect(options.dispatcher).toBeInstanceOf(Agent);
|
|
const agentOptionsSymbol = Object.getOwnPropertySymbols(options.dispatcher).find((s) => s.description === "options");
|
|
const dispatcherOptions = options.dispatcher[agentOptionsSymbol];
|
|
expect(dispatcherOptions.connect.rejectUnauthorized).toBe(false);
|
|
});
|
|
|
|
it("should not set a dispatcher when selfSignedCert is false", () => {
|
|
fetcher = new HTTPFetcher(TEST_URL, {
|
|
reloadInterval: 60000,
|
|
selfSignedCert: false
|
|
});
|
|
|
|
const options = fetcher.getRequestOptions();
|
|
|
|
expect(options.dispatcher).toBeUndefined();
|
|
});
|
|
});
|