chibenwa commented on a change in pull request #752: URL: https://github.com/apache/james-project/pull/752#discussion_r751028057
########## File path: server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/IdentitySetContract.scala ########## @@ -0,0 +1,618 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.contract + +import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT +import io.restassured.RestAssured.{`given`, requestSpecification} +import io.restassured.http.ContentType.JSON +import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson +import org.apache.http.HttpStatus.SC_OK +import org.apache.james.GuiceJamesServer +import org.apache.james.jmap.core.ResponseObject.SESSION_STATE +import org.apache.james.jmap.core.UuidState.INSTANCE +import org.apache.james.jmap.http.UserCredential +import org.apache.james.jmap.rfc8621.contract.Fixture.{ACCEPT_RFC8621_VERSION_HEADER, BOB, BOB_PASSWORD, DOMAIN, authScheme, baseRequestSpecBuilder} +import org.apache.james.utils.DataProbeImpl +import org.junit.jupiter.api.{BeforeEach, Test} + +trait IdentitySetContract { + @BeforeEach + def setUp(server: GuiceJamesServer): Unit = { + server.getProbe(classOf[DataProbeImpl]) + .fluent + .addDomain(DOMAIN.asString) + .addDomain("domain-alias.tld") + .addUser(BOB.asString, BOB_PASSWORD) + + requestSpecification = baseRequestSpecBuilder(server) + .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD))) + .build + } + + @Test + def setIdentityShouldSucceed(): Unit = { + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f29": { + | "name": "Bob", + | "email": "[email protected]", + | "replyTo": [{ + | "name": "Alice", + | "email": "[email protected]" + | }], + | "bcc": [{ + | "name": "David", + | "email": "[email protected]" + | }], + | "textSignature": "Some text signature", + | "htmlSignature": "<p>Some html signature</p>" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s"""{ + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "${INSTANCE.serialize}", + | "created": { + | "4f29": { + | + | } Review comment: `mayDelete` is a server set property and should be returned. `id` is a server set property and should thus be also returned. ########## File path: server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/IdentitySetContract.scala ########## @@ -0,0 +1,618 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.contract + +import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT +import io.restassured.RestAssured.{`given`, requestSpecification} +import io.restassured.http.ContentType.JSON +import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson +import org.apache.http.HttpStatus.SC_OK +import org.apache.james.GuiceJamesServer +import org.apache.james.jmap.core.ResponseObject.SESSION_STATE +import org.apache.james.jmap.core.UuidState.INSTANCE +import org.apache.james.jmap.http.UserCredential +import org.apache.james.jmap.rfc8621.contract.Fixture.{ACCEPT_RFC8621_VERSION_HEADER, BOB, BOB_PASSWORD, DOMAIN, authScheme, baseRequestSpecBuilder} +import org.apache.james.utils.DataProbeImpl +import org.junit.jupiter.api.{BeforeEach, Test} + +trait IdentitySetContract { + @BeforeEach + def setUp(server: GuiceJamesServer): Unit = { + server.getProbe(classOf[DataProbeImpl]) + .fluent + .addDomain(DOMAIN.asString) + .addDomain("domain-alias.tld") + .addUser(BOB.asString, BOB_PASSWORD) + + requestSpecification = baseRequestSpecBuilder(server) + .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD))) + .build + } + + @Test + def setIdentityShouldSucceed(): Unit = { + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f29": { + | "name": "Bob", + | "email": "[email protected]", + | "replyTo": [{ + | "name": "Alice", + | "email": "[email protected]" + | }], + | "bcc": [{ + | "name": "David", + | "email": "[email protected]" + | }], + | "textSignature": "Some text signature", + | "htmlSignature": "<p>Some html signature</p>" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s"""{ + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "${INSTANCE.serialize}", + | "created": { + | "4f29": { + | + | } + | } + |}""".stripMargin) + } + + @Test + def setIdentityWithSomePropertiesOmittedShouldSucceedAndReturnDefaultValues(): Unit = { + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f29": { + | "email": "[email protected]" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s"""{ + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "${INSTANCE.serialize}", + | "created": { + | "4f29": { + | "name": "", + | "textSignature": "", + | "htmlSignature": "" Review comment: `mayDelete` is a server set property and should be returned. `id` is a server set property and should thus be also returned. ########## File path: server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/IdentitySetContract.scala ########## @@ -0,0 +1,618 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.contract + +import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT +import io.restassured.RestAssured.{`given`, requestSpecification} +import io.restassured.http.ContentType.JSON +import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson +import org.apache.http.HttpStatus.SC_OK +import org.apache.james.GuiceJamesServer +import org.apache.james.jmap.core.ResponseObject.SESSION_STATE +import org.apache.james.jmap.core.UuidState.INSTANCE +import org.apache.james.jmap.http.UserCredential +import org.apache.james.jmap.rfc8621.contract.Fixture.{ACCEPT_RFC8621_VERSION_HEADER, BOB, BOB_PASSWORD, DOMAIN, authScheme, baseRequestSpecBuilder} +import org.apache.james.utils.DataProbeImpl +import org.junit.jupiter.api.{BeforeEach, Test} + +trait IdentitySetContract { + @BeforeEach + def setUp(server: GuiceJamesServer): Unit = { + server.getProbe(classOf[DataProbeImpl]) + .fluent + .addDomain(DOMAIN.asString) + .addDomain("domain-alias.tld") + .addUser(BOB.asString, BOB_PASSWORD) + + requestSpecification = baseRequestSpecBuilder(server) + .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD))) + .build + } + + @Test + def setIdentityShouldSucceed(): Unit = { + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f29": { + | "name": "Bob", + | "email": "[email protected]", + | "replyTo": [{ + | "name": "Alice", + | "email": "[email protected]" + | }], + | "bcc": [{ + | "name": "David", + | "email": "[email protected]" + | }], + | "textSignature": "Some text signature", + | "htmlSignature": "<p>Some html signature</p>" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s"""{ + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "${INSTANCE.serialize}", + | "created": { + | "4f29": { + | + | } + | } + |}""".stripMargin) + } + + @Test + def setIdentityWithSomePropertiesOmittedShouldSucceedAndReturnDefaultValues(): Unit = { + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f29": { + | "email": "[email protected]" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s"""{ + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "${INSTANCE.serialize}", + | "created": { + | "4f29": { + | "name": "", + | "textSignature": "", + | "htmlSignature": "" + | } + | } + |}""".stripMargin) + } + + @Test + def setIdentityShouldCreatedSeveralValidCreationRequest(): Unit = { + val request: String = + """{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f28": { + | "name": "Identity1", + | "email": "[email protected]", + | "replyTo": [{ + | "name": "Alice", + | "email": "[email protected]" + | }], + | "bcc": [{ + | "name": "David", + | "email": "[email protected]" + | }], + | "textSignature": "Some text signature", + | "htmlSignature": "<p>Some html signature</p>" + | }, + | "4f29": { + | "name": "Identity2", + | "email": "[email protected]", + | "replyTo": null, + | "bcc": null, + | "textSignature": "Some text signature", + | "htmlSignature": "<p>Some html signature</p>" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response: String = `given` + .body(request) + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s""" { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "created": { + | "4f28": { + | + | }, + | "4f29": { + | + | } + | } + | }""".stripMargin) + } + + @Test + def setIdentityShouldReturnForbiddenFromErrorWhenForbiddenEmailProperty(): Unit = { + val request: String = + """{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f28": { + | "name": "valid send from identity", + | "email": "[email protected]" + | }, + | "4f29": { + | "name": "forbidden send from identity", + | "email": "[email protected]" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response: String = `given` + .body(request) + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s""" { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "created": { + | "4f28": { + | "textSignature": "", + | "htmlSignature": "" + | } + | }, + | "notCreated": [ + | [ + | "4f29", + | { + | "type": "forbiddenFrom", + | "description": "Can not send from [email protected]" + | } + | ] + | ] + |}""".stripMargin) + } + + @Test + def setIdentityShouldSucceedWhenValidEmailProperty(server: GuiceJamesServer): Unit = { + server.getProbe(classOf[DataProbeImpl]).addUserAliasMapping("bob-alias", "domain.tld", "[email protected]") + + val request: String = + """{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f28": { + | "name": "valid send from identity", + | "email": "[email protected]" + | }, + | "4f29": { + | "name": "valid send from identity", + | "email": "[email protected]" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response: String = `given` + .body(request) + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s""" { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "created": { + | "4f28": { + | "textSignature": "", + | "htmlSignature": "" + | }, + | "4f29": { + | "textSignature": "", + | "htmlSignature": "" + | } + | } + |}""".stripMargin) + } + + @Test + def setIdentityShouldFailWhenEmailPropertyIsMissing(): Unit = { + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f29": { + | "name": "Bob", + | "replyTo": [{ + | "name": "Alice", + | "email": "[email protected]" + | }], + | "bcc": [{ + | "name": "David", + | "email": "[email protected]" + | }], + | "textSignature": "Some text signature", + | "htmlSignature": "<p>Some html signature</p>" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s"""{ + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "notCreated": [ + | [ + | "4f29", + | { + | "type": "invalidArguments", + | "description": "Missing '/email' property in Identity object" + | } + | ] + | ] + |}""".stripMargin) + } + + @Test + def setIdentityShouldFailWhenEmailPropertyIsNull(): Unit = { + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f29": { + | "name": "Bob", + | "email": null, + | "replyTo": [{ + | "name": "Alice", + | "email": "[email protected]" + | }], + | "bcc": [{ + | "name": "David", + | "email": "[email protected]" + | }], + | "textSignature": "Some text signature", + | "htmlSignature": "<p>Some html signature</p>" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s"""{ + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "notCreated": [ + | [ + | "4f29", + | { + | "type": "invalidArguments", + | "description": "'/email' property in Identity object is not valid: mail address needs to be represented with a JsString" + | } + | ] + | ] + |}""".stripMargin) + } + + @Test + def setIdentityShouldNotCreatedWhenCreationRequestHasServerSetProperty(): Unit = { + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f29": { + | "id": "someId", + | "mayDelete": false, + | "email": "[email protected]" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s"""{ + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "notCreated": [ + | [ + | "4f29", + | { + | "type": "invalidArguments", + | "description": "Some server-set properties were specified", + | "properties": [ + | "id", + | "mayDelete" + | ] + | } + | ] + | ] + |}""".stripMargin) + } + + @Test + def setIdentityShouldFailWhenWrongAccountId(): Unit = { + val request = + s""" + |{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "unknownAccountId", + | "create": {} + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .log().ifValidationFails() + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response).isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [ + | ["error", { + | "type": "accountNotFound" + | }, "c1"] + | ] + |}""".stripMargin) + } + + @Test + def setIdentityShouldFailWhenMissingCapability(): Unit = { + val request: String = + """{ + | "using": [], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f29": { + | "email": "[email protected]" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response: String = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [ + | [ + | "error", + | { + | "type": "unknownMethod", + | "description": "Missing capability(ies): urn:ietf:params:jmap:core" + | }, + | "c1" + | ] + | ] + |}""".stripMargin) + } + +} Review comment: I would like to have a test ensuring that client creation ids for identities are well added to the processing context. Something like one API call with `Identity/set create af29 + Identity/get #af29` CF https://jmap.io/spec-core.html#set ``` Some records may hold references to other records (foreign keys). That reference may be set (via create or update) in the same request as the referenced record is created. To do this, the client refers to the new record using its creation id prefixed with a #. The order of the method calls in the request by the client MUST be such that the record being referenced is created in the same or an earlier call. Thus, the server never has to look ahead. Instead, while processing a request, the server MUST keep a simple map for the duration of the request of creation id to record id for each newly created record, so it can substitute in the correct value if necessary in later method calls. In the case of records with references to the same type, the server MUST order the creates and updates within a single method call so that creates happen before their creation ids are referenced by another create/update/destroy in the same call. ``` We have a generic mechanism in place managing this but we need to ensure the actual creation is registered... ########## File path: server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/IdentitySetContract.scala ########## @@ -0,0 +1,618 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.contract + +import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT +import io.restassured.RestAssured.{`given`, requestSpecification} +import io.restassured.http.ContentType.JSON +import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson +import org.apache.http.HttpStatus.SC_OK +import org.apache.james.GuiceJamesServer +import org.apache.james.jmap.core.ResponseObject.SESSION_STATE +import org.apache.james.jmap.core.UuidState.INSTANCE +import org.apache.james.jmap.http.UserCredential +import org.apache.james.jmap.rfc8621.contract.Fixture.{ACCEPT_RFC8621_VERSION_HEADER, BOB, BOB_PASSWORD, DOMAIN, authScheme, baseRequestSpecBuilder} +import org.apache.james.utils.DataProbeImpl +import org.junit.jupiter.api.{BeforeEach, Test} + +trait IdentitySetContract { + @BeforeEach + def setUp(server: GuiceJamesServer): Unit = { + server.getProbe(classOf[DataProbeImpl]) + .fluent + .addDomain(DOMAIN.asString) + .addDomain("domain-alias.tld") + .addUser(BOB.asString, BOB_PASSWORD) + + requestSpecification = baseRequestSpecBuilder(server) + .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD))) + .build + } + + @Test + def setIdentityShouldSucceed(): Unit = { + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f29": { + | "name": "Bob", + | "email": "[email protected]", + | "replyTo": [{ + | "name": "Alice", + | "email": "[email protected]" + | }], + | "bcc": [{ + | "name": "David", + | "email": "[email protected]" + | }], + | "textSignature": "Some text signature", + | "htmlSignature": "<p>Some html signature</p>" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s"""{ + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "${INSTANCE.serialize}", + | "created": { + | "4f29": { + | + | } + | } + |}""".stripMargin) + } + + @Test + def setIdentityWithSomePropertiesOmittedShouldSucceedAndReturnDefaultValues(): Unit = { + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f29": { + | "email": "[email protected]" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s"""{ + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "${INSTANCE.serialize}", + | "created": { + | "4f29": { + | "name": "", + | "textSignature": "", + | "htmlSignature": "" + | } + | } + |}""".stripMargin) + } + + @Test + def setIdentityShouldCreatedSeveralValidCreationRequest(): Unit = { + val request: String = + """{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f28": { + | "name": "Identity1", + | "email": "[email protected]", + | "replyTo": [{ + | "name": "Alice", + | "email": "[email protected]" + | }], + | "bcc": [{ + | "name": "David", + | "email": "[email protected]" + | }], + | "textSignature": "Some text signature", + | "htmlSignature": "<p>Some html signature</p>" + | }, + | "4f29": { + | "name": "Identity2", + | "email": "[email protected]", + | "replyTo": null, + | "bcc": null, + | "textSignature": "Some text signature", + | "htmlSignature": "<p>Some html signature</p>" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response: String = `given` + .body(request) + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s""" { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "created": { + | "4f28": { + | + | }, + | "4f29": { + | + | } + | } + | }""".stripMargin) + } + + @Test + def setIdentityShouldReturnForbiddenFromErrorWhenForbiddenEmailProperty(): Unit = { + val request: String = + """{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f28": { + | "name": "valid send from identity", + | "email": "[email protected]" + | }, + | "4f29": { + | "name": "forbidden send from identity", + | "email": "[email protected]" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response: String = `given` + .body(request) + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s""" { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "created": { + | "4f28": { + | "textSignature": "", + | "htmlSignature": "" + | } + | }, + | "notCreated": [ + | [ + | "4f29", + | { + | "type": "forbiddenFrom", + | "description": "Can not send from [email protected]" + | } + | ] + | ] + |}""".stripMargin) + } + + @Test + def setIdentityShouldSucceedWhenValidEmailProperty(server: GuiceJamesServer): Unit = { + server.getProbe(classOf[DataProbeImpl]).addUserAliasMapping("bob-alias", "domain.tld", "[email protected]") + + val request: String = + """{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f28": { + | "name": "valid send from identity", + | "email": "[email protected]" + | }, + | "4f29": { + | "name": "valid send from identity", + | "email": "[email protected]" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response: String = `given` + .body(request) + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s""" { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "created": { + | "4f28": { + | "textSignature": "", + | "htmlSignature": "" + | }, + | "4f29": { + | "textSignature": "", + | "htmlSignature": "" + | } + | } + |}""".stripMargin) + } + + @Test + def setIdentityShouldFailWhenEmailPropertyIsMissing(): Unit = { + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f29": { + | "name": "Bob", + | "replyTo": [{ + | "name": "Alice", + | "email": "[email protected]" + | }], + | "bcc": [{ + | "name": "David", + | "email": "[email protected]" + | }], + | "textSignature": "Some text signature", + | "htmlSignature": "<p>Some html signature</p>" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s"""{ + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "notCreated": [ + | [ + | "4f29", + | { + | "type": "invalidArguments", + | "description": "Missing '/email' property in Identity object" + | } Review comment: ```suggestion | { | "type": "invalidArguments", | "description": "Missing '/email' property in Identity object" | "properties": "email" | } ``` I bet we could easily have the impacted property specified explicitly with the invalidArgument format.... ########## File path: server/data/data-jmap/src/main/scala/org/apache/james/jmap/api/identity/CustomIdentityDAO.scala ########## @@ -99,6 +99,9 @@ class DefaultIdentitySupplier @Inject()(canSendFrom: CanSendFrom) { htmlSignature = HtmlSignature.DEFAULT, mayDelete = MayDeleteIdentity(false)))) + def userCanSendFrom(username: Username, mailAddress: MailAddress): Boolean = + canSendFrom.userCanSendFrom(username, Username.of(mailAddress.asString())) Review comment: `Username.of(mailAddress.asString())` should be replaced by `usersRepository.getUsername(mailAddress)` ########## File path: server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/IdentitySetContract.scala ########## @@ -0,0 +1,618 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.contract + +import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT +import io.restassured.RestAssured.{`given`, requestSpecification} +import io.restassured.http.ContentType.JSON +import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson +import org.apache.http.HttpStatus.SC_OK +import org.apache.james.GuiceJamesServer +import org.apache.james.jmap.core.ResponseObject.SESSION_STATE +import org.apache.james.jmap.core.UuidState.INSTANCE +import org.apache.james.jmap.http.UserCredential +import org.apache.james.jmap.rfc8621.contract.Fixture.{ACCEPT_RFC8621_VERSION_HEADER, BOB, BOB_PASSWORD, DOMAIN, authScheme, baseRequestSpecBuilder} +import org.apache.james.utils.DataProbeImpl +import org.junit.jupiter.api.{BeforeEach, Test} + +trait IdentitySetContract { + @BeforeEach + def setUp(server: GuiceJamesServer): Unit = { + server.getProbe(classOf[DataProbeImpl]) + .fluent + .addDomain(DOMAIN.asString) + .addDomain("domain-alias.tld") + .addUser(BOB.asString, BOB_PASSWORD) + + requestSpecification = baseRequestSpecBuilder(server) + .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD))) + .build + } + + @Test + def setIdentityShouldSucceed(): Unit = { + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f29": { + | "name": "Bob", + | "email": "[email protected]", + | "replyTo": [{ + | "name": "Alice", + | "email": "[email protected]" + | }], + | "bcc": [{ + | "name": "David", + | "email": "[email protected]" + | }], + | "textSignature": "Some text signature", + | "htmlSignature": "<p>Some html signature</p>" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s"""{ + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "${INSTANCE.serialize}", + | "created": { + | "4f29": { + | + | } + | } + |}""".stripMargin) + } + + @Test + def setIdentityWithSomePropertiesOmittedShouldSucceedAndReturnDefaultValues(): Unit = { + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f29": { + | "email": "[email protected]" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s"""{ + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "${INSTANCE.serialize}", + | "created": { + | "4f29": { + | "name": "", + | "textSignature": "", + | "htmlSignature": "" + | } + | } + |}""".stripMargin) + } + + @Test + def setIdentityShouldCreatedSeveralValidCreationRequest(): Unit = { + val request: String = + """{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f28": { + | "name": "Identity1", + | "email": "[email protected]", + | "replyTo": [{ + | "name": "Alice", + | "email": "[email protected]" + | }], + | "bcc": [{ + | "name": "David", + | "email": "[email protected]" + | }], + | "textSignature": "Some text signature", + | "htmlSignature": "<p>Some html signature</p>" + | }, + | "4f29": { + | "name": "Identity2", + | "email": "[email protected]", + | "replyTo": null, + | "bcc": null, + | "textSignature": "Some text signature", + | "htmlSignature": "<p>Some html signature</p>" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response: String = `given` + .body(request) + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s""" { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "created": { + | "4f28": { + | + | }, + | "4f29": { + | + | } + | } + | }""".stripMargin) + } + + @Test + def setIdentityShouldReturnForbiddenFromErrorWhenForbiddenEmailProperty(): Unit = { + val request: String = + """{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f28": { + | "name": "valid send from identity", + | "email": "[email protected]" + | }, + | "4f29": { + | "name": "forbidden send from identity", + | "email": "[email protected]" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response: String = `given` + .body(request) + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s""" { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "created": { + | "4f28": { + | "textSignature": "", + | "htmlSignature": "" + | } + | }, + | "notCreated": [ + | [ + | "4f29", + | { + | "type": "forbiddenFrom", + | "description": "Can not send from [email protected]" + | } + | ] + | ] + |}""".stripMargin) + } + + @Test + def setIdentityShouldSucceedWhenValidEmailProperty(server: GuiceJamesServer): Unit = { + server.getProbe(classOf[DataProbeImpl]).addUserAliasMapping("bob-alias", "domain.tld", "[email protected]") + + val request: String = + """{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f28": { + | "name": "valid send from identity", + | "email": "[email protected]" + | }, + | "4f29": { + | "name": "valid send from identity", + | "email": "[email protected]" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response: String = `given` + .body(request) + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s""" { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "created": { + | "4f28": { + | "textSignature": "", + | "htmlSignature": "" + | }, + | "4f29": { + | "textSignature": "", + | "htmlSignature": "" + | } + | } + |}""".stripMargin) + } + + @Test + def setIdentityShouldFailWhenEmailPropertyIsMissing(): Unit = { + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f29": { + | "name": "Bob", + | "replyTo": [{ + | "name": "Alice", + | "email": "[email protected]" + | }], + | "bcc": [{ + | "name": "David", + | "email": "[email protected]" + | }], + | "textSignature": "Some text signature", + | "htmlSignature": "<p>Some html signature</p>" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s"""{ + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "notCreated": [ + | [ + | "4f29", + | { + | "type": "invalidArguments", + | "description": "Missing '/email' property in Identity object" + | } + | ] + | ] + |}""".stripMargin) + } + + @Test + def setIdentityShouldFailWhenEmailPropertyIsNull(): Unit = { + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f29": { + | "name": "Bob", + | "email": null, + | "replyTo": [{ + | "name": "Alice", + | "email": "[email protected]" + | }], + | "bcc": [{ + | "name": "David", + | "email": "[email protected]" + | }], + | "textSignature": "Some text signature", + | "htmlSignature": "<p>Some html signature</p>" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s"""{ + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "notCreated": [ + | [ + | "4f29", + | { + | "type": "invalidArguments", + | "description": "'/email' property in Identity object is not valid: mail address needs to be represented with a JsString" + | } + | ] + | ] + |}""".stripMargin) + } + + @Test + def setIdentityShouldNotCreatedWhenCreationRequestHasServerSetProperty(): Unit = { + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f29": { + | "id": "someId", + | "mayDelete": false, + | "email": "[email protected]" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s"""{ + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "notCreated": [ + | [ + | "4f29", + | { + | "type": "invalidArguments", + | "description": "Some server-set properties were specified", + | "properties": [ + | "id", + | "mayDelete" + | ] + | } + | ] + | ] + |}""".stripMargin) + } + + @Test + def setIdentityShouldFailWhenWrongAccountId(): Unit = { + val request = + s""" + |{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "unknownAccountId", + | "create": {} + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .log().ifValidationFails() + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response).isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [ + | ["error", { + | "type": "accountNotFound" + | }, "c1"] + | ] + |}""".stripMargin) + } + + @Test + def setIdentityShouldFailWhenMissingCapability(): Unit = { + val request: String = + """{ + | "using": [], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f29": { + | "email": "[email protected]" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response: String = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [ + | [ + | "error", + | { + | "type": "unknownMethod", + | "description": "Missing capability(ies): urn:ietf:params:jmap:core" + | }, + | "c1" + | ] + | ] + |}""".stripMargin) + } + Review comment: Do we also fail when missing `urn:ietf:params:jmap:submission` ? ``` This represents support for the Identity and EmailSubmission data types and associated API methods. ``` ########## File path: server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/IdentitySetCreatePerformer.scala ########## @@ -0,0 +1,105 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.method + +import javax.inject.Inject +import org.apache.james.jmap.api.identity.{IdentityCreationRequest, IdentityRepository} +import org.apache.james.jmap.api.model.{ForbiddenSendFromException, HtmlSignature, IdentityName, TextSignature} +import org.apache.james.jmap.core.SetError +import org.apache.james.jmap.core.SetError.SetErrorDescription +import org.apache.james.jmap.json.IdentitySerializer +import org.apache.james.jmap.mail.{IdentityCreation, IdentityCreationId, IdentityCreationParseException, IdentityCreationResponse, IdentitySetRequest} +import org.apache.james.jmap.method.IdentitySetCreatePerformer.{CreationFailure, CreationResult, CreationResults, CreationSuccess} +import org.apache.james.mailbox.MailboxSession +import play.api.libs.json.{JsObject, JsPath, JsonValidationError} +import reactor.core.scala.publisher.{SFlux, SMono} +import reactor.core.scheduler.Schedulers + +object IdentitySetCreatePerformer { + case class CreationResults(results: Seq[CreationResult]) { + def created: Option[Map[IdentityCreationId, IdentityCreationResponse]] = + Option(results.flatMap { + case result: CreationSuccess => Some((result.clientId, result.response)) + case _ => None + }.toMap) + .filter(_.nonEmpty) + + def notCreated: Option[Map[IdentityCreationId, SetError]] = { Review comment: Useless `{}` spotted... ########## File path: server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/IdentitySetContract.scala ########## @@ -0,0 +1,618 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.contract + +import io.netty.handler.codec.http.HttpHeaderNames.ACCEPT +import io.restassured.RestAssured.{`given`, requestSpecification} +import io.restassured.http.ContentType.JSON +import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson +import org.apache.http.HttpStatus.SC_OK +import org.apache.james.GuiceJamesServer +import org.apache.james.jmap.core.ResponseObject.SESSION_STATE +import org.apache.james.jmap.core.UuidState.INSTANCE +import org.apache.james.jmap.http.UserCredential +import org.apache.james.jmap.rfc8621.contract.Fixture.{ACCEPT_RFC8621_VERSION_HEADER, BOB, BOB_PASSWORD, DOMAIN, authScheme, baseRequestSpecBuilder} +import org.apache.james.utils.DataProbeImpl +import org.junit.jupiter.api.{BeforeEach, Test} + +trait IdentitySetContract { + @BeforeEach + def setUp(server: GuiceJamesServer): Unit = { + server.getProbe(classOf[DataProbeImpl]) + .fluent + .addDomain(DOMAIN.asString) + .addDomain("domain-alias.tld") + .addUser(BOB.asString, BOB_PASSWORD) + + requestSpecification = baseRequestSpecBuilder(server) + .setAuth(authScheme(UserCredential(BOB, BOB_PASSWORD))) + .build + } + + @Test + def setIdentityShouldSucceed(): Unit = { + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f29": { + | "name": "Bob", + | "email": "[email protected]", + | "replyTo": [{ + | "name": "Alice", + | "email": "[email protected]" + | }], + | "bcc": [{ + | "name": "David", + | "email": "[email protected]" + | }], + | "textSignature": "Some text signature", + | "htmlSignature": "<p>Some html signature</p>" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s"""{ + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "${INSTANCE.serialize}", + | "created": { + | "4f29": { + | + | } + | } + |}""".stripMargin) + } + + @Test + def setIdentityWithSomePropertiesOmittedShouldSucceedAndReturnDefaultValues(): Unit = { + val request = + s"""{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f29": { + | "email": "[email protected]" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s"""{ + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "${INSTANCE.serialize}", + | "created": { + | "4f29": { + | "name": "", + | "textSignature": "", + | "htmlSignature": "" + | } + | } + |}""".stripMargin) + } + + @Test + def setIdentityShouldCreatedSeveralValidCreationRequest(): Unit = { + val request: String = + """{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f28": { + | "name": "Identity1", + | "email": "[email protected]", + | "replyTo": [{ + | "name": "Alice", + | "email": "[email protected]" + | }], + | "bcc": [{ + | "name": "David", + | "email": "[email protected]" + | }], + | "textSignature": "Some text signature", + | "htmlSignature": "<p>Some html signature</p>" + | }, + | "4f29": { + | "name": "Identity2", + | "email": "[email protected]", + | "replyTo": null, + | "bcc": null, + | "textSignature": "Some text signature", + | "htmlSignature": "<p>Some html signature</p>" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response: String = `given` + .body(request) + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s""" { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "created": { + | "4f28": { + | + | }, + | "4f29": { + | + | } + | } + | }""".stripMargin) + } + + @Test + def setIdentityShouldReturnForbiddenFromErrorWhenForbiddenEmailProperty(): Unit = { + val request: String = + """{ + | "using": ["urn:ietf:params:jmap:core"], + | "methodCalls": [ + | [ + | "Identity/set", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "create": { + | "4f28": { + | "name": "valid send from identity", + | "email": "[email protected]" + | }, + | "4f29": { + | "name": "forbidden send from identity", + | "email": "[email protected]" + | } + | } + | }, + | "c1" + | ] + | ] + |}""".stripMargin + + val response: String = `given` + .body(request) + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString + + assertThatJson(response) + .inPath("methodResponses[0][1]") + .isEqualTo( + s""" { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "newState": "2c9f1b12-b35a-43e6-9af2-0106fb53a943", + | "created": { + | "4f28": { + | "textSignature": "", + | "htmlSignature": "" + | } + | }, + | "notCreated": [ + | [ + | "4f29", + | { + | "type": "forbiddenFrom", + | "description": "Can not send from [email protected]" + | } + | ] + | ] Review comment: This is not a correct format for a map! ```suggestion | "notCreated": { | "4f29" : { | "type": "forbiddenFrom", | "description": "Can not send from [email protected]" | } | } ``` -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
