Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ded65f8
docs: add spec and implementation plan for dynamic repository scoping
jamestelfer Apr 14, 2026
d533a6c
feat: extend RepositoryScope with CallerScoped state
jamestelfer Apr 14, 2026
2d5301e
refactor: store compiled RepositoryScope in OrganizationProfileAttr
jamestelfer Apr 14, 2026
d10719a
feat: recognise {{caller-scoped-repository}} and {{all-repositories}}…
jamestelfer Apr 14, 2026
268928e
feat: extract and validate repository-scope query parameter
jamestelfer Apr 14, 2026
4d77f85
refactor: thread repositoryScope parameter through vendor chain
jamestelfer Apr 14, 2026
6105fd0
feat: implement bidirectional repository scoping validation
jamestelfer Apr 14, 2026
f43acd1
feat: git-credentials endpoint derives scope from request URL
jamestelfer Apr 14, 2026
9e367c8
feat: include repository name in cache key for caller-scoped profiles
jamestelfer Apr 14, 2026
d2c98d9
test: verify audit logging captures scoping mismatch errors
jamestelfer Apr 15, 2026
4549774
test: add integration tests for dynamic repository scoping
jamestelfer Apr 15, 2026
8644767
fix: address lint issues from dynamic scoping implementation
jamestelfer Apr 15, 2026
35b51d5
test: add fuzz tests for repository scoping input boundaries
jamestelfer Apr 15, 2026
e40aa4b
fix: return 403 for GitHub token creation failures to prevent repo en…
jamestelfer Apr 21, 2026
aad7b43
refactor: add ScopedRepository to ProfileRef
jamestelfer Apr 21, 2026
c337b0a
refactor: thread ProfileRefBuilder through handlers
jamestelfer Apr 21, 2026
4d6b7c4
refactor: validate repository scope in profile ref builder
jamestelfer Apr 21, 2026
285c20c
refactor: rely on ref.String() for cache key distinctness
jamestelfer Apr 21, 2026
fe81435
docs: add plan for ProfileRef-carried scope refactor
jamestelfer Apr 22, 2026
0e4dc7c
refactor: remove repositoryScope from vendor signature
jamestelfer Apr 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ fuzz: mod
@go test -tags=fuzz -fuzz=Fuzz -run=^$$ -fuzztime=$(FUZZING_LOCAL_SECS)s ./internal/credentialhandler
@echo "Fuzzing internal/jwt..."
@go test -tags=fuzz -fuzz=Fuzz -run=^$$ -fuzztime=$(FUZZING_LOCAL_SECS)s ./internal/jwt
@echo "Fuzzing internal/profile..."
@go test -tags=fuzz -fuzz=Fuzz -run=^$$ -fuzztime=$(FUZZING_LOCAL_SECS)s ./internal/profile
@echo "Fuzzing internal/vendor..."
@go test -tags=fuzz -fuzz=Fuzz -run=^$$ -fuzztime=$(FUZZING_LOCAL_SECS)s ./internal/vendor
@echo "Fuzzing handlers..."
@go test -tags=fuzz -fuzz=Fuzz -run=^$$ -fuzztime=$(FUZZING_LOCAL_SECS)s .

# CI targets - output coverage.out for codecov
.PHONY: ci-unit
Expand All @@ -52,6 +58,12 @@ ci-fuzz: mod
@go test -tags=fuzz -fuzz=Fuzz -run=^$$ -fuzztime=$(FUZZING_CI_SECS)s ./internal/credentialhandler
@echo "Fuzzing internal/jwt..."
@go test -tags=fuzz -fuzz=Fuzz -run=^$$ -fuzztime=$(FUZZING_CI_SECS)s ./internal/jwt
@echo "Fuzzing internal/profile..."
@go test -tags=fuzz -fuzz=Fuzz -run=^$$ -fuzztime=$(FUZZING_CI_SECS)s ./internal/profile
@echo "Fuzzing internal/vendor..."
@go test -tags=fuzz -fuzz=Fuzz -run=^$$ -fuzztime=$(FUZZING_CI_SECS)s ./internal/vendor
@echo "Fuzzing handlers..."
@go test -tags=fuzz -fuzz=Fuzz -run=^$$ -fuzztime=$(FUZZING_CI_SECS)s .

dist:
mkdir -p dist
Expand Down
163 changes: 163 additions & 0 deletions api_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"testing"

"github.com/chinmina/chinmina-bridge/internal/jwt/jwxtest"
"github.com/chinmina/chinmina-bridge/internal/profile"
"github.com/chinmina/chinmina-bridge/internal/profile/profiletest"
"github.com/chinmina/chinmina-bridge/internal/testhelpers"
"github.com/lestrrat-go/jwx/v3/jwt"
Expand Down Expand Up @@ -642,3 +643,165 @@ func TestIntegrationBasePath(t *testing.T) {
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
})
}

// ============================================================================
// Dynamic Repository Scoping Tests
// ============================================================================

