Files
kandev/apps/backend/internal/plugins/mentions_test.go
T

241 lines
10 KiB
Go

package plugins
import (
"context"
"errors"
"testing"
"time"
"github.com/kandev/kandev/internal/mentions"
"github.com/kandev/kandev/internal/plugins/manifest"
"github.com/kandev/kandev/internal/plugins/store"
apiv1 "github.com/kandev/kandev/pkg/api/v1"
"github.com/kandev/kandev/pkg/pluginsdk"
)
type fakeMentionSourceClient struct {
records []*store.Record
searchGenerations []pluginDispatchGeneration
search func(context.Context, string, *pluginsdk.SearchEntityReferencesRequest) (*pluginsdk.SearchEntityReferencesResponse, error)
authorize func(context.Context, string, *pluginsdk.AuthorizeEntityReferenceRequest) (*pluginsdk.AuthorizeEntityReferenceResponse, error)
}
func (c *fakeMentionSourceClient) List() []*store.Record {
return c.records
}
func (c *fakeMentionSourceClient) SearchEntityReferences(
ctx context.Context, id string, generation pluginDispatchGeneration, request *pluginsdk.SearchEntityReferencesRequest,
) (*pluginsdk.SearchEntityReferencesResponse, error) {
c.searchGenerations = append(c.searchGenerations, generation)
return c.search(ctx, id, request)
}
func (c *fakeMentionSourceClient) AuthorizeEntityReference(
ctx context.Context, id string, _ pluginDispatchGeneration, request *pluginsdk.AuthorizeEntityReferenceRequest,
) (*pluginsdk.AuthorizeEntityReferenceResponse, error) {
return c.authorize(ctx, id, request)
}
func TestMentionSourceBridgeCanonicalizesPluginCandidatesWithVerifiedSearchScope(t *testing.T) {
client := &fakeMentionSourceClient{
records: []*store.Record{testMentionSourceRecord()},
search: func(_ context.Context, id string, request *pluginsdk.SearchEntityReferencesRequest) (*pluginsdk.SearchEntityReferencesResponse, error) {
if id != "kandev-plugin-bitbucket" || request.Source != "bitbucket" ||
request.WorkspaceID != "workspace-1" || request.Query != "auth" || request.Limit != 3 {
t.Errorf("search request = %#v for %q", request, id)
return &pluginsdk.SearchEntityReferencesResponse{}, nil
}
return &pluginsdk.SearchEntityReferencesResponse{Candidates: []pluginsdk.EntityReferenceCandidate{{
ProviderLocalID: "pull-42",
Title: "Fix authentication",
URL: "https://bitbucket.example/projects/APP/repos/web/pull-requests/42",
Attributes: map[string]any{"provider": "attacker", "scope": "workspace-2"},
}}}, nil
},
authorize: func(_ context.Context, id string, request *pluginsdk.AuthorizeEntityReferenceRequest) (*pluginsdk.AuthorizeEntityReferenceResponse, error) {
if id != "kandev-plugin-bitbucket" || request.Source != "bitbucket" ||
request.WorkspaceID != "workspace-1" || request.Purpose != "search" {
t.Errorf("authorization request = %#v for %q", request, id)
return &pluginsdk.AuthorizeEntityReferenceResponse{Allowed: false}, nil
}
return &pluginsdk.AuthorizeEntityReferenceResponse{Allowed: true}, nil
},
}
registry := mentions.NewRegistry()
bridge := NewMentionSourceBridge(client)
if err := bridge.RegisterMentionSources(registry); err != nil {
t.Fatalf("register mention sources: %v", err)
}
response, err := mentions.NewService(registry).Search(context.Background(), mentions.SearchRequest{
WorkspaceID: "workspace-1", Query: "auth", Limit: 3,
})
if err != nil {
t.Fatalf("search: %v", err)
}
if len(response.Groups) != 1 || len(response.Groups[0].Results) != 1 {
t.Fatalf("response = %#v, want one result", response)
}
result := response.Groups[0].Results[0]
if result.Provider != "bitbucket" || result.Kind != "pull_request" || result.Scope != "workspace-1" ||
result.ID != "pull-42" || result.Ref != "mention:v1:bitbucket:pull_request:workspace-1:pull-42" {
t.Fatalf("host canonical reference = %#v", result)
}
}
func TestMentionSourceBridgeReauthorizesSubmissionAndRejectsStaleOrTamperedReferences(t *testing.T) {
authorizeCalls := 0
client := &fakeMentionSourceClient{
records: []*store.Record{testMentionSourceRecord()},
search: func(context.Context, string, *pluginsdk.SearchEntityReferencesRequest) (*pluginsdk.SearchEntityReferencesResponse, error) {
return &pluginsdk.SearchEntityReferencesResponse{}, nil
},
authorize: func(ctx context.Context, _ string, request *pluginsdk.AuthorizeEntityReferenceRequest) (*pluginsdk.AuthorizeEntityReferenceResponse, error) {
authorizeCalls++
if request.Purpose != "submission" {
t.Errorf("purpose = %q, want submission", request.Purpose)
return &pluginsdk.AuthorizeEntityReferenceResponse{Allowed: false}, nil
}
if request.WorkspaceID != "workspace-1" || request.Source != "bitbucket" {
t.Errorf("submission request = %#v", request)
return &pluginsdk.AuthorizeEntityReferenceResponse{Allowed: false}, nil
}
if request.Reference["id"] == "timeout" {
<-ctx.Done()
return nil, ctx.Err()
}
return &pluginsdk.AuthorizeEntityReferenceResponse{Allowed: request.Reference["id"] == "pull-42"}, nil
},
}
registry := mentions.NewRegistry()
bridge := NewMentionSourceBridge(client)
if err := bridge.RegisterMentionSources(registry); err != nil {
t.Fatalf("register mention sources: %v", err)
}
valid := pluginReference("pull-42", "workspace-1")
if err := registry.AuthorizeForWorkspace(context.Background(), "workspace-1", valid); err != nil {
t.Fatalf("authorize valid reference: %v", err)
}
tampered := pluginReference("pull-43", "workspace-1")
if err := registry.AuthorizeForWorkspace(context.Background(), "workspace-1", tampered); !errors.Is(err, mentions.ErrReferenceUnauthorized) {
t.Fatalf("tampered reference error = %v, want unauthorized", err)
}
crossWorkspace := pluginReference("pull-42", "workspace-2")
if err := registry.AuthorizeForWorkspace(context.Background(), "workspace-1", crossWorkspace); !errors.Is(err, mentions.ErrReferenceUnauthorized) {
t.Fatalf("cross-workspace reference error = %v, want unauthorized", err)
}
if authorizeCalls != 2 {
t.Fatalf("plugin authorization calls = %d, want no cross-workspace call", authorizeCalls)
}
spoofed := valid
spoofed.Provider = "attacker"
if err := registry.AuthorizeForWorkspace(context.Background(), "workspace-1", spoofed); !errors.Is(err, mentions.ErrReferenceProviderUnavailable) {
t.Fatalf("spoofed provider error = %v, want unavailable", err)
}
timeoutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
if err := registry.AuthorizeForWorkspace(timeoutCtx, "workspace-1", pluginReference("timeout", "workspace-1")); !errors.Is(err, mentions.ErrReferenceUnauthorized) {
t.Fatalf("timed-out reference error = %v, want unauthorized", err)
}
client.records = nil
if err := bridge.Sync(); err != nil {
t.Fatalf("remove disabled plugin sources: %v", err)
}
if err := registry.AuthorizeForWorkspace(context.Background(), "workspace-1", valid); !errors.Is(err, mentions.ErrReferenceProviderUnavailable) {
t.Fatalf("disabled plugin error = %v, want unavailable", err)
}
}
func TestMentionSourceBridgeTransfersOwnershipInOneSync(t *testing.T) {
predecessor := testMentionSourceRecord()
predecessor.ID = "z-predecessor"
successor := testMentionSourceRecord()
successor.ID = "a-successor"
client := &fakeMentionSourceClient{
records: []*store.Record{predecessor},
search: func(context.Context, string, *pluginsdk.SearchEntityReferencesRequest) (*pluginsdk.SearchEntityReferencesResponse, error) {
return &pluginsdk.SearchEntityReferencesResponse{}, nil
},
authorize: func(context.Context, string, *pluginsdk.AuthorizeEntityReferenceRequest) (*pluginsdk.AuthorizeEntityReferenceResponse, error) {
return &pluginsdk.AuthorizeEntityReferenceResponse{Allowed: true}, nil
},
}
bridge := NewMentionSourceBridge(client)
if err := bridge.RegisterMentionSources(mentions.NewRegistry()); err != nil {
t.Fatalf("register predecessor: %v", err)
}
client.records = []*store.Record{successor}
if err := bridge.Sync(); err != nil {
t.Fatalf("transfer source ownership: %v", err)
}
if _, retained := bridge.owners[predecessor.ID]; retained {
t.Fatalf("departed owner %q was retained", predecessor.ID)
}
if _, active := bridge.owners[successor.ID]; !active {
t.Fatalf("successor owner %q was not installed by the first sync", successor.ID)
}
}
func TestMentionSourceBridgeRefreshesDispatchGenerationWhenManifestIsUnchanged(t *testing.T) {
installedAt := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
record := testMentionSourceRecord()
record.Version = "1.0.0"
record.InstallPath = "/plugins/bitbucket/1.0.0"
record.InstalledAt = installedAt
client := &fakeMentionSourceClient{
records: []*store.Record{record},
search: func(context.Context, string, *pluginsdk.SearchEntityReferencesRequest) (*pluginsdk.SearchEntityReferencesResponse, error) {
return &pluginsdk.SearchEntityReferencesResponse{}, nil
},
authorize: func(context.Context, string, *pluginsdk.AuthorizeEntityReferenceRequest) (*pluginsdk.AuthorizeEntityReferenceResponse, error) {
return &pluginsdk.AuthorizeEntityReferenceResponse{Allowed: true}, nil
},
}
registry := mentions.NewRegistry()
bridge := NewMentionSourceBridge(client)
if err := bridge.RegisterMentionSources(registry); err != nil {
t.Fatalf("register initial mention source: %v", err)
}
upgraded := *record
upgraded.Version = "2.0.0"
upgraded.InstallPath = "/plugins/bitbucket/2.0.0"
upgraded.InstalledAt = installedAt.Add(time.Hour)
client.records = []*store.Record{&upgraded}
if err := bridge.Sync(); err != nil {
t.Fatalf("sync upgraded mention source: %v", err)
}
if _, err := mentions.NewService(registry).Search(t.Context(), mentions.SearchRequest{
WorkspaceID: "workspace-1", Query: "auth", Limit: 3,
}); err != nil {
t.Fatalf("search upgraded mention source: %v", err)
}
if len(client.searchGenerations) != 1 || !client.searchGenerations[0].matches(&upgraded) {
t.Fatalf("search generation = %#v, want upgraded generation %#v", client.searchGenerations, dispatchGeneration(&upgraded))
}
}
func pluginReference(id, workspaceID string) apiv1.EntityReference {
return apiv1.EntityReference{
Provider: "bitbucket", Kind: "pull_request", ID: id, Scope: workspaceID,
Title: "Fix authentication", URL: "https://bitbucket.example/pull-requests/42",
}
}
func testMentionSourceRecord() *store.Record {
return &store.Record{
Manifest: manifest.Manifest{
ID: "kandev-plugin-bitbucket",
ReferenceSources: []manifest.ReferenceSource{{
Source: "bitbucket", Provider: "bitbucket", Kind: "pull_request",
DisplayName: "Bitbucket", KindLabel: "Pull request", Order: 80,
}},
},
Status: StatusActive,
}
}