diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7214fda..bd4216c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -21,7 +21,8 @@ "Bash(docker image:*)", "Read(//home/anti/Tools/cowrie/src/cowrie/data/txtcmds/**)", "Read(//home/anti/Tools/cowrie/src/cowrie/data/txtcmds/bin/**)", - "mcp__plugin_context-mode_context-mode__ctx_index" + "mcp__plugin_context-mode_context-mode__ctx_index", + "Bash(ls:*)" ] } } diff --git a/.gitignore b/.gitignore index f5a8f9b..2301154 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ windows1 decnet.json .env .env.local +.coverage +.hypothesis/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9b38853 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/DEBT.md b/DEBT.md index f8de25a..7422e02 100644 --- a/DEBT.md +++ b/DEBT.md @@ -117,6 +117,11 @@ Bait emails are hardcoded. A stub env var `IMAP_EMAIL_SEED` is read but currentl The bait store and honeypot files are hardcoded. A dynamic injection framework should be created to populate this payload across different honeypots. **Status:** Deferred β€” out of current scope. +### DEBT-028 β€” Test coverage for `api_deploy_deckies.py` +**File:** `decnet/web/router/fleet/api_deploy_deckies.py` (24% coverage) +The deploy endpoint exercises Docker Compose orchestration via `decnet.engine.deploy`, which creates MACVLAN/IPvlan networks and runs `docker compose up`. Meaningful tests require mocking the entire Docker SDK + subprocess layer, coupling tightly to implementation details. +**Status:** Deferred β€” test after Docker-in-Docker CI is available. + --- ## 🟒 Low @@ -170,6 +175,7 @@ The bait store and honeypot files are hardcoded. A dynamic injection framework s | ~~DEBT-025~~ | βœ… | Build | resolved | | DEBT-026 | 🟑 Medium | Features | deferred (out of scope) | | DEBT-027 | 🟑 Medium | Features | deferred (out of scope) | +| DEBT-028 | 🟑 Medium | Testing | deferred (needs DinD CI) | -**Remaining open:** DEBT-011 (Alembic), DEBT-023 (image pinning), DEBT-026 (modular mailboxes), DEBT-027 (Dynamic bait store) -**Estimated remaining effort:** ~10 hours +**Remaining open:** DEBT-011 (Alembic), DEBT-023 (image pinning), DEBT-026 (modular mailboxes), DEBT-027 (Dynamic bait store), DEBT-028 (deploy endpoint tests) +**Estimated remaining effort:** ~12 hours diff --git a/ast_graph.md b/ast_graph.md new file mode 100644 index 0000000..cfcb065 --- /dev/null +++ b/ast_graph.md @@ -0,0 +1,419 @@ +# DECNET Codebase AST Graph + +This diagram shows the structural organization of the DECNET project, extracted directly from the Python Abstract Syntax Tree (AST). It includes modules (prefixed with `Module_`), their internal functions, and the classes and methods they contain. + +```mermaid +classDiagram + class Module_distros { + +random_hostname() + +get_distro() + +random_distro() + +all_distros() + } + class distros_DistroProfile { + } + Module_distros ..> distros_DistroProfile : contains + + class custom_service_CustomService { + +__init__() + +compose_fragment() + +dockerfile_context() + } + Module_custom_service ..> custom_service_CustomService : contains + class Module_os_fingerprint { + +get_os_sysctls() + +all_os_families() + } + + class Module_network { + +_run() + +detect_interface() + +detect_subnet() + +get_host_ip() + +allocate_ips() + +create_macvlan_network() + +create_ipvlan_network() + +remove_macvlan_network() + +_require_root() + +setup_host_macvlan() + +teardown_host_macvlan() + +setup_host_ipvlan() + +teardown_host_ipvlan() + +ips_to_range() + } + + class Module_env { + +_port() + +_require_env() + } + + class Module_config { + +random_hostname() + +save_state() + +load_state() + +clear_state() + } + class config_DeckyConfig { + +services_not_empty() + } + Module_config ..> config_DeckyConfig : contains + class config_DecnetConfig { + } + Module_config ..> config_DecnetConfig : contains + class Module_ini_loader { + +load_ini() + +load_ini_from_string() + +validate_ini_string() + +_parse_configparser() + } + class ini_loader_DeckySpec { + } + Module_ini_loader ..> ini_loader_DeckySpec : contains + class ini_loader_CustomServiceSpec { + } + Module_ini_loader ..> ini_loader_CustomServiceSpec : contains + class ini_loader_IniConfig { + } + Module_ini_loader ..> ini_loader_IniConfig : contains + class Module_composer { + +generate_compose() + +write_compose() + } + + class Module_archetypes { + +get_archetype() + +all_archetypes() + +random_archetype() + } + class archetypes_Archetype { + } + Module_archetypes ..> archetypes_Archetype : contains + class Module_fleet { + +all_service_names() + +resolve_distros() + +build_deckies() + +build_deckies_from_ini() + } + + class Module_cli { + +_kill_api() + +api() + +deploy() + +collect() + +mutate() + +status() + +teardown() + +list_services() + +list_distros() + +correlate() + +list_archetypes() + +serve_web() + } + + + class services_base_BaseService { + +compose_fragment() + +dockerfile_context() + } + Module_services_base ..> services_base_BaseService : contains + + class services_http_HTTPService { + +compose_fragment() + +dockerfile_context() + } + Module_services_http ..> services_http_HTTPService : contains + + class services_smtp_SMTPService { + +compose_fragment() + +dockerfile_context() + } + Module_services_smtp ..> services_smtp_SMTPService : contains + + class services_mysql_MySQLService { + +compose_fragment() + +dockerfile_context() + } + Module_services_mysql ..> services_mysql_MySQLService : contains + + class services_redis_RedisService { + +compose_fragment() + +dockerfile_context() + } + Module_services_redis ..> services_redis_RedisService : contains + + class services_elasticsearch_ElasticsearchService { + +compose_fragment() + +dockerfile_context() + } + Module_services_elasticsearch ..> services_elasticsearch_ElasticsearchService : contains + + class services_ftp_FTPService { + +compose_fragment() + +dockerfile_context() + } + Module_services_ftp ..> services_ftp_FTPService : contains + + class services_imap_IMAPService { + +compose_fragment() + +dockerfile_context() + } + Module_services_imap ..> services_imap_IMAPService : contains + + class services_k8s_KubernetesAPIService { + +compose_fragment() + +dockerfile_context() + } + Module_services_k8s ..> services_k8s_KubernetesAPIService : contains + + class services_ldap_LDAPService { + +compose_fragment() + +dockerfile_context() + } + Module_services_ldap ..> services_ldap_LDAPService : contains + + class services_llmnr_LLMNRService { + +compose_fragment() + +dockerfile_context() + } + Module_services_llmnr ..> services_llmnr_LLMNRService : contains + + class services_mongodb_MongoDBService { + +compose_fragment() + +dockerfile_context() + } + Module_services_mongodb ..> services_mongodb_MongoDBService : contains + + class services_mqtt_MQTTService { + +compose_fragment() + +dockerfile_context() + } + Module_services_mqtt ..> services_mqtt_MQTTService : contains + + class services_mssql_MSSQLService { + +compose_fragment() + +dockerfile_context() + } + Module_services_mssql ..> services_mssql_MSSQLService : contains + + class services_pop3_POP3Service { + +compose_fragment() + +dockerfile_context() + } + Module_services_pop3 ..> services_pop3_POP3Service : contains + + class services_postgres_PostgresService { + +compose_fragment() + +dockerfile_context() + } + Module_services_postgres ..> services_postgres_PostgresService : contains + + class services_rdp_RDPService { + +compose_fragment() + +dockerfile_context() + } + Module_services_rdp ..> services_rdp_RDPService : contains + + class services_sip_SIPService { + +compose_fragment() + +dockerfile_context() + } + Module_services_sip ..> services_sip_SIPService : contains + + class services_smb_SMBService { + +compose_fragment() + +dockerfile_context() + } + Module_services_smb ..> services_smb_SMBService : contains + + class services_snmp_SNMPService { + +compose_fragment() + +dockerfile_context() + } + Module_services_snmp ..> services_snmp_SNMPService : contains + + class services_tftp_TFTPService { + +compose_fragment() + +dockerfile_context() + } + Module_services_tftp ..> services_tftp_TFTPService : contains + + class services_vnc_VNCService { + +compose_fragment() + +dockerfile_context() + } + Module_services_vnc ..> services_vnc_VNCService : contains + + class services_docker_api_DockerAPIService { + +compose_fragment() + +dockerfile_context() + } + Module_services_docker_api ..> services_docker_api_DockerAPIService : contains + class Module_services_registry { + +_load_plugins() + +register_custom_service() + +get_service() + +all_services() + } + + + class services_smtp_relay_SMTPRelayService { + +compose_fragment() + +dockerfile_context() + } + Module_services_smtp_relay ..> services_smtp_relay_SMTPRelayService : contains + + class services_conpot_ConpotService { + +compose_fragment() + +dockerfile_context() + } + Module_services_conpot ..> services_conpot_ConpotService : contains + + class services_ssh_SSHService { + +compose_fragment() + +dockerfile_context() + } + Module_services_ssh ..> services_ssh_SSHService : contains + + class services_telnet_TelnetService { + +compose_fragment() + +dockerfile_context() + } + Module_services_telnet ..> services_telnet_TelnetService : contains + class Module_logging_forwarder { + +parse_log_target() + +probe_log_target() + } + + class Module_logging_file_handler { + +_get_logger() + +write_syslog() + +get_log_path() + } + + class Module_logging_syslog_formatter { + +_pri() + +_truncate() + +_sd_escape() + +_sd_element() + +format_rfc5424() + } + + + class correlation_graph_TraversalHop { + } + Module_correlation_graph ..> correlation_graph_TraversalHop : contains + class correlation_graph_AttackerTraversal { + +first_seen() + +last_seen() + +duration_seconds() + +deckies() + +decky_count() + +path() + +to_dict() + } + Module_correlation_graph ..> correlation_graph_AttackerTraversal : contains + class Module_correlation_engine { + +_fmt_duration() + } + class correlation_engine_CorrelationEngine { + +__init__() + +ingest() + +ingest_file() + +traversals() + +all_attackers() + +report_table() + +report_json() + +traversal_syslog_lines() + } + Module_correlation_engine ..> correlation_engine_CorrelationEngine : contains + class Module_correlation_parser { + +_parse_sd_params() + +_extract_attacker_ip() + +parse_line() + } + class correlation_parser_LogEvent { + } + Module_correlation_parser ..> correlation_parser_LogEvent : contains + class Module_web_auth { + +verify_password() + +get_password_hash() + +create_access_token() + } + + class Module_engine_deployer { + +_sync_logging_helper() + +_compose() + +_compose_with_retry() + +deploy() + +teardown() + +status() + +_print_status() + } + + class Module_collector_worker { + +parse_rfc5424() + +_load_service_container_names() + +is_service_container() + +is_service_event() + +_stream_container() + } + + class Module_mutator_engine { + +mutate_decky() + +mutate_all() + +run_watch_loop() + } + + + class web_db_repository_BaseRepository { + } + Module_web_db_repository ..> web_db_repository_BaseRepository : contains + + class web_db_models_User { + } + Module_web_db_models ..> web_db_models_User : contains + class web_db_models_Log { + } + Module_web_db_models ..> web_db_models_Log : contains + class web_db_models_Bounty { + } + Module_web_db_models ..> web_db_models_Bounty : contains + class web_db_models_Token { + } + Module_web_db_models ..> web_db_models_Token : contains + class web_db_models_LoginRequest { + } + Module_web_db_models ..> web_db_models_LoginRequest : contains + class web_db_models_ChangePasswordRequest { + } + Module_web_db_models ..> web_db_models_ChangePasswordRequest : contains + class web_db_models_LogsResponse { + } + Module_web_db_models ..> web_db_models_LogsResponse : contains + class web_db_models_BountyResponse { + } + Module_web_db_models ..> web_db_models_BountyResponse : contains + class web_db_models_StatsResponse { + } + Module_web_db_models ..> web_db_models_StatsResponse : contains + class web_db_models_MutateIntervalRequest { + } + Module_web_db_models ..> web_db_models_MutateIntervalRequest : contains + class web_db_models_DeployIniRequest { + } + Module_web_db_models ..> web_db_models_DeployIniRequest : contains + class Module_web_db_sqlite_database { + +get_async_engine() + +get_sync_engine() + +init_db() + } + + + class web_db_sqlite_repository_SQLiteRepository { + +__init__() + +_initialize_sync() + +_apply_filters() + +_apply_bounty_filters() + } + Module_web_db_sqlite_repository ..> web_db_sqlite_repository_SQLiteRepository : contains +``` diff --git a/complete_execution_graph.md b/complete_execution_graph.md new file mode 100644 index 0000000..21aa92b --- /dev/null +++ b/complete_execution_graph.md @@ -0,0 +1,192 @@ +# DECNET: Complete Execution Graph + +This diagram represents the absolute complete call graph of the DECNET project. It connects initial entry points (CLI and Web API) through the orchestration layers, down to the low-level network and service container logic. + +```mermaid +graph TD + subgraph CLI_Entry + cli__kill_api([_kill_api]) + cli_api([api]) + cli_deploy([deploy]) + cli_collect([collect]) + cli_mutate([mutate]) + cli_status([status]) + cli_teardown([teardown]) + cli_list_services([list_services]) + cli_list_distros([list_distros]) + cli_correlate([correlate]) + cli_list_archetypes([list_archetypes]) + cli_serve_web([serve_web]) + cli_do_GET([do_GET]) + end + subgraph Fleet_Management + distros_random_hostname([distros_random_hostname]) + distros_get_distro([distros_get_distro]) + distros_random_distro([distros_random_distro]) + distros_all_distros([distros_all_distros]) + ini_loader_load_ini([ini_loader_load_ini]) + ini_loader_load_ini_from_string([ini_loader_load_ini_from_string]) + ini_loader_validate_ini_string([ini_loader_validate_ini_string]) + ini_loader__parse_configparser([ini_loader__parse_configparser]) + archetypes_get_archetype([archetypes_get_archetype]) + archetypes_all_archetypes([archetypes_all_archetypes]) + archetypes_random_archetype([archetypes_random_archetype]) + fleet_all_service_names([all_service_names]) + fleet_resolve_distros([resolve_distros]) + fleet_build_deckies([build_deckies]) + fleet_build_deckies_from_ini([build_deckies_from_ini]) + end + subgraph Deployment_Engine + network__run([network__run]) + network_detect_interface([network_detect_interface]) + network_detect_subnet([network_detect_subnet]) + network_get_host_ip([network_get_host_ip]) + network_allocate_ips([network_allocate_ips]) + network_create_macvlan_network([network_create_macvlan_network]) + network_create_ipvlan_network([network_create_ipvlan_network]) + network_remove_macvlan_network([network_remove_macvlan_network]) + network__require_root([network__require_root]) + network_setup_host_macvlan([network_setup_host_macvlan]) + network_teardown_host_macvlan([network_teardown_host_macvlan]) + network_setup_host_ipvlan([network_setup_host_ipvlan]) + network_teardown_host_ipvlan([network_teardown_host_ipvlan]) + network_ips_to_range([network_ips_to_range]) + config_random_hostname([config_random_hostname]) + config_save_state([config_save_state]) + config_load_state([config_load_state]) + config_clear_state([config_clear_state]) + composer_generate_compose([composer_generate_compose]) + composer_write_compose([composer_write_compose]) + engine_deployer__sync_logging_helper([_sync_logging_helper]) + engine_deployer__compose([_compose]) + engine_deployer__compose_with_retry([_compose_with_retry]) + engine_deployer_deploy([deploy]) + engine_deployer_teardown([teardown]) + engine_deployer_status([status]) + engine_deployer__print_status([_print_status]) + end + subgraph Monitoring_Mutation + collector_worker_parse_rfc5424([parse_rfc5424]) + collector_worker__load_service_container_names([_load_service_container_names]) + collector_worker_is_service_container([is_service_container]) + collector_worker_is_service_event([is_service_event]) + collector_worker__stream_container([_stream_container]) + collector_worker_log_collector_worker([log_collector_worker]) + collector_worker__spawn([_spawn]) + collector_worker__watch_events([_watch_events]) + mutator_engine_mutate_decky([mutate_decky]) + mutator_engine_mutate_all([mutate_all]) + mutator_engine_run_watch_loop([run_watch_loop]) + end + subgraph Web_Service + web_auth_verify_password([web_auth_verify_password]) + web_auth_get_password_hash([web_auth_get_password_hash]) + web_auth_create_access_token([web_auth_create_access_token]) + web_db_repository_initialize([web_db_repository_initialize]) + web_db_repository_add_log([web_db_repository_add_log]) + web_db_repository_get_logs([web_db_repository_get_logs]) + web_db_repository_get_total_logs([web_db_repository_get_total_logs]) + web_db_repository_get_stats_summary([web_db_repository_get_stats_summary]) + web_db_repository_get_deckies([web_db_repository_get_deckies]) + web_db_repository_get_user_by_uuid([web_db_repository_get_user_by_uuid]) + web_db_repository_update_user_password([web_db_repository_update_user_password]) + web_db_repository_add_bounty([web_db_repository_add_bounty]) + web_db_repository_get_bounties([web_db_repository_get_bounties]) + web_db_repository_get_total_bounties([web_db_repository_get_total_bounties]) + web_db_sqlite_database_get_async_engine([web_db_sqlite_database_get_async_engine]) + web_db_sqlite_database_get_sync_engine([web_db_sqlite_database_get_sync_engine]) + web_db_sqlite_database_init_db([web_db_sqlite_database_init_db]) + web_db_sqlite_repository_initialize([web_db_sqlite_repository_initialize]) + web_db_sqlite_repository_reinitialize([web_db_sqlite_repository_reinitialize]) + web_db_sqlite_repository_add_log([web_db_sqlite_repository_add_log]) + web_db_sqlite_repository__apply_filters([web_db_sqlite_repository__apply_filters]) + web_db_sqlite_repository_get_logs([web_db_sqlite_repository_get_logs]) + web_db_sqlite_repository_get_max_log_id([web_db_sqlite_repository_get_max_log_id]) + web_db_sqlite_repository_get_logs_after_id([web_db_sqlite_repository_get_logs_after_id]) + web_db_sqlite_repository_get_total_logs([web_db_sqlite_repository_get_total_logs]) + web_db_sqlite_repository_get_log_histogram([web_db_sqlite_repository_get_log_histogram]) + web_db_sqlite_repository_get_stats_summary([web_db_sqlite_repository_get_stats_summary]) + web_db_sqlite_repository_get_deckies([web_db_sqlite_repository_get_deckies]) + web_db_sqlite_repository_get_user_by_username([web_db_sqlite_repository_get_user_by_username]) + web_db_sqlite_repository_get_user_by_uuid([web_db_sqlite_repository_get_user_by_uuid]) + web_db_sqlite_repository_create_user([web_db_sqlite_repository_create_user]) + web_db_sqlite_repository_update_user_password([web_db_sqlite_repository_update_user_password]) + web_db_sqlite_repository_add_bounty([web_db_sqlite_repository_add_bounty]) + web_db_sqlite_repository__apply_bounty_filters([web_db_sqlite_repository__apply_bounty_filters]) + web_db_sqlite_repository_get_bounties([web_db_sqlite_repository_get_bounties]) + web_db_sqlite_repository_get_total_bounties([web_db_sqlite_repository_get_total_bounties]) + web_router_auth_api_change_pass_change_password([auth_api_change_pass_change_password]) + web_router_auth_api_login_login([auth_api_login_login]) + web_router_logs_api_get_logs_get_logs([logs_api_get_logs_get_logs]) + web_router_logs_api_get_histogram_get_logs_histogram([logs_api_get_histogram_get_logs_histogram]) + web_router_bounty_api_get_bounties_get_bounties([bounty_api_get_bounties_get_bounties]) + web_router_stats_api_get_stats_get_stats([stats_api_get_stats_get_stats]) + web_router_fleet_api_mutate_decky_api_mutate_decky([api_mutate_decky_api_mutate_decky]) + web_router_fleet_api_get_deckies_get_deckies([api_get_deckies_get_deckies]) + web_router_fleet_api_mutate_interval_api_update_mutate_interval([api_mutate_interval_api_update_mutate_interval]) + web_router_fleet_api_deploy_deckies_api_deploy_deckies([api_deploy_deckies_api_deploy_deckies]) + web_router_stream_api_stream_events_stream_events([stream_api_stream_events_stream_events]) + web_router_stream_api_stream_events_event_generator([stream_api_stream_events_event_generator]) + end + + %% Key Connection Edges + network_detect_interface --> network__run + network_detect_subnet --> network__run + network_get_host_ip --> network__run + network_setup_host_macvlan --> network__run + network_teardown_host_macvlan --> network__run + network_setup_host_ipvlan --> network__run + network_teardown_host_ipvlan --> network__run + + ini_loader_load_ini --> ini_loader__parse_configparser + ini_loader_load_ini_from_string --> ini_loader__parse_configparser + + composer_generate_compose --> os_fingerprint_get_os_sysctls + composer_write_compose --> composer_generate_compose + + fleet_resolve_distros --> distros_random_distro + fleet_build_deckies --> fleet_resolve_distros + fleet_build_deckies --> config_random_hostname + fleet_build_deckies_from_ini --> archetypes_get_archetype + fleet_build_deckies_from_ini --> fleet_all_service_names + + cli_deploy --> ini_loader_load_ini + cli_deploy --> network_detect_interface + cli_deploy --> fleet_build_deckies_from_ini + cli_deploy --> engine_deployer_deploy + + cli_collect --> collector_worker_log_collector_worker + cli_mutate --> mutator_engine_run_watch_loop + + cli_correlate --> correlation_engine_ingest_file + cli_correlate --> correlation_engine_traversals + + engine_deployer_deploy --> network_ips_to_range + engine_deployer_deploy --> network_setup_host_macvlan + engine_deployer_deploy --> composer_write_compose + engine_deployer_deploy --> engine_deployer__compose_with_retry + + engine_deployer_teardown --> network_teardown_host_macvlan + engine_deployer_teardown --> config_clear_state + + collector_worker_log_collector_worker --> collector_worker__stream_container + collector_worker__stream_container --> collector_worker_parse_rfc5424 + + mutator_engine_mutate_decky --> composer_write_compose + mutator_engine_mutate_decky --> engine_deployer__compose_with_retry + mutator_engine_mutate_all --> mutator_engine_mutate_decky + mutator_engine_run_watch_loop --> mutator_engine_mutate_all + + web_db_sqlite_repository_initialize --> web_db_sqlite_database_init_db + web_db_sqlite_repository_get_logs --> web_db_sqlite_repository__apply_filters + + web_router_auth_api_login_login --> web_auth_verify_password + web_router_auth_api_login_login --> web_auth_create_access_token + + web_router_logs_api_get_logs_get_logs --> web_db_sqlite_repository_get_logs + web_router_fleet_api_mutate_decky_api_mutate_decky --> mutator_engine_mutate_decky + web_router_fleet_api_deploy_deckies_api_deploy_deckies --> fleet_build_deckies_from_ini + + web_router_stream_api_stream_events_stream_events --> web_db_sqlite_repository_get_logs_after_id + web_router_stream_api_stream_events_stream_events --> web_router_stream_api_stream_events_event_generator +``` diff --git a/execution_graphs.md b/execution_graphs.md new file mode 100644 index 0000000..e817f3e --- /dev/null +++ b/execution_graphs.md @@ -0,0 +1,66 @@ +# DECNET Execution Graphs + +These graphs illustrate the logical flow of execution within the DECNET framework, showing how high-level commands and API requests trigger secondary processes and subsystem interactions. + +## 1. Deployment & Teardown Flow +This flow shows the orchestration from a CLI `deploy` command down to network setup and container instantiation. + +```mermaid +graph TD + CLI_Deploy([cli.deploy]) --> INI[ini_loader.load_ini] + CLI_Deploy --> NET_Detect[network.detect_interface] + CLI_Deploy --> FleetBuild[fleet.build_deckies_from_ini] + + FleetBuild --> Archetype[archetypes.get_archetype] + FleetBuild --> Distro[distros.get_distro] + + CLI_Deploy --> Engine_Deploy[engine.deployer.deploy] + + Engine_Deploy --> IP_Alloc[network.allocate_ips] + Engine_Deploy --> NET_Setup[network.setup_host_macvlan] + Engine_Deploy --> Compose_Gen[composer.write_compose] + Engine_Deploy --> Docker_Up[engine.deployer._compose_with_retry] + + CLI_Teardown([cli.teardown]) --> Engine_Teardown[engine.deployer.teardown] + Engine_Teardown --> NET_Cleanup[network.teardown_host_macvlan] + Engine_Teardown --> Docker_Down[engine.deployer._compose] +``` + +## 2. Mutation & Monitoring Flow +How DECNET maintains deception by periodically changing decoy identities and monitoring activities. + +```mermaid +graph LR + subgraph Periodic_Process + CLI_Mutate([cli.mutate]) --> Mutate_Loop[mutator.engine.run_watch_loop] + end + + Mutate_Loop --> Mutate_All[mutator.engine.mutate_all] + Mutate_All --> Mutate_Decky[mutator.engine.mutate_decky] + + Mutate_Decky --> Get_New_Identity[archetypes.get_archetype] + Mutate_Decky --> Rewrite_Compose[composer.write_compose] + Mutate_Decky --> Restart_Container[engine.deployer._compose_with_retry] + + subgraph Log_Collection + CLI_Collect([cli.collect]) --> Worker[collector.worker.log_collector_worker] + Worker --> Stream[collector.worker._stream_container] + Stream --> Parse[collector.worker.parse_rfc5424] + end +``` + +## 3. Web API Flow (Fleet Management) +How the Web UI interacts with the underlying systems via the FastAPI router. + +```mermaid +graph TD + Web_UI[Web Dashboard] --> API_Deploy[web.router.fleet.deploy_deckies] + Web_UI --> API_Mutate[web.router.fleet.mutate_decky] + Web_UI --> API_Stream[web.router.stream.stream_events] + + API_Deploy --> FleetBuild[fleet.build_deckies_from_ini] + API_Mutate --> Mutator[mutator.engine.mutate_decky] + + API_Stream --> DB_Pull[web.db.sqlite.repository.get_logs_after_id] + DB_Pull --> SQLite[(SQLite Database)] +``` diff --git a/mermaid.svg b/mermaid.svg new file mode 100644 index 0000000..cf14d82 --- /dev/null +++ b/mermaid.svg @@ -0,0 +1,102 @@ +

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

