| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "reflect" |
| 6 | "sort" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/svcrole" |
| 11 | ) |
| 12 | |
| 13 | // deployDoors is spec §6, verbatim. A door added later is refused to a |
| 14 | // deploy credential unless someone adds it here on purpose. |
| 15 | var deployDoors = []string{ |
| 16 | "GET /api/cli/apps", |
| 17 | "GET /api/cli/apps/{account}/{app}/channels", |
| 18 | "POST /api/cli/apps/{account}/{app}/releases", |
| 19 | "GET /api/cli/apps/{account}/{app}/releases", |
| 20 | "POST /api/cli/apps/{account}/{app}/promote", |
| 21 | "POST /api/cli/apps/{account}/{app}/rollback", |
| 22 | "GET /api/cli/apps/{account}/{app}/tags", |
| 23 | "GET /api/cli/apps/{account}/{app}/tags/{version}", |
| 24 | "GET /api/cli/apps/{account}/{app}/version-target", |
| 25 | "GET /api/cli/apps/{account}/{app}/pipeline", |
| 26 | "GET /api/cli/apps/{account}/{app}/pending", |
| 27 | "GET /api/cli/apps/{account}/{app}/instances", // R6: carlos deploy |
| 28 | "GET /api/cli/apps/{account}/{app}/regions", // R6: carlos deploy --region |
| 29 | } |
| 30 | |
| 31 | func TestDeployReachesExactlyItsDoors(t *testing.T) { |
| 32 | s := newConsoleFixture(t).srv |
| 33 | var got []string |
| 34 | for pattern, set := range s.cliRoles { |
| 35 | if set.Permits(svcrole.Deploy) { |
| 36 | got = append(got, pattern) |
| 37 | } |
| 38 | } |
| 39 | sort.Strings(got) |
| 40 | want := append([]string(nil), deployDoors...) |
| 41 | sort.Strings(want) |
| 42 | if !reflect.DeepEqual(got, want) { |
| 43 | t.Fatalf("deploy reaches:\n got %q\nwant %q", got, want) |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | // Review Focus 2: the app binding holds for deploy like any service. |
| 48 | func TestDeployIsBoundToItsApp(t *testing.T) { |
| 49 | fx := newConsoleFixture(t) |
| 50 | for _, app := range []string{"hello", "other"} { |
| 51 | if _, err := fx.st.CreateAppIn(OpsAccountID, app, "paul@keymail.dev", time.Now()); err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | } |
| 55 | tok, err := fx.st.NewServicePrincipalScoped(OpsAccountID, "ci", "hello", svcrole.Deploy, []string{"edge"}, nil, "paul@keymail.dev", time.Now()) |
| 56 | if err != nil { |
| 57 | t.Fatal(err) |
| 58 | } |
| 59 | // Sibling app: deploy reaches "other" (exists) but its token is bound to "hello" |
| 60 | statusA, bodyA := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/other/promote", |
| 61 | map[string]string{"version": "v1", "channel": "edge"}, tok) |
| 62 | if statusA != http.StatusNotFound { |
| 63 | t.Fatalf("deploy on a sibling app = %d, want 404", statusA) |
| 64 | } |
| 65 | // Nonexistent app: deploy reaches "nosuchapp" (does not exist) on the same account |
| 66 | statusB, bodyB := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/nosuchapp/promote", |
| 67 | map[string]string{"version": "v1", "channel": "edge"}, tok) |
| 68 | if statusB != http.StatusNotFound { |
| 69 | t.Fatalf("deploy on a nonexistent app = %d, want 404", statusB) |
| 70 | } |
| 71 | // Both refusals must be byte-identical to avoid leaking whether "other" exists |
| 72 | if bodyA != bodyB { |
| 73 | t.Fatalf("app-binding 404 is not identical to nonexistent-app 404:\n(sibling) %q\n(nonexistent) %q", bodyA, bodyB) |
| 74 | } |
| 75 | // And a door outside §6 is the role gate's 403, not a 404. |
| 76 | status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/hello/delete", map[string]string{}, tok) |
| 77 | if status != http.StatusForbidden { |
| 78 | t.Fatalf("deploy on trash = %d %v, want 403", status, out) |
| 79 | } |
| 80 | } |
| 81 | |