Files
sandrodach d39d1d1122
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
feat: complete workspace, triage, outliner and sharing overhaul (tasks 1-26)
2026-10-27 19:46:46 +01:00

1303 lines
46 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/config"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
"github.com/sandrodach/mindsift/internal/ws"
)
func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test_mindsift.db")
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatalf("failed to create test store: %v", err)
}
t.Cleanup(func() {
_ = store.Close()
})
user, err := store.EnsureDefaultUser()
if err != nil {
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: false,
}
hub := ws.NewHub()
srv := NewServer(cfg, store, hub, nil)
token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
if err != nil {
t.Fatalf("failed to generate token: %v", err)
}
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/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":"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 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 != "admin" {
t.Fatalf("expected invited user to receive role 'admin', got %q", invitedRes.User.Role)
}
}
func TestConcurrentInitialLoadAndListsNoDeadlock(t *testing.T) {
srv, _, token := setupTestServer(t)
router := srv.Router()
endpoints := []string{
"/api/v1/auth/me",
"/api/v1/triage/deck",
"/api/v1/lists",
"/api/v1/tasks",
"/api/v1/documents",
}
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
for _, ep := range endpoints {
wg.Add(1)
go func(path string) {
defer wg.Done()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("GET %s returned %d: %s", path, rec.Code, rec.Body.String())
}
}(ep)
}
}
wg.Wait()
}
func TestQuickDumpToTriageAndDirectTargets(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
// Verify stale token from non-existent user is rejected (401)
staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old", 1)
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)
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 "+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 Quick Dump, got %d: %s", rec.Code, rec.Body.String())
}
var createdDump domain.Thought
if err := json.Unmarshal(rec.Body.Bytes(), &createdDump); err != nil {
t.Fatalf("failed to decode created dump: %v", err)
}
if createdDump.Status != domain.StatusInbox {
t.Fatalf("expected status 'inbox', got %q", createdDump.Status)
}
// 2. Capture Direct Task (target: task)
taskPayload := map[string]string{
"content": "Direkte Aufgabe ohne Triage",
"priority": "urgent",
"target": "task",
"dueDate": "2026-11-01",
}
body, _ = json.Marshal(taskPayload)
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()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created for Direct Task, got %d: %s", rec.Code, rec.Body.String())
}
var createdTask domain.Thought
_ = json.Unmarshal(rec.Body.Bytes(), &createdTask)
if createdTask.Status != domain.StatusConverted || createdTask.ConvertedType != "task" {
t.Fatalf("expected converted task, got status=%q type=%q", createdTask.Status, createdTask.ConvertedType)
}
// 3. Capture Direct List Item (target: list_item)
defUser, _ := store.EnsureDefaultUser()
lists, err := store.GetAllListsForUser(defUser.ID)
if err != nil || len(lists) == 0 {
t.Fatalf("expected starter lists, got len=%d err=%v", len(lists), err)
}
targetListID := lists[0].ID
listPayload := map[string]string{
"content": "Frische Haferflocken",
"target": "list_item",
"targetListId": targetListID,
}
body, _ = json.Marshal(listPayload)
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()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created for Direct List Item, got %d: %s", rec.Code, rec.Body.String())
}
var createdListItemThought domain.Thought
_ = json.Unmarshal(rec.Body.Bytes(), &createdListItemThought)
if createdListItemThought.ConvertedType != "list_item" || createdListItemThought.ConvertedRefID == "" {
t.Fatalf("expected converted list_item, got %+v", createdListItemThought)
}
// 4. Swipe Triage card to Task
triagePayload := map[string]string{
"action": "keep",
"convertTo": "task",
}
body, _ = json.Marshal(triagePayload)
req = httptest.NewRequest(http.MethodPatch, "/api/v1/triage/"+createdDump.ID, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for triage action, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestNewFullFeatures(t *testing.T) {
srv, _, token := setupTestServer(t)
router := srv.Router()
// 1. Test TaskLists: Create & Get
taskListPayload := map[string]string{
"title": "Homelab & Docker",
"icon": "🐳",
"color": "#3b82f6",
}
body, _ := json.Marshal(taskListPayload)
req := httptest.NewRequest(http.MethodPost, "/api/v1/task-lists", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+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 TaskList, got %d: %s", rec.Code, rec.Body.String())
}
var createdTaskList domain.TaskList
_ = json.Unmarshal(rec.Body.Bytes(), &createdTaskList)
if createdTaskList.Title != "Homelab & Docker" {
t.Fatalf("expected task list title 'Homelab & Docker', got %q", createdTaskList.Title)
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/task-lists", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for GET /api/v1/task-lists, got %d", rec.Code)
}
// 2. Test User Settings: Update Multi-Channel Alarms & Profile
settingsPayload := map[string]interface{}{
"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)
req = httptest.NewRequest(http.MethodPatch, "/api/v1/auth/settings", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
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)
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for GET /api/v1/admin/users, got %d: %s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for GET /api/v1/admin/settings, got %d: %s", rec.Code, rec.Body.String())
}
// 4. Test Kanban & Task details update
taskUpdatePayload := map[string]interface{}{
"kanbanStatus": "in_progress",
"notes": "Detailnotizen für die Aufgabe",
"taskListId": createdTaskList.ID,
}
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")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
var taskThought domain.Thought
_ = json.Unmarshal(rec.Body.Bytes(), &taskThought)
body, _ = json.Marshal(taskUpdatePayload)
req = httptest.NewRequest(http.MethodPatch, "/api/v1/thoughts/"+taskThought.ID, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for PATCH /api/v1/thoughts/{id}, got %d: %s", rec.Code, rec.Body.String())
}
var updatedThought domain.Thought
_ = json.Unmarshal(rec.Body.Bytes(), &updatedThought)
if updatedThought.KanbanStatus != "in_progress" || updatedThought.Notes != "Detailnotizen für die Aufgabe" {
t.Fatalf("expected updated kanbanStatus and notes, got %+v", updatedThought)
}
}
func TestLoginWithUsernameAndDemoMode(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "demo_api_test.db")
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatalf("failed to create store: %v", err)
}
defer store.Close()
if err := store.EnsureDemoAccounts(); err != nil {
t.Fatalf("failed to seed demo accounts: %v", err)
}
cfg := &config.Config{
Port: "8080",
DatabasePath: dbPath,
JWTSecret: "demo-jwt-secret",
DemoMode: true,
}
srv := NewServer(cfg, store, ws.NewHub(), nil)
router := srv.Router()
// 1. Check auth status reports demoMode: 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"`
DemoMode bool `json:"demoMode"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &statusResp)
if !statusResp.DemoMode {
t.Fatalf("expected demoMode=true, got %v", statusResp.DemoMode)
}
if statusResp.NeedsSetup {
t.Fatalf("expected needsSetup=false when demo accounts exist, got %v", statusResp.NeedsSetup)
}
// 2. Login with username "admin" / "admin"
loginBody := []byte(`{"email":"admin","password":"admin"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for admin login, got %d: %s", rec.Code, rec.Body.String())
}
var loginRes struct {
Token string `json:"token"`
User domain.User `json:"user"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &loginRes)
if loginRes.Token == "" || loginRes.User.Role != "admin" {
t.Fatalf("invalid admin login response: %+v", loginRes)
}
// 3. Login with username "user" / "user"
userLoginBody := []byte(`{"username":"user","password":"user"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(userLoginBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for user login, got %d: %s", rec.Code, rec.Body.String())
}
var userLoginRes struct {
Token string `json:"token"`
User domain.User `json:"user"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &userLoginRes)
if userLoginRes.Token == "" || userLoginRes.User.Role != "member" {
t.Fatalf("invalid user login response: %+v", userLoginRes)
}
// 4. Invalid credentials must return 401
wrongLoginBody := []byte(`{"login":"admin","password":"wrongpassword"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(wrongLoginBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 Unauthorized for wrong password, got %d", rec.Code)
}
}
func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "space_members_test.db")
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatalf("failed to create store: %v", err)
}
defer store.Close()
if err := store.EnsureDemoAccounts(); err != nil {
t.Fatalf("failed to seed demo accounts: %v", err)
}
cfg := &config.Config{
Port: "8080",
DatabasePath: dbPath,
JWTSecret: "test-secret",
}
srv := NewServer(cfg, store, ws.NewHub(), nil)
router := srv.Router()
adminUser, err := store.GetUserByLogin("admin")
if err != nil {
t.Fatalf("failed to get admin user: %v", err)
}
normalUser, err := store.GetUserByLogin("user")
if err != nil {
t.Fatalf("failed to get normal user: %v", err)
}
adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName, adminUser.TokenVersion)
if err != nil {
t.Fatalf("failed to generate token: %v", err)
}
userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName, normalUser.TokenVersion)
if err != nil {
t.Fatalf("failed to generate token: %v", err)
}
// 1. Admin creates a list
createListBody := []byte(`{"title":"Einkauf WG","icon":"🛒","listType":"shopping"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/lists", bytes.NewReader(createListBody))
req.Header.Set("Authorization", "Bearer "+adminToken)
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/lists, got %d: %s", rec.Code, rec.Body.String())
}
var createdList domain.SharedList
_ = json.Unmarshal(rec.Body.Bytes(), &createdList)
if createdList.ID == "" {
t.Fatalf("expected created list with ID, got %+v", createdList)
}
// 2. Admin shares list with normalUser by username "user"
addShareBody := []byte(`{"targetType":"user","targetId":"user","role":"editor"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/lists/"+createdList.ID+"/shares", bytes.NewReader(addShareBody))
req.Header.Set("Authorization", "Bearer "+adminToken)
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 add list share, got %d: %s", rec.Code, rec.Body.String())
}
var newShare domain.ListShare
_ = json.Unmarshal(rec.Body.Bytes(), &newShare)
if newShare.TargetID != normalUser.ID {
t.Fatalf("expected share targetId %s, got %s", normalUser.ID, newShare.TargetID)
}
// 3. User checks their lists; the shared list must be listed
req = httptest.NewRequest(http.MethodGet, "/api/v1/lists", nil)
req.Header.Set("Authorization", "Bearer "+userToken)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for user lists, got %d", rec.Code)
}
var userLists []domain.SharedList
_ = json.Unmarshal(rec.Body.Bytes(), &userLists)
found := false
for _, l := range userLists {
if l.ID == createdList.ID {
found = true
break
}
}
if !found {
t.Fatalf("expected list %s to be visible to user, got %+v", createdList.ID, userLists)
}
// 4. Admin removes user share from list
req = httptest.NewRequest(http.MethodDelete, "/api/v1/lists/"+createdList.ID+"/shares/"+newShare.ID, nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for remove share, got %d: %s", rec.Code, rec.Body.String())
}
// 5. Test sharing list with a team
team, err := store.CreateTeam("", "WG Team", "Haushalt")
if err != nil {
t.Fatalf("failed to create team: %v", err)
}
_ = store.AddTeamMember(team.ID, normalUser.ID, "member")
addTeamBody := []byte(`{"targetType":"team","targetId":"` + team.ID + `","role":"editor"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/lists/"+createdList.ID+"/shares", bytes.NewReader(addTeamBody))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
t.Fatalf("expected 201 or 200 for add list team share, got %d: %s", rec.Code, rec.Body.String())
}
// User should see the list again because their team was added
req = httptest.NewRequest(http.MethodGet, "/api/v1/lists", nil)
req.Header.Set("Authorization", "Bearer "+userToken)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for user lists after team share add, got %d", rec.Code)
}
userLists = nil
_ = json.Unmarshal(rec.Body.Bytes(), &userLists)
found = false
for _, l := range userLists {
if l.ID == createdList.ID {
found = true
break
}
}
if !found {
t.Fatalf("expected list %s to be visible to user via team share, got %+v", createdList.ID, userLists)
}
}
func TestWorkspaceTagsAPI(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
user, _ := store.CreateUser("api_tag_user@example.com", "hash", "API Tag User", "member")
token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
// Create document with hashtags
blocks := []domain.DocumentBlock{
{ID: "b1", Type: "bullet", Content: "Planung #Sprint12", Level: 0},
{ID: "b2", Type: "bullet", Content: "Subtask 1", Level: 1},
}
_, err := store.CreateDocument(user.ID, "", "Sprint Plan", "🚀", "", blocks, nil, false)
if err != nil {
t.Fatalf("CreateDocument failed: %v", err)
}
// 1. GET /api/v1/workspace/tags
req := httptest.NewRequest(http.MethodGet, "/api/v1/workspace/tags", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d: %s", rec.Code, rec.Body.String())
}
var tagList []domain.TagSummary
_ = json.Unmarshal(rec.Body.Bytes(), &tagList)
if len(tagList) != 1 || tagList[0].Tag != "sprint12" {
t.Fatalf("expected tag 'sprint12', got %+v", tagList)
}
// 2. GET /api/v1/workspace/tags/Sprint12
req = httptest.NewRequest(http.MethodGet, "/api/v1/workspace/tags/Sprint12", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d: %s", rec.Code, rec.Body.String())
}
var details domain.TagDetails
_ = json.Unmarshal(rec.Body.Bytes(), &details)
if details.Tag != "sprint12" || details.TotalMatches != 1 || len(details.Documents) != 1 {
t.Fatalf("unexpected tag details: %+v", details)
}
matched := details.Documents[0].MatchedBlocks[0]
if matched.RootBlock.ID != "b1" || len(matched.Children) != 1 || matched.Children[0].ID != "b2" {
t.Fatalf("unexpected matched blocks or children: %+v", matched)
}
// 3. Folder endpoints test
// Create folder
createFolderBody := []byte(`{"path":"TestOrdner"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/folders", bytes.NewReader(createFolderBody))
req.Header.Set("Authorization", "Bearer "+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 from /api/v1/folders, got %d: %s", rec.Code, rec.Body.String())
}
// Get folders
req = httptest.NewRequest(http.MethodGet, "/api/v1/folders", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from /api/v1/folders, got %d", rec.Code)
}
var folders []string
_ = json.Unmarshal(rec.Body.Bytes(), &folders)
foundFolder := false
for _, f := range folders {
if f == "TestOrdner" {
foundFolder = true
break
}
}
if !foundFolder {
t.Fatalf("expected TestOrdner in folders list, got %v", folders)
}
// Delete folder
req = httptest.NewRequest(http.MethodDelete, "/api/v1/folders?path=TestOrdner", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from DELETE /api/v1/folders, got %d", rec.Code)
}
}
func TestWebhookTriageBadgeAndIngestion(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "test_webhook.db")
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatalf("failed to init store: %v", err)
}
defer store.Close()
cfg := &config.Config{
JWTSecret: "test-jwt-secret-key",
AllowRegistration: false,
}
srv := NewServer(cfg, store, ws.NewHub(), nil)
router := srv.Router()
// 1. Setup user
setupBody := []byte(`{
"email": "smarthome_owner@example.com",
"password": "secretpassword",
"displayName": "Home User",
"instanceName": "MindSift Home",
"allowRegistration": false,
"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("setup failed: %s", rec.Body.String())
}
var authResp struct {
Token string `json:"token"`
User *domain.User `json:"user"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &authResp)
// 2. Create API key for user
apiKey, err := store.CreateAPIKey(authResp.User.ID, "Home Assistant Webhook")
if err != nil || apiKey == nil || apiKey.RawKey == "" {
t.Fatalf("failed to create API key: %v", err)
}
plainKey := apiKey.RawKey
// 3. Post to /api/v1/inbox using X-API-Key without passing "source"
inboxBody := []byte(`{
"content": "Kellerfeuchtigkeit über 68% gestiegen – Entfeuchter prüfen #smarthome #alarm",
"priority": "urgent"
}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(inboxBody))
req.Header.Set("X-API-Key", plainKey)
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/inbox with API key, got %d: %s", rec.Code, rec.Body.String())
}
var createdThought domain.Thought
_ = json.Unmarshal(rec.Body.Bytes(), &createdThought)
if createdThought.Source != "webhook" {
t.Fatalf("expected created thought to have source='webhook' when called with API key, got '%s'", createdThought.Source)
}
// 4. Post to /api/v1/webhooks/ingest using ?token=... with message alias
webhookBody := []byte(`{
"message": "Waschmaschine im Keller fertig",
"priority": "high",
"tags": ["automation"]
}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/ingest?token="+plainKey, bytes.NewReader(webhookBody))
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/webhooks/ingest, got %d: %s", rec.Code, rec.Body.String())
}
var ingestResp struct {
Status string `json:"status"`
Thought domain.Thought `json:"thought"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &ingestResp)
if ingestResp.Thought.Source != "webhook" {
t.Fatalf("expected ingested webhook thought to have source='webhook', got '%s'", ingestResp.Thought.Source)
}
if !strings.Contains(ingestResp.Thought.Content, "Waschmaschine") {
t.Fatalf("expected thought content to contain 'Waschmaschine', got '%s'", ingestResp.Thought.Content)
}
}
func TestSSEGlobalEndpoint(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "test_sse.db")
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatalf("failed to init store: %v", err)
}
defer store.Close()
hub := ws.NewHub()
cfg := &config.Config{
JWTSecret: "test-jwt-secret-key",
AllowRegistration: false,
}
srv := NewServer(cfg, store, hub, nil)
router := srv.Router()
setupBody := []byte(`{
"email": "sse_user@example.com",
"password": "secretpassword",
"displayName": "SSE User",
"instanceName": "MindSift SSE",
"allowRegistration": false,
"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("setup failed: %s", rec.Body.String())
}
var authResp struct {
Token string `json:"token"`
User *domain.User `json:"user"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &authResp)
token := authResp.Token
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
req = httptest.NewRequest(http.MethodGet, "/api/v1/events?token="+token, nil).WithContext(ctx)
rec = httptest.NewRecorder()
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
router.ServeHTTP(rec, req)
contentType := rec.Header().Get("Content-Type")
if !strings.Contains(contentType, "text/event-stream") {
t.Fatalf("expected Content-Type text/event-stream, got %s", contentType)
}
bodyStr := rec.Body.String()
if !strings.Contains(bodyStr, "SSE_CONNECTED") {
t.Fatalf("expected SSE_CONNECTED event in body, got: %s", bodyStr)
}
}
func TestDecoupledFolderSharingAPI(t *testing.T) {
srv, store, tokenA := setupTestServer(t)
router := srv.Router()
userB, err := store.CreateUser("user_b@example.com", "hash", "User B", "member")
if err != nil {
t.Fatalf("failed to create user B: %v", err)
}
tokenB, err := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion)
if err != nil {
t.Fatalf("failed to generate token B: %v", err)
}
// 1. User A creates a shared folder "TeamProjekt"
shPayload := map[string]string{"name": "TeamProjekt", "mountPath": "TeamProjekt"}
b, _ := json.Marshal(shPayload)
req := httptest.NewRequest(http.MethodPost, "/api/v1/workspace/folders/shared", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+tokenA)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create shared folder failed: %s", rec.Body.String())
}
var shFolder domain.SharedFolder
_ = json.Unmarshal(rec.Body.Bytes(), &shFolder)
// 2. User A creates a document inside "TeamProjekt"
docPayload := map[string]interface{}{
"title": "Spezifikation",
"folderPath": "TeamProjekt",
"blocks": []domain.DocumentBlock{
{ID: "blk_1", Type: "text", Content: "Projekt Details", Level: 0, Position: 1000},
},
}
b, _ = json.Marshal(docPayload)
req = httptest.NewRequest(http.MethodPost, "/api/v1/documents", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+tokenA)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("create doc failed: %s", rec.Body.String())
}
var createdDoc domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &createdDoc)
// 3. User A shares the folder with User B (write permission)
sharePayload := map[string]interface{}{
"userId": userB.ID,
"targetType": "user",
"permission": "write",
}
b, _ = json.Marshal(sharePayload)
req = httptest.NewRequest(http.MethodPost, "/api/v1/workspace/folders/shared/"+shFolder.ID+"/shares", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+tokenA)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("share folder failed: %s", rec.Body.String())
}
// 4. User B gets their documents list -> must see the document in "TeamProjekt"
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents", nil)
req.Header.Set("Authorization", "Bearer "+tokenB)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("get docs userB failed: %s", rec.Body.String())
}
var docsB []domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &docsB)
var foundDocB *domain.Document
for i := range docsB {
if docsB[i].ID == createdDoc.ID {
foundDocB = &docsB[i]
break
}
}
if foundDocB == nil {
t.Fatalf("User B did not see shared document!")
}
if foundDocB.FolderPath != "TeamProjekt" {
t.Fatalf("expected User B initial folderPath 'TeamProjekt', got %q", foundDocB.FolderPath)
}
// 5. User B moves their mount point to "MeineOrdner/TeamProjekt"
movePayload := map[string]string{
"sourcePath": "TeamProjekt",
"targetParentPath": "MeineOrdner",
}
b, _ = json.Marshal(movePayload)
req = httptest.NewRequest(http.MethodPost, "/api/v1/folders/move", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+tokenB)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("User B move folder failed: %s", rec.Body.String())
}
// 6. DECOUPLED CHECK:
// User B's document path must now be "MeineOrdner/TeamProjekt"
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+createdDoc.ID, nil)
req.Header.Set("Authorization", "Bearer "+tokenB)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("User B get doc failed: %s", rec.Body.String())
}
var docBAfterMove domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &docBAfterMove)
if docBAfterMove.FolderPath != "MeineOrdner/TeamProjekt" {
t.Fatalf("expected User B folderPath 'MeineOrdner/TeamProjekt', got %q", docBAfterMove.FolderPath)
}
// User A's document path must STILL be "TeamProjekt" (completely decoupled!)
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+createdDoc.ID, nil)
req.Header.Set("Authorization", "Bearer "+tokenA)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("User A get doc failed: %s", rec.Body.String())
}
var docAAfterMove domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &docAAfterMove)
if docAAfterMove.FolderPath != "TeamProjekt" {
t.Fatalf("User A's folder hierarchy was altered! Expected 'TeamProjekt', got %q", docAAfterMove.FolderPath)
}
// 7. Synchronous contents check:
// User B (read-only recipient) cannot move documents within the shared folder:
moveDocPayload := map[string]string{
"folderPath": "MeineOrdner/TeamProjekt/Archiv",
}
b, _ = json.Marshal(moveDocPayload)
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+createdDoc.ID+"/move", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+tokenB)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("Expected User B move doc in read-only shared folder to fail, got %d", rec.Code)
}
// User A (the owner) moves the document into a subfolder: "TeamProjekt/Archiv"
moveDocPayload = map[string]string{
"folderPath": "TeamProjekt/Archiv",
}
b, _ = json.Marshal(moveDocPayload)
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+createdDoc.ID+"/move", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+tokenA)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("User A move doc into subfolder failed: %s", rec.Body.String())
}
// User B must now see the document synchronized at "MeineOrdner/TeamProjekt/Archiv"!
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+createdDoc.ID, nil)
req.Header.Set("Authorization", "Bearer "+tokenB)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("User B get doc failed: %s", rec.Body.String())
}
var docBSynced domain.Document
_ = json.Unmarshal(rec.Body.Bytes(), &docBSynced)
if docBSynced.FolderPath != "MeineOrdner/TeamProjekt/Archiv" {
t.Fatalf("expected User B to see subfolder sync at 'MeineOrdner/TeamProjekt/Archiv', got %q", docBSynced.FolderPath)
}
}
func TestTokenBlacklistingAndTokenVersionRevocation(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
// 1. Create a fresh user with known password
password := "initialPassword123"
hash, _ := auth.HashPassword(password)
user, err := store.CreateUser("revocation_test@example.com", hash, "Revocation Tester", "member")
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// 2. Perform login to get initial JWT
loginBody, _ := json.Marshal(map[string]string{
"login": user.Email,
"password": password,
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login failed: %s", rec.Body.String())
}
var loginResp struct {
Token string `json:"token"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &loginResp)
token1 := loginResp.Token
if token1 == "" {
t.Fatalf("expected token in login response")
}
// 3. Verify token1 works
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+token1)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("token1 should be valid, got %d", rec.Code)
}
// 4. Test explicit logout (blacklists token1)
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
req.Header.Set("Authorization", "Bearer "+token1)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("logout failed: %s", rec.Body.String())
}
// Token1 must now be blacklisted and rejected (401)
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+token1)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 Unauthorized for blacklisted token1, got %d", rec.Code)
}
// Also check authenticateUserFromReq rejects token1
req = httptest.NewRequest(http.MethodGet, "/api/v1/ws?token="+token1, nil)
if uid := srv.authenticateUserFromReq(req); uid != "" {
t.Fatalf("authenticateUserFromReq should reject blacklisted token1, got uid %q", uid)
}
// 5. Login again to get token2
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
var loginResp2 struct {
Token string `json:"token"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &loginResp2)
token2 := loginResp2.Token
// Token2 works
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+token2)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("token2 should be valid, got %d", rec.Code)
}
// 6. Change password via UpdateUserPassword -> increments token_version
newPassword := "newSecretPassword456"
newHash, _ := auth.HashPassword(newPassword)
if err := store.UpdateUserPassword(user.ID, newHash); err != nil {
t.Fatalf("failed to update password: %v", err)
}
// Token2 must now be REJECTED (401) because its token_version is outdated!
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+token2)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 Unauthorized for token2 after password change, got %d", rec.Code)
}
// 7. Login with new password to get token3
loginNewBody, _ := json.Marshal(map[string]string{
"login": user.Email,
"password": newPassword,
})
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginNewBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
var loginResp3 struct {
Token string `json:"token"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &loginResp3)
token3 := loginResp3.Token
// Token3 works
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+token3)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("token3 should be valid, got %d", rec.Code)
}
// 8. Test revoke-all endpoint -> increments token_version and invalidates all existing sessions
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/revoke-all", nil)
req.Header.Set("Authorization", "Bearer "+token3)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("revoke-all failed: %s", rec.Body.String())
}
// Token3 must now be REJECTED (401)
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+token3)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 Unauthorized for token3 after revoke-all, got %d", rec.Code)
}
}
func TestDocumentSharingStrictlyReadOnly(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
userA, _ := store.CreateUser("user_a_ro@example.com", "hash", "User A", "member")
tokenA, _ := auth.GenerateToken(srv.cfg.JWTSecret, userA.ID, userA.Email, userA.DisplayName, userA.TokenVersion)
userB, _ := store.CreateUser("user_b_ro@example.com", "hash", "User B", "member")
tokenB, _ := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion)
// Create document owned by User A
blocks := []domain.DocumentBlock{
{ID: "b1", Type: "bullet", Content: "Row 1", Level: 0},
{ID: "b2", Type: "bullet", Content: "Row 2", Level: 0},
}
doc, err := store.CreateDocument(userA.ID, "", "Read Only Test Doc", "📝", "", blocks, nil, false)
if err != nil {
t.Fatalf("CreateDocument failed: %v", err)
}
// Share with User B (forced read-only)
_, err = store.CreateDocShare(userA.ID, doc.ID, userB.ID, "read")
if err != nil {
t.Fatalf("CreateDocShare failed: %v", err)
}
// 1. User B can read document
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID, nil)
req.Header.Set("Authorization", "Bearer "+tokenB)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("User B should be able to read doc, got status: %d", rec.Code)
}
// 2. User B tries to update document via PUT -> must fail (not owner)
newTitle := "Hacked Title"
updateBody, _ := json.Marshal(map[string]interface{}{"title": newTitle})
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID, bytes.NewReader(updateBody))
req.Header.Set("Authorization", "Bearer "+tokenB)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code == http.StatusOK {
t.Fatalf("expected error for User B updating document, got 200 OK")
}
// 3. User B tries to patch document blocks -> must fail (not owner)
patchBody, _ := json.Marshal(domain.DocBlocksPatchRequest{
Upserted: []domain.DocumentBlock{{ID: "b2", Content: "Hacked by User B"}},
})
req = httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+doc.ID+"/blocks", bytes.NewReader(patchBody))
req.Header.Set("Authorization", "Bearer "+tokenB)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code == http.StatusOK {
t.Fatalf("expected error for User B patching document blocks, got 200 OK")
}
// 4. User A (owner) can successfully update document
ownerTitle := "Owner Updated Title"
ownerBody, _ := json.Marshal(map[string]interface{}{"title": ownerTitle})
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID, bytes.NewReader(ownerBody))
req.Header.Set("Authorization", "Bearer "+tokenA)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("owner should be able to update document, got %d", rec.Code)
}
}