diff --git a/internal/config/config.go b/internal/config/config.go index 72b2fd80..d8f5cb65 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -31,6 +31,7 @@ type Config struct { DevStreamURI string `json:"dev-stream-uri,omitempty" yaml:"dev-stream-uri,omitempty"` Environment string `json:"environment,omitempty" yaml:"environment,omitempty"` Flag string `json:"flag,omitempty" yaml:"flag,omitempty"` + InstallationID string `json:"-" yaml:"installation-id,omitempty"` Output string `json:"output,omitempty" yaml:"output,omitempty"` Project string `json:"project,omitempty" yaml:"project,omitempty"` } diff --git a/internal/config/installation_id.go b/internal/config/installation_id.go new file mode 100644 index 00000000..7f20e613 --- /dev/null +++ b/internal/config/installation_id.go @@ -0,0 +1,120 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" + "gopkg.in/yaml.v3" +) + +const installationIDKey = "installation-id" + +func EnsureInstallationID(filename string) (string, error) { + if strings.TrimSpace(filename) == "" { + return "", errors.New("config filename is required") + } + + values, mode, err := readConfigValues(filename) + if err != nil { + return "", err + } + + if value, ok := values[installationIDKey]; ok { + installationID, ok := value.(string) + if !ok || uuid.Validate(installationID) != nil { + return "", errors.New("ldcli installation ID in config is invalid") + } + + return installationID, nil + } + + installationID := uuid.NewString() + values[installationIDKey] = installationID + + if err := writeConfigValues(filename, values, mode); err != nil { + return "", err + } + + return installationID, nil +} + +func readConfigValues(filename string) (map[string]any, os.FileMode, error) { + data, err := os.ReadFile(filename) + if errors.Is(err, os.ErrNotExist) { + return map[string]any{}, 0o600, nil + } + if err != nil { + return nil, 0, fmt.Errorf("read ldcli config: %w", err) + } + + values := map[string]any{} + if err := yaml.Unmarshal(data, &values); err != nil { + return nil, 0, fmt.Errorf("parse ldcli config: %w", err) + } + + info, err := os.Stat(filename) + if err != nil { + return nil, 0, fmt.Errorf("stat ldcli config: %w", err) + } + + mode := info.Mode().Perm() + if mode == 0 { + mode = 0o600 + } + + return values, mode, nil +} + +func writeConfigValues( + filename string, + values map[string]any, + mode os.FileMode, +) error { + data, err := yaml.Marshal(values) + if err != nil { + return fmt.Errorf("marshal ldcli config: %w", err) + } + + dir := filepath.Dir(filename) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create ldcli config directory: %w", err) + } + + file, err := os.CreateTemp(dir, ".config-*.tmp") + if err != nil { + return fmt.Errorf("create temporary ldcli config: %w", err) + } + + tempName := file.Name() + defer func() { + _ = os.Remove(tempName) + }() + + if err := file.Chmod(mode); err != nil { + _ = file.Close() + + return fmt.Errorf("set ldcli config permissions: %w", err) + } + if _, err := file.Write(data); err != nil { + _ = file.Close() + + return fmt.Errorf("write ldcli config: %w", err) + } + if err := file.Sync(); err != nil { + _ = file.Close() + + return fmt.Errorf("sync ldcli config: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close ldcli config: %w", err) + } + if err := os.Rename(tempName, filename); err != nil { + return fmt.Errorf("replace ldcli config: %w", err) + } + + return nil +} diff --git a/internal/config/installation_id_test.go b/internal/config/installation_id_test.go new file mode 100644 index 00000000..6f1aea57 --- /dev/null +++ b/internal/config/installation_id_test.go @@ -0,0 +1,89 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestEnsureInstallationIDCreatesConfig(t *testing.T) { + filename := filepath.Join(t.TempDir(), "ldcli", "config.yml") + + installationID, err := EnsureInstallationID(filename) + + require.NoError(t, err) + require.NoError(t, uuid.Validate(installationID)) + + loaded, err := New(filename, os.ReadFile) + require.NoError(t, err) + assert.Equal(t, installationID, loaded.InstallationID) + + info, err := os.Stat(filename) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} + +func TestEnsureInstallationIDIsStableAndPreservesConfig(t *testing.T) { + filename := filepath.Join(t.TempDir(), "config.yml") + require.NoError(t, os.WriteFile( + filename, + []byte("access-token: token\nfuture-setting: value\n"), + 0o640, + )) + + first, err := EnsureInstallationID(filename) + require.NoError(t, err) + second, err := EnsureInstallationID(filename) + require.NoError(t, err) + assert.Equal(t, first, second) + + data, err := os.ReadFile(filename) + require.NoError(t, err) + + var values map[string]any + require.NoError(t, yaml.Unmarshal(data, &values)) + assert.Equal(t, "token", values["access-token"]) + assert.Equal(t, "value", values["future-setting"]) + assert.Equal(t, first, values[installationIDKey]) + + info, err := os.Stat(filename) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o640), info.Mode().Perm()) +} + +func TestEnsureInstallationIDUsesExistingValueWithoutRewriting(t *testing.T) { + filename := filepath.Join(t.TempDir(), "config.yml") + const existing = "installation-id: 45cb6eca-6c83-4db6-b171-174fd2fed588\n" + require.NoError(t, os.WriteFile(filename, []byte(existing), 0o600)) + + installationID, err := EnsureInstallationID(filename) + + require.NoError(t, err) + assert.Equal(t, "45cb6eca-6c83-4db6-b171-174fd2fed588", installationID) + + data, err := os.ReadFile(filename) + require.NoError(t, err) + assert.Equal(t, existing, string(data)) +} + +func TestEnsureInstallationIDRejectsInvalidValue(t *testing.T) { + filename := filepath.Join(t.TempDir(), "config.yml") + require.NoError(t, os.WriteFile(filename, []byte("installation-id: invalid\n"), 0o600)) + + _, err := EnsureInstallationID(filename) + + require.ErrorContains(t, err, "installation ID") +} + +func TestConfigJSONDoesNotExposeInstallationID(t *testing.T) { + data, err := json.Marshal(Config{InstallationID: uuid.NewString()}) + + require.NoError(t, err) + assert.JSONEq(t, `{}`, string(data)) +} diff --git a/internal/sync/repository/git.go b/internal/sync/repository/git.go new file mode 100644 index 00000000..ccdc824f --- /dev/null +++ b/internal/sync/repository/git.go @@ -0,0 +1,149 @@ +package repository + +import ( + "fmt" + "net" + "net/url" + "os/exec" + "strings" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +type GitRepository struct { + Root string + Source syncdomain.Source +} + +type gitRunner interface { + lookPath(name string) (string, error) + output(dir string, args ...string) (string, error) +} + +type execGit struct{} + +func (execGit) lookPath(name string) (string, error) { + return exec.LookPath(name) +} + +func (execGit) output(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return "", err + } + + return strings.TrimSpace(string(out)), nil +} + +func FindGitSource(dir string) (GitRepository, bool, error) { + return findGitSource(execGit{}, dir) +} + +func findGitSource(git gitRunner, dir string) (GitRepository, bool, error) { + if _, err := git.lookPath("git"); err != nil { + return GitRepository{}, false, nil + } + + root, err := git.output(dir, "rev-parse", "--show-toplevel") + if err != nil { + return GitRepository{}, false, nil + } + + origin, err := git.output(root, "config", "--local", "--get", "remote.origin.url") + if err != nil || origin == "" { + return GitRepository{}, false, nil + } + + identifier, err := gitSourceIdentifier(origin) + if err != nil { + return GitRepository{}, false, err + } + + source, err := syncdomain.NewSource(syncdomain.SourceTypeGit, identifier) + if err != nil { + return GitRepository{}, false, err + } + + return GitRepository{Root: root, Source: source}, true, nil +} + +func gitSourceIdentifier(remote string) (string, error) { + remote = strings.TrimSpace(remote) + + var host, repoPath string + + if strings.Contains(remote, "://") { + parsed, err := url.Parse(remote) + if err != nil || parsed.Host == "" { + return "", invalidGitRemote(remote) + } + + host = normalizedURLHost(parsed) + repoPath = parsed.Path + } else { + remoteHost, remotePath, ok := strings.Cut(remote, ":") + if !ok { + return "", invalidGitRemote(remote) + } + + if _, value, ok := strings.Cut(remoteHost, "@"); ok { + remoteHost = value + } + + host = strings.ToLower(strings.TrimSpace(remoteHost)) + repoPath = remotePath + } + + repoPath = strings.TrimSuffix(strings.Trim(repoPath, "/"), ".git") + if host == "" || !validRepositoryPath(repoPath) { + return "", invalidGitRemote(remote) + } + + return host + "/" + repoPath, nil +} + +func normalizedURLHost(remote *url.URL) string { + host := strings.ToLower(remote.Hostname()) + port := remote.Port() + if port == "" || isDefaultPort(remote.Scheme, port) { + return host + } + + return net.JoinHostPort(host, port) +} + +func isDefaultPort(scheme, port string) bool { + switch strings.ToLower(scheme) { + case "http": + return port == "80" + case "https": + return port == "443" + case "ssh": + return port == "22" + case "git": + return port == "9418" + default: + return false + } +} + +func validRepositoryPath(repoPath string) bool { + parts := strings.Split(repoPath, "/") + if len(parts) < 2 { + return false + } + + for _, part := range parts { + if part == "" || part == "." || part == ".." { + return false + } + } + + return true +} + +func invalidGitRemote(remote string) error { + return fmt.Errorf("cannot derive source identifier from Git origin %q", remote) +} diff --git a/internal/sync/repository/git_test.go b/internal/sync/repository/git_test.go new file mode 100644 index 00000000..ba8e7131 --- /dev/null +++ b/internal/sync/repository/git_test.go @@ -0,0 +1,135 @@ +package repository + +import ( + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +func TestGitSourceIdentifier(t *testing.T) { + tests := map[string]string{ + "git@github.com:launchdarkly/ldcli.git": "github.com/launchdarkly/ldcli", + "https://github.com/launchdarkly/ldcli.git": "github.com/launchdarkly/ldcli", + "https://github.com:443/launchdarkly/ldcli.git": "github.com/launchdarkly/ldcli", + "ssh://git@github.com/launchdarkly/ldcli.git": "github.com/launchdarkly/ldcli", + "ssh://git@git.example.com:2222/platform/team/service": "git.example.com:2222/platform/team/service", + } + + for remote, expected := range tests { + t.Run(remote, func(t *testing.T) { + actual, err := gitSourceIdentifier(remote) + require.NoError(t, err) + assert.Equal(t, expected, actual) + }) + } +} + +func TestGitSourceIdentifierRejectsInvalidRemote(t *testing.T) { + for _, remote := range []string{ + "", + "ldcli", + "file:///workspace/launchdarkly/ldcli", + "https://github.com/ldcli", + "https://github.com/org/../repo", + } { + t.Run(remote, func(t *testing.T) { + _, err := gitSourceIdentifier(remote) + require.Error(t, err) + }) + } +} + +func TestFindGitSource(t *testing.T) { + repository, found, err := findGitSource(stubGit{ + commands: map[string]string{ + "rev-parse --show-toplevel": "/workspace", + "config --local --get remote.origin.url": "git@github.com:Acme/Widgets.git", + }, + }, "/workspace/service") + + require.NoError(t, err) + require.True(t, found) + assert.Equal(t, "/workspace", repository.Root) + assert.Equal(t, syncdomain.SourceTypeGit, repository.Source.Type()) + assert.Equal(t, "github.com/Acme/Widgets", repository.Source.Identifier()) +} + +func TestFindGitSourceFallsBackWhenGitIdentityIsUnavailable(t *testing.T) { + tests := []struct { + name string + git stubGit + }{ + { + name: "git is not installed", + git: stubGit{pathErr: errors.New("not found")}, + }, + { + name: "workspace is not a repository", + git: stubGit{ + errors: map[string]error{"rev-parse --show-toplevel": errors.New("not a repository")}, + }, + }, + { + name: "repository has no origin", + git: stubGit{ + commands: map[string]string{"rev-parse --show-toplevel": "/workspace"}, + errors: map[string]error{ + "config --local --get remote.origin.url": errors.New("missing"), + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + repository, found, err := findGitSource(test.git, "/workspace") + + require.NoError(t, err) + assert.False(t, found) + assert.Empty(t, repository) + }) + } +} + +func TestFindGitSourceRejectsInvalidOrigin(t *testing.T) { + _, found, err := findGitSource(stubGit{ + commands: map[string]string{ + "rev-parse --show-toplevel": "/workspace", + "config --local --get remote.origin.url": "invalid", + }, + }, "/workspace") + + require.Error(t, err) + assert.False(t, found) +} + +type stubGit struct { + pathErr error + commands map[string]string + errors map[string]error +} + +func (s stubGit) lookPath(string) (string, error) { + if s.pathErr != nil { + return "", s.pathErr + } + + return "/usr/bin/git", nil +} + +func (s stubGit) output(_ string, args ...string) (string, error) { + key := strings.Join(args, " ") + if err, ok := s.errors[key]; ok { + return "", err + } + if output, ok := s.commands[key]; ok { + return output, nil + } + + return "", errors.New("unexpected git " + key) +} diff --git a/internal/sync/source.go b/internal/sync/source.go new file mode 100644 index 00000000..cc3dea7f --- /dev/null +++ b/internal/sync/source.go @@ -0,0 +1,49 @@ +package sync + +import ( + "errors" + "strings" +) + +const MaxSourceIdentifierLength = 512 + +type SourceType string + +const ( + SourceTypeGit SourceType = "git" + SourceTypeLocal SourceType = "local" +) + +var ( + ErrInvalidSourceType = errors.New("invalid source type") + ErrInvalidSourceIdentifier = errors.New("invalid source identifier") +) + +type Source struct { + sourceType SourceType + identifier string +} + +func NewSource(sourceType SourceType, identifier string) (Source, error) { + if sourceType != SourceTypeGit && sourceType != SourceTypeLocal { + return Source{}, ErrInvalidSourceType + } + if identifier == "" || + identifier != strings.TrimSpace(identifier) || + len(identifier) > MaxSourceIdentifierLength { + return Source{}, ErrInvalidSourceIdentifier + } + + return Source{ + sourceType: sourceType, + identifier: identifier, + }, nil +} + +func (source Source) Type() SourceType { + return source.sourceType +} + +func (source Source) Identifier() string { + return source.identifier +} diff --git a/internal/sync/source/resolver.go b/internal/sync/source/resolver.go new file mode 100644 index 00000000..27a0e078 --- /dev/null +++ b/internal/sync/source/resolver.go @@ -0,0 +1,112 @@ +package source + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + + "github.com/launchdarkly/ldcli/internal/config" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + "github.com/launchdarkly/ldcli/internal/sync/repository" +) + +type Workspace struct { + Root string + Source syncdomain.Source +} + +type Resolver struct { + configFile string + findGitSource func(string) (repository.GitRepository, bool, error) + ensureInstallationID func(string) (string, error) +} + +func NewResolver(configFile string) Resolver { + return Resolver{ + configFile: configFile, + findGitSource: repository.FindGitSource, + ensureInstallationID: config.EnsureInstallationID, + } +} + +func (resolver Resolver) Resolve(dir string) (Workspace, error) { + gitRepository, found, err := resolver.findGitSource(dir) + if err != nil { + return Workspace{}, err + } + if found { + root, err := canonicalPath(gitRepository.Root) + if err != nil { + return Workspace{}, err + } + + return Workspace{ + Root: root, + Source: gitRepository.Source, + }, nil + } + + root, err := localWorkspaceRoot(dir) + if err != nil { + return Workspace{}, err + } + + installationID, err := resolver.ensureInstallationID(resolver.configFile) + if err != nil { + return Workspace{}, err + } + + source, err := syncdomain.NewSource( + syncdomain.SourceTypeLocal, + localSourceIdentifier(installationID, root), + ) + if err != nil { + return Workspace{}, err + } + + return Workspace{Root: root, Source: source}, nil +} + +func localWorkspaceRoot(dir string) (string, error) { + root, err := canonicalPath(dir) + if err != nil { + return "", err + } + + for current := root; ; current = filepath.Dir(current) { + info, err := os.Stat(filepath.Join(current, syncdomain.RootDir)) + switch { + case err == nil && info.IsDir(): + return current, nil + case err != nil && !os.IsNotExist(err): + return "", fmt.Errorf("inspect workspace root: %w", err) + } + + parent := filepath.Dir(current) + if parent == current { + return root, nil + } + } +} + +func canonicalPath(path string) (string, error) { + absolute, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("resolve absolute workspace path: %w", err) + } + + resolved, err := filepath.EvalSymlinks(absolute) + if err != nil { + return "", fmt.Errorf("resolve workspace symlinks: %w", err) + } + + return filepath.Clean(resolved), nil +} + +func localSourceIdentifier(installationID, root string) string { + sum := sha256.Sum256([]byte(installationID + "\x00" + root)) + + return "sha256." + hex.EncodeToString(sum[:]) +} diff --git a/internal/sync/source/resolver_test.go b/internal/sync/source/resolver_test.go new file mode 100644 index 00000000..838ec046 --- /dev/null +++ b/internal/sync/source/resolver_test.go @@ -0,0 +1,154 @@ +package source + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + "github.com/launchdarkly/ldcli/internal/sync/repository" +) + +func TestResolverUsesGitSourceWhenAvailable(t *testing.T) { + root := t.TempDir() + gitSource, err := syncdomain.NewSource( + syncdomain.SourceTypeGit, + "github.com/launchdarkly/ldcli", + ) + require.NoError(t, err) + + resolver := NewResolver(filepath.Join(t.TempDir(), "config.yml")) + resolver.findGitSource = func(string) (repository.GitRepository, bool, error) { + return repository.GitRepository{Root: root, Source: gitSource}, true, nil + } + resolver.ensureInstallationID = func(string) (string, error) { + t.Fatal("Git source must not create an installation ID") + + return "", nil + } + + workspace, err := resolver.Resolve(filepath.Join(root, "nested")) + + require.NoError(t, err) + assert.Equal(t, requireCanonicalPath(t, root), workspace.Root) + assert.Equal(t, gitSource, workspace.Source) +} + +func TestResolverUsesStableLocalSourceFromWorkspaceRoot(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(root, syncdomain.RootDir), 0o755)) + nested := filepath.Join(root, "services", "api") + require.NoError(t, os.MkdirAll(nested, 0o755)) + + resolver := localResolver("installation-id") + + first, err := resolver.Resolve(nested) + require.NoError(t, err) + second, err := resolver.Resolve(root) + require.NoError(t, err) + + expectedRoot := requireCanonicalPath(t, root) + assert.Equal(t, expectedRoot, first.Root) + assert.Equal(t, first, second) + assert.Equal(t, syncdomain.SourceTypeLocal, first.Source.Type()) + assert.Equal( + t, + localSourceIdentifier("installation-id", expectedRoot), + first.Source.Identifier(), + ) + assert.NotContains(t, first.Source.Identifier(), expectedRoot) + + _, err = os.Stat(filepath.Join(root, syncdomain.RootDir, "source.yaml")) + assert.ErrorIs(t, err, os.ErrNotExist) +} + +func TestResolverUsesCurrentDirectoryBeforeBootstrap(t *testing.T) { + root := t.TempDir() + resolver := localResolver("installation-id") + + workspace, err := resolver.Resolve(root) + + require.NoError(t, err) + expectedRoot := requireCanonicalPath(t, root) + assert.Equal(t, expectedRoot, workspace.Root) + assert.Equal( + t, + localSourceIdentifier("installation-id", expectedRoot), + workspace.Source.Identifier(), + ) +} + +func TestResolverCanonicalizesSymlinkedWorkspace(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(root, syncdomain.RootDir), 0o755)) + + link := filepath.Join(t.TempDir(), "workspace") + require.NoError(t, os.Symlink(root, link)) + + workspace, err := localResolver("installation-id").Resolve(link) + + require.NoError(t, err) + expectedRoot := requireCanonicalPath(t, root) + assert.Equal(t, expectedRoot, workspace.Root) + assert.Equal( + t, + localSourceIdentifier("installation-id", expectedRoot), + workspace.Source.Identifier(), + ) +} + +func TestResolverChangesLocalIdentityWhenWorkspaceMoves(t *testing.T) { + first, err := localResolver("installation-id").Resolve(t.TempDir()) + require.NoError(t, err) + second, err := localResolver("installation-id").Resolve(t.TempDir()) + require.NoError(t, err) + + assert.NotEqual(t, first.Source.Identifier(), second.Source.Identifier()) +} + +func TestResolverReturnsGitAndConfigErrors(t *testing.T) { + t.Run("invalid Git source", func(t *testing.T) { + resolver := localResolver("installation-id") + resolver.findGitSource = func(string) (repository.GitRepository, bool, error) { + return repository.GitRepository{}, false, errors.New("invalid origin") + } + + _, err := resolver.Resolve(t.TempDir()) + require.ErrorContains(t, err, "invalid origin") + }) + + t.Run("installation ID", func(t *testing.T) { + resolver := localResolver("installation-id") + resolver.ensureInstallationID = func(string) (string, error) { + return "", errors.New("config unavailable") + } + + _, err := resolver.Resolve(t.TempDir()) + require.ErrorContains(t, err, "config unavailable") + }) +} + +func localResolver(installationID string) Resolver { + resolver := NewResolver("config.yml") + resolver.findGitSource = func(string) (repository.GitRepository, bool, error) { + return repository.GitRepository{}, false, nil + } + resolver.ensureInstallationID = func(string) (string, error) { + return installationID, nil + } + + return resolver +} + +func requireCanonicalPath(t *testing.T, path string) string { + t.Helper() + + resolved, err := canonicalPath(path) + require.NoError(t, err) + + return resolved +} diff --git a/internal/sync/source_test.go b/internal/sync/source_test.go new file mode 100644 index 00000000..7ed011a8 --- /dev/null +++ b/internal/sync/source_test.go @@ -0,0 +1,48 @@ +package sync + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewSource(t *testing.T) { + for _, sourceType := range []SourceType{SourceTypeGit, SourceTypeLocal} { + t.Run(string(sourceType), func(t *testing.T) { + source, err := NewSource(sourceType, "source-identifier") + + require.NoError(t, err) + assert.Equal(t, sourceType, source.Type()) + assert.Equal(t, "source-identifier", source.Identifier()) + }) + } +} + +func TestNewSourceRejectsInvalidValues(t *testing.T) { + tests := []struct { + name string + sourceType SourceType + identifier string + wantErr error + }{ + {"invalid type", "filesystem", "local/project", ErrInvalidSourceType}, + {"missing identifier", SourceTypeGit, "", ErrInvalidSourceIdentifier}, + {"blank identifier", SourceTypeGit, " ", ErrInvalidSourceIdentifier}, + {"surrounding whitespace", SourceTypeGit, " source ", ErrInvalidSourceIdentifier}, + { + "identifier too long", + SourceTypeGit, + strings.Repeat("a", MaxSourceIdentifierLength+1), + ErrInvalidSourceIdentifier, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := NewSource(test.sourceType, test.identifier) + assert.ErrorIs(t, err, test.wantErr) + }) + } +}