diff --git a/.config/dictionaries/project.dic b/.config/dictionaries/project.dic index 9c91a6abac4..9889668eac6 100644 --- a/.config/dictionaries/project.dic +++ b/.config/dictionaries/project.dic @@ -77,6 +77,7 @@ delegators devnet DIND dockerhub +domcontentloaded Dominik dotenv dotenvy @@ -191,6 +192,7 @@ netifas netkey nextest Nodetool +NuFi oapi OCSP Oleksandr @@ -199,6 +201,7 @@ oneshot openapi opentelemetry overprovisioned +pageobject Pbkdf2 pbxproj Pdart @@ -218,6 +221,7 @@ projectcatalyst Prokhorenko proptest psql +psycopg Ptarget pubkey PUBLICKEY @@ -277,6 +281,7 @@ tablestats tacho testcov testdocs +testid testplan testunit thiserror @@ -287,6 +292,7 @@ Toastify todos toggleable tojunit +tomjs Traceback traefik trailings diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91b7891dd3b..6d5a8720255 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,9 +14,9 @@ permissions: jobs: ci: - uses: input-output-hk/catalyst-forge/.github/workflows/ci.yml@ci/v1.2.2 + uses: input-output-hk/catalyst-forge/.github/workflows/ci.yml@ci/v1.5.0 with: - forge_version: 0.5.0 + forge_version: 0.8.0 test_reporting: if: always() diff --git a/.github/workflows/generate-allure-report.yml b/.github/workflows/generate-allure-report.yml index d4efaaabaf3..792d5c26472 100644 --- a/.github/workflows/generate-allure-report.yml +++ b/.github/workflows/generate-allure-report.yml @@ -26,16 +26,16 @@ jobs: - uses: actions/checkout@v4 - name: Install Forge - uses: input-output-hk/catalyst-forge/actions/install@ci/v1.2.2 + uses: input-output-hk/catalyst-forge/actions/install@ci/v1.5.0 with: - version: 0.5.0 + version: 0.8.0 if: always() - name: Setup CI - uses: input-output-hk/catalyst-forge/actions/setup@ci/v1.2.2 + uses: input-output-hk/catalyst-forge/actions/setup@ci/v1.5.0 - name: Get catalyst gateway unit test report - uses: input-output-hk/catalyst-forge/actions/run@ci/v1.2.2 + uses: input-output-hk/catalyst-forge/actions/run@ci/v1.5.0 if: always() continue-on-error: true with: @@ -43,7 +43,7 @@ jobs: args: ./catalyst-gateway+build - name: Get schemathesis test report - uses: input-output-hk/catalyst-forge/actions/run@ci/v1.2.2 + uses: input-output-hk/catalyst-forge/actions/run@ci/v1.5.0 if: always() continue-on-error: true with: @@ -51,7 +51,7 @@ jobs: args: ./catalyst-gateway/tests/schemathesis_tests+test-fuzzer-api - name: Get flutter unit test report - uses: input-output-hk/catalyst-forge/actions/run@ci/v1.2.2 + uses: input-output-hk/catalyst-forge/actions/run@ci/v1.5.0 if: always() continue-on-error: true with: @@ -59,7 +59,7 @@ jobs: args: ./catalyst_voices+test-unit - name: Get python api test report - uses: input-output-hk/catalyst-forge/actions/run@ci/v1.2.2 + uses: input-output-hk/catalyst-forge/actions/run@ci/v1.5.0 if: always() continue-on-error: true with: diff --git a/.github/workflows/nightly-ci.yml b/.github/workflows/nightly-ci.yml index 73e736d59e0..99f04704940 100644 --- a/.github/workflows/nightly-ci.yml +++ b/.github/workflows/nightly-ci.yml @@ -121,7 +121,7 @@ jobs: - name: Send Slack notification if: ${{ always() && steps.allure.outputs.report_url }} - uses: slackapi/slack-github-action@v1.27.0 + uses: slackapi/slack-github-action@v2.0.0 with: payload: | { diff --git a/blueprint.cue b/blueprint.cue index 03f85cddd78..99abaf8dfd7 100644 --- a/blueprint.cue +++ b/blueprint.cue @@ -8,13 +8,13 @@ global: { "^test(-.*)?$", ] registries: [ - ci.providers.aws.registry, + ci.providers.aws.ecr.registry, ] providers: { aws: { - region: "eu-central-1" - registry: "332405224602.dkr.ecr.eu-central-1.amazonaws.com" - role: "arn:aws:iam::332405224602:role/ci" + region: "eu-central-1" + ecr: registry: "332405224602.dkr.ecr.eu-central-1.amazonaws.com" + role: "arn:aws:iam::332405224602:role/ci" } docker: credentials: { @@ -49,7 +49,7 @@ global: { ] } deployment: { - registry: ci.providers.aws.registry + registry: ci.providers.aws.ecr.registry repo: { url: "https://github.com/input-output-hk/catalyst-world" ref: "master" diff --git a/catalyst-gateway/Earthfile b/catalyst-gateway/Earthfile index 1cde02848f7..bde5adb213f 100644 --- a/catalyst-gateway/Earthfile +++ b/catalyst-gateway/Earthfile @@ -124,3 +124,19 @@ check-builder-src-cache: RUN diff ../src_fingerprint.txt ../src_fingerprint_uncached.txt \ || (echo "ERROR: Source fingerprints do not match. Caching Error Detected!!" && exit 1) \ && echo "Source fingerprints match. Caching OK." + +test: + FROM +builder-src + + COPY docker-compose.yml . + + ENV EVENT_DB_URL "postgres://catalyst-event-dev:CHANGE_ME@localhost/CatalystEventDev" + + WITH DOCKER \ + --compose "./docker-compose.yml" \ + --load ./event-db+build \ + --pull alpine:3.20.3 \ + --service event-db-is-running + RUN --mount=$EARTHLY_RUST_CARGO_HOME_CACHE --mount=$EARTHLY_RUST_TARGET_CACHE \ + cargo nextest run --release --run-ignored=only signed_docs + END \ No newline at end of file diff --git a/catalyst-gateway/bin/Cargo.toml b/catalyst-gateway/bin/Cargo.toml index 4099bfb7bf0..c33d2750bd9 100644 --- a/catalyst-gateway/bin/Cargo.toml +++ b/catalyst-gateway/bin/Cargo.toml @@ -15,8 +15,9 @@ repository.workspace = true workspace = true [dependencies] -cardano-chain-follower = { version = "0.0.5", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "cardano-chain-follower-v0.0.5" } +cardano-chain-follower = { version = "0.0.6", git = "https://github.com/input-output-hk/catalyst-libs.git", tag="v0.0.10" } c509-certificate = { version = "0.0.3", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "v0.0.3" } +rbac-registration = { version = "0.0.2", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "v0.0.8" } pallas = { version = "0.30.1", git = "https://github.com/input-output-hk/catalyst-pallas.git", rev = "9b5183c8b90b90fe2cc319d986e933e9518957b3" } pallas-traverse = { version = "0.30.1", git = "https://github.com/input-output-hk/catalyst-pallas.git", rev = "9b5183c8b90b90fe2cc319d986e933e9518957b3" } @@ -42,6 +43,7 @@ tokio-postgres = { version = "0.7.12", features = [ "with-chrono-0_4", "with-serde_json-1", "with-time-0_3", + "with-uuid-1" ] } tokio = { version = "1.41.0", features = ["rt", "macros", "rt-multi-thread"] } dotenvy = "0.15.7" @@ -76,7 +78,7 @@ poem-openapi = { version = "5.1.2", features = [ "url", "chrono", ] } -uuid = { version = "1.11.0", features = ["v4", "serde"] } +uuid = { version = "1.11.0", features = ["v4", "v7", "serde"] } ulid = { version = "1.1.3", features = ["serde", "uuid"] } blake2b_simd = "1.0.2" url = "2.5.3" diff --git a/catalyst-gateway/bin/src/db/event/mod.rs b/catalyst-gateway/bin/src/db/event/mod.rs index d0914f3a888..1bc4bd1d476 100644 --- a/catalyst-gateway/bin/src/db/event/mod.rs +++ b/catalyst-gateway/bin/src/db/event/mod.rs @@ -18,10 +18,11 @@ pub(crate) mod config; pub(crate) mod error; pub(crate) mod legacy; pub(crate) mod schema_check; +pub(crate) mod signed_docs; /// Database version this crate matches. /// Must equal the last Migrations Version Number from `event-db/migrations`. -pub(crate) const DATABASE_SCHEMA_VERSION: i32 = 9; +pub(crate) const DATABASE_SCHEMA_VERSION: i32 = 2; /// Postgres Connection Manager DB Pool type SqlDbPool = Arc>>; @@ -212,7 +213,7 @@ impl EventDB { /// /// The env var "`DATABASE_URL`" can be set directly as an anv var, or in a /// `.env` file. -pub(crate) fn establish_connection() { +pub fn establish_connection() { let (url, user, pass) = Settings::event_db_settings(); // This was pre-validated and can't fail, but provide default in the impossible case it diff --git a/catalyst-gateway/bin/src/db/event/signed_docs/mod.rs b/catalyst-gateway/bin/src/db/event/signed_docs/mod.rs new file mode 100644 index 00000000000..19ae3a854b3 --- /dev/null +++ b/catalyst-gateway/bin/src/db/event/signed_docs/mod.rs @@ -0,0 +1,32 @@ +//! Signed docs queries + +#[cfg(test)] +mod tests; + +use super::EventDB; + +/// Insert sql query +const INSERT_SIGNED_DOCS: &str = include_str!("./sql/insert_signed_documents.sql"); + +/// Make an insert query into the `event-db` by adding data into the `signed_docs` table +/// +/// * IF the record primary key (id,ver) does not exist, then add the new record. Return +/// success. +/// * IF the record does exist, but all values are the same as stored, return Success. +/// * Otherwise return an error. (Can not over-write an existing record with new data). +/// +/// # Arguments: +/// - `id` is a UUID v7 +/// - `ver` is a UUID v7 +/// - `doc_type` is a UUID v4 +#[allow(dead_code)] +pub(crate) async fn insert_signed_docs( + id: &uuid::Uuid, ver: &uuid::Uuid, doc_type: &uuid::Uuid, author: &String, + metadata: &Option, payload: &Option, raw: &Vec, +) -> anyhow::Result<()> { + EventDB::modify(INSERT_SIGNED_DOCS, &[ + id, ver, doc_type, author, metadata, payload, raw, + ]) + .await?; + Ok(()) +} diff --git a/catalyst-gateway/bin/src/db/event/signed_docs/sql/insert_signed_documents.sql b/catalyst-gateway/bin/src/db/event/signed_docs/sql/insert_signed_documents.sql new file mode 100644 index 00000000000..61183ea693a --- /dev/null +++ b/catalyst-gateway/bin/src/db/event/signed_docs/sql/insert_signed_documents.sql @@ -0,0 +1,12 @@ +INSERT INTO signed_docs +( + id, + ver, + type, + author, + metadata, + payload, + raw +) +VALUES +($1, $2, $3, $4, $5, $6, $7) diff --git a/catalyst-gateway/bin/src/db/event/signed_docs/tests/mod.rs b/catalyst-gateway/bin/src/db/event/signed_docs/tests/mod.rs new file mode 100644 index 00000000000..a65c9206adc --- /dev/null +++ b/catalyst-gateway/bin/src/db/event/signed_docs/tests/mod.rs @@ -0,0 +1,50 @@ +//! Integration tests of the `signed docs` queries + +use super::*; +use crate::db::event::establish_connection; + +#[ignore = "An integration test which requires a running EventDB instance, disabled from `testunit` CI run"] +#[tokio::test] +async fn some_test() { + establish_connection(); + + let docs = [ + ( + uuid::Uuid::now_v7(), + uuid::Uuid::now_v7(), + uuid::Uuid::new_v4(), + "Alex".to_string(), + &Some(serde_json::Value::Null), + &Some(serde_json::Value::Null), + vec![1, 2, 3, 4], + ), + ( + uuid::Uuid::now_v7(), + uuid::Uuid::now_v7(), + uuid::Uuid::new_v4(), + "Steven".to_string(), + &Some(serde_json::Value::Null), + &Some(serde_json::Value::Null), + vec![5, 6, 7, 8], + ), + ( + uuid::Uuid::now_v7(), + uuid::Uuid::now_v7(), + uuid::Uuid::new_v4(), + "Sasha".to_string(), + &None, + &None, + vec![9, 10, 11, 12], + ), + ]; + + for (id, ver, doc_type, author, metadata, payload, raw) in &docs { + insert_signed_docs(id, ver, doc_type, author, metadata, payload, raw) + .await + .unwrap(); + // // try to insert the same data again + // insert_signed_docs(id, ver, doc_type, author, metadata, payload, raw) + // .await + // .unwrap(); + } +} diff --git a/catalyst-gateway/bin/src/db/index/block/mod.rs b/catalyst-gateway/bin/src/db/index/block/mod.rs index 6f62abf0ff9..2096bedc4e9 100644 --- a/catalyst-gateway/bin/src/db/index/block/mod.rs +++ b/catalyst-gateway/bin/src/db/index/block/mod.rs @@ -81,13 +81,10 @@ pub(crate) async fn index_block(block: &MultiEraBlock) -> anyhow::Result<()> { } match handle.await { Ok(join_res) => { - match join_res { - Ok(_res) => {}, // debug!(res=?res,"Query OK") - Err(error) => { - // IF a query fails, assume everything else is broken. - error!(error=%error,"Query Failed"); - result = Err(error); - }, + if let Err(error) = join_res { + // IF a query fails, assume everything else is broken. + error!(error=%error,"Query Failed"); + result = Err(error); } }, Err(error) => { diff --git a/catalyst-gateway/bin/src/db/index/block/rbac509/insert_chain_root_for_stake_address.rs b/catalyst-gateway/bin/src/db/index/block/rbac509/insert_chain_root_for_stake_address.rs index b568f6b150a..172650c51db 100644 --- a/catalyst-gateway/bin/src/db/index/block/rbac509/insert_chain_root_for_stake_address.rs +++ b/catalyst-gateway/bin/src/db/index/block/rbac509/insert_chain_root_for_stake_address.rs @@ -17,7 +17,7 @@ const INSERT_CHAIN_ROOT_FOR_STAKE_ADDRESS_QUERY: &str = #[derive(SerializeRow)] pub(super) struct Params { /// Stake Address Hash. 32 bytes. - stake_address: Vec, + stake_addr: Vec, /// Block Slot Number slot_no: num_bigint::BigInt, /// Transaction Offset inside the block. @@ -29,7 +29,7 @@ pub(super) struct Params { impl Debug for Params { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Params") - .field("stake_address", &self.stake_address) + .field("stake_addr", &self.stake_addr) .field("slot_no", &self.slot_no) .field("txn", &self.txn) .field("chain_root", &self.chain_root) @@ -39,9 +39,9 @@ impl Debug for Params { impl Params { /// Create a new record for this transaction. - pub(super) fn new(stake_address: &[u8], chain_root: &[u8], slot_no: u64, txn: i16) -> Self { + pub(super) fn new(stake_addr: &[u8], chain_root: &[u8], slot_no: u64, txn: i16) -> Self { Params { - stake_address: stake_address.to_vec(), + stake_addr: stake_addr.to_vec(), slot_no: num_bigint::BigInt::from(slot_no), txn, chain_root: chain_root.to_vec(), diff --git a/catalyst-gateway/bin/src/db/index/block/rbac509/insert_rbac509.rs b/catalyst-gateway/bin/src/db/index/block/rbac509/insert_rbac509.rs index 53afa6888af..74fad121b4f 100644 --- a/catalyst-gateway/bin/src/db/index/block/rbac509/insert_rbac509.rs +++ b/catalyst-gateway/bin/src/db/index/block/rbac509/insert_rbac509.rs @@ -2,7 +2,7 @@ use std::{fmt::Debug, sync::Arc}; -use cardano_chain_follower::Metadata::cip509::Cip509; +use rbac_registration::cardano::cip509::Cip509; use scylla::{frame::value::MaybeUnset, SerializeRow, Session}; use tracing::error; @@ -56,7 +56,7 @@ impl Params { Params { chain_root: chain_root.to_vec(), transaction_id: transaction_id.to_vec(), - purpose: cip509.purpose.to_vec(), + purpose: cip509.purpose.into(), slot_no: num_bigint::BigInt::from(slot_no), txn, prv_txn_id: if let Some(tx_id) = cip509.prv_tx_id { diff --git a/catalyst-gateway/bin/src/db/index/block/rbac509/mod.rs b/catalyst-gateway/bin/src/db/index/block/rbac509/mod.rs index 9e6b27b930a..99af25e9e6e 100644 --- a/catalyst-gateway/bin/src/db/index/block/rbac509/mod.rs +++ b/catalyst-gateway/bin/src/db/index/block/rbac509/mod.rs @@ -5,33 +5,26 @@ mod insert_chain_root_for_stake_address; mod insert_chain_root_for_txn_id; mod insert_rbac509; -use std::{ - convert::TryInto, - sync::{Arc, LazyLock}, -}; +use std::sync::{Arc, LazyLock}; use c509_certificate::{ c509::C509, extensions::{alt_name::GeneralNamesOrText, extension::ExtensionValue}, general_names::general_name::{GeneralNameTypeRegistry, GeneralNameValue}, }; -use cardano_chain_follower::{ - Metadata::{ - self, - cip509::{ - rbac::{ - certs::{C509Cert, X509DerCert}, - pub_key::SimplePublicKeyType, - role_data::{KeyReference, LocalRefInt}, - Cip509RbacMetadata, - }, - utils::extract_cip19_hash, - }, - }, - MultiEraBlock, -}; +use cardano_chain_follower::{Metadata, MultiEraBlock}; use der_parser::{asn1_rs::oid, der::parse_der_sequence, Oid}; use moka::{policy::EvictionPolicy, sync::Cache}; +use rbac_registration::cardano::cip509::{ + self, + rbac::{ + certs::{C509Cert, X509DerCert}, + pub_key::SimplePublicKeyType, + role_data::{KeyLocalRef, LocalRefInt}, + Cip509RbacMetadata, + }, + utils::cip19::extract_cip19_hash, +}; use scylla::Session; use tracing::debug; use x509_cert::{ @@ -145,7 +138,7 @@ impl Rbac509InsertQuery { pub(crate) fn index( &mut self, txn_hash: &[u8], txn: usize, txn_index: i16, slot_no: u64, block: &MultiEraBlock, ) { - if let Some(decoded_metadata) = block.txn_metadata(txn, Metadata::cip509::LABEL) { + if let Some(decoded_metadata) = block.txn_metadata(txn, cip509::LABEL) { #[allow(irrefutable_let_patterns)] if let Metadata::DecodedMetadataValues::Cip509(rbac) = &decoded_metadata.value { // Skip processing if the following validations fail @@ -158,6 +151,7 @@ impl Rbac509InsertQuery { let transaction_id = txn_hash.to_vec(); let chain_root = rbac + .cip509 .prv_tx_id .as_ref() .and_then(|tx_id| { @@ -177,7 +171,7 @@ impl Rbac509InsertQuery { &transaction_id, slot_no, txn_index, - rbac, + &rbac.cip509, )); CHAIN_ROOT_BY_TXN_ID_CACHE.insert(transaction_id.clone(), chain_root.clone()); @@ -186,7 +180,7 @@ impl Rbac509InsertQuery { &transaction_id, &chain_root, )); - let rbac_metadata = &rbac.x509_chunks.0; + let rbac_metadata = &rbac.cip509.x509_chunks.0; if let Some(role_set) = &rbac_metadata.role_set { for role in role_set.iter().filter(|role| role.role_number == 0) { // Index Role 0 data @@ -303,9 +297,14 @@ fn get_role0_x509_certs_from_reference( ) -> Option { x509_certs.and_then(|certs| { key_offset.and_then(|pk_idx| { - certs - .get(pk_idx) - .and_then(|cert| x509_cert::Certificate::from_der(&cert.0).ok()) + certs.get(pk_idx).and_then(|cert| { + match cert { + X509DerCert::X509Cert(der_cert) => { + x509_cert::Certificate::from_der(der_cert).ok() + }, + X509DerCert::Deleted | X509DerCert::Undefined => None, + } + }) }) }) } @@ -319,7 +318,10 @@ fn get_role0_c509_certs_from_reference( certs.get(pk_idx).and_then(|cert| { match cert { C509Cert::C509Certificate(cert) => Some(cert.as_ref()), - C509Cert::C509CertInMetadatumReference(_) => None, + // Currently C509CertInMetadatumReference is unsupported + C509Cert::C509CertInMetadatumReference(_) + | C509Cert::Undefined + | C509Cert::Deleted => None, } }) }) @@ -328,71 +330,56 @@ fn get_role0_c509_certs_from_reference( /// Extract Role0 Key from `KeyReference` fn extract_role0_data( - key_reference: &KeyReference, rbac_metadata: &Cip509RbacMetadata, + key_local_ref: &KeyLocalRef, rbac_metadata: &Cip509RbacMetadata, ) -> Option { - match key_reference { - KeyReference::KeyHash(role0_key) => { - Some(Role0CertificateData { - role0_key: role0_key.clone(), - stake_addresses: None, - }) - }, - KeyReference::KeyLocalRef(key_local_ref) => { - let key_offset: Option = key_local_ref.key_offset.try_into().ok(); - match key_local_ref.local_ref { - LocalRefInt::X509Certs => { - get_role0_x509_certs_from_reference( - rbac_metadata.x509_certs.as_ref(), - key_offset, - ) - .and_then(|der_cert| { - let role0_key = der_cert - .tbs_certificate - .subject_public_key_info - .subject_public_key - .as_bytes() - .map(<[u8]>::to_vec); - - role0_key.map(|role0_key| { - let stake_addresses = extract_stake_addresses_from_x509(&der_cert); - Role0CertificateData { - role0_key, - stake_addresses, - } - }) - }) - }, - LocalRefInt::C509Certs => { - get_role0_c509_certs_from_reference( - rbac_metadata.c509_certs.as_ref(), - key_offset, - ) - .map(|cert| { - let stake_addresses = extract_stake_addresses_from_c509(cert); + let key_offset: Option = key_local_ref.key_offset.try_into().ok(); + match key_local_ref.local_ref { + LocalRefInt::X509Certs => { + get_role0_x509_certs_from_reference(rbac_metadata.x509_certs.as_ref(), key_offset) + .and_then(|der_cert| { + let role0_key = der_cert + .tbs_certificate + .subject_public_key_info + .subject_public_key + .as_bytes() + .map(<[u8]>::to_vec); + + role0_key.map(|role0_key| { + let stake_addresses = extract_stake_addresses_from_x509(&der_cert); Role0CertificateData { - role0_key: cert.tbs_cert().subject_public_key().to_vec(), + role0_key, stake_addresses, } }) + }) + }, + LocalRefInt::C509Certs => { + get_role0_c509_certs_from_reference(rbac_metadata.c509_certs.as_ref(), key_offset).map( + |cert| { + let stake_addresses = extract_stake_addresses_from_c509(cert); + Role0CertificateData { + role0_key: cert.tbs_cert().subject_public_key().to_vec(), + stake_addresses, + } }, - LocalRefInt::PubKeys => { - key_offset.and_then(|pk_idx| { - rbac_metadata.pub_keys.as_ref().and_then(|keys| { - keys.get(pk_idx).and_then(|pk| { - match pk { - SimplePublicKeyType::Ed25519(pk_bytes) => { - Some(Role0CertificateData { - role0_key: pk_bytes.to_vec(), - stake_addresses: None, - }) - }, - _ => None, - } - }) - }) + ) + }, + LocalRefInt::PubKeys => { + key_offset.and_then(|pk_idx| { + rbac_metadata.pub_keys.as_ref().and_then(|keys| { + keys.get(pk_idx).and_then(|pk| { + match pk { + SimplePublicKeyType::Ed25519(pk_bytes) => { + Some(Role0CertificateData { + role0_key: pk_bytes.to_bytes().to_vec(), + stake_addresses: None, + }) + }, + _ => None, + } }) - }, - } + }) + }) }, } } diff --git a/catalyst-gateway/bin/src/db/index/queries/mod.rs b/catalyst-gateway/bin/src/db/index/queries/mod.rs index b257e855d7d..a759fb5a27e 100644 --- a/catalyst-gateway/bin/src/db/index/queries/mod.rs +++ b/catalyst-gateway/bin/src/db/index/queries/mod.rs @@ -9,7 +9,7 @@ pub(crate) mod sync_status; use std::{fmt::Debug, sync::Arc}; -use anyhow::{bail, Context}; +use anyhow::bail; use crossbeam_skiplist::SkipMap; use rbac::{ get_chain_root::GetChainRootQuery, get_registrations::GetRegistrationsByChainRootQuery, @@ -29,6 +29,7 @@ use staked_ada::{ get_txo_by_stake_address::GetTxoByStakeAddressQuery, update_txo_spent::UpdateTxoSpentQuery, }; use sync_status::update::SyncStatusInsertQuery; +use tracing::error; use super::block::{ certs::CertInsertQuery, cip36::Cip36InsertQuery, rbac509::Rbac509InsertQuery, @@ -379,6 +380,8 @@ impl PreparedQueries { let mut results: Vec = Vec::new(); let chunks = values.chunks(cfg.max_batch_size.try_into().unwrap_or(1)); + let mut query_failed = false; + let query_str = format!("{query}"); for chunk in chunks { let chunk_size: u16 = chunk.len().try_into()?; @@ -387,12 +390,19 @@ impl PreparedQueries { bail!("No batch query found for size {}", chunk_size); }; let batch_query_statements = batch_query.value().clone(); - results.push( - session - .batch(&batch_query_statements, chunk) - .await - .context(format!("query={query}, chunk={chunk:?}"))?, - ); + match session.batch(&batch_query_statements, chunk).await { + Ok(result) => results.push(result), + Err(err) => { + let chunk_str = format!("{chunk:?}"); + error!(error=%err, query=query_str, chunk=chunk_str, "Query Execution Failed"); + query_failed = true; + // Defer failure until all batches have been processed. + }, + } + } + + if query_failed { + bail!("Query Failed: {query_str}!"); } Ok(results) diff --git a/catalyst-gateway/bin/src/service/common/auth/rbac/scheme.rs b/catalyst-gateway/bin/src/service/common/auth/rbac/scheme.rs index 2e06ddf2cc3..0489a5b9721 100644 --- a/catalyst-gateway/bin/src/service/common/auth/rbac/scheme.rs +++ b/catalyst-gateway/bin/src/service/common/auth/rbac/scheme.rs @@ -113,7 +113,7 @@ async fn checker_api_catalyst_auth( }; // Check if the token is young enough. - if !token.young(MAX_TOKEN_AGE, MAX_TOKEN_SKEW) { + if !token.is_young(MAX_TOKEN_AGE, MAX_TOKEN_SKEW) { // Token is too old or too far in the future. error!("Auth token expired: {:?}", token); Err(AuthTokenAccessViolation(vec!["EXPIRED".to_string()]))?; diff --git a/catalyst-gateway/bin/src/service/common/auth/rbac/token.rs b/catalyst-gateway/bin/src/service/common/auth/rbac/token.rs index fce464a27f0..76671cd6184 100644 --- a/catalyst-gateway/bin/src/service/common/auth/rbac/token.rs +++ b/catalyst-gateway/bin/src/service/common/auth/rbac/token.rs @@ -174,15 +174,15 @@ impl CatalystRBACTokenV1 { /// Check if the token is young enough. /// Old tokens are no longer valid. - pub(crate) fn young(&self, max_age: Duration, max_skew: Duration) -> bool { + pub(crate) fn is_young(&self, max_age: Duration, max_skew: Duration) -> bool { // We check that the token is not too old or too skewed. let now = SystemTime::now(); let token_age = self.ulid.datetime(); // The token is considered old if it was issued more than max_age ago. - // Or newer than an allowed clock skew value + // And newer than an allowed clock skew value // This is a safety measure to avoid replay attacks. - ((now - max_age) > token_age) && ((now + max_skew) < token_age) + ((now - max_age) < token_age) && ((now + max_skew) > token_age) } } @@ -203,7 +203,7 @@ mod tests { use ed25519_dalek::SigningKey; use rand::rngs::OsRng; - use crate::service::common::auth::rbac::token::{CatalystRBACTokenV1, Kid}; + use super::*; #[test] fn test_token_generation_and_decoding() { @@ -235,4 +235,35 @@ mod tests { assert!(re_encoded_token.verify(&verifying_key).is_ok()); assert!(re_encoded_token.verify(&verifying_key2).is_err()); } + + #[test] + fn is_young() { + let mut random_seed = OsRng; + let key = SigningKey::generate(&mut random_seed); + let mut token = CatalystRBACTokenV1::new(&key); + + // Update the token timestamp to be two seconds in the past. + let now = SystemTime::now(); + token.ulid = Ulid::from_datetime(now - Duration::from_secs(2)); + + // Check that the token ISN'T young if max_age is one second. + let max_age = Duration::from_secs(1); + let max_skew = Duration::from_secs(1); + assert!(!token.is_young(max_age, max_skew)); + + // Check that the token IS young if max_age is three seconds. + let max_age = Duration::from_secs(3); + assert!(token.is_young(max_age, max_skew)); + + // Update the token timestamp to be two seconds in the future. + token.ulid = Ulid::from_datetime(now + Duration::from_secs(2)); + + // Check that the token IS too new if max_skew is one seconds. + let max_skew = Duration::from_secs(1); + assert!(!token.is_young(max_age, max_skew)); + + // Check that the token ISN'T too new if max_skew is three seconds. + let max_skew = Duration::from_secs(3); + assert!(token.is_young(max_age, max_skew)); + } } diff --git a/catalyst-gateway/bin/src/service/utilities/middleware/schema_validation.rs b/catalyst-gateway/bin/src/service/utilities/middleware/schema_validation.rs index 9f4d70fc0b2..392775d4066 100644 --- a/catalyst-gateway/bin/src/service/utilities/middleware/schema_validation.rs +++ b/catalyst-gateway/bin/src/service/utilities/middleware/schema_validation.rs @@ -8,6 +8,7 @@ //! the wrapped endpoint is called and its response is returned. use poem::{http::StatusCode, Endpoint, EndpointExt, Middleware, Request, Result}; +use tracing::error; use crate::db::event::EventDB; @@ -35,7 +36,8 @@ impl Endpoint for SchemaVersionValidationImpl { async fn call(&self, req: Request) -> Result { // Check if the inner schema version status is set to `Mismatch`, // if so, return the `StatusCode::SERVICE_UNAVAILABLE` code. - if EventDB::schema_version_check().await.is_err() { + if let Err(e) = EventDB::schema_version_check().await { + error!("Schema version check error: {e:?}"); return Err(StatusCode::SERVICE_UNAVAILABLE.into()); } // Calls the endpoint with the request, and returns the response. diff --git a/catalyst-gateway/bin/src/settings/mod.rs b/catalyst-gateway/bin/src/settings/mod.rs index 28726514680..6be03945050 100644 --- a/catalyst-gateway/bin/src/settings/mod.rs +++ b/catalyst-gateway/bin/src/settings/mod.rs @@ -73,10 +73,6 @@ fn calculate_service_uuid() -> String { #[derive(Args, Clone)] #[clap(version = BUILD_INFO)] pub(crate) struct ServiceSettings { - /// Url to the postgres event db - #[clap(long, env)] - pub(crate) event_db_url: Option, - /// Logging level #[clap(long, default_value = LOG_LEVEL_DEFAULT)] pub(crate) log_level: LogLevel, diff --git a/catalyst-gateway/blueprint.cue b/catalyst-gateway/blueprint.cue index c81c19d56c6..9e36433f3f4 100644 --- a/catalyst-gateway/blueprint.cue +++ b/catalyst-gateway/blueprint.cue @@ -12,4 +12,9 @@ project: { } } } + ci: { + targets: { + test: privileged: true + } + } } diff --git a/catalyst-gateway/docker-compose.yml b/catalyst-gateway/docker-compose.yml index 4b2d833735b..00b2a348764 100644 --- a/catalyst-gateway/docker-compose.yml +++ b/catalyst-gateway/docker-compose.yml @@ -1,10 +1,7 @@ -version: "3" - services: event-db: image: event-db:latest environment: - # Required environment variables for migrations - DB_HOST=localhost - DB_PORT=5432 - DB_NAME=CatalystEventDev @@ -16,7 +13,6 @@ services: - INIT_AND_DROP_DB=true - WITH_MIGRATIONS=true - - WITH_SEED_DATA=true ports: - 5432:5432 healthcheck: @@ -25,6 +21,14 @@ services: timeout: 5s retries: 10 +# it is a helper service to wait until the event-db will be ready +# mainly its a trick for Earthly how to wait until service will be fully functional + event-db-is-running: + image: alpine:3.20.3 + depends_on: + event-db: + condition: service_healthy + cat-gateway: image: cat-gateway:latest environment: diff --git a/catalyst-gateway/event-db/Earthfile b/catalyst-gateway/event-db/Earthfile index dac2e2647ca..4b004d1ea62 100644 --- a/catalyst-gateway/event-db/Earthfile +++ b/catalyst-gateway/event-db/Earthfile @@ -4,6 +4,7 @@ VERSION 0.8 IMPORT github.com/input-output-hk/catalyst-ci/earthly/postgresql:v3.2.24 AS postgresql-ci +IMPORT github.com/input-output-hk/catalyst-ci/earthly/python:v3.2.24 AS python-ci # cspell: words @@ -41,24 +42,19 @@ local: SAVE IMAGE --push --insecure $local_registry/event-db:latest END - -# test the event db database schema -# CI target : true -#test: -# FROM github.com/input-output-hk/catalyst-ci/earthly/postgresql:v2.9.2+postgres-base - -# COPY github.com/input-output-hk/catalyst-ci/earthly/utils:v2.9.2+shell-assert/assert.sh . - -# COPY ./docker-compose.yml . -# WITH DOCKER \ -# --compose docker-compose.yml \ -# --load event-db:latest=(+build --with_historic_data=false) \ -# --service event-db \ -# --allow-privileged -# RUN sleep 65;\ -# res=$(psql postgresql://catalyst-event-dev:CHANGE_ME@0.0.0.0:5432/CatalystEventDev -c "SELECT COUNT(*) FROM event");\ - -# source assert.sh;\ -# expected=$(printf " count \n-------\n 5\n(1 row)");\ -# assert_eq "$expected" "$res" -# END +# Run the queries_tests.py +test: + FROM python-ci+python-base + + DO python-ci+BUILDER + COPY --dir tests . + COPY --dir queries . + + WITH DOCKER \ + --compose "./tests/docker-compose.yml" \ + --load event-db:latest=+build \ + --pull alpine:3.20.3 \ + --service event-db-is-running \ + --allow-privileged + RUN poetry run pytest -s -m ci + END diff --git a/catalyst-gateway/event-db/blueprint.cue b/catalyst-gateway/event-db/blueprint.cue index 1dad7a98b08..0d9ef5b2bff 100644 --- a/catalyst-gateway/event-db/blueprint.cue +++ b/catalyst-gateway/event-db/blueprint.cue @@ -1,2 +1,9 @@ version: "1.0.0" -project: name: "gateway-event-db" +project: { + name: "gateway-event-db" + ci: { + targets: { + test: privileged: true + } + } +} diff --git a/catalyst-gateway/event-db/migrations/V2__signed_documents.sql b/catalyst-gateway/event-db/migrations/V2__signed_documents.sql index 523680ef9f2..28ff361d4a3 100644 --- a/catalyst-gateway/event-db/migrations/V2__signed_documents.sql +++ b/catalyst-gateway/event-db/migrations/V2__signed_documents.sql @@ -12,11 +12,11 @@ -- Signed Documents Storage Repository defintion. CREATE TABLE IF NOT EXISTS signed_docs ( - id UUID NOT NULL, -- Actually a ULID - ver UUID NOT NULL, -- Actually a ULID - type UUID NOT NULL, -- Yes its a UUID this time + id UUID NOT NULL, -- UUID v7 + ver UUID NOT NULL, -- UUID v7 + type UUID NOT NULL, -- UUID v4 author TEXT NOT NULL, - metadata JSONB NOT NULL, + metadata JSONB NULL, payload JSONB NULL, raw BYTEA NOT NULL, diff --git a/catalyst-gateway/event-db/poetry.lock b/catalyst-gateway/event-db/poetry.lock new file mode 100644 index 00000000000..9194944001f --- /dev/null +++ b/catalyst-gateway/event-db/poetry.lock @@ -0,0 +1,311 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "jinja2" +version = "3.1.4" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +files = [ + {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, + {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "loguru" +version = "0.7.3" +description = "Python logging made (stupidly) simple" +optional = false +python-versions = "<4.0,>=3.5" +files = [ + {file = "loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c"}, + {file = "loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} +win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} + +[package.extras] +dev = ["Sphinx (==8.1.3)", "build (==1.2.2)", "colorama (==0.4.5)", "colorama (==0.4.6)", "exceptiongroup (==1.1.3)", "freezegun (==1.1.0)", "freezegun (==1.5.0)", "mypy (==v0.910)", "mypy (==v0.971)", "mypy (==v1.13.0)", "mypy (==v1.4.1)", "myst-parser (==4.0.0)", "pre-commit (==4.0.1)", "pytest (==6.1.2)", "pytest (==8.3.2)", "pytest-cov (==2.12.1)", "pytest-cov (==5.0.0)", "pytest-cov (==6.0.0)", "pytest-mypy-plugins (==1.9.3)", "pytest-mypy-plugins (==3.1.0)", "sphinx-rtd-theme (==3.0.2)", "tox (==3.27.1)", "tox (==4.23.2)", "twine (==6.0.1)"] + +[[package]] +name = "markupsafe" +version = "3.0.2" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +files = [ + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, +] + +[[package]] +name = "packaging" +version = "24.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "psycopg" +version = "3.2.3" +description = "PostgreSQL database adapter for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "psycopg-3.2.3-py3-none-any.whl", hash = "sha256:644d3973fe26908c73d4be746074f6e5224b03c1101d302d9a53bf565ad64907"}, + {file = "psycopg-3.2.3.tar.gz", hash = "sha256:a5764f67c27bec8bfac85764d23c534af2c27b893550377e37ce59c12aac47a2"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6", markers = "python_version < \"3.13\""} +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +binary = ["psycopg-binary (==3.2.3)"] +c = ["psycopg-c (==3.2.3)"] +dev = ["ast-comments (>=1.1.2)", "black (>=24.1.0)", "codespell (>=2.2)", "dnspython (>=2.1)", "flake8 (>=4.0)", "mypy (>=1.11)", "types-setuptools (>=57.4)", "wheel (>=0.37)"] +docs = ["Sphinx (>=5.0)", "furo (==2022.6.21)", "sphinx-autobuild (>=2021.3.14)", "sphinx-autodoc-typehints (>=1.12)"] +pool = ["psycopg-pool"] +test = ["anyio (>=4.0)", "mypy (>=1.11)", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"] + +[[package]] +name = "psycopg-binary" +version = "3.2.3" +description = "PostgreSQL database adapter for Python -- C optimisation distribution" +optional = false +python-versions = ">=3.8" +files = [ + {file = "psycopg_binary-3.2.3-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:965455eac8547f32b3181d5ec9ad8b9be500c10fe06193543efaaebe3e4ce70c"}, + {file = "psycopg_binary-3.2.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:71adcc8bc80a65b776510bc39992edf942ace35b153ed7a9c6c573a6849ce308"}, + {file = "psycopg_binary-3.2.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f73adc05452fb85e7a12ed3f69c81540a8875960739082e6ea5e28c373a30774"}, + {file = "psycopg_binary-3.2.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8630943143c6d6ca9aefc88bbe5e76c90553f4e1a3b2dc339e67dc34aa86f7e"}, + {file = "psycopg_binary-3.2.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bffb61e198a91f712cc3d7f2d176a697cb05b284b2ad150fb8edb308eba9002"}, + {file = "psycopg_binary-3.2.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc4fa2240c9fceddaa815a58f29212826fafe43ce80ff666d38c4a03fb036955"}, + {file = "psycopg_binary-3.2.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:192a5f8496e6e1243fdd9ac20e117e667c0712f148c5f9343483b84435854c78"}, + {file = "psycopg_binary-3.2.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64dc6e9ec64f592f19dc01a784e87267a64a743d34f68488924251253da3c818"}, + {file = "psycopg_binary-3.2.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:79498df398970abcee3d326edd1d4655de7d77aa9aecd578154f8af35ce7bbd2"}, + {file = "psycopg_binary-3.2.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:949551752930d5e478817e0b49956350d866b26578ced0042a61967e3fcccdea"}, + {file = "psycopg_binary-3.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:80a2337e2dfb26950894c8301358961430a0304f7bfe729d34cc036474e9c9b1"}, + {file = "psycopg_binary-3.2.3-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:6d8f2144e0d5808c2e2aed40fbebe13869cd00c2ae745aca4b3b16a435edb056"}, + {file = "psycopg_binary-3.2.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:94253be2b57ef2fea7ffe08996067aabf56a1eb9648342c9e3bad9e10c46e045"}, + {file = "psycopg_binary-3.2.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fda0162b0dbfa5eaed6cdc708179fa27e148cb8490c7d62e5cf30713909658ea"}, + {file = "psycopg_binary-3.2.3-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2c0419cdad8c70eaeb3116bb28e7b42d546f91baf5179d7556f230d40942dc78"}, + {file = "psycopg_binary-3.2.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74fbf5dd3ef09beafd3557631e282f00f8af4e7a78fbfce8ab06d9cd5a789aae"}, + {file = "psycopg_binary-3.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d784f614e4d53050cbe8abf2ae9d1aaacf8ed31ce57b42ce3bf2a48a66c3a5c"}, + {file = "psycopg_binary-3.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4e76ce2475ed4885fe13b8254058be710ec0de74ebd8ef8224cf44a9a3358e5f"}, + {file = "psycopg_binary-3.2.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5938b257b04c851c2d1e6cb2f8c18318f06017f35be9a5fe761ee1e2e344dfb7"}, + {file = "psycopg_binary-3.2.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:257c4aea6f70a9aef39b2a77d0658a41bf05c243e2bf41895eb02220ac6306f3"}, + {file = "psycopg_binary-3.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:06b5cc915e57621eebf2393f4173793ed7e3387295f07fed93ed3fb6a6ccf585"}, + {file = "psycopg_binary-3.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:09baa041856b35598d335b1a74e19a49da8500acedf78164600694c0ba8ce21b"}, + {file = "psycopg_binary-3.2.3-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:48f8ca6ee8939bab760225b2ab82934d54330eec10afe4394a92d3f2a0c37dd6"}, + {file = "psycopg_binary-3.2.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5361ea13c241d4f0ec3f95e0bf976c15e2e451e9cc7ef2e5ccfc9d170b197a40"}, + {file = "psycopg_binary-3.2.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb987f14af7da7c24f803111dbc7392f5070fd350146af3345103f76ea82e339"}, + {file = "psycopg_binary-3.2.3-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0463a11b1cace5a6aeffaf167920707b912b8986a9c7920341c75e3686277920"}, + {file = "psycopg_binary-3.2.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8b7be9a6c06518967b641fb15032b1ed682fd3b0443f64078899c61034a0bca6"}, + {file = "psycopg_binary-3.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64a607e630d9f4b2797f641884e52b9f8e239d35943f51bef817a384ec1678fe"}, + {file = "psycopg_binary-3.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:fa33ead69ed133210d96af0c63448b1385df48b9c0247eda735c5896b9e6dbbf"}, + {file = "psycopg_binary-3.2.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:1f8b0d0e99d8e19923e6e07379fa00570be5182c201a8c0b5aaa9a4d4a4ea20b"}, + {file = "psycopg_binary-3.2.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:709447bd7203b0b2debab1acec23123eb80b386f6c29e7604a5d4326a11e5bd6"}, + {file = "psycopg_binary-3.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5e37d5027e297a627da3551a1e962316d0f88ee4ada74c768f6c9234e26346d9"}, + {file = "psycopg_binary-3.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:261f0031ee6074765096a19b27ed0f75498a8338c3dcd7f4f0d831e38adf12d1"}, + {file = "psycopg_binary-3.2.3-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:41fdec0182efac66b27478ac15ef54c9ebcecf0e26ed467eb7d6f262a913318b"}, + {file = "psycopg_binary-3.2.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:07d019a786eb020c0f984691aa1b994cb79430061065a694cf6f94056c603d26"}, + {file = "psycopg_binary-3.2.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c57615791a337378fe5381143259a6c432cdcbb1d3e6428bfb7ce59fff3fb5c"}, + {file = "psycopg_binary-3.2.3-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8eb9a4e394926b93ad919cad1b0a918e9b4c846609e8c1cfb6b743683f64da0"}, + {file = "psycopg_binary-3.2.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5905729668ef1418bd36fbe876322dcb0f90b46811bba96d505af89e6fbdce2f"}, + {file = "psycopg_binary-3.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd65774ed7d65101b314808b6893e1a75b7664f680c3ef18d2e5c84d570fa393"}, + {file = "psycopg_binary-3.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:700679c02f9348a0d0a2adcd33a0275717cd0d0aee9d4482b47d935023629505"}, + {file = "psycopg_binary-3.2.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:96334bb64d054e36fed346c50c4190bad9d7c586376204f50bede21a913bf942"}, + {file = "psycopg_binary-3.2.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9099e443d4cc24ac6872e6a05f93205ba1a231b1a8917317b07c9ef2b955f1f4"}, + {file = "psycopg_binary-3.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1985ab05e9abebfbdf3163a16ebb37fbc5d49aff2bf5b3d7375ff0920bbb54cd"}, + {file = "psycopg_binary-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:e90352d7b610b4693fad0feea48549d4315d10f1eba5605421c92bb834e90170"}, + {file = "psycopg_binary-3.2.3-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:69320f05de8cdf4077ecd7fefdec223890eea232af0d58f2530cbda2871244a0"}, + {file = "psycopg_binary-3.2.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4926ea5c46da30bec4a85907aa3f7e4ea6313145b2aa9469fdb861798daf1502"}, + {file = "psycopg_binary-3.2.3-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c64c4cd0d50d5b2288ab1bcb26c7126c772bbdebdfadcd77225a77df01c4a57e"}, + {file = "psycopg_binary-3.2.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05a1bdce30356e70a05428928717765f4a9229999421013f41338d9680d03a63"}, + {file = "psycopg_binary-3.2.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ad357e426b0ea5c3043b8ec905546fa44b734bf11d33b3da3959f6e4447d350"}, + {file = "psycopg_binary-3.2.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:967b47a0fd237aa17c2748fdb7425015c394a6fb57cdad1562e46a6eb070f96d"}, + {file = "psycopg_binary-3.2.3-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:71db8896b942770ed7ab4efa59b22eee5203be2dfdee3c5258d60e57605d688c"}, + {file = "psycopg_binary-3.2.3-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:2773f850a778575dd7158a6dd072f7925b67f3ba305e2003538e8831fec77a1d"}, + {file = "psycopg_binary-3.2.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aeddf7b3b3f6e24ccf7d0edfe2d94094ea76b40e831c16eff5230e040ce3b76b"}, + {file = "psycopg_binary-3.2.3-cp38-cp38-win_amd64.whl", hash = "sha256:824c867a38521d61d62b60aca7db7ca013a2b479e428a0db47d25d8ca5067410"}, + {file = "psycopg_binary-3.2.3-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:9994f7db390c17fc2bd4c09dca722fd792ff8a49bb3bdace0c50a83f22f1767d"}, + {file = "psycopg_binary-3.2.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1303bf8347d6be7ad26d1362af2c38b3a90b8293e8d56244296488ee8591058e"}, + {file = "psycopg_binary-3.2.3-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:842da42a63ecb32612bb7f5b9e9f8617eab9bc23bd58679a441f4150fcc51c96"}, + {file = "psycopg_binary-3.2.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2bb342a01c76f38a12432848e6013c57eb630103e7556cf79b705b53814c3949"}, + {file = "psycopg_binary-3.2.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd40af959173ea0d087b6b232b855cfeaa6738f47cb2a0fd10a7f4fa8b74293f"}, + {file = "psycopg_binary-3.2.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9b60b465773a52c7d4705b0a751f7f1cdccf81dd12aee3b921b31a6e76b07b0e"}, + {file = "psycopg_binary-3.2.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:fc6d87a1c44df8d493ef44988a3ded751e284e02cdf785f746c2d357e99782a6"}, + {file = "psycopg_binary-3.2.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:f0b018e37608c3bfc6039a1dc4eb461e89334465a19916be0153c757a78ea426"}, + {file = "psycopg_binary-3.2.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2a29f5294b0b6360bfda69653697eff70aaf2908f58d1073b0acd6f6ab5b5a4f"}, + {file = "psycopg_binary-3.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:e56b1fd529e5dde2d1452a7d72907b37ed1b4f07fdced5d8fb1e963acfff6749"}, +] + +[[package]] +name = "pytest" +version = "8.3.4" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, + {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "tzdata" +version = "2024.2" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd"}, + {file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"}, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +description = "A small Python utility to set file creation time on Windows" +optional = false +python-versions = ">=3.5" +files = [ + {file = "win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390"}, + {file = "win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0"}, +] + +[package.extras] +dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.11" +content-hash = "f1cfae8e203124329bc62902795c307371fa78930a3aabc9c8cd41a889b5298e" diff --git a/catalyst-gateway/event-db/pyproject.toml b/catalyst-gateway/event-db/pyproject.toml new file mode 100644 index 00000000000..d7447218181 --- /dev/null +++ b/catalyst-gateway/event-db/pyproject.toml @@ -0,0 +1,23 @@ +# cspell: words bitcoinlib + +[tool.poetry] +name = "tests" +version = "0.1.0" +description = "" +authors = [] +readme = "Readme.md" +license = "MIT or Apache-2.0" + +[tool.poetry.dependencies] +python = "^3.11" +loguru = "^0.7.2" +pytest = "^8.0.0" +psycopg = "3.2.3" +psycopg-binary = "^3.2.3" +jinja2 = "^3.1.4" + +[tool.pytest.ini_options] +markers = [ + "ci: marks tests to be run in ci", + "nightly: marks tests to be run nightly", +] \ No newline at end of file diff --git a/catalyst-gateway/event-db/queries/insert_signed_documents.sql b/catalyst-gateway/event-db/queries/insert_signed_documents.sql new file mode 100644 index 00000000000..b5d79222259 --- /dev/null +++ b/catalyst-gateway/event-db/queries/insert_signed_documents.sql @@ -0,0 +1,22 @@ +INSERT INTO signed_docs +( + id, + ver, + type, + author, + metadata, + payload, + raw +) +VALUES +($1, $2, $3, $4, $5, $6, $7) +ON CONFLICT (id, ver) DO UPDATE +SET +type = signed_docs.type + +WHERE +signed_docs.type = excluded.type +AND signed_docs.author = excluded.author +AND signed_docs.metadata = excluded.metadata +AND signed_docs.payload = excluded.payload +AND signed_docs.raw = excluded.raw diff --git a/catalyst-gateway/event-db/queries/select_signed_documents.sql.jinja b/catalyst-gateway/event-db/queries/select_signed_documents.sql.jinja new file mode 100644 index 00000000000..fb4effa7fb3 --- /dev/null +++ b/catalyst-gateway/event-db/queries/select_signed_documents.sql.jinja @@ -0,0 +1,12 @@ +SELECT + signed_docs.type, + signed_docs.author, + signed_docs.metadata, + signed_docs.payload, + signed_docs.raw +FROM signed_docs +WHERE + signed_docs.id = '{{ id }}' + {% if ver %} AND signed_docs.ver = '{{ ver }}' {% endif %} +ORDER BY signed_docs.ver DESC +LIMIT 1 diff --git a/catalyst-gateway/event-db/queries/select_signed_documents_2.sql.jinja b/catalyst-gateway/event-db/queries/select_signed_documents_2.sql.jinja new file mode 100644 index 00000000000..fda56391dbf --- /dev/null +++ b/catalyst-gateway/event-db/queries/select_signed_documents_2.sql.jinja @@ -0,0 +1,12 @@ +SELECT + signed_docs.id, + signed_docs.ver, + signed_docs.type, + signed_docs.author, + signed_docs.metadata +FROM signed_docs +WHERE + {{ conditions }} +ORDER BY signed_docs.type DESC, signed_docs.id DESC, signed_docs.ver DESC +{% if limit %} LIMIT {{ limit }} {% endif %} +{% if offset %} OFFSET {{ offset }} {% endif %} diff --git a/catalyst-gateway/event-db/tests/docker-compose.yml b/catalyst-gateway/event-db/tests/docker-compose.yml new file mode 100644 index 00000000000..4c9801de859 --- /dev/null +++ b/catalyst-gateway/event-db/tests/docker-compose.yml @@ -0,0 +1,30 @@ +services: + event-db: + image: event-db:latest + environment: + - DB_HOST=localhost + - DB_PORT=5432 + - DB_NAME=CatalystEventDev + - DB_DESCRIPTION="Catalyst Event DB" + - DB_SUPERUSER=postgres + - DB_SUPERUSER_PASSWORD=postgres + - DB_USER=catalyst-event-dev + - DB_USER_PASSWORD=CHANGE_ME + + - INIT_AND_DROP_DB=true + - WITH_MIGRATIONS=true + ports: + - 5432:5432 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${DB_SUPERUSER} -d $${DB_SUPERUSER_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 10 + +# it is a helper service to wait until the event-db will be ready +# mainly its a trick for Earthly how to wait until service will be fully functional + event-db-is-running: + image: alpine:3.20.3 + depends_on: + event-db: + condition: service_healthy diff --git a/catalyst-gateway/event-db/tests/test_signed_docs_queries.py b/catalyst-gateway/event-db/tests/test_signed_docs_queries.py new file mode 100644 index 00000000000..3eaad1af525 --- /dev/null +++ b/catalyst-gateway/event-db/tests/test_signed_docs_queries.py @@ -0,0 +1,147 @@ +import psycopg +import pytest +import jinja2 + + +jinja_env = jinja2.Environment( + loader=jinja2.FileSystemLoader("./queries/"), +) +EVENT_DB_URL = "postgres://catalyst-event-dev:CHANGE_ME@localhost/CatalystEventDev" + + +class SignedData: + def __init__( + self, + id: str, + ver: str, + doc_type: str, + author: str, + metadata: str, + payload: str, + raw: bytes, + ): + self.id = id + self.ver = ver + self.doc_type = doc_type + self.author = author + self.metadata = metadata + self.payload = payload + self.raw = raw + + +@pytest.mark.ci +def test_signed_docs_queries(): + with psycopg.connect(EVENT_DB_URL) as conn: + docs = [ + SignedData( + id="3764e30b-9bb5-4a34-906e-f4de1845e8bf", + ver="299255cb-9f53-46ec-8e24-4360b9d374bd", + doc_type="c17f59b2-1304-4a75-923e-00795add70af", + author="Alex", + metadata="{}", + payload="{}", + raw=b"bytes1", + ), + SignedData( + id="4ee05138-e85b-48b4-91c2-2a45a74a0a82", + ver="3cd78d6c-9388-47ab-9a65-f3a84c76190f", + doc_type="fec1b996-ad89-4fab-a787-6568f42b00b1", + author="Steven", + metadata="{}", + payload="{}", + raw=b"bytes2", + ), + ] + + insert_signed_documents_query(conn, docs) + # try insert the same values + insert_signed_documents_query(conn, docs) + select_signed_documents_query(conn, docs) + select_signed_documents_2_query(conn, docs) + + # try insert the same id and ver, but with the different other data + docs[0].author = "Sasha" + docs[1].author = "Sasha" + should_panic( + lambda: insert_signed_documents_query(conn, docs), + "insert_signed_documents_query should fail", + ) + should_panic( + lambda: select_signed_documents_query(conn, docs), + "select_signed_documents_query should fail", + ) + should_panic( + lambda: select_signed_documents_2_query(conn, docs), + "select_signed_documents_2_query should fail", + ) + + +def insert_signed_documents_query(conn, docs: [SignedData]): + sql_stmt = open("./queries/insert_signed_documents.sql", "r").read() + sql_stmt = ( + sql_stmt.replace("$1", "%s") + .replace("$2", "%s") + .replace("$3", "%s") + .replace("$4", "%s") + .replace("$5", "%s") + .replace("$6", "%s") + .replace("$7", "%s") + ) + for doc in docs: + conn.execute( + sql_stmt, + ( + doc.id, + doc.ver, + doc.doc_type, + doc.author, + doc.metadata, + doc.payload, + doc.raw, + ), + ) + + +def select_signed_documents_query(conn, docs: [SignedData]): + template = jinja_env.get_template("select_signed_documents.sql.jinja") + for doc in docs: + sql_stmt = template.render( + { + "id": doc.id, + "ver": doc.ver, + } + ) + cur = conn.execute(sql_stmt) + (doc_type, author, metadata, payload, raw) = cur.fetchone() + assert str(doc_type) == doc.doc_type + assert author == doc.author + assert str(metadata) == doc.metadata + assert str(payload) == doc.payload + assert raw == doc.raw + + +def select_signed_documents_2_query(conn, docs: [SignedData]): + template = jinja_env.get_template("select_signed_documents_2.sql.jinja") + for doc in docs: + sql_stmt = template.render( + { + "conditions": f"signed_docs.id = '{doc.id}' AND signed_docs.ver = '{doc.ver}'", + "limit": 1, + "offset": 0, + } + ) + cur = conn.execute(sql_stmt) + (id, ver, doc_type, author, metadata) = cur.fetchone() + assert str(id) == doc.id + assert str(ver) == doc.ver + assert str(doc_type) == doc.doc_type + assert author == doc.author + assert str(metadata) == doc.metadata + + +def should_panic(func, msg: str): + try: + func() + assert False, msg + except: + pass diff --git a/catalyst_voices/apps/voices/integration_test/Earthfile b/catalyst_voices/apps/voices/integration_test/Earthfile index 75f0f3de6c8..84470172b61 100644 --- a/catalyst_voices/apps/voices/integration_test/Earthfile +++ b/catalyst_voices/apps/voices/integration_test/Earthfile @@ -21,7 +21,7 @@ integration-test-web: # IF [ $browser = "edge" && $TARGETARCH = "amd64" ]] # LET driver = "msedgedriver" # END - + WORKDIR /frontend/apps/voices RUN ($driver --port=$driver_port > $driver.log &) && \ diff --git a/catalyst_voices/apps/voices/integration_test/app_test.dart b/catalyst_voices/apps/voices/integration_test/app_test.dart index 9c9bad35d45..b0ba116b1fc 100644 --- a/catalyst_voices/apps/voices/integration_test/app_test.dart +++ b/catalyst_voices/apps/voices/integration_test/app_test.dart @@ -1,20 +1,137 @@ import 'package:catalyst_voices/app/view/app.dart'; import 'package:catalyst_voices/configs/bootstrap.dart'; +import 'package:catalyst_voices/routes/routes.dart'; +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:integration_test/integration_test.dart'; +import 'package:patrol_finders/patrol_finders.dart'; -void main() { +import 'pageobject/app_bar_page.dart'; +import 'pageobject/overall_spaces_page.dart'; +import 'pageobject/spaces_drawer_page.dart'; +import 'utils/selector_utils.dart'; + +void main() async { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + late final GoRouter router; + + setUpAll(() async { + router = buildAppRouter(); + await bootstrap(router: router); + }); - group('End to end tests', () { - testWidgets('run app', (tester) async { - final args = await bootstrap(); - await tester.pumpWidget(App(routerConfig: args.routerConfig)); - // let the application load - await tester.pump(const Duration(seconds: 5)); - // pump and settle every 100ms to simulate almost production-like FPS - await tester.pumpAndSettle(const Duration(milliseconds: 100)); - expect(find.text('Coming'), findsOneWidget); - }); + setUp(() async { + await registerDependencies(config: const AppConfig()); + router.go(const DiscoveryRoute().location); }); + + tearDown(() async { + await restartDependencies(); + }); + + patrolWidgetTest( + 'Spaces drawer - visitor - no drawer button', + (PatrolTester $) async { + await $.pumpWidgetAndSettle(App(routerConfig: router)); + await $(OverallSpacesPage.visitorShortcutBtn) + .tap(settleTimeout: const Duration(seconds: 10)); + expect($(AppBarPage.spacesDrawerButton).exists, false); + }, + ); + + patrolWidgetTest( + 'Spaces drawer - guest - chooser - clicking on icons works correctly', + (PatrolTester $) async { + await $.pumpWidgetAndSettle(App(routerConfig: router)); + await $(OverallSpacesPage.guestShortcutBtn) + .tap(settleTimeout: const Duration(seconds: 10)); + await $(AppBarPage.spacesDrawerButton).waitUntilVisible().tap(); + SpacesDrawerPage.guestLooksAsExpected($); + + // iterate thru spaces by clicking on spaces icons directly + for (final space in Space.values) { + await $(SpacesDrawerPage.chooserItem(space)).tap(); + expect( + $(SpacesDrawerPage.chooserIcon(space)), + findsOneWidget, + ); + final children = find.descendant( + of: $(SpacesDrawerPage.guestMenuItems), + matching: find.byWidgetPredicate((widget) => true), + ); + expect($(children), findsAtLeast(1)); + } + SelectorUtils.isDisabled($, $(SpacesDrawerPage.chooserNextBtn)); + }, + ); + + patrolWidgetTest( + 'Spaces drawer - guest - chooser - next,previous buttons work correctly', + (PatrolTester $) async { + await $.pumpWidgetAndSettle(App(routerConfig: router)); + await $(OverallSpacesPage.guestShortcutBtn) + .tap(settleTimeout: const Duration(seconds: 10)); + await $(AppBarPage.spacesDrawerButton).waitUntilVisible().tap(); + + // iterate thru spaces by clicking next + for (var i = 0; i < Space.values.length; i++) { + await $(SpacesDrawerPage.chooserNextBtn).tap(); + final children = find.descendant( + of: $(SpacesDrawerPage.guestMenuItems), + matching: find.byWidgetPredicate((widget) => true), + ); + expect($(children), findsAtLeast(1)); + SelectorUtils.isEnabled($, $(SpacesDrawerPage.chooserPrevBtn)); + } + SelectorUtils.isDisabled($, $(SpacesDrawerPage.chooserNextBtn)); + + // iterate thru spaces by clicking previous + for (var i = 0; i < Space.values.length; i++) { + await $(SpacesDrawerPage.chooserPrevBtn).tap(); + final children = find.descendant( + of: $(SpacesDrawerPage.guestMenuItems), + matching: find.byWidgetPredicate((widget) => true), + ); + expect($(children), findsAtLeast(1)); + SelectorUtils.isEnabled($, $(SpacesDrawerPage.chooserNextBtn)); + } + SelectorUtils.isDisabled($, $(SpacesDrawerPage.chooserPrevBtn)); + }, + ); + + patrolWidgetTest( + 'Spaces drawer - user - chooser - clicking on icons works correctly', + (PatrolTester $) async { + await $.pumpWidgetAndSettle(App(routerConfig: router)); + await $(OverallSpacesPage.userShortcutBtn) + .tap(settleTimeout: const Duration(seconds: 10)); + await $(AppBarPage.spacesDrawerButton).waitUntilVisible().tap(); + await SpacesDrawerPage.userLooksAsExpected($); + }, + ); + + patrolWidgetTest( + 'Spaces drawer - guest - chooser - all spaces button works', + (PatrolTester $) async { + await $.pumpWidgetAndSettle(App(routerConfig: router)); + await $(OverallSpacesPage.guestShortcutBtn) + .tap(settleTimeout: const Duration(seconds: 10)); + await $(AppBarPage.spacesDrawerButton).waitUntilVisible().tap(); + await $(SpacesDrawerPage.allSpacesBtn).tap(); + expect($(OverallSpacesPage.spacesListView), findsOneWidget); + }, + ); + + patrolWidgetTest( + 'Spaces drawer - user - chooser - all spaces button works', + (PatrolTester $) async { + await $.pumpWidgetAndSettle(App(routerConfig: router)); + await $(OverallSpacesPage.userShortcutBtn) + .tap(settleTimeout: const Duration(seconds: 10)); + await $(AppBarPage.spacesDrawerButton).waitUntilVisible().tap(); + await $(SpacesDrawerPage.allSpacesBtn).tap(); + expect($(OverallSpacesPage.spacesListView), findsOneWidget); + }, + ); } diff --git a/catalyst_voices/apps/voices/integration_test/pageobject/app_bar_page.dart b/catalyst_voices/apps/voices/integration_test/pageobject/app_bar_page.dart new file mode 100644 index 00000000000..32e7018cb77 --- /dev/null +++ b/catalyst_voices/apps/voices/integration_test/pageobject/app_bar_page.dart @@ -0,0 +1,7 @@ +library dashboard_page; + +import 'package:flutter/material.dart'; + +class AppBarPage { + static const spacesDrawerButton = Key('DrawerButton'); +} diff --git a/catalyst_voices/apps/voices/integration_test/pageobject/overall_spaces_page.dart b/catalyst_voices/apps/voices/integration_test/pageobject/overall_spaces_page.dart new file mode 100644 index 00000000000..6ee344a019c --- /dev/null +++ b/catalyst_voices/apps/voices/integration_test/pageobject/overall_spaces_page.dart @@ -0,0 +1,15 @@ +library dashboard_page; + +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:flutter/material.dart'; + +class OverallSpacesPage { + static const guestShortcutBtn = Key('GuestShortcut'); + static const visitorShortcutBtn = Key('VisitorShortcut'); + static const userShortcutBtn = Key('UserShortcut'); + static const spacesListView = Key('SpacesListView'); + + static Key spaceOverview(Space space) { + return Key('SpaceOverview.${space.name}'); + } +} diff --git a/catalyst_voices/apps/voices/integration_test/pageobject/spaces_drawer_page.dart b/catalyst_voices/apps/voices/integration_test/pageobject/spaces_drawer_page.dart new file mode 100644 index 00000000000..2b0ab1e65e7 --- /dev/null +++ b/catalyst_voices/apps/voices/integration_test/pageobject/spaces_drawer_page.dart @@ -0,0 +1,157 @@ +library spaces_drawer_page; + +import 'package:catalyst_voices_models/catalyst_voices_models.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patrol_finders/patrol_finders.dart'; + +import '../utils/selector_utils.dart'; + +class SpacesDrawerPage { + static const closeBtn = Key('MenuCloseButton'); + static const guestMenuItems = Key('GuestMenuItems'); + static const allSpacesBtn = Key('DrawerChooserAllSpacesButton'); + static const chooserPrevBtn = Key('DrawerChooserPreviousButton'); + static const chooserNextBtn = Key('DrawerChooserNextButton'); + static const chooserItemContainer = Key('DrawerChooserItem'); + static const userDiscoveryDashboardTile = Key('DiscoveryDashboardTile'); + static const userRolesTile = Key('RolesTile'); + static const userFeedbackTile = Key('FeedbackTile'); + static const userDocumentationTile = Key('DocumentationTile'); + static const userDrawerMenuItem = Key('UserDrawerMenuItem'); + + static Key chooserItem(Space space) { + return Key('DrawerChooser$space'); + } + + static Key userHeader(Space space) { + return Key('SpaceHeader.${space.name}'); + } + + static Key chooserIcon(Space space) { + return Key('DrawerChooser${space}AvatarKey'); + } + + static Key userMenuContainer(Space space) { + return Key('Drawer${space}MenuKey'); + } + + static Key userSectionHeader(Space space) { + return Key('Header.${space.name}'); + } + + static void guestLooksAsExpected(PatrolTester $) { + expect($(closeBtn), findsOneWidget); + expect($(allSpacesBtn), findsOneWidget); + expect($(chooserPrevBtn), findsOneWidget); + SelectorUtils.isDisabled($, $(chooserPrevBtn)); + expect($(chooserNextBtn), findsOneWidget); + expect($(chooserItemContainer), findsExactly(5)); + expect( + $(chooserIcon(Space.discovery)), + findsOneWidget, + ); + } + + static Future userLooksAsExpected(PatrolTester $) async { + for (final space in Space.values) { + await $(SpacesDrawerPage.chooserItem(space)).tap(); + switch (space) { + case Space.discovery: + userDiscoveryLooksAsExpected($); + break; + case Space.workspace: + userWorkspaceLooksAsExpected($); + break; + case Space.voting: + userVotingLooksAsExpected($); + break; + case Space.fundedProjects: + userFundedProjectsLooksAsExpected($); + break; + case Space.treasury: + userTreasuryLooksAsExpected($); + break; + } + } + } + + static void userDiscoveryLooksAsExpected(PatrolTester $) { + expect( + $(userMenuContainer(Space.discovery)).$(userHeader(Space.discovery)), + findsOneWidget, + ); + expect( + $(userMenuContainer(Space.discovery)).$(userDiscoveryDashboardTile), + findsOneWidget, + ); + expect( + $(userMenuContainer(Space.discovery)).$(userRolesTile), + findsOneWidget, + ); + expect( + $(userMenuContainer(Space.discovery)).$(userFeedbackTile), + findsOneWidget, + ); + expect( + $(userMenuContainer(Space.discovery)).$(userDocumentationTile), + findsOneWidget, + ); + } + + static void userWorkspaceLooksAsExpected(PatrolTester $) { + expect( + $(userMenuContainer(Space.workspace)).$(userHeader(Space.workspace)), + findsOneWidget, + ); + expect( + $(userMenuContainer(Space.workspace)) + .$(userSectionHeader(Space.workspace)), + findsOneWidget, + ); + final children = find.descendant( + of: $(userMenuContainer(Space.workspace)), + matching: $(userDrawerMenuItem), + ); + expect($(children), findsAtLeast(1)); + } + + static void userVotingLooksAsExpected(PatrolTester $) { + expect( + $(userMenuContainer(Space.voting)).$(userHeader(Space.voting)), + findsOneWidget, + ); + expect( + $(userMenuContainer(Space.voting)).$(userSectionHeader(Space.voting)), + findsOneWidget, + ); + final children = find.descendant( + of: $(userMenuContainer(Space.voting)), + matching: $(userDrawerMenuItem), + ); + expect($(children), findsAtLeast(1)); + } + + static void userFundedProjectsLooksAsExpected(PatrolTester $) { + expect( + $(userMenuContainer(Space.fundedProjects)), + findsOneWidget, + ); + } + + static void userTreasuryLooksAsExpected(PatrolTester $) { + expect( + $(userMenuContainer(Space.treasury)).$(userHeader(Space.treasury)), + findsOneWidget, + ); + expect( + $(userMenuContainer(Space.treasury)).$(userSectionHeader(Space.treasury)), + findsOneWidget, + ); + final children = find.descendant( + of: $(userMenuContainer(Space.treasury)), + matching: $(userDrawerMenuItem), + ); + expect($(children), findsAtLeast(1)); + } +} diff --git a/catalyst_voices/apps/voices/integration_test/utils/selector_utils.dart b/catalyst_voices/apps/voices/integration_test/utils/selector_utils.dart new file mode 100644 index 00000000000..67a0c980078 --- /dev/null +++ b/catalyst_voices/apps/voices/integration_test/utils/selector_utils.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:patrol_finders/patrol_finders.dart'; + +class SelectorUtils { + static void isDisabled( + PatrolTester $, + PatrolFinder widget, { + bool? reverse = false, + }) { + final widgetProps = $.tester.widget(widget).toString().split('(').last; + final expectedState = reverse! ? 'enabled' : 'disabled'; + expect( + widgetProps.contains('disabled'), + !reverse, + reason: 'Expected $expectedState (${widget.description})', + ); + } + + static void isEnabled(PatrolTester $, PatrolFinder widget) { + isDisabled($, widget, reverse: true); + } +} diff --git a/catalyst_voices/apps/voices/lib/configs/bootstrap.dart b/catalyst_voices/apps/voices/lib/configs/bootstrap.dart index 32a9a8b98a6..05578a7c649 100644 --- a/catalyst_voices/apps/voices/lib/configs/bootstrap.dart +++ b/catalyst_voices/apps/voices/lib/configs/bootstrap.dart @@ -77,6 +77,30 @@ Future _doBootstrapAndRun(BootstrapWidgetBuilder builder) async { await _runApp(app); } +@visibleForTesting +GoRouter buildAppRouter({ + String? initialLocation, +}) { + return AppRouter.init( + initialLocation: initialLocation, + guards: const [ + MilestoneGuard(), + ], + ); +} + +@visibleForTesting +Future registerDependencies({required AppConfig config}) async { + if (!Dependencies.instance.isInitialized) { + await Dependencies.instance.init(config: config); + } +} + +@visibleForTesting +Future restartDependencies() async { + await Dependencies.instance.reset; +} + /// Initializes the application before it can be run. Should setup all /// the things which are necessary before the actual app is run, /// either via [runApp] or injected into a test environment during @@ -84,7 +108,9 @@ Future _doBootstrapAndRun(BootstrapWidgetBuilder builder) async { /// /// Initialization logic that is relevant for [runApp] scenario /// only should be added to [_doBootstrapAndRun], not here. -Future bootstrap() async { +Future bootstrap({ + GoRouter? router, +}) async { _loggingService ..level = kDebugMode ? Level.FINER : Level.OFF ..printLogs = kDebugMode; @@ -97,16 +123,12 @@ Future bootstrap() async { .getAppConfig() .onError((error, stackTrace) => const AppConfig()); - await Dependencies.instance.init(config: config); + await registerDependencies(config: config); // Key derivation needs to be initialized before it can be used await CatalystKeyDerivation.init(); - final router = AppRouter.init( - guards: const [ - MilestoneGuard(), - ], - ); + router ??= buildAppRouter(); Bloc.observer = AppBlocObserver(); diff --git a/catalyst_voices/apps/voices/lib/dependency/dependencies.dart b/catalyst_voices/apps/voices/lib/dependency/dependencies.dart index 9c0037f6173..9bfd7421253 100644 --- a/catalyst_voices/apps/voices/lib/dependency/dependencies.dart +++ b/catalyst_voices/apps/voices/lib/dependency/dependencies.dart @@ -14,6 +14,8 @@ import 'package:shared_preferences/shared_preferences.dart'; final class Dependencies extends DependencyProvider { static final Dependencies instance = Dependencies._(); + bool _isInitialized = false; + Dependencies._(); Future init({ @@ -27,6 +29,17 @@ final class Dependencies extends DependencyProvider { _registerServices(); _registerRepositories(); _registerBlocsWithDependencies(); + + _isInitialized = true; + } + + bool get isInitialized => _isInitialized; + + @override + Future get reset { + return super.reset.whenComplete(() { + _isInitialized = false; + }); } void _registerBlocsWithDependencies() { diff --git a/catalyst_voices/apps/voices/lib/pages/discovery/toggle_state_text.dart b/catalyst_voices/apps/voices/lib/pages/discovery/toggle_state_text.dart index 292653edb1d..f5099724716 100644 --- a/catalyst_voices/apps/voices/lib/pages/discovery/toggle_state_text.dart +++ b/catalyst_voices/apps/voices/lib/pages/discovery/toggle_state_text.dart @@ -1,5 +1,4 @@ import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; -import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -56,35 +55,44 @@ class _ToggleStateTextState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Text.rich( - TextSpan( - children: [ - const TextSpan(text: 'Toggle between'), - const TextSpan(text: ', '), - TextSpan( - text: 'No key (visitor)', - style: const TextStyle(decoration: TextDecoration.underline), - recognizer: _tapVisitor, + return Row( + children: [ + GestureDetector( + key: const Key('VisitorShortcut'), + onTap: _tapVisitor.onTap, + child: const Text( + 'No key (visitor)', + style: TextStyle( + decoration: TextDecoration.underline, + color: Colors.blue, + ), ), - const TextSpan(text: ', '), - TextSpan( - text: 'Key found(Guest/locked)', - style: const TextStyle(decoration: TextDecoration.underline), - recognizer: _tapGuest, + ), + const Text(', '), + GestureDetector( + key: const Key('GuestShortcut'), + onTap: _tapGuest.onTap, + child: const Text( + 'Key found(Guest/locked)', + style: TextStyle( + decoration: TextDecoration.underline, + color: Colors.blue, + ), ), - const TextSpan(text: ', '), - TextSpan( - text: 'Key found (Active user/unlocked)', - style: const TextStyle(decoration: TextDecoration.underline), - recognizer: _tapActiveUser, + ), + const Text(', '), + GestureDetector( + key: const Key('UserShortcut'), + onTap: _tapActiveUser.onTap, + child: const Text( + 'Key found (Active user/unlocked)', + style: TextStyle( + decoration: TextDecoration.underline, + color: Colors.blue, + ), ), - ], - ), - style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colors.textOnPrimary, - ), + ), + ], ); } } diff --git a/catalyst_voices/apps/voices/lib/pages/overall_spaces/spaces_overview_list_view.dart b/catalyst_voices/apps/voices/lib/pages/overall_spaces/spaces_overview_list_view.dart index cc8bd3e0c2c..60b0ef79e58 100644 --- a/catalyst_voices/apps/voices/lib/pages/overall_spaces/spaces_overview_list_view.dart +++ b/catalyst_voices/apps/voices/lib/pages/overall_spaces/spaces_overview_list_view.dart @@ -31,6 +31,7 @@ class _SpacesListViewState extends State { @override Widget build(BuildContext context) { return VoicesScrollbar( + key: const Key('SpacesListView'), controller: _scrollController, alwaysVisible: true, padding: const EdgeInsets.symmetric(horizontal: 6), @@ -43,17 +44,24 @@ class _SpacesListViewState extends State { padding: const EdgeInsets.only(right: 16, bottom: 24), itemBuilder: (context, index) { final space = state[index]; + return switch (space) { - Space.discovery => DiscoveryOverview(key: ObjectKey(space)), - Space.workspace => WorkspaceOverview(key: ObjectKey(space)), + Space.discovery => + DiscoveryOverview(key: Key('SpaceOverview.${space.name}')), + Space.workspace => + WorkspaceOverview(key: Key('SpaceOverview.${space.name}')), Space.voting => GreyOutContainer( - child: VotingOverview(key: ObjectKey(space)), + child: VotingOverview( + key: Key('SpaceOverview.${space.name}'), + ), ), Space.fundedProjects => GreyOutContainer( - child: FundedProjectsOverview(key: ObjectKey(space)), + child: FundedProjectsOverview( + key: Key('SpaceOverview.${space.name}'), + ), ), Space.treasury => TreasuryOverview( - key: ObjectKey(space), + key: Key('SpaceOverview.${space.name}'), ), }; }, diff --git a/catalyst_voices/apps/voices/lib/pages/registration/get_started/get_started_panel.dart b/catalyst_voices/apps/voices/lib/pages/registration/get_started/get_started_panel.dart index 04cef3fd3eb..7add0d58cd5 100644 --- a/catalyst_voices/apps/voices/lib/pages/registration/get_started/get_started_panel.dart +++ b/catalyst_voices/apps/voices/lib/pages/registration/get_started/get_started_panel.dart @@ -20,6 +20,7 @@ class GetStartedPanel extends StatelessWidget { children: [ const SizedBox(height: 24), RegistrationStageMessage( + key: const Key('GetStartedMessage'), title: Text(context.l10n.accountCreationGetStartedTitle), subtitle: Text(context.l10n.accountCreationGetStatedDesc), spacing: 12, diff --git a/catalyst_voices/apps/voices/lib/pages/spaces/drawer/discovery_menu.dart b/catalyst_voices/apps/voices/lib/pages/spaces/drawer/discovery_menu.dart index fa588ba1df4..edfe293e62b 100644 --- a/catalyst_voices/apps/voices/lib/pages/spaces/drawer/discovery_menu.dart +++ b/catalyst_voices/apps/voices/lib/pages/spaces/drawer/discovery_menu.dart @@ -11,10 +11,12 @@ class DiscoveryDrawerMenu extends StatelessWidget { @override Widget build(BuildContext context) { return Column( + key: const ValueKey('DiscoveryDrawerMenuItems'), mainAxisSize: MainAxisSize.min, children: [ const SpaceHeader(Space.discovery), VoicesNavTile( + key: const ValueKey('DiscoveryDashboardTile'), leading: VoicesAssets.icons.home.buildIcon(), name: 'Discovery Dashboard', backgroundColor: Space.discovery.backgroundColor(context), @@ -22,17 +24,20 @@ class DiscoveryDrawerMenu extends StatelessWidget { ), const VoicesDivider(), VoicesNavTile( + key: const ValueKey('RolesTile'), leading: VoicesAssets.icons.user.buildIcon(), name: 'Catalyst Roles', onTap: () => Scaffold.of(context).closeDrawer(), ), VoicesNavTile( + key: const ValueKey('FeedbackTile'), leading: VoicesAssets.icons.annotation.buildIcon(), name: 'Feedback', onTap: () => Scaffold.of(context).closeDrawer(), ), const VoicesDivider(), VoicesNavTile( + key: const ValueKey('DocumentationTile'), leading: VoicesAssets.icons.arrowRight.buildIcon(), name: 'Catalyst Gitbook documentation', onTap: () => Scaffold.of(context).closeDrawer(), diff --git a/catalyst_voices/apps/voices/lib/pages/spaces/drawer/guest_menu.dart b/catalyst_voices/apps/voices/lib/pages/spaces/drawer/guest_menu.dart index bc2df6ec182..f176fa0e5a5 100644 --- a/catalyst_voices/apps/voices/lib/pages/spaces/drawer/guest_menu.dart +++ b/catalyst_voices/apps/voices/lib/pages/spaces/drawer/guest_menu.dart @@ -15,6 +15,7 @@ class GuestMenu extends StatelessWidget { @override Widget build(BuildContext context) { return Column( + key: const ValueKey('GuestMenuItems'), mainAxisSize: MainAxisSize.min, children: [ VoicesNavTile( diff --git a/catalyst_voices/apps/voices/lib/pages/spaces/drawer/individual_private_campaigns.dart b/catalyst_voices/apps/voices/lib/pages/spaces/drawer/individual_private_campaigns.dart index 37b49509d90..c75dec37d76 100644 --- a/catalyst_voices/apps/voices/lib/pages/spaces/drawer/individual_private_campaigns.dart +++ b/catalyst_voices/apps/voices/lib/pages/spaces/drawer/individual_private_campaigns.dart @@ -13,6 +13,7 @@ class IndividualPrivateCampaigns extends StatelessWidget { children: [ const SpaceHeader(Space.treasury), const SectionHeader( + key: ValueKey('Header.treasury'), leading: SizedBox(width: 12), title: Text('Individual private campaigns'), ), diff --git a/catalyst_voices/apps/voices/lib/pages/spaces/drawer/my_private_proposals.dart b/catalyst_voices/apps/voices/lib/pages/spaces/drawer/my_private_proposals.dart index f05f56257e0..f29a148d347 100644 --- a/catalyst_voices/apps/voices/lib/pages/spaces/drawer/my_private_proposals.dart +++ b/catalyst_voices/apps/voices/lib/pages/spaces/drawer/my_private_proposals.dart @@ -13,6 +13,7 @@ class MyPrivateProposals extends StatelessWidget { children: [ SpaceHeader(Space.workspace), SectionHeader( + key: ValueKey('Header.workspace'), leading: SizedBox(width: 12), title: Text('My private proposals (3/5)'), ), diff --git a/catalyst_voices/apps/voices/lib/pages/spaces/drawer/space_header.dart b/catalyst_voices/apps/voices/lib/pages/spaces/drawer/space_header.dart index 724c6d72db3..2814d0808e1 100644 --- a/catalyst_voices/apps/voices/lib/pages/spaces/drawer/space_header.dart +++ b/catalyst_voices/apps/voices/lib/pages/spaces/drawer/space_header.dart @@ -19,6 +19,7 @@ class SpaceHeader extends StatelessWidget { final theme = Theme.of(context); return Container( + key: Key('SpaceHeader.${data.name}'), padding: const EdgeInsets.symmetric(vertical: 14) .add(const EdgeInsets.only(left: 16)), child: Row( diff --git a/catalyst_voices/apps/voices/lib/pages/spaces/drawer/spaces_drawer.dart b/catalyst_voices/apps/voices/lib/pages/spaces/drawer/spaces_drawer.dart index 4d5cb01995c..785038549df 100644 --- a/catalyst_voices/apps/voices/lib/pages/spaces/drawer/spaces_drawer.dart +++ b/catalyst_voices/apps/voices/lib/pages/spaces/drawer/spaces_drawer.dart @@ -67,6 +67,7 @@ class _SpacesDrawerState extends State { bottom: !widget.isUnlocked ? null : VoicesDrawerSpaceChooser( + key: const ValueKey('DrawerSpaceChooser'), currentSpace: widget.space, onChanged: (space) => space.go(context), onOverallTap: () { diff --git a/catalyst_voices/apps/voices/lib/pages/spaces/drawer/voting_rounds.dart b/catalyst_voices/apps/voices/lib/pages/spaces/drawer/voting_rounds.dart index 598cedd01ab..6455b514681 100644 --- a/catalyst_voices/apps/voices/lib/pages/spaces/drawer/voting_rounds.dart +++ b/catalyst_voices/apps/voices/lib/pages/spaces/drawer/voting_rounds.dart @@ -14,6 +14,7 @@ class VotingRounds extends StatelessWidget { children: [ const SpaceHeader(Space.voting), const SectionHeader( + key: ValueKey('Header.voting'), leading: SizedBox(width: 12), title: Text('Active funding rounds'), ), diff --git a/catalyst_voices/apps/voices/lib/routes/app_router.dart b/catalyst_voices/apps/voices/lib/routes/app_router.dart index 5fb2fa2623d..3a7925c7445 100644 --- a/catalyst_voices/apps/voices/lib/routes/app_router.dart +++ b/catalyst_voices/apps/voices/lib/routes/app_router.dart @@ -13,12 +13,13 @@ abstract final class AppRouter { ); static GoRouter init({ + String? initialLocation, List guards = const [], Listenable? refreshListenable, }) { return GoRouter( navigatorKey: _rootNavigatorKey, - initialLocation: Routes.initialLocation, + initialLocation: initialLocation ?? Routes.initialLocation, redirect: (context, state) async => _guard(context, state, guards), observers: [ SentryNavigatorObserver(), diff --git a/catalyst_voices/apps/voices/lib/widgets/app_bar/session/session_action_header.dart b/catalyst_voices/apps/voices/lib/widgets/app_bar/session/session_action_header.dart index daef794462f..4c09f181e65 100644 --- a/catalyst_voices/apps/voices/lib/widgets/app_bar/session/session_action_header.dart +++ b/catalyst_voices/apps/voices/lib/widgets/app_bar/session/session_action_header.dart @@ -39,6 +39,7 @@ class _GetStartedButton extends StatelessWidget { @override Widget build(BuildContext context) { return VoicesFilledButton( + key: const Key('GetStartedButton'), onTap: () => unawaited(RegistrationDialog.show(context)), child: Text(context.l10n.getStarted), ); diff --git a/catalyst_voices/apps/voices/lib/widgets/buttons/voices_buttons.dart b/catalyst_voices/apps/voices/lib/widgets/buttons/voices_buttons.dart index 408077d9fd8..27d91ad462b 100644 --- a/catalyst_voices/apps/voices/lib/widgets/buttons/voices_buttons.dart +++ b/catalyst_voices/apps/voices/lib/widgets/buttons/voices_buttons.dart @@ -15,6 +15,7 @@ class DrawerToggleButton extends StatelessWidget { @override Widget build(BuildContext context) { return VoicesIconButton( + key: const Key('DrawerButton'), onTap: () => Scaffold.maybeOf(context)?.openDrawer(), child: VoicesAssets.icons.menu.buildIcon(), ); diff --git a/catalyst_voices/apps/voices/lib/widgets/drawer/voices_drawer.dart b/catalyst_voices/apps/voices/lib/widgets/drawer/voices_drawer.dart index 730851c1b04..528a08c6cb4 100644 --- a/catalyst_voices/apps/voices/lib/widgets/drawer/voices_drawer.dart +++ b/catalyst_voices/apps/voices/lib/widgets/drawer/voices_drawer.dart @@ -116,13 +116,16 @@ class VoicesDrawerChooser extends StatelessWidget { children: [ if (leading != null) leading!, IconButton( + key: const ValueKey('DrawerChooserPreviousButton'), onPressed: _selectedIndex > 0 ? _onSelectPrevious : null, icon: VoicesAssets.icons.chevronLeft.buildIcon(size: 20), ), for (final item in items) MouseRegion( + key: ValueKey('DrawerChooser$item'), cursor: SystemMouseCursors.click, child: GestureDetector( + key: const ValueKey('DrawerChooserItem'), behavior: HitTestBehavior.opaque, onTap: () => onSelected(item), child: itemBuilder( @@ -133,6 +136,7 @@ class VoicesDrawerChooser extends StatelessWidget { ), ), IconButton( + key: const ValueKey('DrawerChooserNextButton'), onPressed: _selectedIndex < (items.length - 1) ? _onSelectNext : null, icon: VoicesAssets.icons.chevronRight.buildIcon(size: 20), diff --git a/catalyst_voices/apps/voices/lib/widgets/drawer/voices_drawer_space_chooser.dart b/catalyst_voices/apps/voices/lib/widgets/drawer/voices_drawer_space_chooser.dart index 1c89dfda4e6..c67ec723a2e 100644 --- a/catalyst_voices/apps/voices/lib/widgets/drawer/voices_drawer_space_chooser.dart +++ b/catalyst_voices/apps/voices/lib/widgets/drawer/voices_drawer_space_chooser.dart @@ -32,6 +32,7 @@ class VoicesDrawerSpaceChooser extends StatelessWidget { onSelected: onChanged, itemBuilder: _itemBuilder, leading: VoicesIconButton( + key: const ValueKey('DrawerChooserAllSpacesButton'), onTap: onOverallTap, child: VoicesAssets.icons.allSpacesMenu.buildIcon(size: 20), ), diff --git a/catalyst_voices/apps/voices/lib/widgets/headers/brand_header.dart b/catalyst_voices/apps/voices/lib/widgets/headers/brand_header.dart index 5cd46afd663..3e5c0f49ba5 100644 --- a/catalyst_voices/apps/voices/lib/widgets/headers/brand_header.dart +++ b/catalyst_voices/apps/voices/lib/widgets/headers/brand_header.dart @@ -19,6 +19,7 @@ class BrandHeader extends StatelessWidget { children: [ Theme.of(context).brandAssets.brand.logo(context).buildPicture(), IconButton( + key: const ValueKey('MenuCloseButton'), onPressed: Navigator.of(context).pop, icon: VoicesAssets.icons.x.buildIcon(size: 22), ), diff --git a/catalyst_voices/apps/voices/lib/widgets/tiles/voices_nav_tile.dart b/catalyst_voices/apps/voices/lib/widgets/tiles/voices_nav_tile.dart index fb27a289a3a..70e2d515ff6 100644 --- a/catalyst_voices/apps/voices/lib/widgets/tiles/voices_nav_tile.dart +++ b/catalyst_voices/apps/voices/lib/widgets/tiles/voices_nav_tile.dart @@ -42,6 +42,7 @@ class VoicesNavTile extends StatelessWidget { ); return IconTheme( + key: const ValueKey('UserDrawerMenuItem'), data: iconTheme, child: IconButtonTheme( data: const IconButtonThemeData(style: iconButtonStyle), diff --git a/catalyst_voices/apps/voices/lib/widgets/toggles/voices_theme_mode_switch.dart b/catalyst_voices/apps/voices/lib/widgets/toggles/voices_theme_mode_switch.dart index d93aabca0cb..4211d352938 100644 --- a/catalyst_voices/apps/voices/lib/widgets/toggles/voices_theme_mode_switch.dart +++ b/catalyst_voices/apps/voices/lib/widgets/toggles/voices_theme_mode_switch.dart @@ -19,6 +19,7 @@ class VoicesThemeModeSwitch extends StatelessWidget { Text('${context.l10n.themeLight} / ${context.l10n.themeDark}'), const SizedBox(width: 8), VoicesSwitch( + key: const Key('ThemeSwitch'), value: Theme.of(context).brightness == Brightness.dark, onChanged: (value) { onChanged( diff --git a/catalyst_voices/apps/voices/pubspec.yaml b/catalyst_voices/apps/voices/pubspec.yaml index dde231d2121..2fdddf8d30e 100644 --- a/catalyst_voices/apps/voices/pubspec.yaml +++ b/catalyst_voices/apps/voices/pubspec.yaml @@ -80,6 +80,7 @@ dev_dependencies: sdk: flutter mockito: ^5.4.4 mocktail: ^1.0.1 + patrol_finders: ^2.5.1 sentry_dart_plugin: ^2.1.0 flutter: diff --git a/catalyst_voices/apps/voices/test/widgets/empty_state/empty_state_test.dart b/catalyst_voices/apps/voices/test/widgets/empty_state/empty_state_test.dart deleted file mode 100644 index d19e3df0b8b..00000000000 --- a/catalyst_voices/apps/voices/test/widgets/empty_state/empty_state_test.dart +++ /dev/null @@ -1,131 +0,0 @@ -import 'package:catalyst_voices/widgets/empty_state/empty_state.dart'; -import 'package:catalyst_voices/widgets/images/voices_image_scheme.dart'; -import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; -import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../../helpers/helpers.dart'; - -void main() { - group('EmptyState Widget Tests', () { - testWidgets('Renders correctly with default values', (tester) async { - await tester.pumpApp( - const EmptyState(), - ); - await tester.pumpAndSettle(); - - expect(find.byType(CatalystSvgPicture), findsOneWidget); - expect(find.byType(Text), findsNWidgets(2)); - expect(find.text('No draft proposals yet'), findsOneWidget); - expect( - find.text( - // ignore: lines_longer_than_80_chars - 'Discovery space will show draft proposals you can comment on, currently there are no draft proposals.', - ), - findsOneWidget, - ); - }); - - testWidgets('Renders correctly with custom values', (tester) async { - await tester.pumpApp( - const EmptyState( - title: 'Custom Title', - description: 'Custom Description', - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(CatalystSvgPicture), findsOneWidget); - expect(find.text('Custom Title'), findsOneWidget); - expect(find.text('Custom Description'), findsOneWidget); - }); - - testWidgets('Uses correct custom color scheme', (tester) async { - const colors = - VoicesColorScheme.optional(textOnPrimaryLevel1: Colors.red); - await tester.pumpApp( - voicesColors: colors, - const EmptyState( - title: 'Custom Title', - description: 'Custom Description', - ), - ); - await tester.pumpAndSettle(); - - final titleText = tester.widget( - find.byType(Text).first, - ); - - expect( - titleText.style?.color, - colors.textOnPrimaryLevel1, - ); - - final descriptionText = tester.widget( - find.byType(Text).last, - ); - - expect( - descriptionText.style?.color, - colors.textOnPrimaryLevel1, - ); - }); - - testWidgets( - 'Proposal image changes depending on theme brightness', - (tester) async { - // Given - const widget = EmptyState(); - - // When - Light theme - await tester.pumpApp( - widget, - theme: ThemeData(brightness: Brightness.light), - voicesColors: const VoicesColorScheme.optional(), - ); - await tester.pumpAndSettle(); - - // Then - Light theme - final lightThemeImage = tester.widget( - find.byType(CatalystSvgPicture), - ); - expect( - lightThemeImage, - isA(), - ); - - // When - Dark theme - await tester.pumpApp( - widget, - theme: ThemeData(brightness: Brightness.dark), - voicesColors: const VoicesColorScheme.optional(), - ); - await tester.pumpAndSettle(); - - // Then - Dark theme - final darkThemeImage = tester.widget( - find.byType(CatalystSvgPicture), - ); - expect( - darkThemeImage, - isA(), - ); - }, - ); - - testWidgets('Renders correctly with custom image', (tester) async { - await tester.pumpApp( - EmptyState( - image: CatalystSvgPicture.asset( - VoicesAssets.images.noProposalForeground.path, - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(CatalystSvgPicture), findsOneWidget); - expect(find.byType(VoicesImagesScheme), findsNothing); - }); - }); -} diff --git a/catalyst_voices/blueprint.cue b/catalyst_voices/blueprint.cue index 4f50b953086..6a295fb274e 100644 --- a/catalyst_voices/blueprint.cue +++ b/catalyst_voices/blueprint.cue @@ -2,6 +2,10 @@ version: "1.0.0" project: { name: "voices" deployment: { + on: { + merge: {} + tag: {} + } environment: "dev" modules: main: { container: "voices-deployment" @@ -9,7 +13,7 @@ project: { values: { environment: name: "dev" frontend: image: { - tag: _ @forge(name="GIT_COMMIT_HASH") + tag: _ @forge(name="GIT_HASH_OR_TAG") } } } @@ -21,7 +25,7 @@ project: { tag: {} } config: { - tag: _ @forge(name="GIT_COMMIT_HASH") + tag: _ @forge(name="GIT_HASH_OR_TAG") } } } diff --git a/catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/no_proposal_foreground.svg b/catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/no_proposal_foreground.svg deleted file mode 100644 index df9c9749f62..00000000000 --- a/catalyst_voices/packages/internal/catalyst_voices_assets/assets/images/no_proposal_foreground.svg +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/.nvmrc b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/.nvmrc new file mode 100644 index 00000000000..238155bf8d1 --- /dev/null +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/.nvmrc @@ -0,0 +1 @@ +v20.12.2 \ No newline at end of file diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/Earthfile b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/Earthfile index 666e46261d6..abfda491cbb 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/Earthfile +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/Earthfile @@ -13,11 +13,12 @@ deps: src: FROM +deps - COPY keys.txt . + COPY --dir pages ./pages + COPY --dir tests ./tests + COPY --dir utils ./utils COPY playwright.config.ts . - COPY global-setup.ts . - COPY wallet-tests.spec.ts . - COPY utils.ts . + COPY test-fixtures.ts . + COPY setup.ts . package-test: FROM +src @@ -42,7 +43,9 @@ build-web: package-app: FROM nginx:alpine3.20-slim ARG tag='latest' - COPY +build-web/web /usr/share/nginx/html/ + COPY +build-web/web /app + COPY ./nginx.conf /etc/nginx/nginx.conf + EXPOSE 80 SAVE IMAGE test-app:$tag nightly-test: @@ -51,12 +54,10 @@ nightly-test: WITH DOCKER \ --compose compose.yml \ --load test-app:latest=(+package-app) \ - --load test:latest=(+package-test) \ - --service test-app \ - --allow-privileged + --load test:latest=(+package-test) - RUN docker run --network=default_default --name=test test:latest && \ - docker cp test:/results/cardano-wallet.junit-report.xml cardano-wallet.junit-report.xml + RUN docker run --network=default_default --name=test test:latest ; \ + docker cp test:/results/cardano-wallet.junit-report.xml cardano-wallet.junit-report.xml ; END WAIT SAVE ARTIFACT cardano-wallet.junit-report.xml AS LOCAL cardano-wallet.junit-report.xml diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/README.md b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/README.md new file mode 100644 index 00000000000..e72dcf9dc98 --- /dev/null +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/README.md @@ -0,0 +1,49 @@ +# Wallet Automation + +Welcome to wallet automation, a testing package in Playwright that tests wallet integration for Catalyst Voices. + +## Introduction + +Wallet automation is a testing package in Playwright that automates the wallet creation process for the Catalyst project. +It is a part of the Catalyst Voices ecosystem. + +## Getting Started + +1. Clone this repository: + + ```sh + git clone + cd catalyst-voices + ``` + +2. Install Flutter and Dart: + + ```sh + brew install flutter + ``` + +3. Bootstrap the project: + + ```sh + melos bootstrap + ``` + +4. Execute earthly command from this directory: + + ```sh + earthly +package-app + ``` + +5. Use docker compose to run the app: + + ```sh + docker compose up + ``` + + The app should be running on `localhost:8000`. + +6. You can now run tests with the following command: + + ```sh + npx playwright test + ``` diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/compose.yml b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/compose.yml index eea27db6567..f516dd96b9d 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/compose.yml +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/compose.yml @@ -3,4 +3,4 @@ services: test-app: image: test-app:latest ports: - - 8000:80 \ No newline at end of file + - "8000:80" \ No newline at end of file diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/global-setup.ts b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/global-setup.ts deleted file mode 100644 index e65049e504b..00000000000 --- a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/global-setup.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { test as setup } from '@playwright/test'; -import * as fs from 'fs'; -import * as path from 'path'; - -setup('Load wallet keys', async ({ }) => { -const txtContent = fs.readFileSync(path.resolve(__dirname,'keys.txt'), 'utf8'); -txtContent.split('\n').forEach(line => { - const [key, value] = line.split('='); - if (key && value) { - process.env[key.trim()] = value.trim(); - } - }); -}); \ No newline at end of file diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/keys.txt b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/keys.txt deleted file mode 100644 index 12a00302afa..00000000000 --- a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/keys.txt +++ /dev/null @@ -1,5 +0,0 @@ -# .txt file -//WALLET1 -WALLET1_USERNAME=test123 -WALLET1_PASSWORD=test12345678@ -WALLET1_SEED_WORD=stomach,horn,rail,afraid,flip,also,abandon,speed,chaos,daring,soon,soft,okay,online,benefit \ No newline at end of file diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/nginx.conf b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/nginx.conf new file mode 100644 index 00000000000..7fcc05e6bbd --- /dev/null +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/nginx.conf @@ -0,0 +1,40 @@ +user nginx; +worker_processes 1; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; +events { + worker_connections 1024; +} +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + sendfile on; + keepalive_timeout 65; + server { + listen 80; + server_name localhost; + + # https://cjycode.com/flutter_rust_bridge/manual/miscellaneous/web-cross-origin#background + add_header Cross-Origin-Opener-Policy "same-origin"; + add_header Cross-Origin-Embedder-Policy "require-corp"; + + location / { + root /app; + index index.html; + try_files $uri $uri/ /index.html; + } + # Ensure that /m4 (and any other SPA path) serves index.html + location /m4 { + root /app; + try_files $uri $uri/ /index.html; + } + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } + } +} \ No newline at end of file diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/package-lock.json b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/package-lock.json index cf27ca6fc0a..3719a91ad85 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/package-lock.json +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/package-lock.json @@ -9,26 +9,27 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@tomjs/unzip-crx": "^1.1.3", "@types/node-fetch": "^2.6.11", "dotenv": "^16.3.1", "fs-extra": "^11.2.0", "install": "^0.13.0", "node-fetch": "^2.6.7", - "playwright": "^1.45.3", + "playwright": "^1.48.0", "unzip-crx-3": "^0.2.0" }, "devDependencies": { - "@playwright/test": "^1.45.3", + "@playwright/test": "^1.48.0", "@types/node": "^20.14.12" } }, "node_modules/@playwright/test": { - "version": "1.45.3", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.45.3.tgz", - "integrity": "sha512-UKF4XsBfy+u3MFWEH44hva1Q8Da28G6RFtR2+5saw+jgAFQV5yYnB1fu68Mz7fO+5GJF3wgwAIs0UelU8TxFrA==", + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.48.0.tgz", + "integrity": "sha512-W5lhqPUVPqhtc/ySvZI5Q8X2ztBOUgZ8LbAFy0JQgrXZs2xaILrUcNO3rQjwbLPfGK13+rZsDa1FpG+tqYkT5w==", "dev": true, "dependencies": { - "playwright": "1.45.3" + "playwright": "1.48.0" }, "bin": { "playwright": "cli.js" @@ -37,12 +38,23 @@ "node": ">=18" } }, + "node_modules/@tomjs/unzip-crx": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@tomjs/unzip-crx/-/unzip-crx-1.1.3.tgz", + "integrity": "sha512-uqolp78TcG5q2ZBOZ57Nf7m7o3kaKAz1E9uFf4FCSO/nCI11HaDWpw7PaGUk1MImeIjNradiLpT2b9kTKSs4uw==", + "dependencies": { + "jszip": "^3.10.1" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/@types/node": { - "version": "20.14.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.12.tgz", - "integrity": "sha512-r7wNXakLeSsGT0H1AU863vS2wa5wBOK4bWMjZz2wj+8nBx+m5PeIn0k8AloSLpRuiwdRQZwarZqHE4FNArPuJQ==", + "version": "20.16.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.11.tgz", + "integrity": "sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/@types/node-fetch": { @@ -84,20 +96,20 @@ } }, "node_modules/dotenv": { - "version": "16.3.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", - "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", "engines": { "node": ">=12" }, "funding": { - "url": "https://github.com/motdotla/dotenv?sponsor=1" + "url": "https://dotenvx.com" } }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -230,9 +242,9 @@ } }, "node_modules/node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "dependencies": { "whatwg-url": "^5.0.0" }, @@ -254,11 +266,11 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" }, "node_modules/playwright": { - "version": "1.45.3", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.45.3.tgz", - "integrity": "sha512-QhVaS+lpluxCaioejDZ95l4Y4jSFCsBvl2UZkpeXlzxmqS+aABr5c82YmfMHrL6x27nvrvykJAFpkzT2eWdJww==", + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.48.0.tgz", + "integrity": "sha512-qPqFaMEHuY/ug8o0uteYJSRfMGFikhUysk8ZvAtfKmUK3kc/6oNl/y3EczF8OFGYIi/Ex2HspMfzYArk6+XQSA==", "dependencies": { - "playwright-core": "1.45.3" + "playwright-core": "1.48.0" }, "bin": { "playwright": "cli.js" @@ -271,9 +283,9 @@ } }, "node_modules/playwright-core": { - "version": "1.45.3", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.45.3.tgz", - "integrity": "sha512-+ym0jNbcjikaOwwSZycFbwkWgfruWvYlJfThKYAlImbxUgdWFO2oW70ojPm4OpE4t6TAo2FY/smM+hpVTtkhDA==", + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.0.tgz", + "integrity": "sha512-RBvzjM9rdpP7UUFrQzRwR8L/xR4HyC1QXMzGYTbf1vjw25/ya9NRAVnXi/0fvFopjebvyPzsmoK58xxeEOaVvA==", "bin": { "playwright-core": "cli.js" }, @@ -324,9 +336,9 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" }, "node_modules/universalify": { "version": "2.0.1", diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/package.json b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/package.json index cba1e952f6d..af26c6d9bef 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/package.json +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/package.json @@ -25,6 +25,7 @@ "@types/node": "^20.14.12" }, "dependencies": { + "@tomjs/unzip-crx": "^1.1.3", "@types/node-fetch": "^2.6.11", "dotenv": "^16.3.1", "fs-extra": "^11.2.0", diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/pages/homePage.ts b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/pages/homePage.ts new file mode 100644 index 00000000000..8ad84835d00 --- /dev/null +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/pages/homePage.ts @@ -0,0 +1,361 @@ +import { Locator, Page, expect } from "@playwright/test"; +import { WalletConfig } from "../utils/wallets/walletUtils"; +import { Modal, ModalName } from "./modal"; + +export interface UTxO { + tx: string; + index: number; + amount: number; +} + +export interface WalletCipData { + balance: number; + extensions: string[]; + networkId: string; + changeAddress: string; + rewardAddresses: string[]; + unusedAddresses: string[]; + usedAddresses: string[]; + utxos: UTxO[]; + publicDRepKey: string; + registeredPublicStakeKeys: string; + unregisteredPublicStakeKeys: string[]; +} + +export class HomePage { + readonly page: Page; + readonly balanceLabel: Locator; + readonly extensionsLabel: Locator; + readonly networkIdLabel: Locator; + readonly changeAddressLabel: Locator; + readonly rewardAddressesLabel: Locator; + readonly unusedAddressesLabel: Locator; + readonly usedAddressesLabel: Locator; + readonly utxosLabel: Locator; + readonly publicDRepKeyLabel: Locator; + readonly registeredPublicStakeKeysLabel: Locator; + readonly unregisteredPublicStakeKeysLabel: Locator; + readonly signDataButton: Locator; + readonly signAndSubmitTxButton: Locator; + readonly signAndSubmitRBACTxButton: Locator; + + // TODO: Add keys to source app and change locators to locate based on keys + constructor(page: Page) { + this.page = page; + this.balanceLabel = page.getByText(/^Balance: Ada \(lovelaces\):/); + this.extensionsLabel = page.getByText(/^Extensions:/); + this.networkIdLabel = page.getByText(/^Network ID:/); + this.changeAddressLabel = page.getByText(/^Change address:/); + this.rewardAddressesLabel = page.getByText(/^Reward addresses:/); + this.unusedAddressesLabel = page.getByText(/^Unused addresses:/); + this.usedAddressesLabel = page.getByText(/^Used addresses:/); + this.utxosLabel = page.getByText(/^UTXOs:/); + this.publicDRepKeyLabel = page.getByText(/^Public DRep Key:/); + this.registeredPublicStakeKeysLabel = page.getByText( + /^Registered Public Stake Keys:/ + ); + this.unregisteredPublicStakeKeysLabel = page.getByText( + /^Unregistered Public Stake Keys:/ + ); + this.signDataButton = page.getByRole("button", { name: "Sign data" }); + this.signAndSubmitTxButton = page.getByRole("button", { + name: "Sign & submit tx", + }); + this.signAndSubmitRBACTxButton = page.getByRole("button", { + name: "Sign & submit RBAC tx", + }); + } + + async getWalletCipData() { + const walletCipData: WalletCipData = { + balance: 0, + extensions: [], + networkId: "", + changeAddress: "", + rewardAddresses: [], + unusedAddresses: [], + usedAddresses: [], + utxos: [], + publicDRepKey: "", + registeredPublicStakeKeys: "", + unregisteredPublicStakeKeys: [], + }; + await this.balanceLabel.waitFor({ state: "visible", timeout: 10000 }); + walletCipData.balance = await this.getBalance(); + walletCipData.extensions = await this.getExtensions(); + walletCipData.networkId = await this.getNetworkId(); + walletCipData.changeAddress = await this.getChangeAddress(); + walletCipData.rewardAddresses = await this.getRewardAddresses(); + walletCipData.unusedAddresses = await this.getUnusedAddresses(); + walletCipData.usedAddresses = await this.getUsedAddresses(); + walletCipData.utxos = await this.getUTXOs(); + walletCipData.publicDRepKey = await this.getPublicDRepKey(); + walletCipData.registeredPublicStakeKeys = + await this.getRegisteredPublicStakeKeys(); + walletCipData.unregisteredPublicStakeKeys = + await this.getUnregisteredPublicStakeKeys(); + return walletCipData; + } + + async assertModal(modalName: ModalName) { + const modal = new Modal(this.page, modalName); + await modal.assertModalIsVisible(); + } + + async getBalance(): Promise { + const isVisible = await this.balanceLabel.isVisible(); + if (!isVisible) { + throw new Error("Balance label is not visible"); + } + const balanceText = await this.balanceLabel.textContent(); + const match = balanceText?.match(/^Balance: Ada \(lovelaces\): (\d+)/); + if (match && match[1]) { + return Number(match[1]); + } else { + throw new Error(`Unable to extract balance from text: ${balanceText}`); + } + } + + async getExtensions(): Promise { + const isVisible = await this.extensionsLabel.isVisible(); + if (!isVisible) { + throw new Error("Extensions label is not visible"); + } + const extensionsText = await this.extensionsLabel.textContent(); + const match = extensionsText?.trim().match(/^Extensions:\s*(.+)$/); + if (match && match[1]) { + const trimmedText = match[1].trim(); + return trimmedText + .split(",") + .map((ext) => ext.trim()) + .filter((ext) => ext.length > 0); + } else { + throw new Error( + `Unable to extract extensions from text: ${extensionsText}` + ); + } + } + + async getNetworkId(): Promise { + const isVisible = await this.networkIdLabel.isVisible(); + if (!isVisible) { + throw new Error("Network ID label is not visible"); + } + const networkIdText = await this.networkIdLabel.textContent(); + const match = networkIdText?.trim().match(/^Network ID:\s*(.+)$/); + if (match && match[1]) { + return match[1].trim(); + } else { + throw new Error( + `Unable to extract network ID from text: ${networkIdText}` + ); + } + } + + async getChangeAddress(): Promise { + const isVisible = await this.changeAddressLabel.isVisible(); + if (!isVisible) { + throw new Error("Change address label is not visible"); + } + const changeAddressText = await this.changeAddressLabel.textContent(); + const match = changeAddressText?.trim().match(/^Change address:\s*(.+)$/s); + if (match && match[1]) { + return match[1].trim(); + } else { + throw new Error( + `Unable to extract change address from text: ${changeAddressText}` + ); + } + } + + async getRewardAddresses(): Promise { + const isVisible = await this.rewardAddressesLabel.isVisible(); + if (!isVisible) { + throw new Error("Reward addresses label is not visible"); + } + const rewardAddressesText = await this.rewardAddressesLabel.textContent(); + const match = rewardAddressesText?.match(/^Reward addresses:\s*(.+)$/s); + if (match && match[1]) { + const addresses = match[1] + .trim() + .split("\n") + .map((addr) => addr.trim()) + .filter((addr) => addr.length > 0); + return addresses; + } else { + throw new Error( + `Unable to extract reward addresses from text: ${rewardAddressesText}` + ); + } + } + + async getUnusedAddresses(): Promise { + const isVisible = await this.unusedAddressesLabel.isVisible(); + if (!isVisible) { + throw new Error("Unused addresses label is not visible"); + } + const unusedAddressesText = await this.unusedAddressesLabel.textContent(); + const match = unusedAddressesText?.match(/^Unused addresses:\s*(.+)$/s); + if (match && match[1]) { + const addresses = match[1] + .trim() + .split("\n") + .map((addr) => addr.trim()) + .filter((addr) => addr.length > 0); + return addresses; + } else { + throw new Error( + `Unable to extract unused addresses from text: ${unusedAddressesText}` + ); + } + } + + async getUsedAddresses(): Promise { + const isVisible = await this.usedAddressesLabel.isVisible(); + if (!isVisible) { + throw new Error("Used addresses label is not visible"); + } + const usedAddressesText = await this.usedAddressesLabel.textContent(); + const match = usedAddressesText?.match(/^Used addresses:\s*(.+)$/s); + if (match && match[1]) { + const addresses = match[1] + .trim() + .split("\n") + .map((addr) => addr.trim()) + .filter((addr) => addr.length > 0); + return addresses; + } else { + throw new Error( + `Unable to extract used addresses from text: ${usedAddressesText}` + ); + } + } + + async getUTXOs(): Promise { + const isVisible = await this.utxosLabel.isVisible(); + if (!isVisible) { + throw new Error("UTXOs label is not visible"); + } + const utxosText = await this.utxosLabel.textContent(); + const match = utxosText?.match(/^UTXOs:\s*(.+)$/s); + if (match && match[1]) { + const utxosData = match[1].trim(); + const utxoEntries = utxosData + .split(/\n\n+/) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + const utxos: UTxO[] = []; + for (const entry of utxoEntries) { + const txMatch = entry.match(/Tx:\s*([a-fA-F0-9]+)/); + const indexMatch = entry.match(/Index:\s*(\d+)/); + const amountMatch = entry.match(/Amount:\s*Ada \(lovelaces\):\s*(\d+)/); + if (txMatch && indexMatch && amountMatch) { + utxos.push({ + tx: txMatch[1], + index: Number(indexMatch[1]), + amount: Number(amountMatch[1]), + }); + } else { + throw new Error(`Unable to parse UTXO entry: ${entry}`); + } + } + return utxos; + } else { + throw new Error(`Unable to extract UTXOs from text: ${utxosText}`); + } + } + + async getPublicDRepKey(): Promise { + await this.page.waitForTimeout(2000); + const isVisible = await this.publicDRepKeyLabel.isVisible(); + if (!isVisible) { + throw new Error("Public DRep Key label is not visible"); + } + const publicDRepKeyText = await this.publicDRepKeyLabel.textContent(); + const match = publicDRepKeyText + ?.trim() + .match(/^Public DRep Key:\s*([a-fA-F0-9]+)$/); + if (match && match[1]) { + return match[1]; + } else { + throw new Error( + `Unable to extract public DRep key from text: ${publicDRepKeyText}` + ); + } + } + + async getRegisteredPublicStakeKeys(): Promise { + const isVisible = await this.registeredPublicStakeKeysLabel.isVisible(); + if (!isVisible) { + throw new Error("Registered Public Stake Keys label is not visible"); + } + const stakeKeysText = + await this.registeredPublicStakeKeysLabel.textContent(); + const match = stakeKeysText + ?.trim() + .match(/^Registered Public Stake Keys:\s*([a-fA-F0-9]+)$/); + if (match && match[1]) { + return match[1]; + } else { + throw new Error( + `Unable to extract registered public stake keys from text: ${stakeKeysText}` + ); + } + } + + async getUnregisteredPublicStakeKeys(): Promise { + const isVisible = await this.unregisteredPublicStakeKeysLabel.isVisible(); + if (!isVisible) { + throw new Error("Unregistered Public Stake Keys label is not visible"); + } + const keysText = await this.unregisteredPublicStakeKeysLabel.textContent(); + const match = keysText + ?.trim() + .match(/^Unregistered Public Stake Keys:\s*(.*)$/s); + if (match) { + const keysData = match[1].trim(); + if (keysData) { + const keys = keysData + .split("\n") + .map((key) => key.trim()) + .filter((key) => key.length > 0); + return keys; + } else { + return []; + } + } else { + throw new Error( + `Unable to extract unregistered public stake keys from text: ${keysText}` + ); + } + } + + async assertBasicWalletCipData( + actualWalletCipData: WalletCipData, + walletConfig: WalletConfig + ) { + expect(actualWalletCipData.balance).toBeGreaterThan(500000000); + await this.assertExtensions( + actualWalletCipData.extensions, + walletConfig.cipBridge + ); + expect(actualWalletCipData.networkId).not.toBeNaN(); + expect(actualWalletCipData.changeAddress).not.toBeNaN(); + expect(actualWalletCipData.rewardAddresses.length).toBeGreaterThan(0); + //expect(actualWalletCipData.unusedAddresses.length).toBeGreaterThan(0); + expect(actualWalletCipData.usedAddresses.length).toBeGreaterThan(0); + expect(actualWalletCipData.utxos.length).toBeGreaterThan(0); + expect(actualWalletCipData.publicDRepKey).not.toBeNaN(); + expect(actualWalletCipData.registeredPublicStakeKeys).not.toBeNaN(); + } + + //Check if expected extensions are present in actual extensions + async assertExtensions( + actualExtensions: string[], + expectedExtensions: string[] + ) { + for (const ext of expectedExtensions) { + expect(actualExtensions).toContain(ext); + } + } +} diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/pages/modal.ts b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/pages/modal.ts new file mode 100644 index 00000000000..f31d447ebb6 --- /dev/null +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/pages/modal.ts @@ -0,0 +1,64 @@ +import { expect, Locator, Page } from "@playwright/test"; + +export enum ModalName { + SignData = "SignData", + SignAndSubmitTx = "SignAndSubmitTx", + SignAndSubmitRBACTx = "SignAndSubmitRBACTx", + SignDataUserDeclined = "UserDeclined", + SignTxUserDeclined = "SignTxUserDeclined", + SignRBACTxUserDeclined = "SignRBACTxUserDeclined", +} + +export interface ModalContent { + header: string; + unchangingText: string; +} + +export const modalContents: { [key in ModalName]: ModalContent } = { + [ModalName.SignData]: { + header: "Sign data", + unchangingText: "Signature:", + }, + [ModalName.SignAndSubmitTx]: { + header: "Sign & submit tx", + unchangingText: "Tx hash:", + }, + [ModalName.SignAndSubmitRBACTx]: { + header: "Sign & submit RBAC tx", + unchangingText: "Tx hash:", + }, + [ModalName.SignDataUserDeclined]: { + header: "Sign data", + unchangingText: "WalletApiException", + }, + [ModalName.SignTxUserDeclined]: { + header: "Sign & submit tx", + unchangingText: "WalletApiException", + }, + [ModalName.SignRBACTxUserDeclined]: { + header: "Sign & submit RBAC tx", + unchangingText: "WalletApiException", + }, +}; + +export class Modal { + readonly page: Page; + readonly content: ModalContent; + readonly modalHeader: Locator; + readonly modalBody: Locator; + + constructor(page: Page, modalName: ModalName) { + this.page = page; + this.content = modalContents[modalName]; + this.modalHeader = this.page.getByText(this.content.header, { + exact: true, + }); + this.modalBody = this.page.getByText(this.content.unchangingText); + } + + async assertModalIsVisible() { + await this.page.waitForTimeout(2000); + await expect(this.modalHeader).toBeVisible(); + await expect(this.modalBody).toBeVisible(); + } +} diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/pages/walletListPage.ts b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/pages/walletListPage.ts new file mode 100644 index 00000000000..3f2d59c9967 --- /dev/null +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/pages/walletListPage.ts @@ -0,0 +1,22 @@ +import { Page } from "@playwright/test"; +import { BrowserExtensionName } from "../utils/extensions"; + +export class WalletListPage { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + async clickEnableWallet(walletName: BrowserExtensionName): Promise { + if (walletName === BrowserExtensionName.Nufi) { + const [walletPopup] = await Promise.all([ + this.page.context().waitForEvent("page"), + await this.page.locator('//*[text()="Enable wallet"]').first().click(), + ]); + await walletPopup.locator("button:has-text('Connect')").click(); + } else { + await this.page.locator('//*[text()="Enable wallet"]').first().click(); + } + await this.page.waitForTimeout(2000); + } +} diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/playwright.config.ts b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/playwright.config.ts index ba735751faa..6571ff0b0d6 100644 --- a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/playwright.config.ts +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/playwright.config.ts @@ -1,32 +1,37 @@ -import { defineConfig, devices } from '@playwright/test'; +import { defineConfig, devices } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; -if (process.env.APP_URL == undefined){ - throw new Error("APP_URL env variable undefined"); -} +dotenv.config({ path: path.resolve(__dirname, ".env") }); export default defineConfig({ - testDir: '.', + testDir: "./tests", fullyParallel: false, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 1, workers: process.env.CI ? 1 : 1, use: { - baseURL: process.env.APP_URL, - screenshot: 'only-on-failure', - trace: 'on-first-retry', - + baseURL: process.env.APP_URL || "http://localhost:8000", + ignoreHTTPSErrors: true, + screenshot: "only-on-failure", + trace: "on", + video: "on", }, - reporter: [['junit', { outputFile: '/results/cardano-wallet.junit-report.xml' }]], + reporter: [ + ["junit", { outputFile: "/results/cardano-wallet.junit-report.xml" }], + ], timeout: 60 * 1000, projects: [ { - name: 'setup', - testMatch: /global-setup\.ts/, - }, - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - dependencies: ['setup'] + name: "chromium", + use: { + ...devices["Desktop Chrome"], + launchOptions: { + args: [ + "--unsafely-treat-insecure-origin-as-secure=http://test-app:80", + ], + }, + }, }, - ] + ], }); diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/setup.ts b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/setup.ts new file mode 100644 index 00000000000..4630d313d71 --- /dev/null +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/setup.ts @@ -0,0 +1,69 @@ +import { BrowserContext, chromium, Page } from "@playwright/test"; +import { ExtensionDownloader } from "./utils/extensionDownloader"; +import { BrowserExtensionName } from "./utils/extensions"; +import { + allowExtension, + onboardWallet, + WalletConfig, +} from "./utils/wallets/walletUtils"; + +const installExtension = async (extensionName: BrowserExtensionName) => { + const extensionPath = await new ExtensionDownloader().getExtension( + extensionName + ); + const browser = await chromium.launchPersistentContext("", { + headless: false, // extensions only work in headful mode + ignoreHTTPSErrors: true, + args: [ + `--disable-extensions-except=${extensionPath}`, + `--load-extension=${extensionPath}`, + "--unsafely-treat-insecure-origin-as-secure=http://test-app:80", + ], + }); + let [background] = browser.serviceWorkers(); + if (!background) background = await browser.waitForEvent("serviceworker"); + return browser; +}; + +export const restoreWallet = async (walletConfig: WalletConfig) => { + const browser = await installExtension(walletConfig.extension.Name); + const extensionTab = browser.pages()[0]; + walletConfig.extension.HomeUrl = await getDynamicUrlInChrome( + extensionTab, + walletConfig + ); + await extensionTab.goto(walletConfig.extension.HomeUrl); + await onboardWallet(extensionTab, walletConfig); + return browser; +}; + +export const enableWallet = async ( + walletConfig: WalletConfig, + browser: BrowserContext +) => { + const page = browser.pages()[0]; + await page.goto("/"); + await page.waitForTimeout(4000); + const [walletPopup] = await Promise.all([ + browser.waitForEvent("page"), + page.locator('//*[text()="Enable wallet"]').first().click(), + ]); + await walletPopup.waitForTimeout(2000); + await allowExtension(walletPopup, walletConfig.extension.Name); + await page.waitForTimeout(2000); + return browser; +}; + +/** + * We need this because some extensions have dynamic URLs + **/ +const getDynamicUrlInChrome = async ( + extensionTab: Page, + walletConfig: WalletConfig +): Promise => { + await extensionTab.goto("chrome://extensions/"); + const extensionId = await extensionTab + .locator("extensions-item") + .getAttribute("id"); + return `chrome-extension://${extensionId}/${walletConfig.extension.HomeUrl}`; +}; diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/test-fixtures.ts b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/test-fixtures.ts new file mode 100644 index 00000000000..5fa53d8361d --- /dev/null +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/test-fixtures.ts @@ -0,0 +1,32 @@ +// import { test as base, BrowserContext, chromium, Page } from '@playwright/test'; +// import { HomePage } from './pages/homePage'; +// import { WalletListPage } from './pages/walletListPage'; + + +// // import { allowExtension, onboardWallet, WalletConfig } from './utils/wallets/walletUtils'; + +// // type MyFixtures = { +// // enableWallet: (walletConfig: WalletConfig, browser: BrowserContext) => Promise; +// // }; + +// // export const test = base.extend({ +// // enableWallet: async ({ }, use) => { +// // const enableWalletFn = async (walletConfig: WalletConfig, browser: BrowserContext) => { +// // const page = browser.pages()[0]; +// // await page.reload(); +// // await page.goto('/'); +// // await page.waitForTimeout(4000); +// // const [walletPopup] = await Promise.all([ +// // browser.waitForEvent('page'), +// // page.locator('//*[text()="Enable wallet"]').click(), +// // ]); +// // await walletPopup.waitForTimeout(2000); +// // await allowExtension(walletPopup, walletConfig.extension.Name); +// // await page.waitForTimeout(2000); +// // return browser; +// // }; + +// // // Provide the function to the test +// // await use(enableWalletFn); +// // }, +// // }); \ No newline at end of file diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/tests/wallets.spec.ts b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/tests/wallets.spec.ts new file mode 100644 index 00000000000..7867550c2b7 --- /dev/null +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/tests/wallets.spec.ts @@ -0,0 +1,159 @@ +import { BrowserContext, test } from "@playwright/test"; +import { HomePage } from "../pages/homePage"; +import { ModalName } from "../pages/modal"; +import { WalletListPage } from "../pages/walletListPage"; +import { enableWallet, restoreWallet } from "../setup"; +import { walletConfigs } from "../utils/walletConfigs"; +import { signWalletPopup } from "../utils/wallets/walletUtils"; + +let browser: BrowserContext; +walletConfigs.forEach((walletConfig) => { + test.describe(`Testing with ${walletConfig.extension.Name}`, () => { + test.skip( + walletConfig.extension.Name === "Typhon", + "https://github.com/input-output-hk/catalyst-voices/issues/753" + ); + test.skip( + walletConfig.extension.Name === "Yoroi", + "https://github.com/input-output-hk/catalyst-voices/issues/753" + ); + test.skip( + walletConfig.extension.Name === "Lace", + "https://github.com/input-output-hk/catalyst-voices/issues/1190" + ); + test.skip( + walletConfig.extension.Name === "Nufi", + "https://github.com/input-output-hk/catalyst-voices/issues/1190" + ); + test.beforeAll(async () => { + browser = await restoreWallet(walletConfig); + await enableWallet(walletConfig, browser); + }); + + test("Get wallet details for " + walletConfig.extension.Name, async () => { + const page = browser.pages()[0]; + await page.reload(); + await new WalletListPage(page).clickEnableWallet( + walletConfig.extension.Name + ); + const homePage = new HomePage(page); + const walletCipData = await homePage.getWalletCipData(); + await homePage.assertBasicWalletCipData(walletCipData, walletConfig); + }); + + test("Sign data with " + walletConfig.extension.Name, async () => { + const page = browser.pages()[0]; + await page.reload(); + await new WalletListPage(page).clickEnableWallet( + walletConfig.extension.Name + ); + const homePage = new HomePage(page); + await signWalletPopup(browser, walletConfig, homePage.signDataButton); + await homePage.assertModal(ModalName.SignData); + }); + + test("Sign and submit tx with " + walletConfig.extension.Name, async () => { + const page = browser.pages()[0]; + await page.waitForTimeout(2000); + await page.reload(); + await new WalletListPage(page).clickEnableWallet( + walletConfig.extension.Name + ); + const homePage = new HomePage(page); + await signWalletPopup( + browser, + walletConfig, + homePage.signAndSubmitTxButton + ); + await homePage.assertModal(ModalName.SignAndSubmitTx); + }); + + test( + "Sign and submit RBAC tx with " + walletConfig.extension.Name, + async () => { + const page = browser.pages()[0]; + await page.waitForTimeout(2000); + await page.reload(); + await new WalletListPage(page).clickEnableWallet( + walletConfig.extension.Name + ); + const homePage = new HomePage(page); + await signWalletPopup( + browser, + walletConfig, + homePage.signAndSubmitRBACTxButton + ); + await homePage.assertModal(ModalName.SignAndSubmitRBACTx); + } + ); + + test( + "Fail to Sign data with incorrect password " + + walletConfig.extension.Name, + async () => { + const page = browser.pages()[0]; + await page.waitForTimeout(2000); + await page.reload(); + await new WalletListPage(page).clickEnableWallet( + walletConfig.extension.Name + ); + const homePage = new HomePage(page); + const walletConfigClone = structuredClone(walletConfig); + walletConfigClone.password = "BadPassword"; + await signWalletPopup( + browser, + walletConfigClone, + homePage.signDataButton, + false + ); + await homePage.assertModal(ModalName.SignDataUserDeclined); + } + ); + + test( + "Fail to Sign & submit tx with incorrect password" + + walletConfig.extension.Name, + async () => { + const page = browser.pages()[0]; + await page.waitForTimeout(2000); + await page.reload(); + await new WalletListPage(page).clickEnableWallet( + walletConfig.extension.Name + ); + const homePage = new HomePage(page); + const walletConfigClone = structuredClone(walletConfig); + walletConfigClone.password = "BadPassword"; + await signWalletPopup( + browser, + walletConfigClone, + homePage.signAndSubmitTxButton, + false + ); + await homePage.assertModal(ModalName.SignTxUserDeclined); + } + ); + + test( + "Fail to Sign & submit RBAC tx with incorrect password" + + walletConfig.extension.Name, + async () => { + const page = browser.pages()[0]; + await page.waitForTimeout(2000); + await page.reload(); + await new WalletListPage(page).clickEnableWallet( + walletConfig.extension.Name + ); + const homePage = new HomePage(page); + const walletConfigClone = structuredClone(walletConfig); + walletConfigClone.password = "BadPassword"; + await signWalletPopup( + browser, + walletConfigClone, + homePage.signAndSubmitRBACTxButton, + false + ); + await homePage.assertModal(ModalName.SignRBACTxUserDeclined); + } + ); + }); +}); diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/tsconfig.json b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/tsconfig.json new file mode 100644 index 00000000000..a200eb0ca46 --- /dev/null +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "es2018", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"], // Add this line + "typeRoots": ["./node_modules/@types"] + }, + "exclude": ["node_modules", "dist"] +} diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils.ts b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils.ts deleted file mode 100644 index 5dc90512fb5..00000000000 --- a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils.ts +++ /dev/null @@ -1,199 +0,0 @@ -import * as fs from 'fs/promises'; -import * as fsi from 'fs'; -import path from 'path'; -import nodeFetch from "node-fetch"; -import { expect, Page } from '@playwright/test'; - -interface WalletCredentials { - username: string; - password: string; -} -const getWalletCredentials = async (walletID: string): Promise => { - const username = process.env[`${walletID}_USERNAME`]; - const password = process.env[`${walletID}_PASSWORD`]; - console.log(`username: ${username}, password: ${password}`); - - if (!username || !password) { - throw new Error(`Credentials for ${walletID} not found`); - } - - return { username, password }; -}; -export { getWalletCredentials }; - -const getSeedPhrase = async (): Promise => { - let seedPhraseArray: string[]; - seedPhraseArray = process.env[`WALLET1_SEED_WORD`].split(","); - return seedPhraseArray; -}; -export { getSeedPhrase }; - -const downloadExtension = async (extID: string): Promise => { - const unzip = require("unzip-crx-3"); - const url = `https://clients2.google.com/service/update2/crx?response=redirect&os=win&arch=x64&os_arch=x86_64&nacl_arch=x86-64&prod=chromiumcrx&prodchannel=beta&prodversion=79.0.3945.53&lang=ru&acceptformat=crx3&x=id%3D${extID}%26installsource%3Dondemand%26uc`; - const downloadPath = path.resolve(__dirname, 'extensions'); - await fs.mkdir(downloadPath, { recursive: true }); - const filePath = path.join(downloadPath, extID + '.crx'); - const res = await nodeFetch(url); - await new Promise((resolve, reject) => { - console.log(`Downloading extension ${extID}`); - const fileStream = fsi.createWriteStream(filePath); - res?.body?.pipe(fileStream); - res!.body!.on("error", (err) => { - reject(err); - }); - fileStream.on("finish", function() { - console.log(`Extension has been downloaded to: ${filePath}`); - resolve(); - }); - }); - - // Extract the extension - try { - const extractPath = path.join(downloadPath, extID); - await fs.mkdir(extractPath, { recursive: true }); - await unzip(filePath, extractPath); - console.log("Extracted CRX file to:", extractPath); - return extractPath; - } catch (error) { - console.error("Failed to unzip the CRX file:", error.message); - throw new Error('Failed to unzip the CRX file.'); - } - }; - export { downloadExtension }; - - const typhonImportWallet = async (tab: Page): Promise => { - //switch to preprod network - await tab.locator('button#headlessui-menu-button-1').click(); - await tab.locator('button#headlessui-menu-item-6').click(); - //import wallet - await tab.getByRole('button', { name: 'Import' }).click(); - const WalletCredentials = await getWalletCredentials('WALLET1'); - await tab.getByPlaceholder('Wallet Name').fill(WalletCredentials.username); - await tab.getByPlaceholder('Password', { exact: true }).fill(WalletCredentials.password); - await tab.getByPlaceholder('Confirm Password', { exact: true }).fill(WalletCredentials.password); - await tab.locator('input#termsAndConditions').click(); - await tab.getByRole('button', { name: 'Continue' }).click(); - - // Input seed phrase - const seedPhrase = await getSeedPhrase(); - for (let i = 0; i < seedPhrase.length; i++) { - const ftSeedPhraseSelector = `(//input[@type='text'])[${i + 1}]`; - await tab.locator(ftSeedPhraseSelector).fill(seedPhrase[i]); - } - - await tab.locator('//*[@id="app"]/div/div/div[3]/div/div[2]/div/div/div/div[1]/div[1]/div[1]/span[1]').click(); - await tab.getByRole('button', { name: 'Unlock Wallet' }).click(); -}; - -const laceImportWallet = async (tab: Page): Promise => { - await tab.getByRole('button', { name: 'Agree' }).click(); - await tab.getByRole('button', { name: 'Restore' }).click(); - await tab.getByRole('button', { name: 'Next' }).click(); - await tab.getByTestId('recovery-phrase-15').click(); - const seedPhrase = await getSeedPhrase(); - for (let i = 0; i < seedPhrase.length; i++) { - const ftSeedPhraseSelector = `//*[@id="mnemonic-word-${i + 1}"]`; - await tab.locator(ftSeedPhraseSelector).fill(seedPhrase[i]); - } - await tab.getByRole('button', { name: 'Next' }).click(); - const WalletCredentials = await getWalletCredentials('WALLET1'); - await tab.getByTestId('wallet-name-input').fill(WalletCredentials.username); - await tab.getByTestId('wallet-password-verification-input').fill(WalletCredentials.password); - await tab.getByTestId('wallet-password-confirmation-input').fill(WalletCredentials.password); - await tab.getByRole('button', { name: 'Open wallet' }).click(); - //Lace is very slow at loading - await tab.getByTestId('profile-dropdown-trigger-menu').click({timeout: 300000}); - await tab.getByTestId('header-menu').getByTestId('header-menu-network-choice-container').click(); - await tab.getByTestId('header-menu').getByTestId('network-preprod-radio-button').click(); -}; - -const importWallet = async (tab: Page, wallet: string): Promise => { - switch (wallet) { - case 'Typhon': - await typhonImportWallet(tab); - break; - case 'Lace': - await laceImportWallet(tab); - break; - default: - throw new Error('Wallet not in use') - } -} -export { importWallet }; - -const allowExtension = async (tab: Page, wallet: string): Promise => { - switch (wallet) { - case 'Typhon': - await tab.getByRole('button', { name: 'Allow' }).click(); - break; - case 'Lace': - await tab.getByTestId('connect-authorize-button').click(); - await tab.getByRole('button', { name: 'Always' }).click(); - break; - default: - throw new Error('Wallet not in use') - } - -} -export { allowExtension }; - -async function signTyphonData(signTab: Page, password: string) { - await signTab.getByRole('button', { name: 'Sign' }).click(); - await signTab.getByPlaceholder('Password', { exact: true }).fill(password); - await signTab.getByRole('button', { name: 'confirm' }).click(); -} - -async function signLaceData(signTab: Page, password: string) { - await signTab.getByRole('button', { name: 'Confirm' }).click(); - await signTab.getByTestId('password-input').fill(password); - await signTab.getByRole('button', { name: 'Confirm' }).click(); - await signTab.getByRole('button', { name: 'Close' }).click(); -} - -const signData = async (wallet: string, tab: Page, password: string): Promise => { - switch (wallet) { - case 'Typhon': - await signTyphonData(tab, password); - break; - case 'Lace': - await signLaceData(tab, password); - break; - default: - throw new Error('Wallet not in use') - } -} -export { signData }; - -async function signTyphonBadPwd(signTab: Page, password: string) { - await signTab.getByRole('button', { name: 'Sign' }).click(); - await signTab.getByPlaceholder('Password', { exact: true }).fill(password); - await signTab.getByRole('button', { name: 'confirm' }).click(); - await expect(signTab.getByText('Wrong password')).toBeVisible(); - await signTab.locator('//*[@id="headlessui-dialog-2"]/div/div[2]/div[1]/button').click() - await signTab.getByRole('button', { name: 'Reject' }).click(); -} - -async function signLaceBadPwd(signTab: Page, password: string) { - await signTab.getByRole('button', { name: 'Confirm' }).click(); - await signTab.getByTestId('password-input').fill(password); - await signTab.getByRole('button', { name: 'Confirm' }).click(); - await expect(signTab.getByTestId('password-input-error')).toBeVisible(); - await signTab.getByRole('button', { name: 'Cancel' }).click(); - await signTab.getByRole('button', { name: 'Cancel' }).click(); -} - -const signDataBadPwd = async (wallet: string, tab: Page): Promise => { - const password = 'BadPassword' - switch (wallet) { - case 'Typhon': - await signTyphonBadPwd(tab, password); - break; - case 'Lace': - await signLaceBadPwd(tab, password); - break; - default: - throw new Error('Wallet not in use') - } -} -export { signDataBadPwd }; diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/extensionDownloader.ts b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/extensionDownloader.ts new file mode 100644 index 00000000000..d448035c537 --- /dev/null +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/extensionDownloader.ts @@ -0,0 +1,172 @@ +/* cspell:disable */ +import unzip from "@tomjs/unzip-crx"; +import fs, { promises as fsPromises } from "fs"; +import nodeFetch from "node-fetch"; +import * as os from "os"; +import path from "path"; +import { pipeline } from "stream/promises"; +import { BrowserExtensionName, getBrowserExtension } from "./extensions"; + +interface PlatformInfo { + os: string; + arch: string; + nacl_arch: string; +} + +export class ExtensionDownloader { + private extensionsDir: string; + + constructor() { + this.extensionsDir = path.resolve(__dirname, "..", "extensions"); + } + + /** + * Downloads and extracts the specified browser extension. + * @param extensionName The name of the extension to download. + * @returns The path to the extracted extension. + * + * @example + * const extensionPath = await new ExtensionDownloader().getExtension(BrowserExtensionName.Lace); + * console.log(extensionPath); + * Output: /path/to/extension + * + */ + public async getExtension( + extensionName: BrowserExtensionName + ): Promise { + const extensionId = getBrowserExtension(extensionName).Id; + const extensionPath = path.join(this.extensionsDir, extensionId); + + // Check if the extension has already been downloaded + if (fs.existsSync(extensionPath)) { + console.log(`Extension already exists at: ${extensionPath}`); + return extensionPath; + } + + // Download the extension + if (extensionName === BrowserExtensionName.Nufi) { + const zipPath = await this.downloadNufiExtension(); + await this.extractExtension(zipPath, extensionPath); + } else { + const crxPath = await this.downloadExtension(extensionName); + await this.extractExtension(crxPath, extensionPath); + } + // Extract the extension + + return extensionPath; + } + + private async downloadNufiExtension(): Promise { + const url = + "https://assets.nu.fi/extension/testnet/nufi-cwe-testnet-latest.zip"; + const filePath = path.join( + this.extensionsDir, + "nufi-cwe-testnet-latest.zip" + ); + + // Ensure the download directory exists + await fsPromises.mkdir(this.extensionsDir, { recursive: true }); + + // Fetch the extension + const res = await nodeFetch(url); + if (!res.ok) { + throw new Error(`Failed to download extension: ${res.statusText}`); + } + + // Stream the response directly to a file + const fileStream = fs.createWriteStream(filePath); + await pipeline(res.body, fileStream); + + console.log(`Extension has been downloaded to: ${filePath}`); + return filePath; + } + + private async extractExtension( + extensionPath: string, + extractPath: string + ): Promise { + // Ensure the extraction directory exists + await fsPromises.mkdir(extractPath, { recursive: true }); + + // Use unzip-crx to extract the CRX file + try { + await unzip(extensionPath, extractPath); + console.log(`Extension has been extracted to: ${extractPath}`); + } catch (error) { + console.error(`Failed to extract extension: ${(error as Error).message}`); + throw error; + } + } + + private async downloadExtension( + extensionName: BrowserExtensionName + ): Promise { + const extensionId = getBrowserExtension(extensionName).Id; + const url = this.getCrxUrl(extensionName); + + // Ensure the download directory exists + await fsPromises.mkdir(this.extensionsDir, { recursive: true }); + + const filePath = path.join(this.extensionsDir, `${extensionId}.crx`); + + // Fetch the extension + const res = await nodeFetch(url); + if (!res.ok) { + throw new Error(`Failed to download extension: ${res.statusText}`); + } + + // Stream the response directly to a file + const fileStream = fs.createWriteStream(filePath); + await pipeline(res.body, fileStream); + + console.log(`Extension has been downloaded to: ${filePath}`); + return filePath; + } + + private getCrxUrl(extensionName: BrowserExtensionName): string { + const extensionId = getBrowserExtension(extensionName).Id; + + const platformInfo = this.getPlatformInfo(); + const productId = "chromecrx"; + const productChannel = "unknown"; + let productVersion = "9999.0.9999.0"; + + let url = + "https://clients2.google.com/service/update2/crx?response=redirect"; + url += "&os=" + platformInfo.os; + url += "&arch=" + platformInfo.arch; + url += "&os_arch=" + platformInfo.os_arch; + url += "&nacl_arch=" + platformInfo.nacl_arch; + url += "&prod=" + productId; + url += "&prodchannel=" + productChannel; + url += "&prodversion=" + productVersion; + url += "&lang=en"; + url += "&acceptformat=crx3"; + url += "&x=id%3D" + extensionId + "%26installsource%3Dondemand%26uc"; + return url; + } + + private getPlatformInfo(): PlatformInfo & { os_arch: string } { + // Determine OS + let osType = os.type().toLowerCase(); + let osName = "win"; + if (osType.includes("darwin")) { + osName = "mac"; + } else if (osType.includes("linux")) { + osName = "linux"; + } else if (osType.includes("win")) { + osName = "win"; + } else if (osType.includes("cros")) { + osName = "cros"; + } + + // Determine architecture + const arch = os.arch(); // Returns 'x64', 'arm', 'ia32', etc. + const is64Bit = arch === "x64" || arch === "arm64"; + const archName = is64Bit ? "x64" : "x86"; + const os_arch = is64Bit ? "x86_64" : "x86"; + const naclArch = is64Bit ? "x86-64" : "x86-32"; + + return { os: osName, arch: archName, os_arch, nacl_arch: naclArch }; + } +} diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/extensions.ts b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/extensions.ts new file mode 100644 index 00000000000..7c9b6a35337 --- /dev/null +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/extensions.ts @@ -0,0 +1,54 @@ +export interface BrowserExtension { + Name: BrowserExtensionName; + Id: string; + HomeUrl: string; +} + +export enum BrowserExtensionName { + Lace = "Lace", + Typhon = "Typhon", + Eternl = "Eternl", + Yoroi = "Yoroi", + Nufi = "Nufi", +} +/* cspell: disable */ +export const browserExtensions: BrowserExtension[] = [ + { + Name: BrowserExtensionName.Lace, + Id: "gafhhkghbfjjkeiendhlofajokpaflmk", + HomeUrl: "app.html#/setup", + }, + { + Name: BrowserExtensionName.Typhon, + Id: "kfdniefadaanbjodldohaedphafoffoh", + HomeUrl: "tab.html#/wallet/access/", + }, + { + Name: BrowserExtensionName.Eternl, + Id: "kmhcihpebfmpgmihbkipmjlmmioameka", + HomeUrl: "index.html#/app/preprod/welcome", + }, + { + Name: BrowserExtensionName.Yoroi, + Id: "poonlenmfdfbjfeeballhiibknlknepo", + HomeUrl: "main_window.html#", + }, + { + Name: BrowserExtensionName.Nufi, + Id: "hbklpdnlgiadjhdadfnfmemmklbopbcm", + HomeUrl: "/index.html#", + }, +]; +/* cspell: enable */ + +export const getBrowserExtension = ( + name: BrowserExtensionName +): BrowserExtension => { + const extension = browserExtensions.find( + (extension) => extension.Name === name + ); + if (!extension) { + throw new Error(`Browser extension with name ${name} not found`); + } + return extension; +}; diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/walletConfigs.ts b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/walletConfigs.ts new file mode 100644 index 00000000000..ce1e11a8f04 --- /dev/null +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/walletConfigs.ts @@ -0,0 +1,137 @@ +import { BrowserExtensionName, getBrowserExtension } from "./extensions"; +import { WalletConfig } from "./wallets/walletUtils"; + +export const walletConfigs: WalletConfig[] = [ + { + id: "1", + extension: getBrowserExtension(BrowserExtensionName.Lace), + seed: [ + "stomach", + "horn", + "rail", + "afraid", + "flip", + "also", + "abandon", + "speed", + "chaos", + "daring", + "soon", + "soft", + "okay", + "online", + "benefit", + ], + username: "test123", + password: "test12345678@", + cipBridge: ["cip-95"], + }, + { + id: "2", + extension: getBrowserExtension(BrowserExtensionName.Typhon), + seed: [ + "stomach", + "horn", + "rail", + "afraid", + "flip", + "also", + "abandon", + "speed", + "chaos", + "daring", + "soon", + "soft", + "okay", + "online", + "benefit", + ], + username: "test123", + password: "test12345678@", + cipBridge: ["cip-30"], + }, + { + id: "3", + extension: getBrowserExtension(BrowserExtensionName.Eternl), + seed: [ + "stomach", + "horn", + "rail", + "afraid", + "flip", + "also", + "abandon", + "speed", + "chaos", + "daring", + "soon", + "soft", + "okay", + "online", + "benefit", + ], + username: "test123", + password: "test12345678@!!", + cipBridge: ["cip-30", "cip-95"], + }, + { + id: "4", + extension: getBrowserExtension(BrowserExtensionName.Yoroi), + seed: [ + "stomach", + "horn", + "rail", + "afraid", + "flip", + "also", + "abandon", + "speed", + "chaos", + "daring", + "soon", + "soft", + "okay", + "online", + "benefit", + ], + username: "test123", + password: "test12345678@!!", + cipBridge: ["cip-95"], + }, + { + id: "5", + extension: getBrowserExtension(BrowserExtensionName.Nufi), + seed: [ + "stomach", + "horn", + "rail", + "afraid", + "flip", + "also", + "abandon", + "speed", + "chaos", + "daring", + "soon", + "soft", + "okay", + "online", + "benefit", + ], + username: "test123", + password: "test12345678@!!", + cipBridge: ["cip-95"], + }, +]; + +export const getWalletConfig = (id: string): WalletConfig => { + const walletConfig = walletConfigs.find( + (walletConfig) => walletConfig.id === id + ); + if (!walletConfig) { + throw new Error(`Wallet config with id ${id} not found`); + } + return walletConfig; +}; + +export const getWalletConfigs = (): WalletConfig[] => walletConfigs; diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/wallets/eternlUtils.ts b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/wallets/eternlUtils.ts new file mode 100644 index 00000000000..2b310fe343b --- /dev/null +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/wallets/eternlUtils.ts @@ -0,0 +1,28 @@ +import { expect, Page } from "@playwright/test"; +import { WalletConfig } from "./walletUtils"; + +export const onboardEternlWallet = async (page: Page, walletConfig: WalletConfig): Promise => { + await page.locator('button:has-text("Add Wallet")').click(); + await page.locator('button:has-text("Restore wallet")').click(); + await page.locator('button:has-text("15 words")').click(); + await page.locator('button.cc-btn-primary:has-text("next")').click(); + await page.locator('#wordInput').fill(walletConfig.seed.join(' ')); + await page.locator('button:has-text("continue")').click(); + await page.locator('#inputWalletName').fill(walletConfig.username); + await page.locator('#password').fill(walletConfig.password); + await page.locator('#repeatPassword').fill(walletConfig.password); + await page.locator('button:has-text("save")').click(); + await page.locator('button:has-text("save")').click(); + await page.locator('div.flex.flex-row.justify-center.items-center.cursor-pointer.cc-area-light-1').click(); +}; + +export const signEternlData = async (signTab: Page, password: string, isCorrectPassword: boolean): Promise => { + + await signTab.locator('input#password').fill(password); + await signTab.locator('//button[.//span[text()="sign"]]').click(); + if (!isCorrectPassword) { + expect(await signTab.locator('//div[contains(text(), "try again")]').isVisible()).toBeTruthy(); + await signTab.locator('//button[.//span[text()="cancel"]]').click(); + return + } +} \ No newline at end of file diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/wallets/laceUtils.ts b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/wallets/laceUtils.ts new file mode 100644 index 00000000000..6611a80d5da --- /dev/null +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/wallets/laceUtils.ts @@ -0,0 +1,91 @@ +import { expect, Page } from "@playwright/test"; +import { WalletConfig } from "./walletUtils"; + +const clickRestoreWalletButton = async (page: Page): Promise => { + const maxAttempts = 3; + + // Selector for the restore wallet button + const restoreWalletButtonSelector = '[data-testid="restore-wallet-button"]'; + + // Selector for an element that exists only on the next page + const nextPageSelector = '[data-testid="wallet-setup-step-btn-next"]'; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + // Wait for the restore wallet button to be visible and enabled + const restoreWalletButton = page.locator(restoreWalletButtonSelector); + await restoreWalletButton.waitFor({ state: 'visible', timeout: 5000 }); + await expect(restoreWalletButton).toBeEnabled(); + + // Click the restore wallet button and wait for the next page to load + await Promise.all([ + page.waitForSelector(nextPageSelector, { timeout: 10000 }), + restoreWalletButton.click(), + ]); + + // Verify that the next page has loaded by checking for a unique element + const nextPageElement = page.locator(nextPageSelector); + await nextPageElement.waitFor({ state: 'visible', timeout: 5000 }); + + // If the next page is detected, exit the function + console.log('Successfully navigated to the next page.'); + return; + } catch (error) { + if (attempt === maxAttempts) { + // If it's the last attempt, rethrow the error + throw new Error(`Failed to click 'restore-wallet-button' after ${maxAttempts} attempts: ${error}`); + } else { + // Log the attempt and retry + console.warn(`Attempt ${attempt} to click 'restore-wallet-button' failed. Retrying...`); + // Optionally, you can add a short delay before retrying + await page.waitForTimeout(1000); + } + } + } +}; + +/* + * This handles the situation where after clicking restore Lace sometimes leads directly to recovery phrase page + * and sometimes leads to a page where the user has to click on the recovery phrase button to get to the recovery phrase page + */ +const handleNextPage = async (page: Page): Promise => { + const title = await page.getByTestId('wallet-setup-step-title').textContent(); + if(title === 'Choose recovery method') { + await page.locator('[data-testid="wallet-setup-step-btn-next"]').click(); + } else { + return; + } +} + +export const onboardLaceWallet = async (page: Page, walletConfig: WalletConfig): Promise => { + await page.locator('[data-testid="analytics-accept-button"]').click(); + await clickRestoreWalletButton(page); + await handleNextPage(page); + await page.getByTestId('recovery-phrase-15').click(); + const seedPhrase = walletConfig.seed; + for (let i = 0; i < seedPhrase.length; i++) { + const ftSeedPhraseSelector = `//*[@id="mnemonic-word-${i + 1}"]`; + await page.locator(ftSeedPhraseSelector).fill(seedPhrase[i]); + } + await page.getByRole('button', { name: 'Next' }).click(); + await page.getByTestId('wallet-name-input').fill(walletConfig.username); + await page.getByTestId('wallet-password-verification-input').fill(walletConfig.password); + await page.getByTestId('wallet-password-confirmation-input').fill(walletConfig.password); + await page.getByRole('button', { name: 'Open wallet' }).click(); + //Lace is very slow at loading + await page.getByTestId('profile-dropdown-trigger-menu').click({timeout: 300000}); + await page.getByTestId('header-menu').getByTestId('header-menu-network-choice-container').click(); + await page.getByTestId('header-menu').getByTestId('network-preprod-radio-button').click(); + await page.waitForTimeout(4000); +}; + +export const signLaceData = async (page: Page, password: string, isCorrectPassword: boolean): Promise => { + await page.getByRole('button', { name: 'Confirm' }).click(); + await page.getByTestId('password-input').fill(password); + await page.getByRole('button', { name: 'Confirm' }).click(); + if (!isCorrectPassword) { + await page.getByRole('button', { name: 'Close' }).click(); + return; + } + await page.waitForTimeout(2000); + } \ No newline at end of file diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/wallets/nufiUtils.ts b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/wallets/nufiUtils.ts new file mode 100644 index 00000000000..fd1c058b179 --- /dev/null +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/wallets/nufiUtils.ts @@ -0,0 +1,43 @@ +import { Page } from "playwright"; +import { WalletConfig } from "./walletUtils"; + +export const onboardNufiWallet = async ( + page: Page, + walletConfig: WalletConfig +): Promise => { + await page.locator("//*[@data-testid='RestorePageIcon']").click(); + const seedPhrase = walletConfig.seed; + for (let i = 0; i < 15; i++) { + await page + .locator(`//div[@rtl-data-test-id='mnemonic-field-input-${i}']//input`) + .fill(seedPhrase[i]); + } + await page + .locator("//span[@data-test-id='terms-and-conditions-checkbox']/input") + .check(); + await page.locator("button:has-text('Continue')").click(); + await page + .locator("//input[@rtl-data-test-id='wallet-name-field']") + .fill(walletConfig.username); + await page.locator("//input[@id=':rg:']").fill(walletConfig.password); + await page.locator("//input[@id=':rh:']").fill(walletConfig.password); + await page.locator("button:has-text('Continue')").click(); + await page.locator("button:has-text('Recover')").click(); + await page.locator("button:has-text('Go to Wallet')").click(); +}; + +export const connectWalletPopup = async (page: Page): Promise => { + await page.locator("button:has-text('Connect')").click(); +}; + +export const signNufiData = async ( + page: Page, + password: string, + isCorrectPassword: boolean +): Promise => { + if (!isCorrectPassword) { + await page.locator("button:has-text('Reject')").click(); + return; + } + await page.locator("button:has-text('Sign')").click(); +}; diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/wallets/typhonUtils.ts b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/wallets/typhonUtils.ts new file mode 100644 index 00000000000..416ad09ff3c --- /dev/null +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/wallets/typhonUtils.ts @@ -0,0 +1,34 @@ +import { Page } from "playwright"; +import { WalletConfig } from "./walletUtils"; + +export const onboardTyphonWallet = async (page: Page, walletConfig: WalletConfig): Promise => { + //switch to preprod network + await page.locator('button#headlessui-menu-button-1').click(); + await page.locator('button#headlessui-menu-item-6').click(); + //import wallet + await page.getByRole('button', { name: 'Import' }).click(); + await page.getByPlaceholder('Wallet Name').fill(walletConfig.username); + await page.getByPlaceholder('Password', { exact: true }).fill(walletConfig.password); + await page.getByPlaceholder('Confirm Password', { exact: true }).fill(walletConfig.password); + await page.locator('input#termsAndConditions').click(); + await page.getByRole('button', { name: 'Continue' }).click(); + + // Input seed phrase + const seedPhrase = walletConfig.seed; + for (let i = 0; i < seedPhrase.length; i++) { + const ftSeedPhraseSelector = `(//input[@type='text'])[${i + 1}]`; + await page.locator(ftSeedPhraseSelector).fill(seedPhrase[i]); + } + + await page.locator('//*[@id="app"]/div/div/div[3]/div/div[2]/div/div/div/div[1]/div[1]/div[1]/span[1]').click(); + await page.getByRole('button', { name: 'Unlock Wallet' }).click(); +}; + +export const signTyphonData = async (signTab: Page, password: string, isCorrectPassword: boolean): Promise => { + await signTab.getByRole('button', { name: 'Sign' }).click(); + await signTab.getByPlaceholder('Password', { exact: true }).fill(password); + if (!isCorrectPassword) { + return + } + await signTab.getByRole('button', { name: 'confirm' }).click(); +} \ No newline at end of file diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/wallets/walletUtils.ts b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/wallets/walletUtils.ts new file mode 100644 index 00000000000..3b5638a9c45 --- /dev/null +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/wallets/walletUtils.ts @@ -0,0 +1,118 @@ +import { BrowserContext, Locator, Page } from "@playwright/test"; +import { BrowserExtension, BrowserExtensionName } from "../extensions"; +import { onboardEternlWallet, signEternlData } from "./eternlUtils"; +import { onboardLaceWallet, signLaceData } from "./laceUtils"; +import { onboardNufiWallet, signNufiData } from "./nufiUtils"; +import { onboardTyphonWallet, signTyphonData } from "./typhonUtils"; +import { onboardYoroiWallet, signYoroiData } from "./yoroiUtils"; + +export interface WalletConfig { + id: string; + extension: BrowserExtension; + seed: string[]; + username: string; + password: string; + cipBridge: string[]; +} + +export const onboardWallet = async ( + page: Page, + walletConfig: WalletConfig +): Promise => { + switch (walletConfig.extension.Name) { + case BrowserExtensionName.Typhon: + await onboardTyphonWallet(page, walletConfig); + break; + case BrowserExtensionName.Lace: + await onboardLaceWallet(page, walletConfig); + break; + case BrowserExtensionName.Eternl: + await onboardEternlWallet(page, walletConfig); + break; + case BrowserExtensionName.Yoroi: + await onboardYoroiWallet(page, walletConfig); + break; + case BrowserExtensionName.Nufi: + await onboardNufiWallet(page, walletConfig); + break; + default: + throw new Error("Wallet not in use"); + } + await page.waitForTimeout(2000); +}; + +//TODO: move specific cases to specific utils for wallets +export const allowExtension = async ( + tab: Page, + wallet: string +): Promise => { + switch (wallet) { + case "Typhon": + await tab.getByRole("button", { name: "Allow" }).click(); + break; + case "Lace": + await tab.getByTestId("connect-authorize-button").click(); + await tab.getByRole("button", { name: "Always" }).click(); + break; + case "Eternl": + await tab.locator('button:has-text("Grant Access")').click(); + break; + case "Yoroi": + await tab.locator("button:has(#connectedWalletName)").click(); + break; + case "Nufi": + await tab.locator("//input[@type='password']").fill("test12345678@!!"); + await tab.locator("button:has-text('Connect')").click(); + await tab.locator("button:has-text('Connect')").click(); + break; + default: + throw new Error("Wallet not in use"); + } +}; + +const getWalletPopup = async ( + browser: BrowserContext, + triggerLocatorCLick: Locator +): Promise => { + if (browser.pages().length > 1) { + await triggerLocatorCLick.click(); + await browser + .pages() + [browser.pages().length - 1].waitForLoadState("domcontentloaded"); + return browser.pages()[browser.pages().length - 1]; + } else { + const [page] = await Promise.all([ + browser.waitForEvent("page"), + triggerLocatorCLick.click(), + ]); + return page; + } +}; + +export const signWalletPopup = async ( + browser: BrowserContext, + walletConfig: WalletConfig, + locatorTrigger: Locator, + isCorrectPassword = true +): Promise => { + const page = await getWalletPopup(browser, locatorTrigger); + switch (walletConfig.extension.Name) { + case BrowserExtensionName.Typhon: + await signTyphonData(page, walletConfig.password, isCorrectPassword); + break; + case BrowserExtensionName.Lace: + await signLaceData(page, walletConfig.password, isCorrectPassword); + break; + case BrowserExtensionName.Eternl: + await signEternlData(page, walletConfig.password, isCorrectPassword); + break; + case BrowserExtensionName.Yoroi: + await signYoroiData(page, walletConfig.password, isCorrectPassword); + break; + case BrowserExtensionName.Nufi: + await signNufiData(page, walletConfig.password, isCorrectPassword); + break; + default: + throw new Error("Wallet not in use"); + } +}; diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/wallets/yoroiUtils.ts b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/wallets/yoroiUtils.ts new file mode 100644 index 00000000000..b21b5785619 --- /dev/null +++ b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/utils/wallets/yoroiUtils.ts @@ -0,0 +1,49 @@ +import { Page } from "playwright"; +import { WalletConfig } from "./walletUtils"; + +export const onboardYoroiWallet = async ( + page: Page, + walletConfig: WalletConfig +): Promise => { + /* cspell: disable */ + await page.locator("#initialPage-tosAgreement-checkbox").check(); + await page.locator("#initialPage-continue-button").click(); + await page.locator("#mui-2").click(); + await page.locator("#somewhere-checkbox").check(); + await page.locator('button:has-text("Continue")').click(); + + await page + .locator(".UriPromptForm_buttonsWrapper button.MuiButton-secondary") + .click(); + + await page.locator("#restoreWalletButton").click(); + await page.locator('button:has-text("Cardano Preprod Testnet")').click(); + await page.locator("#fifteenWordsButton").click(); + const seedPhrase = walletConfig.seed; + for (let i = 0; i < seedPhrase.length; i++) { + const ftSeedPhraseSelector = `#downshift-${i}-input`; + await page.locator(ftSeedPhraseSelector).fill(seedPhrase[i]); + } + await page.locator(`#downshift-${seedPhrase.length - 1}-input`).blur(); + await page.locator("#primaryButton").click(); + await page.locator("#infoDialogContinueButton").click(); + await page.locator("#walletNameInput-label").fill(walletConfig.username); + await page.locator("#walletPasswordInput-label").fill(walletConfig.password); + await page.locator("#repeatPasswordInput-label").fill(walletConfig.password); + await page.locator("#primaryButton").click(); + await page.locator("#dialog-gotothewallet-button").click(); +}; +/* cspell: enable */ + +export const signYoroiData = async ( + signTab: Page, + password: string, + isCorrectPassword: boolean +): Promise => { + await signTab.locator("#walletPassword").fill(password); + await signTab.locator("#confirmButton").click(); + if (!isCorrectPassword) { + await signTab.locator("#cancelButton").click(); + return; + } +}; diff --git a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/wallet-tests.spec.ts b/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/wallet-tests.spec.ts deleted file mode 100644 index 8c851f25713..00000000000 --- a/catalyst_voices/packages/libs/catalyst_cardano/catalyst_cardano/wallet-automation/wallet-tests.spec.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { test, chromium, expect, BrowserContext, Page } from '@playwright/test'; -import { allowExtension, downloadExtension, getWalletCredentials, importWallet, signData, signDataBadPwd} from './utils'; - -let browser: BrowserContext; -let extensionPath: string; -let extTab: Page; -/* cSpell:disable */ -let wallets = - [{ name: 'Typhon', id: 'kfdniefadaanbjodldohaedphafoffoh', url: 'chrome-extension://changeme/tab.html#/wallet/access/' }, - { name: 'Lace', id: 'gafhhkghbfjjkeiendhlofajokpaflmk', url: 'chrome-extension://changeme/app.html#/setup' },] -/* cSpell:enable */ - -wallets.forEach(({ name, id, url }) => { - test.describe(`Testing with ${name}`,() => { - test.skip(name === 'Typhon', 'https://github.com/input-output-hk/catalyst-voices/issues/753'); - test.afterAll(async () => { - browser.close() - }); - - test.beforeAll(async () => { - // Download extension and import wallet into wallet extension - test.setTimeout(300000); - extensionPath = await downloadExtension(id); - browser = await chromium.launchPersistentContext('', { - headless: false, // extensions only work in headful mode - args: [ - `--disable-extensions-except=${extensionPath}`, - `--load-extension=${extensionPath}`, - ], - }); - let [background] = browser.serviceWorkers(); - if (!background) - background = await browser.waitForEvent('serviceworker'); - const extensionId = background.url().split('/')[2]; - extTab = await browser.newPage(); - const extUrl = url.replace('changeme', extensionId ); - await extTab.goto(extUrl); - await extTab.waitForTimeout(5000); - await importWallet(extTab,name); - await extTab.waitForTimeout(5000); - await extTab.goto('/') - await extTab.locator('//*[text()="Enable wallet"]').click(); - - await expect.poll(async () => { - return browser.pages().length; - }, { timeout: 15000 }).toBe(3); - - const updatedPages = browser.pages(); - const allowTab = updatedPages[updatedPages.length - 1]; - await allowTab.bringToFront(); - await allowExtension(allowTab,name); - await extTab.bringToFront(); - //wait for data to load - const textContent = await extTab.locator('#flt-semantic-node-13').textContent({ timeout: 5000 }); - expect(textContent).not.toBeNull(); - }); - - // Get and match text content - const matchTextContent = async (selector: string, regex: RegExp) => { - const textContent = await extTab.locator(selector).textContent({ timeout: 5000 }); - expect(textContent).not.toBeNull(); - const match = textContent!.match(regex); - expect(match).not.toBeNull(); - return match![1].trim(); - }; - - test('Get wallet details for ' + name , async () => { - await extTab.waitForTimeout(5000); - const balanceTextContent = await matchTextContent('#flt-semantic-node-13', /Balance: Ada \(lovelaces\): (\d+)/); - const balanceAda = (parseInt(balanceTextContent, 10) / 1_000_000).toFixed(2); - expect(parseInt(balanceAda)).toBeGreaterThan(500); - - const cleanedExtensionInfo = await matchTextContent('#flt-semantic-node-14', /Extensions:\s*(.+)/); - switch (name) { - case 'Typhon': - expect(cleanedExtensionInfo).toMatch('cip-30'); - break; - case 'Lace': - expect(cleanedExtensionInfo).toMatch('cip-95'); - break; - default: - throw new Error('Wallet not in use') - } - - expect(matchTextContent('#flt-semantic-node-15', /Network ID: (.+)/)).not.toBeNaN(); - - expect(await matchTextContent('#flt-semantic-node-17', /Reward addresses:\s*(\S+)/)).not.toBeNaN(); - - expect(matchTextContent('#flt-semantic-node-19', /Used addresses:\s*(\S+)/)).not.toBeNaN(); - - const utxoTextContent = await extTab.locator('#flt-semantic-node-20').textContent({ timeout: 5000 }); - expect(utxoTextContent).not.toBeNull(); - const utxoLines = utxoTextContent!.split('\n').map(line => line.trim()); - expect(utxoLines.length).toBeGreaterThanOrEqual(4); - const tx = utxoLines[1].split(':')[1].trim(); - const index = utxoLines[2].split(':')[1].trim(); - const amount = utxoLines[3].split(':')[2].trim(); - const amountAda = (parseInt(amount, 10) / 1_000_000).toFixed(2); - expect(tx).not.toBeUndefined(); - expect(index).not.toBeUndefined(); - expect(amount).not.toBeNaN(); - expect(parseInt(amountAda)).toBeGreaterThan(500); - }); - - async function openSignTab(buttonName: string) { - await extTab.getByRole('button', { name: buttonName }).click(); - await expect.poll(async () => browser.pages().length, { timeout: 15000 }).toBe(3); - const signPage = browser.pages(); - const signTab = signPage[signPage.length - 1]; - await signTab.bringToFront(); - return signTab; - } - - test('Sign data ' + name, async () => { - const signTab = await openSignTab('Sign data'); - const WalletCredentials = await getWalletCredentials('WALLET1'); - await signData(name, signTab, WalletCredentials.password); - await expect(extTab.getByText('Sign Data')).toBeVisible(); - await extTab.getByRole('button', { name: 'Close' }).click(); - }); - - test('Sign and submit tx ' + name, async () => { - const signTab = await openSignTab('Sign & submit tx') - const WalletCredentials = await getWalletCredentials('WALLET1'); - await signData(name, signTab, WalletCredentials.password); - await expect(extTab.getByText('Tx hash')).toBeVisible(); - await extTab.getByRole('button', { name: 'Close' }).click(); - }); - - test('Sign and submit RBAC tx ' + name, async () => { - const signTab = await openSignTab('Sign & submit RBAC tx'); - const WalletCredentials = await getWalletCredentials('WALLET1'); - await signData(name, signTab, WalletCredentials.password); - await expect(extTab.getByText('Tx hash')).toBeVisible(); - await extTab.getByRole('button', { name: 'Close' }).click(); - }); - - test('Fail to Sign data with incorrect password ' + name, async () => { - const signTab = await openSignTab('Sign data'); - await signDataBadPwd(name, signTab); - await extTab.getByRole('button', { name: 'Close' }).click(); - }); - - test('Fail to Sign & submit tx with incorrect password ' + name, async () => { - const signTab = await openSignTab('Sign & submit tx'); - await signDataBadPwd(name, signTab); - await extTab.getByRole('button', { name: 'Close' }).click(); - }); - - test('Fail to Sign & submit RBAC tx with incorrect password ' + name, async () => { - const signTab = await openSignTab('Sign & submit RBAC tx'); - await signDataBadPwd(name, signTab); - await extTab.getByRole('button', { name: 'Close' }).click(); - }); - }); - }); diff --git a/cspell.json b/cspell.json index d4686572a49..2a698c55eb0 100644 --- a/cspell.json +++ b/cspell.json @@ -182,7 +182,7 @@ "catalyst_voices/utilities/poc_local_storage/**/**", "**/*.svg", "catalyst_voices/packages/libs/catalyst_key_derivation/lib/src/rust/**", - "catalyst_voices/packages/internal/catalyst_voices_driver/lib/src/browser_extensions/**" + "catalyst_voices/packages/internal/catalyst_voices_driver/lib/src/browser_extensions/**" ], "enableFiletypes": [ "earthfile",