Introduce a commercial license option alongside AGPL-3.0-only, require a CLA for contributors, and document the terms in COMMERCIAL.md and NOTICE. Add a script to stamp SPDX headers on Go files and apply it across the tree.
477 lines
18 KiB
Go
477 lines
18 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
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"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/test/e2e/screens"
|
|
)
|
|
|
|
// 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 issues the scoped htmx GET the declared search form renders
|
|
// (form-library, spec operator-panel-navigation) 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()
|
|
req := httptest.NewRequestWithContext(e.operator, http.MethodGet, "/operator/lookup?"+url.Values{"term": {term}}.Encode(), nil)
|
|
req.Header.Set("HX-Request", "true")
|
|
rec := httptest.NewRecorder()
|
|
e.mux.ServeHTTP(rec, req)
|
|
return rec.Code, rec.Header().Get("HX-Redirect"), rec.Body.String()
|
|
}
|
|
|
|
// lookupNative issues the plain GET a no-JS submission of the search form's
|
|
// native action produces: no HX-Request header, so a unique match answers
|
|
// with a real 303 redirect instead of HX-Redirect, and an ambiguous or
|
|
// no-match term gets the full landing page back, not the bare fragment
|
|
// (spec operator-panel-navigation, "The lookup resolves without
|
|
// JavaScript").
|
|
func (e *lookupEnv) lookupNative(term string) (int, string, string) {
|
|
e.t.Helper()
|
|
req := httptest.NewRequestWithContext(e.operator, http.MethodGet, "/operator/lookup?"+url.Values{"term": {term}}.Encode(), nil)
|
|
rec := httptest.NewRecorder()
|
|
e.mux.ServeHTTP(rec, req)
|
|
return rec.Code, rec.Header().Get("Location"), 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)
|
|
}
|
|
}
|
|
|
|
// TestOperatorLookupNativeFallbackResolves covers spec
|
|
// operator-panel-navigation, "The lookup resolves without JavaScript": a
|
|
// plain GET carrying no HX-Request header (the search form's native no-JS
|
|
// submission) reaches the same match and lands on the same record's page
|
|
// via an ordinary 303, not HX-Redirect.
|
|
func TestOperatorLookupNativeFallbackResolves(t *testing.T) {
|
|
env := newLookupEnv(t)
|
|
mk := marker()
|
|
orgType := env.seedOrgTypeForLookup(mk)
|
|
|
|
orgName := "Native Fallback Org " + mk
|
|
orgID := env.seedLookupOrg(orgType, orgName)
|
|
|
|
code, location, _ := env.lookupNative(orgName)
|
|
if code != http.StatusSeeOther {
|
|
t.Fatalf("native lookup = %d, want %d", code, http.StatusSeeOther)
|
|
}
|
|
if location != "/operator/organizations/"+orgID {
|
|
t.Errorf("native lookup Location = %q, want /operator/organizations/%s", location, orgID)
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
// seedLookupInvoice inserts a billing account for orgID and one invoice on
|
|
// it carrying the given number, returning the invoice's ID.
|
|
func (e *lookupEnv) seedLookupInvoice(orgID, invoiceNumber string) string {
|
|
e.t.Helper()
|
|
ctx := context.Background()
|
|
var accountID string
|
|
if err := e.database.QueryRowContext(ctx,
|
|
`INSERT INTO core.accounts (org_id, name, status) VALUES ($1,$2,'active') RETURNING billing_account_id`,
|
|
orgID, "Lookup Account "+marker()).Scan(&accountID); err != nil {
|
|
e.t.Fatalf("fixture billing account: %v", err)
|
|
}
|
|
var invoiceID string
|
|
if err := e.database.QueryRowContext(ctx,
|
|
`INSERT INTO core.invoices (billing_account_id, status, currency, invoice_number) VALUES ($1,'open','usd',$2) RETURNING invoice_id`,
|
|
accountID, invoiceNumber).Scan(&invoiceID); err != nil {
|
|
e.t.Fatalf("fixture invoice: %v", err)
|
|
}
|
|
return invoiceID
|
|
}
|
|
|
|
// TestOperatorLookupInvoiceNumberResolves covers design D4: a term equal to
|
|
// exactly one invoice's number redirects straight to that invoice's detail
|
|
// page, ahead of the person and organization checks.
|
|
func TestOperatorLookupInvoiceNumberResolves(t *testing.T) {
|
|
env := newLookupEnv(t)
|
|
mk := marker()
|
|
orgType := env.seedOrgTypeForLookup(mk)
|
|
orgID := env.seedLookupOrg(orgType, "Invoice Lookup Org "+mk)
|
|
|
|
number := keyMarker("INV-", mk)
|
|
invoiceID := env.seedLookupInvoice(orgID, number)
|
|
|
|
code, redirect, _ := env.lookup(number)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("lookup = %d, want %d", code, http.StatusOK)
|
|
}
|
|
if redirect != "/operator/billing/invoices/"+invoiceID {
|
|
t.Errorf("expected redirect to /operator/billing/invoices/%s, got redirect=%q", invoiceID, redirect)
|
|
}
|
|
}
|
|
|
|
// TestOperatorLookupInvoiceNumberDisambiguates covers design D4: an invoice
|
|
// number is unique only per billing account, so the same number can name
|
|
// several invoices across different organizations' accounts — the
|
|
// disambiguation list shows each match's number, organization, and date.
|
|
func TestOperatorLookupInvoiceNumberDisambiguates(t *testing.T) {
|
|
env := newLookupEnv(t)
|
|
mk := marker()
|
|
orgType := env.seedOrgTypeForLookup(mk)
|
|
orgA := env.seedLookupOrg(orgType, "Invoice Disambig Org A "+mk)
|
|
orgB := env.seedLookupOrg(orgType, "Invoice Disambig Org B "+mk)
|
|
|
|
number := keyMarker("INV-", mk)
|
|
invoiceA := env.seedLookupInvoice(orgA, number)
|
|
invoiceB := env.seedLookupInvoice(orgB, number)
|
|
|
|
code, redirect, body := env.lookup(number)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("lookup = %d, want %d", code, http.StatusOK)
|
|
}
|
|
if redirect != "" {
|
|
t.Errorf("expected no redirect on an ambiguous invoice number, got redirect=%q", redirect)
|
|
}
|
|
for _, invoiceID := range []string{invoiceA, invoiceB} {
|
|
if !strings.Contains(body, `href="/operator/billing/invoices/`+invoiceID+`"`) {
|
|
t.Errorf("expected a disambiguation link to /operator/billing/invoices/%s, got:\n%s", invoiceID, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestOperatorLookupNoMatchNamesRegistryClasses covers design D4: the
|
|
// no-match sentence names every record class the resolver registry
|
|
// resolves, generated from the registry rather than hand-typed, so it can
|
|
// never disagree with the code.
|
|
func TestOperatorLookupNoMatchNamesRegistryClasses(t *testing.T) {
|
|
env := newLookupEnv(t)
|
|
term := keyMarker("lk_absent2_", marker())
|
|
|
|
_, redirect, body := env.lookup(term)
|
|
if redirect != "" {
|
|
t.Fatalf("expected no redirect for an unmatched term, got %q", redirect)
|
|
}
|
|
for _, class := range []string{"person", "organization", "product", "entitlement set", "plan ladder", "invoice"} {
|
|
if !strings.Contains(body, class) {
|
|
t.Errorf("no-match notice missing record class %q, got:\n%s", class, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestLookupRegistryCoversManifest covers design D4: "A test SHALL assert
|
|
// that every detail route in the capture manifest has a resolver, so a
|
|
// record class joins the set when it gains a page." Every screens.Manifest
|
|
// entry with an Instance.LinkPrefix under an operator detail route must
|
|
// have a matching entry in lookupRegistry.
|
|
func TestLookupRegistryCoversManifest(t *testing.T) {
|
|
registered := map[string]bool{}
|
|
for _, c := range lookupRegistry {
|
|
registered[c.LinkPrefix] = true
|
|
}
|
|
for _, screen := range screens.Manifest {
|
|
if screen.Instance == nil || screen.Surface != "operator" {
|
|
continue
|
|
}
|
|
prefix := screen.Instance.LinkPrefix
|
|
if !registered[prefix] {
|
|
t.Errorf("manifest detail route %q (screen %q) has no resolver in lookupRegistry", prefix, screen.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|