diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md index 9bed47c..05311fa 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -80,7 +80,7 @@ The join form specs (the table below) are listed with their test counts. The set | 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); at least one plan required, and Name and Price required by the page (not by the plugin: JOIN-169); 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; no tags while another subscription is active (fake Mailchimp). Known problems: JOIN-166 (duplicate Stripe prices for prices with pence), JOIN-169 (Name and Price not checked by the plugin). Open question: JOIN-170 (removed plans stay joinable). | | 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: each one's default (or none), the fallbacks for blank sidebar, stage label and joining verb, 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 a8c6596..204b642 100644 --- a/packages/join-e2e/docker-compose.yml +++ b/packages/join-e2e/docker-compose.yml @@ -57,6 +57,9 @@ services: - api.postcodes.io - api.getaddress.io - api.ideal-postcodes.co.uk + - logs-01.loggly.com + - sentry.e2e.example + - 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..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----- -MIIDPzCCAiegAwIBAgIUL1NIuEmpA0RWqABA149MgtVN6S8wDQYJKoZIhvcNAQEL +MIIDPzCCAiegAwIBAgIUPHRrwi26P4sc3ZGz0uZqZnIYAQcwDQYJKoZIhvcNAQEL BQAwLjEsMCoGA1UEAwwjSm9pbiBFMkUgdGVzdCBDQSAobm90IGZvciByZWFsIHVz -ZSkwIBcNMjYxMDIzMTc1NDA2WhgPMjEyNjA5MjkxNzU0MDZaMC4xLDAqBgNVBAMM +ZSkwIBcNMjYxMDI0MTYyODE2WhgPMjEyNjA5MzAxNjI4MTZaMC4xLDAqBgNVBAMM 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== +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 f410223..47bc2aa 100644 --- a/packages/join-e2e/fake-api/certs/san.cnf +++ b/packages/join-e2e/fake-api/certs/san.cnf @@ -20,3 +20,6 @@ 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 = 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..4599677 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 +MIIEjzCCA3egAwIBAgIUAJw+ei4n8CYxLloWgV0WE8fGUH0wDQYJKoZIhvcNAQEL 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== +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 +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 2dffa2c..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----- -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= +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 6d88557..a95ef53 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) { @@ -90,7 +96,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') || ''; @@ -125,6 +131,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) => @@ -137,6 +170,13 @@ 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.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: {} }; } @@ -146,7 +186,17 @@ 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. 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({ time: new Date().toISOString(), @@ -186,6 +236,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/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/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/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 b183643..912435f 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, rmSync } 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, setBaselinePlan } from './support'; +import { JoinSettingsPage, STANDARD_JOIN_PAGE, postJoin, resetSettings, setBaselinePlan } from './support'; export * from './support'; export * from './fake-api'; @@ -84,17 +84,83 @@ 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 }), + ); +} + +/** 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. @@ -110,3 +176,27 @@ export async function expectDetailsAccepted(page: Page): Promise { await page.locator(CONTINUE).click(); await expect(page.locator('.progress-step--done').first()).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 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); + 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..881e7cf --- /dev/null +++ b/packages/join-e2e/tests/settings/integrations-crm.spec.ts @@ -0,0 +1,443 @@ +import { + STANDARD_JOIN_PAGE, + expect, + fakeApiRequests, + getSettings, + joinFormEnv, + joinThroughForm, + loadLegacySettings, + openJoinForm, + continueToPlans, + 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");'); + +/** 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, + 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', + }); + + 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. + 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', + }, + }, + }); + expect(joinDates).toContain(submission.body.form_data.joined_on); + 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(); + + const joinDates = await joinAndNoteDates(page); + + const [submission] = await fakeApiRequests('api.dev.zetkin.org'); + 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 }) => { + 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 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); + 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); + }); +}); + +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, 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'); + 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, + }, + // 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', () => { + // 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: 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, 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'); + }); +}); + +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', + 'loggly_token', + 'loggly_tags', + ]), + ).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', + loggly_token: 'legacy-loggly-token', + loggly_tags: 'legacy-site, join', + }); + }); + + 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, + }); + + 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' } }, + }); + 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', + ), + ).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'); + // 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 new file mode 100644 index 0000000..8c61761 --- /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.getByText('e2e-site', { exact: true })).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..1d3fd1a --- /dev/null +++ b/packages/join-e2e/tests/settings/integrations-payments.spec.ts @@ -0,0 +1,417 @@ +import { Page } from '@playwright/test'; +import { readFileSync } from 'fs'; +import { + STANDARD_JOIN_PAGE, + continueToPayment, + 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). + */ + +/** 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 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 + // 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..2763841 --- /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": "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/packages/join-e2e/tests/settings/support.ts b/packages/join-e2e/tests/settings/support.ts index 2709f93..5401092 100644 --- a/packages/join-e2e/tests/settings/support.ts +++ b/packages/join-e2e/tests/settings/support.ts @@ -234,6 +234,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 f1f73b3..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. +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`.