263 lines
7.8 KiB
JavaScript
263 lines
7.8 KiB
JavaScript
import axios from "axios";
|
|
import { beforeEach, afterEach, describe, expect, it } from "vitest";
|
|
import { API_URL } from "@/config.js";
|
|
import {
|
|
__configureRequestQueueForTests,
|
|
__resetRequestQueueForTests,
|
|
requestQueueState,
|
|
} from "@/services/requestQueue.js";
|
|
import {
|
|
__resetAxiosRequestQueueInstallerForTests,
|
|
installAxiosRequestQueue,
|
|
} from "@/services/installAxiosRequestQueue.js";
|
|
import {
|
|
__resetReleaseTimelineForTests,
|
|
__setReleaseTimelineTransportForTests,
|
|
configureReleaseRuntime,
|
|
flushReleaseTimelineEvents,
|
|
} from "@/services/releaseTimeline.js";
|
|
|
|
const flushMicrotasks = async () => {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
};
|
|
|
|
const flushManyMicrotasks = async (rounds = 10) => {
|
|
for (let index = 0; index < rounds; index += 1) {
|
|
await Promise.resolve();
|
|
}
|
|
};
|
|
|
|
const createDeferred = () => {
|
|
let resolve;
|
|
let reject;
|
|
const promise = new Promise((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
|
|
return { promise, resolve, reject };
|
|
};
|
|
|
|
const createResponse = (data) => ({
|
|
status: 200,
|
|
statusText: "OK",
|
|
headers: {},
|
|
config: {},
|
|
data,
|
|
});
|
|
|
|
describe("axios request queue interceptor", () => {
|
|
beforeEach(() => {
|
|
__resetAxiosRequestQueueInstallerForTests();
|
|
__resetRequestQueueForTests();
|
|
__resetReleaseTimelineForTests();
|
|
__configureRequestQueueForTests({ maxConcurrentGet: 1, maxConcurrentOther: 1, spacingMs: 0 });
|
|
installAxiosRequestQueue();
|
|
});
|
|
|
|
afterEach(() => {
|
|
__resetAxiosRequestQueueInstallerForTests();
|
|
__resetRequestQueueForTests();
|
|
__resetReleaseTimelineForTests();
|
|
});
|
|
|
|
it("queues plain axios requests system-wide", async () => {
|
|
const first = createDeferred();
|
|
const second = createDeferred();
|
|
const adapters = [first, second];
|
|
|
|
const requestOne = axios({
|
|
url: "/queue-test-1",
|
|
method: "GET",
|
|
adapter: () => adapters.shift()?.promise,
|
|
});
|
|
|
|
const requestTwo = axios({
|
|
url: "/queue-test-2",
|
|
method: "GET",
|
|
adapter: () => adapters.shift()?.promise,
|
|
});
|
|
|
|
await flushMicrotasks();
|
|
|
|
expect(requestQueueState.active).toBe(1);
|
|
expect(requestQueueState.pending).toBe(1);
|
|
expect(requestQueueState.batchTotal).toBe(2);
|
|
|
|
first.resolve(createResponse({ id: 1 }));
|
|
await flushManyMicrotasks();
|
|
|
|
expect(requestQueueState.active).toBe(1);
|
|
expect(requestQueueState.pending).toBe(0);
|
|
|
|
second.resolve(createResponse({ id: 2 }));
|
|
const [responseOne, responseTwo] = await Promise.all([requestOne, requestTwo]);
|
|
|
|
expect(responseOne.data).toEqual({ id: 1 });
|
|
expect(responseTwo.data).toEqual({ id: 2 });
|
|
|
|
await flushMicrotasks();
|
|
|
|
expect(requestQueueState.active).toBe(0);
|
|
expect(requestQueueState.pending).toBe(0);
|
|
expect(requestQueueState.batchCompleted).toBe(2);
|
|
expect(requestQueueState.batchFailed).toBe(0);
|
|
});
|
|
|
|
it("omits failed axios request bodies from timeline events when capture is disabled", async () => {
|
|
const sentBodies = [];
|
|
__setReleaseTimelineTransportForTests(async (body) => {
|
|
sentBodies.push(body);
|
|
return { accepted: body.events.length };
|
|
});
|
|
|
|
await expect(
|
|
axios({
|
|
url: `${API_URL}/auth/login`,
|
|
method: "POST",
|
|
data: {
|
|
customer_number: "cust-123",
|
|
password: "PlaintextP@ssw0rd!",
|
|
g_recaptcha_response: "recaptcha-response-secret",
|
|
},
|
|
adapter: async (config) => {
|
|
expect(config.data).toContain('"password":"PlaintextP@ssw0rd!"');
|
|
throw {
|
|
response: {
|
|
status: 401,
|
|
statusText: "Unauthorized",
|
|
data: { token: "response-token" },
|
|
headers: {},
|
|
},
|
|
message: "Request failed",
|
|
};
|
|
},
|
|
})
|
|
).rejects.toBeTruthy();
|
|
|
|
await flushReleaseTimelineEvents();
|
|
|
|
expect(sentBodies).toHaveLength(1);
|
|
expect(sentBodies[0].events[0].type).toBe("request_failed");
|
|
expect(sentBodies[0].events[0].payload.request.data).toBe("[capture-disabled]");
|
|
expect(sentBodies[0].events[0].payload.response.data).toBe("[capture-disabled]");
|
|
expect(JSON.stringify(sentBodies[0])).not.toContain("PlaintextP@ssw0rd!");
|
|
expect(JSON.stringify(sentBodies[0])).not.toContain("recaptcha-response-secret");
|
|
});
|
|
|
|
it("rewrites default API requests to the active release API before dispatch", async () => {
|
|
configureReleaseRuntime({
|
|
channel: { slug: "canary" },
|
|
versions: { frontend: { version_label: "f" }, api: { version_label: "a" }, bundle_id: 7 },
|
|
api_base_url: "https://api-v2.truckwash.io/canary/api",
|
|
});
|
|
|
|
let dispatchedUrl = "";
|
|
let dispatchedHeaders = {};
|
|
const response = await axios({
|
|
url: `${API_URL}/orders`,
|
|
method: "GET",
|
|
adapter: async (config) => {
|
|
dispatchedUrl = config.url;
|
|
dispatchedHeaders = config.headers || {};
|
|
return createResponse({ ok: true });
|
|
},
|
|
});
|
|
|
|
expect(response.data).toEqual({ ok: true });
|
|
expect(dispatchedUrl).toBe("https://api-v2.truckwash.io/canary/api/orders");
|
|
expect(dispatchedHeaders["X-Release-Trace"]).toBe("test-trace");
|
|
expect(dispatchedHeaders["X-Release-Channel"]).toBe("canary");
|
|
expect(dispatchedHeaders["X-Frontend-Version"]).toEqual(expect.any(String));
|
|
});
|
|
|
|
it("does not rewrite requests that opt out for release manager control calls", async () => {
|
|
configureReleaseRuntime({
|
|
channel: { slug: "canary" },
|
|
versions: { frontend: { version_label: "f" }, api: { version_label: "a" }, bundle_id: 7 },
|
|
api_base_url: "https://api-v2.truckwash.io/canary/api",
|
|
});
|
|
|
|
let dispatchedUrl = "";
|
|
await axios({
|
|
url: `${API_URL}/superuser/releases`,
|
|
method: "GET",
|
|
__skipReleaseApiRewrite: true,
|
|
adapter: async (config) => {
|
|
dispatchedUrl = config.url;
|
|
return createResponse({ ok: true });
|
|
},
|
|
});
|
|
|
|
expect(dispatchedUrl).toBe(`${API_URL}/superuser/releases`);
|
|
});
|
|
|
|
it("allows 5 concurrent GET requests", async () => {
|
|
__configureRequestQueueForTests({ maxConcurrentGet: 5, maxConcurrentOther: 1, spacingMs: 0 });
|
|
|
|
const allDeferred = Array.from({ length: 6 }, () => createDeferred());
|
|
const pendingAdapters = [...allDeferred];
|
|
const adapter = () => pendingAdapters.shift()?.promise;
|
|
|
|
const requests = Array.from({ length: 6 }, (_, index) =>
|
|
axios({
|
|
url: `/queue-get-${index + 1}`,
|
|
method: "GET",
|
|
adapter,
|
|
})
|
|
);
|
|
|
|
await flushMicrotasks();
|
|
|
|
expect(requestQueueState.active).toBe(5);
|
|
expect(requestQueueState.pending).toBe(1);
|
|
|
|
for (let index = 0; index < 5; index += 1) {
|
|
allDeferred[index].resolve(createResponse({ id: index + 1 }));
|
|
}
|
|
|
|
await flushManyMicrotasks();
|
|
|
|
expect(requestQueueState.active).toBe(1);
|
|
expect(requestQueueState.pending).toBe(0);
|
|
|
|
allDeferred[5].resolve(createResponse({ id: 6 }));
|
|
const responses = await Promise.all(requests);
|
|
|
|
expect(responses.map((item) => item.data.id)).toEqual([1, 2, 3, 4, 5, 6]);
|
|
});
|
|
|
|
it("retries configured response status codes", async () => {
|
|
__configureRequestQueueForTests({
|
|
maxConcurrentGet: 1,
|
|
maxConcurrentOther: 1,
|
|
spacingMs: 0,
|
|
retryByStatusCode: { 429: 2 },
|
|
retryDelayBaseMs: 0,
|
|
retryDelayMaxMs: 0,
|
|
retryDelayJitterMs: 0,
|
|
});
|
|
|
|
let attempts = 0;
|
|
const request = axios({
|
|
url: "/queue-retry-429",
|
|
method: "GET",
|
|
adapter: async () => {
|
|
attempts += 1;
|
|
if (attempts < 3) {
|
|
throw { response: { status: 429 } };
|
|
}
|
|
return createResponse({ ok: true, attempts });
|
|
},
|
|
});
|
|
|
|
const response = await request;
|
|
|
|
expect(response.data).toEqual({ ok: true, attempts: 3 });
|
|
expect(attempts).toBe(3);
|
|
expect(requestQueueState.batchCompleted).toBe(1);
|
|
expect(requestQueueState.batchFailed).toBe(0);
|
|
});
|
|
});
|