diff --git a/CHANGELOG.md b/CHANGELOG.md index 603355e..bf27e08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Logout and passkey management: `logout`, `list_passkeys`, and + `delete_passkey`. `logout` regenerates every system-issued token on the + whole company, not just the caller's, so it ends every session sharing + it. Passkey *registration* is not wrapped — it needs a WebAuthn ceremony + only a browser can drive. Adds `Passkey`. +- `InvoiceNinjaClient::login`, an associated function (not a method, since + there is no token yet to build a client with) that exchanges an email and + password for an API token, optionally with a one-time password for + two-factor accounts. Adds `LoginResponse` and `LoginCompanyUser`; the + nested company/user/account data the server also returns is left in + `extra` rather than modelled. Does not cover passkey login, SSO, or + `/login/precheck`. + ## [0.2.0] - 2026-10-25 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index f8b1694..355e5e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -361,24 +361,28 @@ changes, doc tweaks) does not need an entry. ## Coverage **All 34 of the API's `Route::resource` groups are wrapped**, plus `users` -(which isn't one). 371 methods: 30 resources with CRUD + bulk, 3 with CRUD +(which isn't one). 375 methods: 30 resources with CRUD + bulk, 3 with CRUD only, 4 with entity actions, 4 with invitation-key downloads, 16 with -document uploads, and 107 hand-written. +document uploads, and 111 hand-written. Plus the non-CRUD surface: documents, entity actions and PDF downloads for the four action-capable resources, their invitation-key PDF and e-document downloads, `report`, `chart`, `search`, `statics`, `send_email`, `refund_payment`, `ping`, `health_check`, import/export, the 17 e-invoicing -routes, and the QuickBooks, Stripe, calendar, Yodlee and GoCardless -integrations. +routes, the QuickBooks, Stripe, calendar, Yodlee and GoCardless +integrations, `logout`, passkey list/delete, and a standalone `login`. **`GOALS.md`'s roadmap is closed** — every route an API-token client can reach is wrapped. What is left out is left out for a reason, not pending: -the auth routes (a different auth model, and an open question in `GOALS.md`), -the client-portal family and the OAuth and provider-webhook routes (all -outside `/api/v1`, so they don't fit the client's `base_url` or its token), -`POST /quickbooks/settings` (a registered route with no controller method, -so it 500s), and a few company and user extras. +passkey registration and passkey login (WebAuthn ceremonies only a browser +can drive), SSO (`oauth_login`, `oidc/*`) and `/login/precheck`, +`/refresh`/`/refresh_react` (the bulk sync-dump shape parked as its own open +question in `GOALS.md`, not an auth concept), `/connected_account` (Gmail +OAuth linking — an integration, not login/logout), the client-portal family +and the OAuth and provider-webhook routes (all outside `/api/v1`, so they +don't fit the client's `base_url` or its token), `POST /quickbooks/settings` +(a registered route with no controller method, so it 500s), and a few +company and user extras. **`COVERAGE.md` is the detailed inventory** — every wrapped endpoint, every gap, and the reason for each gap. diff --git a/COVERAGE.md b/COVERAGE.md index f644ed4..8b8d5f1 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -18,7 +18,7 @@ declares 34 `Route::resource` groups and 274 explicit route declarations > exists or what it returns. **All 34 `Route::resource` groups are now wrapped**, plus `users` (which is -not a resource group). 371 methods in total. +not a resource group). 375 methods in total. ## Resources with full CRUD + bulk @@ -434,6 +434,60 @@ established that a purged record reads back as a **400**, not a 404. A password containing a control character cannot be a header value and goes out as `X-API-PASSWORD-BASE64` instead. +## Logout, passkeys, and login + +`/login`, `/logout`, and the passkey routes used to sit together under one +"Auth" gap. They don't share a shape: `/logout` and the passkey *settings* +routes are plain `token_auth` endpoints like everything else this crate +wraps, while `/login` sits in a different, unauthenticated middleware group +because the caller has no token yet to prove who they are. + +| Endpoint | Method | Needs an existing client? | +| --- | --- | --- | +| `POST /logout` | `logout` | yes | +| `GET /settings/passkeys` | `list_passkeys` | yes | +| `DELETE /settings/passkeys/{id}` | `delete_passkey` | yes | +| `POST /login` | `InvoiceNinjaClient::login` (associated fn, not a method) | **no** | + +**`logout` has a bigger blast radius than its name suggests.** The +controller doesn't revoke the caller's token — it regenerates *every* +`is_system` token belonging to the whole company, logging out every user +and device that shares it, including the one making the call. + +**Passkey registration is not wrapped.** `POST /settings/passkeys/options` +and `POST /settings/passkeys` exist to drive a WebAuthn ceremony that only a +browser talking to a platform authenticator can complete — there is no +attestation object for this crate to produce standalone. Listing and +deleting an existing credential need no such ceremony, so those are the two +operations offered. + +**`login` returns the server's full "first load" payload** — nested +company, clients, invoices, designs, and more under each `CompanyUser` +entry — because it reuses the same transformer as a browser's initial page +load. This crate does not model that nested tree; [`LoginCompanyUser`] +types only `is_owner`, `is_admin`, and the `token` it exists to produce, +and leaves the rest in `extra`, losslessly. [`LoginCompanyUser::token`] +is what a caller actually needs — feed it to +[`InvoiceNinjaClient::new`] or [`InvoiceNinjaClient::hosted`]. + +`login` optionally takes a one-time password for accounts with two-factor +auth enabled. It does not support passkey login, SSO (`oauth_login`, +`oidc/*`), `/login/precheck`, or a self-hosted instance's optional +`X-API-SECRET` header — each needs a ceremony, a redirect, or a header this +single email/password call does not send. + +`/refresh` and `/refresh_react` were in the old "Auth" bucket too, but +they're not an authentication concept: `BaseController::refreshResponse` +is the same bulk, `updated_at`-filtered account dump as `/login`'s first +load. That's the sync-engine shape [`GOALS.md`](GOALS.md) already parks as +a separate open question, not something to bolt onto auth — not +implemented, for the same reason. + +`/connected_account` (linking a Gmail OAuth account) was also filed under +"Auth". It's an integration-linking endpoint, not login/logout, so it +belongs with Stripe/QuickBooks under [Integrations](#integrations) above — +also not implemented. + ## Per-resource extras Routes that sit beside a resource's CRUD set, each with its own response @@ -491,8 +545,11 @@ check `routes/api.php`). - **User extras** (not password-protected, unlike the rest of `/users`) — `POST /users/{id}/disconnect_mailer`, `POST /users/{id}/disconnect_oauth`, `POST /user/{id}/reconfirm` -- **Auth** — `/login`, `/logout`, `/refresh`, `/connected_account`, - passkey routes +- **Auth** — see [Logout, passkeys, and login](#logout-passkeys-and-login) + above for what's covered (`login`, `logout`, passkey list/delete) and + what isn't: `/login/precheck`, `oauth_login`, `oidc/*`, passkey + registration and passkey login, `/refresh`, `/refresh_react`, and + `/connected_account` - **Migration** — `/migration/start`. Moves a company between instances and is driven by the v4 migration tool, not by an SDK caller. - **Invoice extras** — `delivery_note`, `payment_schedule`, @@ -526,7 +583,7 @@ check `routes/api.php`). `/claim_license`, `/check_license`, `/check_subdomain`, `/one_time_token`, `/last_error`, `/support/messages/send`, `/verify/*`, `/accounts/{id}`, `/feedback`, `/filters/{entity}`, `/scheduler`, `/mailer/check`, - `/smtp/check`, `/password_timeout`, `/refresh_react`, `/webcron`, + `/smtp/check`, `/password_timeout`, `/webcron`, `/token_hash_router`, `/reactivate_email/{bounce_id}`, `/protected_download/{hash}`, `/confirm_purchase`, `/exports/preview/{hash}`, `/templates/preview/{hash}` diff --git a/GOALS.md b/GOALS.md index bd58565..49424b2 100644 --- a/GOALS.md +++ b/GOALS.md @@ -228,6 +228,24 @@ last item, so **the roadmap is closed**: every route in `routes/api.php` that an API-token client can reach is now wrapped. It did land in pieces, as the item predicted — import/export first, then the integrations. +**Done:** the auth routes, once split apart — the single "auth routes" +label had quietly merged four unrelated things. `logout` and the passkey +list/delete routes sit behind the same `token_auth` middleware as every +other wrapped endpoint, so they needed no new client type, just methods +(and a documented warning that `logout` rotates every token on the whole +company, not just the caller's). `login` *does* need a different entry +point — there is no token yet to build an `InvoiceNinjaClient` with — but +that turned out to be a standalone associated function, not a second +client type: `InvoiceNinjaClient::login` trades an email and password for +a `LoginResponse` carrying a token, which then goes to `new` or `hosted` +as normal. Passkey *registration* and passkey *login* stay unwrapped +because they're WebAuthn ceremonies a browser has to drive; `/refresh` and +`/refresh_react` turned out to be the bulk `updated_at` sync dump already +parked below, not an auth concept, so they stay out for that reason +instead; and `/connected_account` (Gmail OAuth linking) was filed under +the wrong heading — it belongs with the Integrations done item above. See +`COVERAGE.md`'s "Logout, passkeys, and login" section for the detail. + As the item also predicted, it is the least SDK-shaped part of the API, and the typing reflects that: most of these return `serde_json::Value`, because their replies are pass-throughs from Storecove, Stripe, Yodlee or GoCardless, @@ -244,18 +262,17 @@ a registered route with no controller method behind it, so it 500s — there is nothing to wrap until upstream adds the handler. What remains unwrapped is now only what each entry in `COVERAGE.md`'s gap -list explains: the auth routes (an open question below), the client-portal -family, the provider webhooks, and a few company and user extras. Keep that -file current — it names exact counts, so it goes stale quietly. +list explains: the passkey and SSO ceremonies and `/login/precheck` +(each needs something — a browser, a redirect — this client doesn't have), +`/refresh`/`/refresh_react` (the sync-engine shape parked below), the +client-portal family, the provider webhooks, and a few company and user +extras. Keep that file current — it names exact counts, so it goes stale +quietly. ## Open questions These are genuinely undecided. Deciding one is a change to this file. -- **Auth routes** (`/login`, `/logout`, `/refresh`, passkeys) are in the gap - list, but they are a different authentication model from the API token - this client is built around. Including them may mean a second client type - rather than more methods on this one. Not obviously in scope. - **`chart` is still generic** over `T: DeserializeOwned`, because the eleven chart endpoints share no response shape. Per-chart structs would be eleven small models of unstable payloads; unclear whether that earns diff --git a/README.md b/README.md index 7fa908f..9112198 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ IN_API_TOKEN=... cargo run --example list_clients ## Coverage -Every one of the API's 34 resource groups, plus users — 371 methods. Each +Every one of the API's 34 resource groups, plus users — 375 methods. Each resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and (where the API offers one) `bulk`: @@ -116,7 +116,9 @@ resource has `list`, `list_all`, `get`, `create`, `update`, `delete`, and Plus documents, entity actions and PDF downloads — by record id, or by invitation key for the PDF and XML a given contact receives — reports, -charts, search, statics, email sending, refunds, and health checks. +charts, search, statics, email sending, refunds, health checks, logout, +passkey management, and a standalone `login` to exchange credentials for +a token. **See [COVERAGE.md](COVERAGE.md) for the full endpoint-by-endpoint inventory**, including everything that is deliberately *not* wrapped and why. diff --git a/src/client.rs b/src/client.rs index 0e49f70..3025d6e 100644 --- a/src/client.rs +++ b/src/client.rs @@ -10,10 +10,11 @@ use crate::models::{ Activity, ActivityFeedEntry, BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client as ClientModel, ClientGatewayToken, Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense, ExpenseCategory, ExportJob, GroupSetting, HealthCheck, - ImportPreview, Invoice, ItemResponse, ListParams, ListResponse, Location, MessageResponse, - Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote, RecurringExpense, - RecurringInvoice, RecurringQuote, SearchResults, Statics, Subscription, SubscriptionStep, - SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor, Webhook, + ImportPreview, Invoice, ItemResponse, ListParams, ListResponse, Location, LoginResponse, + MessageResponse, Passkey, Payment, PaymentTerm, Product, Project, PurchaseOrder, Quote, + RecurringExpense, RecurringInvoice, RecurringQuote, SearchResults, Statics, Subscription, + SubscriptionStep, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor, + Webhook, }; use crate::retry::{self, RetryPolicy}; use crate::upload::FileUpload; @@ -63,6 +64,32 @@ fn seg(value: &str) -> Result { Ok(utf8_percent_encode(value, PATH_SEGMENT)) } +/// Validates a base URL and strips its trailing slash. +/// +/// Shared by [`InvoiceNinjaClient::new`] and +/// [`InvoiceNinjaClient::login`], the one other place a base URL arrives +/// from a caller rather than from an existing client. Parsing up front +/// means a malformed URL — most often a missing scheme, as in +/// `invoicing.co/api/v1` — fails here instead of as a confusing per-request +/// error. +fn validate_base_url(base_url: String) -> Result { + let trimmed = base_url.trim(); + if trimmed.is_empty() { + return Err(Error::InvalidBaseUrl(base_url)); + } + + let parsed = reqwest::Url::parse(trimmed) + .map_err(|e| Error::InvalidBaseUrl(format!("{trimmed}: {e}")))?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(Error::InvalidBaseUrl(format!( + "{trimmed}: expected an http or https url, got scheme `{}`", + parsed.scheme() + ))); + } + + Ok(trimmed.trim_end_matches('/').to_string()) +} + /// Standard base64 with padding. Only used for the one header that needs it, /// which is not worth a dependency. fn base64(bytes: &[u8]) -> String { @@ -135,25 +162,7 @@ pub struct InvoiceNinjaClient { impl InvoiceNinjaClient { pub fn new(base_url: impl Into, api_token: impl Into) -> Result { - let base_url = base_url.into(); - let trimmed = base_url.trim(); - if trimmed.is_empty() { - return Err(Error::InvalidBaseUrl(base_url)); - } - - // Parse up front rather than letting a malformed base URL surface as a - // confusing per-request failure. A missing scheme is the common typo - // (`invoicing.co/api/v1`), and it fails here instead of on every call. - let parsed = reqwest::Url::parse(trimmed) - .map_err(|e| Error::InvalidBaseUrl(format!("{trimmed}: {e}")))?; - if !matches!(parsed.scheme(), "http" | "https") { - return Err(Error::InvalidBaseUrl(format!( - "{trimmed}: expected an http or https url, got scheme `{}`", - parsed.scheme() - ))); - } - - let base_url = trimmed.trim_end_matches('/').to_string(); + let base_url = validate_base_url(base_url.into())?; let mut headers = header::HeaderMap::new(); let mut token_value = header::HeaderValue::from_str(&api_token.into()) @@ -338,6 +347,76 @@ impl InvoiceNinjaClient { Self::new(HOSTED_BASE_URL, api_token) } + /// Exchanges an email and password for an API token, for the one case + /// `new` cannot cover: there is no token yet to build a client with. + /// + /// `POST /login` sits outside the `token_auth` middleware every other + /// wrapped endpoint uses, so this is a standalone async function rather + /// than a method — nothing in `self` could be used to make the request. + /// It builds its own one-shot `reqwest::Client` and does not go through + /// `req` or `execute`, and, like every other `POST` in this crate, is + /// not retried on a 5xx or a timeout. + /// + /// On success the server answers with one entry per company the account + /// has access to, each carrying that company's token + /// ([`LoginCompanyUser::token`](crate::models::LoginCompanyUser::token)) + /// — feed one into [`new`](Self::new) (or [`hosted`](Self::hosted)) to + /// get a working client: + /// + /// ```no_run + /// # async fn run() -> invoiceninja_sdk::Result<()> { + /// use invoiceninja_sdk::InvoiceNinjaClient; + /// + /// let login = InvoiceNinjaClient::login( + /// "https://invoicing.co/api/v1", + /// "me@example.com", + /// "correct-horse-battery-staple", + /// None, + /// ) + /// .await?; + /// let token = &login.data[0].token.as_ref().expect("token include").token; + /// let client = InvoiceNinjaClient::hosted(token)?; + /// # let _ = client; + /// # Ok(()) + /// # } + /// ``` + /// + /// `one_time_password` is only consulted when the account has + /// two-factor authentication enabled; pass `None` otherwise. Passkey + /// login, SSO (`oauth_login`, `oidc/*`), and a self-hosted instance's + /// optional `X-API-SECRET` are not handled — each needs a ceremony or a + /// header this function does not send, and none fits a single + /// email/password call. + pub async fn login( + base_url: &str, + email: &str, + password: &str, + one_time_password: Option<&str>, + ) -> Result { + let base_url = validate_base_url(base_url.to_string())?; + + let mut body = serde_json::json!({ "email": email, "password": password }); + if let Some(otp) = one_time_password { + body["one_time_password"] = serde_json::Value::String(otp.to_string()); + } + + let http = reqwest::Client::builder() + .connect_timeout(CONNECT_TIMEOUT) + .build()?; + let response = http + .post(format!("{base_url}/login")) + .timeout(DEFAULT_TIMEOUT) + .header( + header::ACCEPT, + header::HeaderValue::from_static("application/json"), + ) + .header("X-Requested-With", "XMLHttpRequest") + .json(&body) + .send() + .await?; + Self::decode(response).await + } + fn url(&self, path: &str) -> String { format!("{}/{}", self.base_url, path.trim_start_matches('/')) } @@ -1909,6 +1988,41 @@ impl InvoiceNinjaClient { } } +/// Logout and passkey management. Unlike the credential exchange in +/// [`login`](Self::login), these sit behind the normal `token_auth` +/// middleware — the same one every other wrapped endpoint uses — so they +/// are plain methods here rather than a standalone function. +impl InvoiceNinjaClient { + /// Regenerates every system-issued API token on the **whole company**, + /// not just the one this client is using. + /// + /// `LogoutController::index` looks up the company behind the caller's + /// token and replaces every `is_system` token belonging to it with a + /// fresh random value — one call logs out every user and every device + /// sharing that company. The token this client was built with is among + /// them, so a subsequent call through it will fail; building a new + /// client with a fresh token is the only way back in. + pub async fn logout(&self) -> Result { + self.post_unwrapped("logout", &serde_json::json!({})).await + } + + /// Lists the WebAuthn passkeys registered to the current user. + pub async fn list_passkeys(&self) -> Result> { + self.get_list("settings/passkeys", &ListParams::default()) + .await + } + + /// Deletes a passkey credential. The server refuses with a 403 if + /// `id` belongs to a different user than the one the token + /// authenticates. + /// + /// Registering a new passkey is not wrapped — see [`Passkey`] for why. + pub async fn delete_passkey(&self, id: &str) -> Result<()> { + self.delete_one(&format!("settings/passkeys/{}", seg(id)?)) + .await + } +} + /// Activity log, email history, and document previews. Each has a response /// shape of its own (read from the upstream controllers, and for the activity /// routes confirmed against a 5.13.50 server), so none is macro-generated. diff --git a/src/models/login.rs b/src/models/login.rs new file mode 100644 index 0000000..a9dd160 --- /dev/null +++ b/src/models/login.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use super::CompanyToken; + +/// Response of `POST /login` (via [`crate::InvoiceNinjaClient::login`]). +/// +/// One entry per company the account can access. The full upstream payload +/// also nests the whole company (clients, invoices, designs, ...), the +/// user, and the account under each entry — this crate does not model that; +/// it stays reachable, losslessly, through [`LoginCompanyUser::extra`]. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct LoginResponse { + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub data: Vec, +} + +/// One company membership returned by a login, carrying that company's API +/// token. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct LoginCompanyUser { + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub is_owner: bool, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub is_admin: bool, + /// The token to pass to [`InvoiceNinjaClient::new`](crate::InvoiceNinjaClient::new) + /// for this company. Only absent if the server ever omits the `token` + /// include; every server-side `first_load` request requests it. + #[serde(default)] + pub token: Option, + + #[serde(flatten)] + pub extra: Map, +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 06cada6..7c7cdab 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -17,6 +17,8 @@ mod health; mod import_export; mod invoice; mod location; +mod login; +mod passkey; mod payment; mod payment_term; mod product; @@ -58,6 +60,8 @@ pub use health::{HealthCheck, PhpVersion}; pub use import_export::{ExportJob, ImportPreview}; pub use invoice::{Credit, Invoice, InvoiceItem, Quote}; pub use location::Location; +pub use login::{LoginCompanyUser, LoginResponse}; +pub use passkey::Passkey; pub use payment::{Payment, PaymentInvoice}; pub use payment_term::PaymentTerm; pub use product::Product; diff --git a/src/models/passkey.rs b/src/models/passkey.rs new file mode 100644 index 0000000..864c3c6 --- /dev/null +++ b/src/models/passkey.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +/// A WebAuthn passkey credential registered against the current user. +/// Served from `GET /settings/passkeys`. +/// +/// Registering a new passkey is not wrapped: the server's `/settings/ +/// passkeys/options` and `/settings/passkeys` routes exist to drive a +/// WebAuthn ceremony that only a browser, talking to its platform +/// authenticator, can complete — there is no attestation for this crate to +/// produce on its own. Listing and deleting an existing credential need no +/// such ceremony, so those are the two passkey operations this crate +/// offers. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Passkey { + /// Hashed id — pass to [`crate::InvoiceNinjaClient::delete_passkey`] to + /// remove this credential. + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub id: String, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub name: String, + #[serde(default, deserialize_with = "super::common::null_to_default")] + pub created_at: String, + /// Unix timestamp of the credential's last successful authentication, + /// or `None` if it has never been used to log in. + #[serde(default)] + pub last_used_at: Option, + + #[serde(flatten)] + pub extra: Map, +} diff --git a/tests/client_requests.rs b/tests/client_requests.rs index 58ac66c..b4027f9 100644 --- a/tests/client_requests.rs +++ b/tests/client_requests.rs @@ -2931,3 +2931,129 @@ extra_test!( |c| c.nordigen_institutions(), |got| assert_eq!(got[0]["id"], "BANK_X") ); + +// --- logout, passkeys, login -------------------------------------------- + +extra_test!( + logout_posts_and_returns_the_message, + "POST", + "/logout", + message(), + |c| c.logout(), + |got| assert_eq!(got.message, "ok") +); + +extra_test!( + list_passkeys_decodes_the_data_envelope, + "GET", + "/settings/passkeys", + json!({ "data": [ + { "id": "p1", "name": "Work laptop", "created_at": "2026-01-01T00:00:00.000000Z", "last_used_at": 1700000000 }, + { "id": "p2", "name": "Phone", "created_at": "2026-02-01T00:00:00.000000Z", "last_used_at": null }, + ] }), + |c| c.list_passkeys(), + |got| { + assert_eq!(got.len(), 2); + assert_eq!(got[0].last_used_at, Some(1700000000)); + assert_eq!(got[1].last_used_at, None); + } +); + +/// The server answers a delete with `{"message": ...}`, not an empty body, +/// but `delete_passkey` discards it like every other `delete_*`. +#[tokio::test] +async fn delete_passkey_discards_the_message_body() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/settings/passkeys/p1")) + .respond_with(ResponseTemplate::new(200).set_body_json(message())) + .expect(1) + .mount(&server) + .await; + client(&server).delete_passkey("p1").await.unwrap(); +} + +/// `login` has no client yet, so it is an associated function taking the +/// base url directly rather than a method. +#[tokio::test] +async fn login_posts_credentials_and_decodes_the_company_token() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/login")) + .and(wiremock::matchers::body_json(json!({ + "email": "me@example.com", + "password": "correct-horse-battery-staple", + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": [ + { + "is_owner": true, + "is_admin": true, + "token": { "id": "t1", "token": "new-api-token" }, + "company": { "id": "c1" }, + } + ] + }))) + .expect(1) + .mount(&server) + .await; + + let login = InvoiceNinjaClient::login( + &server.uri(), + "me@example.com", + "correct-horse-battery-staple", + None, + ) + .await + .expect("login should succeed"); + + assert_eq!(login.data.len(), 1); + let token = login.data[0].token.as_ref().expect("token include present"); + assert_eq!(token.token, "new-api-token"); + // The nested company blob is not modelled, but it is not dropped either. + assert!(login.data[0].extra.contains_key("company")); +} + +/// An optional one-time password goes out alongside the credentials when +/// the caller supplies one, and is left out entirely otherwise. +#[tokio::test] +async fn login_sends_one_time_password_only_when_given() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/login")) + .and(wiremock::matchers::body_json(json!({ + "email": "me@example.com", + "password": "pw", + "one_time_password": "123456", + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "data": [] }))) + .expect(1) + .mount(&server) + .await; + + InvoiceNinjaClient::login(&server.uri(), "me@example.com", "pw", Some("123456")) + .await + .expect("login should succeed"); +} + +/// A failed login surfaces the server's validation message like any other +/// non-2xx response. +#[tokio::test] +async fn login_failure_is_an_api_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/login")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({ + "message": "These credentials do not match our records." + }))) + .mount(&server) + .await; + + let err = InvoiceNinjaClient::login(&server.uri(), "me@example.com", "wrong", None) + .await + .expect_err("400"); + assert!( + matches!(&err, Error::Api { status, .. } if status.as_u16() == 400), + "{err:?}" + ); +} diff --git a/tests/decode_tolerance.rs b/tests/decode_tolerance.rs index 942cb80..b3baf3b 100644 --- a/tests/decode_tolerance.rs +++ b/tests/decode_tolerance.rs @@ -72,6 +72,9 @@ fn every_model_absorbs_a_null_or_missing_field() { Invoice, InvoiceItem, Location, + LoginCompanyUser, + LoginResponse, + Passkey, Payment, PaymentInvoice, PaymentTerm, diff --git a/tests/model_roundtrip.rs b/tests/model_roundtrip.rs index a3636a8..a3a5f99 100644 --- a/tests/model_roundtrip.rs +++ b/tests/model_roundtrip.rs @@ -1,10 +1,10 @@ use invoiceninja_sdk::models::{ BankIntegration, BankTransaction, BankTransactionRule, BulkRequest, Client, ClientGatewayToken, Company, CompanyGateway, CompanyToken, Credit, Design, Document, Expense, ExpenseCategory, - GroupSetting, HealthCheck, Invoice, ListParams, ListResponse, Location, PaymentTerm, Project, - PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote, SearchResults, - Statics, Subscription, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, User, Vendor, - Webhook, + GroupSetting, HealthCheck, Invoice, ListParams, ListResponse, Location, LoginResponse, Passkey, + PaymentTerm, Project, PurchaseOrder, Quote, RecurringExpense, RecurringInvoice, RecurringQuote, + SearchResults, Statics, Subscription, SystemLog, Tag, Task, TaskScheduler, TaskStatus, TaxRate, + User, Vendor, Webhook, }; use serde_json::json; @@ -763,3 +763,42 @@ fn statics_ids_accept_both_strings_and_numbers() { assert_eq!(statics.industries[0].id, "3"); assert_eq!(statics.sizes[0].id, "", "a null id decodes as empty"); } + +#[test] +fn login_response_types_the_token_and_preserves_the_rest() { + let payload = json!({ + "data": [{ + "is_owner": true, + "is_admin": true, + "token": { "id": "t1", "token": "abc" }, + "company": { "id": "c1", "settings": {} }, + "user": { "id": "u1", "email": "me@example.com" } + }] + }); + + let login: LoginResponse = serde_json::from_value(payload).unwrap(); + assert!(login.data[0].is_owner); + let token = login.data[0].token.as_ref().unwrap(); + assert_eq!(token.token, "abc"); + + let back = serde_json::to_value(&login).unwrap(); + assert_eq!(back["data"][0]["company"]["id"], "c1"); + assert_eq!(back["data"][0]["user"]["email"], "me@example.com"); +} + +#[test] +fn passkey_round_trip_preserves_last_used_at() { + let used = json!({ + "id": "p1", "name": "Laptop", "created_at": "2026-01-01T00:00:00.000000Z", + "last_used_at": 1700000000 + }); + let never_used = json!({ + "id": "p2", "name": "Phone", "created_at": "2026-02-01T00:00:00.000000Z", + "last_used_at": null + }); + + let used: Passkey = serde_json::from_value(used).unwrap(); + let never_used: Passkey = serde_json::from_value(never_used).unwrap(); + assert_eq!(used.last_used_at, Some(1700000000)); + assert_eq!(never_used.last_used_at, None); +}