oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3import (
4 "amadan.net/oficina/home/handoff/seal"
5 "amadan.net/oficina/home/roster"
6 family "amadan.net/rastrillo/rastrillo/crypto"
7 "amadan.net/rastrillo/rastrillo/migrate"
8 "amadan.net/rastrillo/rastrillo/sessions"
9 "bytes"
10 "context"
11 "encoding/base64"
12 "errors"
13 "github.com/carlosframework/docs/internal/docs/principalstore"
14 "github.com/carlosframework/docs/internal/docs/teamstate"
15 "gorm.io/gorm"
16 "io"
17 "net/http"
18 "strings"
19 "sync"
20 "testing"
21 "time"
22)
23
24func TestTeamPermissionFormerOwnerOnlyReachesExactGrant(t *testing.T) {
25 app := newSampleDocumentsInternalTestApp(t, "0")
26 s := app.Store()
27 ctx := context.Background()
28 owner, e := s.EnsurePerson(ctx, "former@example.com")
29 if e != nil {
30 t.Fatal(e)
31 }
32 other, e := s.EnsurePerson(ctx, "other@example.com")
33 if e != nil {
34 t.Fatal(e)
35 }
36 private, e := s.Create(ctx, owner, "formerly owned")
37 if e != nil {
38 t.Fatal(e)
39 }
40 shared, e := s.Create(ctx, other, "individual grant")
41 if e != nil {
42 t.Fatal(e)
43 }
44 stranger, e := s.Create(ctx, other, "other private")
45 if e != nil {
46 t.Fatal(e)
47 }
48 if e = s.Grant(ctx, other, shared.Sqid, "former@example.com", RoleWriter); e != nil {
49 t.Fatal(e)
50 }
51 s.team = &teamRuntime{}
52 actor := localActor(owner, "former@example.com")
53 for _, state := range []roster.State{roster.NotMember, roster.Unknown} {
54 a := &requestAccess{runtime: s.team, actor: actor, decision: roster.Decision{State: state}}
55 c := context.WithValue(ctx, accessContextKey{}, a)
56 for _, mode := range []permission{permissionRead, permissionWrite, permissionOwner} {
57 for _, doc := range []*Document{private, shared, stranger} {
58 var count int64
59 e := s.permissionScope(c, s.db.Model(&Document{}), actor, mode).Where("documents.id = ?", doc.ID).Count(&count).Error
60 if e != nil {
61 t.Fatal(e)
62 }
63 want := int64(0)
64 if doc == shared && mode != permissionOwner {
65 want = 1
66 }
67 if count != want {
68 t.Fatalf("state %v mode %v document %v: got %d want %d", state, mode, doc.ID, count, want)
69 }
70 }
71 }
72
73 for _, sqid := range []string{private.Sqid, stranger.Sqid, "nonexistent"} {
74 _, e := s.GetFor(c, actor, sqid)
75 if state == roster.Unknown && !errors.Is(e, errTeamUnavailable) {
76 t.Fatal("unknown item must be retryable", e)
77 }
78 if state == roster.NotMember && !errors.Is(e, gorm.ErrRecordNotFound) {
79 t.Fatal("removed owner reached private item", e)
80 }
81 }
82 got, e := s.ListFor(c, actor)
83 if e != nil || len(got) != 1 || got[0].ID != shared.ID {
84 t.Fatal("list escaped individual grant", e)
85 }
86 if _, e = s.GetFor(c, actor, shared.Sqid); e != nil {
87 t.Fatal("independent grant refused", e)
88 }
89 if _, e = s.Get(c, owner, private.Sqid); e == nil {
90 t.Fatal("owner administration escaped membership")
91 }
92
93 e = s.permissionError(c, actor, gorm.ErrRecordNotFound)
94 if state == roster.Unknown && !errors.Is(e, errTeamUnavailable) {
95 t.Fatal("uncertain absence was not retryable")
96 }
97 if state == roster.NotMember && !errors.Is(e, gorm.ErrRecordNotFound) {
98 t.Fatal("known refusal was not definite")
99 }
100 if e = s.requireMember(c, actor); e == nil {
101 t.Fatal("grant became membership")
102 }
103 }
104 for _, c := range []context.Context{ctx, context.WithValue(ctx, accessContextKey{}, &requestAccess{runtime: &teamRuntime{}, actor: actor})} {
105 var n int64
106 if e = s.permissionScope(c, s.db.Model(&Document{}), actor, permissionRead).Count(&n).Error; e != nil || n != 0 {
107 t.Fatal("missing or foreign access context was accepted")
108 }
109 }
110 c := context.WithValue(ctx, accessContextKey{}, &requestAccess{runtime: s.team, actor: actor, decision: roster.Decision{State: roster.NotMember}})
111 var n int64
112 if e = s.permissionScope(c, s.db.Model(&Document{}), localActor(other, "former@example.com"), permissionRead).Count(&n).Error; e != nil || n != 0 {
113 t.Fatal("actor mismatch accepted")
114 }
115}
116
117type teamTestClock struct {
118 next func()
119 mu sync.Mutex
120 elapsed time.Duration
121}
122
123func (c *teamTestClock) Now() (time.Time, time.Duration) {
124 c.mu.Lock()
125 defer c.mu.Unlock()
126 if c.next != nil {
127 fn := c.next
128 c.next = nil
129 fn()
130 }
131 return time.Unix(1800000000, 0).Add(c.elapsed), c.elapsed
132}
133func (c *teamTestClock) step(d time.Duration) { c.mu.Lock(); defer c.mu.Unlock(); c.elapsed += d }
134
135type teamTestTransport func(*http.Request) (*http.Response, error)
136
137func (f teamTestTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
138
139func testTeamRoster(t *testing.T, s *Store, app *App) (*teamRuntime, *teamTestClock, string) {
140 t.Helper()
141 op, e := family.Generate()
142 if e != nil {
143 t.Fatal(e)
144 }
145 key, e := family.Generate()
146 if e != nil {
147 t.Fatal(e)
148 }
149 fixed := func(n byte) string { return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{n}, 32)) }
150 b := roster.Bindings{Issuer: "https://id.example", Directory: "https://home.example", Consumer: "docs-A", App: "docs", Team: "TeamA", Audience: "https://docs.example"}
151 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())}}}
152 raw, e := roster.SignProvisioning(op, "operator1", p)
153 if e != nil {
154 t.Fatal(e)
155 }
156 record, e := roster.VerifyProvisioning(raw, []roster.Key{{ID: "operator1", Public: base64.RawURLEncoding.EncodeToString(op.SignPub())}}, b)
157 if e != nil {
158 t.Fatal(e)
159 }
160 sealer, e := seal.New(bytes.Repeat([]byte{9}, 32), "team-test")
161 if e != nil {
162 t.Fatal(e)
163 }
164 if _, e = migrate.Apply(t.Context(), app.db, TeamBootSchema); e != nil {
165 t.Fatal(e)
166 }
167 clock := &teamTestClock{}
168 subject := "pr_" + strings.Repeat("a", 26)
169 transport := teamTestTransport(func(r *http.Request) (*http.Response, error) {
170 _, elapsed := clock.Now()
171 if elapsed >= 30*time.Second {
172 return nil, errors.New("directory unavailable")
173 }
174 i, complete := 0, true
175 members := []roster.MemberEntry{{Subject: subject, Role: "member"}}
176 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}
177 raw, e := roster.SignPage(key, "roster1", page)
178 if e != nil {
179 return nil, e
180 }
181 return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(bytes.NewReader(raw))}, nil
182 })
183 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})
184 if e != nil {
185 t.Fatal(e)
186 }
187 ps, e := principalstore.New(app.db.Writer(), principalstore.Config{Issuer: b.Issuer, Team: b.Team})
188 if e != nil {
189 t.Fatal(e)
190 }
191 rt := &teamRuntime{cache: cache, bindings: b, db: app.db.Writer(), principals: ps}
192 s.team = rt
193 return rt, clock, subject
194}
195
196func TestTeamPermissionMemberLeaseEndsAtSixtySeconds(t *testing.T) {
197 app := newSampleDocumentsInternalTestApp(t, "0")
198 s := app.Store()
199 ctx := t.Context()
200 owner, e := s.EnsurePerson(ctx, "owner@example.com")
201 if e != nil {
202 t.Fatal(e)
203 }
204 doc, e := s.Create(ctx, owner, "owned")
205 if e != nil {
206 t.Fatal(e)
207 }
208 rt, clock, subject := testTeamRoster(t, s, app)
209 actor := localActor(owner, "owner@example.com")
210 access := rt.resolveAccess(ctx, requestIdentity{Actor: actor, HomeSubject: subject})
211 if access.state() != roster.Member {
212 t.Fatal("signed member refused")
213 }
214 ctx = context.WithValue(ctx, accessContextKey{}, access)
215 check := func(want int64) {
216 t.Helper()
217 var n int64
218 e := s.permissionScope(ctx, s.db.Model(&Document{}), actor, permissionOwner).Where("id = ?", doc.ID).Count(&n).Error
219 if e != nil || n != want {
220 t.Fatalf("owner access count %d want %d: %v", n, want, e)
221 }
222 }
223 check(1)
224 clock.step(30 * time.Second)
225 if e = rt.cache.Refresh(ctx); e == nil {
226 t.Fatal("failed refresh accepted")
227 }
228 check(1)
229 clock.step(30 * time.Second)
230 check(0)
231 if e = s.requireMember(ctx, actor); !errors.Is(e, errTeamUnavailable) {
232 t.Fatal("expired member edit must retry")
233 }
234}
235
236func TestTeamWriterGrantRevokedWhileWaitingForSQLiteWriter(t *testing.T) {
237 app := newSampleDocumentsInternalTestApp(t, "0")
238 s := app.Store()
239 ctx := t.Context()
240 owner, e := s.EnsurePerson(ctx, "owner@example.com")
241 if e != nil {
242 t.Fatal(e)
243 }
244 guest, e := s.EnsurePerson(ctx, "guest@example.com")
245 if e != nil {
246 t.Fatal(e)
247 }
248 doc, e := s.Create(ctx, owner, "shared")
249 if e != nil {
250 t.Fatal(e)
251 }
252 if e = s.Grant(ctx, owner, doc.Sqid, "guest@example.com", RoleWriter); e != nil {
253 t.Fatal(e)
254 }
255 s.team = &teamRuntime{}
256 actor := localActor(guest, "guest@example.com")
257 ctx = context.WithValue(ctx, accessContextKey{}, &requestAccess{runtime: s.team, actor: actor, decision: roster.Decision{State: roster.NotMember}})
258 writer := app.db.Writer()
259 tx, e := writer.BeginTx(ctx, nil)
260 if e != nil {
261 t.Fatal(e)
262 }
263 defer tx.Rollback()
264 before := writer.Stats().WaitCount
265 done := make(chan error, 1)
266 update := validUpdate(t)
267 go func() { _, e := s.ApplyUpdate(ctx, doc, actor, update); done <- e }()
268 deadline := time.Now().Add(3 * time.Second)
269 for writer.Stats().WaitCount == before && time.Now().Before(deadline) {
270 select {
271 case e := <-done:
272 t.Fatalf("update did not reach writer queue: %v", e)
273 default:
274 }
275 time.Sleep(time.Millisecond)
276 }
277 if writer.Stats().WaitCount == before {
278 t.Fatal("update did not wait for actual SQLite writer")
279 }
280 if _, e = tx.ExecContext(ctx, "DELETE FROM grants WHERE doc_id=? AND subject=?", doc.ID, "guest@example.com"); e != nil {
281 t.Fatal(e)
282 }
283 if e = tx.Commit(); e != nil {
284 t.Fatal(e)
285 }
286 select {
287 case e = <-done:
288 if !errors.Is(e, ErrNotWritable) {
289 t.Fatal("revoked writer did not receive definite refusal", e)
290 }
291 case <-time.After(3 * time.Second):
292 t.Fatal("writer deadlock")
293 }
294 var n int64
295 if e = s.db.Model(&DocUpdate{}).Where("doc_id=?", doc.ID).Count(&n).Error; e != nil || n != 0 {
296 t.Fatal("revoked write persisted", e)
297 }
298 var current Document
299 if e = s.db.First(&current, doc.ID).Error; e != nil || !current.UpdatedAt.Equal(doc.UpdatedAt) {
300 t.Fatal("revoked write changed timestamp", e)
301 }
302}
303
304func TestTeamLeaseExpiresAfterInsertBeforeCommit(t *testing.T) {
305 app := newSampleDocumentsInternalTestApp(t, "0")
306 s := app.Store()
307 ctx := t.Context()
308 owner, e := s.EnsurePerson(ctx, "owner@example.com")
309 if e != nil {
310 t.Fatal(e)
311 }
312 doc, e := s.Create(ctx, owner, "owned")
313 if e != nil {
314 t.Fatal(e)
315 }
316 rt, clock, subject := testTeamRoster(t, s, app)
317 actor := localActor(owner, "owner@example.com")
318 ctx = context.WithValue(ctx, accessContextKey{}, rt.resolveAccess(ctx, requestIdentity{Actor: actor, HomeSubject: subject}))
319 fired := false
320 if e = s.db.Callback().Create().After("gorm:create").Register("test:expire-team-lease", func(tx *gorm.DB) {
321 if tx.Statement.Table == "doc_updates" {
322 fired = true
323 clock.step(60 * time.Second)
324 }
325 }); e != nil {
326 t.Fatal(e)
327 }
328 defer s.db.Callback().Create().Remove("test:expire-team-lease")
329 seq, e := s.ApplyUpdate(ctx, doc, actor, validUpdate(t))
330 if !fired || seq != 0 || !errors.Is(e, errTeamUnavailable) {
331 t.Fatal("final expiry did not refuse commit", seq, e)
332 }
333 var n int64
334 if e = s.db.Model(&DocUpdate{}).Where("doc_id=?", doc.ID).Count(&n).Error; e != nil || n != 0 {
335 t.Fatal("expired update committed", e)
336 }
337 var current Document
338 if e = s.db.First(&current, doc.ID).Error; e != nil || !current.UpdatedAt.Equal(doc.UpdatedAt) {
339 t.Fatal("expired update changed timestamp", e)
340 }
341}
342
343func TestTeamMutationLeaseExpiryRollsBackEveryTable(t *testing.T) {
344 for _, table := range []string{"documents", "grants", "links", "doc_snapshots"} {
345 t.Run(table, func(t *testing.T) {
346 app := newSampleDocumentsInternalTestApp(t, "0")
347 s := app.Store()
348 ctx := t.Context()
349 owner, e := s.EnsurePerson(ctx, "owner@example.com")
350 if e != nil {
351 t.Fatal(e)
352 }
353 doc, e := s.Create(ctx, owner, "owned")
354 if e != nil {
355 t.Fatal(e)
356 }
357 rt, clock, subject := testTeamRoster(t, s, app)
358 actor := localActor(owner, "owner@example.com")
359 ctx = context.WithValue(ctx, accessContextKey{}, rt.resolveAccess(ctx, requestIdentity{Actor: actor, HomeSubject: subject}))
360 var before int64
361 if e = s.db.Table(table).Count(&before).Error; e != nil {
362 t.Fatal(e)
363 }
364 fired := false
365 if e = s.db.Callback().Create().After("gorm:create").Register("test:expire-admin", func(tx *gorm.DB) {
366 if tx.Statement.Table == table {
367 fired = true
368 clock.step(60 * time.Second)
369 }
370 }); e != nil {
371 t.Fatal(e)
372 }
373 defer s.db.Callback().Create().Remove("test:expire-admin")
374 switch table {
375 case "documents":
376 _, e = s.Create(ctx, owner, "refused")
377 case "grants":
378 e = s.Grant(ctx, owner, doc.Sqid, "guest@example.com", RoleReader)
379 case "links":
380 _, e = s.Publish(ctx, owner, doc.Sqid)
381 case "doc_snapshots":
382 h, seq, _, err := s.replay(ctx, doc)
383 if err != nil {
384 t.Fatal(err)
385 }
386 defer h.Close(ctx)
387 e = s.writeSnapshot(ctx, doc, h, seq)
388 }
389 if !fired || !errors.Is(e, errTeamUnavailable) {
390 t.Fatal("mutation survived final lease expiry", e)
391 }
392 var after int64
393 if e = s.db.Table(table).Count(&after).Error; e != nil || after != before {
394 t.Fatal("mutation committed after lease expiry", e)
395 }
396 })
397 }
398}
399
400func TestTeamPermissionContentionCannotRecoverIntoPermanentDenial(t *testing.T) {
401 f := newTeamRouterFixture(t)
402 b := f.browser()
403 b.localSignin(t, "alice@example.com")
404 b.linkProof(t, teamSubject('a'), "alice@example.com")
405 routerRedirect(t, b.do("POST", "/account/home/confirm", ""), "/")
406 session, ok := f.sessions.From(b.request("GET", "/", ""))
407 if !ok {
408 t.Fatal("session")
409 }
410 request := sessions.WithSession(b.request("GET", "/", ""), session)
411 id, e := f.app.store.team.bridge.resolver.resolve(request)
412 if e != nil {
413 t.Fatal(e)
414 }
415 a := f.app.store.team.resolveAccess(t.Context(), id)
416 if a.state() != roster.Member {
417 t.Fatal("member unavailable")
418 }
419 ctx := context.WithValue(t.Context(), accessContextKey{}, a)
420 entered, release, finished := make(chan struct{}), make(chan struct{}), make(chan struct{})
421 f.clock.mu.Lock()
422 f.clock.next = func() { close(entered); <-release }
423 f.clock.mu.Unlock()
424 go func() {
425 f.app.store.team.cache.Check(t.Context(), bridgeIssuer, "TeamA", teamSubject('a'))
426 close(finished)
427 }()
428 <-entered
429 // Cache.Check holds the real shared cache mutex while reading this clock.
430 scope := f.app.store.permissionScope(ctx, f.app.store.db.Model(&Document{}), id.Actor, permissionWrite)
431 close(release)
432 <-finished
433 if !a.decision.Lease.Valid() {
434 t.Fatal("fixture did not recover the same valid lease")
435 }
436 var found Document
437 e = scope.Where("id = ?", f.own.ID).First(&found).Error
438 if !errors.Is(e, gorm.ErrRecordNotFound) {
439 t.Fatal("contended ownership scope did not refuse", e)
440 }
441 if e = f.app.store.permissionError(ctx, id.Actor, e); !errors.Is(e, errTeamUnavailable) {
442 t.Fatal("transient ownership refusal became permanent after lease recovered", e)
443 }
444 if e = f.app.store.checkPermission(ctx, f.app.store.db, id.Actor, f.shared.ID, permissionWrite); e != nil {
445 t.Fatal("sticky uncertainty revoked an independent writer grant", e)
446 }
447 if a.state() != roster.Unknown {
448 t.Fatal("request regained uncertain ownership standing")
449 }
450 fresh := f.app.store.team.resolveAccess(t.Context(), id)
451 if fresh.state() != roster.Member {
452 t.Fatal("uncertainty leaked into a new request")
453 }
454}
455