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.
|
//! 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
|
//! Tauri command layer surfaces to the Svelte frontend as the
|
||||||
//! `Result::Err` payload.
|
//! `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 anyhow::{anyhow, Context, Result};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
@@ -58,9 +80,10 @@ pub struct SearchResponse {
|
|||||||
///
|
///
|
||||||
/// `like_count` and `repost_count` are included when the server
|
/// `like_count` and `repost_count` are included when the server
|
||||||
/// resolves a real post; they're `None` for the "not in index"
|
/// resolves a real post; they're `None` for the "not in index"
|
||||||
/// sentinel response (where `post` is null). The AppView has no
|
/// sentinel response (where `post` is null). `/api/post/{uri}` is a
|
||||||
/// auth yet, so we don't get `viewer_liked` / `viewer_reposted`
|
/// public endpoint that takes no token, so there is no viewer to
|
||||||
/// from the server.
|
/// resolve against and we don't get `viewer_liked` /
|
||||||
|
/// `viewer_reposted`; [`Self::fetch_thread`] with a `viewer_did` does.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ThreadResponse {
|
pub struct ThreadResponse {
|
||||||
pub post: Option<PostDto>,
|
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(
|
pub async fn fetch_timeline(
|
||||||
&self,
|
&self,
|
||||||
did: &str,
|
did: &str,
|
||||||
cursor: Option<&str>,
|
cursor: Option<&str>,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
|
access_jwt: &str,
|
||||||
) -> Result<TimelineResponse> {
|
) -> Result<TimelineResponse> {
|
||||||
let mut req = self
|
let mut req = self
|
||||||
.client
|
.client
|
||||||
.get(format!("{}/api/timeline/home", self.base_url))
|
.get(format!("{}/api/timeline/home", self.base_url))
|
||||||
|
.bearer_auth(access_jwt)
|
||||||
.query(&[("did", did), ("limit", &limit.to_string())]);
|
.query(&[("did", did), ("limit", &limit.to_string())]);
|
||||||
if let Some(c) = cursor {
|
if let Some(c) = cursor {
|
||||||
req = req.query(&[("cursor", c)]);
|
req = req.query(&[("cursor", c)]);
|
||||||
@@ -211,13 +240,7 @@ impl AppViewClient {
|
|||||||
.await
|
.await
|
||||||
.context("appview: failed to send timeline request")?;
|
.context("appview: failed to send timeline request")?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let status = resp.status();
|
return Err(status_error("timeline home", resp).await);
|
||||||
let body = resp.text().await.unwrap_or_default();
|
|
||||||
return Err(anyhow!(
|
|
||||||
"appview: timeline home returned {}: {}",
|
|
||||||
status,
|
|
||||||
body
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
resp
|
resp
|
||||||
.json::<TimelineResponse>()
|
.json::<TimelineResponse>()
|
||||||
@@ -244,13 +267,7 @@ impl AppViewClient {
|
|||||||
.await
|
.await
|
||||||
.context("appview: failed to send profile request")?;
|
.context("appview: failed to send profile request")?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let status = resp.status();
|
return Err(status_error("profile", resp).await);
|
||||||
let body = resp.text().await.unwrap_or_default();
|
|
||||||
return Err(anyhow!(
|
|
||||||
"appview: profile returned {}: {}",
|
|
||||||
status,
|
|
||||||
body
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
resp
|
resp
|
||||||
.json::<ProfileResponse>()
|
.json::<ProfileResponse>()
|
||||||
@@ -268,13 +285,7 @@ impl AppViewClient {
|
|||||||
.await
|
.await
|
||||||
.context("appview: failed to send profile-by-did request")?;
|
.context("appview: failed to send profile-by-did request")?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let status = resp.status();
|
return Err(status_error("profile-by-did", resp).await);
|
||||||
let body = resp.text().await.unwrap_or_default();
|
|
||||||
return Err(anyhow!(
|
|
||||||
"appview: profile-by-did returned {}: {}",
|
|
||||||
status,
|
|
||||||
body
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
resp
|
resp
|
||||||
.json::<ProfileResponse>()
|
.json::<ProfileResponse>()
|
||||||
@@ -292,13 +303,7 @@ impl AppViewClient {
|
|||||||
.await
|
.await
|
||||||
.context("appview: failed to send search request")?;
|
.context("appview: failed to send search request")?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let status = resp.status();
|
return Err(status_error("search", resp).await);
|
||||||
let body = resp.text().await.unwrap_or_default();
|
|
||||||
return Err(anyhow!(
|
|
||||||
"appview: search returned {}: {}",
|
|
||||||
status,
|
|
||||||
body
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
resp
|
resp
|
||||||
.json::<SearchResponse>()
|
.json::<SearchResponse>()
|
||||||
@@ -323,13 +328,7 @@ impl AppViewClient {
|
|||||||
.await
|
.await
|
||||||
.context("appview: failed to send post request")?;
|
.context("appview: failed to send post request")?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let status = resp.status();
|
return Err(status_error("post", resp).await);
|
||||||
let body = resp.text().await.unwrap_or_default();
|
|
||||||
return Err(anyhow!(
|
|
||||||
"appview: post returned {}: {}",
|
|
||||||
status,
|
|
||||||
body
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
resp
|
resp
|
||||||
.json::<ThreadResponse>()
|
.json::<ThreadResponse>()
|
||||||
@@ -363,13 +362,7 @@ impl AppViewClient {
|
|||||||
.await
|
.await
|
||||||
.context("appview: failed to send thread request")?;
|
.context("appview: failed to send thread request")?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let status = resp.status();
|
return Err(status_error("thread", resp).await);
|
||||||
let body = resp.text().await.unwrap_or_default();
|
|
||||||
return Err(anyhow!(
|
|
||||||
"appview: thread returned {}: {}",
|
|
||||||
status,
|
|
||||||
body
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
resp
|
resp
|
||||||
.json::<ThreadFullResponse>()
|
.json::<ThreadFullResponse>()
|
||||||
@@ -377,17 +370,20 @@ impl AppViewClient {
|
|||||||
.context("appview: thread JSON parse")
|
.context("appview: thread JSON parse")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/notifications?did=&limit=&cursor=` — newest first,
|
/// `GET /api/notifications?did=&limit=&cursor=` — **authenticated**;
|
||||||
/// same opaque-cursor pagination contract as the timeline.
|
/// newest first, same opaque-cursor pagination contract as the
|
||||||
|
/// timeline.
|
||||||
pub async fn fetch_notifications(
|
pub async fn fetch_notifications(
|
||||||
&self,
|
&self,
|
||||||
did: &str,
|
did: &str,
|
||||||
cursor: Option<&str>,
|
cursor: Option<&str>,
|
||||||
limit: u32,
|
limit: u32,
|
||||||
|
access_jwt: &str,
|
||||||
) -> Result<NotificationsResponse> {
|
) -> Result<NotificationsResponse> {
|
||||||
let mut req = self
|
let mut req = self
|
||||||
.client
|
.client
|
||||||
.get(format!("{}/api/notifications", self.base_url))
|
.get(format!("{}/api/notifications", self.base_url))
|
||||||
|
.bearer_auth(access_jwt)
|
||||||
.query(&[("did", did), ("limit", &limit.to_string())]);
|
.query(&[("did", did), ("limit", &limit.to_string())]);
|
||||||
if let Some(c) = cursor {
|
if let Some(c) = cursor {
|
||||||
req = req.query(&[("cursor", c)]);
|
req = req.query(&[("cursor", c)]);
|
||||||
@@ -397,13 +393,7 @@ impl AppViewClient {
|
|||||||
.await
|
.await
|
||||||
.context("appview: failed to send notifications request")?;
|
.context("appview: failed to send notifications request")?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let status = resp.status();
|
return Err(status_error("notifications", resp).await);
|
||||||
let body = resp.text().await.unwrap_or_default();
|
|
||||||
return Err(anyhow!(
|
|
||||||
"appview: notifications returned {}: {}",
|
|
||||||
status,
|
|
||||||
body
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
resp
|
resp
|
||||||
.json::<NotificationsResponse>()
|
.json::<NotificationsResponse>()
|
||||||
@@ -411,25 +401,24 @@ impl AppViewClient {
|
|||||||
.context("appview: notifications JSON parse")
|
.context("appview: notifications JSON parse")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET /api/notifications/count?did=` — unread count for the
|
/// `GET /api/notifications/count?did=` — **authenticated**; unread
|
||||||
/// NavRail badge. Cheap enough to poll (partial index on the
|
/// count for the NavRail badge. Cheap enough to poll (partial index
|
||||||
/// server side).
|
/// on the server side).
|
||||||
pub async fn notification_count(&self, did: &str) -> Result<NotificationCountResponse> {
|
pub async fn notification_count(
|
||||||
|
&self,
|
||||||
|
did: &str,
|
||||||
|
access_jwt: &str,
|
||||||
|
) -> Result<NotificationCountResponse> {
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
.get(format!("{}/api/notifications/count", self.base_url))
|
.get(format!("{}/api/notifications/count", self.base_url))
|
||||||
|
.bearer_auth(access_jwt)
|
||||||
.query(&[("did", did)])
|
.query(&[("did", did)])
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.context("appview: failed to send notification-count request")?;
|
.context("appview: failed to send notification-count request")?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let status = resp.status();
|
return Err(status_error("notification count", resp).await);
|
||||||
let body = resp.text().await.unwrap_or_default();
|
|
||||||
return Err(anyhow!(
|
|
||||||
"appview: notification count returned {}: {}",
|
|
||||||
status,
|
|
||||||
body
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
resp
|
resp
|
||||||
.json::<NotificationCountResponse>()
|
.json::<NotificationCountResponse>()
|
||||||
@@ -437,9 +426,10 @@ impl AppViewClient {
|
|||||||
.context("appview: notification count JSON parse")
|
.context("appview: notification count JSON parse")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/notifications/seen` — mark everything indexed at or
|
/// `POST /api/notifications/seen` — **authenticated**; mark
|
||||||
/// before `seen_at` as read. Passing `None` marks *all* currently
|
/// everything indexed at or before `seen_at` as read. Passing `None`
|
||||||
/// unread rows. Idempotent; a second call reports `updated: 0`.
|
/// marks *all* currently unread rows. Idempotent; a second call
|
||||||
|
/// reports `updated: 0`.
|
||||||
///
|
///
|
||||||
/// The server accepts both `seenAt` and `seen_at`; we send the
|
/// The server accepts both `seenAt` and `seen_at`; we send the
|
||||||
/// camelCase spelling because that's what the wire contract
|
/// camelCase spelling because that's what the wire contract
|
||||||
@@ -448,6 +438,7 @@ impl AppViewClient {
|
|||||||
&self,
|
&self,
|
||||||
did: &str,
|
did: &str,
|
||||||
seen_at: Option<&str>,
|
seen_at: Option<&str>,
|
||||||
|
access_jwt: &str,
|
||||||
) -> Result<NotificationSeenResponse> {
|
) -> Result<NotificationSeenResponse> {
|
||||||
let mut body = serde_json::json!({ "did": did });
|
let mut body = serde_json::json!({ "did": did });
|
||||||
if let Some(ts) = seen_at {
|
if let Some(ts) = seen_at {
|
||||||
@@ -456,18 +447,13 @@ impl AppViewClient {
|
|||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
.post(format!("{}/api/notifications/seen", self.base_url))
|
.post(format!("{}/api/notifications/seen", self.base_url))
|
||||||
|
.bearer_auth(access_jwt)
|
||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.context("appview: failed to send notifications-seen request")?;
|
.context("appview: failed to send notifications-seen request")?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let status = resp.status();
|
return Err(status_error("notifications seen", resp).await);
|
||||||
let body = resp.text().await.unwrap_or_default();
|
|
||||||
return Err(anyhow!(
|
|
||||||
"appview: notifications seen returned {}: {}",
|
|
||||||
status,
|
|
||||||
body
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
resp
|
resp
|
||||||
.json::<NotificationSeenResponse>()
|
.json::<NotificationSeenResponse>()
|
||||||
@@ -517,9 +503,7 @@ impl AppViewClient {
|
|||||||
.await
|
.await
|
||||||
.with_context(|| format!("appview: failed to send {path} request"))?;
|
.with_context(|| format!("appview: failed to send {path} request"))?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let status = resp.status();
|
return Err(status_error(path, resp).await);
|
||||||
let body = resp.text().await.unwrap_or_default();
|
|
||||||
return Err(anyhow!("appview: {} returned {}: {}", path, status, body));
|
|
||||||
}
|
}
|
||||||
resp
|
resp
|
||||||
.json::<ActorListResponse>()
|
.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.
|
/// 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
|
/// `axum`'s path extractor will decode it back. We use this rather
|
||||||
/// than `url::Url::parse(...).path_segments()` because AT-Protocol
|
/// than `url::Url::parse(...).path_segments()` because AT-Protocol
|
||||||
@@ -553,6 +564,9 @@ fn percent_encode_path(s: &str) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn percent_encode_path_at_uri() {
|
fn percent_encode_path_at_uri() {
|
||||||
@@ -563,4 +577,241 @@ mod tests {
|
|||||||
"at%3A%2F%2Fdid%3Aplc%3Aabc%2Fapp.twi.post%2F3k2"
|
"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]
|
#[tauri::command]
|
||||||
async fn timeline_home(
|
async fn timeline_home(
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
@@ -362,9 +384,10 @@ async fn timeline_home(
|
|||||||
limit: Option<u32>,
|
limit: Option<u32>,
|
||||||
) -> Result<appview_client::TimelineResponse, String> {
|
) -> Result<appview_client::TimelineResponse, String> {
|
||||||
let lim = limit.unwrap_or(30).clamp(1, 100);
|
let lim = limit.unwrap_or(30).clamp(1, 100);
|
||||||
|
let jwt = require_access_jwt(&state, "the home timeline")?;
|
||||||
state
|
state
|
||||||
.appview
|
.appview
|
||||||
.fetch_timeline(&did, cursor.as_deref(), lim)
|
.fetch_timeline(&did, cursor.as_deref(), lim, &jwt)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
@@ -460,9 +483,10 @@ async fn fetch_notifications(
|
|||||||
limit: Option<u32>,
|
limit: Option<u32>,
|
||||||
) -> Result<appview_client::NotificationsResponse, String> {
|
) -> Result<appview_client::NotificationsResponse, String> {
|
||||||
let lim = limit.unwrap_or(30).clamp(1, 100);
|
let lim = limit.unwrap_or(30).clamp(1, 100);
|
||||||
|
let jwt = require_access_jwt(&state, "notifications")?;
|
||||||
state
|
state
|
||||||
.appview
|
.appview
|
||||||
.fetch_notifications(&did, cursor.as_deref(), lim)
|
.fetch_notifications(&did, cursor.as_deref(), lim, &jwt)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
@@ -473,9 +497,10 @@ async fn notification_count(
|
|||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
did: String,
|
did: String,
|
||||||
) -> Result<appview_client::NotificationCountResponse, String> {
|
) -> Result<appview_client::NotificationCountResponse, String> {
|
||||||
|
let jwt = require_access_jwt(&state, "the unread-notification count")?;
|
||||||
state
|
state
|
||||||
.appview
|
.appview
|
||||||
.notification_count(&did)
|
.notification_count(&did, &jwt)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
@@ -491,9 +516,10 @@ async fn mark_notifications_seen(
|
|||||||
did: String,
|
did: String,
|
||||||
seen_at: Option<String>,
|
seen_at: Option<String>,
|
||||||
) -> Result<appview_client::NotificationSeenResponse, String> {
|
) -> Result<appview_client::NotificationSeenResponse, String> {
|
||||||
|
let jwt = require_access_jwt(&state, "marking notifications seen")?;
|
||||||
state
|
state
|
||||||
.appview
|
.appview
|
||||||
.mark_notifications_seen(&did, seen_at.as_deref())
|
.mark_notifications_seen(&did, seen_at.as_deref(), &jwt)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
fetchTimeline,
|
fetchTimeline,
|
||||||
fetchSearch,
|
fetchSearch,
|
||||||
fetchPost,
|
fetchPost,
|
||||||
|
errorMessage,
|
||||||
|
isAuthFailure,
|
||||||
notificationCount,
|
notificationCount,
|
||||||
openExternalUrl,
|
openExternalUrl,
|
||||||
showError,
|
showError,
|
||||||
@@ -377,6 +379,16 @@
|
|||||||
/// Pull the unread count for the NavRail badge. Swallows errors:
|
/// Pull the unread count for the NavRail badge. Swallows errors:
|
||||||
/// the badge is ambient information, and a transient AppView hiccup
|
/// the badge is ambient information, and a transient AppView hiccup
|
||||||
/// shouldn't produce a toast every 5 seconds.
|
/// shouldn't produce a toast every 5 seconds.
|
||||||
|
///
|
||||||
|
/// One class of error is *not* swallowed-and-retried, though. Since
|
||||||
|
/// the AppView started requiring the access JWT on
|
||||||
|
/// `/api/notifications/count`, a rejected token surfaces here — and
|
||||||
|
/// by the time it does, `safeInvoke` has already spent its one
|
||||||
|
/// automatic refresh. Retrying on a 5s timer would then be a request
|
||||||
|
/// loop against a server that keeps answering 401/403 for as long as
|
||||||
|
/// the app is open. So an auth failure stops the poll outright; the
|
||||||
|
/// next successful login restarts it via the `session.subscribe`
|
||||||
|
/// handler in `onMount`.
|
||||||
async function refreshUnreadCount() {
|
async function refreshUnreadCount() {
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
// While the notifications view is open the user is by definition
|
// While the notifications view is open the user is by definition
|
||||||
@@ -386,8 +398,13 @@
|
|||||||
if (view === "notifications") return;
|
if (view === "notifications") return;
|
||||||
try {
|
try {
|
||||||
unreadCount = await notificationCount(currentUser.did);
|
unreadCount = await notificationCount(currentUser.did);
|
||||||
} catch {
|
} catch (e) {
|
||||||
/* ignore — keep the last known count */
|
if (isAuthFailure(e)) {
|
||||||
|
console.warn("notification poll stopped: session rejected", e);
|
||||||
|
stopPoll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* otherwise ignore — keep the last known count */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,7 +437,7 @@
|
|||||||
if (fresh.length > 0) userPosts = [...fresh, ...userPosts];
|
if (fresh.length > 0) userPosts = [...fresh, ...userPosts];
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
timelineError = String(e);
|
timelineError = errorMessage(e);
|
||||||
// Keep whatever we had on a transient failure.
|
// Keep whatever we had on a transient failure.
|
||||||
} finally {
|
} finally {
|
||||||
timelineLoading = false;
|
timelineLoading = false;
|
||||||
@@ -440,7 +457,7 @@
|
|||||||
}
|
}
|
||||||
timelineCursor = r.cursor;
|
timelineCursor = r.cursor;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
timelineError = String(e);
|
timelineError = errorMessage(e);
|
||||||
} finally {
|
} finally {
|
||||||
timelineLoading = false;
|
timelineLoading = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
// The AppView auth contract, from the client's side.
|
||||||
|
//
|
||||||
|
// Same setup as `notifications.test.ts`: `@tauri-apps/api/core` is
|
||||||
|
// mocked so no Tauri shell is needed, and every assertion is about the
|
||||||
|
// exact sequence of commands we hand the Rust IPC layer.
|
||||||
|
//
|
||||||
|
// What's pinned here:
|
||||||
|
// * the **token-renewal chain** — a `TokenInvalid` coming out of the
|
||||||
|
// AppView (not the PDS) triggers exactly one `auth_refresh` + one
|
||||||
|
// retry, for each of the four now-authenticated endpoints;
|
||||||
|
// * that the chain fires for a **bare string** rejection, which is
|
||||||
|
// what `invoke` actually rejects with for our `Result<T, String>`
|
||||||
|
// commands — the shape the old `typeof e !== "object"` guard
|
||||||
|
// silently skipped;
|
||||||
|
// * that it fires **once**, never in a loop, and not at all when the
|
||||||
|
// refresh itself fails or when the error isn't refreshable;
|
||||||
|
// * that the **public** endpoints still work with no session at all
|
||||||
|
// and never reach for a refresh.
|
||||||
|
//
|
||||||
|
// The error strings below are verbatim what the Rust side produces:
|
||||||
|
// `appview_client.rs`'s `status_error()` formats
|
||||||
|
// `"appview: {label} returned {status}: {body}"`, and `lib.rs`
|
||||||
|
// stringifies that into the command's `Err(String)`. The Rust test
|
||||||
|
// `token_invalid_code_survives_into_the_error_string` pins the other
|
||||||
|
// half of the same contract.
|
||||||
|
//
|
||||||
|
// Run with:
|
||||||
|
// npx vitest run src/lib/api/appview-auth.test.ts
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const invokeMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/api/core", () => ({
|
||||||
|
invoke: (...args: unknown[]) => invokeMock(...args),
|
||||||
|
isTauri: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
invokeMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Exactly what a Tauri command rejects with once the AppView has
|
||||||
|
/// refused an expired access token: a bare string, because our
|
||||||
|
/// commands are `Result<T, String>` and `invoke` rejects with the
|
||||||
|
/// deserialised payload — not an `Error`.
|
||||||
|
function appviewTokenInvalid(label: string): string {
|
||||||
|
return (
|
||||||
|
`appview: ${label} returned 401 Unauthorized: ` +
|
||||||
|
`{"error":"TokenInvalid","message":"ExpiredSignature"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FRESH_SESSION = {
|
||||||
|
did: "did:plc:me",
|
||||||
|
handle: "me.test",
|
||||||
|
access_jwt: "fresh-access",
|
||||||
|
refresh_jwt: "fresh-refresh",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The four endpoints that grew an auth guard, each with the command
|
||||||
|
/// name the Rust side registers, the AppView's label in the error
|
||||||
|
/// string, a caller, and the payload the retry should resolve with.
|
||||||
|
const AUTHED = [
|
||||||
|
{
|
||||||
|
name: "timeline_home",
|
||||||
|
label: "timeline home",
|
||||||
|
payload: { posts: [], cursor: null },
|
||||||
|
call: async () => {
|
||||||
|
const { fetchTimeline } = await import("./client");
|
||||||
|
return fetchTimeline("did:plc:me");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fetch_notifications",
|
||||||
|
label: "notifications",
|
||||||
|
payload: { notifications: [], cursor: null },
|
||||||
|
call: async () => {
|
||||||
|
const { fetchNotifications } = await import("./client");
|
||||||
|
return fetchNotifications("did:plc:me");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "notification_count",
|
||||||
|
label: "notification count",
|
||||||
|
payload: { count: 3 },
|
||||||
|
call: async () => {
|
||||||
|
const { notificationCount } = await import("./client");
|
||||||
|
return notificationCount("did:plc:me");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mark_notifications_seen",
|
||||||
|
label: "notifications seen",
|
||||||
|
payload: { ok: true, updated: 2 },
|
||||||
|
call: async () => {
|
||||||
|
const { markNotificationsSeen } = await import("./client");
|
||||||
|
return markNotificationsSeen("did:plc:me", "2026-09-09T10:00:00Z");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
describe("AppView token renewal", () => {
|
||||||
|
for (const ep of AUTHED) {
|
||||||
|
it(`${ep.name}: a TokenInvalid from the AppView refreshes and retries once`, async () => {
|
||||||
|
invokeMock
|
||||||
|
// 1. the call, rejected by the AppView's auth guard
|
||||||
|
.mockRejectedValueOnce(appviewTokenInvalid(ep.label))
|
||||||
|
// 2. auth_refresh mints a new access JWT from the refresh JWT
|
||||||
|
.mockResolvedValueOnce(FRESH_SESSION)
|
||||||
|
// 3. the same call again, now with the fresh token
|
||||||
|
.mockResolvedValueOnce(ep.payload);
|
||||||
|
|
||||||
|
await expect(ep.call()).resolves.toBeDefined();
|
||||||
|
|
||||||
|
expect(invokeMock.mock.calls.map((c) => c[0])).toEqual([
|
||||||
|
ep.name,
|
||||||
|
"auth_refresh",
|
||||||
|
ep.name,
|
||||||
|
]);
|
||||||
|
// The retry must repeat the *same* argument bag — a dropped
|
||||||
|
// cursor or limit here would silently change what the user sees.
|
||||||
|
expect(invokeMock.mock.calls[0][1]).toEqual(invokeMock.mock.calls[2][1]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("returns the retry's payload, not the failed first attempt", async () => {
|
||||||
|
const { notificationCount } = await import("./client");
|
||||||
|
invokeMock
|
||||||
|
.mockRejectedValueOnce(appviewTokenInvalid("notification count"))
|
||||||
|
.mockResolvedValueOnce(FRESH_SESSION)
|
||||||
|
.mockResolvedValueOnce({ count: 7 });
|
||||||
|
|
||||||
|
await expect(notificationCount("did:plc:me")).resolves.toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires for a bare-string rejection — the shape Tauri actually uses", async () => {
|
||||||
|
// Regression guard. `invoke` rejects with the deserialised
|
||||||
|
// `Err(String)` payload, i.e. a primitive string. A guard that
|
||||||
|
// bails on anything that isn't an object never sees the code and
|
||||||
|
// the retry silently never runs — the user's timeline just dies an
|
||||||
|
// hour after login with no error anyone would connect to auth.
|
||||||
|
const { fetchTimeline } = await import("./client");
|
||||||
|
invokeMock
|
||||||
|
.mockRejectedValueOnce(appviewTokenInvalid("timeline home"))
|
||||||
|
.mockResolvedValueOnce(FRESH_SESSION)
|
||||||
|
.mockResolvedValueOnce({ posts: [], cursor: null });
|
||||||
|
|
||||||
|
await expect(fetchTimeline("did:plc:me")).resolves.toEqual({
|
||||||
|
posts: [],
|
||||||
|
cursor: null,
|
||||||
|
});
|
||||||
|
expect(invokeMock).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("also fires when the error arrives as an Error object", async () => {
|
||||||
|
const { fetchTimeline } = await import("./client");
|
||||||
|
invokeMock
|
||||||
|
.mockRejectedValueOnce(new Error(appviewTokenInvalid("timeline home")))
|
||||||
|
.mockResolvedValueOnce(FRESH_SESSION)
|
||||||
|
.mockResolvedValueOnce({ posts: [], cursor: null });
|
||||||
|
|
||||||
|
await expect(fetchTimeline("did:plc:me")).resolves.toBeDefined();
|
||||||
|
expect(invokeMock).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries exactly once — a still-failing retry is not refreshed again", async () => {
|
||||||
|
const { fetchNotifications } = await import("./client");
|
||||||
|
invokeMock
|
||||||
|
.mockRejectedValueOnce(appviewTokenInvalid("notifications"))
|
||||||
|
.mockResolvedValueOnce(FRESH_SESSION)
|
||||||
|
.mockRejectedValueOnce(appviewTokenInvalid("notifications"));
|
||||||
|
|
||||||
|
await expect(fetchNotifications("did:plc:me")).rejects.toThrow(
|
||||||
|
/TokenInvalid/,
|
||||||
|
);
|
||||||
|
// Three calls, not five: no second refresh, no third attempt.
|
||||||
|
expect(invokeMock).toHaveBeenCalledTimes(3);
|
||||||
|
expect(invokeMock.mock.calls.filter((c) => c[0] === "auth_refresh")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates the original error when the refresh itself fails", async () => {
|
||||||
|
// The refresh JWT is good for 90 days, but it does eventually
|
||||||
|
// expire (or get revoked). At that point there's nothing left to
|
||||||
|
// do but surface the failure — retrying with the same dead token
|
||||||
|
// would just be a second 401.
|
||||||
|
const { notificationCount } = await import("./client");
|
||||||
|
invokeMock
|
||||||
|
.mockRejectedValueOnce(appviewTokenInvalid("notification count"))
|
||||||
|
.mockRejectedValueOnce("refresh token expired");
|
||||||
|
|
||||||
|
await expect(notificationCount("did:plc:me")).rejects.toThrow(
|
||||||
|
/TokenInvalid/,
|
||||||
|
);
|
||||||
|
expect(invokeMock.mock.calls.map((c) => c[0])).toEqual([
|
||||||
|
"notification_count",
|
||||||
|
"auth_refresh",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not refresh on a 403 Forbidden — a new token wouldn't help", async () => {
|
||||||
|
// The AppView returns this when the token is perfectly valid but
|
||||||
|
// its `sub` doesn't match the `did` query parameter. Refreshing
|
||||||
|
// mints another token for the same subject, so a retry is pure
|
||||||
|
// waste.
|
||||||
|
const { fetchTimeline } = await import("./client");
|
||||||
|
invokeMock.mockRejectedValueOnce(
|
||||||
|
'appview: timeline home returned 403 Forbidden: ' +
|
||||||
|
'{"error":"Forbidden","message":"did does not match token subject"}',
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(fetchTimeline("did:plc:someone-else")).rejects.toThrow(
|
||||||
|
/Forbidden/,
|
||||||
|
);
|
||||||
|
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not refresh when the shell says there is no session at all", async () => {
|
||||||
|
// `require_access_jwt` in lib.rs. Nothing to refresh *from*, so the
|
||||||
|
// message deliberately carries neither `TokenInvalid` nor
|
||||||
|
// `ExpiredSignature`.
|
||||||
|
const { fetchNotifications } = await import("./client");
|
||||||
|
invokeMock.mockRejectedValueOnce(
|
||||||
|
"not logged in: notifications requires a signed-in session",
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(fetchNotifications("did:plc:me")).rejects.toThrow(
|
||||||
|
/not logged in/,
|
||||||
|
);
|
||||||
|
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a 503 AuthUnavailable as transient, not as an auth failure", async () => {
|
||||||
|
// The AppView answers 503 `AuthUnavailable` when it cannot reach
|
||||||
|
// the PDS to fetch the verification key — it fails closed rather
|
||||||
|
// than guessing. Our token is fine; the *server* is temporarily
|
||||||
|
// unable to check it. So: no refresh (nothing is wrong with the
|
||||||
|
// token), and `isAuthFailure` must stay false so the background
|
||||||
|
// poll keeps trying instead of shutting itself down over an outage
|
||||||
|
// that will resolve on its own.
|
||||||
|
const { notificationCount, isAuthFailure } = await import("./client");
|
||||||
|
const err =
|
||||||
|
'appview: notification count returned 503 Service Unavailable: ' +
|
||||||
|
'{"error":"AuthUnavailable","message":"could not fetch PDS key"}';
|
||||||
|
|
||||||
|
invokeMock.mockRejectedValueOnce(err);
|
||||||
|
await expect(notificationCount("did:plc:me")).rejects.toThrow(
|
||||||
|
/AuthUnavailable/,
|
||||||
|
);
|
||||||
|
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(isAuthFailure(err)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not refresh on a transient server error", async () => {
|
||||||
|
const { notificationCount } = await import("./client");
|
||||||
|
invokeMock.mockRejectedValueOnce(
|
||||||
|
"appview: notification count returned 500 Internal Server Error: db down",
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(notificationCount("did:plc:me")).rejects.toThrow(/500/);
|
||||||
|
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an auth_* command never triggers a refresh (no login loop)", async () => {
|
||||||
|
const { session } = await import("./client");
|
||||||
|
invokeMock.mockRejectedValueOnce("TokenInvalid");
|
||||||
|
|
||||||
|
await expect(session.login("me.test", "pw")).rejects.toBeDefined();
|
||||||
|
expect(invokeMock.mock.calls.map((c) => c[0])).toEqual(["auth_login"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("public AppView endpoints", () => {
|
||||||
|
// These stay unauthenticated server-side, so they must keep working
|
||||||
|
// with no session in the store: one invoke, no bearer token to fetch,
|
||||||
|
// no refresh.
|
||||||
|
const PUBLIC = [
|
||||||
|
{
|
||||||
|
name: "search",
|
||||||
|
payload: { posts: [], q: "hi" },
|
||||||
|
call: async () => (await import("./client")).fetchSearch("hi"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "profile_get",
|
||||||
|
payload: {
|
||||||
|
did: "did:plc:a",
|
||||||
|
handle: "a.test",
|
||||||
|
posts: [],
|
||||||
|
followers: 0,
|
||||||
|
following: 0,
|
||||||
|
post_count: 0,
|
||||||
|
},
|
||||||
|
call: async () => (await import("./client")).fetchProfile("a.test"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "profile_get_by_did",
|
||||||
|
payload: {
|
||||||
|
did: "did:plc:a",
|
||||||
|
handle: "a.test",
|
||||||
|
posts: [],
|
||||||
|
followers: 0,
|
||||||
|
following: 0,
|
||||||
|
post_count: 0,
|
||||||
|
},
|
||||||
|
call: async () => (await import("./client")).fetchProfileByDid("did:plc:a"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "post_get",
|
||||||
|
payload: { post: null, thread: { parent: null, root: null } },
|
||||||
|
call: async () =>
|
||||||
|
(await import("./client")).fetchPost("at://did:plc:a/app.twi.post/1"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fetch_thread",
|
||||||
|
payload: { post: null, parents: [], root: null, replies: [] },
|
||||||
|
call: async () =>
|
||||||
|
(await import("./client")).fetchThread("at://did:plc:a/app.twi.post/1"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fetch_followers",
|
||||||
|
payload: { profiles: [], cursor: null },
|
||||||
|
call: async () => (await import("./client")).fetchFollowers("did:plc:a"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fetch_following",
|
||||||
|
payload: { profiles: [], cursor: null },
|
||||||
|
call: async () => (await import("./client")).fetchFollowing("did:plc:a"),
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const ep of PUBLIC) {
|
||||||
|
it(`${ep.name} resolves without a session and without refreshing`, async () => {
|
||||||
|
invokeMock.mockResolvedValueOnce(ep.payload);
|
||||||
|
|
||||||
|
await expect(ep.call()).resolves.toBeDefined();
|
||||||
|
|
||||||
|
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(invokeMock.mock.calls[0][0]).toBe(ep.name);
|
||||||
|
expect(
|
||||||
|
invokeMock.mock.calls.some((c) => c[0] === "auth_refresh"),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("a public call's own failure surfaces untouched", async () => {
|
||||||
|
const { fetchSearch } = await import("./client");
|
||||||
|
invokeMock.mockRejectedValueOnce(
|
||||||
|
"appview: search returned 400 Bad Request: q is required",
|
||||||
|
);
|
||||||
|
await expect(fetchSearch("")).rejects.toThrow(/q is required/);
|
||||||
|
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isAuthFailure / errorMessage", () => {
|
||||||
|
it("recognises every shape the AppView's auth guard can answer with", async () => {
|
||||||
|
const { isAuthFailure } = await import("./client");
|
||||||
|
for (const msg of [
|
||||||
|
'appview: timeline home returned 401 Unauthorized: {"error":"AuthMissing","message":"no bearer"}',
|
||||||
|
'appview: notifications returned 401 Unauthorized: {"error":"TokenInvalid","message":"ExpiredSignature"}',
|
||||||
|
'appview: notification count returned 403 Forbidden: {"error":"Forbidden"}',
|
||||||
|
"not logged in: the home timeline requires a signed-in session",
|
||||||
|
]) {
|
||||||
|
expect(isAuthFailure(msg)).toBe(true);
|
||||||
|
expect(isAuthFailure(new Error(msg))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mistake a server or network failure for an auth failure", async () => {
|
||||||
|
const { isAuthFailure } = await import("./client");
|
||||||
|
expect(
|
||||||
|
isAuthFailure("appview: notifications returned 500: db down"),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isAuthFailure("appview: failed to send timeline request"),
|
||||||
|
).toBe(false);
|
||||||
|
expect(isAuthFailure(null)).toBe(false);
|
||||||
|
expect(isAuthFailure(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swaps the raw 401 wire string for copy the user can act on", async () => {
|
||||||
|
const { errorMessage } = await import("./client");
|
||||||
|
const raw =
|
||||||
|
'appview: notifications returned 401 Unauthorized: {"error":"TokenInvalid","message":"ExpiredSignature"}';
|
||||||
|
expect(errorMessage(raw)).toBe(
|
||||||
|
"Sitzung abgelaufen oder abgelehnt — bitte neu anmelden.",
|
||||||
|
);
|
||||||
|
// Anything else is shown verbatim: there's nothing better to say
|
||||||
|
// about a 500 than what the server said.
|
||||||
|
expect(errorMessage("appview: search returned 500: db down")).toContain(
|
||||||
|
"500",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -59,14 +59,22 @@ export async function getAppviewUrl(): Promise<string> {
|
|||||||
* webview it falls through to a normal `invoke` call.
|
* webview it falls through to a normal `invoke` call.
|
||||||
*
|
*
|
||||||
* **Auto-refresh on 401**: the access JWT expires after 1 hour. When
|
* **Auto-refresh on 401**: the access JWT expires after 1 hour. When
|
||||||
* the PDS rejects our token with `TokenInvalid` (the rusty
|
* the PDS *or the AppView* rejects our token with `TokenInvalid`
|
||||||
* `routes::auth` handlers return `{"error":"TokenInvalid",...}`),
|
* (both return `{"error":"TokenInvalid",...}` — the PDS from its
|
||||||
* we ask the Rust shell for a fresh access JWT via the
|
* `routes::auth` handlers, the AppView from the guard on
|
||||||
* `auth_refresh` Tauri command. The Rust side reads the stored
|
* `/api/timeline/home`, `/api/notifications`,
|
||||||
* refresh JWT (valid for 90 days) and rotates both. We retry
|
* `/api/notifications/count` and `/api/notifications/seen`), we ask
|
||||||
* exactly once on the same `cmd` + `args`. The `auth_*` commands
|
* the Rust shell for a fresh access JWT via the `auth_refresh` Tauri
|
||||||
* themselves are skipped so a failing login doesn't trigger an
|
* command. The Rust side reads the stored refresh JWT (valid for 90
|
||||||
* infinite refresh loop.
|
* days) and rotates both. We retry exactly once on the same `cmd` +
|
||||||
|
* `args`. The `auth_*` commands themselves are skipped so a failing
|
||||||
|
* login doesn't trigger an infinite refresh loop.
|
||||||
|
*
|
||||||
|
* The whole chain is string-matching, end to end: the AppView states
|
||||||
|
* the code only in its JSON body, `appview_client.rs`'s
|
||||||
|
* `status_error()` formats that body into the `anyhow` message, and
|
||||||
|
* `lib.rs` stringifies it into the command's `Err(String)`. See the
|
||||||
|
* Rust-side test `token_invalid_code_survives_into_the_error_string`.
|
||||||
*/
|
*/
|
||||||
async function safeInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
async function safeInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
if (!isTauri()) {
|
if (!isTauri()) {
|
||||||
@@ -82,17 +90,74 @@ async function safeInvoke<T>(cmd: string, args?: Record<string, unknown>): Promi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Normalise whatever a rejected `invoke` handed us into a string.
|
||||||
|
///
|
||||||
|
/// This is not defensive padding — it is the difference between the
|
||||||
|
/// retry chain working and not. Our Tauri commands are
|
||||||
|
/// `Result<T, String>`, and `@tauri-apps/api`'s `invoke` rejects with
|
||||||
|
/// the *deserialised* error payload, i.e. a bare JS **string**, not an
|
||||||
|
/// `Error`. Anything that only reads `e.message` therefore sees
|
||||||
|
/// nothing at all on the exact path that matters. Errors thrown
|
||||||
|
/// locally (the browser-preview guard above, and the `Error` instances
|
||||||
|
/// the tests use) still arrive as objects, so both shapes are handled.
|
||||||
|
function errorText(e: unknown): string {
|
||||||
|
if (typeof e === "string") return e;
|
||||||
|
if (typeof e === "object" && e !== null) {
|
||||||
|
const m = (e as { message?: unknown }).message;
|
||||||
|
if (typeof m === "string") return m;
|
||||||
|
}
|
||||||
|
return String(e ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
/// Sniff out a `TokenInvalid` response from the Rust error string.
|
/// Sniff out a `TokenInvalid` response from the Rust error string.
|
||||||
/// Returns true when the error message looks like an expired/
|
/// Returns true when the error looks like an expired/invalid JWT —
|
||||||
/// invalid JWT (the PDS uses a stable `"TokenInvalid"` code in its
|
/// both the PDS and the AppView use a stable `"TokenInvalid"` code in
|
||||||
/// JSON error body, which `@tauri-apps/api/core` surfaces verbatim).
|
/// their JSON error body, which travels verbatim through the Rust
|
||||||
|
/// error message and out over the Tauri IPC boundary.
|
||||||
function isTokenInvalid(e: unknown): boolean {
|
function isTokenInvalid(e: unknown): boolean {
|
||||||
if (typeof e !== "object" || e === null) return false;
|
const msg = errorText(e);
|
||||||
const msg = (e as { message?: string }).message ?? String(e);
|
|
||||||
if (!msg) return false;
|
if (!msg) return false;
|
||||||
return msg.includes("TokenInvalid") || msg.includes("ExpiredSignature");
|
return msg.includes("TokenInvalid") || msg.includes("ExpiredSignature");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when an error means "this call will not succeed until the user
|
||||||
|
/// signs in again" — as opposed to a transient network/server hiccup.
|
||||||
|
///
|
||||||
|
/// Covers everything the AppView's auth guard can answer with
|
||||||
|
/// (`AuthMissing` / `TokenInvalid` on 401, `Forbidden` on 403) plus the
|
||||||
|
/// Rust shell's own "no session stored" message from
|
||||||
|
/// `require_access_jwt`. Callers that poll in the background use this
|
||||||
|
/// to *stop* polling: by the time one of these surfaces, `safeInvoke`
|
||||||
|
/// has already spent its one refresh attempt, so retrying on a timer
|
||||||
|
/// would just be a request loop against a server that keeps saying no.
|
||||||
|
export function isAuthFailure(e: unknown): boolean {
|
||||||
|
const msg = errorText(e);
|
||||||
|
if (!msg) return false;
|
||||||
|
return (
|
||||||
|
msg.includes("AuthMissing") ||
|
||||||
|
msg.includes("TokenInvalid") ||
|
||||||
|
msg.includes("ExpiredSignature") ||
|
||||||
|
msg.includes("Forbidden") ||
|
||||||
|
msg.includes("not logged in")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-facing copy for a failed call, in the app's German UI voice.
|
||||||
|
///
|
||||||
|
/// An auth failure gets a sentence naming the actual remedy. The raw
|
||||||
|
/// string a view would otherwise render —
|
||||||
|
/// `appview: timeline home returned 401 Unauthorized:
|
||||||
|
/// {"error":"TokenInvalid","message":"ExpiredSignature"}` — is precise
|
||||||
|
/// and completely unactionable for the person reading it. Everything
|
||||||
|
/// else falls through verbatim: a network error or a 500 is worth
|
||||||
|
/// showing as-is, since there is nothing better to say about it.
|
||||||
|
export function errorMessage(e: unknown): string {
|
||||||
|
if (isAuthFailure(e)) {
|
||||||
|
return "Sitzung abgelaufen oder abgelehnt — bitte neu anmelden.";
|
||||||
|
}
|
||||||
|
return String(e);
|
||||||
|
}
|
||||||
|
|
||||||
export type Session = {
|
export type Session = {
|
||||||
did: string;
|
did: string;
|
||||||
handle: string;
|
handle: string;
|
||||||
@@ -253,8 +318,10 @@ export type SearchResponse = {
|
|||||||
///
|
///
|
||||||
/// `like_count` and `repost_count` are present when the post was
|
/// `like_count` and `repost_count` are present when the post was
|
||||||
/// found; they're `undefined` (or absent) for the "not in index"
|
/// found; they're `undefined` (or absent) for the "not in index"
|
||||||
/// sentinel response (where `post` is null). AppView has no auth
|
/// sentinel response (where `post` is null). `/api/post/{uri}` is one
|
||||||
/// yet, so `viewer_liked` / `viewer_reposted` aren't returned.
|
/// of the AppView's public endpoints — it takes no token and so has no
|
||||||
|
/// viewer to resolve against, hence no `viewer_liked` /
|
||||||
|
/// `viewer_reposted`. Use [`fetchThread`] with a `viewerDid` for those.
|
||||||
export type ThreadResponse = {
|
export type ThreadResponse = {
|
||||||
post: Post | null;
|
post: Post | null;
|
||||||
thread: {
|
thread: {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import Avatar from "./Avatar.svelte";
|
import Avatar from "./Avatar.svelte";
|
||||||
import Skeleton from "./Skeleton.svelte";
|
import Skeleton from "./Skeleton.svelte";
|
||||||
import {
|
import {
|
||||||
|
errorMessage,
|
||||||
fetchNotifications,
|
fetchNotifications,
|
||||||
markNotificationsSeen,
|
markNotificationsSeen,
|
||||||
notificationIcon,
|
notificationIcon,
|
||||||
@@ -71,7 +72,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = String(e);
|
error = errorMessage(e);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
@@ -89,7 +90,7 @@
|
|||||||
items = [...items, ...r.notifications.filter((n) => !seen.has(n.id))];
|
items = [...items, ...r.notifications.filter((n) => !seen.has(n.id))];
|
||||||
cursor = r.cursor;
|
cursor = r.cursor;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = String(e);
|
error = errorMessage(e);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,6 +194,48 @@ describe("NotificationsView actor navigation", () => {
|
|||||||
expect(onThreadClick).not.toHaveBeenCalled();
|
expect(onThreadClick).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows actionable copy when the AppView rejects the session", async () => {
|
||||||
|
// Since `/api/notifications` grew an auth guard, this is what a
|
||||||
|
// rejected token looks like by the time it reaches the view: the
|
||||||
|
// AppView's JSON body, wrapped by `appview_client.rs`'s
|
||||||
|
// `status_error()` and stringified across the Tauri IPC boundary.
|
||||||
|
// `safeInvoke` has already spent its one refresh attempt getting
|
||||||
|
// here, so the only thing left to tell the user is "log in again" —
|
||||||
|
// rendering the raw wire string would be accurate and useless.
|
||||||
|
fetchNotificationsMock.mockRejectedValue(
|
||||||
|
'appview: notifications returned 401 Unauthorized: ' +
|
||||||
|
'{"error":"TokenInvalid","message":"ExpiredSignature"}',
|
||||||
|
);
|
||||||
|
|
||||||
|
app = mount(NotificationsView, {
|
||||||
|
target,
|
||||||
|
props: { did: "did:plc:me" },
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(target.textContent).toContain("bitte neu anmelden");
|
||||||
|
expect(target.textContent).not.toContain("TokenInvalid");
|
||||||
|
expect(target.textContent).not.toContain("401");
|
||||||
|
// A failed load must not leave the spinner up or ack a page it
|
||||||
|
// never rendered.
|
||||||
|
expect(markNotificationsSeenMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still shows a server error verbatim — there's nothing better to say", async () => {
|
||||||
|
fetchNotificationsMock.mockRejectedValue(
|
||||||
|
"appview: notifications returned 500 Internal Server Error: db down",
|
||||||
|
);
|
||||||
|
|
||||||
|
app = mount(NotificationsView, {
|
||||||
|
target,
|
||||||
|
props: { did: "did:plc:me" },
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(target.textContent).toContain("500");
|
||||||
|
expect(target.textContent).toContain("db down");
|
||||||
|
});
|
||||||
|
|
||||||
it("opens the thread for a row that has a subject", async () => {
|
it("opens the thread for a row that has a subject", async () => {
|
||||||
fetchNotificationsMock.mockResolvedValue({
|
fetchNotificationsMock.mockResolvedValue({
|
||||||
notifications: [row()],
|
notifications: [row()],
|
||||||
|
|||||||
Reference in New Issue
Block a user