Files
usoscal/internal/server/server_test.go
T

313 lines
9.1 KiB
Go

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 <strong>Ada Nowak</strong>") {
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)
}
}