package auth import ( "errors" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "gitea.parodia.dev/drwily/petal/internal/db" ) // newStores opens a database holding two users and returns the session and user // stores over it. func newStores(t *testing.T) (*SessionStore, *UserStore, *db.DB) { t.Helper() database, err := db.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatalf("open db: %v", err) } t.Cleanup(func() { database.Close() }) if _, err := database.Exec( `INSERT INTO users (id, email, display_name) VALUES (?, ?, ?)`, "bob", "bob@petal.local", "Bob", ); err != nil { t.Fatalf("seed second user: %v", err) } return NewSessionStore(database.DB), NewUserStore(database.DB), database } // withCookie builds a request carrying a session token. func withCookie(token string) *http.Request { r := httptest.NewRequest(http.MethodGet, "/", nil) r.AddCookie(&http.Cookie{Name: SessionCookie, Value: token}) return r } func TestSessionLifecycle(t *testing.T) { sessions, _, _ := newStores(t) token, err := sessions.Create(db.LocalUserID, "test-agent") if err != nil { t.Fatalf("create: %v", err) } got, err := sessions.Resolve(withCookie(token)) if err != nil { t.Fatalf("resolve: %v", err) } if got != db.LocalUserID { t.Fatalf("resolved %q, want %q", got, db.LocalUserID) } // Signing out must invalidate the token server-side, not just in the browser: // clearing only the cookie leaves a token that still works if it ever leaked. if err := sessions.Revoke(token); err != nil { t.Fatalf("revoke: %v", err) } if _, err := sessions.Resolve(withCookie(token)); !errors.Is(err, ErrNoSession) { t.Fatalf("revoked token still resolves (err=%v)", err) } } // A request with no cookie, or a token nobody issued, is simply not signed in. func TestSessionRejectsUnknown(t *testing.T) { sessions, _, _ := newStores(t) if _, err := sessions.Resolve(httptest.NewRequest(http.MethodGet, "/", nil)); !errors.Is(err, ErrNoSession) { t.Fatalf("bare request err=%v, want ErrNoSession", err) } if _, err := sessions.Resolve(withCookie("not-a-real-token")); !errors.Is(err, ErrNoSession) { t.Fatalf("forged token err=%v, want ErrNoSession", err) } } // The table stores a hash, so a database dump yields no usable session. func TestSessionTokenIsNotStored(t *testing.T) { sessions, _, database := newStores(t) token, err := sessions.Create(db.LocalUserID, "") if err != nil { t.Fatalf("create: %v", err) } var stored string if err := database.QueryRow(`SELECT id FROM sessions`).Scan(&stored); err != nil { t.Fatal(err) } if stored == token || strings.Contains(stored, token) { t.Fatal("the raw session token is stored in the database") } if stored != hashToken(token) { t.Fatal("stored id is not the token's hash") } } func TestSessionExpiry(t *testing.T) { sessions, _, database := newStores(t) token, err := sessions.Create(db.LocalUserID, "") if err != nil { t.Fatalf("create: %v", err) } if _, err := database.Exec( `UPDATE sessions SET expires_at = datetime('now','-1 minute')`, ); err != nil { t.Fatal(err) } if _, err := sessions.Resolve(withCookie(token)); !errors.Is(err, ErrNoSession) { t.Fatalf("expired session still resolves (err=%v)", err) } n, err := sessions.Prune() if err != nil { t.Fatalf("prune: %v", err) } if n != 1 { t.Fatalf("pruned %d rows, want 1", n) } } // The window slides: using a session pushes its expiry back out, so someone who // writes in Petal every few days is never signed out mid-draft. func TestSessionSlidesForward(t *testing.T) { sessions, _, database := newStores(t) token, err := sessions.Create(db.LocalUserID, "") if err != nil { t.Fatalf("create: %v", err) } // Pretend the session has been idle for a fortnight. if _, err := database.Exec( `UPDATE sessions SET expires_at = datetime('now','+16 days')`, ); err != nil { t.Fatal(err) } if _, err := sessions.Resolve(withCookie(token)); err != nil { t.Fatalf("resolve: %v", err) } var extended bool if err := database.QueryRow( `SELECT expires_at > datetime('now','+29 days') FROM sessions`, ).Scan(&extended); err != nil { t.Fatal(err) } if !extended { t.Fatal("using a session did not extend it") } } // Two live sessions must each resolve to their own writer — the whole point. func TestSessionsAreNotInterchangeable(t *testing.T) { sessions, _, _ := newStores(t) aliceToken, err := sessions.Create(db.LocalUserID, "") if err != nil { t.Fatal(err) } bobToken, err := sessions.Create("bob", "") if err != nil { t.Fatal(err) } for token, want := range map[string]string{aliceToken: db.LocalUserID, bobToken: "bob"} { got, err := sessions.Resolve(withCookie(token)) if err != nil { t.Fatalf("resolve: %v", err) } if got != want { t.Fatalf("token resolved to %q, want %q", got, want) } } // Revoking one session leaves the other alone. if err := sessions.Revoke(aliceToken); err != nil { t.Fatal(err) } if _, err := sessions.Resolve(withCookie(bobToken)); err != nil { t.Fatalf("bob was signed out by alice's logout: %v", err) } // RevokeAll signs one writer out everywhere and nobody else. second, _ := sessions.Create("bob", "phone") if err := sessions.RevokeAll("bob"); err != nil { t.Fatal(err) } for _, token := range []string{bobToken, second} { if _, err := sessions.Resolve(withCookie(token)); !errors.Is(err, ErrNoSession) { t.Fatalf("RevokeAll left a session alive (err=%v)", err) } } } // The session store is itself the Resolver the API middleware runs on, so a // valid cookie must carry all the way through to the handler. func TestSessionStoreDrivesMiddleware(t *testing.T) { sessions, _, _ := newStores(t) token, err := sessions.Create("bob", "") if err != nil { t.Fatal(err) } var seen string h := Middleware(sessions)(http.HandlerFunc( func(_ http.ResponseWriter, r *http.Request) { seen = UserID(r.Context()) }, )) rec := httptest.NewRecorder() h.ServeHTTP(rec, withCookie(token)) if rec.Code != http.StatusOK || seen != "bob" { t.Fatalf("status=%d user=%q, want 200/bob", rec.Code, seen) } rec2 := httptest.NewRecorder() h.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/", nil)) if rec2.Code != http.StatusUnauthorized { t.Fatalf("status=%d for a cookieless request, want 401", rec2.Code) } } func TestUserUpsert(t *testing.T) { _, users, database := newStores(t) if err := users.Upsert("sub-123", "her@example.com", "Her Name"); err != nil { t.Fatalf("upsert: %v", err) } user, err := users.Get("sub-123") if err != nil { t.Fatalf("get: %v", err) } if user.Email != "her@example.com" || user.DisplayName != "Her Name" { t.Fatalf("unexpected user %+v", user) } if user.PairLang != "zh" { t.Fatalf("pair_lang = %q, want the zh default", user.PairLang) } // A rename upstream is reflected here; Petal's own settings are not touched. if _, err := database.Exec(`UPDATE users SET pair_lang = 'pt-PT' WHERE id = 'sub-123'`); err != nil { t.Fatal(err) } if err := users.Upsert("sub-123", "new@example.com", "New Name"); err != nil { t.Fatalf("second upsert: %v", err) } user, _ = users.Get("sub-123") if user.Email != "new@example.com" || user.DisplayName != "New Name" { t.Fatalf("login did not refresh the profile: %+v", user) } if user.PairLang != "pt-PT" { t.Fatalf("login reset pair_lang to %q", user.PairLang) } // Falling back to the email keeps the sidebar from showing an empty name. if err := users.Upsert("sub-456", "them@example.com", ""); err != nil { t.Fatal(err) } if u, _ := users.Get("sub-456"); u.DisplayName != "them@example.com" { t.Fatalf("display name = %q, want the email fallback", u.DisplayName) } if err := users.Upsert("", "nobody@example.com", "Nobody"); err == nil { t.Fatal("a login with no subject was accepted") } } // Sessions belong to their account: deleting a user takes their logins with it. func TestSessionsCascadeWithUser(t *testing.T) { sessions, _, database := newStores(t) token, err := sessions.Create("bob", "") if err != nil { t.Fatal(err) } if _, err := database.Exec(`DELETE FROM users WHERE id = 'bob'`); err != nil { t.Fatal(err) } if _, err := sessions.Resolve(withCookie(token)); !errors.Is(err, ErrNoSession) { t.Fatalf("session outlived its account (err=%v)", err) } } func TestAllowlist(t *testing.T) { // No list configured = anyone the IdP authenticates, which is the right // default for a household instance. if !ParseAllowlist("").Permits("anyone", "anyone@example.com") { t.Fatal("an empty allowlist turned someone away") } if !ParseAllowlist(" ").Permits("anyone", "anyone@example.com") { t.Fatal("a whitespace-only allowlist turned someone away") } list := ParseAllowlist(" sub-123 , Her@Example.com ,, ") cases := []struct { sub, email string want bool }{ {"sub-123", "someone@example.com", true}, // by subject {"sub-999", "her@example.com", true}, // by email {"sub-999", "HER@EXAMPLE.COM", true}, // case-insensitively {"sub-999", "stranger@example.com", false}, // neither {"", "", false}, // no claims at all {"sub-1", "", false}, // a near-miss subject } for _, c := range cases { if got := list.Permits(c.sub, c.email); got != c.want { t.Fatalf("Permits(%q, %q) = %v, want %v", c.sub, c.email, got, c.want) } } } // Over https the cookie takes the __Host- prefix, which the browser will only // accept from the exact host that set it — closing the door on a sibling // service under the same registrable domain planting a session in her browser. // Over plain http it cannot: the prefix requires Secure, and a browser drops a // Secure cookie on an insecure origin, so local development would silently stop // logging in. func TestSessionCookieNamePerScheme(t *testing.T) { secure := httptest.NewRecorder() SetSessionCookie(secure, "tok", true) c := secure.Result().Cookies()[0] if c.Name != HostSessionCookie { t.Fatalf("https cookie name=%q, want %q", c.Name, HostSessionCookie) } // The prefix is a promise about these three attributes; a browser rejects // the cookie outright if any is wrong. if !c.Secure || c.Path != "/" || c.Domain != "" { t.Fatalf("__Host- cookie violates its own contract: %+v", c) } insecure := httptest.NewRecorder() SetSessionCookie(insecure, "tok", false) if name := insecure.Result().Cookies()[0].Name; name != SessionCookie { t.Fatalf("http cookie name=%q, want %q", name, SessionCookie) } } // A browser holding a cookie issued before the prefix existed must stay signed // in — and start using the new name at its next sign-in, not be logged out to // get there. func TestResolveAcceptsEitherCookieName(t *testing.T) { store, _, _ := newStores(t) token, err := store.Create("bob", "test-agent") if err != nil { t.Fatal(err) } for _, name := range []string{SessionCookie, HostSessionCookie} { r := httptest.NewRequest(http.MethodGet, "/", nil) r.AddCookie(&http.Cookie{Name: name, Value: token}) got, err := store.Resolve(r) if err != nil || got != "bob" { t.Fatalf("%s: resolved to %q (err=%v)", name, got, err) } } } // Signing out must not leave the browser holding either spelling. func TestClearSessionCookieExpiresBothNames(t *testing.T) { rec := httptest.NewRecorder() ClearSessionCookie(rec, true) cleared := map[string]bool{} for _, c := range rec.Result().Cookies() { if c.MaxAge < 0 { cleared[c.Name] = true } } for _, name := range []string{SessionCookie, HostSessionCookie} { if !cleared[name] { t.Errorf("%s was left in the browser after signing out", name) } } }