191 lines
7.4 KiB
Go
191 lines
7.4 KiB
Go
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)
|
|
}
|
|
}
|
|
|
|
// The creator is recorded in created_by (+ made admin), and the role-group
|
|
// member lists become roles{} MEMBERSHIP — with NO root verb grants (verbs
|
|
// come from the embedded per-folder defaults). "guests" maps to `observer`.
|
|
func TestProjectCreate_RecordsCreatorAndRoles(t *testing.T) {
|
|
cfg, root := projectCreateFixture(t)
|
|
body, _ := json.Marshal(map[string]any{
|
|
"parent": "/",
|
|
"name": "RoleProj",
|
|
"document_controllers": []string{"dc@example.com"},
|
|
"project_team": []string{"t1@example.com", "t2@example.com"},
|
|
"guests": []string{"guest@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, err := zddc.ParseFile(filepath.Join(root, "RoleProj", ".zddc"))
|
|
if err != nil {
|
|
t.Fatalf("read new .zddc: %v", err)
|
|
}
|
|
if zf.CreatedBy != "alice@example.com" {
|
|
t.Errorf("CreatedBy=%q, want alice@example.com", zf.CreatedBy)
|
|
}
|
|
if len(zf.Admins) != 1 || zf.Admins[0] != "alice@example.com" {
|
|
t.Errorf("Admins=%v, want [alice@example.com]", zf.Admins)
|
|
}
|
|
if r, ok := zf.Roles["document_controller"]; !ok || len(r.Members) != 1 || r.Members[0] != "dc@example.com" {
|
|
t.Errorf("document_controller role=%v", zf.Roles["document_controller"])
|
|
}
|
|
if r, ok := zf.Roles["project_team"]; !ok || len(r.Members) != 2 {
|
|
t.Errorf("project_team role=%v", zf.Roles["project_team"])
|
|
}
|
|
// "guests" populates the read-only observer role used by the defaults.
|
|
if r, ok := zf.Roles["observer"]; !ok || len(r.Members) != 1 || r.Members[0] != "guest@example.com" {
|
|
t.Errorf("observer role=%v", zf.Roles["observer"])
|
|
}
|
|
if _, ok := zf.Roles["guest"]; ok {
|
|
t.Errorf("should not create a 'guest' role; it maps to observer")
|
|
}
|
|
// No verbs seeded at the project root — verbs come from the cascade.
|
|
if len(zf.ACL.Permissions) != 0 {
|
|
t.Errorf("project root should carry no acl.permissions, got %v", zf.ACL.Permissions)
|
|
}
|
|
}
|
|
|
|
// The advanced acl.permissions field still passes through verbatim (the
|
|
// escape hatch for operators who want explicit project-root grants).
|
|
func TestProjectCreate_AdvancedACLPassesThrough(t *testing.T) {
|
|
cfg, root := projectCreateFixture(t)
|
|
body, _ := json.Marshal(map[string]any{
|
|
"parent": "/",
|
|
"name": "OverrideProj",
|
|
"project_team": []string{"t@example.com"},
|
|
"acl": map[string]any{"permissions": map[string]string{"*@vendor.com": "r"}},
|
|
})
|
|
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, "OverrideProj", ".zddc"))
|
|
if zf.ACL.Permissions["*@vendor.com"] != "r" {
|
|
t.Errorf("advanced ACL should pass through: got %q want r", zf.ACL.Permissions["*@vendor.com"])
|
|
}
|
|
if _, ok := zf.Roles["project_team"]; !ok {
|
|
t.Errorf("project_team role missing alongside explicit ACL")
|
|
}
|
|
}
|