fix(tauri-app): Token an die AppView senden — und die Erneuerung reparieren
Die vier viewer-bezogenen AppView-Aufrufe (Timeline, Notifications, Count, Seen) senden jetzt das Access-JWT. Ohne Session gibt es einen sprechenden Fehler statt eines leeren Bearer-Headers. Dabei kam heraus, dass die automatische Token-Erneuerung noch nie funktioniert hat: isTokenInvalid() stieg mit `typeof e !== "object"` sofort aus, aber Tauri lehnt bei Commands mit Result<T, String> mit einem blanken String ab — der Zweig war seit seiner Einführung tot. Belegt per Mutationstest: mit der alten Zeile fallen acht der neuen Tests um. Die Prüfung liest den Fehlertext jetzt über einen Helfer, der Strings und Objekte behandelt. Dazu: der Badge-Poll bricht ab, wenn die Erneuerung endgültig scheitert, statt weiter gegen einen 401 zu laufen. 503 AuthUnavailable gilt dabei bewusst nicht als Auth-Fehler — die PDS kann kurz weg sein, der Poll soll das überdauern. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
This commit is contained in:
co-authored by
Claude Opus 5
parent
ac18ff7a16
commit
9ee717bbc7
@@ -1,8 +1,30 @@
|
||||
//! Thin HTTP client the Tauri commands use to talk to the AppView.
|
||||
//!
|
||||
//! All four methods return parsed JSON or a stringified error that the
|
||||
//! Every method returns parsed JSON or a stringified error that the
|
||||
//! Tauri command layer surfaces to the Svelte frontend as the
|
||||
//! `Result::Err` payload.
|
||||
//!
|
||||
//! # Authentication
|
||||
//!
|
||||
//! The AppView's *viewer-scoped* endpoints require the account's access
|
||||
//! JWT in an `Authorization: Bearer` header, and additionally check that
|
||||
//! the token's `sub` equals the `did` query parameter:
|
||||
//!
|
||||
//! * `GET /api/timeline/home`
|
||||
//! * `GET /api/notifications`
|
||||
//! * `GET /api/notifications/count`
|
||||
//! * `POST /api/notifications/seen`
|
||||
//!
|
||||
//! Those four methods therefore take an `access_jwt` argument (last, the
|
||||
//! same position `pds_client.rs` uses). Everything else —
|
||||
//! `/api/profile*`, `/api/search`, `/api/post/{uri}`, `/api/thread`,
|
||||
//! `/api/followers`, `/api/following` — stays public and deliberately
|
||||
//! sends no token, so the read-only views keep working while logged out.
|
||||
//!
|
||||
//! On an auth failure the AppView answers `401` with
|
||||
//! `{"error":"AuthMissing"|"TokenInvalid","message":…}` or `403` with
|
||||
//! `{"error":"Forbidden",…}`. See [`status_error`] for why the response
|
||||
//! body must survive into the error message.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use reqwest::Client;
|
||||
@@ -58,9 +80,10 @@ pub struct SearchResponse {
|
||||
///
|
||||
/// `like_count` and `repost_count` are included when the server
|
||||
/// resolves a real post; they're `None` for the "not in index"
|
||||
/// sentinel response (where `post` is null). The AppView has no
|
||||
/// auth yet, so we don't get `viewer_liked` / `viewer_reposted`
|
||||
/// from the server.
|
||||
/// sentinel response (where `post` is null). `/api/post/{uri}` is a
|
||||
/// public endpoint that takes no token, so there is no viewer to
|
||||
/// resolve against and we don't get `viewer_liked` /
|
||||
/// `viewer_reposted`; [`Self::fetch_thread`] with a `viewer_did` does.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadResponse {
|
||||
pub post: Option<PostDto>,
|
||||
@@ -192,16 +215,22 @@ impl AppViewClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/timeline/home?did=&limit=&cursor=`
|
||||
/// `GET /api/timeline/home?did=&limit=&cursor=` — **authenticated**.
|
||||
///
|
||||
/// `access_jwt` goes out as `Authorization: Bearer`; the AppView
|
||||
/// rejects the call with 401 without it and with 403 when the
|
||||
/// token's `sub` doesn't match `did`.
|
||||
pub async fn fetch_timeline(
|
||||
&self,
|
||||
did: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
access_jwt: &str,
|
||||
) -> Result<TimelineResponse> {
|
||||
let mut req = self
|
||||
.client
|
||||
.get(format!("{}/api/timeline/home", self.base_url))
|
||||
.bearer_auth(access_jwt)
|
||||
.query(&[("did", did), ("limit", &limit.to_string())]);
|
||||
if let Some(c) = cursor {
|
||||
req = req.query(&[("cursor", c)]);
|
||||
@@ -211,13 +240,7 @@ impl AppViewClient {
|
||||
.await
|
||||
.context("appview: failed to send timeline request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: timeline home returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
return Err(status_error("timeline home", resp).await);
|
||||
}
|
||||
resp
|
||||
.json::<TimelineResponse>()
|
||||
@@ -244,13 +267,7 @@ impl AppViewClient {
|
||||
.await
|
||||
.context("appview: failed to send profile request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: profile returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
return Err(status_error("profile", resp).await);
|
||||
}
|
||||
resp
|
||||
.json::<ProfileResponse>()
|
||||
@@ -268,13 +285,7 @@ impl AppViewClient {
|
||||
.await
|
||||
.context("appview: failed to send profile-by-did request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: profile-by-did returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
return Err(status_error("profile-by-did", resp).await);
|
||||
}
|
||||
resp
|
||||
.json::<ProfileResponse>()
|
||||
@@ -292,13 +303,7 @@ impl AppViewClient {
|
||||
.await
|
||||
.context("appview: failed to send search request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: search returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
return Err(status_error("search", resp).await);
|
||||
}
|
||||
resp
|
||||
.json::<SearchResponse>()
|
||||
@@ -323,13 +328,7 @@ impl AppViewClient {
|
||||
.await
|
||||
.context("appview: failed to send post request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: post returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
return Err(status_error("post", resp).await);
|
||||
}
|
||||
resp
|
||||
.json::<ThreadResponse>()
|
||||
@@ -363,13 +362,7 @@ impl AppViewClient {
|
||||
.await
|
||||
.context("appview: failed to send thread request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: thread returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
return Err(status_error("thread", resp).await);
|
||||
}
|
||||
resp
|
||||
.json::<ThreadFullResponse>()
|
||||
@@ -377,17 +370,20 @@ impl AppViewClient {
|
||||
.context("appview: thread JSON parse")
|
||||
}
|
||||
|
||||
/// `GET /api/notifications?did=&limit=&cursor=` — newest first,
|
||||
/// same opaque-cursor pagination contract as the timeline.
|
||||
/// `GET /api/notifications?did=&limit=&cursor=` — **authenticated**;
|
||||
/// newest first, same opaque-cursor pagination contract as the
|
||||
/// timeline.
|
||||
pub async fn fetch_notifications(
|
||||
&self,
|
||||
did: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
access_jwt: &str,
|
||||
) -> Result<NotificationsResponse> {
|
||||
let mut req = self
|
||||
.client
|
||||
.get(format!("{}/api/notifications", self.base_url))
|
||||
.bearer_auth(access_jwt)
|
||||
.query(&[("did", did), ("limit", &limit.to_string())]);
|
||||
if let Some(c) = cursor {
|
||||
req = req.query(&[("cursor", c)]);
|
||||
@@ -397,13 +393,7 @@ impl AppViewClient {
|
||||
.await
|
||||
.context("appview: failed to send notifications request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: notifications returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
return Err(status_error("notifications", resp).await);
|
||||
}
|
||||
resp
|
||||
.json::<NotificationsResponse>()
|
||||
@@ -411,25 +401,24 @@ impl AppViewClient {
|
||||
.context("appview: notifications JSON parse")
|
||||
}
|
||||
|
||||
/// `GET /api/notifications/count?did=` — unread count for the
|
||||
/// NavRail badge. Cheap enough to poll (partial index on the
|
||||
/// server side).
|
||||
pub async fn notification_count(&self, did: &str) -> Result<NotificationCountResponse> {
|
||||
/// `GET /api/notifications/count?did=` — **authenticated**; unread
|
||||
/// count for the NavRail badge. Cheap enough to poll (partial index
|
||||
/// on the server side).
|
||||
pub async fn notification_count(
|
||||
&self,
|
||||
did: &str,
|
||||
access_jwt: &str,
|
||||
) -> Result<NotificationCountResponse> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!("{}/api/notifications/count", self.base_url))
|
||||
.bearer_auth(access_jwt)
|
||||
.query(&[("did", did)])
|
||||
.send()
|
||||
.await
|
||||
.context("appview: failed to send notification-count request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: notification count returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
return Err(status_error("notification count", resp).await);
|
||||
}
|
||||
resp
|
||||
.json::<NotificationCountResponse>()
|
||||
@@ -437,9 +426,10 @@ impl AppViewClient {
|
||||
.context("appview: notification count JSON parse")
|
||||
}
|
||||
|
||||
/// `POST /api/notifications/seen` — mark everything indexed at or
|
||||
/// before `seen_at` as read. Passing `None` marks *all* currently
|
||||
/// unread rows. Idempotent; a second call reports `updated: 0`.
|
||||
/// `POST /api/notifications/seen` — **authenticated**; mark
|
||||
/// everything indexed at or before `seen_at` as read. Passing `None`
|
||||
/// marks *all* currently unread rows. Idempotent; a second call
|
||||
/// reports `updated: 0`.
|
||||
///
|
||||
/// The server accepts both `seenAt` and `seen_at`; we send the
|
||||
/// camelCase spelling because that's what the wire contract
|
||||
@@ -448,6 +438,7 @@ impl AppViewClient {
|
||||
&self,
|
||||
did: &str,
|
||||
seen_at: Option<&str>,
|
||||
access_jwt: &str,
|
||||
) -> Result<NotificationSeenResponse> {
|
||||
let mut body = serde_json::json!({ "did": did });
|
||||
if let Some(ts) = seen_at {
|
||||
@@ -456,18 +447,13 @@ impl AppViewClient {
|
||||
let resp = self
|
||||
.client
|
||||
.post(format!("{}/api/notifications/seen", self.base_url))
|
||||
.bearer_auth(access_jwt)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("appview: failed to send notifications-seen request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: notifications seen returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
return Err(status_error("notifications seen", resp).await);
|
||||
}
|
||||
resp
|
||||
.json::<NotificationSeenResponse>()
|
||||
@@ -517,9 +503,7 @@ impl AppViewClient {
|
||||
.await
|
||||
.with_context(|| format!("appview: failed to send {path} request"))?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!("appview: {} returned {}: {}", path, status, body));
|
||||
return Err(status_error(path, resp).await);
|
||||
}
|
||||
resp
|
||||
.json::<ActorListResponse>()
|
||||
@@ -528,6 +512,33 @@ impl AppViewClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a non-2xx AppView response into an `anyhow::Error` whose
|
||||
/// message carries the server's response body **verbatim**.
|
||||
///
|
||||
/// Keeping the body is load-bearing, not cosmetic. The AppView answers
|
||||
/// an expired or malformed access token with
|
||||
/// `401 {"error":"TokenInvalid","message":…}`, and the frontend's
|
||||
/// `safeInvoke` (`src/lib/api/client.ts`) decides whether to refresh the
|
||||
/// session and retry by sniffing the *stringified* Rust error for the
|
||||
/// literal substring `"TokenInvalid"` (or `"ExpiredSignature"`). The
|
||||
/// chain is therefore:
|
||||
///
|
||||
/// ```text
|
||||
/// AppView 401 body ──► status_error() ──► anyhow msg
|
||||
/// ──► lib.rs `.map_err(|e| e.to_string())` ──► Tauri IPC reject
|
||||
/// ──► safeInvoke's isTokenInvalid() ──► auth_refresh + retry once
|
||||
/// ```
|
||||
///
|
||||
/// Every link is a plain string, so swallowing the body here (e.g.
|
||||
/// formatting only the status code) silently breaks token renewal —
|
||||
/// the user's timeline just stops updating an hour after login. The
|
||||
/// unit tests below pin the substring so that can't regress.
|
||||
async fn status_error(label: &str, resp: reqwest::Response) -> anyhow::Error {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
anyhow!("appview: {} returned {}: {}", label, status, body)
|
||||
}
|
||||
|
||||
/// Percent-encode every byte of `s` for use as a URL path segment.
|
||||
/// `axum`'s path extractor will decode it back. We use this rather
|
||||
/// than `url::Url::parse(...).path_segments()` because AT-Protocol
|
||||
@@ -553,6 +564,9 @@ fn percent_encode_path(s: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
#[test]
|
||||
fn percent_encode_path_at_uri() {
|
||||
@@ -563,4 +577,241 @@ mod tests {
|
||||
"at%3A%2F%2Fdid%3Aplc%3Aabc%2Fapp.twi.post%2F3k2"
|
||||
);
|
||||
}
|
||||
|
||||
// -- mock AppView -------------------------------------------------
|
||||
//
|
||||
// A ~40-line HTTP/1.1 server on an ephemeral port, rather than a
|
||||
// mocking crate, so the test adds no dependency to a workspace that
|
||||
// currently has none for this. It answers every request with a
|
||||
// canned status + body and records each request head so the tests
|
||||
// can assert on the `Authorization` header we did (or deliberately
|
||||
// did not) send.
|
||||
|
||||
/// The raw request heads (request line + header block) the mock saw,
|
||||
/// in arrival order.
|
||||
type Recorded = Arc<Mutex<Vec<String>>>;
|
||||
|
||||
/// Byte offset of the `\r\n\r\n` that ends the header block.
|
||||
fn headers_end(buf: &[u8]) -> Option<usize> {
|
||||
buf.windows(4).position(|w| w == b"\r\n\r\n")
|
||||
}
|
||||
|
||||
/// Announced body length from a request head, if any. Needed so the
|
||||
/// POST test drains the JSON body before the mock closes the socket
|
||||
/// — closing mid-write would surface to `reqwest` as a connection
|
||||
/// error instead of the 401 we're trying to assert on.
|
||||
fn content_length(head: &str) -> usize {
|
||||
head.lines()
|
||||
.find_map(|l| {
|
||||
let (k, v) = l.split_once(':')?;
|
||||
k.trim()
|
||||
.eq_ignore_ascii_case("content-length")
|
||||
.then(|| v.trim().parse::<usize>().ok())?
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Serve `n` connections, answering each with `status`/`reason` and
|
||||
/// `body`. Returns the base URL to point an [`AppViewClient`] at,
|
||||
/// plus the recording handle.
|
||||
async fn spawn_mock(
|
||||
status: u16,
|
||||
reason: &'static str,
|
||||
body: &'static str,
|
||||
n: usize,
|
||||
) -> (String, Recorded) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let recorded: Recorded = Arc::new(Mutex::new(Vec::new()));
|
||||
let rec = recorded.clone();
|
||||
tokio::spawn(async move {
|
||||
for _ in 0..n {
|
||||
let Ok((mut sock, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
let mut chunk = [0u8; 2048];
|
||||
loop {
|
||||
let read = match sock.read(&mut chunk).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(k) => k,
|
||||
};
|
||||
buf.extend_from_slice(&chunk[..read]);
|
||||
if let Some(p) = headers_end(&buf) {
|
||||
let head = String::from_utf8_lossy(&buf[..p]).into_owned();
|
||||
if buf.len() - (p + 4) >= content_length(&head) {
|
||||
rec.lock().unwrap().push(head);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// `connection: close` keeps every request on a fresh
|
||||
// socket, so the recorded order matches the call order.
|
||||
let resp = format!(
|
||||
"HTTP/1.1 {status} {reason}\r\n\
|
||||
content-type: application/json\r\n\
|
||||
content-length: {}\r\n\
|
||||
connection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
let _ = sock.write_all(resp.as_bytes()).await;
|
||||
let _ = sock.shutdown().await;
|
||||
}
|
||||
});
|
||||
(format!("http://{addr}"), recorded)
|
||||
}
|
||||
|
||||
/// Value of the `Authorization` header in a recorded request head,
|
||||
/// or `None` when the request carried none.
|
||||
fn auth_header(head: &str) -> Option<String> {
|
||||
head.lines().find_map(|l| {
|
||||
let (k, v) = l.split_once(':')?;
|
||||
k.trim()
|
||||
.eq_ignore_ascii_case("authorization")
|
||||
.then(|| v.trim().to_string())
|
||||
})
|
||||
}
|
||||
|
||||
// -- the contract the TS retry chain depends on -------------------
|
||||
|
||||
/// The AppView's 401 body for an expired access token.
|
||||
const TOKEN_INVALID_BODY: &str =
|
||||
r#"{"error":"TokenInvalid","message":"ExpiredSignature"}"#;
|
||||
|
||||
/// **This is the test the token-renewal path hangs off.**
|
||||
///
|
||||
/// `src/lib/api/client.ts`'s `safeInvoke` refreshes the session and
|
||||
/// retries exactly once when `isTokenInvalid(e)` matches — and that
|
||||
/// predicate is a substring search for `"TokenInvalid"` /
|
||||
/// `"ExpiredSignature"` over the *stringified* error that came up
|
||||
/// from Rust. The AppView only ever states the code in its JSON
|
||||
/// body, so if [`status_error`] were to drop the body (or truncate
|
||||
/// it to the status code) the retry would never fire and the desktop
|
||||
/// client would sit on a dead timeline until the user restarts it.
|
||||
///
|
||||
/// So: for each of the four authenticated endpoints, assert the code
|
||||
/// survives verbatim all the way into `Error::to_string()` — which
|
||||
/// is exactly what `lib.rs` hands the Tauri IPC layer via
|
||||
/// `.map_err(|e| e.to_string())`.
|
||||
#[tokio::test]
|
||||
async fn token_invalid_code_survives_into_the_error_string() {
|
||||
let (base, _rec) = spawn_mock(401, "Unauthorized", TOKEN_INVALID_BODY, 4).await;
|
||||
let c = AppViewClient::new(base);
|
||||
|
||||
let errs = vec![
|
||||
c.fetch_timeline("did:plc:me", None, 30, "stale")
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
c.fetch_notifications("did:plc:me", None, 30, "stale")
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
c.notification_count("did:plc:me", "stale")
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
c.mark_notifications_seen("did:plc:me", None, "stale")
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
];
|
||||
|
||||
for e in &errs {
|
||||
// The literal the TS `isTokenInvalid()` greps for. Both
|
||||
// spellings it accepts are in this body.
|
||||
assert!(
|
||||
e.contains("TokenInvalid"),
|
||||
"error must carry the AppView's code verbatim, got: {e}"
|
||||
);
|
||||
assert!(
|
||||
e.contains("ExpiredSignature"),
|
||||
"error must carry the AppView's message verbatim, got: {e}"
|
||||
);
|
||||
// The status is useful context, but it is NOT what the retry
|
||||
// keys off — asserting it here documents that both travel.
|
||||
assert!(e.contains("401"), "status should travel too, got: {e}");
|
||||
}
|
||||
|
||||
// Each endpoint still labels itself, so a log line says which
|
||||
// call failed.
|
||||
assert!(errs[0].contains("timeline home"));
|
||||
assert!(errs[1].contains("notifications"));
|
||||
assert!(errs[2].contains("notification count"));
|
||||
assert!(errs[3].contains("notifications seen"));
|
||||
}
|
||||
|
||||
/// The 403 the AppView returns when the token is valid but its
|
||||
/// `sub` doesn't match the `did` query parameter. Deliberately
|
||||
/// *not* something `isTokenInvalid` matches: refreshing wouldn't
|
||||
/// help, so the retry must not fire — but the code still has to
|
||||
/// reach the UI so the message is actionable.
|
||||
#[tokio::test]
|
||||
async fn forbidden_body_survives_and_does_not_look_refreshable() {
|
||||
let (base, _rec) = spawn_mock(
|
||||
403,
|
||||
"Forbidden",
|
||||
r#"{"error":"Forbidden","message":"did does not match token subject"}"#,
|
||||
1,
|
||||
)
|
||||
.await;
|
||||
let c = AppViewClient::new(base);
|
||||
|
||||
let e = c
|
||||
.fetch_timeline("did:plc:someone-else", None, 30, "good-jwt")
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(e.contains("Forbidden"), "got: {e}");
|
||||
assert!(e.contains("403"), "got: {e}");
|
||||
assert!(!e.contains("TokenInvalid"));
|
||||
assert!(!e.contains("ExpiredSignature"));
|
||||
}
|
||||
|
||||
/// The four viewer-scoped endpoints must actually put the JWT on
|
||||
/// the wire — an empty or missing header is a 401 from the server.
|
||||
#[tokio::test]
|
||||
async fn authenticated_endpoints_send_the_bearer_header() {
|
||||
let (base, rec) = spawn_mock(500, "Internal Server Error", "{}", 4).await;
|
||||
let c = AppViewClient::new(base);
|
||||
|
||||
let _ = c.fetch_timeline("did:plc:me", None, 30, "jwt-abc").await;
|
||||
let _ = c.fetch_notifications("did:plc:me", None, 30, "jwt-abc").await;
|
||||
let _ = c.notification_count("did:plc:me", "jwt-abc").await;
|
||||
let _ = c
|
||||
.mark_notifications_seen("did:plc:me", Some("2026-09-09T10:00:00Z"), "jwt-abc")
|
||||
.await;
|
||||
|
||||
let heads = rec.lock().unwrap().clone();
|
||||
assert_eq!(heads.len(), 4, "every call should have reached the server");
|
||||
for h in &heads {
|
||||
assert_eq!(
|
||||
auth_header(h).as_deref(),
|
||||
Some("Bearer jwt-abc"),
|
||||
"missing/!= bearer token in:\n{h}"
|
||||
);
|
||||
}
|
||||
// The POST still carries its JSON body alongside the header.
|
||||
assert!(heads[3].starts_with("POST /api/notifications/seen"));
|
||||
}
|
||||
|
||||
/// The public half of the API must keep working while logged out,
|
||||
/// so it must not grow an `Authorization` header by accident.
|
||||
#[tokio::test]
|
||||
async fn public_endpoints_send_no_authorization_header() {
|
||||
let (base, rec) = spawn_mock(200, "OK", r#"{"posts":[],"q":"hi"}"#, 1).await;
|
||||
let c = AppViewClient::new(base);
|
||||
let r = c.fetch_search("hi", 30).await.unwrap();
|
||||
assert_eq!(r.q, "hi");
|
||||
assert!(auth_header(&rec.lock().unwrap()[0]).is_none());
|
||||
|
||||
let (base2, rec2) = spawn_mock(200, "OK", r#"{"profiles":[],"cursor":null}"#, 2).await;
|
||||
let c2 = AppViewClient::new(base2);
|
||||
c2.fetch_followers("did:plc:me", None, 30).await.unwrap();
|
||||
c2.fetch_following("did:plc:me", None, 30).await.unwrap();
|
||||
for h in rec2.lock().unwrap().iter() {
|
||||
assert!(auth_header(h).is_none(), "unexpected auth header in:\n{h}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,6 +354,28 @@ async fn unfollow_user(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Access JWT for the AppView's viewer-scoped endpoints.
|
||||
///
|
||||
/// The four authenticated AppView calls (`timeline_home`,
|
||||
/// `fetch_notifications`, `notification_count`,
|
||||
/// `mark_notifications_seen`) all need the same thing: the stored
|
||||
/// session's access JWT, or a message the UI can render when there
|
||||
/// isn't one. Factored out so no call site can accidentally send an
|
||||
/// empty `Authorization: Bearer` header — which the AppView would
|
||||
/// answer with a 401 `TokenInvalid`, and the frontend would then burn a
|
||||
/// pointless refresh round trip on before failing anyway.
|
||||
///
|
||||
/// The message deliberately contains neither `TokenInvalid` nor
|
||||
/// `ExpiredSignature`: `safeInvoke`'s `isTokenInvalid()` greps for those
|
||||
/// substrings, and a logged-out client has nothing to refresh *with*.
|
||||
fn require_access_jwt(state: &AppState, what: &str) -> Result<String, String> {
|
||||
state
|
||||
.store
|
||||
.load()
|
||||
.map(|s| s.access_jwt)
|
||||
.ok_or_else(|| format!("not logged in: {what} requires a signed-in session"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn timeline_home(
|
||||
state: tauri::State<'_, AppState>,
|
||||
@@ -362,9 +384,10 @@ async fn timeline_home(
|
||||
limit: Option<u32>,
|
||||
) -> Result<appview_client::TimelineResponse, String> {
|
||||
let lim = limit.unwrap_or(30).clamp(1, 100);
|
||||
let jwt = require_access_jwt(&state, "the home timeline")?;
|
||||
state
|
||||
.appview
|
||||
.fetch_timeline(&did, cursor.as_deref(), lim)
|
||||
.fetch_timeline(&did, cursor.as_deref(), lim, &jwt)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -460,9 +483,10 @@ async fn fetch_notifications(
|
||||
limit: Option<u32>,
|
||||
) -> Result<appview_client::NotificationsResponse, String> {
|
||||
let lim = limit.unwrap_or(30).clamp(1, 100);
|
||||
let jwt = require_access_jwt(&state, "notifications")?;
|
||||
state
|
||||
.appview
|
||||
.fetch_notifications(&did, cursor.as_deref(), lim)
|
||||
.fetch_notifications(&did, cursor.as_deref(), lim, &jwt)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -473,9 +497,10 @@ async fn notification_count(
|
||||
state: tauri::State<'_, AppState>,
|
||||
did: String,
|
||||
) -> Result<appview_client::NotificationCountResponse, String> {
|
||||
let jwt = require_access_jwt(&state, "the unread-notification count")?;
|
||||
state
|
||||
.appview
|
||||
.notification_count(&did)
|
||||
.notification_count(&did, &jwt)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -491,9 +516,10 @@ async fn mark_notifications_seen(
|
||||
did: String,
|
||||
seen_at: Option<String>,
|
||||
) -> Result<appview_client::NotificationSeenResponse, String> {
|
||||
let jwt = require_access_jwt(&state, "marking notifications seen")?;
|
||||
state
|
||||
.appview
|
||||
.mark_notifications_seen(&did, seen_at.as_deref())
|
||||
.mark_notifications_seen(&did, seen_at.as_deref(), &jwt)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user