package console import ( "errors" "net/http" "net/url" "reflect" "strings" "testing" "time" "github.com/carlosframework/platform/internal/runs" "github.com/carlosframework/platform/internal/svcrole" ) var importClassic = [][]string{{"import", "classic"}} func mintRun(t *testing.T, st *Store, name string, commands [][]string, now time.Time) string { t.Helper() raw, err := st.NewServicePrincipalScoped(1, name, "hello", svcrole.Run, nil, commands, "paul@example.test", now) if err != nil { t.Fatalf("mint run %s: %v", name, err) } return raw } func credentialIDOf(t *testing.T, st *Store, raw string) string { t.Helper() var id string if err := st.db.QueryRow(`SELECT credential_id FROM cli_tokens WHERE token_hash = ?`, hashToken(raw)).Scan(&id); err != nil { t.Fatal(err) } return id } func TestRunCredentialCarriesItsCommands(t *testing.T) { st := newDeployStore(t) now := time.Now() raw := mintRun(t, st, "importer", [][]string{{"import", "classic"}, {"import", "sync"}}, now) p, ok := st.CLIPrincipal(raw, now) if !ok { t.Fatal("a run credential does not resolve") } if p.Role != svcrole.Run || p.App != "hello" || p.Name != "importer" { t.Fatalf("CLIPrincipal = %+v", p) } if want := [][]string{{"import", "classic"}, {"import", "sync"}}; !reflect.DeepEqual(p.Commands, want) { t.Fatalf("Commands = %q, want %q", p.Commands, want) } if p.CredentialID == "" || p.CredentialID != credentialIDOf(t, st, raw) { t.Fatalf("CredentialID = %q, want the row's own non-empty id", p.CredentialID) } // At least 128 bits: 16 random bytes, hex. if len(p.CredentialID) < 32 { t.Fatalf("CredentialID %q is shorter than 128 bits of hex", p.CredentialID) } if strings.Contains(p.CredentialID, "importer") { t.Fatalf("CredentialID %q is derived from the name", p.CredentialID) } // Stored canonically: JSON, in the order given. var stored string if err := st.db.QueryRow(`SELECT commands FROM cli_tokens WHERE token_hash = ?`, hashToken(raw)).Scan(&stored); err != nil { t.Fatal(err) } if want := `[["import","classic"],["import","sync"]]`; stored != want { t.Fatalf("stored commands = %s, want %s", stored, want) } // Every other service principal has an id too, and no commands. pub, err := st.NewServicePrincipalScoped(1, "pub", "hello", svcrole.Publish, nil, nil, "paul@example.test", now) if err != nil { t.Fatal(err) } pp, ok := st.CLIPrincipal(pub, now) if !ok || pp.CredentialID == "" || pp.Commands != nil { t.Fatalf("publish principal = %+v, %v; want an id and nil Commands", pp, ok) } if pp.CredentialID == p.CredentialID { t.Fatal("two credentials share one id") } // The listing carries the stored form. rows, err := st.ServicePrincipals(1) if err != nil { t.Fatal(err) } for _, r := range rows { if r.Name == "importer" && r.CommandList() != "import classic, import sync" { t.Fatalf("listing CommandList = %q", r.CommandList()) } if r.Name == "pub" && r.Commands != "" { t.Fatalf("publish listing Commands = %q, want empty", r.Commands) } } } func TestRunCredentialNeedsCommands(t *testing.T) { st := newDeployStore(t) now := time.Now() mint := func(app string, role svcrole.Role, commands [][]string) error { _, err := st.NewServicePrincipalScoped(1, "x", app, role, nil, commands, "paul@example.test", now) return err } if err := mint("hello", svcrole.Run, nil); !errors.Is(err, ErrRunNeedsCommands) { t.Errorf("run with nil commands: %v, want ErrRunNeedsCommands", err) } if err := mint("hello", svcrole.Run, [][]string{}); !errors.Is(err, ErrRunNeedsCommands) { t.Errorf("run with no commands: %v, want ErrRunNeedsCommands", err) } if err := mint("hello", svcrole.Instance, importClassic); !errors.Is(err, ErrCommandsNeedRun) { t.Errorf("instance with commands: %v, want ErrCommandsNeedRun", err) } if err := mint("hello", svcrole.Admin, importClassic); !errors.Is(err, ErrCommandsNeedRun) { t.Errorf("admin with commands: %v, want ErrCommandsNeedRun", err) } if err := mint("", svcrole.Run, importClassic); !errors.Is(err, ErrAccountScopedRun) { t.Errorf("run with no app: %v, want ErrAccountScopedRun", err) } if err := mint("hello", svcrole.Run, [][]string{{"import"}, {}}); !errors.Is(err, runs.ErrEmptyCommand) { t.Errorf("run with a zero-word prefix: %v, want runs.ErrEmptyCommand", err) } if err := mint("hello", svcrole.Run, [][]string{{"import", ""}}); !errors.Is(err, runs.ErrEmptyCommandWord) { t.Errorf("run with an empty word: %v, want runs.ErrEmptyCommandWord", err) } // Rungs and commands are separate fields: a run credential takes no rungs. if _, err := st.NewServicePrincipalScoped(1, "x", "hello", svcrole.Run, []string{"edge"}, importClassic, "paul@example.test", now); !errors.Is(err, ErrRungsNeedDeploy) { t.Errorf("run with rungs: %v, want ErrRungsNeedDeploy", err) } // The old entry point carries no commands, so it cannot mint a run credential. if _, err := st.NewServicePrincipal(1, "x", "hello", svcrole.Run, "paul@example.test", now); !errors.Is(err, ErrRunNeedsCommands) { t.Errorf("NewServicePrincipal(run): %v, want ErrRunNeedsCommands", err) } if rows, err := st.ServicePrincipals(1); err != nil || len(rows) != 0 { t.Fatalf("ServicePrincipals = %d rows %v after refused mints, want none", len(rows), err) } } // TestRunCredentialRefusedWithAnEmptyPrefix: a stored run row whose // commands are missing, unparseable, empty, non-canonical or hold a // zero-word prefix is refused outright, never read as an unscoped principal. func TestRunCredentialRefusedWithAnEmptyPrefix(t *testing.T) { st := newDeployStore(t) now := time.Now() for i, stored := range []string{ `[[]]`, `[["import"],[]]`, `[null]`, ``, `null`, `[]`, `not json`, `[["import",""]]`, `[["import", "classic"]]`, // parses, but not the canonical form this console writes `{"import":"classic"}`, } { raw := mintRun(t, st, "run"+string(rune('a'+i)), importClassic, now) if _, err := st.db.Exec(`UPDATE cli_tokens SET commands = ? WHERE token_hash = ?`, stored, hashToken(raw)); err != nil { t.Fatal(err) } if p, ok := st.CLIPrincipal(raw, now); ok { t.Errorf("commands %q: resolved to %+v; want refused", stored, p) } } // A run row with no credential id is refused: Task 3 keys runs on it. raw := mintRun(t, st, "noid", importClassic, now) if _, err := st.db.Exec(`UPDATE cli_tokens SET credential_id = '' WHERE token_hash = ?`, hashToken(raw)); err != nil { t.Fatal(err) } if p, ok := st.CLIPrincipal(raw, now); ok { t.Errorf("a run row with no credential id resolved to %+v; want refused", p) } // A run row whose app was emptied is refused: never account-wide. raw = mintRun(t, st, "noapp", importClassic, now) if _, err := st.db.Exec(`UPDATE cli_tokens SET app = '' WHERE token_hash = ?`, hashToken(raw)); err != nil { t.Fatal(err) } if p, ok := st.CLIPrincipal(raw, now); ok { t.Errorf("a run row with app '' resolved to %+v; want refused", p) } // Any other role carrying commands is refused too. pub, err := st.NewServicePrincipalScoped(1, "pub", "hello", svcrole.Publish, nil, nil, "paul@example.test", now) if err != nil { t.Fatal(err) } if _, err := st.db.Exec(`UPDATE cli_tokens SET commands = '[["import"]]' WHERE token_hash = ?`, hashToken(pub)); err != nil { t.Fatal(err) } if p, ok := st.CLIPrincipal(pub, now); ok { t.Errorf("a publish row with commands resolved to %+v; want refused", p) } } func TestRotationKeepsCredentialIDAndCommands(t *testing.T) { st := newDeployStore(t) now := time.Now() raw := mintRun(t, st, "importer", importClassic, now) before, ok := st.CLIPrincipal(raw, now) if !ok { t.Fatal("does not resolve") } fresh, err := st.RotateServicePrincipal(1, "importer", now.Add(time.Minute)) if err != nil { t.Fatal(err) } if _, ok := st.CLIPrincipal(raw, now); ok { t.Fatal("the old secret still resolves after rotate") } after, ok := st.CLIPrincipal(fresh, now) if !ok { t.Fatal("the rotated secret does not resolve") } if after.CredentialID != before.CredentialID { t.Fatalf("rotation moved the credential id: %q -> %q", before.CredentialID, after.CredentialID) } if !reflect.DeepEqual(after.Commands, importClassic) { t.Fatalf("rotation changed the commands: %q", after.Commands) } } func TestRemintGetsANewCredentialID(t *testing.T) { st := newDeployStore(t) now := time.Now() old := mintRun(t, st, "importer", importClassic, now) oldID := credentialIDOf(t, st, old) if err := st.RevokeServicePrincipal(1, "importer", now); err != nil { t.Fatal(err) } fresh := mintRun(t, st, "importer", importClassic, now) p, ok := st.CLIPrincipal(fresh, now) if !ok { t.Fatal("the re-minted credential does not resolve") } if p.CredentialID == "" || p.CredentialID == oldID { t.Fatalf("re-minted credential id = %q, old = %q; want a fresh one", p.CredentialID, oldID) } } // TestCredentialIDBackfillIsIdempotent: rows written before the column // existed (an empty credential_id) get a fresh, distinct id at Open, and a // later Open keeps every id it finds. func TestCredentialIDBackfillIsIdempotent(t *testing.T) { path := t.TempDir() + "/console.db" st, err := Open(path) if err != nil { t.Fatal(err) } now := time.Now() svc, err := st.NewServicePrincipalScoped(1, "pub", "hello", svcrole.Publish, nil, nil, "paul@example.test", now) if err != nil { t.Fatal(err) } app, err := st.NewAppToken(1, "hello", "paul@example.test", "router", now) if err != nil { t.Fatal(err) } user := mintUserToken(t, st, "paul@example.test", now) // Pretend these were written by a console before the column existed. if _, err := st.db.Exec(`UPDATE cli_tokens SET credential_id = ''`); err != nil { t.Fatal(err) } st.Close() st, err = Open(path) if err != nil { t.Fatal(err) } svcID, appID, userID := credentialIDOf(t, st, svc), credentialIDOf(t, st, app), credentialIDOf(t, st, user) if svcID == "" || appID == "" { t.Fatalf("backfill left a service row without an id: service %q, app %q", svcID, appID) } if svcID == appID { t.Fatal("backfill gave two rows one id") } if userID != "" { t.Fatalf("backfill gave a person's token an id %q", userID) } st.Close() st, err = Open(path) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) if got := credentialIDOf(t, st, svc); got != svcID { t.Fatalf("a second Open moved the service id: %q -> %q", svcID, got) } if got := credentialIDOf(t, st, app); got != appID { t.Fatalf("a second Open moved the app id: %q -> %q", appID, got) } if p, ok := st.CLIPrincipal(app, now); !ok || p.CredentialID != appID { t.Fatalf("legacy app principal = %+v, %v; want CredentialID %q", p, ok, appID) } if p, ok := st.CLIPrincipal(user, now); !ok || p.CredentialID != "" { t.Fatalf("person principal = %+v, %v; want no CredentialID", p, ok) } } func TestAPIMintRun(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.CreateAppIn(OpsAccountID, "hello", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-run-mint") u := fx.ts.URL + "/api/cli/services/bab" status, out := postJSON(t, u, map[string]any{"name": "importer", "role": "run", "app": "hello", "commands": [][]string{{"import", "classic"}, {"import", "sync"}}}, token) if status != http.StatusCreated { t.Fatalf("mint run = %d %v", status, out) } if got := out["commands"]; !reflect.DeepEqual(got, []any{[]any{"import", "classic"}, []any{"import", "sync"}}) { t.Fatalf("commands in response = %#v", got) } for _, tc := range []struct { body map[string]any want string }{ {map[string]any{"name": "a1", "role": "run", "commands": importClassic}, "must name one app"}, {map[string]any{"name": "a2", "role": "run", "app": "hello"}, "must name the commands it may run"}, {map[string]any{"name": "a3", "role": "run", "app": "hello", "commands": [][]string{{}}}, "a command prefix has no words"}, {map[string]any{"name": "a4", "role": "run", "app": "hello", "commands": [][]string{{"import", ""}}}, "empty word"}, {map[string]any{"name": "a5", "role": "publish", "app": "hello", "commands": importClassic}, "only a run credential takes commands"}, } { status, out := postJSON(t, u, tc.body, token) if status != http.StatusBadRequest || !strings.Contains(str(t, out, "error"), tc.want) { t.Errorf("%v = %d %v; want 400 containing %q", tc.body, status, out, tc.want) } } if status, out := postJSON(t, u, map[string]any{"name": "pub", "role": "publish", "app": "hello"}, token); status != http.StatusCreated { t.Fatalf("mint publish = %d %v", status, out) } else if c, ok := out["commands"].([]any); !ok || len(c) != 0 { t.Fatalf("publish mint commands = %#v, want []", out["commands"]) } status, out = getJSON(t, u, token) if status != http.StatusOK { t.Fatalf("list = %d %v", status, out) } rows, _ := out["services"].([]any) if len(rows) != 2 { t.Fatalf("listing = %v", rows) } for _, r := range rows { row := r.(map[string]any) c, ok := row["commands"].([]any) switch row["name"] { case "importer": if !reflect.DeepEqual(c, []any{[]any{"import", "classic"}, []any{"import", "sync"}}) { t.Errorf("listing commands for importer = %#v", row["commands"]) } case "pub": if !ok || len(c) != 0 { t.Errorf("listing commands for pub = %#v, want []", row["commands"]) } } } } func TestFormMintRun(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) before, err := fx.st.ServicePrincipals(OpsAccountID) if err != nil { t.Fatal(err) } for _, bad := range []struct { why string form url.Values want string }{ {"no app", url.Values{"csrf": {csrf}, "name": {"imp"}, "role": {"run"}, "commands": {"import classic"}}, "A run credential must name one app. Nothing was minted."}, {"no commands", url.Values{"csrf": {csrf}, "name": {"imp"}, "role": {"run"}, "app": {"kass"}}, "A run credential must name the commands it may run"}, {"only commas", url.Values{"csrf": {csrf}, "name": {"imp"}, "role": {"run"}, "app": {"kass"}, "commands": {" , ,"}}, "A run credential must name the commands it may run"}, {"a word too long", url.Values{"csrf": {csrf}, "name": {"imp"}, "role": {"run"}, "app": {"kass"}, "commands": {"import " + strings.Repeat("x", 129)}}, "128 bytes"}, {"commands on publish", url.Values{"csrf": {csrf}, "name": {"imp"}, "role": {"publish"}, "app": {"kass"}, "commands": {"import classic"}}, "Only a run credential takes commands. Nothing was minted."}, } { status, loc, body := postForm(t, c, fx.u("/services/new"), bad.form) if strings.HasPrefix(loc, fx.fk.srv.URL) { t.Errorf("%s cost a passkey trip (-> %q)", bad.why, loc) } if status != http.StatusSeeOther || loc != fx.path("/settings") { t.Errorf("%s = %d -> %q, want 303 back to the page:\n%s", bad.why, status, loc, body) continue } _, _, page := get(t, c, fx.u("/settings")) if !strings.Contains(page, bad.want) { t.Errorf("%s: the settings page does not say %q:\n%s", bad.why, bad.want, page) } rows, err := fx.st.ServicePrincipals(OpsAccountID) if err != nil || len(rows) != len(before) { t.Errorf("%s: ServicePrincipals = %d rows %v, want %d (nothing minted)", bad.why, len(rows), err, len(before)) } } // The form offers the field. _, _, page := get(t, c, fx.u("/settings")) if !strings.Contains(page, `name="commands"`) { t.Fatalf("the mint form has no commands field:\n%s", page) } status, _, body := postGated(t, fx, c, fx.u("/services/new"), url.Values{"csrf": {csrf}, "name": {"imp"}, "role": {"run"}, "app": {"kass"}, "commands": {"import classic, import sync"}}) if status != http.StatusOK { t.Fatalf("run mint = %d:\n%s", status, body) } if !strings.Contains(body, "commands import classic, import sync") { t.Fatalf("the secret banner does not name the commands:\n%s", body) } secret := paneSecret(t, body) p, ok := fx.st.CLIPrincipal(secret, time.Now()) if !ok || !reflect.DeepEqual(p.Commands, [][]string{{"import", "classic"}, {"import", "sync"}}) { t.Fatalf("minted credential resolves to %+v, %v", p, ok) } _, _, page = get(t, c, fx.u("/settings")) if !strings.Contains(page, "import classic, import sync") { t.Fatalf("the credential listing does not show the commands:\n%s", page) } } // badCommandWords are words neither mint door may store. Through the JSON // API each reaches the store as one word; "\xff" arrives as U+FFFD, which // is how the decoder replaces invalid UTF-8, and is refused as such. var badCommandWords = map[string]string{ "a space": "import classic", "a comma": "import,classic", "ESC": "\x1b[8mclassic", "invalid UTF-8": "\xff", "a tab": "import\tclassic", } func TestAPIMintRunRefusesUnprintableWords(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.CreateAppIn(OpsAccountID, "hello", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-run-words") u := fx.ts.URL + "/api/cli/services/bab" for why, w := range badCommandWords { status, out := postJSON(t, u, map[string]any{"name": "imp", "role": "run", "app": "hello", "commands": [][]string{{"import", w}}}, token) if status != http.StatusBadRequest || !strings.Contains(str(t, out, "error"), "cannot be printed") { t.Errorf("a word with %s = %d %v; want 400 naming the character rule", why, status, out) } } if rows, err := fx.st.ServicePrincipals(OpsAccountID); err != nil || len(rows) != 0 { t.Fatalf("ServicePrincipals = %d rows %v, want nothing minted", len(rows), err) } } // The form's field is text, where a space, a comma or a tab separates // words and prefixes, so those three can never reach a word through it: // they split instead (pinned below). ESC, invalid UTF-8 and a zero-width // space do reach a word, and are refused before the passkey. func TestFormMintRunRefusesUnprintableWords(t *testing.T) { fx := newConsoleFixture(t) c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) for why, field := range map[string]string{ "ESC": "import \x1b[8mclassic", "invalid UTF-8": "import \xff", "a zero-width space": "import\u200bclassic", } { status, loc, body := postForm(t, c, fx.u("/services/new"), url.Values{"csrf": {csrf}, "name": {"imp"}, "role": {"run"}, "app": {"kass"}, "commands": {field}}) if strings.HasPrefix(loc, fx.fk.srv.URL) { t.Errorf("%s cost a passkey trip (-> %q)", why, loc) } if status != http.StatusSeeOther || loc != fx.path("/settings") { t.Errorf("%s = %d -> %q, want 303 back to the page:\n%s", why, status, loc, body) continue } _, _, page := get(t, c, fx.u("/settings")) if !strings.Contains(page, "A command word holds a space, a comma or a character that cannot be printed. Nothing was minted.") { t.Errorf("%s: the settings page does not give the character rule:\n%s", why, page) } } if rows, err := fx.st.ServicePrincipals(OpsAccountID); err != nil || len(rows) != 0 { t.Fatalf("ServicePrincipals = %d rows %v, want nothing minted", len(rows), err) } // A space, comma or tab in the field separates; it never lands in a word. status, _, body := postGated(t, fx, c, fx.u("/services/new"), url.Values{"csrf": {csrf}, "name": {"imp"}, "role": {"run"}, "app": {"kass"}, "commands": {"import\tclassic,import sync"}}) if status != http.StatusOK { t.Fatalf("run mint = %d:\n%s", status, body) } p, ok := fx.st.CLIPrincipal(paneSecret(t, body), time.Now()) if !ok || !reflect.DeepEqual(p.Commands, [][]string{{"import", "classic"}, {"import", "sync"}}) { t.Fatalf("minted credential resolves to %+v, %v", p, ok) } } // TestStoredUnprintableWordIsRefusedAtSignIn: the read side runs the same // word check, so a row holding such a word never resolves. func TestStoredUnprintableWordIsRefusedAtSignIn(t *testing.T) { st := newDeployStore(t) now := time.Now() raw := mintRun(t, st, "imp", importClassic, now) if _, err := st.db.Exec(`UPDATE cli_tokens SET commands = ? WHERE token_hash = ?`, `[["import","a\u001bb"]]`, hashToken(raw)); err != nil { t.Fatal(err) } if p, ok := st.CLIPrincipal(raw, now); ok { t.Fatalf("a stored word with ESC resolved to %+v; want refused", p) } } func TestCredentialIDIsUnique(t *testing.T) { st := newDeployStore(t) now := time.Now() a := mintRun(t, st, "a", importClassic, now) b, err := st.NewServicePrincipalScoped(1, "b", "hello", svcrole.Publish, nil, nil, "paul@example.test", now) if err != nil { t.Fatal(err) } if _, err := st.db.Exec(`UPDATE cli_tokens SET credential_id = ? WHERE token_hash = ?`, credentialIDOf(t, st, a), hashToken(b)); err == nil { t.Fatal("two rows were given one credential id; want the unique index to refuse it") } // Any number of people's tokens keep the empty id side by side. for i := 0; i < 3; i++ { mintUserToken(t, st, "paul@example.test", now) } var empty int if err := st.db.QueryRow(`SELECT COUNT(*) FROM cli_tokens WHERE credential_id = ''`).Scan(&empty); err != nil || empty != 3 { t.Fatalf("rows with an empty id = %d, %v; want 3", empty, err) } } // TestUnreadableRunCommandsAreSaidSo: a run row whose stored list cannot // be read is refused at sign-in, so the listing says that rather than // showing it as a credential with no commands. func TestUnreadableRunCommandsAreSaidSo(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.CreateAppIn(OpsAccountID, "hello", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } if _, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "mangled", "hello", svcrole.Run, nil, importClassic, "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } if _, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "pub", "hello", svcrole.Publish, nil, nil, "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } if _, err := fx.st.db.Exec(`UPDATE cli_tokens SET commands = '[[]]' WHERE name = 'mangled'`); err != nil { t.Fatal(err) } token := cliToken(t, fx, "paul@keymail.dev", "code-run-unreadable") status, out := getJSON(t, fx.ts.URL+"/api/cli/services/bab", token) if status != http.StatusOK { t.Fatalf("list = %d %v", status, out) } for _, r := range out["services"].([]any) { row := r.(map[string]any) switch row["name"] { case "mangled": if row["commands_unreadable"] != true { t.Errorf("mangled row = %v; want commands_unreadable true", row) } if c, ok := row["commands"].([]any); !ok || len(c) != 0 { t.Errorf("mangled commands = %#v; want []", row["commands"]) } case "pub": if _, ok := row["commands_unreadable"]; ok { t.Errorf("pub row carries commands_unreadable: %v", row) } } } c := signInPaul(t, fx) _, _, page := get(t, c, fx.u("/settings")) if !strings.Contains(page, "(unreadable — refused at sign-in)") { t.Fatalf("the settings listing does not say the commands are unreadable:\n%s", page) } if n := strings.Count(page, "unreadable — refused"); n != 1 { t.Fatalf("the unreadable note appears %d times, want once (only the run row)", n) } }