oficina / calendar Public

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

Plain git — no account needed to clone.

Download

Download this file

1package calendar
2
3import (
4 "bytes"
5 "encoding/base64"
6 "encoding/json"
7 "io"
8 "log/slog"
9 "net/http"
10 "net/http/httptest"
11 "net/url"
12 "path/filepath"
13 "regexp"
14 "strings"
15 "testing"
16 "time"
17
18 "amadan.net/oficina/home/connect"
19 "amadan.net/rastrillo/rastrillo/assertion"
20 assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
21)
22
23const homeTestOrigin = "https://calendar.example.test"
24const homeTestIssuer = "https://id.example.test"
25
26type homeRouterFixture struct {
27 app *App
28 key *assertioncrypto.Keypair
29 cookies map[string]*http.Cookie
30}
31
32func newHomeRouterFixture(t *testing.T) *homeRouterFixture {
33 t.Helper()
34 key, err := assertioncrypto.Generate()
35 if err != nil {
36 t.Fatal(err)
37 }
38 manifest, _ := json.Marshal(map[string]string{"key": base64.StdEncoding.EncodeToString(key.SignPub())})
39 a, err := New(Config{DBPath: filepath.Join(t.TempDir(), "calendar.db"), Env: "development", Origin: homeTestOrigin, AllowedAddresses: "alice@example.test,replacement@example.test", Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Home: &connect.Credentials{Issuer: homeTestIssuer, ConsumerID: "calendar-test", KeysBase64: base64.StdEncoding.EncodeToString(manifest), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))}})
40 if err != nil {
41 t.Fatal(err)
42 }
43 t.Cleanup(func() { a.Close() })
44 return &homeRouterFixture{a, key, map[string]*http.Cookie{}}
45}
46func (f *homeRouterFixture) do(method, path, body string) *httptest.ResponseRecorder {
47 r := httptest.NewRequest(method, homeTestOrigin+path, strings.NewReader(body))
48 for _, c := range f.cookies {
49 r.AddCookie(c)
50 }
51 if method == "POST" {
52 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
53 r.Header.Set("Origin", homeTestOrigin)
54 if path == "/auth/assertion" {
55 r.Header.Set("Origin", homeTestIssuer)
56 }
57 }
58 w := httptest.NewRecorder()
59 f.app.mux.ServeHTTP(w, r)
60 for _, c := range w.Result().Cookies() {
61 if c.MaxAge < 0 {
62 delete(f.cookies, c.Name)
63 } else {
64 f.cookies[c.Name] = c
65 }
66 }
67 return w
68}
69func homeRedirect(t *testing.T, w *httptest.ResponseRecorder) string {
70 t.Helper()
71 if w.Code != 303 {
72 t.Fatalf("redirect status %d: %s", w.Code, w.Body.String())
73 }
74 return w.Header().Get("Location")
75}
76func (f *homeRouterFixture) local(t *testing.T) {
77 t.Helper()
78 homeRedirect(t, f.do("POST", "/signin", "address=alice%40example.test&force=1"))
79 f.app.signinMail.wait()
80 link := regexp.MustCompile(`https://[^\s<>\"]+/auth/verify\?[^\s<>\"]+`).FindString(f.app.mailRecorder.last())
81 u, err := url.Parse(link)
82 if err != nil || link == "" {
83 t.Fatal("no captured local verification link")
84 }
85 // Opening the link draws the confirm door under LocalGuard and
86 // spends nothing; its Sign in button POSTs the token back, and that
87 // POST is the local callback.
88 open := f.do("GET", u.RequestURI(), "")
89 action, token, ok := ConfirmFormForTest(open.Body.String())
90 if open.Code != 200 || !ok || !strings.Contains(open.Body.String(), "estilo-door") {
91 t.Fatalf("Home-local link: %d, want the confirm door\n%s", open.Code, open.Body.String())
92 }
93 landing := homeRedirect(t, f.do("POST", action, url.Values{"token": {token}}.Encode()))
94 if landing != "/auth/local/complete" {
95 t.Fatal("wrong local landing", landing)
96 }
97 homeRedirect(t, f.do("GET", landing, ""))
98}
99func (f *homeRouterFixture) exchange(t *testing.T, start, subject string) *httptest.ResponseRecorder {
100 t.Helper()
101 target := homeRedirect(t, f.do("GET", start, ""))
102 u, err := url.Parse(target)
103 if err != nil || u.Query().Get("consumer") != "calendar-test" {
104 t.Fatal("bad issuer redirect")
105 }
106 nonce := u.Query().Get("nonce")
107 now := time.Now().Unix()
108 jti, err := assertion.RandomID()
109 if err != nil {
110 t.Fatal(err)
111 }
112 token, err := assertion.Sign(f.key, assertion.Claims{Version: 1, Issuer: homeTestIssuer, Audience: homeTestOrigin, Subject: subject, KeyID: "key", ID: jti, Nonce: nonce, IssuedAt: now, ExpiresAt: now + 90, AuthTime: now})
113 if err != nil {
114 t.Fatal(err)
115 }
116 callback := f.do("POST", "/auth/assertion", url.Values{"assertion": {token}}.Encode())
117 finish := homeRedirect(t, callback)
118 return f.do("GET", finish, "")
119}
120
121func TestHomeRouterRetainsExistingCalendarIdentity(t *testing.T) {
122 f := newHomeRouterFixture(t)
123 f.local(t)
124 if w := f.do("GET", "/day/2026-09-10", ""); w.Code != 200 {
125 t.Fatal("local calendar", w.Code, w.Body.String())
126 } else if !strings.Contains(w.Body.String(), `aria-label="Account: alice@example.test"`) {
127 // With Home on, a LOCAL session's chrome still names the person
128 // (viewerFrom only takes the Home path for a Home session).
129 t.Fatal("local session in Home mode drew no account menu")
130 }
131 var person Person
132 if err := f.app.db.G.Where("address=?", "alice@example.test").First(&person).Error; err != nil {
133 t.Fatal(err)
134 }
135
136 calendarID, err := f.app.store.DefaultCalendarID(t.Context(), person.ID)
137 if err != nil {
138 t.Fatal(err)
139 }
140 event, err := f.app.store.CreateEvent(t.Context(), person.ID, EventInput{CalendarID: calendarID, Title: "Existing private appointment", StartLocal: "2026-09-10T10:00", EndLocal: "2026-09-10T11:00", TZ: "Europe/Dublin"})
141 if err != nil {
142 t.Fatal(err)
143 }
144 if err := f.app.store.AddReminder(t.Context(), person.ID, event.ID, 15); err != nil {
145 t.Fatal(err)
146 }
147 var calendarsBefore int64
148 f.app.db.G.Model(&Calendar{}).Count(&calendarsBefore)
149 cookie := f.cookies[f.app.home.sessions.CookieName()].Value
150 if w := f.do("GET", "/account/home", ""); w.Code != 200 {
151 t.Fatal("link page", w.Code, w.Body.String())
152 }
153 start := homeRedirect(t, f.do("POST", "/account/home", ""))
154 if got := homeRedirect(t, f.exchange(t, start, "opaque-home-alice")); got != "/account/home/confirm" {
155 t.Fatal(got)
156 }
157 if f.cookies[f.app.home.sessions.CookieName()].Value != cookie {
158 t.Fatal("proof replaced session")
159 }
160 homeRedirect(t, f.do("POST", "/account/home/confirm", ""))
161 // connect now preserves this POST across the app → id.* sign-out hop;
162 // a 303 would turn it into a GET and leave the directory session alive.
163 if out := f.do("POST", "/signout", ""); out.Code != http.StatusTemporaryRedirect || out.Header().Get("Location") != homeTestIssuer+"/signout" {
164 t.Fatalf("sign-out = %d %q, want 307 to issuer", out.Code, out.Header().Get("Location"))
165 }
166 start = homeRedirect(t, f.do("GET", "/signin", ""))
167 target := homeRedirect(t, f.exchange(t, start, "opaque-home-alice"))
168 if target != "/" {
169 t.Fatal("unexpected landing", target)
170 }
171 if w := f.do("GET", "/day/2026-09-10", ""); w.Code != 200 {
172 t.Fatal("Home calendar", w.Code, w.Body.String())
173 }
174 resolved, err := f.app.home.bridge.Resolve(t.Context(), "opaque-home-alice")
175 if err != nil || resolved.ID != person.ID || resolved.Label != person.Address {
176 t.Fatal("changed identity", resolved, err)
177 }
178 var people, calendarsAfter int64
179 f.app.db.G.Model(&Person{}).Count(&people)
180 f.app.db.G.Model(&Calendar{}).Count(&calendarsAfter)
181 if people != 1 || calendarsBefore != calendarsAfter {
182 t.Fatal("Home created data", people, calendarsBefore, calendarsAfter)
183 }
184
185 if w := f.do("GET", "/day/2026-09-10", ""); !strings.Contains(w.Body.String(), "Existing private appointment") {
186 t.Fatal("Home lost existing event")
187 }
188 reminders, err := f.app.store.RemindersFor(t.Context(), resolved.ID, event.ID)
189 if err != nil || len(reminders) != 1 || reminders[0].PersonID != person.ID {
190 t.Fatal("Home changed reminder owner", err)
191 }
192
193 if got := homeRedirect(t, f.do("GET", "/account/home", "")); got != "/signin/local" {
194 t.Fatal("Home counted as local proof", got)
195 }
196 savedHome := f.app.home
197 f.app.home = nil
198 if w := f.do("GET", "/day/2026-09-10", ""); w.Code != 404 {
199 t.Fatal("disabled Home accepted session", w.Code)
200 }
201 f.app.home = savedHome
202 delete(f.app.home.admitted.addrs, "alice@example.test")
203 if w := f.do("GET", "/day/2026-09-10", ""); w.Code != 404 {
204 t.Fatal("removed member admitted", w.Code)
205 }
206 f.app.home.admitted.addrs["alice@example.test"] = struct{}{}
207 if err := f.app.db.G.Delete(&Person{}, person.ID).Error; err != nil {
208 t.Fatal(err)
209 }
210 if w := f.do("GET", "/", ""); w.Code != 404 {
211 t.Fatal("deleted account admitted", w.Code)
212 } else {
213 // The 404 still has a session to sign out of, but no identity:
214 // the account menu says "Signed in" and names nobody, rather than
215 // "Account: " and "Signed in as" over an empty address.
216 body := w.Body.String()
217 if strings.Contains(body, `aria-label="Account: "`) || strings.Contains(body, "Signed in as") {
218 t.Fatal("error page drew an empty identity in the account menu")
219 }
220 if !strings.Contains(body, "estilo-account") || !strings.Contains(body, ">Signed in<") || !strings.Contains(body, "Sign out") {
221 t.Fatal("error page lost the account menu for a live session")
222 }
223 }
224 replacement, err := f.app.store.EnsurePerson(t.Context(), "replacement@example.test")
225 if err != nil || replacement.ID == person.ID {
226 t.Fatal("account ID reused", replacement.ID, err)
227 }
228 if w := f.do("GET", "/", ""); w.Code != 404 {
229 t.Fatal("Home attached replacement", w.Code)
230 }
231 // /signin does not shortcut this session to "/", which would 404 with
232 // nowhere to go: it ends the session and starts over (Astra P3, #23).
233 if w := f.do("GET", "/signin", ""); w.Code == http.StatusSeeOther && w.Header().Get("Location") == "/" {
234 t.Fatal("/signin shortcut a deleted Home person to /, which 404s")
235 }
236 if f.cookies[f.app.home.sessions.CookieName()] != nil {
237 t.Fatal("/signin kept a deleted Home person's session")
238 }
239}
240func TestHomeRouterUnknownSubjectCannotCreateAccount(t *testing.T) {
241 f := newHomeRouterFixture(t)
242 if _, err := f.app.store.EnsurePersonSetup(t.Context(), "alice@example.test"); err != nil {
243 t.Fatal(err)
244 }
245 start := homeRedirect(t, f.do("GET", "/signin", ""))
246 if w := f.exchange(t, start, "unknown-home-alice"); w.Code != 403 {
247 t.Fatal("unknown Home admitted", w.Code)
248 }
249 if f.cookies[f.app.home.sessions.CookieName()] != nil {
250 t.Fatal("unknown Home session minted")
251 }
252 var count int64
253 f.app.db.G.Model(&Person{}).Count(&count)
254 if count != 1 {
255 t.Fatal("Home allocated person", count)
256 }
257}
258func TestHomeRouterRejectsCrossSiteMutationAndDuplicateSession(t *testing.T) {
259 f := newHomeRouterFixture(t)
260 f.local(t)
261 req := httptest.NewRequest("POST", homeTestOrigin+"/account/home", nil)
262 req.Header.Set("Origin", "https://stranger.example.test")
263 for _, c := range f.cookies {
264 req.AddCookie(c)
265 }
266 w := httptest.NewRecorder()
267 f.app.mux.ServeHTTP(w, req)
268 if w.Code != 403 {
269 t.Fatal("cross-site mutation", w.Code)
270 }
271 req = httptest.NewRequest("GET", homeTestOrigin+"/account/home", nil)
272 c := f.cookies[f.app.home.sessions.CookieName()]
273 req.AddCookie(c)
274 req.AddCookie(c)
275 w = httptest.NewRecorder()
276 f.app.mux.ServeHTTP(w, req)
277 if w.Code < 400 {
278 t.Fatal("ambiguous cookie admitted", w.Code)
279 }
280}
281
282func TestPartialHomeConfigurationRefusesBeforeOpeningDatabase(t *testing.T) {
283 _, err := New(Config{DBPath: filepath.Join(t.TempDir(), "calendar.db"), Env: "development", Origin: homeTestOrigin, Home: &connect.Credentials{Issuer: homeTestIssuer}})
284 if err == nil {
285 t.Fatal("partial Home credentials accepted")
286 }
287}
288