rastrillo / idear Public

Clone
git clone https://amadan.net/rastrillo/idear

Plain git — no account needed to clone.

Download

Download this file

1package idear_test
2
3import (
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.
24const (
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.
30func 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.
48type spy struct {
49 called bool
50 member *idear.Member
51}
52
53func (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.
65func 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
75func 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
96func 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
117func 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.
149func 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
173func 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
198func 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
213func 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.
227func 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.
264func 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