| 1 | package home |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/sha256" |
| 7 | "encoding/base32" |
| 8 | "encoding/base64" |
| 9 | "errors" |
| 10 | "io" |
| 11 | "net/http" |
| 12 | "net/http/httptest" |
| 13 | "net/url" |
| 14 | "os" |
| 15 | "strconv" |
| 16 | "strings" |
| 17 | "sync" |
| 18 | "testing" |
| 19 | "time" |
| 20 | |
| 21 | "amadan.net/oficina/home/internal/home/sealtest" |
| 22 | "amadan.net/oficina/home/roster" |
| 23 | "amadan.net/rastrillo/rastrillo" |
| 24 | family "amadan.net/rastrillo/rastrillo/crypto" |
| 25 | "github.com/go-json-experiment/json" |
| 26 | ) |
| 27 | |
| 28 | type rosterTransport func(*http.Request) (*http.Response, error) |
| 29 | |
| 30 | func (f rosterTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } |
| 31 | |
| 32 | type rosterMemory struct { |
| 33 | mu sync.Mutex |
| 34 | b []byte |
| 35 | saves int |
| 36 | } |
| 37 | |
| 38 | func (m *rosterMemory) Load(context.Context) ([]byte, error) { |
| 39 | m.mu.Lock() |
| 40 | defer m.mu.Unlock() |
| 41 | if m.b == nil { |
| 42 | return nil, roster.ErrNoEvidence |
| 43 | } |
| 44 | return append([]byte{}, m.b...), nil |
| 45 | } |
| 46 | func (m *rosterMemory) Save(_ context.Context, b []byte) error { |
| 47 | m.mu.Lock() |
| 48 | defer m.mu.Unlock() |
| 49 | m.b = append([]byte{}, b...) |
| 50 | m.saves++ |
| 51 | return nil |
| 52 | } |
| 53 | |
| 54 | type rosterClock struct { |
| 55 | mu sync.Mutex |
| 56 | at time.Time |
| 57 | elapsed time.Duration |
| 58 | } |
| 59 | |
| 60 | func (c *rosterClock) Now() (time.Time, time.Duration) { |
| 61 | c.mu.Lock() |
| 62 | defer c.mu.Unlock() |
| 63 | return c.at, c.elapsed |
| 64 | } |
| 65 | func (c *rosterClock) advance(d time.Duration) { |
| 66 | c.mu.Lock() |
| 67 | defer c.mu.Unlock() |
| 68 | c.at = c.at.Add(d) |
| 69 | c.elapsed += d |
| 70 | } |
| 71 | |
| 72 | type rosterIntegration struct { |
| 73 | f chooserFixture |
| 74 | key *family.Keypair |
| 75 | team int64 |
| 76 | token, generation string |
| 77 | server *httptest.Server |
| 78 | transport http.RoundTripper |
| 79 | } |
| 80 | |
| 81 | func rosterAtom(b byte) string { |
| 82 | return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{b}, 32)) |
| 83 | } |
| 84 | func newRosterIntegration(t *testing.T, n int) *rosterIntegration { |
| 85 | t.Helper() |
| 86 | f := newChooserFixture(t) |
| 87 | key, _ := rosterPrivate(t) |
| 88 | cfg := *f.p.directory.runtime |
| 89 | cfg.roster = rosterRuntime{mode: "serve", signer: key, keyID: rosterKeyID(key)} |
| 90 | app, e := NewRuntime(cfg, f.p.directory.db, f.p.directory.logger) |
| 91 | if e != nil { |
| 92 | t.Fatal(e) |
| 93 | } |
| 94 | f.p.directory = app |
| 95 | id := f.team(t, "A1", "alpha", string(sealtest.Canaries[1]), chooserPerson) |
| 96 | tx, e := app.db.Writer().Begin() |
| 97 | if e != nil { |
| 98 | t.Fatal(e) |
| 99 | } |
| 100 | defer tx.Rollback() |
| 101 | if _, e = tx.Exec(`DELETE FROM memberships WHERE team_id=?`, id); e != nil { |
| 102 | t.Fatal(e) |
| 103 | } |
| 104 | for i := 0; i < n; i++ { |
| 105 | raw := make([]byte, 16) |
| 106 | raw[0] = byte(i >> 8) |
| 107 | raw[1] = byte(i) |
| 108 | ref := "pr_" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw)) |
| 109 | if _, e = tx.Exec(`INSERT INTO memberships VALUES(?,?,'member')`, id, ref); e != nil { |
| 110 | t.Fatal(e) |
| 111 | } |
| 112 | } |
| 113 | token, generation := rosterAtom(11), rosterAtom(12) |
| 114 | hash := sha256.Sum256([]byte(token)) |
| 115 | for _, q := range []struct { |
| 116 | sql string |
| 117 | args []any |
| 118 | }{ |
| 119 | {`INSERT INTO roster_generation VALUES(1,?)`, []any{generation}}, |
| 120 | {`INSERT INTO roster_consumers VALUES('alpha-docs',?,'docs','https://alpha.docs.oficina.works',1)`, []any{id}}, |
| 121 | {`INSERT INTO roster_tokens VALUES(?,'alpha-docs','current',?,NULL)`, []any{hash[:], time.Now().Unix() - 1}}, |
| 122 | } { |
| 123 | if _, e = tx.Exec(q.sql, q.args...); e != nil { |
| 124 | t.Fatal(e) |
| 125 | } |
| 126 | } |
| 127 | if e = tx.Commit(); e != nil { |
| 128 | t.Fatal(e) |
| 129 | } |
| 130 | mux := http.NewServeMux() |
| 131 | mux.Handle("/", app.Router()) |
| 132 | handler, closeHandler, e := rastrillo.Handler(rastrillo.Options{Mux: mux, CSP: app.SecurityPolicy(), Logger: f.p.directory.logger}) |
| 133 | if e != nil { |
| 134 | t.Fatal(e) |
| 135 | } |
| 136 | t.Cleanup(func() { closeHandler() }) |
| 137 | server := httptest.NewTLSServer(handler) |
| 138 | t.Cleanup(server.Close) |
| 139 | target, _ := url.Parse(server.URL) |
| 140 | transport := rosterTransport(func(r *http.Request) (*http.Response, error) { |
| 141 | if r.URL.Scheme != "https" || r.URL.Host != strings.TrimPrefix(directoryOrigin, "https://") { |
| 142 | return nil, errors.New("unexpected test destination") |
| 143 | } |
| 144 | c := r.Clone(r.Context()) |
| 145 | u := *r.URL |
| 146 | c.URL = &u |
| 147 | c.URL.Host = target.Host |
| 148 | c.Host = r.URL.Host |
| 149 | return server.Client().Transport.RoundTrip(c) |
| 150 | }) |
| 151 | return &rosterIntegration{f: f, key: key, team: id, token: token, generation: generation, server: server, transport: transport} |
| 152 | } |
| 153 | func (f *rosterIntegration) record(t *testing.T, team, consumer, audience, kid string) roster.VerifiedRecord { |
| 154 | t.Helper() |
| 155 | op, _ := rosterPrivate(t) |
| 156 | binding := roster.Bindings{Issuer: Origin, Directory: directoryOrigin, Consumer: consumer, App: "docs", Team: team, Audience: audience} |
| 157 | record := roster.Provisioning{Version: 1, Operation: "test-operation", Sequence: 1, Bindings: binding, Generation: f.generation, MinEpoch: 1, Keys: []roster.Key{{ID: kid, Public: base64.RawURLEncoding.EncodeToString(f.key.SignPub())}}} |
| 158 | wire, e := roster.SignProvisioning(op, "test-operator", record) |
| 159 | if e != nil { |
| 160 | t.Fatal(e) |
| 161 | } |
| 162 | verified, e := roster.VerifyProvisioning(wire, []roster.Key{{ID: "test-operator", Public: base64.RawURLEncoding.EncodeToString(op.SignPub())}}, binding) |
| 163 | if e != nil { |
| 164 | t.Fatal(e) |
| 165 | } |
| 166 | return verified |
| 167 | } |
| 168 | func (f *rosterIntegration) cache(t *testing.T, transport http.RoundTripper, kid string) (*roster.Cache, *rosterMemory, *rosterClock) { |
| 169 | t.Helper() |
| 170 | memory := &rosterMemory{} |
| 171 | clock := &rosterClock{at: time.Now()} |
| 172 | c, e := roster.New(context.Background(), roster.Config{Record: f.record(t, "A1", "alpha-docs", "https://alpha.docs.oficina.works", kid), Token: f.token, Transport: transport, Durable: memory, Sealer: f.f.p.directory.secrets.Sealer, Clock: clock}) |
| 173 | if e != nil { |
| 174 | t.Fatal(e) |
| 175 | } |
| 176 | return c, memory, clock |
| 177 | } |
| 178 | func (f *rosterIntegration) get(t *testing.T, query, token string) (int, http.Header, []byte) { |
| 179 | t.Helper() |
| 180 | r, e := http.NewRequest("GET", directoryOrigin+"/roster?"+query, nil) |
| 181 | if e != nil { |
| 182 | t.Fatal(e) |
| 183 | } |
| 184 | r.Header.Set("Authorization", "Bearer "+token) |
| 185 | res, e := f.transport.RoundTrip(r) |
| 186 | if e != nil { |
| 187 | t.Fatal(e) |
| 188 | } |
| 189 | defer res.Body.Close() |
| 190 | b, e := io.ReadAll(io.LimitReader(res.Body, roster.BodyLimit+1)) |
| 191 | if e != nil { |
| 192 | t.Fatal(e) |
| 193 | } |
| 194 | if res.Header.Get("Cache-Control") != "no-store, no-transform" || res.Header.Get("Content-Type") != "application/json" { |
| 195 | t.Fatal(res.Header) |
| 196 | } |
| 197 | for _, k := range []string{"Content-Encoding", "Location", "Set-Cookie", "ETag", "Last-Modified"} { |
| 198 | if res.Header.Get(k) != "" { |
| 199 | t.Fatal("framework changed roster", k) |
| 200 | } |
| 201 | } |
| 202 | return res.StatusCode, res.Header, b |
| 203 | } |
| 204 | func decodeRosterPage(t *testing.T, b []byte) roster.Page { |
| 205 | t.Helper() |
| 206 | var env struct { |
| 207 | Payload string `json:"payload"` |
| 208 | } |
| 209 | if json.Unmarshal(b, &env) != nil { |
| 210 | t.Fatal("missing envelope") |
| 211 | } |
| 212 | plain, e := base64.RawURLEncoding.DecodeString(env.Payload) |
| 213 | if e != nil { |
| 214 | t.Fatal(e) |
| 215 | } |
| 216 | var p roster.Page |
| 217 | if json.Unmarshal(plain, &p) != nil { |
| 218 | t.Fatal("missing page") |
| 219 | } |
| 220 | return p |
| 221 | } |
| 222 | func TestRosterFrameworkAndActualClientZeroOneMany(t *testing.T) { |
| 223 | for _, n := range []int{0, 1, 201} { |
| 224 | t.Run(strconv.Itoa(n), func(t *testing.T) { |
| 225 | f := newRosterIntegration(t, n) |
| 226 | cache, _, _ := f.cache(t, f.transport, rosterKeyID(f.key)) |
| 227 | d := cache.Check(context.Background(), Origin, "A1", chooserPerson) |
| 228 | want := roster.Member |
| 229 | if n == 0 { |
| 230 | want = roster.NotMember |
| 231 | } |
| 232 | if d.State != want { |
| 233 | t.Fatal("actual signed client", n, d.State) |
| 234 | } |
| 235 | if n > 0 && !d.Lease.Valid() { |
| 236 | t.Fatal("missing lease") |
| 237 | } |
| 238 | status, _, wire := f.get(t, "team=A1&fetch="+rosterTestChallenge, f.token) |
| 239 | if status != 200 { |
| 240 | t.Fatal(status) |
| 241 | } |
| 242 | p := decodeRosterPage(t, wire) |
| 243 | if p.Members == nil || p.Complete == nil || p.Index == nil || p.RequestCursorHash != roster.CursorHash("") { |
| 244 | t.Fatal("page shape") |
| 245 | } |
| 246 | if n == 0 && (len(*p.Members) != 0 || !*p.Complete || p.Cursor != nil) { |
| 247 | t.Fatal("empty roster encoding") |
| 248 | } |
| 249 | sealtest.AssertNoPlaintext(t, f.f.p.directoryPath) |
| 250 | corpus := [][]byte{[]byte(f.token), []byte(string(sealtest.Canaries[1]))} |
| 251 | if p.Cursor != nil { |
| 252 | sealed, e := base64.RawURLEncoding.DecodeString(*p.Cursor) |
| 253 | if e != nil { |
| 254 | t.Fatal(e) |
| 255 | } |
| 256 | plain, e := f.f.p.directory.secrets.Sealer.Open("directory.roster-cursor/"+directoryOrigin, sealed) |
| 257 | if e != nil { |
| 258 | t.Fatal(e) |
| 259 | } |
| 260 | corpus = append(corpus, []byte(*p.Cursor), plain) |
| 261 | } |
| 262 | for _, suffix := range []string{"", "-wal", "-shm"} { |
| 263 | b, e := os.ReadFile(f.f.p.directoryPath + suffix) |
| 264 | if os.IsNotExist(e) { |
| 265 | continue |
| 266 | } |
| 267 | if e != nil { |
| 268 | t.Fatal(e) |
| 269 | } |
| 270 | for _, secret := range corpus { |
| 271 | if bytes.Contains(b, secret) { |
| 272 | t.Fatal("request secret at rest") |
| 273 | } |
| 274 | } |
| 275 | } |
| 276 | for _, secret := range append(corpus, []byte(chooserPerson), []byte("/roster?")) { |
| 277 | if bytes.Contains(f.f.p.logs.Bytes(), secret) { |
| 278 | t.Fatal("roster request logged") |
| 279 | } |
| 280 | } |
| 281 | }) |
| 282 | } |
| 283 | } |
| 284 | func TestRosterHTTPConflictDoesNotPublishAndUnknownKidRefuses(t *testing.T) { |
| 285 | for _, generation := range []bool{false, true} { |
| 286 | t.Run(map[bool]string{false: "epoch", true: "generation"}[generation], func(t *testing.T) { |
| 287 | f := newRosterIntegration(t, 201) |
| 288 | changed := false |
| 289 | statuses := []int{} |
| 290 | transport := rosterTransport(func(r *http.Request) (*http.Response, error) { |
| 291 | if r.URL.Query().Get("cursor") != "" && !changed { |
| 292 | changed = true |
| 293 | var e error |
| 294 | if generation { |
| 295 | _, e = f.f.p.directory.db.Writer().Exec(`UPDATE roster_generation SET generation=?`, rosterAtom(13)) |
| 296 | } else { |
| 297 | _, e = f.f.p.directory.db.Writer().Exec(`DELETE FROM memberships WHERE team_id=?`, f.team) |
| 298 | } |
| 299 | if e != nil { |
| 300 | return nil, e |
| 301 | } |
| 302 | } |
| 303 | res, e := f.transport.RoundTrip(r) |
| 304 | if e == nil { |
| 305 | statuses = append(statuses, res.StatusCode) |
| 306 | } |
| 307 | return res, e |
| 308 | }) |
| 309 | cache, memory, _ := f.cache(t, transport, rosterKeyID(f.key)) |
| 310 | d := cache.Check(context.Background(), Origin, "A1", chooserPerson) |
| 311 | if d.State != roster.Unknown || !changed || len(statuses) != 2 || statuses[0] != 200 || statuses[1] != 409 { |
| 312 | t.Fatal(d.State, statuses) |
| 313 | } |
| 314 | memory.mu.Lock() |
| 315 | evidence := append([]byte{}, memory.b...) |
| 316 | memory.mu.Unlock() |
| 317 | if len(evidence) > 0 { |
| 318 | bindings := roster.Bindings{Issuer: Origin, Directory: directoryOrigin, Consumer: "alpha-docs", App: "docs", Team: "A1", Audience: "https://alpha.docs.oficina.works"} |
| 319 | raw, e := json.Marshal(bindings) |
| 320 | if e != nil { |
| 321 | t.Fatal(e) |
| 322 | } |
| 323 | digest := sha256.Sum256(raw) |
| 324 | plain, e := f.f.p.directory.secrets.Sealer.Open("oficina/roster/evidence/v1/"+base64.RawURLEncoding.EncodeToString(digest[:]), evidence) |
| 325 | if e != nil { |
| 326 | t.Fatal(e) |
| 327 | } |
| 328 | var state struct { |
| 329 | Snapshot bool `json:"snapshot"` |
| 330 | Blocked bool `json:"blocked"` |
| 331 | Members []roster.MemberEntry `json:"members"` |
| 332 | } |
| 333 | if json.Unmarshal(plain, &state) != nil || state.Snapshot || len(state.Members) != 0 || state.Blocked != generation { |
| 334 | t.Fatal("partial roster persisted as complete") |
| 335 | } |
| 336 | } |
| 337 | if d.Lease.Valid() { |
| 338 | t.Fatal("conflict granted lease") |
| 339 | } |
| 340 | }) |
| 341 | } |
| 342 | f := newRosterIntegration(t, 1) |
| 343 | cache, _, _ := f.cache(t, f.transport, "unrecognized-key") |
| 344 | if d := cache.Check(context.Background(), Origin, "A1", chooserPerson); d.State != roster.Unknown { |
| 345 | t.Fatal("unknown key accepted", d.State) |
| 346 | } |
| 347 | } |
| 348 | func TestRosterDisableExpiresStandingAndMissingGeneration(t *testing.T) { |
| 349 | f := newRosterIntegration(t, 1) |
| 350 | cache, _, clock := f.cache(t, f.transport, rosterKeyID(f.key)) |
| 351 | d := cache.Check(context.Background(), Origin, "A1", chooserPerson) |
| 352 | if d.State != roster.Member { |
| 353 | t.Fatal(d.State) |
| 354 | } |
| 355 | if _, e := f.f.p.directory.db.Writer().Exec(`UPDATE roster_consumers SET enabled=0`); e != nil { |
| 356 | t.Fatal(e) |
| 357 | } |
| 358 | clock.advance(60 * time.Second) |
| 359 | if next := cache.Check(context.Background(), Origin, "A1", chooserPerson); next.State != roster.Unknown || d.Lease.Valid() { |
| 360 | t.Fatal("disabled token extended standing", next.State) |
| 361 | } |
| 362 | if _, e := f.f.p.directory.db.Writer().Exec(`UPDATE roster_consumers SET enabled=1`); e != nil { |
| 363 | t.Fatal(e) |
| 364 | } |
| 365 | if _, e := f.f.p.directory.db.Writer().Exec(`DELETE FROM roster_generation`); e != nil { |
| 366 | t.Fatal(e) |
| 367 | } |
| 368 | status, _, b := f.get(t, "team=A1&fetch="+rosterTestChallenge, f.token) |
| 369 | if status != 503 || string(b) != `{"error":"unavailable"}` { |
| 370 | t.Fatal("missing generation", status) |
| 371 | } |
| 372 | var count int |
| 373 | if e := f.f.p.directory.db.Writer().QueryRow(`SELECT count(*) FROM roster_generation`).Scan(&count); e != nil || count != 0 { |
| 374 | t.Fatal("generation minted", count, e) |
| 375 | } |
| 376 | } |
| 377 | func TestRosterTwoConsumersCannotCrossTeams(t *testing.T) { |
| 378 | f := newRosterIntegration(t, 1) |
| 379 | id := f.f.team(t, "B2", "beta", "Other team", chooserOther) |
| 380 | token := rosterAtom(14) |
| 381 | hash := sha256.Sum256([]byte(token)) |
| 382 | tx, e := f.f.p.directory.db.Writer().Begin() |
| 383 | if e != nil { |
| 384 | t.Fatal(e) |
| 385 | } |
| 386 | defer tx.Rollback() |
| 387 | for _, q := range []struct { |
| 388 | sql string |
| 389 | args []any |
| 390 | }{{`INSERT INTO roster_consumers VALUES('beta-docs',?,'docs','https://beta.docs.oficina.works',1)`, []any{id}}, {`INSERT INTO roster_tokens VALUES(?,'beta-docs','current',?,NULL)`, []any{hash[:], time.Now().Unix() - 1}}} { |
| 391 | if _, e = tx.Exec(q.sql, q.args...); e != nil { |
| 392 | t.Fatal(e) |
| 393 | } |
| 394 | } |
| 395 | if e = tx.Commit(); e != nil { |
| 396 | t.Fatal(e) |
| 397 | } |
| 398 | for _, pair := range []struct{ token, team string }{{f.token, "B2"}, {token, "A1"}, {f.token, "Missing"}, {token, "Missing"}} { |
| 399 | status, _, b := f.get(t, "team="+pair.team+"&fetch="+rosterTestChallenge, pair.token) |
| 400 | if status != 404 || string(b) != `{"error":"not_found"}` { |
| 401 | t.Fatal(status, string(b)) |
| 402 | } |
| 403 | } |
| 404 | status, _, b := f.get(t, "team=B2&fetch="+rosterTestChallenge, token) |
| 405 | p := decodeRosterPage(t, b) |
| 406 | if status != 200 || p.Team != "B2" || p.Consumer != "beta-docs" || p.Audience != "https://beta.docs.oficina.works" || len(*p.Members) != 1 || (*p.Members)[0].Subject != chooserOther { |
| 407 | t.Fatal("foreign roster") |
| 408 | } |
| 409 | } |
| 410 | |
| 411 | func TestRosterRealClientRefusesSignedWrongScopeAndContext(t *testing.T) { |
| 412 | for _, kind := range []string{"scope", "context", "signature"} { |
| 413 | t.Run(kind, func(t *testing.T) { |
| 414 | f := newRosterIntegration(t, 1) |
| 415 | transport := rosterTransport(func(r *http.Request) (*http.Response, error) { |
| 416 | res, e := f.transport.RoundTrip(r) |
| 417 | if e != nil { |
| 418 | return nil, e |
| 419 | } |
| 420 | raw, e := io.ReadAll(res.Body) |
| 421 | res.Body.Close() |
| 422 | if e != nil { |
| 423 | return nil, e |
| 424 | } |
| 425 | var env struct { |
| 426 | Version int `json:"v"` |
| 427 | KeyID string `json:"kid"` |
| 428 | Payload string `json:"payload"` |
| 429 | Signature string `json:"signature"` |
| 430 | } |
| 431 | if e = json.Unmarshal(raw, &env); e != nil { |
| 432 | return nil, e |
| 433 | } |
| 434 | payload, e := base64.RawURLEncoding.DecodeString(env.Payload) |
| 435 | if e != nil { |
| 436 | return nil, e |
| 437 | } |
| 438 | switch kind { |
| 439 | case "scope": |
| 440 | var p roster.Page |
| 441 | if e = json.Unmarshal(payload, &p); e != nil { |
| 442 | return nil, e |
| 443 | } |
| 444 | p.Team = "B2" |
| 445 | raw, e = roster.SignPage(f.key, rosterKeyID(f.key), p) |
| 446 | case "context": |
| 447 | var sig []byte |
| 448 | sig, e = family.Sign(f.key, roster.ProvisioningContext, payload) |
| 449 | if e == nil { |
| 450 | env.Signature = base64.RawURLEncoding.EncodeToString(sig) |
| 451 | raw, e = json.Marshal(env) |
| 452 | } |
| 453 | case "signature": |
| 454 | var sig []byte |
| 455 | sig, e = base64.RawURLEncoding.DecodeString(env.Signature) |
| 456 | if e == nil { |
| 457 | sig[0] ^= 1 |
| 458 | env.Signature = base64.RawURLEncoding.EncodeToString(sig) |
| 459 | raw, e = json.Marshal(env) |
| 460 | } |
| 461 | } |
| 462 | if e != nil { |
| 463 | return nil, e |
| 464 | } |
| 465 | res.Body = io.NopCloser(bytes.NewReader(raw)) |
| 466 | res.ContentLength = int64(len(raw)) |
| 467 | return res, nil |
| 468 | }) |
| 469 | cache, _, _ := f.cache(t, transport, rosterKeyID(f.key)) |
| 470 | if d := cache.Check(context.Background(), Origin, "A1", chooserPerson); d.State != roster.Unknown || d.Lease.Valid() { |
| 471 | t.Fatal("invalid signed page granted standing", kind) |
| 472 | } |
| 473 | }) |
| 474 | } |
| 475 | } |
| 476 | func TestRosterFrameworkMethodAndPathRefusalsNeverYieldPage(t *testing.T) { |
| 477 | f := newRosterIntegration(t, 1) |
| 478 | for _, method := range []string{"HEAD", "POST", "OPTIONS", "DELETE"} { |
| 479 | r, e := http.NewRequest(method, directoryOrigin+"/roster?team=A1&fetch="+rosterTestChallenge, nil) |
| 480 | if e != nil { |
| 481 | t.Fatal(e) |
| 482 | } |
| 483 | r.Header.Set("Authorization", "Bearer "+f.token) |
| 484 | res, e := f.transport.RoundTrip(r) |
| 485 | if e != nil { |
| 486 | t.Fatal(e) |
| 487 | } |
| 488 | res.Body.Close() |
| 489 | if res.StatusCode != 405 || res.Header.Get("Cache-Control") != "no-store, no-transform" || res.Header.Get("Content-Type") != "application/json" || res.Header.Get("Allow") != "GET" || res.Header.Get("Location") != "" { |
| 490 | t.Fatal(method, res.StatusCode, res.Header) |
| 491 | } |
| 492 | } |
| 493 | r, e := http.NewRequest("GET", directoryOrigin+"//roster?team=A1&fetch="+rosterTestChallenge, nil) |
| 494 | if e != nil { |
| 495 | t.Fatal(e) |
| 496 | } |
| 497 | r.Header.Set("Authorization", "Bearer "+f.token) |
| 498 | res, e := f.transport.RoundTrip(r) |
| 499 | if e != nil { |
| 500 | t.Fatal(e) |
| 501 | } |
| 502 | res.Body.Close() |
| 503 | if res.StatusCode == 200 { |
| 504 | t.Fatal("alternate path yielded roster") |
| 505 | } |
| 506 | calls := 0 |
| 507 | redirect := rosterTransport(func(r *http.Request) (*http.Response, error) { |
| 508 | calls++ |
| 509 | return &http.Response{StatusCode: 301, Header: http.Header{"Location": {directoryOrigin + "/roster"}, "Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(`{}`)), Request: r}, nil |
| 510 | }) |
| 511 | cache, _, _ := f.cache(t, redirect, rosterKeyID(f.key)) |
| 512 | if d := cache.Check(context.Background(), Origin, "A1", chooserPerson); d.State != roster.Unknown || calls != 1 { |
| 513 | t.Fatal("redirect followed", calls) |
| 514 | } |
| 515 | } |
| 516 | |
| 517 | func TestRosterHTTPTokenRotationOverlapBoundary(t *testing.T) { |
| 518 | f := newRosterIntegration(t, 1) |
| 519 | rotated := rosterAtom(15) |
| 520 | hash := sha256.Sum256([]byte(rotated)) |
| 521 | at := time.Now() |
| 522 | tx, e := f.f.p.directory.db.Writer().Begin() |
| 523 | if e != nil { |
| 524 | t.Fatal(e) |
| 525 | } |
| 526 | defer tx.Rollback() |
| 527 | if _, e = tx.Exec(`UPDATE roster_tokens SET slot='previous',issued_at=?,expires_at=? WHERE consumer_id='alpha-docs'`, at.Unix(), at.Unix()+300); e != nil { |
| 528 | t.Fatal(e) |
| 529 | } |
| 530 | if _, e = tx.Exec(`INSERT INTO roster_tokens VALUES(?,'alpha-docs','current',?,NULL)`, hash[:], at.Unix()); e != nil { |
| 531 | t.Fatal(e) |
| 532 | } |
| 533 | if e = tx.Commit(); e != nil { |
| 534 | t.Fatal(e) |
| 535 | } |
| 536 | clock := &rosterClock{at: at} |
| 537 | f.f.p.directory.directory.roster.now = func() time.Time { now, _ := clock.Now(); return now } |
| 538 | for _, token := range []string{f.token, rotated} { |
| 539 | status, _, _ := f.get(t, "team=A1&fetch="+rosterTestChallenge, token) |
| 540 | if status != 200 { |
| 541 | t.Fatal("rotation overlap refused", status) |
| 542 | } |
| 543 | } |
| 544 | clock.advance(300 * time.Second) |
| 545 | status, _, _ := f.get(t, "team=A1&fetch="+rosterTestChallenge, f.token) |
| 546 | if status != 401 { |
| 547 | t.Fatal("expired previous token accepted", status) |
| 548 | } |
| 549 | status, _, _ = f.get(t, "team=A1&fetch="+rosterTestChallenge, rotated) |
| 550 | if status != 200 { |
| 551 | t.Fatal("current token refused", status) |
| 552 | } |
| 553 | } |
| 554 | |