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:
parent
748cc59ce3
commit
610b7ef65a
7 changed files with 257 additions and 23 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue