From f962d59cd6fa2a008d351a598499099615e1cc5a Mon Sep 17 00:00:00 2001 From: J3vb Date: Sat, 4 Apr 2026 21:50:57 +0200 Subject: [PATCH] fix: update tests and fix pending-join drain regression for CI Update LiveKitSession tests to use _state discriminated union instead of old flat field names (room, currentChannelId, latestToken, etc.) removed in the state machine refactor. Also fix renderers.test.ts URL resolution by setting a server host in beforeEach so isSafeUrl can parse relative attachment URLs in jsdom. Stage all four Go test files so the CI Go job runs them. Additionally fix a regression in connectAndSetup's finally block: when a pendingJoin is queued during a stale-join abort, preserve the connecting state so handleVoiceToken's drain loop can pick it up rather than losing it by resetting to idle. --- Client/tauri-client/src/lib/livekitSession.ts | 6 +- .../tests/unit/livekit-session.test.ts | 143 ++++++++++++---- .../tauri-client/tests/unit/renderers.test.ts | 5 + Server/api/auth_handler_test.go | 140 +++++++++++++++ Server/api/invite_handler_test.go | 159 ++++++++++++++++++ Server/api/upload_handler_test.go | 144 ++++++++++++++++ Server/api/waf_test.go | 115 +++++++++++++ 7 files changed, 674 insertions(+), 38 deletions(-) create mode 100644 Server/api/waf_test.go diff --git a/Client/tauri-client/src/lib/livekitSession.ts b/Client/tauri-client/src/lib/livekitSession.ts index 46055459..52c42b0e 100644 --- a/Client/tauri-client/src/lib/livekitSession.ts +++ b/Client/tauri-client/src/lib/livekitSession.ts @@ -924,8 +924,12 @@ export class LiveKitSession { // Only clear "connecting" back to "idle" if we are still in the connecting // state for this generation — never overwrite a "connected" state that was // set by the success path above (guards against risk #4 in the analysis). + // If a pendingJoin was queued while this attempt ran, leave the state as + // "connecting" so handleVoiceToken's drain loop can read and consume it. if (this._state.type === "connecting" && this._state.joinGeneration === myGeneration) { - this.setState({ type: "idle" }); + if (this._state.pendingJoin === null) { + this.setState({ type: "idle" }); + } } } } diff --git a/Client/tauri-client/tests/unit/livekit-session.test.ts b/Client/tauri-client/tests/unit/livekit-session.test.ts index 649e948e..2c2eb995 100644 --- a/Client/tauri-client/tests/unit/livekit-session.test.ts +++ b/Client/tauri-client/tests/unit/livekit-session.test.ts @@ -635,7 +635,14 @@ describe("LiveKitSession", () => { it("preserves local mute state on reconnect", async () => { mockVoiceState.localMuted = true; mockVoiceState.localDeafened = false; - (session as any).currentChannelId = 7; + (session as any)._state = { + type: "reconnecting", + channelId: 7, + latestToken: "reconnect-token", + lastUrl: "/livekit", + lastDirectUrl: "ws://localhost:7880", + ac: new AbortController(), + }; const ac = new AbortController(); const reconnectPromise = (session as any).attemptAutoReconnect( @@ -655,7 +662,14 @@ describe("LiveKitSession", () => { it("re-applies deafened remote subscriptions on reconnect", async () => { mockVoiceState.localMuted = true; mockVoiceState.localDeafened = true; - (session as any).currentChannelId = 9; + (session as any)._state = { + type: "reconnecting", + channelId: 9, + latestToken: "reconnect-token", + lastUrl: "/livekit", + lastDirectUrl: "ws://localhost:7880", + ac: new AbortController(), + }; const setSubscribed = vi.fn(); mockRoom.remoteParticipants = new Map([ @@ -754,8 +768,8 @@ describe("LiveKitSession", () => { expect(disconnectedHandler).toBeDefined(); disconnectedHandler!(7); - // The room should NOT have been nulled — retry loop is still in control - expect((session as any).room).not.toBeNull(); + // The session should NOT have been reset to idle — retry loop is still in control + expect((session as any)._state.type).not.toBe("idle"); // Resolve connect to let the flow complete normally connectDeferred.resolve(undefined); @@ -928,12 +942,19 @@ describe("LiveKitSession", () => { it("aborts reconnectAc when reconnect is in progress", () => { const ac = new AbortController(); const abortSpy = vi.spyOn(ac, "abort"); - (session as any).reconnectAc = ac; + (session as any)._state = { + type: "reconnecting", + channelId: 1, + latestToken: "t", + lastUrl: "/lk", + lastDirectUrl: undefined, + ac, + }; session.leaveVoice(false); expect(abortSpy).toHaveBeenCalled(); - expect((session as any).reconnectAc).toBeNull(); + expect((session as any)._state.type).toBe("idle"); }); it("clears the token refresh timer so it does not fire after leave", () => { @@ -959,15 +980,15 @@ describe("LiveKitSession", () => { }); it("nulls pendingJoin", () => { - (session as any).pendingJoin = { - token: "t", - url: "/lk", - channelId: 1, + (session as any)._state = { + type: "connecting", + pendingJoin: { token: "t", url: "/lk", channelId: 1 }, + joinGeneration: 1, }; session.leaveVoice(false); - expect((session as any).pendingJoin).toBeNull(); + expect((session as any)._state.type).toBe("idle"); }); it("calls cleanupAllAudioElementsFull on _audioElements", () => { @@ -985,8 +1006,8 @@ describe("LiveKitSession", () => { session.setWsClient({ send: vi.fn() } as any); await session.handleVoiceToken("tok", "/lk", 1, "ws://localhost:7880"); - const room = (session as any).room; - expect(room).not.toBeNull(); + expect((session as any)._state.type).toBe("connected"); + const room = (session as any)._state.room; session.leaveVoice(false); @@ -999,11 +1020,11 @@ describe("LiveKitSession", () => { session.setWsClient({ send: vi.fn() } as any); await session.handleVoiceToken("tok", "/lk", 5, "ws://localhost:7880"); - expect((session as any).currentChannelId).toBe(5); + expect((session as any)._state.channelId).toBe(5); session.leaveVoice(false); - expect((session as any).currentChannelId).toBeNull(); + expect((session as any)._state.type).toBe("idle"); }); it("sets latestToken to null after leave", async () => { @@ -1011,11 +1032,11 @@ describe("LiveKitSession", () => { session.setWsClient({ send: vi.fn() } as any); await session.handleVoiceToken("my-token", "/lk", 1, "ws://localhost:7880"); - expect((session as any).latestToken).toBe("my-token"); + expect((session as any)._state.latestToken).toBe("my-token"); session.leaveVoice(false); - expect((session as any).latestToken).toBeNull(); + expect((session as any)._state.type).toBe("idle"); }); it("sets lastUrl to null and lastDirectUrl to undefined after leave", async () => { @@ -1025,8 +1046,7 @@ describe("LiveKitSession", () => { session.leaveVoice(false); - expect((session as any).lastUrl).toBeNull(); - expect((session as any).lastDirectUrl).toBeUndefined(); + expect((session as any)._state.type).toBe("idle"); }); }); @@ -1263,8 +1283,7 @@ describe("LiveKitSession", () => { await session.handleVoiceToken("tok", "/lk", 7, "ws://localhost:7880"); vi.clearAllMocks(); - // Set up for reconnect - (session as any).currentChannelId = 7; + // Session is already in "connected" state with channelId=7 after handleVoiceToken mockRoom.localParticipant.setMicrophoneEnabled.mockRejectedValueOnce(new Error("mic gone")); const ac = new AbortController(); @@ -1585,11 +1604,16 @@ describe("LiveKitSession", () => { "ws://localhost:7880", ); - (session as any).pendingJoin = { - token: "token-2", - url: "/livekit-2", - channelId: 2, - directUrl: "ws://localhost:7882", + // Inject a pendingJoin into the current "connecting" state + const currentState = (session as any)._state; + (session as any)._state = { + ...currentState, + pendingJoin: { + token: "token-2", + url: "/livekit-2", + channelId: 2, + directUrl: "ws://localhost:7882", + }, }; connectDeferred.resolve(undefined); @@ -1625,7 +1649,7 @@ describe("LiveKitSession", () => { mockRoom.connect.mockResolvedValue(undefined); await (session as any).connectAndSetup("token-1", "/livekit", 1, "ws://localhost:7880"); - expect((session as any).room).not.toBeNull(); + expect((session as any)._state.type).toBe("connected"); const leaveSpy = vi.spyOn(session, "leaveVoice"); await (session as any).connectAndSetup("token-2", "/livekit", 2, "ws://localhost:7880"); @@ -1668,8 +1692,10 @@ describe("LiveKitSession", () => { await session.handleVoiceToken("token-2", "/livekit-2", 2, "ws://localhost:7882"); await session.handleVoiceToken("token-3", "/livekit-3", 3, "ws://localhost:7883"); - expect((session as any).pendingJoin.token).toBe("token-3"); - expect((session as any).pendingJoin.channelId).toBe(3); + const s = (session as any)._state; + expect(s.type).toBe("connecting"); + expect(s.pendingJoin.token).toBe("token-3"); + expect(s.pendingJoin.channelId).toBe(3); firstConnect.resolve(undefined); await firstJoin; @@ -1681,7 +1707,14 @@ describe("LiveKitSession", () => { describe("attemptAutoReconnect (lifecycle)", () => { it("returns without reconnecting when signal is aborted during delay", async () => { - (session as any).currentChannelId = 5; + (session as any)._state = { + type: "reconnecting", + channelId: 5, + latestToken: "token", + lastUrl: "/livekit", + lastDirectUrl: "ws://localhost:7880", + ac: new AbortController(), + }; const ac = new AbortController(); const reconnectPromise = (session as any).attemptAutoReconnect( @@ -1700,7 +1733,14 @@ describe("LiveKitSession", () => { }); it("aborts when currentChannelId changes during delay", async () => { - (session as any).currentChannelId = 5; + (session as any)._state = { + type: "reconnecting", + channelId: 5, + latestToken: "token", + lastUrl: "/livekit", + lastDirectUrl: "ws://localhost:7880", + ac: new AbortController(), + }; const ac = new AbortController(); const reconnectPromise = (session as any).attemptAutoReconnect( @@ -1711,7 +1751,7 @@ describe("LiveKitSession", () => { ac.signal, ); - (session as any).currentChannelId = 99; + (session as any)._state = { type: "idle" }; await vi.advanceTimersByTimeAsync(3100); await reconnectPromise; @@ -1719,7 +1759,14 @@ describe("LiveKitSession", () => { }); it("succeeds on second attempt after first fails", async () => { - (session as any).currentChannelId = 5; + (session as any)._state = { + type: "reconnecting", + channelId: 5, + latestToken: "token", + lastUrl: "/livekit", + lastDirectUrl: "ws://localhost:7880", + ac: new AbortController(), + }; session.setServerHost("localhost:7880"); const ac = new AbortController(); @@ -1743,7 +1790,14 @@ describe("LiveKitSession", () => { }); it("calls leaveVoice, leaveVoiceChannel, and error callback after all attempts fail", async () => { - (session as any).currentChannelId = 5; + (session as any)._state = { + type: "reconnecting", + channelId: 5, + latestToken: "token", + lastUrl: "/livekit", + lastDirectUrl: "ws://localhost:7880", + ac: new AbortController(), + }; session.setServerHost("localhost:7880"); const errorCb = vi.fn(); session.setOnError(errorCb); @@ -1768,7 +1822,14 @@ describe("LiveKitSession", () => { }); it("catches room disconnect failure during cleanup without throwing", async () => { - (session as any).currentChannelId = 5; + (session as any)._state = { + type: "reconnecting", + channelId: 5, + latestToken: "token", + lastUrl: "/livekit", + lastDirectUrl: "ws://localhost:7880", + ac: new AbortController(), + }; session.setServerHost("localhost:7880"); const ac = new AbortController(); @@ -1842,7 +1903,7 @@ describe("LiveKitSession", () => { }); it("requestTokenRefresh skips silently when ws is null", () => { - (session as any).room = mockRoom; + // ws is null (not set) — requestTokenRefresh should skip without throwing expect(() => (session as any).requestTokenRefresh()).not.toThrow(); }); @@ -1852,8 +1913,16 @@ describe("LiveKitSession", () => { }); it("handleVoiceTokenRefresh stores valid token and restarts timer", () => { + (session as any)._state = { + type: "connected", + room: mockRoom, + channelId: 1, + latestToken: "old-token", + lastUrl: "/lk", + lastDirectUrl: undefined, + }; session.handleVoiceTokenRefresh("fresh-token"); - expect((session as any).latestToken).toBe("fresh-token"); + expect((session as any)._state.latestToken).toBe("fresh-token"); expect((session as any).tokenRefreshTimer).not.toBeNull(); }); diff --git a/Client/tauri-client/tests/unit/renderers.test.ts b/Client/tauri-client/tests/unit/renderers.test.ts index a8893361..bd00ceaa 100644 --- a/Client/tauri-client/tests/unit/renderers.test.ts +++ b/Client/tauri-client/tests/unit/renderers.test.ts @@ -14,6 +14,7 @@ import { getUserRole, roleColorVar, GROUP_THRESHOLD_MS, + setServerHost, } from "../../src/components/message-list/renderers"; import type { Message } from "../../src/stores/messages.store"; import { membersStore } from "../../src/stores/members.store"; @@ -63,11 +64,15 @@ describe("renderers", () => { beforeEach(() => { resetStores(); + // jsdom sets window.location.origin to "null", which breaks new URL(relativeUrl, origin). + // Set a server host so resolveServerUrl converts relative paths to absolute URLs before isSafeUrl parses them. + setServerHost("localhost:8080"); container = document.createElement("div"); document.body.appendChild(container); }); afterEach(() => { + setServerHost(""); container.remove(); }); diff --git a/Server/api/auth_handler_test.go b/Server/api/auth_handler_test.go index ad93453b..bc8305ed 100644 --- a/Server/api/auth_handler_test.go +++ b/Server/api/auth_handler_test.go @@ -74,6 +74,17 @@ func postJSONWithToken(t *testing.T, router http.Handler, path, token string, bo return rr } +func postJSONFromIP(t *testing.T, router http.Handler, path string, body any, ip string) *httptest.ResponseRecorder { + t.Helper() + raw, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = ip + ":9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + return rr +} + // getWithToken performs a GET with an Authorization header. func getWithToken(t *testing.T, router http.Handler, path, token string) *httptest.ResponseRecorder { t.Helper() @@ -344,6 +355,97 @@ func TestLogin_LockoutUsesTrustedForwardedIP(t *testing.T) { } } +func TestLogin_UsernameLockoutAcrossDifferentIPs(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + hash, _ := auth.HashPassword("correctPass1") + _, _ = database.CreateUser("lockoutuser", hash, 4) + + for i := 0; i < 10; i++ { + rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ + "username": "lockoutuser", + "password": "wrongpassword", + }, fmt.Sprintf("198.51.100.%d", i+1)) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("attempt %d status = %d, want 401; body = %s", i+1, rr.Code, rr.Body.String()) + } + } + + rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ + "username": "lockoutuser", + "password": "wrongpassword", + }, "198.51.100.250") + if rr.Code != http.StatusTooManyRequests { + t.Fatalf("username lockout status = %d, want 429; body = %s", rr.Code, rr.Body.String()) + } +} + +func TestLogin_UsernameLockoutBlocksCorrectPasswordFromFreshIP(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + hash, _ := auth.HashPassword("correctPass1") + _, _ = database.CreateUser("lockoutcorrect", hash, 4) + + for i := 0; i < 10; i++ { + rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ + "username": "lockoutcorrect", + "password": "wrongpassword", + }, fmt.Sprintf("203.0.113.%d", i+1)) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("setup attempt %d status = %d, want 401; body = %s", i+1, rr.Code, rr.Body.String()) + } + } + + rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ + "username": "lockoutcorrect", + "password": "correctPass1", + }, "203.0.113.250") + if rr.Code != http.StatusTooManyRequests { + t.Fatalf("locked correct-password status = %d, want 429; body = %s", rr.Code, rr.Body.String()) + } +} + +func TestLogin_SuccessResetsUsernameFailureCounter(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + hash, _ := auth.HashPassword("correctPass1") + _, _ = database.CreateUser("resetuser", hash, 4) + + for i := 0; i < 8; i++ { + rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ + "username": "resetuser", + "password": "wrongpassword", + }, fmt.Sprintf("192.0.2.%d", i+1)) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("setup attempt %d status = %d, want 401; body = %s", i+1, rr.Code, rr.Body.String()) + } + } + + rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ + "username": "resetuser", + "password": "correctPass1", + }, "192.0.2.200") + if rr.Code != http.StatusOK { + t.Fatalf("success status = %d, want 200; body = %s", rr.Code, rr.Body.String()) + } + + for i := 0; i < 3; i++ { + rr = postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ + "username": "resetuser", + "password": "wrongpassword", + }, fmt.Sprintf("192.0.2.%d", 201+i)) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("post-reset attempt %d status = %d, want 401; body = %s", i+1, rr.Code, rr.Body.String()) + } + } +} + func TestLogin_GenericErrorOnBadCredentials(t *testing.T) { database := newAuthTestDB(t) limiter := auth.NewRateLimiter() @@ -396,6 +498,44 @@ func TestLogin_RequiresTOTPChallenge(t *testing.T) { } } +func TestLogin_UsernameLockoutBlocksTOTPChallenge(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildAuthRouter(database, limiter) + + hash, _ := auth.HashPassword("correctPass1") + userID, _ := database.CreateUser("totplocked", hash, 4) + if _, err := database.Exec(`UPDATE users SET totp_secret = ? WHERE id = ?`, "JBSWY3DPEHPK3PXP", userID); err != nil { + t.Fatalf("set totp secret: %v", err) + } + + for i := 0; i < 10; i++ { + rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ + "username": "totplocked", + "password": "wrongpassword", + }, fmt.Sprintf("198.18.0.%d", i+1)) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("setup attempt %d status = %d, want 401; body = %s", i+1, rr.Code, rr.Body.String()) + } + } + + rr := postJSONFromIP(t, router, "/api/v1/auth/login", map[string]string{ + "username": "totplocked", + "password": "correctPass1", + }, "198.18.0.250") + if rr.Code != http.StatusTooManyRequests { + t.Fatalf("locked TOTP login status = %d, want 429; body = %s", rr.Code, rr.Body.String()) + } + + var resp map[string]any + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp["partial_token"] != nil { + t.Fatalf("partial_token = %v, want nil when username is locked out", resp["partial_token"]) + } +} + func TestVerifyTotp_Success(t *testing.T) { database := newAuthTestDB(t) limiter := auth.NewRateLimiter() diff --git a/Server/api/invite_handler_test.go b/Server/api/invite_handler_test.go index 92d7f92e..783e41d6 100644 --- a/Server/api/invite_handler_test.go +++ b/Server/api/invite_handler_test.go @@ -101,6 +101,95 @@ func TestCreateInvite_Unlimited(t *testing.T) { } } +func TestCreateInvite_EmptyBody(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + token := loginAndGetToken(t, router, database, "emptyinvitebody", 2) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/invites", http.NoBody) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusCreated { + t.Fatalf("CreateInvite empty body status = %d, want 201; body = %s", rr.Code, rr.Body.String()) + } + + var resp map[string]any + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp["max_uses"] != nil { + t.Errorf("max_uses = %v, want nil", resp["max_uses"]) + } + if resp["expires_at"] != nil { + t.Errorf("expires_at = %v, want nil", resp["expires_at"]) + } + if resp["code"] == nil || resp["code"] == "" { + t.Fatal("expected invite code in response") + } +} + +func TestCreateInvite_CreateInviteFailure(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + token := loginAndGetToken(t, router, database, "invitecreatefail", 2) + + if _, err := database.Exec(`DROP TABLE invites`); err != nil { + t.Fatalf("drop invites table: %v", err) + } + + rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{}) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("CreateInvite create failure status = %d, want 500; body = %s", rr.Code, rr.Body.String()) + } + + var resp map[string]any + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp["message"] != "failed to create invite" { + t.Errorf("message = %v, want failed to create invite", resp["message"]) + } +} + +func TestCreateInvite_GetInviteFailure(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + token := loginAndGetToken(t, router, database, "invitegetfail", 2) + + if _, err := database.Exec(` + CREATE TRIGGER delete_invite_after_insert + AFTER INSERT ON invites + BEGIN + DELETE FROM invites WHERE code = NEW.code; + END; + `); err != nil { + t.Fatalf("create trigger: %v", err) + } + + rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{}) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("CreateInvite get failure status = %d, want 500; body = %s", rr.Code, rr.Body.String()) + } + + var resp map[string]any + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp["message"] != "failed to retrieve invite" { + t.Errorf("message = %v, want failed to retrieve invite", resp["message"]) + } + if _, err := database.Exec(`DROP TRIGGER delete_invite_after_insert`); err != nil { + t.Fatalf("drop trigger: %v", err) + } +} + // ─── GET /api/v1/invites ────────────────────────────────────────────────────── func TestListInvites_Success(t *testing.T) { @@ -146,6 +235,31 @@ func TestListInvites_Unauthorized(t *testing.T) { } } +func TestListInvites_EmptyArray(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + token := loginAndGetToken(t, router, database, "emptyinvitelist", 2) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/invites", nil) + req.Header.Set("Authorization", "Bearer "+token) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("ListInvites empty status = %d, want 200; body = %s", rr.Code, rr.Body.String()) + } + + var resp []any + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(resp) != 0 { + t.Fatalf("ListInvites empty returned %d items, want 0", len(resp)) + } +} + // ─── DELETE /api/v1/invites/:code ───────────────────────────────────────────── func TestRevokeInvite_Success(t *testing.T) { @@ -230,6 +344,51 @@ func TestRevokeInvite_MemberForbidden(t *testing.T) { } } +func TestRevokeInvite_RevokeFailure(t *testing.T) { + database := newAuthTestDB(t) + limiter := auth.NewRateLimiter() + router := buildInviteRouter(database, limiter) + token := loginAndGetToken(t, router, database, "revokefailure", 2) + + rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{}) + if rr.Code != http.StatusCreated { + t.Fatalf("setup create invite: status = %d, body = %s", rr.Code, rr.Body.String()) + } + var created map[string]any + if err := json.NewDecoder(rr.Body).Decode(&created); err != nil { + t.Fatalf("decode create response: %v", err) + } + code := created["code"].(string) + + if _, err := database.Exec(` + CREATE TRIGGER block_revoke_invite + BEFORE UPDATE OF revoked ON invites + BEGIN + SELECT RAISE(FAIL, 'revoke blocked'); + END; + `); err != nil { + t.Fatalf("create trigger: %v", err) + } + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/invites/"+code, nil) + req.Header.Set("Authorization", "Bearer "+token) + req.RemoteAddr = "127.0.0.1:9999" + rr2 := httptest.NewRecorder() + router.ServeHTTP(rr2, req) + + if rr2.Code != http.StatusInternalServerError { + t.Fatalf("RevokeInvite failure status = %d, want 500; body = %s", rr2.Code, rr2.Body.String()) + } + + var resp map[string]any + if err := json.NewDecoder(rr2.Body).Decode(&resp); err != nil { + t.Fatalf("decode revoke failure response: %v", err) + } + if resp["message"] != "failed to revoke invite" { + t.Errorf("message = %v, want failed to revoke invite", resp["message"]) + } +} + // TestListInvites_IncludesRevokedAndActive checks the list endpoint returns // correct data for both revoked and active invites. func TestListInvites_IncludesRevokedAndActive(t *testing.T) { diff --git a/Server/api/upload_handler_test.go b/Server/api/upload_handler_test.go index 9f359817..01dd1350 100644 --- a/Server/api/upload_handler_test.go +++ b/Server/api/upload_handler_test.go @@ -11,6 +11,8 @@ import ( "mime/multipart" "net/http" "net/http/httptest" + "os" + "strings" "testing" "testing/fstest" @@ -153,6 +155,15 @@ func buildUploadRouter(database *db.DB, store *storage.Storage, allowedOrigins [ return r } +func buildUploadRouterWithLimiter(database *db.DB, store *storage.Storage, limiter *auth.RateLimiter, allowedOrigins []string) http.Handler { + r := chi.NewRouter() + if limiter == nil { + limiter = auth.NewRateLimiter() + } + api.MountUploadRoutes(r, database, store, limiter, allowedOrigins) + return r +} + // uploadCreateToken creates a user+session and returns the plaintext token. func uploadCreateToken(t *testing.T, database *db.DB, username string, roleID int) string { t.Helper() @@ -462,6 +473,139 @@ func TestUpload_BlockedFileType_ELF(t *testing.T) { } } +func TestUpload_RateLimitedAfterBurst(t *testing.T) { + database := newUploadTestDB(t) + store := newUploadTestStorage(t) + limiter := auth.NewRateLimiter() + router := buildUploadRouterWithLimiter(database, store, limiter, nil) + token := uploadCreateToken(t, database, "burstuser", 1) + otherToken := uploadCreateToken(t, database, "otherburstuser", 1) + content := []byte("upload payload with enough bytes for content type detection") + + for range 10 { + rr := doUpload(t, router, token, "file", "burst.txt", content) + if rr.Code != http.StatusCreated { + t.Fatalf("pre-limit upload status = %d, want 201; body: %s", rr.Code, rr.Body.String()) + } + } + + rr := doUpload(t, router, token, "file", "burst.txt", content) + if rr.Code != http.StatusTooManyRequests { + t.Fatalf("rate-limited upload status = %d, want 429; body: %s", rr.Code, rr.Body.String()) + } + + var resp map[string]any + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode rate-limit response: %v", err) + } + if resp["error"] != "RATE_LIMITED" { + t.Errorf("error = %v, want RATE_LIMITED", resp["error"]) + } + + rr = doUpload(t, router, otherToken, "file", "burst.txt", content) + if rr.Code != http.StatusCreated { + t.Fatalf("other user upload status = %d, want 201; body: %s", rr.Code, rr.Body.String()) + } +} + +func TestUpload_OversizedFileRejected(t *testing.T) { + database := newUploadTestDB(t) + dir := t.TempDir() + store, err := storage.New(dir, 1) + if err != nil { + t.Fatalf("storage.New: %v", err) + } + router := buildUploadRouter(database, store, nil) + token := uploadCreateToken(t, database, "largeupload", 1) + content := bytes.Repeat([]byte("a"), (1<<20)+1) + + rr := doUpload(t, router, token, "file", "too-large.txt", content) + if rr.Code != http.StatusBadRequest { + t.Fatalf("oversized upload status = %d, want 400; body: %s", rr.Code, rr.Body.String()) + } + + var resp map[string]any + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode oversized response: %v", err) + } + message, _ := resp["message"].(string) + if !strings.Contains(message, "file exceeds maximum size") { + t.Fatalf("message = %q, want size rejection", message) + } + if resp["error"] != "BAD_REQUEST" { + t.Errorf("error = %v, want BAD_REQUEST", resp["error"]) + } +} + +func TestUpload_DBCreateAttachmentFailureDeletesStoredFile(t *testing.T) { + database := newUploadTestDB(t) + dir := t.TempDir() + store, err := storage.New(dir, 10) + if err != nil { + t.Fatalf("storage.New: %v", err) + } + router := buildUploadRouter(database, store, nil) + token := uploadCreateToken(t, database, "dbfailupload", 1) + + if _, err := database.Exec(`DROP TABLE attachments`); err != nil { + t.Fatalf("drop attachments table: %v", err) + } + + content := []byte("content that will save to disk before attachment insert fails") + rr := doUpload(t, router, token, "file", "cleanup.txt", content) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("upload status = %d, want 500; body: %s", rr.Code, rr.Body.String()) + } + + var resp map[string]any + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode db failure response: %v", err) + } + if resp["error"] != "INTERNAL_ERROR" { + t.Errorf("error = %v, want INTERNAL_ERROR", resp["error"]) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + if len(entries) != 0 { + t.Fatalf("expected stored file cleanup on DB failure, found %d entries", len(entries)) + } +} + +func TestUpload_SanitizesReservedFilenameToUnnamed(t *testing.T) { + database := newUploadTestDB(t) + store := newUploadTestStorage(t) + router := buildUploadRouter(database, store, nil) + token := uploadCreateToken(t, database, "sanitizeupload", 1) + content := []byte("content for reserved filename sanitization") + + rr := doUpload(t, router, token, "file", ".", content) + if rr.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201; body: %s", rr.Code, rr.Body.String()) + } + + var resp map[string]any + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if resp["filename"] != "unnamed" { + t.Fatalf("filename = %v, want unnamed", resp["filename"]) + } + + att, err := database.GetAttachmentByID(resp["id"].(string)) + if err != nil { + t.Fatalf("GetAttachmentByID: %v", err) + } + if att == nil { + t.Fatal("expected attachment record in DB, got nil") + } + if att.Filename != "unnamed" { + t.Fatalf("DB filename = %q, want unnamed", att.Filename) + } +} + func TestUpload_SuccessfulUploadCreatesDBRecord(t *testing.T) { database := newUploadTestDB(t) store := newUploadTestStorage(t) diff --git a/Server/api/waf_test.go b/Server/api/waf_test.go new file mode 100644 index 00000000..4a3138e0 --- /dev/null +++ b/Server/api/waf_test.go @@ -0,0 +1,115 @@ +package api + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/corazawaf/coraza/v3/types" +) + +func TestHandleWAFInterruption_WritesJSONAndStatus(t *testing.T) { + rr := httptest.NewRecorder() + handleWAFInterruption(rr, &types.Interruption{ + Action: "deny", + Status: http.StatusForbidden, + RuleID: 942100, + Data: "SQL Injection detected", + }) + + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rr.Code) + } + if rr.Header().Get("Content-Type") != "application/json" { + t.Fatalf("Content-Type = %q, want application/json", rr.Header().Get("Content-Type")) + } + if strings.TrimSpace(rr.Body.String()) != `{"error":"request blocked by security rules"}` { + t.Fatalf("body = %q, want blocked JSON", rr.Body.String()) + } +} + +func TestWAFMiddleware_AllowsBenignRequest(t *testing.T) { + called := false + middleware := NewWAFMiddleware(2) + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels?q=hello", nil) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if !called { + t.Fatal("expected downstream handler to be called") + } + if rr.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204", rr.Code) + } +} + +func TestWAFMiddleware_InvalidParanoiaLevelStillAllowsBenignRequest(t *testing.T) { + called := false + middleware := NewWAFMiddleware(99) + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels?q=hello", nil) + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if !called { + t.Fatal("expected downstream handler to be called") + } + if rr.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204", rr.Code) + } +} + +func TestWAFMiddleware_BlocksScannerUserAgent(t *testing.T) { + middleware := NewWAFMiddleware(2) + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("downstream handler should not be called for blocked scanner request") + })) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels", nil) + req.Header.Set("User-Agent", "sqlmap/1.8") + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body = %s", rr.Code, rr.Body.String()) + } +} + +func TestWAFMiddleware_PreservesReadableBodyForDownstream(t *testing.T) { + const requestBody = `{"message":"hello world"}` + middleware := NewWAFMiddleware(2) + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("ReadAll: %v", err) + } + if string(body) != requestBody { + t.Fatalf("body = %q, want %q", string(body), requestBody) + } + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/messages", strings.NewReader(requestBody)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = "127.0.0.1:9999" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body = %s", rr.Code, rr.Body.String()) + } +}