From 47429fadd90fba607da5bef38c1e2bb06bcf09c7 Mon Sep 17 00:00:00 2001 From: akafredperry Date: Wed, 14 Jan 2026 18:57:45 +0000 Subject: [PATCH] feature: support for apkam authentication model plus the onboarding and enrollment workflow --- README.md | 2 +- at_client/pom.xml | 19 +- .../java/org/atsign/client/api/AtClient.java | 75 ++- .../java/org/atsign/client/api/AtKeys.java | 189 ++++++ .../java/org/atsign/client/api/Secondary.java | 3 +- .../client/api/impl/clients/AtClientImpl.java | 41 +- .../connections/AtSecondaryConnection.java | 8 +- .../api/impl/secondaries/RemoteSecondary.java | 13 +- .../org/atsign/client/cli/AbstractCli.java | 267 ++++++++ .../java/org/atsign/client/cli/Activate.java | 497 ++++++++++++++ .../java/org/atsign/client/cli/Delete.java | 3 +- .../java/org/atsign/client/cli/DumpKeys.java | 7 +- .../main/java/org/atsign/client/cli/Get.java | 3 +- .../java/org/atsign/client/cli/Onboard.java | 114 ---- .../main/java/org/atsign/client/cli/REPL.java | 3 +- .../java/org/atsign/client/cli/Register.java | 7 +- .../main/java/org/atsign/client/cli/Scan.java | 3 +- .../java/org/atsign/client/cli/Share.java | 3 +- .../java/org/atsign/client/util/AuthUtil.java | 20 +- .../atsign/client/util/EncryptionUtil.java | 39 +- .../org/atsign/client/util/EnrollmentId.java | 12 + .../org/atsign/client/util/KeyStringUtil.java | 6 +- .../java/org/atsign/client/util/KeysUtil.java | 184 ++++-- .../atsign/client/util/OnboardingUtil.java | 58 -- .../org/atsign/client/util/Preconditions.java | 18 + .../org/atsign/client/util/TypedString.java | 29 + .../java/org/atsign/common/KeyBuilders.java | 2 +- .../atsign/common/ResponseTransformers.java | 18 +- .../org/atsign/client/api/AtKeysTest.java | 397 +++++++++++ .../org/atsign/client/cli/ActivateTest.java | 286 ++++++++ .../client/util/EncryptionUtilTest.java | 39 ++ .../java/org/atsign/common/KeysUtilTest.java | 60 +- .../org/atsign/cucumber/CucumberTests.java | 7 +- .../atsign/cucumber/helpers/AtDemoData.java | 61 ++ .../org/atsign/cucumber/helpers/Helpers.java | 10 + .../atsign/cucumber/steps/ActivateSteps.java | 313 +++++++++ .../cucumber/steps/AtClientContext.java | 619 ++++++++++++------ .../atsign/cucumber/steps/GetAtKeysSteps.java | 542 ++++++++------- .../atsign/cucumber/steps/MonitorSteps.java | 226 ++++--- .../atsign/cucumber/steps/ParameterTypes.java | 53 +- .../cucumber/steps/PublicAtKeySteps.java | 296 ++++++--- .../cucumber/steps/QualifiedAtSign.java | 43 ++ .../atsign/cucumber/steps/SelfAtKeySteps.java | 240 ++++--- .../cucumber/steps/SharedAtKeySteps.java | 321 +++++---- .../org/atsign/virtualenv/VirtualEnv.java | 10 +- .../test/resources/features/Activate.feature | 97 +++ .../src/test/resources/features/Keys.feature | 8 +- .../test/resources/features/Monitor.feature | 16 +- .../resources/features/Namespaces.feature | 111 ++++ .../test/resources/features/PublicKey.feature | 20 +- .../test/resources/features/SelfKey.feature | 12 +- .../test/resources/features/SharedKey.feature | 29 +- 52 files changed, 4234 insertions(+), 1225 deletions(-) create mode 100644 at_client/src/main/java/org/atsign/client/api/AtKeys.java create mode 100644 at_client/src/main/java/org/atsign/client/cli/AbstractCli.java create mode 100644 at_client/src/main/java/org/atsign/client/cli/Activate.java delete mode 100644 at_client/src/main/java/org/atsign/client/cli/Onboard.java create mode 100644 at_client/src/main/java/org/atsign/client/util/EnrollmentId.java delete mode 100644 at_client/src/main/java/org/atsign/client/util/OnboardingUtil.java create mode 100644 at_client/src/main/java/org/atsign/client/util/TypedString.java create mode 100644 at_client/src/test/java/org/atsign/client/api/AtKeysTest.java create mode 100644 at_client/src/test/java/org/atsign/client/cli/ActivateTest.java create mode 100644 at_client/src/test/java/org/atsign/client/util/EncryptionUtilTest.java create mode 100644 at_client/src/test/java/org/atsign/cucumber/helpers/AtDemoData.java create mode 100644 at_client/src/test/java/org/atsign/cucumber/steps/ActivateSteps.java create mode 100644 at_client/src/test/java/org/atsign/cucumber/steps/QualifiedAtSign.java create mode 100644 at_client/src/test/resources/features/Activate.feature create mode 100644 at_client/src/test/resources/features/Namespaces.feature diff --git a/README.md b/README.md index 623c4529..1cb0076c 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ java -cp "target/at_client-1.0-SNAPSHOT.jar:target/lib/*" org.atsign.client.cli. 3) Get 4) Delete 5) Register -6) Onboard +6) Activate #### Note: Each of these classes requires a different set of arguments, make sure to read the help text and provide necessary arguments ** Text about the remaining functionalities coming soon ** diff --git a/at_client/pom.xml b/at_client/pom.xml index 818dc167..fedf19bd 100644 --- a/at_client/pom.xml +++ b/at_client/pom.xml @@ -287,12 +287,19 @@ test - - org.hamcrest - hamcrest-all - 1.3 - test - + + org.hamcrest + hamcrest + 2.2 + test + + + + org.awaitility + awaitility + 4.2.0 + test + diff --git a/at_client/src/main/java/org/atsign/client/api/AtClient.java b/at_client/src/main/java/org/atsign/client/api/AtClient.java index 205b32cf..db629836 100644 --- a/at_client/src/main/java/org/atsign/client/api/AtClient.java +++ b/at_client/src/main/java/org/atsign/client/api/AtClient.java @@ -5,17 +5,15 @@ import org.atsign.client.api.impl.connections.DefaultAtConnectionFactory; import org.atsign.client.api.impl.events.SimpleAtEventBus; import org.atsign.client.api.impl.secondaries.RemoteSecondary; -import org.atsign.client.util.KeysUtil; import org.atsign.common.AtException; import org.atsign.common.AtSign; -import org.atsign.common.exceptions.AtClientConfigException; import org.atsign.common.exceptions.AtSecondaryConnectException; import org.atsign.common.exceptions.AtSecondaryNotFoundException; import org.atsign.common.options.GetRequestOptions; +import java.io.Closeable; import java.io.IOException; import java.util.List; -import java.util.Map; import java.util.concurrent.CompletableFuture; import static org.atsign.common.Keys.*; @@ -24,57 +22,70 @@ * The primary interface of the AtSign client library. */ @SuppressWarnings("unused") -public interface AtClient extends Secondary, AtEvents.AtEventBus { +public interface AtClient extends Secondary, AtEvents.AtEventBus, Closeable { /** * Standard AtClient factory - uses production @ root to look up the cloud secondary address for this atSign - * @param atSign the atsign of this client + * @param atSign the {@link AtSign} of this client - e.g. @alice + * @param keys the {@link AtKeys} for this client * @return An {@link AtClient} * @throws AtException if something goes wrong with looking up or connecting to the remote secondary */ - static AtClient withRemoteSecondary(AtSign atSign) throws AtException { - return withRemoteSecondary("root.atsign.org:64", atSign); + static AtClient withRemoteSecondary(AtSign atSign, AtKeys keys) throws AtException { + return withRemoteSecondary("root.atsign.org:64", atSign, keys); } /** * Standard AtClient factory - uses production @ root to look up the cloud secondary address for this atSign - * @param atSign the atsign of this client + * @param atSign the {@link AtSign} of this client - e.g. @alice + * @param keys the {@link AtKeys} for this client * @param verbose set to true for chatty logs * @return An {@link AtClient} * @throws AtException if something goes wrong with looking up or connecting to the remote secondary */ - static AtClient withRemoteSecondary(AtSign atSign, boolean verbose) throws AtException { - return withRemoteSecondary("root.atsign.org:64", atSign, verbose); + static AtClient withRemoteSecondary(AtSign atSign, AtKeys keys, boolean verbose) throws AtException { + return withRemoteSecondary("root.atsign.org:64", atSign, keys, verbose); } /** * Factory to use when you wish to use a custom Secondary.AddressFinder - * @param atSign the atSign of this client + * @param atSign the {@link AtSign} of this client - e.g. @alice + * @param keys the {@link AtKeys} for this client * @param secondaryAddressFinder will be used to find the Secondary.Address of the atSign * @return An {@link AtClient} * @throws AtException if any other exception occurs while connecting to the remote (cloud) secondary */ - static AtClient withRemoteSecondary(AtSign atSign, Secondary.AddressFinder secondaryAddressFinder) throws AtException { + static AtClient withRemoteSecondary(AtSign atSign, AtKeys keys, Secondary.AddressFinder secondaryAddressFinder) throws AtException { Secondary.Address remoteSecondaryAddress; try { remoteSecondaryAddress = secondaryAddressFinder.findSecondary(atSign); } catch (IOException e) { throw new AtSecondaryConnectException("Failed to find secondary, with IOException", e); } - return withRemoteSecondary(atSign, remoteSecondaryAddress, false); + return withRemoteSecondary(atSign, keys, remoteSecondaryAddress, false); } /** * Factory - returns default AtClientImpl with a RemoteSecondary and a DefaultConnectionFactory * @param rootUrl the address of the root server to use - e.g. root.atsign.org:64 for production at-signs - * @param atSign the atSign of the client - e.g. @alice + * @param atSign the {@link AtSign} of this client - e.g. @alice + * @param keys the {@link AtKeys} for this client * @return An {@link AtClient} * @throws AtException if anything goes wrong during construction */ - static AtClient withRemoteSecondary(String rootUrl, AtSign atSign) throws AtException { - return withRemoteSecondary(rootUrl, atSign, false); + static AtClient withRemoteSecondary(String rootUrl, AtSign atSign, AtKeys keys) throws AtException { + return withRemoteSecondary(rootUrl, atSign, keys, false); } - static AtClient withRemoteSecondary(String rootUrl, AtSign atSign, boolean verbose) throws AtException { + /** + * Factory - returns default AtClientImpl with a RemoteSecondary and a DefaultConnectionFactory + * @param rootUrl the address of the root server to use - e.g. root.atsign.org:64 for production at-signs + * @param atSign the {@link AtSign} of this client - e.g. @alice + * @param keys the {@link AtKeys} for this client + * @param verbose set to true for chatty logs + * @return An {@link AtClient} + * @throws AtException if anything goes wrong during construction + */ + static AtClient withRemoteSecondary(String rootUrl, AtSign atSign, AtKeys keys, boolean verbose) throws AtException { DefaultAtConnectionFactory connectionFactory = new DefaultAtConnectionFactory(); Secondary.Address secondaryAddress; @@ -88,41 +99,36 @@ static AtClient withRemoteSecondary(String rootUrl, AtSign atSign, boolean verbo throw new AtSecondaryNotFoundException("Failed to lookup remote secondary", e); } - return withRemoteSecondary(atSign, secondaryAddress, verbose); + return withRemoteSecondary(atSign, keys, secondaryAddress, verbose); } /** * Factory to use when you wish to use a custom Secondary.AddressFinder - * @param atSign the atSign of this client + * @param atSign the {@link AtSign} of this client - e.g. @alice + * @param keys the {@link AtKeys} for this client * @param verbose set to true for chatty logs * @return An {@link AtClient} * @throws IOException if thrown by the address finder * @throws AtException if any other exception occurs while connecting to the remote (cloud) secondary */ - static AtClient withRemoteSecondary(AtSign atSign, Secondary.AddressFinder secondaryAddressFinder, boolean verbose) throws IOException, AtException { + static AtClient withRemoteSecondary(AtSign atSign, AtKeys keys, Secondary.AddressFinder secondaryAddressFinder, boolean verbose) throws IOException, AtException { Secondary.Address remoteSecondaryAddress = secondaryAddressFinder.findSecondary(atSign); - return withRemoteSecondary(atSign, remoteSecondaryAddress, verbose); + return withRemoteSecondary(atSign, keys, remoteSecondaryAddress, verbose); } /** * Factory to use when you already know the address of the remote (cloud) secondary - * @param atSign the atSign of this client + * @param atSign the {@link AtSign} of this client - e.g. @alice + * @param keys the {@link AtKeys} for this client * @param remoteSecondaryAddress the address of the remote secondary server * @param verbose set to true for chatty logs * @return An {@link AtClient} * @throws AtException if any other exception occurs while connecting to the remote (cloud) secondary */ - static AtClient withRemoteSecondary(AtSign atSign, Secondary.Address remoteSecondaryAddress, boolean verbose) throws AtException { + static AtClient withRemoteSecondary(AtSign atSign, AtKeys keys, Secondary.Address remoteSecondaryAddress, boolean verbose) throws AtException { DefaultAtConnectionFactory connectionFactory = new DefaultAtConnectionFactory(); AtEvents.AtEventBus eventBus = new SimpleAtEventBus(); - Map keys; - try { - keys = KeysUtil.loadKeys(atSign); - } catch (Exception e) { - throw new AtClientConfigException("Failed to load keys", e); - } - RemoteSecondary secondary; try { secondary = new RemoteSecondary(eventBus, atSign, remoteSecondaryAddress, keys, connectionFactory, verbose); @@ -135,20 +141,21 @@ static AtClient withRemoteSecondary(AtSign atSign, Secondary.Address remoteSecon /** * Factory to use when you already know the address of the remote (cloud) secondary - * @param atSign the atSign of this client * @param remoteSecondaryAddress the address of the remote secondary server + * @param atSign the {@link AtSign} of this client - e.g. @alice + * @param keys the {@link AtKeys} for this client * @return An {@link AtClient} * @throws AtException if any other exception occurs while connecting to the remote (cloud) secondary */ - static AtClient withRemoteSecondary(Secondary.Address remoteSecondaryAddress, AtSign atSign) throws AtException { - return withRemoteSecondary(atSign, remoteSecondaryAddress, false); + static AtClient withRemoteSecondary(Secondary.Address remoteSecondaryAddress, AtSign atSign, AtKeys keys) throws AtException { + return withRemoteSecondary(atSign, keys, remoteSecondaryAddress, false); } AtSign getAtSign(); Secondary getSecondary(); - Map getEncryptionKeys(); + AtKeys getEncryptionKeys(); CompletableFuture get(SharedKey sharedKey); CompletableFuture getBinary(SharedKey sharedKey); diff --git a/at_client/src/main/java/org/atsign/client/api/AtKeys.java b/at_client/src/main/java/org/atsign/client/api/AtKeys.java new file mode 100644 index 00000000..1f982c84 --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/api/AtKeys.java @@ -0,0 +1,189 @@ +package org.atsign.client.api; + +import org.atsign.client.util.EnrollmentId; + +import java.security.KeyPair; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.util.Base64; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Data class used to hold an {@link org.atsign.client.api.AtClient}s keys + */ +public class AtKeys { + + /** + * unique id which is assigned by the at_server at enrollment time, this is associated with a specific + * application and device and therefore a specific the apkam key pair + */ + private EnrollmentId enrollmentId; + + /** + * Public Key used for Authentication Management, once this is stored in the at_server then an + * {@link org.atsign.client.api.AtClient} is able to authenticate using the corresponding private key. + */ + private String apkamPublicKey; + + /** + * Private Key used for Authentication Management, this is used to sign the challenge during + * authentication with the at_server. + */ + private String apkamPrivateKey; + + /** + * Encryption Key used during enrollment. This key is sent as part of the enrollment request (encrypted + * with the atsigns public encryption key). The process that approves the enrollment request uses this + * key to encrypt the {@link #selfEncryptKey} and {@link #encryptPrivateKey} in the response that it sends. + * The process which requested the enrollment can then decrypt and store those keys. + * This ensures that all {@link AtKeys} got and {@link org.atsign.common.AtSign} share the same + * {@link #selfEncryptKey} and {@link #encryptPrivateKey} + */ + private String apkamSymmetricKey; + + /** + * Encryption Key used to encrypt {@link org.atsign.common.Keys.SelfKey}s and the pkam and encryption key pairs + * when they are externalised as JSON + */ + private String selfEncryptKey; + + /** + * This is used to encrypt the symmetric keys that are used to encrypt {@link org.atsign.common.Keys.SharedKey}s + * where the shared with {@link org.atsign.common.AtSign} is this {@link org.atsign.common.AtSign} + */ + + private String encryptPublicKey; + + /** + * This is used to decrypt the symmetric keys that are used to encrypt {@link org.atsign.common.Keys.SharedKey}s + * where the shared with {@link org.atsign.common.AtSign} is this {@link org.atsign.common.AtSign} + */ + private String encryptPrivateKey; + + /** + * Transient cache of other keys + */ + private Map cache = new ConcurrentHashMap<>(); + + public boolean hasEnrollmentId() { + return enrollmentId != null; + } + + public EnrollmentId getEnrollmentId() { + return enrollmentId; + } + + public AtKeys setEnrollmentId(EnrollmentId enrollmentId) { + this.enrollmentId = enrollmentId; + return this; + } + + public String getSelfEncryptKey() { + return selfEncryptKey; + } + + public AtKeys setSelfEncryptKey(String key) { + this.selfEncryptKey = key; + return this; + } + + public String getApkamPublicKey() { + return apkamPublicKey; + } + + public AtKeys setApkamPublicKey(String key) { + this.apkamPublicKey = key; + return this; + } + + public AtKeys setApkamPublicKey(PublicKey key) { + return setApkamPublicKey(createStringBase64(key)); + } + + public String getApkamPrivateKey() { + return apkamPrivateKey; + } + + public AtKeys setApkamPrivateKey(String key) { + this.apkamPrivateKey = key; + return this; + } + + public AtKeys setApkamPrivateKey(PrivateKey key) { + return setApkamPrivateKey(createStringBase64(key)); + } + + public AtKeys setApkamKeyPair(KeyPair keyPair) { + setApkamPublicKey(keyPair.getPublic()); + setApkamPrivateKey(keyPair.getPrivate()); + return this; + } + + public boolean hasPkamKeys() { + return this.apkamPublicKey != null && this.apkamPrivateKey != null; + } + + public AtKeys setEncryptKeyPair(KeyPair keyPair) { + setEncryptPublicKey(keyPair.getPublic()); + setEncryptPrivateKey(keyPair.getPrivate()); + return this; + } + + public String getEncryptPublicKey() { + return encryptPublicKey; + } + + public AtKeys setEncryptPublicKey(String key) { + this.encryptPublicKey = key; + return this; + } + + public AtKeys setEncryptPublicKey(PublicKey key) { + return setEncryptPublicKey(createStringBase64(key)); + } + + public String getEncryptPrivateKey() { + return encryptPrivateKey; + } + + public AtKeys setEncryptPrivateKey(String key) { + this.encryptPrivateKey = key; + return this; + } + + public AtKeys setEncryptPrivateKey(PrivateKey key) { + return setEncryptPrivateKey(createStringBase64(key)); + } + + public String getApkamSymmetricKey() { + return apkamSymmetricKey; + } + + public AtKeys setApkamSymmetricKey(String key) { + this.apkamSymmetricKey = key; + return this; + } + + public String get(String key) { + return cache.get(key); + } + + public void put(String key, String value) { + cache.put(key, value); + } + + public Map getCache() { + return Collections.unmodifiableMap(cache); + } + + private static String createStringBase64(PublicKey key) { + return Base64.getEncoder().encodeToString(key.getEncoded()); + } + + private static String createStringBase64(PrivateKey key) { + return Base64.getEncoder().encodeToString(key.getEncoded()); + } + +} diff --git a/at_client/src/main/java/org/atsign/client/api/Secondary.java b/at_client/src/main/java/org/atsign/client/api/Secondary.java index 29198a79..94e48a94 100644 --- a/at_client/src/main/java/org/atsign/client/api/Secondary.java +++ b/at_client/src/main/java/org/atsign/client/api/Secondary.java @@ -4,6 +4,7 @@ import org.atsign.common.exceptions.*; import org.atsign.common.AtSign; +import java.io.Closeable; import java.io.IOException; /** @@ -21,7 +22,7 @@ * interface is effectively the same as when interacting with a cloud secondary via openssl * from command line. */ -public interface Secondary extends AtEvents.AtEventListener { +public interface Secondary extends AtEvents.AtEventListener, Closeable { /** * @param command in @ protocol format * @param throwExceptionOnErrorResponse sometimes we want to inspect an error response, diff --git a/at_client/src/main/java/org/atsign/client/api/impl/clients/AtClientImpl.java b/at_client/src/main/java/org/atsign/client/api/impl/clients/AtClientImpl.java index f30b643c..169daeab 100644 --- a/at_client/src/main/java/org/atsign/client/api/impl/clients/AtClientImpl.java +++ b/at_client/src/main/java/org/atsign/client/api/impl/clients/AtClientImpl.java @@ -7,8 +7,8 @@ import org.atsign.client.api.AtEvents.AtEventListener; import org.atsign.client.api.AtEvents.AtEventType; import org.atsign.client.api.Secondary; +import org.atsign.client.api.AtKeys; import org.atsign.client.util.EncryptionUtil; -import org.atsign.client.util.KeysUtil; import org.atsign.common.*; import org.atsign.common.Keys.AtKey; import org.atsign.common.Keys.PublicKey; @@ -33,6 +33,7 @@ import java.util.concurrent.CompletionException; import static org.atsign.client.api.AtEvents.AtEventType.decryptedUpdateNotification; +import static org.atsign.client.util.Preconditions.checkNotNull; /** * @see org.atsign.client.api.AtClient @@ -46,18 +47,18 @@ public class AtClientImpl implements AtClient { private final AtSign atSign; @Override public AtSign getAtSign() {return atSign;} - private final Map keys; - @Override public Map getEncryptionKeys() {return keys;} + private final AtKeys keys; + @Override public AtKeys getEncryptionKeys() {return keys;} private final Secondary secondary; @Override public Secondary getSecondary() {return secondary;} private final AtEventBus eventBus; - public AtClientImpl(AtEventBus eventBus, AtSign atSign, Map keys, Secondary secondary) { + public AtClientImpl(AtEventBus eventBus, AtSign atSign, AtKeys keys, Secondary secondary) { this.eventBus = eventBus; this.atSign = atSign; this.keys = keys; this.secondary = secondary; - + checkNotNull(keys.getEncryptPrivateKey(), "AtKeys have not been fully enrolled"); eventBus.addEventListener(this, EnumSet.allOf(AtEventType.class)); } @@ -92,7 +93,7 @@ public synchronized void handleEvent(AtEventType eventType, Map String sharedSharedKeyEncryptedValue = (String) eventData.get("value"); // decrypt it with our encryption private key try { - String sharedKeyDecryptedValue = EncryptionUtil.rsaDecryptFromBase64(sharedSharedKeyEncryptedValue, keys.get(KeysUtil.encryptionPrivateKeyName)); + String sharedKeyDecryptedValue = EncryptionUtil.rsaDecryptFromBase64(sharedSharedKeyEncryptedValue, keys.getEncryptPrivateKey()); keys.put(sharedSharedKeyName, sharedKeyDecryptedValue); } catch (Exception e) { System.err.println(OffsetDateTime.now() + ": caught exception " + e + " while decrypting received shared key " + sharedSharedKeyName); @@ -344,7 +345,13 @@ public Response executeCommand(String command, boolean throwExceptionOnErrorResp return secondary.executeCommand(command, throwExceptionOnErrorResponse); } -// ============================================================================================================================================ + @Override + public void close() throws IOException { + stopMonitor(); + secondary.close(); + } + + // ============================================================================================================================================ // ============================================================================================================================================ // ============================================================================================================================================ @@ -449,7 +456,7 @@ private String _get(SelfKey key) throws AtException { // 3. decrypt the value String decryptedValue; String encryptedValue = fetched.data; - String selfEncryptionKey = keys.get(KeysUtil.selfEncryptionKeyName); + String selfEncryptionKey = keys.getSelfEncryptKey(); try { decryptedValue = EncryptionUtil.aesDecryptFromBase64(encryptedValue, selfEncryptionKey); } catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidAlgorithmParameterException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException | NoSuchProviderException e) { @@ -469,7 +476,7 @@ private String _put(SelfKey selfKey, String value) throws AtException { // 2. encrypt data with self encryption key String cipherText; try { - cipherText = EncryptionUtil.aesEncryptToBase64(value, keys.get(KeysUtil.selfEncryptionKeyName)); + cipherText = EncryptionUtil.aesEncryptToBase64(value, keys.getSelfEncryptKey()); } catch (NoSuchPaddingException | NoSuchAlgorithmException | InvalidAlgorithmParameterException | InvalidKeyException | IllegalBlockSizeException | BadPaddingException | NoSuchProviderException e) { throw new AtEncryptionException("Failed to encrypt value with self encryption key", e); } @@ -583,8 +590,10 @@ private List _getAtKeys(String regex, boolean fetchMetadata) throws AtExc } catch (IOException e) { throw new AtSecondaryConnectException("Failed to execute " + scanCommand, e); } - ResponseTransformers.ScanResponseTransformer scanResponseTransformer = new ResponseTransformers.ScanResponseTransformer(); + ResponseTransformers.ScanResponseTransformer scanResponseTransformer + = new ResponseTransformers.ScanResponseTransformer(AtClientImpl::isNotManagementKey); List rawArray = scanResponseTransformer.transform(scanRawResponse); + List atKeys = new ArrayList<>(); for(String atKeyRaw : rawArray) { // eg atKeyRaw == @bob:phone@alice AtKey atKey = Keys.fromString(atKeyRaw); @@ -656,7 +665,7 @@ private String getEncryptionKeySharedByMe(SharedKey key) throws AtException { // When we stored it, we encrypted it with our encryption public key; so we need to decrypt it now with our encryption private key try { - return EncryptionUtil.rsaDecryptFromBase64(rawResponse.getRawDataResponse(), keys.get(KeysUtil.encryptionPrivateKeyName)); + return EncryptionUtil.rsaDecryptFromBase64(rawResponse.getRawDataResponse(), keys.getEncryptPrivateKey()); } catch (Exception e) { throw new AtDecryptionException("Failed to decrypt " + toLookup, e); } @@ -683,7 +692,7 @@ private String getEncryptionKeySharedByOther(SharedKey sharedKey) throws AtExcep String sharedSharedKeyDecryptedValue; try { - sharedSharedKeyDecryptedValue = EncryptionUtil.rsaDecryptFromBase64(rawResponse.getRawDataResponse(), keys.get(KeysUtil.encryptionPrivateKeyName)); + sharedSharedKeyDecryptedValue = EncryptionUtil.rsaDecryptFromBase64(rawResponse.getRawDataResponse(), keys.getEncryptPrivateKey()); } catch (Exception e) { throw new AtDecryptionException("Failed to decrypt the shared_key with our encryption private key", e); } @@ -715,7 +724,7 @@ private String createSharedEncryptionKey(SharedKey sharedKey) throws AtException what = "encrypt new shared key with our public key"; // Encrypt key with our publickey and save it shared_key.bob@alice - String encryptedForUs = EncryptionUtil.rsaEncryptToBase64(aesKey, keys.get(KeysUtil.encryptionPublicKeyName)); + String encryptedForUs = EncryptionUtil.rsaEncryptToBase64(aesKey, keys.getEncryptPublicKey()); what = "save encrypted shared key for us"; secondary.executeCommand("update:" + "shared_key." + sharedKey.sharedWith.withoutPrefix() + sharedKey.sharedBy @@ -757,10 +766,14 @@ private String getPublicEncryptionKey(AtSign sharedWith) throws AtException { private String generateSignature(String value) throws AtException { String signature; try { - signature = EncryptionUtil.signSHA256RSA(value, keys.get(KeysUtil.encryptionPrivateKeyName)); + signature = EncryptionUtil.signSHA256RSA(value, keys.getEncryptPrivateKey()); } catch (Exception e) { throw new AtEncryptionException("Failed to sign value: " + value, e); } return signature; } + + private static boolean isNotManagementKey(String s) { + return !s.matches(".+\\.__manage@.+"); + } } diff --git a/at_client/src/main/java/org/atsign/client/api/impl/connections/AtSecondaryConnection.java b/at_client/src/main/java/org/atsign/client/api/impl/connections/AtSecondaryConnection.java index bef7be2d..a649cb8c 100644 --- a/at_client/src/main/java/org/atsign/client/api/impl/connections/AtSecondaryConnection.java +++ b/at_client/src/main/java/org/atsign/client/api/impl/connections/AtSecondaryConnection.java @@ -5,13 +5,14 @@ import org.atsign.client.api.Secondary; import org.atsign.common.AtSign; +import java.io.Closeable; import java.io.IOException; /** * A connection which understands how to talk with the secondary server. * @see org.atsign.client.api.AtConnection */ -public class AtSecondaryConnection extends AtConnectionBase { +public class AtSecondaryConnection extends AtConnectionBase implements Closeable { private final AtSign atSign; public AtSign getAtSign() {return atSign;} @@ -51,4 +52,9 @@ protected String parseRawResponse(String rawResponse) throws IOException { throw new IOException("Invalid response from server: " + rawResponse); } } + + @Override + public void close() throws IOException { + disconnect(); + } } diff --git a/at_client/src/main/java/org/atsign/client/api/impl/secondaries/RemoteSecondary.java b/at_client/src/main/java/org/atsign/client/api/impl/secondaries/RemoteSecondary.java index 53bfbd51..e6af9ff9 100644 --- a/at_client/src/main/java/org/atsign/client/api/impl/secondaries/RemoteSecondary.java +++ b/at_client/src/main/java/org/atsign/client/api/impl/secondaries/RemoteSecondary.java @@ -4,6 +4,7 @@ import org.atsign.client.api.Secondary; import org.atsign.client.api.impl.connections.AtMonitorConnection; import org.atsign.client.api.impl.connections.AtSecondaryConnection; +import org.atsign.client.api.AtKeys; import org.atsign.client.util.AuthUtil; import org.atsign.common.AtException; import org.atsign.common.AtSign; @@ -33,7 +34,7 @@ public class RemoteSecondary implements Secondary { @SuppressWarnings("unused") public AtSecondaryConnection getConnection() {return connection;} - private AtMonitorConnection monitorConnection; + private volatile AtMonitorConnection monitorConnection; @SuppressWarnings("unused") public AtMonitorConnection getMonitorConnection() {return monitorConnection;} @@ -58,11 +59,11 @@ public void setVerbose(boolean b) { @SuppressWarnings("unused") public RemoteSecondary(AtEventBus eventBus, AtSign atSign, Secondary.Address secondaryAddress, - Map keys, AtConnectionFactory connectionFactory) throws IOException, AtException { + AtKeys keys, AtConnectionFactory connectionFactory) throws IOException, AtException { this(eventBus, atSign, secondaryAddress, keys, connectionFactory, false); } public RemoteSecondary(AtEventBus eventBus, AtSign atSign, Secondary.Address secondaryAddress, - Map keys, AtConnectionFactory connectionFactory, + AtKeys keys, AtConnectionFactory connectionFactory, boolean verbose) throws IOException, AtException { this.eventBus = eventBus; this.atSign = atSign; @@ -116,6 +117,12 @@ public synchronized void handleEvent(AtEventType eventType, Map // if (eventType == ) } + @Override + public void close() throws IOException { + ensureMonitorNotRunning(); + connection.close(); + } + private void ensureMonitorRunning() { String what = ""; try { diff --git a/at_client/src/main/java/org/atsign/client/cli/AbstractCli.java b/at_client/src/main/java/org/atsign/client/cli/AbstractCli.java new file mode 100644 index 00000000..15af3176 --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/cli/AbstractCli.java @@ -0,0 +1,267 @@ +package org.atsign.client.cli; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.atsign.client.api.impl.connections.AtRootConnection; +import org.atsign.client.api.impl.connections.AtSecondaryConnection; +import org.atsign.client.api.impl.events.SimpleAtEventBus; +import org.atsign.client.api.AtKeys; +import org.atsign.client.util.AuthUtil; +import org.atsign.client.util.KeysUtil; +import org.atsign.client.util.TypedString; +import org.atsign.common.AtException; +import org.atsign.common.AtSign; +import org.atsign.common.exceptions.AtSecondaryNotFoundException; +import picocli.CommandLine; +import picocli.CommandLine.ITypeConverter; +import picocli.CommandLine.Option; + +import java.io.File; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.atsign.client.util.Preconditions.checkNotNull; + +public abstract class AbstractCli> { + + protected static final Pattern DATA_JSON_NON_EMPTY_MAP = Pattern.compile("data:(\\{.+})"); + protected static final Pattern DATA_JSON_MAP = Pattern.compile("data:(\\{.*})"); + protected static final Pattern DATA_JSON_NO_EMPTY_LIST = Pattern.compile("data:(\\[.+])"); + protected static final Pattern DATA_INT = Pattern.compile("data:\\d+"); + public static final Pattern DATA_NON_WHITESPACE = Pattern.compile("data:(\\S+)"); + + protected String rootUrl = "root.atsign.org"; + protected AtSign atSign; + protected File keysFile; + protected int connectionRetries = 1; + private boolean verbose = false; + + protected abstract T self(); + + public T setVerbose(boolean isVerbose) { + this.verbose = isVerbose; + return self(); + } + + public T setVerbose() { + return setVerbose(true); + } + + @Option(names = {"-r", "--root"}, paramLabel = "HOST:PORT", description = "atDirectory (aka root) server domain (e.g., root.atsign.org)") + public T setRootUrl(String rootUrl) { + this.rootUrl = rootUrl; + return self(); + } + + @Option(names = {"-a", "--atsign"}, description = "the atsign e.g. @colin", paramLabel = "ATSIGN", converter = AtSignConverter.class) + public T setAtSign(AtSign atSign) { + this.atSign = atSign; + return self(); + } + + @Option(names = {"-k", "--keys"}, paramLabel = "PATH", description = "path to atKeys file to use / create") + public T setKeysFile(String path) { + this.keysFile = new File(path); + return self(); + } + + protected static File checkNotExists(File f) { + if (f.exists()) { + throw new IllegalArgumentException(f.getPath() + " would be overwritten"); + } + return f; + } + + protected static File checkExists(File f) { + if (!f.exists()) { + throw new IllegalArgumentException(f.getPath() + " not found"); + } + return f; + } + + protected static File getAtKeysFile(File keysFile, AtSign atSign) { + return keysFile != null ? keysFile : KeysUtil.getKeysFile(atSign); + } + + protected static void checkAtServerMatchesAtSign(AtSecondaryConnection connection, AtSign atSign) throws IOException { + if (!matchDataJsonList(connection.executeCommand("scan")).contains("signing_publickey" + atSign)) { + // TODO: understand precisely what this means (observed in Dart SDK) + throw new IllegalStateException("TBC"); + } + } + + protected static void deleteKey(AtSecondaryConnection connection, String key) { + try { + match(connection.executeCommand("delete:" + key), DATA_INT); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + protected static void authenticateWithApkam(AtSecondaryConnection connection, AtSign atSign, AtKeys keys) throws AtException, IOException { + new AuthUtil().authenticateWithPkam(connection, atSign, keys); + } + + protected AtSecondaryConnection createAtSecondaryConnection(AtSign atSign, + String rootUrl, + int retries) throws Exception { + checkNotNull(atSign, "atsign not set"); + checkNotNull(rootUrl, "root server endpoint not set"); + + String secondaryUrl = resolveSecondaryUrl(atSign, rootUrl, retries); + AtSecondaryConnection conn = new AtSecondaryConnection(new SimpleAtEventBus(), atSign, secondaryUrl, null, false, verbose); + int retriesRemaining = retries; + Exception ex; + do { + try { + conn.connect(); + return conn; + } catch (Exception e) { + ex = e; + Thread.sleep(2000); + } + } while (retriesRemaining-- > 0); + throw ex; + } + + protected static String resolveSecondaryUrl(AtSign atSign, String rootUrl, int retries) throws Exception { + int retriesRemaining = retries; + Exception ex; + do { + try { + return new AtRootConnection(rootUrl).lookupAtSign(atSign); + } catch (AtSecondaryNotFoundException e) { + ex = e; + Thread.sleep(1000); + } + } while (retriesRemaining-- > 0); + throw ex; + } + + protected static String encodeKeyValuesAsJson(Object... nameValuePairs) throws Exception { + return encodeAsJson(toObjectMap(nameValuePairs)); + } + + protected static Map toObjectMap(Object... nameValuePairs) { + if ((nameValuePairs.length % 2) != 0) { + throw new IllegalArgumentException("odd number of parameters"); + } + Map map = new HashMap<>(); + for (int i = 0; i < nameValuePairs.length; i++) { + String key = nameValuePairs[i].toString(); + Object value = nameValuePairs[++i]; + if (value instanceof TypedString) { + map.put(key, value.toString()); + } else { + map.put(key, value); + } + } + return map; + } + + protected static String encodeAsJson(Map map) throws JsonProcessingException { + ObjectMapper objectMapper = new ObjectMapper(); + return objectMapper.writeValueAsString(map); + } + + protected static Map decodeJsonMapOfStrings(String json) { + try { + return new ObjectMapper().readValue(json, new TypeReference>() {}); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + protected static Map decodeJsonMapOfObjects(String json) { + try { + return new ObjectMapper().readValue(json, new TypeReference>() {}); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + protected static List decodeJsonList(String json) { + try { + return new ObjectMapper().readValue(json, new TypeReference>() {}); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static List decodeJsonListOfStrings(String json) { + try { + return new ObjectMapper().readValue(json, new TypeReference>() {}); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + protected static String match(String input, Pattern pattern) { + Matcher matcher = pattern.matcher(input); + if (!matcher.matches()) { + throw new RuntimeException("expected [" + pattern + "] but input was : " + input); + } + StringBuilder builder = new StringBuilder(); + if (matcher.groupCount() == 0) { + builder.append(input); + } else { + for (int i = 1; i <= matcher.groupCount(); i++) { + builder.append(matcher.group(i)); + } + } + return builder.toString(); + } + + protected static T match(String input, Pattern pattern, Function transformer) { + return transformer.apply(match(input, pattern)); + } + + protected static String matchDataString(String input) { + return match(input, DATA_NON_WHITESPACE, s -> s); + } + + protected static int matchDataInt(String input) { + return match(input, DATA_INT, Integer::parseInt); + } + + protected static List matchDataJsonList(String input) { + return match(input, DATA_JSON_NO_EMPTY_LIST, AbstractCli::decodeJsonList); + } + + public static List matchDataJsonListOfStrings(String input) { + return match(input, DATA_JSON_NO_EMPTY_LIST, AbstractCli::decodeJsonListOfStrings); + } + + protected static Map matchDataJsonMapOfStrings(String input, boolean allowEmpty) { + return match(input, allowEmpty ? DATA_JSON_MAP : DATA_JSON_NON_EMPTY_MAP, AbstractCli::decodeJsonMapOfStrings); + } + + protected static Map matchDataJsonMapOfObjects(String input, boolean allowEmpty) { + return match(input, allowEmpty ? DATA_JSON_MAP : DATA_JSON_NON_EMPTY_MAP, AbstractCli::decodeJsonMapOfObjects); + } + + protected static Map matchDataJsonMapOfStrings(String input) { + return matchDataJsonMapOfStrings(input, false); + } + + protected static Map matchDataJsonMapOfObjects(String input) { + return matchDataJsonMapOfObjects(input, false); + } + + protected static String ensureNotNull(String value, String defaultValue) { + return value != null ? value : defaultValue; + } + + static class AtSignConverter implements ITypeConverter { + @Override + public AtSign convert(String s) { + return new AtSign(s); + } + } +} diff --git a/at_client/src/main/java/org/atsign/client/cli/Activate.java b/at_client/src/main/java/org/atsign/client/cli/Activate.java new file mode 100644 index 00000000..dee431b2 --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/cli/Activate.java @@ -0,0 +1,497 @@ +package org.atsign.client.cli; + +import org.atsign.client.api.AtKeys; +import org.atsign.client.api.impl.connections.AtSecondaryConnection; +import org.atsign.client.util.*; +import org.atsign.common.AtException; +import org.atsign.common.AtSign; +import org.atsign.common.exceptions.AtUnauthenticatedException; +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +import java.io.File; +import java.io.IOException; +import java.security.NoSuchAlgorithmException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static java.util.Collections.singletonList; +import static java.util.Collections.singletonMap; +import static org.atsign.client.util.EncryptionUtil.*; +import static org.atsign.client.util.EnrollmentId.createEnrollmentId; +import static org.atsign.client.util.KeysUtil.saveKeys; +import static org.atsign.client.util.Preconditions.checkNotNull; + +/** + * Utility (and CommandLineInterface) for onboarding and enrolling atSigns and AtSign application devices + */ +@Command( + mixinStandardHelpOptions = true +) +public class Activate extends AbstractCli implements Callable { + + enum Action {onboard, enroll, otp, list, approve, deny, revoke, unrevoke}; + + public static final String DEFAULT_FIRST_APP = "firstApp"; + public static final String DEFAULT_FIRST_DEVICE = "firstDevice"; + + @Parameters(index = "0", description = "onboard action to perform") + private Action action; + + private String appName; + private String deviceName; + private boolean overwriteKeysFile = false; + private String cramSecret; + private boolean deleteCramKey = true; + private AtKeys keys; + private EnrollmentId enrollmentId; + private String requestStatus = "pending"; + private String otp; + private Map namespaces = new LinkedHashMap<>(); + private int completionRetries = 5; + + + public static void main(String[] args) { + System.exit(execute(args)); + } + + public static int execute(String[] args) { + return new CommandLine(new Activate()) + .setUsageHelpWidth(80) + .setAllowOptionsAsOptionParameters(true) + .execute(args); + } + + public Activate() { + // TODO replace with stack check after JDK upgrade + deleteCramKey = !System.getProperty("test.mode", "false").equalsIgnoreCase("true"); + } + + @Override + public Integer call() throws Exception { + switch (action) { + case onboard: + System.out.println(onboard()); + break; + case otp: + System.out.println(otp()); + break; + case enroll: + enroll(); + complete(completionRetries, 1, TimeUnit.SECONDS); + break; + case list: + list().forEach(System.out::println); + break; + case approve: + approve(); + break; + case deny: + deny(); + break; + case revoke: + revoke(); + break; + case unrevoke: + unrevoke(); + break; + default: + throw new Exception("no action"); + } + return 0; + } + + @Override + protected Activate self() { + return this; + } + + @Option(names = {"-p", "--app"}, description = "The name of the app being enrolled") + public Activate setAppName(String appName) { + this.appName = appName; + return self(); + } + + @Option(names = {"-d", "--device"}, description = " A name for the device on which this app is running") + public Activate setDeviceName(String deviceName) { + this.deviceName = deviceName; + return self(); + } + + public Activate allowOverwriteKeysFile() { + this.overwriteKeysFile = true; + return self(); + } + + @Option(names = {"-c", "--cramkey"}, description = "CRAM key") + public Activate setCramSecret(String cramSecret) { + this.cramSecret = cramSecret; + return self(); + } + + public Activate setNoDeleteCramKey() { + this.deleteCramKey = false; + return self(); + } + + @Option(names = {"-i", "--enrollmentId"}, description = "the ID of the enrollment request") + public void setEnrollmentId(String s) { + this.enrollmentId = EnrollmentId.createEnrollmentId(s); + } + + @Option(names = {"-es", " --enrollmentStatus"}, description = "A specific status to filter by", defaultValue = "pending") + public void setRequestStatus(String s) { + this.requestStatus = s; + } + + @Option(names = {"-s", "--passcode"}, description = "passcode to present with this enrollment request (OTP)") + public Activate setOtp(String otp) { + this.otp = otp; + return self(); + } + + @Option(names = {"-n", "--namespaces"}, description = "the namespace access list as comma-separated list " + + "of name:value pairs e.g. \"ns:rw,contacts:rw,__manage:rw\"") + public Activate setNamespaces(String namespaces) { + for (String namespace : namespaces.split(",")) { + String[] parts = namespace.split(":"); + addNamespace(parts[0], parts[1]); + } + return self(); + } + + @Option(names = {"--max-retries"}, defaultValue = "5", description = " number of times to check for approval before giving up") + public Activate setCompletionRetries(int completionRetries) { + this.completionRetries = completionRetries; + return self(); + } + + public Activate addNamespace(String namespace, String accessControl) { + this.namespaces.put(namespace, accessControl); + return self(); + } + + public EnrollmentId onboard() throws Exception { + try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, connectionRetries)) { + return onboard(connection); + } + } + + public EnrollmentId onboard(AtSecondaryConnection connection) throws Exception { + File file = getAtKeysFile(keysFile, atSign); + if (!overwriteKeysFile) { + checkNotExists(file); + } + checkAtServerMatchesAtSign(connection, atSign); + authenticateWithCram(connection, atSign, cramSecret); + AtKeys keys = generateAtKeys(true); + EnrollmentId enrollmentId = enroll(connection, + keys, + ensureNotNull(appName, DEFAULT_FIRST_APP), + ensureNotNull(deviceName, DEFAULT_FIRST_DEVICE)); + keys.setEnrollmentId(enrollmentId); + authenticateWithApkam(connection, atSign, keys); + saveKeys(keys, file); + storeEncryptPublicKey(connection, atSign, keys); + if (deleteCramKey) { + deleteCramSecret(connection); + } + return enrollmentId; + } + + public Activate authenticate(AtSecondaryConnection connection) throws Exception { + File file = checkExists(getAtKeysFile(keysFile, atSign)); + keys = KeysUtil.loadKeys(file); + authenticateWithApkam(connection, atSign, keys); + return this; + } + + public List list() throws Exception { + return list(requestStatus); + } + + public List list(String status) throws Exception { + try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, connectionRetries)) { + return authenticate(connection).list(connection, status); + } + } + + public List list(AtSecondaryConnection connection, String status) throws Exception { + String command = "enroll:list:" + encodeAsJson(singletonMap("enrollmentStatusFilter", singletonList(status))); + return matchDataJsonMapOfObjects(connection.executeCommand(command), true).keySet().stream() + .map(Activate::inferEnrollmentId) + .collect(Collectors.toList()); + } + + private static EnrollmentId inferEnrollmentId(String key) { + Matcher matcher = Pattern.compile("^([^\\.]+).+").matcher(key); + if (key.contains("__manage") && matcher.matches()) { + return EnrollmentId.createEnrollmentId(matcher.group(1)); + } else { + throw new RuntimeException(key + " doesn't match expected enrollment key pattern"); + } + } + + public void approve() throws Exception { + approve(checkNotNull(enrollmentId, "enrollment id not set")); + } + + public void approve(EnrollmentId enrollmentId) throws Exception { + try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, connectionRetries)) { + authenticate(connection).approve(connection, enrollmentId); + } + } + + public void approve(AtSecondaryConnection connection, EnrollmentId enrollmentId) throws Exception { + String key = fetchApkamSymmetricKey(connection, enrollmentId); + String privateKeyIv = generateRandomIvBase64(16); + String encryptPrivateKey = aesEncryptToBase64(keys.getEncryptPrivateKey(), key, privateKeyIv); + String selfKeyIv = generateRandomIvBase64(16); + String selfEncryptKey = aesEncryptToBase64(keys.getSelfEncryptKey(), key, selfKeyIv); + String json = encodeKeyValuesAsJson("enrollmentId", enrollmentId, + "encryptedDefaultEncryptionPrivateKey", encryptPrivateKey, + "encPrivateKeyIV", privateKeyIv, + "encryptedDefaultSelfEncryptionKey", selfEncryptKey, + "selfEncKeyIV", selfKeyIv); + + Map response = matchDataJsonMapOfStrings(connection.executeCommand("enroll:approve:" + json)); + if (!"approved".equals(response.get("status"))) { + throw new RuntimeException("status is not approved : " + response.get("status")); + } + } + + private String fetchApkamSymmetricKey(AtSecondaryConnection connection, EnrollmentId enrollmentId) throws Exception { + String command = "enroll:fetch:" + encodeKeyValuesAsJson("enrollmentId", enrollmentId); + Map request = matchDataJsonMapOfObjects(connection.executeCommand(command)); + if (!"pending".equals(request.get("status"))) { + throw new RuntimeException("status is not pending : " + request.get("status")); + } + String encryptedApkamSymmetricKey = (String) request.get("encryptedAPKAMSymmetricKey"); + return rsaDecryptFromBase64(encryptedApkamSymmetricKey, keys.getEncryptPrivateKey()); + } + + public void deny() throws Exception { + deny(checkNotNull(enrollmentId, "enrollment id not set")); + } + + public void deny(EnrollmentId enrollmentId) throws Exception { + try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, connectionRetries)) { + authenticate(connection).deny(connection, enrollmentId); + } + } + + public void revoke() throws Exception { + revoke(checkNotNull(enrollmentId, "enrollment id not set")); + } + + public void revoke(EnrollmentId enrollmentId) throws Exception { + try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, connectionRetries)) { + authenticate(connection).revoke(connection, enrollmentId); + } + } + + public void unrevoke() throws Exception { + unrevoke(checkNotNull(enrollmentId, "enrollment id not set")); + } + + public void unrevoke(EnrollmentId enrollmentId) throws Exception { + try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, connectionRetries)) { + authenticate(connection).unrevoke(connection, enrollmentId); + } + } + + public void delete(EnrollmentId enrollmentId) throws Exception { + try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, connectionRetries)) { + authenticate(connection).delete(connection, enrollmentId); + } + } + + public void deny(AtSecondaryConnection connection, EnrollmentId enrollmentId) throws Exception { + singleArgEnrollAction(connection, "deny", enrollmentId, "denied"); + } + + public void revoke(AtSecondaryConnection connection, EnrollmentId enrollmentId) throws Exception { + singleArgEnrollAction(connection, "revoke", enrollmentId, "revoked"); + } + + public void unrevoke(AtSecondaryConnection connection, EnrollmentId enrollmentId) throws Exception { + singleArgEnrollAction(connection, "unrevoke", enrollmentId, "approved"); + } + + public void delete(AtSecondaryConnection connection, EnrollmentId enrollmentId) throws Exception { + singleArgEnrollAction(connection, "delete", enrollmentId, "deleted"); + } + + public String otp() throws Exception { + try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, connectionRetries)) { + return otp(connection); + } + } + + public String otp(AtSecondaryConnection connection) throws IOException, AtException { + File file = checkExists(getAtKeysFile(keysFile, atSign)); + AtKeys keys = KeysUtil.loadKeys(file); + authenticateWithApkam(connection, atSign, keys); + return match(connection.executeCommand("otp:get"), DATA_NON_WHITESPACE); + } + + public List scan() throws Exception { + try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, connectionRetries)) { + return scan(connection); + } + } + + public List scan(AtSecondaryConnection connection) throws Exception { + return matchDataJsonListOfStrings(connection.executeCommand("scan:showHidden:true .*")); + } + + private void singleArgEnrollAction(AtSecondaryConnection connection, + String action, + EnrollmentId enrollmentId, + String expectedStatus) throws Exception { + String command = "enroll:" + action + ":" + encodeKeyValuesAsJson("enrollmentId", enrollmentId); + Map map = matchDataJsonMapOfStrings(connection.executeCommand(command)); + if (!expectedStatus.equals(map.get("status"))) { + throw new RuntimeException("status is not " + expectedStatus + " : " + map.get("status")); + } + } + + protected static void authenticateWithCram(AtSecondaryConnection connection, AtSign atSign, String cramSecret) throws AtException, IOException { + checkNotNull(cramSecret, "CRAM secret not set"); + new AuthUtil().authenticateWithCram(connection, atSign, cramSecret); + } + + private static EnrollmentId enroll(AtSecondaryConnection connection, + AtKeys keys, + String appName, + String deviceName) throws Exception { + String json = encodeKeyValuesAsJson( + "appName", appName, + "deviceName", deviceName, + "apkamPublicKey", keys.getApkamPublicKey() + ); + Map response = matchDataJsonMapOfStrings(connection.executeCommand("enroll:request:" + json)); + if (!response.get("status").equals("approved")) { + throw new RuntimeException("enroll request failed, expected status approved : " + response); + } + return createEnrollmentId(response.get("enrollmentId")); + } + + protected static void storeEncryptPublicKey(AtSecondaryConnection connection, AtSign atSign, AtKeys keys) throws IOException { + match(connection.executeCommand("update:public:publickey" + atSign + " " + keys.getEncryptPublicKey()), DATA_INT); + } + + protected static void deleteCramSecret(AtSecondaryConnection connection) { + deleteKey(connection, "privatekey:at_secret"); + } + + protected static AtKeys generateAtKeys(boolean generateEncryptionKeyPair) throws NoSuchAlgorithmException { + AtKeys keys = new AtKeys() + .setSelfEncryptKey(generateAESKeyBase64()) + .setApkamKeyPair(generateRSAKeyPair()) + .setApkamSymmetricKey(generateAESKeyBase64()); + if (generateEncryptionKeyPair) { + keys.setEncryptKeyPair(generateRSAKeyPair()); + } + return keys; + } + + public EnrollmentId enroll() throws Exception { + try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, connectionRetries)) { + return enroll(connection); + } + } + + public EnrollmentId enroll(AtSecondaryConnection connection) throws Exception { + String publicKey = matchDataString(connection.executeCommand("lookup:publickey" + atSign)); + File file = keysFile; + if (!overwriteKeysFile) { + checkNotExists(file); + } + AtKeys keys = generateAtKeys(false); + keys.setEncryptPublicKey(publicKey); + keys.setEnrollmentId(enroll(connection, keys)); + KeysUtil.saveKeys(keys, keysFile); + return keys.getEnrollmentId(); + } + + private EnrollmentId enroll(AtSecondaryConnection connection, AtKeys keys) throws Exception { + System.out.println(keys.getApkamSymmetricKey()); + Map args = toObjectMap("appName", appName, + "deviceName", deviceName, + "apkamPublicKey", keys.getApkamPublicKey(), + "encryptedAPKAMSymmetricKey", rsaEncryptToBase64(keys.getApkamSymmetricKey(), keys.getEncryptPublicKey()), + "otp", otp, + "namespaces", namespaces, + "apkamKeysExpiryInMillis", 0); + String command = "enroll:request:" + encodeAsJson(args); + Map response = matchDataJsonMapOfStrings(connection.executeCommand(command)); + if ("pending".equals(response.get("status"))) { + return EnrollmentId.createEnrollmentId(response.get("enrollmentId")); + } else { + throw new RuntimeException("expected status pending : " + response); + } + } + + public void complete() throws Exception { + try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, connectionRetries)) { + complete(connection); + } + } + + public void complete(AtSecondaryConnection connection) throws Exception { + AtKeys keys = KeysUtil.loadKeys(keysFile); + authenticate(connection); + keys.setSelfEncryptKey(keysGetDecrypted(connection, atSign, keys, "default_self_enc_key")); + keys.setEncryptPrivateKey(keysGetDecrypted(connection, atSign, keys, "default_enc_private_key")); + KeysUtil.saveKeys(keys, keysFile); + } + + public void complete(int retries, long sleepDuration, TimeUnit sleepUnit) throws Exception { + try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, connectionRetries)) { + complete(connection, retries, sleepDuration, sleepUnit); + } + } + + public void complete(AtSecondaryConnection connection, int retries, long sleepDuration, TimeUnit sleepUnit) throws Exception { + Exception exception; + int remainingRetries = retries; + do { + Thread.sleep(sleepUnit.toMillis(sleepDuration)); + try { + complete(connection); + return; + } catch (AtUnauthenticatedException e) { + exception = e.getMessage().contains("is pending") ? null : e; + } catch (Exception e) { + exception = e; + } + } while (exception == null && remainingRetries-- > 0); + + throw exception != null ? exception : new IllegalArgumentException(); + } + + private static String keysGetDecrypted(AtSecondaryConnection connection, + AtSign atSign, + AtKeys keys, + String keyConstant) throws Exception { + EnrollmentId enrollmentId = keys.getEnrollmentId(); + String command = "keys:get:keyName:" + enrollmentId + "." + keyConstant + ".__manage" + atSign; + return decryptEncryptedKey(connection.executeCommand(command), keys.getApkamSymmetricKey()); + } + + protected static String decryptEncryptedKey(String json, String keyBase64) throws Exception { + Map map = matchDataJsonMapOfStrings(json); + String encryptedKey = map.get("value"); + String iv = map.get("iv"); + return aesDecryptFromBase64(encryptedKey, keyBase64, iv); + } +} diff --git a/at_client/src/main/java/org/atsign/client/cli/Delete.java b/at_client/src/main/java/org/atsign/client/cli/Delete.java index 7d871a40..cdb5f1b7 100644 --- a/at_client/src/main/java/org/atsign/client/cli/Delete.java +++ b/at_client/src/main/java/org/atsign/client/cli/Delete.java @@ -2,6 +2,7 @@ import org.atsign.client.api.AtClient; import org.atsign.client.util.ArgsUtil; +import org.atsign.client.util.KeysUtil; import org.atsign.common.AtException; import org.atsign.common.AtSign; import org.atsign.common.KeyBuilders; @@ -31,7 +32,7 @@ public static void main(String[] args) { AtClient atClient = null; try { - atClient = AtClient.withRemoteSecondary(atSign, ArgsUtil.createAddressFinder(rootUrl)); + atClient = AtClient.withRemoteSecondary(atSign, KeysUtil.loadKeys(atSign), ArgsUtil.createAddressFinder(rootUrl)); } catch (AtException e) { System.err.println("Failed to create AtClientImpl : " + e.getMessage()); e.printStackTrace(System.err); diff --git a/at_client/src/main/java/org/atsign/client/cli/DumpKeys.java b/at_client/src/main/java/org/atsign/client/cli/DumpKeys.java index 8c2fdd1e..f32f4c3b 100644 --- a/at_client/src/main/java/org/atsign/client/cli/DumpKeys.java +++ b/at_client/src/main/java/org/atsign/client/cli/DumpKeys.java @@ -3,13 +3,8 @@ import org.atsign.client.util.KeysUtil; import org.atsign.common.AtSign; -import java.util.Map; - public class DumpKeys { public static void main(String[] args) throws Exception { - Map keys = KeysUtil.loadKeys(new AtSign(args[0])); - for (String key : keys.keySet()) { - System.out.println("\tkey: " + key + "\n\t\tvalue: " + keys.get(key) + "\n"); - } + System.out.println(KeysUtil.dump(KeysUtil.loadKeys(new AtSign(args[0])))); } } diff --git a/at_client/src/main/java/org/atsign/client/cli/Get.java b/at_client/src/main/java/org/atsign/client/cli/Get.java index b63afb65..fcc1048a 100644 --- a/at_client/src/main/java/org/atsign/client/cli/Get.java +++ b/at_client/src/main/java/org/atsign/client/cli/Get.java @@ -2,6 +2,7 @@ import org.atsign.client.api.AtClient; import org.atsign.client.util.ArgsUtil; +import org.atsign.client.util.KeysUtil; import org.atsign.common.AtException; import org.atsign.common.AtSign; import org.atsign.common.KeyBuilders; @@ -32,7 +33,7 @@ public static void main(String[] args) { AtClient atClient = null; try { - atClient = AtClient.withRemoteSecondary(atSign, ArgsUtil.createAddressFinder(rootUrl)); + atClient = AtClient.withRemoteSecondary(atSign, KeysUtil.loadKeys(atSign), ArgsUtil.createAddressFinder(rootUrl)); } catch (AtException e) { System.err.println("Failed to create AtClientImpl : " + e.getMessage()); e.printStackTrace(System.err); diff --git a/at_client/src/main/java/org/atsign/client/cli/Onboard.java b/at_client/src/main/java/org/atsign/client/cli/Onboard.java deleted file mode 100644 index e8c1a676..00000000 --- a/at_client/src/main/java/org/atsign/client/cli/Onboard.java +++ /dev/null @@ -1,114 +0,0 @@ -package org.atsign.client.cli; - -import org.atsign.client.api.impl.events.SimpleAtEventBus; -import org.atsign.common.AtSign; -import org.atsign.client.api.impl.connections.AtSecondaryConnection; -import org.atsign.client.api.impl.connections.AtRootConnection; -import org.atsign.client.util.AuthUtil; -import org.atsign.client.util.KeysUtil; -import org.atsign.client.util.OnboardingUtil; -import org.atsign.common.exceptions.AtSecondaryNotFoundException; - -import java.io.IOException; -import java.util.HashMap; -import java.util.Map; - -/** - * Important utility which 'onboards' a new atSign. - * Once onboarding is complete it creates the all-important keys file - */ -public class Onboard { - public static void main(String[] args) throws Exception { - if (args.length != 3) { - System.err.println("Usage: Onboard "); - System.exit(1); - } - - String rootUrl = args[0]; // e.g. "root.atsign.org:64"; - AtSign atSign = new AtSign(args[1]); // e.g. "@alice"; - String cramSecret = args[2]; - - System.out.println("Looking up secondary server address for " + atSign); - String secondaryUrl; - try { - secondaryUrl = new AtRootConnection(rootUrl).lookupAtSign(atSign); - } catch (AtSecondaryNotFoundException e) { - secondaryUrl = retrySecondaryConnection(rootUrl, atSign); - } - - System.out.println("Got address: " + secondaryUrl); - - System.out.println("Connecting to " + secondaryUrl); - AtSecondaryConnection conn = new AtSecondaryConnection(new SimpleAtEventBus(), atSign, secondaryUrl, null, false, true); - try{ - conn.connect(); - } catch (Exception e){ - Thread.sleep(2000); - conn.connect(); - } - - AuthUtil auth = new AuthUtil(); - OnboardingUtil onboardingUtil = new OnboardingUtil(); - - System.out.println("Authenticating with CRAM"); - auth.authenticateWithCram(conn, atSign, cramSecret); - System.out.println("Authenticating with CRAM succeeded"); - - // We've authenticated with CRAM; let's generate and store the various keys we need - Map keys = new HashMap<>(); - System.out.println("Generating symmetric 'self' encryption key"); - onboardingUtil.generateSelfEncryptionKey(keys); - - System.out.println("Generating PKAM keypair"); - onboardingUtil.generatePkamKeypair(keys); - - System.out.println("Generating asymmetric encryption keypair"); - onboardingUtil.generateEncryptionKeypair(keys); - - // Finally, let's store all the keys to a .keys file - System.out.println("Saving keys to file"); - KeysUtil.saveKeys(atSign, keys); - - // we're authenticated, let's store the PKAM public key to the secondary - System.out.println("Storing PKAM public key on cloud secondary"); - onboardingUtil.storePkamPublicKey(conn, keys); - - // and now that the PKAM public key is on the server, let's auth via PKAM - System.out.println("Authenticating with PKAM"); - auth.authenticateWithPkam(conn, atSign, keys); - System.out.println("Authenticating with PKAM succeeded"); - - System.out.println("Storing encryption public key"); - onboardingUtil.storePublicEncryptionKey(conn, atSign, keys); - - // and as we've successfully authenticated with PKAM, let's delete the CRAM secret - System.out.println("Deleting CRAM secret"); - onboardingUtil.deleteCramKey(conn); - - System.out.println("Onboarding complete"); - } - - static String retrySecondaryConnection(String rootUrl, AtSign atSign) - throws IOException, AtSecondaryNotFoundException, InterruptedException { - - int retryCount = 0; - final int maxRetries = 50; - String secondaryUrl = ""; - - Thread.sleep(1000); - - while (retryCount < maxRetries && secondaryUrl.equals("")) { - try { - secondaryUrl = new AtRootConnection(rootUrl).lookupAtSign(atSign); - } catch (AtSecondaryNotFoundException e) { - System.out.println("Retrying fetching secondary address ... attempt " + ++retryCount + "/" + maxRetries); - } - } - - if (secondaryUrl.equals("")) { - throw new AtSecondaryNotFoundException("Root lookup returned null for " + atSign); - } - - return secondaryUrl; - } -} diff --git a/at_client/src/main/java/org/atsign/client/cli/REPL.java b/at_client/src/main/java/org/atsign/client/cli/REPL.java index c431c268..e4278f27 100644 --- a/at_client/src/main/java/org/atsign/client/cli/REPL.java +++ b/at_client/src/main/java/org/atsign/client/cli/REPL.java @@ -6,6 +6,7 @@ import org.atsign.client.api.Secondary; import org.atsign.client.util.ArgsUtil; import org.atsign.client.util.KeyStringUtil; +import org.atsign.client.util.KeysUtil; import org.atsign.common.AtException; import org.atsign.common.AtSign; import org.atsign.common.Keys; @@ -53,7 +54,7 @@ public static void main(String[] args) { AtClient atClient; try { System.out.print(ansi().cursorToColumn(0).bold().fg(Ansi.Color.BLUE).a("Connecting ... ").reset()); - atClient = AtClient.withRemoteSecondary(atSign, ArgsUtil.createAddressFinder(rootUrl), verbose); + atClient = AtClient.withRemoteSecondary(atSign, KeysUtil.loadKeys(atSign), ArgsUtil.createAddressFinder(rootUrl), verbose); System.out.println(ansi().fg(Ansi.Color.GREEN).a("connected. ").reset().a("Type '/help' to see help").reset()); diff --git a/at_client/src/main/java/org/atsign/client/cli/Register.java b/at_client/src/main/java/org/atsign/client/cli/Register.java index 95b9f411..351cd900 100644 --- a/at_client/src/main/java/org/atsign/client/cli/Register.java +++ b/at_client/src/main/java/org/atsign/client/cli/Register.java @@ -60,9 +60,10 @@ public String call() throws Exception { } String[] onboardArgs = new String[] { - params.get("rootDomain") + ":" + params.get("rootPort"), - params.get("atSign"), params.get("cram") }; - Onboard.main(onboardArgs); + "-r", params.get("rootDomain") + ":" + params.get("rootPort"), + "-a", params.get("atSign"), + "-c", params.get("cram") }; + Activate.main(onboardArgs); return "Done."; } diff --git a/at_client/src/main/java/org/atsign/client/cli/Scan.java b/at_client/src/main/java/org/atsign/client/cli/Scan.java index 446793d6..c3eacd8c 100644 --- a/at_client/src/main/java/org/atsign/client/cli/Scan.java +++ b/at_client/src/main/java/org/atsign/client/cli/Scan.java @@ -4,6 +4,7 @@ import org.atsign.client.api.AtClient; import org.atsign.client.api.Secondary; import org.atsign.client.util.ArgsUtil; +import org.atsign.client.util.KeysUtil; import org.atsign.common.AtSign; import org.atsign.common.Keys.AtKey; import org.atsign.common.Metadata; @@ -54,7 +55,7 @@ public static void main(String[] args) { AtClient atClient = null; try { what = "initialize AtClient"; - atClient = AtClient.withRemoteSecondary(atSign, sAddress, verbose); + atClient = AtClient.withRemoteSecondary(atSign, KeysUtil.loadKeys(atSign), sAddress, verbose); } catch (AtException e) { System.err.println("Failed to " + what + " " + e.getMessage()); e.printStackTrace(System.err); diff --git a/at_client/src/main/java/org/atsign/client/cli/Share.java b/at_client/src/main/java/org/atsign/client/cli/Share.java index 3bed09de..30d68b6c 100644 --- a/at_client/src/main/java/org/atsign/client/cli/Share.java +++ b/at_client/src/main/java/org/atsign/client/cli/Share.java @@ -3,6 +3,7 @@ import org.atsign.client.api.AtClient; import org.atsign.client.api.Secondary; import org.atsign.client.util.ArgsUtil; +import org.atsign.client.util.KeysUtil; import org.atsign.common.AtException; import org.atsign.common.AtSign; import org.atsign.common.Keys; @@ -48,7 +49,7 @@ public static void main(String[] args) { AtClient atClient = null; try { - atClient = AtClient.withRemoteSecondary(atSign, addressFinder); + atClient = AtClient.withRemoteSecondary(atSign, KeysUtil.loadKeys(atSign), addressFinder); } catch (AtException e) { System.err.println("Failed to create AtClientImpl : " + e.getMessage()); e.printStackTrace(System.err); diff --git a/at_client/src/main/java/org/atsign/client/util/AuthUtil.java b/at_client/src/main/java/org/atsign/client/util/AuthUtil.java index 55ae451f..3ab53eed 100644 --- a/at_client/src/main/java/org/atsign/client/util/AuthUtil.java +++ b/at_client/src/main/java/org/atsign/client/util/AuthUtil.java @@ -1,6 +1,7 @@ package org.atsign.client.util; import org.atsign.client.api.AtConnection; +import org.atsign.client.api.AtKeys; import org.atsign.client.api.impl.connections.AtSecondaryConnection; import org.atsign.common.AtSign; import org.atsign.common.exceptions.AtClientConfigException; @@ -13,7 +14,6 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; -import java.util.Map; /** * @@ -39,9 +39,9 @@ public void authenticateWithCram(AtSecondaryConnection connection, AtSign atSign } } - public void authenticateWithPkam(AtConnection connection, AtSign atSign, Map keys) throws AtException, IOException { - if (! keys.containsKey(KeysUtil.pkamPrivateKeyName)) { - throw new AtClientConfigException("Cannot authenticate with PKAM: Keys file does not contain " + KeysUtil.pkamPrivateKeyName); + public void authenticateWithPkam(AtConnection connection, AtSign atSign, AtKeys keys) throws AtException, IOException { + if (! keys.hasPkamKeys()) { + throw new AtClientConfigException("Cannot authenticate with PKAM: Keys file does not contain PKAM keys"); } String fromResponse = connection.executeCommand("from:" + atSign); @@ -54,7 +54,7 @@ public void authenticateWithPkam(AtConnection connection, AtSign atSign, Map> STRING_MAP_TYPE = new TypeReference>() { + }; public static final String ATSIGN_KEYS_DIR = "ATSIGN_KEYS_DIR"; public static final String ATSIGN_KEYS_SUFFIX = "ATSIGN_KEYS_SUFFIX"; @@ -33,40 +42,43 @@ public class KeysUtil { "_key.atKeys" ); - public static final String pkamPublicKeyName = "aesPkamPublicKey"; - public static final String pkamPrivateKeyName = "aesPkamPrivateKey"; - public static final String encryptionPublicKeyName = "aesEncryptPublicKey"; - public static final String encryptionPrivateKeyName = "aesEncryptPrivateKey"; - public static final String selfEncryptionKeyName = "selfEncryptionKey"; + /** + * NB: These values are used in the JSON representation (atKeys file contents) and MUST match those used in other SDK impls + */ + private static final String PKAM_PUBLIC_KEY = "aesPkamPublicKey"; + private static final String PKAM_PRIVATE_KEY = "aesPkamPrivateKey"; + private static final String ENCRYPT_PUBLIC_KEY = "aesEncryptPublicKey"; + private static final String ENCRYPT_PRIVATE_KEY = "aesEncryptPrivateKey"; + private static final String SELF_ENCRYPT_KEY = "selfEncryptionKey"; + private static final String APKAM_SYMMETRIC_KEY = "apkamSymmetricKey"; + private static final String ENROLLMENT_ID = "enrollmentId"; + + public static void saveKeys(AtSign atSign, AtKeys keys) throws Exception { + saveKeys(keys, getKeysFile(atSign)); + } - public static void saveKeys(AtSign atSign, Map keys) throws Exception { - File expectedKeysDirectory = new File(expectedKeysFilesLocation); - if (! expectedKeysDirectory.exists()) { - Files.createDirectories(expectedKeysDirectory.toPath()); + public static void saveKeys(AtKeys keys, File file) throws IOException { + if (file.getParentFile() != null && !file.getParentFile().exists()) { + Files.createDirectories(file.getParentFile().toPath()); } - File file = getKeysFile(atSign, expectedKeysFilesLocation); System.out.println("Saving keys to " + file.getAbsolutePath()); - String selfEncryptionKey = keys.get(selfEncryptionKeyName); - - Map encryptedKeys = new TreeMap<>(); + Files.write(file.toPath(), getAsJson(keys).getBytes(UTF_8)); + } - // We encrypt all the keys with the AES self encryption key (which is left unencrypted) - encryptedKeys.put(selfEncryptionKeyName, selfEncryptionKey); - encryptedKeys.put(pkamPublicKeyName, - EncryptionUtil.aesEncryptToBase64(keys.get(pkamPublicKeyName), selfEncryptionKey)); - encryptedKeys.put(pkamPrivateKeyName, - EncryptionUtil.aesEncryptToBase64(keys.get(pkamPrivateKeyName), selfEncryptionKey)); - encryptedKeys.put(encryptionPublicKeyName, - EncryptionUtil.aesEncryptToBase64(keys.get(encryptionPublicKeyName), selfEncryptionKey)); - encryptedKeys.put(encryptionPrivateKeyName, - EncryptionUtil.aesEncryptToBase64(keys.get(encryptionPrivateKeyName), selfEncryptionKey)); + public static AtKeys loadKeys(AtSign atSign) throws AtClientConfigException { + return loadKeys(getKeysFileFallbackToLegacyLocation(atSign)); + } - String json = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(encryptedKeys); - Files.write(file.toPath(), json.getBytes(StandardCharsets.UTF_8)); + public static AtKeys loadKeys(File file) throws AtClientConfigException { + try { + return setAtKeysFromJson(new AtKeys(), new String(Files.readAllBytes(file.toPath()), UTF_8)); + } catch (IOException e) { + throw new AtClientConfigException("failed to read " + file, e); + } } - public static Map loadKeys(AtSign atSign) throws Exception { + private static File getKeysFileFallbackToLegacyLocation(AtSign atSign) throws AtClientConfigException { // check first if file exists at canonical location ~/.atsign/keys/$atSign_key.atKeys File file = getKeysFile(atSign, expectedKeysFilesLocation); @@ -80,24 +92,11 @@ public static Map loadKeys(AtSign atSign) throws Exception { "\t Keys files are expected to be in ~/.atsign/keys/ (canonical location) or ./keys/ (legacy location)"); } } - String json = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); - @SuppressWarnings("unchecked") - Map encryptedKeys = mapper.readValue(json, Map.class); - - // All the keys are encrypted with the AES self encryption key (which is left unencrypted) - String selfEncryptionKey = encryptedKeys.get(selfEncryptionKeyName); - - Map keys = new HashMap<>(); - keys.put(selfEncryptionKeyName, selfEncryptionKey); - keys.put(pkamPublicKeyName, - EncryptionUtil.aesDecryptFromBase64(encryptedKeys.get(pkamPublicKeyName), selfEncryptionKey)); - keys.put(pkamPrivateKeyName, - EncryptionUtil.aesDecryptFromBase64(encryptedKeys.get(pkamPrivateKeyName), selfEncryptionKey)); - keys.put(encryptionPublicKeyName, - EncryptionUtil.aesDecryptFromBase64(encryptedKeys.get(encryptionPublicKeyName), selfEncryptionKey)); - keys.put(encryptionPrivateKeyName, - EncryptionUtil.aesDecryptFromBase64(encryptedKeys.get(encryptionPrivateKeyName), selfEncryptionKey)); - return keys; + return file; + } + + public static File getKeysFile(AtSign atSign) { + return getKeysFile(atSign, expectedKeysFilesLocation); } public static File getKeysFile(AtSign atSign, String folderToLookIn) { @@ -106,10 +105,99 @@ public static File getKeysFile(AtSign atSign, String folderToLookIn) { private static String getFirstNonEmpty(String... candidates) { for (String candidate : candidates) { - if (candidate != null && candidate.trim().length() > 0) { + if (candidate != null && !candidate.trim().isEmpty()) { return candidate; } } throw new IllegalArgumentException("all candidates are null"); } + + private static String getAsJson(AtKeys keys) { + try { + Map map = new TreeMap<>(); + + mapPut(map, SELF_ENCRYPT_KEY, keys.getSelfEncryptKey()); + mapPut(map, ENROLLMENT_ID, keys.getEnrollmentId()); + mapPut(map, APKAM_SYMMETRIC_KEY, keys.getApkamSymmetricKey()); + + mapPutEncrypted(map, PKAM_PUBLIC_KEY, keys.getApkamPublicKey(), keys.getSelfEncryptKey()); + mapPutEncrypted(map, PKAM_PRIVATE_KEY, keys.getApkamPrivateKey(), keys.getSelfEncryptKey()); + mapPutEncrypted(map, ENCRYPT_PUBLIC_KEY, keys.getEncryptPublicKey(), keys.getSelfEncryptKey()); + mapPutEncrypted(map, ENCRYPT_PRIVATE_KEY, keys.getEncryptPrivateKey(), keys.getSelfEncryptKey()); + + return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(map); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static AtKeys setAtKeysFromJson(AtKeys keys, String json) { + try { + Map map = MAPPER.readValue(json, STRING_MAP_TYPE); + + keys.setSelfEncryptKey(mapGet(map, SELF_ENCRYPT_KEY)); + keys.setEnrollmentId(mapGetEnrollmentId(map, ENROLLMENT_ID)); + keys.setApkamSymmetricKey(mapGet(map, APKAM_SYMMETRIC_KEY)); + + keys.setApkamPublicKey(mapGetDecrypted(map, PKAM_PUBLIC_KEY, keys.getSelfEncryptKey())); + keys.setApkamPrivateKey(mapGetDecrypted(map, PKAM_PRIVATE_KEY, keys.getSelfEncryptKey())); + keys.setEncryptPublicKey(mapGetDecrypted(map, ENCRYPT_PUBLIC_KEY, keys.getSelfEncryptKey())); + keys.setEncryptPrivateKey(mapGetDecrypted(map, ENCRYPT_PRIVATE_KEY, keys.getSelfEncryptKey())); + } catch (Exception e) { + throw new RuntimeException(e); + } + return keys; + } + + private static void mapPut(Map map, String key, String value) { + if (value != null) { + map.put(key, value); + } + } + + private static void mapPut(Map map, String key, TypedString value) { + if (value != null) { + map.put(key, value.toString()); + } + } + + private static void mapPutEncrypted(Map map, String key, String value, String encryptKey) throws Exception { + if (value != null) { + map.put(key, aesEncryptToBase64(value, encryptKey)); + } + } + + private static EnrollmentId mapGetEnrollmentId(Map map, String key) { + return createEnrollmentId(map.get(key)); + } + + private static String mapGet(Map map, String key) { + return map.get(key); + } + + private static String mapGetDecrypted(Map map, String key, String decryptKey) throws Exception { + String value = map.get(key); + return value != null ? aesDecryptFromBase64(value, decryptKey) : null; + } + + public static String dump(AtKeys keys) { + StringBuilder builder = new StringBuilder(); + builderAppend(builder, ENROLLMENT_ID, keys.getEnrollmentId()); + builderAppend(builder, PKAM_PUBLIC_KEY, keys.getApkamPublicKey()); + builderAppend(builder, PKAM_PRIVATE_KEY, keys.getApkamPrivateKey()); + builderAppend(builder, ENCRYPT_PUBLIC_KEY, keys.getEncryptPublicKey()); + builderAppend(builder, ENCRYPT_PRIVATE_KEY, keys.getEncryptPrivateKey()); + builderAppend(builder, APKAM_SYMMETRIC_KEY, keys.getApkamSymmetricKey()); + builderAppend(builder, SELF_ENCRYPT_KEY, keys.getSelfEncryptKey()); + for (Map.Entry entry : keys.getCache().entrySet()) { + builderAppend(builder, entry.getKey(), entry.getValue()); + } + return builder.toString(); + } + + private static void builderAppend(StringBuilder builder, String key, Object value) { + if (value != null) { + builder.append("\tkey: ").append(key).append("\n\t\tvalue: ").append(value).append("\n"); + } + } } diff --git a/at_client/src/main/java/org/atsign/client/util/OnboardingUtil.java b/at_client/src/main/java/org/atsign/client/util/OnboardingUtil.java deleted file mode 100644 index 2984e512..00000000 --- a/at_client/src/main/java/org/atsign/client/util/OnboardingUtil.java +++ /dev/null @@ -1,58 +0,0 @@ -package org.atsign.client.util; - -import org.atsign.common.AtSign; -import org.atsign.client.api.impl.connections.AtSecondaryConnection; - -import java.io.IOException; -import java.security.*; -import java.util.Base64; -import java.util.Map; - -public class OnboardingUtil { - public void generatePkamKeypair(Map keys) throws NoSuchAlgorithmException { - // generate pkam keypair; store to files - KeyPair keyPair = EncryptionUtil.generateRSAKeyPair(); - PublicKey publicKey = keyPair.getPublic(); - PrivateKey privateKey = keyPair.getPrivate(); - - String publicKeyString = Base64.getEncoder().encodeToString(publicKey.getEncoded()); - String privateKeyString = Base64.getEncoder().encodeToString(privateKey.getEncoded()); - - keys.put(KeysUtil.pkamPublicKeyName, publicKeyString); - keys.put(KeysUtil.pkamPrivateKeyName, privateKeyString); - } - - public void generateEncryptionKeypair(Map keys) throws NoSuchAlgorithmException { - // generate pkam keypair; store to files - KeyPair keyPair = EncryptionUtil.generateRSAKeyPair(); - PublicKey publicKey = keyPair.getPublic(); - PrivateKey privateKey = keyPair.getPrivate(); - - String publicKeyString = Base64.getEncoder().encodeToString(publicKey.getEncoded()); - String privateKeyString = Base64.getEncoder().encodeToString(privateKey.getEncoded()); - - keys.put(KeysUtil.encryptionPublicKeyName, publicKeyString); - keys.put(KeysUtil.encryptionPrivateKeyName, privateKeyString); - } - - - public void generateSelfEncryptionKey(Map keys) throws NoSuchAlgorithmException { - String selfEncryptionKey = EncryptionUtil.generateAESKeyBase64(); - keys.put(KeysUtil.selfEncryptionKeyName, selfEncryptionKey); - } - - public void storePkamPublicKey(AtSecondaryConnection connection, Map keys) throws IOException { - // send update:privatekey:at_pkam_publickey $pkamKeyPair.publicKey - connection.executeCommand("update:privatekey:at_pkam_publickey " + keys.get(KeysUtil.pkamPublicKeyName)); - } - - public void storePublicEncryptionKey(AtSecondaryConnection connection, AtSign atSign, Map keys) throws IOException { - // send update:public:publickey@atSign encryptionPublicKey - connection.executeCommand("update:public:publickey" + atSign.toString() + " " + keys.get(KeysUtil.encryptionPublicKeyName)); - } - - public void deleteCramKey(AtSecondaryConnection connection) throws IOException { - // send delete:privatekey:at_secret - connection.executeCommand("delete:privatekey:at_secret"); - } -} diff --git a/at_client/src/main/java/org/atsign/client/util/Preconditions.java b/at_client/src/main/java/org/atsign/client/util/Preconditions.java index 9cab27f8..3c4fec29 100644 --- a/at_client/src/main/java/org/atsign/client/util/Preconditions.java +++ b/at_client/src/main/java/org/atsign/client/util/Preconditions.java @@ -1,5 +1,11 @@ package org.atsign.client.util; +import java.io.File; +import java.util.function.Predicate; + +/** + * Basic precondition helpers (we could use something like guava but we want to limit dependencies) + */ public class Preconditions { public static T checkNotNull(T instance, String message) { @@ -8,4 +14,16 @@ public static T checkNotNull(T instance, String message) { } return instance; } + + public static T checkNotNull(T instance) { + return checkNotNull(instance, "null"); + } + + public static File checkFile(File f, Predicate predicate, String message) { + if (!predicate.test(f)) { + throw new IllegalArgumentException(message); + } + return f; + } + } diff --git a/at_client/src/main/java/org/atsign/client/util/TypedString.java b/at_client/src/main/java/org/atsign/client/util/TypedString.java new file mode 100644 index 00000000..98d1aa46 --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/util/TypedString.java @@ -0,0 +1,29 @@ +package org.atsign.client.util; + +import static org.atsign.client.util.Preconditions.checkNotNull; + +public abstract class TypedString { + + private final String value; + + protected TypedString(String s) { + this.value = checkNotNull(s); + } + + @Override + public boolean equals(Object o) { + return o != null + && o.getClass() == this.getClass() + && value.equals(((TypedString) o).value); + } + + @Override + public int hashCode() { + return value.hashCode(); + } + + @Override + public String toString() { + return value; + } +} diff --git a/at_client/src/main/java/org/atsign/common/KeyBuilders.java b/at_client/src/main/java/org/atsign/common/KeyBuilders.java index 388433a7..2bb23653 100644 --- a/at_client/src/main/java/org/atsign/common/KeyBuilders.java +++ b/at_client/src/main/java/org/atsign/common/KeyBuilders.java @@ -27,7 +27,7 @@ public BaseKeyBuilder key(String key) { /// Each app should write to a specific namespace. /// This is required, unless the key already includes some '.' delimiters public BaseKeyBuilder namespace(String namespace) { - namespace = namespace.trim(); + namespace = namespace != null ? namespace.trim() : null; _atKey.setNamespace(namespace); return this; } diff --git a/at_client/src/main/java/org/atsign/common/ResponseTransformers.java b/at_client/src/main/java/org/atsign/common/ResponseTransformers.java index 6c3d7556..a80afa23 100644 --- a/at_client/src/main/java/org/atsign/common/ResponseTransformers.java +++ b/at_client/src/main/java/org/atsign/common/ResponseTransformers.java @@ -1,6 +1,8 @@ package org.atsign.common; import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Collectors; import org.atsign.client.api.Secondary.Response; @@ -16,7 +18,17 @@ public interface ResponseTransformer { public static class ScanResponseTransformer implements ResponseTransformer> { - @Override + private final Predicate filter; + + public ScanResponseTransformer(Predicate filter) { + this.filter = filter; + } + + public ScanResponseTransformer() { + this(k -> true); + } + + @Override public List transform(Response value) { if (value.getRawDataResponse() == null || value.getRawDataResponse().isEmpty()) { @@ -24,7 +36,8 @@ public List transform(Response value) { } try { - return mapper.readerForListOf(String.class).readValue(value.getRawDataResponse()); + List keys = mapper.readerForListOf(String.class).readValue(value.getRawDataResponse()); + return keys.stream().filter(filter).collect(Collectors.toList()); } catch (Exception e) { e.printStackTrace(); return null; @@ -47,5 +60,4 @@ public NotificationStatus transform(Response value) { throw new RuntimeException("Not Implemented"); } } - } diff --git a/at_client/src/test/java/org/atsign/client/api/AtKeysTest.java b/at_client/src/test/java/org/atsign/client/api/AtKeysTest.java new file mode 100644 index 00000000..d065967d --- /dev/null +++ b/at_client/src/test/java/org/atsign/client/api/AtKeysTest.java @@ -0,0 +1,397 @@ +package org.atsign.client.api; + +import org.atsign.client.util.EnrollmentId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.util.Base64; +import java.util.Map; + +import static org.atsign.client.util.EnrollmentId.createEnrollmentId; +import static org.junit.jupiter.api.Assertions.*; + +class AtKeysTest { + + private AtKeys atKeys; + private static final String TEST_ENROLLMENT_ID = "test-enrollment-123"; + private static final String TEST_SELF_ENCRYPT_KEY = "testSelfEncryptKey123"; + private static final String TEST_APKAM_SYMMETRIC_KEY = "testApkamSymmetricKey"; + + @BeforeEach + void setUp() { + atKeys = new AtKeys(); + } + + // EnrollmentId Tests + + @Test + void testHasEnrollmentIdReturnsFalseWhenNotSet() { + assertFalse(atKeys.hasEnrollmentId()); + } + + @Test + void testHasEnrollmentIdReturnsTrueWhenSet() { + atKeys.setEnrollmentId(createEnrollmentId(TEST_ENROLLMENT_ID)); + assertTrue(atKeys.hasEnrollmentId()); + } + + @Test + void testSetAndGetEnrollmentId() { + EnrollmentId enrollmentId = createEnrollmentId(TEST_ENROLLMENT_ID); + atKeys.setEnrollmentId(enrollmentId); + assertEquals(enrollmentId, atKeys.getEnrollmentId()); + } + + @Test + void testSetEnrollmentIdSupportsChaining() { + EnrollmentId enrollmentId = createEnrollmentId(TEST_ENROLLMENT_ID); + AtKeys result = atKeys.setEnrollmentId(enrollmentId); + assertSame(atKeys, result); + } + + @Test + void testGetEnrollmentIdReturnsNullWhenNotSet() { + assertNull(atKeys.getEnrollmentId()); + } + + // Self Encryption Key Tests + + @Test + void testSetAndGetSelfEncryptionKey() { + atKeys.setSelfEncryptKey(TEST_SELF_ENCRYPT_KEY); + assertEquals(TEST_SELF_ENCRYPT_KEY, atKeys.getSelfEncryptKey()); + } + + @Test + void testSetSelfEncryptKeySupportsChaining() { + AtKeys result = atKeys.setSelfEncryptKey(TEST_SELF_ENCRYPT_KEY); + assertSame(atKeys, result); + } + + @Test + void testGetSelfEncryptionKeyReturnsNullWhenNotSet() { + assertNull(atKeys.getSelfEncryptKey()); + } + + // APKAM Public Key Tests + + @Test + void testSetAndGetApkamPublicKeyAsString() { + String testKey = "testPublicKey123"; + atKeys.setApkamPublicKey(testKey); + assertEquals(testKey, atKeys.getApkamPublicKey()); + } + + @Test + void testSetApkamPublicKeyFromPublicKeyObject() throws Exception { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + KeyPair keyPair = keyGen.generateKeyPair(); + + atKeys.setApkamPublicKey(keyPair.getPublic()); + + assertNotNull(atKeys.getApkamPublicKey()); + String expected = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()); + assertEquals(expected, atKeys.getApkamPublicKey()); + } + + @Test + void testSetApkamPublicKeyWithStringSupportsChaining() { + AtKeys result = atKeys.setApkamPublicKey("testKey"); + assertSame(atKeys, result); + } + + @Test + void testSetApkamPublicKeyWithObjectSupportsChaining() throws Exception { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + KeyPair keyPair = keyGen.generateKeyPair(); + AtKeys result = atKeys.setApkamPublicKey(keyPair.getPublic()); + assertSame(atKeys, result); + } + + // APKAM Private Key Tests + + @Test + void testSetAndGetApkamPrivateKeyAsString() { + String testKey = "testPrivateKey123"; + atKeys.setApkamPrivateKey(testKey); + assertEquals(testKey, atKeys.getApkamPrivateKey()); + } + + @Test + void testSetApkamPrivateKeyFromPrivateKeyObject() throws Exception { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + KeyPair keyPair = keyGen.generateKeyPair(); + + atKeys.setApkamPrivateKey(keyPair.getPrivate()); + + assertNotNull(atKeys.getApkamPrivateKey()); + String expected = Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded()); + assertEquals(expected, atKeys.getApkamPrivateKey()); + } + + @Test + void testSetApkamPrivateKeyWithStringSupportsChaining() { + AtKeys result = atKeys.setApkamPrivateKey("testKey"); + assertSame(atKeys, result); + } + + @Test + void testSetApkamPrivateKeyWithObjectSupportsChaining() throws Exception { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + KeyPair keyPair = keyGen.generateKeyPair(); + AtKeys result = atKeys.setApkamPrivateKey(keyPair.getPrivate()); + assertSame(atKeys, result); + } + + // APKAM Key Pair Tests + + @Test + void testSetApkamKeyPair() throws Exception { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + KeyPair keyPair = keyGen.generateKeyPair(); + + atKeys.setApkamKeyPair(keyPair); + + assertNotNull(atKeys.getApkamPublicKey()); + assertNotNull(atKeys.getApkamPrivateKey()); + + String expectedPublic = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()); + String expectedPrivate = Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded()); + + assertEquals(expectedPublic, atKeys.getApkamPublicKey()); + assertEquals(expectedPrivate, atKeys.getApkamPrivateKey()); + } + + @Test + void testSetApkamKeyPairSupportsChaining() throws Exception { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + KeyPair keyPair = keyGen.generateKeyPair(); + AtKeys result = atKeys.setApkamKeyPair(keyPair); + assertSame(atKeys, result); + } + + // hasPkamKeys Tests + + @Test + void testHasPkamKeysReturnsFalseWhenNotSet() { + assertFalse(atKeys.hasPkamKeys()); + } + + @Test + void testHasPkamKeysReturnsFalseWhenOnlyPublicKeySet() { + atKeys.setApkamPublicKey("testPublicKey"); + assertFalse(atKeys.hasPkamKeys()); + } + + @Test + void testHasPkamKeysReturnsFalseWhenOnlyPrivateKeySet() { + atKeys.setApkamPrivateKey("testPrivateKey"); + assertFalse(atKeys.hasPkamKeys()); + } + + @Test + void testHasPkamKeysReturnsTrueWhenBothSet() { + atKeys.setApkamPublicKey("testPublicKey"); + atKeys.setApkamPrivateKey("testPrivateKey"); + assertTrue(atKeys.hasPkamKeys()); + } + + // Encrypt Public Key Tests + + @Test + void testSetAndGetEncryptPublicKeyAsString() { + String testKey = "testEncryptPublicKey"; + atKeys.setEncryptPublicKey(testKey); + assertEquals(testKey, atKeys.getEncryptPublicKey()); + } + + @Test + void testSetEncryptPublicKeyFromPublicKeyObject() throws Exception { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + KeyPair keyPair = keyGen.generateKeyPair(); + + atKeys.setEncryptPublicKey(keyPair.getPublic()); + + assertNotNull(atKeys.getEncryptPublicKey()); + String expected = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()); + assertEquals(expected, atKeys.getEncryptPublicKey()); + } + + @Test + void testSetEncryptPublicKeyWithStringSupportsChaining() { + AtKeys result = atKeys.setEncryptPublicKey("testKey"); + assertSame(atKeys, result); + } + + @Test + void testSetEncryptPublicKeyWithObjectSupportsChaining() throws Exception { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + KeyPair keyPair = keyGen.generateKeyPair(); + AtKeys result = atKeys.setEncryptPublicKey(keyPair.getPublic()); + assertSame(atKeys, result); + } + + // Encrypt Private Key Tests + + @Test + void testSetAndGetEncryptPrivateKeyAsString() { + String testKey = "testEncryptPrivateKey"; + atKeys.setEncryptPrivateKey(testKey); + assertEquals(testKey, atKeys.getEncryptPrivateKey()); + } + + @Test + void testSetEncryptPrivateKeyFromPrivateKeyObject() throws Exception { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + KeyPair keyPair = keyGen.generateKeyPair(); + + atKeys.setEncryptPrivateKey(keyPair.getPrivate()); + + assertNotNull(atKeys.getEncryptPrivateKey()); + String expected = Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded()); + assertEquals(expected, atKeys.getEncryptPrivateKey()); + } + + @Test + void testSetEncryptPrivateKeyWithStringSupportsChaining() { + AtKeys result = atKeys.setEncryptPrivateKey("testKey"); + assertSame(atKeys, result); + } + + @Test + void testSetEncryptPrivateKeyWithObjectSupportsChaining() throws Exception { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + KeyPair keyPair = keyGen.generateKeyPair(); + AtKeys result = atKeys.setEncryptPrivateKey(keyPair.getPrivate()); + assertSame(atKeys, result); + } + + // Encrypt Key Pair Tests + + @Test + void testSetEncryptKeyPair() throws Exception { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); + KeyPair keyPair = keyGen.generateKeyPair(); + + atKeys.setEncryptKeyPair(keyPair); + + assertNotNull(atKeys.getEncryptPublicKey()); + assertNotNull(atKeys.getEncryptPrivateKey()); + + String expectedPublic = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()); + String expectedPrivate = Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded()); + + assertEquals(expectedPublic, atKeys.getEncryptPublicKey()); + assertEquals(expectedPrivate, atKeys.getEncryptPrivateKey()); + } + + @Test + void testSetEncryptKeyPairSupportsChaining() throws Exception { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + KeyPair keyPair = keyGen.generateKeyPair(); + AtKeys result = atKeys.setEncryptKeyPair(keyPair); + assertSame(atKeys, result); + } + + // APKAM Symmetric Key Tests + + @Test + void testSetAndGetApkamSymmetricKey() { + atKeys.setApkamSymmetricKey(TEST_APKAM_SYMMETRIC_KEY); + assertEquals(TEST_APKAM_SYMMETRIC_KEY, atKeys.getApkamSymmetricKey()); + } + + @Test + void testSetApkamSymmetricKeySupportsChaining() { + AtKeys result = atKeys.setApkamSymmetricKey(TEST_APKAM_SYMMETRIC_KEY); + assertSame(atKeys, result); + } + + @Test + void testGetApkamSymmetricKeyReturnsNullWhenNotSet() { + assertNull(atKeys.getApkamSymmetricKey()); + } + + // Cache Tests + + @Test + void testPutAndGetFromCache() { + String key = "customKey"; + String value = "customValue"; + + atKeys.put(key, value); + assertEquals(value, atKeys.get(key)); + } + + @Test + void testGetReturnsNullForNonExistentKey() { + assertNull(atKeys.get("nonExistentKey")); + } + + @Test + void testPutOverwritesExistingKeyValue() { + String key = "testKey"; + atKeys.put(key, "value1"); + atKeys.put(key, "value2"); + assertEquals("value2", atKeys.get(key)); + } + + @Test + void testPutMultipleKeys() { + atKeys.put("key1", "value1"); + atKeys.put("key2", "value2"); + atKeys.put("key3", "value3"); + + assertEquals("value1", atKeys.get("key1")); + assertEquals("value2", atKeys.get("key2")); + assertEquals("value3", atKeys.get("key3")); + } + + @Test + void testGetCacheReturnsUnmodifiableMap() { + atKeys.put("key1", "value1"); + atKeys.put("key2", "value2"); + + Map cache = atKeys.getCache(); + + assertNotNull(cache); + assertEquals(2, cache.size()); + assertEquals("value1", cache.get("key1")); + assertEquals("value2", cache.get("key2")); + + assertThrows(UnsupportedOperationException.class, () -> { + cache.put("key3", "value3"); + }); + } + + @Test + void testGetCacheReturnsEmptyUnmodifiableMapWhenEmpty() { + Map cache = atKeys.getCache(); + + assertNotNull(cache); + assertTrue(cache.isEmpty()); + + assertThrows(UnsupportedOperationException.class, () -> { + cache.put("key1", "value1"); + }); + } + + @Test + void testGetCacheReflectsChanges() { + atKeys.put("key1", "value1"); + Map cache1 = atKeys.getCache(); + assertEquals(1, cache1.size()); + + atKeys.put("key2", "value2"); + Map cache2 = atKeys.getCache(); + assertEquals(2, cache2.size()); + } +} \ No newline at end of file diff --git a/at_client/src/test/java/org/atsign/client/cli/ActivateTest.java b/at_client/src/test/java/org/atsign/client/cli/ActivateTest.java new file mode 100644 index 00000000..e189f414 --- /dev/null +++ b/at_client/src/test/java/org/atsign/client/cli/ActivateTest.java @@ -0,0 +1,286 @@ +package org.atsign.client.cli; + +import org.atsign.client.util.KeysUtil; +import org.atsign.cucumber.helpers.AtDemoData; +import org.atsign.cucumber.helpers.Helpers; +import org.atsign.virtualenv.VirtualEnv; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.file.Files; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +class ActivateTest { + + private static final Logger LOGGER = LoggerFactory.getLogger(ActivateTest.class); + + private final ByteArrayOutputStream outBuffer = new ByteArrayOutputStream(); + private final ByteArrayOutputStream errBuffer = new ByteArrayOutputStream(); + private PrintStream originalStdout; + private PrintStream originalStdErr; + private ExecutorService executor; + private int exitCode; + + @BeforeAll + public static void classSetpUp() { + if (!Helpers.isHostPortReachable("vip.ve.atsign.zone:64", SECONDS.toMillis(2))) { + VirtualEnv.setUp(); + } + } + + + @BeforeEach + public void setup() { + executor = Executors.newSingleThreadExecutor(); + originalStdout = System.out; + System.setOut(new PrintStream(outBuffer, true)); + originalStdErr = System.err; + System.setErr(new PrintStream(errBuffer, true)); + System.setProperty("test.mode", "true"); + } + + @AfterEach + public void teardown() throws IOException { + System.setOut(originalStdout); + System.setErr(originalStdErr); + System.out.print(outBuffer); + System.err.print(errBuffer); + System.setProperty("test.mode", "false"); + executor.shutdown(); + } + + @Test + public void testInvalidArgsReturnsNonZeroExitCodeAndUsageMessage() { + assertExecuteNotSuccess( + "xyz", + "-a", "@srie", + "-r", "vip.ve.atsign.zone:64"); + assertBufferAndReset(errBuffer, containsString("expected one of [onboard, enroll, otp, list, approve")); + } + + @Test + public void testHelpReturnsZeroExitCodeAndUsageMessage() { + assertExecuteSuccess("-h"); + assertBufferAndReset(outBuffer, startsWith("Usage: ")); + } + + @Test + public void testOnboardEnrollApprove() throws IOException { + File keys = new File(Files.createTempDirectory("test").toFile(), "test.atKeys"); + keys.deleteOnExit(); + assertExecuteSuccess( + "onboard", + "-a", "@srie", + "-r", "vip.ve.atsign.zone:64", + "-k", keys.getAbsolutePath(), + "-c", AtDemoData.getClassConst("at_demo_apkam_keys.dart", "SrieKeys", "_cramKey") + ); + + assertExecuteSuccess( + "otp", + "-a", "@srie", + "-r", "vip.ve.atsign.zone:64", + "-k", keys.getAbsolutePath() + ); + String otp = assertBufferFind(outBuffer, "^(\\S+)$"); + + File appKeys = new File(Files.createTempDirectory("test").toFile(), "app.atKeys"); + appKeys.deleteOnExit(); + + executor.submit(() -> testEnrollListAndRespond("@srie", keys, "approve", () -> getEnrollmentIdWhenAvailable(appKeys))); + + assertExecuteSuccess( + "enroll", + "-a", "@srie", + "-r", "vip.ve.atsign.zone:64", + "-p", "app", + "-d", "device-" + System.currentTimeMillis(), + "-n", "ns:rw", + "-k", appKeys.getAbsolutePath(), + "-s", otp + ); + } + + @Test + public void testOnboardEnrollDeny() throws IOException { + File keys = new File(Files.createTempDirectory("test").toFile(), "test.atKeys"); + keys.deleteOnExit(); + assertExecuteSuccess( + "onboard", + "-a", "@srie", + "-r", "vip.ve.atsign.zone:64", + "-k", keys.getAbsolutePath(), + "-c", AtDemoData.getClassConst("at_demo_apkam_keys.dart", "SrieKeys", "_cramKey") + ); + + assertExecuteSuccess( + "otp", + "-a", "@srie", + "-r", "vip.ve.atsign.zone:64", + "-k", keys.getAbsolutePath() + ); + String otp = assertBufferFind(outBuffer, "^(\\S+)$"); + + File appKeys = new File(Files.createTempDirectory("test").toFile(), "app.atKeys"); + appKeys.deleteOnExit(); + + executor.submit(() -> testEnrollListAndRespond("@srie", keys, "deny", () -> getEnrollmentIdWhenAvailable(appKeys))); + + assertExecuteNotSuccess( + "enroll", + "-a", "@srie", + "-r", "vip.ve.atsign.zone:64", + "-p", "app", + "-d", "device-" + System.currentTimeMillis(), + "-n", "ns:rw", + "-k", appKeys.getAbsolutePath(), + "-s", otp + ); + assertBufferAndReset(errBuffer, containsString("is denied")); + } + + @Test + public void testOnboardEnrollRevoke() throws IOException { + File keys = new File(Files.createTempDirectory("test").toFile(), "test.atKeys"); + keys.deleteOnExit(); + assertExecuteSuccess( + "onboard", + "-a", "@srie", + "-r", "vip.ve.atsign.zone:64", + "-k", keys.getAbsolutePath(), + "-c", AtDemoData.getClassConst("at_demo_apkam_keys.dart", "SrieKeys", "_cramKey") + ); + + assertExecuteSuccess( + "otp", + "-a", "@srie", + "-r", "vip.ve.atsign.zone:64", + "-k", keys.getAbsolutePath() + ); + String otp = assertBufferFind(outBuffer, "^(\\S+)$"); + + File appKeys = new File(Files.createTempDirectory("test").toFile(), "app.atKeys"); + appKeys.deleteOnExit(); + + executor.submit(() -> testEnrollListAndRespond("@srie", keys, "approve", () -> getEnrollmentIdWhenAvailable(appKeys))); + + assertExecuteSuccess( + "enroll", + "-a", "@srie", + "-r", "vip.ve.atsign.zone:64", + "-p", "app", + "-d", "device-" + System.currentTimeMillis(), + "-n", "ns:rw", + "-k", appKeys.getAbsolutePath(), + "-s", otp + ); + + assertExecuteSuccess( + "revoke", + "-a", "@srie", + "-r", "vip.ve.atsign.zone:64", + "-k", keys.getAbsolutePath(), + "-i", getEnrollmentIdWhenAvailable(appKeys) + ); + + assertExecuteSuccess( + "unrevoke", + "-a", "@srie", + "-r", "vip.ve.atsign.zone:64", + "-k", keys.getAbsolutePath(), + "-i", getEnrollmentIdWhenAvailable(appKeys) + ); + + } + + + private String getEnrollmentIdWhenAvailable(File keys) { + try { + if (keys.exists()) { + return KeysUtil.loadKeys(keys).getEnrollmentId().toString(); + } + } catch (Exception e) { + } + return null; + } + + private void testEnrollListAndRespond(String atSign, File keys, String action, Supplier id) { + await().atMost(5, SECONDS).until(() -> id.get() != null); + + assertExecuteSuccess( + "list", + "-a", atSign, + "-r", "vip.ve.atsign.zone:64", + "-k", keys.getAbsolutePath(), + "-es", "pending" + ); + assertBufferFind(outBuffer, id.get()); + + assertExecuteSuccess( + action, + "-a", atSign, + "-r", "vip.ve.atsign.zone:64", + "-k", keys.getAbsolutePath(), + "-i", id.get() + ); + } + + private static void sleep(long duration, TimeUnit unit) { + try { + Thread.sleep(unit.toMillis(duration)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + private void assertExecuteSuccess(String... args) { + assertThat(testExecute(args), equalTo(0)); + } + + private void assertExecuteNotSuccess(String... args) { + assertThat(testExecute(args), not(equalTo(0))); + } + + private int testExecute(String... args) { + System.out.print(outBuffer); + System.err.print(errBuffer); + outBuffer.reset(); + errBuffer.reset(); + exitCode = Activate.execute(args); + return exitCode; + } + + private static void assertBufferAndReset(ByteArrayOutputStream buffer, org.hamcrest.Matcher matcher) { + assertThat(buffer.toString(), matcher); + buffer.reset(); + } + + private static String assertBufferFind(ByteArrayOutputStream buffer, String regex) { + Matcher matcher = Pattern.compile(regex, Pattern.MULTILINE).matcher(buffer.toString()); + if (matcher.find()) { + buffer.reset(); + return matcher.groupCount() == 1 ? matcher.group(1) : matcher.group(); + } else { + throw new AssertionError("expected " + matcher.pattern().pattern()); + } + } + +} \ No newline at end of file diff --git a/at_client/src/test/java/org/atsign/client/util/EncryptionUtilTest.java b/at_client/src/test/java/org/atsign/client/util/EncryptionUtilTest.java new file mode 100644 index 00000000..39838a37 --- /dev/null +++ b/at_client/src/test/java/org/atsign/client/util/EncryptionUtilTest.java @@ -0,0 +1,39 @@ +package org.atsign.client.util; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class EncryptionUtilTest { + + @Test + void testAesEncryptionWithNoInitialisationVector() throws Exception { + String key = EncryptionUtil.generateAESKeyBase64(); + String text = "mary had a little lamb"; + String encrypted = EncryptionUtil.aesEncryptToBase64(text, key); + + assertThat(encrypted, not(equalTo(text))); + + String decrypted = EncryptionUtil.aesDecryptFromBase64(encrypted, key); + + assertThat(decrypted, equalTo(text)); + } + + @Test + void testAesEncryptionWithRandomInitialisationVector() throws Exception { + String key = EncryptionUtil.generateAESKeyBase64(); + String text = "mary had a little lamb"; + String iv = EncryptionUtil.generateRandomIvBase64(16); + + String encrypted = EncryptionUtil.aesEncryptToBase64(text, key, iv); + assertThat(encrypted, not(equalTo(text))); + + assertThrows(Exception.class, () -> EncryptionUtil.aesDecryptFromBase64(encrypted, key)); + + String decrypted = EncryptionUtil.aesDecryptFromBase64(encrypted, key, iv); + assertThat(decrypted, equalTo(text)); + } +} \ No newline at end of file diff --git a/at_client/src/test/java/org/atsign/common/KeysUtilTest.java b/at_client/src/test/java/org/atsign/common/KeysUtilTest.java index bc7e2599..a0541619 100644 --- a/at_client/src/test/java/org/atsign/common/KeysUtilTest.java +++ b/at_client/src/test/java/org/atsign/common/KeysUtilTest.java @@ -1,19 +1,22 @@ package org.atsign.common; +import org.atsign.client.api.AtKeys; import org.atsign.client.util.KeysUtil; -import org.atsign.client.util.OnboardingUtil; -import org.junit.*; -import static org.junit.Assert.*; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.HashMap; -import java.util.Map; + +import static org.atsign.client.util.EncryptionUtil.generateAESKeyBase64; +import static org.atsign.client.util.EncryptionUtil.generateRSAKeyPair; +import static org.junit.Assert.*; public class KeysUtilTest { + AtSign testAtSign = new AtSign("@testSaveKeysFile"); @Before @@ -32,11 +35,10 @@ public void testSaveKeysFile() throws Exception { assertFalse(expected.exists()); // Given a Map of keys (like Onboard creates) - Map keys = new HashMap<>(); - OnboardingUtil onboardingUtil = new OnboardingUtil(); - onboardingUtil.generateEncryptionKeypair(keys); - onboardingUtil.generatePkamKeypair(keys); - onboardingUtil.generateSelfEncryptionKey(keys); + AtKeys keys = new AtKeys() + .setEncryptKeyPair(generateRSAKeyPair()) + .setApkamKeyPair(generateRSAKeyPair()) + .setSelfEncryptKey(generateAESKeyBase64()); // When we call KeysUtil.saveKeys KeysUtil.saveKeys(testAtSign, keys); @@ -48,27 +50,25 @@ public void testSaveKeysFile() throws Exception { @Test public void testLoadKeysFile() throws Exception { // Given a correctly formatted keys file in the canonical location - Map keys = new HashMap<>(); - OnboardingUtil onboardingUtil = new OnboardingUtil(); - onboardingUtil.generateEncryptionKeypair(keys); - onboardingUtil.generatePkamKeypair(keys); - onboardingUtil.generateSelfEncryptionKey(keys); + AtKeys keys = new AtKeys() + .setEncryptKeyPair(generateRSAKeyPair()) + .setApkamKeyPair(generateRSAKeyPair()) + .setSelfEncryptKey(generateAESKeyBase64()); KeysUtil.saveKeys(testAtSign, keys); // When we call KeysUtil.loadKeys - Map loadedKeys = KeysUtil.loadKeys(testAtSign); + AtKeys loadedKeys = KeysUtil.loadKeys(testAtSign); // Then the keys are loaded successfully - assertEquals(keys, loadedKeys); + assertContentsMatch(keys, loadedKeys); } @Test public void testLoadKeysFileLegacy() throws Exception { - Map keys = new HashMap<>(); - OnboardingUtil onboardingUtil = new OnboardingUtil(); - onboardingUtil.generateEncryptionKeypair(keys); - onboardingUtil.generatePkamKeypair(keys); - onboardingUtil.generateSelfEncryptionKey(keys); + AtKeys keys = new AtKeys() + .setEncryptKeyPair(generateRSAKeyPair()) + .setApkamKeyPair(generateRSAKeyPair()) + .setSelfEncryptKey(generateAESKeyBase64()); KeysUtil.saveKeys(testAtSign, keys); File expected = KeysUtil.getKeysFile(testAtSign, KeysUtil.expectedKeysFilesLocation); @@ -92,7 +92,17 @@ public void testLoadKeysFileLegacy() throws Exception { // When we call KeysUtil.loadKeys // Then the keys are loaded successfully from the legacy location - Map loadedKeys = KeysUtil.loadKeys(testAtSign); - assertEquals(keys, loadedKeys); + AtKeys loadedKeys = KeysUtil.loadKeys(testAtSign); + assertContentsMatch(keys, loadedKeys); + } + + private static void assertContentsMatch(AtKeys keys1, AtKeys keys2) { + assertEquals(keys1.getEnrollmentId(), keys2.getEnrollmentId()); + assertEquals(keys1.getApkamPublicKey(), keys2.getApkamPublicKey()); + assertEquals(keys1.getApkamPrivateKey(), keys2.getApkamPrivateKey()); + assertEquals(keys1.getEncryptPublicKey(), keys2.getEncryptPublicKey()); + assertEquals(keys1.getEncryptPrivateKey(), keys2.getEncryptPrivateKey()); + assertEquals(keys1.getSelfEncryptKey(), keys2.getSelfEncryptKey()); + assertEquals(keys1.getApkamSymmetricKey(), keys2.getApkamSymmetricKey()); } } diff --git a/at_client/src/test/java/org/atsign/cucumber/CucumberTests.java b/at_client/src/test/java/org/atsign/cucumber/CucumberTests.java index b316a37d..68609d08 100644 --- a/at_client/src/test/java/org/atsign/cucumber/CucumberTests.java +++ b/at_client/src/test/java/org/atsign/cucumber/CucumberTests.java @@ -5,9 +5,12 @@ import io.cucumber.plugin.EventListener; import io.cucumber.plugin.event.EventPublisher; import io.cucumber.plugin.event.TestRunStarted; +import org.atsign.cucumber.helpers.Helpers; import org.atsign.virtualenv.VirtualEnv; import org.junit.runner.RunWith; +import static java.util.concurrent.TimeUnit.SECONDS; + @RunWith(Cucumber.class) @CucumberOptions( features = "src/test/resources/features", @@ -18,6 +21,8 @@ public class CucumberTests implements EventListener { @Override public void setEventPublisher(EventPublisher publisher) { - publisher.registerHandlerFor(TestRunStarted.class, e -> VirtualEnv.setUp()); + if (!Helpers.isHostPortReachable("vip.ve.atsign.zone:64", SECONDS.toMillis(2))) { + publisher.registerHandlerFor(TestRunStarted.class, e -> VirtualEnv.setUp()); + } } } diff --git a/at_client/src/test/java/org/atsign/cucumber/helpers/AtDemoData.java b/at_client/src/test/java/org/atsign/cucumber/helpers/AtDemoData.java new file mode 100644 index 00000000..0335e739 --- /dev/null +++ b/at_client/src/test/java/org/atsign/cucumber/helpers/AtDemoData.java @@ -0,0 +1,61 @@ +package org.atsign.cucumber.helpers; + +import org.jetbrains.annotations.NotNull; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static org.atsign.client.util.Preconditions.checkFile; + +/** + * Using the virtual env requires some corresponding keys and secrets which are available in the dart package + * at_demo_data. The maven pom should download and extract those files. This class encapsulates using those files. + */ +public class AtDemoData { + + public static final File AT_DEMO_DATA_ROOT = new File("target/at_demo_data"); + + private static Map FILE_CONTENTS = new HashMap<>(); + + static { + checkFile(AT_DEMO_DATA_ROOT, + File::exists, + AT_DEMO_DATA_ROOT + " does not exist have you run mvn generate-test-resources?"); + } + + public static String getClassConst(String filename, String className, String constName) { + try { + String fileContents = getFileContents(new File(AT_DEMO_DATA_ROOT, "lib/src/" + filename)); + String regex = "class " + className + " \\{ static const String " + constName + " = '([^']+)'"; + Matcher matcher = Pattern.compile(regex).matcher(fileContents); + if (matcher.find()) { + return matcher.group(1); + } + throw new RuntimeException("unable to match " + regex + " in " + filename + " contents"); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private static String getFileContents(File file) throws IOException { + String key = file.getCanonicalPath(); + if (!FILE_CONTENTS.containsKey(key)) { + String fileContents = Files.lines(file.toPath()) + .filter(line -> !line.matches("^\\s*//.+")) + .collect(Collectors.joining(" ")) + .replaceAll("\\s+", " "); + FILE_CONTENTS.put(key, fileContents); + } + return FILE_CONTENTS.get(key); + } + + public static File getDir(File dir) { + return checkFile(new File(AT_DEMO_DATA_ROOT, dir.getPath()), File::isDirectory, "no such directory"); + } +} diff --git a/at_client/src/test/java/org/atsign/cucumber/helpers/Helpers.java b/at_client/src/test/java/org/atsign/cucumber/helpers/Helpers.java index e91ead59..3ddaaaee 100644 --- a/at_client/src/test/java/org/atsign/cucumber/helpers/Helpers.java +++ b/at_client/src/test/java/org/atsign/cucumber/helpers/Helpers.java @@ -76,6 +76,16 @@ public static String toCanonicalKey(String key) { return key.toLowerCase().replaceAll("\\s+", ""); } + public static String getFirstValue(Map map, String... keys) { + String value = null; + for (String key : keys) { + if ((value = map.get(toCanonicalKey(key))) != null) { + break; + } + } + return value; + } + public static boolean isMatch(Map actual, Map expected) { for (Map.Entry entry : expected.entrySet()) { if (!isMatch(actual.get(entry.getKey()), entry.getValue())) { diff --git a/at_client/src/test/java/org/atsign/cucumber/steps/ActivateSteps.java b/at_client/src/test/java/org/atsign/cucumber/steps/ActivateSteps.java new file mode 100644 index 00000000..41497689 --- /dev/null +++ b/at_client/src/test/java/org/atsign/cucumber/steps/ActivateSteps.java @@ -0,0 +1,313 @@ +package org.atsign.cucumber.steps; + +import io.cucumber.datatable.DataTable; +import io.cucumber.java.After; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import org.atsign.client.api.impl.connections.AtSecondaryConnection; +import org.atsign.client.cli.*; +import org.atsign.client.util.EnrollmentId; +import org.atsign.client.util.KeysUtil; +import org.atsign.common.AtSign; +import org.atsign.common.exceptions.AtClientConfigException; +import org.atsign.cucumber.helpers.AtDemoData; +import org.opentest4j.TestAbortedException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.util.*; + +import static org.atsign.cucumber.helpers.Helpers.getFirstValue; +import static org.atsign.cucumber.helpers.Helpers.toCanonicalMaps; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +public class ActivateSteps { + + private static final Logger LOGGER = LoggerFactory.getLogger(ActivateSteps.class); + + private final AtClientContext context; + + private final List teardownCommands = new ArrayList<>(); + + private final Stack otps = new Stack<>(); + + private final Stack enrollmentIds = new Stack<>(); + + @After + public void teardown() { + teardownCommands.forEach(Runnable::run); + } + + public ActivateSteps(AtClientContext context) { + this.context = context; + } + + @Given("atsign keys for {atsign} are missing") + public void assertMissingKeys(AtSign atsign) throws Exception { + try { + KeysUtil.loadKeys(atsign); + } catch (AtClientConfigException e) { + if (e.getMessage().contains("loadKeys: No file")) { + return; + } + } + throw new TestAbortedException("skipping remainder of the scenario (virtual env needs to be reset)"); + } + + @When("{atsign} Activate.onboard with {word}.{word} from {path} in at_demo_data package") + public void onboard(AtSign atSign, String className, String constName, File fileContainingCramSecret) throws Exception { + onboard(atSign, AtDemoData.getClassConst(fileContainingCramSecret.getName(), className, constName)); + } + + @When("{atsign} Activate.onboard with CRAM secret {string}") + public void onboard(AtSign atSign, String cramSecret) throws Exception { + File keysFile = createAtKeysFile(atSign); + EnrollmentId onboardEnrollmentId = createActivateUtil(atSign) + .setCramSecret(cramSecret) + .setKeysFile(keysFile.getAbsolutePath()) + .setNoDeleteCramKey() + .allowOverwriteKeysFile() + .onboard(); + assertThat(keysFile.exists(), is(true)); + teardownCommands.add(0, new ActivateTeardown(atSign, keysFile, cramSecret, onboardEnrollmentId)); + } + + @Then("{atsign} Activate.onboard fails with CRAM secret {string}") + public void onboardExpectFail(AtSign atSign, String secret) throws Exception { + context.assertException(() -> onboard(atSign, secret)); + } + + @Given("{atsign} Activate.otp generates an OTP") + public void createOtp(AtSign atSign) throws Exception { + otps.push(createActivateUtil(atSign).otp()); + } + + @Given("{atsign} Activate.otp generates {int} OTPs") + public void createOtp(AtSign atSign, int num) throws Exception { + for (int i = 0; i < num; i++) { + createOtp(atSign); + } + } + + @Given("{atsign} Activate.enroll for app {word} and device {word} with last OTP and following namespaces") + public void enrollWithLastOtp(AtSign atSign, String app, String device, DataTable namespaces) throws Exception { + enroll(atSign, app, device, otps.pop(), namespaces); + } + + @Given("{atsign} Activate.enroll for app {word} and device {word} with OTP {word} and following namespaces") + public void enroll(AtSign atSign, String app, String device, String otp, DataTable namespaces) throws Exception { + Activate util = new Activate() + .setVerbose(context.isVerbose()) + .setAtSign(atSign) + .setRootUrl(context.getRootHostAndPort()) + .setAppName(app) + .setDeviceName(device) + .setKeysFile(createAtKeysFile(atSign, app, device).getAbsolutePath()) + .allowOverwriteKeysFile() + .setOtp(otp); + for (Map map : toCanonicalMaps(namespaces.asMaps())) { + String ns = getFirstValue(map, "ns", "namespace"); + String ac = getFirstValue(map, "ac", "accesscontrol"); + if (ns == null || ac == null) { + throw new IllegalArgumentException("expected table with ns (namespace) and ac (access control)"); + } + util.addNamespace(ns, ac); + } + enrollmentIds.push(util.enroll()); + } + + @Given("AtClient with keys {path} for {atsign} completes enrollment") + public void completeEnrollmentAndCreateAtClient(File keysFile, AtSign atSign) throws Exception { + Activate util = new Activate() + .setVerbose(context.isVerbose()) + .setAtSign(atSign) + .setRootUrl(context.getRootHostAndPort()) + .setKeysFile(context.resolveKeysFile(keysFile).getAbsolutePath()) + .allowOverwriteKeysFile(); + util.complete(); + context.createCurrentAtClient(keysFile, atSign); + } + + @When("{atsign} Activate.approve for enrollmentId {string}") + public void approve(AtSign atSign, String enrollmentId) throws Exception { + Activate util = createActivateUtil(atSign); + util.approve(EnrollmentId.createEnrollmentId(enrollmentId)); + } + + @When("{atsign} Activate.approve for last enrollment") + public void approve(AtSign atSign) throws Exception { + approve(atSign, enrollmentIds.peek().toString()); + } + + @When("{atsign} Activate.approve for all enrollments") + public void approveEnrollAll(AtSign atSign) throws Exception { + enrollmentIds.forEach(id -> { + try { + approve(atSign, id.toString()); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + @When("{atsign} Activate.deny for enrollmentId {string}") + public void deny(AtSign atSign, String enrollmentId) throws Exception { + Activate util = createActivateUtil(atSign); + util.deny(EnrollmentId.createEnrollmentId(enrollmentId)); + } + + @When("{atsign} Activate.deny for last enrollment") + public void deny(AtSign atSign) throws Exception { + deny(atSign, enrollmentIds.pop().toString()); + } + + @When("{atsign} Activate.revoke for enrollmentId {string}") + public void revoke(AtSign atSign, String enrollmentId) throws Exception { + Activate util = createActivateUtil(atSign); + util.revoke(EnrollmentId.createEnrollmentId(enrollmentId)); + } + + @When("{atsign} Activate.revoke for last enrollment") + public void revoke(AtSign atSign) throws Exception { + revoke(atSign, enrollmentIds.peek().toString()); + } + + @When("{atsign} Activate.unrevoke for enrollmentId {string}") + public void unrevoke(AtSign atSign, String enrollmentId) throws Exception { + Activate util = createActivateUtil(atSign); + util.unrevoke(EnrollmentId.createEnrollmentId(enrollmentId)); + } + + private Activate createActivateUtil(AtSign atSign) { + return new Activate() + .setVerbose(context.isVerbose()) + .setAtSign(atSign) + .setRootUrl(context.getRootHostAndPort()); + } + + @When("{atsign} Activate.unrevoke for last enrollment") + public void unrevoke(AtSign atSign) throws Exception { + unrevoke(atSign, enrollmentIds.pop().toString()); + } + + private class FileDelete implements Runnable { + private File f; + + public FileDelete(File f) { + this.f = f; + } + + @Override + public void run() { + if (!f.delete()) { + LOGGER.warn("failed to delete {}", f); + } else { + LOGGER.info("deleted {}", f); + } + } + } + + private class ActivateTeardown extends Activate implements Runnable { + + private final EnrollmentId onboardEnrollmentId; + + public ActivateTeardown(AtSign atSign, File keysFile, String cramSecret, EnrollmentId onboardEnrollmentId) { + this.onboardEnrollmentId = onboardEnrollmentId; + setRootUrl(context.getRootHostAndPort()); + setAtSign(atSign); + setKeysFile(keysFile.getAbsolutePath()); + setCramSecret(cramSecret); + } + + public void run() { + + try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, 0)) { + + authenticateWithApkam(connection, atSign, KeysUtil.loadKeys(keysFile)); + + // delete keys that have been created + matchDataJsonListOfStrings(connection.executeCommand("scan")).stream() + .filter(k -> !isProtectedKey(atSign, k)) + .forEach(k -> deleteKeyNoThrow(connection, k)); + + // remove enrollments + list(connection, "pending").forEach(id -> denyDeleteNoThrow(connection, id)); + list(connection, "denied").forEach(id -> deleteNoThrow(connection, id)); + list(connection, "approved").stream() + .filter(id -> !id.equals(onboardEnrollmentId)) + .forEach(id -> revokeDeleteNoThrow(connection, id)); + revokeDeleteNoThrow(connection, onboardEnrollmentId); + } catch (Exception e) { + LOGGER.error("teardown for {} failed : {}", atSign, e.getMessage()); + } + } + + private boolean isProtectedKey(AtSign atSign, String key) { + return key.equals(atSign + ":signing_privatekey" + atSign) + || key.equals("public:signing_publickey" + atSign) + || key.equals("public:publickey" + atSign) + || key.contains(("__manage@")); + } + + protected void deleteKeyNoThrow(AtSecondaryConnection connection, String key) { + try { + LOGGER.info("teardown for {} deleting key {}", connection.getAtSign(), key); + deleteKey(connection, key); + } catch (Exception e) { + LOGGER.error("teardown for {} failed to delete key {} in onboarded server : {}", + connection.getAtSign(), key, e.getMessage()); + } + } + + private void deleteNoThrow(AtSecondaryConnection connection, EnrollmentId id) { + try { + LOGGER.info("teardown for {} deleting enroll request {}", id); + delete(connection, id); + } catch (Exception e) { + LOGGER.error("teardown for {} failed to enroll delete {} in onboarded server : {}", + connection.getAtSign(), id, e.getMessage()); + } + } + + private void denyDeleteNoThrow(AtSecondaryConnection connection, EnrollmentId id) { + try { + LOGGER.info("teardown for {} denying enroll request {}", connection.getAtSign(), id); + deny(connection, id); + LOGGER.info("teardown for {} deleting enroll request {}", connection.getAtSign(), id); + delete(connection, id); + } catch (Exception e) { + LOGGER.error("teardown for {} failed to enroll deny and delete {} in onboarded server : {}", + connection.getAtSign(), id, e.getMessage()); + } + } + + private void revokeDeleteNoThrow(AtSecondaryConnection connection, EnrollmentId id) { + try { + LOGGER.info("teardown for {} revoking enroll request {}", connection.getAtSign(), id); + revoke(connection, id); + LOGGER.info("teardown for {} deleting enroll request {}", connection.getAtSign(), id); + delete(connection, id); + } catch (Exception e) { + LOGGER.error("teardown for {} failed to enroll revoke and delete {} in onboarded server : {}", + connection.getAtSign(), id, e.getMessage()); + } + } + } + + private File createAtKeysFile(AtSign atSign) { + String filename = atSign.toString() + KeysUtil.keysFileSuffix; + File file = new File(new File(KeysUtil.expectedKeysFilesLocation), filename); + teardownCommands.add(new FileDelete(file)); + return file; + } + + private File createAtKeysFile(AtSign atSign, String app, String device) { + String filename = atSign + "-" + app + "-" + device + KeysUtil.keysFileSuffix; + File file = new File(new File(KeysUtil.expectedKeysFilesLocation), filename); + teardownCommands.add(new FileDelete(file)); + return file; + } +} diff --git a/at_client/src/test/java/org/atsign/cucumber/steps/AtClientContext.java b/at_client/src/test/java/org/atsign/cucumber/steps/AtClientContext.java index 1151f62e..badc2e58 100644 --- a/at_client/src/test/java/org/atsign/cucumber/steps/AtClientContext.java +++ b/at_client/src/test/java/org/atsign/cucumber/steps/AtClientContext.java @@ -1,236 +1,481 @@ package org.atsign.cucumber.steps; import io.cucumber.java.After; +import io.cucumber.java.en.And; import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; import org.atsign.client.api.AtClient; import org.atsign.client.api.AtEvents; +import org.atsign.client.api.AtKeys; +import org.atsign.client.util.EnrollmentId; import org.atsign.client.util.KeysUtil; import org.atsign.common.AtException; import org.atsign.common.AtSign; +import org.atsign.cucumber.helpers.AtDemoData; import org.atsign.virtualenv.VirtualEnv; +import org.jetbrains.annotations.NotNull; import org.junit.AssumptionViolatedException; +import org.junit.jupiter.api.function.Executable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.testcontainers.shaded.org.awaitility.Awaitility; import java.io.File; +import java.io.IOException; import java.util.*; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; import static java.util.concurrent.TimeUnit.SECONDS; +import static org.atsign.client.cli.AbstractCli.decodeJsonListOfStrings; +import static org.atsign.client.util.Preconditions.checkNotNull; import static org.atsign.cucumber.helpers.Helpers.isHostPortReachable; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertThrows; public class AtClientContext { - private static final Logger LOGGER = LoggerFactory.getLogger(AtClientContext.class); + private static final Logger LOGGER = LoggerFactory.getLogger(AtClientContext.class); - private static final Set ALL_EVENT_TYPES = Collections.unmodifiableSet( - new HashSet<>(Arrays.asList(AtEvents.AtEventType.values())) - ); + private static final Set ALL_EVENT_TYPES = Collections.unmodifiableSet( + new HashSet<>(Arrays.asList(AtEvents.AtEventType.values())) + ); - private static final Map ROOT_SERVERS = new HashMap<>(); + private static final Map ROOT_SERVERS = new HashMap<>(); - private String rootHostAndPort = null; + private String rootHostAndPort = null; - private boolean isVerbose; + private boolean isVerbose; - private long ttl = SECONDS.toMillis(5); + private long ttl = SECONDS.toMillis(5); - private Map clients = new HashMap<>(); + private LinkedHashMap clients = new LinkedHashMap<>(); - private Map listeners = new HashMap<>(); + private Map listeners = new HashMap<>(); - private AtSign currentAtSign; + private QualifiedAtSign currentQualifiedAtSign; - public long getKeyTtl() { - return ttl; - } - - public AtSign getCurrentAtSign() { - if (currentAtSign == null) { - throw new IllegalArgumentException("the is no current atsign"); - } - return currentAtSign; - } - - @After(order = Integer.MAX_VALUE) - public void teardown() { - int count = 0; - for (Map.Entry entry : listeners.entrySet()) { - count += entry.getValue().clear(); - } - LOGGER.info("discarded {} events", count); - } - - @Given("root server endpoint is {word}:{int}") - public void setRootHostAndPort(String host, int port) throws AtException { - rootHostAndPort = String.format("%s:%d", host, port); - } - - @Given("root server is running") - public void assumeRootServerIsRunning() throws AtException { - boolean isRunning = ROOT_SERVERS.computeIfAbsent(rootHostAndPort, k -> isHostPortReachable(k, SECONDS.toMillis(2))); - if (!isRunning) { - throw createRootServerNotReachableAssumptionFailure(rootHostAndPort); - } - } - - @Given("atsign keys path is {path}") - public void setClientAtSignKeyDir(File path) { - checkKeysPathExists(path); - KeysUtil.expectedKeysFilesLocation = path.getPath(); - } - - @Given("atsign keys suffix is {word}") - public void setClientAtSignKeySuffix(String s) { - KeysUtil.keysFileSuffix = s; - } + private String currentNamespace; - @Given("verbose logging is {word}") - public void setLogging(String onOrOff) throws AtException { - isVerbose = onOrOff.equalsIgnoreCase("on") || onOrOff.equalsIgnoreCase("true"); - } + private Exception expectedException; - @Given("AtClient for {atsign}") - public void createAtClient(AtSign atSign) throws AtException { - currentAtSign = getAtClient(atSign, false).getAtSign(); - } + public long getKeyTtl() { + return ttl; + } + + @And("key ttl is {long} {timeunit}") + public void setKeyTtl(long duration, TimeUnit unit) { + ttl = unit.toMillis(duration); + } + + public QualifiedAtSign getCurrentQualifiedAtSign() { + return checkNotNull(currentQualifiedAtSign, "the is no current atsign, an atClient must be created first"); + } + + public String getRootHostAndPort() { + return rootHostAndPort; + } + + public void assertException(Executable command) { + this.expectedException = assertThrows(Exception.class, command::execute); + } + + private Exception getExpectedException() { + return checkNotNull(expectedException, "no exception"); + } + + public boolean isVerbose() { + return isVerbose; + } + + @After(order = Integer.MAX_VALUE) + public void teardown() { + clients.values().forEach(this::teardownKeysAndClose); + } + + @Given("root server endpoint is {word}:{int}") + public void setRootHostAndPort(String host, int port) throws AtException { + rootHostAndPort = String.format("%s:%d", host, port); + } + + @Given("root server is running") + public void assumeRootServerIsRunning() throws AtException { + boolean isRunning = ROOT_SERVERS.computeIfAbsent(rootHostAndPort, k -> isHostPortReachable(k, SECONDS.toMillis(2))); + if (!isRunning) { + throw createRootServerNotReachableAssumptionFailure(rootHostAndPort); + } + } + + @Given("atsign keys path is {path}") + public void setClientAtSignKeyDir(File path) { + checkKeysPathExists(path); + KeysUtil.expectedKeysFilesLocation = path.getPath(); + } + + @Given("atsign keys path is at_demo_data package {path}") + public void setClientAtSignKeyDirAsAtDemoDataDir(File path) { + setClientAtSignKeyDir(AtDemoData.getDir(path)); + } + + @Given("atsign keys suffix is {word}") + public void setClientAtSignKeySuffix(String s) { + KeysUtil.keysFileSuffix = s; + } + + @Given("verbose logging is {word}") + public void setLogging(String onOrOff) throws AtException { + isVerbose = onOrOff.equalsIgnoreCase("on") || onOrOff.equalsIgnoreCase("true"); + } + + @Given("AtClient with keys {path} for {atsign}") + public void createCurrentAtClient(File keysFile, AtSign atSign) throws Exception { + createCurrentAtClient(atSign, KeysUtil.loadKeys(resolveKeysFile(keysFile)), false); + } + + public File resolveKeysFile(File keysFile) { + if (keysFile.getParentFile() == null) { + return new File(KeysUtil.expectedKeysFilesLocation, keysFile.getName()); + } else { + return keysFile; + } + } + + private void createCurrentAtClient(AtSign atSign, AtKeys keys, boolean withMonitor) throws Exception { + AtClient atClient = createAtClient(atSign, keys, withMonitor); + currentQualifiedAtSign = new QualifiedAtSign(atClient.getAtSign(), getEnrollmentId(keys)); + } + + @Given("AtClient for {atsign}") + public void createCurrentAtClient(AtSign atSign) throws Exception { + createCurrentAtClient(atSign, KeysUtil.loadKeys(atSign), false); + } + + @Given("AtClient is closed") + public void closeCurrentAtClient() throws Exception { + clients.remove(currentQualifiedAtSign).close(); + } + + @Given("namespace is set to {word}") + public void setNamespace(String ns) { + this.currentNamespace = ns; + } + + @Given("namespace is unset") + public void clearNamespace() { + this.currentNamespace = null; + } + + public String getNamespace() { + return this.currentNamespace; + } + + public boolean isNamespaceSet() { + return this.currentNamespace != null; + } + + @Given("AtClient with keys {path} fails for {atsign}") + public void createAtClientExpectFail(File keysFile, AtSign atSign) { + assertException(() -> createCurrentAtClient(keysFile, atSign)); + } + + @Given("AtClient fails for {atsign}") + public void createAtClientExpectFail(AtSign atSign) { + assertException(() -> createCurrentAtClient(atSign)); + } + + @Given("AtClient with keys {path} and startMonitor for {atsign}") + public void createAtClientWithMonitor(File keysFile, AtSign atSign) throws Exception { + createCurrentAtClient(atSign, KeysUtil.loadKeys(resolveKeysFile(keysFile)), true); + } + + @Given("AtClient and startMonitor for {atsign}") + public void createAtClientWithMonitor(AtSign atSign) throws Exception { + createCurrentAtClient(atSign, KeysUtil.loadKeys(atSign), true); + } - @Given("AtClient and startMonitor for {atsign}") - public void createAtClientWithMonitor(AtSign atSign) throws AtException { - currentAtSign = getAtClient(atSign, true).getAtSign(); - } + @Given("{ordinal} {atsign} AtClient startMonitor") + public void startAtClientMonitor(Integer ordinal, AtSign clientAtSign) throws Exception { + AtClient atClient = lookupAtClient(clientAtSign, ordinal); + startAtClientMonitor(atClient); + } + + @Given("{atsign} AtClient startMonitor") + public void startAtClientMonitor(AtSign clientAtSign) throws Exception { + AtClient atClient = lookupOrCreateAtClient(clientAtSign); + startAtClientMonitor(atClient); + } + + @Given("AtClient startMonitor") + public void startAtClientMonitor() throws Exception { + AtClient atClient = lookupAtClient(currentQualifiedAtSign); + startAtClientMonitor(atClient); + } + + private void startAtClientMonitor(AtClient atClient) { + if (!atClient.isMonitorRunning()) { + atClient.startMonitor(); + } + } - @Given("{atsign} AtClient startMonitor") - public void startAtClientMonitor(AtSign atSign) throws AtException { - AtClient atClient = getAtClient(atSign); - if (!atClient.isMonitorRunning()) { - atClient.startMonitor(); + @Given("{ordinal} {atsign} AtClient stopMonitor") + public void stopAtClientMonitor(Integer ordinal, AtSign clientAtSign) throws Exception { + AtClient atClient = lookupAtClient(clientAtSign, ordinal); + stopAtClientMonitor(atClient); } - } - @Given("AtClient startMonitor") - public void startAtClientMonitor() throws AtException { - startAtClientMonitor(currentAtSign); - } + @Given("{atsign} AtClient stopMonitor") + public void stopAtClientMonitor(AtSign atSign) throws Exception { + AtClient atClient = lookupOnlyAtClient(atSign); + stopAtClientMonitor(atClient); + } - @Given("{atsign} AtClient stopMonitor") - public void stopAtClientMonitor(AtSign atSign) throws AtException { - AtClient atClient = getAtClient(atSign); - if (atClient.isMonitorRunning()) { - atClient.stopMonitor(); - Awaitility.await().atMost(2, SECONDS).until(() -> !atClient.isMonitorRunning()); - LOGGER.info("discarded {} events", listeners.get(atSign).clear()); + @Given("AtClient stopMonitor") + public void stopAtClientMonitor() throws Exception { + AtClient atClient = lookupAtClient(currentQualifiedAtSign); + stopAtClientMonitor(atClient); } - } - @Given("AtClient stopMonitor") - public void stopAtClientMonitor() throws AtException { - stopAtClientMonitor(currentAtSign); - } + private void stopAtClientMonitor(AtClient atClient) { + if (atClient.isMonitorRunning()) { + atClient.stopMonitor(); + await().atMost(2, SECONDS).until(() -> !atClient.isMonitorRunning()); + } + } - public AtClient getAtClient(AtSign atSign) throws AtException { - return getAtClient(atSign, false); - } - - public AtClient getAtClient(AtSign atSign, boolean monitor) throws AtException { - AtClient atClient = clients.get(atSign); - if (atClient == null) { - if (rootHostAndPort == null) { - throw new IllegalArgumentException("root host and port not set"); - } - atClient = AtClient.withRemoteSecondary(rootHostAndPort, atSign, isVerbose); - AtClientEventListener listener = new AtClientEventListener(atSign); - atClient.addEventListener(listener, ALL_EVENT_TYPES); - clients.put(atSign, atClient); - listeners.put(atSign, listener); - if (monitor) { - atClient.startMonitor(); - } - } - return atClient; - } - - public List> getEventData(AtSign atSign) throws Exception { - return listeners.get(atSign).events.stream() - .map(e -> e.asMapOfStrings()) - .collect(Collectors.toList()); - } - - public List> getEventData(AtSign atSign, AtEvents.AtEventType eventType) throws Exception { - return listeners.get(atSign).events.stream() - .filter(e -> e.eventType == eventType) - .map(e -> e.asMapOfStrings()) - .collect(Collectors.toList()); - } - - public void clearEvents(AtSign atSign) { - listeners.get(atSign).clear(); - } - - private static class AtClientEventListener implements AtEvents.AtEventListener { - - private final AtSign atSign; - private final List events = new CopyOnWriteArrayList<>(); - - public AtClientEventListener(AtSign atSign) { - this.atSign = atSign; - } - - @Override - public void handleEvent(AtEvents.AtEventType eventType, Map eventData) { - LOGGER.info("{} received {} : {}", atSign, eventType, eventData); - events.add(new AtClientEvent(eventType, eventData)); - } - - private int clear() { - int count = events.size(); - events.clear(); - return count; - } - } - - private static class AtClientEvent { - final AtEvents.AtEventType eventType; - final Map eventData; - - AtClientEvent(AtEvents.AtEventType eventType, Map eventData) { - this.eventType = eventType; - this.eventData = new HashMap<>(eventData); - } - - Map asMapOfStrings() { - Map map = new HashMap(); - map.put("eventType", eventType.name()); - eventData.entrySet().stream() - .filter(e -> e.getValue() != null) - .forEach(e -> map.put(e.getKey(), e.getValue().toString())); - return map; + @Then("exception was {exception}") + public void assertExpectedExceptionClass(Class expectedClass) throws AtException { + if (expectedException.getCause() != null) { + assertThat(expectedException.getCause().getClass(), typeCompatibleWith(expectedClass)); + } else { + assertThat(expectedException.getClass(), typeCompatibleWith(expectedClass)); + } } - } - - private static AssumptionViolatedException createRootServerNotReachableAssumptionFailure(String rootHostAndPort) { - String message = String.format("%s not reachable", rootHostAndPort); - if (rootHostAndPort.contains("vip.ve")) { - message = message + String.format(" (are you running the virtualenv? see test class %s)", VirtualEnv.class); + + @Then("exception message matches {string}") + public void assertExpectedExceptionMessageMatches(String regex) throws AtException { + assertThat(expectedException, notNullValue()); + Matcher matcher = Pattern.compile(regex).matcher(expectedException.getMessage()); + assertThat(expectedException.getMessage() + " does not match " + regex, matcher.find(), is(true)); } - return new AssumptionViolatedException(message); - } - - private void checkKeysPathExists(File path) { - String message = null; - if (!path.exists()) { - message = String.format("%s does not exist", path); + + @Then("exception was {exception} and message matches {string}") + public void assertExpectedException(Class expectedClass, String regex) throws AtException { + assertExpectedExceptionClass(expectedClass); + assertExpectedExceptionMessageMatches(regex); } - if (path.list().length < 1) { - message = String.format("%s is empty", path); - } - if (message != null && path.getPath().startsWith("target")) { - message = message + " (have you run mvn test-compile? this should download at_demo_data"; + + @And("pause for {long} {timeunit}") + public void pause(long duration, TimeUnit unit) throws Exception { + LOGGER.info("sleeping for {} {}", duration, unit); + Thread.sleep(unit.toMillis(duration)); + } + + public AtClient lookupAtClient(QualifiedAtSign qualifiedAtSign) { + return checkNotNull(clients.get(qualifiedAtSign), "no client has been created for " + qualifiedAtSign); } - if (message != null) { - throw new AssumptionViolatedException(message); + + public AtClient lookupAtClient(AtSign atSign, int ordinal) { + List list = lookupAtClients(atSign); + if (list.size() < ordinal) { + throw new IllegalArgumentException(list.size() + " AtClients for " + atSign); + } + return list.get(ordinal - 1); + } + + public AtClient lookupOnlyAtClient(AtSign atSign) throws Exception { + List list = lookupAtClients(atSign); + if (list.size() > 1) { + throw new IllegalArgumentException("multiple AtClients for " + atSign); + } + if (list.isEmpty()) { + throw new IllegalArgumentException("no AtClients for " + atSign); + } + return list.get(0); + } + + public AtClient lookupOrCreateAtClient(AtSign atSign) throws Exception { + List list = lookupAtClients(atSign); + if (list.size() > 1) { + throw new IllegalArgumentException("multiple AtClients for " + atSign + " this is ambiguous"); + } else if (list.size() == 1) { + return list.get(0); + } else { + return createAtClient(atSign, KeysUtil.loadKeys(atSign), false); + } + } + + private List lookupAtClients(AtSign atSign) { + return clients.entrySet().stream() + .filter(entry -> entry.getKey().getAtSign().equals(atSign)) + .map(Map.Entry::getValue) + .collect(Collectors.toList()); + } + + private AtClient createAtClient(AtSign atSign, AtKeys keys, boolean withMonitor) throws Exception { + QualifiedAtSign qualifiedAtSign = new QualifiedAtSign(atSign, getEnrollmentId(keys)); + if (clients.containsKey(qualifiedAtSign)) { + throw new IllegalArgumentException("attempt to create another atClient for the same qualified atsign"); + } + if (rootHostAndPort == null) { + throw new IllegalArgumentException("root host and port not set"); + } + AtClient atClient = AtClient.withRemoteSecondary(rootHostAndPort, atSign, keys, isVerbose); + AtClientEventListener listener = new AtClientEventListener(qualifiedAtSign); + atClient.addEventListener(listener, ALL_EVENT_TYPES); + clients.put(qualifiedAtSign, atClient); + listeners.put(qualifiedAtSign, listener); + if (withMonitor) { + atClient.startMonitor(); + // wait for and old notifications to arrive and then clear listener + Thread.sleep(SECONDS.toMillis(2)); + listener.clear(); + } + return atClient; + } + + private QualifiedAtSign lookupQualifiedAtSign(AtClient atClient) { + return clients.entrySet().stream() + .filter(entry -> entry.getValue() == atClient) + .map(Map.Entry::getKey) + .findFirst() + .orElse(null); + } + + private static EnrollmentId getEnrollmentId(AtKeys keys) { + return keys.getEnrollmentId() != null ? keys.getEnrollmentId() : null; + } + + public List> getEventData(AtClient atClient) throws Exception { + return listeners.get(lookupQualifiedAtSign(atClient)).events.stream() + .map(e -> e.asMapOfStrings()) + .collect(Collectors.toList()); + } + + public List> getEventData(AtClient atClient, AtEvents.AtEventType eventType) throws Exception { + return listeners.get(lookupQualifiedAtSign(atClient)).events.stream() + .filter(e -> e.eventType == eventType) + .map(e -> e.asMapOfStrings()) + .collect(Collectors.toList()); + } + + public void clearEvents(AtClient atClient) { + listeners.get(lookupQualifiedAtSign(atClient)).clear(); + } + + private static class AtClientEventListener implements AtEvents.AtEventListener { + + private final QualifiedAtSign atSign; + private final List events = new CopyOnWriteArrayList<>(); + + public AtClientEventListener(QualifiedAtSign atSign) { + this.atSign = atSign; + } + + @Override + public void handleEvent(AtEvents.AtEventType eventType, Map eventData) { + LOGGER.info("{} received {} : {}", atSign, eventType, eventData); + events.add(new AtClientEvent(eventType, eventData)); + } + + private int clear() { + int count = events.size(); + events.clear(); + LOGGER.info("{} cleared {} events", atSign, count); + return count; + } + } + + private static class AtClientEvent { + final AtEvents.AtEventType eventType; + final Map eventData; + + AtClientEvent(AtEvents.AtEventType eventType, Map eventData) { + this.eventType = eventType; + this.eventData = new HashMap<>(eventData); + } + + Map asMapOfStrings() { + Map map = new HashMap(); + map.put("eventType", eventType.name()); + eventData.entrySet().stream() + .filter(e -> e.getValue() != null) + .forEach(e -> map.put(e.getKey(), e.getValue().toString())); + return map; + } + } + + private static AssumptionViolatedException createRootServerNotReachableAssumptionFailure(String rootHostAndPort) { + String message = String.format("%s not reachable", rootHostAndPort); + if (rootHostAndPort.contains("vip.ve")) { + message = message + String.format(" (are you running the virtualenv? see test class %s)", VirtualEnv.class); + } + return new AssumptionViolatedException(message); + } + + private void checkKeysPathExists(File path) { + String message = null; + if (!path.exists()) { + message = String.format("%s does not exist", path); + } + if (path.list().length < 1) { + message = String.format("%s is empty", path); + } + if (message != null && path.getPath().startsWith("target")) { + message = message + " (have you run mvn test-compile? this should download at_demo_data"; + } + if (message != null) { + throw new AssumptionViolatedException(message); + } + } + + private void teardownKeysAndClose(AtClient client) { + List keys = scanNoThrow(client).stream() + .filter(this::requiresTeardown) + .collect(Collectors.toList()); + keys.forEach(k -> deleteKeyNoThrow(client, k)); + LOGGER.info("teardown for {} deleted {}", lookupQualifiedAtSign(client), keys); + try { + client.close(); + } catch (IOException e) { + LOGGER.info("teardown close for {} threw exception : {}", lookupQualifiedAtSign(client), e.getMessage()); + } + } + + private boolean requiresTeardown(String key) { + if (key.contains(":signing_privatekey@")) { + return false; + } + if (key.startsWith("public:pkaminstalled@")) { + return false; + } + if (key.startsWith("public:publickey@")) { + return false; + } + if (key.startsWith("public:signing_publickey@")) { + return false; + } + return true; + } + + private void deleteKeyNoThrow(AtClient client, String key) { + try { + client.executeCommand("delete:" + key, true); + } catch (Exception e) { + LOGGER.error("attempt to delete {} failed : {}", key, e.getMessage()); + } + } + + private List scanNoThrow(AtClient client) { + try { + String json = client.getSecondary().executeCommand("scan:showHidden:true .*", true).getRawDataResponse(); + return decodeJsonListOfStrings(json); + } catch (Exception e) { + LOGGER.error("failed to scan : {}", e.getMessage()); + return Collections.emptyList(); + } } - } } diff --git a/at_client/src/test/java/org/atsign/cucumber/steps/GetAtKeysSteps.java b/at_client/src/test/java/org/atsign/cucumber/steps/GetAtKeysSteps.java index def6cd80..75dfd852 100644 --- a/at_client/src/test/java/org/atsign/cucumber/steps/GetAtKeysSteps.java +++ b/at_client/src/test/java/org/atsign/cucumber/steps/GetAtKeysSteps.java @@ -4,13 +4,13 @@ import io.cucumber.datatable.DataTableFormatter; import io.cucumber.java.en.And; import io.cucumber.java.en.Then; -import org.atsign.common.AtException; +import org.atsign.client.api.AtClient; import org.atsign.common.AtSign; +import org.atsign.common.KeyBuilders; import org.atsign.common.Keys; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.IOException; import java.util.*; import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; @@ -23,241 +23,321 @@ public class GetAtKeysSteps { - private static final Logger LOGGER = LoggerFactory.getLogger(GetAtKeysSteps.class); - - public static final List HEADINGS = asList( - "Key", - "Name", - "Namespace", - "Shared By", - "Shared With" - ); - - public static final List HEADINGS_INCLUDING_METADATA = asList( - "Key", - "Name", - "Namespace", - "Shared By", - "Shared With", - "Ttl", - "Ttb", - "Ttr", - "Ccd", - "Created By", - "Updated By", - "Available At", - "Expires At", - "Refresh At", - "Created At", - "Updated At", - "Status", - "Version", - "Data Signature", - "Shared Key Status", - "Is Public", - "Is Encrypted", - "Is Hidden", - "Namespace Aware", - "Is Binary", - "Is Cached", - "Shared Key Enc", - "Pub Key CS", - "Encoding"); - - private final AtClientContext context; - - public GetAtKeysSteps(AtClientContext context) { - this.context = context; - } - - @Then("{atsign} AtClient.getAtKeys for {string} matches") - public void assertGetAtKeysMatches(AtSign atSign, String regex, DataTable table) throws Exception { - assertContains(getAtKeysAsListOfMaps(atSign, regex), table.asMaps(), true); - } - - @Then("AtClient.getAtKeys for {string} matches") - public void assertGetAtKeysMatches(String regex, DataTable table) throws Exception { - assertGetAtKeysMatches(context.getCurrentAtSign(), regex, table); - } - - @Then("{atsign} AtClient.getAtKeys for {string} contains") - public void assertGetAtKeysContains(AtSign atSign, String regex, DataTable table) throws Exception { - List> expected = new ArrayList<>(); - if (table.width() == 1) { - for (String key : table.asList()) { - if (key.equalsIgnoreCase("key")) { - // this is a heading ignore + private static final Logger LOGGER = LoggerFactory.getLogger(GetAtKeysSteps.class); + + public static final List HEADINGS = asList( + "Key", + "Name", + "Namespace", + "Shared By", + "Shared With" + ); + + public static final List HEADINGS_INCLUDING_VALUE = asList( + "Key", + "Name", + "Namespace", + "Shared By", + "Shared With", + "Value" + ); + + public static final List HEADINGS_INCLUDING_METADATA = asList( + "Key", + "Name", + "Namespace", + "Shared By", + "Shared With", + "Ttl", + "Ttb", + "Ttr", + "Ccd", + "Created By", + "Updated By", + "Available At", + "Expires At", + "Refresh At", + "Created At", + "Updated At", + "Status", + "Version", + "Data Signature", + "Shared Key Status", + "Is Public", + "Is Encrypted", + "Is Hidden", + "Namespace Aware", + "Is Binary", + "Is Cached", + "Shared Key Enc", + "Pub Key CS", + "Encoding"); + + private final AtClientContext context; + + public GetAtKeysSteps(AtClientContext context) { + this.context = context; + } + + // matches + + @Then("{ordinal} {atsign} AtClient.getAtKeys for {string} matches") + public void assertGetAtKeysMatches(Integer ordinal, AtSign clientAtSign, String regex, DataTable expected) throws Exception { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + assertGetAtKeysMatches(regex, expected, atClient); + } + + @Then("{atsign} AtClient.getAtKeys for {string} matches") + public void assertGetAtKeysMatches(AtSign clientAtSign, String regex, DataTable expected) throws Exception { + AtClient atClient = context.lookupOrCreateAtClient(clientAtSign); + assertGetAtKeysMatches(regex, expected, atClient); + } + + @Then("AtClient.getAtKeys for {string} matches") + public void assertGetAtKeysMatches(String regex, DataTable expected) throws Exception { + AtClient atClient = context.lookupAtClient(context.getCurrentQualifiedAtSign()); + assertGetAtKeysMatches(regex, expected, atClient); + } + + // contains + + @Then("{ordinal} {atsign} AtClient.getAtKeys for {string} contains") + public void assertGetAtKeysContains(Integer ordinal, AtSign clientAtSign, String regex, DataTable expected) throws Exception { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + assertGetAtKeysContains(atClient, regex, expected); + } + + @Then("{atsign} AtClient.getAtKeys for {string} contains") + public void assertGetAtKeysContains(AtSign clientAtSign, String regex, DataTable expected) throws Exception { + AtClient atClient = context.lookupOrCreateAtClient(clientAtSign); + assertGetAtKeysContains(atClient, regex, expected); + } + + @Then("AtClient.getAtKeys for {string} contains") + public void assertGetAtKeysContains(String regex, DataTable expected) throws Exception { + AtClient atClient = context.lookupAtClient(context.getCurrentQualifiedAtSign()); + assertGetAtKeysContains(atClient, regex, expected); + } + + // NOT contains + + @Then("{ordinal} {atsign} AtClient.getAtKeys for {string} does NOT contain") + public void assertGetAtKeysNotContains(Integer ordinal, AtSign clientAtSign, String regex, DataTable notExpected) throws Exception { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + assertGetAtKeysNotContains(regex, notExpected, atClient); + } + + @Then("{atsign} AtClient.getAtKeys for {string} does NOT contain") + public void assertGetAtKeysNotContains(AtSign clientAtSign, String regex, DataTable notExpected) throws Exception { + AtClient atClient = context.lookupOrCreateAtClient(clientAtSign); + assertGetAtKeysNotContains(regex, notExpected, atClient); + } + + @Then("AtClient.getAtKeys for {string} does NOT contain") + public void assertGetAtKeysNotContains(String regex, DataTable notExpected) throws Exception { + AtClient atClient = context.lookupAtClient(context.getCurrentQualifiedAtSign()); + assertGetAtKeysNotContains(regex, notExpected, atClient); + } + + private void assertGetAtKeysMatches(String regex, DataTable expected, AtClient atClient) throws Exception { + List> actual = getAtKeysAsListOfMaps(atClient, regex); + assertContains(actual, expected.asMaps(), true); + } + + private void assertGetAtKeysContains(AtClient atClient, String regex, DataTable table) throws Exception { + List> expected = new ArrayList<>(); + if (table.width() == 1) { + for (String key : table.asList()) { + if (key.equalsIgnoreCase("key")) { + // this is a heading ignore + } else { + expected.add(Collections.singletonMap("Key", key)); + } + } } else { - expected.add(Collections.singletonMap("Key", key)); + assertThat(table.height(), greaterThan(1)); + expected.addAll(table.asMaps()); + } + try { + assertContains(getAtKeysAsListOfMaps(atClient, regex), expected, false); + } catch (Exception | Error e) { + dumpKeys(atClient, HEADINGS_INCLUDING_METADATA, true); + throw e; } - } - } else { - assertThat(table.height(), greaterThan(1)); - expected.addAll(table.asMaps()); } - try { - assertContains(getAtKeysAsListOfMaps(atSign, regex), expected, false); - } catch (Exception | Error e) { - dumpKeys(atSign, HEADINGS_INCLUDING_METADATA, true); - throw e; + + private static void assertGetAtKeysNotContains(String regex, DataTable table, AtClient atClient) throws Exception { + assertThat("expect single column datatable of keys (no heading)", table.width(), equalTo(1)); + Set actual = atClient.getAtKeys(regex, false).get().stream() + .map(Keys.AtKey::toString) + .collect(Collectors.toSet()); + + Set intersection = new HashSet<>(table.asList()); + intersection.retainAll(actual); + + assertThat("contains " + intersection, intersection, empty()); + } + + @And("dump keys") + public void dumpKeys() throws Exception { + dumpKeys(context.lookupAtClient(context.getCurrentQualifiedAtSign()), HEADINGS, false); } - } - - @Then("AtClient.getAtKeys for {string} contains") - public void assertGetAtKeysContains(String regex, DataTable table) throws Exception { - assertGetAtKeysContains(context.getCurrentAtSign(), regex, table); - } - - @Then("{atsign} AtClient.getAtKeys for {string} does NOT contain") - public void assertGetAtKeysNotContains(AtSign atSign, String regex, DataTable table) throws Exception { - assertThat("expect single column datatable of keys (no heading)", table.width(), equalTo(1)); - Set actual = context.getAtClient(atSign).getAtKeys(regex, false).get().stream() - .map(key -> key.toString()) - .collect(Collectors.toSet()); - - Set intersection = new HashSet<>(table.asList()); - intersection.retainAll(actual); - - assertThat("contains " + intersection, intersection, empty()); - } - - @Then("AtClient.getAtKeys for {string} does NOT contain") - public void assertGetAtKeysNotContains(String regex, DataTable table) throws Exception { - assertGetAtKeysNotContains(context.getCurrentAtSign(), regex, table); - } - - @And("dump keys") - public void dumpKeys() throws Exception { - dumpKeys(context.getCurrentAtSign(), HEADINGS, false); - } - - @And("dump keys with metadata") - public void dumpKeysWithMetaData() throws Exception { - dumpKeys(context.getCurrentAtSign(), HEADINGS_INCLUDING_METADATA, true); - } - - private void dumpKeys(AtSign atSign, List headings, boolean fetchMetaData) throws InterruptedException, ExecutionException, AtException, IOException { - List> raw = new ArrayList<>(); - raw.add(headings); - context.getAtClient(atSign).getAtKeys(".*", fetchMetaData).get().stream() - .forEach(k -> raw.add(toDataTableRow(raw.get(0), k))); - DataTableFormatter.builder() - .prefixRow(" ") - .escapeDelimiters(true) - .build() - .formatTo(DataTable.create(raw), System.out); - } - - private List toDataTableRow(List headings, Keys.AtKey k) { - ArrayList row = new ArrayList<>(); - for (String heading : headings) { - switch (toCanonicalKey(heading)) { - case "key": - row.add(k.toString()); - break; - case "name": - row.add(k.name); - break; - case "namespace": - row.add(k.getNamespace()); - break; - case "sharedby": - row.add(k.sharedBy != null ? k.sharedBy.withoutPrefix() : null); - break; - case "sharedwith": - row.add(k.sharedWith != null ? k.sharedWith.withoutPrefix() : null); - break; - case "ttl": - row.add(k.metadata != null ? String.valueOf(k.metadata.ttl) : null); - break; - case "ttb": - row.add(k.metadata != null ? String.valueOf(k.metadata.ttb) : null); - break; - case "ttr": - row.add(k.metadata != null ? String.valueOf(k.metadata.ttr) : null); - break; - case "ccd": - row.add(k.metadata != null ? String.valueOf(k.metadata.ccd) : null); - break; - case "createdby": - row.add(k.metadata != null ? k.metadata.createdBy : null); - break; - case "updatedby": - row.add(k.metadata != null ? k.metadata.updatedBy : null); - break; - case "availableat": - row.add(k.metadata != null ? String.valueOf(k.metadata.availableAt) : null); - break; - case "expiresat": - row.add(k.metadata != null ? String.valueOf(k.metadata.expiresAt) : null); - break; - case "refreshat": - row.add(k.metadata != null ? String.valueOf(k.metadata.refreshAt) : null); - break; - case "createdat": - row.add(k.metadata != null ? String.valueOf(k.metadata.createdAt) : null); - break; - case "updatedat": - row.add(k.metadata != null ? String.valueOf(k.metadata.updatedAt) : null); - break; - case "status": - row.add(k.metadata != null ? k.metadata.status : null); - break; - case "version": - row.add(k.metadata != null ? String.valueOf(k.metadata.version) : null); - break; - case "datasignature": - row.add(k.metadata != null ? k.metadata.dataSignature : null); - break; - case "sharedkeystatus": - row.add(k.metadata != null ? k.metadata.sharedKeyStatus : null); - break; - case "ispublic": - row.add(k.metadata != null ? String.valueOf(k.metadata.isPublic) : null); - break; - case "isencrypted": - row.add(k.metadata != null ? String.valueOf(k.metadata.isEncrypted) : null); - break; - case "ishidden": - row.add(k.metadata != null ? String.valueOf(k.metadata.isHidden) : null); - break; - case "namespaceaware": - row.add(k.metadata != null ? String.valueOf(k.metadata.namespaceAware) : null); - break; - case "isbinary": - row.add(k.metadata != null ? String.valueOf(k.metadata.isBinary) : null); - break; - case "iscached": - row.add(k.metadata != null ? String.valueOf(k.metadata.isCached) : null); - break; - case "sharedkeyenc": - row.add(k.metadata != null ? k.metadata.sharedKeyEnc : null); - break; - case "pubkeycs": - row.add(k.metadata != null ? k.metadata.pubKeyCS : null); - break; - case "encoding": - row.add(k.metadata != null ? k.metadata.encoding : null); - break; - default: - throw new IllegalArgumentException(heading + " not recognised as a key or key metadata field"); - } + + @And("dump keys with metadata") + public void dumpKeysWithMetaData() throws Exception { + dumpKeys(context.lookupAtClient(context.getCurrentQualifiedAtSign()), HEADINGS_INCLUDING_METADATA, true); + } + + @And("dump keys with values") + public void dumpKeysWithValues() throws Exception { + dumpKeys(context.lookupAtClient(context.getCurrentQualifiedAtSign()), HEADINGS_INCLUDING_VALUE, true); } - return row; - } - - private List> getAtKeysAsListOfMaps(AtSign atSign, String regex) throws Exception { - List> actual = new ArrayList<>(); - for (Keys.AtKey key : context.getAtClient(atSign).getAtKeys(regex, true).get()) { - List values = toDataTableRow(HEADINGS_INCLUDING_METADATA, key); - Map map = new HashMap<>(); - for (int i = 0; i < HEADINGS_INCLUDING_METADATA.size(); i++) { - map.put(HEADINGS_INCLUDING_METADATA.get(i), values.get(i)); - } - actual.add(map); + @And("{atsign} dump keys with values") + public void dumpKeysWithValues(AtSign clientAtSign) throws Exception { + dumpKeys(context.lookupOnlyAtClient(clientAtSign), HEADINGS_INCLUDING_VALUE, true); + } + + private void dumpKeys(AtClient atClient, List headings, boolean fetchMetaData) throws Exception { + List> raw = new ArrayList<>(); + raw.add(headings); + boolean lookupValue = headings.contains("Value"); + atClient.getAtKeys(".*", fetchMetaData).get().stream() + .forEach(k -> raw.add(toDataTableRow(raw.get(0), k, lookupValue ? lookupStringValue(atClient, k) : null))); + DataTableFormatter.builder() + .prefixRow(" ") + .escapeDelimiters(true) + .build() + .formatTo(DataTable.create(raw), System.out); + } + + private String lookupStringValue(AtClient atClient, Keys.AtKey key) { + try { + if (key.sharedWith != null) { + return atClient.get(new KeyBuilders.SharedKeyBuilder(key.sharedBy, key.sharedWith).key(key.name).build()).get(); + } else if (key.metadata.isPublic) { + return atClient.get(new KeyBuilders.PublicKeyBuilder(key.sharedBy).key(key.name).build()).get(); + } else { + return atClient.get(new KeyBuilders.SelfKeyBuilder(key.sharedBy).key(key.name).build()).get(); + } + } catch (Exception e) { + return e.getMessage(); + } + } + + private List toDataTableRow(List headings, Keys.AtKey k, String value) { + ArrayList row = new ArrayList<>(); + for (String heading : headings) { + switch (toCanonicalKey(heading)) { + case "key": + row.add(k.toString()); + break; + case "value": + row.add(value); + break; + case "name": + row.add(k.name); + break; + case "namespace": + row.add(k.getNamespace()); + break; + case "sharedby": + row.add(k.sharedBy != null ? k.sharedBy.withoutPrefix() : null); + break; + case "sharedwith": + row.add(k.sharedWith != null ? k.sharedWith.withoutPrefix() : null); + break; + case "ttl": + row.add(k.metadata != null ? String.valueOf(k.metadata.ttl) : null); + break; + case "ttb": + row.add(k.metadata != null ? String.valueOf(k.metadata.ttb) : null); + break; + case "ttr": + row.add(k.metadata != null ? String.valueOf(k.metadata.ttr) : null); + break; + case "ccd": + row.add(k.metadata != null ? String.valueOf(k.metadata.ccd) : null); + break; + case "createdby": + row.add(k.metadata != null ? k.metadata.createdBy : null); + break; + case "updatedby": + row.add(k.metadata != null ? k.metadata.updatedBy : null); + break; + case "availableat": + row.add(k.metadata != null ? String.valueOf(k.metadata.availableAt) : null); + break; + case "expiresat": + row.add(k.metadata != null ? String.valueOf(k.metadata.expiresAt) : null); + break; + case "refreshat": + row.add(k.metadata != null ? String.valueOf(k.metadata.refreshAt) : null); + break; + case "createdat": + row.add(k.metadata != null ? String.valueOf(k.metadata.createdAt) : null); + break; + case "updatedat": + row.add(k.metadata != null ? String.valueOf(k.metadata.updatedAt) : null); + break; + case "status": + row.add(k.metadata != null ? k.metadata.status : null); + break; + case "version": + row.add(k.metadata != null ? String.valueOf(k.metadata.version) : null); + break; + case "datasignature": + row.add(k.metadata != null ? k.metadata.dataSignature : null); + break; + case "sharedkeystatus": + row.add(k.metadata != null ? k.metadata.sharedKeyStatus : null); + break; + case "ispublic": + row.add(k.metadata != null ? String.valueOf(k.metadata.isPublic) : null); + break; + case "isencrypted": + row.add(k.metadata != null ? String.valueOf(k.metadata.isEncrypted) : null); + break; + case "ishidden": + row.add(k.metadata != null ? String.valueOf(k.metadata.isHidden) : null); + break; + case "namespaceaware": + row.add(k.metadata != null ? String.valueOf(k.metadata.namespaceAware) : null); + break; + case "isbinary": + row.add(k.metadata != null ? String.valueOf(k.metadata.isBinary) : null); + break; + case "iscached": + row.add(k.metadata != null ? String.valueOf(k.metadata.isCached) : null); + break; + case "sharedkeyenc": + row.add(k.metadata != null ? k.metadata.sharedKeyEnc : null); + break; + case "pubkeycs": + row.add(k.metadata != null ? k.metadata.pubKeyCS : null); + break; + case "encoding": + row.add(k.metadata != null ? k.metadata.encoding : null); + break; + default: + throw new IllegalArgumentException(heading + " not recognised as a key or key metadata field"); + } + } + + return row; + } + + private List> getAtKeysAsListOfMaps(AtClient client, String regex) throws Exception { + List> actual = new ArrayList<>(); + for (Keys.AtKey key : client.getAtKeys(regex, true).get()) { + List values = toDataTableRow(HEADINGS_INCLUDING_METADATA, key, null); + Map map = new HashMap<>(); + for (int i = 0; i < HEADINGS_INCLUDING_METADATA.size(); i++) { + map.put(HEADINGS_INCLUDING_METADATA.get(i), values.get(i)); + } + actual.add(map); + } + return actual; } - return actual; - } } diff --git a/at_client/src/test/java/org/atsign/cucumber/steps/MonitorSteps.java b/at_client/src/test/java/org/atsign/cucumber/steps/MonitorSteps.java index 58460a51..fddaf078 100644 --- a/at_client/src/test/java/org/atsign/cucumber/steps/MonitorSteps.java +++ b/at_client/src/test/java/org/atsign/cucumber/steps/MonitorSteps.java @@ -3,6 +3,7 @@ import io.cucumber.datatable.DataTable; import io.cucumber.datatable.DataTableFormatter; import io.cucumber.java.en.Then; +import org.atsign.client.api.AtClient; import org.atsign.client.api.AtEvents; import org.atsign.common.AtSign; import org.slf4j.Logger; @@ -17,118 +18,143 @@ import static org.atsign.cucumber.helpers.Helpers.testContains; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; +import static org.testcontainers.shaded.com.google.common.util.concurrent.Uninterruptibles.sleepUninterruptibly; import static org.testcontainers.shaded.org.awaitility.Awaitility.await; public class MonitorSteps { - private static final Logger LOGGER = LoggerFactory.getLogger(MonitorSteps.class); + private static final Logger LOGGER = LoggerFactory.getLogger(MonitorSteps.class); - private final AtClientContext context; + private final AtClientContext context; - private final Map keyStatsValues = new ConcurrentHashMap<>(); + private final Map keyStatsValues = new ConcurrentHashMap<>(); - public MonitorSteps(AtClientContext context) { - this.context = context; - } + public MonitorSteps(AtClientContext context) { + this.context = context; + } + + @Then("{ordinal} {atsign} AtClient monitor receives the following") + public void assertNotifications(Integer ordinal, AtSign clientAtSign, DataTable expected) throws Exception { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + assertNotifications(atClient, expected, 5); + } + + @Then("{atsign} AtClient monitor receives the following") + public void assertNotifications(AtSign clientAtSign, DataTable expected) throws Exception { + AtClient atClient = context.lookupOnlyAtClient(clientAtSign); + assertNotifications(atClient, expected, 5); + } + + @Then("AtClient monitor receives the following") + public void assertNotifications(DataTable table) throws Exception { + AtClient atClient = context.lookupOnlyAtClient(context.getCurrentQualifiedAtSign().getAtSign()); + assertNotifications(atClient, table, 5); + } + + private void assertNotifications(AtClient atClient, DataTable table, long awaitSeconds) throws Exception { + assertThat(atClient.isMonitorRunning(), is(true)); + List> expected = table.asMaps(); + try { + await().atMost(awaitSeconds, TimeUnit.SECONDS) + .until(() -> testContains(context.getEventData(atClient), expected, false)); + } catch (Exception e) { + + } + try { + assertContains(context.getEventData(atClient), expected, false); + } catch (Exception e) { + dumpEventsReceived(atClient); + throw e; + } finally { + context.clearEvents(atClient); + } + } - @Then("{atsign} AtClient monitor receives the following") - public void assertNotifications(AtSign atSign, DataTable table) throws Exception { - assertThat(context.getAtClient(atSign).isMonitorRunning(), is(true)); - List> expected = table.asMaps(); - try { - await().atMost(2, TimeUnit.SECONDS) - .until(() -> testContains(context.getEventData(atSign), expected, false)); - } catch (Exception e) { + @Then("{atsign} AtClient monitor does not receive any notifications") + public void assertNoNotifications(AtSign clientAtSign) throws Exception { + AtClient atClient = context.lookupOnlyAtClient(clientAtSign); + assertNoNotifications(atClient); + } + @Then("AtClient monitor does NOT receive any notifications") + public void assertNoNotifications() throws Exception { + AtClient atClient = context.lookupAtClient(context.getCurrentQualifiedAtSign()); + assertNoNotifications(atClient); } - try { - assertContains(context.getEventData(atSign), expected, false); - } catch (Exception e) { - dumpEventsReceived(atSign); - throw e; - } finally { - context.clearEvents(atSign); + + private void assertNoNotifications(AtClient atClient) throws Exception { + assertThat(atClient.isMonitorRunning(), is(false)); + sleepUninterruptibly(2, TimeUnit.SECONDS); + try { + assertThat(context.getEventData(atClient), is(empty())); + } catch (Exception e) { + dumpEventsReceived(atClient); + throw e; + } finally { + context.clearEvents(atClient); + } } - } - - @Then("AtClient monitor receives the following") - public void assertNotifications(DataTable table) throws Exception { - assertNotifications(context.getCurrentAtSign(), table); - } - - @Then("{atsign} AtClient monitor does not receive any notifications") - public void assertNoNotifications(AtSign atSign) throws Exception { - assertThat(context.getAtClient(atSign).isMonitorRunning(), is(false)); - Uninterruptibles.sleepUninterruptibly(2, TimeUnit.SECONDS); - try { - List> eventData = context.getEventData(atSign); - assertThat(eventData, is(empty())); - } catch (Exception e) { - dumpEventsReceived(atSign); - throw e; - } finally { - context.clearEvents(atSign); + + @Then("{atsign} AtClient monitor receives a new statsNotification") + public void assertNewStatsNotification(AtSign clientAtSign) throws Exception { + AtClient atClient = context.lookupOnlyAtClient(clientAtSign); + String key = String.format("statsNotification.%s", clientAtSign.toString()); + assertNewStatsNotification(atClient, key, 5); } - } - - @Then("AtClient monitor does NOT receive any notifications") - public void assertNoNotifications() throws Exception { - assertNoNotifications(context.getCurrentAtSign()); - } - - @Then("{atsign} AtClient monitor receives a new statsNotification") - public void assertNewStatsNotification(AtSign atSign) throws Exception { - String key = String.format("statsNotification.%s", atSign.toString()); - long existingValue = keyStatsValues.getOrDefault(key, 0L); - try { - await().atMost(3, TimeUnit.SECONDS) - .until(() -> getNewStatsNotificationValue(atSign, key, existingValue).isPresent()); - long newValue = getNewStatsNotificationValue(atSign, key, existingValue).get(); - assertThat(newValue, greaterThan(existingValue)); - keyStatsValues.put(key, newValue); - } catch (Exception e) { - dumpEventsReceived(atSign); - throw e; - } finally { - context.clearEvents(atSign); + + @Then("AtClient monitor receives a new statsNotification") + public void assertNewStatsNotification() throws Exception { + QualifiedAtSign currentQualifiedAtSign = context.getCurrentQualifiedAtSign(); + AtClient atClient = context.lookupAtClient(currentQualifiedAtSign); + String key = String.format("statsNotification.%s", currentQualifiedAtSign.getAtSign().toString()); + assertNewStatsNotification(atClient, key, 5); } - } - - private Optional getNewStatsNotificationValue(AtSign atSign, String key, long existingValue) throws Exception { - return context.getEventData(atSign, AtEvents.AtEventType.statsNotification).stream() - .filter(map -> map.containsValue(key)) - .map(map -> Long.valueOf(map.get("value"))) - .filter(value -> value > existingValue) - .findAny(); - } - - @Then("AtClient monitor receives a new statsNotification") - public void assertNewStatsNotification() throws Exception { - assertNewStatsNotification(context.getCurrentAtSign()); - } - - private void dumpEventsReceived(AtSign atSign) throws Exception { - List> eventData = context.getEventData(atSign); - List headings = new ArrayList<>(); -// headings.add("Event Type"); - Set eventDataKeys = new LinkedHashSet<>(); - eventData.forEach(event -> eventDataKeys.addAll(event.keySet())); - headings.addAll(eventDataKeys); - - List> raw = new ArrayList<>(); - raw.add(headings); - for (Map map : eventData) { - List row = new ArrayList<>(); - for (String key : eventDataKeys) { - Object value = map.get(key); - row.add(value != null ? value.toString() : ""); - } - raw.add(row); + + private void assertNewStatsNotification(AtClient atClient, String key, long awaitSeconds) throws Exception { + long existingValue = keyStatsValues.getOrDefault(key, 0L); + try { + await().atMost(awaitSeconds, TimeUnit.SECONDS) + .until(() -> getNewStatsNotificationValue(atClient, key, existingValue).isPresent()); + long newValue = getNewStatsNotificationValue(atClient, key, existingValue).get(); + assertThat(newValue, greaterThan(existingValue)); + keyStatsValues.put(key, newValue); + } catch (Exception e) { + dumpEventsReceived(atClient); + throw e; + } finally { + context.clearEvents(atClient); + } + } + + private Optional getNewStatsNotificationValue(AtClient atClient, String key, long existingValue) throws Exception { + return context.getEventData(atClient, AtEvents.AtEventType.statsNotification).stream() + .filter(map -> map.containsValue(key)) + .map(map -> Long.valueOf(map.get("value"))) + .filter(value -> value > existingValue) + .findAny(); + } + + private void dumpEventsReceived(AtClient atClient) throws Exception { + List> eventData = context.getEventData(atClient); + List headings = new ArrayList<>(); + Set eventDataKeys = new LinkedHashSet<>(); + eventData.forEach(event -> eventDataKeys.addAll(event.keySet())); + headings.addAll(eventDataKeys); + + List> raw = new ArrayList<>(); + raw.add(headings); + for (Map map : eventData) { + List row = new ArrayList<>(); + for (String key : eventDataKeys) { + Object value = map.get(key); + row.add(value != null ? value.toString() : ""); + } + raw.add(row); + } + DataTableFormatter.builder() + .prefixRow(" ") + .escapeDelimiters(true) + .build() + .formatTo(DataTable.create(raw), System.out); } - DataTableFormatter.builder() - .prefixRow(" ") - .escapeDelimiters(true) - .build() - .formatTo(DataTable.create(raw), System.out); - } } diff --git a/at_client/src/test/java/org/atsign/cucumber/steps/ParameterTypes.java b/at_client/src/test/java/org/atsign/cucumber/steps/ParameterTypes.java index 7739df4f..52ae2104 100644 --- a/at_client/src/test/java/org/atsign/cucumber/steps/ParameterTypes.java +++ b/at_client/src/test/java/org/atsign/cucumber/steps/ParameterTypes.java @@ -6,27 +6,42 @@ import java.io.File; import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public class ParameterTypes { - @ParameterType(".+") - public AtSign atsign(String s) { - return new AtSign(s); - } - - @ParameterType(".+") - public File path(String s) { - return new File(s); - } - - @ParameterType("AtKeyNotFoundException") - public Class exception(String className) throws ClassNotFoundException { - return (Class) Class.forName("org.atsign.common.exceptions." + className); - } - - @ParameterType("SECONDS|MINUTES") - public TimeUnit timeunit(String unit) { - return TimeUnit.valueOf(unit); - } + @ParameterType("@\\S+") + public AtSign atsign(String s) { + return new AtSign(s); + } + + @ParameterType("\\S+") + public File path(String s) { + return new File(s); + } + + @ParameterType("(\\d+(st|nd|rd|th))") + public Integer ordinal(String s) { + Matcher matcher = Pattern.compile("\\d+").matcher(s); + if (!matcher.find()) { + throw new IllegalArgumentException("expected 1st or 2nd etc..."); + } + return Integer.parseInt(matcher.group(0)); + } + + @ParameterType("(AtException|AtKeyNotFoundException|AtUnauthorizedException|AtUnauthenticatedException)") + public Class exception(String className) throws ClassNotFoundException { + return (Class) Class.forName("org.atsign.common.exceptions." + className); + } + + @ParameterType("\\w+") + public TimeUnit timeunit(String unit) { + String s = unit.toUpperCase(); + if (!s.endsWith("S")) { + s += "S"; + } + return TimeUnit.valueOf(s); + } } diff --git a/at_client/src/test/java/org/atsign/cucumber/steps/PublicAtKeySteps.java b/at_client/src/test/java/org/atsign/cucumber/steps/PublicAtKeySteps.java index 4e7fdbaa..a04627fe 100644 --- a/at_client/src/test/java/org/atsign/cucumber/steps/PublicAtKeySteps.java +++ b/at_client/src/test/java/org/atsign/cucumber/steps/PublicAtKeySteps.java @@ -3,17 +3,16 @@ import io.cucumber.java.After; import io.cucumber.java.en.Then; import io.cucumber.java.en.When; -import org.atsign.common.AtException; +import org.atsign.client.api.AtClient; +import org.atsign.client.util.KeyStringUtil; import org.atsign.common.AtSign; import org.atsign.common.KeyBuilders; import org.atsign.common.Keys; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.regex.Matcher; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; @@ -21,95 +20,208 @@ public class PublicAtKeySteps { - private static final Logger LOGGER = LoggerFactory.getLogger(PublicAtKeySteps.class); + private static final Logger LOGGER = LoggerFactory.getLogger(PublicAtKeySteps.class); - private final AtClientContext context; + private final AtClientContext context; - private final Map> keys = new HashMap<>(); + public PublicAtKeySteps(AtClientContext context) { + this.context = context; + } + + // put + + @When("{ordinal} {atsign} AtClient.put for PublicKey {word} and value {string}") + public void put(Integer ordinal, AtSign clientAtSign, String name, String value) throws Exception { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + putKeyValue(atClient, clientAtSign, name, value); + } + + @When("{ordinal} {atsign} AtClient.put fails for PublicKey {word} and value {string}") + public void putFails(Integer ordinal, AtSign clientAtSign, String name, String value) throws Exception { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + context.assertException(() -> putKeyValue(atClient, clientAtSign, name, value)); + } + + @When("{atsign} AtClient.put for PublicKey {word} and value {string}") + public void put(AtSign clientAtSign, String name, String value) throws Exception { + AtClient atClient = context.lookupOrCreateAtClient(clientAtSign); + putKeyValue(atClient, clientAtSign, name, value); + } + + @When("{atsign} AtClient.put fails for PublicKey {word} and value {string}") + public void putFails(AtSign clientAtSign, String name, String value) throws Exception { + AtClient atClient = context.lookupOrCreateAtClient(clientAtSign); + context.assertException(() -> putKeyValue(atClient, clientAtSign, name, value)); + } + + @When("AtClient.put for PublicKey {word} and value {string}") + public void put(String name, String value) throws Exception { + QualifiedAtSign currentQualifiedAtSign = context.getCurrentQualifiedAtSign(); + AtClient atClient = context.lookupAtClient(currentQualifiedAtSign); + putKeyValue(atClient, currentQualifiedAtSign.getAtSign(), name, value); + } + + @When("AtClient.put fails for PublicKey {word} and value {string}") + public void putFails(String name, String value) throws Exception { + QualifiedAtSign currentQualifiedAtSign = context.getCurrentQualifiedAtSign(); + AtClient atClient = context.lookupAtClient(currentQualifiedAtSign); + context.assertException(() -> putKeyValue(atClient, currentQualifiedAtSign.getAtSign(), name, value)); + } + + // delete + + @When("{ordinal} {atsign} AtClient.delete for PublicKey {word}") + public void delete(Integer ordinal, AtSign clientAtSign, String name) throws Exception { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + deleteKeyValue(atClient, clientAtSign, name); + } + + @Then("{ordinal} {atsign} AtClient.delete fails for PublicKey {word}") + public void deleteFails(Integer ordinal, AtSign clientAtSign, String name) throws Exception { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + context.assertException(() -> deleteKeyValue(atClient, clientAtSign, name)); + } + + @When("{atsign} AtClient.delete for PublicKey {word}") + public void delete(AtSign clientAtSign, String name) throws Exception { + AtClient atClient = context.lookupOrCreateAtClient(clientAtSign); + deleteKeyValue(atClient, clientAtSign, name); + } + + @Then("{atsign} AtClient.delete fails for PublicKey {word}") + public void deleteFails(AtSign clientAtSign, String name) throws Exception { + AtClient atClient = context.lookupOrCreateAtClient(clientAtSign); + context.assertException(() -> deleteKeyValue(atClient, clientAtSign, name)); + } + + @When("AtClient.delete for PublicKey {word}") + public void delete(String name) throws Exception { + QualifiedAtSign currentQualifiedAtSign = context.getCurrentQualifiedAtSign(); + AtClient atClient = context.lookupAtClient(currentQualifiedAtSign); + deleteKeyValue(atClient, currentQualifiedAtSign.getAtSign(), name); + } + + @When("AtClient.delete fails for PublicKey {word}") + public void deleteFails(String name) throws Exception { + QualifiedAtSign currentQualifiedAtSign = context.getCurrentQualifiedAtSign(); + AtClient atClient = context.lookupAtClient(currentQualifiedAtSign); + context.assertException(() -> deleteKeyValue(atClient, currentQualifiedAtSign.getAtSign(), name)); + } + + // get + + @Then("{ordinal} {atsign} AtClient.get for PublicKey {word} returns value that matches {string}") + public void getAsOwner(Integer ordinal, AtSign clientAtSign, String name, String expected) throws Exception { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + String keyValue = getKeyValue(atClient, clientAtSign, name); + assertThat(keyValue, equalTo(expected)); + } - @After - public void teardown() { - int count = 0; - for (Map.Entry> entry : keys.entrySet()) { - for (Keys.PublicKey key : entry.getValue()) { - try { - context.getAtClient(entry.getKey()).delete(key); - count++; - } catch (AtException e) { - LOGGER.warn("unexpected exception attempting to delete public key {}", key); + @Then("{ordinal} {atsign} AtClient.get fails for PublicKey {word}") + public void getAsOwnerFails(Integer ordinal, AtSign clientAtSign, String name) throws Exception { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + context.assertException(() -> getKeyValue(atClient, clientAtSign, name)); + } + + @Then("{atsign} AtClient.get for PublicKey {word} returns value that matches {string}") + public void getAsOwner(AtSign clientAtSign, String name, String expected) throws Exception { + AtClient atClient = context.lookupOrCreateAtClient(clientAtSign); + String keyValue = getKeyValue(atClient, clientAtSign, name); + assertThat(keyValue, equalTo(expected)); + } + + @Then("{atsign} AtClient.get fails for PublicKey {word}") + public void getAsOwnerFails(AtSign clientAtSign, String name) throws Exception { + AtClient atClient = context.lookupOrCreateAtClient(clientAtSign); + context.assertException(() -> getKeyValue(atClient, clientAtSign, name)); + } + + @Then("AtClient.get for PublicKey {word} returns value that matches {string}") + public void getAsOwner(String name, String expected) throws Exception { + QualifiedAtSign currentQualifiedAtSign = context.getCurrentQualifiedAtSign(); + AtClient atClient = context.lookupAtClient(currentQualifiedAtSign); + String keyValue = getKeyValue(atClient, currentQualifiedAtSign.getAtSign(), name); + assertThat(keyValue, equalTo(expected)); + } + + @Then("AtClient.get fails for PublicKey {word}") + public void getAsOwnerFails(String name) throws Exception { + QualifiedAtSign currentQualifiedAtSign = context.getCurrentQualifiedAtSign(); + AtClient atClient = context.lookupAtClient(currentQualifiedAtSign); + context.assertException(() -> getKeyValue(atClient, currentQualifiedAtSign.getAtSign(), name)); + } + + @Then("{ordinal} {atsign} AtClient.get for PublicKey {word} shared by {atsign} returns value that matches {string}") + public void getAsNonOwner(Integer ordinal, AtSign clientAtSign, String name, AtSign sharedBy, String expected) throws Exception { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + String keyValue = getKeyValue(atClient, sharedBy, name); + assertThat(keyValue, equalTo(expected)); + } + + @Then("{ordinal} {atsign} AtClient.get fails for PublicKey shared by {atsign}") + public void getAsNonOwnerFails(Integer ordinal, AtSign clientAtSign, String name, AtSign sharedBy) throws Exception { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + context.assertException(() -> getKeyValue(atClient, sharedBy, name)); + } + + @Then("{atsign} AtClient.get for PublicKey {word} shared by {atsign} returns value that matches {string}") + public void getAsNonOwner(AtSign clientAtSign, String name, AtSign sharedBy, String expected) throws Exception { + AtClient atClient = context.lookupOrCreateAtClient(clientAtSign); + String keyValue = getKeyValue(atClient, sharedBy, name); + assertThat(keyValue, equalTo(expected)); + } + + @Then("{atsign} AtClient.get fails for PublicKey {word} shared by {atsign}") + public void getAsNonOwnerFails(AtSign clientAtSign, String name, AtSign sharedBy) throws Exception { + AtClient atClient = context.lookupOrCreateAtClient(clientAtSign); + context.assertException(() -> getKeyValue(atClient, sharedBy, name)); + } + + @Then("AtClient.get for PublicKey {word} shared by {atsign} returns value that matches {string}") + public void getAsNonOwner(String name, AtSign sharedBy, String expected) throws Exception { + QualifiedAtSign currentQualifiedAtSign = context.getCurrentQualifiedAtSign(); + AtClient atClient = context.lookupAtClient(currentQualifiedAtSign); + String keyValue = getKeyValue(atClient, sharedBy, name); + assertThat(keyValue, equalTo(expected)); + } + + @Then("AtClient.get fails for PublicKey {word} shared by {atsign}") + public void getAsNonOwnerFails(String name, AtSign sharedBy) throws Exception { + QualifiedAtSign currentQualifiedAtSign = context.getCurrentQualifiedAtSign(); + AtClient atClient = context.lookupAtClient(currentQualifiedAtSign); + context.assertException(() -> getKeyValue(atClient, sharedBy, name)); + } + + private void putKeyValue(AtClient atClient, AtSign sharedBy, String name, String value) throws InterruptedException, ExecutionException { + Keys.PublicKey key = toKey(sharedBy, name); + atClient.put(key, value).get(); + } + + private String getKeyValue(AtClient atClient, AtSign sharedBy, String name) throws Exception { + Keys.PublicKey key = toKey(sharedBy, name); + return atClient.get(key).get(); + } + + private void deleteKeyValue(AtClient atClient, AtSign owner, String name) throws Exception { + Keys.PublicKey key = toKey(owner, name); + atClient.delete(key).get(); + } + + private Keys.PublicKey toKey(AtSign sharedBy, String s) { + KeyBuilders.PublicKeyBuilder builder = new KeyBuilders.PublicKeyBuilder(sharedBy); + Matcher matcher = KeyStringUtil.createNamespaceQualifiedKeyNameMatcher(s); + if (matcher.matches()) { + if (context.isNamespaceSet()) { + throw new IllegalArgumentException("context has namespace set, intention is ambiguous"); + } + builder.namespace(matcher.group(2)).key(matcher.group(1)); + } else if (context.isNamespaceSet()) { + builder.namespace(context.getNamespace()).key(s); + } else { + builder.key(s); } - } - } - keys.clear(); - LOGGER.info("deleted {} self keys", count); - } - - public PublicAtKeySteps(AtClientContext context) { - this.context = context; - } - - @When("{atsign} AtClient.put for PublicKey {word} and value {string}") - public void putPublicKey(AtSign atSign, String name, String value) throws Exception { - Keys.PublicKey key = toKey(atSign, name); - context.getAtClient(atSign).put(key, value).get(); - keys.computeIfAbsent(atSign, k -> new HashSet<>()).add(key); - } - - @When("AtClient.put for PublicKey {word} and value {string}") - public void putPublicKey(String name, String value) throws Exception { - putPublicKey(context.getCurrentAtSign(), name, value); - } - - @When("{atsign} AtClient.delete for PublicKey {word}") - public void deletePublicKey(AtSign atSign, String name) throws Exception { - Keys.PublicKey key = toKey(atSign, name); - context.getAtClient(atSign).delete(key).get(); - keys.computeIfAbsent(atSign, k -> new HashSet<>()).remove(key); - } - - @When("AtClient.delete for PublicKey {word}") - public void deletePublicKey(String name) throws Exception { - deletePublicKey(context.getCurrentAtSign(), name); - } - - @Then("{atsign} AtClient.get for PublicKey {word} returns value that matches {string}") - public void assertGetPublicKeyResult(AtSign atSign, String name, String expected) throws Exception { - String actual = context.getAtClient(atSign).get(toKey(atSign, name)).get(); - assertThat(actual, equalTo(expected)); - } - - @Then("{atsign} AtClient.get for PublicKey {word} shared by {atsign} returns value that matches {string}") - public void assertGetPublicKeyResult(AtSign atSign, String name, AtSign sharedBy, String expected) throws Exception { - String actual = context.getAtClient(atSign).get(toKey(sharedBy, name)).get(); - assertThat(actual, equalTo(expected)); - } - - @Then("AtClient.get for PublicKey {word} returns value that matches {string}") - public void assertGetPublicKeyResult(String name, String expected) throws Exception { - assertGetPublicKeyResult(context.getCurrentAtSign(), name, expected); - } - - @Then("{atsign} AtClient.get for PublicKey {word} receives {exception} and message {string}") - public void assertGetPublicKeyException(AtSign atSign, - String name, - Class expectedException, - String expectedMessage) { - Exception ex = assertThrows(Exception.class, - () -> context.getAtClient(atSign).get(toKey(atSign, name)).get()); - assertThat(ex.getCause().getClass(), typeCompatibleWith(expectedException)); - assertThat(ex.getMessage(), containsString(expectedMessage)); - } - - @Then("AtClient.get for PublicKey {word} receives {exception} and message {string}") - public void assertGetPublicKeyException(String name, - Class expectedException, - String expectedMessage) { - assertGetPublicKeyException(context.getCurrentAtSign(), name, expectedException, expectedMessage); - } - - private Keys.PublicKey toKey(AtSign atSign, String name) { - Keys.PublicKey key = new KeyBuilders.PublicKeyBuilder(atSign).key(name).build(); - key.metadata.ttl = (int) context.getKeyTtl(); - return key; - } + Keys.PublicKey key = builder.build(); + key.metadata.ttl = (int) context.getKeyTtl(); + return key; + } } diff --git a/at_client/src/test/java/org/atsign/cucumber/steps/QualifiedAtSign.java b/at_client/src/test/java/org/atsign/cucumber/steps/QualifiedAtSign.java new file mode 100644 index 00000000..f15f1d92 --- /dev/null +++ b/at_client/src/test/java/org/atsign/cucumber/steps/QualifiedAtSign.java @@ -0,0 +1,43 @@ +package org.atsign.cucumber.steps; + +import org.atsign.client.util.EnrollmentId; +import org.atsign.common.AtSign; + +import java.util.Objects; + +public class QualifiedAtSign { + + private final AtSign atSign; + + private final EnrollmentId enrollmentId; + + public QualifiedAtSign(AtSign atSign, EnrollmentId enrollmentId) { + this.atSign = atSign; + this.enrollmentId = enrollmentId; + } + + public AtSign getAtSign() { + return atSign; + } + + public EnrollmentId getEnrollmentId() { + return enrollmentId; + } + + @Override + public String toString() { + return enrollmentId != null ? atSign + "(" + enrollmentId + ")" : atSign.toString(); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + QualifiedAtSign that = (QualifiedAtSign) o; + return Objects.equals(atSign, that.atSign) && Objects.equals(enrollmentId, that.enrollmentId); + } + + @Override + public int hashCode() { + return Objects.hash(atSign, enrollmentId); + } +} diff --git a/at_client/src/test/java/org/atsign/cucumber/steps/SelfAtKeySteps.java b/at_client/src/test/java/org/atsign/cucumber/steps/SelfAtKeySteps.java index d6565a6b..9a8d6b72 100644 --- a/at_client/src/test/java/org/atsign/cucumber/steps/SelfAtKeySteps.java +++ b/at_client/src/test/java/org/atsign/cucumber/steps/SelfAtKeySteps.java @@ -1,109 +1,171 @@ package org.atsign.cucumber.steps; -import io.cucumber.java.After; import io.cucumber.java.en.Then; import io.cucumber.java.en.When; -import org.atsign.common.AtException; +import org.atsign.client.api.AtClient; +import org.atsign.client.util.KeyStringUtil; import org.atsign.common.AtSign; import org.atsign.common.KeyBuilders; import org.atsign.common.Keys; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.regex.Matcher; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.*; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.hamcrest.Matchers.equalTo; public class SelfAtKeySteps { - private static final Logger LOGGER = LoggerFactory.getLogger(SelfAtKeySteps.class); + private static final Logger LOGGER = LoggerFactory.getLogger(SelfAtKeySteps.class); - private final AtClientContext context; + private final AtClientContext context; - private final Map> keys = new HashMap<>(); + public SelfAtKeySteps(AtClientContext context) { + this.context = context; + } + + // put + + @When("{ordinal} {atsign} AtClient.put for SelfKey {word} and value {string}") + public void put(Integer ordinal, AtSign clientAtSign, String name, String value) throws Exception { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + putKeyValue(atClient, clientAtSign, name, value); + } + + @When("{ordinal} {atsign} AtClient.put fails for SelfKey {word} and value {string}") + public void putFails(Integer ordinal, AtSign clientAtSign, String name, String value) throws Exception { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + context.assertException(() -> putKeyValue(atClient, clientAtSign, name, value)); + } + + @When("{atsign} AtClient.put for SelfKey {word} and value {string}") + public void put(AtSign clientAtSign, String name, String value) throws Exception { + AtClient atClient = context.lookupOrCreateAtClient(clientAtSign); + putKeyValue(atClient, clientAtSign, name, value); + } + + @When("AtClient.put for SelfKey {word} and value {string}") + public void put(String name, String value) throws Exception { + QualifiedAtSign currentQualifiedAtSign = context.getCurrentQualifiedAtSign(); + AtClient atClient = context.lookupAtClient(currentQualifiedAtSign); + putKeyValue(atClient, currentQualifiedAtSign.getAtSign(), name, value); + } + + // delete - @After - public void teardown() { - int count = 0; - for (Map.Entry> entry : keys.entrySet()) { - for (Keys.SelfKey key : entry.getValue()) { - try { - context.getAtClient(entry.getKey()).delete(key); - count++; - } catch (AtException e) { - LOGGER.warn("unexpected exception attempting to delete self key {}", key); + @When("{ordinal} {atsign} AtClient.delete for SelfKey {word}") + public void delete(Integer ordinal, AtSign clientAtSign, String name) throws Exception { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + deleteKeyValue(atClient, clientAtSign, name); + } + + @Then("{ordinal} {atsign} AtClient.delete fails for SelfKey {word}") + public void deleteFails(Integer ordinal, AtSign clientAtSign, String name) throws Exception { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + context.assertException(() -> deleteKeyValue(atClient, clientAtSign, name)); + } + + @When("{atsign} AtClient.delete for SelfKey {word}") + public void delete(AtSign clientAtSign, String name) throws Exception { + AtClient atClient = context.lookupOrCreateAtClient(clientAtSign); + deleteKeyValue(atClient, clientAtSign, name); + } + + @Then("{atsign} AtClient.delete fails for SelfKey {word}") + public void deleteFails(AtSign clientAtSign, String name) throws Exception { + AtClient atClient = context.lookupOrCreateAtClient(clientAtSign); + context.assertException(() -> deleteKeyValue(atClient, clientAtSign, name)); + } + + @When("AtClient.delete for SelfKey {word}") + public void delete(String name) throws Exception { + QualifiedAtSign currentQualifiedAtSign = context.getCurrentQualifiedAtSign(); + AtClient atClient = context.lookupAtClient(currentQualifiedAtSign); + deleteKeyValue(atClient, currentQualifiedAtSign.getAtSign(), name); + } + + @Then("AtClient.delete fails for SelfKey {word}") + public void deleteFails(String name) throws Exception { + QualifiedAtSign currentQualifiedAtSign = context.getCurrentQualifiedAtSign(); + AtClient atClient = context.lookupAtClient(currentQualifiedAtSign); + context.assertException(() -> deleteKeyValue(atClient, currentQualifiedAtSign.getAtSign(), name)); + } + + // get + + @Then("{ordinal} {atsign} AtClient.get for SelfKey {word} returns value that matches {string}") + public void assertGet(Integer ordinal, AtSign clientAtSign, String name, String expected) throws Exception { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + String keyValue = getKeyValue(atClient, clientAtSign, name); + assertThat(keyValue, equalTo(expected)); + } + + @Then("{ordinal} {atsign} AtClient.get fails for SelfKey {word}") + public void assertGetFails(Integer ordinal, AtSign clientAtSign, String name) throws Exception { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + context.assertException(() -> getKeyValue(atClient, clientAtSign, name)); + } + + @Then("{atsign} AtClient.get for SelfKey {word} returns value that matches {string}") + public void assertGet(AtSign clientAtSign, String name, String expected) throws Exception { + AtClient atClient = context.lookupOrCreateAtClient(clientAtSign); + String keyValue = getKeyValue(atClient, clientAtSign, name); + assertThat(keyValue, equalTo(expected)); + } + + @Then("{atsign} AtClient.get fails for SelfKey {word}") + public void assertGetFails(AtSign clientAtSign, String name) throws Exception { + AtClient atClient = context.lookupOrCreateAtClient(clientAtSign); + context.assertException(() -> getKeyValue(atClient, clientAtSign, name)); + } + + @Then("AtClient.get for SelfKey {word} returns value that matches {string}") + public void assertGet(String name, String expected) throws Exception { + QualifiedAtSign currentQualifiedAtSign = context.getCurrentQualifiedAtSign(); + AtClient atClient = context.lookupAtClient(currentQualifiedAtSign); + String keyValue = getKeyValue(atClient, currentQualifiedAtSign.getAtSign(), name); + assertThat(keyValue, equalTo(expected)); + } + + @Then("AtClient.get fails for SelfKey {word}") + public void assertGetFails(String name) throws Exception { + QualifiedAtSign currentQualifiedAtSign = context.getCurrentQualifiedAtSign(); + AtClient atClient = context.lookupAtClient(currentQualifiedAtSign); + context.assertException(() -> getKeyValue(atClient, currentQualifiedAtSign.getAtSign(), name)); + } + + private void putKeyValue(AtClient atClient, AtSign atSign, String name, String value) throws InterruptedException, ExecutionException { + Keys.SelfKey key = createKey(atSign, name); + atClient.put(key, value).get(); + } + + private String getKeyValue(AtClient atClient, AtSign atSign, String name) throws Exception { + Keys.SelfKey key = createKey(atSign, name); + return atClient.get(key).get(); + } + + private void deleteKeyValue(AtClient atClient, AtSign atSign, String name) throws Exception { + Keys.SelfKey key = createKey(atSign, name); + atClient.delete(key).get(); + } + + private Keys.SelfKey createKey(AtSign owner, String s) { + KeyBuilders.SelfKeyBuilder builder = new KeyBuilders.SelfKeyBuilder(owner); + Matcher matcher = KeyStringUtil.createNamespaceQualifiedKeyNameMatcher(s); + if (matcher.matches()) { + if (context.isNamespaceSet()) { + throw new IllegalArgumentException("context has namespace set, intention is ambiguous"); + } + builder.namespace(matcher.group(2)).key(matcher.group(1)); + } else if (context.isNamespaceSet()) { + builder.namespace(context.getNamespace()).key(s); + } else { + builder.key(s); } - } - } - keys.clear(); - LOGGER.info("deleted {} self keys", count); - } - - public SelfAtKeySteps(AtClientContext context) { - this.context = context; - } - - @When("{atsign} AtClient.put for SelfKey {word} and value {string}") - public void putSelfKey(AtSign atSign, String name, String value) throws Exception { - Keys.SelfKey key = toKey(atSign, name); - context.getAtClient(atSign).put(key, value).get(); - keys.computeIfAbsent(atSign, k -> new HashSet<>()).add(key); - } - - @When("AtClient.put for SelfKey {word} and value {string}") - public void putSelfKey(String name, String value) throws Exception { - putSelfKey(context.getCurrentAtSign(), name, value); - } - - @When("{atsign} AtClient.delete for SelfKey {word}") - public void deleteSelfKey(AtSign atSign, String name) throws Exception { - Keys.SelfKey key = toKey(atSign, name); - context.getAtClient(atSign).delete(key).get(); - keys.computeIfAbsent(atSign, k -> new HashSet<>()).remove(key); - } - - @When("AtClient.delete for SelfKey {word}") - public void deleteSelfKey(String name) throws Exception { - deleteSelfKey(context.getCurrentAtSign(), name); - } - - @Then("{atsign} AtClient.get for SelfKey {word} returns value that matches {string}") - public void assertGetSelfKeyResult(AtSign atSign, String name, String expected) throws Exception { - String actual = context.getAtClient(atSign).get(toKey(atSign, name)).get(); - assertThat(actual, equalTo(expected)); - } - - @Then("AtClient.get for SelfKey {word} returns value that matches {string}") - public void assertGetSelfKeyResult(String name, String expected) throws Exception { - assertGetSelfKeyResult(context.getCurrentAtSign(), name, expected); - } - - @Then("{atsign} AtClient.get for SelfKey {word} receives {exception} and message {string}") - public void assertGetSelfKeyException(AtSign atSign, - String name, - Class expectedException, - String expectedMessage) { - Exception ex = assertThrows(Exception.class, - () -> context.getAtClient(atSign).get(toKey(atSign, name)).get()); - assertThat(ex.getCause().getClass(), typeCompatibleWith(expectedException)); - assertThat(ex.getMessage(), containsString(expectedMessage)); - } - - @Then("AtClient.get for SelfKey {word} receives {exception} and message {string}") - public void assertGetSelfKeyException(String name, - Class expectedException, - String expectedMessage) { - assertGetSelfKeyException(context.getCurrentAtSign(), name, expectedException, expectedMessage); - } - - private Keys.SelfKey toKey(AtSign atSign, String name) { - Keys.SelfKey key = new KeyBuilders.SelfKeyBuilder(atSign).key(name).build(); - key.metadata.ttl = (int) context.getKeyTtl(); - return key; - } + Keys.SelfKey key = builder.build(); + key.metadata.ttl = (int) context.getKeyTtl(); + return key; + } } diff --git a/at_client/src/test/java/org/atsign/cucumber/steps/SharedAtKeySteps.java b/at_client/src/test/java/org/atsign/cucumber/steps/SharedAtKeySteps.java index 6cf6c982..216cfe75 100644 --- a/at_client/src/test/java/org/atsign/cucumber/steps/SharedAtKeySteps.java +++ b/at_client/src/test/java/org/atsign/cucumber/steps/SharedAtKeySteps.java @@ -3,140 +3,219 @@ import io.cucumber.java.After; import io.cucumber.java.en.Then; import io.cucumber.java.en.When; -import org.atsign.common.AtException; +import org.atsign.client.api.AtClient; +import org.atsign.client.util.KeyStringUtil; import org.atsign.common.AtSign; import org.atsign.common.KeyBuilders; import org.atsign.common.Keys; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; +import java.util.regex.Matcher; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.*; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.hamcrest.Matchers.equalTo; public class SharedAtKeySteps { - private static final Logger LOGGER = LoggerFactory.getLogger(SharedAtKeySteps.class); + private static final Logger LOGGER = LoggerFactory.getLogger(SharedAtKeySteps.class); - private final AtClientContext context; + private final AtClientContext context; - private final Map> keys = new HashMap<>(); + public SharedAtKeySteps(AtClientContext context) { + this.context = context; + } + + // put + + @When("{ordinal} {atsign} AtClient.put for SharedKey {word} shared with {atsign} and value {string}") + public void putAsOwner(Integer ordinal, AtSign clientAtSign, String name, AtSign sharedWith, String value) throws Exception { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + putKeyValue(atClient, clientAtSign, name, sharedWith, value); + } + + @Then("{ordinal} {atsign} AtClient.put fails for SharedKey {word} shared with {atsign} and value {string}") + public void putAsOwnerFails(Integer ordinal, AtSign clientAtSign, String name, AtSign sharedWith, String value) throws Exception { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + context.assertException(() -> putKeyValue(atClient, clientAtSign, name, sharedWith, value)); + } + + @When("{atsign} AtClient.put for SharedKey {word} shared with {atsign} and value {string}") + public void putAsOwner(AtSign clientAtSign, String name, AtSign sharedWith, String value) throws Exception { + AtClient atClient = context.lookupOrCreateAtClient(clientAtSign); + putKeyValue(atClient, clientAtSign, name, sharedWith, value); + } + + @Then("{atsign} AtClient.put fails for SharedKey {word} shared with {atsign} and value {string}") + public void putAsOwnerFails(AtSign clientAtSign, String name, AtSign sharedWith, String value) throws Exception { + AtClient atClient = context.lookupOrCreateAtClient(clientAtSign); + context.assertException(() -> putKeyValue(atClient, clientAtSign, name, sharedWith, value)); + } + + @When("AtClient.put for SharedKey {word} shared with {atsign} and value {string}") + public void putAsOwner(String name, AtSign sharedWith, String value) throws Exception { + QualifiedAtSign currentQualifiedAtSign = context.getCurrentQualifiedAtSign(); + AtClient atClient = context.lookupAtClient(currentQualifiedAtSign); + putKeyValue(atClient, currentQualifiedAtSign.getAtSign(), name, sharedWith, value); + } + + @Then("AtClient.put for fails SharedKey {word} shared with {atsign} and value {string}") + public void putAsOwnerFails(String name, AtSign sharedWith, String value) throws Exception { + QualifiedAtSign currentQualifiedAtSign = context.getCurrentQualifiedAtSign(); + AtClient atClient = context.lookupAtClient(currentQualifiedAtSign); + context.assertException(() -> putKeyValue(atClient, currentQualifiedAtSign.getAtSign(), name, sharedWith, value)); + } + + // delete + + @When("{ordinal} {atsign} AtClient.delete for SharedKey {word} shared with {atsign}") + public void deleteAsOwner(Integer ordinal, AtSign clientAtSign, String name, AtSign sharedWith) throws Exception { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + deleteKeyValue(atClient, clientAtSign, name, sharedWith); + } + + @Then("{ordinal} {atsign} AtClient.delete fails for SharedKey {word} shared with {atsign}") + public void deleteAsOwnerFails(Integer ordinal, AtSign clientAtSign, String name, AtSign sharedWith) throws Exception { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + context.assertException(() -> deleteKeyValue(atClient, clientAtSign, name, sharedWith)); + } + + @When("{atsign} AtClient.delete for SharedKey {word} shared with {atsign}") + public void deleteAsOwner(AtSign clientAtSign, String name, AtSign sharedWith) throws Exception { + AtClient atClient = context.lookupOrCreateAtClient(clientAtSign); + deleteKeyValue(atClient, clientAtSign, name, sharedWith); + } + + @Then("{atsign} AtClient.delete fails for SharedKey {word} shared with {atsign}") + public void deleteAsOwnerFails(AtSign clientAtSign, String name, AtSign sharedWith) throws Exception { + AtClient atClient = context.lookupOrCreateAtClient(clientAtSign); + context.assertException(() -> deleteKeyValue(atClient, clientAtSign, name, sharedWith)); + } + + @When("AtClient.delete for SharedKey {word} shared with {atsign}") + public void deleteAsOwner(String name, AtSign sharedWith) throws Exception { + QualifiedAtSign currentQualifiedAtSign = context.getCurrentQualifiedAtSign(); + AtClient atClient = context.lookupAtClient(currentQualifiedAtSign); + deleteKeyValue(atClient, currentQualifiedAtSign.getAtSign(), name, sharedWith); + } + + // get as owner + + @Then("{ordinal} {atsign} AtClient.get for SharedKey {word} shared with {atsign} returns value that matches {string}") + public void assertGetAsOwner(Integer ordinal, AtSign clientAtSign, String name, AtSign sharedWith, String expected) throws Exception { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + String keyValue = getKeyValue(atClient, clientAtSign, name, sharedWith); + assertThat(keyValue, equalTo(expected)); + } + + @Then("{ordinal} {atsign} AtClient.get fails for SharedKey {word} shared with {atsign}") + public void assertGetExceptionAsOwner(Integer ordinal, AtSign clientAtSign, String name, AtSign sharedWith) { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + context.assertException(() -> getKeyValue(atClient, clientAtSign, name, sharedWith)); + } - @After - public void teardown() { - int count = 0; - for (Map.Entry> entry : keys.entrySet()) { - for (Keys.SharedKey key : entry.getValue()) { - try { - context.getAtClient(entry.getKey()).delete(key); - count++; - } catch (AtException e) { - LOGGER.warn("unexpected exception attempting to delete shared key {}", key); + @Then("{atsign} AtClient.get for SharedKey {word} shared with {atsign} returns value that matches {string}") + public void assertGetAsOwner(AtSign clientAtSign, String name, AtSign sharedWith, String expected) throws Exception { + AtClient atClient = context.lookupOrCreateAtClient(clientAtSign); + String keyValue = getKeyValue(atClient, clientAtSign, name, sharedWith); + assertThat(keyValue, equalTo(expected)); + } + + @Then("{atsign} AtClient.get fails for SharedKey {word} shared with {atsign}") + public void assertGetExceptionAsOwner(AtSign clientAtSign, String name, AtSign sharedWith) throws Exception { + AtClient atClient = context.lookupOrCreateAtClient(clientAtSign); + context.assertException(() -> getKeyValue(atClient, clientAtSign, name, sharedWith)); + } + + @Then("AtClient.get for SharedKey {word} shared with {atsign} returns value that matches {string}") + public void assertGetAsOwner(String name, AtSign sharedWith, String expected) throws Exception { + QualifiedAtSign currentQualifiedAtSign = context.getCurrentQualifiedAtSign(); + AtClient atClient = context.lookupAtClient(currentQualifiedAtSign); + String keyValue = getKeyValue(atClient, currentQualifiedAtSign.getAtSign(), name, sharedWith); + assertThat(keyValue, equalTo(expected)); + } + + @Then("AtClient.get fails for SharedKey {word} shared with {atsign}") + public void assertGetExceptionAsOwner(String name, AtSign sharedWith) { + QualifiedAtSign currentQualifiedAtSign = context.getCurrentQualifiedAtSign(); + AtClient atClient = context.lookupAtClient(currentQualifiedAtSign); + context.assertException(() -> getKeyValue(atClient, currentQualifiedAtSign.getAtSign(), name, sharedWith)); + } + + // get as recipient + + @Then("{ordinal} {atsign} AtClient.get for SharedKey {word} shared by {atsign} returns value that matches {string}") + public void assertGetAsRecipient(Integer ordinal, AtSign clientAtSign, String name, AtSign sharedBy, String expected) throws Exception { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + String keyValue = getKeyValue(atClient, sharedBy, name, clientAtSign); + assertThat(keyValue, equalTo(expected)); + } + + @Then("{ordinal} {atsign} AtClient.get fails for SharedKey {word} shared by {atsign}}") + public void assertGetSharedKeyResultForRecipientFails(Integer ordinal, AtSign clientAtSign, String name, AtSign sharedBy) throws Exception { + AtClient atClient = context.lookupAtClient(clientAtSign, ordinal); + context.assertException(() -> getKeyValue(atClient, sharedBy, name, clientAtSign)); + } + + @Then("{atsign} AtClient.get for SharedKey {word} shared by {atsign} returns value that matches {string}") + public void assertGetAsRecipient(AtSign clientAtSign, String name, AtSign sharedBy, String expected) throws Exception { + AtClient atClient = context.lookupOrCreateAtClient(clientAtSign); + String keyValue = getKeyValue(atClient, sharedBy, name, clientAtSign); + assertThat(keyValue, equalTo(expected)); + } + + @Then("{atsign} AtClient.get fails for SharedKey {word} shared by {atsign}") + public void assertGetAsRecipientFails(AtSign clientAtSign, String name, AtSign sharedBy) throws Exception { + AtClient atClient = context.lookupOrCreateAtClient(clientAtSign); + context.assertException(() -> getKeyValue(atClient, sharedBy, name, clientAtSign)); + } + + @Then("AtClient.get for SharedKey {word} shared by {atsign} returns value that matches {string}") + public void assertGetAsRecipient(String name, AtSign sharedBy, String expected) throws Exception { + QualifiedAtSign currentQualifiedAtSign = context.getCurrentQualifiedAtSign(); + AtClient atClient = context.lookupAtClient(currentQualifiedAtSign); + String keyValue = getKeyValue(atClient, sharedBy, name, currentQualifiedAtSign.getAtSign()); + assertThat(keyValue, equalTo(expected)); + } + + @Then("AtClient.get fails for SharedKey {word} shared by {atsign}") + public void assertGetAsRecipientFails(String name, AtSign sharedBy, String expected) throws Exception { + QualifiedAtSign currentQualifiedAtSign = context.getCurrentQualifiedAtSign(); + AtClient atClient = context.lookupAtClient(currentQualifiedAtSign); + context.assertException(() -> getKeyValue(atClient, sharedBy, name, currentQualifiedAtSign.getAtSign())); + } + + private void putKeyValue(AtClient atClient, AtSign sharedBy, String name, AtSign sharedWith, String value) throws Exception { + Keys.SharedKey key = createKey(sharedBy, name, sharedWith); + atClient.put(key, value).get(); + } + + private String getKeyValue(AtClient atClient, AtSign sharedBy, String name,AtSign sharedWith) throws Exception { + Keys.SharedKey key = createKey(sharedBy, name, sharedWith); + return atClient.get(key).get(); + } + + private void deleteKeyValue(AtClient atClient, AtSign sharedBy, String name,AtSign sharedWith) throws Exception { + Keys.SharedKey key = createKey(sharedBy, name, sharedWith); + atClient.delete(key).get(); + } + + private Keys.SharedKey createKey(AtSign sharedBy, String s, AtSign sharedWith) { + KeyBuilders.SharedKeyBuilder builder = new KeyBuilders.SharedKeyBuilder(sharedBy, sharedWith); + Matcher matcher = KeyStringUtil.createNamespaceQualifiedKeyNameMatcher(s); + if (matcher.matches()) { + if (context.isNamespaceSet()) { + throw new IllegalArgumentException("context has namespace set, intention is ambiguous"); + } + builder.namespace(matcher.group(2)).key(matcher.group(1)); + } else if (context.isNamespaceSet()) { + builder.namespace(context.getNamespace()).key(s); + } else { + builder.key(s); } - } - } - keys.clear(); - LOGGER.info("deleted {} shared keys", count); - } - - public SharedAtKeySteps(AtClientContext context) { - this.context = context; - } - - @When("{atsign} AtClient.put for SharedKey {word} shared with {atsign} and value {string}") - public void putSharedKey(AtSign atSign, String name, AtSign sharedWith, String value) throws Exception { - Keys.SharedKey key = toKey(atSign, name, sharedWith); - context.getAtClient(atSign).put(key, value).get(); - keys.computeIfAbsent(atSign, k -> new HashSet<>()).add(key); - } - - @When("AtClient.put for SharedKey {word} shared with {atsign} and value {string}") - public void putSharedKey(String name, AtSign sharedWith, String value) throws Exception { - putSharedKey(context.getCurrentAtSign(), name, sharedWith, value); - } - - @When("{atsign} AtClient.delete for SharedKey {word} shared with {atsign}") - public void deleteSharedKey(AtSign atSign, String name, AtSign sharedWith) throws Exception { - Keys.SharedKey key = toKey(atSign, name, sharedWith); - context.getAtClient(atSign).delete(key).get(); - keys.computeIfAbsent(atSign, k -> new HashSet<>()).remove(key); - } - - @When("AtClient.delete for SharedKey {word} shared with {atsign}") - public void deleteSharedKey(String name, AtSign sharedWith) throws Exception { - deleteSharedKey(context.getCurrentAtSign(), name, sharedWith); - } - - @Then("{atsign} AtClient.get for SharedKey {word} shared with {atsign} returns value that matches {string}") - public void assertGetSharedKeyResultForOwner(AtSign atSign, String name, AtSign sharedWith, String expected) throws Exception { - String actual = context.getAtClient(atSign).get(toKey(atSign, name, sharedWith)).get(); - assertThat(actual, equalTo(expected)); - } - - @Then("{atsign} AtClient.get for SharedKey {word} shared by {atsign} returns value that matches {string}") - public void assertGetSharedKeyResultForRecipient(AtSign atSign, String name, AtSign sharedBy, String expected) throws Exception { - String actual = context.getAtClient(atSign).get(toKey(sharedBy, name, atSign)).get(); - assertThat(actual, equalTo(expected)); - } - - @Then("AtClient.get for SharedKey {word} shared with {atsign} returns value that matches {string}") - public void assertGetSharedKeyResultForOwner(String name, AtSign sharedWith, String expected) throws Exception { - assertGetSharedKeyResultForOwner(context.getCurrentAtSign(), name, sharedWith, expected); - } - - @Then("AtClient.get for SharedKey {word} shared by {atsign} returns value that matches {string}") - public void assertGetSharedKeyResultForRecipient(String name, AtSign sharedBy, String expected) throws Exception { - assertGetSharedKeyResultForRecipient(context.getCurrentAtSign(), name, sharedBy, expected); - } - - @Then("{atsign} AtClient.get for SharedKey {word} shared with {atsign} receives {exception} with message {string}") - public void assertGetSharedKeyExceptionForOwner(AtSign atSign, - String name, - AtSign sharedWith, - Class expectedException, - String expectedMessage) { - Exception ex = assertThrows(Exception.class, - () -> context.getAtClient(atSign).get(toKey(atSign, name, sharedWith)).get()); - assertThat(ex.getCause().getClass(), typeCompatibleWith(expectedException)); - assertThat(ex.getMessage(), containsString(expectedMessage)); - } - - @Then("AtClient.get for SharedKey {word} shared with {atsign} receives {exception} and message {string}") - public void assertGetSharedKeyExceptionForOwner(String name, - AtSign sharedWith, - Class expectedException, - String expectedMessage) { - assertGetSharedKeyExceptionForOwner(context.getCurrentAtSign(), name, sharedWith, expectedException, expectedMessage); - } - - @Then("{atsign} AtClient.get for SharedKey {word} shared by {atsign} receives {exception} and message {string}") - public void assertGetSharedKeyExceptionForRecipient(AtSign atSign, - String name, - AtSign sharedBy, - Class expectedException, - String expectedMessage) { - Exception ex = assertThrows(Exception.class, - () -> context.getAtClient(atSign).get(toKey(sharedBy, name, atSign)).get()); - assertThat(ex.getCause().getClass(), typeCompatibleWith(expectedException)); - assertThat(ex.getMessage(), containsString(expectedMessage)); - } - - @Then("AtClient.get for SharedKey {word} shared by {atsign} receives {exception} with message {string}") - public void assertGetSharedKeyExceptionForRecipient(String name, - AtSign sharedBy, - Class expectedException, - String expectedMessage) { - assertGetSharedKeyExceptionForRecipient(context.getCurrentAtSign(), name, sharedBy, expectedException, expectedMessage); - } - - private Keys.SharedKey toKey(AtSign atSign, String name, AtSign sharedWith) { - Keys.SharedKey key = new KeyBuilders.SharedKeyBuilder(atSign, sharedWith).key(name).build(); - key.metadata.ttl = (int) context.getKeyTtl(); - return key; - } + Keys.SharedKey key = builder.build(); + key.metadata.ttl = (int) context.getKeyTtl(); + return key; + } + } diff --git a/at_client/src/test/java/org/atsign/virtualenv/VirtualEnv.java b/at_client/src/test/java/org/atsign/virtualenv/VirtualEnv.java index bd8a0793..8337c6f4 100644 --- a/at_client/src/test/java/org/atsign/virtualenv/VirtualEnv.java +++ b/at_client/src/test/java/org/atsign/virtualenv/VirtualEnv.java @@ -13,8 +13,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; -import static java.util.concurrent.TimeUnit.HOURS; -import static java.util.concurrent.TimeUnit.SECONDS; +import static java.util.concurrent.TimeUnit.*; public class VirtualEnv { @@ -24,8 +23,10 @@ public class VirtualEnv { public static void main(String[] args) throws Exception { setUp(); - LOGGER.info("running for 1 hour..."); + LOGGER.info("sleeping for 1 hour, after which container will be torn down..."); Thread.sleep(HOURS.toMillis(1)); + LOGGER.info("tearing down"); + tearDown(); } public static void setUp() { @@ -36,9 +37,8 @@ public static void setUp() { CONTAINER.start(); latch.await(20, SECONDS); } catch (Exception e) { - throw new RuntimeException(e); - } finally { CONTAINER = null; + throw new RuntimeException(e); } } diff --git a/at_client/src/test/resources/features/Activate.feature b/at_client/src/test/resources/features/Activate.feature new file mode 100644 index 00000000..63dd1de0 --- /dev/null +++ b/at_client/src/test/resources/features/Activate.feature @@ -0,0 +1,97 @@ +Feature: AtClient API tests for onboarding and enrolling atsign + + Background: + Given root server endpoint is vip.ve.atsign.zone:64 + And root server is running + And atsign keys path is at_demo_data package lib/assets/atkeys + And atsign keys suffix is .atKeys + And verbose logging is off + + Scenario: Attempt to create AtClient prior to onboarding fails + When AtClient fails for @device2 + Then exception message matches "privatekey:at_pkam_publickey does not exist in keystore" + + Scenario: Attempt to onboard atsign with incorrect CRAM secret fails + When @srie Activate.onboard fails with CRAM secret "not-the-correct-cramkey" + Then exception was AtUnauthenticatedException and message matches "Authentication Failed" + + Scenario: After onboarding an AtClient can be created and used to get and put keys + When @srie Activate.onboard with SrieKeys._cramKey from at_demo_apkam_keys.dart in at_demo_data package + Then AtClient with keys @srie.atKeys for @srie + And AtClient.getAtKeys for ".+" contains + | Key | Name | Namespace | Shared By | Shared With | Is Public | Is Encrypted | Is Hidden | + | @srie:signing_privatekey@srie | signing_privatekey | | srie | srie | false | true | false | + | public:publickey@srie | publickey | | srie | | true | false | false | + | public:signing_publickey@srie | signing_publickey | | srie | | true | false | false | + When AtClient.put for PublicKey test and value "hello world" + And AtClient.put for SelfKey test and value "hello me" + And AtClient.put for SharedKey test shared with @colin and value "hello colin" + Then AtClient.getAtKeys for ".+" contains + | Key | Name | Namespace | Shared By | Shared With | + | @colin:test@srie | test | | srie | colin | + | public:test@srie | test | | srie | | + | test@srie | test | | srie | | + And AtClient.get for PublicKey test returns value that matches "hello world" + And AtClient.get for SelfKey test returns value that matches "hello me" + And @colin AtClient.get for PublicKey test shared by @srie returns value that matches "hello world" + And @colin AtClient.get for SharedKey test shared by @srie returns value that matches "hello colin" + And @gary AtClient.get for PublicKey test shared by @srie returns value that matches "hello world" + + Scenario: After enrolling an app and device but prior to approval an AtClient cannot be created + When @srie Activate.onboard with SrieKeys._cramKey from at_demo_apkam_keys.dart in at_demo_data package + And @srie Activate.otp generates an OTP + And @srie Activate.enroll for app app1 and device device1 with last OTP and following namespaces + | Namespace | Access Control | + | ns | rw | + Then AtClient with keys @srie-app1-device1.atKeys fails for @srie + And exception message matches "PKAM command failed: error:AT0026:enrollment_id: .+ is pending" + + Scenario: After enrolling an app and device and approving an AtClient can be created and used to get and put keys + When @srie Activate.onboard with SrieKeys._cramKey from at_demo_apkam_keys.dart in at_demo_data package + And @srie Activate.otp generates an OTP + And @srie Activate.enroll for app app1 and device device1 with last OTP and following namespaces + | Namespace | Access Control | + | ns | rw | + And @srie Activate.approve for last enrollment + And AtClient with keys @srie-app1-device1.atKeys for @srie completes enrollment + And AtClient.put for SelfKey test.ns and value "hello me" + And AtClient.put for PublicKey test.ns and value "hello world" + And AtClient.put for SharedKey test.ns shared with @colin and value "hello colin" + Then AtClient.getAtKeys for "test" contains + | Key | Name | Namespace | Shared By | Shared With | + | test.ns@srie | test | ns | srie | | + | public:test.ns@srie | test | ns | srie | | + | @colin:test.ns@srie | test | ns | srie | colin | + And AtClient.get for PublicKey test.ns returns value that matches "hello world" + And AtClient.get for SelfKey test.ns returns value that matches "hello me" + And @colin AtClient.get for PublicKey test.ns shared by @srie returns value that matches "hello world" + And @colin AtClient.get for SharedKey test.ns shared by @srie returns value that matches "hello colin" + And @gary AtClient.get for PublicKey test.ns shared by @srie returns value that matches "hello world" + + Scenario: After enrolling an app and device and denying an AtClient cannot be created + And @srie Activate.onboard with SrieKeys._cramKey from at_demo_apkam_keys.dart in at_demo_data package + And @srie Activate.otp generates an OTP + And @srie Activate.enroll for app app1 and device device1 with last OTP and following namespaces + | Namespace | Access Control | + | ns | rw | + When @srie Activate.deny for last enrollment + Then AtClient with keys @srie-app1-device1.atKeys fails for @srie + And exception message matches "PKAM command failed: error:AT0025:enrollment_id: .+ is denied" + + Scenario: After enrolling an app and device and approving but then revoking an AtClient cannot be created + And @srie Activate.onboard with SrieKeys._cramKey from at_demo_apkam_keys.dart in at_demo_data package + And @srie Activate.otp generates an OTP + And @srie Activate.enroll for app app1 and device device1 with last OTP and following namespaces + | Namespace | Access Control | + | ns | rw | + And @srie Activate.approve for last enrollment + And AtClient with keys @srie-app1-device1.atKeys for @srie completes enrollment + And AtClient.put for PublicKey test.ns and value "hello world" + When @srie Activate.revoke for last enrollment + Then AtClient.get fails for PublicKey test.ns + And AtClient with keys @srie-app1-device1.atKeys fails for @srie + When AtClient is closed + And @srie Activate.unrevoke for last enrollment + And AtClient with keys @srie-app1-device1.atKeys for @srie + Then AtClient.get for PublicKey test.ns returns value that matches "hello world" + diff --git a/at_client/src/test/resources/features/Keys.feature b/at_client/src/test/resources/features/Keys.feature index 29a1fda8..8f3787e6 100644 --- a/at_client/src/test/resources/features/Keys.feature +++ b/at_client/src/test/resources/features/Keys.feature @@ -1,11 +1,11 @@ -Feature: AtClient API tests for Keys +Feature: AtClient API tests for getAtKeys - Scenario: Test + Scenario: Invocation of getAtKeys returns expected keys Given root server endpoint is vip.ve.atsign.zone:64 And root server is running - And atsign keys path is target/at_demo_data/lib/assets/atkeys + And atsign keys path is at_demo_data package lib/assets/atkeys And atsign keys suffix is .atKeys - When AtClient for gary + When AtClient for @gary Then AtClient.getAtKeys for ".+" contains | Key | Name | Namespace | Shared By | Shared With | Is Public | Is Encrypted | Is Hidden | | @gary:signing_privatekey@gary | signing_privatekey | | gary | gary | false | true | false | diff --git a/at_client/src/test/resources/features/Monitor.feature b/at_client/src/test/resources/features/Monitor.feature index a3e45432..c81341cc 100644 --- a/at_client/src/test/resources/features/Monitor.feature +++ b/at_client/src/test/resources/features/Monitor.feature @@ -3,10 +3,10 @@ Feature: AtClient API Monitor tests Background: Given root server endpoint is vip.ve.atsign.zone:64 And root server is running - And atsign keys path is target/at_demo_data/lib/assets/atkeys + And atsign keys path is at_demo_data package lib/assets/atkeys And atsign keys suffix is .atKeys And verbose logging is off - And AtClient and startMonitor for gary + And AtClient and startMonitor for @gary Scenario: PublicKey put triggers a statsNotification And AtClient.put for PublicKey test and value "hello world" @@ -18,16 +18,14 @@ Feature: AtClient API Monitor tests Scenario: PublicKey does NOT trigger any notifications after stopMonitor And AtClient.put for PublicKey test and value "hello world" - And AtClient monitor receives the following - | Event Type | messageType | from | to | operation | key | - | statsNotification | MessageType.key | @gary | @gary | update | statsNotification.@gary | - And gary AtClient stopMonitor - When AtClient.put for PublicKey test and value "bonjour le monde" + And AtClient monitor receives a new statsNotification + When @gary AtClient stopMonitor + And AtClient.put for PublicKey test and value "bonjour le monde" Then AtClient monitor does NOT receive any notifications Scenario: SharedKey put triggers expected notifications for shared with atsign - When colin AtClient.put for SharedKey test shared with gary and value "hello world" - Then gary AtClient monitor receives the following + When @colin AtClient.put for SharedKey test shared with @gary and value "hello world" + Then @gary AtClient monitor receives the following | Event Type | messageType | from | to | operation | key | decryptedValue | | sharedKeyNotification | MessageType.key | @colin | @gary | update | @gary:shared_key@colin | | | updateNotification | MessageType.key | @colin | @gary | update | @gary:test@colin | | diff --git a/at_client/src/test/resources/features/Namespaces.feature b/at_client/src/test/resources/features/Namespaces.feature new file mode 100644 index 00000000..d506f780 --- /dev/null +++ b/at_client/src/test/resources/features/Namespaces.feature @@ -0,0 +1,111 @@ +Feature: AtClient API tests for namespaces atsign + + Background: + Given root server endpoint is vip.ve.atsign.zone:64 + And root server is running + And atsign keys path is at_demo_data package lib/assets/atkeys + And atsign keys suffix is .atKeys + And verbose logging is off + And @srie Activate.onboard with SrieKeys._cramKey from at_demo_apkam_keys.dart in at_demo_data package + + Scenario: Test namespace access control + And @srie Activate.otp generates an OTP + And @srie Activate.enroll for app app1 and device device1 with last OTP and following namespaces + | Namespace | Access Control | + | ns1 | rw | + And @srie Activate.approve for last enrollment + And AtClient with keys @srie-app1-device1.atKeys for @srie completes enrollment + When AtClient.put for PublicKey test.ns1 and value "hello ns1 world" + Then AtClient.get for PublicKey test.ns1 returns value that matches "hello ns1 world" + And AtClient.getAtKeys for "test" contains + | Key | Name | Namespace | Shared By | Shared With | + | public:test.ns1@srie | test | ns1 | srie | | + When namespace is set to ns2 + Then AtClient.put fails for PublicKey test and value "hello ns2 world" + And exception was AtUnauthorizedException and message matches "AT0009-UnAuthorized client" + And exception message matches "not authorized to update key: public:test.ns2" + + Scenario: Test namespace read-only access control + And @srie Activate.otp generates 2 OTPs + And @srie Activate.enroll for app app1 and device readwrite with last OTP and following namespaces + | Namespace | Access Control | + | ns | rw | + And @srie Activate.enroll for app app1 and device readonly with last OTP and following namespaces + | Namespace | Access Control | + | ns | r | + And @srie Activate.approve for all enrollments + And AtClient with keys @srie-app1-readwrite.atKeys for @srie completes enrollment + And AtClient with keys @srie-app1-readonly.atKeys for @srie completes enrollment + When 1st @srie AtClient.put for PublicKey test.ns and value "hello ns world" + Then 2nd @srie AtClient.get for PublicKey test.ns returns value that matches "hello ns world" + But 2nd @srie AtClient.put fails for PublicKey test.ns and value "hello ns world" + And exception was AtUnauthorizedException and message matches "AT0009-UnAuthorized client" + And exception message matches "not authorized to update key: public:test.ns" + + Scenario: Test namespace read visibility of SelfKeys + And @srie Activate.otp generates 2 OTPs + And @srie Activate.enroll for app app1 and device device1 with last OTP and following namespaces + | Namespace | Access Control | + | ns1 | rw | + And @srie Activate.enroll for app app1 and device device2 with last OTP and following namespaces + | Namespace | Access Control | + | ns2 | rw | + | ns1 | r | + And @srie Activate.approve for all enrollments + When AtClient with keys @srie-app1-device1.atKeys for @srie completes enrollment + And AtClient with keys @srie-app1-device2.atKeys for @srie completes enrollment + And 1st @srie AtClient.put for SelfKey test.ns1 and value "test data 1" + And 2nd @srie AtClient.put for SelfKey test.ns2 and value "test data 2" + Then 1st @srie AtClient.getAtKeys for ".*" contains + | Key | Name | Namespace | Shared By | + | test.ns1@srie | test | ns1 | srie | + And 1st @srie AtClient.getAtKeys for ".*" does NOT contain + | Key | + | test.ns2@srie | + But 2nd @srie AtClient.getAtKeys for ".*" contains + | Key | Name | Namespace | Shared By | + | test.ns1@srie | test | ns1 | srie | + | test.ns2@srie | test | ns2 | srie | + And 2nd @srie AtClient.get for SelfKey test.ns1 returns value that matches "test data 1" + And 2nd @srie AtClient.put fails for SelfKey test.ns1 and value "xxx" + + Scenario: Test namespace read visibility of SharedKeys + And @srie Activate.otp generates 2 OTPs + And @srie Activate.enroll for app app1 and device device1 with last OTP and following namespaces + | Namespace | Access Control | + | ns1 | rw | + And @srie Activate.enroll for app app1 and device device2 with last OTP and following namespaces + | Namespace | Access Control | + | ns2 | rw | + | ns1 | r | + And @srie Activate.approve for all enrollments + When AtClient with keys @srie-app1-device1.atKeys for @srie completes enrollment + And AtClient with keys @srie-app1-device2.atKeys for @srie completes enrollment + And 1st @srie AtClient.put for SharedKey test.ns1 shared with @colin and value "test data 1" + And 2nd @srie AtClient.put for SharedKey test.ns2 shared with @colin and value "test data 2" + Then 1st @srie AtClient.getAtKeys for ".*" contains + | Key | Name | Namespace | Shared By | Shared With | + | @colin:test.ns1@srie | test | ns1 | srie | colin | + And 1st @srie AtClient.getAtKeys for ".*" does NOT contain + | Key | + | @colin:test.ns2@srie | + But 2nd @srie AtClient.getAtKeys for ".*" contains + | Key | Name | Namespace | Shared By | Shared With | + | @colin:test.ns1@srie | test | ns1 | srie | colin | + | @colin:test.ns2@srie | test | ns2 | srie | colin | + + Scenario: Test multiple app / devices updating the same shared key + And @srie Activate.otp generates 2 OTPs + And @srie Activate.enroll for app app1 and device device1 with last OTP and following namespaces + | Namespace | Access Control | + | ns1 | rw | + And @srie Activate.enroll for app app1 and device device2 with last OTP and following namespaces + | Namespace | Access Control | + | ns1 | rw | + And @srie Activate.approve for all enrollments + And AtClient with keys @srie-app1-device1.atKeys for @srie completes enrollment + And AtClient with keys @srie-app1-device2.atKeys for @srie completes enrollment + When 1st @srie AtClient.put for SharedKey test.ns1 shared with @colin and value "test data 1" + Then @colin AtClient.get for SharedKey test.ns1 shared by @srie returns value that matches "test data 1" + When 2nd @srie AtClient.put for SharedKey test.ns1 shared with @colin and value "test data 2" + Then @colin AtClient.get for SharedKey test.ns1 shared by @srie returns value that matches "test data 2" diff --git a/at_client/src/test/resources/features/PublicKey.feature b/at_client/src/test/resources/features/PublicKey.feature index 3e5a8020..d0055a82 100644 --- a/at_client/src/test/resources/features/PublicKey.feature +++ b/at_client/src/test/resources/features/PublicKey.feature @@ -3,24 +3,33 @@ Feature: AtClient API test for PublicKeys Background: Given root server endpoint is vip.ve.atsign.zone:64 And root server is running - And atsign keys path is target/at_demo_data/lib/assets/atkeys + And atsign keys path is at_demo_data package lib/assets/atkeys And atsign keys suffix is .atKeys And verbose logging is off - And AtClient for gary + And AtClient with keys @gary.atKeys for @gary Scenario: PublicKey get throws AtKeyNotFoundException if no key - Then AtClient.get for PublicKey test receives AtKeyNotFoundException and message "test@gary does not exist in keystore" + Then AtClient.get fails for PublicKey test + And exception was AtKeyNotFoundException and message matches "test@gary does not exist in keystore" Scenario: PublicKey get returns value from put When AtClient.put for PublicKey test and value "hello world" Then AtClient.get for PublicKey test returns value that matches "hello world" - Then colin AtClient.get for PublicKey test shared by gary returns value that matches "hello world" + And @colin AtClient.get for PublicKey test shared by @gary returns value that matches "hello world" Scenario: PublicKey get throws AtKeyNotFoundException if key is deleted And AtClient.put for PublicKey test and value "hello world" And AtClient.get for PublicKey test returns value that matches "hello world" When AtClient.delete for PublicKey test - Then AtClient.get for PublicKey test receives AtKeyNotFoundException and message "test@gary does not exist in keystore" + Then AtClient.get fails for PublicKey test + And exception was AtKeyNotFoundException and message matches "test@gary does not exist in keystore" + + Scenario: PublicKey get from shared with AtSign throws AtKeyNotFoundException if key is deleted + And @gary AtClient.put for PublicKey test and value "hello world" + And @colin AtClient.get for PublicKey test shared by @gary returns value that matches "hello world" + When @gary AtClient.delete for PublicKey test + Then @colin AtClient.get fails for PublicKey test shared by @gary + And exception was AtKeyNotFoundException Scenario: PublicKeys are visible to owner When AtClient.put for PublicKey test and value "hello world" @@ -31,4 +40,3 @@ Feature: AtClient API test for PublicKeys And AtClient.getAtKeys for "test.+" matches | Key | Name | Namespace | Shared By | Shared With | | public:test@gary | test | | gary | | - diff --git a/at_client/src/test/resources/features/SelfKey.feature b/at_client/src/test/resources/features/SelfKey.feature index d36c9dc3..0746b83e 100644 --- a/at_client/src/test/resources/features/SelfKey.feature +++ b/at_client/src/test/resources/features/SelfKey.feature @@ -3,13 +3,14 @@ Feature: AtClient API tests for SelfKeys Background: Given root server endpoint is vip.ve.atsign.zone:64 And root server is running - And atsign keys path is target/at_demo_data/lib/assets/atkeys + And atsign keys path is at_demo_data package lib/assets/atkeys And atsign keys suffix is .atKeys And verbose logging is off - And AtClient for gary + And AtClient for @gary Scenario: SelfKey get throws AtKeyNotFoundException if no key - Then AtClient.get for SelfKey test receives AtKeyNotFoundException and message "test@gary does not exist in keystore" + Then AtClient.get fails for SelfKey test + And exception was AtKeyNotFoundException and message matches "test@gary does not exist in keystore" Scenario: SelfKey get returns value from put When AtClient.put for SelfKey test and value "hello world" @@ -19,7 +20,8 @@ Feature: AtClient API tests for SelfKeys And AtClient.put for SelfKey test and value "hello world" And AtClient.get for SelfKey test returns value that matches "hello world" When AtClient.delete for SelfKey test - Then AtClient.get for SelfKey test receives AtKeyNotFoundException and message "test@gary does not exist in keystore" + Then AtClient.get fails for SelfKey test + And exception was AtKeyNotFoundException and message matches "test@gary does not exist in keystore" Scenario: SelfKeys are visible to owner When AtClient.put for SelfKey test and value "hello world" @@ -31,6 +33,6 @@ Feature: AtClient API tests for SelfKeys Scenario: SelfKeys are invisible to other at signs And dump keys And AtClient.put for SelfKey test and value "hello world" - Then colin AtClient.getAtKeys for ".+" does NOT contain + Then @colin AtClient.getAtKeys for ".+" does NOT contain | test@gary | diff --git a/at_client/src/test/resources/features/SharedKey.feature b/at_client/src/test/resources/features/SharedKey.feature index a66f15c6..ab3a8098 100644 --- a/at_client/src/test/resources/features/SharedKey.feature +++ b/at_client/src/test/resources/features/SharedKey.feature @@ -6,22 +6,25 @@ Feature: AtClient API test for SharedKeys And atsign keys path is target/at_demo_data/lib/assets/atkeys And atsign keys suffix is .atKeys And verbose logging is off - And AtClient for gary + And AtClient for @gary Scenario: SharedKey get throws AtKeyNotFoundException if no key - Then AtClient.get for SharedKey test shared with colin receives AtKeyNotFoundException and message "test@gary does not exist in keystore" + Then AtClient.get fails for SharedKey test shared with @colin + And exception was AtKeyNotFoundException and message matches "test@gary does not exist in keystore" Scenario: SharedKey get returns expected value for "Shared By" and "Shared With" atsigns - When AtClient.put for SharedKey test shared with colin and value "hello world" - Then gary AtClient.get for SharedKey test shared with colin returns value that matches "hello world" - And colin AtClient.get for SharedKey test shared by gary returns value that matches "hello world" + When AtClient.put for SharedKey test shared with @colin and value "hello world" + Then @gary AtClient.get for SharedKey test shared with @colin returns value that matches "hello world" + And @colin AtClient.get for SharedKey test shared by @gary returns value that matches "hello world" Scenario: SharedKey get throws AtKeyNotFoundException for "Shared By" and "Shared With" atsigns if key is deleted - And AtClient.put for SharedKey test shared with colin and value "hello world" - And AtClient.get for SharedKey test shared with colin returns value that matches "hello world" - When AtClient.delete for SharedKey test shared with colin - Then gary AtClient.get for SharedKey test shared with colin receives AtKeyNotFoundException with message "test@gary does not exist in keystore" - And colin AtClient.get for SharedKey test shared by gary receives AtKeyNotFoundException and message "test@gary does not exist in keystore" + And AtClient.put for SharedKey test shared with @colin and value "hello world" + And AtClient.get for SharedKey test shared with @colin returns value that matches "hello world" + When AtClient.delete for SharedKey test shared with @colin + Then @gary AtClient.get fails for SharedKey test shared with @colin + And exception was AtKeyNotFoundException and message matches "test@gary does not exist in keystore" + And @colin AtClient.get fails for SharedKey test shared by @gary + And exception was AtKeyNotFoundException and message matches "test@gary does not exist in keystore" # this doesn't work # Scenario: SharedKey get returns expected value for public key when no shared key @@ -31,14 +34,14 @@ Feature: AtClient API test for SharedKeys # But don AtClient.get for SharedKey test by gary returns value that matches "hello world" Scenario: SharedKeys are visible to owner - When AtClient.put for SharedKey test shared with colin and value "hello world" + When AtClient.put for SharedKey test shared with @colin and value "hello world" Then AtClient.getAtKeys for ".+" contains | @colin:test@gary | And AtClient.getAtKeys for "test.+" contains | @colin:test@gary | Scenario: SharedKeys are not visible to other at signs - And AtClient.put for SharedKey test shared with colin and value "hello world" - Then don AtClient.getAtKeys for ".+" does NOT contain + And AtClient.put for SharedKey test shared with @colin and value "hello world" + Then @don AtClient.getAtKeys for ".+" does NOT contain | public:test@gary |