| new file mode 100644 |
| index 0000000..b3fccf6 |
| --- /dev/null |
| +++ b/internal/docs/team_access_test.go |
| @@ -0,0 +1,201 @@ |
| +package docs |
| + |
| +import ( |
| + "amadan.net/oficina/home/handoff/seal" |
| + "amadan.net/oficina/home/roster" |
| + family "amadan.net/rastrillo/rastrillo/crypto" |
| + "bytes" |
| + "context" |
| + "encoding/base64" |
| + "errors" |
| + "github.com/carlosframework/docs/internal/docs/teamstate" |
| + "github.com/carlosframework/rastrillo/migrate" |
| + "gorm.io/gorm" |
| + "io" |
| + "net/http" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestTeamPermissionFormerOwnerOnlyReachesExactGrant(t *testing.T) { |
| + app := newSampleDocumentsInternalTestApp(t, "0") |
| + s := app.Store() |
| + ctx := context.Background() |
| + owner, e := s.EnsurePerson(ctx, "former@example.com") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + other, e := s.EnsurePerson(ctx, "other@example.com") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + private, e := s.Create(ctx, owner, "formerly owned") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + shared, e := s.Create(ctx, other, "individual grant") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + stranger, e := s.Create(ctx, other, "other private") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if e = s.Grant(ctx, other, shared.Sqid, "former@example.com", RoleWriter); e != nil { |
| + t.Fatal(e) |
| + } |
| + s.team = &teamRuntime{} |
| + actor := localActor(owner, "former@example.com") |
| + for _, state := range []roster.State{roster.NotMember, roster.Unknown} { |
| + a := &requestAccess{runtime: s.team, actor: actor, decision: roster.Decision{State: state}} |
| + c := context.WithValue(ctx, accessContextKey{}, a) |
| + for _, mode := range []permission{permissionRead, permissionWrite, permissionOwner} { |
| + for _, doc := range []*Document{private, shared, stranger} { |
| + var count int64 |
| + e := s.permissionScope(c, s.db.Model(&Document{}), actor, mode).Where("documents.id = ?", doc.ID).Count(&count).Error |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + want := int64(0) |
| + if doc == shared && mode != permissionOwner { |
| + want = 1 |
| + } |
| + if count != want { |
| + t.Fatalf("state %v mode %v document %v: got %d want %d", state, mode, doc.ID, count, want) |
| + } |
| + } |
| + } |
| + e := s.permissionError(c, actor, gorm.ErrRecordNotFound) |
| + if state == roster.Unknown && !errors.Is(e, errTeamUnavailable) { |
| + t.Fatal("uncertain absence was not retryable") |
| + } |
| + if state == roster.NotMember && !errors.Is(e, gorm.ErrRecordNotFound) { |
| + t.Fatal("known refusal was not definite") |
| + } |
| + if e = s.requireMember(c, actor); e == nil { |
| + t.Fatal("grant became membership") |
| + } |
| + } |
| + for _, c := range []context.Context{ctx, context.WithValue(ctx, accessContextKey{}, &requestAccess{runtime: &teamRuntime{}, actor: actor})} { |
| + var n int64 |
| + if e = s.permissionScope(c, s.db.Model(&Document{}), actor, permissionRead).Count(&n).Error; e != nil || n != 0 { |
| + t.Fatal("missing or foreign access context was accepted") |
| + } |
| + } |
| + c := context.WithValue(ctx, accessContextKey{}, &requestAccess{runtime: s.team, actor: actor, decision: roster.Decision{State: roster.NotMember}}) |
| + var n int64 |
| + if e = s.permissionScope(c, s.db.Model(&Document{}), localActor(other, "former@example.com"), permissionRead).Count(&n).Error; e != nil || n != 0 { |
| + t.Fatal("actor mismatch accepted") |
| + } |
| +} |
| + |
| +type teamTestClock struct { |
| + mu sync.Mutex |
| + elapsed time.Duration |
| +} |
| + |
| +func (c *teamTestClock) Now() (time.Time, time.Duration) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + return time.Unix(1800000000, 0).Add(c.elapsed), c.elapsed |
| +} |
| +func (c *teamTestClock) step(d time.Duration) { c.mu.Lock(); defer c.mu.Unlock(); c.elapsed += d } |
| + |
| +type teamTestTransport func(*http.Request) (*http.Response, error) |
| + |
| +func (f teamTestTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } |
| + |
| +func testTeamRoster(t *testing.T, s *Store, app *App) (*teamRuntime, *teamTestClock, string) { |
| + t.Helper() |
| + op, e := family.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + key, e := family.Generate() |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + fixed := func(n byte) string { return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{n}, 32)) } |
| + b := roster.Bindings{Issuer: "https://id.example", Directory: "https://home.example", Consumer: "docs-A", App: "docs", Team: "TeamA", Audience: "https://docs.example"} |
| + p := roster.Provisioning{Version: 1, Operation: "provision1", Sequence: 1, Bindings: b, Generation: fixed(1), MinEpoch: 1, Keys: []roster.Key{{ID: "roster1", Public: base64.RawURLEncoding.EncodeToString(key.SignPub())}}} |
| + raw, e := roster.SignProvisioning(op, "operator1", p) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + record, e := roster.VerifyProvisioning(raw, []roster.Key{{ID: "operator1", Public: base64.RawURLEncoding.EncodeToString(op.SignPub())}}, b) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + sealer, e := seal.New(bytes.Repeat([]byte{9}, 32), "team-test") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + if _, e = migrate.Apply(t.Context(), app.db, TeamBootSchema); e != nil { |
| + t.Fatal(e) |
| + } |
| + clock := &teamTestClock{} |
| + subject := "pr_" + strings.Repeat("a", 26) |
| + transport := teamTestTransport(func(r *http.Request) (*http.Response, error) { |
| + _, elapsed := clock.Now() |
| + if elapsed >= 30*time.Second { |
| + return nil, errors.New("directory unavailable") |
| + } |
| + i, complete := 0, true |
| + members := []roster.MemberEntry{{Subject: subject, Role: "member"}} |
| + page := roster.Page{Version: 1, Type: "page", Directory: b.Directory, Consumer: b.Consumer, Team: b.Team, Audience: b.Audience, Fetch: r.URL.Query().Get("fetch"), RequestCursorHash: roster.CursorHash(r.URL.Query().Get("cursor")), Generation: p.Generation, Epoch: 1, Index: &i, Members: &members, Complete: &complete} |
| + raw, e := roster.SignPage(key, "roster1", page) |
| + if e != nil { |
| + return nil, e |
| + } |
| + return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(bytes.NewReader(raw))}, nil |
| + }) |
| + cache, e := roster.New(t.Context(), roster.Config{Record: record, Token: fixed(7), Clock: clock, Sealer: sealer, Durable: teamstate.Evidence{DB: app.db.Writer()}, Transport: transport}) |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + rt := &teamRuntime{cache: cache, bindings: b, db: app.db.Writer()} |
| + s.team = rt |
| + return rt, clock, subject |
| +} |
| + |
| +func TestTeamPermissionMemberLeaseEndsAtSixtySeconds(t *testing.T) { |
| + app := newSampleDocumentsInternalTestApp(t, "0") |
| + s := app.Store() |
| + ctx := t.Context() |
| + owner, e := s.EnsurePerson(ctx, "owner@example.com") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + doc, e := s.Create(ctx, owner, "owned") |
| + if e != nil { |
| + t.Fatal(e) |
| + } |
| + rt, clock, subject := testTeamRoster(t, s, app) |
| + actor := localActor(owner, "owner@example.com") |
| + access := rt.resolveAccess(ctx, requestIdentity{Actor: actor, HomeSubject: subject}) |
| + if access.state() != roster.Member { |
| + t.Fatal("signed member refused") |
| + } |
| + ctx = context.WithValue(ctx, accessContextKey{}, access) |
| + check := func(want int64) { |
| + t.Helper() |
| + var n int64 |
| + e := s.permissionScope(ctx, s.db.Model(&Document{}), actor, permissionOwner).Where("id = ?", doc.ID).Count(&n).Error |
| + if e != nil || n != want { |
| + t.Fatalf("owner access count %d want %d: %v", n, want, e) |
| + } |
| + } |
| + check(1) |
| + clock.step(30 * time.Second) |
| + if e = rt.cache.Refresh(ctx); e == nil { |
| + t.Fatal("failed refresh accepted") |
| + } |
| + check(1) |
| + clock.step(30 * time.Second) |
| + check(0) |
| + if e = s.requireMember(ctx, actor); !errors.Is(e, errTeamUnavailable) { |
| + t.Fatal("expired member edit must retry") |
| + } |
| +} |