diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e95b536..f6d9740 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, 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 + 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 }}"}' diff --git a/tests/integration/badge_verification_test.go b/tests/integration/badge_verification_test.go index f56df53..c83e0eb 100644 --- a/tests/integration/badge_verification_test.go +++ b/tests/integration/badge_verification_test.go @@ -2,16 +2,56 @@ package integration import ( "context" + "crypto" + "crypto/ed25519" + "crypto/rand" + "fmt" "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, fmt.Errorf("public key not found for issuer %q", issuer) +} + +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 +170,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..26414e1 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,23 @@ func TestMain(m *testing.M) { apiBaseURL = "http://localhost:8080" } - exitCode := 0 + // 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 + } + } - // Wait for server to be ready - if err := waitForServer(apiBaseURL, 30*time.Second); err != nil { - fmt.Fprintf(os.Stderr, "Server not ready: %v\n", err) - exitCode = 1 + // Check if server is available (don't block on it) + 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 { - // Run tests - exitCode = m.Run() + serverAvailable = true } - os.Exit(exitCode) + os.Exit(m.Run()) } // waitForServer waits for the server to be healthy