2026-06-13 21:59:49 +02:00
using System ;
using System.IO ;
using System.Text.Json.Nodes ;
2026-06-11 10:48:21 +02:00
using PalladiumWallet.Core.Storage ;
namespace PalladiumWallet.Tests.Storage ;
public class StorageTests
{
private static WalletDocument SampleDoc () => new ()
{
Network = "regtest" ,
ScriptKind = "NativeSegwit" ,
Mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" ,
AccountPath = "84'/1'/0'" ,
AccountXpub = "vpub-fittizia-per-test" ,
Labels = { [ "txid123" ] = "caffè" },
};
2026-06-13 21:59:49 +02:00
private static string TempPath () =>
Path . Combine ( Path . GetTempPath (), $"plm-test-{Guid.NewGuid()}.wallet.json" );
2026-06-16 14:40:06 +02:00
// ---- AES-GCM encryption ----
2026-06-13 21:59:49 +02:00
2026-06-11 10:48:21 +02:00
[Fact]
public void La_cifratura_fa_roundtrip_con_la_password_giusta ()
{
var cipher = EncryptedFile . Encrypt ( "contenuto segreto" , "pass-forte" );
Assert . True ( EncryptedFile . IsEncrypted ( cipher ));
Assert . DoesNotContain ( "segreto" , cipher );
Assert . Equal ( "contenuto segreto" , EncryptedFile . Decrypt ( cipher , "pass-forte" ));
}
[Fact]
public void La_password_errata_viene_rifiutata ()
{
var cipher = EncryptedFile . Encrypt ( "contenuto" , "giusta" );
Assert . Throws < WrongPasswordException >(() => EncryptedFile . Decrypt ( cipher , "sbagliata" ));
}
[Fact]
public void Un_file_manomesso_viene_rifiutato ()
{
var cipher = EncryptedFile . Encrypt ( "contenuto" , "pass" );
2026-06-13 21:59:49 +02:00
var node = JsonNode . Parse ( cipher )!;
2026-06-11 10:48:21 +02:00
var data = Convert . FromBase64String ( node [ "Data" ]!. GetValue < string >());
data [ 0 ] ^= 0xff ;
node [ "Data" ] = Convert . ToBase64String ( data );
Assert . Throws < WrongPasswordException >(() => EncryptedFile . Decrypt ( node . ToJsonString (), "pass" ));
}
2026-06-13 21:59:49 +02:00
[Fact]
public void Ogni_encrypt_produce_nonce_diverso ()
{
var c1 = JsonNode . Parse ( EncryptedFile . Encrypt ( "x" , "p" ))![ "Nonce" ]!. GetValue < string >();
var c2 = JsonNode . Parse ( EncryptedFile . Encrypt ( "x" , "p" ))![ "Nonce" ]!. GetValue < string >();
Assert . NotEqual ( c1 , c2 );
}
2026-07-07 22:05:08 +02:00
[Theory]
[InlineData("not json at all")] // broken JSON
[InlineData("null")] // JSON null
[InlineData("""{"Format":"plm-wallet-aesgcm-v1"}""")] // missing fields → null strings
[InlineData("""{"Format":"plm-wallet-aesgcm-v1","Iterations":600000,"Salt":"$$$","Nonce":"AAAAAAAAAAAAAAAA","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":""}""")] // bad base64
[InlineData("""{"Format":"plm-wallet-aesgcm-v1","Iterations":600000,"Salt":"AA==","Nonce":"AA==","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":""}""")] // wrong nonce size
[InlineData("""{"Format":"plm-wallet-aesgcm-v1","Iterations":0,"Salt":"AA==","Nonce":"AAAAAAAAAAAAAAAA","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":""}""")] // iterations 0
[InlineData("""{"Format":"plm-wallet-aesgcm-v1","Iterations":2147483647,"Salt":"AA==","Nonce":"AAAAAAAAAAAAAAAA","Tag":"AAAAAAAAAAAAAAAAAAAAAA==","Data":""}""")] // PBKDF2 DoS
public void Un_contenitore_malformato_produce_sempre_InvalidDataException ( string container )
{
// A tampered/corrupted file must map to the two typed exceptions, never to a
// raw JsonException/FormatException/ArgumentNullException (found by fuzzing);
// the absurd iteration count would otherwise hang the wallet at open.
Assert . Throws < InvalidDataException >(() => EncryptedFile . Decrypt ( container , "pass" ));
}
2026-06-13 21:59:49 +02:00
[Fact]
public void Ogni_encrypt_produce_salt_diverso ()
{
var s1 = JsonNode . Parse ( EncryptedFile . Encrypt ( "x" , "p" ))![ "Salt" ]!. GetValue < string >();
var s2 = JsonNode . Parse ( EncryptedFile . Encrypt ( "x" , "p" ))![ "Salt" ]!. GetValue < string >();
Assert . NotEqual ( s1 , s2 );
}
[Fact]
public void IsEncrypted_restituisce_false_per_json_non_cifrato ()
{
Assert . False ( EncryptedFile . IsEncrypted ( "{\"Version\": 1}" ));
}
[Fact]
public void IsEncrypted_restituisce_false_per_testo_non_json ()
{
Assert . False ( EncryptedFile . IsEncrypted ( "non è json" ));
}
2026-07-02 23:21:20 +02:00
// Regression: valid JSON whose root is not an object (found by the
// property test) must not throw from TryGetProperty.
[Theory]
[InlineData("5")]
[InlineData("true")]
[InlineData("\"stringa\"")]
[InlineData("[1, 2] ")]
public void IsEncrypted_restituisce_false_per_json_con_radice_non_oggetto ( string content )
{
Assert . False ( EncryptedFile . IsEncrypted ( content ));
}
[Fact]
public void IsEncrypted_restituisce_false_per_utf16_invalido ()
{
// Lone surrogate: cannot be transcoded to UTF-8 for JSON parsing.
Assert . False ( EncryptedFile . IsEncrypted ( "\ud800" ));
}
[Fact]
public void IsEncrypted_restituisce_false_se_Format_non_e_una_stringa ()
{
Assert . False ( EncryptedFile . IsEncrypted ( "{\"Format\": 42}" ));
}
2026-06-13 21:59:49 +02:00
// ---- WalletDocument JSON ----
2026-06-11 10:48:21 +02:00
[Fact]
public void Il_documento_wallet_fa_roundtrip_json ()
{
var doc = SampleDoc ();
var restored = WalletDocument . FromJson ( doc . ToJson ());
Assert . Equal ( doc . Network , restored . Network );
Assert . Equal ( doc . Mnemonic , restored . Mnemonic );
Assert . Equal ( doc . AccountXpub , restored . AccountXpub );
Assert . Equal ( "caffè" , restored . Labels [ "txid123" ]);
Assert . False ( restored . IsWatchOnly );
}
[Fact]
public void Una_versione_futura_del_file_viene_rifiutata ()
{
2026-06-13 21:59:49 +02:00
var json = SampleDoc (). ToJson (). Replace ( "\"Version\": 1" , "\"Version\": 99" );
2026-06-11 10:48:21 +02:00
Assert . Throws < InvalidDataException >(() => WalletDocument . FromJson ( json ));
}
2026-06-13 21:59:49 +02:00
[Fact]
public void Il_documento_senza_mnemonica_e_watch_only ()
{
var doc = SampleDoc ();
doc . Mnemonic = null ;
Assert . True ( WalletDocument . FromJson ( doc . ToJson ()). IsWatchOnly );
}
2026-07-19 12:37:18 +02:00
[Fact]
public void WatchAddresses_fa_roundtrip_json_ed_e_watch_only ()
{
var doc = new WalletDocument
{
Network = "mainnet" ,
ScriptKind = "NativeSegwit" ,
WatchAddresses = [ "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" ],
};
var restored = WalletDocument . FromJson ( doc . ToJson ());
Assert . Equal ( doc . WatchAddresses , restored . WatchAddresses );
Assert . True ( restored . IsWatchOnly );
}
2026-06-13 21:59:49 +02:00
[Fact]
public void Json_corrotto_lancia_eccezione ()
{
Assert . ThrowsAny < Exception >(() => WalletDocument . FromJson ( "{non è json valido}" ));
}
[Fact]
public void Json_con_campi_mancanti_lancia_eccezione ()
{
Assert . ThrowsAny < Exception >(() => WalletDocument . FromJson ( "{}" ));
}
// ---- WalletStore ----
2026-06-11 10:48:21 +02:00
[Fact]
public void Il_wallet_store_salva_e_riapre_con_e_senza_password ()
{
2026-06-13 21:59:49 +02:00
var path = TempPath ();
2026-06-11 10:48:21 +02:00
try
{
WalletStore . Save ( SampleDoc (), path );
Assert . False ( WalletStore . RequiresPassword ( path ));
Assert . Equal ( "regtest" , WalletStore . Load ( path ). Network );
WalletStore . Save ( SampleDoc (), path , "pwd" );
Assert . True ( WalletStore . RequiresPassword ( path ));
Assert . Throws < WrongPasswordException >(() => WalletStore . Load ( path ));
Assert . Throws < WrongPasswordException >(() => WalletStore . Load ( path , "altra" ));
Assert . Equal ( "regtest" , WalletStore . Load ( path , "pwd" ). Network );
}
finally
{
File . Delete ( path );
}
}
[Fact]
2026-06-13 21:59:49 +02:00
public void Scrittura_atomica_non_lascia_file_tmp ()
2026-06-11 10:48:21 +02:00
{
2026-06-13 21:59:49 +02:00
var path = TempPath ();
try
{
WalletStore . Save ( SampleDoc (), path );
Assert . True ( File . Exists ( path ));
Assert . False ( File . Exists ( path + ".tmp" ));
}
finally
{
File . Delete ( path );
}
2026-06-11 10:48:21 +02:00
}
2026-06-13 20:34:19 +02:00
2026-06-13 21:59:49 +02:00
[Fact]
public void Load_da_path_inesistente_lancia_eccezione ()
{
var path = Path . Combine ( Path . GetTempPath (), $"plm-noexist-{Guid.NewGuid()}.wallet.json" );
Assert . Throws < FileNotFoundException >(() => WalletStore . Load ( path ));
}
[Fact]
public void Exists_restituisce_false_per_path_inesistente ()
{
var path = Path . Combine ( Path . GetTempPath (), $"plm-noexist-{Guid.NewGuid()}.wallet.json" );
Assert . False ( WalletStore . Exists ( path ));
}
[Fact]
public void Due_save_successivi_producono_nonce_diversi ()
{
var path = TempPath ();
try
{
WalletStore . Save ( SampleDoc (), path , "password" );
var n1 = JsonNode . Parse ( File . ReadAllText ( path ))![ "Nonce" ]!. GetValue < string >();
WalletStore . Save ( SampleDoc (), path , "password" );
var n2 = JsonNode . Parse ( File . ReadAllText ( path ))![ "Nonce" ]!. GetValue < string >();
Assert . NotEqual ( n1 , n2 );
}
finally
{
File . Delete ( path );
}
}
// ---- WalletLock ----
2026-06-13 20:34:19 +02:00
[Fact]
public void WalletLock_acquisisce_e_rilascia ()
{
2026-06-13 21:59:49 +02:00
var path = TempPath ();
2026-06-13 20:34:19 +02:00
try
{
using var lock1 = WalletLock . TryAcquire ( path );
Assert . NotNull ( lock1 );
}
finally
{
File . Delete ( path + ".lock" );
}
}
[Fact]
public void WalletLock_seconda_istanza_restituisce_null ()
{
2026-06-13 21:59:49 +02:00
var path = TempPath ();
2026-06-13 20:34:19 +02:00
try
{
using var lock1 = WalletLock . TryAcquire ( path );
Assert . NotNull ( lock1 );
2026-06-13 21:59:49 +02:00
Assert . Null ( WalletLock . TryAcquire ( path ));
2026-06-13 20:34:19 +02:00
}
finally
{
File . Delete ( path + ".lock" );
}
}
[Fact]
public void WalletLock_riacquisibile_dopo_rilascio ()
{
2026-06-13 21:59:49 +02:00
var path = TempPath ();
2026-06-13 20:34:19 +02:00
try
{
var lock1 = WalletLock . TryAcquire ( path );
Assert . NotNull ( lock1 );
lock1 !. Dispose ();
using var lock2 = WalletLock . TryAcquire ( path );
Assert . NotNull ( lock2 );
}
finally
{
File . Delete ( path + ".lock" );
}
}
2026-06-13 21:59:49 +02:00
[Fact]
public void WalletLock_dispose_rimuove_il_file_lock ()
{
var path = TempPath ();
var lockPath = path + ".lock" ;
var lock1 = WalletLock . TryAcquire ( path );
Assert . NotNull ( lock1 );
lock1 !. Dispose ();
Assert . False ( File . Exists ( lockPath ));
}
[Fact]
public void WalletLock_file_lock_preesistente_ma_non_bloccato_viene_acquisito ()
{
2026-06-16 14:40:06 +02:00
// A stale .lock left from a previous crash (file exists but nobody holds it)
// must not block wallet opening.
2026-06-13 21:59:49 +02:00
var path = TempPath ();
var lockPath = path + ".lock" ;
try
{
File . WriteAllText ( lockPath , "stale" );
using var lock1 = WalletLock . TryAcquire ( path );
Assert . NotNull ( lock1 );
}
finally
{
File . Delete ( lockPath );
}
}
2026-06-11 10:48:21 +02:00
}