mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
perf(web-app-stalwart): provision accounts and bot app passwords in batched JMAP calls
Every account ran about six JMAP calls of three Ansible tasks each through jmap_call.yml, 80 s for 7 accounts on echoserver. Each phase now sends one request for all accounts: one x:Account/set creates them all (ids from created or, on alreadyExists, from notCreated.objectId), one updates the passwords of the accounts that already existed, and the app-password phases (list, mint, retire, drop) carry one method call per bot, each addressing its bot through accountId, in chunks of STALWART_JMAP_MAX_METHOD_CALLS (Stalwart's default maxMethodCalls, 16). jmap_call.yml retries and asserts on every method response of a batch, and a refusal names its owner through the call id. A create refused for a reason other than alreadyExists now fails the deploy instead of being skipped. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e8872bede8
commit
1fbd4758e3
9 files changed
+768
-119
No files matched your search
@@ -156,8 +156,9 @@ chaining or fallback. Enabling SSO therefore **disables the account password**
|
||||
for every account, including the machine `no-reply` account. App passwords are
|
||||
checked before the directory and keep working, so with SSO enabled the role:
|
||||
|
||||
1. Creates one app password per bot account, acting for that account as the
|
||||
recovery admin (`tasks/04_manage_user/app_password.yml`), and
|
||||
1. Creates one app password per bot account as the recovery admin, which
|
||||
addresses each bot account through its JMAP `accountId`
|
||||
(`tasks/04_manage_user/app_password.yml`), and
|
||||
2. Stores it in the token store, where
|
||||
[`plugins/lookup/email.py`](../../plugins/lookup/email.py) reads the SMTP
|
||||
password of the `no-reply` client.
|
||||
@@ -207,6 +208,12 @@ account (or the Keycloak SSO token when SSO is enabled).
|
||||
(`templates/jmap/*.json.j2`).
|
||||
- **Only authenticated sessions relay, with or without SSO.** Under SSO a bot
|
||||
authenticates with an app password.
|
||||
- **Accounts and app passwords are provisioned in batches.** Each phase
|
||||
(create, password update, app password list, mint, retire, drop) is one JMAP
|
||||
request for all accounts. A phase on the bots' app passwords carries one
|
||||
method call per bot, at most `STALWART_JMAP_MAX_METHOD_CALLS` (Stalwart's
|
||||
default `maxMethodCalls`) per request, and a refusal names the account it
|
||||
hit.
|
||||
- **Address bans expire after `services.stalwart.ban_period_seconds`** (one
|
||||
hour by default). Without a period Stalwart keeps a ban forever, and the
|
||||
reverse proxy, the published ports, Tor and the swarm ingress hand many
|
||||
|
||||
@@ -29,19 +29,8 @@
|
||||
when: stalwart_domain_id | length == 0
|
||||
|
||||
- name: "⚙️ Provision Stalwart accounts"
|
||||
include_tasks:
|
||||
file: 04_manage_user.yml
|
||||
apply:
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
vars:
|
||||
stalwart_user_key: "{{ user_item.key }}"
|
||||
stalwart_user_name: "{{ user_item.value.username }}"
|
||||
stalwart_user_password: "{{ user_item.value.password }}"
|
||||
stalwart_user_roles: "{{ user_item.value.roles | default([]) }}"
|
||||
loop: "{{ STALWART_USERS | dict2items }}"
|
||||
loop_control:
|
||||
loop_var: user_item
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
include_tasks: 04_manage_user.yml
|
||||
when: STALWART_USERS | length > 0
|
||||
|
||||
- name: "🔧 Configure Stalwart mail-listener TLS (platform cert)"
|
||||
include_tasks: 05_tls.yml
|
||||
|
||||
@@ -2,58 +2,118 @@
|
||||
# Bots authenticate over SMTP with a derived sha256-hex secret instead of the
|
||||
# raw inventory password: consumers embed email.password unescaped in dotenv
|
||||
# URLs and YAML, where the password charset ('[', '@', '#', ...) breaks parsing.
|
||||
- name: "🔍 Resolve effective secret for '{{ stalwart_user_name }}'"
|
||||
- name: "🔍 Resolve the Stalwart accounts and their effective secrets"
|
||||
ansible.builtin.set_fact:
|
||||
stalwart_user_secret: >-
|
||||
{{ stalwart_user_password | hash('sha256')
|
||||
if 'bot' in stalwart_user_roles else stalwart_user_password }}
|
||||
stalwart_accounts: >-
|
||||
{%- set accounts = [] -%}
|
||||
{%- for key, user in STALWART_USERS.items() -%}
|
||||
{%- set bot = 'bot' in (user.roles | default([])) -%}
|
||||
{%- set _ = accounts.append({
|
||||
'key': key,
|
||||
'name': user.username,
|
||||
'address': user.username ~ '@' ~ STALWART_DOMAIN,
|
||||
'description': user.email | default(user.username),
|
||||
'bot': bot,
|
||||
'secret': user.password | hash('sha256') if bot else user.password,
|
||||
'stored': (user.tokens | default({}, true)).get(application_id, '')}) -%}
|
||||
{%- endfor -%}
|
||||
{{ accounts }}
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
- name: "📁 Create Stalwart account '{{ stalwart_user_name }}@{{ STALWART_DOMAIN }}'"
|
||||
- name: "📁 Create the Stalwart accounts"
|
||||
include_tasks: jmap_call.yml
|
||||
vars:
|
||||
stalwart_jmap_tolerate_rejected: true
|
||||
stalwart_jmap_body: >-
|
||||
{%- set create = {} -%}
|
||||
{%- for account in stalwart_accounts -%}
|
||||
{%- set _ = create.update({'u' ~ loop.index0: {
|
||||
'@type': 'User',
|
||||
'name': account.name,
|
||||
'domainId': stalwart_domain_id,
|
||||
'description': account.description,
|
||||
'credentials': {'0': {'@type': 'Password', 'secret': account.secret}}}}) -%}
|
||||
{%- endfor -%}
|
||||
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
|
||||
'methodCalls': [['x:Account/set', {'create': {'a': {
|
||||
'@type': 'User',
|
||||
'name': stalwart_user_name,
|
||||
'domainId': stalwart_domain_id,
|
||||
'description': (lookup('users', stalwart_user_key).email | default(stalwart_user_name)),
|
||||
'credentials': {'0': {'@type': 'Password', 'secret': stalwart_user_secret}}
|
||||
}}}, 'c0']]} | to_json }}
|
||||
'methodCalls': [['x:Account/set', {'create': create}, 'c0']]} | to_json }}
|
||||
|
||||
- name: "🔍 Resolve pre-existing account id for '{{ stalwart_user_name }}'"
|
||||
- name: "🔍 Match every account to its Stalwart id"
|
||||
ansible.builtin.set_fact:
|
||||
stalwart_existing_account_id: >-
|
||||
{{ ((stalwart_jmap.methodResponses[0][1].notCreated | default({}))
|
||||
.get('a', {}).get('objectId', {}) or {}).get('id', '') }}
|
||||
stalwart_accounts: >-
|
||||
{%- set result = stalwart_jmap.methodResponses[0][1] -%}
|
||||
{%- set accounts = [] -%}
|
||||
{%- for account in stalwart_accounts -%}
|
||||
{%- set created = (result.created | default({}, true)).get('u' ~ loop.index0) or {} -%}
|
||||
{%- set refusal = (result.notCreated | default({}, true)).get('u' ~ loop.index0) or {} -%}
|
||||
{%- set existing = refusal.get('type') == 'alreadyExists' -%}
|
||||
{%- set _ = accounts.append(account | combine({
|
||||
'id': ((refusal.get('objectId') or {}).get('id') if existing else created.get('id')) or '',
|
||||
'existing': existing,
|
||||
'refusal': refusal})) -%}
|
||||
{%- endfor -%}
|
||||
{{ accounts }}
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
- name: "🔄 Update password for existing account '{{ stalwart_user_name }}'"
|
||||
include_tasks: jmap_call.yml
|
||||
- name: "✅ Ensure Stalwart created or already holds every account"
|
||||
ansible.builtin.assert:
|
||||
that: stalwart_account_refused | length == 0
|
||||
fail_msg: "Stalwart refused the accounts {{ stalwart_account_refused }}"
|
||||
quiet: true
|
||||
vars:
|
||||
stalwart_jmap_body: >-
|
||||
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
|
||||
'methodCalls': [['x:Account/set', {'update': {stalwart_existing_account_id: {
|
||||
'credentials': {'0': {'@type': 'Password', 'secret': stalwart_user_secret}}
|
||||
}}}, 'c0']]} | to_json }}
|
||||
when: stalwart_existing_account_id | length > 0
|
||||
stalwart_account_refused: >-
|
||||
{{ dict(stalwart_accounts | rejectattr('id') | map(attribute='address')
|
||||
| zip(stalwart_accounts | rejectattr('id') | map(attribute='refusal'))) }}
|
||||
|
||||
- name: "💾 Persist Stalwart submission credential for bot '{{ stalwart_user_name }}'"
|
||||
- name: "🔄 Update the password of every pre-existing account"
|
||||
when: stalwart_accounts | selectattr('existing') | list | length > 0
|
||||
block:
|
||||
- name: "🔄 Send the passwords of the pre-existing accounts"
|
||||
include_tasks: jmap_call.yml
|
||||
vars:
|
||||
stalwart_jmap_tolerate_rejected: true
|
||||
stalwart_jmap_body: >-
|
||||
{%- set changes = {} -%}
|
||||
{%- for account in stalwart_accounts if account.existing -%}
|
||||
{%- set _ = changes.update({account.id: {
|
||||
'credentials': {'0': {'@type': 'Password', 'secret': account.secret}}}}) -%}
|
||||
{%- endfor -%}
|
||||
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
|
||||
'methodCalls': [['x:Account/set', {'update': changes}, 'c0']]} | to_json }}
|
||||
|
||||
- name: "✅ Ensure Stalwart took the password of every pre-existing account"
|
||||
ansible.builtin.assert:
|
||||
that: stalwart_account_refused | length == 0
|
||||
fail_msg: "Stalwart refused the password of the accounts {{ stalwart_account_refused }}"
|
||||
quiet: true
|
||||
vars:
|
||||
stalwart_account_refused: >-
|
||||
{%- set rejected = stalwart_jmap.methodResponses[0][1].notUpdated | default({}, true) -%}
|
||||
{%- set refused = {} -%}
|
||||
{%- for account in stalwart_accounts if account.id in rejected -%}
|
||||
{%- set _ = refused.update({account.address: rejected[account.id]}) -%}
|
||||
{%- endfor -%}
|
||||
{{ refused }}
|
||||
|
||||
- name: "💾 Persist the Stalwart submission credential of every bot"
|
||||
ansible.builtin.include_role:
|
||||
name: sys-token-store
|
||||
tasks_from: write.yml
|
||||
vars:
|
||||
sys_token_store_user_key: "{{ stalwart_user_key }}"
|
||||
sys_token_store_user_key: "{{ stalwart_bot.key }}"
|
||||
sys_token_store_app: "{{ application_id }}"
|
||||
sys_token_store_token: "{{ stalwart_user_secret }}"
|
||||
when:
|
||||
- "'bot' in stalwart_user_roles"
|
||||
- not (STALWART_SSO_ENABLED | bool)
|
||||
sys_token_store_token: "{{ stalwart_bot.secret }}"
|
||||
loop: "{{ stalwart_accounts | selectattr('bot') | list }}"
|
||||
loop_control:
|
||||
loop_var: stalwart_bot
|
||||
label: "{{ stalwart_bot.address }}"
|
||||
when: not (STALWART_SSO_ENABLED | bool)
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
- name: "🔑 Keep an app password for bot '{{ stalwart_user_name }}' under SSO"
|
||||
- name: "🔑 Keep an app password for every bot under SSO"
|
||||
include_tasks: 04_manage_user/app_password.yml
|
||||
vars:
|
||||
stalwart_jmap_act_as: "{{ stalwart_user_name }}@{{ STALWART_DOMAIN }}"
|
||||
when:
|
||||
- "'bot' in stalwart_user_roles"
|
||||
- STALWART_SSO_ENABLED | bool
|
||||
loop: "{{ stalwart_accounts | selectattr('bot') | batch(STALWART_JMAP_MAX_METHOD_CALLS) | list }}"
|
||||
loop_control:
|
||||
loop_var: stalwart_bots
|
||||
label: "{{ stalwart_bots | map(attribute='address') | join(', ') }}"
|
||||
when: STALWART_SSO_ENABLED | bool
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
@@ -1,69 +1,102 @@
|
||||
---
|
||||
# Param: stalwart_jmap_act_as - address of the bot account, every call below runs as that account.
|
||||
- name: "📖 List the app passwords of '{{ stalwart_user_name }}'"
|
||||
# Param: stalwart_bots - up to STALWART_JMAP_MAX_METHOD_CALLS bot entries of stalwart_accounts; each method call addresses one bot through its accountId.
|
||||
- name: "📖 List the app passwords of the bots"
|
||||
include_tasks: "{{ role_path }}/tasks/jmap_call.yml"
|
||||
vars:
|
||||
stalwart_jmap_body: >-
|
||||
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
|
||||
'methodCalls': [['x:AppPassword/get', {'ids': none}, 'c0']]} | to_json }}
|
||||
{%- set calls = [] -%}
|
||||
{%- for bot in stalwart_bots -%}
|
||||
{%- set _ = calls.append(['x:AppPassword/get', {'accountId': bot.id, 'ids': none}, bot.address]) -%}
|
||||
{%- endfor -%}
|
||||
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], 'methodCalls': calls} | to_json }}
|
||||
|
||||
- name: "🔍 Decide whether '{{ stalwart_user_name }}' needs a new app password"
|
||||
- name: "🔍 Decide which bots need a new app password"
|
||||
ansible.builtin.set_fact:
|
||||
stalwart_app_password_ids: "{{ stalwart_app_password_owned }}"
|
||||
stalwart_app_password_mint: "{{ stalwart_app_password_renew }}"
|
||||
stalwart_app_password_drop: "{{ stalwart_app_password_retired + (stalwart_app_password_owned if stalwart_app_password_renew | bool else []) }}"
|
||||
vars:
|
||||
stalwart_app_password_owned: >-
|
||||
{{ stalwart_jmap.methodResponses[0][1].list
|
||||
| selectattr('description', 'equalto', STALWART_BOT_APP_PASSWORD_DESCRIPTION)
|
||||
| map(attribute='id') | list }}
|
||||
stalwart_app_password_retired: >-
|
||||
{{ stalwart_jmap.methodResponses[0][1].list
|
||||
| selectattr('description', 'equalto', STALWART_BOT_APP_PASSWORD_RETIRED_DESCRIPTION)
|
||||
| map(attribute='id') | list }}
|
||||
stalwart_app_password_stored: "{{ lookup('users', stalwart_user_key, {}).get('tokens', {}).get(application_id, '') }}"
|
||||
stalwart_app_password_renew: >-
|
||||
{{ (stalwart_app_password_owned | length != 1)
|
||||
or (stalwart_app_password_stored | length == 0)
|
||||
or (stalwart_app_password_stored == stalwart_user_secret) }}
|
||||
stalwart_app_password_plan: >-
|
||||
{%- set listed = {} -%}
|
||||
{%- for method, result, call in stalwart_jmap.methodResponses -%}
|
||||
{%- set _ = listed.update({call: result.list}) -%}
|
||||
{%- endfor -%}
|
||||
{%- set plan = [] -%}
|
||||
{%- for bot in stalwart_bots -%}
|
||||
{%- set owned = listed[bot.address] | selectattr('description', 'equalto', STALWART_BOT_APP_PASSWORD_DESCRIPTION)
|
||||
| map(attribute='id') | list -%}
|
||||
{%- set retired = listed[bot.address] | selectattr('description', 'equalto', STALWART_BOT_APP_PASSWORD_RETIRED_DESCRIPTION)
|
||||
| map(attribute='id') | list -%}
|
||||
{%- set renew = (owned | length != 1) or (bot.stored | length == 0) or (bot.stored == bot.secret) -%}
|
||||
{%- set _ = plan.append({'key': bot.key, 'address': bot.address, 'id': bot.id, 'owned': owned,
|
||||
'renew': renew, 'drop': retired + (owned if renew else [])}) -%}
|
||||
{%- endfor -%}
|
||||
{{ plan }}
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
- name: "🔑 Replace the app password of '{{ stalwart_user_name }}'"
|
||||
when: stalwart_app_password_mint | bool
|
||||
- name: "🔑 Replace the app passwords of the bots that need a new one"
|
||||
when: stalwart_app_password_plan | selectattr('renew') | list | length > 0
|
||||
block:
|
||||
- name: "🔑 Mint the app password of '{{ stalwart_user_name }}'"
|
||||
- name: "🔑 Mint the new app passwords"
|
||||
include_tasks: "{{ role_path }}/tasks/jmap_call.yml"
|
||||
vars:
|
||||
stalwart_jmap_body: >-
|
||||
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
|
||||
'methodCalls': [['x:AppPassword/set', {'create': {'p': {
|
||||
'description': STALWART_BOT_APP_PASSWORD_DESCRIPTION
|
||||
}}}, 'c0']]} | to_json }}
|
||||
{%- set calls = [] -%}
|
||||
{%- for bot in stalwart_app_password_plan if bot.renew -%}
|
||||
{%- set _ = calls.append(['x:AppPassword/set', {'accountId': bot.id,
|
||||
'create': {'p': {'description': STALWART_BOT_APP_PASSWORD_DESCRIPTION}}}, bot.address]) -%}
|
||||
{%- endfor -%}
|
||||
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], 'methodCalls': calls} | to_json }}
|
||||
|
||||
- name: "💾 Persist the app password of '{{ stalwart_user_name }}'"
|
||||
- name: "🔍 Read the minted app passwords"
|
||||
ansible.builtin.set_fact:
|
||||
stalwart_app_password_minted: >-
|
||||
{%- set results = {} -%}
|
||||
{%- for method, result, call in stalwart_jmap.methodResponses -%}
|
||||
{%- set _ = results.update({call: result}) -%}
|
||||
{%- endfor -%}
|
||||
{%- set minted = [] -%}
|
||||
{%- for bot in stalwart_app_password_plan if bot.renew -%}
|
||||
{%- set _ = minted.append({'key': bot.key, 'address': bot.address,
|
||||
'secret': results[bot.address].created.p.secret}) -%}
|
||||
{%- endfor -%}
|
||||
{{ minted }}
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
- name: "💾 Persist the new app passwords"
|
||||
ansible.builtin.include_role:
|
||||
name: sys-token-store
|
||||
tasks_from: write.yml
|
||||
vars:
|
||||
sys_token_store_user_key: "{{ stalwart_user_key }}"
|
||||
sys_token_store_user_key: "{{ stalwart_app_password_new.key }}"
|
||||
sys_token_store_app: "{{ application_id }}"
|
||||
sys_token_store_token: "{{ stalwart_jmap.methodResponses[0][1].created.p.secret }}"
|
||||
sys_token_store_token: "{{ stalwart_app_password_new.secret }}"
|
||||
loop: "{{ stalwart_app_password_minted }}"
|
||||
loop_control:
|
||||
loop_var: stalwart_app_password_new
|
||||
label: "{{ stalwart_app_password_new.address }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
- name: "🏷️ Retire the replaced app passwords of '{{ stalwart_user_name }}'"
|
||||
include_tasks: "{{ role_path }}/tasks/jmap_call.yml"
|
||||
vars:
|
||||
stalwart_jmap_body: >-
|
||||
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
|
||||
'methodCalls': [['x:AppPassword/set', {'update': dict(stalwart_app_password_ids
|
||||
| zip([{'description': STALWART_BOT_APP_PASSWORD_RETIRED_DESCRIPTION}] * (stalwart_app_password_ids | length)))},
|
||||
'c0']]} | to_json }}
|
||||
when: stalwart_app_password_ids | length > 0
|
||||
- name: "🏷️ Retire the app passwords the new ones replace"
|
||||
include_tasks: "{{ role_path }}/tasks/jmap_call.yml"
|
||||
vars:
|
||||
stalwart_jmap_body: >-
|
||||
{%- set calls = [] -%}
|
||||
{%- for bot in stalwart_app_password_plan if bot.renew and bot.owned | length > 0 -%}
|
||||
{%- set _ = calls.append(['x:AppPassword/set', {'accountId': bot.id,
|
||||
'update': dict(bot.owned | zip([{'description': STALWART_BOT_APP_PASSWORD_RETIRED_DESCRIPTION}] * (bot.owned | length)))},
|
||||
bot.address]) -%}
|
||||
{%- endfor -%}
|
||||
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], 'methodCalls': calls} | to_json }}
|
||||
when: stalwart_app_password_plan | selectattr('renew') | selectattr('owned') | list | length > 0
|
||||
|
||||
- name: "📥 Defer dropping the retired app passwords of '{{ stalwart_user_name }}'"
|
||||
when: stalwart_app_password_drop | length > 0
|
||||
- name: "📥 Defer dropping the retired app passwords"
|
||||
when: stalwart_app_password_plan | selectattr('drop') | list | length > 0
|
||||
block:
|
||||
- name: "📥 Record the retired app passwords of '{{ stalwart_user_name }}'"
|
||||
- name: "📥 Record the retired app passwords per bot"
|
||||
ansible.builtin.set_fact:
|
||||
stalwart_app_password_stale: "{{ stalwart_app_password_stale | combine({stalwart_jmap_act_as: stalwart_app_password_drop}) }}"
|
||||
stalwart_app_password_stale: >-
|
||||
{%- set stale = {} -%}
|
||||
{%- for bot in stalwart_app_password_plan if bot.drop | length > 0 -%}
|
||||
{%- set _ = stale.update({bot.address: {'account': bot.id, 'ids': bot.drop}}) -%}
|
||||
{%- endfor -%}
|
||||
{{ stalwart_app_password_stale | combine(stale) }}
|
||||
|
||||
- name: "📥 Queue the drop for the service-loader post-load flush"
|
||||
include_role:
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
---
|
||||
- name: "🗑️ Destroy the retired app passwords per bot account"
|
||||
- name: "🗑️ Destroy the retired app passwords of the bots"
|
||||
include_tasks: "{{ role_path }}/tasks/jmap_call.yml"
|
||||
vars:
|
||||
stalwart_jmap_act_as: "{{ stalwart_app_password_owner.key }}"
|
||||
stalwart_jmap_body: >-
|
||||
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
|
||||
'methodCalls': [['x:AppPassword/set', {'destroy': stalwart_app_password_owner.value}, 'c0']]} | to_json }}
|
||||
loop: "{{ stalwart_app_password_stale | dict2items }}"
|
||||
{%- set calls = [] -%}
|
||||
{%- for owner in stalwart_app_password_owners -%}
|
||||
{%- set _ = calls.append(['x:AppPassword/set', {'accountId': owner.value.account, 'destroy': owner.value.ids}, owner.key]) -%}
|
||||
{%- endfor -%}
|
||||
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], 'methodCalls': calls} | to_json }}
|
||||
loop: "{{ stalwart_app_password_stale | dict2items | batch(STALWART_JMAP_MAX_METHOD_CALLS) | list }}"
|
||||
loop_control:
|
||||
loop_var: stalwart_app_password_owner
|
||||
loop_var: stalwart_app_password_owners
|
||||
label: "{{ stalwart_app_password_owners | map(attribute='key') | join(', ') }}"
|
||||
|
||||
- name: "🧹 Clear the dropped app passwords"
|
||||
ansible.builtin.set_fact:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
# Reusable management JMAP call: in `stalwart_jmap_body` (JSON), out `stalwart_jmap`.
|
||||
# Param: stalwart_jmap_body - JSON request; a batch carries one method call per object owner, its call id naming that owner in a refusal.
|
||||
# Param: stalwart_jmap_tolerate_rejected - true when the caller reads a set-level rejection itself (e.g. alreadyExists).
|
||||
# Param: stalwart_jmap_act_as - account address the recovery admin acts for; unset runs the call as the admin itself.
|
||||
- name: "🔧 Stalwart JMAP call"
|
||||
@@ -18,11 +19,9 @@
|
||||
delay: 5
|
||||
until: >-
|
||||
stalwart_jmap_raw.rc == 0
|
||||
and (
|
||||
(stalwart_jmap_raw.stdout | from_json).methodResponses[0][0] != 'error'
|
||||
or (stalwart_jmap_raw.stdout | from_json).methodResponses[0][1].type
|
||||
not in STALWART_JMAP_TRANSIENT_ERRORS
|
||||
)
|
||||
and (stalwart_jmap_raw.stdout | from_json).methodResponses
|
||||
| selectattr('0', 'equalto', 'error') | map(attribute='1.type')
|
||||
| select('in', STALWART_JMAP_TRANSIENT_ERRORS) | list | length == 0
|
||||
timeout: "{{ lookup('timeout', 120) | int }}"
|
||||
# nocheck: include-uniform-apply-keyword — seven call sites include this file; the in-file no_log is the SPOT, per-site apply would repeat it 7×
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
@@ -35,14 +34,20 @@
|
||||
- name: "✅ Ensure Stalwart accepted the JMAP call"
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- stalwart_jmap.methodResponses[0][0] != 'error'
|
||||
- stalwart_jmap_rejected | length == 0
|
||||
fail_msg: "Stalwart refused the JMAP call {{ stalwart_jmap.methodResponses[0][0] }}: {{ stalwart_jmap_rejected or stalwart_jmap.methodResponses[0][1] }}"
|
||||
- stalwart_jmap_refused | length == 0
|
||||
fail_msg: "Stalwart refused the JMAP call {{ stalwart_jmap_refused }}"
|
||||
quiet: true
|
||||
vars:
|
||||
stalwart_jmap_rejected: >-
|
||||
{{ {} if (stalwart_jmap_tolerate_rejected | default(false) | bool)
|
||||
else (stalwart_jmap.methodResponses[0][1].notCreated | default({}, true))
|
||||
| combine(stalwart_jmap.methodResponses[0][1].notUpdated | default({}, true))
|
||||
| combine(stalwart_jmap.methodResponses[0][1].notDestroyed | default({}, true)
|
||||
| dict2items | rejectattr('value.type', 'equalto', 'notFound') | items2dict) }}
|
||||
stalwart_jmap_refused: >-
|
||||
{%- set refused = {} -%}
|
||||
{%- for method, result, call in stalwart_jmap.methodResponses -%}
|
||||
{%- set rejected = {} if (method == 'error' or (stalwart_jmap_tolerate_rejected | default(false) | bool))
|
||||
else (result.notCreated | default({}, true))
|
||||
| combine(result.notUpdated | default({}, true))
|
||||
| combine(result.notDestroyed | default({}, true)
|
||||
| dict2items | rejectattr('value.type', 'equalto', 'notFound') | items2dict) -%}
|
||||
{%- if method == 'error' or rejected | length > 0 -%}
|
||||
{%- set _ = refused.update({call: {method: result if method == 'error' else rejected}}) -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{ refused }}
|
||||
@@ -29,6 +29,7 @@ STALWART_JMAP_APPLICATION_GET: >-
|
||||
STALWART_JMAP_TRANSIENT_ERRORS:
|
||||
- serverUnavailable
|
||||
- rateLimit
|
||||
STALWART_JMAP_MAX_METHOD_CALLS: 16
|
||||
STALWART_CONTAINER_ADDRESS: "{{ lookup('container_address', application_id, 'stalwart') }}"
|
||||
|
||||
STALWART_DOMAIN: "{{ lookup('config', application_id, 'services.stalwart.domain') }}"
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from ansible.parsing.dataloader import DataLoader
|
||||
from ansible.template import Templar
|
||||
|
||||
from utils.cache.yaml import load_yaml_any
|
||||
from utils.templating.ansible import _trust_as_template
|
||||
|
||||
from . import PROJECT_ROOT
|
||||
|
||||
TASKS = PROJECT_ROOT / "roles" / "web-app-stalwart" / "tasks"
|
||||
DESCRIPTION = "Infinito.Nexus submission"
|
||||
RETIRED = "Infinito.Nexus submission, retired"
|
||||
USING = ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"]
|
||||
|
||||
USERS = {
|
||||
"administrator": {
|
||||
"username": "administrator",
|
||||
"password": "admin-pw",
|
||||
"email": "administrator@infinito.test",
|
||||
"roles": [],
|
||||
},
|
||||
"no-reply": {
|
||||
"username": "no-reply",
|
||||
"password": "no-reply-pw",
|
||||
"email": "no-reply@infinito.test",
|
||||
"roles": ["bot"],
|
||||
"tokens": {"web-app-stalwart": "stored-app-password"},
|
||||
},
|
||||
"contact": {"username": "contact", "password": "contact-pw", "roles": ["bot"]},
|
||||
}
|
||||
|
||||
|
||||
def _flatten(tasks: list[dict]) -> list[dict]:
|
||||
flat = []
|
||||
for task in tasks:
|
||||
flat.append(task)
|
||||
flat.extend(_flatten(task.get("block") or []))
|
||||
return flat
|
||||
|
||||
|
||||
def _task(path: str, name: str) -> dict:
|
||||
tasks = _flatten(load_yaml_any(str(TASKS / path), default_if_missing=[]))
|
||||
return next(t for t in tasks if t.get("name") == name)
|
||||
|
||||
|
||||
def _render(template, **variables):
|
||||
templar = Templar(loader=DataLoader(), variables=variables)
|
||||
return templar.template(_trust_as_template(template))
|
||||
|
||||
|
||||
def _fact(path: str, name: str, fact: str, **variables):
|
||||
return _render(_task(path, name)["ansible.builtin.set_fact"][fact], **variables)
|
||||
|
||||
|
||||
def _body(path: str, name: str, **variables) -> dict:
|
||||
task = _task(path, name)
|
||||
variables = {**variables, **(task.get("vars") or {})}
|
||||
return json.loads(_render(task["vars"]["stalwart_jmap_body"], **variables))
|
||||
|
||||
|
||||
def _assert_var(path: str, name: str, var: str, **variables):
|
||||
return _render(_task(path, name)["vars"][var], **variables)
|
||||
|
||||
|
||||
def _sha(value: str) -> str:
|
||||
return hashlib.sha256(value.encode()).hexdigest()
|
||||
|
||||
|
||||
BASE = {
|
||||
"STALWART_USERS": USERS,
|
||||
"STALWART_DOMAIN": "infinito.test",
|
||||
"STALWART_JMAP_MAX_METHOD_CALLS": 16,
|
||||
"STALWART_BOT_APP_PASSWORD_DESCRIPTION": DESCRIPTION,
|
||||
"STALWART_BOT_APP_PASSWORD_RETIRED_DESCRIPTION": RETIRED,
|
||||
"application_id": "web-app-stalwart",
|
||||
"stalwart_domain_id": "d1",
|
||||
"stalwart_app_password_stale": {},
|
||||
}
|
||||
|
||||
CREATE_RESPONSE = [
|
||||
[
|
||||
"x:Account/set",
|
||||
{
|
||||
"created": {"u0": {"id": "a"}},
|
||||
"notCreated": {
|
||||
"u1": {"type": "alreadyExists", "objectId": {"id": "b"}},
|
||||
"u2": {"type": "alreadyExists", "objectId": {"id": "c"}},
|
||||
},
|
||||
},
|
||||
"c0",
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
def _resolved() -> list[dict]:
|
||||
return _fact(
|
||||
"04_manage_user.yml",
|
||||
"🔍 Resolve the Stalwart accounts and their effective secrets",
|
||||
"stalwart_accounts",
|
||||
**BASE,
|
||||
)
|
||||
|
||||
|
||||
def _matched(response: list = CREATE_RESPONSE) -> list[dict]:
|
||||
return _fact(
|
||||
"04_manage_user.yml",
|
||||
"🔍 Match every account to its Stalwart id",
|
||||
"stalwart_accounts",
|
||||
**BASE,
|
||||
stalwart_accounts=_resolved(),
|
||||
stalwart_jmap={"methodResponses": response},
|
||||
)
|
||||
|
||||
|
||||
class TestAccountPhases(unittest.TestCase):
|
||||
def test_bots_get_the_derived_secret_and_people_their_password(self):
|
||||
accounts = {a["key"]: a for a in _resolved()}
|
||||
self.assertEqual(accounts["administrator"]["secret"], "admin-pw")
|
||||
self.assertEqual(accounts["no-reply"]["secret"], _sha("no-reply-pw"))
|
||||
self.assertEqual(accounts["contact"]["address"], "contact@infinito.test")
|
||||
self.assertEqual(accounts["contact"]["description"], "contact")
|
||||
self.assertEqual(accounts["no-reply"]["stored"], "stored-app-password")
|
||||
self.assertEqual(accounts["contact"]["stored"], "")
|
||||
|
||||
def test_one_create_carries_every_account(self):
|
||||
body = _body(
|
||||
"04_manage_user.yml",
|
||||
"📁 Create the Stalwart accounts",
|
||||
**BASE,
|
||||
stalwart_accounts=_resolved(),
|
||||
)
|
||||
self.assertEqual(body["using"], USING)
|
||||
self.assertEqual(len(body["methodCalls"]), 1)
|
||||
method, arguments, _ = body["methodCalls"][0]
|
||||
self.assertEqual(method, "x:Account/set")
|
||||
creates = arguments["create"]
|
||||
self.assertEqual(sorted(creates), ["u0", "u1", "u2"])
|
||||
self.assertEqual(
|
||||
creates["u1"],
|
||||
{
|
||||
"@type": "User",
|
||||
"name": "no-reply",
|
||||
"domainId": "d1",
|
||||
"description": "no-reply@infinito.test",
|
||||
"credentials": {
|
||||
"0": {"@type": "Password", "secret": _sha("no-reply-pw")}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def test_created_and_existing_accounts_get_their_ids(self):
|
||||
accounts = {a["key"]: a for a in _matched()}
|
||||
self.assertEqual(
|
||||
(accounts["administrator"]["id"], accounts["administrator"]["existing"]),
|
||||
("a", False),
|
||||
)
|
||||
self.assertEqual(
|
||||
(accounts["no-reply"]["id"], accounts["no-reply"]["existing"]), ("b", True)
|
||||
)
|
||||
|
||||
def test_a_refused_create_fails_naming_the_account(self):
|
||||
refusal = {"type": "invalidProperties", "properties": ["name"]}
|
||||
response = [
|
||||
[
|
||||
"x:Account/set",
|
||||
{
|
||||
"created": {"u0": {"id": "a"}, "u1": {"id": "b"}},
|
||||
"notCreated": {"u2": refusal},
|
||||
},
|
||||
"c0",
|
||||
]
|
||||
]
|
||||
refused = _assert_var(
|
||||
"04_manage_user.yml",
|
||||
"✅ Ensure Stalwart created or already holds every account",
|
||||
"stalwart_account_refused",
|
||||
stalwart_accounts=_matched(response),
|
||||
)
|
||||
self.assertEqual(refused, {"contact@infinito.test": refusal})
|
||||
|
||||
def test_an_existing_account_without_an_id_fails(self):
|
||||
response = [
|
||||
["x:Account/set", {"notCreated": {"u0": {"type": "alreadyExists"}}}, "c0"]
|
||||
]
|
||||
refused = _assert_var(
|
||||
"04_manage_user.yml",
|
||||
"✅ Ensure Stalwart created or already holds every account",
|
||||
"stalwart_account_refused",
|
||||
stalwart_accounts=_matched(response),
|
||||
)
|
||||
self.assertIn("administrator@infinito.test", refused)
|
||||
|
||||
def test_one_update_carries_every_existing_account(self):
|
||||
body = _body(
|
||||
"04_manage_user.yml",
|
||||
"🔄 Send the passwords of the pre-existing accounts",
|
||||
**BASE,
|
||||
stalwart_accounts=_matched(),
|
||||
)
|
||||
method, arguments, _ = body["methodCalls"][0]
|
||||
self.assertEqual(method, "x:Account/set")
|
||||
self.assertEqual(sorted(arguments["update"]), ["b", "c"])
|
||||
self.assertEqual(
|
||||
arguments["update"]["c"]["credentials"]["0"]["secret"], _sha("contact-pw")
|
||||
)
|
||||
|
||||
def test_a_refused_update_fails_naming_the_account(self):
|
||||
refused = _assert_var(
|
||||
"04_manage_user.yml",
|
||||
"✅ Ensure Stalwart took the password of every pre-existing account",
|
||||
"stalwart_account_refused",
|
||||
stalwart_accounts=_matched(),
|
||||
stalwart_jmap={
|
||||
"methodResponses": [
|
||||
[
|
||||
"x:Account/set",
|
||||
{"updated": {"b": None}, "notUpdated": {"c": {"type": "x"}}},
|
||||
"c0",
|
||||
]
|
||||
]
|
||||
},
|
||||
)
|
||||
self.assertEqual(refused, {"contact@infinito.test": {"type": "x"}})
|
||||
|
||||
def test_bots_are_chunked_by_the_method_call_limit(self):
|
||||
loop = _task(
|
||||
"04_manage_user.yml", "🔑 Keep an app password for every bot under SSO"
|
||||
)["loop"]
|
||||
bots = [{"bot": True, "address": f"b{i}"} for i in range(17)]
|
||||
chunks = _render(
|
||||
loop,
|
||||
stalwart_accounts=[*bots, {"bot": False}],
|
||||
STALWART_JMAP_MAX_METHOD_CALLS=16,
|
||||
)
|
||||
self.assertEqual([len(c) for c in chunks], [16, 1])
|
||||
|
||||
|
||||
def _bots() -> list[dict]:
|
||||
return [a for a in _matched() if a["bot"]]
|
||||
|
||||
|
||||
def _plan(listed: dict) -> list[dict]:
|
||||
return _fact(
|
||||
"04_manage_user/app_password.yml",
|
||||
"🔍 Decide which bots need a new app password",
|
||||
"stalwart_app_password_plan",
|
||||
**BASE,
|
||||
stalwart_bots=_bots(),
|
||||
stalwart_jmap={
|
||||
"methodResponses": [
|
||||
["x:AppPassword/get", {"list": entries}, address]
|
||||
for address, entries in listed.items()
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
LISTED = {
|
||||
"no-reply@infinito.test": [
|
||||
{"id": "1", "description": DESCRIPTION},
|
||||
{"id": "2", "description": RETIRED},
|
||||
],
|
||||
"contact@infinito.test": [
|
||||
{"id": "1", "description": DESCRIPTION},
|
||||
{"id": "4", "description": DESCRIPTION},
|
||||
{"id": "5", "description": "Laptop"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class TestAppPasswordPhases(unittest.TestCase):
|
||||
def test_one_list_addresses_every_bot_by_account_id(self):
|
||||
body = _body(
|
||||
"04_manage_user/app_password.yml",
|
||||
"📖 List the app passwords of the bots",
|
||||
**BASE,
|
||||
stalwart_bots=_bots(),
|
||||
)
|
||||
self.assertEqual(
|
||||
body["methodCalls"],
|
||||
[
|
||||
[
|
||||
"x:AppPassword/get",
|
||||
{"accountId": "b", "ids": None},
|
||||
"no-reply@infinito.test",
|
||||
],
|
||||
[
|
||||
"x:AppPassword/get",
|
||||
{"accountId": "c", "ids": None},
|
||||
"contact@infinito.test",
|
||||
],
|
||||
],
|
||||
)
|
||||
|
||||
def test_the_plan_reads_each_bot_from_its_own_call(self):
|
||||
plan = {p["key"]: p for p in _plan(dict(reversed(list(LISTED.items()))))}
|
||||
self.assertFalse(plan["no-reply"]["renew"])
|
||||
self.assertEqual(plan["no-reply"]["drop"], ["2"])
|
||||
self.assertTrue(plan["contact"]["renew"])
|
||||
self.assertEqual(plan["contact"]["owned"], ["1", "4"])
|
||||
self.assertEqual(plan["contact"]["drop"], ["1", "4"])
|
||||
|
||||
def test_a_stored_derived_secret_renews(self):
|
||||
bots = _bots()
|
||||
bots[0]["stored"] = bots[0]["secret"]
|
||||
plan = _fact(
|
||||
"04_manage_user/app_password.yml",
|
||||
"🔍 Decide which bots need a new app password",
|
||||
"stalwart_app_password_plan",
|
||||
**BASE,
|
||||
stalwart_bots=bots,
|
||||
stalwart_jmap={
|
||||
"methodResponses": [
|
||||
["x:AppPassword/get", {"list": entries}, address]
|
||||
for address, entries in LISTED.items()
|
||||
]
|
||||
},
|
||||
)
|
||||
self.assertTrue(plan[0]["renew"])
|
||||
|
||||
def test_mint_retire_and_drop_touch_only_the_renewed_bot(self):
|
||||
plan = _plan(LISTED)
|
||||
variables = {**BASE, "stalwart_app_password_plan": plan}
|
||||
mint = _body(
|
||||
"04_manage_user/app_password.yml",
|
||||
"🔑 Mint the new app passwords",
|
||||
**variables,
|
||||
)
|
||||
self.assertEqual(
|
||||
mint["methodCalls"],
|
||||
[
|
||||
[
|
||||
"x:AppPassword/set",
|
||||
{"accountId": "c", "create": {"p": {"description": DESCRIPTION}}},
|
||||
"contact@infinito.test",
|
||||
]
|
||||
],
|
||||
)
|
||||
minted = _fact(
|
||||
"04_manage_user/app_password.yml",
|
||||
"🔍 Read the minted app passwords",
|
||||
"stalwart_app_password_minted",
|
||||
**variables,
|
||||
stalwart_jmap={
|
||||
"methodResponses": [
|
||||
[
|
||||
"x:AppPassword/set",
|
||||
{"created": {"p": {"id": "6", "secret": "fresh"}}},
|
||||
"contact@infinito.test",
|
||||
]
|
||||
]
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
minted,
|
||||
[{"key": "contact", "address": "contact@infinito.test", "secret": "fresh"}],
|
||||
)
|
||||
retire = _body(
|
||||
"04_manage_user/app_password.yml",
|
||||
"🏷️ Retire the app passwords the new ones replace",
|
||||
**variables,
|
||||
)
|
||||
self.assertEqual(
|
||||
retire["methodCalls"],
|
||||
[
|
||||
[
|
||||
"x:AppPassword/set",
|
||||
{
|
||||
"accountId": "c",
|
||||
"update": {
|
||||
"1": {"description": RETIRED},
|
||||
"4": {"description": RETIRED},
|
||||
},
|
||||
},
|
||||
"contact@infinito.test",
|
||||
]
|
||||
],
|
||||
)
|
||||
stale = _fact(
|
||||
"04_manage_user/app_password.yml",
|
||||
"📥 Record the retired app passwords per bot",
|
||||
"stalwart_app_password_stale",
|
||||
**variables,
|
||||
)
|
||||
self.assertEqual(
|
||||
stale,
|
||||
{
|
||||
"no-reply@infinito.test": {"account": "b", "ids": ["2"]},
|
||||
"contact@infinito.test": {"account": "c", "ids": ["1", "4"]},
|
||||
},
|
||||
)
|
||||
drop_task = _task(
|
||||
"04_manage_user/app_password_drop.yml",
|
||||
"🗑️ Destroy the retired app passwords of the bots",
|
||||
)
|
||||
chunks = _render(
|
||||
drop_task["loop"],
|
||||
stalwart_app_password_stale=stale,
|
||||
STALWART_JMAP_MAX_METHOD_CALLS=16,
|
||||
)
|
||||
self.assertEqual(len(chunks), 1)
|
||||
drop = json.loads(
|
||||
_render(
|
||||
drop_task["vars"]["stalwart_jmap_body"],
|
||||
stalwart_app_password_owners=chunks[0],
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
drop["methodCalls"],
|
||||
[
|
||||
[
|
||||
"x:AppPassword/set",
|
||||
{"accountId": "b", "destroy": ["2"]},
|
||||
"no-reply@infinito.test",
|
||||
],
|
||||
[
|
||||
"x:AppPassword/set",
|
||||
{"accountId": "c", "destroy": ["1", "4"]},
|
||||
"contact@infinito.test",
|
||||
],
|
||||
],
|
||||
)
|
||||
|
||||
def test_the_record_keeps_the_owners_of_an_earlier_chunk(self):
|
||||
stale = _fact(
|
||||
"04_manage_user/app_password.yml",
|
||||
"📥 Record the retired app passwords per bot",
|
||||
"stalwart_app_password_stale",
|
||||
**{
|
||||
**BASE,
|
||||
"stalwart_app_password_stale": {
|
||||
"bounce@infinito.test": {"account": "z", "ids": ["9"]}
|
||||
},
|
||||
},
|
||||
stalwart_app_password_plan=_plan(LISTED),
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(stale),
|
||||
["bounce@infinito.test", "contact@infinito.test", "no-reply@infinito.test"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from typing import ClassVar
|
||||
|
||||
from ansible.parsing.dataloader import DataLoader
|
||||
from ansible.template import Templar
|
||||
@@ -45,14 +46,36 @@ def _render(template: str, **variables):
|
||||
return templar.template(_trust_as_template(template))
|
||||
|
||||
|
||||
def _accepted(response: list, **extra) -> bool:
|
||||
def _assert_vars(responses: list, **extra) -> dict:
|
||||
task = _assert_task()
|
||||
variables = {"stalwart_jmap": {"methodResponses": [response]}, **extra}
|
||||
variables = {"stalwart_jmap": {"methodResponses": responses}, **extra}
|
||||
for name, raw in (task.get("vars") or {}).items():
|
||||
variables[name] = _render(raw, **variables)
|
||||
return variables
|
||||
|
||||
|
||||
def _batch_accepted(responses: list, **extra) -> bool:
|
||||
variables = _assert_vars(responses, **extra)
|
||||
that = _module(_assert_task(), "assert")["that"]
|
||||
return all(
|
||||
_render("{{ (" + expr + ") | bool }}", **variables)
|
||||
for expr in _module(task, "assert")["that"]
|
||||
for expr in ([that] if isinstance(that, str) else that)
|
||||
)
|
||||
|
||||
|
||||
def _accepted(response: list, **extra) -> bool:
|
||||
return _batch_accepted([response], **extra)
|
||||
|
||||
|
||||
def _retry_done(rc: int, responses: list) -> bool:
|
||||
task = next(t for t in _tasks() if _module(t, "shell"))
|
||||
return _render(
|
||||
"{{ (" + task["until"] + ") | bool }}",
|
||||
stalwart_jmap_raw={
|
||||
"rc": rc,
|
||||
"stdout": json.dumps({"methodResponses": responses}),
|
||||
},
|
||||
STALWART_JMAP_TRANSIENT_ERRORS=["serverUnavailable", "rateLimit"],
|
||||
)
|
||||
|
||||
|
||||
@@ -105,6 +128,85 @@ class TestJmapRejectionIsAFailure(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestJmapBatchNamesTheRefusedOwner(unittest.TestCase):
|
||||
GET: ClassVar[list] = ["x:AppPassword/get", {"list": []}, "contact@infinito.test"]
|
||||
|
||||
def test_a_clean_batch_passes(self):
|
||||
self.assertTrue(
|
||||
_batch_accepted(
|
||||
[self.GET, ["x:AppPassword/get", {"list": []}, "bounce@infinito.test"]]
|
||||
)
|
||||
)
|
||||
|
||||
def test_an_error_in_a_later_call_fails(self):
|
||||
self.assertFalse(
|
||||
_batch_accepted(
|
||||
[self.GET, ["error", {"type": "forbidden"}, "bounce@infinito.test"]]
|
||||
)
|
||||
)
|
||||
|
||||
def test_a_later_rejection_fails_and_names_its_call(self):
|
||||
responses = [
|
||||
self.GET,
|
||||
[
|
||||
"x:AppPassword/set",
|
||||
{"notCreated": {"p": {"type": "overQuota"}}},
|
||||
"bounce@infinito.test",
|
||||
],
|
||||
]
|
||||
self.assertFalse(_batch_accepted(responses))
|
||||
self.assertEqual(
|
||||
_assert_vars(responses)["stalwart_jmap_refused"],
|
||||
{
|
||||
"bounce@infinito.test": {
|
||||
"x:AppPassword/set": {"p": {"type": "overQuota"}}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def test_a_tolerated_rejection_still_fails_on_an_error(self):
|
||||
self.assertFalse(
|
||||
_batch_accepted(
|
||||
[
|
||||
["x:Account/set", {"notCreated": {"u0": {"type": "x"}}}, "c0"],
|
||||
["error", {"type": "invalidArguments"}, "c1"],
|
||||
],
|
||||
stalwart_jmap_tolerate_rejected=True,
|
||||
)
|
||||
)
|
||||
|
||||
def test_a_destroy_of_an_absent_object_passes(self):
|
||||
self.assertTrue(
|
||||
_batch_accepted(
|
||||
[
|
||||
[
|
||||
"x:AppPassword/set",
|
||||
{"notDestroyed": {"3": {"type": "notFound"}}},
|
||||
"bounce@infinito.test",
|
||||
]
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestJmapRetryCoversEveryCall(unittest.TestCase):
|
||||
OK: ClassVar[list] = ["x:AppPassword/get", {"list": []}, "a"]
|
||||
|
||||
def test_a_clean_batch_ends_the_retry(self):
|
||||
self.assertTrue(_retry_done(0, [self.OK, self.OK]))
|
||||
|
||||
def test_a_transient_error_in_a_later_call_retries(self):
|
||||
self.assertFalse(
|
||||
_retry_done(0, [self.OK, ["error", {"type": "serverUnavailable"}, "b"]])
|
||||
)
|
||||
|
||||
def test_a_permanent_error_ends_the_retry(self):
|
||||
self.assertTrue(_retry_done(0, [["error", {"type": "forbidden"}, "a"]]))
|
||||
|
||||
def test_a_failed_transfer_retries(self):
|
||||
self.assertFalse(_retry_done(22, []))
|
||||
|
||||
|
||||
class TestJmapSecretsStayOffArgv(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.task = _call_task()
|
||||
|
||||
Reference in new issue
Block a user