package main_test import ( "path/filepath" "strings" "testing" "time" "github.com/carlosframework/platform/internal/console" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/release" ) // `console member` is the box-side membership tool, and it exists because // there was none. Authorization is "member of the account AND the account has // the app", so a membership is access to EVERY app in the account — on the // flagship, a backfilled membership handed someone who held one grant for a // toy app the right to promote and roll back kass, keymail and the console // itself. Removing it took hand-edited SQL, because grant/revoke only ever // spoke the pre-accounts language. This verb is the missing half. func TestMemberVerb(t *testing.T) { bin := buildConsole(t) tmp := t.TempDir() dbPath := filepath.Join(tmp, "console.db") run := runner(t, bin, "CONSOLE_DB="+dbPath) // Two accounts: ops with two owners (so one can be removed), and a // single-owner account to prove the last-owner guard. st, err := console.Open(dbPath) if err != nil { t.Fatal(err) } now := time.Now() if err := st.CreateAccount(console.OpsAccountID, "bab", "ops", "paul@keymail.dev", now); err != nil { t.Fatal(err) } if err := st.CreateAccount(42, "cdef", "Kass", "solo@keymail.dev", now); err != nil { t.Fatal(err) } st.Close() // -list shows who is in an account and with what role. out, err := run("member", "-list", "bab") if err != nil { t.Fatalf("member -list: %v\n%s", err, out) } if !strings.Contains(out, "paul@keymail.dev") || !strings.Contains(out, "owner") { t.Errorf("member -list does not show the owner: %s", out) } // -add defaults to member: the safe role. An owner is a deliberate flag. if out, err := run("member", "-add", "dev@keymail.dev", "bab"); err != nil { t.Fatalf("member -add: %v\n%s", err, out) } else if !strings.Contains(out, "member") { t.Errorf("member -add does not report the role it used: %s", out) } if role := roleOf(t, dbPath, console.OpsAccountID, "dev@keymail.dev"); role != console.RoleMember { t.Fatalf("role after -add = %q, want member", role) } // Idempotent, and it SAYS so — an operator repeating a command must be // able to tell "already done" from "just done". out, err = run("member", "-add", "dev@keymail.dev", "bab") if err != nil { t.Fatalf("re-adding must be a no-op: %v\n%s", err, out) } if !strings.Contains(out, "already") { t.Errorf("re-add output does not say it was already done: %s", out) } // Adding an existing member with a different role RE-ROLES them, and says // that is what happened rather than silently doing nothing. out, err = run("member", "-add", "dev@keymail.dev", "bab", "-role", "owner") if err != nil { t.Fatalf("re-role: %v\n%s", err, out) } if !strings.Contains(out, "owner") || strings.Contains(out, "already") { t.Errorf("re-role output does not report the change: %s", out) } if role := roleOf(t, dbPath, console.OpsAccountID, "dev@keymail.dev"); role != console.RoleOwner { t.Fatalf("role after re-role = %q, want owner", role) } // The removal the flagship needed and did not have. if out, err := run("member", "-remove", "dev@keymail.dev", "bab"); err != nil { t.Fatalf("member -remove: %v\n%s", err, out) } if role := roleOf(t, dbPath, console.OpsAccountID, "dev@keymail.dev"); role != "" { t.Fatalf("role after -remove = %q, want none", role) } // Removing a non-member is a no-op that says so, not an error: the // operator's intent ("they must not be in this account") is satisfied. out, err = run("member", "-remove", "dev@keymail.dev", "bab") if err != nil { t.Fatalf("removing a non-member must not fail: %v\n%s", err, out) } if !strings.Contains(out, "not a member") { t.Errorf("no-op removal does not say why: %s", out) } } // An account with nobody who can administer it is unrecoverable: no one left // to invite, rename or delete. The store guard already refuses it; the verb's // job is to surface that refusal instead of swallowing it. func TestMemberVerbRefusesToStrandAnAccount(t *testing.T) { bin := buildConsole(t) tmp := t.TempDir() dbPath := filepath.Join(tmp, "console.db") run := runner(t, bin, "CONSOLE_DB="+dbPath) st, err := console.Open(dbPath) if err != nil { t.Fatal(err) } if err := st.CreateAccount(42, "cdef", "Kass", "solo@keymail.dev", time.Now()); err != nil { t.Fatal(err) } st.Close() out, err := run("member", "-remove", "solo@keymail.dev", "cdef") if err == nil { t.Errorf("removing the last owner succeeded: %s", out) } if !strings.Contains(out, "last owner") { t.Errorf("refusal does not explain itself: %s", out) } out, err = run("member", "-add", "solo@keymail.dev", "cdef", "-role", "member") if err == nil { t.Errorf("demoting the last owner succeeded: %s", out) } if !strings.Contains(out, "last owner") { t.Errorf("demotion refusal does not explain itself: %s", out) } if role := roleOf(t, dbPath, 42, "solo@keymail.dev"); role != console.RoleOwner { t.Errorf("the last owner survived as %q, want owner", role) } } // Addresses are case-insensitive identities, but the memberships primary key // is literal text with no COLLATE NOCASE — so an unnormalized address here // writes a row nobody's session can ever match, while the verb cheerfully // reports success. That is the worst failure available to a tool whose entire // job is repairing authorization: the operator walks away believing access was // restored. func TestMemberVerbNormalizesTheAddress(t *testing.T) { bin := buildConsole(t) tmp := t.TempDir() dbPath := filepath.Join(tmp, "console.db") run := runner(t, bin, "CONSOLE_DB="+dbPath) st, err := console.Open(dbPath) if err != nil { t.Fatal(err) } if err := st.CreateAccount(console.OpsAccountID, "bab", "ops", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } st.Close() if out, err := run("member", "-add", " James@Keymail.dev ", "bab", "-role", "owner"); err != nil { t.Fatalf("member -add: %v\n%s", err, out) } // The address the real session presents is the lowercase one. if role := roleOf(t, dbPath, console.OpsAccountID, "james@keymail.dev"); role != console.RoleOwner { t.Fatalf("role for the lowercase address = %q, want owner — the row is unreachable", role) } // And it is ONE row, not two: the mixed-case form resolves to the same // membership, so re-adding it is the no-op it looks like. out, err := run("member", "-add", "JAMES@keymail.dev", "bab", "-role", "owner") if err != nil { t.Fatalf("re-add in another case: %v\n%s", err, out) } if !strings.Contains(out, "already") { t.Errorf("a case variant created a second membership: %s", out) } if out, _ := run("member", "-list", "bab"); strings.Contains(out, "James@Keymail.dev") || strings.Contains(out, "JAMES@keymail.dev") { t.Errorf("member list shows an unnormalized address: %s", out) } // Removal normalizes too, or the tool cannot undo what it just did. if out, err := run("member", "-remove", "James@Keymail.dev", "bab"); err != nil { t.Fatalf("member -remove: %v\n%s", err, out) } if role := roleOf(t, dbPath, console.OpsAccountID, "james@keymail.dev"); role != "" { t.Errorf("role after case-variant removal = %q, want none", role) } } // The second way an unnormalized address breaks things: a case-typo'd // duplicate would count as a second owner, so the last-owner guard would let // the only owner anybody can sign in as be removed while reporting that // another remains. Normalizing collapses the duplicate and the invariant holds. func TestMemberVerbCaseVariantCannotFakeASecondOwner(t *testing.T) { bin := buildConsole(t) tmp := t.TempDir() dbPath := filepath.Join(tmp, "console.db") run := runner(t, bin, "CONSOLE_DB="+dbPath) st, err := console.Open(dbPath) if err != nil { t.Fatal(err) } if err := st.CreateAccount(42, "cdef", "Kass", "solo@keymail.dev", time.Now()); err != nil { t.Fatal(err) } st.Close() if out, err := run("member", "-add", "Solo@Keymail.dev", "cdef", "-role", "owner"); err != nil { t.Fatalf("member -add: %v\n%s", err, out) } members := membersOf(t, dbPath, 42) if len(members) != 1 { t.Fatalf("account has %d members, want 1 — a case variant became a second owner: %+v", len(members), members) } out, err := run("member", "-remove", "solo@keymail.dev", "cdef") if err == nil { t.Fatalf("removed the only functionally reachable owner: %s", out) } if !strings.Contains(out, "last owner") { t.Errorf("refusal does not explain itself: %s", out) } } // A typo must name what it looked for, not just fail. These commands get run // at 2am against a production box. func TestMemberVerbRejectsBadArguments(t *testing.T) { bin := buildConsole(t) tmp := t.TempDir() dbPath := filepath.Join(tmp, "console.db") run := runner(t, bin, "CONSOLE_DB="+dbPath) st, err := console.Open(dbPath) if err != nil { t.Fatal(err) } if err := st.CreateAccount(console.OpsAccountID, "bab", "ops", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } st.Close() for _, tc := range []struct { name string args []string want []string }{ {"unknown account", []string{"member", "-add", "dev@keymail.dev", "nosuch"}, []string{"nosuch", "accounts/nosuch/account.json", "carlos accounts create"}}, {"malformed address", []string{"member", "-add", "dev", "bab"}, []string{"dev", "@"}}, {"unknown role", []string{"member", "-add", "dev@keymail.dev", "bab", "-role", "admin"}, []string{"admin", "owner", "member"}}, {"no mode", []string{"member", "dev@keymail.dev", "bab"}, []string{"-add", "-remove", "-list"}}, } { t.Run(tc.name, func(t *testing.T) { out, err := run(tc.args...) if err == nil { t.Fatalf("%v succeeded: %s", tc.args, out) } for _, want := range tc.want { if !strings.Contains(out, want) { t.Errorf("error does not mention %q: %s", want, out) } } }) } } // `carlos accounts create` mints an account into the BUCKET and the registry // and never opens the console database. Before materialization, `console // member -add` refused that sqid — on the same box that had just created it. func TestMemberVerbAdministersABucketOnlyAccount(t *testing.T) { bin := buildConsole(t) tmp := t.TempDir() dbPath := filepath.Join(tmp, "console.db") bucket := filepath.Join(tmp, "bucket") run := runner(t, bin, "CONSOLE_DB="+dbPath, "CARLOS_DEPLOYMENT_DIR="+bucket) // Exactly what `carlos accounts create` leaves behind: a manifest, and // nothing at all in the console's database. const id = int64(42) sqid := domains.Sqid(id) rel := release.NewDirStore(bucket) if err := console.WriteAccountManifest(t.Context(), rel, id, sqid, "Kass", time.Now()); err != nil { t.Fatal(err) } out, err := run("member", "-add", "paul@keymail.dev", sqid, "-role", "owner") if err != nil { t.Fatalf("member -add on a bucket-only account: %v\n%s", err, out) } if role := roleOf(t, dbPath, id, "paul@keymail.dev"); role != console.RoleOwner { t.Fatalf("role = %q, want owner", role) } // The id came from the manifest, not from a fresh mint: an invented id // would put the account at a different sqid and re-home everything it owns. st, err := console.Open(dbPath) if err != nil { t.Fatal(err) } defer st.Close() acct, err := st.AccountBySqid(sqid) if err != nil || acct == nil || acct.ID != id || acct.Name != "Kass" { t.Fatalf("materialized account = %+v (%v), want id %d named Kass", acct, err, id) } } func membersOf(t *testing.T, dbPath string, accountID int64) []console.Account { t.Helper() st, err := console.Open(dbPath) if err != nil { t.Fatal(err) } defer st.Close() members, err := st.Members(accountID) if err != nil { t.Fatal(err) } return members } func roleOf(t *testing.T, dbPath string, accountID int64, address string) string { t.Helper() st, err := console.Open(dbPath) if err != nil { t.Fatal(err) } defer st.Close() role, err := st.Role(accountID, address) if err != nil { t.Fatal(err) } return role }