max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
3 files changed,
+208 insertions,
-198 deletions
| @@ -108,6 +108,7 @@ | |||
| 108 | 108 | mod seed_buyer; | |
| 109 | 109 | mod seed_examples; | |
| 110 | 110 | mod session_revocation; | |
| 111 | + | mod signup_notify; | |
| 111 | 112 | mod ssh_keys; | |
| 112 | 113 | mod ssh_management; | |
| 113 | 114 | mod sso; |
| @@ -490,204 +490,6 @@ | |||
| 490 | 490 | /// It had no action and no method, so a browser without JS submitted a GET to | |
| 491 | 491 | /// `/` and the address was dropped silently. This is the only email capture on | |
| 492 | 492 | /// the landing page, so a silent loss is a lost signup nobody can count. | |
| 493 | - | #[tokio::test] | |
| 494 | - | async fn notify_form_without_js_stores_the_address() { | |
| 495 | - | let mut h = TestHarness::new().await; | |
| 496 | - | h.client.fetch_csrf_token().await; | |
| 497 | - | ||
| 498 | - | let resp = h | |
| 499 | - | .client | |
| 500 | - | .post_form("/notify", "email=nojs@example.com") | |
| 501 | - | .await; | |
| 502 | - | assert_eq!( | |
| 503 | - | resp.status, 303, | |
| 504 | - | "expected a redirect back to the landing page" | |
| 505 | - | ); | |
| 506 | - | let location = resp | |
| 507 | - | .headers | |
| 508 | - | .get("location") | |
| 509 | - | .and_then(|v| v.to_str().ok()) | |
| 510 | - | .unwrap_or_default(); | |
| 511 | - | assert!( | |
| 512 | - | location.starts_with("/?notify=ok"), | |
| 513 | - | "redirected to {location}" | |
| 514 | - | ); | |
| 515 | - | ||
| 516 | - | let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM email_signups WHERE email = $1") | |
| 517 | - | .bind("nojs@example.com") | |
| 518 | - | .fetch_one(&h.db) | |
| 519 | - | .await | |
| 520 | - | .expect("count signups"); | |
| 521 | - | assert_eq!(stored, 1, "the address never reached storage"); | |
| 522 | - | } | |
| 523 | - | ||
| 524 | - | /// A rejected address comes back as a sentence on the page, not a 422. This is | |
| 525 | - | /// the last thing on the landing page and an error code is a worse outcome | |
| 526 | - | /// than being told the address looked wrong. | |
| 527 | - | #[tokio::test] | |
| 528 | - | async fn notify_form_rejects_a_bad_address_without_erroring() { | |
| 529 | - | let mut h = TestHarness::new().await; | |
| 530 | - | h.client.fetch_csrf_token().await; | |
| 531 | - | ||
| 532 | - | let resp = h.client.post_form("/notify", "email=not-an-address").await; | |
| 533 | - | assert_eq!(resp.status, 303); | |
| 534 | - | assert!( | |
| 535 | - | resp.headers | |
| 536 | - | .get("location") | |
| 537 | - | .and_then(|v| v.to_str().ok()) | |
| 538 | - | .unwrap_or_default() | |
| 539 | - | .starts_with("/?notify=invalid") | |
| 540 | - | ); | |
| 541 | - | ||
| 542 | - | let landing = h.client.get("/?notify=invalid").await; | |
| 543 | - | assert!( | |
| 544 | - | landing.text.contains("That address didn't look right."), | |
| 545 | - | "the landing page said nothing about the rejection" | |
| 546 | - | ); | |
| 547 | - | } | |
| 548 | - | ||
| 549 | - | /// The success message renders for a no-JS visitor coming back off the | |
| 550 | - | /// redirect, and not on a plain page load. | |
| 551 | - | #[tokio::test] | |
| 552 | - | async fn notify_status_renders_only_when_the_redirect_says_so() { | |
| 553 | - | let mut h = TestHarness::new().await; | |
| 554 | - | ||
| 555 | - | let plain = h.client.get("/").await; | |
| 556 | - | assert!( | |
| 557 | - | !plain.text.contains("You're on the list."), | |
| 558 | - | "status shown on a plain load" | |
| 559 | - | ); | |
| 560 | - | ||
| 561 | - | let after = h.client.get("/?notify=ok").await; | |
| 562 | - | assert!(after.text.contains("You're on the list.")); | |
| 563 | - | } | |
| 564 | - | ||
| 565 | - | // ── Landing signup unsubscribe ── | |
| 566 | - | // | |
| 567 | - | // Step 1 of wiki [[mnw-mailing-lists]]. The table had insert, admin read and | |
| 568 | - | // count, and no way out. Withdrawal has to be as easy as consent, and consent | |
| 569 | - | // is one form field. | |
| 570 | - | ||
| 571 | - | fn signup_unsub_url(email: &str) -> String { | |
| 572 | - | makenotwork::email::generate_signup_unsubscribe_url( | |
| 573 | - | "", | |
| 574 | - | email, | |
| 575 | - | "test-signing-secret-for-integration-tests", | |
| 576 | - | ) | |
| 577 | - | } | |
| 578 | - | ||
| 579 | - | /// The signed link unsubscribes, and the address stops being mailable. | |
| 580 | - | #[tokio::test] | |
| 581 | - | async fn signup_unsubscribe_link_removes_the_address_from_the_mailable_list() { | |
| 582 | - | let mut h = TestHarness::new().await; | |
| 583 | - | h.client.fetch_csrf_token().await; | |
| 584 | - | h.client | |
| 585 | - | .post_form("/notify", "email=leaving@example.com") | |
| 586 | - | .await; | |
| 587 | - | ||
| 588 | - | let before: i64 = sqlx::query_scalar( | |
| 589 | - | "SELECT COUNT(*) FROM email_signups WHERE email = $1 AND unsubscribed_at IS NULL", | |
| 590 | - | ) | |
| 591 | - | .bind("leaving@example.com") | |
| 592 | - | .fetch_one(&h.db) | |
| 593 | - | .await | |
| 594 | - | .expect("count"); | |
| 595 | - | assert_eq!(before, 1, "signup did not land"); | |
| 596 | - | ||
| 597 | - | let resp = h.client.get(&signup_unsub_url("leaving@example.com")).await; | |
| 598 | - | assert_eq!(resp.status, 200); | |
| 599 | - | ||
| 600 | - | let after: i64 = sqlx::query_scalar( | |
| 601 | - | "SELECT COUNT(*) FROM email_signups WHERE email = $1 AND unsubscribed_at IS NULL", | |
| 602 | - | ) | |
| 603 | - | .bind("leaving@example.com") | |
| 604 | - | .fetch_one(&h.db) | |
| 605 | - | .await | |
| 606 | - | .expect("count"); | |
| 607 | - | assert_eq!(after, 0, "still mailable after unsubscribing"); | |
| 608 | - | ||
| 609 | - | // Marked, not deleted: a deleted address is one the next import re-adds. | |
| 610 | - | let retained: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM email_signups WHERE email = $1") | |
| 611 | - | .bind("leaving@example.com") | |
| 612 | - | .fetch_one(&h.db) | |
| 613 | - | .await | |
| 614 | - | .expect("count"); | |
| 615 | - | assert_eq!(retained, 1, "the opt-out record was thrown away"); | |
| 616 | - | } | |
| 617 | - | ||
| 618 | - | /// RFC 8058: a POST to the same URL unsubscribes with no confirmation step, | |
| 619 | - | /// which is what the List-Unsubscribe-Post header promises Gmail and Yahoo. | |
| 620 | - | /// Retries must not fail, so the second POST answers the same as the first. | |
| 621 | - | #[tokio::test] | |
| 622 | - | async fn signup_unsubscribe_one_click_post_is_idempotent() { | |
| 623 | - | let mut h = TestHarness::new().await; | |
| 624 | - | h.client.fetch_csrf_token().await; | |
| 625 | - | h.client | |
| 626 | - | .post_form("/notify", "email=oneclick@example.com") | |
| 627 | - | .await; | |
| 628 | - | ||
| 629 | - | let url = signup_unsub_url("oneclick@example.com"); | |
| 630 | - | let first = h.client.post_form(&url, "List-Unsubscribe=One-Click").await; | |
| 631 | - | assert_eq!(first.status, 200); | |
| 632 | - | let second = h.client.post_form(&url, "List-Unsubscribe=One-Click").await; | |
| 633 | - | assert_eq!(second.status, 200, "a retried one-click POST must not fail"); | |
| 634 | - | } | |
| 635 | - | ||
| 636 | - | /// A tampered signature does nothing. The address is signed over, so a token | |
| 637 | - | /// for one address cannot unsubscribe another. | |
| 638 | - | #[tokio::test] | |
| 639 | - | async fn signup_unsubscribe_rejects_a_forged_link() { | |
| 640 | - | let mut h = TestHarness::new().await; | |
| 641 | - | h.client.fetch_csrf_token().await; | |
| 642 | - | h.client | |
| 643 | - | .post_form("/notify", "email=victim@example.com") | |
| 644 | - | .await; | |
| 645 | - | ||
| 646 | - | // Take a valid token for one address and point it at another. | |
| 647 | - | let forged = signup_unsub_url("attacker@example.com") | |
| 648 | - | .replace("attacker%40example.com", "victim%40example.com"); | |
| 649 | - | let resp = h.client.get(&forged).await; | |
| 650 | - | assert!( | |
| 651 | - | resp.text.contains("Invalid Link"), | |
| 652 | - | "a forged link was accepted" | |
| 653 | - | ); | |
| 654 | - | ||
| 655 | - | let still_mailable: i64 = sqlx::query_scalar( | |
| 656 | - | "SELECT COUNT(*) FROM email_signups WHERE email = $1 AND unsubscribed_at IS NULL", | |
| 657 | - | ) | |
| 658 | - | .bind("victim@example.com") | |
| 659 | - | .fetch_one(&h.db) | |
| 660 | - | .await | |
| 661 | - | .expect("count"); | |
| 662 | - | assert_eq!(still_mailable, 1, "a forged link unsubscribed someone"); | |
| 663 | - | } | |
| 664 | - | ||
| 665 | - | /// Signing up again after unsubscribing re-subscribes. The form is the only | |
| 666 | - | /// interface, so refusing would leave someone unable to opt back in. | |
| 667 | - | #[tokio::test] | |
| 668 | - | async fn signing_up_again_after_unsubscribing_restores_the_subscription() { | |
| 669 | - | let mut h = TestHarness::new().await; | |
| 670 | - | h.client.fetch_csrf_token().await; | |
| 671 | - | h.client | |
| 672 | - | .post_form("/notify", "email=returning@example.com") | |
| 673 | - | .await; | |
| 674 | - | h.client | |
| 675 | - | .get(&signup_unsub_url("returning@example.com")) | |
| 676 | - | .await; | |
| 677 | - | ||
| 678 | - | h.client | |
| 679 | - | .post_form("/notify", "email=returning@example.com") | |
| 680 | - | .await; | |
| 681 | - | ||
| 682 | - | let mailable: i64 = sqlx::query_scalar( | |
| 683 | - | "SELECT COUNT(*) FROM email_signups WHERE email = $1 AND unsubscribed_at IS NULL", | |
| 684 | - | ) | |
| 685 | - | .bind("returning@example.com") | |
| 686 | - | .fetch_one(&h.db) | |
| 687 | - | .await | |
| 688 | - | .expect("count"); | |
| 689 | - | assert_eq!(mailable, 1, "could not opt back in"); | |
| 690 | - | } | |
| 691 | 493 | ||
| 692 | 494 | /// A method mismatch on a path that exists renders the branded error page. | |
| 693 | 495 | /// |
| @@ -1,0 +1,207 @@ | |||
| 1 | + | //! Signup and notify mailing-list tests: storing an address, the unsubscribe | |
| 2 | + | //! link, and resubscribing. | |
| 3 | + | //! | |
| 4 | + | //! Split out of `pages` because the subject is the mailing list's lifecycle | |
| 5 | + | //! rather than whether a route renders. The form lives on a page; what these | |
| 6 | + | //! assert is what happens to the address afterwards. | |
| 7 | + | ||
| 8 | + | use crate::harness::TestHarness; | |
| 9 | + | ||
| 10 | + | #[tokio::test] | |
| 11 | + | async fn notify_form_without_js_stores_the_address() { | |
| 12 | + | let mut h = TestHarness::new().await; | |
| 13 | + | h.client.fetch_csrf_token().await; | |
| 14 | + | ||
| 15 | + | let resp = h | |
| 16 | + | .client | |
| 17 | + | .post_form("/notify", "email=nojs@example.com") | |
| 18 | + | .await; | |
| 19 | + | assert_eq!( | |
| 20 | + | resp.status, 303, | |
| 21 | + | "expected a redirect back to the landing page" | |
| 22 | + | ); | |
| 23 | + | let location = resp | |
| 24 | + | .headers | |
| 25 | + | .get("location") | |
| 26 | + | .and_then(|v| v.to_str().ok()) | |
| 27 | + | .unwrap_or_default(); | |
| 28 | + | assert!( | |
| 29 | + | location.starts_with("/?notify=ok"), | |
| 30 | + | "redirected to {location}" | |
| 31 | + | ); | |
| 32 | + | ||
| 33 | + | let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM email_signups WHERE email = $1") | |
| 34 | + | .bind("nojs@example.com") | |
| 35 | + | .fetch_one(&h.db) | |
| 36 | + | .await | |
| 37 | + | .expect("count signups"); | |
| 38 | + | assert_eq!(stored, 1, "the address never reached storage"); | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// A rejected address comes back as a sentence on the page, not a 422. This is | |
| 42 | + | /// the last thing on the landing page and an error code is a worse outcome | |
| 43 | + | /// than being told the address looked wrong. | |
| 44 | + | #[tokio::test] | |
| 45 | + | async fn notify_form_rejects_a_bad_address_without_erroring() { | |
| 46 | + | let mut h = TestHarness::new().await; | |
| 47 | + | h.client.fetch_csrf_token().await; | |
| 48 | + | ||
| 49 | + | let resp = h.client.post_form("/notify", "email=not-an-address").await; | |
| 50 | + | assert_eq!(resp.status, 303); | |
| 51 | + | assert!( | |
| 52 | + | resp.headers | |
| 53 | + | .get("location") | |
| 54 | + | .and_then(|v| v.to_str().ok()) | |
| 55 | + | .unwrap_or_default() | |
| 56 | + | .starts_with("/?notify=invalid") | |
| 57 | + | ); | |
| 58 | + | ||
| 59 | + | let landing = h.client.get("/?notify=invalid").await; | |
| 60 | + | assert!( | |
| 61 | + | landing.text.contains("That address didn't look right."), | |
| 62 | + | "the landing page said nothing about the rejection" | |
| 63 | + | ); | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /// The success message renders for a no-JS visitor coming back off the | |
| 67 | + | /// redirect, and not on a plain page load. | |
| 68 | + | #[tokio::test] | |
| 69 | + | async fn notify_status_renders_only_when_the_redirect_says_so() { | |
| 70 | + | let mut h = TestHarness::new().await; | |
| 71 | + | ||
| 72 | + | let plain = h.client.get("/").await; | |
| 73 | + | assert!( | |
| 74 | + | !plain.text.contains("You're on the list."), | |
| 75 | + | "status shown on a plain load" | |
| 76 | + | ); | |
| 77 | + | ||
| 78 | + | let after = h.client.get("/?notify=ok").await; | |
| 79 | + | assert!(after.text.contains("You're on the list.")); | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // ── Landing signup unsubscribe ── | |
| 83 | + | // | |
| 84 | + | // Step 1 of wiki [[mnw-mailing-lists]]. The table had insert, admin read and | |
| 85 | + | // count, and no way out. Withdrawal has to be as easy as consent, and consent | |
| 86 | + | // is one form field. | |
| 87 | + | ||
| 88 | + | fn signup_unsub_url(email: &str) -> String { | |
| 89 | + | makenotwork::email::generate_signup_unsubscribe_url( | |
| 90 | + | "", | |
| 91 | + | email, | |
| 92 | + | "test-signing-secret-for-integration-tests", | |
| 93 | + | ) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | /// The signed link unsubscribes, and the address stops being mailable. | |
| 97 | + | #[tokio::test] | |
| 98 | + | async fn signup_unsubscribe_link_removes_the_address_from_the_mailable_list() { | |
| 99 | + | let mut h = TestHarness::new().await; | |
| 100 | + | h.client.fetch_csrf_token().await; | |
| 101 | + | h.client | |
| 102 | + | .post_form("/notify", "email=leaving@example.com") | |
| 103 | + | .await; | |
| 104 | + | ||
| 105 | + | let before: i64 = sqlx::query_scalar( | |
| 106 | + | "SELECT COUNT(*) FROM email_signups WHERE email = $1 AND unsubscribed_at IS NULL", | |
| 107 | + | ) | |
| 108 | + | .bind("leaving@example.com") | |
| 109 | + | .fetch_one(&h.db) | |
| 110 | + | .await | |
| 111 | + | .expect("count"); | |
| 112 | + | assert_eq!(before, 1, "signup did not land"); | |
| 113 | + | ||
| 114 | + | let resp = h.client.get(&signup_unsub_url("leaving@example.com")).await; | |
| 115 | + | assert_eq!(resp.status, 200); | |
| 116 | + | ||
| 117 | + | let after: i64 = sqlx::query_scalar( | |
| 118 | + | "SELECT COUNT(*) FROM email_signups WHERE email = $1 AND unsubscribed_at IS NULL", | |
| 119 | + | ) | |
| 120 | + | .bind("leaving@example.com") | |
| 121 | + | .fetch_one(&h.db) | |
| 122 | + | .await | |
| 123 | + | .expect("count"); | |
| 124 | + | assert_eq!(after, 0, "still mailable after unsubscribing"); | |
| 125 | + | ||
| 126 | + | // Marked, not deleted: a deleted address is one the next import re-adds. | |
| 127 | + | let retained: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM email_signups WHERE email = $1") | |
| 128 | + | .bind("leaving@example.com") | |
| 129 | + | .fetch_one(&h.db) | |
| 130 | + | .await | |
| 131 | + | .expect("count"); | |
| 132 | + | assert_eq!(retained, 1, "the opt-out record was thrown away"); | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | /// RFC 8058: a POST to the same URL unsubscribes with no confirmation step, | |
| 136 | + | /// which is what the List-Unsubscribe-Post header promises Gmail and Yahoo. | |
| 137 | + | /// Retries must not fail, so the second POST answers the same as the first. | |
| 138 | + | #[tokio::test] | |
| 139 | + | async fn signup_unsubscribe_one_click_post_is_idempotent() { | |
| 140 | + | let mut h = TestHarness::new().await; | |
| 141 | + | h.client.fetch_csrf_token().await; | |
| 142 | + | h.client | |
| 143 | + | .post_form("/notify", "email=oneclick@example.com") | |
| 144 | + | .await; | |
| 145 | + | ||
| 146 | + | let url = signup_unsub_url("oneclick@example.com"); | |
| 147 | + | let first = h.client.post_form(&url, "List-Unsubscribe=One-Click").await; | |
| 148 | + | assert_eq!(first.status, 200); | |
| 149 | + | let second = h.client.post_form(&url, "List-Unsubscribe=One-Click").await; | |
| 150 | + | assert_eq!(second.status, 200, "a retried one-click POST must not fail"); | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | /// A tampered signature does nothing. The address is signed over, so a token | |
| 154 | + | /// for one address cannot unsubscribe another. | |
| 155 | + | #[tokio::test] | |
| 156 | + | async fn signup_unsubscribe_rejects_a_forged_link() { | |
| 157 | + | let mut h = TestHarness::new().await; | |
| 158 | + | h.client.fetch_csrf_token().await; | |
| 159 | + | h.client | |
| 160 | + | .post_form("/notify", "email=victim@example.com") | |
| 161 | + | .await; | |
| 162 | + | ||
| 163 | + | // Take a valid token for one address and point it at another. | |
| 164 | + | let forged = signup_unsub_url("attacker@example.com") | |
| 165 | + | .replace("attacker%40example.com", "victim%40example.com"); | |
| 166 | + | let resp = h.client.get(&forged).await; | |
| 167 | + | assert!( | |
| 168 | + | resp.text.contains("Invalid Link"), | |
| 169 | + | "a forged link was accepted" | |
| 170 | + | ); | |
| 171 | + | ||
| 172 | + | let still_mailable: i64 = sqlx::query_scalar( | |
| 173 | + | "SELECT COUNT(*) FROM email_signups WHERE email = $1 AND unsubscribed_at IS NULL", | |
| 174 | + | ) | |
| 175 | + | .bind("victim@example.com") | |
| 176 | + | .fetch_one(&h.db) | |
| 177 | + | .await | |
| 178 | + | .expect("count"); | |
| 179 | + | assert_eq!(still_mailable, 1, "a forged link unsubscribed someone"); | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | /// Signing up again after unsubscribing re-subscribes. The form is the only | |
| 183 | + | /// interface, so refusing would leave someone unable to opt back in. | |
| 184 | + | #[tokio::test] | |
| 185 | + | async fn signing_up_again_after_unsubscribing_restores_the_subscription() { | |
| 186 | + | let mut h = TestHarness::new().await; | |
| 187 | + | h.client.fetch_csrf_token().await; | |
| 188 | + | h.client | |
| 189 | + | .post_form("/notify", "email=returning@example.com") | |
| 190 | + | .await; | |
| 191 | + | h.client | |
| 192 | + | .get(&signup_unsub_url("returning@example.com")) | |
| 193 | + | .await; | |
| 194 | + | ||
| 195 | + | h.client | |
| 196 | + | .post_form("/notify", "email=returning@example.com") | |
| 197 | + | .await; | |
| 198 | + | ||
| 199 | + | let mailable: i64 = sqlx::query_scalar( | |
| 200 | + | "SELECT COUNT(*) FROM email_signups WHERE email = $1 AND unsubscribed_at IS NULL", | |
| 201 | + | ) | |
| 202 | + | .bind("returning@example.com") | |
| 203 | + | .fetch_one(&h.db) | |
| 204 | + | .await | |
| 205 | + | .expect("count"); | |
| 206 | + | assert_eq!(mailable, 1, "could not opt back in"); | |
| 207 | + | } |