Files
OwnCord/Server/api/channel_handler_test.go
T
J3vbandClaude Opus 5 17b17eb1b3 fix(security): close all 13 findings from the 2026-07-28 server scan, plus dependabot rollup (#1264)
* fix(admin): reject banned users in admin auth (F1)

adminAuthMiddleware accepted a Bearer token on session validity plus the
ADMINISTRATOR bit alone and never consulted ban state, so a ban never
revoked admin-panel access. Adds the auth.IsEffectivelyBanned guard that
api.AuthMiddleware already uses, at both admin credential-resolution points.

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

* fix(ws): gate the voice-channel text subscription on READ_MESSAGES (F2)

registerNow subscribed any client with voice state to that channel's
text-message topic regardless of READ_MESSAGES. The handshake's
already-computed readable-channel set is now passed into registerNow and the
subscription only happens when the voice channel is in it, preserving
authorized reconnect delivery.

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

* fix(service): require READ_MESSAGES to delete messages (F4)

The non-DM delete gate checked MANAGE_MESSAGES without READ_MESSAGES, so a
role locked out of a private channel could still delete every message in it.
Requires ReadMessages alongside ManageMessages (and alongside SendMessages on
the author path) and derives the mod flag from that same gate.

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

* fix(service): require READ_MESSAGES alongside MANAGE_MESSAGES in SetMessagePinned (F8)

Pin/unpin checked only MANAGE_MESSAGES, so a role denied READ on a private
channel could still pin and unpin its messages.

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

* fix(service): enforce the DM block at every DM interaction sink (F5)

The DM block was only checked on send, leaving edit, reactions, pins and
typing as bypasses. One shared requireDMNotBlocked is now called from all of
them.

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

* fix(ws): re-check CONNECT_VOICE when minting a refreshed LiveKit token (F6)

voice_token_refresh re-minted a LiveKit token without re-checking
CONNECT_VOICE, so a revoked permission kept working for the life of the
session. The permission is now re-checked where the token is minted, and a
60s sweep evicts participants whose permission was revoked.

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

* fix(ws): rate-limit voice_e2ee_offer after validation, keyed on server state (F7)

The limiter key was built from unvalidated client input, letting an attacker
grow the limiter map without bound. The limiter now runs after validation and
keys on (sender, voiceChannelID), never on client input.

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

* fix(ws): deliver voice_state/voice_leave only to roles that may read the channel (F9)

Voice state of private channels was broadcast to every connected client,
leaking channel membership. All 11 emit sites now route through one
READ-filtered fan-out, channel-tagged so replay filters too.

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

* fix(api): redact the LiveKit access token from proxy dial-failure logs (F10)

A dial failure wrote the LiveKit access-token JWT into the server log via the
URL in the error. redactKey now runs on the error before it reaches slog.

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

* fix(auth): reserve the [deleted-N] username namespace (F11, F12)

The tombstone username namespace used by account deletion was freely
registrable, letting a user impersonate a deleted account. The namespace is
now reserved at validation, and DeleteAccount retries with a random suffix on
collision.

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

* fix(api): strip Unicode format characters from upload filenames (F13)

The attachment filename sanitizer stripped control characters but not
unicode.Cf, allowing bidi-override extension spoofing. Cf is now stripped
alongside controls and foreign path separators are cut.

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

* fix(api): reserve the login attempt before the bcrypt compare (F3)

The per-username lockout was a read-only IsLockedOut check followed by a
failure recorded only after the ~250ms bcrypt compare, so N concurrent
requests all passed the stale check before any of them recorded a failure.
The per-username cap is the only cross-IP brute-force defence (the middleware
limits per IP), so a distributed burst landed N guesses per 15-minute window
instead of 10.

Both counters are now reserved atomically with limiter.Allow before the
compare, and the lockout decision moves to the read-only limiter.Check so the
reservation is not double-counted. The limits are sized at threshold+1, which
leaves the sequential accepted-input set byte-identical to the previous
behaviour: failures 1-10 still land, the 10th still trips the lockout, and the
account owner's correct password on attempt 10 still returns 200. Sizing at
threshold instead would make 9 cheap wrong guesses convert the victim's own
correct password into a 15-minute lockout - the regression that got two
earlier attempts at this fix rejected, now pinned by a boundary test.

Deliberately scoped to handleLogin. The report also suggested widening to the
password-confirmation endpoints, but those are authenticated, share a single
pw_confirm_fail key across the TOTP endpoints, and widening there is what got
the first attempt rejected.

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

* chore(deps): bump five Rust dependencies in /Client/tauri-client/src-tauri

Rolls up dependabot #1259, #1260, #1261, #1262 and #1263:

  tauri-build        2.5.6 -> 2.6.3
  tauri-plugin-fs    2.4.5 -> 2.5.1
  tauri-plugin-http  2.5.7 -> 2.5.9
  tauri-plugin-store 2.4.2 -> 2.4.4
  webpki-roots       1.0.6 -> 1.0.9

All five are lockfile-only; the manifest constraints already permitted the
new versions. The five PRs each rewrote overlapping regions of the same
Cargo.lock and so could not be merged independently, so the lockfile was
regenerated with cargo update --precise for each crate instead. The combined
result is smaller than the sum of the five diffs because they share
transitive updates.

Verified with cargo check --locked --all-targets (exit 0).

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

* chore(deps): bump typescript-eslint from 8.58.0 to 8.65.0 in /Client/tauri-client

Dependabot #1258.

8.65.0 improves @typescript-eslint/no-unnecessary-type-assertion, which
surfaces four assertions that were already redundant and now fail the lint
gate. They are removed here rather than in a follow-up so no commit in this
branch leaves `npm run lint` red:

  UserBar.ts / members.store.ts  "online" as UserStatus -> "online"
                                 (the receiver already accepts the literal)
  media.ts                       drops `as RequestInit` on a literal that is
                                 already assignable
  LoginForm.ts                   drops `as { message: unknown }` made
                                 redundant by the `"message" in err` narrowing

All four are the rule's own autofix. Verified: npm run typecheck, npm run
lint, npm run format:check all clean, and the unit suite is 3572/3572 green
across 129 files.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:48:47 +02:00

939 lines
34 KiB
Go

package api_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"
"github.com/go-chi/chi/v5"
"github.com/owncord/server/api"
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
"github.com/owncord/server/service"
)
// ─── schema for channel tests ─────────────────────────────────────────────────
var channelTestSchema = []byte(`
CREATE TABLE IF NOT EXISTS roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
color TEXT,
permissions INTEGER NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0,
is_default INTEGER NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO roles (id, name, color, permissions, position, is_default) VALUES
(1, 'Owner', '#E74C3C', 2147483647, 100, 0),
(2, 'Admin', '#F39C12', 1073741823, 80, 0),
(3, 'Moderator', '#3498DB', 1048575, 60, 0),
(4, 'Member', NULL, 1635, 40, 1);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password TEXT NOT NULL,
avatar TEXT,
role_id INTEGER NOT NULL DEFAULT 4 REFERENCES roles(id),
totp_secret TEXT,
status TEXT NOT NULL DEFAULT 'offline',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_seen TEXT,
banned INTEGER NOT NULL DEFAULT 0,
ban_reason TEXT,
ban_expires TEXT,
identity_public_key TEXT
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
device TEXT,
ip_address TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'text',
category TEXT,
topic TEXT,
position INTEGER NOT NULL DEFAULT 0,
slow_mode INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
voice_max_users INTEGER NOT NULL DEFAULT 0,
voice_quality TEXT,
mixing_threshold INTEGER,
voice_max_video INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS channel_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
allow INTEGER NOT NULL DEFAULT 0,
deny INTEGER NOT NULL DEFAULT 0,
UNIQUE(channel_id, role_id)
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
content TEXT NOT NULL,
reply_to INTEGER REFERENCES messages(id) ON DELETE SET NULL,
edited_at TEXT,
deleted INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, id DESC);
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
content,
content='messages',
content_rowid='id'
);
CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
END;
CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content);
END;
CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content);
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
END;
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
stored_as TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
uploaded_at TEXT NOT NULL DEFAULT (datetime('now')),
width INTEGER,
height INTEGER
);
CREATE TABLE IF NOT EXISTS reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
UNIQUE(message_id, user_id, emoji)
);
CREATE TABLE IF NOT EXISTS read_states (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
last_message_id INTEGER NOT NULL DEFAULT 0,
mention_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, channel_id)
);
CREATE TABLE IF NOT EXISTS invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
created_by INTEGER NOT NULL REFERENCES users(id),
redeemed_by INTEGER REFERENCES users(id),
max_uses INTEGER,
use_count INTEGER NOT NULL DEFAULT 0,
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
revoked INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
INSERT OR IGNORE INTO settings (key, value) VALUES
('server_name', 'OwnCord Server'),
('motd', 'Welcome!');
CREATE TABLE IF NOT EXISTS dm_participants (
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (channel_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_dm_participants_user ON dm_participants(user_id);
CREATE TABLE IF NOT EXISTS dm_open_state (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
opened_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, channel_id)
);
`)
// ─── helpers ──────────────────────────────────────────────────────────────────
func newChannelTestDB(t *testing.T) *db.DB {
t.Helper()
database, err := db.Open(":memory:")
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
migrFS := fstest.MapFS{"001_schema.sql": {Data: channelTestSchema}}
if err := db.MigrateFS(database, migrFS); err != nil {
t.Fatalf("MigrateFS: %v", err)
}
return database
}
func buildChannelRouter(database *db.DB) http.Handler {
r := chi.NewRouter()
limiter := auth.NewRateLimiter()
st := database
svc := service.New(st, limiter)
api.MountChannelRoutes(r, database, svc, limiter, nil)
return r
}
// chTestCreateToken creates a user+session and returns the plaintext token.
func chTestCreateToken(t *testing.T, database *db.DB, username string, roleID int) string {
t.Helper()
_, err := database.CreateUser(context.Background(), username, "$2a$12$fake", roleID)
if err != nil {
t.Fatalf("CreateUser %q: %v", username, err)
}
token := "chtest-token-" + username
hash := auth.HashToken(token)
_, err = database.ExecContext(context.Background(),
`INSERT INTO sessions (user_id, token, device, ip_address, expires_at)
SELECT id, ?, 'test', '127.0.0.1', '2099-01-01T00:00:00Z' FROM users WHERE username = ?`,
hash, username,
)
if err != nil {
t.Fatalf("insert session for %q: %v", username, err)
}
return token
}
func chGet(t *testing.T, router http.Handler, path, token string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
// ─── GET /api/v1/channels ─────────────────────────────────────────────────────
func TestChannelList_Unauthenticated(t *testing.T) {
router := buildChannelRouter(newChannelTestDB(t))
rr := chGet(t, router, "/api/v1/channels", "")
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rr.Code)
}
}
func TestChannelList_Empty(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "alice", 1)
rr := chGet(t, router, "/api/v1/channels", token)
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
var resp []any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if len(resp) != 0 {
t.Errorf("expected empty array, got %d items", len(resp))
}
}
func TestChannelList_WithChannels(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "bob", 1)
_, _ = database.CreateChannel(context.Background(), "general", "text", "", "", 0)
_, _ = database.CreateChannel(context.Background(), "random", "text", "", "", 1)
rr := chGet(t, router, "/api/v1/channels", token)
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rr.Code)
}
var resp []any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if len(resp) != 2 {
t.Errorf("expected 2 channels, got %d", len(resp))
}
}
// ─── GET /api/v1/channels/{id}/messages ──────────────────────────────────────
func TestChannelMessages_Unauthenticated(t *testing.T) {
router := buildChannelRouter(newChannelTestDB(t))
rr := chGet(t, router, "/api/v1/channels/1/messages", "")
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rr.Code)
}
}
func TestChannelMessages_InvalidID(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "carol", 1)
rr := chGet(t, router, "/api/v1/channels/abc/messages", token)
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rr.Code)
}
}
func TestChannelMessages_ChannelNotFound(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "dave", 1)
rr := chGet(t, router, "/api/v1/channels/9999/messages", token)
if rr.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rr.Code)
}
}
func TestChannelMessages_EmptyChannel(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "eve", 1)
chID, _ := database.CreateChannel(context.Background(), "general", "text", "", "", 0)
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", chID), token)
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
msgs, ok := resp["messages"].([]any)
if !ok || len(msgs) != 0 {
t.Errorf("expected empty messages array, got: %v", resp["messages"])
}
}
func TestChannelMessages_ReturnsMessages(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "frank", 1)
user, _ := database.GetUserByUsername(context.Background(), "frank")
chID, _ := database.CreateChannel(context.Background(), "ch", "text", "", "", 0)
for i := range 3 {
_, _ = database.CreateMessage(context.Background(), chID, user.ID, fmt.Sprintf("msg%d", i), nil)
}
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages", chID), token)
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rr.Code)
}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
msgs := resp["messages"].([]any)
if len(msgs) != 3 {
t.Errorf("expected 3 messages, got %d", len(msgs))
}
}
func TestChannelMessages_LimitCappedAt100(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "grace", 1)
chID, _ := database.CreateChannel(context.Background(), "ch", "text", "", "", 0)
// limit=200 should succeed (capped internally).
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=200", chID), token)
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rr.Code)
}
}
func TestChannelMessages_HasMore(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "henry", 1)
user, _ := database.GetUserByUsername(context.Background(), "henry")
chID, _ := database.CreateChannel(context.Background(), "ch", "text", "", "", 0)
for i := range 60 {
_, _ = database.CreateMessage(context.Background(), chID, user.ID, fmt.Sprintf("m%d", i), nil)
}
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=50", chID), token)
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rr.Code)
}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if resp["has_more"] != true {
t.Errorf("has_more = %v, want true", resp["has_more"])
}
}
func TestChannelMessages_HasMoreFalse(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "ivan", 1)
user, _ := database.GetUserByUsername(context.Background(), "ivan")
chID, _ := database.CreateChannel(context.Background(), "ch", "text", "", "", 0)
for i := range 5 {
_, _ = database.CreateMessage(context.Background(), chID, user.ID, fmt.Sprintf("m%d", i), nil)
}
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=50", chID), token)
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rr.Code)
}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
if resp["has_more"] != false {
t.Errorf("has_more = %v, want false", resp["has_more"])
}
}
// ─── GET /api/v1/search ───────────────────────────────────────────────────────
func TestSearch_Unauthenticated(t *testing.T) {
router := buildChannelRouter(newChannelTestDB(t))
rr := chGet(t, router, "/api/v1/search?q=hello", "")
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rr.Code)
}
}
func TestSearch_MissingQuery(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "julia", 1)
rr := chGet(t, router, "/api/v1/search", token)
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rr.Code)
}
}
func TestSearch_ReturnsResults(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "kim", 1)
user, _ := database.GetUserByUsername(context.Background(), "kim")
chID, _ := database.CreateChannel(context.Background(), "searchable", "text", "", "", 0)
_, _ = database.CreateMessage(context.Background(), chID, user.ID, "uniqueterm in message", nil)
rr := chGet(t, router, "/api/v1/search?q=uniqueterm", token)
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
results, ok := resp["results"].([]any)
if !ok || len(results) == 0 {
t.Errorf("expected search results, got: %v", resp)
}
}
func TestSearch_NoResults(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "larry", 1)
rr := chGet(t, router, "/api/v1/search?q=xyzzynotfound", token)
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rr.Code)
}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
results := resp["results"].([]any)
if len(results) != 0 {
t.Errorf("expected 0 results, got %d", len(results))
}
}
func TestSearch_WithChannelID(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(), "filtered", "text", "", "", 0)
_, _ = database.CreateMessage(context.Background(), chID, user.ID, "filtered message here", nil)
rr := chGet(t, router, fmt.Sprintf("/api/v1/search?q=filtered&channel_id=%d", chID), token)
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
}
func TestSearch_InvalidChannelID(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "badchid", 1)
rr := chGet(t, router, "/api/v1/search?q=test&channel_id=abc", token)
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rr.Code)
}
}
func TestSearch_NegativeChannelID(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "negchid", 1)
rr := chGet(t, router, "/api/v1/search?q=test&channel_id=-1", token)
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rr.Code)
}
}
func TestSearch_WithLimit(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "limituser", 1)
rr := chGet(t, router, "/api/v1/search?q=test&limit=5", token)
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rr.Code)
}
}
func TestSearch_InvalidLimit(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "badlimit", 1)
rr := chGet(t, router, "/api/v1/search?q=test&limit=abc", token)
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rr.Code)
}
}
func TestSearch_InvalidFTSQuery(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "badfts", 1)
user, _ := database.GetUserByUsername(context.Background(), "badfts")
chID, _ := database.CreateChannel(context.Background(), "fts", "text", "", "", 0)
_, _ = database.CreateMessage(context.Background(), chID, user.ID, "search seed", nil)
// FTS5 operator characters are now stripped by sanitizeFTSQuery, so a
// bare quote becomes an empty query which returns 200 with no results.
rr := chGet(t, router, "/api/v1/search?q=%22", token)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
}
func TestSearch_ZeroLimit(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "zerolimit", 1)
rr := chGet(t, router, "/api/v1/search?q=test&limit=0", token)
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rr.Code)
}
}
func TestSearch_LimitCappedAt100(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "highlimit", 1)
// limit=200 should be silently capped to 100
rr := chGet(t, router, "/api/v1/search?q=test&limit=200", token)
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rr.Code)
}
}
func TestSearch_ChannelTypeLookupFailure_FailsClosed(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "searchfailclosed", 1)
user, _ := database.GetUserByUsername(context.Background(), "searchfailclosed")
chID, _ := database.CreateChannel(context.Background(), "searchable", "text", "", "", 0)
_, _ = database.CreateMessage(context.Background(), chID, user.ID, "closedlookupterm", nil)
_, err := database.ExecContext(context.Background(), `ALTER TABLE channels RENAME TO channels_with_type`)
if err != nil {
t.Fatalf("rename channels: %v", err)
}
_, err = database.ExecContext(context.Background(), `CREATE TABLE channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
)`)
if err != nil {
t.Fatalf("recreate channels without type: %v", err)
}
_, err = database.ExecContext(context.Background(), `INSERT INTO channels (id, name) SELECT id, name FROM channels_with_type`)
if err != nil {
t.Fatalf("copy channels: %v", err)
}
rr := chGet(t, router, "/api/v1/search?q=closedlookupterm", token)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500; body: %s", rr.Code, rr.Body.String())
}
}
func TestSearch_ChannelOverrideLookupFailure_ReturnsError(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "searchoverridefail", 4)
user, _ := database.GetUserByUsername(context.Background(), "searchoverridefail")
chID, _ := database.CreateChannel(context.Background(), "searchable", "text", "", "", 0)
_, _ = database.CreateMessage(context.Background(), chID, user.ID, "overridefailterm", nil)
_, err := database.ExecContext(context.Background(), `DROP TABLE channel_overrides`)
if err != nil {
t.Fatalf("drop channel_overrides: %v", err)
}
rr := chGet(t, router, "/api/v1/search?q=overridefailterm", token)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500; body: %s", rr.Code, rr.Body.String())
}
}
func TestSearch_TrustedProxyRateLimitUsesForwardedIP(t *testing.T) {
database := newChannelTestDB(t)
r := chi.NewRouter()
limiter := auth.NewRateLimiter()
svc := service.New(database, limiter)
api.MountChannelRoutes(r, database, svc, limiter, []string{"127.0.0.0/8"})
token := chTestCreateToken(t, database, "proxysearch", 1)
for i := 0; i < 30; i++ {
req := httptest.NewRequest(http.MethodGet, "/api/v1/search?q=test", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Forwarded-For", fmt.Sprintf("198.51.100.%d", i+1))
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("request %d status = %d, want 200; body: %s", i, rr.Code, rr.Body.String())
}
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/search?q=test", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Forwarded-For", "198.51.100.200")
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("independent forwarded client should not be throttled by shared proxy IP, got %d", rr.Code)
}
}
// ─── Messages — before/after cursor ─────────────────────────────────────────
func TestChannelMessages_BeforeCursor(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "cursoruser", 1)
user, _ := database.GetUserByUsername(context.Background(), "cursoruser")
chID, _ := database.CreateChannel(context.Background(), "cursor", "text", "", "", 0)
var lastID int64
for i := range 5 {
lastID, _ = database.CreateMessage(context.Background(), chID, user.ID, fmt.Sprintf("msg%d", i), nil)
}
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?before=%d", chID, lastID), token)
if rr.Code != http.StatusOK {
t.Errorf("before cursor status = %d, want 200", rr.Code)
}
}
func TestChannelMessages_InvalidLimit(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "badlimituser", 1)
chID, _ := database.CreateChannel(context.Background(), "lim", "text", "", "", 0)
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=abc", chID), token)
if rr.Code != http.StatusBadRequest {
t.Errorf("invalid limit status = %d, want 400", rr.Code)
}
}
// ─── GET /api/v1/channels/{id}/pins ─────────────────────────────────────────
// newPinTestDB creates a DB with dm_participants table needed for pin tests.
func newPinTestDB(t *testing.T) *db.DB {
t.Helper()
database := newChannelTestDB(t)
// Add DM tables required by pin handlers for DM authorization.
_, err := database.ExecContext(context.Background(), `
CREATE TABLE IF NOT EXISTS dm_participants (
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (channel_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_dm_participants_user ON dm_participants(user_id);
CREATE TABLE IF NOT EXISTS dm_open_state (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
opened_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, channel_id)
);
-- Mirrors migration 012. DM pin authorization consults the block list
-- (a blocked user must not mutate the blocker's pins), so this fixture
-- needs the table or the lookup fails closed with a 500.
CREATE TABLE IF NOT EXISTS user_blocks (
blocker_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
blocked_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (blocker_id, blocked_id),
CHECK (blocker_id != blocked_id)
);
`)
if err != nil {
t.Fatalf("create dm_participants: %v", err)
}
return database
}
func TestGetPins_Unauthenticated(t *testing.T) {
router := buildChannelRouter(newPinTestDB(t))
rr := chGet(t, router, "/api/v1/channels/1/pins", "")
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rr.Code)
}
}
func TestGetPins_ChannelNotFound(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "pinuser1", 1)
rr := chGet(t, router, "/api/v1/channels/9999/pins", token)
if rr.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404; body: %s", rr.Code, rr.Body.String())
}
}
func TestGetPins_EmptyPins(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "pinuser2", 1)
chID, _ := database.CreateChannel(context.Background(), "general", "text", "", "", 0)
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/pins", chID), token)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
var resp struct {
Messages []any `json:"messages"`
HasMore bool `json:"has_more"`
}
_ = json.NewDecoder(rr.Body).Decode(&resp)
if len(resp.Messages) != 0 {
t.Errorf("expected 0 pinned messages, got %d", len(resp.Messages))
}
if resp.HasMore {
t.Error("has_more should be false for empty pins")
}
}
func TestGetPins_ReturnsPinnedMessages(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "pinuser3", 1)
user, _ := database.GetUserByUsername(context.Background(), "pinuser3")
chID, _ := database.CreateChannel(context.Background(), "general", "text", "", "", 0)
msgID, _ := database.CreateMessage(context.Background(), chID, user.ID, "pinned message", nil)
_ = database.SetMessagePinned(context.Background(), msgID, true)
// Also create an unpinned message — should not appear.
_, _ = database.CreateMessage(context.Background(), chID, user.ID, "not pinned", nil)
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/pins", chID), token)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
var resp struct {
Messages []any `json:"messages"`
}
_ = json.NewDecoder(rr.Body).Decode(&resp)
if len(resp.Messages) != 1 {
t.Errorf("expected 1 pinned message, got %d", len(resp.Messages))
}
}
func TestGetPins_DMChannel_NonParticipantForbidden(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
// Create two users for the DM and a third who should be denied.
chTestCreateToken(t, database, "dmuser1", 4)
chTestCreateToken(t, database, "dmuser2", 4)
outsiderToken := chTestCreateToken(t, database, "outsider", 4)
user1, _ := database.GetUserByUsername(context.Background(), "dmuser1")
user2, _ := database.GetUserByUsername(context.Background(), "dmuser2")
// Create a DM channel manually.
dmCh, _, _ := database.GetOrCreateDMChannel(context.Background(), user1.ID, user2.ID)
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/pins", dmCh.ID), outsiderToken)
if rr.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404; body: %s", rr.Code, rr.Body.String())
}
}
func TestGetPins_MemberNoReadPermission(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
// Role 4 = Member with permissions 1635 (0x663).
// Deny READ_MESSAGES on a specific channel via override.
token := chTestCreateToken(t, database, "nopermuser", 4)
chID, _ := database.CreateChannel(context.Background(), "restricted", "text", "", "", 0)
// Deny all permissions for role 4 on this channel.
_, _ = database.ExecContext(context.Background(),
`INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 2147483647)`,
chID,
)
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/pins", chID), token)
if rr.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403; body: %s", rr.Code, rr.Body.String())
}
}
// ─── POST/DELETE /api/v1/channels/{id}/pins/{messageId} ─────────────────────
func chPost(t *testing.T, router http.Handler, path, token string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
func chDelete(t *testing.T, router http.Handler, path, token string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodDelete, path, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
func TestSetPinned_PinSuccessfully(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "pinner1", 1)
user, _ := database.GetUserByUsername(context.Background(), "pinner1")
chID, _ := database.CreateChannel(context.Background(), "general", "text", "", "", 0)
msgID, _ := database.CreateMessage(context.Background(), chID, user.ID, "pin me", nil)
rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID, msgID), token)
if rr.Code != http.StatusNoContent {
t.Errorf("pin status = %d, want 204; body: %s", rr.Code, rr.Body.String())
}
// Verify the message is actually pinned.
msg, _ := database.GetMessage(context.Background(), msgID)
if !msg.Pinned {
t.Error("message should be pinned after POST")
}
}
func TestSetPinned_UnpinSuccessfully(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "unpinner1", 1)
user, _ := database.GetUserByUsername(context.Background(), "unpinner1")
chID, _ := database.CreateChannel(context.Background(), "general", "text", "", "", 0)
msgID, _ := database.CreateMessage(context.Background(), chID, user.ID, "unpin me", 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())
}
msg, _ := database.GetMessage(context.Background(), msgID)
if msg.Pinned {
t.Error("message should not be pinned after DELETE")
}
}
func TestSetPinned_MessageNotFound(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "pinner2", 1)
chID, _ := database.CreateChannel(context.Background(), "general", "text", "", "", 0)
rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/9999", chID), token)
if rr.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404; body: %s", rr.Code, rr.Body.String())
}
}
func TestSetPinned_ChannelNotFound(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "pinner3", 1)
rr := chPost(t, router, "/api/v1/channels/9999/pins/1", token)
if rr.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404; body: %s", rr.Code, rr.Body.String())
}
}
func TestSetPinned_NoPermission(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
// Member role (4) has permissions 1635 — does not include MANAGE_MESSAGES (0x2000).
token := chTestCreateToken(t, database, "noperm", 4)
user, _ := database.GetUserByUsername(context.Background(), "noperm")
chID, _ := database.CreateChannel(context.Background(), "general", "text", "", "", 0)
msgID, _ := database.CreateMessage(context.Background(), chID, user.ID, "try to pin", nil)
rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID, msgID), token)
if rr.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403; body: %s", rr.Code, rr.Body.String())
}
}
func TestSetPinned_Idempotent(t *testing.T) {
database := newPinTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "pinner4", 1)
user, _ := database.GetUserByUsername(context.Background(), "pinner4")
chID, _ := database.CreateChannel(context.Background(), "general", "text", "", "", 0)
msgID, _ := database.CreateMessage(context.Background(), chID, user.ID, "already pinned", nil)
_ = database.SetMessagePinned(context.Background(), msgID, true)
// Pinning again should still succeed (idempotent).
rr := chPost(t, router, fmt.Sprintf("/api/v1/channels/%d/pins/%d", chID, msgID), token)
if rr.Code != http.StatusNoContent {
t.Errorf("idempotent pin status = %d, want 204; body: %s", rr.Code, rr.Body.String())
}
}