use serde::{Deserialize, Serialize};
use crate::client::idp::{self, GatewaySession, IdpLoginFlow};
use crate::config::Secret;
use crate::error::CoreError;
#[derive(Debug, Clone)]
pub struct SessionLoginOptions {
pub username: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StorageCookie {
pub name: String,
pub value: String,
pub domain: String,
pub path: String,
pub http_only: bool,
pub secure: bool,
pub same_site: String,
pub expires: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StorageState {
pub cookies: Vec<StorageCookie>,
pub origins: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SessionLoginResult {
pub cookie_name: String,
pub cookie_value: String,
pub csrf_token: String,
pub gateway_url: String,
pub storage_state: StorageState,
}
const SAME_SITE: &str = "Strict";
pub fn storage_state_for(
session: &GatewaySession,
base_url: &str,
) -> Result<StorageState, CoreError> {
let url = url::Url::parse(base_url)
.map_err(|err| CoreError::Internal(format!("invalid gateway URL: {err}")))?;
let domain = url
.host_str()
.ok_or_else(|| CoreError::Internal(format!("gateway URL {base_url:?} carries no host")))?
.to_string();
Ok(StorageState {
cookies: vec![StorageCookie {
name: session.cookie_name.clone(),
value: session.cookie_value.clone(),
domain,
path: "/".into(),
http_only: true,
secure: url.scheme() == "https",
same_site: SAME_SITE.into(),
expires: -1.0,
}],
origins: Vec::new(),
})
}
pub async fn session_login(
base_url: &str,
opts: &SessionLoginOptions,
password: &Secret,
) -> Result<SessionLoginResult, CoreError> {
let flow = IdpLoginFlow::new(base_url)?;
let (_flow, session) = idp::login(flow, &opts.username, password).await?;
let storage_state = storage_state_for(&session, base_url)?;
Ok(SessionLoginResult {
cookie_name: session.cookie_name,
cookie_value: session.cookie_value,
csrf_token: session.csrf_token,
gateway_url: base_url.to_string(),
storage_state,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_session() -> GatewaySession {
GatewaySession {
cookie_name: "webui-sid-1766878194".into(),
cookie_value: "session-value".into(),
csrf_token: "csrf-value".into(),
}
}
#[test]
fn storage_state_shape_follows_the_playwright_rules() {
let state = storage_state_for(&fixture_session(), "http://localhost:9088")
.expect("a parseable URL");
assert_eq!(state.origins.len(), 0, "origins stays empty");
assert_eq!(state.cookies.len(), 1, "exactly one cookie");
let cookie = &state.cookies[0];
assert_eq!(cookie.domain, "localhost", "the port is NOT in domain");
assert_eq!(cookie.path, "/");
assert!(cookie.http_only);
assert!(!cookie.secure, "an http gateway is not a secure cookie");
assert_eq!(cookie.same_site, "Strict");
assert_eq!(cookie.expires, -1.0);
}
#[test]
fn an_https_gateway_marks_the_cookie_secure() {
let state = storage_state_for(&fixture_session(), "https://gw.example.com:8043")
.expect("a parseable URL");
assert!(state.cookies[0].secure);
assert_eq!(
state.cookies[0].domain, "gw.example.com",
"the port is stripped from https too"
);
}
#[test]
fn an_unparseable_url_is_internal_not_a_panic() {
let err =
storage_state_for(&fixture_session(), "not a url").expect_err("a garbage URL refuses");
assert_eq!(err.exit_code(), 1, "the IdpLoginFlow::new posture");
}
#[test]
fn the_wire_keys_are_playwrights_camel_case() {
let state = storage_state_for(&fixture_session(), "http://localhost:9088").unwrap();
let wire = serde_json::to_value(&state).expect("serializes");
let cookie = &wire["cookies"][0];
assert!(cookie.get("httpOnly").is_some(), "httpOnly, not http_only");
assert!(cookie.get("sameSite").is_some(), "sameSite, not same_site");
assert!(cookie.get("http_only").is_none());
assert!(cookie.get("same_site").is_none());
assert!(cookie["expires"].is_number(), "expires rides as a number");
let back: StorageState = serde_json::from_value(wire).expect("round-trips");
assert_eq!(back, state);
}
}