mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* docs(b3-1): record PR #1449 =71d867cbin the status line, step table and evidence block Pre-squash SHAs completed with the coverage commita0356ee1and the three Codex rounds (head8614603b). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo * refactor(b3-2): auth_deps.go — the consumer-owned AuthService interface Eight methods beside the handlers that need them: Register, Login, VerifyTOTP, Logout, DeleteAccount, EnableTOTP, ConfirmTOTP, DisableTOTP — fewer than the ten *db.DB methods the two handlers call today. The input and result types they name (Principal, RegisterInput, LoginInput, AuthResult, TOTPChangeResult) and the AuthBroadcaster the delete path needs live in service/auth.go. Nothing implements or calls the interface yet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo * refactor(b3-2): service.AuthService — the auth orchestration, moved verbatim Register, Login, VerifyTOTP, Logout, DeleteAccount, EnableTOTP, ConfirmTOTP, DisableTOTP and the RegistrationPolicy gate two characterization rows pin ahead of the body read. The enumeration guard, the F3 reserve-before-compare, the audit writes, the best-effort custom-status clear and the 200+warning partial-success contract move line for line; persistence stays in db behind Store. Each refusal is a named service.Err* whose Error() is the exact public message the handler wrote and whose category (ErrUnauthorized and ErrInvalidInput join the message.go set) the transport maps to a status. The auth rate multiplier moves to auth/ratescale.go so the route mounts and the login failure accounting read one value; api keeps its wrappers. Nothing calls the service yet — the handlers still own their copies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo * refactor(b3-2): thin auth handlers — decode, call AuthService, encode *db.DB leaves every handler signature in auth_handler.go and totp_handler.go; MountAuthRoutes takes the interface and the AuthMiddleware the caller builds, and router.go constructs the service after the hub. Each refusal is encoded by one writeAuthError switch on the service's error categories. The principal helper in middleware.go hands the handlers the caller as service.Principal, and userResponse moves next to the profile handler, so neither auth file names db any more: their two DBImportAllow rows go in this commit (TestDBImportAllowIsLive proves the rows could not outlive the import) and the boundary fixture points at middleware.go instead. The auth-slice limits leave api/constants.go with the code that reads them; profile_handler.go reads the shared pw_confirm budget from the service. Test files change only where they mount the routes (four helper lines + two direct mounts); no assertion or row moves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo * docs(b3-2): after-state boundary inventory — api db importers 12 → 10 Regenerated table (49 files; move 28 → 26), the auth slice's after-state dependency rows, and the honest reading of the plan's "neither db nor service" target: met for db, not for service — the handlers import service for the interface's types and Err* categories. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo * docs(b3-2): evidence block — pre-squash SHAs, graph deltas, gates, coverage Characterization green at each SHA in a detached worktree with the frozen files byte-identical to 71d867cb; nine-method interface vs ten db methods; api db importers 12 → 10; slice coverage 392/433 = 90.5% → 392/427 = 91.8%; the five behaviour notes (decode-before-gate corner cases, shared AuthMiddleware, folded confirmation block, moved limits, moved converter). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo * docs(hp-3): scorecard draft and the D4 vertical-slice pattern in server.md Five questions answered with commands and outputs at fe1d11b8/3f0d24ec; owner sign-off line left blank. server.md gains D4 — the eight-step interface/service/handler rule for B3-8 with the awkward step (gate-before-decode) named — and its D3 deviation note drops the auth routes. Plans README indexes the scorecard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo * docs(b3-2): record PR #1450 in the evidence block and the HP-3 fetch line Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1173 lines
44 KiB
Go
1173 lines
44 KiB
Go
package api_test
|
|
|
|
// coverage_push_test.go adds tests for functions with low coverage
|
|
// to push the api package above 80%.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/J3vb/OwnCord/Server/api"
|
|
"github.com/J3vb/OwnCord/Server/auth"
|
|
"github.com/J3vb/OwnCord/Server/service"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// ─── handleCreateInvite: malformed JSON body ────────────────────────────────
|
|
|
|
func TestCreateInvite_MalformedJSON(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
limiter := auth.NewRateLimiter()
|
|
router := buildInviteRouter(database, limiter)
|
|
|
|
token := loginAndGetToken(t, router, database, "malformedinvite", 2)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/invites",
|
|
bytes.NewReader([]byte(`{invalid json`)))
|
|
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.StatusBadRequest {
|
|
t.Errorf("CreateInvite malformed JSON: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateInvite_WithExpiration(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
limiter := auth.NewRateLimiter()
|
|
router := buildInviteRouter(database, limiter)
|
|
|
|
token := loginAndGetToken(t, router, database, "expireinvite", 2)
|
|
|
|
rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{
|
|
"max_uses": 10,
|
|
"expires_in_hours": 24,
|
|
})
|
|
|
|
if rr.Code != http.StatusCreated {
|
|
t.Errorf("CreateInvite with expiry: status = %d, want 201; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
|
if resp["expires_at"] == nil {
|
|
t.Error("CreateInvite with expiry: expected expires_at to be set")
|
|
}
|
|
}
|
|
|
|
// ─── handleEnableTOTP: already enabled ──────────────────────────────────────
|
|
|
|
func TestEnableTOTP_AlreadyEnabled(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
limiter := auth.NewRateLimiter()
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
token := loginAndGetToken(t, router, database, "alreadytotp", 4)
|
|
|
|
// Enable TOTP first.
|
|
rr := postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", token,
|
|
map[string]string{"password": "Password1!"})
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("enable: status = %d; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var enableResp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&enableResp)
|
|
secret := extractSecretFromURI(t, enableResp["qr_uri"].(string))
|
|
|
|
// Confirm TOTP.
|
|
code, _ := auth.GenerateTOTPCode(secret, time.Now().UTC())
|
|
rr = postJSONWithToken(t, router, "/api/v1/users/me/totp/confirm", token,
|
|
map[string]string{"password": "Password1!", "code": code})
|
|
if rr.Code != http.StatusNoContent {
|
|
t.Fatalf("confirm: status = %d; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// Try enabling again — should get 409 Conflict.
|
|
rr = postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", token,
|
|
map[string]string{"password": "Password1!"})
|
|
if rr.Code != http.StatusConflict {
|
|
t.Errorf("enable-totp already enabled: status = %d, want 409; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// ─── handleEnableTOTP: malformed body ───────────────────────────────────────
|
|
|
|
func TestEnableTOTP_MalformedBody(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
limiter := auth.NewRateLimiter()
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
token := loginAndGetToken(t, router, database, "enablemalformed", 4)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/totp/enable",
|
|
bytes.NewReader([]byte(`{invalid`)))
|
|
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.StatusBadRequest {
|
|
t.Errorf("enable-totp malformed body: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// ─── handleConfirmTOTP: malformed body ──────────────────────────────────────
|
|
|
|
func TestConfirmTOTP_MalformedBody(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
limiter := auth.NewRateLimiter()
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
token := loginAndGetToken(t, router, database, "confirmmalformed", 4)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/totp/confirm",
|
|
bytes.NewReader([]byte(`{invalid`)))
|
|
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.StatusBadRequest {
|
|
t.Errorf("confirm-totp malformed body: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// ─── handleDisableTOTP: malformed body ──────────────────────────────────────
|
|
|
|
func TestDisableTOTP_MalformedBody(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
limiter := auth.NewRateLimiter()
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
token := loginAndGetToken(t, router, database, "disablemalformed", 4)
|
|
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/users/me/totp",
|
|
bytes.NewReader([]byte(`{invalid`)))
|
|
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.StatusBadRequest {
|
|
t.Errorf("disable-totp malformed body: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// ─── handleUpdateProfile: malformed body ────────────────────────────────────
|
|
|
|
func TestUpdateProfile_MalformedBody(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "profilemalformed", 4)
|
|
|
|
req := httptest.NewRequest(http.MethodPatch, "/api/v1/users/me",
|
|
bytes.NewReader([]byte(`{invalid`)))
|
|
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.StatusBadRequest {
|
|
t.Errorf("UpdateProfile malformed: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// ─── handleUpdateProfile: invalid username (too short/long or special chars) ─
|
|
|
|
func TestUpdateProfile_InvalidUsername(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "validuser1", 4)
|
|
|
|
// Username with only spaces → empty after trim.
|
|
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
|
|
"username": " ",
|
|
})
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("UpdateProfile spaces-only: status = %d, want 400", rr.Code)
|
|
}
|
|
|
|
// Username that is too short (single char) — might fail ValidateUsername.
|
|
rr = patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
|
|
"username": "a",
|
|
})
|
|
// This should be 400 if ValidateUsername rejects it, or 200 if it accepts 1-char names.
|
|
// Either way, we hit the validation code path.
|
|
if rr.Code != http.StatusBadRequest && rr.Code != http.StatusOK {
|
|
t.Errorf("UpdateProfile short username: unexpected status = %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
// ─── handleUpdateProfile: avatar sanitisation ───────────────────────────────
|
|
|
|
func TestUpdateProfile_WithAvatar(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "avataruser", 4)
|
|
|
|
avatar := "https://example.com/avatar.png"
|
|
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]any{
|
|
"username": "avataruser2",
|
|
"avatar": avatar,
|
|
})
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("UpdateProfile with avatar: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUpdateProfile_NullAvatar(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "nullavuser", 4)
|
|
|
|
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]any{
|
|
"username": "nullavuser2",
|
|
"avatar": nil,
|
|
})
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("UpdateProfile null avatar: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// ─── handleChangePassword: malformed body ───────────────────────────────────
|
|
|
|
func TestChangePassword_MalformedBody(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "chpwmalformed", 4)
|
|
|
|
req := httptest.NewRequest(http.MethodPut, "/api/v1/users/me/password",
|
|
bytes.NewReader([]byte(`{invalid`)))
|
|
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.StatusBadRequest {
|
|
t.Errorf("ChangePassword malformed: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// ─── handleChangePassword: missing fields ───────────────────────────────────
|
|
|
|
func TestChangePassword_MissingOldPassword(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "chpwmissold", 4)
|
|
|
|
rr := putJSON(t, router, "/api/v1/users/me/password", token, map[string]string{
|
|
"old_password": "",
|
|
"new_password": "newSecure2",
|
|
})
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("ChangePassword missing old: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestChangePassword_MissingNewPassword(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "chpwmissnew", 4)
|
|
|
|
rr := putJSON(t, router, "/api/v1/users/me/password", token, map[string]string{
|
|
"old_password": "securePass1",
|
|
"new_password": "",
|
|
})
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("ChangePassword missing new: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// ─── handleRevokeSession: invalid ID format ─────────────────────────────────
|
|
|
|
func TestRevokeSession_InvalidID(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "revokebadfmt", 4)
|
|
|
|
rr := profileDelete(t, router, "/api/v1/users/me/sessions/abc", token)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("RevokeSession bad ID: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestRevokeSession_NegativeID(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "revokenegid", 4)
|
|
|
|
rr := profileDelete(t, router, "/api/v1/users/me/sessions/-1", token)
|
|
// Negative IDs should return 400 or 404.
|
|
if rr.Code != http.StatusBadRequest && rr.Code != http.StatusNotFound {
|
|
t.Errorf("RevokeSession negative ID: status = %d, want 400 or 404", rr.Code)
|
|
}
|
|
}
|
|
|
|
// ─── handleLiveKitHealth via exported test helper ───────────────────────────
|
|
|
|
func TestLiveKitHealth_OK(t *testing.T) {
|
|
handler := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) {
|
|
return true, nil
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/health/livekit", nil)
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("LiveKitHealth OK: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
|
if resp["status"] != "ok" {
|
|
t.Errorf("LiveKitHealth OK: status = %v, want 'ok'", resp["status"])
|
|
}
|
|
if resp["livekit_reachable"] != true {
|
|
t.Errorf("LiveKitHealth OK: livekit_reachable = %v, want true", resp["livekit_reachable"])
|
|
}
|
|
}
|
|
|
|
func TestLiveKitHealth_Degraded_WithError(t *testing.T) {
|
|
handler := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) {
|
|
return false, errors.New("connection refused")
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/health/livekit", nil)
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("LiveKitHealth degraded: status = %d, want 503; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
|
if resp["status"] != "degraded" {
|
|
t.Errorf("LiveKitHealth degraded: status = %v, want 'degraded'", resp["status"])
|
|
}
|
|
if resp["error"] != "connection refused" {
|
|
t.Errorf("LiveKitHealth degraded: error = %v, want 'connection refused'", resp["error"])
|
|
}
|
|
}
|
|
|
|
func TestLiveKitHealth_Degraded_NilError(t *testing.T) {
|
|
handler := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) {
|
|
return false, nil
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/health/livekit", nil)
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("LiveKitHealth nil error: status = %d, want 503", rr.Code)
|
|
}
|
|
|
|
var resp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
|
if resp["error"] != "unknown" {
|
|
t.Errorf("LiveKitHealth nil error: error = %v, want 'unknown'", resp["error"])
|
|
}
|
|
}
|
|
|
|
// ─── handleListSessions: unauthenticated ────────────────────────────────────
|
|
|
|
func TestListSessions_BadToken(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/users/me/sessions", nil)
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("ListSessions no token: status = %d, want 401", rr.Code)
|
|
}
|
|
}
|
|
|
|
// ─── handleRevokeSession: unauthenticated ───────────────────────────────────
|
|
|
|
func TestRevokeSession_Unauthorized(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
|
|
rr := profileDelete(t, router, "/api/v1/users/me/sessions/1", "badtoken")
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("RevokeSession unauthorized: status = %d, want 401", rr.Code)
|
|
}
|
|
}
|
|
|
|
// ─── handleChangePassword: unauthorized ─────────────────────────────────────
|
|
|
|
func TestChangePassword_Unauthorized(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
|
|
rr := putJSON(t, router, "/api/v1/users/me/password", "badtoken", map[string]string{
|
|
"old_password": "securePass1",
|
|
"new_password": "newSecure2",
|
|
})
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("ChangePassword unauthorized: status = %d, want 401", rr.Code)
|
|
}
|
|
}
|
|
|
|
// ─── handleUpdateProfile: unauthorized ──────────────────────────────────────
|
|
|
|
func TestUpdateProfile_NoToken(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
|
|
req := httptest.NewRequest(http.MethodPatch, "/api/v1/users/me",
|
|
bytes.NewReader([]byte(`{"username":"hack"}`)))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("UpdateProfile no token: status = %d, want 401", rr.Code)
|
|
}
|
|
}
|
|
|
|
// ─── handleListInvites: member forbidden ────────────────────────────────────
|
|
|
|
func TestListInvites_MemberForbidden(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
limiter := auth.NewRateLimiter()
|
|
router := buildInviteRouter(database, limiter)
|
|
|
|
token := loginAndGetToken(t, router, database, "listmember", 4)
|
|
|
|
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.StatusForbidden {
|
|
t.Errorf("ListInvites member: status = %d, want 403", rr.Code)
|
|
}
|
|
}
|
|
|
|
// ─── handleCloseDM: broadcaster returns false (user not connected) ──────────
|
|
|
|
type offlineBroadcaster struct{}
|
|
|
|
func (b *offlineBroadcaster) SendToUser(_ int64, _ []byte) bool {
|
|
return false
|
|
}
|
|
|
|
func TestCloseDM_BroadcasterUserOffline(t *testing.T) {
|
|
database := newDMTestDB(t)
|
|
broadcaster := &offlineBroadcaster{}
|
|
router := buildDMRouter(database, broadcaster)
|
|
|
|
tokenAlice := dmCreateToken(t, database, "offline_alice", 4)
|
|
_ = dmCreateToken(t, database, "offline_bob", 4)
|
|
bob, _ := database.GetUserByUsername(context.Background(), "offline_bob")
|
|
|
|
// Create a DM.
|
|
rr := dmPost(t, router, "/api/v1/dms", tokenAlice, map[string]any{
|
|
"recipient_id": bob.ID,
|
|
})
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("setup: status = %d", rr.Code)
|
|
}
|
|
var createResp map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&createResp)
|
|
channelID := createResp["channel_id"]
|
|
|
|
// Close — broadcaster returns false (user offline).
|
|
rr2 := dmDelete(t, router, fmt.Sprintf("/api/v1/dms/%v", channelID), tokenAlice)
|
|
if rr2.Code != http.StatusNoContent {
|
|
t.Errorf("CloseDM offline: status = %d, want 204; body = %s", rr2.Code, rr2.Body.String())
|
|
}
|
|
}
|
|
|
|
// ─── handleSearch / isInvalidSearchQueryError coverage ──────────────────────
|
|
// These tests exercise the search endpoint with various query patterns to cover
|
|
// isInvalidSearchQueryError and handleSearch edge cases.
|
|
|
|
func TestSearch_EmptyQuery(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "searchempty", 1)
|
|
|
|
rr := chGet(t, router, "/api/v1/search?q=", token)
|
|
// Empty query should return 400 or 200 with empty results.
|
|
if rr.Code != http.StatusBadRequest && rr.Code != http.StatusOK {
|
|
t.Errorf("Search empty: unexpected status = %d; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSearch_SpecialCharacters(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "searchspecial", 1)
|
|
|
|
// These queries may trigger FTS5 syntax errors which isInvalidSearchQueryError handles.
|
|
queries := []string{
|
|
`"unterminated string`,
|
|
`test OR`,
|
|
`test AND`,
|
|
`*`,
|
|
`test"`,
|
|
}
|
|
for _, q := range queries {
|
|
rr := chGet(t, router, "/api/v1/search?q="+url.QueryEscape(q), token)
|
|
// Should get 400 (invalid query) or 200 (handled gracefully).
|
|
if rr.Code >= 500 {
|
|
t.Errorf("Search %q: unexpected 5xx status = %d; body = %s", q, rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── handleListChannels: unauthorized ───────────────────────────────────────
|
|
|
|
func TestListChannels_Unauthorized(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/channels", nil)
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("ListChannels no auth: status = %d, want 401", rr.Code)
|
|
}
|
|
}
|
|
|
|
// ─── handleGetMessages: edge cases ──────────────────────────────────────────
|
|
|
|
func TestGetMessages_InvalidLimit(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "msglimit", 1)
|
|
chID, _ := database.CreateChannel(context.Background(), "limit-ch", "text", "", "", 0)
|
|
|
|
// Negative limit.
|
|
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=-1", chID), token)
|
|
if rr.Code >= 500 {
|
|
t.Errorf("GetMessages negative limit: unexpected 5xx status = %d", rr.Code)
|
|
}
|
|
|
|
// Limit > 100.
|
|
rr = chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=999", chID), token)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("GetMessages limit=999: status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
// With before parameter.
|
|
rr = chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?before=999999", chID), token)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("GetMessages with before: status = %d, want 200", rr.Code)
|
|
}
|
|
}
|
|
|
|
// ─── handleGetPins: basic and unauthorized ──────────────────────────────────
|
|
|
|
func TestGetPins_Success(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "pinuser", 1)
|
|
chID, _ := database.CreateChannel(context.Background(), "pin-ch", "text", "", "", 0)
|
|
|
|
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/pins", chID), token)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("GetPins: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetPins_Unauthorized(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
chID, _ := database.CreateChannel(context.Background(), "pin-unauth-ch", "text", "", "", 0)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/channels/%d/pins", chID), nil)
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("GetPins unauthorized: status = %d, want 401", rr.Code)
|
|
}
|
|
}
|
|
|
|
// ─── handleSetPinned: unauthorized and invalid ──────────────────────────────
|
|
|
|
func TestSetPinned_Unauthorized(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
chID, _ := database.CreateChannel(context.Background(), "setpin-ch", "text", "", "", 0)
|
|
|
|
req := httptest.NewRequest(http.MethodPut,
|
|
fmt.Sprintf("/api/v1/channels/%d/messages/1/pin", chID),
|
|
bytes.NewReader([]byte(`{"pinned":true}`)))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("SetPinned unauthorized: status = %d, want 401", rr.Code)
|
|
}
|
|
}
|
|
|
|
// ─── writeJSON: verify JSON encoding corner case ────────────────────────────
|
|
// writeJSON is at 75% — testing the success path covers the rest.
|
|
|
|
func TestWriteJSON_BasicSuccess(t *testing.T) {
|
|
handler := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) {
|
|
return true, nil
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
|
rr := httptest.NewRecorder()
|
|
handler.ServeHTTP(rr, req)
|
|
|
|
if ct := rr.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
|
|
t.Errorf("writeJSON Content-Type = %q, want application/json", ct)
|
|
}
|
|
}
|
|
|
|
// ─── helpers: buildChannelRouter, chTestCreateToken, chGet ──────────────────
|
|
// These are defined in channel_handler_test.go but we reference them here.
|
|
// They use newChannelTestDB which is also in that file.
|
|
|
|
// Verify all helper functions are accessible (compile check).
|
|
var (
|
|
_ = newChannelTestDB
|
|
_ = buildChannelRouter
|
|
_ = chTestCreateToken
|
|
_ = chGet
|
|
)
|
|
|
|
// ─── handleRevokeInvite: already revoked ────────────────────────────────────
|
|
|
|
func TestRevokeInvite_AlreadyRevoked(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
limiter := auth.NewRateLimiter()
|
|
router := buildInviteRouter(database, limiter)
|
|
|
|
token := loginAndGetToken(t, router, database, "revoketwice", 2)
|
|
|
|
// Create invite.
|
|
rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{})
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("setup: status = %d", rr.Code)
|
|
}
|
|
var created map[string]any
|
|
_ = json.NewDecoder(rr.Body).Decode(&created)
|
|
code := created["code"].(string)
|
|
|
|
// Revoke it once.
|
|
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.StatusNoContent {
|
|
t.Fatalf("first revoke: status = %d", rr2.Code)
|
|
}
|
|
|
|
// Revoke it again — should still succeed (idempotent) or return error.
|
|
req = httptest.NewRequest(http.MethodDelete, "/api/v1/invites/"+code, nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
rr3 := httptest.NewRecorder()
|
|
router.ServeHTTP(rr3, req)
|
|
// Should not be 500.
|
|
if rr3.Code >= 500 {
|
|
t.Errorf("RevokeInvite already revoked: unexpected 5xx = %d; body = %s", rr3.Code, rr3.Body.String())
|
|
}
|
|
}
|
|
|
|
// ─── handleListSessions: multiple sessions ──────────────────────────────────
|
|
|
|
func TestListSessions_MultipleSessions(t *testing.T) {
|
|
database := newAuthTestDB(t)
|
|
router := buildProfileRouter(database)
|
|
token := profileCreateToken(t, database, "multisess", 4)
|
|
|
|
// Create additional session.
|
|
user, _ := database.GetUserByUsername(context.Background(), "multisess")
|
|
_, _ = database.CreateSession(context.Background(), user.ID, auth.HashToken("extra-token"), "Chrome", "1.2.3.4")
|
|
|
|
rr := getWithToken(t, router, "/api/v1/users/me/sessions", token)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
var resp struct {
|
|
Sessions []map[string]any `json:"sessions"`
|
|
}
|
|
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
|
if len(resp.Sessions) < 2 {
|
|
t.Errorf("expected >= 2 sessions, got %d", len(resp.Sessions))
|
|
}
|
|
}
|
|
|
|
// ─── handleListDMs: with token but invalid ──────────────────────────────────
|
|
|
|
func TestListDMs_InvalidToken(t *testing.T) {
|
|
database := newDMTestDB(t)
|
|
router := buildDMRouter(database, nil)
|
|
|
|
rr := dmGet(t, router, "/api/v1/dms", "invalid-token-xxx")
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("ListDMs invalid token: status = %d, want 401", rr.Code)
|
|
}
|
|
}
|
|
|
|
// ─── buildAuthRouter with profile routes for combined testing ───────────────
|
|
|
|
func buildCombinedRouter(t *testing.T) (http.Handler, *auth.RateLimiter, string) {
|
|
t.Helper()
|
|
database := newAuthTestDB(t)
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
r := chi.NewRouter()
|
|
svc := service.New(database, limiter)
|
|
api.MountAuthRoutes(r, service.NewAuthService(database, limiter, testTOTPKey, nil), api.AuthMiddleware(database), limiter, nil)
|
|
api.MountProfileRoutes(r, database, svc, nil, limiter, nil, nil)
|
|
api.MountInviteRoutes(r, database, svc)
|
|
|
|
token := loginAndGetToken(t, r, database, "combined1", 2)
|
|
return r, limiter, token
|
|
}
|
|
|
|
func TestCombinedRouter_ProfileAndInvites(t *testing.T) {
|
|
router, _, token := buildCombinedRouter(t)
|
|
|
|
// Profile update.
|
|
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
|
|
"username": "combined_newname",
|
|
})
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("Combined profile: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
// Create invite.
|
|
rr = postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{
|
|
"max_uses": 5,
|
|
})
|
|
if rr.Code != http.StatusCreated {
|
|
t.Errorf("Combined invite: status = %d, want 201; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// ─── handleSetPinned: message not found ─────────────────────────────────────
|
|
|
|
func TestSetPinned_MessageNotFound_Push(t *testing.T) {
|
|
database := newPinTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "pinmissmsg", 1)
|
|
chID, _ := database.CreateChannel(context.Background(), "pinmiss-ch", "text", "", "", 0)
|
|
|
|
rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID, 99999), token)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Errorf("SetPinned missing message: status = %d, want 404; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSetPinned_ChannelNotFound_Push(t *testing.T) {
|
|
database := newPinTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "pinmissch", 1)
|
|
|
|
rr := chPost(t, router, "/api/v1/channels/99999/pins/1", token)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Errorf("SetPinned missing channel: status = %d, want 404; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSetPinned_InvalidChannelID(t *testing.T) {
|
|
database := newPinTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "pinbadid", 1)
|
|
|
|
rr := chPost(t, router, "/api/v1/channels/abc/pins/1", token)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("SetPinned bad channel ID: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSetPinned_InvalidMessageID(t *testing.T) {
|
|
database := newPinTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "pinbadmsg", 1)
|
|
chID, _ := database.CreateChannel(context.Background(), "badmsgid-ch", "text", "", "", 0)
|
|
|
|
rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/abc", chID), token)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("SetPinned bad message ID: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestUnpin_Success(t *testing.T) {
|
|
database := newPinTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "unpinner", 1)
|
|
user, _ := database.GetUserByUsername(context.Background(), "unpinner")
|
|
chID, _ := database.CreateChannel(context.Background(), "unpin-ch", "text", "", "", 0)
|
|
msgID, _ := database.CreateMessage(context.Background(), chID, user.ID, "to unpin", nil)
|
|
_ = database.SetMessagePinned(context.Background(), msgID, true)
|
|
|
|
rr := chDelete(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID, msgID), token)
|
|
if rr.Code != http.StatusNoContent {
|
|
t.Errorf("Unpin: status = %d, want 204; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSetPinned_MemberForbidden(t *testing.T) {
|
|
database := newPinTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "pinmember", 4)
|
|
user, _ := database.GetUserByUsername(context.Background(), "pinmember")
|
|
chID, _ := database.CreateChannel(context.Background(), "pinforbid-ch", "text", "", "", 0)
|
|
msgID, _ := database.CreateMessage(context.Background(), chID, user.ID, "cant pin", nil)
|
|
|
|
rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID, msgID), token)
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Errorf("SetPinned member: status = %d, want 403; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSetPinned_WrongChannel(t *testing.T) {
|
|
database := newPinTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "pinwrongch", 1)
|
|
user, _ := database.GetUserByUsername(context.Background(), "pinwrongch")
|
|
chID1, _ := database.CreateChannel(context.Background(), "pin-ch1", "text", "", "", 0)
|
|
chID2, _ := database.CreateChannel(context.Background(), "pin-ch2", "text", "", "", 0)
|
|
msgID, _ := database.CreateMessage(context.Background(), chID1, user.ID, "wrong channel", nil)
|
|
|
|
// Try to pin a message from chID1 using chID2.
|
|
rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID2, msgID), token)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Errorf("SetPinned wrong channel: status = %d, want 404; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// ─── handleSetPinned: DM channel pin ────────────────────────────────────────
|
|
|
|
func TestSetPinned_DMChannel_ParticipantSuccess(t *testing.T) {
|
|
database := newPinTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
tokenAlice := chTestCreateToken(t, database, "dmpin_alice", 4)
|
|
_ = chTestCreateToken(t, database, "dmpin_bob", 4)
|
|
alice, _ := database.GetUserByUsername(context.Background(), "dmpin_alice")
|
|
bob, _ := database.GetUserByUsername(context.Background(), "dmpin_bob")
|
|
|
|
dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), alice.ID, bob.ID)
|
|
msgID, _ := database.CreateMessage(context.Background(), dmCh.ID, alice.ID, "pin this dm msg", nil)
|
|
|
|
rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", dmCh.ID, msgID), tokenAlice)
|
|
if rr.Code != http.StatusNoContent {
|
|
t.Errorf("SetPinned DM participant: status = %d, want 204; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSetPinned_DMChannel_NonParticipantForbidden(t *testing.T) {
|
|
database := newPinTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
_ = chTestCreateToken(t, database, "dmpinforbid_alice", 4)
|
|
_ = chTestCreateToken(t, database, "dmpinforbid_bob", 4)
|
|
tokenCharlie := chTestCreateToken(t, database, "dmpinforbid_charlie", 4)
|
|
alice, _ := database.GetUserByUsername(context.Background(), "dmpinforbid_alice")
|
|
bob, _ := database.GetUserByUsername(context.Background(), "dmpinforbid_bob")
|
|
|
|
dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), alice.ID, bob.ID)
|
|
msgID, _ := database.CreateMessage(context.Background(), dmCh.ID, alice.ID, "secret msg", nil)
|
|
|
|
rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", dmCh.ID, msgID), tokenCharlie)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Errorf("SetPinned DM non-participant: status = %d, want 404; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// ─── handleSearch: with channel_id filter ───────────────────────────────────
|
|
|
|
func TestSearch_WithChannelID_Push(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "searchch", 1)
|
|
user, _ := database.GetUserByUsername(context.Background(), "searchch")
|
|
chID, _ := database.CreateChannel(context.Background(), "search-ch1", "text", "", "", 0)
|
|
_, _ = database.CreateMessage(context.Background(), chID, user.ID, "findable in channel", nil)
|
|
|
|
rr := chGet(t, router, fmt.Sprintf("/api/v1/search?q=findable&channel_id=%d", chID), token)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("Search with channel_id: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSearch_WithInvalidChannelID(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "searchbadch", 1)
|
|
|
|
rr := chGet(t, router, "/api/v1/search?q=test&channel_id=abc", token)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("Search invalid channel_id: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSearch_WithNonexistentChannelID(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "searchmissch", 1)
|
|
|
|
rr := chGet(t, router, "/api/v1/search?q=test&channel_id=99999", token)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Errorf("Search nonexistent channel: status = %d, want 404; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSearch_WithLimit_Push(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "searchlimit", 1)
|
|
|
|
rr := chGet(t, router, "/api/v1/search?q=test&limit=10", token)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("Search with limit: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSearch_WithInvalidLimit(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "searchbadlimit", 1)
|
|
|
|
rr := chGet(t, router, "/api/v1/search?q=test&limit=abc", token)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("Search invalid limit: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSearch_WithNegativeLimit(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "searchneglimit", 1)
|
|
|
|
rr := chGet(t, router, "/api/v1/search?q=test&limit=-1", token)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("Search negative limit: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSearch_WithOverMaxLimit(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "searchmaxlimit", 1)
|
|
|
|
rr := chGet(t, router, "/api/v1/search?q=test&limit=999", token)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("Search over max limit: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSearch_Unauthorized(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
|
|
rr := chGet(t, router, "/api/v1/search?q=test", "")
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("Search unauthorized: status = %d, want 401; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// ─── handleGetMessages: more edge cases ─────────────────────────────────────
|
|
|
|
func TestGetMessages_ChannelNotFound(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "msgnotfound", 1)
|
|
|
|
rr := chGet(t, router, "/api/v1/channels/99999/messages", token)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Errorf("GetMessages channel not found: status = %d, want 404; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetMessages_InvalidChannelID(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "msgbadid", 1)
|
|
|
|
rr := chGet(t, router, "/api/v1/channels/abc/messages", token)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("GetMessages bad channel ID: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetMessages_WithBeforeParam(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "msgbefore", 1)
|
|
user, _ := database.GetUserByUsername(context.Background(), "msgbefore")
|
|
chID, _ := database.CreateChannel(context.Background(), "before-ch", "text", "", "", 0)
|
|
_, _ = database.CreateMessage(context.Background(), chID, user.ID, "msg one", nil)
|
|
msgID2, _ := database.CreateMessage(context.Background(), chID, user.ID, "msg two", nil)
|
|
|
|
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?before=%d", chID, msgID2), token)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("GetMessages before: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetMessages_WithCustomLimit(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "msglimitcust", 1)
|
|
chID, _ := database.CreateChannel(context.Background(), "limitch", "text", "", "", 0)
|
|
|
|
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=5", chID), token)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("GetMessages custom limit: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// ─── handleListChannels: member role filtering ──────────────────────────────
|
|
|
|
func TestListChannels_MemberRole(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "memberchanlist", 4)
|
|
_, _ = database.CreateChannel(context.Background(), "visible-ch", "text", "", "", 0)
|
|
|
|
rr := chGet(t, router, "/api/v1/channels", token)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("ListChannels member: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestListChannels_AdminSeesAll(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "adminchanlist", 2)
|
|
_, _ = database.CreateChannel(context.Background(), "admin-visible-ch", "text", "", "", 0)
|
|
|
|
rr := chGet(t, router, "/api/v1/channels", token)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("ListChannels admin: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
|
|
var resp []any
|
|
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
|
if len(resp) < 1 {
|
|
t.Errorf("Admin should see at least 1 channel, got %d", len(resp))
|
|
}
|
|
}
|
|
|
|
// ─── handleGetMessages: DM channel access ───────────────────────────────────
|
|
|
|
func TestGetMessages_DMChannel_NonParticipant(t *testing.T) {
|
|
database := newPinTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
_ = chTestCreateToken(t, database, "dmmsg_alice", 4)
|
|
_ = chTestCreateToken(t, database, "dmmsg_bob", 4)
|
|
tokenCharlie := chTestCreateToken(t, database, "dmmsg_charlie", 4)
|
|
alice, _ := database.GetUserByUsername(context.Background(), "dmmsg_alice")
|
|
bob, _ := database.GetUserByUsername(context.Background(), "dmmsg_bob")
|
|
|
|
dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), alice.ID, bob.ID)
|
|
|
|
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", dmCh.ID), tokenCharlie)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Errorf("GetMessages DM non-participant: status = %d, want 404; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetMessages_DMChannel_ParticipantSuccess(t *testing.T) {
|
|
database := newPinTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
tokenAlice := chTestCreateToken(t, database, "dmmsgok_alice", 4)
|
|
_ = chTestCreateToken(t, database, "dmmsgok_bob", 4)
|
|
alice, _ := database.GetUserByUsername(context.Background(), "dmmsgok_alice")
|
|
bob, _ := database.GetUserByUsername(context.Background(), "dmmsgok_bob")
|
|
|
|
dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), alice.ID, bob.ID)
|
|
|
|
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", dmCh.ID), tokenAlice)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("GetMessages DM participant: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// ─── handleSearch: DM channel search ────────────────────────────────────────
|
|
|
|
func TestSearch_DMChannelFilter_NonParticipant(t *testing.T) {
|
|
database := newPinTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
_ = chTestCreateToken(t, database, "dmsearch_alice", 4)
|
|
_ = chTestCreateToken(t, database, "dmsearch_bob", 4)
|
|
tokenCharlie := chTestCreateToken(t, database, "dmsearch_charlie", 4)
|
|
alice, _ := database.GetUserByUsername(context.Background(), "dmsearch_alice")
|
|
bob, _ := database.GetUserByUsername(context.Background(), "dmsearch_bob")
|
|
|
|
dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), alice.ID, bob.ID)
|
|
|
|
rr := chGet(t, router, fmt.Sprintf("/api/v1/search?q=test&channel_id=%d", dmCh.ID), tokenCharlie)
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Errorf("Search DM non-participant: status = %d, want 403; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSearch_NegativeChannelID_Push(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "searchnegch", 1)
|
|
|
|
rr := chGet(t, router, "/api/v1/search?q=test&channel_id=-1", token)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("Search negative channel_id: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// ─── searchRateLimitMiddleware: coverage via multiple rapid requests ─────────
|
|
|
|
func TestSearch_RateLimit(t *testing.T) {
|
|
database := newChannelTestDB(t)
|
|
router := buildChannelRouter(database)
|
|
token := chTestCreateToken(t, database, "searchrl", 1)
|
|
|
|
// Make many rapid search requests to trigger rate limiting.
|
|
var lastCode int
|
|
for range 25 {
|
|
rr := chGet(t, router, "/api/v1/search?q=ratelimittest", token)
|
|
lastCode = rr.Code
|
|
if lastCode == http.StatusTooManyRequests {
|
|
break
|
|
}
|
|
}
|
|
// We may or may not hit the rate limit depending on the config,
|
|
// but we exercise the middleware code path either way.
|
|
if lastCode >= 500 {
|
|
t.Errorf("Search rate limit: unexpected 5xx = %d", lastCode)
|
|
}
|
|
}
|