Add KeepOrg sync server: accounts, nested groups, versioned KDBX sync
Why: collaborative KeePass use needs one arbiter of "who wrote last" that never sees a master password, plus access control that follows an org chart. What: stdlib-only Go server. Metadata is a single JSON document with one writer and atomic commit; KDBX payloads are versioned blobs on disk. Access comes from a group tree where a grant on a group reaches every member below it. Login is password+TOTP, invite links, or OIDC through a browser-approved device flow. Pushes carry If-Match and are arbitrated by a per-database merge strategy (server-wins by default). Updates are pushed to clients over SSE. Proved: go test ./internal/... covers RFC 6238 vectors, ACL inheritance and isolation, store rollback, blob pruning, every push verdict, and an end-to-end HTTP pass over login, conflict, force, read-only grants, invite redemption, TOTP enrolment, device login and the event stream.
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestUpdatePersistsAndSurvivesReopen(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "state.json")
|
||||
st, err := Open(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.Update(func(s *State) error {
|
||||
s.Users["u1"] = &User{ID: "u1", Username: "alice"}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := Open(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var name string
|
||||
again.Read(func(s *State) { name = s.Users["u1"].Username })
|
||||
if name != "alice" {
|
||||
t.Fatalf("reopened username = %q", name)
|
||||
}
|
||||
if fi, _ := os.Stat(p); fi.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("state file mode = %v", fi.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedUpdateLeavesStateUntouched(t *testing.T) {
|
||||
st, err := Open(filepath.Join(t.TempDir(), "state.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
boom := errors.New("boom")
|
||||
if err := st.Update(func(s *State) error {
|
||||
s.Users["u1"] = &User{ID: "u1"}
|
||||
return boom
|
||||
}); !errors.Is(err, boom) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
var n int
|
||||
st.Read(func(s *State) { n = len(s.Users) })
|
||||
if n != 0 {
|
||||
t.Fatalf("users after failed update = %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCDropsExpired(t *testing.T) {
|
||||
st, _ := Open(filepath.Join(t.TempDir(), "state.json"))
|
||||
now := time.Now()
|
||||
_ = st.Update(func(s *State) error {
|
||||
s.Sessions["old"] = &Session{Token: "old", ExpiresAt: now.Add(-time.Hour)}
|
||||
s.Sessions["new"] = &Session{Token: "new", ExpiresAt: now.Add(time.Hour)}
|
||||
s.Invites["spent"] = &Invite{Token: "spent", ExpiresAt: now.Add(time.Hour), MaxUses: 1, Uses: 1}
|
||||
return nil
|
||||
})
|
||||
if err := st.GC(now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.Read(func(s *State) {
|
||||
if len(s.Sessions) != 1 || s.Sessions["new"] == nil {
|
||||
t.Fatalf("sessions after GC = %v", s.Sessions)
|
||||
}
|
||||
if len(s.Invites) != 0 {
|
||||
t.Fatalf("invites after GC = %v", s.Invites)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user