From e2ce9e0d4bbad502540b6177c9ff792ed51b5681 Mon Sep 17 00:00:00 2001 From: Gerard Snaauw <33763579+gerardsn@users.noreply.github.com> Date: Tue, 1 Oct 2024 13:22:39 +0200 Subject: [PATCH] Migrate owned did nuts to SQL (#3392) * migrate did:nuts documents to SQL * add e2e-test setup * test migration hack is safe for production network * fix broken tests * reduce complexity * dont migrate documents past deactivation * fix merge conflict * generalize methods * add tests * e2e test; add SQL db checks * e2e test; add more SQL db checks * pr feedback + documentation * revert e2e-test * add e2e-test --- .github/workflows/e2e-tests.yaml | 2 +- .../integrating/version-incompatibilities.rst | 8 + docs/pages/release_notes.rst | 1 + .../docker-compose-post-migration.yml | 32 +++ .../docker-compose-pre-migration.yml | 32 +++ e2e-tests/migration/main_test.go | 120 +++++++++++ e2e-tests/migration/nuts-v5.yaml | 12 ++ e2e-tests/migration/nuts-v6.yaml | 15 ++ e2e-tests/migration/run-test.sh | 160 +++++++++++++++ e2e-tests/migration/run-tests.sh | 8 + e2e-tests/run-tests.sh | 7 + e2e-tests/util.sh | 7 +- storage/orm/did_document.go | 65 ++++++ storage/orm/did_document_test.go | 67 +++++- storage/orm/keyflag.go | 23 +++ storage/orm/keyflag_test.go | 41 ++++ storage/test.go | 2 + vdr/didnuts/didstore/interface.go | 6 + vdr/didnuts/didstore/mock.go | 16 ++ vdr/didnuts/didstore/store.go | 60 ++++++ vdr/didnuts/didstore/store_test.go | 75 +++++++ vdr/didsubject/did_document.go | 2 +- vdr/didsubject/interface.go | 8 + vdr/didsubject/manager.go | 77 ++++++- vdr/didsubject/manager_test.go | 112 ++++++++++ vdr/vdr.go | 117 +++++++---- vdr/vdr_test.go | 192 ++++++++++-------- 27 files changed, 1142 insertions(+), 125 deletions(-) create mode 100644 e2e-tests/migration/docker-compose-post-migration.yml create mode 100644 e2e-tests/migration/docker-compose-pre-migration.yml create mode 100644 e2e-tests/migration/main_test.go create mode 100644 e2e-tests/migration/nuts-v5.yaml create mode 100644 e2e-tests/migration/nuts-v6.yaml create mode 100755 e2e-tests/migration/run-test.sh create mode 100755 e2e-tests/migration/run-tests.sh diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index e77e48599f..91ecbd7d38 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -75,7 +75,7 @@ jobs: - name: Run E2E tests run: | cd e2e-tests && \ - find . -type f -name "docker-compose.yml" | xargs -I{} sed -i 's~nutsfoundation/nuts-node:master~ghcr.io/nuts-foundation/nuts-node-ci:${{ env.SHA }}~g' {} && \ + find . -type f -name "docker-compose*.yml" | xargs -I{} sed -i 's~nutsfoundation/nuts-node:master~ghcr.io/nuts-foundation/nuts-node-ci:${{ env.SHA }}~g' {} && \ find . -type f -name "run-test.sh" | xargs -I{} sed -i 's/docker-compose exec/docker-compose exec -T/g' {} && \ ./run-tests.sh diff --git a/docs/pages/integrating/version-incompatibilities.rst b/docs/pages/integrating/version-incompatibilities.rst index 189333b6d1..659e39d82d 100644 --- a/docs/pages/integrating/version-incompatibilities.rst +++ b/docs/pages/integrating/version-incompatibilities.rst @@ -17,3 +17,11 @@ There are basically two options. Do not use the VDR V1 and VDR V2 API at the same time. This will lead to unexpected behavior. Once you use the VDR V2 API, you cannot go back to the VDR V1 API. The VDR V1 API has also been marked as deprecated. +Nodes running v6 with ``nuts`` configured as one of the ``vdr.did_methods`` will migrate all owned ``did:nuts`` DID documents to the new SQL storage. +This migration includes all historic document updates as published upto a potential deactivation of the document. +For DIDs with a document conflict this is different than the resolved version of the document, which contains a merge of all conflicting document updates. +To prevent the state of the resolver and the SQL storage to be in conflict, all DID document conflicts must be resolved before upgrading to v6. +See ``/status/diagnostics`` if you own any DIDs with a document conflict. If so, use ``/internal/vdr/v1/did/conflicted`` to find the DIDs with a conflict. + +The document migration will run on every restart of the node, meaning that any updates made using the VDR V1 API will be migrated on the next restart. +When switching from the VDR V1 API to the V2 API, the node must be restarted first to migrate any recent changes. diff --git a/docs/pages/release_notes.rst b/docs/pages/release_notes.rst index af00dec687..c32a394393 100644 --- a/docs/pages/release_notes.rst +++ b/docs/pages/release_notes.rst @@ -17,6 +17,7 @@ Breaking changes When migrating from v5, change the owner of the data directory on the host to that of the container's user. (``chown -R 18081:18081 /path/to/host/data-dir``) - Docker image tags have been changed: previously version tags had were prefixed with ``v`` (e.g., ``v5.0.0``), this prefix has been dropped to better adhere to industry standards. - The VDR v1 ``createDID`` (``POST /internal/vdr/v1/did``) no longer supports the ``controller`` and ``selfControl`` fields. All did:nuts documents are now self controlled. All existing documents will be migrated to self controlled at startup. +- Managed ``did:nuts`` DIDs are migrated to the new SQL storage. Unresolved DID document conflicts may contain an incorrect state after migrating to v6. See ``/status/diagnostics`` if you own any DIDs with a document conflict; use ``/internal/vdr/v1/did/conflicted`` to find the specific DIDs. - Removed legacy API authentication tokens. ============ diff --git a/e2e-tests/migration/docker-compose-post-migration.yml b/e2e-tests/migration/docker-compose-post-migration.yml new file mode 100644 index 0000000000..37d8d62015 --- /dev/null +++ b/e2e-tests/migration/docker-compose-post-migration.yml @@ -0,0 +1,32 @@ +services: + nodeA: + image: "${IMAGE_NODE_A:-nutsfoundation/nuts-node:master}" + container_name: nodeA + user: &usr "$USER:$USER" + ports: + - "18081:8081" + environment: + NUTS_URL: "http://nodeA:8080" + volumes: + - "./nuts-v6.yaml:/nuts/config/nuts.yaml" + - "./nodeA/data:/nuts/data" + - "../tls-certs/nodeA-certificate.pem:/nuts/config/certificate-and-key.pem:ro" + - "../tls-certs/truststore.pem:/nuts/config/truststore.pem:ro" + healthcheck: + interval: 1s # Make test run quicker by checking health status more often + nodeB: + image: nutsfoundation/nuts-node:v5.4.11 # must be v5.4.11+ for bugfixes. sync with docker-compose-post-migration.yml + container_name: nodeB + user: *usr + ports: + - "28081:1323" + environment: + NUTS_CONFIGFILE: /opt/nuts/nuts.yaml + NUTS_NETWORK_BOOTSTRAPNODES: "nodeA:5555" + volumes: + - "./nuts-v5.yaml:/opt/nuts/nuts.yaml" + - "./nodeB/data:/opt/nuts/data" + - "../tls-certs/nodeB-certificate.pem:/opt/nuts/certificate-and-key.pem:ro" + - "../tls-certs/truststore.pem:/opt/nuts/truststore.pem:ro" + healthcheck: + interval: 1s # Make test run quicker by checking health status more often \ No newline at end of file diff --git a/e2e-tests/migration/docker-compose-pre-migration.yml b/e2e-tests/migration/docker-compose-pre-migration.yml new file mode 100644 index 0000000000..7c5c6f4fca --- /dev/null +++ b/e2e-tests/migration/docker-compose-pre-migration.yml @@ -0,0 +1,32 @@ +services: + nodeA: + image: &im nutsfoundation/nuts-node:v5.4.11 # must be v5.4.11+ for bugfixes. sync with docker-compose-post-migration.yml + container_name: nodeA + user: &usr "$USER:$USER" + ports: # use v6 ports to minimize changes + - "18081:1323" + environment: + NUTS_CONFIGFILE: /opt/nuts/nuts.yaml + volumes: + - "./nuts-v5.yaml:/opt/nuts/nuts.yaml" + - "./nodeA/data:/opt/nuts/data" + - "../tls-certs/nodeA-certificate.pem:/opt/nuts/certificate-and-key.pem:ro" + - "../tls-certs/truststore.pem:/opt/nuts/truststore.pem:ro" + healthcheck: + interval: 1s # Make test run quicker by checking health status more often + nodeB: + image: *im + container_name: nodeB + user: *usr + ports: + - "28081:1323" + environment: + NUTS_CONFIGFILE: /opt/nuts/nuts.yaml + NUTS_NETWORK_BOOTSTRAPNODES: "nodeA:5555" + volumes: + - "./nuts-v5.yaml:/opt/nuts/nuts.yaml" + - "./nodeB/data:/opt/nuts/data" + - "../tls-certs/nodeB-certificate.pem:/opt/nuts/certificate-and-key.pem:ro" + - "../tls-certs/truststore.pem:/opt/nuts/truststore.pem:ro" + healthcheck: + interval: 1s # Make test run quicker by checking health status more often \ No newline at end of file diff --git a/e2e-tests/migration/main_test.go b/e2e-tests/migration/main_test.go new file mode 100644 index 0000000000..213a34b21c --- /dev/null +++ b/e2e-tests/migration/main_test.go @@ -0,0 +1,120 @@ +//go:build e2e_tests + +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package migration + +import ( + "encoding/json" + did "github.com/nuts-foundation/go-did/did" + "github.com/stretchr/testify/assert" + "os" + "testing" + "time" + + "github.com/nuts-foundation/nuts-node/storage" + "github.com/nuts-foundation/nuts-node/storage/orm" + "github.com/nuts-foundation/nuts-node/vdr/didsubject" + "github.com/stretchr/testify/require" +) + +func Test_Migrations(t *testing.T) { + db := storage.NewTestStorageEngineInDir(t, "./nodeA/data").GetSQLDatabase() + + DIDs, err := didsubject.NewDIDManager(db).All() + require.NoError(t, err) + require.Len(t, DIDs, 4) + + t.Run("vendor", func(t *testing.T) { + // versions for did:nuts: + // - LC0: init -> no controller because vendor + // - LC4: add service1 + // - LC4: add service2, conflicts with above + // - LC8: add verification method, solves conflict + // no updates during migration + // + // total 4 versions in SQL; latest has 2 services and 2 VMs + id := did.MustParseDID(os.Getenv("VENDOR_DID")) + var doc orm.DidDocument + err = db.Preload("DID").Preload("Services").Preload("VerificationMethods").Where("did = ? AND updated_at <= ?", id.String(), time.Now()).Order("version desc").First(&doc).Error + require.NoError(t, err) + + assert.Equal(t, 3, doc.Version) + assert.Len(t, doc.Services, 2) + assert.Len(t, doc.VerificationMethods, 2) + }) + t.Run("org1", func(t *testing.T) { + // versions for did:nuts: + // - LC1: init -> has controller + // - LC5: add service2 + // - LC6: add service1, conflicts with above + // migration removes controller (solves document conflict) + // + // total 4 versions in SQL; latest one has no controller, 2 services, and 1 VM + id := did.MustParseDID(os.Getenv("ORG1_DID")) + var doc orm.DidDocument + err = db.Preload("DID").Preload("Services").Preload("VerificationMethods").Where("did = ? AND updated_at <= ?", id.String(), time.Now()).Order("version desc").First(&doc).Error + require.NoError(t, err) + + assert.Equal(t, 3, doc.Version) + assert.Len(t, doc.Services, 2) + assert.Len(t, doc.VerificationMethods, 1) + didDoc := new(did.Document) + require.NoError(t, json.Unmarshal([]byte(doc.Raw), didDoc)) + assert.Empty(t, didDoc.Controller) + }) + t.Run("org2", func(t *testing.T) { + // versions for did:nuts: + // - LC2: init -> has controller + // - LC5: deactivate + // - LC6: service2, conflicts with above + // deactivated, so no updates during migration; + // + // total 2 versions in SQL, migration stopped at LC5; no controller, 0 service, 0 VM + id := did.MustParseDID(os.Getenv("ORG2_DID")) + var doc orm.DidDocument + err = db.Preload("DID").Preload("Services").Preload("VerificationMethods").Where("did = ? AND updated_at <= ?", id.String(), time.Now()).Order("version desc").First(&doc).Error + require.NoError(t, err) + + assert.Equal(t, 1, doc.Version) + assert.Len(t, doc.Services, 0) + assert.Len(t, doc.VerificationMethods, 0) + }) + t.Run("org3", func(t *testing.T) { + // versions for did:nuts: + // - LC3: init -> has controller + // - LC7: add service1 + // - LC7: add verification method, conflicts with above + // - LC9: add service2, solves conflict + // migration removes controller, total 5 versions in SQL + // + // total 5 versions in SQL; no controller, 2 services, 2 VMs + id := did.MustParseDID(os.Getenv("ORG3_DID")) + var doc orm.DidDocument + err = db.Preload("DID").Preload("Services").Preload("VerificationMethods").Where("did = ? AND updated_at <= ?", id.String(), time.Now()).Order("version desc").First(&doc).Error + require.NoError(t, err) + + assert.Equal(t, 4, doc.Version) + assert.Len(t, doc.Services, 2) + assert.Len(t, doc.VerificationMethods, 2) + didDoc := new(did.Document) + require.NoError(t, json.Unmarshal([]byte(doc.Raw), didDoc)) + assert.Empty(t, didDoc.Controller) + }) +} diff --git a/e2e-tests/migration/nuts-v5.yaml b/e2e-tests/migration/nuts-v5.yaml new file mode 100644 index 0000000000..7cb98a1639 --- /dev/null +++ b/e2e-tests/migration/nuts-v5.yaml @@ -0,0 +1,12 @@ +datadir: /opt/nuts/data +verbosity: debug +strictmode: false +tls: + truststorefile: "/opt/nuts/truststore.pem" + certfile: "/opt/nuts/certificate-and-key.pem" + certkeyfile: "/opt/nuts/certificate-and-key.pem" +auth: + contractvalidators: + - dummy +goldenhammer: + enabled: false \ No newline at end of file diff --git a/e2e-tests/migration/nuts-v6.yaml b/e2e-tests/migration/nuts-v6.yaml new file mode 100644 index 0000000000..cf8b297bd1 --- /dev/null +++ b/e2e-tests/migration/nuts-v6.yaml @@ -0,0 +1,15 @@ +verbosity: debug +strictmode: false +#url: http://nodeA:1323 # set in as env variable +tls: + truststorefile: "/nuts/config/truststore.pem" + certfile: "/nuts/config/certificate-and-key.pem" + certkeyfile: "/nuts/config/certificate-and-key.pem" +auth: + contractvalidators: + - dummy +goldenhammer: + enabled: false +http: + internal: + address: :8081 diff --git a/e2e-tests/migration/run-test.sh b/e2e-tests/migration/run-test.sh new file mode 100755 index 0000000000..641699ce42 --- /dev/null +++ b/e2e-tests/migration/run-test.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +source ../util.sh +USER=$UID + +######################################################### +# THE ORDER OF TRANSACTIONS IS SIGNIFICANT, DONT CHANGE # +######################################################### + +# createOrg creates a DID under the control of another DID +# Args: controller DID +# Returns: the created DID +function createOrg() { + printf '{ + "selfControl": false, + "controllers": ["%s"], + "assertionMethod": true, + "capabilityInvocation": false + }' "$1" | \ + curl -sS -X POST "http://localhost:18081/internal/vdr/v1/did" -H "Content-Type: application/json" --data-binary @- | jq -r ".id" +} + +# addServiceV1 add a service to a DID document using the vdr/v1 API +# Args: service host, service type, DID to add the service to +# Returns: null +function addServiceV1() { + printf '{ + "type": "%s", + "endpoint": "%s/%s" + }' "$2" "$1" "$2" | \ + curl -sS -X POST "http://localhost:18081/internal/didman/v1/did/$3/endpoint" -H "Content-Type: application/json" --data-binary @- > /dev/null +} + +# addVerificationMethodV1 add a verification method to a DID document using the vdr/v1 API +# Args: DID to add the verification method to +# Returns: null +function addVerificationMethodV1() { + curl -sS -X POST "http://localhost:18081/internal/vdr/v1/did/$1/verificationmethod" > /dev/null +} + +# deactivateDIDV1 deactivates a DID document using the vdr/v1 API +# Args: DID to deactivate +# Returns: null +function deactivateDIDV1() { + curl -sS -X DELETE "http://localhost:18081/internal/vdr/v1/did/$1" > /dev/null +} + +echo "------------------------------------" +echo "Cleaning up running Docker containers and volumes, and key material..." +echo "------------------------------------" +docker compose -f docker-compose-pre-migration.yml down +docker compose -f docker-compose-pre-migration.yml rm -f -v +rm -rf ./node*/ +mkdir -p ./nodeA/{data,backup} ./nodeB/data # 'data' dirs will be created with root owner by docker if they do not exit. This creates permission issues on CI. + +echo "------------------------------------" +echo "Starting Docker containers..." +echo "------------------------------------" +docker compose -f docker-compose-pre-migration.yml up --wait nodeA nodeB + +echo "------------------------------------" +echo "Registering DIDs..." +echo "------------------------------------" +# Register Vendor +VENDOR_DID=$(curl -X POST -sS http://localhost:18081/internal/vdr/v1/did | jq -r .id) +echo Vendor DID: "$VENDOR_DID" +# Register org1 +ORG1_DID=$(createOrg "$VENDOR_DID") +echo Org1 DID: "$ORG1_DID" +# Register org2 +ORG2_DID=$(createOrg "$VENDOR_DID") +echo Org2 DID: "$ORG2_DID" +# Register org3 +ORG3_DID=$(createOrg "$VENDOR_DID") +echo Org3 DID: "$ORG3_DID" + +# Wait for NodeB to contain 4 transactions +waitForTXCount "NodeB" "http://localhost:28081/status/diagnostics" 4 10 + +echo "------------------------------------" +echo "Making backup nodeA..." +echo "------------------------------------" +docker compose -f docker-compose-pre-migration.yml stop nodeA +cp -R ./nodeA/data/* ./nodeA/backup/ +docker compose -f docker-compose-pre-migration.yml up --wait nodeA + +echo "------------------------------------" +echo "Adding and syncing left branch..." +echo "------------------------------------" +addServiceV1 "http://vendor" "service1" "$VENDOR_DID" +deactivateDIDV1 "$ORG2_DID" +addServiceV1 "http://org1" "service1" "$ORG1_DID" +addServiceV1 "http://org3" "service1" "$ORG3_DID" + +# Wait for NodeB to contain 8 transactions +waitForTXCount "NodeB" "http://localhost:28081/status/diagnostics" 8 10 + +echo "------------------------------------" +echo "Restoring backup to nodeA..." +echo "------------------------------------" +docker compose -f docker-compose-pre-migration.yml stop +rm -r ./nodeA/data +mv ./nodeA/backup ./nodeA/data +docker compose -f docker-compose-pre-migration.yml up nodeA --wait nodeA + +echo "------------------------------------" +echo "Adding right branch..." +echo "------------------------------------" +addServiceV1 "http://vendor" "service2" "$VENDOR_DID" +addServiceV1 "http://org1" "service2" "$ORG1_DID" +addServiceV1 "http://org2" "service2" "$ORG2_DID" +addVerificationMethodV1 "$ORG3_DID" + +# Check NodeA contains 8 transactions, nodeB is offline +waitForTXCount "NodeA" "http://localhost:18081/status/diagnostics" 8 10 + +echo "------------------------------------" +echo "Syncing right branch..." +echo "------------------------------------" +docker compose -f docker-compose-pre-migration.yml up --wait nodeB +# sync left and right branch through nodeB to create document conflicts on all DIDs + +# Wait for NodeB to contain 12 transactions +waitForTXCount "NodeB" "http://localhost:28081/status/diagnostics" 12 10 + +echo "------------------------------------" +echo "Fix some DID document conflicts..." +echo "------------------------------------" +addVerificationMethodV1 "$VENDOR_DID" +addServiceV1 "http://org3" "service2" "$ORG3_DID" +# ORG1 is in conflicted state +# ORG2 is conflicted but deactivated + +# Wait for NodeB to contain 14 transactions +waitForTXCount "NodeB" "http://localhost:28081/status/diagnostics" 14 10 + +echo "------------------------------------" +echo "Upgrade nodeA to v6..." +echo "------------------------------------" +docker compose -f docker-compose-pre-migration.yml down +docker compose -f docker-compose-post-migration.yml up --wait nodeA nodeB +# controller migration: +2 transactions: remove controllers from ORG1 and ORG3 + +# Wait for NodeB to contain 16 transactions +waitForTXCount "NodeB" "http://localhost:28081/status/diagnostics" 16 10 + +echo "------------------------------------" +echo "Stopping Docker containers..." +echo "------------------------------------" +docker compose -f docker-compose-post-migration.yml stop + +echo "------------------------------------" +echo "Verifying migration results..." +echo "------------------------------------" +# all 'waitForTXCount' calls have confirmed the didstore is (likely to be) in the correct state. Now check SQL store. + +VENDOR_DID=$VENDOR_DID ORG1_DID=$ORG1_DID ORG2_DID=$ORG2_DID ORG3_DID=$ORG3_DID go test -v --tags=e2e_tests -count=1 . +if [ $? -ne 0 ]; then + echo "ERROR: test failure" + exitWithDockerLogs 1 docker-compose-post-migration.yml +fi \ No newline at end of file diff --git a/e2e-tests/migration/run-tests.sh b/e2e-tests/migration/run-tests.sh new file mode 100755 index 0000000000..e0f9acf11a --- /dev/null +++ b/e2e-tests/migration/run-tests.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e # make script fail if any of the tests returns a non-zero exit code + +echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" +echo "!! Running test: Migrations v6 !!" +echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" +./run-test.sh \ No newline at end of file diff --git a/e2e-tests/run-tests.sh b/e2e-tests/run-tests.sh index c6df3fb2ae..98146cf5b6 100755 --- a/e2e-tests/run-tests.sh +++ b/e2e-tests/run-tests.sh @@ -57,3 +57,10 @@ echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" pushd discovery ./run-tests.sh popd + +echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" +echo "!! Running test suite: Migration !!" +echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" +pushd migration +./run-tests.sh +popd diff --git a/e2e-tests/util.sh b/e2e-tests/util.sh index e1306da1b2..b140fb94ce 100644 --- a/e2e-tests/util.sh +++ b/e2e-tests/util.sh @@ -57,10 +57,13 @@ function waitForDiagnostic { echo "" } +# exitWithDockerLogs prints the logs of the containers and exits with a specified exit code +# Args: exit code; docker compose file to use for the logs (default: docker-compose.yml) function exitWithDockerLogs { EXIT_CODE=$1 - docker compose logs - docker compose down + COMPOSE_FILE="${2:-docker-compose.yml}" + docker compose -f $COMPOSE_FILE logs + docker compose -f $COMPOSE_FILE stop exit $EXIT_CODE } diff --git a/storage/orm/did_document.go b/storage/orm/did_document.go index f0d6a05a2d..d0def02736 100644 --- a/storage/orm/did_document.go +++ b/storage/orm/did_document.go @@ -20,6 +20,9 @@ package orm import ( "encoding/json" + "time" + + "github.com/google/uuid" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/jsonld" "gorm.io/gorm/schema" @@ -102,3 +105,65 @@ func (sqlDoc DidDocument) GenerateDIDDocument() (did.Document, error) { return document, nil } + +// MigrationDocument is used to convert a did.Document + metadata to a DidDocument. +// DEPRECATED: only intended to migrate owned did:nuts to SQL storage. +type MigrationDocument struct { + // Raw contains the did.Document in bytes. For did:nuts this is must be equal to the payload in the network transaction. + Raw []byte + Created time.Time + Updated time.Time + Version int +} + +// ToORMDocument converts the Raw document to a DidDocument. Generates a new DidDocument.ID +func (migration MigrationDocument) ToORMDocument(subject string) (DidDocument, error) { + doc := new(did.Document) + err := json.Unmarshal(migration.Raw, doc) + if err != nil { + return DidDocument{}, err + } + + // generate DB documentID + documentID := uuid.New().String() + + // convert []did.VerificationMethod to []VerificationMethod + vms := make([]VerificationMethod, len(doc.VerificationMethod)) + for i, vm := range doc.VerificationMethod { + vmAsJson, _ := json.Marshal(*vm) + vms[i] = VerificationMethod{ + ID: vm.ID.String(), + KeyTypes: VerificationMethodKeyType(verificationMethodToKeyFlags(*doc, vm)), + Data: vmAsJson, + } + } + + // convert []did.Service to []Service + services := make([]Service, len(doc.Service)) + for i, service := range doc.Service { + asJson, _ := json.Marshal(service) + services[i] = Service{ + ID: service.ID.String(), + Data: asJson, + } + } + + // DID + subjectDID := DID{ + ID: doc.ID.String(), + Subject: subject, + } + + // return document + return DidDocument{ + ID: documentID, + DidID: doc.ID.String(), + DID: subjectDID, + CreatedAt: migration.Created.Unix(), + UpdatedAt: migration.Updated.Unix(), + Version: migration.Version, + VerificationMethods: vms, + Services: services, + Raw: string(migration.Raw), + }, nil +} diff --git a/storage/orm/did_document_test.go b/storage/orm/did_document_test.go index 8a532ded00..9c2f886362 100644 --- a/storage/orm/did_document_test.go +++ b/storage/orm/did_document_test.go @@ -1,11 +1,12 @@ package orm import ( + "encoding/json" "github.com/nuts-foundation/go-did/did" - "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "testing" + "time" ) var ( @@ -44,3 +45,65 @@ func TestDIDDocument_ToDIDDocument(t *testing.T) { assert.Equal(t, "#1", didDoc.VerificationMethod[0].ID.String()) assert.Equal(t, "#2", didDoc.Service[0].ID.String()) } + +func TestDIDDocument_FromDIDDocument(t *testing.T) { + created := time.Now() + updated := created.Add(time.Second) + version := 4 + vms := []VerificationMethod{ + { + ID: "#1", + Data: []byte(`{"id":"#1"}`), + KeyTypes: VerificationMethodKeyType(AssertionMethodUsage | KeyAgreementUsage), + }, { + ID: "#2", + Data: []byte(`{"id":"#2"}`), + KeyTypes: VerificationMethodKeyType(KeyAgreementUsage), + }, + } + service := Service{ + ID: "#service", + Data: []byte(`{"id":"#service"}`), + } + didDoc, err := DidDocument{ + DID: DID{ID: alice.String()}, + VerificationMethods: vms, + Services: []Service{service}, + CreatedAt: created.Unix(), + UpdatedAt: updated.Unix(), + }.ToDIDDocument() + require.NoError(t, err) + docRaw, err := json.Marshal(didDoc) + require.NoError(t, err) + + result, err := MigrationDocument{ + Version: version, + Created: created, + Updated: updated, + Raw: docRaw, + }.ToORMDocument("test-subject") + require.NoError(t, err) + + assert.NotEmpty(t, result.ID) + assert.Equal(t, alice.String(), result.DidID) + assert.Equal(t, created.Unix(), result.CreatedAt) + assert.Equal(t, updated.Unix(), result.UpdatedAt) + assert.Equal(t, version, result.Version) + + // DID + assert.Equal(t, DID{ + ID: alice.String(), + Subject: "test-subject", + }, result.DID) + + // Services + require.Len(t, result.Services, 1) + assert.Equal(t, service, result.Services[0]) + + // VerificationMethods + require.Len(t, result.VerificationMethods, 2) + assert.Equal(t, "#1", result.VerificationMethods[0].ID) + assert.Equal(t, VerificationMethodKeyType(AssertionMethodUsage|KeyAgreementUsage), result.VerificationMethods[0].KeyTypes) + assert.Equal(t, "#2", result.VerificationMethods[1].ID) + assert.Equal(t, VerificationMethodKeyType(KeyAgreementUsage), result.VerificationMethods[1].KeyTypes) +} diff --git a/storage/orm/keyflag.go b/storage/orm/keyflag.go index 46a13ff8d8..b04f8dc433 100644 --- a/storage/orm/keyflag.go +++ b/storage/orm/keyflag.go @@ -18,6 +18,8 @@ package orm +import "github.com/nuts-foundation/go-did/did" + // DIDKeyFlags is a bitmask used for specifying for what purposes a key in a DID document can be used (a.k.a. Verification Method relationships). type DIDKeyFlags uint @@ -46,3 +48,24 @@ func AssertionKeyUsage() DIDKeyFlags { func EncryptionKeyUsage() DIDKeyFlags { return KeyAgreementUsage } + +// verificationMethodToKeyFlags creates DIDKeyFlags for a did.VerificationMethod based on its usage in the did.Document. +func verificationMethodToKeyFlags(document did.Document, vm *did.VerificationMethod) DIDKeyFlags { + var flags DIDKeyFlags + if document.Authentication.FindByID(vm.ID) != nil { + flags |= AuthenticationUsage + } + if document.AssertionMethod.FindByID(vm.ID) != nil { + flags |= AssertionMethodUsage + } + if document.CapabilityDelegation.FindByID(vm.ID) != nil { + flags |= CapabilityDelegationUsage + } + if document.CapabilityInvocation.FindByID(vm.ID) != nil { + flags |= CapabilityInvocationUsage + } + if document.KeyAgreement.FindByID(vm.ID) != nil { + flags |= KeyAgreementUsage + } + return flags +} diff --git a/storage/orm/keyflag_test.go b/storage/orm/keyflag_test.go index db6fbe57ee..550c6e180b 100644 --- a/storage/orm/keyflag_test.go +++ b/storage/orm/keyflag_test.go @@ -19,6 +19,7 @@ package orm import ( + "github.com/nuts-foundation/go-did/did" "testing" "github.com/stretchr/testify/assert" @@ -55,3 +56,43 @@ func TestKeyUsage_Is(t *testing.T) { } }) } + +func Test_verificationMethodToKeyFlags(t *testing.T) { + vr1 := did.VerificationRelationship{VerificationMethod: &did.VerificationMethod{ + ID: did.MustParseDIDURL("did:method:something#key-1"), + }} + vr2 := did.VerificationRelationship{VerificationMethod: &did.VerificationMethod{ + ID: did.MustParseDIDURL("did:method:something#key-2"), + }} + t.Run("single-key", func(t *testing.T) { + t.Run("AssertionMethod", func(t *testing.T) { + doc := did.Document{AssertionMethod: did.VerificationRelationships{vr1}} + assert.Equal(t, AssertionMethodUsage, verificationMethodToKeyFlags(doc, vr1.VerificationMethod)) + }) + t.Run("Authentication", func(t *testing.T) { + doc := did.Document{Authentication: did.VerificationRelationships{vr1}} + assert.Equal(t, AuthenticationUsage, verificationMethodToKeyFlags(doc, vr1.VerificationMethod)) + }) + t.Run("CapabilityDelegation", func(t *testing.T) { + doc := did.Document{CapabilityDelegation: did.VerificationRelationships{vr1}} + assert.Equal(t, CapabilityDelegationUsage, verificationMethodToKeyFlags(doc, vr1.VerificationMethod)) + }) + t.Run("CapabilityInvocation", func(t *testing.T) { + doc := did.Document{CapabilityInvocation: did.VerificationRelationships{vr1}} + assert.Equal(t, CapabilityInvocationUsage, verificationMethodToKeyFlags(doc, vr1.VerificationMethod)) + }) + t.Run("KeyAgreement", func(t *testing.T) { + doc := did.Document{KeyAgreement: did.VerificationRelationships{vr1}} + assert.Equal(t, KeyAgreementUsage, verificationMethodToKeyFlags(doc, vr1.VerificationMethod)) + }) + }) + t.Run("multi-key", func(t *testing.T) { + doc := did.Document{ + AssertionMethod: did.VerificationRelationships{vr1}, + CapabilityInvocation: did.VerificationRelationships{vr1, vr2}, + KeyAgreement: did.VerificationRelationships{vr2}, + } + assert.Equal(t, AssertionMethodUsage|CapabilityInvocationUsage, verificationMethodToKeyFlags(doc, vr1.VerificationMethod)) + assert.Equal(t, CapabilityInvocationUsage|KeyAgreementUsage, verificationMethodToKeyFlags(doc, vr2.VerificationMethod)) + }) +} diff --git a/storage/test.go b/storage/test.go index 21b348dd69..e6f0b432b2 100644 --- a/storage/test.go +++ b/storage/test.go @@ -21,6 +21,7 @@ package storage import ( "context" "errors" + "fmt" "github.com/alicebob/miniredis/v2" "github.com/nuts-foundation/go-stoabs" "github.com/nuts-foundation/nuts-node/test/io" @@ -70,6 +71,7 @@ func NewTestStorageEngineInDir(t testing.TB, dir string) Engine { t.Cleanup(func() { _ = result.Shutdown() }) + fmt.Printf("Created test storage engine in %s\n", dir) return result } diff --git a/vdr/didnuts/didstore/interface.go b/vdr/didnuts/didstore/interface.go index 22ee14cc68..d180c7741b 100644 --- a/vdr/didnuts/didstore/interface.go +++ b/vdr/didnuts/didstore/interface.go @@ -20,6 +20,7 @@ package didstore import ( "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/storage/orm" "github.com/nuts-foundation/nuts-node/vdr/resolver" ) @@ -42,6 +43,11 @@ type Store interface { // It returns vdr.ErrNotFound if there are no corresponding DID documents or when the DID Documents are disjoint with the provided ResolveMetadata. // It returns vdr.ErrDeactivated if no metadata is given and the latest version of the DID Document is deactivated. Resolve(id did.DID, metadata *resolver.ResolveMetadata) (*did.Document, *resolver.DocumentMetadata, error) + // HistorySinceVersion returns all versions of the DID Document since the specified version (including version). + // The slice is empty when version is the most recent version of the DID Document. + // The history contains DID Documents as they were published, which differs from Resolve that produces a merge of conflicted documents. + // DEPRECATED: This function exists to migrate the history of owned DIDs from key-value storage to SQL storage. + HistorySinceVersion(id did.DID, version int) ([]orm.MigrationDocument, error) } // Transaction is an alias to the didstore.event. Internally to the didstore it's an event based on a transaction. diff --git a/vdr/didnuts/didstore/mock.go b/vdr/didnuts/didstore/mock.go index 9991b42f16..febc56696b 100644 --- a/vdr/didnuts/didstore/mock.go +++ b/vdr/didnuts/didstore/mock.go @@ -13,6 +13,7 @@ import ( reflect "reflect" did "github.com/nuts-foundation/go-did/did" + orm "github.com/nuts-foundation/nuts-node/storage/orm" resolver "github.com/nuts-foundation/nuts-node/vdr/resolver" gomock "go.uber.org/mock/gomock" ) @@ -98,6 +99,21 @@ func (mr *MockStoreMockRecorder) DocumentCount() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DocumentCount", reflect.TypeOf((*MockStore)(nil).DocumentCount)) } +// HistorySinceVersion mocks base method. +func (m *MockStore) HistorySinceVersion(id did.DID, version int) ([]orm.MigrationDocument, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HistorySinceVersion", id, version) + ret0, _ := ret[0].([]orm.MigrationDocument) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// HistorySinceVersion indicates an expected call of HistorySinceVersion. +func (mr *MockStoreMockRecorder) HistorySinceVersion(id, version any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HistorySinceVersion", reflect.TypeOf((*MockStore)(nil).HistorySinceVersion), id, version) +} + // Iterate mocks base method. func (m *MockStore) Iterate(fn resolver.DocIterator) error { m.ctrl.T.Helper() diff --git a/vdr/didnuts/didstore/store.go b/vdr/didnuts/didstore/store.go index 5b2d44c138..389062e916 100644 --- a/vdr/didnuts/didstore/store.go +++ b/vdr/didnuts/didstore/store.go @@ -26,7 +26,10 @@ import ( "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-stoabs" "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/crypto/hash" "github.com/nuts-foundation/nuts-node/storage" + "github.com/nuts-foundation/nuts-node/storage/orm" + "github.com/nuts-foundation/nuts-node/vdr/log" "github.com/nuts-foundation/nuts-node/vdr/resolver" ) @@ -349,3 +352,60 @@ func latestNonDeactivatedRequested(resolveMetadata *resolver.ResolveMetadata) bo } return !resolveMetadata.AllowDeactivated } + +func (tl *store) HistorySinceVersion(id did.DID, version int) ([]orm.MigrationDocument, error) { + if version < 0 { + return nil, errors.New("negative version") + } + var history []orm.MigrationDocument + txErr := tl.db.Read(context.TODO(), func(tx stoabs.ReadTx) error { + el, err := readEventList(tx, id) + if err != nil { + return err + } + if len(el.Events) == 0 { + return storage.ErrNotFound + } + highestDIDStoreVersion := len(el.Events) - 1 + + // return if no changes + if version > highestDIDStoreVersion { + return nil + } + + created := el.Events[0].SigningTime + documentReader := tx.GetShelfReader(documentShelf) + history = make([]orm.MigrationDocument, 0, highestDIDStoreVersion-version+1) + for v := version; v <= highestDIDStoreVersion; v++ { + payloadHash := el.Events[v].PayloadHash + documentBytes, err := documentReader.Get(stoabs.NewHashKey(payloadHash)) + if err != nil { + if errors.Is(err, stoabs.ErrKeyNotFound) { + return storage.ErrNotFound + } + return err + } + // We expect documentBytes to be equal to the raw payload on the DAG because we are the publisher of the document and use the same un/marshal methods everywhere. + // This will break if did.Document in go-did is changed. + // Confirmed this is safe/true for all documents on prd network as of 13-09-24, so for now just log if they are not the same. + if !payloadHash.Equals(hash.SHA256Sum(documentBytes)) { + log.Logger(). + WithField("payload hash", payloadHash). + WithField("document hash", hash.SHA256Sum(documentBytes)). + WithField("document version", version). + Error("Payload hash does not match hash of DID Document during did:nuts history migration") + } + history = append(history, orm.MigrationDocument{ + Raw: documentBytes, + Created: created, + Updated: el.Events[v].SigningTime, + Version: v, + }) + } + return nil + }) + if txErr != nil { + return nil, txErr + } + return history, nil +} diff --git a/vdr/didnuts/didstore/store_test.go b/vdr/didnuts/didstore/store_test.go index d3d42503e7..bcf0f726fd 100644 --- a/vdr/didnuts/didstore/store_test.go +++ b/vdr/didnuts/didstore/store_test.go @@ -29,6 +29,7 @@ import ( "github.com/nuts-foundation/go-stoabs" "github.com/nuts-foundation/go-stoabs/redis7" "github.com/nuts-foundation/nuts-node/crypto/hash" + "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" @@ -454,3 +455,77 @@ func Test_matches(t *testing.T) { }) }) } + +func TestStore_HistorySinceVersion(t *testing.T) { + store := NewTestStore(t) + + // create DID document with some updates and a document conflict + doc1 := did.Document{ID: testDID, Controller: []did.DID{testDID}, Service: []did.Service{testServiceA}} + tx1 := newTestTransaction(doc1) // serviceA + tx1.SigningTime = time.Now().Add(-time.Second) + doc2a := did.Document{ID: testDID, Service: []did.Service{testServiceA}} + tx2a := newTestTransaction(doc2a, tx1.Ref) // deactivate + doc2b := did.Document{ID: testDID, Controller: []did.DID{testDID}, Service: []did.Service{testServiceB}} + tx2b := newTestTransaction(doc2b, tx1.Ref) // serviceB + doc3 := did.Document{ID: testDID, Controller: []did.DID{testDID}, Service: []did.Service{testServiceA, testServiceB}} + tx3 := newTestTransaction(doc3, tx2a.Ref, tx2b.Ref) // serviceA + serviceB + + // add all transactions. + // txs all have LC=0, so they are sorted on SigningTime. Nano-sec timestamps guarantee that the tx{1,2a,2b,3} order is preserved + require.NoError(t, store.Add(doc1, tx1)) + require.NoError(t, store.Add(doc2a, tx2a)) + require.NoError(t, store.Add(doc2b, tx2b)) + require.NoError(t, store.Add(doc3, tx3)) + + // raw documents in order + raw := [4][]byte{} + raw[0], _ = json.Marshal(doc1) + raw[1], _ = json.Marshal(doc2a) + raw[2], _ = json.Marshal(doc2b) + raw[3], _ = json.Marshal(doc3) + + t.Run("ok - full history", func(t *testing.T) { + history, err := store.HistorySinceVersion(testDID, 0) + assert.NoError(t, err) + require.Len(t, history, 4) + + for idx, tx := range []Transaction{tx1, tx2a, tx2b, tx3} { + result := history[idx] + assert.Equal(t, raw[idx], result.Raw) // make sure result.Raw contains the original documents, not the merged document conflicts + assert.True(t, tx1.SigningTime.Equal(result.Created)) + assert.True(t, tx.SigningTime.Equal(result.Updated)) + assert.Equal(t, idx, result.Version) + } + }) + t.Run("ok - partial history", func(t *testing.T) { + history, err := store.HistorySinceVersion(testDID, 1) + assert.NoError(t, err) + require.Len(t, history, 3) + assert.Equal(t, 1, history[0].Version) + }) + t.Run("ok - no version updates", func(t *testing.T) { + history, err := store.HistorySinceVersion(testDID, 5) + assert.NoError(t, err) + assert.Len(t, history, 0) + }) + t.Run("error - negative version", func(t *testing.T) { + history, err := store.HistorySinceVersion(testDID, -1) + assert.EqualError(t, err, "negative version") + assert.Nil(t, history) + }) + t.Run("error - unknown DID", func(t *testing.T) { + history, err := store.HistorySinceVersion(did.MustParseDID("did:nuts:unknown"), 0) + assert.ErrorIs(t, err, storage.ErrNotFound) + assert.Nil(t, history) + }) + t.Run("error - document version not found", func(t *testing.T) { + err := store.db.WriteShelf(context.Background(), documentShelf, func(writer stoabs.Writer) error { + return writer.Delete(stoabs.NewHashKey(tx1.PayloadHash)) + }) + require.NoError(t, err) + + history, err := store.HistorySinceVersion(testDID, 0) + assert.ErrorIs(t, err, storage.ErrNotFound) + assert.Nil(t, history) + }) +} diff --git a/vdr/didsubject/did_document.go b/vdr/didsubject/did_document.go index f8b283c2ca..3a15797568 100644 --- a/vdr/didsubject/did_document.go +++ b/vdr/didsubject/did_document.go @@ -88,5 +88,5 @@ func (s *SqlDIDDocumentManager) Latest(did did.DID, resolveTime *time.Time) (*or if err != nil { return nil, err } - return &doc, err + return &doc, nil } diff --git a/vdr/didsubject/interface.go b/vdr/didsubject/interface.go index 0c7d17c91c..d718a9bb67 100644 --- a/vdr/didsubject/interface.go +++ b/vdr/didsubject/interface.go @@ -135,6 +135,14 @@ type Manager interface { Rollback(ctx context.Context) } +// DocumentMigration is used to migrate DID document versions to the SQL DB. This should only be used for DID documents managed by this node. +type DocumentMigration interface { + // MigrateDIDHistoryToSQL is used to migrate the history of a DID Document to SQL. + // It adds all versions of a DID Document up to a deactivated version. Any changes after a deactivation are not migrated. + // getHistory retrieves the history of the DID since the requested version. + MigrateDIDHistoryToSQL(id did.DID, subject string, getHistory func(id did.DID, sinceVersion int) ([]orm.MigrationDocument, error)) error +} + // SubjectCreationOption links all create DIDs to the DID Subject type SubjectCreationOption struct { Subject string diff --git a/vdr/didsubject/manager.go b/vdr/didsubject/manager.go index 1888ac9525..334780e9b9 100644 --- a/vdr/didsubject/manager.go +++ b/vdr/didsubject/manager.go @@ -33,6 +33,7 @@ import ( "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/storage/orm" "github.com/nuts-foundation/nuts-node/vdr/log" + "github.com/nuts-foundation/nuts-node/vdr/resolver" "gorm.io/gorm" "regexp" "sort" @@ -134,8 +135,7 @@ func (r *SqlManager) Create(ctx context.Context, options CreationOptions) ([]did sqlDocs := make(map[string]orm.DidDocument) err := r.transactionHelper(ctx, func(tx *gorm.DB) (map[string]orm.DIDChangeLog, error) { // check existence - sqlDIDManager := NewDIDManager(tx) - _, err := sqlDIDManager.FindBySubject(subject) + _, err := NewDIDManager(tx).FindBySubject(subject) if errors.Is(err, ErrSubjectNotFound) { // this is ok, doesn't exist yet } else if err != nil { @@ -648,3 +648,76 @@ func sortDIDDocumentsByMethod(list []did.Document, methodOrder []string) { } copy(list, orderedList) } + +func (r *SqlManager) MigrateDIDHistoryToSQL(id did.DID, subject string, getHistory func(id did.DID, sinceVersion int) ([]orm.MigrationDocument, error)) error { + latestSQLVersion := -1 // -1 means it's a new DID + latestORMDocument, err := NewDIDDocumentManager(r.DB).Latest(id, nil) + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + // new DID, don't update latestSQLVersion + } else { + // don't migrate DID documents past their deactivation + latestDIDDocument, err := latestORMDocument.ToDIDDocument() + if err != nil { + return err + } + if resolver.IsDeactivated(latestDIDDocument) { + return nil + } + // set latestSQLVersion + latestSQLVersion = latestORMDocument.Version + } + + // get all new document updates + // NOTE: this assumes updates are only appended to the end. This breaks if the history of a document is altered. + history, err := getHistory(id, latestSQLVersion+1) + if err != nil { + return err + } + if len(history) == 0 { + return nil + } + + // convert history to orm objects + documentVersions := make([]orm.DidDocument, len(history)) + for i, entry := range history { + documentVersions[i], err = entry.ToORMDocument(subject) + if err != nil { + return err + } + + // break if this version deactivates the document + didDocument, err := documentVersions[i].ToDIDDocument() + if err != nil { + return err + } + if resolver.IsDeactivated(didDocument) { + documentVersions = documentVersions[:i+1] + break + } + } + + return r.DB.Transaction(func(tx *gorm.DB) error { + if latestSQLVersion == -1 { + // add subject to did table + DID := orm.DID{ + ID: id.String(), + Subject: subject, + } + err = tx.Create(&DID).Error + if err != nil { + return err + } + } + // add document history + for _, doc := range documentVersions { + err = tx.Create(&doc).Error + if err != nil { + return err + } + } + return nil + }) +} diff --git a/vdr/didsubject/manager_test.go b/vdr/didsubject/manager_test.go index a46b4d8e93..9134b71e69 100644 --- a/vdr/didsubject/manager_test.go +++ b/vdr/didsubject/manager_test.go @@ -20,8 +20,11 @@ package didsubject import ( "context" + "encoding/json" + "errors" "fmt" ssi "github.com/nuts-foundation/go-did" + "github.com/nuts-foundation/nuts-node/crypto/storage/spi" "github.com/nuts-foundation/nuts-node/storage/orm" "net/url" "strings" @@ -482,3 +485,112 @@ func Test_sortDIDDocumentsByMethod(t *testing.T) { assert.Equal(t, "did:test:1", documents[0].ID.String()) assert.Equal(t, "did:example:1", documents[1].ID.String()) } + +func TestSqlManager_MigrateDIDHistoryToSQL(t *testing.T) { + testDID := did.MustParseDID("did:nuts:test") + vmID := did.MustParseDIDURL("did:nuts:test#key-1") + key, _ := spi.GenerateKeyPair() + vm, err := did.NewVerificationMethod(vmID, ssi.JsonWebKey2020, testDID, key.Public()) + require.NoError(t, err) + service := did.Service{ + ID: ssi.MustParseURI(testDID.String() + "#service-1"), + Type: "test", + ServiceEndpoint: "https://example.com", + } + created := time.Now().Add(-5 * time.Second) + subject := "test-subject" + + rawDocNew, err := json.Marshal(did.Document{ID: testDID, CapabilityInvocation: []did.VerificationRelationship{{VerificationMethod: vm}}, VerificationMethod: did.VerificationMethods{vm}}) + require.NoError(t, err) + rawDocUpdate, _ := json.Marshal(did.Document{ID: testDID, Service: []did.Service{service}, CapabilityInvocation: []did.VerificationRelationship{{VerificationMethod: vm}}}) + rawDocDeactivate, _ := json.Marshal(did.Document{ID: testDID}) + ormMigrateNew := orm.MigrationDocument{Raw: rawDocNew, Created: created, Updated: created, Version: 0} + ormMigrateUpdate := orm.MigrationDocument{Raw: rawDocUpdate, Created: created, Updated: created.Add(2 * time.Second), Version: 1} + ormMigrateDeactivate := orm.MigrationDocument{Raw: rawDocDeactivate, Created: created, Updated: created.Add(2 * time.Second), Version: 1} + ormDocNew, err := ormMigrateNew.ToORMDocument(subject) + ormDocUpdate, _ := ormMigrateUpdate.ToORMDocument(subject) + ormDocDeactivate, _ := ormMigrateDeactivate.ToORMDocument(subject) + equal := func(t *testing.T, o1, o2 orm.DidDocument) { + //assert.NotEqual(t, o1.ID, o2.ID) // ToORMDocument generates a random ID everytime. Should probably be ignored. + assert.Equal(t, o1.DidID, o2.DidID) + assert.Equal(t, o1.DID, o2.DID) + assert.Equal(t, o1.CreatedAt, o2.CreatedAt) + assert.Equal(t, o1.UpdatedAt, o2.UpdatedAt) + assert.Equal(t, o1.Version, o2.Version) + assert.Equal(t, o1.VerificationMethods, o2.VerificationMethods) + assert.Equal(t, o1.Services, o2.Services) + assert.Equal(t, o1.Raw, o2.Raw) + } + t.Run("create", func(t *testing.T) { + db := storage.NewTestStorageEngine(t).GetSQLDatabase() + manager := SqlManager{DB: db} + + err = manager.MigrateDIDHistoryToSQL(testDID, subject, func(id did.DID, sinceVersion int) ([]orm.MigrationDocument, error) { + assert.Equal(t, testDID, id) + assert.Equal(t, 0, sinceVersion) + return []orm.MigrationDocument{ormMigrateNew, ormMigrateUpdate}, nil + }) + require.NoError(t, err) + + // version 0 + ormDoc, err := NewDIDDocumentManager(db).Latest(testDID, &created) + require.NoError(t, err) + equal(t, *ormDoc, ormDocNew) + // version 1 + ormDoc, err = NewDIDDocumentManager(db).Latest(testDID, nil) + require.NoError(t, err) + equal(t, *ormDoc, ormDocUpdate) + }) + t.Run("update", func(t *testing.T) { + db := storage.NewTestStorageEngine(t).GetSQLDatabase() + manager := SqlManager{DB: db} + + // init version 0 + err = manager.MigrateDIDHistoryToSQL(testDID, subject, func(id did.DID, sinceVersion int) ([]orm.MigrationDocument, error) { + assert.Equal(t, 0, sinceVersion) + return []orm.MigrationDocument{ormMigrateNew}, nil + }) + require.NoError(t, err) + ormDoc, err := NewDIDDocumentManager(db).Latest(testDID, nil) + require.NoError(t, err) + equal(t, *ormDoc, ormDocNew) + + // update to version 1 + err = manager.MigrateDIDHistoryToSQL(testDID, subject, func(id did.DID, sinceVersion int) ([]orm.MigrationDocument, error) { + assert.Equal(t, 1, sinceVersion) + return []orm.MigrationDocument{ormMigrateUpdate}, nil + }) + require.NoError(t, err) + ormDoc, err = NewDIDDocumentManager(db).Latest(testDID, nil) + require.NoError(t, err) + equal(t, *ormDoc, ormDocUpdate) + }) + t.Run("deactivate", func(t *testing.T) { + db := storage.NewTestStorageEngine(t).GetSQLDatabase() + manager := SqlManager{DB: db} + + // create in version 0, deactivate in version 1 + err = manager.MigrateDIDHistoryToSQL(testDID, subject, func(id did.DID, sinceVersion int) ([]orm.MigrationDocument, error) { + return []orm.MigrationDocument{ormMigrateNew, ormMigrateDeactivate}, nil + }) + require.NoError(t, err) + ormDoc, err := NewDIDDocumentManager(db).Latest(testDID, nil) + require.NoError(t, err) + equal(t, *ormDoc, ormDocDeactivate) + + // don't update deactivated document + assert.NotPanics(t, func() { + _ = manager.MigrateDIDHistoryToSQL(testDID, subject, func(id did.DID, sinceVersion int) ([]orm.MigrationDocument, error) { + panic("function should not be called") + }) + }) + }) + t.Run("error - getHistory", func(t *testing.T) { + db := storage.NewTestStorageEngine(t).GetSQLDatabase() + manager := SqlManager{DB: db} + err = manager.MigrateDIDHistoryToSQL(testDID, subject, func(id did.DID, sinceVersion int) ([]orm.MigrationDocument, error) { + return nil, errors.New("test") + }) + assert.EqualError(t, err, "test") + }) +} diff --git a/vdr/vdr.go b/vdr/vdr.go index ce61044dd5..dcd4234b59 100644 --- a/vdr/vdr.go +++ b/vdr/vdr.go @@ -77,6 +77,8 @@ type Module struct { keyStore crypto.KeyStore storageInstance storage.Engine eventManager events.Event + // migrations are registered functions to simplify testing + migrations map[string]migration // new style DID management didsubject.Manager @@ -124,6 +126,7 @@ func NewVDR(cryptoClient crypto.KeyStore, networkClient network.Transactions, } m.ctx, m.cancel = context.WithCancel(context.Background()) m.routines = new(sync.WaitGroup) + m.migrations = m.allMigrations() return m } @@ -164,17 +167,19 @@ func (r *Module) Configure(config core.ServerConfig) error { // Register DID resolver and DID methods we can resolve r.ownedDIDResolver = didsubject.Resolver{DB: db} - // Methods we can produce from the Nuts node - // did:nuts - nutsManager := didnuts.NewManager(r.keyStore, r.network, r.store, r.didResolver, db) - r.nutsDocumentManager = nutsManager - methodManagers = map[string]didsubject.MethodManager{} r.documentOwner = &MultiDocumentOwner{ DocumentOwners: []didsubject.DocumentOwner{ newCachingDocumentOwner(DBDocumentOwner{DB: db}, r.didResolver), newCachingDocumentOwner(privateKeyDocumentOwner{keyResolver: r.keyStore}, r.didResolver), }, } + + // Methods we can produce from the Nuts node + methodManagers = map[string]didsubject.MethodManager{} + + // did:nuts + nutsManager := didnuts.NewManager(r.keyStore, r.network, r.store, r.didResolver, db) + r.nutsDocumentManager = nutsManager if slices.Contains(r.config.DIDMethods, didnuts.MethodName) { methodManagers[didnuts.MethodName] = nutsManager r.didResolver.(*resolver.DIDResolverRouter).Register(didnuts.MethodName, &didnuts.Resolver{Store: r.store}) @@ -361,40 +366,82 @@ func (r *Module) Migrate() error { if err != nil { return err } - auditContext := audit.Context(context.Background(), "system", ModuleName, "migrate") + + // only migrate if did:nuts is activated on the node + if slices.Contains(r.SupportedMethods(), "nuts") { + for name, migrate := range r.migrations { + log.Logger().Infof("Running did:nuts migration: '%s'", name) + migrate(owned) + } + } + return nil +} + +func (r *Module) allMigrations() map[string]migration { + return map[string]migration{ // key will be printed as description of the migration + "remove controller": r.migrateRemoveControllerFromDIDNuts, // must come before migrateHistoryOwnedDIDNuts so controller removal is also migrated. + "document history": r.migrateHistoryOwnedDIDNuts, + } +} + +// migrateRemoveControllerFromDIDNuts removes the controller from all did:nuts identifiers under own control. +// This ignores any DIDs that are not did:nuts. +func (r *Module) migrateRemoveControllerFromDIDNuts(owned []did.DID) { + auditContext := audit.Context(context.Background(), "system", ModuleName, "migrate_remove_did_nuts_controller") // resolve the DID Document if the did starts with did:nuts for _, did := range owned { - if did.Method == didnuts.MethodName { - doc, _, err := r.Resolve(did, nil) - if err != nil { - if !(errors.Is(err, resolver.ErrDeactivated) || errors.Is(err, resolver.ErrNoActiveController)) { - log.Logger().WithError(err).WithField(core.LogFieldDID, did.String()).Error("Could not update owned DID document, continuing with next document") - } - continue + if did.Method != didnuts.MethodName { // skip non did:nuts + continue + } + doc, _, err := r.Resolve(did, nil) + if err != nil { + if !(errors.Is(err, resolver.ErrDeactivated) || errors.Is(err, resolver.ErrNoActiveController)) { + log.Logger().WithError(err).WithField(core.LogFieldDID, did.String()).Error("Could not update owned DID document, continuing with next document") } - if len(doc.Controller) > 0 { - doc.Controller = nil - - if len(doc.VerificationMethod) == 0 { - log.Logger().WithField(core.LogFieldDID, doc.ID.String()).Warnf("No verification method found in owned DID document") - continue - } - - if len(doc.CapabilityInvocation) == 0 { - // add all keys as capabilityInvocation keys - for _, vm := range doc.VerificationMethod { - doc.CapabilityInvocation.Add(vm) - } - } - - err = r.nutsDocumentManager.Update(auditContext, did, *doc) - if err != nil { - if !(errors.Is(err, resolver.ErrKeyNotFound)) { - log.Logger().WithError(err).WithField(core.LogFieldDID, did.String()).Error("Could not update owned DID document, continuing with next document") - } - } + continue + } + if len(doc.Controller) == 0 { // has no controller + continue + } + + // try to remove controller + doc.Controller = nil + + if len(doc.VerificationMethod) == 0 { + log.Logger().WithField(core.LogFieldDID, doc.ID.String()).Warnf("No verification method found in owned DID document") + continue + } + + if len(doc.CapabilityInvocation) == 0 { + // add all keys as capabilityInvocation keys + for _, vm := range doc.VerificationMethod { + doc.CapabilityInvocation.Add(vm) + } + } + + err = r.nutsDocumentManager.Update(auditContext, did, *doc) + if err != nil { + if !(errors.Is(err, resolver.ErrKeyNotFound)) { + log.Logger().WithError(err).WithField(core.LogFieldDID, did.String()).Error("Could not update owned DID document, continuing with next document") } } } - return nil } + +// migrateHistoryOwnedDIDNuts migrates did:nuts DIDs from the VDR key-value storage to SQL storage +// This ignores any DIDs that are not did:nuts. +func (r *Module) migrateHistoryOwnedDIDNuts(owned []did.DID) { + for _, id := range owned { + if id.Method != didnuts.MethodName { // skip non did:nuts + continue + } + err := r.Manager.(didsubject.DocumentMigration).MigrateDIDHistoryToSQL(id, id.String(), r.store.HistorySinceVersion) + if err != nil { + log.Logger().WithError(err).Errorf("Failed to migrate DID document history to SQL for %s", id) + } + } +} + +// migration is the signature each migration function in Module.migrations uses +// there is no error return, if something is fatal the function should panic +type migration func(owned []did.DID) diff --git a/vdr/vdr_test.go b/vdr/vdr_test.go index d37c759d0f..6d4058cb5e 100644 --- a/vdr/vdr_test.go +++ b/vdr/vdr_test.go @@ -86,6 +86,7 @@ func newVDRTestCtx(t *testing.T) vdrTestCtx { DB: db, MethodManagers: make(map[string]didsubject.MethodManager), } + vdr.config = DefaultConfig() resolverRouter.Register(didnuts.MethodName, &didnuts.Resolver{Store: mockStore}) return vdrTestCtx{ ctrl: ctrl, @@ -347,103 +348,130 @@ func TestVDR_Migrate(t *testing.T) { require.NoError(t, err) assert.Contains(t, msg, expected) } - - t.Run("ignores self-controlled documents", func(t *testing.T) { - t.Cleanup(func() { hook.Reset() }) + t.Run("ignores non did:nuts", func(t *testing.T) { ctx := newVDRTestCtx(t) - ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) - ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(&did.Document{ID: TestDIDA}, nil, nil) - + testDIDWeb := did.MustParseDID("did:web:example.com") + ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{testDIDWeb}, nil) err := ctx.vdr.Migrate() - - require.NoError(t, err) - // empty logs means all ok. - assert.Nil(t, hook.LastEntry()) + assert.NoError(t, err) + assert.Len(t, ctx.vdr.migrations, 2) // confirm its running allMigrations() that currently is only did:nuts }) - t.Run("makes documents self-controlled", func(t *testing.T) { - t.Cleanup(func() { hook.Reset() }) - ctx := newVDRTestCtx(t) - keyStore := nutsCrypto.NewMemoryCryptoInstance(t) - keyRef, publicKey, err := keyStore.New(ctx.ctx, didnuts.DIDKIDNamingFunc) - require.NoError(t, err) - methodID := did.MustParseDIDURL(keyRef.KID) - methodID.ID = TestDIDA.ID - vm, _ := did.NewVerificationMethod(methodID, ssi.JsonWebKey2020, TestDIDA, publicKey) - documentA := did.Document{Context: []interface{}{did.DIDContextV1URI()}, ID: TestDIDA, Controller: []did.DID{TestDIDB}} - documentA.AddAssertionMethod(vm) - ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) - ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(&documentA, &resolver.DocumentMetadata{}, nil).AnyTimes() - ctx.mockStore.EXPECT().Resolve(TestDIDB, gomock.Any()).Return(&documentB, &resolver.DocumentMetadata{}, nil).AnyTimes() - ctx.mockDocumentManager.EXPECT().Update(gomock.Any(), TestDIDA, gomock.Any()).Return(nil) - - err = ctx.vdr.Migrate() + t.Run("controller migration", func(t *testing.T) { + controllerMigrationSetup := func(t *testing.T) vdrTestCtx { + t.Cleanup(func() { hook.Reset() }) + ctx := newVDRTestCtx(t) + ctx.vdr.migrations = map[string]migration{"remove controller": ctx.vdr.migrateRemoveControllerFromDIDNuts} + return ctx + } + t.Run("ignores self-controlled documents", func(t *testing.T) { + ctx := controllerMigrationSetup(t) + ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) + ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(&did.Document{ID: TestDIDA}, nil, nil) - require.NoError(t, err) - // empty logs means all ok. - assert.Nil(t, hook.LastEntry()) - }) - t.Run("deactivated is ignored", func(t *testing.T) { - t.Cleanup(func() { hook.Reset() }) - ctx := newVDRTestCtx(t) - ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) - ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(nil, nil, resolver.ErrDeactivated) + err := ctx.vdr.Migrate() - err := ctx.vdr.Migrate() + require.NoError(t, err) + // empty logs means all ok. + assert.Nil(t, hook.LastEntry()) + }) + t.Run("makes documents self-controlled", func(t *testing.T) { + ctx := controllerMigrationSetup(t) + keyStore := nutsCrypto.NewMemoryCryptoInstance(t) + keyRef, publicKey, err := keyStore.New(ctx.ctx, didnuts.DIDKIDNamingFunc) + require.NoError(t, err) + methodID := did.MustParseDIDURL(keyRef.KID) + methodID.ID = TestDIDA.ID + vm, _ := did.NewVerificationMethod(methodID, ssi.JsonWebKey2020, TestDIDA, publicKey) + documentA := did.Document{Context: []interface{}{did.DIDContextV1URI()}, ID: TestDIDA, Controller: []did.DID{TestDIDB}} + documentA.AddAssertionMethod(vm) + ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) + ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(&documentA, &resolver.DocumentMetadata{}, nil).AnyTimes() + ctx.mockStore.EXPECT().Resolve(TestDIDB, gomock.Any()).Return(&documentB, &resolver.DocumentMetadata{}, nil).AnyTimes() + ctx.mockDocumentManager.EXPECT().Update(gomock.Any(), TestDIDA, gomock.Any()).Return(nil) + + err = ctx.vdr.Migrate() - require.NoError(t, err) - // empty logs means all ok. - assert.Nil(t, hook.LastEntry()) - }) - t.Run("no active controller is ignored", func(t *testing.T) { - t.Cleanup(func() { hook.Reset() }) - ctx := newVDRTestCtx(t) - ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) - ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(&documentA, nil, nil) - ctx.mockStore.EXPECT().Resolve(TestDIDB, gomock.Any()).Return(&did.Document{ID: TestDIDB}, nil, nil) + require.NoError(t, err) + // empty logs means all ok. + assert.Nil(t, hook.LastEntry()) + }) + t.Run("deactivated is ignored", func(t *testing.T) { + ctx := controllerMigrationSetup(t) + ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) + ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(nil, nil, resolver.ErrDeactivated) - err := ctx.vdr.Migrate() + err := ctx.vdr.Migrate() - require.NoError(t, err) - // empty logs means all ok. - assert.Nil(t, hook.LastEntry()) - }) - t.Run("error is logged", func(t *testing.T) { - t.Cleanup(func() { hook.Reset() }) - ctx := newVDRTestCtx(t) - ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) - ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(nil, nil, assert.AnError) + require.NoError(t, err) + // empty logs means all ok. + assert.Nil(t, hook.LastEntry()) + }) + t.Run("no active controller is ignored", func(t *testing.T) { + ctx := controllerMigrationSetup(t) + ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) + ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(&documentA, nil, nil) + ctx.mockStore.EXPECT().Resolve(TestDIDB, gomock.Any()).Return(&did.Document{ID: TestDIDB}, nil, nil) - err := ctx.vdr.Migrate() + err := ctx.vdr.Migrate() - require.NoError(t, err) - assertLog(t, "Could not update owned DID document, continuing with next document") - assertLog(t, "assert.AnError general error for testing") - }) - t.Run("no verification method is logged", func(t *testing.T) { - t.Cleanup(func() { hook.Reset() }) - ctx := newVDRTestCtx(t) - ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) - ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(&did.Document{Controller: []did.DID{TestDIDB}}, nil, nil) - ctx.mockStore.EXPECT().Resolve(TestDIDB, gomock.Any()).Return(&documentB, &resolver.DocumentMetadata{}, nil) + require.NoError(t, err) + // empty logs means all ok. + assert.Nil(t, hook.LastEntry()) + }) + t.Run("error is logged", func(t *testing.T) { + ctx := controllerMigrationSetup(t) + ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) + ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(nil, nil, assert.AnError) - err := ctx.vdr.Migrate() + err := ctx.vdr.Migrate() - require.NoError(t, err) - assertLog(t, "No verification method found in owned DID document") + require.NoError(t, err) + assertLog(t, "Could not update owned DID document, continuing with next document") + assertLog(t, "assert.AnError general error for testing") + }) + t.Run("no verification method is logged", func(t *testing.T) { + ctx := controllerMigrationSetup(t) + ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) + ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(&did.Document{Controller: []did.DID{TestDIDB}}, nil, nil) + ctx.mockStore.EXPECT().Resolve(TestDIDB, gomock.Any()).Return(&documentB, &resolver.DocumentMetadata{}, nil) + + err := ctx.vdr.Migrate() + + require.NoError(t, err) + assertLog(t, "No verification method found in owned DID document") + }) + t.Run("update error is logged", func(t *testing.T) { + ctx := controllerMigrationSetup(t) + ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) + ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(&documentA, &resolver.DocumentMetadata{}, nil).AnyTimes() + ctx.mockStore.EXPECT().Resolve(TestDIDB, gomock.Any()).Return(&documentB, &resolver.DocumentMetadata{}, nil).AnyTimes() + ctx.mockDocumentManager.EXPECT().Update(gomock.Any(), TestDIDA, gomock.Any()).Return(assert.AnError) + + err := ctx.vdr.Migrate() + + require.NoError(t, err) + assertLog(t, "Could not update owned DID document, continuing with next document") + assertLog(t, "assert.AnError general error for testing") + }) }) - t.Run("update error is logged", func(t *testing.T) { - t.Cleanup(func() { hook.Reset() }) - ctx := newVDRTestCtx(t) - ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) - ctx.mockStore.EXPECT().Resolve(TestDIDA, gomock.Any()).Return(&documentA, &resolver.DocumentMetadata{}, nil).AnyTimes() - ctx.mockStore.EXPECT().Resolve(TestDIDB, gomock.Any()).Return(&documentB, &resolver.DocumentMetadata{}, nil).AnyTimes() - ctx.mockDocumentManager.EXPECT().Update(gomock.Any(), TestDIDA, gomock.Any()).Return(assert.AnError) - err := ctx.vdr.Migrate() + t.Run("history migration", func(t *testing.T) { + historyMigrationSetup := func(t *testing.T) vdrTestCtx { + t.Cleanup(func() { hook.Reset() }) + ctx := newVDRTestCtx(t) + ctx.vdr.migrations = map[string]migration{"history migration": ctx.vdr.migrateHistoryOwnedDIDNuts} + return ctx + } + t.Run("logs error", func(t *testing.T) { + ctx := historyMigrationSetup(t) + ctx.mockDocumentOwner.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{TestDIDA}, nil) + ctx.mockStore.EXPECT().HistorySinceVersion(TestDIDA, 0).Return(nil, assert.AnError).AnyTimes() - require.NoError(t, err) - assertLog(t, "Could not update owned DID document, continuing with next document") - assertLog(t, "assert.AnError general error for testing") + err := ctx.vdr.Migrate() + + assert.NoError(t, err) + assertLog(t, "assert.AnError general error for testing") + }) }) }