package roster import ( "bytes" "context" "encoding/base64" "net/http" "strconv" "testing" "time" family "amadan.net/rastrillo/rastrillo/crypto" "github.com/go-json-experiment/json" ) func TestCanonicalWireAndDecodedSignature(t *testing.T) { f := setup(t) if CursorHash("") != "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU" { t.Fatal("page zero hash") } for _, s := range []string{"", fixed(1) + "=", fixed(1) + "\n", fixed(1)[:42], fixed(1) + "a"} { if ValidChallenge(s) { t.Fatal("noncanonical challenge") } } req, _ := http.NewRequest("GET", "https://home.example/roster?fetch="+fixed(1), nil) p := f.page(req, 1, []MemberEntry{}, true, "") raw, e := SignPage(f.key, "roster1", p) if e != nil { t.Fatal(e) } var env envelope if strict(raw, &env) != nil { t.Fatal("envelope") } payload, _ := canonical(env.Payload, -1) sig, _ := canonical(env.Signature, 64) if !family.Verify(f.key.SignPub(), PageContext, payload, sig) || family.Verify(f.key.SignPub(), PageContext, []byte(env.Payload), sig) { t.Fatal("wrong signed bytes") } if !bytes.Contains(payload, []byte(`"members":[]`)) || bytes.Contains(payload, []byte(`"cursor"`)) { t.Fatalf("empty final shape %s", payload) } } func TestProvisioningStrictBindingsAndKeys(t *testing.T) { for _, name := range []string{"issuer", "audience", "directory", "team", "version", "sequence", "epoch", "generation", "operation", "key", "duplicate-key", "owner", "unknown-field", "duplicate-field", "wrong-context", "unsigned", "case", "number"} { t.Run(name, func(t *testing.T) { f := setup(t) p := f.record p.Keys = append([]Key(nil), p.Keys...) switch name { case "issuer": p.Bindings.Issuer = "https://other.example" case "audience": p.Bindings.Audience = "https://other.example" case "directory": p.Bindings.Directory = "https://other.example" case "team": p.Bindings.Team = "TeamB" case "version": p.Version = 2 case "sequence": p.Sequence = 0 case "epoch": p.MinEpoch = 0 case "generation": p.Generation += "=" case "operation": p.Operation = "unsafe/value" case "key": p.Keys[0].Public = fixed(1) case "duplicate-key": p.Keys = append(p.Keys, p.Keys[0]) case "owner": p.OwnerConfirmation = "unsafe/value" } raw, _ := SignProvisioning(f.op, "operator", p) if name == "unknown-field" || name == "duplicate-field" || name == "case" || name == "number" { b, _ := json.Marshal(p) switch name { case "unknown-field": b = append(b[:len(b)-1], []byte(`,"surprise":true}`)...) case "duplicate-field": b = append(b[:len(b)-1], []byte(`,"version":1}`)...) case "case": b = bytes.Replace(b, []byte(`"version"`), []byte(`"Version"`), 1) case "number": b = bytes.Replace(b, []byte(`"version":1`), []byte(`"version":1.0`), 1) } sig, _ := family.Sign(f.op, ProvisioningContext, b) raw, _ = json.Marshal(envelope{1, "operator", base64.RawURLEncoding.EncodeToString(b), base64.RawURLEncoding.EncodeToString(sig)}) } if name == "wrong-context" { raw, _ = encode(f.op, "operator", PageContext, p) } if name == "unsigned" { raw, _ = json.Marshal(p) } if _, e := VerifyProvisioning(raw, []Key{{"operator", base64.RawURLEncoding.EncodeToString(f.op.SignPub())}}, f.record.Bindings); e == nil { t.Fatal("bad record accepted") } }) } } func TestRecordOperationAndRestartRollback(t *testing.T) { f := setup(t) c := f.cache() n := f.record n.Sequence = 2 n.Operation = "op2" if e := c.UpdateRecord(context.Background(), f.verify(n)); e != nil { t.Fatal(e) } if _, e := New(context.Background(), f.cfg); e == nil { t.Fatal("old signed record on restart accepted") } n.Sequence = 3 n.Operation = "op1" if c.UpdateRecord(context.Background(), f.verify(n)) == nil { t.Fatal("operation reused") } f.cfg.Record = VerifiedRecord{} if _, e := New(context.Background(), f.cfg); e == nil { t.Fatal("zero verified record accepted") } } func TestBoundedCompletePagination(t *testing.T) { for _, count := range []int{101, 10000, 10001} { t.Run(strconv.Itoa(count), func(t *testing.T) { f := setup(t) f.set(func(r *http.Request) (*http.Response, error) { index := 0 if c := r.URL.Query().Get("cursor"); c != "" { b, _ := base64.RawURLEncoding.DecodeString(c) index, _ = strconv.Atoi(string(b)) } end := (index + 1) * 100 if end > count { end = count } m := make([]MemberEntry, end-index*100) for i := range m { m[i] = MemberEntry{person(index*100 + i + 1), "member"} } cursor := base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(index + 1))) p := f.page(r, 3, m, end == count, cursor) p.Index = &index return f.signed(p, 200), nil }) c := f.cache() d := f.check(c, 1) if count <= MemberLimit { state(t, d, Member) state(t, f.check(c, count), Member) } else { state(t, d, Unknown) } }) } } func TestPaginationRefusals(t *testing.T) { for _, name := range []string{"epoch", "order", "repeat-cursor", "short-page", "conflict", "generation-later"} { t.Run(name, func(t *testing.T) { f := setup(t) first := make([]MemberEntry, 100) for i := range first { first[i] = MemberEntry{person(i + 1), "member"} } f.set(func(r *http.Request) (*http.Response, error) { if r.URL.Query().Get("cursor") == "" { m := first if name == "short-page" { m = m[:99] } return f.response(r, 2, m, false, fixed(4)), nil } p := f.page(r, 2, []MemberEntry{{person(101), "member"}}, true, "") status := 200 switch name { case "epoch": p.Epoch = 3 case "order": (*p.Members)[0].Subject = person(1) case "repeat-cursor": m := make([]MemberEntry, 100) for i := range m { m[i] = MemberEntry{person(i + 101), "member"} } p.Members = &m p.Complete = ptr(false) p.Cursor = ptr(fixed(4)) case "conflict": p.Type = "stale-cursor" p.Index = nil p.Members = nil p.Complete = nil status = 409 case "generation-later": p.Generation = fixed(2) } return f.signed(p, status), nil }) c := f.cache() if c.Refresh(context.Background()) == nil { t.Fatal("bad pagination accepted") } state(t, f.check(c, 1), Unknown) }) } } func TestSignedConflictGenerationFence(t *testing.T) { f := setup(t) c := f.cache() d := f.check(c, 1) f.clock.step(30 * time.Second) f.set(func(r *http.Request) (*http.Response, error) { p := f.page(r, 2, nil, false, "") p.Type = "stale-cursor" p.Index = nil p.Members = nil p.Complete = nil p.Cursor = nil p.Generation = fixed(2) return f.signed(p, 409), nil }) if c.Refresh(context.Background()) == nil { t.Fatal("conflict published") } if d.Lease.Valid() { t.Fatal("conflict generation did not fence") } } func TestEvidenceTamperAndPurpose(t *testing.T) { f := setup(t) c := f.cache() state(t, f.check(c, 1), Member) raw, _ := f.disk.Load(context.Background()) raw[len(raw)-1] ^= 1 _ = f.disk.Save(context.Background(), raw) if _, e := New(context.Background(), f.cfg); e == nil { t.Fatal("tampered durable evidence accepted") } f = setup(t) c = f.cache() state(t, f.check(c, 1), Member) p := f.record p.Bindings.Audience = "https://other.example" f.record.Bindings = p.Bindings f.cfg.Record = f.verify(p) if _, e := New(context.Background(), f.cfg); e == nil { t.Fatal("cross-origin durable evidence accepted") } } func TestElapsedFetchIncludedInLease(t *testing.T) { f := setup(t) f.set(func(r *http.Request) (*http.Response, error) { f.clock.step(4 * time.Second) return f.response(r, 1, []MemberEntry{{person(1), "member"}}, true, ""), nil }) c := f.cache() d := f.check(c, 1) state(t, d, Member) if d.Lease.Remaining() != 56*time.Second { t.Fatalf("remaining %v", d.Lease.Remaining()) } f.clock.step(56 * time.Second) if d.Lease.Valid() { t.Fatal("transit extended age") } } func TestActualTransportDeadline(t *testing.T) { f := setup(t) f.set(func(r *http.Request) (*http.Response, error) { <-r.Context().Done(); return nil, r.Context().Err() }) c := f.cache() start := time.Now() if c.Refresh(context.Background()) != ErrUnavailable { t.Fatal("deadline error") } elapsed := time.Since(start) if elapsed < FetchTimeout || elapsed > FetchTimeout+time.Second { t.Fatalf("refresh budget %v", elapsed) } } func TestNoMissingRequiredOrAlternateBody(t *testing.T) { f := setup(t) c := f.cache() f.set(func(r *http.Request) (*http.Response, error) { res := f.response(r, 1, []MemberEntry{}, true, "") res.Header.Set("Content-Type", "application/json; charset=utf-8") return res, nil }) if c.Refresh(context.Background()) == nil { t.Fatal("alternate type accepted") } }