Replace the entity slugs on organizations, workspaces, resource pools, and plan ladders with nullable `key` columns and add keys to products, prices, and entitlement sets. Rename `providers.slug` to `provider` and add partial unique indexes for system and org role names. Assign invoice numbers per billing account from a gapless transactional counter; Stripe's number moves to the invoice mapping as an external reference. Seeds, fixtures, and the operator lookup address rows by key, and the returning-login resync no longer blanks a display name when the IdP sends no `name` claim.
327 lines
12 KiB
Go
327 lines
12 KiB
Go
package server
|
|
|
|
// Landing-surface lookup (entity-keys D8): a single typed input
|
|
// resolves to a person detail, an organization composite, a disambiguation
|
|
// listing, or a no-match notice. Persons resolve first because every
|
|
// personal organization's name contains its owner's display name, so a
|
|
// bare name match must land on the person rather than the organization.
|
|
// DB-backed via TEST_DATABASE_URL, mirroring operator_organizations_list_test.go's
|
|
// setup (newRollbackTestDB, a plain http.ServeMux with routes registered, and
|
|
// a role-carrying session context).
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/alexedwards/scs/v2"
|
|
"github.com/google/uuid"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
|
|
)
|
|
|
|
// lookupEnv is the surface under test: the OperatorHandler with its routes
|
|
// registered, plus one authenticated operator session to drive it with.
|
|
type lookupEnv struct {
|
|
t *testing.T
|
|
database *sql.DB
|
|
mux *http.ServeMux
|
|
operator context.Context
|
|
}
|
|
|
|
func newLookupEnv(t *testing.T) *lookupEnv {
|
|
t.Helper()
|
|
database := newRollbackTestDB(t)
|
|
sm := scs.New()
|
|
authCfg := &auth.Config{SessionManager: sm}
|
|
handler, err := NewOperatorHandler(OperatorHandlerConfig{
|
|
Database: database,
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
AuthConfig: authCfg,
|
|
OrgQ: organization.New(database),
|
|
IdentityQ: identity.New(database),
|
|
// The key branch reaches the catalog tables as well as
|
|
// organizations (entity-keys §5), so the handler under test needs
|
|
// the same queriers the running app gives it.
|
|
BillingQ: billing.New(database),
|
|
EntitlementsQ: entitlements.New(database),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewOperatorHandler: %v", err)
|
|
}
|
|
mux := http.NewServeMux()
|
|
handler.RegisterRoutes(mux)
|
|
|
|
ctx, err := sm.Load(context.Background(), "")
|
|
if err != nil {
|
|
t.Fatalf("session Load: %v", err)
|
|
}
|
|
sm.Put(ctx, "authenticated", true)
|
|
sm.Put(ctx, "roles", []string{OperatorRole})
|
|
|
|
return &lookupEnv{t: t, database: database, mux: mux, operator: ctx}
|
|
}
|
|
|
|
// lookup posts the given term to /operator/lookup and returns the response
|
|
// code, the HX-Redirect header (empty when absent), and the body.
|
|
func (e *lookupEnv) lookup(term string) (int, string, string) {
|
|
e.t.Helper()
|
|
form := url.Values{"term": {term}}
|
|
req := httptest.NewRequestWithContext(e.operator, http.MethodPost, "/operator/lookup", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rec := httptest.NewRecorder()
|
|
e.mux.ServeHTTP(rec, req)
|
|
return rec.Code, rec.Header().Get("HX-Redirect"), rec.Body.String()
|
|
}
|
|
|
|
// seedLookupPerson inserts the user -> person chain a fixture row needs.
|
|
func (e *lookupEnv) seedLookupPerson(displayName, email string) string {
|
|
e.t.Helper()
|
|
ctx := context.Background()
|
|
sub := uuid.New().String()
|
|
var personID string
|
|
var userID string
|
|
if err := e.database.QueryRowContext(ctx,
|
|
`INSERT INTO core.users (oidc_subject) VALUES ($1) RETURNING user_id`, "sub-"+sub).Scan(&userID); err != nil {
|
|
e.t.Fatalf("fixture user: %v", err)
|
|
}
|
|
if err := e.database.QueryRowContext(ctx,
|
|
`INSERT INTO core.persons (user_id, display_name, primary_email) VALUES ($1,$2,$3) RETURNING person_id`,
|
|
userID, displayName, email).Scan(&personID); err != nil {
|
|
e.t.Fatalf("fixture person: %v", err)
|
|
}
|
|
return personID
|
|
}
|
|
|
|
// seedLookupOrg inserts an organization owned by a freshly-created person
|
|
// whose display name shares no substring with the organization's name (so a
|
|
// term that matches the organization does not also match its owner,
|
|
// spuriously triggering the person-first branch). Distinct from a personal
|
|
// organization's real-world naming (owner name embedded in org name), which
|
|
// TestOperatorLookupPersonFirstPrecedence covers on its own.
|
|
func (e *lookupEnv) seedLookupOrg(orgType, name string) string {
|
|
e.t.Helper()
|
|
ownerID := e.seedLookupPerson("Unrelated Owner "+uuid.New().String()[:8], fmt.Sprintf("owner-%s@example.com", marker()))
|
|
ctx := context.Background()
|
|
var orgID string
|
|
if err := e.database.QueryRowContext(ctx,
|
|
`INSERT INTO core.organizations (name, org_type, owner_person_id) VALUES ($1,$2,$3) RETURNING org_id`,
|
|
name, orgType, ownerID).Scan(&orgID); err != nil {
|
|
e.t.Fatalf("fixture org: %v", err)
|
|
}
|
|
return orgID
|
|
}
|
|
|
|
// TestOperatorLookupPersonFirstPrecedence covers design D2: when a term
|
|
// matches both a person and an organization (as it always does for a
|
|
// personal organization, whose name embeds its owner's display name), the
|
|
// person resolves and the organization branch never runs.
|
|
func TestOperatorLookupPersonFirstPrecedence(t *testing.T) {
|
|
env := newLookupEnv(t)
|
|
mk := marker()
|
|
orgType := env.seedOrgTypeForLookup(mk)
|
|
|
|
personName := "Priya Lookup " + mk
|
|
personID := env.seedLookupPerson(personName, fmt.Sprintf("priya-%s@example.com", mk))
|
|
// An organization whose name embeds the person's display name, exactly
|
|
// as a personal organization's name would (auto-provisioning).
|
|
orgID := env.seedOrgWithOwner(orgType, personName+"'s Organization", personID)
|
|
|
|
code, redirect, _ := env.lookup(personName)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("lookup GET = %d, want %d", code, http.StatusOK)
|
|
}
|
|
if redirect != "/operator/persons/"+personID {
|
|
t.Errorf("expected person-first redirect to /operator/persons/%s, got redirect=%q", personID, redirect)
|
|
}
|
|
if strings.Contains(redirect, "/operator/organizations/"+orgID) {
|
|
t.Error("lookup redirected to the organization instead of the person it names")
|
|
}
|
|
}
|
|
|
|
// TestOperatorLookupSingleOrganizationRedirects covers design D2: a term
|
|
// matching no person but exactly one organization's name redirects straight
|
|
// to that organization's composite.
|
|
func TestOperatorLookupSingleOrganizationRedirects(t *testing.T) {
|
|
env := newLookupEnv(t)
|
|
mk := marker()
|
|
orgType := env.seedOrgTypeForLookup(mk)
|
|
|
|
orgName := "Quailwood Collective " + mk
|
|
orgID := env.seedLookupOrg(orgType, orgName)
|
|
|
|
code, redirect, _ := env.lookup(orgName)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("lookup GET = %d, want %d", code, http.StatusOK)
|
|
}
|
|
if redirect != "/operator/organizations/"+orgID {
|
|
t.Errorf("expected redirect to /operator/organizations/%s, got redirect=%q", orgID, redirect)
|
|
}
|
|
}
|
|
|
|
// TestOperatorLookupMultipleOrganizationsDisambiguate covers design D2:
|
|
// several organization-name matches (and no person match) render the
|
|
// disambiguation listing, each candidate linking to its own composite.
|
|
func TestOperatorLookupMultipleOrganizationsDisambiguate(t *testing.T) {
|
|
env := newLookupEnv(t)
|
|
mk := marker()
|
|
orgType := env.seedOrgTypeForLookup(mk)
|
|
|
|
needle := "Briarwood" + mk
|
|
orgA := env.seedLookupOrg(orgType, needle+" Cooperative")
|
|
orgB := env.seedLookupOrg(orgType, needle+" Guild")
|
|
|
|
code, redirect, body := env.lookup(needle)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("lookup GET = %d, want %d", code, http.StatusOK)
|
|
}
|
|
if redirect != "" {
|
|
t.Errorf("expected no redirect on an ambiguous match, got redirect=%q", redirect)
|
|
}
|
|
for _, orgID := range []string{orgA, orgB} {
|
|
if !strings.Contains(body, `href="/operator/organizations/`+orgID+`"`) {
|
|
t.Errorf("expected a disambiguation link to /operator/organizations/%s, got:\n%s", orgID, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// keyMarker turns a test marker into something the key grammar accepts
|
|
// (`^[a-z][a-z0-9_]*$`, entity-keys §4). marker() may contain characters the
|
|
// CHECK constraint rejects, and a key that does not parse would make the
|
|
// fixture, not the handler, the thing under test.
|
|
func keyMarker(prefix, marker string) string {
|
|
var b strings.Builder
|
|
b.WriteString(prefix)
|
|
for _, r := range strings.ToLower(marker) {
|
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
|
b.WriteRune(r)
|
|
} else {
|
|
b.WriteRune('_')
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// TestOperatorLookupExactKeyResolves covers entity-keys §5: a term that is an
|
|
// exact key match resolves to that row's detail page, for each of the four
|
|
// keyed entities an operator can reach one for. Keys resolve before the name
|
|
// search, so each fixture below also carries a name nothing in the term
|
|
// matches -- the redirect proves the key branch ran, not the name branch.
|
|
func TestOperatorLookupExactKeyResolves(t *testing.T) {
|
|
env := newLookupEnv(t)
|
|
mk := marker()
|
|
orgType := env.seedOrgTypeForLookup(mk)
|
|
ctx := context.Background()
|
|
|
|
orgKey := keyMarker("lk_org_", mk)
|
|
orgID := env.seedLookupOrg(orgType, "Sablewood Union "+mk)
|
|
if _, err := env.database.ExecContext(ctx,
|
|
`UPDATE core.organizations SET key = $1 WHERE org_id = $2`, orgKey, orgID); err != nil {
|
|
t.Fatalf("fixture org key: %v", err)
|
|
}
|
|
|
|
ladderKey := keyMarker("lk_ladder_", mk)
|
|
var ladderID string
|
|
if err := env.database.QueryRowContext(ctx,
|
|
`INSERT INTO core.plan_ladders (name, key) VALUES ($1, $2) RETURNING plan_ladder_id`,
|
|
"Lookup Ladder "+mk, ladderKey).Scan(&ladderID); err != nil {
|
|
t.Fatalf("fixture ladder: %v", err)
|
|
}
|
|
|
|
setKey := keyMarker("lk_set_", mk)
|
|
var setID string
|
|
if err := env.database.QueryRowContext(ctx,
|
|
`INSERT INTO core.entitlement_sets (name, key) VALUES ($1, $2) RETURNING set_id`,
|
|
"Lookup Set "+mk, setKey).Scan(&setID); err != nil {
|
|
t.Fatalf("fixture entitlement set: %v", err)
|
|
}
|
|
|
|
productKey := keyMarker("lk_product_", mk)
|
|
var productID string
|
|
if err := env.database.QueryRowContext(ctx,
|
|
`INSERT INTO core.products (name, key) VALUES ($1, $2) RETURNING product_id`,
|
|
"Lookup Product "+mk, productKey).Scan(&productID); err != nil {
|
|
t.Fatalf("fixture product: %v", err)
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
term string
|
|
want string
|
|
}{
|
|
{"organization", orgKey, "/operator/organizations/" + orgID},
|
|
{"plan ladder", ladderKey, "/operator/plan-ladders/" + ladderID},
|
|
{"entitlement set", setKey, "/operator/entitlement-sets/" + setID},
|
|
{"product", productKey, "/operator/products/" + productID},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
code, redirect, body := env.lookup(tc.term)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("lookup = %d, want %d", code, http.StatusOK)
|
|
}
|
|
if redirect != tc.want {
|
|
t.Errorf("key %q redirected to %q, want %q (body: %s)", tc.term, redirect, tc.want, body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestOperatorLookupUnknownKeyFallsThroughToNoMatch covers the other half of
|
|
// §5's ordering: a term shaped like a key that matches no key is not an
|
|
// error, it simply continues to the name search and, matching nothing there
|
|
// either, reaches the no-match notice.
|
|
func TestOperatorLookupUnknownKeyFallsThroughToNoMatch(t *testing.T) {
|
|
env := newLookupEnv(t)
|
|
term := keyMarker("lk_absent_", marker())
|
|
|
|
code, redirect, body := env.lookup(term)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("lookup = %d, want %d", code, http.StatusOK)
|
|
}
|
|
if redirect != "" {
|
|
t.Errorf("an unmatched key redirected to %q; it should fall through", redirect)
|
|
}
|
|
if !strings.Contains(body, term) {
|
|
t.Errorf("expected the no-match notice to name the term %q, got:\n%s", term, body)
|
|
}
|
|
}
|
|
|
|
// seedOrgTypeForLookup inserts a fresh, uniquely-keyed org type so a lookup
|
|
// test never collides with another test's or another run's rows.
|
|
func (e *lookupEnv) seedOrgTypeForLookup(marker string) string {
|
|
e.t.Helper()
|
|
orgType := "t" + uuid.New().String()[:12] // core.org_types.org_type is VARCHAR(20)
|
|
if _, err := e.database.ExecContext(context.Background(),
|
|
`INSERT INTO core.org_types (org_type, display_name, is_active) VALUES ($1, $2, true)`,
|
|
orgType, "Lookup Fixture "+marker); err != nil {
|
|
e.t.Fatalf("fixture org type: %v", err)
|
|
}
|
|
return orgType
|
|
}
|
|
|
|
// seedOrgWithOwner inserts an organization owned by an already-seeded person
|
|
// (rather than minting a fresh owner, as seedLookupOrg does), so a test can
|
|
// build the person/organization name overlap a personal organization has.
|
|
func (e *lookupEnv) seedOrgWithOwner(orgType, name, ownerPersonID string) string {
|
|
e.t.Helper()
|
|
var orgID string
|
|
if err := e.database.QueryRowContext(context.Background(),
|
|
`INSERT INTO core.organizations (name, org_type, owner_person_id) VALUES ($1,$2,$3) RETURNING org_id`,
|
|
name, orgType, ownerPersonID).Scan(&orgID); err != nil {
|
|
e.t.Fatalf("fixture org: %v", err)
|
|
}
|
|
return orgID
|
|
}
|