diff --git a/src/utils/proxy/cookie-jar.js b/src/utils/proxy/cookie-jar.js index 3161d1d2f..66e00dca1 100644 --- a/src/utils/proxy/cookie-jar.js +++ b/src/utils/proxy/cookie-jar.js @@ -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; + } } } diff --git a/src/utils/proxy/cookie-jar.test.js b/src/utils/proxy/cookie-jar.test.js index 29c4bee96..83e459b3c 100644 --- a/src/utils/proxy/cookie-jar.test.js +++ b/src/utils/proxy/cookie-jar.test.js @@ -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"); + }); }); diff --git a/src/widgets/omada/proxy.js b/src/widgets/omada/proxy.js index 04d173de1..d5cc86cf6 100644 --- a/src/widgets/omada/proxy.js +++ b/src/widgets/omada/proxy.js @@ -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, - }), - ); } } diff --git a/src/widgets/omada/proxy.test.js b/src/widgets/omada/proxy.test.js index 060ab9a35..ff29f4eb6 100644 --- a/src/widgets/omada/proxy.test.js +++ b/src/widgets/omada/proxy.test.js @@ -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("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, + }), + ); + }); });