Domain names become an allocatable resource with one authority. A new core module (schema `domains`, own migration stream between core and the integrations) owns claims — a DNS node plus its whole subtree, mutually disjoint: operator shared-domain roots, member claims carved from them, and bring-your-own names proven by TXT verification — and placements, which bind a name inside a claim to a provider slug and resource ref. Verification moves to the claim and decouples from creation. A member proves control of a domain once; afterwards every name inside it places instantly, wildcard-CNAME friendly, with no further DNS work. The claim workflow activates the claim and stops — it no longer creates a site — so the sites list offers a one-click create once a domain verifies. /domains/ask answers from placements and is registered by core rather than the FedWiki adapter; its HTTP contract is unchanged. A configured `domains-ask-fallback-url` forwards names the registry does not know to a legacy answerer, the strangler seam wiki.cafe's migration needs; a name the registry knows but has archived is refused locally. FedWiki's create saga reserves the name before the farm call, carrying a workflow-minted site id so retries are idempotent, and compensates on failure. Sync places only names it owns, never stealing a member's; lifecycle transitions and the retention purge maintain servability. An unconditional boot pass seeds operator roots, releases orphaned placements, and adopts pre-existing sites — grandfathering member-owned external domains shortest-name-first, and skipping name policy, so a live single-letter site cannot lose its certificate. Members manage domains at /domains: claims with verification status, DNS records including an optional wildcard row, check-now, cancel, release. Name policy (reserved, blocked, premium, plus a single-letter guard) is operator data; refusals collapse to a plain "unavailable" so the console never becomes an oracle for who holds what. BREAKING (pre-release): `fedwiki.custom_domain_verifications` and `sites.is_custom_domain` are dropped, the flag now derived from the placement's claim kind; resource key `fedwiki_custom_domains` migrates to the platform-owned `external_domain_claims`; running verify-custom-domain workflows must be terminated before deploy.
224 lines
8.0 KiB
Go
224 lines
8.0 KiB
Go
package workflows
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/domains"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/systemtenant"
|
|
)
|
|
|
|
// placementRow is the slice of domains.placements these tests assert on.
|
|
type placementRow struct {
|
|
Provider string
|
|
ResourceRef string
|
|
Servable bool
|
|
}
|
|
|
|
// placementFor reads the placement serving fqdn, or nil when the name is
|
|
// unplaced. Read straight from SQL rather than through the registry so the test
|
|
// asserts stored state, not the API's interpretation of it.
|
|
func placementFor(t *testing.T, database *sql.DB, fqdn string) *placementRow {
|
|
t.Helper()
|
|
var got placementRow
|
|
err := database.QueryRowContext(context.Background(),
|
|
`SELECT provider, resource_ref, servable FROM domains.placements WHERE fqdn = $1`, fqdn).
|
|
Scan(&got.Provider, &got.ResourceRef, &got.Servable)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("read placement %s: %v", fqdn, err)
|
|
}
|
|
return &got
|
|
}
|
|
|
|
// countPlacementsAt counts the placement rows at fqdn. The column is uniquely
|
|
// indexed, so anything but 0 or 1 is a bug this catches directly.
|
|
func countPlacementsAt(t *testing.T, database *sql.DB, fqdn string) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := database.QueryRowContext(context.Background(),
|
|
`SELECT count(*) FROM domains.placements WHERE fqdn = $1`, fqdn).Scan(&n); err != nil {
|
|
t.Fatalf("count placements %s: %v", fqdn, err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
func siteIDOf(t *testing.T, database *sql.DB, domain string) string {
|
|
t.Helper()
|
|
var id string
|
|
if err := database.QueryRowContext(context.Background(),
|
|
`SELECT site_id FROM fedwiki.sites WHERE domain = $1`, domain).Scan(&id); err != nil {
|
|
t.Fatalf("read site id %s: %v", domain, err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
// insertSystemSite inserts a System-tenant site row with a caller-chosen id (the
|
|
// shape the saga produces: the id exists before the row does) and returns it.
|
|
func insertSystemSite(t *testing.T, database *sql.DB, workspaceID, domain, status string) string {
|
|
t.Helper()
|
|
siteID := uuid.Must(uuid.NewV7()).String()
|
|
if _, err := database.ExecContext(context.Background(),
|
|
`INSERT INTO fedwiki.sites (site_id, workspace_id, domain, status) VALUES ($1,$2,$3,$4)`,
|
|
siteID, workspaceID, domain, status); err != nil {
|
|
t.Fatalf("insert site %s: %v", domain, err)
|
|
}
|
|
return siteID
|
|
}
|
|
|
|
// TestSyncProjectsPlacementsByClaimDepth drives one sync pass over a farm
|
|
// carrying every name class the registry distinguishes, and asserts the
|
|
// placement rules from the fedwiki-sites sync requirement:
|
|
//
|
|
// - inside an operator root and no deeper claim → operator placement ensured;
|
|
// - inside a live member claim → NO placement (the claim owner places there,
|
|
// never the sync);
|
|
// - outside every operator root → NO placement;
|
|
// - farm-absent System-tenant site → row AND placement removed;
|
|
// - observed status crossing the archived boundary → servability follows.
|
|
func TestSyncProjectsPlacementsByClaimDepth(t *testing.T) {
|
|
acts, database := newReconcileTestEnv(t)
|
|
ctx := context.Background()
|
|
uniq := fmt.Sprintf("%d", time.Now().UnixNano())
|
|
|
|
systemWS, err := systemtenant.Ensure(ctx, database)
|
|
if err != nil {
|
|
t.Fatalf("ensure system tenant: %v", err)
|
|
}
|
|
registry := domains.NewRegistry(database)
|
|
|
|
root := "sync" + uniq + ".test"
|
|
if _, err := registry.EnsureOperatorRoot(ctx, systemWS, root); err != nil {
|
|
t.Fatalf("ensure operator root %s: %v", root, err)
|
|
}
|
|
|
|
// A member carved a claim inside the root — their names, not the sync's.
|
|
memberWS, _ := setupQuotaFixture(t, database, 5, 0)
|
|
memberName := "mine." + root
|
|
if _, err := registry.ClaimCarved(ctx, memberWS, memberName); err != nil {
|
|
t.Fatalf("carve member claim %s: %v", memberName, err)
|
|
}
|
|
|
|
// Two pre-existing System-tenant sites with placements: one the farm has
|
|
// dropped (sweep target), one the farm now reports archived (drift target).
|
|
goneName, driftName := "gone."+root, "drift."+root
|
|
goneID := insertSystemSite(t, database, systemWS, goneName, "active")
|
|
driftID := insertSystemSite(t, database, systemWS, driftName, "active")
|
|
for name, id := range map[string]string{goneName: goneID, driftName: driftID} {
|
|
if _, err := registry.Place(ctx, domains.PlaceParams{
|
|
WorkspaceID: systemWS, FQDN: name, Provider: "fedwiki", ResourceRef: id, Servable: true,
|
|
}); err != nil {
|
|
t.Fatalf("place %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
newName := "tools." + root
|
|
foreignName := "foreign" + uniq + ".example.org"
|
|
|
|
out, err := acts.SyncSitesToDBActivity(ctx, SyncSitesToDBInput{
|
|
DefaultWorkspaceID: systemWS,
|
|
FarmSites: []SiteInfo{
|
|
{Name: newName, Status: "active"},
|
|
{Name: memberName, Status: "active"},
|
|
{Name: foreignName, Status: "active"},
|
|
{Name: driftName, Status: "archived"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("sync: %v", err)
|
|
}
|
|
if out.SitesAdded < 3 {
|
|
t.Errorf("SitesAdded = %d, want at least 3 (new, member-claim, foreign)", out.SitesAdded)
|
|
}
|
|
|
|
// Inside the operator root, no deeper claim → operator placement, servable.
|
|
newID := siteIDOf(t, database, newName)
|
|
got := placementFor(t, database, newName)
|
|
if got == nil {
|
|
t.Fatalf("no placement for %s, want one inside the operator root", newName)
|
|
}
|
|
if got.Provider != "fedwiki" || got.ResourceRef != newID || !got.Servable {
|
|
t.Errorf("placement for %s = %+v, want fedwiki/%s servable", newName, got, newID)
|
|
}
|
|
|
|
// Inside a member's claim → the sync must never place there.
|
|
if got := placementFor(t, database, memberName); got != nil {
|
|
t.Errorf("sync placed %s inside a member's claim: %+v", memberName, got)
|
|
}
|
|
// Outside every operator root → no placement at all.
|
|
if got := placementFor(t, database, foreignName); got != nil {
|
|
t.Errorf("sync placed foreign domain %s: %+v", foreignName, got)
|
|
}
|
|
// Both rows must still be projected, placement or not.
|
|
for _, name := range []string{memberName, foreignName} {
|
|
if !siteExists(t, database, name) {
|
|
t.Errorf("%s was not projected into the database", name)
|
|
}
|
|
}
|
|
|
|
// Archived on the farm → the name stops being served, placement retained.
|
|
if got := placementFor(t, database, driftName); got == nil {
|
|
t.Errorf("placement for %s disappeared, want it retained but unservable", driftName)
|
|
} else if got.Servable {
|
|
t.Errorf("placement for %s is still servable after the farm archived it", driftName)
|
|
}
|
|
|
|
// Farm-absent System-tenant site → row and placement both gone.
|
|
if siteExists(t, database, goneName) {
|
|
t.Errorf("farm-absent site %s was not deleted", goneName)
|
|
}
|
|
if got := placementFor(t, database, goneName); got != nil {
|
|
t.Errorf("placement for deleted site %s survived: %+v", goneName, got)
|
|
}
|
|
}
|
|
|
|
// TestSyncRestoresServabilityOnUnarchive asserts the boundary crossing works in
|
|
// both directions: a farm site that returns to active gets its name serving
|
|
// again without the sync having to re-create the placement.
|
|
func TestSyncRestoresServabilityOnUnarchive(t *testing.T) {
|
|
acts, database := newReconcileTestEnv(t)
|
|
ctx := context.Background()
|
|
uniq := fmt.Sprintf("%d", time.Now().UnixNano())
|
|
|
|
systemWS, err := systemtenant.Ensure(ctx, database)
|
|
if err != nil {
|
|
t.Fatalf("ensure system tenant: %v", err)
|
|
}
|
|
registry := domains.NewRegistry(database)
|
|
|
|
root := "unarch" + uniq + ".test"
|
|
if _, err := registry.EnsureOperatorRoot(ctx, systemWS, root); err != nil {
|
|
t.Fatalf("ensure operator root: %v", err)
|
|
}
|
|
name := "back." + root
|
|
siteID := insertSystemSite(t, database, systemWS, name, "archived")
|
|
if _, err := registry.Place(ctx, domains.PlaceParams{
|
|
WorkspaceID: systemWS, FQDN: name, Provider: "fedwiki", ResourceRef: siteID, Servable: false,
|
|
}); err != nil {
|
|
t.Fatalf("place %s: %v", name, err)
|
|
}
|
|
|
|
if _, err := acts.SyncSitesToDBActivity(ctx, SyncSitesToDBInput{
|
|
DefaultWorkspaceID: systemWS,
|
|
FarmSites: []SiteInfo{{Name: name, Status: "active"}},
|
|
}); err != nil {
|
|
t.Fatalf("sync: %v", err)
|
|
}
|
|
|
|
got := placementFor(t, database, name)
|
|
if got == nil {
|
|
t.Fatalf("placement for %s disappeared", name)
|
|
}
|
|
if !got.Servable {
|
|
t.Errorf("placement for %s is still unservable after the farm reactivated it", name)
|
|
}
|
|
}
|