blob: 904bdd4e7eb1116e23aeb29f751def720b7189f7 [file] [log] [blame]
gioe71b12b2026-07-29 10:02:37 +04001package main
2
3import (
4 "encoding/json"
5 "errors"
6 "html"
7 "html/template"
8 "io/fs"
9 "net/http"
10 "net/http/httptest"
11 "net/url"
12 "reflect"
13 "regexp"
14 "strings"
15 "sync/atomic"
16 "testing"
17)
18
19const (
20 sanitizedRetryFlowFixture = `{
21 "id":"retry-flow",
22 "obsolete_state":"obsolete-upstream-state",
23 "ui":{
24 "action":"https://upstream.invalid/do-not-render",
25 "nodes":[
26 {"attributes":{"name":"traits.username","value":"upstream-value-must-not-render"}},
27 {"attributes":{"name":"csrf_token","value":"retry-csrf"}}
28 ],
29 "messages":[{"id":4000007,"text":"upstream prose must not render"}]
30 }
31 }`
32 sanitizedOtherRetryFlowFixture = `{
33 "id":"other-flow",
34 "obsolete_state":"obsolete-upstream-state",
35 "ui":{"action":"https://upstream.invalid/other-action","nodes":[{"attributes":{"name":"csrf_token","value":"other-csrf"}}],"messages":[{"id":1234,"text":"unsafe upstream guidance"}]}
36 }`
37 sanitizedExpiredFlowFixture = `{"error":{"id":"self_service_flow_expired","message":"unsafe expiry prose"}}`
38 sanitizedCSRFFixture = `{"error":{"id":"security_csrf_violation","message":"unsafe CSRF prose"}}`
39
40 obsoleteFlowSentinel = "obsolete-submitted-flow"
41 obsoleteCSRFSentinel = "obsolete-submitted-csrf"
42 submittedPasswordSentinel = "Submitted-Password-Sentinel-9!"
43)
44
45func TestStage3LocalAssetAndPaletteContracts(t *testing.T) {
46 baseTemplate, err := fs.ReadFile(tmpls, "templates/base.html")
47 if err != nil {
48 t.Fatal(err)
49 }
50 markup := string(baseTemplate)
51 for _, expected := range []string{`href="/static/base.css?v=0.0.1"`, `href="/static/main.css?v=0.0.3"`} {
52 if strings.Count(markup, expected) != 1 {
53 t.Fatalf("base template local stylesheet %q count=%d, want 1", expected, strings.Count(markup, expected))
54 }
55 }
56 for _, forbidden := range []string{"pico", "cdnjs", "<script", "http://", "https://"} {
57 if strings.Contains(strings.ToLower(markup), forbidden) {
58 t.Fatalf("base template contains forbidden asset reference %q", forbidden)
59 }
60 }
61 if links := strings.Count(markup, `<link rel="stylesheet"`); links != 2 {
62 t.Fatalf("stylesheet link count=%d, want 2", links)
63 }
64
65 baseCSS, err := fs.ReadFile(static, "static/base.css")
66 if err != nil {
67 t.Fatal(err)
68 }
69 mainCSS, err := fs.ReadFile(static, "static/main.css")
70 if err != nil {
71 t.Fatal(err)
72 }
73 allCSS := string(baseCSS) + "\n" + string(mainCSS)
74 opaqueColor := regexp.MustCompile(`(?i)#[0-9a-f]{3,8}\b|rgba?\(`)
75 colors := opaqueColor.FindAllString(allCSS, -1)
76 wantColors := []string{"#d6d6d6", "#3a3a3a", "#7f9f7f", "#d4888d"}
77 if !reflect.DeepEqual(colors, wantColors) {
78 t.Fatalf("opaque CSS colors=%v, want exact palette once in base.css", colors)
79 }
80 for _, forbidden := range []string{"--pico-", "box-shadow", "@font-face", "url("} {
81 if strings.Contains(strings.ToLower(allCSS), forbidden) {
82 t.Fatalf("CSS contains forbidden presentation primitive %q", forbidden)
83 }
84 }
85 for _, primitive := range []string{"box-sizing: border-box", "border-radius: 0", "min-height: 44px", ":focus-visible", "overflow-wrap: anywhere", "prefers-reduced-motion", "--font-mono"} {
86 if !strings.Contains(string(baseCSS), primitive) {
87 t.Fatalf("base.css omitted owned primitive %q", primitive)
88 }
89 }
90 for _, presentation := range []string{"width: min(100%, 500px)", "min-height: 100vh", "min-height: 100dvh", "align-items: flex-start", "input:-webkit-autofill"} {
91 if !strings.Contains(string(mainCSS), presentation) {
92 t.Fatalf("main.css omitted auth presentation contract %q", presentation)
93 }
94 }
95 removed := []struct {
96 filesystem fs.FS
97 path string
98 }{
99 {static, "static/" + "pico.2.0.6.min.css"},
100 {tmpls, "templates/" + "consent.html"},
101 }
102 for _, asset := range removed {
103 if _, err := fs.Stat(asset.filesystem, asset.path); err == nil || !errors.Is(err, fs.ErrNotExist) {
104 t.Fatalf("removed embedded asset %q still exists", asset.path)
105 }
106 }
107 if _, present := reflect.TypeOf(Templates{}).FieldByName("Consent"); present {
108 t.Fatal("Templates retains dormant consent storage")
109 }
110}
111
112func testTemplates(t *testing.T) *Templates {
113 t.Helper()
114 templates, err := ParseTemplates(tmpls)
115 if err != nil {
116 t.Fatal(err)
117 }
118 return templates
119}
120
121func withKratosServer(t *testing.T, handler http.Handler) *httptest.Server {
122 t.Helper()
123 server := httptest.NewServer(handler)
124 old := *kratos
125 *kratos = server.URL
126 t.Cleanup(func() {
127 *kratos = old
128 server.Close()
129 })
130 return server
131}
132
133func testServer(t *testing.T, kratosURL string) *Server {
134 t.Helper()
135 return NewServer(0, kratosURL, nil, testTemplates(t), true, nil, "")
136}
137
138func formRequest(method, target string, values url.Values) *http.Request {
139 request := httptest.NewRequest(method, target, strings.NewReader(values.Encode()))
140 request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
141 return request
142}
143
144func setJSONResponse(w http.ResponseWriter, status int, body string) {
145 w.Header().Set("Content-Type", "application/json")
146 w.WriteHeader(status)
147 _, _ = w.Write([]byte(body))
148}
149
150func validFlowFixture(id, csrf string) string {
151 body, _ := json.Marshal(map[string]any{
152 "id": id,
153 "ui": map[string]any{"nodes": []any{
154 map[string]any{"attributes": map[string]any{"name": "identifier", "value": ""}},
155 map[string]any{"attributes": map[string]any{"name": "csrf_token", "value": csrf}},
156 }},
157 })
158 return string(body)
159}
160
161func whoAmIFixture() string {
162 return `{"identity":{"id":"identity-id","traits":{"username":"tester"}}}`
163}
164
165func TestParseRetryFlowPinnedFixtures(t *testing.T) {
166 flow, csrf, duplicate, err := parseRetryFlow([]byte(sanitizedRetryFlowFixture))
167 if err != nil || flow != "retry-flow" || csrf != "retry-csrf" || !duplicate {
168 t.Fatalf("duplicate fixture parsed as flow=%q csrf=%q duplicate=%v err=%v", flow, csrf, duplicate, err)
169 }
170 flow, csrf, duplicate, err = parseRetryFlow([]byte(sanitizedOtherRetryFlowFixture))
171 if err != nil || flow != "other-flow" || csrf != "other-csrf" || duplicate {
172 t.Fatalf("other fixture parsed as flow=%q csrf=%q duplicate=%v err=%v", flow, csrf, duplicate, err)
173 }
174 if got := parseOryErrorID([]byte(sanitizedExpiredFlowFixture)); got != "self_service_flow_expired" {
175 t.Fatalf("expired error id = %q", got)
176 }
177 if got := parseOryErrorID([]byte(sanitizedCSRFFixture)); got != "security_csrf_violation" {
178 t.Fatalf("CSRF error id = %q", got)
179 }
180}
181
182func TestParseRetryFlowRejectsMalformedMinimumState(t *testing.T) {
183 tests := []string{
184 `not-json`,
185 `{"id":"","ui":{"nodes":[{"attributes":{"name":"csrf_token","value":"token"}}]}}`,
186 `{"id":"flow","ui":{"nodes":[]}}`,
187 `{"id":"flow","ui":{"nodes":[{"attributes":{"name":"csrf_token","value":""}}]}}`,
188 `{"id":"flow","ui":{"nodes":[{"attributes":{"name":"csrf_token","value":"one"}},{"attributes":{"name":"csrf_token","value":"two"}}]}}`,
189 }
190 for _, fixture := range tests {
191 if _, _, _, err := parseRetryFlow([]byte(fixture)); err == nil {
192 t.Fatalf("malformed fixture accepted: %s", fixture)
193 }
194 }
195}
196
197func TestAuthNoticeAllowlistAndConsumption(t *testing.T) {
198 invalid := httptest.NewRecorder()
199 setAuthNotice(invalid, "arbitrary")
200 if len(invalid.Result().Cookies()) != 0 {
201 t.Fatal("arbitrary notice code was set")
202 }
203
204 set := httptest.NewRecorder()
205 setAuthNotice(set, authNoticeLoginInvalid)
206 cookies := set.Result().Cookies()
207 if len(cookies) != 1 || cookies[0].Value != authNoticeLoginInvalid || cookies[0].MaxAge != 120 || !cookies[0].HttpOnly {
208 t.Fatalf("notice cookie = %#v", cookies)
209 }
210
211 request := httptest.NewRequest(http.MethodGet, "/login?flow=valid", nil)
212 request.AddCookie(cookies[0])
213 if code, clear := pendingAuthNotice(request, authNoticeLoginInvalid); code != authNoticeLoginInvalid || !clear {
214 t.Fatalf("pending matching notice code=%q clear=%v", code, clear)
215 }
216
217 mismatchRequest := httptest.NewRequest(http.MethodGet, "/register?flow=valid", nil)
218 mismatchRequest.AddCookie(cookies[0])
219 if code, clear := pendingAuthNotice(mismatchRequest, authNoticeFlowExpired); code != "" || clear {
220 t.Fatalf("valid nonmatching notice code=%q clear=%v", code, clear)
221 }
222
223 arbitraryRequest := httptest.NewRequest(http.MethodGet, "/login?flow=valid", nil)
224 arbitraryRequest.AddCookie(&http.Cookie{Name: authNoticeCookieName, Value: "arbitrary-client-value"})
225 if code, clear := pendingAuthNotice(arbitraryRequest, authNoticeLoginInvalid, authNoticeFlowExpired); code != "" || !clear {
226 t.Fatalf("arbitrary notice code=%q clear=%v", code, clear)
227 }
228}
229
230func responseCookieNamed(response *http.Response, name string) *http.Cookie {
231 for _, cookie := range response.Cookies() {
232 if cookie.Name == name {
233 return cookie
234 }
235 }
236 return nil
237}
238
239func TestValidFormClearsArbitraryClientNotice(t *testing.T) {
240 upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
241 setJSONResponse(w, http.StatusOK, validFlowFixture("valid-flow", "valid-csrf"))
242 }))
243 server := testServer(t, upstream.URL)
244 request := httptest.NewRequest(http.MethodGet, "/login?flow=valid-flow", nil)
245 request.AddCookie(&http.Cookie{Name: authNoticeCookieName, Value: "arbitrary-client-value"})
246 recorder := httptest.NewRecorder()
247 server.loginInitiate(recorder, request)
248 if recorder.Code != http.StatusOK {
249 t.Fatalf("status = %d, want 200", recorder.Code)
250 }
251 cleared := responseCookieNamed(recorder.Result(), authNoticeCookieName)
252 if cleared == nil || cleared.MaxAge != -1 {
253 t.Fatalf("arbitrary notice was not cleared after valid form render: %#v", cleared)
254 }
255}
256
257func TestMatchingNoticeNotConsumedWithoutValidForm(t *testing.T) {
258 tests := []struct {
259 name string
260 upstream http.Handler
261 breakLogin bool
262 status int
263 }{
264 {
265 name: "flow fetch failure",
266 upstream: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
267 setJSONResponse(w, http.StatusInternalServerError, `{"error":"unavailable"}`)
268 }),
269 status: http.StatusBadGateway,
270 },
271 {
272 name: "template execution failure",
273 upstream: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
274 setJSONResponse(w, http.StatusOK, validFlowFixture("valid-flow", "valid-csrf"))
275 }),
276 breakLogin: true,
277 status: http.StatusInternalServerError,
278 },
279 }
280 for _, tt := range tests {
281 t.Run(tt.name, func(t *testing.T) {
282 upstream := withKratosServer(t, tt.upstream)
283 server := testServer(t, upstream.URL)
284 if tt.breakLogin {
285 server.tmpls.Login = template.Must(template.New("broken-login").Funcs(template.FuncMap{
286 "fail": func() (string, error) { return "", http.ErrAbortHandler },
287 }).Parse(`{{fail}}`))
288 }
289 request := httptest.NewRequest(http.MethodGet, "/login?flow=valid-flow", nil)
290 request.AddCookie(&http.Cookie{Name: authNoticeCookieName, Value: authNoticeFlowExpired})
291 recorder := httptest.NewRecorder()
292 server.loginInitiate(recorder, request)
293 if recorder.Code != tt.status {
294 t.Fatalf("status = %d, want %d", recorder.Code, tt.status)
295 }
296 if cookie := responseCookieNamed(recorder.Result(), authNoticeCookieName); cookie != nil {
297 t.Fatalf("matching notice was consumed without a valid form: %#v", cookie)
298 }
299 })
300 }
301}
302
303func TestRegistrationLocalValidationUsesSharedErrorsWithoutUpstream(t *testing.T) {
304 var calls atomic.Int32
305 upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
306 calls.Add(1)
307 http.Error(w, "unexpected", http.StatusInternalServerError)
308 }))
309 server := testServer(t, upstream.URL)
310 recorder := httptest.NewRecorder()
311 request := formRequest(http.MethodPost, "/register?flow=current-flow", url.Values{
312 "csrf_token": {"current-csrf"}, "username": {"ab"}, "password": {"short-secret"},
313 })
314 server.register(recorder, request)
315 if recorder.Code != http.StatusUnprocessableEntity {
316 t.Fatalf("status = %d, want 422", recorder.Code)
317 }
318 body := html.UnescapeString(recorder.Body.String())
319 for _, expected := range []string{testUsernameLengthMessage, testPasswordLengthMessage, testPasswordCompositionMessage, `value="ab"`, `action="/register?flow=current-flow"`, `value="current-csrf"`} {
320 if !strings.Contains(body, expected) {
321 t.Fatalf("response omitted %q", expected)
322 }
323 }
324 if strings.Contains(body, "short-secret") {
325 t.Fatal("rejected registration password was rendered")
326 }
327 if calls.Load() != 0 {
328 t.Fatalf("local validation made %d upstream calls", calls.Load())
329 }
330}
331
332func TestSettingsLocalValidationUsesSharedErrorsWithoutSubmission(t *testing.T) {
333 var submissions atomic.Int32
334 upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
335 if r.URL.Path == "/sessions/whoami" {
336 setJSONResponse(w, http.StatusOK, whoAmIFixture())
337 return
338 }
339 if r.Method == http.MethodPost {
340 submissions.Add(1)
341 }
342 http.Error(w, "unexpected", http.StatusInternalServerError)
343 }))
344 server := testServer(t, upstream.URL)
345 recorder := httptest.NewRecorder()
346 request := formRequest(http.MethodPost, "/settings?flow=current-flow", url.Values{
347 "csrf_token": {"current-csrf"}, "password": {"short-secret"},
348 })
349 server.changePassword(recorder, request)
350 if recorder.Code != http.StatusUnprocessableEntity {
351 t.Fatalf("status = %d, want 422", recorder.Code)
352 }
353 body := html.UnescapeString(recorder.Body.String())
354 for _, expected := range []string{testPasswordLengthMessage, testPasswordCompositionMessage, `action="/settings?flow=current-flow"`, `value="current-csrf"`} {
355 if !strings.Contains(body, expected) {
356 t.Fatalf("response omitted %q", expected)
357 }
358 }
359 if strings.Contains(body, "short-secret") {
360 t.Fatal("rejected settings password was rendered")
361 }
362 if submissions.Load() != 0 {
363 t.Fatalf("local validation made %d settings submissions", submissions.Load())
364 }
365}
366
367func TestRegistrationExpectedRejectionsUseReturnedRetryState(t *testing.T) {
368 tests := []struct {
369 name string
370 fixture string
371 status int
372 message string
373 flow string
374 csrf string
375 }{
376 {"duplicate", sanitizedRetryFlowFixture, http.StatusConflict, duplicateRegistrationMessage, "retry-flow", "retry-csrf"},
377 {"other", sanitizedOtherRetryFlowFixture, http.StatusUnprocessableEntity, registrationRejectedMessage, "other-flow", "other-csrf"},
378 }
379 for _, tt := range tests {
380 t.Run(tt.name, func(t *testing.T) {
381 upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
382 setJSONResponse(w, http.StatusBadRequest, tt.fixture)
383 }))
384 server := testServer(t, upstream.URL)
385 recorder := httptest.NewRecorder()
386 request := formRequest(http.MethodPost, "/register?flow="+obsoleteFlowSentinel, url.Values{
387 "csrf_token": {obsoleteCSRFSentinel}, "username": {"retained-user"}, "password": {submittedPasswordSentinel},
388 })
389 server.register(recorder, request)
390 if recorder.Code != tt.status {
391 t.Fatalf("status = %d, want %d", recorder.Code, tt.status)
392 }
393 body := recorder.Body.String()
394 for _, expected := range []string{tt.message, `action="/register?flow=` + tt.flow + `"`, `value="` + tt.csrf + `"`, `value="retained-user"`} {
395 if !strings.Contains(body, expected) {
396 t.Fatalf("response omitted %q", expected)
397 }
398 }
399 for _, forbidden := range []string{submittedPasswordSentinel, obsoleteFlowSentinel, obsoleteCSRFSentinel, "https://upstream.invalid/do-not-render", "https://upstream.invalid/other-action", "obsolete-upstream-state", "upstream prose", "upstream-value", "unsafe upstream"} {
400 if strings.Contains(body, forbidden) {
401 t.Fatalf("response rendered forbidden upstream/password text %q", forbidden)
402 }
403 }
404 })
405 }
406}
407
408func TestSettingsExpectedRejectionUsesReturnedRetryState(t *testing.T) {
409 upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
410 switch r.URL.Path {
411 case "/sessions/whoami":
412 setJSONResponse(w, http.StatusOK, whoAmIFixture())
413 case "/self-service/settings":
414 setJSONResponse(w, http.StatusBadRequest, sanitizedOtherRetryFlowFixture)
415 default:
416 http.NotFound(w, r)
417 }
418 }))
419 server := testServer(t, upstream.URL)
420 recorder := httptest.NewRecorder()
421 request := formRequest(http.MethodPost, "/settings?flow="+obsoleteFlowSentinel, url.Values{
422 "csrf_token": {obsoleteCSRFSentinel}, "password": {submittedPasswordSentinel},
423 })
424 server.changePassword(recorder, request)
425 if recorder.Code != http.StatusUnprocessableEntity {
426 t.Fatalf("status = %d, want 422", recorder.Code)
427 }
428 body := recorder.Body.String()
429 for _, expected := range []string{passwordChangeRejectedMessage, `action="/settings?flow=other-flow"`, `value="other-csrf"`} {
430 if !strings.Contains(body, expected) {
431 t.Fatalf("response omitted %q", expected)
432 }
433 }
434 for _, forbidden := range []string{submittedPasswordSentinel, obsoleteFlowSentinel, obsoleteCSRFSentinel, "https://upstream.invalid/other-action", "obsolete-upstream-state", "unsafe upstream"} {
435 if strings.Contains(body, forbidden) {
436 t.Fatalf("settings rejection rendered forbidden state %q", forbidden)
437 }
438 }
439}
440
441func TestSettingsResponseCookiesAreNeverForwarded(t *testing.T) {
442 const sentinelCookieName = "kratos_settings_sentinel"
443 tests := []struct {
444 name string
445 status int
446 fixture string
447 wantStatus int
448 wantNotice bool
449 }{
450 {"success", http.StatusOK, `{}`, http.StatusOK, false},
451 {"expected rejection", http.StatusBadRequest, sanitizedOtherRetryFlowFixture, http.StatusUnprocessableEntity, false},
452 {"unexpected failure", http.StatusInternalServerError, `{"obsolete_state":"obsolete-upstream-state","ui":{"action":"https://upstream.invalid/generic-action"}}`, http.StatusBadGateway, false},
453 {"expiry", http.StatusGone, sanitizedExpiredFlowFixture, http.StatusSeeOther, true},
454 }
455 for _, tt := range tests {
456 t.Run(tt.name, func(t *testing.T) {
457 upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
458 switch r.URL.Path {
459 case "/sessions/whoami":
460 setJSONResponse(w, http.StatusOK, whoAmIFixture())
461 case "/self-service/settings":
462 http.SetCookie(w, &http.Cookie{Name: sentinelCookieName, Value: "must-not-forward", Path: "/"})
463 setJSONResponse(w, tt.status, tt.fixture)
464 default:
465 http.NotFound(w, r)
466 }
467 }))
468 server := testServer(t, upstream.URL)
469 recorder := httptest.NewRecorder()
470 request := formRequest(http.MethodPost, "/settings?flow="+obsoleteFlowSentinel, url.Values{
471 "csrf_token": {obsoleteCSRFSentinel}, "password": {submittedPasswordSentinel},
472 })
473 server.changePassword(recorder, request)
474 if recorder.Code != tt.wantStatus {
475 t.Fatalf("status = %d, want %d", recorder.Code, tt.wantStatus)
476 }
477 response := recorder.Result()
478 responseCookies := response.Cookies()
479 if cookie := responseCookieNamed(response, sentinelCookieName); cookie != nil {
480 t.Fatalf("Kratos settings cookie was forwarded: %#v", cookie)
481 }
482 notice := responseCookieNamed(response, authNoticeCookieName)
483 if tt.wantNotice {
484 if len(responseCookies) != 1 || notice == nil || notice.Value != authNoticeFlowExpired {
485 t.Fatalf("expiry response cookies = %#v", responseCookies)
486 }
487 } else if len(responseCookies) != 0 {
488 t.Fatalf("unexpected settings response cookies = %#v", responseCookies)
489 }
490 body := recorder.Body.String()
491 for _, forbidden := range []string{submittedPasswordSentinel, obsoleteFlowSentinel, obsoleteCSRFSentinel, "https://upstream.invalid/generic-action", "obsolete-upstream-state"} {
492 if strings.Contains(body, forbidden) {
493 t.Fatalf("settings output rendered forbidden state %q", forbidden)
494 }
495 }
496 })
497 }
498}
499
500func TestRegistrationExpiredAndCSRFSubmissionsRestartWithNotice(t *testing.T) {
501 tests := []struct {
502 status int
503 fixture string
504 }{{http.StatusGone, sanitizedExpiredFlowFixture}, {http.StatusForbidden, sanitizedCSRFFixture}}
505 for _, tt := range tests {
506 t.Run(http.StatusText(tt.status), func(t *testing.T) {
507 upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
508 setJSONResponse(w, tt.status, tt.fixture)
509 }))
510 server := testServer(t, upstream.URL)
511 recorder := httptest.NewRecorder()
512 request := formRequest(http.MethodPost, "/register?flow=old", url.Values{
513 "csrf_token": {"old"}, "username": {"valid-user"}, "password": {"Correct-Horse-Battery-9!"},
514 })
515 server.register(recorder, request)
516 if recorder.Code != http.StatusSeeOther || recorder.Header().Get("Location") != upstream.URL+"/self-service/registration/browser" {
517 t.Fatalf("restart response status=%d location=%q", recorder.Code, recorder.Header().Get("Location"))
518 }
519 cookies := recorder.Result().Cookies()
520 if len(cookies) != 1 || cookies[0].Name != authNoticeCookieName || cookies[0].Value != authNoticeFlowExpired {
521 t.Fatalf("restart notice cookies = %#v", cookies)
522 }
523 })
524 }
525}
526
527func TestMalformedRetryStateAndUnexpectedFailureRenderGeneric502(t *testing.T) {
528 tests := []struct {
529 name string
530 status int
531 fixture string
532 }{
533 {"malformed expected rejection", http.StatusBadRequest, `{"id":"flow","obsolete_state":"obsolete-upstream-state","ui":{"action":"https://upstream.invalid/generic-action","nodes":[]},"unsafe":"do not render"}`},
534 {"unexpected status", http.StatusInternalServerError, `{"error":"private upstream failure"}`},
535 {"wrong gone id", http.StatusGone, `{"error":{"id":"other","message":"private upstream failure"}}`},
536 }
537 for _, tt := range tests {
538 t.Run(tt.name, func(t *testing.T) {
539 upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
540 setJSONResponse(w, tt.status, tt.fixture)
541 }))
542 server := testServer(t, upstream.URL)
543 recorder := httptest.NewRecorder()
544 request := formRequest(http.MethodPost, "/register?flow="+obsoleteFlowSentinel, url.Values{
545 "csrf_token": {obsoleteCSRFSentinel}, "username": {"valid-user"}, "password": {submittedPasswordSentinel},
546 })
547 server.register(recorder, request)
548 if recorder.Code != http.StatusBadGateway {
549 t.Fatalf("status = %d, want 502", recorder.Code)
550 }
551 body := recorder.Body.String()
552 for _, expected := range []string{"Registration unavailable", authenticationUnavailableMessage, `href="/register"`, "Try registration again"} {
553 if !strings.Contains(body, expected) {
554 t.Fatalf("generic page omitted %q", expected)
555 }
556 }
557 for _, forbidden := range []string{submittedPasswordSentinel, obsoleteFlowSentinel, obsoleteCSRFSentinel, "https://upstream.invalid/generic-action", "obsolete-upstream-state", "private upstream", "do not render"} {
558 if strings.Contains(body, forbidden) {
559 t.Fatalf("generic page rendered forbidden state %q", forbidden)
560 }
561 }
562 })
563 }
564}
565
566func TestSettingsExpiredAndCSRFSubmissionsRestartMatchingFlow(t *testing.T) {
567 tests := []struct {
568 status int
569 fixture string
570 }{{http.StatusGone, sanitizedExpiredFlowFixture}, {http.StatusForbidden, sanitizedCSRFFixture}}
571 for _, tt := range tests {
572 t.Run(http.StatusText(tt.status), func(t *testing.T) {
573 upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
574 switch r.URL.Path {
575 case "/sessions/whoami":
576 setJSONResponse(w, http.StatusOK, whoAmIFixture())
577 case "/self-service/settings":
578 setJSONResponse(w, tt.status, tt.fixture)
579 case "/self-service/settings/flows":
580 setJSONResponse(w, http.StatusOK, validFlowFixture("fresh-settings-flow", "fresh-settings-csrf"))
581 default:
582 http.NotFound(w, r)
583 }
584 }))
585 server := testServer(t, upstream.URL)
586 recorder := httptest.NewRecorder()
587 request := formRequest(http.MethodPost, "/settings?flow=old", url.Values{
588 "csrf_token": {"old"}, "password": {"Correct-Horse-Battery-9!"},
589 })
590 server.changePassword(recorder, request)
591 if recorder.Code != http.StatusSeeOther || recorder.Header().Get("Location") != upstream.URL+"/self-service/settings/browser" {
592 t.Fatalf("restart response status=%d location=%q", recorder.Code, recorder.Header().Get("Location"))
593 }
594 cookies := recorder.Result().Cookies()
595 if len(cookies) != 1 || cookies[0].Value != authNoticeFlowExpired {
596 t.Fatalf("restart notice cookies = %#v", cookies)
597 }
598
599 freshRequest := httptest.NewRequest(http.MethodGet, "/settings?flow=fresh-settings-flow", nil)
600 freshRequest.AddCookie(cookies[0])
601 fresh := httptest.NewRecorder()
602 server.changePasswordForm(fresh, freshRequest)
603 if fresh.Code != http.StatusOK {
604 t.Fatalf("fresh settings GET status=%d, want 200", fresh.Code)
605 }
606 body := fresh.Body.String()
607 for _, expected := range []string{expiredFlowMessage, `action="/settings?flow=fresh-settings-flow"`, `value="fresh-settings-csrf"`} {
608 if !strings.Contains(body, expected) {
609 t.Fatalf("fresh settings form omitted %q", expected)
610 }
611 }
612 cleared := responseCookieNamed(fresh.Result(), authNoticeCookieName)
613 if cleared == nil || cleared.MaxAge != -1 {
614 t.Fatalf("fresh settings form did not clear notice: %#v", cleared)
615 }
616 })
617 }
618}
619
620func TestDependencyErrorContextContracts(t *testing.T) {
621 tests := []struct {
622 name string
623 context string
624 registration bool
625 title string
626 href string
627 link string
628 }{
629 {"login", "login", true, "Authentication unavailable", "/login", "Try signing in again"},
630 {"registration", "registration", true, "Registration unavailable", "/register", "Try registration again"},
631 {"disabled registration", "registration", false, "Registration unavailable", "/login", "Go to sign in"},
632 {"account", "account", true, "Account unavailable", "/", "Back to account"},
633 }
634 for _, tt := range tests {
635 t.Run(tt.name, func(t *testing.T) {
636 server := NewServer(0, "http://kratos.invalid", nil, testTemplates(t), tt.registration, nil, "")
637 recorder := httptest.NewRecorder()
638 server.renderDependencyError(recorder, tt.context)
639 if recorder.Code != http.StatusBadGateway {
640 t.Fatalf("status = %d, want 502", recorder.Code)
641 }
642 body := recorder.Body.String()
643 for _, expected := range []string{tt.title, authenticationUnavailableMessage, `href="` + tt.href + `"`, tt.link} {
644 if !strings.Contains(body, expected) {
645 t.Fatalf("dependency page omitted %q", expected)
646 }
647 }
648 })
649 }
650}
651
652func TestExpiredFlowFetchRestartsAndValidRenderConsumesNotice(t *testing.T) {
653 var expired atomic.Bool
654 expired.Store(true)
655 upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
656 if expired.Load() {
657 setJSONResponse(w, http.StatusGone, sanitizedExpiredFlowFixture)
658 return
659 }
660 setJSONResponse(w, http.StatusOK, validFlowFixture("fresh-flow", "fresh-csrf"))
661 }))
662 server := testServer(t, upstream.URL)
663 first := httptest.NewRecorder()
664 server.registerInitiate(first, httptest.NewRequest(http.MethodGet, "/register?flow=expired", nil))
665 if first.Code != http.StatusSeeOther || first.Header().Get("Location") != upstream.URL+"/self-service/registration/browser" {
666 t.Fatalf("expired fetch response status=%d location=%q", first.Code, first.Header().Get("Location"))
667 }
668 notice := first.Result().Cookies()[0]
669 expired.Store(false)
670 secondRequest := httptest.NewRequest(http.MethodGet, "/register?flow=fresh-flow", nil)
671 secondRequest.AddCookie(notice)
672 second := httptest.NewRecorder()
673 server.registerInitiate(second, secondRequest)
674 if second.Code != http.StatusOK || !strings.Contains(second.Body.String(), expiredFlowMessage) {
675 t.Fatalf("fresh render status=%d body=%q", second.Code, second.Body.String())
676 }
677 if cookies := second.Result().Cookies(); len(cookies) != 1 || cookies[0].MaxAge != -1 {
678 t.Fatalf("fresh render did not clear notice: %#v", cookies)
679 }
680}
681
682func TestInvalidLoginPreservesRedirectChallengeAndShowsFixedNotice(t *testing.T) {
683 location := "http://auth-ui.invalid/login?flow=retry-login"
684 upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
685 if r.Method == http.MethodPost {
686 w.Header().Set("Location", location)
687 w.WriteHeader(http.StatusSeeOther)
688 return
689 }
690 setJSONResponse(w, http.StatusOK, validFlowFixture("retry-login", "retry-csrf"))
691 }))
692 server := testServer(t, upstream.URL)
693 post := httptest.NewRecorder()
694 postRequest := formRequest(http.MethodPost, "/login?flow=old", url.Values{
695 "csrf_token": {"old"}, "username": {"must-not-be-retained"}, "password": {"must-not-be-retained"},
696 })
697 postRequest.AddCookie(&http.Cookie{Name: "login_challenge", Value: "pending-challenge"})
698 server.login(post, postRequest)
699 if post.Code != http.StatusSeeOther || post.Header().Get("Location") != location {
700 t.Fatalf("login rejection status=%d location=%q", post.Code, post.Header().Get("Location"))
701 }
702 cookies := post.Result().Cookies()
703 if len(cookies) != 1 || cookies[0].Name != authNoticeCookieName || cookies[0].Value != authNoticeLoginInvalid {
704 t.Fatalf("login rejection cookies = %#v", cookies)
705 }
706
707 getRequest := httptest.NewRequest(http.MethodGet, "/login?flow=retry-login", nil)
708 getRequest.AddCookie(cookies[0])
709 getRequest.AddCookie(&http.Cookie{Name: "login_challenge", Value: "pending-challenge"})
710 get := httptest.NewRecorder()
711 server.loginInitiate(get, getRequest)
712 if get.Code != http.StatusOK {
713 t.Fatalf("final GET status = %d, want 200", get.Code)
714 }
715 body := get.Body.String()
716 if !strings.Contains(body, invalidLoginMessage) || !strings.Contains(body, `role="alert"`) || !strings.Contains(body, `action="/login?flow=retry-login"`) {
717 t.Fatal("final login GET omitted fixed notice, alert, or retry action")
718 }
719 if strings.Contains(body, "must-not-be-retained") {
720 t.Fatal("rejected login retained credentials")
721 }
722}
723
724func TestExpiredLoginSubmissionRestartsAndPreservesChallenge(t *testing.T) {
725 tests := []struct {
726 name string
727 status int
728 fixture string
729 }{
730 {"expired flow", http.StatusGone, sanitizedExpiredFlowFixture},
731 {"CSRF violation", http.StatusForbidden, sanitizedCSRFFixture},
732 }
733 for _, tt := range tests {
734 t.Run(tt.name, func(t *testing.T) {
735 upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
736 if r.Method == http.MethodPost {
737 setJSONResponse(w, tt.status, tt.fixture)
738 return
739 }
740 setJSONResponse(w, http.StatusOK, validFlowFixture("fresh-login-flow", "fresh-login-csrf"))
741 }))
742 server := testServer(t, upstream.URL)
743 server.defaultReturnTo = "https://return.example/dashboard"
744 postRequest := formRequest(http.MethodPost, "/login?flow="+obsoleteFlowSentinel, url.Values{
745 "csrf_token": {obsoleteCSRFSentinel}, "username": {"submitted-user"}, "password": {submittedPasswordSentinel},
746 })
747 postRequest.AddCookie(&http.Cookie{Name: "login_challenge", Value: "pending-login-challenge"})
748 post := httptest.NewRecorder()
749 server.login(post, postRequest)
750 if post.Code != http.StatusSeeOther || post.Header().Get("Location") != upstream.URL+"/self-service/login/browser?return_to=https://return.example/dashboard" {
751 t.Fatalf("restart status=%d location=%q", post.Code, post.Header().Get("Location"))
752 }
753 notice := responseCookieNamed(post.Result(), authNoticeCookieName)
754 if notice == nil || notice.Value != authNoticeFlowExpired {
755 t.Fatalf("restart notice = %#v", notice)
756 }
757 if cookie := responseCookieNamed(post.Result(), "login_challenge"); cookie != nil {
758 t.Fatalf("restart mutated pending login challenge: %#v", cookie)
759 }
760
761 getRequest := httptest.NewRequest(http.MethodGet, "/login?flow=fresh-login-flow", nil)
762 getRequest.AddCookie(notice)
763 getRequest.AddCookie(&http.Cookie{Name: "login_challenge", Value: "pending-login-challenge"})
764 get := httptest.NewRecorder()
765 server.loginInitiate(get, getRequest)
766 if get.Code != http.StatusOK {
767 t.Fatalf("fresh GET status=%d, want 200", get.Code)
768 }
769 body := get.Body.String()
770 for _, expected := range []string{expiredFlowMessage, `action="/login?flow=fresh-login-flow"`, `value="fresh-login-csrf"`} {
771 if !strings.Contains(body, expected) {
772 t.Fatalf("fresh login form omitted %q", expected)
773 }
774 }
775 for _, forbidden := range []string{submittedPasswordSentinel, obsoleteFlowSentinel, obsoleteCSRFSentinel, "submitted-user", "pending-login-challenge"} {
776 if strings.Contains(body, forbidden) {
777 t.Fatalf("fresh login form rendered forbidden state %q", forbidden)
778 }
779 }
780 clearedNotice := responseCookieNamed(get.Result(), authNoticeCookieName)
781 if clearedNotice == nil || clearedNotice.MaxAge != -1 {
782 t.Fatalf("fresh login form did not clear notice: %#v", clearedNotice)
783 }
784 if cookie := responseCookieNamed(get.Result(), "login_challenge"); cookie != nil {
785 t.Fatalf("fresh login form mutated pending challenge: %#v", cookie)
786 }
787 })
788 }
789}
790
791func TestUnexpectedLoginOutcomeRendersGeneric502WithoutHydraDecision(t *testing.T) {
792 upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
793 setJSONResponse(w, http.StatusBadRequest, `{"error":"upstream private prose","obsolete_state":"obsolete-upstream-state","ui":{"action":"https://upstream.invalid/generic-action"}}`)
794 }))
795 server := testServer(t, upstream.URL)
796 recorder := httptest.NewRecorder()
797 request := formRequest(http.MethodPost, "/login?flow="+obsoleteFlowSentinel, url.Values{
798 "csrf_token": {obsoleteCSRFSentinel}, "username": {"user"}, "password": {submittedPasswordSentinel},
799 })
800 request.AddCookie(&http.Cookie{Name: "login_challenge", Value: "pending"})
801 server.login(recorder, request)
802 if recorder.Code != http.StatusBadGateway {
803 t.Fatalf("status = %d, want 502", recorder.Code)
804 }
805 body := recorder.Body.String()
806 for _, expected := range []string{"Authentication unavailable", authenticationUnavailableMessage, `href="/login"`} {
807 if !strings.Contains(body, expected) {
808 t.Fatalf("generic login page omitted %q", expected)
809 }
810 }
811 for _, forbidden := range []string{submittedPasswordSentinel, obsoleteFlowSentinel, obsoleteCSRFSentinel, "https://upstream.invalid/generic-action", "obsolete-upstream-state", "upstream private prose"} {
812 if strings.Contains(body, forbidden) {
813 t.Fatalf("generic login page rendered forbidden state %q", forbidden)
814 }
815 }
816}
817
818func TestMalformedLocalFormRequestReturns400(t *testing.T) {
819 server := testServer(t, "http://127.0.0.1:1")
820 request := httptest.NewRequest(http.MethodPost, "/register?flow=flow", strings.NewReader("username=%zz"))
821 request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
822 recorder := httptest.NewRecorder()
823 server.register(recorder, request)
824 if recorder.Code != http.StatusBadRequest {
825 t.Fatalf("status = %d, want 400", recorder.Code)
826 }
827}
828
829func TestRenderTemplateBuffersExecutionFailure(t *testing.T) {
830 tmpl := template.Must(template.New("broken").Funcs(template.FuncMap{
831 "fail": func() (string, error) { return "", http.ErrAbortHandler },
832 }).Parse(`prefix{{fail}}suffix`))
833 recorder := httptest.NewRecorder()
834 renderTemplate(recorder, tmpl, http.StatusUnprocessableEntity, nil)
835 if recorder.Code != http.StatusInternalServerError || strings.Contains(recorder.Body.String(), "prefix") {
836 t.Fatalf("execution failure status=%d body=%q", recorder.Code, recorder.Body.String())
837 }
838}
839
840func renderedElementAttributes(body, element string) []map[string]string {
841 elementPattern := regexp.MustCompile(`(?is)<` + regexp.QuoteMeta(element) + `\b([^>]*)>`)
842 attributePattern := regexp.MustCompile(`(?i)([a-z_:][a-z0-9_:.-]*)(?:\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>]+))?`)
843 matches := elementPattern.FindAllStringSubmatch(body, -1)
844 result := make([]map[string]string, 0, len(matches))
845 for _, match := range matches {
846 attributes := map[string]string{}
847 for _, attribute := range attributePattern.FindAllStringSubmatch(match[1], -1) {
848 value := attribute[2]
849 if len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'')) {
850 value = value[1 : len(value)-1]
851 }
852 attributes[strings.ToLower(attribute[1])] = html.UnescapeString(value)
853 }
854 result = append(result, attributes)
855 }
856 return result
857}
858
859func assertNoHiddenInputsNamed(t *testing.T, body string, forbiddenNames ...string) {
860 t.Helper()
861 forbidden := make(map[string]bool, len(forbiddenNames))
862 for _, name := range forbiddenNames {
863 forbidden[strings.ToLower(name)] = true
864 }
865 for _, attributes := range renderedElementAttributes(body, "input") {
866 if strings.EqualFold(attributes["type"], "hidden") && forbidden[strings.ToLower(attributes["name"])] {
867 t.Fatalf("rendered forbidden hidden input named %q", attributes["name"])
868 }
869 }
870}
871
872func TestRenderedElementAttributesAreOrderIndependent(t *testing.T) {
873 inputs := renderedElementAttributes(`<input value="one" name='username' type="hidden"><input TYPE=hidden disabled NAME=method><input name="alternate" required type='password'>`, "input")
874 if len(inputs) != 3 {
875 t.Fatalf("input count=%d, want 3", len(inputs))
876 }
877 for index, expected := range []map[string]string{
878 {"value": "one", "name": "username", "type": "hidden"},
879 {"type": "hidden", "disabled": "", "name": "method"},
880 {"name": "alternate", "required": "", "type": "password"},
881 } {
882 if !reflect.DeepEqual(inputs[index], expected) {
883 t.Fatalf("input %d attributes=%v, want %v", index, inputs[index], expected)
884 }
885 }
886}
887
888func TestPageModelsDoNotContainPasswordValues(t *testing.T) {
889 models := []any{LoginPageData{}, RegisterPageData{}, ChangePasswordPageData{}, AccountPageData{}, ErrorPageData{}}
890 for _, model := range models {
891 typeOf := reflect.TypeOf(model)
892 for i := 0; i < typeOf.NumField(); i++ {
893 field := typeOf.Field(i)
894 if field.Type.Kind() == reflect.String && strings.Contains(strings.ToLower(field.Name), "password") {
895 t.Fatalf("%s contains password-bearing string field %s", typeOf.Name(), field.Name)
896 }
897 }
898 }
899}
900
901func TestSemanticTemplateContracts(t *testing.T) {
902 templates := testTemplates(t)
903 usernameErrors := []ValidationError{
904 {Field: "username", Message: "First username error."},
905 {Field: "username", Message: "Second username error."},
906 }
907 passwordErrors := []ValidationError{
908 {Field: "password", Message: testPasswordLengthMessage},
909 {Field: "password", Message: testPasswordCompositionMessage},
910 }
911 pages := []struct {
912 name string
913 tmpl *template.Template
914 data any
915 h1 string
916 formCount int
917 passwordCount int
918 }{
919 {"login", templates.Login, LoginPageData{FormAction: "/login?flow=flow", CSRFToken: "csrf", EnableRegistration: true}, "Sign in", 1, 1},
920 {"register", templates.Register, RegisterPageData{FormAction: "/register?flow=flow", CSRFToken: "csrf"}, "Create account", 1, 1},
921 {"register errors", templates.Register, RegisterPageData{FormAction: "/register?flow=flow", CSRFToken: "csrf", Username: "retained", UsernameErrors: usernameErrors, PasswordErrors: passwordErrors, GeneralError: registrationRejectedMessage}, "Create account", 1, 1},
922 {"change password", templates.ChangePassword, ChangePasswordPageData{FormAction: "/settings?flow=flow", CSRFToken: "csrf", Username: "tester"}, "Change password", 1, 1},
923 {"change password errors", templates.ChangePassword, ChangePasswordPageData{FormAction: "/settings?flow=flow", CSRFToken: "csrf", Username: "tester", PasswordErrors: passwordErrors, GeneralError: passwordChangeRejectedMessage}, "Change password", 1, 1},
924 {"account", templates.WhoAmI, AccountPageData{Username: "tester"}, "Account", 0, 0},
925 {"success", templates.ChangePasswordSuccess, nil, "Password changed", 0, 0},
926 {"error", templates.Error, ErrorPageData{Title: "Authentication unavailable", Message: authenticationUnavailableMessage, RecoveryHref: "/login", RecoveryText: "Try signing in again"}, "Authentication unavailable", 0, 0},
927 }
928 idPattern := regexp.MustCompile(`\bid="([^"]+)"`)
929 for _, page := range pages {
930 t.Run(page.name, func(t *testing.T) {
931 rendered, err := executeTemplate(page.tmpl, page.data)
932 if err != nil {
933 t.Fatal(err)
934 }
935 body := string(rendered)
936 if strings.Count(body, "<main") != 1 {
937 t.Fatalf("main count=%d, want 1", strings.Count(body, "<main"))
938 }
939 if strings.Count(body, "<h1") != 1 || !strings.Contains(body, "<h1>"+page.h1+"</h1>") {
940 t.Fatalf("h1 contract missing for %q", page.h1)
941 }
942 seen := map[string]bool{}
943 for _, match := range idPattern.FindAllStringSubmatch(body, -1) {
944 if match[1] == "" || seen[match[1]] {
945 t.Fatalf("empty or duplicate id %q", match[1])
946 }
947 seen[match[1]] = true
948 }
949 for _, forbidden := range []string{"autofocus", "minlength=", "maxlength=", "pattern=", "confirmation", `role="button"`, `aria-invalid="false"`, `aria-invalid="undefined"`} {
950 if strings.Contains(strings.ToLower(body), strings.ToLower(forbidden)) {
951 t.Fatalf("rendered forbidden form contract %q", forbidden)
952 }
953 }
954 forms := renderedElementAttributes(body, "form")
955 if len(forms) != page.formCount {
956 t.Fatalf("form count=%d, want %d", len(forms), page.formCount)
957 }
958 passwordCount := 0
959 for _, attributes := range renderedElementAttributes(body, "input") {
960 if !strings.EqualFold(attributes["type"], "password") {
961 continue
962 }
963 passwordCount++
964 if _, present := attributes["value"]; present {
965 t.Fatal("rendered password input has a value attribute")
966 }
967 }
968 if passwordCount != page.passwordCount {
969 t.Fatalf("password input count=%d, want %d", passwordCount, page.passwordCount)
970 }
971 })
972 }
973}
974
975func TestFormLabelsNativeAttributesAndPersistentPolicy(t *testing.T) {
976 templates := testTemplates(t)
977 login, err := executeTemplate(templates.Login, LoginPageData{FormAction: "/login?flow=flow", CSRFToken: "csrf", EnableRegistration: true})
978 if err != nil {
979 t.Fatal(err)
980 }
981 register, err := executeTemplate(templates.Register, RegisterPageData{FormAction: "/register?flow=flow", CSRFToken: "csrf"})
982 if err != nil {
983 t.Fatal(err)
984 }
985 settings, err := executeTemplate(templates.ChangePassword, ChangePasswordPageData{FormAction: "/settings?flow=flow", CSRFToken: "csrf", Username: "tester"})
986 if err != nil {
987 t.Fatal(err)
988 }
989
990 contracts := []struct {
991 name string
992 body string
993 fragments []string
994 }{
995 {
996 "login",
997 string(login),
998 []string{
999 `<label for="login-username">Username</label>`,
1000 `id="login-username" type="text" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required`,
1001 `<label for="login-password">Password</label>`,
1002 `id="login-password" type="password" name="password" autocomplete="current-password" required`,
1003 `<button id="login-submit" class="subbmit-button" type="submit">Sign in</button>`,
1004 `<a href="/register">Create account</a>`,
1005 },
1006 },
1007 {
1008 "register",
1009 string(register),
1010 []string{
1011 `<label for="register-username">Username</label>`,
1012 `id="register-username" type="text" name="username" value="" autocomplete="username" autocapitalize="none" spellcheck="false" required`,
1013 `<label for="register-password">Password</label>`,
1014 `id="register-password" type="password" name="password" autocomplete="new-password" required aria-describedby="register-password-policy register-password-symbols"`,
1015 `<button id="register-submit" type="submit">Create account</button>`,
1016 `<a href="/login">Sign in</a>`,
1017 },
1018 },
1019 {
1020 "settings",
1021 string(settings),
1022 []string{
1023 `<label for="change-password">New password</label>`,
1024 `id="change-password" type="password" name="password" autocomplete="new-password" required aria-describedby="change-password-policy change-password-symbols"`,
1025 `<button id="change-password-submit" type="submit">Change password</button>`,
1026 `<a href="/">Back to account</a>`,
1027 },
1028 },
1029 }
1030 for _, contract := range contracts {
1031 t.Run(contract.name, func(t *testing.T) {
1032 for _, fragment := range contract.fragments {
1033 if !strings.Contains(contract.body, fragment) {
1034 t.Fatalf("missing semantic fragment %q", fragment)
1035 }
1036 }
1037 if strings.Contains(contract.body, `aria-invalid=`) {
1038 t.Fatal("pristine form rendered aria-invalid")
1039 }
1040 })
1041 }
1042
1043 const policy = "Use at least 20 bytes, including an uppercase letter, lowercase letter, number, and an ASCII symbol or space."
1044 const symbols = `!"#$%&'()*+,-./:;<=>?@[\]^_{|}~`
1045 for name, body := range map[string]string{"register": string(register), "settings": string(settings)} {
1046 unescaped := html.UnescapeString(body)
1047 if strings.Count(unescaped, policy) != 1 {
1048 t.Fatalf("%s policy guidance count=%d, want 1", name, strings.Count(unescaped, policy))
1049 }
1050 if !strings.Contains(unescaped, "Accepted ASCII symbols: <code>"+symbols+"</code>. ASCII space is also accepted.") {
1051 t.Fatalf("%s omitted exact rendered ASCII symbols", name)
1052 }
1053 }
1054}
1055
1056func TestValidationSummariesAndFieldAssociations(t *testing.T) {
1057 templates := testTemplates(t)
1058 usernameErrors := []ValidationError{
1059 {Field: "username", Message: "First username error."},
1060 {Field: "username", Message: "Second username error."},
1061 }
1062 passwordErrors := []ValidationError{
1063 {Field: "password", Message: testPasswordLengthMessage},
1064 {Field: "password", Message: testPasswordCompositionMessage},
1065 }
1066 register, err := executeTemplate(templates.Register, RegisterPageData{
1067 FormAction: "/register?flow=flow",
1068 CSRFToken: "csrf",
1069 UsernameErrors: usernameErrors,
1070 PasswordErrors: passwordErrors,
1071 GeneralError: registrationRejectedMessage,
1072 })
1073 if err != nil {
1074 t.Fatal(err)
1075 }
1076 body := string(register)
1077 if strings.Count(body, `role="alert"`) != 1 || strings.Index(body, `role="alert"`) > strings.Index(body, `id="register-username"`) {
1078 t.Fatal("registration alert summary is not unique and source-ordered before fields")
1079 }
1080 unescapedBody := html.UnescapeString(body)
1081 for _, message := range []string{"First username error.", "Second username error.", testPasswordLengthMessage, testPasswordCompositionMessage} {
1082 if strings.Count(unescapedBody, message) != 2 {
1083 t.Fatalf("error %q count=%d, want summary and field error", message, strings.Count(unescapedBody, message))
1084 }
1085 }
1086 for _, fragment := range []string{
1087 `aria-invalid="true" aria-describedby="register-username-error-0 register-username-error-1"`,
1088 `id="register-username-error-0"`,
1089 `id="register-username-error-1"`,
1090 `aria-describedby="register-password-policy register-password-symbols register-password-error-0 register-password-error-1" aria-invalid="true"`,
1091 `id="register-password-error-0"`,
1092 `id="register-password-error-1"`,
1093 } {
1094 if !strings.Contains(body, fragment) {
1095 t.Fatalf("registration errors missing association %q", fragment)
1096 }
1097 }
1098
1099 settings, err := executeTemplate(templates.ChangePassword, ChangePasswordPageData{
1100 Username: "tester",
1101 FormAction: "/settings?flow=flow",
1102 CSRFToken: "csrf",
1103 PasswordErrors: passwordErrors,
1104 GeneralError: passwordChangeRejectedMessage,
1105 })
1106 if err != nil {
1107 t.Fatal(err)
1108 }
1109 body = string(settings)
1110 if strings.Count(body, `role="alert"`) != 1 || strings.Index(body, `role="alert"`) > strings.Index(body, `id="change-password"`) {
1111 t.Fatal("settings alert summary is not unique and source-ordered before its field")
1112 }
1113 for _, fragment := range []string{
1114 `aria-describedby="change-password-policy change-password-symbols change-password-error-0 change-password-error-1" aria-invalid="true"`,
1115 `id="change-password-error-0"`,
1116 `id="change-password-error-1"`,
1117 } {
1118 if !strings.Contains(body, fragment) {
1119 t.Fatalf("settings errors missing association %q", fragment)
1120 }
1121 }
1122}
1123
1124func TestTemplateHierarchyNavigationAndEscaping(t *testing.T) {
1125 templates := testTemplates(t)
1126 account, err := executeTemplate(templates.WhoAmI, AccountPageData{Username: `<script>alert("secret")</script>`})
1127 if err != nil {
1128 t.Fatal(err)
1129 }
1130 accountBody := string(account)
1131 if strings.Contains(accountBody, `<script>`) || !strings.Contains(accountBody, `&lt;script&gt;`) {
1132 t.Fatal("account username was not safely escaped")
1133 }
1134 for _, fragment := range []string{`<h1>Account</h1>`, `<a href="/settings">Change password</a>`, `<a href="/logout">Log out</a>`} {
1135 if !strings.Contains(accountBody, fragment) {
1136 t.Fatalf("account missing hierarchy/navigation %q", fragment)
1137 }
1138 }
1139
1140 success, err := executeTemplate(templates.ChangePasswordSuccess, nil)
1141 if err != nil {
1142 t.Fatal(err)
1143 }
1144 for _, fragment := range []string{`<h1>Password changed</h1>`, `<p role="status">Password changed successfully.</p>`, `<a href="/">Back to account</a>`} {
1145 if !strings.Contains(string(success), fragment) {
1146 t.Fatalf("success page missing %q", fragment)
1147 }
1148 }
1149
1150 errorPage, err := executeTemplate(templates.Error, ErrorPageData{
1151 Title: "Authentication unavailable",
1152 Message: authenticationUnavailableMessage,
1153 RecoveryHref: "/login",
1154 RecoveryText: "Try signing in again",
1155 })
1156 if err != nil {
1157 t.Fatal(err)
1158 }
1159 errorBody := string(errorPage)
1160 if strings.Count(errorBody, "<a ") != 1 {
1161 t.Fatalf("generic error recovery link count=%d, want 1", strings.Count(errorBody, "<a "))
1162 }
1163 for _, fragment := range []string{`<h1>Authentication unavailable</h1>`, authenticationUnavailableMessage, `<a href="/login">Try signing in again</a>`} {
1164 if !strings.Contains(errorBody, fragment) {
1165 t.Fatalf("generic error page missing %q", fragment)
1166 }
1167 }
1168
1169 register, err := executeTemplate(templates.Register, RegisterPageData{
1170 FormAction: "/register?flow=flow&return=<unsafe>",
1171 CSRFToken: `<csrf&secret>`,
1172 Username: `<img src=x onerror=secret>`,
1173 })
1174 if err != nil {
1175 t.Fatal(err)
1176 }
1177 registerBody := string(register)
1178 for _, forbidden := range []string{`<unsafe>`, `<csrf&secret>`, `<img src=x onerror=secret>`} {
1179 if strings.Contains(registerBody, forbidden) {
1180 t.Fatalf("registration rendered unsafe value %q", forbidden)
1181 }
1182 }
1183 assertNoHiddenInputsNamed(t, registerBody, "method", "username")
1184
1185 settings, err := executeTemplate(templates.ChangePassword, ChangePasswordPageData{Username: "tester", FormAction: "/settings?flow=flow", CSRFToken: "csrf"})
1186 if err != nil {
1187 t.Fatal(err)
1188 }
1189 settingsBody := string(settings)
1190 assertNoHiddenInputsNamed(t, settingsBody, "method", "username")
1191}