diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index c8c2c6e..8232b2f 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -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 diff --git a/zddc/internal/archive/index.go b/zddc/internal/archive/index.go index 9ed0861..dafb238 100644 --- a/zddc/internal/archive/index.go +++ b/zddc/internal/archive/index.go @@ -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 diff --git a/zddc/internal/archive/index_test.go b/zddc/internal/archive/index_test.go index 04e6bed..244d611 100644 --- a/zddc/internal/archive/index_test.go +++ b/zddc/internal/archive/index_test.go @@ -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) { diff --git a/zddc/internal/config/config.go b/zddc/internal/config/config.go index f25a18a..778e0ca 100644 --- a/zddc/internal/config/config.go +++ b/zddc/internal/config/config.go @@ -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.d/logs/access-.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.d/logs/access-.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() diff --git a/zddc/internal/handler/profilehandler.go b/zddc/internal/handler/profilehandler.go index d49e3b2..8066502 100644 --- a/zddc/internal/handler/profilehandler.go +++ b/zddc/internal/handler/profilehandler.go @@ -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 diff --git a/zddc/internal/handler/profilehandler_test.go b/zddc/internal/handler/profilehandler_test.go index 68e6726..83d521d 100644 --- a/zddc/internal/handler/profilehandler_test.go +++ b/zddc/internal/handler/profilehandler_test.go @@ -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) } diff --git a/zddc/internal/handler/zddchandler_test.go b/zddc/internal/handler/zddchandler_test.go index 613637c..06270d1 100644 --- a/zddc/internal/handler/zddchandler_test.go +++ b/zddc/internal/handler/zddchandler_test.go @@ -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()) }