Skip to content

A project's last environment can be removed via the Admin API (400 guard and its e2e test removed in #9427) #12560

Description

@truecourse-agent

Summary

DELETE /api/admin/projects/:projectId/environments/:environment will remove a project's last environment and answer 200, leaving the project with zero environments. The guard that used to refuse this with 400 You must always have one active environment was removed in #9427, together with the e2e test that pinned it. The invariant is still enforced in the frontend, still stated in the docs, and the error class it used is still in the tree with no callers.

Tested on main at 3d91a51635859f13a5bc23547d323c19718629fb (unleash-server 8.1.0), OSS, stock config, Postgres 15, admin token.

Reproduction

Against a fresh instance (default project, environments development + production):

POST /api/admin/projects/default/environments
body: {"environment":"tcref-nowhere"}
-> 400
{"name":"BadDataError","message":"Request validation failed: your request body or params contain invalid data: Environment tcref-nowhere does not exist","details":[{"message":"Environment tcref-nowhere does not exist"}]}

DELETE /api/admin/projects/default/environments/production
-> 200
(empty body)

GET /api/admin/projects/default/overview
-> 200
{... "environments":[{"environment":"development"}] ...}

DELETE /api/admin/projects/default/environments/development     <-- the last one
-> 200
(empty body)

The two DELETEs are indistinguishable: same status, same empty body. Afterwards, on a hand re-run of the same sequence on a clean instance:

GET /api/admin/projects/default/overview
-> "environments":[]

select * from project_environments where project_id = 'default';
-> 0 rows

The first request above is the control: the endpoint's other validation still works, so this is not a case of the route ignoring its input.

What the project looks like with zero environments

Not bricked (an environment can be added back with POST /api/admin/projects/default/environments), but in between:

POST /api/admin/projects/default/features  -> 201     (flag accepted)

GET  /api/admin/projects/default/features/<flag>
-> "environments":[{"name":null,"lastSeenAt":null,"enabled":null,"yes":0,"no":0,"type":null,"sortOrder":null,"strategies":[]}]

POST /api/admin/projects/default/features/<flag>/environments/development/on
-> 404 {"name":"NotFoundError","message":"Could not find environment development for feature: <flag>", ...}

So the project accepts flags it can never enable, and serves a null-named environment entry in the flag response.

Where the check went

src/lib/features/project-environments/environment-service.ts:281-297 at this SHA:

    async removeEnvironmentFromProject(
        environment: string,
        projectId: string,
        auditUser: IAuditUser,
    ): Promise<void> {
        const _projectEnvs =
            await this.projectStore.getEnvironmentsForProject(projectId);

        await this.forceRemoveEnvironmentFromProject(environment, projectId);
        await this.eventService.storeEvent(
            new ProjectEnvironmentRemoved({
                project: projectId,
                environment,
                auditUser,
            }),
        );
    }

The current environment list is fetched and then never read (_projectEnvs). git log -L 281,297:... points at e7ac420, "feat: project environments include visible property (#9427)", merged 2025-03-05:

-        if (projectEnvs.length > 1) {
-            await this.forceRemoveEnvironmentFromProject(
+        await this.forceRemoveEnvironmentFromProject(environment, projectId);
+        await this.eventService.storeEvent(
+            new ProjectEnvironmentRemoved({
+                project: projectId,
                 environment,
-                projectId,
-            );
-            ...
-            return;
-        }
-        throw new MinimumOneEnvironmentError(
-            'You must always have one active environment',
+                auditUser,
+            }),

The same commit deleted the test that pinned the behaviour, in src/lib/features/project-environments/environments.e2e.test.ts:

-test('Should not remove environment from project if project only has one environment enabled', async () => {
-    await app.request
-        .delete(`/api/admin/projects/default/environments/default`)
-        .expect(400)
-        .expect((r) => {
-            expect(r.body.details[0].message).toBe(
-                'You must always have one active environment',
-            );
-        });
-
-    const envs =
-        await db.stores.projectStore.getEnvironmentsForProject('default');
-
-    expect(envs).toHaveLength(1);
-});

Given that the PR's stated purpose is adding a visible property to a response shape, this looks unintended rather than a deliberate relaxation.

Why it reads as a regression rather than an intentional change

Three things still assert the invariant:

  1. The UI. frontend/src/component/project/ProjectEnvironment/ProjectEnvironment.tsx:156-172 disables the toggle and shows "Cannot disable, at least one environment must be visible in the project" when only one environment is visible. Any client that is not that toggle is accepted.
  2. The docs. https://docs.getunleash.io/concepts/environments still says: "The set of all available environments is defined at the instance level. Additionally, each project can choose which of the environments are visible on the project level. The set of environments available to any given project is always a subset of the environments at the instance level. Each project must always have at least one active environment."
  3. The error class. src/lib/error/minimum-one-environment-error.ts is still present and exported; grep -rn 'MinimumOneEnvironment' src/ finds only its own definition, no throw site.

Suggestion

Restore the length check in removeEnvironmentFromProject (the fetched list is already there) and re-add the deleted e2e case. If removing the last environment is in fact intended now, the frontend guard, the error class and the docs sentence all need to change with it.

Suggested labels: bug

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    New

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions