Premiere commit - Omika
This commit is contained in:
@@ -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 <table></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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user