diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/CustomizeNotificationsUi.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/CustomizeNotificationsUi.kt new file mode 100644 index 00000000000..c9deddcb60d --- /dev/null +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/CustomizeNotificationsUi.kt @@ -0,0 +1,13 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +fun checkSeverity(notificationSeverity: String): NotificationSeverity = when (notificationSeverity) { + "Critical" -> NotificationSeverity.CRITICAL + "Warning" -> NotificationSeverity.WARNING + "Info" -> NotificationSeverity.INFO + else -> NotificationSeverity.INFO +} + +// TODO: Add actions that can be performed from the notifications here diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/DisplayToastNotifications.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/DisplayToastNotifications.kt new file mode 100644 index 00000000000..870672f78a8 --- /dev/null +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/DisplayToastNotifications.kt @@ -0,0 +1,6 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +object DisplayToastNotifications diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationCustomDeserializers.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationCustomDeserializers.kt new file mode 100644 index 00000000000..2b6adce2720 --- /dev/null +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationCustomDeserializers.kt @@ -0,0 +1,121 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.JsonToken +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.JsonMappingException +import com.fasterxml.jackson.databind.JsonNode + +class OperationConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.OperationCondition = when (parser.currentToken) { + JsonToken.VALUE_STRING -> { + // Handle direct string value + NotificationExpression.OperationCondition(parser.valueAsString) + } + else -> throw JsonMappingException(parser, "Cannot deserialize OperatingCondition") + } +} + +class ComparisonConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.ComparisonCondition { + val op = OperationConditionDeserializer().deserialize(parser, ctxt) + return NotificationExpression.ComparisonCondition(op.value) + } +} + +class NotEqualsConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.NotEqualsCondition { + val op = OperationConditionDeserializer().deserialize(parser, ctxt) + return NotificationExpression.NotEqualsCondition(op.value) + } +} +class GreaterThanConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.GreaterThanCondition { + val op = OperationConditionDeserializer().deserialize(parser, ctxt) + return NotificationExpression.GreaterThanCondition(op.value) + } +} +class GreaterThanOrEqualsConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.GreaterThanOrEqualsCondition { + val op = OperationConditionDeserializer().deserialize(parser, ctxt) + return NotificationExpression.GreaterThanOrEqualsCondition(op.value) + } +} +class LessThanConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.LessThanCondition { + val op = OperationConditionDeserializer().deserialize(parser, ctxt) + return NotificationExpression.LessThanCondition(op.value) + } +} +class LessThanOrEqualsConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.LessThanOrEqualsCondition { + val op = OperationConditionDeserializer().deserialize(parser, ctxt) + return NotificationExpression.LessThanOrEqualsCondition(op.value) + } +} +class ComplexOperationConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.ComplexOperationCondition { + val node = parser.codec.readTree(parser) + if (!node.isArray) { + throw JsonMappingException(parser, "anyOf/noneOf must contain an array of values") + } + val values = node.map { it.asText() } + return NotificationExpression.ComplexOperationCondition(values) + } +} +class AnyOfConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.AnyOfCondition { + val op = ComplexOperationConditionDeserializer().deserialize(parser, ctxt) + return NotificationExpression.AnyOfCondition(op.value) + } +} + +class NoneOfConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.NoneOfCondition { + val op = ComplexOperationConditionDeserializer().deserialize(parser, ctxt) + return NotificationExpression.NoneOfCondition(op.value) + } +} + +class ComplexConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.ComplexCondition { + val node = parser.codec.readTree(parser) + if (!node.isArray) { + throw JsonMappingException(parser, "or/and must contain an array of values") + } + return NotificationExpression.ComplexCondition(node.toNotificationExpressions(parser)) + } +} +class OrConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.OrCondition { + val op = ComplexConditionDeserializer().deserialize(parser, ctxt) + return NotificationExpression.OrCondition(op.expectedValueList) + } +} + +class AndConditionDeserializer : JsonDeserializer() { + override fun deserialize(parser: JsonParser, ctxt: DeserializationContext): NotificationExpression.AndCondition { + val op = ComplexConditionDeserializer().deserialize(parser, ctxt) + return NotificationExpression.AndCondition(op.expectedValueList) + } +} + +class NotConditionDeserializer : JsonDeserializer() { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): NotificationExpression.NotCondition { + val node = p.codec.readTree(p) + val parser = node.traverse(p.codec) + parser.nextToken() + + return NotificationExpression.NotCondition(parser.readValueAs(NotificationExpression::class.java)) + } +} + +private fun JsonNode.toNotificationExpressions(p: JsonParser): List = this.map { element -> + val parser = element.traverse(p.codec) + parser.nextToken() + parser.readValueAs(NotificationExpression::class.java) +} diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtils.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtils.kt new file mode 100644 index 00000000000..425eaf4c00a --- /dev/null +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtils.kt @@ -0,0 +1,183 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo +import com.fasterxml.jackson.databind.annotation.JsonDeserialize + +data class NotificationsList( + val schema: Schema, + val notifications: List?, +) + +data class Schema( + val version: String, +) + +data class NotificationData( + val id: String, + val schedule: NotificationSchedule, + val severity: String, + val condition: NotificationDisplayCondition?, + val content: NotificationContentDescriptionLocale, + val actions: List? = emptyList(), +) + +data class NotificationSchedule( + val type: String, +) + +enum class NotificationSeverity { + INFO, + WARNING, + CRITICAL, +} + +data class NotificationContentDescriptionLocale( + @JsonProperty("en-US") + val locale: NotificationContentDescription, +) + +data class NotificationContentDescription( + val title: String, + val description: String, +) + +data class NotificationFollowupActions( + val type: String, + val content: NotificationFollowupActionsContent, +) + +data class NotificationFollowupActionsContent( + @JsonProperty("en-US") + val locale: NotificationActionDescription, +) + +data class NotificationActionDescription( + val title: String, + val url: String?, +) + +data class NotificationDisplayCondition( + val compute: ComputeType?, + val os: SystemType?, + val ide: SystemType?, + val extension: List?, + val authx: List?, +) + +data class ComputeType( + val type: NotificationExpression?, + val architecture: NotificationExpression?, +) + +data class SystemType( + val type: NotificationExpression?, + val version: NotificationExpression?, +) + +data class ExtensionType( + val id: String?, + val version: NotificationExpression?, +) + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.WRAPPER_OBJECT +) +@JsonSubTypes( + JsonSubTypes.Type(value = NotificationExpression.ComparisonCondition::class, name = "=="), + JsonSubTypes.Type(value = NotificationExpression.NotEqualsCondition::class, name = "!="), + JsonSubTypes.Type(value = NotificationExpression.GreaterThanCondition::class, name = ">"), + JsonSubTypes.Type(value = NotificationExpression.GreaterThanOrEqualsCondition::class, name = ">="), + JsonSubTypes.Type(value = NotificationExpression.LessThanCondition::class, name = "<"), + JsonSubTypes.Type(value = NotificationExpression.LessThanOrEqualsCondition::class, name = "<="), + JsonSubTypes.Type(value = NotificationExpression.AnyOfCondition::class, name = "anyOf"), + JsonSubTypes.Type(value = NotificationExpression.NotCondition::class, name = "not"), + JsonSubTypes.Type(value = NotificationExpression.OrCondition::class, name = "or"), + JsonSubTypes.Type(value = NotificationExpression.AndCondition::class, name = "and"), + JsonSubTypes.Type(value = NotificationExpression.NoneOfCondition::class, name = "noneOf") +) +sealed interface NotificationExpression { + @JsonDeserialize(using = NotConditionDeserializer::class) + data class NotCondition( + val expectedValue: NotificationExpression, + ) : NotificationExpression + + @JsonDeserialize(using = OrConditionDeserializer::class) + data class OrCondition( + val expectedValueList: List, + ) : NotificationExpression + + @JsonDeserialize(using = AndConditionDeserializer::class) + data class AndCondition( + val expectedValueList: List, + ) : NotificationExpression + + @JsonDeserialize(using = ComplexConditionDeserializer::class) + data class ComplexCondition( + val expectedValueList: List, + ) : NotificationExpression + + // General class for comparison operators + @JsonDeserialize(using = OperationConditionDeserializer::class) + data class OperationCondition( + val value: String, + ) : NotificationExpression + + @JsonDeserialize(using = ComplexOperationConditionDeserializer::class) + data class ComplexOperationCondition( + val value: List, + ) : NotificationExpression + + @JsonDeserialize(using = ComparisonConditionDeserializer::class) + data class ComparisonCondition( + val value: String, + ) : NotificationExpression + + @JsonDeserialize(using = NotEqualsConditionDeserializer::class) + data class NotEqualsCondition( + val value: String, + ) : NotificationExpression + + @JsonDeserialize(using = GreaterThanConditionDeserializer::class) + data class GreaterThanCondition( + val value: String, + ) : NotificationExpression + + @JsonDeserialize(using = GreaterThanOrEqualsConditionDeserializer::class) + data class GreaterThanOrEqualsCondition( + val value: String, + ) : NotificationExpression + + @JsonDeserialize(using = LessThanConditionDeserializer::class) + data class LessThanCondition( + val value: String, + ) : NotificationExpression + + @JsonDeserialize(using = LessThanOrEqualsConditionDeserializer::class) + data class LessThanOrEqualsCondition( + val value: String, + ) : NotificationExpression + + @JsonDeserialize(using = AnyOfConditionDeserializer::class) + data class AnyOfCondition( + val value: List, + ) : NotificationExpression + + @JsonDeserialize(using = NoneOfConditionDeserializer::class) + data class NoneOfCondition( + val value: List, + ) : NotificationExpression +} + +data class AuthxType( + val feature: String, + val type: NotificationExpression?, + val region: NotificationExpression?, + val connectionState: NotificationExpression?, + val ssoScopes: NotificationExpression?, +) diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBase.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBase.kt index 9d60ccd6be0..cd11ba4ef2e 100644 --- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBase.kt +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/ProcessNotificationsBase.kt @@ -3,6 +3,8 @@ package software.aws.toolkits.jetbrains.core.notifications +import com.intellij.openapi.project.Project + class ProcessNotificationsBase { init { // TODO: install a listener for the polling class @@ -17,8 +19,11 @@ class ProcessNotificationsBase { // iterates through the 2 lists and processes each notification(if it isn't dismissed) } - fun processNotification() { - // TODO: calls the Rule engine and notifies listeners + fun processNotification(project: Project, notificationData: NotificationData) { + val shouldShow = RulesEngine.displayNotification(project, notificationData) + if (shouldShow) { + // TODO: notifies listeners + } } fun notifyListenerForNotification() { diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/RulesEngine.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/RulesEngine.kt new file mode 100644 index 00000000000..bfb54952481 --- /dev/null +++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/notifications/RulesEngine.kt @@ -0,0 +1,209 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +import com.intellij.ide.plugins.PluginManagerCore +import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.extensions.PluginId +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.SystemInfo +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.ActiveConnection +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.ActiveConnectionType +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.BearerTokenFeatureSet +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.checkBearerConnectionValidity +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.checkIamProfileByCredentialType +import software.aws.toolkits.jetbrains.utils.isRunningOnRemoteBackend + +object RulesEngine { + + fun displayNotification(project: Project, notification: NotificationData): Boolean { + // If no conditions provided, show display the notification to everyone + val shouldShow = notification.condition?.let { matchesAllRules(it, project) } ?: true + return shouldShow + } + + fun matchesAllRules(notificationConditions: NotificationDisplayCondition, project: Project): Boolean { + val sysDetails = getCurrentSystemAndConnectionDetails() + // If any of these conditions are null, we assume the condition to be true + val compute = notificationConditions.compute?.let { matchesCompute(it, sysDetails.computeType, sysDetails.computeArchitecture) } ?: true + val os = notificationConditions.os?.let { matchesOs(it, sysDetails.osType, sysDetails.osVersion) } ?: true + val ide = notificationConditions.ide?.let { matchesIde(it, sysDetails.ideType, sysDetails.ideVersion) } ?: true + val extension = matchesExtension(notificationConditions.extension, sysDetails.pluginVersions) + val authx = matchesAuth(notificationConditions.authx, project) + return compute && os && ide && extension && authx + } + + private fun matchesCompute(notificationCompute: ComputeType, actualCompute: String, actualArchitecture: String): Boolean { + val type = notificationCompute.type?.let { evaluateNotificationExpression(it, actualCompute) } ?: true + val architecture = notificationCompute.architecture?.let { evaluateNotificationExpression(it, actualArchitecture) } ?: true + return type && architecture + } + + private fun matchesOs(notificationOs: SystemType, actualOs: String, actualOsVersion: String): Boolean { + val os = notificationOs.type?.let { evaluateNotificationExpression(it, actualOs) } ?: true + val osVersion = notificationOs.version?.let { evaluateNotificationExpression(it, actualOsVersion) } ?: true + return os && osVersion + } + + private fun matchesIde(notificationIde: SystemType, actualIde: String, actualIdeVersion: String): Boolean { + val ide = notificationIde.type?.let { evaluateNotificationExpression(it, actualIde) } ?: true + val ideVersion = notificationIde.version?.let { evaluateNotificationExpression(it, actualIdeVersion) } ?: true + return ide && ideVersion + } + + private fun matchesExtension(notificationExtension: List?, actualPluginVersions: Map): Boolean { + if (notificationExtension.isNullOrEmpty()) return true + val extensionsToBeChecked = notificationExtension.map { it.id } + val pluginVersions = actualPluginVersions.filterKeys { extensionsToBeChecked.contains(it) } + return notificationExtension.all { extension -> + val actualVersion = pluginVersions[extension.id] + if (actualVersion == null) { + true + } else { + extension.version?.let { evaluateNotificationExpression(it, actualVersion) } ?: true + } + } + } + + private fun matchesAuth(notificationAuth: List?, project: Project): Boolean { + if (notificationAuth.isNullOrEmpty()) return true + return notificationAuth.all { feature -> + val actualConnection = when (feature.feature) { + "q" -> getConnectionDetailsForFeature(project, BearerTokenFeatureSet.Q) + "codeCatalyst" -> getConnectionDetailsForFeature(project, BearerTokenFeatureSet.CODECATALYST) + "toolkit" -> getConnectionDetailsForToolkit(project) + else -> return true + } + + if (actualConnection == null) { + false + } else { + val authType = feature.type?.let { evaluateNotificationExpression(it, actualConnection.connectionType) } ?: true + val authRegion = feature.region?.let { evaluateNotificationExpression(it, actualConnection.region) } ?: true + val connectionState = feature.connectionState?.let { evaluateNotificationExpression(it, actualConnection.connectionState) } ?: true + // TODO: Add condition for matching scopes + authType && authRegion && connectionState + } + } + } + + private fun evaluateNotificationExpression(notificationExpression: NotificationExpression, value: String): Boolean = when (notificationExpression) { + is NotificationExpression.NotCondition -> performNotOp(notificationExpression, value) + is NotificationExpression.OrCondition -> performOrOp(notificationExpression, value) + is NotificationExpression.AndCondition -> performAndOp(notificationExpression, value) + is NotificationExpression.ComparisonCondition -> notificationExpression.value == value + is NotificationExpression.NotEqualsCondition -> notificationExpression.value != value + is NotificationExpression.GreaterThanCondition -> value > notificationExpression.value + is NotificationExpression.LessThanCondition -> value < notificationExpression.value + is NotificationExpression.GreaterThanOrEqualsCondition -> value >= notificationExpression.value + is NotificationExpression.LessThanOrEqualsCondition -> value <= notificationExpression.value + is NotificationExpression.AnyOfCondition -> notificationExpression.value.contains(value) + is NotificationExpression.NoneOfCondition -> !notificationExpression.value.contains(value) + else -> true + } + + private fun performNotOp(notificationOperation: NotificationExpression.NotCondition, actualValue: String): Boolean = + !evaluateNotificationExpression(notificationOperation.expectedValue, actualValue) + + private fun performOrOp(notificationOperation: NotificationExpression.OrCondition, actualValue: String): Boolean = + notificationOperation.expectedValueList.any { evaluateNotificationExpression(it, actualValue) } + + private fun performAndOp(notificationOperation: NotificationExpression.AndCondition, actualValue: String): Boolean = + notificationOperation.expectedValueList.all { evaluateNotificationExpression(it, actualValue) } +} + +fun getCurrentSystemAndConnectionDetails(): SystemDetails { + val computeType: String = if (isRunningOnRemoteBackend()) "Remote" else "Local" + val computeArchitecture: String = SystemInfo.OS_ARCH + + val osType: String = SystemInfo.OS_NAME + val osVersion: String = SystemInfo.OS_VERSION + + val ideInfo = ApplicationInfo.getInstance() + val ideType: String = ideInfo.build.productCode + val ideVersion = ideInfo.shortVersion + + val pluginVersionMap = createPluginVersionMap() + + return SystemDetails(computeType, computeArchitecture, osType, osVersion, ideType, ideVersion, pluginVersionMap) +} + +data class FeatureAuthDetails( + val connectionType: String, + val region: String, + val connectionState: String, +) + +data class SystemDetails( + val computeType: String, + val computeArchitecture: String, + val osType: String, + val osVersion: String, + val ideType: String, + val ideVersion: String, + val pluginVersions: Map, +) + +fun createPluginVersionMap(): Map { + val pluginVersionMap = mutableMapOf() + val pluginIds = listOf( + "amazon.q", + "aws.toolkit.core", + "aws.toolkit" + ) + pluginIds.forEach { pluginId -> + val plugin = PluginManagerCore.getPlugin(PluginId.getId(pluginId)) + val pluginVersion = plugin?.version + if (pluginVersion != null) { + pluginVersionMap[pluginId] = pluginVersion + } + } + return pluginVersionMap +} + +private fun getConnectionDetailsForToolkit(project: Project): FeatureAuthDetails? { + val connection = checkIamProfileByCredentialType(project) + if (connection.activeConnectionIam == null) return null + val authType = when (connection.connectionType) { + ActiveConnectionType.IAM_IDC -> "Idc" + ActiveConnectionType.IAM -> "Iam" + else -> "Unknown" + } + val authRegion = connection.activeConnectionIam?.defaultRegionId ?: "Unknown" + + val connectionState = when (connection) { + is ActiveConnection.NotConnected -> "NotConnected" + is ActiveConnection.ValidIam -> "Connected" + is ActiveConnection.ExpiredIam -> "Expired" + else -> "Unknown" + } + return FeatureAuthDetails( + authType, + authRegion, + connectionState + ) +} + +fun getConnectionDetailsForFeature(project: Project, featureId: BearerTokenFeatureSet): FeatureAuthDetails? { + val connection = checkBearerConnectionValidity(project, featureId) + if (connection.activeConnectionBearer == null) return null + val authType = when (connection.connectionType) { + ActiveConnectionType.BUILDER_ID -> "BuilderId" + ActiveConnectionType.IAM_IDC -> "Idc" + else -> "Unknown" + } + val authRegion = connection.activeConnectionBearer?.region ?: "Unknown" + + val connectionState = when (connection) { + is ActiveConnection.NotConnected -> "NotConnected" + is ActiveConnection.ValidBearer -> "Connected" + is ActiveConnection.ExpiredBearer -> "Expired" + else -> "Unknown" + } + return FeatureAuthDetails( + authType, + authRegion, + connectionState + ) +} diff --git a/plugins/core/jetbrains-community/tst-resources/exampleNotification2.json b/plugins/core/jetbrains-community/tst-resources/exampleNotification2.json new file mode 100644 index 00000000000..344f45191c7 --- /dev/null +++ b/plugins/core/jetbrains-community/tst-resources/exampleNotification2.json @@ -0,0 +1,124 @@ +{ + "schema": { + "version": "2.0" +}, + "notifications": [ + { + + "id": "example_id_12344", + "schedule": { + "type": "StartUp" + }, + "severity": "Critical", + "condition": { + "compute": { + "type": { + "or": [ + { + "==": "ec2" + }, + { + "==": "desktop" + } + ] + }, + "architecture": { + "!=": "x64" + } + }, + "os": { + "type": { + "anyOf": [ + "Darwin", + "Linux" + ] + }, + "version": { + "<=": "23.0.1.0" + } + + }, + "ide": { + "type": { + "noneOf": [ + "PyCharm", + "IDEA" + ] + }, + "version": { + "and": [ + { + ">=": "1.0" + }, + { + "<": "2.0" + } + ] + } + }, + "extensions": [ + { + "id": "aws.toolkit", + "version": { + "!=": "1.3334" + } + }, + { + "id": "amazon.q", + "version": { + "!=": "3.37.0" + } + } + ] + + , + "authx": [{ + "feature" : "q", + "type": { + "anyOf": [ + "IamIdentityCenter", + "AwsBuilderId" + ] + }, + "region": { + "==": "us-east-1" + }, + "connectionState": { + "!=": "Connected" + }, + "ssoScopes": { + "noneOf": [ + "codewhisperer:scope1", + "sso:account:access" + ] + } + } ] + }, + "content": { + "en-US": { + "title": "Look at this!", + "description": "Some bug is there" + } + }, + "actions": [ + { + "type": "ShowMarketplace", + "content": { + "en-US": { + "title": "Go to market" + } + } + }, + { + "type": "ShowUrl", + "content": { + "en-US": { + "title": "Click me!", + "url": "http://nowhere" + } + } + } + ] + } + ] +} diff --git a/plugins/core/jetbrains-community/tst-resources/olderNotification.json b/plugins/core/jetbrains-community/tst-resources/olderNotification.json new file mode 100644 index 00000000000..9ceee415d37 --- /dev/null +++ b/plugins/core/jetbrains-community/tst-resources/olderNotification.json @@ -0,0 +1,115 @@ +{ + "schema": { + "version": "2.0" + }, + "notifications": [ + { + + "id": "example_id_12344", + "schedule": { + "type": "StartUp" + }, + "severity": "Critical", + "condition": { + "compute": { + "type": { + "or": [ + { + "==": "ec2" + }, + { + "==": "desktop" + } + ] + }, + "architecture": { + "!=": "x64" + } + }, + "os": { + "type": { + "anyOf": [ + "Darwin", + "Linux" + ] + }, + "version": { + "<=": "23.0.1.0" + } + + }, + "ide": { + "type": { + "noneOf": [ + "PyCharm", + "IDEA" + ] + }, + "version": { + "and": [ + { + ">=": "1.0" + }, + { + "<": "2.0" + } + ] + } + }, + "extension": { + "type": { + "==": "AWS Toolkit for JetBrains" + }, + "version": { + "<": "1.47.0.0" + } + }, + "authx": { + "type": { + "anyOf": [ + "IamIdentityCenter", + "AwsBuilderId" + ] + }, + "region": { + "==": "us-east-1" + }, + "connectionState": { + "!=": "Connected" + }, + "ssoScopes": { + "noneOf": [ + "codewhisperer:scope1", + "sso:account:access" + ] + } + } + }, + "content": { + "en-US": { + "title": "Look at this!", + "description": "Some bug is there" + } + }, + "actions": [ + { + "type": "ShowMarketplace", + "content": { + "en-US": { + "title": "Go to market" + } + } + }, + { + "type": "ShowUrl", + "content": { + "en-US": { + "title": "Click me!", + "url": "http://nowhere" + } + } + } + ] + } + ] +} diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtilsTest.kt b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtilsTest.kt new file mode 100644 index 00000000000..35311f21f4f --- /dev/null +++ b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtilsTest.kt @@ -0,0 +1,136 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.intellij.testFramework.ApplicationExtension +import com.intellij.testFramework.ProjectRule +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.assertj.core.api.Assertions.assertThat +import org.junit.Rule +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import software.aws.toolkits.core.utils.exists +import software.aws.toolkits.core.utils.inputStream +import software.aws.toolkits.jetbrains.core.gettingstarted.editor.BearerTokenFeatureSet +import java.io.InputStream +import java.nio.file.Paths +import java.util.stream.Stream + +@ExtendWith(ApplicationExtension::class) +class NotificationFormatUtilsTest { + @Rule + @JvmField + val projectRule = ProjectRule() + + private lateinit var mockSystemDetails: SystemDetails + private lateinit var exampleNotification: InputStream + + @BeforeEach + fun setUp() { + mockSystemDetails = SystemDetails( + computeType = "Local", + computeArchitecture = "x86_64", + osType = "Linux", + osVersion = "5.4.0", + ideType = "IC", + ideVersion = "2023.1", + pluginVersions = mapOf( + "aws.toolkit" to "1.0", + "amazon.q" to "2.0" + ) + ) + + exampleNotification = javaClass.getResource("/exampleNotification2.json")?.let { + Paths.get(it.toURI()).takeIf { f -> f.exists() } + }?.inputStream() ?: throw RuntimeException("Test not found") + + mockkStatic("software.aws.toolkits.jetbrains.core.notifications.RulesEngineKt") + every { getCurrentSystemAndConnectionDetails() } returns mockSystemDetails + every { getConnectionDetailsForFeature(projectRule.project, BearerTokenFeatureSet.Q) } returns FeatureAuthDetails( + "Idc", + "us-west-2", + "Connected" + ) + } + + @AfterEach + fun tearDown() { + unmockkAll() + } + + @Test + fun `test System Details`() { + val result = getCurrentSystemAndConnectionDetails() + assertThat(mockSystemDetails).isEqualTo(result) + } + + @Test + fun `check Json Validity which has all the required fields`() { + assertDoesNotThrow { + mapper.readValue(exampleNotification) + } + } + + @Test + fun `No schema version associated with the notification file throws an exception`() { + assertThrows { + mapper.readValue(exampleNotificationWithoutSchema) + } + } + + @Test + fun `No notifications present with the version file does not throw an exception`() { + assertDoesNotThrow { + mapper.readValue(exampleNotificationWithoutNotification) + } + } + + @ParameterizedTest + @MethodSource("validNotifications") + fun `The notification is shown`(notification: String, expectedData: NotificationData) { + val notificationData = mapper.readValue(notification) + assertThat(notificationData).isEqualTo(expectedData) + val shouldShow = RulesEngine.displayNotification(projectRule.project, notificationData) + assertThat(shouldShow).isTrue + } + + @ParameterizedTest + @MethodSource("invalidNotifications") + fun `The notification is not shown`(notification: String, expectedData: NotificationData) { + val notificationData = mapper.readValue(notification) + assertThat(notificationData).isEqualTo(expectedData) + val shouldShow = RulesEngine.displayNotification(projectRule.project, notificationData) + assertThat(shouldShow).isFalse + } + + companion object { + @JvmStatic + fun validNotifications(): Stream = Stream.of( + Arguments.of(notificationWithConditionsOrActions, notificationWithConditionsOrActionsData), + Arguments.of(notificationWithoutConditionsOrActions, notificationsWithoutConditionsOrActionsData), + Arguments.of(notificationWithValidConnection, notificationWithValidConnectionData) + ) + + @JvmStatic + fun invalidNotifications(): Stream = Stream.of( + Arguments.of(validComputeInvalidOs, validOsInvalidComputeData), + Arguments.of(invalidExtensionVersion, invalidExtensionVersionData), + Arguments.of(invalidIdeTypeAndVersion, invalidIdeTypeAndVersionData) + ) + + private val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + } +} diff --git a/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtilsTestCases.kt b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtilsTestCases.kt new file mode 100644 index 00000000000..9a39843b3f6 --- /dev/null +++ b/plugins/core/jetbrains-community/tst/software/aws/toolkits/jetbrains/core/notifications/NotificationFormatUtilsTestCases.kt @@ -0,0 +1,362 @@ +// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.core.notifications + +val validComputeInvalidOs = """{ + "id": "example_id_12344", + "schedule": { + "type": "StartUp" +}, + "severity": "Critical", + "condition": { + "compute": { + "type": {"==": "Local"} +}, +"os": { + "type": {"==": "Windows"} +} +}, + "actions": [ + { + "type": "ShowMarketplace", + "content": { + "en-US": { + "title": "Go to market" + } + } + } + ], + "content": { + "en-US": { + "title": "Look at this!", + "description": "Some bug is there" +} +} +} +""".trimIndent() + +val validOsInvalidComputeData = NotificationData( + id = "example_id_12344", + schedule = NotificationSchedule(type = "StartUp"), + severity = "Critical", + condition = NotificationDisplayCondition( + compute = ComputeType(type = NotificationExpression.ComparisonCondition("Local"), architecture = null), + os = SystemType(type = NotificationExpression.ComparisonCondition("Windows"), version = null), + ide = null, + extension = null, + authx = null + ), + actions = listOf( + NotificationFollowupActions( + type = "ShowMarketplace", + content = NotificationFollowupActionsContent( + NotificationActionDescription( + title = "Go to market", + url = null + ) + ) + ) + ), + content = NotificationContentDescriptionLocale( + NotificationContentDescription( + title = "Look at this!", + description = "Some bug is there" + ) + ) +) + +val invalidExtensionVersion = """{ + "id": "example_id_12344", + "schedule": { + "type": "StartUp" +}, + "severity": "Critical", + "condition": { + "extension": [ + { + "id": "aws.toolkit", + "version": { + "!=": "1.3334" + } + }, + { + "id": "amazon.q", + "version": { + ">": "3.37.0" + } + } + ] +}, + "content": { + "en-US": { + "title": "Look at this!", + "description": "Some bug is there" +} +} +} +""".trimIndent() + +val invalidExtensionVersionData = NotificationData( + id = "example_id_12344", + schedule = NotificationSchedule(type = "StartUp"), + severity = "Critical", + condition = NotificationDisplayCondition( + compute = null, + os = null, + ide = null, + extension = listOf( + ExtensionType( + id = "aws.toolkit", + version = NotificationExpression.NotEqualsCondition("1.3334") + ), + ExtensionType( + id = "amazon.q", + version = NotificationExpression.GreaterThanCondition("3.37.0") + ) + ), + authx = null + + ), + actions = emptyList(), + content = NotificationContentDescriptionLocale( + NotificationContentDescription( + title = "Look at this!", + description = "Some bug is there" + ) + ) +) + +val exampleNotificationWithoutSchema = """ + { + "notifications": [ + { + "id": "notification-001", + "title": "Test Notification", + "description": "This is a test notification", + "type": "INFO", + + "rules": { + "computeType": "Local", + "osType": "Linux", + "ideType": "IC", + "pluginVersion": { + "aws.toolkit": "1.0" + } + } + } + ] + } +""".trimIndent() + +val exampleNotificationWithoutNotification = """ + { + "schema": { + "version": "2.0" +} + + } +""".trimIndent() + +val notificationWithoutConditionsOrActions = """ + { + "id": "example_id_12344", + "schedule": { + "type": "StartUp" + }, + "severity": "Critical", + "content": { + "en-US": { + "title": "Look at this!", + "description": "Some bug is there" + } + } + } + + +""".trimIndent() + +val notificationsWithoutConditionsOrActionsData = NotificationData( + id = "example_id_12344", + schedule = NotificationSchedule(type = "StartUp"), + severity = "Critical", + condition = null, + actions = emptyList(), + content = NotificationContentDescriptionLocale( + NotificationContentDescription( + title = "Look at this!", + description = "Some bug is there" + ) + ) +) + +val notificationWithConditionsOrActions = """ + { + "id": "example_id_12344", + "schedule": { + "type": "StartUp" + }, + "severity": "Critical", + "condition": { + "compute": { + "type": { + + "==": "Local" + + } + } + }, + "actions": [ + { + "type": "ShowMarketplace", + "content": { + "en-US": { + "title": "Go to market" + } + } + } + ], + "content": { + "en-US": { + "title": "Look at this!", + "description": "Some bug is there" + } + } + } + +""".trimIndent() + +val notificationWithConditionsOrActionsData = NotificationData( + id = "example_id_12344", + schedule = NotificationSchedule(type = "StartUp"), + severity = "Critical", + condition = NotificationDisplayCondition( + compute = ComputeType(type = NotificationExpression.ComparisonCondition("Local"), architecture = null), + os = null, + ide = null, + extension = null, + authx = null + ), + actions = listOf( + NotificationFollowupActions( + type = "ShowMarketplace", + content = NotificationFollowupActionsContent( + NotificationActionDescription( + title = "Go to market", + url = null + ) + ) + ) + ), + content = NotificationContentDescriptionLocale( + NotificationContentDescription( + title = "Look at this!", + description = "Some bug is there" + ) + ) +) + +val notificationWithValidConnection = """{ + "id": "example_id_12344", + "schedule": { + "type": "StartUp" +}, + "severity": "Critical", + "condition": { + "authx": [{ + "feature" : "q", + "type": { + "anyOf": [ + "Idc", + "BuilderId" + ] + }, + "region": { + "==": "us-west-2" + }, + "connectionState": { + "==": "Connected" + } + } ] +}, + "content": { + "en-US": { + "title": "Look at this!", + "description": "Some bug is there" +} +} +} +""".trimIndent() + +val notificationWithValidConnectionData = NotificationData( + id = "example_id_12344", + schedule = NotificationSchedule(type = "StartUp"), + severity = "Critical", + condition = NotificationDisplayCondition( + compute = null, + os = null, + ide = null, + extension = null, + authx = listOf( + AuthxType( + feature = "q", + type = NotificationExpression.AnyOfCondition(listOf("Idc", "BuilderId")), + region = NotificationExpression.ComparisonCondition("us-west-2"), + connectionState = NotificationExpression.ComparisonCondition("Connected"), + ssoScopes = null + ) + ) + ), + actions = emptyList(), + content = NotificationContentDescriptionLocale( + NotificationContentDescription( + title = "Look at this!", + description = "Some bug is there" + ) + ) +) + +val invalidIdeTypeAndVersion = """{ + "id": "example_id_12344", + "schedule": { + "type": "StartUp" +}, + "severity": "Critical", + "condition": { + "ide": { + "type": {"noneOf": ["IC","IU","RD"]}, + "version": {"!=": "1.3334"} +} +}, + "content": { + "en-US": { + "title": "Look at this!", + "description": "Some bug is there" +} +} +} +""".trimIndent() + +val invalidIdeTypeAndVersionData = NotificationData( + id = "example_id_12344", + schedule = NotificationSchedule(type = "StartUp"), + severity = "Critical", + condition = NotificationDisplayCondition( + compute = null, + os = null, + ide = SystemType( + type = NotificationExpression.NoneOfCondition(listOf("IC", "IU", "RD")), + version = NotificationExpression.NotEqualsCondition("1.3334") + ), + extension = null, + authx = null + + ), + actions = emptyList(), + content = NotificationContentDescriptionLocale( + NotificationContentDescription( + title = "Look at this!", + description = "Some bug is there" + ) + ) +)