Allow the rendering of changes imported from other GerritServerIds
Allowing Gerrit to read, index and render the change with NoteDb data
comint from another server with a different serverId.
Add a new test suite for validating the scenario where a Gerrit
setup has two changes coming from different serverIds.
LIMITATION: The imported changes need to have the same or a subset
of the account ids of the hosting Gerrit server, otherwise the review
identities would not match.
Release-Notes: Allow importing changes with different serverIds
Forward-Compatible: checked
Change-Id: I42bac21a746b3935ad6e30daf86442bf6d4cc5d6
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 234389e..365a26e 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -2543,6 +2543,21 @@
by the target version of the upgrade. Refer to the release notes and check whether
the rolling upgrade is possible or not and the associated constraints.
+[[gerrit.importedServerId]]gerrit.importedServerId::
++
+ServerId of the repositories imported from other Gerrit servers. Changes coming
+associated with the imported serverIds are indexed and displayed in the UI.
++
+Specify multiple `gerrit.importedServerId` for allowing the import from multiple
+Gerrit servers with different serverIds. The servers associated with the imported
+serverIds need to have the same or a subset of the account Ids of the hosting
+Gerrit server.
++
+If this value is not set, all changes imported from other Gerrit servers will be
+ignored.
++
+By default empty.
+
[[gerrit.serverId]]gerrit.serverId::
+
Used by NoteDb to, amongst other things, identify author identities from
diff --git a/java/com/google/gerrit/server/config/GerritImportedServerIds.java b/java/com/google/gerrit/server/config/GerritImportedServerIds.java
new file mode 100644
index 0000000..c47d3be
--- /dev/null
+++ b/java/com/google/gerrit/server/config/GerritImportedServerIds.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+/**
+ * List of ServerIds of the Gerrit data imported from other servers.
+ *
+ * <p>This values correspond to the {@code GerritServerId} of other servers.
+ */
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface GerritImportedServerIds {}
diff --git a/java/com/google/gerrit/server/config/GerritImportedServerIdsProvider.java b/java/com/google/gerrit/server/config/GerritImportedServerIdsProvider.java
new file mode 100644
index 0000000..f3f7645
--- /dev/null
+++ b/java/com/google/gerrit/server/config/GerritImportedServerIdsProvider.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.config;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import org.eclipse.jgit.lib.Config;
+
+public class GerritImportedServerIdsProvider implements Provider<ImmutableSet<String>> {
+ public static final String SECTION = "gerrit";
+ public static final String KEY = "importedServerId";
+
+ private final ImmutableSet<String> importedIds;
+
+ @Inject
+ public GerritImportedServerIdsProvider(@GerritServerConfig Config cfg) {
+ importedIds = ImmutableSet.copyOf(cfg.getStringList(SECTION, null, KEY));
+ }
+
+ @Override
+ public ImmutableSet<String> get() {
+ return importedIds;
+ }
+}
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index 158972f..3757244 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -17,6 +17,7 @@
import static java.util.Objects.requireNonNull;
import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.UsedAt;
import com.google.gerrit.entities.Change;
@@ -24,6 +25,7 @@
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.metrics.Timer0;
import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritImportedServerIds;
import com.google.gerrit.server.config.GerritServerId;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
@@ -51,6 +53,7 @@
public final AllUsersName allUsers;
public final NoteDbMetrics metrics;
public final String serverId;
+ public final ImmutableSet<String> importedServerIds;
// Providers required to avoid dependency cycles.
@@ -64,7 +67,8 @@
ChangeNoteJson changeNoteJson,
NoteDbMetrics metrics,
Provider<ChangeNotesCache> cache,
- @GerritServerId String serverId) {
+ @GerritServerId String serverId,
+ @GerritImportedServerIds ImmutableSet<String> importedServerIds) {
this.failOnLoadForTest = new AtomicBoolean();
this.repoManager = repoManager;
this.allUsers = allUsers;
@@ -72,6 +76,7 @@
this.metrics = metrics;
this.cache = cache;
this.serverId = serverId;
+ this.importedServerIds = importedServerIds;
}
}
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 26d5933..31934d5 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -406,6 +406,10 @@
return state.metaId();
}
+ public String getServerId() {
+ return state.serverId();
+ }
+
public ImmutableSortedMap<PatchSet.Id, PatchSet> getPatchSets() {
if (patchSets == null) {
ImmutableSortedMap.Builder<PatchSet.Id, PatchSet> b =
@@ -655,7 +659,9 @@
* be to bump the cache version, but that would invalidate all persistent cache entries, what we
* rather try to avoid.
*/
- if (!Strings.isNullOrEmpty(stateServerId) && !args.serverId.equals(stateServerId)) {
+ if (!Strings.isNullOrEmpty(stateServerId)
+ && !args.serverId.equals(stateServerId)
+ && !args.importedServerIds.contains(stateServerId)) {
throw new InvalidServerIdException(args.serverId, stateServerId);
}
diff --git a/java/com/google/gerrit/server/schema/SchemaModule.java b/java/com/google/gerrit/server/schema/SchemaModule.java
index ff2073d..e0e64a3 100644
--- a/java/com/google/gerrit/server/schema/SchemaModule.java
+++ b/java/com/google/gerrit/server/schema/SchemaModule.java
@@ -16,6 +16,7 @@
import static com.google.inject.Scopes.SINGLETON;
+import com.google.common.collect.ImmutableSet;
import com.google.gerrit.extensions.config.FactoryModule;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.GerritPersonIdentProvider;
@@ -25,9 +26,12 @@
import com.google.gerrit.server.config.AllUsersNameProvider;
import com.google.gerrit.server.config.AnonymousCowardName;
import com.google.gerrit.server.config.AnonymousCowardNameProvider;
+import com.google.gerrit.server.config.GerritImportedServerIds;
+import com.google.gerrit.server.config.GerritImportedServerIdsProvider;
import com.google.gerrit.server.config.GerritServerId;
import com.google.gerrit.server.config.GerritServerIdProvider;
import com.google.gerrit.server.index.group.GroupIndexCollection;
+import com.google.inject.TypeLiteral;
import org.eclipse.jgit.lib.PersonIdent;
/** Bindings for low-level Gerrit schema data. */
@@ -51,6 +55,11 @@
.toProvider(GerritServerIdProvider.class)
.in(SINGLETON);
+ bind(new TypeLiteral<ImmutableSet<String>>() {})
+ .annotatedWith(GerritImportedServerIds.class)
+ .toProvider(GerritImportedServerIdsProvider.class)
+ .in(SINGLETON);
+
// It feels wrong to have this binding in a seemingly unrelated module, but it's a dependency of
// SchemaCreatorImpl, so it's needed.
// TODO(dborowitz): Is there any way to untangle this?
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index b00cadb..b1ab6b1 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -19,6 +19,7 @@
import static com.google.inject.Scopes.SINGLETON;
import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperationsImpl;
@@ -61,6 +62,8 @@
import com.google.gerrit.server.config.FileBasedAllProjectsConfigProvider;
import com.google.gerrit.server.config.FileBasedGlobalPluginConfigProvider;
import com.google.gerrit.server.config.GerritGlobalModule;
+import com.google.gerrit.server.config.GerritImportedServerIds;
+import com.google.gerrit.server.config.GerritImportedServerIdsProvider;
import com.google.gerrit.server.config.GerritInstanceIdModule;
import com.google.gerrit.server.config.GerritInstanceNameModule;
import com.google.gerrit.server.config.GerritOptions;
@@ -311,6 +314,17 @@
@Provides
@Singleton
+ @GerritImportedServerIds
+ public ImmutableSet<String> createImportedServerIds() {
+ ImmutableSet<String> serverIds =
+ ImmutableSet.copyOf(
+ cfg.getStringList(
+ GerritServerIdProvider.SECTION, null, GerritImportedServerIdsProvider.KEY));
+ return serverIds;
+ }
+
+ @Provides
+ @Singleton
@SendEmailExecutor
public ExecutorService createSendEmailExecutor() {
return newDirectExecutorService();
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index d7c779e..d17c3cc 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -18,6 +18,7 @@
import static java.util.concurrent.TimeUnit.SECONDS;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Comment;
@@ -48,11 +49,13 @@
import com.google.gerrit.server.config.CanonicalWebUrl;
import com.google.gerrit.server.config.DefaultUrlFormatter.DefaultUrlFormatterModule;
import com.google.gerrit.server.config.EnablePeerIPInReflogRecord;
+import com.google.gerrit.server.config.GerritImportedServerIds;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.config.GerritServerId;
import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
import com.google.gerrit.server.git.GitModule;
import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.RepositoryCaseMismatchException;
import com.google.gerrit.server.group.SystemGroupBackend;
import com.google.gerrit.server.project.NullProjectCache;
import com.google.gerrit.server.project.ProjectCache;
@@ -67,9 +70,11 @@
import com.google.inject.Guice;
import com.google.inject.Inject;
import com.google.inject.Injector;
+import com.google.inject.TypeLiteral;
import java.time.Instant;
import java.time.ZoneId;
import java.util.concurrent.ExecutorService;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
import org.eclipse.jgit.junit.TestRepository;
import org.eclipse.jgit.lib.Config;
@@ -84,10 +89,13 @@
@Ignore
@RunWith(ConfigSuite.class)
public abstract class AbstractChangeNotesTest {
+ protected static final String LOCAL_SERVER_ID = "gerrit";
+
private static final ZoneId ZONE_ID = ZoneId.of("America/Los_Angeles");
@ConfigSuite.Parameter public Config testConfig;
+ protected Account.Id changeOwnerId;
protected Account.Id otherUserId;
protected FakeAccountCache accountCache;
protected IdentifiedUser changeOwner;
@@ -116,6 +124,14 @@
@Before
public void setUpTestEnvironment() throws Exception {
+ setupTestPrerequisites();
+
+ injector = createTestInjector(LOCAL_SERVER_ID);
+ createAllUsers(injector);
+ injector.injectMembers(this);
+ }
+
+ protected void setupTestPrerequisites() throws Exception {
setTimeForTesting();
serverIdent = new PersonIdent("Gerrit Server", "noreply@gerrit.com", TimeUtil.now(), ZONE_ID);
@@ -134,60 +150,71 @@
ou.setPreferredEmail("other@account.com");
accountCache.put(ou.build());
assertableFanOutExecutor = new AssertableExecutorService();
-
- injector =
- Guice.createInjector(
- new FactoryModule() {
- @Override
- public void configure() {
- install(new GitModule());
-
- install(new DefaultUrlFormatterModule());
- install(NoteDbModule.forTest());
- bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
- bind(String.class).annotatedWith(GerritServerId.class).toInstance("gerrit");
- bind(GitRepositoryManager.class).toInstance(repoManager);
- bind(ProjectCache.class).to(NullProjectCache.class);
- bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(testConfig);
- bind(String.class)
- .annotatedWith(AnonymousCowardName.class)
- .toProvider(AnonymousCowardNameProvider.class);
- bind(String.class)
- .annotatedWith(CanonicalWebUrl.class)
- .toInstance("http://localhost:8080/");
- bind(Boolean.class)
- .annotatedWith(EnablePeerIPInReflogRecord.class)
- .toInstance(Boolean.FALSE);
- bind(Realm.class).to(FakeRealm.class);
- bind(GroupBackend.class).to(SystemGroupBackend.class).in(SINGLETON);
- bind(AccountCache.class).toInstance(accountCache);
- bind(PersonIdent.class)
- .annotatedWith(GerritPersonIdent.class)
- .toInstance(serverIdent);
- bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
- bind(MetricMaker.class).to(DisabledMetricMaker.class);
- bind(ExecutorService.class)
- .annotatedWith(FanOutExecutor.class)
- .toInstance(assertableFanOutExecutor);
- bind(ServiceUserClassifier.class).to(ServiceUserClassifier.NoOp.class);
- bind(InternalChangeQuery.class)
- .toProvider(
- () -> {
- throw new UnsupportedOperationException();
- });
- bind(PatchSetApprovalUuidGenerator.class)
- .to(TestPatchSetApprovalUuidGenerator.class);
- }
- });
-
- injector.injectMembers(this);
- repoManager.createRepository(allUsers);
- changeOwner = userFactory.create(co.id());
- otherUser = userFactory.create(ou.id());
- otherUserId = otherUser.getAccountId();
+ changeOwnerId = co.id();
+ otherUserId = ou.id();
internalUser = new InternalUser();
}
+ protected Injector createTestInjector(String serverId, String... importedServerIds)
+ throws Exception {
+
+ return Guice.createInjector(
+ new FactoryModule() {
+ @Override
+ public void configure() {
+ install(new GitModule());
+
+ install(new DefaultUrlFormatterModule());
+ install(NoteDbModule.forTest());
+ bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
+ bind(String.class).annotatedWith(GerritServerId.class).toInstance(serverId);
+ bind(new TypeLiteral<ImmutableSet<String>>() {})
+ .annotatedWith(GerritImportedServerIds.class)
+ .toInstance(new ImmutableSet.Builder<String>().add(importedServerIds).build());
+ bind(GitRepositoryManager.class).toInstance(repoManager);
+ bind(ProjectCache.class).to(NullProjectCache.class);
+ bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(testConfig);
+ bind(String.class)
+ .annotatedWith(AnonymousCowardName.class)
+ .toProvider(AnonymousCowardNameProvider.class);
+ bind(String.class)
+ .annotatedWith(CanonicalWebUrl.class)
+ .toInstance("http://localhost:8080/");
+ bind(Boolean.class)
+ .annotatedWith(EnablePeerIPInReflogRecord.class)
+ .toInstance(Boolean.FALSE);
+ bind(Realm.class).to(FakeRealm.class);
+ bind(GroupBackend.class).to(SystemGroupBackend.class).in(SINGLETON);
+ bind(AccountCache.class).toInstance(accountCache);
+ bind(PersonIdent.class).annotatedWith(GerritPersonIdent.class).toInstance(serverIdent);
+ bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
+ bind(MetricMaker.class).to(DisabledMetricMaker.class);
+ bind(ExecutorService.class)
+ .annotatedWith(FanOutExecutor.class)
+ .toInstance(assertableFanOutExecutor);
+ bind(ServiceUserClassifier.class).to(ServiceUserClassifier.NoOp.class);
+ bind(InternalChangeQuery.class)
+ .toProvider(
+ () -> {
+ throw new UnsupportedOperationException();
+ });
+ bind(PatchSetApprovalUuidGenerator.class).to(TestPatchSetApprovalUuidGenerator.class);
+ }
+ });
+ }
+
+ protected void createAllUsers(Injector injector)
+ throws RepositoryCaseMismatchException, RepositoryNotFoundException {
+ AllUsersName allUsersName = injector.getInstance(AllUsersName.class);
+
+ repoManager.createRepository(allUsersName);
+
+ IdentifiedUser.GenericFactory identifiedUserFactory =
+ injector.getInstance(IdentifiedUser.GenericFactory.class);
+ changeOwner = identifiedUserFactory.create(changeOwnerId);
+ otherUser = identifiedUserFactory.create(otherUserId);
+ }
+
private void setTimeForTesting() {
systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
TestTimeUtil.resetWithClockStep(1, SECONDS);
@@ -200,8 +227,12 @@
}
protected Change newChange(boolean workInProgress) throws Exception {
+ return newChange(injector, workInProgress);
+ }
+
+ protected Change newChange(Injector injector, boolean workInProgress) throws Exception {
Change c = TestChanges.newChange(project, changeOwner.getAccountId());
- ChangeUpdate u = newUpdateForNewChange(c, changeOwner);
+ ChangeUpdate u = newUpdate(injector, c, changeOwner, false);
u.setChangeId(c.getKey().get());
u.setBranch(c.getDest().branch());
u.setWorkInProgress(workInProgress);
@@ -218,15 +249,20 @@
}
protected ChangeUpdate newUpdateForNewChange(Change c, CurrentUser user) throws Exception {
- return newUpdate(c, user, false);
+ return newUpdate(injector, c, user, false);
}
protected ChangeUpdate newUpdate(Change c, CurrentUser user) throws Exception {
- return newUpdate(c, user, true);
+ return newUpdate(injector, c, user, true);
}
protected ChangeUpdate newUpdate(Change c, CurrentUser user, boolean shouldExist)
throws Exception {
+ return newUpdate(injector, c, user, shouldExist);
+ }
+
+ protected ChangeUpdate newUpdate(
+ Injector injector, Change c, CurrentUser user, boolean shouldExist) throws Exception {
ChangeUpdate update = TestChanges.newUpdate(injector, c, user, shouldExist);
update.setPatchSetId(c.currentPatchSetId());
update.setAllowWriteToNewRef(true);
@@ -237,6 +273,10 @@
return new ChangeNotes(args, c, true, null).load();
}
+ protected ChangeNotes newNotes(AbstractChangeNotes.Args cArgs, Change c) {
+ return new ChangeNotes(cArgs, c, true, null).load();
+ }
+
protected static SubmitRecord submitRecord(
String status, String errorMessage, SubmitRecord.Label... labels) {
SubmitRecord rec = new SubmitRecord();
diff --git a/javatests/com/google/gerrit/server/notedb/ImportedChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ImportedChangeNotesTest.java
new file mode 100644
index 0000000..1eb08ce
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/ImportedChangeNotesTest.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.git.RepositoryCaseMismatchException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ImportedChangeNotesTest extends AbstractChangeNotesTest {
+
+ private static final String FOREIGN_SERVER_ID = "foreign-server-id";
+ private static final String IMPORTED_SERVER_ID = "gerrit-imported-1";
+
+ @Before
+ @Override
+ public void setUpTestEnvironment() throws Exception {
+ setupTestPrerequisites();
+ }
+
+ private void initServerIds(String serverId, String... importedServerIds)
+ throws Exception, RepositoryCaseMismatchException, RepositoryNotFoundException {
+ injector = createTestInjector(serverId, importedServerIds);
+ injector.injectMembers(this);
+ createAllUsers(injector);
+ }
+
+ @Test
+ public void allowChangeFromImportedServerId() throws Exception {
+ initServerIds(LOCAL_SERVER_ID, IMPORTED_SERVER_ID);
+
+ Change importedChange = newChange(createTestInjector(IMPORTED_SERVER_ID), false);
+ Change localChange = newChange();
+
+ assertThat(newNotes(importedChange).getServerId()).isEqualTo(IMPORTED_SERVER_ID);
+ assertThat(newNotes(localChange).getServerId()).isEqualTo(LOCAL_SERVER_ID);
+ }
+
+ @Test
+ public void rejectChangeWithForeignServerId() throws Exception {
+ initServerIds(LOCAL_SERVER_ID);
+ Change foreignChange = newChange(createTestInjector(FOREIGN_SERVER_ID), false);
+
+ InvalidServerIdException invalidServerIdEx =
+ assertThrows(InvalidServerIdException.class, () -> newNotes(foreignChange));
+
+ String invalidServerIdMessage = invalidServerIdEx.getMessage();
+ assertThat(invalidServerIdMessage).contains("expected " + LOCAL_SERVER_ID);
+ assertThat(invalidServerIdMessage).contains("actual: " + FOREIGN_SERVER_ID);
+ }
+}