Files
trufflehog/pkg/sources/gitlab/gitlab_integration_test.go
Kashif Khan 7c724e56cb Fix GitLab project metadata cache and switch to LRU (#4727)
The in-memory cache for GitLab project metadata (used to populate
chunk metadata) was keyed by the raw HTTPURLToRepo on write but by the
normalized repo URL on read, so cache lookups almost always missed and
the underlying map grew unbounded.

Replace the map with an expirable LRU cache (15,000 entries, 1 hour
TTL) to bound memory usage, and consistently use the normalized repo
URL as the cache key on both writes and reads, including normalizing
before cloning in ChunkUnit so the git remote URL used for metadata
lookups matches the cached key.
2026-07-14 09:49:28 +05:00

893 lines
23 KiB
Go

//go:build integration
// +build integration
package gitlab
import (
"fmt"
"sync"
"testing"
"time"
"github.com/kylelemons/godebug/pretty"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/anypb"
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
"github.com/trufflesecurity/trufflehog/v3/pkg/feature"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/credentialspb"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/source_metadatapb"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/sourcespb"
"github.com/trufflesecurity/trufflehog/v3/pkg/sources"
"github.com/trufflesecurity/trufflehog/v3/pkg/sourcestest"
)
func TestSource_Scan(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
secret, err := common.GetTestSecret(ctx)
if err != nil {
t.Fatal(fmt.Errorf("failed to access secret: %v", err))
}
token := secret.MustGetField("GITLAB_TOKEN")
basicUser := secret.MustGetField("GITLAB_USER")
no2FaUser := secret.MustGetField("GITLAB_NO_2FA_USER")
no2FaPass := secret.MustGetField("GITLAB_NO_2FA_PASS")
type init struct {
name string
verify bool
connection *sourcespb.GitLab
}
tests := []struct {
name string
init init
wantChunk *sources.Chunk
wantReposScanned int
wantErr bool
}{
{
name: "token auth, enumerate repo, with explicit ignore",
init: init{
name: "test source",
connection: &sourcespb.GitLab{
Credential: &sourcespb.GitLab_Token{
Token: token,
},
IgnoreRepos: []string{"tes1188/learn-gitlab"},
},
},
wantChunk: &sources.Chunk{
SourceType: sourcespb.SourceType_SOURCE_TYPE_GITLAB,
SourceName: "test source",
},
wantReposScanned: 5,
},
{
name: "token auth, enumerate repo, with glob ignore",
init: init{
name: "test source",
connection: &sourcespb.GitLab{
Credential: &sourcespb.GitLab_Token{
Token: token,
},
IgnoreRepos: []string{"tes1188/*-gitlab"},
},
},
wantChunk: &sources.Chunk{
SourceType: sourcespb.SourceType_SOURCE_TYPE_GITLAB,
SourceName: "test source",
},
wantReposScanned: 5,
},
{
name: "token auth, scoped repo",
init: init{
name: "test source scoped",
connection: &sourcespb.GitLab{
Repositories: []string{"https://gitlab.com/testermctestface/testy.git"},
Credential: &sourcespb.GitLab_Token{
Token: token,
},
},
},
wantChunk: &sources.Chunk{
SourceType: sourcespb.SourceType_SOURCE_TYPE_GITLAB,
SourceName: "test source scoped",
},
wantReposScanned: 1,
},
{
name: "basic auth, scoped repo",
init: init{
name: "test source basic auth scoped",
connection: &sourcespb.GitLab{
Repositories: []string{"https://gitlab.com/trufflesec-detectors/test-project.git"},
Credential: &sourcespb.GitLab_BasicAuth{
BasicAuth: &credentialspb.BasicAuth{
Username: no2FaUser,
Password: no2FaPass,
},
},
},
},
wantChunk: &sources.Chunk{
SourceType: sourcespb.SourceType_SOURCE_TYPE_GITLAB,
SourceName: "test source basic auth scoped",
},
wantReposScanned: 1,
},
{
name: "basic auth access token, scoped repo",
init: init{
name: "test source basic auth access token scoped",
connection: &sourcespb.GitLab{
Repositories: []string{"https://gitlab.com/testermctestface/testy.git"},
Credential: &sourcespb.GitLab_BasicAuth{
BasicAuth: &credentialspb.BasicAuth{
Username: basicUser,
Password: token,
},
},
},
},
wantChunk: &sources.Chunk{
SourceType: sourcespb.SourceType_SOURCE_TYPE_GITLAB,
SourceName: "test source basic auth access token scoped",
},
wantReposScanned: 1,
},
{
name: "token auth, group projects enumeration with include_subgroups",
init: init{
name: "test source group enumeration",
connection: &sourcespb.GitLab{
Credential: &sourcespb.GitLab_Token{
Token: token,
},
GroupIds: []string{"15013490"},
},
},
wantChunk: &sources.Chunk{
SourceType: sourcespb.SourceType_SOURCE_TYPE_GITLAB,
SourceName: "test source group enumeration",
},
wantReposScanned: 5,
},
{
name: "token auth, group projects enumeration with include_subgroups and exclude repositories",
init: init{
name: "test source group enumeration with exclude repos",
connection: &sourcespb.GitLab{
Credential: &sourcespb.GitLab_Token{
Token: token,
},
GroupIds: []string{"15013490"},
IgnoreRepos: []string{"tes1188/test-user-count"},
},
},
wantChunk: &sources.Chunk{
SourceType: sourcespb.SourceType_SOURCE_TYPE_GITLAB,
SourceName: "test source group enumeration with exclude repos",
},
wantReposScanned: 4,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := Source{}
conn, err := anypb.New(tt.init.connection)
if err != nil {
t.Fatal(err)
}
err = s.Init(ctx, tt.init.name, 0, 0, tt.init.verify, conn, 10)
if (err != nil) != tt.wantErr {
t.Errorf("Source.Init() error = %v, wantErr %v", err, tt.wantErr)
return
}
chunksCh := make(chan *sources.Chunk, 1)
go func() {
defer close(chunksCh)
err = s.Chunks(ctx, chunksCh)
if (err != nil) != tt.wantErr {
t.Errorf("Source.Chunks() error = %v, wantErr %v", err, tt.wantErr)
return
}
}()
var chunkCnt int
// Commits don't come in a deterministic order, so remove metadata comparison
for gotChunk := range chunksCh {
chunkCnt++
gotChunk.Data = nil
gotChunk.SourceMetadata = nil
if diff := pretty.Compare(gotChunk, tt.wantChunk); diff != "" {
t.Errorf("Source.Chunks() %s diff: (-got +want)\n%s", tt.name, diff)
}
}
assert.Equal(t, tt.wantReposScanned, len(s.repos))
if chunkCnt < 1 {
t.Errorf("0 chunks scanned.")
}
})
}
}
func TestSource_Validate(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
secret, err := common.GetTestSecret(ctx)
if err != nil {
t.Fatal(fmt.Errorf("failed to access secret: %v", err))
}
token := secret.MustGetField("GITLAB_TOKEN")
tokenWrongScope := secret.MustGetField("GITLAB_TOKEN_WRONG_SCOPE")
tests := []struct {
name string
connection *sourcespb.GitLab
wantErrCount int
wantErrs []string
}{
{
name: "basic auth did not authenticate",
connection: &sourcespb.GitLab{
Credential: &sourcespb.GitLab_BasicAuth{
BasicAuth: &credentialspb.BasicAuth{
Username: "bad-user",
Password: "bad-password",
},
},
},
wantErrCount: 1,
},
{
name: "token did not authenticate",
connection: &sourcespb.GitLab{
Credential: &sourcespb.GitLab_Token{
Token: "bad-token",
},
},
wantErrCount: 1,
},
{
name: "bad repo urls",
connection: &sourcespb.GitLab{
Credential: &sourcespb.GitLab_Token{
Token: token,
},
Repositories: []string{
"https://gitlab.com/testermctestface/testy", // valid
"https://gitlab.com/testermctestface/testy/", // trailing slash
"ssh:[email protected]/testermctestface/testy", // bad protocol
"https://gitlab.com", // no path
"https://gitlab.com/", // no org name
"https://gitlab.com//testy", // no org name
"https://gitlab.com/testermctestface/", // no repo name
},
},
wantErrCount: 6,
},
{
name: "token does not have permission to list projects",
connection: &sourcespb.GitLab{
Credential: &sourcespb.GitLab_Token{
Token: tokenWrongScope,
},
},
wantErrCount: 1,
},
{
name: "repositories and ignore globs both configured",
connection: &sourcespb.GitLab{
Credential: &sourcespb.GitLab_Token{
Token: token,
},
Repositories: []string{
"https://gitlab.com/testermctestface/testy", // valid
},
IgnoreRepos: []string{
"tes1188/*-gitlab",
"[", // glob doesn't compile, but this won't be checked
},
},
wantErrCount: 1,
},
{
name: "could not compile ignore glob(s)",
connection: &sourcespb.GitLab{
Credential: &sourcespb.GitLab_Token{
Token: token,
},
IgnoreRepos: []string{
"tes1188/*-gitlab",
"[", // glob doesn't compile
"[a-]", // glob doesn't compile
},
},
wantErrCount: 2,
},
{
name: "could not compile include glob(s)",
connection: &sourcespb.GitLab{
Credential: &sourcespb.GitLab_Token{
Token: token,
},
IncludeRepos: []string{
"tes1188/*-gitlab",
"[", // glob doesn't compile
"[a-]", // glob doesn't compile
},
IgnoreRepos: []string{
"[",
},
},
wantErrCount: 3,
},
{
name: "repositories do not exist or are not accessible",
connection: &sourcespb.GitLab{
Credential: &sourcespb.GitLab_Token{
Token: token,
},
Repositories: []string{
"https://gitlab.com/testermctestface/testy",
"https://gitlab.com/testermctestface/doesn't-exist",
"https://gitlab.com/testermctestface/also-doesn't-exist",
},
},
wantErrCount: 2,
},
{
name: "ignore globs exclude all repos",
connection: &sourcespb.GitLab{
Credential: &sourcespb.GitLab_Token{
Token: token,
},
IgnoreRepos: []string{
"*",
},
},
wantErrCount: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := Source{}
conn, err := anypb.New(tt.connection)
if err != nil {
t.Fatal(err)
}
err = s.Init(ctx, tt.name, 0, 0, false, conn, 1)
if err != nil {
t.Fatalf("Source.Init() error: %v", err)
}
errs := s.Validate(ctx)
assert.Equal(t, tt.wantErrCount, len(errs))
})
}
}
func TestSource_Chunks_TargetedScan(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
secret, err := common.GetTestSecret(ctx)
if err != nil {
t.Fatal(fmt.Errorf("failed to access secret: %v", err))
}
token := secret.MustGetField("GITLAB_TOKEN")
type init struct {
name string
verify bool
connection *sourcespb.GitLab
queryCriteria *source_metadatapb.MetaData
}
tests := []struct {
name string
init init
wantChunks int
}{
{
name: "targeted scan; single diff",
init: init{
connection: &sourcespb.GitLab{Credential: &sourcespb.GitLab_Token{Token: token}},
queryCriteria: &source_metadatapb.MetaData{
Data: &source_metadatapb.MetaData_Gitlab{
Gitlab: &source_metadatapb.Gitlab{
Repository: "https://gitlab.com/testermctestface/testy.git",
Link: "https://gitlab.com/testermctestface/testy/blob/30c407baee70d41d062114022a59ed8ee048880a/.gitlab-ci.yml#L1",
Commit: "30c407baee70d41d062114022a59ed8ee048880a",
ProjectId: 32561068,
File: "keys",
},
},
},
},
wantChunks: 1,
},
{
name: "targeted scan; multiple diffs",
init: init{
connection: &sourcespb.GitLab{Credential: &sourcespb.GitLab_Token{Token: token}},
queryCriteria: &source_metadatapb.MetaData{
Data: &source_metadatapb.MetaData_Gitlab{
Gitlab: &source_metadatapb.Gitlab{
Commit: "b9a2fafeb0b978201e64f62efc9aa37c52a65045",
ProjectId: 32561068,
},
},
},
},
wantChunks: 2,
},
{
name: "invalid query criteria, missing project ID",
init: init{
connection: &sourcespb.GitLab{Credential: &sourcespb.GitLab_Token{Token: token}},
queryCriteria: &source_metadatapb.MetaData{
Data: &source_metadatapb.MetaData_Gitlab{
Gitlab: &source_metadatapb.Gitlab{
Repository: "test_keys",
Commit: "fbc14303ffbf8fb1c2c1914e8dda7d0121633aca",
File: "not-the-file",
},
},
},
},
wantChunks: 0,
},
{
name: "invalid query criteria, missing commit",
init: init{
name: "test source",
connection: &sourcespb.GitLab{Credential: &sourcespb.GitLab_Token{Token: token}},
queryCriteria: &source_metadatapb.MetaData{
Data: &source_metadatapb.MetaData_Gitlab{
Gitlab: &source_metadatapb.Gitlab{
Repository: "test_keys",
ProjectId: 32561068,
File: "not-the-file",
},
},
},
},
wantChunks: 0,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
s := Source{}
conn, err := anypb.New(tt.init.connection)
assert.NoError(t, err)
err = s.Init(ctx, tt.init.name, 0, 0, tt.init.verify, conn, 8)
assert.NoError(t, err)
var wg sync.WaitGroup
chunksCh := make(chan *sources.Chunk, 1)
wg.Add(1)
go func() {
defer close(chunksCh)
defer wg.Done()
err = s.Chunks(ctx, chunksCh, sources.ChunkingTarget{QueryCriteria: tt.init.queryCriteria})
assert.NoError(t, err)
}()
i := 0
for range chunksCh {
i++
}
wg.Wait()
assert.Equal(t, tt.wantChunks, i)
})
}
}
func TestSource_ChunkUnit_RepoFiltersRespected(t *testing.T) {
ctx := context.Background()
// Arrange: Get test environment token
secret, err := common.GetTestSecret(ctx)
if err != nil {
t.Fatal(fmt.Errorf("failed to access secret: %v", err))
}
token := secret.MustGetField("GITLAB_TOKEN")
// Arrange: Build a unit to scan
unit := sources.CommonSourceUnit{
Kind: "repo",
ID: "https://gitlab.com/testermctestface/testy",
}
tests := []struct {
name string
includeRepos []string
ignoreRepos []string
wantAnyChunks bool
}{
{
name: "empty include, empty ignore",
wantAnyChunks: true,
},
{
name: "unit matches include",
includeRepos: []string{"https://gitlab.com/testermctestface/testy"},
wantAnyChunks: true,
},
{
name: "unit does not match include",
includeRepos: []string{"https://gitlab.com/testermctestface/something-else"},
wantAnyChunks: false,
},
{
name: "unit matches ignore",
ignoreRepos: []string{"https://gitlab.com/testermctestface/testy"},
wantAnyChunks: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Arrange: Create the connection
typedConn := &sourcespb.GitLab{
Credential: &sourcespb.GitLab_Token{
Token: token,
},
IncludeRepos: tt.includeRepos,
IgnoreRepos: tt.ignoreRepos,
}
conn, err := anypb.New(typedConn)
require.NoError(t, err)
// Arrange: Instantiate and initialize the source
s := &Source{}
require.NoError(t, s.Init(ctx, "test source", 1, 1, false, conn, 1))
// Arrange: Build the chunk reporter
chunksChan := make(chan *sources.Chunk, 1024)
chunkReporter := sources.ChanReporter{Ch: chunksChan}
// Act: Scan the unit
require.NoError(t, s.ChunkUnit(ctx, unit, chunkReporter))
// Assert: Verify that chunk production was correct
assert.Equal(t, tt.wantAnyChunks, len(chunksChan) > 0)
})
}
}
func TestSource_InclusionGlobbing(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
secret, err := common.GetTestSecret(ctx)
if err != nil {
t.Fatal(fmt.Errorf("failed to access secret: %v", err))
}
token := secret.MustGetField("GITLAB_TOKEN")
tests := []struct {
name string
connection *sourcespb.GitLab
wantReposScanned int
wantErrCount int
}{
{
name: "Get all Repos",
connection: &sourcespb.GitLab{
Credential: &sourcespb.GitLab_Token{
Token: token,
},
IncludeRepos: []string{"*"},
IgnoreRepos: nil,
},
wantReposScanned: 6,
wantErrCount: 0,
},
{
name: "Ignore testy repo, include all others",
connection: &sourcespb.GitLab{
Credential: &sourcespb.GitLab_Token{
Token: token,
},
IncludeRepos: []string{"*"},
IgnoreRepos: []string{"*testy*"},
},
wantReposScanned: 5,
wantErrCount: 0,
},
{
name: "Ignore all repos",
connection: &sourcespb.GitLab{
Credential: &sourcespb.GitLab_Token{
Token: token,
},
IncludeRepos: nil,
IgnoreRepos: []string{"*"},
},
wantReposScanned: 0,
wantErrCount: 0,
},
{
name: "Ignore all repos, but glob doesn't compile",
connection: &sourcespb.GitLab{
Credential: &sourcespb.GitLab_Token{
Token: token,
},
IncludeRepos: []string{
"[", // glob doesn't compile
"[a-]", // glob doesn't compile
},
IgnoreRepos: []string{
"*", // ignore all repos
"[", // glob doesn't compile
},
},
wantReposScanned: 0,
wantErrCount: 3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
src := &Source{}
conn, err := anypb.New(tt.connection)
assert.NoError(t, err)
err = src.Init(ctx, tt.name, 0, 0, false, conn, 1)
assert.NoError(t, err)
// Query GitLab for the list of configured repos.
var repos []string
visitor := sources.VisitorReporter{
VisitUnit: func(ctx context.Context, unit sources.SourceUnit) error {
id, _ := unit.SourceUnitID()
repos = append(repos, id)
return nil
},
}
apiClient, err := src.newClient()
assert.NoError(t, err)
var errs []error
ignoreRepo := buildIgnorer(src.includeRepos, src.ignoreRepos, func(err error, pattern string) {
errs = append(errs, err)
})
err = src.getAllProjectRepos(ctx, apiClient, ignoreRepo, visitor)
assert.NoError(t, err)
assert.Equal(t, tt.wantErrCount, len(errs))
assert.Equal(t, tt.wantReposScanned, len(repos))
})
}
}
func TestSource_Chunks_ProjectDetailsInChunkMetadata(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
defer cancel()
secret, err := common.GetTestSecret(ctx)
if err != nil {
t.Fatal(fmt.Errorf("failed to access secret: %v", err))
}
token := secret.MustGetField("GITLAB_TOKEN")
tests := []struct {
name string
connection *sourcespb.GitLab
}{
{
name: "project details in chunk metadata - No repos configured",
connection: &sourcespb.GitLab{
Credential: &sourcespb.GitLab_Token{
Token: token,
},
},
},
{
name: "project details in chunk metadata - Repo configured",
connection: &sourcespb.GitLab{
Credential: &sourcespb.GitLab_Token{
Token: token,
},
Repositories: []string{"https://gitlab.com/testermctestface/testy.git"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := Source{}
conn, err := anypb.New(tt.connection)
if err != nil {
t.Fatal(err)
}
err = s.Init(ctx, tt.name, 0, 0, false, conn, 10)
if err != nil {
t.Errorf("Source.Init() error = %v", err)
return
}
chunksCh := make(chan *sources.Chunk, 1)
go func() {
defer close(chunksCh)
err = s.Chunks(context.Background(), chunksCh)
if err != nil {
t.Errorf("Source.Chunks() error = %v", err)
return
}
}()
gotChunks := false
for gotChunk := range chunksCh {
gotChunks = true
metadata := gotChunk.SourceMetadata.Data.(*source_metadatapb.MetaData_Gitlab)
if metadata.Gitlab.ProjectId == 0 || metadata.Gitlab.ProjectName == "" {
t.Errorf("Source.Chunks() missing project details in chunk metadata: %+v", metadata.Gitlab)
}
}
if !gotChunks {
t.Errorf("0 chunks scanned.")
}
})
}
}
func TestSource_Enumerate_ProjectDetailsInChunkMetadata(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
defer cancel()
secret, err := common.GetTestSecret(ctx)
if err != nil {
t.Fatal(fmt.Errorf("failed to access secret: %v", err))
}
token := secret.MustGetField("GITLAB_TOKEN")
s := Source{}
conn, err := anypb.New(&sourcespb.GitLab{
Credential: &sourcespb.GitLab_Token{
Token: token,
},
})
if err != nil {
t.Fatal(err)
}
err = s.Init(ctx, "project details in chunkmetadata", 0, 0, false, conn, 10)
if err != nil {
t.Errorf("Source.Init() error = %v", err)
return
}
testReporter := sourcestest.TestReporter{}
err = s.Enumerate(ctx, &testReporter)
if err != nil {
t.Errorf("Source.Chunks() error = %v", err)
return
}
chunksCh := make(chan *sources.Chunk, 1)
chanReporter := sources.ChanReporter{Ch: chunksCh}
// Clear cache to force querying project details
s.projectMetadataCache.Purge()
go func() {
defer close(chunksCh)
for _, unit := range testReporter.Units {
err := s.ChunkUnit(context.Background(), unit, chanReporter)
if err != nil {
t.Errorf("Source.ChunkUnit() error = %v", err)
}
}
}()
gotChunks := false
for gotChunk := range chunksCh {
gotChunks = true
metadata := gotChunk.SourceMetadata.Data.(*source_metadatapb.MetaData_Gitlab)
if metadata.Gitlab.ProjectId == 0 || metadata.Gitlab.ProjectName == "" {
t.Errorf("Source.Chunks() missing project details in chunk metadata: %+v", metadata.Gitlab)
}
}
if !gotChunks {
t.Errorf("0 chunks scanned.")
}
}
// TestSource_Chunks_SimplifiedGitlabEnumeration enumerates GitLab projects
// using a stored GitLab secret in GCP with the `UseSimplifiedGitlabEnumeration`
// feature flag enabled. When enabled, the enumeration path is redirected to
// `getAllProjectReposV2`, validating project listing via keyset pagination.
func TestSource_Chunks_SimplifiedGitlabEnumeration(t *testing.T) {
// Preserve and restore the feature flag to avoid cross-test contamination
prev := feature.UseSimplifiedGitlabEnumeration.Load()
// enable the simplified gitlab enumeration flag
feature.UseSimplifiedGitlabEnumeration.Store(true)
defer feature.UseSimplifiedGitlabEnumeration.Store(prev)
// Create a bounded context for the entire test
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// Retrieve test secret containing the GitLab token
secret, err := common.GetTestSecret(ctx)
require.NoError(t, err, "failed to access test secret")
token := secret.MustGetField("GITLAB_TOKEN")
// Initialize the GitLab source with token-based authentication
s := Source{}
conn, err := anypb.New(&sourcespb.GitLab{
Credential: &sourcespb.GitLab_Token{
Token: token,
},
})
require.NoError(t, err)
err = s.Init(ctx, "enumerate gitlab projects with V2", 0, 0, false, conn, 10)
require.NoError(t, err, "failed during Source.Init")
// Enumerate GitLab projects
testReporter := sourcestest.TestReporter{}
err = s.Enumerate(ctx, &testReporter)
require.NoError(t, err, "enumeration should not fail")
// Ensure enumeration actually produced units
require.NotEmpty(t, testReporter.Units, "enumeration returned no units")
// Clear project cache to force project-detail lookups during chunking
s.projectMetadataCache.Purge()
// Channel-based reporter to capture emitted chunks
chunksCh := make(chan *sources.Chunk, 1)
chanReporter := sources.ChanReporter{Ch: chunksCh}
// Chunk all enumerated units asynchronously
go func() {
defer close(chunksCh)
for _, unit := range testReporter.Units {
if err := s.ChunkUnit(ctx, unit, chanReporter); err != nil {
t.Errorf("Source.ChunkUnit() error = %v", err)
}
}
}()
// Validate produced chunks and their GitLab metadata
gotChunks := false
for chunk := range chunksCh {
gotChunks = true
meta, ok := chunk.SourceMetadata.Data.(*source_metadatapb.MetaData_Gitlab)
require.True(t, ok, "unexpected metadata type")
assert.NotZero(t, meta.Gitlab.ProjectId, "missing project ID in chunk metadata")
assert.NotEmpty(t, meta.Gitlab.ProjectName, "missing project name in chunk metadata")
}
// Ensure at least one chunk was produced
assert.True(t, gotChunks, "expected at least one chunk, got zero")
}