mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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
@@ -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) {
|
||||
|
||||
@@ -268,4 +268,3 @@ func TestListInvites_IncludesRevokedAndActive(t *testing.T) {
|
||||
t.Errorf("ListInvites status = %d, want 200", rr2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"}`))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/owncord/server/ws"
|
||||
)
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// livekit.go tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user