From a49c426e4eaa4458f29c938b8567f29ab28ccac9 Mon Sep 17 00:00:00 2001 From: Manas Srivastava <[email protected]> Date: Fri, 22 May 2026 08:02:54 +0530 Subject: [PATCH] test(mongo): drive backend/mongo coverage to 96.2% Covers the MongoDB LocalBackend (CREATE USER with DB-scoped readWrite role, deprovision dropping user+db, storage scan) and the K8sBackend orchestration end-to-end against a real mongod (no-auth + authed fixtures) and a fake clientset. Two small testability seams, mirroring the existing initMongoFn pattern: - LocalBackend.connectFn: makes the lazy-connect / disconnect-error arms deterministically reachable (the real driver almost never errors on Connect). - K8sBackend.mongoPort + shared decodeStorageSize helper: lets the dbStats decode arms (int32/int64/float64/absent) be exercised without a cluster. Remaining uncovered lines are genuinely defensive (crypto/rand failure, multi-minute readiness/terminating deadlines, retry give-up) and not reachable without mocking the driver or multi-minute tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/backend/mongo/backend_test.go | 219 ++++++ internal/backend/mongo/coverage_extra_test.go | 629 ++++++++++++++++++ internal/backend/mongo/k8s.go | 48 +- internal/backend/mongo/k8s_lifecycle_test.go | 446 +++++++++++++ internal/backend/mongo/k8s_test.go | 513 ++++++++++++++ internal/backend/mongo/local_test.go | 369 ++++++++++ internal/backend/mongo/mongo.go | 53 +- 7 files changed, 2250 insertions(+), 27 deletions(-) create mode 100644 internal/backend/mongo/backend_test.go create mode 100644 internal/backend/mongo/coverage_extra_test.go create mode 100644 internal/backend/mongo/k8s_lifecycle_test.go create mode 100644 internal/backend/mongo/k8s_test.go create mode 100644 internal/backend/mongo/local_test.go diff --git a/internal/backend/mongo/backend_test.go b/internal/backend/mongo/backend_test.go new file mode 100644 index 0000000..d83dba3 --- /dev/null +++ b/internal/backend/mongo/backend_test.go @@ -0,0 +1,219 @@ +package mongo + +// backend_test.go — covers the factory helpers in backend.go. +// All tests are pure unit and require no live infra. + +import ( + "os" + "testing" +) + +// TestK8sEnv_FallbackAndOverride exercises both branches of k8sEnv. +func TestK8sEnv_FallbackAndOverride(t *testing.T) { + const key = "INSTANT_MONGO_TEST_KEY" + t.Setenv(key, "") + if got := k8sEnv(key, "fallback"); got != "fallback" { + t.Errorf("empty env: got %q, want fallback", got) + } + t.Setenv(key, "explicit") + if got := k8sEnv(key, "fallback"); got != "explicit" { + t.Errorf("explicit env: got %q, want explicit", got) + } + // Unset via OS so we cover the os.Getenv == "" branch directly. + os.Unsetenv(key) + if got := k8sEnv(key, "fb2"); got != "fb2" { + t.Errorf("unset env: got %q, want fb2", got) + } +} + +// TestK8sEnvInt_FallbackParseAndBad covers fallback / good parse / bad parse. +func TestK8sEnvInt_FallbackParseAndBad(t *testing.T) { + const key = "INSTANT_MONGO_TEST_INT" + t.Setenv(key, "") + if got := k8sEnvInt(key, 7); got != 7 { + t.Errorf("empty env: got %d, want 7", got) + } + t.Setenv(key, "42") + if got := k8sEnvInt(key, 7); got != 42 { + t.Errorf("explicit: got %d, want 42", got) + } + // Non-numeric falls back. + t.Setenv(key, "notanint") + if got := k8sEnvInt(key, 7); got != 7 { + t.Errorf("bad parse: got %d, want 7", got) + } +} + +// TestGoredisHelpers asserts the narrow aliases used by NewBackend really +// proxy through to goredis. ParseURL on a bad URL must error; on a good URL +// produce Options whose Addr matches; NewClient must return non-nil. +func TestGoredisHelpers(t *testing.T) { + if _, err := goredisParseURL("://not-a-url"); err == nil { + t.Error("goredisParseURL on bad URL: want error, got nil") + } + opts, err := goredisParseURL("redis://127.0.0.1:6379/0") + if err != nil { + t.Fatalf("goredisParseURL: %v", err) + } + if opts.Addr != "127.0.0.1:6379" { + t.Errorf("Addr = %q, want 127.0.0.1:6379", opts.Addr) + } + c := goredisNewClient(opts) + if c == nil { + t.Fatal("goredisNewClient: got nil") + } + _ = c.Close() +} + +// TestNewBackend_LocalDefault covers the default-case branch. +func TestNewBackend_LocalDefault(t *testing.T) { + b := NewBackend("", "mongodb://x:y@h:1", "h:1") + if _, ok := b.(*LocalBackend); !ok { + t.Fatalf("default backend type: got %T, want *LocalBackend", b) + } + // Unknown type also routes to local (default case). + b2 := NewBackend("unknown", "", "") + if _, ok := b2.(*LocalBackend); !ok { + t.Fatalf("unknown backend type: got %T, want *LocalBackend", b2) + } +} + +// TestNewBackend_K8sFallsBackToLocalWithoutKubeconfig hits the "k8s init failed, +// fall back to local" branch. We deliberately point K8S_KUBECONFIG at a path +// that doesn't exist so newK8sBackend returns an error; NewBackend must catch +// it and return a LocalBackend rather than panic / return nil. +func TestNewBackend_K8sFallsBackToLocalWithoutKubeconfig(t *testing.T) { + t.Setenv("K8S_KUBECONFIG", "/dev/null/does-not-exist") + t.Setenv("REDIS_URL_FOR_ROUTES", "") // ensure route registry path stays off + t.Setenv("REDIS_URL", "") + + b := NewBackend("k8s", "mongodb://x:y@h:1", "h:1") + if _, ok := b.(*LocalBackend); !ok { + t.Fatalf("k8s init failure: got %T, want fallback to *LocalBackend", b) + } +} + +// TestNewK8sDedicatedBackend_BadKubeconfig covers the exported entry point's +// error path. A missing kubeconfig file must surface an error instead of a +// panic / nil-deref. +func TestNewK8sDedicatedBackend_BadKubeconfig(t *testing.T) { + if _, err := NewK8sDedicatedBackend("/dev/null/does-not-exist", "", "", "", 0); err == nil { + t.Fatal("NewK8sDedicatedBackend: want error on bad kubeconfig, got nil") + } +} + +// writeFakeKubeconfig writes a minimal-but-valid kubeconfig to a temp dir and +// returns its path. clientcmd.BuildConfigFromFlags only parses; it does not +// dial the cluster, so a stub URL is fine. +func writeFakeKubeconfig(t *testing.T) string { + t.Helper() + const yaml = ` +apiVersion: v1 +kind: Config +clusters: +- name: stub + cluster: + server: https://127.0.0.1:6443 +contexts: +- name: stub + context: + cluster: stub + user: stub +current-context: stub +users: +- name: stub + user: + token: stubtoken +` + dir := t.TempDir() + path := dir + "/kubeconfig" + if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil { + t.Fatalf("write kubeconfig: %v", err) + } + return path +} + +// TestNewK8sDedicatedBackend_HappyPath covers the success branch of +// newK8sBackend: a parseable kubeconfig produces a non-nil backend whose +// defaults are applied (image=mongo:7, storageClass=gp3, storageSizeGi=50). +func TestNewK8sDedicatedBackend_HappyPath(t *testing.T) { + cfg := writeFakeKubeconfig(t) + b, err := NewK8sDedicatedBackend(cfg, "", "", "host.example", 0) + if err != nil { + t.Fatalf("NewK8sDedicatedBackend: %v", err) + } + kb, ok := b.(*K8sBackend) + if !ok { + t.Fatalf("got %T, want *K8sBackend", b) + } + if kb.image != "mongo:7" { + t.Errorf("default image = %q, want mongo:7", kb.image) + } + if kb.storageClass != "gp3" { + t.Errorf("default storageClass = %q, want gp3", kb.storageClass) + } + if kb.storageSizeGi != 50 { + t.Errorf("default storageSizeGi = %d, want 50", kb.storageSizeGi) + } + if kb.externalHost != "host.example" { + t.Errorf("externalHost = %q", kb.externalHost) + } +} + +// TestNewBackend_K8sSuccessPath_AppliesPublicHostAndRouteRegistry covers the +// k8s-success branch of NewBackend, including the redis route-registry wiring +// when REDIS_URL_FOR_ROUTES is set. +func TestNewBackend_K8sSuccessPath_AppliesPublicHostAndRouteRegistry(t *testing.T) { + cfg := writeFakeKubeconfig(t) + t.Setenv("K8S_KUBECONFIG", cfg) + t.Setenv("K8S_STORAGE_CLASS", "do-block-storage") + t.Setenv("K8S_MONGO_IMAGE", "mongo:6") + t.Setenv("K8S_EXTERNAL_HOST", "ext.example") + t.Setenv("K8S_MONGO_PUBLIC_HOST", "mongo.example.com") + t.Setenv("K8S_MONGO_STORAGE_GI", "200") + t.Setenv("REDIS_URL_FOR_ROUTES", "redis://127.0.0.1:6379/0") + t.Setenv("MONGO_PROXY_ROUTE_PREFIX", "mongo_route:") + t.Setenv("MONGO_PROXY_USER_ROUTE_PREFIX", "mongo_route_by_user:") + + b := NewBackend("k8s", "", "") + kb, ok := b.(*K8sBackend) + if !ok { + t.Fatalf("k8s backend: got %T, want *K8sBackend", b) + } + if kb.publicHost != "mongo.example.com" { + t.Errorf("publicHost = %q", kb.publicHost) + } + if kb.image != "mongo:6" { + t.Errorf("image = %q", kb.image) + } + if kb.storageClass != "do-block-storage" { + t.Errorf("storageClass = %q", kb.storageClass) + } + if kb.storageSizeGi != 200 { + t.Errorf("storageSizeGi = %d", kb.storageSizeGi) + } + if kb.rdb == nil { + t.Errorf("route registry not enabled despite REDIS_URL_FOR_ROUTES set") + } + if kb.routePrefix == "" || kb.userPrefix == "" { + t.Errorf("route prefixes empty: route=%q user=%q", kb.routePrefix, kb.userPrefix) + } +} + +// TestNewBackend_K8sSuccessPath_BadRedisURLSkipsRouteRegistry covers the +// "REDIS_URL_FOR_ROUTES is set but unparseable" branch — the backend still +// initialises, just without route registry. +func TestNewBackend_K8sSuccessPath_BadRedisURLSkipsRouteRegistry(t *testing.T) { + cfg := writeFakeKubeconfig(t) + t.Setenv("K8S_KUBECONFIG", cfg) + t.Setenv("REDIS_URL_FOR_ROUTES", "://not-a-url") + + b := NewBackend("k8s", "", "") + kb, ok := b.(*K8sBackend) + if !ok { + t.Fatalf("k8s backend: got %T, want *K8sBackend", b) + } + if kb.rdb != nil { + t.Errorf("route registry enabled with unparseable URL") + } +} diff --git a/internal/backend/mongo/coverage_extra_test.go b/internal/backend/mongo/coverage_extra_test.go new file mode 100644 index 0000000..35debe4 --- /dev/null +++ b/internal/backend/mongo/coverage_extra_test.go @@ -0,0 +1,629 @@ +package mongo + +// coverage_extra_test.go — closes the remaining branches in mongo.go +// (LocalBackend) and the K8sBackend Provision/StorageBytes happy-path tails +// that the fake-clientset lifecycle tests could not reach without a running +// mongod. +// +// The LocalBackend tests use the no-auth Mongo at CUSTOMER_MONGO_URL (default +// mongodb://127.0.0.1:27017). The auth-fail branch additionally needs an +// authenticated Mongo whose URI is given by CUSTOMER_MONGO_AUTH_URL +// (e.g. mongodb://root:rootpw@127.0.0.1:27018). Both skip cleanly when absent. +// +// The K8sBackend Provision-tail tests inject initMongoFn (the documented test +// seam) + a Ready pod so the orchestration runs end-to-end against a fake +// clientset, exercising the publicHost / NodePort URL builders and the route +// registry writes without standing up a real pod. + +import ( + "context" + "os" + "strconv" + "strings" + "testing" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + goredis "github.com/redis/go-redis/v9" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ktesting "k8s.io/client-go/testing" + "k8s.io/client-go/kubernetes/fake" + + "instant.dev/provisioner/internal/ctxkeys" +) + +// TestDecodeStorageSize_AllBSONNumericTypes exercises every arm of the dbStats +// storageSize decoder, including the BSON integer encodings (int32/int64) that +// a live mongod never emits for storageSize (it returns float64) and the +// missing / non-numeric fall-through. This is the regression guard that keeps +// the shared decoder tolerant of every server-version encoding. +func TestDecodeStorageSize_AllBSONNumericTypes(t *testing.T) { + cases := []struct { + name string + in bson.M + want int64 + }{ + {"int32", bson.M{"storageSize": int32(4096)}, 4096}, + {"int64", bson.M{"storageSize": int64(1 << 40)}, 1 << 40}, + {"float64", bson.M{"storageSize": float64(8192)}, 8192}, + {"absent", bson.M{"other": 1}, 0}, + {"nil-result", bson.M{}, 0}, + {"wrong-type", bson.M{"storageSize": "not-a-number"}, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := decodeStorageSize(tc.in); got != tc.want { + t.Errorf("decodeStorageSize(%v) = %d, want %d", tc.in, got, tc.want) + } + }) + } +} + +// hostPortFromAuthURL extracts the bare host:port from a mongodb:// URI that +// MAY carry user:pass@ credentials, stripping any credentials, path, and query. +// hostFromURI does NOT strip credentials (it keeps everything before the first +// '/'), so a credentialed URL would round-trip its creds — defeating the +// auth-fail test. This helper drops them. +func hostPortFromAuthURL(uri string) string { + u := strings.TrimPrefix(uri, "mongodb://") + if at := strings.Index(u, "@"); at >= 0 { + u = u[at+1:] + } + if i := strings.IndexAny(u, "/?"); i >= 0 { + u = u[:i] + } + return u +} + +// ─── LocalBackend: StorageBytes type-switch + insert-failure ──────────────── + +// TestLocalStorageBytes_DecodesNonZeroSize provisions a DB, writes enough data +// that dbStats reports a non-zero storageSize, and asserts StorageBytes decodes +// it. This drives the storageSize type-switch (int32/int64/float64) return arm +// that the empty-DB path never reaches. +func TestLocalStorageBytes_DecodesNonZeroSize(t *testing.T) { + uri, ok := liveMongoURI(t) + if !ok { + t.Skip("CUSTOMER_MONGO_URL unreachable; skipping") + } + b := newLocalBackend(uri, hostFromURI(uri)) + token := uniqueToken("size-nonzero") + defer func() { _ = b.Deprovision(context.Background(), token, "") }() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if _, err := b.Provision(ctx, token, "hobby"); err != nil { + t.Fatalf("Provision: %v", err) + } + + client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri)) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer client.Disconnect(ctx) + coll := client.Database(mongoDBName(token)).Collection("bulk") + docs := make([]interface{}, 0, 200) + for i := 0; i < 200; i++ { + docs = append(docs, bson.D{{Key: "i", Value: i}, {Key: "pad", Value: strings.Repeat("y", 256)}}) + } + if _, err := coll.InsertMany(ctx, docs); err != nil { + t.Fatalf("InsertMany: %v", err) + } + + got, err := b.StorageBytes(ctx, token, "") + if err != nil { + t.Fatalf("StorageBytes: %v", err) + } + // storageSize for a DB with real data must be > 0 — proving the type-switch + // decoded a concrete numeric value rather than falling through to 0. + if got <= 0 { + t.Errorf("StorageBytes after bulk insert = %d, want > 0", got) + } +} + +// TestLocalProvision_AuthFailReturnsError points the LocalBackend at an +// authenticated Mongo with NO credentials in the admin URI, so createUser is +// rejected with an authentication/authorization error — covering the +// result.Err() != nil branch of Provision under a real auth failure (distinct +// from the connect-parse failure already covered). +func TestLocalProvision_AuthFailReturnsError(t *testing.T) { + authURL := os.Getenv("CUSTOMER_MONGO_AUTH_URL") + if authURL == "" { + t.Skip("CUSTOMER_MONGO_AUTH_URL unset; skipping auth-fail branch") + } + // Strip credentials so createUser is unauthorized. + host := hostPortFromAuthURL(authURL) + noCredURI := "mongodb://" + host + b := newLocalBackend(noCredURI, host) + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + if _, err := b.Provision(ctx, uniqueToken("authfail"), "hobby"); err == nil { + t.Fatal("Provision against authed mongo without creds: want error, got nil") + } +} + +// TestLocalDeprovision_AuthFailReturnsError exercises the Deprovision drop +// path against an authed mongo with no creds: the canonical-DB Drop is +// rejected, so the function returns the wrapped drop error. +func TestLocalDeprovision_AuthFailReturnsError(t *testing.T) { + authURL := os.Getenv("CUSTOMER_MONGO_AUTH_URL") + if authURL == "" { + t.Skip("CUSTOMER_MONGO_AUTH_URL unset; skipping auth-fail branch") + } + host := hostPortFromAuthURL(authURL) + b := newLocalBackend("mongodb://"+host, host) + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + if err := b.Deprovision(ctx, uniqueToken("authfail-drop"), ""); err == nil { + t.Fatal("Deprovision against authed mongo without creds: want drop error, got nil") + } +} + +// TestK8sStorageBytes_AuthFailDrainsCandidates points the dbStats probe at the +// AUTHED mongo with a WRONG root password planted in the Secret. The connection +// is established but every candidate's dbStats RunCommand fails with +// AuthenticationFailed, so the loop drains each candidate through the +// lastErr=err;continue arm and the function returns the wrapped lastErr after +// the loop — covering the candidate-miss continue + post-loop error arms. +func TestK8sStorageBytes_AuthFailDrainsCandidates(t *testing.T) { + authURL := os.Getenv("CUSTOMER_MONGO_AUTH_URL") + if authURL == "" { + t.Skip("CUSTOMER_MONGO_AUTH_URL unset; skipping auth-fail drain") + } + host := hostPortFromAuthURL(authURL) + hostOnly := host + portNum := 27017 + if i := strings.LastIndex(host, ":"); i >= 0 { + hostOnly = host[:i] + if n, perr := strconv.Atoi(host[i+1:]); perr == nil { + portNum = n + } + } + const token = "k8s-authfail" + ns := mongoK8sNsPrefix + token + cs := fake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "mongo-admin", Namespace: ns}, + Data: map[string][]byte{"MONGO_INITDB_ROOT_PASSWORD": []byte("wrong-password")}, + }, + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "mongodb", Namespace: ns}, + Spec: corev1.ServiceSpec{ClusterIP: hostOnly}, + }, + ) + b := &K8sBackend{cs: cs, mongoPort: portNum} + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if _, err := b.StorageBytes(ctx, token, ""); err == nil { + t.Fatal("StorageBytes with wrong creds: want drained dbStats error, got nil") + } +} + +// ─── K8sBackend: applyNamespace terminating-wait ctx-cancel ───────────────── + +// TestApplyNamespace_TerminatingCtxCancel exercises the ctx.Done() arm of the +// terminating-namespace wait loop: a namespace stuck in Terminating that never +// drains, with a short context, must return ctx.Err() from inside the loop +// rather than spinning to the 2-minute deadline. +func TestApplyNamespace_TerminatingCtxCancel(t *testing.T) { + const ns = "instant-customer-mongo-stuckterm" + cs := fake.NewSimpleClientset(&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: ns}, + Status: corev1.NamespaceStatus{Phase: corev1.NamespaceTerminating}, + }) + b := &K8sBackend{cs: cs} + // Context expires well before the loop's 3s poll completes its first + // iteration's Get, so the select hits ctx.Done(). + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + defer cancel() + err := b.applyNamespace(ctx, ns) + if err == nil { + t.Fatal("applyNamespace: want ctx error for stuck-Terminating ns, got nil") + } +} + +// ─── LocalBackend: connect-error branches via the connectFn seam ──────────── + +// errConnect is a connectFn that always fails. The real mongo.Connect almost +// never errors (a bad URI surfaces lazily on first RunCommand), so the +// connect-error arms of Provision / StorageBytes / Deprovision are otherwise +// unreachable. +func errConnect(ctx context.Context, opts ...*options.ClientOptions) (*mongo.Client, error) { + return nil, errSyntheticConnect +} + +var errSyntheticConnect = mongoSyntheticErr("synthetic connect failure") + +type mongoSyntheticErr string + +func (e mongoSyntheticErr) Error() string { return string(e) } + +func TestLocalProvision_ConnectFnError(t *testing.T) { + b := newLocalBackend("mongodb://127.0.0.1:27017", "127.0.0.1:27017") + b.connectFn = errConnect + if _, err := b.Provision(context.Background(), "tok", "hobby"); err == nil { + t.Fatal("Provision: want connect error from seam, got nil") + } +} + +func TestLocalStorageBytes_ConnectFnError(t *testing.T) { + b := newLocalBackend("mongodb://127.0.0.1:27017", "127.0.0.1:27017") + b.connectFn = errConnect + got, err := b.StorageBytes(context.Background(), "tok", "") + // StorageBytes is fail-open: a connect failure must yield (0, nil). + if err != nil { + t.Errorf("StorageBytes connect-fail: want nil err (fail-open), got %v", err) + } + if got != 0 { + t.Errorf("StorageBytes connect-fail: got %d, want 0", got) + } +} + +func TestLocalDeprovision_ConnectFnError(t *testing.T) { + b := newLocalBackend("mongodb://127.0.0.1:27017", "127.0.0.1:27017") + b.connectFn = errConnect + if err := b.Deprovision(context.Background(), "tok", ""); err == nil { + t.Fatal("Deprovision: want connect error from seam, got nil") + } +} + +// TestLocalConnect_DefaultsToRealDriver asserts the seam falls through to the +// real mongo.Connect when connectFn is nil — the prod path. +func TestLocalConnect_DefaultsToRealDriver(t *testing.T) { + b := newLocalBackend("mongodb://127.0.0.1:27017", "127.0.0.1:27017") + c, err := b.connect(context.Background(), options.Client().ApplyURI(b.adminURI)) + if err != nil { + t.Fatalf("default connect: %v", err) + } + _ = c.Disconnect(context.Background()) +} + +// preDisconnectedConnect returns a connectFn that hands back a client which has +// ALREADY been disconnected, so the method's deferred client.Disconnect(ctx) +// returns "client is disconnected" — exercising the disconnect-error log arms +// that a healthy client never triggers. The pre-disconnected client also makes +// the method body's RunCommand fail, which is fine: we only assert the method +// runs to its deferred-disconnect path. +func preDisconnectedConnect(t *testing.T) func(ctx context.Context, opts ...*options.ClientOptions) (*mongo.Client, error) { + return func(ctx context.Context, opts ...*options.ClientOptions) (*mongo.Client, error) { + c, err := mongo.Connect(context.Background(), opts...) + if err != nil { + return nil, err + } + // Disconnect now so the caller's deferred Disconnect errors. + _ = c.Disconnect(context.Background()) + return c, nil + } +} + +func TestLocalProvision_DisconnectErrorLogged(t *testing.T) { + uri, ok := liveMongoURI(t) + if !ok { + t.Skip("CUSTOMER_MONGO_URL unreachable; skipping") + } + b := newLocalBackend(uri, hostFromURI(uri)) + b.connectFn = preDisconnectedConnect(t) + // RunCommand on the disconnected client fails → Provision returns an error, + // but the deferred Disconnect runs first and logs the disconnect error. + if _, err := b.Provision(context.Background(), uniqueToken("disc-prov"), "hobby"); err == nil { + t.Fatal("Provision on pre-disconnected client: want error, got nil") + } +} + +func TestLocalStorageBytes_DisconnectErrorLogged(t *testing.T) { + uri, ok := liveMongoURI(t) + if !ok { + t.Skip("CUSTOMER_MONGO_URL unreachable; skipping") + } + b := newLocalBackend(uri, hostFromURI(uri)) + b.connectFn = preDisconnectedConnect(t) + // Fail-open: returns (0, nil) but the deferred Disconnect-error log fires. + if got, err := b.StorageBytes(context.Background(), uniqueToken("disc-sz"), ""); err != nil || got != 0 { + t.Errorf("StorageBytes pre-disconnected: got (%d,%v), want (0,nil)", got, err) + } +} + +func TestLocalDeprovision_DisconnectErrorLogged(t *testing.T) { + uri, ok := liveMongoURI(t) + if !ok { + t.Skip("CUSTOMER_MONGO_URL unreachable; skipping") + } + b := newLocalBackend(uri, hostFromURI(uri)) + b.connectFn = preDisconnectedConnect(t) + // dropUser/dropDatabase on the disconnected client error; the canonical-DB + // Drop error propagates, and the deferred Disconnect-error log fires too. + if err := b.Deprovision(context.Background(), uniqueToken("disc-drop"), ""); err == nil { + t.Fatal("Deprovision on pre-disconnected client: want error, got nil") + } +} + +// ─── K8sBackend: Provision happy-path tail via injected initMongoFn ───────── + +// readyPodReactor returns a reactor that makes Pods List always report one +// Ready mongodb pod so waitPodReady returns immediately. +func readyPodReactor(ns string) func(ktesting.Action) (bool, runtime.Object, error) { + return func(a ktesting.Action) (bool, runtime.Object, error) { + return true, &corev1.PodList{Items: []corev1.Pod{{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mongodb-ready", + Namespace: ns, + Labels: map[string]string{"app": "mongodb"}, + }, + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{ + {Type: corev1.PodReady, Status: corev1.ConditionTrue}, + }, + }, + }}}, nil + } +} + +// TestK8sProvision_HappyPath_PublicHostURL drives Provision end-to-end with a +// no-op initMongoFn + a Ready pod, and publicHost set — covering the +// publicHost connURL branch and the success return. +func TestK8sProvision_HappyPath_PublicHostURL(t *testing.T) { + const token = "happy-pub" + ns := mongoK8sNsPrefix + token + cs := fake.NewSimpleClientset() + cs.PrependReactor("list", "pods", readyPodReactor(ns)) + b := &K8sBackend{cs: cs, image: "mongo:7", storageClass: "gp3", publicHost: "mongo.instanode.dev"} + b.initMongoFn = func(ctx context.Context, adminURI, dbName, appUser, appPass string) error { + return nil // skip the real mongod init + } + baseCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // Carry a teamID so the namespace gets labelled — exercises the + // ctx.Value(TeamIDKey) propagation branch inside Provision. + ctx := context.WithValue(baseCtx, ctxkeys.TeamIDKey, "team-aaaa-bbbb") + creds, err := b.Provision(ctx, token, "hobby") + if err != nil { + t.Fatalf("Provision: %v", err) + } + if creds == nil { + t.Fatal("Provision returned nil creds on success") + } + if !strings.Contains(creds.URL, "mongo.instanode.dev:27017") { + t.Errorf("URL missing public host: %q", creds.URL) + } + if !strings.Contains(creds.URL, "authSource=") { + t.Errorf("URL missing authSource: %q", creds.URL) + } + if creds.ProviderResourceID != ns { + t.Errorf("ProviderResourceID = %q, want %q", creds.ProviderResourceID, ns) + } + if creds.DatabaseName != mongoDBName(token) { + t.Errorf("DatabaseName = %q, want %q", creds.DatabaseName, mongoDBName(token)) + } +} + +// TestK8sProvision_HappyPath_NodePortURL covers the NodePort fallback connURL +// branch (publicHost empty) plus the route-registry writes (rdb set against an +// unreachable redis is warn-only and must not fail the provision). +func TestK8sProvision_HappyPath_NodePortURL(t *testing.T) { + const token = "happy-np" + ns := mongoK8sNsPrefix + token + cs := fake.NewSimpleClientset() + cs.PrependReactor("list", "pods", readyPodReactor(ns)) + // Force a NodePort onto the created Service so the NodePort URL is built. + cs.PrependReactor("create", "services", func(a ktesting.Action) (bool, runtime.Object, error) { + ca, ok := a.(ktesting.CreateAction) + if !ok { + return false, nil, nil + } + svc := ca.GetObject().(*corev1.Service) + svc.Spec.ClusterIP = "10.0.0.5" + if len(svc.Spec.Ports) > 0 { + svc.Spec.Ports[0].NodePort = 31234 + } + return false, nil, nil + }) + // Route registry pointed at a dead redis — Set fails, logged as warn only. + rdb := goredis.NewClient(&goredis.Options{ + Addr: "127.0.0.1:1", + DialTimeout: 200 * time.Millisecond, + MaxRetries: -1, + }) + defer rdb.Close() + b := &K8sBackend{cs: cs, image: "mongo:7", storageClass: "gp3", externalHost: "node.example.com"} + b.EnableRouteRegistry(rdb, "") + b.initMongoFn = func(ctx context.Context, adminURI, dbName, appUser, appPass string) error { return nil } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + creds, err := b.Provision(ctx, token, "hobby") + if err != nil { + t.Fatalf("Provision: %v", err) + } + if !strings.Contains(creds.URL, "node.example.com:31234") { + t.Errorf("URL missing NodePort host:port: %q", creds.URL) + } +} + +// TestK8sProvision_HappyPath_RouteRegistrySucceeds drives the route-registry +// SUCCESS arm (both Set calls succeed) using a live redis when REDIS_URL_TEST +// is set. Skips otherwise. +func TestK8sProvision_HappyPath_RouteRegistrySucceeds(t *testing.T) { + redisURL := os.Getenv("REDIS_URL_TEST") + if redisURL == "" { + t.Skip("REDIS_URL_TEST unset; skipping route-registry success arm") + } + opt, err := goredis.ParseURL(redisURL) + if err != nil { + t.Skipf("bad REDIS_URL_TEST: %v", err) + } + rdb := goredis.NewClient(opt) + defer rdb.Close() + pingCtx, pc := context.WithTimeout(context.Background(), 2*time.Second) + defer pc() + if err := rdb.Ping(pingCtx).Err(); err != nil { + t.Skipf("REDIS_URL_TEST unreachable: %v", err) + } + + const token = "happy-route" + ns := mongoK8sNsPrefix + token + cs := fake.NewSimpleClientset() + cs.PrependReactor("list", "pods", readyPodReactor(ns)) + b := &K8sBackend{cs: cs, image: "mongo:7", storageClass: "gp3", publicHost: "mongo.instanode.dev"} + b.EnableRouteRegistry(rdb, "") + b.initMongoFn = func(ctx context.Context, adminURI, dbName, appUser, appPass string) error { return nil } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if _, err := b.Provision(ctx, token, "hobby"); err != nil { + t.Fatalf("Provision: %v", err) + } + // The proxy-consumed user route key must exist. + val, err := rdb.Get(ctx, b.userPrefix+mongoUserName(token)).Result() + if err != nil || !strings.Contains(val, ns) { + t.Errorf("user route key = %q, err=%v; want fqdn containing %q", val, err, ns) + } +} + +// ─── K8sBackend: initMongo / tryInitMongo success arms ────────────────────── + +// TestK8sTryInitMongo_SuccessAgainstLiveMongo drives the tryInitMongo SUCCESS +// path: against the no-auth dev Mongo, createUser on the customer DB succeeds, +// so tryInitMongo returns nil — covering the createUser-OK return arm. +func TestK8sTryInitMongo_SuccessAgainstLiveMongo(t *testing.T) { + uri, ok := liveMongoURI(t) + if !ok { + t.Skip("CUSTOMER_MONGO_URL unreachable; skipping") + } + b := &K8sBackend{} + token := uniqueToken("k8s-init") + dbName := mongoDBName(token) + user := mongoUserName(token) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := b.tryInitMongo(ctx, uri, dbName, user, "pw-"+token); err != nil { + t.Fatalf("tryInitMongo against live mongo: %v", err) + } + // initMongo wraps tryInitMongo with retries; the first attempt succeeds so + // it returns nil immediately (covers the err==nil return arm of initMongo). + token2 := uniqueToken("k8s-init2") + if err := b.initMongo(ctx, uri, mongoDBName(token2), mongoUserName(token2), "pw2"); err != nil { + t.Fatalf("initMongo against live mongo: %v", err) + } + // Cleanup the users we created on the admin-less instance. + cleanup := newLocalBackend(uri, hostFromURI(uri)) + _ = cleanup.Deprovision(context.Background(), token, "") + _ = cleanup.Deprovision(context.Background(), token2, "") + // Drop the created users explicitly (they live in the customer DB here). + if c, err := mongo.Connect(context.Background(), options.Client().ApplyURI(uri)); err == nil { + _ = c.Database(dbName).RunCommand(context.Background(), bson.D{{Key: "dropUser", Value: user}}) + _ = c.Database(dbName).Drop(context.Background()) + _ = c.Disconnect(context.Background()) + } +} + +// TestK8sInitMongo_RetriesThenGivesUp drives initMongo's retry loop to +// exhaustion against a reachable-but-auth-failing target so each attempt +// returns a RETRYABLE error ("AuthenticationFailed"); with a context that +// outlives a couple of retry sleeps but the backend never recovers, the loop +// either gives up or exits on ctx.Done — covering the retry-sleep + give-up +// arms. We point at the AUTHED mongo with bad creds so every createUser fails +// with AuthenticationFailed (retryable), and use a short context. +func TestK8sInitMongo_RetriesThenGivesUp(t *testing.T) { + authURL := os.Getenv("CUSTOMER_MONGO_AUTH_URL") + if authURL == "" { + t.Skip("CUSTOMER_MONGO_AUTH_URL unset; skipping retry-exhaustion arm") + } + host := hostPortFromAuthURL(authURL) + // No credentials → createUser returns AuthenticationFailed (retryable). + badURI := "mongodb://" + host + "/admin?serverSelectionTimeoutMS=300" + b := &K8sBackend{} + // Context that allows a couple of 2s retry sleeps then expires, so the loop + // exits via ctx.Done() (the retry-sleep arm) rather than burning all 15. + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := b.initMongo(ctx, badURI, "db_x", "usr_x", "pw"); err == nil { + t.Fatal("initMongo: want error after retries against auth-failing mongo, got nil") + } +} + +// ─── K8sBackend: StorageBytes successful dbStats decode ───────────────────── + +// TestK8sStorageBytes_DecodesAgainstAuthedMongo plants a Secret + Service whose +// ClusterIP+port point at a real AUTHENTICATED Mongo so the dbStats RunCommand +// actually SUCCEEDS and the storageSize type-switch decode arm runs — the one +// arm the unreachable / missing-secret fail-soft tests never reach. +// +// StorageBytes builds mongodb://root:@:/admin. We +// set ClusterIP to the auth Mongo's host and the test-only mongoPort seam to +// its port, with the planted Secret carrying the matching root password. +// Provide the URI via CUSTOMER_MONGO_AUTH_URL (e.g. +// mongodb://root:rootpw@127.0.0.1:27018); skips cleanly when absent. +func TestK8sStorageBytes_DecodesAgainstAuthedMongo(t *testing.T) { + authURL := os.Getenv("CUSTOMER_MONGO_AUTH_URL") + if authURL == "" { + t.Skip("CUSTOMER_MONGO_AUTH_URL unset; skipping k8s dbStats decode") + } + rest := strings.TrimPrefix(authURL, "mongodb://") + at := strings.Index(rest, "@") + if at < 0 { + t.Skip("CUSTOMER_MONGO_AUTH_URL has no credentials; skipping") + } + cp := strings.SplitN(rest[:at], ":", 2) + if len(cp) != 2 { + t.Skip("CUSTOMER_MONGO_AUTH_URL credential not user:pass; skipping") + } + rootPass := cp[1] + host := hostPortFromAuthURL(authURL) + hostOnly := host + portNum := 27017 + if i := strings.LastIndex(host, ":"); i >= 0 { + hostOnly = host[:i] + if n, perr := strconv.Atoi(host[i+1:]); perr == nil { + portNum = n + } + } + + const token = "k8s-decode" + ns := mongoK8sNsPrefix + token + dbName := mongoDBName(token) + + // Seed the canonical DB with real data so dbStats reports a concrete + // numeric storageSize and the type-switch decode arm runs (rather than the + // non-existent-DB fall-through). Clean up afterwards. + seedCtx, seedCancel := context.WithTimeout(context.Background(), 8*time.Second) + defer seedCancel() + seedClient, serr := mongo.Connect(seedCtx, options.Client().ApplyURI(authURL)) + if serr != nil { + t.Skipf("seed connect failed: %v", serr) + } + defer seedClient.Disconnect(context.Background()) + seedColl := seedClient.Database(dbName).Collection("seed") + seedDocs := make([]interface{}, 0, 50) + for i := 0; i < 50; i++ { + seedDocs = append(seedDocs, bson.D{{Key: "i", Value: i}, {Key: "pad", Value: strings.Repeat("z", 128)}}) + } + if _, serr := seedColl.InsertMany(seedCtx, seedDocs); serr != nil { + t.Skipf("seed insert failed: %v", serr) + } + defer func() { _ = seedClient.Database(dbName).Drop(context.Background()) }() + + cs := fake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "mongo-admin", Namespace: ns}, + Data: map[string][]byte{"MONGO_INITDB_ROOT_PASSWORD": []byte(rootPass)}, + }, + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "mongodb", Namespace: ns}, + Spec: corev1.ServiceSpec{ClusterIP: hostOnly}, + }, + ) + b := &K8sBackend{cs: cs, mongoPort: portNum} + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + got, err := b.StorageBytes(ctx, token, "") + if err != nil { + t.Fatalf("StorageBytes: %v", err) + } + if got <= 0 { + t.Errorf("StorageBytes = %d, want > 0 after seeding data", got) + } +} diff --git a/internal/backend/mongo/k8s.go b/internal/backend/mongo/k8s.go index ca5355f..2a60187 100644 --- a/internal/backend/mongo/k8s.go +++ b/internal/backend/mongo/k8s.go @@ -182,7 +182,7 @@ func sizingForTier(tier string) tierSizing { // K8sBackend provisions a dedicated MongoDB pod per token. type K8sBackend struct { - cs *kubernetes.Clientset + cs kubernetes.Interface // kubernetes.Interface allows fake.Clientset in tests storageClass string // K8S_STORAGE_CLASS image string // K8S_MONGO_IMAGE externalHost string // K8S_EXTERNAL_HOST (legacy NodePort host; kept for back-compat) @@ -197,6 +197,28 @@ type K8sBackend struct { rdb *goredis.Client routePrefix string userPrefix string + + // initMongoFn is the seam between Provision and initMongo. Defaulting + // to (b *K8sBackend).initMongo in newK8sBackend; tests substitute a + // no-op to drive the Provision happy path against a fake clientset + // without standing up a real mongod. Never overridden in prod paths. + initMongoFn func(ctx context.Context, adminURI, dbName, appUser, appPass string) error + + // mongoPort is the port StorageBytes dials on the customer pod's Service + // ClusterIP. Zero means the canonical 27017 (every prod pod listens there); + // tests override it to point the dbStats probe at a real Mongo bound to a + // non-default host port so the successful-decode arm is exercisable without + // a cluster. Never set in prod paths. + mongoPort int +} + +// mongoPortOr27017 returns the configured StorageBytes dial port, defaulting to +// the canonical 27017 when unset. +func (b *K8sBackend) mongoPortOr27017() int { + if b.mongoPort != 0 { + return b.mongoPort + } + return 27017 } func newK8sBackend(kubeconfigPath, storageClass, image, externalHost string, storageSizeGi int) (*K8sBackend, error) { @@ -224,7 +246,9 @@ func newK8sBackend(kubeconfigPath, storageClass, image, externalHost string, sto if storageSizeGi <= 0 { storageSizeGi = 50 } - return &K8sBackend{cs: cs, storageClass: storageClass, image: image, externalHost: externalHost, storageSizeGi: storageSizeGi}, nil + b := &K8sBackend{cs: cs, storageClass: storageClass, image: image, externalHost: externalHost, storageSizeGi: storageSizeGi} + b.initMongoFn = b.initMongo + return b, nil } // EnableRouteRegistry tells the K8sBackend to publish routing records to Redis @@ -335,7 +359,11 @@ func (b *K8sBackend) Provision(ctx context.Context, token, tier string) (*Creden // with SHA-256, but the Go driver's negotiator can pick SHA-1 first which // the server then rejects. Pinning the mechanism removes the race. adminURI := fmt.Sprintf("mongodb://root:%s@%s:27017/admin?authMechanism=SCRAM-SHA-256", adminPass, clusterIP) - if err := b.initMongo(provCtx, adminURI, dbName, appUser, appPass); err != nil { + initFn := b.initMongoFn + if initFn == nil { + initFn = b.initMongo + } + if err := initFn(provCtx, adminURI, dbName, appUser, appPass); err != nil { return nil, rollback("init mongo", err) } @@ -423,7 +451,7 @@ func (b *K8sBackend) StorageBytes(ctx context.Context, token, providerResourceID } adminPass := string(secret.Data["MONGO_INITDB_ROOT_PASSWORD"]) - uri := fmt.Sprintf("mongodb://root:%s@%s:27017/admin", adminPass, svc.Spec.ClusterIP) + uri := fmt.Sprintf("mongodb://root:%s@%s:%d/admin", adminPass, svc.Spec.ClusterIP, b.mongoPortOr27017()) clientOpts := options.Client().ApplyURI(uri).SetServerSelectionTimeout(5 * time.Second) client, err := mongoclient.Connect(ctx, clientOpts) @@ -444,17 +472,7 @@ func (b *K8sBackend) StorageBytes(ctx context.Context, token, providerResourceID slog.Debug("k8s mongo.StorageBytes: dbStats miss for candidate", "namespace", ns, "db", dbName, "error", err) continue } - if v, ok := result["storageSize"]; ok { - switch n := v.(type) { - case int32: - return int64(n), nil - case int64: - return n, nil - case float64: - return int64(n), nil - } - } - return 0, nil + return decodeStorageSize(result), nil } if lastErr != nil { return 0, fmt.Errorf("k8s mongo.StorageBytes: dbStats (all candidates): %w", lastErr) diff --git a/internal/backend/mongo/k8s_lifecycle_test.go b/internal/backend/mongo/k8s_lifecycle_test.go new file mode 100644 index 0000000..e0343c7 --- /dev/null +++ b/internal/backend/mongo/k8s_lifecycle_test.go @@ -0,0 +1,446 @@ +package mongo + +// k8s_lifecycle_test.go — covers the K8sBackend Provision/StorageBytes/ +// Deprovision orchestration paths using a fake clientset + reactor stubs. +// +// True end-to-end Provision requires a real cluster + pod scheduling + +// running mongod — out of scope for unit tests. We instead exercise: +// +// * StorageBytes fail-soft paths (missing Secret, missing Service) +// * StorageBytes error propagation (synthetic non-NotFound secret error) +// * StorageBytes connect-failure branch (secret + service present but +// ClusterIP unreachable; SCRAM auth against authless local mongo fails +// so the dbStats loop drains lastErr → wrapped error) +// * Deprovision happy path (namespace Delete via fake clientset) +// * Deprovision idempotent NotFound (delete on missing namespace must +// NOT return an error) +// * Deprovision propagates non-NotFound delete errors +// * Deprovision with route-registry (Del on missing keys is non-fatal) +// * Provision early-bail when applyNamespace fails (AlreadyExists Active +// namespace — Provision returns an error, NOT a nil credential) +// * Provision early-bail when applyResourceQuota fails (rollback path) + +import ( + "context" + "errors" + "testing" + "time" + + goredis "github.com/redis/go-redis/v9" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + ktesting "k8s.io/client-go/testing" + "k8s.io/client-go/kubernetes/fake" + + "instant.dev/provisioner/internal/poolident" +) + +// ─── StorageBytes ─────────────────────────────────────────────────────────── + +func TestK8sStorageBytes_NoSecretFailsSoft(t *testing.T) { + cs := fake.NewSimpleClientset() + b := &K8sBackend{cs: cs} + got, err := b.StorageBytes(context.Background(), "abc", "") + if err != nil { + t.Errorf("missing secret: want nil err (fail-soft), got %v", err) + } + if got != 0 { + t.Errorf("missing secret: got %d bytes, want 0", got) + } +} + +func TestK8sStorageBytes_NoServiceFailsSoft(t *testing.T) { + const token = "abc" + ns := mongoK8sNsPrefix + token + cs := fake.NewSimpleClientset(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "mongo-admin", Namespace: ns}, + Data: map[string][]byte{"MONGO_INITDB_ROOT_PASSWORD": []byte("pw")}, + }) + b := &K8sBackend{cs: cs} + got, err := b.StorageBytes(context.Background(), token, "") + if err != nil { + t.Errorf("missing service: want nil err (fail-soft), got %v", err) + } + if got != 0 { + t.Errorf("missing service: got %d bytes, want 0", got) + } +} + +func TestK8sStorageBytes_SecretGetErrorPropagates(t *testing.T) { + const token = "abc" + cs := fake.NewSimpleClientset() + cs.PrependReactor("get", "secrets", func(a ktesting.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("synthetic secret get failure") + }) + b := &K8sBackend{cs: cs} + if _, err := b.StorageBytes(context.Background(), token, ""); err == nil { + t.Fatal("expected error to propagate, got nil") + } +} + +func TestK8sStorageBytes_ServiceGetErrorPropagates(t *testing.T) { + const token = "abc" + ns := mongoK8sNsPrefix + token + cs := fake.NewSimpleClientset(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "mongo-admin", Namespace: ns}, + Data: map[string][]byte{"MONGO_INITDB_ROOT_PASSWORD": []byte("pw")}, + }) + cs.PrependReactor("get", "services", func(a ktesting.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("synthetic service get failure") + }) + b := &K8sBackend{cs: cs} + if _, err := b.StorageBytes(context.Background(), token, ""); err == nil { + t.Fatal("expected service get error to propagate, got nil") + } +} + +// TestK8sStorageBytes_ClusterIPUnreachable feeds the dbStats loop an +// unreachable ClusterIP so every candidate dbStats fails and the function +// returns a wrapped error from the lastErr path. +func TestK8sStorageBytes_ClusterIPUnreachable(t *testing.T) { + const token = "abc" + ns := mongoK8sNsPrefix + token + cs := fake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "mongo-admin", Namespace: ns}, + Data: map[string][]byte{"MONGO_INITDB_ROOT_PASSWORD": []byte("pw")}, + }, + &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "mongodb", Namespace: ns}, + Spec: corev1.ServiceSpec{ClusterIP: "127.0.0.1"}, // wrong creds → all candidates fail + }, + ) + b := &K8sBackend{cs: cs} + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + _, err := b.StorageBytes(ctx, token, "") + if err == nil { + t.Fatal("expected dbStats-all-candidates error, got nil") + } +} + +// ─── Deprovision ──────────────────────────────────────────────────────────── + +func TestK8sDeprovision_DeletesNamespace(t *testing.T) { + const token = "drop-abc" + ns := mongoK8sNsPrefix + token + cs := fake.NewSimpleClientset(&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: ns}, + }) + b := &K8sBackend{cs: cs} + if err := b.Deprovision(context.Background(), token, ""); err != nil { + t.Fatalf("Deprovision: %v", err) + } + if _, err := cs.CoreV1().Namespaces().Get(context.Background(), ns, metav1.GetOptions{}); !k8serrors.IsNotFound(err) { + t.Errorf("namespace not deleted: %v", err) + } +} + +func TestK8sDeprovision_NotFoundIsIdempotent(t *testing.T) { + cs := fake.NewSimpleClientset() + b := &K8sBackend{cs: cs} + if err := b.Deprovision(context.Background(), "missing", ""); err != nil { + t.Errorf("Deprovision on missing namespace: want nil, got %v", err) + } +} + +func TestK8sDeprovision_DeleteErrorPropagates(t *testing.T) { + cs := fake.NewSimpleClientset() + cs.PrependReactor("delete", "namespaces", func(a ktesting.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("synthetic delete failure") + }) + b := &K8sBackend{cs: cs} + if err := b.Deprovision(context.Background(), "tok", ""); err == nil { + t.Fatal("expected delete error to propagate, got nil") + } +} + +func TestK8sDeprovision_WithRouteRegistry_NoCrashOnUnreachableRedis(t *testing.T) { + const token = "rtr-abc" + ns := mongoK8sNsPrefix + token + cs := fake.NewSimpleClientset(&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: ns}, + }) + // Point at a port nothing listens on — Del must log a warn, not panic. + rdb := goredis.NewClient(&goredis.Options{ + Addr: "127.0.0.1:1", + DialTimeout: 200 * time.Millisecond, + MaxRetries: -1, + }) + defer rdb.Close() + b := &K8sBackend{cs: cs} + b.EnableRouteRegistry(rdb, "") + if err := b.Deprovision(context.Background(), token, ""); err != nil { + t.Errorf("Deprovision with bad redis: want nil (route Del is warn-only), got %v", err) + } +} + +// TestK8sDeprovision_PoolPRID_StripsNamespacePrefix asserts that when the +// provider_resource_id encodes both a base PRID (the real namespace) and a +// pool token, Deprovision deletes the BASE PRID namespace — not the synthetic +// instant-customer-. +func TestK8sDeprovision_PoolPRID_StripsNamespacePrefix(t *testing.T) { + const realNS = "instant-customer-pool-poolish" + prid := poolident.Encode(realNS, "pool-poolish") + cs := fake.NewSimpleClientset(&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: realNS}, + }) + b := &K8sBackend{cs: cs} + if err := b.Deprovision(context.Background(), "requestor", prid); err != nil { + t.Fatalf("Deprovision: %v", err) + } + if _, err := cs.CoreV1().Namespaces().Get(context.Background(), realNS, metav1.GetOptions{}); !k8serrors.IsNotFound(err) { + t.Errorf("real pool namespace must be deleted; got %v", err) + } +} + +// ─── Provision early-error paths ──────────────────────────────────────────── + +// TestK8sProvision_AlreadyExistsActiveNamespace covers the applyNamespace +// branch where the namespace is already Active (not Terminating) — Provision +// must surface the error rather than create over the live namespace. +func TestK8sProvision_AlreadyExistsActiveNamespace(t *testing.T) { + const token = "exists-tok" + ns := mongoK8sNsPrefix + token + cs := fake.NewSimpleClientset(&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: ns}, + Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive}, + }) + b := &K8sBackend{cs: cs, image: "mongo:7", storageClass: "gp3"} + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + creds, err := b.Provision(ctx, token, "hobby") + if err == nil { + t.Fatal("Provision: want error on already-exists Active namespace, got nil") + } + if creds != nil { + t.Errorf("Provision: must not return creds on error, got %+v", creds) + } +} + +// TestK8sProvision_RollsBackOnQuotaFailure forces applyResourceQuota to fail +// via a reactor, then asserts the rollback path runs: the namespace must end +// up deleted. The function returns a wrapped quota error. +func TestK8sProvision_RollsBackOnQuotaFailure(t *testing.T) { + const token = "rollback-tok" + ns := mongoK8sNsPrefix + token + cs := fake.NewSimpleClientset() + cs.PrependReactor("create", "resourcequotas", func(a ktesting.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("synthetic quota create failure") + }) + b := &K8sBackend{cs: cs, image: "mongo:7", storageClass: "gp3"} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + creds, err := b.Provision(ctx, token, "hobby") + if err == nil { + t.Fatal("Provision: want quota error, got nil") + } + if creds != nil { + t.Error("Provision: must not return creds on error") + } + // Rollback Deletes ns on a fresh context; wait briefly for fake clientset + // to settle then assert namespace is gone. + _, getErr := cs.CoreV1().Namespaces().Get(context.Background(), ns, metav1.GetOptions{}) + if !k8serrors.IsNotFound(getErr) { + t.Errorf("rollback: namespace not deleted, got %v", getErr) + } +} + +// TestK8sProvision_RollsBackOnNetworkPolicyFailure injects a failure on the +// NetworkPolicy create. Covers the second rollback site. +func TestK8sProvision_RollsBackOnNetworkPolicyFailure(t *testing.T) { + cs := fake.NewSimpleClientset() + cs.PrependReactor("create", "networkpolicies", func(a ktesting.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("synthetic netpol failure") + }) + b := &K8sBackend{cs: cs, image: "mongo:7", storageClass: "gp3"} + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if _, err := b.Provision(ctx, "np-tok", "hobby"); err == nil { + t.Fatal("Provision: want netpol error, got nil") + } +} + +// TestK8sProvision_RollsBackOnSecretFailure covers the applyAdminSecret error +// rollback branch. +func TestK8sProvision_RollsBackOnSecretFailure(t *testing.T) { + cs := fake.NewSimpleClientset() + cs.PrependReactor("create", "secrets", func(a ktesting.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("synthetic secret failure") + }) + b := &K8sBackend{cs: cs, image: "mongo:7", storageClass: "gp3"} + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if _, err := b.Provision(ctx, "sec-tok", "hobby"); err == nil { + t.Fatal("Provision: want secret error, got nil") + } +} + +// TestK8sProvision_RollsBackOnPVCFailure exercises the PVC rollback branch. +// Only runs for tiers where sz.pvcMi > 0 (hobby) — anonymous skips the PVC. +func TestK8sProvision_RollsBackOnPVCFailure(t *testing.T) { + cs := fake.NewSimpleClientset() + cs.PrependReactor("create", "persistentvolumeclaims", func(a ktesting.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("synthetic pvc failure") + }) + b := &K8sBackend{cs: cs, image: "mongo:7", storageClass: "gp3"} + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if _, err := b.Provision(ctx, "pvc-tok", "hobby"); err == nil { + t.Fatal("Provision: want pvc error, got nil") + } +} + +// TestK8sProvision_RollsBackOnDeploymentFailure exercises the Deployment +// create rollback branch. +func TestK8sProvision_RollsBackOnDeploymentFailure(t *testing.T) { + cs := fake.NewSimpleClientset() + cs.PrependReactor("create", "deployments", func(a ktesting.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("synthetic deployment failure") + }) + b := &K8sBackend{cs: cs, image: "mongo:7", storageClass: "gp3"} + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if _, err := b.Provision(ctx, "dep-tok", "hobby"); err == nil { + t.Fatal("Provision: want deployment error, got nil") + } +} + +// TestK8sProvision_RollsBackOnServiceFailure exercises the Service create +// rollback branch. +func TestK8sProvision_RollsBackOnServiceFailure(t *testing.T) { + cs := fake.NewSimpleClientset() + cs.PrependReactor("create", "services", func(a ktesting.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("synthetic service failure") + }) + b := &K8sBackend{cs: cs, image: "mongo:7", storageClass: "gp3"} + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if _, err := b.Provision(ctx, "svc-tok", "hobby"); err == nil { + t.Fatal("Provision: want service error, got nil") + } +} + +// TestK8sProvision_WaitPodReady_TimeoutRollsBack — drive Provision past every +// applyXxx and into waitPodReady, where the fake clientset has no Ready pod. +// Use a short request context so the wait loop exits via ctx.Done(). +// +// NOTE: Provision uses a fresh 5-minute provCtx internally, so the parent +// ctx is consulted only for the teamID value — we cannot cancel via parent +// ctx here. Instead, we inject a synthetic Pods List error so waitPodReady +// fails fast. +func TestK8sProvision_WaitPodReady_ErrorRollsBack(t *testing.T) { + cs := fake.NewSimpleClientset() + cs.PrependReactor("list", "pods", func(a ktesting.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("synthetic pod list failure") + }) + b := &K8sBackend{cs: cs, image: "mongo:7", storageClass: "gp3"} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := b.Provision(ctx, "wait-tok", "hobby"); err == nil { + t.Fatal("Provision: want wait-ready error, got nil") + } +} + +// TestK8sProvision_InitMongoFailureRollsBack drives Provision through every +// applyXxx + a successful waitPodReady (Ready pod injected) into initMongo. +// We inject a syntactically-invalid ClusterIP into the created Service so +// mongo.Connect's ApplyURI parse step fails IMMEDIATELY (non-retryable → +// initMongo bails on attempt 1 rather than burning 30 seconds of retries). +func TestK8sProvision_InitMongoFailureRollsBack(t *testing.T) { + const token = "init-tok" + ns := mongoK8sNsPrefix + token + cs := fake.NewSimpleClientset() + // Plant a Ready pod up front; waitPodReady will see it immediately. + cs.PrependReactor("list", "pods", func(a ktesting.Action) (bool, runtime.Object, error) { + return true, &corev1.PodList{Items: []corev1.Pod{{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mongodb-readypod", + Namespace: ns, + Labels: map[string]string{"app": "mongodb"}, + }, + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{ + {Type: corev1.PodReady, Status: corev1.ConditionTrue}, + }, + }, + }}}, nil + }) + // Plant an INVALID ClusterIP (contains [ which breaks ApplyURI parse). + // This forces tryInitMongo's first attempt to return a non-retryable + // parse error so initMongo bails immediately. + cs.PrependReactor("create", "services", func(a ktesting.Action) (bool, runtime.Object, error) { + ca, ok := a.(ktesting.CreateAction) + if !ok { + return false, nil, nil + } + svc := ca.GetObject().(*corev1.Service) + svc.Spec.ClusterIP = "[bad-uri-host" + if len(svc.Spec.Ports) > 0 { + svc.Spec.Ports[0].NodePort = 30000 + } + return false, nil, nil // let normal create run with our mutation + }) + b := &K8sBackend{cs: cs, image: "mongo:7", storageClass: "gp3"} + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + creds, err := b.Provision(ctx, token, "hobby") + if err == nil { + t.Fatal("Provision: want init-mongo error, got nil") + } + if creds != nil { + t.Error("Provision: must not return creds on init-mongo failure") + } +} + +// TestApplyNamespace_TerminatingThenDeleted exercises the Terminating-namespace +// branch of applyNamespace: AlreadyExists + Phase==Terminating then re-Create +// after the namespace finally drains. +// +// We start with a Terminating namespace and, after the first Get inside the +// wait loop, delete it from the tracker so the next Get returns NotFound; +// applyNamespace then re-Creates and returns nil. +func TestApplyNamespace_TerminatingThenDeleted(t *testing.T) { + const ns = "instant-customer-mongo-term" + cs := fake.NewSimpleClientset(&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: ns}, + Status: corev1.NamespaceStatus{Phase: corev1.NamespaceTerminating}, + }) + // On the FIRST Get inside the wait loop, asynchronously drop the ns from + // the tracker so the SECOND Get returns NotFound and the re-Create wins. + getInsideLoop := 0 + cs.PrependReactor("get", "namespaces", func(a ktesting.Action) (bool, runtime.Object, error) { + getInsideLoop++ + // First Get is from the main fn body (still returns Terminating); + // second Get is the first loop iteration — drop the ns so the third + // (next loop iteration) returns NotFound. + if getInsideLoop == 2 { + // drop the namespace from the tracker, async to avoid reentrancy. + go func() { + _ = cs.Tracker().Delete(corev1.SchemeGroupVersion.WithResource("namespaces"), "", ns) + }() + } + return false, nil, nil // delegate to default tracker + }) + b := &K8sBackend{cs: cs} + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := b.applyNamespace(ctx, ns); err != nil { + t.Fatalf("applyNamespace: want eventual success after Terminating, got %v", err) + } + // Re-Create succeeded — ns now exists in Active phase via the create. + got, err := cs.CoreV1().Namespaces().Get(context.Background(), ns, metav1.GetOptions{}) + if err != nil { + t.Fatalf("post-recreate get: %v", err) + } + if got.Status.Phase == corev1.NamespaceTerminating { + t.Errorf("re-created ns still shows Terminating phase: %+v", got) + } +} + +// _ ensures the test package compiles against the runtime.Object schema +// even if a future change removes the last direct reference. +var _ = schema.GroupVersionResource{} diff --git a/internal/backend/mongo/k8s_test.go b/internal/backend/mongo/k8s_test.go new file mode 100644 index 0000000..f402ec5 --- /dev/null +++ b/internal/backend/mongo/k8s_test.go @@ -0,0 +1,513 @@ +package mongo + +// k8s_test.go — unit-level coverage for the K8sBackend resource-application +// helpers. Uses a fake clientset (k8s.io/client-go/kubernetes/fake) so each +// test runs without a real cluster. +// +// The end-to-end Provision/StorageBytes/Deprovision paths spin pods and run +// mongod init, which can't be exercised against a fake clientset — they live +// behind real k8s integration tests elsewhere. Here we cover every helper +// that builds the desired-state objects, the wait/init retry loops, and the +// configuration knobs (route registry / public host / password prefix). + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + goredis "github.com/redis/go-redis/v9" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ktesting "k8s.io/client-go/testing" + "k8s.io/client-go/kubernetes/fake" + + "instant.dev/provisioner/internal/ctxkeys" +) + +// ─── tier sizing ──────────────────────────────────────────────────────────── + +// TestSizingForTier_CoversEveryKnownTier exercises every documented branch in +// sizingForTier. The numbers themselves are not the contract (they live in the +// k8s yaml manifests downstream) — what matters is that every tier returns a +// non-zero CPU/memory request so the tier→sizing map cannot silently degrade +// to a zero-resource pod. +func TestSizingForTier_CoversEveryKnownTier(t *testing.T) { + cases := []struct { + tier string + wantMaxConns int + wantPVCNonZero bool + }{ + {"anonymous", 20, false}, // pvcMi=0 → emptyDir + {"hobby", 100, true}, + {"pro", 500, true}, + {"team", 2000, true}, + {"growth", 2000, true}, // shares team sizing + {"unknown", 100, true}, // default → hobby + {"", 100, true}, // empty → hobby + } + for _, tc := range cases { + t.Run(tc.tier, func(t *testing.T) { + sz := sizingForTier(tc.tier) + if sz.maxConns != tc.wantMaxConns { + t.Errorf("maxConns = %d, want %d", sz.maxConns, tc.wantMaxConns) + } + if tc.wantPVCNonZero && sz.pvcMi == 0 { + t.Errorf("pvcMi = 0, want > 0 for tier %q", tc.tier) + } + if !tc.wantPVCNonZero && sz.pvcMi != 0 { + t.Errorf("pvcMi = %d, want 0 for tier %q (emptyDir)", sz.pvcMi, tc.tier) + } + // Every tier MUST produce a parseable resource request — a + // silent typo (e.g. dropping the unit) would make + // resource.MustParse panic in applyDeployment / applyResourceQuota. + resource.MustParse(sz.cpuReq) + resource.MustParse(sz.memReq) + resource.MustParse(sz.cpuLim) + resource.MustParse(sz.memLim) + resource.MustParse(sz.qCPURequests) + resource.MustParse(sz.qMemRequests) + resource.MustParse(sz.qCPULimits) + resource.MustParse(sz.qMemLimits) + }) + } +} + +// TestMongoQuotaHard_IncludesPVCOnlyWhenSized asserts that the persistentvolumeclaims +// quota key is present iff sz.pvcMi > 0 — i.e. anonymous (emptyDir) doesn't +// reserve PVC headroom. +func TestMongoQuotaHard_IncludesPVCOnlyWhenSized(t *testing.T) { + szPVC := sizingForTier("hobby") + szEphemeral := sizingForTier("anonymous") + + withPVC := mongoQuotaHard(szPVC) + if _, ok := withPVC["persistentvolumeclaims"]; !ok { + t.Error("hobby tier: persistentvolumeclaims missing from quota hard") + } + if _, ok := withPVC[corev1.ResourceRequestsCPU]; !ok { + t.Error("hobby tier: requests.cpu missing from quota hard") + } + if _, ok := withPVC[corev1.ResourcePods]; !ok { + t.Error("hobby tier: pods missing from quota hard") + } + + noPVC := mongoQuotaHard(szEphemeral) + if _, ok := noPVC["persistentvolumeclaims"]; ok { + t.Error("anonymous tier: persistentvolumeclaims set in quota; want absent for emptyDir") + } +} + +// ─── small helpers ────────────────────────────────────────────────────────── + +func TestMongoK8sRandHex_LengthAndUniqueness(t *testing.T) { + a, err := mongoK8sRandHex(16) + if err != nil { + t.Fatalf("rand: %v", err) + } + if len(a) != 32 { + t.Errorf("16-byte hex length = %d, want 32", len(a)) + } + b, _ := mongoK8sRandHex(16) + if a == b { + t.Errorf("two consecutive rand-hex calls produced the same value") + } + // Hex-only characters. + for _, c := range a { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + t.Errorf("non-hex char %q in %q", c, a) + } + } +} + +func TestMongoK8sBoolPtr_RoundTrip(t *testing.T) { + if p := mongoK8sBoolPtr(true); p == nil || *p != true { + t.Errorf("bool ptr true: %v", p) + } + if p := mongoK8sBoolPtr(false); p == nil || *p != false { + t.Errorf("bool ptr false: %v", p) + } +} + +// TestMongoDataVolumeSource_PVCvsEmptyDir is the regression guard for the +// anonymous emptyDir branch. PVC tiers must reference the mongo-data PVC by +// name; emptyDir tiers must set EmptyDir non-nil. +func TestMongoDataVolumeSource_PVCvsEmptyDir(t *testing.T) { + pvc := mongoDataVolumeSource(tierSizing{pvcMi: 1024}) + if pvc.PersistentVolumeClaim == nil || pvc.PersistentVolumeClaim.ClaimName != "mongo-data" { + t.Errorf("PVC tier: want PVC volume source with ClaimName=mongo-data, got %+v", pvc) + } + if pvc.EmptyDir != nil { + t.Errorf("PVC tier: EmptyDir must be nil") + } + + ed := mongoDataVolumeSource(tierSizing{pvcMi: 0}) + if ed.EmptyDir == nil { + t.Errorf("emptyDir tier: EmptyDir must be non-nil") + } + if ed.PersistentVolumeClaim != nil { + t.Errorf("emptyDir tier: PersistentVolumeClaim must be nil") + } +} + +// ─── route-registry configuration knobs ───────────────────────────────────── + +func TestK8sBackendRouteRegistryConfig(t *testing.T) { + b := &K8sBackend{} + + // Pre-config: every public host / route prefix knob is empty. + if b.publicHost != "" { + t.Errorf("publicHost initial = %q", b.publicHost) + } + + b.SetPublicHost("mongo.example.com") + if b.publicHost != "mongo.example.com" { + t.Errorf("SetPublicHost: %q", b.publicHost) + } + + // EnableRouteRegistry with empty prefix → default applied. + rdb := goredis.NewClient(&goredis.Options{Addr: "127.0.0.1:1"}) + defer rdb.Close() + b.EnableRouteRegistry(rdb, "") + if b.routePrefix != "mongo_route:" { + t.Errorf("default routePrefix = %q", b.routePrefix) + } + if b.userPrefix != "mongo_route_by_user:" { + t.Errorf("default userPrefix = %q", b.userPrefix) + } + + // Explicit prefix wins. + b2 := &K8sBackend{} + b2.EnableRouteRegistry(rdb, "custom_route:") + if b2.routePrefix != "custom_route:" { + t.Errorf("explicit routePrefix = %q", b2.routePrefix) + } + + // SetPasswordRoutePrefix overrides the user-prefix when non-empty. + b2.SetPasswordRoutePrefix("user_lookup:") + if b2.userPrefix != "user_lookup:" { + t.Errorf("SetPasswordRoutePrefix: %q", b2.userPrefix) + } + // Empty string is a no-op (preserves the previously-set value). + b2.SetPasswordRoutePrefix("") + if b2.userPrefix != "user_lookup:" { + t.Errorf("SetPasswordRoutePrefix(empty) clobbered: %q", b2.userPrefix) + } +} + +// ─── desired-state helpers against a fake clientset ───────────────────────── + +func TestApplyNamespace_CarriesOwnerTeamLabel(t *testing.T) { + const teamID = "11111111-2222-3333-4444-555555555555" + const ns = "instant-customer-mongo-teamlabel" + + cs := fake.NewSimpleClientset() + b := &K8sBackend{cs: cs} + ctx := context.WithValue(context.Background(), ctxkeys.TeamIDKey, teamID) + + if err := b.applyNamespace(ctx, ns); err != nil { + t.Fatalf("applyNamespace: %v", err) + } + got, err := cs.CoreV1().Namespaces().Get(context.Background(), ns, metav1.GetOptions{}) + if err != nil { + t.Fatalf("get namespace: %v", err) + } + if got.Labels[mongoK8sRoleLabel] != mongoK8sRoleValue { + t.Errorf("role label = %q, want %q", got.Labels[mongoK8sRoleLabel], mongoK8sRoleValue) + } + if got.Labels[mongoK8sOwnerTeamLabel] != teamID { + t.Errorf("owner-team label = %q, want %q", got.Labels[mongoK8sOwnerTeamLabel], teamID) + } + if got.Labels["pod-security.kubernetes.io/enforce"] != "baseline" { + t.Error("missing PSS enforce=baseline label") + } +} + +func TestApplyNamespace_NoOwnerLabelWhenContextEmpty(t *testing.T) { + const ns = "instant-customer-mongo-noteam" + cs := fake.NewSimpleClientset() + b := &K8sBackend{cs: cs} + if err := b.applyNamespace(context.Background(), ns); err != nil { + t.Fatalf("applyNamespace: %v", err) + } + got, _ := cs.CoreV1().Namespaces().Get(context.Background(), ns, metav1.GetOptions{}) + if _, ok := got.Labels[mongoK8sOwnerTeamLabel]; ok { + t.Errorf("owner-team label set without context value") + } +} + +func TestApplyNamespace_ReturnsErrOnAlreadyExistsActive(t *testing.T) { + const ns = "instant-customer-mongo-exists" + cs := fake.NewSimpleClientset(&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: ns}, + Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive}, + }) + b := &K8sBackend{cs: cs} + err := b.applyNamespace(context.Background(), ns) + if err == nil { + t.Fatal("applyNamespace: want AlreadyExists error, got nil") + } + if !k8serrors.IsAlreadyExists(err) { + t.Errorf("error must be IsAlreadyExists, got %v", err) + } +} + +func TestApplyNetworkPolicy_CreatesIngressEgressRules(t *testing.T) { + const ns = "instant-customer-mongo-np" + cs := fake.NewSimpleClientset() + b := &K8sBackend{cs: cs} + if err := b.applyNetworkPolicy(context.Background(), ns, 27017); err != nil { + t.Fatalf("applyNetworkPolicy: %v", err) + } + np, err := cs.NetworkingV1().NetworkPolicies(ns).Get(context.Background(), "default-deny", metav1.GetOptions{}) + if err != nil { + t.Fatalf("get netpol: %v", err) + } + if len(np.Spec.Ingress) == 0 || len(np.Spec.Egress) == 0 { + t.Errorf("ingress/egress missing: %+v", np.Spec) + } +} + +func TestApplyResourceQuota_AppliesTierBudget(t *testing.T) { + const ns = "instant-customer-mongo-quota" + cs := fake.NewSimpleClientset() + b := &K8sBackend{cs: cs} + sz := sizingForTier("hobby") + if err := b.applyResourceQuota(context.Background(), ns, sz); err != nil { + t.Fatalf("applyResourceQuota: %v", err) + } + q, err := cs.CoreV1().ResourceQuotas(ns).Get(context.Background(), "tenant-quota", metav1.GetOptions{}) + if err != nil { + t.Fatalf("get quota: %v", err) + } + if _, ok := q.Spec.Hard["persistentvolumeclaims"]; !ok { + t.Errorf("PVC budget missing for hobby quota") + } + if _, ok := q.Spec.Hard[corev1.ResourcePods]; !ok { + t.Errorf("pods budget missing") + } +} + +func TestApplyAdminSecret_StoresRootCredentials(t *testing.T) { + const ns = "instant-customer-mongo-sec" + cs := fake.NewSimpleClientset() + b := &K8sBackend{cs: cs} + if err := b.applyAdminSecret(context.Background(), ns, "s3cret"); err != nil { + t.Fatalf("applyAdminSecret: %v", err) + } + s, err := cs.CoreV1().Secrets(ns).Get(context.Background(), "mongo-admin", metav1.GetOptions{}) + if err != nil { + t.Fatalf("get secret: %v", err) + } + if s.StringData["MONGO_INITDB_ROOT_USERNAME"] != "root" { + t.Errorf("root username = %q, want root", s.StringData["MONGO_INITDB_ROOT_USERNAME"]) + } + if s.StringData["MONGO_INITDB_ROOT_PASSWORD"] != "s3cret" { + t.Errorf("root password not stored") + } +} + +func TestApplyPVC_RequestsSizedStorageOnConfiguredClass(t *testing.T) { + const ns = "instant-customer-mongo-pvc" + cs := fake.NewSimpleClientset() + b := &K8sBackend{cs: cs, storageClass: "do-block-storage"} + sz := sizingForTier("hobby") + if err := b.applyPVC(context.Background(), ns, sz); err != nil { + t.Fatalf("applyPVC: %v", err) + } + pvc, err := cs.CoreV1().PersistentVolumeClaims(ns).Get(context.Background(), "mongo-data", metav1.GetOptions{}) + if err != nil { + t.Fatalf("get pvc: %v", err) + } + if pvc.Spec.StorageClassName == nil || *pvc.Spec.StorageClassName != "do-block-storage" { + t.Errorf("StorageClassName = %v, want do-block-storage", pvc.Spec.StorageClassName) + } + if pvc.Spec.Resources.Requests.Storage().IsZero() { + t.Errorf("PVC storage request is zero") + } +} + +func TestApplyDeployment_BuildsContainerCorrectly(t *testing.T) { + const ns = "instant-customer-mongo-dep" + cs := fake.NewSimpleClientset() + b := &K8sBackend{cs: cs, image: "mongo:7"} + sz := sizingForTier("hobby") + if err := b.applyDeployment(context.Background(), ns, sz); err != nil { + t.Fatalf("applyDeployment: %v", err) + } + dep, err := cs.AppsV1().Deployments(ns).Get(context.Background(), "mongodb", metav1.GetOptions{}) + if err != nil { + t.Fatalf("get deployment: %v", err) + } + containers := dep.Spec.Template.Spec.Containers + if len(containers) != 1 || containers[0].Image != "mongo:7" { + t.Errorf("container image = %v, want mongo:7", containers) + } + // docker-entrypoint.sh requires Args[0] == "mongod" — regression guard + // for the silent createUser/--auth init bug. + if len(containers[0].Args) == 0 || containers[0].Args[0] != "mongod" { + t.Errorf("Args[0] = %v, want mongod", containers[0].Args) + } + // --maxConns must be present and carry the tier's maxConns. + joined := strings.Join(containers[0].Args, " ") + if !strings.Contains(joined, "--maxConns") { + t.Errorf("Args missing --maxConns: %v", containers[0].Args) + } + // PSS / hardening: securityContext drops ALL caps & runs as non-root. + sc := containers[0].SecurityContext + if sc == nil || sc.AllowPrivilegeEscalation == nil || *sc.AllowPrivilegeEscalation { + t.Errorf("container security context missing or AllowPrivilegeEscalation true") + } +} + +func TestApplyDeployment_EmptyDirVolumeForAnonymous(t *testing.T) { + const ns = "instant-customer-mongo-anon" + cs := fake.NewSimpleClientset() + b := &K8sBackend{cs: cs, image: "mongo:7"} + sz := sizingForTier("anonymous") + if err := b.applyDeployment(context.Background(), ns, sz); err != nil { + t.Fatalf("applyDeployment: %v", err) + } + dep, _ := cs.AppsV1().Deployments(ns).Get(context.Background(), "mongodb", metav1.GetOptions{}) + for _, v := range dep.Spec.Template.Spec.Volumes { + if v.Name == "data" && v.EmptyDir == nil { + t.Errorf("anonymous tier: data volume must be emptyDir, got %+v", v) + } + } +} + +func TestApplyService_NodePortShape(t *testing.T) { + const ns = "instant-customer-mongo-svc" + cs := fake.NewSimpleClientset() + b := &K8sBackend{cs: cs} + svc, err := b.applyService(context.Background(), ns) + if err != nil { + t.Fatalf("applyService: %v", err) + } + if svc.Spec.Type != corev1.ServiceTypeNodePort { + t.Errorf("svc type = %v, want NodePort", svc.Spec.Type) + } + if len(svc.Spec.Ports) == 0 || svc.Spec.Ports[0].Port != 27017 { + t.Errorf("port = %v, want 27017", svc.Spec.Ports) + } +} + +// ─── waitPodReady ─────────────────────────────────────────────────────────── + +// TestWaitPodReady_HappyPath returns immediately once a Ready pod exists. +func TestWaitPodReady_HappyPath(t *testing.T) { + const ns = "instant-customer-mongo-ready" + cs := fake.NewSimpleClientset(&corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mongodb-abc", + Namespace: ns, + Labels: map[string]string{"app": "mongodb"}, + }, + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{ + {Type: corev1.PodReady, Status: corev1.ConditionTrue}, + }, + }, + }) + b := &K8sBackend{cs: cs} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := b.waitPodReady(ctx, ns, "app=mongodb"); err != nil { + t.Fatalf("waitPodReady: %v", err) + } +} + +// TestWaitPodReady_ContextCancel exercises the ctx.Done() arm of the select. +func TestWaitPodReady_ContextCancel(t *testing.T) { + // No matching pod — so the poll loop never exits on PodReady. + cs := fake.NewSimpleClientset() + b := &K8sBackend{cs: cs} + ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond) + defer cancel() + err := b.waitPodReady(ctx, "instant-customer-mongo-cancel", "app=mongodb") + if err == nil { + t.Fatal("waitPodReady: want context error, got nil") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("waitPodReady err = %v, want DeadlineExceeded", err) + } +} + +// TestWaitPodReady_ListErrorPropagates covers the kubeclient List error branch. +func TestWaitPodReady_ListErrorPropagates(t *testing.T) { + cs := fake.NewSimpleClientset() + cs.PrependReactor("list", "pods", func(action ktesting.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("synthetic list error") + }) + b := &K8sBackend{cs: cs} + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + err := b.waitPodReady(ctx, "ns", "app=mongodb") + if err == nil || !strings.Contains(err.Error(), "synthetic list error") { + t.Fatalf("waitPodReady: want synthetic list error, got %v", err) + } +} + +// ─── tryInitMongo / initMongo ─────────────────────────────────────────────── + +// TestTryInitMongo_FailsOnUnreachableMongo covers the connect/RunCommand-fail +// path of tryInitMongo. We point at an unused port so server-selection fails. +func TestTryInitMongo_FailsOnUnreachableMongo(t *testing.T) { + b := &K8sBackend{} + ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond) + defer cancel() + uri := "mongodb://root:rootpw@127.0.0.1:1/admin?serverSelectionTimeoutMS=500" + err := b.tryInitMongo(ctx, uri, "db_x", "usr_x", "pw") + if err == nil { + t.Fatal("tryInitMongo on unreachable: want error, got nil") + } +} + +// TestInitMongo_GivesUpAndPropagatesLastErr covers the retry-loop exhaustion +// branch of initMongo. The error contains "server selection" so it qualifies +// as a retryable class — the loop should exhaust attempts and return the +// "gave up" wrapped error. +func TestInitMongo_GivesUpAndPropagatesLastErr(t *testing.T) { + b := &K8sBackend{} + // Use a short context so the per-attempt sleep is interrupted; the test + // finishes in ~ context-deadline rather than the natural retry budget. + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + uri := "mongodb://root:rootpw@127.0.0.1:1/admin?serverSelectionTimeoutMS=200" + err := b.initMongo(ctx, uri, "db_x", "usr_x", "pw") + if err == nil { + t.Fatal("initMongo: want error, got nil") + } + // The function either gave up after maxAttempts or returned ctx.Err() + // when the deadline triggered between retries — both are valid exits + // for the unreachable-backend case. + if !errors.Is(err, context.DeadlineExceeded) && + !strings.Contains(err.Error(), "gave up") && + !strings.Contains(err.Error(), "server selection") && + !strings.Contains(err.Error(), "context") { + t.Errorf("initMongo err = %v; want deadline / gave-up / server-selection", err) + } +} + +// TestInitMongo_FailFastOnNonRetryableError covers the early-return branch: +// an error that does NOT match any retry signature must propagate immediately. +// We force this by using an unparseable URI so mongo.Connect fails with a +// generic error string that doesn't match any retry token. +func TestInitMongo_FailFastOnNonRetryableError(t *testing.T) { + b := &K8sBackend{} + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + // `mongodb://[bad-uri` triggers a parse error in ApplyURI → propagated + // out of mongo.Connect → out of tryInitMongo. The error message contains + // "error parsing uri" — no retry-token match → fast fail. + err := b.initMongo(ctx, "mongodb://[bad-uri", "db_x", "usr_x", "pw") + if err == nil { + t.Fatal("initMongo: want immediate parse error, got nil") + } + if strings.Contains(err.Error(), "gave up") { + t.Errorf("initMongo treated parse error as retryable: %v", err) + } +} diff --git a/internal/backend/mongo/local_test.go b/internal/backend/mongo/local_test.go new file mode 100644 index 0000000..9ac6928 --- /dev/null +++ b/internal/backend/mongo/local_test.go @@ -0,0 +1,369 @@ +package mongo + +// local_test.go — exercises LocalBackend against a real MongoDB instance. +// +// Set CUSTOMER_MONGO_URL to a reachable mongodb:// URI (e.g. +// mongodb://127.0.0.1:27017 from infra/docker-compose.yml). Tests that need a +// live instance skip cleanly if the connect probe fails — they never poison +// CI when Mongo is absent. + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + + "instant.dev/provisioner/internal/poolident" +) + +// liveMongoURI returns the admin URI for the local Mongo and whether it's +// reachable. Tests that mutate state skip when reachable=false. +func liveMongoURI(t *testing.T) (string, bool) { + t.Helper() + uri := os.Getenv("CUSTOMER_MONGO_URL") + if uri == "" { + uri = "mongodb://127.0.0.1:27017" + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri).SetServerSelectionTimeout(2*time.Second)) + if err != nil { + return uri, false + } + defer client.Disconnect(ctx) + if err := client.Ping(ctx, nil); err != nil { + return uri, false + } + return uri, true +} + +// hostFromURI strips the mongodb:// scheme to produce the bare host:port the +// LocalBackend embeds in customer URLs. +func hostFromURI(uri string) string { + u := strings.TrimPrefix(uri, "mongodb://") + if i := strings.Index(u, "/"); i >= 0 { + u = u[:i] + } + if i := strings.Index(u, "?"); i >= 0 { + u = u[:i] + } + return u +} + +// uniqueToken returns a token that won't collide with any existing fixture +// state. The hex shape mimics a real UUID with dashes so naming.go exercises +// its strip path. +func uniqueToken(prefix string) string { + const layout = "20060102-1504-0500-9999" + return prefix + "-" + time.Now().UTC().Format(layout) +} + +// TestNewLocalBackend_AppliesDefaults exercises the parameter-defaulting branch +// (empty adminURI / mongoHost). Pure unit — no Mongo. +func TestNewLocalBackend_AppliesDefaults(t *testing.T) { + b := newLocalBackend("", "") + if b.adminURI != "mongodb://root:root@localhost:27017" { + t.Errorf("default adminURI = %q", b.adminURI) + } + if b.mongoHost != "localhost:27017" { + t.Errorf("default mongoHost = %q", b.mongoHost) + } + + b2 := newLocalBackend("mongodb://x:y@h:1/admin", "other:42") + if b2.adminURI != "mongodb://x:y@h:1/admin" || b2.mongoHost != "other:42" { + t.Errorf("explicit values not preserved: %+v", b2) + } +} + +// TestLocalProvision_Happy provisions a real DB/user, asserts the credential +// shape, and cleans up. Skipped when Mongo is unreachable. +func TestLocalProvision_Happy(t *testing.T) { + uri, ok := liveMongoURI(t) + if !ok { + t.Skip("CUSTOMER_MONGO_URL unreachable; skipping") + } + b := newLocalBackend(uri, hostFromURI(uri)) + token := uniqueToken("prov") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + creds, err := b.Provision(ctx, token, "hobby") + if err != nil { + t.Fatalf("Provision: %v", err) + } + defer func() { + _ = b.Deprovision(context.Background(), token, "") + }() + + wantDB := mongoDBName(token) + if creds.DatabaseName != wantDB { + t.Errorf("DatabaseName = %q, want %q", creds.DatabaseName, wantDB) + } + if !strings.Contains(creds.URL, wantDB) || !strings.Contains(creds.URL, "authSource=admin") { + t.Errorf("URL malformed: %q", creds.URL) + } + if !strings.Contains(creds.URL, mongoUserName(token)) { + t.Errorf("URL missing user: %q", creds.URL) + } + + // Verify the user actually authenticates by connecting through the URL. + verifyCtx, vCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer vCancel() + client, err := mongo.Connect(verifyCtx, options.Client().ApplyURI(creds.URL).SetServerSelectionTimeout(3*time.Second)) + if err != nil { + t.Fatalf("connect with returned URL: %v", err) + } + defer client.Disconnect(verifyCtx) + if err := client.Database(wantDB).RunCommand(verifyCtx, bson.D{{Key: "ping", Value: 1}}).Err(); err != nil { + t.Errorf("user cannot ping its DB: %v", err) + } +} + +// TestLocalProvision_CreateUserConflict exercises the createUser-fail branch: +// running Provision twice against the same token must return an error because +// the second createUser hits a Duplicate-Key. +func TestLocalProvision_CreateUserConflict(t *testing.T) { + uri, ok := liveMongoURI(t) + if !ok { + t.Skip("CUSTOMER_MONGO_URL unreachable; skipping") + } + b := newLocalBackend(uri, hostFromURI(uri)) + token := uniqueToken("dup") + defer func() { _ = b.Deprovision(context.Background(), token, "") }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if _, err := b.Provision(ctx, token, "hobby"); err != nil { + t.Fatalf("first Provision: %v", err) + } + if _, err := b.Provision(ctx, token, "hobby"); err == nil { + t.Fatalf("second Provision: want error, got nil") + } +} + +// TestLocalProvision_ConnectFails covers the connect-error branch by pointing +// the backend at a port nothing listens on. The driver's lazy-connect means +// failures surface inside the first RunCommand — we deliberately use a short +// server-selection timeout so the test is fast. +func TestLocalProvision_ConnectFails(t *testing.T) { + // Use an invalid URI scheme so mongo.Connect itself fails (cheap; no socket). + b := newLocalBackend("mongodb://[bad-uri", "h:1") + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if _, err := b.Provision(ctx, "tkn", "hobby"); err == nil { + t.Fatal("Provision: want connect error, got nil") + } +} + +// TestLocalStorageBytes_ReturnsZeroOnConnectFailure covers the fail-open +// path: an unreachable mongo URI must yield (0, nil) — never an error — so +// the worker quota scan continues past a degraded backend. +func TestLocalStorageBytes_ReturnsZeroOnConnectFailure(t *testing.T) { + b := newLocalBackend("mongodb://[bad-uri", "h:1") + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + got, err := b.StorageBytes(ctx, "tkn", "") + if err != nil { + t.Errorf("StorageBytes connect error: want nil (fail-open), got %v", err) + } + if got != 0 { + t.Errorf("StorageBytes connect error: got %d, want 0", got) + } +} + +// TestLocalStorageBytes_NonExistentDB asserts the all-candidates-missed fall +// through: against a live Mongo, an un-provisioned token has no DB so every +// dbStats candidate fails and the function returns (0, nil) — fail-open. +func TestLocalStorageBytes_NonExistentDB(t *testing.T) { + uri, ok := liveMongoURI(t) + if !ok { + t.Skip("CUSTOMER_MONGO_URL unreachable; skipping") + } + b := newLocalBackend(uri, hostFromURI(uri)) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + // dbStats actually succeeds against a non-existent DB on Mongo (it + // returns a stub with storageSize=0), so we get a (0, nil) success on + // the FIRST candidate. That still exercises the int32/int64/float64 + // type switch and the "no error" return. + got, err := b.StorageBytes(ctx, uniqueToken("absent"), "") + if err != nil { + t.Errorf("StorageBytes: want nil err for missing DB, got %v", err) + } + if got != 0 { + t.Errorf("StorageBytes missing DB: got %d, want 0", got) + } +} + +// TestLocalStorageBytes_AfterProvision exercises the happy dbStats path. +// Mongo's dbStats on a freshly-created (empty) DB returns a tiny non-negative +// storageSize, so we assert >= 0 and that the type-switch decode worked. +func TestLocalStorageBytes_AfterProvision(t *testing.T) { + uri, ok := liveMongoURI(t) + if !ok { + t.Skip("CUSTOMER_MONGO_URL unreachable; skipping") + } + b := newLocalBackend(uri, hostFromURI(uri)) + token := uniqueToken("sz") + defer func() { _ = b.Deprovision(context.Background(), token, "") }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if _, err := b.Provision(ctx, token, "hobby"); err != nil { + t.Fatalf("Provision: %v", err) + } + + // Insert some data so storageSize is observably > 0. + client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri)) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer client.Disconnect(ctx) + dbName := mongoDBName(token) + coll := client.Database(dbName).Collection("data") + for i := 0; i < 5; i++ { + _, _ = coll.InsertOne(ctx, bson.D{{Key: "i", Value: i}, {Key: "s", Value: strings.Repeat("x", 64)}}) + } + + got, err := b.StorageBytes(ctx, token, "") + if err != nil { + t.Fatalf("StorageBytes: %v", err) + } + if got < 0 { + t.Errorf("StorageBytes: got %d, want >= 0", got) + } +} + +// TestLocalStorageBytes_PoolNamingToken proves the poolident.NamingToken +// branch is honoured: when provider_resource_id encodes a pool token, the +// dbStats probe must target the pool DB, not the request token's DB. +func TestLocalStorageBytes_PoolNamingToken(t *testing.T) { + uri, ok := liveMongoURI(t) + if !ok { + t.Skip("CUSTOMER_MONGO_URL unreachable; skipping") + } + b := newLocalBackend(uri, hostFromURI(uri)) + poolToken := uniqueToken("pool-aaaaaaaa") + requestToken := uniqueToken("req") + prid := poolident.Encode("", poolToken) + defer func() { _ = b.Deprovision(context.Background(), poolToken, "") }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // Provision under the pool token (this is what the hot-pool does). + if _, err := b.Provision(ctx, poolToken, "hobby"); err != nil { + t.Fatalf("Provision pool: %v", err) + } + // StorageBytes called with the REQUEST token + PRID encoding the pool + // token must hit the pool DB, not db_. + got, err := b.StorageBytes(ctx, requestToken, prid) + if err != nil { + t.Fatalf("StorageBytes: %v", err) + } + if got < 0 { + t.Errorf("storage_bytes = %d, want >= 0", got) + } +} + +// TestLocalDeprovision_DropsUserAndDB asserts that after Deprovision: (a) the +// user is gone (subsequent auth fails), (b) the DB drop succeeded. +func TestLocalDeprovision_DropsUserAndDB(t *testing.T) { + uri, ok := liveMongoURI(t) + if !ok { + t.Skip("CUSTOMER_MONGO_URL unreachable; skipping") + } + b := newLocalBackend(uri, hostFromURI(uri)) + token := uniqueToken("drop") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + creds, err := b.Provision(ctx, token, "hobby") + if err != nil { + t.Fatalf("Provision: %v", err) + } + if err := b.Deprovision(ctx, token, ""); err != nil { + t.Fatalf("Deprovision: %v", err) + } + + // Try to authenticate with the dropped user — must fail. + authCtx, aCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer aCancel() + client, err := mongo.Connect(authCtx, options.Client().ApplyURI(creds.URL).SetServerSelectionTimeout(2*time.Second)) + if err == nil { + err = client.Ping(authCtx, nil) + _ = client.Disconnect(authCtx) + } + if err == nil { + t.Errorf("dropped user still authenticates: %s", creds.URL) + } +} + +// TestLocalDeprovision_IdempotentOnMissing covers the "drop all candidates" +// loop when none of the candidate DBs/users exist — must NOT return an error +// because dropDatabase on a missing DB is a Mongo no-op. +func TestLocalDeprovision_IdempotentOnMissing(t *testing.T) { + uri, ok := liveMongoURI(t) + if !ok { + t.Skip("CUSTOMER_MONGO_URL unreachable; skipping") + } + b := newLocalBackend(uri, hostFromURI(uri)) + if err := b.Deprovision(context.Background(), uniqueToken("ghost"), ""); err != nil { + t.Errorf("Deprovision on missing token: want nil, got %v", err) + } +} + +// TestLocalDeprovision_ConnectFails covers the connect-failure branch. +func TestLocalDeprovision_ConnectFails(t *testing.T) { + b := newLocalBackend("mongodb://[bad-uri", "h:1") + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := b.Deprovision(ctx, "t", ""); err == nil { + t.Fatal("Deprovision: want connect error, got nil") + } +} + +// TestLocalDeprovision_PoolNamingToken asserts that a pool-PRID routes the drop +// to the pool DB / user — not db_. Provision under the pool +// token, Deprovision via the request token + PRID, then verify the pool DB +// is gone. +func TestLocalDeprovision_PoolNamingToken(t *testing.T) { + uri, ok := liveMongoURI(t) + if !ok { + t.Skip("CUSTOMER_MONGO_URL unreachable; skipping") + } + b := newLocalBackend(uri, hostFromURI(uri)) + poolToken := uniqueToken("pool-bbbbbbbb") + requestToken := uniqueToken("req2") + prid := poolident.Encode("", poolToken) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if _, err := b.Provision(ctx, poolToken, "hobby"); err != nil { + t.Fatalf("Provision pool: %v", err) + } + if err := b.Deprovision(ctx, requestToken, prid); err != nil { + t.Fatalf("Deprovision: %v", err) + } + + // The pool DB must now be missing — listDatabases must not include it. + client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri)) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer client.Disconnect(ctx) + dbs, err := client.ListDatabaseNames(ctx, bson.D{}) + if err != nil { + t.Fatalf("ListDatabaseNames: %v", err) + } + want := mongoDBName(poolToken) + for _, d := range dbs { + if d == want { + t.Errorf("pool DB %q still exists after pool-PRID Deprovision; routing fell through", want) + } + } +} diff --git a/internal/backend/mongo/mongo.go b/internal/backend/mongo/mongo.go index d7cf04d..b519058 100644 --- a/internal/backend/mongo/mongo.go +++ b/internal/backend/mongo/mongo.go @@ -27,10 +27,47 @@ import ( // Short to fail-fast in tests and when MongoDB is not reachable. const connectTimeout = 3 * time.Second +// decodeStorageSize extracts the dbStats storageSize from a decoded result, +// tolerating every BSON numeric encoding the server may use across versions +// (int32 / int64 / float64). Any missing or non-numeric value yields 0 — the +// fail-open contract for the quota scanner. Extracted as a standalone helper so +// the per-type decode arms are unit-testable without a live server returning a +// specific BSON type (real dbStats emits float64, leaving the integer arms +// otherwise unexercised). +func decodeStorageSize(result bson.M) int64 { + switch v := result["storageSize"].(type) { + case int32: + return int64(v) + case int64: + return v + case float64: + return int64(v) + default: + return 0 + } +} + // LocalBackend provisions MongoDB databases on a local instance. type LocalBackend struct { adminURI string // admin connection URI, e.g. mongodb://root:root@localhost:27017 mongoHost string // host for building connection strings, e.g. localhost:27017 + + // connectFn is the seam between the LocalBackend methods and + // mongo.Connect, mirroring K8sBackend.initMongoFn. The real mongo driver + // almost never returns an error from Connect itself (a malformed URI + // surfaces lazily on the first RunCommand), so the connect-error branches + // are otherwise unreachable in tests; substituting connectFn lets a test + // drive those branches deterministically. Never overridden in prod paths. + connectFn func(ctx context.Context, opts ...*options.ClientOptions) (*mongo.Client, error) +} + +// connect dials Mongo via the (overridable) connectFn seam, defaulting to the +// real mongo.Connect. +func (b *LocalBackend) connect(ctx context.Context, opts ...*options.ClientOptions) (*mongo.Client, error) { + if b.connectFn != nil { + return b.connectFn(ctx, opts...) + } + return mongo.Connect(ctx, opts...) } // newLocalBackend creates a LocalBackend. @@ -49,7 +86,7 @@ func newLocalBackend(adminURI, mongoHost string) *LocalBackend { // User: usr_{token} with readWrite role scoped to db_{token} // Returns credentials the caller can use immediately. func (b *LocalBackend) Provision(ctx context.Context, token, tier string) (*Credentials, error) { - client, err := mongo.Connect(ctx, options.Client().ApplyURI(b.adminURI). + client, err := b.connect(ctx, options.Client().ApplyURI(b.adminURI). SetServerSelectionTimeout(connectTimeout)) if err != nil { return nil, fmt.Errorf("nosql.Provision: connect: %w", err) @@ -119,7 +156,7 @@ func (b *LocalBackend) Provision(ctx context.Context, token, tier string) (*Cred // StorageBytes returns the storage size in bytes used by db_{token}. // Runs dbStats on the token database. Returns 0 on any error (fail-open). func (b *LocalBackend) StorageBytes(ctx context.Context, token, providerResourceID string) (int64, error) { - client, err := mongo.Connect(ctx, options.Client().ApplyURI(b.adminURI). + client, err := b.connect(ctx, options.Client().ApplyURI(b.adminURI). SetServerSelectionTimeout(connectTimeout)) if err != nil { slog.Error("nosql.StorageBytes: connect", "token", token, "error", err) @@ -148,15 +185,7 @@ func (b *LocalBackend) StorageBytes(ctx context.Context, token, providerResource slog.Debug("nosql.StorageBytes: dbStats miss for candidate", "token", token, "db", dbName, "error", err) continue } - switch v := result["storageSize"].(type) { - case int32: - return int64(v), nil - case int64: - return v, nil - case float64: - return int64(v), nil - } - return 0, nil + return decodeStorageSize(result), nil } // No candidate database exists yet — fail open. @@ -167,7 +196,7 @@ func (b *LocalBackend) StorageBytes(ctx context.Context, token, providerResource // Deprovision drops the user and database for the given token. // Drops user first, then drops the database. func (b *LocalBackend) Deprovision(ctx context.Context, token, providerResourceID string) error { - client, err := mongo.Connect(ctx, options.Client().ApplyURI(b.adminURI). + client, err := b.connect(ctx, options.Client().ApplyURI(b.adminURI). SetServerSelectionTimeout(connectTimeout)) if err != nil { return fmt.Errorf("nosql.Deprovision: connect: %w", err)