mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user