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)
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue