From 8e48293639ffeff2ed8a88596351ddac9be63768 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 10:32:14 +0000 Subject: [PATCH 1/2] Add settings page regression tests for the Integrations tab Covers every Integrations setting: joins through the real join form reach Zetkin, Mailchimp, Action Network, Auth0 and the webhooks with the credentials set on the page; Stripe direct debit options and the subscriptions download; GoCardless and Zetkin sandbox/live; Chargebee hosted pages; postcode lookups; and Sentry, Google Cloud Logging and Loggly receiving the plugin's logs. A legacy fixture checks settings stored by Carbon Fields still work. The fake API gains a small Zetkin, token endpoints for Auth0 and Google, Stripe invoices, gzip bodies, and hostnames for the logging services. The Chargebee SDK now trusts its test CA, and stand-ins for Stripe.js and Chargebee.js let the payment steps render. The subscriptions download breaking with no Stripe invoices (JOIN-167) is a skipped test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BL2bFrTk84JMy3CPAsCTV9 --- docs/e2e-testing.md | 2 +- packages/join-e2e/docker-compose.yml | 7 +- packages/join-e2e/fake-api/certs/ca.crt | 32 +- packages/join-e2e/fake-api/certs/san.cnf | 4 + packages/join-e2e/fake-api/certs/server.crt | 47 +- packages/join-e2e/fake-api/certs/server.key | 52 +-- packages/join-e2e/fake-api/server.js | 53 ++- packages/join-e2e/tests/settings/fake-api.ts | 2 +- packages/join-e2e/tests/settings/fixtures.ts | 92 +++- .../tests/settings/integrations-crm.spec.ts | 391 ++++++++++++++++ .../settings/integrations-logging.spec.ts | 182 ++++++++ .../settings/integrations-payments.spec.ts | 422 ++++++++++++++++++ .../tests/settings/legacy/integrations.json | 41 ++ packages/join-e2e/tests/settings/support.ts | 5 + readme.md | 2 +- 15 files changed, 1255 insertions(+), 79 deletions(-) create mode 100644 packages/join-e2e/tests/settings/integrations-crm.spec.ts create mode 100644 packages/join-e2e/tests/settings/integrations-logging.spec.ts create mode 100644 packages/join-e2e/tests/settings/integrations-payments.spec.ts create mode 100644 packages/join-e2e/tests/settings/legacy/integrations.json diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md index 8297bae..6596c81 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -80,7 +80,7 @@ The suite currently contains 78 tests across 11 spec files (one skipped pending | Membership Plans | `membership-plans.spec.ts` | Every setting. Plans: added, edited and removed, stored under their ID, offered on join forms without their own plans; each plan field's effect on the form and on joins (`/join`, including the custom-amount minimum); Name, Price and at least one plan required; each frequency and currency set up in Stripe (fake API). Tags: defaults; on a join, the plan's Add Tags added and the lapsed, cancelled, lapsing and other plans' tags removed; lapsed, cancelled and lapsing tags applied by Stripe webhook events; tier changes swap plan tags (fake Mailchimp). Known problem: JOIN-166 (duplicate Stripe prices for prices with pence). | | Theme | `theme.spec.ts` | Every setting: colour defaults, each colour reaching the form's styles (the primary colour on its buttons), custom CSS applied. | | Copy | `copy.spec.ts` | Every setting: defaults and fallbacks, and where each shows (details, plan and bank-details steps, confirmation step, sidebar). Known problems: JOIN-162 (Home Address Copy never shows), JOIN-163 (day-of-month copy unused), JOIN-164 (organisation details marked required but not enforced). | -| Integrations | | Not yet. What Mailchimp, Action Network and Zetkin receive on a join will be covered here. | +| Integrations | `integrations-crm.spec.ts`, `integrations-payments.spec.ts`, `integrations-logging.spec.ts` | Every setting. Joins through the real form reach Zetkin (join form, or the API with OAuth; sandbox/live), Mailchimp, Action Network, Auth0 and the join-complete webhook with the credentials and IDs set here; the step webhook; webhook URLs never reach the form. Stripe direct debit and Direct Debit only (the payment methods Stripe's fields are set up with), the subscriptions download; GoCardless sandbox/live; Chargebee hosted pages; getAddress.io and Ideal Postcodes lookups and their key checks; Sentry, Google Cloud Logging and Loggly receiving the plugin's logs (all fake API). Known problem: JOIN-167 (subscriptions download breaks with no Stripe invoices). | | Logging | | Not yet | | Debug | | Not yet | diff --git a/packages/join-e2e/docker-compose.yml b/packages/join-e2e/docker-compose.yml index 8ad70d3..9e1f84f 100644 --- a/packages/join-e2e/docker-compose.yml +++ b/packages/join-e2e/docker-compose.yml @@ -10,13 +10,14 @@ x-wordpress-volumes: &wordpress-volumes - ../join-block:/var/www/html/wp-content/plugins/join-block - ./scripts:/var/www/html/wp-content/e2e-scripts # Trust the fake API's test CA: PHP's curl and streams, WordPress's own - # HTTP API (via a must-use plugin) and the Stripe SDK each have their own CA + # HTTP API (via a must-use plugin) and the Stripe and Chargebee SDKs each have their own CA # setting. Nothing may be mounted over WordPress's core files: the image # copies them into the volume on first start and fails if one is in the way. - ./fake-api/certs/ca.crt:/usr/local/share/e2e-fake-api-ca.crt:ro - ./fake-api/php-ca.ini:/usr/local/etc/php/conf.d/zz-e2e-fake-api-ca.ini:ro - ./fake-api/e2e-fake-api-ca.php:/var/www/html/wp-content/mu-plugins/e2e-fake-api-ca.php:ro - ./fake-api/certs/ca.crt:/var/www/html/wp-content/plugins/join-block/vendor/stripe/stripe-php/data/ca-certificates.crt:ro + - ./fake-api/certs/ca.crt:/var/www/html/wp-content/plugins/join-block/vendor/chargebee/chargebee-php/lib/ssl/ca-certs.crt:ro x-wordpress-environment: &wordpress-environment WORDPRESS_DB_HOST: db @@ -56,6 +57,10 @@ services: - api.postcodes.io - api.getaddress.io - api.ideal-postcodes.co.uk + - logs-01.loggly.com + - sentry.e2e.example + - oauth2.googleapis.com + - logging.googleapis.com db: image: mariadb:11.4 diff --git a/packages/join-e2e/fake-api/certs/ca.crt b/packages/join-e2e/fake-api/certs/ca.crt index e19edcf..fa5df79 100644 --- a/packages/join-e2e/fake-api/certs/ca.crt +++ b/packages/join-e2e/fake-api/certs/ca.crt @@ -1,20 +1,20 @@ -----BEGIN CERTIFICATE----- -MIIDPzCCAiegAwIBAgIUL1NIuEmpA0RWqABA149MgtVN6S8wDQYJKoZIhvcNAQEL +MIIDPzCCAiegAwIBAgIUE2kQO6o+jEf63sxZQRd/4GeIz7AwDQYJKoZIhvcNAQEL BQAwLjEsMCoGA1UEAwwjSm9pbiBFMkUgdGVzdCBDQSAobm90IGZvciByZWFsIHVz -ZSkwIBcNMjYxMDIzMTc1NDA2WhgPMjEyNjA5MjkxNzU0MDZaMC4xLDAqBgNVBAMM +ZSkwIBcNMjYxMDI0MTAwNzQ3WhgPMjEyNjA5MzAxMDA3NDdaMC4xLDAqBgNVBAMM I0pvaW4gRTJFIHRlc3QgQ0EgKG5vdCBmb3IgcmVhbCB1c2UpMIIBIjANBgkqhkiG -9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5MZLGxuo1wv3q/KZlHPYs1msn5HP9/MS93px -sfubWlrPxW0CWIxTMtv98kyeNggsQ6pqbFdpLSpDrMI2L4+OTYwTwV+LrzV4neSm -GXHVAqB93QLaHLQwdWdjiGJ8vaX069c8LzNvnZb8KVf7MtLWX2MsRNYJtXCCtxuT -wV3RZDQs4TNOqGikoyRg/JNCxkgBPHNSm7ptCj3rj2rbQGDM7nu1skBzH3L6xKiA -QGiYb+vNDWcdK4I38cA8BbQdS+DXvHIf+Y6Qlq8h6esI7a8pnbhm60RIHNvsEdFq -qPFPowydKT8RyjABZHnajyYXyv3PGeGjXC/hRRTsaJg54GUxiwIDAQABo1MwUTAd -BgNVHQ4EFgQU6xlodRtSMB1hTbE+cfkyMBWT2QUwHwYDVR0jBBgwFoAU6xlodRtS -MB1hTbE+cfkyMBWT2QUwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC -AQEAGjcNh/YqYwRw5w+ilPybR9DHl0OG7/k7VXrGRqml0QvbDD1h4jR4Hqz2AwXr -HFt6LdnXOvlVk+u6t6FvNflFu059sNeJx7b5FRBoTvrjNZfFehoKD5NkuamGGWdk -XIEeG0NIBaYF4DYu9qK1jKL1DcpM4lFOfy55pcNEqyBEyULmpJVYhBAsddb4gtuU -oZHM/rZdp/k463E53SHUPMD3arh3CDcq+JLSr3uDs7nRex7HfY0U7DFd4DlcuuH/ -oWnAGLOcb17zzahyAdsmCetiGuzAp7SU7p6DBLnT3Yr5i/CjhWKoKCDqAIaybIgw -dIdD1NBq1DZNpTYgPl/dvaa5fg== +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqJGPIHLHjMSFGgQwlamIs5cEoDCEcnZmGFbE +hYPvmi+t5fY+we5GAoUsv3lyiKbtTP8HWhMJsn+xewjJ9CeEdAvDHyT7+6YMxkMh +fcxZvv71TfQECBKXKv855n+nkXfNVH/aboKr/ShxxTINW92xZMfs27scFqB/ol2d +MTkPlrcxOhQN9WnWeC2+nlg+fE9JDN3c3pNmxszmHRs4nISfpAQFLziR5lMkCFHI +TnUp5UPqD85XICxMqeZMGHYs355GGzJldwSSfT4o6XKvzPcGyHqBF46PT0ZcFBxQ +Jw2qGfLcfaUddfziq8sLrhzYJqL3AiC0p/EQKya/CXQCtWxDmwIDAQABo1MwUTAd +BgNVHQ4EFgQU5pduVZcifwRpZ3y6XZ/QsVOZkNkwHwYDVR0jBBgwFoAU5pduVZci +fwRpZ3y6XZ/QsVOZkNkwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC +AQEAIrOmKZT+RZDe8zgehE4VScO9QPnuluA/I8DDChuZ/voS4krhud+J0HK1W1Yd +Ru913ngJQQjOF/W1elrZjgITKOh+Or4+Y8yldpUh22hyUyjD42k85ckVgECQaUxK +qZnt5UIvG2KDKL4M1TNK6+BZTL8H2zuJBDSCp0343apWEYXhWPytrB6sgxqvtX+c +GoNDhL+gw6WjTf+lE3FA+nM+7bEbYiqzOLFzwlSN3CJtxb/UZP8rMt2dSETs/Ho/ +AieD/jt5xb/tUnJIJL1aFRWoaPUdN5NCl2+dT+tWdKq2fMdx2Pgn0eMkdPi6Qoue +vVf5Y2keiiVtkUx6pJvG1L+I9A== -----END CERTIFICATE----- diff --git a/packages/join-e2e/fake-api/certs/san.cnf b/packages/join-e2e/fake-api/certs/san.cnf index f410223..7cc600a 100644 --- a/packages/join-e2e/fake-api/certs/san.cnf +++ b/packages/join-e2e/fake-api/certs/san.cnf @@ -20,3 +20,7 @@ DNS.10 = api.postcodes.io DNS.11 = api.getaddress.io DNS.12 = api.ideal-postcodes.co.uk DNS.13 = fake-api +DNS.14 = logs-01.loggly.com +DNS.15 = sentry.e2e.example +DNS.16 = oauth2.googleapis.com +DNS.17 = logging.googleapis.com diff --git a/packages/join-e2e/fake-api/certs/server.crt b/packages/join-e2e/fake-api/certs/server.crt index 667cb04..bcc2314 100644 --- a/packages/join-e2e/fake-api/certs/server.crt +++ b/packages/join-e2e/fake-api/certs/server.crt @@ -1,26 +1,27 @@ -----BEGIN CERTIFICATE----- -MIIETTCCAzWgAwIBAgIUcBvmd6AYMCx4hAsRuJYvExc18BUwDQYJKoZIhvcNAQEL +MIIEpjCCA46gAwIBAgIUFe/1wFOXwDaT0olFZdmpjq4PTlgwDQYJKoZIhvcNAQEL BQAwLjEsMCoGA1UEAwwjSm9pbiBFMkUgdGVzdCBDQSAobm90IGZvciByZWFsIHVz -ZSkwIBcNMjYxMDIzMTc1NDA2WhgPMjEyNjA5MjkxNzU0MDZaMBMxETAPBgNVBAMM -CGZha2UtYXBpMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAreVk2RGX -irbBcE/J6YDanGGDwf404Lh9ni6Akw2zEANeVLxqvKiyi5l8wK1q+ah3J2jqo/LU -KrD65ZuhscZW5V+t9w/KqTkOE/4O76s3wJB8Y4sQcUuW4XtV/Wd028gZORu/DMp9 -A1lK7ysqtuu/6K1D1/8evI6LsMNjrQshh3FXcJiUhcrfgrmM8lWVQE652KJIeRTi -iKQm9Xiwo8+Pj632bbmTfvCA6yLHrhOaLS9OB14XSjzzXSoxqLTLP9UTA6GvMLGi -FIlzl/ch8ZlnI9t/37JiEhBY2QzaEymKl2yLNcgG2YCQx71T8xAuuwu7Yy2E/A4k -C2o/LQcp2iJPowIDAQABo4IBejCCAXYwggEFBgNVHREEgf0wgfqCDmFwaS5zdHJp -cGUuY29tghFhY3Rpb25uZXR3b3JrLm9yZ4ILYXBpLnpldGsuaW6CEmFwaS5kZXYu -emV0a2luLm9yZ4IVdXMxLmFwaS5tYWlsY2hpbXAuY29tghBlMmUuZXUuYXV0aDAu -Y29tghJhcGkuZ29jYXJkbGVzcy5jb22CGmFwaS1zYW5kYm94LmdvY2FyZGxlc3Mu -Y29tghFlMmUuY2hhcmdlYmVlLmNvbYIQYXBpLnBvc3Rjb2Rlcy5pb4IRYXBpLmdl -dGFkZHJlc3MuaW+CGWFwaS5pZGVhbC1wb3N0Y29kZXMuY28udWuCCGZha2UtYXBp -MAkGA1UdEwQCMAAwCwYDVR0PBAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB0G -A1UdDgQWBBRmOT+xWfG1qKOcrTEPx4PG5jjkwzAfBgNVHSMEGDAWgBTrGWh1G1Iw -HWFNsT5x+TIwFZPZBTANBgkqhkiG9w0BAQsFAAOCAQEAM0haZ0adhRNvYMHkNm3B -IH614SRIsOlFt2SUYi33DYIy0Im0Mk3BGozgx/zY2/8LUu6mon47aKjJJpP922ew -MzzswMoAM7FnKiN0DV9asfsmCH8OeePjFHde4VWU6elTQ2Tb5Lmg0Tqafaew4sB6 -xFe4usEl3AIaYtoZ6MULdsdNOXF42M4xlNp2l7uMcJT1IYQODxByJ1vd40k0q1Uz -uZx4qiRsF7HqV4q2/emjUu/67R871J5uM8aKxvRS2YLjuP2JruTOnjSQbJf1js60 -PcGGWPREVvveXSLJiwD9DpXHjApKTZjMj7lk5fcGYe+Ka0CDxmhHhewiGiblRjcJ -YA== +ZSkwIBcNMjYxMDI0MTAwNzQ4WhgPMjEyNjA5MzAxMDA3NDhaMBMxETAPBgNVBAMM +CGZha2UtYXBpMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvVuIMrn2 +3svfRxhYpJPgEoNLiUZovKpOsjxHsqUpXc4BwExNhzvunG1xh1+CNh4EIz07cWzf +8RXDIgZBO/aWetzDStSd3sKGBHnmGywJv8CcYeaLgqDeIoyy7dmQQWOKnNO6Ybjb +mvRQUFKUmvOUrg+OucEaQZ9JvcWDyXASpd+W54/Xg/nlEp3ICPd8a6nHQF8RXdGx +kWbld7BqourCy6PaODgB5a2EsOzSHM22n+VSSelvj/XTDD9upTvWINryzRga+1Zn +MbMfGsbAp6aTVq3TlQF60LvaHYJq5cYDDV37rdT9fgvrIV/nyVnZWzeTSjtTPRRL +4KdZ5yp+ojnLGQIDAQABo4IB0zCCAc8wggFeBgNVHREEggFVMIIBUYIOYXBpLnN0 +cmlwZS5jb22CEWFjdGlvbm5ldHdvcmsub3JnggthcGkuemV0ay5pboISYXBpLmRl +di56ZXRraW4ub3JnghV1czEuYXBpLm1haWxjaGltcC5jb22CEGUyZS5ldS5hdXRo +MC5jb22CEmFwaS5nb2NhcmRsZXNzLmNvbYIaYXBpLXNhbmRib3guZ29jYXJkbGVz +cy5jb22CEWUyZS5jaGFyZ2ViZWUuY29tghBhcGkucG9zdGNvZGVzLmlvghFhcGku +Z2V0YWRkcmVzcy5pb4IZYXBpLmlkZWFsLXBvc3Rjb2Rlcy5jby51a4IIZmFrZS1h +cGmCEmxvZ3MtMDEubG9nZ2x5LmNvbYISc2VudHJ5LmUyZS5leGFtcGxlghVvYXV0 +aDIuZ29vZ2xlYXBpcy5jb22CFmxvZ2dpbmcuZ29vZ2xlYXBpcy5jb20wCQYDVR0T +BAIwADALBgNVHQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwHQYDVR0OBBYE +FPsScoZQEYMxULm5IZtm20XKuu8nMB8GA1UdIwQYMBaAFOaXblWXIn8EaWd8ul2f +0LFTmZDZMA0GCSqGSIb3DQEBCwUAA4IBAQAknbs81Qs5Oce/CZdtaD8k8AWna12K ++L9kElkJGc/NReG/eYdvfMSypFzGbdkbCB4y1s446Eet/g8BFlJDfojWYgx1XYnH +nkA476DR6uLpkk7UmdqTWXiXb5Fm39pPzGqOasROyAhyu0Ux8Q/79WwMpOaXAXXe +1EeSwHfE3Ch2fxuZfNI0vGcUKNobRNAGNSZTO1qNJCtwbtyXKZa46ysIabi2+4qM +LvsjRJAb2b7kkEhVmKvbUp02oogkAsKsZOk/J3z8ICfSYAcPB5r1XNTKoYeT7TVM +ZPvbzra2tkWZ32r/QaLKBSRJcjYguhbDheIOXmbIP7sr02qtJxr/1WKa -----END CERTIFICATE----- diff --git a/packages/join-e2e/fake-api/certs/server.key b/packages/join-e2e/fake-api/certs/server.key index 2dffa2c..4b20d17 100644 --- a/packages/join-e2e/fake-api/certs/server.key +++ b/packages/join-e2e/fake-api/certs/server.key @@ -1,28 +1,28 @@ -----BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCt5WTZEZeKtsFw -T8npgNqcYYPB/jTguH2eLoCTDbMQA15UvGq8qLKLmXzArWr5qHcnaOqj8tQqsPrl -m6GxxlblX633D8qpOQ4T/g7vqzfAkHxjixBxS5bhe1X9Z3TbyBk5G78Myn0DWUrv -Kyq267/orUPX/x68jouww2OtCyGHcVdwmJSFyt+CuYzyVZVATrnYokh5FOKIpCb1 -eLCjz4+PrfZtuZN+8IDrIseuE5otL04HXhdKPPNdKjGotMs/1RMDoa8wsaIUiXOX -9yHxmWcj23/fsmISEFjZDNoTKYqXbIs1yAbZgJDHvVPzEC67C7tjLYT8DiQLaj8t -BynaIk+jAgMBAAECggEASIz4vsGiroph+SgozuVmD7F1haW3zPvb2PeAyBmCmA83 -t3WmuJBPcYO3czHG/GMnV0dkAcPJdghBnTMJyoA799Wr4HRzspdtwrSP6blO+OWv -J964nPQS/n71GNSlgh6hO1zSoBUJBDEoXNFvMEL6XaqQMBkt6zAzoqtaNjN7KSTV -qfPyySMcnRJNHZGsSz8i4dekGZDUSwUV7u7kjmp2vlL5vo6T6tlY2JqHYRcm/qdl -GqI6JuH2fFmaLDlP2eyUu3Judqoq0PrwMgcEVz0Rtg7szEUcHrZg6O7YVVWVCyuS -+nC9yPtmbs1oyiXXQsKdItZiy5LwdzuGKXXrXw7WHQKBgQDe7ioZzHhQS39hg87o -YJQJdtlyTemgFywjtItWeX2Arq2n1nj4BioxJlvtEMXuFntYqUgJSOOB2m0n2k2v -duT7EUrON6+xcoHaGDaoT9qZHAzEr5OQb+dJmTUlWWDuU5xGGeBwFmIboO/2vNeS -E9/tqo9L2VU5P9WV2YPHH+pxxQKBgQDHsSQeVPSFzfLw6UvwiMrco8SdU15TOjFF -7hUzNESq/qo3WPuNiAiNDE8frWqVXzMRpbeYpujAFZd+XUONjI29z8CBTbcrZYjp -PKW1VxLn5gL1MCsGModdRnupzp8DVPvRuQhG1wgfqKLtP0wMvhLPkOpO24V5LokL -FRwnaP/aRwKBgQCIdHm6ruiaJYxD8T1OCgezP1dcWwp83pI7DMEZXmYOxV0wVdxX -3NYonZU5J8IvuP0pSPz6Ce6QRlX4Rey1UW5Zq70BOx7E3BVVnOqf3bktbPj3z4zg -pU6KQ0g/o1TsrKsEUcSKd3j39mvLuM2u8ZFo1bS+tbovv74J6kl6MjP8DQKBgH1B -q7oq58YWTNC4RZ327+g+AVH9vSjPU6DdmgaoPnpTi0l0UzWPom12zxq7mCNht5LW -M7dY2p3B7Wih74GWLEJnUgTJTp+17I8AL6DzoTQ8npZVRGd6wlXLpD37ENryL5J5 -Uau9rabODIRsM7O6deokqBQWtj4w5l1BPIur3PB5AoGAYSXUMSGROOwFsJBWMyV0 -Zkki7WTEqtkWD2WA4sggmxQw9jtbuiud558v6BT8RwbolUWICeNlfae0sxNNhMOV -9vQTeemYjZIDTaK8iJ7RdEqoLl27SjhmPsnXdn4+eunBEBUahs2U3QtAJ0SxLSlU -W8tmx1Coy7OXOWnP/jc1PoA= +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC9W4gyufbey99H +GFikk+ASg0uJRmi8qk6yPEeypSldzgHATE2HO+6cbXGHX4I2HgQjPTtxbN/xFcMi +BkE79pZ63MNK1J3ewoYEeeYbLAm/wJxh5ouCoN4ijLLt2ZBBY4qc07phuNua9FBQ +UpSa85SuD465wRpBn0m9xYPJcBKl35bnj9eD+eUSncgI93xrqcdAXxFd0bGRZuV3 +sGqi6sLLo9o4OAHlrYSw7NIczbaf5VJJ6W+P9dMMP26lO9Yg2vLNGBr7Vmcxsx8a +xsCnppNWrdOVAXrQu9odgmrlxgMNXfut1P1+C+shX+fJWdlbN5NKO1M9FEvgp1nn +Kn6iOcsZAgMBAAECggEAI/sRjToC6+DnY1X/+f3TVRE3uDT1z+u4mmuT4CE8FAQC +Fy0oiircM0MkkGmYj3nypVOMSQg8IU5WTs/Lc35up5ftT/nIqbxixNT44W7jx1jt +Hjk1hzPUIdKEaWeu3o3wl84vm0QKDo6f+DNI44ynUs/H+gn0WEXROEsguJUgJf5b +AV71vJmhNCWuXbztbeZb8Q7l93z82jM84n/K6yUD2++peKADQiaQHMZ2sMnoiRlu +T2jJrRyiPOKpv1h2hMJgy54SHVFPVWB/SoMm+BXWbHuSUWHetumH/YesTTFFfsxU +61DY9yfbC7zpVPsgPpY9JjTzMTmDBCL4eGBwaD5bdQKBgQD7Twz7Ysa08QKzAiAJ +MRuqP6T6GU7/7yyUOSo+vasHvMjudNU/SfqWqRgrkL/cs6T097TRWGg/vOKPZfTk +8T1PKXCDEJI90miB6nUmsR2YUZqOdoSgiBiw+5GsFYYcwk/K0v1MlDarnaUMYptN +r45+oW+T7FMmxWnkH4lVgcQEMwKBgQDA5G4TAZNG30pSkRAU/2VE6qGQxhrKUbP5 +Q7DMeEat0fQoLNr2Cgibceo/KMVG/+zp/3sgt37JnJk8Xx20/Vr145x09i/4oyeS +Fm2R65T/AFjhj6GWKU2P5bGjT4IPM2XFTmui2ibGMZq5e+iu8+jQUVfWS391nbJr +KkPqzbjHgwKBgCKOuEZJSvHOwoRDIoyhCCDdJ56MpGRlS0gGpFyEUGR7/VVtE3Xk +gmT9U6mJ+hgrS9+zwFWZRpydKLp3Vrm0wq2EEUi0mqeNq3lR+FIdwOgVMJ+uhIZe +sKaFsX5sZ0jFH5vQb6x8JRhsUSnm1VxVa87V/DXnJXgLZYhNHeF4OrApAoGAYYif +p/iZ8bIiMJGtIhDpsek5/KxPC5I4gIebtCf1BRsua8oShWoZZBA7s+f3qKjrsH5A +UhC2iVcnnIEz0jpxbffDOtjo9L362kGA8dG/KxmP1636tKT51RL9uWgQiYMhqJy4 +jEXmaTzX0YY3547pX7xhv7e5IU21074xTV7sWeUCgYEA8b5f7K1yeUi4ZHJsTEwG +BFeP9ZQWjeQnMI39b51XJjADfIHge5pozYM+t5kyFrOyGj4QsGn1vCpvE0dI+LMn +Kb6pjXOgoT3Qo9K6s//RMWqd5rc14FxVmDW8ImANhNN00z7FcwMlVi4tM3FRDenZ +h2yWGDE7K65JKguaFtnyMOQ= -----END PRIVATE KEY----- diff --git a/packages/join-e2e/fake-api/server.js b/packages/join-e2e/fake-api/server.js index 7c13f41..61a0e07 100644 --- a/packages/join-e2e/fake-api/server.js +++ b/packages/join-e2e/fake-api/server.js @@ -15,14 +15,20 @@ const fs = require('fs'); const http = require('http'); const https = require('https'); const path = require('path'); +const zlib = require('zlib'); const { URL } = require('url'); let requests = []; let overrides = []; let stripe = freshStripe(); +let zetkin = freshZetkin(); function freshStripe() { - return { counter: 0, objects: { products: [], prices: [], customers: [], subscriptions: [] } }; + return { counter: 0, objects: { products: [], prices: [], customers: [], subscriptions: [], invoices: [] } }; +} + +function freshZetkin() { + return { counter: 0, tags: [] }; } function parseBody(raw, contentType) { @@ -68,7 +74,7 @@ function fakeStripe(method, url, body) { const [, type, idOrAction] = match; const store = stripe.objects[type]; if (!store) return null; - const prefix = { products: 'prod', prices: 'price', customers: 'cus', subscriptions: 'sub' }[type]; + const prefix = { products: 'prod', prices: 'price', customers: 'cus', subscriptions: 'sub', invoices: 'in' }[type]; if (method === 'GET' && idOrAction === 'search') { const query = url.searchParams.get('query') || ''; @@ -108,6 +114,33 @@ function fakeStripe(method, url, body) { return null; } +/** + * A tiny in-memory Zetkin (api.zetk.in and api.dev.zetkin.org): enough for + * the plugin's OAuth sign-in, adding a person and tagging them. + */ +function fakeZetkin(method, url, body) { + const path = url.pathname.replace(/^\/v1/, ''); + if (method === 'POST' && path === '/oauth/token') { + return { status: 200, body: { access_token: 'e2e-zetkin-access-token', token_type: 'bearer' } }; + } + if (method === 'POST' && /^\/orgs\/[^/]+\/people$/.test(path)) { + return { status: 200, body: { data: { id: 4242, ...body } } }; + } + if (/^\/orgs\/[^/]+\/people\/tags$/.test(path)) { + if (method === 'GET') return { status: 200, body: { data: zetkin.tags } }; + if (method === 'POST') { + zetkin.counter += 1; + const tag = { id: zetkin.counter, title: body && body.title }; + zetkin.tags.push(tag); + return { status: 200, body: { data: tag } }; + } + } + if (/^\/orgs\/[^/]+\/people\/[^/]+\/tags\/[^/]+$/.test(path)) { + return { status: 200, body: { data: {} } }; + } + return null; +} + function respond(host, method, url, body) { const override = overrides.find( (rule) => @@ -120,6 +153,16 @@ function respond(host, method, url, body) { const result = fakeStripe(method, url, body); if (result) return result; } + if (host === 'api.zetk.in' || host === 'api.dev.zetkin.org') { + const result = fakeZetkin(method, url, body); + if (result) return result; + } + if (host === 'oauth2.googleapis.com' && method === 'POST' && url.pathname === '/token') { + return { status: 200, body: { access_token: 'e2e-google-access-token', token_type: 'Bearer', expires_in: 3600 } }; + } + if (host.endsWith('.auth0.com') && method === 'POST' && url.pathname === '/oauth/token') { + return { status: 200, body: { access_token: 'e2e-auth0-access-token', token_type: 'Bearer', expires_in: 86400 } }; + } return { status: 200, body: {} }; } @@ -129,7 +172,10 @@ function handleService(request, response) { request.on('end', () => { const host = (request.headers.host || '').split(':')[0]; const url = new URL(request.url, `https://${host}`); - const raw = Buffer.concat(chunks).toString('utf8'); + let buffer = Buffer.concat(chunks); + // Sentry, for one, gzips what it sends. + if (request.headers['content-encoding'] === 'gzip') buffer = zlib.gunzipSync(buffer); + const raw = buffer.toString('utf8'); const body = parseBody(raw, request.headers['content-type']); requests.push({ time: new Date().toISOString(), @@ -159,6 +205,7 @@ function handleControl(request, response) { requests = []; overrides = []; stripe = freshStripe(); + zetkin = freshZetkin(); return send(200, { ok: true }); } if (request.method === 'POST' && request.url === '/overrides') { diff --git a/packages/join-e2e/tests/settings/fake-api.ts b/packages/join-e2e/tests/settings/fake-api.ts index 5f0e20f..fa87a74 100644 --- a/packages/join-e2e/tests/settings/fake-api.ts +++ b/packages/join-e2e/tests/settings/fake-api.ts @@ -56,6 +56,6 @@ export async function overrideFakeApi(rule: { * Puts an object straight into the fake Stripe, as if it had been created * there earlier, e.g. seedFakeStripe('customers', { id: 'cus_1', email: '...' }). */ -export async function seedFakeStripe(type: 'products' | 'prices' | 'customers' | 'subscriptions', object: Record): Promise { +export async function seedFakeStripe(type: 'products' | 'prices' | 'customers' | 'subscriptions' | 'invoices', object: Record): Promise { await control('POST', `/stripe/${type}`, object); } diff --git a/packages/join-e2e/tests/settings/fixtures.ts b/packages/join-e2e/tests/settings/fixtures.ts index 0124bb8..89a6e47 100644 --- a/packages/join-e2e/tests/settings/fixtures.ts +++ b/packages/join-e2e/tests/settings/fixtures.ts @@ -2,9 +2,9 @@ import { Page, test as base, expect } from '@playwright/test'; import { mkdtempSync } from 'fs'; import os from 'os'; import path from 'path'; -import { CONTINUE, loginAsAdmin, mockRestEndpoints } from '../helpers'; +import { CONTINUE, captureJoinBodyViaStripeRedirect, loginAsAdmin, mockRestEndpoints } from '../helpers'; import { resetFakeApi } from './fake-api'; -import { JoinSettingsPage, STANDARD_JOIN_PAGE, resetSettings, resetToSettingsBaseline } from './support'; +import { JoinSettingsPage, STANDARD_JOIN_PAGE, postJoin, resetSettings, resetToSettingsBaseline } from './support'; export * from './support'; export * from './fake-api'; @@ -74,14 +74,67 @@ export async function openJoinForm(page: Page, url = STANDARD_JOIN_PAGE): Promis /** * Serves a stand-in for Chargebee's script (with Chargebee on, the form won't * start without it), which records the settings the form initialises - * Chargebee with in window.chargebeeInitOptions. + * Chargebee with in window.chargebeeInitOptions. Its card fields render as + * nothing, but are there, so the form's payment step works. */ export async function stubChargebee(page: Page): Promise { + const script = `(() => { + const field = { at() { return field; }, on() { return field; }, update() {}, focus() {}, blur() {}, clear() {} }; + const component = { + status: 0, + on() { return component; }, + mount() { component.status = 1; return Promise.resolve(); }, + update() {}, + createField() { return field; }, + tokenize() { return Promise.resolve({ token: 'cb_e2e_token' }); }, + focus() {}, blur() {}, clear() {}, + }; + const instance = { + load() { return Promise.resolve(); }, + createComponent() { return component; }, + }; + window.Chargebee = { + init(options) { + window.chargebeeInitOptions = options; + Object.assign(instance, { site: options.site, publishableKey: options.publishableKey }); + return instance; + }, + getInstance() { return instance; }, + }; + })();`; await page.route('https://js.chargebee.com/**', (route) => - route.fulfill({ - contentType: 'application/javascript', - body: 'window.Chargebee = { init: function (options) { window.chargebeeInitOptions = options; return {}; }, getInstance: function () { return {}; } };', - }), + route.fulfill({ contentType: 'application/javascript', body: script }), + ); +} + +/** + * Serves a stand-in for Stripe.js, which records the publishable key the form + * uses in window.stripePublishableKey and the options it sets Stripe's + * payment fields up with in window.stripeElementsOptions (including + * paymentMethodTypes: card, bacs_debit). The payment fields render as nothing. + */ +export async function stubStripe(page: Page): Promise { + const script = `(() => { + const element = { mount() {}, unmount() {}, destroy() {}, update() {}, on() { return element; }, off() {}, collapse() {} }; + window.Stripe = function (key) { + window.stripePublishableKey = key; + return { + elements(options) { + window.stripeElementsOptions = options; + return { + create() { return element; }, + getElement() { return element; }, + update(changes) { Object.assign(window.stripeElementsOptions, changes); }, + submit() { return Promise.resolve({}); }, + fetchUpdates() { return Promise.resolve({}); }, + }; + }, + createToken() {}, createPaymentMethod() {}, confirmCardPayment() {}, confirmPayment() {}, confirmSetup() {}, + }; + }; + })();`; + await page.route('https://js.stripe.com/**', (route) => + route.fulfill({ contentType: 'application/javascript', body: script }), ); } @@ -96,3 +149,28 @@ export async function expectDetailsAccepted(page: Page): Promise { await page.locator(CONTINUE).click(); await expect(page.locator('.progress-step--current')).not.toContainText('Your Details'); } + +/** + * Joins through the join form on `url` as a visitor, and returns the join the + * form sent. Payment is skipped: the form's request to /join is caught at its + * last step (as after a Stripe payment) and passed on to the plugin + * unchanged, so the plugin handles exactly what the form sends. + * + * `fillDetails` can change the details step before moving on. + */ +export async function joinThroughForm( + page: Page, + url = STANDARD_JOIN_PAGE, + fillDetails?: (page: Page) => Promise, +): Promise> { + await openJoinForm(page, url); + if (fillDetails) { + await fillDetails(page); + } + await page.locator(CONTINUE).click(); + await page.waitForSelector('[role="radiogroup"]'); + const join = await captureJoinBodyViaStripeRedirect(page, url); + const response = await postJoin(page.request, join); + expect(response.ok(), `the plugin refused the join: ${await response.text()}`).toBe(true); + return join; +} diff --git a/packages/join-e2e/tests/settings/integrations-crm.spec.ts b/packages/join-e2e/tests/settings/integrations-crm.spec.ts new file mode 100644 index 0000000..cb0cf1d --- /dev/null +++ b/packages/join-e2e/tests/settings/integrations-crm.spec.ts @@ -0,0 +1,391 @@ +import { + STANDARD_JOIN_PAGE, + expect, + fakeApiRequests, + getSettings, + joinFormEnv, + joinThroughForm, + loadLegacySettings, + test, + wpEvalJson, +} from './fixtures'; + +/** + * Settings page, Integrations tab: the CRMs (Zetkin, Mailchimp, Action + * Network), Auth0 and the webhooks. + * + * Each test turns the service on, joins through the real join form, and + * checks what the plugin sent to the service's fake (see fake-api/server.js): + * where it went, the credentials it carried, and the member's details. + */ + +/** The join form's test data, which every join here uses. */ +const MEMBER = { email: 'someone@example.com', firstName: 'Test', lastName: 'Person' }; + +/** Today's date as the plugin writes it in the container, e.g. 2026-10-24. */ +const today = () => wpEvalJson('return date("Y-m-d");'); + +test.describe('Zetkin', () => { + test('a join is sent to the Zetkin join form, with the organisation, form and token set here', async ({ + settings, + page, + }) => { + await settings.open('Features'); + await settings.enableIntegration('Use Zetkin'); + await settings.tab('Integrations'); + await settings.setText('Zetkin Membership Tier Custom Field', 'membership_tier'); + await settings.setText('Zetkin Join Date Custom Field', 'joined_on'); + await settings.save(); + + expect( + getSettings([ + 'zetkin_organisation_id', + 'zetkin_join_form_id', + 'zetkin_join_form_submit_token', + 'zetkin_membership_custom_field', + 'zetkin_join_date_custom_field', + 'zetkin_environment', + ]), + ).toEqual({ + zetkin_organisation_id: '1', + zetkin_join_form_id: '2', + zetkin_join_form_submit_token: 'e2e-zetkin-token', + zetkin_membership_custom_field: 'membership_tier', + zetkin_join_date_custom_field: 'joined_on', + zetkin_environment: 'sandbox', + }); + + await joinThroughForm(page); + + // Without OAuth credentials, the plugin submits Zetkin's join form. The + // sandbox environment (the default) is Zetkin's development server. + const [submission] = await fakeApiRequests('api.dev.zetkin.org'); + expect(submission).toMatchObject({ + method: 'POST', + path: '/v1/orgs/1/join_forms/2/submissions', + body: { + submit_token: 'e2e-zetkin-token', + form_data: { + email: MEMBER.email, + first_name: MEMBER.firstName, + last_name: MEMBER.lastName, + membership_tier: 'Standard', + joined_on: today(), + }, + }, + }); + expect(await fakeApiRequests('api.zetk.in')).toEqual([]); + }); + + test('without the custom fields, the plan and join date are not sent', async ({ settings, page }) => { + await settings.open('Features'); + await settings.enableIntegration('Use Zetkin'); + await settings.save(); + + await joinThroughForm(page); + + const [submission] = await fakeApiRequests('api.dev.zetkin.org'); + expect(Object.values(submission.body.form_data)).not.toContain('Standard'); + expect(Object.values(submission.body.form_data)).not.toContain(today()); + }); + + test('Zetkin Environment "Live" sends joins to Zetkin\'s live server', async ({ settings, page }) => { + await settings.open('Features'); + await settings.enableIntegration('Use Zetkin'); + await settings.tab('Integrations'); + await settings.setSelect('Zetkin Environment', 'live'); + await settings.save(); + + expect(getSettings(['zetkin_environment']).zetkin_environment).toBe('live'); + + await joinThroughForm(page); + + expect((await fakeApiRequests('api.zetk.in')).map((request) => request.path)).toEqual([ + '/v1/orgs/1/join_forms/2/submissions', + ]); + expect(await fakeApiRequests('api.dev.zetkin.org')).toEqual([]); + }); + + test('with the OAuth credentials, the member is added through Zetkin\'s API and tagged', async ({ + settings, + page, + }) => { + await settings.open('Features'); + await settings.enableIntegration('Use Zetkin'); + await settings.tab('Integrations'); + await settings.setText('Zetkin OAuth Client ID', 'e2e-zetkin-client'); + await settings.setText('Zetkin OAuth Client Secret', 'e2e-zetkin-secret'); + await settings.setText('Zetkin OAuth JWT', 'e2e-zetkin-jwt'); + await settings.save(); + + await joinThroughForm(page); + + const requests = await fakeApiRequests('api.dev.zetkin.org'); + const [token] = requests; + expect(token).toMatchObject({ + method: 'POST', + path: '/v1/oauth/token', + body: { assertion: 'e2e-zetkin-jwt', grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer' }, + }); + expect(token.headers.authorization).toBe( + `Basic ${Buffer.from('e2e-zetkin-client:e2e-zetkin-secret').toString('base64')}`, + ); + + const person = requests.find((request) => request.method === 'POST' && request.path === '/v1/orgs/1/people'); + expect(person?.headers.authorization).toBe('Bearer e2e-zetkin-access-token'); + expect(person?.body).toMatchObject({ email: MEMBER.email, first_name: MEMBER.firstName }); + + // Every new member is tagged Unconfirmed; the join form was not used. + const createdTags = requests + .filter((request) => request.method === 'POST' && request.path === '/v1/orgs/1/people/tags') + .map((request) => request.body.title); + expect(createdTags).toContain('Unconfirmed'); + expect(requests.filter((request) => request.method === 'PUT').length).toBeGreaterThan(0); + expect(requests.some((request) => request.path.includes('/join_forms/'))).toBe(false); + }); +}); + +test.describe('Mailchimp', () => { + test('a join adds the member to the audience set here, with the API key set here', async ({ settings, page }) => { + await settings.open('Features'); + await settings.enableIntegration('Use Mailchimp'); + await settings.save(); + + expect(getSettings(['mailchimp_api_key', 'mailchimp_audience_id'])).toEqual({ + mailchimp_api_key: 'e2e-mailchimp-key-us1', + mailchimp_audience_id: 'e2e-audience', + }); + + await joinThroughForm(page); + + // The API key's suffix (us1) names the Mailchimp server to use. + const signup = (await fakeApiRequests('us1.api.mailchimp.com')).find( + (request) => request.method === 'POST' && request.path === '/3.0/lists/e2e-audience/members', + ); + expect(signup?.body).toMatchObject({ + email_address: MEMBER.email, + status: 'subscribed', + merge_fields: { FNAME: MEMBER.firstName, LNAME: MEMBER.lastName }, + }); + expect(Buffer.from(signup!.headers.authorization.replace(/^Basic /, ''), 'base64').toString()).toMatch( + /:e2e-mailchimp-key-us1$/, + ); + }); +}); + +test.describe('Action Network', () => { + test('a join adds the member to Action Network, with the API key set here', async ({ settings, page }) => { + await settings.open('Membership Plans'); + await settings.editPlan(0, { addTags: 'Member' }); + await settings.tab('Features'); + await settings.enableIntegration('Use Action Network'); + await settings.save(); + + expect(getSettings(['action_network_api_key']).action_network_api_key).toBe('e2e-action-network-key'); + + await joinThroughForm(page); + + const signup = (await fakeApiRequests('actionnetwork.org')).find( + (request) => request.method === 'POST' && request.path === '/api/v2/people/', + ); + expect(signup?.headers['osdi-api-token']).toBe('e2e-action-network-key'); + expect(signup?.body).toMatchObject({ + person: { + email_addresses: [expect.objectContaining({ address: MEMBER.email })], + given_name: MEMBER.firstName, + family_name: MEMBER.lastName, + }, + }); + }); +}); + +test.describe('Webhooks', () => { + // The fake API answers webhooks too: inside the Docker network it is + // reachable as http://fake-api. + const JOIN_WEBHOOK = 'http://fake-api/join-complete'; + const STEP_WEBHOOK = 'http://fake-api/step'; + + test('Join Complete Webhook URL is sent the join, but the form only ever sees an ID for it', async ({ + settings, + page, + }) => { + await settings.open('Integrations'); + await settings.setText('Join Complete Webhook URL', JOIN_WEBHOOK); + await settings.save(); + + expect(getSettings(['webhook_url']).webhook_url).toBe(JOIN_WEBHOOK); + + const env = await joinFormEnv(page, STANDARD_JOIN_PAGE); + expect(env.WEBHOOK_UUID).toMatch(/^[0-9a-f-]{36}$/); + expect(JSON.stringify(env)).not.toContain('join-complete'); + + const join = await joinThroughForm(page); + expect(join.webhookUuid).toBe(env.WEBHOOK_UUID); + + const [delivery] = await fakeApiRequests('fake-api'); + expect(delivery).toMatchObject({ + method: 'POST', + path: '/join-complete', + body: { email: MEMBER.email, firstName: MEMBER.firstName, membership: 'standard' }, + }); + // Bank details are never passed on. + expect(delivery.body).not.toHaveProperty('ddAccountNumber'); + expect(delivery.body).not.toHaveProperty('ddSortCode'); + }); + + test('with no Join Complete Webhook URL, nothing is sent', async ({ page }) => { + expect((await joinFormEnv(page, STANDARD_JOIN_PAGE)).WEBHOOK_UUID).toBe(''); + await joinThroughForm(page); + expect(await fakeApiRequests('fake-api')).toEqual([]); + }); + + test('Step Webhook Url is sent the form\'s data after each step', async ({ settings, page }) => { + await settings.open('Integrations'); + await settings.setText('Step Webhook Url', STEP_WEBHOOK); + await settings.save(); + + expect(getSettings(['step_webhook_url']).step_webhook_url).toBe(STEP_WEBHOOK); + + // The form reports each step to the plugin's /step endpoint. + const response = await page.request.post('/wp-json/join/v1/step', { + data: { ...MEMBER, sessionToken: 'e2e-session', stage: 'details', ddAccountNumber: '55779911' }, + }); + expect(response.ok()).toBe(true); + + const [delivery] = await fakeApiRequests('fake-api'); + expect(delivery).toMatchObject({ method: 'POST', path: '/step', body: { email: MEMBER.email, stage: 'details' } }); + expect(delivery.body).not.toHaveProperty('ddAccountNumber'); + }); +}); + +test.describe('Auth0', () => { + test('a join creates the member\'s Auth0 account, with the credentials set here', async ({ settings, page }) => { + await settings.open('Features'); + await settings.enableIntegration('Create Auth0 Account'); + await settings.save(); + + expect( + getSettings(['auth0_domain', 'auth0_client_id', 'auth0_client_secret', 'auth0_management_audience']), + ).toEqual({ + auth0_domain: 'e2e.eu.auth0.com', + auth0_client_id: 'e2e-auth0-client-id', + auth0_client_secret: 'e2e-auth0-client-secret', + auth0_management_audience: 'https://e2e.eu.auth0.com/api/v2/', + }); + + await joinThroughForm(page, STANDARD_JOIN_PAGE, async (form) => { + await form.locator('input#password').fill('Str0ng!password'); + }); + + const requests = await fakeApiRequests('e2e.eu.auth0.com'); + expect(requests.find((request) => request.path === '/oauth/token')?.body).toMatchObject({ + grant_type: 'client_credentials', + client_id: 'e2e-auth0-client-id', + client_secret: 'e2e-auth0-client-secret', + audience: 'https://e2e.eu.auth0.com/api/v2/', + }); + const created = requests.find((request) => request.method === 'POST' && request.path === '/api/v2/users'); + expect(created?.headers.authorization).toBe('Bearer e2e-auth0-access-token'); + expect(created?.body).toMatchObject({ + email: MEMBER.email, + password: 'Str0ng!password', + connection: 'Username-Password-Authentication', + }); + }); +}); + +test.describe('Integrations saved before the settings page was rebuilt', () => { + // See tests/settings/legacy/README.md. The payment providers' settings are + // checked here as read; their effects are covered in the other specs. + test.beforeEach(() => loadLegacySettings('integrations')); + + test('are still read', () => { + expect( + getSettings([ + 'zetkin_organisation_id', + 'zetkin_join_form_id', + 'zetkin_join_form_submit_token', + 'zetkin_membership_custom_field', + 'zetkin_join_date_custom_field', + 'zetkin_environment', + 'use_chargebee_hosted_pages', + 'chargebee_site_name', + 'chargebee_api_key', + 'chargebee_api_publishable_key', + 'gc_access_token', + 'gc_environment', + 'stripe_publishable_key', + 'stripe_secret_key', + 'stripe_direct_debit', + 'stripe_direct_debit_only', + 'postcode_address_provider', + 'ideal_postcodes_api_key', + 'get_address_io_api_key', + 'mailchimp_api_key', + 'mailchimp_audience_id', + 'action_network_api_key', + 'step_webhook_url', + 'webhook_url', + 'auth0_domain', + 'auth0_client_id', + 'auth0_client_secret', + 'auth0_management_audience', + 'sentry_dsn', + ]), + ).toEqual({ + zetkin_organisation_id: '11', + zetkin_join_form_id: '22', + zetkin_join_form_submit_token: 'legacy-zetkin-token', + zetkin_membership_custom_field: 'legacy_tier', + zetkin_join_date_custom_field: 'legacy_joined', + zetkin_environment: 'live', + use_chargebee_hosted_pages: true, + chargebee_site_name: 'legacy', + chargebee_api_key: 'legacy-chargebee-key', + chargebee_api_publishable_key: 'legacy-chargebee-publishable-key', + gc_access_token: 'legacy-gocardless-token', + gc_environment: 'live', + stripe_publishable_key: 'pk_test_legacy', + stripe_secret_key: 'sk_test_legacy', + stripe_direct_debit: true, + stripe_direct_debit_only: true, + postcode_address_provider: 'get_address_io', + ideal_postcodes_api_key: 'legacy-ideal-key', + get_address_io_api_key: 'legacy-getaddress-key', + mailchimp_api_key: 'legacy-mailchimp-key-us1', + mailchimp_audience_id: 'legacy-audience', + action_network_api_key: 'legacy-action-network-key', + step_webhook_url: 'http://fake-api/legacy-step', + webhook_url: 'http://fake-api/legacy-join-complete', + auth0_domain: 'legacy.eu.auth0.com', + auth0_client_id: 'legacy-auth0-client-id', + auth0_client_secret: 'legacy-auth0-client-secret', + auth0_management_audience: 'https://legacy.eu.auth0.com/api/v2/', + sentry_dsn: 'https://legacykey@sentry.e2e.example/7', + }); + }); + + test('a join still reaches Zetkin, Mailchimp, Action Network and the webhook as set up', async ({ page }) => { + expect(await joinFormEnv(page, STANDARD_JOIN_PAGE)).toMatchObject({ + SENTRY_DSN: 'https://legacykey@sentry.e2e.example/7', + POSTCODE_ADDRESS_PROVIDER: 'get_address_io', + USE_POSTCODE_LOOKUP: true, + }); + + await joinThroughForm(page); + + const [zetkin] = await fakeApiRequests('api.zetk.in'); + expect(zetkin).toMatchObject({ + path: '/v1/orgs/11/join_forms/22/submissions', + body: { submit_token: 'legacy-zetkin-token', form_data: { legacy_tier: 'Standard', legacy_joined: today() } }, + }); + expect( + (await fakeApiRequests('us1.api.mailchimp.com')).some( + (request) => request.path === '/3.0/lists/legacy-audience/members', + ), + ).toBe(true); + const [actionNetwork] = await fakeApiRequests('actionnetwork.org'); + expect(actionNetwork.headers['osdi-api-token']).toBe('legacy-action-network-key'); + expect((await fakeApiRequests('fake-api')).map((request) => request.path)).toContain('/legacy-join-complete'); + }); +}); diff --git a/packages/join-e2e/tests/settings/integrations-logging.spec.ts b/packages/join-e2e/tests/settings/integrations-logging.spec.ts new file mode 100644 index 0000000..9f0e1b3 --- /dev/null +++ b/packages/join-e2e/tests/settings/integrations-logging.spec.ts @@ -0,0 +1,182 @@ +import { APIRequestContext } from '@playwright/test'; +import { generateKeyPairSync } from 'crypto'; +import { expect, fakeApiRequests, getSettings, joinFormEnv, openJoinForm, test, wpEvalJson } from './fixtures'; + +/** + * Settings page, Integrations tab: where the plugin's logs go (Sentry, Google + * Cloud Logging, Loggly). The Logging tab (log viewer, retention) is covered + * separately. + * + * Logging is set up at the start of each request, so each test saves the + * settings, makes a fresh request that logs something, and checks what reached + * the service's fake. + */ + +/** Makes the plugin log an info line ("Recording CK Join form step"). */ +async function logSomething(request: APIRequestContext): Promise { + const response = await request.post('/wp-json/join/v1/step', { data: { email: 'someone@example.com', stage: 'details' } }); + expect(response.ok()).toBe(true); +} + +/** Makes the plugin log an error ("Invalid membership plan: ..."). */ +async function logAnError(request: APIRequestContext): Promise { + const response = await request.post('/wp-json/join/v1/join', { + data: { email: 'someone@example.com', membership: 'no-such-plan' }, + }); + expect(response.status()).toBe(500); +} + +/** The size of the plugin's log files on disk, in bytes. */ +const logFilesSize = () => + wpEvalJson(` + $dir = \\CommonKnowledge\\JoinBlock\\Logging::getLogDirectory(); + clearstatcache(); + return array_sum(array_map('filesize', glob("$dir/debug-*.log") ?: [])); + `); + +const LOGGING_HOSTS = ['logs-01.loggly.com', 'sentry.e2e.example', 'oauth2.googleapis.com', 'logging.googleapis.com']; + +test('by default, logs are written to disk and sent nowhere else', async ({ request }) => { + const before = logFilesSize(); + await logSomething(request); + await logAnError(request); + + expect(logFilesSize()).toBeGreaterThan(before); + const sent = await fakeApiRequests(); + expect(sent.filter((request) => LOGGING_HOSTS.includes(request.host))).toEqual([]); +}); + +test.describe('Loggly', () => { + test('Loggly Customer Token sends logs to Loggly instead of to disk, tagged with Loggly Tags', async ({ + settings, + request, + }) => { + await settings.open('Integrations'); + await settings.setText('Loggly Customer Token', 'e2e-loggly-token'); + await settings.setText('Loggly Tags', 'e2e-site, join'); + await settings.save(); + + expect(getSettings(['loggly_token', 'loggly_tags'])).toEqual({ + loggly_token: 'e2e-loggly-token', + loggly_tags: 'e2e-site, join', + }); + + const before = logFilesSize(); + await logSomething(request); + + const sent = await fakeApiRequests('logs-01.loggly.com'); + expect(sent.length).toBeGreaterThan(0); + expect(sent[0]).toMatchObject({ method: 'POST', path: '/bulk/e2e-loggly-token/' }); + expect(sent[0].headers['x-loggly-tag']).toBe('e2e-site,join'); + expect(sent.map((request) => JSON.stringify(request.body)).join('\n')).toContain('Recording CK Join form step'); + expect(logFilesSize()).toBe(before); + }); + + test('without Loggly Tags, logs are tagged join-block', async ({ settings, request }) => { + await settings.open('Integrations'); + await settings.setText('Loggly Customer Token', 'e2e-loggly-token'); + await settings.save(); + + await logSomething(request); + + const [first] = await fakeApiRequests('logs-01.loggly.com'); + expect(first.headers['x-loggly-tag']).toBe('join-block'); + }); + + test('the Logging tab says where the logs went', async ({ settings }) => { + await settings.open('Integrations'); + await settings.setText('Loggly Customer Token', 'e2e-loggly-token'); + await settings.setText('Loggly Tags', 'e2e-site'); + await settings.save(); + + await settings.tab('Logging'); + await expect(settings.page.getByText('Logs are sent to Loggly and are not stored on this server.')).toBeVisible(); + await expect(settings.page.locator('.cf-container__fields strong', { hasText: 'e2e-site' })).toBeVisible(); + }); +}); + +test.describe('Sentry', () => { + const DSN = 'https://e2epublickey@sentry.e2e.example/42'; + + test('Sentry Dsn sends the plugin\'s errors to Sentry, and sets Sentry up in the join form', async ({ + settings, + request, + page, + }) => { + await settings.open('Integrations'); + await settings.setText('Sentry Dsn', DSN); + await settings.save(); + + expect(getSettings(['sentry_dsn']).sentry_dsn).toBe(DSN); + + await logSomething(request); + expect(await fakeApiRequests('sentry.e2e.example')).toEqual([]); + + await logAnError(request); + const [event] = await fakeApiRequests('sentry.e2e.example'); + expect(event).toMatchObject({ method: 'POST', path: '/api/42/envelope/' }); + expect(event.headers['x-sentry-auth']).toContain('sentry_key=e2epublickey'); + expect(event.body).toContain('Invalid membership plan: no-such-plan'); + + // The form reports its own errors to the same project. + expect((await joinFormEnv(page)).SENTRY_DSN).toBe(DSN); + const reported: string[] = []; + await page.route('https://sentry.e2e.example/**', (route) => { + reported.push(route.request().url()); + return route.fulfill({ status: 200, body: '{}' }); + }); + await openJoinForm(page); + await page.evaluate(() => setTimeout(() => { + throw new Error('e2e form error'); + })); + await expect.poll(() => reported.length).toBeGreaterThan(0); + expect(reported[0]).toContain('/api/42/'); + }); +}); + +test.describe('Google Cloud Logging', () => { + test('Google Cloud Project Id and Key File Contents send logs to that project\'s log', async ({ + settings, + request, + }) => { + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const keyFile = JSON.stringify({ + type: 'service_account', + project_id: 'e2e-project', + private_key_id: 'e2e-key', + private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }), + client_email: 'join-logs@e2e-project.iam.gserviceaccount.com', + client_id: '1', + token_uri: 'https://oauth2.googleapis.com/token', + }); + + await settings.open('Integrations'); + await settings.setText('Google Cloud Project Id', 'e2e-project'); + await settings.setText('Google Cloud Key File Contents', keyFile); + await settings.save(); + + expect(getSettings(['google_cloud_project_id', 'google_cloud_key_file_contents'])).toEqual({ + google_cloud_project_id: 'e2e-project', + google_cloud_key_file_contents: keyFile, + }); + + await logSomething(request); + + const writes = (await fakeApiRequests('logging.googleapis.com')).filter( + (request) => request.method === 'POST' && request.path === '/v2/entries:write', + ); + expect(writes.length).toBeGreaterThan(0); + const entries = writes.flatMap((request) => request.body.entries); + expect(entries).toContainEqual( + expect.objectContaining({ + logName: 'projects/e2e-project/logs/join-flow', + jsonPayload: expect.objectContaining({ message: 'Recording CK Join form step' }), + }), + ); + + // Signed in as the key file's service account. + const token = writes[0].headers.authorization.replace(/^Bearer /, ''); + const claims = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()); + expect(claims.iss).toBe('join-logs@e2e-project.iam.gserviceaccount.com'); + }); +}); diff --git a/packages/join-e2e/tests/settings/integrations-payments.spec.ts b/packages/join-e2e/tests/settings/integrations-payments.spec.ts new file mode 100644 index 0000000..c9e9beb --- /dev/null +++ b/packages/join-e2e/tests/settings/integrations-payments.spec.ts @@ -0,0 +1,422 @@ +import { Page } from '@playwright/test'; +import { readFileSync } from 'fs'; +import { CONTINUE } from '../helpers'; +import { + STANDARD_JOIN_PAGE, + expect, + fakeApiRequests, + getSettings, + joinFormEnv, + openJoinForm, + overrideFakeApi, + seedFakeStripe, + stubChargebee, + stubStripe, + test, +} from './fixtures'; + +/** + * Settings page, Integrations tab: the payment providers' settings (Stripe, + * GoCardless, Chargebee) and the postcode address lookup. + * + * Turning each provider on, its logo and the keys the form is given are + * covered with the Features tab (features-integrations.spec.ts). Here: the + * provider settings that change how members pay, and the credentials and + * environment the plugin uses when it calls each provider (fake API). + */ + +/** From the details step, through the plan step, to whatever comes next. */ +async function continueToPayment(page: Page): Promise { + await page.locator(CONTINUE).click(); + await page.waitForSelector('[role="radiogroup"]'); + await page.locator('label.radio-panel').first().click(); + await page.locator(CONTINUE).click(); +} + +/** The payment methods the form offers through Stripe's payment fields. */ +async function stripePaymentMethods(page: Page): Promise { + await stubStripe(page); + await openJoinForm(page); + await continueToPayment(page); + await page.waitForFunction(() => (window as any).stripeElementsOptions); + return page.evaluate(() => (window as any).stripeElementsOptions.paymentMethodTypes); +} + +test.describe('Stripe', () => { + test('Enable direct debit through Stripe offers Direct Debit as well as card', async ({ settings, page }) => { + await settings.open('Features'); + await settings.enableIntegration('Use Stripe'); + await settings.tab('Integrations'); + await settings.setCheckbox('Enable direct debit through Stripe (must be enabled in your Stripe account)', true); + await settings.setCheckbox('Direct Debit only, hide credit card option', false); + await settings.save(); + + expect(getSettings(['stripe_direct_debit', 'stripe_direct_debit_only'])).toEqual({ + stripe_direct_debit: true, + stripe_direct_debit_only: false, + }); + const env = await joinFormEnv(page); + expect(env).toMatchObject({ STRIPE_DIRECT_DEBIT: true, STRIPE_DIRECT_DEBIT_ONLY: false }); + + expect(await stripePaymentMethods(page)).toEqual(['card', 'bacs_debit']); + }); + + test('without it, Stripe only takes cards', async ({ settings, page }) => { + await settings.open('Features'); + await settings.enableIntegration('Use Stripe'); + await settings.save(); + + expect(getSettings(['stripe_direct_debit']).stripe_direct_debit).toBe(false); + expect(await stripePaymentMethods(page)).toEqual(['card']); + }); + + test('Direct Debit only takes card away', async ({ settings, page }) => { + await settings.open('Features'); + await settings.enableIntegration('Use Stripe'); + await settings.tab('Integrations'); + await settings.setCheckbox('Enable direct debit through Stripe (must be enabled in your Stripe account)', true); + await settings.setCheckbox('Direct Debit only, hide credit card option', true); + await settings.save(); + + expect(getSettings(['stripe_direct_debit', 'stripe_direct_debit_only'])).toEqual({ + stripe_direct_debit: true, + stripe_direct_debit_only: true, + }); + expect(await joinFormEnv(page)).toMatchObject({ STRIPE_DIRECT_DEBIT_ONLY: true }); + + expect(await stripePaymentMethods(page)).toEqual(['bacs_debit']); + }); + + test('Direct Debit only is only offered, and only kept, with direct debit enabled', async ({ settings }) => { + await settings.open('Integrations'); + const directDebitOnly = settings.field('Direct Debit only, hide credit card option'); + await expect(directDebitOnly).toBeHidden(); + + // The seeded site has Direct Debit only stored without direct debit + // enabled (see scripts/setup.php). Saving the page drops it. + await settings.save(); + expect(getSettings(['stripe_direct_debit', 'stripe_direct_debit_only'])).toEqual({ + stripe_direct_debit: false, + stripe_direct_debit_only: false, + }); + }); + + test('Download Subscriptions exports Stripe\'s subscriptions as a spreadsheet', async ({ settings }) => { + await settings.open('Features'); + await settings.enableIntegration('Use Stripe'); + await settings.save(); + + await seedFakeStripe('customers', { id: 'cus_export', object: 'customer', email: 'exported@example.org' }); + await seedFakeStripe('products', { id: 'prod_export', object: 'product', name: 'Membership: Standard' }); + await seedFakeStripe('prices', { + id: 'price_export', + object: 'price', + product: 'prod_export', + currency: 'gbp', + unit_amount: 500, + nickname: null, + }); + await seedFakeStripe('subscriptions', { + id: 'sub_export', + object: 'subscription', + customer: 'cus_export', + status: 'active', + created: 1767225600, + current_period_end: 1769904000, + items: { object: 'list', data: [{ id: 'si_export', object: 'subscription_item', price: { id: 'price_export', object: 'price' } }] }, + }); + // With no invoices the export breaks (Linear JOIN-167, tested below). + await seedFakeStripe('invoices', { + id: 'in_export', + object: 'invoice', + subscription: 'sub_export', + customer: 'cus_export', + status: 'paid', + created: 1767312000, + }); + + await settings.tab('Integrations'); + const [download] = await Promise.all([ + settings.page.waitForEvent('download'), + settings.page.getByRole('link', { name: 'Download Subscriptions' }).click(), + ]); + const csv = readFileSync((await download.path()) as string, 'utf8').trim().split('\n'); + + expect(csv[0]).toBe( + '"Email","Customer ID","Subscription ID","Subscription Status","Subscription Created","Subscription End","Price ID","Price Name","First Payment","Last Payment"', + ); + expect(csv[1]).toMatch(/^"exported@example.org","cus_export","sub_export","active",/); + expect(csv[1]).toContain('"price_export","Membership: Standard - GBP 5.00","2026-01-02 00:00:00","2026-01-02 00:00:00"'); + + // The plugin reads Stripe with the secret key set on this tab. + const [first] = await fakeApiRequests('api.stripe.com'); + expect(first.headers.authorization).toBe('Bearer sk_test_e2e'); + }); + + // An empty Stripe list makes the export print a PHP warning into the + // spreadsheet (Linear JOIN-167). Re-enable once it's fixed. + test.fixme('Download Subscriptions works when Stripe has no invoices', async ({ settings }) => { + await settings.open('Features'); + await settings.enableIntegration('Use Stripe'); + await settings.save(); + await seedFakeStripe('customers', { id: 'cus_export', object: 'customer', email: 'exported@example.org' }); + await seedFakeStripe('subscriptions', { + id: 'sub_export', + object: 'subscription', + customer: 'cus_export', + status: 'active', + created: 1767225600, + current_period_end: 1769904000, + items: { object: 'list', data: [] }, + }); + + await settings.tab('Integrations'); + const [download] = await Promise.all([ + settings.page.waitForEvent('download'), + settings.page.getByRole('link', { name: 'Download Subscriptions' }).click(), + ]); + const csv = readFileSync((await download.path()) as string, 'utf8').trim().split('\n'); + expect(csv[0]).toMatch(/^"Email",/); + expect(csv[1]).toMatch(/^"exported@example.org","cus_export","sub_export",/); + }); + + test('Download Subscriptions is for admins only', async ({ page }) => { + const response = await page.request.get('/wp-json/join/v1/stripe/download-subscriptions'); + expect(response.status()).toBe(401); + }); +}); + +test.describe('GoCardless', () => { + const AUTHORISATION_URL = 'https://pay-sandbox.gocardless.example/flow/BRF_E2E'; + + /** Makes the fake GoCardless answer the plugin's two calls to start a Direct Debit sign-up. */ + async function fakeGoCardlessSignUp(): Promise { + await overrideFakeApi({ + method: 'POST', + path: '^/billing_requests$', + body: { billing_requests: { id: 'BRQ_E2E', status: 'pending', links: {} } }, + }); + await overrideFakeApi({ + method: 'POST', + path: '^/billing_request_flows$', + body: { billing_request_flows: { id: 'BRF_E2E', authorisation_url: AUTHORISATION_URL } }, + }); + } + + /** Goes through the form choosing Direct Debit, and returns where it sent the member. */ + async function payByDirectDebit(page: Page): Promise { + await page.route('https://pay-sandbox.gocardless.example/**', (route) => + route.fulfill({ contentType: 'text/html', body: '

