Files
OwnCord/Server/api/coverage_push_test.go
T
J3vbandClaude Opus 4.8 58005c9c6f feat(auth): revocable API tokens, introspect MCP server, and a Go 1.26 idiom pass (#1266)
* feat(auth): add revocable API tokens (bot/service auth)

Add long-lived, revocable API tokens so headless clients (the introspection
MCP tool, bots, CI) can authenticate without a password. Presented as
"Authorization: Bearer <token>", a token authenticates as a specific user,
inheriting that user's role and permissions.

- migration 018 + dedicated api_tokens table (kept separate from sessions so
  bulk logout and the per-user session cap never touch these); only the
  SHA-256 hash is stored, raw token shown once at creation
- auth.ResolveTokenHash: one shared bearer resolver that both AuthMiddleware
  and adminAuthMiddleware now call. Sessions are matched first so existing
  login behavior is unchanged; API tokens are a fallback only on session miss.
  A DB outage is returned wrapped, never mistaken for a bad token.
- `server token create|list|revoke` CLI: mints directly against the DB with no
  HTTP and no login — the password-free bootstrap path
- tests: resolver (8 cases incl. outage-not-fallthrough), db queries (6),
  api middleware integration (valid + revoked token)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(tools): add owncord-introspect MCP server

A local MCP dev tool that lets Claude Code introspect a running OwnCord
instance: read its logs, query any REST endpoint, and tail the desktop
client's log file. It is a thin wrapper over the existing API plus the
client log — no new product surface.

- tools/mcp-introspect/index.mjs (Node/ESM, one dep: @modelcontextprotocol/sdk)
  exposes api_request (full read-write passthrough), server_logs (admin SSE
  ring-buffer stream), client_logs (reads the desktop log file)
- authenticates with an API token (OWNCORD_API_TOKEN); pins the self-signed
  cert and skips hostname checks (the cert has no SAN)
- registered in .mcp.json (secret-free ${OWNCORD_API_TOKEN})
- un-ignore tools/mcp-introspect/ so this shared dev tool is committed, while
  tools/livekit-server.exe and node_modules stay ignored
- docs/mcp-introspect.md: how it works, tool reference, setup, troubleshooting

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dependencies): update and add various crate versions in Cargo.lock

* feat(admin): manage API tokens from the admin panel

Add Owner-gated HTTP endpoints and a UI card to create, list, and revoke
API tokens from the web admin panel. Previously only the `server token`
CLI could manage them, which requires shell access to the host.

- POST|GET|DELETE /admin/api/tokens in admin/handlers_tokens.go, wired in
  admin/api.go. All three are Owner-only (ownerOnlyMiddleware, like
  backups/updates): an HTTP token-mint endpoint is a network-reachable
  credential-minting surface, and API tokens deliberately survive password
  change + bulk logout, so a hijacked admin session must not mint one.
- Reuses the same db.*APIToken calls as the CLI; create sources the actor
  from request context (audits who clicked, not the bound user); the raw
  token is returned once in the 201 body, never stored.
- Add json tags to db.APITokenListItem for snake_case wire consistency.
- Admin panel: "API Tokens" nav item + create modal, show-once reveal,
  revoke confirm in admin/static/index.html.
- Tests: 7 in admin/api_test.go (+api_tokens table in the in-memory schema).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: modernize to Go 1.26 idioms + enable modernize linter

Apply `golangci-lint modernize` autofixes across the server and enable the
linter in .golangci.yml so these stop re-accumulating (they built up only
because modernize was never in the config).

Production code: slices.Contains for hand-rolled membership loops (api
router, ws origin, db/account, plugin manifest); strings.SplitSeq for
allocation-free line/segment iteration (db/migrate, updater, livekit_proxy);
strings.Cut (config); fmt.Appendf (dm_handler); min() (event_pruner);
any (ws client). Tests: range-over-int, t.Context(), WaitGroup.Go,
slices.Sort, maps.Copy, new(expr), interface{}->any.

- plugin/manifest.go parent-traversal check applied by hand: modernize
  skipped it (two conflicting rewrites); used the slices.Contains form.
- Removed the now-dead ptr() test helper after newexpr inlined its callers.
- Dropped dangling sort imports left by the sort.Slice->slices.Sort rewrite.

No behavior change. All four tag variants build, full test suite is green,
and golangci-lint (with modernize enabled) reports 0 issues.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 13:25:46 +02:00

1173 lines
44 KiB
Go

