oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3import (
4 "context"
5 "errors"
6 "net/http"
7 "net/http/httptest"
8 "testing"
9 "time"
10
11 "amadan.net/rastrillo/rastrillo/sessions"
12)
13
14func identityRequest(method, subject, authMethod string) *http.Request {
15 return sessions.WithSession(httptest.NewRequest(method, "https://docs.example/", nil), sessions.Session{Subject: subject, Method: authMethod})
16}
17
18func TestHomeIdentityNeverCreatesPersonOrSamples(t *testing.T) {
19 app := newSampleDocumentsInternalTestApp(t, "1")
20 store := app.Store()
21 h := &handlers{store: store}
22 for _, subject := range []string{"opaque_subject", "looks-like-local@example.com"} {
23 r := identityRequest("GET", subject, homeAssertionMethod)
24 if _, err := h.owner(r); err != errIdentityRefused {
25 t.Fatal("unconfigured Home identity reached legacy owner", err)
26 }
27 if _, err := h.actor(r); err != errIdentityRefused {
28 t.Fatal("unconfigured Home identity reached legacy actor", err)
29 }
30 if h.member(r) {
31 t.Fatal("unconfigured Home identity gained membership")
32 }
33 for _, id := range []int64{0, 999999} {
34 v := identityResolver{store: store, issuer: "https://id.example", linkedPerson: func(ctx context.Context, issuer, got string) (int64, error) {
35 if issuer != "https://id.example" || got != subject {
36 t.Fatal("issuer/subject changed")
37 }
38 return id, nil
39 }}
40 reached := false
41 rec := httptest.NewRecorder()
42 v.middleware("/signin")(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true })).ServeHTTP(rec, r)
43 if reached || rec.Code != 303 {
44 t.Fatal("unknown Home identity reached handler")
45 }
46 }
47 }
48 var people, documents int64
49 if err := store.db.Model(&Person{}).Count(&people).Error; err != nil {
50 t.Fatal(err)
51 }
52 if err := store.db.Model(&Document{}).Count(&documents).Error; err != nil {
53 t.Fatal(err)
54 }
55 if people != 0 || documents != 0 {
56 t.Fatalf("Home path created people=%d documents=%d", people, documents)
57 }
58}
59
60func TestLinkedHomeIdentityPreservesOwnersAndAddressGrants(t *testing.T) {
61 app := newSampleDocumentsInternalTestApp(t, "0")
62 store := app.Store()
63 ctx := t.Context()
64 alice, err := store.EnsurePerson(ctx, "alice@example.com")
65 if err != nil {
66 t.Fatal(err)
67 }
68 bob, err := store.EnsurePerson(ctx, "bob@example.com")
69 if err != nil {
70 t.Fatal(err)
71 }
72 own, err := store.Create(ctx, alice, "Alice private")
73 if err != nil {
74 t.Fatal(err)
75 }
76 shared, err := store.Create(ctx, bob, "Bob shared")
77 if err != nil {
78 t.Fatal(err)
79 }
80 hidden, err := store.Create(ctx, bob, "Bob private")
81 if err != nil {
82 t.Fatal(err)
83 }
84 if err = store.Grant(ctx, bob, shared.Sqid, "alice@example.com", RoleWriter); err != nil {
85 t.Fatal(err)
86 }
87 v := identityResolver{store: store, issuer: "https://id.example", linkedPerson: func(ctx context.Context, issuer, subject string) (int64, error) {
88 if issuer != "https://id.example" || subject != "opaque_alice" {
89 return 0, nil
90 }
91 return alice, nil
92 }}
93 memberAddresses := []string{}
94 h := &handlers{store: store, isMember: func(address string) bool {
95 memberAddresses = append(memberAddresses, address)
96 return address == "alice@example.com"
97 }}
98 reached := false
99 end := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
100 reached = true
101 a, err := h.actor(r)
102 if err != nil || a.PersonID != alice || (a.VerifiedAddress == nil || *a.VerifiedAddress != "alice@example.com") {
103 t.Fatal("linked actor changed", a, err)
104 }
105 id, err := h.owner(r)
106 if err != nil || id != alice {
107 t.Fatal("linked owner changed", err)
108 }
109 if !h.member(r) {
110 t.Fatal("member did not use verified local address")
111 }
112 for _, d := range []*Document{own, shared} {
113 got, err := store.GetFor(ctx, a, d.Sqid)
114 if err != nil || got.ID != d.ID || !store.CanWrite(ctx, a, d.ID) {
115 t.Fatal("existing access lost", err)
116 }
117 }
118 if _, err := store.GetFor(ctx, a, hidden.Sqid); err == nil {
119 t.Fatal("link widened access")
120 }
121 if id, ok := currentIdentity(r); !ok || id.Session.Subject != "opaque_alice" {
122 t.Fatal("opaque identity not kept separate")
123 }
124 })
125 rec := httptest.NewRecorder()
126 v.middleware("/signin")(end).ServeHTTP(rec, identityRequest("GET", "opaque_alice", homeAssertionMethod))
127 if !reached || len(memberAddresses) != 1 {
128 t.Fatal("adapter not used")
129 }
130 var after Document
131 if err = store.db.First(&after, own.ID).Error; err != nil || after.OwnerID != alice {
132 t.Fatal("ownership rewritten")
133 }
134 grants, err := store.ListGrants(ctx, bob, shared.Sqid)
135 if err != nil || len(grants) != 1 || grants[0].Subject != "alice@example.com" {
136 t.Fatal("grant rewritten")
137 }
138 // Resolve the current local address on every request, not a saved copy.
139 if err = store.db.Model(&Person{}).Where("id=?", alice).Update("email", "renamed@example.com").Error; err != nil {
140 t.Fatal(err)
141 }
142 id, err := v.resolve(identityRequest("GET", "opaque_alice", homeAssertionMethod))
143 if err != nil || (id.Actor.VerifiedAddress == nil || *id.Actor.VerifiedAddress != "renamed@example.com") {
144 t.Fatal("stale local address")
145 }
146}
147
148func TestHomeAdmissionRequiresAdapterAndCurrentAddress(t *testing.T) {
149 app := newSampleDocumentsInternalTestApp(t, "0")
150 store := app.Store()
151 person, err := store.EnsurePerson(t.Context(), "local@example.com")
152 if err != nil {
153 t.Fatal(err)
154 }
155 list, err := resolveAllowlist(Config{AllowedAddresses: "local@example.com"})
156 if err != nil {
157 t.Fatal(err)
158 }
159 v := identityResolver{store: store, issuer: "https://id.example", linkedPerson: func(context.Context, string, string) (int64, error) { return person, nil }}
160 reached := false
161 next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true })
162 for _, method := range []string{"GET", "POST"} {
163 reached = false
164 rec := httptest.NewRecorder()
165 requireAdmission(list, "/signin")(next).ServeHTTP(rec, identityRequest(method, "local@example.com", homeAssertionMethod))
166 if reached || (method == "GET" && rec.Code != 303) || (method == "POST" && rec.Code != 403) {
167 t.Fatal("opaque address bypassed adapter")
168 }
169 }
170 rec := httptest.NewRecorder()
171 v.middleware("/signin")(requireAdmission(list, "/signin")(next)).ServeHTTP(rec, identityRequest("GET", "opaque", homeAssertionMethod))
172 if !reached {
173 t.Fatal("resolved local address not admitted")
174 }
175 reached = false
176 list, err = resolveAllowlist(Config{AllowedAddresses: "someone-else@example.com"})
177 if err != nil {
178 t.Fatal(err)
179 }
180 rec = httptest.NewRecorder()
181 v.middleware("/signin")(requireAdmission(list, "/signin")(next)).ServeHTTP(rec, identityRequest("POST", "opaque", homeAssertionMethod))
182 if reached || rec.Code != 403 {
183 t.Fatal("link bypassed changed admission")
184 }
185 v.linkedPerson = func(context.Context, string, string) (int64, error) { return 0, errors.New("fixture storage failure") }
186 rec = httptest.NewRecorder()
187 v.middleware("/signin")(next).ServeHTTP(rec, identityRequest("POST", "opaque", homeAssertionMethod))
188 if reached || rec.Code != 503 {
189 t.Fatal("storage failure became admission or permanent write refusal")
190 }
191}
192
193func TestLocalFreshnessUsesActualMethodAndVerificationTime(t *testing.T) {
194 now := time.Unix(1700000000, 0)
195 for _, tc := range []struct {
196 name, method string
197 auth, created time.Time
198 want bool
199 }{
200 {"magic original", "magiclink", now.Add(-time.Minute), now, true},
201 {"magic persisted creation", "magiclink", time.Time{}, now.Add(-time.Minute), true},
202 {"boundary", "magiclink", now.Add(-5 * time.Minute), now, true},
203 {"old verification fresh remint", "magiclink", now.Add(-301 * time.Second), now, false},
204 {"old persisted creation", "magiclink", time.Time{}, now.Add(-301 * time.Second), false},
205 {"future verification", "magiclink", now.Add(time.Second), now, false},
206 {"future creation", "magiclink", time.Time{}, now.Add(time.Second), false},
207 {"missing times", "magiclink", time.Time{}, time.Time{}, false},
208 {"keymail actual", "keymail", now.Add(-time.Minute), now, true},
209 {"keymail no original", "keymail", time.Time{}, now, false},
210 {"keymail stale", "keymail", now.Add(-301 * time.Second), now, false},
211 {"keymail future", "keymail", now.Add(time.Second), now, false},
212 {"home original", "home-assertion", now, now, false},
213 {"home remint", "home-assertion", time.Time{}, now, false},
214 {"other original", "password", now, now, false},
215 {"unknown remint", "", time.Time{}, now, false},
216 } {
217 t.Run(tc.name, func(t *testing.T) {
218 got, ok := freshLocalAuthentication(sessions.Session{Subject: "local@example.com", Method: tc.method, AuthTime: tc.auth, At: tc.created}, now)
219 if ok != tc.want {
220 t.Fatal("wrong local freshness decision")
221 }
222 expected := tc.auth
223 if expected.IsZero() {
224 expected = tc.created
225 }
226 if ok && !got.Equal(expected) {
227 t.Fatal("verification time rewritten")
228 }
229 if !ok && !got.IsZero() {
230 t.Fatal("refused proof returned freshness")
231 }
232 })
233 }
234}
235
236func TestLocalMagicLinkUsesPersistedVerificationAndRotation(t *testing.T) {
237 app := newSampleDocumentsInternalTestApp(t, "0")
238 reader, err := sessions.New(sessions.Config{DB: app.db.Writer(), Origin: app.cfg.Origin})
239 if err != nil {
240 t.Fatal(err)
241 }
242 first := app.TestRequest(t, "local@example.com", "GET", "/", nil)
243 session, ok := reader.From(first)
244 if !ok || session.Method != "magiclink" || !session.AuthTime.IsZero() || session.At.IsZero() {
245 t.Fatal("real local magic-link session did not match fallback contract")
246 }
247 verified, ok := freshLocalAuthentication(session, time.Now())
248 if !ok || !verified.Equal(session.At) {
249 t.Fatal("actual completed magic link not fresh")
250 }
251 oldCookie, err := first.Cookie(reader.CookieName())
252 if err != nil {
253 t.Fatal(err)
254 }
255 // Complete a second real local email token while presenting the first session.
256 link, err := app.TestSignIn(t, "local@example.com")
257 if err != nil {
258 t.Fatal(err)
259 }
260 verify := confirmRequest(link)
261 verify.AddCookie(oldCookie)
262 rec := httptest.NewRecorder()
263 app.Handler().ServeHTTP(rec, verify)
264 if rec.Code != 303 {
265 t.Fatal("local reverification failed", rec.Code)
266 }
267 second := httptest.NewRequest("GET", "/", nil)
268 for _, c := range rec.Result().Cookies() {
269 second.AddCookie(c)
270 }
271 if _, ok = reader.From(first); ok {
272 t.Fatal("reverification left old session usable")
273 }
274 next, ok := reader.From(second)
275 if !ok {
276 t.Fatal("rotated session missing")
277 }
278 if _, ok = freshLocalAuthentication(next, time.Now()); !ok {
279 t.Fatal("rotated real magic link not fresh")
280 }
281 newCookie, err := second.Cookie(reader.CookieName())
282 if err != nil || newCookie.Value == oldCookie.Value {
283 t.Fatal("actual browser binding did not rotate")
284 }
285}
286