Ctrl K
ComunidadGuías…

internal/mcptools/write_tools_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package mcptools

import (
	"context"
	"errors"
	"strings"
	"testing"
	"time"

	"xprem/internal/repository"
	"xprem/internal/services"
	"xprem/internal/types"

	mcpprot "github.com/modelcontextprotocol/go-sdk/mcp"
)

type fakeWriteServices struct {
	createdBranches  []string
	deletedBranches  []string
	createdChannels  []string
	deletedChannels  []string
	rollbacks        []string
	republishedIDs   []string
	republishedGroup string
	deleteBranchErr  error
	rollbackErr      error
}

func (f *fakeWriteServices) CreateBranch(_ context.Context, _ string, branchName string) (int64, error) {
	f.createdBranches = append(f.createdBranches, branchName)
	return 42, nil
}

func (f *fakeWriteServices) DeleteBranch(_ context.Context, branchName string, _ string) error {
	if f.deleteBranchErr != nil {
		return f.deleteBranchErr
	}
	f.deletedBranches = append(f.deletedBranches, branchName)
	return nil
}

func (f *fakeWriteServices) CreateChannel(_ context.Context, _ string, _ *string, channelName string) (int64, error) {
	f.createdChannels = append(f.createdChannels, channelName)
	return 7, nil
}

func (f *fakeWriteServices) DeleteChannel(_ context.Context, channelName string, _ string) error {
	f.deletedChannels = append(f.deletedChannels, channelName)
	return nil
}

func (f *fakeWriteServices) CreateRollback(_ context.Context, _ string, platform types.Platform, _, runtimeVersion, branchName, _ string) (*types.Update, error) {
	if f.rollbackErr != nil {
		return nil, f.rollbackErr
	}
	f.rollbacks = append(f.rollbacks, string(platform))
	return &types.Update{Branch: branchName, RuntimeVersion: runtimeVersion, UpdateId: "new-" + string(platform)}, nil
}

func (f *fakeWriteServices) RepublishUpdateByID(_ context.Context, _, branchName, runtimeVersion, updateId string) (*types.Update, error) {
	f.republishedIDs = append(f.republishedIDs, updateId)
	return &types.Update{Branch: branchName, RuntimeVersion: runtimeVersion, UpdateId: "republished"}, nil
}

func (f *fakeWriteServices) RepublishPublishGroup(_ context.Context, _, _, _, publishGroup string) (*services.GroupOperationResult, error) {
	f.republishedGroup = publishGroup
	return &services.GroupOperationResult{PublishGroup: "new-group", Updates: []types.Update{{UpdateId: "a"}, {UpdateId: "b"}}}, nil
}

// writeDeps allows every authorization by default; each test tightens what it
// needs to prove.
func writeDeps() (Deps, *fakeWriteServices) {
	deps := readDeps()
	fake := &fakeWriteServices{}
	deps.BranchWriter = fake
	deps.ChannelWriter = fake
	deps.Deployments = fake
	deps.Authorize = func(_ context.Context, _ *services.DashboardPrincipal, _ string, _ Access) error { return nil }
	return deps, fake
}

var writePrincipal = &services.DashboardPrincipal{UserId: "user-1"}

