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, ok := db.SessionToken(cookie)
if !ok || bound.ID != tok.ID || bound.Ceiling != "o" {
t.Errorf("FAIL: SessionToken = %+v ok=%v; want the minting token and its ceiling", bound, 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 _, ok := db.SessionToken(cookie); ok {
t.Error("FAIL: a password session reports a token")
}
}