rdev/internal/service/component_test.go
jordan d91bfc50fa
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fix: handle missing Redis credentials and redis.Nil in provisioner
Two bugs fixed:

1. redis.Nil not handled in GetProjectCache: When ACL GETUSER returns nil
   (user doesn't exist), go-redis represents this as redis.Nil error. The
   provisioner only checked for err.Contains("User") which didn't match,
   causing spurious "get ACL user: redis: nil" errors on re-provision.

2. provisionRedis returns 409 even when REDIS_URL not in credential store:
   If the Redis ACL user exists but REDIS_URL was never stored (e.g., due
   to a failed previous run or lost state), the service would permanently
   refuse to provision, leaving the project without usable Redis credentials.
   Now checks the credential store: if REDIS_URL exists → true 409 duplicate;
   if REDIS_URL missing → re-provision (CreateProjectCache resets the password).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 05:19:00 -07:00

800 lines
22 KiB
Go

package service
import (
"context"
"errors"
"log/slog"
"os"
"testing"
"time"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
func TestAssignComponentPath(t *testing.T) {
tests := []struct {
name string
component *domain.Component
expectedPath string
}{
{
name: "service gets /api/{name}",
component: &domain.Component{
Type: domain.ComponentTypeService,
Name: "auth",
},
expectedPath: "/api/auth",
},
{
name: "service with different name",
component: &domain.Component{
Type: domain.ComponentTypeService,
Name: "users",
},
expectedPath: "/api/users",
},
{
name: "app-react gets /",
component: &domain.Component{
Type: domain.ComponentTypeAppReact,
Name: "web",
},
expectedPath: "/",
},
{
name: "app-astro gets /",
component: &domain.Component{
Type: domain.ComponentTypeAppAstro,
Name: "landing",
},
expectedPath: "/",
},
{
name: "app-nextjs gets /",
component: &domain.Component{
Type: domain.ComponentTypeAppNextJS,
Name: "dashboard",
},
expectedPath: "/",
},
{
name: "worker gets empty path",
component: &domain.Component{
Type: domain.ComponentTypeWorker,
Name: "processor",
},
expectedPath: "",
},
{
name: "cli gets empty path",
component: &domain.Component{
Type: domain.ComponentTypeCLI,
Name: "tool",
},
expectedPath: "",
},
{
name: "postgres gets empty path (infrastructure)",
component: &domain.Component{
Type: domain.ComponentTypePostgres,
Name: "main-db",
},
expectedPath: "",
},
{
name: "redis gets empty path (infrastructure)",
component: &domain.Component{
Type: domain.ComponentTypeRedis,
Name: "cache",
},
expectedPath: "",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := assignComponentPath(tc.component)
if result != tc.expectedPath {
t.Errorf("assignComponentPath(%s %s) = %q, want %q",
tc.component.Type, tc.component.Name, result, tc.expectedPath)
}
})
}
}
// --- Mock implementations for infrastructure component tests ---
type mockDatabaseProvisioner struct {
createCalled bool
deleteCalled bool
getCalled bool
existingDB *domain.DatabaseCredentials
createError error
deleteError error
getError error
createdCredentials *domain.DatabaseCredentials
}
func (m *mockDatabaseProvisioner) CreateProjectDatabase(ctx context.Context, projectID string) (*domain.DatabaseCredentials, error) {
m.createCalled = true
if m.createError != nil {
return nil, m.createError
}
creds := &domain.DatabaseCredentials{
ProjectID: projectID,
DatabaseName: "project_" + projectID,
Username: "project_" + projectID,
Password: "testpassword",
Host: "localhost",
Port: 26257,
SSLMode: "disable",
URL: "postgres://project_" + projectID + ":testpassword@localhost:26257/project_" + projectID + "?sslmode=disable",
URLStaging: "postgres://project_" + projectID + ":testpassword@localhost:26257/project_" + projectID + "?sslmode=disable",
CreatedAt: time.Now(),
}
m.createdCredentials = creds
return creds, nil
}
func (m *mockDatabaseProvisioner) DeleteProjectDatabase(ctx context.Context, projectID string) error {
m.deleteCalled = true
return m.deleteError
}
func (m *mockDatabaseProvisioner) GetProjectDatabase(ctx context.Context, projectID string) (*domain.DatabaseCredentials, error) {
m.getCalled = true
if m.getError != nil {
return nil, m.getError
}
return m.existingDB, nil
}
func (m *mockDatabaseProvisioner) TestConnection(ctx context.Context) error {
return nil
}
type mockCacheProvisioner struct {
createCalled bool
deleteCalled bool
getCalled bool
existingCache *domain.CacheCredentials
createError error
deleteError error
getError error
createdCredentials *domain.CacheCredentials
}
func (m *mockCacheProvisioner) CreateProjectCache(ctx context.Context, projectID string) (*domain.CacheCredentials, error) {
m.createCalled = true
if m.createError != nil {
return nil, m.createError
}
creds := &domain.CacheCredentials{
ProjectID: projectID,
URL: "redis://proj-" + projectID + ":testpassword@localhost:6379",
URLStaging: "redis://proj-" + projectID + ":testpassword@localhost:6379",
Prefix: "project:" + projectID + ":",
Username: "proj-" + projectID,
Host: "localhost",
Port: 6379,
CreatedAt: time.Now(),
}
m.createdCredentials = creds
return creds, nil
}
func (m *mockCacheProvisioner) DeleteProjectCache(ctx context.Context, projectID string, purgeKeys bool) error {
m.deleteCalled = true
return m.deleteError
}
func (m *mockCacheProvisioner) GetProjectCache(ctx context.Context, projectID string) (*domain.CacheCredentials, error) {
m.getCalled = true
if m.getError != nil {
return nil, m.getError
}
return m.existingCache, nil
}
func (m *mockCacheProvisioner) TestConnection(ctx context.Context) error {
return nil
}
type mockCredentialStore struct {
stored map[string]string
setError error
}
func newMockCredentialStore() *mockCredentialStore {
return &mockCredentialStore{stored: make(map[string]string)}
}
func (m *mockCredentialStore) Get(ctx context.Context, key string) (string, error) {
return m.stored[key], nil
}
func (m *mockCredentialStore) GetRequired(ctx context.Context, key string) (string, error) {
v, ok := m.stored[key]
if !ok {
return "", domain.ErrCredentialNotFound
}
return v, nil
}
func (m *mockCredentialStore) Set(ctx context.Context, cred domain.Credential) error {
if m.setError != nil {
return m.setError
}
m.stored[cred.Key] = cred.Value
return nil
}
func (m *mockCredentialStore) Delete(ctx context.Context, key string) error {
delete(m.stored, key)
return nil
}
func (m *mockCredentialStore) List(ctx context.Context) ([]domain.Credential, error) {
return nil, nil
}
func (m *mockCredentialStore) ListByCategory(ctx context.Context, category string) ([]domain.Credential, error) {
return nil, nil
}
func (m *mockCredentialStore) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
result := make(map[string]string)
for _, k := range keys {
if v, ok := m.stored[k]; ok {
result[k] = v
}
}
return result, nil
}
func (m *mockCredentialStore) SetMultiple(ctx context.Context, creds []domain.Credential) error {
for _, c := range creds {
if err := m.Set(ctx, c); err != nil {
return err
}
}
return nil
}
// --- Infrastructure component provisioning tests ---
func TestProvisionPostgres(t *testing.T) {
tests := []struct {
name string
projectID string
componentName string
dbProvisioner *mockDatabaseProvisioner
credStore *mockCredentialStore
wantErr bool
wantErrContains string
}{
{
name: "successful postgres provisioning",
projectID: "test-project",
componentName: "main-db",
dbProvisioner: &mockDatabaseProvisioner{},
credStore: newMockCredentialStore(),
wantErr: false,
},
{
name: "postgres already exists",
projectID: "test-project",
componentName: "main-db",
dbProvisioner: &mockDatabaseProvisioner{
existingDB: &domain.DatabaseCredentials{ProjectID: "test-project"},
},
credStore: newMockCredentialStore(),
wantErr: true,
wantErrContains: "already provisioned",
},
{
name: "provisioning fails",
projectID: "test-project",
componentName: "main-db",
dbProvisioner: &mockDatabaseProvisioner{
createError: errors.New("cockroachdb connection failed"),
},
credStore: newMockCredentialStore(),
wantErr: true,
wantErrContains: "failed to provision database",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
svc := &ComponentService{
dbProvisioner: tc.dbProvisioner,
credentialStore: tc.credStore,
}
ctx := context.Background()
component, err := svc.provisionPostgres(ctx, tc.projectID, tc.componentName)
if tc.wantErr {
if err == nil {
t.Errorf("expected error containing %q, got nil", tc.wantErrContains)
return
}
if tc.wantErrContains != "" && !contains(err.Error(), tc.wantErrContains) {
t.Errorf("error %q does not contain %q", err.Error(), tc.wantErrContains)
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if component.Type != domain.ComponentTypePostgres {
t.Errorf("component type = %v, want %v", component.Type, domain.ComponentTypePostgres)
}
if component.Name != tc.componentName {
t.Errorf("component name = %q, want %q", component.Name, tc.componentName)
}
// Verify credentials were stored
if tc.credStore != nil {
key := tc.projectID + ":DATABASE_URL"
if _, ok := tc.credStore.stored[key]; !ok {
t.Errorf("DATABASE_URL not stored in credential store")
}
}
})
}
}
func TestProvisionRedis(t *testing.T) {
tests := []struct {
name string
projectID string
componentName string
cacheProvisioner *mockCacheProvisioner
credStore *mockCredentialStore
wantErr bool
wantErrContains string
}{
{
name: "successful redis provisioning",
projectID: "test-project",
componentName: "cache",
cacheProvisioner: &mockCacheProvisioner{},
credStore: newMockCredentialStore(),
wantErr: false,
},
{
name: "redis already exists",
projectID: "test-project",
componentName: "cache",
cacheProvisioner: &mockCacheProvisioner{
existingCache: &domain.CacheCredentials{ProjectID: "test-project"},
},
credStore: func() *mockCredentialStore {
// Simulate credentials already stored — this is a true duplicate
cs := newMockCredentialStore()
cs.stored["test-project:REDIS_URL"] = "redis://proj-test-project:pass@localhost:6379"
return cs
}(),
wantErr: true,
wantErrContains: "already provisioned",
},
{
name: "provisioning fails",
projectID: "test-project",
componentName: "cache",
cacheProvisioner: &mockCacheProvisioner{
createError: errors.New("redis connection failed"),
},
credStore: newMockCredentialStore(),
wantErr: true,
wantErrContains: "failed to provision cache",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
svc := &ComponentService{
cacheProvisioner: tc.cacheProvisioner,
credentialStore: tc.credStore,
}
ctx := context.Background()
component, err := svc.provisionRedis(ctx, tc.projectID, tc.componentName)
if tc.wantErr {
if err == nil {
t.Errorf("expected error containing %q, got nil", tc.wantErrContains)
return
}
if tc.wantErrContains != "" && !contains(err.Error(), tc.wantErrContains) {
t.Errorf("error %q does not contain %q", err.Error(), tc.wantErrContains)
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if component.Type != domain.ComponentTypeRedis {
t.Errorf("component type = %v, want %v", component.Type, domain.ComponentTypeRedis)
}
if component.Name != tc.componentName {
t.Errorf("component name = %q, want %q", component.Name, tc.componentName)
}
// Verify credentials were stored
if tc.credStore != nil {
key := tc.projectID + ":REDIS_URL"
if _, ok := tc.credStore.stored[key]; !ok {
t.Errorf("REDIS_URL not stored in credential store")
}
}
})
}
}
func TestAddInfraComponent(t *testing.T) {
tests := []struct {
name string
projectID string
componentType domain.ComponentType
componentName string
dbProvisioner port.DatabaseProvisioner
cacheProvisioner port.CacheProvisioner
credStore port.CredentialStore
wantErr bool
wantErrContains string
}{
{
name: "postgres component routes to provisioner",
projectID: "test-project",
componentType: domain.ComponentTypePostgres,
componentName: "main-db",
dbProvisioner: &mockDatabaseProvisioner{},
credStore: newMockCredentialStore(),
wantErr: false,
},
{
name: "redis component routes to provisioner",
projectID: "test-project",
componentType: domain.ComponentTypeRedis,
componentName: "cache",
cacheProvisioner: &mockCacheProvisioner{},
credStore: newMockCredentialStore(),
wantErr: false,
},
{
name: "postgres without provisioner fails",
projectID: "test-project",
componentType: domain.ComponentTypePostgres,
componentName: "main-db",
dbProvisioner: nil, // nil interface
credStore: newMockCredentialStore(),
wantErr: true,
wantErrContains: "database provisioner not configured",
},
{
name: "redis without provisioner fails",
projectID: "test-project",
componentType: domain.ComponentTypeRedis,
componentName: "cache",
cacheProvisioner: nil, // nil interface
credStore: newMockCredentialStore(),
wantErr: true,
wantErrContains: "cache provisioner not configured",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
svc := &ComponentService{
dbProvisioner: tc.dbProvisioner,
cacheProvisioner: tc.cacheProvisioner,
credentialStore: tc.credStore,
}
ctx := context.Background()
component, err := svc.addInfraComponent(ctx, tc.projectID, tc.componentType, tc.componentName)
if tc.wantErr {
if err == nil {
t.Errorf("expected error containing %q, got nil", tc.wantErrContains)
return
}
if tc.wantErrContains != "" && !contains(err.Error(), tc.wantErrContains) {
t.Errorf("error %q does not contain %q", err.Error(), tc.wantErrContains)
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if component.Type != tc.componentType {
t.Errorf("component type = %v, want %v", component.Type, tc.componentType)
}
})
}
}
func TestIsInfraComponent(t *testing.T) {
tests := []struct {
componentType domain.ComponentType
want bool
}{
{domain.ComponentTypePostgres, true},
{domain.ComponentTypeRedis, true},
{domain.ComponentTypeService, false},
{domain.ComponentTypeWorker, false},
{domain.ComponentTypeAppAstro, false},
{domain.ComponentTypeAppReact, false},
{domain.ComponentTypeCLI, false},
}
for _, tc := range tests {
t.Run(string(tc.componentType), func(t *testing.T) {
got := tc.componentType.IsInfraComponent()
if got != tc.want {
t.Errorf("IsInfraComponent(%s) = %v, want %v", tc.componentType, got, tc.want)
}
})
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && len(substr) > 0 && findSubstring(s, substr)))
}
func findSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// Ensure mock implements interface
var _ port.DatabaseProvisioner = (*mockDatabaseProvisioner)(nil)
var _ port.CacheProvisioner = (*mockCacheProvisioner)(nil)
var _ port.CredentialStore = (*mockCredentialStore)(nil)
func TestToUpperSnake(t *testing.T) {
tests := []struct {
input string
want string
}{
{"auth-svc", "AUTH_SVC"},
{"chat-svc", "CHAT_SVC"},
{"user-service", "USER_SERVICE"},
{"my-auth-svc-v2", "MY_AUTH_SVC_V2"},
{"simple", "SIMPLE"},
{"already-UPPER", "ALREADY_UPPER"},
{"a-b-c-d", "A_B_C_D"},
{"", ""},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
got := toUpperSnake(tc.input)
if got != tc.want {
t.Errorf("toUpperSnake(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}
func TestBuildSiblingServiceURLs(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
tests := []struct {
name string
projectID string
currentComponent string
components []domain.Component
listErr error
want map[string]string
}{
{
name: "builds URLs for sibling services",
projectID: "testproject",
currentComponent: "chat-svc",
components: []domain.Component{
{Name: "auth-svc", Type: domain.ComponentTypeService, Port: 8001},
{Name: "chat-svc", Type: domain.ComponentTypeService, Port: 8002},
{Name: "user-svc", Type: domain.ComponentTypeService, Port: 8003},
},
want: map[string]string{
"AUTH_SVC_URL": "http://testproject-auth-svc:8001",
"USER_SVC_URL": "http://testproject-user-svc:8003",
},
},
{
name: "excludes current component",
projectID: "myapp",
currentComponent: "auth-svc",
components: []domain.Component{
{Name: "auth-svc", Type: domain.ComponentTypeService, Port: 8001},
},
want: map[string]string{},
},
{
name: "excludes workers",
projectID: "myapp",
currentComponent: "api",
components: []domain.Component{
{Name: "auth-svc", Type: domain.ComponentTypeService, Port: 8001},
{Name: "processor", Type: domain.ComponentTypeWorker, Port: 0},
},
want: map[string]string{
"AUTH_SVC_URL": "http://myapp-auth-svc:8001",
},
},
{
name: "excludes apps",
projectID: "myapp",
currentComponent: "api",
components: []domain.Component{
{Name: "auth-svc", Type: domain.ComponentTypeService, Port: 8001},
{Name: "web", Type: domain.ComponentTypeAppReact, Port: 3001},
{Name: "landing", Type: domain.ComponentTypeAppAstro, Port: 3002},
},
want: map[string]string{
"AUTH_SVC_URL": "http://myapp-auth-svc:8001",
},
},
{
name: "excludes CLI",
projectID: "myapp",
currentComponent: "api",
components: []domain.Component{
{Name: "auth-svc", Type: domain.ComponentTypeService, Port: 8001},
{Name: "tool", Type: domain.ComponentTypeCLI, Port: 0},
},
want: map[string]string{
"AUTH_SVC_URL": "http://myapp-auth-svc:8001",
},
},
{
name: "excludes infrastructure components",
projectID: "myapp",
currentComponent: "api",
components: []domain.Component{
{Name: "auth-svc", Type: domain.ComponentTypeService, Port: 8001},
{Name: "main-db", Type: domain.ComponentTypePostgres, Port: 26257},
{Name: "cache", Type: domain.ComponentTypeRedis, Port: 6379},
},
want: map[string]string{
"AUTH_SVC_URL": "http://myapp-auth-svc:8001",
},
},
{
name: "excludes services with zero port",
projectID: "myapp",
currentComponent: "api",
components: []domain.Component{
{Name: "auth-svc", Type: domain.ComponentTypeService, Port: 8001},
{Name: "broken-svc", Type: domain.ComponentTypeService, Port: 0},
},
want: map[string]string{
"AUTH_SVC_URL": "http://myapp-auth-svc:8001",
},
},
{
name: "handles empty component list",
projectID: "myapp",
currentComponent: "api",
components: []domain.Component{},
want: map[string]string{},
},
{
name: "returns nil on list error",
projectID: "myapp",
currentComponent: "api",
listErr: errors.New("database error"),
want: nil,
},
{
name: "handles hyphenated component names correctly",
projectID: "my-project",
currentComponent: "api",
components: []domain.Component{
{Name: "user-auth-service", Type: domain.ComponentTypeService, Port: 8001},
},
want: map[string]string{
"USER_AUTH_SERVICE_URL": "http://my-project-user-auth-service:8001",
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// We need to test the actual function, so we'll use a helper approach
// by directly calling the function logic with mock data
got := buildSiblingServiceURLsHelper(tc.components, tc.projectID, tc.currentComponent, tc.listErr, logger)
// Compare results
if tc.want == nil {
if got != nil {
t.Errorf("buildSiblingServiceURLs() = %v, want nil", got)
}
return
}
if len(got) != len(tc.want) {
t.Errorf("buildSiblingServiceURLs() returned %d URLs, want %d", len(got), len(tc.want))
t.Errorf("got: %v", got)
t.Errorf("want: %v", tc.want)
return
}
for key, wantURL := range tc.want {
if gotURL, ok := got[key]; !ok {
t.Errorf("missing key %q in result", key)
} else if gotURL != wantURL {
t.Errorf("URL for %q = %q, want %q", key, gotURL, wantURL)
}
}
// Verify no extra keys
for key := range got {
if _, ok := tc.want[key]; !ok {
t.Errorf("unexpected key %q in result", key)
}
}
})
}
}
// buildSiblingServiceURLsHelper extracts the core logic for testing without needing a full ComponentService.
// This mirrors the logic in ComponentService.buildSiblingServiceURLs.
func buildSiblingServiceURLsHelper(components []domain.Component, projectID, currentComponent string, listErr error, logger *slog.Logger) map[string]string {
if listErr != nil {
logger.Warn("failed to list components for sibling discovery",
"project", projectID,
"error", listErr,
)
return nil
}
urls := make(map[string]string)
for _, c := range components {
// Skip the current component, non-service types, and components without ports
if c.Name == currentComponent || c.Type != domain.ComponentTypeService || c.Port == 0 {
continue
}
// Build env var name: auth-svc -> AUTH_SVC_URL
envKey := toUpperSnake(c.Name) + "_URL"
// Build internal K8s service URL: http://projectid-componentname:port
serviceName := projectID + "-" + c.Name
urls[envKey] = "http://" + serviceName + ":" + itoa(c.Port)
}
return urls
}
// itoa converts int to string without importing strconv
func itoa(n int) string {
if n == 0 {
return "0"
}
var digits []byte
for n > 0 {
digits = append([]byte{byte('0' + n%10)}, digits...)
n /= 10
}
return string(digits)
}