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) + } +}