rdev/internal/port/port_test.go
jordan 4f01015132
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: implement project access enforcement and management API
- Fix no-op RequireProjectAccess middleware to enforce project_ids
- Apply project access middleware to all project-scoped routes
- Filter GET /projects by allowed project IDs for restricted keys
- Add GET /me endpoint with key identity, scopes, and project access info
- Add PATCH /keys/{id} for partial key updates (name, scopes, project_ids, allowed_ips, expires_in)
- Add GET/POST/DELETE /projects/{id}/access for project-centric access management
- Auto-grant creating key access when using POST /project/create-and-build
- Accept grant_to_key_ids in create-and-build to grant multiple keys on project creation
- Move newProvisionerWithDeps test helper from production code to test file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 15:38:37 -07:00

401 lines
10 KiB
Go

package port_test
import (
"context"
"testing"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// =============================================================================
// Interface Compliance Tests
//
// These tests verify that mock implementations can satisfy the port interfaces.
// They serve as compile-time verification that interfaces are correctly defined
// and provide example implementations for testing.
// =============================================================================
// Compile-time interface compliance checks
var (
_ port.ProjectRepository = (*mockProjectRepository)(nil)
_ port.CommandExecutor = (*mockCommandExecutor)(nil)
_ port.APIKeyRepository = (*mockAPIKeyRepository)(nil)
_ port.StreamPublisher = (*mockStreamPublisher)(nil)
)
// =============================================================================
// Mock Implementations
// =============================================================================
type mockProjectRepository struct {
projects map[domain.ProjectID]*domain.Project
}
func newMockProjectRepository() *mockProjectRepository {
return &mockProjectRepository{
projects: make(map[domain.ProjectID]*domain.Project),
}
}
func (m *mockProjectRepository) List(ctx context.Context) ([]domain.Project, error) {
result := make([]domain.Project, 0, len(m.projects))
for _, p := range m.projects {
result = append(result, *p)
}
return result, nil
}
func (m *mockProjectRepository) Get(ctx context.Context, id domain.ProjectID) (*domain.Project, error) {
if p, ok := m.projects[id]; ok {
return p, nil
}
return nil, domain.ErrProjectNotFound
}
func (m *mockProjectRepository) Exists(ctx context.Context, id domain.ProjectID) (bool, error) {
_, ok := m.projects[id]
return ok, nil
}
func (m *mockProjectRepository) Register(ctx context.Context, project *domain.Project) error {
m.projects[project.ID] = project
return nil
}
func (m *mockProjectRepository) Unregister(ctx context.Context, id domain.ProjectID) error {
delete(m.projects, id)
return nil
}
func (m *mockProjectRepository) RefreshStatus(ctx context.Context) error {
return nil
}
type mockCommandExecutor struct {
executeFunc func(ctx context.Context, cmd *domain.Command, podName string, handler domain.OutputHandler) (*domain.CommandResult, error)
}
func (m *mockCommandExecutor) Execute(ctx context.Context, cmd *domain.Command, podName string, handler domain.OutputHandler) (*domain.CommandResult, error) {
if m.executeFunc != nil {
return m.executeFunc(ctx, cmd, podName, handler)
}
return &domain.CommandResult{
CommandID: cmd.ID,
ExitCode: 0,
DurationMs: 100,
}, nil
}
func (m *mockCommandExecutor) Cancel(ctx context.Context, cmdID domain.CommandID) error {
return nil
}
func (m *mockCommandExecutor) PodExists(ctx context.Context, podName string) (bool, error) {
return true, nil
}
func (m *mockCommandExecutor) CheckConnection(ctx context.Context) error {
return nil
}
type mockAPIKeyRepository struct {
keys map[domain.APIKeyID]*domain.APIKey
}
func newMockAPIKeyRepository() *mockAPIKeyRepository {
return &mockAPIKeyRepository{
keys: make(map[domain.APIKeyID]*domain.APIKey),
}
}
func (m *mockAPIKeyRepository) Create(ctx context.Context, key *domain.APIKey, keyHash string) error {
m.keys[key.ID] = key
return nil
}
func (m *mockAPIKeyRepository) GetByHash(ctx context.Context, keyHash string) (*domain.APIKey, error) {
// In a real implementation, this would look up by hash
return nil, domain.ErrKeyNotFound
}
func (m *mockAPIKeyRepository) Get(ctx context.Context, id domain.APIKeyID) (*domain.APIKey, error) {
if k, ok := m.keys[id]; ok {
return k, nil
}
return nil, domain.ErrKeyNotFound
}
func (m *mockAPIKeyRepository) List(ctx context.Context) ([]*domain.APIKey, error) {
result := make([]*domain.APIKey, 0, len(m.keys))
for _, k := range m.keys {
result = append(result, k)
}
return result, nil
}
func (m *mockAPIKeyRepository) Revoke(ctx context.Context, id domain.APIKeyID) error {
if _, ok := m.keys[id]; !ok {
return domain.ErrKeyNotFound
}
return nil
}
func (m *mockAPIKeyRepository) UpdateLastUsed(ctx context.Context, id domain.APIKeyID) error {
if _, ok := m.keys[id]; !ok {
return domain.ErrKeyNotFound
}
return nil
}
func (m *mockAPIKeyRepository) Update(ctx context.Context, id domain.APIKeyID, update port.APIKeyUpdate) error {
if _, ok := m.keys[id]; !ok {
return domain.ErrKeyNotFound
}
return nil
}
func (m *mockAPIKeyRepository) ListByProjectID(ctx context.Context, projectID domain.ProjectID) ([]*domain.APIKey, error) {
var result []*domain.APIKey
for _, k := range m.keys {
for _, pid := range k.ProjectIDs {
if pid == projectID {
result = append(result, k)
break
}
}
}
return result, nil
}
type mockStreamPublisher struct {
subscribers map[string][]chan port.StreamEvent
}
func newMockStreamPublisher() *mockStreamPublisher {
return &mockStreamPublisher{
subscribers: make(map[string][]chan port.StreamEvent),
}
}
func (m *mockStreamPublisher) Subscribe(streamID string) (<-chan port.StreamEvent, func()) {
ch := make(chan port.StreamEvent, 10)
m.subscribers[streamID] = append(m.subscribers[streamID], ch)
cleanup := func() {
close(ch)
}
return ch, cleanup
}
func (m *mockStreamPublisher) SubscribeFromID(streamID string, lastEventID string) (<-chan port.StreamEvent, func()) {
// Simplified: just subscribe without replay
return m.Subscribe(streamID)
}
func (m *mockStreamPublisher) Publish(streamID string, event port.StreamEvent) string {
for _, ch := range m.subscribers[streamID] {
select {
case ch <- event:
default:
// Channel full, skip
}
}
return event.ID
}
func (m *mockStreamPublisher) Close(streamID string) {
for _, ch := range m.subscribers[streamID] {
close(ch)
}
delete(m.subscribers, streamID)
}
// =============================================================================
// Mock Usage Tests
// =============================================================================
func TestMockProjectRepository_BasicOperations(t *testing.T) {
repo := newMockProjectRepository()
ctx := context.Background()
// Register a project
project := &domain.Project{
ID: "test-proj",
Name: "Test Project",
Status: domain.ProjectStatusRunning,
}
if err := repo.Register(ctx, project); err != nil {
t.Fatalf("Register failed: %v", err)
}
// Verify it exists
exists, err := repo.Exists(ctx, "test-proj")
if err != nil {
t.Fatalf("Exists failed: %v", err)
}
if !exists {
t.Error("project should exist after registration")
}
// Get the project
got, err := repo.Get(ctx, "test-proj")
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if got.Name != "Test Project" {
t.Errorf("Got name %q, want %q", got.Name, "Test Project")
}
// List projects
list, err := repo.List(ctx)
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(list) != 1 {
t.Errorf("List returned %d projects, want 1", len(list))
}
// Unregister
if err := repo.Unregister(ctx, "test-proj"); err != nil {
t.Fatalf("Unregister failed: %v", err)
}
// Verify not found
_, err = repo.Get(ctx, "test-proj")
if err != domain.ErrProjectNotFound {
t.Errorf("Get after unregister: got error %v, want %v", err, domain.ErrProjectNotFound)
}
}
func TestMockCommandExecutor_Execute(t *testing.T) {
executor := &mockCommandExecutor{}
ctx := context.Background()
cmd := &domain.Command{
ID: "cmd-1",
ProjectID: "proj-1",
Type: domain.CommandTypeShell,
Args: []string{"echo", "hello"},
}
var outputLines []domain.OutputLine
handler := func(line domain.OutputLine) {
outputLines = append(outputLines, line)
}
result, err := executor.Execute(ctx, cmd, "test-pod", handler)
if err != nil {
t.Fatalf("Execute failed: %v", err)
}
if result.CommandID != "cmd-1" {
t.Errorf("CommandID = %q, want %q", result.CommandID, "cmd-1")
}
if result.ExitCode != 0 {
t.Errorf("ExitCode = %d, want 0", result.ExitCode)
}
}
func TestMockAPIKeyRepository_CRUD(t *testing.T) {
repo := newMockAPIKeyRepository()
ctx := context.Background()
key := &domain.APIKey{
ID: "key-1",
Name: "Test Key",
Scopes: []domain.Scope{domain.ScopeProjectsRead},
}
// Create
if err := repo.Create(ctx, key, "hash123"); err != nil {
t.Fatalf("Create failed: %v", err)
}
// Get
got, err := repo.Get(ctx, "key-1")
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if got.Name != "Test Key" {
t.Errorf("Name = %q, want %q", got.Name, "Test Key")
}
// List
list, err := repo.List(ctx)
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(list) != 1 {
t.Errorf("List returned %d keys, want 1", len(list))
}
// Revoke
if err := repo.Revoke(ctx, "key-1"); err != nil {
t.Fatalf("Revoke failed: %v", err)
}
// UpdateLastUsed
if err := repo.UpdateLastUsed(ctx, "key-1"); err != nil {
t.Fatalf("UpdateLastUsed failed: %v", err)
}
}
func TestMockStreamPublisher_PubSub(t *testing.T) {
pub := newMockStreamPublisher()
// Subscribe
ch, cleanup := pub.Subscribe("stream-1")
defer cleanup()
// Publish
event := port.StreamEvent{
ID: "evt-1",
Type: "output",
Data: map[string]any{"line": "hello"},
}
eventID := pub.Publish("stream-1", event)
if eventID != "evt-1" {
t.Errorf("Publish returned ID %q, want %q", eventID, "evt-1")
}
// Receive
select {
case received := <-ch:
if received.ID != "evt-1" {
t.Errorf("Received event ID %q, want %q", received.ID, "evt-1")
}
if received.Data["line"] != "hello" {
t.Errorf("Received data = %v, want line=hello", received.Data)
}
default:
t.Error("expected to receive event")
}
}
// =============================================================================
// StreamEvent Tests
// =============================================================================
func TestStreamEvent_CanBeInstantiated(t *testing.T) {
event := port.StreamEvent{
ID: "event-123",
Type: "command_output",
Data: map[string]any{
"stream": "stdout",
"line": "test output",
},
}
if event.ID != "event-123" {
t.Errorf("StreamEvent.ID = %q, want %q", event.ID, "event-123")
}
if event.Type != "command_output" {
t.Errorf("StreamEvent.Type = %q, want %q", event.Type, "command_output")
}
if event.Data["stream"] != "stdout" {
t.Errorf("StreamEvent.Data[stream] = %v, want stdout", event.Data["stream"])
}
}