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); } }