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 "bytes"
5 "context"
6 "crypto/sha256"
7 "encoding/base64"
8 "encoding/hex"
9 "encoding/json"
10 "errors"
11 "io"
12 "log/slog"
13 "net/http"
14 "net/url"
15 "path/filepath"
16 "strings"
17 "sync"
18 "testing"
19 "time"
20
21 "amadan.net/oficina/home/roster"
22 family "amadan.net/rastrillo/rastrillo/crypto"
23 "amadan.net/rastrillo/rastrillo/sessions"
24 "github.com/carlosframework/docs/internal/docs/principalstore"
25)
26
27type teamRouterDirectory struct {
28 mu sync.Mutex
29 key *family.Keypair
30 bindings roster.Bindings
31 generation string
32 epoch int64
33 members []roster.MemberEntry
34 fail bool
35}
36
37func (d *teamRouterDirectory) RoundTrip(r *http.Request) (*http.Response, error) {
38 d.mu.Lock()
39 defer d.mu.Unlock()
40 if d.fail {
41 return nil, errors.New("directory unavailable")
42 }
43 q := r.URL.Query()
44 if r.URL.Scheme+"://"+r.URL.Host != d.bindings.Directory || r.URL.Path != "/roster" || q.Get("team") != d.bindings.Team || !roster.ValidChallenge(q.Get("fetch")) || q.Get("cursor") != "" || r.Header.Get("Authorization") != "Bearer "+base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32)) {
45 return nil, errors.New("wrong roster request scope")
46 }
47 index, complete := 0, true
48 members := append([]roster.MemberEntry{}, d.members...)
49 p := roster.Page{Version: 1, Type: "page", Directory: d.bindings.Directory, Consumer: d.bindings.Consumer, Team: d.bindings.Team, Audience: d.bindings.Audience, Fetch: q.Get("fetch"), RequestCursorHash: roster.CursorHash(""), Generation: d.generation, Epoch: d.epoch, Index: &index, Members: &members, Complete: &complete}
50 raw, e := roster.SignPage(d.key, "roster1", p)
51 if e != nil {
52 return nil, e
53 }
54 return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(bytes.NewReader(raw))}, nil
55}
56func (d *teamRouterDirectory) change(members []roster.MemberEntry, fail bool) {
57 d.mu.Lock()
58 defer d.mu.Unlock()
59 d.members = members
60 d.epoch++
61 d.fail = fail
62}
63
64type teamRouterFixture struct {
65 *routerHomeFixture
66 directory *teamRouterDirectory
67 clock *teamTestClock
68 cfg Config
69 alice, bob int64
70 own, shared, hidden *Document
71}
72
73func newTeamRouterFixture(t *testing.T) *teamRouterFixture {
74 t.Helper()
75 ctx := t.Context()
76 credential, e := family.Generate()
77 if e != nil {
78 t.Fatal(e)
79 }
80 digest := sha256.Sum256(credential.SignPub())
81 kid := hex.EncodeToString(digest[:])
82 manifest, _ := json.Marshal(map[string]string{kid: base64.StdEncoding.EncodeToString(credential.SignPub())})
83 home := HomeConfig{Issuer: bridgeIssuer, ConsumerID: "router-docs", KeysBase64: base64.StdEncoding.EncodeToString(manifest), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{83}, 32))}
84 cfg := Config{DBPath: filepath.Join(t.TempDir(), "team.db"), Env: "development", Origin: bridgeOrigin, Home: &home, AllowedAddresses: "alice@example.com,bob@example.com", SampleDocuments: "0", Mailer: navigationMail{}, Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
85 app, e := New(cfg)
86 if e != nil {
87 t.Fatal(e)
88 }
89 f := &teamRouterFixture{cfg: cfg, clock: &teamTestClock{}}
90 f.alice, e = app.store.EnsurePerson(ctx, "alice@example.com")
91 if e != nil {
92 t.Fatal(e)
93 }
94 f.bob, e = app.store.EnsurePerson(ctx, "bob@example.com")
95 if e != nil {
96 t.Fatal(e)
97 }
98 f.own, e = app.store.Create(ctx, f.alice, "Alice original")
99 if e != nil {
100 t.Fatal(e)
101 }
102 f.shared, e = app.store.Create(ctx, f.bob, "Bob shared")
103 if e != nil {
104 t.Fatal(e)
105 }
106 f.hidden, e = app.store.Create(ctx, f.bob, "Bob private")
107 if e != nil {
108 t.Fatal(e)
109 }
110 if e = app.store.Grant(ctx, f.bob, f.shared.Sqid, "alice@example.com", RoleWriter); e != nil {
111 t.Fatal(e)
112 }
113 for _, d := range []*Document{f.own, f.shared, f.hidden} {
114 address := "alice@example.com"
115 if d.OwnerID == f.bob {
116 address = "bob@example.com"
117 }
118 if _, e = app.store.ApplyUpdate(ctx, d, localActor(d.OwnerID, address), validUpdate(t)); e != nil {
119 t.Fatal(e)
120 }
121 }
122 if e = app.Close(); e != nil {
123 t.Fatal(e)
124 }
125 operator, e := family.Generate()
126 if e != nil {
127 t.Fatal(e)
128 }
129 signer, e := family.Generate()
130 if e != nil {
131 t.Fatal(e)
132 }
133 fixed := func(v byte) string { return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{v}, 32)) }
134 binding := roster.Bindings{Issuer: bridgeIssuer, Directory: "https://home.example", Consumer: "router-docs", App: "docs", Team: "TeamA", Audience: bridgeOrigin}
135 directory := &teamRouterDirectory{key: signer, bindings: binding, generation: fixed(1), epoch: 1, members: []roster.MemberEntry{{Subject: teamSubject('a'), Role: "member"}, {Subject: teamSubject('b'), Role: "member"}, {Subject: teamSubject('c'), Role: "member"}}}
136 record := roster.Provisioning{Version: 1, Operation: "team-provision", Sequence: 1, Bindings: binding, Generation: directory.generation, MinEpoch: 1, Keys: []roster.Key{{ID: "roster1", Public: base64.RawURLEncoding.EncodeToString(signer.SignPub())}}}
137 wire, e := roster.SignProvisioning(operator, "operator1", record)
138 if e != nil {
139 t.Fatal(e)
140 }
141 keys, _ := json.Marshal([]roster.Key{{ID: "operator1", Public: base64.RawURLEncoding.EncodeToString(operator.SignPub())}})
142 cfg.Team = &TeamConfig{TeamID: binding.Team, DirectoryOrigin: binding.Directory, ProvisioningBase64: base64.StdEncoding.EncodeToString(wire), OperatorKeysBase64: base64.StdEncoding.EncodeToString(keys), RosterToken: fixed(7), clock: f.clock, transport: directory}
143 app, e = New(cfg)
144 if e != nil {
145 t.Fatal(e)
146 }
147 t.Cleanup(func() {
148 if e := app.Close(); e != nil {
149 t.Error(e)
150 }
151 })
152 sessionStore, e := sessions.New(sessions.Config{DB: app.db.Writer(), Origin: bridgeOrigin})
153 if e != nil {
154 t.Fatal(e)
155 }
156 f.routerHomeFixture = &routerHomeFixture{app: app, key: credential, keyID: kid, sessions: sessionStore}
157 f.directory = directory
158 f.cfg = cfg
159 return f
160}
161func teamSubject(c byte) string { return "pr_" + strings.Repeat(string(c), 26) }
162func (b *routerBrowser) update(doc *Document, body []byte) int {
163 r := b.request("POST", "/d/"+doc.Sqid+"/updates", string(body))
164 r.Header.Set("Content-Type", "application/octet-stream")
165 return b.serve(r).Code
166}
167func TestTeamRouterLocalLinkMembershipRemovalAndIndependentGrant(t *testing.T) {
168 f := newTeamRouterFixture(t)
169 b := f.browser()
170 b.localSignin(t, "alice@example.com")
171 if r := b.do("GET", "/d/"+f.own.Sqid, ""); r.Code != 404 {
172 t.Fatal("email admission became team standing", r.Code)
173 }
174 if r := b.do("GET", "/d/"+f.shared.Sqid, ""); r.Code != 200 {
175 t.Fatal("unlinked guest lost exact grant", r.Code)
176 }
177 cookie := b.cookies[f.sessions.CookieName()].Value
178 b.linkProof(t, teamSubject('a'), "alice@example.com")
179 routerRedirect(t, b.do("POST", "/account/home/confirm", ""), "/")
180 if b.cookies[f.sessions.CookieName()].Value != cookie {
181 t.Fatal("link rotated local session")
182 }
183 for _, d := range []*Document{f.own, f.shared} {
184 if r := b.do("GET", "/d/"+d.Sqid, ""); r.Code != 200 {
185 t.Fatal("linked member lost access", r.Code)
186 }
187 }
188 if r := b.do("GET", "/d/"+f.hidden.Sqid, ""); r.Code != 404 {
189 t.Fatal("membership opened another owner private document", r.Code)
190 }
191 if r := b.do("GET", "/d/"+f.shared.Sqid+"/share", ""); r.Code != 404 {
192 t.Fatal("writer grant became owner administration", r.Code)
193 }
194 if r := b.do("GET", "/d/"+f.own.Sqid+"/share", ""); r.Code != 200 {
195 t.Fatal("member owner cannot administer", r.Code)
196 }
197 f.clock.step(roster.RetryAfter)
198 f.directory.change([]roster.MemberEntry{{Subject: teamSubject('b'), Role: "member"}, {Subject: teamSubject('c'), Role: "member"}}, false)
199 if e := f.app.store.team.cache.Refresh(t.Context()); e != nil {
200 t.Fatal(e)
201 }
202 for _, path := range []string{"/d/" + f.own.Sqid, "/d/nonexistent", "/d/" + f.own.Sqid + "/share"} {
203 if r := b.do("GET", path, ""); r.Code != 404 {
204 t.Fatal("removed owner retained item access", path, r.Code)
205 }
206 }
207 if r := b.do("POST", "/documents", url.Values{"title": {"refused"}}.Encode()); r.Code != 403 {
208 t.Fatal("removed member created document", r.Code)
209 }
210 if code := b.update(f.own, validUpdate(t)); code != 404 {
211 t.Fatal("removed owner update not definitely refused", code)
212 }
213 if code := b.update(f.shared, validUpdate(t)); code != 204 {
214 t.Fatal("independent writer grant lost after membership removal", code)
215 }
216 f.directory.change(nil, true)
217 f.clock.step(60 * time.Second)
218 if e := f.app.store.team.cache.Refresh(t.Context()); e == nil {
219 t.Fatal("directory failure accepted")
220 }
221 for _, path := range []string{"/d/" + f.own.Sqid + "/share", "/d/nonexistent/share"} {
222 if r := b.do("GET", path, ""); r.Code != 503 {
223 t.Fatal("unknown owner administration became permanent or leaked existence", r.Code)
224 }
225 }
226 missing := b.do("GET", "/d/nonexistent", "")
227 private := b.do("GET", "/d/"+f.own.Sqid, "")
228 if missing.Code != 503 || private.Code != 503 || missing.Body.String() != private.Body.String() {
229 t.Fatal("unknown standing leaked existence or became permanent refusal")
230 }
231 if r := b.do("GET", "/d/"+f.shared.Sqid, ""); r.Code != 200 {
232 t.Fatal("grant unavailable during roster failure", r.Code)
233 }
234 if r := b.do("GET", "/", ""); r.Code != 200 || !strings.Contains(r.Body.String(), `rst-tone="warning"`) || !strings.Contains(r.Body.String(), `<p>docs.team.partial</p>`) || strings.Contains(r.Body.String(), "Alice original") {
235 t.Fatal("partial list misrepresented unavailable owned documents", r.Code)
236 }
237 if r := b.do("GET", "/documents/search?q=shared", ""); r.Code != 200 || r.Header().Get("Docs-Catalog-Partial") != "1" {
238 t.Fatal("partial search lost status", r.Code)
239 }
240 if code := b.update(f.own, validUpdate(t)); code != 503 {
241 t.Fatal("uncertain update became permanent failure", code)
242 }
243 if code := b.update(f.shared, validUpdate(t)); code != 204 {
244 t.Fatal("writer grant depended on roster availability", code)
245 }
246 signoutChained(t, b.do("POST", "/signout", ""))
247 nonce := b.start(t, "/auth/start?return_to=%2F", "")
248 authTime := time.Now().Add(-2 * time.Hour).Truncate(time.Second)
249 routerRedirect(t, b.exchange(t, nonce, teamSubject('a'), authTime), "/")
250 session, ok := f.sessions.From(b.request("GET", "/", ""))
251 if !ok || session.Method != homeAssertionMethod || !session.AuthTime.Equal(authTime) {
252 t.Fatal("ordinary Home sign-in changed authentication time")
253 }
254 if r := b.do("GET", "/d/"+f.shared.Sqid, ""); r.Code != 200 {
255 t.Fatal("linked Home identity lost current verified grant address", r.Code)
256 }
257 p, e := f.app.store.team.principals.Resolve(context.Background(), bridgeIssuer, teamSubject('a'))
258 if e != nil || p.ID != f.alice || p.Kind != principalstore.Local {
259 t.Fatal("existing local ownership identity changed", e)
260 }
261}
262
263func TestTeamRouterHomeOnlyFirstEntryAndConflict(t *testing.T) {
264 f := newTeamRouterFixture(t)
265 b := f.browser()
266 nonce := b.start(t, "/auth/start?return_to=%2F", "")
267 routerRedirect(t, b.exchange(t, nonce, teamSubject('b'), time.Now().Add(-time.Hour)), "/")
268 p, e := f.app.store.team.principals.Resolve(t.Context(), bridgeIssuer, teamSubject('b'))
269 if e != nil || p.Kind != principalstore.Home || p.VerifiedAddress != nil {
270 t.Fatal("first Home entry did not create addressless principal", e)
271 }
272 var count int64
273 if e = f.app.store.db.Model(&Person{}).Count(&count).Error; e != nil || count != 2 {
274 t.Fatal("Home entry created legacy email person", count, e)
275 }
276 if r := b.do("GET", "/", ""); r.Code != 200 || strings.Contains(r.Body.String(), "Alice original") || strings.Contains(r.Body.String(), "Bob private") {
277 t.Fatal("new Home principal inherited local ownership", r.Code)
278 }
279 r := b.do("POST", "/documents", url.Values{"title": {"Home document"}}.Encode())
280 target := routerRedirect(t, r, "")
281 if !strings.HasPrefix(target, "/d/") {
282 t.Fatal("member could not create document")
283 }
284 var d Document
285 if e = f.app.store.db.Where("sqid = ?", strings.TrimPrefix(target, "/d/")).First(&d).Error; e != nil || d.OwnerID != p.ID {
286 t.Fatal("Home-created document has wrong owner", e)
287 }
288 nonce = b.start(t, "/auth/start?return_to=%2F", "")
289 if r = b.exchange(t, nonce, teamSubject('c'), time.Now()); r.Code != 400 {
290 t.Fatal("existing browser switched principal", r.Code)
291 }
292 if _, e = f.app.store.team.principals.Resolve(t.Context(), bridgeIssuer, teamSubject('c')); !errors.Is(e, principalstore.ErrRefused) {
293 t.Fatal("conflict allocated unknown principal", e)
294 }
295 if r = b.do("GET", target, ""); r.Code != 200 {
296 t.Fatal("conflict replaced current session", r.Code)
297 }
298 outsider := f.browser()
299 nonce = outsider.start(t, "/auth/start?return_to=%2F", "")
300 if r = outsider.exchange(t, nonce, teamSubject('z'), time.Now()); r.Code != 403 {
301 t.Fatal("nonmember first entry accepted", r.Code)
302 }
303 if _, e = f.app.store.team.principals.Resolve(t.Context(), bridgeIssuer, teamSubject('z')); !errors.Is(e, principalstore.ErrRefused) {
304 t.Fatal("nonmember allocated principal", e)
305 }
306 if r = b.do("GET", "/account/home", ""); r.Code != 303 || r.Header().Get("Location") != "/signin/local" {
307 t.Fatal("addressless account cannot begin explicit local reauthentication", r.Code, r.Header().Get("Location"))
308 }
309}
310
311func TestTeamRouterActivatedDatabaseRefusesMissingAndMismatchedConfiguration(t *testing.T) {
312 f := newTeamRouterFixture(t)
313 cfg := f.cfg
314 cfg.Team = nil
315 if app, e := New(cfg); e == nil {
316 app.Close()
317 t.Fatal("activated database reopened without TeamMode")
318 }
319 mismatch := *f.cfg.Team
320 mismatch.TeamID = "TeamB"
321 cfg.Team = &mismatch
322 if app, e := New(cfg); e == nil {
323 app.Close()
324 t.Fatal("activated database accepted foreign team configuration")
325 }
326 cfg = f.cfg
327 cfg.Home = nil
328 if app, e := New(cfg); e == nil {
329 app.Close()
330 t.Fatal("activated database accepted missing Home identity configuration")
331 }
332}
333