| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "reflect" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/svcrole" |
| 10 | ) |
| 11 | |
| 12 | func TestCanonicalRungs(t *testing.T) { |
| 13 | for _, tc := range []struct { |
| 14 | in []string |
| 15 | want string |
| 16 | }{ |
| 17 | {nil, ""}, |
| 18 | {[]string{" ", ""}, ""}, |
| 19 | {[]string{"edge"}, "edge"}, |
| 20 | {[]string{"edge", " canary ", "edge", ""}, "canary,edge"}, |
| 21 | {[]string{"beta", "canary", "edge"}, "canary,edge,beta"}, |
| 22 | } { |
| 23 | got, err := canonicalRungs(tc.in) |
| 24 | if err != nil || got != tc.want { |
| 25 | t.Errorf("canonicalRungs(%q) = %q, %v; want %q", tc.in, got, err, tc.want) |
| 26 | } |
| 27 | } |
| 28 | if _, err := canonicalRungs([]string{"edge", "stable"}); !errors.Is(err, ErrStableNotDelegable) { |
| 29 | t.Errorf("stable: err = %v, want ErrStableNotDelegable", err) |
| 30 | } |
| 31 | for _, bad := range []string{"Edge", "canary/x", "prod", "edge;beta"} { |
| 32 | var ue *UnknownRungError |
| 33 | if _, err := canonicalRungs([]string{bad}); !errors.As(err, &ue) || ue.Rung != bad { |
| 34 | t.Errorf("canonicalRungs(%q) err = %v, want UnknownRungError naming it", bad, err) |
| 35 | } |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | func TestParseStoredRungs(t *testing.T) { |
| 40 | if got, ok := parseStoredRungs("canary,edge"); !ok || !reflect.DeepEqual(got, []string{"canary", "edge"}) { |
| 41 | t.Fatalf("parseStoredRungs(canary,edge) = %v, %v", got, ok) |
| 42 | } |
| 43 | // Not canonical, empty, or naming stable: never a usable scope. |
| 44 | for _, bad := range []string{"", "edge,canary", "edge,edge", "edge,stable", "edge,", " edge"} { |
| 45 | if got, ok := parseStoredRungs(bad); ok { |
| 46 | t.Errorf("parseStoredRungs(%q) = %v, true; want false", bad, got) |
| 47 | } |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | func TestMintDeployRules(t *testing.T) { |
| 52 | st := newDeployStore(t) |
| 53 | now := time.Now() |
| 54 | mint := func(app string, role svcrole.Role, rungs []string) error { |
| 55 | _, err := st.NewServicePrincipalScoped(1, "ci", app, role, rungs, nil, "paul@example.test", now) |
| 56 | return err |
| 57 | } |
| 58 | if err := mint("", svcrole.Deploy, []string{"edge"}); !errors.Is(err, ErrAccountScopedDeploy) { |
| 59 | t.Errorf("deploy with no app: %v", err) |
| 60 | } |
| 61 | if err := mint("hello", svcrole.Deploy, nil); !errors.Is(err, ErrDeployNeedsRungs) { |
| 62 | t.Errorf("deploy with no rungs: %v", err) |
| 63 | } |
| 64 | if err := mint("hello", svcrole.Deploy, []string{"stable"}); !errors.Is(err, ErrStableNotDelegable) { |
| 65 | t.Errorf("deploy with stable: %v", err) |
| 66 | } |
| 67 | if err := mint("hello", svcrole.Publish, []string{"edge"}); !errors.Is(err, ErrRungsNeedDeploy) { |
| 68 | t.Errorf("publish with rungs: %v", err) |
| 69 | } |
| 70 | // The old entry point cannot mint a deploy credential: it has no rungs. |
| 71 | if _, err := st.NewServicePrincipal(1, "ci", "hello", svcrole.Deploy, "paul@example.test", now); !errors.Is(err, ErrDeployNeedsRungs) { |
| 72 | t.Errorf("NewServicePrincipal(deploy): %v", err) |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | func TestDeployPrincipalReadsBackItsRungs(t *testing.T) { |
| 77 | st := newDeployStore(t) |
| 78 | now := time.Now() |
| 79 | raw, err := st.NewServicePrincipalScoped(1, "ci", "hello", svcrole.Deploy, []string{"edge", "canary"}, nil, "paul@example.test", now) |
| 80 | if err != nil { |
| 81 | t.Fatal(err) |
| 82 | } |
| 83 | p, ok := st.CLIPrincipal(raw, now) |
| 84 | if !ok || p.Role != svcrole.Deploy || p.App != "hello" || !reflect.DeepEqual(p.Rungs, []string{"canary", "edge"}) { |
| 85 | t.Fatalf("CLIPrincipal = %+v, %v", p, ok) |
| 86 | } |
| 87 | // Review Focus 3: rotation keeps the rungs, and the old secret dies. |
| 88 | fresh, err := st.RotateServicePrincipal(1, "ci", now) |
| 89 | if err != nil { |
| 90 | t.Fatal(err) |
| 91 | } |
| 92 | if _, ok := st.CLIPrincipal(raw, now); ok { |
| 93 | t.Fatal("the old secret still resolves after rotate") |
| 94 | } |
| 95 | p, ok = st.CLIPrincipal(fresh, now) |
| 96 | if !ok || !reflect.DeepEqual(p.Rungs, []string{"canary", "edge"}) { |
| 97 | t.Fatalf("after rotate: %+v, %v", p, ok) |
| 98 | } |
| 99 | rows, err := st.ServicePrincipals(1) |
| 100 | if err != nil || len(rows) != 1 || rows[0].Rungs != "canary,edge" || rows[0].RungList() != "canary, edge" { |
| 101 | t.Fatalf("ServicePrincipals = %+v, %v", rows, err) |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | func TestDeployRowWithBadRungsDoesNotResolve(t *testing.T) { |
| 106 | st := newDeployStore(t) |
| 107 | now := time.Now() |
| 108 | for i, rungs := range []string{"", "edge,canary", "stable", "edge,beta,"} { |
| 109 | raw, err := st.NewServicePrincipalScoped(1, "ci"+string(rune('a'+i)), "hello", svcrole.Deploy, []string{"edge"}, nil, "paul@example.test", now) |
| 110 | if err != nil { |
| 111 | t.Fatal(err) |
| 112 | } |
| 113 | if _, err := st.db.Exec(`UPDATE cli_tokens SET rungs = ? WHERE token_hash = ?`, rungs, hashToken(raw)); err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | if p, ok := st.CLIPrincipal(raw, now); ok { |
| 117 | t.Errorf("rungs %q: resolved to %+v; want refused", rungs, p) |
| 118 | } |
| 119 | } |
| 120 | // A deploy row whose app was emptied (rungs still valid) is refused too: |
| 121 | // a deploy credential is never account-wide. |
| 122 | raw, err := st.NewServicePrincipalScoped(1, "noapp", "hello", svcrole.Deploy, []string{"edge"}, nil, "paul@example.test", now) |
| 123 | if err != nil { |
| 124 | t.Fatal(err) |
| 125 | } |
| 126 | if _, err := st.db.Exec(`UPDATE cli_tokens SET app = '' WHERE token_hash = ?`, hashToken(raw)); err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | if p, ok := st.CLIPrincipal(raw, now); ok { |
| 130 | t.Errorf("a deploy row with app '' resolved to %+v; want refused", p) |
| 131 | } |
| 132 | // A non-deploy row that somehow carries rungs is refused too. |
| 133 | raw, err = st.NewServicePrincipalScoped(1, "pub", "hello", svcrole.Publish, nil, nil, "paul@example.test", now) |
| 134 | if err != nil { |
| 135 | t.Fatal(err) |
| 136 | } |
| 137 | if _, err := st.db.Exec(`UPDATE cli_tokens SET rungs = 'edge' WHERE token_hash = ?`, hashToken(raw)); err != nil { |
| 138 | t.Fatal(err) |
| 139 | } |
| 140 | if _, ok := st.CLIPrincipal(raw, now); ok { |
| 141 | t.Error("a publish row carrying rungs resolved") |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | // newDeployStore is a bare console store, enough for the store-level tests. |
| 146 | func newDeployStore(t *testing.T) *Store { |
| 147 | t.Helper() |
| 148 | st, err := Open(t.TempDir() + "/console.db") |
| 149 | if err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | t.Cleanup(func() { st.Close() }) |
| 153 | return st |
| 154 | } |
| 155 | |