142 lines
5.0 KiB
JavaScript
142 lines
5.0 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import { rebuildApiClient, runWithTokenRefreshRetry } from "../auth/apiClient.js";
|
|
import { mcpSessionCredentials, sessionApiClients, sessionCredentials } from "../auth/credentials.js";
|
|
import { refreshSessionCredentials } from "../auth/sessionRefresh.js";
|
|
|
|
const jwt = (expiresIn = 3600) => {
|
|
const payload = Buffer.from(JSON.stringify({
|
|
exp: Math.floor(Date.now() / 1000) + expiresIn,
|
|
})).toString("base64url");
|
|
return `header.${payload}.signature`;
|
|
};
|
|
|
|
const credentials = (token) => ({
|
|
token,
|
|
tokenHash: "hash",
|
|
website: "demo.example",
|
|
web_url: "https://demo.forge.example",
|
|
api_web_url: "http://web:80",
|
|
forge_host: "demo.forge.example",
|
|
mode: "local",
|
|
project_dir: "/opt/acai/webs/owner/demo",
|
|
acai_user: "owner",
|
|
mcp_secret: "acai_secret",
|
|
role: "developer",
|
|
});
|
|
|
|
test("deduplicates concurrent forced refreshes and updates session credentials", async () => {
|
|
const sessionId = "refresh-session";
|
|
sessionCredentials.set(sessionId, credentials(jwt()));
|
|
let calls = 0;
|
|
const freshToken = jwt(7200);
|
|
const fetcher = async () => {
|
|
calls += 1;
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
return {
|
|
success: true,
|
|
token: freshToken,
|
|
tokenHash: "fresh-hash",
|
|
domain: "demo.example",
|
|
web_url: "https://demo.forge.example",
|
|
api_web_url: "http://web:80",
|
|
project_dir: "/opt/acai/webs/owner/demo",
|
|
};
|
|
};
|
|
|
|
const [first, second] = await Promise.all([
|
|
refreshSessionCredentials(sessionId, { force: true, fetcher }),
|
|
refreshSessionCredentials(sessionId, { force: true, fetcher }),
|
|
]);
|
|
assert.equal(calls, 1);
|
|
assert.equal(first.token, freshToken);
|
|
assert.equal(second.token, freshToken);
|
|
assert.equal(sessionCredentials.get(sessionId).tokenHash, "fresh-hash");
|
|
assert.equal(mcpSessionCredentials.get(sessionId).credentials.mcp_secret, undefined);
|
|
mcpSessionCredentials.delete(sessionId);
|
|
sessionCredentials.delete(sessionId);
|
|
});
|
|
|
|
test("cached Axios client reads the latest token for every request", async () => {
|
|
const sessionId = "axios-session";
|
|
sessionCredentials.set(sessionId, credentials("old-token"));
|
|
const client = await rebuildApiClient(sessionId);
|
|
const observed = [];
|
|
client.defaults.adapter = async (config) => {
|
|
observed.push(config.headers["X-Acai-Token"]);
|
|
return { data: {}, status: 200, statusText: "OK", headers: {}, config };
|
|
};
|
|
|
|
await client.get("/first");
|
|
sessionCredentials.set(sessionId, credentials("new-token"));
|
|
await client.get("/second");
|
|
|
|
assert.deepEqual(observed, ["old-token", "new-token"]);
|
|
sessionApiClients.delete(sessionId);
|
|
sessionCredentials.delete(sessionId);
|
|
});
|
|
|
|
test("403 token failure forces one refresh and retries the original request once", async () => {
|
|
const sessionId = "retry-session";
|
|
sessionCredentials.set(sessionId, credentials("expired-token"));
|
|
let refreshes = 0;
|
|
let attempts = 0;
|
|
const observed = [];
|
|
const fresh = credentials("fresh-token");
|
|
const client = await rebuildApiClient(sessionId, {
|
|
refreshCredentials: async (_sessionId, options) => {
|
|
refreshes += 1;
|
|
assert.equal(options.force, true);
|
|
sessionCredentials.set(sessionId, fresh);
|
|
return fresh;
|
|
},
|
|
});
|
|
client.defaults.adapter = async (config) => {
|
|
attempts += 1;
|
|
observed.push(config.headers["X-Acai-Token"]);
|
|
if (attempts === 1) {
|
|
const error = new Error("Request failed with status code 403");
|
|
error.config = config;
|
|
error.response = { status: 403, data: { error: "Token no valido" } };
|
|
throw error;
|
|
}
|
|
return { data: { success: true }, status: 200, statusText: "OK", headers: {}, config };
|
|
};
|
|
|
|
const response = await client.get("/retry-once");
|
|
|
|
assert.equal(response.data.success, true);
|
|
assert.equal(refreshes, 1);
|
|
assert.equal(attempts, 2);
|
|
assert.deepEqual(observed, ["expired-token", "fresh-token"]);
|
|
sessionApiClients.delete(sessionId);
|
|
sessionCredentials.delete(sessionId);
|
|
});
|
|
|
|
|
|
test("tool-level retry covers Axios clients outside the cached API client", async () => {
|
|
let attempts = 0;
|
|
let refreshes = 0;
|
|
const operation = async () => {
|
|
attempts += 1;
|
|
if (attempts === 1) {
|
|
const error = new Error("forbidden");
|
|
error.response = { status: 403, data: { error: "JWT expired" } };
|
|
throw error;
|
|
}
|
|
return "ok";
|
|
};
|
|
|
|
const result = await runWithTokenRefreshRetry("tool-session", operation, {
|
|
refreshCredentials: async (_sessionId, options) => {
|
|
refreshes += 1;
|
|
assert.equal(options.force, true);
|
|
},
|
|
});
|
|
|
|
assert.equal(result, "ok");
|
|
assert.equal(attempts, 2);
|
|
assert.equal(refreshes, 1);
|
|
});
|