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") } }