auth-ui: rewrite ui
Change-Id: I6f00867015ec77aa7e336e89da4dc1b081e330c6
diff --git a/core/auth/ui/main_test.go b/core/auth/ui/main_test.go
new file mode 100644
index 0000000..904bdd4
--- /dev/null
+++ b/core/auth/ui/main_test.go
@@ -0,0 +1,1191 @@
+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")
+}