GoCardless

' }), + ); + await openJoinForm(page); + await continueToPayment(page); + await page.waitForURL(AUTHORISATION_URL); + return page.url(); + } + + test('members are sent to GoCardless, set up with the access token, in the sandbox by default', async ({ + settings, + page, + }) => { + await settings.open('Features'); + await settings.enableIntegration('Use GoCardless'); + await settings.save(); + + expect(getSettings(['gc_access_token', 'gc_environment'])).toEqual({ + gc_access_token: 'e2e-gocardless-token', + gc_environment: 'sandbox', + }); + + await fakeGoCardlessSignUp(); + expect(await payByDirectDebit(page)).toBe(AUTHORISATION_URL); + + const requests = await fakeApiRequests('api-sandbox.gocardless.com'); + expect(requests.map((request) => `${request.method} ${request.path}`)).toEqual([ + 'POST /billing_requests', + 'POST /billing_request_flows', + ]); + expect(requests[0].headers.authorization).toBe('Bearer e2e-gocardless-token'); + expect(requests[1].body).toMatchObject({ billing_request_flows: { links: { billing_request: 'BRQ_E2E' } } }); + expect(await fakeApiRequests('api.gocardless.com')).toEqual([]); + }); + + test('GoCardless Environment "Live" uses GoCardless\'s live API', async ({ settings, page }) => { + await settings.open('Features'); + await settings.enableIntegration('Use GoCardless'); + await settings.tab('Integrations'); + await settings.setSelect('GoCardless Environment', 'live'); + await settings.save(); + + expect(getSettings(['gc_environment']).gc_environment).toBe('live'); + + await fakeGoCardlessSignUp(); + await payByDirectDebit(page); + + expect((await fakeApiRequests('api.gocardless.com')).map((request) => request.path)).toEqual([ + '/billing_requests', + '/billing_request_flows', + ]); + expect(await fakeApiRequests('api-sandbox.gocardless.com')).toEqual([]); + }); +}); + +test.describe('Chargebee', () => { + test('Use Chargebee Hosted Pages sends card payments to a Chargebee page, set up with the site and API key', async ({ + settings, + page, + }) => { + await settings.open('Features'); + await settings.enableIntegration('Use Chargebee'); + await settings.tab('Integrations'); + await settings.setCheckbox('Use Chargebee Hosted Pages', true); + await settings.save(); + + expect(getSettings(['use_chargebee_hosted_pages', 'chargebee_site_name', 'chargebee_api_key'])).toEqual({ + use_chargebee_hosted_pages: true, + chargebee_site_name: 'e2e', + chargebee_api_key: 'e2e-chargebee-key', + }); + expect(await joinFormEnv(page)).toMatchObject({ USE_CHARGEBEE_HOSTED_PAGES: true }); + + const hostedPageUrl = 'https://e2e.chargebee.example/pages/hp_e2e'; + await overrideFakeApi({ + host: 'e2e.chargebee.com', + method: 'POST', + path: '/hosted_pages/checkout_new_for_items$', + body: { hosted_page: { id: 'hp_e2e', type: 'checkout_new', url: hostedPageUrl, state: 'created', object: 'hosted_page' } }, + }); + await page.route('https://e2e.chargebee.example/**', (route) => + route.fulfill({ contentType: 'text/html', body: '

Chargebee

' }), + ); + await stubChargebee(page); + + await openJoinForm(page); + await continueToPayment(page); + await page.waitForURL(hostedPageUrl); + + const [checkout] = await fakeApiRequests('e2e.chargebee.com'); + expect(checkout).toMatchObject({ + method: 'POST', + path: '/api/v2/hosted_pages/checkout_new_for_items', + body: { subscription_items: { item_price_id: { 0: 'standard' } } }, + }); + expect(Buffer.from(checkout.headers.authorization.replace(/^Basic /, ''), 'base64').toString()).toBe( + 'e2e-chargebee-key:', + ); + }); + + test('without it, card details are taken in the form', async ({ settings, page }) => { + await settings.open('Features'); + await settings.enableIntegration('Use Chargebee'); + await settings.save(); + + expect(await joinFormEnv(page)).toMatchObject({ USE_CHARGEBEE_HOSTED_PAGES: false }); + + await stubChargebee(page); + await openJoinForm(page); + await continueToPayment(page); + await expect(page.getByText('Card Number', { exact: true })).toBeVisible(); + expect(page.url()).toContain(STANDARD_JOIN_PAGE); + expect(await fakeApiRequests('e2e.chargebee.com')).toEqual([]); + }); +}); + +test.describe('Postcode address lookup', () => { + const POSTCODE = 'OX1 4PE'; + + /** The list of addresses found, which the member picks from. */ + const addressChoice = (page: Page) => + page.locator('select', { has: page.locator('option', { hasText: 'Choose your address' }) }); + + async function findAddress(page: Page): Promise { + await openJoinForm(page); + await page.locator('input[name="postcode"]').fill(POSTCODE); + await page.getByRole('button', { name: 'Find address' }).click(); + } + + test('is off by default: the address is typed in', async ({ page }) => { + expect(getSettings(['postcode_address_provider']).postcode_address_provider).toBe(''); + expect(await joinFormEnv(page)).toMatchObject({ USE_POSTCODE_LOOKUP: false }); + + await openJoinForm(page); + await expect(page.getByRole('button', { name: 'Find address' })).toHaveCount(0); + await expect(page.locator('input#addressLine1')).toBeVisible(); + }); + + test('getAddress.io finds addresses with its API key, and fills in the one chosen', async ({ settings, page }) => { + await settings.open('Integrations'); + await settings.setSelect('Postcode Address Provider', 'get_address_io'); + await settings.setText('getAddress.io API Key', 'e2e-getaddress-key'); + await settings.save(); + + expect(getSettings(['postcode_address_provider', 'get_address_io_api_key'])).toEqual({ + postcode_address_provider: 'get_address_io', + get_address_io_api_key: 'e2e-getaddress-key', + }); + expect(await joinFormEnv(page)).toMatchObject({ USE_POSTCODE_LOOKUP: true, POSTCODE_ADDRESS_PROVIDER: 'get_address_io' }); + + await overrideFakeApi({ + host: 'api.getaddress.io', + path: '^/autocomplete/', + body: { suggestions: [{ id: 'gA_e2e', address: '1 Lookup Lane, Oxford' }] }, + }); + await overrideFakeApi({ + host: 'api.getaddress.io', + path: '^/get/gA_e2e$', + body: { line_1: '1 Lookup Lane', line_2: '', town_or_city: 'Oxford', county: 'Oxfordshire', postcode: 'OX1 4PE' }, + }); + + await findAddress(page); + await addressChoice(page).selectOption('gA_e2e'); + await expect(page.locator('input#addressLine1')).toHaveValue('1 Lookup Lane'); + await expect(page.locator('input#addressCity')).toHaveValue('Oxford'); + + const requests = await fakeApiRequests('api.getaddress.io'); + expect(requests.map((request) => request.path)).toEqual(['/autocomplete/OX14PE', '/get/gA_e2e']); + expect(requests[0].query).toMatchObject({ 'api-key': 'e2e-getaddress-key', all: 'true' }); + expect(requests[1].query).toMatchObject({ 'api-key': 'e2e-getaddress-key' }); + }); + + test('Ideal Postcodes finds addresses with its API key', async ({ settings, page }) => { + await settings.open('Integrations'); + await settings.setSelect('Postcode Address Provider', 'ideal_postcodes'); + await settings.setText('Ideal Postcodes API Key', 'e2e-ideal-key'); + await settings.save(); + + expect(getSettings(['postcode_address_provider', 'ideal_postcodes_api_key'])).toEqual({ + postcode_address_provider: 'ideal_postcodes', + ideal_postcodes_api_key: 'e2e-ideal-key', + }); + expect(await joinFormEnv(page)).toMatchObject({ USE_POSTCODE_LOOKUP: true, POSTCODE_ADDRESS_PROVIDER: 'ideal_postcodes' }); + + await overrideFakeApi({ + host: 'api.ideal-postcodes.co.uk', + path: '^/v1/postcodes/', + body: { + result: [{ line_1: '2 Ideal Street', line_2: '', post_town: 'Oxford', county: 'Oxfordshire', postcode: 'OX1 4PE', country: 'England' }], + }, + }); + + await findAddress(page); + await expect(addressChoice(page).locator('option')).toContainText([ + 'Choose your address', + '2 Ideal Street, OX1 4PE, Oxfordshire, England', + ]); + + const [lookup] = await fakeApiRequests('api.ideal-postcodes.co.uk'); + expect(lookup).toMatchObject({ path: '/v1/postcodes/OX14PE', query: { api_key: 'e2e-ideal-key' } }); + }); + + for (const [provider, value, message] of [ + ['getAddress.io', 'get_address_io', 'Missing getAddress.io API key.'], + ['ideal-postcodes.co.uk', 'ideal_postcodes', 'Missing Ideal Postcodes key.'], + ]) { + test(`${provider} is refused without its API key`, async ({ settings }) => { + await settings.open('Integrations'); + await settings.setSelect('Postcode Address Provider', value); + + expect(await settings.saveExpectingErrors()).toEqual([message]); + expect(getSettings(['postcode_address_provider']).postcode_address_provider).toBe(''); + }); + } +}); diff --git a/packages/join-e2e/tests/settings/legacy/integrations.json b/packages/join-e2e/tests/settings/legacy/integrations.json new file mode 100644 index 0000000..a376601 --- /dev/null +++ b/packages/join-e2e/tests/settings/legacy/integrations.json @@ -0,0 +1,41 @@ +{ + "_use_zetkin": "yes", + "_use_action_network": "yes", + "_use_mailchimp": "yes", + "_zetkin_organisation_id": "11", + "_zetkin_join_form_id": "22", + "_zetkin_join_form_submit_token": "legacy-zetkin-token", + "_zetkin_membership_custom_field": "legacy_tier", + "_zetkin_join_date_custom_field": "legacy_joined", + "_zetkin_client_id": "", + "_zetkin_client_secret": "", + "_zetkin_jwt": "", + "_zetkin_environment": "live", + "_use_chargebee_hosted_pages": "yes", + "_chargebee_site_name": "legacy", + "_chargebee_api_key": "legacy-chargebee-key", + "_chargebee_api_publishable_key": "legacy-chargebee-publishable-key", + "_gc_access_token": "legacy-gocardless-token", + "_gc_environment": "live", + "_stripe_publishable_key": "pk_test_legacy", + "_stripe_secret_key": "sk_test_legacy", + "_stripe_direct_debit": "yes", + "_stripe_direct_debit_only": "yes", + "_postcode_address_provider": "get_address_io", + "_ideal_postcodes_api_key": "legacy-ideal-key", + "_get_address_io_api_key": "legacy-getaddress-key", + "_mailchimp_api_key": "legacy-mailchimp-key-us1", + "_mailchimp_audience_id": "legacy-audience", + "_action_network_api_key": "legacy-action-network-key", + "_step_webhook_url": "http://fake-api/legacy-step", + "_webhook_url": "http://fake-api/legacy-join-complete", + "_auth0_domain": "legacy.eu.auth0.com", + "_auth0_client_id": "legacy-auth0-client-id", + "_auth0_client_secret": "legacy-auth0-client-secret", + "_auth0_management_audience": "https://legacy.eu.auth0.com/api/v2/", + "_sentry_dsn": "https://legacykey@sentry.e2e.example/7", + "_google_cloud_project_id": "", + "_google_cloud_key_file_contents": "", + "_loggly_token": "", + "_loggly_tags": "" +} diff --git a/packages/join-e2e/tests/settings/support.ts b/packages/join-e2e/tests/settings/support.ts index 58d46b8..3b8c7a0 100644 --- a/packages/join-e2e/tests/settings/support.ts +++ b/packages/join-e2e/tests/settings/support.ts @@ -214,6 +214,11 @@ export class JoinSettingsPage { await this.field(label).fill(value); } + /** Chooses an option of a select field by its value, e.g. 'live'. */ + async setSelect(label: string, value: string): Promise { + await this.field(label).selectOption(value); + } + /** Sets a colour field to a hex colour, e.g. '#336699', through its colour picker. */ async setColor(label: string, hex: string): Promise { const field = this.page.locator('.cf-field.cf-color').filter({ diff --git a/readme.md b/readme.md index b54288e..73b59a6 100644 --- a/readme.md +++ b/readme.md @@ -505,7 +505,7 @@ How the tests are put together: The `fake-api` container (`packages/join-e2e/fake-api/server.js`) stands in for every service the plugin calls: Stripe, Mailchimp, Action Network, Zetkin, GoCardless, Chargebee, Auth0 and postcode lookups. Inside the Docker network, their hostnames (`api.stripe.com` and so on) resolve to it. It answers over HTTPS with a test certificate that only the E2E WordPress containers trust, so the plugin's real code and SDKs run unchanged and nothing reaches the real services. -It records every request it receives. Tests read them, reset it and override its answers through its control API on `localhost:8890` (`tests/settings/fake-api.ts`). By default it answers `200 {}`, except Stripe, where it keeps the products and prices the plugin creates so searches find them, and lists and looks up customers and subscriptions. Tests can put a Stripe object in place first (`seedFakeStripe`), e.g. the customer a webhook event names. +It records every request it receives. Tests read them, reset it and override its answers through its control API on `localhost:8890` (`tests/settings/fake-api.ts`). By default it answers `200 {}`, except Stripe, where it keeps the products and prices the plugin creates so searches find them, and lists and looks up customers and subscriptions. Tests can put a Stripe object in place first (`seedFakeStripe`), e.g. the customer a webhook event names. Zetkin, Auth0 and Google's token endpoint answer just enough for the plugin to sign in and add a member. The fake is also reachable as `http://fake-api`, for tests that need a webhook URL to send to. To fake another hostname, add it to `fake-api/certs/san.cnf`, run `fake-api/certs/generate.sh`, and add it to the `fake-api` network aliases in `docker-compose.yml`. From 54ff76108c90ca600378a11f8838735291ba9120 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 18:49:54 +0000 Subject: [PATCH 2/2] Address review on the Integrations tests - Fake API: a request body that won't gunzip is recorded as it came instead of crashing the fake. The unused Google token endpoint, its network alias and certificate name are gone (the Google client signs its own token). - Zetkin: checks the Unconfirmed tag is put on the person the API created. - Action Network: checks the other plan's tags and the lapsed, cancelled and lapsing tags are removed. It also sends an empty tag when a plan has no Add Tags: a skipped test for JOIN-171. - Step Webhook Url: the step now comes from the real join form, with /step let through to the plugin rather than posted by the test. - Subscriptions download: the secret key is checked on the export's own Stripe reads, not on requests made when the settings were saved. - Join dates are checked against the dates either side of the join, so a run across midnight still passes. - The legacy fixture sets Loggly, and checks a join's logs reach it with the legacy tags. - continueToPlans/continueToPayment live in fixtures.ts, shared by the specs that had their own copies. - No Carbon Fields class in the logging spec. - Readme lists the logging services the fake stands in for. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BL2bFrTk84JMy3CPAsCTV9 --- packages/join-e2e/docker-compose.yml | 1 - packages/join-e2e/fake-api/certs/ca.crt | 32 +++---- packages/join-e2e/fake-api/certs/san.cnf | 3 +- packages/join-e2e/fake-api/certs/server.crt | 38 ++++---- packages/join-e2e/fake-api/certs/server.key | 52 +++++------ packages/join-e2e/fake-api/server.js | 14 +-- packages/join-e2e/tests/settings/copy.spec.ts | 4 +- .../settings/features-integrations.spec.ts | 10 +-- .../join-e2e/tests/settings/features.spec.ts | 4 +- packages/join-e2e/tests/settings/fixtures.ts | 16 +++- .../tests/settings/integrations-crm.spec.ts | 86 +++++++++++++++---- .../settings/integrations-logging.spec.ts | 2 +- .../settings/integrations-payments.spec.ts | 17 ++-- .../tests/settings/legacy/integrations.json | 4 +- .../tests/settings/membership-plans.spec.ts | 4 +- readme.md | 4 +- 16 files changed, 172 insertions(+), 119 deletions(-) diff --git a/packages/join-e2e/docker-compose.yml b/packages/join-e2e/docker-compose.yml index 9e1f84f..204b642 100644 --- a/packages/join-e2e/docker-compose.yml +++ b/packages/join-e2e/docker-compose.yml @@ -59,7 +59,6 @@ services: - api.ideal-postcodes.co.uk - logs-01.loggly.com - sentry.e2e.example - - oauth2.googleapis.com - logging.googleapis.com db: diff --git a/packages/join-e2e/fake-api/certs/ca.crt b/packages/join-e2e/fake-api/certs/ca.crt index fa5df79..0b1bb91 100644 --- a/packages/join-e2e/fake-api/certs/ca.crt +++ b/packages/join-e2e/fake-api/certs/ca.crt @@ -1,20 +1,20 @@ -----BEGIN CERTIFICATE----- -MIIDPzCCAiegAwIBAgIUE2kQO6o+jEf63sxZQRd/4GeIz7AwDQYJKoZIhvcNAQEL +MIIDPzCCAiegAwIBAgIUPHRrwi26P4sc3ZGz0uZqZnIYAQcwDQYJKoZIhvcNAQEL BQAwLjEsMCoGA1UEAwwjSm9pbiBFMkUgdGVzdCBDQSAobm90IGZvciByZWFsIHVz -ZSkwIBcNMjYxMDI0MTAwNzQ3WhgPMjEyNjA5MzAxMDA3NDdaMC4xLDAqBgNVBAMM +ZSkwIBcNMjYxMDI0MTYyODE2WhgPMjEyNjA5MzAxNjI4MTZaMC4xLDAqBgNVBAMM I0pvaW4gRTJFIHRlc3QgQ0EgKG5vdCBmb3IgcmVhbCB1c2UpMIIBIjANBgkqhkiG -9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqJGPIHLHjMSFGgQwlamIs5cEoDCEcnZmGFbE -hYPvmi+t5fY+we5GAoUsv3lyiKbtTP8HWhMJsn+xewjJ9CeEdAvDHyT7+6YMxkMh -fcxZvv71TfQECBKXKv855n+nkXfNVH/aboKr/ShxxTINW92xZMfs27scFqB/ol2d -MTkPlrcxOhQN9WnWeC2+nlg+fE9JDN3c3pNmxszmHRs4nISfpAQFLziR5lMkCFHI -TnUp5UPqD85XICxMqeZMGHYs355GGzJldwSSfT4o6XKvzPcGyHqBF46PT0ZcFBxQ -Jw2qGfLcfaUddfziq8sLrhzYJqL3AiC0p/EQKya/CXQCtWxDmwIDAQABo1MwUTAd -BgNVHQ4EFgQU5pduVZcifwRpZ3y6XZ/QsVOZkNkwHwYDVR0jBBgwFoAU5pduVZci -fwRpZ3y6XZ/QsVOZkNkwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC -AQEAIrOmKZT+RZDe8zgehE4VScO9QPnuluA/I8DDChuZ/voS4krhud+J0HK1W1Yd -Ru913ngJQQjOF/W1elrZjgITKOh+Or4+Y8yldpUh22hyUyjD42k85ckVgECQaUxK -qZnt5UIvG2KDKL4M1TNK6+BZTL8H2zuJBDSCp0343apWEYXhWPytrB6sgxqvtX+c -GoNDhL+gw6WjTf+lE3FA+nM+7bEbYiqzOLFzwlSN3CJtxb/UZP8rMt2dSETs/Ho/ -AieD/jt5xb/tUnJIJL1aFRWoaPUdN5NCl2+dT+tWdKq2fMdx2Pgn0eMkdPi6Qoue -vVf5Y2keiiVtkUx6pJvG1L+I9A== +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqPWqiTOYaSUhsqffO7PkmfjCRl6+iluKXe/Y +oPG3ZhGVSNzBaoKaYOgzEm9LGH9G7XheqPaYBTpfsTq653uBXG9xN9XP+vJYi1tH +bDKvgOd8W5sus0XaBq/pNz60kWgc76RkMXRJA31isTEF5JWFZqlpPIBQyMfmuXFw +9Gw456A0t5HfnjoSOufrtWMMxyN+KBh6g0aVieS50ZqAJATrhK6/ugM1DlzTY3iI +IGTFi2wbafnxvBeCNn0tsTyi6sKXgfNh8zhBebHAjByzEfbWuTly1kdccBso+4pK +y37H8lpyu1pWZLnkga6WPL3lbFAnsOMQX1e/ltYpoETKUpRQIQIDAQABo1MwUTAd +BgNVHQ4EFgQUZ/8fKXjIKwXYoj7HJRLG9nAeF84wHwYDVR0jBBgwFoAUZ/8fKXjI +KwXYoj7HJRLG9nAeF84wDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC +AQEAIOREepSRwdWrmZnjtwfrEeMBFBjo4AVhEbH5kYeOXyE6Bm7KgXTYJpcbA/cJ +VMmigfzDXPRMnPF6yuCgb3a5bndLmXSYJ8koAFg3lwUQPMoacTS83iyPtECr3H6l +hyggkh6XIR53kQE8jdtqJOa+HZFByQYiwhShMGoRP13wYILHrl0VAyZgUpvwy564 +mBawUjbYJ6mkAwvoPb3YtOapSk9HpSY7q4AvvNa0rVi0kA3HDo/9/mXbZUq8zV8l ++9S1LW2/bWHFL1HYuEdoHhV5znoGwLPdx0PLsBplJC7E6IYyYQXyNVQQ5pW2aPnF +RmV3DFCaaj2w/5D9ef2c8HogTA== -----END CERTIFICATE----- diff --git a/packages/join-e2e/fake-api/certs/san.cnf b/packages/join-e2e/fake-api/certs/san.cnf index 7cc600a..47bc2aa 100644 --- a/packages/join-e2e/fake-api/certs/san.cnf +++ b/packages/join-e2e/fake-api/certs/san.cnf @@ -22,5 +22,4 @@ DNS.12 = api.ideal-postcodes.co.uk DNS.13 = fake-api DNS.14 = logs-01.loggly.com DNS.15 = sentry.e2e.example -DNS.16 = oauth2.googleapis.com -DNS.17 = logging.googleapis.com +DNS.16 = logging.googleapis.com diff --git a/packages/join-e2e/fake-api/certs/server.crt b/packages/join-e2e/fake-api/certs/server.crt index bcc2314..4599677 100644 --- a/packages/join-e2e/fake-api/certs/server.crt +++ b/packages/join-e2e/fake-api/certs/server.crt @@ -1,27 +1,27 @@ -----BEGIN CERTIFICATE----- -MIIEpjCCA46gAwIBAgIUFe/1wFOXwDaT0olFZdmpjq4PTlgwDQYJKoZIhvcNAQEL +MIIEjzCCA3egAwIBAgIUAJw+ei4n8CYxLloWgV0WE8fGUH0wDQYJKoZIhvcNAQEL BQAwLjEsMCoGA1UEAwwjSm9pbiBFMkUgdGVzdCBDQSAobm90IGZvciByZWFsIHVz -ZSkwIBcNMjYxMDI0MTAwNzQ4WhgPMjEyNjA5MzAxMDA3NDhaMBMxETAPBgNVBAMM -CGZha2UtYXBpMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvVuIMrn2 -3svfRxhYpJPgEoNLiUZovKpOsjxHsqUpXc4BwExNhzvunG1xh1+CNh4EIz07cWzf -8RXDIgZBO/aWetzDStSd3sKGBHnmGywJv8CcYeaLgqDeIoyy7dmQQWOKnNO6Ybjb -mvRQUFKUmvOUrg+OucEaQZ9JvcWDyXASpd+W54/Xg/nlEp3ICPd8a6nHQF8RXdGx -kWbld7BqourCy6PaODgB5a2EsOzSHM22n+VSSelvj/XTDD9upTvWINryzRga+1Zn -MbMfGsbAp6aTVq3TlQF60LvaHYJq5cYDDV37rdT9fgvrIV/nyVnZWzeTSjtTPRRL -4KdZ5yp+ojnLGQIDAQABo4IB0zCCAc8wggFeBgNVHREEggFVMIIBUYIOYXBpLnN0 +ZSkwIBcNMjYxMDI0MTYyODE2WhgPMjEyNjA5MzAxNjI4MTZaMBMxETAPBgNVBAMM +CGZha2UtYXBpMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA08Bmq+l4 +O2WBv8ff6OoowzVJynaoeCdh+jBk7vQYqZ+/Qh+lQVwZLTMc0xPjYm/kss16DfyQ +uOBnkEmDyIjLhhxudq5kHTR7b+Fp6tVoM9UEBjtDZlnlL8Q+kyvAcVMZiO9ACOut +i5Ox9AhgnSCPG0LlxuNvMKNLF8RzYab46oS1lhSfsb54C5MV7MOB8gfut5c21EhW +hLYi8+ypzDyULnltOJBAQESyr3SsQCvGa6fkSnFlq+A/kaeGK4ioCiMkcb3DPkYn +h3v8ss+pllLFKcSuWkw6rve2GTbl0Qhn5MlbpXst3ChgzNrqnz1QfFEOAWF0yRjn +i2sjhwVYwJ1GzQIDAQABo4IBvDCCAbgwggFHBgNVHREEggE+MIIBOoIOYXBpLnN0 cmlwZS5jb22CEWFjdGlvbm5ldHdvcmsub3JnggthcGkuemV0ay5pboISYXBpLmRl di56ZXRraW4ub3JnghV1czEuYXBpLm1haWxjaGltcC5jb22CEGUyZS5ldS5hdXRo MC5jb22CEmFwaS5nb2NhcmRsZXNzLmNvbYIaYXBpLXNhbmRib3guZ29jYXJkbGVz cy5jb22CEWUyZS5jaGFyZ2ViZWUuY29tghBhcGkucG9zdGNvZGVzLmlvghFhcGku Z2V0YWRkcmVzcy5pb4IZYXBpLmlkZWFsLXBvc3Rjb2Rlcy5jby51a4IIZmFrZS1h -cGmCEmxvZ3MtMDEubG9nZ2x5LmNvbYISc2VudHJ5LmUyZS5leGFtcGxlghVvYXV0 -aDIuZ29vZ2xlYXBpcy5jb22CFmxvZ2dpbmcuZ29vZ2xlYXBpcy5jb20wCQYDVR0T -BAIwADALBgNVHQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwHQYDVR0OBBYE -FPsScoZQEYMxULm5IZtm20XKuu8nMB8GA1UdIwQYMBaAFOaXblWXIn8EaWd8ul2f -0LFTmZDZMA0GCSqGSIb3DQEBCwUAA4IBAQAknbs81Qs5Oce/CZdtaD8k8AWna12K -+L9kElkJGc/NReG/eYdvfMSypFzGbdkbCB4y1s446Eet/g8BFlJDfojWYgx1XYnH -nkA476DR6uLpkk7UmdqTWXiXb5Fm39pPzGqOasROyAhyu0Ux8Q/79WwMpOaXAXXe -1EeSwHfE3Ch2fxuZfNI0vGcUKNobRNAGNSZTO1qNJCtwbtyXKZa46ysIabi2+4qM -LvsjRJAb2b7kkEhVmKvbUp02oogkAsKsZOk/J3z8ICfSYAcPB5r1XNTKoYeT7TVM -ZPvbzra2tkWZ32r/QaLKBSRJcjYguhbDheIOXmbIP7sr02qtJxr/1WKa +cGmCEmxvZ3MtMDEubG9nZ2x5LmNvbYISc2VudHJ5LmUyZS5leGFtcGxlghZsb2dn +aW5nLmdvb2dsZWFwaXMuY29tMAkGA1UdEwQCMAAwCwYDVR0PBAQDAgWgMBMGA1Ud +JQQMMAoGCCsGAQUFBwMBMB0GA1UdDgQWBBTcS2OCMyXcbcGJgNlCf1iF60xYXDAf +BgNVHSMEGDAWgBRn/x8peMgrBdiiPsclEsb2cB4XzjANBgkqhkiG9w0BAQsFAAOC +AQEAo8x09RNAfZjFdFfTOtPzjA72bybjc9YNQcsqYX3C8qtRRnf8KhDxSekGiPKF +1R/9Dj9qkj7Gk7nPHZm0bqbWlAtrPqrdSwnILhegWj7qgxg5lWotxNGbcs8dHHtC +VoG13Pasaz+ALgak9dIsTOA8D2zvjAR6iy+yH0Ro0eI81P4deDqTE/KzPCS6gYYb +S4c66mTeZwsyOY5Nmgzms3vzvcaQeASdLTOQiKkj6T7Sl646n6AZsZIJVmDlt4Ta +7l+x+PsEejY+xuE4XyNP0V0IsWF5Ewlohh3/vbb1kgwLh6n8befRGrE5GD3r1W/8 +p89Oh7rsu1AiOAVlsQ+if7W3yQ== -----END CERTIFICATE----- diff --git a/packages/join-e2e/fake-api/certs/server.key b/packages/join-e2e/fake-api/certs/server.key index 4b20d17..58f252a 100644 --- a/packages/join-e2e/fake-api/certs/server.key +++ b/packages/join-e2e/fake-api/certs/server.key @@ -1,28 +1,28 @@ -----BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC9W4gyufbey99H -GFikk+ASg0uJRmi8qk6yPEeypSldzgHATE2HO+6cbXGHX4I2HgQjPTtxbN/xFcMi -BkE79pZ63MNK1J3ewoYEeeYbLAm/wJxh5ouCoN4ijLLt2ZBBY4qc07phuNua9FBQ -UpSa85SuD465wRpBn0m9xYPJcBKl35bnj9eD+eUSncgI93xrqcdAXxFd0bGRZuV3 -sGqi6sLLo9o4OAHlrYSw7NIczbaf5VJJ6W+P9dMMP26lO9Yg2vLNGBr7Vmcxsx8a -xsCnppNWrdOVAXrQu9odgmrlxgMNXfut1P1+C+shX+fJWdlbN5NKO1M9FEvgp1nn -Kn6iOcsZAgMBAAECggEAI/sRjToC6+DnY1X/+f3TVRE3uDT1z+u4mmuT4CE8FAQC -Fy0oiircM0MkkGmYj3nypVOMSQg8IU5WTs/Lc35up5ftT/nIqbxixNT44W7jx1jt -Hjk1hzPUIdKEaWeu3o3wl84vm0QKDo6f+DNI44ynUs/H+gn0WEXROEsguJUgJf5b -AV71vJmhNCWuXbztbeZb8Q7l93z82jM84n/K6yUD2++peKADQiaQHMZ2sMnoiRlu -T2jJrRyiPOKpv1h2hMJgy54SHVFPVWB/SoMm+BXWbHuSUWHetumH/YesTTFFfsxU -61DY9yfbC7zpVPsgPpY9JjTzMTmDBCL4eGBwaD5bdQKBgQD7Twz7Ysa08QKzAiAJ -MRuqP6T6GU7/7yyUOSo+vasHvMjudNU/SfqWqRgrkL/cs6T097TRWGg/vOKPZfTk -8T1PKXCDEJI90miB6nUmsR2YUZqOdoSgiBiw+5GsFYYcwk/K0v1MlDarnaUMYptN -r45+oW+T7FMmxWnkH4lVgcQEMwKBgQDA5G4TAZNG30pSkRAU/2VE6qGQxhrKUbP5 -Q7DMeEat0fQoLNr2Cgibceo/KMVG/+zp/3sgt37JnJk8Xx20/Vr145x09i/4oyeS -Fm2R65T/AFjhj6GWKU2P5bGjT4IPM2XFTmui2ibGMZq5e+iu8+jQUVfWS391nbJr -KkPqzbjHgwKBgCKOuEZJSvHOwoRDIoyhCCDdJ56MpGRlS0gGpFyEUGR7/VVtE3Xk -gmT9U6mJ+hgrS9+zwFWZRpydKLp3Vrm0wq2EEUi0mqeNq3lR+FIdwOgVMJ+uhIZe -sKaFsX5sZ0jFH5vQb6x8JRhsUSnm1VxVa87V/DXnJXgLZYhNHeF4OrApAoGAYYif -p/iZ8bIiMJGtIhDpsek5/KxPC5I4gIebtCf1BRsua8oShWoZZBA7s+f3qKjrsH5A -UhC2iVcnnIEz0jpxbffDOtjo9L362kGA8dG/KxmP1636tKT51RL9uWgQiYMhqJy4 -jEXmaTzX0YY3547pX7xhv7e5IU21074xTV7sWeUCgYEA8b5f7K1yeUi4ZHJsTEwG -BFeP9ZQWjeQnMI39b51XJjADfIHge5pozYM+t5kyFrOyGj4QsGn1vCpvE0dI+LMn -Kb6pjXOgoT3Qo9K6s//RMWqd5rc14FxVmDW8ImANhNN00z7FcwMlVi4tM3FRDenZ -h2yWGDE7K65JKguaFtnyMOQ= +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDTwGar6Xg7ZYG/ +x9/o6ijDNUnKdqh4J2H6MGTu9Bipn79CH6VBXBktMxzTE+Nib+SyzXoN/JC44GeQ +SYPIiMuGHG52rmQdNHtv4Wnq1Wgz1QQGO0NmWeUvxD6TK8BxUxmI70AI662Lk7H0 +CGCdII8bQuXG428wo0sXxHNhpvjqhLWWFJ+xvngLkxXsw4HyB+63lzbUSFaEtiLz +7KnMPJQueW04kEBARLKvdKxAK8Zrp+RKcWWr4D+Rp4YriKgKIyRxvcM+RieHe/yy +z6mWUsUpxK5aTDqu97YZNuXRCGfkyVuley3cKGDM2uqfPVB8UQ4BYXTJGOeLayOH +BVjAnUbNAgMBAAECggEAHy+S73avEnBcr5YMUA1Me5N5jqvzG98Q+vvtoZOpnjG8 +xUxL1lhuDM4eTKf9MR4xnHPOqgisd8308rchBYPAFbxSOIBuli6Ij22vX7qWYivZ +L+HCh2xt6J8KaDplWUtX02cJE0dCg+/4oWwqbnnBGc68cJJGae9iu9le9N7WghNM +0ho819L/zQdWBjoHyAhiBARKBXCmRSTl7RFa3+6HoAL4TO/s0Eya8xOs27eYuRto +6h6uQklDSEDFHkQkK5NrVsLNsMtidITO7wxfUhUuuhbz7HVf/JkqsakreyaYTH8H +feJb1CVkzn9YsxU0hi1TxTj7nuqUJB2+uMC8AlAwMQKBgQD0T/YAzX/ElSrbGtQV +zD1rmB1Mpt8DlkVc3341cSiFVxUlV0LGdjYwgWhiahoJt+iMJUHvMLcTZgXp+zzW +OVCbGTv2cEpZ2Jf3iOiepudsGFGLOvR0hZ8ACrbmB3HqVZ2MCP+ZeX8YXcy9JoEL +UG9m7QoAh47TMKiYFgcIMozwJwKBgQDd4azrAs25FayUAIIYLJObucdp+O5e+nNz +YcqmZaZ6CHGItGt0Der81KYfoLFk1txA+gRF+K4rmYqNQVf0nxN+XET/UKkSU8b3 +KdCYL0X7aIG5eGz7DJCj8i1Svi5Snbk7YGK1a285sJ29zGQ5aV2fX4p2RkFTTTx5 +qGNLRo116wKBgBSU2+OLbFUSP+7/XpiLgS0cPnOjpyv1L9i4FzjHVKOPB4ACzuRL +Nxt0+2c3CwSAkr2jsd9FmlavBJaGjMR7RZgbUxrasNjKz1qSFQ6wftRzZAM3rzpV +u2RUgr30KJuWJNSUOXwjaRqTZFkW8k4Ss5/XHj2XLegKtFlDmcoOACOHAoGBAJ5W +Xb+X0Jc0EGPNuQjnRq7qdJP7QKAmuhysvKDiCVssi1iy57t58ydZ2PLZCb6cVbFI +uaP1QtV6TJVg/BNlMdQKpHJObVjW0DWxcVoo4hf3DBv/BEZjQRS9zfouHYB3bagh +sE8my8ivcOHw6dTOjDEfTP/6QsPfajinIbSmNGzhAoGABpB2U3DW2E95HXIPbRtV +CwwSqn4iLbCinJjK4LjNo6m3XsTS1bdKkzPtymin+HCvatBCWpIxj1KGRB2GcQ+l +nQr8fNPLRnj+8aNN43a5+wqp67rqc7JItwxTUWtYztAetKTACU4ytFiCSAATzR+S ++o6ztFhJ26HlF4fNRi6S5v0= -----END PRIVATE KEY----- diff --git a/packages/join-e2e/fake-api/server.js b/packages/join-e2e/fake-api/server.js index 88adbee..a95ef53 100644 --- a/packages/join-e2e/fake-api/server.js +++ b/packages/join-e2e/fake-api/server.js @@ -174,9 +174,6 @@ function respond(host, method, url, body) { const result = fakeZetkin(method, url, body); if (result) return result; } - if (host === 'oauth2.googleapis.com' && method === 'POST' && url.pathname === '/token') { - return { status: 200, body: { access_token: 'e2e-google-access-token', token_type: 'Bearer', expires_in: 3600 } }; - } if (host.endsWith('.auth0.com') && method === 'POST' && url.pathname === '/oauth/token') { return { status: 200, body: { access_token: 'e2e-auth0-access-token', token_type: 'Bearer', expires_in: 86400 } }; } @@ -190,8 +187,15 @@ function handleService(request, response) { const host = (request.headers.host || '').split(':')[0]; const url = new URL(request.url, `https://${host}`); let buffer = Buffer.concat(chunks); - // Sentry, for one, gzips what it sends. - if (request.headers['content-encoding'] === 'gzip') buffer = zlib.gunzipSync(buffer); + // Sentry, for one, gzips what it sends. A body that won't unzip is + // recorded as it came, rather than crashing the fake. + if (request.headers['content-encoding'] === 'gzip') { + try { + buffer = zlib.gunzipSync(buffer); + } catch (error) { + console.error(`Could not gunzip a request to ${host}${request.url}: ${error.message}`); + } + } const raw = buffer.toString('utf8'); const body = parseBody(raw, request.headers['content-type']); requests.push({ diff --git a/packages/join-e2e/tests/settings/copy.spec.ts b/packages/join-e2e/tests/settings/copy.spec.ts index 6da99a7..ecad708 100644 --- a/packages/join-e2e/tests/settings/copy.spec.ts +++ b/packages/join-e2e/tests/settings/copy.spec.ts @@ -6,6 +6,7 @@ import { JoinSettingsPage, MULTI_CURRENCY_PAGE, STANDARD_JOIN_PAGE, + continueToPlans, expect, getSetting, getSettings, @@ -27,8 +28,7 @@ const details = (page: Page) => page.locator('form.form-content').first(); /** From the details step to the plan step. */ async function goToPlans(page: Page, url = STANDARD_JOIN_PAGE) { await openJoinForm(page, url); - await page.locator(CONTINUE).click(); - await page.waitForSelector('[role="radiogroup"]'); + await continueToPlans(page); } /** diff --git a/packages/join-e2e/tests/settings/features-integrations.spec.ts b/packages/join-e2e/tests/settings/features-integrations.spec.ts index 31e2123..519e532 100644 --- a/packages/join-e2e/tests/settings/features-integrations.spec.ts +++ b/packages/join-e2e/tests/settings/features-integrations.spec.ts @@ -1,7 +1,7 @@ import { Page } from '@playwright/test'; -import { CONTINUE } from '../helpers'; import { STANDARD_JOIN_PAGE, + continueToPayment, expect, expectDetailsAccepted, expectDetailsRejected, @@ -156,14 +156,6 @@ test.describe('Create Auth0 Account', () => { }); test.describe('Payment providers', () => { - /** From the details step, through the plan step, to whatever comes next. */ - async function continueToPayment(page: Page) { - await page.locator(CONTINUE).click(); - await page.waitForSelector('[role="radiogroup"]'); - await page.locator('label.radio-panel').first().click(); - await page.locator(CONTINUE).click(); - } - const logo = (page: Page, provider: string) => page.locator(`.progress-steps img[alt="${provider}"]`); test('none is on by default: no provider logos', async ({ page }) => { diff --git a/packages/join-e2e/tests/settings/features.spec.ts b/packages/join-e2e/tests/settings/features.spec.ts index 9d30423..66d760a 100644 --- a/packages/join-e2e/tests/settings/features.spec.ts +++ b/packages/join-e2e/tests/settings/features.spec.ts @@ -1,6 +1,7 @@ import { CONTINUE } from '../helpers'; import { FREE_JOIN_PAGE, + continueToPlans, expect, expectDetailsAccepted, expectDetailsRejected, @@ -266,8 +267,7 @@ test.describe('Enable international phone numbers', () => { test.describe('Hide price display for zero-price items', () => { async function goToPlans(page: import('@playwright/test').Page) { await openJoinForm(page, FREE_JOIN_PAGE); - await page.locator(CONTINUE).click(); - await page.waitForSelector('[role="radiogroup"]'); + await continueToPlans(page); } test('is off by default: a free plan shows its £0 price', async ({ page }) => { diff --git a/packages/join-e2e/tests/settings/fixtures.ts b/packages/join-e2e/tests/settings/fixtures.ts index ed70412..912435f 100644 --- a/packages/join-e2e/tests/settings/fixtures.ts +++ b/packages/join-e2e/tests/settings/fixtures.ts @@ -148,6 +148,19 @@ export async function stubStripe(page: Page): Promise { ); } +/** From the details step, on to the plan step. */ +export async function continueToPlans(page: Page): Promise { + await page.locator(CONTINUE).click(); + await page.waitForSelector('[role="radiogroup"]'); +} + +/** From the details step, through the plan step (first plan), to whatever comes next. */ +export async function continueToPayment(page: Page): Promise { + await continueToPlans(page); + await page.locator('label.radio-panel').first().click(); + await page.locator(CONTINUE).click(); +} + /** * Clicks Continue and checks the form rejected the details: it marks a field * invalid and stays on the details step. @@ -181,8 +194,7 @@ export async function joinThroughForm( if (fillDetails) { await fillDetails(page); } - await page.locator(CONTINUE).click(); - await page.waitForSelector('[role="radiogroup"]'); + await continueToPlans(page); const join = await captureJoinBodyViaStripeRedirect(page, url); const response = await postJoin(page.request, join); expect(response.ok(), `the plugin refused the join: ${await response.text()}`).toBe(true); diff --git a/packages/join-e2e/tests/settings/integrations-crm.spec.ts b/packages/join-e2e/tests/settings/integrations-crm.spec.ts index cb0cf1d..881e7cf 100644 --- a/packages/join-e2e/tests/settings/integrations-crm.spec.ts +++ b/packages/join-e2e/tests/settings/integrations-crm.spec.ts @@ -6,6 +6,8 @@ import { joinFormEnv, joinThroughForm, loadLegacySettings, + openJoinForm, + continueToPlans, test, wpEvalJson, } from './fixtures'; @@ -25,6 +27,13 @@ const MEMBER = { email: 'someone@example.com', firstName: 'Test', lastName: 'Per /** Today's date as the plugin writes it in the container, e.g. 2026-10-24. */ const today = () => wpEvalJson('return date("Y-m-d");'); +/** Joins through the form, and returns the dates it could have been joined on (it may span midnight). */ +async function joinAndNoteDates(page: Parameters[0]): Promise { + const before = today(); + await joinThroughForm(page); + return [before, today()]; +} + test.describe('Zetkin', () => { test('a join is sent to the Zetkin join form, with the organisation, form and token set here', async ({ settings, @@ -55,7 +64,7 @@ test.describe('Zetkin', () => { zetkin_environment: 'sandbox', }); - await joinThroughForm(page); + const joinDates = await joinAndNoteDates(page); // Without OAuth credentials, the plugin submits Zetkin's join form. The // sandbox environment (the default) is Zetkin's development server. @@ -70,10 +79,10 @@ test.describe('Zetkin', () => { first_name: MEMBER.firstName, last_name: MEMBER.lastName, membership_tier: 'Standard', - joined_on: today(), }, }, }); + expect(joinDates).toContain(submission.body.form_data.joined_on); expect(await fakeApiRequests('api.zetk.in')).toEqual([]); }); @@ -82,11 +91,12 @@ test.describe('Zetkin', () => { await settings.enableIntegration('Use Zetkin'); await settings.save(); - await joinThroughForm(page); + const joinDates = await joinAndNoteDates(page); const [submission] = await fakeApiRequests('api.dev.zetkin.org'); - expect(Object.values(submission.body.form_data)).not.toContain('Standard'); - expect(Object.values(submission.body.form_data)).not.toContain(today()); + const sent = Object.values(submission.body.form_data); + expect(sent).not.toContain('Standard'); + for (const date of joinDates) expect(sent).not.toContain(date); }); test('Zetkin Environment "Live" sends joins to Zetkin\'s live server', async ({ settings, page }) => { @@ -135,12 +145,17 @@ test.describe('Zetkin', () => { expect(person?.headers.authorization).toBe('Bearer e2e-zetkin-access-token'); expect(person?.body).toMatchObject({ email: MEMBER.email, first_name: MEMBER.firstName }); - // Every new member is tagged Unconfirmed; the join form was not used. + // Every new member is tagged Unconfirmed: the tag is created (the fake + // numbers tags 1, 2, ... in the order they're created), then put on the + // person the fake created (4242). The join form was not used. const createdTags = requests .filter((request) => request.method === 'POST' && request.path === '/v1/orgs/1/people/tags') .map((request) => request.body.title); - expect(createdTags).toContain('Unconfirmed'); - expect(requests.filter((request) => request.method === 'PUT').length).toBeGreaterThan(0); + const unconfirmedId = createdTags.indexOf('Unconfirmed') + 1; + expect(unconfirmedId).toBeGreaterThan(0); + expect(requests.filter((request) => request.method === 'PUT').map((request) => request.path)).toContain( + `/v1/orgs/1/people/4242/tags/${unconfirmedId}`, + ); expect(requests.some((request) => request.path.includes('/join_forms/'))).toBe(false); }); }); @@ -174,7 +189,11 @@ test.describe('Mailchimp', () => { }); test.describe('Action Network', () => { - test('a join adds the member to Action Network, with the API key set here', async ({ settings, page }) => { + test('a join adds the member to Action Network, with the API key set here, and updates their tags', async ({ + settings, + page, + }) => { + // Another plan's tags, which a member on the form's own plan shouldn't have. await settings.open('Membership Plans'); await settings.editPlan(0, { addTags: 'Member' }); await settings.tab('Features'); @@ -195,8 +214,25 @@ test.describe('Action Network', () => { given_name: MEMBER.firstName, family_name: MEMBER.lastName, }, + // The other plan's tags, and the lapsed, cancelled and lapsing tags, come off. + remove_tags: expect.arrayContaining(['Member', 'Lapsed - failed payment', 'Cancelled', 'Lapsing']), }); }); + + // The plugin sends [""] instead of no tags (Linear JOIN-171). Re-enable once it's fixed. + test.fixme('a join on a plan without Add Tags sends Action Network no empty tag', async ({ settings, page }) => { + await settings.open('Features'); + await settings.enableIntegration('Use Action Network'); + await settings.save(); + + // The form's own plan has no Add Tags. + await joinThroughForm(page); + + const signup = (await fakeApiRequests('actionnetwork.org')).find( + (request) => request.method === 'POST' && request.path === '/api/v2/people/', + ); + expect(signup?.body.add_tags).toEqual([]); + }); }); test.describe('Webhooks', () => { @@ -246,15 +282,22 @@ test.describe('Webhooks', () => { expect(getSettings(['step_webhook_url']).step_webhook_url).toBe(STEP_WEBHOOK); - // The form reports each step to the plugin's /step endpoint. - const response = await page.request.post('/wp-json/join/v1/step', { - data: { ...MEMBER, sessionToken: 'e2e-session', stage: 'details', ddAccountNumber: '55779911' }, - }); - expect(response.ok()).toBe(true); + // The form reports each step to the plugin's /step endpoint: let that + // request through to the plugin, rather than the usual stub. + await openJoinForm(page); + await page.route('**/wp-json/join/v1/step', (route) => route.continue()); + await continueToPlans(page); + await expect.poll(async () => (await fakeApiRequests('fake-api')).length).toBeGreaterThan(0); const [delivery] = await fakeApiRequests('fake-api'); - expect(delivery).toMatchObject({ method: 'POST', path: '/step', body: { email: MEMBER.email, stage: 'details' } }); + expect(delivery).toMatchObject({ + method: 'POST', + path: '/step', + body: { email: MEMBER.email, firstName: MEMBER.firstName, stage: 'enter-details' }, + }); + // The form's test data includes bank details; they're never passed on. expect(delivery.body).not.toHaveProperty('ddAccountNumber'); + expect(delivery.body).not.toHaveProperty('ddSortCode'); }); }); @@ -331,6 +374,8 @@ test.describe('Integrations saved before the settings page was rebuilt', () => { 'auth0_client_secret', 'auth0_management_audience', 'sentry_dsn', + 'loggly_token', + 'loggly_tags', ]), ).toEqual({ zetkin_organisation_id: '11', @@ -362,6 +407,8 @@ test.describe('Integrations saved before the settings page was rebuilt', () => { auth0_client_secret: 'legacy-auth0-client-secret', auth0_management_audience: 'https://legacy.eu.auth0.com/api/v2/', sentry_dsn: 'https://legacykey@sentry.e2e.example/7', + loggly_token: 'legacy-loggly-token', + loggly_tags: 'legacy-site, join', }); }); @@ -372,13 +419,14 @@ test.describe('Integrations saved before the settings page was rebuilt', () => { USE_POSTCODE_LOOKUP: true, }); - await joinThroughForm(page); + const joinDates = await joinAndNoteDates(page); const [zetkin] = await fakeApiRequests('api.zetk.in'); expect(zetkin).toMatchObject({ path: '/v1/orgs/11/join_forms/22/submissions', - body: { submit_token: 'legacy-zetkin-token', form_data: { legacy_tier: 'Standard', legacy_joined: today() } }, + body: { submit_token: 'legacy-zetkin-token', form_data: { legacy_tier: 'Standard' } }, }); + expect(joinDates).toContain(zetkin.body.form_data.legacy_joined); expect( (await fakeApiRequests('us1.api.mailchimp.com')).some( (request) => request.path === '/3.0/lists/legacy-audience/members', @@ -387,5 +435,9 @@ test.describe('Integrations saved before the settings page was rebuilt', () => { const [actionNetwork] = await fakeApiRequests('actionnetwork.org'); expect(actionNetwork.headers['osdi-api-token']).toBe('legacy-action-network-key'); expect((await fakeApiRequests('fake-api')).map((request) => request.path)).toContain('/legacy-join-complete'); + // Logging reads its settings on its own path, early in each request. + const [loggly] = await fakeApiRequests('logs-01.loggly.com'); + expect(loggly).toMatchObject({ path: '/bulk/legacy-loggly-token/' }); + expect(loggly.headers['x-loggly-tag']).toBe('legacy-site,join'); }); }); diff --git a/packages/join-e2e/tests/settings/integrations-logging.spec.ts b/packages/join-e2e/tests/settings/integrations-logging.spec.ts index 9f0e1b3..8c61761 100644 --- a/packages/join-e2e/tests/settings/integrations-logging.spec.ts +++ b/packages/join-e2e/tests/settings/integrations-logging.spec.ts @@ -91,7 +91,7 @@ test.describe('Loggly', () => { await settings.tab('Logging'); await expect(settings.page.getByText('Logs are sent to Loggly and are not stored on this server.')).toBeVisible(); - await expect(settings.page.locator('.cf-container__fields strong', { hasText: 'e2e-site' })).toBeVisible(); + await expect(settings.page.getByText('e2e-site', { exact: true })).toBeVisible(); }); }); diff --git a/packages/join-e2e/tests/settings/integrations-payments.spec.ts b/packages/join-e2e/tests/settings/integrations-payments.spec.ts index c9e9beb..1d3fd1a 100644 --- a/packages/join-e2e/tests/settings/integrations-payments.spec.ts +++ b/packages/join-e2e/tests/settings/integrations-payments.spec.ts @@ -1,8 +1,8 @@ import { Page } from '@playwright/test'; import { readFileSync } from 'fs'; -import { CONTINUE } from '../helpers'; import { STANDARD_JOIN_PAGE, + continueToPayment, expect, fakeApiRequests, getSettings, @@ -25,14 +25,6 @@ import { * environment the plugin uses when it calls each provider (fake API). */ -/** From the details step, through the plan step, to whatever comes next. */ -async function continueToPayment(page: Page): Promise { - await page.locator(CONTINUE).click(); - await page.waitForSelector('[role="radiogroup"]'); - await page.locator('label.radio-panel').first().click(); - await page.locator(CONTINUE).click(); -} - /** The payment methods the form offers through Stripe's payment fields. */ async function stripePaymentMethods(page: Page): Promise { await stubStripe(page); @@ -149,8 +141,11 @@ test.describe('Stripe', () => { expect(csv[1]).toContain('"price_export","Membership: Standard - GBP 5.00","2026-01-02 00:00:00","2026-01-02 00:00:00"'); // The plugin reads Stripe with the secret key set on this tab. - const [first] = await fakeApiRequests('api.stripe.com'); - expect(first.headers.authorization).toBe('Bearer sk_test_e2e'); + const exportReads = (await fakeApiRequests('api.stripe.com')).filter( + (request) => request.method === 'GET' && ['/v1/subscriptions', '/v1/invoices'].includes(request.path), + ); + expect(exportReads.length).toBeGreaterThan(0); + for (const read of exportReads) expect(read.headers.authorization).toBe('Bearer sk_test_e2e'); }); // An empty Stripe list makes the export print a PHP warning into the diff --git a/packages/join-e2e/tests/settings/legacy/integrations.json b/packages/join-e2e/tests/settings/legacy/integrations.json index a376601..2763841 100644 --- a/packages/join-e2e/tests/settings/legacy/integrations.json +++ b/packages/join-e2e/tests/settings/legacy/integrations.json @@ -36,6 +36,6 @@ "_sentry_dsn": "https://legacykey@sentry.e2e.example/7", "_google_cloud_project_id": "", "_google_cloud_key_file_contents": "", - "_loggly_token": "", - "_loggly_tags": "" + "_loggly_token": "legacy-loggly-token", + "_loggly_tags": "legacy-site, join" } diff --git a/packages/join-e2e/tests/settings/membership-plans.spec.ts b/packages/join-e2e/tests/settings/membership-plans.spec.ts index 0f57aa3..b8f5543 100644 --- a/packages/join-e2e/tests/settings/membership-plans.spec.ts +++ b/packages/join-e2e/tests/settings/membership-plans.spec.ts @@ -5,6 +5,7 @@ import { GLOBAL_PLANS_PAGE, JoinSettingsPage, MEMBER_EMAIL, + continueToPlans, expect, fakeApiRequests, getSettings, @@ -55,8 +56,7 @@ const formPlans = async (page: Page) => (await joinFormEnv(page, GLOBAL_PLANS_PA /** Opens the join form on GLOBAL_PLANS_PAGE and moves on to choosing a plan. */ async function openPlanStep(page: Page): Promise { await openJoinForm(page, GLOBAL_PLANS_PAGE); - await page.locator(CONTINUE).click(); - await page.waitForSelector('[role="radiogroup"]'); + await continueToPlans(page); } /** A plan's panel on the join form's plan step. */ diff --git a/readme.md b/readme.md index bc385ea..210354e 100644 --- a/readme.md +++ b/readme.md @@ -503,9 +503,9 @@ How the tests are put together: #### Fake third-party APIs -The `fake-api` container (`packages/join-e2e/fake-api/server.js`) stands in for every service the plugin calls: Stripe, Mailchimp, Action Network, Zetkin, GoCardless, Chargebee, Auth0 and postcode lookups. Inside the Docker network, their hostnames (`api.stripe.com` and so on) resolve to it. It answers over HTTPS with a test certificate that only the E2E WordPress containers trust, so the plugin's real code and SDKs run unchanged and nothing reaches the real services. +The `fake-api` container (`packages/join-e2e/fake-api/server.js`) stands in for every service the plugin calls: Stripe, Mailchimp, Action Network, Zetkin, GoCardless, Chargebee, Auth0, postcode lookups, and the logging services (Sentry, Google Cloud Logging, Loggly). Inside the Docker network, their hostnames (`api.stripe.com` and so on) resolve to it. It answers over HTTPS with a test certificate that only the E2E WordPress containers trust, so the plugin's real code and SDKs run unchanged and nothing reaches the real services. -It records every request it receives. Tests read them, reset it and override its answers through its control API on `localhost:8890` (`tests/settings/fake-api.ts`). By default it answers `200 {}`, except Stripe, where it keeps the products and prices the plugin creates so searches find them, and lists and looks up customers and subscriptions. Tests can put a Stripe object in place first (`seedFakeStripe`), e.g. the customer a webhook event names. Zetkin, Auth0 and Google's token endpoint answer just enough for the plugin to sign in and add a member. The fake is also reachable as `http://fake-api`, for tests that need a webhook URL to send to. +It records every request it receives. Tests read them, reset it and override its answers through its control API on `localhost:8890` (`tests/settings/fake-api.ts`). By default it answers `200 {}`, except Stripe, where it keeps the products and prices the plugin creates so searches find them, and lists and looks up customers and subscriptions. Tests can put a Stripe object in place first (`seedFakeStripe`), e.g. the customer a webhook event names. Zetkin and Auth0 answer just enough for the plugin to sign in and add a member. The fake is also reachable as `http://fake-api`, for tests that need a webhook URL to send to. To fake another hostname, add it to `fake-api/certs/san.cnf`, run `fake-api/certs/generate.sh`, and add it to the `fake-api` network aliases in `docker-compose.yml`.