contains

Module_distros

+random_hostname()

+get_distro()

+random_distro()

+all_distros()

distros_DistroProfile

custom_service_CustomService

+init()

+compose_fragment()

+dockerfile_context()

Module_custom_service

Module_os_fingerprint

+get_os_sysctls()

+all_os_families()

Module_network

+_run()

+detect_interface()

+detect_subnet()

+get_host_ip()

+allocate_ips()

+create_macvlan_network()

+create_ipvlan_network()

+remove_macvlan_network()

+_require_root()

+setup_host_macvlan()

+teardown_host_macvlan()

+setup_host_ipvlan()

+teardown_host_ipvlan()

+ips_to_range()

Module_env

+_port()

+_require_env()

Module_config

+random_hostname()

+save_state()

+load_state()

+clear_state()

config_DeckyConfig

+services_not_empty()

config_DecnetConfig

Module_ini_loader

+load_ini()

+load_ini_from_string()

+validate_ini_string()

+_parse_configparser()

ini_loader_DeckySpec

ini_loader_CustomServiceSpec

ini_loader_IniConfig

Module_composer

+generate_compose()

+write_compose()

Module_archetypes

+get_archetype()

+all_archetypes()

+random_archetype()

archetypes_Archetype

Module_fleet

+all_service_names()

