Add client engine and desktop CLI that mirror KDBX files

Why: a KeePass application must see an ordinary .kdbx file. A mirror
directory of real files gives that on every desktop platform without FUSE,
WinFsp or macFUSE, and without cgo.

What: internal/client holds the API wrapper, an SSE reader, and a mirror
agent whose entire policy is one pure decide() table: adopt, pull, push,
force-push, server-wins, restore or manual. Saves are detected by rescan,
remote changes arrive over the event stream, and every overwrite of local
bytes leaves a conflict copy. cmd/keeporg drives it: login (password, TOTP or
browser/OIDC device flow), run, sync, status, plus the group, grant, database
and invite administration.

Proved: go test ./internal/client covers the decision table and runs a real
server in-process for adopt, follow, server-wins, client-wins, manual,
read-only restore, server-side rename and event-driven update. Also smoke
tested live: built both binaries, bootstrapped a server, logged in, created a
database and adopted a local file.
This commit is contained in:
2026-10-29 16:01:35 +01:00
parent 8bbb3179a1
commit 4d0d53ff04
10 changed files with 2288 additions and 0 deletions
+362
View File
@@ -0,0 +1,362 @@
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):
}
}
}