diff --git a/core.ref b/core.ref index 021ed3f..0cb0abe 100644 --- a/core.ref +++ b/core.ref @@ -1 +1 @@ -2d16aa91cf338891edcfe6e9b149364b19f1fe6e +ca5674382cb750b9d226b70067496f548f4937a0 diff --git a/frontend/src/lib/mobile/MobileItemDetail.test.js b/frontend/src/lib/mobile/MobileItemDetail.test.js new file mode 100644 index 0000000..382f8fd --- /dev/null +++ b/frontend/src/lib/mobile/MobileItemDetail.test.js @@ -0,0 +1,121 @@ +import { cleanup, render, screen } from '@testing-library/svelte'; +import { tick } from 'svelte'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +const stream = vi.hoisted(() => ({ handlers: null })); + +vi.mock('../api.js', () => ({ + api: { + items: { + get: vi.fn(), + getAvailableStatusTransitions: vi.fn(), + getChildren: vi.fn(), + getWatchStatus: vi.fn(), + }, + }, +})); +vi.mock('./mobileItemDetailData.js', () => ({ loadMobileItemDetailSummary: vi.fn() })); +vi.mock('../router.js', () => ({ navigate: vi.fn() })); +vi.mock('../stores/toasts.svelte.js', () => ({ infoToast: vi.fn(), errorToast: vi.fn() })); +vi.mock('../stores/notifications.js', () => ({ notificationActions: { markItemAsRead: vi.fn() } })); +vi.mock('../stores/timerStore.svelte.js', () => ({ timerStore: { hasActive: false } })); +vi.mock('../stores/agentRuns.svelte.js', () => ({ agentRuns: { subscribe: () => () => {} } })); +vi.mock('../stores/itemLiveUpdates.svelte.js', () => ({ itemLiveUpdates: { isLive: () => true } })); +vi.mock('../stores', async () => { + const { writable } = await import('svelte/store'); + return { workspacesStore: writable({ personalWorkspace: { id: 999 } }) }; +}); +vi.mock('../stores/i18n.svelte.js', () => ({ t: (key) => key })); +vi.mock('../composables/useWorkItemPoller.svelte.js', () => ({ useWorkItemPoller: () => {} })); +vi.mock('../composables/useItemEventStream.svelte.js', () => ({ + useItemEventStream: (_itemId, handlers) => { stream.handlers = handlers; }, +})); +vi.mock('../composables/usePullToRefresh.svelte.js', () => ({ + usePullToRefresh: () => ({ pulling: false, refreshing: false, pullDistance: 0, threshold: 60 }), +})); +vi.mock('./MobileHeader.svelte', () => ({ default: function MobileHeader() {} })); +vi.mock('./MobileOptionSheet.svelte', () => ({ default: function MobileOptionSheet() {} })); +vi.mock('../components/StatusPill.svelte', () => ({ default: function StatusPill() {} })); +vi.mock('../features/items/Comments.svelte', () => ({ default: function Comments() {} })); + +import { api } from '../api.js'; +import { navigate } from '../router.js'; +import { infoToast } from '../stores/toasts.svelte.js'; +import { loadMobileItemDetailSummary } from './mobileItemDetailData.js'; +import MobileItemDetail from './MobileItemDetail.svelte'; + +function deferred() { + let resolve; + let reject; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function summary(id) { + return { + item: { + id, + title: `Item ${id}`, + workspace_id: 1, + workspace_key: 'WI', + workspace_item_number: id, + status_name: 'Open', + }, + }; +} + +beforeEach(() => { + stream.handlers = null; + vi.mocked(loadMobileItemDetailSummary).mockImplementation(async (id) => summary(id)); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + vi.restoreAllMocks(); + stream.handlers = null; +}); + +describe('MobileItemDetail deletion refresh', () => { + test('keeps item B visible when a pending refresh of item A returns 404', async () => { + const pendingA = deferred(); + api.items.get.mockReturnValueOnce(pendingA.promise); + vi.spyOn(window.history, 'length', 'get').mockReturnValue(2); + const back = vi.spyOn(window.history, 'back').mockImplementation(() => {}); + const view = render(MobileItemDetail, { itemId: 101 }); + expect(await screen.findByTestId('detail-title')).toHaveTextContent('Item 101'); + + const refreshA = stream.handlers.onItem(); + expect(api.items.get).toHaveBeenCalledWith(101); + await view.rerender({ itemId: 202 }); + expect(await screen.findByTestId('detail-title')).toHaveTextContent('Item 202'); + + pendingA.reject({ status: 404 }); + await refreshA; + await tick(); + expect(screen.getByTestId('detail-title')).toHaveTextContent('Item 202'); + expect(infoToast).not.toHaveBeenCalled(); + expect(back).not.toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + }); + + test('navigates away when the current item refresh returns 404', async () => { + const pending = deferred(); + api.items.get.mockReturnValueOnce(pending.promise); + vi.spyOn(window.history, 'length', 'get').mockReturnValue(2); + const back = vi.spyOn(window.history, 'back').mockImplementation(() => {}); + render(MobileItemDetail, { itemId: 101 }); + expect(await screen.findByTestId('detail-title')).toHaveTextContent('Item 101'); + + const refresh = stream.handlers.onItem(); + expect(api.items.get).toHaveBeenCalledWith(101); + pending.reject({ status: 404 }); + await refresh; + expect(infoToast).toHaveBeenCalledWith('mobile.item.deleted'); + expect(back).toHaveBeenCalledOnce(); + expect(navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/internal/auth/cache_close_test.go b/internal/auth/cache_close_test.go new file mode 100644 index 0000000..4371676 --- /dev/null +++ b/internal/auth/cache_close_test.go @@ -0,0 +1,45 @@ +package auth_test + +import ( + "io" + "sync" + "testing" + + "windshift/internal/auth" +) + +func TestValidationCacheCloseIsConcurrentAndIdempotent(t *testing.T) { + for _, tc := range []struct { + name string + new func() io.Closer + }{ + {"api_tokens", func() io.Closer { return auth.NewTokenManager(nil, nil, 1) }}, + {"scim_tokens", func() io.Closer { return auth.NewSCIMTokenManager(nil, 1) }}, + {"sessions", func() io.Closer { + return auth.NewSessionManagerWithValidationCacheTTL(nil, false, false, nil, "test-secret", "strict", auth.DefaultSessionValidationCacheTTL) + }}, + {"sessions_disabled", func() io.Closer { + return auth.NewSessionManagerWithValidationCacheTTL(nil, false, false, nil, "test-secret", "strict", 0) + }}, + {"api_tokens_no_cache", func() io.Closer { return &auth.TokenManager{} }}, + {"scim_tokens_no_cache", func() io.Closer { return &auth.SCIMTokenManager{} }}, + {"sessions_no_validator", func() io.Closer { return &auth.SessionManager{} }}, + } { + t.Run(tc.name, func(t *testing.T) { + manager := tc.new() + t.Cleanup(func() { _ = manager.Close() }) + var callers sync.WaitGroup + for range 8 { + callers.Go(func() { + if err := manager.Close(); err != nil { + t.Errorf("Close: %v", err) + } + }) + } + callers.Wait() + if err := manager.Close(); err != nil { + t.Fatalf("repeated Close: %v", err) + } + }) + } +} diff --git a/internal/repository/collection_queue_scope_test.go b/internal/repository/collection_queue_scope_test.go new file mode 100644 index 0000000..1d689b4 --- /dev/null +++ b/internal/repository/collection_queue_scope_test.go @@ -0,0 +1,280 @@ +//go:build test + +package repository_test + +import ( + "errors" + "testing" + "time" + + "windshift/internal/database" + "windshift/internal/models" + "windshift/internal/repository" + "windshift/internal/testutils" +) + +func TestCollectionWorkspaceMovePreservesQueues(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + db := tdb.GetDatabase() + ownerID := testutils.InsertID(t, db, `INSERT INTO users (email, username, first_name, last_name) VALUES ('queue-move@example.test', 'queue-move', 'Queue', 'Move')`) + oldWorkspace := testutils.InsertID(t, db, `INSERT INTO workspaces (name, key) VALUES ('Queue old', 'QOLD')`) + newWorkspace := testutils.InsertID(t, db, `INSERT INTO workspaces (name, key) VALUES ('Queue new', 'QNEW')`) + collections := repository.NewCollectionRepository(db) + queues := repository.NewQueueRepository(db) + collection := models.Collection{Name: "Moving collection", WorkspaceID: &oldWorkspace} + if err := collections.Create(&collection, ownerID); err != nil { + t.Fatal(err) + } + created, err := queues.Create(&models.Queue{WorkspaceID: oldWorkspace, CollectionID: &collection.ID, Name: "Keep me", QLQuery: "assignee IS NULL"}) + if err != nil { + t.Fatal(err) + } + collection.WorkspaceID = &newWorkspace + if err := collections.Update(collection.ID, &collection); err != nil { + t.Fatal(err) + } + newRows, err := queues.ListByScope(newWorkspace, &collection.ID) + if err != nil { + t.Fatal(err) + } + oldRows, err := queues.ListByScope(oldWorkspace, &collection.ID) + if err != nil { + t.Fatal(err) + } + if len(newRows) != 1 || newRows[0].ID != created.ID || len(oldRows) != 0 { + t.Fatalf("moved queue not in collection scope: new=%+v old=%+v", newRows, oldRows) + } + if _, err := db.ExecWrite(`DELETE FROM workspaces WHERE id = ?`, oldWorkspace); err != nil { + t.Fatal(err) + } + if _, err := queues.GetByID(created.ID); err != nil { + t.Fatalf("queue lost with old workspace: %v", err) + } +} + +func TestQueueWriteWaitsForConcurrentCollectionMovePostgres(t *testing.T) { + if !testutils.IsPostgres() { + t.Skip("PostgreSQL row-lock serialization") + } + for _, tc := range []struct { + name string + write func(*repository.QueueRepository, *models.Queue) error + }{ + {"create", func(queues *repository.QueueRepository, q *models.Queue) error { + _, err := queues.Create(q) + return err + }}, + {"hide builtin", func(queues *repository.QueueRepository, q *models.Queue) error { + return queues.SetBuiltinHidden(q, true) + }}, + {"restore builtin", func(queues *repository.QueueRepository, q *models.Queue) error { + return queues.SetBuiltinHidden(q, false) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + db := tdb.GetDatabase() + ownerID := testutils.InsertID(t, db, `INSERT INTO users (email, username, first_name, last_name) VALUES ('queue-concurrent@example.test', 'queue-concurrent', 'Queue', 'Concurrent')`) + oldWorkspace := testutils.InsertID(t, db, `INSERT INTO workspaces (name, key) VALUES ('Concurrent queue old', 'CQOLD')`) + newWorkspace := testutils.InsertID(t, db, `INSERT INTO workspaces (name, key) VALUES ('Concurrent queue new', 'CQNEW')`) + collections := repository.NewCollectionRepository(db) + queues := repository.NewQueueRepository(db) + collection := models.Collection{Name: "Concurrent", WorkspaceID: &oldWorkspace} + if err := collections.Create(&collection, ownerID); err != nil { + t.Fatal(err) + } + builtinKey := "unassigned" + q := &models.Queue{WorkspaceID: oldWorkspace, CollectionID: &collection.ID, Name: "Concurrent queue", QLQuery: "assignee IS NULL", BuiltinKey: &builtinKey} + if tc.name == "create" { + q.BuiltinKey = nil + } + + // Hold the same collection row lock as CollectionRepository.Update, + // then observe the writer waiting in PostgreSQL before committing. + tx, err := db.Begin() + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.Exec("UPDATE collections SET workspace_id = ? WHERE id = ?", newWorkspace, collection.ID); err != nil { + t.Fatal(err) + } + writeDone := make(chan error, 1) + go func() { writeDone <- tc.write(queues, q) }() + if err := waitForQueueRowLock(db, 5*time.Second); err != nil { + _ = tx.Rollback() + <-writeDone + t.Fatal(err) + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + if err := <-writeDone; !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("write after concurrent move = %v, want not found", err) + } + rows, err := queues.ListByScope(oldWorkspace, &collection.ID) + if err != nil || len(rows) != 0 { + t.Fatalf("queue stranded in old scope: rows=%+v error=%v", rows, err) + } + }) + } +} + +func waitForQueueRowLock(db database.Database, timeout time.Duration) error { + deadline := time.NewTimer(timeout) + defer deadline.Stop() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + var waiting int + err := db.QueryRow(` + SELECT COUNT(*) FROM pg_stat_activity + WHERE datname = current_database() AND wait_event_type = 'Lock' + AND query LIKE '%UPDATE collections SET updated_at = updated_at%' + `).Scan(&waiting) + if err != nil { + return err + } + if waiting > 0 { + return nil + } + select { + case <-ticker.C: + case <-deadline.C: + return errors.New("queue writer did not wait for the collection row lock") + } + } +} + +func TestCollectionGlobalMoveRejectsExistingQueuesWithoutChangingData(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + db := tdb.GetDatabase() + ownerID := testutils.InsertID(t, db, `INSERT INTO users (email, username, first_name, last_name) VALUES ('queue-global@example.test', 'queue-global', 'Queue', 'Global')`) + workspaceID := testutils.InsertID(t, db, `INSERT INTO workspaces (name, key) VALUES ('Queue scoped', 'QSCOPE')`) + collections := repository.NewCollectionRepository(db) + queues := repository.NewQueueRepository(db) + withQueue := models.Collection{Name: "Has queue", WorkspaceID: &workspaceID} + if err := collections.Create(&withQueue, ownerID); err != nil { + t.Fatal(err) + } + queue, err := queues.Create(&models.Queue{WorkspaceID: workspaceID, CollectionID: &withQueue.ID, Name: "Preserve", QLQuery: "assignee IS NULL"}) + if err != nil { + t.Fatal(err) + } + withQueue.WorkspaceID = nil + withQueue.Name = "Rejected rename" + if err := collections.Update(withQueue.ID, &withQueue); !errors.Is(err, repository.ErrCollectionQueuesRequireWorkspace) { + t.Fatalf("move with queue = %v, want ErrCollectionQueuesRequireWorkspace", err) + } + stored, err := collections.GetModel(withQueue.ID) + if err != nil { + t.Fatal(err) + } + if stored.WorkspaceID == nil || *stored.WorkspaceID != workspaceID || stored.Name != "Has queue" { + t.Fatalf("failed move changed collection: %+v", stored) + } + if _, err := queues.GetByID(queue.ID); err != nil { + t.Fatalf("failed move lost queue: %v", err) + } + + withoutQueue := models.Collection{Name: "No queue", WorkspaceID: &workspaceID} + if err := collections.Create(&withoutQueue, ownerID); err != nil { + t.Fatal(err) + } + withoutQueue.WorkspaceID = nil + if err := collections.Update(withoutQueue.ID, &withoutQueue); err != nil { + t.Fatalf("move without queue: %v", err) + } + global, err := collections.GetModel(withoutQueue.ID) + if err != nil { + t.Fatal(err) + } + if global.WorkspaceID != nil { + t.Fatalf("global collection still has workspace: %+v", global) + } +} + +func TestLegacyGlobalCollectionWithQueueCanBeRenamedAndRebound(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + db := tdb.GetDatabase() + ownerID := testutils.InsertID(t, db, `INSERT INTO users (email, username, first_name, last_name) VALUES ('legacy-queue@example.test', 'legacy-queue', 'Queue', 'Legacy')`) + oldWorkspace := testutils.InsertID(t, db, `INSERT INTO workspaces (name, key) VALUES ('Legacy queue old', 'LQOLD')`) + newWorkspace := testutils.InsertID(t, db, `INSERT INTO workspaces (name, key) VALUES ('Legacy queue new', 'LQNEW')`) + collections := repository.NewCollectionRepository(db) + queues := repository.NewQueueRepository(db) + collection := models.Collection{Name: "Legacy", WorkspaceID: &oldWorkspace} + if err := collections.Create(&collection, ownerID); err != nil { + t.Fatal(err) + } + queue, err := queues.Create(&models.Queue{WorkspaceID: oldWorkspace, CollectionID: &collection.ID, Name: "Retained", QLQuery: "assignee IS NULL"}) + if err != nil { + t.Fatal(err) + } + // Recreate the old bug's otherwise unreachable persisted state. + if _, err := db.ExecWrite("UPDATE collections SET workspace_id = NULL WHERE id = ?", collection.ID); err != nil { + t.Fatal(err) + } + collection.WorkspaceID = nil + collection.Name = "Renamed legacy" + if err := collections.Update(collection.ID, &collection); err != nil { + t.Fatalf("rename legacy global collection: %v", err) + } + stored, err := collections.GetModel(collection.ID) + if err != nil { + t.Fatal(err) + } + if stored.WorkspaceID != nil || stored.Name != "Renamed legacy" { + t.Fatalf("rename changed scope or name: %+v", stored) + } + if _, err := queues.GetByID(queue.ID); err != nil { + t.Fatalf("rename lost legacy queue: %v", err) + } + collection.WorkspaceID = &newWorkspace + if err := collections.Update(collection.ID, &collection); err != nil { + t.Fatalf("rebind legacy collection: %v", err) + } + rows, err := queues.ListByScope(newWorkspace, &collection.ID) + if err != nil || len(rows) != 1 || rows[0].ID != queue.ID { + t.Fatalf("rebound queue: rows=%+v error=%v", rows, err) + } +} + +func TestQueueCreationRejectsStaleCollectionWorkspace(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + db := tdb.GetDatabase() + ownerID := testutils.InsertID(t, db, `INSERT INTO users (email, username, first_name, last_name) VALUES ('stale-queue@example.test', 'stale-queue', 'Queue', 'Stale')`) + oldWorkspace := testutils.InsertID(t, db, `INSERT INTO workspaces (name, key) VALUES ('Stale queue old', 'SQOLD')`) + newWorkspace := testutils.InsertID(t, db, `INSERT INTO workspaces (name, key) VALUES ('Stale queue new', 'SQNEW')`) + collections := repository.NewCollectionRepository(db) + queues := repository.NewQueueRepository(db) + collection := models.Collection{Name: "Moving", WorkspaceID: &oldWorkspace} + if err := collections.Create(&collection, ownerID); err != nil { + t.Fatal(err) + } + collection.WorkspaceID = &newWorkspace + if err := collections.Update(collection.ID, &collection); err != nil { + t.Fatal(err) + } + if _, err := queues.Create(&models.Queue{WorkspaceID: oldWorkspace, CollectionID: &collection.ID, Name: "Stale", QLQuery: "assignee IS NULL"}); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("stale queue create = %v, want not found", err) + } + builtinKey := "unassigned" + staleBuiltin := &models.Queue{WorkspaceID: oldWorkspace, CollectionID: &collection.ID, Name: "Unassigned", QLQuery: "assignee IS NULL", BuiltinKey: &builtinKey} + for _, hidden := range []bool{true, false} { + if err := queues.SetBuiltinHidden(staleBuiltin, hidden); !errors.Is(err, repository.ErrNotFound) { + t.Fatalf("stale builtin hidden=%v = %v, want not found", hidden, err) + } + } + rows, err := queues.ListByScope(oldWorkspace, &collection.ID) + if err != nil || len(rows) != 0 { + t.Fatalf("stale queue persisted: rows=%+v error=%v", rows, err) + } + if _, err := queues.Create(&models.Queue{WorkspaceID: newWorkspace, CollectionID: &collection.ID, Name: "Current", QLQuery: "assignee IS NULL"}); err != nil { + t.Fatalf("current queue create: %v", err) + } +} diff --git a/internal/services/item_cache_close_test.go b/internal/services/item_cache_close_test.go new file mode 100644 index 0000000..d9156b5 --- /dev/null +++ b/internal/services/item_cache_close_test.go @@ -0,0 +1,66 @@ +//go:build test + +package services_test + +import ( + "io" + "sync" + "testing" + "time" + + "windshift/internal/services" + "windshift/internal/testutils" +) + +func TestItemCacheCloseIsConcurrentAndIdempotent(t *testing.T) { + cache, err := services.NewItemCacheService(nil, services.ItemCacheConfig{ + HierarchyTTL: time.Minute, MaxCacheSize: 1, EnablePreWarm: false, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cache.Close() }) + var callers sync.WaitGroup + for range 8 { + callers.Go(func() { + if err := cache.Close(); err != nil { + t.Errorf("Close: %v", err) + } + }) + } + callers.Wait() + if err := cache.Close(); err != nil { + t.Fatalf("repeated Close: %v", err) + } + if err := (&services.ItemCacheService{}).Close(); err != nil { + t.Fatalf("Close with no cache: %v", err) + } +} + +func TestPermissionCacheCloseIsConcurrentAndIdempotent(t *testing.T) { + db := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = db.Close() }) + cache, err := services.NewPermissionService(db.DB, services.PermissionCacheConfig{ + TTL: time.Minute, MaxCacheSize: 1, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cache.Close() }) + for _, closer := range []io.Closer{cache, &services.PermissionService{}} { + var callers sync.WaitGroup + for range 8 { + callers.Go(func() { + if err := closer.Close(); err != nil { + t.Errorf("Close: %v", err) + } + }) + } + callers.Wait() + } + // Cache ownership must not extend to the shared database. + var one int + if err := db.QueryRow("SELECT 1").Scan(&one); err != nil || one != 1 { + t.Fatalf("database after cache close: value=%d error=%v", one, err) + } +} diff --git a/internal/services/item_support_queues_permissions_test.go b/internal/services/item_support_queues_permissions_test.go new file mode 100644 index 0000000..299e70b --- /dev/null +++ b/internal/services/item_support_queues_permissions_test.go @@ -0,0 +1,127 @@ +//go:build test + +package services_test + +import ( + "context" + "errors" + "testing" + + "windshift/internal/models" + "windshift/internal/repository" + "windshift/internal/services" + "windshift/internal/testutils" +) + +func TestCollectionQueuesRequireWorkspaceItemView(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + db := tdb.GetDatabase() + ownerID := testutils.InsertID(t, db, `INSERT INTO users (email, username, first_name, last_name) VALUES ('queue-owner@example.test', 'queue-owner', 'Queue', 'Owner')`) + outsiderID := testutils.InsertID(t, db, `INSERT INTO users (email, username, first_name, last_name) VALUES ('queue-outsider@example.test', 'queue-outsider', 'Queue', 'Outsider')`) + workspaceID := testutils.InsertID(t, db, `INSERT INTO workspaces (name, key) VALUES ('Restricted queue workspace', 'QRESTRICT')`) + if _, err := db.ExecWrite(`INSERT INTO user_workspace_roles (user_id, workspace_id, role_id) SELECT ?, ?, id FROM workspace_roles WHERE builtin_key = 'viewer'`, ownerID, workspaceID); err != nil { + t.Fatal(err) + } + public := models.Collection{Name: "Public queue collection", WorkspaceID: &workspaceID, IsPublic: true} + if err := repository.NewCollectionRepository(db).Create(&public, ownerID); err != nil { + t.Fatal(err) + } + private := models.Collection{Name: "Revoked owner collection", WorkspaceID: &workspaceID} + if err := repository.NewCollectionRepository(db).Create(&private, outsiderID); err != nil { + t.Fatal(err) + } + global := models.Collection{Name: "Private global collection"} + if err := repository.NewCollectionRepository(db).Create(&global, ownerID); err != nil { + t.Fatal(err) + } + if _, err := repository.NewQueueRepository(db).Create(&models.Queue{WorkspaceID: workspaceID, CollectionID: &public.ID, Name: "Internal escalation", QLQuery: "assignee IS NULL"}); err != nil { + t.Fatal(err) + } + + config := services.DefaultPermissionCacheConfig() + config.WarmupOnStartup = false + permissions, err := services.NewPermissionService(db, config) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = permissions.Close() }) + allowed, err := permissions.HasWorkspacePermission(outsiderID, workspaceID, models.PermissionItemView) + if err != nil { + t.Fatal(err) + } + if allowed { + t.Fatal("outsider unexpectedly has item.view") + } + app := services.NewItemApplicationService(db, permissions, nil, nil, nil) + for _, collectionID := range []int{public.ID, private.ID, global.ID} { + if _, err := app.ListQueues(context.Background(), outsiderID, 0, &collectionID); !errors.Is(err, repository.ErrNotFound) { + t.Errorf("ListQueues(collection %d) = %v, want not found", collectionID, err) + } + if _, err := app.CreateQueue(context.Background(), services.AuditActor{UserID: outsiderID}, 0, &collectionID, services.QueueInput{Name: "Unauthorized", QL: "assignee IS NULL"}); !errors.Is(err, repository.ErrNotFound) { + t.Errorf("CreateQueue(collection %d) = %v, want not found", collectionID, err) + } + } + visible, err := app.ListQueues(context.Background(), ownerID, 0, &public.ID) + if err != nil { + t.Fatalf("owner ListQueues: %v", err) + } + found := false + for _, queue := range visible { + if queue.Name == "Internal escalation" { + found = true + } + } + if !found { + t.Fatalf("owner could not see custom queue: %+v", visible) + } +} + +func TestCollectionApplicationRejectsGlobalMoveWhileQueuesExist(t *testing.T) { + tdb := testutils.CreateTestDB(t, true) + t.Cleanup(func() { _ = tdb.Close() }) + db := tdb.GetDatabase() + ownerID := testutils.InsertID(t, db, `INSERT INTO users (email, username, first_name, last_name) VALUES ('queue-move-owner@example.test', 'queue-move-owner', 'Queue', 'Owner')`) + workspaceID := testutils.InsertID(t, db, `INSERT INTO workspaces (name, key) VALUES ('Queue move', 'QMOVE')`) + if _, err := db.ExecWrite(`INSERT INTO user_workspace_roles (user_id, workspace_id, role_id) SELECT ?, ?, id FROM workspace_roles WHERE builtin_key = 'viewer'`, ownerID, workspaceID); err != nil { + t.Fatal(err) + } + collection := models.Collection{Name: "Scoped", WorkspaceID: &workspaceID} + if err := repository.NewCollectionRepository(db).Create(&collection, ownerID); err != nil { + t.Fatal(err) + } + if _, err := repository.NewQueueRepository(db).Create(&models.Queue{WorkspaceID: workspaceID, CollectionID: &collection.ID, Name: "Preserved", QLQuery: "assignee IS NULL"}); err != nil { + t.Fatal(err) + } + config := services.DefaultPermissionCacheConfig() + config.WarmupOnStartup = false + permissions, err := services.NewPermissionService(db, config) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = permissions.Close() }) + app := services.NewCollectionApplicationService(db, permissions) + _, err = app.Update(services.AuditActor{UserID: ownerID}, collection.ID, services.CollectionUpdate{WorkspaceIDSet: true}) + var validation *services.CollectionValidationError + if !errors.As(err, &validation) || validation.Message != "remove collection queues before clearing its workspace" { + t.Fatalf("global move with queues = %v, want collection validation error", err) + } + stored, err := repository.NewCollectionRepository(db).GetModel(collection.ID) + if err != nil || stored.WorkspaceID == nil || *stored.WorkspaceID != workspaceID || stored.Name != "Scoped" { + t.Fatalf("rejected move changed collection: value=%+v error=%v", stored, err) + } + // Emulate the legacy state left by the old move path. Ordinary edits must + // remain possible even when a queue is stranded in the former workspace. + if _, err := db.ExecWrite("UPDATE collections SET workspace_id = NULL WHERE id = ?", collection.ID); err != nil { + t.Fatal(err) + } + updated, err := app.Update(services.AuditActor{UserID: ownerID}, collection.ID, + services.CollectionUpdate{NameSet: true, Name: "Renamed legacy"}) + if err != nil || updated.WorkspaceID != nil || updated.Name != "Renamed legacy" { + t.Fatalf("legacy rename: value=%+v error=%v", updated, err) + } + rows, err := repository.NewQueueRepository(db).ListByScope(workspaceID, &collection.ID) + if err != nil || len(rows) != 1 || rows[0].Name != "Preserved" { + t.Fatalf("legacy rename lost queue: rows=%+v error=%v", rows, err) + } +} diff --git a/internal/services/zammad_actor_completion_test.go b/internal/services/zammad_actor_completion_test.go new file mode 100644 index 0000000..88833a9 --- /dev/null +++ b/internal/services/zammad_actor_completion_test.go @@ -0,0 +1,88 @@ +package services + +import ( + "context" + "strings" + "testing" + + "windshift/internal/models" + "windshift/internal/repository" +) + +func TestZammadCompletionRequiresActiveConfiguredActor(t *testing.T) { + f := newZammadServiceFixture(t, nil) + f.service.workflow = NewWorkflowService(f.db) + + if _, err := f.db.ExecWrite(` + INSERT INTO user_global_permissions (user_id, permission_id) + SELECT ?, id FROM permissions WHERE permission_key = 'system.admin' + `, f.actorID); err != nil { + t.Fatal(err) + } + + link, err := f.service.CreateTicket(context.Background(), f.item1, f.actorID, + models.CreateZammadTicketRequest{ConnectionID: f.connection.ProviderID}) + if err != nil { + t.Fatal(err) + } + closed := []int{4} + if _, err := f.service.UpdateConnection(f.connection.ProviderID, + models.UpdateZammadConnectionRequest{ClosedStateIDs: &closed, CompletionStatusID: &f.doneStatus}); err != nil { + t.Fatal(err) + } + + if _, err := NewUserDeactivationService(f.db, UserDeactivationInvalidators{}).DeactivateUser(f.actorID); err != nil { + t.Fatal(err) + } + config := DefaultPermissionCacheConfig() + config.WarmupOnStartup = false + permission, err := NewPermissionService(f.db, config) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = permission.Close() }) + f.service.permission = permission + allowed, err := permission.HasWorkspacePermission(f.actorID, f.workspace1, models.PermissionItemEdit) + if err != nil || !allowed { + t.Fatalf("stale grant prerequisite: allowed=%v error=%v", allowed, err) + } + + f.transport.getTicket = map[string]any{ + "id": 901, "number": "420901", "group_id": 7, + "state_id": 4, "state": "closed", + } + if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err == nil || !strings.Contains(err.Error(), "configured Zammad actor is inactive") { + t.Fatalf("inactive actor sync error = %v", err) + } + var statusID int + if err := f.db.QueryRow("SELECT status_id FROM items WHERE id = ?", f.item1).Scan(&statusID); err != nil { + t.Fatal(err) + } + if statusID != f.openStatus { + t.Fatalf("inactive actor completed item: status=%d", statusID) + } + stored, err := f.service.GetTicketLink(link.ID) + if err != nil { + t.Fatal(err) + } + if stored.CompletionApplied || !strings.Contains(stored.LastError, "configured Zammad actor is inactive") { + t.Fatalf("rejected completion state = %#v", stored) + } + + if err := repository.NewUserRepository(f.db).SetActive(f.actorID, true); err != nil { + t.Fatal(err) + } + if _, err := f.service.SyncTicketLink(context.Background(), link.ID); err != nil { + t.Fatalf("reactivated actor cannot complete: %v", err) + } + if err := f.db.QueryRow("SELECT status_id FROM items WHERE id = ?", f.item1).Scan(&statusID); err != nil { + t.Fatal(err) + } + stored, err = f.service.GetTicketLink(link.ID) + if err != nil { + t.Fatal(err) + } + if statusID != f.doneStatus || !stored.CompletionApplied || stored.LastError != "" { + t.Fatalf("reactivated completion: status=%d link=%#v", statusID, stored) + } +} diff --git a/tests/server_cache_shutdown_test.go b/tests/server_cache_shutdown_test.go new file mode 100644 index 0000000..cab1f24 --- /dev/null +++ b/tests/server_cache_shutdown_test.go @@ -0,0 +1,179 @@ +package tests + +import ( + "bytes" + "context" + "path/filepath" + "runtime/pprof" + "strings" + "testing" + "time" + + "windshift/internal/auth" + "windshift/internal/config" + "windshift/internal/server" +) + +// Cache workers retain their caches after the server is otherwise unreachable. +// Observe worker identities because unrelated workers exiting must not mask a +// leak. Run sequentially with respect to other server/cache constructors. +func TestServerShutdownStopsCacheWorkers(t *testing.T) { + for _, ttl := range []time.Duration{0, auth.DefaultSessionValidationCacheTTL} { + t.Run("session_cache_"+ttl.String(), func(t *testing.T) { + preexisting := cacheWorkerIDs(t) + other := auth.NewTokenManagerWithCacheBudget(nil, nil, "unrelated_shutdown_test", 1) + t.Cleanup(func() { _ = other.Close() }) + unrelated := cacheWorkerIDs(t) + for id := range preexisting { + delete(unrelated, id) + } + if len(unrelated) != 1 { + t.Fatalf("unrelated cache workers = %d, want 1", len(unrelated)) + } + before := cacheWorkerIDs(t) + srv, cleanup := startCacheTestServer(t, ttl) + owned := cacheWorkerIDs(t) + for id := range before { + delete(owned, id) + } + // Guard against a vacuous pass if BigCache changes its worker name. + wantWorkers := 6 + if ttl > 0 { + wantWorkers++ + } + if len(owned) != wantWorkers { + t.Fatalf("server cache workers = %d, want %d", len(owned), wantWorkers) + } + cleanup() + if err := srv.Shutdown(context.Background()); err != nil { + t.Fatalf("repeated Shutdown: %v", err) + } + waitForCondition(t, 3*time.Second, "server cache workers to stop", func() bool { + for id := range cacheWorkerIDs(t) { + if owned[id] { + return false + } + } + return true + }) + remaining := cacheWorkerIDs(t) + for id := range unrelated { + if !remaining[id] { + t.Fatal("server shutdown stopped an unrelated cache worker") + } + } + }) + } +} + +func startCacheTestServer(t *testing.T, ttl time.Duration) (*server.Server, func()) { + t.Helper() + srv, err := server.New(server.Config{ + Port: "0", SilentMode: true, DisableCSRF: true, + DB: config.DBConfig{ + SQLitePath: filepath.Join(t.TempDir(), "cache-shutdown.db"), + MaxReadConns: 10, MaxWriteConns: 1, + }, + Auth: config.AuthConfig{ + SessionSecret: "cache-shutdown-test-secret", + SessionValidationCacheTTL: ttl, + }, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = srv.Shutdown(context.Background()) }) + if err := srv.Start(); err != nil { + t.Fatal(err) + } + return srv, func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + t.Fatalf("shutdown server: %v", err) + } + } +} + +func waitForCondition(t *testing.T, timeout time.Duration, description string, condition func() bool) { + t.Helper() + deadline := time.NewTimer(timeout) + defer deadline.Stop() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + if condition() { + return + } + select { + case <-ticker.C: + case <-deadline.C: + t.Fatalf("timed out waiting for %s", description) + } + } +} + +func TestServerStartupFailureStopsCacheWorkers_SQLite(t *testing.T) { + before := cacheWorkerIDs(t) + activityBefore := workerIDs(t, "windshift/internal/server.(*Server).runActivityCleanup(") + // A missing production RPID fails after permission, activity and session + // caches are constructed, before the HTTP server is returned to its owner. + srv, err := server.New(server.Config{ + Port: "0", SilentMode: true, + DB: config.DBConfig{ + SQLitePath: filepath.Join(t.TempDir(), "startup-failure.db"), + MaxReadConns: 2, MaxWriteConns: 1, + }, + Auth: config.AuthConfig{ + SessionSecret: "cache-startup-test-secret", + SessionValidationCacheTTL: auth.DefaultSessionValidationCacheTTL, + }, + }) + if srv != nil { + t.Cleanup(func() { _ = srv.Shutdown(context.Background()) }) + t.Fatal("server unexpectedly started with no production RPID") + } + if err == nil || !strings.Contains(err.Error(), "no RP ID provided") { + t.Fatalf("startup error = %v, want missing RPID", err) + } + waitForCondition(t, 3*time.Second, "failed startup cache workers to stop", func() bool { + for id := range cacheWorkerIDs(t) { + if !before[id] { + return false + } + } + return true + }) + waitForCondition(t, 3*time.Second, "failed startup activity cleanup to stop", func() bool { + for id := range workerIDs(t, "windshift/internal/server.(*Server).runActivityCleanup(") { + if !activityBefore[id] { + return false + } + } + return true + }) +} + +func cacheWorkerIDs(t *testing.T) map[string]bool { + t.Helper() + return workerIDs(t, "bigcache/v3.newBigCache.func1()") +} + +func workerIDs(t *testing.T, frame string) map[string]bool { + t.Helper() + var stacks bytes.Buffer + if err := pprof.Lookup("goroutine").WriteTo(&stacks, 2); err != nil { + t.Fatal(err) + } + workers := make(map[string]bool) + for _, stack := range strings.Split(stacks.String(), "\n\n") { + if strings.Contains(stack, frame) { + fields := strings.Fields(stack) + if len(fields) < 2 || fields[0] != "goroutine" { + t.Fatalf("unexpected goroutine profile header: %q", stack) + } + workers[fields[1]] = true + } + } + return workers +} diff --git a/tests/v2_collection_queue_access_test.go b/tests/v2_collection_queue_access_test.go new file mode 100644 index 0000000..a7e17c2 --- /dev/null +++ b/tests/v2_collection_queue_access_test.go @@ -0,0 +1,39 @@ +package tests + +import ( + "fmt" + "net/http" + "testing" +) + +func TestV2PublicCollectionQueuesRequireWorkspaceMembership(t *testing.T) { + server, _ := StartTestServer(t, GetDBType()) + CreateBearerToken(t, server) + key := shortKey("QPUBLIC") + workspaceID, _ := CreateTestWorkspace(t, server, "Restricted public queue", key) + memberID, _, _ := CreateTestUserWithCredentials(t, server, "queue_member", "queue_member@example.test") + AssignWorkspaceRole(t, server, memberID, workspaceID, "Viewer") + _, outsiderName, outsiderPassword := CreateTestUserWithCredentials(t, server, "queue_outsider", "queue_outsider@example.test") + outsiderCookie := CreateBearerTokenForUser(t, server, outsiderName, outsiderPassword) + + created := DecodeV2Document[struct { + ID int `json:"id"` + }](t, + MakeV2SessionRequest(t, server, http.MethodPost, "/collections", map[string]any{ + "name": "Public queue scope", "workspace_id": workspaceID, + "ql_query": fmt.Sprintf(`workspaceKey = "%s"`, key), + "is_public": true, "public_slug": "public-queue-scope", + }), http.StatusCreated) + path := fmt.Sprintf("/v2/collections/%d/queues", created.ID) + for _, request := range []struct { + method string + body any + }{ + {http.MethodGet, nil}, + {http.MethodPost, map[string]any{"name": "Unauthorized", "ql_query": "assignee IS NULL"}}, + } { + response := MakeAuthRequestWithToken(t, server, outsiderCookie, request.method, path, request.body) + AssertStatusCode(t, response, http.StatusNotFound) + _ = response.Body.Close() + } +}