+resolve_distros()

+build_deckies()

+build_deckies_from_ini()

Module_cli

+_kill_api()

+api()

+deploy()

+collect()

+mutate()

+status()

+teardown()

+list_services()

+list_distros()

+correlate()

+list_archetypes()

+serve_web()

services_base_BaseService

+compose_fragment()

+dockerfile_context()

Module_services_base

services_http_HTTPService

+compose_fragment()

+dockerfile_context()

Module_services_http

services_smtp_SMTPService

+compose_fragment()

+dockerfile_context()

Module_services_smtp

services_mysql_MySQLService

+compose_fragment()

+dockerfile_context()

Module_services_mysql

services_redis_RedisService

+compose_fragment()

+dockerfile_context()

Module_services_redis

services_elasticsearch_ElasticsearchService

+compose_fragment()

+dockerfile_context()

Module_services_elasticsearch

services_ftp_FTPService

+compose_fragment()

+dockerfile_context()

Module_services_ftp

services_imap_IMAPService

+compose_fragment()

+dockerfile_context()

Module_services_imap

services_k8s_KubernetesAPIService

+compose_fragment()

+dockerfile_context()

Module_services_k8s

services_ldap_LDAPService

+compose_fragment()

+dockerfile_context()

Module_services_ldap

services_llmnr_LLMNRService

+compose_fragment()

+dockerfile_context()

Module_services_llmnr

services_mongodb_MongoDBService

+compose_fragment()

+dockerfile_context()

Module_services_mongodb

services_mqtt_MQTTService

+compose_fragment()

+dockerfile_context()

Module_services_mqtt

services_mssql_MSSQLService

+compose_fragment()

+dockerfile_context()

Module_services_mssql

services_pop3_POP3Service

+compose_fragment()

+dockerfile_context()

Module_services_pop3

services_postgres_PostgresService

+compose_fragment()

+dockerfile_context()

Module_services_postgres

services_rdp_RDPService

+compose_fragment()

+dockerfile_context()

Module_services_rdp

services_sip_SIPService

+compose_fragment()

+dockerfile_context()

Module_services_sip

services_smb_SMBService

+compose_fragment()

+dockerfile_context()

Module_services_smb

services_snmp_SNMPService

+compose_fragment()

+dockerfile_context()

Module_services_snmp

services_tftp_TFTPService

+compose_fragment()

+dockerfile_context()

Module_services_tftp

services_vnc_VNCService

+compose_fragment()

+dockerfile_context()

Module_services_vnc

services_docker_api_DockerAPIService

+compose_fragment()

+dockerfile_context()

Module_services_docker_api

Module_services_registry

+_load_plugins()

+register_custom_service()

+get_service()

+all_services()

services_smtp_relay_SMTPRelayService

+compose_fragment()

+dockerfile_context()

Module_services_smtp_relay

services_conpot_ConpotService

+compose_fragment()

+dockerfile_context()

Module_services_conpot

services_ssh_SSHService

+compose_fragment()

+dockerfile_context()

Module_services_ssh

services_telnet_TelnetService

+compose_fragment()

+dockerfile_context()

Module_services_telnet

Module_logging_forwarder

+parse_log_target()

+probe_log_target()

Module_logging_file_handler

+_get_logger()

+write_syslog()

+get_log_path()

Module_logging_syslog_formatter

+_pri()

+_truncate()

+_sd_escape()

+_sd_element()

+format_rfc5424()

correlation_graph_TraversalHop

Module_correlation_graph

correlation_graph_AttackerTraversal

+first_seen()

+last_seen()

+duration_seconds()

+deckies()

+decky_count()

+path()

+to_dict()

Module_correlation_engine

+_fmt_duration()

correlation_engine_CorrelationEngine

+init()

+ingest()

+ingest_file()

+traversals()

+all_attackers()

+report_table()

+report_json()

+traversal_syslog_lines()

Module_correlation_parser

+_parse_sd_params()

+_extract_attacker_ip()

+parse_line()

correlation_parser_LogEvent

Module_web_auth

+verify_password()

+get_password_hash()

+create_access_token()

Module_engine_deployer

+_sync_logging_helper()

+_compose()

+_compose_with_retry()

+deploy()

+teardown()

+status()

+_print_status()

Module_collector_worker

+parse_rfc5424()

+_load_service_container_names()

+is_service_container()

+is_service_event()

+_stream_container()

Module_mutator_engine

+mutate_decky()

+mutate_all()

+run_watch_loop()

web_db_repository_BaseRepository

Module_web_db_repository

web_db_models_User

Module_web_db_models

web_db_models_Log

web_db_models_Bounty

web_db_models_Token

web_db_models_LoginRequest

web_db_models_ChangePasswordRequest

web_db_models_LogsResponse

web_db_models_BountyResponse

web_db_models_StatsResponse

web_db_models_MutateIntervalRequest

web_db_models_DeployIniRequest

Module_web_db_sqlite_database

+get_async_engine()

+get_sync_engine()

+init_db()

web_db_sqlite_repository_SQLiteRepository

+init()

+_initialize_sync()

+_apply_filters()

+_apply_bounty_filters()

Module_web_db_sqlite_repository

