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
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"chat/src/auth"
|
||||
@@ -197,6 +198,7 @@ type Dependencies struct {
|
||||
Redis db.RedisClient
|
||||
Hub *ws.Hub
|
||||
SessionStore *auth.SessionStore
|
||||
UserSessions *auth.UserSessionStore
|
||||
DBDriver string
|
||||
}
|
||||
|
||||
@@ -333,6 +335,251 @@ func HandleAdminSession(deps *Dependencies) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// userSessionCookieName is the cookie for registered chat users.
|
||||
const userSessionCookieName = "linearvoid_user_session"
|
||||
|
||||
// UserCredentialsPayload is used by registration and login.
|
||||
type UserCredentialsPayload struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// validateUsername checks the username length constraints.
|
||||
func validateUsername(username string) error {
|
||||
if len(username) < 3 || len(username) > 32 {
|
||||
return fmt.Errorf("username must be 3-32 characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validatePassword checks the password length constraints.
|
||||
func validatePassword(password string) error {
|
||||
if len(password) < 6 {
|
||||
return fmt.Errorf("password must be at least 6 characters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// startUserSession issues a user session cookie for the given username.
|
||||
func startUserSession(deps *Dependencies, w http.ResponseWriter, r *http.Request, username string) {
|
||||
token, err := auth.GenerateToken()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
expiresAt := time.Now().Add(config.UserSessionTTL())
|
||||
deps.UserSessions.Add(token, username, expiresAt)
|
||||
auth.SetCookie(w, r, userSessionCookieName, token, expiresAt)
|
||||
}
|
||||
|
||||
// HandleUserRegister creates a new chat user and signs them in.
|
||||
func HandleUserRegister(deps *Dependencies) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var payload UserCredentialsPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(payload.Username)
|
||||
if err := validateUsername(username); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validatePassword(payload.Password); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var count int64
|
||||
deps.DB.Model(&db.User{}).Where("username = ?", username).Count(&count)
|
||||
if count > 0 {
|
||||
http.Error(w, "username already taken", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(payload.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to hash password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
user := db.User{Username: username, PasswordHash: string(hash)}
|
||||
if err := deps.DB.Create(&user).Error; err != nil {
|
||||
http.Error(w, "failed to create user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
startUserSession(deps, w, r, user.Username)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"username": user.Username})
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUserLogin verifies credentials and signs the user in.
|
||||
func HandleUserLogin(deps *Dependencies) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var payload UserCredentialsPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(payload.Username)
|
||||
|
||||
var user db.User
|
||||
if err := deps.DB.Where("username = ?", username).First(&user).Error; err != nil {
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(payload.Password)); err != nil {
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
startUserSession(deps, w, r, user.Username)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"username": user.Username})
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUserSession returns the current user session state.
|
||||
func HandleUserSession(deps *Dependencies) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
token, _ := auth.GetTokenFromCookie(r, userSessionCookieName)
|
||||
username, ok := deps.UserSessions.Username(token)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"authenticated": ok,
|
||||
"username": username,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUserLogout clears the user session.
|
||||
func HandleUserLogout(deps *Dependencies) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if token, ok := auth.GetTokenFromCookie(r, userSessionCookieName); ok {
|
||||
deps.UserSessions.Remove(token)
|
||||
}
|
||||
auth.ClearCookie(w, r, userSessionCookieName)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||
}
|
||||
}
|
||||
|
||||
// currentUsername returns the authenticated username from the user session cookie.
|
||||
func currentUsername(deps *Dependencies, r *http.Request) (string, bool) {
|
||||
token, ok := auth.GetTokenFromCookie(r, userSessionCookieName)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return deps.UserSessions.Username(token)
|
||||
}
|
||||
|
||||
// dmRoomFor returns the canonical DM room for two users (sorted, prefixed).
|
||||
func dmRoomFor(a, b string) string {
|
||||
if a > b {
|
||||
a, b = b, a
|
||||
}
|
||||
return "dm:" + a + "-" + b
|
||||
}
|
||||
|
||||
// HandleUserDMHistory returns paginated private message history with a target user.
|
||||
func HandleUserDMHistory(deps *Dependencies) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
username, ok := currentUsername(deps, r)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
target := strings.TrimSpace(r.URL.Query().Get("with"))
|
||||
if target == "" {
|
||||
http.Error(w, "missing target user", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
limit := 50
|
||||
if raw := r.URL.Query().Get("limit"); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 200 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
room := dmRoomFor(username, target)
|
||||
|
||||
query := deps.DB.Where("room = ?", room)
|
||||
if beforeRaw := r.URL.Query().Get("before"); beforeRaw != "" {
|
||||
if t, err := time.Parse(time.RFC3339, beforeRaw); err == nil {
|
||||
query = query.Where("timestamp < ?", t)
|
||||
}
|
||||
}
|
||||
var messages []db.Message
|
||||
if err := query.Order("timestamp desc").Limit(limit).Find(&messages).Error; err != nil {
|
||||
http.Error(w, "failed to load history", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
||||
messages[i], messages[j] = messages[j], messages[i]
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(messages)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleUserDMSearch searches private messages with a target user.
|
||||
func HandleUserDMSearch(deps *Dependencies) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
username, ok := currentUsername(deps, r)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
target := strings.TrimSpace(r.URL.Query().Get("with"))
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
if target == "" || q == "" {
|
||||
http.Error(w, "missing target or query", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
limit := 50
|
||||
if raw := r.URL.Query().Get("limit"); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 200 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
room := dmRoomFor(username, target)
|
||||
var messages []db.Message
|
||||
if err := deps.DB.Where("room = ? AND LOWER(text) LIKE ?", room, "%"+strings.ToLower(q)+"%").
|
||||
Order("timestamp desc").Limit(limit).Find(&messages).Error; err != nil {
|
||||
http.Error(w, "failed to search", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
||||
messages[i], messages[j] = messages[j], messages[i]
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(messages)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAdminOverview returns the admin dashboard overview.
|
||||
func HandleAdminOverview(deps *Dependencies) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -757,29 +1004,83 @@ func HandleAdminSystemSummary(deps *Dependencies) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// HandlePublicMatomo exposes the Matomo analytics settings for the viewer frontend.
|
||||
func HandlePublicMatomo() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"matomoUrl": config.MatomoURL(),
|
||||
"matomoSiteId": config.MatomoSiteID(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// adminConfigResponse builds the admin configuration payload from runtime config
|
||||
// and read-only runtime facts.
|
||||
func adminConfigResponse(deps *Dependencies) map[string]any {
|
||||
resp := config.RuntimeConfigSnapshot()
|
||||
resp["rateLimitMax"] = config.RateLimitMax()
|
||||
resp["rateLimitWindowSec"] = int(config.RateLimitWindow().Seconds())
|
||||
resp["databaseDriver"] = deps.DBDriver
|
||||
resp["redisEnabled"] = deps.Redis != nil
|
||||
resp["sessionTTLHours"] = int(config.AdminSessionTTL().Hours())
|
||||
return resp
|
||||
}
|
||||
|
||||
// HandleAdminConfig returns/updates runtime configuration.
|
||||
func HandleAdminConfig(deps *Dependencies) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"rateLimitMax": config.RateLimitMax(),
|
||||
"rateLimitWindowSec": int(config.RateLimitWindow().Seconds()),
|
||||
"databaseDriver": deps.DBDriver,
|
||||
"redisEnabled": deps.Redis != nil,
|
||||
"sessionTTLHours": int(config.AdminSessionTTL().Hours()),
|
||||
})
|
||||
json.NewEncoder(w).Encode(adminConfigResponse(deps))
|
||||
case http.MethodPut:
|
||||
var payload map[string]any
|
||||
// Editable runtime fields (Matomo + HLS). Rate limit fields remain
|
||||
// env-driven and are intentionally read-only here.
|
||||
var payload struct {
|
||||
MatomoURL *string `json:"matomoUrl"`
|
||||
MatomoSiteID *string `json:"matomoSiteId"`
|
||||
HLSSegmentDuration *int `json:"hlsSegmentDuration"`
|
||||
HLSWindowSize *int `json:"hlsWindowSize"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Note: rate limit config is read-only at runtime in this refactored version.
|
||||
// To change, restart with env vars.
|
||||
if payload.MatomoURL != nil {
|
||||
if err := config.SetMatomoURL(*payload.MatomoURL); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if payload.MatomoSiteID != nil {
|
||||
if err := config.SetMatomoSiteID(*payload.MatomoSiteID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if payload.HLSSegmentDuration != nil {
|
||||
if err := config.SetHLSSegmentDuration(*payload.HLSSegmentDuration); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if payload.HLSWindowSize != nil {
|
||||
if err := config.SetHLSWindowSize(*payload.HLSWindowSize); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := config.SaveRuntimeConfig(); err != nil {
|
||||
http.Error(w, fmt.Sprintf("failed to save config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
json.NewEncoder(w).Encode(adminConfigResponse(deps))
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
@@ -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