diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6eae30b..add2661 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -68,10 +68,63 @@ 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-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 ""
+ 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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 97f20fc..9fa7c6f 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,21 @@ 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.
+- **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/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/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 a5cdb52..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();
@@ -558,6 +622,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();