mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 22:09:44 +02:00
* 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>
195 lines
6.7 KiB
Go
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
|
|
}
|