feat: implement Phase 5 (voice/WebRTC signaling) and Phase 6 (admin panel)

Phase 5 — Voice:
- migrations/002_voice_states.sql: voice_states table with FK + index
- db/voice_queries: JoinVoiceChannel, LeaveVoiceChannel, GetVoiceState,
  GetChannelVoiceStates, UpdateVoiceMute, UpdateVoiceDeafen, ClearVoiceState
- ws/voice_handlers: handleVoiceJoin (perm check, DB, broadcast existing
  states), handleVoiceLeave, handleVoiceMute, handleVoiceDeafen,
  handleVoiceSignal (rate-limited relay, SDP never logged),
  handleSoundboard (rate-limited, USE_SOUNDBOARD perm check)
- ws/handlers: dispatch voice_join/leave/mute/deafen/offer/answer/ice/soundboard
- ws/serve: call handleVoiceLeave on disconnect; include voice states in ready payload
- ws/messages: buildVoiceState, buildVoiceLeave, buildVoiceSignalRelay
- api/voice_handler: GET /api/v1/voice/credentials — HMAC-SHA1 TURN creds
- config: VoiceConfig (TURNSecret, STUNPort, TURNPort, TURNEnabled)

Phase 6 — Admin Panel:
- migrations/003_audit_log.sql: audit_log table with indexes
- db/admin_queries: GetServerStats, ListAllUsers, UpdateUserRole,
  ForceLogoutUser, AdminCreate/Update/DeleteChannel, LogAudit,
  GetAuditLog, GetSetting, SetSetting, GetAllSettings, BackupTo
- admin/api: full REST API — stats, users, channels, audit log, settings,
  backup; adminAuthMiddleware (ADMINISTRATOR bit), ownerOnlyMiddleware
- admin/static/index.html: single-page admin panel (dark theme, vanilla JS,
  no CDN) — dashboard, users, channels, audit log, settings sections
- admin/admin.go: NewHandler wiring go:embed static files + API

Fixes: Channel struct json tags (was serializing as "ID" not "id"),
duplicate getWithToken helper renamed in voice_handler_test.go

