Files
multica/server/internal/handler/project_validation_test.go
beast 31d942d010 MUL-3438: fix(projects): require admin for project deletion (#4327)
* fix(projects): require admin for project deletion

* test(projects): clean up orphaned member rows in delete-permission helper

The schema uses no foreign keys or cascades, so deleting the test user
left its member row behind in the shared test workspace, polluting later
tests in the package. Delete the member row before the user in both the
pre-seed cleanup and t.Cleanup.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-21 23:54:58 +08:00

195 lines
6.7 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// An unknown project status must fail fast with a 400 and the valid list, not
// surface the DB CHECK violation as a 500 (#3925: `--status active`).
func TestCreateProjectInvalidStatusReturns400(t *testing.T) {
w := httptest.NewRecorder()
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "invalid status project",
"status": "active",
})
testHandler.CreateProject(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid status, got %d: %s", w.Code, w.Body.String())
}
if body := w.Body.String(); !strings.Contains(body, "planned") {
t.Errorf("expected error to list valid statuses, got: %s", body)
}
}
func TestCreateProjectInvalidPriorityReturns400(t *testing.T) {
w := httptest.NewRecorder()
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "invalid priority project",
"priority": "critical",
})
testHandler.CreateProject(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid priority, got %d: %s", w.Code, w.Body.String())
}
}
// A valid status still creates the project (the validation does not over-reject).
func TestCreateProjectValidStatusReturns201(t *testing.T) {
w := httptest.NewRecorder()
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "valid status project",
"status": "in_progress",
})
testHandler.CreateProject(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201 for valid status, got %d: %s", w.Code, w.Body.String())
}
var project ProjectResponse
if err := json.NewDecoder(w.Body).Decode(&project); err != nil {
t.Fatalf("decode CreateProject: %v", err)
}
t.Cleanup(func() {
req := newRequest("DELETE", "/api/projects/"+project.ID, nil)
req = withURLParam(req, "id", project.ID)
testHandler.DeleteProject(httptest.NewRecorder(), req)
})
if project.Status != "in_progress" {
t.Errorf("expected status in_progress, got %q", project.Status)
}
}
// Updating to an unknown status is a 400, not a 500.
func TestUpdateProjectInvalidStatusReturns400(t *testing.T) {
// Seed a project to update.
w := httptest.NewRecorder()
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "update validation project",
})
testHandler.CreateProject(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("seed CreateProject: %d %s", w.Code, w.Body.String())
}
var project ProjectResponse
if err := json.NewDecoder(w.Body).Decode(&project); err != nil {
t.Fatalf("decode CreateProject: %v", err)
}
t.Cleanup(func() {
req := newRequest("DELETE", "/api/projects/"+project.ID, nil)
req = withURLParam(req, "id", project.ID)
testHandler.DeleteProject(httptest.NewRecorder(), req)
})
w = httptest.NewRecorder()
req = newRequest("PUT", "/api/projects/"+project.ID, map[string]any{"status": "active"})
req = withURLParam(req, "id", project.ID)
testHandler.UpdateProject(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid update status, got %d: %s", w.Code, w.Body.String())
}
}
func TestDeleteProjectRequiresAdminOrOwner(t *testing.T) {
memberUserID := createProjectPermissionTestMember(t, "member")
project := createProjectPermissionTestProject(t, "delete permission denied project")
w := httptest.NewRecorder()
req := newRequestAs(memberUserID, "DELETE", "/api/projects/"+project.ID, nil)
req = withURLParam(req, "id", project.ID)
testHandler.DeleteProject(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for plain member project delete, got %d: %s", w.Code, w.Body.String())
}
var exists bool
if err := testPool.QueryRow(context.Background(), `SELECT EXISTS (SELECT 1 FROM project WHERE id = $1)`, project.ID).Scan(&exists); err != nil {
t.Fatalf("verify project exists: %v", err)
}
if !exists {
t.Fatal("project was deleted despite plain member request")
}
}
func TestDeleteProjectAllowsAdmin(t *testing.T) {
adminUserID := createProjectPermissionTestMember(t, "admin")
project := createProjectPermissionTestProject(t, "delete permission admin project")
w := httptest.NewRecorder()
req := newRequestAs(adminUserID, "DELETE", "/api/projects/"+project.ID, nil)
req = withURLParam(req, "id", project.ID)
testHandler.DeleteProject(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("expected 204 for admin project delete, got %d: %s", w.Code, w.Body.String())
}
var exists bool
if err := testPool.QueryRow(context.Background(), `SELECT EXISTS (SELECT 1 FROM project WHERE id = $1)`, project.ID).Scan(&exists); err != nil {
t.Fatalf("verify project deleted: %v", err)
}
if exists {
t.Fatal("project still exists after admin delete")
}
}
func createProjectPermissionTestMember(t *testing.T, role string) string {
t.Helper()
ctx := context.Background()
email := "project-delete-" + role + "@multica.test"
// The schema uses no foreign keys or cascades, so a leftover member from a
// prior run won't disappear when its user is deleted. Drop the member first.
_, _ = testPool.Exec(ctx, `DELETE FROM member WHERE user_id IN (SELECT id FROM "user" WHERE email = $1)`, email)
_, _ = testPool.Exec(ctx, `DELETE FROM "user" WHERE email = $1`, email)
var userID string
if err := testPool.QueryRow(ctx, `
INSERT INTO "user" (name, email)
VALUES ($1, $2)
RETURNING id
`, "Project Delete "+role, email).Scan(&userID); err != nil {
t.Fatalf("create %s user: %v", role, err)
}
t.Cleanup(func() {
// No cascade in the schema: remove the member row before its user so the
// shared test workspace isn't left with an orphaned member record.
_, _ = testPool.Exec(context.Background(), `DELETE FROM member WHERE user_id = $1`, userID)
_, _ = testPool.Exec(context.Background(), `DELETE FROM "user" WHERE id = $1`, userID)
})
if _, err := testPool.Exec(ctx, `
INSERT INTO member (workspace_id, user_id, role)
VALUES ($1, $2, $3)
`, testWorkspaceID, userID, role); err != nil {
t.Fatalf("create %s member: %v", role, err)
}
return userID
}
func createProjectPermissionTestProject(t *testing.T, title string) ProjectResponse {
t.Helper()
w := httptest.NewRecorder()
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
})
testHandler.CreateProject(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateProject: expected 201, got %d: %s", w.Code, w.Body.String())
}
var project ProjectResponse
if err := json.NewDecoder(w.Body).Decode(&project); err != nil {
t.Fatalf("decode CreateProject: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, project.ID)
})
return project
}