rdev/internal/handlers/infrastructure_mocks_test.go
jordan 17240f4efd fix(rc-5): add Redis ACL persistence + cache reprovision endpoint
## Changes

### port.Deployer interface
- Add PatchProjectSecrets(ctx, projectName, patch) to merge key-value pairs
  into all K8s secrets labeled project={projectName}
- Add RestartAll(ctx, projectName) to trigger rolling restart of all deployments
  for a project, picking up fresh secrets without waiting for CI

### deployer adapter
- Implement PatchProjectSecrets: lists secrets by label, merges patch into Data,
  writes each secret back
- Implement RestartAll: lists deployments by label, sets restartedAt annotation

### domain/credential.go
- Add CredentialCategoryCache = "cache" constant
- Use constant in component_infra.go (was raw string "cache")

### handlers/cache.go (new)
- POST /projects/{projectID}/cache/reprovision
- Calls CreateProjectCache (which handles delete+recreate with new password)
- Updates credential store (REDIS_URL, REDIS_URL_STAGING, REDIS_PREFIX)
- Patches all K8s secrets for the project immediately
- Triggers RestartAll so pods pick up new credentials without waiting for deploy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 20:22:31 -07:00

446 lines
11 KiB
Go

package handlers
import (
"context"
"fmt"
"time"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// mockGitRepository implements port.GitRepository for testing.
type mockGitRepository struct {
repos map[string]*domain.Repo
err error
}
func newMockGitRepository() *mockGitRepository {
return &mockGitRepository{repos: make(map[string]*domain.Repo)}
}
func (m *mockGitRepository) CreateRepo(_ context.Context, name, description string, private bool) (*domain.Repo, error) {
if m.err != nil {
return nil, m.err
}
repo := &domain.Repo{
ID: 1,
Owner: "threesix",
Name: name,
FullName: "threesix/" + name,
Description: description,
Private: private,
CloneSSH: fmt.Sprintf("git@git.threesix.ai:threesix/%s.git", name),
CloneHTTP: fmt.Sprintf("https://git.threesix.ai/threesix/%s.git", name),
HTMLURL: fmt.Sprintf("https://git.threesix.ai/threesix/%s", name),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
m.repos[name] = repo
return repo, nil
}
func (m *mockGitRepository) DeleteRepo(_ context.Context, _, name string) error {
if m.err != nil {
return m.err
}
delete(m.repos, name)
return nil
}
func (m *mockGitRepository) ListRepos(_ context.Context, _ string) ([]*domain.Repo, error) {
if m.err != nil {
return nil, m.err
}
var repos []*domain.Repo
for _, r := range m.repos {
repos = append(repos, r)
}
return repos, nil
}
func (m *mockGitRepository) GetRepo(_ context.Context, _, name string) (*domain.Repo, error) {
if m.err != nil {
return nil, m.err
}
r, ok := m.repos[name]
if !ok {
return nil, fmt.Errorf("repo not found: %s", name)
}
return r, nil
}
func (m *mockGitRepository) AddCollaborator(context.Context, string, string, string, string) error {
return m.err
}
func (m *mockGitRepository) RemoveCollaborator(context.Context, string, string, string) error {
return m.err
}
func (m *mockGitRepository) AddDeployKey(context.Context, string, string, string, string, bool) (*domain.DeployKey, error) {
return nil, m.err
}
func (m *mockGitRepository) DeleteDeployKey(context.Context, string, string, int64) error {
return m.err
}
func (m *mockGitRepository) CreateWebhook(context.Context, string, string, string, string, []string) (*domain.RepoWebhook, error) {
return nil, m.err
}
func (m *mockGitRepository) DeleteWebhook(context.Context, string, string, int64) error {
return m.err
}
func (m *mockGitRepository) ListBranches(_ context.Context, _, _ string) ([]*domain.GitBranch, error) {
if m.err != nil {
return nil, m.err
}
return []*domain.GitBranch{
{Name: "main", CommitSHA: "abc123", Protected: true},
{Name: "develop", CommitSHA: "def456", Protected: false},
}, nil
}
func (m *mockGitRepository) CreateBranch(_ context.Context, _, _, branchName, _ string) (*domain.GitBranch, error) {
if m.err != nil {
return nil, m.err
}
return &domain.GitBranch{
Name: branchName,
CommitSHA: "newcommit123",
Protected: false,
}, nil
}
func (m *mockGitRepository) CreateAccessToken(_ context.Context, name string, _ []string, _ *time.Time) (*domain.GitAccessToken, error) {
if m.err != nil {
return nil, m.err
}
return &domain.GitAccessToken{
ID: 12345,
Name: name,
Token: "test-token-12345",
Scopes: []string{"write:repository"},
}, nil
}
func (m *mockGitRepository) DeleteAccessToken(_ context.Context, _ int64) error {
return m.err
}
// mockDNSProvider implements port.DNSProvider for testing.
type mockDNSProvider struct {
records map[string]*domain.DNSRecord
err error
}
func newMockDNSProvider() *mockDNSProvider {
return &mockDNSProvider{records: make(map[string]*domain.DNSRecord)}
}
func (m *mockDNSProvider) CreateRecord(_ context.Context, record domain.DNSRecord) (*domain.DNSRecord, error) {
if m.err != nil {
return nil, m.err
}
record.ID = "rec-" + record.Name
m.records[record.Name] = &record
return &record, nil
}
func (m *mockDNSProvider) UpdateRecord(_ context.Context, recordID string, record domain.DNSRecord) (*domain.DNSRecord, error) {
if m.err != nil {
return nil, m.err
}
record.ID = recordID
m.records[recordID] = &record
return &record, nil
}
func (m *mockDNSProvider) DeleteRecord(_ context.Context, recordID string) error {
if m.err != nil {
return m.err
}
delete(m.records, recordID)
return nil
}
func (m *mockDNSProvider) DeleteRecordByName(_ context.Context, _, name string) error {
if m.err != nil {
return m.err
}
delete(m.records, name)
return nil
}
func (m *mockDNSProvider) GetRecord(_ context.Context, recordID string) (*domain.DNSRecord, error) {
if m.err != nil {
return nil, m.err
}
r, ok := m.records[recordID]
if !ok {
return nil, fmt.Errorf("record not found")
}
return r, nil
}
func (m *mockDNSProvider) ListRecords(_ context.Context, recordType string) ([]*domain.DNSRecord, error) {
if m.err != nil {
return nil, m.err
}
var result []*domain.DNSRecord
for _, r := range m.records {
if recordType == "" || r.Type == recordType {
result = append(result, r)
}
}
return result, nil
}
func (m *mockDNSProvider) FindRecord(_ context.Context, _, name string) (*domain.DNSRecord, error) {
if m.err != nil {
return nil, m.err
}
r, ok := m.records[name]
if !ok {
return nil, nil
}
return r, nil
}
func (m *mockDNSProvider) UpsertRecord(ctx context.Context, record domain.DNSRecord) (*domain.DNSRecord, error) {
if m.err != nil {
return nil, m.err
}
// Check if record exists, then update or create
existing, _ := m.FindRecord(ctx, record.Type, record.Name)
if existing != nil {
return m.UpdateRecord(ctx, existing.ID, record)
}
return m.CreateRecord(ctx, record)
}
// mockDeployer implements port.Deployer for testing.
type mockDeployer struct {
deployments map[string]*domain.DeployStatus
logs string
err error
}
func newMockDeployer() *mockDeployer {
return &mockDeployer{deployments: make(map[string]*domain.DeployStatus)}
}
func (m *mockDeployer) Deploy(_ context.Context, spec domain.DeploySpec) error {
if m.err != nil {
return m.err
}
m.deployments[spec.ProjectName] = &domain.DeployStatus{
ProjectName: spec.ProjectName,
Image: spec.Image,
Replicas: spec.Replicas,
ReadyReplicas: 0,
URL: "https://" + spec.Domain,
Status: domain.DeploymentStatusPending,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
return nil
}
func (m *mockDeployer) Undeploy(_ context.Context, projectName string) error {
if m.err != nil {
return m.err
}
delete(m.deployments, projectName)
return nil
}
func (m *mockDeployer) UndeployAll(_ context.Context, projectName string) error {
if m.err != nil {
return m.err
}
delete(m.deployments, projectName)
return nil
}
func (m *mockDeployer) GetStatus(_ context.Context, projectName string) (*domain.DeployStatus, error) {
if m.err != nil {
return nil, m.err
}
s, ok := m.deployments[projectName]
if !ok {
return nil, nil
}
return s, nil
}
func (m *mockDeployer) Restart(_ context.Context, _ string) error {
return m.err
}
func (m *mockDeployer) Scale(_ context.Context, projectName string, replicas int) error {
if m.err != nil {
return m.err
}
if s, ok := m.deployments[projectName]; ok {
s.Replicas = replicas
}
return nil
}
func (m *mockDeployer) GetLogs(_ context.Context, _ string, _ int) (string, error) {
if m.err != nil {
return "", m.err
}
return m.logs, nil
}
func (m *mockDeployer) AddIngressHost(_ context.Context, _, _ string) error {
return m.err
}
func (m *mockDeployer) RemoveIngressHost(_ context.Context, _, _ string) error {
return m.err
}
func (m *mockDeployer) UndeployComponent(_ context.Context, _, _ string) error {
return m.err
}
func (m *mockDeployer) GetComponentStatus(_ context.Context, _, _ string) (*domain.DeployStatus, error) {
if m.err != nil {
return nil, m.err
}
return nil, nil
}
func (m *mockDeployer) ListComponentStatuses(_ context.Context, _ string) (*domain.ProjectDeployStatus, error) {
if m.err != nil {
return nil, m.err
}
return &domain.ProjectDeployStatus{}, nil
}
func (m *mockDeployer) RestartComponent(_ context.Context, _, _ string) error {
return m.err
}
func (m *mockDeployer) ScaleComponent(_ context.Context, _, _ string, _ int) error {
return m.err
}
func (m *mockDeployer) GetComponentLogs(_ context.Context, _, _ string, _ int) (string, error) {
if m.err != nil {
return "", m.err
}
return m.logs, nil
}
func (m *mockDeployer) AddIngressPath(_ context.Context, _, _, _, _ string, _ int) error {
return m.err
}
func (m *mockDeployer) RemoveIngressPath(_ context.Context, _, _, _ string) error {
return m.err
}
func (m *mockDeployer) PatchProjectSecrets(_ context.Context, _ string, _ map[string]string) error {
return m.err
}
func (m *mockDeployer) RestartAll(_ context.Context, _ string) error {
return m.err
}
// mockPreviewManager implements port.PreviewManager for testing.
type mockPreviewManager struct {
previews map[string]bool
err error
}
func newMockPreviewManager() *mockPreviewManager {
return &mockPreviewManager{previews: make(map[string]bool)}
}
func (m *mockPreviewManager) CreatePreview(_ context.Context, opts port.PreviewOptions) error {
if m.err != nil {
return m.err
}
m.previews[opts.SessionID] = true
return nil
}
func (m *mockPreviewManager) DeletePreview(_ context.Context, sessionID string) error {
if m.err != nil {
return m.err
}
delete(m.previews, sessionID)
return nil
}
// mockExecutor implements port.CommandExecutor for testing.
type mockExecutor struct {
result *domain.CommandResult
err error
}
func newMockExecutor() *mockExecutor {
return &mockExecutor{
result: &domain.CommandResult{ExitCode: 0, DurationMs: 100},
}
}
func (m *mockExecutor) Execute(_ context.Context, _ *domain.Command, _ string, handler domain.OutputHandler) (*domain.CommandResult, error) {
if m.err != nil {
return nil, m.err
}
if handler != nil {
handler(domain.OutputLine{Stream: "stdout", Line: "mock output", Timestamp: time.Now()})
}
return m.result, nil
}
func (m *mockExecutor) Cancel(_ context.Context, _ domain.CommandID) error {
return nil
}
func (m *mockExecutor) PodExists(_ context.Context, _ string) (bool, error) {
return true, nil
}
func (m *mockExecutor) CheckConnection(_ context.Context) error {
return nil
}
// mockStreamPublisher implements port.StreamPublisher for testing.
type mockStreamPublisher struct {
events map[string][]port.StreamEvent
seq int64
}
func newMockStreamPublisher() *mockStreamPublisher {
return &mockStreamPublisher{events: make(map[string][]port.StreamEvent)}
}
func (m *mockStreamPublisher) Subscribe(streamID string) (<-chan port.StreamEvent, func()) {
ch := make(chan port.StreamEvent, 100)
return ch, func() { close(ch) }
}
func (m *mockStreamPublisher) SubscribeFromID(streamID string, _ string) (<-chan port.StreamEvent, func()) {
return m.Subscribe(streamID)
}
func (m *mockStreamPublisher) Publish(streamID string, event port.StreamEvent) string {
m.seq++
event.ID = fmt.Sprintf("%s:%d", streamID, m.seq)
m.events[streamID] = append(m.events[streamID], event)
return event.ID
}
func (m *mockStreamPublisher) Close(streamID string) {
delete(m.events, streamID)
}
// Verify mock interfaces at compile time.
var _ port.CommandExecutor = (*mockExecutor)(nil)
var _ port.StreamPublisher = (*mockStreamPublisher)(nil)