func TestIntegrationOrganizationToken_CallerScoped_Success(t *testing.T) {
harness := NewAPITestHarness(t)

yamlContent, err := os.ReadFile("testdata/org-profiles-scoped.yaml")
require.NoError(t, err)

profiles, err := profiletest.CompileFromYAML(string(yamlContent))
require.NoError(t, err)
harness.ProfileStore.Update(t.Context(), profiles)

harness.GitHubMock.Token = "ghs_scoped_token"

token := harness.PipelineToken()

result, err := harness.Client().OrganizationTokenScoped(token, "caller-scoped-profile", "my-repo")
require.NoError(t, err)

assert.Equal(t, "ghs_scoped_token", result.Token)
// Scoped URN short form: org:<profile>/<repo> — the ref now carries
// ScopedRepository, so downstream rendering picks it up automatically.
assert.Equal(t, "org:caller-scoped-profile/my-repo", result.Profile)
assert.Equal(t, profile.NewSpecificScope("my-repo"), result.Repositories)
}

func TestIntegrationOrganizationToken_CallerScoped_MissingScope(t *testing.T) {
harness := NewAPITestHarness(t)

yamlContent, err := os.ReadFile("testdata/org-profiles-scoped.yaml")
require.NoError(t, err)

profiles, err := profiletest.CompileFromYAML(string(yamlContent))
require.NoError(t, err)
harness.ProfileStore.Update(t.Context(), profiles)

token := harness.PipelineToken()

// Call without repository-scope — should fail
_, err = harness.Client().OrganizationTokenScoped(token, "caller-scoped-profile", "")
require.Error(t, err)

var apiErr *APIError
require.ErrorAs(t, err, &apiErr)
assert.Equal(t, http.StatusBadRequest, apiErr.StatusCode)
}

func TestIntegrationOrganizationToken_StaticProfile_RejectsScope(t *testing.T) {
harness := NewAPITestHarness(t)

yamlContent, err := os.ReadFile("testdata/org-profiles-scoped.yaml")
require.NoError(t, err)

profiles, err := profiletest.CompileFromYAML(string(yamlContent))
require.NoError(t, err)
harness.ProfileStore.Update(t.Context(), profiles)

token := harness.PipelineToken()

// Provide repository-scope to a static-list profile — should fail
_, err = harness.Client().OrganizationTokenScoped(token, "static-profile", "unwanted-scope")
require.Error(t, err)

var apiErr *APIError
require.ErrorAs(t, err, &apiErr)
assert.Equal(t, http.StatusBadRequest, apiErr.StatusCode)
}

func TestIntegrationOrganizationToken_AllRepos_RejectsScope(t *testing.T) {
harness := NewAPITestHarness(t)

yamlContent, err := os.ReadFile("testdata/org-profiles-scoped.yaml")
require.NoError(t, err)

profiles, err := profiletest.CompileFromYAML(string(yamlContent))
require.NoError(t, err)
harness.ProfileStore.Update(t.Context(), profiles)

token := harness.PipelineToken()

// Provide repository-scope to an all-repos profile — should fail
_, err = harness.Client().OrganizationTokenScoped(token, "all-repos-profile", "unwanted-scope")
require.Error(t, err)

var apiErr *APIError
require.ErrorAs(t, err, &apiErr)
assert.Equal(t, http.StatusBadRequest, apiErr.StatusCode)
}

func TestIntegrationOrganizationToken_InvalidScope_Slash(t *testing.T) {
harness := NewAPITestHarness(t)

yamlContent, err := os.ReadFile("testdata/org-profiles-scoped.yaml")
require.NoError(t, err)

profiles, err := profiletest.CompileFromYAML(string(yamlContent))
require.NoError(t, err)
harness.ProfileStore.Update(t.Context(), profiles)

token := harness.PipelineToken()

// Provide invalid repository-scope with slash — should fail with 400
_, err = harness.Client().OrganizationTokenScoped(token, "caller-scoped-profile", "owner/repo")
require.Error(t, err)

var apiErr *APIError
require.ErrorAs(t, err, &apiErr)
assert.Equal(t, http.StatusBadRequest, apiErr.StatusCode)
}

func TestIntegrationOrganizationGitCredentials_CallerScoped_Success(t *testing.T) {
harness := NewAPITestHarness(t)

yamlContent, err := os.ReadFile("testdata/org-profiles-scoped.yaml")
require.NoError(t, err)

profiles, err := profiletest.CompileFromYAML(string(yamlContent))
require.NoError(t, err)
harness.ProfileStore.Update(t.Context(), profiles)

harness.GitHubMock.Token = "ghs_scoped_creds"

token := harness.PipelineToken()

// Git-credentials derives scope from the request body (path field)
props, err := harness.Client().OrganizationGitCredentials(token, "caller-scoped-profile", GitCredentialRequest{
Protocol: "https",
Host: "github.com",
Path: "test-org/target-repo",
})
require.NoError(t, err)

assert.Equal(t, "ghs_scoped_creds", props.Get("password"))
assert.Equal(t, "test-org/target-repo", props.Get("path"))
}

func TestIntegrationOrganizationGitCredentials_AllRepos_Success(t *testing.T) {
harness := NewAPITestHarness(t)

yamlContent, err := os.ReadFile("testdata/org-profiles-scoped.yaml")
require.NoError(t, err)

profiles, err := profiletest.CompileFromYAML(string(yamlContent))
require.NoError(t, err)
harness.ProfileStore.Update(t.Context(), profiles)

harness.GitHubMock.Token = "ghs_allrepos_creds"

token := harness.PipelineToken()

props, err := harness.Client().OrganizationGitCredentials(token, "all-repos-profile", GitCredentialRequest{
Protocol: "https",
Host: "github.com",
Path: "test-org/any-repo",
})
require.NoError(t, err)

assert.Equal(t, "ghs_allrepos_creds", props.Get("password"))
}
Loading
Loading