diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 81dcf707..4452a25a 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -9,3 +9,8 @@ updates:
directory: "/"
schedule:
interval: "weekly"
+ - package-ecosystem: "github-actions"
+ # Workflow files stored in the default location of `.github/workflows`. (You don't need to specify `/.github/workflows` for `directory`. You can use `directory: "/"`.)
+ directory: "/"
+ schedule:
+ interval: "weekly"
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index c0f081a4..bbcfa5e4 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -4,9 +4,6 @@ on:
workflow_dispatch:
push:
branches: [ 'develop', 'master', 'releases/**' ]
- pull_request:
- # The branches below must be a subset of the branches above
- branches: [ 'develop', 'master', 'releases/**' ]
schedule:
- cron: '17 14 * * 2'
@@ -19,4 +16,5 @@ jobs:
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Use only 'java' to analyze code written in Java, Kotlin or both
# Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
- # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
\ No newline at end of file
+ # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
+ java_version: 21
\ No newline at end of file
diff --git a/.github/workflows/maven-deploy.yml b/.github/workflows/maven-deploy.yml
index 1d4146ba..3cf893a1 100644
--- a/.github/workflows/maven-deploy.yml
+++ b/.github/workflows/maven-deploy.yml
@@ -32,6 +32,7 @@ jobs:
with:
environment: internal-publish
release_type: snapshot
+ java_version: 21
secrets:
username: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
@@ -43,6 +44,7 @@ jobs:
with:
environment: ${{ inputs.environment }}
release_type: ${{ inputs.release_type }}
+ java_version: 21
secrets:
username: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
password: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
diff --git a/.github/workflows/maven-test.yml b/.github/workflows/maven-test.yml
index 6bdada9f..33932bf6 100644
--- a/.github/workflows/maven-test.yml
+++ b/.github/workflows/maven-test.yml
@@ -15,4 +15,6 @@ on:
jobs:
maven-tests:
uses: wultra/wultra-infrastructure/.github/workflows/maven-test.yml@develop
- secrets: inherit
\ No newline at end of file
+ secrets: inherit
+ with:
+ java_version: 21
\ No newline at end of file
diff --git a/.github/workflows/publish-docker-image.yml b/.github/workflows/publish-docker-image.yml
index a47c0bfd..e1ae067d 100644
--- a/.github/workflows/publish-docker-image.yml
+++ b/.github/workflows/publish-docker-image.yml
@@ -7,6 +7,15 @@ on:
- 'develop'
- 'master'
- 'releases/*'
+ inputs:
+ push_to_acr:
+ description: Push to ACR?
+ type: boolean
+ default: true
+ push_to_jfrog:
+ description: Push to JFrog?
+ type: boolean
+ default: false
pull_request:
branches:
- 'develop'
@@ -22,10 +31,10 @@ jobs:
INTERNAL_USERNAME: ${{ secrets.JFROG_USERNAME }}
INTERNAL_PASSWORD: ${{ secrets.JFROG_PASSWORD }}
steps:
- - uses: actions/checkout@v3
- - uses: actions/setup-java@v3
+ - uses: actions/checkout@v4
+ - uses: actions/setup-java@v4
with:
- java-version: '17'
+ java-version: '21'
distribution: 'temurin'
server-id: jfrog-central
server-username: INTERNAL_USERNAME
@@ -41,25 +50,38 @@ jobs:
run: |
mvn -U -DuseInternalRepo=true --no-transfer-progress clean package
- name: Set up QEMU
- uses: docker/setup-qemu-action@v2
+ uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v2
+ uses: docker/setup-buildx-action@v3
with:
install: true
-
+ - name: Log in to JFrog registry
+ if: inputs.push_to_jfrog == true
+ uses: docker/login-action@v3
+ with:
+ registry: https://wultra.jfrog.io/
+ username: ${{ vars.JFROG_CONTAINER_REGISTRY_USERNAME }}
+ password: ${{ secrets.JFROG_CONTAINER_REGISTRY_PASSWORD }}
- name: Log in to Azure registry
- if: ${{ github.event_name == 'workflow_dispatch' }}
- uses: docker/login-action@v2
+ if: inputs.push_to_acr == true
+ uses: docker/login-action@v3
with:
registry: https://powerauth.azurecr.io/
- username: ${{ secrets.ACR_USERNAME }}
+ username: ${{ vars.ACR_USERNAME }}
password: ${{ secrets.ACR_PASSWORD }}
- name: Build and push container image to Azure registry
- uses: docker/build-push-action@v3
+ uses: docker/build-push-action@v6
with:
- push: ${{ github.event_name == 'workflow_dispatch' }}
- platforms: linux/amd64
- tags: powerauth.azurecr.io/mobile-utility-server:${{ env.REVISION }}${{ env.TIMESTAMP }}-${{ github.sha }}
+ push: ${{ inputs.push_to_acr == true || inputs.push_to_jfrog == true }}
+ platforms: linux/amd64,linux/arm64
+ tags: |
+ ${{ inputs.push_to_acr == true && format('powerauth.azurecr.io/mobile-utility-server:{0}{1}-{2}', env.REVISION, env.TIMESTAMP, github.sha) || '' }}
+ ${{ inputs.push_to_jfrog == true && format('wultra.jfrog.io/wultra-docker/mobile-utility-server:{0}{1}-{2}', env.REVISION, env.TIMESTAMP, github.sha) || '' }}
file: ./deploy/dockerfile/runtime/Dockerfile
context: .
-
+ - run: echo '### 🚀 Published images' >> $GITHUB_STEP_SUMMARY
+ - if: inputs.push_to_acr == true
+ run: |
+ echo 'powerauth.azurecr.io/mobile-utility-server:${{ env.REVISION }}${{ env.TIMESTAMP }}-${{ github.sha }}' >> $GITHUB_STEP_SUMMARY
+ - if: inputs.push_to_jfrog == true
+ run: echo 'wultra.jfrog.io/wultra-docker/mobile-utility-server:${{ env.REVISION }}${{ env.TIMESTAMP }}-${{ github.sha }}' >> $GITHUB_STEP_SUMMARY
diff --git a/deploy/dockerfile/runtime/docker-entrypoint.sh b/deploy/dockerfile/runtime/docker-entrypoint.sh
index a54f6128..7dc41bee 100755
--- a/deploy/dockerfile/runtime/docker-entrypoint.sh
+++ b/deploy/dockerfile/runtime/docker-entrypoint.sh
@@ -1,10 +1,10 @@
-#!/usr/bin/env sh
+#!/usr/bin/env bash
set -euo pipefail
-liquibase --headless=true --log-level=INFO --changeLogFile=$LB_HOME/db/changelog/db.changelog-master.xml \
- --username=$MOBILE_UTILITY_SERVER_DATASOURCE_USERNAME \
- --password=$MOBILE_UTILITY_SERVER_DATASOURCE_PASSWORD \
- --url=$MOBILE_UTILITY_SERVER_DATASOURCE_URL \
+liquibase --headless=true --log-level=INFO --changeLogFile="${LB_HOME}/db/changelog/db.changelog-master.xml" \
+ --username="${MOBILE_UTILITY_SERVER_DATASOURCE_USERNAME:-}" \
+ --password="${MOBILE_UTILITY_SERVER_DATASOURCE_PASSWORD:-}" \
+ --url="${MOBILE_UTILITY_SERVER_DATASOURCE_URL}" \
update
java -Dserver.port=8000 -jar /mobile-utility-server.war
diff --git a/docs/API-Admin.md b/docs/API-Admin.md
new file mode 100644
index 00000000..5653c848
--- /dev/null
+++ b/docs/API-Admin.md
@@ -0,0 +1,959 @@
+# Administration Services
+
+
+
+The Administration Services of Mobile Utility Server provide functionality for managing applications,
+certificates, versions, and text resources.
+
+## Possible Error Codes
+
+| HTTP Status Code | Error Code | Description |
+|------------------|------------------------|---------------------------------------------------------------------------------------------------|
+| `400` | `APP_EXCEPTION` | Indicates an error occurring during app operations like creating an already existing app. |
+| `400` | `ERROR_REQUEST` | Request did not pass a structural validation (mandatory field is null, invalid field type, etc.). |
+| `400` | `UNKNOWN_ERROR` | An error occurred when processing the request. Problems with request binding |
+| `401` | `ERROR_AUTHENTICATION` | Unauthorized access attempt. This occurs when invalid credentials are provided. |
+| `404` | `APP_NOT_FOUND` | Indicates that the app with the provided ID was not found. |
+| `500` | `INTERNAL_ERROR` | An internal server error occurred, potentially due to misconfiguration. |
+
+## Services
+
+
+
+### Create Application
+
+Create a new application with specified name.
+
+#### Request
+
+##### Request Body
+
+```json
+{
+ "name": "mobile-app",
+ "displayName": "Mobile App"
+}
+```
+
+| Attribute | Type | Description |
+|---------------------------------------------------------------|----------|----------------------------------|
+| `name`* | `String` | Name of the application. |
+| `displayName`* | `String` | Display name of the application. |
+
+#### Response 200
+
+```json
+{
+ "name": "Application Name",
+ "displayName": "Display name of the Application",
+ "publicKey": "pk1",
+ "domains": [
+ {
+ "name": "name1",
+ "certificates": [
+ {
+ "pem": "pem1",
+ "fingerprint": "fingerprint1",
+ "expires": 100
+ }
+ ]
+ }
+ ]
+}
+```
+
+| Attribute | Type | Description |
+|----------------------------------------|------------|------------------------------------------------------|
+| `name` | `String` | Name of the application. |
+| `displayName` | `String` | Display name of the application. |
+| `publicKey` | `String` | Public key of the application. |
+| `domains` | `Object[]` | List of domain configurations for the application. |
+| `domains[].name` | `String` | Name of the domain. |
+| `domains[].certificates` | `Object[]` | List of certificates for the domain. |
+| `domains[].certificates[].pem` | `String` | PEM-encoded certificate. |
+| `domains[].certificates[].fingerprint` | `String` | Fingerprint of the certificate. |
+| `domains[].certificates[].expires` | `Long` | Expiration time of the certificate in epoch seconds. |
+
+#### Response 400
+
+Application creation failed due to invalid input or missing required fields.
+
+```json
+{
+ "status": "ERROR",
+ "responseObject": {
+ "code": "ERROR_REQUEST",
+ "message": "Required fields are missing"
+ }
+}
+```
+
+Application creation failed due to already used app name or error while generating cryptographic keys.
+
+```json
+{
+ "status": "ERROR",
+ "responseObject": {
+ "code": "APP_EXCEPTION",
+ "message": "Application with name already exists: XY / Error while generating cryptographic keys"
+ }
+}
+```
+
+Possible Error states are:
+
+- `ERROR_REQUEST` - Request did not pass a structural validation (mandatory field is null, invalid field type, etc.).
+- `APP_EXCEPTION` - Application with name already exists: XY or Error while generating cryptographic keys.
+
+#### Response 401
+
+Invalid username or password was provided while calling the service.
+
+```json
+{
+ "status": "ERROR",
+ "responseObject": {
+ "code": "ERROR_AUTHENTICATION",
+ "message": "Unauthorized"
+ }
+}
+```
+
+
+
+
+
+### List Applications
+
+Retrieve a list of all registered applications.
+
+#### Response 200
+
+```json
+{
+ "applications": [
+ {
+ "name": "mobile-app-1",
+ "displayName": "Mobile App 1"
+ },
+ {
+ "name": "mobile-app-2",
+ "displayName": "Mobile App 2"
+ }
+ ]
+}
+```
+
+| Attribute | Type | Description |
+|------------------------------|------------|----------------------------------|
+| `applications` | `Object[]` | List of registered applications. |
+| `applications[].name` | `String` | Name of the application. |
+| `applications[].displayName` | `String` | Display name of the application. |
+
+#### Response 401
+
+Invalid username or password was provided while calling the service.
+
+```json
+{
+ "status": "ERROR",
+ "responseObject": {
+ "code": "ERROR_AUTHENTICATION",
+ "message": "Unauthorized"
+ }
+}
+```
+
+
+
+
+
+### Application Detail
+
+Retrieve detailed information about a specific application by its name. This endpoint provides complete details
+including configuration settings.
+
+#### Request
+
+##### Path Params
+
+| Param | Type | Description |
+|--------------------------------------------------------|----------|--------------------------------------------------|
+| `name`* | `String` | Name of the application to retrieve details for. |
+
+#### Response 200
+
+```json
+{
+ "name": "Application Name",
+ "displayName": "Display name of the Application",
+ "publicKey": "pk1",
+ "domains": [
+ {
+ "name": "name1",
+ "certificates": [
+ {
+ "pem": "pem1",
+ "fingerprint": "fingerprint1",
+ "expires": 100
+ }
+ ]
+ }
+ ]
+}
+```
+
+| Attribute | Type | Description |
+|----------------------------------------|------------|------------------------------------------------------|
+| `name` | `String` | Name of the application. |
+| `displayName` | `String` | Display name of the application. |
+| `publicKey` | `String` | Public key of the application. |
+| `domains` | `Object[]` | List of domain configurations for the application. |
+| `domains[].name` | `String` | Name of the domain. |
+| `domains[].certificates` | `Object[]` | List of certificates for the domain. |
+| `domains[].certificates[].pem` | `String` | PEM-encoded certificate. |
+| `domains[].certificates[].fingerprint` | `String` | Fingerprint of the certificate. |
+| `domains[].certificates[].expires` | `Long` | Expiration time of the certificate in epoch seconds. |
+
+#### Response 401
+
+Invalid username or password was provided while calling the service.
+
+```json
+{
+ "status": "ERROR",
+ "responseObject": {
+ "code": "ERROR_AUTHENTICATION",
+ "message": "Unauthorized"
+ }
+}
+```
+
+
+
+
+
+### Create Automatic Application Certificate
+
+Automatically generate and associate a certificate with a specified application. This endpoint handles the creation and
+registration of a new certificate using server-defined parameters.
+
+#### Request
+
+##### Path Params
+
+| Param | Type | Description |
+|--------------------------------------------------------|----------|--------------------------------------------------------------------|
+| `name`* | `String` | Name of the application for which the certificate will be created. |
+
+##### Request Body
+
+```json
+{
+ "domain": "domain1"
+}
+```
+
+| Attribute | Type | Description |
+|----------------------------------------------------------|----------|-------------------------------------------------|
+| `domain`* | `String` | The domain from which to fetch the certificate. |
+
+#### Response 200
+
+```json
+{
+ "name": "Domain Name",
+ "pem": "pem1",
+ "fingerprint": "fingerprint1",
+ "expires": 100
+}
+```
+
+| Attribute | Type | Description |
+|---------------------------------------------------------------|----------|---------------------------------------------|
+| `name`* | `String` | Name of the domain. |
+| `pem`* | `String` | PEM format of the certificate. |
+| `fingerprint`* | `String` | Fingerprint of the certificate.. |
+| `expires`* | `Long` | Timestamp when the certificate will expire. |
+
+#### Response 400
+
+Certificate creation failed due to invalid input or missing required fields.
+
+```json
+{
+ "status": "ERROR",
+ "responseObject": {
+ "code": "ERROR_REQUEST",
+ "message": "Required fields are missing"
+ }
+}
+```
+
+#### Response 401
+
+Invalid username or password was provided while calling the service.
+
+```json
+{
+ "status": "ERROR",
+ "responseObject": {
+ "code": "ERROR_AUTHENTICATION",
+ "message": "Unauthorized"
+ }
+}
+```
+
+#### Response 404
+
+Failed to create the certificate because the requested app was not found.
+
+```json
+{
+ "status": "ERROR",
+ "responseObject": {
+ "code": "APP_NOT_FOUND",
+ "message": "App with a provided ID was not found."
+ }
+}
+```
+
+#### Response 500
+
+Error occurred during app execution.
+
+```json
+{
+ "timestamp": "TIMESTAMP",
+ "status": 500,
+ "error": "Internal Server Error",
+ "path": "/admin/apps/mobile-app22/certificates/auto"
+}
+```
+
+
+
+
+### Create PEM Application Certificate
+
+Manually add a PEM format certificate to a specified application. This endpoint accepts a PEM-encoded certificate and
+associates it with the application.
+
+#### Request
+
+##### Path Params
+
+| Param | Type | Description |
+|--------------------------------------------------------|----------|--------------------------------------------------------------------|
+| `name`* | `String` | Name of the application to which the certificate will be attached. |
+
+##### Request Body
+
+```json
+{
+ "pem": "pem1"
+}
+```
+
+| Attribute | Type | Description |
+|-------------------------------------------------------|----------|---------------------------------|
+| `pem`* | `String` | PEM encoded certificate string. |
+
+#### Response 200
+
+```json
+{
+ "name": "Domain Name",
+ "pem": "pem1",
+ "fingerprint": "fingerprint1",
+ "expires": 100
+}
+```
+
+| Attribute | Type | Description |
+|---------------------------------------------------------------|----------|---------------------------------------------|
+| `name`* | `String` | Name of the domain. |
+| `pem`* | `String` | PEM format of the certificate. |
+| `fingerprint`* | `String` | Fingerprint of the certificate.. |
+| `expires`* | `Long` | Timestamp when the certificate will expire. |
+
+#### Response 400
+
+Certificate creation failed due to invalid input or missing required fields.
+
+```json
+{
+ "status": "ERROR",
+ "responseObject": {
+ "code": "ERROR_REQUEST",
+ "message": "Required fields are missing"
+ }
+}
+```
+
+#### Response 401
+
+Invalid username or password was provided while calling the service.
+
+```json
+{
+ "status": "ERROR",
+ "responseObject": {
+ "code": "ERROR_AUTHENTICATION",
+ "message": "Unauthorized"
+ }
+}
+```
+
+#### Response 404
+
+Failed to create the certificate because the requested app was not found.
+
+```json
+{
+ "status": "ERROR",
+ "responseObject": {
+ "code": "APP_NOT_FOUND",
+ "message": "App with a provided ID was not found."
+ }
+}
+```
+
+#### Response 500
+
+Error occurred during app execution.
+
+```json
+{
+ "timestamp": "TIMESTAMP",
+ "status": 500,
+ "error": "Internal Server Error",
+ "path": "/admin/apps/mobile-app22/certificates/auto"
+}
+```
+
+
+
+
+
+### Delete Certificate
+
+Delete a certificate associated with a specific application based on domain and fingerprint criteria.
+
+#### Request
+
+##### Path Params
+
+| Param | Type | Description |
+|--------------------------------------------------------|----------|------------------------------------------------------------------|
+| `name`* | `String` | Name of the application from which certificates will be deleted. |
+
+##### Query Parameters
+
+| Parameter | Type | Description |
+|---------------------------------------------------------------|----------|---------------------------------------------------------------------|
+| `domain`* | `String` | Domain associated with the certificate. |
+| `fingerprint`* | `String` | Fingerprint of the certificate to specifically target for deletion. |
+
+#### Response 200
+
+```json
+{
+ "status": "OK"
+}
+```
+
+#### Response 401
+
+Invalid username or password was provided while calling the service.
+
+```json
+{
+ "status": "ERROR",
+ "responseObject": {
+ "code": "ERROR_AUTHENTICATION",
+ "message": "Unauthorized"
+ }
+}
+```
+
+
+
+
+
+### Delete Domain
+
+Delete a domain associated with a specific application.
+
+#### Request
+
+##### Path Params
+
+| Param | Type | Description |
+|--------------------------------------------------------|----------|----------------------------------------------------------------|
+| `name`* | `String` | Name of the application from which the domain will be deleted. |
+
+##### Query Parameters
+
+| Parameter | Type | Description |
+|----------------------------------------------------------|----------|--------------------------------------------|
+| `domain`* | `String` | Domain to be deleted from the application. |
+
+#### Response 200
+
+Indicates that the domain was successfully deleted.
+
+```json
+{
+ "status": "OK"
+}
+```
+
+#### Response 401
+
+Invalid username or password was provided while calling the service.
+
+```json
+{
+ "status": "ERROR",
+ "responseObject": {
+ "code": "ERROR_AUTHENTICATION",
+ "message": "Unauthorized"
+ }
+}
+```
+
+
+
+
+
+### Delete Expired Certificates
+
+Remove all expired certificates from the system. This endpoint provides a cleanup mechanism for old or no longer valid
+certificates.
+
+#### Response 200
+
+Indicates that all expired certificates were successfully removed.
+
+```json
+{
+ "status": "OK"
+}
+```
+
+#### Response 401
+
+Invalid username or password was provided while calling the service.
+
+```json
+{
+ "status": "ERROR",
+ "responseObject": {
+ "code": "ERROR_AUTHENTICATION",
+ "message": "Unauthorized"
+ }
+}
+```
+
+
+
+
+
+### List Application Versions
+
+Retrieve a list of all versions for a specific application. This endpoint provides details about each version, including
+version number and status.
+
+#### Request
+
+##### Path Parameters
+
+| Param | Type | Description |
+|--------------------------------------------------------|----------|--------------------------------------------------------------|
+| `name`* | `String` | Name of the application for which versions are being listed. |
+
+#### Response 200
+
+```json
+{
+ "applicationVersions": [
+ {
+ "id": 1,
+ "platform": "ANDROID",
+ "majorOsVersion": 12,
+ "suggestedVersion": "3.1.2",
+ "requiredVersion": "3.1.2",
+ "messageKey": "update_required"
+ }
+ ]
+}
+```
+
+| Attribute | Type | Description |
+|------------------------------------------------------------------------------------|------------|-------------------------------------------------------------------------------|
+| `applicationVersions` | `Object[]` | List of app versions. |
+| `applicationVersions[].id`* | `Long` | Unique identifier of the application version. |
+| `applicationVersions[].platform`* | `Enum` | Platform of the application (e.g., ANDROID, IOS). |
+| `applicationVersions[].majorOsVersion` | `Integer` | Major OS version for the application, may be `null` to match all. |
+| `applicationVersions[].suggestedVersion` | `String` | Suggested version of the application in SemVer 2.0 format. |
+| `applicationVersions[].requiredVersion` | `String` | Required version of the application in SemVer 2.0 format. |
+| `applicationVersions[].messageKey`* | `String` | Key for the message related to the version (e.g., for localization purposes). |
+
+#### Response 401
+
+Invalid username or password was provided while calling the service.
+
+```json
+{
+ "status": "ERROR",
+ "responseObject": {
+ "code": "ERROR_AUTHENTICATION",
+ "message": "Unauthorized"
+ }
+}
+```
+
+
+
+
+
+### Application Version Detail
+
+Retrieve detailed information about a specific version of an application.
+
+#### Request
+
+##### Path Parameters
+
+| Param | Type | Description |
+|--------------------------------------------------------|----------|----------------------------------------------------|
+| `name`* | `String` | Name of the application. |
+| `id` * | `Long` | Identifier of the version to retrieve details for. |
+
+#### Response 200
+
+```json
+ {
+ "id": 1,
+ "platform": "ANDROID",
+ "majorOsVersion": 12,
+ "suggestedVersion": "3.1.2",
+ "requiredVersion": "3.1.2",
+ "messageKey": "update_required"
+}
+```
+
+| Attribute | Type | Description |
+|--------------------------------------------------------------|-----------|-------------------------------------------------------------------------------|
+| `id`* | `Long` | Unique identifier of the application version. |
+| `platform`* | `Enum` | Platform of the application (e.g., ANDROID, IOS). |
+| `majorOsVersion` | `Integer` | Major OS version for the application, may be `null` to match all. |
+| `suggestedVersion` | `String` | Suggested version of the application in SemVer 2.0 format. |
+| `requiredVersion` | `String` | Required version of the application in SemVer 2.0 format. |
+| `messageKey`* | `String` | Key for the message related to the version (e.g., for localization purposes). |
+
+#### Response 401
+
+Invalid username or password was provided while calling the service.
+
+```json
+{
+ "status": "ERROR",
+ "responseObject": {
+ "code": "ERROR_AUTHENTICATION",
+ "message": "Unauthorized"
+ }
+}
+```
+
+
+
+
+
+### Create Application Version
+
+Add a new version to a specific application.
+
+#### Request
+
+##### Path Parameters
+
+| Param | Type | Description |
+|--------------------------------------------------------|----------|------------------------------------------------|
+| `name`* | `String` | Name of the application to add the version to. |
+
+##### Request Body
+
+```json
+{
+ "platform": "ANDROID",
+ "majorOsVersion": 12,
+ "suggestedVersion": "3.1.2",
+ "requiredVersion": "3.1.2",
+ "messageKey": "update_required"
+}
+
+```
+
+| Attribute | Type | Description |
+|--------------------------------------------------------------------|-----------|-------------------------------------------------------------------------------|
+| `platform`* | `Enum` | Platform of the application (e.g., ANDROID, IOS). |
+| `majorOsVersion` | `Integer` | Major OS version for the application, may be `null` to match all. |
+| `suggestedVersion`* | `String` | Suggested version of the application in SemVer 2.0 format. |
+| `requiredVersion`* | `String` | Required version of the application in SemVer 2.0 format. |
+| `messageKey` | `String` | Key for the message related to the version (e.g., for localization purposes). |
+
+#### Response 200
+
+```json
+ {
+ "id": 1,
+ "platform": "ANDROID",
+ "majorOsVersion": 12,
+ "suggestedVersion": "3.1.2",
+ "requiredVersion": "3.1.2",
+ "messageKey": "update_required"
+}
+```
+
+| Attribute | Type | Description |
+|--------------------------------------------------------------|-----------|-------------------------------------------------------------------------------|
+| `id`* | `Long` | Unique identifier of the application version. |
+| `platform`* | `Enum` | Platform of the application (e.g., ANDROID, IOS). |
+| `majorOsVersion` | `Integer` | Major OS version for the application, may be `null` to match all. |
+| `suggestedVersion` | `String` | Suggested version of the application in SemVer 2.0 format. |
+| `requiredVersion` | `String` | Required version of the application in SemVer 2.0 format. |
+| `messageKey`* | `String` | Key for the message related to the version (e.g., for localization purposes). |
+
+#### Response 400
+
+Certificate creation failed due to invalid input or missing required fields.
+
+```json
+{
+ "status": "ERROR",
+ "responseObject": {
+ "code": "ERROR_REQUEST",
+ "message": "Required fields are missing"
+ }
+}
+```
+
+#### Response 401
+
+Invalid username or password was provided while calling the service.
+
+```json
+{
+ "status": "ERROR",
+ "responseObject": {
+ "code": "ERROR_AUTHENTICATION",
+ "message": "Unauthorized"
+ }
+}
+```
+
+
+
+
+
+### Delete Application Version
+
+Remove a specific version from an application. This action is irreversible and should be used with caution to ensure
+that no critical information is lost.
+
+#### Request
+
+##### Path Parameters
+
+| Param | Type | Description |
+|--------------------------------------------------------|----------|--------------------------------------|
+| `name`* | `String` | Name of the application. |
+| `id`* | `String` | Identifier of the version to delete. |
+
+#### Response 200
+
+Indicates that the version was successfully deleted.
+
+```json
+{
+ "status": "OK"
+}
+```
+
+
+
+
+
+### List Texts
+
+Retrieve a list of all text entries managed within the system.
+
+#### Response 200
+
+```json
+{
+ "texts": [
+ {
+ "messageKey": "welcome_message",
+ "language": "en",
+ "text": "Welcome to our application!"
+ },
+ {
+ "messageKey": "farewell_message",
+ "language": "es",
+ "text": "Gracias por visitar nuestra aplicación."
+ }
+ ]
+}
+```
+
+| Attribute | Type | Description |
+|----------------------------------------------------------------------|------------|-------------------------------------|
+| `texts`* | `Object[]` | List of texts. |
+| `texts[].messageKey`* | `String` | Unique key identifier for the text. |
+| `texts[].language`* | `String` | ISO 639-1 two-letter language code. |
+| `texts[].text`* | `String` | The content of the text. |
+
+#### Response 401
+
+Invalid username or password was provided while calling the service.
+
+```json
+{
+ "status": "ERROR",
+ "responseObject": {
+ "code": "ERROR_AUTHENTICATION",
+ "message": "Unauthorized"
+ }
+}
+```
+
+
+
+
+
+### Text Detail
+
+Retrieve detailed information about a specific text identified by its key and language. This endpoint provides the
+content and the last update timestamp.
+
+#### Request
+
+##### Path Parameters
+
+| Param | Type | Description |
+|------------------------------------------------------------|----------|----------------------------------|
+| `key`* | `String` | The key identifier for the text. |
+| `language`* | `String` | The language code for the text. |
+
+#### Response 200
+
+```json
+{
+ "messageKey": "welcome_message",
+ "language": "en",
+ "text": "Welcome to our application!"
+}
+```
+
+| Attribute | Type | Description |
+|--------------------------------------------------------------|----------|-------------------------------------|
+| `messageKey`* | `String` | Unique key identifier for the text. |
+| `language`* | `String` | ISO 639-1 two-letter language code. |
+| `text`* | `String` | The content of the text. |
+
+#### Response 401
+
+Invalid username or password was provided while calling the service.
+
+```json
+{
+ "status": "ERROR",
+ "responseObject": {
+ "code": "ERROR_AUTHENTICATION",
+ "message": "Unauthorized"
+ }
+}
+```
+
+
+
+
+
+### Create Text
+
+Add a new text entry to the system.
+
+#### Request
+
+##### Request Body
+
+```json
+{
+ "messageKey": "new_message",
+ "language": "fr",
+ "text": "Bienvenue dans notre application!"
+}
+```
+
+| Attribute | Type | Description |
+|--------------------------------------------------------------|----------|-------------------------------------|
+| `messageKey`* | `String` | Unique key identifier for the text. |
+| `language`* | `String` | ISO 639-1 two-letter language code. |
+| `text`* | `String` | The content of the text. |
+
+#### Response 200
+
+```json
+{
+ "messageKey": "new_message",
+ "language": "fr",
+ "text": "Bienvenue dans notre application!"
+}
+```
+
+| Attribute | Type | Description |
+|--------------------------------------------------------------|----------|-------------------------------------|
+| `messageKey`* | `String` | Unique key identifier for the text. |
+| `language`* | `String` | ISO 639-1 two-letter language code. |
+| `text`* | `String` | The content of the text. |
+
+#### Response 400
+
+```json
+{
+ "status": "ERROR",
+ "responseObject": {
+ "code": "ERROR_REQUEST",
+ "message": "Required fields are missing"
+ }
+}
+```
+
+
+
+
+### Delete Text
+
+Remove a specific text entry from the system identified by its key and language.
+
+#### Request
+
+##### Path Parameters
+
+| Param | Type | Description |
+|------------------------------------------------------------|----------|----------------------------------|
+| `key`* | `String` | The key identifier for the text. |
+| `language`* | `String` | The language code for the text. |
+
+#### Response 200
+
+```json
+{
+ "status": "OK"
+}
+```
+
+
diff --git a/docs/Database-Structure.md b/docs/Database-Structure.md
index bf1aad44..747f4544 100644
--- a/docs/Database-Structure.md
+++ b/docs/Database-Structure.md
@@ -27,6 +27,11 @@ Contains information related to various mobile apps.
| `display_name` | `VARCHAR(255)` | Display name of the application, a human readable value, such as `Wultra Demo App`. |
| `sign_private_key` | `VARCHAR(255)` | Base64-encoded private key associated with the application. It is used for signing the data on the server side. |
| `sign_public_key` | `VARCHAR(255)` | Base64-encoded public key associated with the application. It is used by the client applications when verifying data signed on the server side. |
+
+#### Sequence
+
+Sequence `mus_mobile_app_seq` responsible for mobile app autoincrements.
+
@@ -41,6 +46,15 @@ Contains information related to pinned domains.
| `id` | `INTEGER` | Primary key for the table, automatically incremented value. |
| `app_id` | `INTEGER` | Reference to related mobile app entity. |
| `domain` | `VARCHAR(255)` | Host name of the domain, such as `mobile.wultra.com`. |
+
+#### Sequence
+
+Sequence `mus_mobile_domain_seq` responsible for mobile domain autoincrements.
+
+#### Indexes
+
+The tables are relatively small and as a result, do not require indexes. To marginally improve the lookup performance, you can create a foreign index to map the domain to mobile app.
+
@@ -57,6 +71,15 @@ Table with TLS/SSL certificate and fingerprints that should be pinned in the mob
| `fingerprint` | `VARCHAR(255)` | Value of the certificate fingerprint. |
| `expires` | `INTEGER` | Unix timestamp (seconds since Jan 1, 1970) of the certificate expiration. |
| `mobile_domain_id` | `INTEGER` | Reference to related application domain in the `mus_mobile_domain` table. |
+
+#### Sequence
+
+Sequence `mus_certificate_seq` responsible for SSL certificates and fingerprints autoincrements.
+
+#### Indexes
+
+The tables are relatively small and as a result, do not require indexes. To marginally improve the lookup performance, you can create a foreign index for mapping the fingerprint to domain.
+
@@ -86,8 +109,12 @@ Table with users authorities.
| `id` | `INTEGER` | Primary key for the table, automatically incremented value. |
| `user_id` | `INTEGER` | Foreign key column referencing users in `mus_user` table. |
| `authority` | `VARCHAR(255)` | Name of authority for the user prefixed with `ROLE_` (`ROLE_ADMIN`). |
-
+#### Indexes
+
+The tables are relatively small and as a result, do not require indexes. To marginally improve the lookup performance, you can create a foreign index to map the user authority to the user.
+
+
### Mobile Application Version
@@ -105,6 +132,11 @@ Table to force or suggest update of mobile application version.
| `suggested_version` | `VARCHAR(24)` | If the application version is lower, update is suggested. |
| `required_version` | `VARCHAR(24)` | If the application version is lower, update is required. |
| `message_key` | `VARCHAR(255)` | Together with language identifies row in `mus_localized_text` |
+
+#### Sequence
+
+Sequence `mus_mobile_app_version_seq` responsible for mobile application version autoincrements.
+
@@ -121,60 +153,3 @@ Table with localized texts.
| `language` | `VARCHAR(2)` | Primary composite key for the table. ISO 639-1 two-letter language code. |
| `text` | `TEXT` | Localized text. |
-
-
-## Sequences
-
-
-### Mobile App Sequence
-
-Sequence responsible for mobile app autoincrements.
-
-
-
-
-### Mobile App Domain Sequence
-
-Sequence responsible for mobile domain autoincrements.
-
-
-
-
-### SSL Certificate Sequence
-
-Sequence responsible for SSL certificates and fingerprints autoincrements.
-
-
-
-
-### Mobile Application Version Sequence
-
-Sequence responsible for mobile application version autoincrements.
-
-
-
-
-## Foreign Indexes
-
-The tables are relatively small and as a result, do not require indexes. To marginally improve the lookup performance, you can create the following foreign indexes.
-
-
-### Foreign Index for SSL Fingerprint Lookup
-
-Foreign index for mapping the fingerprint to domain.
-
-
-
-
-### Foreign Index for Domain Lookup
-
-Foreign index to map the domain to mobile app.
-
-
-
-
-### Foreign Index for User Authority Lookup
-
-Foreign index to map the user authority to the user.
-
-
diff --git a/docs/Readme.md b/docs/Readme.md
index b32f7b1d..ca046536 100644
--- a/docs/Readme.md
+++ b/docs/Readme.md
@@ -1,16 +1,5 @@
# Mobile Utility Server
-
-## Table of Contents
-
-- [Overview](./Readme.md)
-- [Deployment](./Deployment.md)
-- [Database Structure](./Database-Structure.md)
-- [Migration Instructions](./Migration-Instructions.md)
-- [Configuration](./Configuration.md)
-- [Public RESTful API](./Public-REST-API.md)
-
-
The Mobile Utility Server by [Wultra](https://wultra.com) is a utility server with various features required by typical mobile apps.
Currently, the feature set is relatively narrow:
@@ -24,4 +13,4 @@ Mobile Utility Server is licensed using GNU AGPLv3 license. Please consult us at
## Contact
-If you have any questions, please contact us at [hello@wultra.com](mailto:hello@wultra.com).
+If you have any questions, please contact us at [hello@wultra.com](mailto:hello@wultra.com).
\ No newline at end of file
diff --git a/docs/_Sidebar.md b/docs/_Sidebar.md
index d2611bac..9d423f0f 100644
--- a/docs/_Sidebar.md
+++ b/docs/_Sidebar.md
@@ -1,11 +1,15 @@
-[Overview](./Readme.md)
+**Introduction**
-[Deployment](./Deployment.md)
+- [Overview](./Readme.md)
+- [Configuration](./Configuration.md)
+- [Deployment](./Deployment.md)
+- [Migration Instructions](./Migration-Instructions.md)
-[Database Structure](./Database-Structure.md)
+**API Reference**
-[Migration Instructions](./Migration-Instructions.md)
+- [Admin API](./API-Admin.md)
+- [Public RESTful API](./Public-REST-API.md)
-[Configuration](./Configuration.md)
+**Reference**
-[Public RESTful API](./Public-REST-API.md)
+- [Database Structure](./Database-Structure.md)
diff --git a/pom.xml b/pom.xml
index e4990bde..a937f665 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,13 +6,13 @@
org.springframework.boot
spring-boot-starter-parent
- 3.2.3
+ 3.3.2
com.wultra.app
mobile-utility-server
- 1.7.0
+ 1.8.0
war
Mobile Utility Server
Utility server with various features suitable for mobile apps
@@ -61,13 +61,13 @@
UTF-8
- 1.7.0
- 1.9.0
+ 1.8.0
+ 1.10.0
- 1.77
- 2.3.0
- 3.10.4
+ 1.78.1
+ 2.6.0
+ 3.11.5
7.4
@@ -152,7 +152,7 @@
org.apache.maven
maven-artifact
- 3.9.6
+ 3.9.8
@@ -245,7 +245,7 @@
io.gatling
gatling-maven-plugin
- 4.8.2
+ 4.9.6
org.apache.maven.plugins
diff --git a/src/main/java/com/wultra/app/mobileutilityserver/rest/errorhandling/ExceptionHandlingControllerAdvice.java b/src/main/java/com/wultra/app/mobileutilityserver/rest/errorhandling/ExceptionHandlingControllerAdvice.java
index 9a133d0c..7b705a1e 100644
--- a/src/main/java/com/wultra/app/mobileutilityserver/rest/errorhandling/ExceptionHandlingControllerAdvice.java
+++ b/src/main/java/com/wultra/app/mobileutilityserver/rest/errorhandling/ExceptionHandlingControllerAdvice.java
@@ -17,12 +17,6 @@
*/
package com.wultra.app.mobileutilityserver.rest.errorhandling;
-import com.wultra.app.mobileutilityserver.rest.model.errors.ExtendedError;
-import com.wultra.app.mobileutilityserver.rest.model.errors.Violation;
-import io.getlime.core.rest.model.base.response.ErrorResponse;
-import jakarta.validation.ConstraintViolation;
-import jakarta.validation.ConstraintViolationException;
-import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageConversionException;
import org.springframework.security.access.AccessDeniedException;
@@ -37,6 +31,14 @@
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.servlet.resource.NoResourceFoundException;
+
+import com.wultra.app.mobileutilityserver.rest.model.errors.ExtendedError;
+import com.wultra.app.mobileutilityserver.rest.model.errors.Violation;
+import io.getlime.core.rest.model.base.response.ErrorResponse;
+import jakarta.validation.ConstraintViolation;
+import jakarta.validation.ConstraintViolationException;
+import lombok.extern.slf4j.Slf4j;
/**
* Controller advice responsible for error handling.
@@ -90,9 +92,9 @@ public class ExceptionHandlingControllerAdvice {
@ExceptionHandler(InvalidChallengeHeaderException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public @ResponseBody ErrorResponse handleInvalidChallengeHeaderException(InvalidChallengeHeaderException ex) {
- final String code = "INSUFFICIENT_CHALLENGE";
- final String message = "Request does not contain sufficiently strong challenge header, 16B is required at least.";
- logger.error("Request does not contain sufficiently strong challenge header, 16B is required at least: {}", ex.getMessage());
+ final String code = "INVALID_CHALLENGE";
+ final String message = "Request does not contain correct challenge header, a random Base64 encoded challenge with 16B - 32B raw length is required.";
+ logger.error("Request does not contain correct challenge header, a random Base64 encoded challenge with 16B - 32B raw length is required: {}", ex.getMessage());
logger.debug("Exception detail: ", ex);
return new ErrorResponse(code, message);
}
@@ -222,4 +224,18 @@ public class ExceptionHandlingControllerAdvice {
logger.debug("Exception detail: ", e);
return new io.getlime.core.rest.model.base.response.ErrorResponse("ERROR_AUTHENTICATION", e.getMessage());
}
+
+ /**
+ * Exception handler for no resource found.
+ *
+ * @param e Exception.
+ * @return Response with error details.
+ */
+ @ExceptionHandler(NoResourceFoundException.class)
+ @ResponseStatus(HttpStatus.NOT_FOUND)
+ public @ResponseBody ErrorResponse handleNoResourceFoundException(final NoResourceFoundException e) {
+ logger.warn("Error occurred when calling an API: {}", e.getMessage());
+ logger.debug("Exception detail: ", e);
+ return new ErrorResponse("ERROR_NOT_FOUND", "Resource not found.");
+ }
}
diff --git a/src/main/java/com/wultra/app/mobileutilityserver/rest/errorhandling/InvalidChallengeHeaderException.java b/src/main/java/com/wultra/app/mobileutilityserver/rest/errorhandling/InvalidChallengeHeaderException.java
index 59aca63f..8dce0151 100644
--- a/src/main/java/com/wultra/app/mobileutilityserver/rest/errorhandling/InvalidChallengeHeaderException.java
+++ b/src/main/java/com/wultra/app/mobileutilityserver/rest/errorhandling/InvalidChallengeHeaderException.java
@@ -19,7 +19,7 @@
package com.wultra.app.mobileutilityserver.rest.errorhandling;
/**
- * Exception thrown in the case challenge header is not present or is not sufficiently complex.
+ * Exception thrown in the case challenge header is not present or is not adequately complex.
*
* @author Petr Dvorak, petr@wultra.com
*/
diff --git a/src/main/java/com/wultra/app/mobileutilityserver/rest/http/HttpHeaders.java b/src/main/java/com/wultra/app/mobileutilityserver/rest/http/HttpHeaders.java
index e7783df3..8fc5b330 100644
--- a/src/main/java/com/wultra/app/mobileutilityserver/rest/http/HttpHeaders.java
+++ b/src/main/java/com/wultra/app/mobileutilityserver/rest/http/HttpHeaders.java
@@ -17,22 +17,49 @@
*/
package com.wultra.app.mobileutilityserver.rest.http;
+import java.util.Base64;
+
+import org.apache.commons.lang3.StringUtils;
+
+import lombok.extern.slf4j.Slf4j;
+
/**
* Class with constants for HTTP request / response headers.
*
* @author Petr Dvorak, petr@wultra.com
*/
+@Slf4j
public class HttpHeaders {
public static final int MIN_CHALLENGE_HEADER_LENGTH = 16;
+ public static final int MAX_CHALLENGE_HEADER_LENGTH = 32;
public static final String REQUEST_CHALLENGE = "X-Cert-Pinning-Challenge";
public static final String RESPONSE_SIGNATURE = "X-Cert-Pinning-Signature";
+ private HttpHeaders() {
+ throw new IllegalStateException("Should not be instantiated.");
+ }
+
public static boolean validChallengeHeader(String challengeHeader) {
- return challengeHeader != null
- && challengeHeader.length() >= HttpHeaders.MIN_CHALLENGE_HEADER_LENGTH
- && !challengeHeader.isBlank();
+ try {
+ if (StringUtils.isEmpty(challengeHeader)) {
+ logger.warn("Missing or blank challenge header: {}", challengeHeader);
+ return false;
+ }
+ final byte[] challengeBytes = Base64.getDecoder().decode(challengeHeader);
+ final int challengeLength = challengeBytes.length;
+ if (challengeLength >= MIN_CHALLENGE_HEADER_LENGTH && challengeLength <= MAX_CHALLENGE_HEADER_LENGTH) {
+ return true;
+ } else {
+ logger.warn("Invalid challenge size, must be between {} and {}, was: {}", MIN_CHALLENGE_HEADER_LENGTH, MAX_CHALLENGE_HEADER_LENGTH, challengeLength);
+ return false;
+ }
+ } catch (IllegalArgumentException ex) {
+ logger.warn("Invalid Base64 value received in the header: {}", challengeHeader);
+ logger.debug("Exception detail: {}", ex.getMessage(), ex);
+ return false;
+ }
}
}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 50a88e5d..d01f6d28 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -61,3 +61,6 @@ management.tracing.sampling.probability=1.0
#management.endpoints.web.exposure.include=health, prometheus
#management.endpoint.prometheus.enabled=true
#management.prometheus.metrics.export.enabled=true
+
+spring.autoconfigure.exclude=\
+ org.springframework.boot.actuate.autoconfigure.metrics.jersey.JerseyServerMetricsAutoConfiguration
diff --git a/src/main/webapp/WEB-INF/jboss-deployment-structure.xml b/src/main/webapp/WEB-INF/jboss-deployment-structure.xml
index d9918a05..7de80dc1 100644
--- a/src/main/webapp/WEB-INF/jboss-deployment-structure.xml
+++ b/src/main/webapp/WEB-INF/jboss-deployment-structure.xml
@@ -1,5 +1,5 @@
-
+
diff --git a/src/test/java/com/wultra/app/mobileutilityserver/rest/http/HttpHeadersTest.java b/src/test/java/com/wultra/app/mobileutilityserver/rest/http/HttpHeadersTest.java
new file mode 100644
index 00000000..0555ab5b
--- /dev/null
+++ b/src/test/java/com/wultra/app/mobileutilityserver/rest/http/HttpHeadersTest.java
@@ -0,0 +1,73 @@
+/*
+ * Wultra Mobile Utility Server
+ * Copyright (C) 2024 Wultra s.r.o.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package com.wultra.app.mobileutilityserver.rest.http;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.Base64;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test for {@link HttpHeaders}.
+ *
+ * @author Lubos Racansky, lubos.racansky@wultra.com
+ */
+class HttpHeadersTest {
+
+ @Test
+ void testValidChallengeHeader_success() {
+ final String input = Base64.getEncoder().encodeToString(new byte[20]);
+
+ final boolean result = HttpHeaders.validChallengeHeader(input);
+
+ assertTrue(result);
+ }
+
+ @Test
+ void testValidChallengeHeader_empty() {
+ final boolean result = HttpHeaders.validChallengeHeader("");
+
+ assertFalse(result);
+ }
+
+ @Test
+ void testValidChallengeHeader_tooShort() {
+ final String input = Base64.getEncoder().encodeToString(new byte[14]);
+
+ final boolean result = HttpHeaders.validChallengeHeader(input);
+
+ assertFalse(result);
+ }
+
+ @Test
+ void testValidChallengeHeader_tooLong() {
+ final String input = Base64.getEncoder().encodeToString(new byte[33]);
+
+ final boolean result = HttpHeaders.validChallengeHeader(input);
+
+ assertFalse(result);
+ }
+
+ @Test
+ void testValidChallengeHeader_notBase64() {
+ final boolean result = HttpHeaders.validChallengeHeader("&-+");
+
+ assertFalse(result);
+ }
+}