diff --git a/CHANGELOG.md b/CHANGELOG.md index 25f5779..fa7035e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.3.1] - 2026-07-12 + +### Fixed + +- `POST /v1/apps` now requires `org_slug`, returning `400` to match real Fly, + which rejects app creation without it; the emulator was previously too lenient + and accepted the request, letting a plan that would fail against Fly pass + offline. + ## [0.3.0] - 2026-07-11 Breadth: the full app-resource surface (volumes, secrets, IPs, certificates) plus @@ -48,7 +57,7 @@ Fidelity and correctness pass from an adversarial audit against `superfly/fly-go - `/wait` honors fly-go's `version` filter and a repeatable `state` param (any requested state satisfies the wait); `instance_id` still accepted. - `cordoned` is surfaced on the machine object (`json:"cordoned"`). -- `signal`, `exec`, and `ps` answer an honest `501` and appear in +- `signal`, `exec`, and `ps` answer a plain `501` and appear in `/_mudflaps/health` instead of falling through to a bare `404`. - Response shapes match flaps: create-machine returns `200`, `start` returns a `MachineStartResponse`, and stop/restart/suspend/cordon/delete return an empty @@ -87,7 +96,8 @@ Fidelity and correctness pass from an adversarial audit against `superfly/fly-go - Distroless container image, GoReleaser configuration, mkdocs-material doc site, and CI. -[Unreleased]: https://github.com/intentius/mudflaps/compare/v0.3.0...HEAD +[Unreleased]: https://github.com/intentius/mudflaps/compare/v0.3.1...HEAD +[0.3.1]: https://github.com/intentius/mudflaps/compare/v0.3.0...v0.3.1 [0.3.0]: https://github.com/intentius/mudflaps/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/intentius/mudflaps/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/intentius/mudflaps/releases/tag/v0.1.0 diff --git a/internal/server/server.go b/internal/server/server.go index 8f2fbb1..0174928 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -217,6 +217,10 @@ func (s *Server) createApp(w http.ResponseWriter, r *http.Request) { s.writeError(w, http.StatusBadRequest, "app_name is required") return } + if req.OrgSlug == "" { + s.writeError(w, http.StatusBadRequest, "authorization: request doesn't contain org_slug") + return + } app, err := s.store.CreateApp(flaps.App{Name: req.AppName, Organization: req.OrgSlug}) if errors.Is(err, store.ErrAppExists) { s.writeError(w, http.StatusConflict, "app already exists") diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 7a920f2..1565177 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -66,7 +66,7 @@ func (h *harness) mustJSON(raw []byte, dst any) { // createStartedMachine creates an app+machine and drives it to started. func (h *harness) createStartedMachine(app string) flaps.Machine { h.t.Helper() - if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: app}, nil); code != http.StatusCreated { + if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: app, OrgSlug: "personal"}, nil); code != http.StatusCreated { h.t.Fatalf("create app: %d %s", code, body) } code, body := h.do(http.MethodPost, "/v1/apps/"+app+"/machines", flaps.CreateMachineRequest{ @@ -132,6 +132,19 @@ func TestCreateAndWaitStarted(t *testing.T) { } } +// TestCreateAppRequiresOrgSlug mirrors real Fly, which rejects app creation +// without an org_slug (400) rather than silently creating the app. +func TestCreateAppRequiresOrgSlug(t *testing.T) { + h := newHarness(t) + code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "x"}, nil) + if code != http.StatusBadRequest { + t.Fatalf("create app without org_slug = %d %s, want 400", code, body) + } + if !strings.Contains(string(body), "org_slug") { + t.Fatalf("error body = %s, want it to mention org_slug", body) + } +} + func TestLeaseBlocksMutationWithoutNonce(t *testing.T) { h := newHarness(t) m := h.createStartedMachine("demo") @@ -460,7 +473,7 @@ func TestStopAcceptsDurationStringTimeout(t *testing.T) { // TestSkipLaunchRestsInCreated is the regression for audit M1. func TestSkipLaunchRestsInCreated(t *testing.T) { h := newHarness(t) - if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo"}, nil); code != http.StatusCreated { + if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo", OrgSlug: "personal"}, nil); code != http.StatusCreated { t.Fatalf("create app = %d %s", code, body) } code, body := h.do(http.MethodPost, "/v1/apps/demo/machines", @@ -589,7 +602,7 @@ func TestDestroyAndWaitDestroyed(t *testing.T) { // multi-second real sleep. func TestWaitTimesOut(t *testing.T) { h := newHarness(t) - if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo"}, nil); code != http.StatusCreated { + if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo", OrgSlug: "personal"}, nil); code != http.StatusCreated { t.Fatalf("create app = %d %s", code, body) } // skip_launch leaves the machine in 'created', so it never reaches 'started'. @@ -752,7 +765,7 @@ func TestDeleteAppWithLeasedMachine(t *testing.T) { // data has no description field. func TestResponseShapesMatchFlaps(t *testing.T) { h := newHarness(t) - if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo"}, nil); code != http.StatusCreated { + if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo", OrgSlug: "personal"}, nil); code != http.StatusCreated { t.Fatalf("create app = %d %s", code, body) } // create machine -> 200 (not 201) @@ -822,7 +835,7 @@ func TestPlatformRegions(t *testing.T) { // TestVolumeCRUD covers the volume endpoints (breadth #18). func TestVolumeCRUD(t *testing.T) { h := newHarness(t) - if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo"}, nil); code != http.StatusCreated { + if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo", OrgSlug: "personal"}, nil); code != http.StatusCreated { t.Fatalf("create app = %d %s", code, body) } // empty list, not 501 @@ -876,7 +889,7 @@ func TestVolumeCRUD(t *testing.T) { // delete, and the invariant that a value is never returned. func TestSecretsApplyOnly(t *testing.T) { h := newHarness(t) - if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo"}, nil); code != http.StatusCreated { + if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo", OrgSlug: "personal"}, nil); code != http.StatusCreated { t.Fatalf("create app = %d %s", code, body) } // set @@ -919,7 +932,7 @@ func TestSecretsApplyOnly(t *testing.T) { // TestIPAssignments covers the ip_assignments endpoints (breadth #21). func TestIPAssignments(t *testing.T) { h := newHarness(t) - if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo"}, nil); code != http.StatusCreated { + if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo", OrgSlug: "personal"}, nil); code != http.StatusCreated { t.Fatalf("create app = %d %s", code, body) } // shared v4 @@ -957,7 +970,7 @@ func TestIPAssignments(t *testing.T) { // TestCertificates covers the certificate endpoints (breadth #20). func TestCertificates(t *testing.T) { h := newHarness(t) - if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo"}, nil); code != http.StatusCreated { + if code, body := h.do(http.MethodPost, "/v1/apps", flaps.CreateAppRequest{AppName: "demo", OrgSlug: "personal"}, nil); code != http.StatusCreated { t.Fatalf("create app = %d %s", code, body) } // create