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) ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer cancel() 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) watcher, err := archive.NewWatcher(cfg.Root, idx)
if err != nil { if err != nil {
slog.Warn("failed to start filesystem watcher (index will not auto-update)", "err", err) 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 // HTTP handler
mux := http.NewServeMux() 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 // projects / .zddc editor) keep their existing per-resource 404
// existence-leakage gates inside ServeProfile. // existence-leakage gates inside ServeProfile.
if urlPath == handler.ProfilePathPrefix || strings.HasPrefix(urlPath, handler.ProfilePathPrefix+"/") { if urlPath == handler.ProfilePathPrefix || strings.HasPrefix(urlPath, handler.ProfilePathPrefix+"/") {
handler.ServeProfile(cfg, ring, w, r) handler.ServeProfile(cfg, ring, idx, w, r)
return return
} }
@ -603,6 +614,29 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
handler.ServeFile(w, r, absPath) 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 // setupLogger installs a slog default that fans every record out to stderr
// (the existing TextHandler — user-visible logging is unchanged) AND to an // (the existing TextHandler — user-visible logging is unchanged) AND to an
// in-memory ring buffer that backs the /.profile/logs endpoint. Returns // in-memory ring buffer that backs the /.profile/logs endpoint. Returns

View file

@ -8,6 +8,7 @@ import (
"sort" "sort"
"strings" "strings"
"sync" "sync"
"time"
) )
// RevisionEntry holds the resolved file paths for one base revision. // RevisionEntry holds the resolved file paths for one base revision.
@ -306,6 +307,35 @@ func compareRevisions(a, b string) int {
return 0 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). // UpdateFromDir re-indexes a single transmittal folder (called by the watcher).
func (idx *Index) UpdateFromDir(fsRoot, transmittalDirPath string) error { func (idx *Index) UpdateFromDir(fsRoot, transmittalDirPath string) error {
// Determine the date from the folder name // 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 // projectOf is the canonical place to derive the project key. Validate the
// edge cases so the contract doesn't drift silently. // edge cases so the contract doesn't drift silently.
func TestProjectOf(t *testing.T) { 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. 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. 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). 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 // 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.") "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"), 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).") "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"), accessLogFlag := fs.String("access-log", os.Getenv("ZDDC_ACCESS_LOG"),
"Tee structured access logs to this file (JSON, size-rotated). "+ "Tee structured access logs to this file (JSON, size-rotated). "+
"Default: <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log. "+ "Default: <ZDDC_ROOT>/.zddc.d/logs/access-<hostname>.log. "+
@ -156,6 +159,7 @@ func Load(args []string) (Config, error) {
AppsPubKey: *appsPubKeyFlag, AppsPubKey: *appsPubKeyFlag,
MaxWriteBytes: *maxWriteBytesFlag, MaxWriteBytes: *maxWriteBytesFlag,
CascadeMode: *cascadeModeFlag, CascadeMode: *cascadeModeFlag,
ArchiveRescanInterval: *archiveRescanIntervalFlag,
} }
// Default Root to the current working directory. // 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.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("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.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("help", false, "Print this help and exit.")
fs.Bool("version", false, "Print version info and exit.") fs.Bool("version", false, "Print version info and exit.")
fs.PrintDefaults() fs.PrintDefaults()

View file

@ -4,12 +4,14 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"time" "time"
"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/policy" "codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "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 // ServeProfile is the entry point for /.profile/* routes. The top-level
// page and the access-summary JSON are reachable to anyone (anonymous // page and the access-summary JSON are reachable to anyone (anonymous
// included); admin-only sub-resources (whoami / config / logs / projects / // included); admin-only sub-resources (whoami / config / logs / projects /
// the .zddc editor) keep their existing per-resource 404 leakage gates. // the .zddc editor / reindex) keep their existing per-resource 404 leakage
func ServeProfile(cfg config.Config, ring *LogRing, w http.ResponseWriter, r *http.Request) { // gates.
func ServeProfile(cfg config.Config, ring *LogRing, idx *archive.Index, w http.ResponseWriter, r *http.Request) {
sub := strings.TrimPrefix(r.URL.Path, ProfilePathPrefix) sub := strings.TrimPrefix(r.URL.Path, ProfilePathPrefix)
if sub == "" { if sub == "" {
sub = "/" sub = "/"
@ -70,11 +73,43 @@ func ServeProfile(cfg config.Config, ring *LogRing, w http.ResponseWriter, r *ht
return return
} }
serveProfileEffectivePolicy(cfg, w, r) serveProfileEffectivePolicy(cfg, w, r)
case "/reindex":
if !zddc.IsAdmin(cfg.Root, email) {
http.NotFound(w, r)
return
}
serveProfileReindex(cfg, idx, email, w, r)
default: default:
http.NotFound(w, r) 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 // AccessView is the data the profile page lazy-loads from /.profile/access
// after first paint. The HTML shell renders only Email/EmailHeader/ // after first paint. The HTML shell renders only Email/EmailHeader/
// IsSuperAdmin (all cheap); Projects + AdminSubtrees + HasAnyAdminScope come // IsSuperAdmin (all cheap); Projects + AdminSubtrees + HasAnyAdminScope come

View file

@ -11,6 +11,7 @@ import (
"strings" "strings"
"testing" "testing"
"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/zddc" "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}, {"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}, {"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. // Unknown sub-route still 404.
{"admin unknown subroute", "/.profile/nope", "alice@example.com", http.StatusNotFound}, {"admin unknown subroute", "/.profile/nope", "alice@example.com", http.StatusNotFound},
} }
@ -98,7 +107,7 @@ func TestServeProfileGateMatrix(t *testing.T) {
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder() 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 { if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d (body: %s)", rec.Code, tc.wantStatus, rec.Body.String()) 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 := requestWithEmail(http.MethodGet, "/.profile/whoami", "alice@example.com")
r.Header.Set("X-Other-Header", "hi there") r.Header.Set("X-Other-Header", "hi there")
ServeProfile(cfg, ring, rec, r) ServeProfile(cfg, ring, nil, rec, r)
if rec.Code != 200 { if rec.Code != 200 {
t.Fatalf("status = %d", rec.Code) t.Fatalf("status = %d", rec.Code)
@ -150,7 +159,7 @@ func TestServeProfileConfigPayload(t *testing.T) {
cfg.CORSOrigins = []string{"https://zddc.varasys.io"} cfg.CORSOrigins = []string{"https://zddc.varasys.io"}
rec := httptest.NewRecorder() 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 { if rec.Code != 200 {
t.Fatalf("status = %d", rec.Code) t.Fatalf("status = %d", rec.Code)
@ -177,7 +186,7 @@ func TestServeProfileLogsPayload(t *testing.T) {
logger.Warn("second", "code", 42) logger.Warn("second", "code", 42)
rec := httptest.NewRecorder() 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 { if rec.Code != 200 {
t.Fatalf("status = %d", rec.Code) t.Fatalf("status = %d", rec.Code)
@ -203,7 +212,7 @@ func TestServeProfileLogsLevelFilter(t *testing.T) {
logger.Warn("w") logger.Warn("w")
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
ServeProfile(cfg, ring, rec, ServeProfile(cfg, ring, nil, rec,
requestWithEmail(http.MethodGet, "/.profile/logs?level=warn", "alice@example.com")) requestWithEmail(http.MethodGet, "/.profile/logs?level=warn", "alice@example.com"))
var got []map[string]any var got []map[string]any
@ -274,7 +283,7 @@ func TestServeProfileHTMLLayered(t *testing.T) {
render := func(email string) string { render := func(email string) string {
rec := httptest.NewRecorder() 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 { if rec.Code != http.StatusOK {
t.Fatalf("email=%q status=%d body=%s", email, rec.Code, rec.Body.String()) 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) { func TestServeProfileAccessJSON(t *testing.T) {
cfg, ring := profileTestRoot(t, []string{"alice@example.com"}) cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
rec := httptest.NewRecorder() 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 { if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) 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 { fetchAccess := func(email string) AccessView {
t.Helper() t.Helper()
rec := httptest.NewRecorder() 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 { if rec.Code != http.StatusOK {
t.Fatalf("email=%q status=%d body=%s", email, rec.Code, rec.Body.String()) 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, r := requestWithEmail(http.MethodGet,
"/.profile/effective-policy?path=/Closed-Project/&email=alice@mycompany.com", "/.profile/effective-policy?path=/Closed-Project/&email=alice@mycompany.com",
"super@admin.com") "super@admin.com")
ServeProfile(cfg, ring, rec, r) ServeProfile(cfg, ring, nil, rec, r)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
} }
@ -540,7 +549,7 @@ func TestServeProfileEffectivePolicy(t *testing.T) {
r2 := requestWithEmail(http.MethodGet, r2 := requestWithEmail(http.MethodGet,
"/.profile/effective-policy?path=/Closed-Project/&email=bob@mycompany.com", "/.profile/effective-policy?path=/Closed-Project/&email=bob@mycompany.com",
"super@admin.com") "super@admin.com")
ServeProfile(cfg, ring, rec2, r2) ServeProfile(cfg, ring, nil, rec2, r2)
if rec2.Code != http.StatusOK { if rec2.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec2.Code, rec2.Body.String()) t.Fatalf("status=%d body=%s", rec2.Code, rec2.Body.String())
} }
@ -565,7 +574,7 @@ func TestServeProfileNoAdminsConfiguredStillRendersPage(t *testing.T) {
zddc.InvalidateCache(cfg.Root) zddc.InvalidateCache(cfg.Root)
rec := httptest.NewRecorder() 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 { if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
} }
@ -576,7 +585,7 @@ func TestServeProfileNoAdminsConfiguredStillRendersPage(t *testing.T) {
// Per-resource gates remain. // Per-resource gates remain.
rec = httptest.NewRecorder() 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 { if rec.Code != http.StatusNotFound {
t.Errorf("/.profile/whoami status = %d, want 404 (no admins configured)", rec.Code) 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)) req = req.WithContext(context.WithValue(req.Context(), EmailKey, email))
} }
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
ServeProfile(cfg, ring, rec, req) ServeProfile(cfg, ring, nil, rec, req)
return rec return rec
} }
@ -668,7 +677,7 @@ func TestServeProfileProjectsCreate(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/.profile/projects", nil) req := httptest.NewRequest(http.MethodGet, "/.profile/projects", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com")) req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
ServeProfile(cfg, ring, rec, req) ServeProfile(cfg, ring, nil, rec, req)
if rec.Code != http.StatusMethodNotAllowed { if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("GET /.profile/projects status=%d, want 405", rec.Code) 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.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com")) req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
ServeProfile(cfg, NewLogRing(50), rec, req) ServeProfile(cfg, NewLogRing(50), nil, rec, req)
if rec.Code != http.StatusBadRequest { if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d, want 400; body=%s", rec.Code, rec.Body.String()) 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.Header.Set("Content-Type", "application/json")
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com")) req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
ServeProfile(cfg, NewLogRing(50), rec, req) ServeProfile(cfg, NewLogRing(50), nil, rec, req)
return rec.Code 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 // TestAdminPathHardCut verifies the legacy /.admin prefix is not handled
// by the server — every /.admin/* falls through to the dispatcher's normal // by the server — every /.admin/* falls through to the dispatcher's normal
// path resolution which 404s on the dot-prefix guard. // 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 // Calling ServeProfile directly with /.admin path: it should not match
// the /.profile prefix and so return 404. (The real-world path is // the /.profile prefix and so return 404. (The real-world path is
// dispatch() routing — covered in main_test.go.) // dispatch() routing — covered in main_test.go.)
ServeProfile(cfg, ring, rec, req) ServeProfile(cfg, ring, nil, rec, req)
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Errorf("path=%q status=%d, want 404 (hard-cut)", p, rec.Code) 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 := httptest.NewRequest(http.MethodGet, "/.profile/whoami", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com")) req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
ServeProfile(cfg, nil, rec, req) ServeProfile(cfg, nil, nil, rec, req)
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Errorf("non-admin /.profile/whoami got %d, want 404", rec.Code) 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 = httptest.NewRequest(http.MethodGet, "/.profile/whoami", nil)
req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com")) req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
ServeProfile(cfg, nil, rec, req) ServeProfile(cfg, nil, nil, rec, req)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Errorf("super-admin /.profile/whoami got %d, want 200; body=%s", rec.Code, rec.Body.String()) t.Errorf("super-admin /.profile/whoami got %d, want 200; body=%s", rec.Code, rec.Body.String())
} }