From 9adc80869f7e7c98d854a9dd874bf506895959f6 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Wed, 14 Jan 2026 17:09:00 +0100 Subject: [PATCH 1/5] #3967: get credential status from local store for issued VCs (did:web) --- vcr/api/vcr/v2/api.go | 12 ++++-- vcr/issuer/interface.go | 4 ++ vcr/issuer/issuer.go | 34 ++++++++++++++++- vcr/issuer/sql_store.go | 1 + vcr/revocation/statuslist2021_issuer.go | 33 ++++++++++++++-- vcr/revocation/statuslist2021_issuer_test.go | 40 ++++++++++++++++++++ vcr/revocation/types.go | 12 ++++++ 7 files changed, 128 insertions(+), 8 deletions(-) diff --git a/vcr/api/vcr/v2/api.go b/vcr/api/vcr/v2/api.go index e9f2763da4..9dbe0be05f 100644 --- a/vcr/api/vcr/v2/api.go +++ b/vcr/api/vcr/v2/api.go @@ -253,9 +253,15 @@ func (w *Wrapper) SearchIssuedVCs(ctx context.Context, request SearchIssuedVCsRe if err != nil { return nil, err } - result, err := w.vcsWithRevocationsToSearchResults(foundVCs) - if err != nil { - return nil, err + + result := make([]SearchVCResult, len(foundVCs)) + for i, resolvedVC := range foundVCs { + var revocation *Revocation + revocation, err := w.VCR.Issuer().GetRevocation(*resolvedVC.ID) + if err != nil && !errors.Is(err, vcrTypes.ErrNotFound) { + return nil, err + } + result[i] = SearchVCResult{VerifiableCredential: resolvedVC, Revocation: revocation} } return SearchIssuedVCs200JSONResponse(SearchVCResults{result}), nil } diff --git a/vcr/issuer/interface.go b/vcr/issuer/interface.go index 2c9703adfe..ac4206fc92 100644 --- a/vcr/issuer/interface.go +++ b/vcr/issuer/interface.go @@ -50,6 +50,10 @@ type Issuer interface { // StatusList returns the StatusList2021Credential tracking status list revocations for this issuer at /iam/issuerID/status/page. // Returns types.ErrNotFound when no credential statuses have been published using the issuer and page combination. StatusList(ctx context.Context, issuer did.DID, page int) (*vc.VerifiableCredential, error) + // GetRevocation returns a revocation for a credential ID. + // Returns a types.ErrNotFound when the revocation is not in the store + // Returns a types.ErrMultipleFound when there are multiple revocations for this credential ID in the store + GetRevocation(id ssi.URI) (*credential.Revocation, error) CredentialSearcher } diff --git a/vcr/issuer/issuer.go b/vcr/issuer/issuer.go index 73967320e2..d88a430855 100644 --- a/vcr/issuer/issuer.go +++ b/vcr/issuer/issuer.go @@ -23,14 +23,15 @@ import ( "encoding/json" "errors" "fmt" + "strings" + "time" + "github.com/nuts-foundation/go-stoabs" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vcr/revocation" "github.com/nuts-foundation/nuts-node/vdr/didnuts" "github.com/nuts-foundation/nuts-node/vdr/resolver" "gorm.io/gorm" - "strings" - "time" "github.com/google/uuid" ssi "github.com/nuts-foundation/go-did" @@ -93,6 +94,35 @@ type issuer struct { statusList revocation.StatusList2021Issuer } +func (i issuer) GetRevocation(credentialID ssi.URI) (*credential.Revocation, error) { + // did:nuts; use store.GetRevocation() + // otherwise, use statusList + credentialDIDURL, err := did.ParseDIDURL(credentialID.String()) + if err != nil { + return nil, err + } + if credentialDIDURL.Method == didnuts.MethodName { + return i.store.GetRevocation(credentialID) + } + cred, err := i.store.GetCredential(credentialID) + if err != nil { + return nil, err + } + rev, err := i.statusList.GetRevocation(credentialID) + if err != nil { + return nil, err + } + if rev == nil { + return nil, types.ErrNotFound + } + return &credential.Revocation{ + Issuer: cred.Issuer, + Subject: credentialID, + Reason: rev.Purpose, + Date: rev.RevokedAt, + }, nil +} + // Issue creates a new credential, signs, stores it. // If publish is true, it publishes the credential to the network using the configured Publisher // Use the public flag to pass the visibility settings to the Publisher. diff --git a/vcr/issuer/sql_store.go b/vcr/issuer/sql_store.go index ed7fdd47bb..499fbe7e02 100644 --- a/vcr/issuer/sql_store.go +++ b/vcr/issuer/sql_store.go @@ -20,6 +20,7 @@ package issuer import ( "errors" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" diff --git a/vcr/revocation/statuslist2021_issuer.go b/vcr/revocation/statuslist2021_issuer.go index 437297744b..be935a1825 100644 --- a/vcr/revocation/statuslist2021_issuer.go +++ b/vcr/revocation/statuslist2021_issuer.go @@ -22,15 +22,16 @@ import ( "context" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/audit" - "github.com/nuts-foundation/nuts-node/storage" - "github.com/nuts-foundation/nuts-node/vdr/resolver" "net/url" "slices" "strconv" "strings" "time" + "github.com/nuts-foundation/nuts-node/audit" + "github.com/nuts-foundation/nuts-node/storage" + "github.com/nuts-foundation/nuts-node/vdr/resolver" + "github.com/google/uuid" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" @@ -457,6 +458,32 @@ func (cs *StatusList2021) Revoke(ctx context.Context, credentialID ssi.URI, entr }) } +// GetRevocation checks if the credential, issued locally, was revoked. +// Returns the revocation information if the credential is revoked, or nil if not revoked. +// Returns an error if the database query fails. +func (cs *StatusList2021) GetRevocation(credentialID ssi.URI) (*Revocation, error) { + var result struct { + StatusPurpose string + RevokedAt int64 + } + err := cs.db.Model(&revocationRecord{}). + Select("status_list_credential.status_purpose, status_list_entry.created_at"). + Joins("JOIN status_list_credential ON status_list_entry.status_list_credential = status_list_credential.subject_id"). + Where("status_list_entry.credential_id = ?", credentialID.String()). + First(&result). + Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &Revocation{ + Purpose: result.StatusPurpose, + RevokedAt: time.Unix(result.RevokedAt, 0), + }, nil +} + func (cs *StatusList2021) statusListURL(issuer did.DID, page int) string { // https://example.com/statuslist//page result, _ := url.Parse(cs.baseURL) diff --git a/vcr/revocation/statuslist2021_issuer_test.go b/vcr/revocation/statuslist2021_issuer_test.go index 3e2a106d7c..1eb31d9eec 100644 --- a/vcr/revocation/statuslist2021_issuer_test.go +++ b/vcr/revocation/statuslist2021_issuer_test.go @@ -403,3 +403,43 @@ func toMap(t testing.TB, obj any) (result map[string]any) { require.NoError(t, json.Unmarshal(bs, &result)) return } + +func TestStatusList2021_GetRevocation(t *testing.T) { + s := newTestStatusList2021(t, aliceDID, bobDID) + testCtx := audit.TestContext() + + credentialID := ssi.MustParseURI("did:web:example.com:iam:alice#123") + + t.Run("ok - not revoked", func(t *testing.T) { + revocation, err := s.GetRevocation(credentialID) + + assert.NoError(t, err) + assert.Nil(t, revocation) + }) + + t.Run("ok - revoked", func(t *testing.T) { + // First get an entry and revoke it + entry, err := s.Entry(testCtx, aliceDID, StatusPurposeRevocation) + require.NoError(t, err) + + err = s.Revoke(testCtx, credentialID, *entry) + require.NoError(t, err) + + // Now check if it's revoked + revocation, err := s.GetRevocation(credentialID) + + assert.NoError(t, err) + require.NotNil(t, revocation) + assert.Equal(t, StatusPurposeRevocation, revocation.Purpose) + assert.False(t, revocation.RevokedAt.IsZero(), "RevokedAt should not be zero") + }) + + t.Run("ok - different credential not revoked", func(t *testing.T) { + otherCredentialID := ssi.MustParseURI("did:web:example.com:iam:alice#456") + + revocation, err := s.GetRevocation(otherCredentialID) + + assert.NoError(t, err) + assert.Nil(t, revocation) + }) +} diff --git a/vcr/revocation/types.go b/vcr/revocation/types.go index 00c75da6ac..66f20d02ce 100644 --- a/vcr/revocation/types.go +++ b/vcr/revocation/types.go @@ -65,6 +65,14 @@ const ( statusPurposeSuspension = "suspension" // currently not supported ) +// Revocation contains information about a revoked credential. +type Revocation struct { + // Purpose is the reason for the revocation (e.g., "revocation", "suspension") + Purpose string + // RevokedAt is the time when the credential was revoked (Unix timestamp) + RevokedAt time.Time +} + // StatusList2021Issuer is the issuer side of StatusList2021 type StatusList2021Issuer interface { // Credential provides a valid StatusList2021Credential with subject ID derived from the issuer and page. @@ -78,6 +86,10 @@ type StatusList2021Issuer interface { // The credentialID allows reverse search of revocations, its issuer is NOT verified against the entry issuer or VC. // Returns types.ErrRevoked if already revoked, or types.ErrNotFound when the entry.StatusListCredential is unknown. Revoke(ctx context.Context, credentialID ssi.URI, entry StatusList2021Entry) error + // GetRevocation checks if the credential, issued locally, was revoked. + // Returns the revocation information if the credential is revoked, or nil if not revoked. + // Returns an error if the database query fails. + GetRevocation(credentialID ssi.URI) (*Revocation, error) } // StatusList2021Verifier is the verifier side of StatusList2021 From ea22ee0bef1dc5a9a88e4af3bf13cc75c83e398c Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 15 Jan 2026 08:44:39 +0100 Subject: [PATCH 2/5] fix tests --- makefile | 1 + vcr/api/vcr/v2/api_test.go | 9 +-- vcr/issuer/issuer_test.go | 122 ++++++++++++++++++++++++++++++++++++- vcr/issuer/mock.go | 15 +++++ 4 files changed, 142 insertions(+), 5 deletions(-) diff --git a/makefile b/makefile index d5a6d92156..fd9a0cfcac 100644 --- a/makefile +++ b/makefile @@ -51,6 +51,7 @@ gen-mocks: mockgen -destination=vcr/issuer/openid_mock.go -package=issuer -source=vcr/issuer/openid.go mockgen -destination=vcr/holder/openid_mock.go -package=holder -source=vcr/holder/openid.go mockgen -destination=vcr/openid4vci/identifiers_mock.go -package=openid4vci -source=vcr/openid4vci/identifiers.go + mockgen -destination=vcr/revocation/mock.go -package=revocation -source=vcr/revocation/types.go mockgen -destination=vcr/signature/mock.go -package=signature -source=vcr/signature/signature.go mockgen -destination=vcr/verifier/mock.go -package=verifier -source=vcr/verifier/interface.go mockgen -destination=vdr/didnuts/ambassador_mock.go -package=didnuts -source=vdr/didnuts/ambassador.go diff --git a/vcr/api/vcr/v2/api_test.go b/vcr/api/vcr/v2/api_test.go index 2f8fe7adda..510978caed 100644 --- a/vcr/api/vcr/v2/api_test.go +++ b/vcr/api/vcr/v2/api_test.go @@ -23,13 +23,14 @@ import ( "encoding/json" "errors" "fmt" - "github.com/labstack/echo/v4" - "github.com/nuts-foundation/nuts-node/vcr/types" "net/http" "net/http/httptest" "testing" "time" + "github.com/labstack/echo/v4" + "github.com/nuts-foundation/nuts-node/vcr/types" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -518,7 +519,7 @@ func TestWrapper_SearchIssuedVCs(t *testing.T) { t.Run("ok - without subject, 1 result", func(t *testing.T) { testContext := newMockContext(t) testContext.mockIssuer.EXPECT().SearchCredential(testCredential, *issuerDID, nil).Return([]VerifiableCredential{foundVC}, nil) - testContext.mockVerifier.EXPECT().GetRevocation(vcID).Return(nil, verifier.ErrNotFound) + testContext.mockIssuer.EXPECT().GetRevocation(vcID).Return(nil, types.ErrNotFound) expectedResponse := SearchIssuedVCs200JSONResponse(SearchVCResults{VerifiableCredentials: []SearchVCResult{{VerifiableCredential: foundVC}}}) params := SearchIssuedVCsParams{ CredentialType: "TestCredential", @@ -535,7 +536,7 @@ func TestWrapper_SearchIssuedVCs(t *testing.T) { revocation := &Revocation{Reason: "because of reasons"} testContext := newMockContext(t) testContext.mockIssuer.EXPECT().SearchCredential(testCredential, *issuerDID, nil).Return([]VerifiableCredential{foundVC}, nil) - testContext.mockVerifier.EXPECT().GetRevocation(vcID).Return(revocation, nil) + testContext.mockIssuer.EXPECT().GetRevocation(vcID).Return(revocation, nil) expectedResponse := SearchIssuedVCs200JSONResponse(SearchVCResults{VerifiableCredentials: []SearchVCResult{{VerifiableCredential: foundVC, Revocation: revocation}}}) params := SearchIssuedVCsParams{ CredentialType: "TestCredential", diff --git a/vcr/issuer/issuer_test.go b/vcr/issuer/issuer_test.go index 7dd8fc2576..31b1dc0a41 100644 --- a/vcr/issuer/issuer_test.go +++ b/vcr/issuer/issuer_test.go @@ -24,11 +24,12 @@ import ( "encoding/json" "errors" "fmt" - "gorm.io/gorm" "path" "testing" "time" + "gorm.io/gorm" + "github.com/google/uuid" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" @@ -614,6 +615,125 @@ _:c14n0 . }) }) } + +func Test_issuer_GetRevocation(t *testing.T) { + nutsCredentialID := ssi.MustParseURI("did:nuts:issuer#38E90E8C-F7E5-4333-B63A-F9DD155A0272") + webCredentialID := ssi.MustParseURI("did:web:example.com#12345678-1234-1234-1234-123456789012") + + t.Run("did:nuts credential", func(t *testing.T) { + t.Run("ok - revoked", func(t *testing.T) { + ctrl := gomock.NewController(t) + expectedRevocation := &credential.Revocation{ + Issuer: nutsIssuerDID.URI(), + Subject: nutsCredentialID, + Reason: "test reason", + Date: time.Now(), + } + + store := NewMockStore(ctrl) + store.EXPECT().GetRevocation(nutsCredentialID).Return(expectedRevocation, nil) + + sut := issuer{store: store} + + result, err := sut.GetRevocation(nutsCredentialID) + + assert.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, expectedRevocation, result) + }) + + t.Run("ok - not revoked", func(t *testing.T) { + ctrl := gomock.NewController(t) + + store := NewMockStore(ctrl) + store.EXPECT().GetRevocation(nutsCredentialID).Return(nil, vcr.ErrNotFound) + + sut := issuer{store: store} + + result, err := sut.GetRevocation(nutsCredentialID) + + assert.ErrorIs(t, err, vcr.ErrNotFound) + assert.Nil(t, result) + }) + }) + + t.Run("did:web credential", func(t *testing.T) { + issuerDID := did.MustParseDID("did:web:example.com") + testCredential := &vc.VerifiableCredential{ + ID: &webCredentialID, + Issuer: issuerDID.URI(), + } + + t.Run("ok - revoked", func(t *testing.T) { + ctrl := gomock.NewController(t) + revokedAt := time.Now() + statusListRevocation := &revocation.Revocation{ + Purpose: revocation.StatusPurposeRevocation, + RevokedAt: revokedAt, + } + + store := NewMockStore(ctrl) + store.EXPECT().GetCredential(webCredentialID).Return(testCredential, nil) + + statusList := revocation.NewMockStatusList2021Issuer(ctrl) + statusList.EXPECT().GetRevocation(webCredentialID).Return(statusListRevocation, nil) + + sut := issuer{store: store, statusList: statusList} + + result, err := sut.GetRevocation(webCredentialID) + + assert.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, issuerDID.URI(), result.Issuer) + assert.Equal(t, webCredentialID, result.Subject) + assert.Equal(t, revocation.StatusPurposeRevocation, result.Reason) + assert.Equal(t, revokedAt, result.Date) + }) + + t.Run("ok - not revoked", func(t *testing.T) { + ctrl := gomock.NewController(t) + + store := NewMockStore(ctrl) + store.EXPECT().GetCredential(webCredentialID).Return(testCredential, nil) + + statusList := revocation.NewMockStatusList2021Issuer(ctrl) + statusList.EXPECT().GetRevocation(webCredentialID).Return(nil, nil) + + sut := issuer{store: store, statusList: statusList} + + result, err := sut.GetRevocation(webCredentialID) + + assert.ErrorIs(t, err, vcr.ErrNotFound) + assert.Nil(t, result) + }) + + t.Run("error - credential not found", func(t *testing.T) { + ctrl := gomock.NewController(t) + + store := NewMockStore(ctrl) + store.EXPECT().GetCredential(webCredentialID).Return(nil, vcr.ErrNotFound) + + sut := issuer{store: store} + + result, err := sut.GetRevocation(webCredentialID) + + assert.ErrorIs(t, err, vcr.ErrNotFound) + assert.Nil(t, result) + }) + }) + + t.Run("error - invalid credential ID", func(t *testing.T) { + ctrl := gomock.NewController(t) + + sut := issuer{store: NewMockStore(ctrl)} + + result, err := sut.GetRevocation(ssi.MustParseURI("not-a-did")) + + assert.Error(t, err) + assert.Nil(t, result) + }) +} + func Test_issuer_revokeNetwork(t *testing.T) { credentialID := "did:nuts:issuer#38E90E8C-F7E5-4333-B63A-F9DD155A0272" credentialURI := ssi.MustParseURI(credentialID) diff --git a/vcr/issuer/mock.go b/vcr/issuer/mock.go index 1bde1b63ab..a6b73b62ba 100644 --- a/vcr/issuer/mock.go +++ b/vcr/issuer/mock.go @@ -97,6 +97,21 @@ func (m *MockIssuer) EXPECT() *MockIssuerMockRecorder { return m.recorder } +// GetRevocation mocks base method. +func (m *MockIssuer) GetRevocation(id ssi.URI) (*credential.Revocation, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRevocation", id) + ret0, _ := ret[0].(*credential.Revocation) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRevocation indicates an expected call of GetRevocation. +func (mr *MockIssuerMockRecorder) GetRevocation(id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRevocation", reflect.TypeOf((*MockIssuer)(nil).GetRevocation), id) +} + // Issue mocks base method. func (m *MockIssuer) Issue(ctx context.Context, template vc.VerifiableCredential, options CredentialOptions) (*vc.VerifiableCredential, error) { m.ctrl.T.Helper() From fd38510dd4876cb8e4aa6a6fa285a2f3707ac936 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Thu, 15 Jan 2026 08:45:39 +0100 Subject: [PATCH 3/5] mock --- vcr/revocation/mock.go | 141 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 vcr/revocation/mock.go diff --git a/vcr/revocation/mock.go b/vcr/revocation/mock.go new file mode 100644 index 0000000000..aaf21d40f8 --- /dev/null +++ b/vcr/revocation/mock.go @@ -0,0 +1,141 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: vcr/revocation/types.go +// +// Generated by this command: +// +// mockgen -destination=vcr/revocation/mock.go -package=revocation -source=vcr/revocation/types.go +// + +// Package revocation is a generated GoMock package. +package revocation + +import ( + context "context" + reflect "reflect" + + ssi "github.com/nuts-foundation/go-did" + did "github.com/nuts-foundation/go-did/did" + vc "github.com/nuts-foundation/go-did/vc" + gomock "go.uber.org/mock/gomock" +) + +// MockStatusList2021Issuer is a mock of StatusList2021Issuer interface. +type MockStatusList2021Issuer struct { + ctrl *gomock.Controller + recorder *MockStatusList2021IssuerMockRecorder + isgomock struct{} +} + +// MockStatusList2021IssuerMockRecorder is the mock recorder for MockStatusList2021Issuer. +type MockStatusList2021IssuerMockRecorder struct { + mock *MockStatusList2021Issuer +} + +// NewMockStatusList2021Issuer creates a new mock instance. +func NewMockStatusList2021Issuer(ctrl *gomock.Controller) *MockStatusList2021Issuer { + mock := &MockStatusList2021Issuer{ctrl: ctrl} + mock.recorder = &MockStatusList2021IssuerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStatusList2021Issuer) EXPECT() *MockStatusList2021IssuerMockRecorder { + return m.recorder +} + +// Credential mocks base method. +func (m *MockStatusList2021Issuer) Credential(ctx context.Context, issuer did.DID, page int) (*vc.VerifiableCredential, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Credential", ctx, issuer, page) + ret0, _ := ret[0].(*vc.VerifiableCredential) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Credential indicates an expected call of Credential. +func (mr *MockStatusList2021IssuerMockRecorder) Credential(ctx, issuer, page any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Credential", reflect.TypeOf((*MockStatusList2021Issuer)(nil).Credential), ctx, issuer, page) +} + +// Entry mocks base method. +func (m *MockStatusList2021Issuer) Entry(ctx context.Context, issuer did.DID, purpose StatusPurpose) (*StatusList2021Entry, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Entry", ctx, issuer, purpose) + ret0, _ := ret[0].(*StatusList2021Entry) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Entry indicates an expected call of Entry. +func (mr *MockStatusList2021IssuerMockRecorder) Entry(ctx, issuer, purpose any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Entry", reflect.TypeOf((*MockStatusList2021Issuer)(nil).Entry), ctx, issuer, purpose) +} + +// GetRevocation mocks base method. +func (m *MockStatusList2021Issuer) GetRevocation(credentialID ssi.URI) (*Revocation, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetRevocation", credentialID) + ret0, _ := ret[0].(*Revocation) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetRevocation indicates an expected call of GetRevocation. +func (mr *MockStatusList2021IssuerMockRecorder) GetRevocation(credentialID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRevocation", reflect.TypeOf((*MockStatusList2021Issuer)(nil).GetRevocation), credentialID) +} + +// Revoke mocks base method. +func (m *MockStatusList2021Issuer) Revoke(ctx context.Context, credentialID ssi.URI, entry StatusList2021Entry) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Revoke", ctx, credentialID, entry) + ret0, _ := ret[0].(error) + return ret0 +} + +// Revoke indicates an expected call of Revoke. +func (mr *MockStatusList2021IssuerMockRecorder) Revoke(ctx, credentialID, entry any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Revoke", reflect.TypeOf((*MockStatusList2021Issuer)(nil).Revoke), ctx, credentialID, entry) +} + +// MockStatusList2021Verifier is a mock of StatusList2021Verifier interface. +type MockStatusList2021Verifier struct { + ctrl *gomock.Controller + recorder *MockStatusList2021VerifierMockRecorder + isgomock struct{} +} + +// MockStatusList2021VerifierMockRecorder is the mock recorder for MockStatusList2021Verifier. +type MockStatusList2021VerifierMockRecorder struct { + mock *MockStatusList2021Verifier +} + +// NewMockStatusList2021Verifier creates a new mock instance. +func NewMockStatusList2021Verifier(ctrl *gomock.Controller) *MockStatusList2021Verifier { + mock := &MockStatusList2021Verifier{ctrl: ctrl} + mock.recorder = &MockStatusList2021VerifierMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStatusList2021Verifier) EXPECT() *MockStatusList2021VerifierMockRecorder { + return m.recorder +} + +// Verify mocks base method. +func (m *MockStatusList2021Verifier) Verify(credentialToVerify vc.VerifiableCredential) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Verify", credentialToVerify) + ret0, _ := ret[0].(error) + return ret0 +} + +// Verify indicates an expected call of Verify. +func (mr *MockStatusList2021VerifierMockRecorder) Verify(credentialToVerify any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Verify", reflect.TypeOf((*MockStatusList2021Verifier)(nil).Verify), credentialToVerify) +} From 08288ce4e8c1725c20e0612c0aed59e59db9ffc5 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Mon, 19 Jan 2026 09:40:42 +0100 Subject: [PATCH 4/5] feedback --- vcr/issuer/interface.go | 10 ++++------ vcr/issuer/issuer.go | 26 ++++++++++++++------------ vcr/issuer/issuer_test.go | 32 +++++++++++++++++--------------- vcr/issuer/leia_store.go | 23 +++++++++++------------ vcr/issuer/leia_store_test.go | 23 +++++++++++++---------- vcr/issuer/mock.go | 4 ++-- vcr/issuer/sql_store.go | 4 ++-- vcr/issuer/sql_store_test.go | 9 +++++---- vcr/types/constants.go | 2 +- 9 files changed, 69 insertions(+), 64 deletions(-) diff --git a/vcr/issuer/interface.go b/vcr/issuer/interface.go index ac4206fc92..6add1ef3db 100644 --- a/vcr/issuer/interface.go +++ b/vcr/issuer/interface.go @@ -51,8 +51,7 @@ type Issuer interface { // Returns types.ErrNotFound when no credential statuses have been published using the issuer and page combination. StatusList(ctx context.Context, issuer did.DID, page int) (*vc.VerifiableCredential, error) // GetRevocation returns a revocation for a credential ID. - // Returns a types.ErrNotFound when the revocation is not in the store - // Returns a types.ErrMultipleFound when there are multiple revocations for this credential ID in the store + // Returns nil when no revocation is found. GetRevocation(id ssi.URI) (*credential.Revocation, error) CredentialSearcher } @@ -67,10 +66,9 @@ type Store interface { GetCredential(id ssi.URI) (*vc.VerifiableCredential, error) // StoreCredential writes a VC to storage. StoreCredential(vc vc.VerifiableCredential) error - // GetRevocation returns a revocation for a credential ID - // Returns a types.ErrNotFound when the revocation is not in the store - // Returns a types.ErrMultipleFound when there are multiple revocations for this credential ID in the store - GetRevocation(id ssi.URI) (*credential.Revocation, error) + // GetRevocation returns all revocations for a credential ID. + // Returns an empty slice when no revocations are found. + GetRevocation(id ssi.URI) ([]credential.Revocation, error) // StoreRevocation writes a revocation to storage. StoreRevocation(r credential.Revocation) error CredentialSearcher diff --git a/vcr/issuer/issuer.go b/vcr/issuer/issuer.go index d88a430855..82f3cfdea7 100644 --- a/vcr/issuer/issuer.go +++ b/vcr/issuer/issuer.go @@ -102,8 +102,16 @@ func (i issuer) GetRevocation(credentialID ssi.URI) (*credential.Revocation, err return nil, err } if credentialDIDURL.Method == didnuts.MethodName { - return i.store.GetRevocation(credentialID) + revocations, err := i.store.GetRevocation(credentialID) + if err != nil { + return nil, err + } + if len(revocations) == 0 { + return nil, nil + } + return &revocations[0], nil } + // other DID method; use statusList cred, err := i.store.GetCredential(credentialID) if err != nil { return nil, err @@ -113,7 +121,7 @@ func (i issuer) GetRevocation(credentialID ssi.URI) (*credential.Revocation, err return nil, err } if rev == nil { - return nil, types.ErrNotFound + return nil, nil } return &credential.Revocation{ Issuer: cred.Issuer, @@ -441,17 +449,11 @@ func (i issuer) buildRevocation(ctx context.Context, credentialID ssi.URI) (*cre // isRevoked returns false if no credential.Revocation can be found, all other cases default to true. // Only applies to did:nuts revocations. func (i issuer) isRevoked(credentialID ssi.URI) (bool, error) { - _, err := i.store.GetRevocation(credentialID) - switch err { - case nil: // revocation found - return true, nil - case types.ErrMultipleFound: - return true, nil - case types.ErrNotFound: - return false, nil - default: + revocations, err := i.store.GetRevocation(credentialID) + if err != nil { return true, err } + return len(revocations) > 0, nil } func (i issuer) SearchCredential(credentialType ssi.URI, issuer did.DID, subject *ssi.URI) ([]vc.VerifiableCredential, error) { @@ -516,7 +518,7 @@ func (c combinedStore) StoreCredential(vc vc.VerifiableCredential) error { return c.otherDIDsStore.StoreCredential(vc) } -func (c combinedStore) GetRevocation(id ssi.URI) (*credential.Revocation, error) { +func (c combinedStore) GetRevocation(id ssi.URI) ([]credential.Revocation, error) { if strings.HasPrefix(id.String(), "did:nuts:") { return c.didNutsStore.GetRevocation(id) } diff --git a/vcr/issuer/issuer_test.go b/vcr/issuer/issuer_test.go index 31b1dc0a41..5584424473 100644 --- a/vcr/issuer/issuer_test.go +++ b/vcr/issuer/issuer_test.go @@ -631,7 +631,7 @@ func Test_issuer_GetRevocation(t *testing.T) { } store := NewMockStore(ctrl) - store.EXPECT().GetRevocation(nutsCredentialID).Return(expectedRevocation, nil) + store.EXPECT().GetRevocation(nutsCredentialID).Return([]credential.Revocation{*expectedRevocation}, nil) sut := issuer{store: store} @@ -646,13 +646,13 @@ func Test_issuer_GetRevocation(t *testing.T) { ctrl := gomock.NewController(t) store := NewMockStore(ctrl) - store.EXPECT().GetRevocation(nutsCredentialID).Return(nil, vcr.ErrNotFound) + store.EXPECT().GetRevocation(nutsCredentialID).Return([]credential.Revocation{}, nil) sut := issuer{store: store} result, err := sut.GetRevocation(nutsCredentialID) - assert.ErrorIs(t, err, vcr.ErrNotFound) + assert.NoError(t, err) assert.Nil(t, result) }) }) @@ -703,7 +703,7 @@ func Test_issuer_GetRevocation(t *testing.T) { result, err := sut.GetRevocation(webCredentialID) - assert.ErrorIs(t, err, vcr.ErrNotFound) + assert.NoError(t, err) assert.Nil(t, result) }) @@ -746,7 +746,7 @@ func Test_issuer_revokeNetwork(t *testing.T) { t.Run("for a known credential", func(t *testing.T) { storeWithActualCredential := func(c *gomock.Controller) *MockStore { store := NewMockStore(c) - store.EXPECT().GetRevocation(credentialURI).Return(nil, vcr.ErrNotFound) + store.EXPECT().GetRevocation(credentialURI).Return([]credential.Revocation{}, nil) return store } @@ -808,7 +808,7 @@ func Test_issuer_revokeNetwork(t *testing.T) { defer ctrl.Finish() store := NewMockStore(ctrl) - store.EXPECT().GetRevocation(gomock.Any()).Return(nil, vcr.ErrNotFound) + store.EXPECT().GetRevocation(gomock.Any()).Return([]credential.Revocation{}, nil) sut := issuer{ store: store, @@ -823,7 +823,7 @@ func Test_issuer_revokeNetwork(t *testing.T) { defer ctrl.Finish() store := NewMockStore(ctrl) - store.EXPECT().GetRevocation(gomock.Any()).Return(nil, vcr.ErrNotFound) + store.EXPECT().GetRevocation(gomock.Any()).Return([]credential.Revocation{}, nil) sut := issuer{ store: store, @@ -861,7 +861,7 @@ func Test_issuer_revokeNetwork(t *testing.T) { publisher.EXPECT().PublishRevocation(gomock.Any(), gomock.Any()).Return(nil) store.EXPECT().StoreRevocation(gomock.Any()).Return(nil) // 2nd revocation - store.EXPECT().GetRevocation(credentialURI).Return(&credential.Revocation{}, nil) + store.EXPECT().GetRevocation(credentialURI).Return([]credential.Revocation{{Subject: credentialURI}}, nil) sut := issuer{ store: store, @@ -983,7 +983,7 @@ func TestIssuer_isRevoked(t *testing.T) { } t.Run("ok - no revocation", func(t *testing.T) { - store.EXPECT().GetRevocation(credentialURI).Return(nil, vcr.ErrNotFound) + store.EXPECT().GetRevocation(credentialURI).Return([]credential.Revocation{}, nil) isRevoked, err := sut.isRevoked(credentialURI) @@ -991,7 +991,7 @@ func TestIssuer_isRevoked(t *testing.T) { assert.False(t, isRevoked) }) t.Run("ok - revocation exists", func(t *testing.T) { - store.EXPECT().GetRevocation(credentialURI).Return(&credential.Revocation{}, nil) + store.EXPECT().GetRevocation(credentialURI).Return([]credential.Revocation{{Subject: credentialURI}}, nil) isRevoked, err := sut.isRevoked(credentialURI) @@ -999,7 +999,7 @@ func TestIssuer_isRevoked(t *testing.T) { assert.True(t, isRevoked) }) t.Run("ok - multiple revocations exists", func(t *testing.T) { - store.EXPECT().GetRevocation(credentialURI).Return(nil, vcr.ErrMultipleFound) + store.EXPECT().GetRevocation(credentialURI).Return([]credential.Revocation{{}, {}}, nil) isRevoked, err := sut.isRevoked(credentialURI) @@ -1220,31 +1220,33 @@ func Test_combinedStore_GetRevocation(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() nutsStore := NewMockStore(ctrl) - nutsStore.EXPECT().GetRevocation(gomock.Any()).Return(&credential.Revocation{}, nil) + nutsStore.EXPECT().GetRevocation(gomock.Any()).Return([]credential.Revocation{{Subject: nutsIssuerDID.URI()}}, nil) webStore := NewMockStore(ctrl) sut := combinedStore{ didNutsStore: nutsStore, otherDIDsStore: webStore, } - _, err := sut.GetRevocation(nutsIssuerDID.URI()) + result, err := sut.GetRevocation(nutsIssuerDID.URI()) assert.NoError(t, err) + assert.Len(t, result, 1) }) t.Run("issued by did:web", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() nutsStore := NewMockStore(ctrl) webStore := NewMockStore(ctrl) - webStore.EXPECT().GetRevocation(gomock.Any()).Return(&credential.Revocation{}, nil) + webStore.EXPECT().GetRevocation(gomock.Any()).Return([]credential.Revocation{{Subject: webIssuerDID.URI()}}, nil) sut := combinedStore{ didNutsStore: nutsStore, otherDIDsStore: webStore, } - _, err := sut.GetRevocation(webIssuerDID.URI()) + result, err := sut.GetRevocation(webIssuerDID.URI()) assert.NoError(t, err) + assert.Len(t, result, 1) }) } diff --git a/vcr/issuer/leia_store.go b/vcr/issuer/leia_store.go index d663ac1b0e..36efeeffff 100644 --- a/vcr/issuer/leia_store.go +++ b/vcr/issuer/leia_store.go @@ -22,6 +22,7 @@ import ( "context" "encoding/json" "fmt" + "github.com/nuts-foundation/go-leia/v4" "github.com/nuts-foundation/go-stoabs" "github.com/nuts-foundation/nuts-node/core" @@ -143,26 +144,24 @@ func (s leiaIssuerStore) StoreRevocation(revocation credential.Revocation) error return s.revokedCollection().Add([]leia.Document{revocationAsBytes}) } -func (s leiaIssuerStore) GetRevocation(subject ssi.URI) (*credential.Revocation, error) { +func (s leiaIssuerStore) GetRevocation(subject ssi.URI) ([]credential.Revocation, error) { query := leia.New(leia.Eq(leia.NewJSONPath(credential.RevocationSubjectPath), leia.MustParseScalar(subject.String()))) results, err := s.revokedCollection().Find(context.Background(), query) if err != nil { return nil, fmt.Errorf("error while getting revocation by id: %w", err) } - if len(results) == 0 { - return nil, types.ErrNotFound - } - if len(results) > 1 { - return nil, types.ErrMultipleFound - } - result := results[0] - revocation := &credential.Revocation{} - if err := json.Unmarshal(result, revocation); err != nil { - return nil, err + + revocations := make([]credential.Revocation, 0, len(results)) + for _, result := range results { + revocation := credential.Revocation{} + if err := json.Unmarshal(result, &revocation); err != nil { + return nil, err + } + revocations = append(revocations, revocation) } - return revocation, nil + return revocations, nil } func (s leiaIssuerStore) Close() error { diff --git a/vcr/issuer/leia_store_test.go b/vcr/issuer/leia_store_test.go index d12a8b6ace..f3764b9ee8 100644 --- a/vcr/issuer/leia_store_test.go +++ b/vcr/issuer/leia_store_test.go @@ -23,14 +23,15 @@ import ( "encoding/json" "errors" "fmt" + "path" + "testing" + "github.com/nuts-foundation/go-leia/v4" "github.com/nuts-foundation/go-stoabs" "github.com/nuts-foundation/go-stoabs/bbolt" "github.com/nuts-foundation/nuts-node/vcr/types" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" - "path" - "testing" "github.com/stretchr/testify/assert" @@ -213,7 +214,8 @@ func Test_leiaIssuerStore_StoreRevocation(t *testing.T) { result, err := store.GetRevocation(subjectID) assert.NoError(t, err) - assert.Equal(t, revocation, result) + require.Len(t, result, 1) + assert.Equal(t, *revocation, result[0]) }) } @@ -227,19 +229,20 @@ func Test_leiaIssuerStore_GetRevocation(t *testing.T) { result, err := store.GetRevocation(subjectID) assert.NoError(t, err) - assert.Equal(t, revocation, result) + require.Len(t, result, 1) + assert.Equal(t, *revocation, result[0]) }) - t.Run("it returns a ErrNotFound when revocation could not be found", func(t *testing.T) { + t.Run("it returns an empty slice when revocation could not be found", func(t *testing.T) { unknownSubjectID := ssi.MustParseURI("did:nuts:456#ab-cde") result, err := store.GetRevocation(unknownSubjectID) - assert.ErrorIs(t, err, types.ErrNotFound) - assert.Nil(t, result) + assert.NoError(t, err) + assert.Empty(t, result) }) - t.Run("it fails when multiple revocations exist", func(t *testing.T) { + t.Run("it returns multiple revocations when they exist", func(t *testing.T) { duplicateSubjectID := ssi.MustParseURI("did:nuts:456#ab-duplicate") revocation := &credential.Revocation{Subject: duplicateSubjectID} for i := 0; i < 2; i++ { @@ -249,8 +252,8 @@ func Test_leiaIssuerStore_GetRevocation(t *testing.T) { result, err := store.GetRevocation(duplicateSubjectID) - assert.ErrorIs(t, err, types.ErrMultipleFound) - assert.Nil(t, result) + assert.NoError(t, err) + assert.Len(t, result, 2) }) } diff --git a/vcr/issuer/mock.go b/vcr/issuer/mock.go index a6b73b62ba..888da1919a 100644 --- a/vcr/issuer/mock.go +++ b/vcr/issuer/mock.go @@ -240,10 +240,10 @@ func (mr *MockStoreMockRecorder) GetCredential(id any) *gomock.Call { } // GetRevocation mocks base method. -func (m *MockStore) GetRevocation(id ssi.URI) (*credential.Revocation, error) { +func (m *MockStore) GetRevocation(id ssi.URI) ([]credential.Revocation, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetRevocation", id) - ret0, _ := ret[0].(*credential.Revocation) + ret0, _ := ret[0].([]credential.Revocation) ret1, _ := ret[1].(error) return ret0, ret1 } diff --git a/vcr/issuer/sql_store.go b/vcr/issuer/sql_store.go index 499fbe7e02..aecc8cb3c7 100644 --- a/vcr/issuer/sql_store.go +++ b/vcr/issuer/sql_store.go @@ -126,6 +126,6 @@ func (s sqlStore) StoreRevocation(_ credential.Revocation) error { return errors.New("StoreRevocation() not supported for SQL store") } -func (s sqlStore) GetRevocation(_ ssi.URI) (*credential.Revocation, error) { - return nil, types.ErrNotFound +func (s sqlStore) GetRevocation(_ ssi.URI) ([]credential.Revocation, error) { + return []credential.Revocation{}, nil } diff --git a/vcr/issuer/sql_store_test.go b/vcr/issuer/sql_store_test.go index 4322c91337..95b491e629 100644 --- a/vcr/issuer/sql_store_test.go +++ b/vcr/issuer/sql_store_test.go @@ -19,6 +19,8 @@ package issuer import ( + "testing" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/storage" @@ -28,7 +30,6 @@ import ( "github.com/nuts-foundation/nuts-node/vdr" "github.com/stretchr/testify/require" "gorm.io/gorm" - "testing" ) func Test_sqlStore_Diagnostics(t *testing.T) { @@ -71,10 +72,10 @@ func Test_sqlStore_GetCredential(t *testing.T) { } func Test_sqlStore_GetRevocation(t *testing.T) { - // not supported, always returns error + // not supported, always returns empty slice result, err := sqlStore{}.GetRevocation(ssi.MustParseURI("did:nuts:revocation:123")) - require.ErrorIs(t, err, types.ErrNotFound) - require.Nil(t, result) + require.NoError(t, err) + require.Empty(t, result) } func Test_sqlStore_SearchCredential(t *testing.T) { diff --git a/vcr/types/constants.go b/vcr/types/constants.go index 66dd4e7536..cf2c0f8040 100644 --- a/vcr/types/constants.go +++ b/vcr/types/constants.go @@ -29,7 +29,7 @@ var ErrNotFound = errors.New("credential not found") // ErrStatusNotFound is returned when the credential does not contain the desired credential status var ErrStatusNotFound = errors.New("credential contains no (relevant) status") -// ErrMultipleFound is returned when multiple credentials or revocations are found for the same ID. +// ErrMultipleFound is returned when multiple credentials are found for the same ID. var ErrMultipleFound = errors.New("multiple found") // ErrRevoked is returned when a credential has been revoked and the required action requires it to not be revoked. From 486552d927a162e5f5134a58334a21376f276528 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Mon, 19 Jan 2026 09:45:35 +0100 Subject: [PATCH 5/5] feedback --- vcr/issuer/issuer_test.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/vcr/issuer/issuer_test.go b/vcr/issuer/issuer_test.go index 5584424473..2cfc4d2272 100644 --- a/vcr/issuer/issuer_test.go +++ b/vcr/issuer/issuer_test.go @@ -655,6 +655,33 @@ func Test_issuer_GetRevocation(t *testing.T) { assert.NoError(t, err) assert.Nil(t, result) }) + + t.Run("ok - multiple revocations (returns first)", func(t *testing.T) { + ctrl := gomock.NewController(t) + firstRevocation := credential.Revocation{ + Issuer: nutsIssuerDID.URI(), + Subject: nutsCredentialID, + Reason: "first reason", + Date: time.Now().Add(-time.Hour), + } + secondRevocation := credential.Revocation{ + Issuer: nutsIssuerDID.URI(), + Subject: nutsCredentialID, + Reason: "second reason", + Date: time.Now(), + } + + store := NewMockStore(ctrl) + store.EXPECT().GetRevocation(nutsCredentialID).Return([]credential.Revocation{firstRevocation, secondRevocation}, nil) + + sut := issuer{store: store} + + result, err := sut.GetRevocation(nutsCredentialID) + + assert.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, &firstRevocation, result) + }) }) t.Run("did:web credential", func(t *testing.T) {