schema_migrations records every applied step; the former migrate() is step 3, the baseline that builds the v0.4.1 schema and repairs older databases. Columns are only added when missing, so every other error now stops the start with a clear message instead of being swallowed, and the cleanup statements no longer run on each start. migrations/001_init.sql was never read and is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
207 lines
8.3 KiB
Go
207 lines
8.3 KiB
Go
package storage
|
|
|
|
import (
|
|
"errors"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/sandrodach/mindsift/internal/domain"
|
|
)
|
|
|
|
func setSharingMode(t *testing.T, store *Store, mode string) {
|
|
t.Helper()
|
|
sys, _ := store.GetSystemSettings()
|
|
sys.SharingMode = mode
|
|
if err := store.UpdateSystemSettings(sys); err != nil {
|
|
t.Fatalf("UpdateSystemSettings: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVisibilityFollowsOrganizations(t *testing.T) {
|
|
store, err := NewStore(filepath.Join(t.TempDir(), "visibility.db"))
|
|
if err != nil {
|
|
t.Fatalf("NewStore failed: %v", err)
|
|
}
|
|
defer store.Close()
|
|
if store.SharingMode() != SharingOrganizations {
|
|
t.Fatalf("default mode should be organizations, got %q", store.SharingMode())
|
|
}
|
|
|
|
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
|
|
ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member")
|
|
carl, _ := store.CreateUser("carl@example.com", "hash", "Carl", "admin")
|
|
dora, _ := store.CreateUser("dora@example.com", "hash", "Dora", "member")
|
|
org1, _ := store.CreateOrganization("Familie")
|
|
org2, _ := store.CreateOrganization("Firma")
|
|
_ = store.SetUserOrganization(anna.ID, org1.ID)
|
|
_ = store.SetUserOrganization(ben.ID, org1.ID)
|
|
_ = store.SetUserOrganization(carl.ID, org2.ID)
|
|
|
|
// Who sees whom (server admins follow the same rule)
|
|
cases := []struct {
|
|
a, b *domain.User
|
|
want bool
|
|
}{
|
|
{anna, ben, true}, {ben, anna, true}, {anna, carl, false}, {carl, anna, false},
|
|
{dora, anna, false}, {anna, dora, false}, {dora, dora, true},
|
|
}
|
|
for _, c := range cases {
|
|
if got := store.CanSee(c.a.ID, c.b.ID); got != c.want {
|
|
t.Errorf("CanSee(%s, %s) = %v, want %v", c.a.DisplayName, c.b.DisplayName, got, c.want)
|
|
}
|
|
}
|
|
if users, _ := store.GetDiscoverableUsers(anna.ID); len(users) != 2 {
|
|
t.Errorf("Anna should see herself and Ben, got %d users", len(users))
|
|
}
|
|
if users, _ := store.GetDiscoverableUsers(dora.ID); len(users) != 1 || users[0].ID != dora.ID {
|
|
t.Errorf("Dora without organization should only see herself, got %+v", users)
|
|
}
|
|
|
|
// Sharing: visible people only; an invisible e-mail looks like an unknown one
|
|
doc, _ := store.CreateDocument(anna.ID, "", "Einkauf", "📄", "", nil, nil, false)
|
|
if _, err := store.CreateDocShare(anna.ID, doc.ID, ben.ID, "write"); err != nil {
|
|
t.Fatalf("share with Ben failed: %v", err)
|
|
}
|
|
if _, err := store.CreateDocShare(anna.ID, doc.ID, "carl@example.com", "read"); !errors.Is(err, ErrTargetNotFound) {
|
|
t.Errorf("share with invisible Carl must look like not found, got %v", err)
|
|
}
|
|
if _, err := store.CreateDocShare(anna.ID, doc.ID, "nobody@example.com", "read"); !errors.Is(err, ErrTargetNotFound) {
|
|
t.Errorf("share with unknown address must be not found, got %v", err)
|
|
}
|
|
|
|
// Only the owner shares a list
|
|
list, _ := store.CreateList(anna.ID, "Wocheneinkauf", "🛒", "shopping")
|
|
if _, err := store.AddListShare(anna.ID, list.ID, "user", ben.ID, "editor"); err != nil {
|
|
t.Fatalf("list share with Ben failed: %v", err)
|
|
}
|
|
if _, err := store.AddListShare(ben.ID, list.ID, "user", anna.ID, "editor"); err == nil {
|
|
t.Errorf("an editor must not reshare a list")
|
|
}
|
|
|
|
// Groups: only members may share with them
|
|
team, _ := store.CreateTeam(org1.ID, "WG", "")
|
|
_ = store.AddTeamMember(team.ID, anna.ID, "admin")
|
|
_ = store.AddTeamMember(team.ID, ben.ID, "member")
|
|
tl, _ := store.CreateTaskList(anna.ID, "", "", "Haushalt", "🏠", "")
|
|
if _, err := store.AddTaskListShare(tl.ID, "team", team.ID, "editor", anna.ID); err != nil {
|
|
t.Fatalf("group share failed: %v", err)
|
|
}
|
|
carlList, _ := store.CreateTaskList(carl.ID, "", "", "Fremd", "🔒", "")
|
|
if _, err := store.AddTaskListShare(carlList.ID, "team", team.ID, "editor", carl.ID); !errors.Is(err, ErrTargetNotFound) {
|
|
t.Errorf("non-member must not share with a group, got %v", err)
|
|
}
|
|
task, _ := store.CreateThoughtWithDetails(anna.ID, "Bad putzen", "normal", "manual", "", tl.ID, "todo", "")
|
|
_, _ = store.UpdateThoughtTriage(anna.ID, task.ID, "keep", "task", "", "", tl.ID)
|
|
if _, err := store.SetTaskAssignees(anna.ID, task.ID, []string{ben.ID}); err != nil {
|
|
t.Fatalf("assign Ben failed: %v", err)
|
|
}
|
|
|
|
// A dry run counts without changing anything
|
|
n, err := store.PruneSharing(SharingPrivate, true)
|
|
if err != nil || n == 0 {
|
|
t.Fatalf("dry run should report affected rows, got %d (%v)", n, err)
|
|
}
|
|
if shares, _ := store.GetDocShares(doc.ID); len(shares) != 1 {
|
|
t.Fatalf("dry run must not remove shares")
|
|
}
|
|
|
|
// Ben moves to the other organization: he loses shares, group and assignment
|
|
if err := store.SetUserOrganization(ben.ID, org2.ID); err != nil {
|
|
t.Fatalf("SetUserOrganization failed: %v", err)
|
|
}
|
|
if shares, _ := store.GetDocShares(doc.ID); len(shares) != 0 {
|
|
t.Errorf("doc share with Ben should be gone, got %+v", shares)
|
|
}
|
|
if l, _ := store.GetListByID(anna.ID, list.ID); len(l.Shares) != 0 {
|
|
t.Errorf("list share with Ben should be gone, got %+v", l.Shares)
|
|
}
|
|
if members, _ := store.GetTeamMembers(team.ID); len(members) != 1 {
|
|
t.Errorf("Ben should have left the group, members: %+v", members)
|
|
}
|
|
if th, _ := store.GetThoughtByID(anna.ID, task.ID); len(th.Assignees) != 0 {
|
|
t.Errorf("Ben's assignment should be gone, got %v", th.Assignees)
|
|
}
|
|
|
|
// Open mode: everyone sees everyone
|
|
setSharingMode(t, store, SharingOpen)
|
|
if !store.CanSee(anna.ID, carl.ID) || !store.CanSee(dora.ID, anna.ID) {
|
|
t.Errorf("open mode should make everyone visible")
|
|
}
|
|
|
|
// Private mode: no sharing at all, groups disappear
|
|
if _, err := store.PruneSharing(SharingPrivate, false); err != nil {
|
|
t.Fatalf("PruneSharing private failed: %v", err)
|
|
}
|
|
setSharingMode(t, store, SharingPrivate)
|
|
if store.CanSee(anna.ID, ben.ID) || store.CanShareWith(anna.ID, "team", team.ID) {
|
|
t.Errorf("private mode must not allow seeing or sharing")
|
|
}
|
|
if tls, _ := store.GetTaskListShares(tl.ID); len(tls) != 0 {
|
|
t.Errorf("private mode should remove all task list shares, got %+v", tls)
|
|
}
|
|
if _, err := store.GetTeamByID(team.ID); err == nil {
|
|
t.Errorf("groups should be gone in private mode")
|
|
}
|
|
}
|
|
|
|
// Existing data must survive a restart, whatever user_version the database carries.
|
|
func TestDataSurvivesRestart(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "keep.db")
|
|
store, err := NewStore(path)
|
|
if err != nil {
|
|
t.Fatalf("NewStore failed: %v", err)
|
|
}
|
|
_, _ = store.CreateUser("old@example.com", "hash", "Alt", "member")
|
|
_, _ = store.db.Exec(`PRAGMA user_version = 1`)
|
|
_ = store.Close()
|
|
|
|
store, err = NewStore(path)
|
|
if err != nil {
|
|
t.Fatalf("reopen failed: %v", err)
|
|
}
|
|
defer store.Close()
|
|
if n, _ := store.CountUsers(); n != 1 {
|
|
t.Fatalf("data must be kept on restart, found %d users", n)
|
|
}
|
|
var v int
|
|
_ = store.db.QueryRow(`PRAGMA user_version`).Scan(&v)
|
|
if want := migrations[len(migrations)-1].id; v != want {
|
|
t.Fatalf("user_version = %d, want %d", v, want)
|
|
}
|
|
}
|
|
|
|
func TestListItemsNeedWriteAccess(t *testing.T) {
|
|
store, err := NewStore(filepath.Join(t.TempDir(), "listacl.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer store.Close()
|
|
openSharing(t, store)
|
|
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
|
|
ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member")
|
|
eve, _ := store.CreateUser("eve@example.com", "hash", "Eve", "member")
|
|
list, _ := store.CreateList(anna.ID, "Einkauf", "", "shopping")
|
|
item, _ := store.CreateListItem(list.ID, "Milch", "", "", 0, anna.ID)
|
|
other, _ := store.CreateList(eve.ID, "Eves Liste", "", "shopping")
|
|
otherItem, _ := store.CreateListItem(other.ID, "Geheim", "", "", 0, eve.ID)
|
|
_, _ = store.AddListShare(anna.ID, list.ID, "user", ben.ID, "viewer")
|
|
|
|
if err := store.EnsureListItemsWritable(anna.ID, list.ID, item.ID); err != nil {
|
|
t.Errorf("owner must write: %v", err)
|
|
}
|
|
if err := store.EnsureListItemsWritable(ben.ID, list.ID, item.ID); err != ErrListReadOnly {
|
|
t.Errorf("viewer must be read-only, got %v", err)
|
|
}
|
|
if err := store.EnsureListItemsWritable(eve.ID, list.ID, ""); err != ErrListNotFound {
|
|
t.Errorf("stranger must not see the list, got %v", err)
|
|
}
|
|
if err := store.EnsureListItemsWritable(anna.ID, list.ID, otherItem.ID); err != ErrListNotFound {
|
|
t.Errorf("an item of another list must not be reachable through my list, got %v", err)
|
|
}
|
|
// Turning a thought into an item of someone else's list is refused as well
|
|
th, _ := store.CreateThoughtWithDetails(eve.ID, "Spam", "normal", "manual", "", "", "todo", "")
|
|
if _, err := store.UpdateThoughtTriage(eve.ID, th.ID, "keep", "list_item", list.ID, "", ""); err == nil {
|
|
t.Errorf("thought must not become an item of a list without write access")
|
|
}
|
|
}
|