test: boost server coverage — auth 60→95%, db 69→81%, config 75→85%

Add comprehensive tests across all Go packages:
- auth: username validation, concurrent rate limiting, TOTP stores, timing
- config: env overrides, default credential detection, voice defaults
- db: search, message queries, special char handling
- api: handler edge cases, error paths, DM/invite/TOTP coverage
- ws: voice handler paths, integration scenarios
- updater: version comparison, timeout handling

6 of 8 packages now at 80%+ coverage.
This commit is contained in:
jevb
2026-04-01 11:38:11 +02:00
parent b4e15e1234
commit 2a62f31c39
28 changed files with 3731 additions and 112 deletions
+14
View File
@@ -152,6 +152,20 @@ CREATE TABLE IF NOT EXISTS settings (
INSERT OR IGNORE INTO settings (key, value) VALUES
('server_name', 'OwnCord Server'),
('motd', 'Welcome!');
CREATE TABLE IF NOT EXISTS dm_participants (
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (channel_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_dm_participants_user ON dm_participants(user_id);
CREATE TABLE IF NOT EXISTS dm_open_state (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
opened_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, channel_id)
);
`)
// ─── helpers ──────────────────────────────────────────────────────────────────
File diff suppressed because it is too large Load Diff
-5
View File
@@ -12,11 +12,6 @@ import (
"github.com/owncord/server/db"
)
// hashTokenForTest is a package-level helper wrapping auth.HashToken.
func hashTokenForTest(token string) string {
return auth.HashToken(token)
}
// setupDiagnosticsRouter creates a full router with an authenticated user for
// diagnostics testing.
func setupDiagnosticsRouter(t *testing.T) (http.Handler, string) {
-1
View File
@@ -268,4 +268,3 @@ func TestListInvites_IncludesRevokedAndActive(t *testing.T) {
t.Errorf("ListInvites status = %d, want 200", rr2.Code)
}
}
+90 -5
View File
@@ -122,19 +122,19 @@ func TestExtractBearerToken_TokenPreservesValue(t *testing.T) {
func TestExtractBearerToken_MultipleSpaces(t *testing.T) {
// SplitN with n=2 means "Bearer tok" splits into ["Bearer", " tok"].
// The second part " tok" is non-empty, so the function must return " tok", true.
// The implementation trims whitespace, so " mytoken" becomes "mytoken".
r, _ := http.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("Authorization", "Bearer mytoken")
token, ok := auth.ExtractBearerToken(r)
// The contract: returns whatever follows the single separating space.
// " mytoken" is non-empty, so ok should be true.
// The implementation applies TrimSpace to the extracted token,
// so the leading space from the double-space header is stripped.
if !ok {
t.Fatal("ExtractBearerToken() ok = false for double-space header, want true")
}
if token != " mytoken" {
t.Errorf("ExtractBearerToken() token = %q, want %q", token, " mytoken")
if token != "mytoken" {
t.Errorf("ExtractBearerToken() token = %q, want %q", token, "mytoken")
}
}
@@ -309,3 +309,88 @@ func TestIsEffectivelyBanned_NilUser(t *testing.T) {
t.Error("IsEffectivelyBanned(nil) = true, want false")
}
}
// ─── ValidateUsername ────────────────────────────────────────────────────────
func TestValidateUsername_ValidNames(t *testing.T) {
cases := []string{
"ab", // minimum length (2 runes)
"alice", // normal ASCII
"user_name", // with underscore
"日本語ユーザー", // CJK (multi-byte runes)
"abcdefghijklmnopqrstuvwxyz123456", // exactly 32 chars
}
for _, name := range cases {
if err := auth.ValidateUsername(name); err != nil {
t.Errorf("ValidateUsername(%q) = %v, want nil", name, err)
}
}
}
func TestValidateUsername_TooShort(t *testing.T) {
cases := []string{
"", // empty
"a", // single char
}
for _, name := range cases {
if err := auth.ValidateUsername(name); err == nil {
t.Errorf("ValidateUsername(%q) = nil, want error for too short", name)
}
}
}
func TestValidateUsername_TooLong(t *testing.T) {
// 33 runes exceeds the 32-rune limit.
long := "abcdefghijklmnopqrstuvwxyz1234567"
if err := auth.ValidateUsername(long); err == nil {
t.Errorf("ValidateUsername(%q) = nil, want error for too long", long)
}
}
func TestValidateUsername_ControlCharactersRejected(t *testing.T) {
cases := []string{
"user\x00name", // null byte
"user\nname", // newline
"user\tname", // tab
"abc\x07def", // bell
}
for _, name := range cases {
if err := auth.ValidateUsername(name); err == nil {
t.Errorf("ValidateUsername(%q) = nil, want error for control char", name)
}
}
}
func TestValidateUsername_InvisibleCharactersRejected(t *testing.T) {
// Zero-width joiner (U+200D) is in unicode.Cf category.
name := "user\u200Dname"
if err := auth.ValidateUsername(name); err == nil {
t.Errorf("ValidateUsername(%q) = nil, want error for invisible character", name)
}
// Zero-width space (U+200B).
name2 := "user\u200Bname"
if err := auth.ValidateUsername(name2); err == nil {
t.Errorf("ValidateUsername(%q) = nil, want error for zero-width space", name2)
}
}
func TestValidateUsername_WhitespaceTrimmed(t *testing.T) {
// Leading/trailing whitespace is trimmed, so " a " becomes "a" (1 rune = too short).
if err := auth.ValidateUsername(" a "); err == nil {
t.Error("ValidateUsername(\" a \") = nil, want error (trimmed to 1 rune)")
}
// After trimming, " ab " becomes "ab" (2 runes = valid).
if err := auth.ValidateUsername(" ab "); err != nil {
t.Errorf("ValidateUsername(\" ab \") = %v, want nil (trimmed to 2 runes)", err)
}
}
func TestValidateUsername_UnicodeLength(t *testing.T) {
// Each emoji is 1 rune but multiple bytes. 2 emoji should be valid (min length).
twoEmoji := "😀😀"
if err := auth.ValidateUsername(twoEmoji); err != nil {
t.Errorf("ValidateUsername(%q) = %v, want nil for 2-rune emoji name", twoEmoji, err)
}
}
+66 -5
View File
@@ -65,8 +65,8 @@ func TestCheckPassword_EmptyHash(t *testing.T) {
func TestValidatePasswordStrength_Valid(t *testing.T) {
cases := []string{
"12345678", // exactly 8 chars
"abcdefghij", // 10 chars
"12345678", // exactly 8 chars
"abcdefghij", // 10 chars
strings.Repeat("a", 72), // exactly 72 chars (bcrypt max)
}
for _, pw := range cases {
@@ -78,9 +78,9 @@ func TestValidatePasswordStrength_Valid(t *testing.T) {
func TestValidatePasswordStrength_TooShort(t *testing.T) {
cases := []string{
"", // empty
"1234567", // 7 chars
"abc", // 3 chars
"", // empty
"1234567", // 7 chars
"abc", // 3 chars
}
for _, pw := range cases {
if err := auth.ValidatePasswordStrength(pw); err == nil {
@@ -104,3 +104,64 @@ func TestHashPassword_TwoCallsDifferentHashes(t *testing.T) {
t.Error("HashPassword() produced identical hashes for the same password (salt missing?)")
}
}
func TestCheckPassword_EmptyHashTimingResistance(t *testing.T) {
// Calling CheckPassword with an empty hash should not be significantly
// faster than with a real hash (dummy comparison is performed).
// We just verify it returns false and doesn't panic.
result := auth.CheckPassword("", "anypassword")
if result {
t.Error("CheckPassword(\"\", ...) = true, want false")
}
}
func TestCheckPassword_MalformedHash(t *testing.T) {
// A malformed hash string (not bcrypt) should return false without panic.
result := auth.CheckPassword("not-a-bcrypt-hash", "password")
if result {
t.Error("CheckPassword(malformed, ...) = true, want false")
}
}
func TestHashPassword_UnicodePassword(t *testing.T) {
// Unicode passwords should hash and verify correctly.
pw := "Pässwörd™日本語"
hash, err := auth.HashPassword(pw)
if err != nil {
t.Fatalf("HashPassword(unicode) error: %v", err)
}
if !auth.CheckPassword(hash, pw) {
t.Error("CheckPassword() = false for correct unicode password")
}
if auth.CheckPassword(hash, "Pässwörd™日本") {
t.Error("CheckPassword() = true for slightly different unicode password")
}
}
func TestValidatePasswordStrength_UnicodeMultibyte(t *testing.T) {
// A password of 8 multi-byte runes may exceed 8 bytes but len() counts bytes.
// "日本語日本語日本" is 8 runes but 24 bytes — should pass the min check.
pw := "日本語日本語日本"
if err := auth.ValidatePasswordStrength(pw); err != nil {
t.Errorf("ValidatePasswordStrength(8-rune unicode) = %v, want nil", err)
}
}
func TestValidatePasswordStrength_ExactBoundaries(t *testing.T) {
// Exactly 8 bytes — valid.
if err := auth.ValidatePasswordStrength("12345678"); err != nil {
t.Errorf("exactly 8 chars: %v", err)
}
// Exactly 72 bytes — valid.
if err := auth.ValidatePasswordStrength(strings.Repeat("x", 72)); err != nil {
t.Errorf("exactly 72 chars: %v", err)
}
// 7 bytes — too short.
if err := auth.ValidatePasswordStrength("1234567"); err == nil {
t.Error("7 chars should be too short")
}
// 73 bytes — too long.
if err := auth.ValidatePasswordStrength(strings.Repeat("x", 73)); err == nil {
t.Error("73 chars should be too long")
}
}
+106
View File
@@ -124,3 +124,109 @@ func TestRateLimiter_ThreadSafe(t *testing.T) {
}
// If we get here without a race condition data race, we pass
}
// ─── Check (read-only rate-limit query) ─────────────────────────────────────
func TestRateLimiter_Check_UnderLimit(t *testing.T) {
rl := auth.NewRateLimiter()
// No requests recorded yet — Check should return true.
if !rl.Check("checkKey", 5, time.Second) {
t.Error("Check() = false for fresh key, want true")
}
}
func TestRateLimiter_Check_DoesNotRecordTimestamp(t *testing.T) {
rl := auth.NewRateLimiter()
// Call Check many times — it must NOT record timestamps.
for range 10 {
rl.Check("checkKey2", 3, time.Second)
}
// Allow should still succeed because Check didn't record anything.
if !rl.Allow("checkKey2", 3, time.Second) {
t.Error("Allow() = false after only Check() calls, want true")
}
}
func TestRateLimiter_Check_AtLimit(t *testing.T) {
rl := auth.NewRateLimiter()
// Record exactly 3 requests via Allow.
for range 3 {
rl.Allow("checkKey3", 3, time.Second)
}
// Check should report the key is at/over limit.
if rl.Check("checkKey3", 3, time.Second) {
t.Error("Check() = true when at limit, want false")
}
}
func TestRateLimiter_Check_RespectsLockout(t *testing.T) {
rl := auth.NewRateLimiter()
rl.Lockout("checkLocked", time.Hour)
if rl.Check("checkLocked", 100, time.Second) {
t.Error("Check() = true for locked-out key, want false")
}
}
func TestRateLimiter_Check_LockoutExpired(t *testing.T) {
rl := auth.NewRateLimiter()
rl.Lockout("checkExpLock", 10*time.Millisecond)
time.Sleep(30 * time.Millisecond)
if !rl.Check("checkExpLock", 5, time.Second) {
t.Error("Check() = false after lockout expired, want true")
}
}
func TestRateLimiter_Check_WindowBoundary(t *testing.T) {
rl := auth.NewRateLimiter()
window := 50 * time.Millisecond
// Exhaust limit.
for range 3 {
rl.Allow("checkBound", 3, window)
}
if rl.Check("checkBound", 3, window) {
t.Error("Check() = true at limit, want false")
}
// Wait for window to expire.
time.Sleep(window + 20*time.Millisecond)
if !rl.Check("checkBound", 3, window) {
t.Error("Check() = false after window expired, want true")
}
}
// ─── Concurrent hammering ───────────────────────────────────────────────────
func TestRateLimiter_ConcurrentHammering(t *testing.T) {
rl := auth.NewRateLimiter()
limit := 10
window := time.Second
allowed := make(chan bool, 200)
for range 200 {
go func() {
allowed <- rl.Allow("hammer", limit, window)
}()
}
trueCount := 0
for range 200 {
if <-allowed {
trueCount++
}
}
// Exactly `limit` requests should be allowed.
if trueCount != limit {
t.Errorf("concurrent Allow() allowed %d requests, want exactly %d", trueCount, limit)
}
}
func TestRateLimiter_ResetClearsLockout(t *testing.T) {
rl := auth.NewRateLimiter()
rl.Lockout("resetLock", time.Hour)
if !rl.IsLockedOut("resetLock") {
t.Fatal("precondition: key should be locked out")
}
rl.Reset("resetLock")
if rl.IsLockedOut("resetLock") {
t.Error("Reset() should clear lockout, but key is still locked out")
}
}
+58
View File
@@ -75,3 +75,61 @@ func TestHashToken_DifferentInputsDifferentHashes(t *testing.T) {
t.Errorf("HashToken() same hash for different inputs")
}
}
func TestGenerateToken_MultiDeviceUniqueness(t *testing.T) {
// Simulate multiple devices generating tokens simultaneously.
// All tokens must be unique (no collision across concurrent generation).
const devices = 50
tokens := make(chan string, devices)
errs := make(chan error, devices)
for range devices {
go func() {
tok, err := auth.GenerateToken()
if err != nil {
errs <- err
return
}
tokens <- tok
}()
}
seen := make(map[string]struct{}, devices)
for range devices {
select {
case err := <-errs:
t.Fatalf("GenerateToken() error in goroutine: %v", err)
case tok := <-tokens:
if _, dup := seen[tok]; dup {
t.Fatalf("GenerateToken() produced duplicate across concurrent calls")
}
seen[tok] = struct{}{}
}
}
}
func TestHashToken_ConsistentAfterRotation(t *testing.T) {
// After generating a new token (rotation), the old hash should NOT match
// the new token, and the new hash should match the new token.
oldToken, _ := auth.GenerateToken()
oldHash := auth.HashToken(oldToken)
newToken, _ := auth.GenerateToken()
newHash := auth.HashToken(newToken)
if oldHash == newHash {
t.Error("rotated token produced same hash as old token")
}
// Old token still hashes to old hash (deterministic).
if auth.HashToken(oldToken) != oldHash {
t.Error("HashToken is not deterministic for old token")
}
}
func TestHashToken_EmptyInput(t *testing.T) {
// Hashing an empty string should still produce a valid 64-char hex hash.
hash := auth.HashToken("")
if len(hash) != 64 {
t.Errorf("HashToken(\"\") len = %d, want 64", len(hash))
}
}
+329
View File
@@ -26,6 +26,335 @@ func TestGenerateTOTPCodeAndVerify_RFCVector(t *testing.T) {
}
}
func TestVerifyTOTPCode_ClockSkewTolerance(t *testing.T) {
secret := "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
now := time.Now().UTC()
code, err := auth.GenerateTOTPCode(secret, now)
if err != nil {
t.Fatalf("GenerateTOTPCode: %v", err)
}
// Code should verify at current time.
if !auth.VerifyTOTPCode(secret, code, now) {
t.Error("VerifyTOTPCode should accept code at generation time")
}
// Code should also verify one period (30s) earlier (skew tolerance).
if !auth.VerifyTOTPCode(secret, code, now.Add(-30*time.Second)) {
t.Error("VerifyTOTPCode should accept code one period earlier (clock skew)")
}
// Code should verify one period later.
if !auth.VerifyTOTPCode(secret, code, now.Add(30*time.Second)) {
t.Error("VerifyTOTPCode should accept code one period later (clock skew)")
}
}
func TestVerifyTOTPCode_RejectsBadLength(t *testing.T) {
secret := "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
// Too short.
if auth.VerifyTOTPCode(secret, "12345", time.Now()) {
t.Error("VerifyTOTPCode should reject 5-digit code")
}
// Too long.
if auth.VerifyTOTPCode(secret, "1234567", time.Now()) {
t.Error("VerifyTOTPCode should reject 7-digit code")
}
// Empty.
if auth.VerifyTOTPCode(secret, "", time.Now()) {
t.Error("VerifyTOTPCode should reject empty code")
}
}
func TestGenerateTOTPCode_InvalidSecret(t *testing.T) {
_, err := auth.GenerateTOTPCode("not-valid-base32!!!", time.Now())
if err == nil {
t.Error("GenerateTOTPCode should error for invalid base32 secret")
}
}
// ─── GenerateTOTPSecret ─────────────────────────────────────────────────────
func TestGenerateTOTPSecret_ReturnsValidBase32(t *testing.T) {
secret, err := auth.GenerateTOTPSecret()
if err != nil {
t.Fatalf("GenerateTOTPSecret() error: %v", err)
}
if secret == "" {
t.Fatal("GenerateTOTPSecret() returned empty string")
}
// Should be valid base32 (usable with GenerateTOTPCode).
_, err = auth.GenerateTOTPCode(secret, time.Now())
if err != nil {
t.Errorf("generated secret is not valid base32 for TOTP: %v", err)
}
}
func TestGenerateTOTPSecret_Unique(t *testing.T) {
s1, _ := auth.GenerateTOTPSecret()
s2, _ := auth.GenerateTOTPSecret()
if s1 == s2 {
t.Error("GenerateTOTPSecret() produced duplicate secrets")
}
}
// ─── PartialAuthStore ───────────────────────────────────────────────────────
func TestPartialAuthStore_IssueAndLookup(t *testing.T) {
store := auth.NewPartialAuthStore(time.Minute)
token, err := store.Issue(42, "desktop", "192.168.1.1")
if err != nil {
t.Fatalf("Issue() error: %v", err)
}
if token == "" {
t.Fatal("Issue() returned empty token")
}
challenge, ok := store.Lookup(token)
if !ok {
t.Fatal("Lookup() ok = false for valid token")
}
if challenge.UserID != 42 {
t.Errorf("UserID = %d, want 42", challenge.UserID)
}
if challenge.Device != "desktop" {
t.Errorf("Device = %q, want 'desktop'", challenge.Device)
}
if challenge.IP != "192.168.1.1" {
t.Errorf("IP = %q, want '192.168.1.1'", challenge.IP)
}
}
func TestPartialAuthStore_Consume(t *testing.T) {
store := auth.NewPartialAuthStore(time.Minute)
token, _ := store.Issue(1, "mobile", "10.0.0.1")
// Consume should return the challenge and remove it.
challenge, ok := store.Consume(token)
if !ok {
t.Fatal("Consume() ok = false for valid token")
}
if challenge.UserID != 1 {
t.Errorf("UserID = %d, want 1", challenge.UserID)
}
// Second consume should fail.
_, ok = store.Consume(token)
if ok {
t.Error("Consume() ok = true for already consumed token")
}
}
func TestPartialAuthStore_ConsumeInvalidToken(t *testing.T) {
store := auth.NewPartialAuthStore(time.Minute)
_, ok := store.Consume("nonexistent")
if ok {
t.Error("Consume() ok = true for nonexistent token")
}
}
func TestPartialAuthStore_LookupInvalidToken(t *testing.T) {
store := auth.NewPartialAuthStore(time.Minute)
_, ok := store.Lookup("nonexistent")
if ok {
t.Error("Lookup() ok = true for nonexistent token")
}
}
func TestPartialAuthStore_RegisterFailure(t *testing.T) {
store := auth.NewPartialAuthStore(time.Minute)
token, _ := store.Issue(1, "mobile", "10.0.0.1")
// First failure — should still be alive (maxFailures=3).
if !store.RegisterFailure(token, 3) {
t.Error("RegisterFailure() = false on first failure, want true")
}
// Second failure.
if !store.RegisterFailure(token, 3) {
t.Error("RegisterFailure() = false on second failure, want true")
}
// Third failure — reaches maxFailures, token should be deleted.
if store.RegisterFailure(token, 3) {
t.Error("RegisterFailure() = true on third failure (at max), want false")
}
// Token should be gone.
_, ok := store.Lookup(token)
if ok {
t.Error("token should be deleted after max failures")
}
}
func TestPartialAuthStore_RegisterFailureUnknownToken(t *testing.T) {
store := auth.NewPartialAuthStore(time.Minute)
if store.RegisterFailure("nonexistent", 3) {
t.Error("RegisterFailure() = true for nonexistent token, want false")
}
}
func TestPartialAuthStore_ExpiryCleanup(t *testing.T) {
store := auth.NewPartialAuthStore(50 * time.Millisecond)
token, _ := store.Issue(1, "dev", "1.2.3.4")
time.Sleep(80 * time.Millisecond)
// Lookup triggers cleanup — expired token should be gone.
_, ok := store.Lookup(token)
if ok {
t.Error("Lookup() ok = true for expired token, want false")
}
}
// ─── PendingTOTPStore ───────────────────────────────────────────────────────
func TestPendingTOTPStore_PutAndLookup(t *testing.T) {
store := auth.NewPendingTOTPStore(time.Minute)
store.Put(42, "MYSECRET")
secret, ok := store.Lookup(42)
if !ok {
t.Fatal("Lookup() ok = false for valid userID")
}
if secret != "MYSECRET" {
t.Errorf("secret = %q, want 'MYSECRET'", secret)
}
}
func TestPendingTOTPStore_LookupMissing(t *testing.T) {
store := auth.NewPendingTOTPStore(time.Minute)
_, ok := store.Lookup(999)
if ok {
t.Error("Lookup() ok = true for missing userID, want false")
}
}
func TestPendingTOTPStore_Delete(t *testing.T) {
store := auth.NewPendingTOTPStore(time.Minute)
store.Put(42, "SECRET")
store.Delete(42)
_, ok := store.Lookup(42)
if ok {
t.Error("Lookup() ok = true after Delete(), want false")
}
}
func TestPendingTOTPStore_Overwrite(t *testing.T) {
store := auth.NewPendingTOTPStore(time.Minute)
store.Put(42, "OLD")
store.Put(42, "NEW")
secret, ok := store.Lookup(42)
if !ok {
t.Fatal("Lookup() ok = false")
}
if secret != "NEW" {
t.Errorf("secret = %q, want 'NEW' after overwrite", secret)
}
}
func TestPendingTOTPStore_ExpiryCleanup(t *testing.T) {
store := auth.NewPendingTOTPStore(50 * time.Millisecond)
store.Put(42, "EPHEMERAL")
time.Sleep(80 * time.Millisecond)
_, ok := store.Lookup(42)
if ok {
t.Error("Lookup() ok = true for expired entry, want false")
}
}
// ─── UsedTOTPCodeStore ──────────────────────────────────────────────────────
func TestUsedTOTPCodeStore_MarkUsed(t *testing.T) {
store := auth.NewUsedTOTPCodeStore()
// First use should succeed.
if !store.MarkUsed(1, "123456") {
t.Error("MarkUsed() = false on first use, want true")
}
// Replay should be rejected.
if store.MarkUsed(1, "123456") {
t.Error("MarkUsed() = true on replay, want false")
}
}
func TestUsedTOTPCodeStore_DifferentUsersSameCode(t *testing.T) {
store := auth.NewUsedTOTPCodeStore()
if !store.MarkUsed(1, "111111") {
t.Error("MarkUsed(user1) = false, want true")
}
// Same code but different user should succeed.
if !store.MarkUsed(2, "111111") {
t.Error("MarkUsed(user2) = false for same code different user, want true")
}
}
func TestUsedTOTPCodeStore_DifferentCodes(t *testing.T) {
store := auth.NewUsedTOTPCodeStore()
if !store.MarkUsed(1, "111111") {
t.Error("MarkUsed(code1) = false, want true")
}
if !store.MarkUsed(1, "222222") {
t.Error("MarkUsed(code2) = false for different code same user, want true")
}
}
// ─── VerifyTOTPCodeOnce ─────────────────────────────────────────────────────
func TestVerifyTOTPCodeOnce_ValidCodeAccepted(t *testing.T) {
secret := "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
now := time.Now().UTC()
code, err := auth.GenerateTOTPCode(secret, now)
if err != nil {
t.Fatalf("GenerateTOTPCode: %v", err)
}
store := auth.NewUsedTOTPCodeStore()
if !auth.VerifyTOTPCodeOnce(secret, code, now, 1, store) {
t.Error("VerifyTOTPCodeOnce() = false for valid code, want true")
}
}
func TestVerifyTOTPCodeOnce_ReplayRejected(t *testing.T) {
secret := "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
now := time.Now().UTC()
code, _ := auth.GenerateTOTPCode(secret, now)
store := auth.NewUsedTOTPCodeStore()
auth.VerifyTOTPCodeOnce(secret, code, now, 1, store)
// Second verification of the same code should be rejected.
if auth.VerifyTOTPCodeOnce(secret, code, now, 1, store) {
t.Error("VerifyTOTPCodeOnce() = true for replayed code, want false")
}
}
func TestVerifyTOTPCodeOnce_InvalidCodeRejected(t *testing.T) {
secret := "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
store := auth.NewUsedTOTPCodeStore()
if auth.VerifyTOTPCodeOnce(secret, "000000", time.Unix(59, 0), 1, store) {
t.Error("VerifyTOTPCodeOnce() = true for invalid code, want false")
}
}
func TestVerifyTOTPCodeOnce_NilStoreAccepted(t *testing.T) {
secret := "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
now := time.Now().UTC()
code, _ := auth.GenerateTOTPCode(secret, now)
// With nil store, replay prevention is skipped — should still verify.
if !auth.VerifyTOTPCodeOnce(secret, code, now, 1, nil) {
t.Error("VerifyTOTPCodeOnce() = false with nil store, want true")
}
}
func TestBuildTOTPURI_ContainsIssuerAndSecret(t *testing.T) {
secret := "JBSWY3DPEHPK3PXP"
uri := auth.BuildTOTPURI("alice", secret, "OwnCord")
+151
View File
@@ -290,6 +290,157 @@ voice:
}
}
func TestLoadEnvOverridesPrecedenceOverYAML(t *testing.T) {
// Env vars should override values set in the YAML file.
tmpDir := t.TempDir()
cfgPath := filepath.Join(tmpDir, "config.yaml")
yaml := `
server:
port: 9000
name: "YAML Server"
`
if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil {
t.Fatalf("failed to write yaml: %v", err)
}
t.Setenv("OWNCORD_SERVER_PORT", "5555")
t.Setenv("OWNCORD_SERVER_NAME", "Env Wins")
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("Load() returned error: %v", err)
}
if cfg.Server.Port != 5555 {
t.Errorf("Server.Port = %d, want 5555 (env should override YAML)", cfg.Server.Port)
}
if cfg.Server.Name != "Env Wins" {
t.Errorf("Server.Name = %q, want 'Env Wins' (env should override YAML)", cfg.Server.Name)
}
}
func TestLoadUnreadableConfigFile(t *testing.T) {
// A config file that exists but can't be read should return an error.
tmpDir := t.TempDir()
cfgPath := filepath.Join(tmpDir, "config.yaml")
// Create a directory where a file is expected — os.ReadFile will fail.
if err := os.Mkdir(cfgPath, 0o755); err != nil {
t.Fatalf("failed to create directory: %v", err)
}
_, err := config.Load(cfgPath)
if err == nil {
t.Error("Load() should error when config path is a directory")
}
}
func TestLoadVoiceDefaultCredentialsCleared(t *testing.T) {
// When YAML sets the well-known default dev credentials, Load should clear them.
tmpDir := t.TempDir()
cfgPath := filepath.Join(tmpDir, "config.yaml")
yaml := `
voice:
livekit_api_key: "devkey"
livekit_api_secret: "owncord-dev-secret-key-min-32chars"
`
if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil {
t.Fatalf("failed to write yaml: %v", err)
}
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("Load() returned error: %v", err)
}
if cfg.Voice.LiveKitAPIKey != "" {
t.Errorf("Voice.LiveKitAPIKey = %q, want empty (dev creds should be cleared)", cfg.Voice.LiveKitAPIKey)
}
if cfg.Voice.LiveKitAPISecret != "" {
t.Errorf("Voice.LiveKitAPISecret = %q, want empty (dev creds should be cleared)", cfg.Voice.LiveKitAPISecret)
}
}
func TestLoadVoiceEmptySectionGetsDefaults(t *testing.T) {
// An empty voice section in YAML should still get defaults applied.
tmpDir := t.TempDir()
cfgPath := filepath.Join(tmpDir, "config.yaml")
yaml := "voice:\n"
if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil {
t.Fatalf("failed to write yaml: %v", err)
}
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("Load() returned error: %v", err)
}
if cfg.Voice.LiveKitURL != "ws://localhost:7880" {
t.Errorf("Voice.LiveKitURL = %q, want default 'ws://localhost:7880'", cfg.Voice.LiveKitURL)
}
if cfg.Voice.Quality != "medium" {
t.Errorf("Voice.Quality = %q, want default 'medium'", cfg.Voice.Quality)
}
// Key and secret should be auto-generated (non-empty).
if cfg.Voice.LiveKitAPIKey == "" {
t.Error("Voice.LiveKitAPIKey should be auto-generated, got empty")
}
if cfg.Voice.LiveKitAPISecret == "" {
t.Error("Voice.LiveKitAPISecret should be auto-generated, got empty")
}
}
func TestIsDefaultVoiceCredentials(t *testing.T) {
cases := []struct {
name string
key string
secret string
want bool
}{
{"both default", config.DefaultLiveKitAPIKey, config.DefaultLiveKitAPISecret, true},
{"only key default", config.DefaultLiveKitAPIKey, "custom-secret-long-enough-32chars", true},
{"only secret default", "custom-key", config.DefaultLiveKitAPISecret, true},
{"neither default", "custom-key", "custom-secret-long-enough-32chars", false},
{"both empty", "", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
v := &config.VoiceConfig{
LiveKitAPIKey: tc.key,
LiveKitAPISecret: tc.secret,
}
got := config.IsDefaultVoiceCredentials(v)
if got != tc.want {
t.Errorf("IsDefaultVoiceCredentials() = %v, want %v", got, tc.want)
}
})
}
}
func TestLoadGitHubToken(t *testing.T) {
tmpDir := t.TempDir()
cfgPath := filepath.Join(tmpDir, "config.yaml")
yaml := `
github:
token: "ghp_test123"
`
if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil {
t.Fatalf("failed to write yaml: %v", err)
}
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("Load() returned error: %v", err)
}
if cfg.GitHub.Token != "ghp_test123" {
t.Errorf("GitHub.Token = %q, want 'ghp_test123'", cfg.GitHub.Token)
}
}
func TestLoadUploadBoundaryValues(t *testing.T) {
tmpDir := t.TempDir()
cfgPath := filepath.Join(tmpDir, "config.yaml")
+1 -1
View File
@@ -127,7 +127,7 @@ func TestBackupToSafe_RejectsNullByte(t *testing.T) {
if err := os.MkdirAll(backupDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
malicious := filepath.Join(backupDir, "evil\x00.db")
malicious := filepath.Join(backupDir, "evil\x00.db") //nolint:gocritic // intentional null byte for security test
err := database.BackupToSafe(malicious, backupDir)
if err == nil {
t.Error("BackupToSafe() with null byte in path should return error, got nil")
+780
View File
@@ -0,0 +1,780 @@
package db_test
import (
"testing"
"time"
"github.com/owncord/server/db"
)
// ─── JoinVoiceChannelIfCapacity ─────────────────────────────────────────────
func TestVoice_JoinVoiceChannelIfCapacity_UnderLimit(t *testing.T) {
database := newVoiceTestDB(t)
u1 := seedVoiceUser(t, database, "cap-u1")
chanID := seedVoiceChannel(t, database, "cap-ch")
err := database.JoinVoiceChannelIfCapacity(u1, chanID, 2)
if err != nil {
t.Fatalf("JoinVoiceChannelIfCapacity: %v", err)
}
state, err := database.GetVoiceState(u1)
if err != nil {
t.Fatalf("GetVoiceState: %v", err)
}
if state == nil {
t.Fatal("expected voice state after join")
}
if state.ChannelID != chanID {
t.Errorf("ChannelID = %d, want %d", state.ChannelID, chanID)
}
}
func TestVoice_JoinVoiceChannelIfCapacity_AtLimit(t *testing.T) {
database := newVoiceTestDB(t)
u1 := seedVoiceUser(t, database, "cap-full1")
u2 := seedVoiceUser(t, database, "cap-full2")
u3 := seedVoiceUser(t, database, "cap-full3")
chanID := seedVoiceChannel(t, database, "cap-full-ch")
// Fill channel to capacity (max 2).
if err := database.JoinVoiceChannelIfCapacity(u1, chanID, 2); err != nil {
t.Fatalf("first join: %v", err)
}
if err := database.JoinVoiceChannelIfCapacity(u2, chanID, 2); err != nil {
t.Fatalf("second join: %v", err)
}
// Third join should fail with ErrChannelFull.
err := database.JoinVoiceChannelIfCapacity(u3, chanID, 2)
if err == nil {
t.Fatal("expected ErrChannelFull, got nil")
}
if err != db.ErrChannelFull {
t.Errorf("error = %v, want ErrChannelFull", err)
}
}
func TestVoice_JoinVoiceChannelIfCapacity_ReplacesOwnState(t *testing.T) {
database := newVoiceTestDB(t)
u1 := seedVoiceUser(t, database, "cap-replace")
ch1 := seedVoiceChannel(t, database, "cap-ch1")
ch2 := seedVoiceChannel(t, database, "cap-ch2")
// Join ch1, then join ch2 with capacity check — should replace.
if err := database.JoinVoiceChannelIfCapacity(u1, ch1, 5); err != nil {
t.Fatalf("join ch1: %v", err)
}
if err := database.JoinVoiceChannelIfCapacity(u1, ch2, 5); err != nil {
t.Fatalf("join ch2: %v", err)
}
state, _ := database.GetVoiceState(u1)
if state == nil || state.ChannelID != ch2 {
t.Errorf("expected channel %d, got %v", ch2, state)
}
}
// ─── GetAllVoiceStates ──────────────────────────────────────────────────────
func TestVoice_GetAllVoiceStates_Empty(t *testing.T) {
database := newVoiceTestDB(t)
states, err := database.GetAllVoiceStates()
if err != nil {
t.Fatalf("GetAllVoiceStates: %v", err)
}
if len(states) != 0 {
t.Errorf("got %d states, want 0", len(states))
}
}
func TestVoice_GetAllVoiceStates_MultipleChannels(t *testing.T) {
database := newVoiceTestDB(t)
u1 := seedVoiceUser(t, database, "all-vs-u1")
u2 := seedVoiceUser(t, database, "all-vs-u2")
u3 := seedVoiceUser(t, database, "all-vs-u3")
ch1 := seedVoiceChannel(t, database, "all-vs-ch1")
ch2 := seedVoiceChannel(t, database, "all-vs-ch2")
_ = database.JoinVoiceChannel(u1, ch1)
_ = database.JoinVoiceChannel(u2, ch1)
_ = database.JoinVoiceChannel(u3, ch2)
states, err := database.GetAllVoiceStates()
if err != nil {
t.Fatalf("GetAllVoiceStates: %v", err)
}
if len(states) != 3 {
t.Errorf("got %d states, want 3", len(states))
}
}
// ─── CountActiveCameras ─────────────────────────────────────────────────────
func TestVoice_CountActiveCameras_Zero(t *testing.T) {
database := newVoiceTestDB(t)
chanID := seedVoiceChannel(t, database, "cam-count-empty")
count, err := database.CountActiveCameras(chanID)
if err != nil {
t.Fatalf("CountActiveCameras: %v", err)
}
if count != 0 {
t.Errorf("count = %d, want 0", count)
}
}
func TestVoice_CountActiveCameras_SomeCameras(t *testing.T) {
database := newVoiceTestDB(t)
u1 := seedVoiceUser(t, database, "cam-cnt-u1")
u2 := seedVoiceUser(t, database, "cam-cnt-u2")
u3 := seedVoiceUser(t, database, "cam-cnt-u3")
chanID := seedVoiceChannel(t, database, "cam-cnt-ch")
_ = database.JoinVoiceChannel(u1, chanID)
_ = database.JoinVoiceChannel(u2, chanID)
_ = database.JoinVoiceChannel(u3, chanID)
_ = database.UpdateVoiceCamera(u1, true)
_ = database.UpdateVoiceCamera(u2, true)
// u3 camera stays off.
count, err := database.CountActiveCameras(chanID)
if err != nil {
t.Fatalf("CountActiveCameras: %v", err)
}
if count != 2 {
t.Errorf("count = %d, want 2", count)
}
}
// ─── EnableCameraIfUnderLimit ───────────────────────────────────────────────
func TestVoice_EnableCameraIfUnderLimit_Success(t *testing.T) {
database := newVoiceTestDB(t)
u1 := seedVoiceUser(t, database, "cam-limit-ok")
chanID := seedVoiceChannel(t, database, "cam-limit-ch")
_ = database.JoinVoiceChannel(u1, chanID)
ok, err := database.EnableCameraIfUnderLimit(u1, chanID, 2)
if err != nil {
t.Fatalf("EnableCameraIfUnderLimit: %v", err)
}
if !ok {
t.Error("expected camera to be enabled")
}
state, _ := database.GetVoiceState(u1)
if state == nil || !state.Camera {
t.Error("camera should be true after enable")
}
}
func TestVoice_EnableCameraIfUnderLimit_AtLimit(t *testing.T) {
database := newVoiceTestDB(t)
u1 := seedVoiceUser(t, database, "cam-lim-u1")
u2 := seedVoiceUser(t, database, "cam-lim-u2")
u3 := seedVoiceUser(t, database, "cam-lim-u3")
chanID := seedVoiceChannel(t, database, "cam-lim-ch")
_ = database.JoinVoiceChannel(u1, chanID)
_ = database.JoinVoiceChannel(u2, chanID)
_ = database.JoinVoiceChannel(u3, chanID)
// Enable cameras for u1 and u2 (max is 2).
_, _ = database.EnableCameraIfUnderLimit(u1, chanID, 2)
_, _ = database.EnableCameraIfUnderLimit(u2, chanID, 2)
// u3 should be denied.
ok, err := database.EnableCameraIfUnderLimit(u3, chanID, 2)
if err != nil {
t.Fatalf("EnableCameraIfUnderLimit: %v", err)
}
if ok {
t.Error("expected camera to be denied at limit")
}
}
// ─── SearchMessagesInChannels ───────────────────────────────────────────────
func TestSearchMessagesInChannels_FindsInAllowedChannels(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "srch-multi")
ch1 := seedChannel(t, database, "srch-ch1")
ch2 := seedChannel(t, database, "srch-ch2")
ch3 := seedChannel(t, database, "srch-ch3")
_, _ = database.CreateMessage(ch1, userID, "alpha keyword here", nil)
_, _ = database.CreateMessage(ch2, userID, "beta keyword here", nil)
_, _ = database.CreateMessage(ch3, userID, "gamma keyword here", nil)
// Search only in ch1 and ch2.
results, err := database.SearchMessagesInChannels("keyword", []int64{ch1, ch2}, 10)
if err != nil {
t.Fatalf("SearchMessagesInChannels: %v", err)
}
if len(results) != 2 {
t.Errorf("expected 2 results, got %d", len(results))
}
for _, r := range results {
if r.ChannelID != ch1 && r.ChannelID != ch2 {
t.Errorf("unexpected channel_id %d in results", r.ChannelID)
}
}
}
func TestSearchMessagesInChannels_EmptyQuery(t *testing.T) {
database := openMigratedMemory(t)
results, err := database.SearchMessagesInChannels("", []int64{1}, 10)
if err != nil {
t.Fatalf("SearchMessagesInChannels: %v", err)
}
if len(results) != 0 {
t.Errorf("expected 0 results for empty query, got %d", len(results))
}
}
func TestSearchMessagesInChannels_EmptyChannelIDs(t *testing.T) {
database := openMigratedMemory(t)
results, err := database.SearchMessagesInChannels("test", nil, 10)
if err != nil {
t.Fatalf("SearchMessagesInChannels: %v", err)
}
if len(results) != 0 {
t.Errorf("expected 0 results for no channels, got %d", len(results))
}
}
func TestSearchMessagesInChannels_LimitRespected(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "srch-lim")
ch1 := seedChannel(t, database, "srch-lim-ch")
for range 5 {
_, _ = database.CreateMessage(ch1, userID, "findme content here", nil)
}
results, err := database.SearchMessagesInChannels("findme", []int64{ch1}, 2)
if err != nil {
t.Fatalf("SearchMessagesInChannels: %v", err)
}
if len(results) != 2 {
t.Errorf("expected 2 results (limit), got %d", len(results))
}
}
func TestSearchMessagesInChannels_ZeroLimit(t *testing.T) {
database := openMigratedMemory(t)
results, err := database.SearchMessagesInChannels("test", []int64{1}, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(results) != 0 {
t.Errorf("expected 0 results for zero limit, got %d", len(results))
}
}
// ─── GetPinnedMessages ──────────────────────────────────────────────────────
func TestGetPinnedMessages_Empty(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "pin-empty-u")
chID := seedChannel(t, database, "pin-empty")
msgs, err := database.GetPinnedMessages(chID, userID)
if err != nil {
t.Fatalf("GetPinnedMessages: %v", err)
}
if len(msgs) != 0 {
t.Errorf("expected 0 pinned messages, got %d", len(msgs))
}
}
func TestGetPinnedMessages_ReturnsPinnedOnly(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "pin-user")
chID := seedChannel(t, database, "pin-ch")
id1, _ := database.CreateMessage(chID, userID, "pinned msg", nil)
_, _ = database.CreateMessage(chID, userID, "not pinned", nil)
_ = database.SetMessagePinned(id1, true)
msgs, err := database.GetPinnedMessages(chID, userID)
if err != nil {
t.Fatalf("GetPinnedMessages: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("expected 1 pinned message, got %d", len(msgs))
}
if msgs[0].Content != "pinned msg" {
t.Errorf("Content = %q, want 'pinned msg'", msgs[0].Content)
}
if !msgs[0].Pinned {
t.Error("expected Pinned=true")
}
}
// ─── SetMessagePinned ───────────────────────────────────────────────────────
func TestSetMessagePinned_Pin(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "setpin-u")
chID := seedChannel(t, database, "setpin-ch")
id, _ := database.CreateMessage(chID, userID, "to pin", nil)
if err := database.SetMessagePinned(id, true); err != nil {
t.Fatalf("SetMessagePinned(true): %v", err)
}
msg, _ := database.GetMessage(id)
if msg == nil || !msg.Pinned {
t.Error("message should be pinned")
}
}
func TestSetMessagePinned_Unpin(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "unpin-u")
chID := seedChannel(t, database, "unpin-ch")
id, _ := database.CreateMessage(chID, userID, "to unpin", nil)
_ = database.SetMessagePinned(id, true)
if err := database.SetMessagePinned(id, false); err != nil {
t.Fatalf("SetMessagePinned(false): %v", err)
}
msg, _ := database.GetMessage(id)
if msg == nil || msg.Pinned {
t.Error("message should not be pinned")
}
}
func TestSetMessagePinned_NotFound(t *testing.T) {
database := openMigratedMemory(t)
err := database.SetMessagePinned(99999, true)
if err == nil {
t.Error("expected error for non-existent message")
}
}
func TestSetMessagePinned_DeletedMessage(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "pin-del-u")
chID := seedChannel(t, database, "pin-del-ch")
id, _ := database.CreateMessage(chID, userID, "deleted", nil)
_ = database.DeleteMessage(id, userID, false)
err := database.SetMessagePinned(id, true)
if err == nil {
t.Error("expected error when pinning deleted message")
}
}
// ─── CreateAttachment ───────────────────────────────────────────────────────
func TestCreateAttachment_Success(t *testing.T) {
database := openMigratedMemory(t)
err := database.CreateAttachment("att-001", "photo.png", "stored-001.png", "image/png", 12345, nil, nil)
if err != nil {
t.Fatalf("CreateAttachment: %v", err)
}
att, err := database.GetAttachmentByID("att-001")
if err != nil {
t.Fatalf("GetAttachmentByID: %v", err)
}
if att == nil {
t.Fatal("expected attachment, got nil")
}
if att.Filename != "photo.png" {
t.Errorf("Filename = %q, want 'photo.png'", att.Filename)
}
if att.Size != 12345 {
t.Errorf("Size = %d, want 12345", att.Size)
}
if att.MimeType != "image/png" {
t.Errorf("MimeType = %q, want 'image/png'", att.MimeType)
}
}
func TestCreateAttachment_WithDimensions(t *testing.T) {
database := openMigratedMemory(t)
w, h := 1920, 1080
err := database.CreateAttachment("att-dim", "photo.jpg", "stored-dim.jpg", "image/jpeg", 54321, &w, &h)
if err != nil {
t.Fatalf("CreateAttachment with dims: %v", err)
}
att, _ := database.GetAttachmentByID("att-dim")
if att == nil {
t.Fatal("expected attachment")
}
}
// ─── DeleteOrphanedAttachments ──────────────────────────────────────────────
func TestDeleteOrphanedAttachments_RemovesOrphans(t *testing.T) {
database := openMigratedMemory(t)
// Create an unlinked attachment (message_id IS NULL).
_ = database.CreateAttachment("orphan-1", "file.txt", "stored-orphan.txt", "text/plain", 100, nil, nil)
// Use a cutoff far in the future so the attachment is considered old.
files, err := database.DeleteOrphanedAttachments("2099-01-01T00:00:00Z")
if err != nil {
t.Fatalf("DeleteOrphanedAttachments: %v", err)
}
if len(files) != 1 {
t.Fatalf("expected 1 orphan, got %d", len(files))
}
if files[0] != "stored-orphan.txt" {
t.Errorf("stored_as = %q, want 'stored-orphan.txt'", files[0])
}
// Should be removed from DB.
att, _ := database.GetAttachmentByID("orphan-1")
if att != nil {
t.Error("orphaned attachment should be deleted from DB")
}
}
func TestDeleteOrphanedAttachments_KeepsLinked(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "orphan-linked-u")
chID := seedChannel(t, database, "orphan-linked-ch")
// Create attachment and link it to a message.
_ = database.CreateAttachment("linked-1", "file.txt", "stored-linked.txt", "text/plain", 100, nil, nil)
msgID, _ := database.CreateMessage(chID, userID, "with attachment", nil)
_, _ = database.LinkAttachmentsToMessage(msgID, []string{"linked-1"})
files, err := database.DeleteOrphanedAttachments("2099-01-01T00:00:00Z")
if err != nil {
t.Fatalf("DeleteOrphanedAttachments: %v", err)
}
if len(files) != 0 {
t.Errorf("expected 0 orphans (linked), got %d", len(files))
}
}
func TestDeleteOrphanedAttachments_CutoffRespected(t *testing.T) {
database := openMigratedMemory(t)
_ = database.CreateAttachment("future-1", "file.txt", "stored-future.txt", "text/plain", 100, nil, nil)
// Cutoff in the past — newly created attachment should NOT be deleted.
files, err := database.DeleteOrphanedAttachments("2000-01-01T00:00:00Z")
if err != nil {
t.Fatalf("DeleteOrphanedAttachments: %v", err)
}
if len(files) != 0 {
t.Errorf("expected 0 orphans (cutoff too old), got %d", len(files))
}
}
// ─── GetAllChannelPermissionsForRole ────────────────────────────────────────
func TestGetAllChannelPermissionsForRole_Empty(t *testing.T) {
database := openMigratedMemory(t)
result, err := database.GetAllChannelPermissionsForRole(4)
if err != nil {
t.Fatalf("GetAllChannelPermissionsForRole: %v", err)
}
if len(result) != 0 {
t.Errorf("expected empty map, got %d entries", len(result))
}
}
func TestGetAllChannelPermissionsForRole_WithOverrides(t *testing.T) {
database := openMigratedMemory(t)
ch1, _ := database.CreateChannel("perm-ch1", "text", "", "", 0)
ch2, _ := database.CreateChannel("perm-ch2", "text", "", "", 0)
// Insert overrides for role 4.
_, _ = database.Exec(
`INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, ?, ?)`,
ch1, 4, int64(0x100), int64(0x200),
)
_, _ = database.Exec(
`INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, ?, ?)`,
ch2, 4, int64(0x300), int64(0),
)
result, err := database.GetAllChannelPermissionsForRole(4)
if err != nil {
t.Fatalf("GetAllChannelPermissionsForRole: %v", err)
}
if len(result) != 2 {
t.Fatalf("expected 2 entries, got %d", len(result))
}
if o, ok := result[ch1]; !ok || o.Allow != 0x100 || o.Deny != 0x200 {
t.Errorf("ch1 override = %+v, want allow=0x100 deny=0x200", result[ch1])
}
}
// ─── GetChannelTypes ────────────────────────────────────────────────────────
func TestGetChannelTypes_Empty(t *testing.T) {
database := openMigratedMemory(t)
result, err := database.GetChannelTypes(nil)
if err != nil {
t.Fatalf("GetChannelTypes: %v", err)
}
if len(result) != 0 {
t.Errorf("expected empty map, got %d", len(result))
}
}
func TestGetChannelTypes_ReturnsTypes(t *testing.T) {
database := openMigratedMemory(t)
ch1, _ := database.CreateChannel("type-text", "text", "", "", 0)
ch2, _ := database.CreateChannel("type-voice", "voice", "", "", 0)
result, err := database.GetChannelTypes([]int64{ch1, ch2})
if err != nil {
t.Fatalf("GetChannelTypes: %v", err)
}
if result[ch1] != "text" {
t.Errorf("ch1 type = %q, want 'text'", result[ch1])
}
if result[ch2] != "voice" {
t.Errorf("ch2 type = %q, want 'voice'", result[ch2])
}
}
func TestGetChannelTypes_NonExistentIDs(t *testing.T) {
database := openMigratedMemory(t)
result, err := database.GetChannelTypes([]int64{99999})
if err != nil {
t.Fatalf("GetChannelTypes: %v", err)
}
if len(result) != 0 {
t.Errorf("expected empty map for non-existent IDs, got %d", len(result))
}
}
// ─── CountUsersWithoutTOTP ──────────────────────────────────────────────────
func TestCountUsersWithoutTOTP_AllWithout(t *testing.T) {
database := openMigratedMemory(t)
_, _ = database.CreateUser("totp-u1", "hash", 4)
_, _ = database.CreateUser("totp-u2", "hash", 4)
count, err := database.CountUsersWithoutTOTP()
if err != nil {
t.Fatalf("CountUsersWithoutTOTP: %v", err)
}
if count != 2 {
t.Errorf("count = %d, want 2", count)
}
}
func TestCountUsersWithoutTOTP_WithTOTPSetup(t *testing.T) {
database := openMigratedMemory(t)
uid, _ := database.CreateUser("totp-with", "hash", 4)
_, _ = database.CreateUser("totp-without", "hash", 4)
secret := "JBSWY3DPEHPK3PXP"
_ = database.UpdateUserTOTPSecret(uid, &secret)
count, err := database.CountUsersWithoutTOTP()
if err != nil {
t.Fatalf("CountUsersWithoutTOTP: %v", err)
}
if count != 1 {
t.Errorf("count = %d, want 1 (one has TOTP)", count)
}
}
// ─── UpdateUserTOTPSecret ───────────────────────────────────────────────────
func TestUpdateUserTOTPSecret_Set(t *testing.T) {
database := openMigratedMemory(t)
uid, _ := database.CreateUser("totp-set", "hash", 4)
secret := "JBSWY3DPEHPK3PXP"
if err := database.UpdateUserTOTPSecret(uid, &secret); err != nil {
t.Fatalf("UpdateUserTOTPSecret(set): %v", err)
}
user, _ := database.GetUserByID(uid)
if user == nil || user.TOTPSecret == nil || *user.TOTPSecret != secret {
t.Error("TOTP secret should be set")
}
}
func TestUpdateUserTOTPSecret_Clear(t *testing.T) {
database := openMigratedMemory(t)
uid, _ := database.CreateUser("totp-clear", "hash", 4)
secret := "JBSWY3DPEHPK3PXP"
_ = database.UpdateUserTOTPSecret(uid, &secret)
if err := database.UpdateUserTOTPSecret(uid, nil); err != nil {
t.Fatalf("UpdateUserTOTPSecret(clear): %v", err)
}
user, _ := database.GetUserByID(uid)
if user == nil || user.TOTPSecret != nil {
t.Error("TOTP secret should be nil after clear")
}
}
// ─── CreateUserWithInvite ───────────────────────────────────────────────────
func TestCreateUserWithInvite_Success(t *testing.T) {
database := openMigratedMemory(t)
// Create a user who will create the invite.
creatorID, _ := database.CreateUser("invite-creator", "hash", 2)
code, err := database.CreateInvite(creatorID, 5, nil)
if err != nil {
t.Fatalf("CreateInvite: %v", err)
}
uid, err := database.CreateUserWithInvite("newuser", "hash", 4, code)
if err != nil {
t.Fatalf("CreateUserWithInvite: %v", err)
}
if uid <= 0 {
t.Errorf("expected positive user ID, got %d", uid)
}
// Verify invite use count incremented.
inv, _ := database.GetInvite(code)
if inv == nil || inv.Uses != 1 {
t.Errorf("invite uses = %v, want 1", inv)
}
}
func TestCreateUserWithInvite_InvalidCode(t *testing.T) {
database := openMigratedMemory(t)
_, err := database.CreateUserWithInvite("baduser", "hash", 4, "nonexistent-code")
if err == nil {
t.Error("expected error for invalid invite code")
}
}
func TestCreateUserWithInvite_RevokedInvite(t *testing.T) {
database := openMigratedMemory(t)
creatorID, _ := database.CreateUser("inv-revoke-creator", "hash", 2)
code, _ := database.CreateInvite(creatorID, 0, nil)
_ = database.RevokeInvite(code)
_, err := database.CreateUserWithInvite("revokeduser", "hash", 4, code)
if err == nil {
t.Error("expected error for revoked invite")
}
}
func TestCreateUserWithInvite_ExpiredInvite(t *testing.T) {
database := openMigratedMemory(t)
creatorID, _ := database.CreateUser("inv-expire-creator", "hash", 2)
// Create an invite that expires in the past.
pastTime := time.Now().Add(-1 * time.Hour)
code, _ := database.CreateInvite(creatorID, 0, &pastTime)
_, err := database.CreateUserWithInvite("expireduser", "hash", 4, code)
if err == nil {
t.Error("expected error for expired invite")
}
}
// ─── ListInvites (db layer) ─────────────────────────────────────────────────
func TestListInvites_DB_Empty(t *testing.T) {
database := openMigratedMemory(t)
invites, err := database.ListInvites()
if err != nil {
t.Fatalf("ListInvites: %v", err)
}
if len(invites) != 0 {
t.Errorf("expected 0 invites, got %d", len(invites))
}
}
func TestListInvites_DB_ReturnsAll(t *testing.T) {
database := openMigratedMemory(t)
creatorID, _ := database.CreateUser("list-inv-creator", "hash", 2)
_, _ = database.CreateInvite(creatorID, 5, nil)
_, _ = database.CreateInvite(creatorID, 0, nil)
invites, err := database.ListInvites()
if err != nil {
t.Fatalf("ListInvites: %v", err)
}
if len(invites) != 2 {
t.Errorf("expected 2 invites, got %d", len(invites))
}
}
func TestUseInviteAtomic_NonExistent(t *testing.T) {
database := openMigratedMemory(t)
err := database.UseInviteAtomic("does-not-exist")
if err == nil {
t.Error("expected error for non-existent invite")
}
}
// ─── SearchMessages edge cases ──────────────────────────────────────────────
func TestSearchMessages_EmptyQuery(t *testing.T) {
database := openMigratedMemory(t)
results, err := database.SearchMessages("", nil, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(results) != 0 {
t.Errorf("expected 0 results for empty query, got %d", len(results))
}
}
func TestSearchMessages_ZeroLimit(t *testing.T) {
database := openMigratedMemory(t)
results, err := database.SearchMessages("test", nil, 0)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(results) != 0 {
t.Errorf("expected 0 results for zero limit, got %d", len(results))
}
}
func TestSearchMessages_SpecialCharsStripped(t *testing.T) {
database := openMigratedMemory(t)
userID := seedUser(t, database, "srch-special")
chID := seedChannel(t, database, "srch-special-ch")
_, _ = database.CreateMessage(chID, userID, "hello world content", nil)
// FTS special chars should be stripped, leaving a valid query.
results, err := database.SearchMessages("hello* \"world\"", nil, 10)
if err != nil {
t.Fatalf("SearchMessages with special chars: %v", err)
}
// Should not crash, results may vary.
_ = results
}
+11 -10
View File
@@ -357,6 +357,7 @@ func (d *fakeDir) Close() error { return nil }
func (d *fakeDir) Stat() (fs.FileInfo, error) {
return fakeDirInfo{}, nil
}
func (d *fakeDir) ReadDir(n int) ([]fs.DirEntry, error) {
if d.pos > 0 {
return nil, io.EOF
@@ -367,12 +368,12 @@ func (d *fakeDir) ReadDir(n int) ([]fs.DirEntry, error) {
type fakeDirInfo struct{}
func (fakeDirInfo) Name() string { return "." }
func (fakeDirInfo) Size() int64 { return 0 }
func (fakeDirInfo) Mode() fs.FileMode { return fs.ModeDir | 0o755 }
func (fakeDirInfo) Name() string { return "." }
func (fakeDirInfo) Size() int64 { return 0 }
func (fakeDirInfo) Mode() fs.FileMode { return fs.ModeDir | 0o755 }
func (fakeDirInfo) ModTime() time.Time { return time.Time{} }
func (fakeDirInfo) IsDir() bool { return true }
func (fakeDirInfo) Sys() any { return nil }
func (fakeDirInfo) IsDir() bool { return true }
func (fakeDirInfo) Sys() any { return nil }
type fakeDirEntry struct{}
@@ -383,12 +384,12 @@ func (fakeDirEntry) Info() (fs.FileInfo, error) { return fakeFileInfo{}, nil }
type fakeFileInfo struct{}
func (fakeFileInfo) Name() string { return "001_fail.sql" }
func (fakeFileInfo) Size() int64 { return 0 }
func (fakeFileInfo) Mode() fs.FileMode { return 0o644 }
func (fakeFileInfo) Name() string { return "001_fail.sql" }
func (fakeFileInfo) Size() int64 { return 0 }
func (fakeFileInfo) Mode() fs.FileMode { return 0o644 }
func (fakeFileInfo) ModTime() time.Time { return time.Time{} }
func (fakeFileInfo) IsDir() bool { return false }
func (fakeFileInfo) Sys() any { return nil }
func (fakeFileInfo) IsDir() bool { return false }
func (fakeFileInfo) Sys() any { return nil }
func TestMigrateFSReadFileError(t *testing.T) {
database := openMemory(t)
+2 -2
View File
@@ -172,7 +172,7 @@ func TestGetMessages_BeforePagination(t *testing.T) {
userID := seedUser(t, database, "frank")
chID := seedChannel(t, database, "ch")
var ids []int64
ids := make([]int64, 0, 5)
for range 5 {
id, _ := database.CreateMessage(chID, userID, "msg", nil)
ids = append(ids, id)
@@ -568,7 +568,7 @@ func TestGetMessagesForAPI_BeforePagination(t *testing.T) {
userID := seedUser(t, database, "apipage")
chID := seedChannel(t, database, "apich")
var ids []int64
ids := make([]int64, 0, 5)
for range 5 {
id, _ := database.CreateMessage(chID, userID, "msg", nil)
ids = append(ids, id)
+8 -8
View File
@@ -45,9 +45,9 @@ func (failReadDirFS) Open(name string) (fs.File, error) {
type badDirFile struct{}
func (badDirFile) Read([]byte) (int, error) { return 0, fmt.Errorf("not a file") }
func (badDirFile) Close() error { return nil }
func (badDirFile) Stat() (fs.FileInfo, error) { return fakeDirInfo{}, nil }
func (badDirFile) Read([]byte) (int, error) { return 0, fmt.Errorf("not a file") }
func (badDirFile) Close() error { return nil }
func (badDirFile) Stat() (fs.FileInfo, error) { return fakeDirInfo{}, nil }
func (badDirFile) ReadDir(int) ([]fs.DirEntry, error) {
return nil, fmt.Errorf("readdir always fails")
}
@@ -136,7 +136,7 @@ func TestMigrate_AllMigrationsRecorded(t *testing.T) {
fsys := simpleFS(
"001_alpha.sql", "CREATE TABLE IF NOT EXISTS alpha (id INTEGER PRIMARY KEY);",
"002_beta.sql", "CREATE TABLE IF NOT EXISTS beta (id INTEGER PRIMARY KEY);",
"002_beta.sql", "CREATE TABLE IF NOT EXISTS beta (id INTEGER PRIMARY KEY);",
"003_gamma.sql", "CREATE TABLE IF NOT EXISTS gamma (id INTEGER PRIMARY KEY);",
)
@@ -457,7 +457,7 @@ func TestMigrate_PartialRunRecordsOnlyApplied(t *testing.T) {
fsys := simpleFS(
"001_good.sql", "CREATE TABLE IF NOT EXISTS partial_good (id INTEGER PRIMARY KEY);",
"002_bad.sql", "THIS IS DEFINITELY NOT SQL;",
"002_bad.sql", "THIS IS DEFINITELY NOT SQL;",
)
_ = db.MigrateFS(database, fsys) // we expect an error; ignore it here
@@ -476,9 +476,9 @@ func TestMigrate_NonSQLFilesSkipped(t *testing.T) {
database := openMemory(t)
fsys := fstest.MapFS{
"README.md": {Data: []byte("not sql")},
"001_ok.sql": {Data: []byte("CREATE TABLE IF NOT EXISTS ns_test (id INTEGER PRIMARY KEY);")},
"002_ok.go": {Data: []byte("package migrations")},
"README.md": {Data: []byte("not sql")},
"001_ok.sql": {Data: []byte("CREATE TABLE IF NOT EXISTS ns_test (id INTEGER PRIMARY KEY);")},
"002_ok.go": {Data: []byte("package migrations")},
}
if err := db.MigrateFS(database, fsys); err != nil {
+4 -2
View File
@@ -198,8 +198,10 @@ func TestMessageAPIResponse_JSONKeys(t *testing.T) {
t.Fatalf("Unmarshal: %v", err)
}
required := []string{"id", "channel_id", "user", "content", "reply_to",
"attachments", "reactions", "pinned", "edited_at", "deleted", "timestamp"}
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)
+5 -3
View File
@@ -14,9 +14,11 @@ type mockDB struct {
dmErr error
}
type chanRoleKey struct{ channelID, roleID int64 }
type chanPerm struct{ allow, deny int64 }
type dmKey struct{ userID, channelID int64 }
type (
chanRoleKey struct{ channelID, roleID int64 }
chanPerm struct{ allow, deny int64 }
dmKey struct{ userID, channelID int64 }
)
func newMockDB() *mockDB {
return &mockDB{
+5 -5
View File
@@ -280,8 +280,8 @@ func TestHasPerm_CombinedBitsAllPresent(t *testing.T) {
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
deny := int64(0x7FFFFFFF) // deny everything
allow := permissions.ReadMessages // re-allow just ReadMessages
eff := permissions.EffectivePerms(base, allow, deny)
if eff != permissions.ReadMessages {
@@ -308,8 +308,8 @@ func TestEffectivePerms_MultipleDenyMultipleAllow(t *testing.T) {
// ─── Role hierarchy simulation ──────────────────────────────────────────────
func TestRoleHierarchy_OwnerHasMorePermsThanAdmin(t *testing.T) {
ownerPerms := int64(0x7FFFFFFF) // Owner default
adminPerms := int64(0x3FFFFFFF) // Admin default (no Administrator bit)
ownerPerms := int64(0x7FFFFFFF) // Owner default
adminPerms := int64(0x3FFFFFFF) // Admin default (no Administrator bit)
if !permissions.HasAdmin(ownerPerms) {
t.Error("owner should be admin")
@@ -346,7 +346,7 @@ func TestRoleHierarchy_MemberLacksModPerms(t *testing.T) {
}
func TestRoleHierarchy_MemberHasBasicPerms(t *testing.T) {
memberPerms := int64(1635) // 0x663 = SendMessages|ReadMessages|AttachFiles|AddReactions|ConnectVoice|SpeakVoice
memberPerms := int64(1635) //nolint:gocritic // documenting the bitmask composition, not commented-out code
basicPerms := []struct {
name string
+3 -3
View File
@@ -342,7 +342,7 @@ func TestValidateFileType_ErrorMessageContainsFormat(t *testing.T) {
func TestSave_BlocksExecutable(t *testing.T) {
s := newTestStorage(t)
// Construct content with PE magic followed by padding.
content := append([]byte("MZ"), bytes.Repeat([]byte{0x00}, 100)...)
content := append([]byte("MZ"), make([]byte, 100)...)
err := s.Save("malware.exe", bytes.NewReader(content))
if err == nil {
t.Error("Save(PE executable) = nil, want error")
@@ -352,7 +352,7 @@ func TestSave_BlocksExecutable(t *testing.T) {
// TestSave_BlocksELF verifies Save rejects ELF binary content.
func TestSave_BlocksELF(t *testing.T) {
s := newTestStorage(t)
content := append([]byte("\x7fELF"), bytes.Repeat([]byte{0x00}, 100)...)
content := append([]byte("\x7fELF"), make([]byte, 100)...)
err := s.Save("linux-binary", bytes.NewReader(content))
if err == nil {
t.Error("Save(ELF binary) = nil, want error")
@@ -372,7 +372,7 @@ func TestSave_BlocksShellScript(t *testing.T) {
// TestSave_AllowsPNG verifies Save still accepts legitimate image content after magic check.
func TestSave_AllowsPNG(t *testing.T) {
s := newTestStorage(t)
content := append([]byte("\x89PNG\r\n\x1a\n"), bytes.Repeat([]byte{0x00}, 100)...)
content := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 100)...)
err := s.Save("image.png", bytes.NewReader(content))
if err != nil {
t.Errorf("Save(PNG) = %v, want nil", err)
+323
View File
@@ -0,0 +1,323 @@
package updater
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
// ─── FindClientAssets ───────────────────────────────────────────────────────
func TestFindClientAssets_NilCache(t *testing.T) {
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
ca := u.FindClientAssets()
if ca.InstallerURL != "" || ca.SignatureURL != "" {
t.Error("expected empty ClientAssets when no cache")
}
}
func TestFindClientAssets_WithMatchingAssets(t *testing.T) {
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
u.mu.Lock()
u.cache = &UpdateInfo{
Assets: []Asset{
{Name: "OwnCord_1.0.0_x64-setup.nsis.zip", DownloadURL: "https://example.com/installer.zip"},
{Name: "OwnCord_1.0.0_x64-setup.nsis.zip.sig", DownloadURL: "https://example.com/installer.zip.sig"},
{Name: "chatserver.exe", DownloadURL: "https://example.com/chatserver.exe"},
},
}
u.mu.Unlock()
ca := u.FindClientAssets()
if ca.InstallerURL != "https://example.com/installer.zip" {
t.Errorf("InstallerURL = %q, want installer URL", ca.InstallerURL)
}
if ca.SignatureURL != "https://example.com/installer.zip.sig" {
t.Errorf("SignatureURL = %q, want signature URL", ca.SignatureURL)
}
}
func TestFindClientAssets_NoMatchingAssets(t *testing.T) {
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
u.mu.Lock()
u.cache = &UpdateInfo{
Assets: []Asset{
{Name: "chatserver.exe", DownloadURL: "https://example.com/chatserver.exe"},
{Name: "checksums.sha256", DownloadURL: "https://example.com/checksums.sha256"},
},
}
u.mu.Unlock()
ca := u.FindClientAssets()
if ca.InstallerURL != "" || ca.SignatureURL != "" {
t.Error("expected empty ClientAssets when no NSIS assets")
}
}
// ─── FetchTextAsset ─────────────────────────────────────────────────────────
func TestFetchTextAsset_Success(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("dW50cnVzdGVkIGNvbW1lbnQ="))
}))
defer srv.Close()
u := newTestUpdater(srv.URL, "1.0.0")
text, err := u.FetchTextAsset(context.Background(), srv.URL+"/sig.txt")
if err != nil {
t.Fatalf("FetchTextAsset: %v", err)
}
if text != "dW50cnVzdGVkIGNvbW1lbnQ=" {
t.Errorf("text = %q, want 'dW50cnVzdGVkIGNvbW1lbnQ='", text)
}
}
func TestFetchTextAsset_Error(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u := newTestUpdater(srv.URL, "1.0.0")
_, err := u.FetchTextAsset(context.Background(), srv.URL+"/missing.sig")
if err == nil {
t.Error("expected error for 404 response")
}
}
// ─── shouldSendToken ────────────────────────────────────────────────────────
func TestShouldSendToken_GitHubHost(t *testing.T) {
u := NewUpdater("1.0.0", "tok", "J3vb", "OwnCord")
tests := []struct {
url string
want bool
}{
{"https://api.github.com/repos/foo/bar", true},
{"https://github.com/releases/download/v1", true},
{"https://objects.githubusercontent.com/asset", true},
{"https://evil.com/malicious", false},
{"https://notgithub.example.com/foo", false},
}
for _, tc := range tests {
got := u.shouldSendToken(tc.url)
if got != tc.want {
t.Errorf("shouldSendToken(%q) = %v, want %v", tc.url, got, tc.want)
}
}
}
func TestShouldSendToken_CustomBaseURL(t *testing.T) {
u := NewUpdater("1.0.0", "tok", "J3vb", "OwnCord")
u.baseURL = "http://localhost:9090"
if !u.shouldSendToken("http://localhost:9090/repos/foo/bar") {
t.Error("expected true for URL matching baseURL")
}
if u.shouldSendToken("http://localhost:8080/different") {
t.Error("expected false for URL not matching baseURL")
}
}
// ─── isGitHubHost ───────────────────────────────────────────────────────────
func TestIsGitHubHost(t *testing.T) {
tests := []struct {
url string
want bool
}{
{"https://api.github.com/repos", true},
{"https://github.com/J3vb/OwnCord", true},
{"https://objects.githubusercontent.com/asset", true},
{"https://raw.githubusercontent.com/file", true},
{"https://evil.com", false},
{"not a valid url \x00", false},
{"https://fakegithub.com", false},
}
for _, tc := range tests {
got := isGitHubHost(tc.url)
if got != tc.want {
t.Errorf("isGitHubHost(%q) = %v, want %v", tc.url, got, tc.want)
}
}
}
// ─── ensureVPrefix ──────────────────────────────────────────────────────────
func TestEnsureVPrefix(t *testing.T) {
tests := []struct {
input string
want string
}{
{"1.0.0", "v1.0.0"},
{"v1.0.0", "v1.0.0"},
{"0.0.1", "v0.0.1"},
{"v0.0.1", "v0.0.1"},
}
for _, tc := range tests {
got := ensureVPrefix(tc.input)
if got != tc.want {
t.Errorf("ensureVPrefix(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}
// ─── CheckForUpdate error caching ───────────────────────────────────────────
func TestCheckForUpdate_ErrorCaching(t *testing.T) {
var hitCount int
mux := http.NewServeMux()
mux.HandleFunc("/repos/J3vb/OwnCord/releases/latest", func(w http.ResponseWriter, r *http.Request) {
hitCount++
w.WriteHeader(http.StatusInternalServerError)
_, _ = fmt.Fprint(w, `{"message":"error"}`)
})
srv := httptest.NewServer(mux)
defer srv.Close()
u := newTestUpdater(srv.URL, "1.0.0")
// First call should error and cache.
_, err := u.CheckForUpdate(context.Background())
if err == nil {
t.Fatal("expected error")
}
// Second call should use cached error.
_, err = u.CheckForUpdate(context.Background())
if err == nil {
t.Fatal("expected cached error")
}
if hitCount != 1 {
t.Errorf("expected 1 API hit (error cached), got %d", hitCount)
}
}
// ─── CheckForUpdate with assets list ────────────────────────────────────────
func TestCheckForUpdate_IncludesAssetsList(t *testing.T) {
release := ghRelease{
TagName: "v2.0.0",
Body: "notes",
HTMLURL: "https://github.com/J3vb/OwnCord/releases/tag/v2.0.0",
Assets: []ghAsset{
{Name: "chatserver.exe", BrowserDownloadURL: "https://example.com/chatserver.exe"},
{Name: "checksums.sha256", BrowserDownloadURL: "https://example.com/checksums.sha256"},
{Name: "OwnCord_2.0.0_x64-setup.nsis.zip", BrowserDownloadURL: "https://example.com/installer.zip"},
},
}
mux := http.NewServeMux()
mux.HandleFunc("/repos/J3vb/OwnCord/releases/latest", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(release)
})
srv := httptest.NewServer(mux)
defer srv.Close()
u := newTestUpdater(srv.URL, "1.0.0")
info, err := u.CheckForUpdate(context.Background())
if err != nil {
t.Fatalf("CheckForUpdate: %v", err)
}
if len(info.Assets) != 3 {
t.Errorf("expected 3 assets, got %d", len(info.Assets))
}
}
// ─── downloadFile with auth token ───────────────────────────────────────────
func TestDownloadFile_SendsTokenToGitHub(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("data"))
}))
defer srv.Close()
u := NewUpdater("1.0.0", "my-secret-token", "J3vb", "OwnCord")
u.baseURL = srv.URL
dest := t.TempDir() + "/download.bin"
err := u.downloadFile(context.Background(), srv.URL+"/file", dest)
if err != nil {
t.Fatalf("downloadFile: %v", err)
}
if gotAuth != "token my-secret-token" {
t.Errorf("Authorization = %q, want 'token my-secret-token'", gotAuth)
}
}
func TestDownloadFile_NoTokenToExternalHost(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("data"))
}))
defer srv.Close()
u := NewUpdater("1.0.0", "my-secret-token", "J3vb", "OwnCord")
// baseURL is NOT set to srv.URL, so shouldSendToken returns false.
dest := t.TempDir() + "/download2.bin"
err := u.downloadFile(context.Background(), srv.URL+"/file", dest)
if err != nil {
t.Fatalf("downloadFile: %v", err)
}
if gotAuth != "" {
t.Errorf("expected no Authorization header for external host, got %q", gotAuth)
}
}
// ─── ParseChecksumFile edge cases ───────────────────────────────────────────
func TestParseChecksumFile_SingleSpace(t *testing.T) {
// Some tools output single-space instead of double-space.
data := []byte("abc123 chatserver.exe\n")
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
hash, err := u.ParseChecksumFile(data, "chatserver.exe")
if err != nil {
t.Fatalf("ParseChecksumFile single space: %v", err)
}
if hash != "abc123" {
t.Errorf("hash = %q, want 'abc123'", hash)
}
}
func TestParseChecksumFile_EmptyLines(t *testing.T) {
data := []byte("\n\nabc123 chatserver.exe\n\n")
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
hash, err := u.ParseChecksumFile(data, "chatserver.exe")
if err != nil {
t.Fatalf("ParseChecksumFile with empty lines: %v", err)
}
if hash != "abc123" {
t.Errorf("hash = %q, want 'abc123'", hash)
}
}
func TestParseChecksumFile_EmptyData(t *testing.T) {
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
_, err := u.ParseChecksumFile([]byte(""), "chatserver.exe")
if err == nil {
t.Error("expected error for empty checksum data")
}
}
// ─── VerifyChecksum file not found ──────────────────────────────────────────
func TestVerifyChecksum_FileNotFound(t *testing.T) {
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
err := u.VerifyChecksum("/nonexistent/path/to/file.exe", "abc123")
if err == nil {
t.Error("expected error for non-existent file")
}
}
+3 -2
View File
@@ -1,6 +1,7 @@
package updater
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
@@ -353,7 +354,7 @@ func TestDownloadFile_Success(t *testing.T) {
if err != nil {
t.Fatalf("reading downloaded file: %v", err)
}
if string(got) != string(content) {
if !bytes.Equal(got, content) {
t.Errorf("content = %q, want %q", got, content)
}
}
@@ -412,7 +413,7 @@ func TestDownloadAndVerify_Success(t *testing.T) {
// File should exist and be correct.
got, _ := os.ReadFile(dest)
if string(got) != string(content) {
if !bytes.Equal(got, content) {
t.Errorf("downloaded content mismatch")
}
}
+486
View File
@@ -0,0 +1,486 @@
package ws_test
import (
"encoding/json"
"testing"
"time"
"github.com/owncord/server/db"
"github.com/owncord/server/ws"
)
// ─── IsUserConnected ────────────────────────────────────────────────────────
func TestIsUserConnected_NotConnected(t *testing.T) {
hub, _ := newCoverageHub(t)
if hub.IsUserConnected(9999) {
t.Error("expected false for unregistered user")
}
}
func TestIsUserConnected_Connected(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "connected-user")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
if !hub.IsUserConnected(user.ID) {
t.Error("expected true for registered user")
}
}
func TestIsUserConnected_AfterUnregister(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "unreg-user")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
hub.Unregister(c)
time.Sleep(20 * time.Millisecond)
if hub.IsUserConnected(user.ID) {
t.Error("expected false after unregister")
}
}
// ─── qualityBitrate ─────────────────────────────────────────────────────────
func TestQualityBitrate_KnownPresets(t *testing.T) {
tests := []struct {
quality string
want int
}{
{"low", 32000},
{"medium", 64000},
{"high", 128000},
}
for _, tc := range tests {
got := ws.QualityBitrateForTest(tc.quality)
if got != tc.want {
t.Errorf("qualityBitrate(%q) = %d, want %d", tc.quality, got, tc.want)
}
}
}
func TestQualityBitrate_UnknownFallsBackToMedium(t *testing.T) {
got := ws.QualityBitrateForTest("ultra")
if got != 64000 {
t.Errorf("qualityBitrate('ultra') = %d, want 64000 (medium fallback)", got)
}
}
func TestQualityBitrate_EmptyFallsBackToMedium(t *testing.T) {
got := ws.QualityBitrateForTest("")
if got != 64000 {
t.Errorf("qualityBitrate('') = %d, want 64000 (medium fallback)", got)
}
}
// ─── buildDMChannelOpen ─────────────────────────────────────────────────────
func TestBuildDMChannelOpen_NilRecipient(t *testing.T) {
result := ws.BuildDMChannelOpenForTest(1, nil)
if result != nil {
t.Error("expected nil for nil recipient")
}
}
func TestBuildDMChannelOpen_ValidRecipient(t *testing.T) {
avatar := "avatar.png"
user := &db.User{
ID: 42,
Username: "testuser",
Avatar: &avatar,
Status: "online",
}
result := ws.BuildDMChannelOpenForTest(100, user)
if result == nil {
t.Fatal("expected non-nil result for valid recipient")
}
if !json.Valid(result) {
t.Fatalf("result is not valid JSON: %s", result)
}
var msg struct {
Type string `json:"type"`
Payload struct {
ChannelID int64 `json:"channel_id"`
Recipient struct {
ID int64 `json:"id"`
Username string `json:"username"`
Avatar string `json:"avatar"`
Status string `json:"status"`
} `json:"recipient"`
} `json:"payload"`
}
if err := json.Unmarshal(result, &msg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if msg.Payload.ChannelID != 100 {
t.Errorf("ChannelID = %d, want 100", msg.Payload.ChannelID)
}
if msg.Payload.Recipient.ID != 42 {
t.Errorf("Recipient.ID = %d, want 42", msg.Payload.Recipient.ID)
}
if msg.Payload.Recipient.Username != "testuser" {
t.Errorf("Username = %q, want 'testuser'", msg.Payload.Recipient.Username)
}
if msg.Payload.Recipient.Avatar != "avatar.png" {
t.Errorf("Avatar = %q, want 'avatar.png'", msg.Payload.Recipient.Avatar)
}
}
func TestBuildDMChannelOpen_NilAvatar(t *testing.T) {
user := &db.User{
ID: 43,
Username: "noavatar",
Avatar: nil,
Status: "offline",
}
result := ws.BuildDMChannelOpenForTest(200, user)
if result == nil {
t.Fatal("expected non-nil result")
}
var msg struct {
Payload struct {
Recipient struct {
Avatar string `json:"avatar"`
} `json:"recipient"`
} `json:"payload"`
}
_ = json.Unmarshal(result, &msg)
if msg.Payload.Recipient.Avatar != "" {
t.Errorf("Avatar = %q, want empty string for nil avatar", msg.Payload.Recipient.Avatar)
}
}
// ─── broadcastVoiceStateUpdate ──────────────────────────────────────────────
func TestBroadcastVoiceStateUpdate_NotInVoice(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "bvsu-noop")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
// User not in voice — should be a no-op, no crash.
hub.BroadcastVoiceStateUpdateForTest(c)
}
func TestBroadcastVoiceStateUpdate_InVoice(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "bvsu-voice")
// Create a voice channel.
chanID, err := database.CreateChannel("bvsu-ch", "voice", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
// Join the voice channel in DB.
if err := database.JoinVoiceChannel(user.ID, chanID); err != nil {
t.Fatalf("JoinVoiceChannel: %v", err)
}
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
ws.SetClientVoiceChID(c, chanID)
hub.Register(c)
time.Sleep(20 * time.Millisecond)
// Should broadcast a voice_state message.
hub.BroadcastVoiceStateUpdateForTest(c)
// Drain the channel and check for voice_state message.
time.Sleep(20 * time.Millisecond)
found := false
for len(send) > 0 {
msg := <-send
var m struct {
Type string `json:"type"`
}
_ = json.Unmarshal(msg, &m)
if m.Type == "voice_state" {
found = true
}
}
if !found {
t.Error("expected voice_state broadcast")
}
}
// ─── handleVoiceMute via HandleMessageForTest ───────────────────────────────
func TestHandleVoiceMute_NotInVoice2(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "mute-not-in-voice")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.RegisterNowForTest(c)
payload := `{"muted":true}`
raw, _ := json.Marshal(map[string]any{"type": "voice_mute", "payload": json.RawMessage(payload)})
hub.HandleMessageForTest(c, raw)
// Should receive an error about not being in a voice channel.
time.Sleep(10 * time.Millisecond)
found := false
for len(send) > 0 {
msg := <-send
var m struct {
Type string `json:"type"`
Payload struct {
Message string `json:"message"`
} `json:"payload"`
}
_ = json.Unmarshal(msg, &m)
if m.Type == "error" {
found = true
}
}
if !found {
t.Error("expected error message when not in voice channel")
}
}
// ─── handleVoiceDeafen not in voice ─────────────────────────────────────────
func TestHandleVoiceDeafen_NotInVoice2(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "deafen-not-voice")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.RegisterNowForTest(c)
payload := `{"deafened":true}`
raw, _ := json.Marshal(map[string]any{"type": "voice_deafen", "payload": json.RawMessage(payload)})
hub.HandleMessageForTest(c, raw)
time.Sleep(10 * time.Millisecond)
found := false
for len(send) > 0 {
msg := <-send
var m struct {
Type string `json:"type"`
}
_ = json.Unmarshal(msg, &m)
if m.Type == "error" {
found = true
}
}
if !found {
t.Error("expected error message when not in voice channel")
}
}
// ─── handleVoiceCamera not in voice ─────────────────────────────────────────
func TestHandleVoiceCamera_NotInVoice2(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "cam-not-voice")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.RegisterNowForTest(c)
payload := `{"enabled":true}`
raw, _ := json.Marshal(map[string]any{"type": "voice_camera", "payload": json.RawMessage(payload)})
hub.HandleMessageForTest(c, raw)
time.Sleep(10 * time.Millisecond)
found := false
for len(send) > 0 {
msg := <-send
var m struct {
Type string `json:"type"`
}
_ = json.Unmarshal(msg, &m)
if m.Type == "error" {
found = true
}
}
if !found {
t.Error("expected error message when not in voice channel")
}
}
// ─── handleVoiceScreenshare not in voice ────────────────────────────────────
func TestHandleVoiceScreenshare_NotInVoice2(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "share-not-voice")
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.RegisterNowForTest(c)
payload := `{"enabled":true}`
raw, _ := json.Marshal(map[string]any{"type": "voice_screenshare", "payload": json.RawMessage(payload)})
hub.HandleMessageForTest(c, raw)
time.Sleep(10 * time.Millisecond)
found := false
for len(send) > 0 {
msg := <-send
var m struct {
Type string `json:"type"`
}
_ = json.Unmarshal(msg, &m)
if m.Type == "error" {
found = true
}
}
if !found {
t.Error("expected error message when not in voice channel")
}
}
// ─── handleVoiceMute/Deafen bad payload ─────────────────────────────────────
func TestHandleVoiceMute_BadPayload(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "mute-bad-payload")
chanID, _ := database.CreateChannel("mute-bp-ch", "voice", "", "", 0)
_ = database.JoinVoiceChannel(user.ID, chanID)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
ws.SetClientVoiceChID(c, chanID)
hub.RegisterNowForTest(c)
raw, _ := json.Marshal(map[string]any{"type": "voice_mute", "payload": json.RawMessage(`{invalid json`)})
hub.HandleMessageForTest(c, raw)
time.Sleep(10 * time.Millisecond)
found := false
for len(send) > 0 {
msg := <-send
var m struct {
Type string `json:"type"`
}
_ = json.Unmarshal(msg, &m)
if m.Type == "error" {
found = true
}
}
if !found {
t.Error("expected error for bad payload")
}
}
func TestHandleVoiceDeafen_BadPayload(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "deafen-bad-payload")
chanID, _ := database.CreateChannel("deafen-bp-ch", "voice", "", "", 0)
_ = database.JoinVoiceChannel(user.ID, chanID)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
ws.SetClientVoiceChID(c, chanID)
hub.RegisterNowForTest(c)
raw, _ := json.Marshal(map[string]any{"type": "voice_deafen", "payload": json.RawMessage(`not json`)})
hub.HandleMessageForTest(c, raw)
time.Sleep(10 * time.Millisecond)
found := false
for len(send) > 0 {
msg := <-send
var m struct {
Type string `json:"type"`
}
_ = json.Unmarshal(msg, &m)
if m.Type == "error" {
found = true
}
}
if !found {
t.Error("expected error for bad payload")
}
}
// ─── handleVoiceCamera bad payload ──────────────────────────────────────────
func TestHandleVoiceCamera_BadPayload(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "cam-bad-payload")
chanID, _ := database.CreateChannel("cam-bp-ch", "voice", "", "", 0)
_ = database.JoinVoiceChannel(user.ID, chanID)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
ws.SetClientVoiceChID(c, chanID)
ws.SetClientVoiceStateForTest(c, chanID, "join-token-fake")
hub.RegisterNowForTest(c)
raw, _ := json.Marshal(map[string]any{"type": "voice_camera", "payload": json.RawMessage(`{bad`)})
hub.HandleMessageForTest(c, raw)
time.Sleep(10 * time.Millisecond)
found := false
for len(send) > 0 {
msg := <-send
var m struct {
Type string `json:"type"`
}
_ = json.Unmarshal(msg, &m)
if m.Type == "error" {
found = true
}
}
if !found {
t.Error("expected error for bad payload")
}
}
// ─── handleVoiceScreenshare bad payload ─────────────────────────────────────
func TestHandleVoiceScreenshare_BadPayload(t *testing.T) {
hub, database := newCoverageHub(t)
user := seedCoverageOwner(t, database, "share-bad-payload")
chanID, _ := database.CreateChannel("share-bp-ch", "voice", "", "", 0)
_ = database.JoinVoiceChannel(user.ID, chanID)
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
ws.SetClientVoiceChID(c, chanID)
ws.SetClientVoiceStateForTest(c, chanID, "join-token-fake")
hub.RegisterNowForTest(c)
raw, _ := json.Marshal(map[string]any{"type": "voice_screenshare", "payload": json.RawMessage(`{bad`)})
hub.HandleMessageForTest(c, raw)
time.Sleep(10 * time.Millisecond)
found := false
for len(send) > 0 {
msg := <-send
var m struct {
Type string `json:"type"`
}
_ = json.Unmarshal(msg, &m)
if m.Type == "error" {
found = true
}
}
if !found {
t.Error("expected error for bad payload")
}
}
// ─── leaveVoiceChannelWithRetry empty token ─────────────────────────────────
func TestLeaveVoiceChannelWithRetry_EmptyToken(t *testing.T) {
hub, _ := newCoverageHub(t)
err := ws.LeaveVoiceChannelWithRetryForTest(hub, 1, 1, "")
if err != nil {
t.Errorf("expected nil error for empty token, got %v", err)
}
}
+1 -3
View File
@@ -204,7 +204,7 @@ func TestGracefulStop_MultipleClients(t *testing.T) {
hub, database := newCoverageHub(t)
for i := range 5 {
user := seedCoverageOwner(t, database, strings.ReplaceAll("graceful-multi-"+string(rune('a'+i)), "", ""))
user := seedCoverageOwner(t, database, "graceful-multi-"+string(rune('a'+i)))
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, 0, send)
hub.Register(c)
@@ -2692,5 +2692,3 @@ func TestBroadcastToAll_DropsWhenFull(t *testing.T) {
hub.BroadcastToAll([]byte(`{"type":"test"}`))
}
}
+15
View File
@@ -171,6 +171,21 @@ func (c *Client) ClearVoiceStateForTest() {
c.clearVoiceState()
}
// QualityBitrateForTest exposes qualityBitrate for external tests.
func QualityBitrateForTest(quality string) int {
return qualityBitrate(quality)
}
// BuildDMChannelOpenForTest exposes buildDMChannelOpen for external tests.
func BuildDMChannelOpenForTest(channelID int64, recipient *db.User) []byte {
return buildDMChannelOpen(channelID, recipient)
}
// BroadcastVoiceStateUpdateForTest exposes broadcastVoiceStateUpdate for external tests.
func (h *Hub) BroadcastVoiceStateUpdateForTest(c *Client) {
h.broadcastVoiceStateUpdate(c)
}
// HandleWebhookParticipantLeftForTest exposes handleWebhookParticipantLeft for
// external tests so they can simulate LiveKit webhook events without HTTP.
func (h *Hub) HandleWebhookParticipantLeftForTest(userID int64, channelID int64, joinToken string) {
+1 -1
View File
@@ -354,7 +354,7 @@ func TestHub_ChatSend_RateLimit(t *testing.T) {
// Drain all messages, count errors.
errCount := 0
drainLoop:
drainLoop:
for {
select {
case got := <-send:
-1
View File
@@ -14,7 +14,6 @@ import (
"github.com/owncord/server/ws"
)
// ---------------------------------------------------------------------------
// livekit.go tests
// ---------------------------------------------------------------------------
+25 -25
View File
@@ -249,11 +249,11 @@ func TestOldestSeq_AfterWraparound(t *testing.T) {
func TestConcurrent_PushAndEventsSince(t *testing.T) {
const (
cap = 64
writers = 4
pushes = 500
readers = 4
reads = 500
cap = 64
writers = 4
pushes = 500
readers = 4
reads = 500
)
rb := ws.NewEventRingBuffer(cap)
@@ -295,11 +295,11 @@ func TestConcurrent_PushAndEventsSince(t *testing.T) {
func TestEventsSince_CapacityBoundaries(t *testing.T) {
tests := []struct {
name string
cap int
pushes int
afterSeq uint64
wantLen int // -1 means nil
name string
cap int
pushes int
afterSeq uint64
wantLen int // -1 means nil
wantFirst string
}{
{
@@ -310,11 +310,11 @@ func TestEventsSince_CapacityBoundaries(t *testing.T) {
wantLen: -1,
},
{
name: "exactly at capacity, from oldest",
cap: 4,
pushes: 4,
afterSeq: 1,
wantLen: 3,
name: "exactly at capacity, from oldest",
cap: 4,
pushes: 4,
afterSeq: 1,
wantLen: 3,
wantFirst: "e2",
},
{
@@ -325,19 +325,19 @@ func TestEventsSince_CapacityBoundaries(t *testing.T) {
wantLen: -1,
},
{
name: "one past capacity, valid afterSeq",
cap: 4,
pushes: 5,
afterSeq: 2,
wantLen: 3,
name: "one past capacity, valid afterSeq",
cap: 4,
pushes: 5,
afterSeq: 2,
wantLen: 3,
wantFirst: "e3",
},
{
name: "double capacity",
cap: 4,
pushes: 8,
afterSeq: 5,
wantLen: 3,
name: "double capacity",
cap: 4,
pushes: 8,
afterSeq: 5,
wantLen: 3,
wantFirst: "e6",
},
{
+75 -30
View File
@@ -31,7 +31,7 @@ func TestServeWS_InvalidUpgrade_ReturnsError(t *testing.T) {
defer hub.Stop()
handler := ws.ServeWS(hub, database, []string{"*"})
srv := httptest.NewServer(http.HandlerFunc(handler))
srv := httptest.NewServer(handler)
defer srv.Close()
// Plain GET without WebSocket upgrade headers should fail gracefully.
@@ -59,14 +59,17 @@ func TestAuthenticateConn_NoAuthMessage_ServerClosesConn(t *testing.T) {
defer hub.Stop()
handler := ws.ServeWS(hub, database, []string{"*"})
srv := httptest.NewServer(http.HandlerFunc(handler))
srv := httptest.NewServer(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)
conn, dialResp, err := websocket.Dial(ctx, wsURL, nil)
if dialResp != nil && dialResp.Body != nil {
defer dialResp.Body.Close() //nolint:errcheck // test cleanup
}
if err != nil {
t.Fatalf("websocket.Dial: %v", err)
}
@@ -94,14 +97,17 @@ func TestAuthenticateConn_InvalidJSON_ReceivesAuthError(t *testing.T) {
defer hub.Stop()
handler := ws.ServeWS(hub, database, []string{"*"})
srv := httptest.NewServer(http.HandlerFunc(handler))
srv := httptest.NewServer(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)
conn, dialResp2, err := websocket.Dial(ctx, wsURL, nil)
if dialResp2 != nil && dialResp2.Body != nil {
defer dialResp2.Body.Close() //nolint:errcheck // test cleanup
}
if err != nil {
t.Fatalf("websocket.Dial: %v", err)
}
@@ -137,14 +143,17 @@ func TestAuthenticateConn_WrongMessageType_ReceivesAuthError(t *testing.T) {
defer hub.Stop()
handler := ws.ServeWS(hub, database, []string{"*"})
srv := httptest.NewServer(http.HandlerFunc(handler))
srv := httptest.NewServer(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)
conn, resp, err := websocket.Dial(ctx, wsURL, nil)
if resp != nil && resp.Body != nil {
defer resp.Body.Close()
}
if err != nil {
t.Fatalf("websocket.Dial: %v", err)
}
@@ -183,14 +192,17 @@ func TestAuthenticateConn_MissingToken_ReceivesAuthError(t *testing.T) {
defer hub.Stop()
handler := ws.ServeWS(hub, database, []string{"*"})
srv := httptest.NewServer(http.HandlerFunc(handler))
srv := httptest.NewServer(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)
conn, resp, err := websocket.Dial(ctx, wsURL, nil)
if resp != nil && resp.Body != nil {
defer resp.Body.Close()
}
if err != nil {
t.Fatalf("websocket.Dial: %v", err)
}
@@ -228,14 +240,17 @@ func TestAuthenticateConn_InvalidToken_ReceivesAuthError(t *testing.T) {
defer hub.Stop()
handler := ws.ServeWS(hub, database, []string{"*"})
srv := httptest.NewServer(http.HandlerFunc(handler))
srv := httptest.NewServer(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)
conn, resp, err := websocket.Dial(ctx, wsURL, nil)
if resp != nil && resp.Body != nil {
defer resp.Body.Close()
}
if err != nil {
t.Fatalf("websocket.Dial: %v", err)
}
@@ -287,14 +302,17 @@ func TestServeWS_ValidAuth_FullHandshake(t *testing.T) {
}
handler := ws.ServeWS(hub, database, []string{"*"})
srv := httptest.NewServer(http.HandlerFunc(handler))
srv := httptest.NewServer(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)
conn, resp, err := websocket.Dial(ctx, wsURL, nil)
if resp != nil && resp.Body != nil {
defer resp.Body.Close()
}
if err != nil {
t.Fatalf("websocket.Dial: %v", err)
}
@@ -364,14 +382,17 @@ func TestServeWS_ImmediateDisconnect_DoesNotLeaveGhostClient(t *testing.T) {
}
handler := ws.ServeWS(hub, database, []string{"*"})
srv := httptest.NewServer(http.HandlerFunc(handler))
srv := httptest.NewServer(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)
conn, resp, err := websocket.Dial(ctx, wsURL, nil)
if resp != nil && resp.Body != nil {
defer resp.Body.Close()
}
if err != nil {
t.Fatalf("websocket.Dial: %v", err)
}
@@ -437,7 +458,7 @@ func TestServeWS_DuplicateLogin_KeepsUserOnline(t *testing.T) {
}
handler := ws.ServeWS(hub, database, []string{"*"})
srv := httptest.NewServer(http.HandlerFunc(handler))
srv := httptest.NewServer(handler)
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
@@ -445,7 +466,10 @@ func TestServeWS_DuplicateLogin_KeepsUserOnline(t *testing.T) {
defer cancel()
dialAndAuth := func() *websocket.Conn {
conn, _, dialErr := websocket.Dial(ctx, wsURL, nil)
conn, dialResp, dialErr := websocket.Dial(ctx, wsURL, nil)
if dialResp != nil && dialResp.Body != nil {
dialResp.Body.Close()
}
if dialErr != nil {
t.Fatalf("websocket.Dial: %v", dialErr)
}
@@ -522,7 +546,7 @@ func TestServeWS_Reconnect_PreservesVoiceState(t *testing.T) {
}
handler := ws.ServeWS(hub, database, []string{"*"})
srv := httptest.NewServer(http.HandlerFunc(handler))
srv := httptest.NewServer(handler)
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
@@ -531,7 +555,10 @@ func TestServeWS_Reconnect_PreservesVoiceState(t *testing.T) {
dialAndAuth := func(lastSeq uint64) *websocket.Conn {
t.Helper()
conn, _, dialErr := websocket.Dial(ctx, wsURL, nil)
conn, dialResp, dialErr := websocket.Dial(ctx, wsURL, nil)
if dialResp != nil && dialResp.Body != nil {
dialResp.Body.Close()
}
if dialErr != nil {
t.Fatalf("websocket.Dial: %v", dialErr)
}
@@ -687,7 +714,7 @@ func TestServeWS_FreshReconnect_CleansStaleVoiceState(t *testing.T) {
}
handler := ws.ServeWS(hub, database, []string{"*"})
srv := httptest.NewServer(http.HandlerFunc(handler))
srv := httptest.NewServer(handler)
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
@@ -696,7 +723,10 @@ func TestServeWS_FreshReconnect_CleansStaleVoiceState(t *testing.T) {
dialAndAuthFresh := func(tok string) *websocket.Conn {
t.Helper()
conn, _, dialErr := websocket.Dial(ctx, wsURL, nil)
conn, dialResp, dialErr := websocket.Dial(ctx, wsURL, nil)
if dialResp != nil && dialResp.Body != nil {
dialResp.Body.Close()
}
if dialErr != nil {
t.Fatalf("websocket.Dial: %v", dialErr)
}
@@ -724,7 +754,10 @@ func TestServeWS_FreshReconnect_CleansStaleVoiceState(t *testing.T) {
// conn plus the parsed ready payload so the caller can inspect voice_states.
dialAndReadReady := func(tok string) (*websocket.Conn, map[string]any) {
t.Helper()
conn, _, dialErr := websocket.Dial(ctx, wsURL, nil)
conn, dialResp, dialErr := websocket.Dial(ctx, wsURL, nil)
if dialResp != nil && dialResp.Body != nil {
dialResp.Body.Close()
}
if dialErr != nil {
t.Fatalf("websocket.Dial: %v", dialErr)
}
@@ -885,14 +918,17 @@ func TestServeWS_writePump_MessageDelivered(t *testing.T) {
}
handler := ws.ServeWS(hub, database, []string{"*"})
srv := httptest.NewServer(http.HandlerFunc(handler))
srv := httptest.NewServer(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)
conn, resp, err := websocket.Dial(ctx, wsURL, nil)
if resp != nil && resp.Body != nil {
defer resp.Body.Close()
}
if err != nil {
t.Fatalf("websocket.Dial: %v", err)
}
@@ -993,7 +1029,7 @@ func TestIntegration_MessageRoundTrip(t *testing.T) {
}
handler := ws.ServeWS(hub, database, []string{"*"})
srv := httptest.NewServer(http.HandlerFunc(handler))
srv := httptest.NewServer(handler)
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
@@ -1002,7 +1038,10 @@ func TestIntegration_MessageRoundTrip(t *testing.T) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
conn, _, dialErr := websocket.Dial(ctx, wsURL, nil)
conn, dialResp, dialErr := websocket.Dial(ctx, wsURL, nil)
if dialResp != nil && dialResp.Body != nil {
dialResp.Body.Close()
}
if dialErr != nil {
t.Fatalf("%s dial: %v", label, dialErr)
}
@@ -1117,14 +1156,17 @@ func TestIntegration_SequenceNumbers(t *testing.T) {
}
handler := ws.ServeWS(hub, database, []string{"*"})
srv := httptest.NewServer(http.HandlerFunc(handler))
srv := httptest.NewServer(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)
conn, resp, err := websocket.Dial(ctx, wsURL, nil)
if resp != nil && resp.Body != nil {
defer resp.Body.Close()
}
if err != nil {
t.Fatalf("websocket.Dial: %v", err)
}
@@ -1212,14 +1254,17 @@ func TestServeWS_BannedUser_ReceivesError(t *testing.T) {
}
handler := ws.ServeWS(hub, database, []string{"*"})
srv := httptest.NewServer(http.HandlerFunc(handler))
srv := httptest.NewServer(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)
conn, resp, err := websocket.Dial(ctx, wsURL, nil)
if resp != nil && resp.Body != nil {
defer resp.Body.Close()
}
if err != nil {
t.Fatalf("websocket.Dial: %v", err)
}