package store import ( "bytes" "context" "errors" "testing" "time" "cc.tiredsysadmin.keeporg/internal/secret" "cc.tiredsysadmin.keeporg/internal/sqldb" "cc.tiredsysadmin.keeporg/internal/sqldb/sqldbtest" ) var testKey, _ = secret.NewKeyring(bytes.Repeat([]byte{7}, 32)) // open opens a store on url, closing it when the test ends. func open(t *testing.T, url string) (*Store, *sqldb.DB) { t.Helper() db, err := sqldb.Open(context.Background(), url) if err != nil { t.Fatal(err) } st, err := Open(context.Background(), db, testKey) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close(); db.Close() }) return st, db } func TestUpdatePersistsAndSurvivesReopen(t *testing.T) { url := sqldbtest.URL(t) st, _ := open(t, url) if err := st.Update(func(s *State) error { s.Users["u1"] = &User{ID: "u1", Username: "alice"} s.Sessions["tok"] = &Session{Token: "tok", UserID: "u1"} s.Memberships = append(s.Memberships, Membership{UserID: "u1", GroupID: "g", Role: RoleAdmin}) return nil }); err != nil { t.Fatal(err) } again, _ := open(t, url) again.Read(func(s *State) { if s.Users["u1"] == nil || s.Users["u1"].Username != "alice" || s.Sessions["tok"] == nil || len(s.Memberships) != 1 || s.Memberships[0].Role != RoleAdmin { t.Fatalf("reopened state = %+v", s) } }) } func TestRowsAreSealedAndNamedOpaquely(t *testing.T) { st, db := open(t, sqldbtest.URL(t)) if err := st.Update(func(s *State) error { s.Users["u1"] = &User{ID: "u1", Username: "alice", Email: "alice@example.com"} s.Sessions["secret-session-token"] = &Session{Token: "secret-session-token", UserID: "u1"} return nil }); err != nil { t.Fatal(err) } rows, err := db.Query(`SELECT id, body FROM keeporg_records`) if err != nil { t.Fatal(err) } defer rows.Close() n := 0 for rows.Next() { var id string var body []byte if err := rows.Scan(&id, &body); err != nil { t.Fatal(err) } for _, plain := range []string{"alice", "example.com", "secret-session-token", "u1", "user"} { if bytes.Contains([]byte(id), []byte(plain)) || bytes.Contains(body, []byte(plain)) { t.Fatalf("row %s carries %q in the clear", id, plain) } } n++ } if n != 2 { t.Fatalf("rows = %d, want 2", n) } } func TestOnlyChangedRowsAreWritten(t *testing.T) { st, db := open(t, sqldbtest.URL(t)) _ = st.Update(func(s *State) error { s.Users["u1"] = &User{ID: "u1", Username: "alice"} s.Users["u2"] = &User{ID: "u2", Username: "bob"} return nil }) body := func() map[string]string { out := map[string]string{} rows, _ := db.Query(`SELECT id, body FROM keeporg_records`) defer rows.Close() for rows.Next() { var id string var b []byte rows.Scan(&id, &b) out[id] = string(b) } return out } before := body() gen, _ := db.Generation(context.Background(), db) _ = st.Update(func(s *State) error { s.Users["u2"].Email = "bob@example.com"; return nil }) after := body() changed := 0 for id := range before { if before[id] != after[id] { changed++ } } if changed != 1 { t.Fatalf("%d rows rewritten for one changed user", changed) } // A no-op update writes nothing and does not move the generation. _ = st.Update(func(s *State) error { return nil }) if g, _ := db.Generation(context.Background(), db); g != gen+1 { t.Fatalf("generation %d after one write from %d", g, gen) } } func TestFailedUpdateLeavesStateUntouched(t *testing.T) { st, _ := open(t, sqldbtest.URL(t)) 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) } } // Two processes on one database: a write by one is seen by the other, and a // write based on stale state is re-applied on top of the newer one instead // of overwriting it. func TestTwoStoresShareOneDatabase(t *testing.T) { url := sqldbtest.URL(t) a, _ := open(t, url) b, _ := open(t, url) _ = a.Update(func(s *State) error { s.Users["u1"] = &User{ID: "u1", Username: "alice"}; return nil }) if err := b.Update(func(s *State) error { s.Users["u2"] = &User{ID: "u2", Username: "bob"}; return nil }); err != nil { t.Fatal(err) } b.Read(func(s *State) { if s.Users["u1"] == nil || s.Users["u2"] == nil { t.Fatalf("b lost a's write: %v", s.Users) } }) // a learns of b's write without writing itself. deadline := time.Now().Add(3 * watchInterval) for { var ok bool a.Read(func(s *State) { ok = s.Users["u2"] != nil }) if ok { break } if time.Now().After(deadline) { t.Fatal("a never saw b's write") } time.Sleep(50 * time.Millisecond) } } func TestWrongKeyIsRefused(t *testing.T) { url := sqldbtest.URL(t) _, db := open(t, url) if err := db.CheckKey(context.Background(), testKey); err != nil { t.Fatal(err) } other, _ := secret.NewKeyring(bytes.Repeat([]byte{8}, 32)) if err := db.CheckKey(context.Background(), other); !errors.Is(err, sqldb.ErrWrongKey) { t.Fatalf("a different key was accepted: %v", err) } } func TestGCDropsExpired(t *testing.T) { st, _ := open(t, sqldbtest.URL(t)) 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) } }) }