feat: usos sign-in, private calendar feed and timetable archive
This commit is contained in:
commit
7d4e21f977
33 files changed
+3270
No files matched your search
@@ -0,0 +1,312 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user