| 1 | //go:build browser |
| 2 | |
| 3 | package calendartest |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "io" |
| 8 | "log/slog" |
| 9 | "net/http" |
| 10 | "path/filepath" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "amadan.net/rastrillo/rastrillo" |
| 15 | "amadan.net/rastrillo/rastrillo/harness" |
| 16 | "github.com/chromedp/cdproto/network" |
| 17 | "github.com/chromedp/chromedp" |
| 18 | |
| 19 | calendar "amadan.net/oficina/calendar/internal/calendar" |
| 20 | ) |
| 21 | |
| 22 | // TestPlacedChipsSurviveTheProductionCSP: Calendar in Chromium behind the |
| 23 | // production serving stack (App.ServeOptions through rastrillo.Handler: |
| 24 | // the security headers and Calendar's CSP), standalone. Two events on |
| 25 | // one day, at 09:00 for an hour and at 14:00 for two, paint where the |
| 26 | // packer put them: the later one lower, twice as tall, and a team |
| 27 | // calendar's chip in a different colour from a personal one. |
| 28 | // |
| 29 | // Every one of those is a style attribute. Under rastrillo fb806a2's |
| 30 | // default style-src the browser drops them all — both chips stack at the |
| 31 | // top of the column at their content height, and every chip falls back |
| 32 | // to the stylesheet's default hue — while the other browser checks, |
| 33 | // which serve App.Handler() with no CSP at all, stay green. |
| 34 | func TestPlacedChipsSurviveTheProductionCSP(t *testing.T) { |
| 35 | var app *calendar.App |
| 36 | rig := harness.New(t, func(origin string) http.Handler { |
| 37 | a, err := calendar.New(calendar.Config{ |
| 38 | DBPath: filepath.Join(t.TempDir(), "calendar.db"), |
| 39 | Env: "development", |
| 40 | Origin: origin, |
| 41 | Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), |
| 42 | }) |
| 43 | if err != nil { |
| 44 | t.Fatalf("calendar.New: %v", err) |
| 45 | } |
| 46 | t.Cleanup(func() { a.Close() }) |
| 47 | app = a |
| 48 | h, closeFn, err := rastrillo.Handler(a.ServeOptions(rastrillo.Options{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))})) |
| 49 | if err != nil { |
| 50 | t.Fatalf("rastrillo.Handler: %v", err) |
| 51 | } |
| 52 | if closeFn != nil { |
| 53 | t.Cleanup(func() { _ = closeFn() }) |
| 54 | } |
| 55 | return h |
| 56 | }) |
| 57 | session := signInOverHTTP(t, app, rig.Origin, "host@example.com") |
| 58 | |
| 59 | ctx := context.Background() |
| 60 | st := app.StoreForTest() |
| 61 | host := app.PersonByAddressForTest(t, "host@example.com") |
| 62 | personal, err := st.DefaultCalendarID(ctx, host.ID) |
| 63 | if err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | cals, err := st.Calendars(ctx, host.ID) |
| 67 | if err != nil { |
| 68 | t.Fatal(err) |
| 69 | } |
| 70 | var team int64 |
| 71 | for _, c := range cals { |
| 72 | if c.Kind == "team" { |
| 73 | team = c.ID |
| 74 | } |
| 75 | } |
| 76 | if team == 0 || team == personal { |
| 77 | t.Fatalf("no team calendar beside the personal one: %+v", cals) |
| 78 | } |
| 79 | for _, e := range []calendar.EventInput{ |
| 80 | {CalendarID: personal, Title: "Morning", StartLocal: "2026-09-10T09:00", EndLocal: "2026-09-10T10:00", TZ: "UTC"}, |
| 81 | {CalendarID: team, Title: "Afternoon", StartLocal: "2026-09-10T14:00", EndLocal: "2026-09-10T16:00", TZ: "UTC"}, |
| 82 | } { |
| 83 | if _, err := st.CreateEvent(ctx, host.ID, e); err != nil { |
| 84 | t.Fatal(err) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | run := func(what string, actions ...chromedp.Action) { |
| 89 | t.Helper() |
| 90 | c, cancel := context.WithTimeout(rig.Context(), 20*time.Second) |
| 91 | defer cancel() |
| 92 | if err := chromedp.Run(c, actions...); err != nil { |
| 93 | t.Fatalf("%s: %v", what, err) |
| 94 | } |
| 95 | } |
| 96 | run("sign in", network.SetCookie(session.Name, session.Value).WithURL(rig.Origin+"/"). |
| 97 | WithHTTPOnly(session.HttpOnly).WithSameSite(network.CookieSameSiteLax)) |
| 98 | |
| 99 | var day struct { |
| 100 | MorningTop, MorningHeight, AfternoonTop, AfternoonHeight float64 |
| 101 | MorningBorder, AfternoonBorder string |
| 102 | } |
| 103 | run("day view", |
| 104 | chromedp.Navigate(rig.Origin+"/day/2026-09-10"), |
| 105 | chromedp.WaitVisible(`[cal-placed]`, chromedp.ByQuery), |
| 106 | chromedp.Evaluate(`(() => { |
| 107 | const placed = (title) => [...document.querySelectorAll('[cal-placed]')].find(p => p.textContent.includes(title)); |
| 108 | const box = (title) => placed(title).getBoundingClientRect(); |
| 109 | const chip = (title) => getComputedStyle(placed(title).querySelector('[cal-chip]')); |
| 110 | return { |
| 111 | MorningTop: box('Morning').top, MorningHeight: box('Morning').height, |
| 112 | AfternoonTop: box('Afternoon').top, AfternoonHeight: box('Afternoon').height, |
| 113 | MorningBorder: chip('Morning').borderInlineStartColor, |
| 114 | AfternoonBorder: chip('Afternoon').borderInlineStartColor, |
| 115 | }; |
| 116 | })()`, &day), |
| 117 | ) |
| 118 | if day.AfternoonTop-day.MorningTop < 100 { |
| 119 | t.Errorf("the 14:00 chip sits %.0fpx below the 09:00 one: its position was refused", day.AfternoonTop-day.MorningTop) |
| 120 | } |
| 121 | if ratio := day.AfternoonHeight / day.MorningHeight; ratio < 1.8 || ratio > 2.2 { |
| 122 | t.Errorf("a two-hour chip is %.0fpx and a one-hour chip %.0fpx: their heights were refused", day.AfternoonHeight, day.MorningHeight) |
| 123 | } |
| 124 | if day.MorningBorder == day.AfternoonBorder { |
| 125 | t.Errorf("the personal and team chips paint the same hue %s: --cal-hue was refused", day.MorningBorder) |
| 126 | } |
| 127 | } |
| 128 | |