package admin // Regression coverage for the shared entity CRUD handlers (api_entity.go): // both wire styles must keep their exact contract after the per-entity // handlers were folded into one implementation. import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" ) func newEntityHarness(t *testing.T) *AdminServer { t.Helper() dir := t.TempDir() for _, sub := range []string{"items", "techs", "modules"} { if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil { t.Fatal(err) } } return &AdminServer{dataDir: dir, undoStack: NewUndoStack(dir)} } func callHandler(h http.HandlerFunc, method, path, body string) *httptest.ResponseRecorder { req := httptest.NewRequest(method, path, strings.NewReader(body)) rec := httptest.NewRecorder() h(rec, req) return rec } // Status style (items): validation errors are HTTP statuses, writes echo the // entity map including _raw. func TestEntityCRUDStatusStyle(t *testing.T) { s := newEntityHarness(t) list := s.handleEntityList(itemSpec) byID := s.handleEntityByID(itemSpec) if rec := callHandler(list, http.MethodPost, "/api/items", `{"id":"coin","name":"Coin","value":1}`); rec.Code != http.StatusOK { t.Fatalf("create status = %d body %s", rec.Code, rec.Body.String()) } // The id is written into the file for the status style. data, err := os.ReadFile(filepath.Join(s.dataDir, "items", "coin.yaml")) if err != nil || !strings.Contains(string(data), "id: coin") { t.Fatalf("created file = %q err=%v", data, err) } rec := callHandler(byID, http.MethodGet, "/api/items/coin", "") var got map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("get not json: %v (%s)", err, rec.Body.String()) } if _, ok := got["_raw"]; !ok { t.Fatalf("status-style get did not include _raw: %s", rec.Body.String()) } rec = callHandler(byID, http.MethodPut, "/api/items/coin", `{"name":"Coin","value":9}`) if rec.Code != http.StatusOK { t.Fatalf("update status = %d body %s", rec.Code, rec.Body.String()) } if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || got["_raw"] == nil { t.Fatalf("status-style update did not echo _raw: %v (%s)", err, rec.Body.String()) } // Traversal ids are rejected with a 400 (not a 200 body error). rec = callHandler(list, http.MethodPost, "/api/items", `{"id":"../escape"}`) if rec.Code != http.StatusBadRequest { t.Fatalf("traversal id status = %d, want 400 (body %s)", rec.Code, rec.Body.String()) } if rec := callHandler(byID, http.MethodDelete, "/api/items/coin", ""); rec.Code != http.StatusOK { t.Fatalf("delete status = %d body %s", rec.Code, rec.Body.String()) } if _, err := os.Stat(filepath.Join(s.dataDir, "items", "coin.yaml")); !os.IsNotExist(err) { t.Fatalf("delete left the file behind: %v", err) } } // Body style (techs): errors are a 200 JSON body, writes reply {"ok":true}, // and PUT upserts an absent file. func TestEntityCRUDUpsertStyle(t *testing.T) { s := newEntityHarness(t) list := s.handleEntityList(techSpec) byID := s.handleEntityByID(techSpec) rec := callHandler(byID, http.MethodPut, "/api/techs/laser", `{"name":"Laser"}`) if rec.Code != http.StatusOK { t.Fatalf("upsert status = %d body %s", rec.Code, rec.Body.String()) } var resp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil || resp["ok"] != true { t.Fatalf("upsert response = %s (err %v)", rec.Body.String(), err) } if _, err := os.Stat(filepath.Join(s.dataDir, "techs", "laser.yaml")); err != nil { t.Fatalf("upsert did not create the file: %v", err) } // The body style strips the id from the file (derived from the filename). data, err := os.ReadFile(filepath.Join(s.dataDir, "techs", "laser.yaml")) if err != nil || strings.Contains(string(data), "id:") { t.Fatalf("body-style file should not store id: %q err=%v", data, err) } // Traversal ids are a 200 body error, not a 400 (the newer editors read // res.error out of a 200). rec = callHandler(list, http.MethodPost, "/api/techs", `{"id":"../escape"}`) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "invalid id") { t.Fatalf("body-style traversal id = %d %s; want 200 with 'invalid id'", rec.Code, rec.Body.String()) } // GET echoes the id from the filename. rec = callHandler(byID, http.MethodGet, "/api/techs/laser", "") var got map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || got["id"] != "laser" { t.Fatalf("body-style get id = %v (err %v, body %s)", got["id"], err, rec.Body.String()) } } // A by-id write must update a file that lives in a subdirectory, not create a // shadowing copy at the root. The old module handler resolved updates only at // the root while GET/DELETE searched subdirs. func TestEntityUpsertUpdatesFileInSubdir(t *testing.T) { s := newEntityHarness(t) sub := filepath.Join(s.dataDir, "modules", "sub") if err := os.MkdirAll(sub, 0o755); err != nil { t.Fatal(err) } subPath := filepath.Join(sub, "m1.yaml") if err := os.WriteFile(subPath, []byte("name: Original\n"), 0o644); err != nil { t.Fatal(err) } rec := callHandler(s.handleEntityByID(moduleSpec), http.MethodPut, "/api/modules/m1", `{"name":"Updated"}`) if rec.Code != http.StatusOK { t.Fatalf("subdir update status = %d body %s", rec.Code, rec.Body.String()) } if _, err := os.Stat(filepath.Join(s.dataDir, "modules", "m1.yaml")); !os.IsNotExist(err) { t.Fatal("subdir update created a shadowing root copy") } data, err := os.ReadFile(subPath) if err != nil || !strings.Contains(string(data), "Updated") { t.Fatalf("subdir file not updated: %q err=%v", data, err) } }