Skip to main content

kasl/api/
kasl_server.rs

1//! kasl-server client: the team server this agent reports to.
2//!
3//! Unlike the other clients here, this one has no session to manage. The
4//! server authenticates an agent by a long-lived bearer token that an
5//! administrator issues once (ADR 0004 in kasl-server), so there is nothing
6//! to log into and nothing to cache - the token goes in the OS keyring and
7//! every request carries it.
8//!
9//! ```rust,no_run
10//! # use kasl::api::kasl_server::KaslServer;
11//! # use kasl::libs::config::KaslServerConfig;
12//! # async fn f() -> anyhow::Result<()> {
13//! let config = KaslServerConfig {
14//!     url: "https://kasl.example.com".to_string(),
15//!     ca_certificate: None,
16//! };
17//!
18//! let server = KaslServer::new(&config)?;
19//! let health = server.health().await?;
20//! println!("kasl-server {}", health.version);
21//! # Ok(())
22//! # }
23//! ```
24
25use crate::libs::config::KaslServerConfig;
26use anyhow::{Context, Result, bail};
27use chrono::{DateTime, FixedOffset, NaiveDate, Utc};
28use reqwest::{Client, StatusCode};
29use serde::{Deserialize, Serialize};
30use std::fs;
31use std::time::Duration;
32
33/// Keyring credential holding the agent token.
34///
35/// Named like the other secrets so it shows up beside them in the platform's
36/// credential UI; the leading dot and `_secret` suffix are what
37/// [`Secret::new`](crate::libs::secret::Secret::new) trims into the account
38/// name.
39pub const AGENT_TOKEN_SECRET: &str = ".kasl_server_secret";
40
41/// Prompt shown when the agent token is missing from the keyring.
42pub const AGENT_TOKEN_PROMPT: &str = "Enter the agent token issued by your kasl-server administrator";
43
44/// How long to wait on a request before giving up.
45///
46/// Short on purpose: every call here is a foreground command the user is
47/// waiting on, and a self-hosted server that has not answered in this long is
48/// down rather than slow.
49const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
50
51/// What `GET /health` reports.
52#[derive(Debug, Clone, Deserialize)]
53pub struct Health {
54    /// `ok` when the server considers itself serviceable.
55    pub status: String,
56
57    /// The server's own version - the product version, which the web UI and
58    /// the API share.
59    pub version: String,
60
61    /// Whether the server reached its database on this request.
62    pub database: String,
63}
64
65/// The identity behind a token, as `GET /api/v1/agent/whoami` reports it.
66///
67/// Used to confirm a token belongs to whom the user expects: connecting with
68/// a colleague's token would otherwise succeed silently and file this
69/// machine's days under their name.
70#[derive(Debug, Clone, Deserialize)]
71pub struct AgentIdentity {
72    /// Display name of the employee the token reports for.
73    pub user_name: String,
74
75    /// The label the administrator gave this agent, typically the machine.
76    pub agent_name: String,
77
78    /// The API version the server serves this path under.
79    pub api_version: String,
80
81    /// The server's own version.
82    pub server_version: String,
83}
84
85/// The privacy manifest, as `GET /api/v1/privacy/agent` reports it.
86///
87/// Generated on the server from the level it actually enforces at ingest
88/// (ADR 0011 in kasl-server), which is the whole point of reading it rather
89/// than describing the server from this side: a manifest written into kasl
90/// would describe the server kasl was built against, not the one this
91/// machine reports to.
92///
93/// Every field is owned by the server. Nothing here is defaulted or filled
94/// in locally - a manifest this agent could complete on its own would be one
95/// it could also get wrong.
96#[derive(Debug, Clone, Deserialize)]
97pub struct PrivacyManifest {
98    /// The installation's level: `full`, `moderate` or `coarse`.
99    pub level: String,
100
101    /// One sentence an employee can read without knowing the levels exist.
102    pub summary: String,
103
104    /// What is kept, field by field.
105    pub stored: Vec<StoredKind>,
106
107    /// Named explicitly, because a reader cannot tell "we do not collect
108    /// this" from "this was left off the list".
109    pub never_collected: Vec<String>,
110
111    /// Who can see a given person's data.
112    pub visible_to: Vec<String>,
113
114    /// How long it is kept.
115    pub retention: String,
116
117    /// What changing the level does - and does not do - to what is stored.
118    pub on_change: String,
119
120    /// When the level was last set, if the server says.
121    #[serde(default)]
122    pub updated_at: Option<DateTime<Utc>>,
123}
124
125/// One kind of data the server holds, in the manifest's own words.
126#[derive(Debug, Clone, Deserialize)]
127pub struct StoredKind {
128    pub what: String,
129    pub detail: String,
130}
131
132/// One day as this agent recorded it, in the shape the server accepts.
133///
134/// Field names and types mirror the ingest contract (ADR 0004 in
135/// kasl-server) rather than kasl's own model, so a change on either side
136/// shows up as a compile error here rather than as a `400` in the field.
137///
138/// Every instant carries a UTC offset. kasl stores bare wall-clock text,
139/// which is unambiguous on one laptop and meaningless across a team; the
140/// offset is attached when the day is assembled, and a day whose offset
141/// cannot be determined is not sent (ADR 0003).
142#[derive(Debug, Clone, Serialize)]
143pub struct DayUpload {
144    /// The employee's own calendar date, sent rather than derived: near
145    /// midnight the date of `started_at` and the date the work belongs to
146    /// disagree, and the agent is the side that knows which is meant.
147    pub date: NaiveDate,
148
149    /// When the day started.
150    pub started_at: DateTime<FixedOffset>,
151
152    /// When it ended; absent while the day is still open.
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub ended_at: Option<DateTime<FixedOffset>>,
155
156    pub pauses: Vec<PauseUpload>,
157
158    pub tasks: Vec<TaskUpload>,
159
160    /// Declares `tasks` to be everything this agent holds for the date, so a
161    /// task the employee deleted here is deleted there too (ADR 0005).
162    ///
163    /// kasl always sends the whole date, so this is always true. It is a
164    /// field rather than a constant because the server defaults it to false
165    /// for agents that predate it, and saying it explicitly is what
166    /// distinguishes "I have nothing more" from "I did not mention".
167    pub tasks_are_complete: bool,
168}
169
170/// One break, as the server takes it.
171#[derive(Debug, Clone, Serialize)]
172pub struct PauseUpload {
173    pub started_at: DateTime<FixedOffset>,
174
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub ended_at: Option<DateTime<FixedOffset>>,
177
178    /// Seconds. Sent explicitly because kasl merges neighbouring pauses
179    /// before reporting them, so this is not always `ended_at - started_at`.
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub duration_seconds: Option<i32>,
182
183    /// A break the employee entered by hand - kasl's `protected` flag.
184    pub manual: bool,
185
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub reason: Option<String>,
188}
189
190/// One task, keyed by the ids this agent knows it by.
191#[derive(Debug, Clone, Serialize)]
192pub struct TaskUpload {
193    /// This agent's row id: the key a re-upload matches on, so a corrected
194    /// task updates the stored row instead of piling up beside it.
195    pub agent_task_id: i32,
196
197    /// This agent's `task_id`, tying the same work across several days.
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub agent_group_id: Option<i32>,
200
201    pub recorded_at: DateTime<FixedOffset>,
202
203    pub name: String,
204
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub comment: Option<String>,
207
208    /// Percent complete, 0..=100.
209    pub completeness: i16,
210}
211
212/// What the server reports about a day it stored.
213#[derive(Debug, Clone, Deserialize)]
214pub struct DayAccepted {
215    /// The server's own id for the day, which this agent does not otherwise
216    /// know: worth printing so a day can be looked up on the other side.
217    pub workday_id: String,
218
219    pub date: NaiveDate,
220
221    pub pauses: usize,
222
223    pub tasks: usize,
224
225    /// Tasks the server dropped because this upload declared its set
226    /// authoritative. Non-zero means deletions here reached the server.
227    #[serde(default)]
228    pub deleted_tasks: u64,
229
230    /// The installation's privacy level, always reported - an agent should be
231    /// able to tell a server that keeps everything from one whose policy it
232    /// has not read (ADR 0011).
233    #[serde(default)]
234    pub privacy_level: Option<String>,
235}
236
237/// A stretch of days in one request - what an agent sends after time offline.
238#[derive(Debug, Clone, Serialize)]
239pub struct BatchUpload {
240    pub days: Vec<DayUpload>,
241}
242
243/// What came of a batch.
244///
245/// The counts are read first because the status will not tell: a batch
246/// answers `200` even when days inside it were refused (ADR 0005). A client
247/// that checks only the status believes a rejected day arrived.
248#[derive(Debug, Clone, Deserialize)]
249pub struct BatchResult {
250    pub accepted: usize,
251
252    pub rejected: usize,
253
254    /// One entry per day sent, in the order they were sent.
255    #[serde(default)]
256    pub results: Vec<DayResult>,
257}
258
259/// One day's fate inside a batch.
260///
261/// Tagged by `status` to match what the server serializes. An unrecognized
262/// tag is a contract the two sides no longer share, and is surfaced rather
263/// than silently treated as either outcome.
264#[derive(Debug, Clone, Deserialize)]
265#[serde(tag = "status", rename_all = "lowercase")]
266pub enum DayResult {
267    Accepted {
268        #[serde(flatten)]
269        day: DayAccepted,
270    },
271    Rejected {
272        date: NaiveDate,
273        error: String,
274    },
275}
276
277/// Why an upload failed, and whether sending the same bytes again could ever
278/// work.
279///
280/// The distinction is the server's own (ADR 0005) and it is the whole reason
281/// this is an enum rather than a message: `4xx` means the payload will never
282/// be accepted as sent, so a queue must stop asking; `5xx` and `429` mean the
283/// server could not answer this time, so it must keep the day and try later.
284#[derive(Debug)]
285pub enum UploadError {
286    /// The server refused the payload itself. Retrying is pointless.
287    Rejected { status: StatusCode, message: String },
288
289    /// The server could not answer, or answered that it was unavailable.
290    /// The day is still worth sending.
291    Retryable { message: String },
292}
293
294impl UploadError {
295    /// Whether sending this day again could succeed.
296    pub fn is_retryable(&self) -> bool {
297        matches!(self, UploadError::Retryable { .. })
298    }
299}
300
301impl std::fmt::Display for UploadError {
302    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
303        match self {
304            UploadError::Rejected { status, message } => write!(f, "the server refused the day ({}): {}", status, message),
305            UploadError::Retryable { message } => write!(f, "{}", message),
306        }
307    }
308}
309
310impl std::error::Error for UploadError {}
311
312/// A client bound to one kasl-server instance.
313#[derive(Debug, Clone)]
314pub struct KaslServer {
315    client: Client,
316
317    /// Base URL without a trailing slash, so paths append cleanly.
318    base_url: String,
319}
320
321impl KaslServer {
322    /// Builds a client for the configured server.
323    ///
324    /// A configured CA certificate is added to the trust store rather than
325    /// replacing it: a company CA for the server and public CAs for
326    /// everything else is the normal self-hosted arrangement.
327    pub fn new(config: &KaslServerConfig) -> Result<Self> {
328        let mut builder = Client::builder().timeout(REQUEST_TIMEOUT);
329
330        if let Some(path) = &config.ca_certificate {
331            let pem = fs::read(path).with_context(|| format!("cannot read the CA certificate at '{}'", path))?;
332
333            // `Certificate::from_pem` defers parsing to the TLS backend and
334            // accepts anything here - an empty file, a DER file saved with a
335            // .pem name, a text file. The failure then surfaces at the first
336            // request as an opaque TLS error, pointing at the network rather
337            // than at the file. Checked here, where the path is still in hand.
338            if !looks_like_pem_certificate(&pem) {
339                bail!(
340                    "'{}' does not contain a PEM-encoded certificate (expected a -----BEGIN CERTIFICATE----- block)",
341                    path
342                );
343            }
344
345            let certificate = reqwest::Certificate::from_pem(&pem).with_context(|| format!("'{}' is not a PEM-encoded certificate", path))?;
346            builder = builder.add_root_certificate(certificate);
347        }
348
349        Ok(Self {
350            client: builder.build().context("cannot build the HTTP client for kasl-server")?,
351            base_url: normalize_url(&config.url),
352        })
353    }
354
355    /// Asks the server whether it is serviceable, and which version it runs.
356    ///
357    /// Unauthenticated: this is the call that tells a misspelled URL from a
358    /// bad token, so it must not need the token to answer.
359    pub async fn health(&self) -> Result<Health> {
360        let url = format!("{}/health", self.base_url);
361        let response = self
362            .client
363            .get(&url)
364            .send()
365            .await
366            .with_context(|| format!("cannot reach kasl-server at {}", self.base_url))?;
367
368        let status = response.status();
369        if !status.is_success() {
370            bail!("{} answered {} instead of a health report", self.base_url, status);
371        }
372
373        // A URL that points at something else entirely - a proxy, a parked
374        // domain - answers 200 with a page. Insisting on the documented shape
375        // keeps that from reading as a healthy server.
376        response
377            .json::<Health>()
378            .await
379            .with_context(|| format!("{} answered, but not like a kasl-server", self.base_url))
380    }
381
382    /// Resolves the agent token to the person it reports for.
383    ///
384    /// Doubles as the token check: the server refuses an unknown, revoked, or
385    /// deactivated token with `401`, which is reported as such rather than as
386    /// a transport failure.
387    pub async fn identify(&self, token: &str) -> Result<AgentIdentity> {
388        let url = format!("{}/api/v1/agent/whoami", self.base_url);
389        let response = self
390            .client
391            .get(&url)
392            .bearer_auth(token)
393            .send()
394            .await
395            .with_context(|| format!("cannot reach kasl-server at {}", self.base_url))?;
396
397        match response.status() {
398            StatusCode::OK => response.json::<AgentIdentity>().await.context("cannot read the server's answer"),
399            StatusCode::UNAUTHORIZED => bail!("the server rejected this token - it may be mistyped, revoked, or issued for a deactivated account"),
400            status => bail!("the server answered {} when asked whose token this is", status),
401        }
402    }
403
404    /// Reads the installation's privacy manifest from
405    /// `GET /api/v1/privacy/agent`.
406    ///
407    /// Answered to the agent token rather than to a login, which is the point
408    /// of the route existing at all: the employee finds out what the server
409    /// keeps about them from the CLI they already run, instead of signing
410    /// into the server that watches them in order to ask.
411    ///
412    /// A `404` means the server predates the route and is reported as such.
413    /// It is the one failure here with a different fix - the administrator
414    /// upgrading the server, not the employee doing anything - and reading it
415    /// as "no manifest" would let an old server look like one that keeps
416    /// nothing. In practice it should not be reachable: `connect` already
417    /// needs 0.14.1 for `whoami`, and the route arrived four minors before
418    /// that. It is handled anyway, because "should not be reachable" is a
419    /// statement about today's floor and not about the wire.
420    pub async fn privacy(&self, token: &str) -> Result<PrivacyManifest> {
421        let url = format!("{}/api/v1/privacy/agent", self.base_url);
422        let response = self
423            .client
424            .get(&url)
425            .bearer_auth(token)
426            .send()
427            .await
428            .with_context(|| format!("cannot reach kasl-server at {}", self.base_url))?;
429
430        match response.status() {
431            StatusCode::OK => response.json::<PrivacyManifest>().await.context("cannot read the server's privacy manifest"),
432            StatusCode::UNAUTHORIZED => bail!("the server rejected this token - it may be mistyped, revoked, or issued for a deactivated account"),
433            StatusCode::NOT_FOUND => bail!("this server does not publish a privacy manifest to agents - the route arrived in kasl-server 0.10.0"),
434            status => bail!("the server answered {} when asked what it stores", status),
435        }
436    }
437
438    /// Sends one day to `POST /api/v1/days`.
439    ///
440    /// The last upload wins on the server, so re-sending a day corrects it
441    /// and sending the same day twice changes nothing (ADR 0004). That makes
442    /// a retry safe by construction, which is what the failure split here is
443    /// for: [`UploadError`] separates a payload the server will never take
444    /// from a server that could not answer this time.
445    pub async fn upload_day(&self, token: &str, day: &DayUpload) -> Result<DayAccepted, UploadError> {
446        let url = format!("{}/api/v1/days", self.base_url);
447        let response = match self.client.post(&url).bearer_auth(token).json(day).send().await {
448            Ok(response) => response,
449            // Nothing was answered: DNS, TLS, a refused connection, a timeout.
450            // The day is untouched on the server and worth sending again.
451            Err(error) => {
452                return Err(UploadError::Retryable {
453                    message: format!("cannot reach kasl-server at {}: {}", self.base_url, error),
454                });
455            }
456        };
457
458        let status = response.status();
459        if status.is_success() {
460            // A 2xx whose body is not a day report means the address answers
461            // for something other than this endpoint. Retrying a URL that is
462            // wrong would never come good, so it is a rejection.
463            return response.json::<DayAccepted>().await.map_err(|error| UploadError::Rejected {
464                status,
465                message: format!("the server accepted the day but answered unreadably: {}", error),
466            });
467        }
468
469        // Read the body before classifying: the server explains a refusal
470        // there ("tasks[0]: name is empty"), and a status alone would leave
471        // the user with nothing to fix.
472        let message = response.text().await.unwrap_or_default();
473        Err(classify_failure(status, describe_failure(status, &message)))
474    }
475
476    /// Sends several days to `POST /api/v1/days/batch`.
477    ///
478    /// The reason a backlog goes this way rather than as a loop of single
479    /// uploads is not the round trips - it is that the agent learns about the
480    /// run as a whole. Each day is written in its own transaction there, so
481    /// one day the server will never accept does not hold back the rest
482    /// (ADR 0005).
483    ///
484    /// The error here covers the *request*: a batch that never arrived, or a
485    /// server that refused the whole shape of it. The fate of the days inside
486    /// a batch that did arrive is in [`BatchResult`], and has to be read per
487    /// day - a `200` here means the request was processed, not that every day
488    /// in it was stored.
489    pub async fn upload_batch(&self, token: &str, days: &[DayUpload]) -> Result<BatchResult, UploadError> {
490        let url = format!("{}/api/v1/days/batch", self.base_url);
491        let batch = BatchUpload { days: days.to_vec() };
492
493        let response = match self.client.post(&url).bearer_auth(token).json(&batch).send().await {
494            Ok(response) => response,
495            Err(error) => {
496                return Err(UploadError::Retryable {
497                    message: format!("cannot reach kasl-server at {}: {}", self.base_url, error),
498                });
499            }
500        };
501
502        let status = response.status();
503        if status.is_success() {
504            return response.json::<BatchResult>().await.map_err(|error| UploadError::Rejected {
505                status,
506                message: format!("the server accepted the batch but answered unreadably: {}", error),
507            });
508        }
509
510        // A batch too large for the server to take is refused whole with 413.
511        // It is a rejection of this request, not of the days: the caller
512        // splits the backlog and the same days go again in smaller groups.
513        let message = response.text().await.unwrap_or_default();
514        Err(classify_failure(status, describe_failure(status, &message)))
515    }
516
517    /// The base URL this client talks to, as stored.
518    pub fn base_url(&self) -> &str {
519        &self.base_url
520    }
521}
522
523/// Sorts a failed status into "never going to work" and "try later".
524///
525/// The server's own rule, not a guess (ADR 0005): `4xx` will not be accepted
526/// as sent, `5xx` and `429` are worth repeating. `429` sits inside the `4xx`
527/// range and is the single exception to it.
528///
529/// Shared by both upload paths so the single day and the batch can never
530/// drift into disagreeing about which failures are worth a retry.
531fn classify_failure(status: StatusCode, message: String) -> UploadError {
532    if status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
533        UploadError::Retryable { message }
534    } else {
535        UploadError::Rejected { status, message }
536    }
537}
538
539/// Trims a user-typed URL into the form the client stores.
540///
541/// Only the trailing slash is removed. Guessing a scheme is deliberately not
542/// done here: `http://` and `https://` differ by whether the token crosses
543/// the network in the clear, which is not a default worth inventing on the
544/// user's behalf.
545pub fn normalize_url(url: &str) -> String {
546    url.trim().trim_end_matches('/').to_string()
547}
548
549/// Extracts the sentence a person can act on from a failed response.
550///
551/// The server answers errors as JSON (`{"error": "..."}`), and showing that
552/// wrapper verbatim buries the sentence that matters. A body in any other
553/// shape is passed through as-is rather than dropped: an error from a proxy
554/// in front of the server is still the most informative thing available.
555///
556/// The status is deliberately *not* added here. It is already carried by the
557/// error and printed once when the failure is displayed, and some of the
558/// server's own messages open with it too - stamping it on again produced
559/// "the server refused the day (401 Unauthorized): 401 Unauthorized: the
560/// token is not recognized", which reads as three different problems.
561fn describe_failure(status: StatusCode, body: &str) -> String {
562    let body = body.trim();
563    if body.is_empty() {
564        return format!("the server gave no explanation ({})", status);
565    }
566
567    serde_json::from_str::<serde_json::Value>(body)
568        .ok()
569        .and_then(|value| value.get("error").and_then(|error| error.as_str()).map(str::to_string))
570        .unwrap_or_else(|| body.chars().take(300).collect())
571}
572
573/// Whether a file's bytes carry a PEM certificate block.
574///
575/// A deliberately shallow check. It catches the mistakes people actually make
576/// (the wrong file, an empty file, a DER export named `.pem`) and leaves
577/// judging the certificate itself to the TLS backend, which is the only thing
578/// qualified to do so.
579fn looks_like_pem_certificate(pem: &[u8]) -> bool {
580    // Text search over bytes rather than a UTF-8 conversion: a PEM file is
581    // ASCII, but a binary file that is not valid UTF-8 should fail this check
582    // rather than fail to be examined.
583    pem.windows(BEGIN_CERTIFICATE.len()).any(|window| window == BEGIN_CERTIFICATE)
584}
585
586/// The header opening a PEM certificate block.
587const BEGIN_CERTIFICATE: &[u8] = b"-----BEGIN CERTIFICATE-----";
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592
593    #[test]
594    fn normalize_url_drops_a_trailing_slash() {
595        assert_eq!(normalize_url("https://kasl.example.com/"), "https://kasl.example.com");
596        assert_eq!(normalize_url("https://kasl.example.com"), "https://kasl.example.com");
597    }
598
599    #[test]
600    fn normalize_url_trims_surrounding_whitespace() {
601        // Pasting a URL from a chat message routinely brings a space along.
602        assert_eq!(normalize_url("  https://kasl.example.com/  "), "https://kasl.example.com");
603    }
604
605    #[test]
606    fn normalize_url_keeps_a_path_prefix() {
607        // A server behind a reverse proxy can live under a sub-path, and
608        // dropping it would send every request to the proxy's root.
609        assert_eq!(normalize_url("https://intranet.example.com/kasl/"), "https://intranet.example.com/kasl");
610    }
611
612    #[test]
613    fn a_client_is_built_without_a_certificate() {
614        let config = KaslServerConfig {
615            url: "https://kasl.example.com/".to_string(),
616            ca_certificate: None,
617        };
618
619        let server = KaslServer::new(&config).unwrap();
620        assert_eq!(server.base_url(), "https://kasl.example.com");
621    }
622
623    #[test]
624    fn a_missing_certificate_file_is_reported_by_path() {
625        let config = KaslServerConfig {
626            url: "https://kasl.example.com".to_string(),
627            ca_certificate: Some("/nonexistent/company-ca.pem".to_string()),
628        };
629
630        let error = KaslServer::new(&config).unwrap_err().to_string();
631        assert!(error.contains("company-ca.pem"), "the error should name the file: {}", error);
632    }
633
634    /// Writes `bytes` to a uniquely named file and hands back its path.
635    fn certificate_file(name: &str, bytes: &[u8]) -> (std::path::PathBuf, std::path::PathBuf) {
636        let dir = std::env::temp_dir().join(format!("kasl-ca-test-{}-{}", std::process::id(), name));
637        fs::create_dir_all(&dir).unwrap();
638        let path = dir.join(format!("{name}.pem"));
639        fs::write(&path, bytes).unwrap();
640        (dir, path)
641    }
642
643    /// Every shape of "not a certificate" a person actually hands over.
644    ///
645    /// `reqwest::Certificate::from_pem` accepts all of these without
646    /// complaint - it defers parsing to the TLS backend - so each one used to
647    /// connect happily and fail later as an opaque TLS error.
648    #[test]
649    fn a_certificate_that_is_not_pem_is_refused() {
650        for (name, bytes) in [
651            ("plain-text", &b"this is not a certificate"[..]),
652            ("empty", &b""[..]),
653            ("wrong-pem-block", &b"-----BEGIN PRIVATE KEY-----\nMIIB\n-----END PRIVATE KEY-----\n"[..]),
654            // A DER export saved with a .pem name: binary, and not valid UTF-8.
655            ("der-as-pem", &[0x30u8, 0x82, 0x01, 0x0a, 0xff, 0xfe][..]),
656        ] {
657            let (dir, path) = certificate_file(name, bytes);
658
659            let config = KaslServerConfig {
660                url: "https://kasl.example.com".to_string(),
661                ca_certificate: Some(path.to_string_lossy().into_owned()),
662            };
663
664            let error = match KaslServer::new(&config) {
665                Ok(_) => panic!("'{name}' should not have been accepted as a certificate"),
666                Err(error) => error.to_string(),
667            };
668            assert!(error.contains("PEM"), "the error for '{}' should say the file is not PEM: {}", name, error);
669            assert!(error.contains(name), "the error for '{}' should name the file: {}", name, error);
670
671            let _ = fs::remove_dir_all(&dir);
672        }
673    }
674
675    #[test]
676    fn a_real_certificate_block_is_accepted() {
677        // The counterpart to the test above: the check must not refuse the
678        // file it exists to let through. Body content is left to the TLS
679        // backend - what is asserted here is that a PEM block gets that far.
680        let (dir, path) = certificate_file("company-ca", b"-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAKZ\n-----END CERTIFICATE-----\n");
681
682        let config = KaslServerConfig {
683            url: "https://kasl.example.com".to_string(),
684            ca_certificate: Some(path.to_string_lossy().into_owned()),
685        };
686
687        // Accepted by our check; whether the bytes decode is the backend's
688        // call, and either answer here means the shallow check let it through.
689        let _ = KaslServer::new(&config);
690
691        let _ = fs::remove_dir_all(&dir);
692    }
693}