| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | |
| 7 | "github.com/carlosframework/platform/internal/domains" |
| 8 | "github.com/carlosframework/platform/internal/registry" |
| 9 | "github.com/carlosframework/platform/internal/release" |
| 10 | ) |
| 11 | |
| 12 | // The load-bearing invariant behind the account-default branch (#157): the |
| 13 | // flag default flipped from "ops" to "" for every dual-mode verb, and |
| 14 | // direct mode survives ONLY because resolveAccount reads "" as ops — with |
| 15 | // no registry involved, since CI and fresh boxes have none. |
| 16 | func TestResolveAccountEmptyMeansOps(t *testing.T) { |
| 17 | for _, want := range []string{"", "ops", " ops ", " "} { |
| 18 | if got := resolveAccount("/nonexistent/registry.db", want, ""); got != release.OpsAccount { |
| 19 | t.Errorf("resolveAccount(%q) = %q, want %q", want, got, release.OpsAccount) |
| 20 | } |
| 21 | } |
| 22 | } |
| 23 | |
| 24 | func TestPickAccount(t *testing.T) { |
| 25 | accts := []registry.Account{ |
| 26 | {ID: 1, Sqid: "bab", Name: "ops"}, |
| 27 | {ID: 2, Sqid: "bah", Name: "amadan"}, |
| 28 | {ID: 3, Sqid: "bad", Name: "amadan"}, |
| 29 | {ID: 4, Sqid: "baf", Name: "bah"}, // name collides with #2's sqid |
| 30 | } |
| 31 | if got, err := pickAccount(accts, "bab"); err != nil || got != "bab" { |
| 32 | t.Errorf("sqid: %q, %v", got, err) |
| 33 | } |
| 34 | if got, err := pickAccount(accts, "ops"); err != nil || got != "bab" { |
| 35 | t.Errorf("unique name: %q, %v", got, err) |
| 36 | } |
| 37 | if got, err := pickAccount(accts, "OPS"); err != nil || got != "bab" { |
| 38 | t.Errorf("case-insensitive name: %q, %v", got, err) |
| 39 | } |
| 40 | // A sqid nobody has materialized passes through — a fresh box may not |
| 41 | // have reconciled, and CI has no registry at all. |
| 42 | if got, err := pickAccount(accts, "zzz"); err != nil || got != "zzz" { |
| 43 | t.Errorf("passthrough sqid: %q, %v", got, err) |
| 44 | } |
| 45 | if _, err := pickAccount(accts, "not-a-sqid-or-name"); err == nil { |
| 46 | t.Error("unresolvable non-sqid name must error") |
| 47 | } |
| 48 | // The two ambiguity shapes: a shared name, and a name/sqid collision. |
| 49 | // Both must refuse, and the fix-up named must be -account-id. |
| 50 | for _, want := range []string{"amadan", "bah"} { |
| 51 | _, err := pickAccount(accts, want) |
| 52 | if err == nil || !strings.Contains(err.Error(), "-account-id") { |
| 53 | t.Errorf("pickAccount(%q) = %v, want ambiguity error naming --account-id", want, err) |
| 54 | } |
| 55 | } |
| 56 | if _, err := pickAccount(accts, "bah"); err == nil || |
| 57 | !strings.Contains(err.Error(), "bah") || !strings.Contains(err.Error(), "baf") { |
| 58 | t.Error("name/sqid conflict error must list both candidates") |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | // TestAccountIDShape pins the gate BOTH modes share (checkAccountID, which |
| 63 | // resolveAccount and apiAccount each call). The values are the ones that |
| 64 | // matter: "ops" and "oil" pass release.ValidAccount (^[a-z]{3,16}$) and must |
| 65 | // still be refused, because the frozen encoder has no i, l or o and so can |
| 66 | // never emit them for any id — a phantom account, written to and served by |
| 67 | // nobody. "bab" is real; "zzz" is unminted-but-shaped and must pass, since |
| 68 | // -account-id is the flag you reach for on a box that hasn't reconciled. |
| 69 | // |
| 70 | // The refusals themselves are log.Fatal (os.Exit, unobservable in-process), |
| 71 | // so they are pinned two ways instead: the predicate directly here, and the |
| 72 | // whole `carlos add -account-id ops` invocation end to end in |
| 73 | // TestAddAccountIDRefusesAName (e2e_test.go), which asserts the non-zero |
| 74 | // exit and that no row was written. |
| 75 | func TestAccountIDShape(t *testing.T) { |
| 76 | for _, want := range []string{"bab", "zzz", "baf"} { |
| 77 | if !domains.ValidSqid(want) { |
| 78 | t.Errorf("--account-id %q must be accepted — it is shaped like something Sqid emits", want) |
| 79 | } |
| 80 | // The accept path IS reachable in-process: only a refusal exits. |
| 81 | empty, id := "", want |
| 82 | if a := apiAccount(&empty, &id); a.Seg != want || !a.Exact { |
| 83 | t.Errorf("apiAccount(--account-id %q) = %+v, want {%s true}", want, a, want) |
| 84 | } |
| 85 | } |
| 86 | for _, bad := range []string{"ops", "oil", "ill", "ba", "BAB", "ba1", ""} { |
| 87 | if domains.ValidSqid(bad) { |
| 88 | t.Errorf("--account-id %q must be refused — it is a name, not a sqid", bad) |
| 89 | } |
| 90 | } |
| 91 | // The specific trap the reviewer caught: release.ValidAccount would have |
| 92 | // waved "ops" straight through. If this ever stops being true, the two |
| 93 | // predicates have converged and the gate is doing nothing. |
| 94 | if !release.ValidAccount("ops") { |
| 95 | t.Error("release.ValidAccount no longer accepts \"ops\" — this gate's whole reason to exist has moved") |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | func TestApiAccountExact(t *testing.T) { |
| 100 | acct, id := "ops", "bah" |
| 101 | a := apiAccount(&acct, &id) |
| 102 | if a.Seg != "bah" || !a.Exact { |
| 103 | t.Errorf("apiAccount with --account-id = %+v, want {bah true}", a) |
| 104 | } |
| 105 | id = "" |
| 106 | a = apiAccount(&acct, &id) |
| 107 | if a.Seg != "ops" || a.Exact { |
| 108 | t.Errorf("apiAccount without --account-id = %+v, want {ops false}", a) |
| 109 | } |
| 110 | // Both flags are trimmed the way resolveAccount trims them, so a value |
| 111 | // that picked up whitespace from a shell means the same thing in either |
| 112 | // mode — API mode used to send " ops " to the console verbatim. |
| 113 | padded, id := " ops ", " bah " |
| 114 | if a := apiAccount(&padded, &id); a.Seg != "bah" || !a.Exact { |
| 115 | t.Errorf("apiAccount(padded --account-id) = %+v, want {bah true}", a) |
| 116 | } |
| 117 | id = " " |
| 118 | if a := apiAccount(&padded, &id); a.Seg != "ops" || a.Exact { |
| 119 | t.Errorf("apiAccount(padded --account, blank --account-id) = %+v, want {ops false}", a) |
| 120 | } |
| 121 | if p := apiPath(apiAcct{Seg: "bah", Exact: true}, "web", "/channels"); !strings.HasSuffix(p, "?exact=sqid") { |
| 122 | t.Errorf("apiPath exact = %q, want ?exact=sqid suffix", p) |
| 123 | } |
| 124 | if p := apiPath(apiAcct{Seg: "ops"}, "web", "/channels"); strings.Contains(p, "exact") { |
| 125 | t.Errorf("apiPath non-exact = %q, must carry no exact param", p) |
| 126 | } |
| 127 | } |
| 128 | |