diff --git a/doc/dbus/bus/org.opensuse.Agama.Software1.Product.bus.xml b/doc/dbus/bus/org.opensuse.Agama.Software1.Product.bus.xml new file mode 100644 index 0000000000..7bd3ab733f --- /dev/null +++ b/doc/dbus/bus/org.opensuse.Agama.Software1.Product.bus.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml b/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml index 82970322e0..9535bb3c0f 100644 --- a/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml +++ b/doc/dbus/bus/org.opensuse.Agama.Software1.bus.xml @@ -1,5 +1,6 @@ + @@ -28,12 +29,18 @@ - - - - + + + + + + + + + + @@ -54,8 +61,10 @@ - - + + + + @@ -66,8 +75,4 @@ - - - - diff --git a/doc/dbus/bus/org.opensuse.Agama1.Registration.bus.xml b/doc/dbus/bus/org.opensuse.Agama1.Registration.bus.xml new file mode 120000 index 0000000000..9cfd11ce93 --- /dev/null +++ b/doc/dbus/bus/org.opensuse.Agama1.Registration.bus.xml @@ -0,0 +1 @@ +org.opensuse.Agama.Software1.Product.bus.xml \ No newline at end of file diff --git a/doc/dbus/org.opensuse.Agama.Software1.Product.doc.xml b/doc/dbus/org.opensuse.Agama.Software1.Product.doc.xml new file mode 100644 index 0000000000..165a67094b --- /dev/null +++ b/doc/dbus/org.opensuse.Agama.Software1.Product.doc.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + diff --git a/doc/dbus/org.opensuse.Agama.Software1.doc.xml b/doc/dbus/org.opensuse.Agama.Software1.doc.xml new file mode 100644 index 0000000000..bacbe87d94 --- /dev/null +++ b/doc/dbus/org.opensuse.Agama.Software1.doc.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/dbus/org.opensuse.Agama1.Progress.doc.xml b/doc/dbus/org.opensuse.Agama1.Progress.doc.xml index 7c2cc909fd..1a1a3ed0e0 100644 --- a/doc/dbus/org.opensuse.Agama1.Progress.doc.xml +++ b/doc/dbus/org.opensuse.Agama1.Progress.doc.xml @@ -1,7 +1,9 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/products.d/ALP-Dolomite.yaml b/products.d/ALP-Dolomite.yaml index 345c7a2461..c2d4e7d039 100644 --- a/products.d/ALP-Dolomite.yaml +++ b/products.d/ALP-Dolomite.yaml @@ -15,16 +15,6 @@ translations: bezpečnost pro poskytování úplného minima ke spuštění úloh a služeb v kontejnerech nebo virtuálních strojích. software: - installation_repositories: - - url: https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/x86_64/product/ - archs: x86_64 - - url: https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/aarch64/product/ - archs: aarch64 - - url: https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/s390x/product/ - archs: s390 - - url: https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/ppc64le/product/ - archs: ppc - mandatory_patterns: - alp_base_zypper - alp_cockpit diff --git a/rust/agama-lib/src/error.rs b/rust/agama-lib/src/error.rs index e9a9f205ca..7124e00cbb 100644 --- a/rust/agama-lib/src/error.rs +++ b/rust/agama-lib/src/error.rs @@ -10,14 +10,14 @@ pub enum ServiceError { DBus(#[from] zbus::Error), #[error("Could not connect to Agama bus at '{0}'")] DBusConnectionError(String, #[source] zbus::Error), - #[error("Unknown product '{0}'. Available products: '{1:?}'")] - UnknownProduct(String, Vec), // it's fine to say only "Error" because the original // specific error will be printed too #[error("Error: {0}")] Anyhow(#[from] anyhow::Error), #[error("Wrong user parameters: '{0:?}'")] WrongUser(Vec), + #[error("Error: {0}")] + UnsuccessfulAction(String), } #[derive(Error, Debug)] diff --git a/rust/agama-lib/src/software/client.rs b/rust/agama-lib/src/software/client.rs index 1a56faf9da..2d6ae30c39 100644 --- a/rust/agama-lib/src/software/client.rs +++ b/rust/agama-lib/src/software/client.rs @@ -1,4 +1,4 @@ -use super::proxies::Software1Proxy; +use super::proxies::SoftwareProductProxy; use crate::error::ServiceError; use serde::Serialize; use zbus::Connection; @@ -16,21 +16,21 @@ pub struct Product { /// D-Bus client for the software service pub struct SoftwareClient<'a> { - software_proxy: Software1Proxy<'a>, + product_proxy: SoftwareProductProxy<'a>, } impl<'a> SoftwareClient<'a> { pub async fn new(connection: Connection) -> Result, ServiceError> { Ok(Self { - software_proxy: Software1Proxy::new(&connection).await?, + product_proxy: SoftwareProductProxy::new(&connection).await?, }) } /// Returns the available products pub async fn products(&self) -> Result, ServiceError> { let products: Vec = self - .software_proxy - .available_base_products() + .product_proxy + .available_products() .await? .into_iter() .map(|(id, name, data)| { @@ -50,11 +50,22 @@ impl<'a> SoftwareClient<'a> { /// Returns the selected product to install pub async fn product(&self) -> Result { - Ok(self.software_proxy.selected_base_product().await?) + Ok(self.product_proxy.selected_product().await?) } /// Selects the product to install pub async fn select_product(&self, product_id: &str) -> Result<(), ServiceError> { - Ok(self.software_proxy.select_product(product_id).await?) + let result = self.product_proxy.select_product(product_id).await?; + + match result { + (0, _) => Ok(()), + (3, description) => { + let products = self.products().await?; + let ids: Vec = products.into_iter().map(|p| p.id).collect(); + let error = format!("{0}. Available products: '{1:?}'", description, ids); + Err(ServiceError::UnsuccessfulAction(error)) + } + (_, description) => Err(ServiceError::UnsuccessfulAction(description)), + } } } diff --git a/rust/agama-lib/src/software/proxies.rs b/rust/agama-lib/src/software/proxies.rs index 8803fce495..8fc081b3c6 100644 --- a/rust/agama-lib/src/software/proxies.rs +++ b/rust/agama-lib/src/software/proxies.rs @@ -1,6 +1,6 @@ //! D-Bus interface proxies for: `org.opensuse.Agama.Software1.*` //! -//! This code was generated by `zbus-xmlgen` `3.1.0` from DBus introspection data.`. +//! This code was generated by `zbus-xmlgen` `3.1.1` from DBus introspection data. use zbus::dbus_proxy; #[dbus_proxy( @@ -9,6 +9,9 @@ use zbus::dbus_proxy; default_path = "/org/opensuse/Agama/Software1" )] trait Software1 { + /// AddPattern method + fn add_pattern(&self, id: &str) -> zbus::Result<()>; + /// Finish method fn finish(&self) -> zbus::Result<()>; @@ -18,6 +21,12 @@ trait Software1 { /// IsPackageInstalled method fn is_package_installed(&self, name: &str) -> zbus::Result; + /// ListPatterns method + fn list_patterns( + &self, + filtered: bool, + ) -> zbus::Result>; + /// Probe method fn probe(&self) -> zbus::Result<()>; @@ -27,15 +36,32 @@ trait Software1 { /// ProvisionsSelected method fn provisions_selected(&self, provisions: &[&str]) -> zbus::Result>; - /// SelectProduct method - fn select_product(&self, product_id: &str) -> zbus::Result<()>; + /// RemovePattern method + fn remove_pattern(&self, id: &str) -> zbus::Result<()>; + + /// SetUserPatterns method + fn set_user_patterns(&self, ids: &[&str]) -> zbus::Result<()>; /// UsedDiskSpace method fn used_disk_space(&self) -> zbus::Result; - /// AvailableBaseProducts property + /// SelectedPatterns property + #[dbus_proxy(property)] + fn selected_patterns(&self) -> zbus::Result>; +} + +#[dbus_proxy( + interface = "org.opensuse.Agama.Software1.Product", + default_service = "org.opensuse.Agama.Software1", + default_path = "/org/opensuse/Agama/Software1/Product" +)] +trait SoftwareProduct { + /// SelectProduct method + fn select_product(&self, id: &str) -> zbus::Result<(u32, String)>; + + /// AvailableProducts property #[dbus_proxy(property)] - fn available_base_products( + fn available_products( &self, ) -> zbus::Result< Vec<( @@ -45,9 +71,9 @@ trait Software1 { )>, >; - /// SelectedBaseProduct property + /// SelectedProduct property #[dbus_proxy(property)] - fn selected_base_product(&self) -> zbus::Result; + fn selected_product(&self) -> zbus::Result; } #[dbus_proxy( diff --git a/rust/agama-lib/src/software/store.rs b/rust/agama-lib/src/software/store.rs index 45706ed531..9a8a5fb365 100644 --- a/rust/agama-lib/src/software/store.rs +++ b/rust/agama-lib/src/software/store.rs @@ -29,15 +29,10 @@ impl<'a> SoftwareStore<'a> { pub async fn store(&self, settings: &SoftwareSettings) -> Result<(), ServiceError> { if let Some(product) = &settings.product { - let products = self.software_client.products().await?; - let ids: Vec = products.into_iter().map(|p| p.id).collect(); - if ids.contains(product) { - self.software_client.select_product(product).await?; - self.manager_client.probe().await?; - } else { - return Err(ServiceError::UnknownProduct(product.clone(), ids)); - } + self.software_client.select_product(product).await?; + self.manager_client.probe().await?; } + Ok(()) } } diff --git a/rust/package/agama-cli.changes b/rust/package/agama-cli.changes index 75e95ad9f3..940cd18117 100644 --- a/rust/package/agama-cli.changes +++ b/rust/package/agama-cli.changes @@ -1,4 +1,10 @@ ------------------------------------------------------------------- +Wed Nov 15 12:35:32 UTC 2023 - José Iván López González + +- Adapt to changes in software D-Bus API (gh#openSUSE/agama#869). + +------------------------------------------------------------------- + Wed Nov 15 11:27:10 UTC 2023 - Michal Filka - Improved "agama logs store" (gh#openSUSE/agama#823) @@ -14,7 +20,7 @@ Mon Oct 23 14:43:59 UTC 2023 - Michal Filka - Improved "agama logs store" (gh#openSUSE/agama#812) - the archive file owner is root:root - - the permissions is set to r/w for the owner + - the permissions is set to r/w for the owner ------------------------------------------------------------------- Mon Oct 23 11:33:40 UTC 2023 - Imobach Gonzalez Sosa @@ -58,7 +64,7 @@ Tue Sep 19 11:16:16 UTC 2023 - José Iván López González ------------------------------------------------------------------- Thu Sep 14 19:44:57 UTC 2023 - Josef Reidinger -- Improve questions CLI help text (gh#openSUSE/agama#754) +- Improve questions CLI help text (gh#openSUSE/agama#754) ------------------------------------------------------------------- Thu Sep 14 10:10:37 UTC 2023 - Imobach Gonzalez Sosa diff --git a/service/Gemfile.lock b/service/Gemfile.lock index c114df5bb3..89fdf2541e 100644 --- a/service/Gemfile.lock +++ b/service/Gemfile.lock @@ -9,7 +9,7 @@ PATH fast_gettext (~> 2.2.0) nokogiri (~> 1.13.1) rexml (~> 3.2.5) - ruby-dbus (>= 0.23.0.beta2, < 1.0) + ruby-dbus (>= 0.23.1, < 1.0) GEM remote: https://rubygems.org/ @@ -49,7 +49,7 @@ GEM rspec-support (~> 3.11.0) rspec-support (3.11.0) ruby-augeas (0.5.0) - ruby-dbus (0.23.0.beta2) + ruby-dbus (0.23.1) rexml simplecov (0.21.2) docile (~> 1.1) diff --git a/service/agama.gemspec b/service/agama.gemspec index 4f254b40b7..4cf49d25ca 100644 --- a/service/agama.gemspec +++ b/service/agama.gemspec @@ -58,5 +58,5 @@ Gem::Specification.new do |spec| spec.add_dependency "fast_gettext", "~> 2.2.0" spec.add_dependency "nokogiri", "~> 1.13.1" spec.add_dependency "rexml", "~> 3.2.5" - spec.add_dependency "ruby-dbus", ">= 0.23.0.beta2", "< 1.0" + spec.add_dependency "ruby-dbus", ">= 0.23.1", "< 1.0" end diff --git a/service/lib/agama/config.rb b/service/lib/agama/config.rb index 698bf1bfd4..0e524d648c 100644 --- a/service/lib/agama/config.rb +++ b/service/lib/agama/config.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022] SUSE LLC +# Copyright (c) [2022-2023] SUSE LLC # # All Rights Reserved. # @@ -139,6 +139,51 @@ def merge(config) Config.new(simple_merge(data, config.data)) end + # Elements that match the current arch. + # + # @example + # config.products #=> + # { + # "ALP-Dolomite" => { + # "software" => { + # "installation_repositories" => [ + # { + # "url" => "https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/x86_64/product/", + # "archs" => "x86_64" + # }, + # { + # "url" => https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/aarch64/product/", + # "archs" => "aarch64" + # }, + # "https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/noarch/" + # ] + # } + # } + # } + # + # Yast::Arch.rpm_arch #=> "x86_64" + # config.arch_elements_from("ALP-Dolomite", "software", "installation_repositories", + # property: :url) #=> ["https://.../SUSE/Products/ALP-Dolomite/1.0/x86_64/product/", + # #=> "https://updates.suse.com/SUSE/Products/ALP-Dolomite/1.0/noarch/"] + # + # @param keys [Array] Config data keys of the collection. + # @param property [Symbol|String|nil] Property to retrieve of the elements. + # + # @return [Array] + def arch_elements_from(*keys, property: nil) + keys.map!(&:to_s) + elements = products.dig(*keys) + return [] unless elements.is_a?(Array) + + elements.map do |element| + if !element.is_a?(Hash) + element + elsif arch_match?(element["archs"]) + property ? element[property.to_s] : element + end + end.compact + end + private # Simple deep merge @@ -157,5 +202,15 @@ def simple_merge(a_hash, another_hash) end end end + + # Whether the current arch matches any of the given archs. + # + # @param archs [String] E.g., "x86_64,aarch64" + # @return [Boolean] + def arch_match?(archs) + return true if archs.nil? + + Yast2::ArchFilter.from_string(archs).match? + end end end diff --git a/service/lib/agama/dbus/clients/software.rb b/service/lib/agama/dbus/clients/software.rb index 8b953d705d..9ff10a4fc6 100644 --- a/service/lib/agama/dbus/clients/software.rb +++ b/service/lib/agama/dbus/clients/software.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022] SUSE LLC +# Copyright (c) [2022-2023] SUSE LLC # # All Rights Reserved. # @@ -20,18 +20,18 @@ # find current contact information at www.suse.com. require "agama/dbus/clients/base" -require "agama/dbus/clients/with_service_status" +require "agama/dbus/clients/with_issues" require "agama/dbus/clients/with_progress" -require "agama/dbus/clients/with_validation" +require "agama/dbus/clients/with_service_status" module Agama module DBus module Clients # D-Bus client for software configuration class Software < Base - include WithServiceStatus + include WithIssues include WithProgress - include WithValidation + include WithServiceStatus TYPES = [:package, :pattern].freeze private_constant :TYPES @@ -42,6 +42,9 @@ def initialize @dbus_object = service["/org/opensuse/Agama/Software1"] @dbus_object.introspect + @dbus_product = service["/org/opensuse/Agama/Software1/Product"] + @dbus_product.introspect + @dbus_proposal = service["/org/opensuse/Agama/Software1/Proposal"] @dbus_proposal.introspect end @@ -55,7 +58,7 @@ def service_name # # @return [Array>] name and display name of each product def available_products - dbus_object["org.opensuse.Agama.Software1"]["AvailableBaseProducts"].map do |l| + dbus_product["org.opensuse.Agama.Software1.Product"]["AvailableProducts"].map do |l| l[0..1] end end @@ -64,7 +67,7 @@ def available_products # # @return [String, nil] name of the product def selected_product - product = dbus_object["org.opensuse.Agama.Software1"]["SelectedBaseProduct"] + product = dbus_product["org.opensuse.Agama.Software1.Product"]["SelectedProduct"] return nil if product.empty? product @@ -74,7 +77,7 @@ def selected_product # # @param name [String] def select_product(name) - dbus_object.SelectProduct(name) + dbus_product.SelectProduct(name) end # Starts the probing process @@ -169,9 +172,9 @@ def remove_resolvables(unique_id, type, resolvables, optional: false) # # @param block [Proc] Callback to run when a product is selected def on_product_selected(&block) - on_properties_change(dbus_object) do |_, changes, _| - base_product = changes["SelectedBaseProduct"] - block.call(base_product) unless base_product.nil? + on_properties_change(dbus_product) do |_, changes, _| + product = changes["SelectedProduct"] + block.call(product) unless product.nil? end end @@ -180,6 +183,9 @@ def on_product_selected(&block) # @return [::DBus::Object] attr_reader :dbus_object + # @return [::DBus::Object] + attr_reader :dbus_product + # @return [::DBus::Object] attr_reader :dbus_proposal end diff --git a/service/lib/agama/dbus/clients/with_issues.rb b/service/lib/agama/dbus/clients/with_issues.rb index a4a1dc363e..cc7ba3ea4e 100644 --- a/service/lib/agama/dbus/clients/with_issues.rb +++ b/service/lib/agama/dbus/clients/with_issues.rb @@ -28,10 +28,25 @@ module WithIssues ISSUES_IFACE = "org.opensuse.Agama1.Issues" private_constant :ISSUES_IFACE - # Returns the issues + # Returns issues from all objects that implement the issues interface. # # @return [Array] def issues + objects_with_issues_interface.map { |o| issues_from(o) }.flatten + end + + # Determines whether there are errors + # + # @return [Boolean] + def errors? + issues.any?(&:error?) + end + + def objects_with_issues_interface + service.root.descendant_objects.select { |o| o.interfaces.include?(ISSUES_IFACE) } + end + + def issues_from(dbus_object) sources = [nil, Issue::Source::SYSTEM, Issue::Source::CONFIG] severities = [Issue::Severity::WARN, Issue::Severity::ERROR] @@ -42,13 +57,6 @@ def issues severity: severities[dbus_issue[3]]) end end - - # Determines whether there are errors - # - # @return [Boolean] - def errors? - issues.any?(&:error?) - end end end end diff --git a/service/lib/agama/dbus/software.rb b/service/lib/agama/dbus/software.rb index 670cc4517e..ace66f64fc 100644 --- a/service/lib/agama/dbus/software.rb +++ b/service/lib/agama/dbus/software.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022] SUSE LLC +# Copyright (c) [2022-2023] SUSE LLC # # All Rights Reserved. # @@ -28,4 +28,5 @@ module Software end require "agama/dbus/software/manager" +require "agama/dbus/software/product" require "agama/dbus/software/proposal" diff --git a/service/lib/agama/dbus/software/manager.rb b/service/lib/agama/dbus/software/manager.rb index 25987eea04..aad196e3cb 100644 --- a/service/lib/agama/dbus/software/manager.rb +++ b/service/lib/agama/dbus/software/manager.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022] SUSE LLC +# Copyright (c) [2022-2023] SUSE LLC # # All Rights Reserved. # @@ -21,12 +21,12 @@ require "dbus" require "agama/dbus/base_object" -require "agama/dbus/with_service_status" require "agama/dbus/clients/locale" require "agama/dbus/clients/network" +require "agama/dbus/interfaces/issues" require "agama/dbus/interfaces/progress" require "agama/dbus/interfaces/service_status" -require "agama/dbus/interfaces/validation" +require "agama/dbus/with_service_status" module Agama module DBus @@ -36,7 +36,7 @@ class Manager < BaseObject include WithServiceStatus include Interfaces::Progress include Interfaces::ServiceStatus - include Interfaces::Validation + include Interfaces::Issues PATH = "/org/opensuse/Agama/Software1" private_constant :PATH @@ -54,35 +54,17 @@ def initialize(backend, logger) @selected_patterns = {} end + # List of software related issues, see {DBus::Interfaces::Issues} + # + # @return [Array] + def issues + backend.issues + end + SOFTWARE_INTERFACE = "org.opensuse.Agama.Software1" private_constant :SOFTWARE_INTERFACE dbus_interface SOFTWARE_INTERFACE do - dbus_reader :available_base_products, "a(ssa{sv})" - - dbus_reader :selected_base_product, "s" - - # documented way to be able to write to patterns and trigger signal - attr_writer :selected_patterns - - # selected patterns is hash with pattern name as id and 0 for user selected and - # 1 for auto selected. Can be extended in future e.g. for mandatory patterns - dbus_attr_reader :selected_patterns, "a{sy}" - - dbus_method :SelectProduct, "in ProductID:s" do |product_id| - old_product_id = backend.product - - if old_product_id == product_id - logger.info "Do not changing the product as it is still the same (#{product_id})" - return - end - - logger.info "Selecting product #{product_id}" - select_product(product_id) - dbus_properties_changed(SOFTWARE_INTERFACE, { "SelectedBaseProduct" => product_id }, []) - update_validation # as different product means different software selection - end - # value of result hash is category, description, icon, summary and order dbus_method :ListPatterns, "in Filtered:b, out Result:a{s(sssss)}" do |filtered| [ @@ -101,6 +83,10 @@ def initialize(backend, logger) ] end + # selected patterns is hash with pattern name as id and 0 for user selected and + # 1 for auto selected. Can be extended in future e.g. for mandatory patterns + dbus_reader_attr_accessor :selected_patterns, "a{sy}" + dbus_method(:AddPattern, "in id:s") { |p| backend.add_pattern(p) } dbus_method(:RemovePattern, "in id:s") { |p| backend.remove_pattern(p) } dbus_method(:SetUserPatterns, "in ids:as") { |ids| backend.user_patterns = ids } @@ -121,32 +107,12 @@ def initialize(backend, logger) dbus_method(:Finish) { finish } end - def available_base_products - backend.products.map do |id, data| - [id, data["name"], { "description" => localized_description(data) }].freeze - end - end - - # Returns the selected base product - # - # @return [String] Product ID or an empty string if no product is selected - def selected_base_product - backend.product || "" - end - - def select_product(product_id) - backend.select_product(product_id) - end - def probe busy_while { backend.probe } - - update_validation # probe do force proposal end def propose busy_while { backend.propose } - update_validation nil # explicit nil as return value end @@ -179,31 +145,8 @@ def register_callbacks backend.on_selected_patterns_change do self.selected_patterns = compute_patterns end - end - # find translated product description if available - # @param data [Hash] product configuration from the YAML file - # @return [String,nil] Translated product description (if available) - # or the untranslated description, nil if not found - def localized_description(data) - translations = data["translations"]&.[]("description") - lang = ENV["LANG"] || "" - - # no translations or language not set, return untranslated value - return data["description"] if !translations.is_a?(Hash) || lang.empty? - - # remove the character encoding if present - lang = lang.split(".").first - # full matching (language + country) - return translations[lang] if translations[lang] - - # remove the country part - lang = lang.split("_").first - # partial match (just the language) - return translations[lang] if translations[lang] - - # fallback to original untranslated description - data["description"] + backend.on_issues_change { issues_properties_changed } end USER_SELECTED_PATTERN = 0 diff --git a/service/lib/agama/dbus/software/product.rb b/service/lib/agama/dbus/software/product.rb new file mode 100644 index 0000000000..6c94418a9d --- /dev/null +++ b/service/lib/agama/dbus/software/product.rb @@ -0,0 +1,294 @@ +# frozen_string_literal: true + +# Copyright (c) [2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "dbus" +require "suse/connect" +require "agama/dbus/base_object" +require "agama/dbus/interfaces/issues" +require "agama/registration" + +module Agama + module DBus + module Software + # D-Bus object to manage product configuration. + class Product < BaseObject + include Interfaces::Issues + + PATH = "/org/opensuse/Agama/Software1/Product" + private_constant :PATH + + # @param backend [Agama::Software::Manager] + # @param logger [Logger] + def initialize(backend, logger) + super(PATH, logger: logger) + @backend = backend + @logger = logger + register_callbacks + end + + # List of issues, see {DBus::Interfaces::Issues}. + # + # @return [Array] + def issues + backend.product_issues + end + + def available_products + backend.products.map do |product| + [product.id, product.display_name, { "description" => product.localized_description }] + end + end + + # Returns the selected base product. + # + # @return [String] Product ID or an empty string if no product is selected + def selected_product + backend.product&.id || "" + end + + # Selects a product. + # + # @param id [String] Product id. + # @return [Array(Integer, String)] Result code and a description. + # Possible result codes: + # 0: success + # 1: already selected + # 2: deregister first + # 3: unknown product + def select_product(id) + if backend.product&.id == id + [1, "Product is already selected"] + elsif backend.registration.reg_code + [2, "Current product must be deregistered first"] + else + backend.select_product(id) + [0, ""] + end + rescue ArgumentError + [3, "Unknown product"] + end + + PRODUCT_INTERFACE = "org.opensuse.Agama.Software1.Product" + private_constant :PRODUCT_INTERFACE + + dbus_interface PRODUCT_INTERFACE do + dbus_reader :available_products, "a(ssa{sv})" + + dbus_reader :selected_product, "s" + + dbus_method :SelectProduct, "in id:s, out result:(us)" do |id| + logger.info "Selecting product #{id}" + + code, description = select_product(id) + + if code == 0 + dbus_properties_changed(PRODUCT_INTERFACE, { "SelectedProduct" => id }, []) + dbus_properties_changed(REGISTRATION_INTERFACE, { "Requirement" => requirement }, []) + # FIXME: Product issues might change after selecting a product. Nevertheless, + # #on_issues_change callbacks should be used for emitting issues signals, ensuring + # they are emitted every time the backend changes its issues. Currently, + # #on_issues_change cannot be used for product issues. Note that Software::Manager + # backend takes care of both software and product issues. And it already uses + # #on_issues_change callbacks for software related issues. + issues_properties_changed + end + + [[code, description]] + end + end + + def reg_code + backend.registration.reg_code || "" + end + + def email + backend.registration.email || "" + end + + # Registration requirement. + # + # @return [Integer] Possible values: + # 0: not required + # 1: optional + # 2: mandatory + def requirement + case backend.registration.requirement + when Agama::Registration::Requirement::MANDATORY + 2 + when Agama::Registration::Requirement::OPTIONAL + 1 + else + 0 + end + end + + # Tries to register with the given registration code. + # + # @note Software is not automatically probed after registering the product. The reason is + # to avoid dealing with possible probing issues in the registration D-Bus API. Clients + # have to explicitly call to #Probe after registering a product. + # + # @param reg_code [String] + # @param email [String, nil] + # + # @return [Array(Integer, String)] Result code and a description. + # Possible result codes: + # 0: success + # 1: missing product + # 2: already registered + # 3: registration not required + # 4: network error + # 5: timeout error + # 6: api error + # 7: missing credentials + # 8: incorrect credentials + # 9: invalid certificate + # 10: internal error (e.g., parsing json data) + def register(reg_code, email: nil) + if !backend.product + [1, "Product not selected yet"] + elsif backend.registration.reg_code + [2, "Product already registered"] + elsif backend.registration.requirement == Agama::Registration::Requirement::NOT_REQUIRED + [3, "Product does not require registration"] + else + connect_result(first_error_code: 4) do + backend.registration.register(reg_code, email: email) + end + end + end + + # Tries to deregister. + # + # @note Software is not automatically probed after deregistering the product. The reason is + # to avoid dealing with possible probing issues in the deregistration D-Bus API. Clients + # have to explicitly call to #Probe after deregistering a product. + # + # @return [Array(Integer, String)] Result code and a description. + # Possible result codes: + # 0: success + # 1: missing product + # 2: not registered yet + # 3: network error + # 4: timeout error + # 5: api error + # 6: missing credentials + # 7: incorrect credentials + # 8: invalid certificate + # 9: internal error (e.g., parsing json data) + def deregister + if !backend.product + [1, "Product not selected yet"] + elsif !backend.registration.reg_code + [2, "Product not registered yet"] + else + connect_result(first_error_code: 3) do + backend.registration.deregister + end + end + end + + REGISTRATION_INTERFACE = "org.opensuse.Agama1.Registration" + private_constant :REGISTRATION_INTERFACE + + dbus_interface REGISTRATION_INTERFACE do + dbus_reader(:reg_code, "s") + + dbus_reader(:email, "s") + + dbus_reader(:requirement, "u") + + dbus_method(:Register, "in reg_code:s, in options:a{sv}, out result:(us)") do |*args| + [register(args[0], email: args[1]["Email"])] + end + + dbus_method(:Deregister, "out result:(us)") { [deregister] } + end + + private + + # @return [Agama::Software] + attr_reader :backend + + # @return [Logger] + attr_reader :logger + + # Registers callback to be called + def register_callbacks + # FIXME: Product issues might change after changing the registration. Nevertheless, + # #on_issues_change callbacks should be used for emitting issues signals, ensuring they + # are emitted every time the backend changes its issues. Currently, #on_issues_change + # cannot be used for product issues. Note that Software::Manager backend takes care of + # both software and product issues. And it already uses #on_issues_change callbacks for + # software related issues. + backend.registration.on_change { issues_properties_changed } + backend.registration.on_change { registration_properties_changed } + end + + def registration_properties_changed + dbus_properties_changed(REGISTRATION_INTERFACE, + interfaces_and_properties[REGISTRATION_INTERFACE], []) + end + + # Result from calling to SUSE connect. + # + # @raise [Exception] if an unexpected error is found. + # + # @return [Array(Integer, String)] List including a result code and a description + # (e.g., [1, "Connection to registration server failed (network error)"]). + def connect_result(first_error_code: 1, &block) + block.call + [0, ""] + rescue SocketError => e + connect_result_from_error(e, first_error_code, "network error") + rescue Timeout::Error => e + connect_result_from_error(e, first_error_code + 1, "timeout") + rescue SUSE::Connect::ApiError => e + connect_result_from_error(e, first_error_code + 2) + rescue SUSE::Connect::MissingSccCredentialsFile => e + connect_result_from_error(e, first_error_code + 3, "missing credentials") + rescue SUSE::Connect::MalformedSccCredentialsFile => e + connect_result_from_error(e, first_error_code + 4, "incorrect credentials") + rescue OpenSSL::SSL::SSLError => e + connect_result_from_error(e, first_error_code + 5, "invalid certificate") + rescue JSON::ParserError => e + connect_result_from_error(e, first_error_code + 6) + end + + # Generates a result from a given error. + # + # @param error [Exception] + # @param error_code [Integer] + # @param details [String, nil] + # + # @return [Array(Integer, String)] List including an error code and a description. + def connect_result_from_error(error, error_code, details = nil) + logger.error("Error connecting to registration server: #{error}") + + description = "Connection to registration server failed" + description += " (#{details})" if details + + [error_code, description] + end + end + end + end +end diff --git a/service/lib/agama/dbus/software/proposal.rb b/service/lib/agama/dbus/software/proposal.rb index 13cd847d0a..cd0e3587d0 100644 --- a/service/lib/agama/dbus/software/proposal.rb +++ b/service/lib/agama/dbus/software/proposal.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022] SUSE LLC +# Copyright (c) [2022-2023] SUSE LLC # # All Rights Reserved. # @@ -46,8 +46,6 @@ class Proposal < ::DBus::Object # @param logger [Logger] def initialize(logger) @logger = logger - @on_change_callbacks = [] - super(PATH) end @@ -55,7 +53,6 @@ def initialize(logger) dbus_method :AddResolvables, "in Id:s, in Type:y, in Resolvables:as, in Optional:b" do |id, type, resolvables, opt| Yast::PackagesProposal.AddResolvables(id, TYPES[type], resolvables, optional: opt) - notify_change! end dbus_method :GetResolvables, @@ -66,28 +63,18 @@ def initialize(logger) dbus_method :SetResolvables, "in Id:s, in Type:y, in Resolvables:as, in Optional:b" do |id, type, resolvables, opt| Yast::PackagesProposal.SetResolvables(id, TYPES[type], resolvables, optional: opt) - notify_change! end dbus_method :RemoveResolvables, "in Id:s, in Type:y, in Resolvables:as, in Optional:b" do |id, type, resolvables, opt| Yast::PackagesProposal.RemoveResolvables(id, TYPES[type], resolvables, optional: opt) - notify_change! end end - def on_change(&block) - @on_change_callbacks << block - end - private # @return [Logger] attr_reader :logger - - def notify_change! - @on_change_callbacks.each(&:call) - end end end end diff --git a/service/lib/agama/dbus/software_service.rb b/service/lib/agama/dbus/software_service.rb index 01f573aad8..1ce788a18e 100644 --- a/service/lib/agama/dbus/software_service.rb +++ b/service/lib/agama/dbus/software_service.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022] SUSE LLC +# Copyright (c) [2022-2023] SUSE LLC # # All Rights Reserved. # @@ -82,17 +82,11 @@ def service # @return [Array<::DBus::Object>] def dbus_objects @dbus_objects ||= [ - dbus_software_manager, - Agama::DBus::Software::Proposal.new(logger).tap do |proposal| - proposal.on_change { dbus_software_manager.update_validation } - end + Agama::DBus::Software::Manager.new(@backend, logger), + Agama::DBus::Software::Product.new(@backend, logger), + Agama::DBus::Software::Proposal.new(logger) ] end - - # @return [Agama::DBus::Software::Manager] - def dbus_software_manager - @dbus_software_manager ||= Agama::DBus::Software::Manager.new(@backend, logger) - end end end end diff --git a/service/lib/agama/manager.rb b/service/lib/agama/manager.rb index 4f445aad79..b11b5960cb 100644 --- a/service/lib/agama/manager.rb +++ b/service/lib/agama/manager.rb @@ -200,7 +200,7 @@ def on_services_status_change(&block) # # @return [Boolean] def valid? - [users, software].all?(&:valid?) && !storage.errors? + users.valid? && !software.errors? && !storage.errors? end # Collects the logs and stores them into an archive diff --git a/service/lib/agama/registration.rb b/service/lib/agama/registration.rb new file mode 100644 index 0000000000..3466147215 --- /dev/null +++ b/service/lib/agama/registration.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +# Copyright (c) [2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "fileutils" +require "yast" +require "ostruct" +require "suse/connect" +require "y2packager/new_repository_setup" + +Yast.import "Arch" + +module Agama + # Handles everything related to registration of system to SCC, RMT or similar. + class Registration + # Code used for registering the product. + # + # @return [String, nil] nil if the product is not registered yet. + attr_reader :reg_code + + # Email used for registering the product. + # + # @return [String, nil] + attr_reader :email + + module Requirement + NOT_REQUIRED = :not_required + OPTIONAL = :optional + MANDATORY = :mandatory + end + + # @param software_manager [Agama::Software::Manager] + # @param logger [Logger] + def initialize(software_manager, logger) + @software = software_manager + @logger = logger + end + + # Registers the selected product. + # + # @raise [ + # SocketError|Timeout::Error|SUSE::Connect::ApiError| + # SUSE::Connect::MissingSccCredentialsFile|SUSE::Connect::MissingSccCredentialsFile| + # OpenSSL::SSL::SSLError|JSON::ParserError + # ] + # + # @param code [String] Registration code. + # @param email [String] Email for registering the product. + def register(code, email: "") + return if product.nil? || reg_code + + connect_params = { + token: code, + email: email + } + + login, password = SUSE::Connect::YaST.announce_system(connect_params, target_distro) + # write the global credentials + # TODO: check if we can do it in memory for libzypp + SUSE::Connect::YaST.create_credentials_file(login, password) + + target_product = OpenStruct.new( + arch: Yast::Arch.rpm_arch, + identifier: product.id, + version: product.version || "1.0" + ) + activate_params = {} + @service = SUSE::Connect::YaST.activate_product(target_product, activate_params, email) + # if service require specific credentials file, store it + @credentials_file = credentials_from_url(@service.url) + if @credentials_file + SUSE::Connect::YaST.create_credentials_file(login, password, @credentials_file) + end + Y2Packager::NewRepositorySetup.instance.add_service(@service.name) + @software.add_service(@service) + + @reg_code = code + @email = email + run_on_change_callbacks + end + + # Deregisters the selected product. + # + # It uses the registration code and email passed to {#register}. + # + # @raise [ + # SocketError|Timeout::Error|SUSE::Connect::ApiError| + # SUSE::Connect::MissingSccCredentialsFile|SUSE::Connect::MissingSccCredentialsFile| + # OpenSSL::SSL::SSLError|JSON::ParserError + # ] + def deregister + return unless reg_code + + Y2Packager::NewRepositorySetup.instance.services.delete(@service.name) + @software.remove_service(@service) + + connect_params = { + token: reg_code, + email: email + } + SUSE::Connect::YaST.deactivate_system(connect_params) + FileUtils.rm(SUSE::Connect::YaST::GLOBAL_CREDENTIALS_FILE) # connect does not remove it itself + if @credentials_file + FileUtils.rm(credentials_path(@credentials_file)) + @credentials_file = nil + end + + @reg_code = nil + @email = nil + run_on_change_callbacks + end + + # Copies credentials files to the target system. + def finish + return unless reg_code + + files = [credentials_path(@credentials_file), SUSE::Connect::YaST::GLOBAL_CREDENTIALS_FILE] + files.each do |file| + dest = File.join(Yast::Installation.destdir, file) + FileUtils.cp(file, dest) + end + end + + # Indicates whether the registration is optional, mandatory or not required. + # + # @return [Symbol] See {Requirement}. + def requirement + return Requirement::NOT_REQUIRED unless product + return Requirement::MANDATORY if product.repositories.none? + + Requirement::NOT_REQUIRED + end + + # Callbacks to be called when registration changes (e.g., a different product is selected). + def on_change(&block) + @on_change_callbacks ||= [] + @on_change_callbacks << block + end + + private + + # @return [Agama::Software::Manager] + attr_reader :software + + # Currently selected product. + # + # @return [Agama::Software::Product, nil] + def product + software.product + end + + # Product name expected by SCC. + # + # @return [String] E.g., "ALP-Dolomite-1-x86_64". + def target_distro + v = product.version.to_s.split(".").first || "1" + "#{product.id}-#{v}-#{Yast::Arch.rpm_arch}" + end + + def run_on_change_callbacks + @on_change_callbacks&.map(&:call) + end + + # taken from https://github.com/yast/yast-registration/blob/master/src/lib/registration/url_helpers.rb#L109 + def credentials_from_url(url) + parsed_url = URI(url) + params = URI.decode_www_form(parsed_url.query).to_h + + params["credentials"] + rescue StandardError + # if something goes wrong try to continue like if there is no credentials param + nil + end + + def credentials_path(file) + File.join(SUSE::Connect::YaST::DEFAULT_CREDENTIALS_DIR, file) + end + end +end diff --git a/service/lib/agama/software/manager.rb b/service/lib/agama/software/manager.rb index e5d9f256ed..be47087609 100644 --- a/service/lib/agama/software/manager.rb +++ b/service/lib/agama/software/manager.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2021] SUSE LLC +# Copyright (c) [2021-2023] SUSE LLC # # All Rights Reserved. # @@ -19,18 +19,21 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. -require "yast" require "fileutils" -require "agama/config" -require "agama/helpers" -require "agama/with_progress" -require "agama/validation_error" +require "yast" require "y2packager/product" require "y2packager/resolvable" -require "yast2/arch_filter" +require "agama/config" +require "agama/helpers" +require "agama/issue" +require "agama/registration" require "agama/software/callbacks" +require "agama/software/product" +require "agama/software/product_builder" require "agama/software/proposal" require "agama/software/repositories_manager" +require "agama/with_progress" +require "agama/with_issues" Yast.import "Package" Yast.import "Packages" @@ -40,14 +43,26 @@ module Agama module Software - # This class is responsible for software handling - class Manager + # This class is responsible for software handling. + # + # FIXME: This class has too many responsibilities: + # * Address the software service workflow (probe, propose, install). + # * Manages repositories, packages, patterns, services. + # * Manages product selection. + # * Manages software and product related issues. + # + # It shoud be splitted in separate and smaller classes. + class Manager # rubocop:disable Metrics/ClassLength include Helpers + include WithIssues include WithProgress GPG_KEYS_GLOB = "/usr/lib/rpm/gnupg/keys/gpg-*" private_constant :GPG_KEYS_GLOB + # Selected product. + # + # @return [Agama::Product, nil] attr_reader :product DEFAULT_LANGUAGES = ["en_US"].freeze @@ -58,39 +73,52 @@ class Manager attr_accessor :languages - # FIXME: what about defining a Product class? - # @return [Array>] An array containing the product ID and - # additional information in a hash + # Available products for installation. + # + # @return [Array] attr_reader :products + # @return [Agama::RepositoriesManager] attr_reader :repositories + # @param config [Agama::Config] + # @param logger [Logger] def initialize(config, logger) @config = config @logger = logger @languages = DEFAULT_LANGUAGES - @products = @config.products - if @config.multi_product? || @products.empty? - @product = nil - else - @product = @products.keys.first # use the available product as default - @config.pick_product(@product) - end + @products = build_products + @product = @products.first if @products.size == 1 @repositories = RepositoriesManager.new - on_progress_change { logger.info progress.to_s } + # patterns selected by user + @user_patterns = [] @selected_patterns_change_callbacks = [] + on_progress_change { logger.info(progress.to_s) } end - def select_product(name) - return if name == @product - raise ArgumentError unless @products[name] + # Selects a product with the given id. + # + # @raise {ArgumentError} If id is unknown. + # + # @param id [String] + # @return [Boolean] true on success. + def select_product(id) + return false if id == product&.id + + new_product = @products.find { |p| p.id == id } + + raise ArgumentError unless new_product - @config.pick_product(name) - @product = name + @product = new_product repositories.delete_all + update_issues + true end def probe + # Should an error be raised? + return unless product + logger.info "Probing software" # as we use liveDVD with normal like ENV, lets temporary switch to normal to use its repos @@ -110,6 +138,7 @@ def probe progress.step("Calculating the software proposal") { propose } Yast::Stage.Set("initial") + update_issues end def initialize_target_repos @@ -119,30 +148,19 @@ def initialize_target_repos # Updates the software proposal def propose - proposal.base_product = selected_base_product + # Should an error be raised? + return unless product + + proposal.base_product = product.name proposal.languages = languages select_resolvables result = proposal.calculate + update_issues logger.info "Proposal result: #{result.inspect}" selected_patterns_changed result end - # Returns the errors related to the software proposal - # - # * Repositories that could not be probed are reported as errors. - # * If none of the repositories could be probed, do not report missing - # patterns and/or packages. Those issues does not make any sense if there - # are no repositories to install from. - def validate - errors = repositories.disabled.map do |repo| - ValidationError.new("Could not read the repository #{repo.name}") - end - return errors if repositories.enabled.empty? - - errors + proposal.errors - end - # Installs the packages to the target system def install steps = proposal.packages_count @@ -170,6 +188,7 @@ def finish Yast::Pkg.SourceSaveAll Yast::Pkg.TargetFinish Yast::Pkg.SourceCacheCopyTo(Yast::Installation.destdir) + registration.finish end progress.step("Restoring original repositories") { restore_original_repos } end @@ -266,44 +285,98 @@ def used_disk_space Yast::String.FormatSizeWithPrecision(proposal.packages_size, 1, true) end - private - - def proposal - @proposal ||= Proposal.new + def registration + @registration ||= Registration.new(self, logger) end - # @return [Logger] - attr_reader :logger + # code is based on https://github.com/yast/yast-registration/blob/master/src/lib/registration/sw_mgmt.rb#L365 + # rubocop:disable Metrics/AbcSize + def add_service(service) + # init repos, so we are sure we operate on "/" and have GPG imported + initialize_target_repos + # save repositories before refreshing added services (otherwise + # pkg-bindings will treat them as removed by the service refresh and + # unload them) + if !Yast::Pkg.SourceSaveAll + # error message + @logger.error("Saving repository configuration failed.") + end - def import_gpg_keys - gpg_keys = Dir.glob(GPG_KEYS_GLOB).map(&:to_s) - logger.info "Importing GPG keys: #{gpg_keys}" - gpg_keys.each do |path| - Yast::Pkg.ImportGPGKey(path, true) + @logger.info "Adding service #{service.name.inspect} (#{service.url})" + if !Yast::Pkg.ServiceAdd(service.name, service.url.to_s) + raise format("Adding service '%s' failed.", service.name) + end + + if !Yast::Pkg.ServiceSet(service.name, "autorefresh" => true) + # error message + raise format("Updating service '%s' failed.", service.name) end + + # refresh works only for saved services + if !Yast::Pkg.ServiceSave(service.name) + # error message + raise format("Saving service '%s' failed.", service.name) + end + + # Force refreshing due timing issues (bnc#967828) + if !Yast::Pkg.ServiceForceRefresh(service.name) + # error message + raise format("Refreshing service '%s' failed.", service.name) + end + ensure + Yast::Pkg.SourceSaveAll end + # rubocop:enable Metrics/AbcSize - def arch_select(section) - collection = @config.data["software"][section] || [] - collection.select { |c| !c.is_a?(Hash) || arch_match?(c["archs"]) } + def remove_service(service) + if Yast::Pkg.ServiceDelete(service.name) && !Yast::Pkg.SourceSaveAll + raise format("Removing service '%s' failed.", service_name) + end + + true end - def arch_collection_for(section, key) - arch_select(section).map { |r| r.is_a?(Hash) ? r[key] : r } + # Issues associated to the product. + # + # These issues are not considered as software issues, see {#update_issues}. + # + # @return [Array] + def product_issues + issues = [] + issues << missing_product_issue unless product + issues << missing_registration_issue if missing_registration? + issues end - def selected_base_product - @config.data["software"]["base_product"] + private + + # @return [Agama::Config] + attr_reader :config + + # @return [Logger] + attr_reader :logger + + # Generates a list of products according to the information of the config file. + # + # @return [Array] + def build_products + ProductBuilder.new(config).build end - def arch_match?(archs) - return true if archs.nil? + def proposal + @proposal ||= Proposal.new + end - Yast2::ArchFilter.from_string(archs).match? + def import_gpg_keys + gpg_keys = Dir.glob(GPG_KEYS_GLOB).map(&:to_s) + logger.info "Importing GPG keys: #{gpg_keys}" + gpg_keys.each do |path| + Yast::Pkg.ImportGPGKey(path, true) + end end def add_base_repos - arch_collection_for("installation_repositories", "url").map { |url| repositories.add(url) } + product.repositories.each { |url| repositories.add(url) } end REPOS_BACKUP = "/etc/zypp/repos.d.agama.backup" @@ -331,25 +404,75 @@ def restore_original_repos FileUtils.mv(REPOS_BACKUP, REPOS_DIR) end - # adds resolvables from yaml config for given product + # Adds resolvables for selected product def select_resolvables - mandatory_patterns = arch_collection_for("mandatory_patterns", "pattern") - proposal.set_resolvables("agama", :pattern, mandatory_patterns) + proposal.set_resolvables("agama", :pattern, product.mandatory_patterns) + proposal.set_resolvables("agama", :pattern, product.optional_patterns, optional: true) + proposal.set_resolvables("agama", :package, product.mandatory_packages) + proposal.set_resolvables("agama", :package, product.optional_packages, optional: true) + end - optional_patterns = arch_collection_for("optional_patterns", "pattern") - proposal.set_resolvables("agama", :pattern, optional_patterns, - optional: true) + def selected_patterns_changed + @selected_patterns_change_callbacks.each(&:call) + end + + # Updates the list of software issues. + def update_issues + self.issues = current_issues + end + + # List of current software issues. + # + # @return [Array] + def current_issues + return [] unless product - mandatory_packages = arch_collection_for("mandatory_packages", "package") - proposal.set_resolvables("agama", :package, mandatory_packages) + issues = repos_issues - optional_packages = arch_collection_for("optional_packages", "package") - proposal.set_resolvables("agama", :package, optional_packages, - optional: true) + # If none of the repositories could be probed, then do not report missing patterns and/or + # packages. Those issues does not make any sense if there are no repositories to install + # from. + issues += proposal.issues if repositories.enabled.any? + issues end - def selected_patterns_changed - @selected_patterns_change_callbacks.each(&:call) + # Issues related to the software proposal. + # + # Repositories that could not be probed are reported as errors. + # + # @return [Array] + def repos_issues + repositories.disabled.map do |repo| + Issue.new("Could not read the repository #{repo.name}", + source: Issue::Source::SYSTEM, + severity: Issue::Severity::ERROR) + end + end + + # Issue when a product is missing + # + # @return [Agama::Issue] + def missing_product_issue + Issue.new("Product not selected yet", + source: Issue::Source::CONFIG, + severity: Issue::Severity::ERROR) + end + + # Issue when a product requires registration but it is not registered yet. + # + # @return [Agama::Issue] + def missing_registration_issue + Issue.new("Product must be registered", + source: Issue::Source::SYSTEM, + severity: Issue::Severity::ERROR) + end + + # Whether the registration is missing. + # + # @return [Boolean] + def missing_registration? + registration.reg_code.nil? && + registration.requirement == Agama::Registration::Requirement::MANDATORY end end end diff --git a/service/lib/agama/software/product.rb b/service/lib/agama/software/product.rb new file mode 100644 index 0000000000..24bb8c0b2b --- /dev/null +++ b/service/lib/agama/software/product.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +# Copyright (c) [2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module Agama + module Software + # Represents a product that Agama can install. + class Product + # Product id. + # + # @return [String] + attr_reader :id + + # Name of the product to be display. + # + # @return [String, nil] + attr_accessor :display_name + + # Description of the product. + # + # @return [String, nil] + attr_accessor :description + + # Internal name of the product. This is relevant for registering the product. + # + # @return [String, nil] + attr_accessor :name + + # Version of the product. This is relevant for registering the product. + # + # @return [String, nil] E.g., "1.0". + attr_accessor :version + + # List of repositories. + # + # @return [Array] Empty if the product requires registration. + attr_accessor :repositories + + # Mandatory packages. + # + # @return [Array] + attr_accessor :mandatory_packages + + # Optional packages. + # + # @return [Array] + attr_accessor :optional_packages + + # Mandatory patterns. + # + # @return [Array] + attr_accessor :mandatory_patterns + + # Optional patterns. + # + # @return [Array] + attr_accessor :optional_patterns + + # Product translations. + # + # @example + # product.translations #=> + # { + # "description" => { + # "cs" => "Czech translation", + # "es" => "Spanish translation" + # } + # + # @return [Hash>] + attr_accessor :translations + + # @param id [string] Product id. + def initialize(id) + @id = id + @repositories = [] + @mandatory_packages = [] + @optional_packages = [] + @mandatory_patterns = [] + @optional_patterns = [] + @translations = {} + end + + # Localized product description. + # + # If there is no translation for the current language, then the untranslated description is + # used. + # + # @return [String, nil] + def localized_description + translations = self.translations["description"] + lang = ENV["LANG"] + + # No translations or language not set, return untranslated value. + return description unless translations && lang + + # Remove the character encoding if present. + lang = lang.split(".").first + # Full matching (language + country) + return translations[lang] if translations[lang] + + # Remove the country part. + lang = lang.split("_").first + # Partial match (just the language). + return translations[lang] if translations[lang] + + # Fallback to original untranslated description. + description + end + end + end +end diff --git a/service/lib/agama/software/product_builder.rb b/service/lib/agama/software/product_builder.rb new file mode 100644 index 0000000000..b4ecfeddf8 --- /dev/null +++ b/service/lib/agama/software/product_builder.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +# Copyright (c) [2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "agama/software/product" + +module Agama + module Software + # Builds products from the information of a config file. + class ProductBuilder + # @param config [Agama::Config] + def initialize(config) + @config = config + end + + # Builds the products. + # + # @return [Array] + def build + config.products.map do |id, attrs| + data = product_data_from_config(id) + + Agama::Software::Product.new(id).tap do |product| + product.display_name = attrs["name"] + product.description = attrs["description"] + product.name = data[:name] + product.version = data[:version] + product.repositories = data[:repositories] + product.mandatory_packages = data[:mandatory_packages] + product.optional_packages = data[:optional_packages] + product.mandatory_patterns = data[:mandatory_patterns] + product.optional_patterns = data[:optional_patterns] + product.translations = attrs["translations"] || {} + end + end + end + + private + + # @return [Agama::Config] + attr_reader :config + + # Data from config, filtering by arch. + # + # @param id [String] + # @return [Hash] + def product_data_from_config(id) + { + name: config.products.dig(id, "software", "base_product"), + version: config.products.dig(id, "software", "version"), + repositories: config.arch_elements_from( + id, "software", "installation_repositories", property: :url + ), + mandatory_packages: config.arch_elements_from( + id, "software", "mandatory_packages", property: :package + ), + optional_packages: config.arch_elements_from( + id, "software", "optional_packages", property: :package + ), + mandatory_patterns: config.arch_elements_from( + id, "software", "mandatory_patterns", property: :pattern + ), + optional_patterns: config.arch_elements_from( + id, "software", "optional_patterns", property: :pattern + ) + } + end + end + end +end diff --git a/service/lib/agama/software/proposal.rb b/service/lib/agama/software/proposal.rb index c4310967c2..383e1509d5 100644 --- a/service/lib/agama/software/proposal.rb +++ b/service/lib/agama/software/proposal.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022] SUSE LLC +# Copyright (c) [2022-2023] SUSE LLC # # All Rights Reserved. # @@ -20,7 +20,8 @@ # find current contact information at www.suse.com. require "yast" -require "agama/validation_error" +require "agama/issue" +require "agama/with_issues" Yast.import "Stage" Yast.import "Installation" @@ -47,25 +48,22 @@ module Software # proposal.add_resolvables("agama", :pattern, ["enhanced_base"]) # proposal.languages = ["en_US", "de_DE"] # proposal.calculate #=> true - # proposal.errors #=> [] + # proposal.issues #=> [] class Proposal + include WithIssues + # @return [String,nil] Base product attr_accessor :base_product # @return [Array] List of languages to install attr_accessor :languages - # @return [Array] List of errors from the calculated proposal - attr_reader :errors - # Constructor # # @param logger [Logger] def initialize(logger: nil) @logger = logger || Logger.new($stdout) - @errors = [] @base_product = nil - @calculated = false end # Adds the given list of resolvables to the proposal @@ -84,14 +82,12 @@ def set_resolvables(unique_id, type, resolvables, optional: false) # # @return [Boolean] def calculate - @errors.clear initialize_target select_base_product - proposal = Yast::Packages.Proposal(force_reset = true, reinit = false, _simple = true) + @proposal = Yast::Packages.Proposal(force_reset = true, reinit = false, _simple = true) solve_dependencies - @calculated = true - @errors = find_errors(proposal) + update_issues valid? end @@ -115,7 +111,7 @@ def packages_size # # @return [Boolean] def valid? - @calculated && @errors.empty? + !(proposal.nil? || errors?) end private @@ -123,6 +119,11 @@ def valid? # @return [Logger] attr_reader :logger + # Proposal result + # + # @return [Hash, nil] nil if not calculated yet. + attr_reader :proposal + # Initializes the target, closing the previous one def initialize_target Yast::Pkg.TargetFinish # ensure that previous target is closed @@ -148,21 +149,28 @@ def select_base_product end end - # Returns the errors from the attempt to create a proposal + # Updates the issues from the attempt to create a proposal. # - # It collects errors from: + # It collects issues from: # - # * The proposal result - # * The last solver execution + # * The proposal result. + # * The last solver execution. # - # @param proposal_result [Hash] Proposal result; it might contain a "warning" key with warning - # messages. - # @return [Array] List of errors - def find_errors(proposal_result) + # @return [Array] + def update_issues + self.issues = [] + return unless proposal + msgs = [] - msgs.concat(warning_messages(proposal_result)) + msgs.concat(warning_messages(proposal)) msgs.concat(solver_messages) - msgs.map { |m| ValidationError.new(m) } + issues = msgs.map do |msg| + Issue.new(msg, + source: Issue::Source::CONFIG, + severity: Issue::Severity::ERROR) + end + + self.issues = issues end # Runs the solver to satisfy the solve_dependencies diff --git a/service/lib/agama/with_issues.rb b/service/lib/agama/with_issues.rb index 950ddce6bb..dd45a4cf68 100644 --- a/service/lib/agama/with_issues.rb +++ b/service/lib/agama/with_issues.rb @@ -29,6 +29,13 @@ def issues @issues || [] end + # Whether there are errors + # + # @return [Boolean] + def errors? + issues.any?(&:error?) + end + # Sets the list of current issues # # @param issues [Array] diff --git a/service/package/gem2rpm.yml b/service/package/gem2rpm.yml index 62c7dbb179..f708e08f4d 100644 --- a/service/package/gem2rpm.yml +++ b/service/package/gem2rpm.yml @@ -30,6 +30,8 @@ Requires: open-iscsi Requires: yast2-iscsi-client >= 4.5.7 Requires: yast2-users + # required for registration + Requires: suseconnect-ruby-bindings # yast2 with ArchFilter Requires: yast2 >= 4.5.20 %ifarch s390 s390x diff --git a/service/package/rubygem-agama.changes b/service/package/rubygem-agama.changes index 3246d84103..882ca7c2d7 100644 --- a/service/package/rubygem-agama.changes +++ b/service/package/rubygem-agama.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Wed Nov 15 12:31:10 UTC 2023 - José Iván López González + +- Add D-Bus API for registering a product (gh#openSUSE/agama#869). + ------------------------------------------------------------------- Thu Nov 2 14:00:01 UTC 2023 - Ancor Gonzalez Sosa diff --git a/service/test/agama/config_test.rb b/service/test/agama/config_test.rb index 84ede3e81e..f6e6c4dc58 100644 --- a/service/test/agama/config_test.rb +++ b/service/test/agama/config_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022] SUSE LLC +# Copyright (c) [2022-2023] SUSE LLC # # All Rights Reserved. # @@ -20,7 +20,11 @@ # find current contact information at www.suse.com. require_relative "../test_helper" +require "yast" require "agama/config" +require "agama/product_reader" + +Yast.import "Arch" describe Agama::Config do let(:config) { described_class.new("web" => { "ssl" => "SOMETHING" }) } @@ -128,4 +132,137 @@ end end end + + describe "#arch_elements_from" do + subject { described_class.new } + + before do + allow(Agama::ProductReader).to receive(:new).and_return(reader) + end + + let(:reader) { instance_double(Agama::ProductReader, load_products: products) } + + context "when the given set of keys does not match any data" do + let(:products) do + [ + { + "id" => "Product1", + "name" => "Test product 1" + } + ] + end + + it "returns an empty array" do + expect(subject.arch_elements_from("Product1", "some", "collection")).to be_empty + end + end + + context "when the given set of keys does not contain a collection" do + let(:products) do + [ + { + "id" => "Product1", + "name" => "Test product 1" + } + ] + end + + it "returns an empty array" do + expect(subject.arch_elements_from("Product1", "name")).to be_empty + end + end + + context "when the given set of keys contains a collection" do + let(:products) do + [ + { + "id" => "Product1", + "some" => { + "collection" => [ + "element1", + { + "element" => "element2" + }, + { + "element" => "element3", + "archs" => "x86_64" + }, + { + "element" => "element4", + "archs" => "x86_64,aarch64" + }, + { + "element" => "element5", + "archs" => "ppc64" + } + ] + } + } + ] + end + + before do + allow(Yast::Arch).to receive("x86_64").and_return(true) + allow(Yast::Arch).to receive("aarch64").and_return(false) + allow(Yast::Arch).to receive("ppc64").and_return(false) + end + + it "returns all the elements that match the current arch" do + elements = subject.arch_elements_from("Product1", "some", "collection") + + expect(elements).to contain_exactly( + "element1", + { "element" => "element2" }, + { "element" => "element3", "archs" => "x86_64" }, + { "element" => "element4", "archs" => "x86_64,aarch64" } + ) + end + + context "and there are no elements matching the current arch" do + let(:products) do + [ + { + "id" => "Product1", + "some" => { + "collection" => [ + { + "element" => "element1", + "archs" => "aarch64" + }, + { + "element" => "element2", + "archs" => "ppc64" + } + ] + } + } + ] + end + + it "returns an empty list" do + elements = subject.arch_elements_from("Product1", "some", "collection") + + expect(elements).to be_empty + end + end + + context "and some property is requested" do + it "returns the property from all elements that match the current arch" do + elements = subject.arch_elements_from( + "Product1", "some", "collection", property: :element + ) + + expect(elements).to contain_exactly("element1", "element2", "element3", "element4") + end + end + + context "and the requested property does not exit" do + it "only return elements that are direct values" do + elements = subject.arch_elements_from("Product1", "some", "collection", property: :foo) + + expect(elements).to contain_exactly("element1") + end + end + end + end end diff --git a/service/test/agama/dbus/clients/software_test.rb b/service/test/agama/dbus/clients/software_test.rb index c6ab60127c..c379920f5e 100644 --- a/service/test/agama/dbus/clients/software_test.rb +++ b/service/test/agama/dbus/clients/software_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022] SUSE LLC +# Copyright (c) [2022-2023] SUSE LLC # # All Rights Reserved. # @@ -20,6 +20,7 @@ # find current contact information at www.suse.com. require_relative "../../../test_helper" +require_relative "with_issues_examples" require_relative "with_service_status_examples" require_relative "with_progress_examples" require "agama/dbus/clients/software" @@ -33,27 +34,32 @@ allow(bus).to receive(:service).with("org.opensuse.Agama.Software1").and_return(service) allow(service).to receive(:[]).with("/org/opensuse/Agama/Software1") .and_return(dbus_object) - allow(dbus_object).to receive(:introspect) + allow(service).to receive(:[]).with("/org/opensuse/Agama/Software1/Product") + .and_return(dbus_product) + allow(service).to receive(:[]).with("/org/opensuse/Agama/Software1/Proposal") + .and_return(dbus_proposal) allow(dbus_object).to receive(:[]).with("org.opensuse.Agama.Software1") .and_return(software_iface) - allow(dbus_object).to receive(:[]).with("org.freedesktop.DBus.Properties") + allow(dbus_product).to receive(:[]).with("org.opensuse.Agama.Software1.Product") + .and_return(product_iface) + allow(dbus_product).to receive(:[]).with("org.freedesktop.DBus.Properties") .and_return(properties_iface) - allow(service).to receive(:[]).with("/org/opensuse/Agama/Software1/Proposal") - .and_return(dbus_proposal) end let(:bus) { instance_double(Agama::DBus::Bus) } let(:service) { instance_double(::DBus::ProxyService) } - let(:dbus_object) { instance_double(::DBus::ProxyObject) } + let(:dbus_object) { instance_double(::DBus::ProxyObject, introspect: nil) } + let(:dbus_product) { instance_double(::DBus::ProxyObject, introspect: nil) } let(:dbus_proposal) { instance_double(::DBus::ProxyObject, introspect: nil) } let(:software_iface) { instance_double(::DBus::ProxyObjectInterface) } let(:properties_iface) { instance_double(::DBus::ProxyObjectInterface) } + let(:product_iface) { instance_double(::DBus::ProxyObjectInterface) } subject { described_class.new } describe "#available_products" do before do - allow(software_iface).to receive(:[]).with("AvailableBaseProducts").and_return( + allow(product_iface).to receive(:[]).with("AvailableProducts").and_return( [ ["Tumbleweed", "openSUSE Tumbleweed", {}], ["Leap15.3", "openSUSE Leap 15.3", {}] @@ -71,7 +77,7 @@ describe "#selected_product" do before do - allow(software_iface).to receive(:[]).with("SelectedBaseProduct").and_return(product) + allow(product_iface).to receive(:[]).with("SelectedProduct").and_return(product) end context "when there is no selected product" do @@ -93,17 +99,17 @@ describe "#select_product" do # Using partial double because methods are dynamically added to the proxy object - let(:dbus_object) { double(::DBus::ProxyObject) } + let(:dbus_product) { double(::DBus::ProxyObject, introspect: nil) } it "selects the given product" do - expect(dbus_object).to receive(:SelectProduct).with("Tumbleweed") + expect(dbus_product).to receive(:SelectProduct).with("Tumbleweed") subject.select_product("Tumbleweed") end end describe "#probe" do - let(:dbus_object) { double(::DBus::ProxyObject, Probe: nil) } + let(:dbus_object) { double(::DBus::ProxyObject, introspect: nil, Probe: nil) } it "calls the D-Bus Probe method" do expect(dbus_object).to receive(:Probe) @@ -124,7 +130,7 @@ end describe "#provisions_selected" do - let(:dbus_object) { double(::DBus::ProxyObject) } + let(:dbus_object) { double(::DBus::ProxyObject, introspect: nil) } it "returns true/false for every tag given" do expect(dbus_object).to receive(:ProvisionsSelected) @@ -135,7 +141,10 @@ end describe "#package_installed?" do - let(:dbus_object) { double(::DBus::ProxyObject, IsPackageInstalled: installed?) } + let(:dbus_object) do + double(::DBus::ProxyObject, introspect: nil, IsPackageInstalled: installed?) + end + let(:package) { "NetworkManager" } context "when the package is installed" do @@ -157,7 +166,7 @@ describe "#on_product_selected" do before do - allow(dbus_object).to receive(:path).and_return("/org/opensuse/Agama/Test") + allow(dbus_product).to receive(:path).and_return("/org/opensuse/Agama/Test") allow(properties_iface).to receive(:on_signal) end @@ -180,6 +189,7 @@ end end + include_examples "issues" include_examples "service status" include_examples "progress" end diff --git a/service/test/agama/dbus/clients/with_issues_examples.rb b/service/test/agama/dbus/clients/with_issues_examples.rb index e599db742f..d409970358 100644 --- a/service/test/agama/dbus/clients/with_issues_examples.rb +++ b/service/test/agama/dbus/clients/with_issues_examples.rb @@ -24,19 +24,54 @@ shared_examples "issues" do before do - allow(dbus_object).to receive(:path).and_return("/org/opensuse/Agama/Test") - allow(dbus_object).to receive(:[]).with("org.opensuse.Agama1.Issues") - .and_return(issues_properties) + allow(service).to receive(:root).and_return(root_node) + + allow(dbus_object1).to receive(:[]).with("org.opensuse.Agama1.Issues") + .and_return(issues_interface1) + + allow(dbus_object3).to receive(:[]).with("org.opensuse.Agama1.Issues") + .and_return(issues_interface3) + + allow(issues_interface1).to receive(:[]).with("All").and_return(issues1) + allow(issues_interface3).to receive(:[]).with("All").and_return(issues3) + end + + let(:root_node) do + instance_double(::DBus::Node, descendant_objects: [dbus_object1, dbus_object2, dbus_object3]) + end + + let(:dbus_object1) do + instance_double(::DBus::ProxyObject, + interfaces: ["org.opensuse.Agama1.Test", "org.opensuse.Agama1.Issues"]) + end + + let(:dbus_object2) do + instance_double(::DBus::ProxyObject, interfaces: ["org.opensuse.Agama1.Test"]) + end + + let(:dbus_object3) do + instance_double(::DBus::ProxyObject, interfaces: ["org.opensuse.Agama1.Issues"]) end - let(:issues_properties) { { "All" => issues } } + let(:issues_interface1) { instance_double(::DBus::ProxyObjectInterface) } + + let(:issues_interface3) { instance_double(::DBus::ProxyObjectInterface) } - let(:issues) { [issue1, issue2] } - let(:issue1) { ["Issue 1", "Details 1", 1, 0] } - let(:issue2) { ["Issue 2", "Details 2", 2, 1] } + let(:issues1) do + [ + ["Issue 1", "Details 1", 1, 0], + ["Issue 2", "Details 2", 2, 1] + ] + end + + let(:issues3) do + [ + ["Issue 3", "Details 3", 1, 0] + ] + end describe "#issues" do - it "returns the list of issues" do + it "returns the list of issues from all objects" do expect(subject.issues).to all(be_a(Agama::Issue)) expect(subject.issues).to contain_exactly( @@ -51,6 +86,12 @@ details: "Details 2", source: Agama::Issue::Source::CONFIG, severity: Agama::Issue::Severity::ERROR + ), + an_object_having_attributes( + description: "Issue 3", + details: "Details 3", + source: Agama::Issue::Source::SYSTEM, + severity: Agama::Issue::Severity::WARN ) ) end @@ -58,15 +99,13 @@ describe "#errors?" do context "if there is any error" do - let(:issues) { [issue2] } - it "returns true" do expect(subject.errors?).to eq(true) end end context "if there is no error" do - let(:issues) { [issue1] } + let(:issues1) { [] } it "returns false" do expect(subject.errors?).to eq(false) diff --git a/service/test/agama/dbus/software/manager_test.rb b/service/test/agama/dbus/software/manager_test.rb index b75490ab43..925218b9d5 100644 --- a/service/test/agama/dbus/software/manager_test.rb +++ b/service/test/agama/dbus/software/manager_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022] SUSE LLC +# Copyright (c) [2022-2023] SUSE LLC # # All Rights Reserved. # @@ -20,10 +20,13 @@ # find current contact information at www.suse.com. require_relative "../../../test_helper" -require "agama/dbus/software/manager" +require "agama/config" +require "agama/dbus/clients/locale" +require "agama/dbus/clients/network" +require "agama/dbus/interfaces/issues" require "agama/dbus/interfaces/progress" require "agama/dbus/interfaces/service_status" -require "agama/dbus/interfaces/validation" +require "agama/dbus/software/manager" require "agama/software" describe Agama::DBus::Software::Manager do @@ -31,7 +34,14 @@ let(:logger) { Logger.new($stdout, level: :warn) } - let(:backend) { instance_double(Agama::Software::Manager) } + let(:backend) { Agama::Software::Manager.new(config, logger) } + + let(:config) { Agama::Config.new(config_data) } + + let(:config_data) do + path = File.join(FIXTURES_PATH, "root_dir/etc/agama.yaml") + YAML.safe_load(File.read(path)) + end let(:progress_interface) { Agama::DBus::Interfaces::Progress::PROGRESS_INTERFACE } @@ -39,42 +49,44 @@ Agama::DBus::Interfaces::ServiceStatus::SERVICE_STATUS_INTERFACE end - let(:validation_interface) { Agama::DBus::Interfaces::Validation::VALIDATION_INTERFACE } + let(:issues_interface) { Agama::DBus::Interfaces::Issues::ISSUES_INTERFACE } before do - allow_any_instance_of(described_class).to receive(:register_callbacks) - allow_any_instance_of(described_class).to receive(:register_progress_callbacks) - allow_any_instance_of(described_class).to receive(:register_service_status_callbacks) + allow(Agama::DBus::Clients::Locale).to receive(:new).and_return(locale_client) + allow(Agama::DBus::Clients::Network).to receive(:new).and_return(network_client) + allow(backend).to receive(:probe) + allow(backend).to receive(:propose) + allow(backend).to receive(:install) + allow(backend).to receive(:finish) + allow(subject).to receive(:dbus_properties_changed) end - it "defines Progress D-Bus interface" do - expect(subject.intfs.keys).to include(progress_interface) + let(:locale_client) do + instance_double(Agama::DBus::Clients::Locale, on_language_selected: nil) end - it "defines ServiceStatus D-Bus interface" do - expect(subject.intfs.keys).to include(service_status_interface) + let(:network_client) do + instance_double(Agama::DBus::Clients::Network, on_connection_changed: nil) + end + + it "defines Issues D-Bus interface" do + expect(subject.intfs.keys).to include(issues_interface) end - it "defines Validation D-Bus interface" do - expect(subject.intfs.keys).to include(validation_interface) + it "defines Progress D-Bus interface" do + expect(subject.intfs.keys).to include(progress_interface) end - it "configures callbacks from Progress interface" do - expect_any_instance_of(described_class).to receive(:register_progress_callbacks) - subject + it "defines ServiceStatus D-Bus interface" do + expect(subject.intfs.keys).to include(service_status_interface) end - it "configures callbacks from ServiceStatus interface" do - expect_any_instance_of(described_class).to receive(:register_service_status_callbacks) - subject + it "emits signal when issues changes" do + expect(subject).to receive(:issues_properties_changed) + backend.issues = [] end describe "#probe" do - before do - allow(subject).to receive(:update_validation) - allow(backend).to receive(:probe) - end - it "runs the probing, setting the service as busy meanwhile" do expect(subject.service_status).to receive(:busy) expect(backend).to receive(:probe) @@ -82,20 +94,9 @@ subject.probe end - - it "updates validation" do - expect(subject).to receive(:update_validation) - - subject.probe - end end describe "#propose" do - before do - allow(subject).to receive(:update_validation) - allow(backend).to receive(:propose) - end - it "calculates the proposal, setting the service as busy meanwhile" do expect(subject.service_status).to receive(:busy) expect(backend).to receive(:propose) @@ -103,12 +104,6 @@ subject.propose end - - it "updates validation" do - expect(subject).to receive(:update_validation) - - subject.propose - end end describe "#install" do @@ -140,68 +135,4 @@ expect(installed).to eq(true) end end - - describe "#available_base_products" do - # testing product with translations - products = { - "Tumbleweed" => { - "name" => "openSUSE Tumbleweed", - "description" => "Original description", - "translations" => { - "description" => { - "cs" => "Czech translation", - "es" => "Spanish translation" - } - } - } - } - - it "returns product ID and name" do - expect(backend).to receive(:products).and_return(products) - - product = subject.available_base_products.first - expect(product[0]).to eq("Tumbleweed") - expect(product[1]).to eq("openSUSE Tumbleweed") - end - - it "returns untranslated description when the language is not set" do - allow(ENV).to receive(:[]).with("LANG").and_return(nil) - expect(backend).to receive(:products).and_return(products) - - product = subject.available_base_products.first - expect(product[2]["description"]).to eq("Original description") - end - - it "returns Czech translation if locale is \"cs_CZ.UTF-8\"" do - allow(ENV).to receive(:[]).with("LANG").and_return("cs_CZ.UTF-8") - expect(backend).to receive(:products).and_return(products) - - product = subject.available_base_products.first - expect(product[2]["description"]).to eq("Czech translation") - end - - it "returns Czech translation if locale is \"cs\"" do - allow(ENV).to receive(:[]).with("LANG").and_return("cs") - expect(backend).to receive(:products).and_return(products) - - product = subject.available_base_products.first - expect(product[2]["description"]).to eq("Czech translation") - end - - it "return untranslated description when translation is not available" do - allow(ENV).to receive(:[]).with("LANG").and_return("cs_CZ.UTF-8") - - # testing product without translations - untranslated = { - "Tumbleweed" => { - "name" => "openSUSE Tumbleweed", - "description" => "Original description" - } - } - expect(backend).to receive(:products).and_return(untranslated) - - product = subject.available_base_products.first - expect(product[2]["description"]).to eq("Original description") - end - end end diff --git a/service/test/agama/dbus/software/product_test.rb b/service/test/agama/dbus/software/product_test.rb new file mode 100644 index 0000000000..e25c36e661 --- /dev/null +++ b/service/test/agama/dbus/software/product_test.rb @@ -0,0 +1,399 @@ +# frozen_string_literal: true + +# Copyright (c) [2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../test_helper" +require "agama/dbus/software/product" +require "agama/config" +require "agama/registration" +require "agama/software/manager" +require "suse/connect" + +describe Agama::DBus::Software::Product do + subject { described_class.new(backend, logger) } + + let(:logger) { Logger.new($stdout, level: :warn) } + + let(:backend) { Agama::Software::Manager.new(config, logger) } + + let(:config) { Agama::Config.new } + + before do + allow(config).to receive(:products).and_return(products) + allow(subject).to receive(:dbus_properties_changed) + end + + let(:products) do + { "Tumbleweed" => {}, "ALP-Dolomite" => {} } + end + + it "defines Product D-Bus interface" do + expect(subject.intfs.keys).to include("org.opensuse.Agama.Software1.Product") + end + + it "defines Registration D-Bus interface" do + expect(subject.intfs.keys).to include("org.opensuse.Agama1.Registration") + end + + it "defines Issues D-Bus interface" do + expect(subject.intfs.keys).to include("org.opensuse.Agama1.Issues") + end + + describe "select_product" do + context "if the product is correctly selected" do + it "returns result code 0 with empty description" do + expect(subject.select_product("Tumbleweed")).to contain_exactly(0, "") + end + end + + context "if the given product is already selected" do + before do + subject.select_product("Tumbleweed") + end + + it "returns result code 1 and description" do + expect(subject.select_product("Tumbleweed")).to contain_exactly(1, /already selected/) + end + end + + context "if the current product is registered" do + before do + subject.select_product("Leap16") + allow(backend.registration).to receive(:reg_code).and_return("123XX432") + end + + it "returns result code 2 and description" do + expect(subject.select_product("Tumbleweed")).to contain_exactly(2, /must be deregistered/) + end + end + + context "if the product is unknown" do + it "returns result code 3 and description" do + expect(subject.select_product("Unknown")).to contain_exactly(3, /unknown product/i) + end + end + end + + describe "#reg_code" do + before do + allow(backend.registration).to receive(:reg_code).and_return(reg_code) + end + + context "if there is no registered product yet" do + let(:reg_code) { nil } + + it "returns an empty string" do + expect(subject.reg_code).to eq("") + end + end + + context "if there is a registered product" do + let(:reg_code) { "123XX432" } + + it "returns the registration code" do + expect(subject.reg_code).to eq("123XX432") + end + end + end + + describe "#email" do + before do + allow(backend.registration).to receive(:email).and_return(email) + end + + context "if there is no registered email" do + let(:email) { nil } + + it "returns an empty string" do + expect(subject.email).to eq("") + end + end + + context "if there is a registered email" do + let(:email) { "test@suse.com" } + + it "returns the registered email" do + expect(subject.email).to eq("test@suse.com") + end + end + end + + describe "#requirement" do + before do + allow(backend.registration).to receive(:requirement).and_return(requirement) + end + + context "if the registration is not required" do + let(:requirement) { Agama::Registration::Requirement::NOT_REQUIRED } + + it "returns 0" do + expect(subject.requirement).to eq(0) + end + end + + context "if the registration is optional" do + let(:requirement) { Agama::Registration::Requirement::OPTIONAL } + + it "returns 1" do + expect(subject.requirement).to eq(1) + end + end + + context "if the registration is mandatory" do + let(:requirement) { Agama::Registration::Requirement::MANDATORY } + + it "returns 2" do + expect(subject.requirement).to eq(2) + end + end + end + + describe "#register" do + before do + allow(backend.registration).to receive(:reg_code).and_return(nil) + end + + context "if there is no product selected yet" do + it "returns result code 1 and description" do + expect(subject.register("123XX432")).to contain_exactly(1, /product not selected/i) + end + end + + context "if there is a selected product" do + before do + backend.select_product("Tumbleweed") + + allow(backend.product).to receive(:repositories).and_return(repositories) + end + + let(:repositories) { [] } + + context "if the product is already registered" do + before do + allow(backend.registration).to receive(:reg_code).and_return("123XX432") + end + + it "returns result code 2 and description" do + expect(subject.register("123XX432")).to contain_exactly(2, /product already registered/i) + end + end + + context "if the product does not require registration" do + let(:repositories) { ["https://repo"] } + + it "returns result code 3 and description" do + expect(subject.register("123XX432")).to contain_exactly(3, /not require registration/i) + end + end + + context "if there is a network error" do + before do + allow(backend.registration).to receive(:register).and_raise(SocketError) + end + + it "returns result code 4 and description" do + expect(subject.register("123XX432")).to contain_exactly(4, /network error/) + end + end + + context "if there is a timeout" do + before do + allow(backend.registration).to receive(:register).and_raise(Timeout::Error) + end + + it "returns result code 5 and description" do + expect(subject.register("123XX432")).to contain_exactly(5, /timeout/) + end + end + + context "if there is an API error" do + before do + allow(backend.registration).to receive(:register).and_raise(SUSE::Connect::ApiError, "") + end + + it "returns result code 6 and description" do + expect(subject.register("123XX432")).to contain_exactly(6, /registration server failed/) + end + end + + context "if there is a missing credials error" do + before do + allow(backend.registration) + .to receive(:register).and_raise(SUSE::Connect::MissingSccCredentialsFile) + end + + it "returns result code 7 and description" do + expect(subject.register("123XX432")).to contain_exactly(7, /missing credentials/) + end + end + + context "if there is an incorrect credials error" do + before do + allow(backend.registration) + .to receive(:register).and_raise(SUSE::Connect::MalformedSccCredentialsFile) + end + + it "returns result code 8 and description" do + expect(subject.register("123XX432")).to contain_exactly(8, /incorrect credentials/) + end + end + + context "if there is an invalid certificate error" do + before do + allow(backend.registration).to receive(:register).and_raise(OpenSSL::SSL::SSLError) + end + + it "returns result code 9 and description" do + expect(subject.register("123XX432")).to contain_exactly(9, /invalid certificate/) + end + end + + context "if there is an internal error" do + before do + allow(backend.registration).to receive(:register).and_raise(JSON::ParserError) + end + + it "returns result code 10 and description" do + expect(subject.register("123XX432")).to contain_exactly(10, /registration server failed/) + end + end + + context "if the registration is correctly done" do + before do + allow(backend.registration).to receive(:register) + end + + it "returns result code 0 with empty description" do + expect(subject.register("123XX432")).to contain_exactly(0, "") + end + end + end + end + + describe "#deregister" do + before do + allow(backend.registration).to receive(:reg_code).and_return("123XX432") + end + + context "if there is no product selected yet" do + it "returns result code 1 and description" do + expect(subject.deregister).to contain_exactly(1, /product not selected/i) + end + end + + context "if there is a selected product" do + before do + backend.select_product("Tumbleweed") + end + + context "if the product is not registered yet" do + before do + allow(backend.registration).to receive(:reg_code).and_return(nil) + end + + it "returns result code 2 and description" do + expect(subject.deregister).to contain_exactly(2, /product not registered/i) + end + end + + context "if there is a network error" do + before do + allow(backend.registration).to receive(:deregister).and_raise(SocketError) + end + + it "returns result code 3 and description" do + expect(subject.deregister).to contain_exactly(3, /network error/) + end + end + + context "if there is a timeout" do + before do + allow(backend.registration).to receive(:deregister).and_raise(Timeout::Error) + end + + it "returns result code 4 and description" do + expect(subject.deregister).to contain_exactly(4, /timeout/) + end + end + + context "if there is an API error" do + before do + allow(backend.registration).to receive(:deregister).and_raise(SUSE::Connect::ApiError, "") + end + + it "returns result code 5 and description" do + expect(subject.deregister).to contain_exactly(5, /registration server failed/) + end + end + + context "if there is a missing credials error" do + before do + allow(backend.registration) + .to receive(:deregister).and_raise(SUSE::Connect::MissingSccCredentialsFile) + end + + it "returns result code 6 and description" do + expect(subject.deregister).to contain_exactly(6, /missing credentials/) + end + end + + context "if there is an incorrect credials error" do + before do + allow(backend.registration) + .to receive(:deregister).and_raise(SUSE::Connect::MalformedSccCredentialsFile) + end + + it "returns result code 7 and description" do + expect(subject.deregister).to contain_exactly(7, /incorrect credentials/) + end + end + + context "if there is an invalid certificate error" do + before do + allow(backend.registration).to receive(:deregister).and_raise(OpenSSL::SSL::SSLError) + end + + it "returns result code 8 and description" do + expect(subject.deregister).to contain_exactly(8, /invalid certificate/) + end + end + + context "if there is an internal error" do + before do + allow(backend.registration).to receive(:deregister).and_raise(JSON::ParserError) + end + + it "returns result code 9 and description" do + expect(subject.deregister).to contain_exactly(9, /registration server failed/) + end + end + + context "if the deregistration is correctly done" do + before do + allow(backend.registration).to receive(:deregister) + end + + it "returns result code 0 with empty description" do + expect(subject.deregister).to contain_exactly(0, "") + end + end + end + end +end diff --git a/service/test/agama/manager_test.rb b/service/test/agama/manager_test.rb index e63d6595f4..9e465df938 100644 --- a/service/test/agama/manager_test.rb +++ b/service/test/agama/manager_test.rb @@ -25,6 +25,7 @@ require "agama/config" require "agama/question" require "agama/dbus/service_status" +require "agama/users" describe Agama::Manager do subject { described_class.new(config, logger) } @@ -42,7 +43,7 @@ instance_double( Agama::DBus::Clients::Software, probe: nil, install: nil, propose: nil, finish: nil, on_product_selected: nil, - on_service_status_change: nil, selected_product: product, valid?: true + on_service_status_change: nil, selected_product: product, errors?: false ) end let(:users) do @@ -202,7 +203,7 @@ context "when the software configuration is not valid" do before do - allow(software).to receive(:valid?).and_return(false) + allow(software).to receive(:errors?).and_return(true) end it "returns false" do diff --git a/service/test/agama/registration_test.rb b/service/test/agama/registration_test.rb new file mode 100644 index 0000000000..a2dd6731b8 --- /dev/null +++ b/service/test/agama/registration_test.rb @@ -0,0 +1,375 @@ +# frozen_string_literal: true + +# Copyright (c) [2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../test_helper" +require "agama/config" +require "agama/registration" +require "agama/software/manager" +require "suse/connect" +require "yast" +require "y2packager/new_repository_setup" + +Yast.import("Arch") + +describe Agama::Registration do + subject { described_class.new(manager, logger) } + + let(:manager) { instance_double(Agama::Software::Manager) } + + let(:logger) { Logger.new($stdout, level: :warn) } + + before do + allow(Yast::Arch).to receive(:rpm_arch).and_return("x86_64") + + allow(manager).to receive(:product).and_return(product) + allow(manager).to receive(:add_service) + allow(manager).to receive(:remove_service) + + allow(SUSE::Connect::YaST).to receive(:announce_system).and_return(["test-user", "12345"]) + allow(SUSE::Connect::YaST).to receive(:deactivate_system) + allow(SUSE::Connect::YaST).to receive(:create_credentials_file) + allow(SUSE::Connect::YaST).to receive(:activate_product).and_return(service) + allow(Y2Packager::NewRepositorySetup.instance).to receive(:add_service) + end + + let(:service) { OpenStruct.new(name: "test-service", url: nil) } + + describe "#register" do + context "if there is no product selected yet" do + let(:product) { nil } + + it "does not try to register" do + expect(SUSE::Connect::YaST).to_not receive(:announce_system) + + subject.register("11112222", email: "test@test.com") + end + end + + context "if there is a selected product" do + let(:product) { Agama::Software::Product.new("test").tap { |p| p.version = "5.0" } } + + context "and the product is already registered" do + before do + subject.register("11112222", email: "test@test.com") + end + + it "does not try to register" do + expect(SUSE::Connect::YaST).to_not receive(:announce_system) + + subject.register("11112222", email: "test@test.com") + end + end + + context "and the product is not registered yet" do + it "announces the system" do + expect(SUSE::Connect::YaST).to receive(:announce_system).with( + { token: "11112222", email: "test@test.com" }, + "test-5-x86_64" + ) + + subject.register("11112222", email: "test@test.com") + end + + it "creates credentials file" do + expect(SUSE::Connect::YaST).to receive(:create_credentials_file) + .with("test-user", "12345") + + subject.register("11112222", email: "test@test.com") + end + + it "activates the selected product" do + expect(SUSE::Connect::YaST).to receive(:activate_product).with( + an_object_having_attributes( + arch: "x86_64", identifier: "test", version: "5.0" + ), {}, "test@test.com" + ) + + subject.register("11112222", email: "test@test.com") + end + + it "adds the service to software manager" do + expect(Y2Packager::NewRepositorySetup.instance) + .to receive(:add_service).with("test-service") + + subject.register("11112222", email: "test@test.com") + end + + context "if the service requires a creadentials file" do + let(:service) { OpenStruct.new(name: "test-service", url: "https://credentials/file") } + + before do + allow(subject).to receive(:credentials_from_url) + .with("https://credentials/file").and_return("credentials") + end + + it "creates the credentials file" do + expect(SUSE::Connect::YaST).to receive(:create_credentials_file) + expect(SUSE::Connect::YaST).to receive(:create_credentials_file) + .with("test-user", "12345", "credentials") + + subject.register("11112222", email: "test@test.com") + end + end + + context "if the service does not require a creadentials file" do + let(:service) { OpenStruct.new(name: "test-service", url: nil) } + + it "does not create the credentials file" do + expect(SUSE::Connect::YaST).to receive(:create_credentials_file) + expect(SUSE::Connect::YaST).to_not receive(:create_credentials_file) + .with("test-user", "12345", anything) + + subject.register("11112222", email: "test@test.com") + end + end + + context "if the product was correctly registered" do + before do + subject.on_change(&callback) + end + + let(:callback) { proc {} } + + it "runs the callbacks" do + expect(callback).to receive(:call) + + subject.register("11112222", email: "test@test.com") + end + + it "sets the registration code" do + subject.register("11112222", email: "test@test.com") + + expect(subject.reg_code).to eq("11112222") + end + + it "sets the email" do + subject.register("11112222", email: "test@test.com") + + expect(subject.email).to eq("test@test.com") + end + end + + context "if the product was not correctly registered" do + before do + allow(SUSE::Connect::YaST).to receive(:activate_product).and_raise(Timeout::Error) + subject.on_change(&callback) + end + + let(:callback) { proc {} } + + it "raises an error" do + expect { subject.register("11112222", email: "test@test.com") } + .to raise_error(Timeout::Error) + end + + it "does not run the callbacks" do + expect(callback).to_not receive(:call) + + expect { subject.register("11112222", email: "test@test.com") } + .to raise_error(Timeout::Error) + end + + it "does not set the registration code" do + expect { subject.register("11112222", email: "test@test.com") } + .to raise_error(Timeout::Error) + + expect(subject.reg_code).to be_nil + end + + it "does not set the email" do + expect { subject.register("11112222", email: "test@test.com") } + .to raise_error(Timeout::Error) + + expect(subject.email).to be_nil + end + end + end + end + end + + describe "#deregister" do + before do + allow(FileUtils).to receive(:rm) + end + + context "if there is no product selected yet" do + let(:product) { nil } + + it "does not try to deregister" do + expect(SUSE::Connect::YaST).to_not receive(:deactivate_system) + + subject.deregister + end + end + + context "if there is a selected product" do + let(:product) { Agama::Software::Product.new("test").tap { |p| p.version = "5.0" } } + + context "and the product is not registered yet" do + it "does not try to deregister" do + expect(SUSE::Connect::YaST).to_not receive(:deactivate_system) + + subject.deregister + end + end + + context "and the product is registered" do + before do + allow(subject).to receive(:credentials_from_url) + allow(subject).to receive(:credentials_from_url) + .with("https://credentials/file").and_return("credentials") + + subject.register("11112222", email: "test@test.com") + end + + it "deletes the service from the software config" do + expect(manager).to receive(:remove_service).with(service) + + subject.deregister + end + + it "deactivates the system" do + expect(SUSE::Connect::YaST).to receive(:deactivate_system).with( + { token: "11112222", email: "test@test.com" } + ) + + subject.deregister + end + + it "removes the credentials file" do + expect(FileUtils).to receive(:rm).with(/SCCcredentials/) + + subject.deregister + end + + context "if the service has a credentials files" do + let(:service) { OpenStruct.new(name: "test-service", url: "https://credentials/file") } + + it "removes the credentials file" do + expect(FileUtils).to receive(:rm) + expect(FileUtils).to receive(:rm).with(/\/credentials$/) + + subject.deregister + end + end + + context "if the product has no credentials file" do + let(:service) { OpenStruct.new(name: "test-service", url: nil) } + + it "does not try to remove the credentials file" do + expect(FileUtils).to_not receive(:rm).with(/\/credentials$/) + + subject.deregister + end + end + + context "if the product was correctly deregistered" do + before do + subject.on_change(&callback) + end + + let(:callback) { proc {} } + + it "runs the callbacks" do + expect(callback).to receive(:call) + + subject.deregister + end + + it "removes the registration code" do + subject.deregister + + expect(subject.reg_code).to be_nil + end + + it "removes the email" do + subject.deregister + + expect(subject.email).to be_nil + end + end + + context "if the product was not correctly deregistered" do + before do + allow(SUSE::Connect::YaST).to receive(:deactivate_system).and_raise(Timeout::Error) + subject.on_change(&callback) + end + + let(:callback) { proc {} } + + it "raises an error" do + expect { subject.deregister }.to raise_error(Timeout::Error) + end + + it "does not run the callbacks" do + expect(callback).to_not receive(:call) + + expect { subject.deregister }.to raise_error(Timeout::Error) + end + + it "does not remove the registration code" do + expect { subject.deregister }.to raise_error(Timeout::Error) + + expect(subject.reg_code).to eq("11112222") + end + + it "does not remove the email" do + expect { subject.deregister }.to raise_error(Timeout::Error) + + expect(subject.email).to eq("test@test.com") + end + end + end + end + end + + describe "#requirement" do + context "if there is not product selected yet" do + let(:product) { nil } + + it "returns not required" do + expect(subject.requirement).to eq(Agama::Registration::Requirement::NOT_REQUIRED) + end + end + + context "if there is a selected product" do + let(:product) do + Agama::Software::Product.new("test").tap { |p| p.repositories = repositories } + end + + context "and the product has repositories" do + let(:repositories) { ["https://repo"] } + + it "returns not required" do + expect(subject.requirement).to eq(Agama::Registration::Requirement::NOT_REQUIRED) + end + end + + context "and the product has no repositories" do + let(:repositories) { [] } + + it "returns mandatory" do + expect(subject.requirement).to eq(Agama::Registration::Requirement::MANDATORY) + end + end + end + end +end diff --git a/service/test/agama/software/manager_test.rb b/service/test/agama/software/manager_test.rb index a932423199..f899ab64b3 100644 --- a/service/test/agama/software/manager_test.rb +++ b/service/test/agama/software/manager_test.rb @@ -20,12 +20,15 @@ # find current contact information at www.suse.com. require_relative "../../test_helper" +require_relative "../with_issues_examples" require_relative "../with_progress_examples" -require_relative File.join( - SRC_PATH, "agama", "dbus", "y2dir", "software", "modules", "PackageCallbacks.rb" -) +require_relative File.join(SRC_PATH, "agama/dbus/y2dir/software/modules/PackageCallbacks.rb") require "agama/config" +require "agama/issue" +require "agama/registration" require "agama/software/manager" +require "agama/software/product" +require "agama/software/proposal" require "agama/dbus/clients/questions" describe Agama::Software::Manager do @@ -35,15 +38,19 @@ let(:base_url) { "" } let(:destdir) { "/mnt" } let(:gpg_keys) { [] } + let(:repositories) do instance_double( Agama::Software::RepositoriesManager, add: nil, load: nil, delete_all: nil, - empty?: true + empty?: true, + enabled: enabled_repos, + disabled: disabled_repos ) end + let(:proposal) do instance_double( Agama::Software::Proposal, @@ -51,10 +58,15 @@ calculate: nil, :languages= => nil, set_resolvables: nil, - packages_count: "500 MB" + packages_count: "500 MB", + issues: proposal_issues ) end + let(:enabled_repos) { [] } + let(:disabled_repos) { [] } + let(:proposal_issues) { [] } + let(:config_path) do File.join(FIXTURES_PATH, "root_dir", "etc", "agama.yaml") end @@ -84,6 +96,92 @@ allow(Agama::ProductReader).to receive(:new).and_call_original end + describe "#new" do + before do + allow_any_instance_of(Agama::Software::ProductBuilder) + .to receive(:build).and_return(products) + end + + context "if there are several products" do + let(:products) do + [Agama::Software::Product.new("test1"), Agama::Software::Product.new("test2")] + end + + it "does not select a product by default" do + manager = described_class.new(config, logger) + + expect(manager.product).to be_nil + end + end + + context "if there is only a product" do + let(:products) { [product] } + + let(:product) { Agama::Software::Product.new("test1") } + + it "selects the product" do + manager = described_class.new(config, logger) + + expect(manager.product.id).to eq("test1") + end + end + end + + shared_examples "software issues" do |tested_method| + before do + allow(subject.registration).to receive(:reg_code).and_return(reg_code) + end + + let(:reg_code) { "123XX432" } + let(:proposal_issues) { [Agama::Issue.new("Proposal issue")] } + + context "if there are disabled repositories" do + let(:disabled_repos) do + [ + instance_double(Agama::Software::Repository, name: "Repo #1"), + instance_double(Agama::Software::Repository, name: "Repo #2") + ] + end + + it "adds an issue for each disabled repository" do + subject.public_send(tested_method) + + expect(subject.issues).to include( + an_object_having_attributes( + description: /could not read the repository Repo #1/i + ), + an_object_having_attributes( + description: /could not read the repository Repo #2/i + ) + ) + end + end + + context "if there is any enabled repository" do + let(:enabled_repos) { [instance_double(Agama::Software::Repository, name: "Repo #1")] } + + it "adds the proposal issues" do + subject.public_send(tested_method) + + expect(subject.issues).to include(an_object_having_attributes( + description: /proposal issue/i + )) + end + end + + context "if there is no enabled repository" do + let(:enabled_repos) { [] } + + it "does not add the proposal issues" do + subject.public_send(tested_method) + + expect(subject.issues).to_not include(an_object_having_attributes( + description: /proposal issue/i + )) + end + end + end + describe "#probe" do let(:rootdir) { Dir.mktmpdir } let(:repos_dir) { File.join(rootdir, "etc", "zypp", "repos.d") } @@ -93,6 +191,7 @@ stub_const("Agama::Software::Manager::REPOS_DIR", repos_dir) stub_const("Agama::Software::Manager::REPOS_BACKUP", backup_repos_dir) FileUtils.mkdir_p(repos_dir) + subject.select_product("Tumbleweed") end after do @@ -119,20 +218,23 @@ end it "registers the repository from config" do - expect(repositories).to receive(:add).with(/Dolomite/) + expect(repositories).to receive(:add).with(/tumbleweed/) expect(repositories).to receive(:load) subject.probe end + + include_examples "software issues", "probe" end describe "#products" do it "returns the list of known products" do products = subject.products expect(products.size).to eq(3) - expect(products["Tumbleweed"]).to_not eq nil - expect(products["Tumbleweed"]).to include( - "name" => "openSUSE Tumbleweed", - "description" => String + expect(products).to all(be_a(Agama::Software::Product)) + expect(products).to contain_exactly( + an_object_having_attributes(id: "ALP-Dolomite"), + an_object_having_attributes(id: "Tumbleweed"), + an_object_having_attributes(id: "Leap16") ) end end @@ -140,7 +242,6 @@ describe "#propose" do before do subject.select_product("Tumbleweed") - allow(Yast::Arch).to receive(:s390).and_return(false) end it "creates a new proposal for the selected product" do @@ -150,6 +251,8 @@ subject.propose end + include_examples "software issues", "propose" + it "adds the patterns and packages to install depending on the system architecture" do expect(proposal).to receive(:set_resolvables) .with("agama", :pattern, ["enhanced_base"]) @@ -190,43 +293,6 @@ end end - describe "#validate" do - before do - allow(repositories).to receive(:enabled).and_return(enabled_repos) - allow(repositories).to receive(:disabled).and_return(disabled_repos) - allow(proposal).to receive(:errors).and_return([proposal_error]) - end - - let(:enabled_repos) { [] } - let(:disabled_repos) { [] } - let(:proposal_error) { Agama::ValidationError.new("proposal error") } - - context "when there are not enabled repositories" do - it "does not return the proposal errors" do - expect(subject.validate).to_not include(proposal_error) - end - end - - context "when there are disabled repositories" do - let(:disabled_repos) do - [instance_double(Agama::Software::Repository, name: "Repo #1")] - end - - it "returns an error for each disabled repository" do - expect(subject.validate.size).to eq(1) - error = subject.validate.first - expect(error.message).to match(/Could not read the repository/) - end - end - - context "when there are enabled repositories" do - let(:enabled_repos) { [instance_double(Agama::Software::Repository)] } - it "returns the proposal errors" do - expect(subject.validate).to include(proposal_error) - end - end - end - describe "#finish" do let(:rootdir) { Dir.mktmpdir } let(:repos_dir) { File.join(rootdir, "etc", "zypp", "repos.d") } @@ -281,5 +347,83 @@ end end + describe "#product_issues" do + before do + allow_any_instance_of(Agama::Software::ProductBuilder) + .to receive(:build).and_return([product1, product2]) + end + + let(:product1) do + Agama::Software::Product.new("test1").tap { |p| p.repositories = [] } + end + + let(:product2) do + Agama::Software::Product.new("test2").tap { |p| p.repositories = ["http://test"] } + end + + context "if no product is selected yet" do + it "contains a missing product issue" do + expect(subject.product_issues).to contain_exactly( + an_object_having_attributes( + description: /product not selected/i + ) + ) + end + end + + context "if a product is already selected" do + before do + subject.select_product(product_id) + end + + let(:product_id) { "test1" } + + it "does not include a missing product issue" do + expect(subject.product_issues).to_not include( + an_object_having_attributes( + description: /product not selected/i + ) + ) + end + + context "and the product does not require registration" do + let(:product_id) { "test2" } + + it "does not contain issues" do + expect(subject.product_issues).to be_empty + end + end + + context "and the product requires registration" do + let(:product_id) { "test1" } + + before do + allow(subject.registration).to receive(:reg_code).and_return(reg_code) + end + + context "and the product is not registered" do + let(:reg_code) { nil } + + it "contains a missing registration issue" do + expect(subject.product_issues).to contain_exactly( + an_object_having_attributes( + description: /product must be registered/i + ) + ) + end + end + + context "and the product is registered" do + let(:reg_code) { "1234XX5678" } + + it "does not contain issues" do + expect(subject.product_issues).to be_empty + end + end + end + end + end + + include_examples "issues" include_examples "progress" end diff --git a/service/test/agama/software/product_builder_test.rb b/service/test/agama/software/product_builder_test.rb new file mode 100644 index 0000000000..a48bc87ceb --- /dev/null +++ b/service/test/agama/software/product_builder_test.rb @@ -0,0 +1,302 @@ +# frozen_string_literal: true + +# Copyright (c) [2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../test_helper" +require "yast" +require "agama/config" +require "agama/product_reader" +require "agama/software/product" +require "agama/software/product_builder" + +Yast.import "Arch" + +describe Agama::Software::ProductBuilder do + before do + allow(Agama::ProductReader).to receive(:new).and_return(reader) + end + + let(:reader) { instance_double(Agama::ProductReader, load_products: products) } + + let(:products) do + [ + { + "id" => "Test1", + "name" => "Product Test 1", + "description" => "This is a test product named Test 1", + "translations" => { + "description" => { + "cs" => "Czech", + "es" => "Spanish" + } + }, + "software" => { + "installation_repositories" => [ + { + "url" => "https://repos/test1/x86_64/product/", + "archs" => "x86_64" + }, + { + "url" => "https://repos/test1/aarch64/product/", + "archs" => "aarch64" + } + ], + "mandatory_packages" => [ + { + "package" => "package1-1" + }, + "package1-2", + { + "package" => "package1-3", + "archs" => "aarch64,x86_64" + }, + { + "package" => "package1-4", + "archs" => "ppc64" + } + ], + "optional_packages" => ["package1-5"], + "mandatory_patterns" => ["pattern1-1", "pattern1-2"], + "optional_patterns" => [ + { + "pattern" => "pattern1-3", + "archs" => "x86_64" + }, + { + "pattern" => "pattern1-4", + "archs" => "aarch64" + } + ], + "base_product" => "Test1", + "version" => "1.0" + } + }, + { + "id" => "Test2", + "name" => "Product Test 2", + "description" => "This is a test product named Test 2", + "archs" => "x86_64,aarch64", + "software" => { + "mandatory_patterns" => ["pattern2-1"], + "base_product" => "Test2", + "version" => "2.0" + } + }, + { + "id" => "Test3", + "name" => "Product Test 3", + "description" => "This is a test product named Test 3", + "archs" => "ppc64,aarch64", + "software" => { + "installation_repositories" => ["https://repos/test3/product/"], + "optional_patterns" => [ + { + "pattern" => "pattern3-1", + "archs" => "aarch64" + } + ], + "base_product" => "Test3" + } + } + ] + end + + subject { described_class.new(config) } + + let(:config) { Agama::Config.new } + + describe "#build" do + context "for x86_64" do + before do + allow(Yast::Arch).to receive("x86_64").and_return(true) + allow(Yast::Arch).to receive("aarch64").and_return(false) + allow(Yast::Arch).to receive("ppc64").and_return(false) + allow(Yast::Arch).to receive("s390").and_return(false) + end + + it "generates products according to the current architecture" do + products = subject.build + + expect(products).to all(be_a(Agama::Software::Product)) + + expect(products).to contain_exactly( + an_object_having_attributes( + id: "Test1", + display_name: "Product Test 1", + description: "This is a test product named Test 1", + name: "Test1", + version: "1.0", + repositories: ["https://repos/test1/x86_64/product/"], + mandatory_patterns: ["pattern1-1", "pattern1-2"], + optional_patterns: ["pattern1-3"], + mandatory_packages: ["package1-1", "package1-2", "package1-3"], + optional_packages: ["package1-5"], + translations: { "description" => { "cs" => "Czech", "es" => "Spanish" } } + ), + an_object_having_attributes( + id: "Test2", + display_name: "Product Test 2", + description: "This is a test product named Test 2", + name: "Test2", + version: "2.0", + repositories: [], + mandatory_patterns: ["pattern2-1"], + optional_patterns: [], + mandatory_packages: [], + optional_packages: [], + translations: {} + ) + ) + end + end + + context "for aarch64" do + before do + allow(Yast::Arch).to receive("x86_64").and_return(false) + allow(Yast::Arch).to receive("aarch64").and_return(true) + allow(Yast::Arch).to receive("ppc64").and_return(false) + allow(Yast::Arch).to receive("s390").and_return(false) + end + + it "generates products according to the current architecture" do + products = subject.build + + expect(products).to all(be_a(Agama::Software::Product)) + + expect(products).to contain_exactly( + an_object_having_attributes( + id: "Test1", + display_name: "Product Test 1", + description: "This is a test product named Test 1", + name: "Test1", + version: "1.0", + repositories: ["https://repos/test1/aarch64/product/"], + mandatory_patterns: ["pattern1-1", "pattern1-2"], + optional_patterns: ["pattern1-4"], + mandatory_packages: ["package1-1", "package1-2", "package1-3"], + optional_packages: ["package1-5"], + translations: { "description" => { "cs" => "Czech", "es" => "Spanish" } } + ), + an_object_having_attributes( + id: "Test2", + display_name: "Product Test 2", + description: "This is a test product named Test 2", + name: "Test2", + version: "2.0", + repositories: [], + mandatory_patterns: ["pattern2-1"], + optional_patterns: [], + mandatory_packages: [], + optional_packages: [], + translations: {} + ), + an_object_having_attributes( + id: "Test3", + display_name: "Product Test 3", + description: "This is a test product named Test 3", + name: "Test3", + version: nil, + repositories: ["https://repos/test3/product/"], + mandatory_patterns: [], + optional_patterns: ["pattern3-1"], + mandatory_packages: [], + optional_packages: [], + translations: {} + ) + ) + end + end + + context "for ppc64" do + before do + allow(Yast::Arch).to receive("x86_64").and_return(false) + allow(Yast::Arch).to receive("aarch64").and_return(false) + allow(Yast::Arch).to receive("ppc64").and_return(true) + allow(Yast::Arch).to receive("s390").and_return(false) + end + + it "generates products according to the current architecture" do + products = subject.build + + expect(products).to all(be_a(Agama::Software::Product)) + + expect(products).to contain_exactly( + an_object_having_attributes( + id: "Test1", + display_name: "Product Test 1", + description: "This is a test product named Test 1", + name: "Test1", + version: "1.0", + repositories: [], + mandatory_patterns: ["pattern1-1", "pattern1-2"], + optional_patterns: [], + mandatory_packages: ["package1-1", "package1-2", "package1-4"], + optional_packages: ["package1-5"], + translations: { "description" => { "cs" => "Czech", "es" => "Spanish" } } + ), + an_object_having_attributes( + id: "Test3", + display_name: "Product Test 3", + description: "This is a test product named Test 3", + name: "Test3", + version: nil, + repositories: ["https://repos/test3/product/"], + mandatory_patterns: [], + optional_patterns: [], + mandatory_packages: [], + optional_packages: [], + translations: {} + ) + ) + end + end + + context "for s390" do + before do + allow(Yast::Arch).to receive("x86_64").and_return(false) + allow(Yast::Arch).to receive("aarch64").and_return(false) + allow(Yast::Arch).to receive("ppc64").and_return(false) + allow(Yast::Arch).to receive("s390").and_return(true) + end + + it "generates products according to the current architecture" do + products = subject.build + + expect(products).to all(be_a(Agama::Software::Product)) + + expect(products).to contain_exactly( + an_object_having_attributes( + id: "Test1", + display_name: "Product Test 1", + description: "This is a test product named Test 1", + name: "Test1", + version: "1.0", + repositories: [], + mandatory_patterns: ["pattern1-1", "pattern1-2"], + optional_patterns: [], + mandatory_packages: ["package1-1", "package1-2"], + optional_packages: ["package1-5"], + translations: { "description" => { "cs" => "Czech", "es" => "Spanish" } } + ) + ) + end + end + end +end diff --git a/service/test/agama/software/product_test.rb b/service/test/agama/software/product_test.rb new file mode 100644 index 0000000000..aab6a8397e --- /dev/null +++ b/service/test/agama/software/product_test.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +# Copyright (c) [2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../test_helper" +require "agama/software/product" + +describe Agama::Software::Product do + subject { described_class.new("Test") } + + describe "#localized_description" do + before do + subject.description = "Original description" + subject.translations = { + "description" => { + "cs" => "Czech translation", + "es" => "Spanish translation" + } + } + end + + it "returns untranslated description when the language is not set" do + allow(ENV).to receive(:[]).with("LANG").and_return(nil) + + expect(subject.localized_description).to eq("Original description") + end + + it "returns Czech translation if locale is \"cs_CZ.UTF-8\"" do + allow(ENV).to receive(:[]).with("LANG").and_return("cs_CZ.UTF-8") + + expect(subject.localized_description).to eq("Czech translation") + end + + it "returns Czech translation if locale is \"cs\"" do + allow(ENV).to receive(:[]).with("LANG").and_return("cs") + + expect(subject.localized_description).to eq("Czech translation") + end + + it "return untranslated description when translation is not available" do + allow(ENV).to receive(:[]).with("LANG").and_return("cs_CZ.UTF-8") + subject.translations = {} + + expect(subject.localized_description).to eq("Original description") + end + end +end diff --git a/service/test/agama/software/proposal_test.rb b/service/test/agama/software/proposal_test.rb index 3c850b466f..8dfdf1cb94 100644 --- a/service/test/agama/software/proposal_test.rb +++ b/service/test/agama/software/proposal_test.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2022] SUSE LLC +# Copyright (c) [2022-2023] SUSE LLC # # All Rights Reserved. # @@ -77,9 +77,9 @@ end context "when no errors were reported" do - it "does not register any error" do + it "does not register any issue" do subject.calculate - expect(subject.errors).to be_empty + expect(subject.issues).to be_empty end end @@ -88,10 +88,10 @@ { "warning_level" => :blocker, "warning" => "Could not install..." } end - it "registers the corresponding validation error" do + it "registers the corresponding issue" do subject.calculate - expect(subject.errors).to eq( - [Agama::ValidationError.new("Could not install...")] + expect(subject.issues).to contain_exactly( + an_object_having_attributes({ description: "Could not install..." }) ) end end @@ -100,13 +100,11 @@ let(:last_error) { "Solving errors..." } let(:solve_errors) { 5 } - it "registers them as validation errors" do + it "registers them as issues" do subject.calculate - expect(subject.errors).to eq( - [ - Agama::ValidationError.new("Solving errors..."), - Agama::ValidationError.new("Found 5 dependency issues.") - ] + expect(subject.issues).to contain_exactly( + an_object_having_attributes(description: "Solving errors..."), + an_object_having_attributes(description: "Found 5 dependency issues.") ) end end diff --git a/service/test/agama/with_issues_examples.rb b/service/test/agama/with_issues_examples.rb index e3ce105822..5a68e8180b 100644 --- a/service/test/agama/with_issues_examples.rb +++ b/service/test/agama/with_issues_examples.rb @@ -27,8 +27,6 @@ let(:issues) { [Agama::Issue.new("Issue 1"), Agama::Issue.new("Issue 2")] } it "sets the given list of issues" do - expect(subject.issues).to be_empty - subject.issues = issues expect(subject.issues).to contain_exactly( diff --git a/setup-service.sh b/setup-service.sh index 4760be186f..97c3dcd8af 100755 --- a/setup-service.sh +++ b/setup-service.sh @@ -39,7 +39,7 @@ test -f /etc/zypp/repos.d/d_l_python.repo || \ $SUDO zypper --non-interactive \ addrepo https://download.opensuse.org/repositories/devel:/languages:/python/openSUSE_Tumbleweed/ d_l_python $SUDO zypper --non-interactive --gpg-auto-import-keys install gcc gcc-c++ make openssl-devel ruby-devel \ - python-langtable-data git augeas-devel jemalloc-devel awk || exit 1 + python-langtable-data git augeas-devel jemalloc-devel awk suseconnect-ruby-bindings || exit 1 # only install cargo if it is not available (avoid conflicts with rustup) which cargo || $SUDO zypper --non-interactive install cargo diff --git a/web/package/cockpit-agama.changes b/web/package/cockpit-agama.changes index f510116a81..d9bdc468d7 100644 --- a/web/package/cockpit-agama.changes +++ b/web/package/cockpit-agama.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Wed Nov 15 12:32:25 UTC 2023 - José Iván López González + +- Add UI for registering a product (gh#openSUSE/agama#869). + ------------------------------------------------------------------- Thu Nov 2 07:38:22 UTC 2023 - David Diaz diff --git a/web/src/App.jsx b/web/src/App.jsx index c02367ddd4..4099afcd10 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -24,7 +24,7 @@ import { Outlet } from "react-router-dom"; import { _ } from "~/i18n"; import { useInstallerClient, useInstallerClientStatus } from "~/context/installer"; -import { useSoftware } from "./context/software"; +import { useProduct } from "./context/product"; import { STARTUP, INSTALL } from "~/client/phase"; import { BUSY } from "~/client/status"; @@ -39,7 +39,6 @@ import { ShowTerminalButton, Sidebar } from "~/components/core"; -import { ChangeProductLink } from "~/components/software"; import { LanguageSwitcher } from "./components/l10n"; import { Layout, Loading, Title } from "./components/layout"; import { useL10n } from "./context/l10n"; @@ -57,7 +56,7 @@ const ATTEMPTS = 3; function App() { const client = useInstallerClient(); const { attempt } = useInstallerClientStatus(); - const { products } = useSoftware(); + const { products } = useProduct(); const { language } = useL10n(); const [status, setStatus] = useState(undefined); const [phase, setPhase] = useState(undefined); @@ -107,7 +106,6 @@ function App() { <>
- diff --git a/web/src/App.test.jsx b/web/src/App.test.jsx index 84092633b6..59b0753521 100644 --- a/web/src/App.test.jsx +++ b/web/src/App.test.jsx @@ -31,9 +31,9 @@ jest.mock("~/client"); // list of available products let mockProducts; -jest.mock("~/context/software", () => ({ - ...jest.requireActual("~/context/software"), - useSoftware: () => { +jest.mock("~/context/product", () => ({ + ...jest.requireActual("~/context/product"), + useProduct: () => { return { products: mockProducts, selectedProduct: null diff --git a/web/src/assets/styles/utilities.scss b/web/src/assets/styles/utilities.scss index 6b64d2f47b..1f84016877 100644 --- a/web/src/assets/styles/utilities.scss +++ b/web/src/assets/styles/utilities.scss @@ -142,6 +142,10 @@ padding: 0; } +.p-0 { + padding: 0; +} + .no-stack-gutter { --stack-gutter: 0; } diff --git a/web/src/client/index.js b/web/src/client/index.js index b4d56f18e0..34a656972e 100644 --- a/web/src/client/index.js +++ b/web/src/client/index.js @@ -30,7 +30,6 @@ import { UsersClient } from "./users"; import phase from "./phase"; import { QuestionsClient } from "./questions"; import { NetworkClient } from "./network"; -import { IssuesClient } from "./issues"; import cockpit from "../lib/cockpit"; const BUS_ADDRESS_FILE = "/run/agama/bus.address"; @@ -38,21 +37,34 @@ const MANAGER_SERVICE = "org.opensuse.Agama.Manager1"; /** * @typedef {object} InstallerClient - * @property {LanguageClient} language - language client - * @property {ManagerClient} manager - manager client - * @property {Monitor} monitor - service monitor - * @property {NetworkClient} network - network client - * @property {SoftwareClient} software - software client - * @property {StorageClient} storage - storage client - * @property {UsersClient} users - users client - * @property {QuestionsClient} questions - questions client - * @property {IssuesClient} issues - issues client + * @property {LanguageClient} language - language client. + * @property {ManagerClient} manager - manager client. + * @property {Monitor} monitor - service monitor. + * @property {NetworkClient} network - network client. + * @property {SoftwareClient} software - software client. + * @property {StorageClient} storage - storage client. + * @property {UsersClient} users - users client. + * @property {QuestionsClient} questions - questions client. + * @property {() => Promise} issues - issues from all contexts. + * @property {(handler: IssuesHandler) => (() => void)} onIssuesChange - registers a handler to run + * when issues from any context change. It returns a function to deregister the handler. * @property {() => Promise} isConnected - determines whether the client is connected * @property {(handler: () => void) => (() => void)} onDisconnect - registers a handler to run * when the connection is lost. It returns a function to deregister the * handler. */ +/** + * @typedef {import ("~/client/mixins").Issue} Issue + * + * @typedef {object} Issues + * @property {Issue[]} [product] - Issues from product. + * @property {Issue[]} [storage] - Issues from storage. + * @property {Issue[]} [software] - Issues from software. + * + * @typedef {(issues: Issues) => void} IssuesHandler +*/ + /** * Creates the Agama client * @@ -67,7 +79,38 @@ const createClient = (address = "unix:path=/run/agama/bus") => { const storage = new StorageClient(address); const users = new UsersClient(address); const questions = new QuestionsClient(address); - const issues = new IssuesClient({ storage }); + + /** + * Gets all issues, grouping them by context. + * + * TODO: issues are requested by several components (e.g., overview sections, notifications + * provider, issues page, storage page, etc). There should be an issues provider. + * + * @returns {Promise} + */ + const issues = async () => { + return { + product: await software.product.getIssues(), + storage: await storage.getIssues(), + software: await software.getIssues() + }; + }; + + /** + * Registers a callback to be executed when issues change. + * + * @param {IssuesHandler} handler - Callback function. + * @return {() => void} - Function to deregister the callback. + */ + const onIssuesChange = (handler) => { + const unsubscribeCallbacks = []; + + unsubscribeCallbacks.push(software.product.onIssuesChange(i => handler({ product: i }))); + unsubscribeCallbacks.push(storage.onIssuesChange(i => handler({ storage: i }))); + unsubscribeCallbacks.push(software.onIssuesChange(i => handler({ software: i }))); + + return () => { unsubscribeCallbacks.forEach(cb => cb()) }; + }; const isConnected = async () => { try { @@ -88,6 +131,7 @@ const createClient = (address = "unix:path=/run/agama/bus") => { users, questions, issues, + onIssuesChange, isConnected, onDisconnect: (handler) => monitor.onDisconnect(handler) }; diff --git a/web/src/client/issues.js b/web/src/client/issues.js deleted file mode 100644 index f6a38d4e26..0000000000 --- a/web/src/client/issues.js +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (c) [2023] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -// @ts-check - -/** - * @typedef {object} ClientsIssues - * @property {import ("~/client/mixins").Issue[]} storage - Issues from storage client - */ - -/** - * Client for managing all issues, independently on the service owning the issues - */ -class IssuesClient { - /** - * @param {object} clients - Clients managing issues - * @param {import ("~/client/storage").StorageClient} clients.storage - */ - constructor(clients) { - this.clients = clients; - } - - /** - * Get issues from all clients managing issues - * - * @returns {Promise} - */ - async getAll() { - const storage = await this.clients.storage.getIssues(); - - return { storage }; - } - - /** - * Checks whether there is some error - * - * @returns {Promise} - */ - async any() { - const clientsIssues = await this.getAll(); - const issues = Object.values(clientsIssues).flat(); - - return issues.length > 0; - } - - /** - * Registers a callback for each service to be executed when its issues change - * - * @param {import ("~/client/mixins").IssuesHandler} handler - callback function - * @return {import ("./dbus").RemoveFn} function to disable the callback - */ - onIssuesChange(handler) { - const unsubscribeCallbacks = []; - unsubscribeCallbacks.push(this.clients.storage.onIssuesChange(handler)); - - return () => { unsubscribeCallbacks.forEach(cb => cb()) }; - } -} - -export { IssuesClient }; diff --git a/web/src/client/issues.test.js b/web/src/client/issues.test.js deleted file mode 100644 index 2eec15df3f..0000000000 --- a/web/src/client/issues.test.js +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright (c) [2023] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -// @ts-check - -import { IssuesClient } from "./issues"; -import { StorageClient } from "./storage"; - -const storageIssues = [ - { description: "Storage issue 1", severity: "error", details: "", source: "" }, - { description: "Storage issue 2", severity: "warn", details: "", source: "" }, - { description: "Storage issue 3", severity: "error", details: "", source: "" } -]; - -const issues = { - storage: [] -}; - -jest.spyOn(StorageClient.prototype, 'getIssues').mockImplementation(async () => issues.storage); -jest.spyOn(StorageClient.prototype, 'onIssuesChange'); - -const clientsWithIssues = { - storage: new StorageClient() -}; - -describe("#getAll", () => { - beforeEach(() => { - issues.storage = storageIssues; - }); - - it("returns all the storage issues", async () => { - const client = new IssuesClient(clientsWithIssues); - - const { storage } = await client.getAll(); - expect(storage).toEqual(expect.arrayContaining(storageIssues)); - }); -}); - -describe("#any", () => { - describe("if there are storage issues", () => { - beforeEach(() => { - issues.storage = storageIssues; - }); - - it("returns true", async () => { - const client = new IssuesClient(clientsWithIssues); - - const result = await client.any(); - expect(result).toEqual(true); - }); - }); - - describe("if there are no issues", () => { - beforeEach(() => { - issues.storage = []; - }); - - it("returns false", async () => { - const client = new IssuesClient(clientsWithIssues); - - const result = await client.any(); - expect(result).toEqual(false); - }); - }); -}); - -describe("#onIssuesChange", () => { - it("subscribes to changes in storage issues", () => { - const client = new IssuesClient(clientsWithIssues); - - const handler = jest.fn(); - client.onIssuesChange(handler); - - expect(clientsWithIssues.storage.onIssuesChange).toHaveBeenCalledWith(handler); - }); -}); diff --git a/web/src/client/software.js b/web/src/client/software.js index 5572033707..edec2fcc21 100644 --- a/web/src/client/software.js +++ b/web/src/client/software.js @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -22,11 +22,14 @@ // @ts-check import DBusClient from "./dbus"; -import { WithStatus, WithProgress, WithValidation } from "./mixins"; +import { WithIssues, WithStatus, WithProgress } from "./mixins"; const SOFTWARE_SERVICE = "org.opensuse.Agama.Software1"; const SOFTWARE_IFACE = "org.opensuse.Agama.Software1"; const SOFTWARE_PATH = "/org/opensuse/Agama/Software1"; +const PRODUCT_IFACE = "org.opensuse.Agama.Software1.Product"; +const PRODUCT_PATH = "/org/opensuse/Agama/Software1/Product"; +const REGISTRATION_IFACE = "org.opensuse.Agama1.Registration"; /** * @typedef {object} Product @@ -35,6 +38,175 @@ const SOFTWARE_PATH = "/org/opensuse/Agama/Software1"; * @property {string} description - Product description */ +/** + * @typedef {object} Registration + * @property {string} requirement - Registration requirement (i.e., "not-required", "optional", + * "mandatory"). + * @property {string|null} code - Registration code, if any. + * @property {string|null} email - Registration email, if any. + */ + +/** + * @typedef {object} ActionResult + * @property {boolean} success - Whether the action was successfully done. + * @property {string} message - Result message. + */ + +/** + * Product manager. + * @ignore + */ +class BaseProductManager { + /** + * @param {DBusClient} client + */ + constructor(client) { + this.client = client; + } + + /** + * Returns the list of available products. + * + * @return {Promise>} + */ + async getAll() { + const proxy = await this.client.proxy(PRODUCT_IFACE); + return proxy.AvailableProducts.map(product => { + const [id, name, meta] = product; + return { id, name, description: meta.description?.v }; + }); + } + + /** + * Returns the selected product. + * + * @return {Promise} + */ + async getSelected() { + const products = await this.getAll(); + const proxy = await this.client.proxy(PRODUCT_IFACE); + if (proxy.SelectedProduct === "") { + return null; + } + return products.find(product => product.id === proxy.SelectedProduct); + } + + /** + * Selects a product for installation. + * + * @param {string} id - Product ID. + */ + async select(id) { + const proxy = await this.client.proxy(PRODUCT_IFACE); + return proxy.SelectProduct(id); + } + + /** + * Registers a callback to run when properties in the Product object change. + * + * @param {(id: string) => void} handler - Callback function. + */ + onChange(handler) { + return this.client.onObjectChanged(PRODUCT_PATH, PRODUCT_IFACE, changes => { + if ("SelectedProduct" in changes) { + const selected = changes.SelectedProduct.v.toString(); + handler(selected); + } + }); + } + + /** + * Returns the registration of the selected product. + * + * @return {Promise} + */ + async getRegistration() { + const proxy = await this.client.proxy(REGISTRATION_IFACE, PRODUCT_PATH); + const requirement = this.registrationRequirement(proxy.Requirement); + const code = proxy.RegCode; + const email = proxy.Email; + + const registration = { requirement, code, email }; + if (code.length === 0) registration.code = null; + if (email.length === 0) registration.email = null; + + return registration; + } + + /** + * Tries to register the selected product. + * + * @param {string} code + * @param {string} [email] + * @returns {Promise} + */ + async register(code, email = "") { + const proxy = await this.client.proxy(REGISTRATION_IFACE, PRODUCT_PATH); + const result = await proxy.Register(code, { Email: { t: "s", v: email } }); + + return { + success: result[0] === 0, + message: result[1] + }; + } + + /** + * Tries to deregister the selected product. + * + * @returns {Promise} + */ + async deregister() { + const proxy = await this.client.proxy(REGISTRATION_IFACE, PRODUCT_PATH); + const result = await proxy.Deregister(); + + return { + success: result[0] === 0, + message: result[1] + }; + } + + /** + * Registers a callback to run when the registration changes. + * + * @param {(registration: Registration) => void} handler - Callback function. + */ + onRegistrationChange(handler) { + return this.client.onObjectChanged(PRODUCT_PATH, REGISTRATION_IFACE, () => { + this.getRegistration().then(handler); + }); + } + + /** + * Helper method to generate the requirement representation. + * @private + * + * @param {number} value - D-Bus registration value. + * @returns {string} + */ + registrationRequirement(value) { + let requirement; + + switch (value) { + case 0: + requirement = "not-required"; + break; + case 1: + requirement = "optional"; + break; + case 2: + requirement = "mandatory"; + break; + } + + return requirement; + } +} + +/** + * Manages product selection. + */ +class ProductManager extends WithIssues(BaseProductManager, PRODUCT_PATH) { } + /** * Software client * @@ -46,6 +218,7 @@ class SoftwareBaseClient { */ constructor(address = undefined) { this.client = new DBusClient(SOFTWARE_SERVICE, address); + this.product = new ProductManager(this.client); } /** @@ -58,19 +231,6 @@ class SoftwareBaseClient { return proxy.Probe(); } - /** - * Returns the list of available products - * - * @return {Promise>} - */ - async getProducts() { - const proxy = await this.client.proxy(SOFTWARE_IFACE); - return proxy.AvailableBaseProducts.map(product => { - const [id, name, meta] = product; - return { id, name, description: meta.description?.v }; - }); - } - /** * Returns how much space installation takes on disk * @@ -128,50 +288,12 @@ class SoftwareBaseClient { const proxy = await this.client.proxy(SOFTWARE_IFACE); return proxy.RemovePattern(name); } - - /** - * Returns the selected product - * - * @return {Promise} - */ - async getSelectedProduct() { - const products = await this.getProducts(); - const proxy = await this.client.proxy(SOFTWARE_IFACE); - if (proxy.SelectedBaseProduct === "") { - return null; - } - return products.find(product => product.id === proxy.SelectedBaseProduct); - } - - /** - * Selects a product for installation - * - * @param {string} id - product ID - */ - async selectProduct(id) { - const proxy = await this.client.proxy(SOFTWARE_IFACE); - return proxy.SelectProduct(id); - } - - /** - * Registers a callback to run when properties in the Software object change - * - * @param {(id: string) => void} handler - callback function - */ - onProductChange(handler) { - return this.client.onObjectChanged(SOFTWARE_PATH, SOFTWARE_IFACE, changes => { - if ("SelectedBaseProduct" in changes) { - const selected = changes.SelectedBaseProduct.v.toString(); - handler(selected); - } - }); - } } /** - * Allows getting the list the available products and selecting one for installation. + * Manages software and product configuration. */ -class SoftwareClient extends WithValidation( +class SoftwareClient extends WithIssues( WithProgress( WithStatus(SoftwareBaseClient, SOFTWARE_PATH), SOFTWARE_PATH ), SOFTWARE_PATH diff --git a/web/src/client/software.test.js b/web/src/client/software.test.js index f8460beb43..dfcdaeaed7 100644 --- a/web/src/client/software.test.js +++ b/web/src/client/software.test.js @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -26,45 +26,166 @@ import { SoftwareClient } from "./software"; jest.mock("./dbus"); -const SOFTWARE_IFACE = "org.opensuse.Agama.Software1"; +const PRODUCT_IFACE = "org.opensuse.Agama.Software1.Product"; +const REGISTRATION_IFACE = "org.opensuse.Agama1.Registration"; -const softProxy = { +const productProxy = { wait: jest.fn(), - AvailableBaseProducts: [ + AvailableProducts: [ ["MicroOS", "openSUSE MicroOS", {}], ["Tumbleweed", "openSUSE Tumbleweed", {}] ], - SelectedBaseProduct: "MicroOS" + SelectedProduct: "MicroOS" }; +const registrationProxy = {}; + beforeEach(() => { // @ts-ignore DBusClient.mockImplementation(() => { return { proxy: (iface) => { - if (iface === SOFTWARE_IFACE) return softProxy; + if (iface === PRODUCT_IFACE) return productProxy; + if (iface === REGISTRATION_IFACE) return registrationProxy; } }; }); }); -describe("#getProducts", () => { - it("returns the list of available products", async () => { - const client = new SoftwareClient(); - const availableProducts = await client.getProducts(); - expect(availableProducts).toEqual([ - { id: "MicroOS", name: "openSUSE MicroOS" }, - { id: "Tumbleweed", name: "openSUSE Tumbleweed" } - ]); +describe("#product", () => { + describe("#getAll", () => { + it("returns the list of available products", async () => { + const client = new SoftwareClient(); + const availableProducts = await client.product.getAll(); + expect(availableProducts).toEqual([ + { id: "MicroOS", name: "openSUSE MicroOS" }, + { id: "Tumbleweed", name: "openSUSE Tumbleweed" } + ]); + }); }); -}); -describe('#getSelectedProduct', () => { - it("returns the selected product", async () => { - const client = new SoftwareClient(); - const selectedProduct = await client.getSelectedProduct(); - expect(selectedProduct).toEqual( - { id: "MicroOS", name: "openSUSE MicroOS" } - ); + describe("#getSelected", () => { + it("returns the selected product", async () => { + const client = new SoftwareClient(); + const selectedProduct = await client.product.getSelected(); + expect(selectedProduct).toEqual( + { id: "MicroOS", name: "openSUSE MicroOS" } + ); + }); + }); + + describe("#getRegistration", () => { + describe("if the product is not registered yet", () => { + beforeEach(() => { + registrationProxy.RegCode = ""; + registrationProxy.Email = ""; + registrationProxy.Requirement = 1; + }); + + it("returns the expected registration result", async () => { + const client = new SoftwareClient(); + const registration = await client.product.getRegistration(); + expect(registration).toStrictEqual({ + code: null, + email: null, + requirement: "optional" + }); + }); + }); + + describe("if the product is registered", () => { + beforeEach(() => { + registrationProxy.RegCode = "111222"; + registrationProxy.Email = "test@test.com"; + registrationProxy.Requirement = 2; + }); + + it("returns the expected registration", async () => { + const client = new SoftwareClient(); + const registration = await client.product.getRegistration(); + expect(registration).toStrictEqual({ + code: "111222", + email: "test@test.com", + requirement: "mandatory" + }); + }); + }); + }); + + describe("#register", () => { + beforeEach(() => { + registrationProxy.Register = jest.fn().mockResolvedValue([0, ""]); + }); + + it("performs the expected D-Bus call", async () => { + const client = new SoftwareClient(); + await client.product.register("111222", "test@test.com"); + expect(registrationProxy.Register).toHaveBeenCalledWith( + "111222", + { Email: { t: "s", v: "test@test.com" } } + ); + }); + + describe("when the action is correctly done", () => { + beforeEach(() => { + registrationProxy.Register = jest.fn().mockResolvedValue([0, ""]); + }); + + it("returns a successful result", async () => { + const client = new SoftwareClient(); + const result = await client.product.register("111222", "test@test.com"); + expect(result).toStrictEqual({ + success: true, + message: "" + }); + }); + }); + + describe("when the action fails", () => { + beforeEach(() => { + registrationProxy.Register = jest.fn().mockResolvedValue([1, "error message"]); + }); + + it("returns an unsuccessful result", async () => { + const client = new SoftwareClient(); + const result = await client.product.register("111222", "test@test.com"); + expect(result).toStrictEqual({ + success: false, + message: "error message" + }); + }); + }); + }); + + describe("#deregister", () => { + describe("when the action is correctly done", () => { + beforeEach(() => { + registrationProxy.Deregister = jest.fn().mockResolvedValue([0, ""]); + }); + + it("returns a successful result", async () => { + const client = new SoftwareClient(); + const result = await client.product.deregister(); + expect(result).toStrictEqual({ + success: true, + message: "" + }); + }); + }); + + describe("when the action fails", () => { + beforeEach(() => { + registrationProxy.Deregister = jest.fn().mockResolvedValue([1, "error message"]); + }); + + it("returns an unsuccessful result", async () => { + const client = new SoftwareClient(); + const result = await client.product.deregister(); + expect(result).toStrictEqual({ + success: false, + message: "error message" + }); + }); + }); }); }); diff --git a/web/src/components/core/EmailInput.jsx b/web/src/components/core/EmailInput.jsx new file mode 100644 index 0000000000..dc84711157 --- /dev/null +++ b/web/src/components/core/EmailInput.jsx @@ -0,0 +1,86 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useEffect, useState } from "react"; +import { InputGroup, TextInput } from "@patternfly/react-core"; +import { noop } from "~/utils"; + +/** + * Email validation. + * + * Code inspired by https://github.com/manishsaraan/email-validator/blob/master/index.js + * + * @param {string} email + * @returns {boolean} + */ +const validateEmail = (email) => { + const regexp = /^[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~](\.?[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/; + + const validateFormat = (email) => { + const parts = email.split('@'); + + return parts.length === 2 && regexp.test(email); + }; + + const validateSizes = (email) => { + const [account, address] = email.split('@'); + + if (account.length > 64) return false; + if (address.length > 255) return false; + + const domainParts = address.split('.'); + + if (domainParts.find(p => p.length > 63)) return false; + + return true; + }; + + return validateFormat(email) && validateSizes(email); +}; + +/** + * Renders an email input field which validates its value. + * @component + * + * @param {(boolean) => void} onValidate - Callback to be called every time the input value is + * validated. + * @param {Object} props - Props matching the {@link https://www.patternfly.org/components/forms/text-input PF/TextInput}, + * except `type` and `validated` which are managed by the component. + */ +export default function EmailInput({ onValidate = noop, ...props }) { + const [isValid, setIsValid] = useState(true); + + useEffect(() => { + const isValid = props.value.length === 0 || validateEmail(props.value); + setIsValid(isValid); + onValidate(isValid); + }, [onValidate, props.value, setIsValid]); + + return ( + + + + ); +} diff --git a/web/src/components/core/EmailInput.test.jsx b/web/src/components/core/EmailInput.test.jsx new file mode 100644 index 0000000000..0c64ce08b7 --- /dev/null +++ b/web/src/components/core/EmailInput.test.jsx @@ -0,0 +1,92 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useState } from "react"; +import { screen } from "@testing-library/react"; + +import EmailInput from "./EmailInput"; +import { plainRender } from "~/test-utils"; + +describe("EmailInput component", () => { + it("renders an email input", () => { + plainRender( + + ); + + const inputField = screen.getByRole('textbox', { name: "User email" }); + expect(inputField).toHaveAttribute("type", "email"); + }); + + // Using a controlled component for testing the rendered result instead of testing if + // the given onChange callback is called. The former is more aligned with the + // React Testing Library principles, https://testing-library.com/docs/guiding-principles/ + const EmailInputTest = (props) => { + const [email, setEmail] = useState(""); + const [isValid, setIsValid] = useState(true); + + return ( + <> + setEmail(v)} + onValidate={setIsValid} + /> + {email &&

Email value updated!

} + {isValid === false &&

Email is not valid!

} + + ); + }; + + it("triggers onChange callback", async () => { + const { user } = plainRender(); + const emailInput = screen.getByRole('textbox', { name: "Test email" }); + + expect(screen.queryByText("Email value updated!")).toBeNull(); + + await user.type(emailInput, "test@test.com"); + screen.getByText("Email value updated!"); + }); + + it("triggers onValidate callback", async () => { + const { user } = plainRender(); + const emailInput = screen.getByRole('textbox', { name: "Test email" }); + + expect(screen.queryByText("Email is not valid!")).toBeNull(); + + await user.type(emailInput, "foo"); + await screen.findByText("Email is not valid!"); + }); + + it("marks the input as invalid if the value is not a valid email", async () => { + const { user } = plainRender(); + const emailInput = screen.getByRole('textbox', { name: "Test email" }); + + await user.type(emailInput, "foo"); + + expect(emailInput).toHaveAttribute("aria-invalid"); + }); +}); diff --git a/web/src/components/core/IssuesLink.test.jsx b/web/src/components/core/IssuesLink.test.jsx index dc2fdae1ba..8073276beb 100644 --- a/web/src/components/core/IssuesLink.test.jsx +++ b/web/src/components/core/IssuesLink.test.jsx @@ -25,17 +25,15 @@ import { installerRender, withNotificationProvider } from "~/test-utils"; import { createClient } from "~/client"; import { IssuesLink } from "~/components/core"; -let hasIssues = false; +let mockIssues = {}; jest.mock("~/client"); beforeEach(() => { createClient.mockImplementation(() => { return { - issues: { - any: () => Promise.resolve(hasIssues), - onIssuesChange: jest.fn() - } + issues: jest.fn().mockResolvedValue(mockIssues), + onIssuesChange: jest.fn() }; }); }); @@ -48,7 +46,9 @@ it("renders a link for navigating to the issues page", async () => { describe("if there are issues", () => { beforeEach(() => { - hasIssues = true; + mockIssues = { + storage: [{ description: "issue 1" }] + }; }); it("includes a notification mark", async () => { @@ -60,7 +60,7 @@ describe("if there are issues", () => { describe("if there are not issues", () => { beforeEach(() => { - hasIssues = false; + mockIssues = {}; }); it("does not include a notification mark", async () => { diff --git a/web/src/components/core/IssuesPage.jsx b/web/src/components/core/IssuesPage.jsx index 7d137c5fca..f076c6aece 100644 --- a/web/src/components/core/IssuesPage.jsx +++ b/web/src/components/core/IssuesPage.jsx @@ -21,17 +21,17 @@ import React, { useCallback, useEffect, useState } from "react"; -import { HelperText, HelperTextItem, Skeleton } from "@patternfly/react-core"; +import { HelperText, HelperTextItem } from "@patternfly/react-core"; import { partition, useCancellablePromise } from "~/utils"; -import { If, Page, Section } from "~/components/core"; +import { If, Page, Section, SectionSkeleton } from "~/components/core"; import { Icon } from "~/components/layout"; import { useInstallerClient } from "~/context/installer"; import { useNotification } from "~/context/notification"; import { _ } from "~/i18n"; /** - * Renders an issue + * Item representing an issue. * @component * * @param {object} props @@ -58,54 +58,68 @@ const IssueItem = ({ issue }) => { }; /** - * Generates a specific section with issues + * Generates issue items sorted by severity. * @component * * @param {object} props * @param {import ("~/client/mixins").Issue[]} props.issues - * @param {object} props.props */ -const IssuesSection = ({ issues, ...props }) => { - if (issues.length === 0) return null; - +const IssueItems = ({ issues = [] }) => { const sortedIssues = partition(issues, i => i.severity === "error").flat(); - const issueItems = sortedIssues.map((issue, index) => { + return sortedIssues.map((issue, index) => { return ; }); - - return ( -
- {issueItems} -
- ); }; /** - * Generates the sections with issues + * Generates the sections with issues. * @component * * @param {object} props * @param {import ("~/client/issues").ClientsIssues} props.issues */ const IssuesSections = ({ issues }) => { + const productIssues = issues.product || []; + const storageIssues = issues.storage || []; + const softwareIssues = issues.software || []; + return ( - + <> + 0} + then={ +
+ +
+ } + /> + 0} + then={ +
+ +
+ } + /> + 0} + then={ +
+ +
+ } + /> + ); }; /** - * Generates the content for each section with issues. If there are no issues, then a success - * message is shown. + * Generates sections with issues. If there are no issues, then a success message is shown. * @component * * @param {object} props - * @param {import ("~/client/issues").ClientsIssues} props.issues + * @param {import ("~/client").Issues} props.issues */ const IssuesContent = ({ issues }) => { const NoIssues = () => { @@ -130,28 +144,32 @@ const IssuesContent = ({ issues }) => { }; /** - * Page to show all issues per section + * Page to show all issues. * @component */ export default function IssuesPage() { const [isLoading, setIsLoading] = useState(true); - const [issues, setIssues] = useState({}); - const { issues: client } = useInstallerClient(); + const [issues, setIssues] = useState(); + const client = useInstallerClient(); const { cancellablePromise } = useCancellablePromise(); - const [, updateNotification] = useNotification(); + const [notification, updateNotification] = useNotification(); - const loadIssues = useCallback(async () => { + const load = useCallback(async () => { setIsLoading(true); - const allIssues = await cancellablePromise(client.getAll()); - setIssues(allIssues); + const issues = await cancellablePromise(client.issues()); setIsLoading(false); - updateNotification({ issues: false }); - }, [client, cancellablePromise, setIssues, setIsLoading, updateNotification]); + return issues; + }, [client, cancellablePromise, setIsLoading]); + + const update = useCallback((issues) => { + setIssues(current => ({ ...current, ...issues })); + if (notification.issues) updateNotification({ issues: false }); + }, [notification, setIssues, updateNotification]); useEffect(() => { - loadIssues(); - return client.onIssuesChange(loadIssues); - }, [client, loadIssues]); + load().then(update); + return client.onIssuesChange(update); + }, [client, load, update]); return ( } + then={} else={} /> diff --git a/web/src/components/core/IssuesPage.test.jsx b/web/src/components/core/IssuesPage.test.jsx index 061357e68c..97badf900d 100644 --- a/web/src/components/core/IssuesPage.test.jsx +++ b/web/src/components/core/IssuesPage.test.jsx @@ -20,37 +20,43 @@ */ import React from "react"; -import { screen, within } from "@testing-library/react"; -import { installerRender, withNotificationProvider } from "~/test-utils"; +import { act, screen, waitFor, within } from "@testing-library/react"; +import { installerRender, createCallbackMock, withNotificationProvider } from "~/test-utils"; import { createClient } from "~/client"; import { IssuesPage } from "~/components/core"; jest.mock("~/client"); jest.mock("@patternfly/react-core", () => { - const original = jest.requireActual("@patternfly/react-core"); - return { - ...original, + ...jest.requireActual("@patternfly/react-core"), Skeleton: () =>
PFSkeleton
}; }); -let issues = { +const issues = { + product: [], storage: [ - { description: "Issue 1", details: "Details 1", source: "system", severity: "warn" }, - { description: "Issue 2", details: "Details 2", source: "config", severity: "error" } + { description: "storage issue 1", details: "Details 1", source: "system", severity: "warn" }, + { description: "storage issue 2", details: "Details 2", source: "config", severity: "error" } + ], + software: [ + { description: "software issue 1", details: "Details 1", source: "system", severity: "warn" } ] }; +let mockIssues; + +let mockOnIssuesChange; + beforeEach(() => { + mockIssues = { ...issues }; + mockOnIssuesChange = jest.fn(); + createClient.mockImplementation(() => { return { - issues: { - any: () => Promise.resolve(true), - getAll: () => Promise.resolve(issues), - onIssuesChange: jest.fn() - } + issues: jest.fn().mockResolvedValue(mockIssues), + onIssuesChange: mockOnIssuesChange }; }); }); @@ -59,20 +65,25 @@ it("loads the issues", async () => { installerRender(withNotificationProvider()); screen.getAllByText(/PFSkeleton/); - await screen.findByText(/Issue 1/); + await screen.findByText(/storage issue 1/); }); it("renders sections with issues", async () => { installerRender(withNotificationProvider()); - const section = await screen.findByRole("region", { name: "Storage" }); - within(section).findByText(/Issue 1/); - within(section).findByText(/Issue 2/); + await waitFor(() => expect(screen.queryByText("Product")).not.toBeInTheDocument()); + + const storageSection = await screen.findByText(/Storage/); + within(storageSection).findByText(/storage issue 1/); + within(storageSection).findByText(/storage issue 2/); + + const softwareSection = await screen.findByText(/Software/); + within(softwareSection).findByText(/software issue 1/); }); describe("if there are not issues", () => { beforeEach(() => { - issues = { storage: [] }; + mockIssues = { product: [], storage: [], software: [] }; }); it("renders a success message", async () => { @@ -81,3 +92,21 @@ describe("if there are not issues", () => { await screen.findByText(/No issues found/); }); }); + +describe("if the issues change", () => { + it("shows the new issues", async () => { + const [mockFunction, callbacks] = createCallbackMock(); + mockOnIssuesChange = mockFunction; + + installerRender(withNotificationProvider()); + + await screen.findByText("Storage"); + + mockIssues.storage = []; + act(() => callbacks.forEach(c => c({ storage: mockIssues.storage }))); + + await waitFor(() => expect(screen.queryByText("Storage")).not.toBeInTheDocument()); + const softwareSection = await screen.findByText(/Software/); + within(softwareSection).findByText(/software issue 1/); + }); +}); diff --git a/web/src/components/core/SectionSkeleton.jsx b/web/src/components/core/SectionSkeleton.jsx index b8e0fa8d70..e34da05402 100644 --- a/web/src/components/core/SectionSkeleton.jsx +++ b/web/src/components/core/SectionSkeleton.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -23,19 +23,27 @@ import React from "react"; import { Skeleton } from "@patternfly/react-core"; import { _ } from "~/i18n"; -const SectionSkeleton = () => ( - <> +const WaitingSkeleton = ({ width }) => { + return ( - - -); + ); +}; + +const SectionSkeleton = ({ numRows = 2 }) => { + return ( + <> + { + Array.from({ length: numRows }, (_, i) => { + const width = i % 2 === 0 ? "50%" : "25%"; + return ; + }) + } + + ); +}; export default SectionSkeleton; diff --git a/web/src/components/core/Sidebar.test.jsx b/web/src/components/core/Sidebar.test.jsx index cf20df357d..dce362f2fd 100644 --- a/web/src/components/core/Sidebar.test.jsx +++ b/web/src/components/core/Sidebar.test.jsx @@ -27,19 +27,18 @@ import { createClient } from "~/client"; // Mock some components using contexts and not relevant for below tests jest.mock("~/components/core/LogsButton", () => () =>
LogsButton Mock
); -jest.mock("~/components/software/ChangeProductLink", () => () =>
ChangeProductLink Mock
); -let hasIssues = false; +let mockIssues; jest.mock("~/client"); beforeEach(() => { + mockIssues = []; + createClient.mockImplementation(() => { return { - issues: { - any: () => Promise.resolve(hasIssues), - onIssuesChange: jest.fn() - } + issues: jest.fn().mockResolvedValue(mockIssues), + onIssuesChange: jest.fn() }; }); }); @@ -137,7 +136,13 @@ describe("onClick bubbling", () => { describe("if there are issues", () => { beforeEach(() => { - hasIssues = true; + mockIssues = { + software: [ + { + description: "software issue 1", details: "Details 1", source: "system", severity: "warn" + } + ] + }; }); it("includes a notification mark", async () => { @@ -149,7 +154,7 @@ describe("if there are issues", () => { describe("if there are not issues", () => { beforeEach(() => { - hasIssues = false; + mockIssues = []; }); it("does not include a notification mark", async () => { diff --git a/web/src/components/core/index.js b/web/src/components/core/index.js index d457f49889..73593f96ed 100644 --- a/web/src/components/core/index.js +++ b/web/src/components/core/index.js @@ -30,6 +30,7 @@ export { default as FormLabel } from "./FormLabel"; export { default as FormValidationError } from "./FormValidationError"; export { default as Fieldset } from "./Fieldset"; export { default as Em } from "./Em"; +export { default as EmailInput } from "./EmailInput"; export { default as If } from "./If"; export { default as Installation } from "./Installation"; export { default as InstallationFinished } from "./InstallationFinished"; diff --git a/web/src/components/layout/Icon.jsx b/web/src/components/layout/Icon.jsx index f3866cccf3..42c32748af 100644 --- a/web/src/components/layout/Icon.jsx +++ b/web/src/components/layout/Icon.jsx @@ -44,6 +44,7 @@ import HomeStorage from "@icons/home_storage.svg?component"; import Info from "@icons/info.svg?component"; import Inventory from "@icons/inventory_2.svg?component"; import Lan from "@icons/lan.svg?component"; +import ListAlt from "@icons/list_alt.svg?component"; import Lock from "@icons/lock.svg?component"; import ManageAccounts from "@icons/manage_accounts.svg?component"; import Menu from "@icons/menu.svg?component"; @@ -97,6 +98,7 @@ const icons = { inventory_2: Inventory, lan: Lan, loading: Loading, + list_alt: ListAlt, lock: Lock, manage_accounts: ManageAccounts, menu: Menu, diff --git a/web/src/components/overview/L10nSection.jsx b/web/src/components/overview/L10nSection.jsx index ce8c47c111..1429b7f1a8 100644 --- a/web/src/components/overview/L10nSection.jsx +++ b/web/src/components/overview/L10nSection.jsx @@ -61,7 +61,7 @@ export default function L10nSection({ showErrors }) { const SectionContent = () => { const { busy, languages, language } = state; - if (busy) return ; + if (busy) return ; const selected = languages.find(lang => lang.id === language); diff --git a/web/src/components/overview/Overview.jsx b/web/src/components/overview/Overview.jsx index ed13813746..eebce05b48 100644 --- a/web/src/components/overview/Overview.jsx +++ b/web/src/components/overview/Overview.jsx @@ -20,20 +20,21 @@ */ import React, { useState } from "react"; -import { useSoftware } from "~/context/software"; +import { useProduct } from "~/context/product"; import { Navigate } from "react-router-dom"; - import { Page, InstallButton } from "~/components/core"; import { L10nSection, NetworkSection, + ProductSection, SoftwareSection, StorageSection, UsersSection } from "~/components/overview"; +import { _ } from "~/i18n"; function Overview() { - const { selectedProduct } = useSoftware(); + const { selectedProduct } = useProduct(); const [showErrors, setShowErrors] = useState(false); if (selectedProduct === null) { @@ -42,10 +43,12 @@ function Overview() { return ( setShowErrors(true)} />} > + diff --git a/web/src/components/overview/Overview.test.jsx b/web/src/components/overview/Overview.test.jsx index 05c12e0f9e..dcb0733bd5 100644 --- a/web/src/components/overview/Overview.test.jsx +++ b/web/src/components/overview/Overview.test.jsx @@ -34,9 +34,9 @@ const startInstallationFn = jest.fn(); jest.mock("~/client"); -jest.mock("~/context/software", () => ({ - ...jest.requireActual("~/context/software"), - useSoftware: () => { +jest.mock("~/context/product", () => ({ + ...jest.requireActual("~/context/product"), + useProduct: () => { return { products: mockProducts, selectedProduct: mockProduct @@ -44,6 +44,7 @@ jest.mock("~/context/software", () => ({ } })); +jest.mock("~/components/overview/ProductSection", () => () =>
Product Section
); jest.mock("~/components/overview/L10nSection", () => () =>
Localization Section
); jest.mock("~/components/overview/StorageSection", () => () =>
Storage Section
); jest.mock("~/components/overview/NetworkSection", () => () =>
Network Section
); @@ -68,9 +69,10 @@ beforeEach(() => { describe("when product is selected", () => { it("renders the Overview and the Install button", async () => { installerRender(); - const title = screen.getByText(/openSUSE Tumbleweed/i); + const title = screen.getByText(/installation summary/i); expect(title).toBeInTheDocument(); + await screen.findByText("Product Section"); await screen.findByText("Localization Section"); await screen.findByText("Network Section"); await screen.findByText("Storage Section"); diff --git a/web/src/components/overview/ProductSection.jsx b/web/src/components/overview/ProductSection.jsx new file mode 100644 index 0000000000..83121fab21 --- /dev/null +++ b/web/src/components/overview/ProductSection.jsx @@ -0,0 +1,80 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useEffect, useState } from "react"; +import { Text } from "@patternfly/react-core"; +import { sprintf } from "sprintf-js"; + +import { toValidationError, useCancellablePromise } from "~/utils"; +import { useInstallerClient } from "~/context/installer"; +import { useProduct } from "~/context/product"; +import { Section, SectionSkeleton } from "~/components/core"; +import { _ } from "~/i18n"; + +const errorsFrom = (issues) => { + const errors = issues.filter(i => i.severity === "error"); + return errors.map(toValidationError); +}; + +const Content = ({ isLoading = false }) => { + const { registration, selectedProduct } = useProduct(); + + if (isLoading) return ; + + const isRegistered = registration?.code !== null; + const productName = selectedProduct?.name; + + return ( + + {/* TRANSLATORS: %s is replaced by a product name (e.g., SUSE ALP-Dolomite) */} + {isRegistered ? sprintf(_("%s (registered)"), productName) : productName} + + ); +}; + +export default function ProductSection() { + const { software } = useInstallerClient(); + const [issues, setIssues] = useState([]); + const { selectedProduct } = useProduct(); + const { cancellablePromise } = useCancellablePromise(); + + useEffect(() => { + cancellablePromise(software.product.getIssues()).then(setIssues); + return software.product.onIssuesChange(setIssues); + }, [cancellablePromise, setIssues, software]); + + const isLoading = !selectedProduct; + const errors = isLoading ? [] : errorsFrom(issues); + + return ( +
+ +
+ ); +} diff --git a/web/src/components/overview/ProductSection.test.jsx b/web/src/components/overview/ProductSection.test.jsx new file mode 100644 index 0000000000..17edd0b62f --- /dev/null +++ b/web/src/components/overview/ProductSection.test.jsx @@ -0,0 +1,106 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen, waitFor } from "@testing-library/react"; +import { installerRender } from "~/test-utils"; +import { createClient } from "~/client"; +import { ProductSection } from "~/components/overview"; + +let mockRegistration; +let mockSelectedProduct; + +const mockIssue = { severity: "error", description: "Fake issue" }; + +jest.mock("~/client"); + +jest.mock("~/components/core/SectionSkeleton", () => () =>
Loading
); + +jest.mock("~/context/product", () => ({ + ...jest.requireActual("~/context/product"), + useProduct: () => ({ + registration: mockRegistration, + selectedProduct: mockSelectedProduct + }) +})); + +beforeEach(() => { + const issues = [mockIssue]; + mockRegistration = {}; + mockSelectedProduct = { name: "Test Product" }; + + createClient.mockImplementation(() => { + return { + software: { + product: { + getIssues: jest.fn().mockResolvedValue(issues), + onIssuesChange: jest.fn() + } + } + }; + }); +}); + +it("shows the product name", async () => { + installerRender(); + + await screen.findByText(/Test Product/); + await waitFor(() => expect(screen.queryByText("registered")).not.toBeInTheDocument()); +}); + +it("indicates whether the product is registered", async () => { + mockRegistration = { code: "111222" }; + installerRender(); + + await screen.findByText(/Test Product \(registered\)/); +}); + +it("shows the error", async () => { + installerRender(); + + await screen.findByText("Fake issue"); +}); + +it("does not show warnings", async () => { + mockIssue.severity = "warning"; + + installerRender(); + + await waitFor(() => expect(screen.queryByText("Fake issue")).not.toBeInTheDocument()); +}); + +describe("when no product is selected", () => { + beforeEach(() => { + mockSelectedProduct = undefined; + }); + + it("shows the skeleton", async () => { + installerRender(); + + await screen.findByText("Loading"); + }); + + it("does not show errors", async () => { + installerRender(); + + await waitFor(() => expect(screen.queryByText("Fake issue")).not.toBeInTheDocument()); + }); +}); diff --git a/web/src/components/overview/SoftwareSection.jsx b/web/src/components/overview/SoftwareSection.jsx index 2dd432489b..fbcd81cee8 100644 --- a/web/src/components/overview/SoftwareSection.jsx +++ b/web/src/components/overview/SoftwareSection.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -20,13 +20,13 @@ */ import React, { useReducer, useEffect } from "react"; +import { BUSY } from "~/client/status"; import { Button } from "@patternfly/react-core"; -import { ProgressText, Section } from "~/components/core"; import { Icon } from "~/components/layout"; +import { ProgressText, Section } from "~/components/core"; +import { toValidationError, useCancellablePromise } from "~/utils"; import { UsedSize } from "~/components/software"; -import { useCancellablePromise } from "~/utils"; import { useInstallerClient } from "~/context/installer"; -import { BUSY } from "~/client/status"; import { _ } from "~/i18n"; const initialState = { @@ -78,13 +78,9 @@ export default function SoftwareSection({ showErrors }) { return client.onStatusChange(updateStatus); }, [client, cancellablePromise]); - useEffect(() => { - cancellablePromise(client.getStatus()).then(updateStatus); - }, [client, cancellablePromise]); - useEffect(() => { const updateProposal = async () => { - const errors = await cancellablePromise(client.getValidationErrors()); + const errors = await cancellablePromise(client.getIssues()); const size = await cancellablePromise(client.getUsedSpace()); dispatch({ type: "UPDATE_PROPOSAL", payload: { errors, size } }); @@ -145,7 +141,7 @@ export default function SoftwareSection({ showErrors }) { title={_("Software")} icon="apps" loading={state.busy} - errors={errors} + errors={errors.map(toValidationError)} path="/software" > diff --git a/web/src/components/overview/SoftwareSection.test.jsx b/web/src/components/overview/SoftwareSection.test.jsx index 1299e09bda..d21df0e84c 100644 --- a/web/src/components/overview/SoftwareSection.test.jsx +++ b/web/src/components/overview/SoftwareSection.test.jsx @@ -31,7 +31,7 @@ jest.mock("~/client"); let getStatusFn = jest.fn().mockResolvedValue(IDLE); let getProgressFn = jest.fn().mockResolvedValue({}); -let getValidationErrorsFn = jest.fn().mockResolvedValue([]); +let getIssuesFn = jest.fn().mockResolvedValue([]); beforeEach(() => { createClient.mockImplementation(() => { @@ -39,7 +39,7 @@ beforeEach(() => { software: { getStatus: getStatusFn, getProgress: getProgressFn, - getValidationErrors: getValidationErrorsFn, + getIssues: getIssuesFn, onStatusChange: noop, onProgressChange: noop, getUsedSpace: jest.fn().mockResolvedValue("500 MB") @@ -48,7 +48,7 @@ beforeEach(() => { }); }); -describe("when there proposal is calculated", () => { +describe("when the proposal is calculated", () => { beforeEach(() => { getStatusFn = jest.fn().mockResolvedValue(IDLE); }); @@ -61,7 +61,7 @@ describe("when there proposal is calculated", () => { describe("and there are errors", () => { beforeEach(() => { - getValidationErrorsFn = jest.fn().mockResolvedValue([{ message: "Could not install..." }]); + getIssuesFn = jest.fn().mockResolvedValue([{ description: "Could not install..." }]); }); it("renders a button to refresh the repositories", async () => { diff --git a/web/src/components/overview/index.js b/web/src/components/overview/index.js index bfab42832b..c46f96127a 100644 --- a/web/src/components/overview/index.js +++ b/web/src/components/overview/index.js @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -22,6 +22,7 @@ export { default as Overview } from "./Overview"; export { default as L10nSection } from "./L10nSection"; export { default as NetworkSection } from "./NetworkSection"; +export { default as ProductSection } from "./ProductSection"; export { default as SoftwareSection } from "./SoftwareSection"; export { default as StorageSection } from "./StorageSection"; export { default as UsersSection } from "./UsersSection"; diff --git a/web/src/components/product/ProductPage.jsx b/web/src/components/product/ProductPage.jsx new file mode 100644 index 0000000000..77c671f855 --- /dev/null +++ b/web/src/components/product/ProductPage.jsx @@ -0,0 +1,439 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +// cspell:ignore Deregistration + +import React, { useEffect, useState } from "react"; +import { Alert, Button, Form } from "@patternfly/react-core"; +import { sprintf } from "sprintf-js"; + +import { _ } from "~/i18n"; +import { BUSY } from "~/client/status"; +import { If, Page, Popup, Section } from "~/components/core"; +import { noop, useCancellablePromise } from "~/utils"; +import { ProductRegistrationForm, ProductSelector } from "~/components/product"; +import { useInstallerClient } from "~/context/installer"; +import { useProduct } from "~/context/product"; + +/** + * Popup for selecting a product. + * @component + * + * @param {object} props + * @param {boolean} props.isOpen + * @param {function} props.onFinish - Callback to be called when the product is correctly selected. + * @param {function} props.onCancel - Callback to be called when the product selection is canceled. + */ +const ChangeProductPopup = ({ isOpen = false, onFinish = noop, onCancel = noop }) => { + const { manager, software } = useInstallerClient(); + const { products, selectedProduct } = useProduct(); + const [newProductId, setNewProductId] = useState(selectedProduct?.id); + + const onSubmit = async (e) => { + e.preventDefault(); + + if (newProductId !== selectedProduct?.id) { + await software.product.select(newProductId); + manager.startProbing(); + } + + onFinish(); + }; + + return ( + +
+ + + + + {_("Accept")} + + + +
+ ); +}; + +/** + * Popup for registering a product. + * @component + * + * @param {object} props + * @param {boolean} props.isOpen + * @param {function} props.onFinish - Callback to be called when the product is correctly + * registered. + * @param {function} props.onCancel - Callback to be called when the product registration is + * canceled. + */ +const RegisterProductPopup = ({ + isOpen = false, + onFinish = noop, + onCancel: onCancelProp = noop +}) => { + const { software } = useInstallerClient(); + const { selectedProduct } = useProduct(); + const [isLoading, setIsLoading] = useState(false); + const [isFormValid, setIsFormValid] = useState(true); + const [error, setError] = useState(); + + const onSubmit = async ({ code, email }) => { + setIsLoading(true); + const result = await software.product.register(code, email); + setIsLoading(false); + if (result.success) { + software.probe(); + onFinish(); + } else { + setError(result.message); + } + }; + + const onCancel = () => { + setError(null); + onCancelProp(); + }; + + const isDisabled = isLoading || !isFormValid; + + return ( + + +

{error}

+ + } + /> + + + + {_("Accept")} + + + +
+ ); +}; + +/** + * Popup to deregister a product. + * @component + * + * @param {object} props + * @param {boolean} props.isOpen + * @param {function} props.onFinish - Callback to be called when the product is correctly + * deregistered. + * @param {function} props.onCancel - Callback to be called when the product de-registration is + * canceled. + */ +const DeregisterProductPopup = ({ + isOpen = false, + onFinish = noop, + onCancel: onCancelProp = noop +}) => { + const { software } = useInstallerClient(); + const { selectedProduct } = useProduct(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(); + + const onAccept = async () => { + setIsLoading(true); + const result = await software.product.deregister(); + setIsLoading(false); + if (result.success) { + software.probe(); + onFinish(); + } else { + setError(result.message); + } + }; + + const onCancel = () => { + setError(null); + onCancelProp(); + }; + + return ( + + +

{error}

+ + } + /> +

+ { + // TRANSLATORS: %s is replaced by a product name (e.g., SUSE ALP-Dolomite) + sprintf(_("Do you want to deregister %s?"), selectedProduct.name) + } +

+ + + {_("Accept")} + + + +
+ ); +}; + +/** + * Popup to show a warning when there is a registered product. + * @component + * + * @param {object} props + * @param {boolean} props.isOpen + * @param {function} props.onAccept - Callback to be called when the warning is accepted. + */ +const RegisteredWarningPopup = ({ isOpen = false, onAccept = noop }) => { + const { selectedProduct } = useProduct(); + + return ( + +

+ { + sprintf( + // TRANSLATORS: %s is replaced by a product name (e.g., SUSE ALP-Dolomite) + _("The product %s must be deregistered before selecting a new product."), + selectedProduct.name + ) + } +

+ + + {_("Accept")} + + +
+ ); +}; + +const ChangeProductButton = ({ isDisabled = false }) => { + const [isPopupOpen, setIsPopupOpen] = useState(false); + const { registration } = useProduct(); + + const openPopup = () => setIsPopupOpen(true); + const closePopup = () => setIsPopupOpen(false); + + const isRegistered = registration.code !== null; + + return ( + <> + + + } + else={ + + } + /> + + ); +}; + +/** + * Buttons for a product that is not registered yet. + * @component + * + * @param {object} props + * @param {boolean} props.isDisabled + */ +const RegisterProductButton = ({ isDisabled = false }) => { + const [isPopupOpen, setIsPopupOpen] = useState(false); + + const openPopup = () => setIsPopupOpen(true); + const closePopup = () => setIsPopupOpen(false); + + return ( + <> + + + + ); +}; + +/** + * Buttons for a product that is not registered yet. + * @component + * + * @param {object} props + * @param {boolean} props.isDisabled + */ +const DeregisterProductButton = ({ isDisabled = false }) => { + const [isPopupOpen, setIsPopupOpen] = useState(false); + + const openPopup = () => setIsPopupOpen(true); + const closePopup = () => setIsPopupOpen(false); + + return ( + <> + + + + ); +}; + +const ProductSection = ({ isLoading = false }) => { + const { products, selectedProduct } = useProduct(); + + return ( +
+

{selectedProduct?.description}

+ 1} + then={} + /> +
+ ); +}; + +const RegistrationContent = ({ isLoading = false }) => { + const { registration } = useProduct(); + + const mask = (v) => v.replace(v.slice(0, -4), "*".repeat(Math.max(v.length - 4, 0))); + + return ( + <> +
+ {_("Code:")} + {mask(registration.code)} +
+
+ {_("Email:")} + {registration.email} +
+ + + ); +}; + +const RegistrationSection = ({ isLoading = false }) => { + const { registration } = useProduct(); + + const isRequired = registration?.requirement !== "not-required"; + const isRegistered = registration?.code !== null; + + return ( + // TRANSLATORS: section title. +
+ } + else={ + <> +

{_("This product requires registration.")}

+ + + } + /> + } + else={

{_("This product does not require registration.")}

} + /> +
+ ); +}; + +/** + * Page for configuring a product. + * @component + */ +export default function ProductPage() { + const [managerStatus, setManagerStatus] = useState(); + const [softwareStatus, setSoftwareStatus] = useState(); + const { cancellablePromise } = useCancellablePromise(); + const { manager, software } = useInstallerClient(); + + useEffect(() => { + cancellablePromise(manager.getStatus()).then(setManagerStatus); + return manager.onStatusChange(setManagerStatus); + }, [cancellablePromise, manager]); + + useEffect(() => { + cancellablePromise(software.getStatus()).then(setSoftwareStatus); + return software.onStatusChange(setSoftwareStatus); + }, [cancellablePromise, software]); + + const isLoading = managerStatus === BUSY || softwareStatus === BUSY; + + return ( + + + + + ); +} diff --git a/web/src/components/product/ProductPage.test.jsx b/web/src/components/product/ProductPage.test.jsx new file mode 100644 index 0000000000..af2531340a --- /dev/null +++ b/web/src/components/product/ProductPage.test.jsx @@ -0,0 +1,388 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen, within } from "@testing-library/react"; + +import { BUSY } from "~/client/status"; +import { installerRender } from "~/test-utils"; +import { ProductPage } from "~/components/product"; +import { createClient } from "~/client"; + +let mockManager; +let mockSoftware; +let mockProducts; +let mockRegistration; + +const products = [ + { + id: "Test-Product1", + name: "Test Product1", + description: "Test Product1 description" + }, + { + id: "Test-Product2", + name: "Test Product2", + description: "Test Product2 description" + } +]; + +const selectedProduct = { + id: "Test-Product1", + name: "Test Product1", + description: "Test Product1 description" +}; + +jest.mock("~/client"); + +jest.mock("~/context/product", () => ({ + ...jest.requireActual("~/context/product"), + useProduct: () => ({ products: mockProducts, selectedProduct, registration: mockRegistration }) +})); + +beforeEach(() => { + mockManager = { + startProbing: jest.fn(), + getStatus: jest.fn().mockResolvedValue(), + onStatusChange: jest.fn() + }; + + mockSoftware = { + probe: jest.fn(), + getStatus: jest.fn().mockResolvedValue(), + onStatusChange: jest.fn(), + product: { + getSelected: selectedProduct.id, + select: jest.fn().mockResolvedValue(), + onChange: jest.fn() + } + }; + + mockProducts = products; + + mockRegistration = { + requirement: "not-required", + code: null, + email: null + }; + + createClient.mockImplementation(() => ( + { + manager: mockManager, + software: mockSoftware + } + )); +}); + +it("renders the product name and description", async () => { + installerRender(); + await screen.findByText("Test Product1"); + await screen.findByText("Test Product1 description"); +}); + +it("shows a button to change the product", async () => { + installerRender(); + await screen.findByRole("button", { name: "Change product" }); +}); + +describe("if there is only a product", () => { + beforeEach(() => { + mockProducts = [products[0]]; + }); + + it("does not show a button to change the product", async () => { + installerRender(); + expect(screen.queryByRole("button", { name: "Change product" })).not.toBeInTheDocument(); + }); +}); + +describe("if the product is already registered", () => { + beforeEach(() => { + mockRegistration = { + requirement: "mandatory", + code: "111222", + email: "test@test.com" + }; + }); + + it("shows the information about the registration", async () => { + installerRender(); + await screen.findByText("**1222"); + await screen.findByText("test@test.com"); + }); +}); + +describe("if the product does not require registration", () => { + beforeEach(() => { + mockRegistration.requirement = "not-required"; + }); + + it("does not show a button to register the product", async () => { + installerRender(); + expect(screen.queryByRole("button", { name: "Register" })).not.toBeInTheDocument(); + }); +}); + +describe("if the product requires registration", () => { + beforeEach(() => { + mockRegistration.requirement = "required"; + }); + + describe("and the product is not registered yet", () => { + beforeEach(() => { + mockRegistration.code = null; + }); + + it("shows a button to register the product", async () => { + installerRender(); + await screen.findByRole("button", { name: "Register" }); + }); + }); + + describe("and the product is already registered", () => { + beforeEach(() => { + mockRegistration.code = "11112222"; + }); + + it("shows a button to deregister the product", async () => { + installerRender(); + await screen.findByRole("button", { name: "Deregister product" }); + }); + }); +}); + +describe("when the services are busy", () => { + beforeEach(() => { + mockRegistration.requirement = "required"; + mockRegistration.code = null; + mockSoftware.getStatus = jest.fn().mockResolvedValue(BUSY); + }); + + it("shows disabled buttons", async () => { + installerRender(); + + const selectButton = await screen.findByRole("button", { name: "Change product" }); + const registerButton = screen.getByRole("button", { name: "Register" }); + + expect(selectButton).toHaveAttribute("disabled"); + expect(registerButton).toHaveAttribute("disabled"); + }); +}); + +describe("when the button for changing the product is clicked", () => { + describe("and the product is not registered", () => { + beforeEach(() => { + mockRegistration.code = null; + }); + + it("opens a popup for selecting a new product", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Change product" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + within(popup).getByText("Choose a product"); + within(popup).getByRole("radio", { name: "Test Product1" }); + const radio = within(popup).getByRole("radio", { name: "Test Product2" }); + + await user.click(radio); + const accept = within(popup).getByRole("button", { name: "Accept" }); + await user.click(accept); + + expect(mockSoftware.product.select).toHaveBeenCalledWith("Test-Product2"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + describe("if the popup is canceled", () => { + it("closes the popup without selecting a new product", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Change product" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + const radio = within(popup).getByRole("radio", { name: "Test Product2" }); + + await user.click(radio); + const cancel = within(popup).getByRole("button", { name: "Cancel" }); + await user.click(cancel); + + expect(mockSoftware.product.select).not.toHaveBeenCalled(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + }); + + describe("and the product is registered", () => { + beforeEach(() => { + mockRegistration.requirement = "mandatory"; + mockRegistration.code = "111222"; + }); + + it("shows a warning", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Change product" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + within(popup).getByText(/must be deregistered/); + + const accept = within(popup).getByRole("button", { name: "Accept" }); + await user.click(accept); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); +}); + +describe("when the button for registering the product is clicked", () => { + beforeEach(() => { + mockRegistration.requirement = "mandatory"; + mockRegistration.code = null; + mockSoftware.product.register = jest.fn().mockResolvedValue({ success: true }); + }); + + it("opens a popup for registering the product", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Register" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + within(popup).getByText("Register Test Product1"); + const codeInput = within(popup).getByLabelText(/Registration code/); + const emailInput = within(popup).getByLabelText("Email"); + + await user.type(codeInput, "111222"); + await user.type(emailInput, "test@test.com"); + const accept = within(popup).getByRole("button", { name: "Accept" }); + await user.click(accept); + + expect(mockSoftware.product.register).toHaveBeenCalledWith("111222", "test@test.com"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + describe("if the popup is canceled", () => { + it("closes the popup without registering the product", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Register" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + const cancel = within(popup).getByRole("button", { name: "Cancel" }); + await user.click(cancel); + + expect(mockSoftware.product.register).not.toHaveBeenCalled(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + + describe("if there is an error registering the product", () => { + beforeEach(() => { + mockSoftware.product.register = jest.fn().mockResolvedValue({ + success: false, + message: "Error registering product" + }); + }); + + it("does not close the popup and shows the error", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Register" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + within(popup).getByText("Register Test Product1"); + const codeInput = within(popup).getByLabelText(/Registration code/); + + await user.type(codeInput, "111222"); + const accept = within(popup).getByRole("button", { name: "Accept" }); + await user.click(accept); + + within(popup).getByText("Error registering product"); + }); + }); +}); + +describe("when the button to perform product de-registration is clicked", () => { + beforeEach(() => { + mockRegistration.requirement = "mandatory"; + mockRegistration.code = "111222"; + mockSoftware.product.deregister = jest.fn().mockResolvedValue({ success: true }); + }); + + it("opens a popup to deregister the product", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Deregister product" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + within(popup).getByText("Deregister Test Product1"); + + const accept = within(popup).getByRole("button", { name: "Accept" }); + await user.click(accept); + + expect(mockSoftware.product.deregister).toHaveBeenCalled(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + describe("if the popup is canceled", () => { + it("closes the popup without performing product de-registration", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Deregister product" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + const cancel = within(popup).getByRole("button", { name: "Cancel" }); + await user.click(cancel); + + expect(mockSoftware.product.deregister).not.toHaveBeenCalled(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + + describe("if there is an error performing the product de-registration", () => { + beforeEach(() => { + mockSoftware.product.deregister = jest.fn().mockResolvedValue({ + success: false, + message: "Product cannot be deregistered" + }); + }); + + it("does not close the popup and shows the error", async () => { + const { user } = installerRender(); + + const button = screen.getByRole("button", { name: "Deregister product" }); + await user.click(button); + + const popup = await screen.findByRole("dialog"); + const accept = within(popup).getByRole("button", { name: "Accept" }); + await user.click(accept); + + within(popup).getByText("Product cannot be deregistered"); + }); + }); +}); diff --git a/web/src/components/product/ProductRegistrationForm.jsx b/web/src/components/product/ProductRegistrationForm.jsx new file mode 100644 index 0000000000..510f0c6c20 --- /dev/null +++ b/web/src/components/product/ProductRegistrationForm.jsx @@ -0,0 +1,76 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useEffect, useState } from "react"; +import { Form, FormGroup } from "@patternfly/react-core"; + +import { _ } from "~/i18n"; +import { EmailInput, PasswordInput } from "~/components/core"; +import { noop } from "~/utils"; + +/** + * Form for registering a product. + * @component + * + * @param {object} props + * @param {boolean} props.id - Form id. + * @param {function} props.onSubmit - Callback to be called when the form is submitted. + * @param {(isValid: boolean) => void} props.onValidate - Callback to be called when the form is + * validated. + */ +export default function ProductRegistrationForm({ + id, + onSubmit: onSubmitProp = noop, + onValidate = noop +}) { + const [code, setCode] = useState(""); + const [email, setEmail] = useState(""); + const [isValidEmail, setIsValidEmail] = useState(true); + + const onSubmit = (e) => { + e.preventDefault(); + onSubmitProp({ code, email }); + }; + + useEffect(() => { + const validate = () => { + return code.length > 0 && isValidEmail; + }; + + onValidate(validate()); + }, [code, isValidEmail, onValidate]); + + return ( +
+ + setCode(v)} /> + + + setEmail(v)} + /> + +
+ ); +} diff --git a/web/src/components/product/ProductRegistrationForm.test.jsx b/web/src/components/product/ProductRegistrationForm.test.jsx new file mode 100644 index 0000000000..14a9d18c4a --- /dev/null +++ b/web/src/components/product/ProductRegistrationForm.test.jsx @@ -0,0 +1,93 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useState } from "react"; +import { Button } from "@patternfly/react-core"; +import { screen } from "@testing-library/react"; +import { plainRender } from "~/test-utils"; +import { ProductRegistrationForm } from "~/components/product"; + +it("renders a field for entering the registration code", async() => { + plainRender(); + await screen.findByLabelText(/Registration code/); +}); + +it("renders a field for entering an email", async() => { + plainRender(); + await screen.findByLabelText("Email"); +}); + +const ProductRegistrationFormTest = () => { + const [isSubmitted, setIsSubmitted] = useState(false); + const [isValid, setIsValid] = useState(true); + + return ( + <> + + + {isSubmitted &&

Form is submitted!

} + {isValid === false &&

Form is not valid!

} + + ); +}; + +it("triggers the onSubmit callback", async () => { + const { user } = plainRender(); + + expect(screen.queryByText("Form is submitted!")).toBeNull(); + + const button = screen.getByRole("button", { name: "Accept" }); + await user.click(button); + await screen.findByText("Form is submitted!"); +}); + +it("sets the form as invalid if there is no code", async () => { + plainRender(); + await screen.findByText("Form is not valid!"); +}); + +it("sets the form as invalid if there is a code and a wrong email", async () => { + const { user } = plainRender(); + const codeInput = await screen.findByLabelText(/Registration code/); + const emailInput = await screen.findByLabelText("Email"); + await user.type(codeInput, "111222"); + await user.type(emailInput, "foo"); + + await screen.findByText("Form is not valid!"); +}); + +it("does not set the form as invalid if there is a code and no email", async () => { + const { user } = plainRender(); + const codeInput = await screen.findByLabelText(/Registration code/); + await user.type(codeInput, "111222"); + + expect(screen.queryByText("Form is not valid!")).toBeNull(); +}); + +it("does not set the form as invalid if there is a code and a correct email", async () => { + const { user } = plainRender(); + const codeInput = await screen.findByLabelText(/Registration code/); + const emailInput = await screen.findByLabelText("Email"); + await user.type(codeInput, "111222"); + await user.type(emailInput, "test@test.com"); + + expect(screen.queryByText("Form is not valid!")).toBeNull(); +}); diff --git a/web/src/components/software/ProductSelectionPage.jsx b/web/src/components/product/ProductSelectionPage.jsx similarity index 58% rename from web/src/components/software/ProductSelectionPage.jsx rename to web/src/components/product/ProductSelectionPage.jsx index 86b8d622ef..585747a9db 100644 --- a/web/src/components/software/ProductSelectionPage.jsx +++ b/web/src/components/product/ProductSelectionPage.jsx @@ -21,47 +21,36 @@ import React, { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { useInstallerClient } from "~/context/installer"; -import { useSoftware } from "~/context/software"; -import { _ } from "~/i18n"; - -import { - Button, - Card, - CardBody, - Form, - FormGroup, - Radio -} from "@patternfly/react-core"; +import { Button, Form, FormGroup } from "@patternfly/react-core"; +import { _ } from "~/i18n"; import { Icon, Loading } from "~/components/layout"; +import { ProductSelector } from "~/components/product"; import { Title, PageIcon, MainActions } from "~/components/layout/Layout"; +import { useInstallerClient } from "~/context/installer"; +import { useProduct } from "~/context/product"; function ProductSelectionPage() { - const client = useInstallerClient(); + const { manager, software } = useInstallerClient(); const navigate = useNavigate(); - const { products, selectedProduct } = useSoftware(); - const previous = selectedProduct?.id; - const [selected, setSelected] = useState(selectedProduct?.id); + const { products, selectedProduct } = useProduct(); + const [newProductId, setNewProductId] = useState(selectedProduct?.id); useEffect(() => { // TODO: display a notification in the UI to emphasizes that // selected product has changed - return client.software.onProductChange(() => navigate("/")); - }, [client.software, navigate]); - - const isSelected = p => p.id === selected; + return software.product.onChange(() => navigate("/")); + }, [software, navigate]); - const accept = async (e) => { + const onSubmit = async (e) => { e.preventDefault(); - if (selected === previous) { - navigate("/"); - return; + + if (newProductId !== selectedProduct?.id) { + // TODO: handle errors + await software.product.select(newProductId); + manager.startProbing(); } - // TODO: handle errors - await client.software.selectProduct(selected); - client.manager.startProbing(); navigate("/"); }; @@ -69,40 +58,20 @@ function ProductSelectionPage() { ); - const buildOptions = () => { - const options = products.map((p) => ( - - - setSelected(p.id)} - /> - - - )); - - return options; - }; - return ( <> {/* TRANSLATORS: page header */} {_("Product selection")} - - -
+ - {buildOptions()} +
diff --git a/web/src/components/software/ProductSelectionPage.test.jsx b/web/src/components/product/ProductSelectionPage.test.jsx similarity index 83% rename from web/src/components/software/ProductSelectionPage.test.jsx rename to web/src/components/product/ProductSelectionPage.test.jsx index 197e33cb27..89a943a16e 100644 --- a/web/src/components/software/ProductSelectionPage.test.jsx +++ b/web/src/components/product/ProductSelectionPage.test.jsx @@ -22,7 +22,7 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender, mockNavigateFn } from "~/test-utils"; -import { ProductSelectionPage } from "~/components/software"; +import { ProductSelectionPage } from "~/components/product"; import { createClient } from "~/client"; const products = [ @@ -39,9 +39,9 @@ const products = [ ]; jest.mock("~/client"); -jest.mock("~/context/software", () => ({ - ...jest.requireActual("~/context/software"), - useSoftware: () => { +jest.mock("~/context/product", () => ({ + ...jest.requireActual("~/context/product"), + useProduct: () => { return { products, selectedProduct: products[0] @@ -54,10 +54,12 @@ const managerMock = { }; const softwareMock = { - getProducts: () => Promise.resolve(products), - getSelectedProduct: jest.fn(() => Promise.resolve(products[0])), - selectProduct: jest.fn().mockResolvedValue(), - onProductChange: jest.fn() + product: { + getAll: () => Promise.resolve(products), + getSelected: jest.fn(() => Promise.resolve(products[0])), + select: jest.fn().mockResolvedValue(), + onChange: jest.fn() + } }; beforeEach(() => { @@ -76,7 +78,7 @@ describe("when the user chooses a product", () => { await user.click(radio); const button = await screen.findByRole("button", { name: "Select" }); await user.click(button); - expect(softwareMock.selectProduct).toHaveBeenCalledWith("MicroOS"); + expect(softwareMock.product.select).toHaveBeenCalledWith("MicroOS"); expect(managerMock.startProbing).toHaveBeenCalled(); expect(mockNavigateFn).toHaveBeenCalledWith("/"); }); @@ -88,7 +90,7 @@ describe("when the user chooses does not change the product", () => { await screen.findByText("openSUSE Tumbleweed"); const button = await screen.findByRole("button", { name: "Select" }); await user.click(button); - expect(softwareMock.selectProduct).not.toHaveBeenCalled(); + expect(softwareMock.product.select).not.toHaveBeenCalled(); expect(managerMock.startProbing).not.toHaveBeenCalled(); expect(mockNavigateFn).toHaveBeenCalledWith("/"); }); diff --git a/web/src/components/product/ProductSelector.jsx b/web/src/components/product/ProductSelector.jsx new file mode 100644 index 0000000000..311a6a7e89 --- /dev/null +++ b/web/src/components/product/ProductSelector.jsx @@ -0,0 +1,49 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { Card, CardBody, Radio } from "@patternfly/react-core"; + +import { _ } from "~/i18n"; +import { noop } from "~/utils"; + +export default function ProductSelector({ value, products = [], onChange = noop }) { + if (products.length === 0) return

{_("No products available for selection")}

; + + const isSelected = (product) => product.id === value; + + return ( + products.map((p) => ( + + + onChange(p.id)} + /> + + + )) + ); +} diff --git a/web/src/components/product/ProductSelector.test.jsx b/web/src/components/product/ProductSelector.test.jsx new file mode 100644 index 0000000000..b40af415a0 --- /dev/null +++ b/web/src/components/product/ProductSelector.test.jsx @@ -0,0 +1,75 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React from "react"; +import { screen } from "@testing-library/react"; +import { installerRender } from "~/test-utils"; +import { ProductSelector } from "~/components/product"; +import { createClient } from "~/client"; + +jest.mock("~/client"); + +const products = [ + { + id: "ALP-Dolomite", + name: "ALP Dolomite", + description: "ALP Dolomite description" + }, + { + id: "Tumbleweed", + name: "openSUSE Tumbleweed", + description: "Tumbleweed description..." + }, + { + id: "MicroOS", + name: "openSUSE MicroOS", + description: "MicroOS description" + } +]; + +beforeEach(() => { + createClient.mockImplementation(() => ({})); +}); + +it("shows an option for each product", async () => { + installerRender(); + await screen.findByRole("radio", { name: "ALP Dolomite" }); + await screen.findByRole("radio", { name: "openSUSE Tumbleweed" }); + await screen.findByRole("radio", { name: "openSUSE MicroOS" }); +}); + +it("selects the given value", async () => { + installerRender(); + await screen.findByRole("radio", { name: "openSUSE Tumbleweed", clicked: true }); +}); + +it("calls onChange if a new option is clicked", async () => { + const onChangeFn = jest.fn(); + const { user } = installerRender(); + const radio = await screen.findByRole("radio", { name: "openSUSE Tumbleweed" }); + await user.click(radio); + expect(onChangeFn).toHaveBeenCalledWith("Tumbleweed"); +}); + +it("shows a message if there is no product for selection", async () => { + installerRender(); + await screen.findByText(/no products available/i); +}); diff --git a/web/src/components/software/ChangeProductLink.jsx b/web/src/components/product/index.js similarity index 60% rename from web/src/components/software/ChangeProductLink.jsx rename to web/src/components/product/index.js index 8dc6f07001..be115a18c8 100644 --- a/web/src/components/software/ChangeProductLink.jsx +++ b/web/src/components/product/index.js @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2023] SUSE LLC * * All Rights Reserved. * @@ -19,21 +19,7 @@ * find current contact information at www.suse.com. */ -import React from "react"; -import { Link } from "react-router-dom"; -import { useSoftware } from "~/context/software"; -import { Icon } from "~/components/layout"; -import { _ } from "~/i18n"; - -export default function ChangeProductLink() { - const { products } = useSoftware(); - - if (products?.length === 1) return null; - - return ( - - - {_("Change product")} - - ); -} +export { default as ProductPage } from "./ProductPage"; +export { default as ProductRegistrationForm } from "./ProductRegistrationForm"; +export { default as ProductSelectionPage } from "./ProductSelectionPage"; +export { default as ProductSelector } from "./ProductSelector"; diff --git a/web/src/components/software/ChangeProductLink.test.jsx b/web/src/components/software/ChangeProductLink.test.jsx deleted file mode 100644 index c89a62222a..0000000000 --- a/web/src/components/software/ChangeProductLink.test.jsx +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright (c) [2022-2023] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React from "react"; -import { screen, waitFor } from "@testing-library/react"; -import { installerRender } from "~/test-utils"; -import { createClient } from "~/client"; -import { ChangeProductLink } from "~/components/software"; - -let mockProducts; - -jest.mock("~/client"); -jest.mock("~/context/software", () => ({ - ...jest.requireActual("~/context/software"), - useSoftware: () => { - return { - products: mockProducts, - }; - } -})); - -beforeEach(() => { - createClient.mockImplementation(() => { - return { - software: { - onProductChange: jest.fn() - }, - }; - }); -}); - -describe("ChangeProductLink", () => { - describe("when there is only a single product", () => { - beforeEach(() => { - mockProducts = [ - { id: "openSUSE", name: "openSUSE Tumbleweed" } - ]; - }); - - it("renders nothing", async () => { - installerRender(); - - const main = await screen.findByRole("main"); - await waitFor(() => expect(main).toBeEmptyDOMElement()); - }); - }); - - describe("when there is more than one product", () => { - beforeEach(() => { - mockProducts = [ - { id: "openSUSE", name: "openSUSE Tumbleweed" }, - { id: "Leap Micro", name: "openSUSE Micro" } - ]; - }); - - it("renders a link for navigating to the selection product page", async () => { - installerRender(); - const link = await screen.findByRole("link", { name: "Change product" }); - - expect(link).toHaveAttribute("href", "/products"); - }); - }); -}); diff --git a/web/src/components/software/PatternSelector.jsx b/web/src/components/software/PatternSelector.jsx index d4886c5cd0..30b550a947 100644 --- a/web/src/components/software/PatternSelector.jsx +++ b/web/src/components/software/PatternSelector.jsx @@ -27,6 +27,7 @@ import { useInstallerClient } from "~/context/installer"; import { Section, ValidationErrors } from "~/components/core"; import PatternGroup from "./PatternGroup"; import PatternItem from "./PatternItem"; +import { toValidationError } from "~/utils"; import UsedSize from "./UsedSize"; import { _ } from "~/i18n"; @@ -153,7 +154,7 @@ function PatternSelector() { const refresh = async () => { setSelected(await client.software.selectedPatterns()); setUsed(await client.software.getUsedSpace()); - setErrors(await client.software.getValidationErrors()); + setErrors(await client.software.getIssues()); }; refresh(); @@ -166,7 +167,7 @@ function PatternSelector() { const loadData = async () => { setSelected(await client.software.selectedPatterns()); setUsed(await client.software.getUsedSpace()); - setErrors(await client.software.getValidationErrors()); + setErrors(await client.software.getIssues()); setPatterns(await client.software.patterns(true)); }; @@ -205,11 +206,15 @@ function PatternSelector() { // if there is just a single error then the error is displayed directly instead of this summary const errorLabel = sprintf(_("%d errors"), errors.length); + // FIXME: ValidationErrors should be replaced by an equivalent component to show issues. + // Note that only the Users client uses the old Validation D-Bus interface. + const validationErrors = errors.map(toValidationError); + return ( <>
- + { @@ -40,7 +40,7 @@ beforeEach(() => { software: { selectedPatterns: selectedPatternsFn, getUsedSpace: getUsedSpaceFn, - getValidationErrors: getValidationErrorsFn, + getIssues: getIssuesFn, patterns: patternsFn }, }; diff --git a/web/src/components/software/index.js b/web/src/components/software/index.js index b2c6fef467..af42a2eb9d 100644 --- a/web/src/components/software/index.js +++ b/web/src/components/software/index.js @@ -19,8 +19,6 @@ * find current contact information at www.suse.com. */ -export { default as ProductSelectionPage } from "./ProductSelectionPage"; -export { default as ChangeProductLink } from "./ChangeProductLink"; export { default as PatternSelector } from "./PatternSelector"; export { default as UsedSize } from "./UsedSize"; export { default as SoftwarePage } from "./SoftwarePage"; diff --git a/web/src/context/agama.jsx b/web/src/context/agama.jsx index 6306c1ba8c..0aa16e811b 100644 --- a/web/src/context/agama.jsx +++ b/web/src/context/agama.jsx @@ -24,7 +24,7 @@ import React from "react"; import { InstallerClientProvider } from "./installer"; import { L10nProvider } from "./l10n"; -import { SoftwareProvider } from "./software"; +import { ProductProvider } from "./product"; import { NotificationProvider } from "./notification"; /** @@ -37,11 +37,11 @@ function AgamaProviders({ children }) { return ( - + {children} - + ); diff --git a/web/src/context/notification.jsx b/web/src/context/notification.jsx index 38446dada1..d04df31486 100644 --- a/web/src/context/notification.jsx +++ b/web/src/context/notification.jsx @@ -37,7 +37,8 @@ function NotificationProvider({ children }) { const load = useCallback(async () => { if (!client) return; - const hasIssues = await cancellablePromise(client.issues.any()); + const issues = await cancellablePromise(client.issues()); + const hasIssues = Object.values(issues).flat().length > 0; update({ issues: hasIssues }); }, [client, cancellablePromise, update]); @@ -45,7 +46,7 @@ function NotificationProvider({ children }) { if (!client) return; load(); - return client.issues.onIssuesChange(load); + return client.onIssuesChange(load); }, [client, load]); const value = [state, update]; diff --git a/web/src/context/product.jsx b/web/src/context/product.jsx new file mode 100644 index 0000000000..4f476f710a --- /dev/null +++ b/web/src/context/product.jsx @@ -0,0 +1,80 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +import React, { useContext, useEffect, useState } from "react"; +import { useCancellablePromise } from "~/utils"; +import { useInstallerClient } from "./installer"; + +const ProductContext = React.createContext([]); + +function ProductProvider({ children }) { + const client = useInstallerClient(); + const { cancellablePromise } = useCancellablePromise(); + const [products, setProducts] = useState(undefined); + const [selectedId, setSelectedId] = useState(undefined); + const [registration, setRegistration] = useState(undefined); + + useEffect(() => { + const load = async () => { + const productManager = client.software.product; + const available = await cancellablePromise(productManager.getAll()); + const selected = await cancellablePromise(productManager.getSelected()); + const registration = await cancellablePromise(productManager.getRegistration()); + setProducts(available); + setSelectedId(selected?.id || null); + setRegistration(registration); + }; + + if (client) { + load().catch(console.error); + } + }, [client, setProducts, setSelectedId, setRegistration, cancellablePromise]); + + useEffect(() => { + if (!client) return; + + return client.software.product.onChange(setSelectedId); + }, [client, setSelectedId]); + + useEffect(() => { + if (!client) return; + + return client.software.product.onRegistrationChange(setRegistration); + }, [client, setRegistration]); + + const value = { products, selectedId, registration }; + return {children}; +} + +function useProduct() { + const context = useContext(ProductContext); + + if (!context) { + throw new Error("useProduct must be used within a ProductProvider"); + } + + const { products = [], selectedId } = context; + const selectedProduct = products.find(p => p.id === selectedId) || null; + + return { ...context, selectedProduct }; +} + +export { ProductProvider, useProduct }; diff --git a/web/src/context/software.jsx b/web/src/context/software.jsx deleted file mode 100644 index 8e53f91405..0000000000 --- a/web/src/context/software.jsx +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (c) [2022] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of version 2 of the GNU General Public License as published - * by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -import React, { useEffect } from "react"; -import { useCancellablePromise } from "~/utils"; -import { useInstallerClient } from "./installer"; - -const SoftwareContext = React.createContext([]); - -function SoftwareProvider({ children }) { - const client = useInstallerClient(); - const { cancellablePromise } = useCancellablePromise(); - const [products, setProducts] = React.useState(undefined); - const [selectedId, setSelectedId] = React.useState(undefined); - - useEffect(() => { - const loadProducts = async () => { - const available = await cancellablePromise(client.software.getProducts()); - const selected = await cancellablePromise(client.software.getSelectedProduct()); - setProducts(available); - setSelectedId(selected?.id || null); - }; - - if (client) { - loadProducts().catch(console.error); - } - }, [client, setProducts, setSelectedId, cancellablePromise]); - - useEffect(() => { - if (!client) return; - - return client.software.onProductChange(setSelectedId); - }, [client, setSelectedId]); - - const value = [products, selectedId]; - return {children}; -} - -function useSoftware() { - const context = React.useContext(SoftwareContext); - - if (!context) { - throw new Error("useSoftware must be used within a SoftwareProvider"); - } - - const [products, selectedId] = context; - - let selectedProduct = selectedId; - if (selectedId && products) { - selectedProduct = products.find(p => p.id === selectedId) || null; - } - - return { - products, - selectedProduct - }; -} - -export { SoftwareProvider, useSoftware }; diff --git a/web/src/index.js b/web/src/index.js index 3b7b6815f3..53d58610a3 100644 --- a/web/src/index.js +++ b/web/src/index.js @@ -37,7 +37,8 @@ import App from "~/App"; import Main from "~/Main"; import DevServerWrapper from "~/DevServerWrapper"; import { Overview } from "~/components/overview"; -import { ProductSelectionPage, SoftwarePage } from "~/components/software"; +import { ProductPage, ProductSelectionPage } from "~/components/product"; +import { SoftwarePage } from "~/components/software"; import { ProposalPage as StoragePage, ISCSIPage, DASDPage, ZFCPPage } from "~/components/storage"; import { UsersPage } from "~/components/users"; import { L10nPage } from "~/components/l10n"; @@ -76,6 +77,7 @@ root.render( }> } /> } /> + } /> } /> } /> } />