Compare commits

..
1 Commits
Author SHA1 Message Date
archvillainetteandClaude Opus 5 f958436238 docs(agents): warn that tea label edits are not visible to an immediate read-back
ci / ci (pull_request) Successful in 4m10s
`tea issues edit --add-labels` applies org-level labels correctly, but
`tea issues ls` can return the pre-edit label set for several seconds
afterwards. Reading that stale result as a failed edit sent one
investigation down three wrong paths (missing token, tea too old, switch
to curl) before the original command turned out to have worked.

Recorded next to the existing `</dev/null` trap; both are tea behaviours
that read as breakage to a non-interactive caller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 12:53:46 +02:00
10 changed files with 154 additions and 863 deletions
-6
View File
@@ -11,12 +11,6 @@ permissions:
code: read code: read
releases: write releases: write
# The shared runner runs 2 jobs at once now (sow-platform#189); capacity 1 was
# the implicit mutex. Serialize this workflow explicitly; never cancel mid-run.
concurrency:
group: build-binaries
cancel-in-progress: false
jobs: jobs:
build-binaries: build-binaries:
runs-on: nix-docker runs-on: nix-docker
-6
View File
@@ -7,12 +7,6 @@ on:
permissions: read-all permissions: read-all
# One run per ref: a new push obsoletes the run on the old head, so cancel it
# and free the runner slot for the run that can still matter.
concurrency:
group: ci-${{ gitea.ref }}
cancel-in-progress: true
jobs: jobs:
ci: ci:
runs-on: nix-docker runs-on: nix-docker
-6
View File
@@ -16,12 +16,6 @@ on:
permissions: read-all permissions: read-all
# The shared runner runs 2 jobs at once now (sow-platform#189); capacity 1 was
# the implicit mutex. Serialize this workflow explicitly; never cancel mid-run.
concurrency:
group: sync-wrappers
cancel-in-progress: false
jobs: jobs:
sync: sync:
runs-on: nix-docker runs-on: nix-docker
+8 -26
View File
@@ -57,32 +57,14 @@ preference — move it.
so any non-interactive shell (every agent) hangs forever without so any non-interactive shell (every agent) hangs forever without
`</dev/null`. Same trap on `tea issues create --description` and `</dev/null`. Same trap on `tea issues create --description` and
`tea pr create`. `tea pr create`.
- **Apply / remove labels**: `tea issues edit <number> --add-labels "Kind/Bug"` - **Apply / remove labels**: `tea api --method PATCH` on the issue, or
(and `--remove-labels`). This handles org-level labels (`Kind/*`, `tea api repos/ShadowsOverWestgate/sow-tools/issues/<number>/labels` endpoints.
`Priority/*`, `Reviewed/*`, `Status/*`) from tea 0.15 onwards. On 0.14 it did `tea issues edit --add-labels` works too, org-level labels (`Kind/*`,
not: name resolution searched only this repo's own label set, so an org label `Priority/*`) included, but a read-back straight afterwards can still show
matched nothing and the command exited 0, printed the issue, and changed the old set — `tea issues ls` has returned stale labels for several seconds
nothing. Upstream fixed it in v0.15 (`modules/task/labels.go` also queries after an edit that succeeded. Never conclude an edit failed from a single
`ListOrgLabels`). Note `tea labels` lists repo labels only and will not show immediate list; re-read after a pause, or use the `labels` endpoints above,
you the org set — `tea api orgs/ShadowsOverWestgate/labels` does. whose response *is* the resulting label set and needs no second call.
- **`tea api` needs a token in the login; SSH auth is not enough.** It sends
only the login's `token:` field and does not sign requests with your SSH key,
so an SSH-key-only login gets `{"message":"token is required"}` on every call
that needs auth. Reads against these public repos still succeed anonymously,
which hides the gap until the first write. Add a token to the login in
`~/.config/tea/config.yml` (Settings > Applications; `write:issue` covers
labels, comments and dependencies) and `tea api` works for reads and writes
alike. The `tea issues` / `tea pr` subcommands authenticate either way, so
they keep working with no token at all — that asymmetry is what makes this
confusing to diagnose.
- **Verify every label change by re-reading it.** A label command exiting 0 is
not evidence it applied — that is exactly how the 0.14 silent no-op above hid
for so long, and assuming otherwise has already cost one investigation
several wrong turns. Read the resulting set back with
`tea api repos/ShadowsOverWestgate/sow-tools/issues/<number>` and check its
`labels` field, or `tea issues ls -o json`. The read-back reflects the write
immediately; if it comes back empty, the write genuinely failed. Do not
explain an empty read-back away as replication lag.
- **Close**: `tea issues close <number>` - **Close**: `tea issues close <number>`
`tea` infers the repo from the git remote when run inside the clone. `tea` infers the repo from the git remote when run inside the clone.
+3 -11
View File
@@ -24,19 +24,11 @@ this.
tea issues create --title "..." --description "..." --labels "Kind/Bug" </dev/null tea issues create --title "..." --description "..." --labels "Kind/Bug" </dev/null
tea pr create --title "..." --description "..." --labels "Kind/Feature" </dev/null tea pr create --title "..." --description "..." --labels "Kind/Feature" </dev/null
# add a label to something that already exists (needs tea >= 0.15 for org labels) # add a label to something that already exists
tea issues edit <n> --add-labels "Kind/Bug,Priority/High" tea api --method POST "repos/ShadowsOverWestgate/<repo>/issues/<n>/labels" \
--data '{"labels":["Kind/Bug","Priority/High"]}'
# read the result back — exit 0 is not evidence the label applied
tea api "repos/ShadowsOverWestgate/<repo>/issues/<n>"
``` ```
`tea api` sends only the login's `token:` and never your SSH key, so on an
SSH-only login every authenticated call fails with
`{"message":"token is required"}` while anonymous reads still work. Add a token
to `~/.config/tea/config.yml` if you want the raw endpoints. `tea issues edit`
needs no token.
## Kind — what this is (pick exactly one) ## Kind — what this is (pick exactly one)
| Label | Use it when | | Label | Use it when |
+11 -20
View File
@@ -927,28 +927,19 @@ func (c *topdataConsole) emitWikiBuildResult(outputDir string, pageCount int, st
fmt.Fprintf(c.stdout, "wiki status: %s\n", status) fmt.Fprintf(c.stdout, "wiki status: %s\n", status)
} }
func (c *topdataConsole) emitWikiDeployResult(result topdata.DeployResult) { func (c *topdataConsole) emitWikiDeployResult(localPages, created, updated, skipped, stale, archived, purged, drifted int, manifest string) {
spin.linebreak() spin.linebreak()
fmt.Fprintln(c.stdout, "Deploy Wiki ----------") fmt.Fprintln(c.stdout, "Deploy Wiki ----------")
fmt.Fprintf(c.stdout, "project: %s\n", c.projectName) fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
fmt.Fprintf(c.stdout, "local pages: %d\n", result.LocalPages) fmt.Fprintf(c.stdout, "local pages: %d\n", localPages)
fmt.Fprintf(c.stdout, "created: %d\n", result.Created) fmt.Fprintf(c.stdout, "created: %d\n", created)
fmt.Fprintf(c.stdout, "updated: %d\n", result.Updated) fmt.Fprintf(c.stdout, "updated: %d\n", updated)
fmt.Fprintf(c.stdout, "skipped: %d\n", result.Skipped) fmt.Fprintf(c.stdout, "skipped: %d\n", skipped)
fmt.Fprintf(c.stdout, "stale: %d\n", result.Stale) fmt.Fprintf(c.stdout, "stale: %d\n", stale)
fmt.Fprintf(c.stdout, "archived: %d\n", result.Archived) fmt.Fprintf(c.stdout, "archived: %d\n", archived)
fmt.Fprintf(c.stdout, "purged: %d\n", result.Purged) fmt.Fprintf(c.stdout, "purged: %d\n", purged)
fmt.Fprintf(c.stdout, "drifted: %d\n", result.Drifted) fmt.Fprintf(c.stdout, "drifted: %d\n", drifted)
if result.ResetPurged > 0 || result.ResetSkipped > 0 { fmt.Fprintf(c.stdout, "manifest: %s\n", c.relPath(manifest))
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 { func (c *topdataConsole) relPath(path string) string {
@@ -1588,7 +1579,7 @@ func runDeployWiki(ctx context) error {
return err return err
} }
console.emitWikiDeployResult(result) console.emitWikiDeployResult(result.LocalPages, result.Created, result.Updated, result.Skipped, result.Stale, result.Archived, result.Purged, result.Drifted, result.Manifest)
return nil return nil
} }
+1 -58
View File
@@ -10,7 +10,6 @@ import (
"time" "time"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/pipeline" "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/pipeline"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/topdata"
) )
func TestParseBuildHAKArgsContentAddressedRoot(t *testing.T) { func TestParseBuildHAKArgsContentAddressedRoot(t *testing.T) {
@@ -219,16 +218,7 @@ 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.progress("NodeBB wiki plan: create 1, update 2, skip 3, stale 4, archive 5, purge 6, drift 0")
console.emitWikiDeployResult(topdata.DeployResult{ console.emitWikiDeployResult(10, 1, 2, 3, 4, 5, 6, 0, "/workspace/project/build/wiki/deploy-manifest.json")
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() output := stdout.String()
if !strings.Contains(output, "NodeBB wiki plan") { if !strings.Contains(output, "NodeBB wiki plan") {
@@ -239,53 +229,6 @@ 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) { func TestParseDeployWikiHelpListsPurgeStalePolicy(t *testing.T) {
_, err := parseDeployWikiArgs("deploy-wiki", []string{"--help"}) _, err := parseDeployWikiArgs("deploy-wiki", []string{"--help"})
if err == nil || !strings.Contains(err.Error(), "--stale-policy <report|archive|purge>") { if err == nil || !strings.Contains(err.Error(), "--stale-policy <report|archive|purge>") {
+1 -2
View File
@@ -289,8 +289,7 @@ var Registry = []Builder{
"--dry-run report changes without writing", "--dry-run report changes without writing",
"--create allow missing pages to be created", "--create allow missing pages to be created",
"--force update unchanged pages", "--force update unchanged pages",
"--reset-managed-namespaces delete every page in the managed categories, including", "--reset-managed-namespaces reset managed namespace state",
" pages this deployer never wrote, then recreate from source",
}, },
Aliases: []CommandAlias{{Name: "deploy-wiki"}}, Aliases: []CommandAlias{{Name: "deploy-wiki"}},
}, },
+40 -200
View File
@@ -8,7 +8,6 @@ import (
"fmt" "fmt"
"html" "html"
"io" "io"
"maps"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -53,7 +52,7 @@ type DeployWikiOptions struct {
TitlePrefixMinLength int TitlePrefixMinLength int
} }
type DeployResult struct { type deployResult struct {
LocalPages int LocalPages int
Created int Created int
Updated int Updated int
@@ -62,24 +61,8 @@ type DeployResult struct {
Purged int Purged int
Skipped int Skipped int
Drifted int Drifted int
// DriftedPages names the pages counted in Drifted, in plan order, so an Renamed int
// operator can look at them before deciding whether --force is safe. Manifest string
DriftedPages []string
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 { type wikiDeployPage struct {
@@ -97,13 +80,7 @@ type wikiDeployManifest struct {
} }
type wikiDeployManifestPage struct { type wikiDeployManifestPage struct {
Hash string `json:"hash"` Hash string `json:"hash"`
// RemoteHash is the managed-region hash of the post body NodeBB handed back
// right after our last write to it. NodeBB owns that body, so this — not
// Hash, which is the hash of the text we rendered — is what the next run's
// drift check compares against. Empty on manifests written before this
// field existed; the drift check falls back to Hash for those.
RemoteHash string `json:"remote_hash,omitempty"`
LastSeenHash string `json:"last_seen_hash,omitempty"` LastSeenHash string `json:"last_seen_hash,omitempty"`
ArchivedHash string `json:"archived_hash,omitempty"` ArchivedHash string `json:"archived_hash,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
@@ -117,17 +94,12 @@ type wikiDeployManifestPage struct {
} }
type wikiDeployPlan struct { type wikiDeployPlan struct {
Page wikiDeployPage Page wikiDeployPage
Entry wikiDeployManifestPage Entry wikiDeployManifestPage
Action string Action string
Content string Content string
Title string RemoteHash string
// Reset marks a purge queued by --reset-managed-namespaces rather than by Title string
// 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 { type wikiNamespacesDocument struct {
@@ -144,7 +116,7 @@ type wikiNamespaceDeclaration struct {
EditPolicy string `json:"edit_policy" yaml:"edit_policy"` 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 { if progress == nil {
progress = func(string) {} progress = func(string) {}
} }
@@ -160,19 +132,19 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
} }
} }
if _, err := os.Stat(opts.SourceDir); err != nil { 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 == "" { if opts.Endpoint == "" {
return DeployResult{}, errors.New("NODEBB_API_ENDPOINT is required") return deployResult{}, errors.New("NODEBB_API_ENDPOINT is required")
} }
if opts.Token == "" { 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 != "" { 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" { 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 { if opts.TitlePrefixMinLength <= 0 {
opts.TitlePrefixMinLength = p.EffectiveConfig().TopData.Wiki.TitlePrefixMinLength opts.TitlePrefixMinLength = p.EffectiveConfig().TopData.Wiki.TitlePrefixMinLength
@@ -182,7 +154,7 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
if len(namespaces) == 0 { if len(namespaces) == 0 {
declarations, err := loadWikiNamespaceDeclarations(p) declarations, err := loadWikiNamespaceDeclarations(p)
if err != nil { if err != nil {
return DeployResult{}, err return deployResult{}, err
} }
for _, declaration := range declarations { for _, declaration := range declarations {
namespaces = append(namespaces, declaration.ID) namespaces = append(namespaces, declaration.ID)
@@ -192,7 +164,7 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
} }
envCategories, err := categoryIDsFromNamespaceEnv(declarations) envCategories, err := categoryIDsFromNamespaceEnv(declarations)
if err != nil { if err != nil {
return DeployResult{}, err return deployResult{}, err
} }
if len(envCategories) > 0 { if len(envCategories) > 0 {
merged := envCategories merged := envCategories
@@ -211,7 +183,7 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
progress(fmt.Sprintf("Collecting local wiki pages from %s", opts.SourceDir)) progress(fmt.Sprintf("Collecting local wiki pages from %s", opts.SourceDir))
pages, err := collectLocalPages(opts.SourceDir, opts.PageIndexPath, namespaces) pages, err := collectLocalPages(opts.SourceDir, opts.PageIndexPath, namespaces)
if err != nil { if err != nil {
return DeployResult{}, err return deployResult{}, err
} }
progress(fmt.Sprintf("Loaded %d local wiki page(s)", len(pages))) progress(fmt.Sprintf("Loaded %d local wiki page(s)", len(pages)))
manifest := loadDeployManifest(manifestPath) manifest := loadDeployManifest(manifestPath)
@@ -221,7 +193,7 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
progress(fmt.Sprintf("Planning NodeBB wiki deploy for %d local page(s)", len(pages))) progress(fmt.Sprintf("Planning NodeBB wiki deploy for %d local page(s)", len(pages)))
plans, result, nextManifest, err := planNodeBBDeploy(pages, manifest, opts, client, progress) plans, result, nextManifest, err := planNodeBBDeploy(pages, manifest, opts, client, progress)
if err != nil { if err != nil {
return DeployResult{}, err return deployResult{}, err
} }
result.LocalPages = len(pages) result.LocalPages = len(pages)
result.Manifest = manifestPath result.Manifest = manifestPath
@@ -231,7 +203,7 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
return result, nil return result, nil
} }
if result.Drifted > 0 && !opts.Force { if result.Drifted > 0 && !opts.Force {
return result, fmt.Errorf("remote managed wiki content drifted on %d page(s) (%s); rerun with --force to overwrite", result.Drifted, summarizeDriftedPages(result.DriftedPages)) return result, errors.New("remote managed wiki content drifted; rerun with --force to overwrite")
} }
summary := p.EffectiveConfig().TopData.Wiki.DeployEditSummary summary := p.EffectiveConfig().TopData.Wiki.DeployEditSummary
@@ -240,8 +212,6 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
} }
orderedPlans := orderNodeBBDeployPlans(plans) 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)) 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 { for i, plan := range orderedPlans {
if shouldReportNodeBBDeployActionProgress(i, len(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)) progress(fmt.Sprintf("Executing NodeBB wiki action %d/%d: %s %s", i+1, len(orderedPlans), plan.Action, plan.Page.PageID))
@@ -267,63 +237,24 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
entry.TID = created.TID entry.TID = created.TID
entry.PID = created.PID entry.PID = created.PID
nextManifest.Pages[plan.Page.PageID] = entry nextManifest.Pages[plan.Page.PageID] = entry
if err := recordRemoteHash(nextManifest, plan.Page.PageID, created.PID, client); err != nil {
return result, err
}
case "update": case "update":
if err := client.updatePost(plan.Entry.TID, plan.Entry.PID, plan.Content, summary); err != nil { if err := client.updatePost(plan.Entry.TID, plan.Entry.PID, plan.Content, summary); err != nil {
return result, fmt.Errorf("deploy wiki page %q: update NodeBB post %d: %w", plan.Page.PageID, plan.Entry.PID, err) return result, fmt.Errorf("deploy wiki page %q: update NodeBB post %d: %w", plan.Page.PageID, plan.Entry.PID, err)
} }
if err := recordRemoteHash(nextManifest, plan.Page.PageID, plan.Entry.PID, client); err != nil {
return result, err
}
case "archive": 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 { 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) return result, fmt.Errorf("deploy wiki page %q: archive NodeBB post %d: %w", plan.Page.PageID, plan.Entry.PID, err)
} }
case "purge": case "purge":
if err := client.purgeWikiPage(plan.Entry.TID); err != nil { if err := client.purgeTopic(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) 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": case "rename":
if err := client.renameWikiPage(plan.Entry.TID, plan.Entry.CID, plan.Title); err != nil { 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) 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 { if err := saveDeployManifest(manifestPath, nextManifest); err != nil {
return result, err return result, err
} }
@@ -555,7 +486,7 @@ func isDir(path string) bool {
return err == nil && info.IsDir() 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 { if progress == nil {
progress = func(string) {} progress = func(string) {}
} }
@@ -563,23 +494,17 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife
next := wikiDeployManifest{Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{}} next := wikiDeployManifest{Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{}}
remotePagesByCID := map[int][]nodeBBWikiPage{} remotePagesByCID := map[int][]nodeBBWikiPage{}
var plans []wikiDeployPlan var plans []wikiDeployPlan
var result DeployResult var result deployResult
if opts.ResetManagedNamespaces { if opts.ResetManagedNamespaces {
if !opts.AllowCreates && len(pageIDs) > 0 { 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") return nil, result, next, errors.New("wiki managed namespace reset requires --create so current generated pages can be recreated")
} }
resetPlans, purged, unrecognized, err := planManagedNamespaceReset(opts, manifest, client, progress) resetPlans, purged, err := planManagedNamespaceReset(opts, client, progress)
if err != nil { if err != nil {
return nil, result, next, err return nil, result, next, err
} }
plans = append(plans, resetPlans...) plans = append(plans, resetPlans...)
result.Purged += purged 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{}} manifest = wikiDeployManifest{Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{}}
for _, cid := range opts.CategoryIDs { for _, cid := range opts.CategoryIDs {
if cid != 0 { if cid != 0 {
@@ -694,9 +619,8 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife
next.Pages[pageID] = entry next.Pages[pageID] = entry
} }
remoteHash := computeManagedHash(remote.Content) remoteHash := computeManagedHash(remote.Content)
if remoteContentDrifted(manifest.Pages[pageID], remote, remoteHash) { if manifest.Pages[pageID].Hash != "" && remoteHash != manifest.Pages[pageID].Hash {
result.Drifted++ result.Drifted++
result.DriftedPages = append(result.DriftedPages, pageID)
if !opts.Force { if !opts.Force {
continue continue
} }
@@ -709,7 +633,7 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife
result.Updated++ result.Updated++
entry.SourceContentSynced = true entry.SourceContentSynced = true
next.Pages[pageID] = entry next.Pages[pageID] = entry
plans = append(plans, wikiDeployPlan{Page: page, Entry: entry, Action: "update", Content: merged}) plans = append(plans, wikiDeployPlan{Page: page, Entry: entry, Action: "update", Content: merged, RemoteHash: remoteHash})
} }
for pageID := range manifest.Pages { for pageID := range manifest.Pages {
if _, ok := pages[pageID]; !ok { if _, ok := pages[pageID]; !ok {
@@ -758,11 +682,7 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife
return plans, result, next, nil return plans, result, next, nil
} }
// planManagedNamespaceReset queues a purge for every topic in each managed func planManagedNamespaceReset(opts DeployWikiOptions, client *nodeBBClient, progress func(string)) ([]wikiDeployPlan, int, error) {
// 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) namespaces := slices.Clone(opts.Namespaces)
if len(namespaces) == 0 { if len(namespaces) == 0 {
for namespace := range opts.CategoryIDs { for namespace := range opts.CategoryIDs {
@@ -771,15 +691,7 @@ func planManagedNamespaceReset(opts DeployWikiOptions, manifest wikiDeployManife
} }
slices.Sort(namespaces) slices.Sort(namespaces)
manifestTIDs := map[int]struct{}{}
for _, entry := range manifest.Pages {
if entry.TID != 0 {
manifestTIDs[entry.TID] = struct{}{}
}
}
seenTIDs := map[int]struct{}{} seenTIDs := map[int]struct{}{}
unrecognized := 0
var plans []wikiDeployPlan var plans []wikiDeployPlan
for _, namespace := range namespaces { for _, namespace := range namespaces {
cid := opts.CategoryIDs[namespace] cid := opts.CategoryIDs[namespace]
@@ -789,7 +701,7 @@ func planManagedNamespaceReset(opts DeployWikiOptions, manifest wikiDeployManife
progress(fmt.Sprintf("Listing NodeBB wiki namespace category %d for managed reset", cid)) progress(fmt.Sprintf("Listing NodeBB wiki namespace category %d for managed reset", cid))
remotePages, err := client.listNamespacePages(cid) remotePages, err := client.listNamespacePages(cid)
if err != nil { if err != nil {
return nil, 0, 0, err return nil, 0, err
} }
slices.SortFunc(remotePages, func(a, b nodeBBWikiPage) int { slices.SortFunc(remotePages, func(a, b nodeBBWikiPage) int {
return a.TID - b.TID return a.TID - b.TID
@@ -802,25 +714,19 @@ func planManagedNamespaceReset(opts DeployWikiOptions, manifest wikiDeployManife
continue continue
} }
seenTIDs[remotePage.TID] = struct{}{} seenTIDs[remotePage.TID] = struct{}{}
_, known := manifestTIDs[remotePage.TID]
if !known {
unrecognized++
}
title := strings.TrimSpace(remotePage.Title) title := strings.TrimSpace(remotePage.Title)
if title == "" { if title == "" {
title = strings.TrimSpace(remotePage.TitleLeaf) title = strings.TrimSpace(remotePage.TitleLeaf)
} }
pageID := fmt.Sprintf("%s:reset-topic-%d", namespace, remotePage.TID) pageID := fmt.Sprintf("%s:reset-topic-%d", namespace, remotePage.TID)
plans = append(plans, wikiDeployPlan{ plans = append(plans, wikiDeployPlan{
Page: wikiDeployPage{PageID: pageID, Title: title, Namespace: namespace}, Page: wikiDeployPage{PageID: pageID, Title: title, Namespace: namespace},
Entry: wikiDeployManifestPage{TID: remotePage.TID, CID: cid, Namespace: namespace, Title: title}, Entry: wikiDeployManifestPage{TID: remotePage.TID, CID: cid, Namespace: namespace, Title: title},
Action: "purge", Action: "purge",
Reset: true,
Unrecognized: !known,
}) })
} }
} }
return plans, len(plans), unrecognized, nil return plans, len(plans), nil
} }
func findMappedRemoteTopic(entry wikiDeployManifestPage, client *nodeBBClient) (nodeBBPost, bool, error) { func findMappedRemoteTopic(entry wikiDeployManifestPage, client *nodeBBClient) (nodeBBPost, bool, error) {
@@ -884,9 +790,6 @@ func recoverCreateCollision(plan wikiDeployPlan, manifest wikiDeployManifest, cl
entry.Stale = false entry.Stale = false
entry.SourceContentSynced = true entry.SourceContentSynced = true
manifest.Pages[plan.Page.PageID] = entry manifest.Pages[plan.Page.PageID] = entry
if err := recordRemoteHash(manifest, plan.Page.PageID, entry.PID, client); err != nil {
return false, err
}
return true, nil return true, nil
} }
@@ -1224,49 +1127,6 @@ func saveDeployManifest(path string, manifest wikiDeployManifest) error {
return os.WriteFile(path, append(raw, '\n'), 0o644) return os.WriteFile(path, append(raw, '\n'), 0o644)
} }
// summarizeDriftedPages names the drifted pages for the operator, capped so a
// mass-drift run does not bury the rest of the CI log.
func summarizeDriftedPages(pages []string) string {
const maxNamed = 10
if len(pages) <= maxNamed {
return strings.Join(pages, ", ")
}
return fmt.Sprintf("%s and %d more", strings.Join(pages[:maxNamed], ", "), len(pages)-maxNamed)
}
// remoteContentDrifted answers the only question the drift guard exists to ask:
// did the live page change after we last wrote it? It is not "does the live
// page differ from what we just rendered" — NodeBB owns the stored body, and a
// deploy that regenerates a page always differs from what is live.
func remoteContentDrifted(entry wikiDeployManifestPage, remote nodeBBPost, remoteHash string) bool {
// A post with no sourceContent was written before the deployer stored one,
// so what comes back is NodeBB's rendered HTML and can never hash-equal
// anything we wrote. That is a page awaiting the SourceContentSynced
// repair, not a human edit.
if remote.SourceContent == "" {
return false
}
if entry.RemoteHash != "" {
return remoteHash != entry.RemoteHash
}
// Manifest written before RemoteHash existed: fall back to the old
// comparison rather than declaring every tracked page drifted at once.
return entry.Hash != "" && remoteHash != entry.Hash
}
// recordRemoteHash reads a page back after writing it and records what NodeBB
// actually stored, so the next run compares remote against remote.
func recordRemoteHash(manifest wikiDeployManifest, pageID string, pid int, client *nodeBBClient) error {
remote, err := client.getPost(pid)
if err != nil {
return fmt.Errorf("deploy wiki page %q: read back NodeBB post %d: %w", pageID, pid, err)
}
entry := manifest.Pages[pageID]
entry.RemoteHash = computeManagedHash(remote.Content)
manifest.Pages[pageID] = entry
return nil
}
func computeManagedHash(content string) string { func computeManagedHash(content string) string {
managed, ok := extractManagedRegion(content) managed, ok := extractManagedRegion(content)
if !ok { if !ok {
@@ -1751,36 +1611,16 @@ func (c *nodeBBClient) renameWikiPage(tid, cid int, title string) error {
return c.request("PUT", "/api/v3/plugins/westgate-wiki/page/move", body, nil) return c.request("PUT", "/api/v3/plugins/westgate-wiki/page/move", body, nil)
} }
// purgeWikiPage deletes a wiki topic through the wiki plugin's own page func (c *nodeBBClient) purgeTopic(tid int) error {
// 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 { if tid == 0 {
return fmt.Errorf("NodeBB wiki page purge requires topic id") return fmt.Errorf("NodeBB topic purge requires topic id")
} }
body := map[string]any{"tid": tid} err := c.request(http.MethodDelete, fmt.Sprintf("/api/v3/topics/%d", tid), nil, nil)
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 var httpErr nodeBBHTTPError
return errors.As(err, &httpErr) && httpErr.Status == http.StatusForbidden if errors.As(err, &httpErr) && (httpErr.Status == http.StatusNotFound || httpErr.Status == http.StatusGone) {
return nil
}
return err
} }
func (c *nodeBBClient) acquireEditLock(tid int) (nodeBBEditLock, error) { func (c *nodeBBClient) acquireEditLock(tid int) (nodeBBEditLock, error) {
+90 -528
View File
@@ -2,7 +2,6 @@ package topdata
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -36,7 +35,7 @@ func TestDeployWikiDryRunDoesNotWriteRemoteOrManifest(t *testing.T) {
} }
updateCalls := 0 updateCalls := 0
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer nodebb-token" { if got := r.Header.Get("Authorization"); got != "Bearer nodebb-token" {
t.Fatalf("unexpected authorization header %q", got) t.Fatalf("unexpected authorization header %q", got)
} }
@@ -51,7 +50,7 @@ func TestDeployWikiDryRunDoesNotWriteRemoteOrManifest(t *testing.T) {
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
} }
}) }))
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -93,7 +92,7 @@ func TestDeployWikiReportsPlanningProgressBeforeRemoteWork(t *testing.T) {
} }
progress := []string{} progress := []string{}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/3/pages": case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/3/pages":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -101,7 +100,7 @@ func TestDeployWikiReportsPlanningProgressBeforeRemoteWork(t *testing.T) {
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
} }
}) }))
defer server.Close() defer server.Close()
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ _, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -175,7 +174,7 @@ func TestDeployWikiReportsLiveExecutionProgress(t *testing.T) {
} }
progress := []string{} progress := []string{}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7": case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -195,7 +194,7 @@ func TestDeployWikiReportsLiveExecutionProgress(t *testing.T) {
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
} }
}) }))
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -229,7 +228,7 @@ func TestDeployWikiReportsLiveExecutionProgress(t *testing.T) {
func TestNodeBBNamespacePaginationStopsWhenRepeatedCursorReturnsNoNewPages(t *testing.T) { func TestNodeBBNamespacePaginationStopsWhenRepeatedCursorReturnsNoNewPages(t *testing.T) {
requests := 0 requests := 0
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++ requests++
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{
@@ -241,7 +240,7 @@ func TestNodeBBNamespacePaginationStopsWhenRepeatedCursorReturnsNoNewPages(t *te
"nextCursor": "same-cursor", "nextCursor": "same-cursor",
}, },
}) })
}) }))
defer server.Close() defer server.Close()
client := newNodeBBClient(server.URL, "nodebb-token") client := newNodeBBClient(server.URL, "nodebb-token")
@@ -259,7 +258,7 @@ func TestNodeBBNamespacePaginationStopsWhenRepeatedCursorReturnsNoNewPages(t *te
func TestNodeBBNamespacePaginationRejectsRepeatedCursorWithNewPages(t *testing.T) { func TestNodeBBNamespacePaginationRejectsRepeatedCursorWithNewPages(t *testing.T) {
requests := 0 requests := 0
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++ requests++
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
pages := []map[string]any{ pages := []map[string]any{
@@ -277,7 +276,7 @@ func TestNodeBBNamespacePaginationRejectsRepeatedCursorWithNewPages(t *testing.T
"nextCursor": "same-cursor", "nextCursor": "same-cursor",
}, },
}) })
}) }))
defer server.Close() defer server.Close()
client := newNodeBBClient(server.URL, "nodebb-token") client := newNodeBBClient(server.URL, "nodebb-token")
@@ -481,7 +480,7 @@ func TestDeployWikiDryRunReadoptsMissingMappedPost(t *testing.T) {
t.Fatalf("write deploy manifest: %v", err) t.Fatalf("write deploy manifest: %v", err)
} }
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42": case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -509,7 +508,7 @@ func TestDeployWikiDryRunReadoptsMissingMappedPost(t *testing.T) {
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
} }
}) }))
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -732,7 +731,7 @@ func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) {
} }
createCalls := 0 createCalls := 0
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer nodebb-token" { if got := r.Header.Get("Authorization"); got != "Bearer nodebb-token" {
t.Fatalf("unexpected authorization header %q", got) t.Fatalf("unexpected authorization header %q", got)
} }
@@ -741,10 +740,6 @@ func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) {
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}}) _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}})
return return
} }
if r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42" {
respondWikiPost(w, 42, 11, generated)
return
}
if r.Method != http.MethodPost || r.URL.Path != "/api/v3/topics" { if r.Method != http.MethodPost || r.URL.Path != "/api/v3/topics" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
} }
@@ -774,7 +769,7 @@ func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) {
}, },
}, },
}) })
}) }))
defer server.Close() defer server.Close()
manifestPath := filepath.Join(root, "deploy-manifest.json") manifestPath := filepath.Join(root, "deploy-manifest.json")
@@ -800,9 +795,6 @@ func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) {
if !entry.SourceContentSynced { if !entry.SourceContentSynced {
t.Fatalf("expected created manifest entry to record sourceContent sync") t.Fatalf("expected created manifest entry to record sourceContent sync")
} }
if entry.RemoteHash != computeManagedHash(generated) {
t.Fatalf("expected created manifest entry to record the read-back remote hash, got %#v", entry)
}
} }
func TestParseNodeBBPostPrefersSourceContentForWikiHTML(t *testing.T) { func TestParseNodeBBPostPrefersSourceContentForWikiHTML(t *testing.T) {
@@ -856,7 +848,7 @@ func TestDeployWikiRepairsManifestedPageMissingSourceContentSync(t *testing.T) {
} }
updateCalls := 0 updateCalls := 0
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42": case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -884,7 +876,7 @@ func TestDeployWikiRepairsManifestedPageMissingSourceContentSync(t *testing.T) {
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
} }
}) }))
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -959,16 +951,12 @@ func TestDeployWikiCreatesNodeBBTopicWithoutFallbackForDefaultThreeCharacterTitl
t.Fatalf("write source page: %v", err) t.Fatalf("write source page: %v", err)
} }
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/5/pages" { if r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/5/pages" {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}}) _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}})
return return
} }
if r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42" {
respondWikiPost(w, 42, 11, generated)
return
}
if r.Method != http.MethodPost || r.URL.Path != "/api/v3/topics" { if r.Method != http.MethodPost || r.URL.Path != "/api/v3/topics" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
} }
@@ -995,7 +983,7 @@ func TestDeployWikiCreatesNodeBBTopicWithoutFallbackForDefaultThreeCharacterTitl
}, },
}, },
}) })
}) }))
defer server.Close() defer server.Close()
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ _, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1022,16 +1010,12 @@ func TestDeployWikiCreatesNodeBBTopicWithFallbackForTitleShorterThanConfiguredMi
t.Fatalf("write source page: %v", err) t.Fatalf("write source page: %v", err)
} }
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/5/pages" { if r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/5/pages" {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}}) _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}})
return return
} }
if r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42" {
respondWikiPost(w, 42, 11, generated)
return
}
if r.Method != http.MethodPost || r.URL.Path != "/api/v3/topics" { if r.Method != http.MethodPost || r.URL.Path != "/api/v3/topics" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
} }
@@ -1054,7 +1038,7 @@ func TestDeployWikiCreatesNodeBBTopicWithFallbackForTitleShorterThanConfiguredMi
}, },
}, },
}) })
}) }))
defer server.Close() defer server.Close()
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ _, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1096,7 +1080,7 @@ func TestDeployWikiRenamesExistingPrefixedTopicWhenTitleIsLongEnough(t *testing.
} }
renameCalls := 0 renameCalls := 0
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7": case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -1125,7 +1109,7 @@ func TestDeployWikiRenamesExistingPrefixedTopicWhenTitleIsLongEnough(t *testing.
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
} }
}) }))
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1182,7 +1166,7 @@ func TestDeployWikiRenamesManagedTopicWhenGeneratedTitleChanges(t *testing.T) {
} }
renameCalls := 0 renameCalls := 0
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7": case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -1211,7 +1195,7 @@ func TestDeployWikiRenamesManagedTopicWhenGeneratedTitleChanges(t *testing.T) {
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
} }
}) }))
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1274,9 +1258,9 @@ func TestDeployWikiDoesNotRenameHeadinglessPageToPageIDFallback(t *testing.T) {
t.Fatalf("write deploy manifest: %v", err) t.Fatalf("write deploy manifest: %v", err)
} }
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("headingless unchanged page must not call NodeBB, got %s %s", r.Method, r.URL.String()) t.Fatalf("headingless unchanged page must not call NodeBB, got %s %s", r.Method, r.URL.String())
}) }))
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1336,7 +1320,7 @@ func TestDeployWikiRenamesBrokenHeadinglessFallbackTitleBackToPageIndexTitle(t *
} }
renameCalls := 0 renameCalls := 0
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7": case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -1361,7 +1345,7 @@ func TestDeployWikiRenamesBrokenHeadinglessFallbackTitleBackToPageIndexTitle(t *
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
} }
}) }))
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1412,7 +1396,7 @@ func TestDeployWikiAdoptsExistingNodeBBPageWhenManifestIsMissingWithoutCreate(t
createCalls := 0 createCalls := 0
var updated string var updated string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/9/pages": case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/9/pages":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -1458,7 +1442,7 @@ func TestDeployWikiAdoptsExistingNodeBBPageWhenManifestIsMissingWithoutCreate(t
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
} }
}) }))
defer server.Close() defer server.Close()
manifestPath := filepath.Join(root, "deploy-manifest.json") manifestPath := filepath.Join(root, "deploy-manifest.json")
@@ -1525,7 +1509,7 @@ func TestDeployWikiMergesHTMLManagedAndManualRegions(t *testing.T) {
} }
var updated string var updated string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42": case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -1549,7 +1533,7 @@ func TestDeployWikiMergesHTMLManagedAndManualRegions(t *testing.T) {
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
} }
}) }))
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1605,7 +1589,7 @@ func TestDeployWikiUpdateAcquiresWestgateWikiEditLock(t *testing.T) {
lockAcquired := false lockAcquired := false
var updateToken string var updateToken string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42": case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -1637,7 +1621,7 @@ func TestDeployWikiUpdateAcquiresWestgateWikiEditLock(t *testing.T) {
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
} }
}) }))
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1684,7 +1668,7 @@ func TestDeployWikiCreateCollisionAdoptsExistingNodeBBPage(t *testing.T) {
createCalls := 0 createCalls := 0
var updated string var updated string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/9/pages" && r.URL.Query().Get("q") == "": 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") w.Header().Set("Content-Type", "application/json")
@@ -1735,7 +1719,7 @@ func TestDeployWikiCreateCollisionAdoptsExistingNodeBBPage(t *testing.T) {
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
} }
}) }))
defer server.Close() defer server.Close()
manifestPath := filepath.Join(root, "deploy-manifest.json") manifestPath := filepath.Join(root, "deploy-manifest.json")
@@ -1787,7 +1771,7 @@ func TestDeployWikiCreateCollisionSearchesCanonicalTitleSegment(t *testing.T) {
createCalls := 0 createCalls := 0
var updated string var updated string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/58/pages" && r.URL.Query().Get("q") == "": 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") w.Header().Set("Content-Type", "application/json")
@@ -1844,7 +1828,7 @@ func TestDeployWikiCreateCollisionSearchesCanonicalTitleSegment(t *testing.T) {
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
} }
}) }))
defer server.Close() defer server.Close()
manifestPath := filepath.Join(root, "deploy-manifest.json") manifestPath := filepath.Join(root, "deploy-manifest.json")
@@ -1884,7 +1868,7 @@ func TestDeployWikiReportsAndArchivesStalePages(t *testing.T) {
} }
var archived string var archived string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/edit-lock" { if r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/edit-lock" {
respondWikiEditLock(t, w, r, 7, "archive-lock") respondWikiEditLock(t, w, r, 7, "archive-lock")
return return
@@ -1905,7 +1889,7 @@ func TestDeployWikiReportsAndArchivesStalePages(t *testing.T) {
archived = req.Content archived = req.Content
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 42, "tid": 7}}) _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 42, "tid": 7}})
}) }))
defer server.Close() defer server.Close()
report, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ report, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1961,9 +1945,9 @@ func TestDeployWikiDryRunPlansTrackedStalePagePurge(t *testing.T) {
}); err != nil { }); err != nil {
t.Fatalf("write deploy manifest: %v", err) t.Fatalf("write deploy manifest: %v", err)
} }
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("dry-run purge must not call NodeBB, got %s %s", r.Method, r.URL.Path) t.Fatalf("dry-run purge must not call NodeBB, got %s %s", r.Method, r.URL.Path)
}) }))
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2000,9 +1984,9 @@ func TestDeployWikiPurgeRefusesStaleManifestEntryWithoutTopicID(t *testing.T) {
}); err != nil { }); err != nil {
t.Fatalf("write deploy manifest: %v", err) t.Fatalf("write deploy manifest: %v", err)
} }
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(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) t.Fatalf("missing-topic-id purge must not call NodeBB, got %s %s", r.Method, r.URL.Path)
}) }))
defer server.Close() defer server.Close()
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ _, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2033,12 +2017,15 @@ func TestDeployWikiPurgesTrackedStaleGeneratedTopic(t *testing.T) {
}); err != nil { }); err != nil {
t.Fatalf("write deploy manifest: %v", err) t.Fatalf("write deploy manifest: %v", err)
} }
var calls []string purgeCalls := 0
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !answerWikiPagePurge(t, w, r, &calls) { if r.Method != http.MethodDelete || r.URL.Path != "/api/v3/topics/7" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) 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() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2054,8 +2041,8 @@ func TestDeployWikiPurgesTrackedStaleGeneratedTopic(t *testing.T) {
if result.Stale != 1 || result.Purged != 1 { if result.Stale != 1 || result.Purged != 1 {
t.Fatalf("expected one stale purge, got %#v", result) t.Fatalf("expected one stale purge, got %#v", result)
} }
if strings.Join(calls, ",") != "tombstone:7,hard-purge:7" { if purgeCalls != 1 {
t.Fatalf("expected the wiki plugin page actions to purge topic 7, got %#v", calls) t.Fatalf("expected one NodeBB topic purge call, got %d", purgeCalls)
} }
if _, ok := loadDeployManifest(manifestPath).Pages["skills:retired"]; ok { if _, ok := loadDeployManifest(manifestPath).Pages["skills:retired"]; ok {
t.Fatalf("expected purged stale manifest entry to be removed") t.Fatalf("expected purged stale manifest entry to be removed")
@@ -2088,19 +2075,18 @@ func TestDeployWikiPurgesTrackedStaleTopicsBeforeCreatingReplacementPages(t *tes
} }
var calls []string var calls []string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if answerWikiPagePurge(t, w, r, &calls) {
return
}
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/3/pages": case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/3/pages":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}}) _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}})
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/99": case r.Method == http.MethodDelete && r.URL.Path == "/api/v3/topics/7":
respondWikiPost(w, 99, 11, generated) 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": case r.Method == http.MethodPost && r.URL.Path == "/api/v3/topics":
calls = append(calls, "create") calls = append(calls, "create")
if strings.Join(calls, ",") != "tombstone:7,hard-purge:7,create" { if len(calls) != 2 || calls[0] != "purge" {
t.Fatalf("expected stale purge before create, got calls %#v", calls) t.Fatalf("expected stale purge before create, got calls %#v", calls)
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -2116,7 +2102,7 @@ func TestDeployWikiPurgesTrackedStaleTopicsBeforeCreatingReplacementPages(t *tes
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
} }
}) }))
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2134,7 +2120,7 @@ func TestDeployWikiPurgesTrackedStaleTopicsBeforeCreatingReplacementPages(t *tes
if result.Created != 1 || result.Purged != 1 { if result.Created != 1 || result.Purged != 1 {
t.Fatalf("expected one create and one purge, got %#v", result) t.Fatalf("expected one create and one purge, got %#v", result)
} }
if strings.Join(calls, ",") != "tombstone:7,hard-purge:7,create" { if strings.Join(calls, ",") != "purge,create" {
t.Fatalf("expected purge before create, got %#v", calls) t.Fatalf("expected purge before create, got %#v", calls)
} }
} }
@@ -2164,12 +2150,8 @@ func TestDeployWikiResetManagedNamespacesPurgesRemotePagesBeforeCreatingFreshMan
t.Fatalf("write deploy manifest: %v", err) t.Fatalf("write deploy manifest: %v", err)
} }
const resetCalls = "tombstone:7,hard-purge:7,tombstone:8,hard-purge:8"
var calls []string var calls []string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if answerWikiPagePurge(t, w, r, &calls) {
return
}
switch { switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/3/pages": case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/3/pages":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -2182,11 +2164,17 @@ func TestDeployWikiResetManagedNamespacesPurgesRemotePagesBeforeCreatingFreshMan
"hasMore": false, "hasMore": false,
}, },
}) })
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/99": case r.Method == http.MethodDelete && r.URL.Path == "/api/v3/topics/7":
respondWikiPost(w, 99, 11, generated) 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": case r.Method == http.MethodPost && r.URL.Path == "/api/v3/topics":
calls = append(calls, "create") calls = append(calls, "create")
if strings.Join(calls, ",") != resetCalls+",create" { if strings.Join(calls, ",") != "purge:7,purge:8,create" {
t.Fatalf("expected namespace reset purges before create, got %#v", calls) t.Fatalf("expected namespace reset purges before create, got %#v", calls)
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -2202,7 +2190,7 @@ func TestDeployWikiResetManagedNamespacesPurgesRemotePagesBeforeCreatingFreshMan
default: default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
} }
}) }))
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2217,21 +2205,10 @@ func TestDeployWikiResetManagedNamespacesPurgesRemotePagesBeforeCreatingFreshMan
if err != nil { if err != nil {
t.Fatalf("DeployWikiWithOptions reset-managed-namespaces failed: %v", err) t.Fatalf("DeployWikiWithOptions reset-managed-namespaces failed: %v", err)
} }
if result.Created != 1 || result.Purged != 2 { if result.Created != 1 || result.Purged != 2 || result.Stale != 0 {
t.Fatalf("expected one create and two namespace purges, got %#v", result) t.Fatalf("expected one create and two namespace purges, got %#v", result)
} }
// Callers word their destructive-policy warning around the stale count, so if strings.Join(calls, ",") != "purge:7,purge:8,create" {
// 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) t.Fatalf("expected reset purges before create, got %#v", calls)
} }
manifest := loadDeployManifest(manifestPath) manifest := loadDeployManifest(manifestPath)
@@ -2259,9 +2236,9 @@ func TestDeployWikiResetManagedNamespacesRequiresCreateForLocalPages(t *testing.
t.Fatalf("write source page: %v", err) t.Fatalf("write source page: %v", err)
} }
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(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()) t.Fatalf("reset without --create must fail before remote calls, got %s %s", r.Method, r.URL.String())
}) }))
defer server.Close() defer server.Close()
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ _, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2293,21 +2270,21 @@ func TestDeployWikiPurgeTreatsAlreadyMissingTrackedTopicAsSuccess(t *testing.T)
t.Fatalf("write deploy manifest: %v", err) t.Fatalf("write deploy manifest: %v", err)
} }
seen := map[string]int{} seen := map[string]int{}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
call, ok := wikiPagePurgeCall(t, r) if r.Method != http.MethodDelete {
if !ok {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
} }
seen[call]++ seen[r.URL.Path]++
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
// Topic 7 is already gone, so the plugin cannot find the page to switch r.URL.Path {
// tombstone; topic 8 still exists and purges normally. case "/api/v3/topics/7":
if call == "tombstone:7" {
http.Error(w, `{"status":{"message":"topic not found"}}`, http.StatusNotFound) http.Error(w, `{"status":{"message":"topic not found"}}`, http.StatusNotFound)
return 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)
} }
_ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}}) }))
})
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2323,11 +2300,8 @@ func TestDeployWikiPurgeTreatsAlreadyMissingTrackedTopicAsSuccess(t *testing.T)
if result.Stale != 2 || result.Purged != 2 { if result.Stale != 2 || result.Purged != 2 {
t.Fatalf("expected two stale purges, got %#v", result) t.Fatalf("expected two stale purges, got %#v", result)
} }
if seen["tombstone:7"] != 1 || seen["hard-purge:7"] != 0 { if seen["/api/v3/topics/7"] != 1 || seen["/api/v3/topics/8"] != 1 {
t.Fatalf("expected the already-missing topic to stop after tombstone, got %#v", seen) t.Fatalf("expected one purge call for each tracked topic, 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) manifest := loadDeployManifest(manifestPath)
if _, ok := manifest.Pages["skills:retired"]; ok { if _, ok := manifest.Pages["skills:retired"]; ok {
@@ -2365,9 +2339,9 @@ func TestDeployWikiPurgeDoesNotTargetCurrentGeneratedPages(t *testing.T) {
}); err != nil { }); err != nil {
t.Fatalf("write deploy manifest: %v", err) t.Fatalf("write deploy manifest: %v", err)
} }
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(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) t.Fatalf("current generated page must not call NodeBB during matching-hash deploy, got %s %s", r.Method, r.URL.Path)
}) }))
defer server.Close() defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2411,415 +2385,3 @@ func respondWikiEditLock(t *testing.T, w http.ResponseWriter, r *http.Request, e
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"status": "ok", "tid": expectedTID, "token": token}}) _ = 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.
// driftScenario is one managed page whose generated text changed since the last
// deploy, so the deployer has to read the live post and rule on drift. The fake
// NodeBB serves whatever it was last told to store, which is what a real deploy
// reads back after a write.
type driftScenario struct {
sourceDir string
manifestPath string
endpoint string
generated string
stored string
updateCalls int
lastWritten string
}
// newDriftScenario writes the local page and the manifest entry. remoteStored is
// the body NodeBB hands back; hasSourceContent says whether the post carries the
// sourceContent the wiki plugin stores.
func newDriftScenario(t *testing.T, entry wikiDeployManifestPage, remoteStored string, hasSourceContent bool) (*driftScenario, *httptest.Server) {
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)
}
generated := `<!-- sow-topdata-wiki:page=skills:athletics -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Athletics</h1>
<p>Generated athletics page</p>
<!-- sow-topdata-wiki:managed:end -->
`
if err := os.WriteFile(filepath.Join(sourceDir, "skills", "athletics.html"), []byte(generated), 0644); err != nil {
t.Fatalf("write source page: %v", err)
}
manifestPath := filepath.Join(root, "deploy-manifest.json")
entry.TID, entry.PID, entry.CID = 7, 42, 3
entry.SourceContentSynced = true
if err := saveDeployManifest(manifestPath, wikiDeployManifest{
Version: "nodebb-v1",
Pages: map[string]wikiDeployManifestPage{"skills:athletics": entry},
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
scenario := &driftScenario{sourceDir: sourceDir, manifestPath: manifestPath, generated: generated, stored: remoteStored}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42":
post := map[string]any{"pid": 42, "tid": 7, "content": scenario.stored}
if hasSourceContent {
post["sourceContent"] = scenario.stored
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": post})
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/edit-lock":
respondWikiEditLock(t, w, r, 7, "drift-lock")
case r.Method == http.MethodPut && r.URL.Path == "/api/v3/posts/42":
var req struct {
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode update request: %v", err)
}
scenario.updateCalls++
scenario.lastWritten = req.Content
// NodeBB owns the stored body from here on, and the read-back that
// follows has to see it.
scenario.stored = req.Content
hasSourceContent = true
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 42, "tid": 7}})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
})
return scenario, server
}
func (s *driftScenario) deploy(t *testing.T, force bool) (DeployResult, error) {
t.Helper()
return DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
SourceDir: s.sourceDir,
Endpoint: s.endpoint,
Token: "nodebb-token",
ManifestPath: s.manifestPath,
Force: force,
}, nil)
}
func TestDeployWikiDoesNotTreatNodeBBNormalizationAsDrift(t *testing.T) {
// NodeBB stores its own copy of the page, so what it hands back never has
// to be byte-equal to the text we rendered. Only a change since our last
// write is drift.
stored := `<!-- sow-topdata-wiki:page=skills:athletics -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Athletics</h1><p>Old generated text</p>
<!-- sow-topdata-wiki:managed:end -->
`
scenario, server := newDriftScenario(t, wikiDeployManifestPage{
Hash: "sha256:whatever-we-rendered-last-time",
RemoteHash: computeManagedHash(stored),
}, stored, true)
defer server.Close()
scenario.endpoint = server.URL
result, err := scenario.deploy(t, false)
if err != nil {
t.Fatalf("expected normalized remote copy to deploy cleanly, got: %v", err)
}
if result.Drifted != 0 || result.Updated != 1 || scenario.updateCalls != 1 {
t.Fatalf("expected a clean update, got %#v (updateCalls=%d)", result, scenario.updateCalls)
}
entry := loadDeployManifest(scenario.manifestPath).Pages["skills:athletics"]
if entry.RemoteHash != computeManagedHash(scenario.lastWritten) {
t.Fatalf("expected manifest to record the read-back remote hash, got %#v", entry)
}
}
func TestDeployWikiReportsEditedPageAsDriftedAndNamesIt(t *testing.T) {
stored := `<!-- sow-topdata-wiki:page=skills:athletics -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Athletics</h1>
<p>A person rewrote this by hand.</p>
<!-- sow-topdata-wiki:managed:end -->
`
scenario, server := newDriftScenario(t, wikiDeployManifestPage{
Hash: "sha256:whatever-we-rendered-last-time",
RemoteHash: "sha256:what-we-wrote-last-time",
}, stored, true)
defer server.Close()
scenario.endpoint = server.URL
result, err := scenario.deploy(t, false)
if err == nil {
t.Fatalf("expected a hand-edited page to block the deploy, got %#v", result)
}
if !strings.Contains(err.Error(), "skills:athletics") {
t.Fatalf("expected the drift error to name the page, got: %v", err)
}
if result.Drifted != 1 || scenario.updateCalls != 0 {
t.Fatalf("expected one drifted page and no write, got %#v (updateCalls=%d)", result, scenario.updateCalls)
}
}
func TestDeployWikiForceOverwritesDriftedPageAndRerecordsRemoteHash(t *testing.T) {
stored := `<!-- sow-topdata-wiki:page=skills:athletics -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Athletics</h1>
<p>A person rewrote this by hand.</p>
<!-- sow-topdata-wiki:managed:end -->
`
scenario, server := newDriftScenario(t, wikiDeployManifestPage{
Hash: "sha256:whatever-we-rendered-last-time",
RemoteHash: "sha256:what-we-wrote-last-time",
}, stored, true)
defer server.Close()
scenario.endpoint = server.URL
result, err := scenario.deploy(t, true)
if err != nil {
t.Fatalf("expected --force to overwrite the drifted page, got: %v", err)
}
if result.Drifted != 1 || result.Updated != 1 || scenario.updateCalls != 1 {
t.Fatalf("expected the drifted page overwritten once, got %#v (updateCalls=%d)", result, scenario.updateCalls)
}
entry := loadDeployManifest(scenario.manifestPath).Pages["skills:athletics"]
if entry.RemoteHash != computeManagedHash(scenario.lastWritten) {
t.Fatalf("expected the forced write to re-record the remote hash, got %#v", entry)
}
}
func TestDeployWikiTreatsPostWithoutSourceContentAsUnsyncedNotDrifted(t *testing.T) {
// A post written before the deployer stored sourceContent reads back as
// NodeBB's rendered HTML, which can never match anything we wrote. That is
// a page waiting for the sourceContent repair, not a hand edit.
rendered := "&lt;h1&gt;Athletics&lt;/h1&gt;&lt;p&gt;Old generated text&lt;/p&gt;"
scenario, server := newDriftScenario(t, wikiDeployManifestPage{
Hash: "sha256:whatever-we-rendered-last-time",
}, rendered, false)
defer server.Close()
scenario.endpoint = server.URL
result, err := scenario.deploy(t, false)
if err != nil {
t.Fatalf("expected an unsynced page to deploy, got: %v", err)
}
if result.Drifted != 0 || result.Updated != 1 || scenario.updateCalls != 1 {
t.Fatalf("expected one repair update and no drift, got %#v (updateCalls=%d)", result, scenario.updateCalls)
}
}
func TestDeployWikiFallsBackToLocalHashForManifestWithoutRemoteHash(t *testing.T) {
// Manifest written before remote_hash existed: the remote still matches the
// recorded local hash, so nothing drifted, and this run records the remote
// hash for the next one.
stored := `<!-- sow-topdata-wiki:page=skills:athletics -->
<!-- sow-topdata-wiki:managed:start hash="sha256:old" -->
<h1>Athletics</h1>
<p>Old generated text</p>
<!-- sow-topdata-wiki:managed:end -->
`
scenario, server := newDriftScenario(t, wikiDeployManifestPage{
Hash: computeManagedHash(stored),
}, stored, true)
defer server.Close()
scenario.endpoint = server.URL
result, err := scenario.deploy(t, false)
if err != nil {
t.Fatalf("expected an old-format manifest to deploy, got: %v", err)
}
if result.Drifted != 0 || result.Updated != 1 {
t.Fatalf("expected a clean update, got %#v", result)
}
entry := loadDeployManifest(scenario.manifestPath).Pages["skills:athletics"]
if entry.RemoteHash == "" {
t.Fatalf("expected the deploy to record a remote hash, got %#v", entry)
}
}
// respondWikiPost answers a post read the way the wiki plugin does: the stored
// source HTML in sourceContent, alongside the body NodeBB renders from it.
func respondWikiPost(w http.ResponseWriter, pid, tid int, content string) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{
"pid": pid, "tid": tid, "content": content, "sourceContent": content,
}})
}
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 "<action>:<tid>" 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)
}
}