feat(auth,settings,tasks): add first-boot setup, real login/invites, full-page settings & admin views, multi-channel alarms, and fix null slice crashes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s

This commit is contained in:
sandrodach committed 2026-10-24 22:59:48 +02:00
1 parent 50ac0c8f64
commit 7802eb2cf0
17 files changed
+4391 -1368

No files matched your search

+162 -18
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
@@ -29,14 +30,15 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
user, err := store.EnsureDefaultUser()
if err != nil {
t.Fatalf("failed to ensure default user: %v", err)
t.Fatalf("failed to ensure test user: %v", err)
}
_ = store.SeedStarterContent(user)
cfg := &config.Config{
Port: "8080",
DatabasePath: dbPath,
JWTSecret: "test-jwt-secret-key",
AllowRegistration: true,
AllowRegistration: false,
}
hub := ws.NewHub()
srv := NewServer(cfg, store, hub, nil)
@@ -49,6 +51,137 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
return srv, store, token
}
func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "fresh_mindsift.db")
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatalf("failed to create fresh store: %v", err)
}
defer store.Close()
cfg := &config.Config{
Port: "8080",
DatabasePath: dbPath,
JWTSecret: "test-jwt-secret-key",
AllowRegistration: false,
}
srv := NewServer(cfg, store, ws.NewHub(), nil)
router := srv.Router()
// 1. Fresh DB must report needsSetup = true
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/status", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from /api/v1/auth/status, got %d", rec.Code)
}
var statusResp struct {
NeedsSetup bool `json:"needsSetup"`
AllowRegistration bool `json:"allowRegistration"`
InstanceName string `json:"instanceName"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &statusResp)
if !statusResp.NeedsSetup {
t.Fatalf("expected needsSetup=true on fresh database, got false")
}
// 2. Complete First-Boot Setup (without sample data)
setupBody := []byte(`{
"email": "owner@example.com",
"password": "secretpassword",
"displayName": "Server Owner",
"instanceName": "My MindSift",
"allowRegistration": false,
"sharingMode": "teams_only",
"seedSampleData": false
}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(setupBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created from /api/v1/auth/setup, got %d: %s", rec.Code, rec.Body.String())
}
var setupRes struct {
Token string `json:"token"`
User domain.User `json:"user"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &setupRes)
if setupRes.User.Role != "admin" || setupRes.Token == "" {
t.Fatalf("expected admin user and token, got %+v", setupRes)
}
// 3. Verify empty endpoints return [] and never null (preventing React crashes)
arrayEndpoints := []string{
"/api/v1/task-lists",
"/api/v1/tasks",
"/api/v1/organizations",
"/api/v1/teams",
"/api/v1/spaces",
"/api/v1/lists",
}
for _, ep := range arrayEndpoints {
req = httptest.NewRequest(http.MethodGet, ep, nil)
req.Header.Set("Authorization", "Bearer "+setupRes.Token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET %s returned %d", ep, rec.Code)
}
trimmed := strings.TrimSpace(rec.Body.String())
if trimmed == "null" || !strings.HasPrefix(trimmed, "[") {
t.Fatalf("GET %s returned %q instead of JSON array []", ep, trimmed)
}
}
// 4. Public registration without invite must be rejected (403)
regBody := []byte(`{"email":"member@example.com","password":"password123","displayName":"Member"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 Forbidden when registration is disabled, got %d", rec.Code)
}
// 5. Create an Invite as Admin and register with that Invite Code
invBody := []byte(`{"role":"team_admin","validDays":7}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/invites", bytes.NewReader(invBody))
req.Header.Set("Authorization", "Bearer "+setupRes.Token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created for invite, got %d: %s", rec.Code, rec.Body.String())
}
var createdInvite domain.UserInvite
_ = json.Unmarshal(rec.Body.Bytes(), &createdInvite)
if createdInvite.Code == "" {
t.Fatalf("expected invite code, got empty")
}
regWithInviteBody, _ := json.Marshal(map[string]string{
"email": "invited@example.com",
"password": "password123",
"displayName": "Invited Team Admin",
"inviteCode": createdInvite.Code,
})
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regWithInviteBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created when registering with valid invite code, got %d: %s", rec.Code, rec.Body.String())
}
var invitedRes struct {
User domain.User `json:"user"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &invitedRes)
if invitedRes.User.Role != "team_admin" {
t.Fatalf("expected invited user to receive role 'team_admin', got %q", invitedRes.User.Role)
}
}
func TestConcurrentInitialLoadAndListsNoDeadlock(t *testing.T) {
srv, _, token := setupTestServer(t)
router := srv.Router()
@@ -81,27 +214,31 @@ func TestConcurrentInitialLoadAndListsNoDeadlock(t *testing.T) {
wg.Wait()
}
func TestStaleTokenFallbackAndQuickDumpToTriageAndDirectTargets(t *testing.T) {
srv, store, _ := setupTestServer(t)
func TestQuickDumpToTriageAndDirectTargets(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
// Generate a stale token from a previous container run (non-existent user ID)
staleToken, err := auth.GenerateToken("test-jwt-secret-key", "usr_stale_from_old_container", "admin@mindsift.local", "Sandro")
if err != nil {
t.Fatalf("failed to generate stale token: %v", err)
// Verify stale token from non-existent user is rejected (401)
staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old")
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+staleToken)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 Unauthorized for stale token, got %d", rec.Code)
}
// 1. Capture Quick Dump (default target: inbox) using stale token
// 1. Capture Quick Dump (default target: inbox)
dumpPayload := map[string]string{
"content": "Schneller Quick Dump Testgedanke #test",
"priority": "high",
"target": "inbox",
}
body, _ := json.Marshal(dumpPayload)
req := httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+staleToken)
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
@@ -125,7 +262,7 @@ func TestStaleTokenFallbackAndQuickDumpToTriageAndDirectTargets(t *testing.T) {
}
body, _ = json.Marshal(taskPayload)
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+staleToken)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
@@ -154,7 +291,7 @@ func TestStaleTokenFallbackAndQuickDumpToTriageAndDirectTargets(t *testing.T) {
}
body, _ = json.Marshal(listPayload)
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+staleToken)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
@@ -175,7 +312,7 @@ func TestStaleTokenFallbackAndQuickDumpToTriageAndDirectTargets(t *testing.T) {
}
body, _ = json.Marshal(triagePayload)
req = httptest.NewRequest(http.MethodPatch, "/api/v1/triage/"+createdDump.ID, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+staleToken)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
@@ -219,10 +356,13 @@ func TestNewFullFeatures(t *testing.T) {
t.Fatalf("expected 200 OK for GET /api/v1/task-lists, got %d", rec.Code)
}
// 2. Test User Settings: Update Discord Webhook & Profile
// 2. Test User Settings: Update Multi-Channel Alarms & Profile
settingsPayload := map[string]interface{}{
"displayName": "Sandro D.",
"displayName": "Admin User",
"alarmChannels": []string{"email", "discord", "webhook"},
"alarmEmail": "alarms@example.com",
"discordWebhookUrl": "https://discord.com/api/webhooks/test/123",
"customWebhookUrl": "https://ntfy.sh/my-topic",
"notifyBeforeMinutes": 15,
}
body, _ = json.Marshal(settingsPayload)
@@ -235,6 +375,11 @@ func TestNewFullFeatures(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for PATCH /api/v1/auth/settings, got %d: %s", rec.Code, rec.Body.String())
}
var updatedUser domain.User
_ = json.Unmarshal(rec.Body.Bytes(), &updatedUser)
if len(updatedUser.AlarmChannels) != 3 || updatedUser.AlarmEmail != "alarms@example.com" {
t.Fatalf("expected 3 alarm channels and alarmEmail, got %+v", updatedUser)
}
// 3. Test Admin routes (default user is admin)
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/users", nil)
@@ -261,7 +406,6 @@ func TestNewFullFeatures(t *testing.T) {
"notes": "Detailnotizen für die Aufgabe",
"taskListId": createdTaskList.ID,
}
body, _ = json.Marshal(taskUpdatePayload)
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader([]byte(`{"content":"Kanban Task Test","target":"task"}`)))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")