diff --git a/README.md b/README.md index deea31e..9dedae0 100644 --- a/README.md +++ b/README.md @@ -167,37 +167,64 @@ calls are prefix-aware: The published GHCR image is runtime-prefix safe. You do not need to rebuild the frontend for each mount path. -## Workspaces +## Run-Scoped Isolation Billtap can hold several fully isolated billing datasets in one running server, so parallel test suites do not have to restart Billtap or reset shared state -between runs. +between runs. The Stripe-compatible service URL can include a run scope: -- Requests with no workspace selector use the `default` workspace, backed by +```text +http://billtap:8080/runs/ +``` + +Stripe SDKs can keep their normal `/v1/...` paths because the SDK appends them +under that base URL. + +- Requests with no run selector use the `default` run, backed by the configured `database_url`. Existing integrations keep working unchanged. -- Name a workspace to get an independent dataset (its own customers, invoices, +- Name a run to get an independent dataset (its own customers, invoices, webhooks, idempotency keys, and test clocks). It is created on first use. -- Select a workspace with the `X-Billtap-Workspace` request header or the - `workspace` query parameter. The resolved name is echoed on the - `X-Billtap-Workspace` response header. +- Select a run with `/runs//...`. The resolved name is echoed on + `X-Billtap-Run-Id` and `X-Billtap-Workspace`. +- For backward compatibility, `X-Billtap-Workspace` and the `workspace` query + parameter still select the same isolated run on unprefixed requests. +- `DELETE /runs/` removes that run's dataset. `GET /admin/runs` lists + known runs and row-count summaries. ```bash -# default workspace (backward compatible) +# default run (backward compatible) curl http://localhost:8080/v1/customers # isolated dataset for one test suite +curl http://localhost:8080/runs/suite-a/v1/customers +curl http://localhost:8080/runs/suite-a/v1/webhook_endpoints + +# legacy workspace selectors, mapped to runs curl -H 'X-Billtap-Workspace: suite-a' http://localhost:8080/v1/customers curl 'http://localhost:8080/v1/customers?workspace=suite-a' -# list known workspaces +# list and clean up runs +curl http://localhost:8080/admin/runs +curl -X DELETE http://localhost:8080/runs/suite-a + +# legacy listing alias curl http://localhost:8080/workspaces ``` -Workspace names accept letters, digits, `.`, `-`, and `_`, must start with a -letter or digit, and are case-insensitive. Each named workspace is stored next -to the default database under a `workspaces/` directory (for example -`.billtap/workspaces/suite-a.db`). +Run IDs accept letters, digits, `.`, `-`, and `_`, must start with a letter or +digit, and are case-insensitive. Each named run is stored next to the default +database as an isolated SQLite file. For compatibility with earlier Billtap +builds, those files currently live under the existing `workspaces/` directory +(for example `.billtap/workspaces/suite-a.db`). + +Fixture packs can also be applied directly to a run: + +```bash +go run ./cmd/billtap seed --run-id suite-a --pack seed/sample-basic.yml +``` +When the fixture pack has a top-level `runId`, that value is used for the run +scope and fixture metadata. ## Fixture And Assertion APIs Billtap includes local integration-test helpers: diff --git a/cmd/billtap/main.go b/cmd/billtap/main.go index 0e79f6b..8b1e3cd 100644 --- a/cmd/billtap/main.go +++ b/cmd/billtap/main.go @@ -16,6 +16,7 @@ import ( "github.com/hckim/billtap/internal/billing" "github.com/hckim/billtap/internal/compatibility" "github.com/hckim/billtap/internal/config" + "github.com/hckim/billtap/internal/fixtures" "github.com/hckim/billtap/internal/scenarios" "github.com/hckim/billtap/internal/server" "github.com/hckim/billtap/internal/storage" @@ -33,6 +34,8 @@ func main() { os.Exit(runScenario(args[1:])) case "compatibility": os.Exit(runCompatibility(args[1:])) + case "seed": + os.Exit(runSeed(args[1:])) } } @@ -62,7 +65,7 @@ func main() { appServer := server.New(server.Options{Config: cfg, Store: store}) defer func() { if err := appServer.Close(); err != nil { - slog.Warn("close workspaces", "error", err) + slog.Warn("close runs", "error", err) } }() @@ -88,6 +91,125 @@ func main() { } } +func runSeed(args []string) int { + packPath, runIDFlag, databaseURL, configPath, err := parseSeedArgs(args) + if err != nil { + fmt.Fprintln(os.Stderr, err) + fmt.Fprintln(os.Stderr, "usage: billtap seed --pack path [--run-id id] [--database-url dsn] [--config path]") + return scenarios.ExitInvalidConfig + } + cfg, err := config.Load(configPath) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return scenarios.ExitInvalidConfig + } + if strings.TrimSpace(databaseURL) != "" { + cfg.DatabaseURL = strings.TrimSpace(databaseURL) + } + body, err := os.ReadFile(packPath) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return scenarios.ExitInvalidConfig + } + pack, err := fixtures.LoadPack(body, fixtureContentType(packPath)) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return scenarios.ExitInvalidConfig + } + runID, err := server.NormalizeRunID(firstSeedValue(pack.RunID, runIDFlag, server.DefaultRun)) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return scenarios.ExitInvalidConfig + } + pack.RunID = runID + + ctx := context.Background() + store, err := storage.OpenSQLite(ctx, server.RunDSN(cfg.DatabaseURL, runID)) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return scenarios.ExitRuntimeFailure + } + defer func() { + if err := store.Close(); err != nil { + slog.Warn("close seed store", "error", err) + } + }() + result, err := fixtures.NewService(billing.NewService(store)).Apply(ctx, pack) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return scenarios.ExitRuntimeFailure + } + fmt.Fprintf(os.Stdout, "seeded fixture %s runId=%s customers=%d products=%d prices=%d subscriptions=%d test_clocks=%d\n", + result.Name, + result.RunID, + result.Summary["customers"], + result.Summary["products"], + result.Summary["prices"], + result.Summary["subscriptions"], + result.Summary["test_clocks"], + ) + return scenarios.ExitPass +} + +func parseSeedArgs(args []string) (packPath string, runID string, databaseURL string, configPath string, err error) { + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--pack" || arg == "--run-id" || arg == "--database-url" || arg == "--config": + if i+1 >= len(args) { + return "", "", "", "", fmt.Errorf("%s requires a value", arg) + } + i++ + switch arg { + case "--pack": + packPath = args[i] + case "--run-id": + runID = args[i] + case "--database-url": + databaseURL = args[i] + case "--config": + configPath = args[i] + } + case strings.HasPrefix(arg, "--pack="): + packPath = strings.TrimPrefix(arg, "--pack=") + case strings.HasPrefix(arg, "--run-id="): + runID = strings.TrimPrefix(arg, "--run-id=") + case strings.HasPrefix(arg, "--database-url="): + databaseURL = strings.TrimPrefix(arg, "--database-url=") + case strings.HasPrefix(arg, "--config="): + configPath = strings.TrimPrefix(arg, "--config=") + case strings.HasPrefix(arg, "-"): + return "", "", "", "", fmt.Errorf("unknown flag %s", arg) + default: + if packPath != "" { + return "", "", "", "", fmt.Errorf("multiple fixture packs provided") + } + packPath = arg + } + } + if strings.TrimSpace(packPath) == "" { + return "", "", "", "", fmt.Errorf("fixture pack path is required") + } + return packPath, runID, databaseURL, configPath, nil +} + +func fixtureContentType(path string) string { + lower := strings.ToLower(path) + if strings.HasSuffix(lower, ".yaml") || strings.HasSuffix(lower, ".yml") { + return "application/yaml" + } + return "application/json" +} + +func firstSeedValue(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + func runCompatibility(args []string) int { if len(args) == 0 { fmt.Fprintln(os.Stderr, "usage: billtap compatibility [flags]") diff --git a/cmd/billtap/main_test.go b/cmd/billtap/main_test.go index 8679d81..7187f70 100644 --- a/cmd/billtap/main_test.go +++ b/cmd/billtap/main_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "net/http" "net/http/httptest" "os" @@ -8,7 +9,10 @@ import ( "strings" "testing" + "github.com/hckim/billtap/internal/billing" "github.com/hckim/billtap/internal/scenarios" + "github.com/hckim/billtap/internal/server" + "github.com/hckim/billtap/internal/storage" ) func TestParseScenarioRunArgsAllowsFlagsAfterFile(t *testing.T) { @@ -120,6 +124,37 @@ func TestRunCompatibilityInventoryRequiresOpenAPIPath(t *testing.T) { } } +func TestRunSeedUsesFixtureRunIDBeforeFlag(t *testing.T) { + dir := t.TempDir() + packPath := filepath.Join(dir, "seed.yml") + dbPath := filepath.Join(dir, "billtap.db") + writeFile(t, packPath, ` +name: seed-pack +runId: yaml-run +customers: + - id: cus_seeded + email: seeded@example.test +`) + code := runSeed([]string{"--pack", packPath, "--run-id", "flag-run", "--database-url", dbPath}) + if code != scenarios.ExitPass { + t.Fatalf("exit code = %d, want %d", code, scenarios.ExitPass) + } + + ctx := context.Background() + store, err := storage.OpenSQLite(ctx, server.RunDSN(dbPath, "yaml-run")) + if err != nil { + t.Fatalf("open seeded run store: %v", err) + } + defer store.Close() + customers, err := billing.NewService(store).ListCustomers(ctx) + if err != nil { + t.Fatalf("list customers: %v", err) + } + if len(customers) != 1 || customers[0].ID != "cus_seeded" || customers[0].Metadata["billtap_fixture_run_id"] != "yaml-run" { + t.Fatalf("customers = %#v, want fixture run customer", customers) + } +} + func TestRunScenarioReturnsInvalidConfigExitCode(t *testing.T) { dir := t.TempDir() scenarioPath := filepath.Join(dir, "bad.yml") diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a0773fc..d804a93 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -68,13 +68,14 @@ Initial default: - in-memory option for unit tests One running server can host several isolated billing datasets. The implicit -`default` workspace is backed by the configured `database_url`; each named -workspace (selected per request via the `X-Billtap-Workspace` header or -`workspace` query parameter) opens its own SQLite database lazily under a -sibling `workspaces/` directory and gets an independent API handler, so its -billing state, webhooks, idempotency keys, and test clocks stay isolated. - -Tables (per workspace): +`default` run is backed by the configured `database_url`; each named run +selected through `/runs/` opens its own SQLite database lazily under the +existing sibling `workspaces/` directory and gets an independent API handler, so +its billing state, webhooks, idempotency keys, and test clocks stay isolated. +The legacy `X-Billtap-Workspace` header and `workspace` query parameter remain +aliases for the same run partition. + +Tables (per run): - customers - products diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 82103dc..5a424a2 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -87,10 +87,12 @@ documentation before it counts as implemented. ## Supported Stripe-Like API Subset -Base path: `/v1`. When `PUBLIC_BASE_PATH` or `BILLTAP_PUBLIC_BASE_PATH` is set, -or a proxy sends `X-Forwarded-Prefix`, the same API is available below that -browser-facing prefix, such as `/billtap/v1`. Internal service traffic can keep -using the unprefixed service URL. +Base path: `/v1`. A parallel test run can scope the same Stripe-like API under +`/runs//v1`; unscoped requests use the backward-compatible `default` +run. When `PUBLIC_BASE_PATH` or `BILLTAP_PUBLIC_BASE_PATH` is set, or a proxy +sends `X-Forwarded-Prefix`, the same API is available below that browser-facing +prefix, such as `/billtap/v1` or `/billtap/runs//v1`. Internal service +traffic can keep using the unprefixed service URL. | Resource | Endpoints | Level | Scope | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -120,6 +122,10 @@ using the unprefixed service URL. | Webhook endpoints | `POST /v1/webhook_endpoints`, `GET /v1/webhook_endpoints`, `GET /v1/webhook_endpoints/{id}`, `POST /v1/webhook_endpoints/{id}`, `PATCH /v1/webhook_endpoints/{id}`, `DELETE /v1/webhook_endpoints/{id}`, `GET /v1/webhook_endpoints/{id}/attempts` | Supported | Manage local webhook endpoints and inspect endpoint-scoped delivery attempts. Secrets are generated when omitted and masked in API responses. `enabled_events` supports exact event names, `*`, and prefix wildcards such as `invoice.*`. `PATCH` accepts the same local mutable fields as `POST`, including the `enabled` alias for `active`. | | Events | `GET /v1/events`, `GET /v1/events/{id}` | Supported | List and retrieve Billtap-created events. Filters include `type`, `scenarioRunId`, `created[gte]`, `created[gt]`, `created[lte]`, `created[lt]`, `data.object.customer`, and `data.object.metadata[key]`. | +All list and search endpoints are scoped by the selected run. Webhook endpoints +registered through `/runs//v1/webhook_endpoints` receive only events +emitted in that run, and local test clocks are isolated the same way. + ## Billtap APIs Base path: `/api` @@ -136,6 +142,10 @@ Base path: `/api` | Scenarios | `POST /api/scenarios/run` | Runs a scenario JSON object or YAML payload and returns the scenario report. | | Boundary controls | `GET /api/audit-log`, `POST /api/retention/apply` | Audit and retention controls for replay, delivery overrides, and raw evidence redaction. | +Billtap-only `/api` endpoints are also available under `/runs//api` for +run-scoped checkout completion, fixture apply/snapshot/assert, diagnostics, and +webhook replay workflows. + ## Webhook Compatibility Claim Billtap emits Stripe-style event envelopes for the supported checkout sequence diff --git a/docs/TESTING.md b/docs/TESTING.md index 342a393..08fce87 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -81,11 +81,12 @@ Fixture ergonomics for integration tests: - assert expected objects through `POST /api/fixtures/assert` - keep fixture IDs stable for customer, product, and price setup - use fixture `runId`, `namespace`, `tenantId`, and `ref` metadata to isolate repeated local/CI runs -- for stronger isolation, run parallel suites against separate Billtap - workspaces instead of restarting the server between sets: send - `X-Billtap-Workspace: ` (or `?workspace=`) so each suite gets an - independent dataset, while unselected requests keep using the `default` - workspace; `GET /workspaces` lists what exists +- for stronger isolation, run parallel suites against separate Billtap runs + instead of restarting the server between sets: set the Stripe-compatible API + base to `/runs/` so each suite gets an independent dataset, while + unselected requests keep using the `default` run; `GET /admin/runs` lists + what exists. `X-Billtap-Workspace` and `?workspace=` remain legacy + aliases for unprefixed requests. Integration diagnostics for failed app runs: diff --git a/docs/decisions/0004-run-scoped-isolation.md b/docs/decisions/0004-run-scoped-isolation.md new file mode 100644 index 0000000..f8d67fe --- /dev/null +++ b/docs/decisions/0004-run-scoped-isolation.md @@ -0,0 +1,41 @@ +# 0004 Run-Scoped Isolation + +## Status + +Accepted + +## Context + +Parallel billing test suites need to share one Billtap server without leaking +Stripe-compatible objects, webhook endpoint registrations, idempotency state, or +test clocks across jobs. Existing integrations also need unprefixed `/v1/...` +requests to keep using the default dataset. + +## Decision + +Billtap exposes run-scoped routing at `/runs//v1/...` and +`/runs//api/...`. Unscoped requests use the `default` run. + +The current implementation maps each named run to one isolated SQLite store. +The default run uses the configured `database_url`; named runs use sibling +SQLite databases under the existing `workspaces/` directory for on-disk +compatibility with earlier Billtap builds. This preserves duplicate Stripe +object IDs across runs and isolates webhook fan-out, test clocks, traces, and +fixture state without forcing a risky all-table primary-key migration in the +same change. + +`GET /admin/runs` reports known runs and row-count summaries. `DELETE +/runs/` removes a named run store; `DELETE /runs/default` clears user data +from the default store while retaining schema metadata. + +## Consequences + +- Stripe SDK users can set the API base to `http://billtap:8080/runs/` + and keep normal SDK paths. +- Hosted checkout and portal URLs generated from a run-scoped API request retain + the `/runs/` prefix. +- Existing `X-Billtap-Workspace` and `workspace` query selectors remain + supported as compatibility aliases. +- A future row-level `run_id` schema can be added if a single physical SQLite + file becomes required, but the public isolation contract does not depend on + that migration. diff --git a/internal/api/api.go b/internal/api/api.go index c99f262..890f7db 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -125,6 +125,8 @@ func (h *Handler) routes() { h.mux.HandleFunc("/v1/test_helpers/customers/", h.handleTestHelperCustomer) h.mux.HandleFunc("/v1/test_helpers/test_clocks", h.handleTestClocks) h.mux.HandleFunc("/v1/test_helpers/test_clocks/", h.handleTestClock) + h.mux.HandleFunc("/v1/test_clocks", h.handleTestClocks) + h.mux.HandleFunc("/v1/test_clocks/", h.handleTestClockAlias) h.mux.HandleFunc("/v1/payment_methods", h.handlePaymentMethods) h.mux.HandleFunc("/v1/payment_methods/", h.handlePaymentMethod) h.mux.HandleFunc("/v1/webhook_endpoints", h.handleWebhookEndpoints) @@ -3151,6 +3153,15 @@ func (h *Handler) handleTestClock(w http.ResponseWriter, r *http.Request) { writeResult(w, response, err) } +func (h *Handler) handleTestClockAlias(w http.ResponseWriter, r *http.Request) { + clone := r.Clone(r.Context()) + u := *r.URL + u.Path = "/v1/test_helpers/test_clocks/" + strings.TrimPrefix(r.URL.Path, "/v1/test_clocks/") + u.RawPath = "" + clone.URL = &u + h.handleTestClock(w, clone) +} + func (h *Handler) handlePaymentMethods(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: @@ -3740,6 +3751,7 @@ func (h *Handler) handleFixtureApply(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, fmt.Errorf("%w: %v", billing.ErrInvalidInput, err)) return } + pack = applyRequestRunID(r, pack) result, err := fixtures.NewService(h.billing).Apply(r.Context(), pack) if err != nil { if errors.Is(err, fixtures.ErrAssertionFailed) { @@ -3775,6 +3787,7 @@ func (h *Handler) handleFixtureValidate(w http.ResponseWriter, r *http.Request) writeError(w, http.StatusBadRequest, fmt.Errorf("%w: %v", billing.ErrInvalidInput, err)) return } + pack = applyRequestRunID(r, pack) if err := fixtures.NewService(h.billing).Validate(pack); err != nil { writeResult(w, nil, err) return @@ -3872,7 +3885,7 @@ func (h *Handler) handleFixtureResolve(w http.ResponseWriter, r *http.Request) { } result, err := fixtures.NewService(h.billing).Resolve(r.Context(), fixtures.ResolveFilter{ Ref: firstQuery(r, "ref", "id", "lookup_key", "lookupKey"), - RunID: firstQuery(r, "runId", "run_id"), + RunID: firstNonEmptyString(firstQuery(r, "runId", "run_id"), requestRunID(r)), FixtureName: firstQuery(r, "fixtureName", "fixture_name", "name"), Namespace: firstQuery(r, "namespace"), TenantID: firstQuery(r, "tenantId", "tenant_id"), @@ -3895,6 +3908,9 @@ func (h *Handler) handleFixtureAssert(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, fmt.Errorf("%w: %v", billing.ErrInvalidInput, err)) return } + if req.Filter.RunID == "" { + req.Filter.RunID = requestRunID(r) + } report, err := fixtures.NewService(h.billing).Assert(r.Context(), req) if err != nil { if errors.Is(err, fixtures.ErrAssertionFailed) { @@ -3932,13 +3948,24 @@ func decodeLooseBody(body []byte, contentType string) any { func fixtureSnapshotFilter(r *http.Request) fixtures.SnapshotFilter { return fixtures.SnapshotFilter{ CustomerID: firstQuery(r, "customer", "customerId", "customer_id"), - RunID: firstQuery(r, "runId", "run_id"), + RunID: firstNonEmptyString(firstQuery(r, "runId", "run_id"), requestRunID(r)), TenantID: firstQuery(r, "tenantId", "tenant_id"), FixtureName: firstQuery(r, "fixture", "fixtureName", "fixture_name", "name"), Namespace: firstQuery(r, "namespace", "ns"), } } +func applyRequestRunID(r *http.Request, pack fixtures.Pack) fixtures.Pack { + if pack.RunID == "" { + pack.RunID = requestRunID(r) + } + return pack +} + +func requestRunID(r *http.Request) string { + return strings.TrimSpace(r.Header.Get("X-Billtap-Run-Id")) +} + func debugBundleTimelineFilter(p params) billing.TimelineFilter { objectType := dashboardObjectType(p.first("objectType", "object_type", "targetType", "target_type", "type")) objectID := p.first("objectId", "object_id", "targetId", "target_id", "id") @@ -6738,6 +6765,9 @@ func absoluteURL(r *http.Request, path string, publicBase string) string { path = "/" + path } if publicBase != "" { + if runPrefix := requestRunPrefix(r); runPrefix != "" { + path = runPrefix + path + } return publicBase + path } scheme := "http" @@ -6765,6 +6795,17 @@ func requestForwardedPrefix(r *http.Request) string { return raw } +func requestRunPrefix(r *http.Request) string { + raw := strings.TrimSpace(strings.Split(r.Header.Get("X-Billtap-Run-Prefix"), ",")[0]) + if raw == "" || raw == "/" || strings.Contains(raw, "://") || strings.ContainsAny(raw, "?#") { + return "" + } + if !strings.HasPrefix(raw, "/") { + raw = "/" + raw + } + return strings.TrimRight(raw, "/") +} + func (h *Handler) emitCheckoutWebhooks(r *http.Request, result map[string]any) []webhooks.Event { if h.webhooks == nil { return nil diff --git a/internal/server/run.go b/internal/server/run.go new file mode 100644 index 0000000..9997889 --- /dev/null +++ b/internal/server/run.go @@ -0,0 +1,464 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + + "github.com/hckim/billtap/internal/config" + "github.com/hckim/billtap/internal/storage" +) + +const ( + // DefaultRun is the implicit run used when a request does not name one. It + // is backed by the configured DatabaseURL so existing integrations keep + // working unchanged. + DefaultRun = "default" + + // DefaultWorkspace is kept as a legacy alias for callers and tests that + // still use workspace terminology. + DefaultWorkspace = DefaultRun + + // WorkspaceHeader is a legacy run selector. It is echoed with the resolved + // run so old integrations can confirm the selected partition. + WorkspaceHeader = "X-Billtap-Workspace" + + // WorkspaceQueryParam is a legacy alternative to WorkspaceHeader for callers + // that cannot easily set headers. Prefer /runs/ for new code. + WorkspaceQueryParam = "workspace" + + // RunHeader carries the path-scoped run ID resolved from /runs/. + RunHeader = "X-Billtap-Run-Id" + + // RunPrefixHeader carries the browser path prefix for a path-scoped run. + RunPrefixHeader = "X-Billtap-Run-Prefix" + + maxRunIDLength = 63 +) + +// runIDPattern keeps names safe to use as SQLite filenames: an +// alphanumeric lead character followed by alphanumerics, dot, dash, or +// underscore. The leading-character rule rejects "" and dotted paths (".."). +var runIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`) + +// apiHandlerBuilder constructs the Stripe-like API handler for one store. +type apiHandlerBuilder func(storage.Store) (http.Handler, error) + +type runContextKey struct{} + +// runManager owns one isolated billing store and API handler per run ID. The +// default run reuses the externally-owned store; named runs open their own +// SQLite database lazily on first use. +type runManager struct { + cfg config.Config + build apiHandlerBuilder + + mu sync.Mutex + handlers map[string]http.Handler // name -> API handler + stores map[string]storage.Store // name -> store (lazily opened only) + defaultStore storage.Store + + // apiEnabled is false when the default store cannot back the API (for + // example a non-billing store). It preserves the previous behaviour of + // not mounting /v1/ at all in that case. + apiEnabled bool +} + +func newRunManager(cfg config.Config, defaultStore storage.Store, build apiHandlerBuilder) *runManager { + m := &runManager{ + cfg: cfg, + build: build, + handlers: make(map[string]http.Handler), + stores: make(map[string]storage.Store), + defaultStore: defaultStore, + } + if defaultStore == nil { + return m + } + handler, err := build(defaultStore) + if err != nil { + return m + } + m.handlers[DefaultRun] = handler + m.apiEnabled = true + return m +} + +type runSummary struct { + Name string + IsDefault bool + Open bool + Storage string + Summary map[string]int + Error string +} + +// apiHandler returns the dispatcher mounted on /v1/ and /api/. It resolves the +// run for each request, lazily provisioning isolated storage as needed. +func (m *runManager) apiHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name, err := resolveRunSelector(r) + if err != nil { + writeRunError(w, r, http.StatusBadRequest, err.Error()) + return + } + apiHandler, err := m.get(r.Context(), name) + if err != nil { + writeRunError(w, r, http.StatusInternalServerError, + fmt.Sprintf("could not open run %q: %v", name, err)) + return + } + w.Header().Set(RunHeader, name) + w.Header().Set(WorkspaceHeader, name) + apiHandler.ServeHTTP(w, requestForRunAPI(r, name)) + }) +} + +// requestForRunAPI returns the request seen by the Stripe-compatible API after +// the server has resolved isolation. It overwrites any client-supplied run +// header with the canonical run and strips the legacy workspace query so strict +// parameter validation never sees it. +func requestForRunAPI(r *http.Request, name string) *http.Request { + clone := r.Clone(r.Context()) + clone.Header = r.Header.Clone() + clone.Header.Set(RunHeader, name) + + cloned := *r.URL + if query := r.URL.Query(); query.Has(WorkspaceQueryParam) { + query.Del(WorkspaceQueryParam) + cloned.RawQuery = query.Encode() + cloned.RawPath = "" + } + clone.URL = &cloned + return clone +} + +// get returns the API handler for name, opening its store on first use. +func (m *runManager) get(ctx context.Context, name string) (http.Handler, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if handler, ok := m.handlers[name]; ok { + return handler, nil + } + if !m.apiEnabled { + return nil, fmt.Errorf("billing API is not available for this storage backend") + } + + // Decouple the store lifetime from the triggering request: the store is + // reused for every later request, so a cancelled first request must not + // tear it down. + store, err := storage.OpenSQLite(context.WithoutCancel(ctx), runDSN(m.cfg.DatabaseURL, name)) + if err != nil { + return nil, err + } + handler, err := m.build(store) + if err != nil { + _ = store.Close() + return nil, err + } + m.handlers[name] = handler + m.stores[name] = store + return handler, nil +} + +// list reports the known runs: the default, any opened this session, and any +// whose database file already exists on disk. +func (m *runManager) list() []string { + set := map[string]bool{DefaultRun: true} + + m.mu.Lock() + for name := range m.handlers { + set[name] = true + } + m.mu.Unlock() + + if dir := runStoreDir(m.cfg.DatabaseURL); dir != "" { + ext := runDBExt(m.cfg.DatabaseURL) + if entries, err := os.ReadDir(dir); err == nil { + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ext) { + continue + } + set[strings.TrimSuffix(entry.Name(), ext)] = true + } + } + } + + names := make([]string, 0, len(set)) + for name := range set { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func (m *runManager) summaries(ctx context.Context) []runSummary { + names := m.list() + out := make([]runSummary, 0, len(names)) + for _, name := range names { + summary := runSummary{ + Name: name, + IsDefault: name == DefaultRun, + Open: m.isOpen(name), + Storage: runDSN(m.cfg.DatabaseURL, name), + Summary: map[string]int{}, + } + store, closeStore, err := m.storeForSummary(ctx, name) + if err != nil { + summary.Error = err.Error() + out = append(out, summary) + continue + } + counts, err := storage.SQLiteTableCounts(ctx, store) + if closeStore != nil { + closeStore() + } + if err != nil { + summary.Error = err.Error() + } else { + summary.Summary = counts + } + out = append(out, summary) + } + return out +} + +func (m *runManager) isOpen(name string) bool { + m.mu.Lock() + defer m.mu.Unlock() + if name == DefaultRun { + return m.defaultStore != nil + } + _, ok := m.handlers[name] + return ok +} + +func (m *runManager) storeForSummary(ctx context.Context, name string) (storage.Store, func(), error) { + m.mu.Lock() + if name == DefaultRun { + store := m.defaultStore + m.mu.Unlock() + if store == nil { + return nil, nil, fmt.Errorf("default run storage is not open") + } + return store, nil, nil + } + if store, ok := m.stores[name]; ok { + m.mu.Unlock() + return store, nil, nil + } + m.mu.Unlock() + + if !runDBExists(m.cfg.DatabaseURL, name) { + return nil, nil, fmt.Errorf("run storage does not exist") + } + store, err := storage.OpenSQLite(context.WithoutCancel(ctx), runDSN(m.cfg.DatabaseURL, name)) + if err != nil { + return nil, nil, err + } + return store, func() { _ = store.Close() }, nil +} + +func (m *runManager) delete(ctx context.Context, name string) error { + if name == "" { + return fmt.Errorf("run id is required") + } + if name == DefaultRun { + m.mu.Lock() + store := m.defaultStore + m.mu.Unlock() + if store == nil { + return fmt.Errorf("default run storage is not open") + } + return storage.ResetSQLiteData(ctx, store) + } + + var store storage.Store + m.mu.Lock() + if existing, ok := m.stores[name]; ok { + store = existing + delete(m.stores, name) + } + delete(m.handlers, name) + m.mu.Unlock() + if store != nil { + if err := store.Close(); err != nil { + return err + } + } + if isMemoryDSN(m.cfg.DatabaseURL) { + return nil + } + path := runDBPath(m.cfg.DatabaseURL, name) + for _, candidate := range []string{path, path + "-wal", path + "-shm"} { + if candidate == "" { + continue + } + if err := os.Remove(candidate); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil +} + +// Close releases every lazily-opened run store. The default store is owned by +// the caller and is left untouched. +func (m *runManager) Close() error { + m.mu.Lock() + defer m.mu.Unlock() + + var firstErr error + for name, store := range m.stores { + if err := store.Close(); err != nil && firstErr == nil { + firstErr = err + } + delete(m.stores, name) + delete(m.handlers, name) + } + return firstErr +} + +// resolveRunSelector returns the run chosen by path scope or, for unprefixed +// requests, by the legacy workspace selector. +func resolveRunSelector(r *http.Request) (string, error) { + if scoped, ok := r.Context().Value(runContextKey{}).(string); ok && scoped != "" { + return scoped, nil + } + raw := strings.TrimSpace(r.Header.Get(WorkspaceHeader)) + if raw == "" { + raw = strings.TrimSpace(r.URL.Query().Get(WorkspaceQueryParam)) + } + return normalizeRunID(raw) +} + +func normalizeRunID(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return DefaultRun, nil + } + + // Filenames on macOS/Windows are case-insensitive; normalise so "Foo" + // and "foo" cannot resolve to two handlers over one file. + name := strings.ToLower(raw) + if name == DefaultRun { + return DefaultRun, nil + } + if len(name) > maxRunIDLength { + return "", fmt.Errorf("run id must be at most %d characters", maxRunIDLength) + } + if !runIDPattern.MatchString(name) { + return "", fmt.Errorf("run id %q is invalid: use letters, digits, '.', '-', '_' and a leading alphanumeric", raw) + } + return name, nil +} + +// NormalizeRunID validates a user-supplied run ID using the same rules as +// /runs/ routing and legacy workspace aliases. +func NormalizeRunID(raw string) (string, error) { + return normalizeRunID(raw) +} + +// runDSN derives the SQLite DSN for a named run from the base DSN. The default +// run returns the base DSN unchanged. +func runDSN(baseDSN, name string) string { + if name == "" || name == DefaultRun { + return baseDSN + } + if isMemoryDSN(baseDSN) { + // Each in-memory run needs a distinct shared-cache name so it + // stays isolated yet survives across pooled connections. + return fmt.Sprintf("file:billtap_run_%s?mode=memory&cache=shared", name) + } + + _, query := splitDSN(baseDSN) + wsPath := runDBPath(baseDSN, name) + if query == "" { + return wsPath + } + return "file:" + wsPath + query +} + +// RunDSN returns the SQLite DSN used for a path-scoped run ID. +func RunDSN(baseDSN, runID string) string { + return runDSN(baseDSN, runID) +} + +func runDBPath(baseDSN string, name string) string { + path, _ := splitDSN(baseDSN) + ext := filepath.Ext(path) + if ext == "" { + ext = ".db" + } + // Keep the existing on-disk directory so previously-created isolated stores + // remain visible after the run terminology cleanup. + return filepath.Join(filepath.Dir(path), "workspaces", name+ext) +} + +func runDBExists(baseDSN string, name string) bool { + if name == "" || name == DefaultRun { + return true + } + if isMemoryDSN(baseDSN) { + return false + } + if _, err := os.Stat(runDBPath(baseDSN, name)); err == nil { + return true + } + return false +} + +// runStoreDir returns the directory that holds named run databases, or "" when +// the base DSN is in-memory. +func runStoreDir(baseDSN string) string { + if isMemoryDSN(baseDSN) { + return "" + } + path, _ := splitDSN(baseDSN) + return filepath.Join(filepath.Dir(path), "workspaces") +} + +func runDBExt(baseDSN string) string { + path, _ := splitDSN(baseDSN) + if ext := filepath.Ext(path); ext != "" { + return ext + } + return ".db" +} + +// splitDSN separates a SQLite DSN into its filesystem path and trailing +// query/fragment, dropping any leading "file:" scheme. +func splitDSN(dsn string) (path string, query string) { + path = strings.TrimPrefix(dsn, "file:") + if idx := strings.IndexAny(path, "?#"); idx >= 0 { + return path[:idx], path[idx:] + } + return path, "" +} + +func isMemoryDSN(dsn string) bool { + return dsn == ":memory:" || + strings.HasPrefix(dsn, "file::memory:") || + strings.Contains(dsn, "mode=memory") +} + +func writeRunError(w http.ResponseWriter, r *http.Request, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if r.Method == http.MethodHead { + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "type": "invalid_request_error", + "message": message, + }, + }) +} diff --git a/internal/server/run_test.go b/internal/server/run_test.go new file mode 100644 index 0000000..fe28dc9 --- /dev/null +++ b/internal/server/run_test.go @@ -0,0 +1,407 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/hckim/billtap/internal/config" + "github.com/hckim/billtap/internal/storage" +) + +// newRunServer builds a SQLite-backed server whose configured DatabaseURL +// matches the default store, so named runs resolve to sibling SQLite files. +func newRunServer(t *testing.T) (*Server, string) { + t.Helper() + dir := t.TempDir() + dbPath := filepath.Join(dir, "billtap.db") + store, err := storage.OpenSQLite(context.Background(), dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + cfg := config.Config{ + Addr: ":0", + DatabaseURL: dbPath, + StaticDir: "web/dist", + Environment: "test", + } + srv := New(Options{Config: cfg, Store: store}) + t.Cleanup(func() { + _ = srv.Close() + _ = store.Close() + }) + return srv, dir +} + +func countCustomers(t *testing.T, handler http.Handler, legacyWorkspace string) int { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/v1/customers", nil) + if legacyWorkspace != "" { + req.Header.Set(WorkspaceHeader, legacyWorkspace) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("list customers (legacy workspace=%q) status = %d body = %s", legacyWorkspace, rec.Code, rec.Body.String()) + } + var out struct { + Data []json.RawMessage `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode customer list: %v body=%s", err, rec.Body.String()) + } + return len(out.Data) +} + +func countCustomersPath(t *testing.T, handler http.Handler, path string) int { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("list customers %s status = %d body = %s", path, rec.Code, rec.Body.String()) + } + var out struct { + Data []json.RawMessage `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode customer list: %v body=%s", err, rec.Body.String()) + } + return len(out.Data) +} + +func TestLegacyWorkspaceSelectorIsolatesBillingData(t *testing.T) { + srv, _ := newRunServer(t) + + // Two customers in the default run, one through the legacy workspace alias. + postForm[struct { + ID string `json:"id"` + }](t, srv, "/v1/customers", map[string]string{"email": "default-1@example.test"}) + postForm[struct { + ID string `json:"id"` + }](t, srv, "/v1/customers", map[string]string{"email": "default-2@example.test"}) + postFormWithHeaders[struct { + ID string `json:"id"` + }](t, srv, "/v1/customers", map[string]string{"email": "alt@example.test"}, + map[string]string{WorkspaceHeader: "test-a"}) + + if got := countCustomers(t, srv, ""); got != 2 { + t.Fatalf("default run customer count = %d, want 2", got) + } + if got := countCustomers(t, srv, "test-a"); got != 1 { + t.Fatalf("test-a run customer count = %d, want 1", got) + } + if got := countCustomers(t, srv, "default"); got != 2 { + t.Fatalf("explicit default run customer count = %d, want 2", got) + } + if got := countCustomers(t, srv, "test-b"); got != 0 { + t.Fatalf("fresh run customer count = %d, want 0", got) + } +} + +func TestLegacyWorkspaceResolvedFromQueryParam(t *testing.T) { + srv, _ := newRunServer(t) + + postFormWithHeaders[struct { + ID string `json:"id"` + }](t, srv, "/v1/customers?workspace=via-query", map[string]string{"email": "q@example.test"}, nil) + + req := httptest.NewRequest(http.MethodGet, "/v1/customers?workspace=via-query", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get(WorkspaceHeader); got != "via-query" { + t.Fatalf("response %s = %q, want %q", WorkspaceHeader, got, "via-query") + } + if got := countCustomers(t, srv, ""); got != 0 { + t.Fatalf("default run should stay empty, got %d", got) + } +} + +func TestLegacyWorkspaceHeaderEchoedAndInvalidRejected(t *testing.T) { + srv, _ := newRunServer(t) + + req := httptest.NewRequest(http.MethodGet, "/v1/customers", nil) + req.Header.Set(WorkspaceHeader, "Mixed-Case") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get(WorkspaceHeader); got != "mixed-case" { + t.Fatalf("resolved run = %q, want lowercased %q", got, "mixed-case") + } + + for _, bad := range []string{"bad/name", "../escape", ".hidden", "with space"} { + req := httptest.NewRequest(http.MethodGet, "/v1/customers", nil) + req.Header.Set(WorkspaceHeader, bad) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("run %q status = %d, want 400", bad, rec.Code) + } + } +} + +func TestLegacyWorkspacesListingEndpoint(t *testing.T) { + srv, _ := newRunServer(t) + + postFormWithHeaders[struct { + ID string `json:"id"` + }](t, srv, "/v1/customers", map[string]string{"email": "x@example.test"}, + map[string]string{WorkspaceHeader: "scenario-1"}) + + req := httptest.NewRequest(http.MethodGet, "/workspaces", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) + } + var out struct { + Data []struct { + Name string `json:"name"` + IsDefault bool `json:"is_default"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode workspace list: %v body=%s", err, rec.Body.String()) + } + seen := make(map[string]bool) + for _, ws := range out.Data { + seen[ws.Name] = true + } + if !seen[DefaultRun] || !seen["scenario-1"] { + t.Fatalf("workspace list = %#v, want default and scenario-1", out.Data) + } +} + +func TestRunDSN(t *testing.T) { + cases := []struct { + base string + name string + want string + }{ + {".billtap/billtap.db", "default", ".billtap/billtap.db"}, + {".billtap/billtap.db", "test-a", filepath.Join(".billtap", "workspaces", "test-a.db")}, + {"/data/billtap.db", "ci", filepath.Join("/data", "workspaces", "ci.db")}, + {":memory:", "iso", "file:billtap_run_iso?mode=memory&cache=shared"}, + } + for _, tc := range cases { + if got := runDSN(tc.base, tc.name); got != tc.want { + t.Fatalf("runDSN(%q, %q) = %q, want %q", tc.base, tc.name, got, tc.want) + } + } +} + +func TestRunPathPrefixIsolatesBillingData(t *testing.T) { + srv, _ := newRunServer(t) + + postForm[struct { + ID string `json:"id"` + }](t, srv, "/runs/run-a/v1/customers", map[string]string{"id": "cus_shared", "email": "a@example.test"}) + postForm[struct { + ID string `json:"id"` + }](t, srv, "/runs/run-b/v1/customers", map[string]string{"id": "cus_shared", "email": "b@example.test"}) + postForm[struct { + ID string `json:"id"` + }](t, srv, "/v1/customers", map[string]string{"id": "cus_shared", "email": "default@example.test"}) + + if got := countCustomersPath(t, srv, "/runs/run-a/v1/customers"); got != 1 { + t.Fatalf("run-a customer count = %d, want 1", got) + } + if got := countCustomersPath(t, srv, "/runs/run-b/v1/customers"); got != 1 { + t.Fatalf("run-b customer count = %d, want 1", got) + } + if got := countCustomers(t, srv, ""); got != 1 { + t.Fatalf("default customer count = %d, want 1", got) + } + + req := httptest.NewRequest(http.MethodGet, "/runs/run-a/v1/customers", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if got := rec.Header().Get(RunHeader); got != "run-a" { + t.Fatalf("%s = %q, want run-a", RunHeader, got) + } +} + +func TestRunPathScopeWinsOverLegacyWorkspaceHeader(t *testing.T) { + srv, _ := newRunServer(t) + + req := httptest.NewRequest(http.MethodPost, "/runs/run-a/v1/customers", strings.NewReader("email=a@example.test")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set(WorkspaceHeader, "run-b") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("create scoped customer status = %d body = %s", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get(RunHeader); got != "run-a" { + t.Fatalf("%s = %q, want run-a", RunHeader, got) + } + if got := countCustomersPath(t, srv, "/runs/run-a/v1/customers"); got != 1 { + t.Fatalf("run-a customers = %d, want 1", got) + } + if got := countCustomersPath(t, srv, "/runs/run-b/v1/customers"); got != 0 { + t.Fatalf("run-b customers = %d, want 0", got) + } +} + +func TestRunPathPrefixesHostedCheckoutURL(t *testing.T) { + srv, _ := newRunServer(t) + + customer := postForm[struct { + ID string `json:"id"` + }](t, srv, "/runs/checkout-run/v1/customers", map[string]string{"email": "buyer@example.test"}) + product := postForm[struct { + ID string `json:"id"` + }](t, srv, "/runs/checkout-run/v1/products", map[string]string{"name": "Team"}) + price := postForm[struct { + ID string `json:"id"` + }](t, srv, "/runs/checkout-run/v1/prices", map[string]string{ + "product": product.ID, + "currency": "usd", + "unit_amount": "9900", + "recurring[interval]": "month", + }) + session := postForm[struct { + URL string `json:"url"` + }](t, srv, "/runs/checkout-run/v1/checkout/sessions", map[string]string{ + "customer": customer.ID, + "line_items[0][price]": price.ID, + "line_items[0][quantity]": "1", + }) + if want := "http://example.com/runs/checkout-run/checkout/"; len(session.URL) < len(want) || session.URL[:len(want)] != want { + t.Fatalf("checkout URL = %q, want run prefix %q", session.URL, want) + } + + sessionID := session.URL[strings.LastIndex(session.URL, "/")+1:] + req := httptest.NewRequest(http.MethodGet, "/runs/checkout-run/checkout/"+sessionID, nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusFound { + t.Fatalf("run checkout redirect status = %d, want 302", rec.Code) + } + if got := rec.Header().Get("Location"); got != "/runs/checkout-run/app/checkout/?session_id="+sessionID { + t.Fatalf("run checkout redirect = %q", got) + } +} + +func TestRunWebhookEndpointsOnlyReceiveRunEvents(t *testing.T) { + srv, _ := newRunServer(t) + + endpointA := postForm[struct { + ID string `json:"id"` + }](t, srv, "/runs/webhook-a/v1/webhook_endpoints", map[string]string{ + "url": "https://app-a.example.test/webhook", + "enabled_events": "checkout.session.completed", + }) + endpointB := postForm[struct { + ID string `json:"id"` + }](t, srv, "/runs/webhook-b/v1/webhook_endpoints", map[string]string{ + "url": "https://app-b.example.test/webhook", + "enabled_events": "checkout.session.completed", + }) + + customer := postForm[struct { + ID string `json:"id"` + }](t, srv, "/runs/webhook-a/v1/customers", map[string]string{"email": "buyer@example.test"}) + product := postForm[struct { + ID string `json:"id"` + }](t, srv, "/runs/webhook-a/v1/products", map[string]string{"name": "Team"}) + price := postForm[struct { + ID string `json:"id"` + }](t, srv, "/runs/webhook-a/v1/prices", map[string]string{ + "product": product.ID, + "currency": "usd", + "unit_amount": "9900", + "recurring[interval]": "month", + }) + session := postForm[struct { + ID string `json:"id"` + }](t, srv, "/runs/webhook-a/v1/checkout/sessions", map[string]string{ + "customer": customer.ID, + "line_items[0][price]": price.ID, + "line_items[0][quantity]": "1", + }) + _ = postForm[map[string]any](t, srv, "/runs/webhook-a/v1/checkout/sessions/"+session.ID+"/complete", map[string]string{"outcome": "payment_succeeded"}) + + attemptsA := getRunList(t, srv, "/runs/webhook-a/v1/webhook_endpoints/"+endpointA.ID+"/attempts") + if len(attemptsA.Data) == 0 { + t.Fatalf("run-a endpoint attempts = 0, want checkout delivery attempts") + } + attemptsB := getRunList(t, srv, "/runs/webhook-b/v1/webhook_endpoints/"+endpointB.ID+"/attempts") + if len(attemptsB.Data) != 0 { + t.Fatalf("run-b endpoint attempts = %d, want 0", len(attemptsB.Data)) + } +} + +func TestRunAdminAndCleanup(t *testing.T) { + srv, _ := newRunServer(t) + postForm[struct { + ID string `json:"id"` + }](t, srv, "/runs/cleanup-run/v1/customers", map[string]string{"email": "cleanup@example.test"}) + + before := getRunSummaries(t, srv) + if got := before["cleanup-run"]["customers"]; got != 1 { + t.Fatalf("cleanup-run customers before cleanup = %d, want 1; summaries=%#v", got, before) + } + + req := httptest.NewRequest(http.MethodDelete, "/runs/cleanup-run", nil) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("cleanup status = %d body = %s", rec.Code, rec.Body.String()) + } + if got := countCustomersPath(t, srv, "/runs/cleanup-run/v1/customers"); got != 0 { + t.Fatalf("cleanup-run customers after cleanup = %d, want 0", got) + } +} + +func getRunList(t *testing.T, handler http.Handler, path string) struct { + Data []json.RawMessage `json:"data"` +} { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET %s status = %d body = %s", path, rec.Code, rec.Body.String()) + } + var out struct { + Data []json.RawMessage `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode %s: %v body=%s", path, err, rec.Body.String()) + } + return out +} + +func getRunSummaries(t *testing.T, handler http.Handler) map[string]map[string]int { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/admin/runs", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("admin runs status = %d body = %s", rec.Code, rec.Body.String()) + } + var out struct { + Data []struct { + RunID string `json:"runId"` + Summary map[string]int `json:"summary"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode admin runs: %v body=%s", err, rec.Body.String()) + } + summaries := map[string]map[string]int{} + for _, item := range out.Data { + summaries[item.RunID] = item.Summary + } + return summaries +} diff --git a/internal/server/server.go b/internal/server/server.go index ef28b61..7289399 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,6 +1,7 @@ package server import ( + "context" "encoding/json" "errors" "net/http" @@ -22,10 +23,10 @@ type Options struct { } type Server struct { - cfg config.Config - store storage.Store - mux *http.ServeMux - workspaces *workspaceManager + cfg config.Config + store storage.Store + mux *http.ServeMux + runs *runManager } func New(opts Options) *Server { @@ -35,46 +36,65 @@ func New(opts Options) *Server { mux: http.NewServeMux(), } s.cfg.PublicBasePath = config.NormalizePublicBasePath(s.cfg.PublicBasePath) - s.workspaces = newWorkspaceManager(s.cfg, s.store, s.buildAPIHandler) + s.runs = newRunManager(s.cfg, s.store, s.buildAPIHandler) s.routes() return s } -// Close releases workspace storage opened on demand. The default store passed +// Close releases run storage opened on demand. The default store passed // via Options is owned by the caller and is not closed here. func (s *Server) Close() error { - if s.workspaces == nil { + if s.runs == nil { return nil } - return s.workspaces.Close() + return s.runs.Close() } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + request := s.requestWithBasePath(r) + if s.runs != nil && s.runs.apiEnabled { + scoped, runID, ok, err := s.requestWithRunScope(request) + if err != nil { + writeRunError(w, r, http.StatusBadRequest, err.Error()) + return + } + if ok { + if scoped.URL.Path == "" { + s.handleRunRoot(w, scoped, runID) + return + } + request = scoped + } + } + s.mux.ServeHTTP(w, request) +} + +func (s *Server) requestWithBasePath(r *http.Request) *http.Request { basePath := s.requestBasePath(r) if basePath == "" { - s.mux.ServeHTTP(w, r) - return + return r } - r2 := r.Clone(r.Context()) - r2.Header = r.Header.Clone() - if r2.Header.Get("X-Forwarded-Prefix") == "" { - r2.Header.Set("X-Forwarded-Prefix", basePath) + clone := r.Clone(r.Context()) + clone.Header = r.Header.Clone() + if clone.Header.Get("X-Forwarded-Prefix") == "" { + clone.Header.Set("X-Forwarded-Prefix", basePath) } u := *r.URL if stripped, ok := stripBasePath(u.Path, basePath); ok { u.Path = stripped u.RawPath = "" } - r2.URL = &u - s.mux.ServeHTTP(w, r2) + clone.URL = &u + return clone } func (s *Server) routes() { - if s.workspaces.apiEnabled { - apiHandler := s.workspaces.handler() + if s.runs.apiEnabled { + apiHandler := s.runs.apiHandler() s.mux.Handle("/v1/", apiHandler) s.mux.Handle("/api/", apiHandler) s.mux.HandleFunc("/workspaces", s.handleWorkspaces) + s.mux.HandleFunc("/admin/runs", s.handleAdminRuns) } s.mux.HandleFunc("/", s.handleRoot) s.mux.HandleFunc("/health", s.handleHealth) @@ -88,8 +108,94 @@ func (s *Server) routes() { s.mux.HandleFunc("/assets/", s.handleAssets) } -// buildAPIHandler assembles the Stripe-like API handler for a single -// workspace store. It is invoked once per workspace by the workspace manager. +func (s *Server) handleAdminRuns(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + methodNotAllowed(w) + return + } + summaries := s.runs.summaries(r.Context()) + data := make([]map[string]any, 0, len(summaries)) + for _, run := range summaries { + item := map[string]any{ + "object": "run", + "run_id": run.Name, + "runId": run.Name, + "is_default": run.IsDefault, + "open": run.Open, + "storage": run.Storage, + "summary": run.Summary, + } + if run.Error != "" { + item["error"] = run.Error + } + data = append(data, item) + } + writeJSON(w, r, http.StatusOK, map[string]any{ + "object": "list", + "data": data, + }) +} + +func (s *Server) handleRunRoot(w http.ResponseWriter, r *http.Request, runID string) { + if r.Method != http.MethodDelete { + w.Header().Set("Allow", "DELETE") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if err := s.runs.delete(r.Context(), runID); err != nil { + writeRunError(w, r, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, r, http.StatusOK, map[string]any{ + "object": "run_cleanup", + "run_id": runID, + "runId": runID, + "deleted": true, + }) +} + +func (s *Server) requestWithRunScope(r *http.Request) (*http.Request, string, bool, error) { + runID, rest, ok, err := parseRunPath(r.URL.Path) + if err != nil { + return nil, "", false, err + } + if !ok { + return r, "", false, nil + } + clone := r.Clone(r.Context()) + clone.Header = r.Header.Clone() + clone = clone.WithContext(context.WithValue(clone.Context(), runContextKey{}, runID)) + clone.Header.Set(RunPrefixHeader, "/runs/"+runID) + clone.Header.Set("X-Forwarded-Prefix", joinURLPath(forwardedPrefix(r), "/runs/"+runID)) + + u := *r.URL + u.Path = rest + u.RawPath = "" + clone.URL = &u + return clone, runID, true, nil +} + +func parseRunPath(path string) (string, string, bool, error) { + rest := strings.TrimPrefix(path, "/runs/") + if rest == path { + return "", "", false, nil + } + if rest == "" { + return "", "", true, errors.New("run id is required") + } + rawRunID, suffix, _ := strings.Cut(rest, "/") + runID, err := normalizeRunID(rawRunID) + if err != nil { + return "", "", true, err + } + if suffix == "" { + return runID, "", true, nil + } + return runID, "/" + suffix, true, nil +} + +// buildAPIHandler assembles the Stripe-like API handler for a single run store. +// It is invoked once per run by the run manager. func (s *Server) buildAPIHandler(store storage.Store) (http.Handler, error) { repo, ok := store.(billing.Repository) if !ok { @@ -121,13 +227,13 @@ func (s *Server) handleWorkspaces(w http.ResponseWriter, r *http.Request) { methodNotAllowed(w) return } - names := s.workspaces.list() + names := s.runs.list() data := make([]map[string]any, 0, len(names)) for _, name := range names { data = append(data, map[string]any{ "object": "workspace", "name": name, - "is_default": name == DefaultWorkspace, + "is_default": name == DefaultRun, }) } writeJSON(w, r, http.StatusOK, map[string]any{ diff --git a/internal/server/workspace.go b/internal/server/workspace.go deleted file mode 100644 index 3e40e6d..0000000 --- a/internal/server/workspace.go +++ /dev/null @@ -1,291 +0,0 @@ -package server - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "os" - "path/filepath" - "regexp" - "sort" - "strings" - "sync" - - "github.com/hckim/billtap/internal/config" - "github.com/hckim/billtap/internal/storage" -) - -const ( - // DefaultWorkspace is the implicit workspace used when a request does not - // name one. It is backed by the configured DatabaseURL so existing - // integrations keep working unchanged. - DefaultWorkspace = "default" - - // WorkspaceHeader carries the target workspace name on a request and is - // echoed back on the response so callers can confirm the resolved value. - WorkspaceHeader = "X-Billtap-Workspace" - - // WorkspaceQueryParam is an alternative to WorkspaceHeader for callers - // that cannot easily set headers. - WorkspaceQueryParam = "workspace" - - maxWorkspaceNameLength = 63 -) - -// workspaceNamePattern keeps names safe to use as SQLite filenames: an -// alphanumeric lead character followed by alphanumerics, dot, dash, or -// underscore. The leading-character rule rejects "" and dotted paths (".."). -var workspaceNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`) - -// apiHandlerBuilder constructs the Stripe-like API handler for one store. -type apiHandlerBuilder func(storage.Store) (http.Handler, error) - -// workspaceManager owns one isolated billing store (and API handler) per -// workspace name. The default workspace reuses the externally-owned store; -// named workspaces open their own SQLite database lazily on first use. -type workspaceManager struct { - cfg config.Config - build apiHandlerBuilder - - mu sync.Mutex - handlers map[string]http.Handler // name -> API handler - stores map[string]storage.Store // name -> store (lazily opened only) - - // apiEnabled is false when the default store cannot back the API (for - // example a non-billing store). It preserves the previous behaviour of - // not mounting /v1/ at all in that case. - apiEnabled bool -} - -func newWorkspaceManager(cfg config.Config, defaultStore storage.Store, build apiHandlerBuilder) *workspaceManager { - m := &workspaceManager{ - cfg: cfg, - build: build, - handlers: make(map[string]http.Handler), - stores: make(map[string]storage.Store), - } - if defaultStore == nil { - return m - } - handler, err := build(defaultStore) - if err != nil { - return m - } - m.handlers[DefaultWorkspace] = handler - m.apiEnabled = true - return m -} - -// handler returns the dispatcher mounted on /v1/ and /api/. It resolves the -// workspace for each request, lazily provisioning isolated storage as needed. -func (m *workspaceManager) handler() http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - name, err := resolveWorkspace(r) - if err != nil { - writeWorkspaceError(w, r, http.StatusBadRequest, err.Error()) - return - } - apiHandler, err := m.get(r.Context(), name) - if err != nil { - writeWorkspaceError(w, r, http.StatusInternalServerError, - fmt.Sprintf("could not open workspace %q: %v", name, err)) - return - } - w.Header().Set(WorkspaceHeader, name) - // The workspace selector is consumed here; strip it so the strict - // API parameter validation downstream never sees it. - apiHandler.ServeHTTP(w, stripWorkspaceQuery(r)) - }) -} - -// stripWorkspaceQuery returns a request with the workspace query parameter -// removed, leaving the original untouched when it carries no such parameter. -func stripWorkspaceQuery(r *http.Request) *http.Request { - query := r.URL.Query() - if _, ok := query[WorkspaceQueryParam]; !ok { - return r - } - query.Del(WorkspaceQueryParam) - clone := r.Clone(r.Context()) - cloned := *r.URL - cloned.RawQuery = query.Encode() - cloned.RawPath = "" - clone.URL = &cloned - return clone -} - -// get returns the API handler for name, opening its store on first use. -func (m *workspaceManager) get(ctx context.Context, name string) (http.Handler, error) { - m.mu.Lock() - defer m.mu.Unlock() - - if handler, ok := m.handlers[name]; ok { - return handler, nil - } - if !m.apiEnabled { - return nil, fmt.Errorf("billing API is not available for this storage backend") - } - - // Decouple the store lifetime from the triggering request: the store is - // reused for every later request, so a cancelled first request must not - // tear it down. - store, err := storage.OpenSQLite(context.WithoutCancel(ctx), workspaceDSN(m.cfg.DatabaseURL, name)) - if err != nil { - return nil, err - } - handler, err := m.build(store) - if err != nil { - _ = store.Close() - return nil, err - } - m.handlers[name] = handler - m.stores[name] = store - return handler, nil -} - -// list reports the known workspaces: the default, any opened this session, -// and any whose database file already exists on disk. -func (m *workspaceManager) list() []string { - set := map[string]bool{DefaultWorkspace: true} - - m.mu.Lock() - for name := range m.handlers { - set[name] = true - } - m.mu.Unlock() - - if dir := workspacesDir(m.cfg.DatabaseURL); dir != "" { - ext := workspaceDBExt(m.cfg.DatabaseURL) - if entries, err := os.ReadDir(dir); err == nil { - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ext) { - continue - } - set[strings.TrimSuffix(entry.Name(), ext)] = true - } - } - } - - names := make([]string, 0, len(set)) - for name := range set { - names = append(names, name) - } - sort.Strings(names) - return names -} - -// Close releases every lazily-opened workspace store. The default store is -// owned by the caller and is left untouched. -func (m *workspaceManager) Close() error { - m.mu.Lock() - defer m.mu.Unlock() - - var firstErr error - for name, store := range m.stores { - if err := store.Close(); err != nil && firstErr == nil { - firstErr = err - } - delete(m.stores, name) - delete(m.handlers, name) - } - return firstErr -} - -// resolveWorkspace extracts and validates the workspace name from a request, -// falling back to DefaultWorkspace when none is supplied. -func resolveWorkspace(r *http.Request) (string, error) { - raw := strings.TrimSpace(r.Header.Get(WorkspaceHeader)) - if raw == "" { - raw = strings.TrimSpace(r.URL.Query().Get(WorkspaceQueryParam)) - } - if raw == "" { - return DefaultWorkspace, nil - } - - // Filenames on macOS/Windows are case-insensitive; normalise so "Foo" - // and "foo" cannot resolve to two handlers over one file. - name := strings.ToLower(raw) - if name == DefaultWorkspace { - return DefaultWorkspace, nil - } - if len(name) > maxWorkspaceNameLength { - return "", fmt.Errorf("workspace name must be at most %d characters", maxWorkspaceNameLength) - } - if !workspaceNamePattern.MatchString(name) { - return "", fmt.Errorf("workspace name %q is invalid: use letters, digits, '.', '-', '_' and a leading alphanumeric", raw) - } - return name, nil -} - -// workspaceDSN derives the SQLite DSN for a named workspace from the base -// (default) DSN. The default workspace returns the base DSN unchanged. -func workspaceDSN(baseDSN, name string) string { - if name == "" || name == DefaultWorkspace { - return baseDSN - } - if isMemoryDSN(baseDSN) { - // Each in-memory workspace needs a distinct shared-cache name so it - // stays isolated yet survives across pooled connections. - return fmt.Sprintf("file:billtap_ws_%s?mode=memory&cache=shared", name) - } - - path, query := splitDSN(baseDSN) - ext := filepath.Ext(path) - if ext == "" { - ext = ".db" - } - wsPath := filepath.Join(filepath.Dir(path), "workspaces", name+ext) - if query == "" { - return wsPath - } - return "file:" + wsPath + query -} - -// workspacesDir returns the directory that holds named workspace databases, -// or "" when the base DSN is in-memory. -func workspacesDir(baseDSN string) string { - if isMemoryDSN(baseDSN) { - return "" - } - path, _ := splitDSN(baseDSN) - return filepath.Join(filepath.Dir(path), "workspaces") -} - -func workspaceDBExt(baseDSN string) string { - path, _ := splitDSN(baseDSN) - if ext := filepath.Ext(path); ext != "" { - return ext - } - return ".db" -} - -// splitDSN separates a SQLite DSN into its filesystem path and trailing -// query/fragment, dropping any leading "file:" scheme. -func splitDSN(dsn string) (path string, query string) { - path = strings.TrimPrefix(dsn, "file:") - if idx := strings.IndexAny(path, "?#"); idx >= 0 { - return path[:idx], path[idx:] - } - return path, "" -} - -func isMemoryDSN(dsn string) bool { - return dsn == ":memory:" || - strings.HasPrefix(dsn, "file::memory:") || - strings.Contains(dsn, "mode=memory") -} - -func writeWorkspaceError(w http.ResponseWriter, r *http.Request, status int, message string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - if r.Method == http.MethodHead { - return - } - _ = json.NewEncoder(w).Encode(map[string]any{ - "error": map[string]any{ - "type": "invalid_request_error", - "message": message, - }, - }) -} diff --git a/internal/server/workspace_test.go b/internal/server/workspace_test.go deleted file mode 100644 index 9e13ac3..0000000 --- a/internal/server/workspace_test.go +++ /dev/null @@ -1,183 +0,0 @@ -package server - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "path/filepath" - "testing" - - "github.com/hckim/billtap/internal/config" - "github.com/hckim/billtap/internal/storage" -) - -// newWorkspaceServer builds a SQLite-backed server whose configured -// DatabaseURL matches the default store, so named workspaces resolve to -// sibling files under /workspaces. -func newWorkspaceServer(t *testing.T) (*Server, string) { - t.Helper() - dir := t.TempDir() - dbPath := filepath.Join(dir, "billtap.db") - store, err := storage.OpenSQLite(context.Background(), dbPath) - if err != nil { - t.Fatalf("open sqlite: %v", err) - } - cfg := config.Config{ - Addr: ":0", - DatabaseURL: dbPath, - StaticDir: "web/dist", - Environment: "test", - } - srv := New(Options{Config: cfg, Store: store}) - t.Cleanup(func() { - _ = srv.Close() - _ = store.Close() - }) - return srv, dir -} - -func countCustomers(t *testing.T, handler http.Handler, workspace string) int { - t.Helper() - req := httptest.NewRequest(http.MethodGet, "/v1/customers", nil) - if workspace != "" { - req.Header.Set(WorkspaceHeader, workspace) - } - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("list customers (workspace=%q) status = %d body = %s", workspace, rec.Code, rec.Body.String()) - } - var out struct { - Data []json.RawMessage `json:"data"` - } - if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { - t.Fatalf("decode customer list: %v body=%s", err, rec.Body.String()) - } - return len(out.Data) -} - -func TestWorkspacesIsolateBillingData(t *testing.T) { - srv, _ := newWorkspaceServer(t) - - // Two customers in the default workspace, one in a named workspace. - postForm[struct { - ID string `json:"id"` - }](t, srv, "/v1/customers", map[string]string{"email": "default-1@example.test"}) - postForm[struct { - ID string `json:"id"` - }](t, srv, "/v1/customers", map[string]string{"email": "default-2@example.test"}) - postFormWithHeaders[struct { - ID string `json:"id"` - }](t, srv, "/v1/customers", map[string]string{"email": "alt@example.test"}, - map[string]string{WorkspaceHeader: "test-a"}) - - if got := countCustomers(t, srv, ""); got != 2 { - t.Fatalf("default workspace customer count = %d, want 2", got) - } - if got := countCustomers(t, srv, "test-a"); got != 1 { - t.Fatalf("test-a workspace customer count = %d, want 1", got) - } - if got := countCustomers(t, srv, "default"); got != 2 { - t.Fatalf("explicit default workspace customer count = %d, want 2", got) - } - if got := countCustomers(t, srv, "test-b"); got != 0 { - t.Fatalf("fresh workspace customer count = %d, want 0", got) - } -} - -func TestWorkspaceResolvedFromQueryParam(t *testing.T) { - srv, _ := newWorkspaceServer(t) - - postFormWithHeaders[struct { - ID string `json:"id"` - }](t, srv, "/v1/customers?workspace=via-query", map[string]string{"email": "q@example.test"}, nil) - - req := httptest.NewRequest(http.MethodGet, "/v1/customers?workspace=via-query", nil) - rec := httptest.NewRecorder() - srv.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) - } - if got := rec.Header().Get(WorkspaceHeader); got != "via-query" { - t.Fatalf("response %s = %q, want %q", WorkspaceHeader, got, "via-query") - } - if got := countCustomers(t, srv, ""); got != 0 { - t.Fatalf("default workspace should stay empty, got %d", got) - } -} - -func TestWorkspaceHeaderEchoedAndInvalidRejected(t *testing.T) { - srv, _ := newWorkspaceServer(t) - - req := httptest.NewRequest(http.MethodGet, "/v1/customers", nil) - req.Header.Set(WorkspaceHeader, "Mixed-Case") - rec := httptest.NewRecorder() - srv.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) - } - if got := rec.Header().Get(WorkspaceHeader); got != "mixed-case" { - t.Fatalf("resolved workspace = %q, want lowercased %q", got, "mixed-case") - } - - for _, bad := range []string{"bad/name", "../escape", ".hidden", "with space"} { - req := httptest.NewRequest(http.MethodGet, "/v1/customers", nil) - req.Header.Set(WorkspaceHeader, bad) - rec := httptest.NewRecorder() - srv.ServeHTTP(rec, req) - if rec.Code != http.StatusBadRequest { - t.Fatalf("workspace %q status = %d, want 400", bad, rec.Code) - } - } -} - -func TestWorkspacesListingEndpoint(t *testing.T) { - srv, _ := newWorkspaceServer(t) - - postFormWithHeaders[struct { - ID string `json:"id"` - }](t, srv, "/v1/customers", map[string]string{"email": "x@example.test"}, - map[string]string{WorkspaceHeader: "scenario-1"}) - - req := httptest.NewRequest(http.MethodGet, "/workspaces", nil) - rec := httptest.NewRecorder() - srv.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String()) - } - var out struct { - Data []struct { - Name string `json:"name"` - IsDefault bool `json:"is_default"` - } `json:"data"` - } - if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { - t.Fatalf("decode workspace list: %v body=%s", err, rec.Body.String()) - } - seen := make(map[string]bool) - for _, ws := range out.Data { - seen[ws.Name] = true - } - if !seen[DefaultWorkspace] || !seen["scenario-1"] { - t.Fatalf("workspace list = %#v, want default and scenario-1", out.Data) - } -} - -func TestWorkspaceDSN(t *testing.T) { - cases := []struct { - base string - name string - want string - }{ - {".billtap/billtap.db", "default", ".billtap/billtap.db"}, - {".billtap/billtap.db", "test-a", filepath.Join(".billtap", "workspaces", "test-a.db")}, - {"/data/billtap.db", "ci", filepath.Join("/data", "workspaces", "ci.db")}, - {":memory:", "iso", "file:billtap_ws_iso?mode=memory&cache=shared"}, - } - for _, tc := range cases { - if got := workspaceDSN(tc.base, tc.name); got != tc.want { - t.Fatalf("workspaceDSN(%q, %q) = %q, want %q", tc.base, tc.name, got, tc.want) - } - } -} diff --git a/internal/storage/admin.go b/internal/storage/admin.go new file mode 100644 index 0000000..89feb86 --- /dev/null +++ b/internal/storage/admin.go @@ -0,0 +1,108 @@ +package storage + +import ( + "context" + "database/sql" + "fmt" + "sort" + "strings" +) + +var retainedSQLiteTables = map[string]bool{ + "schema_migrations": true, + "runtime_metadata": true, +} + +// SQLiteTableCounts returns row counts for user-data tables in a SQLite-backed +// store. Migration and runtime metadata are intentionally omitted. +func SQLiteTableCounts(ctx context.Context, store Store) (map[string]int, error) { + db, err := sqliteDB(store) + if err != nil { + return nil, err + } + tables, err := sqliteUserTables(ctx, db) + if err != nil { + return nil, err + } + counts := make(map[string]int, len(tables)) + for _, table := range tables { + var count int + if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+quoteSQLiteIdentifier(table)).Scan(&count); err != nil { + return nil, fmt.Errorf("count %s: %w", table, err) + } + counts[table] = count + } + return counts, nil +} + +// ResetSQLiteData deletes all persisted user data from a SQLite-backed store. +// It keeps schema_migrations and runtime_metadata so the database remains ready +// for immediate reuse. +func ResetSQLiteData(ctx context.Context, store Store) error { + db, err := sqliteDB(store) + if err != nil { + return err + } + tables, err := sqliteUserTables(ctx, db) + if err != nil { + return err + } + if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys = OFF"); err != nil { + return err + } + defer func() { + _, _ = db.ExecContext(context.Background(), "PRAGMA foreign_keys = ON") + }() + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + for _, table := range tables { + if _, err := tx.ExecContext(ctx, "DELETE FROM "+quoteSQLiteIdentifier(table)); err != nil { + _ = tx.Rollback() + return fmt.Errorf("delete %s: %w", table, err) + } + } + return tx.Commit() +} + +func sqliteDB(store Store) (*sql.DB, error) { + if store == nil { + return nil, fmt.Errorf("sqlite store is not open") + } + withDB, ok := store.(interface{ DB() *sql.DB }) + if !ok || withDB.DB() == nil { + return nil, fmt.Errorf("storage backend is not sqlite-backed") + } + return withDB.DB(), nil +} + +func sqliteUserTables(ctx context.Context, db *sql.DB) ([]string, error) { + rows, err := db.QueryContext(ctx, `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name`) + if err != nil { + return nil, err + } + defer rows.Close() + + var tables []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + if retainedSQLiteTables[name] { + continue + } + tables = append(tables, name) + } + if err := rows.Err(); err != nil { + return nil, err + } + sort.Strings(tables) + return tables, nil +} + +func quoteSQLiteIdentifier(value string) string { + return `"` + strings.ReplaceAll(value, `"`, `""`) + `"` +} diff --git a/specs/000-product/contracts/api.md b/specs/000-product/contracts/api.md index dfc8331..2b15c98 100644 --- a/specs/000-product/contracts/api.md +++ b/specs/000-product/contracts/api.md @@ -16,25 +16,38 @@ Process health. Storage and worker readiness. -## Workspaces +## Runs One running server can host several isolated billing datasets. Every `/v1` -and `/api` request resolves a workspace before dispatch: +and `/api` request resolves a run before dispatch: -- A request with no selector uses the `default` workspace, backed by the +- A request with no selector uses the `default` run, backed by the configured `database_url`. This keeps existing integrations unchanged. -- A request may select a named workspace with the `X-Billtap-Workspace` - request header or the `workspace` query parameter. Named workspaces are - created on first use, have their own storage, and are isolated from each - other and from `default`. -- The resolved workspace name is returned on the `X-Billtap-Workspace` - response header. An invalid workspace name returns `400`. +- A request may select a named run with the `/runs/` path prefix, for + example `/runs/ci-123/v1/customers` or `/runs/ci-123/api/diagnostics`. + Named runs are created on first use, have their own storage, and are isolated + from each other and from `default`. +- The resolved run ID is returned on `X-Billtap-Run-Id`. The legacy + `X-Billtap-Workspace` response header is also echoed for compatibility. +- The legacy `X-Billtap-Workspace` request header and `workspace` query + parameter remain supported as aliases for unprefixed requests. An invalid + run ID returns `400`. + +### `GET /admin/runs` + +Lists known runs (the default, any opened this session, and any whose database +file already exists). Returns a `list` envelope of `run` objects with `runId`, +`is_default`, `open`, `storage`, and table row-count `summary`. + +### `DELETE /runs/` + +Deletes a named run store. For `default`, clears user data while preserving +schema metadata. ### `GET /workspaces` -Lists known workspaces (the default, any opened this session, and any whose -database file already exists). Returns a `list` envelope of `workspace` -objects with `name` and `is_default`. +Legacy alias that lists run partitions as `workspace` objects with `name` and +`is_default`. ## Stripe-like API diff --git a/specs/000-product/data-model.md b/specs/000-product/data-model.md index 5c7002d..95889e0 100644 --- a/specs/000-product/data-model.md +++ b/specs/000-product/data-model.md @@ -1,10 +1,13 @@ # Data Model -All entities below are scoped to a single workspace. A server hosts the -implicit `default` workspace plus any named workspaces; each workspace has its -own isolated store, so the same entity id may exist independently in different -workspaces. Workspaces are an instance-level partition and are not themselves -persisted rows — see `contracts/api.md` for selection and listing. +All entities below are scoped to a single run. A server hosts the implicit +`default` run plus any named runs selected through `/runs/`; each run has +its own isolated store, so the same entity id may exist independently in +different runs. Runs are an instance-level partition and are listed through +`GET /admin/runs`. + +The earlier workspace selector remains available as a backward-compatible alias +for the same storage partitioning model. ## Customer diff --git a/specs/000-product/spec.md b/specs/000-product/spec.md index fb3cf09..4de3a68 100644 --- a/specs/000-product/spec.md +++ b/specs/000-product/spec.md @@ -117,11 +117,12 @@ Acceptance criteria: - FR-007: Create, retrieve, confirm, fail, and list payment intents. - FR-008: Create, retrieve, update, delete, and list webhook endpoints. - FR-009: Create, retrieve, and list events. -- FR-010: Serve isolated billing workspaces from one running server. Requests - with no workspace selector use the backward-compatible `default` workspace; - a request may select a named workspace via the `X-Billtap-Workspace` header - or `workspace` query parameter to get an independent dataset, and the known - workspaces are listable. +- FR-010: Serve isolated billing runs from one running server. Requests with no + run selector use the backward-compatible `default` run; a request may select + a named run via `/runs//v1/...` or `/runs//api/...` to get an + independent dataset, and the known runs are listable with row-count + summaries. The legacy `X-Billtap-Workspace` header and `workspace` query + parameter remain supported for backward compatibility. ### Hosted UI @@ -182,7 +183,7 @@ Acceptance criteria: - NFR-005: No real card data is stored. - NFR-006: Contract behavior is fixture-backed. - NFR-007: Profile-specific behavior is fixture-backed and does not require production payment credentials. -- NFR-008: Named workspaces are isolated at the storage boundary so parallel +- NFR-008: Named runs are isolated at the storage boundary so parallel test suites do not need a server restart or shared-state reset between runs. ## Non-Goals diff --git a/specs/000-product/tasks.md b/specs/000-product/tasks.md index 12ad17c..31751a8 100644 --- a/specs/000-product/tasks.md +++ b/specs/000-product/tasks.md @@ -270,6 +270,7 @@ Gate: - [x] T145 Expand customer history, subscription pause/resume, and payment-method attach/detach simulation routes - [x] T146 Add browser-facing public base path and forwarded-prefix support - [x] T147 Add isolated billing workspaces selectable per request so parallel test suites share one server +- [x] T148 Add runId URL-scope routing, admin summaries, cleanup, and fixture seed CLI Suggested agents: