mdedit/ is gone. Its functionality moved into browse's preview plugin
(browse/js/preview-markdown.js) — YAML front matter editing, outline,
and on-demand DOCX/HTML/PDF download all happen there. Browse is the
default_tool for working/ + reviewing/ as of the previous commit, so
existing URLs of the form /<project>/working land on browse without
operator action.
Removed:
• mdedit/ source tree (Toast UI app, CSS, JS, template, build.sh)
• zddc/internal/apps/embedded/mdedit.html (//go:embed blob)
• tests/mdedit.spec.js + the "mdedit" project in playwright.config.js
• mdedit entries in zddc/internal/apps/embed.go (//go:embed, var,
switch case in EmbeddedBytes)
• "mdedit" in zddc/internal/zddc/validate.go AppNames + the matching
error-message app list
• "mdedit.html" branch in zddc/internal/apps/handler.go MatchAppHTML
• mdedit case in tests (handler_test.go, validate_test.go,
zddchandler_test.go) — test fixtures now use browse/classifier
• mdedit from build (per-tool build.sh loop, tool-list literals,
composer cards) and shared/build-lib.sh ZDDC_RELEASE_TOOLS
• mdedit from freshen-channel's tool list and usage banner
• mdedit-specific paragraphs in AGENTS.md and ARCHITECTURE.md;
Markdown Editor section in ARCHITECTURE.md rewritten to point at
browse/js/preview-markdown.js
• mdedit from CLAUDE.md, README.md, zddc/README.md tool lists
Historical mdedit_v*.html / mdedit_v*.html.sig files in
/srv/zddc/releases/ on the deploy host are immutable history — they
stay where they are. The next ./build release cut will simply not
produce new mdedit_v* artifacts.
437 lines
16 KiB
Go
437 lines
16 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
)
|
|
|
|
// zddcTestSetup writes a tree of .zddc files and returns the root and a
|
|
// helper that builds requests with an injected user email. files keys
|
|
// are paths relative to root; the empty string is the root itself. Each
|
|
// path is created as a directory; if the value is non-empty it is
|
|
// written as that directory's .zddc.
|
|
func zddcTestSetup(t *testing.T, files map[string]string) (cfg config.Config, do func(method, target, email, body string) *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
for rel, body := range files {
|
|
dir := filepath.Join(root, rel)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir %s: %v", dir, err)
|
|
}
|
|
zddc.InvalidateCache(dir)
|
|
if body == "" {
|
|
continue
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, ".zddc"), []byte(body), 0o644); err != nil {
|
|
t.Fatalf("write .zddc: %v", err)
|
|
}
|
|
}
|
|
cfg = config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
|
|
|
do = func(method, target, email, body string) *httptest.ResponseRecorder {
|
|
var rdr *bytes.Reader
|
|
if body != "" {
|
|
rdr = bytes.NewReader([]byte(body))
|
|
}
|
|
var req *http.Request
|
|
if rdr != nil {
|
|
req = httptest.NewRequest(method, target, rdr)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
} else {
|
|
req = httptest.NewRequest(method, target, nil)
|
|
}
|
|
ctx := context.WithValue(req.Context(), EmailKey, email)
|
|
req = req.WithContext(ctx)
|
|
rec := httptest.NewRecorder()
|
|
ServeZddc(cfg, rec, req)
|
|
return rec
|
|
}
|
|
return cfg, do
|
|
}
|
|
|
|
func TestServeZddcAuthGate(t *testing.T) {
|
|
// root admin = root@example.com; subtree admin alice@example.com on /projects.
|
|
cfg, do := zddcTestSetup(t, map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
"projects": "admins:\n - alice@example.com\n",
|
|
"projects/x": "",
|
|
})
|
|
|
|
cases := []struct {
|
|
name string
|
|
method string
|
|
target string
|
|
email string
|
|
wantStatus int
|
|
}{
|
|
{"anon GET root", http.MethodGet, "/.profile/zddc?path=/", "", http.StatusNotFound},
|
|
{"non-admin GET root", http.MethodGet, "/.profile/zddc?path=/", "mallory@example.com", http.StatusNotFound},
|
|
{"super-admin GET root", http.MethodGet, "/.profile/zddc?path=/", "root@example.com", http.StatusOK},
|
|
{"subtree-admin GET root (read-only)", http.MethodGet, "/.profile/zddc?path=/", "alice@example.com", http.StatusOK},
|
|
{"subtree-admin GET own grant file (read-only)", http.MethodGet, "/.profile/zddc?path=/projects", "alice@example.com", http.StatusOK},
|
|
{"subtree-admin GET deeper", http.MethodGet, "/.profile/zddc?path=/projects/x", "alice@example.com", http.StatusOK},
|
|
{"subtree-admin POST own grant file (forbidden)", http.MethodPost, "/.profile/zddc?path=/projects", "alice@example.com", http.StatusForbidden},
|
|
{"subtree-admin POST deeper (allowed)", http.MethodPost, "/.profile/zddc?path=/projects/x", "alice@example.com", http.StatusOK},
|
|
{"super-admin POST root", http.MethodPost, "/.profile/zddc?path=/", "root@example.com", http.StatusOK},
|
|
{"non-admin POST anywhere", http.MethodPost, "/.profile/zddc?path=/projects/x", "mallory@example.com", http.StatusNotFound},
|
|
{"DELETE root rejected", http.MethodDelete, "/.profile/zddc?path=/", "root@example.com", http.StatusBadRequest},
|
|
{"super-admin DELETE leaf", http.MethodDelete, "/.profile/zddc?path=/projects/x", "root@example.com", http.StatusNoContent},
|
|
}
|
|
|
|
_ = cfg
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
body := ""
|
|
if tc.method == http.MethodPost {
|
|
if tc.target == "/.profile/zddc?path=/" {
|
|
// Root POST: writer must remain in admins list.
|
|
body = `{"title":"","acl":{"allow":[],"deny":[]},"admins":["root@example.com"]}`
|
|
} else {
|
|
body = `{"title":"x","acl":{"allow":["*@example.com"],"deny":[]},"admins":[]}`
|
|
}
|
|
}
|
|
rec := do(tc.method, tc.target, tc.email, body)
|
|
if rec.Code != tc.wantStatus {
|
|
t.Errorf("status = %d, want %d; body=%s", rec.Code, tc.wantStatus, rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServeZddcGetReturnsChain(t *testing.T) {
|
|
_, do := zddcTestSetup(t, map[string]string{
|
|
"": "admins:\n - root@example.com\nacl:\n allow: [\"*@example.com\"]\n",
|
|
"projects": "title: All Projects\n",
|
|
"projects/sub": "title: Substation\n",
|
|
})
|
|
rec := do(http.MethodGet, "/.profile/zddc?path=/projects/sub", "root@example.com", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp zddcGetResponse
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp.Path != "/projects/sub" {
|
|
t.Errorf("path = %q, want /projects/sub", resp.Path)
|
|
}
|
|
if !resp.CanEdit {
|
|
t.Errorf("CanEdit = false; root admin should edit anywhere")
|
|
}
|
|
if !resp.Exists {
|
|
t.Errorf("Exists = false but file was written")
|
|
}
|
|
if len(resp.EffectiveChain) != 3 {
|
|
t.Fatalf("chain length = %d, want 3 (root, projects, projects/sub)", len(resp.EffectiveChain))
|
|
}
|
|
if resp.EffectiveChain[0].Dir != "/" {
|
|
t.Errorf("chain[0].Dir = %q, want /", resp.EffectiveChain[0].Dir)
|
|
}
|
|
if resp.EffectiveChain[1].Dir != "/projects" {
|
|
t.Errorf("chain[1].Dir = %q, want /projects", resp.EffectiveChain[1].Dir)
|
|
}
|
|
if resp.EffectiveChain[2].Title != "Substation" {
|
|
t.Errorf("chain[2].Title = %q, want Substation", resp.EffectiveChain[2].Title)
|
|
}
|
|
}
|
|
|
|
func TestServeZddcPostValidatesGlob(t *testing.T) {
|
|
_, do := zddcTestSetup(t, map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
"projects": "",
|
|
})
|
|
body := `{"title":"x","acl":{"allow":["alice@@bad","good@example.com"],"deny":[]},"admins":[]}`
|
|
rec := do(http.MethodPost, "/.profile/zddc?path=/projects", "root@example.com", body)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var we writeError
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &we); err != nil {
|
|
t.Fatalf("decode err body: %v", err)
|
|
}
|
|
if len(we.Errors) == 0 || we.Errors[0].Field != "acl.allow[0]" {
|
|
t.Errorf("expected acl.allow[0] error, got %+v", we.Errors)
|
|
}
|
|
}
|
|
|
|
func TestServeZddcRootSelfDemotionRejected(t *testing.T) {
|
|
_, do := zddcTestSetup(t, map[string]string{
|
|
"": "admins:\n - root@example.com\n - bob@example.com\n",
|
|
})
|
|
// root tries to remove themselves, leaving only bob.
|
|
body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":["bob@example.com"]}`
|
|
rec := do(http.MethodPost, "/.profile/zddc?path=/", "root@example.com", body)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400 (self-demotion rejected); body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestServeZddcRootKeepingSelfAccepted(t *testing.T) {
|
|
_, do := zddcTestSetup(t, map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
})
|
|
// root adds bob alongside themselves — fine.
|
|
body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":["root@example.com","bob@example.com"]}`
|
|
rec := do(http.MethodPost, "/.profile/zddc?path=/", "root@example.com", body)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestServeZddcWriteRoundTrip(t *testing.T) {
|
|
_, do := zddcTestSetup(t, map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
"projects": "",
|
|
})
|
|
body := `{"title":"Engineering","acl":{"allow":["*@varasys.io"],"deny":[]},"admins":["alice@varasys.io"]}`
|
|
rec := do(http.MethodPost, "/.profile/zddc?path=/projects", "root@example.com", body)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("write status = %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
rec = do(http.MethodGet, "/.profile/zddc?path=/projects", "root@example.com", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("get status = %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp zddcGetResponse
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp.File.Title != "Engineering" {
|
|
t.Errorf("title round-trip = %q, want Engineering", resp.File.Title)
|
|
}
|
|
if len(resp.File.Admins) != 1 || resp.File.Admins[0] != "alice@varasys.io" {
|
|
t.Errorf("admins round-trip = %v, want [alice@varasys.io]", resp.File.Admins)
|
|
}
|
|
}
|
|
|
|
func TestServeZddcWriteAppsRoundTrip(t *testing.T) {
|
|
_, do := zddcTestSetup(t, map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
"projects": "",
|
|
})
|
|
body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":[],"apps":{` +
|
|
`"default":"https://zddc.varasys.io/releases:stable",` +
|
|
`"classifier":":beta",` +
|
|
`"archive":"https://my.local.stuff/releases"}}`
|
|
rec := do(http.MethodPost, "/.profile/zddc?path=/projects", "root@example.com", body)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("write status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
rec = do(http.MethodGet, "/.profile/zddc?path=/projects", "root@example.com", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("get status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var resp zddcGetResponse
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if got := resp.File.Apps["default"]; got != "https://zddc.varasys.io/releases:stable" {
|
|
t.Errorf("default round-trip = %q", got)
|
|
}
|
|
if got := resp.File.Apps["classifier"]; got != ":beta" {
|
|
t.Errorf("classifier round-trip = %q", got)
|
|
}
|
|
if got := resp.File.Apps["archive"]; got != "https://my.local.stuff/releases" {
|
|
t.Errorf("archive round-trip = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestServeZddcWriteAppsRejectsBadSpec(t *testing.T) {
|
|
_, do := zddcTestSetup(t, map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
"projects": "",
|
|
})
|
|
body := `{"title":"","acl":{"allow":[],"deny":[]},"admins":[],"apps":{"archive":"this is garbage"}}`
|
|
rec := do(http.MethodPost, "/.profile/zddc?path=/projects", "root@example.com", body)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status=%d (want 400)", rec.Code)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), `"apps.archive"`) {
|
|
t.Errorf("expected per-field error for apps.archive; got %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestServeZddcEditorRendersAppsSection(t *testing.T) {
|
|
_, do := zddcTestSetup(t, map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
"projects": "apps:\n default: \":beta\"\n classifier: \"v0.0.4\"\n",
|
|
})
|
|
rec := do(http.MethodGet, "/.profile/zddc/edit?path=/projects", "root@example.com", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
for _, want := range []string{
|
|
"Apps (tool HTML sources)",
|
|
`data-apps-key="default"`,
|
|
`data-apps-key="archive"`,
|
|
`data-apps-key="classifier"`,
|
|
`data-apps-key="browse"`,
|
|
`data-apps-key="transmittal"`,
|
|
`data-apps-key="landing"`,
|
|
`value=":beta"`,
|
|
`value="v0.0.4"`,
|
|
"classifier_v0.0.4.html", // preview reflects the cascaded resolution
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("editor body missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServeZddcTreeFiltersByVisibility(t *testing.T) {
|
|
_, do := zddcTestSetup(t, map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
"alpha": "admins:\n - alice@example.com\n",
|
|
"alpha/x": "title: alpha-x\n",
|
|
"beta": "admins:\n - bob@example.com\n",
|
|
})
|
|
// alice sees alpha (her grant) and alpha/x (descendant), but not beta.
|
|
rec := do(http.MethodGet, "/.profile/zddc/tree", "alice@example.com", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var entries []treeEntry
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &entries); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, e := range entries {
|
|
seen[e.Path] = true
|
|
}
|
|
if !seen["/alpha"] || !seen["/alpha/x"] {
|
|
t.Errorf("alice should see /alpha and /alpha/x; got %v", seen)
|
|
}
|
|
if seen["/beta"] {
|
|
t.Errorf("alice should NOT see /beta; got %v", seen)
|
|
}
|
|
}
|
|
|
|
func TestServeZddcEditorRenders(t *testing.T) {
|
|
_, do := zddcTestSetup(t, map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
"projects": "title: Engineering\n",
|
|
})
|
|
rec := do(http.MethodGet, "/.profile/zddc/edit?path=/projects", "root@example.com", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "Engineering") {
|
|
t.Errorf("editor should pre-fill title; body did not contain 'Engineering'")
|
|
}
|
|
if !strings.Contains(body, "/.profile/zddc?path=") {
|
|
t.Errorf("editor should reference API URL; body lacks /.profile/zddc?path=")
|
|
}
|
|
if !strings.Contains(body, "Subtree admins of /projects") {
|
|
t.Errorf("editor should label admins section as subtree (not bootstrap) for non-root file")
|
|
}
|
|
}
|
|
|
|
func TestServeZddcEditorReadOnlyForNonEditor(t *testing.T) {
|
|
_, do := zddcTestSetup(t, map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
"projects": "admins:\n - alice@example.com\n",
|
|
})
|
|
// alice viewing her own grant file: read-only.
|
|
rec := do(http.MethodGet, "/.profile/zddc/edit?path=/projects", "alice@example.com", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "Read-only") {
|
|
t.Errorf("editor should show Read-only banner for non-editor; body lacks it")
|
|
}
|
|
}
|
|
|
|
func TestServeZddcRejectsReservedPathSegments(t *testing.T) {
|
|
_, do := zddcTestSetup(t, map[string]string{
|
|
"": "admins:\n - root@example.com\n",
|
|
})
|
|
for _, p := range []string{"/.foo", "/_bar", "/projects/.evil"} {
|
|
rec := do(http.MethodGet, "/.profile/zddc?path="+p, "root@example.com", "")
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("path=%q expected 404, got %d", p, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServeZddcAdminDispatchUnchangedForOtherRoutes(t *testing.T) {
|
|
// Confirm that putting /.profile/zddc/* under the broader gate did not
|
|
// regress the super-admin gate on /.profile/whoami etc.
|
|
root := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - root@example.com\n"), 0o644); err != nil {
|
|
t.Fatalf("write .zddc: %v", err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/.profile/whoami", nil)
|
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
|
|
rec := httptest.NewRecorder()
|
|
ServeProfile(cfg, nil, nil, rec, req)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("non-admin /.profile/whoami got %d, want 404", rec.Code)
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodGet, "/.profile/whoami", nil)
|
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
|
|
rec = httptest.NewRecorder()
|
|
ServeProfile(cfg, nil, nil, rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("super-admin /.profile/whoami got %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestServeZddcAssetsCustomCSS(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - root@example.com\n"), 0o644); err != nil {
|
|
t.Fatalf("write .zddc: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, ".admin.css"), []byte("body { color: red; }"), 0o644); err != nil {
|
|
t.Fatalf("write .admin.css: %v", err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/.profile/zddc/assets/custom.css", nil)
|
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
|
|
rec := httptest.NewRecorder()
|
|
ServeZddc(cfg, rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/css") {
|
|
t.Errorf("Content-Type = %q, want text/css...", ct)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "color: red") {
|
|
t.Errorf("body does not contain custom CSS")
|
|
}
|
|
}
|
|
|
|
func TestServeZddcAssetsAbsentReturns404(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n - root@example.com\n"), 0o644); err != nil {
|
|
t.Fatalf("write .zddc: %v", err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/.profile/zddc/assets/custom.css", nil)
|
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
|
|
rec := httptest.NewRecorder()
|
|
ServeZddc(cfg, rec, req)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("status=%d, want 404", rec.Code)
|
|
}
|
|
}
|