using HfSqlOleDbAudit;
namespace HfSqlOleDbAudit.Tests;
///
/// Tests unitaires du mode connection-test (sans serveur HFSQL réel).
/// Couvre : parsing des 3 sections, rejet de section inconnue, non-affichage des
/// secrets, absence de chaîne de connexion complète, validateur d'identifiant,
/// requête SELECT lecture seule, absence d'écriture de fichier, codes de sortie,
/// et préservation du mode historique.
///
public sealed class ConnectionTestTests
{
// ================================================================
// 1-3. Parsing des sections
// ================================================================
[Fact]
public void LoadConfig_Hfsql_ParsesAllFields()
{
const string json = """
{
"Hfsql": {
"Server": "SRV-OMIKA",
"Port": 4900,
"DatabaseName": "Omika",
"Login": "compte_tech",
"Password": "p4ss",
"FilePasswords": { "ProtectionStd": "std", "ProtectionPlus": "plus" }
}
}
""";
WithTempJson(json, path =>
{
Assert.True(ConnectionTest.TryLoadConfig(path, ConnectionTest.Section.Hfsql, out var config, out _));
Assert.NotNull(config);
Assert.Equal("SRV-OMIKA", config.Server);
Assert.Equal(4900, config.Port);
Assert.Equal("Omika", config.DatabaseName);
Assert.Equal("compte_tech", config.Login);
Assert.True(config.DatabasePasswordPresent);
Assert.True(config.ProtectionStdPresent);
Assert.True(config.ProtectionPlusPresent);
// Parité Production : la vue OLE DB porte les mêmes valeurs.
Assert.Equal("SRV-OMIKA", config.ConnectionOptions.Server);
Assert.Equal(4900, config.ConnectionOptions.Port);
Assert.Equal("Omika", config.ConnectionOptions.Database);
Assert.Equal("compte_tech", config.ConnectionOptions.User);
});
}
[Fact]
public void LoadConfig_LicenseConnection_ParsesAllFields()
{
const string json = """
{
"LicenseConnection": {
"Server": "SRV-OMIKA",
"Port": 4900,
"DatabaseName": "Licence",
"Login": "licence_user",
"Password": "licpass",
"FilePasswords": { "ProtectionStd": "std", "ProtectionPlus": "plus" }
}
}
""";
WithTempJson(json, path =>
{
Assert.True(ConnectionTest.TryLoadConfig(path, ConnectionTest.Section.LicenseConnection, out var config, out _));
Assert.NotNull(config);
Assert.Equal("SRV-OMIKA", config.Server);
Assert.Equal(4900, config.Port);
Assert.Equal("Licence", config.DatabaseName);
Assert.Equal("licence_user", config.Login);
Assert.True(config.DatabasePasswordPresent);
Assert.True(config.ProtectionStdPresent);
Assert.True(config.ProtectionPlusPresent);
});
}
[Fact]
public void LoadConfig_ServiceConnection_ParsesAllFields_WithOptionalPassword()
{
const string json = """
{
"ServiceConnection": {
"Server": "SRV-OMIKA",
"Port": 4900,
"DatabaseName": "Service",
"Login": "service_user",
"Password": "",
"FilePasswords": { "ProtectionStd": "", "ProtectionPlus": "" }
}
}
""";
WithTempJson(json, path =>
{
Assert.True(ConnectionTest.TryLoadConfig(path, ConnectionTest.Section.ServiceConnection, out var config, out _));
Assert.NotNull(config);
Assert.Equal("SRV-OMIKA", config.Server);
Assert.Equal(4900, config.Port);
Assert.Equal("Service", config.DatabaseName);
Assert.Equal("service_user", config.Login);
// Mot de passe Service optionnel : absent ici.
Assert.False(config.DatabasePasswordPresent);
Assert.False(config.ProtectionStdPresent);
Assert.False(config.ProtectionPlusPresent);
});
}
// ================================================================
// 4. Section inconnue rejetée
// ================================================================
[Theory]
[InlineData("Batik")]
[InlineData("SyncConnection")]
[InlineData("")]
[InlineData("hfsqlx")]
public void TryParseSection_UnknownSection_ReturnsFalse(string section)
{
Assert.False(ConnectionTest.TryParseSection(section, out _));
}
[Theory]
[InlineData("Hfsql")]
[InlineData("hfsql")]
[InlineData("LicenseConnection")]
[InlineData("ServiceConnection")]
public void TryParseSection_SupportedSection_ReturnsTrue(string section)
{
Assert.True(ConnectionTest.TryParseSection(section, out _));
}
[Fact]
public void Run_UnknownSection_ReturnsExit2()
{
using var sw = new StringWriter();
int code = ConnectionTest.Run(new[] { "connection-test", "C:\\whatever.json", "Bogus" }, sw);
Assert.Equal(2, code);
Assert.Contains("UNSUPPORTED_SECTION", sw.ToString());
}
// ================================================================
// 5-8. Aucun secret / aucune chaîne de connexion affichés
// ================================================================
[Fact]
public void BuildSummaryLines_NeverPrintsSecrets()
{
const string password = "SUP3R-S3CR3T-DBP4SS";
const string std = "STD-SECRET";
const string plus = "PLUS-SECRET";
string json = $$"""
{
"Hfsql": {
"Server": "SRV-OMIKA", "Port": 4900, "DatabaseName": "Omika", "Login": "user",
"Password": "{{password}}",
"FilePasswords": { "ProtectionStd": "{{std}}", "ProtectionPlus": "{{plus}}" }
}
}
""";
WithTempJson(json, path =>
{
Assert.True(ConnectionTest.TryLoadConfig(path, ConnectionTest.Section.Hfsql, out var config, out _));
string all = string.Join('\n', ConnectionTest.BuildSummaryLines(config!));
Assert.DoesNotContain(password, all);
Assert.DoesNotContain(std, all);
Assert.DoesNotContain(plus, all);
});
}
[Fact]
public void BuildSummaryLines_NeverPrintsFullConnectionString()
{
const string json = """
{
"Hfsql": {
"Server": "SRV-OMIKA", "Port": 4900, "DatabaseName": "Omika", "Login": "user",
"Password": "secret-db",
"FilePasswords": { "ProtectionStd": "secret-std", "ProtectionPlus": "secret-plus" }
}
}
""";
WithTempJson(json, path =>
{
Assert.True(ConnectionTest.TryLoadConfig(path, ConnectionTest.Section.Hfsql, out var config, out _));
var lines = ConnectionTest.BuildSummaryLines(config!);
foreach (string line in lines)
{
Assert.DoesNotContain(";", line); // séparateur de chaîne de connexion
Assert.DoesNotContain("Password=", line);
Assert.DoesNotContain("Data Source", line);
Assert.DoesNotContain("Initial Catalog", line);
Assert.DoesNotContain("Extended Properties", line);
Assert.DoesNotContain("User ID", line);
}
});
}
[Fact]
public void BuildSummaryLines_ShowsPresenceFlagsOnly()
{
const string json = """
{
"Hfsql": {
"Server": "SRV-OMIKA", "Port": 4900, "DatabaseName": "Omika", "Login": "user",
"Password": "x",
"FilePasswords": { "ProtectionStd": "y", "ProtectionPlus": "z" }
}
}
""";
WithTempJson(json, path =>
{
Assert.True(ConnectionTest.TryLoadConfig(path, ConnectionTest.Section.Hfsql, out var config, out _));
var lines = ConnectionTest.BuildSummaryLines(config!);
Assert.Contains("DatabasePasswordPresent=YES", lines);
Assert.Contains("ProtectionStdPresent=YES", lines);
Assert.Contains("ProtectionPlusPresent=YES", lines);
Assert.Contains("Provider=PCSOFT.HFSQL", lines);
Assert.Contains("Section=Hfsql", lines);
});
}
// ================================================================
// 9. Identifiant de table
// ================================================================
[Theory]
[InlineData("ParametreMetier")]
[InlineData("ParametreMetierEntete")]
[InlineData("ParametreSysteme")]
[InlineData("TOSRV")]
[InlineData("table_1")]
[InlineData("A1_b2")]
public void IsValidTableIdentifier_Valid(string name)
{
Assert.True(ConnectionTest.IsValidTableIdentifier(name));
}
[Theory]
[InlineData("")]
[InlineData("ParametreMetier; DROP TABLE")]
[InlineData("foo bar")]
[InlineData("foo-bar")]
[InlineData("foo.bar")]
[InlineData("foo/bar")]
[InlineData("foo\\bar")]
public void IsValidTableIdentifier_Invalid(string name)
{
Assert.False(ConnectionTest.IsValidTableIdentifier(name));
}
[Fact]
public void Run_InvalidTable_ReturnsExit2()
{
using var sw = new StringWriter();
int code = ConnectionTest.Run(new[] { "connection-test", "C:\\whatever.json", "Hfsql", "Bad;Table" }, sw);
Assert.Equal(2, code);
Assert.Contains("INVALID_TABLE_IDENTIFIER", sw.ToString());
}
// ================================================================
// 10. Aucune commande SQL d'écriture
// ================================================================
[Fact]
public void BuildSelectSql_IsReadOnlySelect()
{
Assert.Equal("SELECT TOP 1 * FROM ParametreMetier", ConnectionTest.BuildSelectSql("ParametreMetier"));
}
[Fact]
public void ConnectionTestSource_HasNoWriteSql()
{
string source = File.ReadAllText(RepoFile("tools", "HfSqlOleDbAudit", "ConnectionTest.cs"));
string[] forbidden =
{
"ExecuteNonQuery",
"INSERT",
"UPDATE",
"DELETE",
"MERGE",
"TRUNCATE",
"CREATE TABLE",
"DROP TABLE",
"ALTER TABLE",
"CREATE DATABASE",
"DROP DATABASE"
};
foreach (string token in forbidden)
{
Assert.DoesNotContain(token, source, StringComparison.OrdinalIgnoreCase);
}
}
// ================================================================
// 11. Aucune écriture de fichier dans connection-test
// ================================================================
[Fact]
public void ConnectionTestSource_HasNoFileWrite()
{
string source = File.ReadAllText(RepoFile("tools", "HfSqlOleDbAudit", "ConnectionTest.cs"));
string[] forbidden =
{
"File.Write",
"File.Append",
"File.Copy",
"File.Move",
"File.Create",
"File.Delete",
"File.OpenWrite",
"WriteAllText",
"WriteAllLines",
"WriteAllBytes",
"AppendAllText",
"AppendAllLines",
"Directory.CreateDirectory",
"FileStream",
"StreamWriter"
};
foreach (string token in forbidden)
{
Assert.DoesNotContain(token, source, StringComparison.OrdinalIgnoreCase);
}
}
// ================================================================
// 12-13. Codes de sortie
// ================================================================
[Fact]
public void Run_MissingConfigFile_ReturnsExit2()
{
using var sw = new StringWriter();
string missing = Path.Combine(Path.GetTempPath(), "hfsql_audit_missing_" + Guid.NewGuid().ToString("N") + ".json");
int code = ConnectionTest.Run(new[] { "connection-test", missing, "Hfsql" }, sw);
Assert.Equal(2, code);
Assert.Contains("CONFIG_FILE_NOT_FOUND", sw.ToString());
}
[Fact]
public void Run_MissingSection_ReturnsExit2()
{
const string json = """{ "Unrelated": { "x": 1 } }""";
WithTempJson(json, path =>
{
using var sw = new StringWriter();
int code = ConnectionTest.Run(new[] { "connection-test", path, "Hfsql" }, sw);
Assert.Equal(2, code);
Assert.Contains("SECTION_NOT_FOUND", sw.ToString());
});
}
[Fact]
public void Run_MissingArguments_ReturnsExit2()
{
using var sw = new StringWriter();
Assert.Equal(2, ConnectionTest.Run(new[] { "connection-test" }, sw));
Assert.Equal(2, ConnectionTest.Run(new[] { "connection-test", "C:\\x.json" }, sw));
}
[Fact]
public void Run_InvalidConfigPath_DoesNotEchoExceptionMessage()
{
using var sw = new StringWriter();
int code = ConnectionTest.Run(new[] { "connection-test", "\u0000bad\0path.json", "Hfsql" }, sw);
Assert.Equal(2, code);
// Aucun message d'exception brut (peut contenir des fragments sensibles).
Assert.DoesNotContain("at System.", sw.ToString());
}
// ================================================================
// Sanitization / erreurs
// ================================================================
[Fact]
public void ExtractHfSqlErrorCode_ReadsNumericCodeOnly()
{
Assert.Equal("70001", ConnectionTest.ExtractHfSqlErrorCode(new Exception("Échec : Code erreur : 70001 dans l'accès")));
Assert.Equal("42", ConnectionTest.ExtractHfSqlErrorCode(new Exception("Error code: 42")));
Assert.Null(ConnectionTest.ExtractHfSqlErrorCode(new Exception("aucun code ici")));
}
[Fact]
public void SanitizeErrorType_ReturnsTypeNameOnly()
{
Assert.Equal("InvalidOperationException", ConnectionTest.SanitizeErrorType(new InvalidOperationException("secret-message")));
}
// ================================================================
// 14. Mode historique inchangé
// ================================================================
[Fact]
public void HistoricalAuditMode_IsUnchanged()
{
string source = File.ReadAllText(RepoFile("tools", "HfSqlOleDbAudit", "Program.cs"));
// Marqueurs du mode historique toujours présents.
Assert.Contains("=== SUMMARY ===", source);
Assert.Contains("CSV: CREATED", source);
Assert.Contains("MD: CREATED", source);
Assert.Contains("LoadTables", source);
Assert.Contains("Usage: HfSqlOleDbAudit ", source);
// Le dispatch connection-test est le seul ajout en tête de Main.
Assert.Contains(ConnectionTest.CommandName, source);
}
// ================================================================
// Helpers
// ================================================================
private static void WithTempJson(string json, Action action)
{
string path = Path.Combine(Path.GetTempPath(), "hfsql_audit_ct_" + Guid.NewGuid().ToString("N") + ".json");
try
{
File.WriteAllText(path, json);
action(path);
}
finally
{
File.Delete(path);
}
}
private static string RepoFile(params string[] parts)
{
string relative = Path.Combine(parts);
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null)
{
string candidate = Path.Combine(dir.FullName, relative);
if (File.Exists(candidate))
{
return candidate;
}
dir = dir.Parent;
}
throw new InvalidOperationException("Fichier introuvable depuis la racine du dépôt : " + relative);
}
}