@@ -123,17 +123,21 @@ func newTestServer(t *testing.T, requireVerify bool) (*httptest.Server, *capture |
| 123 | 123 | r.Use(middleware.RequestID) |
| 124 | 124 | r.Use(middleware.RealIP(middleware.RealIPConfig{})) |
| 125 | 125 | r.Use(middleware.SessionLoader(store, logger)) |
| 126 | | - r.Use(middleware.OptionalUser(func(ctx context.Context, id int64) (string, error) { |
| 127 | | - // Cheap username lookup against the test pool — RequireUser only |
| 128 | | - // checks ID == 0, but settings handlers use the username. |
| 126 | + r.Use(middleware.OptionalUser(func(ctx context.Context, id int64) (string, int32, error) { |
| 127 | + // Cheap lookup against the test pool — settings handlers use the |
| 128 | + // username, and the epoch comparison enforces log-out-everywhere |
| 129 | + // across the same suite. |
| 129 | 130 | u, err := pool.Acquire(ctx) |
| 130 | 131 | if err != nil { |
| 131 | | - return "", err |
| 132 | + return "", 0, err |
| 132 | 133 | } |
| 133 | 134 | defer u.Release() |
| 134 | 135 | var name string |
| 135 | | - err = u.QueryRow(ctx, "SELECT username FROM users WHERE id = $1", id).Scan(&name) |
| 136 | | - return name, err |
| 136 | + var epoch int32 |
| 137 | + err = u.QueryRow(ctx, |
| 138 | + "SELECT username, session_epoch FROM users WHERE id = $1", id, |
| 139 | + ).Scan(&name, &epoch) |
| 140 | + return name, epoch, err |
| 137 | 141 | })) |
| 138 | 142 | csrf := middleware.CSRF(middleware.CSRFConfig{ |
| 139 | 143 | FailureHandler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
@@ -175,6 +179,7 @@ func authTemplatesFS() fs.FS { |
| 175 | 179 | apprTpl := `{{ define "page" }}<h1>Appearance</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/appearance" method=POST><input name=csrf_token value="{{.CSRFToken}}">THEME={{.CurrentTheme}};</form>{{ end }}` |
| 176 | 180 | emailsTpl := `{{ define "page" }}<h1>Emails</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/emails" method=POST><input name=csrf_token value="{{.CSRFToken}}"></form>EMAILS={{ range .Emails }}{{.ID}}:{{.Email}}:p={{.IsPrimary}}:v={{.Verified}};{{ end }}{{ end }}` |
| 177 | 181 | notifTpl := `{{ define "page" }}<h1>Notifications</h1>{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/notifications" method=POST><input name=csrf_token value="{{.CSRFToken}}">CHANNELS={{ range .Channels }}{{.Key}}:e={{.Enabled}}:r={{.Required}};{{ end }}</form>{{ end }}` |
| 182 | + sessTpl := `{{ define "page" }}<h1>Sessions</h1>{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/sessions/logout-everywhere" method=POST><input name=csrf_token value="{{.CSRFToken}}">UA={{.UserAgent}};</form>{{ end }}` |
| 178 | 183 | errorPage := `{{ define "page" }}<h1>{{.Status}} {{.StatusText}}</h1><p>{{.Message}}</p>{{ end }}` |
| 179 | 184 | return fstest.MapFS{ |
| 180 | 185 | "_layout.html": {Data: []byte(layout)}, |
@@ -196,6 +201,7 @@ func authTemplatesFS() fs.FS { |
| 196 | 201 | "settings/appearance.html": {Data: []byte(apprTpl)}, |
| 197 | 202 | "settings/emails.html": {Data: []byte(emailsTpl)}, |
| 198 | 203 | "settings/notifications.html": {Data: []byte(notifTpl)}, |
| 204 | + "settings/sessions.html": {Data: []byte(sessTpl)}, |
| 199 | 205 | "errors/404.html": {Data: []byte(errorPage)}, |
| 200 | 206 | "errors/403.html": {Data: []byte(errorPage)}, |
| 201 | 207 | "errors/429.html": {Data: []byte(errorPage)}, |