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 "path/filepath"
11 "regexp"
12 "strings"
13 "testing"
14
15 "amadan.net/oficina/home/connect"
16 assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
17)
18
19const homeTestDirectory = "https://directory.example.test"
20
21// newHomeConsumerFixture configures Home the way production does: from the
22// environment, through connect.CredentialsFromEnv, so HOME_DIRECTORY_ORIGIN
23// (unprefixed, the name Home itself reads) is read exactly as it is live.
24func newHomeConsumerFixture(t *testing.T, directory string) *homeRouterFixture {
25 t.Helper()
26 key, err := assertioncrypto.Generate()
27 if err != nil {
28 t.Fatal(err)
29 }
30 manifest, _ := json.Marshal(map[string]string{"key": base64.StdEncoding.EncodeToString(key.SignPub())})
31 t.Setenv("CALENDAR_HOME_ISSUER", homeTestIssuer)
32 t.Setenv("CALENDAR_HOME_CONSUMER_ID", "calendar-test")
33 t.Setenv("CALENDAR_HOME_KEYS_BASE64", base64.StdEncoding.EncodeToString(manifest))
34 t.Setenv("CALENDAR_HOME_DEK_BASE64", base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32)))
35 t.Setenv("HOME_DIRECTORY_ORIGIN", directory)
36 a, err := New(Config{DBPath: filepath.Join(t.TempDir(), "calendar.db"), Env: "development", Origin: homeTestOrigin, AllowedAddresses: "alice@example.test", Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Home: connect.CredentialsFromEnv("CALENDAR")})
37 if err != nil {
38 t.Fatal(err)
39 }
40 t.Cleanup(func() { a.Close() })
41 return &homeRouterFixture{a, key, map[string]*http.Cookie{}}
42}
43
44// Sign out in Calendar ends the id.* session too: the app session ends and
45// the same POST is handed on (307) to {issuer}/signout, which returns the
46// browser to /signed-out. Without it the next "Sign in" passed silently
47// through the live id.* session (spec shared-chrome §9.5).
48func TestHomeSignoutChainsToIssuer(t *testing.T) {
49 f := newHomeConsumerFixture(t, "")
50 f.local(t)
51 w := f.do("POST", "/signout", "")
52 if w.Code != http.StatusTemporaryRedirect || w.Header().Get("Location") != homeTestIssuer+"/signout" {
53 t.Fatalf("POST /signout: status %d Location %q, want 307 to %s/signout", w.Code, w.Header().Get("Location"), homeTestIssuer)
54 }
55 if got := w.Header().Get("Referrer-Policy"); got != "strict-origin-when-cross-origin" {
56 t.Fatalf("Referrer-Policy %q: id.* admits the null-Origin hop by Referer", got)
57 }
58 if f.cookies[f.app.home.sessions.CookieName()] != nil {
59 t.Fatal("sign-out kept the Calendar session cookie")
60 }
61}
62
63// A signed-in person who follows a "Sign in" link goes straight to where
64// they were headed, with no second exchange.
65func TestHomeSigninWhenSignedInReturnsToPage(t *testing.T) {
66 f := newHomeConsumerFixture(t, "")
67 f.local(t)
68 if got := homeRedirect(t, f.do("GET", "/signin?return_to=%2Fday%2F2026-09-10", "")); got != "/day/2026-09-10" {
69 t.Fatalf("signed-in /signin went to %q, want /day/2026-09-10", got)
70 }
71}
72
73// "All apps" is the estilo switcher's item (shared-chrome spec §6), linking
74// to the hub directory's root: HOME_DIRECTORY_ORIGIN (an origin, which
75// connect refuses with a path or a trailing slash) plus "/". No directory,
76// no switcher.
77func TestAllAppsLinksToDirectory(t *testing.T) {
78 item := regexp.MustCompile(`<details estilo-switcher[\s\S]*?<a estilo-menu-item href="([^"]*)"><span estilo-menu-label>All apps</span></a>`)
79 for _, configured := range []string{homeTestDirectory} {
80 f := newHomeConsumerFixture(t, configured)
81 f.local(t)
82 m := item.FindStringSubmatch(f.do("GET", "/day/2026-09-10", "").Body.String())
83 if m == nil {
84 t.Fatalf("HOME_DIRECTORY_ORIGIN=%s: no All apps item in the estilo switcher", configured)
85 }
86 if m[1] != homeTestDirectory+"/" {
87 t.Errorf("HOME_DIRECTORY_ORIGIN=%s: All apps links to %q, want %q", configured, m[1], homeTestDirectory+"/")
88 }
89 }
90
91 bare := newHomeConsumerFixture(t, "")
92 bare.local(t)
93 if body := bare.do("GET", "/day/2026-09-10", "").Body.String(); strings.Contains(body, "All apps") || strings.Contains(body, "estilo-switcher") {
94 t.Fatal("switcher shown with no directory configured")
95 }
96}
97
98// A session whose person is no longer admitted is not "already signed in":
99// /signin ends it and starts over, rather than shortcutting to the page
100// (which requirePerson answers with a terminating 404). Astra P3 on #23;
101// the same check docs' admitted and sheets' homeRequire make.
102func TestSigninDoesNotShortcutARevokedPerson(t *testing.T) {
103 f := newHomeConsumerFixture(t, "")
104 f.local(t)
105 delete(f.app.home.admitted.addrs, "alice@example.test")
106 w := f.do("GET", "/signin?return_to=%2Fday%2F2026-09-10", "")
107 if w.Code == http.StatusSeeOther && w.Header().Get("Location") == "/day/2026-09-10" {
108 t.Fatal("/signin shortcut a removed member to the page, which 404s")
109 }
110 if f.cookies[f.app.home.sessions.CookieName()] != nil {
111 t.Fatal("/signin kept the removed member's session")
112 }
113 // Asked again with no session, it starts a fresh exchange.
114 if got := homeRedirect(t, f.do("GET", "/signin?return_to=%2Fday%2F2026-09-10", "")); !strings.HasPrefix(got, "/auth/start") {
115 t.Fatalf("second /signin went to %q, want the exchange", got)
116 }
117}
118