package oltp
import (
"testing"
"time"
)
func signinDB(t *testing.T, scopes ...string) (*DB, string, Token) {
t.Helper()
db := tokenDB(t, "pub")
if err := db.AddAccount("bot", "irrelevant to signing in", false); err != nil {
t.Fatal(err)
}
_, tok, err := db.MintToken("bot", TokenSpec{Name: "browser", Scopes: scopes, Ceiling: "o"})
if err != nil {
t.Fatal(err)
}
return db, "bot", tok
}
func sessionRowOf(t *testing.T, db *DB, cookie string) (tokenID, expires string, n int) {
t.Helper()
db.db.QueryRow(`SELECT count(*) FROM session WHERE token = ?`, hashToken(cookie)).Scan(&n)
db.db.QueryRow(`SELECT token_id, expires FROM session WHERE token = ?`, hashToken(cookie)).Scan(&tokenID, &expires)
return
}
// A LINK OPENS ONE SESSION BOUND TO ITS TOKEN, AND A SECOND OPEN IS SPENT.
func TestSigninLinkOpensOneBoundSession(t *testing.T) {
db, acct, tok := signinDB(t, ScopeWeb)
link, _, err := db.MintSigninLink(acct, tok.ID, "/r/pub")
if err != nil {
t.Fatal(err)
}
var stored string
db.db.QueryRow(`SELECT token FROM signin_link`).Scan(&stored)
if stored != hashToken(link) || stored == link {
t.Errorf("FAIL: the link is stored as %q; want only its hash", stored)
}
cookie, expires, next, ok := db.RedeemSigninLink(link, "10.0.0.1", "agent-browser")
if !ok || next != "/r/pub" {
t.Fatalf("FAIL: first open ok=%v next=%q; want a session and /r/pub", ok, next)
}
id, _, n := sessionRowOf(t, db, cookie)
if n != 1 || id != tok.ID {
t.Errorf("FAIL: session rows=%d token_id=%q; want one bound to %q", n, id, tok.ID)
}
if life := time.Until(expires); life > TokenSessionTTL || life < TokenSessionTTL-time.Minute {
t.Errorf("FAIL: session lasts %v; want about %v", life, TokenSessionTTL)
}
if a, ok := db.SessionAccount(cookie); !ok || a.Name != acct {
t.Errorf("FAIL: the cookie resolves to %q ok=%v; want %q", a.Name, ok, acct)
}
_, bound, isBound, ok := db.SessionPrincipal(cookie)
if !ok || !isBound || bound.ID != tok.ID || bound.Ceiling != "o" {
t.Errorf("FAIL: SessionPrincipal = %+v bound=%v ok=%v; want the minting token and its ceiling", bound, isBound, ok)
}
if _, _, _, ok := db.RedeemSigninLink(link, "", ""); ok {
t.Error("FAIL: a second open of the same link opened a session")
}
var sessions int
db.db.QueryRow(`SELECT count(*) FROM session WHERE token_id = ?`, tok.ID).Scan(&sessions)
if sessions != 1 {
t.Errorf("FAIL: %d sessions after two opens; want 1", sessions)
}
}
// A TOKEN WITHOUT WEB SCOPE CANNOT MINT.
func TestSigninLinkNeedsWebScope(t *testing.T) {
db, acct, tok := signinDB(t, ScopeAPI)
if _, _, err := db.MintSigninLink(acct, tok.ID, ""); err == nil {
t.Error("FAIL: an api-only token minted a sign-in link")
}
if _, _, err := db.MintSigninLink(acct, "nosuchid", ""); err == nil {
t.Error("FAIL: an unknown token minted a sign-in link")
}
var n int
db.db.QueryRow(`SELECT count(*) FROM signin_link`).Scan(&n)
if n != 0 {
t.Errorf("FAIL: %d link rows after refusals; want 0", n)
}
}
// A LINK PAST TWO MINUTES IS REFUSED AND NO SESSION IS OPENED.
func TestSigninLinkLapses(t *testing.T) {
db, acct, tok := signinDB(t, ScopeWeb)
link, _, err := db.MintSigninLink(acct, tok.ID, "")
if err != nil {
t.Fatal(err)
}
late := time.Now().UTC().Add(-time.Second).Format(time.RFC3339)
if _, err := db.w.Exec(`UPDATE signin_link SET expires = ?`, late); err != nil {
t.Fatal(err)
}
if _, _, _, ok := db.RedeemSigninLink(link, "", ""); ok {
t.Error("FAIL: a lapsed link opened a session")
}
if _, _, _, ok := db.RedeemSigninLink("not a link", "", ""); ok {
t.Error("FAIL: an unknown link opened a session")
}
}
// REVOKING THE TOKEN ENDS ITS SESSIONS IN THE SAME TRANSACTION, AND A
// LINK ALREADY MINTED FOR IT IS SPENT BUT OPENS NOTHING.
func TestRevokingTheTokenEndsItsSessions(t *testing.T) {
db, acct, tok := signinDB(t, ScopeWeb)
link, _, _ := db.MintSigninLink(acct, tok.ID, "")
cookie, _, _, ok := db.RedeemSigninLink(link, "", "")
if !ok {
t.Fatal("FAIL: could not open a session")
}
pending, _, _ := db.MintSigninLink(acct, tok.ID, "")
if err := db.RevokeToken(acct, tok.ID); err != nil {
t.Fatal(err)
}
if _, _, n := sessionRowOf(t, db, cookie); n != 0 {
t.Errorf("FAIL: %d session rows survive the revoke; want 0", n)
}
if _, ok := db.SessionAccount(cookie); ok {
t.Error("FAIL: the cookie of a revoked token's session still resolves")
}
if _, _, _, ok := db.RedeemSigninLink(pending, "", ""); ok {
t.Error("FAIL: a link minted before the revoke opened a session")
}
var used string
db.db.QueryRow(`SELECT used FROM signin_link WHERE token = ?`, hashToken(pending)).Scan(&used)
if used == "" {
t.Error("FAIL: the refused link was not spent")
}
}
// A SESSION NEVER OUTLIVES ITS TOKEN, EVEN WHEN THE TOKEN MERELY LAPSES.
func TestTokenSessionEndsWhenTheTokenExpires(t *testing.T) {
db, acct, tok := signinDB(t, ScopeWeb)
link, _, _ := db.MintSigninLink(acct, tok.ID, "")
cookie, _, _, _ := db.RedeemSigninLink(link, "", "")
if _, err := db.w.Exec(`UPDATE token SET expires = ? WHERE id = ?`,
time.Now().UTC().Add(-time.Second).Format(time.RFC3339), tok.ID); err != nil {
t.Fatal(err)
}
if _, ok := db.SessionAccount(cookie); ok {
t.Error("FAIL: a session outlived its expired token")
}
}
// A SIGN-IN LINK IS UNKNOWN TO THE INVITE PATH: IT CANNOT SET A PASSWORD.
func TestSigninLinkIsNotAnInvite(t *testing.T) {
db, acct, tok := signinDB(t, ScopeWeb)
link, _, _ := db.MintSigninLink(acct, tok.ID, "")
if _, ok := db.InviteFor(link); ok {
t.Error("FAIL: a sign-in link is a live invite")
}
if _, err := db.RedeemInvite(link, "a password of eight"); err == nil {
t.Error("FAIL: a sign-in link redeemed as an invite")
}
}
// A SESSION OPENED BY A PERSON IS BOUND TO NO TOKEN.
func TestPasswordSessionHasNoToken(t *testing.T) {
db := tokenDB(t)
if err := db.AddAccount("pat", "a long password", false); err != nil {
t.Fatal(err)
}
cookie, _, ok := db.NewSession("pat", "a long password")
if !ok {
t.Fatal("FAIL: could not sign in")
}
if _, _, bound, ok := db.SessionPrincipal(cookie); !ok || bound {
t.Error("FAIL: a password session is unbound and good")
}
}
// A BOUND SESSION WHOSE TOKEN CANNOT BE FOUND IS REFUSED AND ENDED, NEVER
// READ AS A PASSWORD SESSION.
func TestBoundSessionWithoutItsTokenIsRefused(t *testing.T) {
db, acct, tok := signinDB(t, ScopeWeb)
link, _, _ := db.MintSigninLink(acct, tok.ID, "")
cookie, _, _, _ := db.RedeemSigninLink(link, "", "")
// The token loses web scope between the two reads a door makes.
if _, err := db.w.Exec(`UPDATE token SET scope = 'api' WHERE id = ?`, tok.ID); err != nil {
t.Fatal(err)
}
if _, _, bound, ok := db.SessionPrincipal(cookie); ok || bound {
t.Errorf("FAIL: ok=%v bound=%v; want the session refused", ok, bound)
}
if _, ok := db.SessionAccount(cookie); ok {
t.Error("FAIL: the session still resolves as an account's")
}
}
// A FAILED READ OF THE TOKEN REFUSES THE SESSION BUT DOES NOT DELETE IT.
func TestTokenReadErrorRefusesWithoutDeleting(t *testing.T) {
db, acct, tok := signinDB(t, ScopeWeb)
link, _, _ := db.MintSigninLink(acct, tok.ID, "")
cookie, _, _, _ := db.RedeemSigninLink(link, "", "")
if _, err := db.w.Exec(`ALTER TABLE token RENAME TO token_away`); err != nil {
t.Fatal(err)
}
if _, ok := db.SessionAccount(cookie); ok {
t.Error("FAIL: a session resolved while its token could not be read")
}
if _, _, n := sessionRowOf(t, db, cookie); n != 1 {
t.Errorf("FAIL: %d session rows after a read error; want the row kept", n)
}
}
// NEXT IS A PATH ON THIS FORGE: BACKSLASH AND CONTROL TRICKS ARE REFUSED
// AT THE MINT.
func TestSigninLinkNextMustBeLocal(t *testing.T) {
db, acct, tok := signinDB(t, ScopeWeb)
for _, next := range []string{"/\\evil.com", "/\t/evil.com", "//evil.com", "https://evil.com", "evil", "/a\nb"} {
if _, _, err := db.MintSigninLink(acct, tok.ID, next); err == nil {
t.Errorf("FAIL: next %q was accepted", next)
}
}
if _, _, err := db.MintSigninLink(acct, tok.ID, "/r/quarry?x=1"); err != nil {
t.Errorf("FAIL: a local next was refused: %v", err)
}
}
// A READ ERROR WHILE FETCHING THE BOUND TOKEN REFUSES WITHOUT ENDING; A
// TOKEN KNOWN GONE ENDS THE SESSION.
func TestPrincipalTokenEndsOnlyWhenKnownGone(t *testing.T) {
db, acct, tok := signinDB(t, ScopeWeb)
if _, ended, ok := db.principalToken(acct, "nosuchid"); ok || !ended {
t.Errorf("FAIL: a missing token ended=%v ok=%v; want ended", ended, ok)
}
if _, ended, ok := db.principalToken(acct, tok.ID); !ok || ended {
t.Fatalf("FAIL: a live token ended=%v ok=%v", ended, ok)
}
if _, err := db.w.Exec(`ALTER TABLE token RENAME TO token_away`); err != nil {
t.Fatal(err)
}
if _, ended, ok := db.principalToken(acct, tok.ID); ok || ended {
t.Errorf("FAIL: a read error ended=%v ok=%v; want refused and not ended", ended, ok)
}
}