package oltp
import (
"path/filepath"
"testing"
)
const cutoverPW = "a-long-enough-password"
// cutoverRig is a store as the cutover finds it: a person, and agents
// that still hold a password from before marking cleared one — a
// person's agent with a native verifier, a forge agent, and a person's
// agent with only an imported fossil credential. The operator columns
// are written by hand, the way a pre-rung-3 mark left them.
func cutoverRig(t *testing.T, path string) *DB {
t.Helper()
db, err := Open(path)
if err != nil {
t.Fatal(err)
}
db.SyncProjectCode = func(string) (string, bool) { return "code", true }
for _, who := range []string{"dan", "dan-bot", "zeit"} {
if err := db.AddAccount(who, cutoverPW, false); err != nil {
t.Fatal(err)
}
}
if err := db.AddRepo("pub", t.TempDir(), "", Public); err != nil {
t.Fatal(err)
}
if _, _, err := db.ImportUsers("pub", "code", []ImportedUser{
{Login: "old-bot", Caps: "oi", PWHash: legacySecret("code", "old-bot", cutoverPW)},
}); err != nil {
t.Fatal(err)
}
for _, who := range []string{"dan", "dan-bot", "zeit"} {
if err := db.SetCaps(who, "pub", "oi"); err != nil {
t.Fatal(err)
}
if err := db.SetSyncUser("pub", who, legacySecret("code", who, cutoverPW)); err != nil {
t.Fatal(err)
}
}
return db
}
func markByHand(t *testing.T, db *DB, name, operator string) {
t.Helper()
if _, err := db.w.Exec(`UPDATE account SET operator = ? WHERE name = ?`, operator, name); err != nil {
t.Fatal(err)
}
}
// Every password door is Authenticate or Secrets: an agent's password
// is refused at both even while one is still stored, and its tokens
// are not. A person's password is untouched.
func TestAgentPasswordRefusedAtEveryDoor(t *testing.T) {
db := cutoverRig(t, filepath.Join(t.TempDir(), "oltp.db"))
defer db.Close()
reset, err := db.ResetPassword("dan-bot")
if err != nil {
t.Fatal(err)
}
botSession, _, ok := db.NewSession("dan-bot", cutoverPW)
if !ok {
t.Fatal("no session before the mark")
}
danSession, _, ok := db.NewSession("dan", cutoverPW)
if !ok {
t.Fatal("no session for the person")
}
markByHand(t, db, "dan-bot", "dan")
markByHand(t, db, "old-bot", "dan")
markByHand(t, db, "zeit", forgeOperator)
for _, who := range []string{"dan-bot", "old-bot", "zeit"} {
if _, ok := db.Authenticate(who, cutoverPW); ok {
t.Errorf("FAIL: %s signed in with a stored password", who)
}
if _, _, ok := db.NewSessionFrom(who, cutoverPW, "", ""); ok {
t.Errorf("FAIL: %s opened a web session with a password", who)
}
if err := db.SetPasswordJournalled(who, cutoverPW, "host", "account passwd"); !IsRefusal(err) {
t.Errorf("FAIL: the host recovery gave %s a password: %v", who, err)
}
}
if _, _, ok := db.LegacyCredential("old-bot"); !ok {
t.Error("FAIL: the legacy credential was spent by a refused sign-in")
}
if _, err := db.RedeemInvite(reset, cutoverPW); err == nil {
t.Error("FAIL: a reset minted before marking set the agent's password")
}
if _, ok := db.Authenticate("dan", cutoverPW); !ok {
t.Error("FAIL: a person's password was refused")
}
// A session no token opened is a password's, and an agent's is
// refused even where the row survived.
if _, _, _, ok := db.SessionPrincipal(botSession); ok {
t.Error("FAIL: an agent's password session still resolves")
}
if _, _, _, ok := db.SessionPrincipal(danSession); !ok {
t.Error("FAIL: a person's session was refused")
}
// The login card: the hand-issued row is a password's secret and
// goes unanswered for an agent; its sync token signs.
if creds := db.Secrets("pub", "dan-bot"); len(creds) != 0 {
t.Errorf("FAIL: an agent's hand-issued row still signs: %d credentials", len(creds))
}
if _, _, err := db.MintToken("dan-bot", TokenSpec{Name: "push", Scopes: []string{ScopeSync}}); err != nil {
t.Fatal(err)
}
creds := db.Secrets("pub", "dan-bot")
if len(creds) != 1 || creds[0].Token == "" {
t.Errorf("FAIL: an agent's login card credentials = %+v, want its one sync token", creds)
}
if creds := db.Secrets("pub", "dan"); len(creds) != 1 || creds[0].Token != "" {
t.Errorf("FAIL: a person's hand-issued row = %+v, want it answered", creds)
}
if !t.Failed() {
t.Log("ok: password refused for both kinds of agent at sign-in, session, recovery, reset and login card; the person's still works")
}
}
// The cutover migration clears every agent's stored password, native
// and imported, and the sessions it opened; a token-bound session and
// the tokens themselves survive, and so does the person.
func TestCutoverRetiresAgentPasswords(t *testing.T) {
path := filepath.Join(t.TempDir(), "oltp.db")
db := cutoverRig(t, path)
byPassword, _, ok := db.NewSession("dan-bot", cutoverPW)
if !ok {
t.Fatal("no session before the mark")
}
markByHand(t, db, "dan-bot", "dan")
markByHand(t, db, "old-bot", "dan")
markByHand(t, db, "zeit", forgeOperator)
value, tok, err := db.MintToken("dan-bot", TokenSpec{Name: "web", Scopes: []string{ScopeAPI, ScopeWeb}})
if err != nil {
t.Fatal(err)
}
link, _, err := db.MintSigninLink("dan-bot", tok.ID, "")
if err != nil {
t.Fatal(err)
}
byToken, _, _, ok := db.RedeemSigninLink(link, "", "")
if !ok {
t.Fatal("the sign-in link did not open a session")
}
db.Close()
db, err = Open(path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
var left int
if err := db.db.QueryRow(`SELECT count(*) FROM account WHERE operator <> ''
AND (pwhash <> '' OR legacy_pw <> '' OR legacy_code <> '')`).Scan(&left); err != nil {
t.Fatal(err)
}
if left != 0 {
t.Errorf("FAIL: %d agents still hold a password after the cutover", left)
}
if _, _, _, ok := db.SessionPrincipal(byPassword); ok {
t.Error("FAIL: the agent's password session outlived the cutover")
}
if _, _, bound, ok := db.SessionPrincipal(byToken); !ok || !bound {
t.Error("FAIL: the agent's token-bound session was ended by the cutover")
}
if _, ok := db.ResolveToken("dan-bot", value, ScopeAPI); !ok {
t.Error("FAIL: the agent's token stopped resolving")
}
var hash string
if err := db.db.QueryRow(`SELECT pwhash FROM account WHERE name = 'dan'`).Scan(&hash); err != nil || hash == "" {
t.Errorf("FAIL: the person's password went with the agents' (%v)", err)
}
if _, ok := db.Authenticate("dan", cutoverPW); !ok {
t.Error("FAIL: a person's password was refused after the cutover")
}
if rows, _ := db.LegacySyncUsers("dan-bot"); len(rows) != 0 {
t.Errorf("FAIL: the agent's hand-issued push rows survived the cutover: %v", rows)
}
if rows, _ := db.LegacySyncUsers("dan"); len(rows) != 1 {
t.Errorf("FAIL: the person's hand-issued push row = %v, want it kept", rows)
}
// Clearing the mark leaves a person with nothing to sign with until
// an invite is redeemed: the old secret does not come back.
if err := db.SetOperator("dan-bot", ""); err != nil {
t.Fatal(err)
}
if creds := db.Secrets("pub", "dan-bot"); len(creds) != 0 {
t.Errorf("FAIL: the unmarked account signs with %d old credentials", len(creds))
}
if !t.Failed() {
t.Log("ok: agents' passwords, push rows and password sessions retired; token, token session and person intact")
}
}
// Marking, as a person's agent or the forge's, drops the account's
// hand-issued push rows in the same act as its password.
func TestMarkingDropsHandIssuedPushRows(t *testing.T) {
db := testDB(t)
if err := db.AddAccount("root", cutoverPW, true); err != nil {
t.Fatal(err)
}
for _, who := range []string{"dan", "dan-bot", "zeit"} {
if err := db.AddAccount(who, cutoverPW, false); err != nil {
t.Fatal(err)
}
if err := db.SetSyncUser("pub", who, legacySecret("code", who, cutoverPW)); err != nil {
t.Fatal(err)
}
}
if err := db.SetOperator("dan-bot", "dan"); err != nil {
t.Fatal(err)
}
if err := db.SetForgeAgent("zeit", "root"); err != nil {
t.Fatal(err)
}
for who, want := range map[string]int{"dan-bot": 0, "zeit": 0, "dan": 1} {
if rows, _ := db.LegacySyncUsers(who); len(rows) != want {
t.Errorf("FAIL: %s holds %d hand-issued push rows, want %d", who, len(rows), want)
}
}
if !t.Failed() {
t.Log("ok: marking drops the agent's push rows and leaves the person's")
}
}