Redesign the markdown edit-history store from content-hashed blobs + log.jsonl to one self-describing file per save: .history/<stem>/<ts>-<email>.<ext> The filename IS the audit (colon-free UTC timestamp valid on SMB/Azure Files + the authoring email); listing the directory is the history. No sidecar log, no hashing. A byte-identical save is a no-op; a pre-existing file lazy-seeds its current bytes (author "unknown", stamped at mtime). Reverting copies an old snapshot back (records as a fresh save). Snapshots are kept forever. Fixes the 404 reading history: reads no longer require history to be *currently* enabled — ServeTextHistory serves whatever .history/<stem>/ exists (empty list when none); the dispatch drops the EffectiveHistory gate for reads. WRITES stay gated by the history: flag. (The 404 came from the aggregator refactor turning history off on project-level working/, which made already-recorded snapshots unreadable.) Renames: an in-place rename carries .history/<stem>/ to the new name (serveFileMove); a cross-dir move leaves it behind. Defaults: history: true now ships on the three live-editing slots — working, mdl, rsk — at both the project-level nodes and the per-party folders. It's a .zddc cascade key, so operators override per project. Records (.yaml in mdl/rsk) keep their separate record-history path. Browse history viewer updated to the filename-based version id (id ← sha). Tests rewritten for the per-file scheme + rename behavior + SMB-safe names; HistoryAt defaults test updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
802 lines
29 KiB
Go
802 lines
29 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"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/policy"
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
)
|
|
|
|
// fileAPITestSetup writes a tree of directories and seed files under a
|
|
// temp root and returns a do() helper that builds and runs file API
|
|
// requests. The root .zddc grants caller@example.com read+write across
|
|
// the tree (single ACL allows both — the internal decider doesn't split
|
|
// read/write yet).
|
|
//
|
|
// seed: relative path → bytes (created as a regular file).
|
|
// dirs: relative paths to mkdir.
|
|
func fileAPITestSetup(t *testing.T, dirs []string, seed map[string]string) (cfg config.Config, do func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder, root string) {
|
|
t.Helper()
|
|
root = t.TempDir()
|
|
|
|
// Root .zddc grants writer access to *@example.com.
|
|
if err := os.WriteFile(filepath.Join(root, ".zddc"),
|
|
[]byte("acl:\n permissions:\n \"*@example.com\": rwcd\n"), 0o644); err != nil {
|
|
t.Fatalf("write root .zddc: %v", err)
|
|
}
|
|
|
|
for _, d := range dirs {
|
|
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
|
|
t.Fatalf("mkdir %s: %v", d, err)
|
|
}
|
|
}
|
|
for rel, body := range seed {
|
|
full := filepath.Join(root, rel)
|
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
|
t.Fatalf("mkdir parent of %s: %v", rel, err)
|
|
}
|
|
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
|
|
t.Fatalf("seed %s: %v", rel, err)
|
|
}
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
cfg = config.Config{
|
|
Root: root,
|
|
EmailHeader: "X-Auth-Request-Email",
|
|
MaxWriteBytes: 1024 * 1024,
|
|
}
|
|
|
|
do = func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder {
|
|
var req *http.Request
|
|
if body != nil {
|
|
req = httptest.NewRequest(method, target, bytes.NewReader(body))
|
|
} else {
|
|
req = httptest.NewRequest(method, target, nil)
|
|
}
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
ctx := context.WithValue(req.Context(), EmailKey, email)
|
|
ctx = context.WithValue(ctx, ElevatedKey, true)
|
|
req = req.WithContext(ctx)
|
|
rec := httptest.NewRecorder()
|
|
ServeFileAPI(cfg, rec, req)
|
|
return rec
|
|
}
|
|
return cfg, do, root
|
|
}
|
|
|
|
func sha32(data []byte) string {
|
|
sum := sha256.Sum256(data)
|
|
return hex.EncodeToString(sum[:])[:32]
|
|
}
|
|
|
|
func TestFileAPI_PutCreatesFile(t *testing.T) {
|
|
_, do, root := fileAPITestSetup(t, []string{"Incoming"}, nil)
|
|
|
|
body := []byte("hello world")
|
|
rec := do(http.MethodPut, "/Incoming/note.txt", "alice@example.com", body, nil)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(root, "Incoming/note.txt"))
|
|
if err != nil {
|
|
t.Fatalf("read back: %v", err)
|
|
}
|
|
if string(got) != "hello world" {
|
|
t.Fatalf("body mismatch: %q", got)
|
|
}
|
|
wantTag := `"` + sha32(body) + `"`
|
|
if got := rec.Header().Get("ETag"); got != wantTag {
|
|
t.Fatalf("ETag: want %s, got %s", wantTag, got)
|
|
}
|
|
}
|
|
|
|
func TestFileAPI_PutOverwritesExisting(t *testing.T) {
|
|
_, do, root := fileAPITestSetup(t, nil, map[string]string{
|
|
"Incoming/old.txt": "first",
|
|
})
|
|
|
|
body := []byte("second")
|
|
rec := do(http.MethodPut, "/Incoming/old.txt", "alice@example.com", body, nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("want 200 (overwrite), got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
got, _ := os.ReadFile(filepath.Join(root, "Incoming/old.txt"))
|
|
if string(got) != "second" {
|
|
t.Fatalf("body: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestFileAPI_PutAutoCreatesParents(t *testing.T) {
|
|
_, do, root := fileAPITestSetup(t, nil, nil)
|
|
|
|
rec := do(http.MethodPut, "/Incoming/sub/deep/x.bin", "alice@example.com", []byte("data"), nil)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "Incoming/sub/deep/x.bin")); err != nil {
|
|
t.Fatalf("stat: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFileAPI_PutDenyForbidden(t *testing.T) {
|
|
cfg, do, _ := fileAPITestSetup(t, []string{"Working"}, nil)
|
|
|
|
// Tighten ACL to a different domain — alice@example.com no longer
|
|
// matches and writes must be 403.
|
|
if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"),
|
|
[]byte("acl:\n permissions:\n \"*@allowed.com\": rwcd\n"), 0o644); err != nil {
|
|
t.Fatalf("rewrite .zddc: %v", err)
|
|
}
|
|
zddc.InvalidateCache(cfg.Root)
|
|
|
|
rec := do(http.MethodPut, "/Working/note.md", "alice@example.com", []byte("nope"), nil)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// 403 body carries JSON with the missing verb so the client toast
|
|
// can render "you need <verb> here" and offer elevation when the
|
|
// path-scoped /.profile/access reports an elevation grant. PUT to
|
|
// a path with no existing file is gated on `c` (create); a PUT
|
|
// over an existing file would gate on `w` instead — covered by
|
|
// the test below.
|
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" {
|
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
|
}
|
|
var body struct {
|
|
Error string `json:"error"`
|
|
MissingVerb string `json:"missing_verb"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode 403 body: %v (raw: %s)", err, rec.Body.String())
|
|
}
|
|
if body.MissingVerb != "c" {
|
|
t.Errorf("missing_verb = %q, want c (PUT to non-existing file gates on create)", body.MissingVerb)
|
|
}
|
|
}
|
|
|
|
// TestFileAPI_PutDenyForbiddenOverwriteVerb — PUT over an existing file
|
|
// gates on the write verb, so 403 reports missing_verb=w. Mirrors
|
|
// TestFileAPI_PutDenyForbidden but with a seeded file.
|
|
func TestFileAPI_PutDenyForbiddenOverwriteVerb(t *testing.T) {
|
|
cfg, do, _ := fileAPITestSetup(t, nil, map[string]string{
|
|
"Working/seeded.md": "before",
|
|
})
|
|
if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"),
|
|
[]byte("acl:\n permissions:\n \"*@allowed.com\": rwcd\n"), 0o644); err != nil {
|
|
t.Fatalf("rewrite .zddc: %v", err)
|
|
}
|
|
zddc.InvalidateCache(cfg.Root)
|
|
|
|
rec := do(http.MethodPut, "/Working/seeded.md", "alice@example.com", []byte("after"), nil)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
var body struct {
|
|
MissingVerb string `json:"missing_verb"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode 403 body: %v", err)
|
|
}
|
|
if body.MissingVerb != "w" {
|
|
t.Errorf("missing_verb = %q, want w (PUT over existing file gates on write)", body.MissingVerb)
|
|
}
|
|
}
|
|
|
|
func TestFileAPI_PutHiddenSegmentRejected(t *testing.T) {
|
|
_, do, _ := fileAPITestSetup(t, nil, nil)
|
|
|
|
// .zddc as a leaf is carved out — gated on admin authority via the
|
|
// decider, not blocked at the segment guard. Every other dot/
|
|
// underscore segment stays reserved.
|
|
for _, p := range []string{"/foo/.hidden", "/_app/spoof.html", "/_template/x", "/.zddc.d/x"} {
|
|
rec := do(http.MethodPut, p, "alice@example.com", []byte("x"), nil)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("want 404 for %s, got %d", p, rec.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFileAPI_PutOversizeRejected(t *testing.T) {
|
|
cfg, _, _ := fileAPITestSetup(t, []string{"Incoming"}, nil)
|
|
cfg.MaxWriteBytes = 16
|
|
|
|
body := bytes.Repeat([]byte("A"), 32)
|
|
req := httptest.NewRequest(http.MethodPut, "/Incoming/big.bin", bytes.NewReader(body))
|
|
ctx := context.WithValue(req.Context(), EmailKey, "alice@example.com")
|
|
ctx = context.WithValue(ctx, ElevatedKey, true)
|
|
req = req.WithContext(ctx)
|
|
rec := httptest.NewRecorder()
|
|
ServeFileAPI(cfg, rec, req)
|
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("want 413, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestFileAPI_PutTrailingSlashRejected(t *testing.T) {
|
|
_, do, _ := fileAPITestSetup(t, nil, nil)
|
|
rec := do(http.MethodPut, "/Incoming/", "alice@example.com", []byte("x"), nil)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("want 400, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestFileAPI_DeleteRemovesFile(t *testing.T) {
|
|
_, do, root := fileAPITestSetup(t, nil, map[string]string{
|
|
"Incoming/old.txt": "garbage",
|
|
})
|
|
|
|
rec := do(http.MethodDelete, "/Incoming/old.txt", "alice@example.com", nil, nil)
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("want 204, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "Incoming/old.txt")); !os.IsNotExist(err) {
|
|
t.Fatalf("file should be gone, err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestFileAPI_DeleteMissing404(t *testing.T) {
|
|
_, do, _ := fileAPITestSetup(t, nil, nil)
|
|
rec := do(http.MethodDelete, "/Incoming/never-existed.txt", "alice@example.com", nil, nil)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("want 404, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestFileAPI_DeleteDirectoryConflict(t *testing.T) {
|
|
_, do, _ := fileAPITestSetup(t, []string{"Incoming/sub"}, nil)
|
|
rec := do(http.MethodDelete, "/Incoming/sub", "alice@example.com", nil, nil)
|
|
if rec.Code != http.StatusConflict {
|
|
t.Fatalf("want 409, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestFileAPI_MoveRenames(t *testing.T) {
|
|
_, do, root := fileAPITestSetup(t, nil, map[string]string{
|
|
"Incoming/old.pdf": "PDF body",
|
|
})
|
|
|
|
rec := do(http.MethodPost, "/Incoming/old.pdf", "alice@example.com", nil, map[string]string{
|
|
"X-ZDDC-Op": "move",
|
|
"X-ZDDC-Destination": "/Incoming/new.pdf",
|
|
})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "Incoming/old.pdf")); !os.IsNotExist(err) {
|
|
t.Fatalf("source still exists")
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(root, "Incoming/new.pdf"))
|
|
if err != nil {
|
|
t.Fatalf("read dest: %v", err)
|
|
}
|
|
if string(got) != "PDF body" {
|
|
t.Fatalf("dest bytes: %q", got)
|
|
}
|
|
if dst := rec.Header().Get("X-ZDDC-Destination"); dst != "/Incoming/new.pdf" {
|
|
t.Fatalf("destination header: %s", dst)
|
|
}
|
|
}
|
|
|
|
func TestFileAPI_MoveDestinationExistsConflict(t *testing.T) {
|
|
_, do, _ := fileAPITestSetup(t, nil, map[string]string{
|
|
"Incoming/a.txt": "a",
|
|
"Incoming/b.txt": "b",
|
|
})
|
|
|
|
rec := do(http.MethodPost, "/Incoming/a.txt", "alice@example.com", nil, map[string]string{
|
|
"X-ZDDC-Op": "move",
|
|
"X-ZDDC-Destination": "/Incoming/b.txt",
|
|
})
|
|
if rec.Code != http.StatusConflict {
|
|
t.Fatalf("want 409, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestFileAPI_MoveMissingDestinationHeader(t *testing.T) {
|
|
_, do, _ := fileAPITestSetup(t, nil, map[string]string{
|
|
"Incoming/a.txt": "a",
|
|
})
|
|
rec := do(http.MethodPost, "/Incoming/a.txt", "alice@example.com", nil, map[string]string{
|
|
"X-ZDDC-Op": "move",
|
|
})
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("want 400, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestFileAPI_MoveCreatesParentDirs(t *testing.T) {
|
|
_, do, root := fileAPITestSetup(t, nil, map[string]string{
|
|
"Incoming/a.txt": "hi",
|
|
})
|
|
rec := do(http.MethodPost, "/Incoming/a.txt", "alice@example.com", nil, map[string]string{
|
|
"X-ZDDC-Op": "move",
|
|
"X-ZDDC-Destination": "/Working/sub/a.txt",
|
|
})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "Working/sub/a.txt")); err != nil {
|
|
t.Fatalf("dest not present: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFileAPI_PostUnknownOp(t *testing.T) {
|
|
_, do, _ := fileAPITestSetup(t, []string{"Incoming"}, nil)
|
|
rec := do(http.MethodPost, "/Incoming/x.txt", "alice@example.com", nil, map[string]string{
|
|
"X-ZDDC-Op": "weld",
|
|
})
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("want 400, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestFileAPI_PostMissingOp(t *testing.T) {
|
|
_, do, _ := fileAPITestSetup(t, []string{"Incoming"}, nil)
|
|
rec := do(http.MethodPost, "/Incoming/x.txt", "alice@example.com", nil, nil)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("want 400, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestFileAPI_MkdirCreates(t *testing.T) {
|
|
// Project-root mkdir is restricted to archive/ + system names
|
|
// after the layout reshape; test mkdir at a depth where the
|
|
// guard doesn't fire (under archive/<party>/incoming/).
|
|
_, do, root := fileAPITestSetup(t, []string{"Proj/archive/Acme/incoming"}, nil)
|
|
|
|
rec := do(http.MethodPost, "/Proj/archive/Acme/incoming/newfolder/", "alice@example.com", nil, map[string]string{
|
|
"X-ZDDC-Op": "mkdir",
|
|
})
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
info, err := os.Stat(filepath.Join(root, "Proj/archive/Acme/incoming/newfolder"))
|
|
if err != nil {
|
|
t.Fatalf("stat: %v", err)
|
|
}
|
|
if !info.IsDir() {
|
|
t.Fatalf("not a dir")
|
|
}
|
|
}
|
|
|
|
func TestFileAPI_MkdirIdempotent(t *testing.T) {
|
|
_, do, _ := fileAPITestSetup(t, []string{"Proj/archive/Acme/incoming/exists"}, nil)
|
|
rec := do(http.MethodPost, "/Proj/archive/Acme/incoming/exists/", "alice@example.com", nil, map[string]string{
|
|
"X-ZDDC-Op": "mkdir",
|
|
})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("want 200, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// TestFileAPI_MkdirProjectRootGuard — direct mkdir at <project>/<name>/
|
|
// is restricted: archive/ and system names (_/.-prefix) are allowed,
|
|
// any other name (including the six virtual aggregator names) is
|
|
// rejected with 409.
|
|
func TestFileAPI_MkdirProjectRootGuard(t *testing.T) {
|
|
_, do, _ := fileAPITestSetup(t, []string{"Proj"}, nil)
|
|
// Reject ad-hoc name.
|
|
rec := do(http.MethodPost, "/Proj/notes/", "alice@example.com", nil, map[string]string{
|
|
"X-ZDDC-Op": "mkdir",
|
|
})
|
|
if rec.Code != http.StatusConflict {
|
|
t.Fatalf("want 409 for /Proj/notes/, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
// Reject each virtual aggregator name.
|
|
for _, name := range []string{"ssr", "mdl", "rsk", "working", "staging", "reviewing"} {
|
|
rec := do(http.MethodPost, "/Proj/"+name+"/", "alice@example.com", nil, map[string]string{
|
|
"X-ZDDC-Op": "mkdir",
|
|
})
|
|
if rec.Code != http.StatusConflict {
|
|
t.Fatalf("%s: want 409, got %d: %s", name, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
// Allow archive/.
|
|
rec = do(http.MethodPost, "/Proj/archive/", "alice@example.com", nil, map[string]string{
|
|
"X-ZDDC-Op": "mkdir",
|
|
})
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("want 201 for /Proj/archive/, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
// `_`/`.`-prefixed system names are caught earlier (resolveTargetPath
|
|
// rejects them as reserved path segments with 404 — see fileapi.go
|
|
// resolveTargetPath); the mkdir guard would also allow them, so the
|
|
// composite end-state is reserved + 404. Tested elsewhere.
|
|
}
|
|
|
|
func TestFileAPI_IfMatchEnforced(t *testing.T) {
|
|
_, do, _ := fileAPITestSetup(t, nil, map[string]string{
|
|
"Incoming/x.txt": "v1",
|
|
})
|
|
|
|
// Wrong ETag → 412.
|
|
rec := do(http.MethodPut, "/Incoming/x.txt", "alice@example.com", []byte("v2"), map[string]string{
|
|
"If-Match": `"` + strings.Repeat("0", 32) + `"`,
|
|
})
|
|
if rec.Code != http.StatusPreconditionFailed {
|
|
t.Fatalf("want 412, got %d", rec.Code)
|
|
}
|
|
|
|
// Correct ETag → 200.
|
|
correctTag := sha32([]byte("v1"))
|
|
rec = do(http.MethodPut, "/Incoming/x.txt", "alice@example.com", []byte("v2"), map[string]string{
|
|
"If-Match": `"` + correctTag + `"`,
|
|
})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestFileAPI_IfMatchWildcardOnMissing(t *testing.T) {
|
|
_, do, _ := fileAPITestSetup(t, []string{"Incoming"}, nil)
|
|
rec := do(http.MethodPut, "/Incoming/new.txt", "alice@example.com", []byte("data"), map[string]string{
|
|
"If-Match": `*`,
|
|
})
|
|
if rec.Code != http.StatusPreconditionFailed {
|
|
t.Fatalf("want 412 (wildcard expects existing), got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestFileAPI_PathTraversalBlocked(t *testing.T) {
|
|
_, do, _ := fileAPITestSetup(t, nil, nil)
|
|
|
|
rec := do(http.MethodPut, "/../escaped.txt", "alice@example.com", []byte("x"), nil)
|
|
if rec.Code != http.StatusNotFound && rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("traversal not blocked: %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestFileAPI_AnonymousDenied(t *testing.T) {
|
|
_, do, _ := fileAPITestSetup(t, []string{"Incoming"}, nil)
|
|
|
|
rec := do(http.MethodPut, "/Incoming/note.txt", "", []byte("x"), nil)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("want 403 for anon, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// rolePermissionsTestSetup creates a project + per-party exchange shape:
|
|
//
|
|
// root .zddc: _company:r, _doc_controller:rwcda
|
|
// <project>/archive/Acme/.zddc: vendor_acme:rwcd, _doc_controller:rwcda, _company:""
|
|
// roles defined at root.
|
|
//
|
|
// The project is "Project-X"; the counterparty is "Acme". URLs target
|
|
// paths like /Project-X/archive/Acme/incoming/<file>.
|
|
//
|
|
// Returns the same do() helper as fileAPITestSetup.
|
|
func rolePermissionsTestSetup(t *testing.T) (cfg config.Config, do func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder, root string) {
|
|
t.Helper()
|
|
root = t.TempDir()
|
|
|
|
// Root .zddc — company gets r, doc_controller gets rwcda. Roles
|
|
// defined here so the per-party subtree's permissions can reference
|
|
// them by name.
|
|
rootZ := []byte(`roles:
|
|
_company:
|
|
members: ["*@mycompany.com"]
|
|
_doc_controller:
|
|
members: [dc@mycompany.com]
|
|
vendor_acme:
|
|
members: ["*@acme.com"]
|
|
acl:
|
|
permissions:
|
|
_company: r
|
|
_doc_controller: rwcda
|
|
`)
|
|
if err := os.WriteFile(filepath.Join(root, ".zddc"), rootZ, 0o644); err != nil {
|
|
t.Fatalf("root .zddc: %v", err)
|
|
}
|
|
|
|
// Project + per-party canonical layout.
|
|
partyDir := filepath.Join(root, "Project-X", "archive", "Acme")
|
|
for _, sub := range []string{"incoming", "issued", "received"} {
|
|
if err := os.MkdirAll(filepath.Join(partyDir, sub), 0o755); err != nil {
|
|
t.Fatalf("mkdir party/%s: %v", sub, err)
|
|
}
|
|
}
|
|
partyZ := []byte(`acl:
|
|
permissions:
|
|
vendor_acme: rwcd
|
|
_doc_controller: rwcda
|
|
_company: ""
|
|
`)
|
|
if err := os.WriteFile(filepath.Join(partyDir, ".zddc"), partyZ, 0o644); err != nil {
|
|
t.Fatalf("party .zddc: %v", err)
|
|
}
|
|
|
|
zddc.InvalidateCache(root)
|
|
|
|
cfg = config.Config{
|
|
Root: root,
|
|
EmailHeader: "X-Auth-Request-Email",
|
|
MaxWriteBytes: 1024 * 1024,
|
|
}
|
|
decider := &policy.InternalDecider{}
|
|
|
|
do = func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder {
|
|
var req *http.Request
|
|
if body != nil {
|
|
req = httptest.NewRequest(method, target, bytes.NewReader(body))
|
|
} else {
|
|
req = httptest.NewRequest(method, target, nil)
|
|
}
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
ctx := context.WithValue(req.Context(), EmailKey, email)
|
|
ctx = context.WithValue(ctx, ElevatedKey, true)
|
|
ctx = context.WithValue(ctx, DeciderKey, decider)
|
|
req = req.WithContext(ctx)
|
|
rec := httptest.NewRecorder()
|
|
ServeFileAPI(cfg, rec, req)
|
|
return rec
|
|
}
|
|
return cfg, do, root
|
|
}
|
|
|
|
func TestFileAPI_RoleBasedVendorIncoming(t *testing.T) {
|
|
_, do, _ := rolePermissionsTestSetup(t)
|
|
|
|
// Vendor PUTs into their incoming → 201.
|
|
rec := do(http.MethodPut, "/Project-X/archive/Acme/incoming/submission.pdf", "rep@acme.com", []byte("data"), nil)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("PUT vendor → incoming: want 201, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
// Vendor overwrites the same file → 200 (rwcd has w).
|
|
rec = do(http.MethodPut, "/Project-X/archive/Acme/incoming/submission.pdf", "rep@acme.com", []byte("data2"), nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("PUT vendor → incoming overwrite: want 200, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestFileAPI_WORM_VendorReadOnlyInIssued(t *testing.T) {
|
|
_, do, root := rolePermissionsTestSetup(t)
|
|
|
|
// Seed an existing issued file.
|
|
if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/spec.pdf"), []byte("FILED"), 0o644); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
// Vendor cannot overwrite — ancestor grant masked to r in issued.
|
|
rec := do(http.MethodPut, "/Project-X/archive/Acme/issued/spec.pdf", "rep@acme.com", []byte("tamper"), nil)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("PUT vendor → issued (overwrite): want 403, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
// Vendor cannot delete.
|
|
rec = do(http.MethodDelete, "/Project-X/archive/Acme/issued/spec.pdf", "rep@acme.com", nil, nil)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("DELETE vendor → issued: want 403, got %d", rec.Code)
|
|
}
|
|
// Vendor cannot create new files — they have no explicit .zddc grant
|
|
// at the issued folder, so the WORM split reduces their inherited
|
|
// rwcd to r-only.
|
|
rec = do(http.MethodPut, "/Project-X/archive/Acme/issued/new.pdf", "rep@acme.com", []byte("x"), nil)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("PUT vendor → issued (create): want 403 (no explicit grant at issued), got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestFileAPI_WORM_DocControllerNeedsExplicitGrant(t *testing.T) {
|
|
_, do, root := rolePermissionsTestSetup(t)
|
|
|
|
// Without a .zddc at archive/Acme/issued/ explicitly granting cr,
|
|
// the dc's inherited rwcda is masked to r. They cannot create.
|
|
rec := do(http.MethodPut, "/Project-X/archive/Acme/issued/no-grant.pdf", "dc@mycompany.com", []byte("x"), nil)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("dc without explicit grant → issued: want 403, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// Operator names the document-controller role in the issued/ WORM
|
|
// zone. That role's members then get {r, c} there — the embedded
|
|
// `worm: []` (no controllers) is unioned with this deeper grant.
|
|
issuedZ := []byte("worm:\n - _doc_controller\n")
|
|
if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/.zddc"), issuedZ, 0o644); err != nil {
|
|
t.Fatalf("write issued .zddc: %v", err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
rec = do(http.MethodPut, "/Project-X/archive/Acme/issued/2026-Q2-spec.pdf", "dc@mycompany.com", []byte("CONTROLLED"), nil)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("dc with explicit grant → issued: want 201, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
got, _ := os.ReadFile(filepath.Join(root, "Project-X/archive/Acme/issued/2026-Q2-spec.pdf"))
|
|
if string(got) != "CONTROLLED" {
|
|
t.Fatalf("body: %q", got)
|
|
}
|
|
|
|
// dc still cannot overwrite — explicit grant is cr, no w.
|
|
rec = do(http.MethodPut, "/Project-X/archive/Acme/issued/2026-Q2-spec.pdf", "dc@mycompany.com", []byte("REVISION"), nil)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("dc PUT overwrite → issued: want 403, got %d", rec.Code)
|
|
}
|
|
// dc still cannot delete.
|
|
rec = do(http.MethodDelete, "/Project-X/archive/Acme/issued/2026-Q2-spec.pdf", "dc@mycompany.com", nil, nil)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("dc DELETE → issued: want 403, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestFileAPI_WORM_AdminBypass(t *testing.T) {
|
|
cfg, do, root := rolePermissionsTestSetup(t)
|
|
|
|
// Promote root@example.com to root admin.
|
|
rootZ, _ := os.ReadFile(filepath.Join(cfg.Root, ".zddc"))
|
|
updated := string(rootZ) + "\nadmins:\n - root@example.com\n"
|
|
if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"), []byte(updated), 0o644); err != nil {
|
|
t.Fatalf("rewrite root .zddc: %v", err)
|
|
}
|
|
zddc.InvalidateCache(cfg.Root)
|
|
|
|
// Seed an issued file and have root@ delete it (escape hatch).
|
|
if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/mistake.pdf"), []byte("oops"), 0o644); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
rec := do(http.MethodDelete, "/Project-X/archive/Acme/issued/mistake.pdf", "root@example.com", nil, nil)
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("admin DELETE → issued: want 204, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestFileAPI_AutoMkdirOwnership(t *testing.T) {
|
|
_, do, root := rolePermissionsTestSetup(t)
|
|
|
|
// Vendor creates a folder under their incoming. Server should
|
|
// auto-write a .zddc granting them rwcda on the new subtree.
|
|
rec := do(http.MethodPost, "/Project-X/archive/Acme/incoming/2026-05-15-issue/", "rep@acme.com", nil, map[string]string{
|
|
"X-ZDDC-Op": "mkdir",
|
|
})
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
autoZ := filepath.Join(root, "Project-X/archive/Acme/incoming/2026-05-15-issue/.zddc")
|
|
data, err := os.ReadFile(autoZ)
|
|
if err != nil {
|
|
t.Fatalf("auto .zddc not written: %v", err)
|
|
}
|
|
body := string(data)
|
|
if !strings.Contains(body, "created_by: rep@acme.com") {
|
|
t.Errorf("auto .zddc missing created_by: %s", body)
|
|
}
|
|
if !strings.Contains(body, "rep@acme.com: rwcda") {
|
|
t.Errorf("auto .zddc missing email→rwcda grant: %s", body)
|
|
}
|
|
|
|
// Now the cascade caches are stale because we didn't go through
|
|
// WriteFile here; the server's writeAutoOwnZddc DID call WriteFile
|
|
// (via zddc.WriteFile → InvalidateCache). Confirm the vendor can
|
|
// now PUT a brand-new file inside their owned folder where they
|
|
// otherwise wouldn't have ACL admin rights.
|
|
zddc.InvalidateCache(root)
|
|
rec = do(http.MethodPut, "/Project-X/archive/Acme/incoming/2026-05-15-issue/note.txt", "rep@acme.com", []byte("x"), nil)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("vendor PUT in own subtree: want 201, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) {
|
|
_, do, root := rolePermissionsTestSetup(t)
|
|
|
|
// Name the document-controller role in the issued/ WORM zone so its
|
|
// members get cr there.
|
|
issuedZ := []byte("worm:\n - _doc_controller\n")
|
|
if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/.zddc"), issuedZ, 0o644); err != nil {
|
|
t.Fatalf("seed issued .zddc: %v", err)
|
|
}
|
|
zddc.InvalidateCache(root)
|
|
|
|
// Doc controller mkdir under issued — should succeed (cr survives the
|
|
// WORM mask) but should NOT auto-write an ownership .zddc (issued is
|
|
// not declared auto_own in the cascade).
|
|
rec := do(http.MethodPost, "/Project-X/archive/Acme/issued/2026-Q2/", "dc@mycompany.com", nil, map[string]string{
|
|
"X-ZDDC-Op": "mkdir",
|
|
})
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
autoZ := filepath.Join(root, "Project-X/archive/Acme/issued/2026-Q2/.zddc")
|
|
if _, err := os.Stat(autoZ); !os.IsNotExist(err) {
|
|
t.Errorf("auto .zddc should NOT be written under issued; got err=%v", err)
|
|
}
|
|
}
|
|
|
|
// (The pre-reshape staging↔working mirror was retired: with staging at
|
|
// archive/<party>/staging/<batch>/ and working at archive/<party>/
|
|
// working/<email>/, the project-level pairing no longer maps cleanly.
|
|
// Tests for the removed behaviour have been deleted.)
|
|
|
|
// Mkdir INSIDE a project-level virtual aggregator is 409'd with a
|
|
// pointer at the party-scoped path, instead of silently materialising an
|
|
// unreachable shadow. The same folder created under archive/<party>/
|
|
// <slot>/ succeeds — which is what browse's party picker targets.
|
|
func TestFileAPI_MkdirInAggregatorRejected(t *testing.T) {
|
|
_, do, root := fileAPITestSetup(t, nil, nil)
|
|
|
|
for _, slot := range []string{"working", "staging", "reviewing", "ssr", "mdl", "rsk"} {
|
|
rec := do(http.MethodPost, "/Proj/"+slot+"/foo/", "alice@example.com", nil, map[string]string{
|
|
"X-ZDDC-Op": "mkdir",
|
|
})
|
|
if rec.Code != http.StatusConflict {
|
|
t.Errorf("%s: mkdir in aggregator: want 409, got %d: %s", slot, rec.Code, rec.Body.String())
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "Proj", slot)); !os.IsNotExist(err) {
|
|
t.Errorf("%s: aggregator slot must not be materialised; got err=%v", slot, err)
|
|
}
|
|
}
|
|
|
|
// The party-scoped path the picker resolves to works.
|
|
rec := do(http.MethodPost, "/Proj/archive/Acme/working/drafts/", "alice@example.com", nil, map[string]string{
|
|
"X-ZDDC-Op": "mkdir",
|
|
})
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("party-scoped mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "Proj", "archive", "Acme", "working", "drafts")); err != nil {
|
|
t.Errorf("party-scoped folder not created: %v", err)
|
|
}
|
|
}
|
|
|
|
// An in-place rename of a markdown file carries its .history/<stem>/ folder
|
|
// to the new name; a cross-directory move leaves history behind.
|
|
func TestFileAPI_MoveRenamesHistoryDir(t *testing.T) {
|
|
_, do, root := fileAPITestSetup(t, nil, map[string]string{
|
|
"Docs/notes.md": "# notes\n",
|
|
})
|
|
// Pre-seed a history snapshot for notes.md.
|
|
histOld := filepath.Join(root, "Docs", ".history", "notes")
|
|
if err := os.MkdirAll(histOld, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(histOld, "20260101T000000.000Z-a@example.com.md"), []byte("# notes\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// In-place rename → history dir follows.
|
|
rec := do(http.MethodPost, "/Docs/notes.md", "alice@example.com", nil, map[string]string{
|
|
"X-ZDDC-Op": "move",
|
|
"X-ZDDC-Destination": "/Docs/renamed.md",
|
|
})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("rename: want 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "Docs", ".history", "renamed")); err != nil {
|
|
t.Errorf("history dir not renamed to <newstem>: %v", err)
|
|
}
|
|
if _, err := os.Stat(histOld); !os.IsNotExist(err) {
|
|
t.Errorf("old history dir should be gone after rename; err=%v", err)
|
|
}
|
|
|
|
// Cross-directory move → history stays behind in the source dir.
|
|
if err := os.MkdirAll(filepath.Join(root, "Other"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec = do(http.MethodPost, "/Docs/renamed.md", "alice@example.com", nil, map[string]string{
|
|
"X-ZDDC-Op": "move",
|
|
"X-ZDDC-Destination": "/Other/renamed.md",
|
|
})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("cross-dir move: want 200, got %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "Docs", ".history", "renamed")); err != nil {
|
|
t.Errorf("history should stay behind on a cross-dir move: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "Other", ".history", "renamed")); !os.IsNotExist(err) {
|
|
t.Errorf("history should NOT follow a cross-dir move; err=%v", err)
|
|
}
|
|
}
|