package server import ( "bytes" "context" "database/sql" "encoding/base64" "io" "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" "path/filepath" "regexp" "strings" "testing" "time" "usoscal/internal/seal" "usoscal/internal/store" "usoscal/internal/syncer" "usoscal/internal/usos" "usoscal/internal/usostest" ) var feedAddress = regexp.MustCompile(`value="(http[^"]+/f/[^"]+\.ics)"`) type app struct { t *testing.T fake *usostest.Fake store *store.Store syncer *syncer.Syncer site *httptest.Server browser *http.Client dbPath string } func newApp(t *testing.T) *app { t.Helper() fake := usostest.New() t.Cleanup(fake.Close) dbPath := filepath.Join(t.TempDir(), "test.db") st, err := store.Open(dbPath) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) box, err := seal.New(base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{5}, 32))) if err != nil { t.Fatal(err) } client := usos.New(fake.URL, usostest.ConsumerKey, usostest.ConsumerSecret) sync := syncer.New(st, client, box, 24*time.Hour) sync.Pause = 0 sync.Now = func() time.Time { return time.Date(2026, 10, 11, 2, 30, 0, 0, usos.Location) } site := httptest.NewUnstartedServer(nil) site.Start() t.Cleanup(site.Close) handler, err := New(site.URL, st, client, box, sync) if err != nil { t.Fatal(err) } site.Config.Handler = handler jar, err := cookiejar.New(nil) if err != nil { t.Fatal(err) } return &app{t: t, fake: fake, store: st, syncer: sync, site: site, browser: &http.Client{Jar: jar}, dbPath: dbPath} } func (a *app) do(client *http.Client, method, path string, form url.Values, header http.Header) (int, string) { a.t.Helper() target := path if strings.HasPrefix(path, "/") { target = a.site.URL + path } var body io.Reader if form != nil { body = strings.NewReader(form.Encode()) } req, err := http.NewRequestWithContext(context.Background(), method, target, body) if err != nil { a.t.Fatal(err) } if form != nil { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } for key, values := range header { req.Header[key] = values } res, err := client.Do(req) if err != nil { a.t.Fatal(err) } defer res.Body.Close() data, err := io.ReadAll(res.Body) if err != nil { a.t.Fatal(err) } return res.StatusCode, string(data) } func (a *app) get(path string) (int, string) { a.t.Helper() return a.do(a.browser, http.MethodGet, path, nil, nil) } func (a *app) post(path string, form url.Values) (int, string) { a.t.Helper() return a.do(a.browser, http.MethodPost, path, form, nil) } func (a *app) anonymous(path string) (int, string) { a.t.Helper() return a.do(http.DefaultClient, http.MethodGet, path, nil, nil) } func (a *app) signIn() string { a.t.Helper() status, body := a.post("/login", url.Values{}) if status != http.StatusOK || !strings.Contains(body, "Signed in as Ada Nowak") { a.t.Fatalf("sign-in ended with %d:\n%s", status, body) } return a.address(body) } func (a *app) address(body string) string { a.t.Helper() match := feedAddress.FindStringSubmatch(body) if match == nil { a.t.Fatalf("no calendar address on the page:\n%s", body) } return match[1] } func (a *app) sync() { a.t.Helper() if err := a.syncer.Sync(context.Background(), usostest.UserID); err != nil { a.t.Fatal(err) } } func TestSignInGivesAWorkingCalendar(t *testing.T) { a := newApp(t) if status, body := a.get("/"); status != http.StatusOK || !strings.Contains(body, "Sign in with USOS") { t.Fatalf("front page: %d", status) } address := a.signIn() a.sync() status, calendar := a.anonymous(address) if status != http.StatusOK { t.Fatalf("feed status %d", status) } unfolded := strings.ReplaceAll(calendar, "\r\n ", "") for _, want := range []string{ "BEGIN:VCALENDAR", "DTSTART:20261014T113000Z", "SUMMARY:Algebra liniowa z elementami matematyki dyskretnej (Ćwiczenia)", `LOCATION:303 (A1 - BM - Budowa Maszyn\, budynek z zegarem)\, ul. Piotrowo 3\, 61-138 Poznań\, Polska`, `Lecturer: dr inż. Jan Kowalski`, } { if !strings.Contains(unfolded, want) { t.Errorf("feed lacks %q", want) } } if strings.Contains(unfolded, "DTSTART:20261007") { t.Error("past class in the feed while the archive is off") } } func TestArchiveAddsThePast(t *testing.T) { a := newApp(t) address := a.signIn() a.sync() if status, body := a.post("/archive", url.Values{"archive": {"on"}}); status != http.StatusOK || !strings.Contains(body, `name="archive" checked`) { t.Fatalf("archive not switched on: %d", status) } a.sync() if _, calendar := a.anonymous(address); !strings.Contains(calendar, "DTSTART:20261007T113000Z") || !strings.Contains(calendar, "DTSTART:20251001T113000Z") { t.Error("archived classes are missing from the feed") } a.post("/archive", url.Values{}) if _, calendar := a.anonymous(address); strings.Contains(calendar, "DTSTART:20261007") { t.Error("past classes stayed in the feed after the archive was turned off") } } func TestAddressCanBeReissuedAndRevoked(t *testing.T) { a := newApp(t) first := a.signIn() a.sync() _, body := a.post("/link", url.Values{"action": {"rotate"}}) second := a.address(body) if second == first { t.Fatal("address did not change") } if status, _ := a.anonymous(first); status != http.StatusNotFound { t.Errorf("old address answers %d after a new one was issued", status) } if status, _ := a.anonymous(second); status != http.StatusOK { t.Errorf("new address answers %d", status) } _, body = a.post("/link", url.Values{"action": {"revoke"}}) if !strings.Contains(body, "There is no active address") { t.Error("page still shows an address after revocation") } if status, _ := a.anonymous(second); status != http.StatusNotFound { t.Errorf("revoked address answers %d", status) } if status, _ := a.anonymous("/f/.ics"); status != http.StatusNotFound { t.Errorf("empty token answers %d", status) } } func TestSignOutDisconnectsAndCanDeleteEverything(t *testing.T) { a := newApp(t) address := a.signIn() a.sync() status, body := a.post("/logout", url.Values{}) if status != http.StatusOK || !strings.Contains(body, "Sign in with USOS") { t.Fatalf("after sign-out: %d", status) } if !a.fake.WasRevoked() { t.Error("the USOS token was not revoked") } if status, _ := a.anonymous(address); status != http.StatusNotFound { t.Errorf("address answers %d after sign-out", status) } ctx := context.Background() user, err := a.store.User(ctx, usostest.UserID) if err != nil || user.Connected() || len(user.FeedToken) != 0 { t.Errorf("tokens left after sign-out: %+v %v", user, err) } if events, _ := a.store.Events(ctx, usostest.UserID); len(events) == 0 { t.Error("timetable deleted although deletion was not requested") } b := newApp(t) b.signIn() b.sync() b.post("/logout", url.Values{"delete": {"on"}}) if _, err := b.store.User(ctx, usostest.UserID); err == nil { t.Error("user row left after sign-out with deletion") } if events, _ := b.store.Events(ctx, usostest.UserID); len(events) != 0 { t.Error("events left after sign-out with deletion") } } func TestSecretsAreNotStoredInTheClear(t *testing.T) { a := newApp(t) address := a.signIn() feedToken := strings.TrimSuffix(address[strings.LastIndex(address, "/")+1:], ".ics") if len(feedToken) < 43 { t.Errorf("feed token is only %d characters", len(feedToken)) } db, err := sql.Open("sqlite", "file:"+a.dbPath) if err != nil { t.Fatal(err) } defer db.Close() var token, secret, feed []byte var feedHash string if err := db.QueryRow(`select usos_token, usos_secret, feed_token, feed_hash from users`).Scan(&token, &secret, &feed, &feedHash); err != nil { t.Fatal(err) } stored := string(token) + string(secret) + string(feed) + feedHash for _, plain := range []string{"access-token", "access-secret", feedToken} { if strings.Contains(stored, plain) { t.Errorf("%q is readable in the database", plain) } } var sessionHash string if err := db.QueryRow(`select token_hash from sessions`).Scan(&sessionHash); err != nil { t.Fatal(err) } site, _ := url.Parse(a.site.URL) for _, cookie := range a.browser.Jar.Cookies(site) { if cookie.Name == sessionCookie && cookie.Value == sessionHash { t.Error("session token stored unhashed") } } } func TestForeignRequestsAreRejected(t *testing.T) { a := newApp(t) a.signIn() status, _ := a.do(a.browser, http.MethodPost, "/link", url.Values{"action": {"revoke"}}, http.Header{"Sec-Fetch-Site": {"cross-site"}}) if status != http.StatusForbidden { t.Errorf("cross-site form post answered %d", status) } if _, body := a.get("/"); !feedAddress.MatchString(body) { t.Error("a cross-site request revoked the address") } stranger := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} if status, _ := a.do(stranger, http.MethodPost, "/link", url.Values{"action": {"revoke"}}, nil); status != http.StatusSeeOther { t.Errorf("anonymous post answered %d", status) } if status, _ := a.do(stranger, http.MethodGet, "/callback?oauth_token=request-token-1&oauth_verifier=x", nil, nil); status != http.StatusBadRequest { t.Errorf("callback without a started sign-in answered %d", status) } }