diff --git a/docs/src/content/docs/en/reference/cli.mdx b/docs/src/content/docs/en/reference/cli.mdx index b3c04f0e3..f6ad9ec70 100644 --- a/docs/src/content/docs/en/reference/cli.mdx +++ b/docs/src/content/docs/en/reference/cli.mdx @@ -426,6 +426,7 @@ Emby: ```bash synctv provider emby login --username alice --server-endpoint https://emby.example --account-username alice --password 'pass' --instance-name emby-main +synctv provider emby login --username alice --server-endpoint https://emby.example --account-username guest --no-password --instance-name emby-main synctv provider emby list --username alice --server-id --path / --instance-name emby-main synctv provider emby me --username alice --server-id --instance-name emby-main synctv provider emby binds --username alice diff --git a/docs/src/content/docs/reference/cli.mdx b/docs/src/content/docs/reference/cli.mdx index 2188a8606..cfb27f28e 100644 --- a/docs/src/content/docs/reference/cli.mdx +++ b/docs/src/content/docs/reference/cli.mdx @@ -438,6 +438,7 @@ Emby 服务操作: ```bash synctv provider emby login --username alice --server-endpoint https://emby.example --account-username alice --password 'pass' --instance-name emby-main +synctv provider emby login --username alice --server-endpoint https://emby.example --account-username guest --no-password --instance-name emby-main synctv provider emby list --username alice --server-id --path / --instance-name emby-main synctv provider emby me --username alice --server-id --instance-name emby-main synctv provider emby binds --username alice diff --git a/synctv-media-providers/proto/emby.proto b/synctv-media-providers/proto/emby.proto index 4dc5b715c..fe04b1cac 100644 --- a/synctv-media-providers/proto/emby.proto +++ b/synctv-media-providers/proto/emby.proto @@ -22,7 +22,7 @@ message LoginReq { string host = 1; string username = 2; oneof credential { - string password = 3; + string password = 3; // Empty explicitly requests passwordless account authentication. string api_key = 4; } } diff --git a/synctv-media-providers/src/emby/service.rs b/synctv-media-providers/src/emby/service.rs index 47a660281..898f7430c 100644 --- a/synctv-media-providers/src/emby/service.rs +++ b/synctv-media-providers/src/emby/service.rs @@ -152,15 +152,8 @@ impl EmbyInterface for EmbyService { } match request.credential { Some(crate::transport_dto::emby::login_req::Credential::Password(password)) => { - let password = password.trim(); - if password.is_empty() { - return Err(EmbyError::InvalidConfig( - "password must not be empty".to_string(), - )); - } - let mut client = self.anonymous_client(&request.host)?; - let (token, user_id) = client.login(username, password).await?; + let (token, user_id) = client.login(username, &password).await?; let user_info = client.me().await?; if !username_matches(&user_info.name, username) { diff --git a/synctv-media-providers/src/grpc/emby_server.rs b/synctv-media-providers/src/grpc/emby_server.rs index d5f36cefd..764fccc17 100644 --- a/synctv-media-providers/src/grpc/emby_server.rs +++ b/synctv-media-providers/src/grpc/emby_server.rs @@ -23,6 +23,21 @@ pub struct EmbyService { service: EmbyServiceImpl, } +#[allow(clippy::result_large_err)] +fn validate_login_credential( + credential: Option<&super::emby::login_req::Credential>, +) -> Result<(), Status> { + match credential { + Some(super::emby::login_req::Credential::Password(_)) => Ok(()), + Some(super::emby::login_req::Credential::ApiKey(api_key)) => { + validate_required("api_key", api_key) + } + None => Err(Status::invalid_argument( + "exactly one of password or api_key must be provided", + )), + } +} + impl EmbyService { pub fn new() -> Result { Ok(Self { @@ -37,19 +52,7 @@ impl Emby for EmbyService { let req = request.into_inner(); validate_provider_grpc_host(&req.host)?; validate_required("username", &req.username)?; - match req.credential.as_ref() { - Some(super::emby::login_req::Credential::Password(password)) => { - validate_required("password", password)?; - } - Some(super::emby::login_req::Credential::ApiKey(api_key)) => { - validate_required("api_key", api_key)?; - } - None => { - return Err(Status::invalid_argument( - "exactly one of password or api_key must be provided", - )); - } - } + validate_login_credential(req.credential.as_ref())?; let resp = self .service .login(req) @@ -205,3 +208,23 @@ impl Emby for EmbyService { Ok(Response::new(resp)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn login_credential_allows_explicit_empty_password() { + let credential = super::super::emby::login_req::Credential::Password(String::new()); + + assert!(validate_login_credential(Some(&credential)).is_ok()); + } + + #[test] + fn login_credential_still_rejects_missing_credentials_and_empty_api_keys() { + assert!(validate_login_credential(None).is_err()); + + let credential = super::super::emby::login_req::Credential::ApiKey(String::new()); + assert!(validate_login_credential(Some(&credential)).is_err()); + } +} diff --git a/synctv-media-providers/tests/emby_tests.rs b/synctv-media-providers/tests/emby_tests.rs index 2cc9b8e53..c7c5810b9 100644 --- a/synctv-media-providers/tests/emby_tests.rs +++ b/synctv-media-providers/tests/emby_tests.rs @@ -3,6 +3,8 @@ //! Tests for item ID validation, API prefix detection, and HTTP API interactions using wiremock. #![allow(clippy::unwrap_used)] +use synctv_media_providers::emby::{EmbyInterface, EmbyService}; +use synctv_media_providers::transport_dto::emby::{login_req, LoginReq, LoginResp}; use synctv_media_providers::EmbyClient; use wiremock::matchers::{body_partial_json, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -28,6 +30,63 @@ async fn mock_public_info(server: &MockServer, prefix: &str) { .await; } +async fn login_through_service(password: &str) -> LoginResp { + let server = MockServer::start().await; + mock_public_info(&server, "/emby").await; + + Mock::given(method("POST")) + .and(path("/emby/Users/authenticatebyname")) + .and(body_partial_json(serde_json::json!({ + "Username": "guest", + "Pw": password, + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "AccessToken": "guest-token", + "User": { + "Id": "guest-id", + "Name": "guest" + } + }))) + .expect(1) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/emby/Users/guest-id")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "Id": "guest-id", + "Name": "guest", + "ServerId": "server-1" + }))) + .expect(1) + .mount(&server) + .await; + + EmbyService::with_client(reqwest::Client::new()) + .login(LoginReq { + host: server.uri(), + username: "guest".to_string(), + credential: Some(login_req::Credential::Password(password.to_string())), + }) + .await + .expect("Emby password login should succeed") +} + +#[tokio::test] +async fn test_emby_service_allows_passwordless_accounts() { + let response = login_through_service("").await; + + assert_eq!(response.token, "guest-token"); + assert_eq!(response.user_id, "guest-id"); +} + +#[tokio::test] +async fn test_emby_service_preserves_password_whitespace() { + let response = login_through_service(" secret ").await; + + assert_eq!(response.token, "guest-token"); +} + #[tokio::test] async fn test_validate_item_id_normal() { // Valid IDs should pass validation (but will fail on missing user_id since we don't set it) diff --git a/synctv-proto/proto/providers/emby.proto b/synctv-proto/proto/providers/emby.proto index da54d2c96..a02736d64 100644 --- a/synctv-proto/proto/providers/emby.proto +++ b/synctv-proto/proto/providers/emby.proto @@ -18,7 +18,7 @@ message LoginRequest { string host = 1 [(buf.validate.field).string.min_len = 1]; string username = 2 [(buf.validate.field).string.min_len = 1]; // Target Emby username to bind and validate oneof credential { - string password = 3; // SECURITY: Plaintext password - requires TLS in transit. + string password = 3; // Plaintext password; empty explicitly selects passwordless login. Requires TLS in transit. string api_key = 4; // SECURITY: Plaintext credential - requires TLS in transit. } string instance_name = 5; // Optional provider instance name diff --git a/synctv/src/cli/commands/provider/emby.rs b/synctv/src/cli/commands/provider/emby.rs index ea65ed511..7328bcf0b 100644 --- a/synctv/src/cli/commands/provider/emby.rs +++ b/synctv/src/cli/commands/provider/emby.rs @@ -27,7 +27,7 @@ pub enum ProviderEmbySubcommand { #[derive(Debug, Args)] #[command(group( ArgGroup::new("emby_login_credential") - .args(["password", "api_key"]) + .args(["password", "no_password", "api_key"]) .required(true) .multiple(false) ))] @@ -43,11 +43,15 @@ pub struct ProviderEmbyLoginArgs { #[arg(long)] pub account_username: String, - /// Emby/Jellyfin account password. Conflicts with --api-key. + /// Emby/Jellyfin account password. Conflicts with --no-password and --api-key. #[arg(long, group = "emby_login_credential")] pub password: Option, - /// Emby/Jellyfin API key. Conflicts with --password. + /// Authenticate an Emby/Jellyfin account that has no password. + #[arg(long, group = "emby_login_credential")] + pub no_password: bool, + + /// Emby/Jellyfin API key. Conflicts with --password and --no-password. #[arg(long, group = "emby_login_credential")] pub api_key: Option, @@ -63,12 +67,17 @@ pub(in crate::cli) fn emby_login_credential( synctv_proto::providers::emby::login_request::Credential::Password(password.clone()), ); } + if args.no_password { + return Ok( + synctv_proto::providers::emby::login_request::Credential::Password(String::new()), + ); + } if let Some(api_key) = &args.api_key { return Ok( synctv_proto::providers::emby::login_request::Credential::ApiKey(api_key.clone()), ); } - bail!("Emby login requires --password or --api-key") + bail!("Emby login requires --password, --no-password, or --api-key") } #[derive(Debug, Args)] diff --git a/synctv/src/cli/tests.rs b/synctv/src/cli/tests.rs index 5f058500d..1747b83ab 100644 --- a/synctv/src/cli/tests.rs +++ b/synctv/src/cli/tests.rs @@ -3447,6 +3447,7 @@ fn cli_parses_provider_emby_login_with_api_key() { assert_eq!(args.server_endpoint, "https://emby.example.com"); assert_eq!(args.account_username, "emby-user"); assert_eq!(args.password, None); + assert!(!args.no_password); assert_eq!(args.api_key.as_deref(), Some("emby-api-key")); assert_eq!(args.instance.instance_name.as_deref(), Some("emby-edge")); } @@ -3479,12 +3480,47 @@ fn cli_parses_provider_emby_login_with_password() { }) => { assert_eq!(args.access.actor.username.as_deref(), Some("alice")); assert_eq!(args.password.as_deref(), Some("secret")); + assert!(!args.no_password); assert_eq!(args.api_key, None); } other => panic!("unexpected command parsed: {other:?}"), } } +#[test] +fn cli_parses_provider_emby_login_without_password() { + let cli = Cli::parse_from([ + "synctv", + "provider", + "emby", + "login", + "--username", + "alice", + "--server-endpoint", + "https://emby.example.com", + "--account-username", + "emby-user", + "--no-password", + ]); + match cli.command { + Commands::Provider(ProviderCommand { + command: + ProviderSubcommand::Emby(ProviderEmbyCommand { + command: ProviderEmbySubcommand::Login(args), + }), + }) => { + assert_eq!(args.password, None); + assert!(args.no_password); + assert_eq!(args.api_key, None); + assert_eq!( + emby_login_credential(&args).expect("no-password credential should build"), + synctv_proto::providers::emby::login_request::Credential::Password(String::new()) + ); + } + other => panic!("unexpected command parsed: {other:?}"), + } +} + #[test] fn cli_parses_provider_emby_list() { let cli = Cli::parse_from([ @@ -3622,6 +3658,30 @@ fn cli_rejects_provider_emby_login_with_both_password_and_api_key() { ); } +#[test] +fn cli_rejects_provider_emby_login_with_password_and_no_password() { + let result = Cli::try_parse_from([ + "synctv", + "provider", + "emby", + "login", + "--username", + "alice", + "--server-endpoint", + "https://emby.example.com", + "--account-username", + "emby-user", + "--password", + "secret", + "--no-password", + ]); + + assert!( + result.is_err(), + "provider emby login must reject simultaneous password modes" + ); +} + #[test] fn cli_parses_provider_bilibili_parse() { let cli = Cli::parse_from([