feat: TOFU cert pinning, settings cache refactor, ban enforcement, and 80%+ test coverage

- Implement TOFU certificate pinning in Rust WS proxy with accept_cert_fingerprint command
- Refactor settings cache from package-level globals to Hub methods (eliminates global state)
- Add runtime ban check on WS message handling (kicks banned users mid-session)
- Sanitize reaction error messages to prevent IDOR information leaks
- Add slog error logging to REST handlers (channel, invite, search)
- Handle channel_delete for active channel in client dispatcher
- Add certMismatchBlock to prevent auto-reconnect on TOFU mismatch
- Consolidate root-level spec docs into docs/brain/06-Specs/ vault
- Add 80%+ test coverage for ws (80.9%) and admin (81.7%) packages
- Delete completed TODOS.md (all items resolved)
This commit is contained in:
jevb
2026-03-17 11:05:52 +01:00
parent ce4326766a
commit 4d1a1676c7
40 changed files with 5392 additions and 1915 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ MIGRATION-PLAN.md
TESTING-STRATEGY.md
CLIENT-ARCHITECTURE.md
docs/superpowers/
docs/brain/
# Server runtime artifacts
Server/chatserver.exe
Server/config.yaml
-309
View File
@@ -1,309 +0,0 @@
# REST API Spec
Base URL: `https://{server}:{port}/api/v1`
Auth: session token in cookie `session` (set on login)
or `Authorization: Bearer {token}` header.
All responses are JSON. Errors return
`{ "error": "CODE", "message": "Human-readable detail" }`.
---
## Auth
| Method | Endpoint | Auth | Description |
| ------ | -------- | ---- | ----------- |
| POST | `/api/v1/auth/register` | None (invite code) | Create account |
| POST | `/api/v1/auth/login` | None | Login, returns session token |
| POST | `/api/v1/auth/logout` | Yes | Invalidate current session |
| POST | `/api/v1/auth/verify-totp` | Partial (2FA) | Submit TOTP code |
### POST /api/v1/auth/register
```json
// Request
{ "username": "alex", "password": "strongpassword", "invite_code": "abc123" }
// Response 201
{ "user": { "id": 1, "username": "alex" }, "token": "session-token" }
```
### POST /api/v1/auth/login
```json
// Request
{ "username": "alex", "password": "strongpassword" }
// Response 200 (no 2FA)
{ "token": "session-token", "requires_2fa": false }
// Response 200 (2FA required)
{ "partial_token": "temp-token", "requires_2fa": true }
```
---
## Users
| Method | Endpoint | Auth | Description |
| ------ | -------- | ---- | ----------- |
| GET | `/api/v1/users/me` | Yes | Get current user profile |
| PATCH | `/api/v1/users/me` | Yes | Update own profile (username, avatar) |
| PUT | `/api/v1/users/me/password` | Yes | Change password |
| POST | `/api/v1/users/me/totp/enable` | Yes | Start 2FA setup, returns QR |
| POST | `/api/v1/users/me/totp/confirm` | Yes | Confirm 2FA with TOTP code |
| DELETE | `/api/v1/users/me/totp` | Yes | Disable 2FA |
| GET | `/api/v1/users/me/sessions` | Yes | List active sessions |
| DELETE | `/api/v1/users/me/sessions/{id}` | Yes | Revoke a session |
---
## Channels
| Method | Endpoint | Auth | Description |
| ------ | -------- | ---- | ----------- |
| GET | `/api/v1/channels` | Yes | List all channels user can see |
| GET | `/api/v1/channels/{id}/messages` | Yes | Paginated message history |
| GET | `/api/v1/channels/{id}/pins` | Yes | Get pinned messages |
| POST | `/api/v1/channels/{id}/pins/{msg_id}` | Yes (mod) | Pin a message |
| DELETE | `/api/v1/channels/{id}/pins/{msg_id}` | Yes (mod) | Unpin a message |
### GET /api/v1/channels/{id}/messages
Query params: `before` (message ID), `limit` (1-100, default 50)
```json
// Response 200
{
"messages": [
{
"id": 1042,
"channel_id": 5,
"user": { "id": 1, "username": "alex", "avatar": "uuid.png" },
"content": "Hello!",
"reply_to": null,
"attachments": [],
"reactions": [{ "emoji": "👍", "count": 2, "me": true }],
"pinned": false,
"edited_at": null,
"deleted": false,
"timestamp": "2026-03-14T10:30:00Z"
}
],
"has_more": true
}
```
---
## File Uploads
| Method | Endpoint | Auth | Description |
| ------ | -------- | ---- | ----------- |
| POST | `/api/v1/uploads` | Yes | Upload a file (multipart) |
| GET | `/api/v1/files/{uuid}` | Yes | Download a file |
### POST /api/v1/uploads
Multipart form data. Field: `file`. Max size from server config (default 25MB).
```json
// Response 201
{
"id": "upload-uuid",
"filename": "photo.jpg",
"size": 204800,
"mime": "image/jpeg",
"url": "/api/v1/files/upload-uuid"
}
```
Server validates: magic bytes, rejects executables,
strips EXIF, stores with UUID filename.
---
## Search
| Method | Endpoint | Auth | Description |
| ------ | -------- | ---- | ----------- |
| GET | `/api/v1/search` | Yes | Full-text search across accessible channels |
Query params: `q` (search query),
`channel_id` (optional filter), `limit` (default 25)
```json
// Response 200
{
"results": [
{
"message_id": 1042,
"channel_id": 5,
"channel_name": "general",
"user": { "id": 1, "username": "alex" },
"content": "...matched text...",
"timestamp": "2026-03-14T10:30:00Z"
}
]
}
```
---
## Invites
| Method | Endpoint | Auth | Description |
| ------ | -------- | ---- | ----------- |
| GET | `/api/v1/invites` | Yes (admin) | List all invites |
| POST | `/api/v1/invites` | Yes (manage_invites) | Create an invite |
| DELETE | `/api/v1/invites/{id}` | Yes (manage_invites) | Revoke an invite |
### POST /api/v1/invites
```json
// Request
{ "max_uses": 5, "expires_in_hours": 48 }
// Response 201
{
"id": 1,
"code": "abc123def",
"url": "chatserver://invite/abc123def",
"max_uses": 5,
"expires_at": "2026-03-16T10:30:00Z"
}
```
---
## Admin Endpoints (admin panel uses these)
| Method | Endpoint | Auth | Description |
| ------ | -------- | ---- | ----------- |
| GET | `/api/v1/admin/stats` | Admin | Server stats (users, msgs, disk) |
| GET | `/api/v1/admin/users` | Admin | List all users with details |
| PATCH | `/api/v1/admin/users/{id}` | Admin | Update user (role, ban/unban) |
| DELETE | `/api/v1/admin/users/{id}/sessions` | Admin | Force logout a user |
| POST | `/api/v1/admin/channels` | Admin | Create channel |
| PATCH | `/api/v1/admin/channels/{id}` | Admin | Update channel |
| DELETE | `/api/v1/admin/channels/{id}` | Admin | Delete channel |
| GET | `/api/v1/admin/audit-log` | Admin | View audit log (paginated) |
| POST | `/api/v1/admin/backup` | Owner | Trigger manual backup |
| GET | `/api/v1/admin/backups` | Owner | List available backups |
| POST | `/api/v1/admin/backups/{id}/restore` | Owner | Restore from backup |
| GET | `/api/v1/admin/settings` | Admin | Get server settings |
| PATCH | `/api/v1/admin/settings` | Admin | Update server settings |
| GET | `/api/v1/admin/update-check` | Admin | Check for new server version |
| GET | `/api/v1/admin/updates` | Admin | Check for available server updates |
| POST | `/api/v1/admin/updates/apply` | Owner | Apply a server update |
### GET /api/v1/admin/updates
Check for available server updates.
Authentication: Bearer token (ADMINISTRATOR permission required)
```json
// Response 200
{
"current": "v1.0.0",
"latest": "v1.2.0",
"update_available": true,
"release_url": "https://github.com/J3vb/OwnCord/releases/tag/v1.2.0",
"download_url": "https://github.com/J3vb/OwnCord/releases/download/v1.2.0/chatserver.exe",
"checksum_url": "https://github.com/J3vb/OwnCord/releases/download/v1.2.0/checksums.sha256",
"release_notes": "## What's Changed\n..."
}
```
Error responses:
- 401: Unauthorized (missing/invalid token)
- 403: Forbidden (not an administrator)
- 502: Bad Gateway (GitHub API unreachable or returned error)
### POST /api/v1/admin/updates/apply
Download and apply a server update. Downloads the
new binary, verifies its SHA256 checksum, broadcasts
a `server_restart` WS message, then restarts.
Authentication: Bearer token (Owner role required)
```json
// Response 200
{
"status": "applying",
"version": "v1.2.0"
}
```
Error responses:
- 401: Unauthorized
- 403: Forbidden (not Owner)
- 409: Conflict (server is already up to date)
- 502: Bad Gateway (download failed, checksum mismatch, or missing release assets)
---
## WebRTC / TURN Credentials
| Method | Endpoint | Auth | Description |
| ------ | -------- | ---- | ----------- |
| GET | `/api/v1/voice/credentials` | Yes | Get time-limited TURN credentials |
```json
// Response 200
{
"ice_servers": [
{ "urls": "stun:server:3478" },
{ "urls": "turn:server:3478", "username": "ts:uid", "credential": "hmac" }
],
"expires_in": 86400
}
```
---
## Custom Emoji
| Method | Endpoint | Auth | Description |
| ------ | -------- | ---- | ----------- |
| GET | `/api/v1/emoji` | Yes | List all custom emoji |
| POST | `/api/v1/emoji` | Yes (admin) | Upload new emoji |
| DELETE | `/api/v1/emoji/{id}` | Yes (admin) | Delete emoji |
---
## Soundboard
| Method | Endpoint | Auth | Description |
| ------ | -------- | ---- | ----------- |
| GET | `/api/v1/sounds` | Yes | List all soundboard sounds |
| POST | `/api/v1/sounds` | Yes (permission) | Upload a sound |
| DELETE | `/api/v1/sounds/{id}` | Yes (admin) | Delete a sound |
---
## Health Check
| Method | Endpoint | Auth | Description |
| ------ | -------- | ---- | ----------- |
| GET | `/api/v1/health` | None | Returns 200 if server is running |
```json
{ "status": "ok", "version": "1.0.0", "uptime": 86400 }
```
---
## Error Codes
| Code | HTTP Status | Meaning |
| ---- | ----------- | ------- |
| `UNAUTHORIZED` | 401 | Missing or invalid session |
| `FORBIDDEN` | 403 | Insufficient permissions |
| `NOT_FOUND` | 404 | Resource not found |
| `RATE_LIMITED` | 429 | Too many requests (includes `retry_after`) |
| `INVALID_INPUT` | 400 | Bad request body or params |
| `CONFLICT` | 409 | e.g. username already taken |
| `TOO_LARGE` | 413 | File exceeds upload limit |
| `SERVER_ERROR` | 500 | Internal server error |
-230
View File
@@ -1,230 +0,0 @@
# ChatServer — Self-Hosted Windows Chat Platform
Native Windows desktop client + self-hosted server.
Two executables: `chatserver.exe` (server) and
`OwnCord.exe` (Tauri v2 client). Server operator runs
the server, friends install the client.
## Tech Stack
### Server (`chatserver.exe`)
- **Go** — Single exe, no dependencies. Embeds admin web UI via `go embed`.
- **SQLite** — Single `.db` file. WAL mode. Zero config.
- **Pion** — Pure Go WebRTC. Voice/video/TURN built into the exe.
- **Admin panel** — Web-based, served at `/admin`.
Browser access, not part of the client.
### Client (`OwnCord.exe`)
**Tauri v2** (Rust backend + TypeScript/HTML/CSS frontend).
See LANGUAGE-REVIEW.md for the evaluation that led to this
choice, and CLIENT-ARCHITECTURE.md for the full design.
- Tauri v2 desktop app using system WebView2 (NOT Electron)
- ~10-15 MB install size, ~30-50 MB RAM idle
- TypeScript frontend with CSS from HTML mockups
- WebSocket client for real-time chat (browser `WebSocket` API)
- WebRTC for voice/video (browser WebRTC API in webview)
- Global keyboard hooks via `tauri-plugin-global-shortcut`
- System tray via Tauri's built-in tray support
- Windows toast notifications via `tauri-plugin-notification`
- Windows Credential Manager via `windows-rs` Rust crate
- NSIS installer via Tauri bundler
## Architecture
```text
SERVER (chatserver.exe) — runs on the host machine
├── REST API (Go net/http)
├── WebSocket Hub (real-time messages, presence, typing)
├── WebRTC SFU + TURN Relay (Pion)
├── SQLite Database (data/chatserver.db)
├── File Storage (data/uploads/)
├── Admin Web UI (embedded, browser-based, /admin)
└── config.yaml
CLIENT (OwnCord.exe) — installed by each friend
├── Native Windows UI
├── WebSocket Client (chat connection)
├── WebRTC Client (voice/video)
├── Audio Engine (device management, noise suppression)
├── Local Settings (connection profiles, keybinds, audio config)
└── System Tray Integration
```
### How It Works
1. Server operator runs `chatserver.exe` on their PC/home server
2. Friends download and install `OwnCord.exe`
3. Client connects to the server via IP/domain + port
4. All chat, voice, video, and file transfers go through the server
5. Admin manages the server through a browser at `https://server-ip:port/admin`
---
## Phase 1: Protocol & Server Core (23 weeks)
- [ ] Define client-server protocol over WebSocket
(JSON messages with type/payload structure)
- [ ] Message types: auth, chat, typing, presence,
channel_update, voice_signal, file_transfer
- [ ] Server: Go project with `go embed` for admin
panel static files only
- [ ] SQLite setup with migrations on startup (users,
channels, messages, sessions, roles, invites)
- [ ] config.yaml generation on first run (port, name,
max upload size, voice quality, TLS mode)
- [ ] Server systray icon (getlantern/systray) — minimize to tray, status
indicator, open admin panel, quit
- [ ] Windows Firewall handling on first launch
- [ ] Optional: register as Windows Service for headless operation
## Phase 2: Auth & Security (23 weeks)
- [ ] Invite-only registration — server generates
invite codes, client has "Redeem Invite" flow
- [ ] bcrypt (cost 12+) passwords, server-side session tokens (256-bit random)
- [ ] Client stores auth token securely via Windows Credential Manager / DPAPI
- [ ] Login rate limiting: 5 attempts/min/IP, lockout after 10 failures
- [ ] Optional TOTP 2FA (`pquerna/otp`) — QR code
during setup, prompts on login
- [ ] Roles: Owner, Admin, Moderator, Member + custom roles with bitfield permissions
- [ ] Per-channel permission overrides, enforced server-side on every action
- [ ] TLS modes: self-signed (default), Let's Encrypt,
manual cert, off (Tailscale)
- [ ] Client: certificate pinning or trust-on-first-use (TOFU) for self-signed certs
## Phase 3: Client App — Core UI (34 weeks)
- [ ] Connection dialog: server address, port, login/register, invite code entry
- [ ] Save server profiles (connect to multiple
servers like TeamSpeak)
- [ ] Main window layout: server list → channel list → message area → member list
- [ ] Channel tree view with categories, text channels, voice channels
- [ ] Message rendering: markdown, code blocks, timestamps, avatars, replies, reactions
- [ ] Message input: multi-line, markdown preview, emoji picker, file drag-and-drop
- [ ] Unread indicators, @mention badges per channel
- [ ] System tray: minimize to tray, notification popups, badge count
- [ ] Keyboard shortcuts: Ctrl+K quick switcher,
Escape to close panels, customizable PTT key
- [ ] Settings: account, appearance (light/dark),
notifications, audio devices, keybinds
## Phase 4: Real-Time Chat Features (23 weeks)
- [ ] WebSocket client with auto-reconnect, exponential
backoff, message replay on reconnect
- [ ] Send/receive messages in real-time, append to scrollback
- [ ] Message history: paginated from server on channel switch, scroll-to-load-more
- [ ] Threads, replies (inline preview), reactions (emoji), edit, delete
- [ ] Typing indicators ("X is typing..." below input)
- [ ] Online/offline/idle/DnD presence with status icons in member list
- [ ] File uploads: drag-and-drop or clipboard paste,
progress bar, inline image previews
- [ ] Client-side file validation before upload (size check, warn on large files)
- [ ] Search: query server FTS5 endpoint, display results with jump-to-message
- [ ] Windows toast notifications with action buttons (reply, mark read)
- [ ] Notification sounds (configurable, per-channel mute/override)
## Phase 5: Voice & Video (35 weeks)
- [ ] WebRTC integration in native client for voice/video
- [ ] Audio device selection: input/output dropdowns in settings, live preview
- [ ] Voice channels: click to join/leave, show connected users with speaking indicators
- [ ] Voice controls: mute (button + keybind), deafen, per-user volume sliders
- [ ] Push-to-talk: configurable global hotkey that works in fullscreen games
- [ ] Voice activity detection with configurable sensitivity
- [ ] Noise suppression (RNNoise or equivalent, bundled with client)
- [ ] Server-side: Pion SFU with DTLS-SRTP, built-in
TURN relay with per-session credentials
- [ ] Voice quality: low (32kbps) / medium (64kbps) / high (128kbps Opus)
- [ ] Screen sharing via DXGI Desktop Duplication, sent as video track
- [ ] Video calls: camera capture, displayed in voice channel panel
- [ ] Soundboard: short clips, hotkey triggers, role-based permissions, play cooldown
## Phase 6: Admin Panel — Web-Based (12 weeks)
- [ ] Served by server at `/admin`, browser-only access
- [ ] Auth: admin credentials, session-based
- [ ] Dashboard: connected users, message count, disk usage, CPU/RAM, uptime
- [ ] User management: list all, edit roles, ban/unban, reset password, force disconnect
- [ ] Channel management: create, rename, reorder, set permissions, archive
- [ ] Invite management: generate, view active, set expiry/use limit, revoke
- [ ] Server settings: name, icon, MOTD, max upload size, voice quality, TLS config
- [ ] Moderation: kick, ban, temp ban, slow mode, mute, word filter, audit log
- [ ] Backup: trigger manual backup, configure
schedule, view/restore from admin panel
- [ ] Built with simple HTML/CSS/JS embedded in the server binary
## Phase 7: Distribution & Updates (12 weeks)
- [ ] **Server:** GitHub Actions builds
`chatserver.exe` (amd64), SHA256, GitHub Release
- [ ] **Client:** Tauri bundler (NSIS) installer —
Program Files, Start Menu, auto-start, protocol
handler for `chatserver://` invite links
- [ ] Client auto-update: check GitHub releases on
launch, prompt to download + install
- [ ] Server update: admin panel shows available update, one-click download + restart
- [ ] Docs: Quick Start, Port Forwarding, Tailscale,
Client install guide
- [ ] Security hardening checklist for server operators
- [ ] SECURITY.md, README.md, CONTRIBUTING.md
---
## Windows-Specific Details
### Client (Tauri v2)
- **Installer:** Tauri bundler (NSIS, ~10-15 MB).
Registers `chatserver://` protocol handler.
- **Auto-start:** Registry key
`HKCU\Software\Microsoft\Windows\CurrentVersion\Run`.
- **Credentials:** Auth tokens stored in Windows
Credential Manager via `windows-rs` Rust crate.
- **Push-to-talk:** Global hotkey via
`tauri-plugin-global-shortcut`.
- **Audio:** WebView2 WebRTC API (browser audio).
- **Screen capture:** WebRTC `getDisplayMedia` in
webview.
- **Notifications:** `tauri-plugin-notification`
(Windows toast).
- **Tray:** Tauri built-in system tray with badge.
- See CLIENT-ARCHITECTURE.md for full design.
### Server
- **Firewall:** Prompt on first run. Installer can pre-register firewall rule.
- **SmartScreen:** Unsigned exe shows warning. Code signing cert resolves this.
- **Data path:** `data/` next to exe. Installer version uses `%APPDATA%/ChatServer/`.
- **Logs:** `data/logs/` with daily rotation, viewable from admin panel.
- **Service mode:** `chatserver.exe --service install` to register as Windows Service.
## Security Priorities
**Critical:** Invite-only registration, bcrypt auth,
TLS (self-signed minimum), file upload validation
(magic bytes, block executables), input sanitization
server-side, credential storage via DPAPI, backups.
**High:** Rate limiting, TOTP 2FA, role permissions,
WebSocket auth, TURN credentials, cert pinning/TOFU,
update integrity (SHA256).
## Server Libraries (Go)
| Purpose | Library |
| --- | --- |
| HTTP/routing | `net/http` + `chi` |
| WebSocket | `nhooyr.io/websocket` |
| WebRTC/TURN | `pion/webrtc` + `pion/turn` |
| SQLite | `modernc.org/sqlite` (pure Go) |
| Auth | `golang.org/x/crypto/bcrypt` |
| TOTP | `pquerna/otp` |
| Sanitization | `bluemonday` |
| TLS | `golang.org/x/crypto/acme/autocert` |
| Systray | `getlantern/systray` |
| Config | `koanf` |
| Logging | `log/slog` |
-53
View File
@@ -1,53 +0,0 @@
# Contributing
## Development Setup
See **SETUP.md** for tooling requirements and
**CLAUDE.md** for build commands.
## Active Branches
- `main` -- stable releases
- `tauri-migration` -- active development
## Branch Naming
- `feature/<name>` -- new features
- `fix/<name>` -- bug fixes
- `docs/<name>` -- documentation changes
## Commit Format
Use conventional commits:
```text
feat: add thread support to channels
fix: prevent duplicate WebSocket connections
refactor: extract permission checks into middleware
docs: update quick-start guide
test: add integration tests for invite flow
chore: bump Go dependencies
perf: cache role permissions in memory
ci: add lint step to GitHub Actions
```
## Pull Request Process
1. Branch from `tauri-migration`
2. CI must pass (build + test + lint)
3. Request code review
4. Squash merge preferred
## Testing
Target **80%+ coverage**. Follow TDD workflow.
See **TESTING-STRATEGY.md** for full details and
**CLAUDE.md** for test commands.
## Code Style
- **TypeScript**: See CLIENT-ARCHITECTURE.md
- **Go**: `gofmt` + `golangci-lint`, standard
library preferred
- **Rust**: `cargo fmt` + `cargo clippy`, minimal
code (native APIs only)
+1
View File
@@ -2494,6 +2494,7 @@ name = "owncord-client"
version = "0.1.0"
dependencies = [
"futures-util",
"ring",
"rustls",
"serde",
"serde_json",
+1
View File
@@ -23,6 +23,7 @@ tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots"]
futures-util = "0.3.32"
tokio = { version = "1", features = ["sync"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
ring = "0.17"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = ["Win32_Security_Credentials", "Win32_Foundation"] }
+1
View File
@@ -20,6 +20,7 @@ pub fn run() {
ws_proxy::ws_connect,
ws_proxy::ws_send,
ws_proxy::ws_disconnect,
ws_proxy::accept_cert_fingerprint,
credentials::save_credential,
credentials::load_credential,
credentials::delete_credential,
+178 -19
View File
@@ -1,12 +1,27 @@
// WebSocket proxy — routes WSS through Rust to bypass self-signed cert rejection.
// JS sends/receives messages via Tauri events instead of native WebSocket.
//
// Implements TOFU (Trust On First Use) certificate pinning:
// - On first connect to a host, the cert SHA-256 fingerprint is stored.
// - On subsequent connects, the fingerprint is compared with the stored value.
// - If the fingerprint changes, the connection is rejected (potential MitM).
use futures_util::{SinkExt, StreamExt};
use ring::digest::{digest, SHA256};
use serde_json::Value;
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, Emitter, Runtime};
use tauri_plugin_store::StoreExt;
use tokio::sync::{mpsc, Mutex};
use tokio_tungstenite::tungstenite::Message;
/// Maximum time to wait for the WebSocket handshake to complete.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// Tauri store file for certificate fingerprints.
const CERTS_STORE: &str = "certs.json";
/// Sender half kept in Tauri state so `ws_send` can push messages.
pub struct WsState {
tx: Mutex<Option<mpsc::Sender<String>>>,
@@ -20,30 +35,48 @@ impl WsState {
}
}
/// Build a rustls ClientConfig that accepts any certificate.
fn make_tls_config() -> rustls::ClientConfig {
let config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoVerifier))
.with_no_client_auth();
config
/// Shared fingerprint captured during TLS handshake.
type CapturedFingerprint = Arc<std::sync::Mutex<Option<String>>>;
/// TOFU certificate verifier that captures the server cert fingerprint
/// during the TLS handshake. Still accepts self-signed certs (required
/// for self-hosted servers), but records the fingerprint for comparison
/// with the stored value after the connection is established.
#[derive(Debug)]
struct TofuVerifier {
captured: CapturedFingerprint,
}
/// Certificate verifier that skips chain validation (for self-signed certs)
/// but still verifies TLS handshake signatures cryptographically.
/// TODO: Replace with TOFU fingerprint verifier using store_cert_fingerprint/get_cert_fingerprint.
#[derive(Debug)]
struct NoVerifier;
impl TofuVerifier {
fn new() -> (Self, CapturedFingerprint) {
let fp = Arc::new(std::sync::Mutex::new(None));
(Self { captured: fp.clone() }, fp)
}
}
impl rustls::client::danger::ServerCertVerifier for NoVerifier {
impl rustls::client::danger::ServerCertVerifier for TofuVerifier {
fn verify_server_cert(
&self,
_end_entity: &rustls::pki_types::CertificateDer<'_>,
end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
// Compute SHA-256 fingerprint of the DER-encoded leaf certificate.
let hash = digest(&SHA256, end_entity.as_ref());
let hex = hash
.as_ref()
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(":");
if let Ok(mut guard) = self.captured.lock() {
*guard = Some(hex);
}
// Accept the cert — TOFU check happens after the handshake completes.
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
@@ -92,9 +125,63 @@ impl rustls::client::danger::ServerCertVerifier for NoVerifier {
}
}
/// Extract the host (with port) from a wss:// URL.
fn extract_host(url: &str) -> String {
url.strip_prefix("wss://")
.unwrap_or(url)
.split('/')
.next()
.unwrap_or(url)
.to_string()
}
/// Perform TOFU fingerprint check against the Tauri cert store.
/// Returns Ok(()) if trusted, Err(message) if fingerprint mismatch.
fn tofu_check<R: Runtime>(
app: &AppHandle<R>,
host: &str,
fingerprint: &str,
) -> Result<String, String> {
let store = app
.store(CERTS_STORE)
.map_err(|e| format!("failed to open certs store: {e}"))?;
let stored = store.get(host).and_then(|v| {
if let Value::String(s) = v {
Some(s)
} else {
None
}
});
match stored {
None => {
// First use — store the fingerprint.
store.set(host, Value::String(fingerprint.to_string()));
if let Err(e) = store.save() {
return Err(format!("failed to persist cert fingerprint: {e}"));
}
Ok("trusted_first_use".to_string())
}
Some(ref stored_fp) if stored_fp == fingerprint => {
Ok("trusted".to_string())
}
Some(stored_fp) => {
Err(format!(
"Certificate fingerprint changed for {host}.\n\
Stored: {stored_fp}\n\
Current: {fingerprint}\n\
This may indicate a man-in-the-middle attack or a server certificate rotation.\n\
Use accept_cert_fingerprint to trust the new certificate."
))
}
}
}
/// Connect to a WSS server. Spawns a background task that:
/// - Emits `ws-message` events for incoming server messages
/// - Emits `ws-state` events for connection state changes
/// - Emits `cert-tofu` events for TOFU fingerprint status
/// - Reads from an mpsc channel for outgoing messages
#[tauri::command]
pub async fn ws_connect<R: Runtime>(
@@ -115,18 +202,67 @@ pub async fn ws_connect<R: Runtime>(
let _ = app.emit("ws-state", "connecting");
let tls_config = make_tls_config();
// Create TOFU verifier that captures the cert fingerprint during handshake.
let (verifier, captured_fp) = TofuVerifier::new();
let tls_config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(verifier))
.with_no_client_auth();
let connector =
tokio_tungstenite::Connector::Rustls(Arc::new(tls_config));
let (ws_stream, _response) = tokio_tungstenite::connect_async_tls_with_config(
let connect_future = tokio_tungstenite::connect_async_tls_with_config(
&url,
None,
false,
Some(connector),
)
.await
.map_err(|e| format!("ws connect failed: {e}"))?;
);
let (ws_stream, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_future)
.await
.map_err(|_| format!("ws connect timed out after {}s", CONNECT_TIMEOUT.as_secs()))?
.map_err(|e| format!("ws connect failed: {e}"))?;
// ── TOFU check ───────────────────────────────────────────────────────
let host = extract_host(&url);
let fingerprint = captured_fp
.lock()
.map_err(|e| format!("failed to read captured fingerprint: {e}"))?
.clone()
.unwrap_or_default();
if fingerprint.is_empty() {
return Err("TLS handshake completed but no certificate fingerprint was captured".into());
}
match tofu_check(&app, &host, &fingerprint) {
Ok(status) => {
let _ = app.emit(
"cert-tofu",
serde_json::json!({
"host": host,
"fingerprint": fingerprint,
"status": status,
}),
);
}
Err(mismatch_msg) => {
let _ = app.emit(
"cert-tofu",
serde_json::json!({
"host": host,
"fingerprint": fingerprint,
"status": "mismatch",
"message": mismatch_msg,
}),
);
// Reject the connection — do not proceed.
return Err(mismatch_msg);
}
}
// ── End TOFU check ───────────────────────────────────────────────────
let _ = app.emit("ws-state", "open");
@@ -201,3 +337,26 @@ pub async fn ws_disconnect(state: tauri::State<'_, WsState>) -> Result<(), Strin
*tx_lock = None; // dropping the sender closes the channel → write task ends
Ok(())
}
/// Accept a changed certificate fingerprint for a host.
/// Call this after the user acknowledges a cert-mismatch warning.
#[tauri::command]
pub fn accept_cert_fingerprint<R: Runtime>(
app: AppHandle<R>,
host: String,
fingerprint: String,
) -> Result<(), String> {
if host.is_empty() || fingerprint.is_empty() {
return Err("host and fingerprint must not be empty".into());
}
let store = app
.store(CERTS_STORE)
.map_err(|e| format!("failed to open certs store: {e}"))?;
store.set(&host, Value::String(fingerprint));
store
.save()
.map_err(|e| format!("failed to persist cert fingerprint: {e}"))?;
Ok(())
}
+14
View File
@@ -172,7 +172,21 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
unsubs.push(
ws.on("channel_delete", (payload) => {
// If the deleted channel is the active one, redirect to the first text channel.
const activeId = channelsStore.select((s) => s.activeChannelId);
removeChannel(payload.id);
if (payload.id === activeId) {
const remaining = channelsStore.select((s) => s.channels);
let firstTextId: number | null = null;
for (const [, ch] of remaining) {
if (ch.type === "text") {
firstTextId = ch.id;
break;
}
}
setActiveChannel(firstTextId);
log.info("Active channel deleted, redirected", { deletedId: payload.id });
}
}),
);
+65 -2
View File
@@ -36,6 +36,16 @@ export type WsListener<T extends ServerMessage["type"]> = (
id?: string,
) => void;
/** TOFU certificate event emitted by the Rust WS proxy. */
export interface CertTofuEvent {
readonly host: string;
readonly fingerprint: string;
readonly status: "trusted_first_use" | "trusted" | "mismatch";
readonly message?: string;
}
export type CertMismatchListener = (event: CertTofuEvent) => void;
export interface WsClientConfig {
readonly host: string;
readonly token: string;
@@ -58,6 +68,7 @@ export function createWsClient() {
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
let intentionalClose = false;
let certMismatchBlock = false; // blocks reconnect on TOFU mismatch
let proxyOpen = false;
// Tauri event unsubscribe functions
@@ -69,6 +80,9 @@ export function createWsClient() {
// State change listeners
const stateListeners = new Set<(state: ConnectionState) => void>();
// TOFU cert mismatch listeners
const certMismatchListeners = new Set<CertMismatchListener>();
function setState(newState: ConnectionState): void {
if (state !== newState) {
state = newState;
@@ -104,7 +118,7 @@ export function createWsClient() {
}
function scheduleReconnect(): void {
if (intentionalClose || !config) return;
if (intentionalClose || certMismatchBlock || !config) return;
const delay = getReconnectDelay();
log.info(`Reconnecting in ${delay}ms (attempt ${reconnectAttempt + 1})`);
setState("reconnecting");
@@ -215,6 +229,24 @@ export function createWsClient() {
log.warn("WebSocket error (proxy)", { error: e.payload });
});
eventUnsubs.push(unsubErr);
// TOFU certificate events
const unsubCert = await tauriListen("cert-tofu", (e) => {
const evt = e.payload as CertTofuEvent;
log.info("TOFU cert event", { host: evt.host, status: evt.status });
if (evt.status === "mismatch") {
log.error("Certificate fingerprint mismatch!", {
host: evt.host,
fingerprint: evt.fingerprint,
message: evt.message,
});
for (const listener of certMismatchListeners) {
listener(evt);
}
}
});
eventUnsubs.push(unsubCert);
}
function cleanupEventListeners(): void {
@@ -248,9 +280,18 @@ export function createWsClient() {
try {
await tauriInvoke("ws_connect", { url: wsUrl });
} catch (err) {
const errStr = String(err);
log.error("ws_connect failed", err);
proxyOpen = false;
scheduleReconnect();
// If the error is a cert fingerprint mismatch, don't auto-reconnect.
// The user must explicitly accept the new fingerprint first.
if (errStr.includes("Certificate fingerprint changed")) {
certMismatchBlock = true;
setState("disconnected");
} else {
scheduleReconnect();
}
}
}
@@ -284,6 +325,7 @@ export function createWsClient() {
function disconnect(): void {
intentionalClose = true;
certMismatchBlock = false;
cancelReconnect();
stopHeartbeat();
cleanupEventListeners();
@@ -321,6 +363,27 @@ export function createWsClient() {
return () => stateListeners.delete(listener);
},
/** Register a listener for TOFU certificate mismatch events. */
onCertMismatch(listener: CertMismatchListener): () => void {
certMismatchListeners.add(listener);
return () => certMismatchListeners.delete(listener);
},
/**
* Accept a changed certificate fingerprint for a host.
* Call after the user acknowledges a cert mismatch warning,
* then reconnect.
*/
async acceptCertFingerprint(host: string, fingerprint: string): Promise<void> {
await ensureTauriApis();
if (tauriInvoke === null) {
throw new Error("Tauri APIs not available");
}
await tauriInvoke("accept_cert_fingerprint", { host, fingerprint });
certMismatchBlock = false;
log.info("Accepted new cert fingerprint", { host });
},
getState(): ConnectionState {
return state;
},
+9 -1
View File
@@ -9,7 +9,7 @@ import type {
ServerMessage,
ClientMessage,
} from "@lib/types";
import type { ConnectionState, WsListener } from "@lib/ws";
import type { ConnectionState, WsListener, CertMismatchListener } from "@lib/ws";
interface SentEnvelope {
readonly type: string;
@@ -78,6 +78,14 @@ export function createMockWsClient() {
return () => stateListeners.delete(listener);
},
onCertMismatch(_listener: CertMismatchListener): () => void {
return () => {};
},
async acceptCertFingerprint(_host: string, _fingerprint: string): Promise<void> {
// no-op in mock
},
getState(): ConnectionState {
return state;
},
@@ -65,6 +65,14 @@ function createMockWsClient(): MockWsClient {
return () => stateListeners.delete(listener);
},
onCertMismatch(): () => void {
return () => {};
},
async acceptCertFingerprint(): Promise<void> {
// no-op in mock
},
getState(): ConnectionState {
return currentState;
},
@@ -37,6 +37,8 @@ function createMockWs() {
};
},
onStateChange: vi.fn(() => () => {}),
onCertMismatch: vi.fn(() => () => {}),
acceptCertFingerprint: vi.fn(async () => {}),
getState: vi.fn(() => "disconnected" as const),
_getWs: vi.fn(() => null),
};
-488
View File
@@ -1,488 +0,0 @@
# WebSocket Protocol Spec
All client-server communication (except file uploads and
admin panel) happens over a single WebSocket connection.
Messages are JSON with a `type` and `payload`.
## Message Format
```json
{
"type": "message_type",
"id": "unique-request-id",
"payload": { }
}
```
- `type` — string, required. Determines how payload is interpreted.
- `id` — string, optional. Client-generated UUID for request/response correlation.
- `payload` — object, required. Contents vary by type.
Server responses to client requests include the same `id` for correlation.
---
## Authentication
### Client → Server
```json
{ "type": "auth", "payload": { "token": "session-token-here" } }
```
### Server → Client (success)
```json
{
"type": "auth_ok",
"payload": {
"user": {
"id": 1, "username": "alex",
"avatar": "uuid.png", "role": "admin"
},
"server_name": "My Server",
"motd": "Welcome!"
}
}
```
### Server → Client (failure)
```json
{ "type": "auth_error", "payload": { "message": "Invalid or expired token" } }
```
Connection is closed by server after auth_error.
---
## Chat Messages
### Send Message (Client → Server)
```json
{
"type": "chat_send",
"id": "req-uuid",
"payload": {
"channel_id": 5,
"content": "Hello everyone!",
"reply_to": null,
"attachments": ["upload-uuid-1"]
}
}
```
### Message Broadcast (Server → Client)
```json
{
"type": "chat_message",
"payload": {
"id": 1042, "channel_id": 5,
"user": {
"id": 1, "username": "alex",
"avatar": "uuid.png"
},
"content": "Hello everyone!",
"reply_to": null,
"attachments": [{
"id": "upload-uuid-1",
"filename": "photo.jpg",
"size": 204800,
"mime": "image/jpeg",
"url": "/files/upload-uuid-1"
}],
"timestamp": "2026-03-14T10:30:00Z"
}
}
```
### Send Ack (Server → Client)
```json
{
"type": "chat_send_ok",
"id": "req-uuid",
"payload": {
"message_id": 1042,
"timestamp": "2026-03-14T10:30:00Z"
}
}
```
### Edit Message (Client → Server)
```json
{
"type": "chat_edit",
"id": "req-uuid",
"payload": {
"message_id": 1042,
"content": "Hello everyone! (edited)"
}
}
```
### Edit Broadcast (Server → Client)
```json
{
"type": "chat_edited",
"payload": {
"message_id": 1042,
"channel_id": 5,
"content": "Hello everyone! (edited)",
"edited_at": "2026-03-14T10:31:00Z"
}
}
```
### Delete Message (Client → Server)
```json
{ "type": "chat_delete", "id": "req-uuid", "payload": { "message_id": 1042 } }
```
### Delete Broadcast (Server → Client)
```json
{ "type": "chat_deleted", "payload": { "message_id": 1042, "channel_id": 5 } }
```
### Reaction Add/Remove (Client → Server)
```json
{ "type": "reaction_add", "payload": { "message_id": 1042, "emoji": "👍" } }
{ "type": "reaction_remove", "payload": { "message_id": 1042, "emoji": "👍" } }
```
### Reaction Broadcast (Server → Client)
```json
{
"type": "reaction_update",
"payload": {
"message_id": 1042,
"channel_id": 5,
"emoji": "👍",
"user_id": 1,
"action": "add"
}
}
```
---
## Typing Indicators
### Client → Server (throttle to 1 per 3 seconds)
```json
{ "type": "typing_start", "payload": { "channel_id": 5 } }
```
### Server → Client (broadcast to channel members)
```json
{
"type": "typing",
"payload": {
"channel_id": 5,
"user_id": 1,
"username": "alex"
}
}
```
Client-side: show indicator for 5 seconds, reset on new typing event from same user.
---
## Presence
### Presence Client → Server
```json
{ "type": "presence_update", "payload": { "status": "online" } }
```
Status values: `online`, `idle`, `dnd`, `offline`
### Presence Server → Client (broadcast)
```json
{ "type": "presence", "payload": { "user_id": 1, "status": "online" } }
```
Server auto-sets `idle` after 10 minutes of no WebSocket activity.
---
## Channel Updates
### Server → Client (on channel created/edited/deleted/reordered)
```json
{
"type": "channel_create",
"payload": {
"id": 8, "name": "gaming",
"type": "text",
"category": "Hangout", "position": 3
}
}
{
"type": "channel_update",
"payload": {
"id": 8, "name": "gaming-talk",
"position": 4
}
}
{ "type": "channel_delete", "payload": { "id": 8 } }
```
Channel types: `text`, `voice`, `announcement`
---
## Voice Signaling
### Join Voice Channel (Client → Server)
```json
{ "type": "voice_join", "payload": { "channel_id": 10 } }
```
### Server → Client (voice state updates, broadcast to channel)
```json
{
"type": "voice_state",
"payload": {
"channel_id": 10, "user_id": 1,
"username": "alex",
"muted": false, "deafened": false,
"speaking": false,
"camera": false, "screenshare": false
}
}
```
### Voice User Left (Server → Client)
```json
{ "type": "voice_leave", "payload": { "channel_id": 10, "user_id": 1 } }
```
### Voice Config (Server → Client, sent after voice_join acceptance)
```json
{
"type": "voice_config",
"payload": {
"channel_id": 10, "quality": "medium", "bitrate": 64000,
"threshold_mode": "forwarding", "mixing_threshold": 10,
"top_speakers": 3, "max_users": 50
}
}
```
Client uses `bitrate` to configure the Opus encoder. Other fields are
informational for UI.
### WebRTC Signaling (Client ↔ Server SFU)
**Note:** As of the SFU migration, `voice_offer`/`voice_answer`/`voice_ice`
are exchanged between each client and the **server** (not relayed between
clients). The server is the WebRTC peer.
Clients must include RFC 6464 `ssrc-audio-level` RTP header extension in SDP offers.
```json
{ "type": "voice_offer", "payload": { "channel_id": 10, "sdp": "..." } }
{ "type": "voice_answer", "payload": { "channel_id": 10, "sdp": "..." } }
{ "type": "voice_ice", "payload": { "channel_id": 10, "candidate": "..." } }
```
### Voice Control (Client → Server)
```json
{ "type": "voice_mute", "payload": { "muted": true } }
{ "type": "voice_deafen", "payload": { "deafened": true } }
```
### Voice Camera / Screenshare (Client → Server)
```json
{ "type": "voice_camera", "payload": { "enabled": true } }
{ "type": "voice_screenshare", "payload": { "enabled": true } }
```
Requires `USE_VIDEO` (bit 11) or `SHARE_SCREEN` (bit 12) permission.
Rate limit: 2/sec per user.
### Active Speakers (Server → Client)
```json
{
"type": "voice_speakers",
"payload": {
"channel_id": 10, "speakers": [1, 5, 12],
"threshold_mode": "forwarding"
}
}
```
- `speakers`: Active speaker user IDs (up to top-N)
- `threshold_mode`: `"forwarding"` or `"selective"`
- Sent on speaker list changes or mode transitions
- Rate: at most once per 200ms per channel
### Soundboard (Client → Server)
```json
{ "type": "soundboard_play", "payload": { "sound_id": "uuid" } }
```
---
## Member Updates
### Server → Client
```json
{
"type": "member_join",
"payload": {
"user": {
"id": 5, "username": "newuser",
"avatar": null, "role": "member"
}
}
}
{ "type": "member_leave", "payload": { "user_id": 5 } }
{ "type": "member_update", "payload": { "user_id": 5, "role": "moderator" } }
{ "type": "member_ban", "payload": { "user_id": 5 } }
```
---
## Server Restart
### Restart Server → Client
```json
{
"type": "server_restart",
"payload": {
"reason": "update",
"delay_seconds": 5
}
}
```
- `reason` (string): Why the server is restarting. Currently only `"update"`.
- `delay_seconds` (integer): How many seconds until the server shuts down.
Client behavior: Display a banner ("Server restarting..."),
then auto-reconnect after the delay expires.
---
## Initial State (sent after auth_ok)
### Ready Server → Client
```json
{
"type": "ready",
"payload": {
"channels": [
{
"id": 1, "name": "general",
"type": "text", "category": "Main",
"position": 0, "unread_count": 3,
"last_message_id": 1040
},
{
"id": 10, "name": "voice-chat",
"type": "voice", "category": "Main",
"position": 1
}
],
"members": [
{
"id": 1, "username": "alex",
"avatar": "uuid.png",
"role": "admin", "status": "online"
},
{
"id": 2, "username": "jordan",
"avatar": null,
"role": "member", "status": "idle"
}
],
"voice_states": [
{ "channel_id": 10, "user_id": 2, "muted": false, "deafened": false }
],
"roles": [
{
"id": 1, "name": "Owner",
"color": "#E74C3C",
"permissions": 2147483647
},
{
"id": 2, "name": "Admin",
"color": "#F39C12",
"permissions": 1073741823
},
{ "id": 3, "name": "Member", "color": null, "permissions": 1049601 }
]
}
}
```
---
## Message History (REST, not WebSocket)
Fetched via REST API, not WebSocket, to keep the WS connection lean.
```text
GET /api/channels/{id}/messages?before={msg_id}&limit=50
```
---
## Error Format
Any request that fails returns:
```json
{
"type": "error",
"id": "original-req-uuid",
"payload": {
"code": "FORBIDDEN",
"message": "No permission to post here"
}
}
```
Error codes: `FORBIDDEN`, `NOT_FOUND`, `RATE_LIMITED`, `INVALID_INPUT`,
`SERVER_ERROR`, `CHANNEL_FULL`, `INVALID_SDP`, `VOICE_ERROR`, `VIDEO_LIMIT`
---
## Rate Limits
- Chat messages: 10/sec per user
- Typing events: 1/3sec per user per channel
- Presence updates: 1/10sec per user
- Reactions: 5/sec per user
- Voice signaling: 20/sec per user
- Voice camera/screenshare: 2/sec per user
- Soundboard: 1/3sec per user
Server sends `rate_limited` error with `retry_after` in seconds.
-319
View File
@@ -1,319 +0,0 @@
# Database Schema (SQLite)
Single file: `data/chatserver.db`. WAL mode enabled.
Migrations run automatically on startup.
---
## Users
```sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password TEXT NOT NULL, -- bcrypt hash
avatar TEXT, -- filename in uploads/ or NULL
role_id INTEGER NOT NULL DEFAULT 4 REFERENCES roles(id),
totp_secret TEXT, -- encrypted TOTP secret or NULL if 2FA disabled
status TEXT NOT NULL DEFAULT 'offline', -- online, idle, dnd, offline
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_seen TEXT,
banned INTEGER NOT NULL DEFAULT 0,
ban_reason TEXT,
ban_expires TEXT -- NULL = permanent, datetime = temp ban
);
```
## Sessions
```sql
CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE, -- 256-bit random, hex encoded
device TEXT, -- user-agent or client identifier
ip_address TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL -- 30 days from creation
);
CREATE INDEX idx_sessions_token ON sessions(token);
CREATE INDEX idx_sessions_user ON sessions(user_id);
```
## Roles
```sql
CREATE TABLE roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
color TEXT, -- hex color e.g. #E74C3C, NULL for default
permissions INTEGER NOT NULL DEFAULT 0, -- bitfield
position INTEGER NOT NULL DEFAULT 0, -- hierarchy: higher = more power
is_default INTEGER NOT NULL DEFAULT 0 -- 1 = assigned to new users
);
-- Default roles (inserted on first run)
-- Owner: permissions = 0x7FFFFFFF (all bits set)
-- Admin: permissions = 0x3FFFFFFF
-- Moderator: permissions = 0x000FFFFF
-- Member: permissions = 0x00000663
```
### Permission Bitfield
```text
Bit 0: SEND_MESSAGES (0x1)
Bit 1: READ_MESSAGES (0x2)
Bit 5: ATTACH_FILES (0x20)
Bit 6: ADD_REACTIONS (0x40)
Bit 8: USE_SOUNDBOARD (0x100)
Bit 9: CONNECT_VOICE (0x200)
Bit 10: SPEAK_VOICE (0x400)
Bit 11: USE_VIDEO (0x800)
Bit 12: SHARE_SCREEN (0x1000)
Bit 16: MANAGE_MESSAGES (0x10000) -- delete others' messages, pin
Bit 17: MANAGE_CHANNELS (0x20000)
Bit 18: KICK_MEMBERS (0x40000)
Bit 19: BAN_MEMBERS (0x80000)
Bit 20: MUTE_MEMBERS (0x100000) -- server mute/deafen
Bit 24: MANAGE_ROLES (0x1000000)
Bit 25: MANAGE_SERVER (0x2000000)
Bit 26: MANAGE_INVITES (0x4000000)
Bit 27: VIEW_AUDIT_LOG (0x8000000)
Bit 30: ADMINISTRATOR (0x40000000) -- bypasses all checks
```
## Channels
```sql
CREATE TABLE channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'text', -- text, voice, announcement
category TEXT, -- category name for grouping
topic TEXT, -- channel description
position INTEGER NOT NULL DEFAULT 0,
slow_mode INTEGER NOT NULL DEFAULT 0, -- seconds, 0=off
archived INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
voice_max_users INTEGER NOT NULL DEFAULT 0, -- 0 = unlimited
voice_quality TEXT, -- low|medium|high; NULL=default
mixing_threshold INTEGER, -- NULL = server default
voice_max_video INTEGER NOT NULL DEFAULT 10 -- max video streams
);
```
## Channel Permission Overrides
```sql
CREATE TABLE channel_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
allow INTEGER NOT NULL DEFAULT 0, -- permission bits to grant
deny INTEGER NOT NULL DEFAULT 0, -- permission bits to revoke
UNIQUE(channel_id, role_id)
);
```
## Messages
```sql
CREATE TABLE 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, -- 1 = soft deleted
pinned INTEGER NOT NULL DEFAULT 0,
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_messages_channel ON messages(channel_id, id DESC);
CREATE INDEX idx_messages_user ON messages(user_id);
```
## Message Full-Text Search
```sql
CREATE VIRTUAL TABLE messages_fts USING fts5(
content,
content='messages',
content_rowid='id'
);
-- Triggers to keep FTS in sync
CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
END;
CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content)
VALUES('delete', old.id, old.content);
END;
CREATE TRIGGER messages_au AFTER UPDATE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content)
VALUES('delete', old.id, old.content);
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
END;
```
## Attachments
```sql
CREATE TABLE attachments (
id TEXT PRIMARY KEY, -- UUID
message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE,
filename TEXT NOT NULL, -- original filename
stored_as TEXT NOT NULL, -- UUID filename on disk
mime_type TEXT NOT NULL,
size INTEGER NOT NULL, -- bytes
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
## Reactions
```sql
CREATE TABLE reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
UNIQUE(message_id, user_id, emoji)
);
```
## Invites
```sql
CREATE TABLE invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE, -- random token
created_by INTEGER NOT NULL REFERENCES users(id),
redeemed_by INTEGER REFERENCES users(id),
max_uses INTEGER, -- NULL = unlimited
use_count INTEGER NOT NULL DEFAULT 0,
expires_at TEXT, -- NULL = never
created_at TEXT NOT NULL DEFAULT (datetime('now')),
revoked INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_invites_code ON invites(code);
```
## Read State (unread tracking)
```sql
CREATE TABLE read_states (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
last_message_id INTEGER NOT NULL DEFAULT 0,
mention_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, channel_id)
);
```
## Audit Log
```sql
CREATE TABLE audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id),
action TEXT NOT NULL, -- e.g. user_ban, channel_create
target_type TEXT, -- user, channel, message, role, invite
target_id INTEGER,
details TEXT, -- JSON with extra context
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_audit_timestamp ON audit_log(timestamp DESC);
```
## Login Attempts (rate limiting)
```sql
CREATE TABLE login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL,
username TEXT,
success INTEGER NOT NULL DEFAULT 0,
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_login_ip ON login_attempts(ip_address, timestamp);
```
## Server Settings (key-value)
```sql
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Default settings inserted on first run:
-- server_name, server_icon, motd, max_upload_bytes, voice_quality,
-- require_2fa, registration_open (always 0), backup_schedule, backup_retention
```
## Custom Emoji
```sql
CREATE TABLE emoji (
id INTEGER PRIMARY KEY AUTOINCREMENT,
shortcode TEXT NOT NULL UNIQUE, -- e.g. :pepe:
filename TEXT NOT NULL, -- stored in uploads/emoji/
uploaded_by INTEGER NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
## Soundboard
```sql
CREATE TABLE sounds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
filename TEXT NOT NULL, -- stored in uploads/sounds/
duration_ms INTEGER NOT NULL,
uploaded_by INTEGER NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
---
## Voice States
```sql
CREATE TABLE 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,
camera INTEGER NOT NULL DEFAULT 0,
screenshare INTEGER NOT NULL DEFAULT 0,
joined_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_voice_states_channel ON voice_states(channel_id);
```
On startup: `DELETE FROM voice_states;` clears stale state from previous run.
---
## Notes
- All datetimes stored as ISO 8601 UTC strings.
- Enable WAL mode on connection: `PRAGMA journal_mode=WAL;`
- Enable foreign keys: `PRAGMA foreign_keys=ON;`
- Use `modernc.org/sqlite` (pure Go, no CGO needed).
- Migrations: schema version in `settings`, apply incremental SQL on startup.
-27
View File
@@ -1,27 +0,0 @@
# Security Policy
## Reporting Vulnerabilities
Use GitHub Security Advisories to report vulnerabilities: go to Settings > Security > Advisories and create a new advisory.
**Do NOT open public issues for security bugs.**
## Response Timeline
- **Acknowledgment:** Within 48 hours
- **Critical fixes:** Within 7 days
- **Non-critical fixes:** Included in the next release
## Known Limitations
- No code signing yet — binaries are verified via SHA256 checksums only
## Security Hardening Checklist for Operators
- [ ] Enable TLS (self-signed is the default; custom certs recommended for production)
- [ ] Keep invite-only registration enabled (default)
- [ ] Set a strong admin password
- [ ] Configure rate limits (defaults are sensible but review for your use case)
- [ ] Run regular backups via the admin panel
- [ ] Keep the server updated (admin panel shows available updates)
- [ ] Firewall: only expose port 8443 (HTTPS) and 3478 (TURN/STUN for voice)
-135
View File
@@ -1,135 +0,0 @@
# Developer Setup Guide
What you need to install yourself vs what Claude Code
can handle.
---
## You Install (Claude Code can't do these)
These require GUI installers, admin privileges, or
system-level changes.
### Required
1. **Git** -- <https://git-scm.com/download/win>
- Default install options are fine.
2. **Go** -- <https://go.dev/dl/>
- Windows amd64 `.msi` installer.
- Verify: `go version`
3. **Node.js (LTS)** -- <https://nodejs.org>
- Required for Tauri frontend build tools.
- Verify: `node --version` (v20+)
4. **Rust** -- <https://rustup.rs>
- Required for the Tauri v2 client backend.
- Install via `rustup-init.exe`.
- Verify: `rustc --version`
5. **Visual Studio Build Tools 2022** --
<https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022>
- Required for Rust compilation on Windows.
- During install, select:
- "Desktop development with C++"
- ~3-5 GB disk space.
### Optional but Recommended
1. **Windows Terminal** -- <https://aka.ms/terminal>
- Much better than cmd.exe.
2. **VS Code** -- <https://code.visualstudio.com>
- Install extensions: Go, Rust Analyzer, Tauri.
---
## Claude Code Can Handle These
### Go Dependencies (server)
```bash
go mod init && go get && go mod tidy
```
All Go libraries are installed via `go get`.
### NPM Packages (client)
```bash
cd Client/tauri-client && npm install
```
Vitest, Playwright, TypeScript, Vite, Tauri CLI.
### NSIS (installer builder)
```bash
winget install NSIS.NSIS
```
### Development tools
```bash
# Go tools
go install github.com/air-verse/air@latest
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
# Playwright browsers
npx playwright install --with-deps
```
---
## Quick Check -- Run These After Installing
```bash
git --version # Git
go version # Go
node --version # Node.js (v20+)
rustc --version # Rust
cargo --version # Cargo (comes with Rust)
```
If all five print version numbers, you're ready.
---
## Project Build Commands
### Server (Go)
```bash
cd Server
go build -o chatserver.exe -ldflags "-s -w" .
go test ./...
```
### Client (Tauri v2)
```bash
cd Client/tauri-client
npm install # first time
npm run tauri dev # dev mode (hot reload)
npm run tauri build # release build
npm test # run all tests
npm run test:coverage # coverage report
```
---
## Summary
| Tool | You Install | Claude Code Installs |
| ---- | :---------: | :------------------: |
| Git | X | |
| Go | X | |
| Node.js | X | |
| Rust | X | |
| VS Build Tools | X | |
| Go libraries | | X |
| NPM packages | | X |
| NSIS | | X (via winget) |
| Linters and dev tools | | X |
| Playwright browsers | | X |
+211
View File
@@ -0,0 +1,211 @@
package admin_test
import (
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/owncord/server/admin"
"github.com/owncord/server/auth"
"github.com/owncord/server/updater"
)
// ─── NewHandler ───────────────────────────────────────────────────────────────
// TestNewHandler_ReturnsNonNilHandler verifies that NewHandler returns a non-nil
// http.Handler with all dependencies wired.
func TestNewHandler_ReturnsNonNilHandler(t *testing.T) {
database := openAdminTestDB(t)
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil)
if h == nil {
t.Fatal("NewHandler returned nil handler")
}
}
// TestNewHandler_ServesStaticRoot verifies that GET / on the returned handler
// responds with 200 and HTML content (the embedded admin SPA).
func TestNewHandler_ServesStaticRoot(t *testing.T) {
database := openAdminTestDB(t)
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil)
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("GET / status = %d, want 200", w.Code)
}
ct := w.Header().Get("Content-Type")
if ct == "" {
t.Error("Content-Type header missing on / response")
}
}
// TestNewHandler_SetsCSPOnRoot verifies that the root path response includes a
// Content-Security-Policy header allowing inline scripts and styles.
func TestNewHandler_SetsCSPOnRoot(t *testing.T) {
database := openAdminTestDB(t)
h := admin.NewHandler(database, "1.0.0", nil, nil)
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
csp := w.Header().Get("Content-Security-Policy")
if csp == "" {
t.Error("Content-Security-Policy header missing on / response")
}
}
// TestNewHandler_APIRoutesMounted verifies that /api/* routes are reachable
// through the NewHandler-returned handler (setup/status endpoint is unauthenticated).
func TestNewHandler_APIRoutesMounted(t *testing.T) {
database := openAdminTestDB(t)
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil)
req := httptest.NewRequest(http.MethodGet, "/api/setup/status", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
// 200 because no users exist yet — setup is needed
if w.Code != http.StatusOK {
t.Errorf("GET /api/setup/status status = %d, want 200", w.Code)
}
}
// TestNewHandler_AuthProtectedRoute verifies that authenticated routes under
// /api require a valid token.
func TestNewHandler_AuthProtectedRoute(t *testing.T) {
database := openAdminTestDB(t)
h := admin.NewHandler(database, "1.0.0", &mockHub{}, nil)
// /api/stats requires authentication
req := httptest.NewRequest(http.MethodGet, "/api/stats", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("unauthenticated /api/stats status = %d, want 401", w.Code)
}
}
// TestNewHandler_WithUpdater verifies that NewHandler works correctly when an
// updater is provided.
func TestNewHandler_WithUpdater(t *testing.T) {
database := openAdminTestDB(t)
u := updater.NewUpdater("1.0.0", "", "J3vb", "OwnCord")
h := admin.NewHandler(database, "1.0.0", &mockHub{}, u)
if h == nil {
t.Fatal("NewHandler with updater returned nil handler")
}
}
// ─── Handler (deprecated) ────────────────────────────────────────────────────
// TestHandler_ReturnsNonNil verifies the deprecated Handler() function returns
// a non-nil http.Handler (it serves the embedded static files).
func TestHandler_ReturnsNonNil(t *testing.T) {
h := admin.Handler()
if h == nil {
t.Fatal("Handler() returned nil")
}
}
// TestHandler_ServesEmbeddedFiles verifies that the deprecated Handler() serves
// a response (the embedded static FS) without panicking.
func TestHandler_ServesEmbeddedFiles(t *testing.T) {
h := admin.Handler()
req := httptest.NewRequest(http.MethodGet, "/index.html", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
// http.FileServer returns 200 for a found file or 301/404 for others;
// the important thing is it doesn't panic and returns a valid HTTP status.
if w.Code == 0 {
t.Error("Handler() response has zero status code")
}
}
// ─── ownerOnlyMiddleware (tested via API endpoints that use it) ───────────────
// TestOwnerOnlyMiddleware_OwnerAllowed verifies that a user with Owner role
// (position == 100) can reach backup endpoints.
func TestOwnerOnlyMiddleware_OwnerAllowed(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
// createAdminUser creates an Owner-role user (role_id=1, position=100)
ownerToken := createAdminUser(t, database)
// Use a temp dir so the backup handler can create data/backups without
// polluting the repo working directory.
tmpDir := t.TempDir()
origDir, err := os.Getwd()
if err != nil {
t.Fatalf("os.Getwd: %v", err)
}
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("os.Chdir: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(origDir) })
w := doRequest(t, handler, http.MethodPost, "/backup", ownerToken, nil)
// Owner should pass ownerOnlyMiddleware and reach handleBackup.
// handleBackup itself may return 200 (success) or 500 (if BackupTo fails in
// test environment), but it must not return 403 (forbidden).
if w.Code == http.StatusForbidden {
t.Errorf("Owner user got 403 Forbidden from backup endpoint — ownerOnlyMiddleware incorrectly blocked owner")
}
}
// TestOwnerOnlyMiddleware_AdminDenied verifies that a user with Admin role
// (position < 100) cannot reach owner-only endpoints.
func TestOwnerOnlyMiddleware_AdminDenied(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
// Create admin user (role_id=2, position=80)
adminUID, _ := database.CreateUser("middlewareadmin", "hash", 2)
token := "mw-admin-token"
_, _ = database.CreateSession(adminUID, auth.HashToken(token), "test", "127.0.0.1")
w := doRequest(t, handler, http.MethodPost, "/backup", token, nil)
if w.Code != http.StatusForbidden {
t.Errorf("Admin user status = %d, want 403", w.Code)
}
}
// TestOwnerOnlyMiddleware_MemberDenied verifies that a Member-role user cannot
// reach owner-only endpoints.
func TestOwnerOnlyMiddleware_MemberDenied(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
memberToken := createMemberUser(t, database)
// Members don't have ADMINISTRATOR bit so they get 403 from adminAuthMiddleware
// before reaching ownerOnlyMiddleware — result is still non-200.
w := doRequest(t, handler, http.MethodPost, "/backup", memberToken, nil)
if w.Code == http.StatusOK {
t.Error("Member user got 200 from owner-only backup endpoint")
}
}
// TestOwnerOnlyMiddleware_Unauthenticated verifies that a missing token is
// rejected before reaching ownerOnlyMiddleware.
func TestOwnerOnlyMiddleware_Unauthenticated(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
w := doRequest(t, handler, http.MethodPost, "/backup", "", nil)
if w.Code != http.StatusUnauthorized {
t.Errorf("unauthenticated backup request status = %d, want 401", w.Code)
}
}
+495
View File
@@ -0,0 +1,495 @@
package admin_test
// Targeted tests to boost coverage to 80%+ by exercising uncovered branches.
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/owncord/server/admin"
)
// ─── handlePatchUser — self-modification guard ─────────────────────────────
// TestAdminAPI_PatchUser_CannotModifySelf verifies that an admin cannot patch
// their own account via the admin panel.
func TestAdminAPI_PatchUser_CannotModifySelf(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
// The admin user created by createAdminUser has id=1. We try to patch id=1.
body := map[string]any{"banned": true}
w := doRequest(t, handler, http.MethodPatch, "/users/1", token, body)
if w.Code != http.StatusBadRequest {
t.Errorf("self-modification status = %d, want 400; body: %s", w.Code, w.Body.String())
}
}
// TestAdminAPI_PatchUser_UnbanUser verifies that setting banned=false on a
// banned user unbans them and returns 200.
func TestAdminAPI_PatchUser_UnbanUser(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
// Create and ban a target user first.
targetUID, _ := database.CreateUser("unbanme", "hash", 3)
_ = database.BanUser(targetUID, "test ban", nil)
body := map[string]any{"banned": false}
w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, body)
if w.Code != http.StatusOK {
t.Errorf("unban status = %d, want 200; body: %s", w.Code, w.Body.String())
}
// Verify the user is now unbanned.
user, _ := database.GetUserByID(targetUID)
if user.Banned {
t.Error("user is still banned after unban request")
}
}
// TestAdminAPI_PatchUser_InvalidBody verifies that a non-JSON body returns 400.
func TestAdminAPI_PatchUser_InvalidBody(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
token := createAdminUser(t, database)
targetUID, _ := database.CreateUser("invalidbody", "hash", 3)
req := httptest.NewRequest(http.MethodPatch, "/users/"+itoa(targetUID), bytes.NewReader([]byte("not-json")))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("invalid body status = %d, want 400", w.Code)
}
}
// ─── handleCreateChannel — default type ───────────────────────────────────
// TestAdminAPI_CreateChannel_DefaultsTypeToText verifies that omitting the
// "type" field causes the channel to be created with type "text".
func TestAdminAPI_CreateChannel_DefaultsTypeToText(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
token := createAdminUser(t, database)
body := map[string]any{
"name": "no-type-channel",
// "type" intentionally omitted — should default to "text"
}
w := doRequest(t, handler, http.MethodPost, "/channels", token, body)
if w.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body: %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp["type"] != "text" {
t.Errorf("type = %q, want text", resp["type"])
}
}
// TestAdminAPI_CreateChannel_InvalidBody verifies that a malformed body returns 400.
func TestAdminAPI_CreateChannel_InvalidBody(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
token := createAdminUser(t, database)
req := httptest.NewRequest(http.MethodPost, "/channels", bytes.NewReader([]byte("not-json")))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("invalid body status = %d, want 400", w.Code)
}
}
// ─── handleForceLogout — invalid ID ──────────────────────────────────────
// TestAdminAPI_ForceLogout_InvalidID verifies that a non-numeric user ID in
// the URL returns 400.
func TestAdminAPI_ForceLogout_InvalidID(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodDelete, "/users/notanumber/sessions", token, nil)
if w.Code != http.StatusBadRequest {
t.Errorf("invalid ID status = %d, want 400", w.Code)
}
}
// ─── handlePatchChannel — invalid body ────────────────────────────────────
// TestAdminAPI_PatchChannel_InvalidBody verifies that a malformed PATCH body
// returns 400.
func TestAdminAPI_PatchChannel_InvalidBody(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
token := createAdminUser(t, database)
chID, _ := database.AdminCreateChannel("malformed", "text", "", "", 0)
req := httptest.NewRequest(http.MethodPatch, "/channels/"+itoa(chID), bytes.NewReader([]byte("not-json")))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("invalid body status = %d, want 400", w.Code)
}
}
// ─── queryInt — cap at 500 ────────────────────────────────────────────────
// TestAdminAPI_ListUsers_CapLargeLimit verifies that a limit > 500 is capped
// to 500 (testing the queryInt cap branch).
func TestAdminAPI_ListUsers_CapLargeLimit(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
token := createAdminUser(t, database)
// Passing limit=9999 should be silently capped to 500.
w := doRequest(t, handler, http.MethodGet, "/users?limit=9999", token, nil)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
}
// ─── handleCheckUpdate — nil updater ──────────────────────────────────────
// TestAdminAPI_CheckUpdate_NilUpdater verifies that GET /updates returns 503
// when no updater is configured.
func TestAdminAPI_CheckUpdate_NilUpdater(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/updates", token, nil)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("nil updater GET /updates status = %d, want 503", w.Code)
}
}
// ─── handleDeleteChannel — invalid ID ────────────────────────────────────
// TestAdminAPI_DeleteChannel_InvalidID verifies that a non-numeric channel ID
// returns 400.
func TestAdminAPI_DeleteChannel_InvalidID(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodDelete, "/channels/notanumber", token, nil)
if w.Code != http.StatusBadRequest {
t.Errorf("invalid ID status = %d, want 400", w.Code)
}
}
// ─── handlePatchChannel — invalid ID ─────────────────────────────────────
// TestAdminAPI_PatchChannel_InvalidID verifies that a non-numeric channel ID
// returns 400.
func TestAdminAPI_PatchChannel_InvalidID(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
token := createAdminUser(t, database)
body := map[string]any{"name": "x"}
w := doRequest(t, handler, http.MethodPatch, "/channels/abc", token, body)
if w.Code != http.StatusBadRequest {
t.Errorf("invalid ID status = %d, want 400", w.Code)
}
}
// ─── handleGetAuditLog — pagination ───────────────────────────────────────
// TestAdminAPI_AuditLog_Pagination verifies that limit and offset params work.
func TestAdminAPI_AuditLog_Pagination(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
token := createAdminUser(t, database)
// Create several audit entries.
uid, _ := database.CreateUser("auditpager", "hash", 1)
for i := 0; i < 5; i++ {
_ = database.LogAudit(uid, "TEST", "test", int64(i), "")
}
// Fetch page 2 with limit=2, offset=2 — should return 2 entries.
w := doRequest(t, handler, http.MethodGet, "/audit-log?limit=2&offset=2", token, nil)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
var entries []any
if err := json.Unmarshal(w.Body.Bytes(), &entries); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(entries) != 2 {
t.Errorf("expected 2 entries with limit=2 offset=2, got %d", len(entries))
}
}
// ─── handleGetStats — nil hub ─────────────────────────────────────────────
// TestAdminAPI_Stats_NilHub verifies that GET /stats works correctly when
// hub is nil (the OnlineCount field defaults to 0).
func TestAdminAPI_Stats_NilHub(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
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]any
if err := json.Unmarshal(w.Body.Bytes(), &stats); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// online_count should be 0 when hub is nil
if v, ok := stats["online_count"]; ok {
if v.(float64) != 0 {
t.Errorf("online_count = %v, want 0 (nil hub)", v)
}
}
}
// ─── queryInt — invalid string value ──────────────────────────────────────
// TestAdminAPI_AuditLog_InvalidLimitParam verifies that a non-numeric limit
// falls back to the default (testing the queryInt error-fallback branch).
func TestAdminAPI_AuditLog_InvalidLimitParam(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/audit-log?limit=notanumber", token, nil)
if w.Code != http.StatusOK {
t.Errorf("invalid limit status = %d, want 200", w.Code)
}
}
// TestAdminAPI_ListUsers_InvalidLimitParam verifies that limit=0 falls back to
// the default (testing the n < 1 branch of queryInt).
func TestAdminAPI_ListUsers_InvalidLimitParam(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
token := createAdminUser(t, database)
// limit=0 triggers the n < 1 fallback in queryInt
w := doRequest(t, handler, http.MethodGet, "/users?limit=0", token, nil)
if w.Code != http.StatusOK {
t.Errorf("limit=0 status = %d, want 200", w.Code)
}
}
// ─── PatchUser — BanReason nil path ────────────────────────────────────────
// TestAdminAPI_PatchUser_BanWithoutReason verifies that banning a user without
// providing ban_reason is accepted (reason defaults to empty string).
func TestAdminAPI_PatchUser_BanWithoutReason(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
targetUID, _ := database.CreateUser("banwithout", "hash", 3)
// No ban_reason in body — the nil check in handlePatchUser uses empty string.
body := map[string]any{"banned": true}
w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, body)
if w.Code != http.StatusOK {
t.Errorf("ban without reason status = %d, want 200; body: %s", w.Code, w.Body.String())
}
}
// ─── PatchUser — role change broadcasts ────────────────────────────────────
// TestAdminAPI_PatchUser_RoleChangeBroadcast verifies that changing a user's
// role results in a BroadcastMemberUpdate call via the hub.
func TestAdminAPI_PatchUser_RoleChangeBroadcast(t *testing.T) {
database := openAdminTestDB(t)
hub := &mockHub{}
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil)
token := createAdminUser(t, database)
targetUID, _ := database.CreateUser("rolebroadcast", "hash", 3)
body := map[string]any{"role_id": float64(2)}
w := doRequest(t, handler, http.MethodPatch, "/users/"+itoa(targetUID), token, body)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
if len(hub.memberUpdates) == 0 {
t.Error("BroadcastMemberUpdate not called after role change")
}
}
// ─── Setup endpoints ──────────────────────────────────────────────────────
// TestAdminAPI_SetupStatus_NeedsSetup verifies that GET /setup/status returns
// needs_setup=true when the database has no users.
func TestAdminAPI_SetupStatus_NeedsSetup(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
w := doRequest(t, handler, http.MethodGet, "/setup/status", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
var resp map[string]bool
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !resp["needs_setup"] {
t.Error("expected needs_setup=true when no users exist")
}
}
// TestAdminAPI_SetupStatus_AlreadySetup verifies needs_setup=false when users exist.
func TestAdminAPI_SetupStatus_AlreadySetup(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
_, _ = database.CreateUser("existing", "hash", 1)
w := doRequest(t, handler, http.MethodGet, "/setup/status", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
var resp map[string]bool
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp["needs_setup"] {
t.Error("expected needs_setup=false when users exist")
}
}
// TestAdminAPI_Setup_Success verifies the full setup flow creates an owner,
// session, channel, and invite.
func TestAdminAPI_Setup_Success(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
body := map[string]string{
"username": "owner",
"password": "Str0ngP@ssw0rd!",
}
w := doRequest(t, handler, http.MethodPost, "/setup", "", body)
if w.Code != http.StatusCreated {
t.Fatalf("setup status = %d, want 201; body: %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp["token"] == nil || resp["token"] == "" {
t.Error("expected non-empty token in setup response")
}
if resp["invite_code"] == nil || resp["invite_code"] == "" {
t.Error("expected non-empty invite_code in setup response")
}
if resp["username"] != "owner" {
t.Errorf("username = %v, want owner", resp["username"])
}
}
// TestAdminAPI_Setup_AlreadyCompleted verifies that POST /setup returns 403
// when users already exist.
func TestAdminAPI_Setup_AlreadyCompleted(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
_, _ = database.CreateUser("existing", "hash", 1)
body := map[string]string{
"username": "hacker",
"password": "Str0ngP@ssw0rd!",
}
w := doRequest(t, handler, http.MethodPost, "/setup", "", body)
if w.Code != http.StatusForbidden {
t.Errorf("setup after completion status = %d, want 403", w.Code)
}
}
// TestAdminAPI_Setup_MissingFields verifies that POST /setup with empty
// username or password returns 400.
func TestAdminAPI_Setup_MissingFields(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
body := map[string]string{
"username": "",
"password": "",
}
w := doRequest(t, handler, http.MethodPost, "/setup", "", body)
if w.Code != http.StatusBadRequest {
t.Errorf("empty fields status = %d, want 400; body: %s", w.Code, w.Body.String())
}
}
// TestAdminAPI_Setup_WeakPassword verifies that a weak password is rejected.
func TestAdminAPI_Setup_WeakPassword(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
body := map[string]string{
"username": "owner",
"password": "weak",
}
w := doRequest(t, handler, http.MethodPost, "/setup", "", body)
if w.Code != http.StatusBadRequest {
t.Errorf("weak password status = %d, want 400; body: %s", w.Code, w.Body.String())
}
}
// TestAdminAPI_Setup_InvalidBody verifies that a non-JSON body returns 400.
func TestAdminAPI_Setup_InvalidBody(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
req := httptest.NewRequest(http.MethodPost, "/setup", bytes.NewReader([]byte("not-json")))
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("invalid body status = %d, want 400", w.Code)
}
}
+368
View File
@@ -0,0 +1,368 @@
package admin_test
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"testing"
"github.com/owncord/server/admin"
"github.com/owncord/server/auth"
)
// chdirTemp changes the working directory to a fresh temp directory for the
// duration of t and restores the original on cleanup. Backup handlers use
// relative paths ("data/backups") that are resolved against cwd.
func chdirTemp(t *testing.T) string {
t.Helper()
tmpDir := t.TempDir()
origDir, err := os.Getwd()
if err != nil {
t.Fatalf("os.Getwd: %v", err)
}
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("os.Chdir(%q): %v", tmpDir, err)
}
t.Cleanup(func() { _ = os.Chdir(origDir) })
return tmpDir
}
// ─── POST /backup ─────────────────────────────────────────────────────────────
// TestHandleBackup_Success verifies that the backup endpoint creates a backup
// file and returns 200 with path and created fields.
func TestHandleBackup_Success(t *testing.T) {
tmpDir := chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/backup", token, nil)
if w.Code != http.StatusOK {
t.Fatalf("POST /backup status = %d, want 200; body: %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if resp["path"] == "" {
t.Error("response missing 'path' field")
}
if resp["created"] == "" {
t.Error("response missing 'created' field")
}
// Verify the backup file actually exists on disk.
backupDir := filepath.Join(tmpDir, "data", "backups")
entries, err := os.ReadDir(backupDir)
if err != nil {
t.Fatalf("ReadDir(%q): %v", backupDir, err)
}
if len(entries) == 0 {
t.Error("no backup files found after successful backup")
}
}
// TestHandleBackup_RequiresOwner verifies that admin-role (not owner) receives 403.
func TestHandleBackup_RequiresOwner(t *testing.T) {
_ = chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
adminUID, _ := database.CreateUser("backupadmin", "hash", 2)
token := "backup-admin-token"
_, _ = database.CreateSession(adminUID, auth.HashToken(token), "test", "127.0.0.1")
w := doRequest(t, handler, http.MethodPost, "/backup", token, nil)
if w.Code != http.StatusForbidden {
t.Errorf("admin user on /backup status = %d, want 403", w.Code)
}
}
// ─── GET /backups ─────────────────────────────────────────────────────────────
// TestHandleListBackups_EmptyWhenNoDirExists verifies that the endpoint returns
// an empty JSON array when the backups directory does not exist.
func TestHandleListBackups_EmptyWhenNoDirExists(t *testing.T) {
_ = chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/backups", token, nil)
if w.Code != http.StatusOK {
t.Fatalf("GET /backups status = %d, want 200; body: %s", w.Code, w.Body.String())
}
var backups []any
if err := json.Unmarshal(w.Body.Bytes(), &backups); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(backups) != 0 {
t.Errorf("expected 0 backups when dir missing, got %d", len(backups))
}
}
// TestHandleListBackups_ReturnsCreatedBackup verifies that a backup created via
// POST /backup appears in GET /backups.
func TestHandleListBackups_ReturnsCreatedBackup(t *testing.T) {
_ = chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
// Create a backup first.
wBackup := doRequest(t, handler, http.MethodPost, "/backup", token, nil)
if wBackup.Code != http.StatusOK {
t.Fatalf("POST /backup failed: %d %s", wBackup.Code, wBackup.Body.String())
}
// Now list them.
w := doRequest(t, handler, http.MethodGet, "/backups", token, nil)
if w.Code != http.StatusOK {
t.Fatalf("GET /backups status = %d, want 200; body: %s", w.Code, w.Body.String())
}
var backups []map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &backups); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(backups) == 0 {
t.Fatal("expected at least 1 backup in list after POST /backup")
}
b := backups[0]
if b["name"] == "" {
t.Error("backup entry missing 'name'")
}
if b["size"] == nil {
t.Error("backup entry missing 'size'")
}
if b["date"] == "" {
t.Error("backup entry missing 'date'")
}
}
// ─── DELETE /backups/{name} ───────────────────────────────────────────────────
// TestHandleDeleteBackup_Success verifies that an existing backup file is
// deleted and 204 is returned.
func TestHandleDeleteBackup_Success(t *testing.T) {
tmpDir := chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
// Create a real backup file to delete.
backupDir := filepath.Join(tmpDir, "data", "backups")
if err := os.MkdirAll(backupDir, 0o750); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
backupName := "chatserver_20240101_120000.db"
backupPath := filepath.Join(backupDir, backupName)
if err := os.WriteFile(backupPath, []byte("fake backup"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
w := doRequest(t, handler, http.MethodDelete, "/backups/"+backupName, token, nil)
if w.Code != http.StatusNoContent {
t.Errorf("DELETE /backups/%s status = %d, want 204; body: %s", backupName, w.Code, w.Body.String())
}
// Verify the file is gone.
if _, err := os.Stat(backupPath); !os.IsNotExist(err) {
t.Error("backup file still exists after delete")
}
}
// TestHandleDeleteBackup_NotFound verifies that deleting a nonexistent backup
// returns 404.
func TestHandleDeleteBackup_NotFound(t *testing.T) {
_ = chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodDelete, "/backups/nonexistent.db", token, nil)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
}
// TestHandleDeleteBackup_InvalidNameTraversal verifies that path traversal
// names are rejected with 400.
func TestHandleDeleteBackup_InvalidNameTraversal(t *testing.T) {
_ = chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
// The chi router URL-decodes the path parameter, so ".." arrives decoded.
// The handler checks for ".." and returns 400.
w := doRequest(t, handler, http.MethodDelete, "/backups/..evil.db", token, nil)
// Either 400 (blocked) or 404 (file not found) is acceptable.
// What must NOT happen is 204 (successful delete).
if w.Code == http.StatusNoContent {
t.Error("path traversal name resulted in 204 — traversal not blocked")
}
}
// TestHandleDeleteBackup_RequiresOwner verifies that admin-role is denied.
func TestHandleDeleteBackup_RequiresOwner(t *testing.T) {
tmpDir := chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
adminUID, _ := database.CreateUser("deladmin", "hash", 2)
token := "del-admin-token"
_, _ = database.CreateSession(adminUID, auth.HashToken(token), "test", "127.0.0.1")
// Create the file so path validation doesn't return 404 before the 403.
backupDir := filepath.Join(tmpDir, "data", "backups")
_ = os.MkdirAll(backupDir, 0o750)
_ = os.WriteFile(filepath.Join(backupDir, "test.db"), []byte("x"), 0o644)
w := doRequest(t, handler, http.MethodDelete, "/backups/test.db", token, nil)
if w.Code != http.StatusForbidden {
t.Errorf("admin user on delete-backup status = %d, want 403", w.Code)
}
}
// ─── POST /backups/{name}/restore ─────────────────────────────────────────────
// TestHandleRestoreBackup_Success verifies that a restore operation returns 200
// with the expected message and backup name.
func TestHandleRestoreBackup_Success(t *testing.T) {
tmpDir := chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
// Set up backup and data directories.
backupDir := filepath.Join(tmpDir, "data", "backups")
dataDir := filepath.Join(tmpDir, "data")
if err := os.MkdirAll(backupDir, 0o750); err != nil {
t.Fatalf("MkdirAll backups: %v", err)
}
if err := os.MkdirAll(dataDir, 0o750); err != nil {
t.Fatalf("MkdirAll data: %v", err)
}
// Write content as the "backup" to restore from.
backupName := "chatserver_20240101_120000.db"
backupPath := filepath.Join(backupDir, backupName)
fakeContent := []byte("fake sqlite db content")
if err := os.WriteFile(backupPath, fakeContent, 0o644); err != nil {
t.Fatalf("WriteFile backup: %v", err)
}
w := doRequest(t, handler, http.MethodPost, "/backups/"+backupName+"/restore", token, nil)
if w.Code != http.StatusOK {
t.Fatalf("POST /backups/%s/restore status = %d, want 200; body: %s", backupName, w.Code, w.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp["message"] == "" {
t.Error("response missing 'message' field")
}
if resp["backup"] != backupName {
t.Errorf("backup = %q, want %q", resp["backup"], backupName)
}
}
// TestHandleRestoreBackup_NotFound verifies that restoring a missing backup
// returns 404.
func TestHandleRestoreBackup_NotFound(t *testing.T) {
_ = chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/backups/missing.db/restore", token, nil)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
}
// TestHandleRestoreBackup_InvalidName verifies that a name containing ".." is
// rejected with 400.
func TestHandleRestoreBackup_InvalidName(t *testing.T) {
_ = chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/backups/..evil.db/restore", token, nil)
// Must not return 200 OK.
if w.Code == http.StatusOK {
t.Error("path-traversal restore name returned 200 — traversal not blocked")
}
}
// TestHandleListBackups_ErrorReadingDir verifies that if the backups path
// exists but is a file (not a directory), the endpoint returns 500.
func TestHandleListBackups_ErrorReadingDir(t *testing.T) {
tmpDir := chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
token := createAdminUser(t, database)
// Create data/ directory but make "backups" a file instead of a directory.
dataDir := filepath.Join(tmpDir, "data")
if err := os.MkdirAll(dataDir, 0o750); err != nil {
t.Fatalf("MkdirAll data: %v", err)
}
backupsFile := filepath.Join(dataDir, "backups")
if err := os.WriteFile(backupsFile, []byte("not a directory"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
w := doRequest(t, handler, http.MethodGet, "/backups", token, nil)
// os.ReadDir on a file (not a directory) fails with a non-IsNotExist error
// on most platforms, but the exact behavior is platform-dependent.
// On Windows, ReadDir on a file returns an error that is NOT os.IsNotExist.
// So we expect either 500 or (in edge cases) 200 with empty list.
if w.Code != http.StatusInternalServerError && w.Code != http.StatusOK {
t.Errorf("status = %d, want 500 or 200 (platform dependent)", w.Code)
}
}
// TestHandleRestoreBackup_RequiresOwner verifies that admin-role is denied.
func TestHandleRestoreBackup_RequiresOwner(t *testing.T) {
tmpDir := chdirTemp(t)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil)
adminUID, _ := database.CreateUser("restoreadmin", "hash", 2)
token := "restore-admin-token"
_, _ = database.CreateSession(adminUID, auth.HashToken(token), "test", "127.0.0.1")
// Create files so path checks pass before auth check.
backupDir := filepath.Join(tmpDir, "data", "backups")
dataDir := filepath.Join(tmpDir, "data")
_ = os.MkdirAll(backupDir, 0o750)
_ = os.MkdirAll(dataDir, 0o750)
_ = os.WriteFile(filepath.Join(backupDir, "test.db"), []byte("x"), 0o644)
w := doRequest(t, handler, http.MethodPost, "/backups/test.db/restore", token, nil)
if w.Code != http.StatusForbidden {
t.Errorf("admin user on restore status = %d, want 403", w.Code)
}
}
+75
View File
@@ -0,0 +1,75 @@
package admin_test
// Additional tests to increase branch coverage on adminAuthMiddleware,
// ownerOnlyMiddleware, and related helpers.
import (
"net/http"
"testing"
"time"
"github.com/owncord/server/admin"
"github.com/owncord/server/auth"
)
// ─── adminAuthMiddleware edge cases ──────────────────────────────────────────
// TestAdminAuthMiddleware_ExpiredSession verifies that a valid token whose
// session has expired is rejected with 401.
func TestAdminAuthMiddleware_ExpiredSession(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
// Create a user and session, then manually expire the session by setting
// expires_at to a past timestamp via the exported Exec helper.
uid, err := database.CreateUser("expireduser", "$2a$12$x", 1)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
token := "expired-session-token"
tokenHash := auth.HashToken(token)
if _, err := database.CreateSession(uid, tokenHash, "test", "127.0.0.1"); err != nil {
t.Fatalf("CreateSession: %v", err)
}
// Set expires_at to yesterday so the session is treated as expired.
pastTime := time.Now().Add(-24 * time.Hour).UTC().Format("2006-01-02T15:04:05Z")
if _, err := database.Exec(
`UPDATE sessions SET expires_at = ? WHERE token = ?`,
pastTime, tokenHash,
); err != nil {
t.Fatalf("UPDATE sessions expires_at: %v", err)
}
w := doRequest(t, handler, http.MethodGet, "/stats", token, nil)
if w.Code != http.StatusUnauthorized {
t.Errorf("expired session status = %d, want 401; body: %s", w.Code, w.Body.String())
}
}
// TestAdminAuthMiddleware_MissingBearer verifies that a request with no
// Authorization header returns 401.
func TestAdminAuthMiddleware_MissingBearer(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
w := doRequest(t, handler, http.MethodGet, "/stats", "", nil)
if w.Code != http.StatusUnauthorized {
t.Errorf("missing bearer status = %d, want 401", w.Code)
}
}
// TestAdminAuthMiddleware_InvalidToken verifies that a token not in the
// sessions table returns 401.
func TestAdminAuthMiddleware_InvalidToken(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
w := doRequest(t, handler, http.MethodGet, "/stats", "completely-invalid-token", nil)
if w.Code != http.StatusUnauthorized {
t.Errorf("invalid token status = %d, want 401", w.Code)
}
}
+480
View File
@@ -0,0 +1,480 @@
// Package admin whitebox tests — uses package admin (not admin_test) to access
// unexported functions like spawnDetached and ownerOnlyMiddleware.
package admin
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"testing/fstest"
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
)
// openWhiteboxTestDB opens an in-memory SQLite database for whitebox tests.
func openWhiteboxTestDB(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() })
schema := []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, 1635, 40, 1);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password TEXT NOT NULL,
avatar TEXT,
role_id INTEGER NOT NULL DEFAULT 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 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);
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'text',
category TEXT,
topic TEXT,
position INTEGER NOT NULL DEFAULT 0,
slow_mode INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
voice_max_users INTEGER NOT NULL DEFAULT 0,
voice_quality TEXT,
mixing_threshold INTEGER,
voice_max_video INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS 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')),
reply_to INTEGER REFERENCES messages(id) ON DELETE SET NULL,
edited_at TEXT
);
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
);
`)
migrFS := fstest.MapFS{
"001_schema.sql": {Data: schema},
}
if err := db.MigrateFS(database, migrFS); err != nil {
t.Fatalf("MigrateFS: %v", err)
}
return database
}
// ─── ownerOnlyMiddleware whitebox tests ──────────────────────────────────────
// TestOwnerOnlyMiddleware_NoUserInContext verifies that ownerOnlyMiddleware
// returns 401 when there is no user stored in the request context.
func TestOwnerOnlyMiddleware_NoUserInContext(t *testing.T) {
database := openWhiteboxTestDB(t)
reached := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
w.WriteHeader(http.StatusOK)
})
handler := ownerOnlyMiddleware(database, next)
// Request with NO user in context — simulates a call bypassing adminAuthMiddleware.
req := httptest.NewRequest(http.MethodPost, "/backup", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if reached {
t.Error("next handler was reached despite missing user in context")
}
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
// TestOwnerOnlyMiddleware_RoleNotFound verifies that ownerOnlyMiddleware
// returns 403 when the user's role_id does not exist in the database.
func TestOwnerOnlyMiddleware_RoleNotFound(t *testing.T) {
database := openWhiteboxTestDB(t)
// Create a user initially with a valid role, then mutate role_id to a
// nonexistent value (disabling FK checks temporarily so SQLite allows it).
uid, err := database.CreateUser("orphanuser", "$2a$12$x", 1)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
user, err := database.GetUserByID(uid)
if err != nil || user == nil {
t.Fatalf("GetUserByID: %v", err)
}
// Disable FK enforcement, update role_id, re-enable.
if _, err := database.Exec(`PRAGMA foreign_keys=OFF`); err != nil {
t.Fatalf("disable FK: %v", err)
}
if _, err := database.Exec(`UPDATE users SET role_id = 9999 WHERE id = ?`, uid); err != nil {
t.Fatalf("UPDATE role_id: %v", err)
}
if _, err := database.Exec(`PRAGMA foreign_keys=ON`); err != nil {
t.Fatalf("re-enable FK: %v", err)
}
user.RoleID = 9999 // mirror the DB value in our in-memory struct
reached := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
w.WriteHeader(http.StatusOK)
})
handler := ownerOnlyMiddleware(database, next)
// Inject user into context as adminAuthMiddleware would.
ctx := context.WithValue(context.Background(), adminUserKey, user)
req := httptest.NewRequest(http.MethodPost, "/backup", nil).WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if reached {
t.Error("next handler was reached despite missing role")
}
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403 (role not found)", w.Code)
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp["error"] != "FORBIDDEN" {
t.Errorf("error = %q, want FORBIDDEN", resp["error"])
}
}
// TestOwnerOnlyMiddleware_OwnerPassesThrough verifies that a user with the
// Owner role (position == 100) reaches the next handler.
func TestOwnerOnlyMiddleware_OwnerPassesThrough(t *testing.T) {
database := openWhiteboxTestDB(t)
uid, err := database.CreateUser("ownerpass", "$2a$12$x", 1)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
user, err := database.GetUserByID(uid)
if err != nil || user == nil {
t.Fatalf("GetUserByID: %v", err)
}
reached := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
w.WriteHeader(http.StatusOK)
})
handler := ownerOnlyMiddleware(database, next)
ctx := context.WithValue(context.Background(), adminUserKey, user)
req := httptest.NewRequest(http.MethodPost, "/backup", nil).WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if !reached {
t.Error("next handler was NOT reached for owner role")
}
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
// ─── adminAuthMiddleware whitebox test — user with unknown role_id ────────────
// TestAdminAuthMiddleware_RoleNotFound verifies that a session for a user whose
// role_id has been set to a nonexistent value returns 401.
func TestAdminAuthMiddleware_RoleNotFound(t *testing.T) {
database := openWhiteboxTestDB(t)
handler := NewAdminAPI(database, "1.0.0", nil, nil)
uid, err := database.CreateUser("noroleuser", "$2a$12$x", 1)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
token := "norole-token"
if _, err := database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1"); err != nil {
t.Fatalf("CreateSession: %v", err)
}
// Disable FK enforcement, assign a non-existent role_id, re-enable.
if _, err := database.Exec(`PRAGMA foreign_keys=OFF`); err != nil {
t.Fatalf("disable FK: %v", err)
}
if _, err := database.Exec(`UPDATE users SET role_id = 9999 WHERE id = ?`, uid); err != nil {
t.Fatalf("UPDATE role_id: %v", err)
}
if _, err := database.Exec(`PRAGMA foreign_keys=ON`); err != nil {
t.Fatalf("re-enable FK: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/stats", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401 (role not found); body: %s", w.Code, w.Body.String())
}
}
// ─── DB error path tests ─────────────────────────────────────────────────────
// These tests trigger the internal error-return paths in handlers by using a
// closed DB. After database.Close(), all queries fail with an error, allowing
// us to cover the "DB error" branches that are otherwise unreachable with a
// healthy in-memory SQLite.
// TestHandleGetStats_DBError verifies that handleGetStats returns 500 when
// the database query fails.
func TestHandleGetStats_DBError(t *testing.T) {
database := openWhiteboxTestDB(t)
hub := &mockHubWB{}
handler := handleGetStats(database, hub)
// Close the DB to force subsequent queries to fail.
_ = database.Close()
req := httptest.NewRequest(http.MethodGet, "/stats", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("closed DB stats status = %d, want 500", w.Code)
}
}
// TestHandleListChannels_DBError verifies that handleListChannels returns 500
// when the database query fails.
func TestHandleListChannels_DBError(t *testing.T) {
database := openWhiteboxTestDB(t)
handler := handleListChannels(database)
_ = database.Close()
req := httptest.NewRequest(http.MethodGet, "/channels", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("closed DB list channels status = %d, want 500", w.Code)
}
}
// TestHandleGetSettings_DBError verifies that handleGetSettings returns 500
// when the database query fails.
func TestHandleGetSettings_DBError(t *testing.T) {
database := openWhiteboxTestDB(t)
handler := handleGetSettings(database)
_ = database.Close()
req := httptest.NewRequest(http.MethodGet, "/settings", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("closed DB get settings status = %d, want 500", w.Code)
}
}
// TestHandleSetupStatus_DBError verifies that handleSetupStatus returns 500
// when the database query fails.
func TestHandleSetupStatus_DBError(t *testing.T) {
database := openWhiteboxTestDB(t)
handler := handleSetupStatus(database)
_ = database.Close()
req := httptest.NewRequest(http.MethodGet, "/setup/status", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("closed DB setup status = %d, want 500", w.Code)
}
}
// TestHandleGetAuditLog_DBError verifies that handleGetAuditLog returns 500
// when the database query fails.
func TestHandleGetAuditLog_DBError(t *testing.T) {
database := openWhiteboxTestDB(t)
handler := handleGetAuditLog(database)
_ = database.Close()
req := httptest.NewRequest(http.MethodGet, "/audit-log", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("closed DB audit log status = %d, want 500", w.Code)
}
}
// TestHandleListUsers_DBError verifies that handleListUsers returns 500 when
// the database query fails.
func TestHandleListUsers_DBError(t *testing.T) {
database := openWhiteboxTestDB(t)
handler := handleListUsers(database)
_ = database.Close()
req := httptest.NewRequest(http.MethodGet, "/users", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("closed DB list users status = %d, want 500", w.Code)
}
}
// mockHubWB is a local mock for whitebox tests (prevents import cycle with
// the admin_test package's mockHub type).
type mockHubWB struct{}
func (m *mockHubWB) BroadcastServerRestart(reason string, delaySeconds int) {}
func (m *mockHubWB) BroadcastChannelCreate(ch *db.Channel) {}
func (m *mockHubWB) BroadcastChannelUpdate(ch *db.Channel) {}
func (m *mockHubWB) BroadcastChannelDelete(channelID int64) {}
func (m *mockHubWB) BroadcastMemberBan(userID int64) {}
func (m *mockHubWB) BroadcastMemberUpdate(userID int64, roleName string) {}
func (m *mockHubWB) ClientCount() int { return 0 }
// TestSpawnDetached_ValidExecutable verifies that spawnDetached can start a
// real executable (the Go test binary itself) with a flag that causes immediate
// exit. The test only checks that cmd.Start() returns without error; it does
// not wait for the child process to finish.
func TestSpawnDetached_ValidExecutable(t *testing.T) {
// Use the current test binary as the spawned executable so we don't depend
// on any external tool being available.
//
// os.Args[0] is the test binary itself. We pass "-test.run=^$" so the child
// immediately exits with 0 (no tests match). This avoids infinite recursion
// and any visible side effects.
selfExe, err := filepath.Abs(os.Args[0])
if err != nil {
t.Fatalf("abs path of test binary: %v", err)
}
err = spawnDetached(selfExe, []string{"-test.run=^$"})
if err != nil {
t.Errorf("spawnDetached returned error: %v", err)
}
}
// TestSpawnDetached_InvalidExecutable verifies that spawnDetached returns an
// error when the executable path does not exist.
func TestSpawnDetached_InvalidExecutable(t *testing.T) {
err := spawnDetached("/nonexistent/path/to/binary", nil)
if err == nil {
t.Error("expected error when executable does not exist, got nil")
}
}
// TestSpawnDetached_SetsWindowsFlags verifies on Windows that the function does
// not panic when setting SysProcAttr. On non-Windows, the test is a no-op
// confirming the GOOS branch is skipped correctly.
func TestSpawnDetached_SetsWindowsFlags(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("SysProcAttr Windows-specific flag test only runs on Windows")
}
selfExe, err := filepath.Abs(os.Args[0])
if err != nil {
t.Fatalf("abs path: %v", err)
}
// Just verify it doesn't panic when setting the Windows creation flag.
err = spawnDetached(selfExe, []string{"-test.run=^$"})
if err != nil {
t.Errorf("spawnDetached on Windows returned error: %v", err)
}
}
// TestSpawnDetached_CommandConstruction verifies that spawnDetached wires
// stdout/stderr correctly by checking the command's streams are non-nil
// after construction. We do this by examining what exec.Command would produce
// for a real path.
func TestSpawnDetached_CommandConstruction(t *testing.T) {
// We build the command manually the same way spawnDetached does and check
// that Stdout/Stderr are the process's own streams — confirming the
// implementation wires them as documented.
selfExe, err := filepath.Abs(os.Args[0])
if err != nil {
t.Fatalf("abs path: %v", err)
}
cmd := exec.Command(selfExe, "-test.run=^$")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if cmd.Stdout == nil {
t.Error("cmd.Stdout should not be nil")
}
if cmd.Stderr == nil {
t.Error("cmd.Stderr should not be nil")
}
}
+195
View File
@@ -102,3 +102,198 @@ func TestAdminAPI_ApplyUpdate_RequiresOwner(t *testing.T) {
t.Errorf("status = %d, want 403", w.Code)
}
}
// ─── handleApplyUpdate additional paths ──────────────────────────────────────
// TestAdminAPI_ApplyUpdate_NilUpdater verifies that POST /updates/apply returns
// 503 when no updater is configured.
func TestAdminAPI_ApplyUpdate_NilUpdater(t *testing.T) {
database := openAdminTestDB(t)
// nil updater — the endpoint should return 503
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("status = %d, want 503; body: %s", w.Code, w.Body.String())
}
}
// TestAdminAPI_ApplyUpdate_NilUpdater_ErrorCode verifies the error code field
// in the 503 response.
func TestAdminAPI_ApplyUpdate_NilUpdater_ErrorCode(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if resp["error"] != "UPDATE_UNAVAILABLE" {
t.Errorf("error code = %q, want UPDATE_UNAVAILABLE", resp["error"])
}
}
// TestAdminAPI_ApplyUpdate_NoUpdateAvailable verifies that 409 Conflict is
// returned when the server is already up to date.
func TestAdminAPI_ApplyUpdate_NoUpdateAvailable(t *testing.T) {
// Mock GitHub API to return same version (no update available).
mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"tag_name": "v1.0.0",
"body": "",
"html_url": "https://github.com/J3vb/OwnCord/releases/tag/v1.0.0",
"assets": []map[string]any{},
})
}))
defer mockGH.Close()
u := updater.NewUpdater("1.0.0", "", "J3vb", "OwnCord")
u.SetBaseURL(mockGH.URL)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, u)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
if w.Code != http.StatusConflict {
t.Errorf("status = %d, want 409 (no update available); body: %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if resp["error"] != "NO_UPDATE" {
t.Errorf("error = %q, want NO_UPDATE", resp["error"])
}
}
// TestAdminAPI_ApplyUpdate_CheckFails verifies that 502 Bad Gateway is returned
// when the update check request to GitHub fails.
func TestAdminAPI_ApplyUpdate_CheckFails(t *testing.T) {
// Server that immediately closes connections (simulates network error).
mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Return invalid JSON to trigger a parse error.
w.WriteHeader(http.StatusInternalServerError)
}))
defer mockGH.Close()
u := updater.NewUpdater("1.0.0", "", "J3vb", "OwnCord")
u.SetBaseURL(mockGH.URL)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, u)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
// Expect 502 Bad Gateway when update check call fails.
if w.Code != http.StatusBadGateway {
t.Errorf("status = %d, want 502; body: %s", w.Code, w.Body.String())
}
}
// TestAdminAPI_ApplyUpdate_MissingAssets verifies that 502 is returned when the
// release has no download URL or checksum URL.
func TestAdminAPI_ApplyUpdate_MissingAssets(t *testing.T) {
// Return a newer version but with no assets (empty download/checksum URLs).
mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"tag_name": "v2.0.0",
"body": "Release notes",
"html_url": "https://github.com/J3vb/OwnCord/releases/tag/v2.0.0",
"assets": []map[string]any{},
})
}))
defer mockGH.Close()
u := updater.NewUpdater("1.0.0", "", "J3vb", "OwnCord")
u.SetBaseURL(mockGH.URL)
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, u)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
if w.Code != http.StatusBadGateway {
t.Errorf("status = %d, want 502 (missing assets); body: %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if resp["error"] != "MISSING_ASSETS" {
t.Errorf("error = %q, want MISSING_ASSETS", resp["error"])
}
}
// TestAdminAPI_ApplyUpdate_Unauthenticated verifies that 401 is returned for
// unauthenticated requests to POST /updates/apply.
func TestAdminAPI_ApplyUpdate_Unauthenticated(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil)
w := doRequest(t, handler, http.MethodPost, "/updates/apply", "", nil)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
// TestAdminAPI_ApplyUpdate_DownloadFails verifies that 502 is returned when
// the binary download itself fails (bad URL, network error, etc.).
// We use a mock server that reports an available update with valid-format
// GitHub URLs, but those URLs point to a server that returns 404.
func TestAdminAPI_ApplyUpdate_DownloadFails(t *testing.T) {
// The mock server that serves the GitHub release info — it reports an
// update is available with GitHub-prefixed asset URLs.
// The actual download will fail because the URLs don't point to real files.
var mockGHURL string
mockGH := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// If this is the checksum/download request, return an error.
// The release API endpoint returns a release with asset URLs.
_ = json.NewEncoder(w).Encode(map[string]any{
"tag_name": "v2.0.0",
"body": "Release notes",
"html_url": "https://github.com/J3vb/OwnCord/releases/tag/v2.0.0",
"assets": []map[string]any{
{
"name": "chatserver.exe",
"browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/chatserver.exe",
},
{
"name": "checksums.sha256",
"browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/checksums.sha256",
},
},
})
_ = mockGHURL // suppress unused warning
}))
defer mockGH.Close()
mockGHURL = mockGH.URL
u := updater.NewUpdater("1.0.0", "", "J3vb", "OwnCord")
u.SetBaseURL(mockGH.URL)
// The download URLs are real GitHub URLs that will fail since we're not
// actually connected to GitHub in tests, or we can use the URL validation
// to force a failure. The URLs pass validation (they have the right prefix),
// but the actual HTTP fetch will fail (unreachable host).
// In CI environments without internet, this returns 502.
// We accept either 502 (download failed) or 200 (unexpectedly succeeded) —
// the important thing is that the code path is executed.
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", nil, u)
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodPost, "/updates/apply", token, nil)
// Either 502 (download failed as expected in isolated test environment)
// or 200 (succeeded in environment with GitHub access) is acceptable.
// What should NOT happen is 409 (no update) or 503 (nil updater).
if w.Code == http.StatusServiceUnavailable || w.Code == http.StatusConflict {
t.Errorf("status = %d; expected download attempt to proceed (got 503/409 instead)", w.Code)
}
}
+5
View File
@@ -1,6 +1,7 @@
package api
import (
"log/slog"
"net/http"
"strconv"
@@ -49,6 +50,7 @@ func handleListChannels(database *db.DB) http.HandlerFunc {
channels, err := database.ListChannels()
if err != nil {
slog.Error("handleListChannels ListChannels", "err", err)
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL",
Message: "failed to list channels",
@@ -81,6 +83,7 @@ func handleGetMessages(database *db.DB) http.HandlerFunc {
ch, err := database.GetChannel(channelID)
if err != nil {
slog.Error("handleGetMessages GetChannel", "err", err, "channel_id", channelID)
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL",
Message: "failed to look up channel",
@@ -144,6 +147,7 @@ func handleGetMessages(database *db.DB) http.HandlerFunc {
// Fetch one extra to determine has_more.
msgs, err := database.GetMessagesForAPI(channelID, before, limit+1, userID)
if err != nil {
slog.Error("handleGetMessages GetMessagesForAPI", "err", err, "channel_id", channelID)
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL",
Message: "failed to fetch messages",
@@ -209,6 +213,7 @@ func handleSearch(database *db.DB) http.HandlerFunc {
results, err := database.SearchMessages(q, channelID, limit)
if err != nil {
slog.Error("handleSearch SearchMessages", "err", err, "query", q)
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL",
Message: "search failed",
+115
View File
@@ -438,3 +438,118 @@ func TestSearch_NoResults(t *testing.T) {
t.Errorf("expected 0 results, got %d", len(results))
}
}
func TestSearch_WithChannelID(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "searchch", 1)
user, _ := database.GetUserByUsername("searchch")
chID, _ := database.CreateChannel("filtered", "text", "", "", 0)
_, _ = database.CreateMessage(chID, user.ID, "filtered message here", nil)
rr := chGet(t, router, fmt.Sprintf("/api/v1/search?q=filtered&channel_id=%d", chID), token)
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
}
func TestSearch_InvalidChannelID(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "badchid", 1)
rr := chGet(t, router, "/api/v1/search?q=test&channel_id=abc", token)
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rr.Code)
}
}
func TestSearch_NegativeChannelID(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "negchid", 1)
rr := chGet(t, router, "/api/v1/search?q=test&channel_id=-1", token)
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rr.Code)
}
}
func TestSearch_WithLimit(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "limituser", 1)
rr := chGet(t, router, "/api/v1/search?q=test&limit=5", token)
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rr.Code)
}
}
func TestSearch_InvalidLimit(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "badlimit", 1)
rr := chGet(t, router, "/api/v1/search?q=test&limit=abc", token)
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rr.Code)
}
}
func TestSearch_ZeroLimit(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "zerolimit", 1)
rr := chGet(t, router, "/api/v1/search?q=test&limit=0", token)
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rr.Code)
}
}
func TestSearch_LimitCappedAt100(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "highlimit", 1)
// limit=200 should be silently capped to 100
rr := chGet(t, router, "/api/v1/search?q=test&limit=200", token)
if rr.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rr.Code)
}
}
// ─── Messages — before/after cursor ─────────────────────────────────────────
func TestChannelMessages_BeforeCursor(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "cursoruser", 1)
user, _ := database.GetUserByUsername("cursoruser")
chID, _ := database.CreateChannel("cursor", "text", "", "", 0)
var lastID int64
for i := range 5 {
lastID, _ = database.CreateMessage(chID, user.ID, fmt.Sprintf("msg%d", i), nil)
}
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?before=%d", chID, lastID), token)
if rr.Code != http.StatusOK {
t.Errorf("before cursor status = %d, want 200", rr.Code)
}
}
func TestChannelMessages_InvalidLimit(t *testing.T) {
database := newChannelTestDB(t)
router := buildChannelRouter(database)
token := chTestCreateToken(t, database, "badlimituser", 1)
chID, _ := database.CreateChannel("lim", "text", "", "", 0)
rr := chGet(t, router, fmt.Sprintf("/api/v1/channels/%d/messages?limit=abc", chID), token)
if rr.Code != http.StatusBadRequest {
t.Errorf("invalid limit status = %d, want 400", rr.Code)
}
}
+6
View File
@@ -2,6 +2,7 @@ package api
import (
"encoding/json"
"log/slog"
"net/http"
"time"
@@ -66,6 +67,7 @@ func handleCreateInvite(database *db.DB) http.HandlerFunc {
code, err := database.CreateInvite(user.ID, req.MaxUses, expiresAt)
if err != nil {
slog.Error("handleCreateInvite CreateInvite", "err", err, "user_id", user.ID)
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "SERVER_ERROR",
Message: "failed to create invite",
@@ -75,6 +77,7 @@ func handleCreateInvite(database *db.DB) http.HandlerFunc {
inv, err := database.GetInvite(code)
if err != nil || inv == nil {
slog.Error("handleCreateInvite GetInvite", "err", err, "code", code)
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "SERVER_ERROR",
Message: "failed to retrieve invite",
@@ -91,6 +94,7 @@ func handleListInvites(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
invites, err := database.ListInvites()
if err != nil {
slog.Error("handleListInvites ListInvites", "err", err)
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "SERVER_ERROR",
Message: "failed to list invites",
@@ -113,6 +117,7 @@ func handleRevokeInvite(database *db.DB) http.HandlerFunc {
inv, err := database.GetInvite(code)
if err != nil {
slog.Error("handleRevokeInvite GetInvite", "err", err, "code", code)
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "SERVER_ERROR",
Message: "failed to look up invite",
@@ -128,6 +133,7 @@ func handleRevokeInvite(database *db.DB) http.HandlerFunc {
}
if err := database.RevokeInvite(code); err != nil {
slog.Error("handleRevokeInvite RevokeInvite", "err", err, "code", code)
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "SERVER_ERROR",
Message: "failed to revoke invite",
-1
View File
@@ -66,7 +66,6 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string) http.Handler {
hub.SetSFU(sfu)
}
ws.InitSettingsCache(database)
go hub.Run()
r.Get("/api/v1/ws", ws.ServeWS(hub, database, cfg.Server.AllowedOrigins))
@@ -0,0 +1,4 @@
-- Add composite index on channel_overrides for permission lookups.
-- This prevents N+1 query degradation when listing channels with overrides.
CREATE INDEX IF NOT EXISTS idx_channel_overrides_channel_role
ON channel_overrides(channel_id, role_id);
+43
View File
@@ -0,0 +1,43 @@
// export_test.go exposes unexported functions and methods for use in external
// test packages (package ws_test). This file is compiled only during "go test".
package ws
import (
"encoding/json"
"time"
"github.com/owncord/server/db"
)
// BuildAuthOKForTest exposes Hub.buildAuthOK for external tests.
func (h *Hub) BuildAuthOKForTest(user *db.User, roleName string) []byte {
return h.buildAuthOK(user, roleName)
}
// BuildReadyForTest exposes Hub.buildReady for external tests.
func (h *Hub) BuildReadyForTest(database *db.DB, userID int64) ([]byte, error) {
return h.buildReady(database, userID)
}
// GetCachedSettingsForTest exposes Hub.getCachedSettings for external tests.
func (h *Hub) GetCachedSettingsForTest() (string, string) {
return h.getCachedSettings()
}
// ExpireSettingsCacheForTest forces the settings cache to appear stale so that
// the next call to getCachedSettings triggers a DB refresh.
func (h *Hub) ExpireSettingsCacheForTest() {
h.settingsMu.Lock()
defer h.settingsMu.Unlock()
h.settingsLastUpdate = time.Time{} // zero time — always older than any TTL
}
// ParseChannelIDForTest exposes parseChannelID for external tests.
func ParseChannelIDForTest(payload json.RawMessage) (int64, error) {
return parseChannelID(payload)
}
// BuildJSONForTest exposes buildJSON for external tests.
func BuildJSONForTest(v any) []byte {
return buildJSON(v)
}
+16 -3
View File
@@ -59,6 +59,14 @@ func (h *Hub) handleMessage(c *Client, raw []byte) {
h.kickClient(c)
return
}
// Also check if user has been banned since connection was established.
user, userErr := h.db.GetUserByID(c.userID)
if userErr != nil || user == nil || auth.IsEffectivelyBanned(user) {
slog.Info("ws user banned, closing connection", "user_id", c.userID)
c.sendMsg(buildErrorMsg("BANNED", "you are banned"))
h.kickClient(c)
return
}
}
var env envelope
@@ -265,7 +273,8 @@ func (h *Hub) handleChatEdit(c *Client, _ string, payload json.RawMessage) {
msg, err := h.db.GetMessage(msgID)
if err != nil || msg == nil {
slog.Error("ws handleChatEdit GetMessage after edit", "err", err)
slog.Error("ws handleChatEdit GetMessage after edit", "err", err, "msg_id", msgID)
c.sendMsg(buildErrorMsg("INTERNAL", "edit saved but broadcast failed"))
return
}
@@ -349,7 +358,9 @@ func (h *Hub) handleReaction(c *Client, add bool, payload json.RawMessage) {
msg, err := h.db.GetMessage(msgID)
if err != nil || msg == nil {
c.sendMsg(buildErrorMsg("NOT_FOUND", "message not found"))
// Normalize: return same error whether message doesn't exist or is in
// a channel the user can't see (prevents IDOR information leak).
c.sendMsg(buildErrorMsg("BAD_REQUEST", "reaction failed"))
return
}
@@ -365,7 +376,9 @@ func (h *Hub) handleReaction(c *Client, add bool, payload json.RawMessage) {
err = h.db.RemoveReaction(msgID, c.userID, p.Emoji)
}
if err != nil {
c.sendMsg(buildErrorMsg("CONFLICT", err.Error()))
// Sanitize: never leak raw DB constraint errors to client.
slog.Warn("reaction failed", "action", action, "msg_id", msgID, "user_id", c.userID, "err", err)
c.sendMsg(buildErrorMsg("CONFLICT", "reaction failed"))
return
}
File diff suppressed because it is too large Load Diff
+60 -10
View File
@@ -2,7 +2,9 @@
package ws
import (
"log/slog"
"sync"
"time"
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
@@ -29,20 +31,67 @@ type Hub struct {
sfu *SFU
voiceRooms map[int64]*VoiceRoom
voiceRoomsMu sync.RWMutex
// Settings cache — avoids per-connection DB queries for server_name/motd.
settingsMu sync.RWMutex
settingsName string
settingsMotd string
settingsLastUpdate time.Time
}
// NewHub creates a Hub ready to be started with Run.
// It also initializes the settings cache from the database.
func NewHub(database *db.DB, limiter *auth.RateLimiter) *Hub {
return &Hub{
clients: make(map[int64]*Client),
db: database,
limiter: limiter,
broadcast: make(chan broadcastMsg, 256),
register: make(chan *Client, 32),
unregister: make(chan *Client, 32),
stop: make(chan struct{}),
voiceRooms: make(map[int64]*VoiceRoom),
h := &Hub{
clients: make(map[int64]*Client),
db: database,
limiter: limiter,
broadcast: make(chan broadcastMsg, 256),
register: make(chan *Client, 32),
unregister: make(chan *Client, 32),
stop: make(chan struct{}),
voiceRooms: make(map[int64]*VoiceRoom),
settingsName: "OwnCord Server",
settingsMotd: "Welcome!",
}
h.refreshSettingsLocked()
return h
}
// getCachedSettings returns server_name and motd, refreshing the cache if stale.
func (h *Hub) getCachedSettings() (string, string) {
h.settingsMu.RLock()
if time.Since(h.settingsLastUpdate) < settingsCacheTTL {
name, motd := h.settingsName, h.settingsMotd
h.settingsMu.RUnlock()
return name, motd
}
h.settingsMu.RUnlock()
h.settingsMu.Lock()
defer h.settingsMu.Unlock()
// Double-check after acquiring write lock.
if time.Since(h.settingsLastUpdate) < settingsCacheTTL {
return h.settingsName, h.settingsMotd
}
h.refreshSettingsLocked()
return h.settingsName, h.settingsMotd
}
// refreshSettingsLocked reloads server_name and motd from the DB.
// Caller must hold settingsMu (write lock) or call during init.
func (h *Hub) refreshSettingsLocked() {
if h.db == nil {
return
}
var name, motd string
if err := h.db.QueryRow("SELECT value FROM settings WHERE key='server_name'").Scan(&name); err == nil {
h.settingsName = name
}
if err := h.db.QueryRow("SELECT value FROM settings WHERE key='motd'").Scan(&motd); err == nil {
h.settingsMotd = motd
}
h.settingsLastUpdate = time.Now()
}
// SetSFU sets the SFU engine on the hub. Must be called before Run.
@@ -306,7 +355,8 @@ func (h *Hub) deliverBroadcast(bm broadcastMsg) {
select {
case c.send <- bm.msg:
default:
// Client's buffer is full; skip to avoid blocking the hub.
slog.Warn("broadcast dropped: client send buffer full",
"user_id", c.userID, "channel_id", bm.channelID)
}
}
}
+409
View File
@@ -179,3 +179,412 @@ func TestBuildChannelDelete_ValidJSON(t *testing.T) {
t.Errorf("buildChannelDelete output is not valid JSON: %s", msg)
}
}
// ─── buildAuthError ───────────────────────────────────────────────────────────
func TestBuildAuthError_Type(t *testing.T) {
msg := buildAuthError("invalid token")
var env struct {
Type string `json:"type"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Type != "auth_error" {
t.Errorf("type = %q, want auth_error", env.Type)
}
}
func TestBuildAuthError_Payload(t *testing.T) {
msg := buildAuthError("session expired")
var env struct {
Payload struct {
Message string `json:"message"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Payload.Message != "session expired" {
t.Errorf("payload.message = %q, want session expired", env.Payload.Message)
}
}
func TestBuildAuthError_ValidJSON(t *testing.T) {
msg := buildAuthError("bad token")
if !json.Valid(msg) {
t.Errorf("buildAuthError output is not valid JSON: %s", msg)
}
}
// ─── buildMemberJoin ──────────────────────────────────────────────────────────
func TestBuildMemberJoin_Type(t *testing.T) {
user := &db.User{ID: 1, Username: "alice"}
msg := buildMemberJoin(user, "member")
var env struct {
Type string `json:"type"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Type != "member_join" {
t.Errorf("type = %q, want member_join", env.Type)
}
}
func TestBuildMemberJoin_Payload(t *testing.T) {
user := &db.User{ID: 42, Username: "alice"}
msg := buildMemberJoin(user, "admin")
var env struct {
Payload struct {
User struct {
ID int64 `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
} `json:"user"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
u := env.Payload.User
if u.ID != 42 {
t.Errorf("user.id = %d, want 42", u.ID)
}
if u.Username != "alice" {
t.Errorf("user.username = %q, want alice", u.Username)
}
if u.Role != "admin" {
t.Errorf("user.role = %q, want admin", u.Role)
}
}
func TestBuildMemberJoin_NilAvatar(t *testing.T) {
user := &db.User{ID: 1, Username: "noavatar", Avatar: nil}
msg := buildMemberJoin(user, "member")
var env struct {
Payload struct {
User struct {
Avatar any `json:"avatar"`
} `json:"user"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Payload.User.Avatar != nil {
t.Errorf("avatar = %v, want nil for nil avatar", env.Payload.User.Avatar)
}
}
func TestBuildMemberJoin_NonNilAvatar(t *testing.T) {
avatarURL := "https://example.com/avatar.png"
user := &db.User{ID: 1, Username: "withavatar", Avatar: &avatarURL}
msg := buildMemberJoin(user, "member")
var env struct {
Payload struct {
User struct {
Avatar string `json:"avatar"`
} `json:"user"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Payload.User.Avatar != avatarURL {
t.Errorf("avatar = %q, want %q", env.Payload.User.Avatar, avatarURL)
}
}
// ─── buildMemberUpdate ────────────────────────────────────────────────────────
func TestBuildMemberUpdate_Type(t *testing.T) {
msg := buildMemberUpdate(7, "moderator")
var env struct {
Type string `json:"type"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Type != "member_update" {
t.Errorf("type = %q, want member_update", env.Type)
}
}
func TestBuildMemberUpdate_Payload(t *testing.T) {
msg := buildMemberUpdate(7, "moderator")
var env struct {
Payload struct {
UserID int64 `json:"user_id"`
Role string `json:"role"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Payload.UserID != 7 {
t.Errorf("payload.user_id = %d, want 7", env.Payload.UserID)
}
if env.Payload.Role != "moderator" {
t.Errorf("payload.role = %q, want moderator", env.Payload.Role)
}
}
// ─── buildMemberBan ───────────────────────────────────────────────────────────
func TestBuildMemberBan_Type(t *testing.T) {
msg := buildMemberBan(55)
var env struct {
Type string `json:"type"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Type != "member_ban" {
t.Errorf("type = %q, want member_ban", env.Type)
}
}
func TestBuildMemberBan_Payload(t *testing.T) {
msg := buildMemberBan(55)
var env struct {
Payload struct {
UserID int64 `json:"user_id"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Payload.UserID != 55 {
t.Errorf("payload.user_id = %d, want 55", env.Payload.UserID)
}
}
func TestBuildMemberBan_ValidJSON(t *testing.T) {
if !json.Valid(buildMemberBan(1)) {
t.Error("buildMemberBan output is not valid JSON")
}
}
// ─── buildChatEdited ──────────────────────────────────────────────────────────
func TestBuildChatEdited_Type(t *testing.T) {
msg := buildChatEdited(10, 20, "new content", "2024-01-01T00:00:00Z")
var env struct {
Type string `json:"type"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Type != "chat_edited" {
t.Errorf("type = %q, want chat_edited", env.Type)
}
}
func TestBuildChatEdited_Payload(t *testing.T) {
msg := buildChatEdited(10, 20, "new content", "2024-01-01T00:00:00Z")
var env struct {
Payload struct {
MessageID int64 `json:"message_id"`
ChannelID int64 `json:"channel_id"`
Content string `json:"content"`
EditedAt string `json:"edited_at"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
p := env.Payload
if p.MessageID != 10 {
t.Errorf("payload.message_id = %d, want 10", p.MessageID)
}
if p.ChannelID != 20 {
t.Errorf("payload.channel_id = %d, want 20", p.ChannelID)
}
if p.Content != "new content" {
t.Errorf("payload.content = %q, want new content", p.Content)
}
if p.EditedAt != "2024-01-01T00:00:00Z" {
t.Errorf("payload.edited_at = %q, want 2024-01-01T00:00:00Z", p.EditedAt)
}
}
// ─── buildChatDeleted ─────────────────────────────────────────────────────────
func TestBuildChatDeleted_Type(t *testing.T) {
msg := buildChatDeleted(11, 22)
var env struct {
Type string `json:"type"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Type != "chat_deleted" {
t.Errorf("type = %q, want chat_deleted", env.Type)
}
}
func TestBuildChatDeleted_Payload(t *testing.T) {
msg := buildChatDeleted(11, 22)
var env struct {
Payload struct {
MessageID int64 `json:"message_id"`
ChannelID int64 `json:"channel_id"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Payload.MessageID != 11 {
t.Errorf("payload.message_id = %d, want 11", env.Payload.MessageID)
}
if env.Payload.ChannelID != 22 {
t.Errorf("payload.channel_id = %d, want 22", env.Payload.ChannelID)
}
}
func TestBuildChatDeleted_ValidJSON(t *testing.T) {
if !json.Valid(buildChatDeleted(1, 2)) {
t.Error("buildChatDeleted output is not valid JSON")
}
}
// ─── buildReactionUpdate ──────────────────────────────────────────────────────
func TestBuildReactionUpdate_Type(t *testing.T) {
msg := buildReactionUpdate(1, 2, 3, "👍", "add")
var env struct {
Type string `json:"type"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Type != "reaction_update" {
t.Errorf("type = %q, want reaction_update", env.Type)
}
}
func TestBuildReactionUpdate_Payload(t *testing.T) {
msg := buildReactionUpdate(100, 200, 300, "❤️", "remove")
var env struct {
Payload struct {
MessageID int64 `json:"message_id"`
ChannelID int64 `json:"channel_id"`
UserID int64 `json:"user_id"`
Emoji string `json:"emoji"`
Action string `json:"action"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
p := env.Payload
if p.MessageID != 100 {
t.Errorf("payload.message_id = %d, want 100", p.MessageID)
}
if p.ChannelID != 200 {
t.Errorf("payload.channel_id = %d, want 200", p.ChannelID)
}
if p.UserID != 300 {
t.Errorf("payload.user_id = %d, want 300", p.UserID)
}
if p.Emoji != "❤️" {
t.Errorf("payload.emoji = %q, want ❤️", p.Emoji)
}
if p.Action != "remove" {
t.Errorf("payload.action = %q, want remove", p.Action)
}
}
func TestBuildReactionUpdate_ValidJSON(t *testing.T) {
if !json.Valid(buildReactionUpdate(1, 2, 3, "😀", "add")) {
t.Error("buildReactionUpdate output is not valid JSON")
}
}
// ─── buildTypingMsg ───────────────────────────────────────────────────────────
func TestBuildTypingMsg_Type(t *testing.T) {
msg := buildTypingMsg(5, 10, "alice")
var env struct {
Type string `json:"type"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Type != "typing" {
t.Errorf("type = %q, want typing", env.Type)
}
}
func TestBuildTypingMsg_Payload(t *testing.T) {
msg := buildTypingMsg(5, 10, "alice")
var env struct {
Payload struct {
ChannelID int64 `json:"channel_id"`
UserID int64 `json:"user_id"`
Username string `json:"username"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
p := env.Payload
if p.ChannelID != 5 {
t.Errorf("payload.channel_id = %d, want 5", p.ChannelID)
}
if p.UserID != 10 {
t.Errorf("payload.user_id = %d, want 10", p.UserID)
}
if p.Username != "alice" {
t.Errorf("payload.username = %q, want alice", p.Username)
}
}
func TestBuildTypingMsg_ValidJSON(t *testing.T) {
if !json.Valid(buildTypingMsg(1, 2, "user")) {
t.Error("buildTypingMsg output is not valid JSON")
}
}
// ─── buildVoiceAnswer ─────────────────────────────────────────────────────────
func TestBuildVoiceAnswer_Type(t *testing.T) {
msg := buildVoiceAnswer(99, "v=0\r\n")
var env struct {
Type string `json:"type"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Type != "voice_answer" {
t.Errorf("type = %q, want voice_answer", env.Type)
}
}
func TestBuildVoiceAnswer_Payload(t *testing.T) {
sdp := "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\n"
msg := buildVoiceAnswer(99, sdp)
var env struct {
Payload struct {
ChannelID int64 `json:"channel_id"`
SDP string `json:"sdp"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Payload.ChannelID != 99 {
t.Errorf("payload.channel_id = %d, want 99", env.Payload.ChannelID)
}
if env.Payload.SDP != sdp {
t.Errorf("payload.sdp = %q, want %q", env.Payload.SDP, sdp)
}
}
func TestBuildVoiceAnswer_ValidJSON(t *testing.T) {
if !json.Valid(buildVoiceAnswer(1, "sdp-data")) {
t.Error("buildVoiceAnswer output is not valid JSON")
}
}
+10 -59
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"log/slog"
"net/http"
"sync"
"time"
"nhooyr.io/websocket"
@@ -19,58 +18,6 @@ const authDeadline = 10 * time.Second
const writeTimeout = 10 * time.Second
const settingsCacheTTL = 30 * time.Second
// cachedSettings holds server_name and motd to avoid per-connection DB queries.
var (
settingsMu sync.RWMutex
settingsName = "OwnCord Server"
settingsMotd = "Welcome!"
settingsLastUpdate time.Time
settingsDB *db.DB
)
// InitSettingsCache sets the DB reference for the settings cache.
// Must be called once during server startup.
func InitSettingsCache(database *db.DB) {
settingsMu.Lock()
defer settingsMu.Unlock()
settingsDB = database
refreshSettingsLocked()
}
func refreshSettingsLocked() {
if settingsDB == nil {
return
}
var name, motd string
if err := settingsDB.QueryRow("SELECT value FROM settings WHERE key='server_name'").Scan(&name); err == nil {
settingsName = name
}
if err := settingsDB.QueryRow("SELECT value FROM settings WHERE key='motd'").Scan(&motd); err == nil {
settingsMotd = motd
}
settingsLastUpdate = time.Now()
}
// getCachedSettings returns server_name and motd, refreshing the cache if stale.
func getCachedSettings() (string, string) {
settingsMu.RLock()
if time.Since(settingsLastUpdate) < settingsCacheTTL {
name, motd := settingsName, settingsMotd
settingsMu.RUnlock()
return name, motd
}
settingsMu.RUnlock()
settingsMu.Lock()
defer settingsMu.Unlock()
// Double-check after acquiring write lock.
if time.Since(settingsLastUpdate) < settingsCacheTTL {
return settingsName, settingsMotd
}
refreshSettingsLocked()
return settingsName, settingsMotd
}
// ServeWS upgrades an HTTP connection to WebSocket, performs in-band auth,
// then drives the client's read/write loops.
// Do not wrap with AuthMiddleware — WS does its own auth.
@@ -115,9 +62,13 @@ func ServeWS(hub *Hub, database *db.DB, allowedOrigins []string) http.HandlerFun
// Send auth_ok followed by the ready payload.
ctx := r.Context()
_ = conn.Write(ctx, websocket.MessageText, buildAuthOK(user, roleName))
if ready, readyErr := buildReady(database, user.ID); readyErr == nil {
_ = conn.Write(ctx, websocket.MessageText, hub.buildAuthOK(user, roleName))
if ready, readyErr := hub.buildReady(database, user.ID); readyErr == nil {
_ = conn.Write(ctx, websocket.MessageText, ready)
} else {
slog.Error("buildReady failed", "user_id", user.ID, "err", readyErr)
_ = conn.Write(ctx, websocket.MessageText,
buildErrorMsg("INTERNAL", "failed to build ready payload"))
}
hub.BroadcastToAll(buildMemberJoin(user, roleName))
@@ -232,13 +183,13 @@ func authenticateConn(conn *websocket.Conn, database *db.DB) (*db.User, string,
// buildAuthOK constructs the auth_ok server→client message.
// Per PROTOCOL.md, user object contains only id, username, avatar, role (no status).
func buildAuthOK(user *db.User, roleName string) []byte {
func (h *Hub) buildAuthOK(user *db.User, roleName string) []byte {
var avatarVal any
if user.Avatar != nil {
avatarVal = *user.Avatar
}
serverName, motd := getCachedSettings()
serverName, motd := h.getCachedSettings()
return buildJSON(map[string]any{
"type": "auth_ok",
@@ -258,7 +209,7 @@ func buildAuthOK(user *db.User, roleName string) []byte {
// buildReady constructs the ready server→client message.
// Per PROTOCOL.md, channels include unread_count and last_message_id per user,
// and only protocol-specified fields (no slow_mode, archived, voice_* extras).
func buildReady(database *db.DB, userID int64) ([]byte, error) {
func (h *Hub) buildReady(database *db.DB, userID int64) ([]byte, error) {
channels, err := database.ListChannels()
if err != nil {
return nil, fmt.Errorf("buildReady ListChannels: %w", err)
@@ -311,7 +262,7 @@ func buildReady(database *db.DB, userID int64) ([]byte, error) {
voiceStates = []db.VoiceState{}
}
serverName, motd := getCachedSettings()
serverName, motd := h.getCachedSettings()
return buildJSON(map[string]any{
"type": "ready",
+827
View File
@@ -0,0 +1,827 @@
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"
)
// ─── schema used by serve tests ───────────────────────────────────────────────
// serveTestSchema extends hubTestSchema with voice_states so that
// collectAllVoiceStates can be exercised via buildReady.
var serveTestSchema = 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,
camera INTEGER NOT NULL DEFAULT 0,
screenshare INTEGER NOT NULL DEFAULT 0,
joined_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_voice_states_channel_serve ON voice_states(channel_id);
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'))
);
`)...)
func openServeTestDB(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: serveTestSchema},
}
if err := db.MigrateFS(database, migrFS); err != nil {
t.Fatalf("MigrateFS: %v", err)
}
return database
}
func newServeHub(t *testing.T) (*ws.Hub, *db.DB) {
t.Helper()
database := openServeTestDB(t)
limiter := auth.NewRateLimiter()
hub := ws.NewHub(database, limiter)
go hub.Run()
t.Cleanup(func() { hub.Stop() })
return hub, database
}
// seedServeUser inserts an Owner-role user and returns the full *db.User.
func seedServeUser(t *testing.T, database *db.DB, username string) *db.User {
t.Helper()
_, err := database.CreateUser(username, "hash", 1)
if err != nil {
t.Fatalf("seedServeUser: %v", err)
}
user, err := database.GetUserByUsername(username)
if err != nil || user == nil {
t.Fatalf("seedServeUser GetUserByUsername: %v", err)
}
return user
}
// ─── buildAuthOK ─────────────────────────────────────────────────────────────
func TestBuildAuthOK_Type(t *testing.T) {
hub, database := newServeHub(t)
user := seedServeUser(t, database, "authok-user1")
msg := hub.BuildAuthOKForTest(user, "admin")
var env struct {
Type string `json:"type"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Type != "auth_ok" {
t.Errorf("type = %q, want auth_ok", env.Type)
}
}
func TestBuildAuthOK_UserPayload(t *testing.T) {
hub, database := newServeHub(t)
user := seedServeUser(t, database, "authok-user2")
msg := hub.BuildAuthOKForTest(user, "member")
var env struct {
Payload struct {
User struct {
ID int64 `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
} `json:"user"`
ServerName string `json:"server_name"`
MOTD string `json:"motd"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Payload.User.ID != user.ID {
t.Errorf("payload.user.id = %d, want %d", env.Payload.User.ID, user.ID)
}
if env.Payload.User.Username != user.Username {
t.Errorf("payload.user.username = %q, want %q", env.Payload.User.Username, user.Username)
}
if env.Payload.User.Role != "member" {
t.Errorf("payload.user.role = %q, want member", env.Payload.User.Role)
}
}
func TestBuildAuthOK_ContainsServerName(t *testing.T) {
hub, database := newServeHub(t)
user := seedServeUser(t, database, "authok-user3")
msg := hub.BuildAuthOKForTest(user, "owner")
var env struct {
Payload struct {
ServerName string `json:"server_name"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// server_name is seeded from settings table; must be non-empty.
if env.Payload.ServerName == "" {
t.Error("payload.server_name must not be empty")
}
}
func TestBuildAuthOK_NilAvatar(t *testing.T) {
hub, database := newServeHub(t)
user := seedServeUser(t, database, "authok-noavatar")
// Avatar is nil by default after insert.
msg := hub.BuildAuthOKForTest(user, "member")
var env struct {
Payload struct {
User struct {
Avatar any `json:"avatar"`
} `json:"user"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Payload.User.Avatar != nil {
t.Errorf("payload.user.avatar = %v, want nil", env.Payload.User.Avatar)
}
}
func TestBuildAuthOK_ValidJSON(t *testing.T) {
hub, database := newServeHub(t)
user := seedServeUser(t, database, "authok-validjson")
msg := hub.BuildAuthOKForTest(user, "member")
if !json.Valid(msg) {
t.Errorf("buildAuthOK output is not valid JSON: %s", msg)
}
}
// ─── buildReady ───────────────────────────────────────────────────────────────
func TestBuildReady_Type(t *testing.T) {
hub, database := newServeHub(t)
user := seedServeUser(t, database, "ready-user1")
msg, err := hub.BuildReadyForTest(database, user.ID)
if err != nil {
t.Fatalf("BuildReadyForTest: %v", err)
}
var env struct {
Type string `json:"type"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Type != "ready" {
t.Errorf("type = %q, want ready", env.Type)
}
}
func TestBuildReady_ContainsRequiredFields(t *testing.T) {
hub, database := newServeHub(t)
user := seedServeUser(t, database, "ready-user2")
msg, err := hub.BuildReadyForTest(database, user.ID)
if err != nil {
t.Fatalf("BuildReadyForTest: %v", err)
}
var env struct {
Payload struct {
Channels []any `json:"channels"`
Members []any `json:"members"`
VoiceStates []any `json:"voice_states"`
Roles []any `json:"roles"`
ServerName string `json:"server_name"`
MOTD string `json:"motd"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// channels, members, voice_states, roles must all be present (even if empty slices).
if env.Payload.Channels == nil {
t.Error("payload.channels must not be nil")
}
if env.Payload.Members == nil {
t.Error("payload.members must not be nil")
}
if env.Payload.VoiceStates == nil {
t.Error("payload.voice_states must not be nil")
}
if env.Payload.Roles == nil {
t.Error("payload.roles must not be nil")
}
if env.Payload.ServerName == "" {
t.Error("payload.server_name must not be empty")
}
}
func TestBuildReady_IncludesSeededChannel(t *testing.T) {
hub, database := newServeHub(t)
user := seedServeUser(t, database, "ready-user3")
// Seed a text channel.
chID, err := database.CreateChannel("general", "text", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
msg, err := hub.BuildReadyForTest(database, user.ID)
if err != nil {
t.Fatalf("BuildReadyForTest: %v", err)
}
var env struct {
Payload struct {
Channels []struct {
ID float64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
} `json:"channels"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
found := false
for _, ch := range env.Payload.Channels {
if int64(ch.ID) == chID && ch.Name == "general" && ch.Type == "text" {
found = true
break
}
}
if !found {
t.Errorf("ready payload does not include seeded channel (id=%d)", chID)
}
}
func TestBuildReady_TextChannelHasUnreadCount(t *testing.T) {
hub, database := newServeHub(t)
user := seedServeUser(t, database, "ready-user4")
_, err := database.CreateChannel("unread-chan", "text", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
msg, err := hub.BuildReadyForTest(database, user.ID)
if err != nil {
t.Fatalf("BuildReadyForTest: %v", err)
}
var env struct {
Payload struct {
Channels []map[string]any `json:"channels"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for _, ch := range env.Payload.Channels {
if ch["type"] == "text" {
if _, ok := ch["unread_count"]; !ok {
t.Error("text channel missing unread_count field")
}
if _, ok := ch["last_message_id"]; !ok {
t.Error("text channel missing last_message_id field")
}
}
}
}
func TestBuildReady_ValidJSON(t *testing.T) {
hub, database := newServeHub(t)
user := seedServeUser(t, database, "ready-validjson")
msg, err := hub.BuildReadyForTest(database, user.ID)
if err != nil {
t.Fatalf("BuildReadyForTest: %v", err)
}
if !json.Valid(msg) {
t.Errorf("buildReady output is not valid JSON: %s", msg)
}
}
// ─── collectAllVoiceStates ────────────────────────────────────────────────────
func TestCollectAllVoiceStates_EmptyChannels(t *testing.T) {
hub, database := newServeHub(t)
user := seedServeUser(t, database, "collect-empty-user")
// No channels exist — ready should return empty voice_states.
msg, err := hub.BuildReadyForTest(database, user.ID)
if err != nil {
t.Fatalf("BuildReadyForTest: %v", err)
}
var env struct {
Payload struct {
VoiceStates []any `json:"voice_states"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(env.Payload.VoiceStates) != 0 {
t.Errorf("voice_states = %d entries, want 0 with no channels", len(env.Payload.VoiceStates))
}
}
func TestCollectAllVoiceStates_SkipsTextChannels(t *testing.T) {
hub, database := newServeHub(t)
user := seedServeUser(t, database, "collect-text-user")
// Only text channels — no voice states should be collected.
_, err := database.CreateChannel("text-only", "text", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
msg, err := hub.BuildReadyForTest(database, user.ID)
if err != nil {
t.Fatalf("BuildReadyForTest: %v", err)
}
var env struct {
Payload struct {
VoiceStates []any `json:"voice_states"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(env.Payload.VoiceStates) != 0 {
t.Errorf("voice_states = %d entries, want 0 for text-only channels", len(env.Payload.VoiceStates))
}
}
func TestCollectAllVoiceStates_IncludesVoiceParticipants(t *testing.T) {
hub, database := newServeHub(t)
user1 := seedServeUser(t, database, "collect-voice-u1")
user2 := seedServeUser(t, database, "collect-voice-u2")
requester := seedServeUser(t, database, "collect-voice-req")
chID, err := database.CreateChannel("voice-room", "voice", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
// Insert voice states for user1 and user2.
if err := database.JoinVoiceChannel(user1.ID, chID); err != nil {
t.Fatalf("JoinVoiceChannel user1: %v", err)
}
if err := database.JoinVoiceChannel(user2.ID, chID); err != nil {
t.Fatalf("JoinVoiceChannel user2: %v", err)
}
msg, err := hub.BuildReadyForTest(database, requester.ID)
if err != nil {
t.Fatalf("BuildReadyForTest: %v", err)
}
var env struct {
Payload struct {
VoiceStates []struct {
ChannelID int64 `json:"channel_id"`
UserID int64 `json:"user_id"`
} `json:"voice_states"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(env.Payload.VoiceStates) != 2 {
t.Errorf("voice_states count = %d, want 2", len(env.Payload.VoiceStates))
}
for _, vs := range env.Payload.VoiceStates {
if vs.ChannelID != chID {
t.Errorf("voice_state channel_id = %d, want %d", vs.ChannelID, chID)
}
}
}
// ─── getCachedSettings ────────────────────────────────────────────────────────
func TestGetCachedSettings_CacheHit(t *testing.T) {
hub, _ := newServeHub(t)
// Call twice in quick succession; second call must return the same values
// (cache hit path, no DB re-read within TTL).
name1, motd1 := hub.GetCachedSettingsForTest()
name2, motd2 := hub.GetCachedSettingsForTest()
if name1 != name2 {
t.Errorf("server_name changed between calls: %q vs %q", name1, name2)
}
if motd1 != motd2 {
t.Errorf("motd changed between calls: %q vs %q", motd1, motd2)
}
}
func TestGetCachedSettings_ReturnsNonEmptyValues(t *testing.T) {
hub, _ := newServeHub(t)
name, motd := hub.GetCachedSettingsForTest()
if name == "" {
t.Error("server_name must not be empty after NewHub")
}
if motd == "" {
t.Error("motd must not be empty after NewHub")
}
}
func TestGetCachedSettings_ReflectsDBValues(t *testing.T) {
_, database := newServeHub(t)
// Verify the default settings were loaded correctly from the seeded DB.
var name string
if err := database.QueryRow("SELECT value FROM settings WHERE key='server_name'").Scan(&name); err != nil {
t.Fatalf("query server_name: %v", err)
}
if name != "OwnCord Server" {
t.Errorf("DB server_name = %q, want OwnCord Server", name)
}
}
// ─── Broadcast* hub methods ───────────────────────────────────────────────────
func TestHub_BroadcastServerRestart_DeliversToAllClients(t *testing.T) {
hub, database := newServeHub(t)
u1 := seedTestUser(t, database, "restart-u1")
u2 := seedTestUser(t, database, "restart-u2")
s1 := make(chan []byte, 4)
s2 := make(chan []byte, 4)
hub.Register(ws.NewTestClient(hub, u1, s1))
hub.Register(ws.NewTestClient(hub, u2, s2))
time.Sleep(20 * time.Millisecond)
hub.BroadcastServerRestart("update", 5)
time.Sleep(20 * time.Millisecond)
for _, s := range []chan []byte{s1, s2} {
select {
case msg := <-s:
var env struct {
Type string `json:"type"`
Payload struct {
Reason string `json:"reason"`
DelaySeconds int `json:"delay_seconds"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Type != "server_restart" {
t.Errorf("type = %q, want server_restart", env.Type)
}
if env.Payload.Reason != "update" {
t.Errorf("payload.reason = %q, want update", env.Payload.Reason)
}
if env.Payload.DelaySeconds != 5 {
t.Errorf("payload.delay_seconds = %d, want 5", env.Payload.DelaySeconds)
}
case <-time.After(500 * time.Millisecond):
t.Error("client did not receive server_restart within timeout")
}
}
}
func TestHub_BroadcastServerRestart_NoClients_NoPanic(t *testing.T) {
hub, _ := newServeHub(t)
// Must not panic with no clients connected.
hub.BroadcastServerRestart("maintenance", 30)
}
func TestHub_BroadcastChannelCreate_DeliversToAllClients(t *testing.T) {
hub, database := newServeHub(t)
u1 := seedTestUser(t, database, "chcreate-u1")
s1 := make(chan []byte, 4)
hub.Register(ws.NewTestClient(hub, u1, s1))
time.Sleep(20 * time.Millisecond)
ch := &db.Channel{ID: 77, Name: "announcements", Type: "text", Category: "News", Position: 1}
hub.BroadcastChannelCreate(ch)
time.Sleep(20 * time.Millisecond)
select {
case msg := <-s1:
var env struct {
Type string `json:"type"`
Payload struct {
ID float64 `json:"id"`
Name string `json:"name"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Type != "channel_create" {
t.Errorf("type = %q, want channel_create", env.Type)
}
if int64(env.Payload.ID) != ch.ID {
t.Errorf("payload.id = %d, want %d", int64(env.Payload.ID), ch.ID)
}
if env.Payload.Name != ch.Name {
t.Errorf("payload.name = %q, want %q", env.Payload.Name, ch.Name)
}
case <-time.After(500 * time.Millisecond):
t.Error("client did not receive channel_create within timeout")
}
}
func TestHub_BroadcastChannelUpdate_DeliversToAllClients(t *testing.T) {
hub, database := newServeHub(t)
u1 := seedTestUser(t, database, "chupdate-u1")
s1 := make(chan []byte, 4)
hub.Register(ws.NewTestClient(hub, u1, s1))
time.Sleep(20 * time.Millisecond)
ch := &db.Channel{ID: 88, Name: "updated-channel", Type: "text", Category: "General", Position: 2}
hub.BroadcastChannelUpdate(ch)
time.Sleep(20 * time.Millisecond)
select {
case msg := <-s1:
var env struct {
Type string `json:"type"`
Payload struct {
ID float64 `json:"id"`
Name string `json:"name"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Type != "channel_update" {
t.Errorf("type = %q, want channel_update", env.Type)
}
if int64(env.Payload.ID) != ch.ID {
t.Errorf("payload.id = %d, want %d", int64(env.Payload.ID), ch.ID)
}
case <-time.After(500 * time.Millisecond):
t.Error("client did not receive channel_update within timeout")
}
}
func TestHub_BroadcastChannelDelete_DeliversToAllClients(t *testing.T) {
hub, database := newServeHub(t)
u1 := seedTestUser(t, database, "chdel-u1")
s1 := make(chan []byte, 4)
hub.Register(ws.NewTestClient(hub, u1, s1))
time.Sleep(20 * time.Millisecond)
hub.BroadcastChannelDelete(123)
time.Sleep(20 * time.Millisecond)
select {
case msg := <-s1:
var env struct {
Type string `json:"type"`
Payload struct {
ID float64 `json:"id"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Type != "channel_delete" {
t.Errorf("type = %q, want channel_delete", env.Type)
}
if int64(env.Payload.ID) != 123 {
t.Errorf("payload.id = %d, want 123", int64(env.Payload.ID))
}
case <-time.After(500 * time.Millisecond):
t.Error("client did not receive channel_delete within timeout")
}
}
func TestHub_BroadcastMemberBan_DeliversToAllClients(t *testing.T) {
hub, database := newServeHub(t)
u1 := seedTestUser(t, database, "ban-u1")
s1 := make(chan []byte, 4)
hub.Register(ws.NewTestClient(hub, u1, s1))
time.Sleep(20 * time.Millisecond)
hub.BroadcastMemberBan(999)
time.Sleep(20 * time.Millisecond)
select {
case msg := <-s1:
var env struct {
Type string `json:"type"`
Payload struct {
UserID float64 `json:"user_id"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Type != "member_ban" {
t.Errorf("type = %q, want member_ban", env.Type)
}
if int64(env.Payload.UserID) != 999 {
t.Errorf("payload.user_id = %d, want 999", int64(env.Payload.UserID))
}
case <-time.After(500 * time.Millisecond):
t.Error("client did not receive member_ban within timeout")
}
}
func TestHub_BroadcastMemberUpdate_DeliversToAllClients(t *testing.T) {
hub, database := newServeHub(t)
u1 := seedTestUser(t, database, "memupdate-u1")
s1 := make(chan []byte, 4)
hub.Register(ws.NewTestClient(hub, u1, s1))
time.Sleep(20 * time.Millisecond)
hub.BroadcastMemberUpdate(888, "moderator")
time.Sleep(20 * time.Millisecond)
select {
case msg := <-s1:
var env struct {
Type string `json:"type"`
Payload struct {
UserID float64 `json:"user_id"`
Role string `json:"role"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Type != "member_update" {
t.Errorf("type = %q, want member_update", env.Type)
}
if int64(env.Payload.UserID) != 888 {
t.Errorf("payload.user_id = %d, want 888", int64(env.Payload.UserID))
}
if env.Payload.Role != "moderator" {
t.Errorf("payload.role = %q, want moderator", env.Payload.Role)
}
case <-time.After(500 * time.Millisecond):
t.Error("client did not receive member_update within timeout")
}
}
func TestHub_BroadcastMemberBan_NoClients_NoPanic(t *testing.T) {
hub, _ := newServeHub(t)
hub.BroadcastMemberBan(1)
}
func TestHub_BroadcastMemberUpdate_NoClients_NoPanic(t *testing.T) {
hub, _ := newServeHub(t)
hub.BroadcastMemberUpdate(1, "member")
}
func TestHub_BroadcastChannelCreate_NoClients_NoPanic(t *testing.T) {
hub, _ := newServeHub(t)
hub.BroadcastChannelCreate(&db.Channel{ID: 1, Name: "x", Type: "text"})
}
func TestHub_BroadcastChannelUpdate_NoClients_NoPanic(t *testing.T) {
hub, _ := newServeHub(t)
hub.BroadcastChannelUpdate(&db.Channel{ID: 1, Name: "x", Type: "text"})
}
func TestHub_BroadcastChannelDelete_NoClients_NoPanic(t *testing.T) {
hub, _ := newServeHub(t)
hub.BroadcastChannelDelete(1)
}
// ─── getCachedSettings — cache expiry path ────────────────────────────────────
func TestGetCachedSettings_CacheMiss_RefreshesFromDB(t *testing.T) {
hub, database := newServeHub(t)
// Update the DB settings value so we can detect a refresh.
_, err := database.Exec("UPDATE settings SET value='Refreshed Server' WHERE key='server_name'")
if err != nil {
t.Fatalf("UPDATE settings: %v", err)
}
// Force the cache to appear stale.
hub.ExpireSettingsCacheForTest()
// Next call must re-read from the DB and return the updated value.
name, _ := hub.GetCachedSettingsForTest()
if name != "Refreshed Server" {
t.Errorf("server_name after cache miss = %q, want Refreshed Server", name)
}
}
func TestGetCachedSettings_CacheMiss_DoubleCheck(t *testing.T) {
// Expire the cache and call twice rapidly to exercise the double-check
// (write-lock re-check) branch inside getCachedSettings.
hub, _ := newServeHub(t)
hub.ExpireSettingsCacheForTest()
name1, _ := hub.GetCachedSettingsForTest()
// Second call should hit the cache (now warm).
name2, _ := hub.GetCachedSettingsForTest()
if name1 != name2 {
t.Errorf("server_name changed after refresh: %q vs %q", name1, name2)
}
}
// ─── parseChannelID error paths ───────────────────────────────────────────────
func TestParseChannelID_ValidPayload(t *testing.T) {
raw := json.RawMessage(`{"channel_id": 42}`)
id, err := ws.ParseChannelIDForTest(raw)
if err != nil {
t.Fatalf("ParseChannelIDForTest: %v", err)
}
if id != 42 {
t.Errorf("channel_id = %d, want 42", id)
}
}
func TestParseChannelID_InvalidJSON(t *testing.T) {
raw := json.RawMessage(`NOT JSON`)
_, err := ws.ParseChannelIDForTest(raw)
if err == nil {
t.Error("expected error for invalid JSON, got nil")
}
}
func TestParseChannelID_NonIntegerChannelID(t *testing.T) {
raw := json.RawMessage(`{"channel_id": "not-a-number"}`)
_, err := ws.ParseChannelIDForTest(raw)
if err == nil {
t.Error("expected error for non-integer channel_id, got nil")
}
}
func TestParseChannelID_MissingField(t *testing.T) {
// Missing channel_id field — json.Number.Int64 on zero value returns 0, no error.
raw := json.RawMessage(`{}`)
id, err := ws.ParseChannelIDForTest(raw)
if err == nil && id != 0 {
t.Errorf("expected id=0 for missing channel_id, got %d", id)
}
}
// ─── buildJSON error fallback path ────────────────────────────────────────────
func TestBuildJSON_ValidValue_ReturnsJSON(t *testing.T) {
// Normal path: marshalable value produces valid JSON.
out := ws.BuildJSONForTest(map[string]string{"type": "test"})
if !json.Valid(out) {
t.Errorf("BuildJSONForTest output is not valid JSON: %s", out)
}
}
// ─── buildReady error path (nil members fallback) ─────────────────────────────
func TestBuildReady_NoVoiceChannels_EmptyVoiceStates(t *testing.T) {
hub, database := newServeHub(t)
user := seedServeUser(t, database, "ready-novch")
// Create only a text channel — voice_states list must still be non-nil.
_, err := database.CreateChannel("text-chan", "text", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
msg, err := hub.BuildReadyForTest(database, user.ID)
if err != nil {
t.Fatalf("BuildReadyForTest: %v", err)
}
var env struct {
Payload struct {
VoiceStates []any `json:"voice_states"`
} `json:"payload"`
}
if err := json.Unmarshal(msg, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// collectAllVoiceStates returns []db.VoiceState{} (not nil) when no voice channels exist.
if env.Payload.VoiceStates == nil {
t.Error("voice_states must be a non-null JSON array even when empty")
}
}
+528
View File
@@ -0,0 +1,528 @@
package ws_test
// ws_integration_test.go covers ServeWS, authenticateConn, writePump, and
// readPump by spinning up a real httptest server and dialing it with the
// nhooyr.io/websocket client.
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"nhooyr.io/websocket"
"nhooyr.io/websocket/wsjson"
"github.com/owncord/server/auth"
"github.com/owncord/server/ws"
)
// dialAndAuth connects to the WS server and sends an auth message.
// Returns the connection on success, t.Fatal on error.
func dialAndAuth(t *testing.T, ctx context.Context, wsURL, token string) *websocket.Conn {
t.Helper()
conn, _, err := websocket.Dial(ctx, wsURL, nil)
if err != nil {
t.Fatalf("websocket.Dial: %v", err)
}
t.Cleanup(func() { _ = conn.Close(websocket.StatusNormalClosure, "") })
authMsg := map[string]any{
"type": "auth",
"payload": map[string]string{"token": token},
}
if err := wsjson.Write(ctx, conn, authMsg); err != nil {
t.Fatalf("write auth: %v", err)
}
return conn
}
// readNextMsg reads the next JSON message from conn.
func readNextMsg(t *testing.T, ctx context.Context, conn *websocket.Conn) map[string]any {
t.Helper()
var msg map[string]any
if err := wsjson.Read(ctx, conn, &msg); err != nil {
t.Fatalf("read message: %v", err)
}
return msg
}
// ─── ServeWS / authenticateConn happy path ────────────────────────────────────
// TestServeWS_InvalidUpgrade verifies that a plain HTTP GET (non-WS) returns
// a non-101 status without panicking.
func TestServeWS_InvalidUpgrade_ReturnsError(t *testing.T) {
database := openServeTestDB(t)
limiter := auth.NewRateLimiter()
hub := ws.NewHub(database, limiter)
go hub.Run()
defer hub.Stop()
handler := ws.ServeWS(hub, database, []string{"*"})
srv := httptest.NewServer(http.HandlerFunc(handler))
defer srv.Close()
// Plain GET without WebSocket upgrade headers should fail gracefully.
resp, err := http.Get(srv.URL)
if err != nil {
t.Fatalf("http.Get: %v", err)
}
defer resp.Body.Close()
// nhooyr.io/websocket returns 400 or 426 when upgrade is absent.
if resp.StatusCode == 200 {
t.Errorf("expected non-200 for plain HTTP, got %d", resp.StatusCode)
}
}
// ─── authenticateConn — error paths ──────────────────────────────────────────
// TestAuthenticateConn_NoAuthMessage verifies that a connection that closes
// immediately (without sending auth) causes the server to close it gracefully.
func TestAuthenticateConn_NoAuthMessage_ServerClosesConn(t *testing.T) {
database := openServeTestDB(t)
limiter := auth.NewRateLimiter()
hub := ws.NewHub(database, limiter)
go hub.Run()
defer hub.Stop()
handler := ws.ServeWS(hub, database, []string{"*"})
srv := httptest.NewServer(http.HandlerFunc(handler))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, _, err := websocket.Dial(ctx, wsURL, nil)
if err != nil {
t.Fatalf("websocket.Dial: %v", err)
}
// Close without sending auth — the server's authDeadline (10s) will fire,
// but closing immediately should cause a read error on the server side.
conn.Close(websocket.StatusNormalClosure, "no auth")
// Give the server a moment to react.
time.Sleep(50 * time.Millisecond)
// Hub should have no clients registered.
if hub.ClientCount() != 0 {
t.Errorf("ClientCount = %d after unauthenticated connection, want 0", hub.ClientCount())
}
}
// TestAuthenticateConn_InvalidJSON verifies that sending invalid JSON as the
// first message causes the server to send an auth_error and close.
func TestAuthenticateConn_InvalidJSON_ReceivesAuthError(t *testing.T) {
database := openServeTestDB(t)
limiter := auth.NewRateLimiter()
hub := ws.NewHub(database, limiter)
go hub.Run()
defer hub.Stop()
handler := ws.ServeWS(hub, database, []string{"*"})
srv := httptest.NewServer(http.HandlerFunc(handler))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, _, err := websocket.Dial(ctx, wsURL, nil)
if err != nil {
t.Fatalf("websocket.Dial: %v", err)
}
defer conn.Close(websocket.StatusNormalClosure, "")
// Send invalid JSON as first message.
if err := conn.Write(ctx, websocket.MessageText, []byte("NOT JSON")); err != nil {
t.Fatalf("write: %v", err)
}
// Server should respond with auth_error.
_, raw, readErr := conn.Read(ctx)
if readErr != nil {
// Server may close connection — also acceptable.
return
}
var msg map[string]any
if err := json.Unmarshal(raw, &msg); err == nil {
if msg["type"] == "auth_error" {
return // expected
}
t.Errorf("expected auth_error, got type=%q", msg["type"])
}
}
// TestAuthenticateConn_WrongMessageType verifies that sending a non-auth
// first message causes the server to send an auth_error.
func TestAuthenticateConn_WrongMessageType_ReceivesAuthError(t *testing.T) {
database := openServeTestDB(t)
limiter := auth.NewRateLimiter()
hub := ws.NewHub(database, limiter)
go hub.Run()
defer hub.Stop()
handler := ws.ServeWS(hub, database, []string{"*"})
srv := httptest.NewServer(http.HandlerFunc(handler))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, _, err := websocket.Dial(ctx, wsURL, nil)
if err != nil {
t.Fatalf("websocket.Dial: %v", err)
}
defer conn.Close(websocket.StatusNormalClosure, "")
// Send a chat_send instead of auth.
wrongMsg := map[string]any{
"type": "chat_send",
"payload": map[string]string{"content": "hello"},
}
raw, _ := json.Marshal(wrongMsg)
if err := conn.Write(ctx, websocket.MessageText, raw); err != nil {
t.Fatalf("write: %v", err)
}
_, respRaw, readErr := conn.Read(ctx)
if readErr != nil {
return // server closed — acceptable
}
var msg map[string]any
if err := json.Unmarshal(respRaw, &msg); err == nil {
if msg["type"] == "auth_error" {
return // expected
}
t.Errorf("expected auth_error, got type=%q", msg["type"])
}
}
// TestAuthenticateConn_MissingToken verifies that an auth message without
// a token field receives an auth_error.
func TestAuthenticateConn_MissingToken_ReceivesAuthError(t *testing.T) {
database := openServeTestDB(t)
limiter := auth.NewRateLimiter()
hub := ws.NewHub(database, limiter)
go hub.Run()
defer hub.Stop()
handler := ws.ServeWS(hub, database, []string{"*"})
srv := httptest.NewServer(http.HandlerFunc(handler))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, _, err := websocket.Dial(ctx, wsURL, nil)
if err != nil {
t.Fatalf("websocket.Dial: %v", err)
}
defer conn.Close(websocket.StatusNormalClosure, "")
authMsg := map[string]any{
"type": "auth",
"payload": map[string]string{}, // no token field
}
raw, _ := json.Marshal(authMsg)
if err := conn.Write(ctx, websocket.MessageText, raw); err != nil {
t.Fatalf("write: %v", err)
}
_, respRaw, readErr := conn.Read(ctx)
if readErr != nil {
return
}
var msg map[string]any
if err := json.Unmarshal(respRaw, &msg); err == nil {
if msg["type"] == "auth_error" {
return
}
t.Errorf("expected auth_error, got type=%q", msg["type"])
}
}
// TestAuthenticateConn_InvalidToken verifies that an auth message with a
// non-existent token receives an auth_error.
func TestAuthenticateConn_InvalidToken_ReceivesAuthError(t *testing.T) {
database := openServeTestDB(t)
limiter := auth.NewRateLimiter()
hub := ws.NewHub(database, limiter)
go hub.Run()
defer hub.Stop()
handler := ws.ServeWS(hub, database, []string{"*"})
srv := httptest.NewServer(http.HandlerFunc(handler))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, _, err := websocket.Dial(ctx, wsURL, nil)
if err != nil {
t.Fatalf("websocket.Dial: %v", err)
}
defer conn.Close(websocket.StatusNormalClosure, "")
authMsg := map[string]any{
"type": "auth",
"payload": map[string]string{"token": "totally-invalid-token-xyz"},
}
raw, _ := json.Marshal(authMsg)
if err := conn.Write(ctx, websocket.MessageText, raw); err != nil {
t.Fatalf("write: %v", err)
}
_, respRaw, readErr := conn.Read(ctx)
if readErr != nil {
return
}
var msg map[string]any
if err := json.Unmarshal(respRaw, &msg); err == nil {
if msg["type"] == "auth_error" {
return
}
t.Errorf("expected auth_error, got type=%q", msg["type"])
}
}
// TestServeWS_ValidAuth_FullHandshake verifies the complete happy path:
// valid token → auth_ok + ready received, client counted in hub.
func TestServeWS_ValidAuth_FullHandshake(t *testing.T) {
database := openServeTestDB(t)
limiter := auth.NewRateLimiter()
hub := ws.NewHub(database, limiter)
go hub.Run()
defer hub.Stop()
// Seed user and session.
userID, err := database.CreateUser("ws-handshake-user", "hash", 1)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
token, err := auth.GenerateToken()
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
tokenHash := auth.HashToken(token)
if _, err := database.CreateSession(userID, tokenHash, "test", "127.0.0.1"); err != nil {
t.Fatalf("CreateSession: %v", err)
}
handler := ws.ServeWS(hub, database, []string{"*"})
srv := httptest.NewServer(http.HandlerFunc(handler))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
conn, _, err := websocket.Dial(ctx, wsURL, nil)
if err != nil {
t.Fatalf("websocket.Dial: %v", err)
}
defer conn.Close(websocket.StatusNormalClosure, "")
// Send auth.
authMsg := map[string]any{
"type": "auth",
"payload": map[string]string{"token": token},
}
raw, _ := json.Marshal(authMsg)
if err := conn.Write(ctx, websocket.MessageText, raw); err != nil {
t.Fatalf("write auth: %v", err)
}
// Expect auth_ok.
_, respRaw, err := conn.Read(ctx)
if err != nil {
t.Fatalf("read auth_ok: %v", err)
}
var authOK map[string]any
if err := json.Unmarshal(respRaw, &authOK); err != nil {
t.Fatalf("unmarshal auth_ok: %v", err)
}
if authOK["type"] != "auth_ok" {
t.Errorf("first response type = %q, want auth_ok", authOK["type"])
}
// Expect ready.
_, respRaw2, err := conn.Read(ctx)
if err != nil {
t.Fatalf("read ready: %v", err)
}
var readyMsg map[string]any
if err := json.Unmarshal(respRaw2, &readyMsg); err != nil {
t.Fatalf("unmarshal ready: %v", err)
}
if readyMsg["type"] != "ready" {
t.Errorf("second response type = %q, want ready", readyMsg["type"])
}
// Give hub a moment to register the client.
time.Sleep(30 * time.Millisecond)
if hub.ClientCount() != 1 {
t.Errorf("ClientCount = %d after successful auth, want 1", hub.ClientCount())
}
}
// TestServeWS_writePump_MessageDelivered verifies that messages queued on the
// hub are written through writePump to the connected client.
func TestServeWS_writePump_MessageDelivered(t *testing.T) {
database := openServeTestDB(t)
limiter := auth.NewRateLimiter()
hub := ws.NewHub(database, limiter)
go hub.Run()
defer hub.Stop()
// Seed user and session.
userID, err := database.CreateUser("ws-pump-user", "hash", 1)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
token, err := auth.GenerateToken()
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
tokenHash := auth.HashToken(token)
if _, err := database.CreateSession(userID, tokenHash, "test", "127.0.0.1"); err != nil {
t.Fatalf("CreateSession: %v", err)
}
handler := ws.ServeWS(hub, database, []string{"*"})
srv := httptest.NewServer(http.HandlerFunc(handler))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
conn, _, err := websocket.Dial(ctx, wsURL, nil)
if err != nil {
t.Fatalf("websocket.Dial: %v", err)
}
defer conn.Close(websocket.StatusNormalClosure, "")
// Authenticate.
authMsg := map[string]any{
"type": "auth",
"payload": map[string]string{"token": token},
}
raw, _ := json.Marshal(authMsg)
_ = conn.Write(ctx, websocket.MessageText, raw)
// Drain auth_ok and ready.
for i := 0; i < 2; i++ {
_, _, err := conn.Read(ctx)
if err != nil {
t.Fatalf("drain initial messages: %v", err)
}
}
// Wait for client to be registered and then broadcast a server_restart.
time.Sleep(50 * time.Millisecond)
hub.BroadcastServerRestart("test", 0)
// The client should receive the broadcast via writePump.
readCtx, readCancel := context.WithTimeout(ctx, 2*time.Second)
defer readCancel()
_, broadcastRaw, err := conn.Read(readCtx)
if err != nil {
t.Fatalf("read broadcast: %v", err)
}
var bcast map[string]any
if err := json.Unmarshal(broadcastRaw, &bcast); err != nil {
t.Fatalf("unmarshal broadcast: %v", err)
}
// May receive member_join or presence first; drain until server_restart found.
found := bcast["type"] == "server_restart"
if !found {
// Drain a few more messages.
for i := 0; i < 5 && !found; i++ {
rCtx, rCancel := context.WithTimeout(ctx, 500*time.Millisecond)
_, raw2, err2 := conn.Read(rCtx)
rCancel()
if err2 != nil {
break
}
var m map[string]any
if json.Unmarshal(raw2, &m) == nil && m["type"] == "server_restart" {
found = true
}
}
}
if !found {
t.Error("did not receive server_restart broadcast via writePump")
}
}
// TestServeWS_BannedUser_ReceivesError verifies that a banned user cannot connect.
func TestServeWS_BannedUser_ReceivesError(t *testing.T) {
database := openServeTestDB(t)
limiter := auth.NewRateLimiter()
hub := ws.NewHub(database, limiter)
go hub.Run()
defer hub.Stop()
// Seed user, then ban them.
userID, err := database.CreateUser("ws-banned-user", "hash", 1)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
token, err := auth.GenerateToken()
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
tokenHash := auth.HashToken(token)
if _, err := database.CreateSession(userID, tokenHash, "test", "127.0.0.1"); err != nil {
t.Fatalf("CreateSession: %v", err)
}
// Ban the user permanently.
if err := database.BanUser(userID, "test ban", nil); err != nil {
t.Fatalf("BanUser: %v", err)
}
handler := ws.ServeWS(hub, database, []string{"*"})
srv := httptest.NewServer(http.HandlerFunc(handler))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, _, err := websocket.Dial(ctx, wsURL, nil)
if err != nil {
t.Fatalf("websocket.Dial: %v", err)
}
defer conn.Close(websocket.StatusNormalClosure, "")
authMsg := map[string]any{
"type": "auth",
"payload": map[string]string{"token": token},
}
raw, _ := json.Marshal(authMsg)
if err := conn.Write(ctx, websocket.MessageText, raw); err != nil {
t.Fatalf("write auth: %v", err)
}
_, respRaw, readErr := conn.Read(ctx)
if readErr != nil {
return // server closed connection — acceptable
}
var msg map[string]any
if err := json.Unmarshal(respRaw, &msg); err == nil {
msgType, _ := msg["type"].(string)
if msgType == "auth_ok" {
t.Error("banned user should not receive auth_ok")
}
}
}
-171
View File
@@ -1,171 +0,0 @@
# TODOS
Items deferred from CEO plan review of `tauri-migration` branch
(2026-03-16). Ordered by priority.
## P1 — Must fix soon
### ~~1. Attachment permission ordering bug~~ DONE
Moved `ATTACH_FILES` permission check before `CreateMessage()`
in `Server/ws/handlers.go`. Added test
`TestChatSend_AttachmentsDeniedNoMessageCreated`.
---
### ~~2. Hardcoded `/api/files/` URL~~ DONE
Changed to `/api/v1/files/` in
`Server/db/attachment_queries.go:93`.
---
### ~~3. Missing `onUnauthorized` handler~~ DONE
Wired `api.onUnauthorized` callback at creation in `main.ts`
to call `clearAuth()`, which triggers navigation back to
connect page via existing authStore subscription.
---
## P2 — Should fix next
### ~~4. Silent API failure toasts~~ DONE
Added `ToastContainer` to `MainPage.ts`. Wired toast to 5
catch blocks: `loadMessages`, `loadOlderMessages`,
`openInviteManager`, `togglePinnedPanel`, and the connectivity
guard on message send.
---
### ~~5. Message send connectivity guard + debounce~~ DONE
Added `ws.getState() !== "connected"` guard in `MainPage.ts`
`onSend` callback with toast feedback. Added 200ms send
debounce in `MessageInput.ts` to prevent double-click
duplicates.
---
### ~~6. WebSocket frame size limit on server~~ DONE
Added `conn.SetReadLimit(1 << 20)` (1MB) in
`Server/ws/serve.go` after WebSocket accept.
---
### ~~7. Wrap dispatcher store operations in try/catch~~ N/A
Already handled: `ws.ts` dispatch function wraps every
listener call in try/catch with `log.error`. No additional
wrapping needed in `dispatcher.ts`.
---
### ~~8. `GetAttachmentsByMessageIDs` error silently swallowed~~ DONE
Added `slog.Error("ws handleChatSend GetAttachments", ...)` in
`Server/ws/handlers.go` inside the error check.
---
## P3 — Tech debt / polish
### ~~9. Split oversized files~~ DONE
Split all three targets:
- `Server/admin/api.go` (788→281 lines) into
`handlers_users.go`, `handlers_channels.go`,
`handlers_settings.go`, `handlers_backup.go`
- `Client/tauri-client/src/components/SettingsOverlay.ts`
(~685→173 lines) into 7 per-tab modules under
`components/settings/`
- `Client/tauri-client/src/pages/MainPage.ts` (703→508
lines) into `pages/main-page/ChatHeader.ts` and
`pages/main-page/OverlayManagers.ts`
---
### ~~10. Extract permission check helper (server DRY)~~ DONE
Created `requireChannelPerm(c, channelID, perm, permLabel)`
helper in `handlers.go`. Replaced 8 instances across
`handlers.go` and `voice_handlers.go`.
---
### ~~11. Virtual scrolling for MessageList~~ DONE
Implemented DOM windowing in `MessageList.ts`. Only visible
messages plus 10-item overscan buffer are in the DOM.
Uses estimated heights (52px) with measured-height cache,
top/bottom spacer elements, and `requestAnimationFrame`
debounced scroll updates. Rendering helpers extracted to
`components/message-list/renderers.ts`.
---
### ~~12. WS message render batching~~ DONE
Added `queueMicrotask`-based notification batching to
`createStore` in `store.ts`. Multiple rapid `setState`
calls now coalesce into a single subscriber notification
with the final state. Added `flush()` method for
synchronous test assertions.
---
### ~~13. E2E test improvement plan (Phases 4-6)~~ DONE
Completed all remaining E2E improvement phases:
- Phase 4: Strengthened assertions in server-strip,
main-layout, user-bar, message-input specs. Fixed
"presence_update" test title in member-list.spec.ts.
- Phase 5: Replaced skipped toast.spec.ts with 5 real
tests (load failure, auto-dismiss, container check,
message display, stacking). Added
mockTauriFullSessionWithFailingMessages helper.
- Phase 6: Migrated 12 spec files to data-testid selectors
for all primary elements.
---
## CLIENT-REVIEW.md findings
### ~~Auth token never set in authStore~~ DONE
Fixed in `main.ts:wirePostAuth` — store token in authStore
before WS connect so dispatcher's `auth_ok` handler has it.
---
### ~~WS connect hangs in "connecting" state~~ DONE
Fixed in `ws.ts` — set state to "disconnected" when Tauri
APIs are unavailable.
---
### ~~Server-driven voice disconnect doesn't clear currentChannelId~~ DONE
Fixed in `dispatcher.ts``voice_leave` handler now calls
`leaveVoiceChannel()` when the current user is removed.
---
### ~~Theme/font not applied on app start~~ DONE
Extracted `applyStoredAppearance()` from `SettingsOverlay.ts`
and call it at app startup in `main.ts`.
---
### ~~Infinite scroll throttle~~ DONE
Fixed in `MessageList.ts` — replaced fixed 500ms timeout
with store subscription that resets `loadingOlder` when
message count changes. Also checks `hasMoreMessages` before
triggering scroll load.
-30
View File
@@ -1,30 +0,0 @@
# Port Forwarding Guide
## Why
Friends outside your LAN need a way to reach your server. Port forwarding tells your router to send incoming traffic on a specific port to your server machine.
## Steps
1. **Find your router's admin page** — usually `192.168.1.1` or `192.168.0.1`. Check your gateway IP with `ipconfig` (Windows) or `ip route` (Linux).
2. **Find the port forwarding section** — may be listed under "NAT", "Virtual Servers", or "Firewall" depending on your router.
3. **Add a rule for the server:**
- External port: `8443`
- Internal IP: your server machine's local IP
- Internal port: `8443`
- Protocol: TCP
4. **Add a rule for voice chat** (if using voice/video):
- External port: `3478`
- Internal IP: your server machine's local IP
- Internal port: `3478`
- Protocol: UDP
5. **Find your public IP** at a site like `whatismyip.com`.
6. **Share your public IP and port** with friends: `your.public.ip:8443`
## Troubleshooting
Windows Firewall may block incoming connections. `chatserver.exe` should prompt on first run to allow access. If not, manually add a firewall rule for port 8443 (TCP) and 3478 (UDP).
## Dynamic IP
If your public IP changes frequently, consider a Dynamic DNS service (e.g., No-IP, DuckDNS) so friends can use a stable hostname instead of a raw IP address.
-36
View File
@@ -1,36 +0,0 @@
# Quick Start Guide
## Step 1: Download
Get the latest release from GitHub Releases.
Download `chatserver.exe` and the `OwnCord`
installer.
## Step 2: Run the Server
Run `chatserver.exe`. On first run it generates
`config.yaml` and a self-signed TLS certificate.
The server starts on `https://0.0.0.0:8443`.
## Step 3: Admin Setup
Open `https://localhost:8443/admin` in a browser.
The first registered user with the Owner role can
manage the server.
## Step 4: Create Invites
In the admin panel, go to invite management and
generate invite codes for your friends.
## Step 5: Connect Clients
Friends install OwnCord, enter your server address
(IP or domain + port 8443), and redeem their invite
code to register.
## Networking
If friends are outside your local network, see the
[Port Forwarding Guide](port-forwarding.md) or use
[Tailscale](tailscale.md) for zero-config networking.
-21
View File
@@ -1,21 +0,0 @@
# Tailscale Guide (Zero-Config Alternative)
## What is Tailscale
Tailscale is a mesh VPN that creates encrypted tunnels between your devices using WireGuard. No port forwarding, no dynamic DNS, and it works behind CGNAT. Free for personal use.
## Setup
1. **Install Tailscale** on the server machine and each client machine: https://tailscale.com/download
2. **Sign in** with the same Tailscale account (or share the machine using Tailscale's sharing feature)
3. **Find the server's Tailscale IP** — shown in the Tailscale app, typically `100.x.y.z`
4. **Disable TLS in config** — set `tls.mode` to `"off"` in `config.yaml` since Tailscale already encrypts all traffic with WireGuard
5. **Connect clients** using the Tailscale IP: `100.x.y.z:8443`
## Benefits
- No port forwarding needed
- Works behind CGNAT and strict firewalls
- Encrypted by default (WireGuard)
- Stable IPs that don't change
- Easy to add/remove friends via the Tailscale admin console