package handler import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) // projectCreateFixture lays out a deployment whose root .zddc grants // `c` (create) to `*@example.com` so non-admin members can spin up // projects, with `admin@example.com` as the super-admin escape hatch. func projectCreateFixture(t *testing.T) (config.Config, string) { t.Helper() root := t.TempDir() mustWriteHelper(t, filepath.Join(root, ".zddc"), "admins:\n - admin@example.com\n"+ "acl:\n permissions:\n \"*@example.com\": c\n") zddc.InvalidateCache(root) return config.Config{Root: root, EmailHeader: "X-Auth-Request-Email", MaxWriteBytes: 64 * 1024}, root } func doProjectCreate(cfg config.Config, email string, elevated bool, body []byte) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, "/.profile/projects", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") ctx := context.WithValue(req.Context(), EmailKey, email) ctx = context.WithValue(ctx, ElevatedKey, elevated) req = req.WithContext(ctx) rec := httptest.NewRecorder() serveProfileProjectsCreate(cfg, rec, req) return rec } // Non-admin user holds `c` at root via cascade — succeeds, becomes // subtree admin of the new project. func TestProjectCreate_CascadeGrantedUserBecomesSubtreeAdmin(t *testing.T) { cfg, root := projectCreateFixture(t) body, _ := json.Marshal(map[string]any{"parent": "/", "name": "Project-1", "title": "My project"}) rec := doProjectCreate(cfg, "alice@example.com", false, body) if rec.Code != http.StatusCreated { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } zddc.InvalidateCache(root) // Verify the new project .zddc names alice as admin. zf, err := zddc.ParseFile(filepath.Join(root, "Project-1", ".zddc")) if err != nil { t.Fatalf("read new .zddc: %v", err) } if len(zf.Admins) != 1 || zf.Admins[0] != "alice@example.com" { t.Errorf("Admins=%v, want [alice@example.com]", zf.Admins) } if zf.Title != "My project" { t.Errorf("Title=%q, want %q", zf.Title, "My project") } } // User with no grant at root — 404 (existence-hidden). func TestProjectCreate_UnauthenticatedUserDenied(t *testing.T) { cfg, _ := projectCreateFixture(t) body, _ := json.Marshal(map[string]any{"parent": "/", "name": "ShouldFail"}) rec := doProjectCreate(cfg, "stranger@other.org", false, body) if rec.Code != http.StatusNotFound { t.Errorf("status=%d, want 404", rec.Code) } } // Super-admin elevated — succeeds via decider's IsActiveAdmin bypass, // not via explicit ACL grant. Admins default to creator email. func TestProjectCreate_ElevatedSuperAdminWithDefaultAdmins(t *testing.T) { cfg, root := projectCreateFixture(t) body, _ := json.Marshal(map[string]any{"parent": "/", "name": "AdminProj"}) rec := doProjectCreate(cfg, "admin@example.com", true, body) if rec.Code != http.StatusCreated { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } zddc.InvalidateCache(root) zf, _ := zddc.ParseFile(filepath.Join(root, "AdminProj", ".zddc")) if len(zf.Admins) != 1 || zf.Admins[0] != "admin@example.com" { t.Errorf("default Admins=%v, want [admin@example.com]", zf.Admins) } } // Caller passes explicit admins list — used instead of auto-defaulting. func TestProjectCreate_ExplicitAdminsListWins(t *testing.T) { cfg, root := projectCreateFixture(t) body, _ := json.Marshal(map[string]any{ "parent": "/", "name": "TeamProj", "admins": []string{"alice@example.com", "bob@example.com"}, }) rec := doProjectCreate(cfg, "alice@example.com", false, body) if rec.Code != http.StatusCreated { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } zddc.InvalidateCache(root) zf, _ := zddc.ParseFile(filepath.Join(root, "TeamProj", ".zddc")) if len(zf.Admins) != 2 { t.Errorf("Admins=%v, want 2 entries", zf.Admins) } } // Re-creating an existing project name fails 409 — duplicate detection // runs before the admin seed write. func TestProjectCreate_DuplicateNameRejected(t *testing.T) { cfg, root := projectCreateFixture(t) if err := os.MkdirAll(filepath.Join(root, "Existing"), 0o755); err != nil { t.Fatal(err) } body, _ := json.Marshal(map[string]any{"parent": "/", "name": "Existing"}) rec := doProjectCreate(cfg, "alice@example.com", false, body) if rec.Code != http.StatusConflict { t.Errorf("status=%d, want 409", rec.Code) } }