package roster import ( "bytes" "context" "encoding/base64" "errors" "io" "net/http" "strings" "sync" "testing" "time" "amadan.net/oficina/home/handoff/seal" family "amadan.net/rastrillo/rastrillo/crypto" "github.com/go-json-experiment/json" ) type fakeClock struct { mu sync.Mutex wall time.Time mono time.Duration } func (f *fakeClock) Now() (time.Time, time.Duration) { f.mu.Lock() defer f.mu.Unlock() return f.wall, f.mono } func (f *fakeClock) step(d time.Duration) { f.mu.Lock() defer f.mu.Unlock() f.wall = f.wall.Add(d) f.mono += d } func (f *fakeClock) wallStep(d time.Duration) { f.mu.Lock() defer f.mu.Unlock() f.wall = f.wall.Add(d) } type memory struct { mu sync.Mutex raw []byte fail bool } func (m *memory) Load(context.Context) ([]byte, error) { m.mu.Lock() defer m.mu.Unlock() if m.raw == nil { return nil, ErrNoEvidence } return bytes.Clone(m.raw), nil } func (m *memory) Save(_ context.Context, b []byte) error { m.mu.Lock() defer m.mu.Unlock() if m.fail { return errors.New("secret-storage-error") } m.raw = bytes.Clone(b) return nil } func (m *memory) failWrites(v bool) { m.mu.Lock(); defer m.mu.Unlock(); m.fail = v } type transport func(*http.Request) (*http.Response, error) func (f transport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } func fixed(v byte) string { return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{v}, 32)) } func ptr[T any](v T) *T { return &v } func person(n int) string { s := []byte(strings.Repeat("a", 26)) for i := 25; n > 0; i-- { s[i] = "abcdefghijklmnopqrstuvwxyz"[n%26] n /= 26 } return "pr_" + string(s) } type fixture struct { t *testing.T op, key *family.Keypair record Provisioning verified VerifiedRecord cfg Config clock *fakeClock disk *memory mu sync.Mutex handler transport calls int } func setup(t *testing.T) *fixture { t.Helper() op, e := family.Generate() if e != nil { t.Fatal(e) } key, e := family.Generate() if e != nil { t.Fatal(e) } f := &fixture{t: t, op: op, key: key, clock: &fakeClock{wall: time.Unix(1800000000, 0)}, disk: &memory{}} f.record = Provisioning{Version: 1, Operation: "op1", Sequence: 1, Bindings: Bindings{Issuer: "https://id.example", Directory: "https://home.example", Consumer: "docs-A", App: "docs", Team: "TeamA", Audience: "https://docs.example"}, Generation: fixed(1), MinEpoch: 1, Keys: []Key{{"roster1", base64.RawURLEncoding.EncodeToString(key.SignPub())}}} f.verified = f.verify(f.record) s, e := seal.New(bytes.Repeat([]byte{9}, 32), "roster-test") if e != nil { t.Fatal(e) } f.cfg = Config{Record: f.verified, Token: fixed(7), Clock: f.clock, Durable: f.disk, Sealer: s} f.handler = func(r *http.Request) (*http.Response, error) { return f.response(r, 1, []MemberEntry{{person(1), "member"}}, true, ""), nil } f.cfg.Transport = transport(func(r *http.Request) (*http.Response, error) { if r.URL.Scheme != "https" || r.URL.Host != "home.example" || r.URL.Path != "/roster" || r.Header.Get("Authorization") != "Bearer "+fixed(7) || !ValidChallenge(r.URL.Query().Get("fetch")) { t.Error("outbound boundary") } f.mu.Lock() f.calls++ h := f.handler f.mu.Unlock() return h(r) }) return f } func (f *fixture) verify(p Provisioning) VerifiedRecord { f.t.Helper() raw, e := SignProvisioning(f.op, "operator", p) if e != nil { f.t.Fatal(e) } v, e := VerifyProvisioning(raw, []Key{{"operator", base64.RawURLEncoding.EncodeToString(f.op.SignPub())}}, f.record.Bindings) if e != nil { f.t.Fatal(e) } return v } func (f *fixture) cache() *Cache { f.t.Helper() c, e := New(context.Background(), f.cfg) if e != nil { f.t.Fatal(e) } return c } func (f *fixture) set(h transport) { f.mu.Lock(); defer f.mu.Unlock(); f.handler = h } func (f *fixture) page(r *http.Request, epoch int64, m []MemberEntry, complete bool, cursor string) Page { q := r.URL.Query() i := 0 if q.Get("cursor") != "" { i = 1 } p := Page{Version: 1, Type: "page", Directory: f.record.Bindings.Directory, Consumer: f.record.Bindings.Consumer, Team: f.record.Bindings.Team, Audience: f.record.Bindings.Audience, Fetch: q.Get("fetch"), RequestCursorHash: CursorHash(q.Get("cursor")), Generation: f.record.Generation, Epoch: epoch, Index: &i, Members: &m, Complete: &complete} if !complete { p.Cursor = &cursor } return p } func (f *fixture) signed(p Page, status int) *http.Response { f.t.Helper() b, e := SignPage(f.key, "roster1", p) if e != nil { f.t.Fatal(e) } return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(bytes.NewReader(b))} } func (f *fixture) response(r *http.Request, e int64, m []MemberEntry, complete bool, cursor string) *http.Response { return f.signed(f.page(r, e, m, complete, cursor), 200) } func (f *fixture) check(c *Cache, n int) Decision { return c.Check(context.Background(), f.record.Bindings.Issuer, f.record.Bindings.Team, person(n)) } func state(t *testing.T, d Decision, s State) { t.Helper() if d.State != s { t.Fatalf("state %d want %d", d.State, s) } } func TestPrivacyAndSixtySecondCeiling(t *testing.T) { f := setup(t) c := f.cache() d := f.check(c, 1) state(t, d, Member) raw, _ := f.disk.Load(context.Background()) for _, v := range []string{fixed(7), person(1), "member", "secret-storage-error"} { if bytes.Contains(raw, []byte(v)) { t.Fatal("readable durable data") } } f.set(func(*http.Request) (*http.Response, error) { return nil, errors.New(fixed(7) + person(1)) }) f.clock.step(30 * time.Second) if e := c.Refresh(context.Background()); e != ErrUnavailable { t.Fatalf("unsafe error %v", e) } state(t, f.check(c, 1), Member) f.clock.step(29 * time.Second) if !d.Lease.Valid() { t.Fatal("early expiry") } f.clock.step(time.Second) if d.Lease.Valid() { t.Fatal("standing survived ceiling") } state(t, f.check(c, 1), Unknown) } func TestNoPartialPublishOrAgeExtension(t *testing.T) { f := setup(t) c := f.cache() state(t, f.check(c, 1), Member) old, _ := f.disk.Load(context.Background()) batch := make([]MemberEntry, 100) for i := range batch { batch[i] = MemberEntry{person(i + 100), "member"} } f.set(func(r *http.Request) (*http.Response, error) { if r.URL.Query().Get("cursor") != "" { return nil, errors.New("partial") } return f.response(r, 2, batch, false, fixed(4)), nil }) f.clock.step(30 * time.Second) if c.Refresh(context.Background()) == nil { t.Fatal("partial accepted") } state(t, f.check(c, 100), NotMember) state(t, f.check(c, 1), Member) after, _ := f.disk.Load(context.Background()) if !bytes.Equal(old, after) { t.Fatal("partial persisted") } f.clock.step(30 * time.Second) state(t, f.check(c, 1), Unknown) } func TestEmptyRosterAndRestartEpochFloor(t *testing.T) { f := setup(t) f.set(func(r *http.Request) (*http.Response, error) { return f.response(r, 2, []MemberEntry{}, true, ""), nil }) c := f.cache() state(t, f.check(c, 1), NotMember) f.clock.step(30 * time.Second) f.set(func(r *http.Request) (*http.Response, error) { return f.response(r, 2, []MemberEntry{{person(1), "member"}}, true, ""), nil }) if c.Refresh(context.Background()) == nil { t.Fatal("changed empty epoch accepted") } state(t, f.check(c, 1), NotMember) c = f.cache() if c.Refresh(context.Background()) == nil { t.Fatal("restart forgot digest") } state(t, f.check(c, 1), Unknown) } func TestGenerationRequiresOperatorApproval(t *testing.T) { f := setup(t) c := f.cache() d := f.check(c, 1) f.clock.step(30 * time.Second) f.set(func(r *http.Request) (*http.Response, error) { p := f.page(r, 2, []MemberEntry{}, true, "") p.Generation = fixed(2) return f.signed(p, 200), nil }) if c.Refresh(context.Background()) == nil { t.Fatal("generation accepted") } if d.Lease.Valid() { t.Fatal("old lease survived") } f.set(func(r *http.Request) (*http.Response, error) { return f.response(r, 1, []MemberEntry{{person(1), "member"}}, true, ""), nil }) f.clock.step(5 * time.Second) state(t, f.check(c, 1), Unknown) c = f.cache() state(t, f.check(c, 1), Unknown) n := f.record n.Sequence = 2 n.Operation = "op2" n.Generation = fixed(2) if c.UpdateRecord(context.Background(), f.verify(n)) == nil { t.Fatal("missing confirmation accepted") } n.OwnerConfirmation = "owner-confirmation" if e := c.UpdateRecord(context.Background(), f.verify(n)); e != nil { t.Fatal(e) } f.set(func(r *http.Request) (*http.Response, error) { p := f.page(r, 1, []MemberEntry{{person(1), "owner"}}, true, "") p.Generation = fixed(2) return f.signed(p, 200), nil }) state(t, f.check(c, 1), Member) } func TestClockToleranceAndColdRestart(t *testing.T) { f := setup(t) c := f.cache() d := f.check(c, 1) f.clock.wallStep(-ClockTolerance) if !d.Lease.Valid() { t.Fatal("tolerated slew fenced") } f.clock.wallStep(-ClockTolerance - time.Nanosecond) if d.Lease.Valid() { t.Fatal("clock rollback missed") } f.clock.step(5 * time.Second) state(t, f.check(c, 1), Member) f.set(func(*http.Request) (*http.Response, error) { return nil, errors.New("offline") }) c = f.cache() state(t, f.check(c, 1), Unknown) } func TestRecordUpdateFencesStagingAndWriteFailure(t *testing.T) { f := setup(t) c := f.cache() d := f.check(c, 1) old, _ := f.disk.Load(context.Background()) n := f.record n.Sequence = 2 n.Operation = "op2" f.disk.failWrites(true) if e := c.UpdateRecord(context.Background(), f.verify(n)); e != ErrUnavailable { t.Fatalf("storage error %v", e) } if !d.Lease.Valid() { t.Fatal("failed write changed lease") } after, _ := f.disk.Load(context.Background()) if !bytes.Equal(old, after) { t.Fatal("failed write persisted") } f.disk.failWrites(false) entered := make(chan struct{}) release := make(chan struct{}) f.clock.step(30 * time.Second) f.set(func(r *http.Request) (*http.Response, error) { close(entered) <-release return f.response(r, 2, []MemberEntry{{person(2), "member"}}, true, ""), nil }) done := make(chan error) go func() { done <- c.Refresh(context.Background()) }() <-entered if e := c.UpdateRecord(context.Background(), f.verify(n)); e != nil { t.Fatal(e) } close(release) if <-done == nil { t.Fatal("old staging published") } if d.Lease.Valid() { t.Fatal("old lease survived") } if c.UpdateRecord(context.Background(), f.verified) == nil { t.Fatal("old record replay") } } func TestSingleFlight(t *testing.T) { f := setup(t) entered := make(chan struct{}) release := make(chan struct{}) var once sync.Once f.set(func(r *http.Request) (*http.Response, error) { once.Do(func() { close(entered) }) <-release return f.response(r, 1, []MemberEntry{{person(1), "member"}}, true, ""), nil }) c := f.cache() var wg sync.WaitGroup for i := 0; i < 24; i++ { wg.Add(1) go func() { defer wg.Done(); state(t, f.check(c, 1), Member) }() } <-entered close(release) wg.Wait() f.mu.Lock() defer f.mu.Unlock() if f.calls != 1 { t.Fatalf("calls %d", f.calls) } } func TestFetchDeadlineAndSaveFailure(t *testing.T) { for _, slow := range []bool{false, true} { t.Run(strings.Repeat("slow", map[bool]int{false: 0, true: 1}[slow])+"failure", func(t *testing.T) { f := setup(t) c := f.cache() f.set(func(r *http.Request) (*http.Response, error) { if slow { f.clock.step(FetchTimeout) } else { f.disk.failWrites(true) } return f.response(r, 2, []MemberEntry{{person(1), "member"}}, true, ""), nil }) if c.Refresh(context.Background()) == nil { t.Fatal("invalid refresh accepted") } state(t, f.check(c, 1), Unknown) }) } } func TestRefusalsNeverRenew(t *testing.T) { for _, name := range []string{"challenge", "cursor-hash", "team", "directory", "audience", "role", "subject", "index", "extra", "duplicate", "context", "signature", "oversize", "compression", "redirect", "304", "epoch"} { t.Run(name, func(t *testing.T) { f := setup(t) c := f.cache() state(t, f.check(c, 1), Member) f.clock.step(30 * time.Second) f.set(func(r *http.Request) (*http.Response, error) { p := f.page(r, 1, []MemberEntry{{person(1), "member"}}, true, "") switch name { case "challenge": p.Fetch = fixed(9) case "cursor-hash": p.RequestCursorHash = "" case "team": p.Team = "TeamB" case "directory": p.Directory = "https://foreign.example" case "audience": p.Audience = "https://foreign.example" case "role": (*p.Members)[0].Role = "admin" case "subject": (*p.Members)[0].Subject = "someone@example.test" case "index": p.Index = ptr(1) case "epoch": p.Epoch = 0 } res := f.signed(p, 200) switch name { case "extra", "duplicate": raw, _ := json.Marshal(p) tail := `,"extra":1}` if name == "duplicate" { tail = `,"epoch":1}` } raw = append(raw[:len(raw)-1], []byte(tail)...) sig, _ := family.Sign(f.key, PageContext, raw) b, _ := json.Marshal(envelope{1, "roster1", base64.RawURLEncoding.EncodeToString(raw), base64.RawURLEncoding.EncodeToString(sig)}) res.Body = io.NopCloser(bytes.NewReader(b)) case "context": b, _ := encode(f.key, "roster1", ProvisioningContext, p) res.Body = io.NopCloser(bytes.NewReader(b)) case "signature": b, _ := io.ReadAll(res.Body) var e envelope _ = json.Unmarshal(b, &e) e.Signature = base64.RawURLEncoding.EncodeToString(make([]byte, 64)) b, _ = json.Marshal(e) res.Body = io.NopCloser(bytes.NewReader(b)) case "oversize": res.Body = io.NopCloser(strings.NewReader(strings.Repeat("a", BodyLimit+1))) case "compression": res.Header.Set("Content-Encoding", "gzip") case "redirect": res.StatusCode = 302 res.Header.Set("Location", "https://foreign.example/roster") case "304": res.StatusCode = 304 } return res, nil }) if c.Refresh(context.Background()) == nil { t.Fatal("invalid response accepted") } f.clock.step(30 * time.Second) state(t, f.check(c, 1), Unknown) }) } }