| 1 | package main_test |
| 2 | |
| 3 | import ( |
| 4 | "path/filepath" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/console" |
| 10 | "github.com/carlosframework/platform/internal/domains" |
| 11 | "github.com/carlosframework/platform/internal/release" |
| 12 | ) |
| 13 | |
| 14 | // `console member` is the box-side membership tool, and it exists because |
| 15 | // there was none. Authorization is "member of the account AND the account has |
| 16 | // the app", so a membership is access to EVERY app in the account — on the |
| 17 | // flagship, a backfilled membership handed someone who held one grant for a |
| 18 | // toy app the right to promote and roll back kass, keymail and the console |
| 19 | // itself. Removing it took hand-edited SQL, because grant/revoke only ever |
| 20 | // spoke the pre-accounts language. This verb is the missing half. |
| 21 | func TestMemberVerb(t *testing.T) { |
| 22 | bin := buildConsole(t) |
| 23 | tmp := t.TempDir() |
| 24 | dbPath := filepath.Join(tmp, "console.db") |
| 25 | run := runner(t, bin, "CONSOLE_DB="+dbPath) |
| 26 | |
| 27 | // Two accounts: ops with two owners (so one can be removed), and a |
| 28 | // single-owner account to prove the last-owner guard. |
| 29 | st, err := console.Open(dbPath) |
| 30 | if err != nil { |
| 31 | t.Fatal(err) |
| 32 | } |
| 33 | now := time.Now() |
| 34 | if err := st.CreateAccount(console.OpsAccountID, "bab", "ops", "paul@keymail.dev", now); err != nil { |
| 35 | t.Fatal(err) |
| 36 | } |
| 37 | if err := st.CreateAccount(42, "cdef", "Kass", "solo@keymail.dev", now); err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | st.Close() |
| 41 | |
| 42 | // -list shows who is in an account and with what role. |
| 43 | out, err := run("member", "-list", "bab") |
| 44 | if err != nil { |
| 45 | t.Fatalf("member -list: %v\n%s", err, out) |
| 46 | } |
| 47 | if !strings.Contains(out, "paul@keymail.dev") || !strings.Contains(out, "owner") { |
| 48 | t.Errorf("member -list does not show the owner: %s", out) |
| 49 | } |
| 50 | |
| 51 | // -add defaults to member: the safe role. An owner is a deliberate flag. |
| 52 | if out, err := run("member", "-add", "dev@keymail.dev", "bab"); err != nil { |
| 53 | t.Fatalf("member -add: %v\n%s", err, out) |
| 54 | } else if !strings.Contains(out, "member") { |
| 55 | t.Errorf("member -add does not report the role it used: %s", out) |
| 56 | } |
| 57 | if role := roleOf(t, dbPath, console.OpsAccountID, "dev@keymail.dev"); role != console.RoleMember { |
| 58 | t.Fatalf("role after -add = %q, want member", role) |
| 59 | } |
| 60 | |
| 61 | // Idempotent, and it SAYS so — an operator repeating a command must be |
| 62 | // able to tell "already done" from "just done". |
| 63 | out, err = run("member", "-add", "dev@keymail.dev", "bab") |
| 64 | if err != nil { |
| 65 | t.Fatalf("re-adding must be a no-op: %v\n%s", err, out) |
| 66 | } |
| 67 | if !strings.Contains(out, "already") { |
| 68 | t.Errorf("re-add output does not say it was already done: %s", out) |
| 69 | } |
| 70 | |
| 71 | // Adding an existing member with a different role RE-ROLES them, and says |
| 72 | // that is what happened rather than silently doing nothing. |
| 73 | out, err = run("member", "-add", "dev@keymail.dev", "bab", "-role", "owner") |
| 74 | if err != nil { |
| 75 | t.Fatalf("re-role: %v\n%s", err, out) |
| 76 | } |
| 77 | if !strings.Contains(out, "owner") || strings.Contains(out, "already") { |
| 78 | t.Errorf("re-role output does not report the change: %s", out) |
| 79 | } |
| 80 | if role := roleOf(t, dbPath, console.OpsAccountID, "dev@keymail.dev"); role != console.RoleOwner { |
| 81 | t.Fatalf("role after re-role = %q, want owner", role) |
| 82 | } |
| 83 | |
| 84 | // The removal the flagship needed and did not have. |
| 85 | if out, err := run("member", "-remove", "dev@keymail.dev", "bab"); err != nil { |
| 86 | t.Fatalf("member -remove: %v\n%s", err, out) |
| 87 | } |
| 88 | if role := roleOf(t, dbPath, console.OpsAccountID, "dev@keymail.dev"); role != "" { |
| 89 | t.Fatalf("role after -remove = %q, want none", role) |
| 90 | } |
| 91 | |
| 92 | // Removing a non-member is a no-op that says so, not an error: the |
| 93 | // operator's intent ("they must not be in this account") is satisfied. |
| 94 | out, err = run("member", "-remove", "dev@keymail.dev", "bab") |
| 95 | if err != nil { |
| 96 | t.Fatalf("removing a non-member must not fail: %v\n%s", err, out) |
| 97 | } |
| 98 | if !strings.Contains(out, "not a member") { |
| 99 | t.Errorf("no-op removal does not say why: %s", out) |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | // An account with nobody who can administer it is unrecoverable: no one left |
| 104 | // to invite, rename or delete. The store guard already refuses it; the verb's |
| 105 | // job is to surface that refusal instead of swallowing it. |
| 106 | func TestMemberVerbRefusesToStrandAnAccount(t *testing.T) { |
| 107 | bin := buildConsole(t) |
| 108 | tmp := t.TempDir() |
| 109 | dbPath := filepath.Join(tmp, "console.db") |
| 110 | run := runner(t, bin, "CONSOLE_DB="+dbPath) |
| 111 | |
| 112 | st, err := console.Open(dbPath) |
| 113 | if err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | if err := st.CreateAccount(42, "cdef", "Kass", "solo@keymail.dev", time.Now()); err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | st.Close() |
| 120 | |
| 121 | out, err := run("member", "-remove", "solo@keymail.dev", "cdef") |
| 122 | if err == nil { |
| 123 | t.Errorf("removing the last owner succeeded: %s", out) |
| 124 | } |
| 125 | if !strings.Contains(out, "last owner") { |
| 126 | t.Errorf("refusal does not explain itself: %s", out) |
| 127 | } |
| 128 | out, err = run("member", "-add", "solo@keymail.dev", "cdef", "-role", "member") |
| 129 | if err == nil { |
| 130 | t.Errorf("demoting the last owner succeeded: %s", out) |
| 131 | } |
| 132 | if !strings.Contains(out, "last owner") { |
| 133 | t.Errorf("demotion refusal does not explain itself: %s", out) |
| 134 | } |
| 135 | if role := roleOf(t, dbPath, 42, "solo@keymail.dev"); role != console.RoleOwner { |
| 136 | t.Errorf("the last owner survived as %q, want owner", role) |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | // Addresses are case-insensitive identities, but the memberships primary key |
| 141 | // is literal text with no COLLATE NOCASE — so an unnormalized address here |
| 142 | // writes a row nobody's session can ever match, while the verb cheerfully |
| 143 | // reports success. That is the worst failure available to a tool whose entire |
| 144 | // job is repairing authorization: the operator walks away believing access was |
| 145 | // restored. |
| 146 | func TestMemberVerbNormalizesTheAddress(t *testing.T) { |
| 147 | bin := buildConsole(t) |
| 148 | tmp := t.TempDir() |
| 149 | dbPath := filepath.Join(tmp, "console.db") |
| 150 | run := runner(t, bin, "CONSOLE_DB="+dbPath) |
| 151 | |
| 152 | st, err := console.Open(dbPath) |
| 153 | if err != nil { |
| 154 | t.Fatal(err) |
| 155 | } |
| 156 | if err := st.CreateAccount(console.OpsAccountID, "bab", "ops", "paul@keymail.dev", time.Now()); err != nil { |
| 157 | t.Fatal(err) |
| 158 | } |
| 159 | st.Close() |
| 160 | |
| 161 | if out, err := run("member", "-add", " James@Keymail.dev ", "bab", "-role", "owner"); err != nil { |
| 162 | t.Fatalf("member -add: %v\n%s", err, out) |
| 163 | } |
| 164 | // The address the real session presents is the lowercase one. |
| 165 | if role := roleOf(t, dbPath, console.OpsAccountID, "james@keymail.dev"); role != console.RoleOwner { |
| 166 | t.Fatalf("role for the lowercase address = %q, want owner — the row is unreachable", role) |
| 167 | } |
| 168 | // And it is ONE row, not two: the mixed-case form resolves to the same |
| 169 | // membership, so re-adding it is the no-op it looks like. |
| 170 | out, err := run("member", "-add", "JAMES@keymail.dev", "bab", "-role", "owner") |
| 171 | if err != nil { |
| 172 | t.Fatalf("re-add in another case: %v\n%s", err, out) |
| 173 | } |
| 174 | if !strings.Contains(out, "already") { |
| 175 | t.Errorf("a case variant created a second membership: %s", out) |
| 176 | } |
| 177 | if out, _ := run("member", "-list", "bab"); strings.Contains(out, "James@Keymail.dev") || |
| 178 | strings.Contains(out, "JAMES@keymail.dev") { |
| 179 | t.Errorf("member list shows an unnormalized address: %s", out) |
| 180 | } |
| 181 | // Removal normalizes too, or the tool cannot undo what it just did. |
| 182 | if out, err := run("member", "-remove", "James@Keymail.dev", "bab"); err != nil { |
| 183 | t.Fatalf("member -remove: %v\n%s", err, out) |
| 184 | } |
| 185 | if role := roleOf(t, dbPath, console.OpsAccountID, "james@keymail.dev"); role != "" { |
| 186 | t.Errorf("role after case-variant removal = %q, want none", role) |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | // The second way an unnormalized address breaks things: a case-typo'd |
| 191 | // duplicate would count as a second owner, so the last-owner guard would let |
| 192 | // the only owner anybody can sign in as be removed while reporting that |
| 193 | // another remains. Normalizing collapses the duplicate and the invariant holds. |
| 194 | func TestMemberVerbCaseVariantCannotFakeASecondOwner(t *testing.T) { |
| 195 | bin := buildConsole(t) |
| 196 | tmp := t.TempDir() |
| 197 | dbPath := filepath.Join(tmp, "console.db") |
| 198 | run := runner(t, bin, "CONSOLE_DB="+dbPath) |
| 199 | |
| 200 | st, err := console.Open(dbPath) |
| 201 | if err != nil { |
| 202 | t.Fatal(err) |
| 203 | } |
| 204 | if err := st.CreateAccount(42, "cdef", "Kass", "solo@keymail.dev", time.Now()); err != nil { |
| 205 | t.Fatal(err) |
| 206 | } |
| 207 | st.Close() |
| 208 | |
| 209 | if out, err := run("member", "-add", "Solo@Keymail.dev", "cdef", "-role", "owner"); err != nil { |
| 210 | t.Fatalf("member -add: %v\n%s", err, out) |
| 211 | } |
| 212 | members := membersOf(t, dbPath, 42) |
| 213 | if len(members) != 1 { |
| 214 | t.Fatalf("account has %d members, want 1 — a case variant became a second owner: %+v", |
| 215 | len(members), members) |
| 216 | } |
| 217 | out, err := run("member", "-remove", "solo@keymail.dev", "cdef") |
| 218 | if err == nil { |
| 219 | t.Fatalf("removed the only functionally reachable owner: %s", out) |
| 220 | } |
| 221 | if !strings.Contains(out, "last owner") { |
| 222 | t.Errorf("refusal does not explain itself: %s", out) |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | // A typo must name what it looked for, not just fail. These commands get run |
| 227 | // at 2am against a production box. |
| 228 | func TestMemberVerbRejectsBadArguments(t *testing.T) { |
| 229 | bin := buildConsole(t) |
| 230 | tmp := t.TempDir() |
| 231 | dbPath := filepath.Join(tmp, "console.db") |
| 232 | run := runner(t, bin, "CONSOLE_DB="+dbPath) |
| 233 | |
| 234 | st, err := console.Open(dbPath) |
| 235 | if err != nil { |
| 236 | t.Fatal(err) |
| 237 | } |
| 238 | if err := st.CreateAccount(console.OpsAccountID, "bab", "ops", "paul@keymail.dev", time.Now()); err != nil { |
| 239 | t.Fatal(err) |
| 240 | } |
| 241 | st.Close() |
| 242 | |
| 243 | for _, tc := range []struct { |
| 244 | name string |
| 245 | args []string |
| 246 | want []string |
| 247 | }{ |
| 248 | {"unknown account", []string{"member", "-add", "dev@keymail.dev", "nosuch"}, |
| 249 | []string{"nosuch", "accounts/nosuch/account.json", "carlos accounts create"}}, |
| 250 | {"malformed address", []string{"member", "-add", "dev", "bab"}, |
| 251 | []string{"dev", "@"}}, |
| 252 | {"unknown role", []string{"member", "-add", "dev@keymail.dev", "bab", "-role", "admin"}, |
| 253 | []string{"admin", "owner", "member"}}, |
| 254 | {"no mode", []string{"member", "dev@keymail.dev", "bab"}, |
| 255 | []string{"-add", "-remove", "-list"}}, |
| 256 | } { |
| 257 | t.Run(tc.name, func(t *testing.T) { |
| 258 | out, err := run(tc.args...) |
| 259 | if err == nil { |
| 260 | t.Fatalf("%v succeeded: %s", tc.args, out) |
| 261 | } |
| 262 | for _, want := range tc.want { |
| 263 | if !strings.Contains(out, want) { |
| 264 | t.Errorf("error does not mention %q: %s", want, out) |
| 265 | } |
| 266 | } |
| 267 | }) |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | // `carlos accounts create` mints an account into the BUCKET and the registry |
| 272 | // and never opens the console database. Before materialization, `console |
| 273 | // member -add` refused that sqid — on the same box that had just created it. |
| 274 | func TestMemberVerbAdministersABucketOnlyAccount(t *testing.T) { |
| 275 | bin := buildConsole(t) |
| 276 | tmp := t.TempDir() |
| 277 | dbPath := filepath.Join(tmp, "console.db") |
| 278 | bucket := filepath.Join(tmp, "bucket") |
| 279 | run := runner(t, bin, "CONSOLE_DB="+dbPath, "CARLOS_DEPLOYMENT_DIR="+bucket) |
| 280 | |
| 281 | // Exactly what `carlos accounts create` leaves behind: a manifest, and |
| 282 | // nothing at all in the console's database. |
| 283 | const id = int64(42) |
| 284 | sqid := domains.Sqid(id) |
| 285 | rel := release.NewDirStore(bucket) |
| 286 | if err := console.WriteAccountManifest(t.Context(), rel, id, sqid, "Kass", time.Now()); err != nil { |
| 287 | t.Fatal(err) |
| 288 | } |
| 289 | |
| 290 | out, err := run("member", "-add", "paul@keymail.dev", sqid, "-role", "owner") |
| 291 | if err != nil { |
| 292 | t.Fatalf("member -add on a bucket-only account: %v\n%s", err, out) |
| 293 | } |
| 294 | if role := roleOf(t, dbPath, id, "paul@keymail.dev"); role != console.RoleOwner { |
| 295 | t.Fatalf("role = %q, want owner", role) |
| 296 | } |
| 297 | // The id came from the manifest, not from a fresh mint: an invented id |
| 298 | // would put the account at a different sqid and re-home everything it owns. |
| 299 | st, err := console.Open(dbPath) |
| 300 | if err != nil { |
| 301 | t.Fatal(err) |
| 302 | } |
| 303 | defer st.Close() |
| 304 | acct, err := st.AccountBySqid(sqid) |
| 305 | if err != nil || acct == nil || acct.ID != id || acct.Name != "Kass" { |
| 306 | t.Fatalf("materialized account = %+v (%v), want id %d named Kass", acct, err, id) |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | func membersOf(t *testing.T, dbPath string, accountID int64) []console.Account { |
| 311 | t.Helper() |
| 312 | st, err := console.Open(dbPath) |
| 313 | if err != nil { |
| 314 | t.Fatal(err) |
| 315 | } |
| 316 | defer st.Close() |
| 317 | members, err := st.Members(accountID) |
| 318 | if err != nil { |
| 319 | t.Fatal(err) |
| 320 | } |
| 321 | return members |
| 322 | } |
| 323 | |
| 324 | func roleOf(t *testing.T, dbPath string, accountID int64, address string) string { |
| 325 | t.Helper() |
| 326 | st, err := console.Open(dbPath) |
| 327 | if err != nil { |
| 328 | t.Fatal(err) |
| 329 | } |
| 330 | defer st.Close() |
| 331 | role, err := st.Role(accountID, address) |
| 332 | if err != nil { |
| 333 | t.Fatal(err) |
| 334 | } |
| 335 | return role |
| 336 | } |
| 337 | |