Files
felhom-controller/controller/internal/family/family_test.go
T
admin 94f44931dd
gates / gates (push) Successful in 45s
v0.325.0: share + escrow limiters reserve first; family sessions as fingerprints; samba renames apply; path guard widened; gates in a worktree
R-1111 reserveAttempt is the one limiter body (share password, escrow re-auth, dashboard sign-in). R-1112
family.json holds fingerprints; old files are converted at load. R-1113 the household's pass reuses its
family session. R-1109 the samba compose carries the smb.conf hash. R-1110 TestNoBareDrivePathToPathHelpers.
R-1114 shared_scripts.py resolves the felhom.eu sibling from a worktree. Each red-proved.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 09:36:34 +02:00

149 lines
4.3 KiB
Go

package family
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
)
func testStore(t *testing.T) (*Store, string) {
t.Helper()
dir := t.TempDir()
s, err := Open(dir)
if err != nil {
t.Fatal(err)
}
s.SetCost(bcrypt.MinCost)
return s, dir
}
// A member signs in with their own password; the password is never stored; the list survives a reopen.
func TestFamily_AddVerifyPersist(t *testing.T) {
s, dir := testStore(t)
pw, err := s.Add("anna")
if err != nil || len(pw) != 19 {
t.Fatalf("add: %v %q", err, pw)
}
if !s.Verify("anna", pw) || s.Verify("anna", pw+"x") || s.Verify("bela", pw) {
t.Fatal("verify wrong")
}
b, _ := os.ReadFile(filepath.Join(dir, "family.json"))
if strings.Contains(string(b), pw) {
t.Fatal("the plain password reached family.json")
}
if st, _ := os.Stat(filepath.Join(dir, "family.json")); st.Mode().Perm() != 0o600 {
t.Fatalf("family.json mode %v", st.Mode().Perm())
}
s2, err := Open(dir)
if err != nil || !s2.Verify("anna", pw) {
t.Fatalf("the list did not survive a reopen: %v", err)
}
if _, err := s.Add("anna"); err != ErrExists {
t.Fatalf("duplicate: %v", err)
}
for _, bad := range []string{"", "Anna", "a b", "../x", strings.Repeat("a", 33), "-x"} {
if _, err := s.Add(bad); err != ErrBadName {
t.Fatalf("name %q accepted", bad)
}
}
}
// THE CONSEQUENCE: a reset or a removal ends the member's sessions at once; a logout ends one session; an expired
// session is refused; another member's session is untouched.
func TestFamily_SessionsEndOnResetRemoveLogout(t *testing.T) {
s, _ := testStore(t)
now := time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC)
s.SetClock(func() time.Time { return now })
s.Add("anna")
s.Add("bela")
a1, _ := s.NewSession("anna")
a2, _ := s.NewSession("anna")
b1, _ := s.NewSession("bela")
h1, _ := s.NewSession("")
for _, id := range []string{a1, a2, b1, h1} {
if _, ok := s.Valid(id); !ok {
t.Fatalf("fresh session %s refused", id)
}
}
if _, err := s.Reset("anna"); err != nil {
t.Fatal(err)
}
if _, ok := s.Valid(a1); ok {
t.Fatal("a reset password must end the member's earlier sessions")
}
if _, ok := s.Valid(b1); !ok {
t.Fatal("another member's session must survive")
}
s.EndSession(b1)
if _, ok := s.Valid(b1); ok {
t.Fatal("logout must end the session")
}
b2, _ := s.NewSession("bela")
s.Remove("bela")
if _, ok := s.Valid(b2); ok {
t.Fatal("a removed member's session must end")
}
if _, err := s.NewSession("bela"); err != ErrNoMember {
t.Fatal("no session for a removed member")
}
now = now.Add(SessionLife + time.Minute)
if _, ok := s.Valid(h1); ok {
t.Fatal("an expired session must be refused")
}
}
// A gen bump survives a reopen: an OLD session read back from disk after a reset is still refused.
func TestFamily_ResetHoldsAcrossReopen(t *testing.T) {
s, dir := testStore(t)
s.Add("anna")
id, _ := s.NewSession("anna")
s.Reset("anna")
s2, _ := Open(dir)
if _, ok := s2.Valid(id); ok {
t.Fatal("after a reopen the pre-reset session must still be refused")
}
}
func TestFamily_UnreadableFileIsAnError(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "family.json"), []byte("{not json"), 0o600)
if _, err := Open(dir); err == nil {
t.Fatal("an unreadable list must be an error, never an empty list")
}
}
func TestFamily_NilStoreAnswersNobody(t *testing.T) {
var s *Store
if s.Verify("a", "b") || len(s.Names()) != 0 {
t.Fatal("nil store")
}
if _, ok := s.Valid("x"); ok {
t.Fatal("nil store valid")
}
}
// R-1112: a file written before fingerprints that cannot be rewritten is an error, never a store that carries on with
// the session values left on disk.
func TestFamily_OldFileThatCannotBeRewrittenIsAnError(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root writes into a read-only directory")
}
dir := t.TempDir()
exp := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
old := `{"members":[],"sessions":[{"id":"a1b2c3","member":"","gen":0,"exp":"` + exp + `"}]}`
os.WriteFile(filepath.Join(dir, "family.json"), []byte(old), 0o600)
os.Chmod(dir, 0o500)
defer os.Chmod(dir, 0o700)
if _, err := Open(dir); err == nil {
t.Fatal("an old file that could not be rewritten opened as a store")
}
b, _ := os.ReadFile(filepath.Join(dir, "family.json"))
if string(b) != old {
t.Fatal("the failed rewrite changed the file")
}
}