977665d8c0
gates / gates (push) Successful in 27s
- internal/family: the family list (bcrypt, generated 4x4 passwords shown once) + 30-day sessions in family.json (0600, atomic); a reset (generation), a removal or a logout ends sessions at the next request. - internal/stacks/family_gate.go: family_gate / family_gate_except / min_controller in .felhom.yml; the door is written BEFORE the first start (install and a removed app's restore), a life record in app.yaml, reconciled by the gate loop; priority below the install hold, setup gate and sign-up block; every exception anchored ^/prefix(/|$) (finding F1). - internal/web/family_gate.go: forwardAuth /__felhom_gate/family (app cookie felhom_famgate, host-only, names a store session); /__family/start|login|logout on the dashboard host (session cookie felhom_family, Path=/__family); sign-in counted per visitor (clientIP) AND per name, short windows; the household's dashboard session vouches. RequireAuth never reads a family cookie. The "Család" card on the security page: add / new password / remove. Red-proofs RP-F1..RP-F7 (felhom.eu audits/family-gate-2026-10-02/A/). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
128 lines
3.5 KiB
Go
128 lines
3.5 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")
|
|
}
|
|
}
|