diff --git a/.circleci/config.yml b/.circleci/config.yml index 11d0bd779..dc4c19f5d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,10 +1,11 @@ -version: 2 +version: 2.1 jobs: ### JOBS FOR on-feature-branch-commit PIPELINE build-test-repo: # machine is needed to run Gradle build and to run docker compose tests machine: image: ubuntu-1604:201903-01 + resource_class: large environment: JAVA_HOME: /usr/lib/jvm/zulu13.28.11-ca-jdk13.0.1-linux_x64 @@ -18,7 +19,7 @@ jobs: sudo tar -zxf /tmp/zulu13.28.11-ca-jdk13.0.1-linux_x64.tar.gz -C /usr/lib/jvm - run: # checking for merge conflicts and merging locally if none exist - name: merging ${CIRCLE_BRANCH} into develop locally + name: merging into develop locally command: | git config --global user.email "${GIT_USER_EMAIL}" git config --global user.name "${GIT_USER_NAME}" @@ -114,6 +115,7 @@ jobs: build-code: machine: image: ubuntu-1604:201903-01 + resource_class: large environment: JAVA_HOME: /usr/lib/jvm/zulu13.28.11-ca-jdk13.0.1-linux_x64 @@ -193,7 +195,8 @@ jobs: at: ~/project # starts a remote docker environment to run docker commands - - setup_remote_docker + - setup_remote_docker: + docker_layer_caching: true - run: name: build Prime docker image and push image to GCR @@ -229,7 +232,8 @@ jobs: at: ~/project # starts a remote docker environment to run docker commands - - setup_remote_docker + - setup_remote_docker: + docker_layer_caching: true - run: name: build scaninfo shredder docker image and push image to GCR diff --git a/README.md b/README.md index 99f4a7f49..f7c9f7870 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -[![Kotlin version badge](https://img.shields.io/badge/kotlin-1.3.50-blue.svg)](http://kotlinlang.org/) +[![Kotlin version badge](https://img.shields.io/badge/kotlin-1.3.60-blue.svg)](http://kotlinlang.org/) [![Prime version](https://img.shields.io/github/tag/ostelco/ostelco-core.svg)](https://github.com/ostelco/ostelco-core/tags) [![GitHub license](https://img.shields.io/github/license/ostelco/ostelco-core.svg)](https://github.com/ostelco/ostelco-core/blob/master/LICENSE) diff --git a/acceptance-tests/src/main/kotlin/org/ostelco/at/common/StripePayment.kt b/acceptance-tests/src/main/kotlin/org/ostelco/at/common/StripePayment.kt index 5d6a9bd3f..2c376fb49 100644 --- a/acceptance-tests/src/main/kotlin/org/ostelco/at/common/StripePayment.kt +++ b/acceptance-tests/src/main/kotlin/org/ostelco/at/common/StripePayment.kt @@ -83,7 +83,7 @@ object StripePayment { * verify that the correspondng 'setDefaultSource' API works as * intended. */ - fun getDefaultSourceForCustomer(stripeCustomerId: String) : String { + fun getDefaultSourceForCustomer(customerId: String) : String { // https://stripe.com/docs/api/java#create_source Stripe.apiKey = System.getenv("STRIPE_API_KEY") @@ -92,7 +92,7 @@ object StripePayment { (0..MAX_TRIES).forEach { try { - return Customer.retrieve(stripeCustomerId).defaultSource + return Customer.retrieve(customerId).defaultSource } catch (e: Exception) { error = e } diff --git a/admin-endpoint/src/main/kotlin/org/ostelco/prime/admin/resources/KYCResource.kt b/admin-endpoint/src/main/kotlin/org/ostelco/prime/admin/resources/KYCResource.kt index f4babc616..f510de80e 100644 --- a/admin-endpoint/src/main/kotlin/org/ostelco/prime/admin/resources/KYCResource.kt +++ b/admin-endpoint/src/main/kotlin/org/ostelco/prime/admin/resources/KYCResource.kt @@ -1,6 +1,8 @@ package org.ostelco.prime.admin.resources import arrow.core.Either +import arrow.core.left +import arrow.core.right import com.fasterxml.jackson.module.kotlin.readValue import org.ostelco.prime.apierror.ApiError import org.ostelco.prime.apierror.ApiErrorCode @@ -77,7 +79,7 @@ class KYCResource { return null } - private fun toScanInformation(dataMap: Map): ScanInformation? { + private fun toScanInformation(dataMap: Map): Either { try { val vendorScanReference: String = dataMap[JumioScanData.JUMIO_SCAN_ID.s]!! var status: ScanStatus = toScanStatus(dataMap[JumioScanData.SCAN_STATUS.s]!!) @@ -110,29 +112,30 @@ class KYCResource { } } } - return getCountryCodeForScan(scanId) - ?.let { countryCode -> - ScanInformation( - scanId, - countryCode, - status, - ScanResult( - vendorScanReference = vendorScanReference, - verificationStatus = verificationStatus, - time = time, - type = type, - country = country, - firstName = firstName, - lastName = lastName, - dob = dob, - expiry = expiry, - rejectReason = rejectReason - ) - ) - } + val countryCode = getCountryCodeForScan(scanId) + if (countryCode == null) { + return NotFoundError("Cannot find country for scan $scanId", ApiErrorCode.FAILED_TO_GET_COUNTRY_FOR_SCAN).left() + } else { + return ScanInformation( + scanId, + countryCode, + status, + ScanResult( + vendorScanReference = vendorScanReference, + verificationStatus = verificationStatus, + time = time, + type = type, + country = country, + firstName = firstName, + lastName = lastName, + dob = dob, + expiry = expiry, + rejectReason = rejectReason + )).right() + } } catch (e: NullPointerException) { logger.error("Missing mandatory fields in scan result $dataMap", e) - return null + return BadRequestError("Missing mandatory fields in scan result", ApiErrorCode.FAILED_TO_CONVERT_SCAN_RESULT).left() } } @@ -144,16 +147,16 @@ class KYCResource { @Context httpHeaders: HttpHeaders, formData: MultivaluedMap): Response { dumpRequestInfo(request, httpHeaders, formData) - val scanInformation = toScanInformation(toRegularMap(formData)) - if (scanInformation == null) { - logger.info("Unable to convert scan information from form data") - val reqError = BadRequestError("Missing mandatory fields in scan result", ApiErrorCode.FAILED_TO_UPDATE_SCAN_RESULTS) - return Response.status(reqError.status).entity(asJson(reqError)).build() - } - logger.info("Updating scan information ${scanInformation.scanId} jumioIdScanReference ${scanInformation.scanResult?.vendorScanReference}") - return updateScanInformation(scanInformation, formData).fold( - { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { Response.status(Response.Status.OK).entity(asJson(scanInformation)) }).build() + return toScanInformation(toRegularMap(formData)) + .fold({ + logger.info("Unable to convert scan information from form data") + Response.status(it.status).entity(asJson(it)) + }, { scanInformation -> + logger.info("Updating scan information ${scanInformation.scanId} jumioIdScanReference ${scanInformation.scanResult?.vendorScanReference}") + updateScanInformation(scanInformation, formData).fold( + { Response.status(it.status).entity(asJson(it)) }, + { Response.status(Response.Status.OK).entity(asJson(scanInformation)) }) + }).build() } private fun getCountryCodeForScan(scanId: String): String? { diff --git a/appleid-auth-service/src/main/kotlin/org/ostelco/prime/auth/apple/Model.kt b/appleid-auth-service/src/main/kotlin/org/ostelco/prime/auth/apple/Model.kt index 9dab2e0bb..0b7afe9b1 100644 --- a/appleid-auth-service/src/main/kotlin/org/ostelco/prime/auth/apple/Model.kt +++ b/appleid-auth-service/src/main/kotlin/org/ostelco/prime/auth/apple/Model.kt @@ -15,13 +15,13 @@ data class TokenResponse( data class ErrorResponse(val error: Error) -enum class Error { - invalid_request, - invalid_client, - invalid_grant, - unauthorized_client, - unsupported_grant_type, - invalid_scope, +enum class Error(val cause: String) { + invalid_request("The request is malformed, normally due to a missing parameter, contains an unsupported parameter, includes multiple credentials, or uses more than one mechanism for authenticating the client."), + invalid_client("The client authentication failed."), + invalid_grant("The authorization grant or refresh token is invalid."), + unauthorized_client("The client is not authorized to use this authorization grant type."), + unsupported_grant_type("The authenticated client is not authorized to use the grant type."), + invalid_scope("The requested scope is invalid."), } data class JWKKey( @@ -33,4 +33,4 @@ data class JWKKey( val use: String ) -data class JWKSet(val keys: Collection) \ No newline at end of file +data class JWKSet(val keys: Collection) diff --git a/appleid-auth-service/src/main/kotlin/org/ostelco/prime/auth/resources/AppleIdAuthResource.kt b/appleid-auth-service/src/main/kotlin/org/ostelco/prime/auth/resources/AppleIdAuthResource.kt index 28cf4f061..adbdc4a61 100644 --- a/appleid-auth-service/src/main/kotlin/org/ostelco/prime/auth/resources/AppleIdAuthResource.kt +++ b/appleid-auth-service/src/main/kotlin/org/ostelco/prime/auth/resources/AppleIdAuthResource.kt @@ -29,7 +29,7 @@ class AppleIdAuthResource { return AppleIdAuthClient.authorize(authCode.authCode) .fold( { - logger.warn("error: {}", it.error) + logger.warn("AppleId Auth Error Response: {}, cause: {}", it.error, it.error.error.cause) Response.status(it.status).entity(asJson(it)) }, { tokenResponse -> diff --git a/build.gradle.kts b/build.gradle.kts index 9d2174c51..1d43ce22f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,7 +10,7 @@ plugins { id("project-report") id("com.github.ben-manes.versions") version "0.27.0" jacoco - kotlin("jvm") version "1.3.50" apply false + kotlin("jvm") version "1.3.60" apply false id("com.google.protobuf") version "0.8.10" apply false id("com.github.johnrengelman.shadow") version "5.2.0" apply false idea diff --git a/buildSrc/src/main/kotlin/org/ostelco/prime/gradle/Version.kt b/buildSrc/src/main/kotlin/org/ostelco/prime/gradle/Version.kt index a22521cc1..138716a32 100644 --- a/buildSrc/src/main/kotlin/org/ostelco/prime/gradle/Version.kt +++ b/buildSrc/src/main/kotlin/org/ostelco/prime/gradle/Version.kt @@ -14,10 +14,10 @@ object Version { const val firebase = "6.11.0" const val googleCloud = "1.91.3" - const val googleCloudDataStore = "1.100.0" + const val googleCloudDataStore = "1.101.0" const val googleCloudLogging = "0.116.0-alpha" - const val googleCloudPubSub = "1.100.0" - const val googleCloudStorage = "1.100.0" + const val googleCloudPubSub = "1.101.0" + const val googleCloudStorage = "1.101.0" const val gson = "2.8.6" const val grpc = "1.25.0" @@ -30,10 +30,10 @@ object Version { // Keeping it version 1.16.1 to be consistent with grpc via PubSub client lib // Keeping it version 1.16.1 to be consistent with netty via Firebase lib const val jaxb = "2.3.1" - const val jdbi3 = "3.10.1" + const val jdbi3 = "3.11.1" const val jjwt = "0.10.7" const val junit5 = "5.5.2" - const val kotlin = "1.3.50" + const val kotlin = "1.3.60" const val kotlinXCoroutines = "1.3.2" const val mockito = "3.1.0" const val mockitoKotlin = "2.2.0" @@ -46,9 +46,9 @@ object Version { const val slf4j = "1.7.29" // IMPORTANT: When Stripe SDK library version is updated, check if the Stripe API version has changed. // If so, then update API version in Stripe Web Console for callback Webhooks. - const val stripe = "15.3.0" - const val swagger = "2.0.10" - const val swaggerCodegen = "2.4.9" + const val stripe = "15.4.0" + const val swagger = "2.1.0" + const val swaggerCodegen = "2.4.10" const val testcontainers = "1.12.3" const val tink = "1.2.2" const val zxing = "3.4.0" diff --git a/docker-compose.esp.yaml b/docker-compose.esp.yaml index d854704b7..ac559034b 100644 --- a/docker-compose.esp.yaml +++ b/docker-compose.esp.yaml @@ -122,7 +122,7 @@ services: datastore-emulator: container_name: datastore-emulator - image: google/cloud-sdk:218.0.0 + image: google/cloud-sdk:272.0.0 expose: - "8081" environment: diff --git a/docker-compose.ocs.yaml b/docker-compose.ocs.yaml index c2846eedd..4507520a8 100644 --- a/docker-compose.ocs.yaml +++ b/docker-compose.ocs.yaml @@ -61,7 +61,7 @@ services: datastore-emulator: container_name: datastore-emulator - image: google/cloud-sdk:218.0.0 + image: google/cloud-sdk:272.0.0 expose: - "8081" environment: diff --git a/docker-compose.seagull.yaml b/docker-compose.seagull.yaml index 005e5d5af..9b16816ac 100644 --- a/docker-compose.seagull.yaml +++ b/docker-compose.seagull.yaml @@ -89,7 +89,7 @@ services: datastore-emulator: container_name: datastore-emulator - image: google/cloud-sdk:218.0.0 + image: google/cloud-sdk:272.0.0 expose: - "8081" environment: diff --git a/docker-compose.yaml b/docker-compose.yaml index e4dd3d44c..c4b3f3326 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -117,7 +117,7 @@ services: datastore-emulator: container_name: datastore-emulator - image: google/cloud-sdk:218.0.0 + image: google/cloud-sdk:272.0.0 expose: - "8081" environment: @@ -141,7 +141,7 @@ services: build: context: sim-administration/postgres dockerfile: Dockerfile - tmpfs: "//var/lib/postgresql/data" + tmpfs: "/var/lib/postgresql/data" ports: - "55432:5432" diff --git a/ekyc/src/main/kotlin/org/ostelco/prime/ekyc/myinfo/v3/MyInfoClient.kt b/ekyc/src/main/kotlin/org/ostelco/prime/ekyc/myinfo/v3/MyInfoClient.kt index ed810bdbd..77a73b224 100644 --- a/ekyc/src/main/kotlin/org/ostelco/prime/ekyc/myinfo/v3/MyInfoClient.kt +++ b/ekyc/src/main/kotlin/org/ostelco/prime/ekyc/myinfo/v3/MyInfoClient.kt @@ -228,11 +228,9 @@ object MyInfoClientSingleton : MyInfoKycService { } if (config.myInfoApiEnableSecurity && httpMethod == GET) { - // TODO vihang: Remove after initial testing is done. - logger.info("jwe PersonData: {}", content) + // logger.info("jwe PersonData: {}", content) val jws = decodeJweCompact(content) - // TODO vihang: Remove after initial testing is done. - logger.info("jws PersonData: {}", jws) + // logger.info("jws PersonData: {}", jws) return getPersonDataFromJwsClaims(jws) } diff --git a/go.mod b/go.mod index 8ea2428ce..63c304d32 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,16 @@ module github.com/ostelco/ostelco-core go 1.13 require ( + cloud.google.com/go/logging v1.0.0 + github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect + github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d // indirect + github.com/go-ozzo/ozzo-dbx v1.0.15 github.com/google/go-cmp v0.3.1 // indirect - github.com/pkg/errors v0.8.1 // indirect + github.com/google/uuid v1.1.1 + github.com/jmoiron/sqlx v1.2.0 + github.com/mattn/go-sqlite3 v1.11.0 + github.com/pkg/errors v0.8.1 + gopkg.in/alecthomas/kingpin.v2 v2.2.6 gotest.tools v2.2.0+incompatible + honnef.co/go/tools v0.0.1-2019.2.3 // indirect ) diff --git a/go.sum b/go.sum index 802d8ce53..2e23d27f4 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,161 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.43.0 h1:banaiRPAM8kUVYneOSkhgcDsLzEvL25FinuiSZaH/2w= +cloud.google.com/go v0.43.0/go.mod h1:BOSR3VbTLkk6FDC/TcffxP4NF/FFBGA5ku+jvKOP7pg= +cloud.google.com/go v0.48.0 h1:6ZHYIRlohUdU4LrLHbTsReY1eYy/MoZW1FsEyBuMXsk= +cloud.google.com/go/logging v1.0.0 h1:kaunpnoEh9L4hu6JUsBa8Y20LBfKnCuDhKUgdZp7oK8= +cloud.google.com/go/logging v1.0.0/go.mod h1:V1cc3ogwobYzQq5f2R7DS/GvRIrI4FKj01Gs5glwAls= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d h1:UQZhZ2O0vMHr2cI+DC1Mbh0TJxzA3RcLoMsFw+aXw7E= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ozzo/ozzo-dbx v1.0.15 h1:cahBHmsueUkRDhSM71f6TLfd1LtYN4XDce7iCblSYKA= +github.com/go-ozzo/ozzo-dbx v1.0.15/go.mod h1:48NXRgCxSU5JUSW+EA5lnokrL7ql1j6Qh2RsWeh6Fvs= +github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk= +github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA= +github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/mattn/go-sqlite3 v1.11.0 h1:LDdKkqtYlom37fkvqs8rMPFKAMe8+SgjbwZ6ex1/A/Q= +github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0 h1:C9hSCOW830chIVkdja34wa6Ky+IzWllkUinR+BtRZd4= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac h1:MQEvx39qSf8vyrx3XRaOe+j1UDIzKwkYOVObRgGPVqI= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0 h1:9sdfJOzWlkqPltHAuzT2Cp+yrBeY1KRVYgms8soxMwM= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190708153700-3bdd9d9f5532/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s= +google.golang.org/genproto v0.0.0-20190716160619-c506a9f90610 h1:Ygq9/SRJX9+dU0WCIICM8RkWvDw03lvB77hrhJnpxfU= +google.golang.org/genproto v0.0.0-20190716160619-c506a9f90610/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1 h1:j6XxA85m/6txkUCHvzlV5f+HBNl/1r5cZ2A/3IEFOO8= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0140ada96..53170a22f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Mon May 27 14:33:41 CEST 2019 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.0.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/kts-engine/build.gradle.kts b/kts-engine/build.gradle.kts index fd49560a8..bcc210486 100644 --- a/kts-engine/build.gradle.kts +++ b/kts-engine/build.gradle.kts @@ -18,6 +18,8 @@ dependencies { implementation(project(":prime-modules")) implementation("com.fasterxml.jackson.core:jackson-databind:${Version.jacksonDatabind}") + implementation("net.java.dev.jna:jna:5.5.0") + testImplementation(kotlin("test")) testImplementation(kotlin("test-junit")) diff --git a/model/src/main/kotlin/org/ostelco/prime/model/Entities.kt b/model/src/main/kotlin/org/ostelco/prime/model/Entities.kt index 6fb4d6b67..801da16c5 100644 --- a/model/src/main/kotlin/org/ostelco/prime/model/Entities.kt +++ b/model/src/main/kotlin/org/ostelco/prime/model/Entities.kt @@ -43,6 +43,7 @@ data class Customer( override val id: String = UUID.randomUUID().toString(), val nickname: String, val contactEmail: String, + val createdOn: String? = null, val analyticsId: String = UUID.randomUUID().toString(), val referralId: String = UUID.randomUUID().toString()) : HasId { @@ -227,7 +228,8 @@ data class ApplicationToken( data class Subscription( val msisdn: String, - val analyticsId: String = UUID.randomUUID().toString()) : HasId { + val analyticsId: String = UUID.randomUUID().toString(), + val lastActiveOn: String? = null) : HasId { override val id: String @JsonIgnore @@ -238,7 +240,8 @@ data class Subscription( data class Bundle( override val id: String, - val balance: Long) : HasId { + val balance: Long, + val lastConsumedOn: String? = null) : HasId { companion object } diff --git a/neo4j-store/src/main/kotlin/org/ostelco/prime/dsl/Syntax.kt b/neo4j-store/src/main/kotlin/org/ostelco/prime/dsl/Syntax.kt index 76dc11393..98c40d27b 100644 --- a/neo4j-store/src/main/kotlin/org/ostelco/prime/dsl/Syntax.kt +++ b/neo4j-store/src/main/kotlin/org/ostelco/prime/dsl/Syntax.kt @@ -231,6 +231,11 @@ infix fun Customer.Companion.withSubscription(subscription: SubscriptionContext) toId = subscription.id ) +infix fun Customer.Companion.withSimProfile(simProfile: SimProfileContext) = + RelatedToClause( + relationType = customerToSimProfileRelation, + toId = simProfile.id + ) // // ExCustomer // diff --git a/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Neo4jStore.kt b/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Neo4jStore.kt index 2ca5625e0..ee95e731d 100644 --- a/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Neo4jStore.kt +++ b/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Neo4jStore.kt @@ -6,7 +6,6 @@ import arrow.core.Either.Right import arrow.core.EitherOf import arrow.core.fix import arrow.core.flatMap -import arrow.core.getOrElse import arrow.core.left import arrow.core.leftIfNull import arrow.core.right @@ -35,6 +34,7 @@ import org.ostelco.prime.dsl.withCode import org.ostelco.prime.dsl.withId import org.ostelco.prime.dsl.withKyc import org.ostelco.prime.dsl.withMsisdn +import org.ostelco.prime.dsl.withSimProfile import org.ostelco.prime.dsl.withSku import org.ostelco.prime.dsl.withSubscription import org.ostelco.prime.dsl.writeTransaction @@ -73,6 +73,8 @@ import org.ostelco.prime.model.ScanStatus import org.ostelco.prime.model.SimEntry import org.ostelco.prime.model.SimProfileStatus import org.ostelco.prime.model.SimProfileStatus.AVAILABLE_FOR_DOWNLOAD +import org.ostelco.prime.model.SimProfileStatus.DELETED +import org.ostelco.prime.model.SimProfileStatus.DOWNLOADED import org.ostelco.prime.model.SimProfileStatus.INSTALLED import org.ostelco.prime.model.SimProfileStatus.NOT_READY import org.ostelco.prime.model.Subscription @@ -132,6 +134,8 @@ import org.ostelco.prime.storage.graph.model.SubscriptionToBundle import org.ostelco.prime.tracing.Trace import java.time.Instant import java.time.LocalDate +import java.time.ZoneOffset +import java.time.ZonedDateTime import java.util.* import java.util.stream.Collectors import javax.ws.rs.core.MultivaluedMap @@ -410,7 +414,7 @@ object Neo4jStoreSingleton : GraphStore { validateCreateCustomerParams(customer, referredBy).bind() val bundleId = UUID.randomUUID().toString() create { Identity(id = identity.id, type = identity.type) }.bind() - create { customer }.bind() + create { customer.copy(createdOn = utcTimeNow()) }.bind() fact { (Identity withId identity.id) identifies (Customer withId customer.id) using Identifies(provider = identity.provider) }.bind() create { Bundle(id = bundleId, balance = 0L) }.bind() fact { (Customer withId customer.id) hasBundle (Bundle withId bundleId) }.bind() @@ -446,9 +450,10 @@ object Neo4jStoreSingleton : GraphStore { IO { Either.monad().binding { // get customer id - val customerId = getCustomerId(identity).bind() + val customer = getCustomer(identity).bind() + val customerId = customer.id // create ex-customer with same id - create { ExCustomer(id = customerId, terminationDate = LocalDate.now().toString()) }.bind() + create { ExCustomer(id = customerId, terminationDate = LocalDate.now().toString(), createdOn = customer.createdOn) }.bind() // get all subscriptions and link them to ex-customer val subscriptions = get(Subscription subscribedBy (Customer withId customerId)).bind() for (subscription in subscriptions) { @@ -488,6 +493,26 @@ object Neo4jStoreSingleton : GraphStore { ifTrue = {}, ifFalse = { NotFoundError(type = identityEntity.name, id = identity.id) }) }.bind() + + /* If removal of payment profile fails, then the customer will be deleted + in neo4j but will still be present in payment backend. In that case the + profile must be removed from the payment backend manually. */ + paymentProcessor.removePaymentProfile(customerId) + .map { + Unit + }.flatMapLeft { + if (it is org.ostelco.prime.paymentprocessor.core.NotFoundError) { + /* Ignore. Customer has not bought products yet. */ + Unit.right() + } else { + logger.error(NOTIFY_OPS_MARKER, + "Removing corresponding payment profile when removing customer $customerId " + + "failed with error ${it.message} : ${it.description}") + NotDeletedError(type = "Payment profile for customer", + id = customerId, + error = it).left() + } + }.bind() }.fix() }.unsafeRunSync() .ifFailedThenRollback(transaction) @@ -660,7 +685,7 @@ object Neo4jStoreSingleton : GraphStore { fun subscribeToSimProfileStatusUpdates() { simManager.addSimProfileStatusUpdateListener { iccId, status -> - readTransaction { + writeTransaction { IO { Either.monad().binding { logger.info("Received status {} for iccId {}", status, iccId) @@ -669,6 +694,16 @@ object Neo4jStoreSingleton : GraphStore { logger.warn("Found {} SIM Profiles with iccId {}", simProfiles.size, iccId) } simProfiles.forEach { simProfile -> + val customers = get(Customer withSimProfile (SimProfile withId simProfile.id)).bind() + customers.forEach { customer -> + AuditLog.info(customerId = customer.id, message = "Sim Profile (iccId = $iccId) is $status") + } + when(status) { + DOWNLOADED -> update { simProfile.copy(downloadedOn = utcTimeNow()) }.bind() + INSTALLED -> update { simProfile.copy(installedOn = utcTimeNow()) }.bind() + DELETED -> update { simProfile.copy(deletedOn = utcTimeNow()) }.bind() + else -> logger.warn("Not storing timestamp for simProfile: {} for status: {}", iccId, status) + } val subscriptions = get(Subscription under (SimProfile withId simProfile.id)).bind() subscriptions.forEach { subscription -> logger.info("Notify status {} for subscription.analyticsId {}", status, subscription.analyticsId) @@ -677,6 +712,7 @@ object Neo4jStoreSingleton : GraphStore { } }.fix() }.unsafeRunSync() + // Skipping transaction rollback since it is just updating timestamps } } } @@ -720,7 +756,7 @@ object Neo4jStoreSingleton : GraphStore { val simEntry = simManager.allocateNextEsimProfile(hlr = hssNameLookup.getHssName(region.id.toLowerCase()), phoneType = profileType) .mapLeft { NotFoundError("eSIM profile", id = "Loltel") } .bind() - val simProfile = SimProfile(id = UUID.randomUUID().toString(), iccId = simEntry.iccId) + val simProfile = SimProfile(id = UUID.randomUUID().toString(), iccId = simEntry.iccId, requestedOn = utcTimeNow()) create { simProfile }.bind() fact { (Customer withId customerId) has (SimProfile withId simProfile.id) }.bind() fact { (SimProfile withId simProfile.id) isFor (Region withCode regionCode.toLowerCase()) }.bind() @@ -1103,10 +1139,10 @@ object Neo4jStoreSingleton : GraphStore { writeSuspended(""" MATCH (sn:${subscriptionEntity.name} {id: '$msisdn'})-[r:${subscriptionToBundleRelation.name}]->(bundle:${bundleEntity.name}) - SET bundle._LOCK_ = true, r._LOCK_ = true + SET bundle._LOCK_ = true, r._LOCK_ = true, sn.lastActiveOn="${utcTimeNow()}" WITH r, bundle, sn.analyticsId AS msisdnAnalyticsId, (CASE WHEN ((toInteger(bundle.balance) + toInteger(r.reservedBytes) - $usedBytes) > 0) THEN (toInteger(bundle.balance) + toInteger(r.reservedBytes) - $usedBytes) ELSE 0 END) AS tmpBalance WITH r, bundle, msisdnAnalyticsId, tmpBalance, (CASE WHEN (tmpBalance < $requestedBytes) THEN tmpBalance ELSE $requestedBytes END) as tmpGranted - SET r.reservedBytes = toString(tmpGranted), bundle.balance = toString(tmpBalance - tmpGranted) + SET r.reservedBytes = toString(tmpGranted), r.reservedOn = "${utcTimeNow()}", bundle.balance = toString(tmpBalance - tmpGranted), bundle.lastConsumedOn="${utcTimeNow()}" REMOVE r._LOCK_, bundle._LOCK_ RETURN msisdnAnalyticsId, r.reservedBytes AS granted, bundle.balance AS balance """.trimIndent(), @@ -1385,7 +1421,7 @@ object Neo4jStoreSingleton : GraphStore { val subscriptionDetailsInfo = paymentProcessor.createSubscription( planId = planStripeId, - stripeCustomerId = profileInfo.id, + customerId = profileInfo.id, trialEnd = trialEnd, taxRegionId = taxRegionId) .mapLeft { @@ -1774,7 +1810,6 @@ object Neo4jStoreSingleton : GraphStore { customerId = customer.id, data = extendedStatus ) - logger.info(NOTIFY_OPS_MARKER, "Jumio verification succeeded for ${customer.contactEmail} Info: $extendedStatus") setKycStatus( customer = customer, regionCode = updatedScanInformation.countryCode.toLowerCase(), @@ -1793,7 +1828,9 @@ object Neo4jStoreSingleton : GraphStore { data = extendedStatus ) } - logger.info(NOTIFY_OPS_MARKER, "Jumio verification failed for ${customer.contactEmail} Info: $extendedStatus") + if(updatedScanInformation.scanResult?.verificationStatus != "NO_ID_UPLOADED") { + logger.info(NOTIFY_OPS_MARKER, "Jumio verification failed for ${customer.contactEmail} Info: $extendedStatus") + } setKycStatus( customer = customer, regionCode = updatedScanInformation.countryCode.toLowerCase(), @@ -2017,14 +2054,28 @@ object Neo4jStoreSingleton : GraphStore { return IO { Either.monad().binding { + // get combinations of KYC needed for this region to be Approved val approvedKycTypeSetList = getApprovedKycTypeSetList(regionCode) + // fetch existing values from DB val existingCustomerRegion = customerRegionRelationStore.get( fromId = customer.id, toId = regionCode, transaction = transaction) - .getOrElse { CustomerRegion(status = PENDING, kycStatusMap = getKycStatusMapForRegion(regionCode)) } + .flatMapLeft { storeError -> + if(storeError is NotFoundError && storeError.type == customerRegionRelation.name) { + // default value if absent in DB + CustomerRegion( + status = PENDING, + kycStatusMap = getKycStatusMapForRegion(regionCode), + initiatedOn = utcTimeNow() + ).right() + } else { + storeError.left() + } + }.bind() + // using existing and received KYC status, compute new KYC status val existingKycStatusMap = existingCustomerRegion.kycStatusMap val existingKycStatus = existingKycStatusMap[kycType] val newKycStatus = when (existingKycStatus) { @@ -2034,6 +2085,7 @@ object Neo4jStoreSingleton : GraphStore { else -> kycStatus } + // if new status is different from existing status if (existingKycStatus != newKycStatus) { if (kycStatus == newKycStatus) { AuditLog.info(customerId = customer.id, message = "Setting $kycType status from $existingKycStatus to $newKycStatus") @@ -2055,29 +2107,42 @@ object Neo4jStoreSingleton : GraphStore { AuditLog.info(customerId = customer.id, message = "Ignoring setting $kycType status to $kycStatus since it is already $existingKycStatus") } + // update KYC status map with new value. This map will then be stored in DB. val newKycStatusMap = existingKycStatusMap.copy(key = kycType, value = newKycStatus) - val approved = approvedKycTypeSetList.any { kycTypeSet -> + // check if Region is Approved. + val isRegionApproved = approvedKycTypeSetList.any { kycTypeSet -> + // Region is approved if the set of Approved KYCs is a superset of any one of the set configured in the list - approvedKycTypeSetList. newKycStatusMap.filter { it.value == KycStatus.APPROVED }.keys.containsAll(kycTypeSet) } - val approvedNow = existingCustomerRegion.status == PENDING && approved + // if the Region status is Approved, but the existing status was not Approved, then it has been approved now. + val isRegionApprovedNow = existingCustomerRegion.status != APPROVED && isRegionApproved - val newStatus = if (approved) { + // Save Region status as APPROVED, if it is approved. Do not change Region status otherwise. + val newRegionStatus = if (isRegionApproved) { APPROVED } else { existingCustomerRegion.status } - if (approvedNow) { + // timestamp for region approval + val regionApprovedOn = if (isRegionApprovedNow) { + AuditLog.info(customerId = customer.id, message = "Approved for region - $regionCode") + onRegionApprovedAction.apply( customer = customer, regionCode = regionCode, transaction = PrimeTransaction(transaction) ).bind() + + utcTimeNow() + } else { + existingCustomerRegion.approvedOn } + // Save KYC expiry date if it is not null. val newKycExpiryDateMap = kycExpiryDate ?.let { existingCustomerRegion.kycExpiryDateMap.copy(key = kycType, value = it) } ?: existingCustomerRegion.kycExpiryDateMap @@ -2086,9 +2151,11 @@ object Neo4jStoreSingleton : GraphStore { .createOrUpdate( fromId = customer.id, relation = CustomerRegion( - status = newStatus, + status = newRegionStatus, kycStatusMap = newKycStatusMap, - kycExpiryDateMap = newKycExpiryDateMap + kycExpiryDateMap = newKycExpiryDateMap, + initiatedOn = existingCustomerRegion.initiatedOn, + approvedOn = regionApprovedOn ), toId = regionCode, transaction = transaction) @@ -2161,13 +2228,14 @@ object Neo4jStoreSingleton : GraphStore { } } - private inline fun EitherOf.flatMapLeft(f: (A) -> Either): Either = + private inline fun EitherOf.flatMapLeft(f: (LEFT) -> Either): Either = fix().let { when (it) { is Right -> it is Left -> f(it.a) } } + // // Balance (Customer - Subscription - Bundle) // @@ -2818,4 +2886,6 @@ fun Map.copy(key: K, value: V): Map { val mutableMap = this.toMutableMap() mutableMap[key] = value return mutableMap.toMap() -} \ No newline at end of file +} + +fun utcTimeNow() = ZonedDateTime.now(ZoneOffset.UTC).toString() \ No newline at end of file diff --git a/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/model/Model.kt b/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/model/Model.kt index 2f5f9cc87..2d352bb6e 100644 --- a/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/model/Model.kt +++ b/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/model/Model.kt @@ -14,7 +14,10 @@ data class Identity( data class Identifies(val provider: String) -data class SubscriptionToBundle(val reservedBytes: Long = 0) +data class SubscriptionToBundle( + val reservedBytes: Long = 0, + val reservedOn: String? = null +) data class PlanSubscription( val subscriptionId: String, @@ -24,12 +27,18 @@ data class PlanSubscription( data class CustomerRegion( val status: CustomerRegionStatus, val kycStatusMap: Map = emptyMap(), - val kycExpiryDateMap: Map = emptyMap()) + val kycExpiryDateMap: Map = emptyMap(), + val initiatedOn: String? = null, + val approvedOn: String? = null) data class SimProfile( override val id: String, val iccId: String, - val alias: String = "") : HasId { + val alias: String = "", + val requestedOn: String? = null, + val downloadedOn: String? = null, + val installedOn: String? = null, + val deletedOn: String? = null) : HasId { companion object } @@ -42,6 +51,7 @@ data class Offer(override val id: String) : HasId data class ExCustomer( override val id:String, + val createdOn: String? = null, val terminationDate: String) : HasId { companion object diff --git a/neo4j-store/src/test/kotlin/org/ostelco/prime/storage/graph/Neo4jStoreTest.kt b/neo4j-store/src/test/kotlin/org/ostelco/prime/storage/graph/Neo4jStoreTest.kt index c360822f8..37f352b5f 100644 --- a/neo4j-store/src/test/kotlin/org/ostelco/prime/storage/graph/Neo4jStoreTest.kt +++ b/neo4j-store/src/test/kotlin/org/ostelco/prime/storage/graph/Neo4jStoreTest.kt @@ -146,7 +146,7 @@ class Neo4jStoreTest { Neo4jStoreSingleton.getCustomer(IDENTITY).bimap( { fail(it.message) }, - { assertEquals(CUSTOMER, it) }) + { assertEquals(CUSTOMER.copy(createdOn = it.createdOn), it) }) // TODO vihang: fix argument captor for neo4j-store tests // val bundleArgCaptor: ArgumentCaptor = ArgumentCaptor.forClass(Bundle::class.java) @@ -169,7 +169,7 @@ class Neo4jStoreTest { val identity: Identity = list.first() Neo4jStoreSingleton.getCustomer(identity).bimap( { fail(it.message) }, - { assertEquals(CUSTOMER, it) })}) + { assertEquals(CUSTOMER.copy(createdOn = it.createdOn), it) })}) } @Test @@ -1165,6 +1165,10 @@ class Neo4jStoreTest { @Test fun `test delete customer`() { + // mock + `when`(mockPaymentProcessor.removePaymentProfile(customerId = CUSTOMER.id)) + .thenReturn(ProfileInfo(EMAIL).right()) + // setup job { create { Region("sg", "Singapore") } @@ -1205,7 +1209,7 @@ class Neo4jStoreTest { val exCustomer = get(ExCustomer withId CUSTOMER.id).bind() assertEquals( - expected = ExCustomer(id = CUSTOMER.id, terminationDate = "%d-%02d-%02d".format(LocalDate.now().year, LocalDate.now().monthValue, LocalDate.now().dayOfMonth)), + expected = ExCustomer(id = CUSTOMER.id, createdOn = exCustomer.createdOn, terminationDate = "%d-%02d-%02d".format(LocalDate.now().year, LocalDate.now().monthValue, LocalDate.now().dayOfMonth)), actual = exCustomer, message = "ExCustomer does not match") diff --git a/payment-processor/src/integration-test/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessorTest.kt b/payment-processor/src/integration-test/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessorTest.kt index 24ea53c0a..c227578a6 100644 --- a/payment-processor/src/integration-test/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessorTest.kt +++ b/payment-processor/src/integration-test/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessorTest.kt @@ -24,7 +24,7 @@ class StripePaymentProcessorTest { private val testCustomer = UUID.randomUUID().toString() private val emailTestCustomer = "test@internet.org" - private var stripeCustomerId = "" + private var customerId = "" private fun createPaymentTokenId(): String { @@ -64,7 +64,7 @@ class StripePaymentProcessorTest { val resultAdd = paymentProcessor.createPaymentProfile(customerId = testCustomer, email = emailTestCustomer) resultAdd.isRight() - stripeCustomerId = resultAdd.fold({ "" }, { it.id }) + customerId = resultAdd.fold({ "" }, { it.id }) } @Before @@ -75,7 +75,7 @@ class StripePaymentProcessorTest { @After fun cleanUp() { - val resultDelete = paymentProcessor.deletePaymentProfile(stripeCustomerId) + val resultDelete = paymentProcessor.removePaymentProfile(customerId) assertNotFailure(resultDelete) } @@ -118,7 +118,7 @@ class StripePaymentProcessorTest { @Test fun unknownCustomerGetSavedSources() { - val result = paymentProcessor.getSavedSources(stripeCustomerId = "unknown") + val result = paymentProcessor.getSavedSources(customerId = "unknown") assertFailure(result) } @@ -127,7 +127,7 @@ class StripePaymentProcessorTest { fun getPaymentProfile() { val result = paymentProcessor.getPaymentProfile(testCustomer) assertNotFailure(result) - assertEquals(stripeCustomerId, result.fold({ "" }, { it.id })) + assertEquals(customerId, result.fold({ "" }, { it.id })) } @Test @@ -140,14 +140,14 @@ class StripePaymentProcessorTest { fun ensureSourcesSorted() { run { - paymentProcessor.addSource(stripeCustomerId, createPaymentTokenId()) + paymentProcessor.addSource(customerId, createPaymentTokenId()) // Ensure that not all sources falls within the same second. Thread.sleep(1_001) - paymentProcessor.addSource(stripeCustomerId, createPaymentSourceId()) + paymentProcessor.addSource(customerId, createPaymentSourceId()) } // Should be in descending sorted order by the "created" timestamp. - val sources = paymentProcessor.getSavedSources(stripeCustomerId) + val sources = paymentProcessor.getSavedSources(customerId) val createdTimestamps = sources.getOrElse { fail("The 'created' field is missing from the list of sources: ${sources}") @@ -163,12 +163,12 @@ class StripePaymentProcessorTest { fun addAndRemoveMultipleSources() { val sources = listOf( - paymentProcessor.addSource(stripeCustomerId, createPaymentTokenId()), - paymentProcessor.addSource(stripeCustomerId, createPaymentSourceId()) + paymentProcessor.addSource(customerId, createPaymentTokenId()), + paymentProcessor.addSource(customerId, createPaymentSourceId()) ) val sourcesRemoved = sources.map { - paymentProcessor.removeSource(stripeCustomerId, it.getOrElse { + paymentProcessor.removeSource(customerId, it.getOrElse { fail("Failed to remove source ${it}") }.id) } @@ -196,28 +196,28 @@ class StripePaymentProcessorTest { @Test fun addSourceToCustomerAndRemove() { - val resultAddSource = paymentProcessor.addSource(stripeCustomerId, createPaymentTokenId()) - val resultStoredSources = paymentProcessor.getSavedSources(stripeCustomerId) + val resultAddSource = paymentProcessor.addSource(customerId, createPaymentTokenId()) + val resultStoredSources = paymentProcessor.getSavedSources(customerId) checkthatStoredResourcesMatchAddedResources(resultAddSource, resultStoredSources) - val resultDeleteSource = paymentProcessor.removeSource(stripeCustomerId, right(resultAddSource).id) + val resultDeleteSource = paymentProcessor.removeSource(customerId, right(resultAddSource).id) assertNotFailure(resultDeleteSource) } @Test fun addSourceToCustomerTwice() { - val resultAddSource = paymentProcessor.addSource(stripeCustomerId, createPaymentTokenId()) + val resultAddSource = paymentProcessor.addSource(customerId, createPaymentTokenId()) - val resultStoredSources = paymentProcessor.getSavedSources(stripeCustomerId) + val resultStoredSources = paymentProcessor.getSavedSources(customerId) checkthatStoredResourcesMatchAddedResources(resultAddSource, resultStoredSources) - val resultAddSecondSource = paymentProcessor.addSource(stripeCustomerId, right(resultStoredSources).first().id) + val resultAddSecondSource = paymentProcessor.addSource(customerId, right(resultStoredSources).first().id) assertFailure(resultAddSecondSource) - val resultDeleteSource = paymentProcessor.removeSource(stripeCustomerId, right(resultAddSource).id) + val resultDeleteSource = paymentProcessor.removeSource(customerId, right(resultAddSource).id) assertNotFailure(resultDeleteSource) } @@ -227,55 +227,55 @@ class StripePaymentProcessorTest { @Test fun addDefaultSourceAndRemove() { - val resultAddSource = paymentProcessor.addSource(stripeCustomerId, createPaymentTokenId()) + val resultAddSource = paymentProcessor.addSource(customerId, createPaymentTokenId()) assertNotFailure(resultAddSource) - val resultAddDefault = paymentProcessor.setDefaultSource(stripeCustomerId, right(resultAddSource).id) + val resultAddDefault = paymentProcessor.setDefaultSource(customerId, right(resultAddSource).id) assertNotFailure(resultAddDefault) - val resultGetDefault = paymentProcessor.getDefaultSource(stripeCustomerId) + val resultGetDefault = paymentProcessor.getDefaultSource(customerId) assertNotFailure(resultGetDefault) assertEquals(resultAddDefault.fold({ "" }, { it.id }), right(resultGetDefault).id) - val resultRemoveDefault = paymentProcessor.removeSource(stripeCustomerId, right(resultAddDefault).id) + val resultRemoveDefault = paymentProcessor.removeSource(customerId, right(resultAddDefault).id) assertNotFailure(resultRemoveDefault) } @Test fun createAuthorizeChargeAndRefund() { - val resultAddSource = paymentProcessor.addSource(stripeCustomerId, createPaymentTokenId()) + val resultAddSource = paymentProcessor.addSource(customerId, createPaymentTokenId()) assertNotFailure(resultAddSource) val amount = 1000 val currency = "NOK" - val resultAuthorizeCharge = paymentProcessor.authorizeCharge(stripeCustomerId, right(resultAddSource).id, amount, currency) + val resultAuthorizeCharge = paymentProcessor.authorizeCharge(customerId, right(resultAddSource).id, amount, currency) assertNotFailure(resultAuthorizeCharge) val resultRefundCharge = paymentProcessor.refundCharge(right(resultAuthorizeCharge), amount) assertNotFailure(resultRefundCharge) - val resultRemoveSource = paymentProcessor.removeSource(stripeCustomerId, right(resultAddSource).id) + val resultRemoveSource = paymentProcessor.removeSource(customerId, right(resultAddSource).id) assertNotFailure(resultRemoveSource) } @Test fun createAuthorizeChargeAndRefundWithZeroAmount() { - val resultAddSource = paymentProcessor.addSource(stripeCustomerId, createPaymentTokenId()) + val resultAddSource = paymentProcessor.addSource(customerId, createPaymentTokenId()) assertNotFailure(resultAddSource) val amount = 0 val currency = "NOK" - val resultAuthorizeCharge = paymentProcessor.authorizeCharge(stripeCustomerId, right(resultAddSource).id, amount, currency) + val resultAuthorizeCharge = paymentProcessor.authorizeCharge(customerId, right(resultAddSource).id, amount, currency) assertNotFailure(resultAuthorizeCharge) val resultRefundCharge = paymentProcessor.refundCharge(right(resultAuthorizeCharge), amount) assertNotFailure(resultRefundCharge) assertEquals(resultAuthorizeCharge.fold({ "" }, { it }), right(resultRefundCharge)) - val resultRemoveSource = paymentProcessor.removeSource(stripeCustomerId, right(resultAddSource).id) + val resultRemoveSource = paymentProcessor.removeSource(customerId, right(resultAddSource).id) assertNotFailure(resultRemoveSource) } @@ -291,7 +291,7 @@ class StripePaymentProcessorTest { @Test fun subscribeAndUnsubscribePlan() { - val resultAddSource = paymentProcessor.addSource(stripeCustomerId, createPaymentTokenId()) + val resultAddSource = paymentProcessor.addSource(customerId, createPaymentTokenId()) assertNotFailure(resultAddSource) val resultCreateProduct = paymentProcessor.createProduct("TestSku") @@ -300,7 +300,7 @@ class StripePaymentProcessorTest { val resultCreatePlan = paymentProcessor.createPlan(right(resultCreateProduct).id, 1000, "NOK", PaymentProcessor.Interval.MONTH) assertNotFailure(resultCreatePlan) - val resultSubscribePlan = paymentProcessor.createSubscription(right(resultCreatePlan).id, stripeCustomerId) + val resultSubscribePlan = paymentProcessor.createSubscription(right(resultCreatePlan).id, customerId) assertNotFailure(resultSubscribePlan) val resultUnsubscribePlan = paymentProcessor.cancelSubscription(right(resultSubscribePlan).id, false) @@ -315,19 +315,19 @@ class StripePaymentProcessorTest { assertNotFailure(resultRemoveProduct) assertEquals(resultCreateProduct.fold({ "" }, { it.id }), right(resultRemoveProduct).id) - val resultDeleteSource = paymentProcessor.removeSource(stripeCustomerId, right(resultAddSource).id) + val resultDeleteSource = paymentProcessor.removeSource(customerId, right(resultAddSource).id) assertNotFailure(resultDeleteSource) } @Test fun createAndDeleteInvoiceItem() { - val resultAddSource = paymentProcessor.addSource(stripeCustomerId, createPaymentTokenId()) + val resultAddSource = paymentProcessor.addSource(customerId, createPaymentTokenId()) assertNotFailure(resultAddSource) val amount = 5000 val currency = "SGD" - val addedInvoiceItem = paymentProcessor.createInvoiceItem(stripeCustomerId, amount, currency, "SGD") + val addedInvoiceItem = paymentProcessor.createInvoiceItem(customerId, amount, currency, "SGD") assertNotFailure(addedInvoiceItem) val removedInvoiceItem = paymentProcessor.removeInvoiceItem(right(addedInvoiceItem).id) @@ -336,13 +336,13 @@ class StripePaymentProcessorTest { @Test fun createAndDeleteInvoiceWithTaxes() { - val resultAddSource = paymentProcessor.addSource(stripeCustomerId, createPaymentTokenId()) + val resultAddSource = paymentProcessor.addSource(customerId, createPaymentTokenId()) assertNotFailure(resultAddSource) val amount = 5000 val currency = "SGD" - val addedInvoiceItem = paymentProcessor.createInvoiceItem(stripeCustomerId, amount, currency, "SGD") + val addedInvoiceItem = paymentProcessor.createInvoiceItem(customerId, amount, currency, "SGD") assertNotFailure(addedInvoiceItem) val taxRegionId = "sg" @@ -350,7 +350,7 @@ class StripePaymentProcessorTest { val taxRates = paymentProcessor.getTaxRatesForTaxRegionId(taxRegionId) assertNotFailure(taxRates) - val addedInvoice = paymentProcessor.createInvoice(stripeCustomerId, right(taxRates)) + val addedInvoice = paymentProcessor.createInvoice(customerId, right(taxRates)) assertNotFailure(addedInvoice) val payedInvoice = paymentProcessor.payInvoice(right(addedInvoice).id) diff --git a/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessor.kt b/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessor.kt index d4d2480b0..3f2f5d2ab 100644 --- a/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessor.kt +++ b/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessor.kt @@ -48,9 +48,9 @@ class StripePaymentProcessor : PaymentProcessor { private val logger by getLogger() - override fun getSavedSources(stripeCustomerId: String): Either> = - either("Failed to retrieve sources for customer $stripeCustomerId") { - val customer = Customer.retrieve(stripeCustomerId) + override fun getSavedSources(customerId: String): Either> = + either("Failed to retrieve sources for customer $customerId") { + val customer = Customer.retrieve(customerId) val sources: List = customer.sources.data.map { val details = getAccountDetails(it) SourceDetailsInfo(it.id, getAccountType(details), details) @@ -148,17 +148,28 @@ class StripePaymentProcessor : PaymentProcessor { } override fun getPaymentProfile(customerId: String): Either = + getCustomer(customerId) + .flatMap { customer -> + ProfileInfo(customer.id) + .right() + } + + /* Fetch customer from Stripe with result checks. */ + private fun getCustomer(customerId: String): Either = Try { Customer.retrieve(customerId) }.fold( ifSuccess = { customer -> when { - customer.deleted == true -> Either.left(NotFoundError("Payment profile for user $customerId was previously deleted")) - else -> Either.right(ProfileInfo(customer.id)) + customer.deleted == true -> NotFoundError("Payment profile for user $customerId was previously deleted") + .left() + else -> customer + .right() } }, ifFailure = { - Either.left(NotFoundError("Could not find a payment profile for user $customerId")) + NotFoundError("Could not find a payment profile for customer $customerId") + .left() } ) @@ -196,38 +207,39 @@ class StripePaymentProcessor : PaymentProcessor { ProductInfo(product.delete().id) } - override fun addSource(stripeCustomerId: String, stripeSourceId: String): Either = - either("Failed to add source $stripeSourceId to customer $stripeCustomerId") { - val customer = Customer.retrieve(stripeCustomerId) - val sourceParams = mapOf("source" to stripeSourceId, + override fun addSource(customerId: String, sourceId: String): Either = + either("Failed to add source $sourceId to customer $customerId") { + val customer = Customer.retrieve(customerId) + val sourceParams = mapOf("source" to sourceId, "metadata" to mapOf("created" to ofEpochMilliToSecond(Instant.now().toEpochMilli()))) SourceInfo(customer.sources.create(sourceParams).id) } - override fun setDefaultSource(stripeCustomerId: String, sourceId: String): Either = - either("Failed to set default source $sourceId for customer $stripeCustomerId") { - val customer = Customer.retrieve(stripeCustomerId) + override fun setDefaultSource(customerId: String, sourceId: String): Either = + either("Failed to set default source $sourceId for customer $customerId") { + val customer = Customer.retrieve(customerId) val updateParams = mapOf("default_source" to sourceId) val customerUpdated = customer.update(updateParams) SourceInfo(customerUpdated.defaultSource) } - override fun getDefaultSource(stripeCustomerId: String): Either = - either("Failed to get default source for customer $stripeCustomerId") { - SourceInfo(Customer.retrieve(stripeCustomerId).defaultSource) + override fun getDefaultSource(customerId: String): Either = + either("Failed to get default source for customer $customerId") { + SourceInfo(Customer.retrieve(customerId).defaultSource) } - override fun deletePaymentProfile(stripeCustomerId: String): Either = - either("Failed to delete customer $stripeCustomerId") { - val customer = Customer.retrieve(stripeCustomerId) - ProfileInfo(customer.delete().id) - } + override fun removePaymentProfile(customerId: String): Either = + getCustomer(customerId) + .flatMap { customer -> + ProfileInfo(customer.delete().id) + .right() + } /* The 'expand' part will cause an immediate attempt at charging for the subscription when creating it. For interpreting the result see: https://stripe.com/docs/billing/subscriptions/payment#signup-3b */ - override fun createSubscription(planId: String, stripeCustomerId: String, trialEnd: Long, taxRegionId: String?): Either = - either("Failed to subscribe customer $stripeCustomerId to plan $planId") { + override fun createSubscription(planId: String, customerId: String, trialEnd: Long, taxRegionId: String?): Either = + either("Failed to subscribe customer $customerId to plan $planId") { val item = mapOf("plan" to planId) val taxRates = getTaxRatesForTaxRegionId(taxRegionId) .fold( @@ -235,7 +247,7 @@ class StripePaymentProcessor : PaymentProcessor { { it } ) val subscriptionParams = mapOf( - "customer" to stripeCustomerId, + "customer" to customerId, "items" to mapOf("0" to item), *(if (trialEnd > Instant.now().epochSecond) arrayOf("trial_end" to trialEnd.toString()) @@ -294,7 +306,8 @@ class StripePaymentProcessor : PaymentProcessor { override fun authorizeCharge(customerId: String, sourceId: String?, amount: Int, currency: String): Either { val errorMessage = "Failed to authorize the charge for customerId $customerId sourceId $sourceId amount $amount currency $currency" return when (amount) { - 0 -> Either.right("ZERO_CHARGE_${UUID.randomUUID()}") + 0 -> "ZERO_CHARGE_${UUID.randomUUID()}" + .right() else -> either(errorMessage) { val chargeParams = mutableMapOf( "amount" to amount, @@ -319,7 +332,7 @@ class StripePaymentProcessor : PaymentProcessor { override fun captureCharge(chargeId: String, customerId: String, amount: Int, currency: String): Either { val errorMessage = "Failed to capture charge for customerId $customerId chargeId $chargeId" return when (amount) { - 0 -> Either.right(chargeId) + 0 -> chargeId.right() else -> either(errorMessage) { Charge.retrieve(chargeId) }.flatMap { charge: Charge -> @@ -332,10 +345,10 @@ class StripePaymentProcessor : PaymentProcessor { }.flatMap { charge -> try { charge.capture() - Either.right(charge.id) + charge.id.right() } catch (e: Exception) { logger.warn(errorMessage, e) - Either.left(BadGatewayError(errorMessage)) + BadGatewayError(errorMessage).left() } } } @@ -349,7 +362,7 @@ class StripePaymentProcessor : PaymentProcessor { override fun refundCharge(chargeId: String, amount: Int): Either = when (amount) { - 0 -> Either.right(chargeId) + 0 -> chargeId.right() else -> either("Failed to refund charge $chargeId") { val refundParams = mapOf( "charge" to chargeId, @@ -358,14 +371,15 @@ class StripePaymentProcessor : PaymentProcessor { } } - override fun removeSource(stripeCustomerId: String, sourceId: String): Either = - either("Failed to remove source $sourceId for stripeCustomerId $stripeCustomerId") { - val accountInfo = Customer.retrieve(stripeCustomerId).sources.retrieve(sourceId) + override fun removeSource(customerId: String, sourceId: String): Either = + either("Failed to remove source $sourceId for customerId $customerId") { + val accountInfo = Customer.retrieve(customerId).sources.retrieve(sourceId) when (accountInfo) { is Card -> accountInfo.delete() is Source -> accountInfo.detach() else -> - Either.left(BadGatewayError("Attempt to remove unsupported account-type $accountInfo")) + BadGatewayError("Attempt to remove unsupported account-type $accountInfo") + .left() } SourceInfo(sourceId) } diff --git a/prime-customer-api/build.gradle.kts b/prime-customer-api/build.gradle.kts index e5751b0b7..80d0e25c5 100644 --- a/prime-customer-api/build.gradle.kts +++ b/prime-customer-api/build.gradle.kts @@ -30,7 +30,7 @@ dependencies { implementation("javax.annotation:javax.annotation-api:${Version.javaxAnnotation}") // taken from build/swagger-code-java-client/build.gradle - implementation("io.swagger:swagger-annotations:1.5.24") + implementation("io.swagger:swagger-annotations:1.6.0") implementation("com.google.code.gson:gson:${Version.gson}") implementation("com.squareup.okhttp:okhttp:2.7.5") implementation("com.squareup.okhttp:logging-interceptor:2.7.5") diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/apierror/ApiError.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/apierror/ApiError.kt index 7bd14b9ec..eebe0ac44 100644 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/apierror/ApiError.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/apierror/ApiError.kt @@ -32,7 +32,7 @@ object ApiErrorMapper { val logger by getLogger() fun mapPaymentErrorToApiError(description: String, errorCode: ApiErrorCode, paymentError: PaymentError) : ApiError { - logger.error("description: $description, errorCode: $errorCode, paymentError: ${asJson(paymentError)}") + logger.error("{}: {}, paymentError: {}", errorCode, description, asJson(paymentError)) return when(paymentError) { is org.ostelco.prime.paymentprocessor.core.PlanAlredyPurchasedError -> ForbiddenError(description, errorCode, paymentError) is org.ostelco.prime.paymentprocessor.core.ForbiddenError -> ForbiddenError(description, errorCode, paymentError) @@ -44,7 +44,7 @@ object ApiErrorMapper { } fun mapStorageErrorToApiError(description: String, errorCode: ApiErrorCode, storeError: StoreError) : ApiError { - logger.error("description: $description, errorCode: $errorCode, storeError: ${asJson(storeError)}") + logger.error("{}: {}, storeError: {}", errorCode, description, asJson(storeError)) return when(storeError) { is org.ostelco.prime.storage.NotFoundError -> NotFoundError(description, errorCode, storeError) is org.ostelco.prime.storage.AlreadyExistsError -> ForbiddenError(description, errorCode, storeError) @@ -61,7 +61,7 @@ object ApiErrorMapper { } fun mapSimManagerErrorToApiError(description: String, errorCode: ApiErrorCode, simManagerError: SimManagerError) : ApiError { - logger.error("description: $description, errorCode: $errorCode, simManagerError: ${asJson(simManagerError)}") + logger.error("{}: {}, simManagerError: {}", errorCode, description, asJson(simManagerError)) return when (simManagerError) { is org.ostelco.prime.simmanager.NotFoundError -> NotFoundError(description, errorCode, simManagerError) is org.ostelco.prime.simmanager.NotUpdatedError -> BadRequestError(description, errorCode, simManagerError) diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/apierror/ApiErrorCodes.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/apierror/ApiErrorCodes.kt index 4c0f69461..42835ebb1 100644 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/apierror/ApiErrorCodes.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/apierror/ApiErrorCodes.kt @@ -91,6 +91,8 @@ enum class ApiErrorCode { // Jumio FAILED_TO_CREATE_SCANID, FAILED_TO_FETCH_SCAN_INFORMATION, + FAILED_TO_GET_COUNTRY_FOR_SCAN, + FAILED_TO_CONVERT_SCAN_RESULT, FAILED_TO_UPDATE_SCAN_RESULTS, FAILED_TO_FETCH_SUBSCRIBER_STATE, diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/PaymentProcessor.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/PaymentProcessor.kt index bcf5ec186..2756ee057 100644 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/PaymentProcessor.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/PaymentProcessor.kt @@ -26,11 +26,11 @@ interface PaymentProcessor { } /** - * @param stripeCustomerId Stripe customer id - * @param stripeSourceId Stripe source id + * @param customerId Stripe customer id + * @param sourceId Stripe source id * @return Stripe sourceId if created */ - fun addSource(stripeCustomerId: String, stripeSourceId: String): Either + fun addSource(customerId: String, sourceId: String): Either /** * @param customerId: Prime unique identifier for customer @@ -40,10 +40,10 @@ interface PaymentProcessor { fun createPaymentProfile(customerId: String, email: String): Either /** - * @param stripeCustomerId Stripe customer id - * @return Stripe customerId if deleted + * @param customerId Stripe customer id + * @return Stripe customerId if removed */ - fun deletePaymentProfile(stripeCustomerId: String): Either + fun removePaymentProfile(customerId: String): Either /** * @param customerId: user email (Prime unique identifier for customer) @@ -69,12 +69,12 @@ interface PaymentProcessor { /** * @param Stripe Plan Id - * @param stripeCustomerId Stripe Customer Id + * @param customerId Stripe Customer Id * @param trielEnd Epoch timestamp for when the trial period ends * @param taxRegion An identifier representing the taxes to be applied to a region * @return Stripe SubscriptionId if subscribed */ - fun createSubscription(planId: String, stripeCustomerId: String, trialEnd: Long = 0L, taxRegionId: String? = null): Either + fun createSubscription(planId: String, customerId: String, trialEnd: Long = 0L, taxRegionId: String? = null): Either /** * @param Stripe Subscription Id @@ -96,23 +96,23 @@ interface PaymentProcessor { fun removeProduct(productId: String): Either /** - * @param stripeCustomerId Stripe customer id + * @param customerId Stripe customer id * @return List of Stripe sourceId */ - fun getSavedSources(stripeCustomerId: String): Either> + fun getSavedSources(customerId: String): Either> /** - * @param stripeCustomerId Stripe customer id + * @param customerId Stripe customer id * @return Stripe default sourceId */ - fun getDefaultSource(stripeCustomerId: String): Either + fun getDefaultSource(customerId: String): Either /** - * @param stripeCustomerId Stripe customer id + * @param customerId Stripe customer id * @param sourceId Stripe source id * @return SourceInfo if created */ - fun setDefaultSource(stripeCustomerId: String, sourceId: String): Either + fun setDefaultSource(customerId: String, sourceId: String): Either /** * @param customerId Customer id in the payment system @@ -144,11 +144,11 @@ interface PaymentProcessor { fun refundCharge(chargeId: String, amount: Int): Either /** - * @param stripeCustomerId Customer id in the payment system + * @param customerId Customer id in the payment system * @param sourceId id of the payment source * @return id if removed */ - fun removeSource(stripeCustomerId: String, sourceId: String): Either + fun removeSource(customerId: String, sourceId: String): Either fun getStripeEphemeralKey(customerId: String, email: String, apiVersion: String): Either diff --git a/prime/build.gradle.kts b/prime/build.gradle.kts index 3489ac4aa..60f722b1b 100644 --- a/prime/build.gradle.kts +++ b/prime/build.gradle.kts @@ -10,7 +10,7 @@ plugins { } // Update version in [script/start.sh] too. -version = "1.72.7" +version = "1.74.0" dependencies { // interface module between prime and prime-modules diff --git a/prime/config/config.yaml b/prime/config/config.yaml index 64dd4bdd6..db550ef6f 100644 --- a/prime/config/config.yaml +++ b/prime/config/config.yaml @@ -155,10 +155,8 @@ modules: profile: OYA_M1_STANDARD_ACB - regex: "Digi.*" profile: OYA_DIGI_STANDARD_ACB - - regex: "Loltel.android" - profile: Loltel_ANDROID_1 - regex: "Loltel.*" - profile: LOLTEL_IPHONE_1 + profile: OYA_LOLTEL_STD_ACB database: driverClass: org.postgresql.Driver user: ${DB_USER} diff --git a/prime/script/start.sh b/prime/script/start.sh index 422d3f80e..aeceda85c 100755 --- a/prime/script/start.sh +++ b/prime/script/start.sh @@ -5,5 +5,5 @@ exec java \ -Dfile.encoding=UTF-8 \ --add-opens java.base/java.lang=ALL-UNNAMED \ --add-opens java.base/java.io=ALL-UNNAMED \ - -agentpath:/opt/cprof/profiler_java_agent.so=-cprof_service=prime,-cprof_service_version=1.72.7,-logtostderr,-minloglevel=2,-cprof_enable_heap_sampling \ + -agentpath:/opt/cprof/profiler_java_agent.so=-cprof_service=prime,-cprof_service_version=1.74.0,-logtostderr,-minloglevel=2,-cprof_enable_heap_sampling \ -jar /prime.jar server /config/config.yaml diff --git a/sim-administration/es2plus4dropwizard/src/main/kotlin/org/ostelco/sim/es2plus/Es2PlusEntities.kt b/sim-administration/es2plus4dropwizard/src/main/kotlin/org/ostelco/sim/es2plus/Es2PlusEntities.kt index b707f62cf..22dffe8cb 100644 --- a/sim-administration/es2plus4dropwizard/src/main/kotlin/org/ostelco/sim/es2plus/Es2PlusEntities.kt +++ b/sim-administration/es2plus4dropwizard/src/main/kotlin/org/ostelco/sim/es2plus/Es2PlusEntities.kt @@ -77,7 +77,8 @@ data class Es2PlusDownloadOrder( data class Es2DownloadOrderResponse( @JsonProperty("header") val header: ES2ResponseHeader = eS2SuccessResponseHeader(), @JsonProperty("iccid") val iccid: String? = null -): Es2Response(header) +) : Es2Response(header) + /// @@ -118,7 +119,7 @@ data class IccidListEntry( @JsonInclude(JsonInclude.Include.NON_NULL) data class Es2ProfileStatusCommand( @JsonProperty("header") val header: ES2RequestHeader, - @JsonProperty("iccidList") val iccidList: List = listOf()) + @JsonProperty("iccidList") val iccidList: List = listOf()) @JsonInclude(JsonInclude.Include.NON_NULL) @@ -126,11 +127,11 @@ data class Es2ProfileStatusResponse( @JsonProperty("header") val header: ES2ResponseHeader = eS2SuccessResponseHeader(), @JsonProperty("profileStatusList") val profileStatusList: List? = listOf(), @JsonProperty("completionTimestamp") val completionTimestamp: String? = getNowAsDatetime() -): Es2Response(myHeader = header) +) : Es2Response(myHeader = header) @JsonInclude(JsonInclude.Include.NON_NULL) data class ProfileStatus( - @JsonProperty("status_last_update_timestamp") val lastUpdateTimestamp:String? = null, + @JsonProperty("status_last_update_timestamp") val lastUpdateTimestamp: String? = null, @JsonProperty("profileStatusList") val profileStatusList: List? = listOf(), @JsonProperty("acToken") val acToken: String? = null, @JsonProperty("state") val state: String? = null, @@ -166,7 +167,7 @@ data class Es2ConfirmOrderResponse( @JsonProperty("eid") val eid: String? = null, @JsonProperty("matchingId") val matchingId: String? = null, @JsonProperty("smdpAddress") val smdsAddress: String? = null -): Es2Response(myHeader = header) +) : Es2Response(myHeader = header) /// /// The CancelOrder function @@ -176,10 +177,10 @@ data class Es2ConfirmOrderResponse( // XXX CXHeck @JsonSchema("ES2+CancelOrder-def") data class Es2CancelOrder( @JsonProperty("header") val header: ES2RequestHeader, - @JsonProperty("eid") val eid: String?=null, + @JsonProperty("eid") val eid: String? = null, @JsonProperty("profileStatusList") val profileStatusList: String? = null, @JsonProperty("matchingId") val matchingId: String? = null, - @JsonProperty("iccid") val iccid: String?=null, + @JsonProperty("iccid") val iccid: String? = null, @JsonProperty("finalProfileStatusIndicator") val finalProfileStatusIndicator: String? = null ) @@ -219,7 +220,7 @@ data class Es2HandleDownloadProgressInfo( val imei: String? = null, // This field is added to ensure that the function signature of the primary and the actual // constructors are not confused by the JVM. It is ignored by all business logic. - private val ignoreThisField : String? = null) { + private val ignoreThisField: String? = null) { // If the stored ICCID contains a trailing "F", which it may because some vendors insist @@ -232,16 +233,16 @@ data class Es2HandleDownloadProgressInfo( // class. @JsonCreator - constructor (@JsonProperty("header") header: ES2RequestHeader, - @JsonProperty("eid") eid: String? = null, - @JsonProperty("iccid") iccid: String, - @JsonProperty("profileType") profileType: String, - @JsonProperty("timestamp") timestamp: String = getNowAsDatetime(), - @JsonProperty("tac") tac: String? = null, - @JsonProperty("notificationPointId") notificationPointId: Int, - @JsonProperty("notificationPointStatus") notificationPointStatus: ES2NotificationPointStatus, - @JsonInclude(JsonInclude.Include.NON_NULL) @JsonProperty("resultData") resultData: String? = null, - @JsonProperty("imei") imei: String? = null) : this( + constructor (@JsonProperty("header") header: ES2RequestHeader, + @JsonProperty("eid") eid: String? = null, + @JsonProperty("iccid") iccid: String, + @JsonProperty("profileType") profileType: String, + @JsonProperty("timestamp") timestamp: String = getNowAsDatetime(), + @JsonProperty("tac") tac: String? = null, + @JsonProperty("notificationPointId") notificationPointId: Int, + @JsonProperty("notificationPointStatus") notificationPointStatus: ES2NotificationPointStatus, + @JsonInclude(JsonInclude.Include.NON_NULL) @JsonProperty("resultData") resultData: String? = null, + @JsonProperty("imei") imei: String? = null) : this( header = header, eid = eid, iccid = if (!iccid.endsWith("F")) { // Rewrite input value if necessary @@ -263,13 +264,13 @@ data class Es2HandleDownloadProgressInfo( @JsonInclude(JsonInclude.Include.NON_NULL) data class ES2NotificationPointStatus( @JsonProperty("status") val status: FunctionExecutionStatusType = FunctionExecutionStatusType.ExecutedSuccess, - @JsonInclude(JsonInclude.Include.NON_NULL) @JsonProperty("statusCodeData") val statusCodeData: ES2StatusCodeData? = null + @JsonInclude(JsonInclude.Include.NON_NULL) @JsonProperty("statusCodeData") val statusCodeData: ES2StatusCodeData? = null ) @JsonInclude(JsonInclude.Include.NON_NULL) data class ES2StatusCodeData( @JsonProperty("subjectCode") val subjectCode: String, // "Executed-Success, Executed-WithWarning, Failed or - @JsonInclude(JsonInclude.Include.NON_NULL) @JsonProperty("reasonCode") val statusCodeData: String, + @JsonInclude(JsonInclude.Include.NON_NULL) @JsonProperty("reasonCode") val statusCodeData: String, @JsonProperty("subjectIdentifier") val subjectIdentifier: String? = null, @JsonProperty("message") val message: String? = null ) @@ -286,6 +287,6 @@ fun newErrorHeader(exception: SmDpPlusException): ES2ResponseHeader { statusCodeData = exception.statusCodeData)) } -fun eS2SuccessResponseHeader() = +fun eS2SuccessResponseHeader(): ES2ResponseHeader = ES2ResponseHeader(functionExecutionStatus = FunctionExecutionStatus(status = FunctionExecutionStatusType.ExecutedSuccess)) \ No newline at end of file diff --git a/sim-administration/es2plus4dropwizard/src/main/kotlin/org/ostelco/sim/es2plus/Es2PlusResources.kt b/sim-administration/es2plus4dropwizard/src/main/kotlin/org/ostelco/sim/es2plus/Es2PlusResources.kt index 66e3ddca1..95952eb2b 100644 --- a/sim-administration/es2plus4dropwizard/src/main/kotlin/org/ostelco/sim/es2plus/Es2PlusResources.kt +++ b/sim-administration/es2plus4dropwizard/src/main/kotlin/org/ostelco/sim/es2plus/Es2PlusResources.kt @@ -88,7 +88,7 @@ class SmdpExceptionMapper : ExceptionMapper { override fun toResponse(ex: SmDpPlusException): Response { // First we log the event. - logger.error("SM-DP+ processing failed: {}" , ex.statusCodeData) + logger.error("SM-DP+ processing failed: {}", ex.statusCodeData) // Then we prepare a response that will be returned to // whoever invoked the resource. @@ -113,7 +113,7 @@ class SmDpPlusServerResource(private val smDpPlus: SmDpPlusService) { private val logger = getLogger() companion object { - const val ES2PLUS_PATH_PREFIX : String = "gsma/rsp2/es2plus/" + const val ES2PLUS_PATH_PREFIX: String = "gsma/rsp2/es2plus/" } /** @@ -136,12 +136,12 @@ class SmDpPlusServerResource(private val smDpPlus: SmDpPlusService) { @POST fun confirmOrder(order: Es2ConfirmOrder): Es2ConfirmOrderResponse { return smDpPlus.confirmOrder( - eid=order.eid, + eid = order.eid, iccid = order.iccid, confirmationCode = order.confirmationCode, smdsAddress = order.smdpAddress, machingId = order.matchingId, - releaseFlag = order.releaseFlag + releaseFlag = order.releaseFlag ) } @@ -177,7 +177,7 @@ class SmDpPlusServerResource(private val smDpPlus: SmDpPlusService) { @POST fun getProfileStatus(order: Es2ProfileStatusCommand): Es2ProfileStatusResponse { logger.value.info("Logging getProfileStatusOrder with order = $order") - return smDpPlus.getProfileStatus(iccidList = order.iccidList.map {it.iccid}.filterNotNull()) + return smDpPlus.getProfileStatus(iccidList = order.iccidList.mapNotNull { it.iccid }) } } diff --git a/sim-administration/es2plus4dropwizard/src/main/kotlin/org/ostelco/sim/es2plus/Es2PlusServiceInterfaces.kt b/sim-administration/es2plus4dropwizard/src/main/kotlin/org/ostelco/sim/es2plus/Es2PlusServiceInterfaces.kt index 98e2a7240..2b52fe087 100644 --- a/sim-administration/es2plus4dropwizard/src/main/kotlin/org/ostelco/sim/es2plus/Es2PlusServiceInterfaces.kt +++ b/sim-administration/es2plus4dropwizard/src/main/kotlin/org/ostelco/sim/es2plus/Es2PlusServiceInterfaces.kt @@ -1,7 +1,6 @@ package org.ostelco.sim.es2plus - class SmDpPlusException(val statusCodeData: StatusCodeData) : Exception() @@ -11,7 +10,7 @@ interface SmDpPlusService { fun downloadOrder(eid: String?, iccid: String?, profileType: String?): Es2DownloadOrderResponse @Throws(SmDpPlusException::class) - fun confirmOrder(eid: String?, iccid: String?, smdsAddress: String?, machingId: String?, confirmationCode: String?, releaseFlag:Boolean): Es2ConfirmOrderResponse + fun confirmOrder(eid: String?, iccid: String?, smdsAddress: String?, machingId: String?, confirmationCode: String?, releaseFlag: Boolean): Es2ConfirmOrderResponse @Throws(SmDpPlusException::class) fun cancelOrder(eid: String?, iccid: String?, matchingId: String?, finalProfileStatusIndicator: String?) @@ -23,7 +22,7 @@ interface SmDpPlusService { fun releaseProfile(iccid: String) } -interface SmDpPlusCallbackService { +interface SmDpPlusCallbackService { @Throws(SmDpPlusException::class) fun handleDownloadProgressInfo( diff --git a/sim-administration/es2plus4dropwizard/src/test/kotlin/org/ostelco/sim/es2plus/EncryptedRemoteEs2ClientOnlyTest.kt b/sim-administration/es2plus4dropwizard/src/test/kotlin/org/ostelco/sim/es2plus/EncryptedRemoteEs2ClientOnlyTest.kt index 4498defc0..f2cb5a5c4 100644 --- a/sim-administration/es2plus4dropwizard/src/test/kotlin/org/ostelco/sim/es2plus/EncryptedRemoteEs2ClientOnlyTest.kt +++ b/sim-administration/es2plus4dropwizard/src/test/kotlin/org/ostelco/sim/es2plus/EncryptedRemoteEs2ClientOnlyTest.kt @@ -105,7 +105,7 @@ class EncryptedRemoteEs2ClientOnlyTest { } companion object { - val SUPPORT = DropwizardTestSupport( + val SUPPORT: DropwizardTestSupport = DropwizardTestSupport( DummyAppUsingSmDpPlusClient::class.java, "src/test/resources/config-external-smdp.yml" ) @@ -113,7 +113,6 @@ class EncryptedRemoteEs2ClientOnlyTest { } - class DummyAppUsingSmDpPlusClient : Application() { override fun getName(): String { @@ -151,7 +150,6 @@ class DummyAppUsingSmDpPlusClient : Application() { class PlaceholderSmDpPlusService : SmDpPlusService { override fun getProfileStatus(iccidList: List): Es2ProfileStatusResponse { - val statuses : List = iccidList.map { - iccid ->ProfileStatus(iccid = iccid, state = "ALLOCATED")} + val statuses: List = iccidList.map { iccid -> ProfileStatus(iccid = iccid, state = "ALLOCATED") } return Es2ProfileStatusResponse( profileStatusList = statuses) } @Throws(SmDpPlusException::class) override fun downloadOrder(eid: String?, iccid: String?, profileType: String?): Es2DownloadOrderResponse { - return Es2DownloadOrderResponse(eS2SuccessResponseHeader(), iccid="01234567890123456789") + return Es2DownloadOrderResponse(eS2SuccessResponseHeader(), iccid = "01234567890123456789") } override fun confirmOrder(eid: String?, iccid: String?, smdsAddress: String?, machingId: String?, confirmationCode: String?, releaseFlag: Boolean): Es2ConfirmOrderResponse { - return Es2ConfirmOrderResponse(eS2SuccessResponseHeader(), eid="1234567890123456789012", matchingId = "foo", smdsAddress = "localhost") + return Es2ConfirmOrderResponse(eS2SuccessResponseHeader(), eid = "1234567890123456789012", matchingId = "foo", smdsAddress = "localhost") } @Throws(SmDpPlusException::class) diff --git a/sim-administration/ostelco-dropwizard-utils/build.gradle.kts b/sim-administration/ostelco-dropwizard-utils/build.gradle.kts index 9dc256d31..80cb4adc0 100644 --- a/sim-administration/ostelco-dropwizard-utils/build.gradle.kts +++ b/sim-administration/ostelco-dropwizard-utils/build.gradle.kts @@ -7,13 +7,11 @@ plugins { dependencies { implementation(kotlin("stdlib-jdk8")) + implementation(kotlin("reflect")) + implementation("io.dropwizard:dropwizard-core:${Version.dropwizard}") implementation("io.swagger.core.v3:swagger-jaxrs2:${Version.swagger}") - - implementation(kotlin("reflect")) - implementation(kotlin("stdlib-jdk8")) - implementation("io.dropwizard:dropwizard-client:${Version.dropwizard}") implementation("io.dropwizard:dropwizard-core:${Version.dropwizard}") diff --git a/sim-administration/sim-batch-management/.gitignore b/sim-administration/sim-batch-management/.gitignore index c97f963b3..8826fe49c 100644 --- a/sim-administration/sim-batch-management/.gitignore +++ b/sim-administration/sim-batch-management/.gitignore @@ -1 +1,5 @@ *.sh +es2pluswrapper.sh +batch-lifecycle-test.sh +tmp + diff --git a/sim-administration/sim-batch-management/README-upload-sim-batch.md b/sim-administration/sim-batch-management/README-upload-sim-batch.md index 26c815894..e69de29bb 100644 --- a/sim-administration/sim-batch-management/README-upload-sim-batch.md +++ b/sim-administration/sim-batch-management/README-upload-sim-batch.md @@ -1,92 +0,0 @@ -# How to upload batch information to prime using the - -## Introduction -Prime has REST endpoint for uploading sim batches. This is an -interface with little error checking (beyond the bare miniumums) -and a low abstraction layer: It requires a CSV file of ICCID/IMSI/MSISDN/PROFILE tuples. - -This is convenient as a starting point, but in practice it has turned -out to be a little too simple, hence the script upload-sim-batch.go. - -This script takes assumes that there is already a way to talk HTTP -(no encryption) to the upload profiles. The default assumption is that -a tunnel has been set up from localhost:8080 to somewhere more -appropriate, but these coordinaters can be tweaked using command line -parameters. - -The basic REST interface assumes an incoming .csv file, but that is -bulky, and the information content is low. In practice we will -more often than not know the ranges of IMSI, ICCID and MSISDN numbers -involved, and obviously also the profile type names. The script can -take these values as parameters and generate a valid CSV file, and -upload it automatically. - -The parameters are checked for consistency, so that if there are -more MSISDNs than ICCIDs in the ranges given as parameters, for instance, -then the script will terminate with an error message. - -(these are reasonable things to check btw, errors have been made -that justifies adding these checks). - -##Prerequisites - -* Go has to be installed on the system being run. -* Prime needs to be accessible via ssh tunnel or otherwise from the host - where the script is being run. - - -##A typical invocation looks like this: - - -(The parameters below have correct lengths, but are otherwise bogus, -and will cause error messages.) - -``` - ./upload-sim-batch.go \ - -first-iccid 1234567678901234567689 \ - -last-iccid 1234567678901234567689 \ - -first-imsi 12345676789012345 \ - -last-imsi 12345676789012345 \ - -first-msisdn 12345676789012345 \ - -last-msisdn 12345676789012345 \ - -profile-type gargle-blaster-zot \ - -profile-vendor idemalto \ - -upload-hostname localhost \ - -upload-portnumber 8080 -``` - -##The full set of command line options - -``` - - -batch-length integer - The number of profiles in the batch. Must match with iccid, msisdn and imsi ranges (if present). - -first-iccid string - An 18 or 19 digit long string. The 19-th digit being a luhn luhnChecksum digit, if present (default "not a valid iccid") - -first-imsi string - First IMSI in batch (default "Not a valid IMSI") - -first-msisdn string - First MSISDN in batch (default "Not a valid MSISDN") - -hss-vendor string - The HSS vendor (default "M1") - -initial-hlr-activation-status-of-profiles string - Initial hss activation state. Legal values are ACTIVATED and NOT_ACTIVATED. (default "ACTIVATED") - -last-iccid string - An 18 or 19 digit long string. The 19-th digit being a luhn luhnChecksum digit, if present (default "not a valid iccid") - -last-imsi string - Last IMSI in batch (default "Not a valid IMSI") - -last-msisdn string - Last MSISDN in batch (default "Not a valid MSISDN") - -profile-type string - SIM profile type (default "Not a valid sim profile type") - -profile-vendor string - Vendor of SIM profiles (default "Idemia") - -upload-hostname string - host to upload batch to (default "localhost") - -upload-portnumber string - port to upload to (default "8080") - -``` - - - diff --git a/sim-administration/sim-batch-management/README.md b/sim-administration/sim-batch-management/README.md index 168b65331..0335ff988 100644 --- a/sim-administration/sim-batch-management/README.md +++ b/sim-administration/sim-batch-management/README.md @@ -21,11 +21,93 @@ go programmes here, both designed to be run from the command line. For both of these programmes, see the source code, in particular the comments near the top of the files for instructions on how to use them. +##To build everything -# TODO -* Make a build command that runs tests and reports test coverage (etc), - make it part of the "build-all.go" script. +Before we build, some things neds to be in order. +### Prerequisites + * Go has to be installed on the system being run. + + * Prime needs to be accessible via ssh tunnel or otherwise from the host + where the script is being run. +### Building + ./build-all.sh + +... will compile and test the program and leave an executable called +"sbm" in the current directory + + . ./build-all.sh + +... will compile and test the program, then if you're running bash +extend your shell with command line extensions for the sbm program. + +## Some common usecases + +### How to upload batch information to prime + +#### Introduction + +Prime has REST endpoint for uploading sim batches. This is an +interface with little error checking (beyond the bare miniumums) +and a low abstraction layer: It requires a CSV file of ICCID/IMSI/MSISDN/PROFILE tuples. + +This is convenient as a starting point, but in practice it has turned +out to be a little too simple, hence the script upload-sim-batch.go. + +This script takes assumes that there is already a way to talk HTTP +(no encryption) to the upload profiles. The default assumption is that +a tunnel has been set up from localhost:8080 to somewhere more +appropriate, but these coordinaters can be tweaked using command line +parameters. + +The basic REST interface assumes an incoming .csv file, but that is +bulky, and the information content is low. In practice we will +more often than not know the ranges of IMSI, ICCID and MSISDN numbers +involved, and obviously also the profile type names. The script can +take these values as parameters and generate a valid CSV file, and +upload it automatically. + +The parameters are checked for consistency, so that if there are +more MSISDNs than ICCIDs in the ranges given as parameters, for instance, +then the script will terminate with an error message. + +(these are reasonable things to check btw, errors have been made +that justifies adding these checks). + +###A typical invocation looks like this: + + TBD + +##TODO + +1. Create a very clean PR for future code review. + +2. Write up a nice markdown documentation describing common usecases. + +3. Add crypto resources so that the program can talk to external parties. + +4. Add code to activate profiles in HSS (if API is known) + +5. Add config for crypto parameters for HSSes, profile-vendors and operators (sftp in particular) + +6. Add misc. parameters about sim vendors, HSSes, Prime instances etc., so that + batches can be properly constrained, defaults set the right way and external + components accessed from gocode. + +7. Figure out how to handle workflows. Be explicit! + +8. The interfaces to external parties will be + - input/output files for profile generation. + - some kind of file (not yet determined) for msisdn lists. + - HTTP upload commands, either indirectly via curl (as now), or + directly from the script later. In either case + it will be assumed that tunnels are set up out of band, and + tunnel setup is not part of this program. + +9. Declare legal hss/dpv combinations, batches must use legal combos. + +10. Declare prime instances (should make sense to have both prod and dev defined + with different constraints on them). diff --git a/sim-administration/sim-batch-management/TODO.md b/sim-administration/sim-batch-management/TODO.md deleted file mode 100644 index aa3827208..000000000 --- a/sim-administration/sim-batch-management/TODO.md +++ /dev/null @@ -1,21 +0,0 @@ -An informal TODO list for the sim batch management tool -== - -1. Make an RDBMS that handles sim card workflows. -2. It must by necessity be able to handle free lists of - imsis, msisdns etc. -3. As today, it should be possible to -generate- those lists - from parameters, where that makes sense. In general howeve,r - in particular for production use, this will not be the case - and we need to cater for that. -4. The programme should -initially- be wholly command line - oriented, with a database using sqlite. -5. At some (much) later stage, it may make sense to put it - in some cloud, somewhere. -6. The interfaces to external parties will be - - input/output files for profile generation. - - some kind of file (not yet determind) for msisdn lists. - - HTTP upload commands, either indirectly via curl (as now), or - directly from the script later. In either case - it will be assumed that tunnels are set up out of band, and - tunnel setup is not part of this program. diff --git a/sim-administration/sim-batch-management/build-all.sh b/sim-administration/sim-batch-management/build-all.sh new file mode 100755 index 000000000..054e77781 --- /dev/null +++ b/sim-administration/sim-batch-management/build-all.sh @@ -0,0 +1,39 @@ +#!/bin/bash + + + + +# First we want the thing to compile +go build + +if [ "$?" -ne "0" ]; then + echo "Sorry compilation failed aborting build." + exit 1 +fi + + + +# THen to pass tests +go test ./... + +if [ "$?" -ne "0" ]; then + echo "Sorry, one or more tests failed, aborting build." + exit 1 +fi + +# Then... +# somewhat nonportably ... run static analysis of the +# go code. + + ~/go/bin/staticcheck ./... + + + + +# If sourcing this script, then the line below +# will modify command line compesion in bash + +if [[ $_ != $0 ]] ; then + rm -f /tmp/tmp.db + eval "$(SIM_BATCH_DATABASE=/tmp/tmp.db ./sbm --completion-script-bash)" +fi diff --git a/sim-administration/sim-batch-management/es2plus/es2plus.go b/sim-administration/sim-batch-management/es2plus/es2plus.go new file mode 100644 index 000000000..263cfd869 --- /dev/null +++ b/sim-administration/sim-batch-management/es2plus/es2plus.go @@ -0,0 +1,463 @@ +package es2plus + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "github.com/google/uuid" + "log" + "net/http" + "strings" +) + + +// Client is an external interface for ES2+ client +type Client interface { + GetStatus(iccid string) (*ProfileStatus, error) + RecoverProfile(iccid string, targetState string) (*RecoverProfileResponse, error) + CancelOrder(iccid string, targetState string) (*CancelOrderResponse, error) + DownloadOrder(iccid string) (*DownloadOrderResponse, error) + ConfirmOrder(iccid string) (*ConfirmOrderResponse, error) + ActivateIccid(iccid string) (*ProfileStatus, error) + RequesterID() string +} + +/// +/// Generic headers for invocations and responses +/// + + +// Header is a generic header for all ES2+ invocations. +type Header struct { + FunctionrequesterIDentifier string `json:"functionrequesterIDentifier"` + FunctionCallIdentifier string `json:"functionCallIdentifier"` +} + +// GetProfileStatusRequest holds a request object for the profileStatus es2+ command. +type GetProfileStatusRequest struct { + Header Header `json:"header"` + IccidList []ICCID `json:"iccidList"` +} + +// ICCID holder of ICCID values in the Es2+ protocol. +type ICCID struct { + Iccid string `json:"iccid"` +} + +// FunctionExecutionStatus is part of the generic es2+ response. +type FunctionExecutionStatus struct { + FunctionExecutionStatusType string `json:"status"` + StatusCodeData StatusCodeData `json:"statusCodeData"` +} + +// ResponseHeader is part of the generic response header in es2+ reponses. +type ResponseHeader struct { + FunctionExecutionStatus FunctionExecutionStatus `json:"FunctionExecutionStatus"` +} + +// +// Status code invocation. +// + + +// StatusCodeData payload from the function execution status field of the +// ES2+ protocol header. +type StatusCodeData struct { + SubjectCode string `json:"subjectCode"` + ReasonCode string `json:"reasonCode"` + SubjectIdentifier string `json:"subjectIdentifier"` + Message string `json:"message"` +} + +type es2ProfileStatusResponse struct { + Header ResponseHeader `json:"header"` + ProfileStatusList []ProfileStatus `json:"profileStatusList"` + CompletionTimestamp string `json:"completionTimestamp"` +} + + +// ProfileStatus holds the "profile status" part of a ProfileStatusResponse +// - response returned from an es2+ request. +type ProfileStatus struct { + StatusLastUpdateTimestamp string `json:"status_last_update_timestamp"` + ACToken string `json:"acToken"` + State string `json:"state"` + Eid string `json:"eid"` + Iccid string `json:"iccid"` + LockFlag bool `json:"lockFlag"` +} + +// +// Profile reset invocation +// + +// RecoverProfileRequest is the payload of the recoverProfile request. +type RecoverProfileRequest struct { + Header Header `json:"header"` + Iccid string `json:"iccid"` + ProfileStatus string `json:"profileStatus"` +} + +// RecoverProfileResponse is the payload of the recoverProfile response. +type RecoverProfileResponse struct { + Header ResponseHeader `json:"header"` +} + + +// CancelOrderRequest the payload of the cancelOrder request response. +type CancelOrderRequest struct { + Header Header `json:"header"` + Iccid string `json:"iccid"` + FinalProfileStatusIndicator string `json:"finalProfileStatusIndicator"` +} + +// CancelOrderResponse the payload of the cancelOrder request response. +type CancelOrderResponse struct { + Header ResponseHeader `json:"header"` +} + + +// DownloadOrderRequest The payload of a downloadOrder request. +type DownloadOrderRequest struct { + Header Header `json:"header"` + Iccid string `json:"iccid"` + Eid string `json:"eid,omitempty"` + Profiletype string `json:"profiletype,omitempty"` +} + + +// DownloadOrderResponse the response of a DownloadOrder command +type DownloadOrderResponse struct { + Header ResponseHeader `json:"header"` + Iccid string `json:"iccid"` +} + +// +// ConfirmOrder invocation +// + + +// ConfirmOrderRequest contains parameters for the es2+ confirmOrder +// command. +type ConfirmOrderRequest struct { + Header Header `json:"header"` + Iccid string `json:"iccid"` + Eid string `json:"eid,omitempty"` + MatchingID string `json:"matchingId,omitempty"` + ConfirmationCode string `json:"confirmationCode,omitempty"` + SmdpAddress string `json:"smdpAddress,omitempty"` + ReleaseFlag bool `json:"releaseFlag"` +} + + +// ConfirmOrderResponse contains the response value for the es2+ confirmOrder +// command. +type ConfirmOrderResponse struct { + Header ResponseHeader `json:"header"` + Iccid string `json:"iccid"` + Eid string `json:"eid,omitempty"` + MatchingID string `json:"matchingId,omitempty"` + SmdpAddress string `json:"smdpAddress,omitempty"` +} + +// +// Generating new ES2Plus clients +// + + +// ClientState struct representing the state of a ES2+ client. +type ClientState struct { + httpClient *http.Client + hostport string + requesterID string + logPayload bool + logHeaders bool +} + + +// NewClient create a new es2+ client instance +func NewClient(certFilePath string, keyFilePath string, hostport string, requesterID string) *ClientState { + return &ClientState{ + httpClient: newHTTPClient(certFilePath, keyFilePath), + hostport: hostport, + requesterID: requesterID, + logPayload: false, + logHeaders: false, + } +} + +func newHTTPClient(certFilePath string, keyFilePath string) *http.Client { + cert, err := tls.LoadX509KeyPair( + certFilePath, + keyFilePath) + if err != nil { + log.Fatalf("server: loadkeys: %s", err) + } + + // TODO: The certificate used to sign the other end of the TLS connection + // is privately signed, and at this time we don't require the full + // certificate chain to be available. + config := tls.Config{Certificates: []tls.Certificate{cert}, InsecureSkipVerify: true} + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &config, + }, + } + return client +} + +/// +/// Generic protocol code +/// + +// +// Function used during debugging to print requests before they are +// sent over the wire. Very useful, should not be deleted even though it +// is not used right now. +// +func formatRequest(r *http.Request) string { + // Create return string + var request []string + // Add the request string + url := fmt.Sprintf("%v %v %v", r.Method, r.URL, r.Proto) + request = append(request, url) + // Add the host + request = append(request, fmt.Sprintf("Host: %v", r.Host)) + // Loop through headers + for name, headers := range r.Header { + name = strings.ToLower(name) + for _, h := range headers { + request = append(request, fmt.Sprintf("%v: %v", name, h)) + } + } + + // If this is a POST, add post data + if r.Method == "POST" { + r.ParseForm() + request = append(request, "\n") + request = append(request, r.Form.Encode()) + } + // Return the request as a string + return strings.Join(request, "\n") +} + +func newUUID() (string, error) { + uuid, err := uuid.NewRandom() + if err != nil { + return "", err + } + return uuid.URN(), nil +} + +func newHeader(client Client) (*Header, error) { + + functionCallIdentifier, err := newUUID() + if err != nil { + return nil, err + } + + return &Header{FunctionCallIdentifier: functionCallIdentifier, FunctionrequesterIDentifier: client.RequesterID()}, nil +} + +// execute is an internal function that will package a payload +// by json serializing it, then execute an ES2+ command +// and unmarshal the result into the result object. +func (client *ClientState) execute( + es2plusCommand string, + payload interface{}, result interface{}) error { + + // Serialize payload as json. + jsonStrB := new(bytes.Buffer) + err := json.NewEncoder(jsonStrB).Encode(payload) + + if err != nil { + return err + } + + if client.logPayload { + log.Print("Payload ->", jsonStrB.String()) + } + + url := fmt.Sprintf("https://%s/gsma/rsp2/es2plus/%s", client.hostport, es2plusCommand) + req, err := http.NewRequest("POST", url, jsonStrB) + if err != nil { + return err + } + req.Header.Set("X-Admin-Protocol", "gsma/rsp/v2.0.0") + req.Header.Set("Content-Type", "application/json") + + if client.logHeaders { + log.Printf("Request -> %s\n", formatRequest(req)) + } + + resp, err := client.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + // TODO Should check response headers here! + // (in particular X-admin-protocol) and fail if not OK. + + return json.NewDecoder(resp.Body).Decode(&result) +} + +/// +/// Externally visible API for Es2Plus protocol +/// + + +// GetStatus will return the status of a profile with a specific ICCID. +func (client *ClientState) GetStatus(iccid string) (*ProfileStatus, error) { + result := new(es2ProfileStatusResponse) + es2plusCommand := "getProfileStatus" + header, err := newHeader(client) + if err != nil { + return nil, err + } + payload := &GetProfileStatusRequest{ + Header: *header, + IccidList: []ICCID{ICCID{Iccid: iccid}}, + } + if err = client.execute(es2plusCommand, payload, result); err != nil { + return nil, err + } + + if len(result.ProfileStatusList) == 0 { + return nil, nil + } else if len(result.ProfileStatusList) == 1 { + return &result.ProfileStatusList[0], nil + } else { + return nil, fmt.Errorf("GetStatus returned more than one profile") + } +} + +// RecoverProfile will recover the state of the profile with a particular ICCID, +// by setting it to the target state. +func (client *ClientState) RecoverProfile(iccid string, targetState string) (*RecoverProfileResponse, error) { + result := new(RecoverProfileResponse) + es2plusCommand := "recoverProfile" + header, err := newHeader(client) + if err != nil { + return nil, err + } + payload := &RecoverProfileRequest{ + Header: *header, + Iccid: iccid, + ProfileStatus: targetState, + } + return result, client.execute(es2plusCommand, payload, result) +} + +// CancelOrder will cancel an order by setting the state of the profile with a particular ICCID, +// the target state. +func (client *ClientState) CancelOrder(iccid string, targetState string) (*CancelOrderResponse, error) { + result := new(CancelOrderResponse) + es2plusCommand := "cancelOrder" + header, err := newHeader(client) + if err != nil { + return nil, err + } + payload := &CancelOrderRequest{ + Header: *header, + Iccid: iccid, + FinalProfileStatusIndicator: targetState, + } + return result, client.execute(es2plusCommand, payload, result) +} + +// DownloadOrder will prepare the profile to be downloaded (first of two steps, the +// ConfirmDownload is also necessary). +func (client *ClientState) DownloadOrder(iccid string) (*DownloadOrderResponse, error) { + result := new(DownloadOrderResponse) + es2plusCommand := "downloadOrder" + header, err := newHeader(client) + if err != nil { + return nil, err + } + payload := &DownloadOrderRequest{ + Header: *header, + Iccid: iccid, + Eid: "", + Profiletype: "", + } + if err = client.execute(es2plusCommand, payload, result); err != nil { + return nil, err + } + + executionStatus := result.Header.FunctionExecutionStatus.FunctionExecutionStatusType + if executionStatus == "Executed-Success" { + return result, nil + } + return result, fmt.Errorf("ExecutionStatus was: ''%s'", executionStatus) +} + +// ConfirmOrder will execute the second of the two steps that are necessary to prepare a profile for +// to be downloaded. +func (client *ClientState) ConfirmOrder(iccid string) (*ConfirmOrderResponse, error) { + result := new(ConfirmOrderResponse) + es2plusCommand := "confirmOrder" + header, err := newHeader(client) + if err != nil { + return nil, err + } + payload := &ConfirmOrderRequest{ + Header: *header, + Iccid: iccid, + Eid: "", + ConfirmationCode: "", + MatchingID: "", + SmdpAddress: "", + ReleaseFlag: true, + } + + if err = client.execute(es2plusCommand, payload, result); err != nil { + return nil, err + } + + executionStatus := result.Header.FunctionExecutionStatus.FunctionExecutionStatusType + if executionStatus != "Executed-Success" { + return result, fmt.Errorf("ExecutionStatus was: ''%s'", executionStatus) + } + + return result, nil +} + + +// ActivateIccid will take a profile to the state "READY" where it can be downloaded. +// This function will if poll the current status of the profile, and if +// necessary advance the state by executing the DownloadOrder and +// ConfirmOrder functions. +func (client *ClientState) ActivateIccid(iccid string) (*ProfileStatus, error) { + + result, err := client.GetStatus(iccid) + if err != nil { + return nil, err + } + + if result.ACToken == "" { + + if result.State == "AVAILABLE" { + if _, err := client.DownloadOrder(iccid); err != nil { + return nil, err + } + if result, err = client.GetStatus(iccid); err != nil { + return nil, err + } + } + + if result.State == "ALLOCATED" { + if _, err = client.ConfirmOrder(iccid); err != nil { + return nil, err + } + } + } + result, err = client.GetStatus(iccid) + return result, err +} + +// RequesterID TODO: This shouldn't have to be public, but how can it be avoided? +func (client *ClientState) RequesterID() string { + return client.requesterID +} diff --git a/sim-administration/sim-batch-management/fieldsyntaxchecks/fieldsyntaxchecks.go b/sim-administration/sim-batch-management/fieldsyntaxchecks/fieldsyntaxchecks.go new file mode 100644 index 000000000..c11d69995 --- /dev/null +++ b/sim-administration/sim-batch-management/fieldsyntaxchecks/fieldsyntaxchecks.go @@ -0,0 +1,151 @@ +package fieldsyntaxchecks + +import ( + "fmt" + "github.com/ostelco/ostelco-core/sim-administration/sim-batch-management/loltelutils" + "log" + "net/url" + "regexp" + "strconv" + "strings" +) + +func generateControlDigit(luhnString string) int { + controlDigit := calculateChecksum(luhnString, true) % 10 + + if controlDigit != 0 { + controlDigit = 10 - controlDigit + } + + return controlDigit +} + +func calculateChecksum(luhnString string, double bool) int { + source := strings.Split(luhnString, "") + checksum := 0 + + for i := len(source) - 1; i > -1; i-- { + t, _ := strconv.ParseInt(source[i], 10, 8) + n := int(t) + + if double { + n = n * 2 + } + + double = !double + + if n >= 10 { + n = n - 9 + } + + checksum += n + } + return checksum +} + +// LuhnChecksum generates a Luhn checksum control digit for the parameter number. +func LuhnChecksum(number int) int { + return generateControlDigit(strconv.Itoa(number)) +} + +// AddLuhnChecksum interprets the parameter as a number, then +// generates and appends an one-digit Luhn checksum. +func AddLuhnChecksum(parameterNumber string) string { + checksum := generateControlDigit(parameterNumber) + return fmt.Sprintf("%s%1d", parameterNumber, checksum) +} + +// IsICCID if the string is an 18 or 19 digit positive integer. +// Does not check luhn checksum. +func IsICCID(s string) bool { + match, _ := regexp.MatchString("^\\d{18}\\d?\\d?$", s) + return match +} + +// CheckICCIDSyntax if the string is an 18 or 19 digit positive integer. +// Does check luhn checksum. +func CheckICCIDSyntax(name string, potentialIccid string) { + if !IsICCID(potentialIccid) { + log.Fatalf("Not a valid '%s' ICCID: '%s'. Must be 18 or 19 (or 20) digits (_including_ luhn checksum).", name, potentialIccid) + } + + stringWithoutLuhnChecksum := IccidWithoutLuhnChecksum(potentialIccid) + controlDigit := generateControlDigit(stringWithoutLuhnChecksum) + checksummedCandidate := fmt.Sprintf("%s%d", stringWithoutLuhnChecksum, controlDigit) + if checksummedCandidate != potentialIccid { + log.Fatalf("Not a valid '%s' ICCID: '%s'. Expected luhn checksom '%d'", name, potentialIccid, controlDigit) + } +} + +// IsIMSI is true iff the parameter string is a 15 digit number, +// indicating that it could be a valid IMSI. +func IsIMSI(s string) bool { + match, _ := regexp.MatchString("^\\d{15}$", s) + return match +} + +// CheckIMSISyntax is a convenience function that checks +// if the potentialIMSI string is a syntactically correct +// IMSI. IF it isn't a log message is written and the program +// terminates. +func CheckIMSISyntax(name string, potentialIMSI string) { + if !IsIMSI(potentialIMSI) { + // TODO: Modify. Return error value instead. + log.Fatalf("Not a valid %s IMSI: '%s'. Must be 15 digits.", name, potentialIMSI) + } +} + +// IsMSISDN bis true if the parameter string is a positive number. +func IsMSISDN(s string) bool { + match, _ := regexp.MatchString("^\\d+$", s) + return match +} + +// CheckMSISDNSyntax is a convenience function that checks if +// the potential msisdn is an actual msisdn or not. If it isn't +// then an error message is written and the program +// terminates. +func CheckMSISDNSyntax(name string, potentialMSISDN string) { + if !IsMSISDN(potentialMSISDN) { + // TODO: Fix this to return error value instead. + log.Fatalf("Not a valid %s MSISDN: '%s'. Must be non-empty sequence of digits.", name, potentialMSISDN) + } +} + + +// CheckURLSyntax is a convenience function that checks if +// the potential url is an actual url or not. If it isn't +// then an error message is written and the program +// terminates. +func CheckURLSyntax(name string, theURL string) { + if _, err := url.ParseRequestURI(theURL); err != nil { + // TODO: Fix this to return error value instead. + log.Fatalf("Not a valid %s URL: '%s'.", name, theURL) + } +} + +// IsProfileName checks if the parameter string is a syntactically valid profile name +func IsProfileName(s string) bool { + match, _ := regexp.MatchString("^[A-Z][A-Z0-9_]+$", s) + return match +} + + +// CheckProfileType is a convenience function that checks if +// the potential profile name is a syntacticaly correct +// profile name or not.. If it isn't +// then an error message is written and the program +// terminates. +func CheckProfileType(name string, potentialProfileName string) { + if !IsProfileName(potentialProfileName) { + // TODO: Fix this to return error value instead. + log.Fatalf("Not a valid %s MSISDN: '%s'. Must be uppercase characters, numbers and underscores. ", name, potentialProfileName) + } +} + + +// IccidWithoutLuhnChecksum takes an ICCID with a trailing Luhn +// checksum, and returns the value without the trailing checksum. +func IccidWithoutLuhnChecksum(s string) string { + return loltelutils.TrimSuffix(s, 1) +} diff --git a/sim-administration/sim-batch-management/uploadtoprime/luhn_test.go b/sim-administration/sim-batch-management/fieldsyntaxchecks/luhn_test.go similarity index 76% rename from sim-administration/sim-batch-management/uploadtoprime/luhn_test.go rename to sim-administration/sim-batch-management/fieldsyntaxchecks/luhn_test.go index 524771436..dae473bd7 100644 --- a/sim-administration/sim-batch-management/uploadtoprime/luhn_test.go +++ b/sim-administration/sim-batch-management/fieldsyntaxchecks/luhn_test.go @@ -1,13 +1,12 @@ -package uploadtoprime - +package fieldsyntaxchecks import ( "testing" ) - func TestLuhn(t *testing.T) { validNumbers := []int{ + 8965030119110000013, 79927398713, 4929972884676289, 4532733309529845, @@ -46,6 +45,10 @@ func TestLuhn(t *testing.T) { 6387065788050980, 6388464094939979} + invalidNumbers := []int{ + 896503011911000001, + } + for _, number := range validNumbers { checksum := LuhnChecksum(number / 10) @@ -53,4 +56,12 @@ func TestLuhn(t *testing.T) { t.Errorf("%v's check number should be %v, but got %v", number, number%10, checksum) } } + + for _, number := range invalidNumbers { + + checksum := LuhnChecksum(number / 10) + if checksum == number%10 { + t.Errorf("%v's check number should _not_ be %v, but got %v", number, number%10, checksum) + } + } } diff --git a/sim-administration/sim-batch-management/model/model.go b/sim-administration/sim-batch-management/model/model.go new file mode 100644 index 000000000..a4eff8126 --- /dev/null +++ b/sim-administration/sim-batch-management/model/model.go @@ -0,0 +1,63 @@ +package model + +// TODO: Delete all the ICCID entries that are not necessary, that would be at +// about three of them. + + +// SimEntry represents individual sim profiles. Instances can be +// subject to JSON serialisation/deserialisation, and can be stored +// in persistent storage. +type SimEntry struct { + ID int64 `db:"id" json:"id"` + BatchID int64 `db:"batchID" json:"batchID"` + RawIccid string `db:"rawIccid" json:"rawIccid"` + IccidWithChecksum string `db:"iccidWithChecksum" json:"iccidWithChecksum"` + IccidWithoutChecksum string `db:"iccidWithoutChecksum" json:"iccidWithoutChecksum"` + Iccid string `db:"iccid" json:"iccid"` + Imsi string `db:"imsi" json:"imsi"` + Msisdn string `db:"msisdn" json:"msisdn"` + Ki string `db:"ki" json:"ki"` + ActivationCode string `db:"activationCode" json:"activationCode"` +} + +// Batch represents batches of sim profiles. Instances can be +// subject to JSON serialisation/deserialisation, and can be stored +// in persistent storage. +type Batch struct { + BatchID int64 `db:"id" json:"id"` // TODO: SHould this be called 'Id' + Name string `db:"name" json:"name"` + + // TODO: Customer is a misnomer: This is the customer name used when + // ordering a sim batch, used in the input file. So a very + // specific use, not in any way the generic thing the word + // as it is used now points to. + + FilenameBase string `db:"filenameBase" json:"filenameBase"` + Customer string `db:"customer" json:"customer"` + ProfileType string `db:"profileType" json:"profileType"` + OrderDate string `db:"orderDate" json:"orderDate"` + BatchNo string `db:"batchNo" json:"batchNo"` + Quantity int `db:"quantity" json:"quantity"` + FirstIccid string `db:"firstIccid" json:"firstIccid"` + FirstImsi string `db:"firstImsi" json:"firstImsi"` + URL string `db:"url" json:"url"` + MsisdnIncrement int `db:"msisdnIncrement" json:"msisdnIncrement"` + IccidIncrement int `db:"iccidIncrement" json:"iccidIncrement"` + ImsiIncrement int `db:"imsiIncrement" json:"imsiIncrement"` + FirstMsisdn string `db:"firstMsisdn" json:"firstMsisdn"` + ProfileVendor string `db:"profileVendor" json:"profileVendor"` +} + + +// ProfileVendor represents sim profile vendors. Instances can be +// subject to JSON serialisation/deserialisation, and can be stored +// in persistent storage. +type ProfileVendor struct { + ID int64 `db:"id" json:"id"` + Name string `db:"name" json:"name"` + Es2PlusCert string `db:"es2PlusCertPath" json:"es2plusCertPath"` + Es2PlusKey string `db:"es2PlusKeyPath" json:"es2PlusKeyPath"` + Es2PlusHost string `db:"es2PlusHostPath" json:"es2plusHostPath"` + Es2PlusPort int `db:"es2PlusPort" json:"es2plusPort"` + Es2PlusRequesterID string `db:"es2PlusRequesterId" json:"es2PlusRequesterId"` +} diff --git a/sim-administration/sim-batch-management/outfile_to_hss_input_converter.go b/sim-administration/sim-batch-management/outfile_to_hss_input_converter.go deleted file mode 100755 index f94fc345c..000000000 --- a/sim-administration/sim-batch-management/outfile_to_hss_input_converter.go +++ /dev/null @@ -1,51 +0,0 @@ -//usr/bin/env go run "$0" "$@"; exit "$?" -/** - * This program is intended to be used from the command line, and will convert an - * output file from a sim card vendor into an input file for a HSS. The assumptions - * necessary for this to work are: - * - * * The SIM card vendor produces output files similar to the example .out file - * found in the same source directory as this program - * - * * The HSS accepts input as a CSV file, with header line 'ICCID, IMSI, KI' and subsequent - * lines containing ICCID/IMSI/Ki fields, all separated by commas. - * - * Needless to say, the outmost care should be taken when handling Ki values and - * this program must, as a matter of course, be considered a security risk, as - * must all software that touch SIM values. - * - * With that caveat in place, the usage of this program typically looks like - * this: - * - * ./outfile_to_hss_input_converter.go \ - * -input-file sample_out_file_for_testing.out - * -output-file-prefix ./hss-input-for- - * - * (followed by cryptographically strong erasure of the .out file, - * encapsulation of the .csv file in strong cryptography etc., none - * of which are handled by this script). - */ - -package main - -import ( - "fmt" - "github.com/ostelco/ostelco-core/sim-administration/sim-batch-management/outfileconversion" - "log" -) - -func main() { - inputFile, outputFilePrefix := outfileconversion.ParseOutputToHssConverterCommandLine() - - fmt.Println("inputFile = ", inputFile) - fmt.Println("outputFilePrefix = ", outputFilePrefix) - - outRecord := outfileconversion.ReadOutputFile(inputFile) - outputFile := outputFilePrefix + outRecord.OutputFileName + ".csv" - fmt.Println("outputFile = ", outputFile) - - err := outfileconversion.WriteHssCsvFile(outputFile, outRecord.Entries) - if err != nil { - log.Fatal("Couldn't close output file '", outputFilePrefix, "'. Error = '", err, "'") - } -} diff --git a/sim-administration/sim-batch-management/outfileconversion/outfile_to_hss_input_converter_lib.go b/sim-administration/sim-batch-management/outfileconversion/outfile_to_hss_input_converter_lib.go deleted file mode 100644 index 6e53238ff..000000000 --- a/sim-administration/sim-batch-management/outfileconversion/outfile_to_hss_input_converter_lib.go +++ /dev/null @@ -1,305 +0,0 @@ -package outfileconversion - -import ( - "bufio" - "flag" - "fmt" - "github.com/ostelco/ostelco-core/sim-administration/sim-batch-management/loltelutils" - "log" - "os" - "regexp" - "strconv" - "strings" -) - -/// -/// Data structures -/// - -type OutputFileRecord struct { - Filename string - inputVariables map[string]string - headerDescription map[string]string - Entries []SimEntry - // TODO: As it is today, the noOfEntries is just the number of Entries, - // but I may want to change that to be the declared number of Entries, - // and then later, dynamically, read in the individual Entries - // in a channel that is just piped to the goroutine that writes - // them to file, and fails if the number of declared Entries - // differs from the actual number of Entries. .... but that is - // for another day. - noOfEntries int - OutputFileName string -} - -const ( - INITIAL = "initial" - HEADER_DESCRIPTION = "header_description" - INPUT_VARIABLES = "input_variables" - OUTPUT_VARIABLES = "output_variables" - UNKNOWN_HEADER = "unknown" -) - -type SimEntry struct { - rawIccid string - iccidWithChecksum string - iccidWithoutChecksum string - imsi string - ki string - outputFileName string -} - -type ParserState struct { - currentState string - inputVariables map[string]string - headerDescription map[string]string - entries []SimEntry -} - -/// -/// Functions -/// - - -func ParseOutputToHssConverterCommandLine() (string, string) { - inputFile := flag.String("input-file", - "not a valid filename", - "path to .out file used as input file") - - outputFile := flag.String("output-file-prefix", - "not a valid filename", - "prefix to path to .csv file used as input file, filename will be autogenerated") - - // - // Parse input according to spec above - // - flag.Parse() - return *inputFile, *outputFile -} - -func ParseLineIntoKeyValueMap(line string, theMap map[string]string) { - var splitString = strings.Split(line, ":") - if len(splitString) != 2 { - log.Fatalf("Unparsable colon separated key/value pair: '%s'\n", line) - } - key := strings.TrimSpace(splitString[0]) - value := strings.TrimSpace(splitString[1]) - theMap[key] = value -} - -func ReadOutputFile(filename string) OutputFileRecord { - - _, err := os.Stat(filename) - - if os.IsNotExist(err) { - log.Fatalf("Couldn't find file '%s'\n", filename) - } - if err != nil { - log.Fatalf("Couldn't stat file '%s'\n", filename) - } - - file, err := os.Open(filename) // For read access. - if err != nil { - log.Fatal(err) - } - - defer file.Close() - - state := ParserState{ - currentState: INITIAL, - inputVariables: make(map[string]string), - headerDescription: make(map[string]string), - } - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - - // Read line, trim spaces in both ends. - line := scanner.Text() - line = strings.TrimSpace(line) - - // Is this a line we should read quickly then - // move on to the next...? - if isComment(line) { - continue - } else if isSectionHeader(line) { - nextMode := modeFromSectionHeader(line) - transitionMode(&state, nextMode) - continue - } - - // ... or should we look closer at it and parse it - // looking for real content? - - switch state.currentState { - case HEADER_DESCRIPTION: - ParseLineIntoKeyValueMap(line, state.headerDescription) - case INPUT_VARIABLES: - if line == "var_In:" { - continue - } - ParseLineIntoKeyValueMap(line, state.inputVariables) - case OUTPUT_VARIABLES: - if line == "var_Out: ICCID/IMSI/KI" { - continue - } - - // We won't handle all variations, only the most common one - // if more fancy variations are necessary (with pin codes etc), then - // we'll add them as needed. - if strings.HasPrefix(line, "var_Out: ") { - log.Fatalf("Unknown output format, only know how to handle ICCID/IMSI/KI, but was '%s'\n", line) - } - - line = strings.TrimSpace(line) - if line == "" { - continue - } - rawIccid, imsi, ki := parseOutputLine(line) - - iccidWithChecksum := rawIccid - if strings.HasSuffix(rawIccid, "F") { - iccidWithChecksum = loltelutils.TrimSuffix(rawIccid, 1) - } - - var iccidWithoutChecksum = loltelutils.TrimSuffix(iccidWithChecksum, 1) - // TODO: Enable this!! checkICCIDSyntax(iccidWithChecksum) - entry := SimEntry{ - rawIccid: rawIccid, - iccidWithChecksum: iccidWithChecksum, - iccidWithoutChecksum: iccidWithoutChecksum, - imsi: imsi, - ki: ki} - state.entries = append(state.entries, entry) - - case UNKNOWN_HEADER: - continue - - default: - log.Fatalf("Unknown parser state '%s'\n", state.currentState) - } - } - - if err := scanner.Err(); err != nil { - log.Fatal(err) - } - - countedNoOfEntries := len(state.entries) - declaredNoOfEntities, err := strconv.Atoi(state.headerDescription["Quantity"]) - - if err != nil { - log.Fatal("Could not find declared quantity of entities") - } - - if countedNoOfEntries != declaredNoOfEntities { - log.Fatalf("Declared no of entities = %d, counted nunber of entities = %d. Mismatch!", - declaredNoOfEntities, - countedNoOfEntries) - } - - result := OutputFileRecord{ - Filename: filename, - inputVariables: state.inputVariables, - headerDescription: state.headerDescription, - Entries: state.entries, - noOfEntries: declaredNoOfEntities, - OutputFileName: getOutputFileName(state), - } - - return result -} - -func getOutputFileName(state ParserState) string { - return "" + getCustomer(state) + "_" + getProfileType(state) + "_" + getBatchNo(state) -} - -func getBatchNo(state ParserState) string { - return state.headerDescription["Batch No"] -} - -func getProfileType(state ParserState) string { - return state.headerDescription["ProfileType"] -} - -func getCustomer(state ParserState) string { - // TODO: Maker safe, so that it fails reliably if Customer is not in map. - // also use constant, not magic string - return state.headerDescription["Customer"] -} - -func parseOutputLine(s string) (string, string, string) { - parsedString := strings.Split(s, " ") - return parsedString[0], parsedString[1], parsedString[2] -} - -func transitionMode(state *ParserState, targetState string) { - state.currentState = targetState -} - -// TODO: Consider replacing this thing with a map lookup. -func modeFromSectionHeader(s string) string { - sectionName := s[1:] - switch sectionName { - case "HEADER DESCRIPTION": - return HEADER_DESCRIPTION - case "INPUT VARIABLES": - return INPUT_VARIABLES - case "OUTPUT VARIABLES": - return OUTPUT_VARIABLES - default: - return UNKNOWN_HEADER - } -} - -func isSectionHeader(s string) bool { - match, _ := regexp.MatchString("^\\*([A-Z0-9 ])+$", s) - return match -} - -func isComment(s string) bool { - match, _ := regexp.MatchString("^\\*+$", s) - return match -} - - - -// fileExists checks if a file exists and is not a directory before we -// try using it to prevent further errors. -func fileExists(filename string) bool { - info, err := os.Stat(filename) - if os.IsNotExist(err) { - return false - } - return !info.IsDir() -} - -// TODO: Consider rewriting using https://golang.org/pkg/encoding/csv/ -func WriteHssCsvFile(filename string, entries []SimEntry) error { - - if fileExists(filename) { - log.Fatal("Output file already exists. '", filename, "'.") - } - - f, err := os.Create(filename) - if err != nil { - log.Fatal("Couldn't create hss csv file '", filename, "': ", err) - } - - _, err = f.WriteString("ICCID, IMSI, KI\n") - if err != nil { - log.Fatal("Couldn't header to hss csv file '", filename, "': ", err) - } - - max := 0 - for i, entry := range entries { - s := fmt.Sprintf("%s, %s, %s\n", entry.iccidWithChecksum, entry.imsi, entry.ki) - _, err = f.WriteString(s) - if err != nil { - log.Fatal("Couldn't write to hss csv file '", filename, "': ", err) - } - max = i + 1 - } - fmt.Println("Successfully written ", max, " sim card records.") - return f.Close() -} - diff --git a/sim-administration/sim-batch-management/outfileconversion/outfile_to_hss_input_converter_lib_test.go b/sim-administration/sim-batch-management/outfileconversion/outfile_to_hss_input_converter_lib_test.go deleted file mode 100644 index 0d6a9bda7..000000000 --- a/sim-administration/sim-batch-management/outfileconversion/outfile_to_hss_input_converter_lib_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package outfileconversion - -import ( - "gotest.tools/assert" - "testing" -) - -func testKeywordValueParser(t *testing.T) { - theMap := make(map[string]string) - ParseLineIntoKeyValueMap("ProfileType : BAR_FOOTEL_STD", theMap) - - assert.Equal(t, "BAR_FOOTEL_STD", theMap["ProfileType"]) -} - -func testReadOutputFile(t *testing.T) { - sample_output_file_name := "sample_out_file_for_testing.out" - record := ReadOutputFile(sample_output_file_name) - - // First parameter to check - assert.Equal(t, sample_output_file_name, record.Filename) - - // Check that all the header variables are there - assert.Equal(t, record.headerDescription["Customer"], "Footel") - assert.Equal(t, record.headerDescription["ProfileType"], "BAR_FOOTEL_STD") - assert.Equal(t, record.headerDescription["Order Date"], "2019092901") - assert.Equal(t, record.headerDescription["Batch No"], "2019092901") - assert.Equal(t, record.headerDescription["Quantity"], "3") - - // Check all the input variables are there - assert.Equal(t, record.inputVariables["ICCID"], "8947000000000012141") - assert.Equal(t, record.inputVariables["IMSI"], "242017100011213") - - // Check that the output entry set looks legit. - assert.Equal(t, 3, len(record.Entries)) - assert.Equal(t, 3, record.noOfEntries) -} diff --git a/sim-administration/sim-batch-management/outfileparser/outfileparser.go b/sim-administration/sim-batch-management/outfileparser/outfileparser.go new file mode 100644 index 000000000..2a93a1ef1 --- /dev/null +++ b/sim-administration/sim-batch-management/outfileparser/outfileparser.go @@ -0,0 +1,311 @@ +package outfileparser + +import ( + "bufio" + "fmt" + "github.com/ostelco/ostelco-core/sim-administration/sim-batch-management/loltelutils" + "github.com/ostelco/ostelco-core/sim-administration/sim-batch-management/model" + "github.com/ostelco/ostelco-core/sim-administration/sim-batch-management/store" + + "log" + "os" + "regexp" + "strconv" + "strings" +) + +//noinspection GoSnakeCaseUsage +const ( + initial = "initial" + headerDescription = "header_description" + inputVariables = "input_variables" + outputVariables = "output_variables" + unknownHeader = "unknown" +) + +// OutputFileRecord is a struct used to represent a parsed outputfile. +type OutputFileRecord struct { + Filename string + InputVariables map[string]string + HeaderDescription map[string]string + Entries []model.SimEntry + NoOfEntries int + OutputFileName string +} + +func parseLineIntoKeyValueMap(line string, theMap map[string]string) { + var splitString = strings.Split(line, ":") + if len(splitString) != 2 { + log.Fatalf("Unparsable colon separated key/value pair: '%s'\n", line) + } + key := strings.TrimSpace(splitString[0]) + value := strings.TrimSpace(splitString[1]) + theMap[key] = value +} + +type parserState struct { + currentState string + inputVariables map[string]string + headerDescription map[string]string + entries []model.SimEntry + csvFieldMap map[string]int +} + +func parseVarOutLine(varOutLine string, result *map[string]int) error { + varOutSplit := strings.Split(varOutLine, ":") + + if len(varOutSplit) != 2 { + return fmt.Errorf("syntax error in var_out line, more than two colon separated fields") + } + + varOutToken := strings.TrimSpace(varOutSplit[0]) + if strings.ToLower(varOutToken) != "var_out" { + return fmt.Errorf("syntax error in var_out line. Does not start with 'var_out', was '%s'", varOutToken) + } + + slashedFields := strings.Split(varOutSplit[1], "/") + for index, columnName := range slashedFields { + (*result)[columnName] = index + } + return nil +} + + +// ParseOutputFile parses an output file, returning an OutputFileRecord, contained +// a parsed version of the inputfile. +func ParseOutputFile(filePath string) (*OutputFileRecord, error) { + + if _, err := os.Stat(filePath); err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("couldn't find file '%s'", filePath) + } + return nil, fmt.Errorf("couldn't stat file '%s'", filePath) + } + + file, err := os.Open(filePath) // For read access. + if err != nil { + log.Fatal(err) + } + + defer file.Close() + + + // Implement a state machine that parses an output file. + + + state := parserState{ + currentState: initial, + inputVariables: make(map[string]string), + headerDescription: make(map[string]string), + csvFieldMap: make(map[string]int), + } + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + + // Read line, trim spaces in both ends. + line := scanner.Text() + line = strings.TrimSpace(line) + + // Is this a line we should read quickly then + // move on to the next...? + if isComment(line) { + continue + } else if isSectionHeader(line) { + nextMode := modeFromSectionHeader(line) + transitionMode(&state, nextMode) + continue + } else if line == "OUTPUT VARIABLES" { + transitionMode(&state, outputVariables) + continue + } + + // ... or should we look closer at it and parse it + // looking for real content? + + switch state.currentState { + case headerDescription: + parseLineIntoKeyValueMap(line, state.headerDescription) + case inputVariables: + if line == "var_In:" || line == "Var_In_List:" || strings.TrimSpace(line) == "" { + continue + } + + parseLineIntoKeyValueMap(line, state.inputVariables) + case outputVariables: + + line = strings.TrimSpace(line) + lowercaseLine := strings.ToLower(line) + + if strings.HasPrefix(lowercaseLine, "var_out:") { + if len(state.csvFieldMap) != 0 { + return nil, fmt.Errorf("parsing multiple 'var_out' lines can't be right") + } + if err := parseVarOutLine(line, &(state.csvFieldMap)); err != nil { + return nil, fmt.Errorf("couldn't parse output variable declaration '%s'", err) + } + continue + } + + if len(state.csvFieldMap) == 0 { + return nil, fmt.Errorf("cannot parse CSV part of input file without having first parsed a CSV header, failed when processing line '%s'", line) + } + + line = strings.TrimSpace(line) + if line == "" { + continue + } + + rawIccid, imsi, ki := parseOutputLine(state, line) + + iccidWithChecksum := rawIccid + if strings.HasSuffix(rawIccid, "F") { + iccidWithChecksum = loltelutils.TrimSuffix(rawIccid, 1) + } + + var iccidWithoutChecksum = loltelutils.TrimSuffix(iccidWithChecksum, 1) + // TODO: Check syntax of iccid with checksum. + entry := model.SimEntry{ + RawIccid: rawIccid, + IccidWithChecksum: iccidWithChecksum, + IccidWithoutChecksum: iccidWithoutChecksum, + Imsi: imsi, + Ki: ki} + state.entries = append(state.entries, entry) + + case unknownHeader: + continue + + default: + return nil, fmt.Errorf("unknown parser state '%s'", state.currentState) + } + } + + if err := scanner.Err(); err != nil { + log.Fatal(err) + } + + countedNoOfEntries := len(state.entries) + declaredNoOfEntities, err := strconv.Atoi(state.headerDescription["Quantity"]) + + if err != nil { + return nil, fmt.Errorf("could not find 'Quantity' field while parsing file '%s'", filePath) + } + + if countedNoOfEntries != declaredNoOfEntities { + return nil, fmt.Errorf("mismatch between no of entities = %d, counted number of entities = %d", + declaredNoOfEntities, + countedNoOfEntries) + } + + result := OutputFileRecord{ + Filename: filePath, + InputVariables: state.inputVariables, + HeaderDescription: state.headerDescription, + Entries: state.entries, + NoOfEntries: declaredNoOfEntities, + OutputFileName: getOutputFileName(state), + } + + return &result, nil +} + +func getOutputFileName(state parserState) string { + return "" + getCustomer(state) + "_" + getProfileType(state) + "_" + getBatchNo(state) +} + +func getBatchNo(state parserState) string { + return state.headerDescription["Batch No"] +} + +func getProfileType(state parserState) string { + return state.headerDescription["ProfileType"] +} + +func getCustomer(state parserState) string { + // TODO: Maker safe, so that it fails reliably if Customer is not in map. + // also use constant, not magic string + return state.headerDescription["Customer"] +} + +func parseOutputLine(state parserState, s string) (string, string, string) { + parsedString := strings.Split(s, " ") + return parsedString[state.csvFieldMap["ICCID"]], parsedString[state.csvFieldMap["IMSI"]], parsedString[state.csvFieldMap["KI"]] +} + +func transitionMode(state *parserState, targetState string) { + state.currentState = targetState +} + +func modeFromSectionHeader(s string) string { + sectionName := strings.Trim(s, "* ") + switch sectionName { + case "HEADER DESCRIPTION": + return headerDescription + case "INPUT VARIABLES": + return inputVariables + case "INPUT VARIABLES DESCRIPTION": + return inputVariables + case "OUTPUT VARIABLES": + return outputVariables + default: + return unknownHeader + } +} + +func isSectionHeader(s string) bool { + match, _ := regexp.MatchString("^\\*([A-Z0-9 ])+$", s) + return match +} + +func isComment(s string) bool { + match, _ := regexp.MatchString("^\\*+$", s) + return match +} + +// fileExists checks if a file exists and is not a directory before we +// try using it to prevent further errors. +func fileExists(filename string) bool { + info, err := os.Stat(filename) + if os.IsNotExist(err) { + return false + } + return !info.IsDir() +} + +// TODO: Move this into some other package. "hssoutput" or something. + + +// WriteHssCsvFile will write all sim profile instances associated to a +// batch object to a file located at filepath. +func WriteHssCsvFile(filepath string, sdb *store.SimBatchDB, batch *model.Batch) error { + + if fileExists(filepath) { + return fmt.Errorf("output file already exists. '%s'", filepath) + } + + f, err := os.Create(filepath) + if err != nil { + return fmt.Errorf("couldn't create hss csv file '%s', %v", filepath, err) + } + + if _, err = f.WriteString("ICCID, IMSI, KI\n"); err != nil { + return fmt.Errorf("couldn't header to hss csv file '%s', %v", filepath, err) + } + + entries, err := sdb.GetAllSimEntriesForBatch(batch.BatchID) + if err != nil { + return err + } + + max := 0 + for i, entry := range entries { + s := fmt.Sprintf("%s, %s, %s\n", entry.IccidWithChecksum, entry.Imsi, entry.Ki) + if _, err = f.WriteString(s); err != nil { + return fmt.Errorf("couldn't write to hss csv file '%s', %v", filepath, err) + } + max = i + 1 + } + fmt.Println("Successfully written ", max, " sim card records.") + return f.Close() +} diff --git a/sim-administration/sim-batch-management/outfileparser/outfileparser_test.go b/sim-administration/sim-batch-management/outfileparser/outfileparser_test.go new file mode 100644 index 000000000..c7d918225 --- /dev/null +++ b/sim-administration/sim-batch-management/outfileparser/outfileparser_test.go @@ -0,0 +1,76 @@ +package outfileparser + +import ( + "fmt" + "gotest.tools/assert" + "testing" +) + +func TestKeywordValueParser(t *testing.T) { + theMap := make(map[string]string) + parseLineIntoKeyValueMap("ProfileType : BAR_FOOTEL_STD", theMap) + + assert.Equal(t, "BAR_FOOTEL_STD", theMap["ProfileType"]) +} + +func TestReadingSimpleOutputFile(t *testing.T) { + sampleOutputFileName := "sample_out_file_for_testing.out" + record, err := ParseOutputFile(sampleOutputFileName) + if err != nil { + t.Error(t) + } + + // First parameter to check + assert.Equal(t, sampleOutputFileName, record.Filename) + + // Check that all the header variables are there + assert.Equal(t, record.HeaderDescription["Customer"], "Footel") + assert.Equal(t, record.HeaderDescription["ProfileType"], "BAR_FOOTEL_STD") + assert.Equal(t, record.HeaderDescription["Order Date"], "2019092901") + assert.Equal(t, record.HeaderDescription["Batch No"], "2019092901") + assert.Equal(t, record.HeaderDescription["Quantity"], "3") + + // Check all the input variables are there + assert.Equal(t, record.InputVariables["ICCID"], "8947000000000012141") + assert.Equal(t, record.InputVariables["IMSI"], "242017100011213") + + // Check that the output entry set looks legit. + assert.Equal(t, 3, len(record.Entries)) + assert.Equal(t, 3, record.NoOfEntries) +} + +func TestReadingComplexOutputFile(t *testing.T) { + sampleOutputFileName := "sample-out-2.out" + record, err := ParseOutputFile(sampleOutputFileName) + if err != nil { + t.Error(t) + } + + fmt.Println("Record = ", record) + + // TODO: Check that we got all the fields +} + +func TestParseOutputVariablesLine(t *testing.T) { + varOutLine := "var_out:ICCID/IMSI/PIN1/PUK1/PIN2/PUK2/ADM1/KI/Access_Control/Code Retailer/Code ADM/ADM2/ADM3/ADM4" + + m := make(map[string]int) + if err := parseVarOutLine(varOutLine, &m); err != nil { + t.Error("Couldn't parse var_out line:", err) + } + + assert.Equal(t, m["ICCID"], 0) + assert.Equal(t, m["IMSI"], 1) + assert.Equal(t, m["PIN1"], 2) + assert.Equal(t, m["PUK1"], 3) + assert.Equal(t, m["PIN2"], 4) + assert.Equal(t, m["PUK2"], 5) + assert.Equal(t, m["ADM1"], 6) + assert.Equal(t, m["KI"], 7) + assert.Equal(t, m["Access_Control"], 8) + assert.Equal(t, m["Code Retailer"], 9) + assert.Equal(t, m["Code ADM"], 10) + assert.Equal(t, m["ADM2"], 11) + assert.Equal(t, m["ADM3"], 12) + assert.Equal(t, m["ADM4"], 13) +} diff --git a/sim-administration/sim-batch-management/sample_out_file_for_testing.out b/sim-administration/sim-batch-management/sample_out_file_for_testing.out deleted file mode 100644 index 28f23d15e..000000000 --- a/sim-administration/sim-batch-management/sample_out_file_for_testing.out +++ /dev/null @@ -1,21 +0,0 @@ -*HEADER DESCRIPTION -*************************************** -Customer : Footel -ProfileType : BAR_FOOTEL_STD -Order Date : 2019092901 -Batch No : 2019092901 -Quantity : 3 -*************************************** -*INPUT VARIABLES -*************************************** -var_In: - ICCID: 8947000000000012141 -IMSI: 242017100011213 -*************************************** -*OUTPUT VARIABLES -*************************************** -var_Out: ICCID/IMSI/KI -8947000000000012140F 242017100011213 AC63D37F5AF4EA77E3539B2A37F51211 -8947000000000012157F 242017100011214 E397EF06A41B78D7F9E0302E763E103C -8947000000000012165F 242017100011215 F8946C451E0E87DC2D5AA4846F0C1336 - diff --git a/sim-administration/sim-batch-management/sbm.go b/sim-administration/sim-batch-management/sbm.go new file mode 100755 index 000000000..cff9f89c6 --- /dev/null +++ b/sim-administration/sim-batch-management/sbm.go @@ -0,0 +1,944 @@ +//usr/bin/env go run "$0" "$@"; exit "$?" +package main + +import ( + "bufio" + "encoding/csv" + "encoding/json" + "fmt" + "github.com/ostelco/ostelco-core/sim-administration/sim-batch-management/es2plus" + "github.com/ostelco/ostelco-core/sim-administration/sim-batch-management/fieldsyntaxchecks" + "github.com/ostelco/ostelco-core/sim-administration/sim-batch-management/model" + "github.com/ostelco/ostelco-core/sim-administration/sim-batch-management/outfileparser" + "github.com/ostelco/ostelco-core/sim-administration/sim-batch-management/store" + "github.com/ostelco/ostelco-core/sim-administration/sim-batch-management/uploadtoprime" + kingpin "gopkg.in/alecthomas/kingpin.v2" + "io" + "log" + "os" + "path/filepath" + "strings" + "sync" +) + +// "gopkg.in/alecthomas/kingpin.v2" +var ( + // TODO: Global flags can be added to Kingpin, but also make it have an effect. + // debug = kingpin.Flag("debug", "enable debug mode").Default("false").Bool() + + /// + /// Profile-vendor - centric commands + /// + // Declare a profile-vendor with an SM-DP+ that can be referred to from + // batches. Referential integrity required, so it won't be possible to + // declare bathes with non-existing profile vendors. + + dpv = kingpin.Command("profile-vendor-declare", "Declare a profile vendor with an SM-DP+ we can talk to") + dpvName = dpv.Flag("name", "Name of profile-vendor").Required().String() + dpvCertFilePath = dpv.Flag("cert", "Certificate pem file.").Required().String() + dpvKeyFilePath = dpv.Flag("key", "Certificate key file.").Required().String() + dpvHost = dpv.Flag("host", "Host of ES2+ endpoint.").Required().String() + dpvPort = dpv.Flag("port", "Port of ES2+ endpoint").Required().Int() + dpvRequesterID = dpv.Flag("requester-id", "ES2+ requester ID.").Required().String() + + /// + /// ICCID - centric commands + /// + + getStatus = kingpin.Command("iccid-get-status", "Get status for an iccid") + getStatusProfileVendor = getStatus.Flag("profile-vendor", "Name of profile vendor").Required().String() + getStatusProfileIccid = getStatus.Arg("iccid", "Iccid to get status for").Required().String() + + downloadOrder = kingpin.Command("iccid-download-order", "Execute es2p download-order.") + downloadOrderVendor = downloadOrder.Flag("profile-vendor", "Name of profile vendor").Required().String() + downloadOrderIccid = downloadOrder.Flag("iccid", "Iccid to recover profile for").Required().String() + + recoverProfile = kingpin.Command("iccid-recover-profile", "Change state of profile.") + recoverProfileVendor = recoverProfile.Flag("profile-vendor", "Name of profile vendor").Required().String() + recoverProfileIccid = recoverProfile.Flag("iccid", "Iccid to recover profile for").Required().String() + recoverProfileTarget = recoverProfile.Flag("target-state", "Desired target state").Required().String() + + cancelIccid = kingpin.Command("iccid-cancel", "Execute es2p iccid-confirm-order.") + cancelIccidIccid = cancelIccid.Flag("iccid", "The iccid to cancel").Required().String() + cancelIccidVendor = cancelIccid.Flag("profile-vendor", "Name of profile vendor").Required().String() + cancelIccidTarget = cancelIccid.Flag("target", "Tarfget t set profile to").Required().String() + + activateIccidFile = kingpin.Command("iccids-activate-from-file", "Execute es2p iccid-confirm-order.") + activateIccidFileVendor = activateIccidFile.Flag("profile-vendor", "Name of profile vendor").Required().String() + activateIccidFileFile = activateIccidFile.Flag("iccid-file", "Iccid to confirm profile for").Required().String() + + bulkActivateIccids = kingpin.Command("iccids-bulk-activate", "Execute es2p iccid-confirm-order.") + bulkActivateIccidsVendor = bulkActivateIccids.Flag("profile-vendor", "Name of profile vendor").Required().String() + bulkActivateIccidsIccids = bulkActivateIccids.Flag("iccid-file", "Iccid to confirm profile for").Required().String() + + activateIccid = kingpin.Command("iccid-activate", "Execute es2p iccid-confirm-order.") + activateIccidVendor = activateIccid.Flag("profile-vendor", "Name of profile vendor").Required().String() + activateIccidIccid = activateIccid.Flag("iccid", "Iccid to confirm profile for").Required().String() + + confirmOrder = kingpin.Command("iccid-confirm-order", "Execute es2p iccid-confirm-order.") + confirmOrderVendor = confirmOrder.Flag("profile-vendor", "Name of profile vendor").Required().String() + confirmOrderIccid = confirmOrder.Flag("iccid", "Iccid to confirm profile for").Required().String() + + /// + /// Batch - centric commands + /// + + setBatchActivationCodes = kingpin.Command("batch-activate-all-profiles", "Execute activation of all profiles in batch, get activation codes from SM-DP+ and put these codes into the local database.") + setBatchActivationCodesBatch = setBatchActivationCodes.Arg("batch-name", "Batch to get activation codes for").Required().String() + + getProfActActStatusesForBatch = kingpin.Command("batch-get-activation-statuses", "Get current activation statuses from SM-DP+ for named batch.") + getProfActActStatusesForBatchBatch = getProfActActStatusesForBatch.Arg("batch-name", "The batch to get activation statuses for.").Required().String() + + describeBatch = kingpin.Command("batch-describe", "Describe a batch with a particular name.") + describeBatchBatch = describeBatch.Arg("batch-name", "The batch to describe").String() + + generateInputFile = kingpin.Command("batch-generate-input-file", "Generate input file for a named batch using stored parameters") + generateInputFileBatchname = generateInputFile.Arg("batch-name", "The batch to generate the input file for.").String() + + addMsisdnFromFile = kingpin.Command("batch-add-msisdn-from-file", "Add MSISDN from CSV file containing at least ICCID/MSISDN, but also possibly IMSI.") + addMsisdnFromFileBatch = addMsisdnFromFile.Flag("batch-name", "The batch to augment").Required().String() + addMsisdnFromFileCsvfile = addMsisdnFromFile.Flag("csv-file", "The CSV file to read from").Required().ExistingFile() + addMsisdnFromFileAddLuhn = addMsisdnFromFile.Flag("add-luhn-checksums", "Assume that the checksums for the ICCIDs are not present, and add them").Default("false").Bool() + + bwBatch = kingpin.Command("batch-write-hss", "Generate a batch upload script") + bwBatchName = bwBatch.Arg("batch-name", "The batch to generate upload script from").String() + bwOutputDirName = bwBatch.Arg("output-dir-name", "The directory in which to place the output file.").String() + + spUpload = kingpin.Command("batch-read-out-file", "Convert an output (.out) file from an sim profile producer into an input file for an HSS.") + spBatchName = spUpload.Arg("batch-name", "The batch to augment").Required().String() + spUploadInputFile = spUpload.Arg("input-file", "path to .out file used as input file").Required().String() + + generateUploadBatch = kingpin.Command("batch-generate-upload-script", "Write a file that can be used by an HSS to insert profiles.") + generateUploadBatchBatch = generateUploadBatch.Arg("batch", "The batch to output from").Required().String() + + // TODO: Delete this asap! + // spUploadOutputFilePrefix = spUpload.Flag("output-file-prefix", + // "prefix to path to .csv file used as input file, filename will be autogenerated").Required().String() + + generateActivationCodeSQL = kingpin.Command("batch-generate-activation-code-updating-sql", "Generate SQL code to update access codes") + generateActivationCodeSQLBatch = generateActivationCodeSQL.Arg("batch-name", "The batch to generate sql coce for").String() + + bd = kingpin.Command("batch-declare", "Declare a batch to be persisted, and used by other commands") + dbName = bd.Flag("name", "Unique name of this batch").Required().String() + dbAddLuhn = bd.Flag("add-luhn-checksums", "Assume that the checksums for the ICCIDs are not present, and add them").Default("false").Bool() + dbCustomer = bd.Flag("customer", "Name of the customer of this batch (with respect to the sim profile vendor)").Required().String() + dbBatchNo = bd.Flag("batch-no", "Unique number of this batch (with respect to the profile vendor)").Required().String() + dbOrderDate = bd.Flag("order-date", "Order date in format ddmmyyyy").Required().String() + dbFirstIccid = bd.Flag("first-rawIccid", + "An 18 or 19 digit long string. The 19-th digit being a luhn Checksum digit, if present").Required().String() + dbLastIccid = bd.Flag("last-rawIccid", + "An 18 or 19 digit long string. The 19-th digit being a luhn Checksum digit, if present").Required().String() + dbFirstIMSI = bd.Flag("first-imsi", "First IMSI in batch").Required().String() + dbLastIMSI = bd.Flag("last-imsi", "Last IMSI in batch").Required().String() + dbFirstMsisdn = bd.Flag("first-msisdn", "First MSISDN in batch").Required().String() + dbLastMsisdn = bd.Flag("last-msisdn", "Last MSISDN in batch").Required().String() + dbProfileType = bd.Flag("profile-type", "SIM profile type").Required().String() + dbBatchLengthString = bd.Flag( + "batch-quantity", + "Number of sim cards in batch").Required().String() + + dbHssVendor = bd.Flag("hss-vendor", "The HSS vendor").Default("M1").String() + dbUploadHostname = bd.Flag("upload-hostname", "host to upload batch to").Default("localhost").String() + dbUploadPortnumber = bd.Flag("upload-portnumber", "port to upload to").Default("8080").String() + dbProfileVendor = bd.Flag("profile-vendor", "Vendor of SIM profiles").Default("Idemia").String() + + dbInitialHlrActivationStatusOfProfiles = bd.Flag( + "initial-hlr-activation-status-of-profiles", + "Initial hss activation state. Legal values are ACTIVATED and NOT_ACTIVATED.").Default("ACTIVATED").String() +) + +func main() { + + kingpin.Command("batches-list", "List all known batches.") + + if err := parseCommandLine(); err != nil { + panic(err) + } +} + +func parseCommandLine() error { + + db, err := store.OpenFileSqliteDatabaseFromPathInEnvironmentVariable("SIM_BATCH_DATABASE") + + if err != nil { + return fmt.Errorf("couldn't open sqlite database. '%s'", err) + } + + db.GenerateTables() + + cmd := kingpin.Parse() + switch cmd { + + case "profile-vendor-declare": + + vendor, err := db.GetProfileVendorByName(*dpvName) + if err != nil { + return err + } + + if vendor != nil { + return fmt.Errorf("already declared profile vendor '%s'", *dpvName) + } + + if _, err := os.Stat(*dpvCertFilePath); os.IsNotExist(err) { + return fmt.Errorf("can't find certificate file '%s'", *dpvCertFilePath) + } + + if _, err := os.Stat(*dpvKeyFilePath); os.IsNotExist(err) { + return fmt.Errorf("can't find key file '%s'", *dpvKeyFilePath) + } + + if *dpvPort <= 0 { + return fmt.Errorf("port must be positive was '%d'", *dpvPort) + } + + if 65534 < *dpvPort { + return fmt.Errorf("port must be smaller than or equal to 65535, was '%d'", *dpvPort) + } + + // Modify the paths to absolute paths. + + absDpvCertFilePath, err := filepath.Abs(*dpvCertFilePath) + if err != nil { + return err + } + absDpvKeyFilePath, err := filepath.Abs(*dpvKeyFilePath) + if err != nil { + return err + } + v := &model.ProfileVendor{ + Name: *dpvName, + Es2PlusCert: absDpvCertFilePath, + Es2PlusKey: absDpvKeyFilePath, + Es2PlusHost: *dpvHost, + Es2PlusPort: *dpvPort, + Es2PlusRequesterID: *dpvRequesterID, + } + + if err := db.CreateProfileVendor(v); err != nil { + return err + } + + fmt.Println("Declared a new vendor named ", *dpvName) + + case "batch-get-activation-statuses": + batchName := *getProfActActStatusesForBatchBatch + + log.Printf("Getting statuses for all profiles in batch named %s\n", batchName) + + batch, err := db.GetBatchByName(batchName) + if err != nil { + return fmt.Errorf("unknown batch '%s'", batchName) + } + + client, err := clientForVendor(db, batch.ProfileVendor) + if err != nil { + return err + } + + entries, err := db.GetAllSimEntriesForBatch(batch.BatchID) + if err != nil { + return err + } + + if len(entries) != batch.Quantity { + return fmt.Errorf("batch quantity retrieved from database (%d) different from batch quantity (%d)", len(entries), batch.Quantity) + } + + log.Printf("Found %d profiles\n", len(entries)) + + // XXX Is this really necessary? I don't think so + var mutex = &sync.Mutex{} + + var waitgroup sync.WaitGroup + + // Limit concurrency of the for-loop below + // to 160 goroutines. The reason is that if we get too + // many we run out of file descriptors, and we don't seem to + // get much speedup after hundred or so. + + concurrency := 160 + sem := make(chan bool, concurrency) + for _, entry := range entries { + + // + // Only apply activation if not already noted in the + // database. + // + + sem <- true + + waitgroup.Add(1) + go func(entry model.SimEntry) { + + defer func() { <-sem }() + + result, err := client.GetStatus(entry.Iccid) + if err != nil { + panic(err) + } + + if result == nil { + log.Printf("ERROR: Couldn't find any status for Iccid='%s'\n", entry.Iccid) + } + + mutex.Lock() + fmt.Printf("%s, %s\n", entry.Iccid, result.State) + mutex.Unlock() + waitgroup.Done() + }(entry) + } + + waitgroup.Wait() + for i := 0; i < cap(sem); i++ { + sem <- true + } + + case "batch-read-out-file": + + tx := db.Begin() + + // By default we're not cool to commit. Need to prove something first. + weCool := false + + defer func() { + if weCool { + tx.Commit() + } else { + tx.Rollback() + } + }() + + batch, err := db.GetBatchByName(*spBatchName) + + if err != nil { + tx.Rollback() + return err + } + + if batch == nil { + return fmt.Errorf("no batch found with name '%s'", *spBatchName) + } + + outRecord, err := outfileparser.ParseOutputFile(*spUploadInputFile) + if err != nil { + return err + } + + if outRecord.NoOfEntries != batch.Quantity { + return fmt.Errorf("number of records returned from outfile (%d) does not match number of profiles (%d) in batch '%s'", + outRecord.NoOfEntries, batch.Quantity, batch.Name) + } + + for _, e := range outRecord.Entries { + simProfile, err := db.GetSimProfileByIccid(e.IccidWithChecksum) + if err != nil { + return err + } + if simProfile == nil { + return fmt.Errorf("couldn't find profile enty for IMSI=%s", e.Imsi) + } + if simProfile.Imsi != e.Imsi { + return fmt.Errorf("profile enty for ICCID=%s has IMSI (%s), but we expected (%s)", e.Iccid, e.Imsi, simProfile.Imsi) + } + db.UpdateSimEntryKi(simProfile.ID, e.Ki) + } + + // Signal to defered transaction cleanup that we're cool + // with a commit. + weCool = true + + case "batch-write-hss": + + batch, err := db.GetBatchByName(*bwBatchName) + + if err != nil { + return err + } + + if batch == nil { + return fmt.Errorf("no batch found with name '%s'", *bwBatchName) + } + + outputFile := fmt.Sprintf("%s/%s.csv", *bwOutputDirName, batch.Name) + log.Println("outputFile = ", outputFile) + + if err := outfileparser.WriteHssCsvFile(outputFile, db, batch); err != nil { + return fmt.Errorf("couldn't write hss output to file '%s', . Error = '%v'", outputFile, err) + } + + case "batches-list": + allBatches, err := db.GetAllBatches() + if err != nil { + return err + } + + fmt.Println("Names of current batches: ") + for _, batch := range allBatches { + fmt.Printf(" %s\n", batch.Name) + } + + case "batch-describe": + + batch, err := db.GetBatchByName(*describeBatchBatch) + if err != nil { + return err + } + + if batch == nil { + return fmt.Errorf("no batch found with name '%s'", *describeBatchBatch) + } + + bytes, err := json.MarshalIndent(batch, " ", " ") + if err != nil { + return fmt.Errorf("can't serialize batch '%v'", batch) + } + + fmt.Printf("%v\n", string(bytes)) + + + case "batch-generate-activation-code-updating-sql": + batch, err := db.GetBatchByName(*generateActivationCodeSQLBatch) + if err != nil { + return fmt.Errorf("couldn't find batch named '%s' (%s) ", *generateActivationCodeSQLBatch, err) + } + + simEntries, err := db.GetAllSimEntriesForBatch(batch.BatchID) + if err != nil { + return err + } + + for _, b := range simEntries { + fmt.Printf( + "UPDATE sim_entries SET matchingid = '%s', smdpplusstate = 'RELEASED', provisionstate = 'AVAILABLE' WHERE Iccid = '%s' and smdpplusstate = 'AVAILABLE';\n", + b.ActivationCode, + b.Iccid) + } + + case "batch-generate-upload-script": + batch, err := db.GetBatchByName(*generateUploadBatchBatch) + if err != nil { + return err + } + + if batch == nil { + return fmt.Errorf("no batch found with name '%s'", *describeBatchBatch) + } + + var csvPayload = uploadtoprime.GenerateCsvPayload(db, *batch) + uploadtoprime.GeneratePostingCurlscript(batch.URL, csvPayload) + + case "batch-generate-input-file": + batch, err := db.GetBatchByName(*generateInputFileBatchname) + if err != nil { + return err + } + + if batch == nil { + return fmt.Errorf("no batch found with name '%s'", *generateInputFileBatchname) + } + var result = generateInputFileString(batch) + fmt.Println(result) + + case "batch-add-msisdn-from-file": + batchName := *addMsisdnFromFileBatch + csvFilename := *addMsisdnFromFileCsvfile + addLuhns := *addMsisdnFromFileAddLuhn + + batch, err := db.GetBatchByName(batchName) + if err != nil { + return err + } + + csvFile, _ := os.Open(csvFilename) + reader := csv.NewReader(bufio.NewReader(csvFile)) + + defer csvFile.Close() + + headerLine, err := reader.Read() + if err == io.EOF { + break + } else if err != nil { + return err + } + + var columnMap map[string]int + columnMap = make(map[string]int) + + for index, fieldname := range headerLine { + columnMap[strings.ToLower(fieldname)] = index + } + + if _, hasIccid := columnMap["Iccid"]; !hasIccid { + return fmt.Errorf("no ICCID column in CSV file") + } + + if _, hasMsisdn := columnMap["msisdn"]; !hasMsisdn { + return fmt.Errorf("no MSISDN column in CSV file") + } + + if _, hasImsi := columnMap["imsi"]; !hasImsi { + return fmt.Errorf("no IMSI column in CSV file") + } + + type csvRecord struct { + iccid string + imsi string + msisdn string + } + + var recordMap map[string]csvRecord + recordMap = make(map[string]csvRecord) + + // Read all the lines into the record map. + for { + line, err := reader.Read() + if err == io.EOF { + break + } else if err != nil { + log.Fatal(err) + } + + iccid := line[columnMap["Iccid"]] + + if addLuhns { + iccid = fieldsyntaxchecks.AddLuhnChecksum(iccid) + } + + record := csvRecord{ + iccid: iccid, + imsi: line[columnMap["imsi"]], + msisdn: line[columnMap["msisdn"]], + } + + if _, duplicateRecordExists := recordMap[record.iccid]; duplicateRecordExists { + return fmt.Errorf("duplicate ICCID record in map: %s", record.iccid) + } + + recordMap[record.iccid] = record + } + + simEntries, err := db.GetAllSimEntriesForBatch(batch.BatchID) + if err != nil { + return err + } + + // Check for compatibility + tx := db.Begin() + noOfRecordsUpdated := 0 + for _, entry := range simEntries { + record, iccidRecordIsPresent := recordMap[entry.Iccid] + if !iccidRecordIsPresent { + tx.Rollback() + return fmt.Errorf("ICCID not in batch: %s", entry.Iccid) + } + + if entry.Imsi != record.imsi { + tx.Rollback() + return fmt.Errorf("IMSI mismatch for ICCID=%s. Batch has %s, csv file has %s", entry.Iccid, entry.Imsi, record.iccid) + } + + if entry.Msisdn != "" && record.msisdn != "" && record.msisdn != entry.Msisdn { + tx.Rollback() + return fmt.Errorf("MSISDN mismatch for ICCID=%s. Batch has %s, csv file has %s", entry.Iccid, entry.Msisdn, record.msisdn) + } + + if entry.Msisdn == "" && record.msisdn != "" { + err = db.UpdateSimEntryMsisdn(entry.ID, record.msisdn) + if err != nil { + tx.Rollback() + return err + } + noOfRecordsUpdated++ + } + } + tx.Commit() + + log.Printf("Updated %d of a total of %d records in batch '%s'\n", noOfRecordsUpdated, len(simEntries), batchName) + + case "batch-declare": + log.Println("Declare batch") + batch, err := db.DeclareBatch( + *dbName, + *dbAddLuhn, + *dbCustomer, + *dbBatchNo, + *dbOrderDate, + *dbFirstIccid, + *dbLastIccid, + *dbFirstIMSI, + *dbLastIMSI, + *dbFirstMsisdn, + *dbLastMsisdn, + *dbProfileType, + *dbBatchLengthString, + *dbHssVendor, + *dbUploadHostname, + *dbUploadPortnumber, + *dbProfileVendor, + *dbInitialHlrActivationStatusOfProfiles) + + if err != nil { + return err + } + log.Printf("Declared batch '%s'", batch.Name) + return nil + + case "iccid-get-status": + client, err := clientForVendor(db, *getStatusProfileVendor) + if err != nil { + return err + } + + result, err := client.GetStatus(*getStatusProfileIccid) + if err != nil { + return err + } + log.Printf("Iccid='%s', state='%s', acToken='%s'\n", *getStatusProfileIccid, (*result).State, (*result).ACToken) + + case "iccid-recover-profile": + client, err := clientForVendor(db, *recoverProfileVendor) + if err != nil { + return err + } + + err = checkEs2TargetState(*recoverProfileTarget) + if err != nil { + return err + } + result, err := client.RecoverProfile(*recoverProfileIccid, *recoverProfileTarget) + if err != nil { + return err + } + log.Println("result -> ", result) + + case "iccid-download-order": + client, err := clientForVendor(db, *downloadOrderVendor) + if err != nil { + return err + } + result, err := client.DownloadOrder(*downloadOrderIccid) + if err != nil { + return err + } + log.Println("result -> ", result) + + case "iccid-confirm-order": + client, err := clientForVendor(db, *confirmOrderVendor) + if err != nil { + return err + } + result, err := client.ConfirmOrder(*confirmOrderIccid) + if err != nil { + return err + } + fmt.Println("result -> ", result) + + case "iccid-activate": + client, err := clientForVendor(db, *activateIccidVendor) + if err != nil { + return err + } + + result, err := client.ActivateIccid(*activateIccidIccid) + + if err != nil { + return err + } + fmt.Printf("%s, %s\n", *activateIccidIccid, result.ACToken) + + case "iccid-cancel": + client, err := clientForVendor(db, *cancelIccidVendor) + if err != nil { + return err + } + err = checkEs2TargetState(*cancelIccidTarget) + if err != nil { + return err + } + _, err = client.CancelOrder(*cancelIccidIccid, *cancelIccidTarget) + if err != nil { + return err + } + + case "iccids-activate-from-file": + client, err := clientForVendor(db, *activateIccidFileVendor) + if err != nil { + return err + } + + csvFilename := *activateIccidFileFile + + csvFile, _ := os.Open(csvFilename) + reader := csv.NewReader(bufio.NewReader(csvFile)) + + defer csvFile.Close() + + headerLine, error := reader.Read() + if error == io.EOF { + break + } else if error != nil { + log.Fatal(error) + } + + var columnMap map[string]int + columnMap = make(map[string]int) + + for index, fieldname := range headerLine { + columnMap[strings.TrimSpace(strings.ToLower(fieldname))] = index + } + + if _, hasIccid := columnMap["iccid"]; !hasIccid { + return fmt.Errorf("no ICCID column in CSV file") + } + + type csvRecord struct { + Iccid string + } + + var recordMap map[string]csvRecord + recordMap = make(map[string]csvRecord) + + // Read all the lines into the record map. + for { + line, err := reader.Read() + if err == io.EOF { + break + } else if err != nil { + return err + } + + iccid := line[columnMap["Iccid"]] + iccid = strings.TrimSpace(iccid) + + record := csvRecord{ + Iccid: iccid, + } + + if _, duplicateRecordExists := recordMap[record.Iccid]; duplicateRecordExists { + return fmt.Errorf("duplicate ICCID record in map: %s", record.Iccid) + } + + recordMap[record.Iccid] = record + } + + // XXX Is this really necessary? I don't think so + var mutex = &sync.Mutex{} + + var waitgroup sync.WaitGroup + + // Limit concurrency of the for-loop below + // to 160 goroutines. The reason is that if we get too + // many we run out of file descriptors, and we don't seem to + // get much speedup after hundred or so. + + concurrency := 160 + sem := make(chan bool, concurrency) + fmt.Printf("%s, %s\n", "ICCID", "STATE") + for _, entry := range recordMap { + + // + // Only apply activation if not already noted in the + // database. + // + + sem <- true + + waitgroup.Add(1) + go func(entry csvRecord) { + + defer func() { <-sem }() + + result, err := client.GetStatus(entry.Iccid) + if err != nil { + panic(err) + } + + if result == nil { + panic(fmt.Sprintf("Couldn't find any status for Iccid='%s'\n", entry.Iccid)) + } + + mutex.Lock() + fmt.Printf("%s, %s\n", entry.Iccid, result.State) + mutex.Unlock() + waitgroup.Done() + }(entry) + } + + waitgroup.Wait() + for i := 0; i < cap(sem); i++ { + sem <- true + } + + case "batch-activate-all-profiles": + + client, batch, err := clientForBatch(db, *setBatchActivationCodesBatch) + if err != nil { + return err + } + + entries, err := db.GetAllSimEntriesForBatch(batch.BatchID) + if err != nil { + return err + } + + if len(entries) != batch.Quantity { + return fmt.Errorf("batch quantity retrieved from database (%d) different from batch quantity (%d)", len(entries), batch.Quantity) + } + + // XXX Is this really necessary? I don't think so + var mutex = &sync.Mutex{} + + var waitgroup sync.WaitGroup + + // Limit concurrency of the for-loop below + // to 160 goroutines. The reason is that if we get too + // many we run out of file descriptors, and we don't seem to + // get much speedup after hundred or so. + + concurrency := 160 + sem := make(chan bool, concurrency) + tx := db.Begin() + for _, entry := range entries { + + // + // Only apply activation if not already noted in the + // database. + + if entry.ActivationCode == "" { + + sem <- true + + waitgroup.Add(1) + go func(entry model.SimEntry) { + + defer func() { <-sem }() + + result, err := client.ActivateIccid(entry.Iccid) + if err != nil { + panic(err) + } + + mutex.Lock() + fmt.Printf("%s, %s\n", entry.Iccid, result.ACToken) + db.UpdateActivationCode(entry.ID, result.ACToken) + mutex.Unlock() + waitgroup.Done() + }(entry) + } + } + + waitgroup.Wait() + for i := 0; i < cap(sem); i++ { + sem <- true + } + tx.Commit() + + case "iccids-bulk-activate": + client, err := clientForVendor(db, *bulkActivateIccidsVendor) + if err != nil { + return err + } + + file, err := os.Open(*bulkActivateIccidsIccids) + if err != nil { + log.Fatal(err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + var mutex = &sync.Mutex{} + var waitgroup sync.WaitGroup + for scanner.Scan() { + iccid := scanner.Text() + waitgroup.Add(1) + go func(i string) { + + result, err := client.ActivateIccid(i) + if err != nil { + panic(err) + } + mutex.Lock() + fmt.Printf("%s, %s\n", i, result.ACToken) + mutex.Unlock() + waitgroup.Done() + }(iccid) + } + + waitgroup.Wait() + + if err := scanner.Err(); err != nil { + log.Fatal(err) + } + + default: + return fmt.Errorf("unknown command: '%s'", cmd) + } + + return nil +} + +func checkEs2TargetState(target string) error { + if target != "AVAILABLE" { + return fmt.Errorf("target ES2+ state unexpected, legal value(s) is(are): 'AVAILABLE'") + } + + return nil +} + +/// +/// Input batch management +/// + +func generateInputFileString(batch *model.Batch) string { + result := "*HEADER DESCRIPTION\n" + + "***************************************\n" + + fmt.Sprintf("Customer : %s\n", batch.Customer) + + fmt.Sprintf("ProfileType : %s\n", batch.ProfileType) + + fmt.Sprintf("Order Date : %s\n", batch.OrderDate) + + fmt.Sprintf("Batch No : %s\n", batch.BatchNo) + + fmt.Sprintf("Quantity : %d\n", batch.Quantity) + + "***************************************\n" + + "*INPUT VARIABLES\n" + + "***************************************\n" + + "var_In:\n" + + fmt.Sprintf(" ICCID: %s\n", batch.FirstIccid) + + fmt.Sprintf("IMSI: %s\n", batch.FirstImsi) + + "***************************************\n" + + "*OUTPUT VARIABLES\n" + + "***************************************\n" + + "var_Out: ICCID/IMSI/KI\n" + return result +} + +func clientForVendor(db *store.SimBatchDB, vendorName string) (es2plus.Client, error) { + vendor, err := db.GetProfileVendorByName(vendorName) + if err != nil { + return nil, err + } + + if vendor == nil { + return nil, fmt.Errorf("unknown profile vendor '%s'", vendorName) + } + + hostport := fmt.Sprintf("%s:%d", vendor.Es2PlusHost, vendor.Es2PlusPort) + return es2plus.NewClient(vendor.Es2PlusCert, vendor.Es2PlusKey, hostport, vendor.Es2PlusRequesterID), nil +} + +func clientForBatch(db *store.SimBatchDB, batchName string) (es2plus.Client, *model.Batch, error) { + + batch, err := db.GetBatchByName(batchName) + if err != nil { + return nil, nil, err + } + if batch == nil { + return nil, nil, fmt.Errorf("unknown batch '%s'", batchName) + } + + client, err := clientForVendor(db, batch.ProfileVendor) + if err != nil { + return nil, nil, err + } + + return client, batch, nil +} diff --git a/sim-administration/sim-batch-management/store/store.go b/sim-administration/sim-batch-management/store/store.go new file mode 100644 index 000000000..1f9981269 --- /dev/null +++ b/sim-administration/sim-batch-management/store/store.go @@ -0,0 +1,603 @@ +package store + +import ( + "database/sql" + "flag" + "fmt" + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" // We need this + "github.com/ostelco/ostelco-core/sim-administration/sim-batch-management/fieldsyntaxchecks" + "github.com/ostelco/ostelco-core/sim-administration/sim-batch-management/loltelutils" + "github.com/ostelco/ostelco-core/sim-administration/sim-batch-management/model" + "log" + "os" + "strconv" + "strings" +) + +// SimBatchDB Holding database abstraction for the sim batch management system. +type SimBatchDB struct { + Db *sqlx.DB +} + +// Store is an interface used to abstract the CRUD operations on the +// various entities in the sim batch management database. +type Store interface { + GenerateTables() error + DropTables() error + + CreateBatch(theBatch *model.Batch) error + GetAllBatches(id string) ([]model.Batch, error) + GetBatchByID(id int64) (*model.Batch, error) + GetBatchByName(id string) (*model.Batch, error) + + CreateSimEntry(simEntry *model.SimEntry) error + UpdateSimEntryMsisdn(simID int64, msisdn string) + UpdateActivationCode(simID int64, activationCode string) error + UpdateSimEntryKi(simID int64, ki string) error + GetAllSimEntriesForBatch(batchID int64) ([]model.SimEntry, error) + GetSimProfileByIccid(msisdn string) (*model.SimEntry, error) + + CreateProfileVendor(*model.ProfileVendor) error + GetProfileVendorByID(id int64) (*model.ProfileVendor, error) + GetProfileVendorByName(name string) (*model.ProfileVendor, error) + + Begin() +} + +// Begin starts a transaction, returns the +// transaction objects. If for some reason the transaction +// can't be started, the functioni will terminate the program +// via panic. +func (sdb *SimBatchDB) Begin() *sql.Tx { + tx, err := sdb.Db.Begin() + if err != nil { + panic(err) + } + return tx +} + +// NewInMemoryDatabase creates a new in-memory instance of an SQLIte database +func NewInMemoryDatabase() (*SimBatchDB, error) { + db, err := sqlx.Connect("sqlite3", ":memory:") + if err != nil { + return nil, err + } + + err = db.Ping() + if err != nil { + return nil, err + } + + return &SimBatchDB{Db: db}, nil +} + +// OpenFileSqliteDatabaseFromPathInEnvironmentVariable creates +// a new instance of an SQLIte database backed by a file whose path +// is found in the named environment variable. If the +// file doesn't exist, then it is created. If the environment variable is not +// defined or empty, an error is returned. +func OpenFileSqliteDatabaseFromPathInEnvironmentVariable(variablename string) (*SimBatchDB, error) { + variableValue := strings.TrimSpace(os.Getenv(variablename)) + if variableValue == "" { + return nil, fmt.Errorf("environment variable '%s' is empty, is should contain the path to a sqlite database used by this program. The file will be created if it's not there, but the path can't be empty", variablename) + } + db, err := OpenFileSqliteDatabase(variableValue) + return db, err +} + +// OpenFileSqliteDatabase creates a new instance of an SQLIte database backed by a file. If the +// file doesn't exist, then it is created. +func OpenFileSqliteDatabase(path string) (*SimBatchDB, error) { + + /* TODO: Introduce 'debug' flag, and let that flag light up this code. + if _, err := os.Stat(path); err == nil { + log.Printf("Using database file at '%s'", path) + } else { + log.Printf("No databasefile found at '%s', will create one", path) + } + */ + + db, err := sqlx.Open("sqlite3", path) + if err != nil { + return nil, err + } + return &SimBatchDB{Db: db}, nil +} + +// GetAllBatches gets a slice containing all the batches in the database +func (sdb SimBatchDB) GetAllBatches() ([]model.Batch, error) { + //noinspection GoPreferNilSlice + result := []model.Batch{} + return result, sdb.Db.Select(&result, "SELECT * from BATCH") +} + +// GetBatchByID gets a batch identified by its datbase ID number. If nothing is found +// a nil value is returned. +func (sdb SimBatchDB) GetBatchByID(id int64) (*model.Batch, error) { + //noinspection GoPreferNilSlice + result := []model.Batch{} + if err := sdb.Db.Select(&result, "SELECT * FROM BATCH WHERE id = ?", id); err != nil { + return nil, err + } else if len(result) == 0 { + fmt.Println("returning null") + return nil, nil + } else { + return &(result[0]), nil + } +} + +// GetBatchByName gets a batch identified by its name. If nothing is found +// a nil value is returned. +func (sdb SimBatchDB) GetBatchByName(name string) (*model.Batch, error) { + //noinspection GoPreferNilSlice + result := []model.Batch{} + if err := sdb.Db.Select(&result, "select * from BATCH where name = ?", name); err != nil { + return nil, err + } else if len(result) == 0 { + return nil, nil + } else { + return &(result[0]), nil + } +} + +// CreateBatch Creates an instance of a batch in the database. Populate it with +// fields from the parameter "theBatch". No error checking +// (except uniqueness of name field) is performed on the the +// "theBatch" parameter. +func (sdb SimBatchDB) CreateBatch(theBatch *model.Batch) error { + // TODO: mutex? + + res, err := sdb.Db.NamedExec("INSERT INTO BATCH (name, filenameBase, orderDate, customer, profileType, batchNo, quantity, profileVendor) values (:name, :filenameBase, :orderDate, :customer, :profileType, :batchNo, :quantity, :profileVendor)", + theBatch, + ) + + if err != nil { + return fmt.Errorf("failed to insert new batch '%s'", err) + } + + id, err := res.LastInsertId() + if err != nil { + return fmt.Errorf("getting last inserted id failed '%s'", err) + } + theBatch.BatchID = id + + _, err = sdb.Db.NamedExec("UPDATE BATCH SET firstIccid = :firstIccid, firstImsi = :firstImsi, firstMsisdn = :firstMsisdn, msisdnIncrement = :msisdnIncrement, iccidIncrement = :iccidIncrement, imsiIncrement = :imsiIncrement, url=:url WHERE id = :id", + theBatch) + + return err +} + +// GenerateTables will, if they don't already exist, then generate the tables used by the +// store package. +func (sdb *SimBatchDB) GenerateTables() error { + s := `CREATE TABLE IF NOT EXISTS BATCH ( + id integer primary key autoincrement, + name VARCHAR NOT NULL UNIQUE, + profileVendor VARCHAR NOT NULL, + filenameBase VARCHAR, + customer VARCHAR, + profileType VARCHAR, + orderDate VARCHAR, + batchNo VARCHAR, + quantity INTEGER, + firstIccid VARCHAR, + firstImsi VARCHAR, + firstMsisdn VARCHAR, + msisdnIncrement INTEGER, + imsiIncrement INTEGER, + iccidIncrement INTEGER, + url VARCHAR)` + _, err := sdb.Db.Exec(s) + if err != nil { + return err + } + + s = `CREATE TABLE IF NOT EXISTS SIM_PROFILE ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batchID INTEGER NOT NULL, + activationCode VARCHAR NOT NULL, + imsi VARCHAR NOT NULL, + rawIccid VARCHAR NOT NULL, + iccidWithChecksum VARCHAR NOT NULL, + iccidWithoutChecksum VARCHAR NOT NULL, + iccid VARCHAR NOT NULL, + ki VARCHAR NOT NULL, + msisdn VARCHAR NOT NULL)` + _, err = sdb.Db.Exec(s) + if err != nil { + return err + } + + s = `CREATE TABLE IF NOT EXISTS PROFILE_VENDOR ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR NOT NULL UNIQUE, + es2PlusCertPath VARCHAR, + es2PlusKeyPath VARCHAR, + es2PlusHostPath VARCHAR, + es2PlusPort VARCHAR, + es2PlusRequesterId VARCHAR)` + _, err = sdb.Db.Exec(s) + + return err +} + +//CreateProfileVendor inject a new profile vendor instance into the database. +func (sdb SimBatchDB) CreateProfileVendor(theEntry *model.ProfileVendor) error { + // TODO: This insert string can be made through reflection, and at some point should be. + + vendor, _ := sdb.GetProfileVendorByName(theEntry.Name) + if vendor != nil { + return fmt.Errorf("duplicate profile vendor named %s, %v", theEntry.Name, vendor) + } + + res, err := sdb.Db.NamedExec(` + INSERT INTO PROFILE_VENDOR (name, es2PlusCertPath, es2PlusKeyPath, es2PlusHostPath, es2PlusPort, es2PlusRequesterId) + VALUES (:name, :es2PlusCertPath, :es2PlusKeyPath, :es2PlusHostPath, :es2PlusPort, :es2PlusRequesterId)`, + theEntry) + if err != nil { + return err + } + + id, err := res.LastInsertId() + if err != nil { + return fmt.Errorf("getting last inserted id failed '%s'", err) + } + theEntry.ID = id + return nil +} + +// GetProfileVendorByID find a profile vendor in the database by looking it up by name. +func (sdb SimBatchDB) GetProfileVendorByID(id int64) (*model.ProfileVendor, error) { + //noinspection GoPreferNilSlice + result := []model.ProfileVendor{} + if err := sdb.Db.Select(&result, "select * from PROFILE_VENDOR where id = ?", id); err != nil { + return nil, err + } + + if len(result) == 0 { + return nil, nil + } else { + return &result[0], nil + } +} + +// GetProfileVendorByName find a profile vendor in the database by looking it up by name. +func (sdb SimBatchDB) GetProfileVendorByName(name string) (*model.ProfileVendor, error) { + //noinspection GoPreferNilSlice + result := []model.ProfileVendor{} + if err := sdb.Db.Select(&result, "select * from PROFILE_VENDOR where name = ?", name); err != nil { + return nil, err + } + + if len(result) == 0 { + return nil, nil + } + + return &result[0], nil +} + +// CreateSimEntry persists a SimEntry instance in the database. +func (sdb SimBatchDB) CreateSimEntry(theEntry *model.SimEntry) error { + + res := sdb.Db.MustExec("INSERT INTO SIM_PROFILE (batchID, activationCode, rawIccid, iccidWithChecksum, iccidWithoutChecksum, iccid, imsi, msisdn, ki) values (?,?,?,?,?,?,?,?,?)", + theEntry.BatchID, + theEntry.ActivationCode, + theEntry.RawIccid, + theEntry.IccidWithChecksum, + theEntry.IccidWithoutChecksum, + theEntry.Iccid, + theEntry.Imsi, + theEntry.Msisdn, + theEntry.Ki, + ) + + id, err := res.LastInsertId() + if err != nil { + return fmt.Errorf("getting last inserted id failed '%s'", err) + } + theEntry.ID = id + return err +} + +// GetSimEntryByID retrieves a sim entryh instance stored in the database. If no +// matching instance can be found, nil is returned. +func (sdb SimBatchDB) GetSimEntryByID(simID int64) (*model.SimEntry, error) { + //noinspection GoPreferNilSlice + result := []model.SimEntry{} + if err := sdb.Db.Select(&result, "select * from SIM_PROFILE where id = ?", simID); err != nil { + return nil, err + } + + if len(result) == 0 { + return nil, nil + } + + return &result[0], nil +} + +// GetAllSimEntriesForBatch retrieves a sim entryh instance stored in the database. If no +// matching instance can be found, nil is returned. +func (sdb SimBatchDB) GetAllSimEntriesForBatch(batchID int64) ([]model.SimEntry, error) { + //noinspection GoPreferNilSlice + result := []model.SimEntry{} + if err := sdb.Db.Select(&result, "SELECT * from SIM_PROFILE WHERE batchID = ?", batchID); err != nil { + return nil, err + } + + if len(result) == 0 { + return nil, nil // TODO: Evaluate if this is wrong. Returning an empty slice isn't necessarily wrong. + } + return result, nil +} + +// GetSimProfileByIccid gets a sim profile from the database, return nil of one +// can't be found. +func (sdb SimBatchDB) GetSimProfileByIccid(iccid string) (*model.SimEntry, error) { + //noinspection GoPreferNilSlice + result := []model.SimEntry{} + if err := sdb.Db.Select(&result, "select * from SIM_PROFILE where iccid = ?", iccid); err != nil { + return nil, err + } + + if len(result) == 0 { + return nil, nil + } + return &result[0], nil + +} + +// GetSimProfileByImsi gets a sim profile from the database, return nil of one +// can't be found. +func (sdb SimBatchDB) GetSimProfileByImsi(imsi string) (*model.SimEntry, error) { + //noinspection GoPreferNilSlice + result := []model.SimEntry{} + if err := sdb.Db.Select(&result, "select * from SIM_PROFILE where imsi = ?", imsi); err != nil { + return nil, err + } + + if len(result) == 0 { + return nil, nil + } + return &result[0], nil +} + +// UpdateSimEntryMsisdn Sets the MSISDN field of a persisted instance of a sim entry. +func (sdb SimBatchDB) UpdateSimEntryMsisdn(simID int64, msisdn string) error { + _, err := sdb.Db.NamedExec("UPDATE SIM_PROFILE SET msisdn=:msisdn WHERE id = :simID", + map[string]interface{}{ + "simID": simID, + "msisdn": msisdn, + }) + return err +} + +// UpdateSimEntryKi Sets the Ki field of a persisted instance of a sim entry. +func (sdb SimBatchDB) UpdateSimEntryKi(simID int64, ki string) error { + _, err := sdb.Db.NamedExec("UPDATE SIM_PROFILE SET ki=:ki WHERE id = :simID", + map[string]interface{}{ + "simID": simID, + "ki": ki, + }) + return err +} + +// UpdateActivationCode Sets the activation code field of a persisted instance of a sim entry. +func (sdb SimBatchDB) UpdateActivationCode(simID int64, activationCode string) error { + _, err := sdb.Db.NamedExec("UPDATE SIM_PROFILE SET activationCode=:activationCode WHERE id = :simID", + map[string]interface{}{ + "simID": simID, + "activationCode": activationCode, + }) + return err +} + +// DropTables Drop all tables used by the store package. +func (sdb *SimBatchDB) DropTables() error { + foo := `DROP TABLE BATCH` + _, err := sdb.Db.Exec(foo) + if err != nil { + return err + } + foo = `DROP TABLE SIM_PROFILE` + _, err = sdb.Db.Exec(foo) + return err +} + +// DeclareBatch generates a batch instance by first checking all of its +// parameters, and then storing it, and finally returning it from the function. +func (sdb SimBatchDB) DeclareBatch( + name string, + addLuhn bool, + customer string, + batchNo string, + orderDate string, + firstIccid string, + lastIccid string, + firstIMSI string, + lastIMSI string, + firstMsisdn string, + lastMsisdn string, + profileType string, + batchLengthString string, + hssVendor string, + uploadHostname string, + uploadPortnumber string, + profileVendor string, + initialHlrActivationStatusOfProfiles string) (*model.Batch, error) { + + log.Println("Declaring batch ...") + + vendor, err := sdb.GetProfileVendorByName(profileVendor) + if err != nil { + return nil, err + } + if vendor == nil { + return nil, fmt.Errorf("unknown profile vendor: '%s'", profileVendor) + } + + // TODO: + // 1. Check all the arguments (methods already written). + // 2. Check that the name isn't already registred. + // 3. If it isn't, then persist it + + // + // Check parameters for syntactic correctness and + // semantic sanity. + // + if addLuhn { + firstIccid = fieldsyntaxchecks.AddLuhnChecksum(firstIccid) + lastIccid = fieldsyntaxchecks.AddLuhnChecksum(lastIccid) + } + + fieldsyntaxchecks.CheckICCIDSyntax("first-rawIccid", firstIccid) + fieldsyntaxchecks.CheckICCIDSyntax("last-rawIccid", lastIccid) + fieldsyntaxchecks.CheckIMSISyntax("last-imsi", lastIMSI) + + fieldsyntaxchecks.CheckIMSISyntax("first-imsi", firstIMSI) + fieldsyntaxchecks.CheckMSISDNSyntax("last-msisdn", lastMsisdn) + fieldsyntaxchecks.CheckMSISDNSyntax("first-msisdn", firstMsisdn) + + batchLength, err := strconv.Atoi(batchLengthString) + if err != nil { + return nil, fmt.Errorf("not a valid batch Quantity string '%s'", batchLengthString) + } + + if batchLength <= 0 { + return nil, fmt.Errorf("OutputBatch Quantity must be positive, but was '%d'", batchLength) + } + + uploadURL := fmt.Sprintf("http://%s:%s/ostelco/sim-inventory/%s/import-batch/profilevendor/%s?initialHssState=%s", + uploadHostname, uploadPortnumber, hssVendor, profileVendor, initialHlrActivationStatusOfProfiles) + + fieldsyntaxchecks.CheckURLSyntax("uploadURL", uploadURL) + fieldsyntaxchecks.CheckProfileType("profile-type", profileType) + + // Convert to integers, and get lengths + msisdnIncrement := -1 + if firstMsisdn <= lastMsisdn { + msisdnIncrement = 1 + } + + var firstMsisdnInt, _ = strconv.Atoi(firstMsisdn) + var lastMsisdnInt, _ = strconv.Atoi(lastMsisdn) + var msisdnLen = lastMsisdnInt - firstMsisdnInt + 1 + if msisdnLen < 0 { + msisdnLen = -msisdnLen + } + + var firstImsiInt, _ = strconv.Atoi(firstIMSI) + var lastImsiInt, _ = strconv.Atoi(lastIMSI) + var imsiLen = lastImsiInt - firstImsiInt + 1 + + var firstIccidInt, _ = strconv.Atoi(fieldsyntaxchecks.IccidWithoutLuhnChecksum(firstIccid)) + var lastIccidInt, _ = strconv.Atoi(fieldsyntaxchecks.IccidWithoutLuhnChecksum(lastIccid)) + var iccidlen = lastIccidInt - firstIccidInt + 1 + + // Validate that lengths of sequences are equal in absolute + // values. + // TODO: Perhaps use some varargs trick of some sort here? + if loltelutils.Abs(msisdnLen) != loltelutils.Abs(iccidlen) || loltelutils.Abs(msisdnLen) != loltelutils.Abs(imsiLen) || batchLength != loltelutils.Abs(imsiLen) { + log.Printf("msisdnLen = %10d\n", msisdnLen) + log.Printf("iccidLen = %10d\n", iccidlen) + log.Printf("imsiLen = %10d\n", imsiLen) + log.Printf("batchLength = %10d\n", batchLength) + log.Fatal("FATAL: msisdnLen, iccidLen and imsiLen are not identical.") + } + + tail := flag.Args() + if len(tail) != 0 { + return nil, fmt.Errorf("unknown parameters: %s", flag.Args()) + } + + filenameBase := fmt.Sprintf("%s%s%s", customer, orderDate, batchNo) + + batch := model.Batch{ + OrderDate: orderDate, + Customer: customer, + FilenameBase: filenameBase, + Name: name, + BatchNo: batchNo, + ProfileType: profileType, + URL: uploadURL, + Quantity: loltelutils.Abs(iccidlen), + FirstIccid: firstIccid, + IccidIncrement: loltelutils.Sign(iccidlen), + FirstImsi: firstIMSI, + ImsiIncrement: loltelutils.Sign(imsiLen), + FirstMsisdn: firstMsisdn, + MsisdnIncrement: msisdnIncrement, + ProfileVendor: profileVendor, + } + + tx := sdb.Begin() + + // This variable should be se to "true" if all transactions + // were successful, otherwise it will be rolled back. + weCool := false + + defer func() { + if weCool { + err := tx.Commit() + if err != nil { + panic(err) + } + } else { + err := tx.Rollback() + if err != nil { + panic(err) + } + } + }() + + // Persist the newly created batch, + if err = sdb.CreateBatch(&batch); err != nil { + return nil, err + } + + imsi, err := strconv.Atoi(batch.FirstImsi) + if err != nil { + return nil, err + } + + // Now create all the sim profiles + + iccidWithoutLuhnChecksum := firstIccidInt + + // XXX !!! TODO THis is wrong, but I'm doing it now, just to get started! + var msisdn, err2 = strconv.Atoi(batch.FirstMsisdn) + if err2 != nil { + return nil, err + } + + for i := 0; i < batch.Quantity; i++ { + + iccidWithLuhnChecksum := fmt.Sprintf("%d%d", iccidWithoutLuhnChecksum, fieldsyntaxchecks.LuhnChecksum(iccidWithoutLuhnChecksum)) + + simEntry := &model.SimEntry{ + BatchID: batch.BatchID, + ActivationCode: "", + RawIccid: fmt.Sprintf("%d", iccidWithoutLuhnChecksum), + IccidWithChecksum: iccidWithLuhnChecksum, + IccidWithoutChecksum: fmt.Sprintf("%d", iccidWithoutLuhnChecksum), + Iccid: iccidWithLuhnChecksum, + Imsi: fmt.Sprintf("%d", imsi), + Msisdn: fmt.Sprintf("%d", msisdn), + Ki: "", // Should be null + } + + if err = sdb.CreateSimEntry(simEntry); err != nil { + return nil, err + } + + iccidWithoutLuhnChecksum += batch.IccidIncrement + imsi += batch.ImsiIncrement + msisdn += batch.MsisdnIncrement + } + + // Signal to deferred function that we're ready to commit. + weCool = true + + // Return the newly created batch + return &batch, err +} diff --git a/sim-administration/sim-batch-management/store/store_test.go b/sim-administration/sim-batch-management/store/store_test.go new file mode 100644 index 000000000..e165f7c1d --- /dev/null +++ b/sim-administration/sim-batch-management/store/store_test.go @@ -0,0 +1,333 @@ +package store + +import ( + "fmt" + _ "github.com/mattn/go-sqlite3" + "github.com/ostelco/ostelco-core/sim-administration/sim-batch-management/model" + "gotest.tools/assert" + "os" + "reflect" + "testing" +) + +var ( + sdb *SimBatchDB + sdbSetupError error +) + +func TestMain(m *testing.M) { + setup() + code := m.Run() + shutdown() + os.Exit(code) +} + +func setup() { + + // In memory database fails, so we try this gonzo method of getting + // a fresh database + filename := "bazunka.db" + + // delete file, ignore any errors + _ = os.Remove(filename) + + sdb, sdbSetupError = + OpenFileSqliteDatabase(filename) + + if sdbSetupError != nil { + panic(fmt.Errorf("Couldn't open new in memory database '%s", sdbSetupError)) + } + + if sdb == nil { + panic("Returned null database object") + } + + if sdbSetupError = sdb.GenerateTables(); sdbSetupError != nil { + panic(fmt.Sprintf("Couldn't generate tables '%s'", sdbSetupError)) + } + + cleanTables() +} + +func cleanTables() { + fmt.Println("Cleaning tables ...") + _, err := sdb.Db.Exec("DELETE FROM SIM_PROFILE") + if err != nil { + panic(fmt.Sprintf("Couldn't delete SIM_PROFILE '%s'", err)) + } + + _, err = sdb.Db.Exec("DELETE FROM BATCH") + if err != nil { + panic(fmt.Sprintf("Couldn't delete BATCH '%s'", err)) + } + + _, err = sdb.Db.Exec("DELETE FROM PROFILE_VENDOR") + if err != nil { + panic(fmt.Sprintf("Couldn't delete PROFILE_VENDOR '%s'", err)) + } + fmt.Println(" Cleaned tables ...") + + vendor, _ := sdb.GetProfileVendorByName("Durian") + if vendor != nil { + fmt.Println(" Unclean Durian detected") + } + +} + +func shutdown() { + cleanTables() + if err := sdb.DropTables(); err != nil { + panic(fmt.Sprintf("Couldn't drop tables '%s'", err)) + } + _ = sdb.Db.Close() +} + +// ... just to know that everything is sane. +func TestMemoryDbPing(t *testing.T) { + if err := sdb.Db.Ping(); err != nil { + t.Errorf("Could not ping in-memory database. '%s'", err) + } +} + +func injectTestBatch() *model.Batch { + theBatch := model.Batch{ + Name: "SOME UNIQUE NAME", + OrderDate: "20200101", + Customer: "firstInputBatch", + ProfileType: "banana", + BatchNo: "100", + Quantity: 100, + URL: "http://vg.no", + FirstIccid: "1234567890123456789", + FirstImsi: "123456789012345", + ProfileVendor: "Durian", + MsisdnIncrement: -1, + } + + batch, _ := sdb.GetBatchByName(theBatch.Name) + if batch != nil { + panic(fmt.Errorf("duplicate batch detected '%s'", theBatch.Name)) + } + + err := sdb.CreateBatch(&theBatch) + if err != nil { + panic(err) + } + return &theBatch +} + +func TestGetBatchByID(t *testing.T) { + + fmt.Print("TestGetBatchByID starts") + cleanTables() + batch, _ := sdb.GetBatchByName("SOME UNIQUE NAME") + if batch != nil { + t.Errorf("Duplicate detected, error in test setup") + } + + // Inject a sample batch + injectTestprofileVendor(t) + theBatch := injectTestBatch() + + batchByID, _ := sdb.GetBatchByID(theBatch.BatchID) + if !reflect.DeepEqual(batchByID, theBatch) { + + t.Logf("theBatch = %v\n", theBatch) + t.Logf("batchByID = %v\n", batchByID) + t.Errorf("getBatchByID failed") + } +} + +func TestGetAllBatches(t *testing.T) { + + cleanTables() + + allBatches, err := sdb.GetAllBatches() + if err != nil { + t.Errorf("Reading query failed '%s'", err) + } + + assert.Equal(t, len(allBatches), 0) + + theBatch := injectTestBatch() + + allBatches, err = sdb.GetAllBatches() + if err != nil { + t.Errorf("Reading query failed '%s'", err) + } + + assert.Equal(t, len(allBatches), 1) + + firstInputBatch := allBatches[0] + if !reflect.DeepEqual(firstInputBatch, *theBatch) { + t.Errorf("getBatchById failed, returned batch not equal to initial batch") + } +} + +//noinspection GoUnusedParameter +func declareTestBatch(t *testing.T) *model.Batch { + + theBatch, err := sdb.DeclareBatch( + "Name", + false, + "Customer", + "8778fsda", // batch number + "20200101", // date string + "89148000000745809013", // firstIccid string, + "89148000000745809013", // lastIccid string, + "242017100012213", // firstIMSI string, + "242017100012213", // lastIMSI string, + "47900184", // firstMsisdn string, + "47900184", // lastMsisdn string, + "BAR_FOOTEL_STD", //profileType string, + "1", // batchLengthString string, + "LOL", // hssVendor string, + "localhost", // uploadHostname string, + "8088", // uploadPortnumber string, + "Durian", // profileVendor string, + "ACTIVE") // initialHlrActivationStatusOfProfiles string + + if err != nil { + panic(err) + } + return theBatch +} + +func TestDeclareBatch(t *testing.T) { + injectTestprofileVendor(t) + theBatch := declareTestBatch(t) + + retrievedValue, _ := sdb.GetBatchByID(theBatch.BatchID) + if retrievedValue == nil { + t.Fatalf("Null retrievedValue") + } + if !reflect.DeepEqual(retrievedValue, theBatch) { + t.Fatal("getBatchById failed, stored batch not equal to retrieved batch") + } + + retrievedEntries, err := sdb.GetAllSimEntriesForBatch(theBatch.BatchID) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, 1, len(retrievedEntries)) + + // TODO: Add check for content of retrieved entity +} + +//noinspection GoUnusedParameter +func injectTestprofileVendor(t *testing.T) *model.ProfileVendor { + v := &model.ProfileVendor{ + Name: "Durian", + Es2PlusCert: "cert", + Es2PlusKey: "key", + Es2PlusHost: "host", + Es2PlusPort: 4711, + Es2PlusRequesterID: "1.2.3", + } + + if err := sdb.CreateProfileVendor(v); err != nil { + panic(err) + // t.Fatal(err) + } + return v +} + +func TestDeclareAndRetrieveProfileVendorEntry(t *testing.T) { + cleanTables() + + v := injectTestprofileVendor(t) + + nameRetrievedVendor, err := sdb.GetProfileVendorByName(v.Name) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(nameRetrievedVendor, v) { + t.Fatalf("name retrieved and stored profile vendor entries are different, %v v.s. %v", nameRetrievedVendor, v) + } + + idRetrievedVendor, err := sdb.GetProfileVendorByID(v.ID) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(idRetrievedVendor, v) { + t.Fatalf("name retrieved and stored profile vendor entries are different, %v v.s. %v", idRetrievedVendor, v) + } +} + +func TestDeclareAndRetrieveSimEntries(t *testing.T) { + cleanTables() + injectTestprofileVendor(t) + theBatch := declareTestBatch(t) + batchID := theBatch.BatchID + + entry := model.SimEntry{ + BatchID: batchID, + RawIccid: "1", + IccidWithChecksum: "2", + IccidWithoutChecksum: "3", + Iccid: "4", + Imsi: "5", + Msisdn: "6", + Ki: "7", + ActivationCode: "8", + } + + err := sdb.CreateSimEntry(&entry) + if err != nil { + t.Fatal(err) + } + assert.Assert(t, entry.ID != 0) + + retrivedEntry, err := sdb.GetSimEntryByID(entry.ID) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(retrivedEntry, &entry) { + t.Fatal("Retrieved and stored sim entry are different") + } +} + +func TestSimBatchDB_UpdateSimEntryKi(t *testing.T) { + cleanTables() + injectTestprofileVendor(t) + theBatch := declareTestBatch(t) + batchID := theBatch.BatchID + + entry := model.SimEntry{ + BatchID: batchID, + RawIccid: "1", + IccidWithChecksum: "2", + IccidWithoutChecksum: "3", + Iccid: "4", + Imsi: "5", + Msisdn: "6", + Ki: "7", + ActivationCode: "8", + } + + err := sdb.CreateSimEntry(&entry) + if err != nil { + t.Fatal(err) + } + + assert.Assert(t, entry.ID != 0) + + newKi := "12" + err = sdb.UpdateSimEntryKi(entry.ID, newKi) + + if err != nil { + t.Fatal(err) + } + + retrivedEntry, err := sdb.GetSimEntryByID(entry.ID) + if err != nil { + t.Fatal(err) + } + + if retrivedEntry.Ki != "12" { + t.Fatalf("Retrieved (%s) and stored (%s) ki values are different", retrivedEntry.Ki, newKi) + } +} diff --git a/sim-administration/sim-batch-management/upload-sim-batch.go b/sim-administration/sim-batch-management/upload-sim-batch.go deleted file mode 100755 index 71e5b93b2..000000000 --- a/sim-administration/sim-batch-management/upload-sim-batch.go +++ /dev/null @@ -1,15 +0,0 @@ -//usr/bin/env go run "$0" "$@"; exit "$?" - -package main - -import "github.com/ostelco/ostelco-core/sim-administration/sim-batch-management/uploadtoprime" - -func main() { - batch := uploadtoprime.ParseUploadFileGeneratorCommmandline() - - // TODO: Combine these two into something inside uploadtoprime. - // It's unecessary to break the batch thingy open in this way. - var csvPayload = uploadtoprime.GenerateCsvPayload(batch) - - uploadtoprime.GeneratePostingCurlscript(batch.Url, csvPayload) -} \ No newline at end of file diff --git a/sim-administration/sim-batch-management/uploadtoprime/upload-sim-batch-lib.go b/sim-administration/sim-batch-management/uploadtoprime/upload-sim-batch-lib.go deleted file mode 100755 index bdb069639..000000000 --- a/sim-administration/sim-batch-management/uploadtoprime/upload-sim-batch-lib.go +++ /dev/null @@ -1,331 +0,0 @@ -//usr/bin/env go run "$0" "$@"; exit "$?" - -// XXX This is an utility script to feed the prime with sim profiles. -// it is actually a much better idea to extend the import functionality of -// prime to generate sequences and checksums, but that will require a major -// extension of a program that is soon going into production, so I'm keeping this -// complexity external for now. However, the existance of this program should be -// considered technical debt, and the debt can be paid back e.g. by -// internalizing the logic into prime. - -package uploadtoprime - -import ( - "flag" - "fmt" - "github.com/ostelco/ostelco-core/sim-administration/sim-batch-management/loltelutils" - "log" - "net/url" - "regexp" - "strconv" - "strings" -) - -func GeneratePostingCurlscript(url string, payload string) { - fmt.Printf("#!/bin/bash\n") - - fmt.Printf("curl -H 'Content-Type: text/plain' -X PUT --data-binary @- %s < -1; i-- { - t, _ := strconv.ParseInt(source[i], 10, 8) - n := int(t) - - if double { - n = n * 2 - } - - double = !double - - if n >= 10 { - n = n - 9 - } - - checksum += n - } - return checksum -} - -func LuhnChecksum(number int) int { - return generateControlDigit(strconv.Itoa(number)) -} - -func GenerateCsvPayload(batch OutputBatch) string { - var sb strings.Builder - sb.WriteString("ICCID, IMSI, MSISDN, PIN1, PIN2, PUK1, PUK2, PROFILE\n") - - var iccidWithoutLuhnChecksum = batch.firstIccid - - var imsi = batch.firstImsi - var msisdn = batch.firstMsisdn - for i := 0; i < batch.length; i++ { - - iccid := fmt.Sprintf("%d%1d", iccidWithoutLuhnChecksum, LuhnChecksum(iccidWithoutLuhnChecksum)) - line := fmt.Sprintf("%s, %d, %d,,,,,%s\n", iccid, imsi, msisdn, batch.profileType) - sb.WriteString(line) - - iccidWithoutLuhnChecksum += batch.iccidIncrement - imsi += batch.imsiIncrement - msisdn += batch.msisdnIncrement - } - - return sb.String() -} - -func isICCID(s string) bool { - match, _ := regexp.MatchString("^\\d{18}\\d?\\d?$", s) - return match -} - -func checkICCIDSyntax(name string, potentialIccid string) { - if !isICCID(potentialIccid) { - log.Fatalf("Not a valid %s ICCID: '%s'. Must be 18 or 19 (or 20) digits (_including_ luhn checksum).", name, potentialIccid) - } - - stringWithoutLuhnChecksum := IccidWithoutLuhnChecksum(potentialIccid) - controlDigit := generateControlDigit(stringWithoutLuhnChecksum) - checksummedCandidate := fmt.Sprintf("%s%d", stringWithoutLuhnChecksum, controlDigit) - if checksummedCandidate != potentialIccid { - log.Fatalf("Not a valid ICCID: '%s'. Expected luhn checksom '%d'", potentialIccid, controlDigit) - } -} - -func isIMSI(s string) bool { - match, _ := regexp.MatchString("^\\d{15}$", s) - return match -} - -func checkIMSISyntax(name string, potentialIMSI string) { - if !isIMSI(potentialIMSI) { - log.Fatalf("Not a valid %s IMSI: '%s'. Must be 15 digits.", name, potentialIMSI) - } -} - -func isMSISDN(s string) bool { - match, _ := regexp.MatchString("^\\d+$", s) - return match -} - -func checkMSISDNSyntax(name string, potentialMSISDN string) { - if !isMSISDN(potentialMSISDN) { - log.Fatalf("Not a valid %s MSISDN: '%s'. Must be non-empty sequence of digits.", name, potentialMSISDN) - } -} - -func checkURLSyntax(name string, theUrl string) { - _, err := url.ParseRequestURI(theUrl) - if err != nil { - log.Fatalf("Not a valid %s URL: '%s'.", name, theUrl) - } -} - -func isProfileName(s string) bool { - match, _ := regexp.MatchString("^[A-Z][A-Z0-9_]*$", s) - return match -} - -func checkProfileType(name string, potentialProfileName string) { - if !isProfileName(potentialProfileName) { - log.Fatalf("Not a valid %s MSISDN: '%s'. Must be uppercase characters, numbers and underscores. ", name, potentialProfileName) - } -} - -type OutputBatch struct { - profileType string - Url string - length int - firstMsisdn int - msisdnIncrement int - firstIccid int - iccidIncrement int - firstImsi int - imsiIncrement int -} - -func IccidWithoutLuhnChecksum(s string) string { - return loltelutils.TrimSuffix(s, 1) -} - -func ParseUploadFileGeneratorCommmandline() OutputBatch { - - // - // Set up command line parsing - // - firstIccid := flag.String("first-rawIccid", - "not a valid rawIccid", - "An 18 or 19 digit long string. The 19-th digit being a luhn luhnChecksum digit, if present") - lastIccid := flag.String("last-rawIccid", - "not a valid rawIccid", - "An 18 or 19 digit long string. The 19-th digit being a luhn luhnChecksum digit, if present") - firstIMSI := flag.String("first-imsi", "Not a valid IMSI", "First IMSI in batch") - lastIMSI := flag.String("last-imsi", "Not a valid IMSI", "Last IMSI in batch") - firstMsisdn := flag.String("first-msisdn", "Not a valid MSISDN", "First MSISDN in batch") - lastMsisdn := flag.String("last-msisdn", "Not a valid MSISDN", "Last MSISDN in batch") - profileType := flag.String("profile-type", "Not a valid sim profile type", "SIM profile type") - batchLengthString := flag.String( - "batch-quantity", - "Not a valid batch-quantity, must be an integer", - "Number of sim cards in batch") - - // XXX Legal values are Loltel and M1 at this time, how to configure that - // flexibly? Eventually by puttig them in a database and consulting it during - // command execution, but for now, just by squinting. - - hssVendor := flag.String("hss-vendor", "M1", "The HSS vendor") - uploadHostname := - flag.String("upload-hostname", "localhost", "host to upload batch to") - uploadPortnumber := - flag.String("upload-portnumber", "8080", "port to upload to") - - profileVendor := - flag.String("profile-vendor", "Idemia", "Vendor of SIM profiles") - - initialHlrActivationStatusOfProfiles := - flag.String( - "initial-hlr-activation-status-of-profiles", - "ACTIVATED", - "Initial hss activation state. Legal values are ACTIVATED and NOT_ACTIVATED.") - - // - // Parse input according to spec above - // - flag.Parse() - - // - // Check parameters for syntactic correctness and - // semantic sanity. - // - - checkICCIDSyntax("first-rawIccid", *firstIccid) - checkICCIDSyntax("last-rawIccid", *lastIccid) - checkIMSISyntax("last-imsi", *lastIMSI) - checkIMSISyntax("first-imsi", *firstIMSI) - checkMSISDNSyntax("last-msisdn", *lastMsisdn) - checkMSISDNSyntax("first-msisdn", *firstMsisdn) - - batchLength, err := strconv.Atoi(*batchLengthString) - if err != nil { - log.Fatalf("Not a valid batch quantity string '%s'.\n", *batchLengthString) - } - - if batchLength <= 0 { - log.Fatalf("OutputBatch quantity must be positive, but was '%d'", batchLength) - } - - uploadUrl := fmt.Sprintf("http://%s:%s/ostelco/sim-inventory/%s/import-batch/profilevendor/%s?initialHssState=%s", - *uploadHostname, *uploadPortnumber, *hssVendor, *profileVendor, *initialHlrActivationStatusOfProfiles) - - checkURLSyntax("uploadUrl", uploadUrl) - checkProfileType("profile-type", *profileType) - - // Convert to integers, and get lengths - msisdnIncrement := -1 - if *firstMsisdn <= *lastMsisdn { - msisdnIncrement = 1 - } - - log.Println("firstmsisdn = ", *firstMsisdn) - log.Println("lastmsisdn = ", *lastMsisdn) - log.Println("msisdnIncrement = ", msisdnIncrement) - - var firstMsisdnInt, _ = strconv.Atoi(*firstMsisdn) - var lastMsisdnInt, _ = strconv.Atoi(*lastMsisdn) - var msisdnLen = lastMsisdnInt - firstMsisdnInt + 1 - if msisdnLen < 0 { - msisdnLen = -msisdnLen - } - - var firstImsiInt, _ = strconv.Atoi(*firstIMSI) - var lastImsiInt, _ = strconv.Atoi(*lastIMSI) - var imsiLen = lastImsiInt - firstImsiInt + 1 - - var firstIccidInt, _ = strconv.Atoi(IccidWithoutLuhnChecksum(*firstIccid)) - var lastIccidInt, _ = strconv.Atoi(IccidWithoutLuhnChecksum(*lastIccid)) - var iccidlen = lastIccidInt - firstIccidInt + 1 - - // Validate that lengths of sequences are equal in absolute - // values. - // TODO: Perhaps use some varargs trick of some sort here? - if loltelutils.Abs(msisdnLen) != loltelutils.Abs(iccidlen) || loltelutils.Abs(msisdnLen) != loltelutils.Abs(imsiLen) || batchLength != loltelutils.Abs(imsiLen) { - log.Printf("msisdnLen = %10d\n", msisdnLen) - log.Printf("iccidLen = %10d\n", iccidlen) - log.Printf("imsiLen = %10d\n", imsiLen) - log.Fatal("FATAL: msisdnLen, iccidLen and imsiLen are not identical.") - } - - tail := flag.Args() - if len(tail) != 0 { - log.Printf("Unknown parameters: %s", flag.Args()) - } - - // Return a correctly parsed batch - return OutputBatch{ - profileType: *profileType, - Url: uploadUrl, - length: loltelutils.Abs(iccidlen), - firstIccid: firstIccidInt, - iccidIncrement: loltelutils.Sign(iccidlen), - firstImsi: firstImsiInt, - imsiIncrement: loltelutils.Sign(imsiLen), - firstMsisdn: firstMsisdnInt, - msisdnIncrement: msisdnIncrement, - } -} - -/// -/// Input batch management -/// - -type InputBatch struct { - customer string - profileType string - orderDate string - batchNo string - quantity int - firstIccid int - firstImsi int -} - -func ParseInputFileGeneratorCommmandline() InputBatch { - // TODO: This function should be rewritten to parse a string array and send it to flags. - // we need to up our Go-Fu before we can make flag.Parse(arguments) work - - return InputBatch{customer: "Footel", profileType: "BAR_FOOTEL_STD", orderDate: "20191007", batchNo: "2019100701", quantity: 10, firstIccid: 894700000000002214, firstImsi: 242017100012213} -} - -func GenerateInputFile(batch InputBatch) string { - result := "*HEADER DESCRIPTION\n" + - "***************************************\n" + - fmt.Sprintf("Customer :%s\n", batch.customer) + - fmt.Sprintf("ProfileType : %s\n", batch.profileType) + - fmt.Sprintf("Order Date : %s\n", batch.orderDate) + - fmt.Sprintf("Batch No : %s\n", batch.batchNo) + - fmt.Sprintf("Quantity : %d\n", batch.quantity) + - "***************************************\n" + - "*INPUT VARIABLES\n" + - "***************************************\n" + - "var_In:\n" + - fmt.Sprintf(" ICCID: %d\n", batch.firstIccid) + - fmt.Sprintf("IMSI: %d\n", batch.firstImsi) + - "***************************************\n" + - "*OUTPUT VARIABLES\n" + - "***************************************\n" + - "var_Out: ICCID/IMSI/KI\n" - return result -} diff --git a/sim-administration/sim-batch-management/uploadtoprime/uploadtoprime.go b/sim-administration/sim-batch-management/uploadtoprime/uploadtoprime.go new file mode 100755 index 000000000..b3970ebc7 --- /dev/null +++ b/sim-administration/sim-batch-management/uploadtoprime/uploadtoprime.go @@ -0,0 +1,37 @@ +package uploadtoprime + +import ( + "fmt" + "github.com/ostelco/ostelco-core/sim-administration/sim-batch-management/model" + "github.com/ostelco/ostelco-core/sim-administration/sim-batch-management/store" + "strings" +) + + +// GeneratePostingCurlscript print on standard output a bash script +// that can be used to upload the payload to an url. +func GeneratePostingCurlscript(url string, payload string) { + fmt.Printf("#!/bin/bash\n") + + fmt.Printf("curl -H 'Content-Type: text/plain' -X PUT --data-binary @- %s <