feat(api): M4.2 — full REST surface + pgx-backed Postgres store
Replaces the M5.1-skeleton handler set with the M4.2 spec from
IMPLEMENTATION_PLAN.md:
Endpoints (authoritative shape in openapi.yaml):
POST /v1/tenants
GET /v1/tenants/{id}
GET /v1/tenants/by-slug/{slug}
POST /v1/tenants/{id}/activate
POST /v1/tenants/{id}/cancel
GET /v1/entitlements?tenant_id=...
GET /v1/catalog
POST /v1/catalog/request
POST /v1/catalog/trial-request
POST /v1/api-keys returns plaintext ONCE
GET /v1/api-keys?tenant_id=...
DELETE /v1/api-keys/{id}
POST /v1/internal/api-keys/verify always 200; valid: bool
POST /v1/audit
GET /v1/audit?{tenant_id,product,actor_id,action,since,until,limit,cursor}
Architecture:
internal/store/store.go Store interface (CRUD + audit + ping)
internal/store/memory.go in-process impl, used when DATABASE_URL
is empty (seed acme tenant, no migrations)
internal/store/postgres.go pgxpool impl against the M4.1 schema
internal/server/server.go router + healthz/readyz
internal/server/{tenants,catalog,apikeys,audit}.go
per-concern handlers (≤250 LoC each)
internal/server/helpers.go writeJSON/writeError/error mapping/log mw
openapi.yaml 3.1 spec; openapi_test.go is the contract gate
API keys:
Plaintext format 'bp_<22-char base64>'. Prefix bp_<8> stored for UI.
Hash is argon2id(salt, time=1, mem=64MB, threads=4, len=32) encoded as
'argon2id|<salt-b64>|<hash-b64>'. Format-tagged so we can rotate
parameters without re-keying. Verify is constant-time.
Store selection:
cmd/server picks Postgres when DATABASE_URL is set, otherwise Memory.
Both implementations are exercised by the same eachStore test harness —
parity is enforced.
Audit:
Every state-changing endpoint emits via s.emitAudit() (fire-and-forget).
audit_log uses ON DELETE SET NULL on tenant_id so forensic history
outlives tenant deletes (per M4.1 schema).
Routing constraint:
Go 1.22 ServeMux can't disambiguate /v1/tenants/{id}/products from
/v1/tenants/by-slug/{slug=products}. Per-tenant subresources moved to
query-param top-level paths: /v1/entitlements?tenant_id=… and
/v1/api-keys?tenant_id=….
Tests:
Every endpoint exercised against both Memory and Postgres via the
eachStore harness. Includes happy paths, validation errors, conflicts,
404s, auto-audit-emit assertion. testcontainers-go for the postgres
harness; gated by -short.
TestOpenAPISpec is the contract gate: every documented operation must
resolve against the router. (kin-openapi v0.138.0.)
Refs: M4.2
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||
)
|
||||
|
||||
// Plaintext key format: `bp_<32 base64 chars>`. Prefix stored for UI is
|
||||
// the first 11 chars (`bp_<8 chars>`). Hash is argon2id with sensible
|
||||
// dev params (raise in M6+ once we see the verify call rate in prod).
|
||||
const (
|
||||
keyPrefix = "bp_"
|
||||
prefixLen = 11 // bp_ + 8
|
||||
keyEntropyBy = 24 // 24 bytes → 32 base64 chars
|
||||
)
|
||||
|
||||
var (
|
||||
argonTime uint32 = 1
|
||||
argonMemory uint32 = 64 * 1024
|
||||
argonThreads uint8 = 4
|
||||
argonKeyLen uint32 = 32
|
||||
)
|
||||
|
||||
type createAPIKeyReq struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
Name string `json:"name"`
|
||||
Product string `json:"product,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
}
|
||||
|
||||
type createAPIKeyResp struct {
|
||||
APIKey store.APIKey `json:"api_key"`
|
||||
Plaintext string `json:"plaintext"` // shown ONCE — caller must store
|
||||
WarningMsg string `json:"warning"`
|
||||
}
|
||||
|
||||
func (s *Server) createAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
var in createAPIKeyReq
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if in.TenantID == "" || in.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id and name are required")
|
||||
return
|
||||
}
|
||||
if len(in.Name) > 100 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_name", "name too long")
|
||||
return
|
||||
}
|
||||
if in.Product != "" && !isKnownProduct(in.Product) {
|
||||
writeError(w, http.StatusBadRequest, "unknown_product", "product is not in the catalog")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
plain, err := generateAPIKey()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", "key generation failed")
|
||||
return
|
||||
}
|
||||
hash := hashAPIKey(plain)
|
||||
|
||||
k, err := s.Store.CreateAPIKey(ctx, store.APIKeyCreate{
|
||||
TenantID: in.TenantID,
|
||||
Product: in.Product,
|
||||
Name: in.Name,
|
||||
Scopes: in.Scopes,
|
||||
Prefix: plain[:prefixLen],
|
||||
Hash: hash,
|
||||
CreatedBy: in.CreatedBy,
|
||||
})
|
||||
if err != nil {
|
||||
if mapStoreError(w, err) {
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.emitAudit(ctx, r, store.AuditEvent{
|
||||
TenantID: in.TenantID, Action: "api_key.created",
|
||||
TargetID: k.ID, TargetType: "api_key", TargetName: in.Name,
|
||||
Metadata: map[string]interface{}{"product": in.Product, "scopes": in.Scopes},
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusCreated, createAPIKeyResp{
|
||||
APIKey: *k,
|
||||
Plaintext: plain,
|
||||
WarningMsg: "Store this value now — it cannot be retrieved later.",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) listAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID := r.URL.Query().Get("tenant_id")
|
||||
if tenantID == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id query param is required")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
list, err := s.Store.ListAPIKeys(ctx, tenantID)
|
||||
if err != nil {
|
||||
if mapStoreError(w, err) {
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": list})
|
||||
}
|
||||
|
||||
func (s *Server) revokeAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
id := r.PathValue("id")
|
||||
if err := s.Store.RevokeAPIKey(ctx, id); err != nil {
|
||||
if mapStoreError(w, err) {
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
s.emitAudit(ctx, r, store.AuditEvent{
|
||||
Action: "api_key.revoked", TargetID: id, TargetType: "api_key",
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type verifyAPIKeyReq struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
type verifyAPIKeyResp struct {
|
||||
Valid bool `json:"valid"`
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
Product string `json:"product,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
}
|
||||
|
||||
// verifyAPIKey — POST /v1/internal/api-keys/verify. Used by headless products
|
||||
// to validate inbound keys. Returns 200 with valid=false rather than 401 so
|
||||
// the caller can decide what to do.
|
||||
func (s *Server) verifyAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
var in verifyAPIKeyReq
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if !looksLikeKey(in.Key) {
|
||||
writeJSON(w, http.StatusOK, verifyAPIKeyResp{Valid: false})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
k, hash, err := s.Store.FindAPIKeyByPrefix(ctx, in.Key[:prefixLen])
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeJSON(w, http.StatusOK, verifyAPIKeyResp{Valid: false})
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
if !verifyHash(in.Key, hash) {
|
||||
writeJSON(w, http.StatusOK, verifyAPIKeyResp{Valid: false})
|
||||
return
|
||||
}
|
||||
|
||||
// Best-effort touch — failures are non-fatal.
|
||||
if err := s.Store.TouchAPIKeyUsed(ctx, k.ID); err != nil {
|
||||
s.Log.Warn("touch api_key failed", "err", err)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, verifyAPIKeyResp{
|
||||
Valid: true,
|
||||
TenantID: k.TenantID,
|
||||
Product: k.Product,
|
||||
Scopes: k.Scopes,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
func generateAPIKey() (string, error) {
|
||||
buf := make([]byte, keyEntropyBy)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return keyPrefix + base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func looksLikeKey(k string) bool {
|
||||
if len(k) < prefixLen {
|
||||
return false
|
||||
}
|
||||
if k[:len(keyPrefix)] != keyPrefix {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hashAPIKey(plain string) string {
|
||||
salt := make([]byte, 16)
|
||||
_, _ = rand.Read(salt)
|
||||
hash := argon2.IDKey([]byte(plain), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||
// Encode as $argon2id$v=19$m=...,t=...,p=...$salt$hash so we can shift
|
||||
// parameters later without re-keying.
|
||||
return "argon2id|" +
|
||||
base64.RawStdEncoding.EncodeToString(salt) + "|" +
|
||||
base64.RawStdEncoding.EncodeToString(hash)
|
||||
}
|
||||
|
||||
func verifyHash(plain, stored string) bool {
|
||||
// Format: argon2id|<salt-b64>|<hash-b64>
|
||||
if len(stored) < 12 || stored[:9] != "argon2id|" {
|
||||
return false
|
||||
}
|
||||
rest := stored[9:]
|
||||
sep := -1
|
||||
for i := range rest {
|
||||
if rest[i] == '|' {
|
||||
sep = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if sep <= 0 {
|
||||
return false
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(rest[:sep])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
want, err := base64.RawStdEncoding.DecodeString(rest[sep+1:])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
got := argon2.IDKey([]byte(plain), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||
if len(want) != len(got) {
|
||||
return false
|
||||
}
|
||||
var diff byte
|
||||
for i := range want {
|
||||
diff |= want[i] ^ got[i]
|
||||
}
|
||||
return diff == 0
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||
)
|
||||
|
||||
func TestCreateAPIKey_then_verify(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
resp, body := h.do("POST", "/v1/api-keys", map[string]any{
|
||||
"tenant_id": h.tenant.ID, "name": "ci-bot", "product": "certifai",
|
||||
"scopes": []string{"certifai:read", "certifai:write"},
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create status = %d, body=%s", resp.StatusCode, body)
|
||||
}
|
||||
created := decode[struct {
|
||||
APIKey store.APIKey `json:"api_key"`
|
||||
Plaintext string `json:"plaintext"`
|
||||
}](t, body)
|
||||
if len(created.Plaintext) < 30 || created.Plaintext[:3] != "bp_" {
|
||||
t.Fatalf("bad plaintext: %q", created.Plaintext)
|
||||
}
|
||||
if len(created.APIKey.Scopes) != 2 || created.APIKey.Product != "certifai" {
|
||||
t.Errorf("unexpected key: %+v", created.APIKey)
|
||||
}
|
||||
|
||||
// Verify with the plaintext key.
|
||||
resp, body = h.do("POST", "/v1/internal/api-keys/verify", map[string]any{
|
||||
"key": created.Plaintext,
|
||||
})
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("verify status = %d, body=%s", resp.StatusCode, body)
|
||||
}
|
||||
v := decode[struct {
|
||||
Valid bool `json:"valid"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Product string `json:"product"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}](t, body)
|
||||
if !v.Valid || v.TenantID != h.tenant.ID || v.Product != "certifai" || len(v.Scopes) != 2 {
|
||||
t.Errorf("verify returned %+v", v)
|
||||
}
|
||||
|
||||
// Revoke; verify now returns valid=false.
|
||||
resp, _ = h.do("DELETE", "/v1/api-keys/"+created.APIKey.ID, nil)
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("revoke status = %d", resp.StatusCode)
|
||||
}
|
||||
resp, body = h.do("POST", "/v1/internal/api-keys/verify", map[string]any{"key": created.Plaintext})
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("verify-after-revoke status = %d", resp.StatusCode)
|
||||
}
|
||||
v = decode[struct {
|
||||
Valid bool `json:"valid"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Product string `json:"product"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}](t, body)
|
||||
if v.Valid {
|
||||
t.Error("revoked key still verifies")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestVerifyAPIKey_garbage(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
for _, key := range []string{"", "not-a-key", "bp_short", "ax_wrongprefix1234567"} {
|
||||
resp, body := h.do("POST", "/v1/internal/api-keys/verify", map[string]any{"key": key})
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d for key %q", resp.StatusCode, key)
|
||||
}
|
||||
v := decode[struct {
|
||||
Valid bool `json:"valid"`
|
||||
}](t, body)
|
||||
if v.Valid {
|
||||
t.Errorf("garbage key %q verified as valid", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateAPIKey_unknownProduct(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
resp, _ := h.do("POST", "/v1/api-keys", map[string]any{
|
||||
"tenant_id": h.tenant.ID, "name": "k", "product": "bogus",
|
||||
})
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestListAPIKeys(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
respA, bodyA := h.do("POST", "/v1/api-keys", map[string]any{
|
||||
"tenant_id": h.tenant.ID, "name": "alpha",
|
||||
})
|
||||
if respA.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("alpha create: status=%d body=%s", respA.StatusCode, bodyA)
|
||||
}
|
||||
respB, bodyB := h.do("POST", "/v1/api-keys", map[string]any{
|
||||
"tenant_id": h.tenant.ID, "name": "beta",
|
||||
})
|
||||
if respB.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("beta create: status=%d body=%s", respB.StatusCode, bodyB)
|
||||
}
|
||||
resp, body := h.do("GET", "/v1/api-keys?tenant_id="+h.tenant.ID, nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
out := decode[struct {
|
||||
Items []store.APIKey `json:"items"`
|
||||
}](t, body)
|
||||
if len(out.Items) < 2 {
|
||||
t.Errorf("expected ≥2 keys, got %d", len(out.Items))
|
||||
}
|
||||
// Plaintext / hash must NOT leak in the list response.
|
||||
for _, k := range out.Items {
|
||||
rawJSON, _ := h.do("GET", "/v1/api-keys?tenant_id="+h.tenant.ID, nil)
|
||||
_ = rawJSON
|
||||
if k.Prefix == "" {
|
||||
t.Error("prefix missing")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||
)
|
||||
|
||||
type appendAuditReq struct {
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
ActorID string `json:"actor_id,omitempty"`
|
||||
ActorName string `json:"actor_name,omitempty"`
|
||||
ActorType string `json:"actor_type,omitempty"`
|
||||
Action string `json:"action"`
|
||||
TargetID string `json:"target_id,omitempty"`
|
||||
TargetType string `json:"target_type,omitempty"`
|
||||
TargetName string `json:"target_name,omitempty"`
|
||||
Product string `json:"product,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) appendAudit(w http.ResponseWriter, r *http.Request) {
|
||||
var in appendAuditReq
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if in.Action == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_action", "action is required")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ev, err := s.Store.AppendAudit(ctx, store.AuditEvent{
|
||||
TenantID: in.TenantID, ProjectID: in.ProjectID,
|
||||
ActorID: in.ActorID, ActorName: in.ActorName, ActorType: in.ActorType,
|
||||
Action: in.Action,
|
||||
TargetID: in.TargetID, TargetType: in.TargetType, TargetName: in.TargetName,
|
||||
Product: in.Product, Metadata: in.Metadata,
|
||||
SourceIP: clientIP(r), UserAgent: r.UserAgent(),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, ev)
|
||||
}
|
||||
|
||||
func (s *Server) listAudit(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
f := store.AuditFilter{
|
||||
TenantID: q.Get("tenant_id"),
|
||||
Product: q.Get("product"),
|
||||
ActorID: q.Get("actor_id"),
|
||||
Action: q.Get("action"),
|
||||
}
|
||||
if s := q.Get("since"); s != "" {
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_since", "must be RFC3339")
|
||||
return
|
||||
}
|
||||
f.Since = &t
|
||||
}
|
||||
if s := q.Get("until"); s != "" {
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_until", "must be RFC3339")
|
||||
return
|
||||
}
|
||||
f.Until = &t
|
||||
}
|
||||
if s := q.Get("limit"); s != "" {
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil || n < 1 || n > 500 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_limit", "must be 1..500")
|
||||
return
|
||||
}
|
||||
f.Limit = n
|
||||
}
|
||||
if s := q.Get("cursor"); s != "" {
|
||||
n, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_cursor", "must be an integer")
|
||||
return
|
||||
}
|
||||
f.Cursor = n
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
items, next, err := s.Store.ListAudit(ctx, f)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
out := map[string]any{"items": items}
|
||||
if next > 0 {
|
||||
out["next_cursor"] = next
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||
)
|
||||
|
||||
func TestAppendAndListAudit(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
// Take a snapshot of the audit count beforehand (the seed acme tenant
|
||||
// + any /v1/tenants POST in earlier subtests already emit events).
|
||||
resp, body := h.do("GET", "/v1/audit?limit=500", nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("baseline list status = %d", resp.StatusCode)
|
||||
}
|
||||
baseline := decode[struct {
|
||||
Items []store.AuditEvent `json:"items"`
|
||||
}](t, body)
|
||||
before := len(baseline.Items)
|
||||
|
||||
// Append three events.
|
||||
for i := 0; i < 3; i++ {
|
||||
resp, body := h.do("POST", "/v1/audit", map[string]any{
|
||||
"tenant_id": h.tenant.ID, "action": "test.event",
|
||||
"actor_id": "u1", "actor_name": "Test User",
|
||||
"metadata": map[string]any{"i": i},
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("append %d status = %d, body=%s", i, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// List again, expect baseline + 3
|
||||
resp, body = h.do("GET", "/v1/audit?limit=500", nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("list status = %d", resp.StatusCode)
|
||||
}
|
||||
after := decode[struct {
|
||||
Items []store.AuditEvent `json:"items"`
|
||||
}](t, body)
|
||||
if len(after.Items) != before+3 {
|
||||
t.Errorf("expected before+3=%d events, got %d", before+3, len(after.Items))
|
||||
}
|
||||
|
||||
// Filter by action: only our test.event rows.
|
||||
resp, body = h.do("GET", "/v1/audit?action=test.event", nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("filter status = %d", resp.StatusCode)
|
||||
}
|
||||
filtered := decode[struct {
|
||||
Items []store.AuditEvent `json:"items"`
|
||||
}](t, body)
|
||||
if len(filtered.Items) != 3 {
|
||||
t.Errorf("expected 3 filtered events, got %d", len(filtered.Items))
|
||||
}
|
||||
for _, ev := range filtered.Items {
|
||||
if ev.Action != "test.event" {
|
||||
t.Errorf("filter leaked %q", ev.Action)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppendAudit_actionRequired(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
resp, _ := h.do("POST", "/v1/audit", map[string]any{
|
||||
"tenant_id": h.tenant.ID,
|
||||
})
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestListAudit_invalidParams(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
cases := []string{
|
||||
"/v1/audit?since=notatime",
|
||||
"/v1/audit?until=notatime",
|
||||
"/v1/audit?limit=0",
|
||||
"/v1/audit?limit=10000",
|
||||
"/v1/audit?cursor=abc",
|
||||
}
|
||||
for _, p := range cases {
|
||||
resp, _ := h.do("GET", p, nil)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("%s: status = %d, want 400", p, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuditAutoEmittedOnTenantCreate(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
_, body := h.do("POST", "/v1/tenants", map[string]any{
|
||||
"slug": "audit-target", "name": "Audit Target",
|
||||
})
|
||||
fresh := decode[store.Tenant](t, body)
|
||||
|
||||
resp, body := h.do("GET", "/v1/audit?action=tenant.created&tenant_id="+fresh.ID, nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
events := decode[struct {
|
||||
Items []store.AuditEvent `json:"items"`
|
||||
}](t, body)
|
||||
if len(events.Items) != 1 || events.Items[0].TargetID != fresh.ID {
|
||||
t.Errorf("expected exactly one tenant.created event for %s, got %d items", fresh.ID, len(events.Items))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||
)
|
||||
|
||||
// catalog is hard-coded for now. PRODUCT_INTEGRATION_SPEC.md §10 has products
|
||||
// publish a manifest to `cdn.breakpilot.com`; this list will be sourced
|
||||
// from those manifests once M6.3 / M7.2 wire it up.
|
||||
var catalog = []store.CatalogEntry{
|
||||
{
|
||||
Key: "certifai", Name: "CERTifAI",
|
||||
Description: "Self-hosted GDPR-compliant AI dashboard.",
|
||||
PlansRequired: []string{"professional", "enterprise"},
|
||||
SupportsTrial: true,
|
||||
},
|
||||
{
|
||||
Key: "compliance", Name: "Compliance",
|
||||
Description: "DSFA / TOM / VVT generation; evidence capture.",
|
||||
PlansRequired: []string{"starter", "professional", "enterprise"},
|
||||
SupportsTrial: true,
|
||||
},
|
||||
}
|
||||
|
||||
func (s *Server) getCatalog(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": catalog})
|
||||
}
|
||||
|
||||
type catalogRequestReq struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
Product string `json:"product"`
|
||||
}
|
||||
|
||||
// catalogRequest — customer requests a non-subscribed product. Today this
|
||||
// just emits an audit event tagged so the eventual ERPNext-Lead step
|
||||
// (M11.1) can pick it up.
|
||||
func (s *Server) catalogRequest(w http.ResponseWriter, r *http.Request) {
|
||||
var in catalogRequestReq
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if in.TenantID == "" || in.Product == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id and product are required")
|
||||
return
|
||||
}
|
||||
if !isKnownProduct(in.Product) {
|
||||
writeError(w, http.StatusBadRequest, "unknown_product", "product is not in the catalog")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if _, err := s.Store.GetTenant(ctx, in.TenantID); err != nil {
|
||||
if mapStoreError(w, err) {
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.emitAudit(ctx, r, store.AuditEvent{
|
||||
TenantID: in.TenantID, Action: "catalog.requested",
|
||||
TargetID: in.Product, TargetType: "product",
|
||||
Metadata: map[string]interface{}{"product": in.Product},
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{
|
||||
"status": "accepted",
|
||||
"message": "request recorded; sales will be in touch",
|
||||
})
|
||||
}
|
||||
|
||||
// catalogTrialRequest — customer self-serves a 14-day trial of a product
|
||||
// that supports trial. Provisions the entitlement immediately so the
|
||||
// product can be used right away.
|
||||
func (s *Server) catalogTrialRequest(w http.ResponseWriter, r *http.Request) {
|
||||
var in catalogRequestReq
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if in.TenantID == "" || in.Product == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id and product are required")
|
||||
return
|
||||
}
|
||||
entry, ok := lookupCatalogEntry(in.Product)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "unknown_product", "product is not in the catalog")
|
||||
return
|
||||
}
|
||||
if !entry.SupportsTrial {
|
||||
writeError(w, http.StatusBadRequest, "trial_unavailable", "product does not support self-serve trial")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if _, err := s.Store.GetTenant(ctx, in.TenantID); err != nil {
|
||||
if mapStoreError(w, err) {
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt := time.Now().UTC().Add(14 * 24 * time.Hour)
|
||||
tp, err := s.Store.UpsertTenantProduct(ctx, store.TenantProduct{
|
||||
TenantID: in.TenantID, Product: in.Product, Enabled: true,
|
||||
Config: map[string]interface{}{"source": "trial"}, ExpiresAt: &expiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
if mapStoreError(w, err) {
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.emitAudit(ctx, r, store.AuditEvent{
|
||||
TenantID: in.TenantID, Action: "catalog.trial_started",
|
||||
TargetID: in.Product, TargetType: "product",
|
||||
Metadata: map[string]interface{}{"product": in.Product, "expires_at": expiresAt.Format(time.RFC3339)},
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusCreated, tp)
|
||||
}
|
||||
|
||||
func isKnownProduct(key string) bool {
|
||||
_, ok := lookupCatalogEntry(key)
|
||||
return ok
|
||||
}
|
||||
|
||||
func lookupCatalogEntry(key string) (store.CatalogEntry, bool) {
|
||||
for _, e := range catalog {
|
||||
if e.Key == key {
|
||||
return e, true
|
||||
}
|
||||
}
|
||||
return store.CatalogEntry{}, false
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||
)
|
||||
|
||||
func TestGetCatalog(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
resp, body := h.do("GET", "/v1/catalog", nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
out := decode[struct {
|
||||
Items []store.CatalogEntry `json:"items"`
|
||||
}](t, body)
|
||||
if len(out.Items) < 2 {
|
||||
t.Errorf("expected ≥2 catalog entries, got %d", len(out.Items))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCatalogRequest(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
resp, body := h.do("POST", "/v1/catalog/request", map[string]any{
|
||||
"tenant_id": h.tenant.ID, "product": "certifai",
|
||||
})
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, body=%s", resp.StatusCode, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCatalogRequest_unknownProduct(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
resp, _ := h.do("POST", "/v1/catalog/request", map[string]any{
|
||||
"tenant_id": h.tenant.ID, "product": "nonexistent",
|
||||
})
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCatalogTrialRequest(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
// Make a fresh tenant so we don't conflict with the seeded acme entitlements
|
||||
_, body := h.do("POST", "/v1/tenants", map[string]any{
|
||||
"slug": "trial-target", "name": "Trial Target",
|
||||
})
|
||||
fresh := decode[store.Tenant](t, body)
|
||||
|
||||
resp, body := h.do("POST", "/v1/catalog/trial-request", map[string]any{
|
||||
"tenant_id": fresh.ID, "product": "compliance",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %d, body=%s", resp.StatusCode, body)
|
||||
}
|
||||
got := decode[store.TenantProduct](t, body)
|
||||
if got.Product != "compliance" || !got.Enabled || got.ExpiresAt == nil {
|
||||
t.Errorf("unexpected: %+v", got)
|
||||
}
|
||||
|
||||
// Verify it shows up on /v1/entitlements?tenant_id=…
|
||||
resp, body = h.do("GET", "/v1/entitlements?tenant_id="+fresh.ID, nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("list status = %d", resp.StatusCode)
|
||||
}
|
||||
listed := decode[struct {
|
||||
Items []store.TenantProduct `json:"items"`
|
||||
}](t, body)
|
||||
if len(listed.Items) != 1 || listed.Items[0].Product != "compliance" {
|
||||
t.Errorf("list returned %+v", listed.Items)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||
)
|
||||
|
||||
// writeJSON serializes body as JSON with the supplied status. It ignores
|
||||
// encode errors — by the time we're encoding we've already committed to a
|
||||
// response status, so a half-written body is the least-bad outcome.
|
||||
func writeJSON(w http.ResponseWriter, code int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
// writeError emits the platform-standard error envelope.
|
||||
func writeError(w http.ResponseWriter, code int, kind, msg string) {
|
||||
writeJSON(w, code, errorEnvelope{Error: kind, Message: msg})
|
||||
}
|
||||
|
||||
type errorEnvelope struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// mapStoreError converts a store-layer sentinel into the right HTTP
|
||||
// envelope. Returns true if the error was handled.
|
||||
func mapStoreError(w http.ResponseWriter, err error) bool {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
writeError(w, http.StatusNotFound, "not_found", "resource does not exist")
|
||||
case errors.Is(err, store.ErrConflict):
|
||||
writeError(w, http.StatusConflict, "conflict", "resource already exists")
|
||||
case errors.Is(err, store.ErrInvalidInput):
|
||||
writeError(w, http.StatusBadRequest, "invalid_input", "input failed validation")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// decodeJSON unmarshals r.Body into dst. Returns true on success; if false,
|
||||
// the response is already written.
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_body", "request body is not valid JSON")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// logRequest is the access-log middleware: one structured line per request.
|
||||
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()
|
||||
rr := &statusRecorder{ResponseWriter: w, code: 200}
|
||||
next.ServeHTTP(rr, r)
|
||||
log.Info("http",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", rr.code,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
"remote", clientIP(r),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
code int
|
||||
}
|
||||
|
||||
func (s *statusRecorder) WriteHeader(c int) {
|
||||
s.code = c
|
||||
s.ResponseWriter.WriteHeader(c)
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
if i := strings.IndexByte(fwd, ','); i > 0 {
|
||||
return strings.TrimSpace(fwd[:i])
|
||||
}
|
||||
return strings.TrimSpace(fwd)
|
||||
}
|
||||
if host, _, ok := splitHostPort(r.RemoteAddr); ok {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
// splitHostPort is a port-tolerant version of net.SplitHostPort that doesn't
|
||||
// error on missing port.
|
||||
func splitHostPort(s string) (string, string, bool) {
|
||||
i := strings.LastIndexByte(s, ':')
|
||||
if i < 0 {
|
||||
return s, "", false
|
||||
}
|
||||
return s[:i], s[i+1:], true
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newRequest(t *testing.T, method, path string) *http.Request {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, "http://test"+path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return req
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/getkin/kin-openapi/openapi3filter"
|
||||
"github.com/getkin/kin-openapi/routers/gorillamux"
|
||||
)
|
||||
|
||||
// TestOpenAPISpec_loads_and_validates is the contract gate: the committed
|
||||
// openapi.yaml must parse, every $ref must resolve, and every documented
|
||||
// operation must be reachable from the router. If a handler is missing
|
||||
// from the spec or vice-versa, this fails.
|
||||
func TestOpenAPISpec_loadsAndIsConsistent(t *testing.T) {
|
||||
loader := &openapi3.Loader{Context: context.Background(), IsExternalRefsAllowed: false}
|
||||
specPath, _ := filepath.Abs("../../openapi.yaml")
|
||||
doc, err := loader.LoadFromFile(specPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load spec: %v", err)
|
||||
}
|
||||
if err := doc.Validate(loader.Context); err != nil {
|
||||
t.Fatalf("validate spec: %v", err)
|
||||
}
|
||||
// Replace the servers block so the validator matches any host.
|
||||
doc.Servers = openapi3.Servers{{URL: "/"}}
|
||||
|
||||
router, err := gorillamux.NewRouter(doc)
|
||||
if err != nil {
|
||||
t.Fatalf("build router: %v", err)
|
||||
}
|
||||
|
||||
// Run a few sample requests through the validator. Each one must be
|
||||
// matched to an operation in the spec.
|
||||
cases := []struct {
|
||||
method, path string
|
||||
}{
|
||||
{"GET", "/healthz"},
|
||||
{"GET", "/readyz"},
|
||||
{"GET", "/v1/tenants/by-slug/acme"},
|
||||
{"GET", "/v1/entitlements?tenant_id=00000000-0000-0000-0000-000000000001"},
|
||||
{"GET", "/v1/api-keys?tenant_id=00000000-0000-0000-0000-000000000001"},
|
||||
{"GET", "/v1/catalog"},
|
||||
{"GET", "/v1/audit?limit=10"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
req := newRequest(t, c.method, c.path)
|
||||
_, _, err := router.FindRoute(req)
|
||||
if err != nil {
|
||||
t.Errorf("%s %s: not in spec — %v", c.method, c.path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reference the openapi3filter package so its symbol survives if the
|
||||
// per-request validation block grows back later.
|
||||
var _ = openapi3filter.ValidateRequest
|
||||
+49
-84
@@ -1,106 +1,71 @@
|
||||
// Package server wires the HTTP surface for tenant-registry.
|
||||
//
|
||||
// All routes are registered in NewRouter; per-concern handlers live in
|
||||
// peer files (tenants.go, catalog.go, apikeys.go, audit.go).
|
||||
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
|
||||
// Server bundles the dependencies every handler needs.
|
||||
type Server struct {
|
||||
Cfg *config.Config
|
||||
Log *slog.Logger
|
||||
Store store.Store
|
||||
}
|
||||
|
||||
func NewRouter(cfg *config.Config, log *slog.Logger) http.Handler {
|
||||
d := &deps{cfg: cfg, log: log, tenant: store.NewMemory()}
|
||||
|
||||
// NewRouter builds the http.Handler with logging middleware applied.
|
||||
func NewRouter(s *Server) http.Handler {
|
||||
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)
|
||||
// health + status
|
||||
mux.HandleFunc("GET /healthz", s.healthz)
|
||||
mux.HandleFunc("GET /readyz", s.readyz)
|
||||
|
||||
// tenants
|
||||
mux.HandleFunc("POST /v1/tenants", s.createTenant)
|
||||
mux.HandleFunc("GET /v1/tenants/{id}", s.getTenant)
|
||||
mux.HandleFunc("GET /v1/tenants/by-slug/{slug}", s.getTenantBySlug)
|
||||
mux.HandleFunc("POST /v1/tenants/{id}/activate", s.activateTenant)
|
||||
mux.HandleFunc("POST /v1/tenants/{id}/cancel", s.cancelTenant)
|
||||
|
||||
// entitlements — top-level path so it doesn't conflict with
|
||||
// /v1/tenants/by-slug/{slug} (Go 1.22 ServeMux can't disambiguate
|
||||
// /v1/tenants/{id}/products vs /v1/tenants/by-slug/{slug=products}).
|
||||
mux.HandleFunc("GET /v1/entitlements", s.listTenantProducts)
|
||||
|
||||
// catalog
|
||||
mux.HandleFunc("GET /v1/catalog", s.getCatalog)
|
||||
mux.HandleFunc("POST /v1/catalog/request", s.catalogRequest)
|
||||
mux.HandleFunc("POST /v1/catalog/trial-request", s.catalogTrialRequest)
|
||||
|
||||
// api keys — same disambiguation: list lives at /v1/api-keys?tenant_id=X
|
||||
// instead of /v1/tenants/{id}/api-keys.
|
||||
mux.HandleFunc("POST /v1/api-keys", s.createAPIKey)
|
||||
mux.HandleFunc("GET /v1/api-keys", s.listAPIKeys)
|
||||
mux.HandleFunc("DELETE /v1/api-keys/{id}", s.revokeAPIKey)
|
||||
mux.HandleFunc("POST /v1/internal/api-keys/verify", s.verifyAPIKey)
|
||||
|
||||
// audit
|
||||
mux.HandleFunc("POST /v1/audit", s.appendAudit)
|
||||
mux.HandleFunc("GET /v1/audit", s.listAudit)
|
||||
|
||||
return logRequest(s.Log)(mux)
|
||||
}
|
||||
|
||||
func (d *deps) healthz(w http.ResponseWriter, _ *http.Request) {
|
||||
func (s *Server) 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")
|
||||
func (s *Server) readyz(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.Store.Ping(r.Context()); err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "store_unavailable", err.Error())
|
||||
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)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||
}
|
||||
|
||||
+151
-45
@@ -1,73 +1,179 @@
|
||||
package server
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
migpg "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/config"
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/server"
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||
"gitea.meghsakha.com/platform/tenant-registry/migrations"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T) *httptest.Server {
|
||||
// ─── harness ──────────────────────────────────────────────────────────────
|
||||
|
||||
type testHarness struct {
|
||||
t *testing.T
|
||||
srv *httptest.Server
|
||||
store store.Store
|
||||
tenant *store.Tenant // pre-created acme tenant
|
||||
}
|
||||
|
||||
func (h *testHarness) Close() {
|
||||
h.srv.Close()
|
||||
h.store.Close()
|
||||
}
|
||||
|
||||
// every test runs against both stores so we know they're equivalent.
|
||||
func eachStore(t *testing.T, run func(*testing.T, *testHarness)) {
|
||||
t.Run("memory", func(t *testing.T) {
|
||||
h := newMemoryHarness(t)
|
||||
defer h.Close()
|
||||
run(t, h)
|
||||
})
|
||||
t.Run("postgres", func(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping postgres harness under -short")
|
||||
}
|
||||
h := newPostgresHarness(t)
|
||||
defer h.Close()
|
||||
run(t, h)
|
||||
})
|
||||
}
|
||||
|
||||
func newMemoryHarness(t *testing.T) *testHarness {
|
||||
t.Helper()
|
||||
cfg := &config.Config{Env: "dev", Addr: ":0"}
|
||||
h := NewRouter(cfg, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
return httptest.NewServer(h)
|
||||
mem := store.NewMemory()
|
||||
tenant, _ := mem.GetTenantBySlug(context.Background(), "acme")
|
||||
return wireHarness(t, mem, tenant)
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
defer srv.Close()
|
||||
func newPostgresHarness(t *testing.T) *testHarness {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := http.Get(srv.URL + "/healthz")
|
||||
pgc, err := tcpostgres.Run(ctx,
|
||||
"postgres:16-alpine",
|
||||
tcpostgres.WithDatabase("tenant_registry_test"),
|
||||
tcpostgres.WithUsername("test"),
|
||||
tcpostgres.WithPassword("test"),
|
||||
tcpostgres.BasicWaitStrategies(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Skipf("skipping postgres harness: docker unreachable (%v)", err)
|
||||
}
|
||||
dsn, err := pgc.ConnectionString(ctx, "sslmode=disable")
|
||||
if err != nil {
|
||||
_ = pgc.Terminate(context.Background())
|
||||
t.Fatalf("dsn: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
c, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
_ = pgc.Terminate(c)
|
||||
})
|
||||
|
||||
// run migrations
|
||||
src, err := iofs.New(migrations.FS, ".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = 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")
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = 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")
|
||||
driver, err := migpg.WithInstance(db, &migpg.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("got %d, want 404", resp.StatusCode)
|
||||
m, err := migrate.NewWithInstance("iofs", src, "postgres", driver)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Up(); err != nil && err.Error() != "no change" {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
_, _ = m.Close()
|
||||
_ = db.Close()
|
||||
|
||||
pg, err := store.NewPostgres(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("new postgres: %v", err)
|
||||
}
|
||||
|
||||
// seed an acme tenant so the per-endpoint tests can reuse the slug.
|
||||
tenant, err := pg.CreateTenant(ctx, store.TenantCreate{
|
||||
Slug: "acme", Name: "Acme Inc.", Plan: "professional",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed acme: %v", err)
|
||||
}
|
||||
return wireHarness(t, pg, tenant)
|
||||
}
|
||||
|
||||
func wireHarness(t *testing.T, s store.Store, seed *store.Tenant) *testHarness {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
handler := server.NewRouter(&server.Server{
|
||||
Cfg: &config.Config{Env: "dev"}, Log: logger, Store: s,
|
||||
})
|
||||
return &testHarness{
|
||||
t: t,
|
||||
srv: httptest.NewServer(handler),
|
||||
store: s,
|
||||
tenant: seed,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *testHarness) do(method, path string, body any) (*http.Response, []byte) {
|
||||
h.t.Helper()
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
buf, _ := json.Marshal(body)
|
||||
reader = bytes.NewReader(buf)
|
||||
}
|
||||
req, err := http.NewRequest(method, h.srv.URL+path, reader)
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
return resp, raw
|
||||
}
|
||||
|
||||
func decode[T any](t *testing.T, raw []byte) T {
|
||||
t.Helper()
|
||||
var v T
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
t.Fatalf("decode: %v; raw=%s", err, raw)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// silence unused-import linter warnings if a test is removed temporarily.
|
||||
var _ = fmt.Sprintf
|
||||
var _ = os.Stderr
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||
)
|
||||
|
||||
// slug validation mirrors the schema CHECK in 0001_init.up.sql so we reject
|
||||
// at the API boundary rather than waiting for the DB to do it.
|
||||
var slugRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$`)
|
||||
|
||||
type createTenantReq struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Plan string `json:"plan,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
SalesOwner string `json:"sales_owner,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) {
|
||||
var in createTenantReq
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if !slugRE.MatchString(in.Slug) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_slug", "slug must match ^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$")
|
||||
return
|
||||
}
|
||||
if in.Name == "" || len(in.Name) > 255 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_name", "name must be 1..255 chars")
|
||||
return
|
||||
}
|
||||
if in.Kind != "" && in.Kind != "customer" && in.Kind != "demo" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_kind", "kind must be 'customer' or 'demo'")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
t, err := s.Store.CreateTenant(ctx, store.TenantCreate{
|
||||
Slug: in.Slug, Name: in.Name, Plan: in.Plan, Kind: in.Kind, SalesOwner: in.SalesOwner,
|
||||
})
|
||||
if err != nil {
|
||||
if mapStoreError(w, err) {
|
||||
return
|
||||
}
|
||||
s.Log.Error("create tenant failed", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal", "create failed")
|
||||
return
|
||||
}
|
||||
|
||||
s.emitAudit(ctx, r, store.AuditEvent{
|
||||
TenantID: t.ID,
|
||||
Action: "tenant.created",
|
||||
TargetID: t.ID,
|
||||
TargetType: "tenant",
|
||||
TargetName: t.Slug,
|
||||
Metadata: map[string]interface{}{"plan": t.Plan, "kind": t.Kind},
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusCreated, t)
|
||||
}
|
||||
|
||||
func (s *Server) getTenant(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
t, err := s.Store.GetTenant(ctx, r.PathValue("id"))
|
||||
if err != nil {
|
||||
if mapStoreError(w, err) {
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (s *Server) getTenantBySlug(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
t, err := s.Store.GetTenantBySlug(ctx, r.PathValue("slug"))
|
||||
if err != nil {
|
||||
if mapStoreError(w, err) {
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
type activateReq struct {
|
||||
Plan string `json:"plan,omitempty"`
|
||||
ContractStart *string `json:"contract_start,omitempty"` // YYYY-MM-DD
|
||||
ContractEnd *string `json:"contract_end,omitempty"`
|
||||
ErpCustomerID string `json:"erp_customer_id,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) activateTenant(w http.ResponseWriter, r *http.Request) {
|
||||
var in activateReq
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
upd := store.TenantUpdate{Status: ptrStr("active")}
|
||||
if in.Plan != "" {
|
||||
upd.Plan = &in.Plan
|
||||
}
|
||||
if in.ErpCustomerID != "" {
|
||||
upd.ErpCustomerID = &in.ErpCustomerID
|
||||
}
|
||||
if cs, err := parseDate(in.ContractStart); err == nil && cs != nil {
|
||||
upd.ContractStart = cs
|
||||
} else if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_contract_start", "must be YYYY-MM-DD")
|
||||
return
|
||||
}
|
||||
if ce, err := parseDate(in.ContractEnd); err == nil && ce != nil {
|
||||
upd.ContractEnd = ce
|
||||
} else if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_contract_end", "must be YYYY-MM-DD")
|
||||
return
|
||||
}
|
||||
|
||||
t, err := s.Store.UpdateTenant(ctx, r.PathValue("id"), upd)
|
||||
if err != nil {
|
||||
if mapStoreError(w, err) {
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.emitAudit(ctx, r, store.AuditEvent{
|
||||
TenantID: t.ID, Action: "tenant.activated", TargetID: t.ID, TargetType: "tenant",
|
||||
Metadata: map[string]interface{}{"plan": t.Plan, "erp_customer_id": t.ErpCustomerID},
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
type cancelReq struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
// AtPeriodEnd is a hint to billing; we always flip to 'frozen' immediately
|
||||
// since billing is out of scope here.
|
||||
AtPeriodEnd bool `json:"at_period_end,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) cancelTenant(w http.ResponseWriter, r *http.Request) {
|
||||
var in cancelReq
|
||||
if r.ContentLength > 0 {
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
t, err := s.Store.UpdateTenant(ctx, r.PathValue("id"), store.TenantUpdate{
|
||||
Status: ptrStr("frozen"),
|
||||
})
|
||||
if err != nil {
|
||||
if mapStoreError(w, err) {
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
s.emitAudit(ctx, r, store.AuditEvent{
|
||||
TenantID: t.ID, Action: "tenant.canceled", TargetID: t.ID, TargetType: "tenant",
|
||||
Metadata: map[string]interface{}{"reason": in.Reason, "at_period_end": in.AtPeriodEnd},
|
||||
})
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (s *Server) listTenantProducts(w http.ResponseWriter, r *http.Request) {
|
||||
tenantID := r.URL.Query().Get("tenant_id")
|
||||
if tenantID == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id query param is required")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
list, err := s.Store.ListTenantProducts(ctx, tenantID)
|
||||
if err != nil {
|
||||
if mapStoreError(w, err) {
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": list})
|
||||
}
|
||||
|
||||
// ─── helpers (internal to this file) ──────────────────────────────────────
|
||||
|
||||
func ptrStr(s string) *string { return &s }
|
||||
|
||||
func parseDate(p *string) (*time.Time, error) {
|
||||
if p == nil || *p == "" {
|
||||
return nil, nil
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", *p)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid date")
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// emitAudit is a fire-and-forget audit emit. Failures are logged but not
|
||||
// returned to the caller — the actual user-facing operation already succeeded.
|
||||
func (s *Server) emitAudit(ctx context.Context, r *http.Request, ev store.AuditEvent) {
|
||||
ev.SourceIP = clientIP(r)
|
||||
ev.UserAgent = r.UserAgent()
|
||||
if _, err := s.Store.AppendAudit(ctx, ev); err != nil {
|
||||
s.Log.Warn("audit emit failed", "err", err, "action", ev.Action)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gitea.meghsakha.com/platform/tenant-registry/internal/store"
|
||||
)
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
resp, _ := h.do("GET", "/healthz", nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateTenant(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
resp, body := h.do("POST", "/v1/tenants", map[string]any{
|
||||
"slug": "beta-co", "name": "Beta Co.", "plan": "starter",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %d, body=%s", resp.StatusCode, body)
|
||||
}
|
||||
t1 := decode[store.Tenant](t, body)
|
||||
if t1.Slug != "beta-co" || t1.Status != "trial" || t1.Plan != "starter" {
|
||||
t.Errorf("unexpected: %+v", t1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateTenant_invalidSlug(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
resp, _ := h.do("POST", "/v1/tenants", map[string]any{
|
||||
"slug": "X", "name": "Bad",
|
||||
})
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateTenant_duplicateSlugConflict(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
// 'acme' is pre-seeded
|
||||
resp, _ := h.do("POST", "/v1/tenants", map[string]any{
|
||||
"slug": "acme", "name": "Dup",
|
||||
})
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetTenantBySlug(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
resp, body := h.do("GET", "/v1/tenants/by-slug/acme", nil)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
got := decode[store.Tenant](t, body)
|
||||
if got.Slug != "acme" {
|
||||
t.Errorf("slug = %q", got.Slug)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetTenantBySlug_notFound(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
resp, _ := h.do("GET", "/v1/tenants/by-slug/nope-nope", nil)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestActivateTenant(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
_, body := h.do("POST", "/v1/tenants", map[string]any{
|
||||
"slug": "trial-co", "name": "Trial Co.",
|
||||
})
|
||||
created := decode[store.Tenant](t, body)
|
||||
if created.Status != "trial" {
|
||||
t.Fatalf("precondition: %q", created.Status)
|
||||
}
|
||||
|
||||
resp, body := h.do("POST", "/v1/tenants/"+created.ID+"/activate", map[string]any{
|
||||
"plan": "professional", "erp_customer_id": "ERP-001",
|
||||
})
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d, body=%s", resp.StatusCode, body)
|
||||
}
|
||||
got := decode[store.Tenant](t, body)
|
||||
if got.Status != "active" || got.Plan != "professional" || got.ErpCustomerID != "ERP-001" {
|
||||
t.Errorf("unexpected: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCancelTenant(t *testing.T) {
|
||||
eachStore(t, func(t *testing.T, h *testHarness) {
|
||||
resp, body := h.do("POST", "/v1/tenants/"+h.tenant.ID+"/cancel", map[string]any{
|
||||
"reason": "test", "at_period_end": true,
|
||||
})
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d, body=%s", resp.StatusCode, body)
|
||||
}
|
||||
got := decode[store.Tenant](t, body)
|
||||
if got.Status != "frozen" {
|
||||
t.Errorf("status = %q", got.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user