package oltp
import (
"path/filepath"
"testing"
"time"
)
func testDB(t *testing.T) *DB {
t.Helper()
db, err := Open(filepath.Join(t.TempDir(), "oltp.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestPasswordRoundTrip(t *testing.T) {
h, err := HashPassword("correct horse battery staple")
if err != nil {
t.Fatal(err)
}
if !VerifyPassword(h, "correct horse battery staple") {
t.Fatal("correct password rejected")
}
if VerifyPassword(h, "wrong") {
t.Fatal("wrong password accepted")
}
// Salted: the same password twice must not produce the same verifier,
// or the store leaks which accounts share a password.
h2, _ := HashPassword("correct horse battery staple")
if h == h2 {
t.Fatal("verifier is not salted")
}
}
func TestVerifyRejectsMalformed(t *testing.T) {
for _, bad := range []string{
"", "not-a-hash", "$argon2id$", "$argon2i$v=19$m=1,t=1,p=1$AAAA$AAAA",
"$argon2id$v=99$m=65536,t=1,p=4$AAAA$AAAA",
"$argon2id$v=19$m=65536,t=1,p=4$!!!$AAAA",
} {
if VerifyPassword(bad, "anything") {
t.Errorf("accepted malformed verifier %q", bad)
}
}
}
func TestAccountLifecycle(t *testing.T) {
db := testDB(t)
// The gate is open until the first account exists — local single-user
// serving keeps working, exactly as sync users do.
if db.HasAccounts() {
t.Fatal("fresh database reports accounts")
}
if err := db.AddAccount("dan", "hunter2", false); err != nil {
t.Fatal(err)
}
if !db.HasAccounts() {
t.Fatal("account added but gate still open")
}
if err := db.AddAccount("dan", "other", false); err == nil {
t.Fatal("duplicate account name accepted — that is an account takeover")
}
if _, ok := db.Authenticate("dan", "hunter2"); !ok {
t.Fatal("correct credentials rejected")
}
if _, ok := db.Authenticate("dan", "wrong"); ok {
t.Fatal("wrong password accepted")
}
if _, ok := db.Authenticate("nobody", "hunter2"); ok {
t.Fatal("nonexistent account authenticated")
}
if err := db.SetPassword("dan", "hunter3"); err != nil {
t.Fatal(err)
}
if _, ok := db.Authenticate("dan", "hunter2"); ok {
t.Fatal("old password still works after change")
}
if _, ok := db.Authenticate("dan", "hunter3"); !ok {
t.Fatal("new password rejected")
}
if err := db.SetPassword("ghost", "x"); err == nil {
t.Fatal("set password on a nonexistent account")
}
}
func TestRolesAreOrdered(t *testing.T) {
db := testDB(t)
if err := db.AddAccount("dan", "pw", false); err != nil {
t.Fatal(err)
}
acct, _ := db.Authenticate("dan", "pw")
if got := db.RoleOf(acct, "quarry"); got != NoRole {
t.Fatalf("ungranted repo gives %s", got)
}
if err := db.Grant("dan", "quarry", Write); err != nil {
t.Fatal(err)
}
if got := db.RoleOf(acct, "quarry"); got != Write {
t.Fatalf("role = %s, want write", got)
}
// A grant on one repo says nothing about another.
if got := db.RoleOf(acct, "other"); got != NoRole {
t.Fatalf("grant leaked to another repo: %s", got)
}
// Ordering is what authorization compares against.
if !(NoRole < Read && Read < Write && Write < RepoAdmin) {
t.Fatal("roles are not ordered")
}
if err := db.Revoke("dan", "quarry"); err != nil {
t.Fatal(err)
}
if got := db.RoleOf(acct, "quarry"); got != NoRole {
t.Fatalf("revoked role still %s", got)
}
if err := db.Grant("ghost", "quarry", Write); err == nil {
t.Fatal("granted a role to a nonexistent account")
}
}
func TestSiteAdminHoldsEveryRepo(t *testing.T) {
db := testDB(t)
if err := db.AddAccount("root", "pw", true); err != nil {
t.Fatal(err)
}
acct, ok := db.Authenticate("root", "pw")
if !ok || !acct.Admin {
t.Fatalf("admin flag lost: %+v", acct)
}
// No explicit grant anywhere, yet admin everywhere — that is what
// "site admin" means, and it must not need per-repo rows.
if got := db.RoleOf(acct, "never-granted"); got != RepoAdmin {
t.Fatalf("site admin has %s on an ungranted repo", got)
}
}
func TestDeleteAccountDropsItsRoles(t *testing.T) {
db := testDB(t)
if err := db.AddAccount("temp", "pw", false); err != nil {
t.Fatal(err)
}
if err := db.Grant("temp", "quarry", Write); err != nil {
t.Fatal(err)
}
if err := db.DeleteAccount("temp"); err != nil {
t.Fatal(err)
}
if _, ok := db.Authenticate("temp", "pw"); ok {
t.Fatal("deleted account still authenticates")
}
// A later account reusing the name must not inherit the old grants.
if err := db.AddAccount("temp", "pw", false); err != nil {
t.Fatal(err)
}
acct, _ := db.Authenticate("temp", "pw")
if got := db.RoleOf(acct, "quarry"); got != NoRole {
t.Fatalf("recreated account inherited %s", got)
}
}
func TestGrantsForListsRepos(t *testing.T) {
db := testDB(t)
if err := db.AddAccount("dan", "pw", false); err != nil {
t.Fatal(err)
}
for repo, role := range map[string]Role{"a": Read, "b": Write, "c": RepoAdmin} {
if err := db.Grant("dan", repo, role); err != nil {
t.Fatal(err)
}
}
got, err := db.GrantsFor("dan")
if err != nil {
t.Fatal(err)
}
if len(got) != 3 || got["a"] != Read || got["b"] != Write || got["c"] != RepoAdmin {
t.Fatalf("grants = %v", got)
}
if repos := SortedRepos(got); repos[0] != "a" || repos[2] != "c" {
t.Fatalf("SortedRepos = %v", repos)
}
}
func TestSessionLifecycle(t *testing.T) {
db := testDB(t)
if err := db.AddAccount("dan", "hunter2", false); err != nil {
t.Fatal(err)
}
token, expires, ok := db.NewSession("dan", "hunter2")
if !ok {
t.Fatal("login with correct credentials failed")
}
if expires.Before(time.Now()) {
t.Fatalf("session already expired: %v", expires)
}
acct, ok := db.SessionAccount(token)
if !ok || acct.Name != "dan" {
t.Fatalf("resolve = %+v %v", acct, ok)
}
if _, _, ok := db.NewSession("dan", "wrong"); ok {
t.Fatal("login succeeded with the wrong password")
}
if _, ok := db.SessionAccount("not-a-token"); ok {
t.Fatal("an invented token resolved")
}
db.EndSession(token)
if _, ok := db.SessionAccount(token); ok {
t.Fatal("session survived logout")
}
}
// The cookie value must not be recoverable from the database. A session
// table that reads back into working cookies turns a backup or a stray
// SELECT into a set of live logins.
func TestSessionTokenIsStoredHashed(t *testing.T) {
db := testDB(t)
if err := db.AddAccount("dan", "pw", false); err != nil {
t.Fatal(err)
}
token, _, ok := db.NewSession("dan", "pw")
if !ok {
t.Fatal("login failed")
}
var stored string
if err := db.db.QueryRow(`SELECT token FROM session`).Scan(&stored); err != nil {
t.Fatal(err)
}
if stored == token {
t.Fatal("the raw cookie value is in the database")
}
if _, ok := db.SessionAccount(stored); ok {
t.Fatal("the stored digest works as a cookie")
}
}
// Changing a password is how somebody responds to a credential they
// believe is compromised; it has to invalidate the browser already
// holding a session, or the response does nothing.
func TestPasswordChangeEndsSessions(t *testing.T) {
db := testDB(t)
if err := db.AddAccount("dan", "old", false); err != nil {
t.Fatal(err)
}
token, _, _ := db.NewSession("dan", "old")
if _, ok := db.SessionAccount(token); !ok {
t.Fatal("session not established")
}
if err := db.SetPassword("dan", "new"); err != nil {
t.Fatal(err)
}
if _, ok := db.SessionAccount(token); ok {
t.Fatal("session survived a password change")
}
}
// VerifyPassword is Authenticate's verdict without its session —
// exactly the "is this really your current password" check a
// self-service change needs.
func TestVerifyPassword(t *testing.T) {
db := testDB(t)
if err := db.AddAccount("dan", "hunter2", false); err != nil {
t.Fatal(err)
}
if !db.VerifyPassword("dan", "hunter2") {
t.Fatal("the correct password did not verify")
}
if db.VerifyPassword("dan", "wrong") {
t.Fatal("the wrong password verified")
}
if db.VerifyPassword("nobody", "anything") {
t.Fatal("a nonexistent account verified")
}
}
// SetPasswordSelf ends every OTHER session but spares the one named by
// keep — the browser making the change stays signed in, unlike
// SetPassword's full sweep.
func TestSetPasswordSelfSparesTheNamedSession(t *testing.T) {
db := testDB(t)
if err := db.AddAccount("dan", "old", false); err != nil {
t.Fatal(err)
}
mine, _, _ := db.NewSession("dan", "old")
other, _, _ := db.NewSessionFrom("dan", "old", "", "")
if err := db.SetPasswordSelf("dan", "new", SessionID(mine)); err != nil {
t.Fatal(err)
}
if _, ok := db.SessionAccount(mine); !ok {
t.Fatal("the session making the change was ended")
}
if _, ok := db.SessionAccount(other); ok {
t.Fatal("another session survived a self-service password change")
}
if !db.VerifyPassword("dan", "new") {
t.Fatal("the new password does not verify")
}
}
// keep == "" behaves exactly like the admin-driven SetPassword: every
// session ends, including the one that asked.
func TestSetPasswordSelfWithNoKeepEndsEverything(t *testing.T) {
db := testDB(t)
if err := db.AddAccount("dan", "old", false); err != nil {
t.Fatal(err)
}
token, _, _ := db.NewSession("dan", "old")
if err := db.SetPasswordSelf("dan", "new", ""); err != nil {
t.Fatal(err)
}
if _, ok := db.SessionAccount(token); ok {
t.Fatal("a session survived keep=\"\"")
}
}
// A deleted account cannot keep browsing on a live cookie.
func TestDeletedAccountEndsSessions(t *testing.T) {
db := testDB(t)
if err := db.AddAccount("temp", "pw", false); err != nil {
t.Fatal(err)
}
token, _, _ := db.NewSession("temp", "pw")
if err := db.DeleteAccount("temp"); err != nil {
t.Fatal(err)
}
if _, ok := db.SessionAccount(token); ok {
t.Fatal("deleted account still resolves its session")
}
}
// RFC 0008: a role is a name for a string. Granting one writes the
// preset, reading it back finds the preset, and a hand-edited string
// reads as the nearest role or as none at all.
func TestGrantWritesThePresetString(t *testing.T) {
db := testDB(t)
if err := db.AddAccount("dan", "pw12345678", false); err != nil {
t.Fatal(err)
}
acct, _ := db.AccountByName("dan")
if err := db.Grant("dan", "quarry", Maintain); err != nil {
t.Fatal(err)
}
if caps, _ := db.CapsOf("dan", "quarry"); caps != Maintain.Caps() {
t.Fatalf("grant wrote %q, want the Maintainer string %q", caps, Maintain.Caps())
}
if got := db.RoleOf(acct, "quarry"); got != Maintain {
t.Fatalf("read back as %v", got)
}
if err := db.SetCaps("dan", "quarry", "uv3bBC7l"); err != nil { // Developer plus one
t.Fatal(err)
}
if got := db.RoleOf(acct, "quarry"); got != Write {
t.Fatalf("developer-plus-one read as %v", got)
}
if err := db.SetCaps("dan", "quarry", "iy"); err != nil { // a bot: no role names it
t.Fatal(err)
}
if got := db.RoleOf(acct, "quarry"); got != NoRole {
t.Fatalf("custom string read as %v", got)
}
if err := db.Revoke("dan", "quarry"); err != nil {
t.Fatal(err)
}
if got := db.RoleOf(acct, "quarry"); got != NoRole {
t.Fatalf("revoked but read as %v", got)
}
}
// MayAt is the only gate: signed in meets the token floor whatever the
// string, and the admin floor is the a letter — a Maintainer does not
// have it, an account holding s does.
func TestMayAtGatesOnLetters(t *testing.T) {
db := testDB(t)
for _, n := range []string{"m", "s", "none"} {
if err := db.AddAccount(n, "pw12345678", false); err != nil {
t.Fatal(err)
}
}
if err := db.Grant("m", "quarry", Maintain); err != nil {
t.Fatal(err)
}
if err := db.SetCaps("s", "quarry", "s"); err != nil {
t.Fatal(err)
}
m, _ := db.AccountByName("m")
s, _ := db.AccountByName("s")
none, _ := db.AccountByName("none")
if !db.MayAt(none, "quarry", false) || db.MayAt(none, "quarry", true) {
t.Fatal("no string: token yes, admin no")
}
if !db.MayAt(m, "quarry", false) || db.MayAt(m, "quarry", true) {
t.Fatal("maintainer: token yes, admin no")
}
if !db.MayAt(s, "quarry", true) {
t.Fatal("s implies a")
}
}
// Audit follow-up (ticket ed5bdd89c39a): logins are a closed alphabet.
func TestAccountNamesAreAClosedAlphabet(t *testing.T) {
db := testDB(t)
for _, ok := range []string{"dan", "ai-local", "a.b_c-9", "9lives"} {
if err := db.AddAccount(ok, "pw12345678", false); err != nil {
t.Fatalf("%q refused: %v", ok, err)
}
}
for _, bad := range []string{"a b", "a'b", `a"b`, "<x>", "-lead", ".lead", "naïve", "a/b"} {
if err := db.AddAccount(bad, "pw12345678", false); err == nil {
t.Fatalf("%q accepted", bad)
}
}
}