| new file mode 100644 |
| index 0000000..8b491f4 |
| --- /dev/null |
| +++ b/internal/docs/home_exchange_test.go |
| @@ -0,0 +1,437 @@ |
| +package docs |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/oficina/home/handoff" |
| + "amadan.net/rastrillo/rastrillo/assertion" |
| + assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" |
| + "github.com/carlosframework/docs/internal/docs/linkstore" |
| + rauth "github.com/carlosframework/rastrillo/auth" |
| + "github.com/carlosframework/rastrillo/csrf" |
| + "github.com/carlosframework/rastrillo/migrate" |
| + "github.com/carlosframework/rastrillo/sessions" |
| +) |
| + |
| +const bridgeOrigin = "https://blue.docs.oficina.works" |
| +const bridgeIssuer = "https://id.oficina.works" |
| + |
| +type bridgeFixture struct { |
| + b *homeBridge |
| + app *App |
| + key *assertioncrypto.Keypair |
| +} |
| + |
| +func newBridgeFixture(t *testing.T) *bridgeFixture { |
| + t.Helper() |
| + app, err := NewForTestWith(t, Config{Origin: bridgeOrigin, SampleDocuments: "false"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + schema := migrate.Merge(linkstore.Schema, migrate.MustFromFS(handoff.Migrations, "oficina_handoff")) |
| + if _, err := migrate.Apply(context.Background(), app.db, schema); err != nil { |
| + t.Fatal(err) |
| + } |
| + key, err := assertioncrypto.Generate() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + settings := &homeSettings{issuer: bridgeIssuer, consumerID: "blue-docs", keys: map[string][]byte{"key": key.SignPub()}, dek: bytes.Repeat([]byte{41}, 32)} |
| + auth, err := rauth.New(rauth.Config{DB: app.db.Writer(), Origin: bridgeOrigin, InstanceKey: devInstanceKey, Logger: app.logger}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b, err := newHomeBridge(settings, bridgeOrigin, app.db.Writer(), auth, app.handlers.render, app.store) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return &bridgeFixture{b: b, app: app, key: key} |
| +} |
| +func bridgeRequest(method, path string, cookies []*http.Cookie) *http.Request { |
| + r := httptest.NewRequest(method, bridgeOrigin+path, nil) |
| + for _, cookie := range cookies { |
| + r.AddCookie(cookie) |
| + } |
| + return r |
| +} |
| +func bridgeCookie(t *testing.T, w *httptest.ResponseRecorder, name string) *http.Cookie { |
| + t.Helper() |
| + for _, cookie := range w.Result().Cookies() { |
| + if cookie.Name == name && cookie.MaxAge >= 0 { |
| + if !cookie.Secure || !cookie.HttpOnly || cookie.Domain != "" || cookie.Path != "/" || cookie.SameSite != http.SameSiteLaxMode { |
| + t.Fatal("unsafe cookie attributes") |
| + } |
| + return cookie |
| + } |
| + } |
| + t.Fatalf("missing cookie %s; status=%d", name, w.Code) |
| + return nil |
| +} |
| +func bridgeNoSession(t *testing.T, b *homeBridge, w *httptest.ResponseRecorder) { |
| + t.Helper() |
| + for _, c := range w.Result().Cookies() { |
| + if c.Name == b.sessionCookie { |
| + t.Fatal("unexpected app session mutation") |
| + } |
| + } |
| +} |
| +func (f *bridgeFixture) local(t *testing.T, address, method string, authTime time.Time) *http.Cookie { |
| + t.Helper() |
| + if _, err := f.app.store.EnsurePerson(context.Background(), address); err != nil { |
| + t.Fatal(err) |
| + } |
| + token, err := f.b.sessions.Mint(sessions.Session{Subject: address, Method: method, AuthTime: authTime}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return &http.Cookie{Name: f.b.sessionCookie, Value: token} |
| +} |
| +func (f *bridgeFixture) start(t *testing.T, cookies []*http.Cookie, path string) (*http.Cookie, string) { |
| + t.Helper() |
| + w := httptest.NewRecorder() |
| + f.b.Start(w, bridgeRequest(http.MethodGet, "/auth/start?return_to="+url.QueryEscape(path), cookies)) |
| + if w.Code != 303 { |
| + t.Fatalf("start=%d body=%s", w.Code, w.Body.String()) |
| + } |
| + target, err := url.Parse(w.Header().Get("Location")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return bridgeCookie(t, w, "__Host-oficina-assertion-pending"), target.Query().Get("nonce") |
| +} |
| +func (f *bridgeFixture) callback(t *testing.T, subject, nonce string, authTime time.Time) *http.Cookie { |
| + t.Helper() |
| + now := time.Now().Unix() |
| + id, err := assertion.RandomID() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + token, err := assertion.Sign(f.key, assertion.Claims{Version: 1, Issuer: bridgeIssuer, Audience: bridgeOrigin, Subject: subject, KeyID: "key", ID: id, Nonce: nonce, IssuedAt: now, ExpiresAt: now + 90, AuthTime: authTime.Unix()}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + form := url.Values{"assertion": {token}} |
| + r := httptest.NewRequest(http.MethodPost, bridgeOrigin+"/auth/assertion", strings.NewReader(form.Encode())) |
| + r.Header.Set("Origin", bridgeIssuer) |
| + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + w := httptest.NewRecorder() |
| + f.b.Callback(w, r) |
| + if w.Code != 303 { |
| + t.Fatalf("callback=%d", w.Code) |
| + } |
| + bridgeNoSession(t, f.b, w) |
| + return bridgeCookie(t, w, "__Host-oficina-assertion-inbox") |
| +} |
| +func (f *bridgeFixture) linkProof(t *testing.T, local *http.Cookie, subject string) (*http.Cookie, []*http.Cookie) { |
| + t.Helper() |
| + w := httptest.NewRecorder() |
| + f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{local})) |
| + if w.Code != 303 { |
| + t.Fatalf("link start=%d", w.Code) |
| + } |
| + init := bridgeCookie(t, w, homeInitCookie) |
| + cookies := []*http.Cookie{local, init} |
| + start := httptest.NewRecorder() |
| + f.b.Start(start, bridgeRequest(http.MethodGet, w.Header().Get("Location"), cookies)) |
| + if start.Code != 303 { |
| + t.Fatalf("fresh start=%d", start.Code) |
| + } |
| + target, _ := url.Parse(start.Header().Get("Location")) |
| + if target.Query().Get("max_age") != "300" { |
| + t.Fatal("link exchange did not request fresh authentication") |
| + } |
| + pending := bridgeCookie(t, start, "__Host-oficina-assertion-pending") |
| + inbox := f.callback(t, subject, target.Query().Get("nonce"), time.Now().Add(-30*time.Second)) |
| + finish := httptest.NewRecorder() |
| + f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", append(cookies, pending, inbox))) |
| + if finish.Code != 303 || finish.Header().Get("Location") != "/account/home/confirm" { |
| + t.Fatalf("link finish=%d location=%q", finish.Code, finish.Header().Get("Location")) |
| + } |
| + bridgeNoSession(t, f.b, finish) |
| + return bridgeCookie(t, finish, homeProofCookie), []*http.Cookie{local} |
| +} |
| +func (f *bridgeFixture) link(t *testing.T, local *http.Cookie, subject string) { |
| + t.Helper() |
| + proof, cookies := f.linkProof(t, local, subject) |
| + cookies = append(cookies, proof) |
| + page := httptest.NewRecorder() |
| + f.b.ConfirmPage(page, bridgeRequest(http.MethodGet, "/account/home/confirm", cookies)) |
| + if page.Code != 200 { |
| + t.Fatalf("confirm page=%d", page.Code) |
| + } |
| + w := httptest.NewRecorder() |
| + f.b.ConfirmPost(w, bridgeRequest(http.MethodPost, "/account/home/confirm", cookies)) |
| + if w.Code != 303 || w.Header().Get("Location") != "/" { |
| + t.Fatalf("confirm=%d", w.Code) |
| + } |
| + bridgeNoSession(t, f.b, w) |
| +} |
| + |
| +func TestHomeBridgeLinkThenOrdinarySignInPreservesIdentity(t *testing.T) { |
| + f := newBridgeFixture(t) |
| + local := f.local(t, "one@example.com", "magiclink", time.Time{}) |
| + person, err := f.app.store.EnsurePerson(context.Background(), "one@example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + doc, err := f.app.store.Create(context.Background(), person, "Existing document") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.link(t, local, "opaque-subject-one") |
| + if _, ok := f.b.sessions.From(bridgeRequest(http.MethodGet, "/", []*http.Cookie{local})); !ok { |
| + t.Fatal("linking replaced local session") |
| + } |
| + pending, nonce := f.start(t, nil, "/d/"+doc.Sqid) |
| + original := time.Now().Add(-time.Hour).Truncate(time.Second) |
| + inbox := f.callback(t, "opaque-subject-one", nonce, original) |
| + finish := httptest.NewRecorder() |
| + f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{pending, inbox})) |
| + if finish.Code != 303 || finish.Header().Get("Location") != "/d/"+doc.Sqid { |
| + t.Fatalf("ordinary finish=%d %q", finish.Code, finish.Header().Get("Location")) |
| + } |
| + cookie := bridgeCookie(t, finish, f.b.sessionCookie) |
| + r := bridgeRequest(http.MethodGet, "/", []*http.Cookie{cookie}) |
| + session, ok := f.b.sessions.From(r) |
| + if !ok || session.Method != homeAssertionMethod || session.Subject != "opaque-subject-one" || !session.AuthTime.Equal(original) { |
| + t.Fatalf("wrong Home session: %+v", session) |
| + } |
| + identity, err := f.b.resolver.resolve(sessions.WithSession(r, session)) |
| + if err != nil || identity.Actor.PersonID != person || identity.Actor.Subject != "one@example.com" { |
| + t.Fatalf("identity=%+v %v", identity, err) |
| + } |
| + if _, err := f.app.store.Get(context.Background(), person, doc.Sqid); err != nil { |
| + t.Fatal("existing ownership changed", err) |
| + } |
| + var count int |
| + if err := f.app.db.Writer().QueryRow("SELECT COUNT(*) FROM people").Scan(&count); err != nil || count != 1 { |
| + t.Fatal("SSO created an extra person", count, err) |
| + } |
| + replay := httptest.NewRecorder() |
| + f.b.Finish(replay, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{pending, inbox})) |
| + if replay.Code == 303 { |
| + t.Fatal("assertion replay minted session") |
| + } |
| + bridgeNoSession(t, f.b, replay) |
| +} |
| + |
| +func TestHomeBridgeUnknownAndDifferentCurrentAccountNeverMint(t *testing.T) { |
| + f := newBridgeFixture(t) |
| + local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| + other := f.local(t, "two@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| + f.link(t, local, "linked-subject") |
| + for _, tc := range []struct { |
| + name, subject string |
| + cookies []*http.Cookie |
| + }{{"unknown", "unknown-private-subject", nil}, {"different-local", "linked-subject", []*http.Cookie{other}}} { |
| + t.Run(tc.name, func(t *testing.T) { |
| + pending, n := f.start(t, tc.cookies, "/") |
| + inbox := f.callback(t, tc.subject, n, time.Now().Add(-time.Minute)) |
| + w := httptest.NewRecorder() |
| + f.b.Finish(w, bridgeRequest(http.MethodGet, "/auth/finish", append(tc.cookies, pending, inbox))) |
| + if w.Code != 403 { |
| + t.Fatalf("rejected exchange=%d", w.Code) |
| + } |
| + bridgeNoSession(t, f.b, w) |
| + if tc.name == "unknown" && !strings.Contains(w.Body.String(), "/signin/local") { |
| + t.Fatal("unknown identity lacks local continuation") |
| + } |
| + if strings.Contains(w.Body.String(), tc.subject) { |
| + t.Fatal("opaque identity leaked into response") |
| + } |
| + retry := httptest.NewRecorder() |
| + f.b.Finish(retry, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{pending, inbox})) |
| + if retry.Code == 303 { |
| + t.Fatal("rejected completion remained reusable") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestHomeBridgeChangedLocalSessionAndProofReplayRefused(t *testing.T) { |
| + f := newBridgeFixture(t) |
| + authTime := time.Now().Add(-time.Minute) |
| + original := f.local(t, "one@example.com", "magiclink", authTime) |
| + proof, cookies := f.linkProof(t, original, "opaque") |
| + rotated := f.local(t, "one@example.com", "magiclink", authTime) |
| + w := httptest.NewRecorder() |
| + f.b.ConfirmPost(w, bridgeRequest(http.MethodPost, "/account/home/confirm", []*http.Cookie{rotated, proof})) |
| + if w.Code != 403 { |
| + t.Fatal("different session linked proof", w.Code) |
| + } |
| + retry := httptest.NewRecorder() |
| + f.b.ConfirmPost(retry, bridgeRequest(http.MethodPost, "/account/home/confirm", append(cookies, proof))) |
| + if retry.Code != 403 { |
| + t.Fatal("rejected proof reusable", retry.Code) |
| + } |
| +} |
| + |
| +func TestHomeBridgeStrictCookiesAndStaleLocalRefusal(t *testing.T) { |
| + f := newBridgeFixture(t) |
| + local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| + for _, cookies := range [][]*http.Cookie{{{Name: homeInitCookie, Value: "bad"}}, {{Name: homeInitCookie, Value: strings.Repeat("A", 43)}, {Name: homeInitCookie, Value: strings.Repeat("A", 43)}}, {local, local}, {{Name: f.b.sessionCookie, Value: strings.Repeat("A", 43)}}} { |
| + w := httptest.NewRecorder() |
| + f.b.Start(w, bridgeRequest(http.MethodGet, "/auth/start", cookies)) |
| + if w.Code == 303 { |
| + t.Fatal("malformed/duplicate/unresolved cookie downgraded into ordinary sign-in") |
| + } |
| + } |
| + for _, method := range []string{"magiclink", "keymail", homeAssertionMethod} { |
| + cookie := f.local(t, "one@example.com", method, time.Now().Add(-6*time.Minute)) |
| + w := httptest.NewRecorder() |
| + f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{cookie})) |
| + if w.Code != 403 { |
| + t.Fatal("stale or Home method began linking", method, w.Code) |
| + } |
| + } |
| + pending, n := f.start(t, nil, "/") |
| + inbox := f.callback(t, "unknown", n, time.Now().Add(-time.Minute)) |
| + for _, cookies := range [][]*http.Cookie{{pending, inbox, local, local}, {pending, inbox, {Name: f.b.sessionCookie, Value: strings.Repeat("A", 43)}}} { |
| + w := httptest.NewRecorder() |
| + f.b.Finish(w, bridgeRequest(http.MethodGet, "/auth/finish", cookies)) |
| + if w.Code != 403 { |
| + t.Fatal("unresolved/ambiguous current account accepted", w.Code) |
| + } |
| + bridgeNoSession(t, f.b, w) |
| + } |
| +} |
| + |
| +func TestHomeBridgeCallbackHasNoSessionDependencyAndExactOrigin(t *testing.T) { |
| + f := newBridgeFixture(t) |
| + _, n := f.start(t, nil, "/") |
| + id, _ := assertion.RandomID() |
| + now := time.Now().Unix() |
| + token, err := assertion.Sign(f.key, assertion.Claims{Version: 1, Issuer: bridgeIssuer, Audience: bridgeOrigin, Subject: "opaque", KeyID: "key", ID: id, Nonce: n, IssuedAt: now, ExpiresAt: now + 90, AuthTime: now}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, origin := range []string{"", "https://evil.example", bridgeIssuer} { |
| + r := httptest.NewRequest(http.MethodPost, bridgeOrigin+"/auth/assertion", strings.NewReader(url.Values{"assertion": {token}}.Encode())) |
| + r.Header.Set("Origin", origin) |
| + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + // Even malformed duplicate app cookies cannot affect the issuer callback. |
| + r.AddCookie(&http.Cookie{Name: f.b.sessionCookie, Value: "bad"}) |
| + r.AddCookie(&http.Cookie{Name: f.b.sessionCookie, Value: "bad"}) |
| + w := httptest.NewRecorder() |
| + f.b.Callback(w, r) |
| + bridgeNoSession(t, f.b, w) |
| + if origin == bridgeIssuer && w.Code != 303 { |
| + t.Fatal("callback resolved local session", w.Code) |
| + } |
| + if origin != bridgeIssuer && w.Code == 303 { |
| + t.Fatal("callback accepted different origin") |
| + } |
| + } |
| +} |
| + |
| +func TestHomeBridgeLinkMutationsRemainBehindCSRF(t *testing.T) { |
| + f := newBridgeFixture(t) |
| + local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| + protected := csrf.Protect(bridgeOrigin)(http.HandlerFunc(f.b.LinkStart)) |
| + r := bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{local}) |
| + r.Header.Set("Origin", "https://evil.example") |
| + r.Header.Set("Sec-Fetch-Site", "cross-site") |
| + w := httptest.NewRecorder() |
| + protected.ServeHTTP(w, r) |
| + if w.Code != 403 { |
| + t.Fatal("cross-site link initiation accepted", w.Code) |
| + } |
| + var count int |
| + if err := f.app.db.Writer().QueryRow("SELECT COUNT(*) FROM docs_link_flows").Scan(&count); err != nil || count != 0 { |
| + t.Fatal("cross-site initiation retained state", count, err) |
| + } |
| +} |
| + |
| +func TestHomeBridgeOrdinaryProofCannotBecomeLinkProof(t *testing.T) { |
| + f := newBridgeFixture(t) |
| + local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| + w := httptest.NewRecorder() |
| + f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{local})) |
| + init := bridgeCookie(t, w, homeInitCookie) |
| + // Bind an explicit link to its own generated nonce. |
| + f.start(t, []*http.Cookie{local, init}, "/account/home/confirm") |
| + // A separate ordinary sign-in produces a valid assertion with another nonce. |
| + pending, n := f.start(t, []*http.Cookie{local}, "/") |
| + inbox := f.callback(t, "opaque", n, time.Now().Add(-time.Minute)) |
| + finish := httptest.NewRecorder() |
| + f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{local, init, pending, inbox})) |
| + if finish.Code != 403 { |
| + t.Fatal("ordinary exchange reused for linking", finish.Code) |
| + } |
| + bridgeNoSession(t, f.b, finish) |
| + for _, cookie := range finish.Result().Cookies() { |
| + if cookie.Name == homeProofCookie && cookie.MaxAge > 0 { |
| + t.Fatal("wrong nonce created proof") |
| + } |
| + } |
| + var rows int |
| + if err := f.app.db.Writer().QueryRow("SELECT COUNT(*) FROM docs_link_flows").Scan(&rows); err != nil || rows != 0 { |
| + t.Fatal("failed link initiation not burned", rows, err) |
| + } |
| +} |
| + |
| +func TestHomeBridgeStaleFreshExchangeCannotMintOrCreateProof(t *testing.T) { |
| + f := newBridgeFixture(t) |
| + local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| + w := httptest.NewRecorder() |
| + f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{local})) |
| + init := bridgeCookie(t, w, homeInitCookie) |
| + pending, n := f.start(t, []*http.Cookie{local, init}, "/account/home/confirm") |
| + inbox := f.callback(t, "opaque", n, time.Now().Add(-6*time.Minute)) |
| + for i := 0; i < 2; i++ { |
| + finish := httptest.NewRecorder() |
| + f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{local, init, pending, inbox})) |
| + if finish.Code == 303 { |
| + t.Fatal("stale Home proof accepted or revived") |
| + } |
| + bridgeNoSession(t, f.b, finish) |
| + } |
| + var rows int |
| + if err := f.app.db.Writer().QueryRow("SELECT COUNT(*) FROM docs_link_flows WHERE stage = 'pending'").Scan(&rows); err != nil || rows != 0 { |
| + t.Fatal("stale Home gesture created a pending link", rows, err) |
| + } |
| +} |
| + |
| +func TestHomeBridgeCurrentHomeSubjectIsCheckedByConsumer(t *testing.T) { |
| + f := newBridgeFixture(t) |
| + local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| + f.link(t, local, "subject-one") |
| + other := f.local(t, "two@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| + f.link(t, other, "subject-two") |
| + homeToken, err := f.b.sessions.Mint(sessions.Session{Subject: "subject-one", Method: homeAssertionMethod, AuthTime: time.Now().Add(-time.Minute)}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + current := &http.Cookie{Name: f.b.sessionCookie, Value: homeToken} |
| + pending, n := f.start(t, []*http.Cookie{current}, "/") |
| + inbox := f.callback(t, "subject-two", n, time.Now().Add(-time.Minute)) |
| + w := httptest.NewRecorder() |
| + f.b.Finish(w, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{current, pending, inbox})) |
| + if w.Code == 303 { |
| + t.Fatal("signed-in Home identity silently replaced") |
| + } |
| + bridgeNoSession(t, f.b, w) |
| +} |
| + |
| +func TestHomeBridgeReturnPathCallbackRemainsSafe(t *testing.T) { |
| + for _, tc := range []struct{ input, want string }{{"/d/existing-document?view=reading", "/d/existing-document?view=reading"}, {"https://evil.example/private", "/"}, {"//evil.example", "/"}, {"/auth/finish", "/"}, {"/%2fhost", "/"}, {"", "/"}} { |
| + t.Run(tc.input, func(t *testing.T) { |
| + f := newBridgeFixture(t) |
| + f.b.returnPath = func(*http.Request) string { return tc.input } |
| + local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute)) |
| + proof, cookies := f.linkProof(t, local, "opaque") |
| + w := httptest.NewRecorder() |
| + f.b.ConfirmPost(w, bridgeRequest(http.MethodPost, "/account/home/confirm", append(cookies, proof))) |
| + if w.Code != 303 || w.Header().Get("Location") != tc.want { |
| + t.Fatalf("return location=%q status=%d", w.Header().Get("Location"), w.Code) |
| + } |
| + }) |
| + } |
| +} |