Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37022f34ae | ||
|
|
75dae858b3 |
@@ -10,30 +10,54 @@ import (
|
||||
"github.com/hanzoai/cloud/clients/commerce/models/store"
|
||||
)
|
||||
|
||||
// getCurrent returns the first (default) store for the authenticated org.
|
||||
// The admin dashboard calls GET /store/current to resolve the active store context.
|
||||
// getCurrent returns the authenticated org's default store. It resolves the store
|
||||
// from the org's OWN namespaced datastore (NOT the shared system DB) and lazily
|
||||
// provisions it on first use. The admin dashboard and the content storefront edge
|
||||
// (clients/content/storefront.go currentStore) both call GET /store/current to
|
||||
// resolve the active store id; reading the shared system DB always returned the
|
||||
// phantom "default", which the storefront edge treats as unconfigured — so it
|
||||
// skipped publishing product imagery for every org.
|
||||
func getCurrent(c *gin.Context) {
|
||||
ctx := middleware.GetContext(c)
|
||||
db := datastore.New(ctx)
|
||||
|
||||
var s store.Store
|
||||
s.Init(db)
|
||||
|
||||
q := s.Query().All().Limit(1)
|
||||
|
||||
var stores []store.Store
|
||||
if _, err := q.GetAll(&stores); err != nil || len(stores) == 0 {
|
||||
// Return a minimal default so the dashboard can render
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"store": gin.H{
|
||||
"id": "default",
|
||||
"name": "Default Store",
|
||||
"default_currency": "usd",
|
||||
"currencies": []string{"usd"},
|
||||
},
|
||||
})
|
||||
org, ok := middleware.GetOrganizationOK(c)
|
||||
if !ok {
|
||||
// No authenticated org in context: return a minimal default so the dashboard
|
||||
// still renders, exactly as before. The route now runs behind tokenRequired,
|
||||
// so real callers (dashboard IAM, storefront service token) always have an org.
|
||||
c.JSON(http.StatusOK, defaultStorePayload())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"store": stores[0]})
|
||||
// Resolve the caller org's OWN store — the SAME per-org namespace the write path
|
||||
// (listing.go orgNamespacedDB, rest.newEntity) persists into.
|
||||
db := datastore.NewNamespaced(org.Namespaced(c))
|
||||
|
||||
// Return the org's existing store if it already has one (any slug).
|
||||
var stores []store.Store
|
||||
if _, err := store.New(db).Query().All().Limit(1).GetAll(&stores); err == nil && len(stores) > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"store": stores[0]})
|
||||
return
|
||||
}
|
||||
|
||||
// First authenticated visit for an org with no store yet: lazily provision the
|
||||
// canonical default store (idempotent, org-scoped, no payment creds) so the
|
||||
// storefront edge resolves a REAL store id instead of the phantom "default".
|
||||
s, err := store.EnsureDefault(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, defaultStorePayload())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"store": s})
|
||||
}
|
||||
|
||||
// defaultStorePayload is the minimal fallback used only when no org context is
|
||||
// present or provisioning fails — never the normal path for an authenticated org.
|
||||
func defaultStorePayload() gin.H {
|
||||
return gin.H{
|
||||
"store": gin.H{
|
||||
"id": "default",
|
||||
"name": "Default Store",
|
||||
"default_currency": "usd",
|
||||
"currencies": []string{"usd"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// Copyright © 2026 Hanzo AI. MIT License.
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/hanzoai/cloud/clients/commerce/datastore"
|
||||
"github.com/hanzoai/cloud/clients/commerce/datastore/query"
|
||||
"github.com/hanzoai/cloud/clients/commerce/db"
|
||||
"github.com/hanzoai/cloud/clients/commerce/models/organization"
|
||||
commercestore "github.com/hanzoai/cloud/clients/commerce/models/store"
|
||||
)
|
||||
|
||||
// withResolver installs the SAME per-org DB resolver the production commerce
|
||||
// bootstrap installs (commerce.go: datastore.SetOrgDBResolver(app.DB.Org)), so
|
||||
// datastore.NewNamespaced routes each org to its OWN physical store — the wiring
|
||||
// the getCurrent fix depends on. Mirrors clients/commerce/test/perorg.
|
||||
func withResolver(t *testing.T) func() {
|
||||
t.Helper()
|
||||
dir, err := os.MkdirTemp("", "storecurrent-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := db.DefaultConfig()
|
||||
cfg.DataDir = dir
|
||||
cfg.EnableVectorSearch = false
|
||||
cfg.EnableDatastore = false
|
||||
mgr, err := db.NewManager(cfg)
|
||||
if err != nil {
|
||||
os.RemoveAll(dir)
|
||||
t.Fatalf("NewManager: %v", err)
|
||||
}
|
||||
sys, err := mgr.Org("system")
|
||||
if err != nil {
|
||||
mgr.Close()
|
||||
os.RemoveAll(dir)
|
||||
t.Fatalf("Org(system): %v", err)
|
||||
}
|
||||
datastore.SetDefaultDB(sys)
|
||||
query.SetDefaultDB(sys)
|
||||
datastore.SetOrgDBResolver(mgr.Org)
|
||||
|
||||
return func() {
|
||||
datastore.SetOrgDBResolver(nil)
|
||||
datastore.SetDefaultDB(nil)
|
||||
query.SetDefaultDB(nil)
|
||||
mgr.Close()
|
||||
os.RemoveAll(dir)
|
||||
}
|
||||
}
|
||||
|
||||
type storeResp struct {
|
||||
Store struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
} `json:"store"`
|
||||
}
|
||||
|
||||
// callCurrent drives the real getCurrent handler with the given org bound in
|
||||
// context exactly as middleware.TokenRequired does (c.Set("organization", …)),
|
||||
// and returns the decoded response. An empty org omits the binding, exercising
|
||||
// the no-identity fallback.
|
||||
func callCurrent(t *testing.T, org string) storeResp {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1/store/current", nil)
|
||||
if org != "" {
|
||||
c.Set("organization", &organization.Organization{Name: org})
|
||||
}
|
||||
|
||||
getCurrent(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("org %q: getCurrent status = %d, want 200", org, w.Code)
|
||||
}
|
||||
var resp storeResp
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("org %q: decode body %q: %v", org, w.Body.String(), err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// TestGetCurrentProvisionsOrgScopedStore is the round-trip proof for the fix: an
|
||||
// authenticated org's GET /v1/store/current resolves (and lazily provisions) a
|
||||
// REAL org-scoped store id — never the phantom shared "default" — the store id
|
||||
// the content storefront edge needs to publish product imagery.
|
||||
func TestGetCurrentProvisionsOrgScopedStore(t *testing.T) {
|
||||
defer withResolver(t)()
|
||||
|
||||
// First visit for an org with no store yet → a real store id is provisioned.
|
||||
karma := callCurrent(t, "karma")
|
||||
if karma.Store.ID == "" || karma.Store.ID == "default" {
|
||||
t.Fatalf("karma store id = %q, want a real provisioned id (not empty/\"default\")", karma.Store.ID)
|
||||
}
|
||||
if karma.Store.Slug != commercestore.DefaultSlug {
|
||||
t.Fatalf("karma store slug = %q, want %q", karma.Store.Slug, commercestore.DefaultSlug)
|
||||
}
|
||||
|
||||
// Idempotent: a second visit returns the SAME store, not a duplicate.
|
||||
karma2 := callCurrent(t, "karma")
|
||||
if karma2.Store.ID != karma.Store.ID {
|
||||
t.Fatalf("second GET /store/current provisioned a new store: %q != %q", karma2.Store.ID, karma.Store.ID)
|
||||
}
|
||||
|
||||
// Org isolation: a different org resolves its OWN store, never karma's.
|
||||
other := callCurrent(t, "acme")
|
||||
if other.Store.ID == "" || other.Store.ID == "default" {
|
||||
t.Fatalf("acme store id = %q, want a real provisioned id", other.Store.ID)
|
||||
}
|
||||
if other.Store.ID == karma.Store.ID {
|
||||
t.Fatalf("CROSS-TENANT LEAK: acme resolved karma's store id %q", karma.Store.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetCurrentNoOrgFallsBackToDefault proves the endpoint degrades cleanly (no
|
||||
// panic, no 500) when reached with no org in context — returning the minimal
|
||||
// default payload rather than provisioning against a missing tenant.
|
||||
func TestGetCurrentNoOrgFallsBackToDefault(t *testing.T) {
|
||||
defer withResolver(t)()
|
||||
|
||||
resp := callCurrent(t, "")
|
||||
if resp.Store.ID != "default" {
|
||||
t.Fatalf("no-org store id = %q, want \"default\"", resp.Store.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureDefaultIsIdempotent proves the canonical provisioning primitive is
|
||||
// idempotent at the datastore layer: repeated calls in one org's namespace return
|
||||
// the SAME store, and a second org gets a distinct one.
|
||||
func TestEnsureDefaultIsIdempotent(t *testing.T) {
|
||||
defer withResolver(t)()
|
||||
|
||||
ctxA := (&organization.Organization{Name: "org-a"}).Namespaced(context.Background())
|
||||
a1, err := commercestore.EnsureDefault(datastore.NewNamespaced(ctxA))
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureDefault org-a: %v", err)
|
||||
}
|
||||
a2, err := commercestore.EnsureDefault(datastore.NewNamespaced(ctxA))
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureDefault org-a (2): %v", err)
|
||||
}
|
||||
if a1.Id() == "" || a1.Id() != a2.Id() {
|
||||
t.Fatalf("EnsureDefault not idempotent: %q vs %q", a1.Id(), a2.Id())
|
||||
}
|
||||
|
||||
ctxB := (&organization.Organization{Name: "org-b"}).Namespaced(context.Background())
|
||||
b1, err := commercestore.EnsureDefault(datastore.NewNamespaced(ctxB))
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureDefault org-b: %v", err)
|
||||
}
|
||||
if b1.Id() == a1.Id() {
|
||||
t.Fatalf("CROSS-TENANT: org-b store id == org-a store id (%q)", a1.Id())
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,12 @@ func Route(router router.Router, args ...gin.HandlerFunc) {
|
||||
|
||||
api := rest.New(store.Store{})
|
||||
|
||||
// Admin dashboard expects /store/current to return the default store.
|
||||
api.GET("/current", getCurrent)
|
||||
// Admin dashboard + the content storefront edge expect /store/current to return
|
||||
// the caller org's store. getCurrent resolves the org FROM CONTEXT, so /current
|
||||
// must run behind the same base auth gate (args = tokenRequired) that sets it —
|
||||
// custom sub-routes do NOT inherit the base CRUD middleware (see util/rest.Route).
|
||||
current := append(append([]gin.HandlerFunc{}, args...), getCurrent)
|
||||
api.GET("/current", current...)
|
||||
|
||||
// Mint the org's least-privilege Published storefront read key (design
|
||||
// path b). Admin-gated + org-bound; the returned token is stored in KMS and
|
||||
|
||||
@@ -213,6 +213,28 @@ func New(db *datastore.Datastore) *Store {
|
||||
return s
|
||||
}
|
||||
|
||||
// DefaultSlug is the stable slug of the store every org gets on first use. It is
|
||||
// safe to reuse across tenants because each org's merchant rows live in its OWN
|
||||
// namespaced store (datastore.NewNamespaced), so the slug never collides.
|
||||
const DefaultSlug = "default"
|
||||
|
||||
// EnsureDefault returns the org's default store, creating it on first use. This is
|
||||
// the ONE canonical way an org gets its commerce store: idempotent (keyed by the
|
||||
// stable DefaultSlug), org-scoped (db MUST be a per-org datastore.NewNamespaced),
|
||||
// and carrying NO payment credentials — binding Square/Stripe stays a separate
|
||||
// business step. It replaces the long-dead commented-out provisioning in
|
||||
// util/provision: a store is lazily provisioned at the first authenticated
|
||||
// GET /v1/store/current instead of eagerly at org creation.
|
||||
func EnsureDefault(db *datastore.Datastore) (*Store, error) {
|
||||
s := New(db)
|
||||
s.Name = "Default Store"
|
||||
s.Slug = DefaultSlug
|
||||
if err := s.GetOrCreate("Slug=", DefaultSlug); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func Query(db *datastore.Datastore) datastore.Query {
|
||||
return db.Query("store")
|
||||
}
|
||||
|
||||
@@ -17,23 +17,8 @@ func Provision(org *organization.Organization, usr *user.User) {
|
||||
usr.MustCreate()
|
||||
}
|
||||
|
||||
// // Figure out ownership
|
||||
// if usr.Organizations == nil {
|
||||
// org.Owners
|
||||
// }
|
||||
|
||||
// // Create default store
|
||||
// stor := store.New(nsdb)
|
||||
// stor.Name = "development"
|
||||
// stor.GetOrCreate("Name=", stor.Name)
|
||||
// stor.MustSetKey("KawdtZuoMY")
|
||||
// stor.Prefix = "/"
|
||||
// stor.Currency = currency.USD
|
||||
// stor.Mailchimp.APIKey = ""
|
||||
// stor.Mailchimp.ListId = "421751eb03"
|
||||
// stor.MustUpdate()
|
||||
|
||||
// org.AddDefaultTokens()
|
||||
|
||||
// org.MustUpdate()
|
||||
// The org's commerce store is NOT provisioned here: it is created lazily and
|
||||
// idempotently on the org's first authenticated GET /v1/store/current, via the
|
||||
// ONE canonical primitive store.EnsureDefault (org-scoped, no payment creds).
|
||||
// See clients/commerce/api/store/current.go.
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
// cek opens the store encrypted at rest (migrate-on-open + shred).
|
||||
"github.com/hanzoai/cloud/cek"
|
||||
@@ -99,9 +100,34 @@ CREATE INDEX IF NOT EXISTS ix_marketing_campaigns_org_channel ON marketing_campa
|
||||
if _, err := s.db.Exec(ddl); err != nil {
|
||||
return fmt.Errorf("marketing migrate campaigns: %w", err)
|
||||
}
|
||||
// Idempotent column upgrades for a pre-existing prod table created before a
|
||||
// column was added to the DDL above. CREATE TABLE IF NOT EXISTS never alters an
|
||||
// existing table, so an old marketing_campaigns is frozen at its original schema
|
||||
// and every write of a newer column 500s ("table marketing_campaigns has no
|
||||
// column named scheduled_at"). ADD COLUMN on a column that already exists is the
|
||||
// only error swallowed (see addColumn). The store is an encrypted single-file
|
||||
// SQLite only the binary can open, so this migrate-on-open is the ONLY way an old
|
||||
// prod table gains the column.
|
||||
for _, ac := range []struct{ table, col, def string }{
|
||||
{"marketing_campaigns", "scheduled_at", "INTEGER NOT NULL DEFAULT 0"},
|
||||
} {
|
||||
if err := s.addColumn(ac.table, ac.col, ac.def); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addColumn adds a column to an existing table, treating an already-present column
|
||||
// as success (SQLite has no ADD COLUMN IF NOT EXISTS). Mirrors clients/social/store.go.
|
||||
func (s *Store) addColumn(table, col, def string) error {
|
||||
_, err := s.db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, col, def))
|
||||
if err == nil || strings.Contains(err.Error(), "duplicate column name") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("marketing migrate: add %s.%s: %w", table, col, err)
|
||||
}
|
||||
|
||||
// Close closes the underlying database. Idempotent-safe via sql.DB.
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright © 2026 Hanzo AI. MIT License.
|
||||
|
||||
package marketing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/hanzoai/cloud/cek"
|
||||
)
|
||||
|
||||
// TestMigrateUpgradesOldCampaignsTable is the regression for the prod 500
|
||||
// "table marketing_campaigns has no column named scheduled_at": a marketing_campaigns
|
||||
// table created BEFORE scheduled_at was added to the DDL must gain the column on the
|
||||
// next store open. CREATE TABLE IF NOT EXISTS never alters an existing table, so
|
||||
// without the additive ALTER an old prod table is frozen at its original schema and
|
||||
// every campaign write 500s. The store is an encrypted single-file SQLite only the
|
||||
// binary can open, so migrate-on-open is the ONLY upgrade path (no hand-patch).
|
||||
func TestMigrateUpgradesOldCampaignsTable(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "marketing.db")
|
||||
|
||||
// Seed a prod-shaped OLD DB: marketing_campaigns WITHOUT scheduled_at, plus an
|
||||
// existing row — exactly what a pre-scheduling prod deployment holds. Written
|
||||
// through cek so the on-disk format matches what openStore reads back.
|
||||
raw, err := cek.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("cek.Open (seed old db): %v", err)
|
||||
}
|
||||
if _, err := raw.Exec(`CREATE TABLE marketing_campaigns (
|
||||
id TEXT PRIMARY KEY,
|
||||
org TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
channel TEXT NOT NULL DEFAULT 'email',
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
objective TEXT NOT NULL DEFAULT '',
|
||||
budget INTEGER NOT NULL DEFAULT 0,
|
||||
spend INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);`); err != nil {
|
||||
t.Fatalf("create old-schema table: %v", err)
|
||||
}
|
||||
if _, err := raw.Exec(`INSERT INTO marketing_campaigns
|
||||
(id,org,name,channel,status,objective,budget,spend,created_at,updated_at)
|
||||
VALUES ('old1','karma','Legacy','email','active','',0,0,1,2)`); err != nil {
|
||||
t.Fatalf("seed legacy row: %v", err)
|
||||
}
|
||||
_ = raw.Close()
|
||||
|
||||
// Open through the real store: migrate() must ADD the scheduled_at column to the
|
||||
// existing table (idempotent — swallows "duplicate column name" on a fresh DB).
|
||||
s, err := openStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("openStore (migrate must upgrade old table): %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// The exact prod repro: a create that writes scheduled_at must now SUCCEED, not
|
||||
// 500 with "no column named scheduled_at".
|
||||
if _, err := s.CreateCampaign(ctx, Campaign{
|
||||
ID: "new1", Org: "karma", Name: "Spring", Channel: "email",
|
||||
Status: "draft", ScheduledAt: 123, CreatedAt: 10, UpdatedAt: 10,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateCampaign after migrate must not 500 on scheduled_at: %v", err)
|
||||
}
|
||||
got, err := s.GetCampaign(ctx, "karma", "new1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetCampaign new: %v", err)
|
||||
}
|
||||
if got.ScheduledAt != 123 {
|
||||
t.Fatalf("scheduled_at = %d, want 123", got.ScheduledAt)
|
||||
}
|
||||
|
||||
// The legacy row survived the upgrade and defaults scheduled_at to 0.
|
||||
old, err := s.GetCampaign(ctx, "karma", "old1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetCampaign legacy: %v", err)
|
||||
}
|
||||
if old.ScheduledAt != 0 || old.Name != "Legacy" {
|
||||
t.Fatalf("legacy row corrupted after migrate: %+v", old)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateOnFreshDBIsIdempotent proves the additive ALTER is a no-op on a fresh
|
||||
// DB (the column already exists via the CREATE) and that re-opening never errors.
|
||||
func TestMigrateOnFreshDBIsIdempotent(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "marketing.db")
|
||||
|
||||
s, err := openStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("openStore fresh: %v", err)
|
||||
}
|
||||
_ = s.Close()
|
||||
|
||||
// Re-open: migrate runs again; the ALTER must swallow "duplicate column name".
|
||||
s2, err := openStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("re-open must be idempotent: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s2.Close() })
|
||||
|
||||
if _, err := s2.CreateCampaign(context.Background(), Campaign{
|
||||
ID: "c1", Org: "karma", Name: "x", Channel: "email", Status: "draft",
|
||||
ScheduledAt: 7, CreatedAt: 1, UpdatedAt: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateCampaign on fresh+reopened db: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user