Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"}'
182 changes: 167 additions & 15 deletions tests/integration/badge_verification_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Comment on lines +26 to +31

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 &registry.BadgeStatus{JTI: jti, Revoked: true}, nil
}
return &registry.BadgeStatus{JTI: jti, Revoked: false}, nil
}

func (m *mockRegistry) GetAgentStatus(ctx context.Context, issuerURL string, agentID string) (*registry.AgentStatus, error) {
return &registry.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.
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions tests/integration/data_plane_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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)

Expand All @@ -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)

Expand All @@ -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)

Expand All @@ -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())
Expand Down
2 changes: 2 additions & 0 deletions tests/integration/dv_order_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions tests/integration/pop_challenge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
33 changes: 25 additions & 8 deletions tests/integration/setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Comment on lines +45 to +48
} else {
// Run tests
exitCode = m.Run()
serverAvailable = true
}

os.Exit(exitCode)
os.Exit(m.Run())
}

// waitForServer waits for the server to be healthy
Expand Down
Loading