mirror of
https://github.com/gethomepage/homepage.git
synced 2026-04-11 04:31:20 -07:00
Fix: prevent omada race conditions with auth and cookie caching (#6549)
Some checks are pending
Docker CI / Docker Build & Push (push) Waiting to run
Lint / Linting Checks (push) Waiting to run
Release Drafter / Update Release Draft (push) Waiting to run
Release Drafter / Auto Label PR (push) Waiting to run
Tests / vitest (1) (push) Waiting to run
Tests / vitest (2) (push) Waiting to run
Tests / vitest (3) (push) Waiting to run
Tests / vitest (4) (push) Waiting to run
Some checks are pending
Docker CI / Docker Build & Push (push) Waiting to run
Lint / Linting Checks (push) Waiting to run
Release Drafter / Update Release Draft (push) Waiting to run
Release Drafter / Auto Label PR (push) Waiting to run
Tests / vitest (1) (push) Waiting to run
Tests / vitest (2) (push) Waiting to run
Tests / vitest (3) (push) Waiting to run
Tests / vitest (4) (push) Waiting to run
This commit is contained in:
@@ -7,7 +7,10 @@ export function setCookieHeader(url, params) {
|
||||
const existingCookie = cookieJar.getCookieStringSync(url.toString());
|
||||
if (existingCookie) {
|
||||
params.headers = params.headers ?? {};
|
||||
params.headers[params.cookieHeader ?? "Cookie"] = existingCookie;
|
||||
const cookieHeader = params.cookieHeader ?? "Cookie";
|
||||
if (!params.headers[cookieHeader]) {
|
||||
params.headers[cookieHeader] = existingCookie;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,4 +42,16 @@ describe("utils/proxy/cookie-jar", () => {
|
||||
|
||||
expect(params.headers.Cookie).toContain("c=d");
|
||||
});
|
||||
|
||||
it("does not overwrite an explicit cookie header", async () => {
|
||||
const { addCookieToJar, setCookieHeader } = await import("./cookie-jar");
|
||||
|
||||
const url = new URL("http://example4.test/path");
|
||||
addCookieToJar(url, { "set-cookie": ["sid=1; Path=/"] });
|
||||
|
||||
const params = { headers: { Cookie: "manual=1" } };
|
||||
setCookieHeader(url, params);
|
||||
|
||||
expect(params.headers.Cookie).toBe("manual=1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,40 @@
|
||||
import cache from "memory-cache";
|
||||
|
||||
import getServiceWidget from "utils/config/service-helpers";
|
||||
import createLogger from "utils/logger";
|
||||
import { httpProxy } from "utils/proxy/http";
|
||||
|
||||
const proxyName = "omadaProxyHandler";
|
||||
const sessionCacheKey = `${proxyName}__session`;
|
||||
|
||||
const logger = createLogger(proxyName);
|
||||
|
||||
async function login(loginUrl, username, password, controllerVersionMajor) {
|
||||
function getSessionCacheId(group, service, index) {
|
||||
return [sessionCacheKey, group, service, index ?? "0"].join(".");
|
||||
}
|
||||
|
||||
function shouldRetryWithFreshSession(status, responseData, attempt, usedCachedSession) {
|
||||
return attempt === 0 && usedCachedSession && (status === 401 || status === 403 || responseData?.errorCode > 0);
|
||||
}
|
||||
|
||||
function getCookieHeader(responseHeaders) {
|
||||
const setCookie = responseHeaders?.["set-cookie"];
|
||||
if (!setCookie) return null;
|
||||
|
||||
const cookies = new Map();
|
||||
(Array.isArray(setCookie) ? setCookie : [setCookie]).forEach((cookie) => {
|
||||
const cookiePair = cookie.split(";")[0];
|
||||
if (!cookiePair) return;
|
||||
|
||||
const separatorIndex = cookiePair.indexOf("=");
|
||||
const cookieName = separatorIndex === -1 ? cookiePair : cookiePair.slice(0, separatorIndex);
|
||||
cookies.set(cookieName, cookiePair);
|
||||
});
|
||||
|
||||
return cookies.size > 0 ? Array.from(cookies.values()).join("; ") : null;
|
||||
}
|
||||
|
||||
async function login(loginUrl, username, password, controllerVersionMajor, sessionCacheId) {
|
||||
const params = {
|
||||
username,
|
||||
password,
|
||||
@@ -20,7 +48,7 @@ async function login(loginUrl, username, password, controllerVersionMajor) {
|
||||
};
|
||||
}
|
||||
|
||||
const [status, contentType, data] = await httpProxy(loginUrl, {
|
||||
const [status, contentType, data, responseHeaders] = await httpProxy(loginUrl, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(params),
|
||||
headers: {
|
||||
@@ -28,7 +56,20 @@ async function login(loginUrl, username, password, controllerVersionMajor) {
|
||||
},
|
||||
});
|
||||
|
||||
return [status, JSON.parse(data.toString())];
|
||||
const loginResponseData = JSON.parse(data.toString());
|
||||
|
||||
if (status === 200 && loginResponseData.errorCode === 0) {
|
||||
cache.put(
|
||||
sessionCacheId,
|
||||
{
|
||||
token: loginResponseData.result.token,
|
||||
cookieHeader: getCookieHeader(responseHeaders),
|
||||
},
|
||||
55 * 60 * 1000, // Cache session for 55 minutes
|
||||
);
|
||||
}
|
||||
|
||||
return [status, loginResponseData];
|
||||
}
|
||||
|
||||
export default async function omadaProxyHandler(req, res) {
|
||||
@@ -86,182 +127,247 @@ export default async function omadaProxyHandler(req, res) {
|
||||
break;
|
||||
}
|
||||
|
||||
const [loginStatus, loginResponseData] = await login(
|
||||
loginUrl,
|
||||
widget.username,
|
||||
widget.password,
|
||||
controllerVersionMajor,
|
||||
);
|
||||
const sessionCacheId = getSessionCacheId(group, service, index);
|
||||
let session = cache.get(sessionCacheId);
|
||||
|
||||
if (loginStatus !== 200 || loginResponseData.errorCode > 0) {
|
||||
return res
|
||||
.status(loginStatus)
|
||||
.json({ error: { message: "Error logging in to Omada controller", url: loginUrl, data: loginResponseData } });
|
||||
}
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
const usedCachedSession = Boolean(session);
|
||||
|
||||
const { token } = loginResponseData.result;
|
||||
if (!session) {
|
||||
const [loginStatus, loginResponseData] = await login(
|
||||
loginUrl,
|
||||
widget.username,
|
||||
widget.password,
|
||||
controllerVersionMajor,
|
||||
sessionCacheId,
|
||||
);
|
||||
|
||||
let sitesUrl;
|
||||
let body = {};
|
||||
let params = { token };
|
||||
let headers = { "Csrf-Token": token };
|
||||
let method = "GET";
|
||||
if (loginStatus !== 200 || loginResponseData.errorCode > 0) {
|
||||
return res.status(loginStatus).json({
|
||||
error: { message: "Error logging in to Omada controller", url: loginUrl, data: loginResponseData },
|
||||
});
|
||||
}
|
||||
|
||||
switch (controllerVersionMajor) {
|
||||
case 3:
|
||||
sitesUrl = `${url}/web/v1/controller?ajax=&token=${token}`;
|
||||
body = {
|
||||
method: "getUserSites",
|
||||
params: {
|
||||
userName: widget.username,
|
||||
},
|
||||
};
|
||||
method = "POST";
|
||||
break;
|
||||
case 4:
|
||||
sitesUrl = `${url}/api/v2/sites?token=${token}¤tPage=1¤tPageSize=1000`;
|
||||
break;
|
||||
case 5:
|
||||
case 6:
|
||||
sitesUrl = `${url}/${cId}/api/v2/sites?token=${token}¤tPage=1¤tPageSize=1000`;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
session = cache.get(sessionCacheId);
|
||||
}
|
||||
|
||||
[status, contentType, data] = await httpProxy(sitesUrl, {
|
||||
method,
|
||||
params,
|
||||
body: JSON.stringify(body),
|
||||
headers,
|
||||
});
|
||||
const { token, cookieHeader } = session;
|
||||
|
||||
const sitesResponseData = JSON.parse(data);
|
||||
let sitesUrl;
|
||||
let body = {};
|
||||
let params = { token };
|
||||
let headers = { "Csrf-Token": token };
|
||||
if (cookieHeader) {
|
||||
headers.Cookie = cookieHeader;
|
||||
}
|
||||
let method = "GET";
|
||||
|
||||
if (status !== 200 || sitesResponseData.errorCode > 0) {
|
||||
logger.debug(`HTTP ${status} getting sites list: ${sitesResponseData.msg}`);
|
||||
return res
|
||||
.status(status)
|
||||
.json({ error: { message: "Error getting sites list", url, data: sitesResponseData } });
|
||||
}
|
||||
switch (controllerVersionMajor) {
|
||||
case 3:
|
||||
sitesUrl = `${url}/web/v1/controller?ajax=&token=${token}`;
|
||||
body = {
|
||||
method: "getUserSites",
|
||||
params: {
|
||||
userName: widget.username,
|
||||
},
|
||||
};
|
||||
headers = { "Content-Type": "application/json" };
|
||||
if (cookieHeader) {
|
||||
headers.Cookie = cookieHeader;
|
||||
}
|
||||
method = "POST";
|
||||
break;
|
||||
case 4:
|
||||
sitesUrl = `${url}/api/v2/sites?token=${token}¤tPage=1¤tPageSize=1000`;
|
||||
break;
|
||||
case 5:
|
||||
case 6:
|
||||
sitesUrl = `${url}/${cId}/api/v2/sites?token=${token}¤tPage=1¤tPageSize=1000`;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const site =
|
||||
controllerVersionMajor === 3
|
||||
? sitesResponseData.result.siteList.find((s) => s.name === widget.site)
|
||||
: sitesResponseData.result.data.find((s) => s.name === widget.site);
|
||||
|
||||
if (!site) {
|
||||
return res.status(status).json({ error: { message: `Site ${widget.site} is not found`, url: sitesUrl, data } });
|
||||
}
|
||||
|
||||
let siteResponseData;
|
||||
|
||||
let connectedAp;
|
||||
let activeUser;
|
||||
let connectedSwitches;
|
||||
let connectedGateways;
|
||||
let alerts;
|
||||
|
||||
if (controllerVersionMajor === 3) {
|
||||
// Omada v3 controller requires switching site
|
||||
const switchUrl = `${url}/web/v1/controller?ajax=&token=${token}`;
|
||||
method = "POST";
|
||||
body = {
|
||||
method: "switchSite",
|
||||
params: {
|
||||
siteName: site.siteName,
|
||||
userName: widget.username,
|
||||
},
|
||||
};
|
||||
headers = { "Content-Type": "application/json" };
|
||||
params = { token };
|
||||
|
||||
[status, contentType, data] = await httpProxy(switchUrl, {
|
||||
method,
|
||||
params,
|
||||
body: JSON.stringify(body),
|
||||
headers,
|
||||
});
|
||||
|
||||
const switchResponseData = JSON.parse(data);
|
||||
if (status !== 200 || switchResponseData.errorCode > 0) {
|
||||
logger.error(`HTTP ${status} getting sites list: ${data}`);
|
||||
return res.status(status).json({ error: { message: "Error switching site", url: switchUrl, data } });
|
||||
}
|
||||
|
||||
const statsUrl = `${url}/web/v1/controller?getGlobalStat=&token=${token}`;
|
||||
[status, contentType, data] = await httpProxy(statsUrl, {
|
||||
method,
|
||||
params,
|
||||
body: JSON.stringify({
|
||||
method: "getGlobalStat",
|
||||
}),
|
||||
headers,
|
||||
});
|
||||
|
||||
siteResponseData = JSON.parse(data);
|
||||
|
||||
if (status !== 200 || siteResponseData.errorCode > 0) {
|
||||
return res.status(status).json({ error: { message: "Error getting stats", url: statsUrl, data } });
|
||||
}
|
||||
|
||||
connectedAp = siteResponseData.result.connectedAp;
|
||||
activeUser = siteResponseData.result.activeUser;
|
||||
alerts = siteResponseData.result.alerts;
|
||||
} else if ([4, 5, 6].includes(controllerVersionMajor)) {
|
||||
const siteName = controllerVersionMajor > 4 ? site.id : site.key;
|
||||
const siteStatsUrl =
|
||||
controllerVersionMajor === 4
|
||||
? `${url}/api/v2/sites/${siteName}/dashboard/overviewDiagram?token=${token}¤tPage=1¤tPageSize=1000`
|
||||
: `${url}/${cId}/api/v2/sites/${siteName}/dashboard/overviewDiagram?token=${token}¤tPage=1¤tPageSize=1000`;
|
||||
|
||||
[status, contentType, data] = await httpProxy(siteStatsUrl, {
|
||||
headers: {
|
||||
"Csrf-Token": token,
|
||||
},
|
||||
});
|
||||
|
||||
siteResponseData = JSON.parse(data);
|
||||
|
||||
if (status !== 200 || siteResponseData.errorCode > 0) {
|
||||
logger.debug(`HTTP ${status} getting stats for site ${widget.site} with message ${siteResponseData.msg}`);
|
||||
return res.status(status === 200 ? 500 : status).json({
|
||||
error: {
|
||||
message: "Error getting stats",
|
||||
url: siteStatsUrl,
|
||||
data: siteResponseData,
|
||||
},
|
||||
[status, contentType, data] = await httpProxy(sitesUrl, {
|
||||
method,
|
||||
params,
|
||||
body: JSON.stringify(body),
|
||||
headers: { ...headers },
|
||||
});
|
||||
|
||||
const sitesResponseData = JSON.parse(data);
|
||||
|
||||
if (status !== 200 || sitesResponseData.errorCode > 0) {
|
||||
logger.debug(`HTTP ${status} getting sites list: ${sitesResponseData.msg}`);
|
||||
if (shouldRetryWithFreshSession(status, sitesResponseData, attempt, usedCachedSession)) {
|
||||
cache.del(sessionCacheId);
|
||||
session = null;
|
||||
continue;
|
||||
}
|
||||
return res
|
||||
.status(status)
|
||||
.json({ error: { message: "Error getting sites list", url, data: sitesResponseData } });
|
||||
}
|
||||
|
||||
const site =
|
||||
controllerVersionMajor === 3
|
||||
? sitesResponseData.result.siteList.find((s) => s.name === widget.site)
|
||||
: sitesResponseData.result.data.find((s) => s.name === widget.site);
|
||||
|
||||
if (!site) {
|
||||
return res
|
||||
.status(status)
|
||||
.json({ error: { message: `Site ${widget.site} is not found`, url: sitesUrl, data } });
|
||||
}
|
||||
|
||||
let siteResponseData;
|
||||
|
||||
let connectedAp;
|
||||
let activeUser;
|
||||
let connectedSwitches;
|
||||
let connectedGateways;
|
||||
let alerts;
|
||||
|
||||
if (controllerVersionMajor === 3) {
|
||||
// Omada v3 controller requires switching site
|
||||
const switchUrl = `${url}/web/v1/controller?ajax=&token=${token}`;
|
||||
method = "POST";
|
||||
body = {
|
||||
method: "switchSite",
|
||||
params: {
|
||||
siteName: site.siteName,
|
||||
userName: widget.username,
|
||||
},
|
||||
};
|
||||
headers = { "Content-Type": "application/json" };
|
||||
if (cookieHeader) {
|
||||
headers.Cookie = cookieHeader;
|
||||
}
|
||||
params = { token };
|
||||
|
||||
[status, contentType, data] = await httpProxy(switchUrl, {
|
||||
method,
|
||||
params,
|
||||
body: JSON.stringify(body),
|
||||
headers: { ...headers },
|
||||
});
|
||||
|
||||
const switchResponseData = JSON.parse(data);
|
||||
if (status !== 200 || switchResponseData.errorCode > 0) {
|
||||
logger.error(`HTTP ${status} getting sites list: ${data}`);
|
||||
if (shouldRetryWithFreshSession(status, switchResponseData, attempt, usedCachedSession)) {
|
||||
cache.del(sessionCacheId);
|
||||
session = null;
|
||||
continue;
|
||||
}
|
||||
return res.status(status).json({ error: { message: "Error switching site", url: switchUrl, data } });
|
||||
}
|
||||
|
||||
const statsUrl = `${url}/web/v1/controller?getGlobalStat=&token=${token}`;
|
||||
[status, contentType, data] = await httpProxy(statsUrl, {
|
||||
method,
|
||||
params,
|
||||
body: JSON.stringify({
|
||||
method: "getGlobalStat",
|
||||
}),
|
||||
headers: { ...headers },
|
||||
});
|
||||
|
||||
siteResponseData = JSON.parse(data);
|
||||
|
||||
if (status !== 200 || siteResponseData.errorCode > 0) {
|
||||
if (shouldRetryWithFreshSession(status, siteResponseData, attempt, usedCachedSession)) {
|
||||
cache.del(sessionCacheId);
|
||||
session = null;
|
||||
continue;
|
||||
}
|
||||
return res.status(status).json({ error: { message: "Error getting stats", url: statsUrl, data } });
|
||||
}
|
||||
|
||||
connectedAp = siteResponseData.result.connectedAp;
|
||||
activeUser = siteResponseData.result.activeUser;
|
||||
alerts = siteResponseData.result.alerts;
|
||||
} else if ([4, 5, 6].includes(controllerVersionMajor)) {
|
||||
const siteName = controllerVersionMajor > 4 ? site.id : site.key;
|
||||
const siteStatsUrl =
|
||||
controllerVersionMajor === 4
|
||||
? `${url}/api/v2/sites/${siteName}/dashboard/overviewDiagram?token=${token}¤tPage=1¤tPageSize=1000`
|
||||
: `${url}/${cId}/api/v2/sites/${siteName}/dashboard/overviewDiagram?token=${token}¤tPage=1¤tPageSize=1000`;
|
||||
|
||||
[status, contentType, data] = await httpProxy(siteStatsUrl, {
|
||||
headers: { ...headers },
|
||||
});
|
||||
|
||||
siteResponseData = JSON.parse(data);
|
||||
|
||||
if (status !== 200 || siteResponseData.errorCode > 0) {
|
||||
logger.debug(`HTTP ${status} getting stats for site ${widget.site} with message ${siteResponseData.msg}`);
|
||||
if (shouldRetryWithFreshSession(status, siteResponseData, attempt, usedCachedSession)) {
|
||||
cache.del(sessionCacheId);
|
||||
session = null;
|
||||
continue;
|
||||
}
|
||||
return res.status(status === 200 ? 500 : status).json({
|
||||
error: {
|
||||
message: "Error getting stats",
|
||||
url: siteStatsUrl,
|
||||
data: siteResponseData,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const alertUrl =
|
||||
controllerVersionMajor === 4
|
||||
? `${url}/api/v2/sites/${siteName}/alerts/num?token=${token}¤tPage=1¤tPageSize=1000`
|
||||
: `${url}/${cId}/api/v2/sites/${siteName}/alerts/num?token=${token}¤tPage=1¤tPageSize=1000`;
|
||||
|
||||
[status, contentType, data] = await httpProxy(alertUrl, {
|
||||
headers: { ...headers },
|
||||
});
|
||||
const alertResponseData = JSON.parse(data);
|
||||
|
||||
if (status !== 200 || alertResponseData.errorCode > 0) {
|
||||
if (shouldRetryWithFreshSession(status, alertResponseData, attempt, usedCachedSession)) {
|
||||
cache.del(sessionCacheId);
|
||||
session = null;
|
||||
continue;
|
||||
}
|
||||
return res.status(status === 200 ? 500 : status).json({
|
||||
error: {
|
||||
message: "Error getting alerts",
|
||||
url: alertUrl,
|
||||
data: alertResponseData,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
activeUser = siteResponseData.result.totalClientNum;
|
||||
connectedAp = siteResponseData.result.connectedApNum;
|
||||
connectedGateways = siteResponseData.result.connectedGatewayNum;
|
||||
connectedSwitches = siteResponseData.result.connectedSwitchNum;
|
||||
alerts = alertResponseData.result.alertNum;
|
||||
}
|
||||
|
||||
return res.send(
|
||||
JSON.stringify({
|
||||
connectedAp,
|
||||
activeUser,
|
||||
alerts,
|
||||
connectedGateways,
|
||||
connectedSwitches,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError && attempt === 0) {
|
||||
cache.del(sessionCacheId);
|
||||
session = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const alertUrl =
|
||||
controllerVersionMajor === 4
|
||||
? `${url}/api/v2/sites/${siteName}/alerts/num?token=${token}¤tPage=1¤tPageSize=1000`
|
||||
: `${url}/${cId}/api/v2/sites/${siteName}/alerts/num?token=${token}¤tPage=1¤tPageSize=1000`;
|
||||
|
||||
[status, contentType, data] = await httpProxy(alertUrl, {
|
||||
headers: {
|
||||
"Csrf-Token": token,
|
||||
},
|
||||
});
|
||||
const alertResponseData = JSON.parse(data);
|
||||
|
||||
activeUser = siteResponseData.result.totalClientNum;
|
||||
connectedAp = siteResponseData.result.connectedApNum;
|
||||
connectedGateways = siteResponseData.result.connectedGatewayNum;
|
||||
connectedSwitches = siteResponseData.result.connectedSwitchNum;
|
||||
alerts = alertResponseData.result.alertNum;
|
||||
}
|
||||
|
||||
return res.send(
|
||||
JSON.stringify({
|
||||
connectedAp,
|
||||
activeUser,
|
||||
alerts,
|
||||
connectedGateways,
|
||||
connectedSwitches,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,24 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import createMockRes from "test-utils/create-mock-res";
|
||||
|
||||
const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({
|
||||
httpProxy: vi.fn(),
|
||||
getServiceWidget: vi.fn(),
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
const { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {
|
||||
const store = new Map();
|
||||
|
||||
return {
|
||||
httpProxy: vi.fn(),
|
||||
getServiceWidget: vi.fn(),
|
||||
cache: {
|
||||
get: vi.fn((k) => store.get(k)),
|
||||
put: vi.fn((k, v) => store.set(k, v)),
|
||||
del: vi.fn((k) => store.delete(k)),
|
||||
_reset: () => store.clear(),
|
||||
},
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("utils/logger", () => ({
|
||||
default: () => logger,
|
||||
@@ -20,15 +30,19 @@ vi.mock("utils/config/service-helpers", () => ({
|
||||
vi.mock("utils/proxy/http", () => ({
|
||||
httpProxy,
|
||||
}));
|
||||
vi.mock("memory-cache", () => ({
|
||||
default: cache,
|
||||
...cache,
|
||||
}));
|
||||
|
||||
import omadaProxyHandler from "./proxy";
|
||||
|
||||
describe("widgets/omada/proxy", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Clear one-off implementations between tests (some branches return early).
|
||||
httpProxy.mockReset();
|
||||
getServiceWidget.mockReset();
|
||||
cache._reset();
|
||||
});
|
||||
|
||||
it("fetches controller info, logs in, selects site, and returns overview stats (v4)", async () => {
|
||||
@@ -51,6 +65,7 @@ describe("widgets/omada/proxy", () => {
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })),
|
||||
{ "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] },
|
||||
])
|
||||
// sites list
|
||||
.mockResolvedValueOnce([
|
||||
@@ -91,6 +106,12 @@ describe("widgets/omada/proxy", () => {
|
||||
connectedSwitches: 3,
|
||||
}),
|
||||
);
|
||||
expect(httpProxy.mock.calls[2][1]).toMatchObject({
|
||||
headers: {
|
||||
"Csrf-Token": "t",
|
||||
Cookie: "TPOMADA_SESSIONID=sid",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an error when controller info cannot be retrieved", async () => {
|
||||
@@ -169,6 +190,7 @@ describe("widgets/omada/proxy", () => {
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })),
|
||||
{ "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] },
|
||||
])
|
||||
.mockResolvedValueOnce([200, "application/json", JSON.stringify({ errorCode: 2, msg: "bad" })]);
|
||||
|
||||
@@ -195,6 +217,7 @@ describe("widgets/omada/proxy", () => {
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })),
|
||||
{ "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
@@ -222,6 +245,7 @@ describe("widgets/omada/proxy", () => {
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })),
|
||||
{ "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] },
|
||||
])
|
||||
// getUserSites
|
||||
.mockResolvedValueOnce([
|
||||
@@ -271,6 +295,7 @@ describe("widgets/omada/proxy", () => {
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })),
|
||||
{ "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
@@ -301,6 +326,7 @@ describe("widgets/omada/proxy", () => {
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })),
|
||||
{ "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
@@ -324,4 +350,414 @@ describe("widgets/omada/proxy", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses a cached Omada session across polls", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
url: "http://omada",
|
||||
username: "u",
|
||||
password: "p",
|
||||
site: "Default",
|
||||
});
|
||||
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
JSON.stringify({ result: { omadacId: "cid", controllerVer: "4.5.6" } }),
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })),
|
||||
{ "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
JSON.stringify({ errorCode: 0, result: { data: [{ name: "Default", key: "sitekey" }] } }),
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
JSON.stringify({
|
||||
errorCode: 0,
|
||||
result: {
|
||||
totalClientNum: 10,
|
||||
connectedApNum: 2,
|
||||
connectedGatewayNum: 1,
|
||||
connectedSwitchNum: 3,
|
||||
},
|
||||
}),
|
||||
])
|
||||
.mockResolvedValueOnce([200, "application/json", JSON.stringify({ errorCode: 0, result: { alertNum: 4 } })])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
JSON.stringify({ result: { omadacId: "cid", controllerVer: "4.5.6" } }),
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
JSON.stringify({ errorCode: 0, result: { data: [{ name: "Default", key: "sitekey" }] } }),
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
JSON.stringify({
|
||||
errorCode: 0,
|
||||
result: {
|
||||
totalClientNum: 10,
|
||||
connectedApNum: 2,
|
||||
connectedGatewayNum: 1,
|
||||
connectedSwitchNum: 3,
|
||||
},
|
||||
}),
|
||||
])
|
||||
.mockResolvedValueOnce([200, "application/json", JSON.stringify({ errorCode: 0, result: { alertNum: 4 } })]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", index: "0" } };
|
||||
|
||||
await omadaProxyHandler(req, createMockRes());
|
||||
await omadaProxyHandler(req, createMockRes());
|
||||
|
||||
const loginCalls = httpProxy.mock.calls.filter(([url]) => url.toString().includes("/api/v2/login"));
|
||||
expect(loginCalls).toHaveLength(1);
|
||||
expect(httpProxy.mock.calls[6][1].headers.Cookie).toBe("TPOMADA_SESSIONID=sid");
|
||||
});
|
||||
|
||||
it("does not reuse a cached session across different widget identities", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
url: "http://omada",
|
||||
username: "u",
|
||||
password: "p",
|
||||
site: "Default",
|
||||
});
|
||||
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
JSON.stringify({ result: { omadacId: "cid", controllerVer: "4.5.6" } }),
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t1" } })),
|
||||
{ "set-cookie": ["TPOMADA_SESSIONID=sid1; Path=/; HttpOnly"] },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
JSON.stringify({ errorCode: 0, result: { data: [{ name: "Default", key: "sitekey" }] } }),
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
JSON.stringify({
|
||||
errorCode: 0,
|
||||
result: {
|
||||
totalClientNum: 10,
|
||||
connectedApNum: 2,
|
||||
connectedGatewayNum: 1,
|
||||
connectedSwitchNum: 3,
|
||||
},
|
||||
}),
|
||||
])
|
||||
.mockResolvedValueOnce([200, "application/json", JSON.stringify({ errorCode: 0, result: { alertNum: 4 } })])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
JSON.stringify({ result: { omadacId: "cid", controllerVer: "4.5.6" } }),
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t2" } })),
|
||||
{ "set-cookie": ["TPOMADA_SESSIONID=sid2; Path=/; HttpOnly"] },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
JSON.stringify({ errorCode: 0, result: { data: [{ name: "Default", key: "sitekey" }] } }),
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
JSON.stringify({
|
||||
errorCode: 0,
|
||||
result: {
|
||||
totalClientNum: 10,
|
||||
connectedApNum: 2,
|
||||
connectedGatewayNum: 1,
|
||||
connectedSwitchNum: 3,
|
||||
},
|
||||
}),
|
||||
])
|
||||
.mockResolvedValueOnce([200, "application/json", JSON.stringify({ errorCode: 0, result: { alertNum: 4 } })]);
|
||||
|
||||
await omadaProxyHandler({ query: { group: "g1", service: "svc", index: "0" } }, createMockRes());
|
||||
await omadaProxyHandler({ query: { group: "g2", service: "svc", index: "0" } }, createMockRes());
|
||||
|
||||
const loginCalls = httpProxy.mock.calls.filter(([url]) => url.toString().includes("/api/v2/login"));
|
||||
expect(loginCalls).toHaveLength(2);
|
||||
expect(httpProxy.mock.calls[2][1].headers.Cookie).toBe("TPOMADA_SESSIONID=sid1");
|
||||
expect(httpProxy.mock.calls[7][1].headers.Cookie).toBe("TPOMADA_SESSIONID=sid2");
|
||||
});
|
||||
|
||||
it("keeps the latest value when Omada sets the same cookie more than once during login", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
url: "http://omada",
|
||||
username: "u",
|
||||
password: "p",
|
||||
site: "Default",
|
||||
});
|
||||
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
JSON.stringify({ result: { omadacId: "cid", controllerVer: "4.5.6" } }),
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })),
|
||||
{
|
||||
"set-cookie": [
|
||||
"TPOMADA_SESSIONID=deleteMe; Path=/; Max-Age=0",
|
||||
"TPOMADA_SESSIONID=sid; Path=/; HttpOnly",
|
||||
"rememberMe=deleteMe; Path=/; Max-Age=0",
|
||||
],
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
JSON.stringify({ errorCode: 0, result: { data: [{ name: "Default", key: "sitekey" }] } }),
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
JSON.stringify({
|
||||
errorCode: 0,
|
||||
result: {
|
||||
totalClientNum: 10,
|
||||
connectedApNum: 2,
|
||||
connectedGatewayNum: 1,
|
||||
connectedSwitchNum: 3,
|
||||
},
|
||||
}),
|
||||
])
|
||||
.mockResolvedValueOnce([200, "application/json", JSON.stringify({ errorCode: 0, result: { alertNum: 4 } })]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await omadaProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy.mock.calls[2][1].headers.Cookie).toBe("TPOMADA_SESSIONID=sid; rememberMe=deleteMe");
|
||||
expect(res.body).toBe(
|
||||
JSON.stringify({
|
||||
connectedAp: 2,
|
||||
activeUser: 10,
|
||||
alerts: 4,
|
||||
connectedGateways: 1,
|
||||
connectedSwitches: 3,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not reuse a mutated content-length header on later GET requests", async () => {
|
||||
getServiceWidget.mockResolvedValue({
|
||||
url: "http://omada",
|
||||
username: "u",
|
||||
password: "p",
|
||||
site: "Default",
|
||||
});
|
||||
|
||||
const responses = [
|
||||
[200, "application/json", JSON.stringify({ result: { omadacId: "cid", controllerVer: "4.5.6" } })],
|
||||
[
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "t" } })),
|
||||
{ "set-cookie": ["TPOMADA_SESSIONID=sid; Path=/; HttpOnly"] },
|
||||
],
|
||||
[
|
||||
200,
|
||||
"application/json",
|
||||
JSON.stringify({ errorCode: 0, result: { data: [{ name: "Default", key: "sitekey" }] } }),
|
||||
],
|
||||
[
|
||||
200,
|
||||
"application/json",
|
||||
JSON.stringify({
|
||||
errorCode: 0,
|
||||
result: {
|
||||
totalClientNum: 10,
|
||||
connectedApNum: 2,
|
||||
connectedGatewayNum: 1,
|
||||
connectedSwitchNum: 3,
|
||||
},
|
||||
}),
|
||||
],
|
||||
[200, "application/json", JSON.stringify({ errorCode: 0, result: { alertNum: 4 } })],
|
||||
];
|
||||
|
||||
httpProxy.mockImplementation(async (_url, params = {}) => {
|
||||
if (params.body) {
|
||||
params.headers["content-length"] = Buffer.byteLength(params.body);
|
||||
}
|
||||
|
||||
return responses.shift();
|
||||
});
|
||||
|
||||
const req = { query: { group: "g", service: "svc", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await omadaProxyHandler(req, res);
|
||||
|
||||
expect(httpProxy.mock.calls[2][1].headers["content-length"]).toBe(2);
|
||||
expect(httpProxy.mock.calls[3][1].headers["content-length"]).toBeUndefined();
|
||||
expect(httpProxy.mock.calls[4][1].headers["content-length"]).toBeUndefined();
|
||||
expect(res.body).toBe(
|
||||
JSON.stringify({
|
||||
connectedAp: 2,
|
||||
activeUser: 10,
|
||||
alerts: 4,
|
||||
connectedGateways: 1,
|
||||
connectedSwitches: 3,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the cached session and re-authenticates when an authenticated response is not JSON", async () => {
|
||||
cache.put("omadaProxyHandler__session.g.svc.0", {
|
||||
token: "stale-token",
|
||||
cookieHeader: "TPOMADA_SESSIONID=stale",
|
||||
});
|
||||
|
||||
getServiceWidget.mockResolvedValue({
|
||||
url: "http://omada",
|
||||
username: "u",
|
||||
password: "p",
|
||||
site: "Default",
|
||||
});
|
||||
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
JSON.stringify({ result: { omadacId: "cid", controllerVer: "4.5.6" } }),
|
||||
])
|
||||
.mockResolvedValueOnce([200, "text/html", Buffer.from("<!DOCTYPE html>login")])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "fresh-token" } })),
|
||||
{ "set-cookie": ["TPOMADA_SESSIONID=fresh; Path=/; HttpOnly"] },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
JSON.stringify({ errorCode: 0, result: { data: [{ name: "Default", key: "sitekey" }] } }),
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
JSON.stringify({
|
||||
errorCode: 0,
|
||||
result: {
|
||||
totalClientNum: 10,
|
||||
connectedApNum: 2,
|
||||
connectedGatewayNum: 1,
|
||||
connectedSwitchNum: 3,
|
||||
},
|
||||
}),
|
||||
])
|
||||
.mockResolvedValueOnce([200, "application/json", JSON.stringify({ errorCode: 0, result: { alertNum: 4 } })]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await omadaProxyHandler(req, res);
|
||||
|
||||
expect(cache.del).toHaveBeenCalledWith("omadaProxyHandler__session.g.svc.0");
|
||||
const loginCalls = httpProxy.mock.calls.filter(([url]) => url.toString().includes("/api/v2/login"));
|
||||
expect(loginCalls).toHaveLength(1);
|
||||
expect(res.body).toBe(
|
||||
JSON.stringify({
|
||||
connectedAp: 2,
|
||||
activeUser: 10,
|
||||
alerts: 4,
|
||||
connectedGateways: 1,
|
||||
connectedSwitches: 3,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the cached session and re-authenticates when a cached session returns a JSON auth error", async () => {
|
||||
cache.put("omadaProxyHandler__session.g.svc.0", {
|
||||
token: "stale-token",
|
||||
cookieHeader: "TPOMADA_SESSIONID=stale",
|
||||
});
|
||||
|
||||
getServiceWidget.mockResolvedValue({
|
||||
url: "http://omada",
|
||||
username: "u",
|
||||
password: "p",
|
||||
site: "Default",
|
||||
});
|
||||
|
||||
httpProxy
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
JSON.stringify({ result: { omadacId: "cid", controllerVer: "4.5.6" } }),
|
||||
])
|
||||
.mockResolvedValueOnce([200, "application/json", JSON.stringify({ errorCode: 1, msg: "Login required" })])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
Buffer.from(JSON.stringify({ errorCode: 0, result: { token: "fresh-token" } })),
|
||||
{ "set-cookie": ["TPOMADA_SESSIONID=fresh; Path=/; HttpOnly"] },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
JSON.stringify({ errorCode: 0, result: { data: [{ name: "Default", key: "sitekey" }] } }),
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
200,
|
||||
"application/json",
|
||||
JSON.stringify({
|
||||
errorCode: 0,
|
||||
result: {
|
||||
totalClientNum: 10,
|
||||
connectedApNum: 2,
|
||||
connectedGatewayNum: 1,
|
||||
connectedSwitchNum: 3,
|
||||
},
|
||||
}),
|
||||
])
|
||||
.mockResolvedValueOnce([200, "application/json", JSON.stringify({ errorCode: 0, result: { alertNum: 4 } })]);
|
||||
|
||||
const req = { query: { group: "g", service: "svc", index: "0" } };
|
||||
const res = createMockRes();
|
||||
|
||||
await omadaProxyHandler(req, res);
|
||||
|
||||
expect(cache.del).toHaveBeenCalledWith("omadaProxyHandler__session.g.svc.0");
|
||||
const loginCalls = httpProxy.mock.calls.filter(([url]) => url.toString().includes("/api/v2/login"));
|
||||
expect(loginCalls).toHaveLength(1);
|
||||
expect(res.body).toBe(
|
||||
JSON.stringify({
|
||||
connectedAp: 2,
|
||||
activeUser: 10,
|
||||
alerts: 4,
|
||||
connectedGateways: 1,
|
||||
connectedSwitches: 3,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user