// Every write tool must go through Deps.Authorize with the permission its
// route twin declares, and must refuse when it denies.
func TestWriteToolsAuthorize(t *testing.T) {
	ctx := context.Background()
	req := callToolRequestFor(writePrincipal)

	calls := map[string]struct {
		perm     string
		callWith func(deps Deps, req *mcpprot.CallToolRequest) error
	}{
		"create_branch": {"branch:create", func(deps Deps, req *mcpprot.CallToolRequest) error {
			_, _, err := createBranchHandler(deps)(ctx, req, CreateBranchInput{AppId: "app-1", Branch: "new"})
			return err
		}},
		"delete_branch": {"branch:delete", func(deps Deps, req *mcpprot.CallToolRequest) error {
			_, _, err := deleteBranchHandler(deps)(ctx, req, DeleteBranchInput{AppId: "app-1", Branch: "main"})
			return err
		}},
		"create_channel": {"channel:create", func(deps Deps, req *mcpprot.CallToolRequest) error {
			_, _, err := createChannelHandler(deps)(ctx, req, CreateChannelInput{AppId: "app-1", Channel: "prod"})
			return err
		}},
		"delete_channel": {"channel:delete", func(deps Deps, req *mcpprot.CallToolRequest) error {
			_, _, err := deleteChannelHandler(deps)(ctx, req, DeleteChannelInput{AppId: "app-1", Channel: "prod"})
			return err
		}},
		"rollback_branch": {"update:publish", func(deps Deps, req *mcpprot.CallToolRequest) error {
			_, _, err := rollbackHandler(deps)(ctx, req, RollbackInput{AppId: "app-1", Branch: "main", RuntimeVersion: "1.0.0", Message: "bad update"})
			return err
		}},
		"republish_update": {"update:publish", func(deps Deps, req *mcpprot.CallToolRequest) error {
			_, _, err := republishHandler(deps)(ctx, req, RepublishInput{AppId: "app-1", Branch: "main", RuntimeVersion: "1.0.0", UpdateId: "12"})
			return err
		}},
	}
	for name, tc := range calls {
		t.Run(name, func(t *testing.T) {
			deps, _ := writeDeps()
			var seen Access
			deps.Authorize = func(_ context.Context, _ *services.DashboardPrincipal, appID string, access Access) error {
				seen = access
				if appID != "app-1" {
					return errors.New("wrong app")
				}
				return nil
			}
			if err := tc.callWith(deps, req); err != nil {
				t.Fatalf("allowed call must succeed, got %v", err)
			}
			if seen.Perm != tc.perm || seen.Fallback != FallbackAdminOnly {
				t.Errorf("expected %s/admin-only, got %+v", tc.perm, seen)
			}

			denied, _ := writeDeps()
			denied.Authorize = func(_ context.Context, _ *services.DashboardPrincipal, _ string, _ Access) error {
				return errors.New("permission denied")
			}
			if err := tc.callWith(denied, req); err == nil {
				t.Fatal("a denied authorization must refuse the write")
			}

			// No principal on the session: refused before authorization is
			// ever consulted.
			anonymous, _ := writeDeps()
			authorizeCalled := false
			anonymous.Authorize = func(_ context.Context, _ *services.DashboardPrincipal, _ string, _ Access) error {
				authorizeCalled = true
				return nil
			}
			anonymousReq := callToolRequestFor(nil)
			if err := tc.callWith(anonymous, anonymousReq); err == nil {
				t.Fatal("a session without a principal must be refused")
			}
			if authorizeCalled {
				t.Error("authorize must not be consulted without a principal")
			}
		})
	}
}

// The services resolve their audit actor from the context, so every write
// tool must hand them a context carrying the principal; otherwise the audit
// log records the mutation with an empty actor.
func TestWriteToolsPassPrincipalForAudit(t *testing.T) {
	ctx := context.Background()
	req := callToolRequestFor(writePrincipal)

	assertActor := func(t *testing.T, serviceCtx context.Context) {
		t.Helper()
		principal := services.PrincipalFromContext(serviceCtx)
		if principal == nil || principal.UserId != "user-1" {
			t.Fatalf("the service context must name the actor, got %+v", principal)
		}
	}

	t.Run("delete_branch", func(t *testing.T) {
		deps, _ := writeDeps()
		var seen context.Context
		deps.BranchWriter = contextCapturingWriter{onDeleteBranch: func(c context.Context) { seen = c }}
		if _, _, err := deleteBranchHandler(deps)(ctx, req, DeleteBranchInput{AppId: "app-1", Branch: "main"}); err != nil {
			t.Fatal(err)
		}
		assertActor(t, seen)
	})

	t.Run("rollback_branch", func(t *testing.T) {
		deps, _ := writeDeps()
		var seen context.Context
		deps.Deployments = contextCapturingWriter{onRollback: func(c context.Context) { seen = c }}
		if _, _, err := rollbackHandler(deps)(ctx, req, RollbackInput{
			AppId: "app-1", Branch: "main", RuntimeVersion: "1.0.0", Message: "bad update", Platform: "ios",
		}); err != nil {
			t.Fatal(err)
		}
		assertActor(t, seen)
	})

	t.Run("get_certificate", func(t *testing.T) {
		deps, _ := writeDeps()
		var seen context.Context
		deps.Certificates = contextCapturingWriter{onCertificate: func(c context.Context) { seen = c }}
		if _, _, err := getCertificateHandler(deps)(ctx, req, GetCertificateInput{AppId: "app-1"}); err != nil {
			t.Fatal(err)
		}
		assertActor(t, seen)
	})
}

// contextCapturingWriter records the context each service call receives.
type contextCapturingWriter struct {
	onDeleteBranch func(context.Context)
	onRollback     func(context.Context)
	onCertificate  func(context.Context)
}

func (c contextCapturingWriter) CreateBranch(ctx context.Context, _ string, _ string) (int64, error) {
	return 1, nil
}

func (c contextCapturingWriter) DeleteBranch(ctx context.Context, _ string, _ string) error {
	c.onDeleteBranch(ctx)
	return nil
}

func (c contextCapturingWriter) CreateRollback(ctx context.Context, _ string, _ types.Platform, _, _, _, _ string) (*types.Update, error) {
	c.onRollback(ctx)
	return &types.Update{}, nil
}

func (c contextCapturingWriter) RepublishUpdateByID(_ context.Context, _, _, _, _ string) (*types.Update, error) {
	return &types.Update{}, nil
}

func (c contextCapturingWriter) RepublishPublishGroup(_ context.Context, _, _, _, _ string) (*services.GroupOperationResult, error) {
	return &services.GroupOperationResult{}, nil
}

func (c contextCapturingWriter) RetrieveAppCertificate(ctx context.Context, _ string) (string, error) {
	c.onCertificate(ctx)
	return "-----BEGIN CERTIFICATE-----", nil
}

func TestDeleteBranchSurfacesProtection(t *testing.T) {
	deps, fake := writeDeps()
	fake.deleteBranchErr = &repository.ErrBranchProtected{BranchName: "main"}

	_, _, err := deleteBranchHandler(deps)(context.Background(), callToolRequestFor(writePrincipal), DeleteBranchInput{AppId: "app-1", Branch: "main"})
	if err == nil || !strings.Contains(err.Error(), "protected") {
		t.Fatalf("the protection refusal must reach the caller verbatim, got %v", err)
	}
}

func TestDeleteBranchMasksUnexpectedErrors(t *testing.T) {
	deps, fake := writeDeps()
	fake.deleteBranchErr = errors.New("connection refused: 10.0.0.5:5432")

	_, _, err := deleteBranchHandler(deps)(context.Background(), callToolRequestFor(writePrincipal), DeleteBranchInput{AppId: "app-1", Branch: "main"})
	if err == nil || strings.Contains(err.Error(), "10.0.0.5") {
		t.Fatalf("infrastructure details must not leak, got %v", err)
	}
}

func TestRollbackBothPlatforms(t *testing.T) {
	deps, fake := writeDeps()

	_, output, err := rollbackHandler(deps)(context.Background(), callToolRequestFor(writePrincipal), RollbackInput{
		AppId: "app-1", Branch: "main", RuntimeVersion: "1.0.0", Message: "bad update",
	})
	if err != nil {
		t.Fatal(err)
	}
	if len(fake.rollbacks) != 2 || len(output.Updates) != 2 {
		t.Fatalf("expected both platforms rolled back, got %v", fake.rollbacks)
	}

	// A rollback needs a reason, and a bogus platform is refused.
	if _, _, err := rollbackHandler(deps)(context.Background(), callToolRequestFor(writePrincipal), RollbackInput{
		AppId: "app-1", Branch: "main", RuntimeVersion: "1.0.0",
	}); err == nil || !strings.Contains(err.Error(), "message is required") {
		t.Fatalf("expected the message requirement, got %v", err)
	}
	if _, _, err := rollbackHandler(deps)(context.Background(), callToolRequestFor(writePrincipal), RollbackInput{
		AppId: "app-1", Branch: "main", RuntimeVersion: "1.0.0", Message: "x", Platform: "windows",
	}); err == nil {
		t.Fatal("an unknown platform must be refused")
	}
}

// An unknown branch or runtime version must be named, not turned into an
// opaque internal error by the not-null constraint on the insert.
func TestPublishToolsRefuseUnknownTarget(t *testing.T) {
	deps, fake := writeDeps()
	ctx := context.Background()
	req := callToolRequestFor(writePrincipal)

	_, _, err := rollbackHandler(deps)(ctx, req, RollbackInput{
		AppId: "app-1", Branch: "branch-id-1", RuntimeVersion: "1.0.0", Message: "oops",
	})
	if err == nil || !strings.Contains(err.Error(), "no branch named") {
		t.Fatalf("a branch id passed as a name must be named as unknown, got %v", err)
	}
	if len(fake.rollbacks) != 0 {
		t.Fatal("nothing must reach the service on an unknown target")
	}

	_, _, err = rollbackHandler(deps)(ctx, req, RollbackInput{
		AppId: "app-1", Branch: "main", RuntimeVersion: "9.9.9", Message: "oops",
	})
	if err == nil || !strings.Contains(err.Error(), "no runtime version") {
		t.Fatalf("an unknown runtime version must be named, got %v", err)
	}

	_, _, err = republishHandler(deps)(ctx, req, RepublishInput{
		AppId: "app-1", Branch: "main", RuntimeVersion: "9.9.9", UpdateId: "12",
	})
	if err == nil || !strings.Contains(err.Error(), "no runtime version") {
		t.Fatalf("republish must check its target too, got %v", err)
	}
}

// A partial rollback must name what was rolled back and what to retry, and
// must not leak the underlying failure: the SDK drops the structured output of
// a failing call, so the message is all the caller gets.
func TestRollbackPartialFailureMessage(t *testing.T) {
	deps, _ := writeDeps()
	calls := 0
	deps.Deployments = failAfterFirstPlatform{onCall: func() int { calls++; return calls }}

	_, _, err := rollbackHandler(deps)(context.Background(), callToolRequestFor(writePrincipal), RollbackInput{
		AppId: "app-1", Branch: "main", RuntimeVersion: "1.0.0", Message: "bad update",
	})
	if err == nil {
		t.Fatal("expected the partial failure to be reported")
	}
	message := err.Error()
	for _, want := range []string{"rolled back ios", "android failed", "platform=android"} {
		if !strings.Contains(message, want) {
			t.Errorf("message must contain %q, got %q", want, message)
		}
	}
	if strings.Contains(message, "10.0.0.5") {
		t.Errorf("the underlying error must not leak, got %q", message)
	}
}

// failAfterFirstPlatform rolls back the first platform and fails the second.
type failAfterFirstPlatform struct {
	onCall func() int
}

func (f failAfterFirstPlatform) CreateBranch(context.Context, string, string) (int64, error) {
	return 0, nil
}
func (f failAfterFirstPlatform) DeleteBranch(context.Context, string, string) error { return nil }
func (f failAfterFirstPlatform) CreateRollback(_ context.Context, _ string, platform types.Platform, _, runtimeVersion, branchName, _ string) (*types.Update, error) {
	if f.onCall() > 1 {
		return nil, errors.New("failed to insert rollback update into database: dial tcp 10.0.0.5:5432: connect: connection refused")
	}
	return &types.Update{Branch: branchName, RuntimeVersion: runtimeVersion, UpdateId: "1"}, nil
}
func (f failAfterFirstPlatform) RepublishUpdateByID(context.Context, string, string, string, string) (*types.Update, error) {
	return &types.Update{}, nil
}
func (f failAfterFirstPlatform) RepublishPublishGroup(context.Context, string, string, string, string) (*services.GroupOperationResult, error) {
	return &services.GroupOperationResult{}, nil
}

// The publish tools validate their inputs like their route twins, so a mistake
// reads as a mistake instead of an infrastructure retry.
func TestPublishToolsValidateInputs(t *testing.T) {
	deps, _ := writeDeps()
	ctx := context.Background()
	req := callToolRequestFor(writePrincipal)

	// A UUID in updateId (get_updates also returns updateUUID) is named.
	_, _, err := republishHandler(deps)(ctx, req, RepublishInput{
		AppId: "app-1", Branch: "main", RuntimeVersion: "1.0.0",
		UpdateId: "11111111-1111-1111-1111-111111111111",
	})
	if err == nil || !strings.Contains(err.Error(), "numeric") {
		t.Fatalf("a non-numeric updateId must be named, got %v", err)
	}

	// Control characters and oversized messages are refused, like the route.
	if _, _, err := rollbackHandler(deps)(ctx, req, RollbackInput{
		AppId: "app-1", Branch: "main", RuntimeVersion: "1.0.0", Message: "bad\x00update",
	}); err == nil {
		t.Fatal("a message with a null byte must be refused")
	}
	if _, _, err := rollbackHandler(deps)(ctx, req, RollbackInput{
		AppId: "app-1", Branch: "main", RuntimeVersion: "1.0.0", Message: strings.Repeat("x", 4096),
	}); err == nil {
		t.Fatal("an oversized message must be refused")
	}
}

// Publish outputs carry an RFC3339 timestamp, not the Unix-nanosecond integer
// types.Update holds internally.
func TestPublishOutputTimestamp(t *testing.T) {
	deps, _ := writeDeps()
	_, output, err := rollbackHandler(deps)(context.Background(), callToolRequestFor(writePrincipal), RollbackInput{
		AppId: "app-1", Branch: "main", RuntimeVersion: "1.0.0", Message: "bad update", Platform: "ios",
	})
	if err != nil {
		t.Fatal(err)
	}
	if len(output.Updates) != 1 || output.Updates[0].Platform != "ios" {
		t.Fatalf("unexpected output: %+v", output.Updates)
	}
	if _, err := time.Parse(time.RFC3339, output.Updates[0].CreatedAt); err != nil {
		t.Errorf("createdAt must be RFC3339, got %q", output.Updates[0].CreatedAt)
	}
}

func TestRollbackRefusesDuringActiveRollout(t *testing.T) {
	deps, fake := writeDeps()
	fake.rollbackErr = services.ErrActiveRolloutBlocksPublish

	_, _, err := rollbackHandler(deps)(context.Background(), callToolRequestFor(writePrincipal), RollbackInput{
		AppId: "app-1", Branch: "main", RuntimeVersion: "1.0.0", Message: "bad update",
	})
	if err == nil || !strings.Contains(err.Error(), "rollout") {
		t.Fatalf("the active-rollout refusal must reach the caller, got %v", err)
	}
}

