From 9e52a62e66d654edd1785b2e55cd35ed9f0a9eff Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Wed, 5 Aug 2026 17:44:09 +0200 Subject: [PATCH] fix(wiki): purge through the plugin page actions and count reset deletions deploy-wiki purged pages with DELETE /api/v3/topics/{tid}. A NodeBB running nodebb-plugin-westgate-wiki refuses that for topics in wiki categories, because wiki revision history is plugin-owned, so every purge failed with HTTP 400 and the deploy exited 1. Purge now goes through the plugin's own page actions: tombstone the page, then hard-purge it, since a page must be tombstoned before it can be purged. Archiving is unaffected: it rewrites the page through the ordinary post edit the plugin allows, rather than deleting anything. Every fake NodeBB in the deploy tests now goes through one constructor that refuses native topic mutation the way the plugin does. The old fakes answered the core API, which is why this shipped green. A namespace reset can reach pages NodeBB will not delete at all - the wiki home topic above all - so those are skipped rather than aborting the reset. NodeBB answers 403 for that and for a token without purge privileges alike, and the response cannot tell them apart; what can is scope, so a category where nothing at all could be deleted still fails the run. Under --reset-managed-namespaces the preview reported stale: 0 on a run that would delete every topic in the managed categories, because the reset purge never went through stale computation. The reset deletions are now counted as stale, which is the number callers word their destructive-policy warning around, and the summary names how many of them the manifest has no record of writing - the deletions a re-seed cannot undo. Closes #99 Closes #100 Co-Authored-By: Claude Opus 5 (1M context) --- internal/app/app.go | 31 ++- internal/app/app_test.go | 59 ++++- internal/dispatch/dispatch.go | 3 +- internal/topdata/wiki_deploy.go | 155 +++++++++-- internal/topdata/wiki_deploy_test.go | 371 ++++++++++++++++++++------- 5 files changed, 488 insertions(+), 131 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 46bb872..70cb4bc 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -927,19 +927,28 @@ func (c *topdataConsole) emitWikiBuildResult(outputDir string, pageCount int, st fmt.Fprintf(c.stdout, "wiki status: %s\n", status) } -func (c *topdataConsole) emitWikiDeployResult(localPages, created, updated, skipped, stale, archived, purged, drifted int, manifest string) { +func (c *topdataConsole) emitWikiDeployResult(result topdata.DeployResult) { spin.linebreak() fmt.Fprintln(c.stdout, "Deploy Wiki ----------") fmt.Fprintf(c.stdout, "project: %s\n", c.projectName) - fmt.Fprintf(c.stdout, "local pages: %d\n", localPages) - fmt.Fprintf(c.stdout, "created: %d\n", created) - fmt.Fprintf(c.stdout, "updated: %d\n", updated) - fmt.Fprintf(c.stdout, "skipped: %d\n", skipped) - fmt.Fprintf(c.stdout, "stale: %d\n", stale) - fmt.Fprintf(c.stdout, "archived: %d\n", archived) - fmt.Fprintf(c.stdout, "purged: %d\n", purged) - fmt.Fprintf(c.stdout, "drifted: %d\n", drifted) - fmt.Fprintf(c.stdout, "manifest: %s\n", c.relPath(manifest)) + fmt.Fprintf(c.stdout, "local pages: %d\n", result.LocalPages) + fmt.Fprintf(c.stdout, "created: %d\n", result.Created) + fmt.Fprintf(c.stdout, "updated: %d\n", result.Updated) + fmt.Fprintf(c.stdout, "skipped: %d\n", result.Skipped) + fmt.Fprintf(c.stdout, "stale: %d\n", result.Stale) + fmt.Fprintf(c.stdout, "archived: %d\n", result.Archived) + fmt.Fprintf(c.stdout, "purged: %d\n", result.Purged) + fmt.Fprintf(c.stdout, "drifted: %d\n", result.Drifted) + if result.ResetPurged > 0 || result.ResetSkipped > 0 { + fmt.Fprintf(c.stdout, "namespace reset: %d (unrecognized: %d)\n", result.ResetPurged, result.ResetUnrecognized) + } + if result.ResetUnrecognized > 0 { + fmt.Fprintln(c.stdout, " unrecognized pages were not written by this deployer; recreating them is not possible") + } + if result.ResetSkipped > 0 { + fmt.Fprintf(c.stdout, " reset skipped: %d (NodeBB refused deletion, e.g. the wiki home page)\n", result.ResetSkipped) + } + fmt.Fprintf(c.stdout, "manifest: %s\n", c.relPath(result.Manifest)) } func (c *topdataConsole) relPath(path string) string { @@ -1579,7 +1588,7 @@ func runDeployWiki(ctx context) error { return err } - console.emitWikiDeployResult(result.LocalPages, result.Created, result.Updated, result.Skipped, result.Stale, result.Archived, result.Purged, result.Drifted, result.Manifest) + console.emitWikiDeployResult(result) return nil } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index a60fd11..1680ef7 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -10,6 +10,7 @@ import ( "time" "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/pipeline" + "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/topdata" ) func TestParseBuildHAKArgsContentAddressedRoot(t *testing.T) { @@ -218,7 +219,16 @@ func TestTopdataConsoleDebugProgressAndRelativePaths(t *testing.T) { } console.progress("NodeBB wiki plan: create 1, update 2, skip 3, stale 4, archive 5, purge 6, drift 0") - console.emitWikiDeployResult(10, 1, 2, 3, 4, 5, 6, 0, "/workspace/project/build/wiki/deploy-manifest.json") + console.emitWikiDeployResult(topdata.DeployResult{ + LocalPages: 10, + Created: 1, + Updated: 2, + Skipped: 3, + Stale: 4, + Archived: 5, + Purged: 6, + Manifest: "/workspace/project/build/wiki/deploy-manifest.json", + }) output := stdout.String() if !strings.Contains(output, "NodeBB wiki plan") { @@ -229,6 +239,53 @@ func TestTopdataConsoleDebugProgressAndRelativePaths(t *testing.T) { } } +func TestTopdataConsoleReportsManagedNamespaceReset(t *testing.T) { + var stdout bytes.Buffer + console := &topdataConsole{ + stdout: &stdout, + projectRoot: "/workspace/project", + projectName: "Test Module", + commandName: "deploy-wiki", + commandLabel: "Deploy Wiki", + } + + console.emitWikiDeployResult(topdata.DeployResult{ + LocalPages: 1200, + Created: 1200, + Stale: 1213, + Purged: 1213, + ResetPurged: 1213, + ResetUnrecognized: 13, + ResetSkipped: 1, + Manifest: "/workspace/project/build/wiki/deploy-manifest.json", + }) + + // The counts are what an operator reads to decide whether a destructive run + // is safe, so each has to reach the output on a line that names what it + // counts; the wording around them is free to change. + output := stdout.String() + var staleLine, resetLine, skippedLine string + for _, line := range strings.Split(output, "\n") { + switch { + case strings.Contains(line, "stale"): + staleLine = line + case strings.Contains(line, "skipped"): + skippedLine = line + case strings.Contains(line, "reset"): + resetLine = line + } + } + if !strings.Contains(staleLine, "1213") { + t.Fatalf("expected reset deletions counted as stale, got %q", output) + } + if !strings.Contains(resetLine, "1213") || !strings.Contains(resetLine, "13") { + t.Fatalf("expected the reset line to carry both the total and the unrecognized count, got %q", output) + } + if !strings.Contains(skippedLine, "1") { + t.Fatalf("expected refused reset deletions to be reported, got %q", output) + } +} + func TestParseDeployWikiHelpListsPurgeStalePolicy(t *testing.T) { _, err := parseDeployWikiArgs("deploy-wiki", []string{"--help"}) if err == nil || !strings.Contains(err.Error(), "--stale-policy ") { diff --git a/internal/dispatch/dispatch.go b/internal/dispatch/dispatch.go index cf7b7e5..1d45732 100644 --- a/internal/dispatch/dispatch.go +++ b/internal/dispatch/dispatch.go @@ -289,7 +289,8 @@ var Registry = []Builder{ "--dry-run report changes without writing", "--create allow missing pages to be created", "--force update unchanged pages", - "--reset-managed-namespaces reset managed namespace state", + "--reset-managed-namespaces delete every page in the managed categories, including", + " pages this deployer never wrote, then recreate from source", }, Aliases: []CommandAlias{{Name: "deploy-wiki"}}, }, diff --git a/internal/topdata/wiki_deploy.go b/internal/topdata/wiki_deploy.go index 2697ced..9901c37 100644 --- a/internal/topdata/wiki_deploy.go +++ b/internal/topdata/wiki_deploy.go @@ -8,6 +8,7 @@ import ( "fmt" "html" "io" + "maps" "net/http" "net/url" "os" @@ -52,7 +53,7 @@ type DeployWikiOptions struct { TitlePrefixMinLength int } -type deployResult struct { +type DeployResult struct { LocalPages int Created int Updated int @@ -63,6 +64,19 @@ type deployResult struct { Drifted int Renamed int Manifest string + // ResetPurged counts the deletions queued by --reset-managed-namespaces. + // They are also included in Stale and Purged, because callers warn about + // destructive policies in terms of the stale count: sow-topdata's + // deploy-wiki wrapper prints "of the pages counted as 'stale' below, they + // will be DELETED from NodeBB" directly above this block. + ResetPurged int + // ResetUnrecognized counts the subset of ResetPurged that the deploy + // manifest has no record of writing. Those deletions are the ones a + // re-seed cannot undo. + ResetUnrecognized int + // ResetSkipped counts reset targets NodeBB refused to delete, such as the + // wiki home topic, which the plugin excludes from tombstone and purge. + ResetSkipped int } type wikiDeployPage struct { @@ -100,6 +114,12 @@ type wikiDeployPlan struct { Content string RemoteHash string Title string + // Reset marks a purge queued by --reset-managed-namespaces rather than by + // stale computation over the manifest. + Reset bool + // Unrecognized marks a reset purge whose topic the manifest has no record + // of writing. + Unrecognized bool } type wikiNamespacesDocument struct { @@ -116,7 +136,7 @@ type wikiNamespaceDeclaration struct { EditPolicy string `json:"edit_policy" yaml:"edit_policy"` } -func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress func(string)) (deployResult, error) { +func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress func(string)) (DeployResult, error) { if progress == nil { progress = func(string) {} } @@ -132,19 +152,19 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress } } if _, err := os.Stat(opts.SourceDir); err != nil { - return deployResult{}, fmt.Errorf("wiki source directory not found: %w", err) + return DeployResult{}, fmt.Errorf("wiki source directory not found: %w", err) } if opts.Endpoint == "" { - return deployResult{}, errors.New("NODEBB_API_ENDPOINT is required") + return DeployResult{}, errors.New("NODEBB_API_ENDPOINT is required") } if opts.Token == "" { - return deployResult{}, errors.New("NODEBB_API_TOKEN is required") + return DeployResult{}, errors.New("NODEBB_API_TOKEN is required") } if opts.Username != "" || opts.Password != "" || opts.NotesDelimiter != "" { - return deployResult{}, errors.New("DokuWiki deployment options are no longer supported; use NodeBB endpoint, token, and category mappings") + return DeployResult{}, errors.New("DokuWiki deployment options are no longer supported; use NodeBB endpoint, token, and category mappings") } if opts.StalePolicy != "" && opts.StalePolicy != "report" && opts.StalePolicy != "archive" && opts.StalePolicy != "purge" { - return deployResult{}, fmt.Errorf("wiki stale policy %q is not supported", opts.StalePolicy) + return DeployResult{}, fmt.Errorf("wiki stale policy %q is not supported", opts.StalePolicy) } if opts.TitlePrefixMinLength <= 0 { opts.TitlePrefixMinLength = p.EffectiveConfig().TopData.Wiki.TitlePrefixMinLength @@ -154,7 +174,7 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress if len(namespaces) == 0 { declarations, err := loadWikiNamespaceDeclarations(p) if err != nil { - return deployResult{}, err + return DeployResult{}, err } for _, declaration := range declarations { namespaces = append(namespaces, declaration.ID) @@ -164,7 +184,7 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress } envCategories, err := categoryIDsFromNamespaceEnv(declarations) if err != nil { - return deployResult{}, err + return DeployResult{}, err } if len(envCategories) > 0 { merged := envCategories @@ -183,7 +203,7 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress progress(fmt.Sprintf("Collecting local wiki pages from %s", opts.SourceDir)) pages, err := collectLocalPages(opts.SourceDir, opts.PageIndexPath, namespaces) if err != nil { - return deployResult{}, err + return DeployResult{}, err } progress(fmt.Sprintf("Loaded %d local wiki page(s)", len(pages))) manifest := loadDeployManifest(manifestPath) @@ -193,7 +213,7 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress progress(fmt.Sprintf("Planning NodeBB wiki deploy for %d local page(s)", len(pages))) plans, result, nextManifest, err := planNodeBBDeploy(pages, manifest, opts, client, progress) if err != nil { - return deployResult{}, err + return DeployResult{}, err } result.LocalPages = len(pages) result.Manifest = manifestPath @@ -212,6 +232,8 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress } orderedPlans := orderNodeBBDeployPlans(plans) progress(fmt.Sprintf("Executing NodeBB wiki actions: total %d, create %d, update %d, rename %d, archive %d, purge %d", len(orderedPlans), result.Created, result.Updated, result.Renamed, result.Archived, result.Purged)) + resetPurgedByCID := map[int]int{} + resetSkippedByCID := map[int]int{} for i, plan := range orderedPlans { if shouldReportNodeBBDeployActionProgress(i, len(orderedPlans)) { progress(fmt.Sprintf("Executing NodeBB wiki action %d/%d: %s %s", i+1, len(orderedPlans), plan.Action, plan.Page.PageID)) @@ -242,19 +264,52 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress return result, fmt.Errorf("deploy wiki page %q: update NodeBB post %d: %w", plan.Page.PageID, plan.Entry.PID, err) } case "archive": + // Archiving rewrites the page rather than removing it, so it goes + // through the ordinary post edit the wiki plugin allows; only + // delete, restore, and purge are reserved to the page actions. if err := client.updatePost(plan.Entry.TID, plan.Entry.PID, plan.Content, summary); err != nil { return result, fmt.Errorf("deploy wiki page %q: archive NodeBB post %d: %w", plan.Page.PageID, plan.Entry.PID, err) } case "purge": - if err := client.purgeTopic(plan.Entry.TID); err != nil { + if err := client.purgeWikiPage(plan.Entry.TID); err != nil { + // A namespace reset sweeps every topic in the category, so it + // can reach pages NodeBB will not delete at all — the wiki home + // topic above all. Skip those rather than abandoning the reset. + if plan.Reset && isNodeBBWikiPageUndeletable(err) { + result.ResetSkipped++ + result.ResetPurged-- + result.Purged-- + result.Stale-- + if plan.Unrecognized { + result.ResetUnrecognized-- + } + resetSkippedByCID[plan.Entry.CID]++ + progress(fmt.Sprintf("NodeBB refused to delete wiki topic %d during managed namespace reset; skipping it", plan.Entry.TID)) + continue + } return result, fmt.Errorf("deploy wiki page %q: purge NodeBB topic %d: %w", plan.Page.PageID, plan.Entry.TID, err) } + if plan.Reset { + resetPurgedByCID[plan.Entry.CID]++ + } case "rename": if err := client.renameWikiPage(plan.Entry.TID, plan.Entry.CID, plan.Title); err != nil { return result, fmt.Errorf("deploy wiki page %q: rename NodeBB topic %d to %q: %w", plan.Page.PageID, plan.Entry.TID, plan.Title, err) } } } + // NodeBB answers 403 both for the pages it will never delete — the wiki + // home topic — and for a token without purge privileges, and the two are + // not distinguishable from the response. What tells them apart is scope: a + // category where nothing at all could be deleted is a privilege problem, + // not a home page. Reporting that as a completed reset would leave the + // manifest claiming a fresh start over pages that are all still there, so + // the next deploy would recreate every one of them as a duplicate. + for _, cid := range slices.Sorted(maps.Keys(resetSkippedByCID)) { + if resetPurgedByCID[cid] == 0 { + return result, fmt.Errorf("NodeBB refused every managed namespace reset deletion in category %d (%d topic(s)); check that the deploy token has wiki purge privileges there", cid, resetSkippedByCID[cid]) + } + } if err := saveDeployManifest(manifestPath, nextManifest); err != nil { return result, err } @@ -486,7 +541,7 @@ func isDir(path string) bool { return err == nil && info.IsDir() } -func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManifest, opts DeployWikiOptions, client *nodeBBClient, progress func(string)) ([]wikiDeployPlan, deployResult, wikiDeployManifest, error) { +func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManifest, opts DeployWikiOptions, client *nodeBBClient, progress func(string)) ([]wikiDeployPlan, DeployResult, wikiDeployManifest, error) { if progress == nil { progress = func(string) {} } @@ -494,17 +549,23 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife next := wikiDeployManifest{Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{}} remotePagesByCID := map[int][]nodeBBWikiPage{} var plans []wikiDeployPlan - var result deployResult + var result DeployResult if opts.ResetManagedNamespaces { if !opts.AllowCreates && len(pageIDs) > 0 { return nil, result, next, errors.New("wiki managed namespace reset requires --create so current generated pages can be recreated") } - resetPlans, purged, err := planManagedNamespaceReset(opts, client, progress) + resetPlans, purged, unrecognized, err := planManagedNamespaceReset(opts, manifest, client, progress) if err != nil { return nil, result, next, err } plans = append(plans, resetPlans...) result.Purged += purged + // A namespace reset deletes remote pages the same way stale purge does, + // so it is counted as stale: that is the number callers word their + // destructive-policy warning around. + result.Stale += purged + result.ResetPurged = purged + result.ResetUnrecognized = unrecognized manifest = wikiDeployManifest{Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{}} for _, cid := range opts.CategoryIDs { if cid != 0 { @@ -682,7 +743,11 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife return plans, result, next, nil } -func planManagedNamespaceReset(opts DeployWikiOptions, client *nodeBBClient, progress func(string)) ([]wikiDeployPlan, int, error) { +// planManagedNamespaceReset queues a purge for every topic in each managed +// category, not only the ones the manifest says we wrote. The second return is +// the plan count; the third is how many of those topics the manifest has no +// record of, which is the subset a re-seed cannot put back. +func planManagedNamespaceReset(opts DeployWikiOptions, manifest wikiDeployManifest, client *nodeBBClient, progress func(string)) ([]wikiDeployPlan, int, int, error) { namespaces := slices.Clone(opts.Namespaces) if len(namespaces) == 0 { for namespace := range opts.CategoryIDs { @@ -691,7 +756,15 @@ func planManagedNamespaceReset(opts DeployWikiOptions, client *nodeBBClient, pro } slices.Sort(namespaces) + manifestTIDs := map[int]struct{}{} + for _, entry := range manifest.Pages { + if entry.TID != 0 { + manifestTIDs[entry.TID] = struct{}{} + } + } + seenTIDs := map[int]struct{}{} + unrecognized := 0 var plans []wikiDeployPlan for _, namespace := range namespaces { cid := opts.CategoryIDs[namespace] @@ -701,7 +774,7 @@ func planManagedNamespaceReset(opts DeployWikiOptions, client *nodeBBClient, pro progress(fmt.Sprintf("Listing NodeBB wiki namespace category %d for managed reset", cid)) remotePages, err := client.listNamespacePages(cid) if err != nil { - return nil, 0, err + return nil, 0, 0, err } slices.SortFunc(remotePages, func(a, b nodeBBWikiPage) int { return a.TID - b.TID @@ -714,19 +787,25 @@ func planManagedNamespaceReset(opts DeployWikiOptions, client *nodeBBClient, pro continue } seenTIDs[remotePage.TID] = struct{}{} + _, known := manifestTIDs[remotePage.TID] + if !known { + unrecognized++ + } title := strings.TrimSpace(remotePage.Title) if title == "" { title = strings.TrimSpace(remotePage.TitleLeaf) } pageID := fmt.Sprintf("%s:reset-topic-%d", namespace, remotePage.TID) plans = append(plans, wikiDeployPlan{ - Page: wikiDeployPage{PageID: pageID, Title: title, Namespace: namespace}, - Entry: wikiDeployManifestPage{TID: remotePage.TID, CID: cid, Namespace: namespace, Title: title}, - Action: "purge", + Page: wikiDeployPage{PageID: pageID, Title: title, Namespace: namespace}, + Entry: wikiDeployManifestPage{TID: remotePage.TID, CID: cid, Namespace: namespace, Title: title}, + Action: "purge", + Reset: true, + Unrecognized: !known, }) } } - return plans, len(plans), nil + return plans, len(plans), unrecognized, nil } func findMappedRemoteTopic(entry wikiDeployManifestPage, client *nodeBBClient) (nodeBBPost, bool, error) { @@ -1611,16 +1690,36 @@ func (c *nodeBBClient) renameWikiPage(tid, cid int, title string) error { return c.request("PUT", "/api/v3/plugins/westgate-wiki/page/move", body, nil) } -func (c *nodeBBClient) purgeTopic(tid int) error { +// purgeWikiPage deletes a wiki topic through the wiki plugin's own page +// actions. The core DELETE /api/v3/topics/{tid} route is refused for topics in +// wiki categories, because revision history is plugin-owned, so a purge has to +// tombstone the page first and then hard-purge it. +func (c *nodeBBClient) purgeWikiPage(tid int) error { if tid == 0 { - return fmt.Errorf("NodeBB topic purge requires topic id") + return fmt.Errorf("NodeBB wiki page purge requires topic id") } - err := c.request(http.MethodDelete, fmt.Sprintf("/api/v3/topics/%d", tid), nil, nil) + body := map[string]any{"tid": tid} + if err := c.request(http.MethodPut, "/api/v3/plugins/westgate-wiki/page/tombstone", body, nil); err != nil { + if isNodeBBMissingResource(err) { + return nil + } + return err + } + if err := c.request(http.MethodDelete, "/api/v3/plugins/westgate-wiki/page/hard-purge", body, nil); err != nil { + if isNodeBBMissingResource(err) { + return nil + } + return err + } + return nil +} + +// isNodeBBWikiPageUndeletable reports whether NodeBB refused to delete the page +// outright rather than failing transiently. The wiki home topic answers this +// way: the plugin excludes it from tombstone, restore, and purge alike. +func isNodeBBWikiPageUndeletable(err error) bool { var httpErr nodeBBHTTPError - if errors.As(err, &httpErr) && (httpErr.Status == http.StatusNotFound || httpErr.Status == http.StatusGone) { - return nil - } - return err + return errors.As(err, &httpErr) && httpErr.Status == http.StatusForbidden } func (c *nodeBBClient) acquireEditLock(tid int) (nodeBBEditLock, error) { diff --git a/internal/topdata/wiki_deploy_test.go b/internal/topdata/wiki_deploy_test.go index edf2a54..33f9872 100644 --- a/internal/topdata/wiki_deploy_test.go +++ b/internal/topdata/wiki_deploy_test.go @@ -2,6 +2,7 @@ package topdata import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" @@ -35,7 +36,7 @@ func TestDeployWikiDryRunDoesNotWriteRemoteOrManifest(t *testing.T) { } updateCalls := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Authorization"); got != "Bearer nodebb-token" { t.Fatalf("unexpected authorization header %q", got) } @@ -50,7 +51,7 @@ func TestDeployWikiDryRunDoesNotWriteRemoteOrManifest(t *testing.T) { default: t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) } - })) + }) defer server.Close() result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ @@ -92,7 +93,7 @@ func TestDeployWikiReportsPlanningProgressBeforeRemoteWork(t *testing.T) { } progress := []string{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/3/pages": w.Header().Set("Content-Type", "application/json") @@ -100,7 +101,7 @@ func TestDeployWikiReportsPlanningProgressBeforeRemoteWork(t *testing.T) { default: t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) } - })) + }) defer server.Close() _, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ @@ -174,7 +175,7 @@ func TestDeployWikiReportsLiveExecutionProgress(t *testing.T) { } progress := []string{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7": w.Header().Set("Content-Type", "application/json") @@ -194,7 +195,7 @@ func TestDeployWikiReportsLiveExecutionProgress(t *testing.T) { default: t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) } - })) + }) defer server.Close() result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ @@ -228,7 +229,7 @@ func TestDeployWikiReportsLiveExecutionProgress(t *testing.T) { func TestNodeBBNamespacePaginationStopsWhenRepeatedCursorReturnsNoNewPages(t *testing.T) { requests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { requests++ w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ @@ -240,7 +241,7 @@ func TestNodeBBNamespacePaginationStopsWhenRepeatedCursorReturnsNoNewPages(t *te "nextCursor": "same-cursor", }, }) - })) + }) defer server.Close() client := newNodeBBClient(server.URL, "nodebb-token") @@ -258,7 +259,7 @@ func TestNodeBBNamespacePaginationStopsWhenRepeatedCursorReturnsNoNewPages(t *te func TestNodeBBNamespacePaginationRejectsRepeatedCursorWithNewPages(t *testing.T) { requests := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { requests++ w.Header().Set("Content-Type", "application/json") pages := []map[string]any{ @@ -276,7 +277,7 @@ func TestNodeBBNamespacePaginationRejectsRepeatedCursorWithNewPages(t *testing.T "nextCursor": "same-cursor", }, }) - })) + }) defer server.Close() client := newNodeBBClient(server.URL, "nodebb-token") @@ -480,7 +481,7 @@ func TestDeployWikiDryRunReadoptsMissingMappedPost(t *testing.T) { t.Fatalf("write deploy manifest: %v", err) } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42": w.Header().Set("Content-Type", "application/json") @@ -508,7 +509,7 @@ func TestDeployWikiDryRunReadoptsMissingMappedPost(t *testing.T) { default: t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) } - })) + }) defer server.Close() result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ @@ -731,7 +732,7 @@ func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) { } createCalls := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Authorization"); got != "Bearer nodebb-token" { t.Fatalf("unexpected authorization header %q", got) } @@ -769,7 +770,7 @@ func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) { }, }, }) - })) + }) defer server.Close() manifestPath := filepath.Join(root, "deploy-manifest.json") @@ -848,7 +849,7 @@ func TestDeployWikiRepairsManifestedPageMissingSourceContentSync(t *testing.T) { } updateCalls := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42": w.Header().Set("Content-Type", "application/json") @@ -876,7 +877,7 @@ func TestDeployWikiRepairsManifestedPageMissingSourceContentSync(t *testing.T) { default: t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) } - })) + }) defer server.Close() result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ @@ -951,7 +952,7 @@ func TestDeployWikiCreatesNodeBBTopicWithoutFallbackForDefaultThreeCharacterTitl t.Fatalf("write source page: %v", err) } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/5/pages" { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}}) @@ -983,7 +984,7 @@ func TestDeployWikiCreatesNodeBBTopicWithoutFallbackForDefaultThreeCharacterTitl }, }, }) - })) + }) defer server.Close() _, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ @@ -1010,7 +1011,7 @@ func TestDeployWikiCreatesNodeBBTopicWithFallbackForTitleShorterThanConfiguredMi t.Fatalf("write source page: %v", err) } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/5/pages" { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}}) @@ -1038,7 +1039,7 @@ func TestDeployWikiCreatesNodeBBTopicWithFallbackForTitleShorterThanConfiguredMi }, }, }) - })) + }) defer server.Close() _, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ @@ -1080,7 +1081,7 @@ func TestDeployWikiRenamesExistingPrefixedTopicWhenTitleIsLongEnough(t *testing. } renameCalls := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7": w.Header().Set("Content-Type", "application/json") @@ -1109,7 +1110,7 @@ func TestDeployWikiRenamesExistingPrefixedTopicWhenTitleIsLongEnough(t *testing. default: t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) } - })) + }) defer server.Close() result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ @@ -1166,7 +1167,7 @@ func TestDeployWikiRenamesManagedTopicWhenGeneratedTitleChanges(t *testing.T) { } renameCalls := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7": w.Header().Set("Content-Type", "application/json") @@ -1195,7 +1196,7 @@ func TestDeployWikiRenamesManagedTopicWhenGeneratedTitleChanges(t *testing.T) { default: t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) } - })) + }) defer server.Close() result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ @@ -1258,9 +1259,9 @@ func TestDeployWikiDoesNotRenameHeadinglessPageToPageIDFallback(t *testing.T) { t.Fatalf("write deploy manifest: %v", err) } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { t.Fatalf("headingless unchanged page must not call NodeBB, got %s %s", r.Method, r.URL.String()) - })) + }) defer server.Close() result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ @@ -1320,7 +1321,7 @@ func TestDeployWikiRenamesBrokenHeadinglessFallbackTitleBackToPageIndexTitle(t * } renameCalls := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7": w.Header().Set("Content-Type", "application/json") @@ -1345,7 +1346,7 @@ func TestDeployWikiRenamesBrokenHeadinglessFallbackTitleBackToPageIndexTitle(t * default: t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) } - })) + }) defer server.Close() result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ @@ -1396,7 +1397,7 @@ func TestDeployWikiAdoptsExistingNodeBBPageWhenManifestIsMissingWithoutCreate(t createCalls := 0 var updated string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/9/pages": w.Header().Set("Content-Type", "application/json") @@ -1442,7 +1443,7 @@ func TestDeployWikiAdoptsExistingNodeBBPageWhenManifestIsMissingWithoutCreate(t default: t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) } - })) + }) defer server.Close() manifestPath := filepath.Join(root, "deploy-manifest.json") @@ -1509,7 +1510,7 @@ func TestDeployWikiMergesHTMLManagedAndManualRegions(t *testing.T) { } var updated string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42": w.Header().Set("Content-Type", "application/json") @@ -1533,7 +1534,7 @@ func TestDeployWikiMergesHTMLManagedAndManualRegions(t *testing.T) { default: t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) } - })) + }) defer server.Close() result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ @@ -1589,7 +1590,7 @@ func TestDeployWikiUpdateAcquiresWestgateWikiEditLock(t *testing.T) { lockAcquired := false var updateToken string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42": w.Header().Set("Content-Type", "application/json") @@ -1621,7 +1622,7 @@ func TestDeployWikiUpdateAcquiresWestgateWikiEditLock(t *testing.T) { default: t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) } - })) + }) defer server.Close() result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ @@ -1668,7 +1669,7 @@ func TestDeployWikiCreateCollisionAdoptsExistingNodeBBPage(t *testing.T) { createCalls := 0 var updated string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/9/pages" && r.URL.Query().Get("q") == "": w.Header().Set("Content-Type", "application/json") @@ -1719,7 +1720,7 @@ func TestDeployWikiCreateCollisionAdoptsExistingNodeBBPage(t *testing.T) { default: t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) } - })) + }) defer server.Close() manifestPath := filepath.Join(root, "deploy-manifest.json") @@ -1771,7 +1772,7 @@ func TestDeployWikiCreateCollisionSearchesCanonicalTitleSegment(t *testing.T) { createCalls := 0 var updated string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/58/pages" && r.URL.Query().Get("q") == "": w.Header().Set("Content-Type", "application/json") @@ -1828,7 +1829,7 @@ func TestDeployWikiCreateCollisionSearchesCanonicalTitleSegment(t *testing.T) { default: t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) } - })) + }) defer server.Close() manifestPath := filepath.Join(root, "deploy-manifest.json") @@ -1868,7 +1869,7 @@ func TestDeployWikiReportsAndArchivesStalePages(t *testing.T) { } var archived string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/edit-lock" { respondWikiEditLock(t, w, r, 7, "archive-lock") return @@ -1889,7 +1890,7 @@ func TestDeployWikiReportsAndArchivesStalePages(t *testing.T) { archived = req.Content w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 42, "tid": 7}}) - })) + }) defer server.Close() report, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ @@ -1945,9 +1946,9 @@ func TestDeployWikiDryRunPlansTrackedStalePagePurge(t *testing.T) { }); err != nil { t.Fatalf("write deploy manifest: %v", err) } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { t.Fatalf("dry-run purge must not call NodeBB, got %s %s", r.Method, r.URL.Path) - })) + }) defer server.Close() result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ @@ -1984,9 +1985,9 @@ func TestDeployWikiPurgeRefusesStaleManifestEntryWithoutTopicID(t *testing.T) { }); err != nil { t.Fatalf("write deploy manifest: %v", err) } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { t.Fatalf("missing-topic-id purge must not call NodeBB, got %s %s", r.Method, r.URL.Path) - })) + }) defer server.Close() _, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ @@ -2017,15 +2018,12 @@ func TestDeployWikiPurgesTrackedStaleGeneratedTopic(t *testing.T) { }); err != nil { t.Fatalf("write deploy manifest: %v", err) } - purgeCalls := 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodDelete || r.URL.Path != "/api/v3/topics/7" { + var calls []string + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { + if !answerWikiPagePurge(t, w, r, &calls) { t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) } - purgeCalls++ - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}}) - })) + }) defer server.Close() result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ @@ -2041,8 +2039,8 @@ func TestDeployWikiPurgesTrackedStaleGeneratedTopic(t *testing.T) { if result.Stale != 1 || result.Purged != 1 { t.Fatalf("expected one stale purge, got %#v", result) } - if purgeCalls != 1 { - t.Fatalf("expected one NodeBB topic purge call, got %d", purgeCalls) + if strings.Join(calls, ",") != "tombstone:7,hard-purge:7" { + t.Fatalf("expected the wiki plugin page actions to purge topic 7, got %#v", calls) } if _, ok := loadDeployManifest(manifestPath).Pages["skills:retired"]; ok { t.Fatalf("expected purged stale manifest entry to be removed") @@ -2075,18 +2073,17 @@ func TestDeployWikiPurgesTrackedStaleTopicsBeforeCreatingReplacementPages(t *tes } var calls []string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { + if answerWikiPagePurge(t, w, r, &calls) { + return + } switch { case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/3/pages": w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}}) - case r.Method == http.MethodDelete && r.URL.Path == "/api/v3/topics/7": - calls = append(calls, "purge") - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}}) case r.Method == http.MethodPost && r.URL.Path == "/api/v3/topics": calls = append(calls, "create") - if len(calls) != 2 || calls[0] != "purge" { + if strings.Join(calls, ",") != "tombstone:7,hard-purge:7,create" { t.Fatalf("expected stale purge before create, got calls %#v", calls) } w.Header().Set("Content-Type", "application/json") @@ -2102,7 +2099,7 @@ func TestDeployWikiPurgesTrackedStaleTopicsBeforeCreatingReplacementPages(t *tes default: t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) } - })) + }) defer server.Close() result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ @@ -2120,7 +2117,7 @@ func TestDeployWikiPurgesTrackedStaleTopicsBeforeCreatingReplacementPages(t *tes if result.Created != 1 || result.Purged != 1 { t.Fatalf("expected one create and one purge, got %#v", result) } - if strings.Join(calls, ",") != "purge,create" { + if strings.Join(calls, ",") != "tombstone:7,hard-purge:7,create" { t.Fatalf("expected purge before create, got %#v", calls) } } @@ -2150,8 +2147,12 @@ func TestDeployWikiResetManagedNamespacesPurgesRemotePagesBeforeCreatingFreshMan t.Fatalf("write deploy manifest: %v", err) } + const resetCalls = "tombstone:7,hard-purge:7,tombstone:8,hard-purge:8" var calls []string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { + if answerWikiPagePurge(t, w, r, &calls) { + return + } switch { case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/3/pages": w.Header().Set("Content-Type", "application/json") @@ -2164,17 +2165,9 @@ func TestDeployWikiResetManagedNamespacesPurgesRemotePagesBeforeCreatingFreshMan "hasMore": false, }, }) - case r.Method == http.MethodDelete && r.URL.Path == "/api/v3/topics/7": - calls = append(calls, "purge:7") - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}}) - case r.Method == http.MethodDelete && r.URL.Path == "/api/v3/topics/8": - calls = append(calls, "purge:8") - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}}) case r.Method == http.MethodPost && r.URL.Path == "/api/v3/topics": calls = append(calls, "create") - if strings.Join(calls, ",") != "purge:7,purge:8,create" { + if strings.Join(calls, ",") != resetCalls+",create" { t.Fatalf("expected namespace reset purges before create, got %#v", calls) } w.Header().Set("Content-Type", "application/json") @@ -2190,7 +2183,7 @@ func TestDeployWikiResetManagedNamespacesPurgesRemotePagesBeforeCreatingFreshMan default: t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) } - })) + }) defer server.Close() result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ @@ -2205,10 +2198,21 @@ func TestDeployWikiResetManagedNamespacesPurgesRemotePagesBeforeCreatingFreshMan if err != nil { t.Fatalf("DeployWikiWithOptions reset-managed-namespaces failed: %v", err) } - if result.Created != 1 || result.Purged != 2 || result.Stale != 0 { + if result.Created != 1 || result.Purged != 2 { t.Fatalf("expected one create and two namespace purges, got %#v", result) } - if strings.Join(calls, ",") != "purge:7,purge:8,create" { + // Callers word their destructive-policy warning around the stale count, so + // reset deletions have to show up there rather than on a separate path the + // preview never mentions. + if result.Stale != 2 || result.ResetPurged != 2 { + t.Fatalf("expected reset deletions counted as stale, got %#v", result) + } + // tid 7 is the manifest's own page; tid 8 is a topic the deployer never + // wrote, and re-seeding cannot bring it back. + if result.ResetUnrecognized != 1 { + t.Fatalf("expected one unrecognized reset deletion, got %#v", result) + } + if strings.Join(calls, ",") != resetCalls+",create" { t.Fatalf("expected reset purges before create, got %#v", calls) } manifest := loadDeployManifest(manifestPath) @@ -2236,9 +2240,9 @@ func TestDeployWikiResetManagedNamespacesRequiresCreateForLocalPages(t *testing. t.Fatalf("write source page: %v", err) } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { t.Fatalf("reset without --create must fail before remote calls, got %s %s", r.Method, r.URL.String()) - })) + }) defer server.Close() _, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ @@ -2270,21 +2274,21 @@ func TestDeployWikiPurgeTreatsAlreadyMissingTrackedTopicAsSuccess(t *testing.T) t.Fatalf("write deploy manifest: %v", err) } seen := map[string]int{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodDelete { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { + call, ok := wikiPagePurgeCall(t, r) + if !ok { t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) } - seen[r.URL.Path]++ + seen[call]++ w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/api/v3/topics/7": + // Topic 7 is already gone, so the plugin cannot find the page to + // tombstone; topic 8 still exists and purges normally. + if call == "tombstone:7" { http.Error(w, `{"status":{"message":"topic not found"}}`, http.StatusNotFound) - case "/api/v3/topics/8": - _ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}}) - default: - t.Fatalf("unexpected purge path %s", r.URL.Path) + return } - })) + _ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}}) + }) defer server.Close() result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ @@ -2300,8 +2304,11 @@ func TestDeployWikiPurgeTreatsAlreadyMissingTrackedTopicAsSuccess(t *testing.T) if result.Stale != 2 || result.Purged != 2 { t.Fatalf("expected two stale purges, got %#v", result) } - if seen["/api/v3/topics/7"] != 1 || seen["/api/v3/topics/8"] != 1 { - t.Fatalf("expected one purge call for each tracked topic, got %#v", seen) + if seen["tombstone:7"] != 1 || seen["hard-purge:7"] != 0 { + t.Fatalf("expected the already-missing topic to stop after tombstone, got %#v", seen) + } + if seen["tombstone:8"] != 1 || seen["hard-purge:8"] != 1 { + t.Fatalf("expected the surviving topic to be tombstoned then purged, got %#v", seen) } manifest := loadDeployManifest(manifestPath) if _, ok := manifest.Pages["skills:retired"]; ok { @@ -2339,9 +2346,9 @@ func TestDeployWikiPurgeDoesNotTargetCurrentGeneratedPages(t *testing.T) { }); err != nil { t.Fatalf("write deploy manifest: %v", err) } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { t.Fatalf("current generated page must not call NodeBB during matching-hash deploy, got %s %s", r.Method, r.URL.Path) - })) + }) defer server.Close() result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ @@ -2385,3 +2392,187 @@ func respondWikiEditLock(t *testing.T, w http.ResponseWriter, r *http.Request, e w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"status": "ok", "tid": expectedTID, "token": token}}) } + +// newFakeNodeBB stands up a fake NodeBB that behaves like one running +// nodebb-plugin-westgate-wiki: native topic mutation on wiki categories is +// refused, because wiki revision history is plugin-owned. Every deploy test +// goes through here, so a deployer that reaches for the core topic API fails in +// CI the way it fails in production rather than passing against a fake that is +// more permissive than the real thing. +func newFakeNodeBB(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/v3/topics/") && (r.Method == http.MethodDelete || r.Method == http.MethodPut) { + http.Error( + w, + `{"status":{"code":"bad-request","message":"Use the wiki page actions to delete, restore, or purge wiki pages."}}`, + http.StatusBadRequest, + ) + return + } + handler(w, r) + })) +} + +// wikiPagePurgeCall recognizes the plugin page actions a purge goes through and +// reports them as ":" so tests can assert the order. +func wikiPagePurgeCall(t *testing.T, r *http.Request) (string, bool) { + t.Helper() + var action string + switch { + case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/page/tombstone": + action = "tombstone" + case r.Method == http.MethodDelete && r.URL.Path == "/api/v3/plugins/westgate-wiki/page/hard-purge": + action = "hard-purge" + default: + return "", false + } + var req struct { + TID int `json:"tid"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode wiki %s request: %v", action, err) + } + if req.TID == 0 { + t.Fatalf("expected wiki %s request to carry a topic id", action) + } + return fmt.Sprintf("%s:%d", action, req.TID), true +} + +// answerWikiPagePurge records and acknowledges a plugin purge action, which is +// what most fakes want to do with one. +func answerWikiPagePurge(t *testing.T, w http.ResponseWriter, r *http.Request, calls *[]string) bool { + t.Helper() + call, ok := wikiPagePurgeCall(t, r) + if !ok { + return false + } + *calls = append(*calls, call) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}}) + return true +} + +// resetPurgeFixture builds a source tree, a deploy manifest, and the reset +// options the namespace-reset tests share. +func resetPurgeFixture(t *testing.T, namespaces map[string]int, tidsByCID map[int][]int) (DeployWikiOptions, func(w http.ResponseWriter, r *http.Request) bool) { + t.Helper() + root := t.TempDir() + sourceDir := filepath.Join(root, "pages") + if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil { + t.Fatalf("create source dir: %v", err) + } + manifestPath := filepath.Join(root, "deploy-manifest.json") + if err := saveDeployManifest(manifestPath, wikiDeployManifest{Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{}}); err != nil { + t.Fatalf("write deploy manifest: %v", err) + } + + listPages := func(w http.ResponseWriter, r *http.Request) bool { + if r.Method != http.MethodGet { + return false + } + var cid int + if _, err := fmt.Sscanf(r.URL.Path, "/api/v3/plugins/westgate-wiki/namespace/%d/pages", &cid); err != nil { + return false + } + tids, ok := tidsByCID[cid] + if !ok { + t.Fatalf("unexpected namespace listing for category %d", cid) + } + pages := make([]map[string]any, 0, len(tids)) + for _, tid := range tids { + pages = append(pages, map[string]any{"tid": tid, "title": fmt.Sprintf("Page %d", tid)}) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": pages, "hasMore": false}}) + return true + } + + return DeployWikiOptions{ + SourceDir: sourceDir, + ManifestPath: manifestPath, + Token: "nodebb-token", + CategoryIDs: namespaces, + AllowCreates: true, + ResetManagedNamespaces: true, + }, listPages +} + +func TestDeployWikiResetSkipsTopicsNodeBBRefusesToDelete(t *testing.T) { + opts, listPages := resetPurgeFixture(t, map[string]int{"skills": 3}, map[int][]int{3: {5, 6}}) + var calls []string + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { + if listPages(w, r) { + return + } + call, ok := wikiPagePurgeCall(t, r) + if !ok { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + calls = append(calls, call) + // Topic 5 stands in for the wiki home page, which the plugin excludes + // from tombstone, restore, and purge alike. + if call == "tombstone:5" { + http.Error(w, `{"status":{"message":"[[error:no-privileges]]"}}`, http.StatusForbidden) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}}) + }) + defer server.Close() + opts.Endpoint = server.URL + + result, err := DeployWikiWithOptions(&project.Project{}, opts, nil) + if err != nil { + t.Fatalf("DeployWikiWithOptions reset with an undeletable topic failed: %v", err) + } + if result.ResetSkipped != 1 || result.ResetPurged != 1 { + t.Fatalf("expected one skipped and one completed reset deletion, got %#v", result) + } + if result.Purged != 1 || result.Stale != 1 { + t.Fatalf("expected the skipped topic to be dropped from the counts, got %#v", result) + } + if result.ResetUnrecognized != 1 { + t.Fatalf("expected only the purged unrecognized topic to be counted, got %#v", result) + } + if strings.Join(calls, ",") != "tombstone:5,tombstone:6,hard-purge:6" { + t.Fatalf("expected the reset to continue past the refused topic, got %#v", calls) + } +} + +// NodeBB answers 403 both for the wiki home page and for a token without purge +// privileges. A category where nothing could be deleted at all is the second +// case, and reporting it as a completed reset would leave the manifest claiming +// a fresh start over pages that are all still there. +func TestDeployWikiResetFailsWhenACategoryRefusesEveryDeletion(t *testing.T) { + opts, listPages := resetPurgeFixture(t, + map[string]int{"skills": 3, "feats": 4}, + map[int][]int{3: {5, 6}, 4: {7, 8}}, + ) + server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { + if listPages(w, r) { + return + } + call, ok := wikiPagePurgeCall(t, r) + if !ok { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + // Category 4 holds topics 7 and 8, and the token can purge neither. + if strings.HasSuffix(call, ":7") || strings.HasSuffix(call, ":8") { + http.Error(w, `{"status":{"message":"[[error:no-privileges]]"}}`, http.StatusForbidden) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}}) + }) + defer server.Close() + opts.Endpoint = server.URL + + _, err := DeployWikiWithOptions(&project.Project{}, opts, nil) + if err == nil || !strings.Contains(err.Error(), "wiki purge privileges") { + t.Fatalf("expected a category that deleted nothing to fail loudly, got %v", err) + } + if !strings.Contains(err.Error(), "4") { + t.Fatalf("expected the failure to name the refusing category, got %v", err) + } +}