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,143 @@
|
||||
package store
|
||||
|
||||
import "sort"
|
||||
|
||||
// maxDepth bounds every tree walk so a corrupted parent cycle cannot hang the
|
||||
// server. Group creation rejects cycles, so this is a backstop only.
|
||||
const maxDepth = 64
|
||||
|
||||
// Ancestors returns groupID and every parent above it, nearest first.
|
||||
func (s *State) Ancestors(groupID string) []string {
|
||||
out := []string{}
|
||||
seen := map[string]bool{}
|
||||
for id := groupID; id != "" && !seen[id] && len(out) < maxDepth; {
|
||||
g, ok := s.Groups[id]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
seen[id] = true
|
||||
out = append(out, id)
|
||||
id = g.ParentID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Descendants returns groupID and every group below it.
|
||||
func (s *State) Descendants(groupID string) []string {
|
||||
if _, ok := s.Groups[groupID]; !ok {
|
||||
return nil
|
||||
}
|
||||
out := []string{groupID}
|
||||
for i := 0; i < len(out); i++ {
|
||||
for id, g := range s.Groups {
|
||||
if g.ParentID == out[i] && !contains(out, id) {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// IsAncestor reports whether anc is at or above desc in the tree.
|
||||
func (s *State) IsAncestor(anc, desc string) bool {
|
||||
return contains(s.Ancestors(desc), anc)
|
||||
}
|
||||
|
||||
// EffectiveGroups is the set of groups whose grants reach the user: every
|
||||
// group the user belongs to, plus every group above those. A grant on
|
||||
// "Company" therefore reaches a member of "Company/Engineering/Backend".
|
||||
func (s *State) EffectiveGroups(userID string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, m := range s.Memberships {
|
||||
if m.UserID != userID {
|
||||
continue
|
||||
}
|
||||
for _, id := range s.Ancestors(m.GroupID) {
|
||||
out[id] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsGroupAdmin reports whether the user administers groupID, either directly
|
||||
// or by administering a group above it. Server admins administer everything.
|
||||
func (s *State) IsGroupAdmin(userID, groupID string) bool {
|
||||
if u := s.Users[userID]; u != nil && u.ServerAdmin {
|
||||
return true
|
||||
}
|
||||
anc := s.Ancestors(groupID)
|
||||
for _, m := range s.Memberships {
|
||||
if m.UserID == userID && m.Role == RoleAdmin && contains(anc, m.GroupID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DatabasePerm is the strongest permission the user holds on a database.
|
||||
func (s *State) DatabasePerm(userID, dbID string) Perm {
|
||||
db, ok := s.Databases[dbID]
|
||||
if !ok {
|
||||
return PermNone
|
||||
}
|
||||
if s.IsGroupAdmin(userID, db.OwnerGroupID) {
|
||||
return PermAdmin
|
||||
}
|
||||
reach := s.EffectiveGroups(userID)
|
||||
best := PermNone
|
||||
for _, g := range s.Grants {
|
||||
if g.DatabaseID != dbID || !reach[g.GroupID] {
|
||||
continue
|
||||
}
|
||||
if g.Perm.Rank() > best.Rank() {
|
||||
best = g.Perm
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// Assignment pairs a database with the caller's permission on it.
|
||||
type Assignment struct {
|
||||
DB *Database
|
||||
Perm Perm
|
||||
}
|
||||
|
||||
// Assignments lists every database the user may see, strongest permission
|
||||
// first resolved, sorted by name for a stable client view.
|
||||
func (s *State) Assignments(userID string) []Assignment {
|
||||
out := []Assignment{}
|
||||
for id, db := range s.Databases {
|
||||
if p := s.DatabasePerm(userID, id); p != PermNone {
|
||||
out = append(out, Assignment{DB: db, Perm: p})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].DB.Name != out[j].DB.Name {
|
||||
return out[i].DB.Name < out[j].DB.Name
|
||||
}
|
||||
return out[i].DB.ID < out[j].DB.ID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Watchers lists the user IDs that must be told when a database changes.
|
||||
func (s *State) Watchers(dbID string) []string {
|
||||
out := []string{}
|
||||
for id := range s.Users {
|
||||
if s.DatabasePerm(id, dbID) != PermNone {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func contains(ss []string, s string) bool {
|
||||
for _, v := range ss {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
// tree: company > engineering > backend, company > sales
|
||||
func aclFixture() *State {
|
||||
s := newState()
|
||||
s.Groups["company"] = &Group{ID: "company", Name: "Company"}
|
||||
s.Groups["eng"] = &Group{ID: "eng", Name: "Engineering", ParentID: "company"}
|
||||
s.Groups["backend"] = &Group{ID: "backend", Name: "Backend", ParentID: "eng"}
|
||||
s.Groups["sales"] = &Group{ID: "sales", Name: "Sales", ParentID: "company"}
|
||||
s.Users["alice"] = &User{ID: "alice", Username: "alice"}
|
||||
s.Users["bob"] = &User{ID: "bob", Username: "bob"}
|
||||
s.Users["root"] = &User{ID: "root", Username: "root", ServerAdmin: true}
|
||||
s.Memberships = []Membership{
|
||||
{UserID: "alice", GroupID: "backend", Role: RoleMember},
|
||||
{UserID: "bob", GroupID: "sales", Role: RoleAdmin},
|
||||
}
|
||||
s.Databases["shared"] = &Database{ID: "shared", Name: "Shared", OwnerGroupID: "company"}
|
||||
s.Databases["salesdb"] = &Database{ID: "salesdb", Name: "Sales", OwnerGroupID: "sales"}
|
||||
s.Grants = []Grant{
|
||||
{GroupID: "company", DatabaseID: "shared", Perm: PermRead},
|
||||
{GroupID: "eng", DatabaseID: "shared", Perm: PermWrite},
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestGrantOnAncestorReachesDescendantMember(t *testing.T) {
|
||||
s := aclFixture()
|
||||
if got := s.DatabasePerm("alice", "shared"); got != PermWrite {
|
||||
t.Fatalf("alice on shared = %q, want write (read from company, write from eng)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantDoesNotLeakAcrossSiblings(t *testing.T) {
|
||||
s := aclFixture()
|
||||
if got := s.DatabasePerm("alice", "salesdb"); got != PermNone {
|
||||
t.Fatalf("alice on salesdb = %q, want none", got)
|
||||
}
|
||||
if got := s.DatabasePerm("bob", "shared"); got != PermRead {
|
||||
t.Fatalf("bob on shared = %q, want read", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerGroupAdminGetsAdmin(t *testing.T) {
|
||||
s := aclFixture()
|
||||
if got := s.DatabasePerm("bob", "salesdb"); got != PermAdmin {
|
||||
t.Fatalf("bob on own group db = %q, want admin", got)
|
||||
}
|
||||
if !s.IsGroupAdmin("root", "backend") {
|
||||
t.Fatal("server admin should administer any group")
|
||||
}
|
||||
if s.IsGroupAdmin("bob", "eng") {
|
||||
t.Fatal("sales admin must not administer engineering")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminOfParentAdministersChild(t *testing.T) {
|
||||
s := aclFixture()
|
||||
s.Memberships = append(s.Memberships, Membership{UserID: "alice", GroupID: "company", Role: RoleAdmin})
|
||||
if !s.IsGroupAdmin("alice", "backend") {
|
||||
t.Fatal("company admin should administer backend")
|
||||
}
|
||||
if got := s.DatabasePerm("alice", "salesdb"); got != PermAdmin {
|
||||
t.Fatalf("company admin on salesdb = %q, want admin", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignmentsAndWatchers(t *testing.T) {
|
||||
s := aclFixture()
|
||||
got := s.Assignments("alice")
|
||||
if len(got) != 1 || got[0].DB.ID != "shared" || got[0].Perm != PermWrite {
|
||||
t.Fatalf("alice assignments = %+v", got)
|
||||
}
|
||||
w := s.Watchers("shared")
|
||||
if len(w) != 3 {
|
||||
t.Fatalf("watchers of shared = %v, want alice, bob and root", w)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreeWalksSurviveCycle(t *testing.T) {
|
||||
s := aclFixture()
|
||||
s.Groups["company"].ParentID = "backend" // corrupt
|
||||
if len(s.Ancestors("backend")) != 3 {
|
||||
t.Fatalf("cycle walk = %v", s.Ancestors("backend"))
|
||||
}
|
||||
if len(s.Descendants("company")) == 0 {
|
||||
t.Fatal("descendants must terminate on a cycle")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// Package store holds the server's authoritative metadata: users, nested
|
||||
// groups, databases and the grants that connect them.
|
||||
//
|
||||
// Glossary:
|
||||
//
|
||||
// User an account, authenticated by password+TOTP or by OIDC.
|
||||
// Group a node in a single tree of groups. Grants hang off groups.
|
||||
// Membership a user's place in one group, as member or admin.
|
||||
// Database one KDBX file tracked by the server. Owned by one group.
|
||||
// Grant a (group, database, permission) triple.
|
||||
// Invite a single- or multi-use registration token.
|
||||
// Session a bearer token held by a client device.
|
||||
package store
|
||||
|
||||
import "time"
|
||||
|
||||
// Perm is a permission on a database, ordered read < write < admin.
|
||||
type Perm string
|
||||
|
||||
const (
|
||||
PermNone Perm = ""
|
||||
PermRead Perm = "read"
|
||||
PermWrite Perm = "write"
|
||||
PermAdmin Perm = "admin"
|
||||
)
|
||||
|
||||
var permRank = map[Perm]int{PermNone: 0, PermRead: 1, PermWrite: 2, PermAdmin: 3}
|
||||
|
||||
// Rank orders permissions so the strongest of several grants can be picked.
|
||||
func (p Perm) Rank() int { return permRank[p] }
|
||||
|
||||
// AtLeast reports whether p is as strong as want.
|
||||
func (p Perm) AtLeast(want Perm) bool { return p.Rank() >= want.Rank() }
|
||||
|
||||
// Valid reports whether p is a permission that may be stored.
|
||||
func (p Perm) Valid() bool { return p == PermRead || p == PermWrite || p == PermAdmin }
|
||||
|
||||
// Role is a user's role inside a group.
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleMember Role = "member"
|
||||
RoleAdmin Role = "admin"
|
||||
)
|
||||
|
||||
// Valid reports whether r is a role that may be stored.
|
||||
func (r Role) Valid() bool { return r == RoleMember || r == RoleAdmin }
|
||||
|
||||
// Strategy decides what happens when a client pushes a database version that
|
||||
// is no longer current.
|
||||
type Strategy string
|
||||
|
||||
const (
|
||||
// StrategyServerWins rejects the stale push; the client takes the server
|
||||
// copy and keeps its own as a conflict file. This is the default.
|
||||
StrategyServerWins Strategy = "server-wins"
|
||||
// StrategyClientWins lets the client overwrite the server copy.
|
||||
StrategyClientWins Strategy = "client-wins"
|
||||
// StrategyManual rejects the stale push and leaves both copies in place
|
||||
// for a human to reconcile.
|
||||
StrategyManual Strategy = "manual"
|
||||
)
|
||||
|
||||
// Valid reports whether s is a strategy that may be stored.
|
||||
func (s Strategy) Valid() bool {
|
||||
return s == StrategyServerWins || s == StrategyClientWins || s == StrategyManual
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email,omitempty"`
|
||||
PassHash string `json:"pass_hash,omitempty"`
|
||||
OIDCIssuer string `json:"oidc_issuer,omitempty"`
|
||||
OIDCSubject string `json:"oidc_subject,omitempty"`
|
||||
TOTPSecret string `json:"totp_secret,omitempty"`
|
||||
TOTPConfirmed bool `json:"totp_confirmed"`
|
||||
ServerAdmin bool `json:"server_admin"`
|
||||
Disabled bool `json:"disabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Group struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Membership struct {
|
||||
UserID string `json:"user_id"`
|
||||
GroupID string `json:"group_id"`
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
|
||||
type Database struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OwnerGroupID string `json:"owner_group_id"`
|
||||
MergeStrategy Strategy `json:"merge_strategy"`
|
||||
Version int64 `json:"version"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Size int64 `json:"size"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
UpdatedBy string `json:"updated_by,omitempty"`
|
||||
}
|
||||
|
||||
type Grant struct {
|
||||
GroupID string `json:"group_id"`
|
||||
DatabaseID string `json:"database_id"`
|
||||
Perm Perm `json:"perm"`
|
||||
}
|
||||
|
||||
type Invite struct {
|
||||
Token string `json:"token"`
|
||||
GroupID string `json:"group_id,omitempty"`
|
||||
Role Role `json:"role"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
MaxUses int `json:"max_uses"`
|
||||
Uses int `json:"uses"`
|
||||
}
|
||||
|
||||
// Usable reports whether the invite may still be redeemed at time now.
|
||||
func (i *Invite) Usable(now time.Time) bool {
|
||||
return now.Before(i.ExpiresAt) && (i.MaxUses <= 0 || i.Uses < i.MaxUses)
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
Token string `json:"token"`
|
||||
UserID string `json:"user_id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// DeviceAuth backs the browser-assisted login used for OIDC on desktop and
|
||||
// Android: the client shows a short user code, the browser approves it, the
|
||||
// client polls until a session token appears.
|
||||
type DeviceAuth struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
Label string `json:"label,omitempty"`
|
||||
SessionToken string `json:"session_token,omitempty"`
|
||||
OIDCState string `json:"oidc_state,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// State is the whole metadata set, serialized as one JSON document.
|
||||
type State struct {
|
||||
Users map[string]*User `json:"users"`
|
||||
Groups map[string]*Group `json:"groups"`
|
||||
Databases map[string]*Database `json:"databases"`
|
||||
Invites map[string]*Invite `json:"invites"`
|
||||
Sessions map[string]*Session `json:"sessions"`
|
||||
Devices map[string]*DeviceAuth `json:"devices"`
|
||||
Memberships []Membership `json:"memberships"`
|
||||
Grants []Grant `json:"grants"`
|
||||
}
|
||||
|
||||
func newState() *State {
|
||||
return &State{
|
||||
Users: map[string]*User{},
|
||||
Groups: map[string]*Group{},
|
||||
Databases: map[string]*Database{},
|
||||
Invites: map[string]*Invite{},
|
||||
Sessions: map[string]*Session{},
|
||||
Devices: map[string]*DeviceAuth{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) init() {
|
||||
if s.Users == nil {
|
||||
s.Users = map[string]*User{}
|
||||
}
|
||||
if s.Groups == nil {
|
||||
s.Groups = map[string]*Group{}
|
||||
}
|
||||
if s.Databases == nil {
|
||||
s.Databases = map[string]*Database{}
|
||||
}
|
||||
if s.Invites == nil {
|
||||
s.Invites = map[string]*Invite{}
|
||||
}
|
||||
if s.Sessions == nil {
|
||||
s.Sessions = map[string]*Session{}
|
||||
}
|
||||
if s.Devices == nil {
|
||||
s.Devices = map[string]*DeviceAuth{}
|
||||
}
|
||||
}
|
||||
|
||||
// UserByName finds a user by exact username.
|
||||
func (s *State) UserByName(name string) *User {
|
||||
for _, u := range s.Users {
|
||||
if u.Username == name {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserByOIDC finds a user previously linked to an OIDC issuer and subject.
|
||||
func (s *State) UserByOIDC(issuer, subject string) *User {
|
||||
for _, u := range s.Users {
|
||||
if u.OIDCIssuer == issuer && u.OIDCSubject == subject {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeviceByUserCode finds a pending device login by the code shown to the user.
|
||||
func (s *State) DeviceByUserCode(code string) *DeviceAuth {
|
||||
for _, d := range s.Devices {
|
||||
if d.UserCode == code {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Store is the single writer of the metadata document. Every mutation runs on
|
||||
// a private copy of the state and is swapped in only once it has reached the
|
||||
// disk, so a reader never observes a half-applied update and a crash never
|
||||
// leaves a partial file.
|
||||
type Store struct {
|
||||
path string
|
||||
mu sync.RWMutex
|
||||
s *State
|
||||
}
|
||||
|
||||
// Open loads the state document at path, creating an empty one if absent.
|
||||
func Open(path string) (*Store, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st := &Store{path: path, s: newState()}
|
||||
b, err := os.ReadFile(path)
|
||||
switch {
|
||||
case os.IsNotExist(err):
|
||||
return st, st.persist(st.s)
|
||||
case err != nil:
|
||||
return nil, err
|
||||
}
|
||||
s := newState()
|
||||
if err := json.Unmarshal(b, s); err != nil {
|
||||
return nil, fmt.Errorf("store: parse %s: %w", path, err)
|
||||
}
|
||||
s.init()
|
||||
st.s = s
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Read runs fn against a consistent view of the state. fn must not retain or
|
||||
// mutate anything it is given.
|
||||
func (st *Store) Read(fn func(*State)) {
|
||||
st.mu.RLock()
|
||||
defer st.mu.RUnlock()
|
||||
fn(st.s)
|
||||
}
|
||||
|
||||
// Update applies fn to a copy of the state and commits it. If fn or the write
|
||||
// fails, the stored state is left untouched.
|
||||
func (st *Store) Update(fn func(*State) error) error {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
next, err := clone(st.s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fn(next); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.persist(next); err != nil {
|
||||
return err
|
||||
}
|
||||
st.s = next
|
||||
return nil
|
||||
}
|
||||
|
||||
// GC drops expired sessions, device logins and invites.
|
||||
func (st *Store) GC(now time.Time) error {
|
||||
return st.Update(func(s *State) error {
|
||||
for k, v := range s.Sessions {
|
||||
if now.After(v.ExpiresAt) {
|
||||
delete(s.Sessions, k)
|
||||
}
|
||||
}
|
||||
for k, v := range s.Devices {
|
||||
if now.After(v.ExpiresAt) {
|
||||
delete(s.Devices, k)
|
||||
}
|
||||
}
|
||||
for k, v := range s.Invites {
|
||||
if !v.Usable(now) {
|
||||
delete(s.Invites, k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (st *Store) persist(s *State) error {
|
||||
b, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFileAtomic(st.path, b, 0o600)
|
||||
}
|
||||
|
||||
func clone(s *State) (*State, error) {
|
||||
b, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := newState()
|
||||
if err := json.Unmarshal(b, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.init()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// writeFileAtomic replaces path with data, surviving a crash mid-write.
|
||||
func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
|
||||
tmp := path + ".tmp"
|
||||
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
@@ -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