blob: 904bdd4e7eb1116e23aeb29f751def720b7189f7 [file] [log] [blame]
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, `&lt;script&gt;`) {
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")
}