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
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:
build-binaries:
runs-on: nix-docker
-6
View File
@@ -7,12 +7,6 @@ on:
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:
ci:
runs-on: nix-docker
-6
View File
@@ -16,12 +16,6 @@ on:
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:
sync:
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
`</dev/null`. Same trap on `tea issues create --description` and
`tea pr create`.
- **Apply / remove labels**: `tea issues edit <number> --add-labels "Kind/Bug"`
(and `--remove-labels`). This handles org-level labels (`Kind/*`,
`Priority/*`, `Reviewed/*`, `Status/*`) from tea 0.15 onwards. On 0.14 it did
not: name resolution searched only this repo's own label set, so an org label
matched nothing and the command exited 0, printed the issue, and changed
nothing. Upstream fixed it in v0.15 (`modules/task/labels.go` also queries
`ListOrgLabels`). Note `tea labels` lists repo labels only and will not show
you the org set — `tea api orgs/ShadowsOverWestgate/labels` does.
- **`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.
- **Apply / remove labels**: `tea api --method PATCH` on the issue, or
`tea api repos/ShadowsOverWestgate/sow-tools/issues/<number>/labels` endpoints.
`tea issues edit --add-labels` works too, org-level labels (`Kind/*`,
`Priority/*`) included, but a read-back straight afterwards can still show
the old set — `tea issues ls` has returned stale labels for several seconds
after an edit that succeeded. Never conclude an edit failed from a single
immediate list; re-read after a pause, or use the `labels` endpoints above,
whose response *is* the resulting label set and needs no second call.
- **Close**: `tea issues close <number>`
`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 pr create --title "..." --description "..." --labels "Kind/Feature" </dev/null
# add a label to something that already exists (needs tea >= 0.15 for org labels)
tea issues edit <n> --add-labels "Kind/Bug,Priority/High"
# read the result back — exit 0 is not evidence the label applied
tea api "repos/ShadowsOverWestgate/<repo>/issues/<n>"
# add a label to something that already exists
tea api --method POST "repos/ShadowsOverWestgate/<repo>/issues/<n>/labels" \
--data '{"labels":["Kind/Bug","Priority/High"]}'
```
`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)
| 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)
}
func (c *topdataConsole) emitWikiDeployResult(result topdata.DeployResult) {
func (c *topdataConsole) emitWikiDeployResult(localPages, created, updated, skipped, stale, archived, purged, drifted int, manifest string) {
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", 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))
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))
}
func (c *topdataConsole) relPath(path string) string {
@@ -1588,7 +1579,7 @@ func runDeployWiki(ctx context) error {
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
}
+1 -58
View File
@@ -10,7 +10,6 @@ import (
"time"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/pipeline"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/topdata"
)
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.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",
})
console.emitWikiDeployResult(10, 1, 2, 3, 4, 5, 6, 0, "/workspace/project/build/wiki/deploy-manifest.json")
output := stdout.String()
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) {
_, err := parseDeployWikiArgs("deploy-wiki", []string{"--help"})
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",
"--create allow missing pages to be created",
"--force update unchanged pages",
"--reset-managed-namespaces delete every page in the managed categories, including",
" pages this deployer never wrote, then recreate from source",
"--reset-managed-namespaces reset managed namespace state",
},
Aliases: []CommandAlias{{Name: "deploy-wiki"}},
},
+29 -189
View File
@@ -8,7 +8,6 @@ import (
"fmt"
"html"
"io"
"maps"
"net/http"
"net/url"
"os"
@@ -53,7 +52,7 @@ type DeployWikiOptions struct {
TitlePrefixMinLength int
}
type DeployResult struct {
type deployResult struct {
LocalPages int
Created int
Updated int
@@ -62,24 +61,8 @@ type DeployResult struct {
Purged int
Skipped int
Drifted int
// DriftedPages names the pages counted in Drifted, in plan order, so an
// operator can look at them before deciding whether --force is safe.
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 {
@@ -98,12 +81,6 @@ type wikiDeployManifest struct {
type wikiDeployManifestPage struct {
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"`
ArchivedHash string `json:"archived_hash,omitempty"`
Title string `json:"title,omitempty"`
@@ -121,13 +98,8 @@ type wikiDeployPlan struct {
Entry wikiDeployManifestPage
Action string
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 {
@@ -144,7 +116,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) {}
}
@@ -160,19 +132,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
@@ -182,7 +154,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)
@@ -192,7 +164,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
@@ -211,7 +183,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)
@@ -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)))
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
@@ -231,7 +203,7 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
return result, nil
}
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
@@ -240,8 +212,6 @@ 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))
@@ -267,63 +237,24 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
entry.TID = created.TID
entry.PID = created.PID
nextManifest.Pages[plan.Page.PageID] = entry
if err := recordRemoteHash(nextManifest, plan.Page.PageID, created.PID, client); err != nil {
return result, err
}
case "update":
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)
}
if err := recordRemoteHash(nextManifest, plan.Page.PageID, plan.Entry.PID, client); err != nil {
return result, 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.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
}
if err := client.purgeTopic(plan.Entry.TID); err != nil {
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
}
@@ -555,7 +486,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) {}
}
@@ -563,23 +494,17 @@ 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, unrecognized, err := planManagedNamespaceReset(opts, manifest, client, progress)
resetPlans, purged, err := planManagedNamespaceReset(opts, 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 {
@@ -694,9 +619,8 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife
next.Pages[pageID] = entry
}
remoteHash := computeManagedHash(remote.Content)
if remoteContentDrifted(manifest.Pages[pageID], remote, remoteHash) {
if manifest.Pages[pageID].Hash != "" && remoteHash != manifest.Pages[pageID].Hash {
result.Drifted++
result.DriftedPages = append(result.DriftedPages, pageID)
if !opts.Force {
continue
}
@@ -709,7 +633,7 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife
result.Updated++
entry.SourceContentSynced = true
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 {
if _, ok := pages[pageID]; !ok {
@@ -758,11 +682,7 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife
return plans, result, next, nil
}
// 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) {
func planManagedNamespaceReset(opts DeployWikiOptions, client *nodeBBClient, progress func(string)) ([]wikiDeployPlan, int, error) {
namespaces := slices.Clone(opts.Namespaces)
if len(namespaces) == 0 {
for namespace := range opts.CategoryIDs {
@@ -771,15 +691,7 @@ func planManagedNamespaceReset(opts DeployWikiOptions, manifest wikiDeployManife
}
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]
@@ -789,7 +701,7 @@ func planManagedNamespaceReset(opts DeployWikiOptions, manifest wikiDeployManife
progress(fmt.Sprintf("Listing NodeBB wiki namespace category %d for managed reset", cid))
remotePages, err := client.listNamespacePages(cid)
if err != nil {
return nil, 0, 0, err
return nil, 0, err
}
slices.SortFunc(remotePages, func(a, b nodeBBWikiPage) int {
return a.TID - b.TID
@@ -802,10 +714,6 @@ func planManagedNamespaceReset(opts DeployWikiOptions, manifest wikiDeployManife
continue
}
seenTIDs[remotePage.TID] = struct{}{}
_, known := manifestTIDs[remotePage.TID]
if !known {
unrecognized++
}
title := strings.TrimSpace(remotePage.Title)
if title == "" {
title = strings.TrimSpace(remotePage.TitleLeaf)
@@ -815,12 +723,10 @@ func planManagedNamespaceReset(opts DeployWikiOptions, manifest wikiDeployManife
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), unrecognized, nil
return plans, len(plans), nil
}
func findMappedRemoteTopic(entry wikiDeployManifestPage, client *nodeBBClient) (nodeBBPost, bool, error) {
@@ -884,9 +790,6 @@ func recoverCreateCollision(plan wikiDeployPlan, manifest wikiDeployManifest, cl
entry.Stale = false
entry.SourceContentSynced = true
manifest.Pages[plan.Page.PageID] = entry
if err := recordRemoteHash(manifest, plan.Page.PageID, entry.PID, client); err != nil {
return false, err
}
return true, nil
}
@@ -1224,49 +1127,6 @@ func saveDeployManifest(path string, manifest wikiDeployManifest) error {
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 {
managed, ok := extractManagedRegion(content)
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)
}
// 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 {
func (c *nodeBBClient) purgeTopic(tid int) error {
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}
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 {
err := c.request(http.MethodDelete, fmt.Sprintf("/api/v3/topics/%d", tid), nil, nil)
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) {
+90 -528
View File
@@ -2,7 +2,6 @@ package topdata
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
@@ -36,7 +35,7 @@ func TestDeployWikiDryRunDoesNotWriteRemoteOrManifest(t *testing.T) {
}
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" {
t.Fatalf("unexpected authorization header %q", got)
}
@@ -51,7 +50,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{
@@ -93,7 +92,7 @@ func TestDeployWikiReportsPlanningProgressBeforeRemoteWork(t *testing.T) {
}
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 {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/3/pages":
w.Header().Set("Content-Type", "application/json")
@@ -101,7 +100,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{
@@ -175,7 +174,7 @@ func TestDeployWikiReportsLiveExecutionProgress(t *testing.T) {
}
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 {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7":
w.Header().Set("Content-Type", "application/json")
@@ -195,7 +194,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{
@@ -229,7 +228,7 @@ func TestDeployWikiReportsLiveExecutionProgress(t *testing.T) {
func TestNodeBBNamespacePaginationStopsWhenRepeatedCursorReturnsNoNewPages(t *testing.T) {
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++
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
@@ -241,7 +240,7 @@ func TestNodeBBNamespacePaginationStopsWhenRepeatedCursorReturnsNoNewPages(t *te
"nextCursor": "same-cursor",
},
})
})
}))
defer server.Close()
client := newNodeBBClient(server.URL, "nodebb-token")
@@ -259,7 +258,7 @@ func TestNodeBBNamespacePaginationStopsWhenRepeatedCursorReturnsNoNewPages(t *te
func TestNodeBBNamespacePaginationRejectsRepeatedCursorWithNewPages(t *testing.T) {
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++
w.Header().Set("Content-Type", "application/json")
pages := []map[string]any{
@@ -277,7 +276,7 @@ func TestNodeBBNamespacePaginationRejectsRepeatedCursorWithNewPages(t *testing.T
"nextCursor": "same-cursor",
},
})
})
}))
defer server.Close()
client := newNodeBBClient(server.URL, "nodebb-token")
@@ -481,7 +480,7 @@ func TestDeployWikiDryRunReadoptsMissingMappedPost(t *testing.T) {
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 {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42":
w.Header().Set("Content-Type", "application/json")
@@ -509,7 +508,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{
@@ -732,7 +731,7 @@ func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) {
}
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" {
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}})
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" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
@@ -774,7 +769,7 @@ func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) {
},
},
})
})
}))
defer server.Close()
manifestPath := filepath.Join(root, "deploy-manifest.json")
@@ -800,9 +795,6 @@ func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) {
if !entry.SourceContentSynced {
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) {
@@ -856,7 +848,7 @@ func TestDeployWikiRepairsManifestedPageMissingSourceContentSync(t *testing.T) {
}
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 {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42":
w.Header().Set("Content-Type", "application/json")
@@ -884,7 +876,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{
@@ -959,16 +951,12 @@ func TestDeployWikiCreatesNodeBBTopicWithoutFallbackForDefaultThreeCharacterTitl
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" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}})
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" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
@@ -995,7 +983,7 @@ func TestDeployWikiCreatesNodeBBTopicWithoutFallbackForDefaultThreeCharacterTitl
},
},
})
})
}))
defer server.Close()
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1022,16 +1010,12 @@ func TestDeployWikiCreatesNodeBBTopicWithFallbackForTitleShorterThanConfiguredMi
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" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}})
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" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
@@ -1054,7 +1038,7 @@ func TestDeployWikiCreatesNodeBBTopicWithFallbackForTitleShorterThanConfiguredMi
},
},
})
})
}))
defer server.Close()
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1096,7 +1080,7 @@ func TestDeployWikiRenamesExistingPrefixedTopicWhenTitleIsLongEnough(t *testing.
}
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 {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7":
w.Header().Set("Content-Type", "application/json")
@@ -1125,7 +1109,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{
@@ -1182,7 +1166,7 @@ func TestDeployWikiRenamesManagedTopicWhenGeneratedTitleChanges(t *testing.T) {
}
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 {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7":
w.Header().Set("Content-Type", "application/json")
@@ -1211,7 +1195,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{
@@ -1274,9 +1258,9 @@ func TestDeployWikiDoesNotRenameHeadinglessPageToPageIDFallback(t *testing.T) {
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())
})
}))
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -1336,7 +1320,7 @@ func TestDeployWikiRenamesBrokenHeadinglessFallbackTitleBackToPageIndexTitle(t *
}
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 {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/7":
w.Header().Set("Content-Type", "application/json")
@@ -1361,7 +1345,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{
@@ -1412,7 +1396,7 @@ func TestDeployWikiAdoptsExistingNodeBBPageWhenManifestIsMissingWithoutCreate(t
createCalls := 0
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 {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/9/pages":
w.Header().Set("Content-Type", "application/json")
@@ -1458,7 +1442,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")
@@ -1525,7 +1509,7 @@ func TestDeployWikiMergesHTMLManagedAndManualRegions(t *testing.T) {
}
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 {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42":
w.Header().Set("Content-Type", "application/json")
@@ -1549,7 +1533,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{
@@ -1605,7 +1589,7 @@ func TestDeployWikiUpdateAcquiresWestgateWikiEditLock(t *testing.T) {
lockAcquired := false
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 {
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42":
w.Header().Set("Content-Type", "application/json")
@@ -1637,7 +1621,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{
@@ -1684,7 +1668,7 @@ func TestDeployWikiCreateCollisionAdoptsExistingNodeBBPage(t *testing.T) {
createCalls := 0
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 {
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")
@@ -1735,7 +1719,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")
@@ -1787,7 +1771,7 @@ func TestDeployWikiCreateCollisionSearchesCanonicalTitleSegment(t *testing.T) {
createCalls := 0
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 {
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")
@@ -1844,7 +1828,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")
@@ -1884,7 +1868,7 @@ func TestDeployWikiReportsAndArchivesStalePages(t *testing.T) {
}
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" {
respondWikiEditLock(t, w, r, 7, "archive-lock")
return
@@ -1905,7 +1889,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{
@@ -1961,9 +1945,9 @@ func TestDeployWikiDryRunPlansTrackedStalePagePurge(t *testing.T) {
}); err != nil {
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)
})
}))
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2000,9 +1984,9 @@ func TestDeployWikiPurgeRefusesStaleManifestEntryWithoutTopicID(t *testing.T) {
}); err != nil {
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)
})
}))
defer server.Close()
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2033,12 +2017,15 @@ func TestDeployWikiPurgesTrackedStaleGeneratedTopic(t *testing.T) {
}); err != nil {
t.Fatalf("write deploy manifest: %v", err)
}
var calls []string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if !answerWikiPagePurge(t, w, r, &calls) {
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" {
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{
@@ -2054,8 +2041,8 @@ func TestDeployWikiPurgesTrackedStaleGeneratedTopic(t *testing.T) {
if result.Stale != 1 || result.Purged != 1 {
t.Fatalf("expected one stale purge, got %#v", result)
}
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 purgeCalls != 1 {
t.Fatalf("expected one NodeBB topic purge call, got %d", purgeCalls)
}
if _, ok := loadDeployManifest(manifestPath).Pages["skills:retired"]; ok {
t.Fatalf("expected purged stale manifest entry to be removed")
@@ -2088,19 +2075,18 @@ func TestDeployWikiPurgesTrackedStaleTopicsBeforeCreatingReplacementPages(t *tes
}
var calls []string
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if answerWikiPagePurge(t, w, r, &calls) {
return
}
server := httptest.NewServer(http.HandlerFunc(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")
_ = 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":
respondWikiPost(w, 99, 11, generated)
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 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)
}
w.Header().Set("Content-Type", "application/json")
@@ -2116,7 +2102,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{
@@ -2134,7 +2120,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, ",") != "tombstone:7,hard-purge:7,create" {
if strings.Join(calls, ",") != "purge,create" {
t.Fatalf("expected purge before create, got %#v", calls)
}
}
@@ -2164,12 +2150,8 @@ 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 := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
if answerWikiPagePurge(t, w, r, &calls) {
return
}
server := httptest.NewServer(http.HandlerFunc(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")
@@ -2182,11 +2164,17 @@ func TestDeployWikiResetManagedNamespacesPurgesRemotePagesBeforeCreatingFreshMan
"hasMore": false,
},
})
case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/99":
respondWikiPost(w, 99, 11, generated)
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, ",") != resetCalls+",create" {
if strings.Join(calls, ",") != "purge:7,purge:8,create" {
t.Fatalf("expected namespace reset purges before create, got %#v", calls)
}
w.Header().Set("Content-Type", "application/json")
@@ -2202,7 +2190,7 @@ func TestDeployWikiResetManagedNamespacesPurgesRemotePagesBeforeCreatingFreshMan
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
})
}))
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2217,21 +2205,10 @@ func TestDeployWikiResetManagedNamespacesPurgesRemotePagesBeforeCreatingFreshMan
if err != nil {
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)
}
// 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" {
if strings.Join(calls, ",") != "purge:7,purge:8,create" {
t.Fatalf("expected reset purges before create, got %#v", calls)
}
manifest := loadDeployManifest(manifestPath)
@@ -2259,9 +2236,9 @@ func TestDeployWikiResetManagedNamespacesRequiresCreateForLocalPages(t *testing.
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())
})
}))
defer server.Close()
_, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2293,21 +2270,21 @@ func TestDeployWikiPurgeTreatsAlreadyMissingTrackedTopicAsSuccess(t *testing.T)
t.Fatalf("write deploy manifest: %v", err)
}
seen := map[string]int{}
server := newFakeNodeBB(t, func(w http.ResponseWriter, r *http.Request) {
call, ok := wikiPagePurgeCall(t, r)
if !ok {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
seen[call]++
seen[r.URL.Path]++
w.Header().Set("Content-Type", "application/json")
// 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" {
switch r.URL.Path {
case "/api/v3/topics/7":
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)
}
}))
defer server.Close()
result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{
@@ -2323,11 +2300,8 @@ func TestDeployWikiPurgeTreatsAlreadyMissingTrackedTopicAsSuccess(t *testing.T)
if result.Stale != 2 || result.Purged != 2 {
t.Fatalf("expected two stale purges, got %#v", result)
}
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)
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)
}
manifest := loadDeployManifest(manifestPath)
if _, ok := manifest.Pages["skills:retired"]; ok {
@@ -2365,9 +2339,9 @@ func TestDeployWikiPurgeDoesNotTargetCurrentGeneratedPages(t *testing.T) {
}); err != nil {
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)
})
}))
defer server.Close()
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")
_ = 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)
}
}