feat(archive): periodic rescan + admin reindex endpoint

The fsnotify watcher only sees events the local kernel generates, so on
SMB/CIFS-backed roots (Azure Files) writes from any other client are
invisible — the archive index would silently miss them until pod
restart. Add two backstops:

1. Periodic full re-walk via Index.Rebuild on a configurable interval
   (--archive-rescan-interval / ZDDC_ARCHIVE_RESCAN_INTERVAL, default
   60s, 0 to disable). Atomically swaps ByProject under the existing
   RWMutex; concurrent reads stay safe.
2. Admin-only POST /.profile/reindex that triggers an immediate rebuild
   and returns {duration_ms, project_count, tracking_count}, for the
   "I just dropped 50 files and don't want to wait" case. Gated by
   IsAdmin with the same 404-on-non-admin pattern as the other admin
   sub-resources.

Tests: TestRebuild_PicksUpAddsAndDrops covers add+drop semantics and
returned counts; TestServeProfileReindexPOST covers the happy admin
path; matrix entries cover the gate (anonymous/non-admin → 404, admin
GET → 405 method-not-allowed since the route is POST-only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-06 08:50:51 -05:00
parent 748cc59ce3
commit 610b7ef65a
7 changed files with 257 additions and 23 deletions

View file

@ -101,7 +101,8 @@ func main() {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer cancel()
// Start file-system watcher
// Start file-system watcher (best-effort live updates; misses cross-client
// writes on SMB/CIFS mounts since inotify only sees local-kernel events).
watcher, err := archive.NewWatcher(cfg.Root, idx)
if err != nil {
slog.Warn("failed to start filesystem watcher (index will not auto-update)", "err", err)
@ -113,6 +114,16 @@ func main() {
}()
}
// Periodic full re-scan. Required when the served root is an SMB/CIFS
// share (Azure Files, etc.) — fsnotify sees only events the local kernel
// generates, so writes from other clients to the share are invisible to
// the watcher above. A periodic full walk closes that gap.
if cfg.ArchiveRescanInterval > 0 {
go runPeriodicRescan(ctx, cfg.Root, idx, cfg.ArchiveRescanInterval)
} else {
slog.Info("archive periodic rescan disabled (interval=0)")
}
// HTTP handler
mux := http.NewServeMux()
@ -410,7 +421,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// projects / .zddc editor) keep their existing per-resource 404
// existence-leakage gates inside ServeProfile.
if urlPath == handler.ProfilePathPrefix || strings.HasPrefix(urlPath, handler.ProfilePathPrefix+"/") {
handler.ServeProfile(cfg, ring, w, r)
handler.ServeProfile(cfg, ring, idx, w, r)
return
}
@ -603,6 +614,29 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
handler.ServeFile(w, r, absPath)
}
// runPeriodicRescan calls idx.Rebuild on `interval` until ctx is cancelled.
// Each tick walks fsRoot from scratch and atomically replaces the live index;
// concurrent reads are safe via the index's RWMutex. Errors are logged but do
// not stop the loop — a transient walk failure shouldn't disable rescans.
func runPeriodicRescan(ctx context.Context, fsRoot string, idx *archive.Index, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
slog.Info("archive periodic rescan started", "interval", interval)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
dur, projects, tracking, err := idx.Rebuild(fsRoot)
if err != nil {
slog.Warn("archive rescan failed", "err", err, "duration", dur)
continue
}
slog.Debug("archive rescan ok", "duration", dur, "projects", projects, "tracking", tracking)
}
}
}
// setupLogger installs a slog default that fans every record out to stderr
// (the existing TextHandler — user-visible logging is unchanged) AND to an
// in-memory ring buffer that backs the /.profile/logs endpoint. Returns

View file

