Wrap the eight invitation-key downloads
`GOALS.md` listed these as "eight routes that take no auth token, so they do
not fit the authenticated request path and need a different entry point."
That was wrong on every count, and wrong in the way goal 6 exists to catch:
the premise came from the route names rather than the route file.
All eight sit inside `routes/api.php`'s `api/v1` group, under its
`token_auth` middleware, and the controllers' own OpenAPI blocks declare
`X-API-TOKEN`. So they needed no new entry point — just a macro, alongside
`entity_action_methods!` and for the same four entities. What the item had
actually described is the client-portal family in `routes/client.php`, which
is genuinely token-less, is not under `/api/v1`, and stays unwrapped for
that reason; it is now the gap entry in `COVERAGE.md`.
`download_{invoice,quote,credit,purchase_order}_by_invitation_key` returns
the PDF and `download_e_*_by_invitation_key` the XML e-document. These take
a contact's invitation key rather than a record id, so the server renders in
that contact's locale — a client with contacts in two languages gets a
different document per key, which the id-based `download_*` cannot express.
Two things here are easy to get wrong, so both are pinned by tests. The
entity segment is singular (`invoice/{key}/download`), the only route family
in the API that departs from the plural CRUD path. And an unknown key is a
400 carrying `no record found`: the controllers return that directly instead
of raising `ModelNotFoundException`, so the status matches the rule for a
missing record but the message does not.
The smoke test's read tier now downloads an invoice by invitation key,
taking the key from the `invitations` array the list route already returns.
It is there to re-check the token claim against a real server rather than
against a second reading of the source. The `download_e_*` four are left
out: they render from e-invoice settings a test instance need not have.
Also in here, unrelated: a rustfmt fix to a `match` arm in
`examples/smoke_test.rs` that was already off-format on main, so
`cargo fmt --check` — and with it CI's `test` job — was failing before this
change. It could not be split out cleanly, being in a file this change also
touches.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2221,3 +2221,147 @@ async fn notes_are_not_replayed_on_5xx() {
|
||||
.expect_err("500");
|
||||
assert_eq!(server.received_requests().await.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
// --- invitation-key downloads -------------------------------------------
|
||||
|
||||
/// Mounts one GET route answering 200 with raw bytes. Expects exactly one
|
||||
/// request, so a call that built a different path fails when the server drops.
|
||||
async fn raw_route_server(route: &str, body: &'static [u8]) -> MockServer {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(route))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(body.to_vec()))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
server
|
||||
}
|
||||
|
||||
/// One test per invitation-key download. The route each mounts is the one
|
||||
/// `routes/api.php` registers — note the singular entity segment, which is
|
||||
/// what these would get wrong if the plural CRUD path were reused.
|
||||
macro_rules! invitation_download_test {
|
||||
($name:ident, $route:literal, $body:literal, |$c:ident| $call:expr) => {
|
||||
#[tokio::test]
|
||||
async fn $name() {
|
||||
let server = raw_route_server($route, $body).await;
|
||||
let $c = client(&server);
|
||||
let got = $call.await.expect("the download should succeed");
|
||||
assert_eq!(got, $body);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
invitation_download_test!(
|
||||
download_invoice_by_invitation_key_hits_the_singular_route,
|
||||
"/invoice/key1/download",
|
||||
b"%PDF-1.4",
|
||||
|c| c.download_invoice_by_invitation_key("key1")
|
||||
);
|
||||
|
||||
invitation_download_test!(
|
||||
download_e_invoice_by_invitation_key_hits_the_singular_route,
|
||||
"/invoice/key1/download_e_invoice",
|
||||
b"<?xml version=\"1.0\"?>",
|
||||
|c| c.download_e_invoice_by_invitation_key("key1")
|
||||
);
|
||||
|
||||
invitation_download_test!(
|
||||
download_quote_by_invitation_key_hits_the_singular_route,
|
||||
"/quote/key1/download",
|
||||
b"%PDF-1.4",
|
||||
|c| c.download_quote_by_invitation_key("key1")
|
||||
);
|
||||
|
||||
invitation_download_test!(
|
||||
download_e_quote_by_invitation_key_hits_the_singular_route,
|
||||
"/quote/key1/download_e_quote",
|
||||
b"<?xml version=\"1.0\"?>",
|
||||
|c| c.download_e_quote_by_invitation_key("key1")
|
||||
);
|
||||
|
||||
invitation_download_test!(
|
||||
download_credit_by_invitation_key_hits_the_singular_route,
|
||||
"/credit/key1/download",
|
||||
b"%PDF-1.4",
|
||||
|c| c.download_credit_by_invitation_key("key1")
|
||||
);
|
||||
|
||||
invitation_download_test!(
|
||||
download_e_credit_by_invitation_key_hits_the_singular_route,
|
||||
"/credit/key1/download_e_credit",
|
||||
b"<?xml version=\"1.0\"?>",
|
||||
|c| c.download_e_credit_by_invitation_key("key1")
|
||||
);
|
||||
|
||||
invitation_download_test!(
|
||||
download_purchase_order_by_invitation_key_hits_the_singular_route,
|
||||
"/purchase_order/key1/download",
|
||||
b"%PDF-1.4",
|
||||
|c| c.download_purchase_order_by_invitation_key("key1")
|
||||
);
|
||||
|
||||
invitation_download_test!(
|
||||
download_e_purchase_order_by_invitation_key_hits_the_singular_route,
|
||||
"/purchase_order/key1/download_e_purchase_order",
|
||||
b"<?xml version=\"1.0\"?>",
|
||||
|c| c.download_e_purchase_order_by_invitation_key("key1")
|
||||
);
|
||||
|
||||
/// These are authenticated like any other call, which is the whole reason
|
||||
/// they fit the client at all. A token-less variant would need a separate
|
||||
/// entry point.
|
||||
#[tokio::test]
|
||||
async fn an_invitation_download_still_sends_the_api_token() {
|
||||
let server = raw_route_server("/invoice/key1/download", b"%PDF-1.4").await;
|
||||
client(&server)
|
||||
.download_invoice_by_invitation_key("key1")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let headers = &server.received_requests().await.unwrap()[0].headers;
|
||||
assert_eq!(headers["X-Api-Token"], "test-token");
|
||||
}
|
||||
|
||||
/// An unknown key is a `400` carrying `no record found`, not a `404`: the
|
||||
/// controllers return that directly rather than raising
|
||||
/// `ModelNotFoundException`.
|
||||
#[tokio::test]
|
||||
async fn an_unknown_invitation_key_is_a_400_not_a_404() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/invoice/gone/download"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(400).set_body_json(json!({ "message": "no record found" })),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = client(&server)
|
||||
.download_invoice_by_invitation_key("gone")
|
||||
.await
|
||||
.expect_err("an unknown key should fail");
|
||||
assert!(
|
||||
matches!(&err, Error::Api { status, message, .. }
|
||||
if status.as_u16() == 400 && message == "no record found"),
|
||||
"{err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `invoice//download` is a different route, so an empty key must not reach
|
||||
/// the server.
|
||||
#[tokio::test]
|
||||
async fn an_empty_invitation_key_is_refused_before_the_request() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"%PDF-1.4".to_vec()))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = client(&server)
|
||||
.download_invoice_by_invitation_key("")
|
||||
.await
|
||||
.expect_err("an empty key must not be sent");
|
||||
assert!(matches!(err, Error::InvalidPathSegment(_)), "got {err:?}");
|
||||
assert!(server.received_requests().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user