package cabana_test import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "slices" "strings" "testing" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/lagoon/attach" "git.golem15.com/golem15/summercms/modules/party" ) // linkedIDs lists the ids of a gadget relation's linked rows. func (e *conformEnv) linkedIDs(t *testing.T, gadget uint, relation string, headers map[string]string) []uint { t.Helper() rec := e.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/%s", gadget, relation), nil, "", headers) if rec.Code != http.StatusOK { t.Fatalf("linked %s status=%d body=%s", relation, rec.Code, rec.Body.String()) } var body struct { Data []struct { ID uint `json:"id"` } `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal(err) } out := make([]uint, 0, len(body.Data)) for _, row := range body.Data { out = append(out, row.ID) } return out } // partOwner reads a part's gadget_id straight from the table. func (e *conformEnv) partOwner(t *testing.T, id uint) *uint { t.Helper() var part conformPart if err := e.db.First(&part, id).Error; err != nil { t.Fatal(err) } return part.GadgetID } // TestRelationChildSmokeCreate creates a hasMany child of a saved gadget // through the relation manager: the server sets the foreign key from the // scoped parent, the child lists under that parent only, and a body naming // the foreign key cannot move it. func TestRelationChildSmokeCreate(t *testing.T) { env := newConformEnv(t) env.loginAs(t, env.login) a := env.createGadget(t, "a-"+env.stamp, "") b := env.createGadget(t, "b-"+env.stamp, "") rec := env.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records", a), map[string]any{"label": "wheel", "gadget_id": b}, true) if rec.Code != http.StatusCreated { t.Fatalf("create part status=%d body=%s", rec.Code, rec.Body.String()) } part := dataID(t, rec.Body.Bytes()) if owner := env.partOwner(t, part); owner == nil || *owner != a { t.Fatalf("part gadget_id = %v, want %d", owner, a) } if got := env.linkedIDs(t, a, "parts", nil); !slices.Contains(got, part) { t.Fatalf("gadget A parts = %v, want %d", got, part) } if got := env.linkedIDs(t, b, "parts", nil); slices.Contains(got, part) { t.Fatalf("gadget B lists A's part: %v", got) } invalid := env.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records", a), map[string]any{"label": ""}, true) if invalid.Code != http.StatusUnprocessableEntity { t.Fatalf("empty label status=%d body=%s", invalid.Code, invalid.Body.String()) } undeclared := env.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/records", a), map[string]any{"email": "x@example.test"}, true) if undeclared.Code != http.StatusForbidden { t.Fatalf("create on a relation without the create button status=%d body=%s", undeclared.Code, undeclared.Body.String()) } missing := env.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records", b+1000), map[string]any{"label": "x"}, true) if missing.Code != http.StatusNotFound { t.Fatalf("create under a missing parent status=%d body=%s", missing.Code, missing.Body.String()) } } // relationPath is the admin API path of a gadget relation route. func relationPath(gadget uint, relation, rest string) string { return fmt.Sprintf("/acme/conform/gadgets/%d/relations/%s%s", gadget, relation, rest) } // expectStatus fails the test unless rec has the status. func expectStatus(t *testing.T, what string, rec *httptest.ResponseRecorder, status int) { t.Helper() if rec.Code != status { t.Fatalf("%s status=%d want %d body=%s", what, rec.Code, status, rec.Body.String()) } } // createPart creates a part under a gadget through the relation manager. func (e *conformEnv) createPart(t *testing.T, gadget uint, label string) uint { t.Helper() rec := e.send(t, http.MethodPost, relationPath(gadget, "parts", "/records"), map[string]any{"label": label}, true) expectStatus(t, "create part", rec, http.StatusCreated) return dataID(t, rec.Body.Bytes()) } // TestRelationChildSmokeScope checks D-15 on every child route: a child of // another parent, a member linked to another parent and a parent hidden by // FormExtendQuery all answer 404, and a delete naming one foreign child // deletes nothing. It also walks hasMany link, unlink and delete. func TestRelationChildSmokeScope(t *testing.T) { env := newConformEnv(t) env.loginAs(t, env.login) a := env.createGadget(t, "a-"+env.stamp, "") b := env.createGadget(t, "b-"+env.stamp, "") pa := env.createPart(t, a, "pa") pb := env.createPart(t, b, "pb") expectStatus(t, "show foreign child", env.send(t, http.MethodGet, relationPath(a, "parts", fmt.Sprintf("/records/%d", pb)), nil, true), http.StatusNotFound) expectStatus(t, "update foreign child", env.send(t, http.MethodPut, relationPath(a, "parts", fmt.Sprintf("/records/%d", pb)), map[string]any{"label": "stolen"}, true), http.StatusNotFound) expectStatus(t, "delete foreign child", env.send(t, http.MethodPost, relationPath(a, "parts", "/delete"), map[string]any{"ids": []uint{pa, pb}}, true), http.StatusNotFound) for _, id := range []uint{pa, pb} { var n int64 if err := env.db.Model(&conformPart{}).Where("id = ?", id).Count(&n).Error; err != nil || n != 1 { t.Fatalf("part %d count=%d err=%v after a refused delete", id, n, err) } } shown := env.send(t, http.MethodGet, relationPath(a, "parts", fmt.Sprintf("/records/%d", pa)), nil, true) expectStatus(t, "show own child", shown, http.StatusOK) updated := env.send(t, http.MethodPut, relationPath(a, "parts", fmt.Sprintf("/records/%d", pa)), map[string]any{"label": "pa-renamed", "gadget_id": b}, true) expectStatus(t, "update own child", updated, http.StatusOK) if owner := env.partOwner(t, pa); owner == nil || *owner != a { t.Fatalf("update moved the part to %v", owner) } // A member linked to B has no pivot row under A. expectStatus(t, "link member to B", env.send(t, http.MethodPost, relationPath(b, "members", "/link"), map[string]any{"ids": []uint{env.memberID}}, true), http.StatusOK) expectStatus(t, "pivot of B's member through A", env.send(t, http.MethodGet, relationPath(a, "members", fmt.Sprintf("/pivot/%d", env.memberID)), nil, true), http.StatusNotFound) expectStatus(t, "pivot update of B's member through A", env.send(t, http.MethodPut, relationPath(a, "members", fmt.Sprintf("/pivot/%d", env.memberID)), map[string]any{"note": "x"}, true), http.StatusNotFound) // A parent FormExtendQuery hides is 404 on every child route. hidden := conformGadget{Name: "hidden-" + env.stamp} if err := env.db.Create(&hidden).Error; err != nil { t.Fatal(err) } ph := conformPart{GadgetID: &hidden.ID, Label: "ph"} if err := env.db.Create(&ph).Error; err != nil { t.Fatal(err) } expectStatus(t, "hidden parent linked list", env.send(t, http.MethodGet, relationPath(hidden.ID, "parts", ""), nil, true), http.StatusNotFound) expectStatus(t, "hidden parent child", env.send(t, http.MethodGet, relationPath(hidden.ID, "parts", fmt.Sprintf("/records/%d", ph.ID)), nil, true), http.StatusNotFound) expectStatus(t, "hidden parent create", env.send(t, http.MethodPost, relationPath(hidden.ID, "parts", "/records"), map[string]any{"label": "x"}, true), http.StatusNotFound) // hasMany link adopts a free part, unlink frees it, delete removes it. free := conformPart{Label: "free"} if err := env.db.Create(&free).Error; err != nil { t.Fatal(err) } candidates := env.send(t, http.MethodGet, relationPath(a, "parts", "/candidates"), nil, true) expectStatus(t, "candidates", candidates, http.StatusOK) if !strings.Contains(candidates.Body.String(), `"label":"free"`) || strings.Contains(candidates.Body.String(), `"label":"pb"`) { t.Fatalf("hasMany candidates = %s", candidates.Body.String()) } expectStatus(t, "link owned part of B", env.send(t, http.MethodPost, relationPath(a, "parts", "/link"), map[string]any{"ids": []uint{pb}}, true), http.StatusUnprocessableEntity) expectStatus(t, "link free part", env.send(t, http.MethodPost, relationPath(a, "parts", "/link"), map[string]any{"ids": []uint{free.ID}}, true), http.StatusOK) if owner := env.partOwner(t, free.ID); owner == nil || *owner != a { t.Fatalf("linked part owner = %v, want %d", owner, a) } expectStatus(t, "unlink part", env.send(t, http.MethodPost, relationPath(a, "parts", "/unlink"), map[string]any{"ids": []uint{free.ID, pb}}, true), http.StatusOK) if owner := env.partOwner(t, free.ID); owner != nil { t.Fatalf("unlinked part owner = %d", *owner) } if owner := env.partOwner(t, pb); owner == nil || *owner != b { t.Fatalf("unlink through A freed B's part: %v", owner) } expectStatus(t, "delete own child", env.send(t, http.MethodPost, relationPath(a, "parts", "/delete"), map[string]any{"ids": []uint{pa}}, true), http.StatusOK) var n int64 if err := env.db.Model(&conformPart{}).Where("id = ?", pa).Count(&n).Error; err != nil || n != 0 { t.Fatalf("deleted part count=%d err=%v", n, err) } } // TestRelationChildSmokePivot links a member with a pivot note (D-14): the // note is stored, RelationBeforeLink still stamps its hook column, and pivot // keys outside the pivot form are refused on link and on the pivot route. func TestRelationChildSmokePivot(t *testing.T) { env := newConformEnv(t) env.loginAs(t, env.login) g := env.createGadget(t, "g-"+env.stamp, "") second := conformMember{Email: "second-" + env.stamp + "@example.test"} if err := env.db.Create(&second).Error; err != nil { t.Fatal(err) } for name, body := range map[string]map[string]any{ "foreign key": {"ids": []uint{env.memberID}, "pivot": map[string]any{"gadget_id": 99}}, "hook column": {"ids": []uint{env.memberID}, "pivot": map[string]any{"stamp": "forged"}}, "two ids": {"ids": []uint{env.memberID, second.ID}, "pivot": map[string]any{"note": "x"}}, "hasMany link": nil, } { if body == nil { rec := env.send(t, http.MethodPost, relationPath(g, "parts", "/link"), map[string]any{"ids": []uint{1}, "pivot": map[string]any{"note": "x"}}, true) expectStatus(t, name, rec, http.StatusUnprocessableEntity) continue } expectStatus(t, name, env.send(t, http.MethodPost, relationPath(g, "members", "/link"), body, true), http.StatusUnprocessableEntity) } var count int64 if err := env.db.Model(&conformGadgetMember{}).Where("gadget_id = ?", g).Count(&count).Error; err != nil || count != 0 { t.Fatalf("refused links wrote %d pivot rows (%v)", count, err) } expectStatus(t, "link with note", env.send(t, http.MethodPost, relationPath(g, "members", "/link"), map[string]any{"ids": []uint{env.memberID}, "pivot": map[string]any{"note": "hello"}}, true), http.StatusOK) var row conformGadgetMember if err := env.db.Where("gadget_id = ? AND member_id = ?", g, env.memberID).Take(&row).Error; err != nil { t.Fatal(err) } if row.Note != "hello" || row.Stamp != "linked" { t.Fatalf("pivot row = %+v, want note hello and stamp linked", row) } shown := env.send(t, http.MethodGet, relationPath(g, "members", fmt.Sprintf("/pivot/%d", env.memberID)), nil, true) expectStatus(t, "pivot show", shown, http.StatusOK) if !strings.Contains(shown.Body.String(), `"note":"hello"`) || strings.Contains(shown.Body.String(), "stamp") { t.Fatalf("pivot show = %s", shown.Body.String()) } expectStatus(t, "pivot update with a foreign key", env.send(t, http.MethodPut, relationPath(g, "members", fmt.Sprintf("/pivot/%d", env.memberID)), map[string]any{"member_id": second.ID}, true), http.StatusUnprocessableEntity) expectStatus(t, "pivot update", env.send(t, http.MethodPut, relationPath(g, "members", fmt.Sprintf("/pivot/%d", env.memberID)), map[string]any{"note": "changed"}, true), http.StatusOK) if err := env.db.Where("gadget_id = ? AND member_id = ?", g, env.memberID).Take(&row).Error; err != nil { t.Fatal(err) } if row.Note != "changed" || row.Stamp != "linked" || row.MemberID != env.memberID { t.Fatalf("pivot row after update = %+v", row) } } // sendJSON sends a JSON body with extra headers. func (e *conformEnv) sendJSON(t *testing.T, method, rel string, body any, headers map[string]string) *httptest.ResponseRecorder { t.Helper() raw, err := json.Marshal(body) if err != nil { t.Fatal(err) } return e.sendWith(t, method, rel, raw, "application/json", headers) } // TestRelationChildSmokeDeferredCreate manages relations on a gadget that is // not saved yet (D-03, D-04): a part created and a member linked with a // pivot note under the session key are attached by the gadget's create save // with the same key, and no binding is left. func TestRelationChildSmokeDeferredCreate(t *testing.T) { env := newConformEnv(t) env.loginAs(t, env.login) key := newSessionKey(t) h := map[string]string{cabana.SessionKeyHeader: key} expectStatus(t, "id 0 without a key", env.send(t, http.MethodPost, relationPath(0, "parts", "/records"), map[string]any{"label": "x"}, true), http.StatusNotFound) created := env.sendJSON(t, http.MethodPost, relationPath(0, "parts", "/records"), map[string]any{"label": "pending-" + env.stamp}, h) expectStatus(t, "deferred create", created, http.StatusCreated) part := dataID(t, created.Body.Bytes()) if owner := env.partOwner(t, part); owner != nil { t.Fatalf("pending part already owned by %d", *owner) } if got := env.linkedIDs(t, 0, "parts", h); !slices.Contains(got, part) { t.Fatalf("pending parts = %v, want %d", got, part) } other := env.createGadget(t, "other-"+env.stamp, "") candidates := env.send(t, http.MethodGet, relationPath(other, "parts", "/candidates"), nil, true) expectStatus(t, "candidates of another gadget", candidates, http.StatusOK) if strings.Contains(candidates.Body.String(), "pending-"+env.stamp) { t.Fatalf("another gadget may adopt the pending part: %s", candidates.Body.String()) } link := env.sendJSON(t, http.MethodPost, relationPath(0, "members", "/link"), map[string]any{"ids": []uint{env.memberID}, "pivot": map[string]any{"note": "deferred"}}, h) expectStatus(t, "deferred link", link, http.StatusOK) pivot := env.sendWith(t, http.MethodGet, relationPath(0, "members", fmt.Sprintf("/pivot/%d", env.memberID)), nil, "", h) expectStatus(t, "pending pivot", pivot, http.StatusOK) if !strings.Contains(pivot.Body.String(), `"note":"deferred"`) { t.Fatalf("pending pivot = %s", pivot.Body.String()) } // Unlinking a part the session created deletes it. dropped := env.sendJSON(t, http.MethodPost, relationPath(0, "parts", "/records"), map[string]any{"label": "dropped-" + env.stamp}, h) expectStatus(t, "second deferred create", dropped, http.StatusCreated) droppedID := dataID(t, dropped.Body.Bytes()) expectStatus(t, "deferred unlink", env.sendJSON(t, http.MethodPost, relationPath(0, "parts", "/unlink"), map[string]any{"ids": []uint{droppedID}}, h), http.StatusOK) var left int64 if err := env.db.Model(&conformPart{}).Where("id = ?", droppedID).Count(&left).Error; err != nil || left != 0 { t.Fatalf("unlinked pending part rows = %d (%v)", left, err) } gadget := env.createGadget(t, "deferred-"+env.stamp, key) if owner := env.partOwner(t, part); owner == nil || *owner != gadget { t.Fatalf("part owner after save = %v, want %d", owner, gadget) } var row conformGadgetMember if err := env.db.Where("gadget_id = ? AND member_id = ?", gadget, env.memberID).Take(&row).Error; err != nil { t.Fatalf("pivot row after save: %v", err) } if row.Note != "deferred" || row.Stamp != "linked" { t.Fatalf("pivot row = %+v, want note deferred and stamp linked", row) } if n := env.bindingCount(t, key); n != 0 { t.Fatalf("bindings left after save = %d", n) } } // TestRelationChildSmokeDeferredRollback links a member that the saved // gadget excludes (ExcludedRelatedIDs sees the real parent only at save): // the save answers 422 on the relation-manager field, nothing is created and // the binding stays for the next attempt. func TestRelationChildSmokeDeferredRollback(t *testing.T) { env := newConformEnv(t) env.loginAs(t, env.login) key := newSessionKey(t) h := map[string]string{cabana.SessionKeyHeader: key} member := conformMember{Email: "excluded-" + env.stamp + "@example.test"} if err := env.db.Create(&member).Error; err != nil { t.Fatal(err) } expectStatus(t, "deferred link", env.sendJSON(t, http.MethodPost, relationPath(0, "members", "/link"), map[string]any{"ids": []uint{member.ID}}, h), http.StatusOK) name := fmt.Sprintf("exclude-%d-%s", member.ID, env.stamp) saved := env.sendJSON(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": name}, h) expectStatus(t, "save with an ineligible link", saved, http.StatusUnprocessableEntity) var body struct { Error struct { Details map[string][]string `json:"details"` } `json:"error"` } if err := json.Unmarshal(saved.Body.Bytes(), &body); err != nil || len(body.Error.Details["members"]) == 0 { t.Fatalf("422 details = %s (%v)", saved.Body.String(), err) } var n int64 if err := env.db.Model(&conformGadget{}).Where("name = ?", name).Count(&n).Error; err != nil || n != 0 { t.Fatalf("rolled back gadget rows = %d (%v)", n, err) } if got := env.bindingCount(t, key); got != 1 { t.Fatalf("bindings after the 422 = %d, want 1", got) } } // TestRelationChildSmokeChildFile uploads an image to a part that is not // created yet (child 0, X-Child-Session-Key) and creates the part with the // same child key: the file is attached to the new part (D-17). func TestRelationChildSmokeChildFile(t *testing.T) { env := newConformEnv(t) env.loginAs(t, env.login) gadget := env.createGadget(t, "files-"+env.stamp, "") childKey := newSessionKey(t) expectStatus(t, "child 0 list without a child key", env.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records/0/files/images", gadget), nil, "", nil), http.StatusNotFound) up := env.childUpload(t, gadget, 0, "images", "part.png", conformPNG(t), childKey) expectStatus(t, "child 0 upload", up, http.StatusCreated) file := dataID(t, up.Body.Bytes()) created := env.sendJSON(t, http.MethodPost, relationPath(gadget, "parts", "/records"), map[string]any{"label": "with image"}, map[string]string{cabana.ChildSessionKeyHeader: childKey}) expectStatus(t, "create child with files", created, http.StatusCreated) part := dataID(t, created.Body.Bytes()) var f attach.File if err := env.db.Where("id = ?", file).Take(&f).Error; err != nil { t.Fatal(err) } if f.AttachmentType != "acme.conform.part" || f.AttachmentID != fmt.Sprint(part) || f.Field != "images" { t.Fatalf("file attached to %s/%s/%s, want acme.conform.part/%d/images", f.AttachmentType, f.AttachmentID, f.Field, part) } if n := env.bindingCount(t, childKey); n != 0 { t.Fatalf("child bindings left = %d", n) } list := env.sendWith(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/parts/records/%d/files/images", gadget, part), nil, "", nil) if got := fileList(t, list); len(got) != 1 || got[0].Pending || got[0].URL != "" { t.Fatalf("child file list = %#v", got) } } // noModelsPlugin is the conform plugin without its Models list. type noModelsPlugin struct{ conformPlugin } func (noModelsPlugin) Models() []any { return nil } // TestRelationChildSmokePurgeModels: a deferrable relation that creates // children needs its related model in some plugin's Models(), or // deferred:purge could not remove abandoned children (Pitfall 9). func TestRelationChildSmokePurgeModels(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-purge-models\n"), 0o644); err != nil { t.Fatal(err) } cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}}) if err != nil { t.Fatal(err) } _, err = cabana.Activate(backpack.New(cfg), []party.Plugin{noModelsPlugin{}}) if err == nil || !strings.Contains(err.Error(), "relation parts creates acme.conform.part records under deferral") { t.Fatalf("err = %v", err) } if _, err := cabana.Activate(backpack.New(cfg), []party.Plugin{conformPlugin{}}); err != nil { t.Fatalf("listed model failed boot: %v", err) } }