1use crate::error::Result;
13use tracing::{debug, instrument};
14
15const DEFAULT_ENTREZ_URL: &str = "https://entrez.enphaseenergy.com";
17
18#[derive(Debug, Clone)]
23pub struct Entrez {
24 client: reqwest::Client,
26 base_url: String,
28}
29
30impl Default for Entrez {
31 #[inline]
36 fn default() -> Self {
37 Self::new(DEFAULT_ENTREZ_URL)
38 }
39}
40
41impl Entrez {
42 #[inline]
63 #[expect(
64 clippy::missing_panics_doc,
65 clippy::expect_used,
66 reason = "reqwest::Client::builder() with basic config cannot fail"
67 )]
68 pub fn new(url: impl Into<String>) -> Self {
69 let base_url = url.into();
70
71 let client = reqwest::Client::builder()
72 .user_agent(format!("enphase-api/{}", env!("CARGO_PKG_VERSION")))
73 .cookie_store(true)
74 .timeout(core::time::Duration::from_secs(30))
75 .build()
76 .expect("Failed to build HTTP client");
77
78 Self { client, base_url }
79 }
80
81 #[inline]
104 pub fn with_client(url: impl Into<String>, client: reqwest::Client) -> Self {
105 let base_url = url.into();
106
107 Self { client, base_url }
108 }
109
110 #[inline]
142 #[instrument(skip(self, username, password), level = "debug")]
143 pub async fn login(&self, username: impl AsRef<str>, password: impl AsRef<str>) -> Result<()> {
144 let username_str = username.as_ref();
145 let password_str = password.as_ref();
146 debug!("Logging in to Enphase Entrez with {}", username_str);
147
148 let endpoint = format!("{}{}", self.base_url, "/login");
149 debug!("POST {endpoint}");
150
151 let form_data = [
152 ("username", username_str),
153 ("password", password_str),
154 ("authFlow", "entrezSession"),
155 ];
156
157 let response = self.client.post(&endpoint).form(&form_data).send().await?;
158 debug!("Status code: {}", response.status());
159
160 Ok(())
161 }
162
163 #[inline]
194 pub async fn login_with_env(&self) -> Result<()> {
195 let username = std::env::var("ENTREZ_USERNAME").map_err(|_e| {
196 crate::error::EnphaseError::ConfigurationError(
197 "ENTREZ_USERNAME environment variable not set".to_owned(),
198 )
199 })?;
200
201 let password = std::env::var("ENTREZ_PASSWORD").map_err(|_e| {
202 crate::error::EnphaseError::ConfigurationError(
203 "ENTREZ_PASSWORD environment variable not set".to_owned(),
204 )
205 })?;
206
207 self.login(username, password).await
208 }
209
210 #[inline]
249 #[instrument(skip(self, site_name, serial_number, commissioned), level = "debug")]
250 pub async fn generate_token(
251 &self,
252 site_name: impl AsRef<str>,
253 serial_number: impl AsRef<str>,
254 commissioned: bool,
255 ) -> Result<String> {
256 let site_name_str = site_name.as_ref();
257 let serial_number_str = serial_number.as_ref();
258 debug!(
259 "Generating token for site: {}, serial: {}",
260 site_name_str, serial_number_str
261 );
262
263 let normalized_site = site_name_str.to_lowercase().replace(' ', "+");
265
266 let endpoint = format!("{}/entrez_tokens", self.base_url);
267 debug!("POST {endpoint}");
268
269 let form_data = [
270 ("uncommissioned", if commissioned { "on" } else { "off" }),
271 ("Site", normalized_site.as_str()),
272 ("serialNum", serial_number_str),
273 ];
274
275 let response = self.client.post(&endpoint).form(&form_data).send().await?;
276 debug!("Status code: {}", response.status());
277
278 let response_text = response.text().await?;
280
281 if let Some((_, rest)) = response_text.split_once(r#"id="JWTToken""#)
284 && let Some((_, start_textarea)) = rest.split_once('>')
285 && let Some((token_text, _)) = start_textarea.split_once("</textarea>")
286 {
287 let token = token_text.trim().to_owned();
288
289 if !token.is_empty() {
290 debug!("Token generated successfully");
291 return Ok(token);
292 }
293 }
294
295 Err(crate::error::EnphaseError::InvalidResponse(
296 "Failed to extract token from response".to_owned(),
297 ))
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use pretty_assertions::assert_eq;
305 use wiremock::matchers::{body_string_contains, method, path};
306 use wiremock::{Mock, MockServer, ResponseTemplate};
307
308 fn load_fixture(category: &str, name: &str) -> serde_json::Value {
310 let fixture_path = format!("fixtures/{category}/{name}.json");
311 let content = std::fs::read_to_string(&fixture_path)
312 .unwrap_or_else(|_| panic!("Failed to read fixture: {fixture_path}"));
313 serde_json::from_str(&content)
314 .unwrap_or_else(|_| panic!("Failed to parse fixture: {fixture_path}"))
315 }
316
317 #[tokio::test]
318 async fn login_success() {
319 let mock_server = MockServer::start().await;
320
321 let fixture = load_fixture("entrez", "login-success");
322 let status_code: u16 = fixture
323 .get("status_code")
324 .expect("status_code not found in fixture")
325 .as_u64()
326 .and_then(|v| v.try_into().ok())
327 .expect("status_code is not a valid u16");
328 let cookie_value = fixture
330 .get("headers")
331 .and_then(|h| h.as_array())
332 .and_then(|arr| {
333 arr.iter()
334 .filter_map(|v| v.as_str())
335 .find(|h| h.to_lowercase().starts_with("set-cookie:"))
336 .map(|h| {
337 h.trim_end_matches('\r')
338 .trim_start_matches("set-cookie:")
339 .trim()
340 })
341 })
342 .expect("set-cookie header not found in fixture")
343 .to_owned();
344
345 Mock::given(method("POST"))
346 .and(path("/login"))
347 .and(body_string_contains("username=test%40example.com"))
348 .and(body_string_contains("password=test_password"))
349 .and(body_string_contains("authFlow=entrezSession"))
350 .respond_with(
351 ResponseTemplate::new(status_code).append_header("Set-Cookie", &cookie_value),
352 )
353 .expect(1)
354 .mount(&mock_server)
355 .await;
356
357 let client = Entrez::new(mock_server.uri());
358 let result = client.login("test@example.com", "test_password").await;
359
360 assert!(
361 result.is_ok(),
362 "Login should succeed with valid credentials"
363 );
364 }
365
366 #[tokio::test]
367 async fn login_invalid_credentials() {
368 let mock_server = MockServer::start().await;
369
370 let fixture = load_fixture("entrez", "login-failure");
371 let status_code: u16 = fixture
372 .get("status_code")
373 .and_then(serde_json::Value::as_u64)
374 .and_then(|v| v.try_into().ok())
375 .expect("status_code is not a valid u16");
376 let body = fixture
377 .get("body")
378 .and_then(serde_json::Value::as_str)
379 .expect("body is not a string")
380 .to_owned();
381
382 Mock::given(method("POST"))
383 .and(path("/login"))
384 .respond_with(ResponseTemplate::new(status_code).set_body_string(&body))
385 .mount(&mock_server)
386 .await;
387
388 let client = Entrez::new(mock_server.uri());
389 let result = client.login("wrong@example.com", "wrong_password").await;
390
391 assert!(
394 result.is_ok(),
395 "Current implementation accepts any response"
396 );
397 }
398
399 #[tokio::test]
400 async fn login_network_error() {
401 let client = Entrez::new("http://localhost:1");
403 let result = client.login("test@example.com", "test_password").await;
404
405 assert!(result.is_err(), "Login should fail with network error");
406 if let Err(err) = result {
407 assert!(
408 matches!(err, crate::error::EnphaseError::Http(_)),
409 "Error should be HTTP error type"
410 );
411 }
412 }
413
414 #[tokio::test]
415 async fn generate_token_success() {
416 let mock_server = MockServer::start().await;
417
418 let fixture = load_fixture("entrez", "generate-token-success");
419 let status_code: u16 = fixture
420 .get("status_code")
421 .and_then(serde_json::Value::as_u64)
422 .and_then(|v| v.try_into().ok())
423 .expect("status_code is not a valid u16");
424 let html_response = fixture
425 .get("body")
426 .and_then(serde_json::Value::as_str)
427 .expect("body is not a string")
428 .to_owned();
429
430 Mock::given(method("POST"))
431 .and(path("/entrez_tokens"))
432 .respond_with(
433 ResponseTemplate::new(status_code)
434 .set_body_string(&html_response)
435 .insert_header("Content-Type", "text/html; charset=utf-8"),
436 )
437 .mount(&mock_server)
438 .await;
439
440 let client = Entrez::new(mock_server.uri());
441 let token = client
442 .generate_token("My Site", "121212121212", true)
443 .await
444 .expect("Token generation should succeed");
445
446 assert!(!token.is_empty(), "Token should not be empty");
448 }
449
450 #[tokio::test]
451 async fn generate_token_commissioned() {
452 let mock_server = MockServer::start().await;
453 let expected_token = "test_token_for_commissioned";
454
455 let html_response = format!(
456 r#"<html><body><textarea id="JWTToken">{expected_token}</textarea></body></html>"#
457 );
458
459 Mock::given(method("POST"))
460 .and(path("/entrez_tokens"))
461 .and(body_string_contains("uncommissioned=on"))
462 .respond_with(ResponseTemplate::new(200).set_body_string(html_response))
463 .mount(&mock_server)
464 .await;
465
466 let client = Entrez::new(mock_server.uri());
467 let token = client
468 .generate_token("Test Site", "603980032", true)
469 .await
470 .expect("Should succeed");
471
472 assert_eq!(token, expected_token);
473 }
474
475 #[tokio::test]
476 async fn generate_token_missing_textarea() {
477 let mock_server = MockServer::start().await;
478
479 let html_response = "<!DOCTYPE html>
481<html>
482<head><title>Error</title></head>
483<body>
484 <p>Error: Invalid request or unauthorized</p>
485</body>
486</html>";
487
488 Mock::given(method("POST"))
489 .and(path("/entrez_tokens"))
490 .respond_with(ResponseTemplate::new(200).set_body_string(html_response))
491 .mount(&mock_server)
492 .await;
493
494 let client = Entrez::new(mock_server.uri());
495 let result = client.generate_token("My Site", "121212121212", true).await;
496
497 assert!(result.is_err(), "Should fail when token not in response");
498 if let Err(err) = result {
499 assert!(
500 matches!(err, crate::error::EnphaseError::InvalidResponse(_)),
501 "Error should be InvalidResponse type"
502 );
503 }
504 }
505
506 #[expect(
507 clippy::multiple_unsafe_ops_per_block,
508 reason = "Setting and removing environment variables in tests"
509 )]
510 #[tokio::test]
511 async fn login_with_env_success() {
512 let mock_server = MockServer::start().await;
513
514 Mock::given(method("POST"))
515 .and(path("/login"))
516 .respond_with(
517 ResponseTemplate::new(200).append_header("Set-Cookie", "sessionId=xyz789; Path=/"),
518 )
519 .mount(&mock_server)
520 .await;
521
522 unsafe {
526 std::env::set_var("ENTREZ_USERNAME", "env_test@example.com");
527 std::env::set_var("ENTREZ_PASSWORD", "env_test_password");
528 }
529
530 let client = Entrez::new(mock_server.uri());
531 let result = client.login_with_env().await;
532
533 unsafe {
537 std::env::remove_var("ENTREZ_USERNAME");
538 std::env::remove_var("ENTREZ_PASSWORD");
539 }
540
541 assert!(
542 result.is_ok(),
543 "Login with env vars should succeed when vars are set"
544 );
545 }
546}