mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
+1
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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 (2–3 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 (2–3 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 (3–4 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 (2–3 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 (3–5 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 (1–2 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 (1–2 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` |
|
||||
@@ -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)
|
||||
Generated
+1
@@ -2494,6 +2494,7 @@ name = "owncord-client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"ring",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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,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
@@ -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.
|
||||
@@ -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
@@ -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)
|
||||
@@ -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 |
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user