oficina / docs Public

Exercise Home account connection through the actual Docs router

Paul Campbell pushed by paul@keymail.dev 7d9c8c42ae1bc00c7fa4f628fe63dfde03fee193 parent 3741f6f
1 file changed, +487 −0
  • internal/docs/home_router_integration_test.go +487 −0

CI passed — run details

diff --git a/internal/docs/home_router_integration_test.go b/internal/docs/home_router_integration_test.go
new file mode 100644
index 0000000..dacda5a
--- /dev/null
+++ b/internal/docs/home_router_integration_test.go
@@ -0,0 +1,487 @@
+package docs
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "amadan.net/rastrillo/rastrillo/assertion"
+ assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
+ "github.com/carlosframework/rastrillo/sessions"
+)
+
+type routerHomeFixture struct {
+ app *App
+ key *assertioncrypto.Keypair
+ keyID string
+ sessions *sessions.Sessions
+}
+type routerBrowser struct {
+ f *routerHomeFixture
+ cookies map[string]*http.Cookie
+}
+
+func newRouterHomeFixture(t *testing.T) *routerHomeFixture {
+ t.Helper()
+ key, err := assertioncrypto.Generate()
+ if err != nil {
+ t.Fatal(err)
+ }
+ digest := sha256.Sum256(key.SignPub())
+ kid := hex.EncodeToString(digest[:])
+ manifest, err := json.Marshal(map[string]string{kid: base64.StdEncoding.EncodeToString(key.SignPub())})
+ if err != nil {
+ t.Fatal(err)
+ }
+ cfg := HomeConfig{Issuer: bridgeIssuer, ConsumerID: "router-docs", KeysBase64: base64.StdEncoding.EncodeToString(manifest), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{83}, 32))}
+ app, err := NewForTestWith(t, Config{Origin: bridgeOrigin, Home: &cfg, AllowedAddresses: "alice@example.com,bob@example.com", SampleDocuments: "0", Mailer: navigationMail{}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ sess, err := sessions.New(sessions.Config{DB: app.db.Writer(), Origin: bridgeOrigin})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return &routerHomeFixture{app: app, key: key, keyID: kid, sessions: sess}
+}
+func (f *routerHomeFixture) browser() *routerBrowser {
+ return &routerBrowser{f: f, cookies: map[string]*http.Cookie{}}
+}
+func (b *routerBrowser) request(method, path, body string) *http.Request {
+ r := httptest.NewRequest(method, bridgeOrigin+path, strings.NewReader(body))
+ for _, c := range b.cookies {
+ r.AddCookie(c)
+ }
+ if method == "POST" {
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ r.Header.Set("Origin", bridgeOrigin)
+ r.Header.Set("Sec-Fetch-Site", "same-origin")
+ }
+ return r
+}
+func (b *routerBrowser) serve(r *http.Request) *httptest.ResponseRecorder {
+ rec := httptest.NewRecorder()
+ b.f.app.Handler().ServeHTTP(rec, r)
+ for _, c := range rec.Result().Cookies() {
+ if c.MaxAge < 0 {
+ delete(b.cookies, c.Name)
+ } else {
+ b.cookies[c.Name] = c
+ }
+ }
+ return rec
+}
+func (b *routerBrowser) do(method, path, body string) *httptest.ResponseRecorder {
+ return b.serve(b.request(method, path, body))
+}
+func routerRedirect(t *testing.T, rec *httptest.ResponseRecorder, want string) string {
+ t.Helper()
+ location := rec.Header().Get("Location")
+ if rec.Code != 303 || (want != "" && location != want) {
+ t.Fatalf("router redirect status=%d target=%q want=%q", rec.Code, location, want)
+ }
+ return location
+}
+func (b *routerBrowser) localLink(t *testing.T, address string) string {
+ t.Helper()
+ before := b.f.app.mailRecorder.sends
+ rec := b.do("POST", "/signin", url.Values{"address": {address}, "force": {"1"}}.Encode())
+ if rec.Code != 303 || !strings.Contains(rec.Header().Get("Location"), "sent=1") {
+ t.Fatal("local mail begin failed", rec.Code)
+ }
+ b.f.app.mailRecorder.mu.Lock()
+ to, body, after := b.f.app.mailRecorder.lastTo, b.f.app.mailRecorder.lastBody, b.f.app.mailRecorder.sends
+ b.f.app.mailRecorder.mu.Unlock()
+ if after != before+1 || to != address {
+ t.Fatal("actual local mail not captured")
+ }
+ link := magicLinkPattern.FindString(body)
+ path, err := linkPath(link)
+ if err != nil || path == "" {
+ t.Fatal("captured verification link missing")
+ }
+ return path
+}
+func (b *routerBrowser) localSignin(t *testing.T, address string) {
+ t.Helper()
+ path := b.localLink(t, address)
+ rec := b.do("GET", path, "")
+ routerRedirect(t, rec, localLinkCompletePath)
+ routerRedirect(t, b.do("GET", localLinkCompletePath, ""), "/")
+ if _, ok := b.cookies[b.f.sessions.CookieName()]; !ok {
+ t.Fatal("local verification did not set real session")
+ }
+}
+func (b *routerBrowser) start(t *testing.T, path, maxAge string) string {
+ t.Helper()
+ rec := b.do("GET", path, "")
+ target, err := url.Parse(routerRedirect(t, rec, ""))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if target.Scheme+"://"+target.Host != bridgeIssuer || target.Path != "/authorize" || target.Query().Get("consumer") != "router-docs" || target.Query().Get("max_age") != maxAge || target.Query().Get("nonce") == "" {
+ t.Fatal("router selected wrong issuer/freshness")
+ }
+ return target.Query().Get("nonce")
+}
+func (f *routerHomeFixture) token(t *testing.T, nonce, subject string, auth time.Time, change func(*assertion.Claims)) string {
+ t.Helper()
+ id, err := assertion.RandomID()
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now().Unix()
+ c := assertion.Claims{Version: 1, Issuer: bridgeIssuer, Audience: bridgeOrigin, Subject: subject, KeyID: f.keyID, ID: id, Nonce: nonce, IssuedAt: now, ExpiresAt: now + 120, AuthTime: auth.Unix()}
+ if change != nil {
+ change(&c)
+ }
+ token, err := assertion.Sign(f.key, c)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return token
+}
+func (b *routerBrowser) callback(token, origin string) *httptest.ResponseRecorder {
+ // The issuer POST deliberately carries no browser cookies, as with Lax cookies.
+ r := httptest.NewRequest("POST", bridgeOrigin+"/auth/assertion", strings.NewReader(url.Values{"assertion": {token}}.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ r.Header.Set("Origin", origin)
+ r.Header.Set("Sec-Fetch-Site", "cross-site")
+ return b.serve(r)
+}
+func (b *routerBrowser) exchange(t *testing.T, nonce, subject string, auth time.Time) *httptest.ResponseRecorder {
+ t.Helper()
+ routerRedirect(t, b.callback(b.f.token(t, nonce, subject, auth, nil), bridgeIssuer), "/auth/finish")
+ return b.do("GET", "/auth/finish", "")
+}
+func (b *routerBrowser) linkProof(t *testing.T, subject, address string) {
+ t.Helper()
+ page := b.do("GET", "/account/home", "")
+ if page.Code != 200 || !strings.Contains(page.Body.String(), address) {
+ t.Fatal("explicit initiation page did not identify local account", page.Code)
+ }
+ next := routerRedirect(t, b.do("POST", "/account/home", ""), "")
+ nonce := b.start(t, next, "300")
+ routerRedirect(t, b.exchange(t, nonce, subject, time.Now().Add(-time.Minute)), "/account/home/confirm")
+ confirm := b.do("GET", "/account/home/confirm", "")
+ if confirm.Code != 200 || !strings.Contains(confirm.Body.String(), address) {
+ t.Fatal("confirmation did not identify local account", confirm.Code)
+ }
+}
+func routerRows(t *testing.T, f *routerHomeFixture, table string) int {
+ t.Helper()
+ var n int
+ if err := f.app.db.Writer().QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n); err != nil {
+ t.Fatal(err)
+ }
+ return n
+}
+
+func TestHomeRouterExistingAccountRoundTripPreservesOwnedAndSharedContent(t *testing.T) {
+ f := newRouterHomeFixture(t)
+ s := f.app.store
+ ctx := t.Context()
+ alice, err := s.EnsurePerson(ctx, "alice@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ bob, err := s.EnsurePerson(ctx, "bob@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ own, err := s.Create(ctx, alice, "Alice original")
+ if err != nil {
+ t.Fatal(err)
+ }
+ shared, err := s.Create(ctx, bob, "Bob shared")
+ if err != nil {
+ t.Fatal(err)
+ }
+ hidden, err := s.Create(ctx, bob, "Bob private")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = s.Grant(ctx, bob, shared.Sqid, "alice@example.com", RoleWriter); err != nil {
+ t.Fatal(err)
+ }
+ for _, doc := range []*Document{own, shared} {
+ owner := alice
+ address := "alice@example.com"
+ if doc.ID == shared.ID {
+ owner = bob
+ address = "bob@example.com"
+ }
+ if _, err = s.ApplyUpdate(ctx, doc, Actor{PersonID: owner, Subject: address}, ReadFixture(t, "hello")); err != nil {
+ t.Fatal(err)
+ }
+ }
+ var originalDocs []Document
+ var originalUpdates []DocUpdate
+ var originalGrants []Grant
+ if err = s.db.Order("id").Find(&originalDocs).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err = s.db.Order("id").Find(&originalUpdates).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err = s.db.Order("id").Find(&originalGrants).Error; err != nil {
+ t.Fatal(err)
+ }
+ b := f.browser()
+ resource := "/d/" + own.Sqid
+ signin := routerRedirect(t, b.do("GET", resource, ""), "")
+ parsed, err := url.Parse(signin)
+ if err != nil || parsed.Path != "/signin" || parsed.Query().Get("return_to") != resource {
+ t.Fatal("unsigned document lost original destination")
+ }
+ start := routerRedirect(t, b.do("GET", signin, ""), "")
+ nonce := b.start(t, start, "")
+ unknown := b.exchange(t, nonce, "opaque-alice", time.Now().Add(-time.Hour))
+ if unknown.Code != 403 || !strings.Contains(unknown.Body.String(), "/signin/local") {
+ t.Fatal("unknown Home subject did not show local guidance", unknown.Code)
+ }
+ if routerRows(t, f, "people") != 2 || routerRows(t, f, "documents") != 3 || routerRows(t, f, "docs_identity_links") != 0 || b.cookies[f.sessions.CookieName()] != nil {
+ t.Fatal("unknown Home subject created an account or session")
+ }
+ b.localSignin(t, "alice@example.com")
+ localCookie := b.cookies[f.sessions.CookieName()].Value
+ b.linkProof(t, "opaque-alice", "alice@example.com")
+ if b.cookies[f.sessions.CookieName()].Value != localCookie || routerRows(t, f, "docs_identity_links") != 0 {
+ t.Fatal("proof replaced local session or linked before confirmation")
+ }
+ routerRedirect(t, b.do("POST", "/account/home/confirm", ""), resource)
+ if b.cookies[f.sessions.CookieName()].Value != localCookie || routerRows(t, f, "docs_identity_links") != 1 {
+ t.Fatal("confirmation failed local session retention")
+ }
+ if rec := b.do("GET", resource, ""); rec.Code != 200 {
+ t.Fatal("linked local account cannot read original document", rec.Code)
+ }
+ routerRedirect(t, b.do("POST", "/signout", ""), "/signin")
+ if b.cookies[f.sessions.CookieName()] != nil {
+ t.Fatal("actual signout retained session cookie")
+ }
+ oldRequest := httptest.NewRequest("GET", bridgeOrigin+resource, nil)
+ oldRequest.AddCookie(&http.Cookie{Name: f.sessions.CookieName(), Value: localCookie})
+ oldRec := httptest.NewRecorder()
+ f.app.Handler().ServeHTTP(oldRec, oldRequest)
+ if oldRec.Code != 303 {
+ t.Fatal("actual signout did not revoke old session")
+ }
+ signin = routerRedirect(t, b.do("GET", resource, ""), "")
+ start = routerRedirect(t, b.do("GET", signin, ""), "")
+ nonce = b.start(t, start, "")
+ originalAuth := time.Now().Add(-2 * time.Hour).Truncate(time.Second)
+ routerRedirect(t, b.exchange(t, nonce, "opaque-alice", originalAuth), resource)
+ session, ok := f.sessions.From(b.request("GET", resource, ""))
+ if !ok || session.Method != homeAssertionMethod || session.Subject != "opaque-alice" || !session.AuthTime.Equal(originalAuth) {
+ t.Fatal("ordinary Home session lost actual authentication time")
+ }
+ for _, doc := range []*Document{own, shared} {
+ rec := b.do("GET", "/d/"+doc.Sqid, "")
+ if rec.Code != 200 {
+ t.Fatal("Home session lost owned/shared access", rec.Code)
+ }
+ }
+ if rec := b.do("GET", "/d/"+hidden.Sqid, ""); rec.Code != 404 {
+ t.Fatal("Home link widened another owner's private access", rec.Code)
+ }
+ var afterDocs []Document
+ var afterUpdates []DocUpdate
+ var afterGrants []Grant
+ if err = s.db.Order("id").Find(&afterDocs).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err = s.db.Order("id").Find(&afterUpdates).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err = s.db.Order("id").Find(&afterGrants).Error; err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(originalDocs, afterDocs) || !reflect.DeepEqual(originalUpdates, afterUpdates) || !reflect.DeepEqual(originalGrants, afterGrants) || routerRows(t, f, "people") != 2 {
+ t.Fatal("account link changed owners/grants/content encoding or bytes")
+ }
+ // A different current local account cannot be silently replaced by Alice's SSO.
+ other := f.browser()
+ other.localSignin(t, "bob@example.com")
+ before := other.cookies[f.sessions.CookieName()].Value
+ nonce = other.start(t, "/auth/start?return_to=%2F", "")
+ if rec := other.exchange(t, nonce, "opaque-alice", time.Now()); rec.Code != 403 {
+ t.Fatal("different local account was replaced", rec.Code)
+ }
+ if other.cookies[f.sessions.CookieName()].Value != before {
+ t.Fatal("conflict rotated local account")
+ }
+}
+
+func TestHomeRouterCSRFGuardsBothLinkMutations(t *testing.T) {
+ f := newRouterHomeFixture(t)
+ b := f.browser()
+ b.localSignin(t, "alice@example.com")
+ if rec := b.do("GET", "/account/home", ""); rec.Code != 200 {
+ t.Fatal("missing local initiation page")
+ }
+ for _, fetch := range []string{"cross-site", ""} {
+ r := b.request("POST", "/account/home", "")
+ r.Header.Set("Origin", "https://evil.example")
+ r.Header.Set("Sec-Fetch-Site", fetch)
+ rec := b.serve(r)
+ if rec.Code != 403 || routerRows(t, f, "docs_link_flows") != 0 {
+ t.Fatal("actual router permitted foreign link initiation", rec.Code)
+ }
+ }
+ b.linkProof(t, "opaque-alice", "alice@example.com")
+ for _, fetch := range []string{"cross-site", ""} {
+ r := b.request("POST", "/account/home/confirm", "")
+ r.Header.Set("Origin", "https://evil.example")
+ r.Header.Set("Sec-Fetch-Site", fetch)
+ rec := b.serve(r)
+ if rec.Code != 403 || routerRows(t, f, "docs_identity_links") != 0 || routerRows(t, f, "docs_link_flows") != 1 {
+ t.Fatal("actual router permitted foreign confirmation or consumed valid proof", rec.Code)
+ }
+ }
+ routerRedirect(t, b.do("POST", "/account/home/confirm", ""), "/")
+ if routerRows(t, f, "docs_identity_links") != 1 {
+ t.Fatal("valid same-origin confirmation failed after refusal")
+ }
+}
+
+func TestHomeRouterRejectsDuplicateCookiesBeforeActualEmailVerification(t *testing.T) {
+ f := newRouterHomeFixture(t)
+ b := f.browser()
+ b.localSignin(t, "alice@example.com")
+ old := b.cookies[f.sessions.CookieName()]
+ path := b.localLink(t, "alice@example.com")
+ for _, name := range []string{f.sessions.CookieName(), "__Host-rastrillo_pending"} {
+ r := b.request("GET", path, "")
+ if name == f.sessions.CookieName() {
+ r.AddCookie(old)
+ } else {
+ r.AddCookie(&http.Cookie{Name: name, Value: "one"})
+ r.AddCookie(&http.Cookie{Name: name, Value: "two"})
+ }
+ rec := b.serve(r)
+ if rec.Code != 400 {
+ t.Fatal("duplicate cookie reached actual Verify", name, rec.Code)
+ }
+ if b.cookies[f.sessions.CookieName()].Value != old.Value {
+ t.Fatal("refused duplicate rotated browser session")
+ }
+ if _, ok := f.sessions.From(b.request("GET", "/", "")); !ok {
+ t.Fatal("refused duplicate revoked actual session")
+ }
+ }
+ // Both refusals happened before CompleteMagicLink consumed the token.
+ routerRedirect(t, b.do("GET", path, ""), localLinkCompletePath)
+ if b.cookies[f.sessions.CookieName()].Value == old.Value {
+ t.Fatal("clean real Verify did not rotate cookie")
+ }
+ r := httptest.NewRequest("GET", bridgeOrigin+"/", nil)
+ r.AddCookie(old)
+ if _, ok := f.sessions.From(r); ok {
+ t.Fatal("successful real Verify did not revoke old session")
+ }
+ routerRedirect(t, b.do("GET", localLinkCompletePath, ""), "/")
+}
+
+func TestHomeRouterStaleLocalGestureRestartsWithoutResumingProof(t *testing.T) {
+ f := newRouterHomeFixture(t)
+ b := f.browser()
+ b.localSignin(t, "alice@example.com")
+ b.linkProof(t, "opaque-alice", "alice@example.com")
+ oldProof := b.cookies[homeProofCookie]
+ oldSession := b.cookies[f.sessions.CookieName()]
+ if oldProof == nil || routerRows(t, f, "docs_link_flows") != 1 {
+ t.Fatal("pending proof fixture missing")
+ }
+ // Move only the actual stored local verification time into the past. The
+ // old pending proof remains present so its post-rotation refusal is exercised.
+ if _, err := f.app.db.Writer().Exec(`UPDATE sessions SET created_at=? WHERE token_hash=?`, time.Now().Add(-6*time.Minute).UTC().Format(time.RFC3339), sessions.HashToken(oldSession.Value)); err != nil {
+ t.Fatal(err)
+ }
+ routerRedirect(t, b.do("GET", "/account/home", ""), "/signin/local")
+ if b.cookies[localLinkNavigationCookie] == nil || b.cookies[homeInitCookie] != nil || b.cookies[homeProofCookie] != nil {
+ t.Fatal("stale local restart retained link browser proof")
+ }
+ if rec := b.do("GET", "/signin/local", ""); rec.Code != 200 {
+ t.Fatal("signed-in stale visitor could not see local signin")
+ }
+ path := b.localLink(t, "alice@example.com")
+ routerRedirect(t, b.do("GET", path, ""), localLinkCompletePath)
+ routerRedirect(t, b.do("GET", localLinkCompletePath, ""), "/account/home")
+ if b.cookies[f.sessions.CookieName()].Value == oldSession.Value || b.cookies[homeProofCookie] != nil || b.cookies[homeInitCookie] != nil || routerRows(t, f, "docs_identity_links") != 0 {
+ t.Fatal("navigation minted or resumed link authority")
+ }
+ if rec := b.do("GET", "/account/home", ""); rec.Code != 200 {
+ t.Fatal("fresh local gesture did not reach initiation page")
+ }
+ // A copied old proof cannot attach to the freshly rotated local session.
+ b.cookies[homeProofCookie] = oldProof
+ if rec := b.do("POST", "/account/home/confirm", ""); rec.Code != 403 {
+ t.Fatal("old proof resumed after actual session rotation", rec.Code)
+ }
+ if routerRows(t, f, "docs_identity_links") != 0 || routerRows(t, f, "docs_link_flows") != 0 {
+ t.Fatal("old proof failure created link or retained reusable proof")
+ }
+ oldRequest := httptest.NewRequest("GET", bridgeOrigin+"/", nil)
+ oldRequest.AddCookie(oldSession)
+ if _, ok := f.sessions.From(oldRequest); ok {
+ t.Fatal("old local browser token survived reverification")
+ }
+ // Only this new explicit POST creates a new initiation, not the email or GET.
+ routerRedirect(t, b.do("POST", "/account/home", ""), "/auth/start?return_to=%2Faccount%2Fhome%2Fconfirm")
+ if routerRows(t, f, "docs_link_flows") != 1 || routerRows(t, f, "docs_identity_links") != 0 || b.cookies[homeProofCookie] != nil {
+ t.Fatal("new explicit initiation state wrong")
+ }
+}
+
+func TestHomeRouterCallbackChecksExactOriginIssuerAndAudience(t *testing.T) {
+ f := newRouterHomeFixture(t)
+ b := f.browser()
+ nonce := b.start(t, "/auth/start?return_to=%2F", "")
+ for _, tc := range []struct {
+ name, origin string
+ change func(*assertion.Claims)
+ }{
+ {"wrong origin", "https://evil.example", nil},
+ {"missing origin", "", nil},
+ {"wrong signed issuer", bridgeIssuer, func(c *assertion.Claims) { c.Issuer = "https://other-id.example" }},
+ {"wrong signed audience", bridgeIssuer, func(c *assertion.Claims) { c.Audience = "https://other.docs.oficina.works" }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ rec := b.callback(f.token(t, nonce, "opaque", time.Now(), tc.change), tc.origin)
+ if rec.Code != 400 || routerRows(t, f, "oficina_handoff_inbox") != 0 || b.cookies[f.sessions.CookieName()] != nil {
+ t.Fatal("invalid callback created inbox/session", rec.Code)
+ }
+ })
+ }
+ r := b.request("GET", "/auth/start?return_to=%2F", "")
+ r.Host = "other.docs.oficina.works"
+ if rec := b.serve(r); rec.Code != 404 {
+ t.Fatal("router accepted another team host", rec.Code)
+ }
+ // A valid issuer callback still crosses the outer CSRF boundary and reaches
+ // the shared consumer. Duplicate local cookies cannot poison that callback.
+ token := f.token(t, nonce, "opaque", time.Now(), nil)
+ r = httptest.NewRequest("POST", bridgeOrigin+"/auth/assertion", strings.NewReader(url.Values{"assertion": {token}}.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ r.Header.Set("Origin", bridgeIssuer)
+ r.Header.Set("Sec-Fetch-Site", "cross-site")
+ r.AddCookie(&http.Cookie{Name: f.sessions.CookieName(), Value: "bad"})
+ r.AddCookie(&http.Cookie{Name: f.sessions.CookieName(), Value: "bad"})
+ routerRedirect(t, b.serve(r), "/auth/finish")
+ if rec := b.do("GET", "/auth/finish", ""); rec.Code != 403 || !strings.Contains(rec.Body.String(), "/signin/local") {
+ t.Fatal("unknown verified identity not guided after valid callback")
+ }
+ if routerRows(t, f, "people") != 0 || routerRows(t, f, "docs_identity_links") != 0 {
+ t.Fatal("unknown callback created person/link")
+ }
+}