package api_test
// coverage_push_test.go adds tests for functions with low coverage
// to push the api package above 80%.
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/owncord/server/api"
"github.com/owncord/server/auth"
"github.com/owncord/server/service"
)
// ─── handleCreateInvite: malformed JSON body ────────────────────────────────
func TestCreateInvite_MalformedJSON(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildInviteRouter(database, limiter)
token := loginAndGetToken(t, router, database, "malformedinvite", 2)
req := httptest.NewRequest(http.MethodPost, "/api/v1/invites",
bytes.NewReader([]byte(`{invalid json`)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("CreateInvite malformed JSON: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
}
func TestCreateInvite_WithExpiration(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildInviteRouter(database, limiter)
token := loginAndGetToken(t, router, database, "expireinvite", 2)
rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{
"max_uses": 10,
"expires_in_hours": 24,
})
if rr.Code != http.StatusCreated {
t.Errorf("CreateInvite with expiry: status = %d, want 201; body = %s", rr.Code, rr.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if resp["expires_at"] == nil {
t.Error("CreateInvite with expiry: expected expires_at to be set")
}
}
// ─── handleEnableTOTP: already enabled ──────────────────────────────────────
func TestEnableTOTP_AlreadyEnabled(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
token := loginAndGetToken(t, router, database, "alreadytotp", 4)
// Enable TOTP first.
rr := postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", token,
map[string]string{"password": "Password1!"})
if rr.Code != http.StatusOK {
t.Fatalf("enable: status = %d; body = %s", rr.Code, rr.Body.String())
}
var enableResp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&enableResp)
secret := extractSecretFromURI(t, enableResp["qr_uri"].(string))
// Confirm TOTP.
code, _ := auth.GenerateTOTPCode(secret, time.Now().UTC())
rr = postJSONWithToken(t, router, "/api/v1/users/me/totp/confirm", token,
map[string]string{"password": "Password1!", "code": code})
if rr.Code != http.StatusNoContent {
t.Fatalf("confirm: status = %d; body = %s", rr.Code, rr.Body.String())
}
// Try enabling again — should get 409 Conflict.
rr = postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", token,
map[string]string{"password": "Password1!"})
if rr.Code != http.StatusConflict {
t.Errorf("enable-totp already enabled: status = %d, want 409; body = %s", rr.Code, rr.Body.String())
}
}
// ─── handleEnableTOTP: malformed body ───────────────────────────────────────
func TestEnableTOTP_MalformedBody(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
token := loginAndGetToken(t, router, database, "enablemalformed", 4)
req := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/totp/enable",
bytes.NewReader([]byte(`{invalid`)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("enable-totp malformed body: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
}
// ─── handleConfirmTOTP: malformed body ──────────────────────────────────────
func TestConfirmTOTP_MalformedBody(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
token := loginAndGetToken(t, router, database, "confirmmalformed", 4)
req := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/totp/confirm",
bytes.NewReader([]byte(`{invalid`)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("confirm-totp malformed body: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
}
// ─── handleDisableTOTP: malformed body ──────────────────────────────────────
func TestDisableTOTP_MalformedBody(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
token := loginAndGetToken(t, router, database, "disablemalformed", 4)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/users/me/totp",
bytes.NewReader([]byte(`{invalid`)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("disable-totp malformed body: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
}
// ─── handleUpdateProfile: malformed body ────────────────────────────────────
func TestUpdateProfile_MalformedBody(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "profilemalformed", 4)
req := httptest.NewRequest(http.MethodPatch, "/api/v1/users/me",
bytes.NewReader([]byte(`{invalid`)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("UpdateProfile malformed: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
}
// ─── handleUpdateProfile: invalid username (too short/long or special chars) ─
func TestUpdateProfile_InvalidUsername(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "validuser1", 4)
// Username with only spaces → empty after trim.
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
"username": " ",
})
if rr.Code != http.StatusBadRequest {
t.Errorf("UpdateProfile spaces-only: status = %d, want 400", rr.Code)
}
// Username that is too short (single char) — might fail ValidateUsername.
rr = patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
"username": "a",
})
// This should be 400 if ValidateUsername rejects it, or 200 if it accepts 1-char names.
// Either way, we hit the validation code path.
if rr.Code != http.StatusBadRequest && rr.Code != http.StatusOK {
t.Errorf("UpdateProfile short username: unexpected status = %d", rr.Code)
}
}
// ─── handleUpdateProfile: avatar sanitisation ───────────────────────────────
func TestUpdateProfile_WithAvatar(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "avataruser", 4)
avatar := "https://example.com/avatar.png"
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]any{
"username": "avataruser2",
"avatar": avatar,
})
if rr.Code != http.StatusOK {
t.Errorf("UpdateProfile with avatar: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
}
func TestUpdateProfile_NullAvatar(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "nullavuser", 4)
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]any{
"username": "nullavuser2",
"avatar": nil,
})
if rr.Code != http.StatusOK {
t.Errorf("UpdateProfile null avatar: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
}
// ─── handleChangePassword: malformed body ───────────────────────────────────
func TestChangePassword_MalformedBody(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "chpwmalformed", 4)
req := httptest.NewRequest(http.MethodPut, "/api/v1/users/me/password",
bytes.NewReader([]byte(`{invalid`)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("ChangePassword malformed: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
}
// ─── handleChangePassword: missing fields ───────────────────────────────────
func TestChangePassword_MissingOldPassword(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "chpwmissold", 4)
rr := putJSON(t, router, "/api/v1/users/me/password", token, map[string]string{
"old_password": "",
"new_password": "newSecure2",
})
if rr.Code != http.StatusBadRequest {
t.Errorf("ChangePassword missing old: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
}
func TestChangePassword_MissingNewPassword(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "chpwmissnew", 4)
rr := putJSON(t, router, "/api/v1/users/me/password", token, map[string]string{
"old_password": "securePass1",
"new_password": "",
})
if rr.Code != http.StatusBadRequest {
t.Errorf("ChangePassword missing new: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
}
// ─── handleRevokeSession: invalid ID format ─────────────────────────────────
func TestRevokeSession_InvalidID(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "revokebadfmt", 4)
rr := profileDelete(t, router, "/api/v1/users/me/sessions/abc", token)
if rr.Code != http.StatusBadRequest {
t.Errorf("RevokeSession bad ID: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
}
func TestRevokeSession_NegativeID(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "revokenegid", 4)
rr := profileDelete(t, router, "/api/v1/users/me/sessions/-1", token)
// Negative IDs should return 400 or 404.
if rr.Code != http.StatusBadRequest && rr.Code != http.StatusNotFound {
t.Errorf("RevokeSession negative ID: status = %d, want 400 or 404", rr.Code)
}
}
// ─── handleLiveKitHealth via exported test helper ───────────────────────────
func TestLiveKitHealth_OK(t *testing.T) {
handler := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) {
return true, nil
})
req := httptest.NewRequest(http.MethodGet, "/api/v1/health/livekit", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("LiveKitHealth OK: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if resp["status"] != "ok" {
t.Errorf("LiveKitHealth OK: status = %v, want 'ok'", resp["status"])
}
if resp["livekit_reachable"] != true {
t.Errorf("LiveKitHealth OK: livekit_reachable = %v, want true", resp["livekit_reachable"])
}
}
func TestLiveKitHealth_Degraded_WithError(t *testing.T) {
handler := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) {
return false, errors.New("connection refused")
})
req := httptest.NewRequest(http.MethodGet, "/api/v1/health/livekit", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusServiceUnavailable {
t.Errorf("LiveKitHealth degraded: status = %d, want 503; body = %s", rr.Code, rr.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if resp["status"] != "degraded" {
t.Errorf("LiveKitHealth degraded: status = %v, want 'degraded'", resp["status"])
}
if resp["error"] != "connection refused" {
t.Errorf("LiveKitHealth degraded: error = %v, want 'connection refused'", resp["error"])
}
}
func TestLiveKitHealth_Degraded_NilError(t *testing.T) {
handler := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) {
return false, nil
})
req := httptest.NewRequest(http.MethodGet, "/api/v1/health/livekit", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusServiceUnavailable {
t.Errorf("LiveKitHealth nil error: status = %d, want 503", rr.Code)
}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if resp["error"] != "unknown" {
t.Errorf("LiveKitHealth nil error: error = %v, want 'unknown'", resp["error"])
}
}
// ─── handleListSessions: unauthenticated ────────────────────────────────────
func TestListSessions_BadToken(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
req := httptest.NewRequest(http.MethodGet, "/api/v1/users/me/sessions", nil)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("ListSessions no token: status = %d, want 401", rr.Code)
}
}
// ─── handleRevokeSession: unauthenticated ───────────────────────────────────
func TestRevokeSession_Unauthorized(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
rr := profileDelete(t, router, "/api/v1/users/me/sessions/1", "badtoken")
if rr.Code != http.StatusUnauthorized {
t.Errorf("RevokeSession unauthorized: status = %d, want 401", rr.Code)
}
}
// ─── handleChangePassword: unauthorized ─────────────────────────────────────
func TestChangePassword_Unauthorized(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
rr := putJSON(t, router, "/api/v1/users/me/password", "badtoken", map[string]string{
"old_password": "securePass1",
"new_password": "newSecure2",
})
if rr.Code != http.StatusUnauthorized {
t.Errorf("ChangePassword unauthorized: status = %d, want 401", rr.Code)
}
}
// ─── handleUpdateProfile: unauthorized ──────────────────────────────────────
func TestUpdateProfile_NoToken(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
req := httptest.NewRequest(http.MethodPatch, "/api/v1/users/me",
bytes.NewReader([]byte(`{"username":"hack"}`)))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("UpdateProfile no token: status = %d, want 401", rr.Code)
}
}
// ─── handleListInvites: member forbidden ────────────────────────────────────
func TestListInvites_MemberForbidden(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildInviteRouter(database, limiter)
token := loginAndGetToken(t, router, database, "listmember", 4)
req := httptest.NewRequest(http.MethodGet, "/api/v1/invites", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("ListInvites member: status = %d, want 403", rr.Code)
}
}
// ─── handleCloseDM: broadcaster returns false (user not connected) ──────────
type offlineBroadcaster struct{}
func (b *offlineBroadcaster) SendToUser(_ int64, _ []byte) bool {
return false
}
func TestCloseDM_BroadcasterUserOffline(t *testing.T) {
database := newDMTestDB(t)
broadcaster := &offlineBroadcaster{}
router := buildDMRouter(database, broadcaster)
tokenAlice := dmCreateToken(t, database, "offline_alice", 4)
_ = dmCreateToken(t, database, "offline_bob", 4)
bob, _ := database.GetUserByUsername(context.Background(), "offline_bob")
// Create a DM.
rr := dmPost(t, router, "/api/v1/dms", tokenAlice, map[string]any{
"recipient_id": bob.ID,
})
if rr.Code != http.StatusCreated {
t.Fatalf("setup: status = %d", rr.Code)
}
var createResp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&createResp)
channelID := createResp["channel_id"]
// Close — broadcaster returns false (user offline).
rr2 := dmDelete(t, router, fmt.Sprintf("/api/v1/dms/%v", channelID), tokenAlice)
if rr2.Code != http.StatusNoContent {
t.Errorf("CloseDM offline: status = %d, want 204; body = %s", rr2.Code, rr2.Body.String())
}
}
// ─── handleSearch / isInvalidSearchQueryError coverage ──────────────────────
// These tests exercise the search endpoint with various query patterns to cover
// isInvalidSearchQueryError and handleSearch edge cases.
func TestSearch_EmptyQuery(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "searchempty", 1)
rr := chGet(t, router, "/api/v1/search?q=", token)
// Empty query should return 400 or 200 with empty results.
if rr.Code != http.StatusBadRequest && rr.Code != http.StatusOK {
t.Errorf("Search empty: unexpected status = %d; body = %s", rr.Code, rr.Body.String())
}
}
func TestSearch_SpecialCharacters(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "searchspecial", 1)
// These queries may trigger FTS5 syntax errors which isInvalidSearchQueryError handles.
queries := []string{
`"unterminated string`,
`test OR`,
`test AND`,
`*`,
`test"`,
}
for _, q := range queries {
rr := chGet(t, router, "/api/v1/search?q="+url.QueryEscape(q), token)
// Should get 400 (invalid query) or 200 (handled gracefully).
if rr.Code >= 500 {
t.Errorf("Search %q: unexpected 5xx status = %d; body = %s", q, rr.Code, rr.Body.String())
}
}
}
// ─── handleListChannels: unauthorized ───────────────────────────────────────
func TestListChannels_Unauthorized(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
req := httptest.NewRequest(http.MethodGet, "/api/v1/channels", nil)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("ListChannels no auth: status = %d, want 401", rr.Code)
}
}
// ─── handleGetMessages: edge cases ──────────────────────────────────────────
func TestGetMessages_InvalidLimit(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "msglimit", 1)
chID, _ := database.CreateChannel(context.Background(), "limit-ch", "text", "", "", 0)
// Negative limit.
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=-1", chID), token)
if rr.Code >= 500 {
t.Errorf("GetMessages negative limit: unexpected 5xx status = %d", rr.Code)
}
// Limit > 100.
rr = chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=999", chID), token)
if rr.Code != http.StatusOK {
t.Errorf("GetMessages limit=999: status = %d, want 200", rr.Code)
}
// With before parameter.
rr = chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?before=999999", chID), token)
if rr.Code != http.StatusOK {
t.Errorf("GetMessages with before: status = %d, want 200", rr.Code)
}
}
// ─── handleGetPins: basic and unauthorized ──────────────────────────────────
func TestGetPins_Success(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "pinuser", 1)
chID, _ := database.CreateChannel(context.Background(), "pin-ch", "text", "", "", 0)
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/pins", chID), token)
if rr.Code != http.StatusOK {
t.Errorf("GetPins: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
}
func TestGetPins_Unauthorized(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
chID, _ := database.CreateChannel(context.Background(), "pin-unauth-ch", "text", "", "", 0)
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/channels/%d/pins", chID), nil)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("GetPins unauthorized: status = %d, want 401", rr.Code)
}
}
// ─── handleSetPinned: unauthorized and invalid ──────────────────────────────
func TestSetPinned_Unauthorized(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
chID, _ := database.CreateChannel(context.Background(), "setpin-ch", "text", "", "", 0)
req := httptest.NewRequest(http.MethodPut,
fmt.Sprintf("/api/v1/channels/%d/messages/1/pin", chID),
bytes.NewReader([]byte(`{"pinned":true}`)))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("SetPinned unauthorized: status = %d, want 401", rr.Code)
}
}
// ─── writeJSON: verify JSON encoding corner case ────────────────────────────
// writeJSON is at 75% — testing the success path covers the rest.
func TestWriteJSON_BasicSuccess(t *testing.T) {
handler := api.HandleLiveKitHealthForTest(func(_ context.Context) (bool, error) {
return true, nil
})
req := httptest.NewRequest(http.MethodGet, "/test", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if ct := rr.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Errorf("writeJSON Content-Type = %q, want application/json", ct)
}
}
// ─── helpers: buildChannelRouter, chTestCreateToken, chGet ──────────────────
// These are defined in channel_handler_test.go but we reference them here.
// They use newChannelTestDB which is also in that file.
// Verify all helper functions are accessible (compile check).
var (
_ = newChannelTestDB
_ = buildChannelRouter
_ = chTestCreateToken
_ = chGet
)
// ─── handleRevokeInvite: already revoked ────────────────────────────────────
func TestRevokeInvite_AlreadyRevoked(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildInviteRouter(database, limiter)
token := loginAndGetToken(t, router, database, "revoketwice", 2)
// Create invite.
rr := postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{})
if rr.Code != http.StatusCreated {
t.Fatalf("setup: status = %d", rr.Code)
}
var created map[string]any
_ = json.NewDecoder(rr.Body).Decode(&created)
code := created["code"].(string)
// Revoke it once.
req := httptest.NewRequest(http.MethodDelete, "/api/v1/invites/"+code, nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:9999"
rr2 := httptest.NewRecorder()
router.ServeHTTP(rr2, req)
if rr2.Code != http.StatusNoContent {
t.Fatalf("first revoke: status = %d", rr2.Code)
}
// Revoke it again — should still succeed (idempotent) or return error.
req = httptest.NewRequest(http.MethodDelete, "/api/v1/invites/"+code, nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:9999"
rr3 := httptest.NewRecorder()
router.ServeHTTP(rr3, req)
// Should not be 500.
if rr3.Code >= 500 {
t.Errorf("RevokeInvite already revoked: unexpected 5xx = %d; body = %s", rr3.Code, rr3.Body.String())
}
}
// ─── handleListSessions: multiple sessions ──────────────────────────────────
func TestListSessions_MultipleSessions(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "multisess", 4)
// Create additional session.
user, _ := database.GetUserByUsername(context.Background(), "multisess")
_, _ = database.CreateSession(context.Background(), user.ID, auth.HashToken("extra-token"), "Chrome", "1.2.3.4")
rr := getWithToken(t, router, "/api/v1/users/me/sessions", token)
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rr.Code)
}
var resp struct {
Sessions []map[string]any `json:"sessions"`
}
_ = json.NewDecoder(rr.Body).Decode(&resp)
if len(resp.Sessions) < 2 {
t.Errorf("expected >= 2 sessions, got %d", len(resp.Sessions))
}
}
// ─── handleListDMs: with token but invalid ──────────────────────────────────
func TestListDMs_InvalidToken(t *testing.T) {
database := newDMTestDB(t)
router := buildDMRouter(database, nil)
rr := dmGet(t, router, "/api/v1/dms", "invalid-token-xxx")
if rr.Code != http.StatusUnauthorized {
t.Errorf("ListDMs invalid token: status = %d, want 401", rr.Code)
}
}
// ─── buildAuthRouter with profile routes for combined testing ───────────────
func buildCombinedRouter(t *testing.T) (http.Handler, *auth.RateLimiter, string) {
t.Helper()
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
r := chi.NewRouter()
svc := service.New(database, limiter)
api.MountAuthRoutes(r, database, limiter, nil, testTOTPKey)
api.MountProfileRoutes(r, database, svc, limiter, nil, nil)
api.MountInviteRoutes(r, database, svc)
token := loginAndGetToken(t, r, database, "combined1", 2)
return r, limiter, token
}
func TestCombinedRouter_ProfileAndInvites(t *testing.T) {
router, _, token := buildCombinedRouter(t)
// Profile update.
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
"username": "combined_newname",
})
if rr.Code != http.StatusOK {
t.Errorf("Combined profile: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
// Create invite.
rr = postJSONWithToken(t, router, "/api/v1/invites", token, map[string]any{
"max_uses": 5,
})
if rr.Code != http.StatusCreated {
t.Errorf("Combined invite: status = %d, want 201; body = %s", rr.Code, rr.Body.String())
}
}
// ─── handleSetPinned: message not found ─────────────────────────────────────
func TestSetPinned_MessageNotFound_Push(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "pinmissmsg", 1)
chID, _ := database.CreateChannel(context.Background(), "pinmiss-ch", "text", "", "", 0)
rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID, 99999), token)
if rr.Code != http.StatusNotFound {
t.Errorf("SetPinned missing message: status = %d, want 404; body = %s", rr.Code, rr.Body.String())
}
}
func TestSetPinned_ChannelNotFound_Push(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "pinmissch", 1)
rr := chPost(t, router, "/api/v1/channels/99999/pins/1", token)
if rr.Code != http.StatusNotFound {
t.Errorf("SetPinned missing channel: status = %d, want 404; body = %s", rr.Code, rr.Body.String())
}
}
func TestSetPinned_InvalidChannelID(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "pinbadid", 1)
rr := chPost(t, router, "/api/v1/channels/abc/pins/1", token)
if rr.Code != http.StatusBadRequest {
t.Errorf("SetPinned bad channel ID: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
}
func TestSetPinned_InvalidMessageID(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "pinbadmsg", 1)
chID, _ := database.CreateChannel(context.Background(), "badmsgid-ch", "text", "", "", 0)
rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/abc", chID), token)
if rr.Code != http.StatusBadRequest {
t.Errorf("SetPinned bad message ID: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
}
func TestUnpin_Success(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "unpinner", 1)
user, _ := database.GetUserByUsername(context.Background(), "unpinner")
chID, _ := database.CreateChannel(context.Background(), "unpin-ch", "text", "", "", 0)
msgID, _ := database.CreateMessage(context.Background(), chID, user.ID, "to unpin", nil)
_ = database.SetMessagePinned(context.Background(), msgID, true)
rr := chDelete(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID, msgID), token)
if rr.Code != http.StatusNoContent {
t.Errorf("Unpin: status = %d, want 204; body = %s", rr.Code, rr.Body.String())
}
}
func TestSetPinned_MemberForbidden(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "pinmember", 4)
user, _ := database.GetUserByUsername(context.Background(), "pinmember")
chID, _ := database.CreateChannel(context.Background(), "pinforbid-ch", "text", "", "", 0)
msgID, _ := database.CreateMessage(context.Background(), chID, user.ID, "cant pin", nil)
rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID, msgID), token)
if rr.Code != http.StatusForbidden {
t.Errorf("SetPinned member: status = %d, want 403; body = %s", rr.Code, rr.Body.String())
}
}
func TestSetPinned_WrongChannel(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "pinwrongch", 1)
user, _ := database.GetUserByUsername(context.Background(), "pinwrongch")
chID1, _ := database.CreateChannel(context.Background(), "pin-ch1", "text", "", "", 0)
chID2, _ := database.CreateChannel(context.Background(), "pin-ch2", "text", "", "", 0)
msgID, _ := database.CreateMessage(context.Background(), chID1, user.ID, "wrong channel", nil)
// Try to pin a message from chID1 using chID2.
rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID2, msgID), token)
if rr.Code != http.StatusNotFound {
t.Errorf("SetPinned wrong channel: status = %d, want 404; body = %s", rr.Code, rr.Body.String())
}
}
// ─── handleSetPinned: DM channel pin ────────────────────────────────────────
func TestSetPinned_DMChannel_ParticipantSuccess(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
tokenAlice := chTestCreateToken(t, database, "dmpin_alice", 4)
_ = chTestCreateToken(t, database, "dmpin_bob", 4)
alice, _ := database.GetUserByUsername(context.Background(), "dmpin_alice")
bob, _ := database.GetUserByUsername(context.Background(), "dmpin_bob")
dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), alice.ID, bob.ID)
msgID, _ := database.CreateMessage(context.Background(), dmCh.ID, alice.ID, "pin this dm msg", nil)
rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", dmCh.ID, msgID), tokenAlice)
if rr.Code != http.StatusNoContent {
t.Errorf("SetPinned DM participant: status = %d, want 204; body = %s", rr.Code, rr.Body.String())
}
}
func TestSetPinned_DMChannel_NonParticipantForbidden(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
_ = chTestCreateToken(t, database, "dmpinforbid_alice", 4)
_ = chTestCreateToken(t, database, "dmpinforbid_bob", 4)
tokenCharlie := chTestCreateToken(t, database, "dmpinforbid_charlie", 4)
alice, _ := database.GetUserByUsername(context.Background(), "dmpinforbid_alice")
bob, _ := database.GetUserByUsername(context.Background(), "dmpinforbid_bob")
dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), alice.ID, bob.ID)
msgID, _ := database.CreateMessage(context.Background(), dmCh.ID, alice.ID, "secret msg", nil)
rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", dmCh.ID, msgID), tokenCharlie)
if rr.Code != http.StatusNotFound {
t.Errorf("SetPinned DM non-participant: status = %d, want 404; body = %s", rr.Code, rr.Body.String())
}
}
// ─── handleSearch: with channel_id filter ───────────────────────────────────
func TestSearch_WithChannelID_Push(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "searchch", 1)
user, _ := database.GetUserByUsername(context.Background(), "searchch")
chID, _ := database.CreateChannel(context.Background(), "search-ch1", "text", "", "", 0)
_, _ = database.CreateMessage(context.Background(), chID, user.ID, "findable in channel", nil)
rr := chGet(t, router, fmt.Sprintf("/api/v1/search?q=findable&channel_id=%d", chID), token)
if rr.Code != http.StatusOK {
t.Errorf("Search with channel_id: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
}
func TestSearch_WithInvalidChannelID(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "searchbadch", 1)
rr := chGet(t, router, "/api/v1/search?q=test&channel_id=abc", token)
if rr.Code != http.StatusBadRequest {
t.Errorf("Search invalid channel_id: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
}
func TestSearch_WithNonexistentChannelID(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "searchmissch", 1)
rr := chGet(t, router, "/api/v1/search?q=test&channel_id=99999", token)
if rr.Code != http.StatusNotFound {
t.Errorf("Search nonexistent channel: status = %d, want 404; body = %s", rr.Code, rr.Body.String())
}
}
func TestSearch_WithLimit_Push(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "searchlimit", 1)
rr := chGet(t, router, "/api/v1/search?q=test&limit=10", token)
if rr.Code != http.StatusOK {
t.Errorf("Search with limit: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
}
func TestSearch_WithInvalidLimit(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "searchbadlimit", 1)
rr := chGet(t, router, "/api/v1/search?q=test&limit=abc", token)
if rr.Code != http.StatusBadRequest {
t.Errorf("Search invalid limit: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
}
func TestSearch_WithNegativeLimit(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "searchneglimit", 1)
rr := chGet(t, router, "/api/v1/search?q=test&limit=-1", token)
if rr.Code != http.StatusBadRequest {
t.Errorf("Search negative limit: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
}
func TestSearch_WithOverMaxLimit(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "searchmaxlimit", 1)
rr := chGet(t, router, "/api/v1/search?q=test&limit=999", token)
if rr.Code != http.StatusOK {
t.Errorf("Search over max limit: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
}
func TestSearch_Unauthorized(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
rr := chGet(t, router, "/api/v1/search?q=test", "")
if rr.Code != http.StatusUnauthorized {
t.Errorf("Search unauthorized: status = %d, want 401; body = %s", rr.Code, rr.Body.String())
}
}
// ─── handleGetMessages: more edge cases ─────────────────────────────────────
func TestGetMessages_ChannelNotFound(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "msgnotfound", 1)
rr := chGet(t, router, "/api/v1/channels/99999/messages", token)
if rr.Code != http.StatusNotFound {
t.Errorf("GetMessages channel not found: status = %d, want 404; body = %s", rr.Code, rr.Body.String())
}
}
func TestGetMessages_InvalidChannelID(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "msgbadid", 1)
rr := chGet(t, router, "/api/v1/channels/abc/messages", token)
if rr.Code != http.StatusBadRequest {
t.Errorf("GetMessages bad channel ID: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
}
func TestGetMessages_WithBeforeParam(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "msgbefore", 1)
user, _ := database.GetUserByUsername(context.Background(), "msgbefore")
chID, _ := database.CreateChannel(context.Background(), "before-ch", "text", "", "", 0)
_, _ = database.CreateMessage(context.Background(), chID, user.ID, "msg one", nil)
msgID2, _ := database.CreateMessage(context.Background(), chID, user.ID, "msg two", nil)
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?before=%d", chID, msgID2), token)
if rr.Code != http.StatusOK {
t.Errorf("GetMessages before: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
}
func TestGetMessages_WithCustomLimit(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "msglimitcust", 1)
chID, _ := database.CreateChannel(context.Background(), "limitch", "text", "", "", 0)
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=5", chID), token)
if rr.Code != http.StatusOK {
t.Errorf("GetMessages custom limit: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
}
// ─── handleListChannels: member role filtering ──────────────────────────────
func TestListChannels_MemberRole(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "memberchanlist", 4)
_, _ = database.CreateChannel(context.Background(), "visible-ch", "text", "", "", 0)
rr := chGet(t, router, "/api/v1/channels", token)
if rr.Code != http.StatusOK {
t.Errorf("ListChannels member: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
}
func TestListChannels_AdminSeesAll(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "adminchanlist", 2)
_, _ = database.CreateChannel(context.Background(), "admin-visible-ch", "text", "", "", 0)
rr := chGet(t, router, "/api/v1/channels", token)
if rr.Code != http.StatusOK {
t.Errorf("ListChannels admin: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
var resp []any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if len(resp) < 1 {
t.Errorf("Admin should see at least 1 channel, got %d", len(resp))
}
}
// ─── handleGetMessages: DM channel access ───────────────────────────────────
func TestGetMessages_DMChannel_NonParticipant(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
_ = chTestCreateToken(t, database, "dmmsg_alice", 4)
_ = chTestCreateToken(t, database, "dmmsg_bob", 4)
tokenCharlie := chTestCreateToken(t, database, "dmmsg_charlie", 4)
alice, _ := database.GetUserByUsername(context.Background(), "dmmsg_alice")
bob, _ := database.GetUserByUsername(context.Background(), "dmmsg_bob")
dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), alice.ID, bob.ID)
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", dmCh.ID), tokenCharlie)
if rr.Code != http.StatusNotFound {
t.Errorf("GetMessages DM non-participant: status = %d, want 404; body = %s", rr.Code, rr.Body.String())
}
}
func TestGetMessages_DMChannel_ParticipantSuccess(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
tokenAlice := chTestCreateToken(t, database, "dmmsgok_alice", 4)
_ = chTestCreateToken(t, database, "dmmsgok_bob", 4)
alice, _ := database.GetUserByUsername(context.Background(), "dmmsgok_alice")
bob, _ := database.GetUserByUsername(context.Background(), "dmmsgok_bob")
dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), alice.ID, bob.ID)
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", dmCh.ID), tokenAlice)
if rr.Code != http.StatusOK {
t.Errorf("GetMessages DM participant: status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
}
// ─── handleSearch: DM channel search ────────────────────────────────────────
func TestSearch_DMChannelFilter_NonParticipant(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
_ = chTestCreateToken(t, database, "dmsearch_alice", 4)
_ = chTestCreateToken(t, database, "dmsearch_bob", 4)
tokenCharlie := chTestCreateToken(t, database, "dmsearch_charlie", 4)
alice, _ := database.GetUserByUsername(context.Background(), "dmsearch_alice")
bob, _ := database.GetUserByUsername(context.Background(), "dmsearch_bob")
dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), alice.ID, bob.ID)
rr := chGet(t, router, fmt.Sprintf("/api/v1/search?q=test&channel_id=%d", dmCh.ID), tokenCharlie)
if rr.Code != http.StatusForbidden {
t.Errorf("Search DM non-participant: status = %d, want 403; body = %s", rr.Code, rr.Body.String())
}
}
func TestSearch_NegativeChannelID_Push(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "searchnegch", 1)
rr := chGet(t, router, "/api/v1/search?q=test&channel_id=-1", token)
if rr.Code != http.StatusBadRequest {
t.Errorf("Search negative channel_id: status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
}
// ─── searchRateLimitMiddleware: coverage via multiple rapid requests ─────────
func TestSearch_RateLimit(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "searchrl", 1)
// Make many rapid search requests to trigger rate limiting.
var lastCode int
for range 25 {
rr := chGet(t, router, "/api/v1/search?q=ratelimittest", token)
lastCode = rr.Code
if lastCode == http.StatusTooManyRequests {
break
}
}
// We may or may not hit the rate limit depending on the config,
// but we exercise the middleware code path either way.
if lastCode >= 500 {
t.Errorf("Search rate limit: unexpected 5xx = %d", lastCode)
}
}