feat(framework+cms): generic app-lane module fixtures + CMS content model

Framework: a generic, DRY module-install path so an app lane (CMS/ERP/Helpdesk)
declares its DocTypes as fixtures (framework.RegisterModule, sibling to
RegisterHook) and installs them per-org via the engine's own gate:
  GET  /v1/framework/modules                list registered lanes
  GET  /v1/framework/modules/:module        lane fixtures + which are installed in-org
  POST /v1/framework/modules/:module/install  ensure fixtures exist (managerOnly, idempotent)
'modules' reserved so the static routes are never shadowed by a document route.

CMS (clients/cms): the first lane — the content model as fixtures only, NO HTTP
surface of its own. Page/Post/Article (slug-named, status Draft/Published,
author Link), Media (Attach-backed DAM), Navigation (JSON menu), Author. A CMS
collection IS a framework DocType (module 'cms'); content IS documents;
publishing IS a status field. Registered at init; installed per-org.

Secure by default: install is managerOnly (owner seeded trust-on-first-use),
create-if-absent (never clobbers a customised DocType), stamps the module tag,
and every op stays per-org via principal.Tenant.

Tests: install/idempotency/unknown-404/tenant-isolation/forged-principal-403/
non-owner-403/module-tag; CMS fixture validity + content-model spec + a full
HTTP install->create Author->create Page(link)->publish->filter round-trip.
This commit is contained in:
2026-07-03 12:44:06 -07:00
parent 4f9fb6f97b
commit 1eea6105e9
7 changed files with 710 additions and 1 deletions
+165
View File
@@ -0,0 +1,165 @@
// Package cms declares the Hanzo CMS content model as DocType fixtures on the
// framework engine (clients/framework). CMS is NOT a bespoke subsystem and mounts
// NO HTTP surface of its own: a "collection" IS a framework DocType in module
// "cms", a content entry IS a framework document, media IS an Attach-backed
// document, and publishing IS a status field. All CRUD, permissions, tenant
// isolation, and install are the framework's generic, already-live surface
// (/v1/framework/*). This package only DECLARES the fixtures and registers them
// with the engine at init — the ONE source of truth for the CMS content model,
// per-org on Base/SQLite.
//
// This is the first app lane on the framework and the template for the rest:
// ERPNext DocTypes and Helpdesk register their fixtures the same way
// (framework.RegisterModule) and are installed + rendered by the SAME generic
// install path and the SAME generic @hanzo/ui DocType renderer. One engine, one
// renderer, every business app.
package cms
import "github.com/hanzoai/cloud/clients/framework"
// Module is the framework module tag every CMS DocType carries. The console's CMS
// surface is the generic DocType renderer scoped to this module.
const Module = "cms"
// RoleContentEditor is the editorial role the CMS content DocTypes grant read/
// write/create/delete. The org owner (System Manager, seeded trust-on-first-use)
// assigns it to teammates via /v1/framework/roles; a role-less member stays
// denied (secure by default). Publishing is a status write, so it needs no extra
// right.
const RoleContentEditor = "Content Editor"
// init registers the CMS content model with the framework. Installing the "cms"
// module (POST /v1/framework/modules/cms/install) ensures these DocTypes exist in
// the caller's org.
func init() { framework.RegisterModule(Module, DocTypes()) }
// DocTypes returns the canonical CMS content model — the default collections an
// org gets when it installs the CMS lane. Order matters only for readability;
// Link targets are resolved at document write, not at define, so a DocType may
// reference another that is defined later in the set.
func DocTypes() []framework.DocType {
return []framework.DocType{author(), media(), page(), post(), article(), navigation()}
}
// ---- content DocTypes ----
// author is a content author profile — the Link target for the author field on
// pages/posts/articles. Hash-named (an author needs no URL slug); the display
// name is the title.
func author() framework.DocType {
return framework.DocType{
Name: "Author", Module: Module, TitleField: "name",
Fields: []framework.DocField{
{Fieldname: "name", Fieldtype: framework.FieldData, Label: "Name", Reqd: true, InListView: true},
{Fieldname: "email", Fieldtype: framework.FieldData, Label: "Email", InListView: true},
{Fieldname: "bio", Fieldtype: framework.FieldText, Label: "Bio"},
{Fieldname: "avatar", Fieldtype: framework.FieldAttach, Label: "Avatar"},
},
Perms: contentPerms(),
}
}
// media is the DAM: an Attach-backed catalogue of uploaded assets (the Attach
// holds the object URL under the org's S3/SeaweedFS prefix). Hash-named.
func media() framework.DocType {
return framework.DocType{
Name: "Media", Module: Module, TitleField: "title",
Fields: []framework.DocField{
{Fieldname: "title", Fieldtype: framework.FieldData, Label: "Title", Reqd: true, InListView: true},
{Fieldname: "file", Fieldtype: framework.FieldAttach, Label: "File", Reqd: true, InListView: true},
{Fieldname: "alt", Fieldtype: framework.FieldData, Label: "Alt Text"},
{Fieldname: "mime", Fieldtype: framework.FieldData, Label: "Type", InListView: true},
{Fieldname: "size", Fieldtype: framework.FieldInt, Label: "Size"},
{Fieldname: "width", Fieldtype: framework.FieldInt, Label: "Width"},
{Fieldname: "height", Fieldtype: framework.FieldInt, Label: "Height"},
{Fieldname: "folder", Fieldtype: framework.FieldData, Label: "Folder", InListView: true},
},
Perms: contentPerms(),
}
}
// page is site structure (marketing/product pages). Slug-named: the document name
// IS the slug, so a page's stable URL key is unique per org.
func page() framework.DocType {
return framework.DocType{
Name: "Page", Module: Module, Autoname: "field:slug", TitleField: "title",
Fields: append(baseContentFields(),
seoTitle(), seoDescription()),
Perms: contentPerms(),
}
}
// post is a blog/changelog entry: the base content plus a category and an explicit
// publish timestamp.
func post() framework.DocType {
return framework.DocType{
Name: "Post", Module: Module, Autoname: "field:slug", TitleField: "title",
Fields: append(baseContentFields(),
category(),
framework.DocField{Fieldname: "published_at", Fieldtype: framework.FieldDatetime, Label: "Published At"}),
Perms: contentPerms(),
}
}
// article is a knowledge-base / docs article: the base content plus a category.
func article() framework.DocType {
return framework.DocType{
Name: "Article", Module: Module, Autoname: "field:slug", TitleField: "title",
Fields: append(baseContentFields(), category()),
Perms: contentPerms(),
}
}
// navigation is a site menu (header/footer/sidebar): an ordered, optionally nested
// list of links held as JSON ([{label,url,children}]).
func navigation() framework.DocType {
return framework.DocType{
Name: "Navigation", Module: Module, Autoname: "field:slug", TitleField: "title",
Fields: []framework.DocField{
{Fieldname: "title", Fieldtype: framework.FieldData, Label: "Title", Reqd: true, InListView: true},
{Fieldname: "slug", Fieldtype: framework.FieldData, Label: "Slug", Reqd: true, InListView: true},
{Fieldname: "location", Fieldtype: framework.FieldSelect, Label: "Location", Options: "header\nfooter\nsidebar", Default: "header", InListView: true},
{Fieldname: "items", Fieldtype: framework.FieldJSON, Label: "Items"},
},
Perms: contentPerms(),
}
}
// ---- shared field builders (DRY: page/post/article share these) ----
// baseContentFields is the field set every publishable content DocType shares:
// title, URL slug, rich body, excerpt, the publish status, a linked author, a
// featured image, and tags. Returned fresh each call (append-safe).
func baseContentFields() []framework.DocField {
return []framework.DocField{
{Fieldname: "title", Fieldtype: framework.FieldData, Label: "Title", Reqd: true, InListView: true},
{Fieldname: "slug", Fieldtype: framework.FieldData, Label: "Slug", Reqd: true, InListView: true},
{Fieldname: "body", Fieldtype: framework.FieldText, Label: "Body"},
{Fieldname: "excerpt", Fieldtype: framework.FieldSmall, Label: "Excerpt"},
{Fieldname: "status", Fieldtype: framework.FieldSelect, Label: "Status", Options: "Draft\nPublished", Default: "Draft", InListView: true},
{Fieldname: "author", Fieldtype: framework.FieldLink, Label: "Author", Options: "Author"},
{Fieldname: "featured_image", Fieldtype: framework.FieldAttach, Label: "Featured Image"},
{Fieldname: "tags", Fieldtype: framework.FieldData, Label: "Tags"},
}
}
func category() framework.DocField {
return framework.DocField{Fieldname: "category", Fieldtype: framework.FieldData, Label: "Category", InListView: true}
}
func seoTitle() framework.DocField {
return framework.DocField{Fieldname: "seo_title", Fieldtype: framework.FieldData, Label: "SEO Title"}
}
func seoDescription() framework.DocField {
return framework.DocField{Fieldname: "seo_description", Fieldtype: framework.FieldSmall, Label: "SEO Description"}
}
// contentPerms is the shared permission set: the org owner (System Manager) has
// full rights; a granted Content Editor may read/write/create/delete. A role-less
// member is denied — secure by default (the engine seeds a System-Manager-only
// grant when perms are empty, so this is an explicit widening, never a loosening).
func contentPerms() []framework.DocPerm {
return []framework.DocPerm{
{Role: framework.RoleSystemManager, Read: true, Write: true, Create: true, Delete: true, Submit: true, Cancel: true},
{Role: RoleContentEditor, Read: true, Write: true, Create: true, Delete: true},
}
}
+192
View File
@@ -0,0 +1,192 @@
package cms
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/framework"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// TestFixturesValid proves every CMS DocType is a well-formed schema and that its
// Link targets resolve WITHIN the lane — so installing the module yields a
// self-consistent content model (no dangling Link target that would 422 at write).
func TestFixturesValid(t *testing.T) {
dts := DocTypes()
names := map[string]framework.DocType{}
for _, dt := range dts {
if err := dt.Validate(); err != nil {
t.Fatalf("fixture %q invalid: %v", dt.Name, err)
}
names[dt.Name] = dt
}
for _, dt := range dts {
for _, f := range dt.Fields {
if f.Fieldtype == framework.FieldLink {
if _, ok := names[f.Options]; !ok {
t.Fatalf("%s.%s links to %q which is not a CMS DocType", dt.Name, f.Fieldname, f.Options)
}
}
}
}
}
// TestContentModelSpec locks the content-model contract the console's generic
// DocType renderer relies on: the publishable content types, their publish status
// field (Draft/Published), the slug autoname, and the author Link.
func TestContentModelSpec(t *testing.T) {
byName := map[string]framework.DocType{}
for _, dt := range DocTypes() {
if dt.Module != Module {
t.Fatalf("%s: module want %q, got %q", dt.Name, Module, dt.Module)
}
byName[dt.Name] = dt
}
for _, want := range []string{"Author", "Media", "Page", "Post", "Article", "Navigation"} {
if _, ok := byName[want]; !ok {
t.Fatalf("missing CMS DocType %q", want)
}
}
// The three publishable content types share the publish contract.
for _, name := range []string{"Page", "Post", "Article"} {
dt := byName[name]
if dt.Autoname != "field:slug" {
t.Fatalf("%s: autoname want field:slug, got %q", name, dt.Autoname)
}
status, ok := fieldOf(dt, "status")
if !ok || status.Fieldtype != framework.FieldSelect || status.Default != "Draft" {
t.Fatalf("%s: expected a Select status field defaulting to Draft, got %+v", name, status)
}
if status.Options != "Draft\nPublished" {
t.Fatalf("%s: status options want Draft/Published, got %q", name, status.Options)
}
author, ok := fieldOf(dt, "author")
if !ok || author.Fieldtype != framework.FieldLink || author.Options != "Author" {
t.Fatalf("%s: expected an author Link to Author, got %+v", name, author)
}
}
// Media is Attach-backed (the DAM).
if file, ok := fieldOf(byName["Media"], "file"); !ok || file.Fieldtype != framework.FieldAttach || !file.Reqd {
t.Fatalf("Media.file must be a required Attach, got %+v", file)
}
}
func fieldOf(dt framework.DocType, name string) (framework.DocField, bool) {
for _, f := range dt.Fields {
if f.Fieldname == name {
return f, true
}
}
return framework.DocField{}, false
}
// TestInstallAndPublishRoundTrip drives the WHOLE content model through the real
// engine over HTTP: install the lane, create an author, create a page that links
// it (status Draft), then publish it and read it back by slug — proving publish =
// a status field and the Link integrity holds.
func TestInstallAndPublishRoundTrip(t *testing.T) {
app := zip.New(zip.Config{Logger: luxlog.New("test")})
if err := framework.Mount(app, cloud.Deps{Logger: luxlog.New("test"), DataDir: t.TempDir()}); err != nil {
t.Fatalf("mount framework: %v", err)
}
t.Cleanup(func() { _ = framework.Shutdown() })
const org = "acme"
// Install the CMS lane (the caller becomes System Manager, trust-on-first-use).
if code, body := call(t, app, http.MethodPost, "/v1/framework/modules/cms/install", org, nil); code != http.StatusOK {
t.Fatalf("install cms want 200, got %d (%s)", code, body)
}
// Create an Author (hash-named) to link.
code, body := call(t, app, http.MethodPost, "/v1/framework/Author", org, map[string]any{"name": "Ada Lovelace", "email": "ada@example.com"})
if code != http.StatusCreated {
t.Fatalf("create author want 201, got %d (%s)", code, body)
}
var author map[string]any
_ = json.Unmarshal(body, &author)
authorName, _ := author["name"].(string) // hash id
if authorName == "" {
t.Fatalf("author has no name: %s", body)
}
// Create a Page linking the author; slug becomes the document name; Draft.
code, body = call(t, app, http.MethodPost, "/v1/framework/Page", org, map[string]any{
"title": "About Us", "slug": "about-us", "body": "<h1>About</h1>", "author": authorName,
})
if code != http.StatusCreated {
t.Fatalf("create page want 201, got %d (%s)", code, body)
}
var page map[string]any
_ = json.Unmarshal(body, &page)
if page["name"] != "about-us" {
t.Fatalf("page name (autoname field:slug) want about-us, got %v", page["name"])
}
if page["status"] != "Draft" {
t.Fatalf("new page status want Draft (default), got %v", page["status"])
}
// No published pages yet.
if n := listCount(t, app, org, `/v1/framework/Page?filters={"status":"Published"}`); n != 0 {
t.Fatalf("published pages want 0, got %d", n)
}
// Publish = set the status field.
if code, body := call(t, app, http.MethodPut, "/v1/framework/Page/about-us", org, map[string]any{
"title": "About Us", "slug": "about-us", "status": "Published", "author": authorName,
}); code != http.StatusOK {
t.Fatalf("publish page want 200, got %d (%s)", code, body)
}
if n := listCount(t, app, org, `/v1/framework/Page?filters={"status":"Published"}`); n != 1 {
t.Fatalf("published pages want 1, got %d", n)
}
// A dangling author Link is refused (422) — Link integrity within the org.
if code, _ := call(t, app, http.MethodPost, "/v1/framework/Page", org, map[string]any{
"title": "Ghost", "slug": "ghost", "author": "nonexistent-author",
}); code != http.StatusUnprocessableEntity {
t.Fatalf("dangling author link want 422, got %d", code)
}
}
// ---- test HTTP harness (a validated principal u_<org>) ----
func call(t *testing.T, app *zip.App, method, path, org string, body any) (int, []byte) {
t.Helper()
var r io.Reader
if body != nil {
b, _ := json.Marshal(body)
r = bytes.NewReader(b)
}
req := httptest.NewRequest(method, path, r)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("X-Org-Id", org)
req.Header.Set("X-User-Id", "u_"+org) // the validated-principal signal
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Test %s %s: %v", method, path, err)
}
defer func() { _ = resp.Body.Close() }()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, b
}
func listCount(t *testing.T, app *zip.App, org, path string) int {
t.Helper()
code, body := call(t, app, http.MethodGet, path, org, nil)
if code != http.StatusOK {
t.Fatalf("list %s want 200, got %d (%s)", path, code, body)
}
var res struct {
Data []map[string]any `json:"data"`
}
_ = json.Unmarshal(body, &res)
return len(res.Data)
}
+1 -1
View File
@@ -99,7 +99,7 @@ var needsOptions = map[string]bool{FieldSelect: true, FieldLink: true, FieldTabl
// why the static routes can be registered before the generic /:doctype routes
// without ambiguity.
var reservedDocTypeNames = map[string]bool{
"doctypes": true, "roles": true, "health": true, "summary": true,
"doctypes": true, "roles": true, "health": true, "summary": true, "modules": true,
}
// Limits. maxField bounds a single scalar text value; maxDocBytes bounds a whole
+98
View File
@@ -64,6 +64,14 @@ func Mount(app *zip.App, deps cloud.Deps) error {
app.Post("/v1/framework/roles", s.assignRole)
app.Delete("/v1/framework/roles/:user/:role", s.revokeRole)
// App-lane module fixtures: list the registered lanes, inspect one, and
// install its DocType fixtures into the caller's org. Registered BEFORE the
// generic /:doctype routes so "modules" resolves to these static handlers, and
// "modules" is a reserved DocType name so no document route can shadow them.
app.Get("/v1/framework/modules", s.listModules)
app.Get("/v1/framework/modules/:module", s.getModule)
app.Post("/v1/framework/modules/:module/install", s.installModule)
// GENERIC metadata-driven document surface.
app.Get("/v1/framework/:doctype", s.listDocuments)
app.Post("/v1/framework/:doctype", s.createDocument)
@@ -232,6 +240,96 @@ func (s *svc) revokeRole(c *zip.Ctx) error {
return c.NoContent(http.StatusNoContent)
}
// ---- Module (app-lane fixture) handlers ----
// listModules reports the app lanes registered in this binary and the DocType
// names each installs. Any validated member may read the catalog (it is
// compile-time first-party data, not tenant data); installing is gated separately.
func (s *svc) listModules(c *zip.Ctx) error {
if _, err := s.resolveAccess(c); err != nil {
return err
}
mods := registeredModules()
out := make([]map[string]any, 0, len(mods))
for _, m := range mods {
out = append(out, map[string]any{"module": m, "doctypes": fixtureNames(moduleFixtures(m))})
}
return c.JSON(http.StatusOK, map[string]any{"data": out})
}
// getModule reports one lane's fixtures and which of them are already installed in
// the caller's org — the honest install-state the UI shows ("set up" vs "installed").
func (s *svc) getModule(c *zip.Ctx) error {
acc, err := s.resolveAccess(c)
if err != nil {
return err
}
module := strings.TrimSpace(c.Param("module"))
fx := moduleFixtures(module)
if len(fx) == 0 {
return zip.ErrNotFound("unknown module: " + module)
}
installed := make([]string, 0, len(fx))
for _, dt := range fx {
if _, err := s.store.GetDocType(c.Context(), acc.org, dt.Name); err == nil {
installed = append(installed, dt.Name)
} else if !errors.Is(err, errNotFound) {
return mapErr(err, "")
}
}
return c.JSON(http.StatusOK, map[string]any{"module": module, "doctypes": fixtureNames(fx), "installed": installed})
}
// installModule ensures every registered DocType of a lane exists in the caller's
// org. It is idempotent (create-if-absent — an org's customised DocType is NEVER
// clobbered) and gated by managerOnly, so the org owner (System Manager, seeded
// trust-on-first-use) installs a lane into their OWN tenant and no one else's. The
// module name is stamped onto each fixture so the lane's DocTypes are discoverable
// by `module`.
func (s *svc) installModule(c *zip.Ctx) error {
acc, err := s.managerOnly(c)
if err != nil {
return err
}
module := strings.TrimSpace(c.Param("module"))
fx := moduleFixtures(module)
if len(fx) == 0 {
return zip.ErrNotFound("unknown module: " + module)
}
created := make([]string, 0, len(fx))
existing := make([]string, 0, len(fx))
for _, dt := range fx {
if _, err := s.store.GetDocType(c.Context(), acc.org, dt.Name); err == nil {
existing = append(existing, dt.Name)
continue
} else if !errors.Is(err, errNotFound) {
return mapErr(err, "")
}
dt.Module = module // the lane owns the module tag
if err := dt.Validate(); err != nil {
return zip.Errorf(http.StatusInternalServerError, "fixture %q invalid: %v", dt.Name, err)
}
if _, err := s.store.CreateDocType(c.Context(), acc.org, dt); err != nil {
if errors.Is(err, errConflict) { // lost a race with a concurrent install
existing = append(existing, dt.Name)
continue
}
return mapErr(err, "")
}
created = append(created, dt.Name)
}
return c.JSON(http.StatusOK, map[string]any{"module": module, "created": created, "existing": existing})
}
// fixtureNames returns the ordered DocType names of a fixture set.
func fixtureNames(fx []DocType) []string {
names := make([]string, len(fx))
for i, dt := range fx {
names[i] = dt.Name
}
return names
}
// ---- Document handlers ----
func (s *svc) createDocument(c *zip.Ctx) error {
+70
View File
@@ -0,0 +1,70 @@
package framework
import (
"sort"
"sync"
)
// Modules — the app-lane fixture registry.
//
// A sibling app lane (cms, erp, helpdesk, …) DECLARES its content model as a set
// of DocType fixtures and registers them here from a package init(), exactly as
// hooks are registered (hook.go). Installing a module into an org ensures those
// DocTypes exist in that org's per-org store — the Frappe "install app / load
// fixtures" step, decomplected to pure data. This is the ONE way an app lane
// seeds its content model: it never forks the engine and never hand-rolls a
// second install path. CMS is the first lane; ERP/CRM/Helpdesk register the same
// way and reuse the SAME generic install below.
//
// The registry is process-global (fixtures are compile-time first-party data,
// like hooks), read under a mutex so a late init() registration is still safe.
// Installation itself is per-org and gated (managerOnly) at the HTTP layer, so a
// module's fixtures only ever land in an org an authorized owner installs them
// into — the registry holds NO tenant state.
var (
moduleMu sync.RWMutex
moduleRegistry = map[string][]DocType{}
)
// RegisterModule declares the DocType fixtures a module installs. Call from a
// package init() so the content model is declared once at build time. The module
// name is stamped onto every fixture at install so a lane's DocTypes are always
// discoverable by `module`. Fixtures are cloned on registration so a caller's
// slice can never mutate the registry.
func RegisterModule(module string, fixtures []DocType) {
if module == "" || len(fixtures) == 0 {
return
}
cp := make([]DocType, len(fixtures))
copy(cp, fixtures)
moduleMu.Lock()
defer moduleMu.Unlock()
moduleRegistry[module] = append(moduleRegistry[module], cp...)
}
// moduleFixtures returns the registered fixtures for a module (nil if none).
func moduleFixtures(module string) []DocType {
moduleMu.RLock()
defer moduleMu.RUnlock()
return moduleRegistry[module]
}
// registeredModules returns the sorted set of registered module names.
func registeredModules() []string {
moduleMu.RLock()
defer moduleMu.RUnlock()
out := make([]string, 0, len(moduleRegistry))
for m := range moduleRegistry {
out = append(out, m)
}
sort.Strings(out)
return out
}
// resetModules clears the registry. TEST-ONLY, mirroring resetHooks — keeps
// install tests independent of process-global registrations.
func resetModules() {
moduleMu.Lock()
defer moduleMu.Unlock()
moduleRegistry = map[string][]DocType{}
}
+177
View File
@@ -0,0 +1,177 @@
package framework
import (
"encoding/json"
"net/http"
"testing"
)
// testFixtures is a tiny two-DocType lane used to exercise the generic install
// path without depending on any real app lane (clients/cms imports framework, so
// framework's own tests can't import it back).
func testFixtures() []DocType {
return []DocType{
{Name: "Widget", Fields: []DocField{{Fieldname: "code", Fieldtype: FieldData, Reqd: true}}},
{Name: "Gadget", Module: "wrong", Fields: []DocField{{Fieldname: "label", Fieldtype: FieldData}}},
}
}
// withTestModule registers a lane for the duration of a test and resets the
// process-global registry afterward (mirrors resetHooks in the hook tests).
func withTestModule(t *testing.T, module string, fx []DocType) {
t.Helper()
resetModules()
RegisterModule(module, fx)
t.Cleanup(resetModules)
}
func TestInstallModule_CreatesFixturesStampedWithModule(t *testing.T) {
withTestModule(t, "shop", testFixtures())
app := mountApp(t)
code, body := do(t, app, http.MethodPost, "/v1/framework/modules/shop/install", "acme", nil)
if code != http.StatusOK {
t.Fatalf("install want 200, got %d (%s)", code, body)
}
var res struct {
Module string `json:"module"`
Created []string `json:"created"`
Existing []string `json:"existing"`
}
_ = json.Unmarshal(body, &res)
if res.Module != "shop" || len(res.Created) != 2 || len(res.Existing) != 0 {
t.Fatalf("install result mismatch: %+v", res)
}
// The fixtures now exist in the org AND carry the module tag (Gadget's stray
// "wrong" module is overwritten with the lane's own name).
code, body = do(t, app, http.MethodGet, "/v1/framework/doctypes/Gadget", "acme", nil)
if code != http.StatusOK {
t.Fatalf("get installed doctype want 200, got %d (%s)", code, body)
}
var dt DocType
_ = json.Unmarshal(body, &dt)
if dt.Module != "shop" {
t.Fatalf("installed doctype module want %q, got %q", "shop", dt.Module)
}
}
func TestInstallModule_Idempotent(t *testing.T) {
withTestModule(t, "shop", testFixtures())
app := mountApp(t)
if code, body := do(t, app, http.MethodPost, "/v1/framework/modules/shop/install", "acme", nil); code != http.StatusOK {
t.Fatalf("first install want 200, got %d (%s)", code, body)
}
// Second install creates nothing and reports everything as already present.
code, body := do(t, app, http.MethodPost, "/v1/framework/modules/shop/install", "acme", nil)
var res struct {
Created []string `json:"created"`
Existing []string `json:"existing"`
}
_ = json.Unmarshal(body, &res)
if code != http.StatusOK || len(res.Created) != 0 || len(res.Existing) != 2 {
t.Fatalf("idempotent install want created=0 existing=2, got %d %+v", code, res)
}
}
func TestInstallModule_UnknownModule404(t *testing.T) {
withTestModule(t, "shop", testFixtures())
app := mountApp(t)
if code, _ := do(t, app, http.MethodPost, "/v1/framework/modules/ghost/install", "acme", nil); code != http.StatusNotFound {
t.Fatalf("unknown module install want 404, got %d", code)
}
}
// TestInstallModule_TenantIsolation: installing into one org NEVER creates the
// lane's DocTypes in another org.
func TestInstallModule_TenantIsolation(t *testing.T) {
withTestModule(t, "shop", testFixtures())
app := mountApp(t)
if code, _ := do(t, app, http.MethodPost, "/v1/framework/modules/shop/install", "acme", nil); code != http.StatusOK {
t.Fatal("install into acme failed")
}
// A different tenant sees NONE of acme's installed DocTypes.
code, body := do(t, app, http.MethodGet, "/v1/framework/doctypes", "victim", nil)
var list struct {
Data []DocType `json:"data"`
}
_ = json.Unmarshal(body, &list)
if code != http.StatusOK || len(list.Data) != 0 {
t.Fatalf("victim org must have zero doctypes, got %d %+v", code, list.Data)
}
}
// TestInstallModule_ForgedPrincipalRefused: an X-Org-Id with no validated
// principal (no X-User-Id) is refused before any store access.
func TestInstallModule_ForgedPrincipalRefused(t *testing.T) {
withTestModule(t, "shop", testFixtures())
app := mountApp(t)
// call with empty user = no validated principal.
if code, _ := call(t, app, http.MethodPost, "/v1/framework/modules/shop/install", "victim", "", false, nil); code != http.StatusForbidden {
t.Fatalf("forged-principal install want 403, got %d", code)
}
}
// TestInstallModule_NonOwnerDenied: after the owner is seeded (trust-on-first-use),
// a different member of the same org who is not a System Manager cannot install.
func TestInstallModule_NonOwnerDenied(t *testing.T) {
withTestModule(t, "shop", testFixtures())
app := mountApp(t)
// u_acme installs first → becomes System Manager (owner seed).
if code, _ := call(t, app, http.MethodPost, "/v1/framework/modules/shop/install", "acme", "u_acme", false, nil); code != http.StatusOK {
t.Fatal("owner install failed")
}
// A different, non-admin member of acme is denied (org is now owned).
if code, _ := call(t, app, http.MethodPost, "/v1/framework/modules/shop/install", "acme", "u_intruder", false, nil); code != http.StatusForbidden {
t.Fatalf("non-owner install want 403, got %d", code)
}
}
func TestListAndGetModule(t *testing.T) {
withTestModule(t, "shop", testFixtures())
app := mountApp(t)
// listModules
code, body := do(t, app, http.MethodGet, "/v1/framework/modules", "acme", nil)
var lst struct {
Data []struct {
Module string `json:"module"`
Doctypes []string `json:"doctypes"`
} `json:"data"`
}
_ = json.Unmarshal(body, &lst)
if code != http.StatusOK || len(lst.Data) != 1 || lst.Data[0].Module != "shop" || len(lst.Data[0].Doctypes) != 2 {
t.Fatalf("listModules mismatch: %d %+v", code, lst.Data)
}
// getModule before install → installed empty.
code, body = do(t, app, http.MethodGet, "/v1/framework/modules/shop", "acme", nil)
var g struct {
Doctypes []string `json:"doctypes"`
Installed []string `json:"installed"`
}
_ = json.Unmarshal(body, &g)
if code != http.StatusOK || len(g.Doctypes) != 2 || len(g.Installed) != 0 {
t.Fatalf("getModule (pre-install) mismatch: %d %+v", code, g)
}
// Install, then getModule → installed lists both.
do(t, app, http.MethodPost, "/v1/framework/modules/shop/install", "acme", nil)
_, body = do(t, app, http.MethodGet, "/v1/framework/modules/shop", "acme", nil)
_ = json.Unmarshal(body, &g)
if len(g.Installed) != 2 {
t.Fatalf("getModule (post-install) installed want 2, got %+v", g.Installed)
}
}
// TestInstallModule_ReservedRouteNotShadowed proves "modules" is a reserved
// DocType name, so the static module routes can never be shadowed by a document
// route (define of a DocType named "modules" is refused).
func TestInstallModule_ReservedRouteNotShadowed(t *testing.T) {
if err := (&DocType{Name: "modules", Fields: []DocField{{Fieldname: "a", Fieldtype: FieldData}}}).Validate(); err == nil {
t.Fatal("DocType named \"modules\" must be reserved (Validate should fail)")
}
}
+7
View File
@@ -129,6 +129,13 @@ import (
// runtime dep). Order 129 binds /v1/framework/* before the AI /v1/* catch-all.
_ "github.com/hanzoai/cloud/clients/framework" // order 129 — /v1/framework/* (DocType engine)
// The CMS app lane: DocType fixtures (Page/Post/Article/Media/Navigation/
// Author, module "cms") registered with the framework at init. It mounts NO
// HTTP surface of its own — CMS content IS documents on /v1/framework/*,
// installed per-org via /v1/framework/modules/cms/install. First lane on the
// engine; ERP/Helpdesk register the same way.
_ "github.com/hanzoai/cloud/clients/cms" // (no order) — registers the "cms" framework module
_ "github.com/hanzoai/cloud/clients/functions" // order 128 — /v1/functions/*
_ "github.com/hanzoai/cloud/clients/git" // order 132 — /v1/git/* (S3-backed native Git hosting; smart-HTTP clone/push)
_ "github.com/hanzoai/cloud/clients/prompts" // order 126 — /v1/prompts/*