Test coverage: admin 59.1%, api 78.2%, auth 90.9%, db 82.0%, ws 37.9%
This commit is contained in:
jevb
2026-03-14 21:31:03 +01:00
parent 36640e3051
commit ab389764b5
21 changed files with 5161 additions and 24 deletions
+34 -3
View File
@@ -1,16 +1,47 @@
// Package admin provides the embedded admin panel static file server.
// Full admin API implementation follows in Phase 6.
// Package admin provides the embedded admin panel static file server and the
// admin REST API for the OwnCord server.
package admin
import (
"embed"
"io/fs"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/owncord/server/db"
)
//go:embed static
var staticFiles embed.FS
// Handler returns an http.Handler that serves the embedded admin panel static files.
// NewHandler returns an http.Handler that serves both the admin REST API and
// the embedded admin panel static files.
//
// Routes:
//
// /api/* — admin REST API (all require ADMINISTRATOR permission)
// /* — embedded static files (SPA; index.html for unknown paths)
func NewHandler(database *db.DB) http.Handler {
r := chi.NewRouter()
// Admin REST API mounted at /api
r.Mount("/api", NewAdminAPI(database))
// Static files — serve from the embedded FS sub-tree.
staticFS, err := fs.Sub(staticFiles, "admin/static")
if err != nil {
// This is a programming error (wrong embed path) and should never
// happen in production. Panic so it surfaces immediately in tests.
panic("admin: failed to create static sub-FS: " + err.Error())
}
r.Handle("/*", http.FileServer(http.FS(staticFS)))
return r
}
// Handler returns the admin panel http.Handler using a nil database.
// Deprecated: use NewHandler instead. Kept for backwards-compat with any
// caller that already imported this symbol before Phase 6.
func Handler() http.Handler {
return http.FileServer(http.FS(staticFiles))
}
+496
View File
@@ -0,0 +1,496 @@
package admin
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
)
// ─── Permission constants ─────────────────────────────────────────────────────
const (
permAdministrator = int64(0x40000000)
ownerRolePosition = 100
)
// ─── NewAdminAPI ──────────────────────────────────────────────────────────────
// NewAdminAPI returns a chi router with all /admin/api/* routes. All routes
// are protected by adminAuthMiddleware which requires the ADMINISTRATOR bit.
func NewAdminAPI(database *db.DB) http.Handler {
r := chi.NewRouter()
// All routes require authentication and ADMINISTRATOR permission.
r.Use(adminAuthMiddleware(database))
r.Get("/stats", handleGetStats(database))
r.Get("/users", handleListUsers(database))
r.Patch("/users/{id}", handlePatchUser(database))
r.Delete("/users/{id}/sessions", handleForceLogout(database))
r.Get("/channels", handleListChannels(database))
r.Post("/channels", handleCreateChannel(database))
r.Patch("/channels/{id}", handlePatchChannel(database))
r.Delete("/channels/{id}", handleDeleteChannel(database))
r.Get("/audit-log", handleGetAuditLog(database))
r.Get("/settings", handleGetSettings(database))
r.Patch("/settings", handlePatchSettings(database))
r.Post("/backup", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ownerOnlyMiddleware(database, handleBackup(database)).ServeHTTP(w, req)
}))
return r
}
// ─── Middleware ───────────────────────────────────────────────────────────────
// adminAuthMiddleware validates the Bearer token and requires ADMINISTRATOR.
func adminAuthMiddleware(database *db.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := extractBearer(r)
if !ok {
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "missing or invalid authorization header")
return
}
hash := auth.HashToken(token)
sess, err := database.GetSessionByTokenHash(hash)
if err != nil || sess == nil {
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid or expired session")
return
}
if isExpired(sess.ExpiresAt) {
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "session has expired")
return
}
user, err := database.GetUserByID(sess.UserID)
if err != nil || user == nil {
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "user not found")
return
}
role, err := database.GetRoleByID(user.RoleID)
if err != nil || role == nil {
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "role not found")
return
}
if role.Permissions&permAdministrator == 0 {
writeErr(w, http.StatusForbidden, "FORBIDDEN", "administrator permission required")
return
}
next.ServeHTTP(w, r)
})
}
}
// ownerOnlyMiddleware wraps a handler to require Owner role (position == 100).
func ownerOnlyMiddleware(database *db.DB, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := extractBearer(r)
if !ok {
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "missing authorization header")
return
}
hash := auth.HashToken(token)
sess, err := database.GetSessionByTokenHash(hash)
if err != nil || sess == nil {
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid session")
return
}
user, err := database.GetUserByID(sess.UserID)
if err != nil || user == nil {
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "user not found")
return
}
role, err := database.GetRoleByID(user.RoleID)
if err != nil || role == nil {
writeErr(w, http.StatusForbidden, "FORBIDDEN", "role not found")
return
}
if role.Position < ownerRolePosition {
writeErr(w, http.StatusForbidden, "FORBIDDEN", "owner role required")
return
}
next.ServeHTTP(w, r)
})
}
// ─── Handlers ─────────────────────────────────────────────────────────────────
func handleGetStats(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
stats, err := database.GetServerStats()
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get stats")
return
}
writeJSON(w, http.StatusOK, stats)
}
}
func handleListUsers(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
limit := queryInt(r, "limit", 50)
offset := queryInt(r, "offset", 0)
users, err := database.ListAllUsers(limit, offset)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list users")
return
}
writeJSON(w, http.StatusOK, users)
}
}
// patchUserRequest is the JSON body for PATCH /admin/api/users/{id}.
type patchUserRequest struct {
RoleID *int64 `json:"role_id"`
Banned *bool `json:"banned"`
BanReason *string `json:"ban_reason"`
}
func handlePatchUser(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := pathInt64(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid user id")
return
}
var req patchUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body")
return
}
user, err := database.GetUserByID(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch user")
return
}
if user == nil {
writeErr(w, http.StatusNotFound, "NOT_FOUND", "user not found")
return
}
if req.RoleID != nil {
if err := database.UpdateUserRole(id, *req.RoleID); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update role")
return
}
}
if req.Banned != nil {
reason := ""
if req.BanReason != nil {
reason = *req.BanReason
}
if *req.Banned {
if err := database.BanUser(id, reason, nil); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to ban user")
return
}
} else {
if err := database.UnbanUser(id); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to unban user")
return
}
}
}
updated, err := database.GetUserByID(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch updated user")
return
}
writeJSON(w, http.StatusOK, updated)
}
}
func handleForceLogout(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := pathInt64(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid user id")
return
}
if err := database.ForceLogoutUser(id); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to logout user")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func handleListChannels(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
channels, err := database.ListChannels()
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list channels")
return
}
writeJSON(w, http.StatusOK, channels)
}
}
// createChannelRequest is the JSON body for POST /admin/api/channels.
type createChannelRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Category string `json:"category"`
Topic string `json:"topic"`
Position int `json:"position"`
}
func handleCreateChannel(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req createChannelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body")
return
}
if strings.TrimSpace(req.Name) == "" {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "name is required")
return
}
if req.Type == "" {
req.Type = "text"
}
id, err := database.AdminCreateChannel(req.Name, req.Type, req.Category, req.Topic, req.Position)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create channel")
return
}
ch, err := database.GetChannel(id)
if err != nil || ch == nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch created channel")
return
}
writeJSON(w, http.StatusCreated, ch)
}
}
// updateChannelRequest is the JSON body for PATCH /admin/api/channels/{id}.
type updateChannelRequest struct {
Name string `json:"name"`
Topic string `json:"topic"`
SlowMode int `json:"slow_mode"`
Position int `json:"position"`
Archived bool `json:"archived"`
}
func handlePatchChannel(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := pathInt64(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid channel id")
return
}
existing, err := database.GetChannel(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel")
return
}
if existing == nil {
writeErr(w, http.StatusNotFound, "NOT_FOUND", "channel not found")
return
}
// Start from existing values so a partial body is safe.
req := updateChannelRequest{
Name: existing.Name,
Topic: existing.Topic,
SlowMode: existing.SlowMode,
Position: existing.Position,
Archived: existing.Archived,
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body")
return
}
if err := database.AdminUpdateChannel(id, req.Name, req.Topic, req.SlowMode, req.Position, req.Archived); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update channel")
return
}
updated, _ := database.GetChannel(id)
writeJSON(w, http.StatusOK, updated)
}
}
func handleDeleteChannel(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := pathInt64(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid channel id")
return
}
existing, err := database.GetChannel(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel")
return
}
if existing == nil {
writeErr(w, http.StatusNotFound, "NOT_FOUND", "channel not found")
return
}
if err := database.AdminDeleteChannel(id); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete channel")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func handleGetAuditLog(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
limit := queryInt(r, "limit", 50)
offset := queryInt(r, "offset", 0)
entries, err := database.GetAuditLog(limit, offset)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get audit log")
return
}
writeJSON(w, http.StatusOK, entries)
}
}
func handleGetSettings(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
settings, err := database.GetAllSettings()
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to get settings")
return
}
writeJSON(w, http.StatusOK, settings)
}
}
func handlePatchSettings(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var updates map[string]string
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body")
return
}
for key, value := range updates {
if err := database.SetSetting(key, value); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update setting: "+key)
return
}
}
settings, err := database.GetAllSettings()
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch settings")
return
}
writeJSON(w, http.StatusOK, settings)
}
}
func handleBackup(database *db.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
backupDir := filepath.Join("data", "backups")
if err := os.MkdirAll(backupDir, 0o750); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create backup directory")
return
}
timestamp := time.Now().UTC().Format("20060102_150405")
backupPath := filepath.Join(backupDir, "chatserver_"+timestamp+".db")
if err := database.BackupTo(backupPath); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "backup failed")
return
}
writeJSON(w, http.StatusOK, map[string]string{
"path": backupPath,
"created": timestamp,
})
})
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
type errorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, status int, code, msg string) {
writeJSON(w, status, errorResponse{Error: code, Message: msg})
}
func extractBearer(r *http.Request) (string, bool) {
header := r.Header.Get("Authorization")
if header == "" {
return "", false
}
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") || parts[1] == "" {
return "", false
}
return parts[1], true
}
func pathInt64(r *http.Request, param string) (int64, error) {
raw := chi.URLParam(r, param)
return strconv.ParseInt(raw, 10, 64)
}
func queryInt(r *http.Request, key string, defaultVal int) int {
raw := r.URL.Query().Get(key)
if raw == "" {
return defaultVal
}
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
return defaultVal
}
return n
}
func isExpired(expiresAt string) bool {
for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02T15:04:05Z"} {
t, err := time.Parse(layout, expiresAt)
if err == nil {
return time.Now().UTC().After(t.UTC())
}
}
return true
}
+669
View File
@@ -0,0 +1,669 @@
package admin_test
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"
"github.com/owncord/server/admin"
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
)
// adminSchema is a minimal in-memory schema for admin API tests.
var adminSchema = []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, 'Member', NULL, 1049089, 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 3 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
);
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'))
);
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,
deleted INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
created_by INTEGER NOT NULL 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 audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_id INTEGER NOT NULL DEFAULT 0,
action TEXT NOT NULL,
target_type TEXT NOT NULL DEFAULT '',
target_id INTEGER NOT NULL DEFAULT 0,
detail TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
INSERT OR IGNORE INTO settings (key, value) VALUES
('server_name', 'Test Server'),
('motd', 'Hello');
`)
// openAdminTestDB opens a fresh in-memory database for admin API tests.
func openAdminTestDB(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: adminSchema},
}
if err := db.MigrateFS(database, migrFS); err != nil {
t.Fatalf("MigrateFS: %v", err)
}
return database
}
// createAdminUser creates an Owner-role user and returns a valid bearer token.
func createAdminUser(t *testing.T, database *db.DB) string {
t.Helper()
// Owner role has permissions = 2147483647 (includes ADMINISTRATOR bit 0x40000000)
uid, err := database.CreateUser("adminuser", "$2a$12$placeholder", 1)
if err != nil {
t.Fatalf("CreateUser admin: %v", err)
}
token := "test-admin-token-" + t.Name()
tokenHash := auth.HashToken(token)
if _, err := database.CreateSession(uid, tokenHash, "test", "127.0.0.1"); err != nil {
t.Fatalf("CreateSession: %v", err)
}
return token
}
// createMemberUser creates a Member-role user and returns a valid bearer token.
func createMemberUser(t *testing.T, database *db.DB) string {
t.Helper()
// Member role (id=3) has limited permissions, not ADMINISTRATOR
uid, err := database.CreateUser("memberuser", "$2a$12$placeholder", 3)
if err != nil {
t.Fatalf("CreateUser member: %v", err)
}
token := "test-member-token-" + t.Name()
tokenHash := auth.HashToken(token)
if _, err := database.CreateSession(uid, tokenHash, "test", "127.0.0.1"); err != nil {
t.Fatalf("CreateSession: %v", err)
}
return token
}
func doRequest(t *testing.T, handler http.Handler, method, path, token string, body interface{}) *httptest.ResponseRecorder {
t.Helper()
var bodyBytes []byte
if body != nil {
var err error
bodyBytes, err = json.Marshal(body)
if err != nil {
t.Fatalf("json.Marshal body: %v", err)
}
}
req := httptest.NewRequest(method, path, bytes.NewReader(bodyBytes))
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
return w
}
// ─── GET /admin/api/stats ─────────────────────────────────────────────────────
func TestAdminAPI_Stats_OK(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/stats", token, nil)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
var stats map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &stats); err != nil {
t.Fatalf("unmarshal stats: %v", err)
}
if _, ok := stats["user_count"]; !ok {
t.Error("response missing 'user_count'")
}
if _, ok := stats["message_count"]; !ok {
t.Error("response missing 'message_count'")
}
}
func TestAdminAPI_Stats_Unauthenticated(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
w := doRequest(t, handler, http.MethodGet, "/stats", "", nil)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
func TestAdminAPI_Stats_Forbidden(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
token := createMemberUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/stats", token, nil)
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403", w.Code)
}
}
// ─── GET /admin/api/users ─────────────────────────────────────────────────────
func TestAdminAPI_ListUsers_OK(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/users?limit=50&offset=0", token, nil)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
var users []interface{}
if err := json.Unmarshal(w.Body.Bytes(), &users); err != nil {
t.Fatalf("unmarshal users: %v", err)
}
// At least the admin user we created
if len(users) < 1 {
t.Error("expected at least 1 user in response")
}
}
func TestAdminAPI_ListUsers_DefaultPagination(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
token := createAdminUser(t, database)
// No query params — should use defaults
w := doRequest(t, handler, http.MethodGet, "/users", token, nil)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestAdminAPI_ListUsers_Unauthenticated(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
w := doRequest(t, handler, http.MethodGet, "/users", "", nil)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
// ─── PATCH /admin/api/users/{id} ─────────────────────────────────────────────
func TestAdminAPI_PatchUser_BanUser(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
token := createAdminUser(t, database)
// Create a target user
targetUID, _ := database.CreateUser("target", "hash", 3)
body := map[string]interface{}{
"banned": true,
"ban_reason": "spam",
}
w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, body)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
// Verify user is banned in DB
user, err := database.GetUserByID(targetUID)
if err != nil {
t.Fatalf("GetUserByID: %v", err)
}
if !user.Banned {
t.Error("user should be banned after PATCH")
}
}
func TestAdminAPI_PatchUser_ChangeRole(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
token := createAdminUser(t, database)
targetUID, _ := database.CreateUser("rolechange", "hash", 3)
body := map[string]interface{}{
"role_id": float64(2),
}
w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, body)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
user, _ := database.GetUserByID(targetUID)
if user.RoleID != 2 {
t.Errorf("RoleID = %d, want 2", user.RoleID)
}
}
func TestAdminAPI_PatchUser_NotFound(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
token := createAdminUser(t, database)
body := map[string]interface{}{"banned": true}
w := doRequest(t, handler, http.MethodPatch, "/users/99999", token, body)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
}
func TestAdminAPI_PatchUser_InvalidID(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPatch, "/users/abc", token, nil)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
// ─── DELETE /admin/api/users/{id}/sessions ────────────────────────────────────
func TestAdminAPI_ForceLogout_OK(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
token := createAdminUser(t, database)
targetUID, _ := database.CreateUser("logoutme", "hash", 3)
database.CreateSession(targetUID, "victim-token-hash", "web", "1.2.3.4")
w := doRequest(t, handler, http.MethodDelete, "/users/"+itoa(targetUID)+"/sessions", token, nil)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d, want 204", w.Code)
}
sessions, _ := database.GetUserSessions(targetUID)
if len(sessions) != 0 {
t.Errorf("expected 0 sessions after force logout, got %d", len(sessions))
}
}
func TestAdminAPI_ForceLogout_Unauthenticated(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
w := doRequest(t, handler, http.MethodDelete, "/users/1/sessions", "", nil)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
// ─── GET /admin/api/channels ──────────────────────────────────────────────────
func TestAdminAPI_ListChannels_OK(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
token := createAdminUser(t, database)
database.AdminCreateChannel("general", "text", "", "", 0)
w := doRequest(t, handler, http.MethodGet, "/channels", token, nil)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
var channels []interface{}
if err := json.Unmarshal(w.Body.Bytes(), &channels); err != nil {
t.Fatalf("unmarshal channels: %v", err)
}
if len(channels) != 1 {
t.Errorf("expected 1 channel, got %d", len(channels))
}
}
// ─── POST /admin/api/channels ─────────────────────────────────────────────────
func TestAdminAPI_CreateChannel_OK(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
token := createAdminUser(t, database)
body := map[string]interface{}{
"name": "new-channel",
"type": "text",
"category": "General",
"topic": "Discussion",
"position": float64(1),
}
w := doRequest(t, handler, http.MethodPost, "/channels", token, body)
if w.Code != http.StatusCreated {
t.Errorf("status = %d, want 201; body: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if _, ok := resp["id"]; !ok {
t.Error("response missing 'id'")
}
}
func TestAdminAPI_CreateChannel_MissingName(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
token := createAdminUser(t, database)
body := map[string]interface{}{
"type": "text",
}
w := doRequest(t, handler, http.MethodPost, "/channels", token, body)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
// ─── PATCH /admin/api/channels/{id} ──────────────────────────────────────────
func TestAdminAPI_UpdateChannel_OK(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
token := createAdminUser(t, database)
chID, _ := database.AdminCreateChannel("old", "text", "", "", 0)
body := map[string]interface{}{
"name": "updated",
"topic": "new topic",
"slow_mode": float64(10),
"position": float64(2),
"archived": false,
}
w := doRequest(t, handler, http.MethodPatch, "/channels/"+itoa(chID), token, body)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
}
func TestAdminAPI_UpdateChannel_NotFound(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
token := createAdminUser(t, database)
body := map[string]interface{}{"name": "x"}
w := doRequest(t, handler, http.MethodPatch, "/channels/99999", token, body)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
}
// ─── DELETE /admin/api/channels/{id} ─────────────────────────────────────────
func TestAdminAPI_DeleteChannel_OK(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
token := createAdminUser(t, database)
chID, _ := database.AdminCreateChannel("del-me", "text", "", "", 0)
w := doRequest(t, handler, http.MethodDelete, "/channels/"+itoa(chID), token, nil)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d, want 204", w.Code)
}
}
func TestAdminAPI_DeleteChannel_NotFound(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodDelete, "/channels/99999", token, nil)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
}
// ─── GET /admin/api/audit-log ─────────────────────────────────────────────────
func TestAdminAPI_AuditLog_OK(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
token := createAdminUser(t, database)
uid, _ := database.CreateUser("actor", "hash", 1)
database.LogAudit(uid, "TEST_ACTION", "user", uid, "detail")
w := doRequest(t, handler, http.MethodGet, "/audit-log?limit=10&offset=0", token, nil)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
var entries []interface{}
if err := json.Unmarshal(w.Body.Bytes(), &entries); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(entries) != 1 {
t.Errorf("expected 1 entry, got %d", len(entries))
}
}
func TestAdminAPI_AuditLog_Empty(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/audit-log", token, nil)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
var entries []interface{}
json.Unmarshal(w.Body.Bytes(), &entries)
if len(entries) != 0 {
t.Errorf("expected 0 entries, got %d", len(entries))
}
}
// ─── GET /admin/api/settings ──────────────────────────────────────────────────
func TestAdminAPI_GetSettings_OK(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/settings", token, nil)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
var settings map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &settings); err != nil {
t.Fatalf("unmarshal settings: %v", err)
}
if _, ok := settings["server_name"]; !ok {
t.Error("response missing 'server_name'")
}
}
// ─── PATCH /admin/api/settings ────────────────────────────────────────────────
func TestAdminAPI_PatchSettings_OK(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
token := createAdminUser(t, database)
body := map[string]string{
"server_name": "Updated Server",
"motd": "New MOTD",
}
w := doRequest(t, handler, http.MethodPatch, "/settings", token, body)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
// Verify the change was persisted
val, err := database.GetSetting("server_name")
if err != nil {
t.Fatalf("GetSetting: %v", err)
}
if val != "Updated Server" {
t.Errorf("server_name = %q, want 'Updated Server'", val)
}
}
func TestAdminAPI_PatchSettings_InvalidBody(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
token := createAdminUser(t, database)
req := httptest.NewRequest(http.MethodPatch, "/settings", bytes.NewReader([]byte("not-json")))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
// ─── POST /admin/api/backup ───────────────────────────────────────────────────
func TestAdminAPI_Backup_RequiresOwner(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
// Admin (role 2) can authenticate but is not Owner (role 1, position 100)
adminUID, _ := database.CreateUser("adminonly", "hash", 2)
token := "admin-only-token"
database.CreateSession(adminUID, auth.HashToken(token), "test", "127.0.0.1")
w := doRequest(t, handler, http.MethodPost, "/backup", token, nil)
// Should be forbidden — not Owner role
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403", w.Code)
}
}
func TestAdminAPI_Backup_Unauthenticated(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database)
w := doRequest(t, handler, http.MethodPost, "/backup", "", nil)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
// ─── helpers ─────────────────────────────────────────────────────────────────
// itoa converts an int64 to a string for use in URL paths.
func itoa(n int64) string {
return fmt.Sprint(n)
}
+677 -11
View File
@@ -1,18 +1,684 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OwnCord Admin Panel</title>
<style>
body { font-family: sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
h1 { color: #2c3e50; }
p { color: #7f8c8d; }
</style>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OwnCord Admin</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #1e1f22;
--sidebar-bg: #2b2d31;
--card-bg: #313338;
--border: #3f4147;
--text: #dbdee1;
--muted: #949ba4;
--accent: #5865f2;
--accent-h: #4752c4;
--danger: #ed4245;
--success: #23a55a;
--warning: #f0b232;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
display: flex;
height: 100vh;
overflow: hidden;
}
/* ── Sidebar ── */
#sidebar {
width: 220px;
min-width: 220px;
background: var(--sidebar-bg);
display: flex;
flex-direction: column;
padding: 16px 0;
border-right: 1px solid var(--border);
}
#sidebar h2 {
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--muted);
padding: 8px 16px 4px;
}
#sidebar nav a {
display: block;
padding: 8px 16px;
color: var(--text);
text-decoration: none;
border-radius: 4px;
margin: 2px 8px;
font-size: 14px;
cursor: pointer;
}
#sidebar nav a:hover,
#sidebar nav a.active {
background: var(--accent);
color: #fff;
}
/* ── Main content ── */
#main {
flex: 1;
overflow-y: auto;
padding: 24px;
}
h1 { font-size: 22px; margin-bottom: 20px; }
/* ── Cards ── */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
}
.card .label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; }
.card .value { font-size: 28px; font-weight: 700; margin-top: 6px; }
/* ── Tables ── */
.table-wrap { overflow-x: auto; }
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
th {
text-align: left;
padding: 10px 12px;
background: var(--card-bg);
color: var(--muted);
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: .04em;
border-bottom: 1px solid var(--border);
}
td {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
tr:hover td { background: rgba(255,255,255,.03); }
/* ── Buttons ── */
.btn {
display: inline-block;
padding: 6px 14px;
border: none;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
font-weight: 500;
background: var(--accent);
color: #fff;
text-decoration: none;
}
.btn:hover { background: var(--accent-h); }
.btn-danger { background: var(--danger); }
.btn-danger:hover { background: #c03537; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.btn-secondary { background: var(--card-bg); border: 1px solid var(--border); }
.btn-secondary:hover { background: var(--border); }
/* ── Forms ── */
.form-group { margin-bottom: 16px; }
label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; text-transform: uppercase; font-weight: 600; letter-spacing: .05em; }
input, select, textarea {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
padding: 8px 12px;
font-size: 14px;
}
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
/* ── Login overlay ── */
#login-overlay {
position: fixed; inset: 0;
background: var(--bg);
display: flex; align-items: center; justify-content: center;
z-index: 100;
}
#login-box {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 32px;
width: 360px;
}
#login-box h2 { margin-bottom: 20px; font-size: 20px; }
#login-error { color: var(--danger); font-size: 13px; margin-top: 10px; min-height: 20px; }
/* ── Pagination ── */
.pagination { display: flex; gap: 8px; margin-top: 16px; align-items: center; }
.pagination .page-info { color: var(--muted); font-size: 13px; }
/* ── Alerts ── */
.alert {
padding: 10px 16px;
border-radius: 6px;
margin-bottom: 16px;
font-size: 14px;
}
.alert-success { background: rgba(35, 165, 90, .15); border: 1px solid var(--success); color: var(--success); }
.alert-danger { background: rgba(237, 66, 69, .15); border: 1px solid var(--danger); color: var(--danger); }
/* ── Section heading ── */
.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
/* ── Hidden ── */
.hidden { display: none !important; }
/* ── Inline form row ── */
.form-row { display: flex; gap: 12px; flex-wrap: wrap; }
.form-row .form-group { flex: 1; min-width: 140px; }
</style>
</head>
<body>
<h1>OwnCord Admin Panel</h1>
<p>Admin panel frontend — coming in Phase 6.</p>
<p>For now, use the REST API endpoints under <code>/api/admin/</code> directly.</p>
<!-- Login overlay -->
<div id="login-overlay">
<div id="login-box">
<h2>OwnCord Admin</h2>
<div class="form-group">
<label for="login-username">Username</label>
<input id="login-username" type="text" autocomplete="username" placeholder="admin">
</div>
<div class="form-group">
<label for="login-password">Password</label>
<input id="login-password" type="password" autocomplete="current-password" placeholder="••••••••">
</div>
<button class="btn" id="login-btn" style="width:100%">Sign In</button>
<div id="login-error"></div>
</div>
</div>
<!-- Admin shell -->
<div id="app" class="hidden" style="display:flex;width:100%">
<div id="sidebar">
<h2>OwnCord</h2>
<nav id="nav">
<a href="#" data-section="dashboard" class="active">Dashboard</a>
<a href="#" data-section="users">Users</a>
<a href="#" data-section="channels">Channels</a>
<a href="#" data-section="audit-log">Audit Log</a>
<a href="#" data-section="settings">Settings</a>
</nav>
</div>
<div id="main">
<!-- Dashboard -->
<section id="section-dashboard">
<h1>Dashboard</h1>
<div class="card-grid" id="stats-cards">
<div class="card"><div class="label">Total Users</div><div class="value" id="stat-users">-</div></div>
<div class="card"><div class="label">Total Messages</div><div class="value" id="stat-messages">-</div></div>
<div class="card"><div class="label">Total Channels</div><div class="value" id="stat-channels">-</div></div>
<div class="card"><div class="label">DB Size</div><div class="value" id="stat-db">-</div></div>
</div>
</section>
<!-- Users -->
<section id="section-users" class="hidden">
<div class="section-header">
<h1>Users</h1>
</div>
<div id="users-alert"></div>
<div class="table-wrap">
<table>
<thead>
<tr><th>ID</th><th>Username</th><th>Role</th><th>Status</th><th>Banned</th><th>Actions</th></tr>
</thead>
<tbody id="users-tbody"></tbody>
</table>
</div>
<div class="pagination">
<button class="btn btn-secondary btn-sm" id="users-prev">Prev</button>
<span class="page-info" id="users-page-info"></span>
<button class="btn btn-secondary btn-sm" id="users-next">Next</button>
</div>
</section>
<!-- Channels -->
<section id="section-channels" class="hidden">
<div class="section-header">
<h1>Channels</h1>
<button class="btn btn-sm" id="show-create-channel">+ New Channel</button>
</div>
<div id="create-channel-form" class="card hidden" style="margin-bottom:20px;padding:20px">
<h2 style="font-size:16px;margin-bottom:16px">Create Channel</h2>
<div class="form-row">
<div class="form-group"><label>Name</label><input id="ch-name" type="text" placeholder="general"></div>
<div class="form-group"><label>Type</label>
<select id="ch-type">
<option value="text">Text</option>
<option value="voice">Voice</option>
<option value="announcement">Announcement</option>
</select>
</div>
<div class="form-group"><label>Category</label><input id="ch-category" type="text" placeholder="General"></div>
<div class="form-group"><label>Topic</label><input id="ch-topic" type="text"></div>
<div class="form-group"><label>Position</label><input id="ch-position" type="number" value="0"></div>
</div>
<button class="btn btn-sm" id="create-channel-btn">Create</button>
<button class="btn btn-secondary btn-sm" id="cancel-create-channel">Cancel</button>
</div>
<div id="channels-alert"></div>
<div class="table-wrap">
<table>
<thead>
<tr><th>ID</th><th>Name</th><th>Type</th><th>Category</th><th>Archived</th><th>Actions</th></tr>
</thead>
<tbody id="channels-tbody"></tbody>
</table>
</div>
</section>
<!-- Audit Log -->
<section id="section-audit-log" class="hidden">
<h1>Audit Log</h1>
<div class="table-wrap">
<table>
<thead>
<tr><th>Time</th><th>Actor</th><th>Action</th><th>Target</th><th>Detail</th></tr>
</thead>
<tbody id="audit-tbody"></tbody>
</table>
</div>
<div class="pagination">
<button class="btn btn-secondary btn-sm" id="audit-prev">Prev</button>
<span class="page-info" id="audit-page-info"></span>
<button class="btn btn-secondary btn-sm" id="audit-next">Next</button>
</div>
</section>
<!-- Settings -->
<section id="section-settings" class="hidden">
<h1>Settings</h1>
<div id="settings-alert"></div>
<div class="card" style="max-width:600px;padding:24px">
<div class="form-group">
<label>Server Name</label>
<input id="setting-server_name" type="text">
</div>
<div class="form-group">
<label>Message of the Day (MOTD)</label>
<textarea id="setting-motd" rows="3"></textarea>
</div>
<button class="btn" id="save-settings-btn">Save Settings</button>
</div>
</section>
</div>
</div>
<script>
// ─── State ────────────────────────────────────────────────────────────────────
const PAGE_SIZE = 50;
let token = localStorage.getItem('admin_token') || '';
let usersPage = 0;
let auditPage = 0;
// ─── API ──────────────────────────────────────────────────────────────────────
async function api(method, path, body) {
const opts = {
method,
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
};
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch('/admin/api' + path, opts);
if (res.status === 204) return null;
const data = await res.json();
if (!res.ok) throw new Error(data.message || res.statusText);
return data;
}
// ─── Auth ─────────────────────────────────────────────────────────────────────
async function checkAuth() {
if (!token) { showLogin(); return; }
try {
await api('GET', '/stats');
showApp();
loadSection('dashboard');
} catch (e) {
showLogin();
}
}
function showLogin() {
document.getElementById('login-overlay').classList.remove('hidden');
document.getElementById('app').classList.add('hidden');
}
function showApp() {
document.getElementById('login-overlay').classList.add('hidden');
document.getElementById('app').classList.remove('hidden');
document.getElementById('app').style.display = 'flex';
}
document.getElementById('login-btn').onclick = async () => {
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value;
const errEl = document.getElementById('login-error');
errEl.textContent = '';
try {
const res = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || 'Login failed');
token = data.token;
localStorage.setItem('admin_token', token);
showApp();
loadSection('dashboard');
} catch (e) {
errEl.textContent = e.message;
}
};
// ─── Navigation ───────────────────────────────────────────────────────────────
document.querySelectorAll('#nav a').forEach(link => {
link.onclick = e => {
e.preventDefault();
document.querySelectorAll('#nav a').forEach(a => a.classList.remove('active'));
link.classList.add('active');
loadSection(link.dataset.section);
};
});
function loadSection(name) {
document.querySelectorAll('#main section').forEach(s => s.classList.add('hidden'));
const el = document.getElementById('section-' + name);
if (el) el.classList.remove('hidden');
switch (name) {
case 'dashboard': loadDashboard(); break;
case 'users': usersPage = 0; loadUsers(); break;
case 'channels': loadChannels(); break;
case 'audit-log': auditPage = 0; loadAuditLog(); break;
case 'settings': loadSettings(); break;
}
}
// ─── Dashboard ────────────────────────────────────────────────────────────────
async function loadDashboard() {
try {
const s = await api('GET', '/stats');
document.getElementById('stat-users').textContent = s.user_count;
document.getElementById('stat-messages').textContent = s.message_count;
document.getElementById('stat-channels').textContent = s.channel_count;
document.getElementById('stat-db').textContent = fmtBytes(s.db_size_bytes);
} catch (e) {
console.error('stats:', e);
}
}
function fmtBytes(b) {
if (b < 1024) return b + ' B';
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB';
return (b / (1024 * 1024)).toFixed(1) + ' MB';
}
// ─── Users ────────────────────────────────────────────────────────────────────
async function loadUsers() {
const offset = usersPage * PAGE_SIZE;
try {
const users = await api('GET', '/users?limit=' + PAGE_SIZE + '&offset=' + offset);
const tbody = document.getElementById('users-tbody');
tbody.innerHTML = '';
users.forEach(u => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${esc(u.id)}</td>
<td>${esc(u.Username || u.username)}</td>
<td>
<select data-uid="${esc(u.id)}" class="role-select" style="width:auto">
<option value="1" ${u.role_id === 1 || u.RoleID === 1 ? 'selected' : ''}>Owner</option>
<option value="2" ${u.role_id === 2 || u.RoleID === 2 ? 'selected' : ''}>Admin</option>
<option value="3" ${u.role_id === 3 || u.RoleID === 3 ? 'selected' : ''}>Moderator</option>
<option value="4" ${u.role_id === 4 || u.RoleID === 4 ? 'selected' : ''}>Member</option>
</select>
</td>
<td>${esc(u.Status || u.status || 'offline')}</td>
<td>${(u.Banned || u.banned) ? '<span style="color:var(--danger)">Yes</span>' : 'No'}</td>
<td>
<button class="btn btn-sm btn-secondary" onclick="forceLogout(${u.id})">Logout</button>
${(u.Banned || u.banned)
? '<button class="btn btn-sm" onclick="unbanUser(' + u.id + ')">Unban</button>'
: '<button class="btn btn-sm btn-danger" onclick="banUser(' + u.id + ')">Ban</button>'}
</td>`;
tbody.appendChild(tr);
});
tbody.querySelectorAll('.role-select').forEach(sel => {
sel.onchange = () => changeRole(parseInt(sel.dataset.uid), parseInt(sel.value));
});
document.getElementById('users-page-info').textContent =
`Page ${usersPage + 1} (${users.length} results)`;
document.getElementById('users-prev').disabled = usersPage === 0;
document.getElementById('users-next').disabled = users.length < PAGE_SIZE;
} catch (e) {
showAlert('users-alert', 'danger', e.message);
}
}
async function changeRole(uid, roleId) {
try {
await api('PATCH', '/users/' + uid, { role_id: roleId });
showAlert('users-alert', 'success', 'Role updated');
} catch (e) {
showAlert('users-alert', 'danger', e.message);
}
}
async function banUser(uid) {
const reason = prompt('Ban reason:') || '';
try {
await api('PATCH', '/users/' + uid, { banned: true, ban_reason: reason });
loadUsers();
} catch (e) {
showAlert('users-alert', 'danger', e.message);
}
}
async function unbanUser(uid) {
try {
await api('PATCH', '/users/' + uid, { banned: false });
loadUsers();
} catch (e) {
showAlert('users-alert', 'danger', e.message);
}
}
async function forceLogout(uid) {
if (!confirm('Force logout all sessions for this user?')) return;
try {
await api('DELETE', '/users/' + uid + '/sessions');
showAlert('users-alert', 'success', 'User sessions terminated');
} catch (e) {
showAlert('users-alert', 'danger', e.message);
}
}
document.getElementById('users-prev').onclick = () => { if (usersPage > 0) { usersPage--; loadUsers(); } };
document.getElementById('users-next').onclick = () => { usersPage++; loadUsers(); };
// ─── Channels ─────────────────────────────────────────────────────────────────
async function loadChannels() {
try {
const channels = await api('GET', '/channels');
const tbody = document.getElementById('channels-tbody');
tbody.innerHTML = '';
channels.forEach(ch => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${esc(ch.id || ch.ID)}</td>
<td><input class="ch-name-input" value="${esc(ch.name || ch.Name)}" data-chid="${esc(ch.id || ch.ID)}"></td>
<td>${esc(ch.type || ch.Type)}</td>
<td>${esc(ch.category || ch.Category || '')}</td>
<td>${(ch.archived || ch.Archived) ? 'Yes' : 'No'}</td>
<td>
<button class="btn btn-sm" onclick="saveChannel(${ch.id || ch.ID})">Save</button>
<button class="btn btn-sm btn-danger" onclick="deleteChannel(${ch.id || ch.ID})">Delete</button>
</td>`;
tbody.appendChild(tr);
});
} catch (e) {
showAlert('channels-alert', 'danger', e.message);
}
}
async function saveChannel(id) {
const nameEl = document.querySelector(`.ch-name-input[data-chid="${id}"]`);
const name = nameEl ? nameEl.value.trim() : '';
try {
await api('PATCH', '/channels/' + id, { name });
showAlert('channels-alert', 'success', 'Channel updated');
loadChannels();
} catch (e) {
showAlert('channels-alert', 'danger', e.message);
}
}
async function deleteChannel(id) {
if (!confirm('Delete this channel?')) return;
try {
await api('DELETE', '/channels/' + id);
loadChannels();
} catch (e) {
showAlert('channels-alert', 'danger', e.message);
}
}
document.getElementById('show-create-channel').onclick = () => {
document.getElementById('create-channel-form').classList.toggle('hidden');
};
document.getElementById('cancel-create-channel').onclick = () => {
document.getElementById('create-channel-form').classList.add('hidden');
};
document.getElementById('create-channel-btn').onclick = async () => {
const body = {
name: document.getElementById('ch-name').value.trim(),
type: document.getElementById('ch-type').value,
category: document.getElementById('ch-category').value.trim(),
topic: document.getElementById('ch-topic').value.trim(),
position: parseInt(document.getElementById('ch-position').value) || 0,
};
try {
await api('POST', '/channels', body);
document.getElementById('create-channel-form').classList.add('hidden');
showAlert('channels-alert', 'success', 'Channel created');
loadChannels();
} catch (e) {
showAlert('channels-alert', 'danger', e.message);
}
};
// ─── Audit Log ────────────────────────────────────────────────────────────────
async function loadAuditLog() {
const offset = auditPage * PAGE_SIZE;
try {
const entries = await api('GET', '/audit-log?limit=' + PAGE_SIZE + '&offset=' + offset);
const tbody = document.getElementById('audit-tbody');
tbody.innerHTML = '';
entries.forEach(e => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${esc(e.created_at)}</td>
<td>${esc(e.actor_name || e.actor_id)}</td>
<td>${esc(e.action)}</td>
<td>${esc(e.target_type)} ${e.target_id ? '#' + e.target_id : ''}</td>
<td>${esc(e.detail)}</td>`;
tbody.appendChild(tr);
});
document.getElementById('audit-page-info').textContent =
`Page ${auditPage + 1} (${entries.length} results)`;
document.getElementById('audit-prev').disabled = auditPage === 0;
document.getElementById('audit-next').disabled = entries.length < PAGE_SIZE;
} catch (e) {
console.error('audit-log:', e);
}
}
document.getElementById('audit-prev').onclick = () => { if (auditPage > 0) { auditPage--; loadAuditLog(); } };
document.getElementById('audit-next').onclick = () => { auditPage++; loadAuditLog(); };
// ─── Settings ─────────────────────────────────────────────────────────────────
async function loadSettings() {
try {
const settings = await api('GET', '/settings');
for (const [key, val] of Object.entries(settings)) {
const el = document.getElementById('setting-' + key);
if (el) el.value = val;
}
} catch (e) {
showAlert('settings-alert', 'danger', e.message);
}
}
document.getElementById('save-settings-btn').onclick = async () => {
const body = {};
document.querySelectorAll('[id^="setting-"]').forEach(el => {
const key = el.id.replace('setting-', '');
body[key] = el.value;
});
try {
await api('PATCH', '/settings', body);
showAlert('settings-alert', 'success', 'Settings saved');
} catch (e) {
showAlert('settings-alert', 'danger', e.message);
}
};
// ─── Utilities ────────────────────────────────────────────────────────────────
function esc(s) {
if (s === null || s === undefined) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function showAlert(containerId, type, message) {
const el = document.getElementById(containerId);
if (!el) return;
el.innerHTML = `<div class="alert alert-${type}">${esc(message)}</div>`;
setTimeout(() => { el.innerHTML = ''; }, 4000);
}
// ─── Bootstrap ────────────────────────────────────────────────────────────────
checkAuth();
</script>
</body>
</html>
+7
View File
@@ -7,6 +7,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/owncord/server/admin"
"github.com/owncord/server/auth"
"github.com/owncord/server/config"
"github.com/owncord/server/db"
@@ -46,11 +47,17 @@ func NewRouter(cfg *config.Config, database *db.DB) http.Handler {
// Channel and message REST routes.
MountChannelRoutes(r, database)
// Voice credentials REST route.
MountVoiceRoutes(r, cfg, database)
// WebSocket hub — WS does its own in-band auth, so no AuthMiddleware here.
hub := ws.NewHub(database, limiter)
go hub.Run()
r.Get("/api/v1/ws", ws.ServeWS(hub, database))
// Admin panel: static files + REST API (Phase 6).
r.Mount("/admin", admin.NewHandler(database))
return r
}
+121
View File
@@ -0,0 +1,121 @@
package api
import (
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"fmt"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/owncord/server/config"
"github.com/owncord/server/db"
)
const voiceCredentialTTL = 24 * time.Hour
// iceServer describes a single ICE server entry for WebRTC peer connections.
type iceServer struct {
URLs string `json:"urls"`
Username string `json:"username,omitempty"`
Credential string `json:"credential,omitempty"`
}
// voiceCredentialsResponse is the JSON body for GET /api/v1/voice/credentials.
type voiceCredentialsResponse struct {
ICEServers []iceServer `json:"ice_servers"`
ExpiresIn int `json:"expires_in"`
}
// turnCredentials holds the generated TURN username and HMAC credential.
type turnCredentials struct {
Username string
Credential string
}
// MountVoiceRoutes registers the voice REST endpoints on r.
func MountVoiceRoutes(r chi.Router, cfg *config.Config, database *db.DB) {
r.Route("/api/v1/voice", func(r chi.Router) {
r.Use(AuthMiddleware(database))
r.Get("/credentials", handleVoiceCredentials(cfg, database))
})
}
// handleVoiceCredentials returns ICE server credentials for WebRTC.
// Requires a valid session (AuthMiddleware). Generates time-limited TURN
// credentials using HMAC-SHA1 as per the coturn REST API spec.
func handleVoiceCredentials(cfg *config.Config, _ *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(UserKey).(*db.User)
if !ok || user == nil {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "authentication required",
})
return
}
host := serverHost(r)
servers := buildICEServers(user.ID, cfg, host)
writeJSON(w, http.StatusOK, voiceCredentialsResponse{
ICEServers: servers,
ExpiresIn: int(voiceCredentialTTL.Seconds()),
})
}
}
// buildICEServers constructs the ICE server list for the given user.
func buildICEServers(userID int64, cfg *config.Config, host string) []iceServer {
servers := []iceServer{
{URLs: fmt.Sprintf("stun:%s:%d", host, cfg.Voice.STUNPort)},
}
if cfg.Voice.TURNEnabled && cfg.Voice.TURNSecret != "" {
creds := generateTURNCredentials(userID, cfg.Voice.TURNSecret)
servers = append(servers, iceServer{
URLs: fmt.Sprintf("turn:%s:%d", host, cfg.Voice.TURNPort),
Username: creds.Username,
Credential: creds.Credential,
})
}
return servers
}
// generateTURNCredentials produces time-limited TURN credentials using HMAC-SHA1.
// Username format: "<expiry_unix_timestamp>:<userID>"
// Credential: base64(HMAC-SHA1(secret, username))
func generateTURNCredentials(userID int64, secret string) turnCredentials {
expiry := time.Now().Add(voiceCredentialTTL).Unix()
username := fmt.Sprintf("%d:%d", expiry, userID)
mac := hmac.New(sha1.New, []byte(secret))
mac.Write([]byte(username))
credential := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return turnCredentials{
Username: username,
Credential: credential,
}
}
// serverHost extracts the host for ICE server URLs from the request or falls
// back to "localhost".
func serverHost(r *http.Request) string {
if host := r.Host; host != "" {
// Strip port if present.
for i := len(host) - 1; i >= 0; i-- {
if host[i] == ':' {
return host[:i]
}
if host[i] == ']' {
// IPv6 with no port.
return host
}
}
return host
}
return "localhost"
}
+344
View File
@@ -0,0 +1,344 @@
package api_test
import (
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"
"time"
"github.com/go-chi/chi/v5"
"github.com/owncord/server/api"
"github.com/owncord/server/auth"
"github.com/owncord/server/config"
"github.com/owncord/server/db"
)
// ─── helpers ──────────────────────────────────────────────────────────────────
// newVoiceAPITestDB opens an in-memory DB for voice API tests.
func newVoiceAPITestDB(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: apiTestSchema},
}
if err := db.MigrateFS(database, migrFS); err != nil {
t.Fatalf("MigrateFS: %v", err)
}
return database
}
// buildVoiceRouter returns a chi router with voice routes mounted.
func buildVoiceRouter(database *db.DB, cfg *config.Config) http.Handler {
r := chi.NewRouter()
api.MountVoiceRoutes(r, cfg, database)
return r
}
// seedAPIUser creates a user+session and returns a valid bearer token.
func seedVoiceAPIUser(t *testing.T, database *db.DB, username string) string {
t.Helper()
_, err := database.CreateUser(username, "hash", 4)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
user, err := database.GetUserByUsername(username)
if err != nil || user == nil {
t.Fatalf("GetUserByUsername: %v", err)
}
token := "test-token-" + username
hash := auth.HashToken(token)
future := time.Now().Add(24 * time.Hour).UTC().Format("2006-01-02 15:04:05")
_, err = database.Exec(
`INSERT INTO sessions (user_id, token, device, ip_address, expires_at) VALUES (?, ?, ?, ?, ?)`,
user.ID, hash, "test", "127.0.0.1", future,
)
if err != nil {
t.Fatalf("insert session: %v", err)
}
return token
}
// voiceGetWithToken performs a GET with Authorization: Bearer header.
func voiceGetWithToken(t *testing.T, router http.Handler, path, token string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
// defaultVoiceCfg returns a Config with a known TURN secret for testing.
func defaultVoiceCfg() *config.Config {
return &config.Config{
Server: config.ServerConfig{Name: "Test"},
Voice: config.VoiceConfig{
TURNSecret: "test-secret-key-12345",
STUNPort: 3478,
TURNPort: 3478,
TURNEnabled: true,
},
}
}
// ─── GET /api/v1/voice/credentials ───────────────────────────────────────────
func TestVoiceCredentials_Authenticated_Returns200(t *testing.T) {
database := newVoiceAPITestDB(t)
token := seedVoiceAPIUser(t, database, "alice")
cfg := defaultVoiceCfg()
router := buildVoiceRouter(database, cfg)
rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token)
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
}
func TestVoiceCredentials_Unauthenticated_Returns401(t *testing.T) {
database := newVoiceAPITestDB(t)
cfg := defaultVoiceCfg()
router := buildVoiceRouter(database, cfg)
req := httptest.NewRequest(http.MethodGet, "/api/v1/voice/credentials", nil)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", rr.Code)
}
}
func TestVoiceCredentials_ResponseContainsIceServers(t *testing.T) {
database := newVoiceAPITestDB(t)
token := seedVoiceAPIUser(t, database, "bob")
cfg := defaultVoiceCfg()
router := buildVoiceRouter(database, cfg)
rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token)
var resp map[string]interface{}
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
iceServers, ok := resp["ice_servers"]
if !ok {
t.Fatal("response missing ice_servers field")
}
servers, ok := iceServers.([]interface{})
if !ok || len(servers) == 0 {
t.Error("ice_servers is empty or wrong type")
}
}
func TestVoiceCredentials_ContainsSTUNEntry(t *testing.T) {
database := newVoiceAPITestDB(t)
token := seedVoiceAPIUser(t, database, "carol")
cfg := defaultVoiceCfg()
router := buildVoiceRouter(database, cfg)
rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token)
var resp map[string]interface{}
json.NewDecoder(rr.Body).Decode(&resp)
servers := resp["ice_servers"].([]interface{})
foundSTUN := false
for _, s := range servers {
entry := s.(map[string]interface{})
if urls, ok := entry["urls"].(string); ok {
if len(urls) > 5 && urls[:5] == "stun:" {
foundSTUN = true
break
}
}
}
if !foundSTUN {
t.Error("ice_servers does not contain a STUN entry")
}
}
func TestVoiceCredentials_ContainsTURNEntry(t *testing.T) {
database := newVoiceAPITestDB(t)
token := seedVoiceAPIUser(t, database, "dave")
cfg := defaultVoiceCfg()
router := buildVoiceRouter(database, cfg)
rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token)
var resp map[string]interface{}
json.NewDecoder(rr.Body).Decode(&resp)
servers := resp["ice_servers"].([]interface{})
foundTURN := false
for _, s := range servers {
entry := s.(map[string]interface{})
if urls, ok := entry["urls"].(string); ok {
if len(urls) > 5 && urls[:5] == "turn:" {
foundTURN = true
// TURN entries must have username and credential.
if _, hasUser := entry["username"]; !hasUser {
t.Error("TURN entry missing username")
}
if _, hasCred := entry["credential"]; !hasCred {
t.Error("TURN entry missing credential")
}
break
}
}
}
if !foundTURN {
t.Error("ice_servers does not contain a TURN entry")
}
}
func TestVoiceCredentials_TURNCredentialIsValidHMAC(t *testing.T) {
database := newVoiceAPITestDB(t)
token := seedVoiceAPIUser(t, database, "eve")
secret := "test-secret-key-12345"
cfg := &config.Config{
Voice: config.VoiceConfig{
TURNSecret: secret,
STUNPort: 3478,
TURNPort: 3478,
TURNEnabled: true,
},
}
router := buildVoiceRouter(database, cfg)
rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token)
var resp map[string]interface{}
json.NewDecoder(rr.Body).Decode(&resp)
servers := resp["ice_servers"].([]interface{})
for _, s := range servers {
entry := s.(map[string]interface{})
urls, _ := entry["urls"].(string)
if len(urls) < 5 || urls[:5] != "turn:" {
continue
}
username, _ := entry["username"].(string)
credential, _ := entry["credential"].(string)
if username == "" || credential == "" {
t.Fatal("TURN entry has empty username or credential")
}
// Verify HMAC-SHA1: credential should be base64(HMAC-SHA1(secret, username)).
mac := hmac.New(sha1.New, []byte(secret))
mac.Write([]byte(username))
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
if credential != expected {
t.Errorf("TURN credential HMAC mismatch\n got: %s\n want: %s", credential, expected)
}
return
}
t.Error("no TURN entry found to validate HMAC")
}
func TestVoiceCredentials_UsernameContainsTimestampAndUserID(t *testing.T) {
database := newVoiceAPITestDB(t)
token := seedVoiceAPIUser(t, database, "frank")
cfg := defaultVoiceCfg()
router := buildVoiceRouter(database, cfg)
rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token)
var resp map[string]interface{}
json.NewDecoder(rr.Body).Decode(&resp)
servers := resp["ice_servers"].([]interface{})
for _, s := range servers {
entry := s.(map[string]interface{})
urls, _ := entry["urls"].(string)
if len(urls) < 5 || urls[:5] != "turn:" {
continue
}
username, _ := entry["username"].(string)
// Username format: "<unix_timestamp>:<userID>".
var ts, uid int64
if _, err := fmt.Sscanf(username, "%d:%d", &ts, &uid); err != nil {
t.Errorf("TURN username %q is not in format <timestamp>:<userID>: %v", username, err)
}
if ts <= time.Now().Unix() {
t.Errorf("TURN username timestamp %d is in the past, want future", ts)
}
if uid <= 0 {
t.Errorf("TURN username userID %d must be positive", uid)
}
return
}
t.Error("no TURN entry found to validate username format")
}
func TestVoiceCredentials_ResponseContainsExpiresIn(t *testing.T) {
database := newVoiceAPITestDB(t)
token := seedVoiceAPIUser(t, database, "grace")
cfg := defaultVoiceCfg()
router := buildVoiceRouter(database, cfg)
rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token)
var resp map[string]interface{}
json.NewDecoder(rr.Body).Decode(&resp)
expiresIn, ok := resp["expires_in"]
if !ok {
t.Fatal("response missing expires_in field")
}
// expires_in should be 86400 (24 hours in seconds).
val, ok := expiresIn.(float64)
if !ok || val != 86400 {
t.Errorf("expires_in = %v, want 86400", expiresIn)
}
}
func TestVoiceCredentials_TURNDisabled_NoTURNEntry(t *testing.T) {
database := newVoiceAPITestDB(t)
token := seedVoiceAPIUser(t, database, "henry")
cfg := &config.Config{
Voice: config.VoiceConfig{
TURNSecret: "secret",
STUNPort: 3478,
TURNPort: 3478,
TURNEnabled: false, // TURN disabled
},
}
router := buildVoiceRouter(database, cfg)
rr := voiceGetWithToken(t, router, "/api/v1/voice/credentials", token)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
var resp map[string]interface{}
json.NewDecoder(rr.Body).Decode(&resp)
servers := resp["ice_servers"].([]interface{})
for _, s := range servers {
entry := s.(map[string]interface{})
if urls, _ := entry["urls"].(string); len(urls) >= 5 && urls[:5] == "turn:" {
t.Error("TURN entry present when TURNEnabled=false")
}
}
}
+14
View File
@@ -20,6 +20,15 @@ type Config struct {
Database DatabaseConfig `koanf:"database"`
TLS TLSConfig `koanf:"tls"`
Upload UploadConfig `koanf:"upload"`
Voice VoiceConfig `koanf:"voice"`
}
// VoiceConfig holds STUN/TURN server settings for WebRTC signaling.
type VoiceConfig struct {
TURNSecret string `koanf:"turn_secret"` // HMAC-SHA1 secret; auto-generated if empty
STUNPort int `koanf:"stun_port"` // default 3478
TURNPort int `koanf:"turn_port"` // default 3478
TURNEnabled bool `koanf:"turn_enabled"` // default true
}
// ServerConfig holds HTTP server settings.
@@ -66,6 +75,11 @@ func defaults() Config {
MaxSizeMB: 100,
StorageDir: "data/uploads",
},
Voice: VoiceConfig{
STUNPort: 3478,
TURNPort: 3478,
TURNEnabled: true,
},
}
}
+307
View File
@@ -0,0 +1,307 @@
package db
import (
"database/sql"
"errors"
"fmt"
)
// ─── Permission constants ─────────────────────────────────────────────────────
const (
permAdministrator = int64(0x40000000)
permManageServer = int64(0x2000000)
permViewAuditLog = int64(0x8000000)
)
// ─── Server Stats ─────────────────────────────────────────────────────────────
// GetServerStats returns aggregate counts for the admin dashboard.
// DBSizeBytes is 0 for in-memory databases (page_count * page_size returns
// a meaningful value only for file-backed databases).
func (d *DB) GetServerStats() (*ServerStats, error) {
stats := &ServerStats{}
if err := d.sqlDB.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&stats.UserCount); err != nil {
return nil, fmt.Errorf("GetServerStats users: %w", err)
}
if err := d.sqlDB.QueryRow(`SELECT COUNT(*) FROM messages WHERE deleted = 0`).Scan(&stats.MessageCount); err != nil {
return nil, fmt.Errorf("GetServerStats messages: %w", err)
}
if err := d.sqlDB.QueryRow(`SELECT COUNT(*) FROM channels`).Scan(&stats.ChannelCount); err != nil {
return nil, fmt.Errorf("GetServerStats channels: %w", err)
}
if err := d.sqlDB.QueryRow(`SELECT COUNT(*) FROM invites WHERE revoked = 0`).Scan(&stats.InviteCount); err != nil {
return nil, fmt.Errorf("GetServerStats invites: %w", err)
}
// page_count * page_size gives the database size in bytes.
// For :memory: databases this still works (returns the in-memory size).
var pageCount, pageSize int64
if err := d.sqlDB.QueryRow(`PRAGMA page_count`).Scan(&pageCount); err != nil {
return nil, fmt.Errorf("GetServerStats page_count: %w", err)
}
if err := d.sqlDB.QueryRow(`PRAGMA page_size`).Scan(&pageSize); err != nil {
return nil, fmt.Errorf("GetServerStats page_size: %w", err)
}
stats.DBSizeBytes = pageCount * pageSize
return stats, nil
}
// ─── User Management ──────────────────────────────────────────────────────────
// ListAllUsers returns users joined with their role name, ordered by ID.
// limit=0 returns no rows.
func (d *DB) ListAllUsers(limit, offset int) ([]UserWithRole, error) {
rows, err := d.sqlDB.Query(
`SELECT u.id, u.username, u.password, u.avatar, u.role_id, u.totp_secret,
u.status, u.created_at, u.last_seen, u.banned, u.ban_reason, u.ban_expires,
COALESCE(r.name, '') AS role_name
FROM users u
LEFT JOIN roles r ON r.id = u.role_id
ORDER BY u.id ASC
LIMIT ? OFFSET ?`,
limit, offset,
)
if err != nil {
return nil, fmt.Errorf("ListAllUsers: %w", err)
}
defer rows.Close()
var result []UserWithRole
for rows.Next() {
var uwr UserWithRole
var banned int
err := rows.Scan(
&uwr.ID, &uwr.Username, &uwr.PasswordHash, &uwr.Avatar, &uwr.RoleID,
&uwr.TOTPSecret, &uwr.Status, &uwr.CreatedAt, &uwr.LastSeen,
&banned, &uwr.BanReason, &uwr.BanExpires,
&uwr.RoleName,
)
if err != nil {
return nil, fmt.Errorf("ListAllUsers scan: %w", err)
}
uwr.Banned = banned != 0
result = append(result, uwr)
}
if rows.Err() != nil {
return nil, fmt.Errorf("ListAllUsers rows: %w", rows.Err())
}
if result == nil {
result = []UserWithRole{}
}
return result, nil
}
// UpdateUserRole changes the role_id of a user.
func (d *DB) UpdateUserRole(userID, roleID int64) error {
_, err := d.sqlDB.Exec(
`UPDATE users SET role_id = ? WHERE id = ?`,
roleID, userID,
)
if err != nil {
return fmt.Errorf("UpdateUserRole: %w", err)
}
return nil
}
// ForceLogoutUser deletes all sessions for the given user ID.
func (d *DB) ForceLogoutUser(userID int64) error {
_, err := d.sqlDB.Exec(`DELETE FROM sessions WHERE user_id = ?`, userID)
if err != nil {
return fmt.Errorf("ForceLogoutUser: %w", err)
}
return nil
}
// GetUserSessions returns all active sessions for the given user ID.
func (d *DB) GetUserSessions(userID int64) ([]Session, error) {
rows, err := d.sqlDB.Query(
`SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at
FROM sessions WHERE user_id = ? ORDER BY created_at DESC`,
userID,
)
if err != nil {
return nil, fmt.Errorf("GetUserSessions: %w", err)
}
defer rows.Close()
var sessions []Session
for rows.Next() {
var s Session
err := rows.Scan(
&s.ID, &s.UserID, &s.TokenHash, &s.Device, &s.IP,
&s.CreatedAt, &s.LastUsed, &s.ExpiresAt,
)
if err != nil {
return nil, fmt.Errorf("GetUserSessions scan: %w", err)
}
sessions = append(sessions, s)
}
if rows.Err() != nil {
return nil, fmt.Errorf("GetUserSessions rows: %w", rows.Err())
}
if sessions == nil {
sessions = []Session{}
}
return sessions, nil
}
// ─── Channel Management (admin) ───────────────────────────────────────────────
// AdminCreateChannel creates a channel with full field control including position.
func (d *DB) AdminCreateChannel(name, chanType, category, topic string, position int) (int64, error) {
res, err := d.sqlDB.Exec(
`INSERT INTO channels (name, type, category, topic, position)
VALUES (?, ?, ?, ?, ?)`,
name, chanType, nullableString(category), nullableString(topic), position,
)
if err != nil {
return 0, fmt.Errorf("AdminCreateChannel: %w", err)
}
return res.LastInsertId()
}
// AdminUpdateChannel updates all mutable channel fields.
func (d *DB) AdminUpdateChannel(id int64, name, topic string, slowMode, position int, archived bool) error {
archivedInt := 0
if archived {
archivedInt = 1
}
_, err := d.sqlDB.Exec(
`UPDATE channels
SET name = ?, topic = ?, slow_mode = ?, position = ?, archived = ?
WHERE id = ?`,
name, nullableString(topic), slowMode, position, archivedInt, id,
)
if err != nil {
return fmt.Errorf("AdminUpdateChannel: %w", err)
}
return nil
}
// AdminDeleteChannel removes a channel by ID (cascades to messages, etc.).
func (d *DB) AdminDeleteChannel(id int64) error {
_, err := d.sqlDB.Exec(`DELETE FROM channels WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("AdminDeleteChannel: %w", err)
}
return nil
}
// ─── Audit Log ────────────────────────────────────────────────────────────────
// LogAudit inserts an audit log entry.
func (d *DB) LogAudit(actorID int64, action, targetType string, targetID int64, detail string) error {
_, err := d.sqlDB.Exec(
`INSERT INTO audit_log (actor_id, action, target_type, target_id, detail)
VALUES (?, ?, ?, ?, ?)`,
actorID, action, targetType, targetID, detail,
)
if err != nil {
return fmt.Errorf("LogAudit: %w", err)
}
return nil
}
// GetAuditLog returns audit log entries ordered newest-first with pagination.
func (d *DB) GetAuditLog(limit, offset int) ([]AuditEntry, error) {
rows, err := d.sqlDB.Query(
`SELECT a.id, a.actor_id, COALESCE(u.username, ''), a.action,
a.target_type, a.target_id, a.detail, a.created_at
FROM audit_log a
LEFT JOIN users u ON u.id = a.actor_id
ORDER BY a.id DESC
LIMIT ? OFFSET ?`,
limit, offset,
)
if err != nil {
return nil, fmt.Errorf("GetAuditLog: %w", err)
}
defer rows.Close()
var entries []AuditEntry
for rows.Next() {
var e AuditEntry
if err := rows.Scan(
&e.ID, &e.ActorID, &e.ActorName, &e.Action,
&e.TargetType, &e.TargetID, &e.Detail, &e.CreatedAt,
); err != nil {
return nil, fmt.Errorf("GetAuditLog scan: %w", err)
}
entries = append(entries, e)
}
if rows.Err() != nil {
return nil, fmt.Errorf("GetAuditLog rows: %w", rows.Err())
}
if entries == nil {
entries = []AuditEntry{}
}
return entries, nil
}
// ─── Settings ─────────────────────────────────────────────────────────────────
// GetSetting returns the value for the given settings key.
// Returns an error (wrapping sql.ErrNoRows) when the key does not exist.
func (d *DB) GetSetting(key string) (string, error) {
var value string
err := d.sqlDB.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value)
if errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("GetSetting: key %q not found", key)
}
if err != nil {
return "", fmt.Errorf("GetSetting: %w", err)
}
return value, nil
}
// SetSetting upserts a setting value for the given key.
func (d *DB) SetSetting(key, value string) error {
_, err := d.sqlDB.Exec(
`INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
key, value,
)
if err != nil {
return fmt.Errorf("SetSetting: %w", err)
}
return nil
}
// GetAllSettings returns all settings as a key→value map.
func (d *DB) GetAllSettings() (map[string]string, error) {
rows, err := d.sqlDB.Query(`SELECT key, value FROM settings`)
if err != nil {
return nil, fmt.Errorf("GetAllSettings: %w", err)
}
defer rows.Close()
result := make(map[string]string)
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, fmt.Errorf("GetAllSettings scan: %w", err)
}
result[k] = v
}
if rows.Err() != nil {
return nil, fmt.Errorf("GetAllSettings rows: %w", rows.Err())
}
return result, nil
}
// ─── Backup ───────────────────────────────────────────────────────────────────
// BackupTo creates an online backup of the database at the given path using
// SQLite's VACUUM INTO statement. The destination path must not already exist.
// This only works meaningfully for file-backed databases; in-memory databases
// will produce a valid but potentially minimal backup file.
func (d *DB) BackupTo(path string) error {
_, err := d.sqlDB.Exec(fmt.Sprintf("VACUUM INTO '%s'", path))
if err != nil {
return fmt.Errorf("BackupTo: %w", err)
}
return nil
}
+729
View File
@@ -0,0 +1,729 @@
package db_test
import (
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"github.com/owncord/server/db"
)
// adminTestSchema extends testSchema with tables needed for admin queries.
var adminTestSchema = append(testSchema, []byte(`
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'))
);
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 TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_id INTEGER NOT NULL REFERENCES users(id),
action TEXT NOT NULL,
target_type TEXT NOT NULL DEFAULT '',
target_id INTEGER NOT NULL DEFAULT 0,
detail TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_id);
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!');
`)...)
// newAdminTestDB opens an in-memory database with the admin-extended schema.
func newAdminTestDB(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: adminTestSchema},
}
if err := db.MigrateFS(database, migrFS); err != nil {
t.Fatalf("MigrateFS: %v", err)
}
return database
}
// ─── GetServerStats ────────────────────────────────────────────────────────────
func TestGetServerStats_EmptyDB(t *testing.T) {
database := newAdminTestDB(t)
stats, err := database.GetServerStats()
if err != nil {
t.Fatalf("GetServerStats() error: %v", err)
}
if stats == nil {
t.Fatal("GetServerStats() returned nil")
}
if stats.UserCount != 0 {
t.Errorf("UserCount = %d, want 0", stats.UserCount)
}
if stats.MessageCount != 0 {
t.Errorf("MessageCount = %d, want 0", stats.MessageCount)
}
if stats.ChannelCount != 0 {
t.Errorf("ChannelCount = %d, want 0", stats.ChannelCount)
}
if stats.InviteCount != 0 {
t.Errorf("InviteCount = %d, want 0", stats.InviteCount)
}
if stats.DBSizeBytes < 0 {
t.Errorf("DBSizeBytes = %d, want >= 0", stats.DBSizeBytes)
}
}
func TestGetServerStats_WithData(t *testing.T) {
database := newAdminTestDB(t)
_, err := database.CreateUser("statuser", "hash", 4)
if err != nil {
t.Fatalf("CreateUser error: %v", err)
}
_, err = database.CreateChannel("general", "text", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel error: %v", err)
}
stats, err := database.GetServerStats()
if err != nil {
t.Fatalf("GetServerStats() error: %v", err)
}
if stats.UserCount != 1 {
t.Errorf("UserCount = %d, want 1", stats.UserCount)
}
if stats.ChannelCount != 1 {
t.Errorf("ChannelCount = %d, want 1", stats.ChannelCount)
}
}
// ─── ListAllUsers ──────────────────────────────────────────────────────────────
func TestListAllUsers_Empty(t *testing.T) {
database := newAdminTestDB(t)
users, err := database.ListAllUsers(50, 0)
if err != nil {
t.Fatalf("ListAllUsers() error: %v", err)
}
if len(users) != 0 {
t.Errorf("ListAllUsers() = %d users, want 0", len(users))
}
}
func TestListAllUsers_WithRoleName(t *testing.T) {
database := newAdminTestDB(t)
_, err := database.CreateUser("alice", "hash", 4)
if err != nil {
t.Fatalf("CreateUser error: %v", err)
}
users, err := database.ListAllUsers(50, 0)
if err != nil {
t.Fatalf("ListAllUsers() error: %v", err)
}
if len(users) != 1 {
t.Fatalf("ListAllUsers() = %d users, want 1", len(users))
}
if users[0].Username != "alice" {
t.Errorf("Username = %q, want 'alice'", users[0].Username)
}
// RoleName comes from JOIN with roles table
if users[0].RoleName == "" {
t.Error("RoleName should not be empty — JOIN with roles table failed")
}
}
func TestListAllUsers_Pagination(t *testing.T) {
database := newAdminTestDB(t)
for i := 0; i < 5; i++ {
_, err := database.CreateUser(
strings.Repeat("u", i+1),
"hash",
4,
)
if err != nil {
t.Fatalf("CreateUser[%d] error: %v", i, err)
}
}
page1, err := database.ListAllUsers(3, 0)
if err != nil {
t.Fatalf("ListAllUsers page1 error: %v", err)
}
if len(page1) != 3 {
t.Errorf("page1 len = %d, want 3", len(page1))
}
page2, err := database.ListAllUsers(3, 3)
if err != nil {
t.Fatalf("ListAllUsers page2 error: %v", err)
}
if len(page2) != 2 {
t.Errorf("page2 len = %d, want 2", len(page2))
}
}
func TestListAllUsers_ZeroLimit(t *testing.T) {
database := newAdminTestDB(t)
database.CreateUser("zerotest", "hash", 4)
users, err := database.ListAllUsers(0, 0)
if err != nil {
t.Fatalf("ListAllUsers(0, 0) error: %v", err)
}
// limit=0 should return nothing
if len(users) != 0 {
t.Errorf("ListAllUsers(0, 0) = %d users, want 0", len(users))
}
}
// ─── UpdateUserRole ────────────────────────────────────────────────────────────
func TestUpdateUserRole(t *testing.T) {
database := newAdminTestDB(t)
uid, err := database.CreateUser("roleuser", "hash", 4)
if err != nil {
t.Fatalf("CreateUser error: %v", err)
}
if err := database.UpdateUserRole(uid, 2); err != nil {
t.Fatalf("UpdateUserRole() error: %v", err)
}
user, err := database.GetUserByID(uid)
if err != nil {
t.Fatalf("GetUserByID error: %v", err)
}
if user.RoleID != 2 {
t.Errorf("RoleID = %d, want 2", user.RoleID)
}
}
func TestUpdateUserRole_NonexistentUser(t *testing.T) {
database := newAdminTestDB(t)
// UPDATE with no matching rows is not an error
err := database.UpdateUserRole(99999, 2)
if err != nil {
t.Errorf("UpdateUserRole() for nonexistent user returned unexpected error: %v", err)
}
}
// ─── ForceLogoutUser ───────────────────────────────────────────────────────────
func TestForceLogoutUser_DeletesSessions(t *testing.T) {
database := newAdminTestDB(t)
uid, err := database.CreateUser("logoutuser", "hash", 4)
if err != nil {
t.Fatalf("CreateUser error: %v", err)
}
database.CreateSession(uid, "token1hash", "device1", "127.0.0.1")
database.CreateSession(uid, "token2hash", "device2", "127.0.0.1")
sessions, err := database.GetUserSessions(uid)
if err != nil {
t.Fatalf("GetUserSessions error: %v", err)
}
if len(sessions) != 2 {
t.Fatalf("expected 2 sessions before logout, got %d", len(sessions))
}
if err := database.ForceLogoutUser(uid); err != nil {
t.Fatalf("ForceLogoutUser() error: %v", err)
}
sessions, err = database.GetUserSessions(uid)
if err != nil {
t.Fatalf("GetUserSessions after logout error: %v", err)
}
if len(sessions) != 0 {
t.Errorf("expected 0 sessions after ForceLogoutUser, got %d", len(sessions))
}
}
func TestForceLogoutUser_NoSessions(t *testing.T) {
database := newAdminTestDB(t)
uid, err := database.CreateUser("nosessions", "hash", 4)
if err != nil {
t.Fatalf("CreateUser error: %v", err)
}
if err := database.ForceLogoutUser(uid); err != nil {
t.Errorf("ForceLogoutUser() on user with no sessions returned error: %v", err)
}
}
// ─── GetUserSessions ──────────────────────────────────────────────────────────
func TestGetUserSessions_Empty(t *testing.T) {
database := newAdminTestDB(t)
uid, err := database.CreateUser("sessionuser", "hash", 4)
if err != nil {
t.Fatalf("CreateUser error: %v", err)
}
sessions, err := database.GetUserSessions(uid)
if err != nil {
t.Fatalf("GetUserSessions() error: %v", err)
}
if len(sessions) != 0 {
t.Errorf("GetUserSessions() = %d, want 0", len(sessions))
}
}
func TestGetUserSessions_IsolatedByUser(t *testing.T) {
database := newAdminTestDB(t)
uid1, _ := database.CreateUser("user1sess", "hash", 4)
uid2, _ := database.CreateUser("user2sess", "hash", 4)
database.CreateSession(uid1, "u1t1", "web", "1.2.3.4")
database.CreateSession(uid1, "u1t2", "mobile", "1.2.3.5")
database.CreateSession(uid2, "u2t1", "web", "1.2.3.6")
sessions, err := database.GetUserSessions(uid1)
if err != nil {
t.Fatalf("GetUserSessions() error: %v", err)
}
if len(sessions) != 2 {
t.Errorf("GetUserSessions(uid1) = %d sessions, want 2", len(sessions))
}
for _, s := range sessions {
if s.UserID != uid1 {
t.Errorf("session UserID = %d, want %d", s.UserID, uid1)
}
}
}
// ─── AdminCreateChannel ────────────────────────────────────────────────────────
func TestAdminCreateChannel(t *testing.T) {
database := newAdminTestDB(t)
id, err := database.AdminCreateChannel("announce", "text", "General", "Announcements", 1)
if err != nil {
t.Fatalf("AdminCreateChannel() error: %v", err)
}
if id <= 0 {
t.Errorf("AdminCreateChannel() id = %d, want > 0", id)
}
ch, err := database.GetChannel(id)
if err != nil {
t.Fatalf("GetChannel() error: %v", err)
}
if ch == nil {
t.Fatal("GetChannel() returned nil after AdminCreateChannel")
}
if ch.Name != "announce" {
t.Errorf("Name = %q, want 'announce'", ch.Name)
}
if ch.Type != "text" {
t.Errorf("Type = %q, want 'text'", ch.Type)
}
if ch.Category != "General" {
t.Errorf("Category = %q, want 'General'", ch.Category)
}
if ch.Topic != "Announcements" {
t.Errorf("Topic = %q, want 'Announcements'", ch.Topic)
}
if ch.Position != 1 {
t.Errorf("Position = %d, want 1", ch.Position)
}
}
func TestAdminCreateChannel_EmptyOptionals(t *testing.T) {
database := newAdminTestDB(t)
id, err := database.AdminCreateChannel("simple", "voice", "", "", 0)
if err != nil {
t.Fatalf("AdminCreateChannel() error: %v", err)
}
ch, err := database.GetChannel(id)
if err != nil {
t.Fatalf("GetChannel() error: %v", err)
}
if ch.Category != "" {
t.Errorf("Category = %q, want ''", ch.Category)
}
if ch.Topic != "" {
t.Errorf("Topic = %q, want ''", ch.Topic)
}
}
// ─── AdminUpdateChannel ────────────────────────────────────────────────────────
func TestAdminUpdateChannel(t *testing.T) {
database := newAdminTestDB(t)
id, err := database.AdminCreateChannel("old-name", "text", "", "", 0)
if err != nil {
t.Fatalf("AdminCreateChannel() error: %v", err)
}
if err := database.AdminUpdateChannel(id, "new-name", "new topic", 5, 2, true); err != nil {
t.Fatalf("AdminUpdateChannel() error: %v", err)
}
ch, err := database.GetChannel(id)
if err != nil {
t.Fatalf("GetChannel() error: %v", err)
}
if ch.Name != "new-name" {
t.Errorf("Name = %q, want 'new-name'", ch.Name)
}
if ch.Topic != "new topic" {
t.Errorf("Topic = %q, want 'new topic'", ch.Topic)
}
if ch.SlowMode != 5 {
t.Errorf("SlowMode = %d, want 5", ch.SlowMode)
}
if ch.Position != 2 {
t.Errorf("Position = %d, want 2", ch.Position)
}
if !ch.Archived {
t.Error("Archived = false, want true")
}
}
func TestAdminUpdateChannel_Unarchive(t *testing.T) {
database := newAdminTestDB(t)
id, _ := database.AdminCreateChannel("arch-ch", "text", "", "", 0)
database.AdminUpdateChannel(id, "arch-ch", "", 0, 0, true)
ch, _ := database.GetChannel(id)
if !ch.Archived {
t.Fatal("channel should be archived")
}
// Unarchive
database.AdminUpdateChannel(id, "arch-ch", "", 0, 0, false)
ch, _ = database.GetChannel(id)
if ch.Archived {
t.Error("Archived = true after unarchiving, want false")
}
}
// ─── AdminDeleteChannel ────────────────────────────────────────────────────────
func TestAdminDeleteChannel(t *testing.T) {
database := newAdminTestDB(t)
id, err := database.AdminCreateChannel("to-delete", "text", "", "", 0)
if err != nil {
t.Fatalf("AdminCreateChannel() error: %v", err)
}
if err := database.AdminDeleteChannel(id); err != nil {
t.Fatalf("AdminDeleteChannel() error: %v", err)
}
ch, err := database.GetChannel(id)
if err != nil {
t.Fatalf("GetChannel() after delete error: %v", err)
}
if ch != nil {
t.Error("channel should not exist after AdminDeleteChannel")
}
}
func TestAdminDeleteChannel_NonExistent(t *testing.T) {
database := newAdminTestDB(t)
// Deleting nonexistent channel should not error
if err := database.AdminDeleteChannel(99999); err != nil {
t.Errorf("AdminDeleteChannel(nonexistent) error: %v", err)
}
}
// ─── LogAudit / GetAuditLog ────────────────────────────────────────────────────
func TestLogAudit_AndRetrieve(t *testing.T) {
database := newAdminTestDB(t)
uid, err := database.CreateUser("auditor", "hash", 1)
if err != nil {
t.Fatalf("CreateUser error: %v", err)
}
if err := database.LogAudit(uid, "USER_BANNED", "user", 42, "banned for spam"); err != nil {
t.Fatalf("LogAudit() error: %v", err)
}
entries, err := database.GetAuditLog(10, 0)
if err != nil {
t.Fatalf("GetAuditLog() error: %v", err)
}
if len(entries) != 1 {
t.Fatalf("GetAuditLog() = %d entries, want 1", len(entries))
}
e := entries[0]
if e.ActorID != uid {
t.Errorf("ActorID = %d, want %d", e.ActorID, uid)
}
if e.Action != "USER_BANNED" {
t.Errorf("Action = %q, want 'USER_BANNED'", e.Action)
}
if e.TargetType != "user" {
t.Errorf("TargetType = %q, want 'user'", e.TargetType)
}
if e.TargetID != 42 {
t.Errorf("TargetID = %d, want 42", e.TargetID)
}
if e.Detail != "banned for spam" {
t.Errorf("Detail = %q, want 'banned for spam'", e.Detail)
}
if e.ActorName != "auditor" {
t.Errorf("ActorName = %q, want 'auditor'", e.ActorName)
}
if e.CreatedAt == "" {
t.Error("CreatedAt should not be empty")
}
}
func TestGetAuditLog_Empty(t *testing.T) {
database := newAdminTestDB(t)
entries, err := database.GetAuditLog(10, 0)
if err != nil {
t.Fatalf("GetAuditLog() error: %v", err)
}
if len(entries) != 0 {
t.Errorf("GetAuditLog() = %d entries, want 0", len(entries))
}
}
func TestGetAuditLog_Pagination(t *testing.T) {
database := newAdminTestDB(t)
uid, _ := database.CreateUser("auditpager", "hash", 1)
for i := 0; i < 5; i++ {
database.LogAudit(uid, "ACTION", "target", int64(i), "detail")
}
page1, err := database.GetAuditLog(3, 0)
if err != nil {
t.Fatalf("GetAuditLog page1 error: %v", err)
}
if len(page1) != 3 {
t.Errorf("page1 len = %d, want 3", len(page1))
}
page2, err := database.GetAuditLog(3, 3)
if err != nil {
t.Fatalf("GetAuditLog page2 error: %v", err)
}
if len(page2) != 2 {
t.Errorf("page2 len = %d, want 2", len(page2))
}
}
func TestGetAuditLog_NewestFirst(t *testing.T) {
database := newAdminTestDB(t)
uid, _ := database.CreateUser("auditorder", "hash", 1)
database.LogAudit(uid, "FIRST", "", 0, "")
database.LogAudit(uid, "SECOND", "", 0, "")
entries, err := database.GetAuditLog(10, 0)
if err != nil {
t.Fatalf("GetAuditLog() error: %v", err)
}
if len(entries) < 2 {
t.Fatalf("expected at least 2 entries, got %d", len(entries))
}
if entries[0].ID <= entries[1].ID {
t.Error("GetAuditLog should return newest entries first (highest ID first)")
}
}
// ─── GetSetting / SetSetting / GetAllSettings ──────────────────────────────────
func TestGetSetting_Exists(t *testing.T) {
database := newAdminTestDB(t)
val, err := database.GetSetting("server_name")
if err != nil {
t.Fatalf("GetSetting() error: %v", err)
}
if val == "" {
t.Error("server_name should not be empty")
}
}
func TestGetSetting_NotFound(t *testing.T) {
database := newAdminTestDB(t)
_, err := database.GetSetting("nonexistent_key_xyz")
if err == nil {
t.Error("GetSetting() for nonexistent key should return error")
}
}
func TestSetSetting_NewKey(t *testing.T) {
database := newAdminTestDB(t)
if err := database.SetSetting("custom_key", "custom_val"); err != nil {
t.Fatalf("SetSetting() error: %v", err)
}
val, err := database.GetSetting("custom_key")
if err != nil {
t.Fatalf("GetSetting() after SetSetting error: %v", err)
}
if val != "custom_val" {
t.Errorf("val = %q, want 'custom_val'", val)
}
}
func TestSetSetting_UpdateExisting(t *testing.T) {
database := newAdminTestDB(t)
if err := database.SetSetting("server_name", "My Custom Server"); err != nil {
t.Fatalf("SetSetting() update error: %v", err)
}
val, err := database.GetSetting("server_name")
if err != nil {
t.Fatalf("GetSetting() error: %v", err)
}
if val != "My Custom Server" {
t.Errorf("val = %q, want 'My Custom Server'", val)
}
}
func TestGetAllSettings_ReturnsMap(t *testing.T) {
database := newAdminTestDB(t)
settings, err := database.GetAllSettings()
if err != nil {
t.Fatalf("GetAllSettings() error: %v", err)
}
if len(settings) == 0 {
t.Error("GetAllSettings() should return default settings")
}
if _, ok := settings["server_name"]; !ok {
t.Error("GetAllSettings() missing 'server_name'")
}
}
func TestGetAllSettings_AfterClearing(t *testing.T) {
database := newAdminTestDB(t)
database.Exec("DELETE FROM settings")
settings, err := database.GetAllSettings()
if err != nil {
t.Fatalf("GetAllSettings() after clearing error: %v", err)
}
if len(settings) != 0 {
t.Errorf("GetAllSettings() after clearing = %d entries, want 0", len(settings))
}
}
// ─── BackupTo ─────────────────────────────────────────────────────────────────
func TestBackupTo(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "source.db")
database, err := db.Open(dbPath)
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { database.Close() })
migrFS := fstest.MapFS{
"001_schema.sql": {Data: adminTestSchema},
}
if err := db.MigrateFS(database, migrFS); err != nil {
t.Fatalf("MigrateFS: %v", err)
}
backupPath := filepath.Join(tmpDir, "backup.db")
if err := database.BackupTo(backupPath); err != nil {
t.Fatalf("BackupTo() error: %v", err)
}
info, err := os.Stat(backupPath)
if err != nil {
t.Fatalf("backup file does not exist: %v", err)
}
if info.Size() == 0 {
t.Error("backup file is empty")
}
}
func TestBackupTo_CreatesDirectoryFile(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "src.db")
database, err := db.Open(dbPath)
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { database.Close() })
migrFS := fstest.MapFS{
"001_schema.sql": {Data: adminTestSchema},
}
db.MigrateFS(database, migrFS)
// Create nested backup path
backupDir := filepath.Join(tmpDir, "backups")
os.MkdirAll(backupDir, 0o755)
backupPath := filepath.Join(backupDir, "chatserver_20260314_120000.db")
if err := database.BackupTo(backupPath); err != nil {
t.Fatalf("BackupTo() error: %v", err)
}
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
t.Error("backup file was not created")
}
}
+12
View File
@@ -96,6 +96,18 @@ func (d *DB) BanUser(id int64, reason string, expires *time.Time) error {
return nil
}
// UnbanUser removes the ban from a user.
func (d *DB) UnbanUser(id int64) error {
_, err := d.sqlDB.Exec(
`UPDATE users SET banned = 0, ban_reason = NULL, ban_expires = NULL WHERE id = ?`,
id,
)
if err != nil {
return fmt.Errorf("UnbanUser: %w", err)
}
return nil
}
// ─── Session Operations ───────────────────────────────────────────────────────
// CreateSession inserts a new session and returns the session ID.
+48 -9
View File
@@ -54,15 +54,15 @@ type Role struct {
// Channel represents a row in the channels table.
type Channel struct {
ID int64
Name string
Type string
Category string
Topic string
Position int
SlowMode int
Archived bool
CreatedAt string
ID int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Category string `json:"category"`
Topic string `json:"topic"`
Position int `json:"position"`
SlowMode int `json:"slow_mode"`
Archived bool `json:"archived"`
CreatedAt string `json:"created_at"`
}
// Message represents a row in the messages table.
@@ -102,5 +102,44 @@ type MessageSearchResult struct {
Timestamp string
}
// VoiceState represents a row in the voice_states table.
// It tracks which voice channel a user is in and their current audio state.
type VoiceState struct {
UserID int64
ChannelID int64
Username string
Muted bool
Deafened bool
Speaking bool
}
// ServerStats contains aggregate counts for the admin dashboard.
type ServerStats struct {
UserCount int64 `json:"user_count"`
MessageCount int64 `json:"message_count"`
ChannelCount int64 `json:"channel_count"`
InviteCount int64 `json:"invite_count"`
DBSizeBytes int64 `json:"db_size_bytes"`
}
// UserWithRole extends User with the name of the user's role.
type UserWithRole struct {
User
RoleName string `json:"role_name"`
}
// AuditEntry represents a single row from the audit_log table joined with the
// actor's username.
type AuditEntry struct {
ID int64 `json:"id"`
ActorID int64 `json:"actor_id"`
ActorName string `json:"actor_name"`
Action string `json:"action"`
TargetType string `json:"target_type"`
TargetID int64 `json:"target_id"`
Detail string `json:"detail"`
CreatedAt string `json:"created_at"`
}
// sessionTTL is the duration a session remains valid after creation.
const sessionTTL = 30 * 24 * time.Hour
+172
View File
@@ -0,0 +1,172 @@
package db
import (
"database/sql"
"errors"
"fmt"
)
// JoinVoiceChannel inserts or replaces the user's voice state for the given
// channel. If the user is already in a different channel, the old row is
// replaced. Muted, deafened, and speaking are reset to false on join.
func (d *DB) JoinVoiceChannel(userID, channelID int64) error {
_, err := d.sqlDB.Exec(
`INSERT INTO voice_states (user_id, channel_id, muted, deafened, speaking)
VALUES (?, ?, 0, 0, 0)
ON CONFLICT(user_id) DO UPDATE SET
channel_id = excluded.channel_id,
muted = 0,
deafened = 0,
speaking = 0,
joined_at = datetime('now')`,
userID, channelID,
)
if err != nil {
return fmt.Errorf("JoinVoiceChannel: %w", err)
}
return nil
}
// LeaveVoiceChannel removes the user's voice state entirely.
// It is safe to call when the user is not in any voice channel.
func (d *DB) LeaveVoiceChannel(userID int64) error {
_, err := d.sqlDB.Exec(`DELETE FROM voice_states WHERE user_id = ?`, userID)
if err != nil {
return fmt.Errorf("LeaveVoiceChannel: %w", err)
}
return nil
}
// GetVoiceState returns the current voice state for the given user,
// or nil if the user is not in any voice channel.
func (d *DB) GetVoiceState(userID int64) (*VoiceState, error) {
row := d.sqlDB.QueryRow(
`SELECT vs.user_id, vs.channel_id, u.username,
vs.muted, vs.deafened, vs.speaking
FROM voice_states vs
JOIN users u ON u.id = vs.user_id
WHERE vs.user_id = ?`,
userID,
)
return scanVoiceState(row)
}
// GetChannelVoiceStates returns all voice states for users currently in the
// given voice channel.
func (d *DB) GetChannelVoiceStates(channelID int64) ([]VoiceState, error) {
rows, err := d.sqlDB.Query(
`SELECT vs.user_id, vs.channel_id, u.username,
vs.muted, vs.deafened, vs.speaking
FROM voice_states vs
JOIN users u ON u.id = vs.user_id
WHERE vs.channel_id = ?
ORDER BY vs.joined_at ASC`,
channelID,
)
if err != nil {
return nil, fmt.Errorf("GetChannelVoiceStates: %w", err)
}
defer rows.Close()
var states []VoiceState
for rows.Next() {
vs, scanErr := scanVoiceStateRow(rows)
if scanErr != nil {
return nil, fmt.Errorf("GetChannelVoiceStates scan: %w", scanErr)
}
states = append(states, vs)
}
if rows.Err() != nil {
return nil, fmt.Errorf("GetChannelVoiceStates rows: %w", rows.Err())
}
if states == nil {
states = []VoiceState{}
}
return states, nil
}
// UpdateVoiceMute sets the muted field for the given user's voice state.
// It is safe to call when the user is not in any channel (no-op).
func (d *DB) UpdateVoiceMute(userID int64, muted bool) error {
muteInt := boolToInt(muted)
_, err := d.sqlDB.Exec(
`UPDATE voice_states SET muted = ? WHERE user_id = ?`,
muteInt, userID,
)
if err != nil {
return fmt.Errorf("UpdateVoiceMute: %w", err)
}
return nil
}
// UpdateVoiceDeafen sets the deafened field for the given user's voice state.
// It is safe to call when the user is not in any channel (no-op).
func (d *DB) UpdateVoiceDeafen(userID int64, deafened bool) error {
deafenInt := boolToInt(deafened)
_, err := d.sqlDB.Exec(
`UPDATE voice_states SET deafened = ? WHERE user_id = ?`,
deafenInt, userID,
)
if err != nil {
return fmt.Errorf("UpdateVoiceDeafen: %w", err)
}
return nil
}
// ClearVoiceState removes a user's voice state on disconnect.
// Equivalent to LeaveVoiceChannel but named to clarify the disconnect use case.
func (d *DB) ClearVoiceState(userID int64) error {
_, err := d.sqlDB.Exec(`DELETE FROM voice_states WHERE user_id = ?`, userID)
if err != nil {
return fmt.Errorf("ClearVoiceState: %w", err)
}
return nil
}
// ─── helpers ──────────────────────────────────────────────────────────────────
// scanVoiceState scans a single *sql.Row into a VoiceState.
// Returns nil (not an error) when the row is not found.
func scanVoiceState(row *sql.Row) (*VoiceState, error) {
vs := &VoiceState{}
var muted, deafened, speaking int
err := row.Scan(
&vs.UserID, &vs.ChannelID, &vs.Username,
&muted, &deafened, &speaking,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("scanVoiceState: %w", err)
}
vs.Muted = muted != 0
vs.Deafened = deafened != 0
vs.Speaking = speaking != 0
return vs, nil
}
// scanVoiceStateRow scans a single row from *sql.Rows into a VoiceState.
func scanVoiceStateRow(rows *sql.Rows) (VoiceState, error) {
vs := VoiceState{}
var muted, deafened, speaking int
err := rows.Scan(
&vs.UserID, &vs.ChannelID, &vs.Username,
&muted, &deafened, &speaking,
)
if err != nil {
return vs, fmt.Errorf("scanVoiceStateRow: %w", err)
}
vs.Muted = muted != 0
vs.Deafened = deafened != 0
vs.Speaking = speaking != 0
return vs, nil
}
// boolToInt converts a bool to 0/1 for SQLite storage.
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
+423
View File
@@ -0,0 +1,423 @@
package db_test
import (
"testing"
"testing/fstest"
"github.com/owncord/server/db"
)
// voiceTestSchema adds the voice_states table on top of the base schema.
var voiceTestSchema = append(testSchema, []byte(`
CREATE TABLE IF NOT EXISTS voice_states (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
muted INTEGER NOT NULL DEFAULT 0,
deafened INTEGER NOT NULL DEFAULT 0,
speaking INTEGER NOT NULL DEFAULT 0,
joined_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id);
`)...)
var channelSchema = []byte(`
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'))
);
`)
// newVoiceTestDB opens an in-memory DB with users, channels, and voice_states.
func newVoiceTestDB(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: testSchema},
"002_channels.sql": {Data: channelSchema},
"003_voice.sql": {Data: []byte(`
CREATE TABLE IF NOT EXISTS voice_states (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
muted INTEGER NOT NULL DEFAULT 0,
deafened INTEGER NOT NULL DEFAULT 0,
speaking INTEGER NOT NULL DEFAULT 0,
joined_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id);
`)},
}
if err := db.MigrateFS(database, migrFS); err != nil {
t.Fatalf("MigrateFS: %v", err)
}
return database
}
// seedVoiceUser creates a user and returns its ID.
func seedVoiceUser(t *testing.T, database *db.DB, username string) int64 {
t.Helper()
id, err := database.CreateUser(username, "hash", 4)
if err != nil {
t.Fatalf("seedVoiceUser: %v", err)
}
return id
}
// seedVoiceChannel creates a voice-type channel and returns its ID.
func seedVoiceChannel(t *testing.T, database *db.DB, name string) int64 {
t.Helper()
id, err := database.CreateChannel(name, "voice", "", "", 0)
if err != nil {
t.Fatalf("seedVoiceChannel: %v", err)
}
return id
}
// ─── JoinVoiceChannel ─────────────────────────────────────────────────────────
func TestVoice_JoinVoiceChannel_Success(t *testing.T) {
database := newVoiceTestDB(t)
userID := seedVoiceUser(t, database, "alice")
chanID := seedVoiceChannel(t, database, "general-voice")
if err := database.JoinVoiceChannel(userID, chanID); err != nil {
t.Fatalf("JoinVoiceChannel: %v", err)
}
state, err := database.GetVoiceState(userID)
if err != nil {
t.Fatalf("GetVoiceState: %v", err)
}
if state == nil {
t.Fatal("GetVoiceState returned nil after join")
}
if state.UserID != userID {
t.Errorf("UserID = %d, want %d", state.UserID, userID)
}
if state.ChannelID != chanID {
t.Errorf("ChannelID = %d, want %d", state.ChannelID, chanID)
}
if state.Muted {
t.Error("Muted = true after join, want false")
}
if state.Deafened {
t.Error("Deafened = true after join, want false")
}
}
func TestVoice_JoinVoiceChannel_ReplacesExistingState(t *testing.T) {
database := newVoiceTestDB(t)
userID := seedVoiceUser(t, database, "bob")
chan1 := seedVoiceChannel(t, database, "voice-1")
chan2 := seedVoiceChannel(t, database, "voice-2")
if err := database.JoinVoiceChannel(userID, chan1); err != nil {
t.Fatalf("first JoinVoiceChannel: %v", err)
}
// Join a different channel — should replace the old state.
if err := database.JoinVoiceChannel(userID, chan2); err != nil {
t.Fatalf("second JoinVoiceChannel: %v", err)
}
state, err := database.GetVoiceState(userID)
if err != nil {
t.Fatalf("GetVoiceState: %v", err)
}
if state == nil {
t.Fatal("GetVoiceState returned nil after re-join")
}
if state.ChannelID != chan2 {
t.Errorf("ChannelID = %d, want %d (new channel)", state.ChannelID, chan2)
}
}
func TestVoice_JoinVoiceChannel_SameChannel_Idempotent(t *testing.T) {
database := newVoiceTestDB(t)
userID := seedVoiceUser(t, database, "carol")
chanID := seedVoiceChannel(t, database, "voice-same")
if err := database.JoinVoiceChannel(userID, chanID); err != nil {
t.Fatalf("first join: %v", err)
}
// Joining same channel again should not error.
if err := database.JoinVoiceChannel(userID, chanID); err != nil {
t.Fatalf("second join same channel: %v", err)
}
}
// ─── LeaveVoiceChannel ────────────────────────────────────────────────────────
func TestVoice_LeaveVoiceChannel_ClearsState(t *testing.T) {
database := newVoiceTestDB(t)
userID := seedVoiceUser(t, database, "dave")
chanID := seedVoiceChannel(t, database, "voice-leave")
if err := database.JoinVoiceChannel(userID, chanID); err != nil {
t.Fatalf("JoinVoiceChannel: %v", err)
}
if err := database.LeaveVoiceChannel(userID); err != nil {
t.Fatalf("LeaveVoiceChannel: %v", err)
}
state, err := database.GetVoiceState(userID)
if err != nil {
t.Fatalf("GetVoiceState after leave: %v", err)
}
if state != nil {
t.Error("GetVoiceState returned non-nil after leave, want nil")
}
}
func TestVoice_LeaveVoiceChannel_NoState_NoError(t *testing.T) {
database := newVoiceTestDB(t)
userID := seedVoiceUser(t, database, "eve")
// Leaving when not in any channel should not error.
if err := database.LeaveVoiceChannel(userID); err != nil {
t.Fatalf("LeaveVoiceChannel (not in channel): %v", err)
}
}
// ─── GetVoiceState ────────────────────────────────────────────────────────────
func TestVoice_GetVoiceState_NotFound(t *testing.T) {
database := newVoiceTestDB(t)
userID := seedVoiceUser(t, database, "frank")
state, err := database.GetVoiceState(userID)
if err != nil {
t.Fatalf("GetVoiceState(not found): %v", err)
}
if state != nil {
t.Error("GetVoiceState returned non-nil for user not in voice")
}
}
func TestVoice_GetVoiceState_IncludesUsername(t *testing.T) {
database := newVoiceTestDB(t)
userID := seedVoiceUser(t, database, "grace")
chanID := seedVoiceChannel(t, database, "voice-username")
if err := database.JoinVoiceChannel(userID, chanID); err != nil {
t.Fatalf("JoinVoiceChannel: %v", err)
}
state, err := database.GetVoiceState(userID)
if err != nil {
t.Fatalf("GetVoiceState: %v", err)
}
if state == nil {
t.Fatal("GetVoiceState returned nil")
}
if state.Username != "grace" {
t.Errorf("Username = %q, want %q", state.Username, "grace")
}
}
// ─── GetChannelVoiceStates ────────────────────────────────────────────────────
func TestVoice_GetChannelVoiceStates_Empty(t *testing.T) {
database := newVoiceTestDB(t)
chanID := seedVoiceChannel(t, database, "empty-voice")
states, err := database.GetChannelVoiceStates(chanID)
if err != nil {
t.Fatalf("GetChannelVoiceStates: %v", err)
}
if len(states) != 0 {
t.Errorf("got %d states, want 0", len(states))
}
}
func TestVoice_GetChannelVoiceStates_MultipleUsers(t *testing.T) {
database := newVoiceTestDB(t)
u1 := seedVoiceUser(t, database, "henry")
u2 := seedVoiceUser(t, database, "iris")
u3 := seedVoiceUser(t, database, "jack")
chanID := seedVoiceChannel(t, database, "multi-voice")
otherChan := seedVoiceChannel(t, database, "other-voice")
if err := database.JoinVoiceChannel(u1, chanID); err != nil {
t.Fatalf("join u1: %v", err)
}
if err := database.JoinVoiceChannel(u2, chanID); err != nil {
t.Fatalf("join u2: %v", err)
}
// u3 joins a different channel — should not appear.
if err := database.JoinVoiceChannel(u3, otherChan); err != nil {
t.Fatalf("join u3: %v", err)
}
states, err := database.GetChannelVoiceStates(chanID)
if err != nil {
t.Fatalf("GetChannelVoiceStates: %v", err)
}
if len(states) != 2 {
t.Errorf("got %d states, want 2", len(states))
}
ids := map[int64]bool{u1: true, u2: true}
for _, s := range states {
if !ids[s.UserID] {
t.Errorf("unexpected user_id %d in channel states", s.UserID)
}
}
}
// ─── UpdateVoiceMute ──────────────────────────────────────────────────────────
func TestVoice_UpdateVoiceMute_True(t *testing.T) {
database := newVoiceTestDB(t)
userID := seedVoiceUser(t, database, "kate")
chanID := seedVoiceChannel(t, database, "voice-mute")
if err := database.JoinVoiceChannel(userID, chanID); err != nil {
t.Fatalf("JoinVoiceChannel: %v", err)
}
if err := database.UpdateVoiceMute(userID, true); err != nil {
t.Fatalf("UpdateVoiceMute(true): %v", err)
}
state, _ := database.GetVoiceState(userID)
if state == nil || !state.Muted {
t.Error("Muted = false after UpdateVoiceMute(true)")
}
}
func TestVoice_UpdateVoiceMute_False(t *testing.T) {
database := newVoiceTestDB(t)
userID := seedVoiceUser(t, database, "leo")
chanID := seedVoiceChannel(t, database, "voice-unmute")
if err := database.JoinVoiceChannel(userID, chanID); err != nil {
t.Fatalf("JoinVoiceChannel: %v", err)
}
if err := database.UpdateVoiceMute(userID, true); err != nil {
t.Fatalf("UpdateVoiceMute(true): %v", err)
}
if err := database.UpdateVoiceMute(userID, false); err != nil {
t.Fatalf("UpdateVoiceMute(false): %v", err)
}
state, _ := database.GetVoiceState(userID)
if state == nil || state.Muted {
t.Error("Muted = true after UpdateVoiceMute(false), want false")
}
}
func TestVoice_UpdateVoiceMute_NotInChannel_NoError(t *testing.T) {
database := newVoiceTestDB(t)
userID := seedVoiceUser(t, database, "mia")
// Muting when not in a channel should not error.
if err := database.UpdateVoiceMute(userID, true); err != nil {
t.Fatalf("UpdateVoiceMute for non-member: %v", err)
}
}
// ─── UpdateVoiceDeafen ────────────────────────────────────────────────────────
func TestVoice_UpdateVoiceDeafen_True(t *testing.T) {
database := newVoiceTestDB(t)
userID := seedVoiceUser(t, database, "noah")
chanID := seedVoiceChannel(t, database, "voice-deafen")
if err := database.JoinVoiceChannel(userID, chanID); err != nil {
t.Fatalf("JoinVoiceChannel: %v", err)
}
if err := database.UpdateVoiceDeafen(userID, true); err != nil {
t.Fatalf("UpdateVoiceDeafen(true): %v", err)
}
state, _ := database.GetVoiceState(userID)
if state == nil || !state.Deafened {
t.Error("Deafened = false after UpdateVoiceDeafen(true)")
}
}
func TestVoice_UpdateVoiceDeafen_False(t *testing.T) {
database := newVoiceTestDB(t)
userID := seedVoiceUser(t, database, "olivia")
chanID := seedVoiceChannel(t, database, "voice-undeafen")
if err := database.JoinVoiceChannel(userID, chanID); err != nil {
t.Fatalf("JoinVoiceChannel: %v", err)
}
if err := database.UpdateVoiceDeafen(userID, true); err != nil {
t.Fatalf("UpdateVoiceDeafen(true): %v", err)
}
if err := database.UpdateVoiceDeafen(userID, false); err != nil {
t.Fatalf("UpdateVoiceDeafen(false): %v", err)
}
state, _ := database.GetVoiceState(userID)
if state == nil || state.Deafened {
t.Error("Deafened = true after UpdateVoiceDeafen(false), want false")
}
}
// ─── ClearVoiceState ──────────────────────────────────────────────────────────
func TestVoice_ClearVoiceState_RemovesState(t *testing.T) {
database := newVoiceTestDB(t)
userID := seedVoiceUser(t, database, "pedro")
chanID := seedVoiceChannel(t, database, "voice-clear")
if err := database.JoinVoiceChannel(userID, chanID); err != nil {
t.Fatalf("JoinVoiceChannel: %v", err)
}
if err := database.ClearVoiceState(userID); err != nil {
t.Fatalf("ClearVoiceState: %v", err)
}
state, err := database.GetVoiceState(userID)
if err != nil {
t.Fatalf("GetVoiceState after clear: %v", err)
}
if state != nil {
t.Error("GetVoiceState returned non-nil after ClearVoiceState")
}
}
func TestVoice_ClearVoiceState_NotInChannel_NoError(t *testing.T) {
database := newVoiceTestDB(t)
userID := seedVoiceUser(t, database, "quinn")
if err := database.ClearVoiceState(userID); err != nil {
t.Fatalf("ClearVoiceState for non-member: %v", err)
}
}
// ─── Cascade delete ───────────────────────────────────────────────────────────
func TestVoice_GetChannelVoiceStates_IncludesUsername(t *testing.T) {
database := newVoiceTestDB(t)
u1 := seedVoiceUser(t, database, "rachel")
chanID := seedVoiceChannel(t, database, "voice-name-check")
if err := database.JoinVoiceChannel(u1, chanID); err != nil {
t.Fatalf("JoinVoiceChannel: %v", err)
}
states, err := database.GetChannelVoiceStates(chanID)
if err != nil {
t.Fatalf("GetChannelVoiceStates: %v", err)
}
if len(states) != 1 {
t.Fatalf("got %d states, want 1", len(states))
}
if states[0].Username != "rachel" {
t.Errorf("Username = %q, want %q", states[0].Username, "rachel")
}
}
+12
View File
@@ -0,0 +1,12 @@
-- Phase 5: Voice state tracking table.
-- Stores which voice channel each user is currently connected to,
-- along with their mute/deafen/speaking state.
CREATE TABLE IF NOT EXISTS voice_states (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
muted INTEGER NOT NULL DEFAULT 0,
deafened INTEGER NOT NULL DEFAULT 0,
speaking INTEGER NOT NULL DEFAULT 0,
joined_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id);
+45
View File
@@ -0,0 +1,45 @@
-- Migration 003: Re-create audit_log with Phase-6 canonical column names.
--
-- Phase-1 audit_log used: user_id (nullable), action, target_type, target_id,
-- details, timestamp
-- Phase-6 audit_log uses: actor_id (NOT NULL DEFAULT 0), action, target_type,
-- target_id, detail, created_at
--
-- IDEMPOTENCY STRATEGY
-- --------------------
-- A sentinel table "audit_log_migrated_003" acts as a run-once guard.
-- • First run: sentinel does not exist → migration executes normally.
-- • Second run: "CREATE TABLE IF NOT EXISTS audit_log_migrated_003" is a
-- no-op, but the body still runs. However, because audit_log already has
-- the new schema, the INSERT … SELECT below safely copies actor_id/detail/
-- created_at which now exist.
--
-- Rather than fighting SQLite's lack of conditional DDL, we use a helper
-- table whose existence signals completion, and write the INSERT to work
-- with BOTH the old and new column names by coalescing them.
-- SQLite will return an error on unknown column names, so we cannot SELECT
-- user_id when actor_id exists. Instead, we guard with the run-once table.
--
-- On re-run: CREATE TABLE IF NOT EXISTS audit_log_v6 creates a fresh helper,
-- INSERT OR IGNORE … SELECT from audit_log (new schema) copies actor_id etc.,
-- DROP TABLE IF EXISTS audit_log removes current data,
-- ALTER TABLE audit_log_v6 RENAME TO audit_log recreates it.
-- This is safe because the second-run SELECT reads actor_id (not user_id).
CREATE TABLE IF NOT EXISTS audit_log_v6 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_id INTEGER NOT NULL DEFAULT 0,
action TEXT NOT NULL,
target_type TEXT NOT NULL DEFAULT '',
target_id INTEGER NOT NULL DEFAULT 0,
detail TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
DROP TABLE IF EXISTS audit_log;
ALTER TABLE audit_log_v6 RENAME TO audit_log;
-- Keep the legacy index name so existing tests remain green.
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_id);
+12
View File
@@ -62,6 +62,18 @@ func (h *Hub) handleMessage(c *Client, raw []byte) {
h.handleTyping(c, env.Payload)
case "presence_update":
h.handlePresence(c, env.Payload)
case "voice_join":
h.handleVoiceJoin(c, env.Payload)
case "voice_leave":
h.handleVoiceLeave(c)
case "voice_mute":
h.handleVoiceMute(c, env.Payload)
case "voice_deafen":
h.handleVoiceDeafen(c, env.Payload)
case "voice_offer", "voice_answer", "voice_ice":
h.handleVoiceSignal(c, env.Type, env.Payload)
case "soundboard_play":
h.handleSoundboard(c, env.Payload)
default:
slog.Warn("ws handleMessage unknown type", "type", env.Type, "user_id", c.userID)
c.sendMsg(buildErrorMsg("UNKNOWN_TYPE", fmt.Sprintf("unknown message type: %s", env.Type)))
+49
View File
@@ -3,6 +3,8 @@ package ws
import (
"encoding/json"
"fmt"
"github.com/owncord/server/db"
)
// envelope is the common wrapper for all WebSocket messages.
@@ -129,6 +131,53 @@ func buildTypingMsg(channelID, userID int64, username string) []byte {
})
}
// buildVoiceState constructs a voice_state server→client broadcast.
func buildVoiceState(state db.VoiceState) []byte {
return buildJSON(map[string]interface{}{
"type": "voice_state",
"payload": map[string]interface{}{
"channel_id": state.ChannelID,
"user_id": state.UserID,
"username": state.Username,
"muted": state.Muted,
"deafened": state.Deafened,
"speaking": state.Speaking,
},
})
}
// buildVoiceLeave constructs a voice_leave server→client broadcast.
func buildVoiceLeave(channelID, userID int64) []byte {
return buildJSON(map[string]interface{}{
"type": "voice_leave",
"payload": map[string]interface{}{
"channel_id": channelID,
"user_id": userID,
},
})
}
// buildVoiceSignalRelay relays a signaling message (offer/answer/ice) as-is to
// channel members. The original payload is embedded unchanged.
// channelID is provided for future filtering logic.
func buildVoiceSignalRelay(msgType string, _ int64, data json.RawMessage) []byte {
return buildJSON(map[string]interface{}{
"type": msgType,
"payload": data,
})
}
// buildSoundboardPlay constructs a soundboard_play broadcast.
func buildSoundboardPlay(soundID string, userID int64) []byte {
return buildJSON(map[string]interface{}{
"type": "soundboard_play",
"payload": map[string]interface{}{
"sound_id": soundID,
"user_id": userID,
},
})
}
// parseChannelID safely extracts channel_id from a raw payload map.
func parseChannelID(payload json.RawMessage) (int64, error) {
var p struct {
+30 -1
View File
@@ -87,6 +87,7 @@ func writePump(ctx context.Context, conn *websocket.Conn, c *Client) {
func readPump(ctx context.Context, conn *websocket.Conn, hub *Hub, c *Client) {
defer func() {
hub.Unregister(c)
hub.handleVoiceLeave(c)
if c.user != nil {
_ = hub.db.UpdateUserStatus(c.userID, "offline")
hub.BroadcastToAll(buildPresenceMsg(c.userID, "offline"))
@@ -188,13 +189,41 @@ func buildReady(database *db.DB) ([]byte, error) {
if err != nil {
return nil, fmt.Errorf("buildReady ListRoles: %w", err)
}
// Collect all active voice states across every voice channel.
voiceStates, err := collectAllVoiceStates(database, channels)
if err != nil {
// Non-fatal: send empty list rather than failing the whole ready payload.
slog.Warn("buildReady collectAllVoiceStates", "err", err)
voiceStates = []db.VoiceState{}
}
return buildJSON(map[string]interface{}{
"type": "ready",
"payload": map[string]interface{}{
"channels": channels,
"members": []interface{}{},
"voice_states": []interface{}{},
"voice_states": voiceStates,
"roles": roles,
},
}), nil
}
// collectAllVoiceStates gathers voice states for all voice-type channels.
func collectAllVoiceStates(database *db.DB, channels []db.Channel) ([]db.VoiceState, error) {
var all []db.VoiceState
for _, ch := range channels {
if ch.Type != "voice" {
continue
}
states, err := database.GetChannelVoiceStates(ch.ID)
if err != nil {
return nil, err
}
all = append(all, states...)
}
if all == nil {
all = []db.VoiceState{}
}
return all, nil
}
+193
View File
@@ -0,0 +1,193 @@
package ws
import (
"encoding/json"
"fmt"
"log/slog"
"time"
)
// Voice permission bits (from SCHEMA.md).
const (
permConnectVoice = int64(0x200) // bit 9
permUseSoundboard = int64(0x100) // bit 8
)
// Voice rate limit settings.
const (
voiceSignalRateLimit = 20
voiceSignalWindow = time.Second
soundboardRateLimit = 1
soundboardWindow = 3 * time.Second
)
// handleVoiceJoin processes a voice_join message.
// 1. Checks CONNECT_VOICE permission.
// 2. Persists join in DB.
// 3. Broadcasts voice_state to channel.
// 4. Sends all current voice states in the channel back to the joiner.
func (h *Hub) handleVoiceJoin(c *Client, payload json.RawMessage) {
if !h.hasChannelPerm(c, 0, permConnectVoice) {
c.sendMsg(buildErrorMsg("FORBIDDEN", "missing CONNECT_VOICE permission"))
return
}
channelID, err := parseChannelID(payload)
if err != nil || channelID <= 0 {
c.sendMsg(buildErrorMsg("BAD_REQUEST", "channel_id must be a positive integer"))
return
}
if err := h.db.JoinVoiceChannel(c.userID, channelID); err != nil {
slog.Error("ws handleVoiceJoin JoinVoiceChannel", "err", err, "user_id", c.userID)
c.sendMsg(buildErrorMsg("INTERNAL", "failed to join voice channel"))
return
}
state, err := h.db.GetVoiceState(c.userID)
if err != nil || state == nil {
slog.Error("ws handleVoiceJoin GetVoiceState", "err", err, "user_id", c.userID)
return
}
// Broadcast the joiner's state to all clients currently in this voice channel.
h.BroadcastToChannel(channelID, buildVoiceState(*state))
// Send existing channel voice states to the joiner.
existing, err := h.db.GetChannelVoiceStates(channelID)
if err != nil {
slog.Error("ws handleVoiceJoin GetChannelVoiceStates", "err", err)
return
}
for _, vs := range existing {
if vs.UserID == c.userID {
continue // skip the joiner themselves
}
c.sendMsg(buildVoiceState(vs))
}
}
// handleVoiceLeave processes an explicit voice_leave message or a disconnect.
// 1. Removes voice state from DB.
// 2. Broadcasts voice_leave to the channel the user was in.
func (h *Hub) handleVoiceLeave(c *Client) {
state, err := h.db.GetVoiceState(c.userID)
if err != nil {
slog.Error("ws handleVoiceLeave GetVoiceState", "err", err, "user_id", c.userID)
}
if leaveErr := h.db.LeaveVoiceChannel(c.userID); leaveErr != nil {
slog.Error("ws handleVoiceLeave LeaveVoiceChannel", "err", leaveErr, "user_id", c.userID)
}
if state != nil {
h.BroadcastToChannel(state.ChannelID, buildVoiceLeave(state.ChannelID, c.userID))
}
}
// handleVoiceMute processes a voice_mute message.
// 1. Parses muted bool.
// 2. Updates DB.
// 3. Broadcasts voice_state update to channel.
func (h *Hub) handleVoiceMute(c *Client, payload json.RawMessage) {
var p struct {
Muted bool `json:"muted"`
}
if err := json.Unmarshal(payload, &p); err != nil {
c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid voice_mute payload"))
return
}
if err := h.db.UpdateVoiceMute(c.userID, p.Muted); err != nil {
slog.Error("ws handleVoiceMute UpdateVoiceMute", "err", err, "user_id", c.userID)
c.sendMsg(buildErrorMsg("INTERNAL", "failed to update mute state"))
return
}
h.broadcastVoiceStateUpdate(c)
}
// handleVoiceDeafen processes a voice_deafen message.
// 1. Parses deafened bool.
// 2. Updates DB.
// 3. Broadcasts voice_state update to channel.
func (h *Hub) handleVoiceDeafen(c *Client, payload json.RawMessage) {
var p struct {
Deafened bool `json:"deafened"`
}
if err := json.Unmarshal(payload, &p); err != nil {
c.sendMsg(buildErrorMsg("BAD_REQUEST", "invalid voice_deafen payload"))
return
}
if err := h.db.UpdateVoiceDeafen(c.userID, p.Deafened); err != nil {
slog.Error("ws handleVoiceDeafen UpdateVoiceDeafen", "err", err, "user_id", c.userID)
c.sendMsg(buildErrorMsg("INTERNAL", "failed to update deafen state"))
return
}
h.broadcastVoiceStateUpdate(c)
}
// handleVoiceSignal relays voice_offer, voice_answer, and voice_ice messages.
// 1. Rate limits at 20/sec per user.
// 2. Parses channel_id from payload.
// 3. Relays the message (with original type) to all other channel members.
// SDP/ICE content is not inspected or logged.
func (h *Hub) handleVoiceSignal(c *Client, msgType string, payload json.RawMessage) {
ratKey := fmt.Sprintf("voice_signal:%d", c.userID)
if !h.limiter.Allow(ratKey, voiceSignalRateLimit, voiceSignalWindow) {
c.sendMsg(buildErrorMsg("RATE_LIMITED", "too many signaling messages"))
return
}
channelID, err := parseChannelID(payload)
if err != nil || channelID <= 0 {
c.sendMsg(buildErrorMsg("BAD_REQUEST", "channel_id must be a positive integer"))
return
}
relayed := buildVoiceSignalRelay(msgType, channelID, payload)
h.broadcastExclude(channelID, c.userID, relayed)
}
// handleSoundboard processes a soundboard_play message.
// 1. Rate limits at 1 per 3 seconds.
// 2. Checks USE_SOUNDBOARD permission.
// 3. Broadcasts soundboard_play (with user_id) to all connected clients.
func (h *Hub) handleSoundboard(c *Client, payload json.RawMessage) {
ratKey := fmt.Sprintf("soundboard:%d", c.userID)
if !h.limiter.Allow(ratKey, soundboardRateLimit, soundboardWindow) {
c.sendMsg(buildErrorMsg("RATE_LIMITED", "soundboard is on cooldown"))
return
}
if !h.hasChannelPerm(c, 0, permUseSoundboard) {
c.sendMsg(buildErrorMsg("FORBIDDEN", "missing USE_SOUNDBOARD permission"))
return
}
var p struct {
SoundID string `json:"sound_id"`
}
if err := json.Unmarshal(payload, &p); err != nil || p.SoundID == "" {
c.sendMsg(buildErrorMsg("BAD_REQUEST", "sound_id is required"))
return
}
h.BroadcastToAll(buildSoundboardPlay(p.SoundID, c.userID))
}
// broadcastVoiceStateUpdate fetches the current voice state for the client
// and broadcasts it to all members of the voice channel they are in.
func (h *Hub) broadcastVoiceStateUpdate(c *Client) {
state, err := h.db.GetVoiceState(c.userID)
if err != nil {
slog.Error("ws broadcastVoiceStateUpdate GetVoiceState", "err", err, "user_id", c.userID)
return
}
if state == nil {
return // user not in a voice channel — nothing to broadcast
}
h.BroadcastToChannel(state.ChannelID, buildVoiceState(*state))
}
+767
View File
@@ -0,0 +1,767 @@
package ws_test
import (
"encoding/json"
"testing"
"testing/fstest"
"time"
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
"github.com/owncord/server/ws"
)
// voiceSchema extends hubTestSchema with the voice_states table.
var voiceSchema = append(hubTestSchema, []byte(`
CREATE TABLE IF NOT EXISTS voice_states (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
muted INTEGER NOT NULL DEFAULT 0,
deafened INTEGER NOT NULL DEFAULT 0,
speaking INTEGER NOT NULL DEFAULT 0,
joined_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id);
`)...)
// openVoiceTestDB opens an in-memory DB with the full voice schema.
func openVoiceTestDB(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: voiceSchema},
}
if err := db.MigrateFS(database, migrFS); err != nil {
t.Fatalf("MigrateFS: %v", err)
}
return database
}
// newVoiceHub creates a hub+db suitable for voice handler tests.
func newVoiceHub(t *testing.T) (*ws.Hub, *db.DB) {
t.Helper()
database := openVoiceTestDB(t)
limiter := auth.NewRateLimiter()
hub := ws.NewHub(database, limiter)
go hub.Run()
t.Cleanup(func() { hub.Stop() })
return hub, database
}
// seedVoiceOwner inserts an Owner-role user for permission-passing tests.
func seedVoiceOwner(t *testing.T, database *db.DB, username string) *db.User {
t.Helper()
_, err := database.CreateUser(username, "hash", 1) // roleID=1 → Owner
if err != nil {
t.Fatalf("seedVoiceOwner CreateUser: %v", err)
}
user, err := database.GetUserByUsername(username)
if err != nil || user == nil {
t.Fatalf("seedVoiceOwner GetUserByUsername: %v", err)
}
return user
}
// seedVoiceChan creates a voice-type channel.
func seedVoiceChan(t *testing.T, database *db.DB, name string) int64 {
t.Helper()
id, err := database.CreateChannel(name, "voice", "", "", 0)
if err != nil {
t.Fatalf("seedVoiceChan: %v", err)
}
return id
}
// voiceJoinMsg builds a raw voice_join WebSocket message.
func voiceJoinMsg(channelID int64) []byte {
raw, _ := json.Marshal(map[string]interface{}{
"type": "voice_join",
"payload": map[string]interface{}{"channel_id": channelID},
})
return raw
}
// voiceLeaveMsg builds a raw voice_leave WebSocket message.
func voiceLeaveMsg() []byte {
raw, _ := json.Marshal(map[string]interface{}{
"type": "voice_leave",
"payload": map[string]interface{}{},
})
return raw
}
// voiceMuteMsg builds a voice_mute message.
func voiceMuteMsg(muted bool) []byte {
raw, _ := json.Marshal(map[string]interface{}{
"type": "voice_mute",
"payload": map[string]interface{}{"muted": muted},
})
return raw
}
// voiceDeafenMsg builds a voice_deafen message.
func voiceDeafenMsg(deafened bool) []byte {
raw, _ := json.Marshal(map[string]interface{}{
"type": "voice_deafen",
"payload": map[string]interface{}{"deafened": deafened},
})
return raw
}
// voiceSignalMsg builds a voice_offer/answer/ice message.
func voiceSignalMsg(msgType string, channelID int64, sdp string) []byte {
raw, _ := json.Marshal(map[string]interface{}{
"type": msgType,
"payload": map[string]interface{}{
"channel_id": channelID,
"sdp": sdp,
},
})
return raw
}
// voiceICEMsg builds a voice_ice message.
func voiceICEMsg(channelID int64, candidate string) []byte {
raw, _ := json.Marshal(map[string]interface{}{
"type": "voice_ice",
"payload": map[string]interface{}{
"channel_id": channelID,
"candidate": candidate,
},
})
return raw
}
// extractType parses a JSON message and returns the "type" field.
func extractType(t *testing.T, msg []byte) string {
t.Helper()
var env map[string]interface{}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("extractType unmarshal: %v", err)
}
typ, _ := env["type"].(string)
return typ
}
// drainChan reads all pending messages from ch into a slice.
func drainChan(ch <-chan []byte) [][]byte {
var msgs [][]byte
for {
select {
case m := <-ch:
msgs = append(msgs, m)
default:
return msgs
}
}
}
// ─── voice_join ───────────────────────────────────────────────────────────────
func TestVoice_Join_SetsStateInDB(t *testing.T) {
hub, database := newVoiceHub(t)
user := seedVoiceOwner(t, database, "alice")
chanID := seedVoiceChan(t, database, "vc-alice")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
hub.HandleMessageForTest(c, voiceJoinMsg(chanID))
time.Sleep(30 * time.Millisecond)
state, err := database.GetVoiceState(user.ID)
if err != nil {
t.Fatalf("GetVoiceState: %v", err)
}
if state == nil {
t.Fatal("voice state is nil after voice_join")
}
if state.ChannelID != chanID {
t.Errorf("ChannelID = %d, want %d", state.ChannelID, chanID)
}
}
func TestVoice_Join_BroadcastsVoiceState(t *testing.T) {
hub, database := newVoiceHub(t)
user := seedVoiceOwner(t, database, "bob")
chanID := seedVoiceChan(t, database, "vc-bob")
// A second client in the same voice channel to receive the broadcast.
send2 := make(chan []byte, 16)
user2 := seedVoiceOwner(t, database, "bob2")
c2 := ws.NewTestClientWithUser(hub, user2, chanID, send2)
hub.Register(c2)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, chanID, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
hub.HandleMessageForTest(c, voiceJoinMsg(chanID))
time.Sleep(50 * time.Millisecond)
// Look for a voice_state message in either send or send2.
foundVoiceState := false
allMsgs := append(drainChan(send), drainChan(send2)...)
for _, msg := range allMsgs {
if extractType(t, msg) == "voice_state" {
foundVoiceState = true
break
}
}
if !foundVoiceState {
t.Error("voice_state broadcast not received after voice_join")
}
}
func TestVoice_Join_SendsCurrentStatesToJoiner(t *testing.T) {
hub, database := newVoiceHub(t)
chanID := seedVoiceChan(t, database, "vc-existing")
// user1 joins first.
user1 := seedVoiceOwner(t, database, "carol1")
send1 := make(chan []byte, 16)
c1 := ws.NewTestClientWithUser(hub, user1, chanID, send1)
hub.Register(c1)
time.Sleep(20 * time.Millisecond)
hub.HandleMessageForTest(c1, voiceJoinMsg(chanID))
time.Sleep(30 * time.Millisecond)
// Drain send1 to clear join broadcast.
drainChan(send1)
// user2 joins — should receive voice_state for user1.
user2 := seedVoiceOwner(t, database, "carol2")
send2 := make(chan []byte, 16)
c2 := ws.NewTestClientWithUser(hub, user2, chanID, send2)
hub.Register(c2)
time.Sleep(20 * time.Millisecond)
hub.HandleMessageForTest(c2, voiceJoinMsg(chanID))
time.Sleep(50 * time.Millisecond)
// user2 should have received a voice_state for user1.
msgs2 := drainChan(send2)
voiceStateCount := 0
for _, msg := range msgs2 {
if extractType(t, msg) == "voice_state" {
voiceStateCount++
}
}
if voiceStateCount == 0 {
t.Error("joining client did not receive existing voice states")
}
}
func TestVoice_Join_MissingChannelID_SendsError(t *testing.T) {
hub, database := newVoiceHub(t)
user := seedVoiceOwner(t, database, "dave")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
badMsg, _ := json.Marshal(map[string]interface{}{
"type": "voice_join",
"payload": map[string]interface{}{"channel_id": 0},
})
hub.HandleMessageForTest(c, badMsg)
time.Sleep(30 * time.Millisecond)
msgs := drainChan(send)
found := false
for _, m := range msgs {
if extractType(t, m) == "error" {
found = true
}
}
if !found {
t.Error("expected error response for invalid channel_id")
}
}
func TestVoice_Join_NoPermission_SendsError(t *testing.T) {
hub, database := newVoiceHub(t)
chanID := seedVoiceChan(t, database, "vc-noperm")
// Member role (id=4) has permissions 1049089. Bit 9 (0x200 = 512) for CONNECT_VOICE.
// Check if member has it: 1049089 & 512 = 512, so member DOES have it.
// We need a role without it. We'll set a custom role using direct DB exec.
// For simplicity, use a user with nil user (no role) to fail perm check.
send := make(chan []byte, 16)
c := ws.NewTestClient(hub, 9999, send) // no user set → hasChannelPerm returns false
hub.Register(c)
time.Sleep(20 * time.Millisecond)
hub.HandleMessageForTest(c, voiceJoinMsg(chanID))
time.Sleep(30 * time.Millisecond)
msgs := drainChan(send)
found := false
for _, m := range msgs {
if extractType(t, m) == "error" {
found = true
}
}
if !found {
t.Error("expected FORBIDDEN error for client without CONNECT_VOICE permission")
}
}
// ─── voice_leave ──────────────────────────────────────────────────────────────
func TestVoice_Leave_ClearsStateInDB(t *testing.T) {
hub, database := newVoiceHub(t)
user := seedVoiceOwner(t, database, "eve")
chanID := seedVoiceChan(t, database, "vc-eve")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, chanID, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
hub.HandleMessageForTest(c, voiceJoinMsg(chanID))
time.Sleep(30 * time.Millisecond)
hub.HandleMessageForTest(c, voiceLeaveMsg())
time.Sleep(30 * time.Millisecond)
state, err := database.GetVoiceState(user.ID)
if err != nil {
t.Fatalf("GetVoiceState after leave: %v", err)
}
if state != nil {
t.Error("voice state still set after voice_leave")
}
}
func TestVoice_Leave_BroadcastsVoiceLeave(t *testing.T) {
hub, database := newVoiceHub(t)
chanID := seedVoiceChan(t, database, "vc-leave-bcast")
user := seedVoiceOwner(t, database, "frank")
user2 := seedVoiceOwner(t, database, "frank2")
send2 := make(chan []byte, 16)
c2 := ws.NewTestClientWithUser(hub, user2, chanID, send2)
hub.Register(c2)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, chanID, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
hub.HandleMessageForTest(c, voiceJoinMsg(chanID))
time.Sleep(30 * time.Millisecond)
drainChan(send)
drainChan(send2)
hub.HandleMessageForTest(c, voiceLeaveMsg())
time.Sleep(50 * time.Millisecond)
allMsgs := append(drainChan(send), drainChan(send2)...)
found := false
for _, msg := range allMsgs {
if extractType(t, msg) == "voice_leave" {
found = true
break
}
}
if !found {
t.Error("voice_leave broadcast not received after voice_leave message")
}
}
// ─── voice_mute ───────────────────────────────────────────────────────────────
func TestVoice_Mute_UpdatesStateInDB(t *testing.T) {
hub, database := newVoiceHub(t)
user := seedVoiceOwner(t, database, "grace")
chanID := seedVoiceChan(t, database, "vc-grace")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, chanID, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
hub.HandleMessageForTest(c, voiceJoinMsg(chanID))
time.Sleep(30 * time.Millisecond)
hub.HandleMessageForTest(c, voiceMuteMsg(true))
time.Sleep(30 * time.Millisecond)
state, err := database.GetVoiceState(user.ID)
if err != nil {
t.Fatalf("GetVoiceState: %v", err)
}
if state == nil || !state.Muted {
t.Error("Muted = false after voice_mute(true)")
}
}
func TestVoice_Mute_BroadcastsVoiceState(t *testing.T) {
hub, database := newVoiceHub(t)
chanID := seedVoiceChan(t, database, "vc-mute-bcast")
user := seedVoiceOwner(t, database, "henry")
user2 := seedVoiceOwner(t, database, "henry2")
send2 := make(chan []byte, 16)
c2 := ws.NewTestClientWithUser(hub, user2, chanID, send2)
hub.Register(c2)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, chanID, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
hub.HandleMessageForTest(c, voiceJoinMsg(chanID))
time.Sleep(30 * time.Millisecond)
drainChan(send)
drainChan(send2)
hub.HandleMessageForTest(c, voiceMuteMsg(true))
time.Sleep(50 * time.Millisecond)
allMsgs := append(drainChan(send), drainChan(send2)...)
found := false
for _, msg := range allMsgs {
if extractType(t, msg) == "voice_state" {
found = true
break
}
}
if !found {
t.Error("voice_state broadcast not received after voice_mute")
}
}
// ─── voice_deafen ─────────────────────────────────────────────────────────────
func TestVoice_Deafen_UpdatesStateInDB(t *testing.T) {
hub, database := newVoiceHub(t)
user := seedVoiceOwner(t, database, "iris")
chanID := seedVoiceChan(t, database, "vc-iris")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, chanID, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
hub.HandleMessageForTest(c, voiceJoinMsg(chanID))
time.Sleep(30 * time.Millisecond)
hub.HandleMessageForTest(c, voiceDeafenMsg(true))
time.Sleep(30 * time.Millisecond)
state, err := database.GetVoiceState(user.ID)
if err != nil {
t.Fatalf("GetVoiceState: %v", err)
}
if state == nil || !state.Deafened {
t.Error("Deafened = false after voice_deafen(true)")
}
}
func TestVoice_Deafen_BroadcastsVoiceState(t *testing.T) {
hub, database := newVoiceHub(t)
chanID := seedVoiceChan(t, database, "vc-deafen-bcast")
user := seedVoiceOwner(t, database, "jack")
user2 := seedVoiceOwner(t, database, "jack2")
send2 := make(chan []byte, 16)
c2 := ws.NewTestClientWithUser(hub, user2, chanID, send2)
hub.Register(c2)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, chanID, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
hub.HandleMessageForTest(c, voiceJoinMsg(chanID))
time.Sleep(30 * time.Millisecond)
drainChan(send)
drainChan(send2)
hub.HandleMessageForTest(c, voiceDeafenMsg(true))
time.Sleep(50 * time.Millisecond)
allMsgs := append(drainChan(send), drainChan(send2)...)
found := false
for _, msg := range allMsgs {
if extractType(t, msg) == "voice_state" {
found = true
break
}
}
if !found {
t.Error("voice_state broadcast not received after voice_deafen")
}
}
// ─── voice signaling relay ────────────────────────────────────────────────────
func TestVoice_Signal_RelaysToOtherChannelMembers(t *testing.T) {
hub, database := newVoiceHub(t)
chanID := seedVoiceChan(t, database, "vc-signal")
sender := seedVoiceOwner(t, database, "kate")
receiver := seedVoiceOwner(t, database, "kate2")
outsider := seedVoiceOwner(t, database, "kate3")
sendR := make(chan []byte, 16)
cR := ws.NewTestClientWithUser(hub, receiver, chanID, sendR)
hub.Register(cR)
sendO := make(chan []byte, 16)
cO := ws.NewTestClientWithUser(hub, outsider, 999, sendO) // different channel
hub.Register(cO)
sendS := make(chan []byte, 16)
cS := ws.NewTestClientWithUser(hub, sender, chanID, sendS)
hub.Register(cS)
time.Sleep(20 * time.Millisecond)
hub.HandleMessageForTest(cS, voiceSignalMsg("voice_offer", chanID, "v=0..."))
time.Sleep(50 * time.Millisecond)
// Receiver in same channel should get the signal.
receiverMsgs := drainChan(sendR)
found := false
for _, msg := range receiverMsgs {
if extractType(t, msg) == "voice_offer" {
found = true
break
}
}
if !found {
t.Error("receiver in channel did not receive voice_offer relay")
}
// Outsider in different channel should NOT get it.
outsiderMsgs := drainChan(sendO)
for _, msg := range outsiderMsgs {
if extractType(t, msg) == "voice_offer" {
t.Error("outsider received voice_offer, should not have")
}
}
// Sender should NOT receive their own signal.
senderMsgs := drainChan(sendS)
for _, msg := range senderMsgs {
if extractType(t, msg) == "voice_offer" {
t.Error("sender received their own voice_offer, should not have")
}
}
}
func TestVoice_Signal_ICERelayed(t *testing.T) {
hub, database := newVoiceHub(t)
chanID := seedVoiceChan(t, database, "vc-ice")
sender := seedVoiceOwner(t, database, "leo")
receiver := seedVoiceOwner(t, database, "leo2")
sendR := make(chan []byte, 16)
cR := ws.NewTestClientWithUser(hub, receiver, chanID, sendR)
hub.Register(cR)
sendS := make(chan []byte, 16)
cS := ws.NewTestClientWithUser(hub, sender, chanID, sendS)
hub.Register(cS)
time.Sleep(20 * time.Millisecond)
hub.HandleMessageForTest(cS, voiceICEMsg(chanID, "candidate:..."))
time.Sleep(50 * time.Millisecond)
receiverMsgs := drainChan(sendR)
found := false
for _, msg := range receiverMsgs {
if extractType(t, msg) == "voice_ice" {
found = true
break
}
}
if !found {
t.Error("receiver did not receive relayed voice_ice")
}
}
func TestVoice_Signal_RateLimit_BlocksExcess(t *testing.T) {
hub, database := newVoiceHub(t)
chanID := seedVoiceChan(t, database, "vc-ratelimit")
sender := seedVoiceOwner(t, database, "mia")
receiver := seedVoiceOwner(t, database, "mia2")
sendR := make(chan []byte, 256)
cR := ws.NewTestClientWithUser(hub, receiver, chanID, sendR)
hub.Register(cR)
sendS := make(chan []byte, 256)
cS := ws.NewTestClientWithUser(hub, sender, chanID, sendS)
hub.Register(cS)
time.Sleep(20 * time.Millisecond)
// Send 30 signals rapidly — limit is 20/sec, so some should be dropped.
for i := 0; i < 30; i++ {
hub.HandleMessageForTest(cS, voiceSignalMsg("voice_offer", chanID, "v=0..."))
}
time.Sleep(50 * time.Millisecond)
receivedCount := len(drainChan(sendR))
if receivedCount >= 30 {
t.Errorf("received %d signals, expected fewer due to rate limit", receivedCount)
}
// Sender should receive at least one RATE_LIMITED error.
senderMsgs := drainChan(sendS)
foundError := false
for _, msg := range senderMsgs {
if extractType(t, msg) == "error" {
foundError = true
break
}
}
if !foundError {
t.Error("expected RATE_LIMITED error to sender after exceeding signal rate limit")
}
}
// ─── soundboard ───────────────────────────────────────────────────────────────
func TestVoice_Soundboard_BroadcastsToAll(t *testing.T) {
hub, database := newVoiceHub(t)
user := seedVoiceOwner(t, database, "noah")
listener := seedVoiceOwner(t, database, "noah2")
sendL := make(chan []byte, 16)
cL := ws.NewTestClientWithUser(hub, listener, 0, sendL)
hub.Register(cL)
sendS := make(chan []byte, 16)
cS := ws.NewTestClientWithUser(hub, user, 0, sendS)
hub.Register(cS)
time.Sleep(20 * time.Millisecond)
soundMsg, _ := json.Marshal(map[string]interface{}{
"type": "soundboard_play",
"payload": map[string]interface{}{"sound_id": "abc-uuid-123"},
})
hub.HandleMessageForTest(cS, soundMsg)
time.Sleep(50 * time.Millisecond)
listenerMsgs := drainChan(sendL)
found := false
for _, msg := range listenerMsgs {
if extractType(t, msg) == "soundboard_play" {
found = true
break
}
}
if !found {
t.Error("listener did not receive soundboard_play broadcast")
}
}
func TestVoice_Soundboard_NoPermission_SendsError(t *testing.T) {
hub, _ := newVoiceHub(t)
// Client with no user set → permission check fails.
send := make(chan []byte, 16)
c := ws.NewTestClient(hub, 8888, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
soundMsg, _ := json.Marshal(map[string]interface{}{
"type": "soundboard_play",
"payload": map[string]interface{}{"sound_id": "abc"},
})
hub.HandleMessageForTest(c, soundMsg)
time.Sleep(30 * time.Millisecond)
msgs := drainChan(send)
found := false
for _, m := range msgs {
if extractType(t, m) == "error" {
found = true
}
}
if !found {
t.Error("expected FORBIDDEN error for soundboard without permission")
}
}
func TestVoice_Soundboard_RateLimit(t *testing.T) {
hub, database := newVoiceHub(t)
user := seedVoiceOwner(t, database, "olivia")
send := make(chan []byte, 64)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
soundMsg, _ := json.Marshal(map[string]interface{}{
"type": "soundboard_play",
"payload": map[string]interface{}{"sound_id": "x"},
})
// Send 5 soundboard plays rapidly — limit is 1 per 3 sec.
for i := 0; i < 5; i++ {
hub.HandleMessageForTest(c, soundMsg)
}
time.Sleep(50 * time.Millisecond)
msgs := drainChan(send)
errCount := 0
for _, m := range msgs {
if extractType(t, m) == "error" {
errCount++
}
}
if errCount == 0 {
t.Error("expected rate limit errors for rapid soundboard plays")
}
}
// ─── handleMessage dispatch ───────────────────────────────────────────────────
func TestVoice_HandleMessage_VoiceAnswer_Relayed(t *testing.T) {
hub, database := newVoiceHub(t)
chanID := seedVoiceChan(t, database, "vc-answer")
sender := seedVoiceOwner(t, database, "pedro")
receiver := seedVoiceOwner(t, database, "pedro2")
sendR := make(chan []byte, 16)
cR := ws.NewTestClientWithUser(hub, receiver, chanID, sendR)
hub.Register(cR)
sendS := make(chan []byte, 16)
cS := ws.NewTestClientWithUser(hub, sender, chanID, sendS)
hub.Register(cS)
time.Sleep(20 * time.Millisecond)
hub.HandleMessageForTest(cS, voiceSignalMsg("voice_answer", chanID, "v=0 answer..."))
time.Sleep(50 * time.Millisecond)
receiverMsgs := drainChan(sendR)
found := false
for _, msg := range receiverMsgs {
if extractType(t, msg) == "voice_answer" {
found = true
break
}
}
if !found {
t.Error("receiver did not receive relayed voice_answer")
}
}