feat: runtime config, Matomo analytics, chat accounts, DM history/search, and Prometheus retention
This commit is contained in:
1 parent
06ab5b4655
commit
121fdbacdc
27 files changed
+1841
-161
No files matched your search
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"chat/src/auth"
|
||||
@@ -102,7 +103,7 @@ func setupTestDeps(t *testing.T) (*Dependencies, *gorm.DB, *auth.SessionStore) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open test DB: %v", err)
|
||||
}
|
||||
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{}, &db.Media{}, &db.ScheduleOverride{})
|
||||
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{}, &db.Media{}, &db.ScheduleOverride{}, &db.User{})
|
||||
mr := newMockRedis()
|
||||
sessionStore := auth.NewSessionStore()
|
||||
hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, config.AdminUsername())
|
||||
@@ -112,6 +113,7 @@ func setupTestDeps(t *testing.T) (*Dependencies, *gorm.DB, *auth.SessionStore) {
|
||||
Redis: mr,
|
||||
Hub: hub,
|
||||
SessionStore: sessionStore,
|
||||
UserSessions: auth.NewUserSessionStore(),
|
||||
DBDriver: "sqlite",
|
||||
}
|
||||
return deps, gormDB, sessionStore
|
||||
@@ -476,6 +478,7 @@ func TestHandleAdminConfig_Get(t *testing.T) {
|
||||
|
||||
func TestHandleAdminConfig_Put(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
setupConfigTest(t)
|
||||
|
||||
handler := HandleAdminConfig(deps)
|
||||
body := `{"rateLimitMax":20}`
|
||||
@@ -490,6 +493,289 @@ func TestHandleAdminConfig_Put(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// setupConfigTest points the runtime config at a fresh temp file and resets the store.
|
||||
func setupConfigTest(t *testing.T) {
|
||||
t.Helper()
|
||||
os.Setenv("CONFIG_PATH", filepath.Join(t.TempDir(), "config.json"))
|
||||
os.Unsetenv("MATOMO_URL")
|
||||
os.Unsetenv("MATOMO_SITE_ID")
|
||||
os.Unsetenv("HLS_SEGMENT_DURATION")
|
||||
os.Unsetenv("HLS_WINDOW_SIZE")
|
||||
if err := config.LoadRuntimeConfig(); err != nil {
|
||||
t.Fatalf("LoadRuntimeConfig: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.Unsetenv("CONFIG_PATH") })
|
||||
}
|
||||
|
||||
func TestHandleAdminConfig_Get_NewFields(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
setupConfigTest(t)
|
||||
|
||||
handler := HandleAdminConfig(deps)
|
||||
req := httptest.NewRequest("GET", "/api/admin/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&cfg); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
for _, k := range []string{"matomoUrl", "matomoSiteId", "hlsSegmentDuration", "hlsWindowSize"} {
|
||||
if _, ok := cfg[k]; !ok {
|
||||
t.Errorf("response missing %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminConfig_Put_Persists(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
setupConfigTest(t)
|
||||
|
||||
handler := HandleAdminConfig(deps)
|
||||
body := `{"hlsSegmentDuration":5,"hlsWindowSize":30,"matomoUrl":"https://stats.example.com","matomoSiteId":"42"}`
|
||||
req := httptest.NewRequest("PUT", "/api/admin/config", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&cfg); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if cfg["hlsSegmentDuration"] != float64(5) {
|
||||
t.Errorf("hlsSegmentDuration = %v, want 5", cfg["hlsSegmentDuration"])
|
||||
}
|
||||
if cfg["hlsWindowSize"] != float64(30) {
|
||||
t.Errorf("hlsWindowSize = %v, want 30", cfg["hlsWindowSize"])
|
||||
}
|
||||
if cfg["matomoUrl"] != "https://stats.example.com" {
|
||||
t.Errorf("matomoUrl = %v", cfg["matomoUrl"])
|
||||
}
|
||||
if cfg["matomoSiteId"] != "42" {
|
||||
t.Errorf("matomoSiteId = %v", cfg["matomoSiteId"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminConfig_Put_Invalid(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
setupConfigTest(t)
|
||||
|
||||
handler := HandleAdminConfig(deps)
|
||||
body := `{"hlsSegmentDuration":99}`
|
||||
req := httptest.NewRequest("PUT", "/api/admin/config", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePublicMatomo(t *testing.T) {
|
||||
setupConfigTest(t)
|
||||
|
||||
handler := HandlePublicMatomo()
|
||||
req := httptest.NewRequest("GET", "/api/matomo", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
for _, k := range []string{"matomoUrl", "matomoSiteId"} {
|
||||
if _, ok := resp[k]; !ok {
|
||||
t.Errorf("response missing %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUserRegister_Success(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
handler := HandleUserRegister(deps)
|
||||
body := `{"username":"alice","password":"secret123"}`
|
||||
req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (got %s)", w.Code, w.Body.String())
|
||||
}
|
||||
var count int64
|
||||
gormDB.Model(&db.User{}).Where("username = ?", "alice").Count(&count)
|
||||
if count != 1 {
|
||||
t.Errorf("user count = %d, want 1", count)
|
||||
}
|
||||
if len(w.Result().Cookies()) == 0 {
|
||||
t.Error("expected session cookie to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUserRegister_Duplicate(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
gormDB.Create(&db.User{Username: "alice", PasswordHash: "x"})
|
||||
|
||||
handler := HandleUserRegister(deps)
|
||||
body := `{"username":"alice","password":"secret123"}`
|
||||
req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Errorf("status = %d, want 409", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUserLogin_Success(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.MinCost)
|
||||
gormDB.Create(&db.User{Username: "bob", PasswordHash: string(hash)})
|
||||
|
||||
handler := HandleUserLogin(deps)
|
||||
body := `{"username":"bob","password":"secret123"}`
|
||||
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (got %s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUserLogin_WrongPassword(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.MinCost)
|
||||
gormDB.Create(&db.User{Username: "bob", PasswordHash: string(hash)})
|
||||
|
||||
handler := HandleUserLogin(deps)
|
||||
body := `{"username":"bob","password":"wrong"}`
|
||||
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUserSession(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
handler := HandleUserSession(deps)
|
||||
|
||||
// Unauthenticated
|
||||
req := httptest.NewRequest("GET", "/api/auth/session", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler(w, req)
|
||||
var resp map[string]any
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["authenticated"] != false {
|
||||
t.Errorf("authenticated = %v, want false", resp["authenticated"])
|
||||
}
|
||||
|
||||
// Authenticated
|
||||
token, _ := auth.GenerateToken()
|
||||
deps.UserSessions.Add(token, "carol", time.Now().Add(1*time.Hour))
|
||||
req2 := httptest.NewRequest("GET", "/api/auth/session", nil)
|
||||
req2.AddCookie(&http.Cookie{Name: "linearvoid_user_session", Value: token})
|
||||
w2 := httptest.NewRecorder()
|
||||
handler(w2, req2)
|
||||
var resp2 map[string]any
|
||||
json.NewDecoder(w2.Body).Decode(&resp2)
|
||||
if resp2["username"] != "carol" {
|
||||
t.Errorf("username = %v, want carol", resp2["username"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUserDMHistory(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
token, _ := auth.GenerateToken()
|
||||
deps.UserSessions.Add(token, "alice", time.Now().Add(1*time.Hour))
|
||||
|
||||
now := time.Now()
|
||||
gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "alice", Text: "hi bob", Type: "private", Timestamp: now.Add(-2 * time.Hour)})
|
||||
gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "bob", Text: "hi alice", Type: "private", Timestamp: now.Add(-1 * time.Hour)})
|
||||
|
||||
handler := HandleUserDMHistory(deps)
|
||||
req := httptest.NewRequest("GET", "/api/dm/history?with=bob", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "linearvoid_user_session", Value: token})
|
||||
w := httptest.NewRecorder()
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var msgs []db.Message
|
||||
json.NewDecoder(w.Body).Decode(&msgs)
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(msgs))
|
||||
}
|
||||
if msgs[0].Text != "hi bob" {
|
||||
t.Errorf("first message = %q, want hi bob (chronological)", msgs[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUserDMHistory_Unauthorized(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
handler := HandleUserDMHistory(deps)
|
||||
req := httptest.NewRequest("GET", "/api/dm/history?with=bob", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUserDMSearch(t *testing.T) {
|
||||
deps, gormDB, _ := setupTestDeps(t)
|
||||
|
||||
token, _ := auth.GenerateToken()
|
||||
deps.UserSessions.Add(token, "alice", time.Now().Add(1*time.Hour))
|
||||
|
||||
gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "alice", Text: "meet at 8", Type: "private"})
|
||||
gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "bob", Text: "see you", Type: "private"})
|
||||
|
||||
handler := HandleUserDMSearch(deps)
|
||||
req := httptest.NewRequest("GET", "/api/dm/search?with=bob&q=meet", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "linearvoid_user_session", Value: token})
|
||||
w := httptest.NewRecorder()
|
||||
handler(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
var msgs []db.Message
|
||||
json.NewDecoder(w.Body).Decode(&msgs)
|
||||
if len(msgs) != 1 || msgs[0].Text != "meet at 8" {
|
||||
t.Fatalf("search results = %v, want [meet at 8]", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminRestart(t *testing.T) {
|
||||
deps, _, _ := setupTestDeps(t)
|
||||
|
||||
|
||||
Reference in new issue
Block a user