Compare commits
No commits in common. "f6dc9d557a44c5ee65327db623d96b5024c64d47" and "8cf1aa10028684f4b1cc19d3678165bc0d42fd35" have entirely different histories.
f6dc9d557a
...
8cf1aa1002
13 changed files with 353 additions and 526 deletions
|
|
@ -476,57 +476,18 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for .archive segment in the path. .archive is project-scoped
|
// Check for .archive segment in the path
|
||||||
// and addressed at exactly one depth — /<project>/.archive/... — even
|
|
||||||
// though offline-built HTML files reference siblings via
|
|
||||||
// "../.archive/<tracking>.html" from arbitrary depths. Any deeper form
|
|
||||||
// (/<project>/<sub>/.../.archive/...) gets a 301 to the project-rooted
|
|
||||||
// canonical so anchored links and bookmarks normalize to a single
|
|
||||||
// stable URL per tracking number. The redirect target preserves the
|
|
||||||
// path tail after .archive/ verbatim and the query string; browsers
|
|
||||||
// preserve the fragment automatically across redirects.
|
|
||||||
//
|
|
||||||
// .archive is read-only: only GET/HEAD reach the handler. Anything
|
|
||||||
// else (PUT/POST/DELETE) returns 405 here, before the file API would
|
|
||||||
// otherwise see the request. This avoids the 302→GET silent-method-
|
|
||||||
// downgrade trap and makes the contract explicit.
|
|
||||||
for i, seg := range segments {
|
for i, seg := range segments {
|
||||||
if seg != cfg.IndexPath {
|
if seg == cfg.IndexPath {
|
||||||
continue
|
// contextPath is everything before .archive
|
||||||
}
|
contextPath := "/" + strings.Join(segments[:i], "/")
|
||||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
||||||
w.Header().Set("Allow", "GET, HEAD")
|
|
||||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// segments[0] is the project; segments[i] is .archive. i==0
|
|
||||||
// means /.archive/... at the very root, with no project to
|
|
||||||
// scope by — 404 (a tracking-number reference must be project-
|
|
||||||
// rooted; cross-project tracking-number collisions otherwise
|
|
||||||
// silently pick a winner).
|
|
||||||
if i == 0 {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
project := segments[0]
|
|
||||||
var filename string
|
var filename string
|
||||||
if i+1 < len(segments) {
|
if i+1 < len(segments) {
|
||||||
filename = strings.Join(segments[i+1:], "/")
|
filename = strings.Join(segments[i+1:], "/")
|
||||||
}
|
}
|
||||||
// Canonicalize anything below /<project>/.archive/. Building
|
handler.ServeArchive(cfg, idx, w, r, contextPath, filename)
|
||||||
// the target by hand (rather than re-encoding) keeps any
|
|
||||||
// already-encoded characters in the original URL.RawPath
|
|
||||||
// trailing segments intact for the browser to follow.
|
|
||||||
if i > 1 {
|
|
||||||
target := "/" + project + "/" + cfg.IndexPath + "/" + filename
|
|
||||||
if r.URL.RawQuery != "" {
|
|
||||||
target += "?" + r.URL.RawQuery
|
|
||||||
}
|
|
||||||
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handler.ServeArchive(cfg, idx, w, r, project, filename)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tables-system intercept: *.table.html is a virtual URL that the
|
// Tables-system intercept: *.table.html is a virtual URL that the
|
||||||
|
|
|
||||||
|
|
@ -285,138 +285,6 @@ func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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, 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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, 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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustMkdir(t *testing.T, path string) {
|
func mustMkdir(t *testing.T, path string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -2131,7 +2131,7 @@ td[data-field="trackingNumber"] {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Archive</span>
|
<span class="app-header__title">ZDDC Archive</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-07</span></span>
|
<span class="build-timestamp">v0.0.16</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data" style="font-size:1.1rem;">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data" style="font-size:1.1rem;">⟳</button>
|
||||||
|
|
|
||||||
|
|
@ -896,7 +896,7 @@ body {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Browse</span>
|
<span class="app-header__title">ZDDC Browse</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-07</span></span>
|
<span class="build-timestamp">v0.0.16</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing" style="font-size:1.1rem;">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing" style="font-size:1.1rem;">⟳</button>
|
||||||
|
|
|
||||||
|
|
@ -1394,7 +1394,7 @@ body.help-open .app-header {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Classifier</span>
|
<span class="app-header__title">ZDDC Classifier</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-07</span></span>
|
<span class="build-timestamp">v0.0.16</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||||
|
|
|
||||||
|
|
@ -885,7 +885,7 @@ body {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC</span>
|
<span class="app-header__title">ZDDC</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-07</span></span>
|
<span class="build-timestamp">v0.0.16</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
|
|
@ -1792,7 +1792,7 @@ body.help-open .app-header {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Markdown</span>
|
<span class="app-header__title">ZDDC Markdown</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-07</span></span>
|
<span class="build-timestamp">v0.0.16</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="addDirectoryBtn" class="btn btn-primary" title="Add a local directory">Add Local Directory</button>
|
<button id="addDirectoryBtn" class="btn btn-primary" title="Add a local directory">Add Local Directory</button>
|
||||||
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh directory" aria-label="Refresh" style="font-size:1.1rem;">⟳</button>
|
||||||
|
|
|
||||||
|
|
@ -2192,7 +2192,7 @@ dialog.modal--narrow {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title">ZDDC Transmittal</span>
|
<span class="app-header__title">ZDDC Transmittal</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-07</span></span>
|
<span class="build-timestamp">v0.0.16</span>
|
||||||
</div>
|
</div>
|
||||||
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
<span id="no-js-notice" class="text-gray-400 text-xs italic">JavaScript not available</span>
|
||||||
<!-- Publish split-button (Transmittal-specific primary action;
|
<!-- Publish split-button (Transmittal-specific primary action;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
# Generated by build.sh — do not edit. One <app>=<build label> per line.
|
||||||
archive=v0.0.17-beta · 2026-05-07
|
archive=v0.0.16
|
||||||
transmittal=v0.0.17-beta · 2026-05-07
|
transmittal=v0.0.16
|
||||||
classifier=v0.0.17-beta · 2026-05-07
|
classifier=v0.0.16
|
||||||
mdedit=v0.0.17-beta · 2026-05-07
|
mdedit=v0.0.16
|
||||||
landing=v0.0.17-beta · 2026-05-07
|
landing=v0.0.16
|
||||||
form=v0.0.17-beta · 2026-05-07
|
form=v0.0.16
|
||||||
tables=v0.0.17-beta · 2026-05-07
|
tables=v0.0.16
|
||||||
browse=v0.0.17-beta · 2026-05-07
|
browse=v0.0.16
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
|
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/listing"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/listing"
|
||||||
|
|
@ -15,45 +14,50 @@ import (
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServeArchive handles requests under a project's .archive virtual path.
|
// ServeArchive handles requests under a .archive virtual path segment.
|
||||||
//
|
//
|
||||||
// The dispatcher canonicalizes every .archive request to /<project>/.archive/...
|
// .archive is exposed at every folder depth so HTML produced for offline use
|
||||||
// before reaching here (any deeper /<project>/sub/.../archive/... gets a 301
|
// can reference sibling tracking numbers via "../.archive/<tracking>.html".
|
||||||
// to the project-rooted form), so this handler only ever sees one shape:
|
// In a browser the relative link is resolved before the request reaches the
|
||||||
// project = first URL segment, filename = whatever follows .archive/.
|
// server, so the contextPath the request arrives under is significant: its
|
||||||
|
// FIRST segment is the project, and the .archive listing/resolver is scoped
|
||||||
|
// to that project's bucket. This avoids cross-project collisions when the
|
||||||
|
// same tracking number is issued under multiple projects.
|
||||||
//
|
//
|
||||||
// Permissions follow the FILE, not .archive itself. .archive is a virtual
|
// contextPath: the URL path leading up to (but not including) .archive
|
||||||
// surface — it has no on-disk directory and no .zddc of its own. Two gates
|
// - first segment selects the project bucket
|
||||||
// only:
|
// - used to gate the listing endpoint via cascading .zddc ACL
|
||||||
|
// - used as the URL prefix for the entries returned in the listing
|
||||||
|
// - empty (root /.archive/) returns 404 — refs must be project-rooted
|
||||||
//
|
//
|
||||||
// 1. Listing: returned entries are filtered by the per-target file's ACL
|
// filename: the part after .archive/ (empty for directory listing)
|
||||||
// chain. If the project bucket is empty (or doesn't exist in the index)
|
func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, filename string) {
|
||||||
// the response is 404; if the user can read NO entries in a non-empty
|
email := EmailFromContext(r)
|
||||||
// bucket the response is 403, so existence of an inaccessible project's
|
decider := DeciderFromContext(r)
|
||||||
// archive does not leak.
|
ctx := r.Context()
|
||||||
//
|
|
||||||
// 2. Resolve: only the per-target file's ACL gates access. A user with
|
project := projectFromContextPath(contextPath)
|
||||||
// no project-root permission but an explicit allow on one transmittal
|
|
||||||
// folder can fetch that file's tracking-number URL; conversely, a user
|
|
||||||
// with broad project access but a narrower deny on a specific subtree
|
|
||||||
// gets 404 (not 403) on its tracking numbers — existence must not leak.
|
|
||||||
//
|
|
||||||
// Listings serve the embedded `browse` SPA on Accept: text/html and the
|
|
||||||
// JSON entry array on Accept: application/json — same content negotiation
|
|
||||||
// as ServeDirectory, so the SPA's auto-detect path-fetch works at .archive
|
|
||||||
// URLs identically to real directories.
|
|
||||||
func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, project, filename string) {
|
|
||||||
if project == "" {
|
if project == "" {
|
||||||
http.Error(w, "Not Found: .archive must be requested under a project directory (e.g. /<project>/.archive/)", http.StatusNotFound)
|
http.Error(w, "Not Found: .archive must be requested under a project directory (e.g. /<project>/.archive/)", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
email := EmailFromContext(r)
|
// ACL gate on the context directory: callers who can't reach the
|
||||||
decider := DeciderFromContext(r)
|
// directory hosting this .archive shouldn't be able to query it either.
|
||||||
ctx := r.Context()
|
dirPath := strings.TrimPrefix(contextPath, "/")
|
||||||
|
dirPath = strings.TrimSuffix(dirPath, "/")
|
||||||
|
absDir := filepath.Join(cfg.Root, filepath.FromSlash(dirPath))
|
||||||
|
chain, err := zddc.EffectivePolicy(cfg.Root, absDir)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("ACL policy error", "path", absDir, "err", err)
|
||||||
|
}
|
||||||
|
if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, contextPath); !allowed {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if filename == "" {
|
if filename == "" {
|
||||||
serveArchiveListing(cfg, idx, w, r, project, email, decider)
|
serveArchiveListing(cfg, idx, w, r, contextPath, project, email)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,11 +67,11 @@ func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter,
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-target ACL is the only gate. 404 (not 403) so the tracking
|
// Per-target ACL: the resolved file may live in a subtree the caller
|
||||||
// number's mere existence isn't disclosed to a caller who can't
|
// can't reach even though they could reach the contextPath. 404 (not
|
||||||
// actually read the resolved file.
|
// 403) so the tracking number's mere existence isn't disclosed.
|
||||||
fileDir := filepath.Dir(filepath.Join(cfg.Root, filepath.FromSlash(target)))
|
fileDir := filepath.Dir(filepath.Join(cfg.Root, filepath.FromSlash(target)))
|
||||||
chain, err := zddc.EffectivePolicy(cfg.Root, fileDir)
|
chain, err = zddc.EffectivePolicy(cfg.Root, fileDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("ACL policy error on resolved file", "path", fileDir, "err", err)
|
slog.Warn("ACL policy error on resolved file", "path", fileDir, "err", err)
|
||||||
}
|
}
|
||||||
|
|
@ -76,40 +80,50 @@ func ServeArchive(cfg config.Config, idx *archive.Index, w http.ResponseWriter,
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve in place — DO NOT redirect to the resolved file's real path.
|
// Serve the resolved file in place — DO NOT redirect. The .archive/
|
||||||
// People share .archive/<tracking>.html#section URLs and expect the
|
// URL is meant to be a stable forward-able link (people share
|
||||||
// link to keep tracking the latest revision; redirecting would pin
|
// `.archive/<tracking>.html#section` and expect that to keep tracking
|
||||||
// the bookmark to a specific transmittal-folder snapshot. The
|
// the latest revision). A redirect would expose the specific
|
||||||
// canonicalization redirect (/<project>/<sub>/.archive/X → /<project>/.archive/X)
|
// transmittal-folder URL, and any anchor/hash bookmarked from the
|
||||||
// happens upstream in the dispatcher and is a different thing — it
|
// browser bar would pin to that snapshot instead of "the latest."
|
||||||
// only collapses the .archive prefix, not the resolved bytes.
|
|
||||||
//
|
//
|
||||||
// Cache-Control: no-cache forces conditional revalidation each load —
|
// Cache-Control no-cache forces a conditional revalidation each
|
||||||
// http.ServeFile sets Last-Modified/ETag from the on-disk file, so
|
// load — http.ServeFile sets Last-Modified/ETag from the on-disk
|
||||||
// when the resolver picks a newer target the ETag changes and the
|
// file, so when the resolver picks a newer target the ETag changes
|
||||||
// browser refetches.
|
// and the browser refetches.
|
||||||
absFile := filepath.Join(cfg.Root, filepath.FromSlash(target))
|
absFile := filepath.Join(cfg.Root, filepath.FromSlash(target))
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
http.ServeFile(w, r, absFile)
|
http.ServeFile(w, r, absFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, project, email string, decider policy.Decider) {
|
// projectFromContextPath returns the first non-empty segment of the
|
||||||
ctx := r.Context()
|
// contextPath, which is the project bucket key for archive lookups. Returns
|
||||||
allEntries := idx.AllEntries(project)
|
// "" for "/" or "" (root .archive — has no project).
|
||||||
if len(allEntries) == 0 {
|
func projectFromContextPath(contextPath string) string {
|
||||||
// Project bucket missing or empty. 404 with no body distinction
|
cleaned := strings.Trim(contextPath, "/")
|
||||||
// from "unknown project" — a caller probing for project names
|
if cleaned == "" {
|
||||||
// gets the same shape whether or not the project exists.
|
return ""
|
||||||
http.Error(w, "Not Found", http.StatusNotFound)
|
}
|
||||||
return
|
if i := strings.IndexByte(cleaned, '/'); i >= 0 {
|
||||||
|
return cleaned[:i]
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
archiveBase := "/" + project + "/" + cfg.IndexPath + "/"
|
func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseWriter, r *http.Request, contextPath, project, email string) {
|
||||||
|
decider := DeciderFromContext(r)
|
||||||
|
ctx := r.Context()
|
||||||
|
allEntries := idx.AllEntries(project)
|
||||||
|
archiveBase := contextPath
|
||||||
|
if !strings.HasSuffix(archiveBase, "/") {
|
||||||
|
archiveBase += "/"
|
||||||
|
}
|
||||||
|
archiveBase += cfg.IndexPath + "/"
|
||||||
|
|
||||||
// ACL chains are folder-keyed and the listing typically hits the same
|
// ACL chains are folder-keyed and the listing typically hits the same
|
||||||
// few directories repeatedly (one per transmittal folder), so cache
|
// few directories repeatedly (one per transmittal folder), so cache the
|
||||||
// the allow/deny decision per directory rather than re-walking .zddc
|
// allow/deny decision per directory rather than re-walking .zddc files
|
||||||
// files for every entry.
|
// for every entry.
|
||||||
aclCache := make(map[string]bool)
|
aclCache := make(map[string]bool)
|
||||||
allowed := func(targetPath string) bool {
|
allowed := func(targetPath string) bool {
|
||||||
fileDir := filepath.Dir(filepath.Join(cfg.Root, filepath.FromSlash(targetPath)))
|
fileDir := filepath.Dir(filepath.Join(cfg.Root, filepath.FromSlash(targetPath)))
|
||||||
|
|
@ -126,7 +140,7 @@ func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseW
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]listing.FileInfo, 0, len(allEntries))
|
var result []listing.FileInfo
|
||||||
for _, e := range allEntries {
|
for _, e := range allEntries {
|
||||||
if !allowed(e.TargetPath) {
|
if !allowed(e.TargetPath) {
|
||||||
continue
|
continue
|
||||||
|
|
@ -138,68 +152,9 @@ func serveArchiveListing(cfg config.Config, idx *archive.Index, w http.ResponseW
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Existence-leak guard: if the user can read no entries in a
|
|
||||||
// non-empty bucket, 403 — never confirm the project's archive
|
|
||||||
// exists to a caller with no permissions in it.
|
|
||||||
if len(result) == 0 {
|
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vary: Accept is critical because the same URL serves either the
|
|
||||||
// JSON listing or the embedded browse SPA depending on Accept;
|
|
||||||
// without it, browsers/CDNs may serve one Accept's body for the
|
|
||||||
// other Accept value and break the SPA's JSON auto-fetch.
|
|
||||||
w.Header().Set("Vary", "Accept")
|
|
||||||
|
|
||||||
if strings.Contains(r.Header.Get("Accept"), "application/json") {
|
|
||||||
body, err := json.Marshal(result)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("encoding archive listing", "err", err)
|
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
etag := `"` + listingETag(body) + `"`
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("ETag", etag)
|
|
||||||
w.Header().Set("Cache-Control", "private, max-age=0, must-revalidate")
|
|
||||||
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
|
|
||||||
w.WriteHeader(http.StatusNotModified)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = w.Write(body)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTML: serve the embedded `browse` SPA. The SPA auto-detects the
|
|
||||||
// server-mode listing by re-fetching this same URL with
|
|
||||||
// Accept: application/json — that path lands in the JSON branch
|
|
||||||
// above and renders the archive entries as a sortable, filterable
|
|
||||||
// flat list.
|
|
||||||
body := apps.EmbeddedBytes("browse")
|
|
||||||
if len(body) == 0 {
|
|
||||||
// Bootstrap state: a fresh build hasn't populated browse.html
|
|
||||||
// into the embed yet. Fall through to JSON for clients that
|
|
||||||
// will still parse it.
|
|
||||||
jsonBody, err := json.Marshal(result)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("encoding archive listing (no-embed fallback)", "err", err)
|
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
_, _ = w.Write(jsonBody)
|
if err := json.NewEncoder(w).Encode(result); err != nil {
|
||||||
return
|
slog.Error("encoding archive listing", "err", err)
|
||||||
}
|
}
|
||||||
etag := `"` + apps.EmbeddedETag("browse") + `"`
|
|
||||||
w.Header().Set("ETag", etag)
|
|
||||||
w.Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.Header().Set("X-ZDDC-Source", "embedded:browse")
|
|
||||||
if match := r.Header.Get("If-None-Match"); match != "" && match == etag {
|
|
||||||
w.WriteHeader(http.StatusNotModified)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, _ = w.Write(body)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
|
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
|
||||||
"codeberg.org/VARASYS/ZDDC/zddc/internal/listing"
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/listing"
|
||||||
|
|
@ -77,46 +76,38 @@ func archiveCfg(root string) config.Config {
|
||||||
return config.Config{Root: root, EmailHeader: "X-Auth-Request-Email", IndexPath: ".archive"}
|
return config.Config{Root: root, EmailHeader: "X-Auth-Request-Email", IndexPath: ".archive"}
|
||||||
}
|
}
|
||||||
|
|
||||||
// callArchive drives ServeArchive directly with (project, filename). The
|
func callArchive(t *testing.T, cfg config.Config, idx *archive.Index, email, contextPath, filename string) *httptest.ResponseRecorder {
|
||||||
// dispatcher is responsible for canonicalizing deeper /<project>/<sub>/
|
|
||||||
// .archive/... paths to this shape (see TestDispatchArchiveRedirect in
|
|
||||||
// the cmd package). Tests that want a specific Accept header set it on
|
|
||||||
// the recorder request before calling.
|
|
||||||
func callArchive(t *testing.T, cfg config.Config, idx *archive.Index, email, project, filename string) *httptest.ResponseRecorder {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
urlPath := "/"
|
// Build a syntactically valid URL by escaping each segment of the
|
||||||
if project != "" {
|
// contextPath and filename. The handler receives the decoded
|
||||||
urlPath = "/" + url.PathEscape(project) + "/" + cfg.IndexPath + "/"
|
// contextPath/filename arguments directly (as the dispatcher would have
|
||||||
}
|
// decoded them); the URL itself just needs to parse for httptest.
|
||||||
|
urlPath := encodePath(contextPath) + "/" + cfg.IndexPath
|
||||||
if filename != "" {
|
if filename != "" {
|
||||||
urlPath += url.PathEscape(filename)
|
urlPath += "/" + url.PathEscape(filename)
|
||||||
|
} else {
|
||||||
|
urlPath += "/"
|
||||||
}
|
}
|
||||||
req := httptest.NewRequest(http.MethodGet, urlPath, nil)
|
req := httptest.NewRequest(http.MethodGet, urlPath, nil)
|
||||||
req = req.WithContext(context.WithValue(req.Context(), EmailKey, email))
|
req = req.WithContext(context.WithValue(req.Context(), EmailKey, email))
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
ServeArchive(cfg, idx, rec, req, project, filename)
|
ServeArchive(cfg, idx, rec, req, contextPath, filename)
|
||||||
return rec
|
return rec
|
||||||
}
|
}
|
||||||
|
|
||||||
// callArchiveAccept is callArchive plus a custom Accept header — used to
|
// encodePath URL-escapes each non-empty slash-separated segment of p so
|
||||||
// drive the listing's content-negotiation branches.
|
// special characters like spaces and parens don't break NewRequest's URL
|
||||||
func callArchiveAccept(t *testing.T, cfg config.Config, idx *archive.Index, email, project, filename, accept string) *httptest.ResponseRecorder {
|
// parser. A leading slash is preserved; an empty input becomes "/".
|
||||||
t.Helper()
|
func encodePath(p string) string {
|
||||||
urlPath := "/"
|
trimmed := strings.Trim(p, "/")
|
||||||
if project != "" {
|
if trimmed == "" {
|
||||||
urlPath = "/" + url.PathEscape(project) + "/" + cfg.IndexPath + "/"
|
return ""
|
||||||
}
|
}
|
||||||
if filename != "" {
|
parts := strings.Split(trimmed, "/")
|
||||||
urlPath += url.PathEscape(filename)
|
for i, s := range parts {
|
||||||
|
parts[i] = url.PathEscape(s)
|
||||||
}
|
}
|
||||||
req := httptest.NewRequest(http.MethodGet, urlPath, nil)
|
return "/" + strings.Join(parts, "/")
|
||||||
if accept != "" {
|
|
||||||
req.Header.Set("Accept", accept)
|
|
||||||
}
|
|
||||||
req = req.WithContext(context.WithValue(req.Context(), EmailKey, email))
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
ServeArchive(cfg, idx, rec, req, project, filename)
|
|
||||||
return rec
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeListing(t *testing.T, body []byte) []listing.FileInfo {
|
func decodeListing(t *testing.T, body []byte) []listing.FileInfo {
|
||||||
|
|
@ -145,49 +136,36 @@ func contains(xs []string, x string) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty project (no first segment) is rejected at the handler. The
|
// /.archive/ at the very root has no project segment to scope by, so it's a
|
||||||
// dispatcher already 404s /.archive/ before reaching here, but the handler
|
// hard 404 — even for an admin. Stable references must include the project
|
||||||
// keeps a defense-in-depth guard so a future direct caller can't bypass.
|
// directory; otherwise cross-project tracking-number collisions would silently
|
||||||
func TestServeArchive_EmptyProject404(t *testing.T) {
|
// pick a winner.
|
||||||
|
func TestServeArchive_RootHasNoProjectScope404(t *testing.T) {
|
||||||
root, idx := archiveTestRoot(t)
|
root, idx := archiveTestRoot(t)
|
||||||
writeZddc(t, root, ".", `acl:
|
writeZddc(t, root, ".", `acl:
|
||||||
allow: ["*"]
|
allow: ["*"]
|
||||||
`)
|
`)
|
||||||
cfg := archiveCfg(root)
|
cfg := archiveCfg(root)
|
||||||
|
|
||||||
rec := callArchive(t, cfg, idx, "alice@example.com", "", "")
|
for _, ctx := range []string{"/", ""} {
|
||||||
|
t.Run("ctx="+ctx, func(t *testing.T) {
|
||||||
|
rec := callArchive(t, cfg, idx, "alice@example.com", ctx, "")
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Errorf("listing with empty project: status %d, want 404", rec.Code)
|
t.Errorf("listing at root: status %d, want 404; body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
rec = callArchive(t, cfg, idx, "alice@example.com", "", "100.html")
|
rec = callArchive(t, cfg, idx, "alice@example.com", ctx, "100.html")
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Errorf("resolve with empty project: status %d, want 404", rec.Code)
|
t.Errorf("resolve at root: status %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unknown / empty project bucket returns 404 (not 403) — a probe for
|
// .archive listings are scoped to the contextPath's first segment (the
|
||||||
// project names gets the same shape whether or not the project exists.
|
// project). Each project sees only its own tracking numbers; cross-project
|
||||||
func TestServeArchive_UnknownProject404(t *testing.T) {
|
// entries are invisible. Subdirectory contextPaths still resolve to the
|
||||||
root, idx := archiveTestRoot(t)
|
// top-level project's bucket — a request from /ProjectA/sub/sub/.archive/
|
||||||
writeZddc(t, root, ".", `acl:
|
// shows ProjectA's entries with that deeper URL prefix.
|
||||||
allow: ["*"]
|
|
||||||
`)
|
|
||||||
cfg := archiveCfg(root)
|
|
||||||
|
|
||||||
rec := callArchive(t, cfg, idx, "alice@example.com", "NoSuchProject", "")
|
|
||||||
if rec.Code != http.StatusNotFound {
|
|
||||||
t.Errorf("listing for unknown project: status %d, want 404; body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
rec = callArchive(t, cfg, idx, "alice@example.com", "NoSuchProject", "100.html")
|
|
||||||
if rec.Code != http.StatusNotFound {
|
|
||||||
t.Errorf("resolve in unknown project: status %d, want 404", rec.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listing scoping: each project's bucket surfaces only its own entries,
|
|
||||||
// and entry URLs are always project-rooted (/<project>/.archive/...) —
|
|
||||||
// independent of any deeper request path the caller might have started
|
|
||||||
// from (the dispatcher canonicalizes those before reaching the handler).
|
|
||||||
func TestServeArchive_ListingScopedToProject(t *testing.T) {
|
func TestServeArchive_ListingScopedToProject(t *testing.T) {
|
||||||
root, idx := archiveTestRoot(t)
|
root, idx := archiveTestRoot(t)
|
||||||
writeZddc(t, root, ".", `acl:
|
writeZddc(t, root, ".", `acl:
|
||||||
|
|
@ -198,21 +176,28 @@ func TestServeArchive_ListingScopedToProject(t *testing.T) {
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
project string
|
contextPath string
|
||||||
urlPrefix string
|
urlPrefix string
|
||||||
wantNames []string
|
wantNames []string
|
||||||
denyNames []string
|
denyNames []string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"ProjectA",
|
"ProjectA top level",
|
||||||
"ProjectA",
|
"/ProjectA",
|
||||||
"/ProjectA/.archive/",
|
"/ProjectA/.archive/",
|
||||||
[]string{"100.html", "100_A.html", "100_~A.html"},
|
[]string{"100.html", "100_A.html", "100_~A.html"},
|
||||||
[]string{"200.html", "200_0.html"},
|
[]string{"200.html", "200_0.html"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ProjectB",
|
"ProjectA deeper subpath",
|
||||||
"ProjectB",
|
"/ProjectA/2025-01-01_T1 (IFR) - Title",
|
||||||
|
"/ProjectA/2025-01-01_T1 (IFR) - Title/.archive/",
|
||||||
|
[]string{"100.html", "100_A.html", "100_~A.html"},
|
||||||
|
[]string{"200.html", "200_0.html"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ProjectB top level",
|
||||||
|
"/ProjectB",
|
||||||
"/ProjectB/.archive/",
|
"/ProjectB/.archive/",
|
||||||
[]string{"200.html", "200_0.html"},
|
[]string{"200.html", "200_0.html"},
|
||||||
[]string{"100.html", "100_A.html", "100_~A.html"},
|
[]string{"100.html", "100_A.html", "100_~A.html"},
|
||||||
|
|
@ -221,7 +206,7 @@ func TestServeArchive_ListingScopedToProject(t *testing.T) {
|
||||||
|
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
rec := callArchiveAccept(t, cfg, idx, email, c.project, "", "application/json")
|
rec := callArchive(t, cfg, idx, email, c.contextPath, "")
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -229,12 +214,12 @@ func TestServeArchive_ListingScopedToProject(t *testing.T) {
|
||||||
gotNames := names(got)
|
gotNames := names(got)
|
||||||
for _, want := range c.wantNames {
|
for _, want := range c.wantNames {
|
||||||
if !contains(gotNames, want) {
|
if !contains(gotNames, want) {
|
||||||
t.Errorf("missing %q in %s; got %v", want, c.project, gotNames)
|
t.Errorf("missing %q at %s; got %v", want, c.contextPath, gotNames)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, deny := range c.denyNames {
|
for _, deny := range c.denyNames {
|
||||||
if contains(gotNames, deny) {
|
if contains(gotNames, deny) {
|
||||||
t.Errorf("unexpected cross-project entry %q in %s; got %v", deny, c.project, gotNames)
|
t.Errorf("unexpected cross-project entry %q at %s; got %v", deny, c.contextPath, gotNames)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, e := range got {
|
for _, e := range got {
|
||||||
|
|
@ -246,34 +231,36 @@ func TestServeArchive_ListingScopedToProject(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listing existence-leak guard: a user who can read no entries in a
|
// Listing endpoint is gated by the contextPath ACL: callers who can't reach
|
||||||
// non-empty project bucket gets 403, NOT 200 with an empty list. The
|
// the directory the .archive virtually sits in get 403 (the directory is
|
||||||
// project must not confirm its existence to a caller with no permissions.
|
// known to exist; just not accessible).
|
||||||
func TestServeArchive_ListingForbiddenWhenUserCanReadNothing(t *testing.T) {
|
func TestServeArchive_ListingDeniedByContextPathACL(t *testing.T) {
|
||||||
root, idx := archiveTestRoot(t)
|
root, idx := archiveTestRoot(t)
|
||||||
// Default-deny: only alice listed at any level. mallory is in no
|
|
||||||
// allow list anywhere → every per-target check returns deny → the
|
|
||||||
// filtered listing is empty → 403.
|
|
||||||
writeZddc(t, root, ".", `acl:
|
writeZddc(t, root, ".", `acl:
|
||||||
allow: ["alice@example.com"]
|
allow: ["alice@example.com"]
|
||||||
|
`)
|
||||||
|
writeZddc(t, root, "ProjectA", `acl:
|
||||||
|
deny: ["mallory@example.com"]
|
||||||
|
allow: ["alice@example.com"]
|
||||||
`)
|
`)
|
||||||
cfg := archiveCfg(root)
|
cfg := archiveCfg(root)
|
||||||
|
|
||||||
rec := callArchiveAccept(t, cfg, idx, "mallory@example.com", "ProjectA", "", "application/json")
|
rec := callArchive(t, cfg, idx, "mallory@example.com", "/ProjectA", "")
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Errorf("mallory listing: status %d, want 403; body=%s", rec.Code, rec.Body.String())
|
t.Errorf("denied caller got status %d, want 403; body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
rec = callArchiveAccept(t, cfg, idx, "alice@example.com", "ProjectA", "", "application/json")
|
rec = callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", "")
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Errorf("alice listing: status %d, want 200; body=%s", rec.Code, rec.Body.String())
|
t.Errorf("allowed caller got status %d, want 200; body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listing entries are filtered per-target by ACL: a caller denied at one
|
// Listing entries are filtered per-target by ACL: a caller denied at a
|
||||||
// transmittal subtree but allowed at others sees the unblocked entries
|
// subtree's transmittal directory sees no entries whose target lives there.
|
||||||
// (200 with the subset), not 403, because they have SOME read access
|
// Excluding a user from a subdir requires an explicit deny there (the
|
||||||
// in the project.
|
// cascade is "first explicit match wins, bottom-up", so a child allow list
|
||||||
|
// doesn't narrow a parent's allow:["*"]).
|
||||||
func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) {
|
func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) {
|
||||||
root, idx := archiveTestRoot(t)
|
root, idx := archiveTestRoot(t)
|
||||||
writeZddc(t, root, ".", `acl:
|
writeZddc(t, root, ".", `acl:
|
||||||
|
|
@ -281,13 +268,14 @@ func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) {
|
||||||
`)
|
`)
|
||||||
// Deny alice on the transmittal folder where 100_~A+C1 lives, so her
|
// Deny alice on the transmittal folder where 100_~A+C1 lives, so her
|
||||||
// listing of /ProjectA/.archive/ drops that entry — but other ProjectA
|
// listing of /ProjectA/.archive/ drops that entry — but other ProjectA
|
||||||
// entries stay visible.
|
// entries stay visible. (A blanket /ProjectA deny would 403 the
|
||||||
|
// listing entirely; that's covered by the previous test.)
|
||||||
writeZddc(t, root, "ProjectA/2025-02-01_T2 (RTN) - Comments", `acl:
|
writeZddc(t, root, "ProjectA/2025-02-01_T2 (RTN) - Comments", `acl:
|
||||||
deny: ["alice@example.com"]
|
deny: ["alice@example.com"]
|
||||||
`)
|
`)
|
||||||
cfg := archiveCfg(root)
|
cfg := archiveCfg(root)
|
||||||
|
|
||||||
rec := callArchiveAccept(t, cfg, idx, "alice@example.com", "ProjectA", "", "application/json")
|
rec := callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", "")
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -298,66 +286,138 @@ func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) {
|
||||||
t.Errorf("alice missing accessible entry %q; got %v", want, gotNames)
|
t.Errorf("alice missing accessible entry %q; got %v", want, gotNames)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 100_~A+C1.html maps to a denied target — must not appear.
|
|
||||||
if contains(gotNames, "100_~A+C1.html") {
|
// Bob has no per-target denials in either project.
|
||||||
t.Errorf("alice unexpectedly saw denied entry 100_~A+C1.html; got %v", gotNames)
|
rec = callArchive(t, cfg, idx, "bob@example.com", "/ProjectB", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("bob ProjectB listing: status %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
gotNames = names(decodeListing(t, rec.Body.Bytes()))
|
||||||
|
if !contains(gotNames, "200.html") {
|
||||||
|
t.Errorf("bob should see ProjectB entry 200.html; got %v", gotNames)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve: only the per-target ACL gates access. A caller denied on the
|
// Direct redirect requests for a tracking number whose target the caller
|
||||||
// resolved file's directory gets 404 (not 403) — never confirm the
|
// can't read return 404 (not 403, not 302) — the file's existence must not
|
||||||
// tracking number's existence.
|
// leak across the ACL boundary. Cross-project tracking-number requests also
|
||||||
func TestServeArchive_ResolvePerTargetACLOnly(t *testing.T) {
|
// 404 because each project's bucket is separate.
|
||||||
|
func TestServeArchive_ResolveACLDeniedReturns404(t *testing.T) {
|
||||||
root, idx := archiveTestRoot(t)
|
root, idx := archiveTestRoot(t)
|
||||||
// Both alice and mallory are root-allowed, but a deny on the
|
|
||||||
// transmittal folder kicks mallory out at the per-target chain
|
|
||||||
// ("first explicit match wins, bottom-up").
|
|
||||||
writeZddc(t, root, ".", `acl:
|
writeZddc(t, root, ".", `acl:
|
||||||
allow: ["alice@example.com", "mallory@example.com"]
|
allow: ["*"]
|
||||||
`)
|
`)
|
||||||
writeZddc(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", `acl:
|
writeZddc(t, root, "ProjectB", `acl:
|
||||||
deny: ["mallory@example.com"]
|
deny: ["alice@example.com"]
|
||||||
`)
|
`)
|
||||||
cfg := archiveCfg(root)
|
cfg := archiveCfg(root)
|
||||||
|
|
||||||
// alice can resolve.
|
// 200 doesn't even live in ProjectA, so the resolver itself returns 404
|
||||||
rec := callArchive(t, cfg, idx, "alice@example.com", "ProjectA", "100.html")
|
// regardless of ACL — project scoping comes first.
|
||||||
if rec.Code != http.StatusOK {
|
rec := callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", "200.html")
|
||||||
t.Errorf("alice resolve: status %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// mallory is denied at the file's directory → 404 (existence-leak guard).
|
|
||||||
rec = callArchive(t, cfg, idx, "mallory@example.com", "ProjectA", "100.html")
|
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Errorf("mallory resolve: status %d, want 404 (per-target deny); body=%s", rec.Code, rec.Body.String())
|
t.Errorf("alice → /ProjectA/.archive/200.html: status %d, want 404 (cross-project)", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alice in /ProjectA can resolve all of ProjectA's entries.
|
||||||
|
for _, fn := range []string{"100.html", "100_A.html", "100_~A.html", "100_~A+C1.html"} {
|
||||||
|
rec := callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", fn)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("alice → /ProjectA/.archive/%s: status %d, want 200; body = %s", fn, rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve is decoupled from project-root ACL: a user explicitly allowed
|
// Alice attempting ProjectB directly is denied at the contextPath ACL.
|
||||||
// at one transmittal folder but denied at the project root (and not in
|
rec = callArchive(t, cfg, idx, "alice@example.com", "/ProjectB", "200.html")
|
||||||
// any other allow list) can still fetch tracking numbers that resolve
|
if rec.Code != http.StatusForbidden {
|
||||||
// to that folder. .archive/ is a virtual surface — the file's own ACL
|
t.Errorf("alice → /ProjectB/.archive/200.html: status %d, want 403 (denied at contextPath)", rec.Code)
|
||||||
// chain decides.
|
}
|
||||||
func TestServeArchive_ResolveBypassesProjectRootDenyWhenPerTargetAllows(t *testing.T) {
|
|
||||||
|
// Bob has no denies — he can pull 200.html from /ProjectB.
|
||||||
|
rec = callArchive(t, cfg, idx, "bob@example.com", "/ProjectB", "200.html")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("bob → /ProjectB/.archive/200.html: status %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cascade direction sanity check: a denial at the subtree wins over an
|
||||||
|
// allow at the parent, AND a target-level allow can rescue a user the
|
||||||
|
// parent didn't mention. Both directions must be exercised so future
|
||||||
|
// refactors of the per-target ACL helper can't silently break one.
|
||||||
|
func TestServeArchive_CascadeDirectionsBothEnforced(t *testing.T) {
|
||||||
root, idx := archiveTestRoot(t)
|
root, idx := archiveTestRoot(t)
|
||||||
// Project root denies bob, but the transmittal folder under it
|
// Root: deny default — only bob is on the list. ProjectA: explicitly
|
||||||
// allows him. The cascade is "first explicit match wins, bottom-up"
|
// allow alice. So alice is rescued at ProjectA, mallory stays out
|
||||||
// — so the per-target chain at the file's directory hits the local
|
// everywhere, bob stays in everywhere. Per-target ACL on resolved files
|
||||||
// allow first.
|
// doesn't kick in here — both projects allow bob via the root rule.
|
||||||
writeZddc(t, root, ".", `acl:
|
writeZddc(t, root, ".", `acl:
|
||||||
allow: ["alice@example.com"]
|
|
||||||
`)
|
|
||||||
writeZddc(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", `acl:
|
|
||||||
allow: ["bob@example.com"]
|
allow: ["bob@example.com"]
|
||||||
|
`)
|
||||||
|
writeZddc(t, root, "ProjectA", `acl:
|
||||||
|
allow: ["alice@example.com"]
|
||||||
`)
|
`)
|
||||||
cfg := archiveCfg(root)
|
cfg := archiveCfg(root)
|
||||||
|
|
||||||
rec := callArchive(t, cfg, idx, "bob@example.com", "ProjectA", "100.html")
|
cases := []struct {
|
||||||
|
email string
|
||||||
|
contextPath string
|
||||||
|
filename string
|
||||||
|
wantStatus int
|
||||||
|
why string
|
||||||
|
}{
|
||||||
|
{"bob@example.com", "/ProjectA", "100.html", http.StatusOK, "bob allowed at root → reaches ProjectA target"},
|
||||||
|
{"bob@example.com", "/ProjectB", "200.html", http.StatusOK, "bob allowed at root → reaches ProjectB target"},
|
||||||
|
{"alice@example.com", "/ProjectA", "100.html", http.StatusOK, "alice rescued by ProjectA allow"},
|
||||||
|
{"alice@example.com", "/ProjectB", "200.html", http.StatusForbidden, "alice not in ProjectB chain → 403 at contextPath"},
|
||||||
|
// mallory denied everywhere; the contextPath gate fires first.
|
||||||
|
{"mallory@example.com", "/ProjectA", "100.html", http.StatusForbidden, "mallory blocked at contextPath"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.email+"_"+c.contextPath+"_"+c.filename, func(t *testing.T) {
|
||||||
|
rec := callArchive(t, cfg, idx, c.email, c.contextPath, c.filename)
|
||||||
|
if rec.Code != c.wantStatus {
|
||||||
|
t.Errorf("%s @ %s → %s: status %d, want %d (%s)", c.email, c.contextPath, c.filename, rec.Code, c.wantStatus, c.why)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// .archive serves the resolved file in place — the URL never changes.
|
||||||
|
// From any depth within the same project the resolver picks the same
|
||||||
|
// target file, so the bytes returned to the caller must be identical
|
||||||
|
// across context paths (the per-revision file URL is intentionally
|
||||||
|
// hidden so external links remain stable).
|
||||||
|
func TestServeArchive_ServedBytesStableAcrossDepthWithinProject(t *testing.T) {
|
||||||
|
root, idx := archiveTestRoot(t)
|
||||||
|
writeZddc(t, root, ".", `acl:
|
||||||
|
allow: ["*"]
|
||||||
|
`)
|
||||||
|
cfg := archiveCfg(root)
|
||||||
|
|
||||||
|
wantBodyPrefix := "ProjectA/2025-01-01_T1 (IFR) - Title/100_A"
|
||||||
|
var firstBody string
|
||||||
|
for i, ctx := range []string{
|
||||||
|
"/ProjectA",
|
||||||
|
"/ProjectA/2025-01-01_T1 (IFR) - Title",
|
||||||
|
"/ProjectA/2025-02-01_T2 (RTN) - Comments",
|
||||||
|
} {
|
||||||
|
rec := callArchive(t, cfg, idx, "alice@example.com", ctx, "100.html")
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Errorf("bob resolve: status %d, want 200 (per-target allow rescues him); body=%s", rec.Code, rec.Body.String())
|
t.Errorf("ctx=%s status=%d body=%s", ctx, rec.Code, rec.Body.String())
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if loc := rec.Header().Get("Location"); loc != "" {
|
if loc := rec.Header().Get("Location"); loc != "" {
|
||||||
t.Errorf("unexpected Location=%q (.archive must serve in place)", loc)
|
t.Errorf("ctx=%s unexpected Location=%q (.archive must serve in place)", ctx, loc)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.HasPrefix(body, wantBodyPrefix) {
|
||||||
|
t.Errorf("ctx=%s body=%q, want prefix %q", ctx, body, wantBodyPrefix)
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
firstBody = body
|
||||||
|
} else if body != firstBody {
|
||||||
|
t.Errorf("ctx=%s body differs from first contextPath (resolver should pick the same target regardless of depth)", ctx)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -387,7 +447,7 @@ func TestServeArchive_CrossProjectSameTrackingNoLeak(t *testing.T) {
|
||||||
cfg := archiveCfg(root)
|
cfg := archiveCfg(root)
|
||||||
const email = "alice@example.com"
|
const email = "alice@example.com"
|
||||||
|
|
||||||
recA := callArchive(t, cfg, idx, email, "ProjectA", "123.html")
|
recA := callArchive(t, cfg, idx, email, "/ProjectA", "123.html")
|
||||||
if recA.Code != http.StatusOK {
|
if recA.Code != http.StatusOK {
|
||||||
t.Fatalf("ProjectA 123.html status=%d body=%s", recA.Code, recA.Body.String())
|
t.Fatalf("ProjectA 123.html status=%d body=%s", recA.Code, recA.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -396,7 +456,7 @@ func TestServeArchive_CrossProjectSameTrackingNoLeak(t *testing.T) {
|
||||||
t.Errorf("ProjectA body=%q, want a ProjectA/ file's content", bodyA)
|
t.Errorf("ProjectA body=%q, want a ProjectA/ file's content", bodyA)
|
||||||
}
|
}
|
||||||
|
|
||||||
recB := callArchive(t, cfg, idx, email, "ProjectB", "123.html")
|
recB := callArchive(t, cfg, idx, email, "/ProjectB", "123.html")
|
||||||
if recB.Code != http.StatusOK {
|
if recB.Code != http.StatusOK {
|
||||||
t.Fatalf("ProjectB 123.html status=%d body=%s", recB.Code, recB.Body.String())
|
t.Fatalf("ProjectB 123.html status=%d body=%s", recB.Code, recB.Body.String())
|
||||||
}
|
}
|
||||||
|
|
@ -419,26 +479,59 @@ func TestServeArchive_CrossProjectSameTrackingNoLeak(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listing each project shows only its own.
|
// Listing each project shows only its own.
|
||||||
for _, c := range []struct{ project, mustHave string }{
|
for _, c := range []struct{ ctx, mustHave, mustNot string }{
|
||||||
{"ProjectA", "ProjectA"},
|
{"/ProjectA", "ProjectA", "ProjectB"},
|
||||||
{"ProjectB", "ProjectB"},
|
{"/ProjectB", "ProjectB", "ProjectA"},
|
||||||
} {
|
} {
|
||||||
rec := callArchiveAccept(t, cfg, idx, email, c.project, "", "application/json")
|
rec := callArchive(t, cfg, idx, email, c.ctx, "")
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("listing %s: status %d", c.project, rec.Code)
|
t.Fatalf("listing %s: status %d", c.ctx, rec.Code)
|
||||||
}
|
}
|
||||||
got := decodeListing(t, rec.Body.Bytes())
|
got := decodeListing(t, rec.Body.Bytes())
|
||||||
for _, e := range got {
|
for _, e := range got {
|
||||||
if !strings.Contains(e.URL, "/"+c.mustHave+"/") {
|
if !strings.Contains(e.URL, "/"+c.mustHave+"/") {
|
||||||
t.Errorf("project=%s entry URL %q lacks /%s/ segment", c.project, e.URL, c.mustHave)
|
t.Errorf("ctx=%s entry URL %q lacks /%s/ segment", c.ctx, e.URL, c.mustHave)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default-deny: as soon as ANY .zddc exists in the chain, an unmatched
|
||||||
|
// caller is denied. Verify this applies to listing entries too — a target
|
||||||
|
// in a directory with a restrictive .zddc is not surfaced to outsiders even
|
||||||
|
// though the file exists.
|
||||||
|
func TestServeArchive_DefaultDenyOnceZddcExists(t *testing.T) {
|
||||||
|
root, idx := archiveTestRoot(t)
|
||||||
|
// Root .zddc allows alice only. No "*" — so anyone else is default-denied.
|
||||||
|
writeZddc(t, root, ".", `acl:
|
||||||
|
allow: ["alice@example.com"]
|
||||||
|
`)
|
||||||
|
cfg := archiveCfg(root)
|
||||||
|
|
||||||
|
// alice sees everything she's allowed to in ProjectA.
|
||||||
|
rec := callArchive(t, cfg, idx, "alice@example.com", "/ProjectA", "")
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("alice listing: status %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if len(decodeListing(t, rec.Body.Bytes())) == 0 {
|
||||||
|
t.Errorf("alice listing was empty, want entries")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charlie isn't on any list → default-deny → 403 even for the listing.
|
||||||
|
rec = callArchive(t, cfg, idx, "charlie@example.com", "/ProjectA", "")
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("charlie listing: status %d, want 403", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct resolve: contextPath ACL fires first → 403.
|
||||||
|
rec = callArchive(t, cfg, idx, "charlie@example.com", "/ProjectA", "100.html")
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("charlie resolve: status %d, want 403 (denied at contextPath)", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Empty email never matches — even an `allow: ["*"]` policy denies it,
|
// Empty email never matches — even an `allow: ["*"]` policy denies it,
|
||||||
// per the existing zddc package contract. .archive must honor it: the
|
// which is the existing zddc package contract. .archive must honor it.
|
||||||
// listing 403s (empty filtered set) and resolves return 404.
|
|
||||||
func TestServeArchive_EmptyEmailDeniedEvenWithStarAllow(t *testing.T) {
|
func TestServeArchive_EmptyEmailDeniedEvenWithStarAllow(t *testing.T) {
|
||||||
root, idx := archiveTestRoot(t)
|
root, idx := archiveTestRoot(t)
|
||||||
writeZddc(t, root, ".", `acl:
|
writeZddc(t, root, ".", `acl:
|
||||||
|
|
@ -446,80 +539,30 @@ func TestServeArchive_EmptyEmailDeniedEvenWithStarAllow(t *testing.T) {
|
||||||
`)
|
`)
|
||||||
cfg := archiveCfg(root)
|
cfg := archiveCfg(root)
|
||||||
|
|
||||||
rec := callArchiveAccept(t, cfg, idx, "", "ProjectA", "", "application/json")
|
rec := callArchive(t, cfg, idx, "", "/ProjectA", "")
|
||||||
if rec.Code != http.StatusForbidden {
|
if rec.Code != http.StatusForbidden {
|
||||||
t.Errorf("anonymous listing: status %d, want 403", rec.Code)
|
t.Errorf("anonymous listing: status %d, want 403", rec.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
rec = callArchive(t, cfg, idx, "", "ProjectA", "100.html")
|
|
||||||
if rec.Code != http.StatusNotFound {
|
|
||||||
t.Errorf("anonymous resolve: status %d, want 404", rec.Code)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listing content negotiation: Accept: application/json returns the
|
// projectFromContextPath is the canonical place to derive the project key
|
||||||
// JSON entry array; Accept: text/html returns the embedded `browse` SPA
|
// from the .archive contextPath. Pin the edge cases.
|
||||||
// bytes (tested by content-type and the embedded ETag header).
|
func TestProjectFromContextPath(t *testing.T) {
|
||||||
// The same URL must serve both, with Vary: Accept set.
|
cases := []struct {
|
||||||
func TestServeArchive_ListingContentNegotiation(t *testing.T) {
|
ctx string
|
||||||
root, idx := archiveTestRoot(t)
|
want string
|
||||||
writeZddc(t, root, ".", `acl:
|
}{
|
||||||
allow: ["*"]
|
{"/ProjectA", "ProjectA"},
|
||||||
`)
|
{"/ProjectA/", "ProjectA"},
|
||||||
cfg := archiveCfg(root)
|
{"/ProjectA/sub/sub", "ProjectA"},
|
||||||
const email = "alice@example.com"
|
{"/", ""},
|
||||||
|
{"", ""},
|
||||||
// JSON branch.
|
{"ProjectA/sub", "ProjectA"},
|
||||||
recJSON := callArchiveAccept(t, cfg, idx, email, "ProjectA", "", "application/json")
|
|
||||||
if recJSON.Code != http.StatusOK {
|
|
||||||
t.Fatalf("JSON listing: status %d, want 200; body=%s", recJSON.Code, recJSON.Body.String())
|
|
||||||
}
|
}
|
||||||
if ct := recJSON.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
|
for _, c := range cases {
|
||||||
t.Errorf("JSON listing content-type=%q, want application/json", ct)
|
got := projectFromContextPath(c.ctx)
|
||||||
}
|
if got != c.want {
|
||||||
if vary := recJSON.Header().Get("Vary"); !strings.Contains(vary, "Accept") {
|
t.Errorf("projectFromContextPath(%q) = %q, want %q", c.ctx, got, c.want)
|
||||||
t.Errorf("JSON listing missing Vary: Accept (got %q)", vary)
|
|
||||||
}
|
|
||||||
_ = decodeListing(t, recJSON.Body.Bytes())
|
|
||||||
|
|
||||||
// HTML branch — falls back to JSON only if the embedded slot is
|
|
||||||
// empty, which won't be the case in a normal test run (the embed is
|
|
||||||
// populated at compile time). Verify either branch is sane.
|
|
||||||
recHTML := callArchiveAccept(t, cfg, idx, email, "ProjectA", "", "text/html")
|
|
||||||
if recHTML.Code != http.StatusOK {
|
|
||||||
t.Fatalf("HTML listing: status %d, want 200; body=%s", recHTML.Code, recHTML.Body.String())
|
|
||||||
}
|
|
||||||
ct := recHTML.Header().Get("Content-Type")
|
|
||||||
switch {
|
|
||||||
case strings.Contains(ct, "text/html"):
|
|
||||||
// Normal path: embedded browse bytes were served.
|
|
||||||
if etag := recHTML.Header().Get("ETag"); etag == "" || etag != `"`+apps.EmbeddedETag("browse")+`"` {
|
|
||||||
t.Errorf("HTML listing ETag=%q, want %q", etag, `"`+apps.EmbeddedETag("browse")+`"`)
|
|
||||||
}
|
|
||||||
if src := recHTML.Header().Get("X-ZDDC-Source"); src != "embedded:browse" {
|
|
||||||
t.Errorf("HTML listing X-ZDDC-Source=%q, want embedded:browse", src)
|
|
||||||
}
|
|
||||||
case strings.Contains(ct, "application/json"):
|
|
||||||
// Bootstrap path: embedded slot empty (e.g. fresh build before
|
|
||||||
// browse.html has been populated). JSON fallback is acceptable
|
|
||||||
// — confirm it parses as a listing.
|
|
||||||
_ = decodeListing(t, recHTML.Body.Bytes())
|
|
||||||
default:
|
|
||||||
t.Errorf("HTML listing unexpected content-type=%q", ct)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Conditional GET: re-fetching with If-None-Match for the JSON ETag
|
|
||||||
// short-circuits to 304.
|
|
||||||
etagJSON := recJSON.Header().Get("ETag")
|
|
||||||
if etagJSON != "" {
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/ProjectA/.archive/", nil)
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
req.Header.Set("If-None-Match", etagJSON)
|
|
||||||
req = req.WithContext(context.WithValue(req.Context(), EmailKey, email))
|
|
||||||
rec304 := httptest.NewRecorder()
|
|
||||||
ServeArchive(cfg, idx, rec304, req, "ProjectA", "")
|
|
||||||
if rec304.Code != http.StatusNotModified {
|
|
||||||
t.Errorf("conditional JSON GET: status %d, want 304", rec304.Code)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -741,7 +741,7 @@ body.help-open .app-header {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="form-title">ZDDC Form</span>
|
<span class="app-header__title" id="form-title">ZDDC Form</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-07</span></span>
|
<span class="build-timestamp">v0.0.16</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
|
|
@ -665,7 +665,7 @@ body.help-open .app-header {
|
||||||
</svg>
|
</svg>
|
||||||
<div class="header-title-group">
|
<div class="header-title-group">
|
||||||
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
<span class="app-header__title" id="table-title">ZDDC Table</span>
|
||||||
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-beta · 2026-05-07</span></span>
|
<span class="build-timestamp">v0.0.16</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue