Files
kandev/apps/backend/internal/workflow/service/access_test.go
T
2026-08-26 15:57:40 +00:00

281 lines
11 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/auth/authn"
taskmodels "github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository/repoerrors"
"github.com/kandev/kandev/internal/workflow/models"
)
// scopedCtx is a request context carrying a real (non-synthetic) identity.
func scopedCtx() context.Context {
return authn.WithIdentity(context.Background(), authn.Identity{UserID: "user-a", Role: authn.RoleMember})
}
// syntheticCtx is what the auth middleware injects while authentication is
// disabled.
func syntheticCtx() context.Context {
return authn.WithIdentity(context.Background(), authn.Identity{UserID: "user-a", Synthetic: true})
}
// denyAll stands in for a task service that answers "not yours" for everything.
func denyAll(context.Context, string) error { return repoerrors.ErrWorkspaceNotFound }
// callRecorder records the IDs a checker was asked about.
type callRecorder struct {
ids []string
err error
}
func (r *callRecorder) check(_ context.Context, id string) error {
r.ids = append(r.ids, id)
return r.err
}
func TestAuthorizeWorkflowAndWorkspaceFailClosedOnAnEmptyID(t *testing.T) {
svc, _ := setupTestService(t)
recorder := &callRecorder{}
svc.SetWorkflowAccessChecker(recorder.check)
svc.SetWorkspaceAccessChecker(recorder.check)
svc.SetTaskAccessChecker(recorder.check)
// An empty ID means the owner could not be resolved. The task service
// reads workspaceID=="" as "no scoping applies" and would allow it, which
// is why this never reaches the checker (see backendapp/auth.go's
// runSubscriptionCheck for the same trap).
require.ErrorIs(t, svc.AuthorizeWorkflow(scopedCtx(), ""), ErrNotVisible)
require.ErrorIs(t, svc.AuthorizeWorkspace(scopedCtx(), ""), ErrNotVisible)
require.ErrorIs(t, svc.AuthorizeTask(scopedCtx(), ""), ErrNotVisible)
unwired, _ := setupTestService(t)
require.ErrorIs(t, unwired.AuthorizeStep(scopedCtx(), ""), ErrNotVisible)
require.Empty(t, recorder.ids, "an unresolvable owner was passed to the task service")
}
func TestAuthorizeHelpersNoOpWhenAuthIsDisabled(t *testing.T) {
svc, _ := setupTestService(t)
recorder := &callRecorder{err: repoerrors.ErrWorkspaceNotFound}
svc.SetWorkflowAccessChecker(recorder.check)
svc.SetWorkspaceAccessChecker(recorder.check)
svc.SetTaskAccessChecker(recorder.check)
for name, ctx := range map[string]context.Context{
"synthetic identity": syntheticCtx(),
"no identity": context.Background(),
} {
t.Run(name, func(t *testing.T) {
require.NoError(t, svc.AuthorizeWorkflow(ctx, "wf-1"))
require.NoError(t, svc.AuthorizeWorkspace(ctx, "ws-1"))
require.NoError(t, svc.AuthorizeStep(ctx, "step-1"))
require.NoError(t, svc.AuthorizeTask(ctx, "task-1"))
require.NoError(t, svc.AuthorizeWorkflow(ctx, ""), "an empty ID must not fail closed for an unscoped caller")
})
}
require.Empty(t, recorder.ids, "unscoped callers must not reach the task service at all")
}
func TestAuthorizeStepResolvesItsOwningWorkflow(t *testing.T) {
t.Run("a step in a visible workflow is allowed", func(t *testing.T) {
svc, _ := setupTestService(t)
recorder := &callRecorder{}
svc.SetWorkflowAccessChecker(recorder.check)
step := &models.WorkflowStep{WorkflowID: "wf-1", Name: "Backlog", Color: "#111111"}
require.NoError(t, svc.CreateStep(context.Background(), step))
require.NoError(t, svc.AuthorizeStep(scopedCtx(), step.ID))
require.Equal(t, []string{"wf-1"}, recorder.ids)
})
t.Run("a step in a foreign workflow is not found", func(t *testing.T) {
svc, _ := setupTestService(t)
svc.SetWorkflowAccessChecker(denyAll)
step := &models.WorkflowStep{WorkflowID: "wf-1", Name: "Backlog", Color: "#111111"}
require.NoError(t, svc.CreateStep(context.Background(), step))
require.ErrorIs(t, svc.AuthorizeStep(scopedCtx(), step.ID), ErrNotVisible)
})
// A step row with no workflow has no resolvable owner. Failing open here
// would hand every scoped caller a step nobody can be shown to own, so the
// branch is pinned with a row inserted straight into the table (the model
// validator rejects one through the service).
t.Run("a step with no owning workflow fails closed", func(t *testing.T) {
svc, db := setupTestService(t)
svc.SetWorkflowAccessChecker(func(context.Context, string) error { return nil })
_, err := db.Exec(
`INSERT INTO workflow_steps (id, workflow_id, name, position, wip_limit, created_at, updated_at)
VALUES ('orphan-step', '', 'Orphan', 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
require.NoError(t, err)
require.ErrorIs(t, svc.AuthorizeStep(scopedCtx(), "orphan-step"), ErrNotVisible)
})
t.Run("a nonexistent step is the same error", func(t *testing.T) {
svc, _ := setupTestService(t)
svc.SetWorkflowAccessChecker(denyAll)
require.ErrorIs(t, svc.AuthorizeStep(scopedCtx(), "no-such-step"), ErrNotVisible)
})
}
// TestAuthorizeWorkflowNormalizesAMissingWorkflow pins the classification the
// no-existence-leak rule depends on. The task repository reports a missing
// workflow as a formatted error rather than a sentinel, so treating only
// sentinels as denials would answer 500 for "does not exist" and 404 for "not
// yours" — telling the two apart.
func TestAuthorizeWorkflowNormalizesAMissingWorkflow(t *testing.T) {
svc, _ := setupTestService(t)
svc.SetWorkflowAccessChecker(func(context.Context, string) error {
return fmt.Errorf("workflow not found: %s", "wf-1")
})
require.ErrorIs(t, svc.AuthorizeWorkflow(scopedCtx(), "wf-1"), ErrNotVisible)
}
// TestAuthorizeWorkflowPropagatesRealFailures is the other side of that coin: a
// database failure must not be laundered into a 404, which would hide an
// outage behind an empty board.
func TestAuthorizeWorkflowPropagatesRealFailures(t *testing.T) {
svc, _ := setupTestService(t)
broken := errors.New("database is locked")
svc.SetWorkflowAccessChecker(func(context.Context, string) error { return broken })
err := svc.AuthorizeWorkflow(scopedCtx(), "wf-1")
require.ErrorIs(t, err, broken)
require.NotErrorIs(t, err, ErrNotVisible)
}
// TestServiceEntryPointsAuthorizeThemselves covers the methods reached without
// passing through the workflow controller: the boot-payload builder calls
// ListStepsByWorkspaceID directly, and the MCP config tools call
// ExportWorkflow/ImportWorkflows directly.
func TestServiceEntryPointsAuthorizeThemselves(t *testing.T) {
newDeniedService := func(t *testing.T) *Service {
t.Helper()
svc, _ := setupTestService(t)
svc.SetWorkflowAccessChecker(denyAll)
svc.SetWorkspaceAccessChecker(denyAll)
// A provider that would panic-free serve data if the guard let the call
// through, so a missing guard shows up as a nil error, not a crash.
svc.SetWorkflowProvider(&stubWorkflowProvider{})
return svc
}
t.Run("list steps by workspace", func(t *testing.T) {
svc := newDeniedService(t)
_, err := svc.ListStepsByWorkspaceID(scopedCtx(), "ws-a")
require.ErrorIs(t, err, ErrNotVisible)
})
t.Run("export one workflow", func(t *testing.T) {
svc := newDeniedService(t)
_, err := svc.ExportWorkflow(scopedCtx(), "wf-a")
require.ErrorIs(t, err, ErrNotVisible)
})
t.Run("export a workspace", func(t *testing.T) {
svc := newDeniedService(t)
_, err := svc.ExportWorkflows(scopedCtx(), "ws-a", nil)
require.ErrorIs(t, err, ErrNotVisible)
})
t.Run("import into a workspace", func(t *testing.T) {
svc := newDeniedService(t)
_, err := svc.ImportWorkflows(scopedCtx(), "ws-a", &models.WorkflowExport{
Version: models.ExportVersion,
Type: models.ExportType,
Workflows: []models.WorkflowPortable{{Name: "Smuggled"}},
})
require.ErrorIs(t, err, ErrNotVisible)
require.Empty(t, svc.workflowProvider.(*stubWorkflowProvider).created)
})
// ReorderSteps is the layer that writes the positions, so it authorizes
// the workflow itself rather than trusting a caller to have done it. The
// membership check below cannot stand in for this one: steps that really
// do belong to a workflow the caller cannot see would otherwise reorder.
t.Run("reorder authorizes the workflow it is asked to reorder", func(t *testing.T) {
svc, _ := setupTestService(t)
step := &models.WorkflowStep{WorkflowID: "wf-a", Name: "Backlog", Color: "#111111", Position: 7}
require.NoError(t, svc.CreateStep(context.Background(), step))
svc.SetWorkflowAccessChecker(denyAll)
require.ErrorIs(t, svc.ReorderSteps(scopedCtx(), "wf-a", []string{step.ID}), ErrNotVisible)
stored, err := svc.GetStep(context.Background(), step.ID)
require.NoError(t, err)
require.Equal(t, 7, stored.Position, "a denied reorder wrote anyway")
})
t.Run("reorder rejects a step from another workflow", func(t *testing.T) {
svc, _ := setupTestService(t)
// Position 7, so the reorder this test denies would visibly move the
// step to 0 if the guard were missing. wf-b is authorized: the step
// belonging to wf-a is the only reason to refuse.
step := &models.WorkflowStep{WorkflowID: "wf-a", Name: "Backlog", Color: "#111111", Position: 7}
require.NoError(t, svc.CreateStep(context.Background(), step))
svc.SetWorkflowAccessChecker(func(context.Context, string) error { return nil })
require.ErrorIs(t, svc.ReorderSteps(scopedCtx(), "wf-b", []string{step.ID}), ErrNotVisible)
stored, err := svc.GetStep(context.Background(), step.ID)
require.NoError(t, err)
require.Equal(t, 7, stored.Position, "a denied reorder wrote anyway")
})
}
// stubWorkflowProvider is a permissive task-domain stand-in: every method
// succeeds, so any assertion that fails is the guard's absence rather than a
// missing fixture.
type stubWorkflowProvider struct {
created []string
}
func (p *stubWorkflowProvider) ListWorkflows(context.Context, string, bool) ([]*taskmodels.Workflow, error) {
return nil, nil
}
func (p *stubWorkflowProvider) GetWorkflow(_ context.Context, id string) (*taskmodels.Workflow, error) {
return &taskmodels.Workflow{ID: id, WorkspaceID: "ws-a", Name: "A Flow"}, nil
}
func (p *stubWorkflowProvider) CreateWorkflow(
_ context.Context, workspaceID, name, description string,
) (*taskmodels.Workflow, error) {
p.created = append(p.created, name)
return &taskmodels.Workflow{ID: "created-" + name, WorkspaceID: workspaceID, Name: name, Description: description}, nil
}
func (p *stubWorkflowProvider) UpdateWorkflow(context.Context, *taskmodels.Workflow) error {
return nil
}
// opaqueWrap wraps an error behind a message that the textual fallback cannot
// match, so a passing assertion proves the typed set did the classifying.
type opaqueWrap struct{ err error }
func (o opaqueWrap) Error() string { return "internal failure" }
func (o opaqueWrap) Unwrap() error { return o.err }
func TestIsNotFoundMatchesSentinelsWithoutRelyingOnWording(t *testing.T) {
for name, sentinel := range map[string]error{
"not visible": ErrNotVisible,
"workspace": repoerrors.ErrWorkspaceNotFound,
"task": repoerrors.ErrTaskNotFound,
"task session": taskmodels.ErrTaskSessionNotFound,
} {
t.Run(name, func(t *testing.T) {
require.True(t, IsNotFound(sentinel))
require.True(t, IsNotFound(fmt.Errorf("wrapped: %w", sentinel)))
require.True(t, IsNotFound(opaqueWrap{err: sentinel}),
"classification fell through to the textual fallback instead of the typed set")
})
}
require.False(t, IsNotFound(nil))
require.False(t, IsNotFound(errors.New("connection reset by peer")))
}