| 1 | package directorydata |
| 2 | |
| 3 | import ( |
| 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 | |
| 21 | var rosterNow = time.Unix(2000000000, 0) |
| 22 | |
| 23 | func challenge(b byte) string { |
| 24 | return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{b}, 32)) |
| 25 | } |
| 26 | func 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 | } |
| 52 | func 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 | } |
| 72 | func 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 | } |
| 112 | func 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 | } |
| 152 | func 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 | } |
| 216 | func 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 | } |
| 254 | func 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 | } |
| 279 | func 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 | |
| 300 | type onOpenSealer struct { |
| 301 | Sealer |
| 302 | once func() |
| 303 | } |
| 304 | |
| 305 | func (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 | } |
| 313 | func 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 | } |
| 350 | func 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 | |
| 363 | func 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 | |