package idear_test import ( "io" "net/http" "net/http/httptest" "testing" "github.com/carlosframework/rastrillo/sessions" "amadan.net/rastrillo/idear" "amadan.net/rastrillo/idear/internal/ideartest" ) // The two renderers below are deliberately NOT http.NotFound's and // http.Error's default output. // // Config.NotFound must be the same renderer the app gives chi's own // NotFound: an app with a custom 404 page and idear's default // http.NotFound produces two DISTINGUISHABLE 404s, and that delta is // the membership oracle the design forbids. A test that left the // default in place could not tell whether the hook was consulted at // all. const ( appNotFound = "the app's own 404 page" appForbidden = "the app's own 403 page" ) // guardedHarness is a roster whose refusals are the app's own pages. func guardedHarness(t *testing.T) *ideartest.Harness { t.Helper() return ideartest.NewWith(t, idear.Config{ NotFound: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) io.WriteString(w, appNotFound) }, Forbidden: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) io.WriteString(w, appForbidden) }, }) } // spy is the guarded handler: it records that it ran and what viewer // idear.From handed it. A middleware test that only checked the status // code would not notice a Require that answered 404 AND still called // next. type spy struct { called bool member *idear.Member } func (s *spy) handler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.called = true s.member = idear.From(r) io.WriteString(w, "the guarded page") }) } // as issues one GET through h carrying subject's session, exactly as // the app's own session middleware would have stashed it. An EMPTY // subject means no session at all — which is what Require sees when it // is mounted outside the app's session guard. func as(subject string, h http.Handler) *httptest.ResponseRecorder { r := httptest.NewRequest(http.MethodGet, "/members", nil) if subject != "" { r = sessions.WithSession(r, sessions.Session{Subject: subject}) } w := httptest.NewRecorder() h.ServeHTTP(w, r) return w } func TestRequireAdmitsAnActiveMember(t *testing.T) { h := guardedHarness(t) owner := h.Owner() var s spy w := as(owner.Subject, h.Roster.Require(s.handler())) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body %q", w.Code, w.Body.String()) } if !s.called { t.Fatal("Require did not call next for an active member") } if s.member == nil { t.Fatal("idear.From returned nil inside Require; the viewer must ride the context") } if s.member.ID != owner.ID || s.member.Role != idear.RoleOwner { t.Errorf("From = %+v, want the owner %+v", s.member, owner) } } func TestRequireAnswersNotFoundForNonMember(t *testing.T) { h := guardedHarness(t) h.Owner() // the instance is claimed; the visitor is simply not in it var s spy w := as("a-subject-with-no-row", h.Roster.Require(s.handler())) if w.Code != http.StatusNotFound { t.Errorf("status = %d, want 404", w.Code) } if got := w.Body.String(); got != appNotFound { t.Errorf("body = %q, want the app's own 404 page %q; a different 404 is a membership oracle", got, appNotFound) } if s.called { t.Error("Require called next for a non-member") } if loc := w.Header().Get("Location"); loc != "" { t.Errorf("Require redirected to %q; signed-out handling belongs to the upstream sessions.Require", loc) } } func TestRequireAnswersNotFoundForDeactivatedMember(t *testing.T) { h := guardedHarness(t) h.Owner() gone := h.Deactivated(idear.RoleAdmin) var s spy w := as(gone.Subject, h.Roster.Require(s.handler())) if w.Code != http.StatusNotFound { t.Errorf("status = %d, want 404; a deactivated member keeps their row and loses every privilege it carried", w.Code) } if s.called { t.Error("Require called next for a deactivated member") } // The two refusals must be INDISTINGUISHABLE. A deactivated member // who could tell their own answer apart from a stranger's has been // told that this instance knows them, which is the same oracle a // custom 404 page would open. var s2 spy stranger := as("a-subject-with-no-row", h.Roster.Require(s2.handler())) if w.Code != stranger.Code || w.Body.String() != stranger.Body.String() { t.Errorf("deactivated member got %d/%q, stranger got %d/%q; the two must be byte-identical", w.Code, w.Body.String(), stranger.Code, stranger.Body.String()) } } // TestRequireAnswersNotFoundWithNoSession pins the design's loudest // silent trap: mounted OUTSIDE the app's session guard, Config.Subject // resolves nothing and every request 404s — including a real member's. // Correct, and undetectable from the response, which is why it is a // test and a doc comment rather than a comment alone. func TestRequireAnswersNotFoundWithNoSession(t *testing.T) { h := guardedHarness(t) owner := h.Owner() var s spy w := as("", h.Roster.Require(s.handler())) if w.Code != http.StatusNotFound { t.Errorf("status = %d, want 404", w.Code) } if s.called { t.Error("Require called next with no session subject") } if loc := w.Header().Get("Location"); loc != "" { t.Errorf("Require redirected to %q; it must never redirect", loc) } // And the same viewer, with a session, is admitted — so the 404 // above is the missing session and not a broken lookup. var s2 spy if got := as(owner.Subject, h.Roster.Require(s2.handler())); got.Code != http.StatusOK { t.Fatalf("the same member WITH a session got %d, want 200", got.Code) } } func TestRequireRoleForbidsBelowMinimum(t *testing.T) { h := guardedHarness(t) h.Owner() plain := h.Member(idear.RoleMember) var s spy // Stacked, which is the only supported mounting: RequireRole // INSIDE Require. guard := h.Roster.Require(h.Roster.RequireRole(idear.RoleAdmin)(s.handler())) w := as(plain.Subject, guard) if w.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403; a member may legitimately see the page and merely may not act", w.Code) } if w.Code == http.StatusNotFound { t.Error("RequireRole answered 404; 404 is for non-members, 403 is for insufficient rank") } if got := w.Body.String(); got != appForbidden { t.Errorf("body = %q, want the app's own 403 page %q", got, appForbidden) } if s.called { t.Error("RequireRole called next for a member below the minimum") } } func TestRequireRoleAdmitsAtAndAboveMinimum(t *testing.T) { h := guardedHarness(t) owner := h.Owner() admin := h.Member(idear.RoleAdmin) for _, m := range []*idear.Member{admin, owner} { var s spy guard := h.Roster.Require(h.Roster.RequireRole(idear.RoleAdmin)(s.handler())) w := as(m.Subject, guard) if w.Code != http.StatusOK || !s.called { t.Errorf("%s got %d (next called: %v), want 200 and next called", m.Role, w.Code, s.called) } } } func TestFromIsNilWithoutRequire(t *testing.T) { var s spy as("whoever", s.handler()) if s.member != nil { t.Errorf("idear.From = %+v outside Require, want nil", s.member) } } // TestRequireHonoursSubjectNotOk pins the OTHER half of the subject // guard. Config.Subject returns (string, bool), and an override is // free to return a non-empty string alongside ok=false — a stale // cookie's subject, say, or a half-resolved session. The bool is the // answer; the string is not. Reading the string and ignoring the bool // admits exactly the viewer the override was refusing. func TestRequireHonoursSubjectNotOk(t *testing.T) { h := guardedHarness(t) owner := h.Owner() rs, err := idear.New(idear.Config{ DB: h.DB.G, Subject: func(r *http.Request) (string, bool) { // A real subject, refused. return owner.Subject, false }, NotFound: func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) io.WriteString(w, appNotFound) }, }) if err != nil { t.Fatalf("idear.New: %v", err) } var s spy w := as(owner.Subject, rs.Require(s.handler())) if w.Code != http.StatusNotFound { t.Errorf("status = %d, want 404: Config.Subject said ok=false", w.Code) } if s.called { t.Error("Require called next for a subject its own resolver refused") } } // TestRequireAnswersNotFoundWhenTheStoreIsBroken: a storage failure is // NOT a membership answer, and it is deliberately rendered as one. // // A 500 here would hand a prober a signal that varies with the // database rather than with membership, and it would look different // from every other refusal this middleware makes. Fail closed, render // the app's own 404, and put the distinction in the log — the same // posture Authorize takes for the same reason. func TestRequireAnswersNotFoundWhenTheStoreIsBroken(t *testing.T) { h := guardedHarness(t) owner := h.Owner() if err := h.DB.G.Exec("DROP TABLE idear_members").Error; err != nil { t.Fatalf("dropping idear_members: %v", err) } var s spy w := as(owner.Subject, h.Roster.Require(s.handler())) if w.Code != http.StatusNotFound { t.Errorf("status = %d, want 404: a broken store must not answer differently from a refusal", w.Code) } if got := w.Body.String(); got != appNotFound { t.Errorf("body = %q, want the app's own 404 page %q", got, appNotFound) } if s.called { t.Error("Require called next when it could not resolve the viewer at all") } }