console: CLI create door refuses reserved app names too
claimApp's switch in handleAPICreateApp had no claimReserved case, so `carlos apps create -app new` got 200 OK with a zero-length body — a refusal wearing a success status. Add the missing case (409, "that name is reserved"), a default case so a future claimOutcome can't silently 200 either, and a refusal-matrix test leg covering it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+13
−0
internal/console/apicli.go+6 −0internal/console/apicli_test.go+7 −0
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index 7afd4bb13..101bccba3 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -170,6 +170,8 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) { |
| case claimInvalid: |
| jsonError(w, http.StatusBadRequest, |
| "invalid app name — lowercase letters, digits, and dashes, starting with a letter") |
| + case claimReserved: |
| + jsonError(w, http.StatusConflict, "that name is reserved") |
| case claimTaken: |
| jsonError(w, http.StatusConflict, "that name is taken") |
| case claimCheckFailed, claimCreateFailed: |
| @@ -184,6 +186,10 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) { |
| "account": acct.Sqid, "app": name, |
| "url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(name), |
| }) |
| + default: |
| + // Any future claimOutcome lands here rather than silently 200ing |
| + // with an empty body — see the reserved-name finding this guards. |
| + jsonError(w, http.StatusInternalServerError, "could not create the app — try again") |
| } |
| } |
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go| index 6efab0315..1f0407a8b 100644 |
| --- a/internal/console/apicli_test.go |
| +++ b/internal/console/apicli_test.go |
| @@ -2043,6 +2043,13 @@ func TestAPICreateAppRefusalMatrix(t *testing.T) { |
| if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "9lives"}, token); status != http.StatusBadRequest { |
| t.Fatalf("invalid name = %d %v, want 400", status, out) |
| } |
| + // "new" is the literal name the console's own routes shadow — claimApp's |
| + // claimReserved leg, refused before any store read/write. |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "new"}, token); status != http.StatusConflict { |
| + t.Fatalf("reserved name = %d %v, want 409", status, out) |
| + } else if !strings.Contains(str(t, out, "error"), "reserved") { |
| + t.Fatalf("reserved name error = %v, want it to say reserved", out) |
| + } |
| // kass is the fixture's already-claimed app. |
| if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "kass"}, token); status != http.StatusConflict { |
| t.Fatalf("claimed name = %d %v, want 409", status, out) |