2026-04-01 11:38:11 +02:00
|
|
|
package api_test
|
|
|
|
|
|
|
|
|
|
// coverage_push_test.go adds tests for functions with low coverage
|
|
|
|
|
// to push the api package above 80%.
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
2026-04-03 23:18:06 +02:00
|
|
|
"context"
|
2026-04-01 11:38:11 +02:00
|
|
|
"encoding/json"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"net/url"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-08-26 20:23:49 +00:00
|
|
|
"github.com/J3vb/OwnCord/Server/api"
|
|
|
|
|
"github.com/J3vb/OwnCord/Server/auth"
|
|
|
|
|
"github.com/J3vb/OwnCord/Server/service"
|
2026-04-01 11:38:11 +02:00
|
|
|
"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())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 13:25:46 +02:00
|
|
|
var enableResp map[string]any
|
2026-04-01 11:38:11 +02:00
|
|
|
_ = 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) {
|
2026-04-03 23:18:06 +02:00
|
|
|
handler := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) {
|
2026-04-01 11:38:11 +02:00
|
|
|
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) {
|
2026-04-03 23:18:06 +02:00
|
|
|
handler := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) {
|
2026-04-01 11:38:11 +02:00
|
|
|
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) {
|
2026-04-03 23:18:06 +02:00
|
|
|
handler := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) {
|
2026-04-01 11:38:11 +02:00
|
|
|
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)
|
2026-07-23 17:03:52 +02:00
|
|
|
bob, _ := database.GetUserByUsername(context.Background(), "offline_bob")
|
2026-04-01 11:38:11 +02:00
|
|
|
|
|
|
|
|
// 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)
|
2026-07-23 17:03:52 +02:00
|
|
|
chID, _ := database.CreateChannel(context.Background(), "limit-ch", "text", "", "", 0)
|
2026-04-01 11:38:11 +02:00
|
|
|
|
|
|
|
|
// 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)
|
2026-07-23 17:03:52 +02:00
|
|
|
chID, _ := database.CreateChannel(context.Background(), "pin-ch", "text", "", "", 0)
|
2026-04-01 11:38:11 +02:00
|
|
|
|
|
|
|
|
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)
|
2026-07-23 17:03:52 +02:00
|
|
|
chID, _ := database.CreateChannel(context.Background(), "pin-unauth-ch", "text", "", "", 0)
|
2026-04-01 11:38:11 +02:00
|
|
|
|
|
|
|
|
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)
|
2026-07-23 17:03:52 +02:00
|
|
|
chID, _ := database.CreateChannel(context.Background(), "setpin-ch", "text", "", "", 0)
|
2026-04-01 11:38:11 +02:00
|
|
|
|
|
|
|
|
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) {
|
2026-04-03 23:18:06 +02:00
|
|
|
handler := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) {
|
2026-04-01 11:38:11 +02:00
|
|
|
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.
|
2026-07-23 17:03:52 +02:00
|
|
|
user, _ := database.GetUserByUsername(context.Background(), "multisess")
|
|
|
|
|
_, _ = database.CreateSession(context.Background(), user.ID, auth.HashToken("extra-token"), "Chrome", "1.2.3.4")
|
2026-04-01 11:38:11 +02:00
|
|
|
|
|
|
|
|
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()
|
2026-07-19 16:33:58 +00:00
|
|
|
svc := service.New(database, limiter)
|
2026-04-02 15:05:56 +02:00
|
|
|
api.MountAuthRoutes(r, database, limiter, nil, testTOTPKey)
|
2026-08-01 22:06:14 +02:00
|
|
|
api.MountProfileRoutes(r, database, svc, nil, limiter, nil, nil)
|
2026-04-06 22:48:59 +02:00
|
|
|
api.MountInviteRoutes(r, database, svc)
|
2026-04-01 11:38:11 +02:00
|
|
|
|
|
|
|
|
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)
|
2026-07-23 17:03:52 +02:00
|
|
|
chID, _ := database.CreateChannel(context.Background(), "pinmiss-ch", "text", "", "", 0)
|
2026-04-01 11:38:11 +02:00
|
|
|
|
|
|
|
|
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)
|
2026-07-23 17:03:52 +02:00
|
|
|
chID, _ := database.CreateChannel(context.Background(), "badmsgid-ch", "text", "", "", 0)
|
2026-04-01 11:38:11 +02:00
|
|
|
|
|
|
|
|
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)
|
2026-07-23 17:03:52 +02:00
|
|
|
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)
|
2026-04-01 11:38:11 +02:00
|
|
|
|
|
|
|
|
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)
|
2026-07-23 17:03:52 +02:00
|
|
|
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)
|
2026-04-01 11:38:11 +02:00
|
|
|
|
|
|
|
|
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)
|
2026-07-23 17:03:52 +02:00
|
|
|
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)
|
2026-04-01 11:38:11 +02:00
|
|
|
|
|
|
|
|
// 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)
|
2026-07-23 17:03:52 +02:00
|
|
|
alice, _ := database.GetUserByUsername(context.Background(), "dmpin_alice")
|
|
|
|
|
bob, _ := database.GetUserByUsername(context.Background(), "dmpin_bob")
|
2026-04-01 11:38:11 +02:00
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), alice.ID, bob.ID)
|
|
|
|
|
msgID, _ := database.CreateMessage(context.Background(), dmCh.ID, alice.ID, "pin this dm msg", nil)
|
2026-04-01 11:38:11 +02:00
|
|
|
|
|
|
|
|
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)
|
2026-07-23 17:03:52 +02:00
|
|
|
alice, _ := database.GetUserByUsername(context.Background(), "dmpinforbid_alice")
|
|
|
|
|
bob, _ := database.GetUserByUsername(context.Background(), "dmpinforbid_bob")
|
2026-04-01 11:38:11 +02:00
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), alice.ID, bob.ID)
|
|
|
|
|
msgID, _ := database.CreateMessage(context.Background(), dmCh.ID, alice.ID, "secret msg", nil)
|
2026-04-01 11:38:11 +02:00
|
|
|
|
|
|
|
|
rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", dmCh.ID, msgID), tokenCharlie)
|
2026-04-02 14:44:41 +02:00
|
|
|
if rr.Code != http.StatusNotFound {
|
|
|
|
|
t.Errorf("SetPinned DM non-participant: status = %d, want 404; body = %s", rr.Code, rr.Body.String())
|
2026-04-01 11:38:11 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── handleSearch: with channel_id filter ───────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestSearch_WithChannelID_Push(t *testing.T) {
|
|
|
|
|
database := newChannelTestDB(t)
|
|
|
|
|
router := buildChannelRouter(database)
|
|
|
|
|
token := chTestCreateToken(t, database, "searchch", 1)
|
2026-07-23 17:03:52 +02:00
|
|
|
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)
|
2026-04-01 11:38:11 +02:00
|
|
|
|
|
|
|
|
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)
|
2026-07-23 17:03:52 +02:00
|
|
|
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)
|
2026-04-01 11:38:11 +02:00
|
|
|
|
|
|
|
|
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)
|
2026-07-23 17:03:52 +02:00
|
|
|
chID, _ := database.CreateChannel(context.Background(), "limitch", "text", "", "", 0)
|
2026-04-01 11:38:11 +02:00
|
|
|
|
|
|
|
|
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)
|
2026-07-23 17:03:52 +02:00
|
|
|
_, _ = database.CreateChannel(context.Background(), "visible-ch", "text", "", "", 0)
|
2026-04-01 11:38:11 +02:00
|
|
|
|
|
|
|
|
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)
|
2026-07-23 17:03:52 +02:00
|
|
|
_, _ = database.CreateChannel(context.Background(), "admin-visible-ch", "text", "", "", 0)
|
2026-04-01 11:38:11 +02:00
|
|
|
|
|
|
|
|
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)
|
2026-07-23 17:03:52 +02:00
|
|
|
alice, _ := database.GetUserByUsername(context.Background(), "dmmsg_alice")
|
|
|
|
|
bob, _ := database.GetUserByUsername(context.Background(), "dmmsg_bob")
|
2026-04-01 11:38:11 +02:00
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), alice.ID, bob.ID)
|
2026-04-01 11:38:11 +02:00
|
|
|
|
|
|
|
|
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", dmCh.ID), tokenCharlie)
|
2026-04-02 14:44:41 +02:00
|
|
|
if rr.Code != http.StatusNotFound {
|
|
|
|
|
t.Errorf("GetMessages DM non-participant: status = %d, want 404; body = %s", rr.Code, rr.Body.String())
|
2026-04-01 11:38:11 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
2026-07-23 17:03:52 +02:00
|
|
|
alice, _ := database.GetUserByUsername(context.Background(), "dmmsgok_alice")
|
|
|
|
|
bob, _ := database.GetUserByUsername(context.Background(), "dmmsgok_bob")
|
2026-04-01 11:38:11 +02:00
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), alice.ID, bob.ID)
|
2026-04-01 11:38:11 +02:00
|
|
|
|
|
|
|
|
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)
|
2026-07-23 17:03:52 +02:00
|
|
|
alice, _ := database.GetUserByUsername(context.Background(), "dmsearch_alice")
|
|
|
|
|
bob, _ := database.GetUserByUsername(context.Background(), "dmsearch_bob")
|
2026-04-01 11:38:11 +02:00
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), alice.ID, bob.ID)
|
2026-04-01 11:38:11 +02:00
|
|
|
|
|
|
|
|
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
|
2026-07-29 13:25:46 +02:00
|
|
|
for range 25 {
|
2026-04-01 11:38:11 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|