All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 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>
401 lines
10 KiB
Go
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"])
|
|
}
|
|
}
|