carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package main_test
2
3import (
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.
21func 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.
106func 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.
146func 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.
194func 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.
228func 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.
274func 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
310func 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
324func 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