From 1900f70062081709305d63747dad3fe53358037c Mon Sep 17 00:00:00 2001 From: Reza Maghoul Date: Fri, 29 May 2026 11:00:28 +0200 Subject: [PATCH 1/5] ci(release): extract notes from CHANGELOG, fix wizard jar pattern - Replace `generate_release_notes: true` with an awk-based extraction of the matching `## [VERSION]` section from CHANGELOG.md. Embedding the section content sidesteps GitHub's date-sensitive anchor generation (e.g. `030---2026-05-29`) so release notes stay correct regardless of when the tag is cut. Fails the job if no matching section is found, surfacing missing CHANGELOG entries early. - Append an Artifacts block that points library users at Maven Central with a ready-to-paste coordinate, and notes that the wizard uber-jar is the only attached download (it is excluded from Central deploys). - Fix the wizard jar glob: the shade plugin writes `didwebvh-wizard/target/didwebvh-wizard.jar` (no version, no `-shaded` classifier), so the previous `*-shaded.jar` pattern matched nothing and broke release uploads. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/release.yml | 48 ++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6eae30b..31ad691 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,10 +68,52 @@ jobs: ./mvnw clean deploy -P release -B --no-transfer-progress -DskipTests + - name: Extract release notes from CHANGELOG + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + # Pull the section between "## [VERSION]" and the next "## [" heading. + awk -v ver="$version" ' + $0 ~ "^## \\["ver"\\]" { flag=1; print; next } + flag && /^## \[/ { exit } + flag { print } + ' CHANGELOG.md > .release-notes.md + + if [ ! -s .release-notes.md ]; then + echo "::error::No CHANGELOG section found for [$version]. Add a '## [$version] - YYYY-MM-DD' entry before tagging." + exit 1 + fi + + { + echo + echo "---" + echo + echo "### Artifacts" + echo + echo "**Maven Central** (recommended for library users):" + echo + echo '```xml' + echo "" + echo " io.github.decentralized-identity" + echo " didwebvh-core" + echo " ${version}" + echo "" + echo '```' + echo + echo "Browse all modules: " + echo + echo "**CLI wizard uber-jar** is attached to this release below (not published to Maven Central)." + echo + echo "Full changelog: " + } >> .release-notes.md + + echo "----- release body -----" + cat .release-notes.md + echo "------------------------" + - name: Create GitHub Release uses: softprops/action-gh-release@v3 with: - generate_release_notes: true + body_path: .release-notes.md files: | - didwebvh-core/target/*.jar - didwebvh-wizard/target/*-shaded.jar + didwebvh-wizard/target/didwebvh-wizard.jar From 14f2fcbe369fd7ea1edea2021696171a836dc58a Mon Sep 17 00:00:00 2001 From: Reza Maghoul Date: Fri, 29 May 2026 11:11:05 +0200 Subject: [PATCH 2/5] docs(changelog): bump v0.3.0 date and note release-workflow improvement Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97f20fc..9af2c05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.3.0] - 2026-05-28 +## [0.3.0] - 2026-05-29 This release closes the failures reported by the [did:webvh interop test suite](https://github.com/swcurran/didwebvh-test-suite) @@ -86,6 +86,11 @@ against the v0.2.0 line (see issue #2). Node.js 24, and Node.js 24 is forced in both the CI and release workflows to silence the deprecation warning emitted by older actions on the GHA runners. +- **Release notes**: the GitHub Release body is now generated + directly from the matching `## [VERSION]` section of this + CHANGELOG (with an appended Maven Central coordinate), and the + release attaches only the self-contained `didwebvh-wizard.jar` + uber-jar — library modules are consumed from Maven Central. ## [0.2.0] - 2026-05-06 From 8fb1773d349721a853f432dc56aa827478521dfd Mon Sep 17 00:00:00 2001 From: Reza Maghoul Date: Fri, 29 May 2026 11:18:50 +0200 Subject: [PATCH 3/5] ci(release): align release-body coordinate with README aggregator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README directs users at the aggregate `didwebvh-java` artifact (pom.xml:8), which transitively brings in `didwebvh-core` and `didwebvh-signing-local`. Release notes were recommending `didwebvh-core` alone, which is the right call only when supplying a custom `Signer`. Lead with the aggregate, mention `didwebvh-core` as the BYO-Signer alternative — matching README.md:35-47 exactly. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/release.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 31ad691..add2661 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -95,6 +95,17 @@ jobs: echo '```xml' echo "" echo " io.github.decentralized-identity" + echo " didwebvh-java" + echo " ${version}" + echo "" + echo '```' + echo + echo "The aggregate \`didwebvh-java\` artifact transitively pulls in \`didwebvh-core\` and \`didwebvh-signing-local\`. Depend on \`didwebvh-core\` directly if you plan to supply your own \`Signer\` implementation." + echo + echo '```xml' + echo "" + echo "" + echo " io.github.decentralized-identity" echo " didwebvh-core" echo " ${version}" echo "" From 565e8b7b276702524bad4bf04e55d889c0275ca1 Mon Sep 17 00:00:00 2001 From: Reza Maghoul Date: Fri, 29 May 2026 14:29:35 +0200 Subject: [PATCH 4/5] test(core): cover meaningful resolver and validator error paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift didwebvh-core coverage from ~84% to ~91% by exercising real behaviour: - LogChainValidator: malformed versionId, malformed versionTime (with a properly-rehashed entry so the parse step is actually reached), last-entry versionTime > now+60s rejection, and witness threshold = 0 rejection. - DidResolver: invalid versionNumber query → invalidDid, versionTime query selector honoured, PROACTIVE witness fetch (success / swallowed notFound / non-notFound rethrow), WHEN_REQUIRED with missing witness → "Witness proofs are required" mapped to invalidDid, and the resolveFromLog(rawJsonl, did) overload. - FileDidFetcher: null-path → invalidDid and missing file → notFound error-code mapping. - Jcs: malformed JSON wraps IOException as ValidationException. --- CHANGELOG.md | 5 + .../didwebvh/core/crypto/JcsTest.java | 9 ++ .../core/resolve/DidResolverTest.java | 139 ++++++++++++++++++ .../core/resolve/FileDidFetcherTest.java | 64 ++++++++ .../core/validate/LogChainValidatorTest.java | 77 ++++++++++ 5 files changed, 294 insertions(+) create mode 100644 didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/resolve/FileDidFetcherTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 9af2c05..2de40ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,11 @@ against the v0.2.0 line (see issue #2). CHANGELOG (with an appended Maven Central coordinate), and the release attaches only the self-contained `didwebvh-wizard.jar` uber-jar — library modules are consumed from Maven Central. +- **Test coverage**: lifted `didwebvh-core` from ~84% to ~91% by + covering real resolver/validator error paths (malformed + versionId/versionTime, future versionTime rejection, witness + threshold bounds, query-param parsing, PROACTIVE / WHEN_REQUIRED + witness fetch branches, and file/HTTP fetcher error mapping). ## [0.2.0] - 2026-05-06 diff --git a/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/crypto/JcsTest.java b/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/crypto/JcsTest.java index 38cf1cd..a753833 100644 --- a/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/crypto/JcsTest.java +++ b/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/crypto/JcsTest.java @@ -1,11 +1,13 @@ package io.github.decentralizedidentity.didwebvh.core.crypto; import com.google.gson.JsonObject; +import io.github.decentralizedidentity.didwebvh.core.ValidationException; import org.junit.jupiter.api.Test; import java.nio.charset.StandardCharsets; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class JcsTest { @@ -52,4 +54,11 @@ void canonicalizeUnicode() { String result = new String(Jcs.canonicalize(input), StandardCharsets.UTF_8); assertThat(result).contains("\u20ac"); } + + @Test + void canonicalizeInvalidJsonThrowsValidationException() { + assertThatThrownBy(() -> Jcs.canonicalize("{not-json")) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("JCS canonicalization failed"); + } } diff --git a/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/resolve/DidResolverTest.java b/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/resolve/DidResolverTest.java index 388f8f2..7b68cbc 100644 --- a/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/resolve/DidResolverTest.java +++ b/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/resolve/DidResolverTest.java @@ -128,6 +128,145 @@ void resolverCanBeConfiguredWithHttpTimeoutAndResponseLimit() { assertThat(resolver).isNotNull(); } + @Test + void invalidVersionNumberQueryThrows() { + Signer signer = makeTestSigner(); + CreateDidResult create = DidWebVh.create("example.com", signer).execute(); + DidResolver resolver = new DidResolver(new StubRemoteDidFetcher(create.getLogLine()), + new FileDidFetcher(), new LogProcessor()); + + assertThatThrownBy(() -> resolver.resolve(create.getDid() + "?versionNumber=not-a-number")) + .isInstanceOf(ResolutionException.class) + .hasMessageContaining("Invalid versionNumber") + .extracting("error").isEqualTo("invalidDid"); + } + + @Test + void versionTimeQueryParamIsHonoured() { + Signer signer = makeTestSigner(); + CreateDidResult create = DidWebVh.create("example.com", signer).execute(); + LogEntry first = create.getLogEntry(); + JsonObject secondState = first.getState().deepCopy(); + secondState.addProperty("updated", "true"); + LogEntry second = buildUpdateEntry(first, signer, null, secondState); + String log = first.toJsonLine() + "\n" + second.toJsonLine(); + + DidResolver resolver = new DidResolver(new StubRemoteDidFetcher(log), + new FileDidFetcher(), new LogProcessor()); + + // versionTime equal to the first entry's time selects the first entry. + ResolveResult result = resolver.resolve( + create.getDid() + "?versionTime=" + first.getVersionTime()); + + assertThat(result.getMetadata().getVersionId()).isEqualTo(first.getVersionId()); + } + + @Test + void proactiveWitnessFetchPullsWitnessFile() { + Signer author = makeTestSigner(); + Signer witness = makeTestSigner(); + String witnessDid = "did:key:" + extractMultikey(witness.verificationMethod()); + WitnessConfig witnessConfig = new WitnessConfig(1, + Collections.singletonList(new WitnessEntry(witnessDid))); + CreateDidResult create = DidWebVh.create("example.com", author) + .witness(witnessConfig) + .execute(); + WitnessProofCollection proofs = witnessProofs( + create.getLogEntry().getVersionId(), witness); + StubRemoteDidFetcher fetcher = new StubRemoteDidFetcher(create.getLogLine(), + JsonSupport.compact().toJson(proofs.getEntries())); + + DidResolver resolver = new DidResolver(fetcher, new FileDidFetcher(), + new LogProcessor()); + + ResolveResult result = resolver.resolve(create.getDid(), ResolveOptions.builder() + .witnessFetchMode(ResolveOptions.WitnessFetchMode.PROACTIVE) + .build()); + + assertThat(result.getMetadata().getWitness()).isEqualTo(witnessConfig); + assertThat(fetcher.witnessFetchCount).isEqualTo(1); + } + + @Test + void proactiveWitnessSwallowsNotFoundForUnwitnessedLog() { + Signer signer = makeTestSigner(); + CreateDidResult create = DidWebVh.create("example.com", signer).execute(); + StubRemoteDidFetcher fetcher = new StubRemoteDidFetcher(create.getLogLine()); + + DidResolver resolver = new DidResolver(fetcher, new FileDidFetcher(), + new LogProcessor()); + + ResolveResult result = resolver.resolve(create.getDid(), ResolveOptions.builder() + .witnessFetchMode(ResolveOptions.WitnessFetchMode.PROACTIVE) + .build()); + + // PROACTIVE attempted a witness fetch, but a 404 (notFound) must be + // swallowed and the resolve must succeed when the log has no witnesses. + assertThat(result.getDidDocument().getId()).isEqualTo(create.getDid()); + assertThat(fetcher.witnessFetchCount).isEqualTo(1); + } + + @Test + void proactiveWitnessRethrowsNonNotFoundErrors() { + Signer signer = makeTestSigner(); + CreateDidResult create = DidWebVh.create("example.com", signer).execute(); + RemoteDidFetcher fetcher = new RemoteDidFetcher() { + @Override public String fetchDidLog(String httpsUrl) { + return create.getLogLine(); + } + @Override public String fetchWitnessProofs(String witnessUrl) { + throw new ResolutionException("upstream down", "httpError"); + } + }; + DidResolver resolver = new DidResolver(fetcher, new FileDidFetcher(), + new LogProcessor()); + + assertThatThrownBy(() -> resolver.resolve(create.getDid(), + ResolveOptions.builder() + .witnessFetchMode(ResolveOptions.WitnessFetchMode.PROACTIVE) + .build())) + .isInstanceOf(ResolutionException.class) + .extracting("error").isEqualTo("httpError"); + } + + @Test + void whenRequiredButWitnessMissingMapsToInvalidDid() { + Signer author = makeTestSigner(); + Signer witness = makeTestSigner(); + String witnessDid = "did:key:" + extractMultikey(witness.verificationMethod()); + WitnessConfig witnessConfig = new WitnessConfig(1, + Collections.singletonList(new WitnessEntry(witnessDid))); + CreateDidResult create = DidWebVh.create("example.com", author) + .witness(witnessConfig) + .execute(); + // Fetcher signals notFound for witness proofs (the chain *needs* them). + StubRemoteDidFetcher fetcher = new StubRemoteDidFetcher(create.getLogLine()); + + DidResolver resolver = new DidResolver(fetcher, new FileDidFetcher(), + new LogProcessor()); + + assertThatThrownBy(() -> resolver.resolve(create.getDid(), + ResolveOptions.builder() + .witnessFetchMode(ResolveOptions.WitnessFetchMode.WHEN_REQUIRED) + .build())) + .isInstanceOf(ResolutionException.class) + .hasMessageContaining("Witness proofs are required") + .extracting("error").isEqualTo("invalidDid"); + } + + @Test + void resolveFromLogValidatesAgainstDid() { + Signer signer = makeTestSigner(); + CreateDidResult create = DidWebVh.create("example.com", signer).execute(); + + ResolveResult result = new DidResolver().resolveFromLog( + create.getLogLine(), create.getDid()); + + assertThat(result.getDidDocument().getId()).isEqualTo(create.getDid()); + assertThat(result.getMetadata().getVersionId()) + .isEqualTo(create.getLogEntry().getVersionId()); + } + private static final class StubRemoteDidFetcher implements RemoteDidFetcher { private final String didLog; private final String witnessContent; diff --git a/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/resolve/FileDidFetcherTest.java b/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/resolve/FileDidFetcherTest.java new file mode 100644 index 0000000..733b888 --- /dev/null +++ b/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/resolve/FileDidFetcherTest.java @@ -0,0 +1,64 @@ +package io.github.decentralizedidentity.didwebvh.core.resolve; + +import io.github.decentralizedidentity.didwebvh.core.ResolutionException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FileDidFetcherTest { + + @Test + void fetchDidLogReadsFile(@TempDir Path tmp) throws Exception { + Path log = tmp.resolve("did.jsonl"); + Files.write(log, "hello".getBytes(StandardCharsets.UTF_8)); + FileDidFetcher fetcher = new FileDidFetcher(); + assertThat(fetcher.fetchDidLog(log)).isEqualTo("hello"); + } + + @Test + void fetchWitnessProofsReadsFile(@TempDir Path tmp) throws Exception { + Path proofs = tmp.resolve("witness.json"); + Files.write(proofs, "{}".getBytes(StandardCharsets.UTF_8)); + assertThat(new FileDidFetcher().fetchWitnessProofs(proofs)).isEqualTo("{}"); + } + + @Test + void fetchDidLogRejectsNullPath() { + assertThatThrownBy(() -> new FileDidFetcher().fetchDidLog(null)) + .isInstanceOf(ResolutionException.class) + .hasMessageContaining("did log path is required") + .extracting("error").isEqualTo("invalidDid"); + } + + @Test + void fetchWitnessProofsRejectsNullPath() { + assertThatThrownBy(() -> new FileDidFetcher().fetchWitnessProofs(null)) + .isInstanceOf(ResolutionException.class) + .hasMessageContaining("witness proofs path is required") + .extracting("error").isEqualTo("invalidDid"); + } + + @Test + void fetchDidLogMapsMissingFileToNotFound(@TempDir Path tmp) { + Path missing = tmp.resolve("nope.jsonl"); + assertThatThrownBy(() -> new FileDidFetcher().fetchDidLog(missing)) + .isInstanceOf(ResolutionException.class) + .hasMessageContaining("Unable to read did log") + .extracting("error").isEqualTo("notFound"); + } + + @Test + void fetchWitnessProofsMapsMissingFileToNotFound(@TempDir Path tmp) { + Path missing = tmp.resolve("nope.json"); + assertThatThrownBy(() -> new FileDidFetcher().fetchWitnessProofs(missing)) + .isInstanceOf(ResolutionException.class) + .hasMessageContaining("Unable to read witness proofs") + .extracting("error").isEqualTo("notFound"); + } +} diff --git a/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/validate/LogChainValidatorTest.java b/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/validate/LogChainValidatorTest.java index a5cdb52..72c13e3 100644 --- a/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/validate/LogChainValidatorTest.java +++ b/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/validate/LogChainValidatorTest.java @@ -558,6 +558,83 @@ void portableScidSwapFails() { assertThat(vr.getFailureReason()).containsIgnoringCase("SCID"); } + @Test + void invalidVersionIdFormatFails() { + CreateDidResult create = DidWebVh.create("example.com", signer).execute(); + LogEntry malformed = new LogEntry() + .setVersionId("not-a-version-id") + .setVersionTime(create.getLogEntry().getVersionTime()) + .setParameters(create.getLogEntry().getParameters()) + .setState(create.getLogEntry().getState()) + .setProof(create.getLogEntry().getProof()); + + ValidationResult vr = validator.validate( + Collections.singletonList(malformed), create.getDid()); + + assertThat(vr.isValid()).isFalse(); + assertThat(vr.getFailureReason()).containsIgnoringCase("invalid versionId"); + } + + @Test + void invalidVersionTimeFormatFails() { + // Build a real 2-entry chain whose second entry's versionTime is malformed. + // The entry hash is computed over the malformed time so validation gets + // past the hash/proof checks and reaches the versionTime parse step. + CreateDidResult create = DidWebVh.create("example.com", signer).execute(); + LogEntry e1 = create.getLogEntry(); + LogEntry e2 = buildEntryWithVersionTime(e1, "not-a-timestamp", 2); + + ValidationResult vr = validator.validate(Arrays.asList(e1, e2), create.getDid()); + + assertThat(vr.isValid()).isFalse(); + assertThat(vr.getFailureReason()).containsIgnoringCase("invalid versionTime"); + } + + @Test + void lastVersionTimeInFutureFails() { + // Last entry versionTime > now + 60 s must be rejected. + CreateDidResult create = DidWebVh.create("example.com", signer).execute(); + LogEntry e1 = create.getLogEntry(); + String farFuture = Instant.now().plusSeconds(7200).toString(); + LogEntry e2 = buildEntryWithVersionTime(e1, farFuture, 2); + + ValidationResult vr = validator.validate(Arrays.asList(e1, e2), create.getDid()); + + assertThat(vr.isValid()).isFalse(); + assertThat(vr.getFailureReason()).containsIgnoringCase("future"); + } + + /** Build a real next entry whose versionTime is the given string. */ + private static LogEntry buildEntryWithVersionTime(LogEntry previous, String versionTime, + int newVersion) { + LogEntry preliminary = new LogEntry() + .setVersionId(previous.getVersionId()) + .setVersionTime(versionTime) + .setParameters(new Parameters()) + .setState(previous.getState().deepCopy()); + String entryHash = EntryHashGenerator.generate(preliminary.toJsonLine(), + previous.getVersionId()); + preliminary.setVersionId(newVersion + "-" + entryHash); + DataIntegrityProof proof = ProofGenerator.generate(signer, preliminary); + return preliminary.setProof(Collections.singletonList(proof)); + } + + @Test + void witnessThresholdZeroFails() { + WitnessConfig zeroThreshold = new WitnessConfig(0, + Collections.singletonList(new WitnessEntry("did:key:z6MkWit"))); + + CreateDidResult create = DidWebVh.create("example.com", signer) + .witness(zeroThreshold) + .execute(); + + ValidationResult vr = validator.validate( + Collections.singletonList(create.getLogEntry()), create.getDid()); + + assertThat(vr.isValid()).isFalse(); + assertThat(vr.getFailureReason()).containsIgnoringCase("threshold"); + } + @Test void noProofFails() { CreateDidResult create = DidWebVh.create("example.com", signer).execute(); From cc3f2dceea0b91138c7844e1ab6c2752715eaf44 Mon Sep 17 00:00:00 2001 From: Reza Maghoul Date: Fri, 29 May 2026 14:58:57 +0200 Subject: [PATCH 5/5] test(core): cover real branches in validator, resolver, migrate, create, URL Raise didwebvh-core Codecov coverage from ~77% to ~82% (line coverage 93%) by adding branch-targeted tests on meaningful spec behaviour. Locally measured codecov-equivalent ratio (hits / (hits + misses + partial branches)). - LogChainValidator: first-entry missing scid / missing updateKeys, method-version downgrade rejection (exercises compareMethod / extractMethodVersion). - LogProcessor: empty/blank log content, malformed JSONL entry, unknown versionId/versionNumber selectors, malformed versionTime query, versionTime before first entry, malformed witness proofs. - MigrateDidOperation: null existingState/signer/newDomain, empty newDomain, deactivated-DID rejection, newPath path-segment appending, alsoKnownAs duplicate skip. - CreateDidOperation: controllers list omitted/single/array forms; extractMultikey for bare did:key VM and rejection of non-did:key verification methods. - DidWebVhUrl: empty domain, port out of range, non-numeric port, bracketed IPv6 host, '=' less query param, multi-param toString. - CHANGELOG: rewrite the v0.3.0 coverage entry to reflect the Codecov-metric numbers and scope. --- CHANGELOG.md | 15 ++- .../core/create/CreateDidOperationTest.java | 60 ++++++++++++ .../core/resolve/LogProcessorTest.java | 95 +++++++++++++++++++ .../core/update/UpdateDidOperationTest.java | 88 +++++++++++++++++ .../didwebvh/core/url/DidWebVhUrlTest.java | 52 ++++++++++ .../core/validate/LogChainValidatorTest.java | 64 +++++++++++++ 6 files changed, 369 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2de40ed..9fa7c6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,11 +91,16 @@ against the v0.2.0 line (see issue #2). CHANGELOG (with an appended Maven Central coordinate), and the release attaches only the self-contained `didwebvh-wizard.jar` uber-jar — library modules are consumed from Maven Central. -- **Test coverage**: lifted `didwebvh-core` from ~84% to ~91% by - covering real resolver/validator error paths (malformed - versionId/versionTime, future versionTime rejection, witness - threshold bounds, query-param parsing, PROACTIVE / WHEN_REQUIRED - witness fetch branches, and file/HTTP fetcher error mapping). +- **Test coverage**: raised `didwebvh-core` from ~77% to ~82% on + the Codecov metric (line coverage 93%) by covering real branches + in `LogChainValidator`, `LogProcessor`, `DidResolver`, + `MigrateDidOperation`, `CreateDidOperation`, `DidWebVhUrl`, and + the file fetcher — malformed versionId/versionTime, future + versionTime rejection, witness threshold bounds, method-version + downgrade, query-param parsing, PROACTIVE / WHEN_REQUIRED witness + fetch branches, migrate guards (null/empty inputs, deactivated, + newPath, alsoKnownAs dedup), controller-list array vs string + forms, and URL port / IPv6 / empty-domain rejection. ## [0.2.0] - 2026-05-06 diff --git a/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/create/CreateDidOperationTest.java b/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/create/CreateDidOperationTest.java index e21f38c..b0c54d8 100644 --- a/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/create/CreateDidOperationTest.java +++ b/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/create/CreateDidOperationTest.java @@ -395,6 +395,66 @@ void nextKeyHashEmptyEntryThrows() { .hasMessageContaining("non-empty"); } + @Test + void controllersEmptyListOmitsControllerProperty() { + CreateDidResult result = DidWebVh.create("example.com", testSigner) + .controllers(Collections.emptyList()) + .execute(); + JsonObject doc = result.getLogEntry().getState(); + assertThat(doc.has("controller")).isFalse(); + } + + @Test + void controllersSingleElementUsesStringForm() { + CreateDidResult result = DidWebVh.create("example.com", testSigner) + .controllers(Collections.singletonList("did:web:other.example.com")) + .execute(); + JsonObject doc = result.getLogEntry().getState(); + assertThat(doc.get("controller").isJsonPrimitive()).isTrue(); + assertThat(doc.get("controller").getAsString()) + .isEqualTo("did:web:other.example.com"); + } + + @Test + void controllersMultipleElementsUsesArrayForm() { + CreateDidResult result = DidWebVh.create("example.com", testSigner) + .controllers(Arrays.asList( + "did:web:a.example.com", "did:web:b.example.com")) + .execute(); + JsonObject doc = result.getLogEntry().getState(); + assertThat(doc.get("controller").isJsonArray()).isTrue(); + assertThat(doc.getAsJsonArray("controller")).hasSize(2); + } + + @Test + void extractMultikeyAcceptsBareDidKeyVerificationMethod() { + // Signer whose verificationMethod is the did:key URI without a fragment. + Signer bareSigner = new Signer() { + @Override public String keyType() { return testSigner.keyType(); } + @Override public String verificationMethod() { + return "did:key:" + testMultikey; + } + @Override public byte[] sign(byte[] data) { return testSigner.sign(data); } + }; + + CreateDidResult result = DidWebVh.create("example.com", bareSigner).execute(); + assertThat(result.getLogEntry().getParameters().getUpdateKeys()) + .containsExactly(testMultikey); + } + + @Test + void extractMultikeyRejectsForeignVerificationMethod() { + Signer foreignSigner = new Signer() { + @Override public String keyType() { return testSigner.keyType(); } + @Override public String verificationMethod() { return "https://example.com/keys/1"; } + @Override public byte[] sign(byte[] data) { return testSigner.sign(data); } + }; + + assertThatThrownBy(() -> DidWebVh.create("example.com", foreignSigner).execute()) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("Cannot extract multikey"); + } + @Test void didDocumentHasVerificationMethod() { CreateDidResult result = DidWebVh.create("example.com", testSigner) diff --git a/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/resolve/LogProcessorTest.java b/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/resolve/LogProcessorTest.java index 5a4686f..433f3f8 100644 --- a/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/resolve/LogProcessorTest.java +++ b/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/resolve/LogProcessorTest.java @@ -270,6 +270,101 @@ void crossDidWitnessReplayRejected() { .isEqualTo("invalidDid")); } + @Test + void emptyLogContentMapsToInvalidDid() { + assertThatThrownBy(() -> processor.process("", null, null, ResolveOptions.defaults())) + .isInstanceOf(ResolutionException.class) + .hasMessageContaining("DID log must not be empty") + .extracting("error").isEqualTo("invalidDid"); + } + + @Test + void blankOnlyLogMapsToMustContainAtLeastOneEntry() { + assertThatThrownBy(() -> processor.process(" \n\n \n", null, null, + ResolveOptions.defaults())) + .isInstanceOf(ResolutionException.class) + .hasMessageContaining("DID log must not be empty"); + } + + @Test + void malformedLogLineMapsToInvalidDid() { + assertThatThrownBy(() -> processor.process("{not-json", null, null, + ResolveOptions.defaults())) + .isInstanceOf(ResolutionException.class) + .hasMessageContaining("Unable to parse DID log entry") + .extracting("error").isEqualTo("invalidDid"); + } + + @Test + void unknownVersionIdMapsToInvalidDid() { + Signer signer = makeTestSigner(); + CreateDidResult create = DidWebVh.create("example.com", signer).execute(); + + assertThatThrownBy(() -> processor.process(create.getLogLine(), null, + create.getDid(), + ResolveOptions.builder().versionId("999-Qmwrong").build())) + .isInstanceOf(ResolutionException.class) + .hasMessageContaining("No DID log entry found for versionId"); + } + + @Test + void unknownVersionNumberMapsToInvalidDid() { + Signer signer = makeTestSigner(); + CreateDidResult create = DidWebVh.create("example.com", signer).execute(); + + assertThatThrownBy(() -> processor.process(create.getLogLine(), null, + create.getDid(), + ResolveOptions.builder().versionNumber(42).build())) + .isInstanceOf(ResolutionException.class) + .hasMessageContaining("No DID log entry found for versionNumber"); + } + + @Test + void invalidVersionTimeQueryMapsToInvalidDid() { + Signer signer = makeTestSigner(); + CreateDidResult create = DidWebVh.create("example.com", signer).execute(); + + assertThatThrownBy(() -> processor.process(create.getLogLine(), null, + create.getDid(), + ResolveOptions.builder().versionTime("not-a-timestamp").build())) + .isInstanceOf(ResolutionException.class) + .hasMessageContaining("Invalid versionTime") + .extracting("error").isEqualTo("invalidDid"); + } + + @Test + void versionTimeBeforeFirstEntryMapsToInvalidDid() { + Signer signer = makeTestSigner(); + CreateDidResult create = DidWebVh.create("example.com", signer).execute(); + + String beforeAll = Instant.parse(create.getLogEntry().getVersionTime()) + .minusSeconds(3600).toString(); + + assertThatThrownBy(() -> processor.process(create.getLogLine(), null, + create.getDid(), + ResolveOptions.builder().versionTime(beforeAll).build())) + .isInstanceOf(ResolutionException.class) + .hasMessageContaining("No DID log entry found at or before versionTime"); + } + + @Test + void malformedWitnessProofsMapToInvalidDid() { + Signer author = makeTestSigner(); + Signer witness = makeTestSigner(); + String witnessDid = "did:key:" + extractMultikey(witness.verificationMethod()); + WitnessConfig witnessConfig = new WitnessConfig(1, + Collections.singletonList(new WitnessEntry(witnessDid))); + CreateDidResult create = DidWebVh.create("example.com", author) + .witness(witnessConfig) + .execute(); + + assertThatThrownBy(() -> processor.process(create.getLogLine(), + "{not-json", create.getDid(), ResolveOptions.defaults())) + .isInstanceOf(ResolutionException.class) + .hasMessageContaining("Unable to parse witness proofs") + .extracting("error").isEqualTo("invalidDid"); + } + @Test void problemDetailsUseDidwebvhProblemType() { ResolutionException exception = new ResolutionException( diff --git a/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/update/UpdateDidOperationTest.java b/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/update/UpdateDidOperationTest.java index b2ebdba..842d7e3 100644 --- a/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/update/UpdateDidOperationTest.java +++ b/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/update/UpdateDidOperationTest.java @@ -242,6 +242,94 @@ void migration_notPortable_throws() { .hasMessageContaining("portable"); } + @Test + void migration_nullExistingState_throws() { + assertThatThrownBy(() -> new MigrateDidConfig(null, signerA, "new.example.com") + .execute()) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("existingState is required"); + } + + @Test + void migration_nullSigner_throws() { + DidWebVhState state = createPortableState("old.example.com", signerA); + assertThatThrownBy(() -> new MigrateDidConfig(state, null, "new.example.com") + .execute()) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("signer is required"); + } + + @Test + void migration_nullNewDomain_throws() { + DidWebVhState state = createPortableState("old.example.com", signerA); + assertThatThrownBy(() -> new MigrateDidConfig(state, signerA, null).execute()) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("newDomain is required"); + } + + @Test + void migration_emptyNewDomain_throws() { + DidWebVhState state = createPortableState("old.example.com", signerA); + assertThatThrownBy(() -> new MigrateDidConfig(state, signerA, "").execute()) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("newDomain is required"); + } + + @Test + void migration_deactivatedDid_throws() { + DidWebVhState state = createPortableState("old.example.com", signerA); + UpdateDidResult deactivation = DidWebVh.deactivate(state, signerA).execute(); + state.appendEntry(deactivation.getLogEntry()); + + assertThatThrownBy(() -> DidWebVh.migrate(state, signerA, "new.example.com") + .execute()) + .isInstanceOf(ValidationException.class) + .hasMessageContaining("deactivated"); + } + + @Test + void migration_withNewPath_appendsPathSegment() { + DidWebVhState state = createPortableState("old.example.com", signerA); + + UpdateDidResult result = DidWebVh.migrate(state, signerA, "new.example.com") + .newPath("dids:issuer") + .execute(); + state.appendEntry(result.getLogEntry()); + + String newId = state.getLastEntry().getState().get("id").getAsString(); + assertThat(newId).contains(":new.example.com:dids:issuer"); + } + + @Test + void migration_existingAlsoKnownAsContainsOldDid_isNotDuplicated() { + // Seed the document with an alsoKnownAs that already references the + // old DID; the migrate op must not append a duplicate. + DidWebVhState state = createPortableState("old.example.com", signerA); + String oldDid = state.getDid(); + + JsonObject docWithAka = state.getLastEntry().getState().deepCopy(); + JsonArray aka = new JsonArray(); + aka.add(oldDid); + docWithAka.add("alsoKnownAs", aka); + UpdateDidResult prep = DidWebVh.update(state, signerA) + .newDocument(docWithAka) + .execute(); + state.appendEntry(prep.getLogEntry()); + + UpdateDidResult migrated = DidWebVh.migrate(state, signerA, "new.example.com").execute(); + state.appendEntry(migrated.getLogEntry()); + + JsonArray finalAka = state.getLastEntry().getState() + .getAsJsonArray("alsoKnownAs"); + long oldDidCount = 0; + for (com.google.gson.JsonElement el : finalAka) { + if (oldDid.equals(el.getAsString())) { + oldDidCount++; + } + } + assertThat(oldDidCount).as("old DID appears once in alsoKnownAs").isEqualTo(1); + } + // ------------------------------------------------------------------------- // Deactivation // ------------------------------------------------------------------------- diff --git a/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/url/DidWebVhUrlTest.java b/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/url/DidWebVhUrlTest.java index 2bbefc6..9a33c62 100644 --- a/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/url/DidWebVhUrlTest.java +++ b/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/url/DidWebVhUrlTest.java @@ -187,6 +187,58 @@ void rejectMissingDomain() { .hasMessageContaining("at least SCID and domain"); } + @Test + void rejectEmptyDomain() { + // SCID followed by an empty domain segment + assertThatThrownBy(() -> DidWebVhUrl.parse("did:webvh:" + SCID + "::path")) + .isInstanceOf(UrlParseException.class) + .hasMessageContaining("Domain"); + } + + @Test + void rejectPortOutOfRange() { + assertThatThrownBy(() -> DidWebVhUrl.parse( + "did:webvh:" + SCID + ":example.com%3A99999")) + .isInstanceOf(UrlParseException.class) + .hasMessageContaining("Port"); + } + + @Test + void rejectNonNumericPort() { + assertThatThrownBy(() -> DidWebVhUrl.parse( + "did:webvh:" + SCID + ":example.com%3Aabc")) + .isInstanceOf(UrlParseException.class) + .hasMessageContaining("port"); + } + + @Test + void rejectIpv6BracketedAddress() { + // Bracketed host triggers isIpAddress's IPv6 branch. + assertThatThrownBy(() -> DidWebVhUrl.parse( + "did:webvh:" + SCID + ":[host]")) + .isInstanceOf(UrlParseException.class) + .hasMessageContaining("IP address"); + } + + @Test + void queryParamWithoutEqualsStoredWithEmptyValue() { + DidWebVhUrl url = DidWebVhUrl.parse( + "did:webvh:" + SCID + ":example.com?flag&versionNumber=2"); + assertThat(url.getQueryParams()).containsEntry("flag", ""); + assertThat(url.getQueryParams()).containsEntry("versionNumber", "2"); + } + + @Test + void toStringRoundTripsMultipleQueryParams() { + String input = "did:webvh:" + SCID + + ":example.com?versionNumber=2&versionId=2-Qmabc"; + DidWebVhUrl url = DidWebVhUrl.parse(input); + // toString joins with '&' between params (exercises the separator branch). + assertThat(url.toString()).contains("&"); + assertThat(url.toString()).contains("versionNumber=2"); + assertThat(url.toString()).contains("versionId=2-Qmabc"); + } + @Test void casInsensitivePercentEncoding() { // %3a (lowercase) should also work diff --git a/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/validate/LogChainValidatorTest.java b/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/validate/LogChainValidatorTest.java index 72c13e3..767877a 100644 --- a/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/validate/LogChainValidatorTest.java +++ b/didwebvh-core/src/test/java/io/github/decentralizedidentity/didwebvh/core/validate/LogChainValidatorTest.java @@ -369,6 +369,70 @@ void missingMethodInFirstEntryFails() { assertThat(vr.getFailureReason()).contains("method"); } + @Test + void missingScidInFirstEntryFails() { + CreateDidResult create = DidWebVh.create("example.com", signer).execute(); + LogEntry original = create.getLogEntry(); + Parameters noScid = new Parameters() + .setMethod(original.getParameters().getMethod()) + .setUpdateKeys(original.getParameters().getUpdateKeys()); + + LogEntry bad = new LogEntry() + .setVersionId(original.getVersionId()) + .setVersionTime(original.getVersionTime()) + .setParameters(noScid) + .setState(original.getState()) + .setProof(original.getProof()); + + ValidationResult vr = validator.validate( + Collections.singletonList(bad), create.getDid()); + + assertThat(vr.isValid()).isFalse(); + assertThat(vr.getFailureReason()).containsIgnoringCase("scid"); + } + + @Test + void missingUpdateKeysInFirstEntryFails() { + CreateDidResult create = DidWebVh.create("example.com", signer).execute(); + LogEntry original = create.getLogEntry(); + Parameters noKeys = new Parameters() + .setMethod(original.getParameters().getMethod()) + .setScid(original.getParameters().getScid()) + .setUpdateKeys(Collections.emptyList()); + + LogEntry bad = new LogEntry() + .setVersionId(original.getVersionId()) + .setVersionTime(original.getVersionTime()) + .setParameters(noKeys) + .setState(original.getState()) + .setProof(original.getProof()); + + ValidationResult vr = validator.validate( + Collections.singletonList(bad), create.getDid()); + + assertThat(vr.isValid()).isFalse(); + assertThat(vr.getFailureReason()).containsIgnoringCase("updateKeys"); + } + + @Test + void methodVersionDecreaseFails() { + // Build a chain whose entry 2 declares a method with a lower version + // than the active one. compareMethod must reject the downgrade. + CreateDidResult create = DidWebVh.create("example.com", signer).execute(); + LogEntry e1 = create.getLogEntry(); + String activeMethod = e1.getParameters().getMethod(); + // active is like "did:webvh:1.0" — drop minor by one. + String lowerMethod = activeMethod.replaceFirst(":\\d+\\.\\d+$", ":0.9"); + + Parameters downgrade = new Parameters().setMethod(lowerMethod); + LogEntry e2 = buildUpdateEntry(e1, create.getDid(), signer, downgrade, null); + + ValidationResult vr = validator.validate(Arrays.asList(e1, e2), create.getDid()); + + assertThat(vr.isValid()).isFalse(); + assertThat(vr.getFailureReason()).containsIgnoringCase("method version must not decrease"); + } + @Test void scidInSecondEntryFails() { CreateDidResult create = DidWebVh.create("example.com", signer).execute();