Skip to main content

max / makenotwork

22.8 KB · 700 lines History Blame Raw
1 //! Integration tests for the internal API (HMAC-signed requests from MNW).
2
3 use axum::Router;
4 use axum::body::Body;
5 use axum::extract::ConnectInfo;
6 use axum::http::{Method, Request, StatusCode};
7 use hmac::{Hmac, KeyInit, Mac};
8 use http_body_util::BodyExt;
9 use sha2::Sha256;
10 use sqlx::PgPool;
11 use std::net::SocketAddr;
12 use tower::ServiceExt;
13 use uuid::Uuid;
14
15 use crate::harness::db::TestDb;
16
17 const TEST_SECRET: &str = "test-internal-secret-key-for-hmac";
18
19 /// Minimal harness for internal API tests, no CSRF/session, just the internal routes.
20 struct InternalTestHarness {
21 app: Router,
22 db: PgPool,
23 _test_db: TestDb,
24 }
25
26 impl InternalTestHarness {
27 async fn new() -> Self {
28 let test_db = TestDb::new().await;
29 let pool = test_db.pool.clone();
30
31 let config = multithreaded::config::Config {
32 mnw_base_url: "http://127.0.0.1:9999".into(),
33 oauth_client_id: "test-client-id".to_string(),
34 oauth_redirect_uri: "http://127.0.0.1:3400/auth/callback".to_string(),
35 platform_admin_id: None,
36 cookie_secure: false,
37 s3: None,
38 internal_shared_secret: Some(TEST_SECRET.to_string()),
39 trusted_proxies: std::sync::Arc::from([std::net::IpAddr::from([127, 0, 0, 1])]),
40 };
41
42 let state = multithreaded::AppState {
43 db: pool.clone(),
44 config,
45 http: multithreaded::tls::builder().build().unwrap(),
46 link_preview: multithreaded::link_preview::LinkPreviewFetcher::Noop,
47 s3: None,
48 chat: std::sync::Arc::new(multithreaded::chat::new_chat()),
49 chat_identities: multithreaded::chat::IdentityCache::new(),
50 };
51
52 let app = multithreaded::routes::internal::internal_routes(state);
53
54 InternalTestHarness {
55 app,
56 db: pool,
57 _test_db: test_db,
58 }
59 }
60
61 /// Send a v2-signed request, binding method + `sign_path` + nonce. The
62 /// request is sent to `uri`; `sign_path` is what the signature covers, pass
63 /// the same value normally, or a different one to test path-binding.
64 async fn send_signed(
65 &self,
66 method: Method,
67 uri: &str,
68 sign_path: &str,
69 nonce: &str,
70 body: &str,
71 ) -> (StatusCode, String) {
72 let timestamp = chrono::Utc::now().timestamp().to_string();
73 let mut mac = Hmac::<Sha256>::new_from_slice(TEST_SECRET.as_bytes()).expect("HMAC key");
74 for field in [timestamp.as_str(), method.as_str(), sign_path, nonce] {
75 mac.update(field.as_bytes());
76 mac.update(b"\n");
77 }
78 mac.update(body.as_bytes());
79 let signature = hex::encode(mac.finalize().into_bytes());
80
81 let mut builder = Request::builder()
82 .method(method)
83 .uri(uri)
84 .header("X-Internal-Timestamp", &timestamp)
85 .header("X-Internal-Signature", &signature)
86 .header("X-Internal-Nonce", nonce);
87 if !body.is_empty() {
88 builder = builder.header("Content-Type", "application/json");
89 }
90 let mut request = builder
91 .body(Body::from(body.to_string()))
92 .expect("build request");
93
94 request
95 .extensions_mut()
96 .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0))));
97
98 let response = self
99 .app
100 .clone()
101 .oneshot(request)
102 .await
103 .expect("send request");
104 let status = response.status();
105 let bytes = response
106 .into_body()
107 .collect()
108 .await
109 .expect("read body")
110 .to_bytes();
111 (status, String::from_utf8_lossy(&bytes).to_string())
112 }
113
114 /// Send a v2-signed POST with a fresh nonce.
115 async fn signed_post(&self, uri: &str, body: &str) -> (StatusCode, String) {
116 let nonce = Uuid::new_v4().simple().to_string();
117 self.send_signed(Method::POST, uri, uri, &nonce, body).await
118 }
119
120 /// Send a v2-signed GET with a fresh nonce.
121 async fn get(&self, uri: &str) -> (StatusCode, String) {
122 let nonce = Uuid::new_v4().simple().to_string();
123 self.send_signed(Method::GET, uri, uri, &nonce, "").await
124 }
125 }
126
127 // Community tests
128
129 #[tokio::test]
130 async fn create_community_happy_path() {
131 let h = InternalTestHarness::new().await;
132 let owner_id = Uuid::new_v4();
133
134 let body = serde_json::json!({
135 "name": "Test Project",
136 "slug": "test-project",
137 "description": "A test community",
138 "owner_mnw_id": owner_id,
139 "owner_username": "testcreator",
140 "owner_display_name": "Test Creator"
141 });
142
143 let (status, text) = h
144 .signed_post("/internal/communities", &body.to_string())
145 .await;
146 assert_eq!(status, StatusCode::OK, "body: {text}");
147
148 let resp: serde_json::Value = serde_json::from_str(&text).unwrap();
149 assert!(resp["created"].as_bool().unwrap());
150 assert!(resp["community_id"].as_str().is_some());
151
152 // Verify default categories were created. Order is fixed in
153 // `routes/internal.rs`; Issues + Patches were added in step 6 to surface
154 // the email-driven workflows in fresh communities.
155 let community_id: Uuid = resp["community_id"].as_str().unwrap().parse().unwrap();
156 let categories: Vec<(String,)> =
157 sqlx::query_as("SELECT slug FROM categories WHERE community_id = $1 ORDER BY sort_order")
158 .bind(community_id)
159 .fetch_all(&h.db)
160 .await
161 .unwrap();
162
163 let slugs: Vec<&str> = categories.iter().map(|(s,)| s.as_str()).collect();
164 assert_eq!(
165 slugs,
166 vec!["items", "blog", "devlog", "discussion", "issues", "patches"]
167 );
168 }
169
170 #[tokio::test]
171 async fn create_community_idempotent() {
172 let h = InternalTestHarness::new().await;
173 let owner_id = Uuid::new_v4();
174
175 let body = serde_json::json!({
176 "name": "Idem Project",
177 "slug": "idem-project",
178 "owner_mnw_id": owner_id,
179 "owner_username": "idemcreator",
180 });
181
182 let (s1, t1) = h
183 .signed_post("/internal/communities", &body.to_string())
184 .await;
185 assert_eq!(s1, StatusCode::OK);
186 let r1: serde_json::Value = serde_json::from_str(&t1).unwrap();
187 assert!(r1["created"].as_bool().unwrap());
188
189 // Second call with same slug
190 let (s2, t2) = h
191 .signed_post("/internal/communities", &body.to_string())
192 .await;
193 assert_eq!(s2, StatusCode::OK);
194 let r2: serde_json::Value = serde_json::from_str(&t2).unwrap();
195 assert!(!r2["created"].as_bool().unwrap());
196 assert_eq!(r1["community_id"], r2["community_id"]);
197 }
198
199 #[tokio::test]
200 async fn create_community_rejects_bad_signature() {
201 let h = InternalTestHarness::new().await;
202 let body = r#"{"name":"Bad","slug":"bad","owner_mnw_id":"00000000-0000-0000-0000-000000000001","owner_username":"bad"}"#;
203
204 let timestamp = chrono::Utc::now().timestamp().to_string();
205
206 let mut request = Request::builder()
207 .method(Method::POST)
208 .uri("/internal/communities")
209 .header("Content-Type", "application/json")
210 .header("X-Internal-Timestamp", &timestamp)
211 .header("X-Internal-Signature", "deadbeef")
212 .body(Body::from(body))
213 .expect("build request");
214
215 request
216 .extensions_mut()
217 .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0))));
218
219 let response = h.app.clone().oneshot(request).await.expect("send request");
220 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
221 }
222
223 #[tokio::test]
224 async fn create_community_rejects_missing_headers() {
225 let h = InternalTestHarness::new().await;
226 let body = r#"{"name":"No Auth","slug":"noauth","owner_mnw_id":"00000000-0000-0000-0000-000000000001","owner_username":"noauth"}"#;
227
228 let mut request = Request::builder()
229 .method(Method::POST)
230 .uri("/internal/communities")
231 .header("Content-Type", "application/json")
232 .body(Body::from(body))
233 .expect("build request");
234
235 request
236 .extensions_mut()
237 .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0))));
238
239 let response = h.app.clone().oneshot(request).await.expect("send request");
240 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
241 }
242
243 // Thread tests
244
245 #[tokio::test]
246 async fn create_thread_happy_path() {
247 let h = InternalTestHarness::new().await;
248 let owner_id = Uuid::new_v4();
249
250 // First create a community
251 let comm_body = serde_json::json!({
252 "name": "Thread Project",
253 "slug": "thread-project",
254 "owner_mnw_id": owner_id,
255 "owner_username": "threadcreator",
256 });
257 let (status, _) = h
258 .signed_post("/internal/communities", &comm_body.to_string())
259 .await;
260 assert_eq!(status, StatusCode::OK);
261
262 // Create a thread
263 let thread_body = serde_json::json!({
264 "community_slug": "thread-project",
265 "category_slug": "items",
266 "title": "New Item Discussion",
267 "body_markdown": "Discussion for [New Item](https://example.com/i/123)",
268 "author_mnw_id": owner_id,
269 "author_username": "threadcreator",
270 "external_ref": "mnw:item:00000000-0000-0000-0000-000000000123"
271 });
272 let (status, text) = h
273 .signed_post("/internal/threads", &thread_body.to_string())
274 .await;
275 assert_eq!(status, StatusCode::OK, "body: {text}");
276
277 let resp: serde_json::Value = serde_json::from_str(&text).unwrap();
278 assert!(resp["created"].as_bool().unwrap());
279 assert!(resp["thread_id"].as_str().is_some());
280 assert!(resp["post_id"].as_str().is_some());
281
282 // Verify thread has external_ref in DB
283 let thread_id: Uuid = resp["thread_id"].as_str().unwrap().parse().unwrap();
284 let ext_ref: Option<String> =
285 sqlx::query_scalar("SELECT external_ref FROM threads WHERE id = $1")
286 .bind(thread_id)
287 .fetch_one(&h.db)
288 .await
289 .unwrap();
290 assert_eq!(
291 ext_ref.as_deref(),
292 Some("mnw:item:00000000-0000-0000-0000-000000000123")
293 );
294 }
295
296 #[tokio::test]
297 async fn create_thread_idempotent() {
298 let h = InternalTestHarness::new().await;
299 let owner_id = Uuid::new_v4();
300
301 // Create community
302 let comm_body = serde_json::json!({
303 "name": "Idem Thread Proj",
304 "slug": "idem-thread",
305 "owner_mnw_id": owner_id,
306 "owner_username": "idemthreaduser",
307 });
308 h.signed_post("/internal/communities", &comm_body.to_string())
309 .await;
310
311 let thread_body = serde_json::json!({
312 "community_slug": "idem-thread",
313 "category_slug": "blog",
314 "title": "Blog Discussion",
315 "body_markdown": "Discussion body",
316 "author_mnw_id": owner_id,
317 "author_username": "idemthreaduser",
318 "external_ref": "mnw:blog:dedup-test"
319 });
320
321 let (s1, t1) = h
322 .signed_post("/internal/threads", &thread_body.to_string())
323 .await;
324 assert_eq!(s1, StatusCode::OK);
325 let r1: serde_json::Value = serde_json::from_str(&t1).unwrap();
326 assert!(r1["created"].as_bool().unwrap());
327
328 // Second call with same external_ref
329 let (s2, t2) = h
330 .signed_post("/internal/threads", &thread_body.to_string())
331 .await;
332 assert_eq!(s2, StatusCode::OK);
333 let r2: serde_json::Value = serde_json::from_str(&t2).unwrap();
334 assert!(!r2["created"].as_bool().unwrap());
335 assert_eq!(r1["thread_id"], r2["thread_id"]);
336 }
337
338 #[tokio::test]
339 async fn create_thread_missing_community() {
340 let h = InternalTestHarness::new().await;
341 let author_id = Uuid::new_v4();
342
343 let thread_body = serde_json::json!({
344 "community_slug": "nonexistent",
345 "category_slug": "items",
346 "title": "Orphan Thread",
347 "body_markdown": "Should fail",
348 "author_mnw_id": author_id,
349 "author_username": "orphan",
350 "external_ref": "mnw:item:orphan"
351 });
352 let (status, _) = h
353 .signed_post("/internal/threads", &thread_body.to_string())
354 .await;
355 assert_eq!(status, StatusCode::NOT_FOUND);
356 }
357
358 // Thread stats tests
359
360 #[tokio::test]
361 async fn thread_stats_happy_path() {
362 let h = InternalTestHarness::new().await;
363 let owner_id = Uuid::new_v4();
364
365 // Create community + thread via internal API
366 let comm_body = serde_json::json!({
367 "name": "Stats Project",
368 "slug": "stats-project",
369 "owner_mnw_id": owner_id,
370 "owner_username": "statsuser",
371 });
372 h.signed_post("/internal/communities", &comm_body.to_string())
373 .await;
374
375 let thread_body = serde_json::json!({
376 "community_slug": "stats-project",
377 "category_slug": "items",
378 "title": "Stats Thread",
379 "body_markdown": "Opening post",
380 "author_mnw_id": owner_id,
381 "author_username": "statsuser",
382 "external_ref": "mnw:item:stats-1"
383 });
384 let (_, text) = h
385 .signed_post("/internal/threads", &thread_body.to_string())
386 .await;
387 let resp: serde_json::Value = serde_json::from_str(&text).unwrap();
388 let thread_id = resp["thread_id"].as_str().unwrap();
389
390 let (status, stats_text) = h.get(&format!("/internal/threads/{thread_id}/stats")).await;
391 assert_eq!(status, StatusCode::OK, "body: {stats_text}");
392
393 let stats: serde_json::Value = serde_json::from_str(&stats_text).unwrap();
394 assert_eq!(stats["post_count"].as_i64().unwrap(), 1); // opening post
395 assert!(stats["last_activity_at"].as_str().is_some());
396 }
397
398 #[tokio::test]
399 async fn thread_stats_nonexistent() {
400 let h = InternalTestHarness::new().await;
401 let fake_id = Uuid::new_v4();
402
403 let (status, text) = h.get(&format!("/internal/threads/{fake_id}/stats")).await;
404 assert_eq!(status, StatusCode::OK, "body: {text}");
405
406 let stats: serde_json::Value = serde_json::from_str(&text).unwrap();
407 assert_eq!(stats["post_count"].as_i64().unwrap(), 0);
408 }
409
410 #[tokio::test]
411 async fn thread_stats_invalid_uuid() {
412 let h = InternalTestHarness::new().await;
413
414 let (status, _) = h.get("/internal/threads/not-a-uuid/stats").await;
415 assert_eq!(status, StatusCode::NOT_FOUND);
416 }
417
418 // Create post tests
419
420 #[tokio::test]
421 async fn create_post_happy_path() {
422 let h = InternalTestHarness::new().await;
423 let owner_id = Uuid::new_v4();
424
425 // Create community + thread
426 let comm_body = serde_json::json!({
427 "name": "Post Project",
428 "slug": "post-project",
429 "owner_mnw_id": owner_id,
430 "owner_username": "postuser",
431 });
432 h.signed_post("/internal/communities", &comm_body.to_string())
433 .await;
434
435 let thread_body = serde_json::json!({
436 "community_slug": "post-project",
437 "category_slug": "items",
438 "title": "Thread for Reply",
439 "body_markdown": "Opening post",
440 "author_mnw_id": owner_id,
441 "author_username": "postuser",
442 "external_ref": "mnw:item:post-test-1"
443 });
444 let (_, text) = h
445 .signed_post("/internal/threads", &thread_body.to_string())
446 .await;
447 let resp: serde_json::Value = serde_json::from_str(&text).unwrap();
448 let thread_id = resp["thread_id"].as_str().unwrap();
449
450 // Create a reply
451 let reply_body = serde_json::json!({
452 "body_markdown": "This is a reply via internal API",
453 "author_mnw_id": owner_id,
454 "author_username": "postuser",
455 "author_display_name": "Post User",
456 "external_ref": "mnw:post:reply-1"
457 });
458 let (status, text) = h
459 .signed_post(
460 &format!("/internal/threads/{thread_id}/posts"),
461 &reply_body.to_string(),
462 )
463 .await;
464 assert_eq!(status, StatusCode::OK, "body: {text}");
465
466 let post_resp: serde_json::Value = serde_json::from_str(&text).unwrap();
467 let first_post_id = post_resp["post_id"].as_str().unwrap().to_string();
468 assert!(
469 post_resp["created"].as_bool().unwrap(),
470 "first call creates"
471 );
472
473 // Verify thread now has 2 posts
474 let (_, stats_text) = h.get(&format!("/internal/threads/{thread_id}/stats")).await;
475 let stats: serde_json::Value = serde_json::from_str(&stats_text).unwrap();
476 assert_eq!(stats["post_count"].as_i64().unwrap(), 2);
477
478 // Replay the same external_ref (a retried/replayed call): same post id,
479 // created=false, and the post count does not grow (audit E1).
480 let (status, text2) = h
481 .signed_post(
482 &format!("/internal/threads/{thread_id}/posts"),
483 &reply_body.to_string(),
484 )
485 .await;
486 assert_eq!(status, StatusCode::OK, "body: {text2}");
487 let replay: serde_json::Value = serde_json::from_str(&text2).unwrap();
488 assert_eq!(
489 replay["post_id"].as_str().unwrap(),
490 first_post_id,
491 "replay returns original"
492 );
493 assert!(
494 !replay["created"].as_bool().unwrap(),
495 "replay does not create"
496 );
497 let (_, stats_text2) = h.get(&format!("/internal/threads/{thread_id}/stats")).await;
498 let stats2: serde_json::Value = serde_json::from_str(&stats_text2).unwrap();
499 assert_eq!(
500 stats2["post_count"].as_i64().unwrap(),
501 2,
502 "no duplicate reply"
503 );
504 }
505
506 #[tokio::test]
507 async fn create_post_nonexistent_thread() {
508 let h = InternalTestHarness::new().await;
509 let fake_id = Uuid::new_v4();
510
511 let body = serde_json::json!({
512 "body_markdown": "Reply to nothing",
513 "author_mnw_id": Uuid::new_v4(),
514 "author_username": "nobody",
515 "external_ref": "mnw:post:orphan-reply",
516 });
517 let (status, _) = h
518 .signed_post(
519 &format!("/internal/threads/{fake_id}/posts"),
520 &body.to_string(),
521 )
522 .await;
523 assert_eq!(status, StatusCode::NOT_FOUND);
524 }
525
526 // Auto-create category tests
527
528 #[tokio::test]
529 async fn create_thread_auto_creates_category() {
530 let h = InternalTestHarness::new().await;
531 let owner_id = Uuid::new_v4();
532
533 // Create community (has 4 default categories)
534 let comm_body = serde_json::json!({
535 "name": "Autocat Project",
536 "slug": "autocat-project",
537 "owner_mnw_id": owner_id,
538 "owner_username": "autocatuser",
539 });
540 let (status, _) = h
541 .signed_post("/internal/communities", &comm_body.to_string())
542 .await;
543 assert_eq!(status, StatusCode::OK);
544
545 // Create thread with a non-existent category slug, should auto-create it.
546 // Use "releases" (not a default) since "issues"/"patches" are now seeded.
547 let thread_body = serde_json::json!({
548 "community_slug": "autocat-project",
549 "category_slug": "releases",
550 "title": "v1.0 release notes",
551 "body_markdown": "First release",
552 "author_mnw_id": owner_id,
553 "author_username": "autocatuser",
554 "external_ref": "mnw:release:autocat-test"
555 });
556 let (status, text) = h
557 .signed_post("/internal/threads", &thread_body.to_string())
558 .await;
559 assert_eq!(status, StatusCode::OK, "body: {text}");
560
561 let resp: serde_json::Value = serde_json::from_str(&text).unwrap();
562 assert!(resp["created"].as_bool().unwrap());
563
564 // Verify "releases" category was auto-created
565 let comm_resp: serde_json::Value = serde_json::from_str(
566 &h.signed_post("/internal/communities", &comm_body.to_string())
567 .await
568 .1,
569 )
570 .unwrap();
571 let community_id: Uuid = comm_resp["community_id"].as_str().unwrap().parse().unwrap();
572
573 let categories: Vec<(String,)> =
574 sqlx::query_as("SELECT slug FROM categories WHERE community_id = $1 ORDER BY sort_order")
575 .bind(community_id)
576 .fetch_all(&h.db)
577 .await
578 .unwrap();
579
580 let slugs: Vec<&str> = categories.iter().map(|c| c.0.as_str()).collect();
581 assert!(
582 slugs.contains(&"releases"),
583 "Expected 'releases' category, got: {slugs:?}"
584 );
585 assert_eq!(categories.len(), 7); // 6 default + 1 auto-created
586 }
587
588 // --- v2 signing: method/path binding + nonce replay protection
589
590 fn community_body(slug: &str) -> String {
591 serde_json::json!({
592 "name": "Replay Project",
593 "slug": slug,
594 "description": null,
595 "owner_mnw_id": Uuid::new_v4(),
596 "owner_username": "replayowner",
597 "owner_display_name": null,
598 })
599 .to_string()
600 }
601
602 #[tokio::test]
603 async fn replayed_nonce_is_rejected() {
604 let h = InternalTestHarness::new().await;
605 let body = community_body("replay-proj");
606 let nonce = Uuid::new_v4().simple().to_string();
607
608 // First use of the nonce succeeds.
609 let (status, text) = h
610 .send_signed(
611 Method::POST,
612 "/internal/communities",
613 "/internal/communities",
614 &nonce,
615 &body,
616 )
617 .await;
618 assert_eq!(
619 status,
620 StatusCode::OK,
621 "first request should succeed: {text}"
622 );
623
624 // Replaying the exact same signed request (same nonce) is rejected, even
625 // though create_community is otherwise idempotent.
626 let (status, text) = h
627 .send_signed(
628 Method::POST,
629 "/internal/communities",
630 "/internal/communities",
631 &nonce,
632 &body,
633 )
634 .await;
635 assert_eq!(
636 status,
637 StatusCode::UNAUTHORIZED,
638 "replayed nonce must be rejected: {text}"
639 );
640 }
641
642 #[tokio::test]
643 async fn signature_bound_to_path() {
644 let h = InternalTestHarness::new().await;
645 let body = community_body("wrongpath-proj");
646 let nonce = Uuid::new_v4().simple().to_string();
647
648 // Sign for a different path than the request is sent to → signature mismatch.
649 let (status, _text) = h
650 .send_signed(
651 Method::POST,
652 "/internal/communities",
653 "/internal/threads",
654 &nonce,
655 &body,
656 )
657 .await;
658 assert_eq!(
659 status,
660 StatusCode::UNAUTHORIZED,
661 "path-mismatched signature must be rejected"
662 );
663 }
664
665 #[tokio::test]
666 async fn signature_bound_to_method() {
667 let h = InternalTestHarness::new().await;
668 let nonce = Uuid::new_v4().simple().to_string();
669
670 // Sign as GET but the stats endpoint is reached via GET with a POST-signed
671 // message → mismatch. (Sign method "POST", send via GET.)
672 let id = Uuid::new_v4();
673 let uri = format!("/internal/threads/{id}/stats");
674 let timestamp = chrono::Utc::now().timestamp().to_string();
675 let mut mac = Hmac::<Sha256>::new_from_slice(TEST_SECRET.as_bytes()).expect("HMAC key");
676 for field in [timestamp.as_str(), "POST", uri.as_str(), nonce.as_str()] {
677 mac.update(field.as_bytes());
678 mac.update(b"\n");
679 }
680 let signature = hex::encode(mac.finalize().into_bytes());
681
682 let mut request = Request::builder()
683 .method(Method::GET)
684 .uri(&uri)
685 .header("X-Internal-Timestamp", &timestamp)
686 .header("X-Internal-Signature", &signature)
687 .header("X-Internal-Nonce", &nonce)
688 .body(Body::empty())
689 .expect("build request");
690 request
691 .extensions_mut()
692 .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0))));
693 let response = h.app.clone().oneshot(request).await.expect("send");
694 assert_eq!(
695 response.status(),
696 StatusCode::UNAUTHORIZED,
697 "method-mismatched signature must be rejected"
698 );
699 }
700