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:
@@ -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):
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user