oficina / home Public

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

Plain git — no account needed to clone.

Download

Download this file

1package directorydata
2
3import (
4 "bytes"
5 "context"
6 "crypto/sha256"
7 "database/sql"
8 "encoding/base32"
9 "encoding/base64"
10 "errors"
11 "os"
12 "strings"
13 "testing"
14 "time"
15
16 "amadan.net/oficina/home/internal/home/sealtest"
17 "amadan.net/rastrillo/rastrillo/migrate"
18 "github.com/go-json-experiment/json"
19)
20
21var rosterNow = time.Unix(2000000000, 0)
22
23func challenge(b byte) string {
24 return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{b}, 32))
25}
26func rosterFixture(t *testing.T, extra ...*migrate.Set) (*RosterSource, RosterRequest, string, int64) {
27 t.Helper()
28 s, d, path := fixture(t)
29 if _, err := migrate.Apply(context.Background(), d, RosterSchema); err != nil {
30 t.Fatal(err)
31 }
32 for _, schema := range extra {
33 if _, err := migrate.Apply(context.Background(), d, schema); err != nil {
34 t.Fatal(err)
35 }
36 }
37 a := team(t, s, "A1", "alpha", privateName)
38 instance(t, s, a, "alpha", "docs", "live")
39 b := team(t, s, "B2", "beta", "Other")
40 instance(t, s, b, "beta", "docs", "live")
41 exec(t, s.db, `INSERT INTO roster_generation VALUES(1,?)`, challenge(3))
42 exec(t, s.db, `INSERT INTO roster_consumers VALUES('alpha-docs',?,'docs','https://alpha.docs.oficina.works',1)`, a)
43 token := challenge(9)
44 h := sha256.Sum256([]byte(token))
45 exec(t, s.db, `INSERT INTO roster_tokens VALUES(?,'alpha-docs','current',?,NULL)`, h[:], rosterNow.Unix()-1)
46 src, err := NewRosterSource(s.db, s.sealer, Config{Apps: []string{"docs"}, ValidateRoute: s.validateRoute}, "https://home.oficina.works")
47 if err != nil {
48 t.Fatal(err)
49 }
50 return src, RosterRequest{Token: token, Team: "A1", Fetch: challenge(8)}, path, a
51}
52func addRoster(t *testing.T, s *RosterSource, team int64, n int) {
53 t.Helper()
54 tx, e := s.store.db.Begin()
55 if e != nil {
56 t.Fatal(e)
57 }
58 defer tx.Rollback()
59 for i := 0; i < n; i++ {
60 raw := make([]byte, 16)
61 raw[0] = byte(i >> 8)
62 raw[1] = byte(i)
63 ref := "pr_" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw))
64 if _, e = tx.Exec(`INSERT INTO memberships VALUES(?,?,'member')`, team, ref); e != nil {
65 t.Fatal(e)
66 }
67 }
68 if e = tx.Commit(); e != nil {
69 t.Fatal(e)
70 }
71}
72func TestRosterEmptyScopeRemovalPrivacy(t *testing.T) {
73 s, r, path, a := rosterFixture(t)
74 ctx := context.Background()
75 p, status, e := s.Read(ctx, r, rosterNow)
76 if e != nil || status != 200 || p.Members == nil || len(*p.Members) != 0 || p.Complete == nil || !*p.Complete || p.Cursor != nil || p.Consumer != "alpha-docs" || p.Team != "A1" || p.Audience != "https://alpha.docs.oficina.works" {
77 t.Fatal(p, status, e)
78 }
79 member(t, s.store, a, personA, "owner")
80 p, _, e = s.Read(ctx, r, rosterNow)
81 if e != nil || len(*p.Members) != 1 || (*p.Members)[0].Subject != personA {
82 t.Fatal(p, e)
83 }
84 epoch := p.Epoch
85 exec(t, s.store.db, `DELETE FROM memberships WHERE team_id=?`, a)
86 p, _, e = s.Read(ctx, r, rosterNow)
87 if e != nil || len(*p.Members) != 0 || p.Epoch != epoch+1 {
88 t.Fatal(p, e)
89 }
90 for _, team := range []string{"B2", "Missing"} {
91 bad := r
92 bad.Team = team
93 p, status, e = s.Read(ctx, bad, rosterNow)
94 if status != 404 || !errors.Is(e, ErrRosterNotFound) || p.Team != "" {
95 t.Fatal(p, status, e)
96 }
97 }
98 sealtest.AssertNoPlaintext(t, path)
99 for _, suffix := range []string{"", "-wal", "-shm"} {
100 b, e := os.ReadFile(path + suffix)
101 if e != nil && os.IsNotExist(e) {
102 continue
103 }
104 if e != nil {
105 t.Fatal(e)
106 }
107 if bytes.Contains(b, []byte(r.Token)) {
108 t.Fatal("plaintext token at rest")
109 }
110 }
111}
112func TestRosterRotationDisableAndMissingGeneration(t *testing.T) {
113 s, r, _, _ := rosterFixture(t)
114 ctx := context.Background()
115 old := r
116 tx, e := s.store.db.Begin()
117 if e != nil {
118 t.Fatal(e)
119 }
120 if _, e = tx.Exec(`UPDATE roster_tokens SET slot='previous',issued_at=?,expires_at=?`, rosterNow.Unix(), rosterNow.Unix()+300); e != nil {
121 t.Fatal(e)
122 }
123 r.Token = challenge(10)
124 h := sha256.Sum256([]byte(r.Token))
125 if _, e = tx.Exec(`INSERT INTO roster_tokens VALUES(?,'alpha-docs','current',?,NULL)`, h[:], rosterNow.Unix()); e != nil {
126 t.Fatal(e)
127 }
128 if e = tx.Commit(); e != nil {
129 t.Fatal(e)
130 }
131 for _, req := range []RosterRequest{old, r} {
132 if _, status, e := s.Read(ctx, req, rosterNow); e != nil || status != 200 {
133 t.Fatal(status, e)
134 }
135 }
136 if _, status, e := s.Read(ctx, old, rosterNow.Add(300*time.Second)); status != 401 || e != ErrRosterUnauthorized {
137 t.Fatal(status, e)
138 }
139 if _, e = s.store.db.Exec(`UPDATE roster_tokens SET expires_at=issued_at+301 WHERE slot='previous'`); e == nil {
140 t.Fatal("unbounded overlap accepted")
141 }
142 exec(t, s.store.db, `UPDATE roster_consumers SET enabled=0`)
143 if _, status, e := s.Read(ctx, r, rosterNow); status != 401 || e != ErrRosterUnauthorized {
144 t.Fatal(status, e)
145 }
146 exec(t, s.store.db, `UPDATE roster_consumers SET enabled=1`)
147 exec(t, s.store.db, `DELETE FROM roster_generation`)
148 if _, status, e := s.Read(ctx, r, rosterNow); status != 503 || e != ErrUnavailable {
149 t.Fatal(status, e)
150 }
151}
152func TestRosterPagingSealedCursorAndEpoch(t *testing.T) {
153 s, r, path, a := rosterFixture(t)
154 addRoster(t, s, a, 201)
155 ctx := context.Background()
156 first, status, e := s.Read(ctx, r, rosterNow)
157 if e != nil || status != 200 || len(*first.Members) != 100 || *first.Complete || first.Cursor == nil {
158 t.Fatal(first, status, e)
159 }
160 cursor := *first.Cursor
161 raw, err := base64.RawURLEncoding.DecodeString(cursor)
162 if err != nil {
163 t.Fatal(err)
164 }
165 plain, err := s.store.sealer.Open(s.cursorPurpose(), raw)
166 if err != nil {
167 t.Fatal(err)
168 }
169 for _, suffix := range []string{"", "-wal", "-shm"} {
170 b, e := os.ReadFile(path + suffix)
171 if os.IsNotExist(e) {
172 continue
173 }
174 if e != nil {
175 t.Fatal(e)
176 }
177 for _, secret := range [][]byte{[]byte(r.Token), []byte(cursor), plain} {
178 if bytes.Contains(b, secret) {
179 t.Fatal("roster request or cursor persisted")
180 }
181 }
182 }
183 sealtest.AssertNoPlaintext(t, path)
184 r.Cursor = cursor
185 second, status, e := s.Read(ctx, r, rosterNow)
186 if e != nil || status != 200 || *second.Index != 1 || len(*second.Members) != 100 || (*second.Members)[0].Subject <= (*first.Members)[99].Subject {
187 t.Fatal(second, status, e)
188 }
189 r.Cursor = *second.Cursor
190 third, status, e := s.Read(ctx, r, rosterNow)
191 if e != nil || status != 200 || len(*third.Members) != 1 || !*third.Complete || third.Cursor != nil {
192 t.Fatal(third, status, e)
193 }
194 r.Cursor = cursor
195 for _, change := range []func(*RosterRequest){func(r *RosterRequest) { r.Fetch = challenge(99) }, func(r *RosterRequest) { r.Cursor = r.Cursor[:len(r.Cursor)-1] + "!" }, func(r *RosterRequest) { r.Cursor += "=" }} {
196 bad := r
197 change(&bad)
198 if _, status, e = s.Read(ctx, bad, rosterNow); status != 400 || e != ErrRosterBadRequest {
199 t.Fatal(status, e)
200 }
201 }
202 if _, status, e = s.Read(ctx, r, rosterNow.Add(10*time.Second)); status != 400 || e != ErrRosterBadRequest {
203 t.Fatal(status, e)
204 }
205 exec(t, s.store.db, `UPDATE memberships SET role='owner' WHERE team_id=? AND person_ref=?`, a, personA)
206 p, status, e := s.Read(ctx, r, rosterNow)
207 if e != nil || status != 409 || p.Type != "stale-cursor" || p.Index != nil || p.Members != nil || p.Complete != nil || p.Cursor != nil || p.Epoch != first.Epoch+1 {
208 t.Fatal(p, status, e)
209 }
210 exec(t, s.store.db, `UPDATE roster_generation SET generation=?`, challenge(4))
211 p, status, e = s.Read(ctx, r, rosterNow)
212 if e != nil || status != 409 || p.Generation != challenge(4) {
213 t.Fatal(p, status, e)
214 }
215}
216func TestRosterCursorStrictFieldsAndPurpose(t *testing.T) {
217 s, r, _, a := rosterFixture(t)
218 addRoster(t, s, a, 101)
219 p, _, e := s.Read(context.Background(), r, rosterNow)
220 if e != nil {
221 t.Fatal(e)
222 }
223 b, _ := base64.RawURLEncoding.DecodeString(*p.Cursor)
224 plain, e := s.store.sealer.Open(s.cursorPurpose(), b)
225 if e != nil {
226 t.Fatal(e)
227 }
228 for _, body := range []string{strings.TrimSuffix(string(plain), "}") + `,"unknown":1}`, strings.TrimSuffix(string(plain), "}") + `,"version":1}`} {
229 sealed, e := s.store.sealer.Seal(s.cursorPurpose(), []byte(body))
230 if e != nil {
231 t.Fatal(e)
232 }
233 r.Cursor = base64.RawURLEncoding.EncodeToString(sealed)
234 if _, status, e := s.Read(context.Background(), r, rosterNow); status != 400 || e != ErrRosterBadRequest {
235 t.Fatal(status, e)
236 }
237 }
238 var c rosterCursor
239 if json.Unmarshal(plain, &c) != nil {
240 t.Fatal("cursor decode")
241 }
242 if c.Expires-c.Issued != 10 {
243 t.Fatal(c)
244 }
245 sealed, e := s.store.sealer.Seal("directory.roster-cursor/https://other.oficina.works", plain)
246 if e != nil {
247 t.Fatal(e)
248 }
249 r.Cursor = base64.RawURLEncoding.EncodeToString(sealed)
250 if _, status, e := s.Read(context.Background(), r, rosterNow); status != 400 || e != ErrRosterBadRequest {
251 t.Fatal(status, e)
252 }
253}
254func TestRosterEpochOverflowAtomicAndOtherTeamIsolation(t *testing.T) {
255 s, _, _, a := rosterFixture(t)
256 member(t, s.store, a, personA, "owner")
257 var before int64
258 if e := s.store.db.QueryRow(`SELECT roster_epoch FROM teams WHERE sqid='B2'`).Scan(&before); e != nil {
259 t.Fatal(e)
260 }
261 exec(t, s.store.db, `UPDATE memberships SET role='member' WHERE team_id=?`, a)
262 var after int64
263 s.store.db.QueryRow(`SELECT roster_epoch FROM teams WHERE sqid='B2'`).Scan(&after)
264 if after != before {
265 t.Fatal("foreign epoch changed")
266 }
267 exec(t, s.store.db, `UPDATE teams SET roster_epoch=9223372036854775807 WHERE id=?`, a)
268 for _, q := range []string{`DELETE FROM memberships WHERE team_id=?`, `UPDATE memberships SET role='owner' WHERE team_id=?`, `INSERT INTO memberships(team_id,person_ref,role) VALUES(?,'pr_bbbbbbbbbbbbbbbbbbbbbbbbbb','member')`} {
269 if _, e := s.store.db.Exec(q, a); e == nil {
270 t.Fatal("overflow accepted")
271 }
272 }
273 var count int
274 s.store.db.QueryRow(`SELECT count(*) FROM memberships WHERE team_id=?`, a).Scan(&count)
275 if count != 1 {
276 t.Fatal(count)
277 }
278}
279func TestRosterBoundAndInvalidLookahead(t *testing.T) {
280 s, r, _, a := rosterFixture(t)
281 addRoster(t, s, a, 10001)
282 if _, status, e := s.Read(context.Background(), r, rosterNow); status != 503 || e != ErrUnavailable {
283 t.Fatal(status, e)
284 }
285 exec(t, s.store.db, `DELETE FROM memberships WHERE person_ref=(SELECT max(person_ref) FROM memberships WHERE team_id=?) AND team_id=?`, a, a)
286 p, status, e := s.Read(context.Background(), r, rosterNow)
287 if status != 200 || e != nil || len(*p.Members) != 100 {
288 t.Fatal(p, status, e)
289 }
290 exec(t, s.store.db, `DELETE FROM memberships`)
291 addRoster(t, s, a, 100)
292 exec(t, s.store.db, `PRAGMA ignore_check_constraints=ON`)
293 exec(t, s.store.db, `INSERT INTO memberships VALUES(?,'zz-invalid','owner')`, a)
294 exec(t, s.store.db, `PRAGMA ignore_check_constraints=OFF`)
295 if _, status, e := s.Read(context.Background(), r, rosterNow); status != 503 || e != ErrUnavailable {
296 t.Fatal(status, e)
297 }
298}
299
300type onOpenSealer struct {
301 Sealer
302 once func()
303}
304
305func (s *onOpenSealer) Open(p string, b []byte) ([]byte, error) {
306 if s.once != nil {
307 f := s.once
308 s.once = nil
309 f()
310 }
311 return s.Sealer.Open(p, b)
312}
313func TestRosterOneReadSnapshotUnderConcurrentRevocation(t *testing.T) {
314 s, r, path, a := rosterFixture(t)
315 addRoster(t, s, a, 201)
316 p, _, e := s.Read(context.Background(), r, rosterNow)
317 if e != nil {
318 t.Fatal(e)
319 }
320 r.Cursor = *p.Cursor
321 writer, e := sql.Open("sqlite", path)
322 if e != nil {
323 t.Fatal(e)
324 }
325 defer writer.Close()
326 s.store.sealer = &onOpenSealer{Sealer: s.store.sealer, once: func() {
327 tx, e := writer.Begin()
328 if e != nil {
329 t.Fatal(e)
330 }
331 defer tx.Rollback()
332 if _, e = tx.Exec(`DELETE FROM memberships WHERE team_id=?`, a); e != nil {
333 t.Fatal(e)
334 }
335 if _, e = tx.Exec(`UPDATE roster_consumers SET enabled=0`); e != nil {
336 t.Fatal(e)
337 }
338 if e = tx.Commit(); e != nil {
339 t.Fatal(e)
340 }
341 }}
342 second, status, e := s.Read(context.Background(), r, rosterNow)
343 if e != nil || status != 200 || second.Epoch != p.Epoch || len(*second.Members) != 100 {
344 t.Fatal(second, status, e)
345 }
346 if _, status, e := s.Read(context.Background(), r, rosterNow); status != 401 || e != ErrRosterUnauthorized {
347 t.Fatal(status, e)
348 }
349}
350func TestRosterMigrationLeavesAuthorityEmpty(t *testing.T) {
351 _, d, _ := fixture(t)
352 if _, e := migrate.Apply(context.Background(), d, RosterSchema); e != nil {
353 t.Fatal(e)
354 }
355 for _, table := range []string{"roster_generation", "roster_consumers", "roster_tokens", "roster_operator_receipts"} {
356 var n int
357 if e := d.Writer().QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); e != nil || n != 0 {
358 t.Fatal(table, n, e)
359 }
360 }
361}
362
363func TestRosterInputAndConfigurationRefusal(t *testing.T) {
364 s, r, _, _ := rosterFixture(t)
365 for _, tc := range []struct {
366 change func(*RosterRequest)
367 status int
368 }{
369 {func(r *RosterRequest) { r.Token = "secret@example.invalid" }, 401},
370 {func(r *RosterRequest) { r.Token = challenge(55) }, 401},
371 {func(r *RosterRequest) { r.Team = "../B2" }, 400},
372 {func(r *RosterRequest) { r.Fetch = "canary@example.invalid" }, 400},
373 {func(r *RosterRequest) { r.Cursor = strings.Repeat("a", 2049) }, 400},
374 } {
375 bad := r
376 tc.change(&bad)
377 p, status, e := s.Read(context.Background(), bad, rosterNow)
378 if status != tc.status || e == nil || p.Team != "" || strings.Contains(e.Error(), "@") || strings.Contains(e.Error(), bad.Token) {
379 t.Fatal(p, status, e)
380 }
381 }
382 for _, q := range []string{`UPDATE team_instances SET state='pending'`, `UPDATE team_instances SET state='disabled'`, `UPDATE roster_consumers SET origin='https://foreign.oficina.works'`} {
383 exec(t, s.store.db, q)
384 if _, status, e := s.Read(context.Background(), r, rosterNow); e == nil || status != 503 && status != 401 {
385 t.Fatal(status, e)
386 }
387 }
388}
389