oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1package connect
2
3import (
4 "amadan.net/oficina/home/connect/linkstore"
5 "amadan.net/oficina/home/handoff"
6 "amadan.net/rastrillo/rastrillo/assertion"
7 assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
8 "bytes"
9 "context"
10 "crypto/rand"
11 "database/sql"
12 "encoding/base64"
13 "io/fs"
14 _ "modernc.org/sqlite"
15 "net/http"
16 "net/http/httptest"
17 "net/url"
18 "path/filepath"
19 "strings"
20 "testing"
21 "time"
22)
23
24const bridgeOrigin = "https://blue.docs.oficina.works"
25const bridgeIssuer = "https://id.oficina.works"
26
27type fakeSessions struct {
28 cookie string
29 values map[string]Session
30}
31
32func (f *fakeSessions) Mint(s Session) (string, error) {
33 raw := make([]byte, 32)
34 if _, err := rand.Read(raw); err != nil {
35 return "", err
36 }
37 token := base64.RawURLEncoding.EncodeToString(raw)
38 s.At = time.Now()
39 f.values[token] = s
40 return token, nil
41}
42func (f *fakeSessions) From(r *http.Request) (Session, bool) {
43 c, e := r.Cookie(f.cookie)
44 if e != nil {
45 return Session{}, false
46 }
47 s, ok := f.values[c.Value]
48 return s, ok
49}
50
51type bridgeFixture struct {
52 b *Bridge
53 db *sql.DB
54 key *assertioncrypto.Keypair
55 sessions *fakeSessions
56}
57
58func newBridgeFixture(t *testing.T) *bridgeFixture {
59 t.Helper()
60 d, e := sql.Open("sqlite", filepath.Join(t.TempDir(), "connect.db")+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
61 if e != nil {
62 t.Fatal(e)
63 }
64 t.Cleanup(func() { d.Close() })
65 for _, schema := range []fs.FS{handoff.Migrations, linkstore.Migrations} {
66 files, e := fs.Glob(schema, "migrations/*.sql")
67 if e != nil {
68 t.Fatal(e)
69 }
70 for _, name := range files {
71 raw, e := fs.ReadFile(schema, name)
72 if e != nil {
73 t.Fatal(e)
74 }
75 if _, e = d.Exec(string(raw)); e != nil {
76 t.Fatal(e)
77 }
78 }
79 }
80 if _, e = d.Exec("CREATE TABLE people(id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE)"); e != nil {
81 t.Fatal(e)
82 }
83 key, e := assertioncrypto.Generate()
84 if e != nil {
85 t.Fatal(e)
86 }
87 sessions := &fakeSessions{cookie: "__Host-local-session", values: map[string]Session{}}
88 f := &bridgeFixture{db: d, key: key, sessions: sessions}
89 cfg := Config{DB: d, Origin: bridgeOrigin, Issuer: bridgeIssuer, ConsumerID: "blue-docs", Namespace: "oficina/docs", DEK: bytes.Repeat([]byte{41}, 32), Keys: map[string][]byte{"key": key.SignPub()}, SessionCookie: sessions.cookie,
90 ReadSession: func(r *http.Request) (*Session, error) {
91 s, ok := sessions.From(r)
92 if !ok {
93 return nil, nil
94 }
95 return &s, nil
96 },
97 MintHomeSession: func(w http.ResponseWriter, r *http.Request, s Session) error {
98 token, e := sessions.Mint(s)
99 if e != nil {
100 return e
101 }
102 http.SetCookie(w, &http.Cookie{Name: sessions.cookie, Value: token, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode})
103 return nil
104 },
105 LookupLocalSession: func(ctx context.Context, s Session) (Person, error) {
106 var p Person
107 e := d.QueryRowContext(ctx, "SELECT id,email FROM people WHERE email=?", s.Subject).Scan(&p.ID, &p.Label)
108 return p, e
109 },
110 LookupPerson: func(ctx context.Context, q linkstore.Queryer, id int64) (string, error) {
111 var label string
112 e := q.QueryRowContext(ctx, "SELECT email FROM people WHERE id=? AND email<>''", id).Scan(&label)
113 return label, e
114 },
115 Render: func(w http.ResponseWriter, r *http.Request, state, label string, status int) {
116 w.WriteHeader(status)
117 w.Write([]byte(state))
118 }}
119 f.b, e = New(cfg)
120 if e != nil {
121 t.Fatal(e)
122 }
123 return f
124}
125func bridgeRequest(method, path string, cookies []*http.Cookie) *http.Request {
126 r := httptest.NewRequest(method, bridgeOrigin+path, nil)
127 for _, cookie := range cookies {
128 r.AddCookie(cookie)
129 }
130 return r
131}
132func bridgeCookie(t *testing.T, w *httptest.ResponseRecorder, name string) *http.Cookie {
133 t.Helper()
134 for _, cookie := range w.Result().Cookies() {
135 if cookie.Name == name && cookie.MaxAge >= 0 {
136 if !cookie.Secure || !cookie.HttpOnly || cookie.Domain != "" || cookie.Path != "/" || cookie.SameSite != http.SameSiteLaxMode {
137 t.Fatal("unsafe cookie attributes")
138 }
139 return cookie
140 }
141 }
142 t.Fatalf("missing cookie %s; status=%d", name, w.Code)
143 return nil
144}
145func bridgeNoSession(t *testing.T, b *Bridge, w *httptest.ResponseRecorder) {
146 t.Helper()
147 for _, c := range w.Result().Cookies() {
148 if c.Name == b.sessionCookie {
149 t.Fatal("unexpected app session mutation")
150 }
151 }
152}
153func (f *bridgeFixture) local(t *testing.T, address, method string, authTime time.Time) *http.Cookie {
154 t.Helper()
155 if _, err := f.db.Exec("INSERT INTO people(email) VALUES(?) ON CONFLICT DO NOTHING", address); err != nil {
156 t.Fatal(err)
157 }
158 token, err := f.sessions.Mint(Session{Subject: address, Method: method, AuthTime: authTime})
159 if err != nil {
160 t.Fatal(err)
161 }
162 return &http.Cookie{Name: f.b.sessionCookie, Value: token}
163}
164func (f *bridgeFixture) start(t *testing.T, cookies []*http.Cookie, path string) (*http.Cookie, string) {
165 t.Helper()
166 w := httptest.NewRecorder()
167 f.b.Start(w, bridgeRequest(http.MethodGet, "/auth/start?return_to="+url.QueryEscape(path), cookies))
168 if w.Code != 303 {
169 t.Fatalf("start=%d body=%s", w.Code, w.Body.String())
170 }
171 target, err := url.Parse(w.Header().Get("Location"))
172 if err != nil {
173 t.Fatal(err)
174 }
175 return bridgeCookie(t, w, "__Host-oficina-assertion-pending"), target.Query().Get("nonce")
176}
177func (f *bridgeFixture) callback(t *testing.T, subject, nonce string, authTime time.Time) *http.Cookie {
178 t.Helper()
179 now := time.Now().Unix()
180 id, err := assertion.RandomID()
181 if err != nil {
182 t.Fatal(err)
183 }
184 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()})
185 if err != nil {
186 t.Fatal(err)
187 }
188 form := url.Values{"assertion": {token}}
189 r := httptest.NewRequest(http.MethodPost, bridgeOrigin+"/auth/assertion", strings.NewReader(form.Encode()))
190 r.Header.Set("Origin", bridgeIssuer)
191 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
192 w := httptest.NewRecorder()
193 f.b.Callback(w, r)
194 if w.Code != 303 {
195 t.Fatalf("callback=%d", w.Code)
196 }
197 bridgeNoSession(t, f.b, w)
198 return bridgeCookie(t, w, "__Host-oficina-assertion-inbox")
199}
200func (f *bridgeFixture) linkProof(t *testing.T, local *http.Cookie, subject string) (*http.Cookie, []*http.Cookie) {
201 t.Helper()
202 w := httptest.NewRecorder()
203 f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{local}))
204 if w.Code != 303 {
205 t.Fatalf("link start=%d", w.Code)
206 }
207 init := bridgeCookie(t, w, homeInitCookie)
208 cookies := []*http.Cookie{local, init}
209 start := httptest.NewRecorder()
210 f.b.Start(start, bridgeRequest(http.MethodGet, w.Header().Get("Location"), cookies))
211 if start.Code != 303 {
212 t.Fatalf("fresh start=%d", start.Code)
213 }
214 target, _ := url.Parse(start.Header().Get("Location"))
215 if target.Query().Get("max_age") != "300" {
216 t.Fatal("link exchange did not request fresh authentication")
217 }
218 pending := bridgeCookie(t, start, "__Host-oficina-assertion-pending")
219 inbox := f.callback(t, subject, target.Query().Get("nonce"), time.Now().Add(-30*time.Second))
220 finish := httptest.NewRecorder()
221 f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", append(cookies, pending, inbox)))
222 if finish.Code != 303 || finish.Header().Get("Location") != "/account/home/confirm" {
223 t.Fatalf("link finish=%d location=%q", finish.Code, finish.Header().Get("Location"))
224 }
225 bridgeNoSession(t, f.b, finish)
226 return bridgeCookie(t, finish, homeProofCookie), []*http.Cookie{local}
227}
228func (f *bridgeFixture) link(t *testing.T, local *http.Cookie, subject string) {
229 t.Helper()
230 proof, cookies := f.linkProof(t, local, subject)
231 cookies = append(cookies, proof)
232 page := httptest.NewRecorder()
233 f.b.ConfirmPage(page, bridgeRequest(http.MethodGet, "/account/home/confirm", cookies))
234 if page.Code != 200 {
235 t.Fatalf("confirm page=%d", page.Code)
236 }
237 w := httptest.NewRecorder()
238 f.b.ConfirmPost(w, bridgeRequest(http.MethodPost, "/account/home/confirm", cookies))
239 if w.Code != 303 || w.Header().Get("Location") != "/" {
240 t.Fatalf("confirm=%d", w.Code)
241 }
242 bridgeNoSession(t, f.b, w)
243}
244
245func TestHomeBridgeLinkThenOrdinarySignInPreservesIdentity(t *testing.T) {
246 f := newBridgeFixture(t)
247 local := f.local(t, "one@example.com", "magiclink", time.Time{})
248 var person int64
249 if err := f.db.QueryRow("SELECT id FROM people WHERE email='one@example.com'").Scan(&person); err != nil {
250 t.Fatal(err)
251 }
252 if _, err := f.db.Exec("CREATE TABLE content(owner INTEGER, bytes BLOB); INSERT INTO content VALUES(1,x'010203')"); err != nil {
253 t.Fatal(err)
254 }
255 f.link(t, local, "opaque-subject-one")
256 if _, ok := f.sessions.From(bridgeRequest(http.MethodGet, "/", []*http.Cookie{local})); !ok {
257 t.Fatal("linking replaced local session")
258 }
259 pending, nonce := f.start(t, nil, "/d/existing")
260 original := time.Now().Add(-time.Hour).Truncate(time.Second)
261 inbox := f.callback(t, "opaque-subject-one", nonce, original)
262 finish := httptest.NewRecorder()
263 f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{pending, inbox}))
264 if finish.Code != 303 || finish.Header().Get("Location") != "/d/existing" {
265 t.Fatalf("ordinary finish=%d %q", finish.Code, finish.Header().Get("Location"))
266 }
267 cookie := bridgeCookie(t, finish, f.b.sessionCookie)
268 r := bridgeRequest(http.MethodGet, "/", []*http.Cookie{cookie})
269 session, ok := f.sessions.From(r)
270 if !ok || session.Method != HomeMethod || session.Subject != "opaque-subject-one" || !session.AuthTime.Equal(original) {
271 t.Fatalf("wrong Home session: %+v", session)
272 }
273 identity, err := f.b.ResolveSession(r)
274 if err != nil || identity.ID != person || identity.Label != "one@example.com" {
275 t.Fatal("identity changed", err)
276 }
277 var content []byte
278 if err = f.db.QueryRow("SELECT bytes FROM content WHERE owner=?", person).Scan(&content); err != nil || !bytes.Equal(content, []byte{1, 2, 3}) {
279 t.Fatal("existing ownership changed", err)
280 }
281 var count int
282 if err := f.db.QueryRow("SELECT COUNT(*) FROM people").Scan(&count); err != nil || count != 1 {
283 t.Fatal("SSO created an extra person", count, err)
284 }
285 replay := httptest.NewRecorder()
286 f.b.Finish(replay, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{pending, inbox}))
287 if replay.Code == 303 {
288 t.Fatal("assertion replay minted session")
289 }
290 bridgeNoSession(t, f.b, replay)
291}
292
293func TestHomeBridgeUnknownAndDifferentCurrentAccountNeverMint(t *testing.T) {
294 f := newBridgeFixture(t)
295 local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute))
296 other := f.local(t, "two@example.com", "magiclink", time.Now().Add(-time.Minute))
297 f.link(t, local, "linked-subject")
298 for _, tc := range []struct {
299 name, subject string
300 cookies []*http.Cookie
301 }{{"unknown", "unknown-private-subject", nil}, {"different-local", "linked-subject", []*http.Cookie{other}}} {
302 t.Run(tc.name, func(t *testing.T) {
303 pending, n := f.start(t, tc.cookies, "/")
304 inbox := f.callback(t, tc.subject, n, time.Now().Add(-time.Minute))
305 w := httptest.NewRecorder()
306 f.b.Finish(w, bridgeRequest(http.MethodGet, "/auth/finish", append(tc.cookies, pending, inbox)))
307 if w.Code != 403 {
308 t.Fatalf("rejected exchange=%d", w.Code)
309 }
310 bridgeNoSession(t, f.b, w)
311 if tc.name == "unknown" && w.Body.String() != "unknown" {
312 t.Fatal("unknown identity did not select local continuation state")
313 }
314 if strings.Contains(w.Body.String(), tc.subject) {
315 t.Fatal("opaque identity leaked into response")
316 }
317 retry := httptest.NewRecorder()
318 f.b.Finish(retry, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{pending, inbox}))
319 if retry.Code == 303 {
320 t.Fatal("rejected completion remained reusable")
321 }
322 })
323 }
324}
325
326func TestHomeBridgeChangedLocalSessionAndProofReplayRefused(t *testing.T) {
327 f := newBridgeFixture(t)
328 authTime := time.Now().Add(-time.Minute)
329 original := f.local(t, "one@example.com", "magiclink", authTime)
330 proof, cookies := f.linkProof(t, original, "opaque")
331 rotated := f.local(t, "one@example.com", "magiclink", authTime)
332 w := httptest.NewRecorder()
333 f.b.ConfirmPost(w, bridgeRequest(http.MethodPost, "/account/home/confirm", []*http.Cookie{rotated, proof}))
334 if w.Code != 403 {
335 t.Fatal("different session linked proof", w.Code)
336 }
337 retry := httptest.NewRecorder()
338 f.b.ConfirmPost(retry, bridgeRequest(http.MethodPost, "/account/home/confirm", append(cookies, proof)))
339 if retry.Code != 403 {
340 t.Fatal("rejected proof reusable", retry.Code)
341 }
342}
343
344func TestHomeBridgeStrictCookiesAndStaleLocalRefusal(t *testing.T) {
345 f := newBridgeFixture(t)
346 local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute))
347 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)}}} {
348 w := httptest.NewRecorder()
349 f.b.Start(w, bridgeRequest(http.MethodGet, "/auth/start", cookies))
350 if w.Code == 303 {
351 t.Fatal("malformed/duplicate/unresolved cookie downgraded into ordinary sign-in")
352 }
353 }
354 for _, method := range []string{"magiclink", "keymail", HomeMethod} {
355 cookie := f.local(t, "one@example.com", method, time.Now().Add(-6*time.Minute))
356 w := httptest.NewRecorder()
357 f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{cookie}))
358 if w.Code != 403 {
359 t.Fatal("stale or Home method began linking", method, w.Code)
360 }
361 }
362 pending, n := f.start(t, nil, "/")
363 inbox := f.callback(t, "unknown", n, time.Now().Add(-time.Minute))
364 for _, cookies := range [][]*http.Cookie{{pending, inbox, local, local}, {pending, inbox, {Name: f.b.sessionCookie, Value: strings.Repeat("A", 43)}}} {
365 w := httptest.NewRecorder()
366 f.b.Finish(w, bridgeRequest(http.MethodGet, "/auth/finish", cookies))
367 if w.Code != 403 {
368 t.Fatal("unresolved/ambiguous current account accepted", w.Code)
369 }
370 bridgeNoSession(t, f.b, w)
371 }
372}
373
374func TestHomeBridgeCallbackHasNoSessionDependencyAndExactOrigin(t *testing.T) {
375 f := newBridgeFixture(t)
376 _, n := f.start(t, nil, "/")
377 id, _ := assertion.RandomID()
378 now := time.Now().Unix()
379 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})
380 if err != nil {
381 t.Fatal(err)
382 }
383 for _, origin := range []string{"", "https://evil.example", bridgeIssuer} {
384 r := httptest.NewRequest(http.MethodPost, bridgeOrigin+"/auth/assertion", strings.NewReader(url.Values{"assertion": {token}}.Encode()))
385 r.Header.Set("Origin", origin)
386 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
387 // Even malformed duplicate app cookies cannot affect the issuer callback.
388 r.AddCookie(&http.Cookie{Name: f.b.sessionCookie, Value: "bad"})
389 r.AddCookie(&http.Cookie{Name: f.b.sessionCookie, Value: "bad"})
390 w := httptest.NewRecorder()
391 f.b.Callback(w, r)
392 bridgeNoSession(t, f.b, w)
393 if origin == bridgeIssuer && w.Code != 303 {
394 t.Fatal("callback resolved local session", w.Code)
395 }
396 if origin != bridgeIssuer && w.Code == 303 {
397 t.Fatal("callback accepted different origin")
398 }
399 }
400}
401
402func TestHomeBridgeOrdinaryProofCannotBecomeLinkProof(t *testing.T) {
403 f := newBridgeFixture(t)
404 local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute))
405 w := httptest.NewRecorder()
406 f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{local}))
407 init := bridgeCookie(t, w, homeInitCookie)
408 // Bind an explicit link to its own generated nonce.
409 f.start(t, []*http.Cookie{local, init}, "/account/home/confirm")
410 // A separate ordinary sign-in produces a valid assertion with another nonce.
411 pending, n := f.start(t, []*http.Cookie{local}, "/")
412 inbox := f.callback(t, "opaque", n, time.Now().Add(-time.Minute))
413 finish := httptest.NewRecorder()
414 f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{local, init, pending, inbox}))
415 if finish.Code != 403 {
416 t.Fatal("ordinary exchange reused for linking", finish.Code)
417 }
418 bridgeNoSession(t, f.b, finish)
419 for _, cookie := range finish.Result().Cookies() {
420 if cookie.Name == homeProofCookie && cookie.MaxAge > 0 {
421 t.Fatal("wrong nonce created proof")
422 }
423 }
424 var rows int
425 if err := f.db.QueryRow("SELECT COUNT(*) FROM home_link_flows").Scan(&rows); err != nil || rows != 0 {
426 t.Fatal("failed link initiation not burned", rows, err)
427 }
428}
429
430func TestHomeBridgeStaleFreshExchangeCannotMintOrCreateProof(t *testing.T) {
431 f := newBridgeFixture(t)
432 local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute))
433 w := httptest.NewRecorder()
434 f.b.LinkStart(w, bridgeRequest(http.MethodPost, "/account/home", []*http.Cookie{local}))
435 init := bridgeCookie(t, w, homeInitCookie)
436 pending, n := f.start(t, []*http.Cookie{local, init}, "/account/home/confirm")
437 inbox := f.callback(t, "opaque", n, time.Now().Add(-6*time.Minute))
438 for i := 0; i < 2; i++ {
439 finish := httptest.NewRecorder()
440 f.b.Finish(finish, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{local, init, pending, inbox}))
441 if finish.Code == 303 {
442 t.Fatal("stale Home proof accepted or revived")
443 }
444 bridgeNoSession(t, f.b, finish)
445 }
446 var rows int
447 if err := f.db.QueryRow("SELECT COUNT(*) FROM home_link_flows WHERE stage = 'pending'").Scan(&rows); err != nil || rows != 0 {
448 t.Fatal("stale Home gesture created a pending link", rows, err)
449 }
450}
451
452func TestHomeBridgeCurrentHomeSubjectIsCheckedByConsumer(t *testing.T) {
453 f := newBridgeFixture(t)
454 local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute))
455 f.link(t, local, "subject-one")
456 other := f.local(t, "two@example.com", "magiclink", time.Now().Add(-time.Minute))
457 f.link(t, other, "subject-two")
458 homeToken, err := f.sessions.Mint(Session{Subject: "subject-one", Method: HomeMethod, AuthTime: time.Now().Add(-time.Minute)})
459 if err != nil {
460 t.Fatal(err)
461 }
462 current := &http.Cookie{Name: f.b.sessionCookie, Value: homeToken}
463 pending, n := f.start(t, []*http.Cookie{current}, "/")
464 inbox := f.callback(t, "subject-two", n, time.Now().Add(-time.Minute))
465 w := httptest.NewRecorder()
466 f.b.Finish(w, bridgeRequest(http.MethodGet, "/auth/finish", []*http.Cookie{current, pending, inbox}))
467 if w.Code == 303 {
468 t.Fatal("signed-in Home identity silently replaced")
469 }
470 bridgeNoSession(t, f.b, w)
471}
472
473func TestHomeBridgeReturnPathCallbackRemainsSafe(t *testing.T) {
474 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", "/"}, {"", "/"}} {
475 t.Run(tc.input, func(t *testing.T) {
476 f := newBridgeFixture(t)
477 f.b.returnPath = func(*http.Request) string { return tc.input }
478 local := f.local(t, "one@example.com", "magiclink", time.Now().Add(-time.Minute))
479 proof, cookies := f.linkProof(t, local, "opaque")
480 w := httptest.NewRecorder()
481 f.b.ConfirmPost(w, bridgeRequest(http.MethodPost, "/account/home/confirm", append(cookies, proof)))
482 if w.Code != 303 || w.Header().Get("Location") != tc.want {
483 t.Fatalf("return location=%q status=%d", w.Header().Get("Location"), w.Code)
484 }
485 })
486 }
487}
488