From 7b6c41e34c28814520d180951192dd9a7c2b6f80 Mon Sep 17 00:00:00 2001 From: Bob Stasyszyn Date: Fri, 17 Sep 2021 16:24:34 -0400 Subject: [PATCH] feat: Return alternate links in WebFinger query for DID and CAS Implemented an anchor acknowledgement handler that stores additional links for anchor credentials from domains that returned a "Like" activity. These additional links are returned in WebFinger queries for DIDs and CAS. closes #766 Signed-off-by: Bob Stasyszyn --- cmd/orb-server/startcmd/start.go | 45 ++- .../service/activityhandler/inboxhandler.go | 31 +- .../handler/acknowlegement/acknowledgement.go | 73 +++++ .../acknowlegement/acknowledgement_test.go | 57 ++++ pkg/anchor/linkstore/linkstore.go | 145 +++++++++ pkg/anchor/linkstore/linkstore_test.go | 195 ++++++++++++ pkg/cas/ipfs/ipfs.go | 4 +- pkg/cas/resolver/resolver_test.go | 17 +- pkg/discovery/endpoint/restapi/operations.go | 298 ++++++++++++++---- .../endpoint/restapi/operations_test.go | 280 +++++++++++++--- pkg/hashlink/hashlink.go | 23 ++ pkg/hashlink/hashlink_test.go | 12 + pkg/mocks/anchorlinkstore.gen.go | 166 ++++++++++ pkg/observer/observer.go | 59 +++- pkg/observer/observer_test.go | 8 + pkg/store/cas/cas.go | 4 +- pkg/webfinger/client/client_test.go | 12 +- scripts/integration.sh | 4 +- test/bdd/common_steps.go | 27 ++ test/bdd/did_orb_steps.go | 14 + test/bdd/features/did-orb.feature | 22 +- 21 files changed, 1303 insertions(+), 193 deletions(-) create mode 100644 pkg/anchor/handler/acknowlegement/acknowledgement.go create mode 100644 pkg/anchor/handler/acknowlegement/acknowledgement_test.go create mode 100644 pkg/anchor/linkstore/linkstore.go create mode 100644 pkg/anchor/linkstore/linkstore_test.go create mode 100644 pkg/mocks/anchorlinkstore.gen.go diff --git a/cmd/orb-server/startcmd/start.go b/cmd/orb-server/startcmd/start.go index 9fd737bf4..e434bafdd 100644 --- a/cmd/orb-server/startcmd/start.go +++ b/cmd/orb-server/startcmd/start.go @@ -75,8 +75,10 @@ import ( "github.com/trustbloc/orb/pkg/activitypub/vocab" "github.com/trustbloc/orb/pkg/anchor/builder" "github.com/trustbloc/orb/pkg/anchor/graph" + "github.com/trustbloc/orb/pkg/anchor/handler/acknowlegement" "github.com/trustbloc/orb/pkg/anchor/handler/credential" "github.com/trustbloc/orb/pkg/anchor/handler/proof" + "github.com/trustbloc/orb/pkg/anchor/linkstore" "github.com/trustbloc/orb/pkg/anchor/policy" policyhandler "github.com/trustbloc/orb/pkg/anchor/policy/resthandler" "github.com/trustbloc/orb/pkg/anchor/writer" @@ -607,6 +609,11 @@ func startOrbServices(parameters *orbParameters) error { resourceResolver := resource.New(httpClient, ipfsReader) + anchorLinkStore, err := linkstore.New(storeProviders.provider) + if err != nil { + return fmt.Errorf("open store: %w", err) + } + // create new observer and start it providers := &observer.Providers{ ProtocolClientProvider: pcp, @@ -618,6 +625,7 @@ func startOrbServices(parameters *orbParameters) error { WebFingerResolver: resourceResolver, CASResolver: casResolver, DocLoader: orbDocumentLoader, + AnchorLinkStore: anchorLinkStore, } o, err := observer.New(providers, observer.WithDiscoveryDomain(parameters.discoveryDomain)) @@ -625,6 +633,8 @@ func startOrbServices(parameters *orbParameters) error { return fmt.Errorf("failed to create observer: %s", err.Error()) } + anchorEventHandler := acknowlegement.New(anchorLinkStore) + activityPubService, err = apservice.New(apConfig, apStore, t, apSigVerifier, pubSub, apClient, resourceResolver, metrics.Get(), apspi.WithProofHandler(proofHandler), @@ -636,7 +646,7 @@ func startOrbServices(parameters *orbParameters) error { // apspi.WithWitnessInvitationAuth(inviteWitnessAuth), // apspi.WithFollowerAuth(followerAuth), // apspi.WithUndeliverableHandler(undeliverableHandler), - // apspi.WithAnchorEventAcknowledgementHandler(anchorEventHandler), + apspi.WithAnchorEventAcknowledgementHandler(anchorEventHandler), ) if err != nil { return fmt.Errorf("failed to create ActivityPub service: %s", err.Error()) @@ -755,20 +765,25 @@ func startOrbServices(parameters *orbParameters) error { orbDocUpdateHandler := updatehandler.New(didDocHandler, metrics.Get(), updateHandlerOpts...) // create discovery rest api - endpointDiscoveryOp, err := discoveryrest.New(&discoveryrest.Config{ - PubKey: pubKey, - VerificationMethodType: verificationMethodType, - KID: parameters.keyID, - ResolutionPath: baseResolvePath, - OperationPath: baseUpdatePath, - WebCASPath: casPath, - BaseURL: parameters.externalEndpoint, - DiscoveryDomains: parameters.discoveryDomains, - DiscoveryMinimumResolvers: parameters.discoveryMinimumResolvers, - VctURL: parameters.vctURL, - DiscoveryVctDomains: parameters.discoveryVctDomains, - ResourceRegistry: resourceRegistry, - }) + endpointDiscoveryOp, err := discoveryrest.New( + &discoveryrest.Config{ + PubKey: pubKey, + VerificationMethodType: verificationMethodType, + KID: parameters.keyID, + ResolutionPath: baseResolvePath, + OperationPath: baseUpdatePath, + WebCASPath: casPath, + BaseURL: parameters.externalEndpoint, + DiscoveryDomains: parameters.discoveryDomains, + DiscoveryMinimumResolvers: parameters.discoveryMinimumResolvers, + VctURL: parameters.vctURL, + DiscoveryVctDomains: parameters.discoveryVctDomains, + }, + &discoveryrest.Providers{ + ResourceRegistry: resourceRegistry, + CAS: coreCASClient, + AnchorLinkStore: anchorLinkStore, + }) if err != nil { return fmt.Errorf("discovery rest: %w", err) } diff --git a/pkg/activitypub/service/activityhandler/inboxhandler.go b/pkg/activitypub/service/activityhandler/inboxhandler.go index 5fe862497..682e68132 100644 --- a/pkg/activitypub/service/activityhandler/inboxhandler.go +++ b/pkg/activitypub/service/activityhandler/inboxhandler.go @@ -969,36 +969,7 @@ type noOpAnchorEventAcknowledgementHandler struct{} func (p *noOpAnchorEventAcknowledgementHandler) AnchorEventAcknowledged(actor, anchorRef *url.URL, additionalAnchorRefs []*url.URL) error { logger.Infof("Anchor event was acknowledged by [%s] for anchor %s. Additional anchors: %s", - actor, newHashLinkInfo(anchorRef), newHashLinkInfo(additionalAnchorRefs...)) + actor, hashlink.ToString(anchorRef), hashlink.ToString(additionalAnchorRefs...)) return nil } - -type hashLinkInfo struct { - hl []*url.URL -} - -func newHashLinkInfo(hl ...*url.URL) *hashLinkInfo { - return &hashLinkInfo{hl: hl} -} - -func (hlInfo *hashLinkInfo) String() string { - str := "" - - parser := hashlink.New() - - for i, hl := range hlInfo.hl { - if i > 0 { - str += ", " - } - - info, err := parser.ParseHashLink(hl.String()) - if err != nil { - str += fmt.Sprintf("{INVALID HASHLINK [%s]}", hl) - } else { - str += fmt.Sprintf("{Hash [%s], Links %s}", info.ResourceHash, info.Links) - } - } - - return str -} diff --git a/pkg/anchor/handler/acknowlegement/acknowledgement.go b/pkg/anchor/handler/acknowlegement/acknowledgement.go new file mode 100644 index 000000000..e9ff5d54a --- /dev/null +++ b/pkg/anchor/handler/acknowlegement/acknowledgement.go @@ -0,0 +1,73 @@ +/* +Copyright SecureKey Technologies Inc. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +package acknowlegement + +import ( + "fmt" + "net/url" + + "github.com/trustbloc/edge-core/pkg/log" + + "github.com/trustbloc/orb/pkg/hashlink" +) + +var logger = log.New("anchor-acknowledgement-handler") + +type anchorLinkStore interface { + PutLinks(links []*url.URL) error +} + +// Handler handles notifications of successful anchor events processed from an Orb server. +type Handler struct { + anchorLinkStore anchorLinkStore +} + +// New returns a new handler. +func New(store anchorLinkStore) *Handler { + return &Handler{anchorLinkStore: store} +} + +// AnchorEventAcknowledged handles a notification of a successful anchor event processed from an Orb server. +// The given additional references are added to the anchor link store so that they are available for +// WebFinger requests. +func (p *Handler) AnchorEventAcknowledged(actor, anchorRef *url.URL, additionalAnchorRefs []*url.URL) error { + logger.Infof("Anchor event was acknowledged by [%s] for anchor %s. Additional anchors: %s", + actor, hashlink.ToString(anchorRef), hashlink.ToString(additionalAnchorRefs...)) + + parser := hashlink.New() + + info, err := parser.ParseHashLink(anchorRef.String()) + if err != nil { + return fmt.Errorf("parse hashlink [%s]: %w", anchorRef, err) + } + + var links []*url.URL + + for _, hl := range additionalAnchorRefs { + hlInfo, err := parser.ParseHashLink(hl.String()) + if err != nil { + logger.Warnf("Error parsing hashlink [%s]: %s", anchorRef, err) + + continue + } + + if hlInfo.ResourceHash != info.ResourceHash { + logger.Warnf("Hash in additional anchor ref [%s] does not match the hash of the acknowledged anchor event [%s]", + hlInfo.ResourceHash, info.ResourceHash) + + continue + } + + links = append(links, hl) + } + + if err := p.anchorLinkStore.PutLinks(links); err != nil { + return fmt.Errorf("put links [%s]: %w", info.ResourceHash, err) + } + + return nil +} diff --git a/pkg/anchor/handler/acknowlegement/acknowledgement_test.go b/pkg/anchor/handler/acknowlegement/acknowledgement_test.go new file mode 100644 index 000000000..8cce44a0e --- /dev/null +++ b/pkg/anchor/handler/acknowlegement/acknowledgement_test.go @@ -0,0 +1,57 @@ +/* +Copyright SecureKey Technologies Inc. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +package acknowlegement + +import ( + "errors" + "net/url" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/trustbloc/orb/pkg/internal/testutil" + "github.com/trustbloc/orb/pkg/mocks" +) + +func TestHandler(t *testing.T) { + actor := testutil.MustParseURL("https://domain1.com") + anchorRef := testutil.MustParseURL("hl:uEiALYp_C4wk2WegpfnCSoSTBdKZ1MVdDadn4rdmZl5GKzQ:uoQ-BeDVpcGZzOi8vUW1jcTZKV0RVa3l4ZWhxN1JWWmtQM052aUU0SHFSdW5SalgzOXZ1THZFSGFRTg") //nolint:lll + + additionalRefs := []*url.URL{ + // Valid hashlink. + testutil.MustParseURL("hl:uEiALYp_C4wk2WegpfnCSoSTBdKZ1MVdDadn4rdmZl5GKzQ:uoQ-BeEtodHRwczovL29yYi5kb21haW4yLmNvbS9jYXMvdUVpQlVRRFJJNXR0SXpYYmUxTFpLVWFaV2I2eUZzbk1ucmdEa3NBdFEtd0NhS3c"), //nolint:lll + // Hash in hashlink doesn't match anchor hash. + testutil.MustParseURL("hl:uEiBUQDRI5ttIzXbe1LZKUaZWb6yFsnMnrgDksAtQ-wCaKw:uoQ-BeEtodHRwczovL29yYi5kb21haW4yLmNvbS9jYXMvdUVpQlVRRFJJNXR0SXpYYmUxTFpLVWFaV2I2eUZzbk1ucmdEa3NBdFEtd0NhS3c"), //nolint:lll + // Invalid hashlink. + testutil.MustParseURL("xx:invalid"), + } + + linkStore := &mocks.AnchorLinkStore{} + + h := New(linkStore) + + t.Run("Success", func(t *testing.T) { + linkStore.PutLinksReturns(nil) + + require.NoError(t, h.AnchorEventAcknowledged(actor, anchorRef, additionalRefs)) + }) + + t.Run("Anchor link storage error", func(t *testing.T) { + errExpected := errors.New("injected storage error") + + linkStore.PutLinksReturns(errExpected) + + err := h.AnchorEventAcknowledged(actor, anchorRef, additionalRefs) + + require.Error(t, err) + require.Contains(t, err.Error(), errExpected.Error()) + }) + + t.Run("Invalid anchor link", func(t *testing.T) { + require.Error(t, h.AnchorEventAcknowledged(actor, testutil.MustParseURL("xx:invalid"), additionalRefs)) + }) +} diff --git a/pkg/anchor/linkstore/linkstore.go b/pkg/anchor/linkstore/linkstore.go new file mode 100644 index 000000000..8715fc968 --- /dev/null +++ b/pkg/anchor/linkstore/linkstore.go @@ -0,0 +1,145 @@ +/* +Copyright SecureKey Technologies Inc. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +package linkstore + +import ( + "encoding/json" + "fmt" + "net/url" + + "github.com/google/uuid" + "github.com/hyperledger/aries-framework-go/spi/storage" + "github.com/trustbloc/edge-core/pkg/log" + + orberrors "github.com/trustbloc/orb/pkg/errors" + "github.com/trustbloc/orb/pkg/hashlink" +) + +const ( + storeName = "anchorlink" + hashTag = "anchorHash" +) + +var logger = log.New("anchorlinkstore") + +// New creates a new anchor link store. +func New(provider storage.Provider) (*Store, error) { + store, err := provider.OpenStore(storeName) + if err != nil { + return nil, fmt.Errorf("failed to open anchor link store: %w", err) + } + + err = provider.SetStoreConfig(storeName, storage.StoreConfiguration{TagNames: []string{hashTag}}) + if err != nil { + return nil, fmt.Errorf("failed to set store configuration: %w", err) + } + + return &Store{ + store: store, + marshal: json.Marshal, + unmarshal: json.Unmarshal, + }, nil +} + +// Store is implements an anchor link store. +type Store struct { + store storage.Store + marshal func(interface{}) ([]byte, error) + unmarshal func(data []byte, v interface{}) error +} + +// PutLinks stores the given hash links. +func (s *Store) PutLinks(links []*url.URL) error { + operations := make([]storage.Operation, len(links)) + + for i, link := range links { + anchorHash, err := hashlink.GetResourceHashFromHashLink(link.String()) + if err != nil { + return fmt.Errorf("get hash from hashlink [%s]: %w", link, err) + } + + linkBytes, err := s.marshal(link.String()) + if err != nil { + return fmt.Errorf("marshal anchor link [%s]: %w", link, err) + } + + logger.Debugf("Storing anchor link for hash [%s]: [%s]", anchorHash, linkBytes) + + op := storage.Operation{ + Key: uuid.New().String(), + Value: linkBytes, + Tags: []storage.Tag{ + { + Name: hashTag, + Value: anchorHash, + }, + }, + } + + operations[i] = op + } + + err := s.store.Batch(operations) + if err != nil { + return orberrors.NewTransient(fmt.Errorf("store anchor links: %w", err)) + } + + return nil +} + +// GetLinks returns the links for the given anchor hash. +func (s *Store) GetLinks(anchorHash string) ([]*url.URL, error) { + logger.Debugf("Retrieving anchor links for hash [%s]...", anchorHash) + + var err error + + query := fmt.Sprintf("%s:%s", hashTag, anchorHash) + + iter, err := s.store.Query(query) + if err != nil { + return nil, orberrors.NewTransient(fmt.Errorf("failed to get links for anchor [%s] query[%s]: %w", + anchorHash, query, err)) + } + + ok, err := iter.Next() + if err != nil { + return nil, orberrors.NewTransient(fmt.Errorf("iterator error for anchor [%s]: %w", anchorHash, err)) + } + + var links []*url.URL + + for ok { + value, err := iter.Value() + if err != nil { + return nil, orberrors.NewTransient(fmt.Errorf("failed to get iterator value for anchor [%s]: %w", + anchorHash, err)) + } + + var link string + + err = s.unmarshal(value, &link) + if err != nil { + return nil, fmt.Errorf("unmarshal link [%s] for anchor [%s]: %w", value, anchorHash, err) + } + + u, err := url.Parse(link) + if err != nil { + return nil, fmt.Errorf("parse link [%s] for anchor [%s]: %w", link, anchorHash, err) + } + + links = append(links, u) + + ok, err = iter.Next() + if err != nil { + return nil, orberrors.NewTransient(fmt.Errorf("iterator error for anchor [%s]: %w", anchorHash, err)) + } + } + + logger.Debugf("Returning anchor links for hash [%s]: %s", anchorHash, links) + + return links, nil +} diff --git a/pkg/anchor/linkstore/linkstore_test.go b/pkg/anchor/linkstore/linkstore_test.go new file mode 100644 index 000000000..e059722e1 --- /dev/null +++ b/pkg/anchor/linkstore/linkstore_test.go @@ -0,0 +1,195 @@ +/* +Copyright SecureKey Technologies Inc. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +package linkstore + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "testing" + + "github.com/hyperledger/aries-framework-go/pkg/mock/storage" + "github.com/stretchr/testify/require" + + orberrors "github.com/trustbloc/orb/pkg/errors" + "github.com/trustbloc/orb/pkg/internal/testutil" +) + +func TestNew(t *testing.T) { + t.Run("Success", func(t *testing.T) { + s, err := New(storage.NewMockStoreProvider()) + require.NoError(t, err) + require.NotNil(t, s) + }) + + t.Run("Open store error", func(t *testing.T) { + provider := storage.NewMockStoreProvider() + + errExpected := errors.New("injected open store error") + + provider.ErrOpenStoreHandle = errExpected + + s, err := New(provider) + require.Error(t, err) + require.Contains(t, err.Error(), errExpected.Error()) + require.Nil(t, s) + }) + + t.Run("Open store error", func(t *testing.T) { + provider := storage.NewMockStoreProvider() + + errExpected := errors.New("injected set config error") + + provider.ErrSetStoreConfig = errExpected + + s, err := New(provider) + require.Error(t, err) + require.Contains(t, err.Error(), errExpected.Error()) + require.Nil(t, s) + }) +} + +func TestStore_PutLinks(t *testing.T) { + provider := storage.NewMockStoreProvider() + + s, err := New(provider) + require.NoError(t, err) + require.NotNil(t, s) + + t.Run("Success", func(t *testing.T) { + const hash1 = "uEiALYp_C4wk2WegpfnCSoSTBdKZ1MVdDadn4rdmZl5GKzQ" + const hash2 = "uEiBUQDRI5ttIzXbe1LZKUaZWb6yFsnMnrgDksAtQ-wCaKw" + + link1 := fmt.Sprintf("hl:%s:uoQ-BeEtodmdEa3NBdFEtd0NhS3c", hash1) + link2 := fmt.Sprintf("hl:%s:uoQ-BeEtodzZ4OVhtYkNTZjRfTWc", hash1) + link3 := fmt.Sprintf("hl:%s:uoQ-BeEtodmdEa3NBdFEtd0NhS3c", hash2) + + require.NoError(t, s.PutLinks( + []*url.URL{ + testutil.MustParseURL(link1), + testutil.MustParseURL(link2), + testutil.MustParseURL(link3), + }, + )) + }) + + t.Run("Invalid hashlink", func(t *testing.T) { + require.Error(t, s.PutLinks([]*url.URL{testutil.MustParseURL("https://xxx")})) + }) + + t.Run("Marshal error", func(t *testing.T) { + s.marshal = func(i interface{}) ([]byte, error) { return nil, errors.New("injected marshal error") } + defer func() { s.marshal = json.Marshal }() + + require.Error(t, s.PutLinks([]*url.URL{testutil.MustParseURL("hl:xxx")})) + }) + + t.Run("Store error", func(t *testing.T) { + errExpected := errors.New("injected batch error") + + provider.Store.ErrBatch = errExpected + + err := s.PutLinks([]*url.URL{testutil.MustParseURL("hl:xxx")}) + require.Error(t, err) + require.Contains(t, err.Error(), errExpected.Error()) + }) +} + +func TestStore_GetLinks(t *testing.T) { + const ( + hash1 = "uEiALYp_C4wk2WegpfnCSoSTBdKZ1MVdDadn4rdmZl5GKzQ" + hash2 = "uEiBUQDRI5ttIzXbe1LZKUaZWb6yFsnMnrgDksAtQ-wCaKw" + ) + + provider := storage.NewMockStoreProvider() + + s, err := New(provider) + require.NoError(t, err) + require.NotNil(t, s) + + t.Run("Success", func(t *testing.T) { + link1 := fmt.Sprintf("hl:%s:uoQ-BeEtodUZzbk1ucmdEa3NBdFEtd0NhS3c", hash1) + link2 := fmt.Sprintf("hl:%s:uoQ-BeEtodWJRbWI2SzZ4OVhtYkNTZjRfTWc", hash1) + link3 := fmt.Sprintf("hl:%s:uoQ-BeEtodUZzbk1ucmdEa3NBdFEtd0NhS3c", hash2) + + require.NoError(t, s.PutLinks( + []*url.URL{ + testutil.MustParseURL(link1), + testutil.MustParseURL(link2), + testutil.MustParseURL(link3), + }, + )) + + links, err := s.GetLinks(hash1) + require.NoError(t, err) + require.Len(t, links, 2) + + links, err = s.GetLinks(hash2) + require.NoError(t, err) + require.Len(t, links, 1) + }) + + t.Run("Query error", func(t *testing.T) { + errExpected := errors.New("injected query error") + + provider.Store.ErrQuery = errExpected + defer func() { provider.Store.ErrQuery = nil }() + + links, err := s.GetLinks(hash1) + require.Error(t, err) + require.Len(t, links, 0) + require.Contains(t, err.Error(), errExpected.Error()) + require.True(t, orberrors.IsTransient(err)) + }) + + t.Run("Iterator.Next error", func(t *testing.T) { + errExpected := errors.New("injected iterator error") + + provider.Store.ErrNext = errExpected + defer func() { provider.Store.ErrNext = nil }() + + links, err := s.GetLinks(hash1) + require.Error(t, err) + require.Len(t, links, 0) + require.Contains(t, err.Error(), errExpected.Error()) + require.True(t, orberrors.IsTransient(err)) + }) + + t.Run("Iterator.Value error", func(t *testing.T) { + errExpected := errors.New("injected iterator error") + + provider.Store.ErrValue = errExpected + defer func() { provider.Store.ErrValue = nil }() + + link1 := fmt.Sprintf("hl:%s:uoQ-BeEtodHRwczovL29yYi5kb0NhS3c", hash1) + + require.NoError(t, s.PutLinks([]*url.URL{testutil.MustParseURL(link1)})) + + links, err := s.GetLinks(hash1) + require.Error(t, err) + require.Len(t, links, 0) + require.Contains(t, err.Error(), errExpected.Error()) + require.True(t, orberrors.IsTransient(err)) + }) + + t.Run("Unmarshal error", func(t *testing.T) { + errExpected := errors.New("injected unmarshal error") + + s.unmarshal = func(data []byte, v interface{}) error { return errExpected } + + link1 := fmt.Sprintf("hl:%s:uoQ-BeEtodHRwczovL29yYi5kb21hNhS3c", hash1) + + require.NoError(t, s.PutLinks([]*url.URL{testutil.MustParseURL(link1)})) + + links, err := s.GetLinks(hash1) + require.Error(t, err) + require.Len(t, links, 0) + require.Contains(t, err.Error(), errExpected.Error()) + require.False(t, orberrors.IsTransient(err)) + }) +} diff --git a/pkg/cas/ipfs/ipfs.go b/pkg/cas/ipfs/ipfs.go index c20cfd9ae..a4c7d7e79 100644 --- a/pkg/cas/ipfs/ipfs.go +++ b/pkg/cas/ipfs/ipfs.go @@ -168,9 +168,7 @@ func (m *Client) Read(cidOrHash string) ([]byte, error) { func (m *Client) get(cid string) ([]byte, error) { startTime := time.Now() - defer func() { - m.metrics.CASReadTime(casType, time.Since(startTime)) - }() + defer m.metrics.CASReadTime(casType, time.Since(startTime)) reader, err := m.ipfs.Cat(cid) if err != nil { diff --git a/pkg/cas/resolver/resolver_test.go b/pkg/cas/resolver/resolver_test.go index 9b879a032..5246990e3 100644 --- a/pkg/cas/resolver/resolver_test.go +++ b/pkg/cas/resolver/resolver_test.go @@ -216,6 +216,8 @@ func TestResolver_Resolve(t *testing.T) { casClient := &resolvermocks.CASClient{} casClient.ReadReturns([]byte(sampleData), nil) + linkStore := &orbmocks.AnchorLinkStore{} + webCAS := webcas.New(&resthandler.Config{}, memstore.New(""), &mocks.SignatureVerifier{}, casClient) require.NotNil(t, webCAS) @@ -227,7 +229,9 @@ func TestResolver_Resolve(t *testing.T) { testServer := httptest.NewServer(router) defer testServer.Close() - operations, err := restapi.New(&restapi.Config{BaseURL: testServer.URL, WebCASPath: "/cas"}) + operations, err := restapi.New( + &restapi.Config{BaseURL: testServer.URL, WebCASPath: "/cas"}, + &restapi.Providers{CAS: casClient, AnchorLinkStore: linkStore}) require.NoError(t, err) router.HandleFunc(operations.GetRESTHandlers()[1].Path(), operations.GetRESTHandlers()[1].Handler()) @@ -274,11 +278,15 @@ func TestResolver_Resolve(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, hl) + linkStore := &orbmocks.AnchorLinkStore{} + rh, err := hashlink.GetResourceHashFromHashLink(hl) require.NoError(t, err) // remote server doesn't have cid (clean CAS) - webCAS := webcas.New(&resthandler.Config{}, memstore.New(""), &mocks.SignatureVerifier{}, createInMemoryCAS(t)) + webCAS := webcas.New( + &resthandler.Config{}, + memstore.New(""), &mocks.SignatureVerifier{}, createInMemoryCAS(t)) require.NotNil(t, webCAS) router := mux.NewRouter() @@ -289,7 +297,10 @@ func TestResolver_Resolve(t *testing.T) { testServer := httptest.NewServer(router) defer testServer.Close() - operations, err := restapi.New(&restapi.Config{BaseURL: testServer.URL, WebCASPath: "/cas"}) + operations, err := restapi.New( + &restapi.Config{BaseURL: testServer.URL, WebCASPath: "/cas"}, + &restapi.Providers{CAS: casClient, AnchorLinkStore: linkStore}, + ) require.NoError(t, err) router.HandleFunc(operations.GetRESTHandlers()[1].Path(), operations.GetRESTHandlers()[1].Handler()) diff --git a/pkg/discovery/endpoint/restapi/operations.go b/pkg/discovery/endpoint/restapi/operations.go index d58547e28..aea8ee657 100644 --- a/pkg/discovery/endpoint/restapi/operations.go +++ b/pkg/discovery/endpoint/restapi/operations.go @@ -7,6 +7,7 @@ package restapi import ( "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -16,6 +17,9 @@ import ( "github.com/trustbloc/edge-core/pkg/log" "github.com/trustbloc/sidetree-core-go/pkg/restapi/common" + orberrors "github.com/trustbloc/orb/pkg/errors" + "github.com/trustbloc/orb/pkg/hashlink" + "github.com/trustbloc/orb/pkg/multihash" "github.com/trustbloc/orb/pkg/resolver/resource/registry" ) @@ -31,10 +35,11 @@ const ( webDIDEndpoint = "/.well-known/did.json" nodeInfoEndpoint = "/.well-known/nodeinfo" - selfRelation = "self" - alternateRelation = "alternate" - viaRelation = "via" - serviceRelation = "service" + selfRelation = "self" + alternateRelation = "alternate" + viaRelation = "via" + serviceRelation = "service" + workingCopyRelation = "working-copy" ldJSONType = "application/ld+json" jrdJSONType = "application/jrd+json" @@ -51,8 +56,16 @@ const ( context = "https://w3id.org/did/v1" ) +type cas interface { + Read(address string) ([]byte, error) +} + +type anchorLinkStore interface { + GetLinks(anchorHash string) ([]*url.URL, error) +} + // New returns discovery operations. -func New(c *Config) (*Operation, error) { +func New(c *Config, p *Providers) (*Operation, error) { u, err := url.Parse(c.BaseURL) if err != nil { return nil, fmt.Errorf("parse base URL: %w", err) @@ -76,7 +89,9 @@ func New(c *Config) (*Operation, error) { discoveryMinimumResolvers: c.DiscoveryMinimumResolvers, discoveryDomains: c.DiscoveryDomains, discoveryVctDomains: c.DiscoveryVctDomains, - resourceRegistry: c.ResourceRegistry, + resourceRegistry: p.ResourceRegistry, + cas: p.CAS, + anchorStore: p.AnchorLinkStore, }, nil } @@ -95,6 +110,8 @@ type Operation struct { discoveryVctDomains []string discoveryMinimumResolvers int resourceRegistry *registry.Registry + cas cas + anchorStore anchorLinkStore } // Config defines configuration for discovery operations. @@ -110,7 +127,13 @@ type Config struct { DiscoveryDomains []string DiscoveryVctDomains []string DiscoveryMinimumResolvers int - ResourceRegistry *registry.Registry +} + +// Providers defines the providers for discovery operations. +type Providers struct { + ResourceRegistry *registry.Registry + CAS cas + AnchorLinkStore anchorLinkStore } // GetRESTHandlers get all controller API handler available for this service. @@ -205,7 +228,6 @@ func (o *Operation) nodeInfoHandler(rw http.ResponseWriter, r *http.Request) { }, http.StatusOK) } -//nolint:funlen,gocyclo,cyclop func (o *Operation) writeResponseForResourceRequest(rw http.ResponseWriter, resource string) { switch { case resource == fmt.Sprintf("%s%s", o.baseURL, o.resolutionPath): @@ -213,7 +235,7 @@ func (o *Operation) writeResponseForResourceRequest(rw http.ResponseWriter, reso Subject: resource, Properties: map[string]interface{}{minResolvers: o.discoveryMinimumResolvers}, Links: []Link{ - {Rel: "self", Href: resource}, + {Rel: selfRelation, Href: resource}, }, } @@ -229,7 +251,7 @@ func (o *Operation) writeResponseForResourceRequest(rw http.ResponseWriter, reso resp := &JRD{ Subject: resource, Links: []Link{ - {Rel: "self", Href: resource}, + {Rel: selfRelation, Href: resource}, }, } @@ -244,73 +266,80 @@ func (o *Operation) writeResponseForResourceRequest(rw http.ResponseWriter, reso case strings.HasPrefix(resource, fmt.Sprintf("%s%s", o.baseURL, o.webCASPath)): o.handleWebCASQuery(rw, resource) case strings.HasPrefix(resource, "did:orb:"): - // TODO (#536): Support resources other than did:orb. - // TODO (#537): Show IPFS alternates if configured. - metadata, err := o.resourceRegistry.GetResourceInfo(resource) - if err != nil { - writeErrorResponse(rw, http.StatusInternalServerError, - fmt.Sprintf("failed to get info on %s: %s", resource, err.Error())) + o.handleDIDOrbQuery(rw, resource) + // TODO (#536): Support resources other than did:orb. + default: + writeErrorResponse(rw, http.StatusNotFound, fmt.Sprintf("resource %s not found,", resource)) + } +} - return - } +func (o *Operation) getAnchorInfo(resource string) (interface{}, string, error) { + // TODO (#537): Show IPFS alternates if configured. + metadata, err := o.resourceRegistry.GetResourceInfo(resource) + if err != nil { + return "", "", fmt.Errorf("get info for resource [%s]: %w", resource, err) + } - anchorOrigin, ok := metadata[registry.AnchorOriginProperty] - if !ok { - writeErrorResponse(rw, http.StatusBadRequest, "anchor origin property missing from metadata") + anchorOrigin, ok := metadata[registry.AnchorOriginProperty] + if !ok { + return "", "", fmt.Errorf("anchor origin property missing from metadata for resource [%s]", resource) + } - return - } + anchorURIRaw, ok := metadata[registry.AnchorURIProperty] + if !ok { + return "", "", fmt.Errorf("anchor URI property missing from metadata for resource [%s]", resource) + } - anchorURIRaw, ok := metadata[registry.AnchorURIProperty] - if !ok { - writeErrorResponse(rw, http.StatusBadRequest, "anchor URI property missing from metadata") + anchorURI, ok := anchorURIRaw.(string) + if !ok { + return "", "", fmt.Errorf("anchor URI could not be asserted as a string for resource [%s]", resource) + } - return - } + return anchorOrigin, anchorURI, nil +} - anchorURI, ok := anchorURIRaw.(string) - if !ok { - writeErrorResponse(rw, http.StatusBadRequest, "anchor URI could not be asserted as a string") +func (o *Operation) handleDIDOrbQuery(rw http.ResponseWriter, resource string) { + anchorOrigin, anchorURI, err := o.getAnchorInfo(resource) + if err != nil { + writeErrorResponse(rw, http.StatusInternalServerError, + fmt.Sprintf("failed to get info on %s: %s", resource, err.Error())) - return - } + return + } - resp := &JRD{ - Properties: map[string]interface{}{ - "https://trustbloc.dev/ns/anchor-origin": anchorOrigin, - minResolvers: o.discoveryMinimumResolvers, + resp := &JRD{ + Properties: map[string]interface{}{ + "https://trustbloc.dev/ns/anchor-origin": anchorOrigin, + minResolvers: o.discoveryMinimumResolvers, + }, + Links: []Link{ + { + Rel: selfRelation, + Type: didLDJSONType, + Href: fmt.Sprintf("%s%s%s", o.baseURL, "/sidetree/v1/identifiers/", resource), }, - Links: []Link{ - { - Rel: selfRelation, - Type: didLDJSONType, - Href: fmt.Sprintf("%s%s%s", o.baseURL, "/sidetree/v1/identifiers/", resource), - }, - { - Rel: viaRelation, - Type: ldJSONType, - Href: anchorURI, - }, - { - Rel: serviceRelation, - Type: ActivityJSONType, - Href: constructActivityPubURL(o.baseURL), - }, + { + Rel: viaRelation, + Type: ldJSONType, + Href: anchorURI, }, - } - - for _, discoveryDomain := range o.discoveryDomains { - resp.Links = append(resp.Links, Link{ - Rel: alternateRelation, - Type: didLDJSONType, - Href: fmt.Sprintf("%s%s%s", discoveryDomain, "/sidetree/v1/identifiers/", resource), - }) - } + { + Rel: serviceRelation, + Type: ActivityJSONType, + Href: constructActivityPubURL(o.baseURL), + }, + }, + } - writeResponse(rw, resp, http.StatusOK) - default: - writeErrorResponse(rw, http.StatusNotFound, fmt.Sprintf("resource %s not found,", resource)) + for _, discoveryDomain := range o.appendAlternateDomains(o.discoveryDomains, anchorURI) { + resp.Links = append(resp.Links, Link{ + Rel: alternateRelation, + Type: didLDJSONType, + Href: fmt.Sprintf("%s%s%s", discoveryDomain, "/sidetree/v1/identifiers/", resource), + }) } + + writeResponse(rw, resp, http.StatusOK) } func (o *Operation) handleWebCASQuery(rw http.ResponseWriter, resource string) { @@ -318,18 +347,42 @@ func (o *Operation) handleWebCASQuery(rw http.ResponseWriter, resource string) { cid := resourceSplitBySlash[len(resourceSplitBySlash)-1] + // Ensure that the CID is resolvable. + _, err := o.cas.Read(cid) + if err != nil { + if errors.Is(err, orberrors.ErrContentNotFound) { + logger.Debugf("CAS resource not found [%s]", cid) + + writeErrorResponse(rw, http.StatusNotFound, "resource not found") + } else { + logger.Warnf("Error returning CAS resource [%s]: %s", cid, err) + + writeErrorResponse(rw, http.StatusInternalServerError, "error retrieving resource") + } + + return + } + resp := &JRD{ Subject: resource, Links: []Link{ - {Rel: "self", Type: "application/ld+json", Href: resource}, - {Rel: "working-copy", Type: "application/ld+json", Href: fmt.Sprintf("%s/cas/%s", o.baseURL, cid)}, + {Rel: selfRelation, Type: ldJSONType, Href: resource}, }, } + // Add the local reference. + refs := []string{fmt.Sprintf("%s/cas/%s", o.baseURL, cid)} + + // Add the references from the configured discovery domains. for _, v := range o.discoveryDomains { + refs = append(refs, fmt.Sprintf("%s/cas/%s", v, cid)) + } + + // Add references from the anchor link storage. + for _, ref := range o.appendAlternateAnchorRefs(refs, cid) { resp.Links = append(resp.Links, Link{ - Rel: "working-copy", Type: "application/ld+json", Href: fmt.Sprintf("%s/cas/%s", v, cid), + Rel: workingCopyRelation, Type: ldJSONType, Href: ref, }) } @@ -385,6 +438,105 @@ func (o *Operation) respondWithHostMetaJSON(rw http.ResponseWriter) { writeResponse(rw, resp, http.StatusOK) } +func (o *Operation) appendAlternateDomains(domains []string, anchorURI string) []string { + parser := hashlink.New() + + anchorInfo, err := parser.ParseHashLink(anchorURI) + if err != nil { + logger.Infof("Error parsing hashlink for anchor URI [%s]: %w", anchorURI, err) + + return domains + } + + alternates, err := o.anchorStore.GetLinks(anchorInfo.ResourceHash) + if err != nil { + logger.Infof("Error getting alternate links for anchor URI [%s]: %w", anchorURI, err) + + return domains + } + + for _, domain := range getDomainsFromHashLinks(alternates) { + if !contains(domains, domain) { + domains = append(domains, domain) + } + } + + return domains +} + +func (o *Operation) appendAlternateAnchorRefs(refs []string, cidOrHash string) []string { + hash, e := multihash.CIDToMultihash(cidOrHash) + if e != nil { + hash = cidOrHash + } else if hash != cidOrHash { + logger.Debugf("Converted CID [%s] to multihash [%s]", cidOrHash, hash) + } + + alternates, err := o.anchorStore.GetLinks(hash) + if err != nil { + // Not fatal. + logger.Warnf("Error retrieving additional links for resource [%s]: %s", cidOrHash, err) + + return refs + } + + parser := hashlink.New() + + for _, hl := range alternates { + hlInfo, err := parser.ParseHashLink(hl.String()) + if err != nil { + logger.Warnf("Error parsing hashlink [%s]: %s", hl, err) + + continue + } + + for _, l := range hlInfo.Links { + if !contains(refs, l) { + refs = append(refs, l) + } + } + } + + return refs +} + +func getDomainsFromHashLinks(hashLinks []*url.URL) []string { + parser := hashlink.New() + + var domains []string + + for _, hl := range hashLinks { + hlInfo, err := parser.ParseHashLink(hl.String()) + if err != nil { + logger.Warnf("Error parsing hashlink [%s]: %s", hl, err) + + continue + } + + for _, l := range hlInfo.Links { + link, err := url.Parse(l) + if err != nil { + logger.Warnf("Error parsing additional anchor link [%s] for hash [%s]: %s", + l, hlInfo.ResourceHash, err) + + continue + } + + if !strings.EqualFold(link.Scheme, "https") { + continue + } + + domain := fmt.Sprintf("https://%s", link.Host) + + if !contains(domains, domain) { + domains = append(domains, domain) + } + } + } + + return domains +} + // writeErrorResponse write error resp. func writeErrorResponse(rw http.ResponseWriter, status int, msg string) { rw.Header().Add("Content-Type", "application/json") @@ -439,3 +591,13 @@ func (h *httpHandler) Handler() common.HTTPRequestHandler { func constructActivityPubURL(baseURL string) string { return fmt.Sprintf("%s%s", baseURL, "/services/orb") } + +func contains(strs []string, str string) bool { + for _, s := range strs { + if s == str { + return true + } + } + + return false +} diff --git a/pkg/discovery/endpoint/restapi/operations_test.go b/pkg/discovery/endpoint/restapi/operations_test.go index 037d3212d..664a9fdc7 100644 --- a/pkg/discovery/endpoint/restapi/operations_test.go +++ b/pkg/discovery/endpoint/restapi/operations_test.go @@ -8,15 +8,21 @@ package restapi_test import ( "bytes" "encoding/json" + "errors" "net/http" "net/http/httptest" + "net/url" "testing" "github.com/gorilla/mux" "github.com/stretchr/testify/require" "github.com/trustbloc/sidetree-core-go/pkg/restapi/common" + "github.com/trustbloc/orb/pkg/cas/resolver/mocks" "github.com/trustbloc/orb/pkg/discovery/endpoint/restapi" + orberrors "github.com/trustbloc/orb/pkg/errors" + "github.com/trustbloc/orb/pkg/internal/testutil" + orbmocks "github.com/trustbloc/orb/pkg/mocks" "github.com/trustbloc/orb/pkg/resolver/resource/registry" ) @@ -27,12 +33,28 @@ const ( nodeInfoEndpoint = "/.well-known/nodeinfo" ) -type mockResourceInfoProvider struct{} +type mockResourceInfoProvider struct { + anchorOrigin string + anchorURI string +} + +func newMockResourceInfoProvider() *mockResourceInfoProvider { + return &mockResourceInfoProvider{ + anchorOrigin: "MockAnchorOrigin", + anchorURI: "MockAnchorURI", + } +} + +func (m *mockResourceInfoProvider) withAnchorURI(value string) *mockResourceInfoProvider { + m.anchorURI = value + + return m +} func (m *mockResourceInfoProvider) GetResourceInfo(string) (registry.Metadata, error) { return map[string]interface{}{ - registry.AnchorOriginProperty: "MockAnchorOrigin", - registry.AnchorURIProperty: "MockAnchorURI", + registry.AnchorOriginProperty: m.anchorOrigin, + registry.AnchorURIProperty: m.anchorURI, }, nil } @@ -42,19 +64,19 @@ func (m *mockResourceInfoProvider) Accept(string) bool { func TestGetRESTHandlers(t *testing.T) { t.Run("Error - invalid base URL", func(t *testing.T) { - c, err := restapi.New(&restapi.Config{BaseURL: "://"}) + c, err := restapi.New(&restapi.Config{BaseURL: "://"}, nil) require.EqualError(t, err, "parse base URL: parse \"://\": missing protocol scheme") require.Nil(t, c) }) t.Run("Error - empty WebCAS path", func(t *testing.T) { - c, err := restapi.New(&restapi.Config{BaseURL: "https://example.com"}) + c, err := restapi.New(&restapi.Config{BaseURL: "https://example.com"}, &restapi.Providers{}) require.EqualError(t, err, "webCAS path cannot be empty") require.Nil(t, c) }) t.Run("Success", func(t *testing.T) { - c, err := restapi.New(&restapi.Config{BaseURL: "https://example.com", WebCASPath: "/cas"}) + c, err := restapi.New(&restapi.Config{BaseURL: "https://example.com", WebCASPath: "/cas"}, &restapi.Providers{}) require.NoError(t, err) require.Equal(t, 6, len(c.GetRESTHandlers())) }) @@ -67,7 +89,7 @@ func TestWebFinger(t *testing.T) { ResolutionPath: "/resolve", WebCASPath: "/cas", BaseURL: "http://base", - }) + }, &restapi.Providers{}) require.NoError(t, err) @@ -85,7 +107,7 @@ func TestWebFinger(t *testing.T) { ResolutionPath: "/resolve", WebCASPath: "/cas", BaseURL: "http://base", - }) + }, &restapi.Providers{}) require.NoError(t, err) handler := getHandler(t, c, restapi.WebFingerEndpoint) @@ -104,7 +126,7 @@ func TestWebFinger(t *testing.T) { BaseURL: "http://base", DiscoveryDomains: []string{"http://domain1"}, DiscoveryMinimumResolvers: 2, - }) + }, &restapi.Providers{}) require.NoError(t, err) handler := getHandler(t, c, restapi.WebFingerEndpoint) @@ -130,7 +152,7 @@ func TestWebFinger(t *testing.T) { BaseURL: "http://base", DiscoveryDomains: []string{"http://domain1"}, DiscoveryMinimumResolvers: 2, - }) + }, &restapi.Providers{}) require.NoError(t, err) handler := getHandler(t, c, restapi.WebFingerEndpoint) @@ -149,6 +171,14 @@ func TestWebFinger(t *testing.T) { }) t.Run("test WebCAS resource", func(t *testing.T) { + casClient := &mocks.CASClient{} + + linkStore := &orbmocks.AnchorLinkStore{} + linkStore.GetLinksReturns([]*url.URL{ + testutil.MustParseURL( + "hl:uEiALYp_C4wk2WegpfnCSoSTBdKZ1MVdDadn4rdmZl5GKzQ:uoQ-BeDVpcGZzOi8vUW1jcTZKV0RVa3l4ZWhxN1JWWmtQM052aUU0SHFSdW5SalgzOXZ1THZFSGFRTg"), //nolint:lll + }, nil) + c, err := restapi.New(&restapi.Config{ OperationPath: "/op", ResolutionPath: "/resolve", @@ -156,30 +186,119 @@ func TestWebFinger(t *testing.T) { BaseURL: "http://base", DiscoveryDomains: []string{"http://domain1"}, DiscoveryMinimumResolvers: 2, + }, &restapi.Providers{ + CAS: casClient, + AnchorLinkStore: linkStore, }) require.NoError(t, err) handler := getHandler(t, c, restapi.WebFingerEndpoint) - rr := serveHTTP(t, handler.Handler(), http.MethodGet, restapi.WebFingerEndpoint+ - "?resource=http://base/cas/bafkreiatkubvbkdidscmqynkyls3iqawdqvthi7e6mbky2amuw3inxsi3y", nil, nil, false) + t.Run("Success with CID", func(t *testing.T) { + casClient.ReadReturns(nil, nil) - require.Equal(t, http.StatusOK, rr.Code) + rr := serveHTTP(t, handler.Handler(), http.MethodGet, restapi.WebFingerEndpoint+ + "?resource=http://base/cas/bafkreiatkubvbkdidscmqynkyls3iqawdqvthi7e6mbky2amuw3inxsi3y", nil, nil, false) - var w restapi.JRD + require.Equal(t, http.StatusOK, rr.Code) - require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &w)) - require.Len(t, w.Links, 3) - require.Equal(t, "http://base/cas/bafkreiatkubvbkdidscmqynkyls3iqawdqvthi7e6mbky2amuw3inxsi3y", - w.Links[0].Href) - require.Equal(t, "http://base/cas/bafkreiatkubvbkdidscmqynkyls3iqawdqvthi7e6mbky2amuw3inxsi3y", - w.Links[1].Href) - require.Equal(t, "http://domain1/cas/bafkreiatkubvbkdidscmqynkyls3iqawdqvthi7e6mbky2amuw3inxsi3y", - w.Links[2].Href) - require.Empty(t, w.Properties) + var w restapi.JRD + + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &w)) + require.Len(t, w.Links, 4) + require.Equal(t, "http://base/cas/bafkreiatkubvbkdidscmqynkyls3iqawdqvthi7e6mbky2amuw3inxsi3y", + w.Links[0].Href) + require.Equal(t, "http://base/cas/bafkreiatkubvbkdidscmqynkyls3iqawdqvthi7e6mbky2amuw3inxsi3y", + w.Links[1].Href) + require.Equal(t, "http://domain1/cas/bafkreiatkubvbkdidscmqynkyls3iqawdqvthi7e6mbky2amuw3inxsi3y", + w.Links[2].Href) + require.Equal(t, "ipfs://Qmcq6JWDUkyxehq7RVZkP3NviE4HqRunRjX39vuLvEHaQN", + w.Links[3].Href) + require.Empty(t, w.Properties) + }) + + t.Run("Success with multihash", func(t *testing.T) { + casClient.ReadReturns(nil, nil) + + rr := serveHTTP(t, handler.Handler(), http.MethodGet, restapi.WebFingerEndpoint+ + "?resource=http://base/cas/uEiATVQNQqGgchMhhqsLltEAWHCszo-TzAqxoDKW2ht5I3g", nil, nil, false) + + require.Equal(t, http.StatusOK, rr.Code) + + var w restapi.JRD + + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &w)) + require.Len(t, w.Links, 4) + require.Equal(t, "http://base/cas/uEiATVQNQqGgchMhhqsLltEAWHCszo-TzAqxoDKW2ht5I3g", + w.Links[0].Href) + require.Equal(t, "http://base/cas/uEiATVQNQqGgchMhhqsLltEAWHCszo-TzAqxoDKW2ht5I3g", + w.Links[1].Href) + require.Equal(t, "http://domain1/cas/uEiATVQNQqGgchMhhqsLltEAWHCszo-TzAqxoDKW2ht5I3g", + w.Links[2].Href) + require.Equal(t, "ipfs://Qmcq6JWDUkyxehq7RVZkP3NviE4HqRunRjX39vuLvEHaQN", + w.Links[3].Href) + require.Empty(t, w.Properties) + }) + + t.Run("Resource not found", func(t *testing.T) { + casClient.ReadReturns(nil, orberrors.ErrContentNotFound) + + rr := serveHTTP(t, handler.Handler(), http.MethodGet, restapi.WebFingerEndpoint+ + "?resource=http://base/cas/bafkreiatkubvbkdidscmqynkyls3iqawdqvthi7e6mbky2amuw3inxsi3y", nil, nil, false) + + require.Equal(t, http.StatusNotFound, rr.Code) + }) + + t.Run("CAS error", func(t *testing.T) { + casClient.ReadReturns(nil, errors.New("injected CAS client error")) + + rr := serveHTTP(t, handler.Handler(), http.MethodGet, restapi.WebFingerEndpoint+ + "?resource=http://base/cas/bafkreiatkubvbkdidscmqynkyls3iqawdqvthi7e6mbky2amuw3inxsi3y", nil, nil, false) + + require.Equal(t, http.StatusInternalServerError, rr.Code) + }) + + t.Run("Anchor link storage error", func(t *testing.T) { + casClient.ReadReturns(nil, nil) + linkStore.GetLinksReturns(nil, errors.New("injected storage error")) + + rr := serveHTTP(t, handler.Handler(), http.MethodGet, restapi.WebFingerEndpoint+ + "?resource=http://base/cas/bafkreiatkubvbkdidscmqynkyls3iqawdqvthi7e6mbky2amuw3inxsi3y", nil, nil, false) + + require.Equal(t, http.StatusOK, rr.Code) + + var w restapi.JRD + + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &w)) + + // The alternate link won't be included due to a storage error, but it should still return results. + require.Len(t, w.Links, 3) + }) + + t.Run("Invalid alternate hashlink", func(t *testing.T) { + casClient.ReadReturns(nil, nil) + linkStore.GetLinksReturns([]*url.URL{testutil.MustParseURL("xl:xxx")}, nil) + + rr := serveHTTP(t, handler.Handler(), http.MethodGet, restapi.WebFingerEndpoint+ + "?resource=http://base/cas/bafkreiatkubvbkdidscmqynkyls3iqawdqvthi7e6mbky2amuw3inxsi3y", nil, nil, false) + + require.Equal(t, http.StatusOK, rr.Code) + + var w restapi.JRD + + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &w)) + + // The alternate link won't be included due to a storage error, but it should still return results. + require.Len(t, w.Links, 3) + }) }) t.Run("test did:orb resource", func(t *testing.T) { + const anchorURI = "hl:uEiALYp_C4wk2WegpfnCSoSTBdKZ1MVdDadn4rdmZl5GKzQ:uoQ-BeDVpcGZzOi8vUW1jcTZKV0RVa3l4ZWhxN1JWWmtQM052aUU0SHFSdW5SalgzOXZ1THZFSGFRTg" //nolint:lll + + linkStore := &orbmocks.AnchorLinkStore{} + resourceInfoProvider := newMockResourceInfoProvider().withAnchorURI(anchorURI) + c, err := restapi.New(&restapi.Config{ OperationPath: "/op", ResolutionPath: "/resolve", @@ -189,43 +308,102 @@ func TestWebFinger(t *testing.T) { DiscoveryVctDomains: []string{"http://vct.com/maple2019"}, DiscoveryMinimumResolvers: 2, VctURL: "http://vct.com/maple2020", - ResourceRegistry: registry.New(registry.WithResourceInfoProvider(&mockResourceInfoProvider{})), + }, &restapi.Providers{ + ResourceRegistry: registry.New(registry.WithResourceInfoProvider(resourceInfoProvider)), + AnchorLinkStore: linkStore, }) require.NoError(t, err) handler := getHandler(t, c, restapi.WebFingerEndpoint) - rr := serveHTTP(t, handler.Handler(), http.MethodGet, restapi.WebFingerEndpoint+ - "?resource=did:orb:suffix", nil, nil, false) + t.Run("Success", func(t *testing.T) { + resourceInfoProvider.withAnchorURI(anchorURI) - require.Equal(t, http.StatusOK, rr.Code) + linkStore.GetLinksReturns([]*url.URL{ + testutil.MustParseURL( + "hl:uEiBUQDRI5ttIzXbe1LZKUaZWb6yFsnMnrgDksAtQ-wCaKw:uoQ-BeEtodHRwczovL29yYi5kb21haW4yLmNvbS9jYXMvdUVpQlVRRFJJNXR0SXpYYmUxTFpLVWFaV2I2eUZzbk1ucmdEa3NBdFEtd0NhS3c"), //nolint:lll + }, nil) - var w restapi.JRD + rr := serveHTTP(t, handler.Handler(), http.MethodGet, restapi.WebFingerEndpoint+ + "?resource=did:orb:suffix", nil, nil, false) - require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &w)) + require.Equal(t, http.StatusOK, rr.Code) - require.Len(t, w.Properties, 2) + var w restapi.JRD - require.Equal(t, "MockAnchorOrigin", w.Properties["https://trustbloc.dev/ns/anchor-origin"]) - require.Equal(t, float64(2), w.Properties["https://trustbloc.dev/ns/min-resolvers"]) + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &w)) - require.Len(t, w.Links, 4) + require.Len(t, w.Properties, 2) - require.Equal(t, "self", w.Links[0].Rel) - require.Equal(t, "application/did+ld+json", w.Links[0].Type) - require.Equal(t, "http://base/sidetree/v1/identifiers/did:orb:suffix", w.Links[0].Href) + require.Equal(t, "MockAnchorOrigin", w.Properties["https://trustbloc.dev/ns/anchor-origin"]) + require.Equal(t, float64(2), w.Properties["https://trustbloc.dev/ns/min-resolvers"]) - require.Equal(t, "via", w.Links[1].Rel) - require.Equal(t, "application/ld+json", w.Links[1].Type) - require.Equal(t, "MockAnchorURI", w.Links[1].Href) + require.Len(t, w.Links, 5) + + require.Equal(t, "self", w.Links[0].Rel) + require.Equal(t, "application/did+ld+json", w.Links[0].Type) + require.Equal(t, "http://base/sidetree/v1/identifiers/did:orb:suffix", w.Links[0].Href) - require.Equal(t, "service", w.Links[2].Rel) - require.Equal(t, "application/activity+json", w.Links[2].Type) - require.Equal(t, "http://base/services/orb", w.Links[2].Href) + require.Equal(t, "via", w.Links[1].Rel) + require.Equal(t, "application/ld+json", w.Links[1].Type) + require.Equal(t, anchorURI, w.Links[1].Href) + + require.Equal(t, "service", w.Links[2].Rel) + require.Equal(t, "application/activity+json", w.Links[2].Type) + require.Equal(t, "http://base/services/orb", w.Links[2].Href) + + require.Equal(t, "alternate", w.Links[3].Rel) + require.Equal(t, "application/did+ld+json", w.Links[3].Type) + require.Equal(t, "http://domain1/sidetree/v1/identifiers/did:orb:suffix", w.Links[3].Href) + + require.Equal(t, "alternate", w.Links[4].Rel) + require.Equal(t, "application/did+ld+json", w.Links[4].Type) + require.Equal(t, "https://orb.domain2.com/sidetree/v1/identifiers/did:orb:suffix", w.Links[4].Href) + }) + + t.Run("Invalid hashlink for anchor URI", func(t *testing.T) { + resourceInfoProvider.withAnchorURI("https://xxx") + + rr := serveHTTP(t, handler.Handler(), http.MethodGet, restapi.WebFingerEndpoint+ + "?resource=did:orb:suffix", nil, nil, false) + + require.Equal(t, http.StatusOK, rr.Code) - require.Equal(t, "alternate", w.Links[3].Rel) - require.Equal(t, "application/did+ld+json", w.Links[3].Type) - require.Equal(t, "http://domain1/sidetree/v1/identifiers/did:orb:suffix", w.Links[3].Href) + var w restapi.JRD + + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &w)) + + require.Len(t, w.Properties, 2) + + require.Equal(t, "MockAnchorOrigin", w.Properties["https://trustbloc.dev/ns/anchor-origin"]) + require.Equal(t, float64(2), w.Properties["https://trustbloc.dev/ns/min-resolvers"]) + + // The alternate link won't be included due to a parse error, but it should still return results. + require.Len(t, w.Links, 4) + }) + + t.Run("Anchor link storage error", func(t *testing.T) { + resourceInfoProvider.withAnchorURI(anchorURI) + + linkStore.GetLinksReturns(nil, errors.New("injected storage error")) + + rr := serveHTTP(t, handler.Handler(), http.MethodGet, restapi.WebFingerEndpoint+ + "?resource=did:orb:suffix", nil, nil, false) + + require.Equal(t, http.StatusOK, rr.Code) + + var w restapi.JRD + + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &w)) + + require.Len(t, w.Properties, 2) + + require.Equal(t, "MockAnchorOrigin", w.Properties["https://trustbloc.dev/ns/anchor-origin"]) + require.Equal(t, float64(2), w.Properties["https://trustbloc.dev/ns/min-resolvers"]) + + // The alternate link won't be included due to a storage error, but it should still return results. + require.Len(t, w.Links, 4) + }) }) } @@ -240,7 +418,7 @@ func TestHostMeta(t *testing.T) { DiscoveryDomains: []string{"http://domain1"}, VctURL: "http://vct", DiscoveryMinimumResolvers: 2, - }) + }, &restapi.Providers{}) require.NoError(t, err) handler := getHandler(t, c, hostMetaEndpoint) @@ -279,7 +457,7 @@ func TestHostMeta(t *testing.T) { DiscoveryDomains: []string{"http://domain1"}, VctURL: "http://vct", DiscoveryMinimumResolvers: 2, - }) + }, &restapi.Providers{}) require.NoError(t, err) handler := getHandler(t, c, restapi.HostMetaJSONEndpoint) @@ -319,7 +497,7 @@ func TestHostMeta(t *testing.T) { DiscoveryDomains: []string{"http://domain1"}, VctURL: "http://vct", DiscoveryMinimumResolvers: 2, - }) + }, &restapi.Providers{}) require.NoError(t, err) handler := getHandler(t, c, hostMetaEndpoint) @@ -343,7 +521,7 @@ func TestWellKnownDID(t *testing.T) { c, err := restapi.New(&restapi.Config{ BaseURL: "https://example.com", WebCASPath: "/cas", - }) + }, &restapi.Providers{}) require.NoError(t, err) handler := getHandler(t, c, webDIDEndpoint) @@ -365,7 +543,7 @@ func TestWellKnown(t *testing.T) { ResolutionPath: "/resolve", WebCASPath: "/cas", BaseURL: "http://base", - }) + }, &restapi.Providers{}) require.NoError(t, err) handler := getHandler(t, c, didOrbEndpoint) @@ -387,7 +565,7 @@ func TestWellKnownNodeInfo(t *testing.T) { ResolutionPath: "/resolve", WebCASPath: "/cas", BaseURL: "http://base", - }) + }, &restapi.Providers{}) require.NoError(t, err) handler := getHandler(t, c, nodeInfoEndpoint) diff --git a/pkg/hashlink/hashlink.go b/pkg/hashlink/hashlink.go index 18538dea0..ef8e17dc4 100644 --- a/pkg/hashlink/hashlink.go +++ b/pkg/hashlink/hashlink.go @@ -9,6 +9,7 @@ package hashlink import ( "encoding/base64" "fmt" + "net/url" "strings" cbor "github.com/fxamacker/cbor/v2" @@ -280,3 +281,25 @@ func (hl *HashLink) isValidMultihash(encodedMultihash string) error { return nil } + +// ToString parses the given hashlink(s) and returns a human-readable form. +func ToString(hl ...*url.URL) string { + str := "" + + parser := New() + + for i, hl := range hl { + if i > 0 { + str += ", " + } + + info, err := parser.ParseHashLink(hl.String()) + if err != nil { + str += fmt.Sprintf("{INVALID HASHLINK [%s]}", hl) + } else { + str += fmt.Sprintf("{Hash [%s], Links %s}", info.ResourceHash, info.Links) + } + } + + return str +} diff --git a/pkg/hashlink/hashlink_test.go b/pkg/hashlink/hashlink_test.go index 166f55277..a0bfc82d0 100644 --- a/pkg/hashlink/hashlink_test.go +++ b/pkg/hashlink/hashlink_test.go @@ -13,6 +13,8 @@ import ( "github.com/btcsuite/btcutil/base58" cbor "github.com/fxamacker/cbor/v2" "github.com/stretchr/testify/require" + + "github.com/trustbloc/orb/pkg/internal/testutil" ) const ( @@ -346,6 +348,16 @@ func TestGetResourceHashFromHashLink(t *testing.T) { }) } +func TestToString(t *testing.T) { + const ( + hl1 = "hl:uEiC8e7XhtySK1lYVLTIiAi66FAEmmxdiu2_EwVkJYTlsLw:uoQ-CeEtodHRwczovL29yYi5kb21haW4xLmNvbS9jYXMvdUVpQzhlN1hodHlTSzFsWVZMVElpQWk2NkZBRW1teGRpdTJfRXdWa0pZVGxzTHd4QmlwZnM6Ly9iYWZrcmVpZjRwbzI2ZG56ZXJsbGZtZmpuZ2lyYWVsdjJjcWFzbmd5eG1rNXc3cmdibGVld2NvbG1mNA" //nolint:lll + hl2 = "xx:xxx" + ) + + str := ToString(testutil.MustParseURL(hl1), testutil.MustParseURL(hl2)) + require.Equal(t, "{Hash [uEiC8e7XhtySK1lYVLTIiAi66FAEmmxdiu2_EwVkJYTlsLw], Links [https://orb.domain1.com/cas/uEiC8e7XhtySK1lYVLTIiAi66FAEmmxdiu2_EwVkJYTlsLw ipfs://bafkreif4po26dnzerllfmfjngiraelv2cqasngyxmk5w7rgbleewcolmf4]}, {INVALID HASHLINK [xx:xxx]}", str) //nolint:lll +} + var base58Encoder = func(data []byte) string { return "z" + base58.Encode(data) } diff --git a/pkg/mocks/anchorlinkstore.gen.go b/pkg/mocks/anchorlinkstore.gen.go new file mode 100644 index 000000000..a85cf0f56 --- /dev/null +++ b/pkg/mocks/anchorlinkstore.gen.go @@ -0,0 +1,166 @@ +// Code generated by counterfeiter. DO NOT EDIT. +package mocks + +import ( + "net/url" + "sync" +) + +type AnchorLinkStore struct { + PutLinksStub func(links []*url.URL) error + putLinksMutex sync.RWMutex + putLinksArgsForCall []struct { + links []*url.URL + } + putLinksReturns struct { + result1 error + } + putLinksReturnsOnCall map[int]struct { + result1 error + } + GetLinksStub func(anchorHash string) ([]*url.URL, error) + getLinksMutex sync.RWMutex + getLinksArgsForCall []struct { + anchorHash string + } + getLinksReturns struct { + result1 []*url.URL + result2 error + } + getLinksReturnsOnCall map[int]struct { + result1 []*url.URL + result2 error + } + invocations map[string][][]interface{} + invocationsMutex sync.RWMutex +} + +func (fake *AnchorLinkStore) PutLinks(links []*url.URL) error { + var linksCopy []*url.URL + if links != nil { + linksCopy = make([]*url.URL, len(links)) + copy(linksCopy, links) + } + fake.putLinksMutex.Lock() + ret, specificReturn := fake.putLinksReturnsOnCall[len(fake.putLinksArgsForCall)] + fake.putLinksArgsForCall = append(fake.putLinksArgsForCall, struct { + links []*url.URL + }{linksCopy}) + fake.recordInvocation("PutLinks", []interface{}{linksCopy}) + fake.putLinksMutex.Unlock() + if fake.PutLinksStub != nil { + return fake.PutLinksStub(links) + } + if specificReturn { + return ret.result1 + } + return fake.putLinksReturns.result1 +} + +func (fake *AnchorLinkStore) PutLinksCallCount() int { + fake.putLinksMutex.RLock() + defer fake.putLinksMutex.RUnlock() + return len(fake.putLinksArgsForCall) +} + +func (fake *AnchorLinkStore) PutLinksArgsForCall(i int) []*url.URL { + fake.putLinksMutex.RLock() + defer fake.putLinksMutex.RUnlock() + return fake.putLinksArgsForCall[i].links +} + +func (fake *AnchorLinkStore) PutLinksReturns(result1 error) { + fake.PutLinksStub = nil + fake.putLinksReturns = struct { + result1 error + }{result1} +} + +func (fake *AnchorLinkStore) PutLinksReturnsOnCall(i int, result1 error) { + fake.PutLinksStub = nil + if fake.putLinksReturnsOnCall == nil { + fake.putLinksReturnsOnCall = make(map[int]struct { + result1 error + }) + } + fake.putLinksReturnsOnCall[i] = struct { + result1 error + }{result1} +} + +func (fake *AnchorLinkStore) GetLinks(anchorHash string) ([]*url.URL, error) { + fake.getLinksMutex.Lock() + ret, specificReturn := fake.getLinksReturnsOnCall[len(fake.getLinksArgsForCall)] + fake.getLinksArgsForCall = append(fake.getLinksArgsForCall, struct { + anchorHash string + }{anchorHash}) + fake.recordInvocation("GetLinks", []interface{}{anchorHash}) + fake.getLinksMutex.Unlock() + if fake.GetLinksStub != nil { + return fake.GetLinksStub(anchorHash) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fake.getLinksReturns.result1, fake.getLinksReturns.result2 +} + +func (fake *AnchorLinkStore) GetLinksCallCount() int { + fake.getLinksMutex.RLock() + defer fake.getLinksMutex.RUnlock() + return len(fake.getLinksArgsForCall) +} + +func (fake *AnchorLinkStore) GetLinksArgsForCall(i int) string { + fake.getLinksMutex.RLock() + defer fake.getLinksMutex.RUnlock() + return fake.getLinksArgsForCall[i].anchorHash +} + +func (fake *AnchorLinkStore) GetLinksReturns(result1 []*url.URL, result2 error) { + fake.GetLinksStub = nil + fake.getLinksReturns = struct { + result1 []*url.URL + result2 error + }{result1, result2} +} + +func (fake *AnchorLinkStore) GetLinksReturnsOnCall(i int, result1 []*url.URL, result2 error) { + fake.GetLinksStub = nil + if fake.getLinksReturnsOnCall == nil { + fake.getLinksReturnsOnCall = make(map[int]struct { + result1 []*url.URL + result2 error + }) + } + fake.getLinksReturnsOnCall[i] = struct { + result1 []*url.URL + result2 error + }{result1, result2} +} + +func (fake *AnchorLinkStore) Invocations() map[string][][]interface{} { + fake.invocationsMutex.RLock() + defer fake.invocationsMutex.RUnlock() + fake.putLinksMutex.RLock() + defer fake.putLinksMutex.RUnlock() + fake.getLinksMutex.RLock() + defer fake.getLinksMutex.RUnlock() + copiedInvocations := map[string][][]interface{}{} + for key, value := range fake.invocations { + copiedInvocations[key] = value + } + return copiedInvocations +} + +func (fake *AnchorLinkStore) recordInvocation(key string, args []interface{}) { + fake.invocationsMutex.Lock() + defer fake.invocationsMutex.Unlock() + if fake.invocations == nil { + fake.invocations = map[string][][]interface{}{} + } + if fake.invocations[key] == nil { + fake.invocations[key] = [][]interface{}{} + } + fake.invocations[key] = append(fake.invocations[key], args) +} diff --git a/pkg/observer/observer.go b/pkg/observer/observer.go index ff568473e..28c54117d 100644 --- a/pkg/observer/observer.go +++ b/pkg/observer/observer.go @@ -86,6 +86,10 @@ type documentLoader interface { LoadDocument(u string) (*ld.RemoteDocument, error) } +type anchorLinkStore interface { + PutLinks(links []*url.URL) error +} + type outboxProvider func() Outbox // Option is an option for observer. @@ -109,6 +113,7 @@ type Providers struct { WebFingerResolver resourceResolver CASResolver casResolver DocLoader documentLoader + AnchorLinkStore anchorLinkStore } // Observer receives transactions over a channel and processes them by storing them to an operation store. @@ -297,7 +302,7 @@ func (o *Observer) processAnchor(anchor *anchorinfo.AnchorInfo, info *verifiable len(anchorPayload.PreviousAnchors), anchor.Hashlink, anchorPayload.CoreIndex) // Post a 'Like' activity to the originator of the anchor credential. - err = o.postLikeActivity(anchor) + err = o.saveAnchorLinkAndPostLikeActivity(anchor) if err != nil { // This is not a critical error. We have already processed the anchor, so we don't want // to trigger a retry by returning a transient error. Just log a warning. @@ -307,7 +312,7 @@ func (o *Observer) processAnchor(anchor *anchorinfo.AnchorInfo, info *verifiable return nil } -func (o *Observer) postLikeActivity(anchor *anchorinfo.AnchorInfo) error { +func (o *Observer) saveAnchorLinkAndPostLikeActivity(anchor *anchorinfo.AnchorInfo) error { if anchor.AttributedTo == "" { logger.Debugf("Not posting 'Like' activity since no attributedTo ID was specified for anchor [%s]", anchor.Hashlink) @@ -325,19 +330,15 @@ func (o *Observer) postLikeActivity(anchor *anchorinfo.AnchorInfo) error { return fmt.Errorf("parse origin [%s]: %w", anchor.AttributedTo, err) } - var result *vocab.ObjectProperty - - if anchor.LocalHashlink != "" { - u, e := url.Parse(anchor.LocalHashlink) - if e != nil { - return fmt.Errorf("parse local hashlink [%s]: %w", anchor.LocalHashlink, e) - } + err = o.saveAnchorHashlink(refURL) + if err != nil { + // Not fatal. + logger.Warnf("Error saving anchor link [%s]: %s", refURL, err) + } - result = vocab.NewObjectProperty(vocab.WithAnchorReference( - vocab.NewAnchorReferenceWithOpts( - vocab.WithURL(u), - )), - ) + result, err := newLikeResult(anchor.LocalHashlink) + if err != nil { + return fmt.Errorf("new like result for local hashlink: %w", err) } err = o.doPostLikeActivity(attributedTo, refURL, result) @@ -421,6 +422,19 @@ func (o *Observer) resolveActorFromHashlink(hl string) (*url.URL, error) { return actor, nil } +// saveAnchorHashlink saves the hashlink of an anchor credential so that it may be returned +// in a WebFinger query as an alternate link. +func (o *Observer) saveAnchorHashlink(ref *url.URL) error { + err := o.AnchorLinkStore.PutLinks([]*url.URL{ref}) + if err != nil { + return fmt.Errorf("put anchor link [%s]: %w", ref, err) + } + + logger.Debugf("Saved anchor link [%s]", ref) + + return nil +} + func getKeys(m map[string]string) []string { keys := make([]string, 0, len(m)) for k := range m { @@ -429,3 +443,20 @@ func getKeys(m map[string]string) []string { return keys } + +func newLikeResult(hashLink string) (*vocab.ObjectProperty, error) { + if hashLink == "" { + return nil, nil + } + + u, e := url.Parse(hashLink) + if e != nil { + return nil, fmt.Errorf("parse hashlink [%s]: %w", hashLink, e) + } + + return vocab.NewObjectProperty(vocab.WithAnchorReference( + vocab.NewAnchorReferenceWithOpts( + vocab.WithURL(u), + )), + ), nil +} diff --git a/pkg/observer/observer_test.go b/pkg/observer/observer_test.go index 7bb459813..a3e91fb7a 100644 --- a/pkg/observer/observer_test.go +++ b/pkg/observer/observer_test.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "testing" "time" @@ -40,6 +41,12 @@ import ( ) //go:generate counterfeiter -o ../mocks/anchorgraph.gen.go --fake-name AnchorGraph . AnchorGraph +//go:generate counterfeiter -o ../mocks/anchorlinkstore.gen.go --fake-name AnchorLinkStore . linkStore + +type linkStore interface { //nolint:deadcode,unused + PutLinks(links []*url.URL) error + GetLinks(anchorHash string) ([]*url.URL, error) +} const casLink = "https://domain.com/cas" @@ -163,6 +170,7 @@ func TestStartObserver(t *testing.T) { WebFingerResolver: &apmocks.WebFingerResolver{}, CASResolver: casResolver, DocLoader: testutil.GetLoader(t), + AnchorLinkStore: &orbmocks.AnchorLinkStore{}, } o, err := New(providers, WithDiscoveryDomain("webcas:shared.domain.com")) diff --git a/pkg/store/cas/cas.go b/pkg/store/cas/cas.go index cb60c29ef..e3cb3bf6c 100644 --- a/pkg/store/cas/cas.go +++ b/pkg/store/cas/cas.go @@ -171,9 +171,7 @@ func (p *CAS) Read(address string) ([]byte, error) { func (p *CAS) get(address string) ([]byte, error) { startTime := time.Now() - defer func() { - p.metrics.CASReadTime(casType, time.Since(startTime)) - }() + defer p.metrics.CASReadTime(casType, time.Since(startTime)) content, err := p.cas.Get(address) if err != nil { diff --git a/pkg/webfinger/client/client_test.go b/pkg/webfinger/client/client_test.go index 6f1837aaa..fa44a8d07 100644 --- a/pkg/webfinger/client/client_test.go +++ b/pkg/webfinger/client/client_test.go @@ -19,7 +19,9 @@ import ( "github.com/gorilla/mux" "github.com/stretchr/testify/require" + "github.com/trustbloc/orb/pkg/cas/resolver/mocks" discoveryrest "github.com/trustbloc/orb/pkg/discovery/endpoint/restapi" + orbmocks "github.com/trustbloc/orb/pkg/mocks" ) func TestNew(t *testing.T) { @@ -244,7 +246,10 @@ func TestResolveWebFingerResource(t *testing.T) { testServer := httptest.NewServer(router) defer testServer.Close() - operations, err := discoveryrest.New(&discoveryrest.Config{BaseURL: testServer.URL, WebCASPath: "/cas"}) + operations, err := discoveryrest.New( + &discoveryrest.Config{BaseURL: testServer.URL, WebCASPath: "/cas"}, + &discoveryrest.Providers{CAS: &mocks.CASClient{}, AnchorLinkStore: &orbmocks.AnchorLinkStore{}}, + ) require.NoError(t, err) router.HandleFunc(operations.GetRESTHandlers()[1].Path(), operations.GetRESTHandlers()[1].Handler()) @@ -323,7 +328,10 @@ func TestGetWebCASURL(t *testing.T) { testServer := httptest.NewServer(router) defer testServer.Close() - operations, err := discoveryrest.New(&discoveryrest.Config{BaseURL: testServer.URL, WebCASPath: "/cas"}) + operations, err := discoveryrest.New( + &discoveryrest.Config{BaseURL: testServer.URL, WebCASPath: "/cas"}, + &discoveryrest.Providers{CAS: &mocks.CASClient{}, AnchorLinkStore: &orbmocks.AnchorLinkStore{}}, + ) require.NoError(t, err) router.HandleFunc(operations.GetRESTHandlers()[1].Path(), operations.GetRESTHandlers()[1].Handler()) diff --git a/scripts/integration.sh b/scripts/integration.sh index a5fea5a3f..bed0b8356 100755 --- a/scripts/integration.sh +++ b/scripts/integration.sh @@ -16,10 +16,10 @@ export CAS_TYPE=local export COMPOSE_HTTP_TIMEOUT=120 cd test/bdd -go test -count=1 -v -cover . -p 1 -timeout=20m -race +go test -run all,local_cas -count=1 -v -cover . -p 1 -timeout=20m -race export CAS_TYPE=ipfs -go test -count=1 -v -cover . -p 1 -timeout=20m -race +go test -run all -count=1 -v -cover . -p 1 -timeout=20m -race cd $PWD diff --git a/test/bdd/common_steps.go b/test/bdd/common_steps.go index 5da4f0075..196dd8ae9 100644 --- a/test/bdd/common_steps.go +++ b/test/bdd/common_steps.go @@ -14,6 +14,7 @@ import ( "net/url" "os" "path/filepath" + "regexp" "strconv" "strings" "time" @@ -234,6 +235,31 @@ func (d *CommonSteps) jsonPathOfResponseContains(path, expected string) error { return fmt.Errorf("JSON path resolves to [%s] which is not the expected value [%s]", r.Array(), expected) } +func (d *CommonSteps) jsonPathOfResponseContainsRegEx(path, pattern string) error { + resolvedRegEx, err := d.state.resolveVars(pattern) + if err != nil { + return err + } + + regEx, err := regexp.Compile(resolvedRegEx.(string)) + if err != nil { + return err + } + + r := gjson.Get(d.state.getResponse(), path) + + logger.Infof("Path [%s] of JSON %s resolves to %s", path, d.state.getResponse(), r.Raw) + + for _, a := range r.Array() { + if regEx.MatchString(a.Str) { + return nil + } + } + + return fmt.Errorf("JSON path resolves to [%s] which does not match the regular expression [%s]", + r.Array(), pattern) +} + func (d *CommonSteps) jsonPathOfResponseNotContains(path, notExpected string) error { resolved, err := d.state.resolveVars(notExpected) if err != nil { @@ -880,6 +906,7 @@ func (d *CommonSteps) RegisterSteps(s *godog.Suite) { s.Step(`^the JSON path "([^"]*)" of the boolean response equals "([^"]*)"$`, d.jsonPathOfBoolResponseEquals) s.Step(`^the JSON path "([^"]*)" of the response has (\d+) items$`, d.jsonPathOfResponseHasNumItems) s.Step(`^the JSON path "([^"]*)" of the response contains "([^"]*)"$`, d.jsonPathOfResponseContains) + s.Step(`^the JSON path "([^"]*)" of the response contains expression "([^"]*)"$`, d.jsonPathOfResponseContainsRegEx) s.Step(`^the JSON path "([^"]*)" of the response does not contain "([^"]*)"$`, d.jsonPathOfResponseNotContains) s.Step(`^the JSON path "([^"]*)" of the response is saved to variable "([^"]*)"$`, d.jsonPathOfResponseSavedToVar) s.Step(`^the JSON path "([^"]*)" of the numeric response is saved to variable "([^"]*)"$`, d.jsonPathOfNumericResponseSavedToVar) diff --git a/test/bdd/did_orb_steps.go b/test/bdd/did_orb_steps.go index fc5567184..1956e2b4b 100644 --- a/test/bdd/did_orb_steps.go +++ b/test/bdd/did_orb_steps.go @@ -130,6 +130,8 @@ const docTemplate = `{ // DIDOrbSteps type DIDOrbSteps struct { + state *state + namespace string createRequest *model.CreateRequest recoveryKey *ecdsa.PrivateKey @@ -154,6 +156,7 @@ type DIDOrbSteps struct { func NewDIDSideSteps(context *BDDContext, state *state, namespace string) *DIDOrbSteps { return &DIDOrbSteps{ bddContext: context, + state: state, namespace: namespace, httpClient: newHTTPClient(state, context), didPrintEnabled: true, @@ -291,6 +294,16 @@ func extractCIDAndSuffix(canonicalID string) (string, string, error) { return parts[2], parts[3], nil } +func (d *DIDOrbSteps) createDIDDocumentSaveIDToVar(url, varName string) error { + if err := d.createDIDDocument(url); err != nil { + return err + } + + d.state.setVar(varName, d.interimDID) + + return nil +} + func (d *DIDOrbSteps) createDIDDocument(url string) error { logger.Info("create did document") @@ -1195,6 +1208,7 @@ func (d *DIDOrbSteps) RegisterSteps(s *godog.Suite) { s.Step(`^client sends request to "([^"]*)" to request anchor origin$`, d.clientRequestsAnchorOrigin) s.Step(`^check error response contains "([^"]*)"$`, d.checkErrorResp) s.Step(`^client sends request to "([^"]*)" to create DID document$`, d.createDIDDocument) + s.Step(`^client sends request to "([^"]*)" to create DID document and the ID is saved to variable "([^"]*)"$`, d.createDIDDocumentSaveIDToVar) s.Step(`^check success response contains "([^"]*)"$`, d.checkSuccessRespContains) s.Step(`^check success response does NOT contain "([^"]*)"$`, d.checkSuccessRespDoesntContain) s.Step(`^client sends request to "([^"]*)" to resolve DID document with interim did$`, d.resolveDIDDocumentWithInterimDID) diff --git a/test/bdd/features/did-orb.feature b/test/bdd/features/did-orb.feature index b67a78cb9..47f240861 100644 --- a/test/bdd/features/did-orb.feature +++ b/test/bdd/features/did-orb.feature @@ -4,7 +4,6 @@ # SPDX-License-Identifier: Apache-2.0 # -@all @did-orb Feature: Background: Setup @@ -55,6 +54,7 @@ Feature: Then we wait 3 seconds + @all @discover_did_hashlink Scenario: discover did (hashlink) When client discover orb endpoints @@ -91,6 +91,7 @@ Feature: Then check success response contains "#canonicalDID" Then check success response contains "recoveryKey" + @all @discover_did_https Scenario: discover did (https) When client discover orb endpoints @@ -127,6 +128,7 @@ Feature: Then check success response contains "#canonicalDID" Then check success response contains "recoveryKey" + @all @follow_anchor_writer_domain1 Scenario: domain2 server follows domain1 server (anchor writer) @@ -143,6 +145,7 @@ Feature: When client sends request to "https://orb.domain2.com/sidetree/v1/identifiers" to resolve DID document with canonical did Then check success response contains "#canonicalDID" + @all @follow_anchor_writer_domain2 Scenario: domain1 server follows domain2 server (anchor writer) @@ -163,6 +166,7 @@ Feature: When client sends request to "https://orb2.domain1.com/sidetree/v1/identifiers" to resolve DID document with canonical did Then check success response contains "#canonicalDID" + @all @concurrent_requests_scenario Scenario: concurrent requests plus server shutdown tests @@ -235,6 +239,7 @@ Feature: Then the JSON path "orderedItems.#" of the response has 1 items And the JSON path "orderedItems.#.actor" of the response contains "${domain3IRI}" + @all @enable_create_document_store_interim Scenario: domain4 has create document store enabled (interim DID) @@ -250,6 +255,7 @@ Feature: When client sends request to "https://orb.domain4.com/sidetree/v1/identifiers" to resolve DID document with interim did Then check success response contains "canonicalId" + @all @enable_create_document_store_interim_with_hint Scenario: domain4 has create document store enabled (interim DID with hint) @@ -259,6 +265,7 @@ Feature: When client sends request to "https://orb.domain4.com/sidetree/v1/identifiers" to resolve interim DID document with hint "https:orb.domain4.com" Then check success response does NOT contain "canonicalId" + @all @enable_update_document_store Scenario: domain4 has update document store enabled @@ -296,4 +303,15 @@ Feature: # resolve second update right away When client sends request to "https://orb.domain4.com/sidetree/v1/identifiers" to resolve DID document with canonical did - Then check success response contains "secondKey" \ No newline at end of file + Then check success response contains "secondKey" + + @local_cas + @alternate_links_scenario + Scenario: WebFinger query returns alternate links for "Liked" anchor credentials + When client sends request to "https://orb.domain1.com/sidetree/v1/operations" to create DID document and the ID is saved to variable "didID" + Then we wait 3 seconds + + When an HTTP GET is sent to "https://orb.domain1.com/.well-known/webfinger?resource=${didID}" + And the JSON path "links.#.href" of the response contains expression ".*orb\.domain1\.com.*" + And the JSON path "links.#.href" of the response contains expression ".*orb\.domain2\.com.*" + And the JSON path "links.#.href" of the response contains expression ".*orb\.domain3\.com.*"