\ No newline at end of file diff --git a/tests/api/fleet/test_mutate_decky.py b/tests/api/fleet/test_mutate_decky.py new file mode 100644 index 0000000..c8cbb97 --- /dev/null +++ b/tests/api/fleet/test_mutate_decky.py @@ -0,0 +1,41 @@ +""" +Tests for the mutate decky API endpoint. +""" + +import pytest +import httpx +from unittest.mock import patch + + +class TestMutateDecky: + @pytest.mark.asyncio + async def test_unauthenticated_returns_401(self, client: httpx.AsyncClient): + resp = await client.post("/api/v1/deckies/decky-01/mutate") + assert resp.status_code == 401 + + @pytest.mark.asyncio + async def test_successful_mutation(self, client: httpx.AsyncClient, auth_token: str): + with patch("decnet.web.router.fleet.api_mutate_decky.mutate_decky", return_value=True): + resp = await client.post( + "/api/v1/deckies/decky-01/mutate", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert resp.status_code == 200 + assert "Successfully mutated" in resp.json()["message"] + + @pytest.mark.asyncio + async def test_failed_mutation_returns_404(self, client: httpx.AsyncClient, auth_token: str): + with patch("decnet.web.router.fleet.api_mutate_decky.mutate_decky", return_value=False): + resp = await client.post( + "/api/v1/deckies/decky-01/mutate", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert resp.status_code == 404 + + @pytest.mark.asyncio + async def test_invalid_decky_name_returns_422(self, client: httpx.AsyncClient, auth_token: str): + resp = await client.post( + "/api/v1/deckies/INVALID NAME!!/mutate", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert resp.status_code == 422 diff --git a/tests/api/fleet/test_mutate_interval.py b/tests/api/fleet/test_mutate_interval.py new file mode 100644 index 0000000..c1e757f --- /dev/null +++ b/tests/api/fleet/test_mutate_interval.py @@ -0,0 +1,91 @@ +""" +Tests for the mutate interval API endpoint. +""" + +import json +import pytest +import httpx +from unittest.mock import patch, MagicMock +from pathlib import Path + +import decnet.config +from decnet.config import DeckyConfig, DecnetConfig + + +def _decky(name: str = "decky-01") -> DeckyConfig: + return DeckyConfig( + name=name, ip="192.168.1.10", services=["ssh"], + distro="debian", base_image="debian", hostname="test-host", + build_base="debian:bookworm-slim", nmap_os="linux", + mutate_interval=30, + ) + + +def _config() -> DecnetConfig: + return DecnetConfig( + mode="unihost", interface="eth0", subnet="192.168.1.0/24", + gateway="192.168.1.1", deckies=[_decky()], + ) + + +class TestMutateInterval: + @pytest.mark.asyncio + async def test_unauthenticated_returns_401(self, client: httpx.AsyncClient): + resp = await client.put( + "/api/v1/deckies/decky-01/mutate-interval", + json={"mutate_interval": 60}, + ) + assert resp.status_code == 401 + + @pytest.mark.asyncio + async def test_no_active_deployment(self, client: httpx.AsyncClient, auth_token: str): + with patch("decnet.web.router.fleet.api_mutate_interval.load_state", return_value=None): + resp = await client.put( + "/api/v1/deckies/decky-01/mutate-interval", + headers={"Authorization": f"Bearer {auth_token}"}, + json={"mutate_interval": 60}, + ) + assert resp.status_code == 500 + + @pytest.mark.asyncio + async def test_decky_not_found(self, client: httpx.AsyncClient, auth_token: str): + config = _config() + with patch("decnet.web.router.fleet.api_mutate_interval.load_state", + return_value=(config, Path("test.yml"))): + resp = await client.put( + "/api/v1/deckies/nonexistent/mutate-interval", + headers={"Authorization": f"Bearer {auth_token}"}, + json={"mutate_interval": 60}, + ) + assert resp.status_code == 404 + + @pytest.mark.asyncio + async def test_successful_interval_update(self, client: httpx.AsyncClient, auth_token: str): + config = _config() + with patch("decnet.web.router.fleet.api_mutate_interval.load_state", + return_value=(config, Path("test.yml"))): + with patch("decnet.web.router.fleet.api_mutate_interval.save_state") as mock_save: + resp = await client.put( + "/api/v1/deckies/decky-01/mutate-interval", + headers={"Authorization": f"Bearer {auth_token}"}, + json={"mutate_interval": 120}, + ) + assert resp.status_code == 200 + assert resp.json()["message"] == "Mutation interval updated" + mock_save.assert_called_once() + # Verify the interval was actually updated on the decky config + assert config.deckies[0].mutate_interval == 120 + + @pytest.mark.asyncio + async def test_null_interval_removes_mutation(self, client: httpx.AsyncClient, auth_token: str): + config = _config() + with patch("decnet.web.router.fleet.api_mutate_interval.load_state", + return_value=(config, Path("test.yml"))): + with patch("decnet.web.router.fleet.api_mutate_interval.save_state"): + resp = await client.put( + "/api/v1/deckies/decky-01/mutate-interval", + headers={"Authorization": f"Bearer {auth_token}"}, + json={"mutate_interval": None}, + ) + assert resp.status_code == 200 + assert config.deckies[0].mutate_interval is None diff --git a/tests/api/stream/__init__.py b/tests/api/stream/__init__.py new file mode 100644 index 0000000..c3b2ed3 --- /dev/null +++ b/tests/api/stream/__init__.py @@ -0,0 +1 @@ +# Stream test package diff --git a/tests/api/stream/test_stream_events.py b/tests/api/stream/test_stream_events.py new file mode 100644 index 0000000..4a9df9b --- /dev/null +++ b/tests/api/stream/test_stream_events.py @@ -0,0 +1,54 @@ +""" +Tests for the SSE stream endpoint (decnet/web/router/stream/api_stream_events.py). +""" + +import json +import pytest +import httpx +import asyncio + +from unittest.mock import AsyncMock, MagicMock, patch + + +# ── Stream endpoint tests ───────────────────────────────────────────────────── + +class TestStreamEvents: + @pytest.mark.asyncio + async def test_unauthenticated_returns_401(self, client: httpx.AsyncClient): + resp = await client.get("/api/v1/stream") + assert resp.status_code == 401 + + @pytest.mark.asyncio + async def test_stream_sends_initial_stats(self, client: httpx.AsyncClient, auth_token: str): + # We force the generator to exit immediately by making the first awaitable raise + with patch("decnet.web.router.stream.api_stream_events.repo") as mock_repo: + mock_repo.get_max_log_id = AsyncMock(side_effect=StopAsyncIteration) + + # This will hit the 'except Exception' or just exit the generator + resp = await client.get( + "/api/v1/stream", + headers={"Authorization": f"Bearer {auth_token}"}, + params={"lastEventId": "0"}, + ) + # It might return a 200 with an empty/error stream or a 500 depending on how SSE-starlette handles generator failure + # But the important thing is that it FINISHES. + assert resp.status_code in (200, 500) + + @pytest.mark.asyncio + async def test_stream_with_query_token(self, client: httpx.AsyncClient, auth_token: str): + # Apply the same crash-fix to avoid hanging + with patch("decnet.web.router.stream.api_stream_events.repo") as mock_repo: + mock_repo.get_max_log_id = AsyncMock(side_effect=StopAsyncIteration) + resp = await client.get( + "/api/v1/stream", + params={"token": auth_token, "lastEventId": "0"}, + ) + assert resp.status_code in (200, 500) + + @pytest.mark.asyncio + async def test_stream_invalid_token_401(self, client: httpx.AsyncClient): + resp = await client.get( + "/api/v1/stream", + params={"token": "bad-token", "lastEventId": "0"}, + ) + assert resp.status_code == 401 diff --git a/tests/test_base_repo.py b/tests/test_base_repo.py new file mode 100644 index 0000000..d0efc78 --- /dev/null +++ b/tests/test_base_repo.py @@ -0,0 +1,39 @@ +""" +Mock test for BaseRepository to ensure coverage of abstract pass lines. +""" + +import pytest +from decnet.web.db.repository import BaseRepository + +class DummyRepo(BaseRepository): + async def initialize(self) -> None: await super().initialize() + async def add_log(self, data): await super().add_log(data) + async def get_logs(self, **kw): await super().get_logs(**kw) + async def get_total_logs(self, **kw): await super().get_total_logs(**kw) + async def get_stats_summary(self): await super().get_stats_summary() + async def get_deckies(self): await super().get_deckies() + async def get_user_by_username(self, u): await super().get_user_by_username(u) + async def get_user_by_uuid(self, u): await super().get_user_by_uuid(u) + async def create_user(self, d): await super().create_user(d) + async def update_user_password(self, *a, **kw): await super().update_user_password(*a, **kw) + async def add_bounty(self, d): await super().add_bounty(d) + async def get_bounties(self, **kw): await super().get_bounties(**kw) + async def get_total_bounties(self, **kw): await super().get_total_bounties(**kw) + +@pytest.mark.asyncio +async def test_base_repo_coverage(): + dr = DummyRepo() + # Call all to hit 'pass' statements + await dr.initialize() + await dr.add_log({}) + await dr.get_logs() + await dr.get_total_logs() + await dr.get_stats_summary() + await dr.get_deckies() + await dr.get_user_by_username("a") + await dr.get_user_by_uuid("a") + await dr.create_user({}) + await dr.update_user_password("a", "b") + await dr.add_bounty({}) + await dr.get_bounties() + await dr.get_total_bounties() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..440cd22 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,364 @@ +""" +Tests for decnet/cli.py β€” CLI commands via Typer's CliRunner. +""" + +import subprocess +import os +import socketserver +from pathlib import Path +from unittest.mock import MagicMock, patch, AsyncMock + +import pytest +import psutil +from typer.testing import CliRunner + +from decnet.cli import app +from decnet.config import DeckyConfig, DecnetConfig + +runner = CliRunner() + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _decky(name: str = "decky-01", ip: str = "192.168.1.10") -> DeckyConfig: + return DeckyConfig( + name=name, ip=ip, services=["ssh"], + distro="debian", base_image="debian", hostname="test-host", + build_base="debian:bookworm-slim", nmap_os="linux", + ) + + +def _config() -> DecnetConfig: + return DecnetConfig( + mode="unihost", interface="eth0", subnet="192.168.1.0/24", + gateway="192.168.1.1", deckies=[_decky()], + ) + + +# ── services command ────────────────────────────────────────────────────────── + +class TestServicesCommand: + def test_lists_services(self): + result = runner.invoke(app, ["services"]) + assert result.exit_code == 0 + assert "ssh" in result.stdout + + +# ── distros command ─────────────────────────────────────────────────────────── + +class TestDistrosCommand: + def test_lists_distros(self): + result = runner.invoke(app, ["distros"]) + assert result.exit_code == 0 + assert "debian" in result.stdout.lower() + + +# ── archetypes command ──────────────────────────────────────────────────────── + +class TestArchetypesCommand: + def test_lists_archetypes(self): + result = runner.invoke(app, ["archetypes"]) + assert result.exit_code == 0 + assert "deaddeck" in result.stdout.lower() + + +# ── deploy command ──────────────────────────────────────────────────────────── + +class TestDeployCommand: + @patch("decnet.engine.deploy") + @patch("decnet.cli.allocate_ips", return_value=["192.168.1.10"]) + @patch("decnet.cli.get_host_ip", return_value="192.168.1.2") + @patch("decnet.cli.detect_subnet", return_value=("192.168.1.0/24", "192.168.1.1")) + @patch("decnet.cli.detect_interface", return_value="eth0") + def test_deploy_dry_run(self, mock_iface, mock_subnet, mock_hip, + mock_ips, mock_deploy): + result = runner.invoke(app, [ + "deploy", "--deckies", "1", "--services", "ssh", "--dry-run", + ]) + assert result.exit_code == 0 + mock_deploy.assert_called_once() + + def test_deploy_no_interface_found(self): + with patch("decnet.cli.detect_interface", side_effect=ValueError("No interface")): + result = runner.invoke(app, ["deploy", "--deckies", "1"]) + assert result.exit_code == 1 + + def test_deploy_no_subnet_found(self): + with patch("decnet.cli.detect_interface", return_value="eth0"), \ + patch("decnet.cli.detect_subnet", side_effect=ValueError("No subnet")): + result = runner.invoke(app, ["deploy", "--deckies", "1", "--services", "ssh"]) + assert result.exit_code == 1 + + def test_deploy_invalid_mode(self): + result = runner.invoke(app, ["deploy", "--mode", "invalid", "--deckies", "1"]) + assert result.exit_code == 1 + + @patch("decnet.cli.detect_interface", return_value="eth0") + def test_deploy_no_deckies_no_config(self, mock_iface): + result = runner.invoke(app, ["deploy", "--services", "ssh"]) + assert result.exit_code == 1 + + @patch("decnet.cli.detect_interface", return_value="eth0") + def test_deploy_no_services_no_randomize(self, mock_iface): + result = runner.invoke(app, ["deploy", "--deckies", "1"]) + assert result.exit_code == 1 + + @patch("decnet.engine.deploy") + @patch("decnet.cli.allocate_ips", return_value=["192.168.1.10"]) + @patch("decnet.cli.get_host_ip", return_value="192.168.1.2") + @patch("decnet.cli.detect_subnet", return_value=("192.168.1.0/24", "192.168.1.1")) + @patch("decnet.cli.detect_interface", return_value="eth0") + def test_deploy_with_archetype(self, mock_iface, mock_subnet, mock_hip, + mock_ips, mock_deploy): + result = runner.invoke(app, [ + "deploy", "--deckies", "1", "--archetype", "deaddeck", "--dry-run", + ]) + assert result.exit_code == 0 + + def test_deploy_invalid_archetype(self): + result = runner.invoke(app, [ + "deploy", "--deckies", "1", "--archetype", "nonexistent_arch", + ]) + assert result.exit_code == 1 + + @patch("decnet.engine.deploy") + @patch("subprocess.Popen") + @patch("decnet.cli.allocate_ips", return_value=["192.168.1.10"]) + @patch("decnet.cli.get_host_ip", return_value="192.168.1.2") + @patch("decnet.cli.detect_subnet", return_value=("192.168.1.0/24", "192.168.1.1")) + @patch("decnet.cli.detect_interface", return_value="eth0") + def test_deploy_full_with_api(self, mock_iface, mock_subnet, mock_hip, + mock_ips, mock_popen, mock_deploy): + # Test non-dry-run with API and collector starts + result = runner.invoke(app, [ + "deploy", "--deckies", "1", "--services", "ssh", "--api", + ]) + assert result.exit_code == 0 + assert mock_popen.call_count >= 1 # API + + @patch("decnet.engine.deploy") + @patch("decnet.cli.allocate_ips", return_value=["192.168.1.10"]) + @patch("decnet.cli.get_host_ip", return_value="192.168.1.2") + @patch("decnet.cli.detect_subnet", return_value=("192.168.1.0/24", "192.168.1.1")) + @patch("decnet.cli.detect_interface", return_value="eth0") + def test_deploy_with_distro(self, mock_iface, mock_subnet, mock_hip, + mock_ips, mock_deploy): + result = runner.invoke(app, [ + "deploy", "--deckies", "1", "--services", "ssh", "--distro", "debian", "--dry-run", + ]) + assert result.exit_code == 0 + + def test_deploy_invalid_distro(self): + result = runner.invoke(app, [ + "deploy", "--deckies", "1", "--services", "ssh", "--distro", "nonexistent_distro", + ]) + assert result.exit_code == 1 + + @patch("decnet.engine.deploy") + @patch("decnet.cli.load_ini") + @patch("decnet.cli.get_host_ip", return_value="192.168.1.2") + @patch("decnet.cli.detect_subnet", return_value=("192.168.1.0/24", "192.168.1.1")) + @patch("decnet.cli.detect_interface", return_value="eth0") + def test_deploy_with_config_file(self, mock_iface, mock_subnet, mock_hip, + mock_load_ini, mock_deploy, tmp_path): + from decnet.ini_loader import IniConfig, DeckySpec + ini_file = tmp_path / "test.ini" + ini_file.touch() + mock_load_ini.return_value = IniConfig( + deckies=[DeckySpec(name="test-1", services=["ssh"], ip="192.168.1.50")], + interface="eth0", subnet="192.168.1.0/24", gateway="192.168.1.1", + ) + result = runner.invoke(app, [ + "deploy", "--config", str(ini_file), "--dry-run", + ]) + assert result.exit_code == 0 + + def test_deploy_config_file_not_found(self): + result = runner.invoke(app, [ + "deploy", "--config", "/nonexistent/config.ini", + ]) + assert result.exit_code == 1 + + +# ── teardown command ────────────────────────────────────────────────────────── + +class TestTeardownCommand: + def test_teardown_no_args(self): + result = runner.invoke(app, ["teardown"]) + assert result.exit_code == 1 + + @patch("decnet.cli._kill_api") + @patch("decnet.engine.teardown") + def test_teardown_all(self, mock_teardown, mock_kill): + result = runner.invoke(app, ["teardown", "--all"]) + assert result.exit_code == 0 + + @patch("decnet.engine.teardown") + def test_teardown_by_id(self, mock_teardown): + result = runner.invoke(app, ["teardown", "--id", "decky-01"]) + assert result.exit_code == 0 + mock_teardown.assert_called_once_with(decky_id="decky-01") + + @patch("decnet.engine.teardown", side_effect=Exception("Teardown failed")) + def test_teardown_error(self, mock_teardown): + result = runner.invoke(app, ["teardown", "--all"]) + assert result.exit_code == 1 + + @patch("decnet.engine.teardown", side_effect=Exception("Specific ID failed")) + def test_teardown_id_error(self, mock_teardown): + result = runner.invoke(app, ["teardown", "--id", "decky-01"]) + assert result.exit_code == 1 + + +# ── status command ──────────────────────────────────────────────────────────── + +class TestStatusCommand: + @patch("decnet.engine.status", return_value=[]) + def test_status_empty(self, mock_status): + result = runner.invoke(app, ["status"]) + assert result.exit_code == 0 + + @patch("decnet.engine.status", return_value=[{"ID": "1", "Status": "running"}]) + def test_status_active(self, mock_status): + result = runner.invoke(app, ["status"]) + assert result.exit_code == 0 + + +# ── mutate command ──────────────────────────────────────────────────────────── + +class TestMutateCommand: + @patch("decnet.mutator.mutate_all") + def test_mutate_default(self, mock_mutate_all): + result = runner.invoke(app, ["mutate"]) + assert result.exit_code == 0 + + @patch("decnet.mutator.mutate_all") + def test_mutate_force_all(self, mock_mutate_all): + result = runner.invoke(app, ["mutate", "--all"]) + assert result.exit_code == 0 + + @patch("decnet.mutator.mutate_decky") + def test_mutate_specific_decky(self, mock_mutate): + result = runner.invoke(app, ["mutate", "--decky", "decky-01"]) + assert result.exit_code == 0 + + @patch("decnet.mutator.run_watch_loop") + def test_mutate_watch(self, mock_watch): + result = runner.invoke(app, ["mutate", "--watch"]) + assert result.exit_code == 0 + + @patch("decnet.mutator.mutate_all", side_effect=Exception("Mutate error")) + def test_mutate_error(self, mock_mutate): + result = runner.invoke(app, ["mutate"]) + assert result.exit_code == 1 + + +# ── collect command ─────────────────────────────────────────────────────────── + +class TestCollectCommand: + @patch("asyncio.run") + def test_collect(self, mock_run): + result = runner.invoke(app, ["collect"]) + assert result.exit_code == 0 + + @patch("asyncio.run", side_effect=KeyboardInterrupt) + def test_collect_interrupt(self, mock_run): + result = runner.invoke(app, ["collect"]) + assert result.exit_code in (0, 130) + + @patch("asyncio.run", side_effect=Exception("Collect error")) + def test_collect_error(self, mock_run): + result = runner.invoke(app, ["collect"]) + assert result.exit_code == 1 + + +# ── web command ─────────────────────────────────────────────────────────────── + +class TestWebCommand: + @patch("pathlib.Path.exists", return_value=False) + def test_web_no_dist(self, mock_exists): + result = runner.invoke(app, ["web"]) + assert result.exit_code == 1 + assert "Frontend build not found" in result.stdout + + @patch("socketserver.TCPServer") + @patch("os.chdir") + @patch("pathlib.Path.exists", return_value=True) + def test_web_success(self, mock_exists, mock_chdir, mock_server): + # We need to simulate a KeyboardInterrupt to stop serve_forever + mock_server.return_value.__enter__.return_value.serve_forever.side_effect = KeyboardInterrupt + result = runner.invoke(app, ["web"]) + assert result.exit_code == 0 + assert "Serving DECNET Web Dashboard" in result.stdout + + +# ── correlate command ───────────────────────────────────────────────────────── + +class TestCorrelateCommand: + def test_correlate_no_input(self): + with patch("sys.stdin.isatty", return_value=True): + result = runner.invoke(app, ["correlate"]) + if result.exit_code != 0: + assert result.exit_code == 1 + assert "Provide --log-file" in result.stdout + + def test_correlate_with_file(self, tmp_path): + log_file = tmp_path / "test.log" + log_file.write_text( + "<134>1 2024-01-15T12:00:00+00:00 decky-01 ssh - auth " + '[decnet@55555 src_ip="10.0.0.5" username="admin"] login\n' + ) + result = runner.invoke(app, ["correlate", "--log-file", str(log_file)]) + assert result.exit_code == 0 + + +# ── api command ─────────────────────────────────────────────────────────────── + +class TestApiCommand: + @patch("subprocess.run", side_effect=KeyboardInterrupt) + def test_api_keyboard_interrupt(self, mock_run): + result = runner.invoke(app, ["api"]) + assert result.exit_code == 0 + + @patch("subprocess.run", side_effect=FileNotFoundError) + def test_api_not_found(self, mock_run): + result = runner.invoke(app, ["api"]) + assert result.exit_code == 0 + + +# ── _kill_api ───────────────────────────────────────────────────────────────── + +class TestKillApi: + @patch("os.kill") + @patch("psutil.process_iter") + def test_kills_matching_processes(self, mock_iter, mock_kill): + from decnet.cli import _kill_api + mock_uvicorn = MagicMock() + mock_uvicorn.info = { + "pid": 111, "name": "python", + "cmdline": ["python", "-m", "uvicorn", "decnet.web.api:app"], + } + mock_mutate = MagicMock() + mock_mutate.info = { + "pid": 222, "name": "python", + "cmdline": ["python", "decnet.cli", "mutate", "--watch"], + } + mock_iter.return_value = [mock_uvicorn, mock_mutate] + _kill_api() + assert mock_kill.call_count == 2 + + @patch("psutil.process_iter") + def test_no_matching_processes(self, mock_iter): + from decnet.cli import _kill_api + mock_proc = MagicMock() + mock_proc.info = {"pid": 1, "name": "bash", "cmdline": ["bash"]} + mock_iter.return_value = [mock_proc] + _kill_api() + + @patch("psutil.process_iter") + def test_handles_empty_cmdline(self, mock_iter): + from decnet.cli import _kill_api + mock_proc = MagicMock() + mock_proc.info = {"pid": 1, "name": "bash", "cmdline": None} + mock_iter.return_value = [mock_proc] + _kill_api() diff --git a/tests/test_collector.py b/tests/test_collector.py index edef7f2..ca0a9da 100644 --- a/tests/test_collector.py +++ b/tests/test_collector.py @@ -1,9 +1,17 @@ """Tests for the host-side Docker log collector.""" import json +import asyncio +import pytest +from pathlib import Path from types import SimpleNamespace -from unittest.mock import patch +from unittest.mock import patch, MagicMock, AsyncMock from decnet.collector import parse_rfc5424, is_service_container, is_service_event +from decnet.collector.worker import ( + _stream_container, + _load_service_container_names, + log_collector_worker +) _KNOWN_NAMES = {"omega-decky-http", "omega-decky-smtp", "relay-decky-ftp"} @@ -50,6 +58,21 @@ class TestParseRfc5424: result = parse_rfc5424(line) assert result["attacker_ip"] == "10.0.0.5" + def test_extracts_attacker_ip_from_client_ip(self): + line = self._make_line('client_ip="10.0.0.7"') + result = parse_rfc5424(line) + assert result["attacker_ip"] == "10.0.0.7" + + def test_extracts_attacker_ip_from_remote_ip(self): + line = self._make_line('remote_ip="10.0.0.8"') + result = parse_rfc5424(line) + assert result["attacker_ip"] == "10.0.0.8" + + def test_extracts_attacker_ip_from_ip(self): + line = self._make_line('ip="10.0.0.9"') + result = parse_rfc5424(line) + assert result["attacker_ip"] == "10.0.0.9" + def test_attacker_ip_defaults_to_unknown(self): line = self._make_line('user="admin"') result = parse_rfc5424(line) @@ -88,6 +111,26 @@ class TestParseRfc5424: # Should not raise json.dumps(result) + def test_invalid_timestamp_preserved_as_is(self): + line = "<134>1 not-a-date decky-01 http - request -" + result = parse_rfc5424(line) + assert result is not None + assert result["timestamp"] == "not-a-date" + + def test_sd_rest_is_plain_text(self): + # When SD starts with neither '-' nor '[', treat as msg + line = "<134>1 2024-01-15T12:00:00+00:00 decky-01 http - request hello world" + result = parse_rfc5424(line) + assert result is not None + assert result["msg"] == "hello world" + + def test_sd_with_msg_after_bracket(self): + line = '<134>1 2024-01-15T12:00:00+00:00 decky-01 http - request [decnet@55555 src_ip="1.2.3.4"] login attempt' + result = parse_rfc5424(line) + assert result is not None + assert result["fields"]["src_ip"] == "1.2.3.4" + assert result["msg"] == "login attempt" + class TestIsServiceContainer: def test_known_container_returns_true(self): @@ -113,6 +156,12 @@ class TestIsServiceContainer: with patch("decnet.collector.worker._load_service_container_names", return_value=set()): assert is_service_container(_make_container("omega-decky-http")) is False + def test_string_argument(self): + with patch("decnet.collector.worker._load_service_container_names", return_value=_KNOWN_NAMES): + assert is_service_container("omega-decky-http") is True + assert is_service_container("/omega-decky-http") is True + assert is_service_container("nginx") is False + class TestIsServiceEvent: def test_known_service_event_returns_true(self): @@ -130,3 +179,171 @@ class TestIsServiceEvent: def test_no_state_returns_false(self): with patch("decnet.collector.worker._load_service_container_names", return_value=set()): assert is_service_event({"name": "omega-decky-smtp"}) is False + + def test_strips_leading_slash(self): + with patch("decnet.collector.worker._load_service_container_names", return_value=_KNOWN_NAMES): + assert is_service_event({"name": "/omega-decky-smtp"}) is True + + def test_empty_name(self): + with patch("decnet.collector.worker._load_service_container_names", return_value=_KNOWN_NAMES): + assert is_service_event({"name": ""}) is False + assert is_service_event({}) is False + + +class TestLoadServiceContainerNames: + def test_with_valid_state(self, tmp_path, monkeypatch): + import decnet.config + from decnet.config import DeckyConfig, DecnetConfig + state_file = tmp_path / "state.json" + config = DecnetConfig( + mode="unihost", interface="eth0", subnet="192.168.1.0/24", + gateway="192.168.1.1", + deckies=[ + DeckyConfig(name="decky-01", ip="192.168.1.10", services=["ssh", "http"], + distro="debian", base_image="debian", hostname="test", + build_base="debian:bookworm-slim"), + ], + ) + state_file.write_text(json.dumps({ + "config": config.model_dump(), + "compose_path": "test.yml", + })) + monkeypatch.setattr(decnet.config, "STATE_FILE", state_file) + names = _load_service_container_names() + assert names == {"decky-01-ssh", "decky-01-http"} + + def test_no_state(self, tmp_path, monkeypatch): + import decnet.config + state_file = tmp_path / "nonexistent.json" + monkeypatch.setattr(decnet.config, "STATE_FILE", state_file) + names = _load_service_container_names() + assert names == set() + + +class TestStreamContainer: + def test_streams_rfc5424_lines(self, tmp_path): + log_path = tmp_path / "test.log" + json_path = tmp_path / "test.json" + + mock_container = MagicMock() + rfc_line = '<134>1 2024-01-15T12:00:00+00:00 decky-01 ssh - auth [decnet@55555 src_ip="1.2.3.4"] login\n' + mock_container.logs.return_value = [rfc_line.encode("utf-8")] + + mock_client = MagicMock() + mock_client.containers.get.return_value = mock_container + + with patch("docker.from_env", return_value=mock_client): + _stream_container("test-id", log_path, json_path) + + assert log_path.exists() + log_content = log_path.read_text() + assert "decky-01" in log_content + + assert json_path.exists() + json_content = json_path.read_text().strip() + parsed = json.loads(json_content) + assert parsed["service"] == "ssh" + + def test_handles_non_rfc5424_lines(self, tmp_path): + log_path = tmp_path / "test.log" + json_path = tmp_path / "test.json" + + mock_container = MagicMock() + mock_container.logs.return_value = [b"just a plain log line\n"] + + mock_client = MagicMock() + mock_client.containers.get.return_value = mock_container + + with patch("docker.from_env", return_value=mock_client): + _stream_container("test-id", log_path, json_path) + + assert log_path.exists() + assert json_path.read_text() == "" # No JSON written for non-RFC lines + + def test_handles_docker_error(self, tmp_path): + log_path = tmp_path / "test.log" + json_path = tmp_path / "test.json" + + mock_client = MagicMock() + mock_client.containers.get.side_effect = Exception("Container not found") + + with patch("docker.from_env", return_value=mock_client): + _stream_container("bad-id", log_path, json_path) + + # Should not raise, just log the error + + def test_skips_empty_lines(self, tmp_path): + log_path = tmp_path / "test.log" + json_path = tmp_path / "test.json" + + mock_container = MagicMock() + mock_container.logs.return_value = [b"\n\n\n"] + + mock_client = MagicMock() + mock_client.containers.get.return_value = mock_container + + with patch("docker.from_env", return_value=mock_client): + _stream_container("test-id", log_path, json_path) + + assert log_path.read_text() == "" + + +class TestLogCollectorWorker: + @pytest.mark.asyncio + async def test_worker_initial_discovery(self, tmp_path): + log_file = str(tmp_path / "decnet.log") + + mock_container = MagicMock() + mock_container.id = "c1" + mock_container.name = "/s-1" + # Mock labels to satisfy is_service_container + mock_container.labels = {"com.docker.compose.project": "decnet"} + + mock_client = MagicMock() + mock_client.containers.list.return_value = [mock_container] + # Make events return an empty generator/iterator immediately + mock_client.events.return_value = iter([]) + + with patch("docker.from_env", return_value=mock_client), \ + patch("decnet.collector.worker.is_service_container", return_value=True): + # Run with a short task timeout because it loops + try: + await asyncio.wait_for(log_collector_worker(log_file), timeout=0.1) + except (asyncio.TimeoutError, StopIteration): + pass + + # Should have tried to list and watch events + mock_client.containers.list.assert_called_once() + + @pytest.mark.asyncio + async def test_worker_handles_events(self, tmp_path): + log_file = str(tmp_path / "decnet.log") + + mock_client = MagicMock() + mock_client.containers.list.return_value = [] + + event = { + "id": "c2", + "Actor": {"Attributes": {"name": "s-2", "com.docker.compose.project": "decnet"}} + } + mock_client.events.return_value = iter([event]) + + with patch("docker.from_env", return_value=mock_client), \ + patch("decnet.collector.worker.is_service_event", return_value=True): + try: + await asyncio.wait_for(log_collector_worker(log_file), timeout=0.1) + except (asyncio.TimeoutError, StopIteration): + pass + + mock_client.events.assert_called_once() + + @pytest.mark.asyncio + async def test_worker_exception_handling(self, tmp_path): + log_file = str(tmp_path / "decnet.log") + mock_client = MagicMock() + mock_client.containers.list.side_effect = Exception("Docker down") + + with patch("docker.from_env", return_value=mock_client): + # Should not raise + await log_collector_worker(log_file) + diff --git a/tests/test_deployer.py b/tests/test_deployer.py new file mode 100644 index 0000000..4af61c3 --- /dev/null +++ b/tests/test_deployer.py @@ -0,0 +1,309 @@ +""" +Tests for decnet/engine/deployer.py + +Covers _compose, _compose_with_retry, _sync_logging_helper, +deploy (dry-run and mocked), teardown, status, and _print_status. +All Docker and subprocess calls are mocked. +""" + +import subprocess +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch, call + +import pytest + +from decnet.config import DeckyConfig, DecnetConfig + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _decky(name: str = "decky-01", ip: str = "192.168.1.10", + services: list[str] | None = None) -> DeckyConfig: + return DeckyConfig( + name=name, ip=ip, services=services or ["ssh"], + distro="debian", base_image="debian", hostname="test-host", + build_base="debian:bookworm-slim", nmap_os="linux", + ) + + +def _config(deckies: list[DeckyConfig] | None = None, ipvlan: bool = False) -> DecnetConfig: + return DecnetConfig( + mode="unihost", interface="eth0", subnet="192.168.1.0/24", + gateway="192.168.1.1", deckies=deckies or [_decky()], + ipvlan=ipvlan, + ) + + +# ── _compose ────────────────────────────────────────────────────────────────── + +class TestCompose: + @patch("decnet.engine.deployer.subprocess.run") + def test_compose_constructs_correct_command(self, mock_run): + from decnet.engine.deployer import _compose + _compose("up", "-d", compose_file=Path("test.yml")) + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + assert cmd[:4] == ["docker", "compose", "-f", "test.yml"] + assert "up" in cmd + assert "-d" in cmd + + @patch("decnet.engine.deployer.subprocess.run") + def test_compose_passes_env(self, mock_run): + from decnet.engine.deployer import _compose + _compose("build", env={"DOCKER_BUILDKIT": "1"}) + _, kwargs = mock_run.call_args + assert "DOCKER_BUILDKIT" in kwargs["env"] + + +# ── _compose_with_retry ─────────────────────────────────────────────────────── + +class TestComposeWithRetry: + @patch("decnet.engine.deployer.subprocess.run") + def test_success_first_try(self, mock_run): + from decnet.engine.deployer import _compose_with_retry + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + _compose_with_retry("up", "-d") # should not raise + + @patch("decnet.engine.deployer.time.sleep") + @patch("decnet.engine.deployer.subprocess.run") + def test_transient_failure_retries(self, mock_run, mock_sleep): + from decnet.engine.deployer import _compose_with_retry + fail_result = MagicMock(returncode=1, stdout="", stderr="temporary error") + ok_result = MagicMock(returncode=0, stdout="ok", stderr="") + mock_run.side_effect = [fail_result, ok_result] + _compose_with_retry("up", retries=3) + assert mock_run.call_count == 2 + mock_sleep.assert_called_once() + + @patch("decnet.engine.deployer.time.sleep") + @patch("decnet.engine.deployer.subprocess.run") + def test_permanent_error_no_retry(self, mock_run, mock_sleep): + from decnet.engine.deployer import _compose_with_retry + fail_result = MagicMock(returncode=1, stdout="", stderr="manifest unknown error") + mock_run.return_value = fail_result + with pytest.raises(subprocess.CalledProcessError): + _compose_with_retry("pull", retries=3) + assert mock_run.call_count == 1 + mock_sleep.assert_not_called() + + @patch("decnet.engine.deployer.time.sleep") + @patch("decnet.engine.deployer.subprocess.run") + def test_max_retries_exhausted(self, mock_run, mock_sleep): + from decnet.engine.deployer import _compose_with_retry + fail_result = MagicMock(returncode=1, stdout="", stderr="connection refused") + mock_run.return_value = fail_result + with pytest.raises(subprocess.CalledProcessError): + _compose_with_retry("up", retries=2) + assert mock_run.call_count == 2 + + @patch("decnet.engine.deployer.subprocess.run") + def test_stdout_printed_on_success(self, mock_run, capsys): + from decnet.engine.deployer import _compose_with_retry + mock_run.return_value = MagicMock(returncode=0, stdout="done\n", stderr="") + _compose_with_retry("build") + captured = capsys.readouterr() + assert "done" in captured.out + + +# ── _sync_logging_helper ───────────────────────────────────────────────────── + +class TestSyncLoggingHelper: + @patch("decnet.engine.deployer.shutil.copy2") + @patch("decnet.engine.deployer._CANONICAL_LOGGING") + def test_copies_when_file_differs(self, mock_canonical, mock_copy): + from decnet.engine.deployer import _sync_logging_helper + mock_svc = MagicMock() + mock_svc.dockerfile_context.return_value = Path("/tmp/test_ctx") + mock_canonical.__truediv__ = Path.__truediv__ + + with patch("decnet.services.registry.get_service", return_value=mock_svc): + with patch("pathlib.Path.exists", return_value=False): + config = _config() + _sync_logging_helper(config) + + +# ── deploy ──────────────────────────────────────────────────────────────────── + +class TestDeploy: + @patch("decnet.engine.deployer._print_status") + @patch("decnet.engine.deployer._compose_with_retry") + @patch("decnet.engine.deployer.save_state") + @patch("decnet.engine.deployer.write_compose", return_value=Path("test.yml")) + @patch("decnet.engine.deployer._sync_logging_helper") + @patch("decnet.engine.deployer.setup_host_macvlan") + @patch("decnet.engine.deployer.create_macvlan_network") + @patch("decnet.engine.deployer.get_host_ip", return_value="192.168.1.2") + @patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32") + @patch("decnet.engine.deployer.docker.from_env") + def test_dry_run_no_containers(self, mock_docker, mock_range, mock_hip, + mock_create, mock_setup, mock_sync, + mock_compose, mock_save, mock_retry, mock_print): + from decnet.engine.deployer import deploy + config = _config() + deploy(config, dry_run=True) + mock_create.assert_not_called() + mock_retry.assert_not_called() + mock_save.assert_not_called() + + @patch("decnet.engine.deployer._print_status") + @patch("decnet.engine.deployer._compose_with_retry") + @patch("decnet.engine.deployer.save_state") + @patch("decnet.engine.deployer.write_compose", return_value=Path("test.yml")) + @patch("decnet.engine.deployer._sync_logging_helper") + @patch("decnet.engine.deployer.setup_host_macvlan") + @patch("decnet.engine.deployer.create_macvlan_network") + @patch("decnet.engine.deployer.get_host_ip", return_value="192.168.1.2") + @patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32") + @patch("decnet.engine.deployer.docker.from_env") + def test_macvlan_deploy(self, mock_docker, mock_range, mock_hip, + mock_create, mock_setup, mock_sync, + mock_compose, mock_save, mock_retry, mock_print): + from decnet.engine.deployer import deploy + config = _config(ipvlan=False) + deploy(config) + mock_create.assert_called_once() + mock_setup.assert_called_once() + mock_save.assert_called_once() + mock_retry.assert_called() + + @patch("decnet.engine.deployer._print_status") + @patch("decnet.engine.deployer._compose_with_retry") + @patch("decnet.engine.deployer.save_state") + @patch("decnet.engine.deployer.write_compose", return_value=Path("test.yml")) + @patch("decnet.engine.deployer._sync_logging_helper") + @patch("decnet.engine.deployer.setup_host_ipvlan") + @patch("decnet.engine.deployer.create_ipvlan_network") + @patch("decnet.engine.deployer.get_host_ip", return_value="192.168.1.2") + @patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32") + @patch("decnet.engine.deployer.docker.from_env") + def test_ipvlan_deploy(self, mock_docker, mock_range, mock_hip, + mock_create, mock_setup, mock_sync, + mock_compose, mock_save, mock_retry, mock_print): + from decnet.engine.deployer import deploy + config = _config(ipvlan=True) + deploy(config) + mock_create.assert_called_once() + mock_setup.assert_called_once() + + @patch("decnet.engine.deployer._print_status") + @patch("decnet.engine.deployer._compose_with_retry") + @patch("decnet.engine.deployer.save_state") + @patch("decnet.engine.deployer.write_compose", return_value=Path("test.yml")) + @patch("decnet.engine.deployer._sync_logging_helper") + @patch("decnet.engine.deployer.setup_host_macvlan") + @patch("decnet.engine.deployer.create_macvlan_network") + @patch("decnet.engine.deployer.get_host_ip", return_value="192.168.1.2") + @patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32") + @patch("decnet.engine.deployer.docker.from_env") + def test_parallel_build(self, mock_docker, mock_range, mock_hip, + mock_create, mock_setup, mock_sync, + mock_compose, mock_save, mock_retry, mock_print): + from decnet.engine.deployer import deploy + config = _config() + deploy(config, parallel=True) + # Parallel mode calls _compose_with_retry for "build" and "up" separately + calls = mock_retry.call_args_list + assert any("build" in str(c) for c in calls) + + @patch("decnet.engine.deployer._print_status") + @patch("decnet.engine.deployer._compose_with_retry") + @patch("decnet.engine.deployer.save_state") + @patch("decnet.engine.deployer.write_compose", return_value=Path("test.yml")) + @patch("decnet.engine.deployer._sync_logging_helper") + @patch("decnet.engine.deployer.setup_host_macvlan") + @patch("decnet.engine.deployer.create_macvlan_network") + @patch("decnet.engine.deployer.get_host_ip", return_value="192.168.1.2") + @patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32") + @patch("decnet.engine.deployer.docker.from_env") + def test_no_cache_build(self, mock_docker, mock_range, mock_hip, + mock_create, mock_setup, mock_sync, + mock_compose, mock_save, mock_retry, mock_print): + from decnet.engine.deployer import deploy + config = _config() + deploy(config, no_cache=True) + calls = mock_retry.call_args_list + assert any("--no-cache" in str(c) for c in calls) + + +# ── teardown ────────────────────────────────────────────────────────────────── + +class TestTeardown: + @patch("decnet.engine.deployer.load_state", return_value=None) + def test_no_state(self, mock_load): + from decnet.engine.deployer import teardown + teardown() # should not raise + + @patch("decnet.engine.deployer.clear_state") + @patch("decnet.engine.deployer.remove_macvlan_network") + @patch("decnet.engine.deployer.teardown_host_macvlan") + @patch("decnet.engine.deployer._compose") + @patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32") + @patch("decnet.engine.deployer.docker.from_env") + @patch("decnet.engine.deployer.load_state") + def test_full_teardown_macvlan(self, mock_load, mock_docker, mock_range, + mock_compose, mock_td_macvlan, mock_rm_net, + mock_clear): + config = _config() + mock_load.return_value = (config, Path("test.yml")) + from decnet.engine.deployer import teardown + teardown() + mock_compose.assert_called_once() + mock_td_macvlan.assert_called_once() + mock_rm_net.assert_called_once() + mock_clear.assert_called_once() + + @patch("decnet.engine.deployer.clear_state") + @patch("decnet.engine.deployer.remove_macvlan_network") + @patch("decnet.engine.deployer.teardown_host_ipvlan") + @patch("decnet.engine.deployer._compose") + @patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32") + @patch("decnet.engine.deployer.docker.from_env") + @patch("decnet.engine.deployer.load_state") + def test_full_teardown_ipvlan(self, mock_load, mock_docker, mock_range, + mock_compose, mock_td_ipvlan, mock_rm_net, + mock_clear): + config = _config(ipvlan=True) + mock_load.return_value = (config, Path("test.yml")) + from decnet.engine.deployer import teardown + teardown() + mock_td_ipvlan.assert_called_once() + + +# ── status ──────────────────────────────────────────────────────────────────── + +class TestStatus: + @patch("decnet.engine.deployer.load_state", return_value=None) + def test_no_state(self, mock_load): + from decnet.engine.deployer import status + status() # should not raise + + @patch("decnet.engine.deployer.docker.from_env") + @patch("decnet.engine.deployer.load_state") + def test_with_running_containers(self, mock_load, mock_docker): + config = _config() + mock_load.return_value = (config, Path("test.yml")) + mock_container = MagicMock() + mock_container.name = "decky-01-ssh" + mock_container.status = "running" + mock_docker.return_value.containers.list.return_value = [mock_container] + from decnet.engine.deployer import status + status() # should not raise + + @patch("decnet.engine.deployer.docker.from_env") + @patch("decnet.engine.deployer.load_state") + def test_with_absent_containers(self, mock_load, mock_docker): + config = _config() + mock_load.return_value = (config, Path("test.yml")) + mock_docker.return_value.containers.list.return_value = [] + from decnet.engine.deployer import status + status() # should not raise + + +# ── _print_status ───────────────────────────────────────────────────────────── + +class TestPrintStatus: + def test_renders_table(self): + from decnet.engine.deployer import _print_status + config = _config(deckies=[_decky(), _decky("decky-02", "192.168.1.11")]) + _print_status(config) # should not raise diff --git a/tests/test_fleet.py b/tests/test_fleet.py new file mode 100644 index 0000000..61aa6e8 --- /dev/null +++ b/tests/test_fleet.py @@ -0,0 +1,192 @@ +""" +Tests for decnet/fleet.py β€” fleet builder logic. + +Covers build_deckies, build_deckies_from_ini, resolve_distros, +and edge cases like IP exhaustion and missing services. +""" + +import pytest + +from decnet.archetypes import get_archetype +from decnet.fleet import ( + all_service_names, + build_deckies, + build_deckies_from_ini, + resolve_distros, +) +from decnet.ini_loader import IniConfig, DeckySpec + + +# ── resolve_distros ─────────────────────────────────────────────────────────── + +class TestResolveDistros: + def test_explicit_distros_cycled(self): + result = resolve_distros(["debian", "ubuntu22"], False, 5) + assert result == ["debian", "ubuntu22", "debian", "ubuntu22", "debian"] + + def test_explicit_single_distro(self): + result = resolve_distros(["rocky9"], False, 3) + assert result == ["rocky9", "rocky9", "rocky9"] + + def test_randomize_returns_correct_count(self): + result = resolve_distros(None, True, 4) + assert len(result) == 4 + # All returned slugs should be valid distro slugs + from decnet.distros import all_distros + valid = set(all_distros().keys()) + for slug in result: + assert slug in valid + + def test_archetype_preferred_distros(self): + arch = get_archetype("deaddeck") + result = resolve_distros(None, False, 3, archetype=arch) + for slug in result: + assert slug in arch.preferred_distros + + def test_fallback_cycles_all_distros(self): + result = resolve_distros(None, False, 2) + from decnet.distros import all_distros + slugs = list(all_distros().keys()) + assert result[0] == slugs[0] + assert result[1] == slugs[1] + + +# ── build_deckies ───────────────────────────────────────────────────────────── + +class TestBuildDeckies: + _IPS: list[str] = ["192.168.1.10", "192.168.1.11", "192.168.1.12"] + + def test_explicit_services(self): + deckies = build_deckies(3, self._IPS, ["ssh", "http"], False) + assert len(deckies) == 3 + for decky in deckies: + assert decky.services == ["ssh", "http"] + + def test_archetype_services(self): + arch = get_archetype("deaddeck") + deckies = build_deckies(2, self._IPS[:2], None, False, archetype=arch) + assert len(deckies) == 2 + for decky in deckies: + assert set(decky.services) == set(arch.services) + assert decky.archetype == "deaddeck" + assert decky.nmap_os == arch.nmap_os + + def test_randomize_services(self): + deckies = build_deckies(3, self._IPS, None, True) + assert len(deckies) == 3 + for decky in deckies: + assert len(decky.services) >= 1 + + def test_no_services_raises(self): + with pytest.raises(ValueError, match="Provide services_explicit"): + build_deckies(1, self._IPS[:1], None, False) + + def test_names_sequential(self): + deckies = build_deckies(3, self._IPS, ["ssh"], False) + assert [d.name for d in deckies] == ["decky-01", "decky-02", "decky-03"] + + def test_ips_assigned_correctly(self): + deckies = build_deckies(3, self._IPS, ["ssh"], False) + assert [d.ip for d in deckies] == self._IPS + + def test_mutate_interval_propagated(self): + deckies = build_deckies(1, self._IPS[:1], ["ssh"], False, mutate_interval=15) + assert deckies[0].mutate_interval == 15 + + def test_distros_explicit(self): + deckies = build_deckies(2, self._IPS[:2], ["ssh"], False, distros_explicit=["rocky9"]) + for decky in deckies: + assert decky.distro == "rocky9" + + def test_randomize_distros(self): + deckies = build_deckies(2, self._IPS[:2], ["ssh"], False, randomize_distros=True) + from decnet.distros import all_distros + valid = set(all_distros().keys()) + for decky in deckies: + assert decky.distro in valid + + +# ── build_deckies_from_ini ──────────────────────────────────────────────────── + +class TestBuildDeckiesFromIni: + _SUBNET: str = "192.168.1.0/24" + _GATEWAY: str = "192.168.1.1" + _HOST_IP: str = "192.168.1.2" + + def _make_ini(self, deckies: list[DeckySpec], **kwargs) -> IniConfig: + defaults: dict = { + "interface": "eth0", + "subnet": None, + "gateway": None, + "mutate_interval": None, + "custom_services": [], + } + defaults.update(kwargs) + return IniConfig(deckies=deckies, **defaults) + + def test_explicit_ip(self): + spec = DeckySpec(name="test-1", ip="192.168.1.50", services=["ssh"]) + ini = self._make_ini([spec]) + deckies = build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False) + assert len(deckies) == 1 + assert deckies[0].ip == "192.168.1.50" + + def test_auto_ip_allocation(self): + spec = DeckySpec(name="test-1", services=["ssh"]) + ini = self._make_ini([spec]) + deckies = build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False) + assert len(deckies) == 1 + assert deckies[0].ip not in (self._GATEWAY, self._HOST_IP, "192.168.1.0", "192.168.1.255") + + def test_archetype_services(self): + spec = DeckySpec(name="test-1", archetype="deaddeck") + ini = self._make_ini([spec]) + deckies = build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False) + arch = get_archetype("deaddeck") + assert set(deckies[0].services) == set(arch.services) + + def test_randomize_services(self): + spec = DeckySpec(name="test-1") + ini = self._make_ini([spec]) + deckies = build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, True) + assert len(deckies[0].services) >= 1 + + def test_no_services_no_arch_no_randomize_raises(self): + spec = DeckySpec(name="test-1") + ini = self._make_ini([spec]) + with pytest.raises(ValueError, match="has no services"): + build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False) + + def test_unknown_service_raises(self): + spec = DeckySpec(name="test-1", services=["nonexistent_svc_xyz"]) + ini = self._make_ini([spec]) + with pytest.raises(ValueError, match="Unknown service"): + build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False) + + def test_mutate_interval_from_cli(self): + spec = DeckySpec(name="test-1", services=["ssh"]) + ini = self._make_ini([spec]) + deckies = build_deckies_from_ini( + ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False, cli_mutate_interval=42 + ) + assert deckies[0].mutate_interval == 42 + + def test_mutate_interval_from_ini(self): + spec = DeckySpec(name="test-1", services=["ssh"]) + ini = self._make_ini([spec], mutate_interval=99) + deckies = build_deckies_from_ini( + ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False, cli_mutate_interval=None + ) + assert deckies[0].mutate_interval == 99 + + def test_nmap_os_from_spec(self): + spec = DeckySpec(name="test-1", services=["ssh"], nmap_os="windows") + ini = self._make_ini([spec]) + deckies = build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False) + assert deckies[0].nmap_os == "windows" + + def test_nmap_os_from_archetype(self): + spec = DeckySpec(name="test-1", archetype="deaddeck") + ini = self._make_ini([spec]) + deckies = build_deckies_from_ini(ini, self._SUBNET, self._GATEWAY, self._HOST_IP, False) + assert deckies[0].nmap_os == "linux" diff --git a/tests/test_ingester.py b/tests/test_ingester.py new file mode 100644 index 0000000..cdbe52a --- /dev/null +++ b/tests/test_ingester.py @@ -0,0 +1,218 @@ +""" +Tests for decnet/web/ingester.py + +Covers log_ingestion_worker and _extract_bounty with +async tests using temporary files. +""" + +import asyncio +import json +import os +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +# ── _extract_bounty ─────────────────────────────────────────────────────────── + +class TestExtractBounty: + @pytest.mark.asyncio + async def test_credential_extraction(self): + from decnet.web.ingester import _extract_bounty + mock_repo = MagicMock() + mock_repo.add_bounty = AsyncMock() + log_data: dict = { + "decky": "decky-01", + "service": "ssh", + "attacker_ip": "10.0.0.5", + "fields": {"username": "admin", "password": "hunter2"}, + } + await _extract_bounty(mock_repo, log_data) + mock_repo.add_bounty.assert_awaited_once() + bounty = mock_repo.add_bounty.call_args[0][0] + assert bounty["bounty_type"] == "credential" + assert bounty["payload"]["username"] == "admin" + assert bounty["payload"]["password"] == "hunter2" + + @pytest.mark.asyncio + async def test_no_fields_skips(self): + from decnet.web.ingester import _extract_bounty + mock_repo = MagicMock() + mock_repo.add_bounty = AsyncMock() + await _extract_bounty(mock_repo, {"decky": "x"}) + mock_repo.add_bounty.assert_not_awaited() + + @pytest.mark.asyncio + async def test_fields_not_dict_skips(self): + from decnet.web.ingester import _extract_bounty + mock_repo = MagicMock() + mock_repo.add_bounty = AsyncMock() + await _extract_bounty(mock_repo, {"fields": "not-a-dict"}) + mock_repo.add_bounty.assert_not_awaited() + + @pytest.mark.asyncio + async def test_missing_password_skips(self): + from decnet.web.ingester import _extract_bounty + mock_repo = MagicMock() + mock_repo.add_bounty = AsyncMock() + await _extract_bounty(mock_repo, {"fields": {"username": "admin"}}) + mock_repo.add_bounty.assert_not_awaited() + + @pytest.mark.asyncio + async def test_missing_username_skips(self): + from decnet.web.ingester import _extract_bounty + mock_repo = MagicMock() + mock_repo.add_bounty = AsyncMock() + await _extract_bounty(mock_repo, {"fields": {"password": "pass"}}) + mock_repo.add_bounty.assert_not_awaited() + + +# ── log_ingestion_worker ────────────────────────────────────────────────────── + +class TestLogIngestionWorker: + @pytest.mark.asyncio + async def test_no_env_var_returns_immediately(self): + from decnet.web.ingester import log_ingestion_worker + mock_repo = MagicMock() + with patch.dict(os.environ, {}, clear=False): + # Remove DECNET_INGEST_LOG_FILE if set + os.environ.pop("DECNET_INGEST_LOG_FILE", None) + await log_ingestion_worker(mock_repo) + # Should return immediately without error + + @pytest.mark.asyncio + async def test_file_not_exists_waits(self, tmp_path): + from decnet.web.ingester import log_ingestion_worker + mock_repo = MagicMock() + mock_repo.add_log = AsyncMock() + log_file = str(tmp_path / "nonexistent.log") + _call_count: int = 0 + + async def fake_sleep(secs): + nonlocal _call_count + _call_count += 1 + if _call_count >= 2: + raise asyncio.CancelledError() + + with patch.dict(os.environ, {"DECNET_INGEST_LOG_FILE": log_file}): + with patch("decnet.web.ingester.asyncio.sleep", side_effect=fake_sleep): + with pytest.raises(asyncio.CancelledError): + await log_ingestion_worker(mock_repo) + mock_repo.add_log.assert_not_awaited() + + @pytest.mark.asyncio + async def test_ingests_json_lines(self, tmp_path): + from decnet.web.ingester import log_ingestion_worker + mock_repo = MagicMock() + mock_repo.add_log = AsyncMock() + mock_repo.add_bounty = AsyncMock() + + log_file = str(tmp_path / "test.log") + json_file = tmp_path / "test.json" + json_file.write_text( + json.dumps({"decky": "d1", "service": "ssh", "event_type": "auth", + "attacker_ip": "1.2.3.4", "fields": {}, "raw_line": "x", "msg": ""}) + "\n" + ) + + _call_count: int = 0 + + async def fake_sleep(secs): + nonlocal _call_count + _call_count += 1 + if _call_count >= 2: + raise asyncio.CancelledError() + + with patch.dict(os.environ, {"DECNET_INGEST_LOG_FILE": log_file}): + with patch("decnet.web.ingester.asyncio.sleep", side_effect=fake_sleep): + with pytest.raises(asyncio.CancelledError): + await log_ingestion_worker(mock_repo) + + mock_repo.add_log.assert_awaited_once() + + @pytest.mark.asyncio + async def test_handles_json_decode_error(self, tmp_path): + from decnet.web.ingester import log_ingestion_worker + mock_repo = MagicMock() + mock_repo.add_log = AsyncMock() + mock_repo.add_bounty = AsyncMock() + + log_file = str(tmp_path / "test.log") + json_file = tmp_path / "test.json" + json_file.write_text("not valid json\n") + + _call_count: int = 0 + + async def fake_sleep(secs): + nonlocal _call_count + _call_count += 1 + if _call_count >= 2: + raise asyncio.CancelledError() + + with patch.dict(os.environ, {"DECNET_INGEST_LOG_FILE": log_file}): + with patch("decnet.web.ingester.asyncio.sleep", side_effect=fake_sleep): + with pytest.raises(asyncio.CancelledError): + await log_ingestion_worker(mock_repo) + + mock_repo.add_log.assert_not_awaited() + + @pytest.mark.asyncio + async def test_file_truncation_resets_position(self, tmp_path): + from decnet.web.ingester import log_ingestion_worker + mock_repo = MagicMock() + mock_repo.add_log = AsyncMock() + mock_repo.add_bounty = AsyncMock() + + log_file = str(tmp_path / "test.log") + json_file = tmp_path / "test.json" + + _line: str = json.dumps({"decky": "d1", "service": "ssh", "event_type": "auth", + "attacker_ip": "1.2.3.4", "fields": {}, "raw_line": "x", "msg": ""}) + # Write 2 lines, then truncate to 1 + json_file.write_text(_line + "\n" + _line + "\n") + + _call_count: int = 0 + + async def fake_sleep(secs): + nonlocal _call_count + _call_count += 1 + if _call_count == 2: + # Simulate truncation + json_file.write_text(_line + "\n") + if _call_count >= 4: + raise asyncio.CancelledError() + + with patch.dict(os.environ, {"DECNET_INGEST_LOG_FILE": log_file}): + with patch("decnet.web.ingester.asyncio.sleep", side_effect=fake_sleep): + with pytest.raises(asyncio.CancelledError): + await log_ingestion_worker(mock_repo) + + # Should have ingested lines from original + after truncation + assert mock_repo.add_log.await_count >= 2 + + @pytest.mark.asyncio + async def test_partial_line_not_processed(self, tmp_path): + from decnet.web.ingester import log_ingestion_worker + mock_repo = MagicMock() + mock_repo.add_log = AsyncMock() + mock_repo.add_bounty = AsyncMock() + + log_file = str(tmp_path / "test.log") + json_file = tmp_path / "test.json" + # Write a partial line (no newline at end) + json_file.write_text('{"partial": true') + + _call_count: int = 0 + + async def fake_sleep(secs): + nonlocal _call_count + _call_count += 1 + if _call_count >= 2: + raise asyncio.CancelledError() + + with patch.dict(os.environ, {"DECNET_INGEST_LOG_FILE": log_file}): + with patch("decnet.web.ingester.asyncio.sleep", side_effect=fake_sleep): + with pytest.raises(asyncio.CancelledError): + await log_ingestion_worker(mock_repo) + + mock_repo.add_log.assert_not_awaited() diff --git a/tests/test_smtp_relay.py b/tests/test_smtp_relay.py new file mode 100644 index 0000000..2bc421b --- /dev/null +++ b/tests/test_smtp_relay.py @@ -0,0 +1,28 @@ +""" +Tests for SMTP Relay service. +""" + +from decnet.services.smtp_relay import SMTPRelayService + +def test_smtp_relay_compose_fragment(): + svc = SMTPRelayService() + fragment = svc.compose_fragment("test-decky", log_target="log-server") + + assert fragment["container_name"] == "test-decky-smtp_relay" + assert fragment["environment"]["SMTP_OPEN_RELAY"] == "1" + assert fragment["environment"]["LOG_TARGET"] == "log-server" + +def test_smtp_relay_custom_cfg(): + svc = SMTPRelayService() + fragment = svc.compose_fragment( + "test-decky", + service_cfg={"banner": "Welcome", "mta": "Postfix"} + ) + assert fragment["environment"]["SMTP_BANNER"] == "Welcome" + assert fragment["environment"]["SMTP_MTA"] == "Postfix" + +def test_smtp_relay_dockerfile_context(): + svc = SMTPRelayService() + ctx = svc.dockerfile_context() + assert ctx.name == "smtp" + assert ctx.is_dir() diff --git a/tests/test_web_api.py b/tests/test_web_api.py new file mode 100644 index 0000000..a2afd11 --- /dev/null +++ b/tests/test_web_api.py @@ -0,0 +1,157 @@ +""" +Tests for decnet/web/api.py lifespan and decnet/web/dependencies.py auth helpers. +""" + +import asyncio +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import jwt +import pytest +import httpx + +from decnet.web.auth import SECRET_KEY, ALGORITHM, create_access_token + + +# ── get_current_user ────────────────────────────────────────────────────────── + +class TestGetCurrentUser: + @pytest.mark.asyncio + async def test_valid_token(self): + from decnet.web.dependencies import get_current_user + token = create_access_token({"uuid": "test-uuid-123"}) + request = MagicMock() + request.headers = {"Authorization": f"Bearer {token}"} + result = await get_current_user(request) + assert result == "test-uuid-123" + + @pytest.mark.asyncio + async def test_no_auth_header(self): + from fastapi import HTTPException + from decnet.web.dependencies import get_current_user + request = MagicMock() + request.headers = {} + with pytest.raises(HTTPException) as exc_info: + await get_current_user(request) + assert exc_info.value.status_code == 401 + + @pytest.mark.asyncio + async def test_invalid_jwt(self): + from fastapi import HTTPException + from decnet.web.dependencies import get_current_user + request = MagicMock() + request.headers = {"Authorization": "Bearer invalid-token"} + with pytest.raises(HTTPException) as exc_info: + await get_current_user(request) + assert exc_info.value.status_code == 401 + + @pytest.mark.asyncio + async def test_missing_uuid_in_payload(self): + from fastapi import HTTPException + from decnet.web.dependencies import get_current_user + token = create_access_token({"sub": "no-uuid-field"}) + request = MagicMock() + request.headers = {"Authorization": f"Bearer {token}"} + with pytest.raises(HTTPException) as exc_info: + await get_current_user(request) + assert exc_info.value.status_code == 401 + + @pytest.mark.asyncio + async def test_bearer_prefix_required(self): + from fastapi import HTTPException + from decnet.web.dependencies import get_current_user + token = create_access_token({"uuid": "test-uuid"}) + request = MagicMock() + request.headers = {"Authorization": f"Token {token}"} + with pytest.raises(HTTPException): + await get_current_user(request) + + +# ── get_stream_user ─────────────────────────────────────────────────────────── + +class TestGetStreamUser: + @pytest.mark.asyncio + async def test_bearer_header(self): + from decnet.web.dependencies import get_stream_user + token = create_access_token({"uuid": "stream-uuid"}) + request = MagicMock() + request.headers = {"Authorization": f"Bearer {token}"} + result = await get_stream_user(request, token=None) + assert result == "stream-uuid" + + @pytest.mark.asyncio + async def test_query_param_fallback(self): + from decnet.web.dependencies import get_stream_user + token = create_access_token({"uuid": "query-uuid"}) + request = MagicMock() + request.headers = {} + result = await get_stream_user(request, token=token) + assert result == "query-uuid" + + @pytest.mark.asyncio + async def test_no_token_raises(self): + from fastapi import HTTPException + from decnet.web.dependencies import get_stream_user + request = MagicMock() + request.headers = {} + with pytest.raises(HTTPException) as exc_info: + await get_stream_user(request, token=None) + assert exc_info.value.status_code == 401 + + @pytest.mark.asyncio + async def test_invalid_token_raises(self): + from fastapi import HTTPException + from decnet.web.dependencies import get_stream_user + request = MagicMock() + request.headers = {} + with pytest.raises(HTTPException): + await get_stream_user(request, token="bad-token") + + @pytest.mark.asyncio + async def test_missing_uuid_raises(self): + from fastapi import HTTPException + from decnet.web.dependencies import get_stream_user + token = create_access_token({"sub": "no-uuid"}) + request = MagicMock() + request.headers = {"Authorization": f"Bearer {token}"} + with pytest.raises(HTTPException): + await get_stream_user(request, token=None) + + +# ── web/api.py lifespan ────────────────────────────────────────────────────── + +class TestLifespan: + @pytest.mark.asyncio + async def test_lifespan_startup_and_shutdown(self): + from decnet.web.api import lifespan + mock_app = MagicMock() + mock_repo = MagicMock() + mock_repo.initialize = AsyncMock() + + with patch("decnet.web.api.repo", mock_repo): + with patch("decnet.web.api.log_ingestion_worker", return_value=asyncio.sleep(0)): + with patch("decnet.web.api.log_collector_worker", return_value=asyncio.sleep(0)): + async with lifespan(mock_app): + mock_repo.initialize.assert_awaited_once() + + @pytest.mark.asyncio + async def test_lifespan_db_retry(self): + from decnet.web.api import lifespan + mock_app = MagicMock() + mock_repo = MagicMock() + _call_count: int = 0 + + async def _failing_init(): + nonlocal _call_count + _call_count += 1 + if _call_count < 3: + raise Exception("DB locked") + + mock_repo.initialize = _failing_init + + with patch("decnet.web.api.repo", mock_repo): + with patch("decnet.web.api.asyncio.sleep", new_callable=AsyncMock): + with patch("decnet.web.api.log_ingestion_worker", return_value=asyncio.sleep(0)): + with patch("decnet.web.api.log_collector_worker", return_value=asyncio.sleep(0)): + async with lifespan(mock_app): + assert _call_count == 3