Files
inkwell/core/src/sync/client.rs
T
bvandeusenandClaude Opus 5.5 63abff8681 DRY pass #2, batch 8: the Rust and ffi docs (#5372)
sync/mod.rs listed 5 of its 9 modules; migrate's doc sat above the v9 SQL;
client.rs had items after its test module; the ffi's sync_now doc had fused
into client_update's; complete_reminder (ffi and EditorAction) still said
recurrence advancement was to come, though the core does it; NoteQuery.view
listed views the core never matched and claimed it validated. Test scratch
dirs drop the old ts-/iw- prefixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:05:43 -04:00

785 lines
28 KiB
Rust

//! HTTP transport to an Inkwell server.
//!
//! Covers the compatibility handshake (M10.6) and device-token auth (M10.7a). The
//! engine that moves notes — push, pull, cursor — grows on top of the same client,
//! which is why the timeout, identity headers and error vocabulary live here rather
//! than inline at each call site.
//!
//! Nothing here runs unless the user has linked a server; the app is local-first and
//! fully usable with no network at all.
use std::path::Path;
use std::time::Duration;
use reqwest::{RequestBuilder, Response, StatusCode};
use serde::{Deserialize, Serialize};
use super::compat::{self, Compatibility, ServerInfo};
use super::wire;
/// Timeout for the short request/response calls in this module. Kept tight because a
/// user is watching a button while they run, and the most common mistake — a wrong
/// host on a LAN — fails by hanging rather than refusing, so an unbounded wait would
/// just look frozen. The sync engine's bulk transfers will need their own, longer one.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
/// Bulk transfers get much longer: a first full sync can be thousands of notes, and
/// failing one at ten seconds would make a large store impossible to ever pull.
const SYNC_TIMEOUT: Duration = Duration::from_secs(120);
/// One file going up can be far larger than a page of notes, and a timeout shorter
/// than the transfer is not a failure that retrying ever fixes — the same upload
/// would time out again every cycle.
const UPLOAD_TIMEOUT: Duration = Duration::from_secs(600);
/// Shared by every call that presents a token, so a revoked one reads the same way
/// wherever it surfaces.
const TOKEN_REJECTED: &str = "This server rejected the device token — it may have been \
revoked. Unlink and link again to issue a new one.";
/// What the link UI needs after a handshake: where we ended up (the normalized URL,
/// which may differ from what was typed), who answered, and whether we can work
/// with them.
#[derive(Debug, Serialize)]
pub struct ProbeResult {
pub base_url: String,
pub server: ServerInfo,
pub compatibility: Compatibility,
}
/// The account a device token belongs to. Surfaced after linking so the user can
/// confirm they linked the account they meant to — easy to get wrong on a server
/// hosting more than one.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Identity {
pub id: String,
pub email: String,
#[serde(default)]
pub display_name: String,
}
#[derive(Deserialize)]
struct DeviceLoginResponse {
token: String,
user: Identity,
}
/// What became of this device's token on the SERVER when unlinking.
///
/// Not a bool, and not an error: unlinking must never be blocked by the network —
/// wanting to stop syncing is a local decision — so the remote half reports back
/// instead of failing the call, and each outcome needs different advice.
///
/// Serialized tagged, like `Compatibility`, so the frontend can `switch` on `status`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum RevokeOutcome {
/// The server confirmed it: this token authenticates nothing now.
Revoked,
/// This server has no self-revoke route — it predates one. The token is still
/// live, and only the web app can retire it.
Unsupported,
/// We couldn't reach the server, or it refused. The token is still live.
Failed { reason: String },
/// Nothing to revoke; the app wasn't linked.
Skipped,
}
/// Retire the device token we authenticate with, server-side.
///
/// Identified by the token itself rather than a device id, because a token pasted
/// from the web app never carried one — a route keyed on the id would work for
/// exactly one of the two ways this app can be linked.
pub async fn revoke_self(base_url: &str, token: &str) -> RevokeOutcome {
let client = match http() {
Ok(client) => client,
Err(reason) => return RevokeOutcome::Failed { reason },
};
let request = prepare(client.delete(revoke_self_url(base_url)), Some(token));
let response = match request.send().await {
Ok(response) => response,
Err(e) => {
return RevokeOutcome::Failed {
reason: describe_transport_error(base_url, &e),
}
}
};
let status = response.status();
// 401 counts as revoked: the token already authenticates nothing — retired by
// another device, or purged server-side — which is the state we were asking for.
if status.is_success() || status == StatusCode::UNAUTHORIZED {
return RevokeOutcome::Revoked;
}
match status {
// No such route: a server older than self-revoke. Any other shape of 404
// (a proxy, a stale base URL) leaves the token live too, so the advice the
// user needs is the same either way.
StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED => RevokeOutcome::Unsupported,
other => RevokeOutcome::Failed {
reason: unexpected_status(base_url, other),
},
}
}
fn http_with(timeout: Duration) -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.timeout(timeout)
.build()
.map_err(|e| format!("Could not start the network client: {e}"))
}
fn http() -> Result<reqwest::Client, String> {
http_with(REQUEST_TIMEOUT)
}
/// Attach the client-identity headers every request carries, plus a bearer token
/// when we hold one.
fn prepare(builder: RequestBuilder, token: Option<&str>) -> RequestBuilder {
let mut builder = builder;
for (name, value) in compat::client_headers() {
builder = builder.header(name, value);
}
match token {
Some(t) => builder.bearer_auth(t),
None => builder,
}
}
fn unexpected_status(base_url: &str, status: StatusCode) -> String {
format!(
"{base_url} answered with HTTP {}. Check the address — a reverse proxy or a \
different site may be answering there.",
status.as_u16()
)
}
/// Send `request`, failing the way every call to the server fails alike: a
/// transport error, described for a person, and a 401, as `unauthorized` (what a
/// rejected credential means depends on the call). Any other status is the
/// caller's to read; [`send`] is this plus "anything but success is unexpected".
async fn send_raw(
base_url: &str,
request: RequestBuilder,
unauthorized: &str,
) -> Result<Response, String> {
let response = request
.send()
.await
.map_err(|e| describe_transport_error(base_url, &e))?;
if response.status() == StatusCode::UNAUTHORIZED {
return Err(unauthorized.to_string());
}
Ok(response)
}
/// [`send_raw`], and anything but a success is [`unexpected_status`].
async fn send(
base_url: &str,
request: RequestBuilder,
unauthorized: &str,
) -> Result<Response, String> {
let response = send_raw(base_url, request, unauthorized).await?;
let status = response.status();
if !status.is_success() {
return Err(unexpected_status(base_url, status));
}
Ok(response)
}
/// The server's own words for a refusal, when it gave some (`{"error": "…"}`).
/// "file is too large (max 25 MB)" is what a person can act on; a status is not.
async fn server_reason(response: Response) -> Option<String> {
let body = response.json::<serde_json::Value>().await.ok()?;
body.get("error")?.as_str().map(String::from)
}
/// Ask a server who it is and whether we can sync with it.
///
/// `Err` means we never got a usable answer (bad address, unreachable, not an
/// Inkwell server). A server that answers but is *incompatible* comes back `Ok`
/// with a verdict — that distinction matters, because the two need very different
/// messages: one is "check what you typed", the other is "update something".
pub async fn probe(raw_url: &str) -> Result<ProbeResult, String> {
let base_url = compat::normalize_base_url(raw_url)
.ok_or("Enter a server address, like https://notes.example.com")?;
// Before anything is sent, and in particular before a password or token is.
compat::refuse_public_cleartext(&base_url)?;
let request = prepare(http()?.get(config_url(&base_url)), None);
let response = request
.send()
.await
.map_err(|e| describe_transport_error(&base_url, &e))?;
let status = response.status();
if !status.is_success() {
return Err(unexpected_status(&base_url, status));
}
// Something answered 200 that isn't an Inkwell server (a router login page, a
// captive portal). Report the address, not the parse error, which would mean
// nothing to the person reading it.
let server: ServerInfo = response.json().await.map_err(|_| {
format!(
"{base_url} responded, but not with Inkwell's configuration. \
Is that the right address?"
)
})?;
let compatibility = compat::evaluate(&server);
Ok(ProbeResult {
base_url,
server,
compatibility,
})
}
/// Exchange email + password for a device bearer token.
///
/// The fresh-install path: it needs no existing session, which is what lets a brand
/// new desktop install link without visiting the web app first.
pub async fn device_login(
base_url: &str,
email: &str,
password: &str,
device_name: &str,
) -> Result<(String, Identity), String> {
let body = serde_json::json!({
"email": email,
"password": password,
"name": device_name,
});
let request = prepare(http()?.post(device_login_url(base_url)), None).json(&body);
let refused = "That email and password didn't match an account on this server.";
let response = send(base_url, request, refused).await?;
let parsed: DeviceLoginResponse = response
.json()
.await
.map_err(|_| format!("{base_url} signed us in but sent an unexpected reply."))?;
Ok((parsed.token, parsed.user))
}
/// Validate a token by asking whom it belongs to.
///
/// Used when the user pastes a token issued from the web app. Storing it unverified
/// would turn a copy/paste slip into a failure that only surfaces at the next sync,
/// far from the thing that caused it.
pub async fn fetch_identity(base_url: &str, token: &str) -> Result<Identity, String> {
let request = prepare(http()?.get(me_url(base_url)), Some(token));
let refused = "That token isn't valid on this server — it may have been revoked. \
Issue a new one from the web app under Account → Linked devices.";
let response = send(base_url, request, refused).await?;
response
.json()
.await
.map_err(|_| format!("{base_url} accepted the token but sent an unexpected reply."))
}
/// Fetch one page of the change feed, starting after `since`.
///
/// The caller loops until `has_more` is false (see `pull::run`); paging lives there
/// rather than here so the transport stays a single request/response.
pub async fn fetch_changes(
base_url: &str,
token: &str,
since: i64,
shares: bool,
) -> Result<wire::ChangesPage, String> {
// `shares=1` only to a server advertising `shares`: it asks for the notes shared
// with this account and the `revoked` list, which an older server would ignore.
let url = if shares {
format!("{base_url}/api/sync/changes?since={since}&shares=1")
} else {
format!("{base_url}/api/sync/changes?since={since}")
};
let request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token));
let response = send(base_url, request, TOKEN_REJECTED).await?;
response
.json()
.await
.map_err(|e| format!("Couldn't read the change feed from {base_url}: {e}"))
}
/// Download one attachment's bytes.
///
/// Metadata already arrived on the delta feed; this is only the payload, fetched
/// over the same route the web app uses (owner/shared scoped server-side).
pub async fn fetch_attachment(
base_url: &str,
token: &str,
note_id: &str,
attachment_id: &str,
) -> Result<Vec<u8>, String> {
let url = format!("{base_url}/api/notes/{note_id}/attachments/{attachment_id}");
let request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token));
let response = send(base_url, request, TOKEN_REJECTED).await?;
response
.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| format!("Couldn't download an attachment from {base_url}: {e}"))
}
/// Why an upload didn't land, split by whether trying again could help.
#[derive(Debug, PartialEq, Eq)]
pub enum UploadError {
/// Worth another attempt next cycle: the network, a server error, or a note the
/// server hasn't got yet.
Retry(String),
/// The server refused this file and will refuse it the same way every time —
/// too large, an id in use, bytes that don't match their hash.
Refused(String),
}
/// Upload one file attached on this device, under the id it was given here.
///
/// `PUT /api/sync/attachments/<id>` takes the raw bytes; idempotent, so a retry of
/// an upload whose reply was lost answers 200 and stores nothing twice.
#[allow(clippy::too_many_arguments)]
pub async fn upload_attachment(
base_url: &str,
token: &str,
note_id: &str,
attachment_id: &str,
filename: &str,
mime: &str,
sha256: &str,
bytes: Vec<u8>,
) -> Result<(), UploadError> {
let url = format!("{base_url}/api/sync/attachments/{attachment_id}");
let client = http_with(UPLOAD_TIMEOUT).map_err(UploadError::Retry)?;
let request = prepare(client.put(url), Some(token))
.query(&[
("note_id", note_id),
("filename", filename),
("sha256", sha256),
])
.header(reqwest::header::CONTENT_TYPE, mime)
.body(bytes);
let response = request
.send()
.await
.map_err(|e| UploadError::Retry(describe_transport_error(base_url, &e)))?;
let status = response.status();
if status.is_success() {
return Ok(());
}
if status == StatusCode::UNAUTHORIZED {
return Err(UploadError::Retry(TOKEN_REJECTED.to_string()));
}
// The server's own words, when it gave some: "file is too large (max 25 MB)" is
// what a person can act on, and a bare status code is not.
let reason = server_reason(response)
.await
.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
if status.is_client_error() && status != StatusCode::NOT_FOUND {
Err(UploadError::Refused(reason))
} else {
// 404: the note isn't on the server yet (its push was rejected, say). 5xx:
// the server's problem, and likely a passing one.
Err(UploadError::Retry(reason))
}
}
/// Send a batch of changes and hand back the raw reply.
///
/// Returns text rather than parsed results so this module stays pure transport —
/// `push::parse_results` owns the result shapes, and keeping them there is what lets
/// the parsing be unit-tested without a server.
pub async fn push_changes<T: Serialize>(
base_url: &str,
token: &str,
changes: &[T],
) -> Result<String, String> {
let body = serde_json::json!({ "changes": changes });
let url = format!("{base_url}/api/sync/push");
let request = prepare(http_with(SYNC_TIMEOUT)?.post(url), Some(token)).json(&body);
let response = send(base_url, request, TOKEN_REJECTED).await?;
response
.text()
.await
.map_err(|e| format!("Couldn't read the push reply from {base_url}: {e}"))
}
// --- sharing (#5175) -----------------------------------------------------------
//
// The Share dialog on a linked device asks the server directly, over the device
// token: who is on the instance, and who a note is shared with. Shares are the
// server's, so there is nothing to keep offline and nothing to queue.
/// Someone on the instance a note can be shared with.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct Member {
pub id: String,
#[serde(default)]
pub display_name: String,
#[serde(default)]
pub email: String,
}
/// A group the admin made, which a note can be shared with (#5177). Sharing with it
/// reaches whoever is in it at the time.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct ShareGroup {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub member_count: u32,
}
/// Everyone a note can be shared with: the other people on the instance, and every
/// group. `groups` defaults empty for a server from before groups.
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
pub struct Directory {
#[serde(default)]
pub members: Vec<Member>,
#[serde(default)]
pub groups: Vec<ShareGroup>,
}
/// One share of a note, at `view` or `edit`: to a person (`member`) or to a group
/// (`group`), never both.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct NoteShare {
pub id: String,
#[serde(default)]
pub member: Option<Member>,
#[serde(default)]
pub group: Option<ShareGroup>,
pub permission: String,
#[serde(default)]
pub created_at: Option<String>,
}
/// Who a new share goes to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShareTarget {
Member(String),
Group(String),
}
impl ShareTarget {
fn body(&self, permission: &str) -> serde_json::Value {
match self {
ShareTarget::Member(id) => {
serde_json::json!({ "user_id": id, "permission": permission })
}
ShareTarget::Group(id) => {
serde_json::json!({ "group_id": id, "permission": permission })
}
}
}
}
#[derive(Deserialize)]
struct SharesReply {
shares: Vec<NoteShare>,
}
/// A note made on this device that hasn't been pushed yet is a 404 to the server,
/// and so is one this account doesn't own.
const SHARE_NOT_FOUND: &str = "The server doesn't have this note yet. Sync, then share it.";
async fn read_reply<T: serde::de::DeserializeOwned>(
base_url: &str,
request: RequestBuilder,
) -> Result<T, String> {
let response = send_raw(base_url, request, TOKEN_REJECTED).await?;
let status = response.status();
if status == StatusCode::NOT_FOUND {
return Err(SHARE_NOT_FOUND.to_string());
}
if !status.is_success() {
// The server's own words when it gave some ("choose someone to share with").
let reason = server_reason(response).await;
return Err(reason.unwrap_or_else(|| unexpected_status(base_url, status)));
}
response
.json()
.await
.map_err(|e| format!("Couldn't read the reply from {base_url}: {e}"))
}
/// Everyone on the instance but this account, and every group.
pub async fn directory(base_url: &str, token: &str) -> Result<Directory, String> {
let url = format!("{base_url}/api/users/directory");
read_reply(base_url, prepare(http()?.get(url), Some(token))).await
}
pub async fn list_shares(
base_url: &str,
token: &str,
note_id: &str,
) -> Result<Vec<NoteShare>, String> {
let url = format!("{base_url}/api/notes/{note_id}/shares");
let reply: SharesReply = read_reply(base_url, prepare(http()?.get(url), Some(token))).await?;
Ok(reply.shares)
}
/// Share with a person or a group, or change their permission. Answers the note's
/// shares.
pub async fn share_note(
base_url: &str,
token: &str,
note_id: &str,
target: &ShareTarget,
permission: &str,
) -> Result<Vec<NoteShare>, String> {
let url = format!("{base_url}/api/notes/{note_id}/shares");
let body = target.body(permission);
let reply: SharesReply = read_reply(
base_url,
prepare(http()?.post(url), Some(token)).json(&body),
)
.await?;
Ok(reply.shares)
}
pub async fn unshare_note(
base_url: &str,
token: &str,
note_id: &str,
share_id: &str,
) -> Result<Vec<NoteShare>, String> {
let url = format!("{base_url}/api/notes/{note_id}/shares/{share_id}");
let reply: SharesReply =
read_reply(base_url, prepare(http()?.delete(url), Some(token))).await?;
Ok(reply.shares)
}
/// The public, unauthenticated endpoint carrying the handshake.
fn config_url(base_url: &str) -> String {
format!("{base_url}/api/config")
}
fn device_login_url(base_url: &str) -> String {
format!("{base_url}/api/auth/device-login")
}
fn me_url(base_url: &str) -> String {
format!("{base_url}/api/auth/me")
}
/// `self` rather than a device id: see `revoke_self`.
fn revoke_self_url(base_url: &str) -> String {
format!("{base_url}/api/auth/devices/self")
}
/// Turn a transport failure into something a person can act on. reqwest's own
/// Display is accurate but reads like a stack trace.
fn describe_transport_error(base_url: &str, err: &reqwest::Error) -> String {
if err.is_timeout() {
// No specific duration here: these calls run under two different budgets
// (interactive vs bulk sync), and naming the wrong one is worse than naming
// none.
format!(
"{base_url} didn't respond in time. It may be offline, or unreachable \
from this network."
)
} else if err.is_connect() {
format!(
"Couldn't reach {base_url}. Check the address and that the server is \
running. If it uses plain HTTP, include http:// explicitly."
)
} else {
format!("Couldn't reach {base_url}: {err}")
}
}
/// The Android client a linked server can hand out.
///
/// Mirrors `/api/client/android` (see the server's `client_dist.py`). Absent there
/// means the server has no client to offer, which is an ordinary state and not an
/// error — a self-hoster who never touches Android has one.
#[derive(Debug, Clone, Deserialize)]
pub struct ClientRelease {
pub version: String,
/// What decides "is this newer". The name is for people and sorts like a string.
pub version_code: i64,
pub size: i64,
pub sha256: String,
/// Path on the same server, not an absolute URL — the client joins it to the
/// base it is already linked to, so a compromised or misconfigured server
/// cannot redirect the download somewhere else.
pub url: String,
}
/// What Android client the linked server has, if any.
///
/// `Ok(None)` for a server that simply has none — that is the answer to the
/// question, not a failure to answer it.
pub async fn fetch_client_release(
base_url: &str,
token: &str,
) -> Result<Option<ClientRelease>, String> {
let url = format!("{base_url}/api/client/android");
let request = prepare(http()?.get(url), Some(token));
let response = send_raw(base_url, request, TOKEN_REJECTED).await?;
let status = response.status();
if status == StatusCode::NOT_FOUND {
return Ok(None);
}
if !status.is_success() {
return Err(unexpected_status(base_url, status));
}
response
.json::<ClientRelease>()
.await
.map(Some)
.map_err(|e| {
format!("{base_url} described its Android client in a way this app could not read: {e}")
})
}
/// Download the client to `dest`, verifying it on the way in.
///
/// Streamed rather than buffered: the APK is ~55 MiB and holding that in memory on
/// a phone, on top of whatever the app is already using, is how an update gets
/// killed by the low-memory killer half way through.
///
/// Written to `dest.part` and renamed only once the digest matches, so an
/// interrupted download can never be mistaken for a finished one. The digest is
/// not a trust anchor — the APK signature is, and Android checks that at install —
/// but it catches a truncated or corrupted transfer before the installer is
/// bothered with it.
pub async fn download_client(
base_url: &str,
token: &str,
release: &ClientRelease,
dest: &Path,
) -> Result<(), String> {
use sha2::{Digest, Sha256};
use std::io::Write;
// The advertised path is joined to the base we are LINKED to. Taking an
// absolute URL from the response would let a server point the download at a
// host the user never agreed to.
let path = release.url.trim_start_matches('/');
let url = format!("{base_url}/{path}");
let request = prepare(http_with(SYNC_TIMEOUT)?.get(url), Some(token));
let mut response = send(base_url, request, TOKEN_REJECTED).await?;
let partial = dest.with_extension("part");
if let Some(parent) = partial.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Couldn't prepare a place to download to: {e}"))?;
}
let mut file = std::fs::File::create(&partial)
.map_err(|e| format!("Couldn't open the download file: {e}"))?;
let mut hasher = Sha256::new();
let mut written: i64 = 0;
loop {
let chunk = response
.chunk()
.await
.map_err(|e| describe_transport_error(base_url, &e))?;
let Some(chunk) = chunk else { break };
hasher.update(&chunk);
written += chunk.len() as i64;
file.write_all(&chunk)
.map_err(|e| format!("Couldn't write the download: {e}"))?;
}
file.flush()
.map_err(|e| format!("Couldn't finish writing the download: {e}"))?;
drop(file);
let digest = format!("{:x}", hasher.finalize());
let mismatch = if written != release.size {
Some(format!("expected {} bytes, got {written}", release.size))
} else if !digest.eq_ignore_ascii_case(&release.sha256) {
Some("the contents did not match the checksum the server published".to_string())
} else {
None
};
if let Some(why) = mismatch {
// The half-file is removed rather than left: a later run finding it would
// have no way to tell it from a good one.
let _ = std::fs::remove_file(&partial);
return Err(format!("The download from {base_url} was damaged — {why}."));
}
std::fs::rename(&partial, dest)
.map_err(|e| format!("Couldn't put the downloaded update in place: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_share_names_a_person_or_a_group() {
assert_eq!(
ShareTarget::Member("u1".into()).body("view"),
serde_json::json!({ "user_id": "u1", "permission": "view" })
);
assert_eq!(
ShareTarget::Group("g1".into()).body("edit"),
serde_json::json!({ "group_id": "g1", "permission": "edit" })
);
let raw = r#"{"id":"s2","member":null,
"group":{"id":"g1","name":"Family","member_count":3},
"permission":"edit"}"#;
let share: NoteShare = serde_json::from_str(raw).expect("a group share reads");
assert!(share.member.is_none());
assert_eq!(share.group.expect("group").member_count, 3);
// A server from before groups sends no `groups` at all.
let older: Directory = serde_json::from_str(r#"{"members":[]}"#).expect("directory");
assert!(older.groups.is_empty());
}
#[test]
fn urls_join_without_doubling_slashes() {
// normalize_base_url has already stripped any trailing slash, so plain
// concatenation is correct — this pins that assumption.
assert_eq!(
config_url("https://notes.example.com"),
"https://notes.example.com/api/config"
);
assert_eq!(
device_login_url("https://notes.example.com"),
"https://notes.example.com/api/auth/device-login"
);
assert_eq!(
me_url("https://notes.example.com"),
"https://notes.example.com/api/auth/me"
);
assert_eq!(
revoke_self_url("https://notes.example.com"),
"https://notes.example.com/api/auth/devices/self"
);
}
#[test]
fn revoke_outcome_serializes_tagged_for_the_frontend() {
// The UI decides between "signed out on the server" and "still valid, go
// revoke it" by reading this tag, so its shape is part of the contract.
let json = serde_json::to_string(&RevokeOutcome::Failed {
reason: "offline".into(),
})
.expect("outcome serializes");
assert!(json.contains("\"status\":\"failed\""), "got {json}");
let json = serde_json::to_string(&RevokeOutcome::Revoked).expect("outcome serializes");
assert!(json.contains("\"status\":\"revoked\""), "got {json}");
}
#[test]
fn urls_preserve_a_port_and_subpath() {
assert_eq!(
config_url("http://192.168.1.10:8000/inkwell"),
"http://192.168.1.10:8000/inkwell/api/config"
);
}
}