test: add server core test coverage (Session 1)

New test files:
- db/errors_test.go: sentinel error identity, wrapping, IsUniqueConstraintError (12 tests)
- db/models_test.go: JSON round-trip and tag verification for all model types (14 tests)
- db/account_test.go: DeleteAccount last-admin guard, anonymisation, cascade cleanup (12 tests)
- api/totp_handler_test.go: TOTP verify/enable/confirm/disable handler flows (20 tests)

Upgraded existing:
- permissions/permissions_test.go: multi-bit checks, role hierarchy, deny-all+allow-one (8 tests)
- permissions/checker_test.go: admin DM bypass, voice channel perms, multi-bit combined (4 tests)

Total: 70 new tests across 6 files.
This commit is contained in:
jevb
2026-04-01 08:41:09 +02:00
parent 30fd7fd880
commit d87dabeb65
6 changed files with 1268 additions and 0 deletions
+442
View File
@@ -0,0 +1,442 @@
package api_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/owncord/server/auth"
)
// ─── POST /api/v1/auth/verify-totp ──────────────────────────────────────────
func TestVerifyTOTP_Success(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
// Create user with TOTP enabled.
secret, _ := auth.GenerateTOTPSecret()
hash, _ := auth.HashPassword("Password1!")
uid, _ := database.CreateUser("totpuser", hash, 4)
_ = database.UpdateUserTOTPSecret(uid, &secret)
// Login should return requires_2fa + partial_token.
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
"username": "totpuser",
"password": "Password1!",
})
if rr.Code != http.StatusOK {
t.Fatalf("login status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
var loginResp map[string]interface{}
_ = json.NewDecoder(rr.Body).Decode(&loginResp)
if loginResp["requires_2fa"] != true {
t.Fatal("expected requires_2fa=true in login response")
}
partialToken, ok := loginResp["partial_token"].(string)
if !ok || partialToken == "" {
t.Fatal("expected non-empty partial_token")
}
// Generate valid TOTP code and verify.
code, err := auth.GenerateTOTPCode(secret, time.Now().UTC())
if err != nil {
t.Fatalf("GenerateTOTPCode: %v", err)
}
rr = postJSONWithToken(t, router, "/api/v1/auth/verify-totp", partialToken,
map[string]string{"code": code})
if rr.Code != http.StatusOK {
t.Errorf("verify-totp status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
var verifyResp map[string]interface{}
_ = json.NewDecoder(rr.Body).Decode(&verifyResp)
if verifyResp["token"] == nil {
t.Error("verify-totp response missing session token")
}
}
func TestVerifyTOTP_InvalidCode(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
secret, _ := auth.GenerateTOTPSecret()
hash, _ := auth.HashPassword("Password1!")
uid, _ := database.CreateUser("totpuser2", hash, 4)
_ = database.UpdateUserTOTPSecret(uid, &secret)
// Login to get partial token.
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
"username": "totpuser2",
"password": "Password1!",
})
var loginResp map[string]interface{}
_ = json.NewDecoder(rr.Body).Decode(&loginResp)
partialToken := loginResp["partial_token"].(string)
// Submit wrong code.
rr = postJSONWithToken(t, router, "/api/v1/auth/verify-totp", partialToken,
map[string]string{"code": "000000"})
if rr.Code != http.StatusUnauthorized {
t.Errorf("verify-totp with bad code: status = %d, want 401", rr.Code)
}
}
func TestVerifyTOTP_MissingToken(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
rr := postJSON(t, router, "/api/v1/auth/verify-totp",
map[string]string{"code": "123456"})
if rr.Code != http.StatusUnauthorized {
t.Errorf("verify-totp without token: status = %d, want 401", rr.Code)
}
}
func TestVerifyTOTP_InvalidPartialToken(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
rr := postJSONWithToken(t, router, "/api/v1/auth/verify-totp", "bogus-token",
map[string]string{"code": "123456"})
if rr.Code != http.StatusUnauthorized {
t.Errorf("verify-totp with bogus token: status = %d, want 401", rr.Code)
}
}
func TestVerifyTOTP_MalformedBody(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
// Need a valid partial token to get past the token check.
secret, _ := auth.GenerateTOTPSecret()
hash, _ := auth.HashPassword("Password1!")
uid, _ := database.CreateUser("totpuser3", hash, 4)
_ = database.UpdateUserTOTPSecret(uid, &secret)
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
"username": "totpuser3",
"password": "Password1!",
})
var loginResp map[string]interface{}
_ = json.NewDecoder(rr.Body).Decode(&loginResp)
partialToken := loginResp["partial_token"].(string)
// Send invalid JSON.
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/verify-totp",
bytes.NewReader([]byte("{invalid")))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+partialToken)
req.RemoteAddr = "127.0.0.1:9999"
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("verify-totp with malformed body: status = %d, want 400", rec.Code)
}
}
func TestVerifyTOTP_ReplayProtection(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
secret, _ := auth.GenerateTOTPSecret()
hash, _ := auth.HashPassword("Password1!")
uid, _ := database.CreateUser("totpuser4", hash, 4)
_ = database.UpdateUserTOTPSecret(uid, &secret)
code, _ := auth.GenerateTOTPCode(secret, time.Now().UTC())
// First login + verify — should succeed.
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
"username": "totpuser4",
"password": "Password1!",
})
var resp1 map[string]interface{}
_ = json.NewDecoder(rr.Body).Decode(&resp1)
token1 := resp1["partial_token"].(string)
rr = postJSONWithToken(t, router, "/api/v1/auth/verify-totp", token1,
map[string]string{"code": code})
if rr.Code != http.StatusOK {
t.Fatalf("first verify: status = %d, want 200", rr.Code)
}
// Second login + verify with same code — should fail (consumed token).
rr = postJSON(t, router, "/api/v1/auth/login", map[string]string{
"username": "totpuser4",
"password": "Password1!",
})
var resp2 map[string]interface{}
_ = json.NewDecoder(rr.Body).Decode(&resp2)
token2 := resp2["partial_token"].(string)
rr = postJSONWithToken(t, router, "/api/v1/auth/verify-totp", token2,
map[string]string{"code": code})
// Code was already used in UsedTOTPCodeStore, so it should be rejected.
if rr.Code != http.StatusUnauthorized {
t.Errorf("replay code: status = %d, want 401", rr.Code)
}
}
// ─── POST /api/v1/users/me/totp/enable ──────────────────────────────────────
func TestEnableTOTP_Success(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
token := loginAndGetToken(t, router, database, "enableuser", 4)
rr := postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", token,
map[string]string{"password": "Password1!"})
if rr.Code != http.StatusOK {
t.Errorf("enable-totp status = %d, want 200; body = %s", rr.Code, rr.Body.String())
}
var resp map[string]interface{}
_ = json.NewDecoder(rr.Body).Decode(&resp)
if resp["qr_uri"] == nil || resp["qr_uri"] == "" {
t.Error("enable-totp response missing qr_uri")
}
}
func TestEnableTOTP_WrongPassword(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
token := loginAndGetToken(t, router, database, "enableuser2", 4)
rr := postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", token,
map[string]string{"password": "wrongpassword"})
if rr.Code != http.StatusBadRequest {
t.Errorf("enable-totp with wrong password: status = %d, want 400", rr.Code)
}
}
func TestEnableTOTP_Unauthenticated(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
rr := postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", "badtoken",
map[string]string{"password": "Password1!"})
if rr.Code != http.StatusUnauthorized {
t.Errorf("enable-totp unauthenticated: status = %d, want 401", rr.Code)
}
}
// ─── POST /api/v1/users/me/totp/confirm ─────────────────────────────────────
func TestConfirmTOTP_Success(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
token := loginAndGetToken(t, router, database, "confirmuser", 4)
// Step 1: Enable to get pending secret.
rr := postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", token,
map[string]string{"password": "Password1!"})
if rr.Code != http.StatusOK {
t.Fatalf("enable: status = %d; body = %s", rr.Code, rr.Body.String())
}
var enableResp map[string]interface{}
_ = json.NewDecoder(rr.Body).Decode(&enableResp)
qrURI, _ := enableResp["qr_uri"].(string)
// Extract secret from QR URI (otpauth://totp/...?secret=XXX&...)
secret := extractSecretFromURI(t, qrURI)
// Step 2: Generate valid code and confirm.
code, _ := auth.GenerateTOTPCode(secret, time.Now().UTC())
rr = postJSONWithToken(t, router, "/api/v1/users/me/totp/confirm", token,
map[string]string{"password": "Password1!", "code": code})
if rr.Code != http.StatusNoContent {
t.Errorf("confirm-totp: status = %d, want 204; body = %s", rr.Code, rr.Body.String())
}
// Verify TOTP is now stored on user.
user, _ := database.GetUserByUsername("confirmuser")
if user == nil {
t.Fatal("user not found after confirm")
}
if user.TOTPSecret == nil {
t.Error("expected TOTPSecret to be set after confirm")
}
}
func TestConfirmTOTP_InvalidCode_Handler(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
token := loginAndGetToken(t, router, database, "confirmuser2", 4)
// Enable first.
postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", token,
map[string]string{"password": "Password1!"})
// Confirm with wrong code.
rr := postJSONWithToken(t, router, "/api/v1/users/me/totp/confirm", token,
map[string]string{"password": "Password1!", "code": "000000"})
if rr.Code != http.StatusUnauthorized {
t.Errorf("confirm-totp with bad code: status = %d, want 401", rr.Code)
}
}
func TestConfirmTOTP_NoPendingEnrollment(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
token := loginAndGetToken(t, router, database, "confirmuser3", 4)
// Try to confirm without enable first.
rr := postJSONWithToken(t, router, "/api/v1/users/me/totp/confirm", token,
map[string]string{"password": "Password1!", "code": "123456"})
if rr.Code != http.StatusBadRequest {
t.Errorf("confirm-totp without enable: status = %d, want 400", rr.Code)
}
}
func TestConfirmTOTP_WrongPassword_Handler(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
token := loginAndGetToken(t, router, database, "confirmuser4", 4)
postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", token,
map[string]string{"password": "Password1!"})
rr := postJSONWithToken(t, router, "/api/v1/users/me/totp/confirm", token,
map[string]string{"password": "wrong", "code": "123456"})
if rr.Code != http.StatusBadRequest {
t.Errorf("confirm-totp wrong password: status = %d, want 400", rr.Code)
}
}
// ─── DELETE /api/v1/users/me/totp ────────────────────────────────────────────
func TestDisableTOTP_Success(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
token := loginAndGetToken(t, router, database, "disableuser", 4)
// Enable and confirm TOTP first.
rr := postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", token,
map[string]string{"password": "Password1!"})
var enableResp map[string]interface{}
_ = json.NewDecoder(rr.Body).Decode(&enableResp)
secret := extractSecretFromURI(t, enableResp["qr_uri"].(string))
code, _ := auth.GenerateTOTPCode(secret, time.Now().UTC())
postJSONWithToken(t, router, "/api/v1/users/me/totp/confirm", token,
map[string]string{"password": "Password1!", "code": code})
// Now disable.
rr = deleteWithToken(t, router, "/api/v1/users/me/totp", token,
map[string]string{"password": "Password1!"})
if rr.Code != http.StatusNoContent {
t.Errorf("disable-totp: status = %d, want 204; body = %s", rr.Code, rr.Body.String())
}
}
func TestDisableTOTP_WrongPassword_Handler(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
token := loginAndGetToken(t, router, database, "disableuser2", 4)
rr := deleteWithToken(t, router, "/api/v1/users/me/totp", token,
map[string]string{"password": "wrong"})
if rr.Code != http.StatusBadRequest {
t.Errorf("disable-totp wrong password: status = %d, want 400", rr.Code)
}
}
func TestDisableTOTP_Unauthenticated(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
rr := deleteWithToken(t, router, "/api/v1/users/me/totp", "badtoken",
map[string]string{"password": "Password1!"})
if rr.Code != http.StatusUnauthorized {
t.Errorf("disable-totp unauthenticated: status = %d, want 401", rr.Code)
}
}
func TestDisableTOTP_BlockedByServerPolicy(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
token := loginAndGetToken(t, router, database, "disableuser3", 4)
// Enable require_2fa server policy.
_, _ = database.Exec(`INSERT OR REPLACE INTO settings (key, value) VALUES ('require_2fa', '1')`)
rr := deleteWithToken(t, router, "/api/v1/users/me/totp", token,
map[string]string{"password": "Password1!"})
if rr.Code != http.StatusForbidden {
t.Errorf("disable-totp with require_2fa: status = %d, want 403; body = %s", rr.Code, rr.Body.String())
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
// deleteWithToken sends a DELETE request with a JSON body and auth token.
func deleteWithToken(t *testing.T, router http.Handler, path, token string, body any) *httptest.ResponseRecorder {
t.Helper()
raw, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodDelete, path, bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "127.0.0.1:9999"
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
// extractSecretFromURI parses a TOTP otpauth:// URI and returns the secret parameter.
func extractSecretFromURI(t *testing.T, uri string) string {
t.Helper()
// URI format: otpauth://totp/OwnCord:username?secret=XXX&issuer=OwnCord
// Simple parse — find "secret=" and extract until next '&' or end.
const marker = "secret="
idx := 0
for i := 0; i+len(marker) <= len(uri); i++ {
if uri[i:i+len(marker)] == marker {
idx = i + len(marker)
break
}
}
if idx == 0 {
t.Fatalf("no secret= found in URI: %s", uri)
}
end := len(uri)
for i := idx; i < len(uri); i++ {
if uri[i] == '&' {
end = i
break
}
}
return uri[idx:end]
}
+200
View File
@@ -0,0 +1,200 @@
package db_test
import (
"context"
"errors"
"fmt"
"testing"
"github.com/owncord/server/db"
)
// ─── DeleteAccount — last admin guard ────────────────────────────────────────
func TestDeleteAccount_LastOwnerBlocked(t *testing.T) {
database := openMigratedMemory(t)
// Create a single owner (role_id=1). No other admins exist.
ownerID := seedUser(t, database, "owner")
setRole(t, database, ownerID, 1) // Owner
err := database.DeleteAccount(context.Background(), ownerID)
if !errors.Is(err, db.ErrLastAdmin) {
t.Errorf("DeleteAccount(last owner) = %v, want ErrLastAdmin", err)
}
}
func TestDeleteAccount_LastAdminBlocked(t *testing.T) {
database := openMigratedMemory(t)
adminID := seedUser(t, database, "admin")
setRole(t, database, adminID, 2) // Admin
err := database.DeleteAccount(context.Background(), adminID)
if !errors.Is(err, db.ErrLastAdmin) {
t.Errorf("DeleteAccount(last admin) = %v, want ErrLastAdmin", err)
}
}
func TestDeleteAccount_AllowedWhenOtherAdminExists(t *testing.T) {
database := openMigratedMemory(t)
admin1 := seedUser(t, database, "admin1")
admin2 := seedUser(t, database, "admin2")
setRole(t, database, admin1, 2) // Admin
setRole(t, database, admin2, 2) // Admin
err := database.DeleteAccount(context.Background(), admin1)
if err != nil {
t.Fatalf("DeleteAccount with another admin present: %v", err)
}
}
func TestDeleteAccount_AdminAllowedWhenOwnerExists(t *testing.T) {
database := openMigratedMemory(t)
ownerID := seedUser(t, database, "owner")
adminID := seedUser(t, database, "admin")
setRole(t, database, ownerID, 1) // Owner
setRole(t, database, adminID, 2) // Admin
// Admin can delete because owner still exists.
err := database.DeleteAccount(context.Background(), adminID)
if err != nil {
t.Fatalf("DeleteAccount(admin with owner present): %v", err)
}
}
// ─── DeleteAccount — member deletion ─────────────────────────────────────────
func TestDeleteAccount_MemberSucceeds(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "alice") // default role_id=4
err := database.DeleteAccount(context.Background(), userID)
if err != nil {
t.Fatalf("DeleteAccount(member): %v", err)
}
}
// ─── DeleteAccount — anonymisation ───────────────────────────────────────────
func TestDeleteAccount_AnonymisesUsername(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "alice")
if err := database.DeleteAccount(context.Background(), userID); err != nil {
t.Fatalf("DeleteAccount: %v", err)
}
user, err := database.GetUserByID(userID)
if err != nil {
t.Fatalf("GetUserByID after delete: %v", err)
}
expected := fmt.Sprintf("[deleted-%d]", userID)
if user.Username != expected {
t.Errorf("Username = %q, want %q", user.Username, expected)
}
}
func TestDeleteAccount_ClearsPassword(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "bob")
database.DeleteAccount(context.Background(), userID) //nolint:errcheck
user, _ := database.GetUserByID(userID)
if user.PasswordHash != "" {
t.Errorf("PasswordHash = %q, want empty", user.PasswordHash)
}
}
func TestDeleteAccount_ClearsAvatarAndTOTP(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "charlie")
// Set avatar and TOTP before deletion.
database.Exec("UPDATE users SET avatar = 'pic.png', totp_secret = 'SECRET' WHERE id = ?", userID) //nolint:errcheck
database.DeleteAccount(context.Background(), userID) //nolint:errcheck
user, _ := database.GetUserByID(userID)
if user.Avatar != nil {
t.Errorf("Avatar = %v, want nil", user.Avatar)
}
if user.TOTPSecret != nil {
t.Errorf("TOTPSecret = %v, want nil", user.TOTPSecret)
}
}
func TestDeleteAccount_SetsBannedAndOffline(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "dave")
database.DeleteAccount(context.Background(), userID) //nolint:errcheck
user, _ := database.GetUserByID(userID)
if !user.Banned {
t.Error("Banned should be true after deletion")
}
if user.Status != "offline" {
t.Errorf("Status = %q, want 'offline'", user.Status)
}
}
// ─── DeleteAccount — related data cleanup ────────────────────────────────────
func TestDeleteAccount_DeletesSessions(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "eve")
// Insert a session directly.
database.Exec(
"INSERT INTO sessions (user_id, token, expires_at) VALUES (?, 'tok123', datetime('now', '+1 day'))",
userID,
) //nolint:errcheck
database.DeleteAccount(context.Background(), userID) //nolint:errcheck
var count int
database.QueryRow("SELECT COUNT(*) FROM sessions WHERE user_id = ?", userID).Scan(&count) //nolint:errcheck
if count != 0 {
t.Errorf("sessions count = %d, want 0", count)
}
}
func TestDeleteAccount_SoftDeletesMessages(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "frank")
chID := seedChannel(t, database, "general")
msgID, _ := database.CreateMessage(chID, userID, "hello world", nil)
database.DeleteAccount(context.Background(), userID) //nolint:errcheck
msg, err := database.GetMessage(msgID)
if err != nil {
t.Fatalf("GetMessage after delete: %v", err)
}
if !msg.Deleted {
t.Error("message should be soft-deleted")
}
if msg.Content != "" {
t.Errorf("message content = %q, want empty", msg.Content)
}
}
func TestDeleteAccount_NonexistentUser(t *testing.T) {
database := openMigratedMemory(t)
err := database.DeleteAccount(context.Background(), 999999)
if err == nil {
t.Error("DeleteAccount(nonexistent) should return error")
}
}
// ─── Helper ──────────────────────────────────────────────────────────────────
func setRole(t *testing.T, database *db.DB, userID, roleID int64) {
t.Helper()
if _, err := database.Exec("UPDATE users SET role_id = ? WHERE id = ?", roleID, userID); err != nil {
t.Fatalf("setRole(%d, %d): %v", userID, roleID, err)
}
}
+141
View File
@@ -0,0 +1,141 @@
package db_test
import (
"errors"
"fmt"
"testing"
"github.com/owncord/server/db"
)
// ─── Sentinel error identity ─────────────────────────────────────────────────
func TestSentinelErrors_AreDistinct(t *testing.T) {
sentinels := []struct {
name string
err error
}{
{"ErrNotFound", db.ErrNotFound},
{"ErrForbidden", db.ErrForbidden},
{"ErrConflict", db.ErrConflict},
{"ErrBanned", db.ErrBanned},
{"ErrLastAdmin", db.ErrLastAdmin},
}
for i, a := range sentinels {
for j, b := range sentinels {
if i == j {
continue
}
if errors.Is(a.err, b.err) {
t.Errorf("%s should not match %s", a.name, b.name)
}
}
}
}
func TestSentinelErrors_MatchThemselves(t *testing.T) {
sentinels := []struct {
name string
err error
}{
{"ErrNotFound", db.ErrNotFound},
{"ErrForbidden", db.ErrForbidden},
{"ErrConflict", db.ErrConflict},
{"ErrBanned", db.ErrBanned},
{"ErrLastAdmin", db.ErrLastAdmin},
}
for _, tc := range sentinels {
t.Run(tc.name, func(t *testing.T) {
if !errors.Is(tc.err, tc.err) {
t.Errorf("errors.Is(%s, %s) = false, want true", tc.name, tc.name)
}
})
}
}
func TestSentinelErrors_MatchWrapped(t *testing.T) {
sentinels := []struct {
name string
err error
}{
{"ErrNotFound", db.ErrNotFound},
{"ErrForbidden", db.ErrForbidden},
{"ErrConflict", db.ErrConflict},
{"ErrBanned", db.ErrBanned},
{"ErrLastAdmin", db.ErrLastAdmin},
}
for _, tc := range sentinels {
t.Run(tc.name, func(t *testing.T) {
wrapped := fmt.Errorf("operation failed: %w", tc.err)
if !errors.Is(wrapped, tc.err) {
t.Errorf("errors.Is(wrapped, %s) = false, want true", tc.name)
}
})
}
}
func TestSentinelErrors_DoubleWrapped(t *testing.T) {
inner := fmt.Errorf("db query: %w", db.ErrNotFound)
outer := fmt.Errorf("handler: %w", inner)
if !errors.Is(outer, db.ErrNotFound) {
t.Error("double-wrapped ErrNotFound should still match")
}
}
func TestSentinelErrors_HaveNonEmptyMessages(t *testing.T) {
sentinels := []error{
db.ErrNotFound,
db.ErrForbidden,
db.ErrConflict,
db.ErrBanned,
db.ErrLastAdmin,
}
for _, err := range sentinels {
if err.Error() == "" {
t.Errorf("sentinel error has empty message")
}
}
}
// ─── IsUniqueConstraintError ─────────────────────────────────────────────────
func TestIsUniqueConstraintError_MatchesSQLiteMessage(t *testing.T) {
sqliteErr := errors.New("UNIQUE constraint failed: users.username")
if !db.IsUniqueConstraintError(sqliteErr) {
t.Error("should match SQLite UNIQUE constraint error")
}
}
func TestIsUniqueConstraintError_CaseSensitive(t *testing.T) {
lower := errors.New("unique constraint failed: users.username")
if db.IsUniqueConstraintError(lower) {
t.Error("should be case-sensitive (SQLite always uses uppercase UNIQUE)")
}
}
func TestIsUniqueConstraintError_NilError(t *testing.T) {
if db.IsUniqueConstraintError(nil) {
t.Error("nil error should return false")
}
}
func TestIsUniqueConstraintError_UnrelatedError(t *testing.T) {
unrelated := errors.New("connection refused")
if db.IsUniqueConstraintError(unrelated) {
t.Error("unrelated error should return false")
}
}
func TestIsUniqueConstraintError_WrappedSQLiteError(t *testing.T) {
inner := errors.New("UNIQUE constraint failed: users.email")
wrapped := fmt.Errorf("insert user: %w", inner)
// The function uses strings.Contains on err.Error(), so wrapping preserves the substring.
if !db.IsUniqueConstraintError(wrapped) {
t.Error("wrapped UNIQUE constraint error should match (message preserved in chain)")
}
}
+310
View File
@@ -0,0 +1,310 @@
package db_test
import (
"encoding/json"
"testing"
"github.com/owncord/server/db"
)
// ─── Role JSON round-trip ────────────────────────────────────────────────────
func TestRole_JSONRoundTrip(t *testing.T) {
color := "#ff0000"
original := db.Role{
ID: 1,
Name: "admin",
Color: &color,
Permissions: 0x40000000,
Position: 100,
IsDefault: false,
}
data, err := json.Marshal(original)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
var decoded db.Role
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if decoded.ID != original.ID {
t.Errorf("ID = %d, want %d", decoded.ID, original.ID)
}
if decoded.Name != original.Name {
t.Errorf("Name = %q, want %q", decoded.Name, original.Name)
}
if decoded.Color == nil || *decoded.Color != color {
t.Errorf("Color = %v, want %q", decoded.Color, color)
}
if decoded.Permissions != original.Permissions {
t.Errorf("Permissions = %d, want %d", decoded.Permissions, original.Permissions)
}
if decoded.Position != original.Position {
t.Errorf("Position = %d, want %d", decoded.Position, original.Position)
}
if decoded.IsDefault != original.IsDefault {
t.Errorf("IsDefault = %v, want %v", decoded.IsDefault, original.IsDefault)
}
}
func TestRole_JSONKeys(t *testing.T) {
role := db.Role{ID: 1, Name: "member", Permissions: 3, Position: 1, IsDefault: true}
data, _ := json.Marshal(role)
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatalf("Unmarshal to map: %v", err)
}
expectedKeys := []string{"id", "name", "color", "permissions", "position", "is_default"}
for _, k := range expectedKeys {
if _, ok := raw[k]; !ok {
t.Errorf("missing JSON key %q", k)
}
}
}
func TestRole_NilColor(t *testing.T) {
role := db.Role{ID: 1, Name: "member"}
data, _ := json.Marshal(role)
var raw map[string]interface{}
json.Unmarshal(data, &raw) //nolint:errcheck
if raw["color"] != nil {
t.Errorf("nil Color should serialize as null, got %v", raw["color"])
}
}
// ─── Channel JSON round-trip ─────────────────────────────────────────────────
func TestChannel_JSONRoundTrip(t *testing.T) {
quality := "high"
threshold := 10
original := db.Channel{
ID: 42,
Name: "general",
Type: "text",
Category: "Main",
Topic: "General chat",
Position: 0,
SlowMode: 5,
Archived: false,
CreatedAt: "2026-01-01T00:00:00Z",
VoiceMaxUsers: 25,
VoiceQuality: &quality,
MixingThreshold: &threshold,
VoiceMaxVideo: 4,
}
data, err := json.Marshal(original)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
var decoded db.Channel
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if decoded.ID != original.ID || decoded.Name != original.Name {
t.Errorf("basic fields mismatch: got ID=%d Name=%q", decoded.ID, decoded.Name)
}
if decoded.VoiceQuality == nil || *decoded.VoiceQuality != quality {
t.Errorf("VoiceQuality = %v, want %q", decoded.VoiceQuality, quality)
}
if decoded.MixingThreshold == nil || *decoded.MixingThreshold != threshold {
t.Errorf("MixingThreshold = %v, want %d", decoded.MixingThreshold, threshold)
}
}
func TestChannel_OmitEmptyFields(t *testing.T) {
ch := db.Channel{ID: 1, Name: "voice-1", Type: "voice"}
data, _ := json.Marshal(ch)
var raw map[string]json.RawMessage
json.Unmarshal(data, &raw) //nolint:errcheck
// voice_quality and mixing_threshold have omitempty — should be absent when nil.
if _, ok := raw["voice_quality"]; ok {
t.Error("nil VoiceQuality should be omitted")
}
if _, ok := raw["mixing_threshold"]; ok {
t.Error("nil MixingThreshold should be omitted")
}
}
// ─── VoiceState JSON ─────────────────────────────────────────────────────────
func TestVoiceState_JoinedAtOmittedFromJSON(t *testing.T) {
vs := db.VoiceState{
UserID: 1,
ChannelID: 2,
Username: "alice",
JoinedAt: "2026-01-01T00:00:00Z",
}
data, _ := json.Marshal(vs)
var raw map[string]json.RawMessage
json.Unmarshal(data, &raw) //nolint:errcheck
if _, ok := raw["JoinedAt"]; ok {
t.Error("JoinedAt has json:\"-\" tag and should not appear in JSON output")
}
if _, ok := raw["joined_at"]; ok {
t.Error("JoinedAt should not appear under any key in JSON output")
}
}
func TestVoiceState_BoolDefaults(t *testing.T) {
vs := db.VoiceState{UserID: 1, ChannelID: 2, Username: "bob"}
data, _ := json.Marshal(vs)
var decoded db.VoiceState
json.Unmarshal(data, &decoded) //nolint:errcheck
if decoded.Muted || decoded.Deafened || decoded.Speaking || decoded.Camera || decoded.Screenshare {
t.Error("zero-value VoiceState bools should all be false")
}
}
// ─── MessageAPIResponse JSON ─────────────────────────────────────────────────
func TestMessageAPIResponse_JSONKeys(t *testing.T) {
resp := db.MessageAPIResponse{
ID: 1,
ChannelID: 2,
User: db.UserPublic{ID: 3, Username: "alice"},
Content: "hello",
Attachments: []db.AttachmentInfo{},
Reactions: []db.ReactionInfo{},
Timestamp: "2026-01-01T00:00:00Z",
}
data, _ := json.Marshal(resp)
var raw map[string]json.RawMessage
json.Unmarshal(data, &raw) //nolint:errcheck
required := []string{"id", "channel_id", "user", "content", "reply_to",
"attachments", "reactions", "pinned", "edited_at", "deleted", "timestamp"}
for _, k := range required {
if _, ok := raw[k]; !ok {
t.Errorf("missing required JSON key %q", k)
}
}
}
// ─── AttachmentInfo omitempty ─────────────────────────────────────────────────
func TestAttachmentInfo_OmitsNilDimensions(t *testing.T) {
att := db.AttachmentInfo{
ID: "abc", Filename: "doc.pdf", Size: 1024, Mime: "application/pdf", URL: "/files/abc",
}
data, _ := json.Marshal(att)
var raw map[string]json.RawMessage
json.Unmarshal(data, &raw) //nolint:errcheck
if _, ok := raw["width"]; ok {
t.Error("nil Width should be omitted")
}
if _, ok := raw["height"]; ok {
t.Error("nil Height should be omitted")
}
}
func TestAttachmentInfo_IncludesDimensions(t *testing.T) {
w, h := 1920, 1080
att := db.AttachmentInfo{
ID: "abc", Filename: "img.png", Size: 2048, Mime: "image/png",
URL: "/files/abc", Width: &w, Height: &h,
}
data, _ := json.Marshal(att)
var raw map[string]json.RawMessage
json.Unmarshal(data, &raw) //nolint:errcheck
if _, ok := raw["width"]; !ok {
t.Error("non-nil Width should be present")
}
if _, ok := raw["height"]; !ok {
t.Error("non-nil Height should be present")
}
}
// ─── UserPublic omitempty ────────────────────────────────────────────────────
func TestUserPublic_OmitsNilAvatar(t *testing.T) {
u := db.UserPublic{ID: 1, Username: "alice"}
data, _ := json.Marshal(u)
var raw map[string]json.RawMessage
json.Unmarshal(data, &raw) //nolint:errcheck
if _, ok := raw["avatar"]; ok {
t.Error("nil Avatar should be omitted")
}
}
func TestUserPublic_IncludesAvatar(t *testing.T) {
av := "avatar.png"
u := db.UserPublic{ID: 1, Username: "alice", Avatar: &av}
data, _ := json.Marshal(u)
var raw map[string]json.RawMessage
json.Unmarshal(data, &raw) //nolint:errcheck
if _, ok := raw["avatar"]; !ok {
t.Error("non-nil Avatar should be present")
}
}
// ─── ServerStats JSON ────────────────────────────────────────────────────────
func TestServerStats_JSONRoundTrip(t *testing.T) {
original := db.ServerStats{
UserCount: 150,
MessageCount: 50000,
ChannelCount: 20,
InviteCount: 5,
DBSizeBytes: 1048576,
OnlineCount: 42,
}
data, err := json.Marshal(original)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
var decoded db.ServerStats
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if decoded != original {
t.Errorf("round-trip mismatch:\n got %+v\n want %+v", decoded, original)
}
}
// ─── AuditEntry JSON ─────────────────────────────────────────────────────────
func TestAuditEntry_JSONKeys(t *testing.T) {
entry := db.AuditEntry{
ID: 1, ActorID: 2, ActorName: "admin", Action: "ban_user",
TargetType: "user", TargetID: 3, Detail: "reason", CreatedAt: "2026-01-01",
}
data, _ := json.Marshal(entry)
var raw map[string]json.RawMessage
json.Unmarshal(data, &raw) //nolint:errcheck
required := []string{"id", "actor_id", "actor_name", "action", "target_type", "target_id", "detail", "created_at"}
for _, k := range required {
if _, ok := raw[k]; !ok {
t.Errorf("missing JSON key %q", k)
}
}
}
+40
View File
@@ -259,6 +259,46 @@ func TestRequireChannelAccess(t *testing.T) {
dmOK: true,
wantErr: nil,
},
{
name: "admin bypasses regular channel check",
userID: 1,
rolePerms: Administrator,
roleID: 1,
channelType: "text",
channelID: 10,
perm: ManageChannels | ManageRoles, // multi-bit
wantErr: nil,
},
{
name: "admin does NOT bypass DM participant check",
userID: 1,
rolePerms: Administrator,
roleID: 1,
channelType: "dm",
channelID: 100,
dmOK: false,
wantErr: ErrNotDMParticipant,
},
{
name: "voice channel uses role perms",
userID: 1,
rolePerms: ReadMessages | ConnectVoice | SpeakVoice,
roleID: 4,
channelType: "voice",
channelID: 20,
perm: ConnectVoice,
wantErr: nil,
},
{
name: "voice channel denied without perm",
userID: 1,
rolePerms: ReadMessages | SendMessages,
roleID: 4,
channelType: "voice",
channelID: 20,
perm: ConnectVoice,
wantErr: ErrPermissionDenied,
},
}
for _, tt := range tests {
+135
View File
@@ -254,3 +254,138 @@ func TestEffectivePerms_DenyAllGrantNone(t *testing.T) {
t.Errorf("EffectivePerms deny all: got 0x%X, want 0", got)
}
}
// ─── HasPerm — combined multi-bit checks ────────────────────────────────────
func TestHasPerm_RequiresAllBitsPresent(t *testing.T) {
// Require both SendMessages AND ManageMessages; user only has SendMessages.
rolePerms := permissions.SendMessages | permissions.ReadMessages
combined := permissions.SendMessages | permissions.ManageMessages
if permissions.HasPerm(rolePerms, combined) {
t.Error("should fail when only some of the required bits are present")
}
}
func TestHasPerm_CombinedBitsAllPresent(t *testing.T) {
rolePerms := permissions.SendMessages | permissions.ReadMessages | permissions.ManageMessages
combined := permissions.SendMessages | permissions.ManageMessages
if !permissions.HasPerm(rolePerms, combined) {
t.Error("should succeed when all required combined bits are present")
}
}
// ─── EffectivePerms — channel override edge cases ───────────────────────────
func TestEffectivePerms_DenyAllThenAllowOne(t *testing.T) {
base := permissions.SendMessages | permissions.ReadMessages | permissions.ConnectVoice
deny := int64(0x7FFFFFFF) // deny everything
allow := permissions.ReadMessages // re-allow just ReadMessages
eff := permissions.EffectivePerms(base, allow, deny)
if eff != permissions.ReadMessages {
t.Errorf("deny-all + allow-one: got 0x%X, want 0x%X", eff, permissions.ReadMessages)
}
}
func TestEffectivePerms_MultipleDenyMultipleAllow(t *testing.T) {
base := permissions.SendMessages | permissions.ReadMessages | permissions.AttachFiles | permissions.ConnectVoice
deny := permissions.SendMessages | permissions.ConnectVoice
allow := permissions.ManageChannels | permissions.ManageMessages
eff := permissions.EffectivePerms(base, allow, deny)
// Should keep: ReadMessages, AttachFiles (not denied)
// Should lose: SendMessages, ConnectVoice (denied)
// Should gain: ManageChannels, ManageMessages (allowed)
want := permissions.ReadMessages | permissions.AttachFiles | permissions.ManageChannels | permissions.ManageMessages
if eff != want {
t.Errorf("multi deny+allow: got 0x%X, want 0x%X", eff, want)
}
}
// ─── Role hierarchy simulation ──────────────────────────────────────────────
func TestRoleHierarchy_OwnerHasMorePermsThanAdmin(t *testing.T) {
ownerPerms := int64(0x7FFFFFFF) // Owner default
adminPerms := int64(0x3FFFFFFF) // Admin default (no Administrator bit)
if !permissions.HasAdmin(ownerPerms) {
t.Error("owner should be admin")
}
if permissions.HasAdmin(adminPerms) {
t.Error("admin role should NOT have Administrator bit")
}
// Owner can ManageServer via admin bypass.
// Admin can ManageServer via direct bit.
if !permissions.HasPerm(adminPerms, permissions.ManageServer) {
t.Error("admin should have ManageServer bit directly")
}
}
func TestRoleHierarchy_MemberLacksModPerms(t *testing.T) {
memberPerms := int64(1635) // Default member permissions from schema
modPerms := []int64{
permissions.ManageMessages,
permissions.ManageChannels,
permissions.KickMembers,
permissions.BanMembers,
permissions.ManageRoles,
permissions.ManageServer,
permissions.Administrator,
}
for _, p := range modPerms {
if permissions.HasPerm(memberPerms, p) {
t.Errorf("member (0x%X) should not have permission 0x%X", memberPerms, p)
}
}
}
func TestRoleHierarchy_MemberHasBasicPerms(t *testing.T) {
memberPerms := int64(1635) // 0x663 = SendMessages|ReadMessages|AttachFiles|AddReactions|ConnectVoice|SpeakVoice
basicPerms := []struct {
name string
perm int64
}{
{"SendMessages", permissions.SendMessages},
{"ReadMessages", permissions.ReadMessages},
}
for _, tc := range basicPerms {
t.Run(tc.name, func(t *testing.T) {
if !permissions.HasPerm(memberPerms, tc.perm) {
t.Errorf("member should have %s", tc.name)
}
})
}
}
// ─── Permission bits are unique powers of 2 ─────────────────────────────────
func TestPermissionBits_AreDistinctPowersOfTwo(t *testing.T) {
bits := []int64{
permissions.SendMessages, permissions.ReadMessages, permissions.AttachFiles,
permissions.AddReactions, permissions.UseSoundboard, permissions.ConnectVoice,
permissions.SpeakVoice, permissions.UseVideo, permissions.ShareScreen,
permissions.ManageMessages, permissions.ManageChannels, permissions.KickMembers,
permissions.BanMembers, permissions.MuteMembers, permissions.ManageRoles,
permissions.ManageServer, permissions.ManageInvites, permissions.ViewAuditLog,
permissions.Administrator,
}
seen := make(map[int64]bool)
for _, b := range bits {
if b&(b-1) != 0 {
t.Errorf("permission 0x%X is not a power of 2", b)
}
if seen[b] {
t.Errorf("duplicate permission bit: 0x%X", b)
}
seen[b] = true
}
}