From ead48ff121b63df2c754b787368168ba9f5f7b71 Mon Sep 17 00:00:00 2001 From: Beon de Nood Date: Fri, 22 May 2026 09:35:09 -0400 Subject: [PATCH 1/4] ci: trigger E2E tests on merge to main After CI passes on a push to main, dispatch a repository_dispatch event to capiscio/capiscio-e2e-tests so cross-product E2E tests run within minutes instead of waiting for the daily 6am cron. --- .github/workflows/ci.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e95b536..4b8259e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,3 +120,17 @@ jobs: with: name: gosec-results path: gosec-results.json + + trigger-e2e: + name: Trigger E2E Tests + needs: [test] + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - name: Dispatch E2E workflow + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.REPO_ACCESS_TOKEN }} + repository: capiscio/capiscio-e2e-tests + event-type: upstream-merge + client-payload: '{"repo": "capiscio-core", "sha": "${{ github.sha }}"}' From d1915e3eca4b5d560674d12984ccd88397c8dded Mon Sep 17 00:00:00 2001 From: Beon de Nood Date: Fri, 22 May 2026 09:47:03 -0400 Subject: [PATCH 2/4] test: implement security verification tests and improve test isolation - Implement TestBadgeVerificationExpired: locally-signed badge with past expiry correctly rejected with BADGE_EXPIRED - Implement TestBadgeVerificationRevoked: badge with JTI in mock revocation list correctly rejected with BADGE_REVOKED - Implement TestBadgeVerificationSelfSigned: did:key self-signed badge rejected without AcceptSelfSigned (BADGE_ISSUER_UNTRUSTED), accepted with it - Refactor TestMain to not block all tests when server unavailable; add requireServer(t) helper so server-dependent tests skip gracefully - Add requireServer(t) to all DV, PoP, and data-plane tests that hit the API These security tests run without a live server or Clerk auth, making them suitable for CI and local development. --- tests/integration/badge_verification_test.go | 181 +++++++++++++++++-- tests/integration/data_plane_test.go | 7 + tests/integration/dv_order_test.go | 2 + tests/integration/pop_challenge_test.go | 4 + tests/integration/setup_test.go | 25 ++- 5 files changed, 196 insertions(+), 23 deletions(-) diff --git a/tests/integration/badge_verification_test.go b/tests/integration/badge_verification_test.go index f56df53..2567c56 100644 --- a/tests/integration/badge_verification_test.go +++ b/tests/integration/badge_verification_test.go @@ -2,16 +2,55 @@ package integration import ( "context" + "crypto" + "crypto/ed25519" + "crypto/rand" "os" "testing" "time" "github.com/capiscio/capiscio-core/v2/pkg/badge" + "github.com/capiscio/capiscio-core/v2/pkg/did" "github.com/capiscio/capiscio-core/v2/pkg/registry" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// mockRegistry is a simple in-memory registry for security verification tests. +type mockRegistry struct { + keys map[string]crypto.PublicKey + revokedBadges map[string]bool +} + +func (m *mockRegistry) GetPublicKey(ctx context.Context, issuer string) (crypto.PublicKey, error) { + if key, ok := m.keys[issuer]; ok { + return key, nil + } + return nil, assert.AnError +} + +func (m *mockRegistry) IsRevoked(ctx context.Context, id string) (bool, error) { + if m.revokedBadges != nil { + return m.revokedBadges[id], nil + } + return false, nil +} + +func (m *mockRegistry) GetBadgeStatus(ctx context.Context, issuerURL string, jti string) (*registry.BadgeStatus, error) { + if m.revokedBadges != nil && m.revokedBadges[jti] { + return ®istry.BadgeStatus{JTI: jti, Revoked: true}, nil + } + return ®istry.BadgeStatus{JTI: jti, Revoked: false}, nil +} + +func (m *mockRegistry) GetAgentStatus(ctx context.Context, issuerURL string, agentID string) (*registry.AgentStatus, error) { + return ®istry.AgentStatus{ID: agentID, Status: registry.AgentStatusActive}, nil +} + +func (m *mockRegistry) SyncRevocations(ctx context.Context, issuerURL string, since time.Time) ([]registry.Revocation, error) { + return nil, nil +} + // TestBadgeVerification tests badge verification against live JWKS (Task 3) // NOTE: These tests require Clerk authentication to issue badges first. // Use the DV flow (test_dv_badge_flow.py) for local integration tests. @@ -130,33 +169,145 @@ func TestBadgeVerificationWithOptions(t *testing.T) { } // TestBadgeVerificationExpired tests expired badge rejection (Task 3) +// This test does not require Clerk auth — it signs badges locally. func TestBadgeVerificationExpired(t *testing.T) { - t.Skip("Requires short TTL and waiting - implement when needed") + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + issuerDID := "did:web:test-registry.capisc.io" + reg := &mockRegistry{ + keys: map[string]crypto.PublicKey{issuerDID: pub}, + } + verifier := badge.NewVerifier(reg) + + // Create a badge that expired 10 minutes ago + now := time.Now() + claims := &badge.Claims{ + JTI: "expired-badge-001", + Issuer: issuerDID, + Subject: "did:web:test-registry.capisc.io:agents:expired-test", + IssuedAt: now.Add(-1 * time.Hour).Unix(), + Expiry: now.Add(-10 * time.Minute).Unix(), + VC: badge.VerifiableCredential{ + Type: []string{"VerifiableCredential", "AgentIdentity"}, + CredentialSubject: badge.CredentialSubject{ + Domain: "expired.example.com", + Level: "1", + }, + }, + } + + token, err := badge.SignBadge(claims, priv) + require.NoError(t, err, "signing expired badge should succeed") + + _, err = verifier.Verify(context.Background(), token) + require.Error(t, err, "expired badge must be rejected") - // TODO: Implement expired badge test - // 1. Issue badge with 1-second TTL - // 2. Wait 2 seconds - // 3. Verify - should fail with expiry error + errCode := badge.GetErrorCode(err) + assert.Equal(t, badge.ErrCodeExpired, errCode, + "error code must be BADGE_EXPIRED, got: %s (%v)", errCode, err) } // TestBadgeVerificationRevoked tests revoked badge rejection (Task 3) +// This test does not require Clerk auth — it signs badges locally and +// uses a mock registry with the badge JTI marked as revoked. func TestBadgeVerificationRevoked(t *testing.T) { - t.Skip("Requires revocation implementation - will test in Task 7") + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + const revokedJTI = "revoked-badge-001" + issuerDID := "did:web:test-registry.capisc.io" + reg := &mockRegistry{ + keys: map[string]crypto.PublicKey{issuerDID: pub}, + revokedBadges: map[string]bool{revokedJTI: true}, + } + verifier := badge.NewVerifier(reg) + + now := time.Now() + claims := &badge.Claims{ + JTI: revokedJTI, + Issuer: issuerDID, + Subject: "did:web:test-registry.capisc.io:agents:revoked-test", + IssuedAt: now.Unix(), + Expiry: now.Add(1 * time.Hour).Unix(), + VC: badge.VerifiableCredential{ + Type: []string{"VerifiableCredential", "AgentIdentity"}, + CredentialSubject: badge.CredentialSubject{ + Domain: "revoked.example.com", + Level: "1", + }, + }, + } + + token, err := badge.SignBadge(claims, priv) + require.NoError(t, err, "signing revoked badge should succeed") - // TODO: Implement revoked badge test - // 1. Issue badge - // 2. Revoke badge via API - // 3. Verify - should fail with revocation error + _, err = verifier.Verify(context.Background(), token) + require.Error(t, err, "revoked badge must be rejected") + + errCode := badge.GetErrorCode(err) + assert.Equal(t, badge.ErrCodeRevoked, errCode, + "error code must be BADGE_REVOKED, got: %s (%v)", errCode, err) } // TestBadgeVerificationSelfSigned tests self-signed badge rejection (Task 3) +// A did:key badge with AcceptSelfSigned=false MUST be rejected. +// With AcceptSelfSigned=true it MUST be accepted (level 0 only). func TestBadgeVerificationSelfSigned(t *testing.T) { - t.Skip("Requires self-signed badge generation - implement when needed") + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + didKey := did.NewKeyDID(pub) - // TODO: Implement self-signed badge test - // 1. Generate did:key badge locally - // 2. Verify without AcceptSelfSigned - should fail - // 3. Verify with AcceptSelfSigned=true - should succeed + reg := &mockRegistry{ + keys: map[string]crypto.PublicKey{}, + } + verifier := badge.NewVerifier(reg) + + now := time.Now() + claims := &badge.Claims{ + JTI: "self-signed-badge-001", + Issuer: didKey, + Subject: didKey, // iss == sub for self-signed + IssuedAt: now.Unix(), + Expiry: now.Add(1 * time.Hour).Unix(), + VC: badge.VerifiableCredential{ + Type: []string{"VerifiableCredential", "AgentIdentity"}, + CredentialSubject: badge.CredentialSubject{ + Domain: "self-signed.example.com", + Level: "0", + }, + }, + } + + token, err := badge.SignBadge(claims, priv) + require.NoError(t, err) + + t.Run("rejected_without_AcceptSelfSigned", func(t *testing.T) { + opts := badge.VerifyOptions{ + Mode: badge.VerifyModeOffline, + AcceptSelfSigned: false, + SkipRevocationCheck: true, + SkipAgentStatusCheck: true, + } + _, err := verifier.VerifyWithOptions(context.Background(), token, opts) + require.Error(t, err, "self-signed badge must be rejected when AcceptSelfSigned=false") + + errCode := badge.GetErrorCode(err) + assert.Equal(t, badge.ErrCodeIssuerUntrusted, errCode, + "error code must be BADGE_ISSUER_UNTRUSTED, got: %s (%v)", errCode, err) + }) + + t.Run("accepted_with_AcceptSelfSigned", func(t *testing.T) { + opts := badge.VerifyOptions{ + Mode: badge.VerifyModeOffline, + AcceptSelfSigned: true, + } + result, err := verifier.VerifyWithOptions(context.Background(), token, opts) + require.NoError(t, err, "self-signed badge must be accepted with AcceptSelfSigned=true") + assert.Equal(t, didKey, result.Claims.Issuer) + assert.Equal(t, "0", result.Claims.TrustLevel()) + }) } // TestBadgeVerificationOfflineMode tests offline verification (Task 3) diff --git a/tests/integration/data_plane_test.go b/tests/integration/data_plane_test.go index 74fb651..ed852e9 100644 --- a/tests/integration/data_plane_test.go +++ b/tests/integration/data_plane_test.go @@ -48,6 +48,7 @@ func bundleURL() string { // TestDataPlane_BundleClientFetch verifies the BundleClient can pull a real // bundle from the server and the response contains valid Rego modules. func TestDataPlane_BundleClientFetch(t *testing.T) { + requireServer(t) client, err := pdp.NewBundleClient(bundleURL(), testAPIKey()) require.NoError(t, err) @@ -66,6 +67,7 @@ func TestDataPlane_BundleClientFetch(t *testing.T) { // TestDataPlane_OPALocalClientEvaluatesBundle verifies that a bundle fetched // from the live server can be loaded and evaluated by OPALocalClient. func TestDataPlane_OPALocalClientEvaluatesBundle(t *testing.T) { + requireServer(t) client, err := pdp.NewBundleClient(bundleURL(), testAPIKey()) require.NoError(t, err) @@ -106,6 +108,7 @@ func TestDataPlane_OPALocalClientEvaluatesBundle(t *testing.T) { // TestDataPlane_NewLocalPDPFullStack verifies the one-call NewLocalPDP // initialization against a live server. func TestDataPlane_NewLocalPDPFullStack(t *testing.T) { + requireServer(t) cfg := pdp.PolicyEnforcementConfig{ BundleURL: bundleURL(), APIKey: testAPIKey(), @@ -148,6 +151,7 @@ func TestDataPlane_NewLocalPDPFullStack(t *testing.T) { // TestDataPlane_BundleRevisionConsistency verifies that consecutive fetches // return the same revision when no config has changed. func TestDataPlane_BundleRevisionConsistency(t *testing.T) { + requireServer(t) client, err := pdp.NewBundleClient(bundleURL(), testAPIKey()) require.NoError(t, err) @@ -164,6 +168,7 @@ func TestDataPlane_BundleRevisionConsistency(t *testing.T) { // TestDataPlane_BundleAuthRejection verifies that an invalid API key // is properly rejected by the server. func TestDataPlane_BundleAuthRejection(t *testing.T) { + requireServer(t) client, err := pdp.NewBundleClient(bundleURL(), "invalid-key-that-should-fail") require.NoError(t, err) @@ -175,6 +180,7 @@ func TestDataPlane_BundleAuthRejection(t *testing.T) { // TestDataPlane_BundleContainsData verifies that the bundle data section // contains expected agent/registry data from the server. func TestDataPlane_BundleContainsData(t *testing.T) { + requireServer(t) client, err := pdp.NewBundleClient(bundleURL(), testAPIKey()) require.NoError(t, err) @@ -193,6 +199,7 @@ func TestDataPlane_BundleContainsData(t *testing.T) { // TestDataPlane_BundleFetchHTTPHeaders verifies that the server respects // standard HTTP semantics for the bundle endpoint. func TestDataPlane_BundleFetchHTTPHeaders(t *testing.T) { + requireServer(t) req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, bundleURL(), nil) require.NoError(t, err) req.Header.Set("X-Capiscio-Registry-Key", testAPIKey()) diff --git a/tests/integration/dv_order_test.go b/tests/integration/dv_order_test.go index e21e27e..2ca06fa 100644 --- a/tests/integration/dv_order_test.go +++ b/tests/integration/dv_order_test.go @@ -20,6 +20,7 @@ import ( // TestDVOrderCreation tests DV order creation (Task 5 - RFC-002 v1.2) func TestDVOrderCreation(t *testing.T) { + requireServer(t) ctx := context.Background() // Generate test key pair @@ -85,6 +86,7 @@ func TestDVOrderCreation(t *testing.T) { // TestDVOrderStatus tests retrieving order status (Task 5) func TestDVOrderStatus(t *testing.T) { + requireServer(t) ctx := context.Background() // Create order first diff --git a/tests/integration/pop_challenge_test.go b/tests/integration/pop_challenge_test.go index 06f4a90..851cd13 100644 --- a/tests/integration/pop_challenge_test.go +++ b/tests/integration/pop_challenge_test.go @@ -16,6 +16,7 @@ import ( // TestPoPChallengeFlow tests RFC-003 PoP challenge-response flow (Task 4) func TestPoPChallengeFlow(t *testing.T) { + requireServer(t) ctx := context.Background() // Step 1: Generate key pair for test agent @@ -81,6 +82,7 @@ func TestPoPChallengeReplay(t *testing.T) { // TestPoPWithInvalidSignature tests invalid signature rejection (Task 4) func TestPoPWithInvalidSignature(t *testing.T) { + requireServer(t) ctx := context.Background() // Generate two different key pairs @@ -107,6 +109,7 @@ func TestPoPWithInvalidSignature(t *testing.T) { // TestPoPWithMalformedDID tests malformed DID rejection (Task 4) func TestPoPWithMalformedDID(t *testing.T) { + requireServer(t) ctx := context.Background() _, privKey, err := ed25519.GenerateKey(rand.Reader) @@ -166,6 +169,7 @@ func TestPoPBadgeVerification(t *testing.T) { // TestPoPWithCustomAudience tests PoP badge with audience restrictions (Task 4) func TestPoPWithCustomAudience(t *testing.T) { + requireServer(t) ctx := context.Background() pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) diff --git a/tests/integration/setup_test.go b/tests/integration/setup_test.go index 22e01ee..cd6af60 100644 --- a/tests/integration/setup_test.go +++ b/tests/integration/setup_test.go @@ -12,8 +12,20 @@ import ( var ( // apiBaseURL is the base URL for the capiscio-server apiBaseURL string + + // serverAvailable is true when the live server is reachable. + // Tests that require it should call requireServer(t). + serverAvailable bool ) +// requireServer skips a test if the live capiscio-server is not running. +func requireServer(t *testing.T) { + t.Helper() + if !serverAvailable { + t.Skip("Skipping: live capiscio-server not available at " + apiBaseURL) + } +} + // TestMain sets up the test environment func TestMain(m *testing.M) { // Get API URL from environment @@ -22,18 +34,15 @@ func TestMain(m *testing.M) { apiBaseURL = "http://localhost:8080" } - exitCode := 0 - - // Wait for server to be ready + // Check if server is available (don't block on it) if err := waitForServer(apiBaseURL, 30*time.Second); err != nil { - fmt.Fprintf(os.Stderr, "Server not ready: %v\n", err) - exitCode = 1 + fmt.Fprintf(os.Stderr, "Server not ready: %v (server-dependent tests will be skipped)\n", err) + serverAvailable = false } else { - // Run tests - exitCode = m.Run() + serverAvailable = true } - os.Exit(exitCode) + os.Exit(m.Run()) } // waitForServer waits for the server to be healthy From 940d681b8d628c442d3bf5f4749c8c4484db156b Mon Sep 17 00:00:00 2001 From: Beon de Nood Date: Fri, 22 May 2026 11:59:01 -0400 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20CI=20?= =?UTF-8?q?gate=20on=20all=20jobs=20+=20fork=20guard=20+=20descriptive=20e?= =?UTF-8?q?rrors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - trigger-e2e now depends on [test, lint, protobuf, security] not just [test] - Add github.repository guard so forks without REPO_ACCESS_TOKEN don't fail - Replace assert.AnError with descriptive fmt.Errorf in mock registry --- .github/workflows/ci.yml | 4 ++-- tests/integration/badge_verification_test.go | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b8259e..f6d9740 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,8 +123,8 @@ jobs: trigger-e2e: name: Trigger E2E Tests - needs: [test] - if: github.ref == 'refs/heads/main' && github.event_name == 'push' + needs: [test, lint, protobuf, security] + if: github.ref == 'refs/heads/main' && github.event_name == 'push' && github.repository == 'capiscio/capiscio-core' runs-on: ubuntu-latest steps: - name: Dispatch E2E workflow diff --git a/tests/integration/badge_verification_test.go b/tests/integration/badge_verification_test.go index 2567c56..c83e0eb 100644 --- a/tests/integration/badge_verification_test.go +++ b/tests/integration/badge_verification_test.go @@ -5,6 +5,7 @@ import ( "crypto" "crypto/ed25519" "crypto/rand" + "fmt" "os" "testing" "time" @@ -26,7 +27,7 @@ func (m *mockRegistry) GetPublicKey(ctx context.Context, issuer string) (crypto. if key, ok := m.keys[issuer]; ok { return key, nil } - return nil, assert.AnError + return nil, fmt.Errorf("public key not found for issuer %q", issuer) } func (m *mockRegistry) IsRevoked(ctx context.Context, id string) (bool, error) { From 8925aac1d7fee8b305ac95a53f7dc54c4d4f1c54 Mon Sep 17 00:00:00 2001 From: Beon de Nood Date: Wed, 27 May 2026 13:17:28 -0400 Subject: [PATCH 4/4] fix: make server wait timeout configurable (review feedback) - Default reduced from 30s to 5s for fast skip in serverless runs - Configurable via SERVER_WAIT_TIMEOUT env var for CI with slow startup --- tests/integration/setup_test.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/integration/setup_test.go b/tests/integration/setup_test.go index cd6af60..26414e1 100644 --- a/tests/integration/setup_test.go +++ b/tests/integration/setup_test.go @@ -34,8 +34,16 @@ func TestMain(m *testing.M) { apiBaseURL = "http://localhost:8080" } + // Server wait timeout (configurable via env, defaults to 5s for quick skip in serverless runs) + waitTimeout := 5 * time.Second + if t := os.Getenv("SERVER_WAIT_TIMEOUT"); t != "" { + if d, err := time.ParseDuration(t); err == nil { + waitTimeout = d + } + } + // Check if server is available (don't block on it) - if err := waitForServer(apiBaseURL, 30*time.Second); err != nil { + if err := waitForServer(apiBaseURL, waitTimeout); err != nil { fmt.Fprintf(os.Stderr, "Server not ready: %v (server-dependent tests will be skipped)\n", err) serverAvailable = false } else {