mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
M1 — TOTP secrets are now AES-256-GCM encrypted before being stored in the database. Key is auto-generated on first run (data/totp.key) or set via OWNCORD_TOTP_KEY env var. Existing plaintext secrets are detected and returned as-is for backwards compatibility. M3 — Replay buffer events are now tagged with their channel ID. On reconnect, the server computes the user's current accessible channels and only replays events from those channels. Global broadcasts (presence, voice state, member updates) are always replayed. Falls back to full ready payload if permission computation fails.
1170 lines
43 KiB
Go
1170 lines
43 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"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/owncord/server/api"
|
|
"github.com/owncord/server/auth"
|
|
)
|
|
|
|
// ─── 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]interface{}
|
|
_ = 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() (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() (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() (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("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("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("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("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("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() (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("multisess")
|
|
_, _ = database.CreateSession(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()
|
|
api.MountAuthRoutes(r, database, limiter, nil, testTOTPKey)
|
|
api.MountProfileRoutes(r, database, limiter, nil)
|
|
api.MountInviteRoutes(r, database)
|
|
|
|
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("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("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("unpinner")
|
|
chID, _ := database.CreateChannel("unpin-ch", "text", "", "", 0)
|
|
msgID, _ := database.CreateMessage(chID, user.ID, "to unpin", nil)
|
|
_ = database.SetMessagePinned(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("pinmember")
|
|
chID, _ := database.CreateChannel("pinforbid-ch", "text", "", "", 0)
|
|
msgID, _ := database.CreateMessage(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("pinwrongch")
|
|
chID1, _ := database.CreateChannel("pin-ch1", "text", "", "", 0)
|
|
chID2, _ := database.CreateChannel("pin-ch2", "text", "", "", 0)
|
|
msgID, _ := database.CreateMessage(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("dmpin_alice")
|
|
bob, _ := database.GetUserByUsername("dmpin_bob")
|
|
|
|
dmCh, _, _ := database.GetOrCreateDMChannel(alice.ID, bob.ID)
|
|
msgID, _ := database.CreateMessage(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("dmpinforbid_alice")
|
|
bob, _ := database.GetUserByUsername("dmpinforbid_bob")
|
|
|
|
dmCh, _, _ := database.GetOrCreateDMChannel(alice.ID, bob.ID)
|
|
msgID, _ := database.CreateMessage(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("searchch")
|
|
chID, _ := database.CreateChannel("search-ch1", "text", "", "", 0)
|
|
_, _ = database.CreateMessage(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("msgbefore")
|
|
chID, _ := database.CreateChannel("before-ch", "text", "", "", 0)
|
|
_, _ = database.CreateMessage(chID, user.ID, "msg one", nil)
|
|
msgID2, _ := database.CreateMessage(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("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("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("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("dmmsg_alice")
|
|
bob, _ := database.GetUserByUsername("dmmsg_bob")
|
|
|
|
dmCh, _, _ := database.GetOrCreateDMChannel(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("dmmsgok_alice")
|
|
bob, _ := database.GetUserByUsername("dmmsgok_bob")
|
|
|
|
dmCh, _, _ := database.GetOrCreateDMChannel(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("dmsearch_alice")
|
|
bob, _ := database.GetUserByUsername("dmsearch_bob")
|
|
|
|
dmCh, _, _ := database.GetOrCreateDMChannel(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 i := 0; i < 25; i++ {
|
|
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)
|
|
}
|
|
}
|