Two intertwined refactors that share too many files to split cleanly.
Both are described separately below.
PART 1 — in-dir convention for table+form spec files
Old layout had the spec at the parent and rows in a child:
archive/<party>/
mdl.table.yaml spec
mdl.form.yaml row-edit form
mdl/ rows-dir
row-001.yaml ...
URLs were /<dir>/mdl.table.html and /<dir>/mdl.form.html. Copying
mdl/ elsewhere lost the spec and form because they lived next door.
New layout collapses everything into the rows-dir:
archive/<party>/mdl/ self-contained
table.yaml spec
form.yaml row-edit form
row-001.yaml ... rows
URLs become /<dir>/mdl/table.html and /<dir>/mdl/form.html. The
"copying-the-folder-takes-everything" property the user asked for
falls out by construction; the row-edit URL /<dir>/<id>.yaml.html
keeps the same shape (spec is now in the same dir, not the
grandparent).
Server changes:
- internal/handler/tablehandler.go RecognizeTableRequest fires on
/<dir>/table.html when <dir>/table.yaml exists. The .zddc.tables
alias map is gone — pure presence-based discovery now matches
the form system's existing convention. Default-MDL fallback at
archive/<party>/mdl/ stays for the virgin-archive case (the
rows-dir need not exist on disk; the URL renders fully virtually).
- internal/handler/formhandler.go RecognizeFormRequest fires on
/<dir>/form.html and /<dir>/<id>.yaml.html with spec at
<dir>/form.yaml. specEligible accepts on-disk files OR the
default-MDL virtual path so an empty mdl/ dir still surfaces the
add-row form.
- internal/handler/tablehandler.go IsDefaultMdlSpec moves to
serving archive/<party>/mdl/{table,form}.yaml (5 segments after
ZDDC_ROOT). New isAtArchivePartyMdlLevel predicate; new
isAtArchivePartyMdlDir for directory-based recognition. New
IsDefaultMdlSpecAbs accessor for callers that hold an abs path
rather than a URL (formhandler).
- internal/handler/formhandler.go loadFormSpec(fsRoot, path) falls
back to embedded default-MDL bytes when os.ReadFile returns
NotExist AND the path matches the archive-party-mdl shape. Three
call sites updated to pass cfg.Root.
- internal/handler/formhandler.go serveFormCreate writes
submissions to filepath.Dir(req.SpecPath) — the spec, the form,
and rows all live in one directory. The submissionsDir creation
is idempotent (MkdirAll); cascade falls back one level for ACL
evaluation when the dir hasn't been materialized yet.
- internal/handler/tablehandler.go tableRowsRedirect now points at
/<dir>/table.html (was /<dir>.table.html) when the directory
request maps to a recognized table.
- cmd/zddc-server/main.go dispatch synth flips from
urlPath + ".table.html" to urlPath + "/table.html" for the
no-trailing-slash → tables-app routing.
- internal/apps/availability.go DefaultAppAt comment clarified
that the dir at archive/<party>/mdl/ IS the table (not a child).
Client changes:
- tables/js/context.js walkServer fetches <currentdir>/table.yaml
directly — no .zddc walk for table declarations. Rows are every
*.yaml in current dir EXCLUDING table.yaml and form.yaml. The
.zddc fetch-for-aliases is gated on file:// (online mode 404s
on .zddc reads via the dispatcher's reserve guard, so skipping
the request avoids browser console noise).
- tables/js/main.js add-row button links to relative form.html
(same dir).
- tables/js/render.js + filters.js: every column's autofilter is
uniformly a text-contains input, even enum columns — keeps the
filter row visually consistent and doesn't constrain users to
the enum vocabulary.
PART 2 — unified table+form HTML bundle
The form-render and table-render code paths share field schemas,
the cell editor for excel-mode IS a form widget, and the form
system's POST-back / validation already exists. Combining the two
HTMLs eliminates duplicating jsyaml/jsonschema/theme/source-
detection/.zddc-parsing across two single-file tools.
- tables/template.html grows two top-level mode containers:
#table-mode (toolbar + sortable table) and #form-mode (form +
submit button). Both hidden at parse time; the dispatcher
unhides one. The shared #form-context placeholder was added
here so the server's existing injectFormContext target
resolves.
- tables/js/mode.js (new) sets window.zddcMode synchronously
based on URL pattern: /form.html or /<id>.yaml.html → form,
/table.html → table, else inline-context fallback for
file:// (whichever context blob is non-empty wins). Unhides
the matching container at DOMContentLoaded.
- tables/js/main.js init() and form/js/main.js boot() each guard
early when mode isn't theirs. Both apps live on different
globals (window.tablesApp vs window.formApp) so module
registration doesn't collide.
- form/js/main.js title write falls back from #form-title to
#table-title (the unified bundle's shared header element)
when the dedicated id isn't present.
- tables/build.sh concatenates form modules (widgets, render,
object, array, errors, post, serialize, util) and form CSS.
No new external deps. Bundle grows from ~95KB to ~120KB.
- internal/handler/formhandler.go drops the //go:embed form.html
directive; serveFormRender now writes embeddedTablesHTML via
a small formRenderHTML() accessor (var declared in
tablehandler.go, same package). The embedded form.html file
is removed.
- build script: cp form/dist/form.html → internal/handler/form.html
step is gone (file no longer exists in the source tree). cp
tables/dist/tables.html → internal/handler/tables.html now
runs unconditionally rather than only on beta/stable cuts —
the renderer is a fixed binary component and dev iteration
needs the embedded copy refreshed every build. Channel-cascaded
apps (internal/apps/embedded/) stay channel-gated as before.
- form/dist/form.html still builds for standalone offline-only
use (downloadable from /releases/), but no longer goes into
the binary.
Tests:
- internal/handler/tablehandler_test.go and formhandler_test.go
rewritten for the in-dir layout. New test
TestRecognizeFormRequest_DefaultMdlAtArchiveParty covers
empty-form, create POST, re-edit row, and the negative cases
(Working/, non-mdl name) where the fallback must NOT fire.
- internal/handler/directory_test.go updated for the new
/<dir>/table.html redirect target.
- cmd/zddc-server/main_test.go TestDispatchSlashRouting Location
expectation updated.
- tests/form-safety.spec.js loads tables/dist/tables.html
(named form.html in the temp dir to trigger form-mode in the
dispatcher) so it tests the same bytes the server returns.
Title-element selector switches to #table-title.
- tests/tables.spec.js updates the status-filter test for the
uniform text-input filter.
Docs:
- AGENTS.md form-data system rewrites the URL conventions and
storage layout for in-dir; gains a Tables system section
parallel to forms describing the self-contained-directory
property; subfolder rules ("one table per folder by
construction; subfolders allowed and silently ignored as rows
— legitimate uses: nested sub-tables, per-row attachments,
drafts, future history sidecars") so we don't re-derive this.
Not included (deferred):
- ACL gating on cell-level writes — not relevant until Phase 3.
- Editable cells UI — separate commit (Phase 1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
739 lines
26 KiB
Go
739 lines
26 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
)
|
|
|
|
// TestDispatchHidesDotPrefixedSegments asserts the dispatch() guard that
|
|
// rejects requests whose URL contains a dot-prefixed segment (other than
|
|
// the recognized virtual prefixes .archive and /.profile handled separately).
|
|
//
|
|
// The guard exists so the in-image dev-shell can keep persistent state
|
|
// (settings, source clones, Go module cache) under /srv/.devshell on the
|
|
// same Azure Files PVC as served data without ever exposing those files
|
|
// via direct HTTP fetch.
|
|
func TestDispatchHidesDotPrefixedSegments(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
// Realistic shape: a project dir, a hidden top-level dir, and a hidden
|
|
// sibling of a normal file inside the project.
|
|
mustMkdir(t, filepath.Join(root, "Project-A"))
|
|
mustWrite(t, filepath.Join(root, "Project-A", "doc.txt"), "ok")
|
|
mustMkdir(t, filepath.Join(root, ".devshell"))
|
|
mustMkdir(t, filepath.Join(root, ".devshell", "coder"))
|
|
mustWrite(t, filepath.Join(root, ".devshell", "coder", "settings.json"), "secret")
|
|
mustMkdir(t, filepath.Join(root, "Project-A", ".internal"))
|
|
mustWrite(t, filepath.Join(root, "Project-A", ".internal", "notes.md"), "secret")
|
|
|
|
idx, err := archive.BuildIndex(root)
|
|
if err != nil {
|
|
t.Fatalf("BuildIndex: %v", err)
|
|
}
|
|
|
|
cfg := config.Config{
|
|
Root: root,
|
|
IndexPath: ".archive",
|
|
EmailHeader: "X-Auth-Request-Email",
|
|
}
|
|
ring := handler.NewLogRing(10)
|
|
|
|
cases := []struct {
|
|
name string
|
|
path string
|
|
wantStatus int
|
|
}{
|
|
// Hidden top-level dir — every shape blocked.
|
|
{"hidden top dir", "/.devshell/", http.StatusNotFound},
|
|
{"hidden top dir nested", "/.devshell/coder/settings.json", http.StatusNotFound},
|
|
|
|
// Hidden segment under a real project dir — also blocked.
|
|
{"hidden segment mid path", "/Project-A/.internal/notes.md", http.StatusNotFound},
|
|
|
|
// Sanity: recognized virtual prefixes are NOT blocked. .archive falls
|
|
// through to its own handler (which 404s on missing tracking number);
|
|
// .profile is handled by ServeProfile and the page itself is public.
|
|
// /.admin no longer exists — it is hard-cut and falls through to the
|
|
// dot-prefix guard, which 404s.
|
|
{".archive prefix passes guard", "/.archive/UNKNOWN", http.StatusNotFound}, // unknown tracking → 404 from archive handler
|
|
{".profile not blocked by guard", "/.profile/", http.StatusOK}, // public page renders for anonymous
|
|
{".admin hard-cut → dot-prefix guard", "/.admin/whoami", http.StatusNotFound},
|
|
|
|
// Normal files unaffected.
|
|
{"plain file", "/Project-A/doc.txt", http.StatusOK},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
|
rec := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if rec.Code != tc.wantStatus {
|
|
t.Errorf("path=%q status=%d want=%d body=%q",
|
|
tc.path, rec.Code, tc.wantStatus, rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDispatchAppsResolution drives the full apps fetch+cache flow through
|
|
// dispatch() with a fake upstream. Confirms that:
|
|
// - GET / serves the landing app from the apps subsystem
|
|
// - GET /archive.html serves the archive app via fetch+cache
|
|
// - second GET /archive.html serves from cache (X-ZDDC-Source: cache:)
|
|
// - direct URL access to /_zddc/... is rejected
|
|
func TestDispatchAppsResolution(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
body := []byte("<!doctype html>archive content")
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("GenerateKey: %v", err)
|
|
}
|
|
sig := ed25519.Sign(priv, body)
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Same body for every artifact; same signature for every .sig
|
|
// (since the body is identical across the five tools in this
|
|
// fixture). Real deployments publish a distinct .sig per
|
|
// artifact; the test only cares that the verify gate passes.
|
|
if strings.HasSuffix(r.URL.Path, ".sig") {
|
|
_, _ = w.Write(sig)
|
|
return
|
|
}
|
|
w.Header().Set("ETag", `"v1"`)
|
|
_, _ = w.Write(body)
|
|
}))
|
|
defer upstream.Close()
|
|
upstreamURL, _ := url.Parse(upstream.URL)
|
|
upstreamHost := upstreamURL.Host
|
|
if i := strings.Index(upstreamHost, ":"); i >= 0 {
|
|
upstreamHost = upstreamHost[:i]
|
|
}
|
|
|
|
_ = upstreamHost // referenced below
|
|
|
|
// Seed root .zddc with subdir-cascade Apps entries pointing at the
|
|
// fake upstream. Allow all email patterns (anonymous) so the test
|
|
// doesn't have to set up email headers.
|
|
zf := zddc.ZddcFile{
|
|
ACL: zddc.ACLRules{Allow: []string{"*"}},
|
|
Apps: map[string]string{
|
|
"archive": upstream.URL + "/archive_stable.html",
|
|
"transmittal": upstream.URL + "/transmittal_stable.html",
|
|
"classifier": upstream.URL + "/classifier_stable.html",
|
|
"mdedit": upstream.URL + "/mdedit_stable.html",
|
|
"landing": upstream.URL + "/landing_stable.html",
|
|
},
|
|
}
|
|
if err := zddc.WriteFile(root, zf); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
// Create folder convention dirs so classifier/mdedit/transmittal
|
|
// availability rules pass for the test paths used below.
|
|
mustMkdir(t, filepath.Join(root, "Project-A", "Working"))
|
|
|
|
idx, err := archive.BuildIndex(root)
|
|
if err != nil {
|
|
t.Fatalf("BuildIndex: %v", err)
|
|
}
|
|
cfg := config.Config{
|
|
Root: root,
|
|
IndexPath: ".archive",
|
|
EmailHeader: "X-Auth-Request-Email",
|
|
}
|
|
ring := handler.NewLogRing(10)
|
|
|
|
appsSrv, err := setupApps(cfg)
|
|
if err != nil {
|
|
t.Fatalf("setupApps: %v", err)
|
|
}
|
|
// Override the production embedded public key with the test fixture's
|
|
// pubkey so signature verification of upstream.Sign'd bodies succeeds.
|
|
appsSrv.Fetcher.VerifyKey = pub
|
|
|
|
// GET /archive.html → fetched from upstream (archive is available everywhere)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/archive.html", nil)
|
|
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("first /archive.html: status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec.Body.String() != string(body) {
|
|
t.Errorf("first /archive.html: body mismatch")
|
|
}
|
|
|
|
// GET /archive.html again → cache hit (no new upstream fetch)
|
|
rec2 := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, appsSrv, nil, rec2, httptest.NewRequest(http.MethodGet, "/archive.html", nil))
|
|
if rec2.Code != http.StatusOK {
|
|
t.Errorf("second /archive.html: status=%d", rec2.Code)
|
|
}
|
|
|
|
// GET / → landing
|
|
rec3 := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, appsSrv, nil, rec3, httptest.NewRequest(http.MethodGet, "/", nil))
|
|
if rec3.Code != http.StatusOK {
|
|
t.Errorf("GET /: status=%d", rec3.Code)
|
|
}
|
|
|
|
// Direct URL access to /_app/ → 404
|
|
rec4 := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, appsSrv, nil, rec4, httptest.NewRequest(http.MethodGet, "/_app/foo.html", nil))
|
|
if rec4.Code != http.StatusNotFound {
|
|
t.Errorf("/_app/ direct: status=%d, want 404", rec4.Code)
|
|
}
|
|
|
|
// Folder availability rules: classifier should NOT be served at root
|
|
// (root has no Incoming/Working/Staging ancestor), but SHOULD work in
|
|
// /Project-A/Working/.
|
|
rec5 := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, appsSrv, nil, rec5, httptest.NewRequest(http.MethodGet, "/classifier.html", nil))
|
|
if rec5.Code != http.StatusNotFound {
|
|
t.Errorf("/classifier.html at root: status=%d, want 404 (not in Incoming/Working/Staging)", rec5.Code)
|
|
}
|
|
rec6 := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, appsSrv, nil, rec6, httptest.NewRequest(http.MethodGet, "/Project-A/Working/classifier.html", nil))
|
|
if rec6.Code != http.StatusOK {
|
|
t.Errorf("/Project-A/Working/classifier.html: status=%d, want 200", rec6.Code)
|
|
}
|
|
}
|
|
|
|
// silence "imported and not used" if apps not referenced elsewhere — keep
|
|
// import even when we trim test cases later.
|
|
var _ = apps.DefaultUpstream
|
|
|
|
// TestDispatchRoutesWritesToFileAPI verifies dispatch sends PUT/DELETE/POST
|
|
// to the file API rather than to the read pipeline.
|
|
func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
|
root := t.TempDir()
|
|
mustWrite(t, filepath.Join(root, ".zddc"),
|
|
"acl:\n allow:\n - \"*@example.com\"\n deny: []\n")
|
|
mustMkdir(t, filepath.Join(root, "Project-A", "Working"))
|
|
|
|
idx, err := archive.BuildIndex(root)
|
|
if err != nil {
|
|
t.Fatalf("BuildIndex: %v", err)
|
|
}
|
|
cfg := config.Config{
|
|
Root: root,
|
|
IndexPath: ".archive",
|
|
EmailHeader: "X-Auth-Request-Email",
|
|
MaxWriteBytes: 1 << 20,
|
|
}
|
|
ring := handler.NewLogRing(10)
|
|
|
|
withEmail := func(req *http.Request, email string) *http.Request {
|
|
// dispatch reads email from context (ACLMiddleware would normally
|
|
// set it), so set it directly here.
|
|
return req.WithContext(handler.WithEmail(req.Context(), email))
|
|
}
|
|
|
|
// PUT a new file via dispatch.
|
|
body := []byte("note body")
|
|
req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/Working/note.md", strings.NewReader(string(body))), "alice@example.com")
|
|
rec := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("PUT: want 201, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// GET it back.
|
|
req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/Working/note.md", nil), "alice@example.com")
|
|
rec = httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if rec.Code != http.StatusOK || rec.Body.String() != string(body) {
|
|
t.Fatalf("GET back: code=%d body=%q", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// MOVE it.
|
|
req = withEmail(httptest.NewRequest(http.MethodPost, "/Project-A/Working/note.md", nil), "alice@example.com")
|
|
req.Header.Set("X-ZDDC-Op", "move")
|
|
req.Header.Set("X-ZDDC-Destination", "/Project-A/Working/renamed.md")
|
|
rec = httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("MOVE: want 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// DELETE it.
|
|
req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/Working/renamed.md", nil), "alice@example.com")
|
|
rec = httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("DELETE: want 204, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// Reserved segment guard still applies to writes.
|
|
req = withEmail(httptest.NewRequest(http.MethodPut, "/.devshell/foo.txt", strings.NewReader("x")), "alice@example.com")
|
|
rec = httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("PUT /.devshell/...: want 404, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// TestDispatchArchiveRedirect: any /<project>/<sub>/.../.archive/... is 301'd
|
|
// to the canonical /<project>/.archive/... so all tracking-number references
|
|
// converge on a single stable URL per (project, tracking) regardless of the
|
|
// folder a relative "../.archive/..." link was resolved from.
|
|
func TestDispatchArchiveRedirect(t *testing.T) {
|
|
root := t.TempDir()
|
|
mustWrite(t, filepath.Join(root, ".zddc"),
|
|
"acl:\n allow:\n - \"*\"\n")
|
|
mustMkdir(t, filepath.Join(root, "ProjectA", "Working"))
|
|
|
|
idx, err := archive.BuildIndex(root)
|
|
if err != nil {
|
|
t.Fatalf("BuildIndex: %v", err)
|
|
}
|
|
cfg := config.Config{
|
|
Root: root,
|
|
IndexPath: ".archive",
|
|
EmailHeader: "X-Auth-Request-Email",
|
|
}
|
|
ring := handler.NewLogRing(10)
|
|
|
|
cases := []struct {
|
|
name string
|
|
path string
|
|
query string
|
|
wantStatus int
|
|
wantLoc string
|
|
}{
|
|
{
|
|
"deep two segments",
|
|
"/ProjectA/Working/.archive/100.html",
|
|
"",
|
|
http.StatusMovedPermanently,
|
|
"/ProjectA/.archive/100.html",
|
|
},
|
|
{
|
|
"deep three segments",
|
|
"/ProjectA/sub/sub2/.archive/100.html",
|
|
"",
|
|
http.StatusMovedPermanently,
|
|
"/ProjectA/.archive/100.html",
|
|
},
|
|
{
|
|
"deep with trailing slash (listing)",
|
|
"/ProjectA/Working/.archive/",
|
|
"",
|
|
http.StatusMovedPermanently,
|
|
"/ProjectA/.archive/",
|
|
},
|
|
{
|
|
"deep with query string preserved",
|
|
"/ProjectA/Working/.archive/100.html",
|
|
"v=42",
|
|
http.StatusMovedPermanently,
|
|
"/ProjectA/.archive/100.html?v=42",
|
|
},
|
|
{
|
|
"already canonical (no redirect)",
|
|
"/ProjectA/.archive/100.html",
|
|
"",
|
|
// 100.html doesn't resolve in this index (no transmittal
|
|
// folders), so the handler 404s rather than redirecting.
|
|
http.StatusNotFound,
|
|
"",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rawURL := tc.path
|
|
if tc.query != "" {
|
|
rawURL += "?" + tc.query
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, rawURL, nil)
|
|
rec := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if rec.Code != tc.wantStatus {
|
|
t.Fatalf("path=%q status=%d, want %d; body=%s", tc.path, rec.Code, tc.wantStatus, rec.Body.String())
|
|
}
|
|
if tc.wantLoc != "" {
|
|
if got := rec.Header().Get("Location"); got != tc.wantLoc {
|
|
t.Errorf("path=%q Location=%q, want %q", tc.path, got, tc.wantLoc)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDispatchSlashRouting(t *testing.T) {
|
|
// Convention: <dir>/ → browse (directory view); <dir> → the canonical
|
|
// default tool for the directory (mdedit under working/, transmittal
|
|
// under staging/, archive under archive/, tables under
|
|
// archive/<party>/mdl/). Without a default app, no-slash falls
|
|
// through to the legacy 301-to-trailing-slash redirect.
|
|
//
|
|
// Exception: a directory that is the rows-dir of a registered table
|
|
// (declared via parent .zddc tables:) — including the default-MDL
|
|
// fallback at archive/<party>/mdl/ — redirects the trailing-slash
|
|
// form too, bouncing to <parent>/<name>.table.html. Bare folder
|
|
// listings here would just be a row-of-yaml-files preview that the
|
|
// table view subsumes.
|
|
root := t.TempDir()
|
|
mustWrite(t, filepath.Join(root, ".zddc"),
|
|
"acl:\n permissions:\n \"*\": rwcda\n")
|
|
for _, sub := range []string{
|
|
"Project/working",
|
|
"Project/staging",
|
|
"Project/archive",
|
|
"Project/archive/Acme",
|
|
"Project/archive/Acme/incoming",
|
|
"Project/archive/Acme/mdl",
|
|
"Project/scratch",
|
|
} {
|
|
mustMkdir(t, filepath.Join(root, sub))
|
|
}
|
|
|
|
idx, err := archive.BuildIndex(root)
|
|
if err != nil {
|
|
t.Fatalf("BuildIndex: %v", err)
|
|
}
|
|
cfg := config.Config{
|
|
Root: root,
|
|
IndexPath: ".archive",
|
|
EmailHeader: "X-Auth-Request-Email",
|
|
}
|
|
ring := handler.NewLogRing(10)
|
|
appsSrv, err := setupApps(cfg)
|
|
if err != nil {
|
|
t.Fatalf("setupApps: %v", err)
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
path string
|
|
wantStatus int
|
|
wantNoRedirect bool
|
|
wantLoc string // checked when wantStatus is a redirect
|
|
}{
|
|
{"working no-slash → mdedit", "/Project/working", http.StatusOK, true, ""},
|
|
{"working slash → browse", "/Project/working/", http.StatusOK, true, ""},
|
|
{"staging no-slash → transmittal", "/Project/staging", http.StatusOK, true, ""},
|
|
{"staging slash → browse", "/Project/staging/", http.StatusOK, true, ""},
|
|
{"archive no-slash → archive", "/Project/archive", http.StatusOK, true, ""},
|
|
{"archive slash → browse", "/Project/archive/", http.StatusOK, true, ""},
|
|
{"archive/<party> no-slash → archive", "/Project/archive/Acme", http.StatusOK, true, ""},
|
|
{"archive/<party> slash → browse", "/Project/archive/Acme/", http.StatusOK, true, ""},
|
|
{"archive/<party>/mdl no-slash → tables", "/Project/archive/Acme/mdl", http.StatusOK, true, ""},
|
|
// Trailing-slash form on a tables rows-dir bounces to the canonical
|
|
// .table.html URL so users land on the table view rather than a
|
|
// browse listing of the row-yaml files.
|
|
{"archive/<party>/mdl slash → 302 in-dir table.html", "/Project/archive/Acme/mdl/", http.StatusFound, false, "/Project/archive/Acme/mdl/table.html"},
|
|
{"archive/<party>/incoming no-slash → archive", "/Project/archive/Acme/incoming", http.StatusOK, true, ""},
|
|
{"archive/<party>/incoming slash → browse", "/Project/archive/Acme/incoming/", http.StatusOK, true, ""},
|
|
{"non-canonical no-slash → 301 to slash", "/Project/scratch", http.StatusMovedPermanently, false, ""},
|
|
{"non-canonical slash → browse", "/Project/scratch/", http.StatusOK, true, ""},
|
|
{"project root no-slash → 301 to slash", "/Project", http.StatusMovedPermanently, false, ""},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
|
rec := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
|
|
if rec.Code != tc.wantStatus {
|
|
t.Fatalf("path=%q status=%d, want %d; body=%s",
|
|
tc.path, rec.Code, tc.wantStatus, rec.Body.String())
|
|
}
|
|
if tc.wantNoRedirect && rec.Code >= 300 && rec.Code < 400 {
|
|
t.Errorf("path=%q unexpected redirect to %q",
|
|
tc.path, rec.Header().Get("Location"))
|
|
}
|
|
if tc.wantLoc != "" {
|
|
if got := rec.Header().Get("Location"); got != tc.wantLoc {
|
|
t.Errorf("path=%q Location=%q, want %q", tc.path, got, tc.wantLoc)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDispatchArchiveMethodGate: .archive is read-only. PUT/POST/DELETE on
|
|
// any .archive URL returns 405 with Allow: GET, HEAD — ahead of the file
|
|
// API's write path, so a write to an archive URL never silently mutates
|
|
// anything (and so a 302 redirect can never silently downgrade a write
|
|
// to a GET on the canonical URL).
|
|
func TestDispatchArchiveMethodGate(t *testing.T) {
|
|
root := t.TempDir()
|
|
mustWrite(t, filepath.Join(root, ".zddc"),
|
|
"acl:\n allow:\n - \"*\"\n")
|
|
mustMkdir(t, filepath.Join(root, "ProjectA"))
|
|
|
|
idx, err := archive.BuildIndex(root)
|
|
if err != nil {
|
|
t.Fatalf("BuildIndex: %v", err)
|
|
}
|
|
cfg := config.Config{
|
|
Root: root,
|
|
IndexPath: ".archive",
|
|
EmailHeader: "X-Auth-Request-Email",
|
|
MaxWriteBytes: 1 << 20,
|
|
}
|
|
ring := handler.NewLogRing(10)
|
|
|
|
for _, method := range []string{http.MethodPut, http.MethodPost, http.MethodDelete} {
|
|
for _, path := range []string{
|
|
"/ProjectA/.archive/100.html",
|
|
"/ProjectA/Working/.archive/100.html",
|
|
} {
|
|
t.Run(method+" "+path, func(t *testing.T) {
|
|
req := httptest.NewRequest(method, path, strings.NewReader("body"))
|
|
req = req.WithContext(handler.WithEmail(req.Context(), "alice@example.com"))
|
|
rec := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("%s %s: status %d, want 405; body=%s", method, path, rec.Code, rec.Body.String())
|
|
}
|
|
if allow := rec.Header().Get("Allow"); !strings.Contains(allow, "GET") {
|
|
t.Errorf("%s %s: Allow=%q, want to contain GET", method, path, allow)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDispatchCaseInsensitiveURL: mixed-case URLs resolve to the on-disk
|
|
// canonical case, with the lowercase variant winning when both case
|
|
// variants exist as siblings on disk.
|
|
func TestDispatchCaseInsensitiveURL(t *testing.T) {
|
|
root := t.TempDir()
|
|
mustWrite(t, filepath.Join(root, ".zddc"),
|
|
"acl:\n allow:\n - \"*\"\n")
|
|
mustMkdir(t, filepath.Join(root, "project-a", "working"))
|
|
mustWrite(t, filepath.Join(root, "project-a", "working", "note.md"), "lowercase note")
|
|
|
|
// Sibling Mixed-Case dir present too. Lowercase must win on the
|
|
// case-insensitive resolution; the Mixed-Case dir's contents must
|
|
// not bleed through under any URL casing.
|
|
mustMkdir(t, filepath.Join(root, "Project-A", "Working"))
|
|
mustWrite(t, filepath.Join(root, "Project-A", "Working", "note.md"), "MIXEDCASE note")
|
|
|
|
idx, err := archive.BuildIndex(root)
|
|
if err != nil {
|
|
t.Fatalf("BuildIndex: %v", err)
|
|
}
|
|
cfg := config.Config{
|
|
Root: root,
|
|
IndexPath: ".archive",
|
|
EmailHeader: "X-Auth-Request-Email",
|
|
}
|
|
ring := handler.NewLogRing(10)
|
|
|
|
cases := []struct {
|
|
name string
|
|
url string
|
|
}{
|
|
{"all lowercase", "/project-a/working/note.md"},
|
|
{"mixed case top", "/Project-A/working/note.md"},
|
|
{"mixed case nested", "/PROJECT-A/Working/Note.md"},
|
|
{"all uppercase", "/PROJECT-A/WORKING/NOTE.MD"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, tc.url, nil)
|
|
rec := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%q", rec.Code, rec.Body.String())
|
|
}
|
|
if got := rec.Body.String(); got != "lowercase note" {
|
|
t.Errorf("body=%q want %q (lowercase variant must win)",
|
|
got, "lowercase note")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func mustMkdir(t *testing.T, path string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(path, 0o755); err != nil {
|
|
t.Fatalf("mkdir %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
func mustWrite(t *testing.T, path, body string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatalf("write %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
// TestGzhttpWrapper_CompressesLargeResponses asserts the gzhttp wrapper
|
|
// behavior we wire in main(): responses above MinSize get gzip-encoded
|
|
// when the client advertises Accept-Encoding: gzip; small responses
|
|
// pass through uncompressed; HEAD requests still set Vary correctly.
|
|
//
|
|
// We construct the wrapper the same way main() does (1024 byte minsize)
|
|
// and exercise it against a tiny test handler — full end-to-end is
|
|
// covered by the live curl smoke test in CI / dev verification.
|
|
func TestGzhttpWrapper_CompressesLargeResponses(t *testing.T) {
|
|
// Re-create the wrapper config from main.go so this test stays in
|
|
// sync with the real wiring.
|
|
wrapper, err := newGzipWrapper()
|
|
if err != nil {
|
|
t.Fatalf("newGzipWrapper: %v", err)
|
|
}
|
|
|
|
largeBody := strings.Repeat("ZDDC ", 4000) // ~20 KB, well over MinSize
|
|
smallBody := "ok"
|
|
|
|
handler := wrapper(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if r.URL.Path == "/large" {
|
|
_, _ = w.Write([]byte(largeBody))
|
|
} else {
|
|
_, _ = w.Write([]byte(smallBody))
|
|
}
|
|
}))
|
|
|
|
srv := httptest.NewServer(handler)
|
|
defer srv.Close()
|
|
|
|
t.Run("large body with Accept-Encoding gzip → compressed", func(t *testing.T) {
|
|
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/large", nil)
|
|
req.Header.Set("Accept-Encoding", "gzip")
|
|
// Disable transparent decompression so we can read the raw bytes
|
|
// and confirm the wire format.
|
|
client := &http.Client{Transport: &http.Transport{DisableCompression: true}}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if got := resp.Header.Get("Content-Encoding"); got != "gzip" {
|
|
t.Errorf("Content-Encoding = %q, want gzip", got)
|
|
}
|
|
if got := resp.Header.Get("Vary"); !strings.Contains(strings.ToLower(got), "accept-encoding") {
|
|
t.Errorf("Vary = %q, want to contain Accept-Encoding", got)
|
|
}
|
|
})
|
|
|
|
t.Run("small body → not compressed", func(t *testing.T) {
|
|
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/small", nil)
|
|
req.Header.Set("Accept-Encoding", "gzip")
|
|
client := &http.Client{Transport: &http.Transport{DisableCompression: true}}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if got := resp.Header.Get("Content-Encoding"); got == "gzip" {
|
|
t.Errorf("Content-Encoding = gzip; small response should not be compressed")
|
|
}
|
|
})
|
|
|
|
t.Run("no Accept-Encoding → not compressed", func(t *testing.T) {
|
|
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/large", nil)
|
|
client := &http.Client{Transport: &http.Transport{DisableCompression: true}}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if got := resp.Header.Get("Content-Encoding"); got != "" {
|
|
t.Errorf("Content-Encoding = %q; client without Accept-Encoding should get plain", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestDispatchZddcEditorAtPath verifies the per-directory <dir>/.zddc.html
|
|
// virtual URL is recognised by the dispatcher and routed to the editor
|
|
// handler (carved out from the dot-prefix guard). Permission gate is
|
|
// hasAnyAdminScope; non-admins get 404.
|
|
func TestDispatchZddcEditorAtPath(t *testing.T) {
|
|
root := t.TempDir()
|
|
mustWrite(t, filepath.Join(root, ".zddc"),
|
|
"admins:\n - root@example.com\n")
|
|
mustMkdir(t, filepath.Join(root, "Project", "working"))
|
|
mustWrite(t, filepath.Join(root, "Project", ".zddc"),
|
|
"title: Demo Project\n")
|
|
|
|
idx, err := archive.BuildIndex(root)
|
|
if err != nil {
|
|
t.Fatalf("BuildIndex: %v", err)
|
|
}
|
|
cfg := config.Config{
|
|
Root: root,
|
|
IndexPath: ".archive",
|
|
EmailHeader: "X-Auth-Request-Email",
|
|
}
|
|
ring := handler.NewLogRing(10)
|
|
|
|
cases := []struct {
|
|
name string
|
|
path string
|
|
email string
|
|
wantStatus int
|
|
wantSubstr string
|
|
}{
|
|
{
|
|
"root admin opens project editor",
|
|
"/Project/.zddc.html", "root@example.com",
|
|
http.StatusOK, "Demo Project",
|
|
},
|
|
{
|
|
"root admin opens working/ editor (no .zddc on disk yet)",
|
|
"/Project/working/.zddc.html", "root@example.com",
|
|
http.StatusOK, ".zddc editor",
|
|
},
|
|
{
|
|
"root admin opens deployment-root editor",
|
|
"/.zddc.html", "root@example.com",
|
|
http.StatusOK, ".zddc editor",
|
|
},
|
|
{
|
|
"non-admin gets 404",
|
|
"/Project/.zddc.html", "stranger@example.com",
|
|
http.StatusNotFound, "",
|
|
},
|
|
{
|
|
"anonymous gets 404",
|
|
"/Project/.zddc.html", "",
|
|
http.StatusNotFound, "",
|
|
},
|
|
{
|
|
"missing directory gets 404",
|
|
"/Project/no-such-dir/.zddc.html", "root@example.com",
|
|
http.StatusNotFound, "",
|
|
},
|
|
{
|
|
"deeper than leaf rejected",
|
|
"/Project/.zddc.html/extra", "root@example.com",
|
|
http.StatusNotFound, "",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
|
req = req.WithContext(context.WithValue(req.Context(), handler.EmailKey, tc.email))
|
|
rec := httptest.NewRecorder()
|
|
dispatch(cfg, idx, ring, nil, nil, rec, req)
|
|
if rec.Code != tc.wantStatus {
|
|
t.Fatalf("path=%q status=%d, want %d; body=%s",
|
|
tc.path, rec.Code, tc.wantStatus, rec.Body.String())
|
|
}
|
|
if tc.wantSubstr != "" && !strings.Contains(rec.Body.String(), tc.wantSubstr) {
|
|
t.Errorf("path=%q body missing %q", tc.path, tc.wantSubstr)
|
|
}
|
|
})
|
|
}
|
|
}
|