use crate::{HarmontClient, Result};
impl HarmontClient {
pub async fn redeem_code(&self, code: &str) -> Result<String> {
let url = format!("{}/api/v0/auth/cli/redeem", self.base);
let resp = self.http.post(&url)
.json(&serde_json::json!({ "code": code }))
.send().await?;
#[derive(serde::Deserialize)]
struct R { token: String }
let r: R = self.parse_json(resp).await?;
Ok(r.token)
}
pub async fn claim_token(&self, nonce: &str) -> Result<String> {
let url = format!("{}/api/v0/auth/cli/claim", self.base);
let resp = self.http.post(&url)
.json(&serde_json::json!({ "nonce": nonce }))
.send().await?;
#[derive(serde::Deserialize)]
struct R { token: String }
let r: R = self.parse_json(resp).await?;
Ok(r.token)
}
pub async fn create_api_token(&self, description: &str) -> Result<String> {
let url = format!("{}/api/v0/user/api-tokens", self.base);
let resp = self.http.post(&url)
.json(&serde_json::json!({ "description": description }))
.send().await?;
#[derive(serde::Deserialize)]
struct R { token: String }
let r: R = self.parse_json(resp).await?;
Ok(r.token)
}
}
#[cfg(test)]
mod tests {
use crate::HarmontClient;
use wiremock::matchers::{body_partial_json, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use serde_json::json;
#[tokio::test]
async fn redeem_returns_token() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v0/auth/cli/redeem"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"token": "hm_live"})))
.mount(&server).await;
let client = HarmontClient::anonymous(server.uri());
let tok = client.redeem_code("ABCD-1234").await.expect("ok");
assert_eq!(tok, "hm_live");
}
#[tokio::test]
async fn redeem_sends_code_field() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v0/auth/cli/redeem"))
.and(body_partial_json(json!({"code": "ZZZZ-9999"})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"token": "hm_abc"})))
.mount(&server).await;
let client = HarmontClient::anonymous(server.uri());
client.redeem_code("ZZZZ-9999").await.expect("ok");
}
#[tokio::test]
async fn redeem_invalid_code_maps_to_api_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v0/auth/cli/redeem"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!({
"error": {"code": "cli_code_invalid", "message": "This CLI code is invalid, expired, or already used."}
})))
.mount(&server).await;
let client = HarmontClient::anonymous(server.uri());
let err = client.redeem_code("BAD-CODE").await.unwrap_err();
match err {
crate::HarmontError::Api { status, code, .. } => {
assert_eq!(status, 400);
assert_eq!(code, "cli_code_invalid");
}
other => panic!("expected Api error, got {other:?}"),
}
}
#[tokio::test]
async fn claim_returns_token() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v0/auth/cli/claim"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"token": "hm_session"})))
.mount(&server).await;
let client = HarmontClient::anonymous(server.uri());
let tok = client.claim_token("my-random-nonce-xyz").await.expect("ok");
assert_eq!(tok, "hm_session");
}
#[tokio::test]
async fn claim_sends_nonce_field() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v0/auth/cli/claim"))
.and(body_partial_json(json!({"nonce": "abc-nonce-123"})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"token": "hm_tok"})))
.mount(&server).await;
let client = HarmontClient::anonymous(server.uri());
client.claim_token("abc-nonce-123").await.expect("ok");
}
#[tokio::test]
async fn claim_not_yet_available_maps_to_api_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v0/auth/cli/claim"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!({
"error": {"code": "cli_code_invalid", "message": "No token parked for this nonce yet."}
})))
.mount(&server).await;
let client = HarmontClient::anonymous(server.uri());
let err = client.claim_token("no-match-nonce").await.unwrap_err();
match err {
crate::HarmontError::Api { status, code, .. } => {
assert_eq!(status, 400);
assert_eq!(code, "cli_code_invalid");
}
other => panic!("expected Api error, got {other:?}"),
}
}
#[tokio::test]
async fn create_api_token_returns_raw_token() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v0/user/api-tokens"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"token": "hm_personal_abc123",
"api_token": {
"id": "00000000-0000-0000-0000-000000000001",
"description": "my local machine",
"created_at": "2026-06-04T00:00:00Z",
"expires_at": null,
"last_used_at": null
}
})))
.mount(&server).await;
let client = HarmontClient::with_base_url("hm_session_token", server.uri());
let raw = client.create_api_token("my local machine").await.expect("ok");
assert_eq!(raw, "hm_personal_abc123");
}
#[tokio::test]
async fn create_api_token_sends_description_field() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v0/user/api-tokens"))
.and(body_partial_json(json!({"description": "laptop key"})))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"token": "hm_xyz",
"api_token": {
"id": "00000000-0000-0000-0000-000000000002",
"description": "laptop key",
"created_at": "2026-06-04T00:00:00Z",
"expires_at": null,
"last_used_at": null
}
})))
.mount(&server).await;
let client = HarmontClient::with_base_url("hm_session", server.uri());
client.create_api_token("laptop key").await.expect("ok");
}
#[tokio::test]
async fn create_api_token_unauthorized_maps_cleanly() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/v0/user/api-tokens"))
.respond_with(ResponseTemplate::new(401))
.mount(&server).await;
let client = HarmontClient::with_base_url("bad_token", server.uri());
let err = client.create_api_token("test").await.unwrap_err();
assert!(matches!(err, crate::HarmontError::Unauthorized));
}
}