Premiere commit - Omika

This commit is contained in:
2026-10-23 18:46:50 +02:00
commit 8ea76c1b24
2155 changed files with 984301 additions and 0 deletions
+435
View File
@@ -0,0 +1,435 @@
using System.Data.Common;
using System.Text.Json;
using System.Text.RegularExpressions;
using Omika.Data.Configuration;
using Omika.Data.HfSql.Connection;
namespace HfSqlOleDbAudit;
/// <summary>
/// Mode <c>connection-test</c> : valide les trois connexions HFSQL de Production
/// (OLE DB <c>PCSOFT.HFSQL</c>) en LECTURE SEULE, sans démarrer Omika.Api ni
/// Omika.Worker.
///
/// <para>
/// Parité Production exacte : la chaîne de connexion OLE DB est construite par
/// <see cref="HfSqlConnectionOptionsFactory"/> (Create / CreateForLicense /
/// CreateForService), <see cref="HfSqlOleDbConnectionStringBuilder"/> et
/// <see cref="HfSqlOleDbConnectionFactory"/> — les mêmes classes que
/// <c>BusinessOleDbConnection</c>, <c>LicenseOleDbConnection</c> et
/// <c>ServiceOleDbConnection</c>. Les mots de passe de fichiers
/// (ProtectionStd / ProtectionPlus) sont injectés dans les Extended Properties
/// par le mécanisme Production (réflexion sur <c>HfSqlTableAttribute</c>),
/// jamais dérivés, jamais calculés (aucun MD5, aucun wildcard improvisé).
/// </para>
///
/// <para>
/// Sécurité : lecture seule stricte (aucune commande d'écriture de données ni de
/// schéma), aucune écriture de fichier, aucun secret en argument CLI, aucun
/// secret affiché (seuls des indicateurs YES/NO et un code d'erreur numérique
/// sont émis). Aucune chaîne de connexion complète n'est jamais affichée.
/// </para>
/// </summary>
public static class ConnectionTest
{
public const string CommandName = "connection-test";
private static readonly Regex HfSqlErrorCodeRegex = new(
@"Code\s+erreur\s*:\s*(\d+)|Error\s+code\s*:\s*(\d+)",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
/// <summary>Sections de configuration supportées (strictement).</summary>
public enum Section
{
Hfsql,
LicenseConnection,
ServiceConnection
}
/// <summary>Arguments CLI validés du mode connection-test.</summary>
public sealed record Arguments(string ConfigPath, Section SectionValue, string? Table);
/// <summary>
/// Configuration de connexion résolue depuis appsettings.Production.json,
/// avec la vue OLE DB Production (<see cref="HfSqlConnectionOptions"/>).
/// Ne contient jamais les valeurs secrètes en clair dans sa représentation.
/// </summary>
public sealed record Config(
Section SectionValue,
string Server,
int Port,
string DatabaseName,
string Login,
bool DatabasePasswordPresent,
bool ProtectionStdPresent,
bool ProtectionPlusPresent,
HfSqlConnectionOptions ConnectionOptions);
// ================================================================
// ENTRY
// ================================================================
public static int Run(string[] args) => Run(args, Console.Out);
public static int Run(string[] args, TextWriter output)
{
ArgumentNullException.ThrowIfNull(args);
ArgumentNullException.ThrowIfNull(output);
if (!TryParseArguments(args, out Arguments? arguments, out string? argError) || arguments is null)
{
output.WriteLine("ERROR=" + argError);
PrintUsage(output);
return 2;
}
if (!TryLoadConfig(arguments.ConfigPath, arguments.SectionValue, out Config? config, out string? configError) || config is null)
{
output.WriteLine("ERROR=" + configError);
return 2;
}
foreach (string line in BuildSummaryLines(config))
{
output.WriteLine(line);
}
var factory = new HfSqlOleDbConnectionFactory();
// La connexion est créée puis TOUJOURS disposée (y compris si l'ouverture
// échoue) : le provider OLE DB natif PCSOFT.HFSQL doit être libéré
// proprement, sinon le processus peut se terminer anormalement à la
// finalisation de l'objet non disposé.
DbConnection connection;
try
{
connection = factory.Create(config.ConnectionOptions);
}
catch (Exception ex)
{
output.WriteLine("CONNECTION_OPEN=FAIL");
output.WriteLine("ERROR_TYPE=" + SanitizeErrorType(ex));
output.WriteLine("HFSQL_ERROR_CODE=" + (ExtractHfSqlErrorCode(ex) ?? "NONE"));
return 1;
}
using (connection)
{
try
{
connection.Open();
}
catch (Exception ex)
{
// Jamais de message brut : uniquement le type et le code numérique.
output.WriteLine("CONNECTION_OPEN=FAIL");
output.WriteLine("ERROR_TYPE=" + SanitizeErrorType(ex));
output.WriteLine("HFSQL_ERROR_CODE=" + (ExtractHfSqlErrorCode(ex) ?? "NONE"));
return 1;
}
output.WriteLine("CONNECTION_OPEN=PASS");
if (arguments.Table is not null)
{
bool ok = TrySelect(connection, arguments.Table, out int fieldCount, out bool hasRows, out string? code);
output.WriteLine("TABLE=" + arguments.Table);
output.WriteLine("SELECT=" + (ok ? "PASS" : "FAIL"));
output.WriteLine("FIELD_COUNT=" + (ok ? fieldCount.ToString() : "0"));
output.WriteLine("HAS_ROWS=" + (ok && hasRows ? "YES" : "NO"));
output.WriteLine("HFSQL_ERROR_CODE=" + (code ?? "NONE"));
if (!ok)
{
return 1;
}
}
}
return 0;
}
// ================================================================
// ARGUMENT PARSING
// ================================================================
public static bool TryParseArguments(IReadOnlyList<string> args, out Arguments? arguments, out string? error)
{
arguments = null;
error = null;
// args[0] = "connection-test", args[1] = <configPath>, args[2] = <section>, args[3]? = <table>.
if (args.Count < 3)
{
error = "INVALID_ARGUMENTS";
return false;
}
if (!TryParseSection(args[2], out Section section))
{
error = "UNSUPPORTED_SECTION";
return false;
}
string? table = null;
if (args.Count >= 4)
{
table = args[3];
if (!IsValidTableIdentifier(table))
{
error = "INVALID_TABLE_IDENTIFIER";
return false;
}
}
arguments = new Arguments(args[1], section, table);
return true;
}
public static bool TryParseSection(string section, out Section parsed)
{
if (section.Equals("Hfsql", StringComparison.OrdinalIgnoreCase))
{
parsed = Section.Hfsql;
return true;
}
if (section.Equals("LicenseConnection", StringComparison.OrdinalIgnoreCase))
{
parsed = Section.LicenseConnection;
return true;
}
if (section.Equals("ServiceConnection", StringComparison.OrdinalIgnoreCase))
{
parsed = Section.ServiceConnection;
return true;
}
parsed = default;
return false;
}
// ================================================================
// CONFIG LOADING (lecture seule)
// ================================================================
public static bool TryLoadConfig(string configPath, Section section, out Config? config, out string? error)
{
config = null;
error = null;
string json;
try
{
json = File.ReadAllText(configPath);
}
catch (Exception)
{
error = "CONFIG_FILE_NOT_FOUND";
return false;
}
JsonDocument document;
try
{
document = JsonDocument.Parse(json);
}
catch (JsonException)
{
error = "CONFIG_PARSE_FAILED";
return false;
}
using (document)
{
string sectionName = SectionName(section);
if (!document.RootElement.TryGetProperty(sectionName, out JsonElement sectionElement)
|| sectionElement.ValueKind != JsonValueKind.Object)
{
error = "SECTION_NOT_FOUND";
return false;
}
var jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
HfSqlConnectionOptions connectionOptions;
string server;
int port;
string databaseName;
string login;
try
{
switch (section)
{
case Section.Hfsql:
{
var options = sectionElement.Deserialize<HfsqlOptions>(jsonOptions) ?? new HfsqlOptions();
connectionOptions = HfSqlConnectionOptionsFactory.Create(options, options.FilePasswords);
server = options.Server ?? string.Empty;
port = options.Port;
databaseName = options.DatabaseName ?? string.Empty;
login = options.Login ?? string.Empty;
break;
}
case Section.LicenseConnection:
{
var options = sectionElement.Deserialize<LicenseConnectionOptions>(jsonOptions) ?? new LicenseConnectionOptions();
connectionOptions = HfSqlConnectionOptionsFactory.CreateForLicense(options);
server = options.Server ?? string.Empty;
port = options.Port;
databaseName = options.DatabaseName ?? string.Empty;
login = options.Login ?? string.Empty;
break;
}
case Section.ServiceConnection:
{
var options = sectionElement.Deserialize<ServiceConnectionOptions>(jsonOptions) ?? new ServiceConnectionOptions();
connectionOptions = HfSqlConnectionOptionsFactory.CreateForService(options);
server = options.Server ?? string.Empty;
port = options.Port;
databaseName = options.DatabaseName ?? string.Empty;
login = options.Login ?? string.Empty;
break;
}
default:
error = "UNSUPPORTED_SECTION";
return false;
}
}
catch (Exception)
{
error = "CONFIG_PARSE_FAILED";
return false;
}
config = new Config(
section,
server,
port,
databaseName,
login,
!string.IsNullOrWhiteSpace(connectionOptions.Credential),
!string.IsNullOrWhiteSpace(connectionOptions.FilePasswords.ProtectionStd),
!string.IsNullOrWhiteSpace(connectionOptions.FilePasswords.ProtectionPlus),
connectionOptions);
return true;
}
}
private static string SectionName(Section section) => section switch
{
Section.Hfsql => HfsqlOptions.SectionName,
Section.LicenseConnection => LicenseConnectionOptions.SectionName,
Section.ServiceConnection => ServiceConnectionOptions.SectionName,
_ => throw new ArgumentOutOfRangeException(nameof(section))
};
// ================================================================
// SUMMARY (jamais de secret)
// ================================================================
public static IReadOnlyList<string> BuildSummaryLines(Config config)
{
ArgumentNullException.ThrowIfNull(config);
return new[]
{
"Section=" + SectionName(config.SectionValue),
"Server=" + config.Server,
"Port=" + config.Port.ToString(),
"DatabaseName=" + config.DatabaseName,
"Login=" + config.Login,
"Provider=" + HfSqlConnectionOptions.OleDbProvider,
"DatabasePasswordPresent=" + (config.DatabasePasswordPresent ? "YES" : "NO"),
"ProtectionStdPresent=" + (config.ProtectionStdPresent ? "YES" : "NO"),
"ProtectionPlusPresent=" + (config.ProtectionPlusPresent ? "YES" : "NO")
};
}
// ================================================================
// SELECT (lecture seule)
// ================================================================
public static bool IsValidTableIdentifier(string name)
{
if (string.IsNullOrEmpty(name))
{
return false;
}
foreach (char c in name)
{
bool ok = (c >= 'A' && c <= 'Z')
|| (c >= 'a' && c <= 'z')
|| (c >= '0' && c <= '9')
|| c == '_';
if (!ok)
{
return false;
}
}
return true;
}
/// <summary>Unique requête autorisée : <c>SELECT TOP 1 * FROM &lt;table&gt;</c>.</summary>
public static string BuildSelectSql(string table) => $"SELECT TOP 1 * FROM {table}";
private static bool TrySelect(
DbConnection connection,
string table,
out int fieldCount,
out bool hasRows,
out string? hfSqlErrorCode)
{
fieldCount = 0;
hasRows = false;
hfSqlErrorCode = null;
if (!IsValidTableIdentifier(table))
{
return false;
}
try
{
using var command = connection.CreateCommand();
command.CommandText = BuildSelectSql(table);
using var reader = command.ExecuteReader();
fieldCount = reader.FieldCount;
hasRows = reader.Read();
return true;
}
catch (Exception ex)
{
hfSqlErrorCode = ExtractHfSqlErrorCode(ex);
return false;
}
}
// ================================================================
// SANITIZATION (aucun message brut, aucun secret)
// ================================================================
public static string SanitizeErrorType(Exception ex) => ex.GetType().Name;
public static string? ExtractHfSqlErrorCode(Exception ex)
{
var match = HfSqlErrorCodeRegex.Match(ex.Message);
if (!match.Success)
{
return null;
}
return !string.IsNullOrEmpty(match.Groups[1].Value)
? match.Groups[1].Value
: match.Groups[2].Value;
}
private static void PrintUsage(TextWriter output)
{
output.WriteLine("Usage: HfSqlOleDbAudit connection-test <configPath> <Hfsql|LicenseConnection|ServiceConnection> [<table>]");
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Le sous-dossier tests/ héberge un projet de tests xUnit séparé : il ne
doit pas être compilé ni publié par l'exécutable de diagnostic. -->
<DefaultItemExcludes>$(DefaultItemExcludes);tests\**</DefaultItemExcludes>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.Data.OleDb" />
</ItemGroup>
</Project>
+784
View File
@@ -0,0 +1,784 @@
using System.Data.OleDb;
using System.Reflection;
using System.Text.Json;
using System.Text.RegularExpressions;
using Omika.Data.HfSql.Protection;
namespace HfSqlOleDbAudit;
internal static class Program
{
private static int Main(string[] args)
{
// Mode "connection-test" (lecture seule, OLE DB PCSOFT.HFSQL). Dispatch
// ajouté sans modifier le comportement historique ci-dessous : tout autre
// premier argument emprunte exactement le chemin d'origine.
if (args.Length > 0
&& StringComparer.OrdinalIgnoreCase.Equals(args[0], ConnectionTest.CommandName))
{
return ConnectionTest.Run(args);
}
if (args.Length < 2)
{
Console.WriteLine("Usage: HfSqlOleDbAudit <configPath> <Batik|Omika>");
return 1;
}
string configPath = args[0];
string schema = args[1];
// ================================================================
// CONFIG LOADING
// ================================================================
TestConfig? config;
try
{
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
config = JsonSerializer.Deserialize<TestConfig>(File.ReadAllText(configPath), options);
}
catch
{
config = null;
}
if (config is null)
{
Console.WriteLine("Config JSON: INVALID");
return 1;
}
Console.WriteLine("Config JSON: VALID");
if (string.IsNullOrWhiteSpace(config.ProtectionPlusSecret))
{
Console.WriteLine("ProtectionPlusSecret: MISSING");
return 1;
}
Console.WriteLine("ProtectionPlusSecret: AVAILABLE");
// ================================================================
// DATABASE CHOICE
// ================================================================
DatabaseConfig db = schema.Equals("Batik", StringComparison.OrdinalIgnoreCase)
? config.Batik
: config.Omika;
string connectionDatabase = db.Database;
string passwordDatabaseName = db.OriginalDatabaseName ?? db.Database;
Console.WriteLine("PasswordDatabaseNameSource: " +
(db.OriginalDatabaseName is null ? "CURRENT" : "ORIGINAL"));
// ================================================================
// EXPLICIT LOCAL CONFIG — file passwords come from the local config,
// NOT derived. No derivation, no wildcard.
// ================================================================
string protectionStd = config.FilePasswords.ProtectionStd;
string protectionPlus = config.FilePasswords.ProtectionPlus;
Console.WriteLine("PasswordSource: EXPLICIT_LOCAL_CONFIG");
Console.WriteLine("PasswordDerivationUsed: NO");
Console.WriteLine("ProtectionPlusSecretUsedForConnection: NO");
// ================================================================
// LOAD TABLES
// ================================================================
List<TableInfo> tables = LoadTables(schema);
var results = new Dictionary<string, TableResult>(StringComparer.OrdinalIgnoreCase);
foreach (var t in tables)
{
results[t.LogicalName] = new TableResult { Table = t };
}
// ================================================================
// PHASE 1 — DIRECT
// ================================================================
using (var cn = new OleDbConnection(BuildConnectionString(config, db, null)))
{
cn.Open();
foreach (var t in tables)
{
var r = results[t.LogicalName];
QueryResult qr = TrySelect(cn, t.LogicalName);
if (qr.Success)
{
r.DirectAccess = true;
r.FinalAccess = FinalAccess.Direct;
r.FieldCount = qr.FieldCount;
r.HasRows = qr.HasRows;
}
else
{
r.DirectAccess = false;
r.FinalAccess = FinalAccess.Unknown;
r.HfSqlErrorCode = qr.HfSqlErrorCode;
}
}
}
// ================================================================
// PHASE 2 — FAILURES-ONLY EXPLICIT MAP
// ================================================================
string strategy = "FAILURES_ONLY_EXPLICIT_MAP";
List<string>? workingEntries = null;
var failedProtected = tables
.Where(t => !results[t.LogicalName].DirectAccess
&& (t.ExpectedGroup == ExpectedGroup.ProtectionStd
|| t.ExpectedGroup == ExpectedGroup.ProtectionPlus))
.ToList();
if (failedProtected.Count > 0)
{
var entries = new List<string>();
foreach (var t in failedProtected)
{
if (t.ExpectedGroup == ExpectedGroup.ProtectionStd)
entries.Add(BuildFilePasswordEntry(t.LogicalName, protectionStd));
else
entries.Add(BuildFilePasswordEntry(t.LogicalName, protectionPlus));
}
string ext = string.Join(";", entries);
using (var cn = new OleDbConnection(BuildConnectionString(config, db, ext)))
{
cn.Open();
foreach (var t in failedProtected)
{
var r = results[t.LogicalName];
QueryResult qr = TrySelect(cn, t.LogicalName);
if (qr.Success)
{
r.ProtectedAccess = true;
r.FinalAccess = t.ExpectedGroup == ExpectedGroup.ProtectionStd
? FinalAccess.ProtectionStd
: FinalAccess.ProtectionPlus;
r.FieldCount = qr.FieldCount;
r.HasRows = qr.HasRows;
}
else
{
r.HfSqlErrorCode = qr.HfSqlErrorCode;
}
}
}
workingEntries = entries;
}
// ================================================================
// CANARY CHECK — if ParametreMetier passes, STD is verified.
// ================================================================
bool stdVerified = false;
bool plusVerified = false;
if (results.TryGetValue("ParametreMetier", out var pmFinal)
&& !pmFinal.DirectAccess && pmFinal.ProtectedAccess)
{
stdVerified = true;
}
if ((results.TryGetValue("ParametreMetierEntete", out var pmeFinal)
&& !pmeFinal.DirectAccess && pmeFinal.ProtectedAccess)
|| (results.TryGetValue("ParametreSysteme", out var psFinal)
&& !psFinal.DirectAccess && psFinal.ProtectedAccess))
{
plusVerified = true;
}
// ================================================================
// STRATEGY 2 — FULL EXPLICIT MAP (if ParametreMetier still failed)
// ================================================================
bool pmStillFailed = results.TryGetValue("ParametreMetier", out var pmCheck)
&& pmCheck.FinalAccess != FinalAccess.Direct
&& pmCheck.FinalAccess != FinalAccess.ProtectionStd;
if (pmStillFailed)
{
strategy = "FULL_EXPLICIT_MAP";
var entries = new List<string>();
foreach (var t in tables)
{
if (t.ExpectedGroup == ExpectedGroup.ProtectionStd)
entries.Add(BuildFilePasswordEntry(t.LogicalName, protectionStd));
else if (t.ExpectedGroup == ExpectedGroup.ProtectionPlus)
entries.Add(BuildFilePasswordEntry(t.LogicalName, protectionPlus));
}
string ext = string.Join(";", entries);
using (var cn = new OleDbConnection(BuildConnectionString(config, db, ext)))
{
cn.Open();
foreach (var name in new[] { "Client", "ParametreMetier", "ParametreMetierEntete", "ParametreSysteme" })
{
if (results.TryGetValue(name, out var r) && !r.DirectAccess)
{
QueryResult qr = TrySelect(cn, name);
if (qr.Success)
{
r.ProtectedAccess = true;
r.FinalAccess = r.Table.ExpectedGroup == ExpectedGroup.ProtectionStd
? FinalAccess.ProtectionStd
: FinalAccess.ProtectionPlus;
r.FieldCount = qr.FieldCount;
r.HasRows = qr.HasRows;
}
else
{
r.HfSqlErrorCode = qr.HfSqlErrorCode;
}
}
}
foreach (var t in tables.Where(t => !results[t.LogicalName].DirectAccess
&& results[t.LogicalName].FinalAccess == FinalAccess.Unknown))
{
var r = results[t.LogicalName];
QueryResult qr = TrySelect(cn, t.LogicalName);
if (qr.Success)
{
r.ProtectedAccess = true;
r.FinalAccess = t.ExpectedGroup == ExpectedGroup.ProtectionStd
? FinalAccess.ProtectionStd
: FinalAccess.ProtectionPlus;
r.FieldCount = qr.FieldCount;
r.HasRows = qr.HasRows;
}
else
{
r.HfSqlErrorCode = qr.HfSqlErrorCode;
}
}
}
workingEntries = entries;
// Re-evaluate canaries after strategy 2
if (results.TryGetValue("ParametreMetier", out var pm2)
&& !pm2.DirectAccess && pm2.ProtectedAccess)
{
stdVerified = true;
}
if ((results.TryGetValue("ParametreMetierEntete", out var pme2)
&& !pme2.DirectAccess && pme2.ProtectedAccess)
|| (results.TryGetValue("ParametreSysteme", out var ps2)
&& !ps2.DirectAccess && ps2.ProtectedAccess))
{
plusVerified = true;
}
}
// ================================================================
// STRATEGY 3 — WILDCARD_PLUS_OVERRIDES (if ParametreMetier still failed)
// ================================================================
bool pmStillFailed2 = results.TryGetValue("ParametreMetier", out var pmCheck2)
&& pmCheck2.FinalAccess != FinalAccess.Direct
&& pmCheck2.FinalAccess != FinalAccess.ProtectionStd;
if (pmStillFailed2)
{
strategy = "WILDCARD_PLUS_OVERRIDES";
var entries = new List<string>();
// Wildcard first: applies ProtectionStd to all files.
entries.Add(BuildFilePasswordEntry("*", protectionStd));
// Then explicit overrides for each ProtectionPlus file.
foreach (var t in tables.Where(t => t.ExpectedGroup == ExpectedGroup.ProtectionPlus))
{
entries.Add(BuildFilePasswordEntry(t.LogicalName, protectionPlus));
}
string ext = string.Join(";", entries);
using (var cn = new OleDbConnection(BuildConnectionString(config, db, ext)))
{
cn.Open();
foreach (var name in new[] { "Client", "ParametreMetier", "ParametreMetierEntete", "ParametreSysteme" })
{
if (results.TryGetValue(name, out var r) && !r.DirectAccess)
{
QueryResult qr = TrySelect(cn, name);
if (qr.Success)
{
r.ProtectedAccess = true;
r.FinalAccess = r.Table.ExpectedGroup == ExpectedGroup.ProtectionStd
? FinalAccess.ProtectionStd
: FinalAccess.ProtectionPlus;
r.FieldCount = qr.FieldCount;
r.HasRows = qr.HasRows;
}
else
{
r.HfSqlErrorCode = qr.HfSqlErrorCode;
}
}
}
foreach (var t in tables.Where(t => !results[t.LogicalName].DirectAccess
&& results[t.LogicalName].FinalAccess == FinalAccess.Unknown))
{
var r = results[t.LogicalName];
QueryResult qr = TrySelect(cn, t.LogicalName);
if (qr.Success)
{
r.ProtectedAccess = true;
r.FinalAccess = t.ExpectedGroup == ExpectedGroup.ProtectionStd
? FinalAccess.ProtectionStd
: FinalAccess.ProtectionPlus;
r.FieldCount = qr.FieldCount;
r.HasRows = qr.HasRows;
}
else
{
r.HfSqlErrorCode = qr.HfSqlErrorCode;
}
}
}
workingEntries = entries;
// Re-evaluate canaries after strategy 3
if (results.TryGetValue("ParametreMetier", out var pm3)
&& !pm3.DirectAccess && pm3.ProtectedAccess)
{
stdVerified = true;
}
if ((results.TryGetValue("ParametreMetierEntete", out var pme3)
&& !pme3.DirectAccess && pme3.ProtectedAccess)
|| (results.TryGetValue("ParametreSysteme", out var ps3)
&& !ps3.DirectAccess && ps3.ProtectedAccess))
{
plusVerified = true;
}
}
// ================================================================
// NEGATIVE CONTROL
// ================================================================
bool negativeControlRun = false;
bool negativeControlPass = false;
var protectedPassed = tables.FirstOrDefault(t => results[t.LogicalName].ProtectedAccess);
if (protectedPassed is not null && workingEntries is not null)
{
negativeControlRun = true;
string fakePassword = "00000000000000000000000000000000";
string chosenName = protectedPassed.LogicalName;
var fakeEntries = new List<string>(workingEntries);
string escapedChosen = EscapeExtendedPropertyValue(chosenName);
fakeEntries.RemoveAll(e => e.StartsWith("Password=" + escapedChosen + ":"));
fakeEntries.Add(BuildFilePasswordEntry(chosenName, fakePassword));
string fakeExt = string.Join(";", fakeEntries);
using (var cn = new OleDbConnection(BuildConnectionString(config, db, fakeExt)))
{
cn.Open();
QueryResult qr = TrySelect(cn, chosenName);
negativeControlPass = !qr.Success;
}
}
// ================================================================
// FINALIZE — mark any table still unresolved as Failed
// ================================================================
foreach (var t in tables)
{
var r = results[t.LogicalName];
if (r.FinalAccess == FinalAccess.Unknown)
{
r.FinalAccess = FinalAccess.Failed;
}
}
// ================================================================
// CONSOLE OUTPUT
// ================================================================
Console.WriteLine();
Console.WriteLine($"=== {schema} ===");
Console.WriteLine();
foreach (var t in tables)
{
var r = results[t.LogicalName];
Console.WriteLine("LogicalFile: " + t.LogicalName);
Console.WriteLine("ExpectedGroup: " + t.ExpectedGroup);
Console.WriteLine("DirectAccess: " + r.DirectAccess);
Console.WriteLine("ProtectedAccess: " + r.ProtectedAccess);
Console.WriteLine("FinalAccess: " + GetFinalAccessLabel(r.FinalAccess));
Console.WriteLine("FieldCount: " + r.FieldCount);
Console.WriteLine("HasRows: " + r.HasRows);
Console.WriteLine("HfSqlErrorCode: " + (r.HfSqlErrorCode ?? "NONE"));
Console.WriteLine();
}
int directCount = tables.Count(t => results[t.LogicalName].DirectAccess);
int stdCount = tables.Count(t => results[t.LogicalName].FinalAccess == FinalAccess.ProtectionStd);
int plusCount = tables.Count(t => results[t.LogicalName].FinalAccess == FinalAccess.ProtectionPlus);
int failedCount = tables.Count(t => results[t.LogicalName].FinalAccess == FinalAccess.Failed);
Console.WriteLine("=== SUMMARY ===");
Console.WriteLine($"Tables: {tables.Count}");
Console.WriteLine($"Direct: {directCount}");
Console.WriteLine($"ProtectionStd: {stdCount}");
Console.WriteLine($"ProtectionPlus: {plusCount}");
Console.WriteLine($"Failed: {failedCount}");
Console.WriteLine();
Console.WriteLine($"ParametreMetier: {GetCanaryLabel(results, "ParametreMetier")}");
Console.WriteLine($"ParametreMetierEntete: {GetCanaryLabel(results, "ParametreMetierEntete")}");
Console.WriteLine($"ParametreSysteme: {GetCanaryLabel(results, "ParametreSysteme")}");
Console.WriteLine();
Console.WriteLine($"Strategy: {strategy}");
Console.WriteLine($"ProtectionStd OLE DB: {(stdVerified ? "VERIFIED_FROM_DATABASE" : "UNRESOLVED")}");
Console.WriteLine($"ProtectionPlus OLE DB: {(plusVerified ? "VERIFIED_FROM_DATABASE" : "UNRESOLVED")}");
Console.WriteLine($"NegativeControl: {(negativeControlRun ? (negativeControlPass ? "PASS" : "FAIL") : "NOT_RUN")}");
// ================================================================
// CSV REPORT
// ================================================================
string csvPath = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "docs", "diagnostics", "hfsql-oledb-open-all.csv");
csvPath = Path.GetFullPath(csvPath);
Directory.CreateDirectory(Path.GetDirectoryName(csvPath)!);
bool csvExists = File.Exists(csvPath);
var csvLines = new List<string>();
if (!csvExists)
{
csvLines.Add("Schema,Table,DiskName,ExpectedGroup,DirectAccess,ProtectedAccess,FinalAccess,FieldCount,HasRows,HfSqlErrorCode");
}
foreach (var t in tables)
{
var r = results[t.LogicalName];
csvLines.Add(string.Join(",",
Csv(schema), Csv(t.LogicalName), Csv(t.DiskName), Csv(t.ExpectedGroup.ToString()),
Csv(r.DirectAccess.ToString()), Csv(r.ProtectedAccess.ToString()),
Csv(GetFinalAccessLabel(r.FinalAccess)),
Csv(r.FieldCount.ToString()), Csv(r.HasRows.ToString()),
Csv(r.HfSqlErrorCode ?? "")));
}
File.AppendAllLines(csvPath, csvLines);
Console.WriteLine($"CSV: CREATED ({csvPath})");
// ================================================================
// MARKDOWN REPORT
// ================================================================
string mdPath = Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "docs", "diagnostics", "hfsql-oledb-open-all.md");
mdPath = Path.GetFullPath(mdPath);
Directory.CreateDirectory(Path.GetDirectoryName(mdPath)!);
var md = new System.Text.StringBuilder();
bool mdExists = File.Exists(mdPath);
if (!mdExists)
{
md.AppendLine("# HFSQL OLE DB Open All — Diagnostic");
md.AppendLine();
}
md.AppendLine($"## {schema}");
md.AppendLine();
md.AppendLine("### Résumé");
md.AppendLine();
md.AppendLine($"- Tables: {tables.Count}");
md.AppendLine($"- Direct: {directCount}");
md.AppendLine($"- ProtectionStd: {stdCount}");
md.AppendLine($"- ProtectionPlus: {plusCount}");
md.AppendLine($"- Failed: {failedCount}");
md.AppendLine();
md.AppendLine($"- ParametreMetier: {GetCanaryLabel(results, "ParametreMetier")}");
md.AppendLine($"- ParametreMetierEntete: {GetCanaryLabel(results, "ParametreMetierEntete")}");
md.AppendLine($"- ParametreSysteme: {GetCanaryLabel(results, "ParametreSysteme")}");
md.AppendLine();
md.AppendLine($"- Strategy: {strategy}");
md.AppendLine($"- ProtectionStd OLE DB: {(stdVerified ? "VERIFIED_FROM_DATABASE" : "UNRESOLVED")}");
md.AppendLine($"- ProtectionPlus OLE DB: {(plusVerified ? "VERIFIED_FROM_DATABASE" : "UNRESOLVED")}");
md.AppendLine($"- NegativeControl: {(negativeControlRun ? (negativeControlPass ? "PASS" : "FAIL") : "NOT_RUN")}");
md.AppendLine();
md.AppendLine("### Canaris");
md.AppendLine();
md.AppendLine($"- ParametreMetier: {GetCanaryLabel(results, "ParametreMetier")}");
md.AppendLine($"- ParametreMetierEntete: {GetCanaryLabel(results, "ParametreMetierEntete")}");
md.AppendLine($"- ParametreSysteme: {GetCanaryLabel(results, "ParametreSysteme")}");
md.AppendLine();
md.AppendLine("### Échecs");
md.AppendLine();
foreach (var t in tables.Where(t => results[t.LogicalName].FinalAccess == FinalAccess.Failed))
{
var r = results[t.LogicalName];
md.AppendLine($"- {t.LogicalName} (Expected={t.ExpectedGroup}, HfSql={r.HfSqlErrorCode ?? "NONE"})");
}
md.AppendLine();
File.AppendAllText(mdPath, md.ToString());
Console.WriteLine($"MD: CREATED ({mdPath})");
return 0;
}
// ================================================================
// HELPERS
// ================================================================
private static string GetCanaryLabel(Dictionary<string, TableResult> results, string name)
{
if (!results.TryGetValue(name, out var r)) return "FAILED";
return r.FinalAccess switch
{
FinalAccess.Direct => "DIRECT",
FinalAccess.ProtectionStd => "PROTECTION_STD",
FinalAccess.ProtectionPlus => "PROTECTION_PLUS",
FinalAccess.Failed => "FAILED",
_ => "FAILED"
};
}
private static string GetFinalAccessLabel(FinalAccess access)
{
return access switch
{
FinalAccess.Direct => "DIRECT",
FinalAccess.ProtectionStd => "PROTECTION_STD",
FinalAccess.ProtectionPlus => "PROTECTION_PLUS",
FinalAccess.Failed => "FAILED",
_ => "UNKNOWN"
};
}
private static string Csv(string? value)
{
if (string.IsNullOrEmpty(value)) return "";
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
return "\"" + value.Replace("\"", "\"\"") + "\"";
return value;
}
// ================================================================
// MODEL LOADING (reflection)
// ================================================================
private static List<TableInfo> LoadTables(string schema)
{
var assembly = typeof(HfsqlFilePasswordDeriver).Assembly;
string ns = schema.Equals("Batik", StringComparison.OrdinalIgnoreCase)
? "Omika.Data.HfSql.Models.Batik"
: "Omika.Data.HfSql.Models.Omika";
var tables = new List<TableInfo>();
foreach (var type in assembly.GetTypes())
{
if (type.Namespace != ns) continue;
var attr = type.GetCustomAttributes()
.FirstOrDefault(a => a.GetType().Name == "HfSqlTableAttribute");
if (attr is null) continue;
string tableName = GetAttrProperty(attr, "TableName") ?? type.Name;
string diskName = GetAttrProperty(attr, "DiskName") ?? tableName + ".fic";
string[] groups = GetAttrGroups(attr);
bool hasStd = groups.Any(g => g.Equals("ProtectionStd", StringComparison.OrdinalIgnoreCase));
bool hasPlus = groups.Any(g => g.Equals("ProtectionPlus", StringComparison.OrdinalIgnoreCase));
ExpectedGroup expected;
if (hasStd && hasPlus) expected = ExpectedGroup.Conflict;
else if (hasPlus) expected = ExpectedGroup.ProtectionPlus;
else if (hasStd) expected = ExpectedGroup.ProtectionStd;
else expected = ExpectedGroup.None;
tables.Add(new TableInfo
{
LogicalName = tableName,
DiskName = diskName,
ExpectedGroup = expected
});
}
return tables.OrderBy(t => t.LogicalName).ToList();
}
private static string? GetAttrProperty(object attr, string propName)
{
var prop = attr.GetType().GetProperty(propName);
if (prop is null) return null;
var val = prop.GetValue(attr);
return val?.ToString();
}
private static string[] GetAttrGroups(object attr)
{
var prop = attr.GetType().GetProperty("Groups");
if (prop is null) return [];
var val = prop.GetValue(attr);
if (val is string[] arr) return arr;
if (val is IEnumerable<string> en) return en.ToArray();
return [];
}
// ================================================================
// CONNECTION STRING / EXTENDED PROPERTIES
// ================================================================
private static string EscapeExtendedPropertyValue(string value)
{
return value
.Replace("\\", "\\\\")
.Replace("=", "\\=")
.Replace(";", "\\;")
.Replace(":", "\\:");
}
private static string BuildFilePasswordEntry(string logicalFileName, string password)
{
return "Password=" +
EscapeExtendedPropertyValue(logicalFileName) +
":" +
EscapeExtendedPropertyValue(password);
}
private static string BuildConnectionString(
TestConfig root,
DatabaseConfig database,
string? extendedProperties)
{
var builder = new OleDbConnectionStringBuilder();
builder.Provider = "PCSOFT.HFSQL";
builder.DataSource = root.Server + ":" + root.Port;
builder["Initial Catalog"] = database.Database;
builder["User ID"] = database.User;
builder["Password"] = database.Credential;
if (!string.IsNullOrWhiteSpace(extendedProperties))
{
builder["Extended Properties"] = extendedProperties;
}
return builder.ConnectionString;
}
// ================================================================
// QUERY
// ================================================================
private static QueryResult TrySelect(OleDbConnection connection, string logicalName)
{
if (!IsValidIdentifier(logicalName))
{
return new QueryResult(false, 0, false, null);
}
try
{
using var cmd = connection.CreateCommand();
cmd.CommandText = $"SELECT TOP 1 * FROM {logicalName}";
using var reader = cmd.ExecuteReader();
int fieldCount = reader.FieldCount;
bool hasRows = reader.Read();
return new QueryResult(true, fieldCount, hasRows, null);
}
catch (OleDbException ex)
{
return new QueryResult(false, 0, false, ExtractHfSqlErrorCode(ex));
}
}
private static bool IsValidIdentifier(string name)
{
if (string.IsNullOrEmpty(name)) return false;
foreach (char c in name)
{
bool ok = (c >= 'A' && c <= 'Z')
|| (c >= 'a' && c <= 'z')
|| (c >= '0' && c <= '9')
|| c == '_';
if (!ok) return false;
}
return true;
}
private static string? ExtractHfSqlErrorCode(OleDbException ex)
{
string msg = ex.Message;
var m = Regex.Match(msg, @"Code\s+erreur\s*:\s*(\d+)");
if (!m.Success)
m = Regex.Match(msg, @"Error\s+code\s*:\s*(\d+)");
return m.Success ? m.Groups[1].Value : null;
}
// ================================================================
// STRUCTURES
// ================================================================
private sealed record QueryResult(
bool Success,
int FieldCount,
bool HasRows,
string? HfSqlErrorCode);
private sealed class TestConfig
{
public int Port { get; set; }
public string Server { get; set; } = "";
public string ProtectionPlusSecret { get; set; } = "";
public FilePasswordsConfig FilePasswords { get; set; } = new();
public DatabaseConfig Batik { get; set; } = new();
public DatabaseConfig Omika { get; set; } = new();
}
private sealed class FilePasswordsConfig
{
public string ProtectionStd { get; set; } = "";
public string ProtectionPlus { get; set; } = "";
}
private sealed class DatabaseConfig
{
public string Credential { get; set; } = "";
public string Database { get; set; } = "";
public string User { get; set; } = "";
public string? OriginalDatabaseName { get; set; }
}
private enum ExpectedGroup
{
None,
ProtectionStd,
ProtectionPlus,
Conflict
}
private enum FinalAccess
{
Unknown,
Direct,
ProtectionStd,
ProtectionPlus,
Failed
}
private sealed class TableInfo
{
public string LogicalName { get; init; } = "";
public string DiskName { get; init; } = "";
public ExpectedGroup ExpectedGroup { get; init; }
}
private sealed class TableResult
{
public required TableInfo Table { get; init; }
public bool DirectAccess { get; set; }
public bool ProtectedAccess { get; set; }
public FinalAccess FinalAccess { get; set; }
public int FieldCount { get; set; }
public bool HasRows { get; set; }
public string? HfSqlErrorCode { get; set; }
}
}
@@ -0,0 +1,463 @@
using HfSqlOleDbAudit;
namespace HfSqlOleDbAudit.Tests;
/// <summary>
/// Tests unitaires du mode <c>connection-test</c> (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.
/// </summary>
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 <configPath> <Batik|Omika>", 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<string> 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);
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<!--
Tests du mode "connection-test" de HfSqlOleDbAudit SANS serveur HFSQL réel :
parsing de configuration, rejet de section inconnue, non-affichage des secrets,
validateur d'identifiant de table, requête SELECT lecture seule, absence
d'écriture de fichier, codes de sortie et préservation du mode historique.
-->
<ItemGroup>
<ProjectReference Include="..\..\HfSqlOleDbAudit.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>