| 1 | package idear_test |
| 2 | |
| 3 | import ( |
| 4 | "io" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "testing" |
| 8 | |
| 9 | "github.com/carlosframework/rastrillo/sessions" |
| 10 | |
| 11 | "amadan.net/rastrillo/idear" |
| 12 | "amadan.net/rastrillo/idear/internal/ideartest" |
| 13 | ) |
| 14 | |
| 15 | // The two renderers below are deliberately NOT http.NotFound's and |
| 16 | // http.Error's default output. |
| 17 | // |
| 18 | // Config.NotFound must be the same renderer the app gives chi's own |
| 19 | // NotFound: an app with a custom 404 page and idear's default |
| 20 | // http.NotFound produces two DISTINGUISHABLE 404s, and that delta is |
| 21 | // the membership oracle the design forbids. A test that left the |
| 22 | // default in place could not tell whether the hook was consulted at |
| 23 | // all. |
| 24 | const ( |
| 25 | appNotFound = "the app's own 404 page" |
| 26 | appForbidden = "the app's own 403 page" |
| 27 | ) |
| 28 | |
| 29 | // guardedHarness is a roster whose refusals are the app's own pages. |
| 30 | func guardedHarness(t *testing.T) *ideartest.Harness { |
| 31 | t.Helper() |
| 32 | return ideartest.NewWith(t, idear.Config{ |
| 33 | NotFound: func(w http.ResponseWriter, r *http.Request) { |
| 34 | w.WriteHeader(http.StatusNotFound) |
| 35 | io.WriteString(w, appNotFound) |
| 36 | }, |
| 37 | Forbidden: func(w http.ResponseWriter, r *http.Request) { |
| 38 | w.WriteHeader(http.StatusForbidden) |
| 39 | io.WriteString(w, appForbidden) |
| 40 | }, |
| 41 | }) |
| 42 | } |
| 43 | |
| 44 | // spy is the guarded handler: it records that it ran and what viewer |
| 45 | // idear.From handed it. A middleware test that only checked the status |
| 46 | // code would not notice a Require that answered 404 AND still called |
| 47 | // next. |
| 48 | type spy struct { |
| 49 | called bool |
| 50 | member *idear.Member |
| 51 | } |
| 52 | |
| 53 | func (s *spy) handler() http.Handler { |
| 54 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 55 | s.called = true |
| 56 | s.member = idear.From(r) |
| 57 | io.WriteString(w, "the guarded page") |
| 58 | }) |
| 59 | } |
| 60 | |
| 61 | // as issues one GET through h carrying subject's session, exactly as |
| 62 | // the app's own session middleware would have stashed it. An EMPTY |
| 63 | // subject means no session at all — which is what Require sees when it |
| 64 | // is mounted outside the app's session guard. |
| 65 | func as(subject string, h http.Handler) *httptest.ResponseRecorder { |
| 66 | r := httptest.NewRequest(http.MethodGet, "/members", nil) |
| 67 | if subject != "" { |
| 68 | r = sessions.WithSession(r, sessions.Session{Subject: subject}) |
| 69 | } |
| 70 | w := httptest.NewRecorder() |
| 71 | h.ServeHTTP(w, r) |
| 72 | return w |
| 73 | } |
| 74 | |
| 75 | func TestRequireAdmitsAnActiveMember(t *testing.T) { |
| 76 | h := guardedHarness(t) |
| 77 | owner := h.Owner() |
| 78 | |
| 79 | var s spy |
| 80 | w := as(owner.Subject, h.Roster.Require(s.handler())) |
| 81 | |
| 82 | if w.Code != http.StatusOK { |
| 83 | t.Fatalf("status = %d, want 200; body %q", w.Code, w.Body.String()) |
| 84 | } |
| 85 | if !s.called { |
| 86 | t.Fatal("Require did not call next for an active member") |
| 87 | } |
| 88 | if s.member == nil { |
| 89 | t.Fatal("idear.From returned nil inside Require; the viewer must ride the context") |
| 90 | } |
| 91 | if s.member.ID != owner.ID || s.member.Role != idear.RoleOwner { |
| 92 | t.Errorf("From = %+v, want the owner %+v", s.member, owner) |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | func TestRequireAnswersNotFoundForNonMember(t *testing.T) { |
| 97 | h := guardedHarness(t) |
| 98 | h.Owner() // the instance is claimed; the visitor is simply not in it |
| 99 | |
| 100 | var s spy |
| 101 | w := as("a-subject-with-no-row", h.Roster.Require(s.handler())) |
| 102 | |
| 103 | if w.Code != http.StatusNotFound { |
| 104 | t.Errorf("status = %d, want 404", w.Code) |
| 105 | } |
| 106 | if got := w.Body.String(); got != appNotFound { |
| 107 | t.Errorf("body = %q, want the app's own 404 page %q; a different 404 is a membership oracle", got, appNotFound) |
| 108 | } |
| 109 | if s.called { |
| 110 | t.Error("Require called next for a non-member") |
| 111 | } |
| 112 | if loc := w.Header().Get("Location"); loc != "" { |
| 113 | t.Errorf("Require redirected to %q; signed-out handling belongs to the upstream sessions.Require", loc) |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | func TestRequireAnswersNotFoundForDeactivatedMember(t *testing.T) { |
| 118 | h := guardedHarness(t) |
| 119 | h.Owner() |
| 120 | gone := h.Deactivated(idear.RoleAdmin) |
| 121 | |
| 122 | var s spy |
| 123 | w := as(gone.Subject, h.Roster.Require(s.handler())) |
| 124 | |
| 125 | if w.Code != http.StatusNotFound { |
| 126 | t.Errorf("status = %d, want 404; a deactivated member keeps their row and loses every privilege it carried", w.Code) |
| 127 | } |
| 128 | if s.called { |
| 129 | t.Error("Require called next for a deactivated member") |
| 130 | } |
| 131 | |
| 132 | // The two refusals must be INDISTINGUISHABLE. A deactivated member |
| 133 | // who could tell their own answer apart from a stranger's has been |
| 134 | // told that this instance knows them, which is the same oracle a |
| 135 | // custom 404 page would open. |
| 136 | var s2 spy |
| 137 | stranger := as("a-subject-with-no-row", h.Roster.Require(s2.handler())) |
| 138 | if w.Code != stranger.Code || w.Body.String() != stranger.Body.String() { |
| 139 | t.Errorf("deactivated member got %d/%q, stranger got %d/%q; the two must be byte-identical", |
| 140 | w.Code, w.Body.String(), stranger.Code, stranger.Body.String()) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | // TestRequireAnswersNotFoundWithNoSession pins the design's loudest |
| 145 | // silent trap: mounted OUTSIDE the app's session guard, Config.Subject |
| 146 | // resolves nothing and every request 404s — including a real member's. |
| 147 | // Correct, and undetectable from the response, which is why it is a |
| 148 | // test and a doc comment rather than a comment alone. |
| 149 | func TestRequireAnswersNotFoundWithNoSession(t *testing.T) { |
| 150 | h := guardedHarness(t) |
| 151 | owner := h.Owner() |
| 152 | |
| 153 | var s spy |
| 154 | w := as("", h.Roster.Require(s.handler())) |
| 155 | |
| 156 | if w.Code != http.StatusNotFound { |
| 157 | t.Errorf("status = %d, want 404", w.Code) |
| 158 | } |
| 159 | if s.called { |
| 160 | t.Error("Require called next with no session subject") |
| 161 | } |
| 162 | if loc := w.Header().Get("Location"); loc != "" { |
| 163 | t.Errorf("Require redirected to %q; it must never redirect", loc) |
| 164 | } |
| 165 | // And the same viewer, with a session, is admitted — so the 404 |
| 166 | // above is the missing session and not a broken lookup. |
| 167 | var s2 spy |
| 168 | if got := as(owner.Subject, h.Roster.Require(s2.handler())); got.Code != http.StatusOK { |
| 169 | t.Fatalf("the same member WITH a session got %d, want 200", got.Code) |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | func TestRequireRoleForbidsBelowMinimum(t *testing.T) { |
| 174 | h := guardedHarness(t) |
| 175 | h.Owner() |
| 176 | plain := h.Member(idear.RoleMember) |
| 177 | |
| 178 | var s spy |
| 179 | // Stacked, which is the only supported mounting: RequireRole |
| 180 | // INSIDE Require. |
| 181 | guard := h.Roster.Require(h.Roster.RequireRole(idear.RoleAdmin)(s.handler())) |
| 182 | w := as(plain.Subject, guard) |
| 183 | |
| 184 | if w.Code != http.StatusForbidden { |
| 185 | t.Fatalf("status = %d, want 403; a member may legitimately see the page and merely may not act", w.Code) |
| 186 | } |
| 187 | if w.Code == http.StatusNotFound { |
| 188 | t.Error("RequireRole answered 404; 404 is for non-members, 403 is for insufficient rank") |
| 189 | } |
| 190 | if got := w.Body.String(); got != appForbidden { |
| 191 | t.Errorf("body = %q, want the app's own 403 page %q", got, appForbidden) |
| 192 | } |
| 193 | if s.called { |
| 194 | t.Error("RequireRole called next for a member below the minimum") |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | func TestRequireRoleAdmitsAtAndAboveMinimum(t *testing.T) { |
| 199 | h := guardedHarness(t) |
| 200 | owner := h.Owner() |
| 201 | admin := h.Member(idear.RoleAdmin) |
| 202 | |
| 203 | for _, m := range []*idear.Member{admin, owner} { |
| 204 | var s spy |
| 205 | guard := h.Roster.Require(h.Roster.RequireRole(idear.RoleAdmin)(s.handler())) |
| 206 | w := as(m.Subject, guard) |
| 207 | if w.Code != http.StatusOK || !s.called { |
| 208 | t.Errorf("%s got %d (next called: %v), want 200 and next called", m.Role, w.Code, s.called) |
| 209 | } |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | func TestFromIsNilWithoutRequire(t *testing.T) { |
| 214 | var s spy |
| 215 | as("whoever", s.handler()) |
| 216 | if s.member != nil { |
| 217 | t.Errorf("idear.From = %+v outside Require, want nil", s.member) |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | // TestRequireHonoursSubjectNotOk pins the OTHER half of the subject |
| 222 | // guard. Config.Subject returns (string, bool), and an override is |
| 223 | // free to return a non-empty string alongside ok=false — a stale |
| 224 | // cookie's subject, say, or a half-resolved session. The bool is the |
| 225 | // answer; the string is not. Reading the string and ignoring the bool |
| 226 | // admits exactly the viewer the override was refusing. |
| 227 | func TestRequireHonoursSubjectNotOk(t *testing.T) { |
| 228 | h := guardedHarness(t) |
| 229 | owner := h.Owner() |
| 230 | |
| 231 | rs, err := idear.New(idear.Config{ |
| 232 | DB: h.DB.G, |
| 233 | Subject: func(r *http.Request) (string, bool) { |
| 234 | // A real subject, refused. |
| 235 | return owner.Subject, false |
| 236 | }, |
| 237 | NotFound: func(w http.ResponseWriter, r *http.Request) { |
| 238 | w.WriteHeader(http.StatusNotFound) |
| 239 | io.WriteString(w, appNotFound) |
| 240 | }, |
| 241 | }) |
| 242 | if err != nil { |
| 243 | t.Fatalf("idear.New: %v", err) |
| 244 | } |
| 245 | |
| 246 | var s spy |
| 247 | w := as(owner.Subject, rs.Require(s.handler())) |
| 248 | if w.Code != http.StatusNotFound { |
| 249 | t.Errorf("status = %d, want 404: Config.Subject said ok=false", w.Code) |
| 250 | } |
| 251 | if s.called { |
| 252 | t.Error("Require called next for a subject its own resolver refused") |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | // TestRequireAnswersNotFoundWhenTheStoreIsBroken: a storage failure is |
| 257 | // NOT a membership answer, and it is deliberately rendered as one. |
| 258 | // |
| 259 | // A 500 here would hand a prober a signal that varies with the |
| 260 | // database rather than with membership, and it would look different |
| 261 | // from every other refusal this middleware makes. Fail closed, render |
| 262 | // the app's own 404, and put the distinction in the log — the same |
| 263 | // posture Authorize takes for the same reason. |
| 264 | func TestRequireAnswersNotFoundWhenTheStoreIsBroken(t *testing.T) { |
| 265 | h := guardedHarness(t) |
| 266 | owner := h.Owner() |
| 267 | if err := h.DB.G.Exec("DROP TABLE idear_members").Error; err != nil { |
| 268 | t.Fatalf("dropping idear_members: %v", err) |
| 269 | } |
| 270 | |
| 271 | var s spy |
| 272 | w := as(owner.Subject, h.Roster.Require(s.handler())) |
| 273 | |
| 274 | if w.Code != http.StatusNotFound { |
| 275 | t.Errorf("status = %d, want 404: a broken store must not answer differently from a refusal", w.Code) |
| 276 | } |
| 277 | if got := w.Body.String(); got != appNotFound { |
| 278 | t.Errorf("body = %q, want the app's own 404 page %q", got, appNotFound) |
| 279 | } |
| 280 | if s.called { |
| 281 | t.Error("Require called next when it could not resolve the viewer at all") |
| 282 | } |
| 283 | } |
| 284 | |