From 2558ae3bc943207076bda476955968534955097d Mon Sep 17 00:00:00 2001 From: Dimitri Huisman Date: Fri, 22 Mar 2024 15:01:37 +0000 Subject: [PATCH] Add automatic tests for RESTful API. Fix all remaining issues that I could find with the API. --- .github/workflows/build_test_deploy.yml | 2 +- core/admin/mailu/api/v1/alias.py | 47 +++++- core/admin/mailu/api/v1/domain.py | 55 ++++--- core/admin/mailu/api/v1/relay.py | 7 +- core/admin/mailu/api/v1/token.py | 91 +++++++++-- core/admin/mailu/api/v1/user.py | 19 ++- tests/compose/api/00_create_users.sh | 86 ++++++++++ tests/compose/api/01_test_user_interfaces.sh | 80 ++++++++++ .../compose/api/02_test_domain_interfaces.sh | 145 +++++++++++++++++ tests/compose/api/03_test_token_interfaces.sh | 107 +++++++++++++ tests/compose/api/04_test_relay_interfaces.sh | 98 ++++++++++++ tests/compose/api/05_test_alias_interfaces.sh | 111 +++++++++++++ tests/compose/api/docker-compose.yml | 112 +++++++++++++ tests/compose/api/mailu.env | 151 ++++++++++++++++++ 14 files changed, 1065 insertions(+), 46 deletions(-) create mode 100755 tests/compose/api/00_create_users.sh create mode 100755 tests/compose/api/01_test_user_interfaces.sh create mode 100755 tests/compose/api/02_test_domain_interfaces.sh create mode 100755 tests/compose/api/03_test_token_interfaces.sh create mode 100755 tests/compose/api/04_test_relay_interfaces.sh create mode 100755 tests/compose/api/05_test_alias_interfaces.sh create mode 100644 tests/compose/api/docker-compose.yml create mode 100644 tests/compose/api/mailu.env diff --git a/.github/workflows/build_test_deploy.yml b/.github/workflows/build_test_deploy.yml index 71e8d099..1ce4f117 100644 --- a/.github/workflows/build_test_deploy.yml +++ b/.github/workflows/build_test_deploy.yml @@ -418,7 +418,7 @@ jobs: strategy: fail-fast: false matrix: - target: ["core", "fetchmail", "filters", "webmail", "webdav"] + target: ["api", "core", "fetchmail", "filters", "webmail", "webdav"] time: ["2"] include: - target: "filters" diff --git a/core/admin/mailu/api/v1/alias.py b/core/admin/mailu/api/v1/alias.py index a40295a8..ffe0177d 100644 --- a/core/admin/mailu/api/v1/alias.py +++ b/core/admin/mailu/api/v1/alias.py @@ -2,6 +2,7 @@ from flask_restx import Resource, fields, marshal from . import api, response_fields from .. import common from ... import models +import validators db = models.db @@ -15,7 +16,7 @@ alias_fields_update = alias.model('AliasUpdate', { alias_fields = alias.inherit('Alias',alias_fields_update, { 'email': fields.String(description='the alias email address', example='user@example.com', required=True), - 'destination': fields.List(fields.String(description='alias email address', example='user@example.com', required=True)), + 'destination': fields.List(fields.String(description='destination email address', example='user@example.com', required=True)), }) @@ -24,6 +25,7 @@ alias_fields = alias.inherit('Alias',alias_fields_update, { class Aliases(Resource): @alias.doc('list_alias') @alias.marshal_with(alias_fields, as_list=True, skip_none=True, mask=None) + @alias.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @alias.doc(security='Bearer') @common.api_token_authorization def get(self): @@ -34,6 +36,8 @@ class Aliases(Resource): @alias.expect(alias_fields) @alias.response(200, 'Success', response_fields) @alias.response(400, 'Input validation exception', response_fields) + @alias.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) + @alias.response(404, 'Not found', response_fields) @alias.response(409, 'Duplicate alias', response_fields) @alias.doc(security='Bearer') @common.api_token_authorization @@ -41,6 +45,20 @@ class Aliases(Resource): """ Create a new alias """ data = api.payload + if not validators.email(data['email']): + return { 'code': 400, 'message': f'Provided alias {data["email"]} is not a valid email address'}, 400 + localpart, domain_name = data['email'].lower().rsplit('@', 1) + domain_found = models.Domain.query.get(domain_name) + if not domain_found: + return { 'code': 404, 'message': f'Domain {domain_name} does not exist ({data["email"]})'}, 404 + if not domain_found.max_aliases == -1 and len(domain_found.aliases) >= domain_found.max_aliases: + return { 'code': 409, 'message': f'Too many aliases for domain {domain_name}'}, 409 + for dest in data['destination']: + if not validators.email(dest): + return { 'code': 400, 'message': f'Provided destination email address {dest} is not a valid email address'}, 400 + elif models.User.query.filter_by(email=dest).first() is None: + return { 'code': 404, 'message': f'Provided destination email address {dest} does not exist'}, 404 + alias_found = models.Alias.query.filter_by(email = data['email']).first() if alias_found: return { 'code': 409, 'message': f'Duplicate alias {data["email"]}'}, 409 @@ -53,17 +71,21 @@ class Aliases(Resource): db.session.add(alias_model) db.session.commit() - return {'code': 200, 'message': f'Alias {data["email"]} to destination {data["destination"]} has been created'}, 200 + return {'code': 200, 'message': f'Alias {data["email"]} to destination(s) {data["destination"]} has been created'}, 200 @alias.route('/') class Alias(Resource): @alias.doc('find_alias') @alias.response(200, 'Success', alias_fields) + @alias.response(400, 'Input validation exception', response_fields) + @alias.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @alias.response(404, 'Alias not found', response_fields) @alias.doc(security='Bearer') @common.api_token_authorization def get(self, alias): """ Look up the specified alias """ + if not validators.email(alias): + return { 'code': 400, 'message': f'Provided alias (email address) {alias} is not a valid email address'}, 400 alias_found = models.Alias.query.filter_by(email = alias).first() if alias_found is None: return { 'code': 404, 'message': f'Alias {alias} cannot be found'}, 404 @@ -73,6 +95,7 @@ class Alias(Resource): @alias.doc('update_alias') @alias.expect(alias_fields_update) @alias.response(200, 'Success', response_fields) + @alias.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @alias.response(404, 'Alias not found', response_fields) @alias.response(400, 'Input validation exception', response_fields) @alias.doc(security='Bearer') @@ -80,6 +103,9 @@ class Alias(Resource): def patch(self, alias): """ Update the specfied alias """ data = api.payload + + if not validators.email(alias): + return { 'code': 400, 'message': f'Provided alias (email address) {alias} is not a valid email address'}, 400 alias_found = models.Alias.query.filter_by(email = alias).first() if alias_found is None: return { 'code': 404, 'message': f'Alias {alias} cannot be found'}, 404 @@ -87,6 +113,11 @@ class Alias(Resource): alias_found.comment = data['comment'] if 'destination' in data: alias_found.destination = data['destination'] + for dest in data['destination']: + if not validators.email(dest): + return { 'code': 400, 'message': f'Provided destination email address {dest} is not a valid email address'}, 400 + elif models.User.query.filter_by(email=dest).first() is None: + return { 'code': 404, 'message': f'Provided destination email address {dest} does not exist'}, 404 if 'wildcard' in data: alias_found.wildcard = data['wildcard'] db.session.add(alias_found) @@ -95,11 +126,15 @@ class Alias(Resource): @alias.doc('delete_alias') @alias.response(200, 'Success', response_fields) + @alias.response(400, 'Input validation exception', response_fields) + @alias.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @alias.response(404, 'Alias not found', response_fields) @alias.doc(security='Bearer') @common.api_token_authorization def delete(self, alias): """ Delete the specified alias """ + if not validators.email(alias): + return { 'code': 400, 'message': f'Provided alias (email address) {alias} is not a valid email address'}, 400 alias_found = models.Alias.query.filter_by(email = alias).first() if alias_found is None: return { 'code': 404, 'message': f'Alias {alias} cannot be found'}, 404 @@ -110,12 +145,16 @@ class Alias(Resource): @alias.route('/destination/') class AliasWithDest(Resource): @alias.doc('find_alias_filter_domain') - @alias.marshal_with(alias_fields, code=200, description='Success' ,as_list=True, skip_none=True, mask=None) + @alias.response(200, 'Success', alias_fields) + @alias.response(400, 'Input validation exception', response_fields) + @alias.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @alias.response(404, 'Alias or domain not found', response_fields) @alias.doc(security='Bearer') @common.api_token_authorization def get(self, domain): """ Look up the aliases of the specified domain """ + if not validators.domain(domain): + return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400 domain_found = models.Domain.query.filter_by(name=domain).first() if domain_found is None: return { 'code': 404, 'message': f'Domain {domain} cannot be found'}, 404 @@ -123,4 +162,4 @@ class AliasWithDest(Resource): if aliases_found.count == 0: return { 'code': 404, 'message': f'No alias can be found for domain {domain}'}, 404 else: - return marshal(aliases_found, alias_fields, as_list=True), 200 + return marshal(aliases_found, alias_fields), 200 diff --git a/core/admin/mailu/api/v1/domain.py b/core/admin/mailu/api/v1/domain.py index 1d60ab8c..f5048b5d 100644 --- a/core/admin/mailu/api/v1/domain.py +++ b/core/admin/mailu/api/v1/domain.py @@ -16,7 +16,7 @@ domain_fields = api.model('Domain', { 'max_aliases': fields.Integer(description='maximum number of aliases', min=-1, default=-1), 'max_quota_bytes': fields.Integer(description='maximum quota for mailbox', min=0), 'signup_enabled': fields.Boolean(description='allow signup'), - 'alternatives': fields.List(fields.String(attribute='name', description='FQDN', example='example2.com')), + 'alternatives': fields.List(fields.String(attribute='name', description='FQDN'), example='["example.com"]'), }) domain_fields_update = api.model('DomainUpdate', { @@ -25,17 +25,18 @@ domain_fields_update = api.model('DomainUpdate', { 'max_aliases': fields.Integer(description='maximum number of aliases', min=-1, default=-1), 'max_quota_bytes': fields.Integer(description='maximum quota for mailbox', min=0), 'signup_enabled': fields.Boolean(description='allow signup'), - 'alternatives': fields.List(fields.String(attribute='name', description='FQDN', example='example2.com')), + 'alternatives': fields.List(fields.String(attribute='name', description='FQDN'), example='["example.com"]'), }) domain_fields_get = api.model('DomainGet', { 'name': fields.String(description='FQDN (e.g. example.com)', example='example.com', required=True), 'comment': fields.String(description='a comment'), + 'managers': fields.List(fields.String(attribute='email', description='manager of domain')), 'max_users': fields.Integer(description='maximum number of users', min=-1, default=-1), 'max_aliases': fields.Integer(description='maximum number of aliases', min=-1, default=-1), 'max_quota_bytes': fields.Integer(description='maximum quota for mailbox', min=0), 'signup_enabled': fields.Boolean(description='allow signup'), - 'alternatives': fields.List(fields.String(attribute='name', description='FQDN', example='example2.com')), + 'alternatives': fields.List(fields.String(attribute='name', description='FQDN'), example='["example.com"]'), 'dns_autoconfig': fields.List(fields.String(description='DNS client auto-configuration entry')), 'dns_mx': fields.String(Description='MX record for domain'), 'dns_spf': fields.String(Description='SPF record for domain'), @@ -56,8 +57,7 @@ domain_fields_dns = api.model('DomainDNS', { }) manager_fields = api.model('Manager', { - 'domain_name': fields.String(description='domain managed by manager'), - 'user_email': fields.String(description='email address of manager'), + 'managers': fields.List(fields.String(attribute='email', description='manager of domain')), }) manager_fields_create = api.model('ManagerCreate', { @@ -78,6 +78,7 @@ alternative_fields = api.model('AlternativeDomain', { class Domains(Resource): @dom.doc('list_domain') @dom.marshal_with(domain_fields_get, as_list=True, skip_none=True, mask=None) + @dom.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @dom.doc(security='Bearer') @common.api_token_authorization def get(self): @@ -88,6 +89,7 @@ class Domains(Resource): @dom.expect(domain_fields) @dom.response(200, 'Success', response_fields) @dom.response(400, 'Input validation exception', response_fields) + @dom.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @dom.response(409, 'Duplicate domain/alternative name', response_fields) @dom.doc(security='Bearer') @common.api_token_authorization @@ -131,15 +133,16 @@ class Domains(Resource): class Domain(Resource): @dom.doc('find_domain') - @dom.marshal_with(domain_fields, code=200, description='Success', as_list=False, skip_none=True, mask=None) + @dom.response(200, 'Success', domain_fields_get) @dom.response(400, 'Input validation exception', response_fields) + @dom.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @dom.response(404, 'Domain not found', response_fields) @dom.doc(security='Bearer') @common.api_token_authorization def get(self, domain): """ Look up the specified domain """ if not validators.domain(domain): - return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400 + return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 200 domain_found = models.Domain.query.get(domain) if not domain_found: return { 'code': 404, 'message': f'Domain {domain} does not exist'}, 404 @@ -149,6 +152,7 @@ class Domain(Resource): @dom.expect(domain_fields_update) @dom.response(200, 'Success', response_fields) @dom.response(400, 'Input validation exception', response_fields) + @dom.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @dom.response(404, 'Domain not found', response_fields) @dom.response(409, 'Duplicate domain/alternative name', response_fields) @dom.doc(security='Bearer') @@ -158,7 +162,7 @@ class Domain(Resource): if not validators.domain(domain): return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400 domain_found = models.Domain.query.get(domain) - if not domain: + if not domain_found: return { 'code': 404, 'message': f'Domain {data["name"]} does not exist'}, 404 data = api.payload @@ -172,7 +176,7 @@ class Domain(Resource): if not validators.domain(item): return { 'code': 400, 'message': f'Alternative domain {item} is not a valid domain'}, 400 for item in data['alternatives']: - alternative = models.Alternative(name=item, domain_name=data['name']) + alternative = models.Alternative(name=item, domain_name=domain) models.db.session.add(alternative) if 'comment' in data: @@ -194,6 +198,7 @@ class Domain(Resource): @dom.doc('delete_domain') @dom.response(200, 'Success', response_fields) @dom.response(400, 'Input validation exception', response_fields) + @dom.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @dom.response(404, 'Domain not found', response_fields) @dom.doc(security='Bearer') @common.api_token_authorization @@ -202,7 +207,7 @@ class Domain(Resource): if not validators.domain(domain): return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400 domain_found = models.Domain.query.get(domain) - if not domain: + if not domain_found: return { 'code': 404, 'message': f'Domain {domain} does not exist'}, 404 db.session.delete(domain_found) db.session.commit() @@ -213,6 +218,7 @@ class Domain(Resource): @dom.doc('generate_dkim') @dom.response(200, 'Success', response_fields) @dom.response(400, 'Input validation exception', response_fields) + @dom.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @dom.response(404, 'Domain not found', response_fields) @dom.doc(security='Bearer') @common.api_token_authorization @@ -230,8 +236,9 @@ class Domain(Resource): @dom.route('//manager') class Manager(Resource): @dom.doc('list_managers') - @dom.marshal_with(manager_fields, code=200, description='Success', as_list=True, skip_none=True, mask=None) + @dom.response(200, 'Success', manager_fields) @dom.response(400, 'Input validation exception', response_fields) + @dom.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @dom.response(404, 'domain not found', response_fields) @dom.doc(security='Bearer') @common.api_token_authorization @@ -239,15 +246,16 @@ class Manager(Resource): """ List all managers of the specified domain """ if not validators.domain(domain): return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400 - if not domain: + domain_found = models.Domain.query.get(domain) + if not domain_found: return { 'code': 404, 'message': f'Domain {domain} does not exist'}, 404 - domain = models.Domain.query.filter_by(name=domain) - return domain.managers + return marshal(domain_found, manager_fields), 200 @dom.doc('create_manager') @dom.expect(manager_fields_create) @dom.response(200, 'Success', response_fields) @dom.response(400, 'Input validation exception', response_fields) + @dom.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @dom.response(404, 'User or domain not found', response_fields) @dom.response(409, 'Duplicate domain manager', response_fields) @dom.doc(security='Bearer') @@ -274,13 +282,14 @@ class Manager(Resource): @dom.route('//manager/') class Domain(Resource): @dom.doc('find_manager') - @dom.marshal_with(manager_fields, code=200, description='Success', as_list=False, skip_none=True, mask=None) + @dom.response(200, 'Success', response_fields) @dom.response(400, 'Input validation exception', response_fields) + @dom.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @dom.response(404, 'Manager not found', response_fields) @dom.doc(security='Bearer') @common.api_token_authorization def get(self, domain, email): - """ Look up the specified manager of the specified domain """ + """ Check if the specified user is a manager of the specified domain """ if not validators.email(email): return {'code': 400, 'message': f'Invalid email address {email}'}, 400 if not validators.domain(domain): @@ -294,7 +303,7 @@ class Domain(Resource): if user in domain.managers: for manager in domain.managers: if manager.email == email: - return marshal(manager, manager_fields),200 + return { 'code': 200, 'message': f'User {email} is a manager of the domain {domain}'}, 200 else: return { 'code': 404, 'message': f'User {email} is not a manager of the domain {domain}'}, 404 @@ -302,6 +311,7 @@ class Domain(Resource): @dom.doc('delete_manager') @dom.response(200, 'Success', response_fields) @dom.response(400, 'Input validation exception', response_fields) + @dom.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @dom.response(404, 'Manager not found', response_fields) @dom.doc(security='Bearer') @common.api_token_authorization @@ -327,8 +337,9 @@ class Domain(Resource): @dom.route('//users') class User(Resource): @dom.doc('list_user_domain') - @dom.marshal_with(user.user_fields_get, code=200, description='Success', as_list=True, skip_none=True, mask=None) + @dom.response(200, 'Success', user.user_fields_get) @dom.response(400, 'Input validation exception', response_fields) + @dom.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @dom.response(404, 'Domain not found', response_fields) @dom.doc(security='Bearer') @common.api_token_authorization @@ -339,13 +350,14 @@ class User(Resource): domain_found = models.Domain.query.get(domain) if not domain_found: return { 'code': 404, 'message': f'Domain {domain} does not exist'}, 404 - return models.User.query.filter_by(domain=domain_found).all() + return marshal(models.User.query.filter_by(domain=domain_found).all(), user.user_fields_get),200 @alt.route('') class Alternatives(Resource): @alt.doc('list_alternative') @alt.marshal_with(alternative_fields, as_list=True, skip_none=True, mask=None) + @alt.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @alt.doc(security='Bearer') @common.api_token_authorization def get(self): @@ -357,6 +369,7 @@ class Alternatives(Resource): @alt.expect(alternative_fields) @alt.response(200, 'Success', response_fields) @alt.response(400, 'Input validation exception', response_fields) + @alt.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @alt.response(404, 'Domain not found or missing', response_fields) @alt.response(409, 'Duplicate alternative domain name', response_fields) @alt.doc(security='Bearer') @@ -383,8 +396,9 @@ class Alternatives(Resource): class Alternative(Resource): @alt.doc('find_alternative') @alt.doc(security='Bearer') - @alt.marshal_with(alternative_fields, code=200, description='Success' ,as_list=True, skip_none=True, mask=None) + @alt.response(200, 'Success', alternative_fields) @alt.response(400, 'Input validation exception', response_fields) + @alt.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @alt.response(404, 'Alternative not found or missing', response_fields) @common.api_token_authorization def get(self, alt): @@ -399,6 +413,7 @@ class Alternative(Resource): @alt.doc('delete_alternative') @alt.response(200, 'Success', response_fields) @alt.response(400, 'Input validation exception', response_fields) + @alt.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @alt.response(404, 'Alternative/Domain not found or missing', response_fields) @alt.response(409, 'Duplicate domain name', response_fields) @alt.doc(security='Bearer') diff --git a/core/admin/mailu/api/v1/relay.py b/core/admin/mailu/api/v1/relay.py index d9542de6..0f160947 100644 --- a/core/admin/mailu/api/v1/relay.py +++ b/core/admin/mailu/api/v1/relay.py @@ -24,6 +24,7 @@ relay_fields_update = api.model('RelayUpdate', { class Relays(Resource): @relay.doc('list_relays') @relay.marshal_with(relay_fields, as_list=True, skip_none=True, mask=None) + @relay.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @relay.doc(security='Bearer') @common.api_token_authorization def get(self): @@ -34,6 +35,7 @@ class Relays(Resource): @relay.expect(relay_fields) @relay.response(200, 'Success', response_fields) @relay.response(400, 'Input validation exception', response_fields) + @relay.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @relay.response(409, 'Duplicate relay', response_fields) @relay.doc(security='Bearer') @common.api_token_authorization @@ -58,8 +60,9 @@ class Relays(Resource): @relay.route('/') class Relay(Resource): @relay.doc('find_relay') - @relay.marshal_with(relay_fields, code=200, description='Success', as_list=False, skip_none=True, mask=None) + @relay.response(200, 'Success', relay_fields) @relay.response(400, 'Input validation exception', response_fields) + @relay.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @relay.response(404, 'Relay not found', response_fields) @relay.doc(security='Bearer') @common.api_token_authorization @@ -77,6 +80,7 @@ class Relay(Resource): @relay.expect(relay_fields_update) @relay.response(200, 'Success', response_fields) @relay.response(400, 'Input validation exception', response_fields) + @relay.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @relay.response(404, 'Relay not found', response_fields) @relay.doc(security='Bearer') @common.api_token_authorization @@ -103,6 +107,7 @@ class Relay(Resource): @relay.doc('delete_relay') @relay.response(200, 'Success', response_fields) @relay.response(400, 'Input validation exception', response_fields) + @relay.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @relay.response(404, 'Relay not found', response_fields) @relay.doc(security='Bearer') @common.api_token_authorization diff --git a/core/admin/mailu/api/v1/token.py b/core/admin/mailu/api/v1/token.py index 041ff322..ba23dc79 100644 --- a/core/admin/mailu/api/v1/token.py +++ b/core/admin/mailu/api/v1/token.py @@ -15,20 +15,20 @@ token_user_fields = api.model('TokenGetResponse', { 'id': fields.String(description='The record id of the token (unique identifier)', example='1'), 'email': fields.String(description='The email address of the user', example='John.Doe@example.com', attribute='user_email'), 'comment': fields.String(description='A description for the token. This description is shown on the Authentication tokens page', example='my comment'), - 'AuthorizedIP': fields.String(description='Comma separated list of white listed IP addresses or networks that may use this token.', example="['203.0.113.0/24']", attribute='ip'), + 'AuthorizedIP': fields.List(fields.String(description='White listed IP addresses or networks that may use this token.', example="203.0.113.0/24"), attribute='ip'), 'Created': fields.String(description='The date when the token was created', example='John.Doe@example.com', attribute='created_at'), 'Last edit': fields.String(description='The date when the token was last modifified', example='John.Doe@example.com', attribute='updated_at') }) token_user_fields_post = api.model('TokenPost', { - 'email': fields.String(description='The email address of the user', example='John.Doe@example.com', attribute='user_email'), + 'email': fields.String(description='The email address of the user', example='John.Doe@example.com', attribute='user_email', required=True), 'comment': fields.String(description='A description for the token. This description is shown on the Authentication tokens page', example='my comment'), - 'AuthorizedIP': fields.String(description='Comma separated list of white listed IP addresses or networks that may use this token.', example="['203.0.113.0/24']", attribute='ip'), + 'AuthorizedIP': fields.List(fields.String(description='White listed IP addresses or networks that may use this token.', example="203.0.113.0/24")), }) token_user_fields_post2 = api.model('TokenPost2', { 'comment': fields.String(description='A description for the token. This description is shown on the Authentication tokens page', example='my comment'), - 'AuthorizedIP': fields.String(description='Comma separated list of white listed IP addresses or networks that may use this token.', example="['203.0.113.0/24']", attribute='ip'), + 'AuthorizedIP': fields.List(fields.String(description='White listed IP addresses or networks that may use this token.', example="203.0.113.0/24")), }) token_user_post_response = api.model('TokenPostResponse', { @@ -36,14 +36,17 @@ token_user_post_response = api.model('TokenPostResponse', { 'token': fields.String(description='The created authentication token for the user.', example='2caf6607de5129e4748a2c061aee56f2', attribute='password'), 'email': fields.String(description='The email address of the user', example='John.Doe@example.com', attribute='user_email'), 'comment': fields.String(description='A description for the token. This description is shown on the Authentication tokens page', example='my comment'), - 'AuthorizedIP': fields.String(description='Comma separated list of white listed IP addresses or networks that may use this token.', example="['203.0.113.0/24']", attribute='ip'), + 'AuthorizedIP': fields.List(fields.String(description='White listed IP addresses or networks that may use this token.', example="203.0.113.0/24")), 'Created': fields.String(description='The date when the token was created', example='John.Doe@example.com', attribute='created_at') }) + + @token.route('') class Tokens(Resource): @token.doc('list_tokens') @token.marshal_with(token_user_fields, as_list=True, skip_none=True, mask=None) + @token.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @token.doc(security='Bearer') @common.api_token_authorization def get(self): @@ -52,8 +55,10 @@ class Tokens(Resource): @token.doc('create_token') @token.expect(token_user_fields_post) - @token.marshal_with(token_user_post_response, code=200, description='Success', as_list=False, skip_none=True, mask=None) + @token.response(200, 'Success', token_user_post_response) @token.response(400, 'Input validation exception', response_fields) + @token.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) + @token.response(404, 'User not found', response_fields) @token.doc(security='Bearer') @common.api_token_authorization def post(self): @@ -71,7 +76,11 @@ class Tokens(Resource): if 'comment' in data: token_new.comment = data['comment'] if 'AuthorizedIP' in data: - token_new.ip = data['AuthorizedIP'].replace(' ','').split(',') + token_new.ip = data['AuthorizedIP'] + for ip in token_new.ip: + if (not validators.ip_address.ipv4(ip,cidr=True, strict=False, host_bit=False) and + not validators.ip_address.ipv6(ip,cidr=True, strict=False, host_bit=False)): + return { 'code': 400, 'message': f'Provided AuthorizedIP {ip} in {token_new.ip} is invalid'}, 400 raw_password = pwd.genword(entropy=128, length=32, charset="hex") token_new.set_password(raw_password) models.db.session.add(token_new) @@ -91,8 +100,9 @@ class Tokens(Resource): @token.route('user/') class Token(Resource): @token.doc('find_tokens_of_user') - @token.marshal_with(token_user_fields, code=200, description='Success', as_list=True, skip_none=True, mask=None) + @token.response(200, 'Success', token_user_fields) @token.response(400, 'Input validation exception', response_fields) + @token.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @token.response(404, 'Token not found', response_fields) @token.doc(security='Bearer') @common.api_token_authorization @@ -104,12 +114,25 @@ class Token(Resource): if not user_found: return {'code': 404, 'message': f'User {email} cannot be found'}, 404 tokens = user_found.tokens - return tokens + response_list = [] + for token in tokens: + response_dict = { + 'id' : token.id, + 'email' : token.user_email, + 'comment' : token.comment, + 'AuthorizedIP' : token.ip, + 'Created': str(token.created_at), + 'Last edit': str(token.updated_at) + } + response_list.append(response_dict) + return response_list @token.doc('create_token') @token.expect(token_user_fields_post2) @token.response(200, 'Success', token_user_post_response) @token.response(400, 'Input validation exception', response_fields) + @token.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) + @token.response(404, 'User not found', response_fields) @token.doc(security='Bearer') @common.api_token_authorization def post(self, email): @@ -125,7 +148,11 @@ class Token(Resource): if 'comment' in data: token_new.comment = data['comment'] if 'AuthorizedIP' in data: - token_new.ip = token_new.ip = data['AuthorizedIP'].replace(' ','').split(',') + token_new.ip = token_new.ip = data['AuthorizedIP'] + for ip in token_new.ip: + if (not validators.ip_address.ipv4(ip,cidr=True, strict=False, host_bit=False) and + not validators.ip_address.ipv6(ip,cidr=True, strict=False, host_bit=False)): + return { 'code': 400, 'message': f'Provided AuthorizedIP {ip} in {token_new.ip} is invalid'}, 400 raw_password = pwd.genword(entropy=128, length=32, charset="hex") token_new.set_password(raw_password) models.db.session.add(token_new) @@ -144,7 +171,8 @@ class Token(Resource): @token.route('/') class Token(Resource): @token.doc('find_token') - @token.marshal_with(token_user_fields, code=200, description='Success', as_list=False, skip_none=True, mask=None) + @token.response(200, 'Success', token_user_fields) + @token.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @token.response(404, 'Token not found', response_fields) @token.doc(security='Bearer') @common.api_token_authorization @@ -153,11 +181,48 @@ class Token(Resource): token = models.Token.query.get(token_id) if not token: return { 'code' : 404, 'message' : f'Record cannot be found for id {token_id} or invalid id provided'}, 404 - return token + response_dict = { + 'id' : token.id, + 'email' : token.user_email, + 'comment' : token.comment, + 'AuthorizedIP' : token.ip, + 'Created': str(token.created_at), + 'Last edit': str(token.updated_at) + } + return response_dict + + @token.doc('update_token') + @token.expect(token_user_fields_post2) + @token.response(200, 'Success', response_fields) + @token.response(400, 'Input validation exception', response_fields) + @token.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) + @token.response(404, 'User not found', response_fields) + @token.doc(security='Bearer') + def patch(self, token_id): + """ Update the specified token """ + data = api.payload + + token = models.Token.query.get(token_id) + if not token: + return { 'code' : 404, 'message' : f'Record cannot be found for id {token_id} or invalid id provided'}, 404 + + if 'comment' in data: + token.comment = data['comment'] + if 'AuthorizedIP' in data: + token.ip = token.ip = data['AuthorizedIP'] + for ip in token.ip: + if (not validators.ip_address.ipv4(ip,cidr=True, strict=False, host_bit=False) and + not validators.ip_address.ipv6(ip,cidr=True, strict=False, host_bit=False)): + return { 'code': 400, 'message': f'Provided AuthorizedIP {ip} in {token.ip} is invalid'}, 400 + models.db.session.add(token) + #apply the changes + db.session.commit() + return {'code': 200, 'message': f'Token with id {token_id} has been updated'}, 200 + @token.doc('delete_token') @token.response(200, 'Success', response_fields) - @token.response(400, 'Input validation exception', response_fields) + @token.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @token.response(404, 'Token not found', response_fields) @token.doc(security='Bearer') @common.api_token_authorization diff --git a/core/admin/mailu/api/v1/user.py b/core/admin/mailu/api/v1/user.py index c135cfbb..9dc6279e 100644 --- a/core/admin/mailu/api/v1/user.py +++ b/core/admin/mailu/api/v1/user.py @@ -22,7 +22,7 @@ user_fields_get = api.model('UserGet', { 'enable_pop': fields.Boolean(description='Allow email retrieval via POP3'), 'allow_spoofing': fields.Boolean(description='Allow the user to spoof the sender (send email as anyone)'), 'forward_enabled': fields.Boolean(description='Enable auto forwarding'), - 'forward_destination': fields.List(fields.String(description='Email address to forward emails to'), example='Other@example.com'), + 'forward_destination': fields.List(fields.String(description='Email address to forward emails to'), example='["Other@example.com"]'), 'forward_keep': fields.Boolean(description='Keep a copy of the forwarded email in the inbox'), 'reply_enabled': fields.Boolean(description='Enable automatic replies. This is also known as out of office (ooo) or out of facility (oof) replies'), 'reply_subject': fields.String(description='Optional subject for the automatic reply', example='Out of office'), @@ -47,7 +47,7 @@ user_fields_post = api.model('UserCreate', { 'enable_pop': fields.Boolean(description='Allow email retrieval via POP3'), 'allow_spoofing': fields.Boolean(description='Allow the user to spoof the sender (send email as anyone)'), 'forward_enabled': fields.Boolean(description='Enable auto forwarding'), - 'forward_destination': fields.List(fields.String(description='Email address to forward emails to'), example='Other@example.com'), + 'forward_destination': fields.List(fields.String(description='Email address to forward emails to'), example='["Other@example.com"]'), 'forward_keep': fields.Boolean(description='Keep a copy of the forwarded email in the inbox'), 'reply_enabled': fields.Boolean(description='Enable automatic replies. This is also known as out of office (ooo) or out of facility (oof) replies'), 'reply_subject': fields.String(description='Optional subject for the automatic reply', example='Out of office'), @@ -71,7 +71,7 @@ user_fields_put = api.model('UserUpdate', { 'enable_pop': fields.Boolean(description='Allow email retrieval via POP3'), 'allow_spoofing': fields.Boolean(description='Allow the user to spoof the sender (send email as anyone)'), 'forward_enabled': fields.Boolean(description='Enable auto forwarding'), - 'forward_destination': fields.List(fields.String(description='Email address to forward emails to'), example='Other@example.com'), + 'forward_destination': fields.List(fields.String(description='Email address to forward emails to'), example='["Other@example.com"]'), 'forward_keep': fields.Boolean(description='Keep a copy of the forwarded email in the inbox'), 'reply_enabled': fields.Boolean(description='Enable automatic replies. This is also known as out of office (ooo) or out of facility (oof) replies'), 'reply_subject': fields.String(description='Optional subject for the automatic reply', example='Out of office'), @@ -89,6 +89,7 @@ user_fields_put = api.model('UserUpdate', { class Users(Resource): @user.doc('list_user') @user.marshal_with(user_fields_get, as_list=True, skip_none=True, mask=None) + @user.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @user.doc(security='Bearer') @common.api_token_authorization def get(self): @@ -99,6 +100,7 @@ class Users(Resource): @user.expect(user_fields_post) @user.response(200, 'Success', response_fields) @user.response(400, 'Input validation exception', response_fields) + @user.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @user.response(409, 'Duplicate user', response_fields) @user.doc(security='Bearer') @common.api_token_authorization @@ -111,11 +113,12 @@ class Users(Resource): domain_found = models.Domain.query.get(domain_name) if not domain_found: return { 'code': 404, 'message': f'Domain {domain_name} does not exist'}, 404 + if not domain_found.max_users == -1 and len(domain_found.users) >= domain_found.max_users: + return { 'code': 409, 'message': f'Too many users for domain {domain_name}'}, 409 email_found = models.User.query.filter_by(email=data['email']).first() if email_found: return { 'code': 409, 'message': f'User {data["email"]} already exists'}, 409 - user_new = models.User(email=data['email']) if 'raw_password' in data: user_new.set_password(data['raw_password']) @@ -168,12 +171,12 @@ class Users(Resource): return {'code': 200,'message': f'User {data["email"]} has been created'}, 200 - @user.route('/') class User(Resource): @user.doc('find_user') - @user.marshal_with(user_fields_get, code=200, description='Success', as_list=False, skip_none=True, mask=None) + @user.response(200, 'Success', user_fields_get) @user.response(400, 'Input validation exception', response_fields) + @user.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @user.response(404, 'User not found', response_fields) @user.doc(security='Bearer') @common.api_token_authorization @@ -191,6 +194,7 @@ class User(Resource): @user.expect(user_fields_put) @user.response(200, 'Success', response_fields) @user.response(400, 'Input validation exception', response_fields) + @user.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @user.response(404, 'User not found', response_fields) @user.doc(security='Bearer') @common.api_token_authorization @@ -198,7 +202,7 @@ class User(Resource): """ Update the specified user """ data = api.payload if not validators.email(email): - return { 'code': 400, 'message': f'Provided email address {data["email"]} is not a valid email address'}, 400 + return { 'code': 400, 'message': f'Provided email address {email} is not a valid email address'}, 400 user_found = models.User.query.get(email) if not user_found: return {'code': 404, 'message': f'User {email} cannot be found'}, 404 @@ -258,6 +262,7 @@ class User(Resource): @user.doc('delete_user') @user.response(200, 'Success', response_fields) @user.response(400, 'Input validation exception', response_fields) + @user.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @user.response(404, 'User not found', response_fields) @user.doc(security='Bearer') @common.api_token_authorization diff --git a/tests/compose/api/00_create_users.sh b/tests/compose/api/00_create_users.sh new file mode 100755 index 00000000..0a96e968 --- /dev/null +++ b/tests/compose/api/00_create_users.sh @@ -0,0 +1,86 @@ +# create user admin@maiu.io +echo "Create users" +curl --silent --insecure -X 'POST' \ + 'https://localhost/api/v1/user' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "email": "admin@mailu.io", + "raw_password": "password", + "comment": "created for testing RESTful API", + "global_admin": true, + "enabled": true, + "change_pw_next_login": false, + "enable_imap": true, + "enable_pop": true, + "allow_spoofing": false, + "forward_enabled": false, + "reply_enabled": false, + "displayed_name": "admin", + "spam_enabled": true, + "spam_mark_as_read": true +}' | grep 200 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Created admin user (admin@mailu.io) successfully" + +# Test if creating duplicate returns 409 HTTP response. +curl --silent --insecure -X 'POST' \ + 'https://localhost/api/v1/user' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "email": "admin@mailu.io", + "raw_password": "password", + "comment": "created for testing RESTful API", + "global_admin": true, + "enabled": true, + "change_pw_next_login": false, + "enable_imap": true, + "enable_pop": true, + "allow_spoofing": false, + "forward_enabled": false, + "reply_enabled": false, + "displayed_name": "admin", + "spam_enabled": true, + "spam_mark_as_read": true +}' | grep 409 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "OK. Failed creating duplicate user." + +# create user user@mailu.io +curl --silent --insecure -X 'POST' \ + 'https://localhost/api/v1/user' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "email": "user@mailu.io", + "raw_password": "password", + "comment": "created for testing RESTful API", + "global_admin": false, + "enabled": true, + "change_pw_next_login": false, + "enable_imap": true, + "enable_pop": true, + "allow_spoofing": false, + "forward_enabled": false, + "reply_enabled": false, + "displayed_name": "admin", + "spam_enabled": true, + "spam_mark_as_read": true +}' | grep 200 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Created user (user@mailu.io) successfully" + +echo "Finished 00_create_users.sh" \ No newline at end of file diff --git a/tests/compose/api/01_test_user_interfaces.sh b/tests/compose/api/01_test_user_interfaces.sh new file mode 100755 index 00000000..f63258c3 --- /dev/null +++ b/tests/compose/api/01_test_user_interfaces.sh @@ -0,0 +1,80 @@ +echo "Test user interfaces" +# create user user@mailu.io for testing deletion +curl --silent --insecure -X 'POST' \ + 'https://localhost/api/v1/user' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "email": "user2@mailu.io", + "raw_password": "password", + "comment": "created for testing RESTful API", + "global_admin": false, + "enabled": true, + "change_pw_next_login": false, + "enable_imap": true, + "enable_pop": true, + "allow_spoofing": false, + "forward_enabled": false, + "reply_enabled": false, + "displayed_name": "admin", + "spam_enabled": true, + "spam_mark_as_read": true +}' | grep 200 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "created user (user2@mailu.io) successfully" + +#delete user2@mailu.io +curl --silent --insecure -X 'DELETE' \ + 'https://localhost/api/v1/user/user2%40mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + | grep 200 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Deleted user2 (user2@mailu.io) successfully" + +#Check if updating user works +curl --silent --insecure -X 'PATCH' \ + 'https://localhost/api/v1/user/user%40mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "comment": "updated_comment" +}' | grep 200 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Updated user(user@mailu.io) successfully" + +curl --silent --insecure -X 'GET' \ + 'https://localhost/api/v1/user/user%40mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + | grep updated_comment + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Confirmed that comment attribute of user was correctly updated" + +# try get all users. At this moment we should have 2 users total +curl --silent --insecure -X 'GET' \ + 'https://localhost/api/v1/user' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + | grep -o "email" | grep -c "email" | grep 2 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Retrieved all users successfully" + +echo "Finished 01_test_user_interfaces.sh" \ No newline at end of file diff --git a/tests/compose/api/02_test_domain_interfaces.sh b/tests/compose/api/02_test_domain_interfaces.sh new file mode 100755 index 00000000..c989f7a6 --- /dev/null +++ b/tests/compose/api/02_test_domain_interfaces.sh @@ -0,0 +1,145 @@ +echo "Test Domain interfaces" + +curl --silent --insecure -X 'POST' \ + 'https://localhost/api/v1/domain' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "mailu2.io", + "comment": "internal domain for testing", + "max_users": -1, + "max_aliases": -1, + "max_quota_bytes": 0, + "signup_enabled": false +}' | grep 200 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Domain mail2.io has been created successfully" + +curl --silent --insecure -X 'PATCH' \ + 'https://localhost/api/v1/domain/mailu2.io' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "comment": "updated_domain" +}' | grep 200 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Domain mail2.io has been updated" + +curl --silent --insecure -X 'GET' \ + 'https://localhost/api/v1/domain/mailu2.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep updated_domain + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Confirmed that comment attribute of domain mailu2.io was correctly updated" + +# try get all domains. At this moment we should have 2 domains total +curl --silent --insecure -X 'GET' \ + 'https://localhost/api/v1/domain' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + | grep -o "name" | grep -c "name" | grep 2 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Retrieved all domains successfully" + +# try create dkim keys +curl --silent --insecure -X 'POST' \ + 'https://mailutest/api/v1/domain/mailu2.io/dkim' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + -d '' \ + | grep 200 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "dkim keys were created successfully for domain mailu2.io" + +# try deleting a domain +curl --silent --insecure -X 'DELETE' \ + 'https://localhost/api/v1/domain/mailu2.io' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + | grep 200 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Domain mailu2.io was deleted successfully" + +# try looking up all users of a domain. There should be 2 users. +curl --silent --insecure -X 'GET' \ + 'https://mailutest/api/v1/domain/mailu.io/users' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep -o "email" | grep -c "email" | grep 2 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Retrieved all users of domain mailu.io successfully" + + +#### Alternatives + +#try to create an alternative +curl --silent --insecure -X 'POST' \ + 'https://mailutest/api/v1/alternative' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "mailu2.io", + "domain": "mailu.io" +}' | grep 200 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Alternative mailu2.io for domain mailu.io was created successfully" + +# try get all alternatives. At this moment we should have 1 alternative total +curl --silent --insecure -X 'GET' \ + 'https://localhost/api/v1/alternative' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + | grep -o "name" | grep -c "name" | grep 1 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Retrieved all alternatives successfully" + +# try to check if an alternative exists +curl --silent --insecure -X 'GET' \ + 'https://mailutest/api/v1/alternative/mailu2.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep '{"name": "mailu2.io", "domain": "mailu.io"}' + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Lookup for alternative mailu2.io was successful" + +# try to delete an alternative +curl --silent --insecure -X 'DELETE' \ + 'https://mailutest/api/v1/alternative/mailu2.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' + +echo "Finshed 02_test_domain_interfaces.sh" \ No newline at end of file diff --git a/tests/compose/api/03_test_token_interfaces.sh b/tests/compose/api/03_test_token_interfaces.sh new file mode 100755 index 00000000..9180bc7a --- /dev/null +++ b/tests/compose/api/03_test_token_interfaces.sh @@ -0,0 +1,107 @@ +echo "start token tests" + +# Try creating a token /token +curl --silent --insecure -X 'POST' \ + 'https://mailutest/api/v1/token' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "email": "user@mailu.io", + "comment": "my token related comment", + "AuthorizedIP": [ + "203.0.113.0/24", + "203.2.114.2/32" + ] +}' | grep '"token": "' +if [ $? -ne 0 ]; then + exit 1 +fi +echo "created a token for user@mailu.io successfully" + +# Try create a token for a specific user /tokenuser/{email} +curl --silent --insecure -X 'POST' \ + 'https://mailutest/api/v1/tokenuser/user%40mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "comment": "token test" +}' | grep '"token": "' +if [ $? -ne 0 ]; then + exit 1 +fi +echo "created a second token for user@mailu.io successfully" + +# Try retrieving all tokens /token. We expect to retrieve 2 in total. +curl --silent --insecure -X 'GET' \ + 'https://mailutest/api/v1/token' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep -o "id" | grep -c "id" | grep 2 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Retrieved all tokens (2 in total) successfully" + +# Try finding a specific token /token/{token_id} +curl --silent --insecure -X 'GET' \ + 'https://mailutest/api/v1/token/2' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep '"id": 2' +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Retrieved token with id 2 successfully" + +# Try deleting a token /token/{token_id} +curl --silent --insecure -X 'DELETE' \ + 'https://mailutest/api/v1/token/1' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep 200 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Deleted token with id 1 successfully" + +# Try updating a token /token/{token_id} +curl --silent --insecure -X 'PATCH' \ + 'https://mailutest/api/v1/token/2' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "comment": "updated_comment", + "AuthorizedIP": [ + "203.0.112.0/24" + ] +}' | grep 200 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Updated token with id 2 successfully" + +curl --silent --insecure -X 'GET' \ + 'https://mailutest/api/v1/token/2' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep 'comment": "updated_comment"' +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Confirmed that comment field of token with id 2 was correctly updated" + +# Try looking up all tokens of a specific user /tokenuser/{email} +curl --silent --insecure -X 'GET' \ + 'https://mailutest/api/v1/tokenuser/user%40mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep -o "id" | grep -c "id" | grep 1 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Retrieved all tokens (1 in total) for user@mailu.io successfully" + +echo "Finished 03_test_token_interfaces.sh" \ No newline at end of file diff --git a/tests/compose/api/04_test_relay_interfaces.sh b/tests/compose/api/04_test_relay_interfaces.sh new file mode 100755 index 00000000..9fb493e1 --- /dev/null +++ b/tests/compose/api/04_test_relay_interfaces.sh @@ -0,0 +1,98 @@ +echo "Start 04_test_relay_interfaces.sh" + +# Try creating a new relay /relay +curl --silent --insecure -X 'POST' \ + 'https://mailutest/api/v1/relay' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "relay1.mailu.io", + "smtp": "relay1.mailu.io:8755", + "comment": "backup relay1" +}' | grep 200 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "created a relay for domain relay1.mailu.io successfully" + +curl --silent --insecure -X 'POST' \ + 'https://mailutest/api/v1/relay' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "relay2.mailu.io", + "comment": "backup relay2" +}' | grep 200 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "created a relay for domain relay2.mailu.io successfully" + +# Try retrieving all relays /relay. We expect to retrieve 2 in total +curl --silent --insecure -X 'GET' \ + 'https://mailutest/api/v1/relay' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep -o '"name":' | grep -c '"name":' | grep 2 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Retrieved all relays (2 in total) successfully" + +# Try looking up a specific relay /relay/{name} +curl --silent --insecure -X 'GET' \ + 'https://mailutest/api/v1/relay/relay1.mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep '"name": "relay1.mailu.io"' +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Retrieved the specified relay (relay1.mailu.io) successfully" + +# Try deleting a specific relay /relay/{name} +curl -silent --insecure -X 'DELETE' \ + 'https://mailutest/api/v1/relay/relay2.mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep 200 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Deleted relay2.mailu.io successfully" + +curl --silent --insecure -X 'GET' \ + 'https://mailutest/api/v1/relay' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep -o '"name":' | grep -c '"name":' | grep 1 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "confirmed we only have 1 relay now" + +# Try updating a specific relay /relay/{name} +curl --silent --insecure -X 'PATCH' \ + 'https://mailutest/api/v1/relay/relay1.mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "smtp": "anotherName", + "comment": "updated_comment" +}' | grep 200 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "update of relay was succcessful" + +curl --silent --insecure -X 'GET' \ + 'https://mailutest/api/v1/relay/relay1.mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep anotherName | grep updated_comment +echo "confirmed that smtp attribute and comment attribute were correctly updated" + +echo "Finished 04_test_relay_interfaces.sh" \ No newline at end of file diff --git a/tests/compose/api/05_test_alias_interfaces.sh b/tests/compose/api/05_test_alias_interfaces.sh new file mode 100755 index 00000000..be23c92e --- /dev/null +++ b/tests/compose/api/05_test_alias_interfaces.sh @@ -0,0 +1,111 @@ +# try create, find, lookup, delete + +echo "Start 05_test_alias_interfaces.sh" + +# Try creating a new alias /alias +curl --silent --insecure -X 'POST' \ + 'https://mailutest/api/v1/alias' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "comment": "test alias for user@mailu.io and admin@mailu.io", + "destination": [ + "user@mailu.io", + "admin@mailu.io" + ], + "wildcard": false, + "email": "test@mailu.io" +}' | grep 200 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Created alias test@mailu.io succcessfully for user@mailu.io and admin@mailu.io" + +curl --silent --insecure -X 'POST' \ + 'https://mailutest/api/v1/alias' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "comment": "test2 alias for user@mailu.io", + "destination": [ + "user@mailu.io" + ], + "wildcard": false, + "email": "test2@mailu.io" +}' | grep 200 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Created alias test2@mailu.io succcessfully for user@mailu.io " + +# Try retrieving all aliases /alias. We expect to retrieve 2 +curl --silent --insecure -X 'GET' \ + 'https://mailutest/api/v1/alias' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep -o '"destination":' | grep -c '"destination":' | grep 2 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Successfully retrieved 2 aliases" + +# Try looking up the aliases for a specific domain /alias/destination/{domain}. We expect to retrieve 2 +curl --silent --insecure -X 'GET' \ + 'https://mailutest/api/v1/alias/destination/mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep -o '"destination":' | grep -c '"destination":' | grep 2 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Successfully retrieved 2 aliases" + +# Try deleting a specific alias /alias/{alias} +curl --silent --insecure -X 'DELETE' \ + 'https://mailutest/api/v1/alias/test2%40mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep 200 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Deleted alias test2@mailu.io succcessfully" + +# Try updating a specific alias /alias/{alias} +curl --silent --insecure -X 'PATCH' \ + 'https://mailutest/api/v1/alias/test%40mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "comment": "updated_comment", + "destination": [ + "user@mailu.io" + ], + "wildcard": true +}' | grep 200 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Updated alias test2@mailu.io succcessfully" + +# Try looking up a specific alias /alias/{alias}. +#Check if values were updated correctyly in previous step. +response=$(curl --silent --insecure -X 'GET' \ + 'https://mailutest/api/v1/alias/test%40mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest') +echo $response | grep 'admin@mailu.io' +if [ $? -ne 1 ]; then + exit 1 +fi +echo "Confirmed that destination admin@mailu.io is removed from alias test@mailu.io" +echo $response | grep 'updated_comment' +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Confirmed that comment attribute is updated successfully" + +echo "Finished 05_test_alias_interfaces.sh" \ No newline at end of file diff --git a/tests/compose/api/docker-compose.yml b/tests/compose/api/docker-compose.yml new file mode 100644 index 00000000..384e89cd --- /dev/null +++ b/tests/compose/api/docker-compose.yml @@ -0,0 +1,112 @@ +# This file is auto-generated by the Mailu configuration wizard. +# Please read the documentation before attempting any change. +# Generated for compose flavor + +version: '3.6' + +services: + + # External dependencies + redis: + image: redis:alpine + restart: always + volumes: + - "/mailu/redis:/data" + + # Core services + front: + image: ${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}nginx:${MAILU_VERSION:-local} + restart: always + env_file: mailu.env + logging: + driver: json-file + ports: + - "127.0.0.1:80:80" + - "127.0.0.1:443:443" + - "127.0.0.1:25:25" + - "127.0.0.1:465:465" + - "127.0.0.1:587:587" + - "127.0.0.1:110:110" + - "127.0.0.1:995:995" + - "127.0.0.1:143:143" + - "127.0.0.1:993:993" + - "127.0.0.1:4190:4190" + volumes: + - "/mailu/certs:/certs" + + admin: + image: ${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}admin:${MAILU_VERSION:-local} + restart: always + env_file: mailu.env + volumes: + - "/mailu/data:/data" + - "/mailu/dkim:/dkim" + dns: + - 192.168.203.254 + depends_on: + - redis + - resolver + + imap: + image: ${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}dovecot:${MAILU_VERSION:-local} + restart: always + env_file: mailu.env + volumes: + - "/mailu/mail:/mail" + - "/mailu/overrides:/overrides" + depends_on: + - front + + smtp: + image: ${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}postfix:${MAILU_VERSION:-local} + restart: always + env_file: mailu.env + volumes: + - "/mailu/overrides:/overrides" + depends_on: + - front + + oletools: + image: ${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}oletools:${MAILU_VERSION:-local} + hostname: oletools + restart: always + networks: + - noinet + + antispam: + image: ${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}rspamd:${MAILU_VERSION:-local} + restart: always + env_file: mailu.env + networks: + - default + - noinet + volumes: + - "/mailu/filter:/var/lib/rspamd" + - "/mailu/dkim:/dkim" + - "/mailu/overrides/rspamd:/etc/rspamd/override.d" + depends_on: + - front + + # Optional services + + resolver: + image: ${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}unbound:${MAILU_VERSION:-local} + env_file: mailu.env + restart: always + networks: + default: + ipv4_address: 192.168.203.254 + + # Webmail + + +networks: + default: + driver: bridge + ipam: + driver: default + config: + - subnet: 192.168.203.0/24 + noinet: + driver: bridge + internal: true diff --git a/tests/compose/api/mailu.env b/tests/compose/api/mailu.env new file mode 100644 index 00000000..d9789150 --- /dev/null +++ b/tests/compose/api/mailu.env @@ -0,0 +1,151 @@ +# Mailu main configuration file +# +# Generated for compose flavor +# +# This file is autogenerated by the configuration management wizard. +# For a detailed list of configuration variables, see the documentation at +# https://mailu.io + +################################### +# Common configuration variables +################################### + +# Set this to the path where Mailu data and configuration is stored +# This variable is now set directly in `docker-compose.yml by the setup utility +# ROOT=/mailu + +# Mailu version to run (1.0, 1.1, etc. or master) +#VERSION=master + +# Set to a randomly generated 16 bytes string +SECRET_KEY=HGZCYGVI6FVG31HS + +# Address where listening ports should bind +# This variables are now set directly in `docker-compose.yml by the setup utility +# PUBLIC_IPV4= 127.0.0.1 (default: 127.0.0.1) +# PUBLIC_IPV6= (default: ::1) + +# Subnet of the docker network. This should not conflict with any networks to which your system is connected. (Internal and external!) +SUBNET=192.168.203.0/24 + +# Main mail domain +DOMAIN=mailu.io + +# Hostnames for this server, separated with commas +HOSTNAMES=localhost + +# Postmaster local part (will append the main mail domain) +POSTMASTER=admin + +# Choose how secure connections will behave (value: letsencrypt, cert, notls, mail, mail-letsencrypt) +TLS_FLAVOR=cert + +# Authentication rate limit (per source IP address) +AUTH_RATELIMIT=10/minute;1000/hour + +# Opt-out of statistics, replace with "True" to opt out +DISABLE_STATISTICS=False + +################################### +# Optional features +################################### + +# Expose the admin interface (value: true, false) +ADMIN=true + +# Choose which webmail to run if any (values: roundcube, snappymail, none) +WEBMAIL=none + +# Dav server implementation (value: radicale, none) +WEBDAV=none + +# Antivirus solution (value: clamav, none) +#ANTIVIRUS=none + +#Antispam solution +ANTISPAM=none + +#RESTful API +API=true + +# Scan Macros solution (value: true, false) +SCAN_MACROS=True + +################################### +# Mail settings +################################### + +# Message size limit in bytes +# Default: accept messages up to 50MB +MESSAGE_SIZE_LIMIT=50000000 + +# Networks granted relay permissions +# Use this with care, all hosts in this networks will be able to send mail without authentication! +RELAYNETS= + +# Will relay all outgoing mails if configured +RELAYHOST= + +# Show fetchmail functionality in admin interface +FETCHMAIL_ENABLED=false + +# Fetchmail delay +FETCHMAIL_DELAY=600 + +# Recipient delimiter, character used to delimiter localpart from custom address part +RECIPIENT_DELIMITER=+ + +# DMARC rua and ruf email +DMARC_RUA=admin +DMARC_RUF=admin + + +# Maildir Compression +# choose compression-method, default: none (value: gz, bz2, lz4, zstd) +COMPRESSION= +# change compression-level, default: 6 (value: 1-9) +COMPRESSION_LEVEL= + +################################### +# Web settings +################################### + +# Path to the admin interface if enabled +WEB_ADMIN=/admin + +# Path to the webmail if enabled +WEB_WEBMAIL=/webmail + +WEB_API=/api + +# Website name +SITENAME=Mailu + +# Linked Website URL +WEBSITE=https://mailu.io + + + +################################### +# Advanced settings +################################### + +# Log driver for front service. Possible values: +# json-file (default) +# journald (On systemd platforms, useful for Fail2Ban integration) +# syslog (Non systemd platforms, Fail2Ban integration. Disables `docker compose log` for front!) +# LOG_DRIVER=json-file + +# Docker-compose project name, this will prepended to containers names. +COMPOSE_PROJECT_NAME=mailu + +# Header to take the real ip from +REAL_IP_HEADER= + +# IPs for nginx set_real_ip_from (CIDR list separated by commas) +REAL_IP_FROM= + +# choose wether mailu bounces (no) or rejects (yes) mail when recipient is unknown (value: yes, no) +REJECT_UNLISTED_RECIPIENT= + +API_TOKEN=apitest \ No newline at end of file