313 lines
9.1 KiB
Go
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)
|
|
}
|
|
}
|