| package main |
| |
| import ( |
| "encoding/json" |
| "errors" |
| "html" |
| "html/template" |
| "io/fs" |
| "net/http" |
| "net/http/httptest" |
| "net/url" |
| "reflect" |
| "regexp" |
| "strings" |
| "sync/atomic" |
| "testing" |
| ) |
| |
| const ( |
| sanitizedRetryFlowFixture = `{ |
| "id":"retry-flow", |
| "obsolete_state":"obsolete-upstream-state", |
| "ui":{ |
| "action":"https://upstream.invalid/do-not-render", |
| "nodes":[ |
| {"attributes":{"name":"traits.username","value":"upstream-value-must-not-render"}}, |
| {"attributes":{"name":"csrf_token","value":"retry-csrf"}} |
| ], |
| "messages":[{"id":4000007,"text":"upstream prose must not render"}] |
| } |
| }` |
| sanitizedOtherRetryFlowFixture = `{ |
| "id":"other-flow", |
| "obsolete_state":"obsolete-upstream-state", |
| "ui":{"action":"https://upstream.invalid/other-action","nodes":[{"attributes":{"name":"csrf_token","value":"other-csrf"}}],"messages":[{"id":1234,"text":"unsafe upstream guidance"}]} |
| }` |
| sanitizedExpiredFlowFixture = `{"error":{"id":"self_service_flow_expired","message":"unsafe expiry prose"}}` |
| sanitizedCSRFFixture = `{"error":{"id":"security_csrf_violation","message":"unsafe CSRF prose"}}` |
| |
| obsoleteFlowSentinel = "obsolete-submitted-flow" |
| obsoleteCSRFSentinel = "obsolete-submitted-csrf" |
| submittedPasswordSentinel = "Submitted-Password-Sentinel-9!" |
| ) |
| |
| func TestStage3LocalAssetAndPaletteContracts(t *testing.T) { |
| baseTemplate, err := fs.ReadFile(tmpls, "templates/base.html") |
| if err != nil { |
| t.Fatal(err) |
| } |
| markup := string(baseTemplate) |
| for _, expected := range []string{`href="/static/base.css?v=0.0.1"`, `href="/static/main.css?v=0.0.3"`} { |
| if strings.Count(markup, expected) != 1 { |
| t.Fatalf("base template local stylesheet %q count=%d, want 1", expected, strings.Count(markup, expected)) |
| } |
| } |
| for _, forbidden := range []string{"pico", "cdnjs", "<script", "http://", "https://"} { |
| if strings.Contains(strings.ToLower(markup), forbidden) { |
| t.Fatalf("base template contains forbidden asset reference %q", forbidden) |
| } |
| } |
| if links := strings.Count(markup, `<link rel="stylesheet"`); links != 2 { |
| t.Fatalf("stylesheet link count=%d, want 2", links) |
| } |
| |
| baseCSS, err := fs.ReadFile(static, "static/base.css") |
| if err != nil { |
| t.Fatal(err) |
| } |
| mainCSS, err := fs.ReadFile(static, "static/main.css") |
| if err != nil { |
| t.Fatal(err) |
| } |
| allCSS := string(baseCSS) + "\n" + string(mainCSS) |
| opaqueColor := regexp.MustCompile(`(?i)#[0-9a-f]{3,8}\b|rgba?\(`) |
| colors := opaqueColor.FindAllString(allCSS, -1) |
| wantColors := []string{"#d6d6d6", "#3a3a3a", "#7f9f7f", "#d4888d"} |
| if !reflect.DeepEqual(colors, wantColors) { |
| t.Fatalf("opaque CSS colors=%v, want exact palette once in base.css", colors) |
| } |
| for _, forbidden := range []string{"--pico-", "box-shadow", "@font-face", "url("} { |
| if strings.Contains(strings.ToLower(allCSS), forbidden) { |
| t.Fatalf("CSS contains forbidden presentation primitive %q", forbidden) |
| } |
| } |
| for _, primitive := range []string{"box-sizing: border-box", "border-radius: 0", "min-height: 44px", ":focus-visible", "overflow-wrap: anywhere", "prefers-reduced-motion", "--font-mono"} { |
| if !strings.Contains(string(baseCSS), primitive) { |
| t.Fatalf("base.css omitted owned primitive %q", primitive) |
| } |
| } |
| for _, presentation := range []string{"width: min(100%, 500px)", "min-height: 100vh", "min-height: 100dvh", "align-items: flex-start", "input:-webkit-autofill"} { |
| if !strings.Contains(string(mainCSS), presentation) { |
| t.Fatalf("main.css omitted auth presentation contract %q", presentation) |
| } |
| } |
| removed := []struct { |
| filesystem fs.FS |
| path string |
| }{ |
| {static, "static/" + "pico.2.0.6.min.css"}, |
| {tmpls, "templates/" + "consent.html"}, |
| } |
| for _, asset := range removed { |
| if _, err := fs.Stat(asset.filesystem, asset.path); err == nil || !errors.Is(err, fs.ErrNotExist) { |
| t.Fatalf("removed embedded asset %q still exists", asset.path) |
| } |
| } |
| if _, present := reflect.TypeOf(Templates{}).FieldByName("Consent"); present { |
| t.Fatal("Templates retains dormant consent storage") |
| } |
| } |
| |
| func testTemplates(t *testing.T) *Templates { |
| t.Helper() |
| templates, err := ParseTemplates(tmpls) |
| if err != nil { |
| t.Fatal(err) |
| } |
| return templates |
| } |
| |
| func withKratosServer(t *testing.T, handler http.Handler) *httptest.Server { |
| t.Helper() |
| server := httptest.NewServer(handler) |
| old := *kratos |
| *kratos = server.URL |
| t.Cleanup(func() { |
| *kratos = old |
| server.Close() |
| }) |
| return server |
| } |
| |
| func testServer(t *testing.T, kratosURL string) *Server { |
| t.Helper() |
| return NewServer(0, kratosURL, nil, testTemplates(t), true, nil, "") |
| } |
| |
| func formRequest(method, target string, values url.Values) *http.Request { |
| request := httptest.NewRequest(method, target, strings.NewReader(values.Encode())) |
| request.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| return request |
| } |
| |
| func setJSONResponse(w http.ResponseWriter, status int, body string) { |
| w.Header().Set("Content-Type", "application/json") |
| w.WriteHeader(status) |
| _, _ = w.Write([]byte(body)) |
| } |
| |
| func validFlowFixture(id, csrf string) string { |
| body, _ := json.Marshal(map[string]any{ |
| "id": id, |
| "ui": map[string]any{"nodes": []any{ |
| map[string]any{"attributes": map[string]any{"name": "identifier", "value": ""}}, |
| map[string]any{"attributes": map[string]any{"name": "csrf_token", "value": csrf}}, |
| }}, |
| }) |
| return string(body) |
| } |
| |
| func whoAmIFixture() string { |
| return `{"identity":{"id":"identity-id","traits":{"username":"tester"}}}` |
| } |
| |
| func TestParseRetryFlowPinnedFixtures(t *testing.T) { |
| flow, csrf, duplicate, err := parseRetryFlow([]byte(sanitizedRetryFlowFixture)) |
| if err != nil || flow != "retry-flow" || csrf != "retry-csrf" || !duplicate { |
| t.Fatalf("duplicate fixture parsed as flow=%q csrf=%q duplicate=%v err=%v", flow, csrf, duplicate, err) |
| } |
| flow, csrf, duplicate, err = parseRetryFlow([]byte(sanitizedOtherRetryFlowFixture)) |
| if err != nil || flow != "other-flow" || csrf != "other-csrf" || duplicate { |
| t.Fatalf("other fixture parsed as flow=%q csrf=%q duplicate=%v err=%v", flow, csrf, duplicate, err) |
| } |
| if got := parseOryErrorID([]byte(sanitizedExpiredFlowFixture)); got != "self_service_flow_expired" { |
| t.Fatalf("expired error id = %q", got) |
| } |
| if got := parseOryErrorID([]byte(sanitizedCSRFFixture)); got != "security_csrf_violation" { |
| t.Fatalf("CSRF error id = %q", got) |
| } |
| } |
| |
| func TestParseRetryFlowRejectsMalformedMinimumState(t *testing.T) { |
| tests := []string{ |
| `not-json`, |
| `{"id":"","ui":{"nodes":[{"attributes":{"name":"csrf_token","value":"token"}}]}}`, |
| `{"id":"flow","ui":{"nodes":[]}}`, |
| `{"id":"flow","ui":{"nodes":[{"attributes":{"name":"csrf_token","value":""}}]}}`, |
| `{"id":"flow","ui":{"nodes":[{"attributes":{"name":"csrf_token","value":"one"}},{"attributes":{"name":"csrf_token","value":"two"}}]}}`, |
| } |
| for _, fixture := range tests { |
| if _, _, _, err := parseRetryFlow([]byte(fixture)); err == nil { |
| t.Fatalf("malformed fixture accepted: %s", fixture) |
| } |
| } |
| } |
| |
| func TestAuthNoticeAllowlistAndConsumption(t *testing.T) { |
| invalid := httptest.NewRecorder() |
| setAuthNotice(invalid, "arbitrary") |
| if len(invalid.Result().Cookies()) != 0 { |
| t.Fatal("arbitrary notice code was set") |
| } |
| |
| set := httptest.NewRecorder() |
| setAuthNotice(set, authNoticeLoginInvalid) |
| cookies := set.Result().Cookies() |
| if len(cookies) != 1 || cookies[0].Value != authNoticeLoginInvalid || cookies[0].MaxAge != 120 || !cookies[0].HttpOnly { |
| t.Fatalf("notice cookie = %#v", cookies) |
| } |
| |
| request := httptest.NewRequest(http.MethodGet, "/login?flow=valid", nil) |
| request.AddCookie(cookies[0]) |
| if code, clear := pendingAuthNotice(request, authNoticeLoginInvalid); code != authNoticeLoginInvalid || !clear { |
| t.Fatalf("pending matching notice code=%q clear=%v", code, clear) |
| } |
| |
| mismatchRequest := httptest.NewRequest(http.MethodGet, "/register?flow=valid", nil) |
| mismatchRequest.AddCookie(cookies[0]) |
| if code, clear := pendingAuthNotice(mismatchRequest, authNoticeFlowExpired); code != "" || clear { |
| t.Fatalf("valid nonmatching notice code=%q clear=%v", code, clear) |
| } |
| |
| arbitraryRequest := httptest.NewRequest(http.MethodGet, "/login?flow=valid", nil) |
| arbitraryRequest.AddCookie(&http.Cookie{Name: authNoticeCookieName, Value: "arbitrary-client-value"}) |
| if code, clear := pendingAuthNotice(arbitraryRequest, authNoticeLoginInvalid, authNoticeFlowExpired); code != "" || !clear { |
| t.Fatalf("arbitrary notice code=%q clear=%v", code, clear) |
| } |
| } |
| |
| func responseCookieNamed(response *http.Response, name string) *http.Cookie { |
| for _, cookie := range response.Cookies() { |
| if cookie.Name == name { |
| return cookie |
| } |
| } |
| return nil |
| } |
| |
| func TestValidFormClearsArbitraryClientNotice(t *testing.T) { |
| upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| setJSONResponse(w, http.StatusOK, validFlowFixture("valid-flow", "valid-csrf")) |
| })) |
| server := testServer(t, upstream.URL) |
| request := httptest.NewRequest(http.MethodGet, "/login?flow=valid-flow", nil) |
| request.AddCookie(&http.Cookie{Name: authNoticeCookieName, Value: "arbitrary-client-value"}) |
| recorder := httptest.NewRecorder() |
| server.loginInitiate(recorder, request) |
| if recorder.Code != http.StatusOK { |
| t.Fatalf("status = %d, want 200", recorder.Code) |
| } |
| cleared := responseCookieNamed(recorder.Result(), authNoticeCookieName) |
| if cleared == nil || cleared.MaxAge != -1 { |
| t.Fatalf("arbitrary notice was not cleared after valid form render: %#v", cleared) |
| } |
| } |
| |
| func TestMatchingNoticeNotConsumedWithoutValidForm(t *testing.T) { |
| tests := []struct { |
| name string |
| upstream http.Handler |
| breakLogin bool |
| status int |
| }{ |
| { |
| name: "flow fetch failure", |
| upstream: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| setJSONResponse(w, http.StatusInternalServerError, `{"error":"unavailable"}`) |
| }), |
| status: http.StatusBadGateway, |
| }, |
| { |
| name: "template execution failure", |
| upstream: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| setJSONResponse(w, http.StatusOK, validFlowFixture("valid-flow", "valid-csrf")) |
| }), |
| breakLogin: true, |
| status: http.StatusInternalServerError, |
| }, |
| } |
| for _, tt := range tests { |
| t.Run(tt.name, func(t *testing.T) { |
| upstream := withKratosServer(t, tt.upstream) |
| server := testServer(t, upstream.URL) |
| if tt.breakLogin { |
| server.tmpls.Login = template.Must(template.New("broken-login").Funcs(template.FuncMap{ |
| "fail": func() (string, error) { return "", http.ErrAbortHandler }, |
| }).Parse(`{{fail}}`)) |
| } |
| request := httptest.NewRequest(http.MethodGet, "/login?flow=valid-flow", nil) |
| request.AddCookie(&http.Cookie{Name: authNoticeCookieName, Value: authNoticeFlowExpired}) |
| recorder := httptest.NewRecorder() |
| server.loginInitiate(recorder, request) |
| if recorder.Code != tt.status { |
| t.Fatalf("status = %d, want %d", recorder.Code, tt.status) |
| } |
| if cookie := responseCookieNamed(recorder.Result(), authNoticeCookieName); cookie != nil { |
| t.Fatalf("matching notice was consumed without a valid form: %#v", cookie) |
| } |
| }) |
| } |
| } |
| |
| func TestRegistrationLocalValidationUsesSharedErrorsWithoutUpstream(t *testing.T) { |
| var calls atomic.Int32 |
| upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| calls.Add(1) |
| http.Error(w, "unexpected", http.StatusInternalServerError) |
| })) |
| server := testServer(t, upstream.URL) |
| recorder := httptest.NewRecorder() |
| request := formRequest(http.MethodPost, "/register?flow=current-flow", url.Values{ |
| "csrf_token": {"current-csrf"}, "username": {"ab"}, "password": {"short-secret"}, |
| }) |
| server.register(recorder, request) |
| if recorder.Code != http.StatusUnprocessableEntity { |
| t.Fatalf("status = %d, want 422", recorder.Code) |
| } |
| body := html.UnescapeString(recorder.Body.String()) |
| for _, expected := range []string{testUsernameLengthMessage, testPasswordLengthMessage, testPasswordCompositionMessage, `value="ab"`, `action="/register?flow=current-flow"`, `value="current-csrf"`} { |
| if !strings.Contains(body, expected) { |
| t.Fatalf("response omitted %q", expected) |
| } |
| } |
| if strings.Contains(body, "short-secret") { |
| t.Fatal("rejected registration password was rendered") |
| } |
| if calls.Load() != 0 { |
| t.Fatalf("local validation made %d upstream calls", calls.Load()) |
| } |
| } |
| |
| func TestSettingsLocalValidationUsesSharedErrorsWithoutSubmission(t *testing.T) { |
| var submissions atomic.Int32 |
| upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| if r.URL.Path == "/sessions/whoami" { |
| setJSONResponse(w, http.StatusOK, whoAmIFixture()) |
| return |
| } |
| if r.Method == http.MethodPost { |
| submissions.Add(1) |
| } |
| http.Error(w, "unexpected", http.StatusInternalServerError) |
| })) |
| server := testServer(t, upstream.URL) |
| recorder := httptest.NewRecorder() |
| request := formRequest(http.MethodPost, "/settings?flow=current-flow", url.Values{ |
| "csrf_token": {"current-csrf"}, "password": {"short-secret"}, |
| }) |
| server.changePassword(recorder, request) |
| if recorder.Code != http.StatusUnprocessableEntity { |
| t.Fatalf("status = %d, want 422", recorder.Code) |
| } |
| body := html.UnescapeString(recorder.Body.String()) |
| for _, expected := range []string{testPasswordLengthMessage, testPasswordCompositionMessage, `action="/settings?flow=current-flow"`, `value="current-csrf"`} { |
| if !strings.Contains(body, expected) { |
| t.Fatalf("response omitted %q", expected) |
| } |
| } |
| if strings.Contains(body, "short-secret") { |
| t.Fatal("rejected settings password was rendered") |
| } |
| if submissions.Load() != 0 { |
| t.Fatalf("local validation made %d settings submissions", submissions.Load()) |
| } |
| } |
| |
| func TestRegistrationExpectedRejectionsUseReturnedRetryState(t *testing.T) { |
| tests := []struct { |
| name string |
| fixture string |
| status int |
| message string |
| flow string |
| csrf string |
| }{ |
| {"duplicate", sanitizedRetryFlowFixture, http.StatusConflict, duplicateRegistrationMessage, "retry-flow", "retry-csrf"}, |
| {"other", sanitizedOtherRetryFlowFixture, http.StatusUnprocessableEntity, registrationRejectedMessage, "other-flow", "other-csrf"}, |
| } |
| for _, tt := range tests { |
| t.Run(tt.name, func(t *testing.T) { |
| upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| setJSONResponse(w, http.StatusBadRequest, tt.fixture) |
| })) |
| server := testServer(t, upstream.URL) |
| recorder := httptest.NewRecorder() |
| request := formRequest(http.MethodPost, "/register?flow="+obsoleteFlowSentinel, url.Values{ |
| "csrf_token": {obsoleteCSRFSentinel}, "username": {"retained-user"}, "password": {submittedPasswordSentinel}, |
| }) |
| server.register(recorder, request) |
| if recorder.Code != tt.status { |
| t.Fatalf("status = %d, want %d", recorder.Code, tt.status) |
| } |
| body := recorder.Body.String() |
| for _, expected := range []string{tt.message, `action="/register?flow=` + tt.flow + `"`, `value="` + tt.csrf + `"`, `value="retained-user"`} { |
| if !strings.Contains(body, expected) { |
| t.Fatalf("response omitted %q", expected) |
| } |
| } |
| 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"} { |
| if strings.Contains(body, forbidden) { |
| t.Fatalf("response rendered forbidden upstream/password text %q", forbidden) |
| } |
| } |
| }) |
| } |
| } |
| |
| func TestSettingsExpectedRejectionUsesReturnedRetryState(t *testing.T) { |
| upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| switch r.URL.Path { |
| case "/sessions/whoami": |
| setJSONResponse(w, http.StatusOK, whoAmIFixture()) |
| case "/self-service/settings": |
| setJSONResponse(w, http.StatusBadRequest, sanitizedOtherRetryFlowFixture) |
| default: |
| http.NotFound(w, r) |
| } |
| })) |
| server := testServer(t, upstream.URL) |
| recorder := httptest.NewRecorder() |
| request := formRequest(http.MethodPost, "/settings?flow="+obsoleteFlowSentinel, url.Values{ |
| "csrf_token": {obsoleteCSRFSentinel}, "password": {submittedPasswordSentinel}, |
| }) |
| server.changePassword(recorder, request) |
| if recorder.Code != http.StatusUnprocessableEntity { |
| t.Fatalf("status = %d, want 422", recorder.Code) |
| } |
| body := recorder.Body.String() |
| for _, expected := range []string{passwordChangeRejectedMessage, `action="/settings?flow=other-flow"`, `value="other-csrf"`} { |
| if !strings.Contains(body, expected) { |
| t.Fatalf("response omitted %q", expected) |
| } |
| } |
| for _, forbidden := range []string{submittedPasswordSentinel, obsoleteFlowSentinel, obsoleteCSRFSentinel, "https://upstream.invalid/other-action", "obsolete-upstream-state", "unsafe upstream"} { |
| if strings.Contains(body, forbidden) { |
| t.Fatalf("settings rejection rendered forbidden state %q", forbidden) |
| } |
| } |
| } |
| |
| func TestSettingsResponseCookiesAreNeverForwarded(t *testing.T) { |
| const sentinelCookieName = "kratos_settings_sentinel" |
| tests := []struct { |
| name string |
| status int |
| fixture string |
| wantStatus int |
| wantNotice bool |
| }{ |
| {"success", http.StatusOK, `{}`, http.StatusOK, false}, |
| {"expected rejection", http.StatusBadRequest, sanitizedOtherRetryFlowFixture, http.StatusUnprocessableEntity, false}, |
| {"unexpected failure", http.StatusInternalServerError, `{"obsolete_state":"obsolete-upstream-state","ui":{"action":"https://upstream.invalid/generic-action"}}`, http.StatusBadGateway, false}, |
| {"expiry", http.StatusGone, sanitizedExpiredFlowFixture, http.StatusSeeOther, true}, |
| } |
| for _, tt := range tests { |
| t.Run(tt.name, func(t *testing.T) { |
| upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| switch r.URL.Path { |
| case "/sessions/whoami": |
| setJSONResponse(w, http.StatusOK, whoAmIFixture()) |
| case "/self-service/settings": |
| http.SetCookie(w, &http.Cookie{Name: sentinelCookieName, Value: "must-not-forward", Path: "/"}) |
| setJSONResponse(w, tt.status, tt.fixture) |
| default: |
| http.NotFound(w, r) |
| } |
| })) |
| server := testServer(t, upstream.URL) |
| recorder := httptest.NewRecorder() |
| request := formRequest(http.MethodPost, "/settings?flow="+obsoleteFlowSentinel, url.Values{ |
| "csrf_token": {obsoleteCSRFSentinel}, "password": {submittedPasswordSentinel}, |
| }) |
| server.changePassword(recorder, request) |
| if recorder.Code != tt.wantStatus { |
| t.Fatalf("status = %d, want %d", recorder.Code, tt.wantStatus) |
| } |
| response := recorder.Result() |
| responseCookies := response.Cookies() |
| if cookie := responseCookieNamed(response, sentinelCookieName); cookie != nil { |
| t.Fatalf("Kratos settings cookie was forwarded: %#v", cookie) |
| } |
| notice := responseCookieNamed(response, authNoticeCookieName) |
| if tt.wantNotice { |
| if len(responseCookies) != 1 || notice == nil || notice.Value != authNoticeFlowExpired { |
| t.Fatalf("expiry response cookies = %#v", responseCookies) |
| } |
| } else if len(responseCookies) != 0 { |
| t.Fatalf("unexpected settings response cookies = %#v", responseCookies) |
| } |
| body := recorder.Body.String() |
| for _, forbidden := range []string{submittedPasswordSentinel, obsoleteFlowSentinel, obsoleteCSRFSentinel, "https://upstream.invalid/generic-action", "obsolete-upstream-state"} { |
| if strings.Contains(body, forbidden) { |
| t.Fatalf("settings output rendered forbidden state %q", forbidden) |
| } |
| } |
| }) |
| } |
| } |
| |
| func TestRegistrationExpiredAndCSRFSubmissionsRestartWithNotice(t *testing.T) { |
| tests := []struct { |
| status int |
| fixture string |
| }{{http.StatusGone, sanitizedExpiredFlowFixture}, {http.StatusForbidden, sanitizedCSRFFixture}} |
| for _, tt := range tests { |
| t.Run(http.StatusText(tt.status), func(t *testing.T) { |
| upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| setJSONResponse(w, tt.status, tt.fixture) |
| })) |
| server := testServer(t, upstream.URL) |
| recorder := httptest.NewRecorder() |
| request := formRequest(http.MethodPost, "/register?flow=old", url.Values{ |
| "csrf_token": {"old"}, "username": {"valid-user"}, "password": {"Correct-Horse-Battery-9!"}, |
| }) |
| server.register(recorder, request) |
| if recorder.Code != http.StatusSeeOther || recorder.Header().Get("Location") != upstream.URL+"/self-service/registration/browser" { |
| t.Fatalf("restart response status=%d location=%q", recorder.Code, recorder.Header().Get("Location")) |
| } |
| cookies := recorder.Result().Cookies() |
| if len(cookies) != 1 || cookies[0].Name != authNoticeCookieName || cookies[0].Value != authNoticeFlowExpired { |
| t.Fatalf("restart notice cookies = %#v", cookies) |
| } |
| }) |
| } |
| } |
| |
| func TestMalformedRetryStateAndUnexpectedFailureRenderGeneric502(t *testing.T) { |
| tests := []struct { |
| name string |
| status int |
| fixture string |
| }{ |
| {"malformed expected rejection", http.StatusBadRequest, `{"id":"flow","obsolete_state":"obsolete-upstream-state","ui":{"action":"https://upstream.invalid/generic-action","nodes":[]},"unsafe":"do not render"}`}, |
| {"unexpected status", http.StatusInternalServerError, `{"error":"private upstream failure"}`}, |
| {"wrong gone id", http.StatusGone, `{"error":{"id":"other","message":"private upstream failure"}}`}, |
| } |
| for _, tt := range tests { |
| t.Run(tt.name, func(t *testing.T) { |
| upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| setJSONResponse(w, tt.status, tt.fixture) |
| })) |
| server := testServer(t, upstream.URL) |
| recorder := httptest.NewRecorder() |
| request := formRequest(http.MethodPost, "/register?flow="+obsoleteFlowSentinel, url.Values{ |
| "csrf_token": {obsoleteCSRFSentinel}, "username": {"valid-user"}, "password": {submittedPasswordSentinel}, |
| }) |
| server.register(recorder, request) |
| if recorder.Code != http.StatusBadGateway { |
| t.Fatalf("status = %d, want 502", recorder.Code) |
| } |
| body := recorder.Body.String() |
| for _, expected := range []string{"Registration unavailable", authenticationUnavailableMessage, `href="/register"`, "Try registration again"} { |
| if !strings.Contains(body, expected) { |
| t.Fatalf("generic page omitted %q", expected) |
| } |
| } |
| for _, forbidden := range []string{submittedPasswordSentinel, obsoleteFlowSentinel, obsoleteCSRFSentinel, "https://upstream.invalid/generic-action", "obsolete-upstream-state", "private upstream", "do not render"} { |
| if strings.Contains(body, forbidden) { |
| t.Fatalf("generic page rendered forbidden state %q", forbidden) |
| } |
| } |
| }) |
| } |
| } |
| |
| func TestSettingsExpiredAndCSRFSubmissionsRestartMatchingFlow(t *testing.T) { |
| tests := []struct { |
| status int |
| fixture string |
| }{{http.StatusGone, sanitizedExpiredFlowFixture}, {http.StatusForbidden, sanitizedCSRFFixture}} |
| for _, tt := range tests { |
| t.Run(http.StatusText(tt.status), func(t *testing.T) { |
| upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| switch r.URL.Path { |
| case "/sessions/whoami": |
| setJSONResponse(w, http.StatusOK, whoAmIFixture()) |
| case "/self-service/settings": |
| setJSONResponse(w, tt.status, tt.fixture) |
| case "/self-service/settings/flows": |
| setJSONResponse(w, http.StatusOK, validFlowFixture("fresh-settings-flow", "fresh-settings-csrf")) |
| default: |
| http.NotFound(w, r) |
| } |
| })) |
| server := testServer(t, upstream.URL) |
| recorder := httptest.NewRecorder() |
| request := formRequest(http.MethodPost, "/settings?flow=old", url.Values{ |
| "csrf_token": {"old"}, "password": {"Correct-Horse-Battery-9!"}, |
| }) |
| server.changePassword(recorder, request) |
| if recorder.Code != http.StatusSeeOther || recorder.Header().Get("Location") != upstream.URL+"/self-service/settings/browser" { |
| t.Fatalf("restart response status=%d location=%q", recorder.Code, recorder.Header().Get("Location")) |
| } |
| cookies := recorder.Result().Cookies() |
| if len(cookies) != 1 || cookies[0].Value != authNoticeFlowExpired { |
| t.Fatalf("restart notice cookies = %#v", cookies) |
| } |
| |
| freshRequest := httptest.NewRequest(http.MethodGet, "/settings?flow=fresh-settings-flow", nil) |
| freshRequest.AddCookie(cookies[0]) |
| fresh := httptest.NewRecorder() |
| server.changePasswordForm(fresh, freshRequest) |
| if fresh.Code != http.StatusOK { |
| t.Fatalf("fresh settings GET status=%d, want 200", fresh.Code) |
| } |
| body := fresh.Body.String() |
| for _, expected := range []string{expiredFlowMessage, `action="/settings?flow=fresh-settings-flow"`, `value="fresh-settings-csrf"`} { |
| if !strings.Contains(body, expected) { |
| t.Fatalf("fresh settings form omitted %q", expected) |
| } |
| } |
| cleared := responseCookieNamed(fresh.Result(), authNoticeCookieName) |
| if cleared == nil || cleared.MaxAge != -1 { |
| t.Fatalf("fresh settings form did not clear notice: %#v", cleared) |
| } |
| }) |
| } |
| } |
| |
| func TestDependencyErrorContextContracts(t *testing.T) { |
| tests := []struct { |
| name string |
| context string |
| registration bool |
| title string |
| href string |
| link string |
| }{ |
| {"login", "login", true, "Authentication unavailable", "/login", "Try signing in again"}, |
| {"registration", "registration", true, "Registration unavailable", "/register", "Try registration again"}, |
| {"disabled registration", "registration", false, "Registration unavailable", "/login", "Go to sign in"}, |
| {"account", "account", true, "Account unavailable", "/", "Back to account"}, |
| } |
| for _, tt := range tests { |
| t.Run(tt.name, func(t *testing.T) { |
| server := NewServer(0, "http://kratos.invalid", nil, testTemplates(t), tt.registration, nil, "") |
| recorder := httptest.NewRecorder() |
| server.renderDependencyError(recorder, tt.context) |
| if recorder.Code != http.StatusBadGateway { |
| t.Fatalf("status = %d, want 502", recorder.Code) |
| } |
| body := recorder.Body.String() |
| for _, expected := range []string{tt.title, authenticationUnavailableMessage, `href="` + tt.href + `"`, tt.link} { |
| if !strings.Contains(body, expected) { |
| t.Fatalf("dependency page omitted %q", expected) |
| } |
| } |
| }) |
| } |
| } |
| |
| func TestExpiredFlowFetchRestartsAndValidRenderConsumesNotice(t *testing.T) { |
| var expired atomic.Bool |
| expired.Store(true) |
| upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| if expired.Load() { |
| setJSONResponse(w, http.StatusGone, sanitizedExpiredFlowFixture) |
| return |
| } |
| setJSONResponse(w, http.StatusOK, validFlowFixture("fresh-flow", "fresh-csrf")) |
| })) |
| server := testServer(t, upstream.URL) |
| first := httptest.NewRecorder() |
| server.registerInitiate(first, httptest.NewRequest(http.MethodGet, "/register?flow=expired", nil)) |
| if first.Code != http.StatusSeeOther || first.Header().Get("Location") != upstream.URL+"/self-service/registration/browser" { |
| t.Fatalf("expired fetch response status=%d location=%q", first.Code, first.Header().Get("Location")) |
| } |
| notice := first.Result().Cookies()[0] |
| expired.Store(false) |
| secondRequest := httptest.NewRequest(http.MethodGet, "/register?flow=fresh-flow", nil) |
| secondRequest.AddCookie(notice) |
| second := httptest.NewRecorder() |
| server.registerInitiate(second, secondRequest) |
| if second.Code != http.StatusOK || !strings.Contains(second.Body.String(), expiredFlowMessage) { |
| t.Fatalf("fresh render status=%d body=%q", second.Code, second.Body.String()) |
| } |
| if cookies := second.Result().Cookies(); len(cookies) != 1 || cookies[0].MaxAge != -1 { |
| t.Fatalf("fresh render did not clear notice: %#v", cookies) |
| } |
| } |
| |
| func TestInvalidLoginPreservesRedirectChallengeAndShowsFixedNotice(t *testing.T) { |
| location := "http://auth-ui.invalid/login?flow=retry-login" |
| upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| if r.Method == http.MethodPost { |
| w.Header().Set("Location", location) |
| w.WriteHeader(http.StatusSeeOther) |
| return |
| } |
| setJSONResponse(w, http.StatusOK, validFlowFixture("retry-login", "retry-csrf")) |
| })) |
| server := testServer(t, upstream.URL) |
| post := httptest.NewRecorder() |
| postRequest := formRequest(http.MethodPost, "/login?flow=old", url.Values{ |
| "csrf_token": {"old"}, "username": {"must-not-be-retained"}, "password": {"must-not-be-retained"}, |
| }) |
| postRequest.AddCookie(&http.Cookie{Name: "login_challenge", Value: "pending-challenge"}) |
| server.login(post, postRequest) |
| if post.Code != http.StatusSeeOther || post.Header().Get("Location") != location { |
| t.Fatalf("login rejection status=%d location=%q", post.Code, post.Header().Get("Location")) |
| } |
| cookies := post.Result().Cookies() |
| if len(cookies) != 1 || cookies[0].Name != authNoticeCookieName || cookies[0].Value != authNoticeLoginInvalid { |
| t.Fatalf("login rejection cookies = %#v", cookies) |
| } |
| |
| getRequest := httptest.NewRequest(http.MethodGet, "/login?flow=retry-login", nil) |
| getRequest.AddCookie(cookies[0]) |
| getRequest.AddCookie(&http.Cookie{Name: "login_challenge", Value: "pending-challenge"}) |
| get := httptest.NewRecorder() |
| server.loginInitiate(get, getRequest) |
| if get.Code != http.StatusOK { |
| t.Fatalf("final GET status = %d, want 200", get.Code) |
| } |
| body := get.Body.String() |
| if !strings.Contains(body, invalidLoginMessage) || !strings.Contains(body, `role="alert"`) || !strings.Contains(body, `action="/login?flow=retry-login"`) { |
| t.Fatal("final login GET omitted fixed notice, alert, or retry action") |
| } |
| if strings.Contains(body, "must-not-be-retained") { |
| t.Fatal("rejected login retained credentials") |
| } |
| } |
| |
| func TestExpiredLoginSubmissionRestartsAndPreservesChallenge(t *testing.T) { |
| tests := []struct { |
| name string |
| status int |
| fixture string |
| }{ |
| {"expired flow", http.StatusGone, sanitizedExpiredFlowFixture}, |
| {"CSRF violation", http.StatusForbidden, sanitizedCSRFFixture}, |
| } |
| for _, tt := range tests { |
| t.Run(tt.name, func(t *testing.T) { |
| upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| if r.Method == http.MethodPost { |
| setJSONResponse(w, tt.status, tt.fixture) |
| return |
| } |
| setJSONResponse(w, http.StatusOK, validFlowFixture("fresh-login-flow", "fresh-login-csrf")) |
| })) |
| server := testServer(t, upstream.URL) |
| server.defaultReturnTo = "https://return.example/dashboard" |
| postRequest := formRequest(http.MethodPost, "/login?flow="+obsoleteFlowSentinel, url.Values{ |
| "csrf_token": {obsoleteCSRFSentinel}, "username": {"submitted-user"}, "password": {submittedPasswordSentinel}, |
| }) |
| postRequest.AddCookie(&http.Cookie{Name: "login_challenge", Value: "pending-login-challenge"}) |
| post := httptest.NewRecorder() |
| server.login(post, postRequest) |
| if post.Code != http.StatusSeeOther || post.Header().Get("Location") != upstream.URL+"/self-service/login/browser?return_to=https://return.example/dashboard" { |
| t.Fatalf("restart status=%d location=%q", post.Code, post.Header().Get("Location")) |
| } |
| notice := responseCookieNamed(post.Result(), authNoticeCookieName) |
| if notice == nil || notice.Value != authNoticeFlowExpired { |
| t.Fatalf("restart notice = %#v", notice) |
| } |
| if cookie := responseCookieNamed(post.Result(), "login_challenge"); cookie != nil { |
| t.Fatalf("restart mutated pending login challenge: %#v", cookie) |
| } |
| |
| getRequest := httptest.NewRequest(http.MethodGet, "/login?flow=fresh-login-flow", nil) |
| getRequest.AddCookie(notice) |
| getRequest.AddCookie(&http.Cookie{Name: "login_challenge", Value: "pending-login-challenge"}) |
| get := httptest.NewRecorder() |
| server.loginInitiate(get, getRequest) |
| if get.Code != http.StatusOK { |
| t.Fatalf("fresh GET status=%d, want 200", get.Code) |
| } |
| body := get.Body.String() |
| for _, expected := range []string{expiredFlowMessage, `action="/login?flow=fresh-login-flow"`, `value="fresh-login-csrf"`} { |
| if !strings.Contains(body, expected) { |
| t.Fatalf("fresh login form omitted %q", expected) |
| } |
| } |
| for _, forbidden := range []string{submittedPasswordSentinel, obsoleteFlowSentinel, obsoleteCSRFSentinel, "submitted-user", "pending-login-challenge"} { |
| if strings.Contains(body, forbidden) { |
| t.Fatalf("fresh login form rendered forbidden state %q", forbidden) |
| } |
| } |
| clearedNotice := responseCookieNamed(get.Result(), authNoticeCookieName) |
| if clearedNotice == nil || clearedNotice.MaxAge != -1 { |
| t.Fatalf("fresh login form did not clear notice: %#v", clearedNotice) |
| } |
| if cookie := responseCookieNamed(get.Result(), "login_challenge"); cookie != nil { |
| t.Fatalf("fresh login form mutated pending challenge: %#v", cookie) |
| } |
| }) |
| } |
| } |
| |
| func TestUnexpectedLoginOutcomeRendersGeneric502WithoutHydraDecision(t *testing.T) { |
| upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| setJSONResponse(w, http.StatusBadRequest, `{"error":"upstream private prose","obsolete_state":"obsolete-upstream-state","ui":{"action":"https://upstream.invalid/generic-action"}}`) |
| })) |
| server := testServer(t, upstream.URL) |
| recorder := httptest.NewRecorder() |
| request := formRequest(http.MethodPost, "/login?flow="+obsoleteFlowSentinel, url.Values{ |
| "csrf_token": {obsoleteCSRFSentinel}, "username": {"user"}, "password": {submittedPasswordSentinel}, |
| }) |
| request.AddCookie(&http.Cookie{Name: "login_challenge", Value: "pending"}) |
| server.login(recorder, request) |
| if recorder.Code != http.StatusBadGateway { |
| t.Fatalf("status = %d, want 502", recorder.Code) |
| } |
| body := recorder.Body.String() |
| for _, expected := range []string{"Authentication unavailable", authenticationUnavailableMessage, `href="/login"`} { |
| if !strings.Contains(body, expected) { |
| t.Fatalf("generic login page omitted %q", expected) |
| } |
| } |
| for _, forbidden := range []string{submittedPasswordSentinel, obsoleteFlowSentinel, obsoleteCSRFSentinel, "https://upstream.invalid/generic-action", "obsolete-upstream-state", "upstream private prose"} { |
| if strings.Contains(body, forbidden) { |
| t.Fatalf("generic login page rendered forbidden state %q", forbidden) |
| } |
| } |
| } |
| |
| func TestMalformedLocalFormRequestReturns400(t *testing.T) { |
| server := testServer(t, "http://127.0.0.1:1") |
| request := httptest.NewRequest(http.MethodPost, "/register?flow=flow", strings.NewReader("username=%zz")) |
| request.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| recorder := httptest.NewRecorder() |
| server.register(recorder, request) |
| if recorder.Code != http.StatusBadRequest { |
| t.Fatalf("status = %d, want 400", recorder.Code) |
| } |
| } |
| |
| func TestRenderTemplateBuffersExecutionFailure(t *testing.T) { |
| tmpl := template.Must(template.New("broken").Funcs(template.FuncMap{ |
| "fail": func() (string, error) { return "", http.ErrAbortHandler }, |
| }).Parse(`prefix{{fail}}suffix`)) |
| recorder := httptest.NewRecorder() |
| renderTemplate(recorder, tmpl, http.StatusUnprocessableEntity, nil) |
| if recorder.Code != http.StatusInternalServerError || strings.Contains(recorder.Body.String(), "prefix") { |
| t.Fatalf("execution failure status=%d body=%q", recorder.Code, recorder.Body.String()) |
| } |
| } |
| |
| func renderedElementAttributes(body, element string) []map[string]string { |
| elementPattern := regexp.MustCompile(`(?is)<` + regexp.QuoteMeta(element) + `\b([^>]*)>`) |
| attributePattern := regexp.MustCompile(`(?i)([a-z_:][a-z0-9_:.-]*)(?:\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>]+))?`) |
| matches := elementPattern.FindAllStringSubmatch(body, -1) |
| result := make([]map[string]string, 0, len(matches)) |
| for _, match := range matches { |
| attributes := map[string]string{} |
| for _, attribute := range attributePattern.FindAllStringSubmatch(match[1], -1) { |
| value := attribute[2] |
| if len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'')) { |
| value = value[1 : len(value)-1] |
| } |
| attributes[strings.ToLower(attribute[1])] = html.UnescapeString(value) |
| } |
| result = append(result, attributes) |
| } |
| return result |
| } |
| |
| func assertNoHiddenInputsNamed(t *testing.T, body string, forbiddenNames ...string) { |
| t.Helper() |
| forbidden := make(map[string]bool, len(forbiddenNames)) |
| for _, name := range forbiddenNames { |
| forbidden[strings.ToLower(name)] = true |
| } |
| for _, attributes := range renderedElementAttributes(body, "input") { |
| if strings.EqualFold(attributes["type"], "hidden") && forbidden[strings.ToLower(attributes["name"])] { |
| t.Fatalf("rendered forbidden hidden input named %q", attributes["name"]) |
| } |
| } |
| } |
| |
| func TestRenderedElementAttributesAreOrderIndependent(t *testing.T) { |
| inputs := renderedElementAttributes(`<input value="one" name='username' type="hidden"><input TYPE=hidden disabled NAME=method><input name="alternate" required type='password'>`, "input") |
| if len(inputs) != 3 { |
| t.Fatalf("input count=%d, want 3", len(inputs)) |
| } |
| for index, expected := range []map[string]string{ |
| {"value": "one", "name": "username", "type": "hidden"}, |
| {"type": "hidden", "disabled": "", "name": "method"}, |
| {"name": "alternate", "required": "", "type": "password"}, |
| } { |
| if !reflect.DeepEqual(inputs[index], expected) { |
| t.Fatalf("input %d attributes=%v, want %v", index, inputs[index], expected) |
| } |
| } |
| } |
| |
| func TestPageModelsDoNotContainPasswordValues(t *testing.T) { |
| models := []any{LoginPageData{}, RegisterPageData{}, ChangePasswordPageData{}, AccountPageData{}, ErrorPageData{}} |
| for _, model := range models { |
| typeOf := reflect.TypeOf(model) |
| for i := 0; i < typeOf.NumField(); i++ { |
| field := typeOf.Field(i) |
| if field.Type.Kind() == reflect.String && strings.Contains(strings.ToLower(field.Name), "password") { |
| t.Fatalf("%s contains password-bearing string field %s", typeOf.Name(), field.Name) |
| } |
| } |
| } |
| } |
| |
| func TestSemanticTemplateContracts(t *testing.T) { |
| templates := testTemplates(t) |
| usernameErrors := []ValidationError{ |
| {Field: "username", Message: "First username error."}, |
| {Field: "username", Message: "Second username error."}, |
| } |
| passwordErrors := []ValidationError{ |
| {Field: "password", Message: testPasswordLengthMessage}, |
| {Field: "password", Message: testPasswordCompositionMessage}, |
| } |
| pages := []struct { |
| name string |
| tmpl *template.Template |
| data any |
| h1 string |
| formCount int |
| passwordCount int |
| }{ |
| {"login", templates.Login, LoginPageData{FormAction: "/login?flow=flow", CSRFToken: "csrf", EnableRegistration: true}, "Sign in", 1, 1}, |
| {"register", templates.Register, RegisterPageData{FormAction: "/register?flow=flow", CSRFToken: "csrf"}, "Create account", 1, 1}, |
| {"register errors", templates.Register, RegisterPageData{FormAction: "/register?flow=flow", CSRFToken: "csrf", Username: "retained", UsernameErrors: usernameErrors, PasswordErrors: passwordErrors, GeneralError: registrationRejectedMessage}, "Create account", 1, 1}, |
| {"change password", templates.ChangePassword, ChangePasswordPageData{FormAction: "/settings?flow=flow", CSRFToken: "csrf", Username: "tester"}, "Change password", 1, 1}, |
| {"change password errors", templates.ChangePassword, ChangePasswordPageData{FormAction: "/settings?flow=flow", CSRFToken: "csrf", Username: "tester", PasswordErrors: passwordErrors, GeneralError: passwordChangeRejectedMessage}, "Change password", 1, 1}, |
| {"account", templates.WhoAmI, AccountPageData{Username: "tester"}, "Account", 0, 0}, |
| {"success", templates.ChangePasswordSuccess, nil, "Password changed", 0, 0}, |
| {"error", templates.Error, ErrorPageData{Title: "Authentication unavailable", Message: authenticationUnavailableMessage, RecoveryHref: "/login", RecoveryText: "Try signing in again"}, "Authentication unavailable", 0, 0}, |
| } |
| idPattern := regexp.MustCompile(`\bid="([^"]+)"`) |
| for _, page := range pages { |
| t.Run(page.name, func(t *testing.T) { |
| rendered, err := executeTemplate(page.tmpl, page.data) |
| if err != nil { |
| t.Fatal(err) |
| } |
| body := string(rendered) |
| if strings.Count(body, "<main") != 1 { |
| t.Fatalf("main count=%d, want 1", strings.Count(body, "<main")) |
| } |
| if strings.Count(body, "<h1") != 1 || !strings.Contains(body, "<h1>"+page.h1+"</h1>") { |
| t.Fatalf("h1 contract missing for %q", page.h1) |
| } |
| seen := map[string]bool{} |
| for _, match := range idPattern.FindAllStringSubmatch(body, -1) { |
| if match[1] == "" || seen[match[1]] { |
| t.Fatalf("empty or duplicate id %q", match[1]) |
| } |
| seen[match[1]] = true |
| } |
| for _, forbidden := range []string{"autofocus", "minlength=", "maxlength=", "pattern=", "confirmation", `role="button"`, `aria-invalid="false"`, `aria-invalid="undefined"`} { |
| if strings.Contains(strings.ToLower(body), strings.ToLower(forbidden)) { |
| t.Fatalf("rendered forbidden form contract %q", forbidden) |
| } |
| } |
| forms := renderedElementAttributes(body, "form") |
| if len(forms) != page.formCount { |
| t.Fatalf("form count=%d, want %d", len(forms), page.formCount) |
| } |
| passwordCount := 0 |
| for _, attributes := range renderedElementAttributes(body, "input") { |
| if !strings.EqualFold(attributes["type"], "password") { |
| continue |
| } |
| passwordCount++ |
| if _, present := attributes["value"]; present { |
| t.Fatal("rendered password input has a value attribute") |
| } |
| } |
| if passwordCount != page.passwordCount { |
| t.Fatalf("password input count=%d, want %d", passwordCount, page.passwordCount) |
| } |
| }) |
| } |
| } |
| |
| func TestFormLabelsNativeAttributesAndPersistentPolicy(t *testing.T) { |
| templates := testTemplates(t) |
| login, err := executeTemplate(templates.Login, LoginPageData{FormAction: "/login?flow=flow", CSRFToken: "csrf", EnableRegistration: true}) |
| if err != nil { |
| t.Fatal(err) |
| } |
| register, err := executeTemplate(templates.Register, RegisterPageData{FormAction: "/register?flow=flow", CSRFToken: "csrf"}) |
| if err != nil { |
| t.Fatal(err) |
| } |
| settings, err := executeTemplate(templates.ChangePassword, ChangePasswordPageData{FormAction: "/settings?flow=flow", CSRFToken: "csrf", Username: "tester"}) |
| if err != nil { |
| t.Fatal(err) |
| } |
| |
| contracts := []struct { |
| name string |
| body string |
| fragments []string |
| }{ |
| { |
| "login", |
| string(login), |
| []string{ |
| `<label for="login-username">Username</label>`, |
| `id="login-username" type="text" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required`, |
| `<label for="login-password">Password</label>`, |
| `id="login-password" type="password" name="password" autocomplete="current-password" required`, |
| `<button id="login-submit" class="subbmit-button" type="submit">Sign in</button>`, |
| `<a href="/register">Create account</a>`, |
| }, |
| }, |
| { |
| "register", |
| string(register), |
| []string{ |
| `<label for="register-username">Username</label>`, |
| `id="register-username" type="text" name="username" value="" autocomplete="username" autocapitalize="none" spellcheck="false" required`, |
| `<label for="register-password">Password</label>`, |
| `id="register-password" type="password" name="password" autocomplete="new-password" required aria-describedby="register-password-policy register-password-symbols"`, |
| `<button id="register-submit" type="submit">Create account</button>`, |
| `<a href="/login">Sign in</a>`, |
| }, |
| }, |
| { |
| "settings", |
| string(settings), |
| []string{ |
| `<label for="change-password">New password</label>`, |
| `id="change-password" type="password" name="password" autocomplete="new-password" required aria-describedby="change-password-policy change-password-symbols"`, |
| `<button id="change-password-submit" type="submit">Change password</button>`, |
| `<a href="/">Back to account</a>`, |
| }, |
| }, |
| } |
| for _, contract := range contracts { |
| t.Run(contract.name, func(t *testing.T) { |
| for _, fragment := range contract.fragments { |
| if !strings.Contains(contract.body, fragment) { |
| t.Fatalf("missing semantic fragment %q", fragment) |
| } |
| } |
| if strings.Contains(contract.body, `aria-invalid=`) { |
| t.Fatal("pristine form rendered aria-invalid") |
| } |
| }) |
| } |
| |
| const policy = "Use at least 20 bytes, including an uppercase letter, lowercase letter, number, and an ASCII symbol or space." |
| const symbols = `!"#$%&'()*+,-./:;<=>?@[\]^_{|}~` |
| for name, body := range map[string]string{"register": string(register), "settings": string(settings)} { |
| unescaped := html.UnescapeString(body) |
| if strings.Count(unescaped, policy) != 1 { |
| t.Fatalf("%s policy guidance count=%d, want 1", name, strings.Count(unescaped, policy)) |
| } |
| if !strings.Contains(unescaped, "Accepted ASCII symbols: <code>"+symbols+"</code>. ASCII space is also accepted.") { |
| t.Fatalf("%s omitted exact rendered ASCII symbols", name) |
| } |
| } |
| } |
| |
| func TestValidationSummariesAndFieldAssociations(t *testing.T) { |
| templates := testTemplates(t) |
| usernameErrors := []ValidationError{ |
| {Field: "username", Message: "First username error."}, |
| {Field: "username", Message: "Second username error."}, |
| } |
| passwordErrors := []ValidationError{ |
| {Field: "password", Message: testPasswordLengthMessage}, |
| {Field: "password", Message: testPasswordCompositionMessage}, |
| } |
| register, err := executeTemplate(templates.Register, RegisterPageData{ |
| FormAction: "/register?flow=flow", |
| CSRFToken: "csrf", |
| UsernameErrors: usernameErrors, |
| PasswordErrors: passwordErrors, |
| GeneralError: registrationRejectedMessage, |
| }) |
| if err != nil { |
| t.Fatal(err) |
| } |
| body := string(register) |
| if strings.Count(body, `role="alert"`) != 1 || strings.Index(body, `role="alert"`) > strings.Index(body, `id="register-username"`) { |
| t.Fatal("registration alert summary is not unique and source-ordered before fields") |
| } |
| unescapedBody := html.UnescapeString(body) |
| for _, message := range []string{"First username error.", "Second username error.", testPasswordLengthMessage, testPasswordCompositionMessage} { |
| if strings.Count(unescapedBody, message) != 2 { |
| t.Fatalf("error %q count=%d, want summary and field error", message, strings.Count(unescapedBody, message)) |
| } |
| } |
| for _, fragment := range []string{ |
| `aria-invalid="true" aria-describedby="register-username-error-0 register-username-error-1"`, |
| `id="register-username-error-0"`, |
| `id="register-username-error-1"`, |
| `aria-describedby="register-password-policy register-password-symbols register-password-error-0 register-password-error-1" aria-invalid="true"`, |
| `id="register-password-error-0"`, |
| `id="register-password-error-1"`, |
| } { |
| if !strings.Contains(body, fragment) { |
| t.Fatalf("registration errors missing association %q", fragment) |
| } |
| } |
| |
| settings, err := executeTemplate(templates.ChangePassword, ChangePasswordPageData{ |
| Username: "tester", |
| FormAction: "/settings?flow=flow", |
| CSRFToken: "csrf", |
| PasswordErrors: passwordErrors, |
| GeneralError: passwordChangeRejectedMessage, |
| }) |
| if err != nil { |
| t.Fatal(err) |
| } |
| body = string(settings) |
| if strings.Count(body, `role="alert"`) != 1 || strings.Index(body, `role="alert"`) > strings.Index(body, `id="change-password"`) { |
| t.Fatal("settings alert summary is not unique and source-ordered before its field") |
| } |
| for _, fragment := range []string{ |
| `aria-describedby="change-password-policy change-password-symbols change-password-error-0 change-password-error-1" aria-invalid="true"`, |
| `id="change-password-error-0"`, |
| `id="change-password-error-1"`, |
| } { |
| if !strings.Contains(body, fragment) { |
| t.Fatalf("settings errors missing association %q", fragment) |
| } |
| } |
| } |
| |
| func TestTemplateHierarchyNavigationAndEscaping(t *testing.T) { |
| templates := testTemplates(t) |
| account, err := executeTemplate(templates.WhoAmI, AccountPageData{Username: `<script>alert("secret")</script>`}) |
| if err != nil { |
| t.Fatal(err) |
| } |
| accountBody := string(account) |
| if strings.Contains(accountBody, `<script>`) || !strings.Contains(accountBody, `<script>`) { |
| t.Fatal("account username was not safely escaped") |
| } |
| for _, fragment := range []string{`<h1>Account</h1>`, `<a href="/settings">Change password</a>`, `<a href="/logout">Log out</a>`} { |
| if !strings.Contains(accountBody, fragment) { |
| t.Fatalf("account missing hierarchy/navigation %q", fragment) |
| } |
| } |
| |
| success, err := executeTemplate(templates.ChangePasswordSuccess, nil) |
| if err != nil { |
| t.Fatal(err) |
| } |
| for _, fragment := range []string{`<h1>Password changed</h1>`, `<p role="status">Password changed successfully.</p>`, `<a href="/">Back to account</a>`} { |
| if !strings.Contains(string(success), fragment) { |
| t.Fatalf("success page missing %q", fragment) |
| } |
| } |
| |
| errorPage, err := executeTemplate(templates.Error, ErrorPageData{ |
| Title: "Authentication unavailable", |
| Message: authenticationUnavailableMessage, |
| RecoveryHref: "/login", |
| RecoveryText: "Try signing in again", |
| }) |
| if err != nil { |
| t.Fatal(err) |
| } |
| errorBody := string(errorPage) |
| if strings.Count(errorBody, "<a ") != 1 { |
| t.Fatalf("generic error recovery link count=%d, want 1", strings.Count(errorBody, "<a ")) |
| } |
| for _, fragment := range []string{`<h1>Authentication unavailable</h1>`, authenticationUnavailableMessage, `<a href="/login">Try signing in again</a>`} { |
| if !strings.Contains(errorBody, fragment) { |
| t.Fatalf("generic error page missing %q", fragment) |
| } |
| } |
| |
| register, err := executeTemplate(templates.Register, RegisterPageData{ |
| FormAction: "/register?flow=flow&return=<unsafe>", |
| CSRFToken: `<csrf&secret>`, |
| Username: `<img src=x onerror=secret>`, |
| }) |
| if err != nil { |
| t.Fatal(err) |
| } |
| registerBody := string(register) |
| for _, forbidden := range []string{`<unsafe>`, `<csrf&secret>`, `<img src=x onerror=secret>`} { |
| if strings.Contains(registerBody, forbidden) { |
| t.Fatalf("registration rendered unsafe value %q", forbidden) |
| } |
| } |
| assertNoHiddenInputsNamed(t, registerBody, "method", "username") |
| |
| settings, err := executeTemplate(templates.ChangePassword, ChangePasswordPageData{Username: "tester", FormAction: "/settings?flow=flow", CSRFToken: "csrf"}) |
| if err != nil { |
| t.Fatal(err) |
| } |
| settingsBody := string(settings) |
| assertNoHiddenInputsNamed(t, settingsBody, "method", "username") |
| } |