@ -8,6 +8,7 @@ import (
"sort"
"strings"
"sync"
"time"
)
// RevisionEntry holds the resolved file paths for one base revision.
@ -306,6 +307,35 @@ func compareRevisions(a, b string) int {
return 0
}
// Rebuild walks fsRoot from scratch and atomically replaces this Index's
// contents with the result. Used by the periodic re-scan and the admin
// /.profile/reindex endpoint.
//
// Why we need this even with the fsnotify watcher: inotify (which fsnotify
// wraps on Linux) only fires on events the local kernel sees. When another
// SMB/CIFS client writes to an Azure Files share, those writes never produce
// inotify events on this pod's mount — so the watcher silently misses every
// cross-client change. A periodic full re-scan covers that gap.
//
// Returns the duration of the rebuild and counts (projects, tracking numbers)
// for the caller to log or surface to an admin endpoint.
func (idx *Index) Rebuild(fsRoot string) (time.Duration, int, int, error) {
start := time.Now()
fresh, err := BuildIndex(fsRoot)
if err != nil {
return time.Since(start), 0, 0, err
}
projects := len(fresh.ByProject)
tracking := 0
for _, pe := range fresh.ByProject {
tracking += len(pe.ByTracking)
}
idx.mu.Lock()
idx.ByProject = fresh.ByProject
idx.mu.Unlock()
return time.Since(start), projects, tracking, nil
}
// UpdateFromDir re-indexes a single transmittal folder (called by the watcher).
func (idx *Index) UpdateFromDir(fsRoot, transmittalDirPath string) error {
// Determine the date from the folder name

View file

@ -324,6 +324,68 @@ func TestRecordFile_ReindexSamePathNoCollisionLog(t *testing.T) {
}
}
// Rebuild atomically replaces the index contents with a fresh walk of fsRoot.
// Files added after the initial build but before Rebuild are picked up;
// files removed are dropped. Counts and duration come back for the caller.
func TestRebuild_PicksUpAddsAndDrops(t *testing.T) {
root := t.TempDir()
mkTransmittal(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title",
"100_A (IFR) - Title.pdf",
)
idx, err := BuildIndex(root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
if _, ok := Resolve(idx, "ProjectA", "100.html"); !ok {
t.Fatalf("pre-rebuild: 100 should resolve")
}
if _, ok := Resolve(idx, "ProjectA", "200.html"); ok {
t.Fatalf("pre-rebuild: 200 should NOT resolve yet")
}
// Add a second tracking number into a NEW transmittal folder (the
// inotify watcher would miss this on SMB; Rebuild must catch it).
mkTransmittal(t, root, "ProjectA/2025-02-01_T2 (IFR) - Other",
"200_A (IFR) - Other.pdf",
)
dur, projects, tracking, err := idx.Rebuild(root)
if err != nil {
t.Fatalf("Rebuild: %v", err)
}
if dur <= 0 {
t.Errorf("Rebuild duration = %v, want > 0", dur)
}
if projects != 1 {
t.Errorf("projects = %d, want 1", projects)
}
if tracking != 2 {
t.Errorf("tracking = %d, want 2 (100, 200)", tracking)
}
if _, ok := Resolve(idx, "ProjectA", "100.html"); !ok {
t.Errorf("post-rebuild: 100 lost")
}
if _, ok := Resolve(idx, "ProjectA", "200.html"); !ok {
t.Errorf("post-rebuild: 200 not picked up")
}
// Remove the original transmittal entirely; Rebuild should drop it.
if err := os.RemoveAll(filepath.Join(root, "ProjectA/2025-01-01_T1 (IFR) - Title")); err != nil {
t.Fatalf("RemoveAll: %v", err)
}
if _, _, _, err := idx.Rebuild(root); err != nil {
t.Fatalf("Rebuild: %v", err)
}
if _, ok := Resolve(idx, "ProjectA", "100.html"); ok {
t.Errorf("post-second-rebuild: 100 should be gone")
}
if _, ok := Resolve(idx, "ProjectA", "200.html"); !ok {
t.Errorf("post-second-rebuild: 200 lost")
}
}
// projectOf is the canonical place to derive the project key. Validate the
// edge cases so the contract doesn't drift silently.
func TestProjectOf(t *testing.T) {

View file

@ -33,6 +33,7 @@ type Config struct {
AppsPubKey string // --apps-pubkey / ZDDC_APPS_PUBKEY — path to the Ed25519 public key (PEM) used to verify Ed25519 signatures on URL-fetched apps: artifacts. Empty = URL apps disabled (only embedded + local-path apps work). Operators using zddc.varasys.io's canonical channels download pubkey.pem from there.
MaxWriteBytes int64 // --max-write-bytes / ZDDC_MAX_WRITE_BYTES — upper bound on PUT body size. Default 256 MiB. Per-request limit; rejected with 413.
CascadeMode string // --cascade-mode / ZDDC_CASCADE_MODE — "delegated" (default; leaf grants override ancestor denies) or "strict" (ancestor explicit-denies are absolute, NIST AC-6).
ArchiveRescanInterval time.Duration // --archive-rescan-interval / ZDDC_ARCHIVE_RESCAN_INTERVAL — periodic full re-walk of the archive index. Covers SMB/CIFS where inotify misses cross-client writes. Default 60s; 0 to disable.
}
// ErrHelpRequested is returned by Load when --help is passed; the caller
@ -97,6 +98,8 @@ func Load(args []string) (Config, error) {
"Maximum PUT body size in bytes for the file API. Default 256 MiB. Larger requests are rejected with 413.")
cascadeModeFlag := fs.String("cascade-mode", getEnv("ZDDC_CASCADE_MODE", "delegated"),
"ACL cascade evaluation mode: \"delegated\" (default — subtree allow can override ancestor deny) or \"strict\" (ancestor explicit-deny is absolute; NIST AC-6).")
archiveRescanIntervalFlag := fs.Duration("archive-rescan-interval", parseDurationOrDefault(os.Getenv("ZDDC_ARCHIVE_RESCAN_INTERVAL"), 60*time.Second),
"Periodic full re-walk of the archive index. Required on SMB/CIFS-backed roots where inotify misses cross-client writes. Default 60s; set 0 to disable.")
accessLogFlag := fs.String("access-log", os.Getenv("ZDDC_ACCESS_LOG"),
"Tee structured access logs to this file (JSON, size-rotated). "+
"Default: <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log. "+
@ -156,6 +159,7 @@ func Load(args []string) (Config, error) {
AppsPubKey: *appsPubKeyFlag,
MaxWriteBytes: *maxWriteBytesFlag,
CascadeMode: *cascadeModeFlag,
ArchiveRescanInterval: *archiveRescanIntervalFlag,
}
// Default Root to the current working directory.
@ -275,6 +279,7 @@ func Usage(w io.Writer) {
fs.Duration("opa-cache-ttl", time.Second, "External OPA: per-decision cache TTL (default 1s; 0 disables).")
fs.String("apps-pubkey", "", "Path to PEM Ed25519 pubkey for verifying signed URL-fetched apps. Empty = URL apps refused.")
fs.String("access-log", "", "Tee structured access logs to this file (JSON, size-rotated). Default <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log; --access-log= disables.")
fs.Duration("archive-rescan-interval", 60*time.Second, "Periodic full re-walk of the archive index (covers SMB inotify gap). Default 60s; 0 disables.")
fs.Bool("help", false, "Print this help and exit.")
fs.Bool("version", false, "Print version info and exit.")
fs.PrintDefaults()

View file

@ -4,12 +4,14 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"path/filepath"
"sort"
"strings"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
@ -24,8 +26,9 @@ const ProfilePathPrefix = "/.profile"
// ServeProfile is the entry point for /.profile/* routes. The top-level
// page and the access-summary JSON are reachable to anyone (anonymous
// included); admin-only sub-resources (whoami / config / logs / projects /
// the .zddc editor) keep their existing per-resource 404 leakage gates.
func ServeProfile(cfg config.Config, ring *LogRing, w http.ResponseWriter, r *http.Request) {
// the .zddc editor / reindex) keep their existing per-resource 404 leakage
// gates.
func ServeProfile(cfg config.Config, ring *LogRing, idx *archive.Index, w http.ResponseWriter, r *http.Request) {
sub := strings.TrimPrefix(r.URL.Path, ProfilePathPrefix)
if sub == "" {
sub = "/"
@ -70,11 +73,43 @@ func ServeProfile(cfg config.Config, ring *LogRing, w http.ResponseWriter, r *ht
return
}
serveProfileEffectivePolicy(cfg, w, r)
case "/reindex":
if !zddc.IsAdmin(cfg.Root, email) {
http.NotFound(w, r)
return
}
serveProfileReindex(cfg, idx, email, w, r)
default:
http.NotFound(w, r)
}
}
// serveProfileReindex is an admin-only POST endpoint that triggers an
// immediate full re-walk of the archive index. Useful when a write has
// landed on the share via a path the local watcher can't see (other SMB
// clients, the just-restarted dev pod hitting prod data, etc.) and the
// operator wants the index updated without waiting for the next periodic
// rescan.
func serveProfileReindex(cfg config.Config, idx *archive.Index, email string, w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
dur, projects, tracking, err := idx.Rebuild(cfg.Root)
if err != nil {
slog.Warn("admin reindex failed", "email", email, "err", err, "duration", dur)
http.Error(w, fmt.Sprintf("reindex failed: %v", err), http.StatusInternalServerError)
return
}
slog.Info("admin reindex ok", "email", email, "duration", dur, "projects", projects, "tracking", tracking)
writeJSON(w, map[string]any{
"duration_ms": dur.Milliseconds(),
"project_count": projects,
"tracking_count": tracking,
})
}
// AccessView is the data the profile page lazy-loads from /.profile/access
// after first paint. The HTML shell renders only Email/EmailHeader/
// IsSuperAdmin (all cheap); Projects + AdminSubtrees + HasAnyAdminScope come

View file

@ -11,6 +11,7 @@ import (
"strings"
"testing"
"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)
@ -91,6 +92,14 @@ func TestServeProfileGateMatrix(t *testing.T) {
{"non-admin /.profile/effective-policy", "/.profile/effective-policy", "bob@example.com", http.StatusNotFound},
{"admin /.profile/effective-policy without params", "/.profile/effective-policy", "alice@example.com", http.StatusBadRequest},
// /.profile/reindex is admin-only too. GET (the matrix uses GET) is
// rejected with 405 once the admin gate clears, confirming the gate
// behaves the same way as the others. Real triggering is POST and is
// covered separately by TestServeProfileReindexPOST.
{"anonymous /.profile/reindex", "/.profile/reindex", "", http.StatusNotFound},
{"non-admin /.profile/reindex", "/.profile/reindex", "bob@example.com", http.StatusNotFound},
{"admin GET /.profile/reindex (POST-only)", "/.profile/reindex", "alice@example.com", http.StatusMethodNotAllowed},
// Unknown sub-route still 404.
{"admin unknown subroute", "/.profile/nope", "alice@example.com", http.StatusNotFound},
}
@ -98,7 +107,7 @@ func TestServeProfileGateMatrix(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, rec, requestWithEmail(http.MethodGet, tc.path, tc.email))
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, tc.path, tc.email))
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d (body: %s)", rec.Code, tc.wantStatus, rec.Body.String())
}
@ -112,7 +121,7 @@ func TestServeProfileWhoamiPayload(t *testing.T) {
r := requestWithEmail(http.MethodGet, "/.profile/whoami", "alice@example.com")
r.Header.Set("X-Other-Header", "hi there")
ServeProfile(cfg, ring, rec, r)
ServeProfile(cfg, ring, nil, rec, r)
if rec.Code != 200 {
t.Fatalf("status = %d", rec.Code)
@ -150,7 +159,7 @@ func TestServeProfileConfigPayload(t *testing.T) {
cfg.CORSOrigins = []string{"https://zddc.varasys.io"}
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.profile/config", "alice@example.com"))
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/config", "alice@example.com"))
if rec.Code != 200 {
t.Fatalf("status = %d", rec.Code)
@ -177,7 +186,7 @@ func TestServeProfileLogsPayload(t *testing.T) {
logger.Warn("second", "code", 42)
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.profile/logs", "alice@example.com"))
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/logs", "alice@example.com"))
if rec.Code != 200 {
t.Fatalf("status = %d", rec.Code)
@ -203,7 +212,7 @@ func TestServeProfileLogsLevelFilter(t *testing.T) {
logger.Warn("w")
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, rec,
ServeProfile(cfg, ring, nil, rec,
requestWithEmail(http.MethodGet, "/.profile/logs?level=warn", "alice@example.com"))
var got []map[string]any
@ -274,7 +283,7 @@ func TestServeProfileHTMLLayered(t *testing.T) {
render := func(email string) string {
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.profile/", email))
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/", email))
if rec.Code != http.StatusOK {
t.Fatalf("email=%q status=%d body=%s", email, rec.Code, rec.Body.String())
}
@ -368,7 +377,7 @@ func TestServeProfileHTMLLayered(t *testing.T) {
func TestServeProfileAccessJSON(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.profile/access", "alice@example.com"))
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/access", "alice@example.com"))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
@ -410,7 +419,7 @@ func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) {
fetchAccess := func(email string) AccessView {
t.Helper()
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.profile/access", email))
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/access", email))
if rec.Code != http.StatusOK {
t.Fatalf("email=%q status=%d body=%s", email, rec.Code, rec.Body.String())
}
@ -496,7 +505,7 @@ func TestServeProfileEffectivePolicy(t *testing.T) {
r := requestWithEmail(http.MethodGet,
"/.profile/effective-policy?path=/Closed-Project/&email=alice@mycompany.com",
"super@admin.com")
ServeProfile(cfg, ring, rec, r)
ServeProfile(cfg, ring, nil, rec, r)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
@ -540,7 +549,7 @@ func TestServeProfileEffectivePolicy(t *testing.T) {
r2 := requestWithEmail(http.MethodGet,
"/.profile/effective-policy?path=/Closed-Project/&email=bob@mycompany.com",
"super@admin.com")
ServeProfile(cfg, ring, rec2, r2)
ServeProfile(cfg, ring, nil, rec2, r2)
if rec2.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec2.Code, rec2.Body.String())
}
@ -565,7 +574,7 @@ func TestServeProfileNoAdminsConfiguredStillRendersPage(t *testing.T) {
zddc.InvalidateCache(cfg.Root)
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.profile/", "alice@example.com"))
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/", "alice@example.com"))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
@ -576,7 +585,7 @@ func TestServeProfileNoAdminsConfiguredStillRendersPage(t *testing.T) {
// Per-resource gates remain.
rec = httptest.NewRecorder()
ServeProfile(cfg, ring, rec, requestWithEmail(http.MethodGet, "/.profile/whoami", "alice@example.com"))
ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/whoami", "alice@example.com"))
if rec.Code != http.StatusNotFound {
t.Errorf("/.profile/whoami status = %d, want 404 (no admins configured)", rec.Code)
}
@ -601,7 +610,7 @@ func TestServeProfileProjectsCreate(t *testing.T) {
req = req.WithContext(context.WithValue(req.Context(), EmailKey, email))
}
rec := httptest.NewRecorder()
ServeProfile(cfg, ring, rec, req)
ServeProfile(cfg, ring, nil, rec, req)
return rec
}
@ -668,7 +677,7 @@ func TestServeProfileProjectsCreate(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/.profile/projects", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
rec = httptest.NewRecorder()
ServeProfile(cfg, ring, rec, req)
ServeProfile(cfg, ring, nil, rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("GET /.profile/projects status=%d, want 405", rec.Code)
}
@ -689,7 +698,7 @@ func TestServeProfileProjectsCreateValidatesZddc(t *testing.T) {
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
rec := httptest.NewRecorder()
ServeProfile(cfg, NewLogRing(50), rec, req)
ServeProfile(cfg, NewLogRing(50), nil, rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d, want 400; body=%s", rec.Code, rec.Body.String())
@ -725,7 +734,7 @@ func TestSubtreeAdminCanCreateInScope(t *testing.T) {
req.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
rec := httptest.NewRecorder()
ServeProfile(cfg, NewLogRing(50), rec, req)
ServeProfile(cfg, NewLogRing(50), nil, rec, req)
return rec.Code
}
@ -737,6 +746,65 @@ func TestSubtreeAdminCanCreateInScope(t *testing.T) {
}
}
// TestServeProfileReindexPOST exercises the happy path: admin POSTs to
// /.profile/reindex, the index rebuilds against the temp root, and the
// response carries non-zero counts plus a duration_ms field.
//
// Setup writes a transmittal folder under cfg.Root AFTER the initial empty
// index is created, so a successful Rebuild has to discover it. This mirrors
// the real-world reason the endpoint exists (an SMB-side write the watcher
// missed).
func TestServeProfileReindexPOST(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
// Empty initial index — the directory hasn't been populated yet.
idx, err := archive.BuildIndex(cfg.Root)
if err != nil {
t.Fatalf("BuildIndex: %v", err)
}
// Drop a transmittal folder in after the index is built. Without
// rebuild this would be invisible to the .archive resolver.
dir := filepath.Join(cfg.Root, "ProjectA", "2025-01-01_T1 (IFR) - Title")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "100_A (IFR) - Title.pdf"), []byte("x"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
rec := httptest.NewRecorder()
req := requestWithEmail(http.MethodPost, "/.profile/reindex", "alice@example.com")
ServeProfile(cfg, ring, idx, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp struct {
DurationMs int64 `json:"duration_ms"`
ProjectCount int `json:"project_count"`
TrackingCount int `json:"tracking_count"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
}
if resp.ProjectCount != 1 {
t.Errorf("project_count=%d, want 1", resp.ProjectCount)
}
if resp.TrackingCount != 1 {
t.Errorf("tracking_count=%d, want 1", resp.TrackingCount)
}
if resp.DurationMs < 0 {
t.Errorf("duration_ms=%d, want >= 0", resp.DurationMs)
}
// The post-rebuild index must now resolve the new tracking number.
if _, ok := archive.Resolve(idx, "ProjectA", "100.html"); !ok {
t.Errorf("post-reindex: 100 should resolve")
}
}
// TestAdminPathHardCut verifies the legacy /.admin prefix is not handled
// by the server — every /.admin/* falls through to the dispatcher's normal
// path resolution which 404s on the dot-prefix guard.
@ -748,7 +816,7 @@ func TestAdminPathHardCut(t *testing.T) {
// Calling ServeProfile directly with /.admin path: it should not match
// the /.profile prefix and so return 404. (The real-world path is
// dispatch() routing — covered in main_test.go.)
ServeProfile(cfg, ring, rec, req)
ServeProfile(cfg, ring, nil, rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("path=%q status=%d, want 404 (hard-cut)", p, rec.Code)
}

View file

@ -379,7 +379,7 @@ func TestServeZddcAdminDispatchUnchangedForOtherRoutes(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/.profile/whoami", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
rec := httptest.NewRecorder()
ServeProfile(cfg, nil, rec, req)
ServeProfile(cfg, nil, nil, rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("non-admin /.profile/whoami got %d, want 404", rec.Code)
}
@ -387,7 +387,7 @@ func TestServeZddcAdminDispatchUnchangedForOtherRoutes(t *testing.T) {
req = httptest.NewRequest(http.MethodGet, "/.profile/whoami", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
rec = httptest.NewRecorder()
ServeProfile(cfg, nil, rec, req)
ServeProfile(cfg, nil, nil, rec, req)
if rec.Code != http.StatusOK {
t.Errorf("super-admin /.profile/whoami got %d, want 200; body=%s", rec.Code, rec.Body.String())
}