- boardwalk exports ContentType and SetSecurityHeaders
- pact.AdminClientAssets files are read and hashed at boot and served by exact
key under {prefix}/assets/{vendor}/{plugin}/ with nosniff, CSP, CORP,
no-cache and an ETag; a miss falls through to the SPA
- list and form schemas carry assets URLs with a ?v= hash
- toolbar.buttons resolves create, delete and registered actions after decode;
toolbarActions is permission-filtered per admin
- POST .../toolbar/{action} behind requireAjax and action permissions
486 lines
19 KiB
Go
486 lines
19 KiB
Go
package cabana
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// refreshSubjects is a map-backed bouncer.UserProvider for refresh tests
|
|
// that run without a database; err, when set, is returned for every lookup.
|
|
type refreshSubjects struct {
|
|
byID map[uint]*bouncer.Principal
|
|
err error
|
|
}
|
|
|
|
func (p refreshSubjects) FindByID(_ context.Context, id uint) (*bouncer.Principal, error) {
|
|
if p.err != nil {
|
|
return nil, p.err
|
|
}
|
|
return p.byID[id], nil
|
|
}
|
|
|
|
// emptyOptionsRow is a filter source whose scope has no choices yet;
|
|
// literalOptionsRow's choice label is a literal rather than a phrase key.
|
|
type emptyOptionsRow struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
Name string `gorm:"column:name"`
|
|
Active bool `gorm:"column:active"`
|
|
}
|
|
|
|
func (emptyOptionsRow) FilterScopes() []string { return []string{"filterByGroup"} }
|
|
func (emptyOptionsRow) FilterScope(_ string, db *gorm.DB, _ any) *gorm.DB { return db }
|
|
func (emptyOptionsRow) FilterOptions(string) []pact.Option { return []pact.Option{} }
|
|
|
|
type literalOptionsRow struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
Name string `gorm:"column:name"`
|
|
Active bool `gorm:"column:active"`
|
|
}
|
|
|
|
func (literalOptionsRow) FilterScopes() []string { return []string{"filterByGroup"} }
|
|
func (literalOptionsRow) FilterScope(_ string, db *gorm.DB, _ any) *gorm.DB { return db }
|
|
func (literalOptionsRow) FilterOptions(string) []pact.Option {
|
|
return []pact.Option{{Value: "x", Label: "Literal label"}}
|
|
}
|
|
|
|
// TestPhase10Coverage fills the Phase 10 cabana branches the feature tests
|
|
// left without a named case: the mounted route table against the CSRF walk,
|
|
// relation and filter option edges, read-only relation labels, relation
|
|
// message defaults, the bundle's configured fallback and a cookie refresh of
|
|
// an expired access token inside the refresh window.
|
|
func TestPhase10Coverage(t *testing.T) {
|
|
t.Run("every unsafe mounted route is CSRF-walked", func(t *testing.T) {
|
|
router := &handlerRouter{handlers: map[string]http.HandlerFunc{}}
|
|
svc := phase09DeniedService()
|
|
svc.mount(router)
|
|
api := adminAPI("")
|
|
var unsafe, safe []string
|
|
for _, key := range router.order {
|
|
method, path, _ := strings.Cut(key, " ")
|
|
rel := strings.TrimPrefix(path, api)
|
|
switch method {
|
|
case http.MethodGet:
|
|
safe = append(safe, rel)
|
|
case http.MethodPost, http.MethodPut, http.MethodDelete:
|
|
unsafe = append(unsafe, method+" "+rel)
|
|
default:
|
|
t.Fatalf("unexpected method mounted: %s", key)
|
|
}
|
|
}
|
|
sort.Strings(unsafe)
|
|
want := []string{
|
|
"DELETE /{vendor}/{plugin}/{controller}/{id}",
|
|
"POST /auth/login",
|
|
"POST /auth/logout",
|
|
"POST /auth/refresh",
|
|
"POST /{vendor}/{plugin}/{controller}",
|
|
"POST /{vendor}/{plugin}/{controller}/bulk-delete",
|
|
"POST /{vendor}/{plugin}/{controller}/toolbar/{action}",
|
|
"POST /{vendor}/{plugin}/{controller}/widgets/{field}",
|
|
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/link",
|
|
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink",
|
|
"PUT /settings/{code}",
|
|
"PUT /{vendor}/{plugin}/{controller}/{id}",
|
|
}
|
|
if strings.Join(unsafe, "\n") != strings.Join(want, "\n") {
|
|
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 11 besides login):\n%s", strings.Join(unsafe, "\n"))
|
|
}
|
|
// The routes added in Phase 10 are safe reads: GET /lang and the shared
|
|
// nested pattern serving field options, filter options and relation lists.
|
|
for _, need := range []string{"/lang", "/{vendor}/{plugin}/{controller}/{id}/{segment}/{name}"} {
|
|
found := false
|
|
for _, rel := range safe {
|
|
found = found || rel == need
|
|
}
|
|
if !found {
|
|
t.Fatalf("GET %s is not mounted: %v", need, safe)
|
|
}
|
|
}
|
|
// A safe read with only the cookie is never refused by the CSRF check.
|
|
req := httptest.NewRequest(http.MethodGet, adminAPI("/lang"), nil)
|
|
req.AddCookie(&http.Cookie{Name: AdminCookieName, Value: "cookie-only"})
|
|
rec := httptest.NewRecorder()
|
|
router.handlers[http.MethodGet+" "+adminAPI("/lang")](rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("cookie-only GET /lang status=%d", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("relation option edges", func(t *testing.T) {
|
|
svc, _, _ := p10Fixture(t)
|
|
decode := func(rec *httptest.ResponseRecorder) ([]RelationOption, ListMeta) {
|
|
t.Helper()
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var body struct {
|
|
Data []RelationOption `json:"data"`
|
|
Meta ListMeta `json:"meta"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Data == nil {
|
|
t.Fatalf("data is null: %s", rec.Body.String())
|
|
}
|
|
return body.Data, body.Meta
|
|
}
|
|
all, allMeta := decode(p10Options(svc, "tags", "", p10Principal(true)))
|
|
for _, query := range []string{"search=", "search=%20%20"} {
|
|
rows, meta := decode(p10Options(svc, "tags", query, p10Principal(true)))
|
|
if len(rows) != len(all) || meta.Total != allMeta.Total {
|
|
t.Fatalf("%s is not a no-op search: %d rows, total %d", query, len(rows), meta.Total)
|
|
}
|
|
}
|
|
beyond, meta := decode(p10Options(svc, "tags", "page=9", p10Principal(true)))
|
|
if len(beyond) != 0 || meta.Page != 9 || meta.Total != allMeta.Total || meta.LastPage != allMeta.LastPage {
|
|
t.Fatalf("page beyond last: rows=%d meta=%+v", len(beyond), meta)
|
|
}
|
|
capped, cappedMeta := decode(p10Options(svc, "tags", "per_page=100", p10Principal(true)))
|
|
if cappedMeta.PerPage != 100 || len(capped) != len(all) {
|
|
t.Fatalf("per_page at the cap: %+v", cappedMeta)
|
|
}
|
|
above := p10Options(svc, "tags", "per_page=1000", p10Principal(true))
|
|
if above.Code != http.StatusUnprocessableEntity || !strings.Contains(above.Body.String(), `"per_page"`) {
|
|
t.Fatalf("per_page above the cap status=%d body=%s", above.Code, above.Body.String())
|
|
}
|
|
none, noneMeta := decode(p10Options(svc, "tags", "search=no-such-tag", p10Principal(true)))
|
|
if len(none) != 0 || noneMeta.Total != 0 || noneMeta.LastPage < 1 {
|
|
t.Fatalf("empty result: %d rows meta=%+v", len(none), noneMeta)
|
|
}
|
|
})
|
|
|
|
t.Run("filter option edges", func(t *testing.T) {
|
|
granted := &bouncer.Principal{ID: 1, PermissionGrants: map[string]bool{"acme.demo.access": true}}
|
|
empty := filterOptionsCall(filterOptionsService(t, &emptyOptionsRow{}, []string{"acme.demo.access"}, nil), "grouped", "", granted)
|
|
if empty.Code != http.StatusOK || !strings.Contains(empty.Body.String(), `"data":[]`) {
|
|
t.Fatalf("no choices status=%d body=%s", empty.Code, empty.Body.String())
|
|
}
|
|
literal := filterOptionsCall(filterOptionsService(t, &literalOptionsRow{}, []string{"acme.demo.access"}, nil), "grouped", "pl", granted)
|
|
if literal.Code != http.StatusOK || !strings.Contains(literal.Body.String(), `{"value":"x","label":"Literal label"}`) {
|
|
t.Fatalf("literal label status=%d body=%s", literal.Code, literal.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("read-only relation label outside the options scope", func(t *testing.T) {
|
|
svc, db, seed := p10Fixture(t)
|
|
created := p10Decode(t, p10Save(svc, http.MethodPost, "", map[string]any{"name": "labelled"}), http.StatusCreated)
|
|
id := fmt.Sprintf("%d", p10ID(created.Data["id"]))
|
|
// The options hook hides every person (1 = 0), yet the label shows.
|
|
shown := p10Decode(t, p10Save(svc, http.MethodGet, id, nil), http.StatusOK)
|
|
if p10ID(shown.Data["person"]) != seed.person || len(shown.Meta.Labels["person"]) != 1 || shown.Meta.Labels["person"][0].Label != "admin@acme.test" {
|
|
t.Fatalf("read-only label data=%v labels=%+v", shown.Data["person"], shown.Meta.Labels["person"])
|
|
}
|
|
// A dangling foreign key keeps the value and gets no label.
|
|
if err := db.Delete(&p10Person{}, seed.person).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dangling := p10Decode(t, p10Save(svc, http.MethodGet, id, nil), http.StatusOK)
|
|
if labels, ok := dangling.Meta.Labels["person"]; !ok || len(labels) != 0 {
|
|
t.Fatalf("dangling person labels=%+v", dangling.Meta.Labels)
|
|
}
|
|
// Saving never writes the read-only key.
|
|
p10Decode(t, p10Save(svc, http.MethodPut, id, map[string]any{"name": "again", "person": 42}), http.StatusOK)
|
|
if stored := p10Stored(t, db, p10ID(created.Data["id"])); stored.UserID != seed.person {
|
|
t.Fatalf("read-only foreign key written: %+v", stored)
|
|
}
|
|
})
|
|
|
|
t.Run("relation messages default every key", func(t *testing.T) {
|
|
tr := messagesTranslator(t)
|
|
ctl := relationTestController{}
|
|
fsys := relationTestFS(validRelationYAML)
|
|
form, err := CompileForm("acme.demo", ctl, fsys)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
relations, err := compileRelations("acme.demo", ctl, fsys, form)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, locale := range []string{"pl", "en"} {
|
|
view := relations["editors"].Schema.Localize(localeCtx(locale), tr)
|
|
raw, err := json.Marshal(view.Messages)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var messages map[string]map[string]string
|
|
if err := json.Unmarshal(raw, &messages); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, key := range []string{"link", "linkHint", "candidateSearch", "linked", "unlinkSelected", "unlinkConfirm", "unlinked", "empty"} {
|
|
if messages[key]["other"] == "" || strings.Contains(messages[key]["other"], "backend::") {
|
|
t.Fatalf("%s %s default = %v", locale, key, messages[key])
|
|
}
|
|
}
|
|
if locale == "pl" && len(messages["linked"]) != 4 {
|
|
t.Fatalf("pl linked is not plural: %v", messages["linked"])
|
|
}
|
|
}
|
|
if err := validateMessageKeys(&Registry{byID: map[string]*CompiledController{
|
|
"acme.demo.owners": {Controller: ctl, PluginID: "acme.demo", Relations: relations},
|
|
}}, tr); err != nil {
|
|
t.Fatalf("framework defaults failed validation: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("form-less controller drops create from any toolbar", func(t *testing.T) {
|
|
config := "modelClass: Widget\nlist: ~/plugins/acme/demo/models/widget/columns.yaml\ntoolbar:\n buttons: [create]\n"
|
|
reg, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: listFS(config, "columns: {}\n")}, ctl: schemaController{}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cc, _ := reg.Get("acme.demo.widgets")
|
|
if len(cc.List.ToolbarButtons) != 0 || cc.List.ToolbarButtons == nil {
|
|
t.Fatalf("toolbar=%#v, want an empty list", cc.List.ToolbarButtons)
|
|
}
|
|
})
|
|
|
|
t.Run("bundle falls back to app.fallback_locale", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: coverage\nlocale: pl\nfallback_locale: pl\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app := backpack.New(cfg)
|
|
if err := phrasebook.Activate(app, []langPlugin{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
svc := &service{app: app}
|
|
for _, requested := range []string{"de", ""} {
|
|
req := httptest.NewRequest(http.MethodGet, adminAPI("/lang"), nil)
|
|
if requested != "" {
|
|
req = req.WithContext(localeCtx(requested))
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
svc.langBundle(rec, req)
|
|
var body struct {
|
|
Data map[string]map[string]string `json:"data"`
|
|
Meta struct {
|
|
Locale string `json:"locale"`
|
|
} `json:"meta"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Meta.Locale != "pl" || body.Data["backend::lang.form.save"]["other"] != "Zapisz" {
|
|
t.Fatalf("requested %q: locale=%q save=%v", requested, body.Meta.Locale, body.Data["backend::lang.form.save"])
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("cookie refresh of an expired access token inside the refresh window", func(t *testing.T) {
|
|
const secret = "phase10-coverage-secret"
|
|
svc := &service{
|
|
secret: secret,
|
|
ttl: 15 * time.Minute,
|
|
refreshTTL: 2 * time.Hour,
|
|
issuer: "https://app.test" + DefaultAdminPrefix,
|
|
bl: bouncer.NewMemoryBlacklist(),
|
|
users: refreshSubjects{byID: map[uint]*bouncer.Principal{5: {ID: 5, Backend: true}}},
|
|
}
|
|
sign := func(iat, exp time.Time, jti string) string {
|
|
t.Helper()
|
|
tok, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
|
"sub": "5", "aud": bouncer.AudienceBackend, "iss": svc.issuer, "jti": jti,
|
|
"iat": iat.Unix(), "nbf": iat.Unix(), "exp": exp.Unix(),
|
|
}).SignedString([]byte(secret))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return tok
|
|
}
|
|
call := func(token string, cookie bool) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodPost, adminAPI("/auth/refresh"), nil)
|
|
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
|
if cookie {
|
|
req.AddCookie(&http.Cookie{Name: AdminCookieName, Value: token})
|
|
} else {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
requireAjax(svc.refresh)(rec, req)
|
|
return rec
|
|
}
|
|
now := time.Now()
|
|
expired := sign(now.Add(-30*time.Minute), now.Add(-15*time.Minute), "expired-in-window")
|
|
if _, _, _, _, err := bouncer.VerifyClaimsAudience(expired, secret, bouncer.AudienceBackend); err == nil {
|
|
t.Fatal("fixture token is not expired")
|
|
}
|
|
|
|
rec := call(expired, true)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("cookie refresh in window status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var next *http.Cookie
|
|
for _, c := range rec.Result().Cookies() {
|
|
if c.Name == AdminCookieName {
|
|
next = c
|
|
}
|
|
}
|
|
if next == nil || next.Value == "" || next.Value == expired || !next.HttpOnly || !next.Secure || next.SameSite != http.SameSiteStrictMode ||
|
|
next.Path != DefaultAdminPrefix || next.MaxAge != int((2*time.Hour)/time.Second) {
|
|
t.Fatalf("refreshed cookie = %+v", next)
|
|
}
|
|
if strings.Contains(rec.Body.String(), "access_token") || strings.Contains(rec.Body.String(), next.Value) {
|
|
t.Fatalf("cookie refresh put the token in the body: %s", rec.Body.String())
|
|
}
|
|
if _, _, _, _, err := bouncer.VerifyClaimsAudience(next.Value, secret, bouncer.AudienceBackend); err != nil {
|
|
t.Fatalf("refreshed cookie does not verify: %v", err)
|
|
}
|
|
// The old token is blacklisted by the rotation: a second refresh fails.
|
|
if again := call(expired, true); again.Code != http.StatusUnauthorized {
|
|
t.Fatalf("replayed refresh status=%d", again.Code)
|
|
}
|
|
|
|
bearer := call(sign(now.Add(-30*time.Minute), now.Add(-15*time.Minute), "bearer-in-window"), false)
|
|
if bearer.Code != http.StatusOK || !strings.Contains(bearer.Body.String(), `"access_token"`) || len(bearer.Result().Cookies()) != 0 {
|
|
t.Fatalf("bearer refresh status=%d body=%s", bearer.Code, bearer.Body.String())
|
|
}
|
|
|
|
stale := call(sign(now.Add(-3*time.Hour), now.Add(-170*time.Minute), "outside-window"), true)
|
|
if stale.Code != http.StatusUnauthorized || len(stale.Result().Cookies()) != 0 {
|
|
t.Fatalf("refresh outside the window status=%d cookies=%v", stale.Code, stale.Result().Cookies())
|
|
}
|
|
missing := call("", true)
|
|
if missing.Code != http.StatusUnauthorized {
|
|
t.Fatalf("refresh without a token status=%d", missing.Code)
|
|
}
|
|
frontend := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
|
"sub": "5", "aud": bouncer.AudienceUser, "jti": "frontend", "iat": now.Unix(), "exp": now.Add(time.Minute).Unix(),
|
|
})
|
|
frontendTok, err := frontend.SignedString([]byte(secret))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cross := call(frontendTok, true); cross.Code != http.StatusUnauthorized {
|
|
t.Fatalf("frontend token refreshed through the admin cookie: %d", cross.Code)
|
|
}
|
|
if !bytes.Contains(rec.Body.Bytes(), []byte(`"token_type"`)) {
|
|
t.Fatalf("cookie refresh body=%s", rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("refresh enforces the guard's subject checks", func(t *testing.T) {
|
|
const secret = "phase10-subject-secret"
|
|
now := time.Now()
|
|
cutoff := now.Add(-10 * time.Minute)
|
|
subjects := refreshSubjects{byID: map[uint]*bouncer.Principal{
|
|
5: {ID: 5, Backend: true, TokensValidAfter: cutoff},
|
|
}}
|
|
newService := func(users bouncer.UserProvider) *service {
|
|
return &service{
|
|
secret: secret,
|
|
ttl: 15 * time.Minute,
|
|
refreshTTL: 2 * time.Hour,
|
|
issuer: "https://app.test" + DefaultAdminPrefix,
|
|
bl: bouncer.NewMemoryBlacklist(),
|
|
users: users,
|
|
}
|
|
}
|
|
sign := func(sub string, iat time.Time, jti string) string {
|
|
t.Helper()
|
|
tok, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
|
"sub": sub, "aud": bouncer.AudienceBackend, "jti": jti,
|
|
"iat": iat.Unix(), "nbf": iat.Unix(), "exp": iat.Add(15 * time.Minute).Unix(),
|
|
}).SignedString([]byte(secret))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return tok
|
|
}
|
|
call := func(svc *service, token string, cookie bool) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodPost, adminAPI("/auth/refresh"), nil)
|
|
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
|
if cookie {
|
|
req.AddCookie(&http.Cookie{Name: AdminCookieName, Value: token})
|
|
} else {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
requireAjax(svc.refresh)(rec, req)
|
|
return rec
|
|
}
|
|
errorCode := func(rec *httptest.ResponseRecorder) string {
|
|
t.Helper()
|
|
var body struct {
|
|
Error struct {
|
|
Code string `json:"code"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("error json: %v body=%s", err, rec.Body.String())
|
|
}
|
|
return body.Error.Code
|
|
}
|
|
assertExpired := func(name string, rec *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
if rec.Code != http.StatusUnauthorized || errorCode(rec) != "unauthenticated" {
|
|
t.Fatalf("%s: status=%d body=%s, want 401 unauthenticated", name, rec.Code, rec.Body.String())
|
|
}
|
|
var expired *http.Cookie
|
|
for _, c := range rec.Result().Cookies() {
|
|
if c.Name == AdminCookieName {
|
|
expired = c
|
|
}
|
|
}
|
|
if expired == nil || expired.Value != "" || expired.MaxAge >= 0 || expired.Path != DefaultAdminPrefix {
|
|
t.Fatalf("%s: cookie = %+v, want an expiring %s with Path %s", name, expired, AdminCookieName, DefaultAdminPrefix)
|
|
}
|
|
}
|
|
assertNoCookies := func(name string, rec *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
if rec.Code != http.StatusUnauthorized || errorCode(rec) != "unauthenticated" {
|
|
t.Fatalf("%s: status=%d body=%s, want 401 unauthenticated", name, rec.Code, rec.Body.String())
|
|
}
|
|
if got := rec.Header().Values("Set-Cookie"); len(got) != 0 {
|
|
t.Fatalf("%s: set cookies %q", name, got)
|
|
}
|
|
}
|
|
|
|
svc := newService(subjects)
|
|
assertExpired("pre-cutoff cookie", call(svc, sign("5", cutoff.Add(-time.Minute), "pre-cutoff-cookie"), true))
|
|
assertExpired("unknown subject cookie", call(svc, sign("6", now.Add(-time.Minute), "unknown-cookie"), true))
|
|
assertNoCookies("pre-cutoff bearer", call(svc, sign("5", cutoff.Add(-time.Minute), "pre-cutoff-bearer"), false))
|
|
|
|
failing := newService(refreshSubjects{err: errors.New("lookup failed")})
|
|
assertNoCookies("provider error cookie", call(failing, sign("5", now.Add(-time.Minute), "provider-error"), true))
|
|
|
|
ok := call(svc, sign("5", cutoff.Add(time.Minute), "post-cutoff-cookie"), true)
|
|
if ok.Code != http.StatusOK {
|
|
t.Fatalf("post-cutoff cookie refresh status=%d body=%s", ok.Code, ok.Body.String())
|
|
}
|
|
var rotated *http.Cookie
|
|
for _, c := range ok.Result().Cookies() {
|
|
if c.Name == AdminCookieName {
|
|
rotated = c
|
|
}
|
|
}
|
|
if rotated == nil || rotated.Value == "" || rotated.MaxAge <= 0 || rotated.Path != DefaultAdminPrefix {
|
|
t.Fatalf("post-cutoff rotated cookie = %+v", rotated)
|
|
}
|
|
})
|
|
}
|