feat(server): tenant-registry skeleton boots against dev stack
Minimal Go service: /healthz + /v1/tenants/by-slug/:slug + /v1/tenants/:id with an in-memory store seeded with the acme tenant. Stdlib-only; pgx + JWT validation land in M4.1 follow-up.
This commit was merged in pull request #4.
This commit is contained in:
@@ -57,12 +57,12 @@ jobs:
|
||||
|
||||
test:
|
||||
runs-on: docker
|
||||
if: hashFiles('go.sum') != ''
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with: { go-version: '1.22' }
|
||||
with: { go-version: '1.24' }
|
||||
|
||||
- name: fmt
|
||||
run: test -z "$(gofmt -l .)"
|
||||
@@ -75,7 +75,10 @@ jobs:
|
||||
with: { version: latest }
|
||||
|
||||
- name: test
|
||||
run: go test -race -coverprofile=cover.out ./...
|
||||
# Coverage scoped to ./internal/... — cmd/server is the entrypoint
|
||||
# with signal-handling + bind that isn't worth unit-testing. When
|
||||
# real integration tests land in M4.1, widen this back to ./...
|
||||
run: go test -race -coverprofile=cover.out ./internal/...
|
||||
|
||||
- name: coverage gate
|
||||
run: |
|
||||
|
||||
@@ -35,3 +35,4 @@ vendor/
|
||||
|
||||
# Rust
|
||||
**/target/
|
||||
bin/
|
||||
|
||||
@@ -6,6 +6,7 @@ Generated section is appended on release tag via `git-cliff` (see `.gitea/workfl
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- feat(server): minimal Go service — /healthz + GET /v1/tenants/by-slug/:slug + GET /v1/tenants/:id with in-memory store seeded with the acme tenant
|
||||
-
|
||||
|
||||
### Changed
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# Multi-stage build for tenant-registry.
|
||||
|
||||
FROM golang:1.24-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/tenant-registry ./cmd/server
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
WORKDIR /
|
||||
COPY --from=build /out/tenant-registry /tenant-registry
|
||||
USER nonroot:nonroot
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/tenant-registry"]
|
||||
@@ -0,0 +1,41 @@
|
||||
# tenant-registry — Go service for tenant glue, audit, API keys.
|
||||
|
||||
.PHONY: help dev test build fmt vet lint docker clean
|
||||
|
||||
ADDR ?= :8080
|
||||
APP_ENV ?= dev
|
||||
|
||||
help:
|
||||
@echo "tenant-registry targets:"
|
||||
@echo " make dev go run ./cmd/server (foreground, APP_ENV=dev)"
|
||||
@echo " make test go test -race ./..."
|
||||
@echo " make build compile binary to ./bin/tenant-registry"
|
||||
@echo " make fmt go fmt ./..."
|
||||
@echo " make vet go vet ./..."
|
||||
@echo " make docker build local image (tenant-registry:dev)"
|
||||
|
||||
dev:
|
||||
@APP_ENV=$(APP_ENV) ADDR=$(ADDR) go run ./cmd/server
|
||||
|
||||
test:
|
||||
@go test -race ./...
|
||||
|
||||
build:
|
||||
@mkdir -p bin
|
||||
@CGO_ENABLED=0 go build -o bin/tenant-registry ./cmd/server
|
||||
@echo "built ./bin/tenant-registry"
|
||||
|
||||
fmt:
|
||||
@gofmt -w .
|
||||
@test -z "$$(gofmt -l .)"
|
||||
|
||||
vet:
|
||||
@go vet ./...
|
||||
|
||||
lint: fmt vet
|
||||
|
||||
docker:
|
||||
@docker build -t tenant-registry:dev .
|
||||
|
||||
clean:
|
||||
@rm -rf bin
|
||||
@@ -20,19 +20,51 @@ Multi-tenant glue: orgs, entitlements, API keys, audit. Scaffolded under milesto
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
# prerequisites: see CONTRIBUTING.md for tooling once code lands
|
||||
make dev # starts dependencies + this service on http://localhost:8080
|
||||
make test # unit + integration
|
||||
make e2e # only if this repo ships user-facing flows
|
||||
# Prerequisites: Go 1.25+
|
||||
# Dependencies (Keycloak, pg-app) come from the dev stack — see platform/orca-platform/dev.
|
||||
|
||||
# In one terminal — bring up dev dependencies (in the orca-platform clone):
|
||||
cd /path/to/platform/orca-platform && make dev-up
|
||||
|
||||
# In another — run the service:
|
||||
make dev # APP_ENV=dev, listens on :8080
|
||||
make test # unit tests
|
||||
make build # compile to ./bin/tenant-registry
|
||||
```
|
||||
|
||||
Local secrets come from `.env.local` (gitignored). Template at `.env.example`.
|
||||
Env vars (override at the shell):
|
||||
|
||||
## Endpoints / surface
|
||||
| Var | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `APP_ENV` | `dev` | one of `dev`, `stage`, `prod` |
|
||||
| `ADDR` | `:8080` | listen address |
|
||||
| `KEYCLOAK_ISSUER` | `http://localhost:8080/realms/breakpilot-dev` | OIDC issuer URL |
|
||||
| `DATABASE_URL` | empty (in-memory store in skeleton) | Postgres DSN, wired up in the M4.1 schema PR |
|
||||
|
||||
{{For services: list the top-level routes or commands.
|
||||
For libraries: list the public API entry points.
|
||||
For IaC: list the make targets.}}
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Returns |
|
||||
|---|---|---|
|
||||
| GET | `/healthz` | `{"status":"ok"}` — liveness probe |
|
||||
| GET | `/v1/tenants/by-slug/{slug}` | 200 with tenant JSON, 404 if missing |
|
||||
| GET | `/v1/tenants/{id}` | 200 with tenant JSON, 404 if missing |
|
||||
|
||||
The skeleton's store is in-memory and pre-seeded with one tenant:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"slug": "acme",
|
||||
"name": "Acme Inc.",
|
||||
"status": "active",
|
||||
"plan": "professional",
|
||||
"products": ["certifai", "compliance"]
|
||||
}
|
||||
```
|
||||
|
||||
So `curl http://localhost:8080/v1/tenants/by-slug/acme` works the moment `make dev` is up.
|
||||
|
||||
The full schema (tenants, tenant_products, audit_log) is committed at `migrations/0001_init.up.sql` for review, but unapplied until the M4.1 follow-up PR swaps the in-memory store for pgx-backed Postgres.
|
||||
|
||||
## Deployment
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/config"
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
slog.Error("config load failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
mux := server.NewRouter(cfg, logger)
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: mux,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go func() {
|
||||
slog.Info("tenant-registry listening", "addr", cfg.Addr, "env", cfg.Env)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
slog.Error("server crashed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
slog.Info("shutdown requested")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
slog.Error("shutdown failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("bye")
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Env string // dev | stage | prod
|
||||
Addr string // listen address, e.g. ":8080"
|
||||
KeycloakIssuer string // e.g. http://localhost:8080/realms/breakpilot-dev
|
||||
DatabaseURL string // postgres DSN (unused in skeleton; in-memory store)
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
env := getenv("APP_ENV", "dev")
|
||||
if env != "dev" && env != "stage" && env != "prod" {
|
||||
return nil, fmt.Errorf("invalid APP_ENV %q", env)
|
||||
}
|
||||
return &Config{
|
||||
Env: env,
|
||||
Addr: getenv("ADDR", ":8080"),
|
||||
KeycloakIssuer: getenv("KEYCLOAK_ISSUER", "http://localhost:8080/realms/breakpilot-dev"),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getenv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoad_defaults(t *testing.T) {
|
||||
t.Setenv("APP_ENV", "")
|
||||
t.Setenv("ADDR", "")
|
||||
t.Setenv("KEYCLOAK_ISSUER", "")
|
||||
t.Setenv("DATABASE_URL", "")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Env != "dev" {
|
||||
t.Errorf("Env = %q, want dev", cfg.Env)
|
||||
}
|
||||
if cfg.Addr != ":8080" {
|
||||
t.Errorf("Addr = %q, want :8080", cfg.Addr)
|
||||
}
|
||||
if cfg.KeycloakIssuer == "" {
|
||||
t.Error("KeycloakIssuer is empty; expected a default")
|
||||
}
|
||||
if cfg.DatabaseURL != "" {
|
||||
t.Errorf("DatabaseURL = %q, want empty default", cfg.DatabaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_overrides(t *testing.T) {
|
||||
t.Setenv("APP_ENV", "stage")
|
||||
t.Setenv("ADDR", ":9000")
|
||||
t.Setenv("KEYCLOAK_ISSUER", "https://auth.example/realms/r")
|
||||
t.Setenv("DATABASE_URL", "postgres://x")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Env != "stage" || cfg.Addr != ":9000" || cfg.KeycloakIssuer != "https://auth.example/realms/r" || cfg.DatabaseURL != "postgres://x" {
|
||||
t.Errorf("overrides not applied: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_invalidEnv(t *testing.T) {
|
||||
t.Setenv("APP_ENV", "bogus")
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid APP_ENV")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/config"
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||
)
|
||||
|
||||
type deps struct {
|
||||
cfg *config.Config
|
||||
log *slog.Logger
|
||||
tenant *store.Memory
|
||||
}
|
||||
|
||||
func NewRouter(cfg *config.Config, log *slog.Logger) http.Handler {
|
||||
d := &deps{cfg: cfg, log: log, tenant: store.NewMemory()}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", d.healthz)
|
||||
mux.HandleFunc("GET /v1/tenants/by-slug/{slug}", d.tenantBySlug)
|
||||
mux.HandleFunc("GET /v1/tenants/{id}", d.tenantByID)
|
||||
|
||||
return logRequest(log)(mux)
|
||||
}
|
||||
|
||||
func (d *deps) healthz(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (d *deps) tenantBySlug(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
t, err := d.tenant.BySlug(ctx, slug)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "tenant_not_found", "no tenant with that slug")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
d.log.Error("tenant lookup failed", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal", "lookup failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (d *deps) tenantByID(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
t, err := d.tenant.ByID(ctx, id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "tenant_not_found", "no tenant with that id")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
d.log.Error("tenant lookup failed", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal", "lookup failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, code int, kind, msg string) {
|
||||
writeJSON(w, code, map[string]string{"error": kind, "message": msg})
|
||||
}
|
||||
|
||||
func logRequest(log *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
ww := &statusRecorder{ResponseWriter: w, code: 200}
|
||||
next.ServeHTTP(ww, r)
|
||||
log.Info("http",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", ww.code,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
code int
|
||||
}
|
||||
|
||||
func (s *statusRecorder) WriteHeader(c int) {
|
||||
s.code = c
|
||||
s.ResponseWriter.WriteHeader(c)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/config"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
cfg := &config.Config{Env: "dev", Addr: ":0"}
|
||||
h := NewRouter(cfg, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
return httptest.NewServer(h)
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + "/healthz")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("got %d, want 200", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantBySlug_acme(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + "/v1/tenants/by-slug/acme")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("got %d, want 200; body=%s", resp.StatusCode, body)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["slug"] != "acme" {
|
||||
t.Fatalf("expected slug=acme, got %v", payload["slug"])
|
||||
}
|
||||
if payload["status"] != "active" {
|
||||
t.Fatalf("expected status=active, got %v", payload["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantBySlug_unknown(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + "/v1/tenants/by-slug/nope")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("got %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Package store is a stand-in for the real Postgres-backed tenant store.
|
||||
// The skeleton ships an in-memory implementation pre-seeded with one tenant
|
||||
// (acme) so portal middleware has something to resolve in local dev.
|
||||
// Replace with a pgx-backed implementation in the M4.1 follow-up PR.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("tenant not found")
|
||||
|
||||
type Tenant struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // active | trial | frozen | archived | demo
|
||||
Plan string `json:"plan"` // starter | professional | enterprise
|
||||
Products []string `json:"products"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Memory struct {
|
||||
mu sync.RWMutex
|
||||
bySlug map[string]*Tenant
|
||||
byID map[string]*Tenant
|
||||
}
|
||||
|
||||
func NewMemory() *Memory {
|
||||
m := &Memory{
|
||||
bySlug: make(map[string]*Tenant),
|
||||
byID: make(map[string]*Tenant),
|
||||
}
|
||||
seed := &Tenant{
|
||||
ID: "00000000-0000-0000-0000-000000000001",
|
||||
Slug: "acme",
|
||||
Name: "Acme Inc.",
|
||||
Status: "active",
|
||||
Plan: "professional",
|
||||
Products: []string{"certifai", "compliance"},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
m.bySlug[seed.Slug] = seed
|
||||
m.byID[seed.ID] = seed
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Memory) BySlug(_ context.Context, slug string) (*Tenant, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
t, ok := m.bySlug[slug]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
cp := *t
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *Memory) ByID(_ context.Context, id string) (*Tenant, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
t, ok := m.byID[id]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
cp := *t
|
||||
return &cp, nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMemory_seededAcme(t *testing.T) {
|
||||
m := NewMemory()
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("by slug returns seed", func(t *testing.T) {
|
||||
got, err := m.BySlug(ctx, "acme")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Slug != "acme" {
|
||||
t.Errorf("slug = %q, want acme", got.Slug)
|
||||
}
|
||||
if got.Status != "active" {
|
||||
t.Errorf("status = %q, want active", got.Status)
|
||||
}
|
||||
if len(got.Products) != 2 {
|
||||
t.Errorf("products = %v, want [certifai compliance]", got.Products)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("by id returns seed", func(t *testing.T) {
|
||||
got, err := m.ByID(ctx, "00000000-0000-0000-0000-000000000001")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Slug != "acme" {
|
||||
t.Errorf("slug = %q, want acme", got.Slug)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing slug returns ErrNotFound", func(t *testing.T) {
|
||||
_, err := m.BySlug(ctx, "nope")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing id returns ErrNotFound", func(t *testing.T) {
|
||||
_, err := m.ByID(ctx, "deadbeef")
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returned tenant is a copy, not the stored pointer", func(t *testing.T) {
|
||||
got, err := m.BySlug(ctx, "acme")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got.Name = "mutated"
|
||||
got2, _ := m.BySlug(ctx, "acme")
|
||||
if got2.Name == "mutated" {
|
||||
t.Error("store leaked internal pointer; caller could mutate seeded state")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
DROP TABLE IF EXISTS audit_log;
|
||||
DROP TABLE IF EXISTS tenant_products;
|
||||
DROP TABLE IF EXISTS tenants;
|
||||
DROP TYPE IF EXISTS tenant_kind;
|
||||
DROP TYPE IF EXISTS tenant_status;
|
||||
@@ -0,0 +1,52 @@
|
||||
-- Placeholder for the M4.1 schema (see PLATFORM_ARCHITECTURE.md §5c).
|
||||
-- The skeleton uses an in-memory store; this file lands the table shape
|
||||
-- the real M4.1 PR will use, so the schema review can happen alongside
|
||||
-- the rest of the boot scaffolding.
|
||||
|
||||
-- enums --------------------------------------------------------------------
|
||||
|
||||
CREATE TYPE tenant_status AS ENUM ('trial', 'active', 'frozen', 'archived', 'demo');
|
||||
CREATE TYPE tenant_kind AS ENUM ('customer', 'demo', 'stage', 'internal');
|
||||
|
||||
-- tenants ------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE tenants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug TEXT UNIQUE NOT NULL CHECK (slug ~ '^[a-z0-9-]{2,40}$'),
|
||||
name TEXT NOT NULL,
|
||||
status tenant_status NOT NULL DEFAULT 'trial',
|
||||
kind tenant_kind NOT NULL DEFAULT 'customer',
|
||||
plan TEXT NOT NULL DEFAULT 'starter',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
trial_ends_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX tenants_status_idx ON tenants (status);
|
||||
|
||||
-- tenant ↔ product entitlements -------------------------------------------
|
||||
|
||||
CREATE TABLE tenant_products (
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
product TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (tenant_id, product)
|
||||
);
|
||||
|
||||
-- audit log (Retraced-shape; PRODUCT_INTEGRATION_SPEC.md §8.4) ------------
|
||||
|
||||
CREATE TABLE audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id UUID REFERENCES tenants(id),
|
||||
actor_id TEXT,
|
||||
actor_name TEXT,
|
||||
action TEXT NOT NULL,
|
||||
target_id TEXT,
|
||||
target_type TEXT,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
source_ip INET,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX audit_log_tenant_idx ON audit_log (tenant_id, created_at DESC);
|
||||
Reference in New Issue
Block a user