diff --git a/package-lock.json b/package-lock.json index c1e675e..7fb446e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1712,7 +1712,7 @@ "version": "1.62.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "peer": true, "dependencies": { @@ -4198,6 +4198,7 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, "license": "MIT" }, "node_modules/@types/json5": { @@ -4211,6 +4212,7 @@ "version": "20.19.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -4220,7 +4222,7 @@ "version": "19.2.9", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", - "devOptional": true, + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -4231,7 +4233,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peer": true, "peerDependencies": { @@ -5052,6 +5054,7 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, "license": "MIT", "peer": true, "bin": { @@ -5843,7 +5846,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -6712,6 +6715,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" @@ -6782,6 +6786,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -7245,6 +7250,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/has-bigints": { @@ -7264,6 +7270,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10991,7 +10998,7 @@ "version": "1.62.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.62.1" @@ -11010,7 +11017,7 @@ "version": "1.62.1", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "peer": true, "bin": { @@ -11358,6 +11365,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12198,6 +12206,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -12581,6 +12590,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { diff --git a/src/lib/session-sse.ts b/src/lib/session-sse.ts index e102b3e..955bd4b 100644 --- a/src/lib/session-sse.ts +++ b/src/lib/session-sse.ts @@ -100,6 +100,7 @@ export function connectSessionSse( async function connect() { if (closed) return; controller = new AbortController(); + let shouldRetry = true; try { const baseUrl = env.API_BASE_URL.replace(/\/+$/, ""); @@ -121,7 +122,7 @@ export function connectSessionSse( return; } console.warn( - `[session-sse] /auth/events returned ${response.status} — retrying`, + `[session-sse] /auth/events returned ${response.status} — ${response.status === 401 || response.status === 403 ? "stopping" : "retrying"}`, ); return; } @@ -129,24 +130,31 @@ export function connectSessionSse( // Reset retry count on successful connection retryCount = 0; - const reader = response.body?.getReader(); - if (!reader) { - console.warn("[session-sse] Response body has no readable stream"); - return; // triggers reconnect with backoff + if (response.status === 401 || response.status === 403) { + shouldRetry = false; + } + } else { + // Reset retry count on successful connection + retryCount = 0; } - const decoder = new TextDecoder(); - let buffer = ""; + if (shouldRetry) { + const reader = response.body?.getReader(); + if (!reader) { + console.warn("[session-sse] Response body has no readable stream"); + } else { + const decoder = new TextDecoder(); + let buffer = ""; - while (!closed) { - const { done, value } = await reader.read(); - if (done) break; + while (!closed) { + const { done, value } = await reader.read(); + if (done) break; - buffer += decoder.decode(value, { stream: true }); + buffer += decoder.decode(value, { stream: true }); - // Parse SSE frames - const frames = buffer.split("\n\n"); - buffer = frames.pop() ?? ""; // Keep incomplete frame in buffer + // Parse SSE frames + const frames = buffer.split("\n\n"); + buffer = frames.pop() ?? ""; // Keep incomplete frame in buffer for (const frame of frames) { const event = parseSessionSseFrame(frame); @@ -159,7 +167,7 @@ export function connectSessionSse( } // Reconnect with backoff if not closed - if (!closed) { + if (!closed && shouldRetry) { retryCount++; const delay = getReconnectDelay(); retryTimeout = setTimeout(connect, delay); diff --git a/tests/session-sse.test.ts b/tests/session-sse.test.ts new file mode 100644 index 0000000..2775496 --- /dev/null +++ b/tests/session-sse.test.ts @@ -0,0 +1,129 @@ +/** ApexChain Network Operations Intelligence Platform */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { connectSessionSse, processFrame } from "@/lib/session-sse"; + +describe("processFrame", () => { + const onEvent = vi.fn(); + + beforeEach(() => { + onEvent.mockClear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("handles a single-line session_revoked frame", () => { + processFrame( + 'event: session_revoked\ndata: {"reason":"admin_logout"}\n\n', + onEvent, + ); + + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent).toHaveBeenCalledWith({ + type: "session_revoked", + reason: "admin_logout", + }); + }); + + it("keeps the current multi-line data truncation behavior", () => { + processFrame( + 'event: session_revoked\ndata: {"reason":"admin_logout"}\ndata: {"reason":"password_changed"}\n\n', + onEvent, + ); + + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent).toHaveBeenCalledWith({ + type: "session_revoked", + reason: "password_changed", + }); + }); + + it("ignores malformed JSON payloads", () => { + processFrame('event: session_revoked\ndata: {"reason":\n\n', onEvent); + + expect(onEvent).not.toHaveBeenCalled(); + }); + + it("ignores unknown event types", () => { + processFrame('event: heartbeat\ndata: {"timestamp":1234567890}\n\n', onEvent); + + expect(onEvent).not.toHaveBeenCalled(); + }); + + it("ignores a session_revoked payload when the event line is missing", () => { + processFrame('data: {"reason":"session_expired"}\n\n', onEvent); + + expect(onEvent).not.toHaveBeenCalled(); + }); + + it.each([ + ["admin_logout", "admin_logout"], + ["password_changed", "password_changed"], + ["session_expired", "session_expired"], + ["not_a_known_reason", "unknown"], + ])("maps reason %s to %s", (reason, expected) => { + processFrame( + `event: session_revoked\ndata: {"reason":"${reason}"}\n\n`, + onEvent, + ); + + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent).toHaveBeenCalledWith({ + type: "session_revoked", + reason: expected, + }); + }); +}); + +describe("connectSessionSse reconnect behavior", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("schedules a retry after a non-200 response", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + }); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(Math, "random").mockReturnValue(0.5); + + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + const connection = connectSessionSse(() => {}); + + await Promise.resolve(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(setTimeoutSpy).toHaveBeenCalledTimes(1); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), expect.any(Number)); + + connection.close(); + }); + + it("stops retrying after 401 or 403 responses", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + }); + vi.stubGlobal("fetch", fetchMock); + + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + const connection = connectSessionSse(() => {}); + + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(1_000); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(setTimeoutSpy).not.toHaveBeenCalled(); + + connection.close(); + }); +}); diff --git a/vitest-session-sse.json b/vitest-session-sse.json new file mode 100644 index 0000000..71b80d8 --- /dev/null +++ b/vitest-session-sse.json @@ -0,0 +1 @@ +{"numTotalTestSuites":3,"numPassedTestSuites":1,"numFailedTestSuites":2,"numPendingTestSuites":0,"numTotalTests":11,"numPassedTests":10,"numFailedTests":1,"numPendingTests":0,"numTodoTests":0,"snapshot":{"added":0,"failure":false,"filesAdded":0,"filesRemoved":0,"filesRemovedList":[],"filesUnmatched":0,"filesUpdated":0,"matched":0,"total":0,"unchecked":0,"uncheckedKeysByFile":[],"unmatched":0,"updated":0,"didUpdate":false},"startTime":1788117061551,"success":false,"testResults":[{"assertionResults":[{"ancestorTitles":["processFrame"],"fullName":"processFrame handles a single-line session_revoked frame","status":"passed","title":"handles a single-line session_revoked frame","duration":3.6824000000001433,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["processFrame"],"fullName":"processFrame keeps the current multi-line data truncation behavior","status":"passed","title":"keeps the current multi-line data truncation behavior","duration":0.9655999999999949,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["processFrame"],"fullName":"processFrame ignores malformed JSON payloads","status":"passed","title":"ignores malformed JSON payloads","duration":0.6653999999998632,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["processFrame"],"fullName":"processFrame ignores unknown event types","status":"passed","title":"ignores unknown event types","duration":0.49739999999974316,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["processFrame"],"fullName":"processFrame ignores a session_revoked payload when the event line is missing","status":"passed","title":"ignores a session_revoked payload when the event line is missing","duration":0.30960000000004584,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["processFrame"],"fullName":"processFrame maps reason admin_logout to admin_logout","status":"passed","title":"maps reason admin_logout to admin_logout","duration":0.7570999999998094,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["processFrame"],"fullName":"processFrame maps reason password_changed to password_changed","status":"passed","title":"maps reason password_changed to password_changed","duration":0.6610000000000582,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["processFrame"],"fullName":"processFrame maps reason session_expired to session_expired","status":"passed","title":"maps reason session_expired to session_expired","duration":0.9315000000001419,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["processFrame"],"fullName":"processFrame maps reason not_a_known_reason to unknown","status":"passed","title":"maps reason not_a_known_reason to unknown","duration":0.7445000000002437,"failureMessages":[],"meta":{},"tags":[]},{"ancestorTitles":["connectSessionSse reconnect behavior"],"fullName":"connectSessionSse reconnect behavior schedules a retry after a non-200 response","status":"failed","title":"schedules a retry after a non-200 response","duration":846.2370000000001,"failureMessages":["Error: Aborting after running 10000 timers, assuming an infinite loop!\nTimeout - connect\n at connect (C:/Users/a-ahmedyahaya/Desktop/dev/abdulsnk/ApexChainx-Frontend/src/lib/session-sse.ts:137:22)"],"meta":{},"tags":[]},{"ancestorTitles":["connectSessionSse reconnect behavior"],"fullName":"connectSessionSse reconnect behavior stops retrying after 401 or 403 responses","status":"passed","title":"stops retrying after 401 or 403 responses","duration":2.9220000000000255,"failureMessages":[],"meta":{},"tags":[]}],"startTime":1788117063857,"endTime":1788117064716.922,"status":"failed","message":"","name":"C:/Users/a-ahmedyahaya/Desktop/dev/abdulsnk/ApexChainx-Frontend/tests/session-sse.test.ts"}]} \ No newline at end of file