diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..1e73609 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,556 @@ +package config + +import ( + "os" + "strings" + "testing" +) + +// hexKey64 returns a deterministic 64-char hex string for AES_KEY. +const hexKey64 = "0011223344556677889900112233445566778899aabbccddeeff001122334455" + +// applyBaselineEnv writes the minimum env vars Load() requires (DATABASE_URL, +// JWT_SECRET, AES_KEY) plus optional overrides. It also clears every other +// var Load() reads, so the test gets a deterministic env regardless of host +// shape. +func applyBaselineEnv(t *testing.T, overrides map[string]string) { + t.Helper() + // Clear every env var Load() touches so we get deterministic defaults. + for _, k := range allKeys() { + t.Setenv(k, "") + _ = os.Unsetenv(k) + } + // Set the required trio plus any caller overrides. + t.Setenv("DATABASE_URL", "postgres://localhost/test") + t.Setenv("JWT_SECRET", strings.Repeat("J", 32)) + t.Setenv("AES_KEY", hexKey64) + for k, v := range overrides { + t.Setenv(k, v) + } +} + +// allKeys enumerates every env var Load() reads. Used to clear host-state. +func allKeys() []string { + return []string{ + "PORT", "DATABASE_URL", "CUSTOMER_DATABASE_URL", "REDIS_URL", + "JWT_SECRET", "AES_KEY", "MAXMIND_LICENSE_KEY", "GEOLITE2_DB_PATH", + "RAZORPAY_KEY_ID", "RAZORPAY_KEY_SECRET", "RAZORPAY_WEBHOOK_SECRET", + "RAZORPAY_PLAN_ID_HOBBY", "RAZORPAY_PLAN_ID_HOBBY_PLUS", + "RAZORPAY_PLAN_ID_PRO", "RAZORPAY_PLAN_ID_GROWTH", + "RAZORPAY_PLAN_ID_TEAM", "RAZORPAY_PLAN_ID_HOBBY_ANNUAL", + "RAZORPAY_PLAN_ID_HOBBY_PLUS_ANNUAL", "RAZORPAY_PLAN_ID_PRO_ANNUAL", + "RAZORPAY_PLAN_ID_GROWTH_ANNUAL", "RAZORPAY_PLAN_ID_TEAM_ANNUAL", + "RESEND_API_KEY", "EMAIL_PROVIDER", "BREVO_API_KEY", + "EMAIL_FROM_NAME", "EMAIL_FROM_ADDRESS", + "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET", + "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "GOOGLE_REDIRECT_URI", + "INSTANT_ENABLED_SERVICES", "ENVIRONMENT", "TRUSTED_PROXY_CIDRS", + "REDIS_PROVISION_BACKEND", "REDIS_PROVISION_HOST", + "MONGO_ADMIN_URI", "MONGO_HOST", + "POSTGRES_PROVISION_BACKEND", "NEON_API_KEY", "NEON_REGION_ID", + "POSTGRES_CUSTOMERS_URL", "PROVISIONER_ADDR", "PROVISIONER_SECRET", + "NATS_HOST", "QUEUE_BACKEND", "NATS_PUBLIC_HOST", + "NATS_OPERATOR_SEED", "NATS_SYSTEM_ACCOUNT_PUBLIC_KEY", "NATS_USE_TLS", + "R2_ENDPOINT", "R2_BUCKET_NAME", "R2_API_TOKEN", + "OBJECT_STORE_MODE", "OBJECT_STORE_BACKEND", "OBJECT_STORE_ENDPOINT", + "OBJECT_STORE_PUBLIC_URL", "OBJECT_STORE_ACCESS_KEY", + "OBJECT_STORE_SECRET_KEY", "OBJECT_STORE_BUCKET", + "OBJECT_STORE_REGION", "OBJECT_STORE_SECURE", + "OBJECT_STORE_ALLOW_SHARED_KEY", + "MINIO_ENDPOINT", "MINIO_PUBLIC_ENDPOINT", "MINIO_ROOT_USER", + "MINIO_ROOT_PASSWORD", "MINIO_BUCKET_NAME", + "DEPLOY_DOMAIN", "COMPUTE_PROVIDER", "KUBE_NAMESPACE_APPS", + "METRICS_TOKEN", "DASHBOARD_BASE_URL", "API_PUBLIC_URL", + "DELETION_CONFIRMATION_TTL_MINUTES", "FAMILY_BINDINGS_ENABLED", + "BREVO_WEBHOOK_SECRET", "SES_SNS_SUBSCRIPTION_ARN", + "SENDGRID_WEBHOOK_PUBLIC_KEY", + "WORKER_INTERNAL_JWT_SECRET", "ADMIN_PATH_PREFIX", + } +} + +func TestErrMissingConfig_Error(t *testing.T) { + e := &ErrMissingConfig{Key: "FOO"} + if got := e.Error(); !strings.Contains(got, "FOO") || !strings.Contains(got, "required") { + t.Fatalf("unexpected Error() output: %q", got) + } +} + +func TestGetenv_FallbackAndExplicit(t *testing.T) { + _ = os.Unsetenv("X_UNIT_GETENV") + if got := getenv("X_UNIT_GETENV", "fallback"); got != "fallback" { + t.Fatalf("expected fallback, got %q", got) + } + t.Setenv("X_UNIT_GETENV", "explicit") + if got := getenv("X_UNIT_GETENV", "fallback"); got != "explicit" { + t.Fatalf("expected explicit, got %q", got) + } + // Empty string falls back too — getenv treats "" as missing. + t.Setenv("X_UNIT_GETENV", "") + if got := getenv("X_UNIT_GETENV", "fallback"); got != "fallback" { + t.Fatalf("empty env should fall back, got %q", got) + } +} + +func TestRequire_PanicsOnMissing(t *testing.T) { + _ = os.Unsetenv("X_UNIT_REQUIRE") + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic") + } + err, ok := r.(*ErrMissingConfig) + if !ok { + t.Fatalf("expected *ErrMissingConfig, got %T", r) + } + if err.Key != "X_UNIT_REQUIRE" { + t.Fatalf("key=%q", err.Key) + } + }() + _ = require("X_UNIT_REQUIRE") +} + +func TestRequire_ReturnsValueWhenSet(t *testing.T) { + t.Setenv("X_UNIT_REQUIRE", "ok") + if got := require("X_UNIT_REQUIRE"); got != "ok" { + t.Fatalf("got %q", got) + } +} + +func TestMask(t *testing.T) { + if got := mask(""); got != "***" { + t.Fatalf("empty: %q", got) + } + if got := mask("short"); got != "***" { + t.Fatalf("short: %q", got) + } + // 12 chars is still "***" — boundary + if got := mask("123456789012"); got != "***" { + t.Fatalf("12-char: %q", got) + } + // 13+ → first 8, ***, last 4 + got := mask("abcdefgh12345678ijklmn") + if !strings.HasPrefix(got, "abcdefgh") || !strings.Contains(got, "***") || !strings.HasSuffix(got, "klmn") { + t.Fatalf("long: %q", got) + } +} + +func TestMaskSecret(t *testing.T) { + if got := maskSecret(""); got != "" { + t.Fatalf("empty: %q", got) + } + // 4 chars exactly — prefix is whole string, no stars + if got := maskSecret("abcd"); got != "abcd" { + t.Fatalf("4-char: %q", got) + } + // Longer — prefix is first 4, rest stars + if got := maskSecret("abcdefghij"); got != "abcd******" { + t.Fatalf("10-char: %q", got) + } +} + +func TestValidateAdminPathPrefix(t *testing.T) { + if err := ValidateAdminPathPrefix(""); err != nil { + t.Fatalf("empty must pass (closed by default), got %v", err) + } + // 32 chars alphanumeric — valid + if err := ValidateAdminPathPrefix(strings.Repeat("a", 32)); err != nil { + t.Fatalf("32-char alphanumeric should pass, got %v", err) + } + // 31 chars — too short + if err := ValidateAdminPathPrefix(strings.Repeat("a", 31)); err == nil { + t.Fatal("31-char must fail (too short)") + } + // 32 chars but contains '-' — fail + bad := strings.Repeat("a", 31) + "-" + if err := ValidateAdminPathPrefix(bad); err == nil { + t.Fatal("non-alphanumeric must fail") + } + // Spaces, slashes, digits — covers each ASCII class + if err := ValidateAdminPathPrefix(strings.Repeat("0", 16) + strings.Repeat("Z", 16)); err != nil { + t.Fatalf("digit+upper should pass, got %v", err) + } + if err := ValidateAdminPathPrefix(strings.Repeat(" ", 32)); err == nil { + t.Fatal("space-byte 0x20 must fail") + } + if err := ValidateAdminPathPrefix(strings.Repeat("/", 32)); err == nil { + t.Fatal("slash must fail") + } +} + +func TestConfig_IsServiceEnabled(t *testing.T) { + c := &Config{EnabledServices: "redis, postgres,mongodb,queue"} + for _, s := range []string{"redis", "postgres", "mongodb", "queue"} { + if !c.IsServiceEnabled(s) { + t.Errorf("expected %s enabled", s) + } + } + if c.IsServiceEnabled("storage") { + t.Error("storage must NOT be enabled") + } + // Empty list + if (&Config{}).IsServiceEnabled("redis") { + t.Error("empty list must not match") + } +} + +func TestLoad_HappyPath_AppliesDefaults(t *testing.T) { + applyBaselineEnv(t, nil) + cfg := Load() + if cfg.Port != "8080" { + t.Errorf("Port default: %q", cfg.Port) + } + if cfg.RedisURL != "redis://localhost:6379" { + t.Errorf("RedisURL default: %q", cfg.RedisURL) + } + if cfg.Environment != "development" { + t.Errorf("Environment default: %q", cfg.Environment) + } + if cfg.EnabledServices != "redis,postgres,mongodb,queue" { + t.Errorf("EnabledServices default: %q", cfg.EnabledServices) + } + if cfg.GeoLite2DBPath != "./GeoLite2-City.mmdb" { + t.Errorf("GeoLite2DBPath default: %q", cfg.GeoLite2DBPath) + } + if cfg.RedisProvisionBackend != "local" { + t.Errorf("RedisProvisionBackend default: %q", cfg.RedisProvisionBackend) + } + if cfg.MongoAdminURI != "mongodb://root:root@localhost:27017" { + t.Errorf("MongoAdminURI default: %q", cfg.MongoAdminURI) + } + if cfg.QueueBackend != "nats" { + t.Errorf("QueueBackend default: %q", cfg.QueueBackend) + } + if cfg.NATSPublicHost != "nats.instanode.dev" { + t.Errorf("NATSPublicHost default: %q", cfg.NATSPublicHost) + } + if cfg.DeployDomain != "instant.dev" { + t.Errorf("DeployDomain default: %q", cfg.DeployDomain) + } + if cfg.ComputeProvider != "noop" { + t.Errorf("ComputeProvider default: %q", cfg.ComputeProvider) + } + if cfg.KubeNamespaceApps != "instant-apps" { + t.Errorf("KubeNamespaceApps default: %q", cfg.KubeNamespaceApps) + } + if cfg.DashboardBaseURL != "http://localhost:5173" { + t.Errorf("DashboardBaseURL default: %q", cfg.DashboardBaseURL) + } + if cfg.DeletionConfirmationTTLMinutes != 15 { + t.Errorf("DeletionConfirmationTTLMinutes default: %d", cfg.DeletionConfirmationTTLMinutes) + } + if !cfg.FamilyBindingsEnabled { + t.Error("FamilyBindingsEnabled default must be true") + } + // Object store mode resolution: with everything empty → "admin" / "minio-admin" + if cfg.ObjectStoreMode != "admin" || cfg.ObjectStoreBackend != "minio-admin" { + t.Errorf("ObjectStoreMode/Backend defaults: %q/%q", cfg.ObjectStoreMode, cfg.ObjectStoreBackend) + } +} + +func TestLoad_OverrideDefaults(t *testing.T) { + applyBaselineEnv(t, map[string]string{ + "PORT": "9090", + "REDIS_URL": "redis://r:6379", + "ENVIRONMENT": "production", + "INSTANT_ENABLED_SERVICES": "postgres", + "GEOLITE2_DB_PATH": "/data/geo.mmdb", + "RAZORPAY_KEY_ID": "rzp_test_x", + "RESEND_API_KEY": "re_x", + "BREVO_API_KEY": "br_x", + "EMAIL_PROVIDER": "brevo", + "EMAIL_FROM_ADDRESS": "noreply@x.dev", + "EMAIL_FROM_NAME": "X", + "GITHUB_CLIENT_ID": "gh-x", + "GOOGLE_CLIENT_ID": "g-x", + "GOOGLE_REDIRECT_URI": "https://x/callback", + "REDIS_PROVISION_BACKEND": "upstash", + "REDIS_PROVISION_HOST": "redis.x", + "MONGO_HOST": "mongo.x", + "POSTGRES_PROVISION_BACKEND": "neon", + "NEON_API_KEY": "nk-x", + "PROVISIONER_ADDR": "prov:50051", + "PROVISIONER_SECRET": "ps", + "NATS_HOST": "nats.x", + "QUEUE_BACKEND": "legacy_open", + "NATS_PUBLIC_HOST": "public.x", + "NATS_OPERATOR_SEED": "SO_seed", + "NATS_SYSTEM_ACCOUNT_PUBLIC_KEY": "ACSYS", + "NATS_USE_TLS": "true", + "R2_ENDPOINT": "r2.x", + "R2_BUCKET_NAME": "x-bucket", + "R2_API_TOKEN": "r2tok", + "DEPLOY_DOMAIN": "x.dev", + "COMPUTE_PROVIDER": "k8s", + "KUBE_NAMESPACE_APPS": "x-apps", + "METRICS_TOKEN": strings.Repeat("M", 64), + "DASHBOARD_BASE_URL": "https://dash.x", + "API_PUBLIC_URL": "https://api.x/", + "BREVO_WEBHOOK_SECRET": "brevo-wh", + "SES_SNS_SUBSCRIPTION_ARN": "arn:aws:sns:x", + "SENDGRID_WEBHOOK_PUBLIC_KEY": "sg-key", + "WORKER_INTERNAL_JWT_SECRET": " worker-secret ", + "TRUSTED_PROXY_CIDRS": "10.0.0.0/8", + "MAXMIND_LICENSE_KEY": "mm", + }) + cfg := Load() + if cfg.Port != "9090" || cfg.Environment != "production" { + t.Fatalf("overrides not applied: port=%q env=%q", cfg.Port, cfg.Environment) + } + if cfg.QueueBackend != "legacy_open" { + t.Errorf("QueueBackend override: %q", cfg.QueueBackend) + } + if !cfg.NATSUseTLS { + t.Error("NATSUseTLS must be true when env=true") + } + // API_PUBLIC_URL — trailing slash must be trimmed. + if cfg.APIPublicURL != "https://api.x" { + t.Errorf("APIPublicURL trim: %q", cfg.APIPublicURL) + } + // WORKER_INTERNAL_JWT_SECRET trim + if cfg.WorkerInternalJWTSecret != "worker-secret" { + t.Errorf("WorkerInternalJWTSecret trim: %q", cfg.WorkerInternalJWTSecret) + } + if cfg.MetricsToken == "" { + t.Error("MetricsToken should be set") + } +} + +func TestLoad_FamilyBindingsDisabled(t *testing.T) { + for _, val := range []string{"false", "FALSE", "0", "no", " No "} { + applyBaselineEnv(t, map[string]string{"FAMILY_BINDINGS_ENABLED": val}) + cfg := Load() + if cfg.FamilyBindingsEnabled { + t.Errorf("FAMILY_BINDINGS_ENABLED=%q should disable", val) + } + } + // Unrecognized value → default true + applyBaselineEnv(t, map[string]string{"FAMILY_BINDINGS_ENABLED": "yes"}) + if !Load().FamilyBindingsEnabled { + t.Error(`FAMILY_BINDINGS_ENABLED="yes" must default to true`) + } +} + +func TestLoad_DeletionTTL_OverrideAndInvalid(t *testing.T) { + applyBaselineEnv(t, map[string]string{"DELETION_CONFIRMATION_TTL_MINUTES": "30"}) + if got := Load().DeletionConfirmationTTLMinutes; got != 30 { + t.Errorf("override: got %d", got) + } + applyBaselineEnv(t, map[string]string{"DELETION_CONFIRMATION_TTL_MINUTES": "abc"}) + if got := Load().DeletionConfirmationTTLMinutes; got != 15 { + t.Errorf("invalid value should fall back to 15, got %d", got) + } + applyBaselineEnv(t, map[string]string{"DELETION_CONFIRMATION_TTL_MINUTES": "-5"}) + if got := Load().DeletionConfirmationTTLMinutes; got != 15 { + t.Errorf("negative value should fall back to 15, got %d", got) + } + applyBaselineEnv(t, map[string]string{"DELETION_CONFIRMATION_TTL_MINUTES": "0"}) + if got := Load().DeletionConfirmationTTLMinutes; got != 15 { + t.Errorf("zero value should fall back to 15, got %d", got) + } + // Whitespace-only is treated as empty by the TrimSpace guard. + applyBaselineEnv(t, map[string]string{"DELETION_CONFIRMATION_TTL_MINUTES": " "}) + if got := Load().DeletionConfirmationTTLMinutes; got != 15 { + t.Errorf("whitespace-only should fall back to 15, got %d", got) + } +} + +func TestLoad_ObjectStore_MinioFallback(t *testing.T) { + applyBaselineEnv(t, map[string]string{ + "MINIO_ENDPOINT": "minio:9000", + "MINIO_PUBLIC_ENDPOINT": "https://s3.x", + "MINIO_ROOT_USER": "miniouser", + "MINIO_ROOT_PASSWORD": "miniosecret", + "MINIO_BUCKET_NAME": "x-bucket", + }) + cfg := Load() + if cfg.ObjectStoreEndpoint != "minio:9000" { + t.Errorf("MINIO_ENDPOINT fallback: %q", cfg.ObjectStoreEndpoint) + } + if cfg.ObjectStorePublicURL != "https://s3.x" { + t.Errorf("MINIO_PUBLIC_ENDPOINT fallback: %q", cfg.ObjectStorePublicURL) + } + if cfg.ObjectStoreAccessKey != "miniouser" { + t.Errorf("MINIO_ROOT_USER fallback: %q", cfg.ObjectStoreAccessKey) + } + if cfg.ObjectStoreSecretKey != "miniosecret" { + t.Errorf("MINIO_ROOT_PASSWORD fallback: %q", cfg.ObjectStoreSecretKey) + } + // MINIO_BUCKET_NAME overrides the default-only "instant-shared" path. + if cfg.ObjectStoreBucket != "x-bucket" { + t.Errorf("MINIO_BUCKET_NAME fallback: %q", cfg.ObjectStoreBucket) + } +} + +func TestLoad_ObjectStore_ExplicitOverridesFallback(t *testing.T) { + applyBaselineEnv(t, map[string]string{ + "OBJECT_STORE_ENDPOINT": "nyc3.digitaloceanspaces.com", + "OBJECT_STORE_PUBLIC_URL": "https://s3.instanode.dev", + "OBJECT_STORE_ACCESS_KEY": "AKIA", + "OBJECT_STORE_SECRET_KEY": "SECRET", + "OBJECT_STORE_BUCKET": "do-bucket", + "OBJECT_STORE_REGION": "nyc3", + "OBJECT_STORE_SECURE": "true", + "OBJECT_STORE_ALLOW_SHARED_KEY": "true", + // Set MINIO_* so we prove they DON'T win. + "MINIO_ENDPOINT": "minio:9000", + "MINIO_ROOT_USER": "ignored", + }) + cfg := Load() + if cfg.ObjectStoreEndpoint != "nyc3.digitaloceanspaces.com" { + t.Errorf("explicit OBJECT_STORE_ENDPOINT should win: %q", cfg.ObjectStoreEndpoint) + } + if cfg.ObjectStoreAccessKey != "AKIA" { + t.Errorf("explicit AccessKey: %q", cfg.ObjectStoreAccessKey) + } + if !cfg.ObjectStoreSecure { + t.Error("OBJECT_STORE_SECURE=true not honoured") + } + if !cfg.ObjectStoreAllowSharedKey { + t.Error("OBJECT_STORE_ALLOW_SHARED_KEY=true not honoured") + } +} + +func TestLoad_ObjectStore_ModeBackendAliases(t *testing.T) { + // OBJECT_STORE_MODE wins when set. + applyBaselineEnv(t, map[string]string{ + "OBJECT_STORE_MODE": "shared-key", + }) + cfg := Load() + if cfg.ObjectStoreMode != "shared-key" { + t.Errorf("Mode (explicit): %q", cfg.ObjectStoreMode) + } + // Backend inherits from Mode when only Mode is set. + if cfg.ObjectStoreBackend != "shared-key" { + t.Errorf("Backend inherits from Mode: %q", cfg.ObjectStoreBackend) + } + + // OBJECT_STORE_BACKEND-only sets both. + applyBaselineEnv(t, map[string]string{ + "OBJECT_STORE_BACKEND": "do-spaces", + }) + cfg = Load() + if cfg.ObjectStoreBackend != "do-spaces" || cfg.ObjectStoreMode != "do-spaces" { + t.Errorf("Backend-only: mode=%q backend=%q", cfg.ObjectStoreMode, cfg.ObjectStoreBackend) + } +} + +func TestLoad_PanicsOnMissingRequired(t *testing.T) { + cases := []string{"DATABASE_URL", "JWT_SECRET", "AES_KEY"} + for _, missing := range cases { + t.Run(missing, func(t *testing.T) { + applyBaselineEnv(t, nil) + _ = os.Unsetenv(missing) + t.Setenv(missing, "") + defer func() { + if r := recover(); r == nil { + t.Fatalf("expected panic when %s missing", missing) + } + }() + _ = Load() + }) + } +} + +func TestLoad_PanicsOnShortJWT(t *testing.T) { + applyBaselineEnv(t, map[string]string{"JWT_SECRET": "tooshort"}) + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic for short JWT_SECRET") + } + msg, _ := r.(string) + if !strings.Contains(msg, "JWT_SECRET") { + t.Fatalf("panic msg: %v", r) + } + }() + _ = Load() +} + +func TestLoad_PanicsOnBadAESKey(t *testing.T) { + applyBaselineEnv(t, map[string]string{"AES_KEY": strings.Repeat("a", 63)}) + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic for short AES_KEY") + } + msg, _ := r.(string) + if !strings.Contains(msg, "AES_KEY") { + t.Fatalf("panic msg: %v", r) + } + }() + _ = Load() +} + +func TestLoad_PanicsOnBadAdminPathPrefix(t *testing.T) { + applyBaselineEnv(t, map[string]string{"ADMIN_PATH_PREFIX": "tooshort"}) + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic on short admin prefix") + } + }() + _ = Load() +} + +func TestLoad_AdminPathPrefix_Valid(t *testing.T) { + prefix := strings.Repeat("a", 64) + applyBaselineEnv(t, map[string]string{"ADMIN_PATH_PREFIX": prefix}) + cfg := Load() + if cfg.AdminPathPrefix != prefix { + t.Errorf("AdminPathPrefix: %q", cfg.AdminPathPrefix) + } +} + +func TestLoad_LogStartupConfig_MetricsToken_Prod(t *testing.T) { + // Cover the production-no-token branch end-to-end through Load(). + applyBaselineEnv(t, map[string]string{ + "ENVIRONMENT": "production", + }) + cfg := Load() + if cfg.MetricsToken != "" { + t.Fatal("test setup: METRICS_TOKEN must be empty for this branch") + } + if cfg.Environment != "production" { + t.Fatalf("env: %q", cfg.Environment) + } +} + +// TestLoad_RazorpayPlanIDs ensures every plan-id env mapping lands on the +// matching Config field (D28 F3 / 2026-05-15 alignment). +func TestLoad_RazorpayPlanIDs(t *testing.T) { + applyBaselineEnv(t, map[string]string{ + "RAZORPAY_PLAN_ID_HOBBY": "plan_hobby", + "RAZORPAY_PLAN_ID_HOBBY_PLUS": "plan_hp", + "RAZORPAY_PLAN_ID_PRO": "plan_pro", + "RAZORPAY_PLAN_ID_GROWTH": "plan_growth", + "RAZORPAY_PLAN_ID_TEAM": "plan_team", + "RAZORPAY_PLAN_ID_HOBBY_ANNUAL": "plan_hobby_y", + "RAZORPAY_PLAN_ID_HOBBY_PLUS_ANNUAL": "plan_hp_y", + "RAZORPAY_PLAN_ID_PRO_ANNUAL": "plan_pro_y", + "RAZORPAY_PLAN_ID_GROWTH_ANNUAL": "plan_growth_y", + "RAZORPAY_PLAN_ID_TEAM_ANNUAL": "plan_team_y", + "RAZORPAY_KEY_SECRET": "secret", + "RAZORPAY_WEBHOOK_SECRET": "whsec", + }) + c := Load() + checks := map[string]string{ + "hobby": c.RazorpayPlanIDHobby, + "hp": c.RazorpayPlanIDHobbyPlus, + "pro": c.RazorpayPlanIDPro, + "growth": c.RazorpayPlanIDGrowth, + "team": c.RazorpayPlanIDTeam, + "hobby_y": c.RazorpayPlanIDHobbyYearly, + "hp_y": c.RazorpayPlanIDHobbyPlusYearly, + "pro_y": c.RazorpayPlanIDProYearly, + "growth_y": c.RazorpayPlanIDGrowthYearly, + "team_y": c.RazorpayPlanIDTeamYearly, + } + for tag, got := range checks { + want := "plan_" + tag + if got != want { + t.Errorf("%s: got %q want %q", tag, got, want) + } + } + if c.RazorpayKeySecret != "secret" || c.RazorpayWebhookSecret != "whsec" { + t.Error("KeySecret/WebhookSecret not loaded") + } +} diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go new file mode 100644 index 0000000..8b8f7b4 --- /dev/null +++ b/internal/metrics/metrics_test.go @@ -0,0 +1,184 @@ +package metrics + +import ( + "strings" + "testing" + + dto "github.com/prometheus/client_model/go" + "github.com/prometheus/client_golang/prometheus" +) + +func TestStatusClass(t *testing.T) { + cases := []struct { + code int + want string + }{ + {200, "2xx"}, + {201, "2xx"}, + {299, "2xx"}, + {400, "4xx"}, + {404, "4xx"}, + {499, "4xx"}, + {500, "5xx"}, + {503, "5xx"}, + {599, "5xx"}, + {100, "other"}, + {0, "other"}, + {301, "other"}, + {-1, "other"}, + } + for _, c := range cases { + if got := StatusClass(c.code); got != c.want { + t.Errorf("StatusClass(%d) = %q, want %q", c.code, got, c.want) + } + } +} + +// counterValue extracts the float64 value of a prometheus.Counter by hitting +// Collect()'s channel directly. Used to verify Observe paths actually move +// the underlying metric. +func counterValue(t *testing.T, c prometheus.Collector) float64 { + t.Helper() + ch := make(chan prometheus.Metric, 16) + go func() { + c.Collect(ch) + close(ch) + }() + var total float64 + for m := range ch { + var dtoMetric dto.Metric + if err := m.Write(&dtoMetric); err != nil { + t.Fatalf("metric.Write: %v", err) + } + if dtoMetric.Counter != nil { + total += dtoMetric.Counter.GetValue() + } + if dtoMetric.Gauge != nil { + total += dtoMetric.Gauge.GetValue() + } + } + return total +} + +func TestReadyzCheckStatus_SetsGauge(t *testing.T) { + // Hit each value in the documented contract (1, 0.5, 0) plus an + // arbitrary float to confirm the gauge accepts the full range. + for _, v := range []float64{1, 0.5, 0, 0.25} { + ReadyzCheckStatus("unit-test-check", v) + } + // Read back via the underlying gauge — service label is stamped by + // ReadyzCheckStatus itself, so we look up by both labels. + g, err := readyzCheckStatusGauge.GetMetricWithLabelValues("instant-api", "unit-test-check") + if err != nil { + t.Fatalf("GetMetricWithLabelValues: %v", err) + } + var dtoMetric dto.Metric + if err := g.Write(&dtoMetric); err != nil { + t.Fatalf("g.Write: %v", err) + } + if got := dtoMetric.Gauge.GetValue(); got != 0.25 { + t.Fatalf("expected last-set value 0.25, got %v", got) + } +} + +// TestAllMetricsRegistered exercises every exported metric by performing one +// representative observation. Counter/Gauge/Histogram values are read back to +// confirm the path actually moves the underlying series — a smoke harness that +// fails fast if a metric is renamed, retyped, or accidentally removed. +func TestAllMetricsRegistered(t *testing.T) { + ProvisionsTotal.WithLabelValues("postgres", "hobby").Inc() + ProvisionFailures.WithLabelValues("postgres", "grpc_error").Inc() + ProvisionDuration.WithLabelValues("postgres", "hobby").Observe(0.1) + HTTPRequestDuration.WithLabelValues("POST", "/db/new", "2xx").Observe(0.05) + HTTPErrors.WithLabelValues("POST", "/db/new", "4xx").Inc() + GRPCDuration.WithLabelValues("Provision", "ok").Observe(0.2) + FingerprintAbuseBlocked.Inc() + RecycleGateBlocked.WithLabelValues("postgres").Inc() + ConversionFunnel.WithLabelValues("paid").Inc() + RedisErrors.WithLabelValues("get").Inc() + FailOpenEvents.WithLabelValues("redis_rate_limit", "redis_unavailable").Inc() + GeoIPDBAge.Set(12.5) + StorageIAMUsersCreated.Inc() + StorageIAMUsersDeleted.Inc() + StorageIAMUsersFailed.WithLabelValues("create", "add_user").Inc() + DedicatedTierUpgradeBlocked.WithLabelValues("db", "free").Inc() + StackProvisionLimitBlocked.WithLabelValues("hobby").Inc() + QueueProvisionLimitBlocked.WithLabelValues("hobby").Inc() + DeployTeardownMarkFailed.Inc() + NatsAuthFailures.Inc() + GoroutinePanics.WithLabelValues("runDeploy").Inc() + BrevoWebhookEventsTotal.WithLabelValues("delivered").Inc() + MagicLinkEmailRateLimited.Inc() + RazorpayWebhookTeamNotFound.Inc() + PGPoolInUse.WithLabelValues("platform_db").Set(3) + PGPoolIdle.WithLabelValues("platform_db").Set(2) + PGPoolOpen.WithLabelValues("platform_db").Set(5) + PGPoolMax.WithLabelValues("platform_db").Set(25) + PGPoolWaitCount.WithLabelValues("platform_db").Set(42) + PGPoolWaitDurationSeconds.WithLabelValues("platform_db").Set(3.14) + + // Confirm two representative metrics actually carry a value (covers + // the rest by construction — same prometheus library + same Inc/Set). + if v := counterValue(t, FingerprintAbuseBlocked); v < 1 { + t.Fatalf("FingerprintAbuseBlocked should be >= 1, got %v", v) + } + if v := counterValue(t, GeoIPDBAge); v != 12.5 { + t.Fatalf("GeoIPDBAge should be 12.5, got %v", v) + } +} + +// TestMetricsExposedViaPrometheusRegistry confirms each metric has the +// documented Prometheus name and Help text — a contract test against +// dashboards/alerts that reference these strings. +func TestMetricsExposedViaPrometheusRegistry(t *testing.T) { + want := []string{ + "instant_provisions_total", + "instant_provision_failures_total", + "instant_provision_duration_seconds", + "instant_http_request_duration_seconds", + "instant_http_errors_total", + "instant_grpc_duration_seconds", + "instant_fingerprint_abuse_blocked_total", + "instant_recycle_gate_blocked_total", + "instant_conversion_funnel_total", + "instant_redis_errors_total", + "instant_fail_open_events_total", + "instant_geoip_db_age_hours", + "instant_storage_iam_users_created_total", + "instant_storage_iam_users_deleted_total", + "instant_storage_iam_users_failed_total", + "instant_dedicated_tier_upgrade_blocked_total", + "instant_stack_provision_limit_blocked_total", + "instant_queue_provision_limit_blocked_total", + "instant_deploy_teardown_mark_failed_total", + "nats_auth_failures_total", + "instant_goroutine_panics_total", + "brevo_webhook_events_total", + "instant_magic_link_email_rate_limited_total", + "razorpay_webhook_team_not_found_total", + "readyz_check_status", + "instant_pg_pool_in_use", + "instant_pg_pool_idle", + "instant_pg_pool_open", + "instant_pg_pool_max", + "instant_pg_pool_wait_count", + "instant_pg_pool_wait_duration_seconds", + } + mfs, err := prometheus.DefaultGatherer.Gather() + if err != nil { + t.Fatalf("Gather: %v", err) + } + seen := make(map[string]bool, len(mfs)) + for _, mf := range mfs { + seen[mf.GetName()] = true + } + var missing []string + for _, n := range want { + if !seen[n] { + missing = append(missing, n) + } + } + if len(missing) > 0 { + t.Fatalf("metrics missing from default registry: %s", strings.Join(missing, ", ")) + } +} diff --git a/internal/tokens/store_test.go b/internal/tokens/store_test.go new file mode 100644 index 0000000..14edcd0 --- /dev/null +++ b/internal/tokens/store_test.go @@ -0,0 +1,240 @@ +package tokens + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +// withHome redirects the HOME env var to a temp dir for the duration of fn so +// that storePath() resolves to a hermetic location. Returns the temp dir for +// callers that need to assert against it. +func withHome(t *testing.T, fn func(home string)) { + t.Helper() + dir := t.TempDir() + prev, hadPrev := os.LookupEnv("HOME") + prevUser, hadUser := os.LookupEnv("USERPROFILE") // Windows path resolution + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + t.Cleanup(func() { + if hadPrev { + _ = os.Setenv("HOME", prev) + } else { + _ = os.Unsetenv("HOME") + } + if hadUser { + _ = os.Setenv("USERPROFILE", prevUser) + } else { + _ = os.Unsetenv("USERPROFILE") + } + }) + fn(dir) +} + +func TestLoad_MissingFileReturnsEmptyStore(t *testing.T) { + withHome(t, func(home string) { + s, err := Load() + if err != nil { + t.Fatalf("expected nil error on missing file, got %v", err) + } + if s == nil { + t.Fatal("expected non-nil store") + } + if len(s.Entries) != 0 { + t.Fatalf("expected 0 entries, got %d", len(s.Entries)) + } + if s.path != filepath.Join(home, ".instant-tokens") { + t.Fatalf("unexpected path %q", s.path) + } + }) +} + +func TestLoad_ReadsExistingStore(t *testing.T) { + withHome(t, func(home string) { + path := filepath.Join(home, ".instant-tokens") + entries := &Store{Entries: []Entry{{ + Token: "tok-1", + Name: "monitor-a", + URL: "https://example.com/ping", + Schedule: "* * * * *", + Source: "manual", + CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + }}} + data, _ := json.Marshal(entries) + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatalf("seed: %v", err) + } + s, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(s.Entries) != 1 || s.Entries[0].Token != "tok-1" { + t.Fatalf("entries not parsed: %+v", s.Entries) + } + if s.path != path { + t.Fatalf("path not restored: %q", s.path) + } + }) +} + +func TestLoad_InvalidJSONReturnsError(t *testing.T) { + withHome(t, func(home string) { + path := filepath.Join(home, ".instant-tokens") + if err := os.WriteFile(path, []byte("not-json"), 0600); err != nil { + t.Fatal(err) + } + _, err := Load() + if err == nil { + t.Fatal("expected error for invalid JSON") + } + }) +} + +func TestLoad_ReadErrorOtherThanNotExist(t *testing.T) { + withHome(t, func(home string) { + // Create the path as a directory so os.ReadFile fails with EISDIR + // (not IsNotExist), exercising the non-IsNotExist error branch. + path := filepath.Join(home, ".instant-tokens") + if err := os.Mkdir(path, 0700); err != nil { + t.Fatal(err) + } + _, err := Load() + if err == nil { + t.Fatal("expected error reading a directory as file") + } + // must NOT be a not-exist error — that branch is covered separately + if errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected non-ErrNotExist, got %v", err) + } + }) +} + +func TestAddAndFindAndSaveRoundTrip(t *testing.T) { + withHome(t, func(home string) { + s, err := Load() + if err != nil { + t.Fatal(err) + } + // Add with empty CreatedAt — Add must stamp it. + err = s.Add(Entry{Token: "tok-A", Name: "a", Source: "manual"}) + if err != nil { + t.Fatalf("Add: %v", err) + } + if len(s.Entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(s.Entries)) + } + if s.Entries[0].CreatedAt.IsZero() { + t.Fatal("Add must auto-stamp CreatedAt when zero") + } + + // Add with explicit CreatedAt — Add must preserve it. + fixed := time.Date(2024, 6, 1, 12, 0, 0, 0, time.UTC) + if err := s.Add(Entry{Token: "tok-B", CreatedAt: fixed}); err != nil { + t.Fatalf("Add B: %v", err) + } + if !s.Entries[1].CreatedAt.Equal(fixed) { + t.Fatalf("expected CreatedAt preserved, got %v", s.Entries[1].CreatedAt) + } + + // Find — hit + miss + if got := s.Find("tok-A"); got == nil || got.Token != "tok-A" { + t.Fatalf("Find(tok-A) miss: %+v", got) + } + if got := s.Find("nope"); got != nil { + t.Fatalf("Find(nope) should return nil, got %+v", got) + } + + // Save persisted -> Load reads it back. + s2, err := Load() + if err != nil { + t.Fatalf("Load (round-trip): %v", err) + } + if len(s2.Entries) != 2 { + t.Fatalf("expected 2 entries after round-trip, got %d", len(s2.Entries)) + } + + // File perms must be 0600 (token store is sensitive). + info, err := os.Stat(filepath.Join(home, ".instant-tokens")) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("expected 0600 perms, got %v", info.Mode().Perm()) + } + }) +} + +func TestSave_WriteErrorPropagates(t *testing.T) { + // Point the store at an unwritable path by setting path to a directory. + dir := t.TempDir() + s := &Store{path: dir} // writing to a directory must fail + if err := s.Save(); err == nil { + t.Fatal("expected write error when path is a directory") + } +} + +func TestStorePath_UnsetHome(t *testing.T) { + // On unix, unsetting HOME triggers UserHomeDir's error path. + prev, had := os.LookupEnv("HOME") + prevUser, hadUser := os.LookupEnv("USERPROFILE") + _ = os.Unsetenv("HOME") + _ = os.Unsetenv("USERPROFILE") + t.Cleanup(func() { + if had { + _ = os.Setenv("HOME", prev) + } + if hadUser { + _ = os.Setenv("USERPROFILE", prevUser) + } + }) + + p, err := storePath() + if err != nil { + // Expected on most CI environments: UserHomeDir returns an error. + if p != "" { + t.Fatalf("expected empty path on error, got %q", p) + } + return + } + // If the runtime still resolved a home (some platforms / runners do), + // just assert the result ends with the canonical filename so the + // function contract is exercised either way. + if filepath.Base(p) != ".instant-tokens" { + t.Fatalf("unexpected resolved path %q", p) + } +} + +// TestLoad_StorePathError exercises Load's "storePath failed" early-return +// branch by unsetting HOME (and USERPROFILE for Windows-shape runners) so +// os.UserHomeDir returns an error. +func TestLoad_StorePathError(t *testing.T) { + prev, had := os.LookupEnv("HOME") + prevUser, hadUser := os.LookupEnv("USERPROFILE") + _ = os.Unsetenv("HOME") + _ = os.Unsetenv("USERPROFILE") + t.Cleanup(func() { + if had { + _ = os.Setenv("HOME", prev) + } + if hadUser { + _ = os.Setenv("USERPROFILE", prevUser) + } + }) + s, err := Load() + if err == nil { + // Some runtimes (containers with /etc/passwd seeded) will still + // resolve a home. In that case, just assert we got a usable store + // — the storePath error branch is platform-conditional and the + // other Load tests cover the happy path. + if s == nil { + t.Fatal("expected store on resolved home") + } + return + } + if s != nil { + t.Fatalf("expected nil store on storePath error, got %+v", s) + } +}