Fix four FedWiki defects in the code #165 rewrites
- The three active and force-reduced site lists end on site_id, so two sites that tie on every other column come back in one order, and the sweep parks the same one every pass (#225). - The quota check answers "no entitlement" only when the pool has no entitlement row; any other read failure is returned, and the create ends failed with quota_unavailable (#231). The nilerr exception goes. - The JSON create checks the assembled name's length against the farm domain, as the form does. - A form create whose repeat lookup fails is refused with the address kept, instead of going on as a first create.
This commit is contained in:
@@ -56,10 +56,6 @@ linters:
|
||||
- path: '^internal/integrations/fedwiki/workflows/sync\.go$'
|
||||
linters: [gocognit]
|
||||
text: 'cognitive complexity 35 of func `\(\*Activities\)\.SyncSitesToDBActivity`' # #165
|
||||
# #231: the text also matches the function's last return, which nilerr does not report.
|
||||
- path: '^internal/integrations/fedwiki/workflows/activities\.go$'
|
||||
linters: [nilerr]
|
||||
source: 'return &CheckQuotaOutput\{$' # #231
|
||||
# #205: each rule pins the calls on one line of source text; the ladder_id text is on two lines, one in each of PostKeep and PostCancel.
|
||||
- path: '^internal/server/billing\.go$'
|
||||
linters: [forbidigo]
|
||||
|
||||
@@ -151,7 +151,7 @@ WHERE domain = $1;
|
||||
-- Active sites, most-recently-modified first — the force_reduce keep/park order.
|
||||
SELECT * FROM fedwiki.sites
|
||||
WHERE workspace_id = $1 AND status = 'active'
|
||||
ORDER BY last_modified_at DESC NULLS LAST, created_at DESC;
|
||||
ORDER BY last_modified_at DESC NULLS LAST, created_at DESC, site_id DESC;
|
||||
|
||||
-- name: ListActiveSitesByPool :many
|
||||
-- The active sites of every workspace whose active primary assignment is the
|
||||
@@ -162,7 +162,7 @@ WHERE status = 'active' AND workspace_id IN (
|
||||
SELECT pa.workspace_id FROM core.pool_assignments pa
|
||||
WHERE pa.pool_id = sqlc.arg(pool_id)::uuid AND pa.is_primary = TRUE AND pa.status = 'active'
|
||||
)
|
||||
ORDER BY last_modified_at DESC NULLS LAST, created_at DESC;
|
||||
ORDER BY last_modified_at DESC NULLS LAST, created_at DESC, site_id DESC;
|
||||
|
||||
-- name: ListForceReducedSitesByPool :many
|
||||
-- Sites a downgrade parked that are still read-only, in every workspace
|
||||
@@ -174,7 +174,7 @@ WHERE force_reduced_at IS NOT NULL AND status = 'readonly' AND workspace_id IN (
|
||||
SELECT pa.workspace_id FROM core.pool_assignments pa
|
||||
WHERE pa.pool_id = sqlc.arg(pool_id)::uuid AND pa.is_primary = TRUE AND pa.status = 'active'
|
||||
)
|
||||
ORDER BY last_modified_at DESC NULLS LAST, created_at DESC;
|
||||
ORDER BY last_modified_at DESC NULLS LAST, created_at DESC, site_id DESC;
|
||||
|
||||
-- name: CountActiveSitesByPool :one
|
||||
-- The active sites of every workspace whose active primary assignment is the
|
||||
|
||||
@@ -291,7 +291,7 @@ WHERE status = 'active' AND workspace_id IN (
|
||||
SELECT pa.workspace_id FROM core.pool_assignments pa
|
||||
WHERE pa.pool_id = $1::uuid AND pa.is_primary = TRUE AND pa.status = 'active'
|
||||
)
|
||||
ORDER BY last_modified_at DESC NULLS LAST, created_at DESC
|
||||
ORDER BY last_modified_at DESC NULLS LAST, created_at DESC, site_id DESC
|
||||
`
|
||||
|
||||
// The active sites of every workspace whose active primary assignment is the
|
||||
@@ -334,7 +334,7 @@ func (q *Queries) ListActiveSitesByPool(ctx context.Context, poolID string) ([]S
|
||||
const listActiveSitesByWorkspace = `-- name: ListActiveSitesByWorkspace :many
|
||||
SELECT site_id, workspace_id, domain, created_at, updated_at, status, storage_bytes, last_modified_at, force_reduced_at, archived_at FROM fedwiki.sites
|
||||
WHERE workspace_id = $1 AND status = 'active'
|
||||
ORDER BY last_modified_at DESC NULLS LAST, created_at DESC
|
||||
ORDER BY last_modified_at DESC NULLS LAST, created_at DESC, site_id DESC
|
||||
`
|
||||
|
||||
// Active sites, most-recently-modified first — the force_reduce keep/park order.
|
||||
@@ -626,7 +626,7 @@ WHERE force_reduced_at IS NOT NULL AND status = 'readonly' AND workspace_id IN (
|
||||
SELECT pa.workspace_id FROM core.pool_assignments pa
|
||||
WHERE pa.pool_id = $1::uuid AND pa.is_primary = TRUE AND pa.status = 'active'
|
||||
)
|
||||
ORDER BY last_modified_at DESC NULLS LAST, created_at DESC
|
||||
ORDER BY last_modified_at DESC NULLS LAST, created_at DESC, site_id DESC
|
||||
`
|
||||
|
||||
// Sites a downgrade parked that are still read-only, in every workspace
|
||||
|
||||
@@ -276,6 +276,21 @@ func sameCreate(held integration.LifecycleRequest, personID string, params reque
|
||||
(p.IsCustomDomain == nil || *p.IsCustomDomain == *params.IsCustomDomain)
|
||||
}
|
||||
|
||||
// validateHostedName is the refusal of a hosted label, or "" when it stands:
|
||||
// the label's own rules, then the length of the name it makes under the first
|
||||
// allowed farm domain, the parent the JSON create always resolves under
|
||||
// (RFC 1035 §2.3.4). With no allowed domain it refuses
|
||||
// nothing, because the resolve that follows answers that case.
|
||||
func validateHostedName(label string, allowed []string) string {
|
||||
if errMsg := dnsname.ValidateDNSLabel(label); errMsg != "" {
|
||||
return errMsg
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
return ""
|
||||
}
|
||||
return dnsname.ValidateFQDN(label, allowed[0])
|
||||
}
|
||||
|
||||
// CreateSite handles POST /api/fedwiki/sites - initiates site creation workflow.
|
||||
func (h *FedWikiHandler) CreateSite(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := h.getSessionFromRequest(r)
|
||||
@@ -396,7 +411,7 @@ func (h *FedWikiHandler) CreateSite(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
// dispositionOwnActive falls through to the create path below.
|
||||
} else if errMsg := dnsname.ValidateDNSLabel(req.Domain); errMsg != "" {
|
||||
} else if errMsg := validateHostedName(req.Domain, h.FedWikiAllowedDomains); errMsg != "" {
|
||||
// Hosted path only: a custom domain is a full FQDN, not a label.
|
||||
respondError(w, http.StatusBadRequest, errMsg, "")
|
||||
return
|
||||
|
||||
@@ -388,6 +388,48 @@ func TestCreateRepeatTable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A create whose repeat lookup fails is refused with the form-level
|
||||
// sentence and its address kept. The registry is a separate handle, so with
|
||||
// only the handler's database closed the registry would still answer the
|
||||
// placed name as taken; the sentence shows the lookup's failure was what
|
||||
// answered. The same submission with the lookup healthy is accepted.
|
||||
func TestCreateRepeatLookupFailureRefusesTheCreate(t *testing.T) {
|
||||
e := newSiteEnv(t, 5)
|
||||
domain := e.verifiedClaim("lookupfail")
|
||||
instance, gen := e.createForm()
|
||||
if rec := e.submitCustomCreate(domain, instance, gen); rec.Code != http.StatusOK || rec.Body.Len() != 0 {
|
||||
t.Fatalf("custom create = %d %q, want accepted", rec.Code, rec.Body.String())
|
||||
}
|
||||
// What the workflow does before the farm call.
|
||||
if err := workflows.AllocateSiteDomain(e.ctx, e.registry, e.ws, domain, instance); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
healthy := e.h.Database
|
||||
e.h.Database = dbtest.Closed(t)
|
||||
failed := e.submitCustomCreate(domain, instance, gen)
|
||||
e.h.Database = healthy
|
||||
if failed.Code != http.StatusUnprocessableEntity || !strings.Contains(failed.Body.String(), "Failed to initiate site creation") {
|
||||
t.Fatalf("failed lookup = %d %s, want 422 with the form-level sentence", failed.Code, failed.Body.String())
|
||||
}
|
||||
if !strings.Contains(failed.Body.String(), domain) {
|
||||
t.Errorf("the refusal did not keep the typed name")
|
||||
}
|
||||
if gotInstance, gotGen := formAddress(t, failed.Body.String()); gotInstance != instance || gotGen != gen {
|
||||
t.Errorf("the refusal's address = (%s, %d), want the submitted (%s, %d)", gotInstance, gotGen, instance, gen)
|
||||
}
|
||||
if e.requestCount(instance) != 1 || len(e.temporal.starts()) != 1 {
|
||||
t.Errorf("a refused repeat left %d requests and %d workflows, want 1 and 1", e.requestCount(instance), len(e.temporal.starts()))
|
||||
}
|
||||
|
||||
if ok := e.submitCustomCreate(domain, instance, gen); ok.Code != http.StatusOK || !strings.Contains(ok.Header().Get("HX-Trigger"), "fedwikiCreateAccepted") {
|
||||
t.Errorf("healthy lookup = %d %s, want accepted", ok.Code, ok.Body.String())
|
||||
}
|
||||
if e.requestCount(instance) != 1 || len(e.temporal.starts()) != 1 {
|
||||
t.Errorf("the healthy repeat left %d requests and %d workflows, want 1 and 1", e.requestCount(instance), len(e.temporal.starts()))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateConcurrentDoubleSubmit sends one form's submission twice at once.
|
||||
func TestCreateConcurrentDoubleSubmit(t *testing.T) {
|
||||
e := newSiteEnv(t, 5)
|
||||
@@ -1065,6 +1107,10 @@ func TestNoticeSentences(t *testing.T) {
|
||||
"recipes.sites.localhost wasn't created. A site with this name already exists."; got != want {
|
||||
t.Errorf("noticeSentence = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := noticeSentence("recipes.sites.localhost", "created", workflows.ReasonQuotaUnavailable),
|
||||
"recipes.sites.localhost wasn't created. Couldn't check your site limit. Try again later."; got != want {
|
||||
t.Errorf("noticeSentence = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoticesPerAction: each row action's declined or failed request is a
|
||||
@@ -1256,6 +1302,48 @@ func TestAPICreatePolling(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The JSON create runs the assembled-name length check the form runs: a
|
||||
// label whose name under the first farm domain passes 253 characters is
|
||||
// refused before anything is recorded or started, and one that fits is
|
||||
// accepted under the same long farm domain.
|
||||
func TestAPICreateRefusesAnAssembledNameOverTheLimit(t *testing.T) {
|
||||
e := newSiteEnv(t, 5)
|
||||
long := strings.Join([]string{strings.Repeat("a", 63), strings.Repeat("b", 63), strings.Repeat("c", 63), strings.Repeat("d", 8)}, ".")
|
||||
if len(long) != 200 {
|
||||
t.Fatalf("test fixture: farm domain is %d characters, want 200", len(long))
|
||||
}
|
||||
e.api.FedWikiAllowedDomains = []string{long}
|
||||
requests := func() (n int) {
|
||||
if err := e.db.QueryRowContext(e.ctx,
|
||||
`SELECT count(*) FROM core.lifecycle_requests WHERE workspace_id = $1`, e.ws).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
label := strings.Repeat("e", 60)
|
||||
refused := e.apiCreate(map[string]any{"domain": label})
|
||||
if refused.Code != http.StatusBadRequest {
|
||||
t.Fatalf("long name = %d %s, want 400", refused.Code, refused.Body.String())
|
||||
}
|
||||
if want := "The full domain name (" + label + "." + long + ") exceeds the 253-character limit"; !strings.Contains(refused.Body.String(), want) {
|
||||
t.Errorf("body = %s, want it to say %q", refused.Body.String(), want)
|
||||
}
|
||||
if n := requests(); n != 0 {
|
||||
t.Errorf("a refused create recorded %d requests", n)
|
||||
}
|
||||
if starts := e.temporal.starts(); len(starts) != 0 {
|
||||
t.Errorf("a refused create started %v", starts)
|
||||
}
|
||||
|
||||
if ok := e.apiCreate(map[string]any{"domain": "fits" + e.uniq}); ok.Code != http.StatusAccepted {
|
||||
t.Errorf("a name that fits = %d %s, want 202", ok.Code, ok.Body.String())
|
||||
}
|
||||
if n := requests(); n != 1 {
|
||||
t.Errorf("requests after the accepted create = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A client that repeats an unanswered create sends the same instance twice
|
||||
// at once; both lookups can miss before either records. The second is a
|
||||
// repeat of the first, so both are answered 202.
|
||||
|
||||
@@ -234,7 +234,8 @@ func (h *FedWikiPartialsHandler) CreateSite(w http.ResponseWriter, r *http.Reque
|
||||
// in the caller's workspace: accepted when it is the same create, and the
|
||||
// stale-form answer under a fresh address otherwise. It reports whether it
|
||||
// answered; a form without an address, or an address that holds nothing the
|
||||
// caller may see, goes on to the checks and the recording.
|
||||
// caller may see, goes on to the checks and the recording. A lookup that
|
||||
// fails answers the form-level refusal with the address kept.
|
||||
func (h *FedWikiPartialsHandler) answerCreateRepeat(w http.ResponseWriter, r *http.Request, session *auth.UserSession,
|
||||
values forms.Values, errs *forms.Errors, domain, selectedDomain string, isCustomDomain bool) bool {
|
||||
addr, ok := parseAddressValues(values.String("instance"), values.String("generation"))
|
||||
@@ -243,9 +244,10 @@ func (h *FedWikiPartialsHandler) answerCreateRepeat(w http.ResponseWriter, r *ht
|
||||
}
|
||||
_, held, found, err := integration.LookupLifecycleRequest(r.Context(), h.Database, session.WorkspaceID, addr.Instance, addr.Generation)
|
||||
if err != nil {
|
||||
// The recording below reads the address again under its lock.
|
||||
h.Logger.Warn("failed to look up a create request", slog.Any("error", err))
|
||||
return false
|
||||
h.Logger.Error("failed to look up a create request", slog.Any("error", err))
|
||||
errs.Form("Failed to initiate site creation")
|
||||
h.renderCreateFormRefusal(w, r, session.WorkspaceID, values, errs, false)
|
||||
return true
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
|
||||
@@ -407,7 +407,7 @@ func (a *Activities) CheckQuotaActivity(ctx context.Context, input CheckQuotaInp
|
||||
PoolID: assignment.PoolID,
|
||||
ResourceKey: "fedwiki_sites",
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// No entitlement means no quota
|
||||
a.Logger.Info("no sites entitlement found",
|
||||
slog.String("workspaceID", input.WorkspaceID))
|
||||
@@ -417,6 +417,9 @@ func (a *Activities) CheckQuotaActivity(ctx context.Context, input CheckQuotaInp
|
||||
CanCreate: false,
|
||||
}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get sites entitlement: %w", err)
|
||||
}
|
||||
|
||||
usage, err := a.EntitlementsQ.GetUsageByPoolAndResource(ctx, entitlements.GetUsageByPoolAndResourceParams{
|
||||
PoolID: assignment.PoolID,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/fedwikitest"
|
||||
)
|
||||
|
||||
// entitlementReadFails is the entitlements querier whose entitlement read
|
||||
// answers err; every other read is the real one.
|
||||
type entitlementReadFails struct {
|
||||
entitlements.Querier
|
||||
err error
|
||||
}
|
||||
|
||||
func (q entitlementReadFails) GetNumericEntitlementByPoolAndResource(context.Context, entitlements.GetNumericEntitlementByPoolAndResourceParams) (entitlements.NumericEntitlement, error) {
|
||||
return entitlements.NumericEntitlement{}, q.err
|
||||
}
|
||||
|
||||
// A pool with no sites entitlement has no quota: the check answers it as
|
||||
// a refusal to create, not as an error (#231).
|
||||
func TestCheckQuotaActivityAnswersNoEntitlement_DB(t *testing.T) {
|
||||
e := newReqEnv(t, 5, 0)
|
||||
bare := fedwikitest.New(t, e.db).Tenant("NoSites")
|
||||
|
||||
out, err := e.acts.CheckQuotaActivity(e.ctx, CheckQuotaInput{WorkspaceID: bare.Workspace.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("check quota: %v", err)
|
||||
}
|
||||
if out.CanCreate || out.Quota != 0 || out.CurrentCount != 0 {
|
||||
t.Errorf("check = %+v, want no quota and no create", out)
|
||||
}
|
||||
}
|
||||
|
||||
// An entitlement read that fails is not "no entitlement": the check returns
|
||||
// the error (#231).
|
||||
func TestCheckQuotaActivityReturnsAFailedEntitlementRead_DB(t *testing.T) {
|
||||
e := newReqEnv(t, 5, 0)
|
||||
boom := errors.New("entitlement read failed")
|
||||
e.acts.EntitlementsQ = entitlementReadFails{Querier: e.acts.EntitlementsQ, err: boom}
|
||||
|
||||
out, err := e.acts.CheckQuotaActivity(e.ctx, CheckQuotaInput{WorkspaceID: e.ws})
|
||||
if !errors.Is(err, boom) {
|
||||
t.Fatalf("check quota = %+v, %v; want the read's error", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A create whose quota check cannot read the entitlement ends failed with
|
||||
// quota_unavailable, reserves no name and calls no farm.
|
||||
func TestCreateRequestFailsWhenTheEntitlementReadFails_DB(t *testing.T) {
|
||||
e := newReqEnv(t, 5, 0)
|
||||
e.acts.EntitlementsQ = entitlementReadFails{Querier: e.acts.EntitlementsQ, err: errors.New("entitlement read failed")}
|
||||
in := e.startCreate("unchecked")
|
||||
env := e.testEnv()
|
||||
env.ExecuteWorkflow(CreateFedWikiSiteRequestWorkflow, in)
|
||||
out := runOutput(t, env)
|
||||
if out.Status != "failed" || out.ReasonCode != ReasonQuotaUnavailable {
|
||||
t.Errorf("output = %+v", out)
|
||||
}
|
||||
if status, reason := e.request(in.InstanceID, in.Generation); status != "failed" || reason != ReasonQuotaUnavailable {
|
||||
t.Errorf("request = %s/%s", status, reason)
|
||||
}
|
||||
if e.placed(in.Domain) {
|
||||
t.Error("a name was reserved for a create that could not check its limit")
|
||||
}
|
||||
if creates, _, _ := e.farm.Counts(); creates != 0 {
|
||||
t.Errorf("the farm was called %d times", creates)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||||
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/fedwikitest"
|
||||
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
|
||||
)
|
||||
|
||||
// tiedSites creates two sites in one workspace that share every sort column
|
||||
// of FedWiki's keep-and-park lists (last_modified_at and created_at) and
|
||||
// returns the workspace, its pool, and the two site ids newest-first, which
|
||||
// is site_id DESC: the order the lists end on. Lowercase text order equals
|
||||
// UUID byte order, and the ids are uuidv7, so id order is write order and
|
||||
// the newest site comes first.
|
||||
func tiedSites(t *testing.T, database *sql.DB, limit, used int64) (ws, poolID string, byIDDesc []string) {
|
||||
t.Helper()
|
||||
env := fedwikitest.New(t, database)
|
||||
ws = env.Counted("Tester", limit, used).Workspace.ID
|
||||
at := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
for i := range 2 {
|
||||
domain := fmt.Sprintf("tie%d-%s.localtest.me", i, env.Sfx)
|
||||
fedwikitest.SiteModifiedAt(t, database, ws, domain, at)
|
||||
var id string
|
||||
if err := database.QueryRowContext(context.Background(),
|
||||
`UPDATE fedwiki.sites SET created_at = $2 WHERE domain = $1 RETURNING site_id::text`, domain, at).Scan(&id); err != nil {
|
||||
t.Fatalf("tie created_at of %s: %v", domain, err)
|
||||
}
|
||||
byIDDesc = append(byIDDesc, id)
|
||||
}
|
||||
slices.Sort(byIDDesc)
|
||||
slices.Reverse(byIDDesc)
|
||||
if err := database.QueryRowContext(context.Background(), `
|
||||
SELECT pool_id::text FROM core.pool_assignments
|
||||
WHERE workspace_id = $1 AND is_primary AND status = 'active'`, ws).Scan(&poolID); err != nil {
|
||||
t.Fatalf("pool of %s: %v", ws, err)
|
||||
}
|
||||
return ws, poolID, byIDDesc
|
||||
}
|
||||
|
||||
func siteIDs(sites []fwmod.Site) []string {
|
||||
ids := make([]string, len(sites))
|
||||
for i, s := range sites {
|
||||
ids[i] = s.SiteID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func siteStatusByID(t *testing.T, database *sql.DB, id string) string {
|
||||
t.Helper()
|
||||
var status string
|
||||
if err := database.QueryRowContext(context.Background(),
|
||||
`SELECT status FROM fedwiki.sites WHERE site_id = $1`, id).Scan(&status); err != nil {
|
||||
t.Fatalf("read site %s: %v", id, err)
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
// Two sites that tie on every other sort column come back from the three
|
||||
// lists in site_id DESC order, so the sweep parks and reactivates the same
|
||||
// site on every run (#225).
|
||||
func TestKeepAndParkListsBreakTiesOnSiteID(t *testing.T) {
|
||||
_, database := newReconcileTestEnv(t)
|
||||
ws, poolID, want := tiedSites(t, database, 2, 2)
|
||||
ctx := context.Background()
|
||||
q := fwmod.New(database)
|
||||
|
||||
byWorkspace, err := q.ListActiveSitesByWorkspace(ctx, ws)
|
||||
if err != nil {
|
||||
t.Fatalf("list active by workspace: %v", err)
|
||||
}
|
||||
byPool, err := q.ListActiveSitesByPool(ctx, poolID)
|
||||
if err != nil {
|
||||
t.Fatalf("list active by pool: %v", err)
|
||||
}
|
||||
if _, err := database.ExecContext(ctx,
|
||||
`UPDATE fedwiki.sites SET status = 'readonly', force_reduced_at = NOW() WHERE workspace_id = $1`, ws); err != nil {
|
||||
t.Fatalf("park both: %v", err)
|
||||
}
|
||||
forceReduced, err := q.ListForceReducedSitesByPool(ctx, poolID)
|
||||
if err != nil {
|
||||
t.Fatalf("list force-reduced by pool: %v", err)
|
||||
}
|
||||
|
||||
for name, got := range map[string][]fwmod.Site{
|
||||
"ListActiveSitesByWorkspace": byWorkspace,
|
||||
"ListActiveSitesByPool": byPool,
|
||||
"ListForceReducedSitesByPool": forceReduced,
|
||||
} {
|
||||
if ids := siteIDs(got); !slices.Equal(ids, want) {
|
||||
t.Errorf("%s = %v, want site_id DESC %v", name, ids, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The sweep keeps the first site of the tied list and parks the last, which
|
||||
// is the one with the lower site_id.
|
||||
func TestSweepParksTheLowerSiteIDOfATie(t *testing.T) {
|
||||
acts, database := newReconcileTestEnv(t)
|
||||
ws, _, byIDDesc := tiedSites(t, database, 1, 2)
|
||||
governFedwikiLimit(t, database, ws, "force_reduce")
|
||||
|
||||
out, err := acts.ReconcileFedWikiQuotaActivity(context.Background(), ReconcileFedWikiQuotaInput{WorkspaceID: ws})
|
||||
if err != nil {
|
||||
t.Fatalf("sweep: %v", err)
|
||||
}
|
||||
if out.Parked != 1 {
|
||||
t.Fatalf("parked = %d, want 1", out.Parked)
|
||||
}
|
||||
if got := siteStatusByID(t, database, byIDDesc[0]); got != "active" {
|
||||
t.Errorf("higher site_id %s is %s, want active", byIDDesc[0], got)
|
||||
}
|
||||
if got := siteStatusByID(t, database, byIDDesc[1]); got != "readonly" {
|
||||
t.Errorf("lower site_id %s is %s, want readonly", byIDDesc[1], got)
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,11 @@ Each numbered section below is one commit, and its items are that commit's parts
|
||||
|
||||
## 2. Ride-along fixes that stand alone
|
||||
|
||||
- [ ] 2.1 #225, FedWiki's lists: end `ListActiveSitesByWorkspace`, `ListActiveSitesByPool` and `ListForceReducedSitesByPool` (`store/queries/sites.sql`) on `site_id DESC`, regenerate sqlc, and add a database test with two sites that share every other sort column, which pins the order each list returns and the site the sweep parks.
|
||||
- [ ] 2.2 #231: `CheckQuotaActivity` (`workflows/activities.go`) answers "no entitlement" only for `sql.ErrNoRows` and returns any other error, which the create workflow already ends `failed` with `quota_unavailable`. Remove the nilerr exception for `return &CheckQuotaOutput{` in `.golangci.yml`. Tests: a querier whose entitlement read fails returns the error; the create workflow with that activity ends the request `failed` with `quota_unavailable` and the notice "<domain> wasn't created. Couldn't check your site limit. Try again later." (copy 3).
|
||||
- [ ] 2.3 The JSON create runs the assembled-name length check the form runs (design D14). In `web/api.go`, add `validateHostedName(label string, allowed []string) string`: it returns `dnsname.ValidateDNSLabel(label)` when that refuses, then `dnsname.ValidateFQDN(label, allowed[0])` (the parent `resolveCreateDomain` falls back to when the JSON names none), and returns "" when `allowed` is empty, because the resolve that follows answers that case with `msgServiceAvailable`. `(*FedWikiHandler).CreateSite` calls it in place of its `dnsname.ValidateDNSLabel` call on the hosted path and answers 400 with the refusal (copy 7); the call replaces a call, so the function's cognitive complexity stays 48 and its exception in `.golangci.yml` still matches. Test, in `web/requests_db_test.go`: a handler configured with a 200-character farm domain answers a 60-character hosted label with 400 and "The full domain name (<label>.<domain>) exceeds the 253-character limit", records no request, and starts no workflow; a label that fits is still accepted. `TestAPICreatePolling` and the other JSON create tests pass unchanged. No golden moves.
|
||||
- [ ] 2.4 A failed repeat lookup on the form fails the create (design D14). In `answerCreateRepeat` (`web/site_actions.go`), when `integration.LookupLifecycleRequest` returns an error, log it, add the form error "Failed to initiate site creation" (copy 6), render `renderCreateFormRefusal` with the address kept (`stale` false) and return true, in place of logging a warning and returning false. Test, in `web/requests_db_test.go` beside `TestCreateRepeatTable`: a form create whose repeat lookup fails answers 422 with that sentence, keeps the form's address and records no request; the same submission with the lookup healthy is accepted. No golden moves.
|
||||
- [ ] 2.5 No golden moves in this section; a golden, replay history or test result that changes is a stop.
|
||||
- [x] 2.1 #225, FedWiki's lists: end `ListActiveSitesByWorkspace`, `ListActiveSitesByPool` and `ListForceReducedSitesByPool` (`store/queries/sites.sql`) on `site_id DESC`, regenerate sqlc, and add a database test with two sites that share every other sort column, which pins the order each list returns and the site the sweep parks.
|
||||
- [x] 2.2 #231: `CheckQuotaActivity` (`workflows/activities.go`) answers "no entitlement" only for `sql.ErrNoRows` and returns any other error, which the create workflow already ends `failed` with `quota_unavailable`. Remove the nilerr exception for `return &CheckQuotaOutput{` in `.golangci.yml`. Tests: a querier whose entitlement read fails returns the error; the create workflow with that activity ends the request `failed` with `quota_unavailable` and the notice "<domain> wasn't created. Couldn't check your site limit. Try again later." (copy 3).
|
||||
- [x] 2.3 The JSON create runs the assembled-name length check the form runs (design D14). In `web/api.go`, add `validateHostedName(label string, allowed []string) string`: it returns `dnsname.ValidateDNSLabel(label)` when that refuses, then `dnsname.ValidateFQDN(label, allowed[0])` (the parent `resolveCreateDomain` falls back to when the JSON names none), and returns "" when `allowed` is empty, because the resolve that follows answers that case with `msgServiceAvailable`. `(*FedWikiHandler).CreateSite` calls it in place of its `dnsname.ValidateDNSLabel` call on the hosted path and answers 400 with the refusal (copy 7); the call replaces a call, so the function's cognitive complexity stays 48 and its exception in `.golangci.yml` still matches. Test, in `web/requests_db_test.go`: a handler configured with a 200-character farm domain answers a 60-character hosted label with 400 and "The full domain name (<label>.<domain>) exceeds the 253-character limit", records no request, and starts no workflow; a label that fits is still accepted. `TestAPICreatePolling` and the other JSON create tests pass unchanged. No golden moves.
|
||||
- [x] 2.4 A failed repeat lookup on the form fails the create (design D14). In `answerCreateRepeat` (`web/site_actions.go`), when `integration.LookupLifecycleRequest` returns an error, log it, add the form error "Failed to initiate site creation" (copy 6), render `renderCreateFormRefusal` with the address kept (`stale` false) and return true, in place of logging a warning and returning false. Test, in `web/requests_db_test.go` beside `TestCreateRepeatTable`: a form create whose repeat lookup fails answers 422 with that sentence, keeps the form's address and records no request; the same submission with the lookup healthy is accepted. No golden moves.
|
||||
- [x] 2.5 No golden moves in this section; a golden, replay history or test result that changes is a stop.
|
||||
|
||||
## 3. The six interleavings, pinned against today's code
|
||||
|
||||
|
||||
Reference in New Issue
Block a user