package client import ( "context" "io" "log" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "cc.tiredsysadmin.keeporg/internal/secret" "cc.tiredsysadmin.keeporg/internal/server" "cc.tiredsysadmin.keeporg/internal/store" ) type stack struct { t *testing.T admin *API mir *Mirror dbID string srv *server.Server url string } const password = "admin-password-1" // newStack runs a real server in-process with one database owned by the // "everyone" group, and a mirror logged in as the administrator. func newStack(t *testing.T) *stack { t.Helper() srv, err := server.New(server.Config{DataDir: t.TempDir()}, log.New(io.Discard, "", 0)) if err != nil { t.Fatal(err) } hash, err := secret.Hash(password) if err != nil { t.Fatal(err) } if err := srv.Store().Update(func(st *store.State) error { st.Users["admin"] = &store.User{ID: "admin", Username: "admin", PassHash: hash, ServerAdmin: true} st.Groups["everyone"] = &store.Group{ID: "everyone", Name: "everyone"} st.Memberships = append(st.Memberships, store.Membership{UserID: "admin", GroupID: "everyone", Role: store.RoleAdmin}) return nil }); err != nil { t.Fatal(err) } ts := httptest.NewServer(srv.Handler()) t.Cleanup(ts.Close) ctx := context.Background() api := NewAPI(ts.URL, "") sess, err := api.Login(ctx, "admin", password, "", "test") if err != nil { t.Fatal(err) } api.Token = sess.Token db, err := api.CreateDatabase(ctx, "Team Vault", "everyone", "") if err != nil { t.Fatal(err) } mir, err := NewMirror(api, t.TempDir(), log.New(io.Discard, "", 0), 50*time.Millisecond) if err != nil { t.Fatal(err) } return &stack{t: t, admin: api, mir: mir, dbID: db.ID, srv: srv, url: ts.URL} } func (s *stack) path(name string) string { return filepath.Join(s.mir.Dir(), name) } func (s *stack) write(name string, data string) { s.t.Helper() if err := os.WriteFile(s.path(name), []byte(data), 0o600); err != nil { s.t.Fatal(err) } } func (s *stack) read(name string) string { s.t.Helper() b, err := os.ReadFile(s.path(name)) if err != nil { s.t.Fatal(err) } return string(b) } func (s *stack) sync() { s.t.Helper() if err := s.mir.SyncAll(context.Background()); err != nil { s.t.Fatalf("sync: %v", err) } } // remoteVersion is what the server currently holds. func (s *stack) remote() (int64, string) { s.t.Helper() data, v, err := s.admin.Pull(context.Background(), s.dbID) if err != nil { s.t.Fatal(err) } return v, string(data) } // conflictFiles lists the conflict copies the mirror left behind. func (s *stack) conflictFiles() []string { s.t.Helper() entries, err := os.ReadDir(s.mir.Dir()) if err != nil { s.t.Fatal(err) } out := []string{} for _, e := range entries { if strings.Contains(e.Name(), ".conflict-") || strings.Contains(e.Name(), ".server-v") { out = append(out, e.Name()) } } return out } func TestMirrorAdoptsLocalFileThenFollowsRemote(t *testing.T) { s := newStack(t) // A KeePass application saves a database into the mirror directory. s.write("Team Vault.kdbx", "local-v1") s.sync() if v, data := s.remote(); v != 1 || data != "local-v1" { t.Fatalf("after adopt: version %d data %q", v, data) } // Another client pushes a change; the mirror applies it locally. if _, err := s.admin.Push(context.Background(), s.dbID, 1, []byte("remote-v2"), false); err != nil { t.Fatal(err) } s.sync() if got := s.read("Team Vault.kdbx"); got != "remote-v2" { t.Fatalf("local file = %q, want remote-v2", got) } // A settled mirror does nothing and burns no versions. s.sync() if v, _ := s.remote(); v != 2 { t.Fatalf("version after idle sync = %d", v) } if got := s.conflictFiles(); len(got) != 0 { t.Fatalf("unexpected conflict files %v", got) } } func TestMirrorMaterializesDatabaseItHasNeverSeen(t *testing.T) { s := newStack(t) if _, err := s.admin.Push(context.Background(), s.dbID, 0, []byte("from-elsewhere"), false); err != nil { t.Fatal(err) } s.sync() if got := s.read("Team Vault.kdbx"); got != "from-elsewhere" { t.Fatalf("local file = %q", got) } } func TestServerWinsKeepsLosingSaveAsConflictCopy(t *testing.T) { s := newStack(t) s.write("Team Vault.kdbx", "base") s.sync() // Simultaneous edits: this client saves locally while another client // pushes a different version. if _, err := s.admin.Push(context.Background(), s.dbID, 1, []byte("their-edit"), false); err != nil { t.Fatal(err) } s.write("Team Vault.kdbx", "my-edit") s.sync() if got := s.read("Team Vault.kdbx"); got != "their-edit" { t.Fatalf("local file = %q, want the server copy", got) } files := s.conflictFiles() if len(files) != 1 { t.Fatalf("conflict files = %v, want exactly one", files) } if got := s.read(files[0]); got != "my-edit" { t.Fatalf("conflict copy = %q, want my-edit", got) } if st := s.mir.Status(); len(st) != 1 || st[0].State != "conflict-resolved" { t.Fatalf("status = %+v", st) } } func TestClientWinsOverwritesTheServer(t *testing.T) { s := newStack(t) s.write("Team Vault.kdbx", "base") s.sync() if _, err := s.admin.SetStrategy(context.Background(), s.dbID, "client-wins"); err != nil { t.Fatal(err) } if _, err := s.admin.Push(context.Background(), s.dbID, 1, []byte("their-edit"), false); err != nil { t.Fatal(err) } s.write("Team Vault.kdbx", "my-edit") s.sync() if v, data := s.remote(); data != "my-edit" || v != 3 { t.Fatalf("server holds version %d %q, want my-edit", v, data) } if got := s.read("Team Vault.kdbx"); got != "my-edit" { t.Fatalf("local file = %q", got) } if got := s.conflictFiles(); len(got) != 0 { t.Fatalf("client-wins should not leave conflict copies, got %v", got) } } func TestManualStrategyLeavesBothCopiesOnce(t *testing.T) { s := newStack(t) s.write("Team Vault.kdbx", "base") s.sync() if _, err := s.admin.SetStrategy(context.Background(), s.dbID, "manual"); err != nil { t.Fatal(err) } if _, err := s.admin.Push(context.Background(), s.dbID, 1, []byte("their-edit"), false); err != nil { t.Fatal(err) } s.write("Team Vault.kdbx", "my-edit") s.sync() if got := s.read("Team Vault.kdbx"); got != "my-edit" { t.Fatalf("manual must not touch the local file, got %q", got) } files := s.conflictFiles() if len(files) != 1 || !strings.Contains(files[0], ".server-v2.kdbx") { t.Fatalf("conflict files = %v", files) } if got := s.read(files[0]); got != "their-edit" { t.Fatalf("server copy = %q", got) } st := s.mir.Status() if len(st) != 1 || st[0].State != "conflict" { t.Fatalf("status = %+v", st) } // Repeated passes must not pile up more copies or more versions. s.sync() s.sync() if files := s.conflictFiles(); len(files) != 1 { t.Fatalf("conflict files after idle passes = %v", files) } if v, _ := s.remote(); v != 2 { t.Fatalf("server version = %d, want 2", v) } } func TestReadOnlyAccessRestoresTheServerCopy(t *testing.T) { s := newStack(t) ctx := context.Background() if _, err := s.admin.Push(ctx, s.dbID, 0, []byte("official"), false); err != nil { t.Fatal(err) } // A reader in a child group with a read grant. g, err := s.admin.CreateGroup(ctx, "readers", "everyone") if err != nil { t.Fatal(err) } if err := s.admin.SetGrant(ctx, g.ID, s.dbID, "read"); err != nil { t.Fatal(err) } hash, _ := secret.Hash("reader-password") if err := s.srv.Store().Update(func(st *store.State) error { st.Users["r"] = &store.User{ID: "r", Username: "reader", PassHash: hash} st.Memberships = append(st.Memberships, store.Membership{UserID: "r", GroupID: g.ID, Role: store.RoleMember}) return nil }); err != nil { t.Fatal(err) } rapi := NewAPI(s.url, "") sess, err := rapi.Login(ctx, "reader", "reader-password", "", "reader") if err != nil { t.Fatal(err) } rapi.Token = sess.Token rmir, err := NewMirror(rapi, t.TempDir(), log.New(io.Discard, "", 0), 50*time.Millisecond) if err != nil { t.Fatal(err) } if err := rmir.SyncAll(ctx); err != nil { t.Fatal(err) } local := filepath.Join(rmir.Dir(), "Team Vault.kdbx") if b, err := os.ReadFile(local); err != nil || string(b) != "official" { t.Fatalf("reader local file = %q %v", b, err) } // The reader edits anyway: the edit is preserved beside the restored copy. if err := os.WriteFile(local, []byte("reader-edit"), 0o600); err != nil { t.Fatal(err) } if err := rmir.SyncAll(ctx); err != nil { t.Fatal(err) } if b, _ := os.ReadFile(local); string(b) != "official" { t.Fatalf("reader file = %q, want the server copy restored", b) } if v, data := s.remote(); v != 1 || data != "official" { t.Fatalf("server changed: version %d %q", v, data) } entries, _ := os.ReadDir(rmir.Dir()) var found bool for _, e := range entries { if strings.Contains(e.Name(), ".conflict-") { found = true b, _ := os.ReadFile(filepath.Join(rmir.Dir(), e.Name())) if string(b) != "reader-edit" { t.Fatalf("conflict copy = %q", b) } } } if !found { t.Fatal("the rejected edit was not kept") } } func TestMirrorFollowsServerSideRename(t *testing.T) { s := newStack(t) s.write("Team Vault.kdbx", "v1") s.sync() if _, err := s.admin.RenameDatabase(context.Background(), s.dbID, "Company Vault"); err != nil { t.Fatal(err) } s.sync() if got := s.read("Company Vault.kdbx"); got != "v1" { t.Fatalf("renamed file = %q", got) } if _, err := os.Stat(s.path("Team Vault.kdbx")); !os.IsNotExist(err) { t.Fatal("the old filename is still there") } } func TestRunAppliesAPushedUpdateWithoutWaitingForThePoll(t *testing.T) { s := newStack(t) ctx, cancel := context.WithCancel(context.Background()) defer cancel() // A long poll interval proves the update arrived over the event stream. mir, err := NewMirror(s.admin, s.mir.Dir(), log.New(io.Discard, "", 0), time.Hour) if err != nil { t.Fatal(err) } go func() { _ = mir.Run(ctx) }() if _, err := s.admin.Push(ctx, s.dbID, 0, []byte("pushed-by-another-client"), false); err != nil { t.Fatal(err) } deadline := time.After(10 * time.Second) for { if b, err := os.ReadFile(s.path("Team Vault.kdbx")); err == nil && string(b) == "pushed-by-another-client" { return } select { case <-deadline: t.Fatal("the pushed update never reached the mirror") case <-time.After(25 * time.Millisecond): } } }