func TestRepublishModes(t *testing.T) {
	ctx := context.Background()
	req := callToolRequestFor(writePrincipal)

	// Group mode mints a new publish group.
	deps, fake := writeDeps()
	_, output, err := republishHandler(deps)(ctx, req, RepublishInput{
		AppId: "app-1", Branch: "main", RuntimeVersion: "1.0.0",
		PublishGroup: "11111111-1111-1111-1111-111111111111",
	})
	if err != nil {
		t.Fatal(err)
	}
	if output.PublishGroup != "new-group" || len(output.Updates) != 2 || fake.republishedGroup == "" {
		t.Fatalf("unexpected group republish: %+v", output)
	}

	// Both or neither is refused, and a non-UUID group too.
	for name, input := range map[string]RepublishInput{
		"neither": {AppId: "app-1", Branch: "main", RuntimeVersion: "1.0.0"},
		"both":    {AppId: "app-1", Branch: "main", RuntimeVersion: "1.0.0", UpdateId: "12", PublishGroup: "11111111-1111-1111-1111-111111111111"},
		"bad uuid": {AppId: "app-1", Branch: "main", RuntimeVersion: "1.0.0",
			PublishGroup: "not-a-uuid"},
	} {
		t.Run(name, func(t *testing.T) {
			if _, _, err := republishHandler(deps)(ctx, req, input); err == nil {
				t.Fatal("expected a refusal")
			}
		})
	}
}

// The write tools must not claim to be read-only, and the destructive ones
// must say so: clients build their human-approval prompts on these hints.
func TestWriteToolAnnotations(t *testing.T) {
	deps, _ := writeDeps()
	deps.CanUseSomewhere = func(_ context.Context, _ *services.DashboardPrincipal, _ Access) bool { return true }

	server := mcpprot.NewServer(&mcpprot.Implementation{Name: "test", Version: "0"}, nil)
	Configurator(deps)(context.Background(), writePrincipal, server)

	destructive := map[string]bool{
		"delete_branch":    true,
		"delete_channel":   true,
		"rollback_branch":  true,
		"create_branch":    false,
		"create_channel":   false,
		"republish_update": false,
	}
	tools := listToolAnnotations(t, server)
	for name, wantDestructive := range destructive {
		annotations, ok := tools[name]
		if !ok {
			t.Fatalf("%s is not registered", name)
		}
		if annotations == nil {
			t.Fatalf("%s carries no annotations", name)
		}
		if annotations.ReadOnlyHint {
			t.Errorf("%s must not be read-only", name)
		}
		if annotations.DestructiveHint == nil || *annotations.DestructiveHint != wantDestructive {
			t.Errorf("%s destructiveHint: expected %v, got %v", name, wantDestructive, annotations.DestructiveHint)
		}
	}
	// Every read tool declares itself read-only.
	for _, name := range []string{"whoami", "get_apps", "get_branches", "get_runtime_versions", "get_channels", "get_channel_rollouts", "get_updates", "get_update_rollout", "get_certificate", "get_server_config"} {
		annotations, ok := tools[name]
		if !ok {
			t.Fatalf("%s is not registered", name)
		}
		if annotations == nil || !annotations.ReadOnlyHint {
			t.Errorf("%s must declare readOnlyHint", name)
		}
	}
}

func listToolAnnotations(t *testing.T, server *mcpprot.Server) map[string]*mcpprot.ToolAnnotations {
	t.Helper()
	ctx := context.Background()
	clientTransport, serverTransport := mcpprot.NewInMemoryTransports()
	if _, err := server.Connect(ctx, serverTransport, nil); err != nil {
		t.Fatal(err)
	}
	session, err := mcpprot.NewClient(&mcpprot.Implementation{Name: "test-client", Version: "0"}, nil).Connect(ctx, clientTransport, nil)
	if err != nil {
		t.Fatal(err)
	}
	defer session.Close()
	list, err := session.ListTools(ctx, nil)
	if err != nil {
		t.Fatal(err)
	}
	annotations := map[string]*mcpprot.ToolAnnotations{}
	for _, tool := range list.Tools {
		annotations[tool.Name] = tool.Annotations
	}
	return annotations
}