Premiere commit - Omika
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using Omika.Data.HfSql.Metadata;
|
||||
using Omika.Data.HfSql.Protection;
|
||||
|
||||
namespace HfSqlModelAudit;
|
||||
|
||||
/// <summary>
|
||||
/// Audite les modèles physiques HFSQL (Batik / Omika) déclarés dans l'assembly
|
||||
/// Omika.Data. Lit par réflexion l'attribut <see cref="HfSqlTableAttribute"/> de
|
||||
/// chaque type des namespaces <c>Omika.Data.HfSql.Models.Batik</c> et
|
||||
/// <c>Omika.Data.HfSql.Models.Omika</c>.
|
||||
///
|
||||
/// Génère deux rapports :
|
||||
/// docs/diagnostics/hfsql-model-audit.csv
|
||||
/// docs/diagnostics/hfsql-model-audit.md
|
||||
///
|
||||
/// Aucune connexion n'est ouverte et aucun secret n'est manipulé.
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
private const string BatikNamespace = "Omika.Data.HfSql.Models.Batik";
|
||||
private const string OmikaNamespace = "Omika.Data.HfSql.Models.Omika";
|
||||
|
||||
private sealed record ModelInfo(
|
||||
string Schema,
|
||||
string LogicalName,
|
||||
string DiskName,
|
||||
string Groups,
|
||||
string SchemaVersions);
|
||||
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
var outputDir = args.Length > 0 ? args[0] : @"D:\Avalonia\Omika\docs\diagnostics";
|
||||
Directory.CreateDirectory(outputDir);
|
||||
|
||||
var assembly = typeof(HfsqlFilePasswordDeriver).Assembly;
|
||||
var models = LoadModels(assembly);
|
||||
|
||||
var batik = models.Where(m => m.Schema == "Batik").OrderBy(m => m.LogicalName).ToList();
|
||||
var omika = models.Where(m => m.Schema == "Omika").OrderBy(m => m.LogicalName).ToList();
|
||||
|
||||
WriteCsv(Path.Combine(outputDir, "hfsql-model-audit.csv"), models);
|
||||
WriteMarkdown(Path.Combine(outputDir, "hfsql-model-audit.md"), batik, omika);
|
||||
|
||||
Console.WriteLine($"Batik : {batik.Count} tables");
|
||||
Console.WriteLine($"Omika : {omika.Count} tables");
|
||||
Console.WriteLine($"Total : {models.Count} tables");
|
||||
Console.WriteLine("Rapports générés dans " + outputDir);
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static List<ModelInfo> LoadModels(Assembly assembly)
|
||||
{
|
||||
var result = new List<ModelInfo>();
|
||||
|
||||
foreach (var type in assembly.GetTypes())
|
||||
{
|
||||
string schema;
|
||||
if (type.Namespace == BatikNamespace)
|
||||
schema = "Batik";
|
||||
else if (type.Namespace == OmikaNamespace)
|
||||
schema = "Omika";
|
||||
else
|
||||
continue;
|
||||
|
||||
var attr = type.GetCustomAttribute<HfSqlTableAttribute>();
|
||||
if (attr is null)
|
||||
continue;
|
||||
|
||||
result.Add(new ModelInfo(
|
||||
schema,
|
||||
attr.TableName,
|
||||
attr.DiskName ?? "",
|
||||
string.Join(",", attr.Groups),
|
||||
string.Join(",", attr.SchemaVersions)));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void WriteCsv(string path, List<ModelInfo> models)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Schema,Table,DiskName,Groups,SchemaVersions");
|
||||
foreach (var m in models.OrderBy(m => m.Schema).ThenBy(m => m.LogicalName))
|
||||
{
|
||||
sb.AppendLine($"{m.Schema},{m.LogicalName},{m.DiskName},{m.Groups},{m.SchemaVersions}");
|
||||
}
|
||||
File.WriteAllText(path, sb.ToString());
|
||||
}
|
||||
|
||||
private static void WriteMarkdown(string path, List<ModelInfo> batik, List<ModelInfo> omika)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("# Audit des modèles physiques HFSQL");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Généré par `tools/HfSqlModelAudit` à partir de l'assembly `Omika.Data`.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## Batik");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"**{batik.Count} tables**");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| Table | DiskName | Groups |");
|
||||
sb.AppendLine("|---|---|---|");
|
||||
foreach (var m in batik)
|
||||
{
|
||||
sb.AppendLine($"| {m.LogicalName} | {m.DiskName} | {m.Groups} |");
|
||||
}
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## Omika");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"**{omika.Count} tables**");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| Table | DiskName | Groups |");
|
||||
sb.AppendLine("|---|---|---|");
|
||||
foreach (var m in omika)
|
||||
{
|
||||
sb.AppendLine($"| {m.LogicalName} | {m.DiskName} | {m.Groups} |");
|
||||
}
|
||||
sb.AppendLine();
|
||||
File.WriteAllText(path, sb.ToString());
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>Omika.ClientSnapshotProbe</AssemblyName>
|
||||
<RootNamespace>Omika.ClientSnapshotProbe</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="System.Data.OleDb" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,132 @@
|
||||
// Probe LECTURE SEULE ciblé : chargement Clients du snapshot mobile.
|
||||
//
|
||||
// Reproduit EXACTEMENT le chemin du snapshot Clients corrigé :
|
||||
// MobileSnapshotService.LoadClientsAsync
|
||||
// -> ClientService.SearchDetailsAsync
|
||||
// -> BatikClientRepository.SearchDetailsAsync (bulk, pas de N+1)
|
||||
//
|
||||
// Le probe compte le nombre de requêtes HFSQL exécutées pour prouver que le
|
||||
// chargement est bulk (nombre borné, indépendant du nombre de clients) et
|
||||
// qu'aucun GetById par client n'est émis.
|
||||
//
|
||||
// Aucune écriture. Aucun password affiché. Aucune donnée personnelle loggée.
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Omika.Core.Contracts.Clients;
|
||||
using Omika.Core.Contracts.Common;
|
||||
using Omika.Core.Domain.Entities.Clients;
|
||||
using Omika.Core.Interfaces;
|
||||
|
||||
using Omika.Data.Configuration;
|
||||
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.HfSql.Connection;
|
||||
using Omika.Data.HfSql.Metadata;
|
||||
using Omika.Data.Repositories.Batik;
|
||||
|
||||
// --- 1. Charger la configuration Hfsql (avec FilePasswords) ---
|
||||
var configPath = Path.GetFullPath(Path.Combine(
|
||||
Directory.GetCurrentDirectory(),
|
||||
"config", "hfsql-test.local.json"));
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddJsonFile(configPath, optional: false, reloadOnChange: false)
|
||||
.Build();
|
||||
|
||||
var hfsqlOptions = new HfsqlOptions();
|
||||
config.GetSection(HfsqlOptions.SectionName).Bind(hfsqlOptions);
|
||||
|
||||
Console.WriteLine($"=== Probe Client Snapshot (base {hfsqlOptions.DatabaseName}) ===");
|
||||
Console.WriteLine();
|
||||
|
||||
// --- 2. Reproduire le mécanisme de l'API : BusinessOleDbConnection ---
|
||||
var factory = new HfSqlOleDbConnectionFactory();
|
||||
await using var business = new BusinessOleDbConnection(hfsqlOptions, factory);
|
||||
|
||||
// Wrapper compteur de requêtes HFSQL (lecture seule, délègue tout).
|
||||
var counting = new CountingHfsqlConnection(business);
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
await counting.OpenAsync();
|
||||
Console.WriteLine("Connexion OLE DB ouverte (mécanisme API).");
|
||||
|
||||
// --- 3. Repository Batik + chemin bulk du snapshot Clients ---
|
||||
IClientRepository repository = new BatikClientRepository(counting);
|
||||
|
||||
var criteria = new ClientSearchCriteria
|
||||
{
|
||||
Page = 1,
|
||||
PageSize = 1000
|
||||
};
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
PagedResult<Client> result = await repository.SearchDetailsAsync(criteria, cts.Token);
|
||||
|
||||
sw.Stop();
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("--- Résultat ---");
|
||||
Console.WriteLine($"clients chargés (page) = {result.Items.Count}");
|
||||
Console.WriteLine($"total clients (SearchDetails) = {result.TotalCount}");
|
||||
Console.WriteLine($"requêtes HFSQL exécutées = {counting.QueryCount}");
|
||||
Console.WriteLine($"elapsed = {sw.ElapsedMilliseconds} ms");
|
||||
|
||||
// Vérification anti-N+1 : le nombre de requêtes doit être borné et très
|
||||
// inférieur au nombre de clients. Avec l'ancien pattern N+1, on aurait
|
||||
// observé ~1 requête par client (18824 requêtes).
|
||||
if (result.TotalCount > 0 && counting.QueryCount >= result.TotalCount)
|
||||
{
|
||||
Console.WriteLine("ATTENTION : le nombre de requêtes est >= au nombre de clients (pattern N+1 suspect).");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("OK : chargement bulk (requêtes << clients), aucun GetById par client.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
Console.WriteLine($"ERREUR: {ex.Message}");
|
||||
Console.WriteLine($"elapsed = {sw.ElapsedMilliseconds} ms");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Probe Client Snapshot terminé (lecture seule).");
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper IHfsqlConnection qui délègue à la connexion réelle et compte les
|
||||
/// requêtes de lecture (QueryAsync). Lecture seule : aucune écriture.
|
||||
/// </summary>
|
||||
internal sealed class CountingHfsqlConnection : IHfsqlConnection
|
||||
{
|
||||
private readonly IHfsqlConnection _inner;
|
||||
public int QueryCount { get; private set; }
|
||||
|
||||
public CountingHfsqlConnection(IHfsqlConnection inner) => _inner = inner;
|
||||
|
||||
public HfsqlOptions Options => _inner.Options;
|
||||
public HfsqlConnectionState State => _inner.State;
|
||||
|
||||
public Task OpenAsync(CancellationToken ct = default) => _inner.OpenAsync(ct);
|
||||
|
||||
public Task CloseAsync() => _inner.CloseAsync();
|
||||
|
||||
|
||||
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
|
||||
=> _inner.ExecuteScalarAsync(sql, parameters, ct);
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
|
||||
=> _inner.ExecuteNonQueryAsync(sql, parameters, ct);
|
||||
|
||||
public async Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
|
||||
{
|
||||
QueryCount++;
|
||||
return await _inner.QueryAsync(sql, parameters, ct);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken ct = default)
|
||||
=> _inner.GetColumnsAsync(tableName, ct);
|
||||
|
||||
public ValueTask DisposeAsync() => _inner.DisposeAsync();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>Omika.CollaborateurBulkProbe</AssemblyName>
|
||||
<RootNamespace>Omika.CollaborateurBulkProbe</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="System.Data.OleDb" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,132 @@
|
||||
// Probe LECTURE SEULE ciblé : chargement Collaborateurs du snapshot mobile (bulk).
|
||||
//
|
||||
// Reproduit EXACTEMENT le chemin du snapshot Collaborateurs corrigé :
|
||||
// MobileSnapshotService.LoadCollaborateursAsync
|
||||
// -> ICollaborateurService.SearchDetailsAsync
|
||||
// -> BatikCollaborateurRepository.SearchDetailsAsync (bulk, pas de N+1)
|
||||
//
|
||||
// Le probe compte le nombre de requêtes HFSQL exécutées pour prouver que le
|
||||
// chargement est bulk (nombre borné, indépendant du nombre de collaborateurs) et
|
||||
// qu'aucun GetById par collaborateur n'est émis.
|
||||
//
|
||||
// Aucune écriture. Aucun password affiché. Aucune donnée personnelle loggée.
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Omika.Core.Contracts.Common;
|
||||
using Omika.Core.Domain.Commands;
|
||||
using Omika.Core.Domain.Entities.Organisation;
|
||||
using Omika.Core.Interfaces;
|
||||
|
||||
using Omika.Data.Configuration;
|
||||
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.HfSql.Connection;
|
||||
using Omika.Data.HfSql.Metadata;
|
||||
using Omika.Data.Repositories.Batik;
|
||||
|
||||
// --- 1. Charger la configuration Hfsql (avec FilePasswords) ---
|
||||
var configPath = Path.GetFullPath(Path.Combine(
|
||||
Directory.GetCurrentDirectory(),
|
||||
"config", "hfsql-test.local.json"));
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddJsonFile(configPath, optional: false, reloadOnChange: false)
|
||||
.Build();
|
||||
|
||||
var hfsqlOptions = new HfsqlOptions();
|
||||
config.GetSection(HfsqlOptions.SectionName).Bind(hfsqlOptions);
|
||||
|
||||
Console.WriteLine($"=== Probe Collaborateurs Snapshot Bulk (base {hfsqlOptions.DatabaseName}) ===");
|
||||
Console.WriteLine();
|
||||
|
||||
// --- 2. Reproduire le mécanisme de l'API : BusinessOleDbConnection ---
|
||||
var factory = new HfSqlOleDbConnectionFactory();
|
||||
await using var business = new BusinessOleDbConnection(hfsqlOptions, factory);
|
||||
|
||||
// Wrapper compteur de requêtes HFSQL (lecture seule, délègue tout).
|
||||
var counting = new CountingHfsqlConnection(business);
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
await counting.OpenAsync();
|
||||
Console.WriteLine("Connexion OLE DB ouverte (mécanisme API).");
|
||||
|
||||
// --- 3. Repository Batik + chemin bulk du snapshot Collaborateurs ---
|
||||
ICollaborateurRepository repository = new BatikCollaborateurRepository(counting);
|
||||
|
||||
var criteria = new CollaborateurSearchCriteria
|
||||
{
|
||||
Page = 1,
|
||||
PageSize = 1000
|
||||
};
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
PagedResult<Collaborateur> result = await repository.SearchDetailsAsync(criteria, cts.Token);
|
||||
|
||||
sw.Stop();
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("--- Résultat ---");
|
||||
Console.WriteLine($"collaborateurs chargés (page) = {result.Items.Count}");
|
||||
Console.WriteLine($"total collaborateurs (SearchDetails) = {result.TotalCount}");
|
||||
Console.WriteLine($"requêtes HFSQL exécutées = {counting.QueryCount}");
|
||||
Console.WriteLine($"elapsed = {sw.ElapsedMilliseconds} ms");
|
||||
|
||||
// Vérification anti-N+1 : le nombre de requêtes doit être borné et très
|
||||
// inférieur au nombre de collaborateurs. Avec l'ancien pattern N+1, on aurait
|
||||
// observé ~1 requête par collaborateur (timeout HTTP 30s).
|
||||
if (result.TotalCount > 0 && counting.QueryCount >= result.TotalCount)
|
||||
{
|
||||
Console.WriteLine("ATTENTION : le nombre de requêtes est >= au nombre de collaborateurs (pattern N+1 suspect).");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("OK : chargement bulk (requêtes << collaborateurs), aucun GetById par collaborateur.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
Console.WriteLine($"ERREUR: {ex.Message}");
|
||||
Console.WriteLine($"elapsed = {sw.ElapsedMilliseconds} ms");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Probe Collaborateurs Snapshot Bulk terminé (lecture seule).");
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper IHfsqlConnection qui délègue à la connexion réelle et compte les
|
||||
/// requêtes de lecture (QueryAsync). Lecture seule : aucune écriture.
|
||||
/// </summary>
|
||||
internal sealed class CountingHfsqlConnection : IHfsqlConnection
|
||||
{
|
||||
private readonly IHfsqlConnection _inner;
|
||||
public int QueryCount { get; private set; }
|
||||
|
||||
public CountingHfsqlConnection(IHfsqlConnection inner) => _inner = inner;
|
||||
|
||||
public HfsqlOptions Options => _inner.Options;
|
||||
public HfsqlConnectionState State => _inner.State;
|
||||
|
||||
public Task OpenAsync(CancellationToken ct = default) => _inner.OpenAsync(ct);
|
||||
|
||||
public Task CloseAsync() => _inner.CloseAsync();
|
||||
|
||||
|
||||
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
|
||||
=> _inner.ExecuteScalarAsync(sql, parameters, ct);
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
|
||||
=> _inner.ExecuteNonQueryAsync(sql, parameters, ct);
|
||||
|
||||
public async Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
|
||||
{
|
||||
QueryCount++;
|
||||
return await _inner.QueryAsync(sql, parameters, ct);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken ct = default)
|
||||
=> _inner.GetColumnsAsync(tableName, ct);
|
||||
|
||||
public ValueTask DisposeAsync() => _inner.DisposeAsync();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Data.OleDb" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,65 @@
|
||||
// Probe pour compter les Interventions E2E_TEST dans BATIK_TEST.
|
||||
// Vérifie qu'une seule Intervention correspond à l'opération E2E.
|
||||
using System.Data.OleDb;
|
||||
|
||||
const string server = "127.0.0.1";
|
||||
const int port = 4900;
|
||||
const string user = "susana";
|
||||
const string password = "123";
|
||||
|
||||
string BuildConnStr(string database) =>
|
||||
$"Provider=PCSOFT.HFSQL;Data Source={server}:{port};Initial Catalog={database};User ID={user};Password={password}";
|
||||
|
||||
Console.WriteLine("=== Comptage Interventions E2E_TEST dans BATIK_TEST ===");
|
||||
|
||||
try
|
||||
{
|
||||
using var conn = new OleDbConnection(BuildConnStr("BATIK_TEST"));
|
||||
conn.Open();
|
||||
|
||||
// Compter toutes les Interventions E2E_TEST.
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM Intervention WHERE IN_RefInterne LIKE 'E2E_TEST%'";
|
||||
var count = cmd.ExecuteScalar();
|
||||
Console.WriteLine($" Interventions E2E_TEST (IN_RefInterne LIKE 'E2E_TEST%'): {count}");
|
||||
}
|
||||
|
||||
// Lister les Interventions E2E_TEST avec leurs détails.
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT IN_PKID, IN_RefInterne, IN_Titre, IN_Description FROM Intervention WHERE IN_RefInterne LIKE 'E2E_TEST%'";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
Console.WriteLine("\n Détails des Interventions E2E_TEST:");
|
||||
while (reader.Read())
|
||||
{
|
||||
Console.WriteLine($" IN_PKID={reader["IN_PKID"]}, Ref={reader["IN_RefInterne"]}, Titre={reader["IN_Titre"]}, Desc={reader["IN_Description"]}");
|
||||
}
|
||||
}
|
||||
|
||||
// Compter les MobLog pour l'opération Replay (operationId 0fce297f dans ML_Message JSON).
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM MobLog WHERE ML_Message LIKE '%0fce297f%'";
|
||||
var count = cmd.ExecuteScalar();
|
||||
Console.WriteLine($"\n MobLog pour operationId 0fce297f (Replay): {count}");
|
||||
}
|
||||
|
||||
// Lister les MobLog pour l'opération Replay.
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT ML_PKID, ML_DateCreat, ML_Message, ML_Niveau FROM MobLog WHERE ML_Message LIKE '%0fce297f%'";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
Console.WriteLine("\n Détails MobLog Replay:");
|
||||
while (reader.Read())
|
||||
{
|
||||
Console.WriteLine($" ML_PKID={reader["ML_PKID"]}, Date={reader["ML_DateCreat"]}, Niveau={reader["ML_Niveau"]}, Msg={reader["ML_Message"]}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($" ERREUR: {ex.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine("\nProbe comptage terminé.");
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>Omika.DiagnosticHashTool</AssemblyName>
|
||||
<RootNamespace>Omika.DiagnosticHashTool</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
Outil hors-ligne de génération de hash PBKDF2-SHA256 pour le COMPTE BOOTSTRAP uniquement
|
||||
(T23, §39). N'est PAS un endpoint Web anonyme. Utilisé sur une machine admin pour préparer
|
||||
la valeur DiagnosticsAuth:Operators[].PasswordHash du premier déploiement.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Omika.Diagnostics.Core\Omika.Diagnostics.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,45 @@
|
||||
using Omika.Diagnostics.Security;
|
||||
|
||||
// ============================================================
|
||||
// Omika.DiagnosticHashTool — génération HORS LIGNE d'un hash
|
||||
// PBKDF2-SHA256 pour le COMPTE BOOTSTRAP Diagnostics.Web (T23 §39).
|
||||
//
|
||||
// Usage :
|
||||
// Omika.DiagnosticHashTool "<mot-de-passe-bootstrap>"
|
||||
// Omika.DiagnosticHashTool --stdin (lit une ligne sur stdin)
|
||||
//
|
||||
// ⚠️ Réservé au bootstrap du PREMIER admin Production. Le mot de passe ne
|
||||
// doit jamais être placé dans Git / le rapport / appsettings versionné.
|
||||
// Seul le HASH est ensuite mis dans la configuration / le secret store.
|
||||
// Préférez --stdin pour éviter de laisser le mot de passe dans
|
||||
// l'historique du shell.
|
||||
// ============================================================
|
||||
|
||||
string? password;
|
||||
|
||||
if (args.Length == 0)
|
||||
{
|
||||
Console.Error.WriteLine("Usage: Omika.DiagnosticHashTool \"<mot-de-passe>\" | --stdin");
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (args[0] == "--stdin")
|
||||
{
|
||||
password = Console.ReadLine();
|
||||
}
|
||||
else
|
||||
{
|
||||
password = args[0];
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
Console.Error.WriteLine("Mot de passe vide refusé.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
string hash = DiagnosticPasswordHasher.Hash(password);
|
||||
|
||||
// Affichage du HASH uniquement (jamais le mot de passe).
|
||||
Console.WriteLine(hash);
|
||||
return 0;
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,108 @@
|
||||
// Probe LECTURE SEULE de l'accès aux fichiers HFSQL nécessaires au CREATE
|
||||
// Intervention E2E, via BusinessOleDbConnection + HfSqlFileAccessResolver.
|
||||
// Aucune écriture. Aucun secret affiché.
|
||||
using System.Text.Json;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.HfSql.Connection;
|
||||
|
||||
const string ConfigPath = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
|
||||
|
||||
if (!File.Exists(ConfigPath))
|
||||
{
|
||||
Console.WriteLine("CONFIG ABSENTE : " + ConfigPath);
|
||||
return;
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(ConfigPath);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// --- Charger la section Hfsql (BATIK_TEST) ---
|
||||
if (!root.TryGetProperty("Hfsql", out var hfsql))
|
||||
{
|
||||
Console.WriteLine("SECTION Hfsql ABSENTE dans la config.");
|
||||
return;
|
||||
}
|
||||
|
||||
var options = new HfsqlOptions
|
||||
{
|
||||
ConnectionName = GetString(hfsql, "ConnectionName") ?? "Business",
|
||||
Server = GetString(hfsql, "Server"),
|
||||
Port = GetInt(hfsql, "Port") ?? 4900,
|
||||
DatabaseName = GetString(hfsql, "DatabaseName"),
|
||||
Login = GetString(hfsql, "Login"),
|
||||
Password = GetString(hfsql, "Password"),
|
||||
Driver = GetString(hfsql, "Driver") ?? "HFSQL Client/Server",
|
||||
ConnectionTimeoutSeconds = GetInt(hfsql, "ConnectionTimeoutSeconds") ?? 30
|
||||
};
|
||||
|
||||
// --- FilePasswords (ProtectionStd / ProtectionPlus) ---
|
||||
if (hfsql.TryGetProperty("FilePasswords", out var fp))
|
||||
{
|
||||
options.FilePasswords = new HfSqlFilePasswordOptions
|
||||
{
|
||||
ProtectionStd = GetString(fp, "ProtectionStd"),
|
||||
ProtectionPlus = GetString(fp, "ProtectionPlus")
|
||||
};
|
||||
}
|
||||
|
||||
bool protectionStdConfigured = !string.IsNullOrWhiteSpace(options.FilePasswords.ProtectionStd);
|
||||
Console.WriteLine($"ProtectionStd configured : {(protectionStdConfigured ? "YES" : "NO")}");
|
||||
Console.WriteLine($"Database : {options.DatabaseName}");
|
||||
Console.WriteLine($"Server : {options.Server}:{options.Port}");
|
||||
Console.WriteLine();
|
||||
|
||||
// --- Construire les options OLE DB ---
|
||||
var oleDbOptions = HfSqlConnectionOptionsFactory.Create(options, options.FilePasswords);
|
||||
var factory = new HfSqlOleDbConnectionFactory();
|
||||
var resolver = new HfSqlFileAccessResolver(factory, oleDbOptions);
|
||||
|
||||
// --- Fichiers nécessaires au CREATE Intervention Batik ---
|
||||
var files = new List<HfSqlFileAccessProfile>
|
||||
{
|
||||
new() { LogicalFile = "Intervention", ExpectedGroup = "ProtectionStd" },
|
||||
new() { LogicalFile = "ParametreMetier", ExpectedGroup = "ProtectionStd" },
|
||||
new() { LogicalFile = "Intervention_Collaborateur", ExpectedGroup = "ProtectionStd" },
|
||||
new() { LogicalFile = "Intervention_Personne", ExpectedGroup = "ProtectionStd" },
|
||||
new() { LogicalFile = "EtatIntervention", ExpectedGroup = "ProtectionStd" }
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var result = await resolver.ResolveAsync(files);
|
||||
|
||||
Console.WriteLine("=== Résolution d'accès (lecture seule) ===");
|
||||
bool allOk = true;
|
||||
foreach (var file in files)
|
||||
{
|
||||
var profile = result[file.LogicalFile];
|
||||
string status = profile.ObservedAccess switch
|
||||
{
|
||||
HfSqlFileAccessMode.Direct => "DIRECT",
|
||||
HfSqlFileAccessMode.ProtectionStd => "PROTECTION_STD",
|
||||
HfSqlFileAccessMode.ProtectionPlus => "PROTECTION_PLUS",
|
||||
HfSqlFileAccessMode.Failed => "FAILED",
|
||||
_ => profile.ObservedAccess.ToString()
|
||||
};
|
||||
string error = profile.HfSqlErrorCode is null ? "" : $" (HFSQL code {profile.HfSqlErrorCode})";
|
||||
Console.WriteLine($" {file.LogicalFile,-30} : {status}{error}");
|
||||
if (profile.ObservedAccess == HfSqlFileAccessMode.Failed)
|
||||
allOk = false;
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(allOk
|
||||
? "RESULTAT : TOUS LES FICHIERS ACCESSIBLES"
|
||||
: "RESULTAT : AU MOINS UN FICHIER INACCESSIBLE");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"ERREUR GLOBALE : {ex.Message}");
|
||||
Console.WriteLine($" Type : {ex.GetType().FullName}");
|
||||
}
|
||||
|
||||
static string? GetString(JsonElement el, string name)
|
||||
=> el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||
|
||||
static int? GetInt(JsonElement el, string name)
|
||||
=> el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number ? v.GetInt32() : null;
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>Omika.FilePasswordProbe</AssemblyName>
|
||||
<RootNamespace>Omika.FilePasswordProbe</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="System.Data.OleDb" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,140 @@
|
||||
// Probe LECTURE SEULE ciblé : reproduit EXACTEMENT le mécanisme de connexion de l'API
|
||||
// (BusinessOleDbConnection -> HfSqlOleDbConnectionFactory -> BuildExtendedPropertiesFromFilePasswords)
|
||||
// pour tester l'accès aux tables Client, Lieu, ParametreMetier sur BATIK_TEST.
|
||||
//
|
||||
// Aucune écriture. Aucun password affiché. Aucun hash/dérivation.
|
||||
// Les seules valeurs utilisées sont celles de config/hfsql-test.local.json (Hfsql.FilePasswords).
|
||||
using System.Data.Common;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.HfSql.Connection;
|
||||
using Omika.Data.HfSql.Metadata;
|
||||
|
||||
|
||||
// --- 1. Charger la configuration Hfsql (avec FilePasswords) ---
|
||||
// Chemin absolu vers config/hfsql-test.local.json à la racine du dépôt.
|
||||
// Le probe est lancé depuis la racine du dépôt (D:\Avalonia\Omika).
|
||||
var configPath = Path.GetFullPath(Path.Combine(
|
||||
Directory.GetCurrentDirectory(),
|
||||
"config", "hfsql-test.local.json"));
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddJsonFile(configPath, optional: false, reloadOnChange: false)
|
||||
.Build();
|
||||
|
||||
|
||||
|
||||
var hfsqlOptions = new HfsqlOptions();
|
||||
config.GetSection(HfsqlOptions.SectionName).Bind(hfsqlOptions);
|
||||
|
||||
bool protectionStdConfigured = !string.IsNullOrWhiteSpace(hfsqlOptions.FilePasswords.ProtectionStd);
|
||||
bool protectionPlusConfigured = !string.IsNullOrWhiteSpace(hfsqlOptions.FilePasswords.ProtectionPlus);
|
||||
|
||||
Console.WriteLine($"=== Probe FilePassword (base {hfsqlOptions.DatabaseName}) ===");
|
||||
Console.WriteLine($"ProtectionStd configurée : {(protectionStdConfigured ? "OUI" : "NON")}");
|
||||
Console.WriteLine($"ProtectionPlus configurée : {(protectionPlusConfigured ? "OUI" : "NON")}");
|
||||
Console.WriteLine();
|
||||
|
||||
// --- 2. Reproduire le mécanisme de l'API : BusinessOleDbConnection ---
|
||||
var factory = new HfSqlOleDbConnectionFactory();
|
||||
await using var business = new BusinessOleDbConnection(hfsqlOptions, factory);
|
||||
|
||||
// --- 3. Tables à tester : nom logique, groupe attendu (modèles physiques), colonne PK ---
|
||||
var tables = new[]
|
||||
{
|
||||
new { Logical = "Client", Group = "ProtectionStd", Column = "CL_PKID" },
|
||||
new { Logical = "Lieu", Group = "ProtectionStd", Column = "LI_PKID" },
|
||||
new { Logical = "ParametreMetier", Group = "ProtectionStd", Column = "PA_PKID" },
|
||||
};
|
||||
|
||||
// --- 4. Instrumenter la fabrique pour savoir quels fichiers reçoivent le mot de passe ---
|
||||
// La fabrique construit les Extended Properties via BuildExtendedPropertiesFromFilePasswords,
|
||||
// qui dérive désormais la liste des fichiers protégés depuis les modèles physiques
|
||||
// (attributs HfSqlTable avec Groups). On reproduit cette logique pour déterminer si
|
||||
// chaque table est couverte par le mot de passe.
|
||||
var coveredByFactory = GetFactoryCoveredFiles();
|
||||
|
||||
Console.WriteLine("--- Couverture des mots de passe par la fabrique (mécanisme API) ---");
|
||||
foreach (var t in tables)
|
||||
{
|
||||
bool covered = coveredByFactory.Contains(t.Logical, StringComparer.OrdinalIgnoreCase);
|
||||
Console.WriteLine($" {t.Logical,-18} groupe={t.Group,-14} password_applied_par_fabrique={(covered ? "OUI" : "NON")}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
|
||||
|
||||
// --- 5. Ouvrir la connexion (même mécanisme que l'API) et tester chaque table ---
|
||||
try
|
||||
{
|
||||
await business.OpenAsync();
|
||||
Console.WriteLine("Connexion OLE DB ouverte (mécanisme API).");
|
||||
Console.WriteLine();
|
||||
|
||||
foreach (var t in tables)
|
||||
{
|
||||
await ProbeTableAsync(business, t.Logical, t.Group, t.Column);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"ERREUR ouverture connexion: {ex.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Probe FilePassword terminé (lecture seule).");
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
static async Task ProbeTableAsync(
|
||||
IHfsqlConnection connection,
|
||||
string logical,
|
||||
string group,
|
||||
string column)
|
||||
{
|
||||
string sql = $"SELECT TOP 1 {column} FROM {logical}";
|
||||
Console.WriteLine($"--- {logical} (groupe={group}) ---");
|
||||
Console.WriteLine($" requête: {sql}");
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var rows = await connection.QueryAsync(sql, cancellationToken: cts.Token);
|
||||
Console.WriteLine($" résultat: OK (rows={rows.Count})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($" résultat: ERREUR");
|
||||
Console.WriteLine($" message: {ex.Message}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reproduit la logique de HfSqlOleDbConnectionFactory.BuildExtendedPropertiesFromFilePasswords
|
||||
/// (dérivée des modèles physiques via les attributs HfSqlTable avec Groups) pour déterminer
|
||||
/// quels fichiers logiques reçoivent un mot de passe via le mécanisme API.
|
||||
/// </summary>
|
||||
static HashSet<string> GetFactoryCoveredFiles()
|
||||
{
|
||||
var covered = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var type in typeof(HfSqlOleDbConnectionFactory).Assembly.GetTypes())
|
||||
{
|
||||
var attribute = type.GetCustomAttribute<HfSqlTableAttribute>();
|
||||
if (attribute is null)
|
||||
continue;
|
||||
|
||||
foreach (var group in attribute.Groups)
|
||||
{
|
||||
if (group.Equals("ProtectionStd", StringComparison.OrdinalIgnoreCase) ||
|
||||
group.Equals("ProtectionPlus", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
covered.Add(attribute.TableName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return covered;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Omika.Tests")]
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Omika.HfSqlProbe;
|
||||
|
||||
internal static partial class ConnectionStringRedactor
|
||||
{
|
||||
[GeneratedRegex("PWD=[^;]*", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex PwdPattern();
|
||||
|
||||
[GeneratedRegex("Password=[^;]*", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex PasswordPattern();
|
||||
|
||||
[GeneratedRegex("UID=[^;]*", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex UidPattern();
|
||||
|
||||
public static string Redact(string message)
|
||||
{
|
||||
message = PwdPattern().Replace(message, "PWD=***");
|
||||
message = PasswordPattern().Replace(message, "Password=***");
|
||||
message = UidPattern().Replace(message, "UID=***");
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Omika.HfSqlProbe;
|
||||
|
||||
/// <summary>
|
||||
/// Root configuration for local HFSQL test databases.
|
||||
/// Never serialize this object — it contains credentials.
|
||||
/// </summary>
|
||||
public sealed class HfSqlTestConfiguration
|
||||
{
|
||||
[JsonPropertyName("Server")]
|
||||
public string Server { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("Port")]
|
||||
public int Port { get; set; }
|
||||
|
||||
[JsonPropertyName("Batik")]
|
||||
public HfSqlTestDatabaseConfiguration Batik { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("Omika")]
|
||||
public HfSqlTestDatabaseConfiguration Omika { get; set; } = new();
|
||||
|
||||
public HfSqlTestDatabaseConfiguration Resolve(string databaseKey)
|
||||
{
|
||||
return databaseKey.ToLowerInvariant() switch
|
||||
{
|
||||
"batik" => Batik,
|
||||
"omika" => Omika,
|
||||
_ => throw new ArgumentException(
|
||||
$"Unknown database key '{databaseKey}'. Expected 'batik' or 'omika'.")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Omika.HfSqlProbe;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the local test configuration for a single HFSQL database.
|
||||
/// </summary>
|
||||
public sealed class HfSqlTestDatabaseConfiguration
|
||||
{
|
||||
[JsonPropertyName("Database")]
|
||||
public string Database { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("User")]
|
||||
public string User { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Server credential; never displayed.</summary>
|
||||
[JsonPropertyName("Credential")]
|
||||
public string Credential { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Original database name before cloning, if applicable.</summary>
|
||||
[JsonPropertyName("OriginalDatabaseName")]
|
||||
public string? OriginalDatabaseName { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace Omika.HfSqlProbe;
|
||||
|
||||
internal static class ProbeArgumentParser
|
||||
{
|
||||
public static ProbeOptions Parse(ReadOnlySpan<string> args)
|
||||
{
|
||||
var options = new ProbeOptions();
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
string arg = args[i];
|
||||
if (arg == "--connection-string" && i + 1 < args.Length)
|
||||
options.ConnectionString = args[++i];
|
||||
else if (arg == "--allow-write-probe")
|
||||
options.AllowWriteProbe = true;
|
||||
else if (arg == "--table" && i + 1 < args.Length)
|
||||
options.Table = args[++i];
|
||||
else if (arg == "--id-column" && i + 1 < args.Length)
|
||||
options.IdColumn = args[++i];
|
||||
else if (arg == "--marker-column" && i + 1 < args.Length)
|
||||
options.MarkerColumn = args[++i];
|
||||
else if (arg == "--schema" && i + 1 < args.Length)
|
||||
options.Schema = args[++i];
|
||||
else if (arg == "--database-name" && i + 1 < args.Length)
|
||||
options.DatabaseName = args[++i];
|
||||
else if (arg == "--use-client-table")
|
||||
options.UseClientTable = true;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
public static string NormalizeCommand(ReadOnlySpan<string> args)
|
||||
{
|
||||
if (args.Length == 0) return "";
|
||||
return args[0].ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static bool IsWriteCommand(string command)
|
||||
=> command is "transaction" or "returning" or "client-insert" or "client-graph";
|
||||
|
||||
public static bool IsValidCommand(string command)
|
||||
=> command is "capabilities" or "audit" or "transaction" or "returning" or "omika-guid"
|
||||
or "client-insert" or "client-graph" or "table-exists" or "protected-access" or "--help";
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Omika.HfSqlProbe;
|
||||
|
||||
internal sealed class ProbeOptions
|
||||
{
|
||||
public string? ConnectionString { get; set; }
|
||||
public bool AllowWriteProbe { get; set; }
|
||||
public string? Table { get; set; }
|
||||
public string? IdColumn { get; set; }
|
||||
public string? MarkerColumn { get; set; }
|
||||
public string? Schema { get; set; }
|
||||
public string? DatabaseName { get; set; }
|
||||
public bool UseClientTable { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace Omika.HfSqlProbe;
|
||||
|
||||
internal static class ProbeSqlBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds INSERT ... RETURNING <idColumn>.
|
||||
/// Defensively validates all identifiers before interpolation.
|
||||
/// </summary>
|
||||
public static string BuildInsertReturning(string table, string idColumn, string markerColumn)
|
||||
{
|
||||
SqlIdentifierValidator.Validate(table);
|
||||
SqlIdentifierValidator.Validate(idColumn);
|
||||
SqlIdentifierValidator.Validate(markerColumn);
|
||||
return $"INSERT INTO {table} ({markerColumn}) VALUES (?) RETURNING {idColumn}";
|
||||
}
|
||||
|
||||
/// <summary>Count rows matching a marker value.</summary>
|
||||
public static string BuildCountByMarker(string table, string markerColumn)
|
||||
{
|
||||
SqlIdentifierValidator.Validate(table);
|
||||
SqlIdentifierValidator.Validate(markerColumn);
|
||||
return $"SELECT COUNT(*) FROM {table} WHERE {markerColumn} = ?";
|
||||
}
|
||||
|
||||
/// <summary>Insert a marker row (no RETURNING).</summary>
|
||||
public static string BuildInsertMarker(string table, string markerColumn)
|
||||
{
|
||||
SqlIdentifierValidator.Validate(table);
|
||||
SqlIdentifierValidator.Validate(markerColumn);
|
||||
return $"INSERT INTO {table} ({markerColumn}) VALUES (?)";
|
||||
}
|
||||
|
||||
/// <summary>Read GUID samples.</summary>
|
||||
public static string BuildSelectGuids(int limit)
|
||||
{
|
||||
return "SELECT GUID FROM Client WHERE GUID IS NOT NULL AND GUID <> '' " +
|
||||
$"LIMIT {limit}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
using System.Data;
|
||||
using System.Data.Odbc;
|
||||
|
||||
namespace Omika.HfSqlProbe;
|
||||
|
||||
public static class Program
|
||||
{
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
if (args.Length == 0) { PrintUsage(); return 2; }
|
||||
|
||||
string command = ProbeArgumentParser.NormalizeCommand(args);
|
||||
if (!ProbeArgumentParser.IsValidCommand(command)) { Console.Error.WriteLine($"ERROR: unknown command '{command}'."); PrintUsage(); return 2; }
|
||||
|
||||
if (command == "--help") { PrintUsage(); return 0; }
|
||||
|
||||
var options = ProbeArgumentParser.Parse(args.AsSpan(1));
|
||||
if (options.ConnectionString is null) { Console.Error.WriteLine("ERROR: --connection-string is required."); return 2; }
|
||||
|
||||
if (command == "capabilities") return RunCapabilities(options);
|
||||
if (command == "audit") return RunAudit(options);
|
||||
if (command == "table-exists") return RunTableExists(options);
|
||||
if (command == "protected-access") return RunProtectedAccess(options);
|
||||
|
||||
if (ProbeArgumentParser.IsWriteCommand(command))
|
||||
{
|
||||
if (!options.AllowWriteProbe) { Console.Error.WriteLine("ERROR: This probe performs temporary writes."); Console.Error.WriteLine(" It must be run ONLY against a clone/test database."); Console.Error.WriteLine(" Add --allow-write-probe to confirm you understand this."); return 2; }
|
||||
Console.WriteLine("===================================");
|
||||
Console.WriteLine(" HFSQL ODBC WRITE PROBE");
|
||||
Console.WriteLine("===================================");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("\u26a0 This probe performs TEMPORARY writes.");
|
||||
Console.WriteLine(" It MUST run on a clone/test database ONLY.");
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
if (command == "transaction") return RunTransactionProbe(options);
|
||||
if (command == "returning") return RunReturningProbe(options);
|
||||
if (command == "client-insert") return RunClientInsert(options);
|
||||
if (command == "client-graph") return RunClientGraph(options);
|
||||
if (command == "omika-guid") return RunOmikaGuidProbe(options);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int RunCapabilities(ProbeOptions options)
|
||||
{
|
||||
Console.WriteLine("HFSQL ODBC CAPABILITY PROBE");
|
||||
Console.WriteLine("===========================");
|
||||
Console.WriteLine();
|
||||
try
|
||||
{
|
||||
using var connection = new OdbcConnection(options.ConnectionString);
|
||||
connection.Open();
|
||||
Console.WriteLine("Connection: OPEN");
|
||||
Console.WriteLine($" Driver: {MaskConnectionProperty(connection.Driver)}");
|
||||
Console.WriteLine($" DataSource: {MaskConnectionProperty(connection.DataSource)}");
|
||||
Console.WriteLine($" ServerVersion: {connection.ServerVersion}");
|
||||
Console.WriteLine($" Database property: {(string.IsNullOrEmpty(connection.Database) ? "(empty / not exposed by driver)" : MaskConnectionProperty(connection.Database))}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("CONNECTION: PASS");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("── ODBC Metadata ──");
|
||||
try
|
||||
{
|
||||
var tables = connection.GetSchema("Tables");
|
||||
Console.WriteLine($" ODBC metadata: SUPPORTED");
|
||||
Console.WriteLine($" Tables: {tables.Rows.Count}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($" ODBC metadata: UNSUPPORTED ({ex.GetType().Name})");
|
||||
}
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("── Driver info ──");
|
||||
using (var tx = connection.BeginTransaction()) { tx.Rollback(); Console.WriteLine(" BeginTransaction + Rollback: OK (API level)"); }
|
||||
return 0;
|
||||
}
|
||||
catch (OdbcException ex) { Console.Error.WriteLine($"CONNECTION FAILED: {ConnectionStringRedactor.Redact(ex.Message)}"); return 3; }
|
||||
catch (Exception ex) { Console.Error.WriteLine($"CONNECTION FAILED: {ConnectionStringRedactor.Redact(ex.Message)}"); return 3; }
|
||||
}
|
||||
|
||||
static int RunTransactionProbe(ProbeOptions options)
|
||||
{
|
||||
if (options.Table is null) { Console.Error.WriteLine("ERROR: --table is required for transaction probe."); return 2; }
|
||||
int code = ValidateIdentifiers(options.Table, options.IdColumn ?? "PKID", options.MarkerColumn ?? "Marker");
|
||||
if (code != 0) return code;
|
||||
|
||||
string marker = $"PROBE_TX_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}";
|
||||
string table = options.Table, markerCol = options.MarkerColumn ?? "Marker";
|
||||
Console.WriteLine($"Table: {table}");
|
||||
Console.WriteLine($"Marker: {marker}");
|
||||
Console.WriteLine();
|
||||
|
||||
try
|
||||
{
|
||||
using var connection = new OdbcConnection(options.ConnectionString);
|
||||
connection.Open();
|
||||
using var tx = connection.BeginTransaction();
|
||||
Console.WriteLine("BEGIN TRANSACTION: OK");
|
||||
|
||||
using (var insertCmd = connection.CreateCommand()) { insertCmd.Transaction = tx; insertCmd.CommandText = ProbeSqlBuilder.BuildInsertMarker(table, markerCol); insertCmd.Parameters.Add(new OdbcParameter { Value = marker }); Console.WriteLine($"INSERT: OK ({insertCmd.ExecuteNonQuery()} row(s))"); }
|
||||
|
||||
using (var selCmd = connection.CreateCommand()) { selCmd.Transaction = tx; selCmd.CommandText = ProbeSqlBuilder.BuildCountByMarker(table, markerCol); selCmd.Parameters.Add(new OdbcParameter { Value = marker }); Console.WriteLine($"VISIBLE BEFORE ROLLBACK: {(Convert.ToInt32(selCmd.ExecuteScalar()) > 0 ? "YES" : "NO")}"); }
|
||||
|
||||
tx.Rollback();
|
||||
Console.WriteLine("ROLLBACK: OK");
|
||||
|
||||
using (var selCmd2 = connection.CreateCommand()) { selCmd2.CommandText = ProbeSqlBuilder.BuildCountByMarker(table, markerCol); selCmd2.Parameters.Add(new OdbcParameter { Value = marker }); Console.WriteLine($"ABSENT AFTER ROLLBACK: {(Convert.ToInt32(selCmd2.ExecuteScalar()) == 0 ? "YES" : "NO")}"); }
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (OdbcException ex) { Console.Error.WriteLine($"FAILED: {ConnectionStringRedactor.Redact(ex.Message)}"); return 1; }
|
||||
}
|
||||
|
||||
static int RunReturningProbe(ProbeOptions options)
|
||||
{
|
||||
if (options.Table is null) { Console.Error.WriteLine("ERROR: --table is required for returning probe."); return 2; }
|
||||
int code = ValidateIdentifiers(options.Table, options.IdColumn ?? "PKID", options.MarkerColumn ?? "Marker");
|
||||
if (code != 0) return code;
|
||||
|
||||
string marker = $"PROBE_RET_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}";
|
||||
string table = options.Table, idCol = options.IdColumn ?? "PKID", markerCol = options.MarkerColumn ?? "Marker";
|
||||
Console.WriteLine($"Table: {table}");
|
||||
Console.WriteLine($"ID col: {idCol}");
|
||||
Console.WriteLine($"Marker: {marker}");
|
||||
Console.WriteLine();
|
||||
|
||||
try
|
||||
{
|
||||
using var connection = new OdbcConnection(options.ConnectionString);
|
||||
connection.Open();
|
||||
|
||||
Console.WriteLine("-- METHOD A: ExecuteScalar --");
|
||||
try
|
||||
{
|
||||
using var txA = connection.BeginTransaction();
|
||||
using var cmdA = connection.CreateCommand();
|
||||
cmdA.Transaction = txA;
|
||||
cmdA.CommandText = ProbeSqlBuilder.BuildInsertReturning(table, idCol, markerCol);
|
||||
cmdA.Parameters.Add(new OdbcParameter { Value = marker });
|
||||
object? scalar = cmdA.ExecuteScalar();
|
||||
Console.WriteLine($" Accepted: YES");
|
||||
Console.WriteLine($" CLR type: {scalar?.GetType().Name ?? "null"}");
|
||||
Console.WriteLine($" Raw value: {scalar}");
|
||||
if (scalar is long l) Console.WriteLine($" Int64: {l} ({(l > 0 ? "VALID" : "ZERO/INVALID")})");
|
||||
else if (scalar is not null) { try { Console.WriteLine($" Convert.ToInt64: {Convert.ToInt64(scalar)}"); } catch { Console.WriteLine(" Convert.ToInt64: FAILED"); } }
|
||||
txA.Rollback();
|
||||
}
|
||||
catch (OdbcException ex) { Console.WriteLine($" ERROR: {ConnectionStringRedactor.Redact(ex.Message)}"); }
|
||||
Console.WriteLine();
|
||||
|
||||
Console.WriteLine("-- METHOD B: ExecuteReader --");
|
||||
try
|
||||
{
|
||||
using var txB = connection.BeginTransaction();
|
||||
using var cmdB = connection.CreateCommand();
|
||||
cmdB.Transaction = txB;
|
||||
cmdB.CommandText = ProbeSqlBuilder.BuildInsertReturning(table, idCol, markerCol);
|
||||
cmdB.Parameters.Add(new OdbcParameter { Value = marker });
|
||||
using var reader = cmdB.ExecuteReader();
|
||||
Console.WriteLine($" HasRows: {reader.HasRows}");
|
||||
Console.WriteLine($" FieldCount: {reader.FieldCount}");
|
||||
if (reader.Read()) { for (int f = 0; f < reader.FieldCount; f++) Console.WriteLine($" [{f}] {reader.GetName(f)} ({reader.GetDataTypeName(f)}): {reader.GetValue(f)}"); }
|
||||
reader.Close();
|
||||
txB.Rollback();
|
||||
}
|
||||
catch (OdbcException ex) { Console.WriteLine($" ERROR: {ConnectionStringRedactor.Redact(ex.Message)}"); }
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("-- VERIFY ROLLBACK --");
|
||||
using (var verifyCmd = connection.CreateCommand()) { verifyCmd.CommandText = ProbeSqlBuilder.BuildCountByMarker(table, markerCol); verifyCmd.Parameters.Add(new OdbcParameter { Value = marker }); Console.WriteLine($" Rows with marker: {Convert.ToInt32(verifyCmd.ExecuteScalar())}"); }
|
||||
return 0;
|
||||
}
|
||||
catch (OdbcException ex) { Console.Error.WriteLine($"FAILED: {ConnectionStringRedactor.Redact(ex.Message)}"); return 1; }
|
||||
}
|
||||
|
||||
static string DeriveProtectionStdPassword(string databaseName)
|
||||
{
|
||||
byte[] hash = System.Security.Cryptography.MD5.HashData(
|
||||
System.Text.Encoding.UTF8.GetBytes(databaseName));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
static int RunProtectedAccess(ProbeOptions options)
|
||||
{
|
||||
if (options.Table is null) { Console.Error.WriteLine("ERROR: --table is required."); return 2; }
|
||||
string table = SqlIdentifierValidator.Validate(options.Table);
|
||||
if (string.IsNullOrEmpty(options.DatabaseName)) { Console.Error.WriteLine("ERROR: --database-name is required."); return 2; }
|
||||
string dbName = options.DatabaseName;
|
||||
|
||||
Console.WriteLine($"Table: {table}");
|
||||
Console.WriteLine($"Database: {dbName}");
|
||||
Console.WriteLine();
|
||||
|
||||
try
|
||||
{
|
||||
using var connection = new OdbcConnection(options.ConnectionString);
|
||||
connection.Open();
|
||||
Console.WriteLine("Connection: OPEN");
|
||||
|
||||
// Step A: try normal access
|
||||
Console.WriteLine("-- Normal access --");
|
||||
bool directPassed = false;
|
||||
try
|
||||
{
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = $"SELECT COUNT(*) FROM {table}";
|
||||
int count = Convert.ToInt32(cmd.ExecuteScalar());
|
||||
Console.WriteLine($" ACCESS = UNPROTECTED (rows: {count})");
|
||||
return 0;
|
||||
}
|
||||
catch (OdbcException ex)
|
||||
{
|
||||
string msg = ex.Message;
|
||||
Console.WriteLine($" ERROR: {ConnectionStringRedactor.Redact(msg)}");
|
||||
Console.WriteLine($" SQLState: {ex.Errors[0]?.SQLState}, NativeError: {ex.Errors[0]?.NativeError}");
|
||||
Console.WriteLine(" Classification: DIRECT ACCESS FAILED — attempting OPEN FILE fallback");
|
||||
}
|
||||
|
||||
// Step B: password fallback — PC SOFT official ODBC syntax
|
||||
Console.WriteLine("-- ProtectionStd fallback (OPEN FILE USING) --");
|
||||
string derived = DeriveProtectionStdPassword(dbName);
|
||||
// Validate derived password is 32-char hex before any use
|
||||
if (!System.Text.RegularExpressions.Regex.IsMatch(derived, @"^[0-9a-f]{32}$"))
|
||||
{
|
||||
Console.Error.WriteLine(" FATAL: Derived password failed validation.");
|
||||
return 1;
|
||||
}
|
||||
Console.WriteLine($" Password derived: YES (32 hex chars)");
|
||||
Console.WriteLine($" Password value: [NEVER DISPLAYED]");
|
||||
|
||||
// Execute OPEN FILE USING on the same connection — Client/Server form
|
||||
try
|
||||
{
|
||||
using var openCmd = connection.CreateCommand();
|
||||
// Extract connection properties for Client/Server OPEN FILE
|
||||
string csDb = connection.Database;
|
||||
if (string.IsNullOrEmpty(csDb)) csDb = dbName;
|
||||
string csServer = connection.DataSource;
|
||||
// Build Client/Server explicit OPEN FILE (sanitized form for reporting)
|
||||
string openCmdText = $"OPEN FILE {table} SERVER '{csServer}' DATABASE '{csDb}' USER '<supplied>' USING '<redacted>'";
|
||||
Console.WriteLine($" {openCmdText}");
|
||||
openCmd.CommandText = $"OPEN FILE {table} SERVER '{csServer}' DATABASE '{csDb}' USER 'susana' USING '{derived}'";
|
||||
openCmd.ExecuteNonQuery();
|
||||
Console.WriteLine(" OPEN FILE (C/S): ACCEPTED");
|
||||
|
||||
// Retry SELECT
|
||||
using var retryCmd = connection.CreateCommand();
|
||||
retryCmd.CommandText = $"SELECT COUNT(*) FROM {table}";
|
||||
int retryCount = Convert.ToInt32(retryCmd.ExecuteScalar());
|
||||
Console.WriteLine($" SELECT after OPEN: PASS (rows: {retryCount})");
|
||||
Console.WriteLine($" ACCESS = PROTECTED — ProtectionStd VERIFIED");
|
||||
return 0;
|
||||
}
|
||||
catch (OdbcException ex2)
|
||||
{
|
||||
Console.WriteLine($" OPEN FILE (C/S) error: {ConnectionStringRedactor.Redact(ex2.Message)}");
|
||||
Console.WriteLine($" SQLState: {ex2.Errors[0]?.SQLState}, NativeError: {ex2.Errors[0]?.NativeError}");
|
||||
Console.WriteLine(" Classification: OPEN FILE USING FAILED");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
catch (OdbcException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"FAILED: {ConnectionStringRedactor.Redact(ex.Message)}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
static int RunTableExists(ProbeOptions options)
|
||||
{
|
||||
if (options.Table is null) { Console.Error.WriteLine("ERROR: --table is required."); return 2; }
|
||||
string table = SqlIdentifierValidator.Validate(options.Table);
|
||||
Console.WriteLine($"Checking: {table}");
|
||||
try
|
||||
{
|
||||
using var connection = new OdbcConnection(options.ConnectionString);
|
||||
connection.Open();
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.CommandText = $"SELECT COUNT(*) FROM {table}";
|
||||
int count = Convert.ToInt32(cmd.ExecuteScalar());
|
||||
Console.WriteLine($" EXISTS: YES (rows: {count})");
|
||||
|
||||
// Also try GetSchema
|
||||
try
|
||||
{
|
||||
var cols = connection.GetSchema("Columns", [null, null, table, null]);
|
||||
Console.WriteLine($" GetSchema Columns: {cols.Rows.Count} columns");
|
||||
foreach (System.Data.DataRow row in cols.Rows)
|
||||
{
|
||||
string col = row["COLUMN_NAME"]?.ToString() ?? "?";
|
||||
string type = row["TYPE_NAME"]?.ToString() ?? "?";
|
||||
int size = row["COLUMN_SIZE"] is int s ? s : 0;
|
||||
bool nullable = row["IS_NULLABLE"]?.ToString() == "YES";
|
||||
Console.WriteLine($" {col,-25} {type,-10} size={size,4} nullable={nullable}");
|
||||
}
|
||||
}
|
||||
catch { Console.WriteLine(" GetSchema Columns: FAILED"); }
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (OdbcException ex) { Console.Error.WriteLine($" DOES NOT EXIST or error: {ConnectionStringRedactor.Redact(ex.Message)}"); return 1; }
|
||||
}
|
||||
|
||||
static int RunAudit(ProbeOptions options)
|
||||
{
|
||||
Console.WriteLine("HFSQL ODBC PHYSICAL AUDIT");
|
||||
Console.WriteLine("========================");
|
||||
Console.WriteLine();
|
||||
try
|
||||
{
|
||||
using var connection = new OdbcConnection(options.ConnectionString);
|
||||
connection.Open();
|
||||
Console.WriteLine("Connected.");
|
||||
Console.WriteLine();
|
||||
|
||||
// ── MetaDataCollections ──
|
||||
Console.WriteLine("── ODBC MetaDataCollections ──");
|
||||
try
|
||||
{
|
||||
var md = connection.GetSchema("MetaDataCollections");
|
||||
foreach (System.Data.DataRow row in md.Rows)
|
||||
Console.WriteLine($" {row["CollectionName"]}: {row["NumberOfRestrictions"]} restrictions");
|
||||
}
|
||||
catch (Exception ex) { Console.WriteLine($" ERROR: {ex.Message}"); }
|
||||
Console.WriteLine();
|
||||
|
||||
// ── Tables ──
|
||||
Console.WriteLine("── Tables ──");
|
||||
var tables = connection.GetSchema("Tables");
|
||||
var tableNames = new List<string>();
|
||||
foreach (System.Data.DataRow row in tables.Rows)
|
||||
{
|
||||
string? name = row["TABLE_NAME"]?.ToString();
|
||||
if (!string.IsNullOrEmpty(name)) tableNames.Add(name);
|
||||
}
|
||||
tableNames.Sort();
|
||||
int i = 0;
|
||||
foreach (var t in tableNames)
|
||||
Console.WriteLine($" [{++i:D2}] {t}");
|
||||
Console.WriteLine($" Total: {tableNames.Count}");
|
||||
Console.WriteLine();
|
||||
|
||||
// ── Columns for key tables ──
|
||||
string[] keyTables = ["Client", "Adresse", "ParametreMetier", "Contact",
|
||||
"Site", "Client_Adresse", "ClientAdresse", "Client_Lieu", "ClientLieu",
|
||||
"Lieu", "Personne", "Lieu_Personne", "LieuPersonne", "Compteur"];
|
||||
foreach (var tbl in keyTables)
|
||||
{
|
||||
if (!tableNames.Contains(tbl, StringComparer.OrdinalIgnoreCase)) continue;
|
||||
Console.WriteLine($"── Columns: {tbl} ──");
|
||||
try
|
||||
{
|
||||
var cols = connection.GetSchema("Columns", [null, null, tbl, null]);
|
||||
foreach (System.Data.DataRow row in cols.Rows)
|
||||
{
|
||||
string colName = row["COLUMN_NAME"]?.ToString() ?? "?";
|
||||
string typeName = row["TYPE_NAME"]?.ToString() ?? "?";
|
||||
int colSize = row["COLUMN_SIZE"] is int s ? s : 0;
|
||||
bool nullable = row["IS_NULLABLE"]?.ToString() == "YES";
|
||||
Console.WriteLine($" {colName,-30} {typeName,-15} size={colSize,5} nullable={nullable}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Console.WriteLine($" ERROR: {ex.Message}"); }
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (OdbcException ex) { Console.Error.WriteLine($"FAILED: {ConnectionStringRedactor.Redact(ex.Message)}"); return 1; }
|
||||
}
|
||||
|
||||
static int RunClientInsert(ProbeOptions options)
|
||||
{
|
||||
if (!options.UseClientTable)
|
||||
{
|
||||
Console.Error.WriteLine("ERROR: --use-client-table is required for client-insert.");
|
||||
Console.Error.WriteLine(" This probe writes to the Client table on a CLONE/TEST DB ONLY.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
bool isOmika = string.Equals(options.Schema, "omika", StringComparison.OrdinalIgnoreCase);
|
||||
string marker = $"ODBC_PROBE_{DateTime.UtcNow:yyyyMMddHHmmss}";
|
||||
string markerGuid = isOmika
|
||||
? Guid.NewGuid().ToString("D").ToLowerInvariant()[..32]
|
||||
: "";
|
||||
|
||||
Console.WriteLine($"Target: {(isOmika ? "OMIKA" : "BATIK")} Client");
|
||||
Console.WriteLine($"Marker: {marker}");
|
||||
|
||||
try
|
||||
{
|
||||
using var connection = new OdbcConnection(options.ConnectionString);
|
||||
connection.Open();
|
||||
|
||||
using var tx = connection.BeginTransaction();
|
||||
Console.WriteLine("BEGIN: OK");
|
||||
|
||||
string sql;
|
||||
var parameters = new List<OdbcParameter>();
|
||||
|
||||
if (isOmika)
|
||||
{
|
||||
sql = @"INSERT INTO Client (
|
||||
CL_NOM,
|
||||
CL_CODECLIENT,
|
||||
CL_INFOSUP,
|
||||
CL_PAYS,
|
||||
CL_CODETVA,
|
||||
CL_SIREN,
|
||||
CL_SIRET,
|
||||
GUID
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?
|
||||
) RETURNING CL_PKID";
|
||||
parameters.Add(new OdbcParameter { Value = $"ODBC PROBE {marker}" });
|
||||
parameters.Add(new OdbcParameter { Value = marker });
|
||||
parameters.Add(new OdbcParameter { Value = "" });
|
||||
parameters.Add(new OdbcParameter { Value = "FR" });
|
||||
parameters.Add(new OdbcParameter { Value = "" });
|
||||
parameters.Add(new OdbcParameter { Value = "" });
|
||||
parameters.Add(new OdbcParameter { Value = "" });
|
||||
parameters.Add(new OdbcParameter { Value = markerGuid });
|
||||
Console.WriteLine($"GUID candidate: {markerGuid.Length} chars, pattern: {(markerGuid.Count(c => c == '-') == 4 ? "8-4-4-4-8" : "UNEXPECTED")}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Minimal BATIK Client INSERT — only columns documented as required
|
||||
sql = @"INSERT INTO Client (
|
||||
CL_NOM,
|
||||
CL_CODECLIENT
|
||||
) VALUES (
|
||||
?, ?
|
||||
) RETURNING CL_PKID";
|
||||
parameters.Add(new OdbcParameter { Value = $"ODBC PROBE {marker}" });
|
||||
parameters.Add(new OdbcParameter { Value = marker });
|
||||
}
|
||||
|
||||
using var cmd = connection.CreateCommand();
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText = sql;
|
||||
cmd.Parameters.AddRange([.. parameters]);
|
||||
|
||||
Console.WriteLine("-- ExecuteScalar --");
|
||||
object? scalar = cmd.ExecuteScalar();
|
||||
Console.WriteLine($" Accepted: YES");
|
||||
Console.WriteLine($" CLR type: {scalar?.GetType().Name ?? "null"}");
|
||||
Console.WriteLine($" Raw value: {scalar}");
|
||||
long pkid = 0;
|
||||
if (scalar is not null)
|
||||
{
|
||||
try { pkid = Convert.ToInt64(scalar); Console.WriteLine($" Convert.ToInt64: {pkid} ({(pkid > 0 ? "VALID" : "ZERO/INVALID")})"); }
|
||||
catch (Exception ex) { Console.WriteLine($" Convert.ToInt64: FAILED ({ex.Message})"); }
|
||||
}
|
||||
|
||||
// Rollback
|
||||
tx.Rollback();
|
||||
Console.WriteLine("ROLLBACK: OK");
|
||||
|
||||
Console.WriteLine("-- Verify rollback --");
|
||||
using var verifyCmd = connection.CreateCommand();
|
||||
verifyCmd.CommandText = "SELECT COUNT(*) FROM Client WHERE CL_CODECLIENT = ?";
|
||||
verifyCmd.Parameters.Add(new OdbcParameter { Value = marker });
|
||||
int count = Convert.ToInt32(verifyCmd.ExecuteScalar());
|
||||
Console.WriteLine($" Rows with marker: {count}");
|
||||
Console.WriteLine(count == 0 ? " Rollback: VERIFIED" : " Rollback: UNEXPECTED");
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(pkid > 0 && count == 0
|
||||
? "VERDICT: CLIENT INSERT + RETURNING + ROLLBACK = VERIFIED"
|
||||
: "VERDICT: UNEXPECTED — check output above.");
|
||||
return (pkid > 0 && count == 0) ? 0 : 1;
|
||||
}
|
||||
catch (OdbcException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"FAILED: {ConnectionStringRedactor.Redact(ex.Message)}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
static int RunClientGraph(ProbeOptions options)
|
||||
{
|
||||
if (!options.UseClientTable)
|
||||
{
|
||||
Console.Error.WriteLine("ERROR: --use-client-table is required for client-graph.");
|
||||
Console.Error.WriteLine(" This probe writes to Client + related tables on a CLONE/TEST DB ONLY.");
|
||||
return 2;
|
||||
}
|
||||
bool isOmika = string.Equals(options.Schema, "omika", StringComparison.OrdinalIgnoreCase);
|
||||
string marker = $"GRAPH_PROBE_{DateTime.UtcNow:yyyyMMddHHmmss}";
|
||||
Console.WriteLine($"Target: {(isOmika ? "OMIKA" : "BATIK")} Client + Adresse graph");
|
||||
Console.WriteLine($"Marker: {marker}");
|
||||
try
|
||||
{
|
||||
using var connection = new OdbcConnection(options.ConnectionString);
|
||||
connection.Open();
|
||||
using var tx = connection.BeginTransaction();
|
||||
Console.WriteLine("BEGIN: OK");
|
||||
|
||||
// === Step 1: INSERT Adresse ===
|
||||
long adressePkId = 0;
|
||||
{
|
||||
using var cmdAdr = connection.CreateCommand();
|
||||
cmdAdr.Transaction = tx;
|
||||
cmdAdr.CommandText = "INSERT INTO Adresse (AD_LIGNE_1, AD_CODEPOSTAL, AD_VILLE) VALUES (?, ?, ?) RETURNING AD_PKID";
|
||||
cmdAdr.Parameters.Add(new OdbcParameter { Value = $"Probe {marker}" });
|
||||
cmdAdr.Parameters.Add(new OdbcParameter { Value = "99999" });
|
||||
cmdAdr.Parameters.Add(new OdbcParameter { Value = "PROBE CITY" });
|
||||
object? adrId = cmdAdr.ExecuteScalar();
|
||||
adressePkId = Convert.ToInt64(adrId!);
|
||||
Console.WriteLine($"INSERT Adresse: OK, AD_PKID={adressePkId}, CLR={adrId?.GetType().Name}");
|
||||
}
|
||||
|
||||
if (adressePkId <= 0) { Console.Error.WriteLine("FAILED: Adresse PKID invalid."); return 1; }
|
||||
|
||||
// === Step 2: INSERT Client with FK to Adresse ===
|
||||
long clientPkId = 0;
|
||||
{
|
||||
using var cmdCli = connection.CreateCommand();
|
||||
cmdCli.Transaction = tx;
|
||||
cmdCli.CommandText = "INSERT INTO Client (CL_NOM, CL_CODECLIENT, CL_IDADRESSEPRINCIPALE) VALUES (?, ?, ?) RETURNING CL_PKID";
|
||||
cmdCli.Parameters.Add(new OdbcParameter { Value = $"ODBC GRAPH {marker}" });
|
||||
cmdCli.Parameters.Add(new OdbcParameter { Value = marker });
|
||||
cmdCli.Parameters.Add(new OdbcParameter { Value = adressePkId });
|
||||
object? cliId = cmdCli.ExecuteScalar();
|
||||
clientPkId = Convert.ToInt64(cliId!);
|
||||
Console.WriteLine($"INSERT Client: OK, CL_PKID={clientPkId}, FK_ADRESSE={adressePkId}, CLR={cliId?.GetType().Name}");
|
||||
}
|
||||
|
||||
if (clientPkId <= 0) { Console.Error.WriteLine("FAILED: Client PKID invalid."); return 1; }
|
||||
Console.WriteLine($"FK chain verified: AD_PKID {adressePkId} -> CL_IDADRESSEPRINCIPALE {adressePkId}");
|
||||
|
||||
// === Rollback global ===
|
||||
tx.Rollback();
|
||||
Console.WriteLine("ROLLBACK: OK (global)");
|
||||
|
||||
// === Verify all absent ===
|
||||
using (var cmdCheck = connection.CreateCommand())
|
||||
{
|
||||
cmdCheck.CommandText = "SELECT COUNT(*) FROM Client WHERE CL_CODECLIENT = ?";
|
||||
cmdCheck.Parameters.Add(new OdbcParameter { Value = marker });
|
||||
int c = Convert.ToInt32(cmdCheck.ExecuteScalar());
|
||||
Console.WriteLine($"Client rows with marker: {c}");
|
||||
}
|
||||
using (var cmdCheckAdr = connection.CreateCommand())
|
||||
{
|
||||
cmdCheckAdr.CommandText = "SELECT COUNT(*) FROM Adresse WHERE AD_VILLE = 'PROBE CITY' AND AD_LIGNE_1 LIKE 'Probe GRAPH%'";
|
||||
int a = Convert.ToInt32(cmdCheckAdr.ExecuteScalar());
|
||||
Console.WriteLine($"Adresse rows with marker: {a}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("VERDICT: MULTI-INSERT + FK CHAINING + GLOBAL ROLLBACK = VERIFIED");
|
||||
return 0;
|
||||
}
|
||||
catch (OdbcException ex) { Console.Error.WriteLine($"FAILED: {ConnectionStringRedactor.Redact(ex.Message)}"); return 1; }
|
||||
}
|
||||
|
||||
static int RunOmikaGuidProbe(ProbeOptions options)
|
||||
{
|
||||
Console.WriteLine("OMIKA GUID PROBE");
|
||||
Console.WriteLine("================");
|
||||
Console.WriteLine();
|
||||
try
|
||||
{
|
||||
using var connection = new OdbcConnection(options.ConnectionString);
|
||||
connection.Open();
|
||||
Console.WriteLine("Connected. Querying Client table for GUID statistics...");
|
||||
Console.WriteLine();
|
||||
using var cmd = connection.CreateCommand(); cmd.CommandText = ProbeSqlBuilder.BuildSelectGuids(200);
|
||||
int count = 0, empty = 0, allHex = 0, hasDash = 0, hasBrace = 0, hasNonPrint = 0;
|
||||
int parseN = 0, parseD = 0, parseAny = 0;
|
||||
int exactly4Dashes = 0, standardDashPos = 0, looksLikeTruncatedD = 0;
|
||||
int allUpper = 0, allLower = 0, mixedCase = 0;
|
||||
var nonHexSeen = new Dictionary<char, int>();
|
||||
var lenDist = new Dictionary<int, int>();
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
count++;
|
||||
string? raw = reader.IsDBNull(0) ? null : reader.GetString(0);
|
||||
if (string.IsNullOrEmpty(raw)) { empty++; continue; }
|
||||
string guid = raw.TrimEnd('\0', ' ');
|
||||
int len = guid.Length;
|
||||
lenDist.TryGetValue(len, out int lc); lenDist[len] = lc + 1;
|
||||
|
||||
bool isAllHex = guid.Where(c => c != '-').All(c => (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'));
|
||||
if (isAllHex) allHex++;
|
||||
if (guid.Contains('-')) hasDash++;
|
||||
if (guid.Contains('{') || guid.Contains('}')) hasBrace++;
|
||||
if (guid.Any(c => char.IsControl(c) && c != '\0')) hasNonPrint++;
|
||||
|
||||
foreach (char c in guid)
|
||||
{
|
||||
bool isHex = (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f');
|
||||
if (!isHex && c != '-')
|
||||
{
|
||||
nonHexSeen.TryGetValue(c, out int nh); nonHexSeen[c] = nh + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (Guid.TryParseExact(guid, "N", out _)) parseN++;
|
||||
if (Guid.TryParseExact(guid, "D", out _)) parseD++;
|
||||
if (Guid.TryParse(guid, out _)) parseAny++;
|
||||
|
||||
// Dash position analysis (0-based)
|
||||
int dashCount = guid.Count(c => c == '-');
|
||||
if (dashCount == 4) exactly4Dashes++;
|
||||
bool hasStandardPositions = dashCount == 4
|
||||
&& guid.Length == 32
|
||||
&& guid[8] == '-' && guid[13] == '-' && guid[18] == '-' && guid[23] == '-';
|
||||
if (hasStandardPositions) standardDashPos++;
|
||||
if (hasStandardPositions && isAllHex)
|
||||
{
|
||||
looksLikeTruncatedD++;
|
||||
}
|
||||
|
||||
// Casing
|
||||
var hexOnly = guid.Replace("-", "");
|
||||
if (hexOnly.Length > 0)
|
||||
{
|
||||
if (hexOnly.All(c => c >= 'A' && c <= 'F' || c >= '0' && c <= '9')) allUpper++;
|
||||
else if (hexOnly.All(c => c >= 'a' && c <= 'f' || c >= '0' && c <= '9')) allLower++;
|
||||
else mixedCase++;
|
||||
}
|
||||
}
|
||||
Console.WriteLine($" Sample: {count}");
|
||||
Console.WriteLine($" Empty/null: {empty}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("── Length distribution ──");
|
||||
foreach (var kv in lenDist.OrderBy(x => x.Key))
|
||||
Console.WriteLine($" Len={kv.Key}: {kv.Value}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("── Content analysis ──");
|
||||
Console.WriteLine($" All-hex: {allHex}/{count}");
|
||||
Console.WriteLine($" Contains '-': {hasDash}/{count}");
|
||||
Console.WriteLine($" Contains brace: {hasBrace}/{count}");
|
||||
Console.WriteLine($" Non-printable: {hasNonPrint}/{count}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("── Dash position analysis ──");
|
||||
Console.WriteLine($" Exactly 4 dashes: {exactly4Dashes}/{count}");
|
||||
Console.WriteLine($" Standard positions: {standardDashPos}/{count}");
|
||||
Console.WriteLine($" LooksLikeTruncatedD:{looksLikeTruncatedD}/{count}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("── Casing (hex chars only) ──");
|
||||
Console.WriteLine($" All uppercase: {allUpper}/{count}");
|
||||
Console.WriteLine($" All lowercase: {allLower}/{count}");
|
||||
Console.WriteLine($" Mixed case: {mixedCase}/{count}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("── Guid.Parse ──");
|
||||
Console.WriteLine($" ParseExact N: {parseN}/{count}");
|
||||
Console.WriteLine($" ParseExact D: {parseD}/{count}");
|
||||
Console.WriteLine($" ParseAny: {parseAny}/{count}");
|
||||
if (nonHexSeen.Count > 0)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("── Non-hex chars (codepoint: count) ──");
|
||||
foreach (var kv in nonHexSeen.OrderByDescending(x => x.Value).Take(20))
|
||||
Console.WriteLine($" U+{(int)kv.Key:X4} ('{kv.Key}'): {kv.Value}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("VERDICT: DETAILED ANALYSIS ABOVE.");
|
||||
return 0;
|
||||
}
|
||||
catch (OdbcException ex) { Console.Error.WriteLine($"FAILED: {ConnectionStringRedactor.Redact(ex.Message)}"); return 1; }
|
||||
}
|
||||
|
||||
static int ValidateIdentifiers(string table, string idColumn, string markerColumn)
|
||||
{
|
||||
try { SqlIdentifierValidator.Validate(table); SqlIdentifierValidator.Validate(idColumn); SqlIdentifierValidator.Validate(markerColumn); return 0; }
|
||||
catch (ArgumentException ex) { Console.Error.WriteLine($"ERROR: {ex.Message}"); return 2; }
|
||||
}
|
||||
|
||||
static string MaskConnectionProperty(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return "(empty)";
|
||||
if (value.Contains("PWD=", StringComparison.OrdinalIgnoreCase) || value.Contains("Password=", StringComparison.OrdinalIgnoreCase) || value.Contains("UID=", StringComparison.OrdinalIgnoreCase)) return "(masked)";
|
||||
return value;
|
||||
}
|
||||
|
||||
static void PrintUsage()
|
||||
{
|
||||
Console.WriteLine("HFSQL ODBC CAPABILITY PROBE");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("COMMANDS:");
|
||||
Console.WriteLine(" capabilities Display driver info + connection capabilities (READ-ONLY)");
|
||||
Console.WriteLine(" audit List all tables and key columns metadata (READ-ONLY)");
|
||||
Console.WriteLine(" transaction Test BEGIN/INSERT/ROLLBACK cycle (WRITE — needs --allow-write-probe)");
|
||||
Console.WriteLine(" returning Test INSERT ... RETURNING via ODBC (WRITE — needs --allow-write-probe)");
|
||||
Console.WriteLine(" omika-guid Read GUID statistics from Client table (READ-ONLY)");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("OPTIONS:");
|
||||
Console.WriteLine(" --connection-string HFSQL ODBC connection string (DSN=...)");
|
||||
Console.WriteLine(" --allow-write-probe Confirm working on clone/test DB (required for writes)");
|
||||
Console.WriteLine(" --table Table name for write probes");
|
||||
Console.WriteLine(" --id-column Auto-ID column name (default: PKID)");
|
||||
Console.WriteLine(" --marker-column Column to write a marker value (default: Marker)");
|
||||
Console.WriteLine(" --schema batik | omika (for client-specific probes)");
|
||||
Console.WriteLine(" --use-client-table Write to Client table (clone DB only!)");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("\u26a0 NEVER run write probes against a production database.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Omika.HfSqlProbe;
|
||||
|
||||
internal static partial class SqlIdentifierValidator
|
||||
{
|
||||
/// <summary>Accepts only letters, digits, and underscores.</summary>
|
||||
[GeneratedRegex(@"\A[A-Za-z_][A-Za-z0-9_]*\z")]
|
||||
private static partial Regex IdentifierPattern();
|
||||
|
||||
public static bool IsValid(string name) => IdentifierPattern().IsMatch(name);
|
||||
|
||||
/// <summary>Validates and returns the identifier, or throws on invalid input.</summary>
|
||||
public static string Validate(string name)
|
||||
{
|
||||
if (!IsValid(name))
|
||||
throw new ArgumentException(
|
||||
$"Invalid SQL identifier: '{name}'. Only [A-Za-z0-9_] allowed.");
|
||||
return name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Omika.IdempotenceProbe</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Data.OleDb" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,192 @@
|
||||
// Probe de diagnostic IDEMPOTENCE — 100% LECTURE SEULE.
|
||||
// Interroge MobReception, MobLog et les Interventions E2E_TEST dans BATIK_TEST
|
||||
// pour reconstruire la chronologie des OperationId CREATE et Replay.
|
||||
// Aucun INSERT / UPDATE / DELETE.
|
||||
using System.Data.OleDb;
|
||||
|
||||
const string server = "127.0.0.1";
|
||||
const int port = 4900;
|
||||
const string user = "susana";
|
||||
const string password = "123";
|
||||
|
||||
string BuildConnStr(string database) =>
|
||||
$"Provider=PCSOFT.HFSQL;Data Source={server}:{port};Initial Catalog={database};User ID={user};Password={password}";
|
||||
|
||||
const string createOpId = "5b915439-d9d9-44b5-9552-1f8f90db96c3";
|
||||
const string replayOpId = "0fce297f-e4e4-45ad-9027-b4e6e23d5c8a";
|
||||
|
||||
Console.WriteLine("=== DIAGNOSTIC IDEMPOTENCE — LECTURE SEULE ===");
|
||||
Console.WriteLine($"CREATE OperationId : {createOpId}");
|
||||
Console.WriteLine($"Replay OperationId : {replayOpId}");
|
||||
Console.WriteLine();
|
||||
|
||||
try
|
||||
{
|
||||
using var conn = new OleDbConnection(BuildConnStr("BATIK_TEST"));
|
||||
conn.Open();
|
||||
Console.WriteLine("Connexion BATIK_TEST: OPEN");
|
||||
Console.WriteLine();
|
||||
|
||||
// ============================================================
|
||||
// 1. Interventions E2E_TEST (toutes)
|
||||
// ============================================================
|
||||
Console.WriteLine("── 1. Interventions E2E_TEST (IN_RefInterne LIKE 'E2E_TEST%') ──");
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT IN_PKID, IN_RefInterne, IN_Titre, IN_Description FROM Intervention WHERE IN_RefInterne LIKE 'E2E_TEST%' ORDER BY IN_PKID";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
int n = 0;
|
||||
while (reader.Read())
|
||||
{
|
||||
n++;
|
||||
Console.WriteLine($" [{n}] IN_PKID={reader["IN_PKID"]}, Ref={reader["IN_RefInterne"]}, Titre={reader["IN_Titre"]}, Desc={reader["IN_Description"]}");
|
||||
}
|
||||
Console.WriteLine($" Total E2E_TEST: {n}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
|
||||
// ============================================================
|
||||
// 2. MobLog pour CREATE OperationId
|
||||
// ============================================================
|
||||
Console.WriteLine($"── 2. MobLog pour CREATE OperationId {createOpId} ──");
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT ML_PKID, ML_DateCreat, ML_Message, ML_Niveau FROM MobLog WHERE ML_Message LIKE ? ORDER BY ML_PKID";
|
||||
cmd.Parameters.Add(new OleDbParameter { Value = $"%{createOpId}%" });
|
||||
using var reader = cmd.ExecuteReader();
|
||||
int n = 0;
|
||||
while (reader.Read())
|
||||
{
|
||||
n++;
|
||||
Console.WriteLine($" [{n}] ML_PKID={reader["ML_PKID"]}, Date={reader["ML_DateCreat"]}, Niveau={reader["ML_Niveau"]}");
|
||||
Console.WriteLine($" Msg={reader["ML_Message"]}");
|
||||
}
|
||||
Console.WriteLine($" Total MobLog CREATE: {n}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
|
||||
// ============================================================
|
||||
// 3. MobLog pour Replay OperationId
|
||||
// ============================================================
|
||||
Console.WriteLine($"── 3. MobLog pour Replay OperationId {replayOpId} ──");
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT ML_PKID, ML_DateCreat, ML_Message, ML_Niveau FROM MobLog WHERE ML_Message LIKE ? ORDER BY ML_PKID";
|
||||
cmd.Parameters.Add(new OleDbParameter { Value = $"%{replayOpId}%" });
|
||||
using var reader = cmd.ExecuteReader();
|
||||
int n = 0;
|
||||
while (reader.Read())
|
||||
{
|
||||
n++;
|
||||
Console.WriteLine($" [{n}] ML_PKID={reader["ML_PKID"]}, Date={reader["ML_DateCreat"]}, Niveau={reader["ML_Niveau"]}");
|
||||
Console.WriteLine($" Msg={reader["ML_Message"]}");
|
||||
}
|
||||
Console.WriteLine($" Total MobLog Replay: {n}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
|
||||
// ============================================================
|
||||
// 4. MobReception pour CREATE OperationId
|
||||
// ============================================================
|
||||
Console.WriteLine($"── 4. MobReception pour CREATE OperationId {createOpId} ──");
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT MR_PKID, MR_IDToSRV, MR_JSON, MR_CMD, MR_Fait, MR_DateHeureRecep FROM MobReception WHERE MR_JSON LIKE ? ORDER BY MR_PKID";
|
||||
cmd.Parameters.Add(new OleDbParameter { Value = $"%{createOpId}%" });
|
||||
using var reader = cmd.ExecuteReader();
|
||||
int n = 0;
|
||||
while (reader.Read())
|
||||
{
|
||||
n++;
|
||||
Console.WriteLine($" [{n}] MR_PKID={reader["MR_PKID"]}, MR_IDToSRV={reader["MR_IDToSRV"]}, Fait={reader["MR_Fait"]}, Date={reader["MR_DateHeureRecep"]}");
|
||||
Console.WriteLine($" JSON={reader["MR_JSON"]}");
|
||||
}
|
||||
Console.WriteLine($" Total MobReception CREATE: {n}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
|
||||
// ============================================================
|
||||
// 5. MobReception pour Replay OperationId
|
||||
// ============================================================
|
||||
Console.WriteLine($"── 5. MobReception pour Replay OperationId {replayOpId} ──");
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT MR_PKID, MR_IDToSRV, MR_JSON, MR_CMD, MR_Fait, MR_DateHeureRecep FROM MobReception WHERE MR_JSON LIKE ? ORDER BY MR_PKID";
|
||||
cmd.Parameters.Add(new OleDbParameter { Value = $"%{replayOpId}%" });
|
||||
using var reader = cmd.ExecuteReader();
|
||||
int n = 0;
|
||||
while (reader.Read())
|
||||
{
|
||||
n++;
|
||||
Console.WriteLine($" [{n}] MR_PKID={reader["MR_PKID"]}, MR_IDToSRV={reader["MR_IDToSRV"]}, Fait={reader["MR_Fait"]}, Date={reader["MR_DateHeureRecep"]}");
|
||||
Console.WriteLine($" JSON={reader["MR_JSON"]}");
|
||||
}
|
||||
Console.WriteLine($" Total MobReception Replay: {n}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
|
||||
// ============================================================
|
||||
// 6. Tous les MobLog contenant E2E_TEST (pour corréler les références)
|
||||
// ============================================================
|
||||
Console.WriteLine("── 6. MobLog contenant 'E2E_TEST' (corrélation références) ──");
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT ML_PKID, ML_DateCreat, ML_Message, ML_Niveau FROM MobLog WHERE ML_Message LIKE '%E2E_TEST%' ORDER BY ML_PKID";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
int n = 0;
|
||||
while (reader.Read())
|
||||
{
|
||||
n++;
|
||||
Console.WriteLine($" [{n}] ML_PKID={reader["ML_PKID"]}, Date={reader["ML_DateCreat"]}, Niveau={reader["ML_Niveau"]}");
|
||||
Console.WriteLine($" Msg={reader["ML_Message"]}");
|
||||
}
|
||||
Console.WriteLine($" Total MobLog E2E_TEST: {n}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
|
||||
// ============================================================
|
||||
// 7. Tous les MobReception contenant E2E_TEST
|
||||
// ============================================================
|
||||
Console.WriteLine("── 7. MobReception contenant 'E2E_TEST' ──");
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT MR_PKID, MR_IDToSRV, MR_JSON, MR_CMD, MR_Fait, MR_DateHeureRecep FROM MobReception WHERE MR_JSON LIKE '%E2E_TEST%' ORDER BY MR_PKID";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
int n = 0;
|
||||
while (reader.Read())
|
||||
{
|
||||
n++;
|
||||
Console.WriteLine($" [{n}] MR_PKID={reader["MR_PKID"]}, MR_IDToSRV={reader["MR_IDToSRV"]}, Fait={reader["MR_Fait"]}, Date={reader["MR_DateHeureRecep"]}");
|
||||
Console.WriteLine($" JSON={reader["MR_JSON"]}");
|
||||
}
|
||||
Console.WriteLine($" Total MobReception E2E_TEST: {n}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
|
||||
// ============================================================
|
||||
// 8. TOSRV (base Service) — vérifier s'il reste des opérations
|
||||
// ============================================================
|
||||
Console.WriteLine("── 8. TOSRV (base Service BATIK.01_BLS) ──");
|
||||
using (var srvConn = new OleDbConnection(BuildConnStr("BATIK.01_BLS")))
|
||||
{
|
||||
srvConn.Open();
|
||||
using var cmd = srvConn.CreateCommand();
|
||||
cmd.CommandText = "SELECT IDTOSRV, TS_Date, TS_Mobile, TS_JSON, TS_CMD FROM TOSRV";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
int n = 0;
|
||||
while (reader.Read())
|
||||
{
|
||||
n++;
|
||||
Console.WriteLine($" [{n}] IDTOSRV={reader["IDTOSRV"]}, Date={reader["TS_Date"]}, Mobile={reader["TS_Mobile"]}, Cmd={reader["TS_CMD"]}");
|
||||
Console.WriteLine($" JSON={reader["TS_JSON"]}");
|
||||
}
|
||||
Console.WriteLine($" Total TOSRV: {n}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
|
||||
Console.WriteLine("=== DIAGNOSTIC TERMINÉ (aucune écriture effectuée) ===");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($" ERREUR: {ex.Message}");
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Data.OleDb" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,69 @@
|
||||
// Probe pour tester la récupération de l'identifiant auto-généré HFSQL
|
||||
// après un INSERT réel dans une transaction.
|
||||
// Écriture dans BATIK_TEST (base de test confirmée) avec référence unique E2E_PROBE_.
|
||||
using System.Data.OleDb;
|
||||
|
||||
const string server = "127.0.0.1";
|
||||
const int port = 4900;
|
||||
const string user = "susana";
|
||||
const string password = "123";
|
||||
|
||||
string BuildConnStr(string database) =>
|
||||
$"Provider=PCSOFT.HFSQL;Data Source={server}:{port};Initial Catalog={database};User ID={user};Password={password}";
|
||||
|
||||
string reference = $"E2E_PROBE_{DateTime.Now:HHmmss}";
|
||||
Console.WriteLine($"=== Test INSERT + LAST_INSERT_ID() (base BATIK_TEST) ===");
|
||||
Console.WriteLine($" Référence unique: {reference}");
|
||||
|
||||
try
|
||||
{
|
||||
using var conn = new OleDbConnection(BuildConnStr("BATIK_TEST"));
|
||||
conn.Open();
|
||||
|
||||
using var tx = conn.BeginTransaction();
|
||||
|
||||
// INSERT dans Intervention (IN_PKID auto-généré).
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText = "INSERT INTO Intervention (IN_IDTypeIntervention, IN_Titre, IN_RefInterne, IN_Description, Archive, CreationDate, ModifDate) VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||
cmd.Parameters.Add(new OleDbParameter { Value = 1 });
|
||||
cmd.Parameters.Add(new OleDbParameter { Value = "Probe LAST_INSERT_ID" });
|
||||
cmd.Parameters.Add(new OleDbParameter { Value = reference });
|
||||
cmd.Parameters.Add(new OleDbParameter { Value = "Probe test" });
|
||||
cmd.Parameters.Add(new OleDbParameter { Value = false });
|
||||
cmd.Parameters.Add(new OleDbParameter { Value = DateTime.Now });
|
||||
cmd.Parameters.Add(new OleDbParameter { Value = DateTime.Now });
|
||||
int rows = cmd.ExecuteNonQuery();
|
||||
Console.WriteLine($" INSERT exécuté, rows={rows}");
|
||||
}
|
||||
|
||||
// Test LAST_INSERT_ID() dans la même transaction.
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText = "SELECT LAST_INSERT_ID()";
|
||||
var value = cmd.ExecuteScalar();
|
||||
Console.WriteLine($" LAST_INSERT_ID() -> {(value is null || value == DBNull.Value ? "NULL" : value)}");
|
||||
}
|
||||
|
||||
// Test MAX(IN_PKID) pour comparaison.
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText = "SELECT MAX(IN_PKID) FROM Intervention";
|
||||
var value = cmd.ExecuteScalar();
|
||||
Console.WriteLine($" MAX(IN_PKID) -> {(value is null || value == DBNull.Value ? "NULL" : value)}");
|
||||
}
|
||||
|
||||
// ROLLBACK pour ne pas laisser de données.
|
||||
tx.Rollback();
|
||||
Console.WriteLine(" ROLLBACK effectué (aucune donnée laissée).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($" ERREUR: {ex.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Probe INSERT + LAST_INSERT_ID() terminé.");
|
||||
@@ -0,0 +1,121 @@
|
||||
namespace Omika.LanguesProbe;
|
||||
|
||||
/// <summary>
|
||||
/// Arguments d'une commande, séparés en positionnels et options nommées.
|
||||
///
|
||||
/// <para>
|
||||
/// L'analyse est volontairement stricte : une option inconnue est <b>refusée</b>
|
||||
/// au lieu d'être ignorée. Sur un poste de développement, une faute de frappe
|
||||
/// silencieuse (<c>--dossiers</c>, <c>--sens</c> mal orthographié) ferait
|
||||
/// travailler l'outil sur autre chose que ce que le développeur croit, et le
|
||||
/// diagnostic serait faux sans que rien ne le signale.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Les valeurs ne sont jamais redécoupées : un chemin contenant des espaces
|
||||
/// arrive en un seul argument, grâce aux guillemets du shell, et le reste.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class Arguments
|
||||
{
|
||||
private readonly List<string> _positionnels = [];
|
||||
private readonly Dictionary<string, string> _options = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private Arguments()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyse les arguments d'une commande.
|
||||
/// </summary>
|
||||
/// <param name="args">Arguments suivant le nom de la commande.</param>
|
||||
/// <param name="optionsAttendues">Options nommées acceptées, sans les tirets.</param>
|
||||
/// <exception cref="AideDemandeeException"><c>--aide</c> ou <c>-h</c> est présent.</exception>
|
||||
/// <exception cref="ErreurUsageException">Option inconnue, ou option sans valeur.</exception>
|
||||
public static Arguments Analyser(string[] args, params string[] optionsAttendues)
|
||||
{
|
||||
var resultat = new Arguments();
|
||||
var connues = new HashSet<string>(optionsAttendues, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
string argument = args[i];
|
||||
|
||||
if (argument is "--aide" or "-h")
|
||||
{
|
||||
// Reconnu à n'importe quelle position : « traduire --aide » doit expliquer la
|
||||
// commande, pas réclamer --dossier.
|
||||
throw new AideDemandeeException();
|
||||
}
|
||||
|
||||
if (!argument.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
resultat._positionnels.Add(SansGuillemets(argument));
|
||||
continue;
|
||||
}
|
||||
|
||||
string nom = argument[2..];
|
||||
if (!connues.Contains(nom))
|
||||
{
|
||||
throw new ErreurUsageException($"option inconnue « {argument} »");
|
||||
}
|
||||
|
||||
if (i + 1 >= args.Length)
|
||||
{
|
||||
throw new ErreurUsageException($"l'option « {argument} » attend une valeur");
|
||||
}
|
||||
|
||||
// La valeur est consommée telle quelle, même si elle commence par « -- » : un texte à
|
||||
// traduire peut légitimement commencer par un tiret.
|
||||
resultat._options[nom] = SansGuillemets(args[++i]);
|
||||
}
|
||||
|
||||
return resultat;
|
||||
}
|
||||
|
||||
/// <summary>Argument positionnel, ou erreur d'usage s'il manque.</summary>
|
||||
public string Positionnel(int index, string description)
|
||||
=> index < _positionnels.Count
|
||||
? _positionnels[index]
|
||||
: throw new ErreurUsageException($"argument manquant : {description}");
|
||||
|
||||
/// <summary>Valeur d'une option, ou <see langword="null"/> si elle est absente.</summary>
|
||||
public string? Option(string nom)
|
||||
=> _options.TryGetValue(nom, out string? valeur) ? valeur : null;
|
||||
|
||||
/// <summary>Valeur d'une option, ou <paramref name="defaut"/> si elle est absente.</summary>
|
||||
public string Option(string nom, string defaut) => Option(nom) ?? defaut;
|
||||
|
||||
/// <summary>Positionnels à partir d'un index (un texte peut arriver en plusieurs arguments).</summary>
|
||||
public IReadOnlyList<string> Reste(int depuis)
|
||||
{
|
||||
if (depuis >= _positionnels.Count)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return _positionnels.GetRange(depuis, _positionnels.Count - depuis);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retire une paire de guillemets entourant une valeur : un copier-coller
|
||||
/// depuis un document, ou un shell qui transmet les guillemets eux-mêmes,
|
||||
/// produirait sinon un chemin de fichier introuvable.
|
||||
/// </summary>
|
||||
private static string SansGuillemets(string valeur)
|
||||
=> valeur.Length >= 2 && valeur[0] == '"' && valeur[^1] == '"' ? valeur[1..^1] : valeur;
|
||||
}
|
||||
|
||||
/// <summary>Argument manquant, option inconnue : l'appel est incorrect (code de sortie 1).</summary>
|
||||
internal sealed class ErreurUsageException : Exception
|
||||
{
|
||||
public ErreurUsageException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary><c>--aide</c> demandé : ce n'est pas une erreur (code de sortie 0).</summary>
|
||||
internal sealed class AideDemandeeException : Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
using System.Text;
|
||||
using Omika.App.Local.Services;
|
||||
|
||||
namespace Omika.LanguesProbe;
|
||||
|
||||
/// <summary>
|
||||
/// Commandes « inspecter » et « encoder » : tout ce qui se vérifie sur le
|
||||
/// <b>vocabulaire seul</b>, sans moteur d'inférence.
|
||||
///
|
||||
/// <para>
|
||||
/// C'est la partie utilisable dès aujourd'hui, moteur éteint : un vocabulaire
|
||||
/// illisible, un profil de normalisation approximé, un repli octet absent ou un
|
||||
/// découpage non réversible s'observent ici, alors qu'ils ne se verraient en
|
||||
/// production que sous forme de traductions dégradées.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class InspectionVocabulaire
|
||||
{
|
||||
/// <summary>
|
||||
/// Phrases témoins de l'aller-retour : elles cumulent ce qui casse un
|
||||
/// découpage réel — accents, ponctuation espagnole inversée, apostrophes,
|
||||
/// chiffres et unités souvent hors vocabulaire, majuscules en début de
|
||||
/// phrase (le marqueur d'espace y est présent).
|
||||
/// </summary>
|
||||
private static readonly string[] PhrasesTemoins =
|
||||
[
|
||||
"Bonjour, comment allez-vous ?",
|
||||
"Le client a signé le devis hier après-midi.",
|
||||
"Rendez-vous demain matin à 8 h 30.",
|
||||
"L'intervention est terminée : 1 234,56 € de pièces.",
|
||||
"¿Dónde está el cliente?",
|
||||
"La factura se enviará mañana por la mañana.",
|
||||
"Necesito hablar con el responsable de la obra.",
|
||||
"El técnico llegó a las nueve y revisó la caldera."
|
||||
];
|
||||
|
||||
/// <summary>Commande « inspecter » : identité et comportement du vocabulaire.</summary>
|
||||
public static int Inspecter(string[] args)
|
||||
{
|
||||
Arguments arguments = Arguments.Analyser(args);
|
||||
string chemin = arguments.Positionnel(0, "chemin du vocabulaire (.spm)");
|
||||
|
||||
SentencePieceTokenizer tokenizer;
|
||||
try
|
||||
{
|
||||
tokenizer = SentencePieceTokenizer.Charger(chemin);
|
||||
}
|
||||
catch (SentencePieceException ex)
|
||||
{
|
||||
return EchecVocabulaire(ex);
|
||||
}
|
||||
|
||||
Console.WriteLine("── Vocabulaire ──");
|
||||
Console.WriteLine($" Fichier : {Program.NomFichier(chemin)}");
|
||||
Console.WriteLine($" Pièces : {tokenizer.TailleVocabulaire}");
|
||||
Console.WriteLine($" Jetons <unk>/<s>/</s>/<pad> : {TexteIdentifiant(tokenizer.IdTokenInconnu)} / {TexteIdentifiant(tokenizer.IdDebut)}"
|
||||
+ $" / {TexteIdentifiant(tokenizer.IdFin)} / {TexteIdentifiant(tokenizer.IdRemplissage)}");
|
||||
Console.WriteLine($" Normalisation : {tokenizer.ProfilNormalisation}");
|
||||
|
||||
// Le repli octet décide du sort des caractères hors vocabulaire : accents perdus ou
|
||||
// préservés. C'est la seconde chose à vérifier après les jetons techniques.
|
||||
Console.WriteLine(tokenizer.RepliOctetDisponible
|
||||
? " Repli octet : disponible (un caractère hors vocabulaire est découpé en octets UTF-8)"
|
||||
: " Repli octet : INDISPONIBLE (un caractère hors vocabulaire devient <unk>, donc perdu)");
|
||||
|
||||
// Le type de segmentation (UNIGRAM / BPE) vit dans le trainer_spec du .spm, que le
|
||||
// tokeniseur lit sans le publier : aucune propriété ne l'expose. L'annoncer demanderait de
|
||||
// le déduire du seul comportement, ce qui serait une supposition — on dit qu'il est
|
||||
// indéterminable ici plutôt que de laisser croire à une mesure.
|
||||
Console.WriteLine(" Segmentation : non déterminable ici (UNIGRAM/BPE non exposé par SentencePieceTokenizer)");
|
||||
Console.WriteLine();
|
||||
|
||||
Console.WriteLine("── Avertissements du tokeniseur ──");
|
||||
if (tokenizer.Avertissements.Count == 0)
|
||||
{
|
||||
Console.WriteLine(" aucun avertissement");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (string avertissement in tokenizer.Avertissements)
|
||||
{
|
||||
Console.WriteLine($" - {avertissement}");
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("── Aller-retour : texte → identifiants → texte ──");
|
||||
Console.WriteLine(" DIFFERENT signale un découpage non réversible : à comparer avec un tokeniseur de référence.");
|
||||
|
||||
int ecarts = 0;
|
||||
foreach (string phrase in PhrasesTemoins)
|
||||
{
|
||||
if (!AllerRetour(tokenizer, phrase))
|
||||
{
|
||||
ecarts++;
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(ecarts == 0
|
||||
? $"VERDICT : aller-retour IDENTIQUE sur les {PhrasesTemoins.Length} phrases témoins."
|
||||
: $"VERDICT : {ecarts} phrase(s) sur {PhrasesTemoins.Length} ne reviennent pas identiques (premier écart indiqué).");
|
||||
return CodesSortie.Succes;
|
||||
}
|
||||
|
||||
/// <summary>Commande « encoder » : identifiants et pièces, dans l'ordre.</summary>
|
||||
public static int Encoder(string[] args)
|
||||
{
|
||||
Arguments arguments = Arguments.Analyser(args, "texte");
|
||||
string chemin = arguments.Positionnel(0, "chemin du vocabulaire (.spm)");
|
||||
|
||||
// Un texte non cité arrive en plusieurs arguments : les recoller avec des espaces évite de
|
||||
// ne traduire que le premier mot.
|
||||
string texte = arguments.Option("texte") ?? string.Join(' ', arguments.Reste(1));
|
||||
if (string.IsNullOrEmpty(texte))
|
||||
{
|
||||
throw new ErreurUsageException("texte manquant : « encoder <vocab.spm> \"<texte>\" »");
|
||||
}
|
||||
|
||||
SentencePieceTokenizer tokenizer;
|
||||
try
|
||||
{
|
||||
tokenizer = SentencePieceTokenizer.Charger(chemin);
|
||||
}
|
||||
catch (SentencePieceException ex)
|
||||
{
|
||||
return EchecVocabulaire(ex);
|
||||
}
|
||||
|
||||
IReadOnlyList<int> ids = tokenizer.EncodeAsync(texte).GetAwaiter().GetResult();
|
||||
|
||||
Console.WriteLine($"Vocabulaire : {Program.NomFichier(chemin)} ({tokenizer.TailleVocabulaire} pièces)");
|
||||
Console.WriteLine($"Texte : {texte}");
|
||||
Console.WriteLine($"Identifiants ({ids.Count}) : {string.Join(' ', ids)}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" # id pièce");
|
||||
|
||||
for (int i = 0; i < ids.Count; i++)
|
||||
{
|
||||
Console.WriteLine($" {i,-5} {ids[i],-8} {TextePiece(tokenizer, ids[i])}");
|
||||
}
|
||||
|
||||
return CodesSortie.Succes;
|
||||
}
|
||||
|
||||
/// <summary>Aller-retour d'une phrase témoin ; vrai si le texte revient identique.</summary>
|
||||
private static bool AllerRetour(SentencePieceTokenizer tokenizer, string phrase)
|
||||
{
|
||||
// L'encodage est synchrone et sans E/S : l'attente directe est légitime dans une
|
||||
// application console, qui n'a pas de contexte de synchronisation à préserver.
|
||||
IReadOnlyList<int> ids = tokenizer.EncodeAsync(phrase).GetAwaiter().GetResult();
|
||||
string decode = tokenizer.Decode(ids);
|
||||
bool identique = string.Equals(decode, phrase, StringComparison.Ordinal);
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($" « {phrase} »");
|
||||
Console.WriteLine($" identifiants ({ids.Count}) : {AfficherIds(ids)}");
|
||||
Console.WriteLine($" décodé : « {decode} »");
|
||||
|
||||
if (identique)
|
||||
{
|
||||
Console.WriteLine(" VERDICT : IDENTIQUE");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Le premier caractère divergent désigne la pièce fautive : le développeur n'a pas à
|
||||
// comparer les deux chaînes à l'œil.
|
||||
int position = PremiereDivergence(phrase, decode);
|
||||
Console.WriteLine(
|
||||
$" VERDICT : DIFFERENT — premier écart au caractère {position} : "
|
||||
+ $"« {CaractereLisible(phrase, position)} » attendu, « {CaractereLisible(decode, position)} » obtenu");
|
||||
}
|
||||
|
||||
return identique;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Texte d'une pièce, tel que le tokeniseur le restitue.
|
||||
///
|
||||
/// <para>
|
||||
/// La table des pièces n'est pas publiée (elle est privée) : la seule lecture
|
||||
/// possible passe par <see cref="SentencePieceTokenizer.Decode"/> sur un
|
||||
/// identifiant isolé. Deux conséquences, rendues visibles à l'affichage :
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>le marqueur d'espace ▁ est retraduit en espace puis rogné en bord de
|
||||
/// chaîne : il est retrouvé en décodant <b>deux fois</b> le même identifiant,
|
||||
/// le second exemplaire révélant l'espace de séparation ;</item>
|
||||
/// <item>une pièce d'octet isolée ne forme pas un caractère UTF-8 valide et se
|
||||
/// décode en U+FFFD — c'est en soi l'indice d'un fragment d'octet. Un octet
|
||||
/// ASCII, lui, s'affiche comme le caractère correspondant, qui <b>est</b> sa
|
||||
/// valeur.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private static string TextePiece(SentencePieceTokenizer tokenizer, int id)
|
||||
{
|
||||
if (id == tokenizer.IdTokenInconnu)
|
||||
{
|
||||
return "<unk> — jeton technique";
|
||||
}
|
||||
|
||||
if (id == tokenizer.IdDebut)
|
||||
{
|
||||
return "<s> — jeton technique";
|
||||
}
|
||||
|
||||
if (id == tokenizer.IdFin)
|
||||
{
|
||||
return "</s> — jeton technique";
|
||||
}
|
||||
|
||||
if (id == tokenizer.IdRemplissage)
|
||||
{
|
||||
return "<pad> — jeton technique";
|
||||
}
|
||||
|
||||
string seul = tokenizer.Decode([id]);
|
||||
if (seul.Length == 0)
|
||||
{
|
||||
return "(aucun texte : jeton de contrôle ou identifiant hors vocabulaire)";
|
||||
}
|
||||
|
||||
if (seul.All(c => c == '\uFFFD'))
|
||||
{
|
||||
return "<octet> — fragment d'octet, non affichable isolément";
|
||||
}
|
||||
|
||||
bool porteMarqueur = tokenizer.Decode([id, id]).Length == (2 * seul.Length) + 1;
|
||||
return porteMarqueur ? $"▁{seul}" : seul;
|
||||
}
|
||||
|
||||
/// <summary>Liste d'identifiants, tronquée quand elle dépasse ce qui reste lisible.</summary>
|
||||
private static string AfficherIds(IReadOnlyList<int> ids)
|
||||
{
|
||||
const int maximum = 24;
|
||||
var texte = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < Math.Min(ids.Count, maximum); i++)
|
||||
{
|
||||
if (i > 0)
|
||||
{
|
||||
texte.Append(' ');
|
||||
}
|
||||
|
||||
texte.Append(ids[i]);
|
||||
}
|
||||
|
||||
if (ids.Count > maximum)
|
||||
{
|
||||
texte.Append($" … (+{ids.Count - maximum} identifiants)");
|
||||
}
|
||||
|
||||
return texte.ToString();
|
||||
}
|
||||
|
||||
private static int PremiereDivergence(string attendu, string obtenu)
|
||||
{
|
||||
int commun = Math.Min(attendu.Length, obtenu.Length);
|
||||
for (int i = 0; i < commun; i++)
|
||||
{
|
||||
if (attendu[i] != obtenu[i])
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return commun;
|
||||
}
|
||||
|
||||
private static string CaractereLisible(string texte, int position)
|
||||
=> position >= texte.Length ? "fin de chaîne" : texte[position].ToString();
|
||||
|
||||
/// <summary>Identifiant d'un jeton technique, « absent » pour -1 (pièce absente du vocabulaire).</summary>
|
||||
private static string TexteIdentifiant(int id) => id >= 0 ? id.ToString() : "absent";
|
||||
|
||||
/// <summary>
|
||||
/// Le message de <see cref="SentencePieceException"/> est déjà destiné à
|
||||
/// l'utilisateur (français, sans chemin) : il est affiché tel quel.
|
||||
/// </summary>
|
||||
private static int EchecVocabulaire(SentencePieceException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"ÉCHEC : {Program.MessageSansChemin(ex.Message)}");
|
||||
return CodesSortie.OperationEchouee;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Omika.App.Local\Omika.App.Local.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,213 @@
|
||||
using Omika.App.Local.Abstractions;
|
||||
using Omika.App.Local.Models;
|
||||
using Omika.Core.Contracts.Mobile.Languages;
|
||||
|
||||
namespace Omika.LanguesProbe;
|
||||
|
||||
/// <summary>
|
||||
/// Magasin de pack de langue adossé à un dossier du disque : c'est ce qui permet
|
||||
/// d'exécuter le <b>vrai</b> pipeline (<c>OnnxTranslationService</c>) sur les
|
||||
/// fichiers d'un développeur, hors téléphone.
|
||||
///
|
||||
/// <para>
|
||||
/// Le dossier passé en ligne de commande est traité comme
|
||||
/// « <c>langues/<code>/<sens></c> » : tous les fichiers
|
||||
/// <c>*.spm</c> et <c>*.onnx</c> qu'il contient sont déclarés pour le sens
|
||||
/// demandé, avec leur taille réelle. Aucune empreinte SHA-256 n'est inventée :
|
||||
/// un pack décrit depuis le disque n'a pas de référence serveur à laquelle se
|
||||
/// comparer, et une fausse empreinte rendrait la vérification mensongère.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Lecture seule.</b> Les méthodes d'écriture de l'interface refusent d'agir
|
||||
/// plutôt que de faire semblant : un outil de diagnostic ne doit jamais
|
||||
/// modifier, ni supprimer, ce qu'un développeur lui demande d'examiner.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class PackLangueDossier : ILanguagePackStore
|
||||
{
|
||||
private readonly string _dossier;
|
||||
private readonly string _code;
|
||||
private readonly string _sens;
|
||||
|
||||
public PackLangueDossier(string dossier, string code, string sens)
|
||||
{
|
||||
_dossier = dossier;
|
||||
_code = code;
|
||||
_sens = sens;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dossier partagé entre les deux sens d'une même langue
|
||||
/// (« <code>\commun »), frère du dossier examiné. Un modèle familial
|
||||
/// (<c>opus-mt-tc-big-itc-itc</c>) y range ses poids une seule fois, et chaque
|
||||
/// sens ne porte que son <c>modele.json</c>. Absent pour un pack bilingue.
|
||||
/// </summary>
|
||||
private string DossierCommun
|
||||
{
|
||||
get
|
||||
{
|
||||
string? parent = Path.GetDirectoryName(_dossier);
|
||||
return string.IsNullOrEmpty(parent)
|
||||
? string.Empty
|
||||
: Path.Combine(parent, LanguageModelDirections.Commun);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Répertoire racine des packs sur l'appareil — ici, le dossier examiné.</summary>
|
||||
public string Racine => _dossier;
|
||||
|
||||
/// <summary>
|
||||
/// Aucun manifeste serveur n'est disponible hors application : le retour
|
||||
/// <see langword="null"/> est l'état « mémoire d'index absente », déjà géré
|
||||
/// par les appelants.
|
||||
/// </summary>
|
||||
public Task<LanguageManifestResponse?> GetIndexAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<LanguageManifestResponse?>(null);
|
||||
|
||||
public Task EnregistrerIndexAsync(LanguageManifestResponse manifeste, CancellationToken cancellationToken = default)
|
||||
=> throw LectureSeule();
|
||||
|
||||
public Task<PackLangueInstalle?> GetPackInstalleAsync(string code, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<PackLangueInstalle?>(DecrirePack());
|
||||
|
||||
public Task<IReadOnlyList<PackLangueInstalle>> ListerPacksInstallesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var packs = new List<PackLangueInstalle>();
|
||||
if (Directory.Exists(_dossier))
|
||||
{
|
||||
packs.Add(DecrirePack());
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<PackLangueInstalle>>(packs);
|
||||
}
|
||||
|
||||
public Task EnregistrerPackAsync(PackLangueInstalle pack, CancellationToken cancellationToken = default)
|
||||
=> throw LectureSeule();
|
||||
|
||||
public Task SupprimerPackAsync(string code, CancellationToken cancellationToken = default)
|
||||
=> throw LectureSeule();
|
||||
|
||||
public string PreparerFichierPartiel(string code, string sens, string nomFichier)
|
||||
=> throw LectureSeule();
|
||||
|
||||
/// <summary>
|
||||
/// Décrit ce qui est <b>réellement présent</b> : c'est cette description qui
|
||||
/// décide des fichiers que le service ouvrira, donc ce que l'outil mesure.
|
||||
/// </summary>
|
||||
public PackLangueInstalle DecrirePack()
|
||||
{
|
||||
var fichiers = new List<FichierModeleInstalle>();
|
||||
|
||||
if (Directory.Exists(_dossier))
|
||||
{
|
||||
// Tri par nom : deux exécutions successives doivent produire exactement le même
|
||||
// inventaire, sans quoi une comparaison entre deux mesures n'aurait aucun sens.
|
||||
string[] chemins = Directory.GetFiles(_dossier);
|
||||
Array.Sort(chemins, StringComparer.Ordinal);
|
||||
|
||||
foreach (string chemin in chemins)
|
||||
{
|
||||
string nom = Path.GetFileName(chemin);
|
||||
if (!nom.EndsWith(".spm", StringComparison.OrdinalIgnoreCase)
|
||||
&& !nom.EndsWith(".onnx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
fichiers.Add(new FichierModeleInstalle
|
||||
{
|
||||
Sens = _sens,
|
||||
NomFichier = nom,
|
||||
TailleOctets = new FileInfo(chemin).Length
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Poids partagés d'un modèle familial : le service les résout APRÈS le
|
||||
// dossier du sens (voir OnnxTranslationService.FichiersDuSens). Les
|
||||
// omettre ferait déclarer « pack incomplet » un pack familial valide.
|
||||
string commun = DossierCommun;
|
||||
if (commun.Length > 0 && Directory.Exists(commun))
|
||||
{
|
||||
string[] cheminsCommuns = Directory.GetFiles(commun);
|
||||
Array.Sort(cheminsCommuns, StringComparer.Ordinal);
|
||||
|
||||
foreach (string chemin in cheminsCommuns)
|
||||
{
|
||||
string nom = Path.GetFileName(chemin);
|
||||
if (!nom.EndsWith(".spm", StringComparison.OrdinalIgnoreCase)
|
||||
&& !nom.EndsWith(".onnx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
fichiers.Add(new FichierModeleInstalle
|
||||
{
|
||||
Sens = LanguageModelDirections.Commun,
|
||||
NomFichier = nom,
|
||||
TailleOctets = new FileInfo(chemin).Length
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new PackLangueInstalle
|
||||
{
|
||||
Code = _code,
|
||||
// Le libellé vient du manifeste serveur, absent ici ; il n'est lu par aucun appel du
|
||||
// pipeline de traduction.
|
||||
Libelle = _code,
|
||||
Version = string.Empty,
|
||||
InstalleLeUtc = DateTimeOffset.UtcNow,
|
||||
Fichiers = fichiers
|
||||
};
|
||||
}
|
||||
|
||||
public bool FichierPresent(string code, FichierModeleInstalle fichier)
|
||||
=> File.Exists(CheminFichier(code, fichier.Sens, fichier.NomFichier));
|
||||
|
||||
/// <summary>
|
||||
/// Vérifie la présence et la taille relevée à l'inventaire. L'empreinte
|
||||
/// SHA-256 n'est pas recalculée : le pack étant décrit depuis le disque
|
||||
/// lui-même, il n'existe aucune empreinte attendue à confronter.
|
||||
/// </summary>
|
||||
public Task<bool> VerifierFichierAsync(
|
||||
string code, FichierModeleInstalle fichier, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var info = new FileInfo(CheminFichier(code, fichier.Sens, fichier.NomFichier));
|
||||
bool conforme = info.Exists && (fichier.TailleOctets <= 0 || info.Length == fichier.TailleOctets);
|
||||
return Task.FromResult(conforme);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chemin réel d'un fichier du pack. Le nom est réduit à son nom simple,
|
||||
/// comme dans <c>LanguagePaths</c> : un nom de fichier ne doit jamais
|
||||
/// permettre de sortir du dossier examiné. Un fichier absent du dossier du
|
||||
/// sens est cherché dans son frère <c>commun</c> (poids partagé d'un modèle
|
||||
/// familial), en suivant exactement la priorité du service.
|
||||
/// </summary>
|
||||
public string CheminFichier(string code, string sens, string nomFichier)
|
||||
{
|
||||
string nom = Path.GetFileName(nomFichier);
|
||||
string chemin = Path.Combine(_dossier, nom);
|
||||
if (File.Exists(chemin))
|
||||
{
|
||||
return chemin;
|
||||
}
|
||||
|
||||
string commun = DossierCommun;
|
||||
if (commun.Length > 0)
|
||||
{
|
||||
string partage = Path.Combine(commun, nom);
|
||||
if (File.Exists(partage))
|
||||
{
|
||||
return partage;
|
||||
}
|
||||
}
|
||||
|
||||
return chemin;
|
||||
}
|
||||
|
||||
private static NotSupportedException LectureSeule()
|
||||
=> new("Cet outil de diagnostic ne modifie jamais le dossier examiné.");
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Omika.LanguesProbe;
|
||||
|
||||
/// <summary>
|
||||
/// Omika.LanguesProbe — vérifie un couple de traduction hors ligne (FR ⇄ ES)
|
||||
/// sur le poste de développement, à partir des fichiers réels du pack.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Pourquoi cet outil.</b> Tant que le symbole <c>OMIKA_ONNX_RUNTIME</c> n'est
|
||||
/// pas défini, le moteur d'inférence n'est embarqué dans aucune tête de
|
||||
/// livraison : contrôler un <c>vocab.spm</c> et un <c>*.onnx</c> réels
|
||||
/// (identifiants des jetons techniques, normalisation, aller-retour du
|
||||
/// découpage, traduction complète) supposait un déploiement sur téléphone. Ici
|
||||
/// les fichiers sont lus directement sur le disque : c'est le véhicule
|
||||
/// d'exécution qui permet de décider de l'activation du moteur à partir de
|
||||
/// mesures, et non de suppositions.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Aucun paquet NuGet supplémentaire</b> : l'outil n'utilise que du code déjà
|
||||
/// présent dans <c>Omika.App.Local</c>, qui compile et s'exécute sans ONNX
|
||||
/// Runtime.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Codes de sortie</b> : 0 succès, 1 erreur d'usage (argument manquant,
|
||||
/// option ou commande inconnue), 2 opération en échec (fichier absent,
|
||||
/// vocabulaire refusé, traduction échouée).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Version lue dans les métadonnées d'assembly, elle-même issue de
|
||||
/// <c>Directory.Build.props</c> : un littéral ici dériverait de la version de
|
||||
/// la solution sans que personne ne s'en aperçoive.
|
||||
/// </summary>
|
||||
private static readonly string VersionOutil =
|
||||
typeof(Program).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||
?? typeof(Program).Assembly.GetName().Version?.ToString()
|
||||
?? "version inconnue";
|
||||
|
||||
// Motif des chemins absolus (Windows « C:\a\b » comme Unix « /a/b »), réduits au seul nom de
|
||||
// fichier dans les messages : un diagnostic ne doit pas divulguer l'arborescence de la machine.
|
||||
private static readonly Regex CheminAbsolu = new(@"(?:[A-Za-z]:)?(?:[\\/][^\\/\s""']+){2,}", RegexOptions.Compiled);
|
||||
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Executer(args);
|
||||
}
|
||||
catch (AideDemandeeException)
|
||||
{
|
||||
// « --aide » demandé au milieu d'une commande : ce n'est pas une erreur.
|
||||
AfficherAide();
|
||||
return CodesSortie.Succes;
|
||||
}
|
||||
catch (ErreurUsageException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"ERREUR D'USAGE : {ex.Message}");
|
||||
Console.Error.WriteLine("Utilisez « --aide » pour la liste des commandes.");
|
||||
return CodesSortie.ErreurUsage;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Aucune trace technique ne sort d'ici : le message d'une exception .NET peut contenir
|
||||
// le chemin absolu d'un fichier. Le nom de fichier seul suffit à agir.
|
||||
Console.Error.WriteLine($"ÉCHEC : {MessageSansChemin(ex.Message)}");
|
||||
return CodesSortie.OperationEchouee;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nom de fichier seul, pour tout message destiné à l'écran. Retourne une
|
||||
/// mention neutre plutôt que le chemin brut quand le chemin n'a pas de nom
|
||||
/// exploitable (chemin racine, chemin terminé par un séparateur).
|
||||
/// </summary>
|
||||
internal static string NomFichier(string chemin)
|
||||
{
|
||||
string nom = Path.GetFileName(chemin.TrimEnd('\\', '/'));
|
||||
return string.IsNullOrEmpty(nom) ? "(élément sans nom)" : nom;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remplace les chemins absolus d'un message par leur seul nom de fichier.
|
||||
/// Les messages de ce dépôt évitent déjà les chemins, mais ceux du framework
|
||||
/// (<c>IOException</c>, <c>FileNotFoundException</c>) n'en donnent aucune
|
||||
/// garantie : le filtre est donc appliqué à la frontière, une seule fois.
|
||||
/// </summary>
|
||||
internal static string MessageSansChemin(string message)
|
||||
=> CheminAbsolu.Replace(message, m => Path.GetFileName(m.Value));
|
||||
|
||||
/// <summary>
|
||||
/// Rappel actionnable quand aucun moteur d'inférence n'est utilisable : sans
|
||||
/// lui, l'échec serait incompréhensible alors que c'est l'état attendu de la
|
||||
/// machine de développement.
|
||||
/// </summary>
|
||||
internal static void AfficherMoteurAbsent()
|
||||
{
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine(" La traduction ne peut pas être calculée : le moteur d'inférence n'est pas");
|
||||
Console.Error.WriteLine(" compilé dans Omika.App.Local (symbole OMIKA_ONNX_RUNTIME absent).");
|
||||
Console.Error.WriteLine(" → Activer le moteur : docs\\VALIDATION_TRADUCTION_ONNX.md");
|
||||
Console.Error.WriteLine(" (procédure et état actuels : docs\\TRADUCTION_HORS_LIGNE_MOBILE.md, §7)");
|
||||
Console.Error.WriteLine(" → « inspecter » et « encoder » restent utilisables sans moteur.");
|
||||
}
|
||||
|
||||
private static int Executer(string[] args)
|
||||
{
|
||||
PreparerConsole();
|
||||
AfficherEnTete();
|
||||
|
||||
if (args.Length == 0)
|
||||
{
|
||||
// L'aide est la réponse utile, mais l'appel est incomplet : le code de sortie le dit,
|
||||
// pour qu'un script distingue « commande absente » de « commande réussie ».
|
||||
AfficherAide();
|
||||
return CodesSortie.ErreurUsage;
|
||||
}
|
||||
|
||||
string commande = args[0].Trim().ToLowerInvariant();
|
||||
string[] reste = args[1..];
|
||||
|
||||
switch (commande)
|
||||
{
|
||||
case "--aide" or "-h" or "aide":
|
||||
AfficherAide();
|
||||
return CodesSortie.Succes;
|
||||
case "inspecter":
|
||||
return InspectionVocabulaire.Inspecter(reste);
|
||||
case "encoder":
|
||||
return InspectionVocabulaire.Encoder(reste);
|
||||
case "traduire":
|
||||
return TraductionEssai.Traduire(reste);
|
||||
default:
|
||||
Console.Error.WriteLine($"ERREUR D'USAGE : commande inconnue « {args[0]} ».");
|
||||
AfficherAide();
|
||||
return CodesSortie.ErreurUsage;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sortie en UTF-8 : les messages sont en français et l'affichage des pièces
|
||||
/// utilise le marqueur d'espace ▁ du modèle. Sans cela, une console Windows
|
||||
/// restée sur une page de codes héritée rendrait des caractères de
|
||||
/// remplacement — un diagnostic illisible ne sert à rien. Un échec est
|
||||
/// ignoré : une sortie redirigée n'a pas de console à reconfigurer.
|
||||
/// </summary>
|
||||
private static void PreparerConsole()
|
||||
{
|
||||
try
|
||||
{
|
||||
Console.OutputEncoding = System.Text.Encoding.UTF8;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Aucune console interactive : l'encodage demandé par l'appelant fait foi.
|
||||
}
|
||||
}
|
||||
|
||||
private static void AfficherEnTete()
|
||||
=> Console.WriteLine(
|
||||
$"Omika.LanguesProbe {VersionOutil} — validation d'un couple de traduction hors ligne (FR ⇄ ES), "
|
||||
+ "sans déploiement." + Environment.NewLine);
|
||||
|
||||
private static void AfficherAide()
|
||||
{
|
||||
Console.WriteLine(
|
||||
"""
|
||||
COMMANDES
|
||||
inspecter <vocab.spm>
|
||||
Lit un vocabulaire SentencePiece réel et affiche : nombre de pièces,
|
||||
identifiants des jetons <unk> / <s> / </s> / <pad> (« absent » vaut -1),
|
||||
profil de normalisation, repli octet, avertissements du tokeniseur, puis un
|
||||
aller-retour texte → identifiants → texte sur des phrases françaises et
|
||||
espagnoles. Ne nécessite AUCUN moteur ONNX.
|
||||
|
||||
encoder <vocab.spm> <texte…>
|
||||
Découpe un texte et affiche chaque identifiant avec la pièce qu'il porte, dans
|
||||
l'ordre. Sert à comparer avec un tokeniseur de référence (sentencepiece,
|
||||
transformers) pour localiser une divergence de découpage.
|
||||
Le texte peut être cité entre guillemets ou donné en plusieurs arguments.
|
||||
|
||||
traduire --dossier <dossierDuPack> --texte "<texte>" [--sens <sens>] [--code <code>]
|
||||
Décrit le pack RÉELLEMENT présent dans <dossierDuPack> (fichiers *.spm et
|
||||
*.onnx), puis exécute le pipeline complet de OnnxTranslationService : résultat,
|
||||
temps de chargement du vocabulaire, temps de chargement de chaque modèle et
|
||||
durée de la traduction. Le dossier est traité comme « langues/es/<sens> » :
|
||||
ses fichiers sont lus tels quels, rien n'y est écrit ni modifié.
|
||||
--sens versFrancais (défaut) | depuisFrancais
|
||||
--code code de langue du pack (défaut : es)
|
||||
--texte texte à traduire (obligatoire)
|
||||
|
||||
--aide, -h
|
||||
Affiche cette aide.
|
||||
|
||||
CODES DE SORTIE
|
||||
0 succès
|
||||
1 erreur d'usage (argument manquant, option ou commande inconnue)
|
||||
2 opération en échec (fichier absent, vocabulaire refusé, traduction échouée)
|
||||
|
||||
SANS MOTEUR ONNX RUNTIME
|
||||
Le moteur n'est compilé que sous le symbole OMIKA_ONNX_RUNTIME (voir
|
||||
docs\VALIDATION_TRADUCTION_ONNX.md ; état actuel : docs\TRADUCTION_HORS_LIGNE_MOBILE.md,
|
||||
§7). « inspecter » et « encoder » fonctionnent sans lui ; « traduire » explique alors
|
||||
que la traduction est indisponible, sans jamais afficher de trace technique.
|
||||
|
||||
EXEMPLES
|
||||
Omika.LanguesProbe inspecter "C:\packs\es\versFrancais\vocab.spm"
|
||||
Omika.LanguesProbe encoder "C:\packs\es\versFrancais\vocab.spm" "Bonjour, comment allez-vous ?"
|
||||
Omika.LanguesProbe traduire --dossier "C:\packs\es\versFrancais" --texte "La factura se enviará mañana."
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Codes de sortie du programme, énoncés aussi par <c>--aide</c>.</summary>
|
||||
internal static class CodesSortie
|
||||
{
|
||||
internal const int Succes = 0;
|
||||
internal const int ErreurUsage = 1;
|
||||
internal const int OperationEchouee = 2;
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using Omika.App.Local.Abstractions;
|
||||
using Omika.App.Local.Infrastructure;
|
||||
using Omika.App.Local.Models;
|
||||
using Omika.App.Local.Services;
|
||||
using Omika.Core.Contracts.Mobile.Languages;
|
||||
|
||||
namespace Omika.LanguesProbe;
|
||||
|
||||
/// <summary>
|
||||
/// Commande « traduire » : décrit le pack réellement présent sur le disque, puis
|
||||
/// exécute le <b>vrai</b> pipeline de traduction
|
||||
/// (<c>OnnxTranslationService</c>) hors d'un téléphone.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Le moteur est retrouvé par réflexion, et c'est délibéré.</b>
|
||||
/// <c>OnnxRuntimeInferenceSessionFactory</c> n'existe dans l'assembly
|
||||
/// <c>Omika.App.Local</c> que si le symbole <c>OMIKA_ONNX_RUNTIME</c> y est
|
||||
/// défini : une référence directe ne compilerait pas tant que le moteur n'est
|
||||
/// pas embarqué, ce qui interdirait justement d'utiliser cet outil <b>avant</b>
|
||||
/// de l'activer. La réflexion laisse donc l'outil compiler dans les deux états,
|
||||
/// sans référencer <c>Microsoft.ML.OnnxRuntime</c>, et fait basculer cette
|
||||
/// commande toute seule le jour où le moteur est activé.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class TraductionEssai
|
||||
{
|
||||
private const string NomTypeMoteur = "Omika.App.Local.Services.OnnxRuntimeInferenceSessionFactory";
|
||||
|
||||
public static int Traduire(string[] args)
|
||||
{
|
||||
Arguments arguments = Arguments.Analyser(args, "dossier", "sens", "texte", "code");
|
||||
string dossier = arguments.Option("dossier")
|
||||
?? throw new ErreurUsageException("option obligatoire manquante : --dossier <dossierDuPack>");
|
||||
string texte = arguments.Option("texte")
|
||||
?? throw new ErreurUsageException("option obligatoire manquante : --texte \"<texte>\"");
|
||||
string code = arguments.Option("code", "es");
|
||||
string sensDemande = arguments.Option("sens", LanguageModelDirections.VersFrancais);
|
||||
|
||||
if (!LanguagePaths.EstCodeValide(code))
|
||||
{
|
||||
throw new ErreurUsageException(
|
||||
$"code de langue invalide « {code} » (minuscules ASCII et tirets, 2 à 10 caractères : es, pt-br)");
|
||||
}
|
||||
|
||||
SensTraduction? sens = LireSens(sensDemande)
|
||||
?? throw new ErreurUsageException(
|
||||
$"sens inconnu « {sensDemande} » : attendu {LanguageModelDirections.VersFrancais} "
|
||||
+ $"ou {LanguageModelDirections.DepuisFrancais}");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(texte))
|
||||
{
|
||||
throw new ErreurUsageException("le texte à traduire est vide");
|
||||
}
|
||||
|
||||
// Le dossier est traité comme « langues/<code>/<sens> » : c'est le contrat de la commande.
|
||||
if (!Directory.Exists(dossier))
|
||||
{
|
||||
Console.Error.WriteLine($"ÉCHEC : le dossier « {Program.NomFichier(dossier)} » est introuvable.");
|
||||
return CodesSortie.OperationEchouee;
|
||||
}
|
||||
|
||||
string sensCode = sens.Value.VersCode();
|
||||
var magasin = new PackLangueDossier(dossier, code, sensCode);
|
||||
PackLangueInstalle pack = magasin.DecrirePack();
|
||||
|
||||
Console.WriteLine("── Pack présent sur le disque ──");
|
||||
Console.WriteLine($" Dossier : {Program.NomFichier(dossier)} Langue : {code} Sens : {sensCode}");
|
||||
|
||||
if (pack.Fichiers.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("ÉCHEC : ce dossier ne contient aucun fichier .spm ni .onnx.");
|
||||
return CodesSortie.OperationEchouee;
|
||||
}
|
||||
|
||||
foreach (FichierModeleInstalle fichier in pack.Fichiers)
|
||||
{
|
||||
Console.WriteLine($" {fichier.NomFichier,-22} {LangueDisponible.FormaterTaille(fichier.TailleOctets),10}");
|
||||
}
|
||||
|
||||
// L'inventaire ne dit pas QUEL fichier le service retiendra : le rappeler évite de chercher
|
||||
// longtemps pourquoi « encoder_v2.onnx » n'est pas celui qui a été chargé.
|
||||
Console.WriteLine($" Priorité du service : {OnnxTranslationService.FichierVocabulaire}, "
|
||||
+ $"{OnnxTranslationService.FichierEncodeur}, puis {OnnxTranslationService.FichierDecodeur} "
|
||||
+ "(sinon le premier fichier du dossier)");
|
||||
|
||||
// Un jeton de cible mal déclaré produirait la mauvaise langue SANS erreur :
|
||||
// l'afficher est la première chose à vérifier quand une traduction « sonne faux ».
|
||||
AfficherMetadonnees(magasin, code, sensCode);
|
||||
|
||||
FichierModeleInstalle? vocabulaire = VocabulaireRetenu(pack);
|
||||
if (vocabulaire is null)
|
||||
{
|
||||
Console.Error.WriteLine("ÉCHEC : aucun vocabulaire .spm dans ce dossier.");
|
||||
return CodesSortie.OperationEchouee;
|
||||
}
|
||||
|
||||
var chrono = Stopwatch.StartNew();
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("── Vocabulaire ──");
|
||||
|
||||
SentencePieceTokenizer tokenizer;
|
||||
try
|
||||
{
|
||||
// Chargé ici en plus du service (qui le recharge pour lui) : cela donne le temps réel du
|
||||
// fichier et fait échouer la commande AVANT le chargement des modèles, qui coûte
|
||||
// autrement plus cher qu'un diagnostic de vocabulaire.
|
||||
tokenizer = SentencePieceTokenizer.Charger(magasin.CheminFichier(code, sensCode, vocabulaire.NomFichier));
|
||||
}
|
||||
catch (SentencePieceException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"ÉCHEC : {Program.MessageSansChemin(ex.Message)}");
|
||||
return CodesSortie.OperationEchouee;
|
||||
}
|
||||
|
||||
chrono.Stop();
|
||||
Console.WriteLine($" {vocabulaire.NomFichier} chargé en {chrono.ElapsedMilliseconds} ms — "
|
||||
+ $"{tokenizer.TailleVocabulaire} pièces, {tokenizer.Avertissements.Count} avertissement(s)");
|
||||
Console.WriteLine($" Normalisation : {tokenizer.ProfilNormalisation}");
|
||||
Console.WriteLine(" Détail (aller-retour, repli octet, pièces) : commandes « inspecter » et « encoder ».");
|
||||
|
||||
IOnnxInferenceSessionFactory fabrique = ResoudreFabrique();
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("── Moteur d'inférence ──");
|
||||
Console.WriteLine($" Moteur : {fabrique.NomMoteur}");
|
||||
Console.WriteLine($" Disponible : {(fabrique.EstDisponible ? "oui" : "non")}");
|
||||
|
||||
if (!fabrique.EstDisponible)
|
||||
{
|
||||
Program.AfficherMoteurAbsent();
|
||||
return CodesSortie.OperationEchouee;
|
||||
}
|
||||
|
||||
// Le service est libéré en fin de commande (ses sessions ONNX avec lui) : un modèle de
|
||||
// plusieurs dizaines de Mo ne doit pas survivre à un outil de diagnostic.
|
||||
using var service = new OnnxTranslationService(magasin, fabrique, new OnnxTranslationOptions());
|
||||
if (!service.EstInstalleeAsync(code, sens.Value).GetAwaiter().GetResult())
|
||||
{
|
||||
Console.Error.WriteLine("ÉCHEC : le service ne reconnaît pas ce dossier comme un pack exploitable");
|
||||
Console.Error.WriteLine(" (vocabulaire ou modèle .onnx manquant pour ce sens).");
|
||||
return CodesSortie.OperationEchouee;
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("── Chargement des modèles (session jetable, libérée aussitôt) ──");
|
||||
foreach (FichierModeleInstalle fichier in pack.Fichiers)
|
||||
{
|
||||
if (fichier.NomFichier.EndsWith(".onnx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
MesurerChargementModele(fabrique, magasin, code, sensCode, fichier);
|
||||
}
|
||||
}
|
||||
|
||||
// Écart le plus fréquent entre deux exports d'un même modèle : les noms d'entrées/sorties.
|
||||
// Le service les lit dans OnnxTranslationOptions ; les afficher rend l'écart visible.
|
||||
Console.WriteLine(" Noms attendus par OnnxTranslationOptions : input_ids, attention_mask,");
|
||||
Console.WriteLine(" decoder_input_ids, encoder_hidden_states, encoder_attention_mask, logits.");
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("── Traduction ──");
|
||||
Console.WriteLine($" Sens : {sensCode} ({LibelleSens(sens.Value)})");
|
||||
Console.WriteLine($" Source : {texte}");
|
||||
|
||||
chrono.Restart();
|
||||
try
|
||||
{
|
||||
string traduction = service.TraduireAsync(texte, code, sens.Value).GetAwaiter().GetResult();
|
||||
chrono.Stop();
|
||||
Console.WriteLine($" Résultat : {traduction}");
|
||||
Console.WriteLine($" Durée : {chrono.ElapsedMilliseconds} ms (chargement des modèles et inférence compris)");
|
||||
return CodesSortie.Succes;
|
||||
}
|
||||
catch (TranslationException ex)
|
||||
{
|
||||
chrono.Stop();
|
||||
Console.Error.WriteLine($"ÉCHEC : {Program.MessageSansChemin(ex.Message)}");
|
||||
|
||||
// Moteur absent au premier appel (bibliothèque native manquante, symbole non défini) :
|
||||
// c'est l'échec attendu d'une machine de développement, pas un défaut du pack.
|
||||
if (ex.Message.Contains("indisponible", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Program.AfficherMoteurAbsent();
|
||||
}
|
||||
|
||||
return CodesSortie.OperationEchouee;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ouvre un modèle pour en mesurer le seul chargement, et publier au
|
||||
/// passage les noms d'entrées/sorties réels : un modèle corrompu est ainsi
|
||||
/// désigné nommément, avant la traduction.
|
||||
/// </summary>
|
||||
private static void MesurerChargementModele(
|
||||
IOnnxInferenceSessionFactory fabrique,
|
||||
PackLangueDossier magasin,
|
||||
string code,
|
||||
string sens,
|
||||
FichierModeleInstalle fichier)
|
||||
{
|
||||
var chrono = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using IOnnxInferenceSession session = fabrique.Ouvrir(magasin.CheminFichier(code, sens, fichier.NomFichier));
|
||||
chrono.Stop();
|
||||
Console.WriteLine($" {fichier.NomFichier,-22} {chrono.ElapsedMilliseconds,6} ms");
|
||||
Console.WriteLine($" entrées : {string.Join(", ", session.NomsEntrees)}");
|
||||
Console.WriteLine($" sorties : {string.Join(", ", session.NomsSorties)}");
|
||||
}
|
||||
catch (TranslationException ex)
|
||||
{
|
||||
chrono.Stop();
|
||||
Console.WriteLine($" {fichier.NomFichier,-22} non mesurable : {Program.MessageSansChemin(ex.Message)}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fabrique d'inférence réellement disponible : celle du moteur si le
|
||||
/// symbole <c>OMIKA_ONNX_RUNTIME</c> est défini dans
|
||||
/// <c>Omika.App.Local</c>, sinon le repli de l'application — qui répond
|
||||
/// « indisponible » au lieu de planter.
|
||||
/// </summary>
|
||||
private static IOnnxInferenceSessionFactory ResoudreFabrique()
|
||||
{
|
||||
try
|
||||
{
|
||||
Type? typeMoteur = typeof(OnnxTranslationService).Assembly.GetType(NomTypeMoteur, throwOnError: false);
|
||||
object? instance = typeMoteur
|
||||
?.GetProperty("Instance", BindingFlags.Public | BindingFlags.Static)
|
||||
?.GetValue(null);
|
||||
|
||||
if (instance is IOnnxInferenceSessionFactory fabrique)
|
||||
{
|
||||
return fabrique;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Le type existe mais sa bibliothèque native manque : ce n'est pas une erreur de l'outil,
|
||||
// c'est exactement l'état « moteur indisponible » que le repli représente.
|
||||
}
|
||||
|
||||
return UnavailableOnnxInferenceSessionFactory.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Affiche le jeton de cible que le pack déclare (<c>modele.json</c>), tel que
|
||||
/// le moteur l'enverra réellement au modèle. Un fichier absent est l'état
|
||||
/// normal d'un modèle dédié à une paire ; un fichier illisible est signalé
|
||||
/// ici plutôt que découvert au milieu d'une traduction.
|
||||
/// </summary>
|
||||
private static void AfficherMetadonnees(PackLangueDossier magasin, string code, string sens)
|
||||
{
|
||||
string chemin = magasin.CheminFichier(code, sens, ModelePackMetadonnees.NomFichier);
|
||||
if (!File.Exists(chemin))
|
||||
{
|
||||
Console.WriteLine(" Jeton cible : aucun (modèle dédié à une paire)");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ModelePackMetadonnees? metadonnees = ModelePackMetadonnees.DepuisJson(File.ReadAllText(chemin));
|
||||
if (metadonnees is null)
|
||||
{
|
||||
Console.WriteLine($" Jeton cible : aucun ({ModelePackMetadonnees.NomFichier} vide)");
|
||||
return;
|
||||
}
|
||||
|
||||
string modele = string.IsNullOrEmpty(metadonnees.ModeleSource) ? "non déclaré" : metadonnees.ModeleSource;
|
||||
string jeton = metadonnees.AjouteUnJeton ? metadonnees.JetonCible : "aucun";
|
||||
Console.WriteLine($" Jeton cible : {jeton} (modèle {modele})");
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
Console.WriteLine($" Jeton cible : ILLISIBLE — {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vocabulaire que le service retiendra : <c>vocab.spm</c> s'il est présent,
|
||||
/// sinon le premier <c>.spm</c> du dossier — même règle que
|
||||
/// <c>OnnxTranslationService</c>, reproduite ici uniquement pour que le temps
|
||||
/// mesuré porte sur le fichier réellement utilisé.
|
||||
/// </summary>
|
||||
private static FichierModeleInstalle? VocabulaireRetenu(PackLangueInstalle pack)
|
||||
{
|
||||
FichierModeleInstalle? premier = null;
|
||||
|
||||
foreach (FichierModeleInstalle fichier in pack.Fichiers)
|
||||
{
|
||||
if (!fichier.NomFichier.EndsWith(".spm", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(fichier.NomFichier, OnnxTranslationService.FichierVocabulaire, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return fichier;
|
||||
}
|
||||
|
||||
premier ??= fichier;
|
||||
}
|
||||
|
||||
return premier;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sens demandé, comparé aux valeurs <b>canoniques</b> du manifeste plutôt
|
||||
/// qu'à des chaînes écrites à la main. La casse est tolérée parce que le sens
|
||||
/// est aussi tapé à la main sur un poste de développement.
|
||||
/// </summary>
|
||||
private static SensTraduction? LireSens(string valeur)
|
||||
{
|
||||
foreach (SensTraduction sens in Enum.GetValues<SensTraduction>())
|
||||
{
|
||||
if (string.Equals(sens.VersCode(), valeur, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return sens;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Rappel du rôle métier de chaque sens (dictée ou lecture), pour lever tout doute.</summary>
|
||||
private static string LibelleSens(SensTraduction sens)
|
||||
=> sens == SensTraduction.VersFrancais
|
||||
? "langue secondaire → français (dictée)"
|
||||
: "français → langue secondaire (lecture)";
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>Omika.LieuProbe</AssemblyName>
|
||||
<RootNamespace>Omika.LieuProbe</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Data.OleDb" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,98 @@
|
||||
// Probe LECTURE SEULE ciblé : déterminer si le fichier "Lieu" existe dans BATIK_TEST.
|
||||
// Aucune écriture. Source de vérité : la base réelle via OLE DB PCSOFT.HFSQL.
|
||||
using System.Data.OleDb;
|
||||
|
||||
const string server = "127.0.0.1";
|
||||
const int port = 4900;
|
||||
const string user = "susana";
|
||||
const string password = "123";
|
||||
const string database = "BATIK_TEST";
|
||||
|
||||
string BuildConnStr() =>
|
||||
$"Provider=PCSOFT.HFSQL;Data Source={server}:{port};Initial Catalog={database};User ID={user};Password={password}";
|
||||
|
||||
Console.WriteLine($"=== Probe Lieu (base {database}) ===");
|
||||
|
||||
try
|
||||
{
|
||||
using var conn = new OleDbConnection(BuildConnStr());
|
||||
conn.Open();
|
||||
Console.WriteLine("Connexion OK.");
|
||||
|
||||
// 1) Lister les tables disponibles (schéma) pour voir si "Lieu" existe.
|
||||
Console.WriteLine("\n--- Tables disponibles (GetOleDbSchemaTable) ---");
|
||||
try
|
||||
{
|
||||
var tables = conn.GetOleDbSchemaTable(OleDbSchemaGuid.Tables, null);
|
||||
if (tables is null)
|
||||
{
|
||||
Console.WriteLine(" (aucune table retournée)");
|
||||
}
|
||||
else
|
||||
{
|
||||
var names = new List<string>();
|
||||
foreach (System.Data.DataRow row in tables.Rows)
|
||||
{
|
||||
if (row.Table.Columns.Contains("TABLE_NAME"))
|
||||
{
|
||||
names.Add(row["TABLE_NAME"]?.ToString() ?? "");
|
||||
}
|
||||
}
|
||||
names.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var n in names)
|
||||
{
|
||||
Console.WriteLine($" {n}");
|
||||
}
|
||||
Console.WriteLine($" Total: {names.Count}");
|
||||
Console.WriteLine($" Lieu présent: {(names.Contains("Lieu", StringComparer.OrdinalIgnoreCase) ? "OUI" : "NON")}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($" ERREUR listing tables: {ex.Message}");
|
||||
}
|
||||
|
||||
// 2) Tester la requête réelle FROM Lieu (contrôle de l'erreur 70114).
|
||||
Console.WriteLine("\n--- SELECT LI_PKID FROM Lieu ---");
|
||||
try
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT LI_PKID FROM Lieu";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
int rowCount = 0;
|
||||
while (reader.Read())
|
||||
{
|
||||
rowCount++;
|
||||
}
|
||||
Console.WriteLine($" rows={rowCount}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($" ERREUR: {ex.Message}");
|
||||
}
|
||||
|
||||
// 3) Contrôle : Client doit fonctionner (le snapshot Clients passe).
|
||||
Console.WriteLine("\n--- SELECT CL_PKID FROM Client (contrôle) ---");
|
||||
try
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT CL_PKID FROM Client";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
int rowCount = 0;
|
||||
while (reader.Read())
|
||||
{
|
||||
rowCount++;
|
||||
}
|
||||
Console.WriteLine($" rows={rowCount}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($" ERREUR: {ex.Message}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"ERREUR connexion: {ex.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine("\nProbe Lieu terminé (lecture seule).");
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>Omika.MessageExterneProbe</AssemblyName>
|
||||
<RootNamespace>Omika.MessageExterneProbe</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Data.OleDb" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,802 @@
|
||||
using System.Data.OleDb;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
// ============================================================
|
||||
// OMIKA MESSAGE EXTERNE PROBE — READ-ONLY DB
|
||||
// Audit structurel des corps HTML (MessageExterne.ME_Message) et des
|
||||
// modèles DOCX (MailModele.MM_MsgDoc) pour la compatibilité WinDev ↔ Omika.
|
||||
// Ne modifie AUCUNE donnée. N'affiche JAMAIS de contenu privé : le squelette
|
||||
// du body est affiché avec le texte masqué (seul le CSS générique WinDev et
|
||||
// la structure des balises/attributs sont montrés).
|
||||
// ============================================================
|
||||
|
||||
const string server = "127.0.0.1";
|
||||
const int port = 4900;
|
||||
const string user = "susana";
|
||||
const string pass = "123";
|
||||
|
||||
var dbMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["OMIKA_TEST"] = "A1BAF17C65BC565F2927BF1A85DAFEED",
|
||||
["BATIK_TEST"] = "E2FC3072E6145E1B20DA7321755CD607",
|
||||
};
|
||||
|
||||
int dbIdx = Array.IndexOf(args, "--db");
|
||||
string db = dbIdx >= 0 && dbIdx + 1 < args.Length ? args[dbIdx + 1] : "OMIKA_TEST";
|
||||
if (!dbMap.TryGetValue(db, out string? stdPwd))
|
||||
{
|
||||
Console.Error.WriteLine($"ERROR: unknown database '{db}'.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
string connStr =
|
||||
$"Provider=PCSOFT.HFSQL;Data Source={server}:{port};Initial Catalog={db};" +
|
||||
$"User ID={user};Password={pass};" +
|
||||
$"Extended Properties=\"Password=ParametreUtilisateur:{stdPwd}\"";
|
||||
|
||||
if (args.Contains("--list-messages")) return ListMessages(connStr, db);
|
||||
if (args.Contains("--list-models")) return ListModels(connStr, db);
|
||||
|
||||
int ipos = Array.IndexOf(args, "--collab-identity");
|
||||
if (ipos >= 0 && ipos + 1 < args.Length) return CollabIdentity(connStr, long.Parse(args[ipos + 1]));
|
||||
ipos = Array.IndexOf(args, "--smtp-config");
|
||||
if (ipos >= 0 && ipos + 1 < args.Length) return SmtpConfig(db, stdPwd, long.Parse(args[ipos + 1]));
|
||||
ipos = Array.IndexOf(args, "--smtp-resolution");
|
||||
if (ipos >= 0 && ipos + 1 < args.Length) return SmtpResolution(connStr, long.Parse(args[ipos + 1]));
|
||||
ipos = Array.IndexOf(args, "--image-dims");
|
||||
if (ipos >= 0 && ipos + 1 < args.Length) return ImageDims(connStr, long.Parse(args[ipos + 1]));
|
||||
ipos = Array.IndexOf(args, "--message-fields");
|
||||
if (ipos >= 0 && ipos + 1 < args.Length) return MessageFields(connStr, long.Parse(args[ipos + 1]));
|
||||
ipos = Array.IndexOf(args, "--smtp-keys");
|
||||
if (ipos >= 0 && ipos + 1 < args.Length) return SmtpKeys(connStr, long.Parse(args[ipos + 1]));
|
||||
|
||||
int pos = Array.IndexOf(args, "--analyze");
|
||||
if (pos >= 0 && pos + 1 < args.Length) return AnalyzeMessage(connStr, long.Parse(args[pos + 1]));
|
||||
pos = Array.IndexOf(args, "--model-structure");
|
||||
if (pos >= 0 && pos + 1 < args.Length) return ModelStructure(connStr, long.Parse(args[pos + 1]));
|
||||
|
||||
pos = Array.IndexOf(args, "--dump-message");
|
||||
if (pos >= 0 && pos + 2 < args.Length) return DumpMessage(connStr, long.Parse(args[pos + 1]), args[pos + 2]);
|
||||
pos = Array.IndexOf(args, "--dump-model");
|
||||
if (pos >= 0 && pos + 2 < args.Length) return DumpModel(connStr, long.Parse(args[pos + 1]), args[pos + 2]);
|
||||
|
||||
Console.Error.WriteLine("USAGE:");
|
||||
Console.Error.WriteLine(" --list-messages [--db OMIKA_TEST|BATIK_TEST]");
|
||||
Console.Error.WriteLine(" --list-models [--db ...]");
|
||||
Console.Error.WriteLine(" --collab-identity <COLLAB_PKID> [--db ...]");
|
||||
Console.Error.WriteLine(" --smtp-config <UT_PKID> [--db ...]");
|
||||
Console.Error.WriteLine(" --smtp-resolution <COLLAB_PKID> [--db ...]");
|
||||
Console.Error.WriteLine(" --image-dims <ME_PKID> [--db ...]");
|
||||
Console.Error.WriteLine(" --message-fields <ME_PKID> [--db ...]");
|
||||
Console.Error.WriteLine(" --smtp-keys <UT_PKID> [--db ...]");
|
||||
Console.Error.WriteLine(" --analyze <ME_PKID> [--db ...]");
|
||||
Console.Error.WriteLine(" --model-structure <MM_PKID> [--db ...]");
|
||||
Console.Error.WriteLine(" --dump-message <ME_PKID> <file> [--db ...]");
|
||||
Console.Error.WriteLine(" --dump-model <MM_PKID> <file> [--db ...]");
|
||||
return 2;
|
||||
|
||||
// ------------------------------------------------------------
|
||||
|
||||
/// <summary>Résout l'utilisateur lié à un collaborateur (UT_PKID) + nom du collaborateur.</summary>
|
||||
static int CollabIdentity(string connStr, long collabPkId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var conn = new OleDbConnection(connStr);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $"SELECT UT_PKID, UT_Nom, UT_Prenom, UT_Login FROM Utilisateur WHERE UT_IDCollaborateur = {collabPkId}";
|
||||
using var r = cmd.ExecuteReader();
|
||||
if (!r.Read())
|
||||
{
|
||||
Console.WriteLine("UTILISATEUR LIE AU COLLABORATEUR = NO");
|
||||
return 0;
|
||||
}
|
||||
Console.WriteLine("UTILISATEUR LIE AU COLLABORATEUR = YES");
|
||||
Console.WriteLine("UT_PKID = " + (r.IsDBNull(0) ? "(null)" : r.GetValue(0)?.ToString()));
|
||||
Console.WriteLine("UT_NOM = " + (r.IsDBNull(1) ? "(null)" : r.GetValue(1)?.ToString()));
|
||||
Console.WriteLine("UT_PRENOM = " + (r.IsDBNull(2) ? "(null)" : r.GetValue(2)?.ToString()));
|
||||
Console.WriteLine("UT_LOGIN = " + (r.IsDBNull(3) ? "(null)" : r.GetValue(3)?.ToString()));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("QUERY FAILED = " + ex.GetType().Name + " : " + ex.Message);
|
||||
return 3;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var conn = new OleDbConnection(connStr);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $"SELECT * FROM Collaborateur WHERE CL_PKID = {collabPkId}";
|
||||
using var r = cmd.ExecuteReader();
|
||||
if (r.Read())
|
||||
{
|
||||
for (int i = 0; i < r.FieldCount; i++)
|
||||
{
|
||||
string? name = r.GetName(i);
|
||||
if (name is "CL_Nom" or "CL_Prenom" or "CL_PKID")
|
||||
{
|
||||
Console.WriteLine(name + " = " + (r.IsDBNull(i) ? "(null)" : r.GetValue(i)?.ToString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("COLLABORATEUR = NOT FOUND (ou colonne PK différente de CL_PKID)");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("COLLAB QUERY FAILED = " + ex.GetType().Name + " : " + ex.Message);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reproduit EXACTEMENT la résolution SMTP côté API (lecture seule) :
|
||||
/// Collaborateur (CO_PKID, GUID) → UT_PKID (UT_IDCollaborateur) → ParametreUtilisateur
|
||||
/// (PU_IDUtilisateur, PU_Code LIKE 'EMAIL_ENVOI%'). N'affiche jamais le contenu
|
||||
/// des valeurs JSON ni aucun secret.
|
||||
/// </summary>
|
||||
static int SmtpResolution(string connStr, long collabPkId)
|
||||
{
|
||||
Console.WriteLine("COLLAB PKID = " + collabPkId);
|
||||
string? guid = null;
|
||||
long utPk = 0;
|
||||
try
|
||||
{
|
||||
using var conn = new OleDbConnection(connStr);
|
||||
conn.Open();
|
||||
|
||||
// 1. GUID physique du collaborateur (résolution Omika : WHERE GUID = ?).
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = $"SELECT CO_PKID, GUID FROM Collaborateur WHERE CO_PKID = {collabPkId}";
|
||||
using var r = cmd.ExecuteReader();
|
||||
if (!r.Read())
|
||||
{
|
||||
Console.WriteLine("COLLABORATEUR FOUND = NO (CO_PKID introuvable)");
|
||||
return 0;
|
||||
}
|
||||
guid = r.IsDBNull(1) ? null : r.GetValue(1)?.ToString();
|
||||
Console.WriteLine("COLLABORATEUR FOUND = YES");
|
||||
Console.WriteLine("CO_PKID = " + (r.IsDBNull(0) ? "(null)" : r.GetValue(0)?.ToString()));
|
||||
Console.WriteLine("GUID RAW = " + (guid is null ? "(null)" : $"[{guid}] len={guid.Length}"));
|
||||
}
|
||||
|
||||
// Guid.TryParse + reserialize "D" = ce que l'API fait (PhysicalGuidParser → ToString("D")).
|
||||
string? guidD = null;
|
||||
if (guid is not null && Guid.TryParse(guid, out Guid g))
|
||||
{
|
||||
guidD = g.ToString("D");
|
||||
Console.WriteLine("GUID PARSED (D) = " + guidD);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("GUID PARSED (D) = (non analysable)");
|
||||
}
|
||||
|
||||
// 2. Résolution GUID → CO_PKID (requête de ResolveCollaborateurPkAsync).
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT CO_PKID FROM Collaborateur WHERE GUID = ?";
|
||||
cmd.Parameters.Add(new OleDbParameter("?", guidD ?? (object)DBNull.Value));
|
||||
object? v = cmd.ExecuteScalar();
|
||||
Console.WriteLine("RESOLVE GUID→CO_PKID = " + (v is null or DBNull ? "NO MATCH" : v.ToString()));
|
||||
}
|
||||
|
||||
// 3. Résolution UT_PKID (UT_IDCollaborateur = CO_PKID).
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = $"SELECT UT_PKID FROM Utilisateur WHERE UT_IDCollaborateur = {collabPkId}";
|
||||
object? v = cmd.ExecuteScalar();
|
||||
utPk = v is null or DBNull ? 0 : Convert.ToInt64(v);
|
||||
Console.WriteLine("RESOLVE UT_PKID = " + (utPk > 0 ? utPk.ToString() : "NO MATCH"));
|
||||
}
|
||||
|
||||
// 4. Configurations SMTP (PU_IDUtilisateur = UT_PKID).
|
||||
if (utPk <= 0)
|
||||
{
|
||||
Console.WriteLine("SMTP ROWS = 0 (UT_PKID non résolu)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = $"SELECT PU_Code FROM ParametreUtilisateur WHERE PU_IDUtilisateur = {utPk} AND PU_Code LIKE 'EMAIL_ENVOI%' ORDER BY PU_Code";
|
||||
using var r = cmd.ExecuteReader();
|
||||
var codes = new List<string>();
|
||||
while (r.Read())
|
||||
{
|
||||
codes.Add(r.GetValue(0)?.ToString() ?? "");
|
||||
}
|
||||
Console.WriteLine("SMTP ROWS = " + codes.Count);
|
||||
Console.WriteLine("SMTP CODES = " + string.Join(", ", codes));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("QUERY FAILED = " + ex.GetType().Name + " : " + ex.Message);
|
||||
return 3;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dimensions de l'image inline d'un message (lecture seule) : attributs width/height
|
||||
/// de la balise <img> vs dimensions intrinsèques du PNG/JPEG embarqué (data URI).
|
||||
/// N'affiche JAMAIS l'image ni son contenu base64, uniquement des entiers.
|
||||
/// </summary>
|
||||
static int ImageDims(string connStr, long pk)
|
||||
{
|
||||
string? msg = ReadMessage(connStr, pk);
|
||||
if (msg is null) { Console.WriteLine("MESSAGE FOUND = NO"); return 4; }
|
||||
|
||||
Match img = Regex.Match(msg, "<img\\b[^>]*>", RegexOptions.IgnoreCase);
|
||||
if (!img.Success) { Console.WriteLine("IMG TAG = NO"); return 0; }
|
||||
|
||||
Console.WriteLine("IMG TAG = YES");
|
||||
Match w = Regex.Match(img.Value, "width\\s*=\\s*[\"']?(\\d+)", RegexOptions.IgnoreCase);
|
||||
Match h = Regex.Match(img.Value, "height\\s*=\\s*[\"']?(\\d+)", RegexOptions.IgnoreCase);
|
||||
Console.WriteLine("IMG TAG WIDTH = " + (w.Success ? w.Groups[1].Value : "(absent)"));
|
||||
Console.WriteLine("IMG TAG HEIGHT = " + (h.Success ? h.Groups[1].Value : "(absent)"));
|
||||
|
||||
Match src = Regex.Match(img.Value, "src\\s*=\\s*\"(data:[^\"]+)\"", RegexOptions.IgnoreCase);
|
||||
if (!src.Success)
|
||||
{
|
||||
Console.WriteLine("DATA URI = NO");
|
||||
return 0;
|
||||
}
|
||||
|
||||
string dataUri = src.Groups[1].Value;
|
||||
int comma = dataUri.IndexOf(',');
|
||||
string meta = comma > 0 ? dataUri[..comma] : "";
|
||||
bool isPng = meta.Contains("png", StringComparison.OrdinalIgnoreCase);
|
||||
bool isJpeg = meta.Contains("jpeg", StringComparison.OrdinalIgnoreCase) || meta.Contains("jpg", StringComparison.OrdinalIgnoreCase);
|
||||
Console.WriteLine("IMAGE FORMAT = " + (isPng ? "PNG" : isJpeg ? "JPEG" : "AUTRE"));
|
||||
|
||||
try
|
||||
{
|
||||
byte[] bytes = Convert.FromBase64String(dataUri[(comma + 1)..]);
|
||||
if (isPng && bytes.Length >= 24)
|
||||
{
|
||||
int wpx = (bytes[16] << 24) | (bytes[17] << 16) | (bytes[18] << 8) | bytes[19];
|
||||
int hpx = (bytes[20] << 24) | (bytes[21] << 16) | (bytes[22] << 8) | bytes[23];
|
||||
Console.WriteLine("PNG INTRINSIC WIDTH = " + wpx);
|
||||
Console.WriteLine("PNG INTRINSIC HEIGHT = " + hpx);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("INTRINSIC DIMS = (non extraites, format non PNG ou trop court)");
|
||||
}
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
Console.WriteLine("BASE64 DECODE = FAIL");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Champs MessageExterne (lecture seule) — adresses masquées, corps jamais affiché.</summary>
|
||||
static int MessageFields(string connStr, long pk)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var conn = new OleDbConnection(connStr);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT ME_PKID, ME_IDIntervention, ME_AdrExp, ME_AdrDestinataires, ME_AdrCC, ME_AdrCCI, " +
|
||||
"ME_Objet, ME_Envoyer, ME_Brouillon, ME_PJ, ME_IDDocPJ, ME_EnvoyerMob, CreationUser, CreationDate, ME_Message " +
|
||||
"FROM MessageExterne WHERE ME_PKID = " + pk;
|
||||
using var r = cmd.ExecuteReader();
|
||||
if (!r.Read())
|
||||
{
|
||||
Console.WriteLine("MESSAGE FOUND = NO");
|
||||
return 4;
|
||||
}
|
||||
|
||||
Console.WriteLine("MESSAGE FOUND = YES");
|
||||
Console.WriteLine("ME_PKID = " + Val(r, 0));
|
||||
Console.WriteLine("ME_IDIntervention = " + Val(r, 1));
|
||||
Console.WriteLine("ME_AdrExp = " + MaskEmails(Val(r, 2)));
|
||||
Console.WriteLine("ME_AdrDestinataires = " + MaskEmails(Val(r, 3)));
|
||||
Console.WriteLine("ME_AdrCC = " + MaskEmails(Val(r, 4)));
|
||||
Console.WriteLine("ME_AdrCCI = " + MaskEmails(Val(r, 5)));
|
||||
Console.WriteLine("ME_Objet = " + MaskText(Val(r, 6) ?? ""));
|
||||
Console.WriteLine("ME_Envoyer = " + BoolVal(r, 7));
|
||||
Console.WriteLine("ME_Brouillon = " + BoolVal(r, 8));
|
||||
Console.WriteLine("ME_PJ = " + BoolVal(r, 9));
|
||||
Console.WriteLine("ME_IDDocPJ = " + MaskPj(Val(r, 10)));
|
||||
Console.WriteLine("ME_EnvoyerMob = " + BoolVal(r, 11));
|
||||
Console.WriteLine("CreationUser = " + Val(r, 12));
|
||||
Console.WriteLine("CreationDate = " + Val(r, 13));
|
||||
|
||||
string? msg = r.IsDBNull(14) ? null : r.GetValue(14)?.ToString();
|
||||
Console.WriteLine("BODY LENGTH = " + (msg?.Length ?? 0));
|
||||
Console.WriteLine("HAS SIGNATURE = " + HasSignature(msg));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("QUERY FAILED = " + ex.GetType().Name + " : " + ex.Message);
|
||||
return 3;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Clés JSON (structure, sans valeur) des configs SMTP d'un utilisateur.</summary>
|
||||
static int SmtpKeys(string connStr, long userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var conn = new OleDbConnection(connStr);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $"SELECT PU_Code, PU_Valeur FROM ParametreUtilisateur WHERE PU_IDUtilisateur = {userId} AND PU_Code LIKE 'EMAIL_ENVOI%' ORDER BY PU_Code";
|
||||
using var r = cmd.ExecuteReader();
|
||||
int n = 0;
|
||||
while (r.Read())
|
||||
{
|
||||
string code = (r.GetValue(0)?.ToString() ?? "").Trim();
|
||||
string? valeur = r.IsDBNull(1) ? null : r.GetValue(1)?.ToString();
|
||||
n++;
|
||||
Console.WriteLine($"--- ROW {n}: PU_Code={code} ---");
|
||||
if (string.IsNullOrWhiteSpace(valeur)) { Console.WriteLine("VALUE = (empty)"); continue; }
|
||||
try
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(valeur);
|
||||
JsonElement root = doc.RootElement;
|
||||
IEnumerable<JsonElement> items = root.ValueKind == JsonValueKind.Array
|
||||
? root.EnumerateArray()
|
||||
: [root];
|
||||
|
||||
int i = 0;
|
||||
foreach (JsonElement el in items)
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) { Console.WriteLine($" [{i}] = non-objet"); i++; continue; }
|
||||
Console.WriteLine($" [{i}] keys = " + string.Join(", ", el.EnumerateObject().Select(p => p.Name)));
|
||||
if (el.TryGetProperty("Compte", out JsonElement compte) && compte.ValueKind == JsonValueKind.Object)
|
||||
Console.WriteLine($" [{i}] Compte keys = " + string.Join(", ", compte.EnumerateObject().Select(p => p.Name)));
|
||||
if (el.TryGetProperty("SMTP", out JsonElement smtp) && smtp.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
Console.WriteLine($" [{i}] SMTP keys = " + string.Join(", ", smtp.EnumerateObject().Select(p => p.Name)));
|
||||
foreach (JsonProperty p in smtp.EnumerateObject())
|
||||
{
|
||||
string kind = p.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Array => "array",
|
||||
JsonValueKind.Object => "object",
|
||||
JsonValueKind.String => "string",
|
||||
JsonValueKind.True => "bool(true)",
|
||||
JsonValueKind.False => "bool(false)",
|
||||
JsonValueKind.Number => "number",
|
||||
JsonValueKind.Null => "null",
|
||||
_ => "other"
|
||||
};
|
||||
int len = p.Value.ValueKind == JsonValueKind.String ? p.Value.GetString()?.Length ?? 0
|
||||
: p.Value.ValueKind == JsonValueKind.Array ? p.Value.GetArrayLength()
|
||||
: -1;
|
||||
string extra = "";
|
||||
if (p.Name == "bufSignature" && p.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
string? sv = p.Value.GetString();
|
||||
string prefix = sv is { Length: > 0 } ? sv[..Math.Min(sv.Length, 16)] : "";
|
||||
extra = $" prefix=\"{prefix.Replace("\n", "\\n").Replace("\r", "\\r")}\"";
|
||||
}
|
||||
else if (p.Name == "tabCC_CopieEmail" && p.Value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var elems = new List<string>();
|
||||
int idx = 0;
|
||||
foreach (JsonElement e in p.Value.EnumerateArray())
|
||||
{
|
||||
if (e.ValueKind == JsonValueKind.String)
|
||||
elems.Add("s:" + MaskEmails(e.GetString() ?? ""));
|
||||
else if (e.ValueKind == JsonValueKind.Object)
|
||||
elems.Add("obj{" + string.Join(",", e.EnumerateObject().Select(x => x.Name)) + "}");
|
||||
else
|
||||
elems.Add(e.ValueKind.ToString());
|
||||
idx++;
|
||||
}
|
||||
extra = $" elements=[{string.Join(" | ", elems)}]";
|
||||
}
|
||||
Console.WriteLine($" SMTP.{p.Name} = {kind} len={len}{extra}");
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
catch (JsonException) { Console.WriteLine("JSON = invalid"); }
|
||||
}
|
||||
if (n == 0) Console.WriteLine("NO ROWS");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("QUERY FAILED = " + ex.GetType().Name + " : " + ex.Message);
|
||||
return 3;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Audit structurel (sans secret) des configs SMTP d'un utilisateur.</summary>
|
||||
static int SmtpConfig(string db, string? stdPwd, long userId)
|
||||
{
|
||||
string connStr2 = $"Provider=PCSOFT.HFSQL;Data Source={server}:{port};Initial Catalog={db};" +
|
||||
$"User ID={user};Password={pass};" +
|
||||
$"Extended Properties=\"Password=ParametreUtilisateur:{stdPwd}\"";
|
||||
|
||||
Console.WriteLine("PU_IDUTILISATEUR = " + userId);
|
||||
try
|
||||
{
|
||||
using var conn = new OleDbConnection(connStr2);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $"SELECT PU_Code, PU_Valeur FROM ParametreUtilisateur WHERE PU_IDUtilisateur = {userId} AND PU_Code LIKE 'EMAIL_ENVOI%' ORDER BY PU_Code";
|
||||
using var r = cmd.ExecuteReader();
|
||||
int n = 0;
|
||||
while (r.Read())
|
||||
{
|
||||
string code = (r.GetValue(0)?.ToString() ?? "").Trim();
|
||||
string? valeur = r.IsDBNull(1) ? null : r.GetValue(1)?.ToString();
|
||||
n++;
|
||||
Console.WriteLine($"--- ROW {n}: PU_Code={code} ---");
|
||||
Console.WriteLine("DEV SMTP CONFIG FOUND = YES");
|
||||
Console.WriteLine("VALUE PRESENT = " + (string.IsNullOrWhiteSpace(valeur) ? "NO" : "YES"));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(valeur))
|
||||
{
|
||||
Console.WriteLine("STANDARD CONFIG FOUND = NO");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Structure JSON (jamais le contenu sMdp/sMdpStandard lui-même).
|
||||
try
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(valeur);
|
||||
var smtp = doc.RootElement.ValueKind == JsonValueKind.Array
|
||||
? doc.RootElement.EnumerateArray().Select(e => e.TryGetProperty("SMTP", out JsonElement s) ? s : default).FirstOrDefault(s => s.ValueKind == JsonValueKind.Object)
|
||||
: (doc.RootElement.TryGetProperty("SMTP", out JsonElement s0) ? s0 : default);
|
||||
|
||||
if (smtp.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
bool hasStd = smtp.TryGetProperty("sMdpStandard", out JsonElement e) && e.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e.GetString());
|
||||
bool hasMdp = smtp.TryGetProperty("sMdp", out JsonElement e2) && e2.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(e2.GetString());
|
||||
string host = smtp.TryGetProperty("sAdresseServeur", out JsonElement h) ? h.GetString() ?? "" : "";
|
||||
string port = smtp.TryGetProperty("sPortServeur", out JsonElement p) ? p.GetString() ?? "" : "";
|
||||
int cryptage = smtp.TryGetProperty("nCryptage", out JsonElement c) ? c.GetInt32() : 0;
|
||||
string login = smtp.TryGetProperty("sLogin", out JsonElement l) ? l.GetString() ?? "" : "";
|
||||
|
||||
Console.WriteLine("STANDARD CONFIG FOUND = " + (hasStd ? "YES" : "NO"));
|
||||
Console.WriteLine("sMdpStandard PRESENT = " + (hasStd ? "YES" : "NO"));
|
||||
Console.WriteLine("LEGACY sMdp PRESENT = " + (hasMdp ? "YES" : "NO"));
|
||||
Console.WriteLine("HOST PRESENT = " + (string.IsNullOrWhiteSpace(host) ? "NO" : "YES"));
|
||||
Console.WriteLine("PORT PRESENT = " + (string.IsNullOrWhiteSpace(port) ? "NO" : "YES"));
|
||||
Console.WriteLine("CRYPTAGE = " + cryptage);
|
||||
Console.WriteLine("LOGIN PRESENT = " + (string.IsNullOrWhiteSpace(login) ? "NO" : "YES"));
|
||||
Console.WriteLine("CONFIGURATION VALID = " +
|
||||
((hasStd && !string.IsNullOrWhiteSpace(host) && !string.IsNullOrWhiteSpace(port) && cryptage is >= 1 and <= 3 && !string.IsNullOrWhiteSpace(login)) ? "YES" : "NO"));
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("STANDARD CONFIG FOUND = NO (SMTP absent du JSON)");
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
Console.WriteLine("STANDARD CONFIG FOUND = NO (JSON invalide ou legacy/binaire)");
|
||||
}
|
||||
}
|
||||
|
||||
if (n == 0)
|
||||
{
|
||||
Console.WriteLine("DEV SMTP CONFIG FOUND = NO");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("QUERY FAILED = " + ex.GetType().Name + " : " + ex.Message);
|
||||
return 3;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int ListMessages(string connStr, string db)
|
||||
{
|
||||
Console.WriteLine("DATABASE = " + db);
|
||||
try
|
||||
{
|
||||
using var conn = new OleDbConnection(connStr);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT ME_PKID, ME_Objet, ME_Message, CreationDate, CreationUser FROM MessageExterne ORDER BY CreationDate DESC";
|
||||
using var r = cmd.ExecuteReader();
|
||||
int i = 0;
|
||||
while (r.Read())
|
||||
{
|
||||
long pk = Convert.ToInt64(r.GetValue(0));
|
||||
string? objet = r.IsDBNull(1) ? null : r.GetValue(1)?.ToString();
|
||||
string? msg = r.IsDBNull(2) ? null : r.GetValue(2)?.ToString();
|
||||
string? created = r.IsDBNull(3) ? null : r.GetValue(3)?.ToString();
|
||||
string? creator = r.IsDBNull(4) ? null : r.GetValue(4)?.ToString();
|
||||
string kind = Classify(msg);
|
||||
string obj = (objet ?? "").Replace("\n", " ").Replace("\r", " ");
|
||||
if (obj.Length > 60) obj = obj[..60];
|
||||
Console.WriteLine($"[{i}] PK={pk} KIND={kind} LEN={(msg ?? "").Length} OBJ=\"{obj}\" CREATED={created} USER={creator}");
|
||||
i++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("QUERY FAILED = " + ex.GetType().Name + " : " + ex.Message);
|
||||
return 3;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int ListModels(string connStr, string db)
|
||||
{
|
||||
Console.WriteLine("DATABASE = " + db);
|
||||
try
|
||||
{
|
||||
using var conn = new OleDbConnection(connStr);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT MM_PKID, MM_Nom, MM_Sujet, MM_MsgDoc FROM MailModele ORDER BY MM_PKID";
|
||||
using var r = cmd.ExecuteReader();
|
||||
int i = 0;
|
||||
while (r.Read())
|
||||
{
|
||||
long pk = Convert.ToInt64(r.GetValue(0));
|
||||
string? nom = r.IsDBNull(1) ? null : r.GetValue(1)?.ToString();
|
||||
string? sujet = r.IsDBNull(2) ? null : r.GetValue(2)?.ToString();
|
||||
int docLen = r.IsDBNull(3) ? 0 : (r.GetValue(3) as byte[])?.Length ?? 0;
|
||||
Console.WriteLine($"[{i}] PK={pk} NOM=\"{nom}\" SUJET=\"{sujet}\" DOC_LEN={docLen}");
|
||||
i++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("QUERY FAILED = " + ex.GetType().Name + " : " + ex.Message);
|
||||
return 3;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int AnalyzeMessage(string connStr, long pk)
|
||||
{
|
||||
string? msg = ReadMessage(connStr, pk);
|
||||
if (msg is null)
|
||||
{
|
||||
Console.WriteLine("MESSAGE FOUND = NO");
|
||||
return 4;
|
||||
}
|
||||
|
||||
Console.WriteLine("MESSAGE FOUND = YES");
|
||||
Console.WriteLine($"PK = {pk}");
|
||||
Console.WriteLine($"BODY LENGTH = {msg.Length}");
|
||||
string lower = msg.ToLowerInvariant();
|
||||
|
||||
Console.WriteLine("FULL HTML DOCUMENT = " + (Contains(lower, "<html") ? "YES" : "NO"));
|
||||
Console.WriteLine("HAS HEAD = " + (Contains(lower, "<head") ? "YES" : "NO"));
|
||||
Console.WriteLine("HAS STYLE = " + (Contains(lower, "<style") ? "YES" : "NO"));
|
||||
Console.WriteLine("HAS SCRIPT = " + (Contains(lower, "<script") ? "YES" : "NO"));
|
||||
Console.WriteLine("HAS BODY = " + (Contains(lower, "<body") ? "YES" : "NO"));
|
||||
Console.WriteLine("HAS META = " + (Contains(lower, "<meta") ? "YES" : "NO"));
|
||||
Console.WriteLine("HAS TITLE = " + (Contains(lower, "<title") ? "YES" : "NO"));
|
||||
Console.WriteLine("HAS LINK = " + (Contains(lower, "<link") ? "YES" : "NO"));
|
||||
|
||||
Console.WriteLine("TABLE COUNT = " + Regex.Matches(msg, "<table", RegexOptions.IgnoreCase).Count);
|
||||
Console.WriteLine("TR COUNT = " + Regex.Matches(msg, "<tr", RegexOptions.IgnoreCase).Count);
|
||||
Console.WriteLine("TD COUNT = " + Regex.Matches(msg, "<td", RegexOptions.IgnoreCase).Count);
|
||||
Console.WriteLine("TH COUNT = " + Regex.Matches(msg, "<th", RegexOptions.IgnoreCase).Count);
|
||||
Console.WriteLine("TBODY COUNT = " + Regex.Matches(msg, "<tbody", RegexOptions.IgnoreCase).Count);
|
||||
Console.WriteLine("IMG COUNT = " + Regex.Matches(msg, "<img", RegexOptions.IgnoreCase).Count);
|
||||
Console.WriteLine("P COUNT = " + Regex.Matches(msg, "<p", RegexOptions.IgnoreCase).Count);
|
||||
Console.WriteLine("BR COUNT = " + Regex.Matches(msg, "<br", RegexOptions.IgnoreCase).Count);
|
||||
Console.WriteLine("SPAN COUNT = " + Regex.Matches(msg, "<span", RegexOptions.IgnoreCase).Count);
|
||||
Console.WriteLine("DIV COUNT = " + Regex.Matches(msg, "<div", RegexOptions.IgnoreCase).Count);
|
||||
Console.WriteLine("COL COUNT = " + Regex.Matches(msg, "<col", RegexOptions.IgnoreCase).Count);
|
||||
|
||||
var classes = Regex.Matches(msg, "class\\s*=\\s*\"([^\"]+)\"", RegexOptions.IgnoreCase)
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.SelectMany(v => v.Split(' ', StringSplitOptions.RemoveEmptyEntries))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(c => c, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
Console.WriteLine("UNIQUE CLASSES = " + string.Join(", ", classes));
|
||||
|
||||
// Bloc <style> générique (CSS WinDev, non privé) — affiché pour la spec.
|
||||
var styles = Regex.Matches(msg, "<style[^>]*>(.*?)</style>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
|
||||
Console.WriteLine("STYLE BLOCKS = " + styles.Count);
|
||||
for (int i = 0; i < styles.Count; i++)
|
||||
{
|
||||
Console.WriteLine($"--- STYLE BLOCK {i} (len={styles[i].Groups[1].Value.Length}) ---");
|
||||
Console.WriteLine(styles[i].Groups[1].Value.Trim());
|
||||
}
|
||||
|
||||
Console.WriteLine("--- BODY SKELETON (texte masqué, src/href masqués) ---");
|
||||
Console.WriteLine(Skeleton(msg));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int ModelStructure(string connStr, long pk)
|
||||
{
|
||||
byte[]? docx = ReadModel(connStr, pk);
|
||||
if (docx is null)
|
||||
{
|
||||
Console.WriteLine("MODEL FOUND = NO");
|
||||
return 4;
|
||||
}
|
||||
|
||||
Console.WriteLine("MODEL FOUND = YES");
|
||||
Console.WriteLine($"PK = {pk}");
|
||||
Console.WriteLine($"DOC LENGTH = {docx.Length}");
|
||||
Console.WriteLine("ZIP MAGIC (DOCX) = " + (docx.Length >= 2 && docx[0] == 0x50 && docx[1] == 0x4B ? "YES" : "NO"));
|
||||
|
||||
if (!(docx.Length >= 2 && docx[0] == 0x50 && docx[1] == 0x4B))
|
||||
{
|
||||
Console.WriteLine("NON-DOCX MODEL — first 16 bytes hex: " + BitConverter.ToString(docx.Take(16).ToArray()));
|
||||
return 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var ms = new MemoryStream(docx);
|
||||
using var zip = new ZipArchive(ms, ZipArchiveMode.Read);
|
||||
var entries = zip.Entries.Select(e => e.FullName).ToList();
|
||||
Console.WriteLine("ZIP ENTRIES = " + string.Join(", ", entries));
|
||||
|
||||
var documentEntry = zip.Entries.FirstOrDefault(e => e.FullName == "word/document.xml");
|
||||
if (documentEntry is null)
|
||||
{
|
||||
Console.WriteLine("document.xml ABSENT");
|
||||
return 0;
|
||||
}
|
||||
|
||||
using var sr = new StreamReader(documentEntry.Open(), Encoding.UTF8);
|
||||
string xml = sr.ReadToEnd();
|
||||
Console.WriteLine("document.xml LENGTH = " + xml.Length);
|
||||
Console.WriteLine("PARAGRAPH COUNT = " + Regex.Matches(xml, "<w:p[ >]").Count);
|
||||
Console.WriteLine("TABLE COUNT = " + Regex.Matches(xml, "<w:tbl>").Count);
|
||||
Console.WriteLine("ROW COUNT = " + Regex.Matches(xml, "<w:tr>").Count);
|
||||
Console.WriteLine("CELL COUNT = " + Regex.Matches(xml, "<w:tc>").Count);
|
||||
Console.WriteLine("DRAWING/IMAGE COUNT = " + Regex.Matches(xml, "<w:drawing>").Count);
|
||||
Console.WriteLine("TEXT COUNT = " + Regex.Matches(xml, "<w:t[ >]").Count);
|
||||
|
||||
Console.WriteLine("--- DOCUMENT.XML SKELETON (texte masqué) ---");
|
||||
Console.WriteLine(SkeletonXml(xml));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("DOCX PARSE FAILED = " + ex.GetType().Name + " : " + ex.Message);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int DumpMessage(string connStr, long pk, string file)
|
||||
{
|
||||
string? msg = ReadMessage(connStr, pk);
|
||||
if (msg is null) { Console.WriteLine("MESSAGE FOUND = NO"); return 4; }
|
||||
File.WriteAllText(file, msg, Encoding.UTF8);
|
||||
Console.WriteLine($"WROTE {msg.Length} chars to {file}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int DumpModel(string connStr, long pk, string file)
|
||||
{
|
||||
byte[]? docx = ReadModel(connStr, pk);
|
||||
if (docx is null) { Console.WriteLine("MODEL FOUND = NO"); return 4; }
|
||||
File.WriteAllBytes(file, docx);
|
||||
Console.WriteLine($"WROTE {docx.Length} bytes to {file}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static string? ReadMessage(string connStr, long pk)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var conn = new OleDbConnection(connStr);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $"SELECT ME_Message FROM MessageExterne WHERE ME_PKID = {pk}";
|
||||
object? v = cmd.ExecuteScalar();
|
||||
return v is DBNull or null ? null : v.ToString();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("READ MESSAGE FAILED = " + ex.GetType().Name + " : " + ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static byte[]? ReadModel(string connStr, long pk)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var conn = new OleDbConnection(connStr);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $"SELECT MM_MsgDoc FROM MailModele WHERE MM_PKID = {pk}";
|
||||
object? v = cmd.ExecuteScalar();
|
||||
return v switch
|
||||
{
|
||||
byte[] b => b,
|
||||
DBNull or null => null,
|
||||
_ => Encoding.UTF8.GetBytes(v.ToString() ?? "")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("READ MODEL FAILED = " + ex.GetType().Name + " : " + ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static bool Contains(string haystack, string needle)
|
||||
=> haystack.Contains(needle, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
static string Classify(string? msg)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(msg)) return "EMPTY";
|
||||
string l = msg.ToLowerInvariant();
|
||||
if (l.Contains("<html") && (l.Contains("<head") || l.Contains("<body") || l.Contains("<style")))
|
||||
return "WINFULL";
|
||||
if (l.Contains("<table") || l.Contains("<p")) return "OMIKA-FRAGMENT";
|
||||
return "OTHER";
|
||||
}
|
||||
|
||||
static string Skeleton(string html)
|
||||
{
|
||||
// Masque les textes entre balises et les valeurs src/href (aucun contenu privé).
|
||||
string s = Regex.Replace(html, "(src|href)\\s*=\\s*\"[^\"]*\"", "$1=\"{MASKED}\"", RegexOptions.IgnoreCase);
|
||||
s = Regex.Replace(s, ">([^<>]+)<", m => ">" + MaskText(m.Groups[1].Value) + "<");
|
||||
return s;
|
||||
}
|
||||
|
||||
static string MaskText(string text)
|
||||
{
|
||||
string t = text.Trim();
|
||||
if (t.Length == 0) return text;
|
||||
return "{T" + t.Length + "}";
|
||||
}
|
||||
|
||||
static string Val(System.Data.OleDb.OleDbDataReader r, int i)
|
||||
=> r.IsDBNull(i) ? "(null)" : (r.GetValue(i)?.ToString() ?? "(null)");
|
||||
|
||||
static string BoolVal(System.Data.OleDb.OleDbDataReader r, int i)
|
||||
=> r.IsDBNull(i) ? "(null)" : (Convert.ToBoolean(r.GetValue(i)) ? "TRUE" : "FALSE");
|
||||
|
||||
static string MaskEmails(string? s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) return "(vide)";
|
||||
return Regex.Replace(s, @"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", "[email]", RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
static string MaskPj(string? s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) return "(vide)";
|
||||
// Les références de PJ peuvent être des GUID/IDs/chemins : on masque les GUID
|
||||
// et chemins, mais on conserve la STRUCTURE (séparateurs + nombre d'éléments).
|
||||
string masked = Regex.Replace(s, @"[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{12}", "[guid]", RegexOptions.IgnoreCase);
|
||||
masked = Regex.Replace(masked, @"[A-Za-z]:\\[^;]*", "[path]", RegexOptions.IgnoreCase);
|
||||
return masked;
|
||||
}
|
||||
|
||||
static string HasSignature(string? msg)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(msg)) return "NO (body vide)";
|
||||
string l = msg.ToLowerInvariant();
|
||||
bool sig = l.Contains("signature") || l.Contains("cordialement") || l.Contains("salutations");
|
||||
return sig ? "YES (marqueur trouvé)" : "NO (aucun marqueur)";
|
||||
}
|
||||
|
||||
static string SkeletonXml(string xml)
|
||||
{
|
||||
// Masque le contenu des <w:t> (texte Word) uniquement — structure conservée.
|
||||
string s = Regex.Replace(xml, "<w:t[^>]*>([^<]*)</w:t>", m => "<w:t>{T" + m.Groups[1].Value.Length + "}</w:t>");
|
||||
return s;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Data.OleDb" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,61 @@
|
||||
// Probe LECTURE SEULE ciblé sur l'opération E2E existante.
|
||||
// Aucune écriture. Source de vérité : la base réelle via OLE DB PCSOFT.HFSQL.
|
||||
using System.Data.OleDb;
|
||||
|
||||
const string server = "127.0.0.1";
|
||||
const int port = 4900;
|
||||
const string user = "susana";
|
||||
const string password = "123";
|
||||
const string operationId = "5b915439-d9d9-44b5-9552-1f8f90db96c3";
|
||||
const long mrIdToSrv = 1626;
|
||||
|
||||
string BuildConnStr(string database) =>
|
||||
$"Provider=PCSOFT.HFSQL;Data Source={server}:{port};Initial Catalog={database};User ID={user};Password={password}";
|
||||
|
||||
void QueryTable(string database, string table, string sql)
|
||||
{
|
||||
Console.WriteLine($"=== {table} (base {database}) ===");
|
||||
try
|
||||
{
|
||||
using var conn = new OleDbConnection(BuildConnStr(database));
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
using var reader = cmd.ExecuteReader();
|
||||
int rowCount = 0;
|
||||
while (reader.Read())
|
||||
{
|
||||
rowCount++;
|
||||
var values = new object[reader.FieldCount];
|
||||
reader.GetValues(values);
|
||||
Console.WriteLine($" Row {rowCount}: " + string.Join(" | ", values.Select(v => v?.ToString() ?? "NULL")));
|
||||
}
|
||||
if (rowCount == 0)
|
||||
{
|
||||
Console.WriteLine(" (aucune ligne)");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($" ERREUR: {ex.Message}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
// Base Service (BATIK.01_BLS) -> TOSRV pour IDTOSRV=1626
|
||||
QueryTable("BATIK.01_BLS", "TOSRV (IDTOSRV=1626)",
|
||||
"SELECT IDTOSRV, TS_Date, TS_Mobile, TS_CMD, TS_Lu, TS_JSON FROM TOSRV WHERE IDTOSRV = 1626");
|
||||
|
||||
// Base WebService (BATIK_TEST) -> MobReception pour MR_IDToSRV=1626
|
||||
QueryTable("BATIK_TEST", "MobReception (MR_IDToSRV=1626)",
|
||||
"SELECT MR_PKID, MR_IDToSRV, MR_CMD, MR_Fait, MR_DateHeureRecep, MR_JSON FROM MobReception WHERE MR_IDToSRV = 1626");
|
||||
|
||||
// Base WebService (BATIK_TEST) -> MobLog pour l'operationId
|
||||
QueryTable("BATIK_TEST", "MobLog (operationId)",
|
||||
"SELECT ML_PKID, ML_DateCreat, ML_Niveau, ML_Message FROM MobLog WHERE ML_Message LIKE '%5b915439-d9d9-44b5-9552-1f8f90db96c3%'");
|
||||
|
||||
// Base WebService (BATIK_TEST) -> Intervention (recherche E2E_TEST)
|
||||
QueryTable("BATIK_TEST", "Intervention (E2E_TEST)",
|
||||
"SELECT IN_PKID, IN_RefInterne, IN_Titre, IN_Description, Archive, CreationDate FROM Intervention WHERE IN_RefInterne LIKE 'E2E_TEST_%'");
|
||||
|
||||
Console.WriteLine("Probe ciblé terminé (lecture seule).");
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>Omika.RdvProbeJour</AssemblyName>
|
||||
<RootNamespace>Omika.RdvProbeJour</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="System.Data.OleDb" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,262 @@
|
||||
// Sonde LECTURE SEULE : rendez-vous créés « aujourd'hui » dans OMIKA_TEST.
|
||||
// Rejoue, étape par étape, les filtres du snapshot mobile pour déterminer lequel
|
||||
// écarte un rendez-vous (groupe, plusieurs jours, plusieurs collaborateurs).
|
||||
//
|
||||
// Aucune écriture. Aucune donnée personnelle loggée : seuls les identifiants
|
||||
// techniques (PKID, GUID), dates, drapeaux et clés métier sont affichés.
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.HfSql.Connection;
|
||||
|
||||
var configPath = Path.GetFullPath(Path.Combine(
|
||||
Directory.GetCurrentDirectory(),
|
||||
"config", "hfsql-test.local.json"));
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddJsonFile(configPath, optional: false, reloadOnChange: false)
|
||||
.Build();
|
||||
|
||||
var hfsqlOptions = new HfsqlOptions();
|
||||
config.GetSection(HfsqlOptions.SectionName).Bind(hfsqlOptions);
|
||||
|
||||
DateTime today = DateTime.Today;
|
||||
// Fenêtre d'agenda du snapshot mobile : [-14 j, +28 j], borne SQL exclusive sur +1 jour.
|
||||
DateTime sqlDebutMin = today.AddDays(-14);
|
||||
DateTime sqlDebutMax = today.AddDays(29);
|
||||
|
||||
// Collaborateur de test (ASA) : GUID canonique + PK numérique.
|
||||
string asaGuid = "62224eca-4565-4f47-9197-5c7439400000";
|
||||
long asaPk = 281474976710675;
|
||||
// Formes physiques réellement stockées dans les rubriques GUID Omika (la
|
||||
// rubrique RVC_GUID_Colb fait 32 caractères : la forme WinDev « D » tronquée
|
||||
// des quatre derniers hexadécimaux y est fréquente).
|
||||
IReadOnlyList<string> asaCandidats = Omika.Data.Mapping.Common.PhysicalGuidParser
|
||||
.GetStorageCandidates(Guid.Parse(asaGuid));
|
||||
bool EstAsa(string valeur) => asaCandidats.Any(c => string.Equals(c, valeur, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
Console.WriteLine($"=== Sonde rendez-vous (base {hfsqlOptions.DatabaseName}) ===");
|
||||
Console.WriteLine($"aujourd'hui = {today:yyyy-MM-dd} ({today.DayOfWeek})");
|
||||
Console.WriteLine($"fenêtre agenda = {sqlDebutMin:yyyy-MM-dd} .. {sqlDebutMax:yyyy-MM-dd} (SQL exclusif)");
|
||||
Console.WriteLine();
|
||||
|
||||
var factory = new HfSqlOleDbConnectionFactory();
|
||||
await using var business = new BusinessOleDbConnection(hfsqlOptions, factory);
|
||||
await business.OpenAsync();
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
||||
CancellationToken ct = cts.Token;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 1. Tous les rendez-vous créés aujourd'hui (date de création = aujourd'hui).
|
||||
// -----------------------------------------------------------------------------
|
||||
Console.WriteLine("--- 1. RDV_Planning créés aujourd'hui ---");
|
||||
var rvDuJour = await business.QueryAsync(
|
||||
"SELECT RV_PKID, GUID, RV_Objet, RV_CleType, RV_CleStatut, RV_Date, RV_Groupe, " +
|
||||
"RV_PlusieursJours, RV_IdInter, RV_IdDossier, RV_IdSociete, Archive, CreationDate " +
|
||||
"FROM RDV_Planning WHERE CreationDate >= ? AND CreationDate < ? ORDER BY RV_PKID",
|
||||
[today, today.AddDays(1)], ct);
|
||||
|
||||
Console.WriteLine($"nombre de rendez-vous créés aujourd'hui = {rvDuJour.Count}");
|
||||
foreach (var row in rvDuJour)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$" RV_PKID={Str(row, "RV_PKID")} GUID={Str(row, "GUID")} groupe={Str(row, "RV_Groupe")} " +
|
||||
$"plusieursJours={Str(row, "RV_PlusieursJours")} archive={Str(row, "Archive")} " +
|
||||
$"type={Str(row, "RV_CleType")} statut={Str(row, "RV_CleStatut")} date={Str(row, "RV_Date")} " +
|
||||
$"idInter={Str(row, "RV_IdInter")} idDossier={Str(row, "RV_IdDossier")} idSociete={Str(row, "RV_IdSociete")} " +
|
||||
$"cree={Str(row, "CreationDate")} objet={Trunc(Str(row, "RV_Objet"), 60)}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 2. Affectations (RDV_Collaborateur) de ces rendez-vous.
|
||||
// -----------------------------------------------------------------------------
|
||||
Console.WriteLine("--- 2. Affectations RDV_Collaborateur des RDV ci-dessus ---");
|
||||
var guidList = rvDuJour
|
||||
.Select(r => Str(r, "GUID"))
|
||||
.Where(g => !string.IsNullOrWhiteSpace(g))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var affectations = new List<IReadOnlyDictionary<string, object?>>();
|
||||
if (guidList.Count > 0)
|
||||
{
|
||||
string placeholders = string.Join(", ", guidList.Select(_ => "?"));
|
||||
affectations = (await business.QueryAsync(
|
||||
"SELECT RVC_PKID, RVC_GUID_RDV, RVC_GUID_Colb, RVC_DateDeb, RVC_DateFin, RVC_CleStatut, " +
|
||||
"RVC_Responsable, RVC_TempsValide, Archive, RVC_CR " +
|
||||
$"FROM RDV_Collaborateur WHERE RVC_GUID_RDV IN ({placeholders}) ORDER BY RVC_GUID_RDV, RVC_PKID",
|
||||
guidList.Cast<object?>().ToList(), ct)).ToList();
|
||||
}
|
||||
|
||||
Console.WriteLine($"nombre d'affectations = {affectations.Count}");
|
||||
foreach (var rdv in guidList)
|
||||
{
|
||||
var lignes = affectations
|
||||
.Where(a => string.Equals(Str(a, "RVC_GUID_RDV"), rdv, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
Console.WriteLine($" RDV {rdv} : {lignes.Count} affectation(s)");
|
||||
foreach (var a in lignes)
|
||||
{
|
||||
DateTime deb = ToDateTime(a, "RVC_DateDeb");
|
||||
DateTime fin = ToDateTime(a, "RVC_DateFin");
|
||||
bool debDansFenetre = deb >= sqlDebutMin && deb < sqlDebutMax;
|
||||
Console.WriteLine(
|
||||
$" RVC_PKID={Str(a, "RVC_PKID")} collab={Str(a, "RVC_GUID_Colb")} " +
|
||||
$"deb={deb:yyyy-MM-dd HH:mm} fin={fin:yyyy-MM-dd HH:mm} " +
|
||||
$"statut={Str(a, "RVC_CleStatut")} archive={Str(a, "Archive")} " +
|
||||
$"resp={Str(a, "RVC_Responsable")} cr={Str(a, "RVC_CR")} " +
|
||||
$"debDansFenetreSQL={debDansFenetre}");
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 3. Collaborateurs référencés (GUID → PK / trigramme / actif / auPlanning).
|
||||
// -----------------------------------------------------------------------------
|
||||
Console.WriteLine("--- 3. Collaborateurs référencés par ces affectations ---");
|
||||
var collabGuids = affectations
|
||||
.Select(a => Str(a, "RVC_GUID_Colb"))
|
||||
.Where(g => !string.IsNullOrWhiteSpace(g))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
foreach (var guid in collabGuids)
|
||||
{
|
||||
var rows = await business.QueryAsync(
|
||||
"SELECT CO_PKID, CO_Nom, CO_Prenom, CO_Trigramme, CO_Actif, CO_AuPlanning, GUID FROM Collaborateur WHERE GUID = ?",
|
||||
[guid], ct);
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
Console.WriteLine($" GUID {guid} → AUCUN collaborateur (forme physique inventoriée : {Omika.Data.Mapping.Common.PhysicalGuidParser.ParseOrNull(guid)?.ToString("D") ?? "-"})");
|
||||
continue;
|
||||
}
|
||||
|
||||
var c = rows[0];
|
||||
Console.WriteLine(
|
||||
$" GUID {guid} → CO_PKID={Str(c, "CO_PKID")} trigramme={Str(c, "CO_Trigramme")} " +
|
||||
$"actif={Str(c, "CO_Actif")} auPlanning={Str(c, "CO_AuPlanning")} " +
|
||||
$"nom={Trunc(Str(c, "CO_Nom"), 20)} prenom={Trunc(Str(c, "CO_Prenom"), 20)} " +
|
||||
$"{(EstAsa(guid) ? " <== COLLABORATEUR TEST (ASA)" : string.Empty)}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 4. Type de rendez-vous : drapeau « Envoi_mobile » du paramétrage métier.
|
||||
// -----------------------------------------------------------------------------
|
||||
Console.WriteLine("--- 4. Types de rendez-vous utilisés + drapeau Envoi_mobile ---");
|
||||
var types = rvDuJour
|
||||
.Select(r => Str(r, "RV_CleType"))
|
||||
.Where(t => !string.IsNullOrWhiteSpace(t))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
foreach (var type in types)
|
||||
{
|
||||
var rows = await business.QueryAsync(
|
||||
"SELECT PA_Code, PA_Libelle, PA_Parametre FROM ParametreMetier WHERE PA_Code = ?",
|
||||
[type], ct);
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
Console.WriteLine($" type '{type}' → AUCUN ParametreMetier");
|
||||
continue;
|
||||
}
|
||||
|
||||
var p = rows[0];
|
||||
string parametre = Str(p, "PA_Parametre");
|
||||
Console.WriteLine(
|
||||
$" type '{type}' libelle='{Trunc(Str(p, "PA_Libelle"), 40)}' " +
|
||||
$"Envoi_mobile_present={parametre.Contains("Envoi_mobile", StringComparison.OrdinalIgnoreCase)} " +
|
||||
$"parametre={Trunc(parametre, 220)}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 5. Simulation du snapshot pour le collaborateur de test : chaque filtre.
|
||||
// -----------------------------------------------------------------------------
|
||||
Console.WriteLine($"--- 5. Simulation snapshot pour ASA (GUID {asaGuid} / CO_PKID {asaPk}) ---");
|
||||
foreach (var row in rvDuJour)
|
||||
{
|
||||
string guid = Str(row, "GUID");
|
||||
var lignes = affectations
|
||||
.Where(a => string.Equals(Str(a, "RVC_GUID_RDV"), guid, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
var asaLigne = lignes.FirstOrDefault(a => EstAsa(Str(a, "RVC_GUID_Colb")));
|
||||
|
||||
Console.WriteLine($" RV {guid} ({Trunc(Str(row, "RV_Objet"), 50)})");
|
||||
Console.WriteLine($" filtre SQL collaborateur (RVC_GUID_Colb = ASA) : {(asaLigne is not null ? "PASSE" : "ÉCARTÉ")}");
|
||||
|
||||
if (asaLigne is null)
|
||||
{
|
||||
Console.WriteLine($" → affectations présentes : {string.Join(", ", lignes.Select(l => Str(l, "RVC_GUID_Colb")))}");
|
||||
continue;
|
||||
}
|
||||
|
||||
DateTime deb = ToDateTime(asaLigne, "RVC_DateDeb");
|
||||
DateTime fin = ToDateTime(asaLigne, "RVC_DateFin");
|
||||
bool debDansFenetre = deb >= sqlDebutMin && deb < sqlDebutMax;
|
||||
Console.WriteLine($" filtre SQL fenêtre (RVC_DateDeb) : {(debDansFenetre ? "PASSE" : "ÉCARTÉ")} ({deb:yyyy-MM-dd})");
|
||||
|
||||
bool periodeExploitable = deb != default;
|
||||
Console.WriteLine($" date début d'affectation exploitable : {(periodeExploitable ? "PASSE" : "ÉCARTÉ (default)")}");
|
||||
|
||||
bool dansFenetreMemoire = deb.Date >= today.AddDays(-14) && deb.Date <= today.AddDays(28);
|
||||
Console.WriteLine($" fenêtre mémoire [-14,+28] sur Debut rabattu : {(dansFenetreMemoire ? "PASSE" : "ÉCARTÉ")}");
|
||||
|
||||
bool archive = Str(asaLigne, "Archive") is "True" or "true" or "1";
|
||||
Console.WriteLine($" affectation archivée : {(archive ? "ÉCARTÉ" : "PASSE")}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 6. Autres rendez-vous (créés avant aujourd'hui) touchant le collaborateur test,
|
||||
// pour comparaison : ce sont ceux qui SONT affichés.
|
||||
// -----------------------------------------------------------------------------
|
||||
Console.WriteLine("--- 6. Comparaison : tous les RDV affectés à ASA dans la fenêtre SQL ---");
|
||||
string asaPlaceholders = string.Join(", ", asaCandidats.Select(_ => "?"));
|
||||
var rvAsa = await business.QueryAsync(
|
||||
"SELECT RDV_Planning.RV_PKID, RDV_Planning.GUID, RDV_Planning.RV_Objet, RDV_Planning.RV_Groupe, " +
|
||||
"RDV_Planning.RV_PlusieursJours, RDV_Planning.Archive, RDV_Planning.CreationDate, " +
|
||||
"RDV_Collaborateur.RVC_DateDeb, RDV_Collaborateur.RVC_DateFin, RDV_Collaborateur.Archive " +
|
||||
"FROM RDV_Planning INNER JOIN RDV_Collaborateur ON RDV_Collaborateur.RVC_GUID_RDV = RDV_Planning.GUID " +
|
||||
$"WHERE RDV_Collaborateur.RVC_GUID_Colb IN ({asaPlaceholders}) AND RDV_Collaborateur.RVC_DateDeb >= ? " +
|
||||
"AND RDV_Collaborateur.RVC_DateDeb < ? ORDER BY RDV_Collaborateur.RVC_DateDeb",
|
||||
[.. asaCandidats.Cast<object?>(), sqlDebutMin, sqlDebutMax], ct);
|
||||
|
||||
Console.WriteLine($"nombre = {rvAsa.Count}");
|
||||
foreach (var r in rvAsa)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$" RV={Str(r, "RV_PKID")} GUID={Str(r, "GUID")} groupe={Str(r, "RV_Groupe")} " +
|
||||
$"plusieursJours={Str(r, "RV_PlusieursJours")} archiveRV={Str(r, "Archive")} " +
|
||||
$"deb={ToDateTime(r, "RVC_DateDeb"):yyyy-MM-dd} fin={ToDateTime(r, "RVC_DateFin"):yyyy-MM-dd} " +
|
||||
$"cree={Str(r, "CreationDate")}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Sonde terminée (lecture seule).");
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
static string Str(IReadOnlyDictionary<string, object?> row, string column)
|
||||
=> row.TryGetValue(column, out var value) && value is not null ? Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty : string.Empty;
|
||||
|
||||
static DateTime ToDateTime(IReadOnlyDictionary<string, object?> row, string column)
|
||||
{
|
||||
if (row.TryGetValue(column, out var value) && value is not null)
|
||||
{
|
||||
if (value is DateTime dt) return dt;
|
||||
if (DateTime.TryParse(Convert.ToString(value, CultureInfo.InvariantCulture), CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime parsed))
|
||||
return parsed;
|
||||
}
|
||||
return default;
|
||||
}
|
||||
|
||||
static string Trunc(string value, int max)
|
||||
=> value.Length <= max ? value : value[..max] + "…";
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>Omika.RendezVousBulkProbe</AssemblyName>
|
||||
<RootNamespace>Omika.RendezVousBulkProbe</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="System.Data.OleDb" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,140 @@
|
||||
// Probe LECTURE SEULE ciblé : chargement RendezVous du snapshot mobile (bulk).
|
||||
//
|
||||
// Reproduit EXACTEMENT le chemin du snapshot RendezVous corrigé :
|
||||
// MobileSnapshotService.LoadRendezVousAsync
|
||||
// -> IRendezVousService.SearchDetailsAsync
|
||||
// -> BatikRendezVousRepository.SearchDetailsAsync (bulk, pas de N+1)
|
||||
//
|
||||
// Le probe compte le nombre de requêtes HFSQL exécutées pour prouver que le
|
||||
// chargement est bulk (nombre borné, indépendant du nombre de rendez-vous) et
|
||||
// qu'aucun GetById par rendez-vous n'est émis.
|
||||
//
|
||||
// La quarantaine read-only des anomalies physiques (PL_DateFin < PL_DateDebut)
|
||||
// est appliquée par le repository AVANT le mapper : aucune DomainValidationException
|
||||
// ne doit être levée pour les 53 anomalies source.
|
||||
//
|
||||
// Aucune écriture. Aucun password affiché. Aucune donnée personnelle loggée.
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Omika.Core.Contracts.Common;
|
||||
using Omika.Core.Domain.Commands;
|
||||
using Omika.Core.Domain.Entities.Planning;
|
||||
using Omika.Core.Interfaces;
|
||||
|
||||
using Omika.Data.Configuration;
|
||||
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.HfSql.Connection;
|
||||
using Omika.Data.HfSql.Metadata;
|
||||
using Omika.Data.Repositories.Batik;
|
||||
|
||||
// --- 1. Charger la configuration Hfsql (avec FilePasswords) ---
|
||||
var configPath = Path.GetFullPath(Path.Combine(
|
||||
Directory.GetCurrentDirectory(),
|
||||
"config", "hfsql-test.local.json"));
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddJsonFile(configPath, optional: false, reloadOnChange: false)
|
||||
.Build();
|
||||
|
||||
var hfsqlOptions = new HfsqlOptions();
|
||||
config.GetSection(HfsqlOptions.SectionName).Bind(hfsqlOptions);
|
||||
|
||||
Console.WriteLine($"=== Probe RendezVous Snapshot Bulk (base {hfsqlOptions.DatabaseName}) ===");
|
||||
Console.WriteLine();
|
||||
|
||||
// --- 2. Reproduire le mécanisme de l'API : BusinessOleDbConnection ---
|
||||
var factory = new HfSqlOleDbConnectionFactory();
|
||||
await using var business = new BusinessOleDbConnection(hfsqlOptions, factory);
|
||||
|
||||
// Wrapper compteur de requêtes HFSQL (lecture seule, délègue tout).
|
||||
var counting = new CountingHfsqlConnection(business);
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
await counting.OpenAsync();
|
||||
Console.WriteLine("Connexion OLE DB ouverte (mécanisme API).");
|
||||
|
||||
// --- 3. Repository Batik + chemin bulk du snapshot RendezVous ---
|
||||
IRendezVousRepository repository = new BatikRendezVousRepository(counting);
|
||||
|
||||
var criteria = new RendezVousSearchCriteria
|
||||
{
|
||||
Page = 1,
|
||||
PageSize = 1000
|
||||
};
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
PagedResult<RendezVous> result = await repository.SearchDetailsAsync(criteria, cts.Token);
|
||||
|
||||
sw.Stop();
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("--- Résultat ---");
|
||||
Console.WriteLine($"rendez-vous chargés (page) = {result.Items.Count}");
|
||||
Console.WriteLine($"total rendez-vous (SearchDetails) = {result.TotalCount}");
|
||||
Console.WriteLine($"requêtes HFSQL exécutées = {counting.QueryCount}");
|
||||
Console.WriteLine($"elapsed = {sw.ElapsedMilliseconds} ms");
|
||||
|
||||
// Vérification anti-N+1 : le nombre de requêtes doit être borné et très
|
||||
// inférieur au nombre de rendez-vous. Avec l'ancien pattern N+1, on aurait
|
||||
// observé ~1 requête par rendez-vous (timeout HTTP 30s).
|
||||
if (result.TotalCount > 0 && counting.QueryCount >= result.TotalCount)
|
||||
{
|
||||
Console.WriteLine("ATTENTION : le nombre de requêtes est >= au nombre de rendez-vous (pattern N+1 suspect).");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("OK : chargement bulk (requêtes << rendez-vous), aucun GetById par rendez-vous.");
|
||||
}
|
||||
|
||||
// Aucune DomainValidationException ne doit être levée : la quarantaine des
|
||||
// anomalies (PL_DateFin < PL_DateDebut) est appliquée AVANT le mapper.
|
||||
Console.WriteLine("OK : aucune DomainValidationException (quarantaine appliquée avant mapper).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
Console.WriteLine($"ERREUR: {ex.Message}");
|
||||
Console.WriteLine($"elapsed = {sw.ElapsedMilliseconds} ms");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Probe RendezVous Snapshot Bulk terminé (lecture seule).");
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper IHfsqlConnection qui délègue à la connexion réelle et compte les
|
||||
/// requêtes de lecture (QueryAsync). Lecture seule : aucune écriture.
|
||||
/// </summary>
|
||||
internal sealed class CountingHfsqlConnection : IHfsqlConnection
|
||||
{
|
||||
private readonly IHfsqlConnection _inner;
|
||||
public int QueryCount { get; private set; }
|
||||
|
||||
public CountingHfsqlConnection(IHfsqlConnection inner) => _inner = inner;
|
||||
|
||||
public HfsqlOptions Options => _inner.Options;
|
||||
public HfsqlConnectionState State => _inner.State;
|
||||
|
||||
public Task OpenAsync(CancellationToken ct = default) => _inner.OpenAsync(ct);
|
||||
|
||||
public Task CloseAsync() => _inner.CloseAsync();
|
||||
|
||||
|
||||
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
|
||||
=> _inner.ExecuteScalarAsync(sql, parameters, ct);
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
|
||||
=> _inner.ExecuteNonQueryAsync(sql, parameters, ct);
|
||||
|
||||
public async Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
|
||||
{
|
||||
QueryCount++;
|
||||
return await _inner.QueryAsync(sql, parameters, ct);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken ct = default)
|
||||
=> _inner.GetColumnsAsync(tableName, ct);
|
||||
|
||||
public ValueTask DisposeAsync() => _inner.DisposeAsync();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>Omika.RendezVousProbe</AssemblyName>
|
||||
<RootNamespace>Omika.RendezVousProbe</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="System.Data.OleDb" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,146 @@
|
||||
// Probe LECTURE SEULE ciblé RendezVous.
|
||||
// Reproduit EXACTEMENT le mécanisme de connexion de l'API
|
||||
// (BusinessOleDbConnection -> HfSqlOleDbConnectionFactory -> BuildExtendedPropertiesFromFilePasswords)
|
||||
// pour lire Planning_RDV sur BATIK_TEST et identifier les lignes où PL_DateFin < PL_DateDebut.
|
||||
//
|
||||
// Aucune écriture. Aucun password affiché. Aucune donnée personnelle loggée
|
||||
// (nom client, personne, adresse, notes, téléphone, email).
|
||||
// Seuls PL_PKID (identifiant technique) et les valeurs brutes DATE/HEURE sont affichés.
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.HfSql.Connection;
|
||||
|
||||
// --- 1. Charger la configuration Hfsql (avec FilePasswords) ---
|
||||
var configPath = Path.GetFullPath(Path.Combine(
|
||||
Directory.GetCurrentDirectory(),
|
||||
"config", "hfsql-test.local.json"));
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddJsonFile(configPath, optional: false, reloadOnChange: false)
|
||||
.Build();
|
||||
|
||||
var hfsqlOptions = new HfsqlOptions();
|
||||
config.GetSection(HfsqlOptions.SectionName).Bind(hfsqlOptions);
|
||||
|
||||
Console.WriteLine($"=== Probe RendezVous (base {hfsqlOptions.DatabaseName}) ===");
|
||||
Console.WriteLine();
|
||||
|
||||
// --- 2. Reproduire le mécanisme de l'API : BusinessOleDbConnection ---
|
||||
var factory = new HfSqlOleDbConnectionFactory();
|
||||
await using var business = new BusinessOleDbConnection(hfsqlOptions, factory);
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
int totalRows = 0;
|
||||
int countInvalid = 0;
|
||||
int countCrossMidnight = 0;
|
||||
int countAnomalyPure = 0;
|
||||
long? firstInvalidPk = null;
|
||||
DateTime? firstInvalidStart = null;
|
||||
DateTime? firstInvalidEnd = null;
|
||||
|
||||
try
|
||||
{
|
||||
await business.OpenAsync();
|
||||
Console.WriteLine("Connexion OLE DB ouverte (mécanisme API).");
|
||||
|
||||
// --- 3. Lire les colonnes DATE/HEURE de Planning_RDV ---
|
||||
// Même périmètre que le repository (SearchAsync) : toutes les lignes.
|
||||
string sql = "SELECT PL_PKID, PL_DateDebut, PL_DateFin FROM Planning_RDV";
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
var rows = await business.QueryAsync(sql, cancellationToken: cts.Token);
|
||||
|
||||
totalRows = rows.Count;
|
||||
Console.WriteLine($"rows lues = {totalRows}");
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
long pk = ToLong(row, "PL_PKID");
|
||||
DateTime start = ToDateTime(row, "PL_DateDebut");
|
||||
DateTime end = ToDateTime(row, "PL_DateFin");
|
||||
|
||||
if (end < start)
|
||||
{
|
||||
countInvalid++;
|
||||
if (firstInvalidPk is null)
|
||||
{
|
||||
firstInvalidPk = pk;
|
||||
firstInvalidStart = start;
|
||||
firstInvalidEnd = end;
|
||||
}
|
||||
|
||||
// Distinguer un éventuel rendez-vous à cheval sur minuit
|
||||
// (fin le lendemain du début) d'une anomalie pure (fin avant début).
|
||||
// Cross-midnight : la fin est le jour suivant le début.
|
||||
bool isCrossMidnight = end.Date == start.Date.AddDays(1);
|
||||
if (isCrossMidnight)
|
||||
{
|
||||
countCrossMidnight++;
|
||||
}
|
||||
else
|
||||
{
|
||||
countAnomalyPure++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("--- Résultat ---");
|
||||
Console.WriteLine($"rows = {totalRows}");
|
||||
Console.WriteLine($"count_invalid (PL_DateFin < PL_DateDebut) = {countInvalid}");
|
||||
Console.WriteLine($" dont cross-midnight (fin = lendemain du début) = {countCrossMidnight}");
|
||||
Console.WriteLine($" dont anomalie pure (fin avant début, hors lendemain) = {countAnomalyPure}");
|
||||
Console.WriteLine($"elapsed = {sw.ElapsedMilliseconds} ms");
|
||||
|
||||
if (firstInvalidPk is not null)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("--- Première ligne invalide (technique, non sensible) ---");
|
||||
Console.WriteLine($"PL_PKID = {firstInvalidPk}");
|
||||
Console.WriteLine($"PL_DateDebut (brut) = {firstInvalidStart:O}");
|
||||
Console.WriteLine($"PL_DateFin (brut) = {firstInvalidEnd:O}");
|
||||
Console.WriteLine($"start calculé = {firstInvalidStart:O}");
|
||||
Console.WriteLine($"end calculé = {firstInvalidEnd:O}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Aucune ligne invalide détectée.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
Console.WriteLine($"ERREUR: {ex.Message}");
|
||||
Console.WriteLine($"elapsed = {sw.ElapsedMilliseconds} ms");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Probe RendezVous terminé (lecture seule).");
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
static long ToLong(IReadOnlyDictionary<string, object?> row, string column)
|
||||
{
|
||||
if (row.TryGetValue(column, out var value) && value is not null)
|
||||
{
|
||||
if (value is long l) return l;
|
||||
if (value is int i) return i;
|
||||
if (value is decimal d) return (long)d;
|
||||
if (long.TryParse(value.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out long parsed))
|
||||
return parsed;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static DateTime ToDateTime(IReadOnlyDictionary<string, object?> row, string column)
|
||||
{
|
||||
if (row.TryGetValue(column, out var value) && value is not null)
|
||||
{
|
||||
if (value is DateTime dt) return dt;
|
||||
if (DateTime.TryParse(value.ToString(), CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime parsed))
|
||||
return parsed;
|
||||
}
|
||||
return default;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>Omika.SiteSnapshotProbe</AssemblyName>
|
||||
<RootNamespace>Omika.SiteSnapshotProbe</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="System.Data.OleDb" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,132 @@
|
||||
// Probe LECTURE SEULE ciblé : chargement Sites du snapshot mobile.
|
||||
//
|
||||
// Reproduit EXACTEMENT le chemin du snapshot Sites corrigé :
|
||||
// MobileSnapshotService.LoadSitesAsync
|
||||
// -> SiteService.SearchDetailsAsync
|
||||
// -> BatikSiteRepository.SearchDetailsAsync (bulk, pas de N+1)
|
||||
//
|
||||
// Le probe compte le nombre de requêtes HFSQL exécutées pour prouver que le
|
||||
// chargement est bulk (nombre borné, indépendant du nombre de sites) et
|
||||
// qu'aucun GetById par site n'est émis.
|
||||
//
|
||||
// Aucune écriture. Aucun password affiché. Aucune donnée personnelle loggée.
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Omika.Core.Contracts.Common;
|
||||
using Omika.Core.Contracts.Sites;
|
||||
using Omika.Core.Domain.Entities.Clients;
|
||||
using Omika.Core.Interfaces;
|
||||
|
||||
using Omika.Data.Configuration;
|
||||
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.HfSql.Connection;
|
||||
using Omika.Data.HfSql.Metadata;
|
||||
using Omika.Data.Repositories.Batik;
|
||||
|
||||
// --- 1. Charger la configuration Hfsql (avec FilePasswords) ---
|
||||
var configPath = Path.GetFullPath(Path.Combine(
|
||||
Directory.GetCurrentDirectory(),
|
||||
"config", "hfsql-test.local.json"));
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddJsonFile(configPath, optional: false, reloadOnChange: false)
|
||||
.Build();
|
||||
|
||||
var hfsqlOptions = new HfsqlOptions();
|
||||
config.GetSection(HfsqlOptions.SectionName).Bind(hfsqlOptions);
|
||||
|
||||
Console.WriteLine($"=== Probe Site Snapshot (base {hfsqlOptions.DatabaseName}) ===");
|
||||
Console.WriteLine();
|
||||
|
||||
// --- 2. Reproduire le mécanisme de l'API : BusinessOleDbConnection ---
|
||||
var factory = new HfSqlOleDbConnectionFactory();
|
||||
await using var business = new BusinessOleDbConnection(hfsqlOptions, factory);
|
||||
|
||||
// Wrapper compteur de requêtes HFSQL (lecture seule, délègue tout).
|
||||
var counting = new CountingHfsqlConnection(business);
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
await counting.OpenAsync();
|
||||
Console.WriteLine("Connexion OLE DB ouverte (mécanisme API).");
|
||||
|
||||
// --- 3. Repository Batik + chemin bulk du snapshot Sites ---
|
||||
ISiteRepository repository = new BatikSiteRepository(counting);
|
||||
|
||||
var criteria = new SiteSearchCriteria
|
||||
{
|
||||
Page = 1,
|
||||
PageSize = 1000
|
||||
};
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
PagedResult<Site> result = await repository.SearchDetailsAsync(criteria, cts.Token);
|
||||
|
||||
sw.Stop();
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("--- Résultat ---");
|
||||
Console.WriteLine($"sites chargés (page) = {result.Items.Count}");
|
||||
Console.WriteLine($"total sites (SearchDetails) = {result.TotalCount}");
|
||||
Console.WriteLine($"requêtes HFSQL exécutées = {counting.QueryCount}");
|
||||
Console.WriteLine($"elapsed = {sw.ElapsedMilliseconds} ms");
|
||||
|
||||
// Vérification anti-N+1 : le nombre de requêtes doit être borné et très
|
||||
// inférieur au nombre de sites. Avec l'ancien pattern N+1, on aurait
|
||||
// observé ~1 requête par site (timeout HTTP 30s).
|
||||
if (result.TotalCount > 0 && counting.QueryCount >= result.TotalCount)
|
||||
{
|
||||
Console.WriteLine("ATTENTION : le nombre de requêtes est >= au nombre de sites (pattern N+1 suspect).");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("OK : chargement bulk (requêtes << sites), aucun GetById par site.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
Console.WriteLine($"ERREUR: {ex.Message}");
|
||||
Console.WriteLine($"elapsed = {sw.ElapsedMilliseconds} ms");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Probe Site Snapshot terminé (lecture seule).");
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper IHfsqlConnection qui délègue à la connexion réelle et compte les
|
||||
/// requêtes de lecture (QueryAsync). Lecture seule : aucune écriture.
|
||||
/// </summary>
|
||||
internal sealed class CountingHfsqlConnection : IHfsqlConnection
|
||||
{
|
||||
private readonly IHfsqlConnection _inner;
|
||||
public int QueryCount { get; private set; }
|
||||
|
||||
public CountingHfsqlConnection(IHfsqlConnection inner) => _inner = inner;
|
||||
|
||||
public HfsqlOptions Options => _inner.Options;
|
||||
public HfsqlConnectionState State => _inner.State;
|
||||
|
||||
public Task OpenAsync(CancellationToken ct = default) => _inner.OpenAsync(ct);
|
||||
|
||||
public Task CloseAsync() => _inner.CloseAsync();
|
||||
|
||||
|
||||
public Task<object?> ExecuteScalarAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
|
||||
=> _inner.ExecuteScalarAsync(sql, parameters, ct);
|
||||
|
||||
public Task<int> ExecuteNonQueryAsync(string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
|
||||
=> _inner.ExecuteNonQueryAsync(sql, parameters, ct);
|
||||
|
||||
public async Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> QueryAsync(
|
||||
string sql, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default)
|
||||
{
|
||||
QueryCount++;
|
||||
return await _inner.QueryAsync(sql, parameters, ct);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<HfsqlColumnMetadata>> GetColumnsAsync(string tableName, CancellationToken ct = default)
|
||||
=> _inner.GetColumnsAsync(tableName, ct);
|
||||
|
||||
public ValueTask DisposeAsync() => _inner.DisposeAsync();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>Omika.SmtpMobileConfigProbe</AssemblyName>
|
||||
<RootNamespace>Omika.SmtpMobileConfigProbe</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
|
||||
<PackageReference Include="System.Data.OleDb" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,920 @@
|
||||
// ============================================================
|
||||
// SONDE — CONFIGURATION SMTP RÉELLEMENT SERVIE À UN COLLABORATEUR
|
||||
//
|
||||
// Exécute le VRAI service de production (SmtpConfigurationService + repositories
|
||||
// Omika + déchiffreur sMdpStandard) contre une base Omika, et affiche pour chaque
|
||||
// collaborateur demandé :
|
||||
// 1. les lignes physiques lues en base (contexte) ;
|
||||
// 2. la liste des configurations EXPOSÉES AU MOBILE (contrat
|
||||
// GET /api/mails/configurations-smtp : Id, Libelle, Email) ;
|
||||
// 3. la configuration EFFECTIVE utilisée à l'envoi (serveur, port, cryptage,
|
||||
// login, mot de passe résolu, signature, CCI) et sa validité.
|
||||
//
|
||||
// LECTURE SEULE : aucune écriture, aucune connexion SMTP, aucun envoi.
|
||||
// Le mot de passe n'est jamais affiché en clair sans --montrer-mdp.
|
||||
//
|
||||
// USAGE : Omika.SmtpMobileConfigProbe [id...] [--montrer-mdp]
|
||||
// ============================================================
|
||||
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Omika.Core.Configuration;
|
||||
using Omika.Core.Contracts.Documents;
|
||||
using Omika.Core.Interfaces;
|
||||
using Omika.Core.Security;
|
||||
using Omika.Core.Services;
|
||||
using Omika.Core.Services.Mobile;
|
||||
using Omika.Data.Configuration;
|
||||
using Omika.Data.Connection;
|
||||
using Omika.Data.HfSql.Connection;
|
||||
using Omika.Data.Repositories.Omika;
|
||||
|
||||
Console.OutputEncoding = Encoding.UTF8;
|
||||
|
||||
const string CheminConfiguration = @"D:\Avalonia\Omika\config\hfsql-test.local.json";
|
||||
|
||||
bool montrerMotDePasse = args.Contains("--montrer-mdp", StringComparer.OrdinalIgnoreCase);
|
||||
bool montrerSignatures = args.Contains("--signatures", StringComparer.OrdinalIgnoreCase);
|
||||
string dossierSignatures = Path.Combine(Directory.GetCurrentDirectory(), ".diag", "signatures");
|
||||
string[] cibles = args
|
||||
.Where(a => !a.StartsWith("--", StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
|
||||
if (montrerSignatures)
|
||||
{
|
||||
Directory.CreateDirectory(dossierSignatures);
|
||||
}
|
||||
|
||||
if (cibles.Length == 0)
|
||||
{
|
||||
cibles = ["281474976710679", "281474976710671", "281474976710697"];
|
||||
}
|
||||
|
||||
// --- 1. Options HFSQL de la base de test (section « Hfsql ») -----------------
|
||||
if (!File.Exists(CheminConfiguration))
|
||||
{
|
||||
Console.Error.WriteLine($"ERREUR : configuration absente ({CheminConfiguration}).");
|
||||
return 2;
|
||||
}
|
||||
|
||||
HfsqlOptions options;
|
||||
using (JsonDocument document = JsonDocument.Parse(File.ReadAllText(CheminConfiguration)))
|
||||
{
|
||||
JsonElement hfsql = document.RootElement.GetProperty("Hfsql");
|
||||
JsonElement motsDePasse = hfsql.GetProperty("FilePasswords");
|
||||
options = new HfsqlOptions
|
||||
{
|
||||
ConnectionName = LireTexte(hfsql, "ConnectionName") ?? "Business",
|
||||
Server = LireTexte(hfsql, "Server"),
|
||||
Port = hfsql.TryGetProperty("Port", out JsonElement port) ? port.GetInt32() : 4900,
|
||||
DatabaseName = LireTexte(hfsql, "DatabaseName"),
|
||||
Login = LireTexte(hfsql, "Login"),
|
||||
Password = LireTexte(hfsql, "Password"),
|
||||
Driver = LireTexte(hfsql, "Driver") ?? HfsqlOptions.DefaultOdbcDriver,
|
||||
FilePasswords = new HfSqlFilePasswordOptions
|
||||
{
|
||||
ProtectionStd = LireTexte(motsDePasse, "ProtectionStd"),
|
||||
ProtectionPlus = LireTexte(motsDePasse, "ProtectionPlus")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Console.WriteLine("==============================================================================");
|
||||
Console.WriteLine(" CONFIGURATION SMTP SERVIE AU MOBILE — SERVICE DE PRODUCTION, LECTURE SEULE");
|
||||
Console.WriteLine("==============================================================================");
|
||||
Console.WriteLine($"Base : {options.DatabaseName} ({options.Server}:{options.Port})");
|
||||
Console.WriteLine($"Identifiants : {string.Join(", ", cibles)} (lus d'abord comme UT_PKID, puis CO_PKID)");
|
||||
Console.WriteLine($"Mdp en clair : {(montrerMotDePasse ? "OUI (--montrer-mdp)" : "non (longueur seulement)")}");
|
||||
Console.WriteLine();
|
||||
|
||||
// --- 2. Service de production, câblé sur les vrais repositories Omika --------
|
||||
await using var connexion = new BusinessOleDbConnection(options, new HfSqlOleDbConnectionFactory());
|
||||
await connexion.OpenAsync();
|
||||
|
||||
var service = new SmtpConfigurationService(
|
||||
new OmikaSmtpConfigurationRepository(connexion),
|
||||
new OmikaCollaborateurRepository(connexion),
|
||||
new MobileEntityIdentityResolver(),
|
||||
new JournalConsole(),
|
||||
Options.Create(new SmtpPasswordOptions()),
|
||||
new SmtpPasswordStandardDecryptor());
|
||||
|
||||
foreach (string id in cibles)
|
||||
{
|
||||
Console.WriteLine("==============================================================================");
|
||||
Console.WriteLine($" IDENTIFIANT FOURNI : {id}");
|
||||
Console.WriteLine("==============================================================================");
|
||||
|
||||
try
|
||||
{
|
||||
(string collaborateurId, string origine) = await ResoudreCollaborateurAsync(connexion, id);
|
||||
Console.WriteLine("-- 0. Résolution de l'identifiant");
|
||||
Console.WriteLine($" {origine}");
|
||||
|
||||
if (collaborateurId.Length == 0)
|
||||
{
|
||||
Console.WriteLine(" → aucun collaborateur résolu : le mobile ne reçoit RIEN.");
|
||||
Console.WriteLine();
|
||||
continue;
|
||||
}
|
||||
|
||||
await AfficherContexteAsync(connexion, collaborateurId);
|
||||
|
||||
IReadOnlyList<SmtpConfigurationDto> liste = await service.GetListAsync(collaborateurId);
|
||||
|
||||
Console.WriteLine("-- CE QUE LE MOBILE REÇOIT (GET /api/mails/configurations-smtp)");
|
||||
if (liste.Count == 0)
|
||||
{
|
||||
Console.WriteLine(" (aucune configuration : l'écran de sélection sera vide)");
|
||||
}
|
||||
|
||||
foreach (SmtpConfigurationDto dto in liste)
|
||||
{
|
||||
string libelle = string.IsNullOrWhiteSpace(dto.Libelle)
|
||||
? (dto.Email ?? "(sans libellé)")
|
||||
: dto.Libelle;
|
||||
|
||||
Console.WriteLine($" - Id = {dto.Id}");
|
||||
Console.WriteLine($" Libelle = {libelle}");
|
||||
Console.WriteLine($" Email = {dto.Email ?? "(aucun)"}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("-- CONFIGURATION EFFECTIVE UTILISÉE À L'ENVOI (résolue côté serveur)");
|
||||
if (liste.Count == 0)
|
||||
{
|
||||
Console.WriteLine(" (aucune configuration à résoudre)");
|
||||
}
|
||||
|
||||
foreach (SmtpConfigurationDto dto in liste)
|
||||
{
|
||||
SmtpConfiguration? configuration = await service.GetAsync(collaborateurId, dto.Id);
|
||||
if (configuration is null)
|
||||
{
|
||||
Console.WriteLine($" [{dto.Id}] NON RÉSOLUE (identifiant absent de la liste chargée).");
|
||||
continue;
|
||||
}
|
||||
|
||||
AfficherConfiguration(configuration, montrerMotDePasse, montrerSignatures, dossierSignatures, id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($" ERREUR {ex.GetType().Name} : {ex.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
Console.WriteLine("Sonde terminée (aucune modification en base).");
|
||||
|
||||
// --- Preuve du cas « configuration de SOCIÉTÉ seule » -----------------------
|
||||
// Le bug d'origine : un collaborateur en messagerie interne qui n'a AUCUNE ligne
|
||||
// EMAIL_ENVOI personnelle ne recevait rien (ou une configuration fantôme). On
|
||||
// cherche ici de vrais collaborateurs dans cette situation et on affiche ce que
|
||||
// le service leur rend.
|
||||
if (args.Contains("--exemples-societe", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("==============================================================================");
|
||||
Console.WriteLine(" EXEMPLES : messagerie « Back-Office » SANS configuration personnelle");
|
||||
Console.WriteLine("==============================================================================");
|
||||
|
||||
IReadOnlyList<IReadOnlyDictionary<string, object?>> candidats = await connexion.QueryAsync(
|
||||
"SELECT PU_IDUtilisateur FROM ParametreUtilisateur " +
|
||||
"WHERE PU_Code = 'CHOIX_MESSAGERIE' AND PU_Valeur = 'Back-Office'",
|
||||
null);
|
||||
|
||||
int montres = 0;
|
||||
foreach (IReadOnlyDictionary<string, object?> candidat in candidats)
|
||||
{
|
||||
if (!candidat.TryGetValue("PU_IDUtilisateur", out object? brutId) || brutId is null
|
||||
|| !long.TryParse(brutId.ToString(), out long utid))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
IReadOnlyList<IReadOnlyDictionary<string, object?>> perso = await connexion.QueryAsync(
|
||||
"SELECT PU_Code FROM ParametreUtilisateur WHERE PU_IDUtilisateur = ? AND PU_Code LIKE 'EMAIL_ENVOI%'",
|
||||
[utid]);
|
||||
if (perso.Count > 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
IReadOnlyList<IReadOnlyDictionary<string, object?>> users = await connexion.QueryAsync(
|
||||
"SELECT UT_PKID, UT_Login, UT_IDCollaborateur FROM Utilisateur WHERE UT_PKID = ?",
|
||||
[utid]);
|
||||
if (users.Count == 0
|
||||
|| !users[0].TryGetValue("UT_IDCollaborateur", out object? brutColb) || brutColb is null
|
||||
|| !long.TryParse(brutColb.ToString(), out long colbId) || colbId <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string login = users[0].TryGetValue("UT_Login", out object? brutLogin)
|
||||
? brutLogin?.ToString() ?? "?"
|
||||
: "?";
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"-- Collaborateur {colbId} (utilisateur {utid}, login {login}) — aucune ligne EMAIL_ENVOI");
|
||||
IReadOnlyList<SmtpConfigurationDto> liste = await service.GetListAsync(colbId.ToString());
|
||||
foreach (SmtpConfigurationDto dto in liste)
|
||||
{
|
||||
Console.WriteLine($" reçu : Id = {dto.Id} | Libelle = {dto.Libelle} | Email = {dto.Email ?? "(aucun)"}");
|
||||
SmtpConfiguration? configuration = await service.GetAsync(colbId.ToString(), dto.Id);
|
||||
if (configuration is not null)
|
||||
{
|
||||
AfficherConfiguration(
|
||||
configuration, montrerMotDePasse, montrerSignatures, dossierSignatures, colbId.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
if (liste.Count == 0)
|
||||
{
|
||||
Console.WriteLine(" reçu : liste VIDE");
|
||||
}
|
||||
|
||||
montres++;
|
||||
if (montres >= 3)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (montres == 0)
|
||||
{
|
||||
Console.WriteLine(" (aucun collaborateur dans cette situation dans la base)");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Audit : la fusion société + ligne utilisateur perd-elle un champ ? ------
|
||||
// Un « Configuration SMTP invalide ou incomplète » ne peut venir que d'une
|
||||
// configuration EFFECTIVE sans serveur, port, cryptage, login ou mot de passe.
|
||||
// La fusion (Fusionner) ne reprend de la ligne EMAIL_ENVOI_<soc> que le compte,
|
||||
// la signature et les copies : si la SOCIÉTÉ n'a pas de login ou de mot de passe
|
||||
// alors que la ligne utilisateur les porte, l'envoi échoue. On mesure ce cas.
|
||||
if (args.Contains("--audit-fusion", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
await AuditerFusionAsync(connexion);
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
// ============================================================
|
||||
// Audit de la fusion société / ligne utilisateur
|
||||
// ============================================================
|
||||
|
||||
static async Task AuditerFusionAsync(IHfsqlConnection connexion)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("==============================================================================");
|
||||
Console.WriteLine(" AUDIT — PRÉSENCE DES CHAMPS SMTP (sociétés vs lignes utilisateur)");
|
||||
Console.WriteLine("==============================================================================");
|
||||
|
||||
Dictionary<long, PresenceSmtp> societes = new();
|
||||
IReadOnlyList<IReadOnlyDictionary<string, object?>> lignesSociete = await connexion.QueryAsync(
|
||||
"SELECT SO_PKID, SO_Parametres FROM Societe",
|
||||
null);
|
||||
|
||||
foreach (IReadOnlyDictionary<string, object?> ligne in lignesSociete)
|
||||
{
|
||||
if (!ligne.TryGetValue("SO_PKID", out object? brut) || brut is null
|
||||
|| !long.TryParse(brut.ToString(), out long soId) || soId <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string? json = ligne.TryGetValue("SO_Parametres", out object? brutJson) ? brutJson?.ToString() : null;
|
||||
PresenceSmtp presence = LirePresence(json);
|
||||
societes[soId] = presence;
|
||||
Console.WriteLine($" Société {soId,-20} {presence.Resume()}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("-- C. CAS OÙ LA FUSION ACTUELLE PERD LE LOGIN OU LE MOT DE PASSE");
|
||||
IReadOnlyList<IReadOnlyDictionary<string, object?>> lignesUtilisateur = await connexion.QueryAsync(
|
||||
"SELECT PU_IDUtilisateur, PU_Code, PU_Valeur FROM ParametreUtilisateur " +
|
||||
"WHERE PU_Code LIKE 'EMAIL_ENVOI\\_%'",
|
||||
null);
|
||||
|
||||
int cas = 0;
|
||||
foreach (IReadOnlyDictionary<string, object?> ligne in lignesUtilisateur)
|
||||
{
|
||||
string code = ligne.TryGetValue("PU_Code", out object? brutCode) ? brutCode?.ToString() ?? "" : "";
|
||||
string suffixe = code["EMAIL_ENVOI_".Length..];
|
||||
if (!long.TryParse(suffixe, out long soId) || !societes.TryGetValue(soId, out PresenceSmtp societe))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string? json = ligne.TryGetValue("PU_Valeur", out object? brutValeur) ? brutValeur?.ToString() : null;
|
||||
PresenceSmtp perso = LirePresence(json);
|
||||
bool loginPerdu = !societe.Login && perso.Login;
|
||||
bool mdpPerdu = !societe.MdpStandard && perso.MdpStandard;
|
||||
bool serveurPerdu = (!societe.Serveur || !societe.Port || !societe.Cryptage)
|
||||
&& perso.Serveur && perso.Port && perso.Cryptage;
|
||||
if (!loginPerdu && !mdpPerdu && !serveurPerdu)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
cas++;
|
||||
string ut = ligne.TryGetValue("PU_IDUtilisateur", out object? brutUt) ? brutUt?.ToString() ?? "?" : "?";
|
||||
Console.WriteLine($" UT {ut} / {code}");
|
||||
Console.WriteLine($" société {soId} : {societe.Resume()}");
|
||||
Console.WriteLine($" ligne utilisateur : {perso.Resume()}");
|
||||
Console.WriteLine(
|
||||
$" → perdu par la fusion : " +
|
||||
string.Join(", ", new[]
|
||||
{
|
||||
loginPerdu ? "LOGIN" : null,
|
||||
mdpPerdu ? "MOT DE PASSE" : null,
|
||||
serveurPerdu ? "SERVEUR/PORT/CRYPTAGE" : null
|
||||
}.Where(v => v is not null)));
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($" {cas} cas où la fusion jette un champ indispensable alors que la ligne utilisateur le porte.");
|
||||
if (cas == 0)
|
||||
{
|
||||
Console.WriteLine(" (aucun dans cette base : les sociétés portent elles-mêmes login et mot de passe)");
|
||||
}
|
||||
}
|
||||
|
||||
static PresenceSmtp LirePresence(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(json);
|
||||
JsonElement racine = document.RootElement;
|
||||
if (racine.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
if (racine.GetArrayLength() == 0)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
racine = racine[0];
|
||||
}
|
||||
|
||||
if (racine.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
JsonElement compte = racine.TryGetProperty("Compte", out JsonElement blocCompte)
|
||||
&& blocCompte.ValueKind == JsonValueKind.Object ? blocCompte : default;
|
||||
JsonElement smtp = racine.TryGetProperty("SMTP", out JsonElement blocSmtp)
|
||||
&& blocSmtp.ValueKind == JsonValueKind.Object ? blocSmtp : default;
|
||||
|
||||
bool cryptage = smtp.ValueKind == JsonValueKind.Object
|
||||
&& smtp.TryGetProperty("nCryptage", out JsonElement n)
|
||||
&& n.ValueKind == JsonValueKind.Number
|
||||
&& n.GetInt32() is 1 or 2 or 3;
|
||||
|
||||
return new PresenceSmtp(
|
||||
Present(smtp, "sAdresseServeur"),
|
||||
Present(smtp, "sPortServeur"),
|
||||
cryptage,
|
||||
Present(smtp, "sLogin"),
|
||||
Present(smtp, "sMdpStandard"),
|
||||
Present(smtp, "sMdp"),
|
||||
Present(compte, "sCompte"),
|
||||
Present(compte, "sEmail"));
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
static bool Present(JsonElement element, string nom)
|
||||
=> element.ValueKind == JsonValueKind.Object
|
||||
&& element.TryGetProperty(nom, out JsonElement valeur)
|
||||
&& valeur.ValueKind == JsonValueKind.String
|
||||
&& !string.IsNullOrWhiteSpace(valeur.GetString());
|
||||
|
||||
// ============================================================
|
||||
// Affichage
|
||||
// ============================================================
|
||||
|
||||
/// <summary>
|
||||
/// Résout un identifiant fourni vers l'identifiant de COLLABORATEUR attendu par
|
||||
/// le service. Un identifiant utilisateur (<c>UT_PKID</c>) est l'entrée la plus
|
||||
/// fréquente ; un <c>CO_PKID</c> reste accepté (et signalé comme tel).
|
||||
/// </summary>
|
||||
static async Task<(string CollaborateurId, string Origine)> ResoudreCollaborateurAsync(
|
||||
IHfsqlConnection connexion,
|
||||
string id)
|
||||
{
|
||||
if (!long.TryParse(id, out long cle) || cle <= 0)
|
||||
{
|
||||
return (string.Empty, "Identifiant non numérique : le mobile n'envoie jamais un tel identifiant.");
|
||||
}
|
||||
|
||||
IReadOnlyList<IReadOnlyDictionary<string, object?>> utilisateurs = await connexion.QueryAsync(
|
||||
"SELECT UT_PKID, UT_Login, UT_Nom, UT_Prenom, UT_IDCollaborateur FROM Utilisateur WHERE UT_PKID = ?",
|
||||
[cle]);
|
||||
|
||||
if (utilisateurs.Count > 0)
|
||||
{
|
||||
IReadOnlyDictionary<string, object?> utilisateur = utilisateurs[0];
|
||||
string login = utilisateur.TryGetValue("UT_Login", out object? brutLogin)
|
||||
? brutLogin?.ToString() ?? "?"
|
||||
: "?";
|
||||
string nom = utilisateur.TryGetValue("UT_Nom", out object? brutNom) ? brutNom?.ToString() ?? "" : "";
|
||||
string prenom = utilisateur.TryGetValue("UT_Prenom", out object? brutPrenom) ? brutPrenom?.ToString() ?? "" : "";
|
||||
|
||||
bool lie = utilisateur.TryGetValue("UT_IDCollaborateur", out object? brutColb)
|
||||
&& brutColb is not null
|
||||
&& long.TryParse(brutColb.ToString(), out long colbId)
|
||||
&& colbId > 0;
|
||||
|
||||
if (!lie)
|
||||
{
|
||||
return (string.Empty,
|
||||
$"UT_PKID {cle} = utilisateur « {login} » ({nom} {prenom}) mais UT_IDCollaborateur est vide : " +
|
||||
"aucun collaborateur, donc aucune configuration.");
|
||||
}
|
||||
|
||||
long collaborateurId = long.Parse(utilisateur["UT_IDCollaborateur"]!.ToString()!);
|
||||
return (collaborateurId.ToString(),
|
||||
$"UT_PKID {cle} = utilisateur « {login} » ({nom} {prenom}) → collaborateur {collaborateurId}");
|
||||
}
|
||||
|
||||
IReadOnlyList<IReadOnlyDictionary<string, object?>> collaborateurs = await connexion.QueryAsync(
|
||||
"SELECT CO_PKID, CO_Nom, CO_Prenom FROM Collaborateur WHERE CO_PKID = ?",
|
||||
[cle]);
|
||||
|
||||
if (collaborateurs.Count > 0)
|
||||
{
|
||||
IReadOnlyDictionary<string, object?> collaborateur = collaborateurs[0];
|
||||
string nom = collaborateur.TryGetValue("CO_Nom", out object? brutNom) ? brutNom?.ToString() ?? "" : "";
|
||||
string prenom = collaborateur.TryGetValue("CO_Prenom", out object? brutPrenom) ? brutPrenom?.ToString() ?? "" : "";
|
||||
return (id, $"Aucun UT_PKID {cle} : c'est un CO_PKID ({nom} {prenom}), utilisé directement.");
|
||||
}
|
||||
|
||||
return (string.Empty,
|
||||
$"Identifiant {cle} absent de Utilisateur (UT_PKID) ET de Collaborateur (CO_PKID).");
|
||||
}
|
||||
|
||||
static async Task AfficherContexteAsync(IHfsqlConnection connexion, string id)
|
||||
{
|
||||
long pk = long.TryParse(id, out long pkNumerique) ? pkNumerique : 0;
|
||||
|
||||
Console.WriteLine("-- 1. Collaborateur (CO_PKID)");
|
||||
IReadOnlyList<IReadOnlyDictionary<string, object?>> collaborateurs = await AfficherLignesAsync(
|
||||
connexion,
|
||||
"SELECT CO_PKID, CO_Nom, CO_Prenom, CO_Email, CO_Actif, GUID FROM Collaborateur WHERE CO_PKID = ?",
|
||||
[pk]);
|
||||
|
||||
if (collaborateurs.Count == 0)
|
||||
{
|
||||
Console.WriteLine(
|
||||
" ⚠ Aucun Collaborateur pour cet identifiant : le service rend une liste VIDE " +
|
||||
"(il n'existe aucun utilisateur lié).");
|
||||
}
|
||||
|
||||
Console.WriteLine("-- 2. Utilisateur lié (UT_IDCollaborateur = CO_PKID)");
|
||||
IReadOnlyList<IReadOnlyDictionary<string, object?>> utilisateurs = await AfficherLignesAsync(
|
||||
connexion,
|
||||
"SELECT UT_PKID, UT_Login, UT_Nom, UT_Prenom, UT_IDCollaborateur, UT_IDSocietePrincipale " +
|
||||
"FROM Utilisateur WHERE UT_IDCollaborateur = ?",
|
||||
[pk]);
|
||||
|
||||
if (utilisateurs.Count == 0)
|
||||
{
|
||||
Console.WriteLine(
|
||||
" (aucun Utilisateur pour ce collaborateur : le service ne peut résoudre aucun compte d'envoi)");
|
||||
}
|
||||
|
||||
var idsUtilisateur = new List<long>();
|
||||
foreach (IReadOnlyDictionary<string, object?> ligne in utilisateurs)
|
||||
{
|
||||
if (ligne.TryGetValue("UT_PKID", out object? brutUt) && brutUt is not null
|
||||
&& long.TryParse(brutUt.ToString(), out long utid))
|
||||
{
|
||||
idsUtilisateur.Add(utid);
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine("-- 3. Sociétés de rattachement (Societe_Collaborateur)");
|
||||
IReadOnlyList<IReadOnlyDictionary<string, object?>> liaisons = await AfficherLignesAsync(
|
||||
connexion,
|
||||
"SELECT SC_IDCSociete FROM Societe_Collaborateur WHERE SC_IDCollaborateur = ?",
|
||||
[pk]);
|
||||
|
||||
var idsSociete = new List<long>();
|
||||
foreach (IReadOnlyDictionary<string, object?> ligne in liaisons)
|
||||
{
|
||||
if (ligne.TryGetValue("SC_IDCSociete", out object? brutSoc) && brutSoc is not null
|
||||
&& long.TryParse(brutSoc.ToString(), out long socId) && socId > 0)
|
||||
{
|
||||
idsSociete.Add(socId);
|
||||
}
|
||||
}
|
||||
|
||||
if (idsSociete.Count > 0)
|
||||
{
|
||||
string placeholders = string.Join(", ", idsSociete.Select(_ => "?"));
|
||||
Console.WriteLine(" Paramètres des sociétés rattachées (Societe.SO_Parametres) :");
|
||||
IReadOnlyList<IReadOnlyDictionary<string, object?>> societes = await connexion.QueryAsync(
|
||||
$"SELECT SO_PKID, SO_Parametres FROM Societe WHERE SO_PKID IN ({placeholders})",
|
||||
idsSociete.Cast<object?>().ToArray());
|
||||
|
||||
foreach (IReadOnlyDictionary<string, object?> ligne in societes)
|
||||
{
|
||||
string soId = ligne.TryGetValue("SO_PKID", out object? brutSo) ? brutSo?.ToString() ?? "?" : "?";
|
||||
string? contenu = ligne.TryGetValue("SO_Parametres", out object? brutParams)
|
||||
? brutParams?.ToString()
|
||||
: null;
|
||||
Console.WriteLine($" Société {soId}");
|
||||
ResumeJson($"EMAIL_ENVOI_{soId}", contenu);
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine("-- 4. ParametreUtilisateur (EMAIL_ENVOI% + CHOIX_MESSAGERIE)");
|
||||
if (idsUtilisateur.Count == 0)
|
||||
{
|
||||
Console.WriteLine(" (aucun utilisateur : aucune ligne à lire)");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (long utid in idsUtilisateur)
|
||||
{
|
||||
IReadOnlyList<IReadOnlyDictionary<string, object?>> lignes = await connexion.QueryAsync(
|
||||
"SELECT PU_Code, PU_Valeur FROM ParametreUtilisateur WHERE PU_IDUtilisateur = ? " +
|
||||
"AND (PU_Code LIKE 'EMAIL_ENVOI%' OR PU_Code = 'CHOIX_MESSAGERIE')",
|
||||
[utid]);
|
||||
|
||||
if (lignes.Count == 0)
|
||||
{
|
||||
Console.WriteLine($" (UT_PKID {utid} : aucune ligne EMAIL_ENVOI / CHOIX_MESSAGERIE)");
|
||||
}
|
||||
|
||||
foreach (IReadOnlyDictionary<string, object?> ligne in lignes)
|
||||
{
|
||||
string code = ligne.TryGetValue("PU_Code", out object? brutCode) ? brutCode?.ToString() ?? "" : "";
|
||||
string? contenu = ligne.TryGetValue("PU_Valeur", out object? brutValeur) ? brutValeur?.ToString() : null;
|
||||
Console.WriteLine($" UT_PKID {utid} / {code}");
|
||||
ResumeJson(code, contenu);
|
||||
}
|
||||
|
||||
// Inventaire complet : garantit qu'aucune ligne de configuration SMTP
|
||||
// n'échappe au filtre du service (autre code, autre convention).
|
||||
Console.WriteLine($" (toutes les lignes ParametreUtilisateur de UT_PKID {utid})");
|
||||
IReadOnlyList<IReadOnlyDictionary<string, object?>> toutes = await connexion.QueryAsync(
|
||||
"SELECT PU_Code, PU_Valeur FROM ParametreUtilisateur WHERE PU_IDUtilisateur = ?",
|
||||
[utid]);
|
||||
|
||||
if (toutes.Count == 0)
|
||||
{
|
||||
Console.WriteLine(" (aucune ligne du tout)");
|
||||
}
|
||||
|
||||
foreach (IReadOnlyDictionary<string, object?> ligne in toutes)
|
||||
{
|
||||
string code = ligne.TryGetValue("PU_Code", out object? brutCode) ? brutCode?.ToString() ?? "" : "";
|
||||
string? contenu = ligne.TryGetValue("PU_Valeur", out object? brutValeur) ? brutValeur?.ToString() : null;
|
||||
string apercu = contenu is null
|
||||
? "(null)"
|
||||
: contenu.Length <= 45
|
||||
? $"« {contenu} » ({contenu.Length} car.)"
|
||||
: $"{contenu.Length} car.";
|
||||
Console.WriteLine($" {code} = {apercu}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> AfficherLignesAsync(
|
||||
IHfsqlConnection connexion,
|
||||
string sql,
|
||||
IReadOnlyList<object?> parametres)
|
||||
{
|
||||
IReadOnlyList<IReadOnlyDictionary<string, object?>> lignes =
|
||||
await connexion.QueryAsync(sql, parametres);
|
||||
|
||||
if (lignes.Count == 0)
|
||||
{
|
||||
Console.WriteLine(" (aucune ligne)");
|
||||
return lignes;
|
||||
}
|
||||
|
||||
foreach (IReadOnlyDictionary<string, object?> ligne in lignes)
|
||||
{
|
||||
IEnumerable<string> paires = ligne.Select(kv => $"{kv.Key} = {ResumerValeur(kv.Value)}");
|
||||
Console.WriteLine(" " + string.Join(" | ", paires));
|
||||
}
|
||||
|
||||
return lignes;
|
||||
}
|
||||
|
||||
/// <summary>Résume une valeur JSON de <c>ParametreUtilisateur</c> sans secret.</summary>
|
||||
static void ResumeJson(string code, string? valeur)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(valeur))
|
||||
{
|
||||
Console.WriteLine(" (valeur vide)");
|
||||
return;
|
||||
}
|
||||
|
||||
string debut = valeur.TrimStart();
|
||||
if (!debut.StartsWith('{') && !debut.StartsWith('['))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$" VALEUR NON JSON ({valeur.Length} car.) : ignorée par le service " +
|
||||
"(chiffrement/binaire historique).");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(valeur);
|
||||
JsonElement racine = document.RootElement;
|
||||
|
||||
if (racine.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
int index = 0;
|
||||
foreach (JsonElement element in racine.EnumerateArray())
|
||||
{
|
||||
Console.WriteLine($" [tableau, entrée {index}]");
|
||||
ResumeConfiguration(element);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ResumeConfiguration(racine);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
Console.WriteLine($" JSON INVALIDE ({valeur.Length} car.) : ignorée par le service.");
|
||||
}
|
||||
}
|
||||
|
||||
static void ResumeConfiguration(JsonElement element)
|
||||
{
|
||||
string? compte = null;
|
||||
string? email = null;
|
||||
if (element.TryGetProperty("Compte", out JsonElement blocCompte)
|
||||
&& blocCompte.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
compte = LireTexte(blocCompte, "sCompte");
|
||||
email = LireTexte(blocCompte, "sEmail");
|
||||
}
|
||||
|
||||
if (!element.TryGetProperty("SMTP", out JsonElement smtp) || smtp.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
Console.WriteLine(" (aucun bloc SMTP)");
|
||||
return;
|
||||
}
|
||||
|
||||
string? login = LireTexte(smtp, "sLogin");
|
||||
string? serveur = LireTexte(smtp, "sAdresseServeur");
|
||||
string? port = LireTexte(smtp, "sPortServeur");
|
||||
string? signature = LireTexte(smtp, "bufSignature");
|
||||
string? mdp = LireTexte(smtp, "sMdp");
|
||||
string? mdpStandard = LireTexte(smtp, "sMdpStandard");
|
||||
int cryptage = smtp.TryGetProperty("nCryptage", out JsonElement n) && n.ValueKind == JsonValueKind.Number
|
||||
? n.GetInt32()
|
||||
: 0;
|
||||
bool enCopieCachee = smtp.TryGetProperty("bEnCopieCachee", out JsonElement bcc)
|
||||
&& (bcc.ValueKind == JsonValueKind.True
|
||||
|| (bcc.ValueKind == JsonValueKind.String
|
||||
&& bool.TryParse(bcc.GetString(), out bool drapeau) && drapeau));
|
||||
int copies = smtp.TryGetProperty("tabCC_CopieEmail", out JsonElement cc)
|
||||
&& cc.ValueKind == JsonValueKind.Array
|
||||
? cc.GetArrayLength()
|
||||
: 0;
|
||||
string listeCopies = "(aucune)";
|
||||
if (copies > 0)
|
||||
{
|
||||
var adresses = cc.EnumerateArray()
|
||||
.Select(a => a.ValueKind == JsonValueKind.String ? (a.GetString() ?? "").Trim() : a.ToString())
|
||||
.Select(a => a.Length == 0 ? "«vide»" : a);
|
||||
listeCopies = string.Join(" | ", adresses);
|
||||
}
|
||||
|
||||
Console.WriteLine($" Compte.sCompte = {ResumerValeur(compte)}");
|
||||
Console.WriteLine($" Compte.sEmail = {ResumerValeur(email)}");
|
||||
Console.WriteLine($" SMTP.sAdresseServeur = {ResumerValeur(serveur)}");
|
||||
Console.WriteLine($" SMTP.sPortServeur = {ResumerValeur(port)}");
|
||||
Console.WriteLine($" SMTP.nCryptage = {LibelleCryptage(cryptage)}");
|
||||
Console.WriteLine($" SMTP.sLogin = {ResumerValeur(login)}");
|
||||
Console.WriteLine($" SMTP.sMdp = {(string.IsNullOrWhiteSpace(mdp) ? "absent" : $"présent ({mdp.Length} car.) — JAMAIS lu par Omika")}");
|
||||
Console.WriteLine($" SMTP.sMdpStandard = {(string.IsNullOrWhiteSpace(mdpStandard) ? "ABSENT" : $"présent ({mdpStandard.Length} car. base64)")}");
|
||||
Console.WriteLine($" Signature = {(string.IsNullOrWhiteSpace(signature) ? "aucune" : $"{signature.Length} car. base64")}");
|
||||
Console.WriteLine($" bEnCopieCachee = {enCopieCachee}");
|
||||
Console.WriteLine($" tabCC_CopieEmail = {copies} adresse(s) — {listeCopies}");
|
||||
}
|
||||
|
||||
static void AfficherConfiguration(
|
||||
SmtpConfiguration configuration,
|
||||
bool montrerMotDePasse,
|
||||
bool montrerSignatures,
|
||||
string dossierSignatures,
|
||||
string etiquetteProprietaire)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($" [{configuration.Id}]");
|
||||
|
||||
string? motDePasse = configuration.MotDePasse;
|
||||
string statutMotDePasse = string.IsNullOrWhiteSpace(motDePasse)
|
||||
? "ABSENT → envoi impossible"
|
||||
: montrerMotDePasse
|
||||
? $"« {motDePasse} » ({motDePasse.Length} car.)"
|
||||
: $"{motDePasse.Length} caractère(s), résolu et déchiffré";
|
||||
|
||||
Console.WriteLine($" Serveur = {Valeur(configuration.Serveur)}");
|
||||
Console.WriteLine($" Port = {configuration.Port}");
|
||||
Console.WriteLine($" Cryptage = {LibelleCryptage(configuration.Cryptage)}");
|
||||
Console.WriteLine($" Login = {Valeur(configuration.Login)}");
|
||||
Console.WriteLine($" Expéditeur (From) = {Valeur(configuration.Email)}");
|
||||
Console.WriteLine($" Compte (libellé) = {Valeur(configuration.Compte)}");
|
||||
Console.WriteLine($" Mot de passe = {statutMotDePasse}");
|
||||
Console.WriteLine($" Signature = {(string.IsNullOrWhiteSpace(configuration.Signature) ? "aucune" : $"{configuration.Signature.Length} caractère(s) de HTML")}");
|
||||
Console.WriteLine($" Copie cachée (CCI) = {(configuration.EnCopieCachee ? "oui" : "non")}");
|
||||
Console.WriteLine($" Copies (CC) = {(configuration.CcCopieEmail.Count == 0 ? "aucune" : string.Join(", ", configuration.CcCopieEmail))}");
|
||||
|
||||
try
|
||||
{
|
||||
configuration.Validate();
|
||||
Console.WriteLine(" Prête à envoyer = OUI (validation complète)");
|
||||
}
|
||||
catch (EmailEnvoiException ex)
|
||||
{
|
||||
Console.WriteLine($" Prête à envoyer = NON ({ex.Message})");
|
||||
}
|
||||
|
||||
if (montrerSignatures)
|
||||
{
|
||||
AfficherSignature(configuration, dossierSignatures, etiquetteProprietaire);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Affiche la signature qui sera réellement concaténée au corps du courriel :
|
||||
/// texte visible (balises retirées, images signalées) et fichier HTML complet
|
||||
/// écrit sur disque pour la vérification visuelle.
|
||||
/// </summary>
|
||||
static void AfficherSignature(SmtpConfiguration configuration, string dossier, string etiquettes)
|
||||
{
|
||||
Console.WriteLine(" --- SIGNATURE AJOUTÉE AU CORPS ---");
|
||||
|
||||
string? html = configuration.Signature;
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
{
|
||||
Console.WriteLine(" (aucune signature : le corps du courriel reste inchangé)");
|
||||
return;
|
||||
}
|
||||
|
||||
int images = Regex.Matches(html, "<img\\b", RegexOptions.IgnoreCase).Count;
|
||||
int imagesIntegrees = Regex.Matches(html, "src\\s*=\\s*[\"']data:", RegexOptions.IgnoreCase).Count;
|
||||
int liens = Regex.Matches(html, "<a\\b", RegexOptions.IgnoreCase).Count;
|
||||
int tableaux = Regex.Matches(html, "<table\\b", RegexOptions.IgnoreCase).Count;
|
||||
|
||||
Console.WriteLine($" HTML = {html.Length} caractère(s), {images} image(s) " +
|
||||
$"({imagesIntegrees} intégrée(s) en base64), {liens} lien(s), {tableaux} tableau(x)");
|
||||
|
||||
string texte = SignatureVersTexte(html);
|
||||
Console.WriteLine(" Texte visible :");
|
||||
foreach (string ligne in texte.Split('\n'))
|
||||
{
|
||||
Console.WriteLine($" | {ligne}");
|
||||
}
|
||||
|
||||
string nomFichier = Regex.Replace($"{etiquettes}_{configuration.Id}", "[^A-Za-z0-9_.-]", "_");
|
||||
string chemin = Path.Combine(dossier, nomFichier + ".html");
|
||||
File.WriteAllText(
|
||||
chemin,
|
||||
$"<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Signature {configuration.Id}</title></head>" +
|
||||
$"<body><p>--- corps du courriel ---</p>{html}</body></html>",
|
||||
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
Console.WriteLine($" Aperçu HTML complet : {chemin}");
|
||||
}
|
||||
|
||||
/// <summary>Rend lisible un fragment HTML de signature : texte, images signalées.</summary>
|
||||
static string SignatureVersTexte(string html)
|
||||
{
|
||||
string travail = html;
|
||||
|
||||
travail = Regex.Replace(travail, "<!--.*?-->", " ", RegexOptions.Singleline);
|
||||
travail = Regex.Replace(travail, "<(script|style|head)\\b[^>]*>.*?</\\1\\s*>", " ", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
|
||||
// Images : on signale leur présence sans déverser le base64 dans la console.
|
||||
travail = Regex.Replace(
|
||||
travail,
|
||||
"<img\\b[^>]*?alt\\s*=\\s*[\"']([^\"']*)[\"'][^>]*>",
|
||||
" [image: $1] ",
|
||||
RegexOptions.IgnoreCase);
|
||||
travail = Regex.Replace(travail, "<img\\b[^>]*>", " [image] ", RegexOptions.IgnoreCase);
|
||||
|
||||
travail = Regex.Replace(travail, "<\\s*br\\s*/?>", "\n", RegexOptions.IgnoreCase);
|
||||
travail = Regex.Replace(travail, "</\\s*(p|div|tr|h[1-6]|li|table|blockquote)\\s*>", "\n", RegexOptions.IgnoreCase);
|
||||
travail = Regex.Replace(travail, "<\\s*(p|div|tr|h[1-6]|li|table|blockquote)\\b[^>]*>", "\n", RegexOptions.IgnoreCase);
|
||||
|
||||
travail = Regex.Replace(travail, "<[^>]+>", " ");
|
||||
travail = WebUtility.HtmlDecode(travail);
|
||||
travail = travail.Replace('\u00a0', ' ');
|
||||
|
||||
// Jetons base64 résiduels (attributs data:/cid: collés, styles volumineux).
|
||||
travail = Regex.Replace(travail, "[A-Za-z0-9+/]{120,}={0,2}", " ");
|
||||
travail = Regex.Replace(travail, "[ \\t\\r]+", " ");
|
||||
travail = Regex.Replace(travail, " *\n *", "\n");
|
||||
travail = Regex.Replace(travail, "\n{2,}", "\n");
|
||||
|
||||
return travail.Trim();
|
||||
}
|
||||
|
||||
static string LibelleCryptage(int cryptage) => cryptage switch
|
||||
{
|
||||
1 => "1 = aucun",
|
||||
2 => "2 = TLS / STARTTLS",
|
||||
3 => "3 = SSL",
|
||||
_ => $"{cryptage} = NON RENSEIGNÉ"
|
||||
};
|
||||
|
||||
static string Valeur(string? texte)
|
||||
=> string.IsNullOrWhiteSpace(texte) ? "(absent)" : texte;
|
||||
|
||||
static string ResumerValeur(object? valeur)
|
||||
{
|
||||
if (valeur is null or DBNull)
|
||||
{
|
||||
return "(null)";
|
||||
}
|
||||
|
||||
string texte = valeur.ToString() ?? "";
|
||||
if (texte.Length <= 90)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(texte) ? "(vide)" : texte;
|
||||
}
|
||||
|
||||
return $"{texte[..90]}… ({texte.Length} car.)";
|
||||
}
|
||||
|
||||
static string? LireTexte(JsonElement element, string nom)
|
||||
=> element.TryGetProperty(nom, out JsonElement valeur) && valeur.ValueKind == JsonValueKind.String
|
||||
? valeur.GetString()
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Présence (et non valeur) des champs qui décident de la validité d'une
|
||||
/// configuration SMTP, telle qu'elle est réellement stockée en base.
|
||||
/// </summary>
|
||||
internal readonly record struct PresenceSmtp(
|
||||
bool Serveur,
|
||||
bool Port,
|
||||
bool Cryptage,
|
||||
bool Login,
|
||||
bool MdpStandard,
|
||||
bool MdpLegacy,
|
||||
bool Compte,
|
||||
bool Email)
|
||||
{
|
||||
public string Resume()
|
||||
=> $"serveur={Drapeau(Serveur)} port={Drapeau(Port)} cryptage={Drapeau(Cryptage)} " +
|
||||
$"login={Drapeau(Login)} sMdpStandard={Drapeau(MdpStandard)} sMdpLegacy={Drapeau(MdpLegacy)} " +
|
||||
$"compte={Drapeau(Compte)} email={Drapeau(Email)}";
|
||||
|
||||
private static string Drapeau(bool present) => present ? "oui" : "NON";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Journal minimal : les messages du service (invariants de diagnostic) sont
|
||||
/// affichés tels quels, sans exposer de secret.
|
||||
/// </summary>
|
||||
internal sealed class JournalConsole : ILogger<SmtpConfigurationService>
|
||||
{
|
||||
public IDisposable? BeginScope<TState>(TState state)
|
||||
where TState : notnull
|
||||
=> null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (!IsEnabled(logLevel))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($" [journal {logLevel}] {formatter(state, exception)}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>Omika.SmtpRealProbe</AssemblyName>
|
||||
<RootNamespace>Omika.SmtpRealProbe</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Omika.Core\Omika.Core.csproj" />
|
||||
<PackageReference Include="System.Data.OleDb" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,362 @@
|
||||
using System.Data.OleDb;
|
||||
using System.Text.Json;
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
using MimeKit;
|
||||
using Omika.Core.Security;
|
||||
|
||||
// ============================================================
|
||||
// OMIKA SMTP PROBE — READ-ONLY DB + (optionnel) SMTP connect/auth/send
|
||||
// Modes :
|
||||
// --audit [--db OMIKA_TEST|BATIK_TEST]
|
||||
// Scan de TOUTES les configs EMAIL_ENVOI% : classification + structure
|
||||
// + déchiffrement (composant DE PRODUCTION SmtpPasswordStandardDecryptor).
|
||||
// Ne modifie aucune donnée.
|
||||
// <PU_Code> <PU_IDUtilisateur> [--db ...] [--send]
|
||||
// Audit d'une configuration unique + SMTP connect/auth (+envoi si --send).
|
||||
// Ne JAMAIS afficher sMdp / sMdpStandard / plaintext / login / clé.
|
||||
// ============================================================
|
||||
|
||||
const string server = "127.0.0.1";
|
||||
const int port = 4900;
|
||||
const string user = "susana";
|
||||
const string pass = "123";
|
||||
|
||||
// ProtectionStd (ParametreUtilisateur) par base de test.
|
||||
var dbMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["OMIKA_TEST"] = "A1BAF17C65BC565F2927BF1A85DAFEED",
|
||||
["BATIK_TEST"] = "E2FC3072E6145E1B20DA7321755CD607",
|
||||
};
|
||||
|
||||
bool audit = args.Contains("--audit");
|
||||
int dbIdx = Array.IndexOf(args, "--db");
|
||||
string db = dbIdx >= 0 && dbIdx + 1 < args.Length ? args[dbIdx + 1] : "OMIKA_TEST";
|
||||
if (!dbMap.TryGetValue(db, out string? stdPwd))
|
||||
{
|
||||
Console.Error.WriteLine($"ERROR: unknown database '{db}' (known: OMIKA_TEST, BATIK_TEST).");
|
||||
return 2;
|
||||
}
|
||||
|
||||
string connStr =
|
||||
$"Provider=PCSOFT.HFSQL;Data Source={server}:{port};Initial Catalog={db};" +
|
||||
$"User ID={user};Password={pass};" +
|
||||
$"Extended Properties=\"Password=ParametreUtilisateur:{stdPwd}\"";
|
||||
|
||||
var decryptor = new SmtpPasswordStandardDecryptor();
|
||||
|
||||
if (audit)
|
||||
{
|
||||
return RunAudit(connStr, db, decryptor);
|
||||
}
|
||||
|
||||
// --- Mode config unique ---
|
||||
string code = args.Where(a => !a.StartsWith("--")).ElementAtOrDefault(0) ?? "EMAIL_ENVOI_5";
|
||||
string idUserText = args.Where(a => !a.StartsWith("--")).ElementAtOrDefault(1) ?? "7";
|
||||
bool send = args.Contains("--send");
|
||||
|
||||
if (code.Any(c => !(char.IsLetterOrDigit(c) || c == '_')) || !long.TryParse(idUserText, out long idUser))
|
||||
{
|
||||
Console.Error.WriteLine("USAGE: Omika.SmtpRealProbe <PU_Code> <PU_IDUtilisateur> [--db DB] [--send]");
|
||||
return 2;
|
||||
}
|
||||
|
||||
string? json = null;
|
||||
try
|
||||
{
|
||||
using var conn = new OleDbConnection(connStr);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $"SELECT PU_Valeur FROM ParametreUtilisateur WHERE PU_Code = '{code}' AND PU_IDUtilisateur = {idUser}";
|
||||
json = cmd.ExecuteScalar() as string;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"SMTP CONFIG FOUND = NO (DB read failed: {ex.GetType().Name})");
|
||||
return 3;
|
||||
}
|
||||
|
||||
Console.WriteLine("SMTP CONFIG FOUND = " + (string.IsNullOrWhiteSpace(json) ? "NO" : "YES"));
|
||||
Console.WriteLine("SOURCE TABLE = ParametreUtilisateur");
|
||||
Console.WriteLine($"PU_CODE = {code}");
|
||||
Console.WriteLine($"PU_IDUTILISATEUR = {idUser}");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json)) return 4;
|
||||
|
||||
var cfg = ExtractFirst(json);
|
||||
if (cfg is null) { Console.WriteLine("JSON PARSE = FAILED"); return 5; }
|
||||
|
||||
Console.WriteLine("sMdp PRESENT = " + (string.IsNullOrWhiteSpace(cfg.SMdp) ? "NO" : "YES"));
|
||||
Console.WriteLine("sMdpStandard PRESENT = " + (string.IsNullOrWhiteSpace(cfg.SMdpStandard) ? "NO" : "YES"));
|
||||
Console.WriteLine("sMdpStandard EMPTY = " + (string.IsNullOrWhiteSpace(cfg.SMdpStandard) ? "YES" : "NO"));
|
||||
Console.WriteLine("SMTP HOST = " + (string.IsNullOrWhiteSpace(cfg.Host) ? "(absent)" : cfg.Host));
|
||||
Console.WriteLine("SMTP PORT = " + (string.IsNullOrWhiteSpace(cfg.Port) ? "(absent)" : cfg.Port));
|
||||
Console.WriteLine("SMTP SECURITY MODE = " + cfg.Cryptage switch { 1 => "AUCUN", 2 => "TLS/STARTTLS", 3 => "SSL", _ => $"INCONNU({cfg.Cryptage})" });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(cfg.SMdpStandard))
|
||||
{
|
||||
Console.WriteLine("REAL STANDARD PASSWORD DECRYPT = NOT TESTED (sMdpStandard absent)");
|
||||
return 6;
|
||||
}
|
||||
|
||||
return TestSingle(decryptor, cfg, send);
|
||||
|
||||
// ============================================================
|
||||
|
||||
static int RunAudit(string connStr, string db, SmtpPasswordStandardDecryptor decryptor)
|
||||
{
|
||||
Console.WriteLine("DATABASE = " + db);
|
||||
Console.WriteLine("IS OMIKA DATABASE = " + (db.Equals("OMIKA_TEST", StringComparison.OrdinalIgnoreCase) ? "YES" : "NO"));
|
||||
|
||||
var rows = new List<(string Code, string IdUser, string? Valeur)>();
|
||||
try
|
||||
{
|
||||
using var conn = new OleDbConnection(connStr);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT PU_Code, PU_IDUtilisateur, PU_Valeur FROM ParametreUtilisateur WHERE PU_Code LIKE 'EMAIL_ENVOI%'";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
string c = (reader.GetValue(0)?.ToString() ?? "").Trim();
|
||||
string i = (reader.GetValue(1)?.ToString() ?? "").Trim();
|
||||
string? v = reader.IsDBNull(2) ? null : reader.GetValue(2)?.ToString();
|
||||
rows.Add((c, i, v));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("QUERY FAILED = " + ex.GetType().Name);
|
||||
return 3;
|
||||
}
|
||||
|
||||
int emailEnvoi = 0, emailEnvoiSoc = 0, emailEnvoiPerso = 0;
|
||||
int invalidJson = 0, legacyBinary = 0, emptyValue = 0;
|
||||
int withMdp = 0, withStd = 0, withoutStd = 0, emptyStd = 0;
|
||||
int base64Valid = 0, base64Invalid = 0;
|
||||
int decryptPass = 0, decryptFail = 0, plaintextEmpty = 0;
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (row.Code == "EMAIL_ENVOI") emailEnvoi++;
|
||||
else if (System.Text.RegularExpressions.Regex.IsMatch(row.Code, @"^EMAIL_ENVOI_\d+$")) emailEnvoiSoc++;
|
||||
else if (row.Code.Contains("Perso")) emailEnvoiPerso++;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(row.Valeur)) { emptyValue++; continue; }
|
||||
|
||||
var configs = ExtractAll(row.Valeur);
|
||||
if (configs is null)
|
||||
{
|
||||
// Non-JSON : legacy/binaire ou JSON invalide.
|
||||
bool looksBinary = row.Valeur.Any(c => c < 32 && c != '\t' && c != '\n' && c != '\r');
|
||||
bool startsJson = row.Valeur.TrimStart().StartsWith('{') || row.Valeur.TrimStart().StartsWith('[');
|
||||
if (startsJson && !looksBinary) invalidJson++;
|
||||
else legacyBinary++;
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var cfg in configs)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(cfg.SMdp)) withMdp++;
|
||||
if (!string.IsNullOrWhiteSpace(cfg.SMdpStandard))
|
||||
{
|
||||
withStd++;
|
||||
byte[]? payload = TryBase64(cfg.SMdpStandard);
|
||||
if (payload is null) base64Invalid++;
|
||||
else
|
||||
{
|
||||
base64Valid++;
|
||||
try
|
||||
{
|
||||
string pt = decryptor.Decrypt(cfg.SMdpStandard);
|
||||
decryptPass++;
|
||||
if (string.IsNullOrEmpty(pt)) plaintextEmpty++;
|
||||
}
|
||||
catch (SmtpStandardPasswordException) { decryptFail++; }
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
withoutStd++;
|
||||
if (cfg.SMdpStandard is not null) emptyStd++; // champ présent mais vide
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine("TOTAL EMAIL CONFIGS = " + rows.Count);
|
||||
Console.WriteLine("EMAIL_ENVOI = " + emailEnvoi);
|
||||
Console.WriteLine("EMAIL_ENVOI_<idSoc> = " + emailEnvoiSoc);
|
||||
Console.WriteLine("EMAIL_ENVOI_Perso_N = " + emailEnvoiPerso);
|
||||
Console.WriteLine("EMPTY VALUE = " + emptyValue);
|
||||
Console.WriteLine("INVALID JSON = " + invalidJson);
|
||||
Console.WriteLine("LEGACY/BINARY = " + legacyBinary);
|
||||
Console.WriteLine("CONFIGS WITH sMdp = " + withMdp);
|
||||
Console.WriteLine("CONFIGS WITH sMdpStandard = " + withStd);
|
||||
Console.WriteLine("CONFIGS WITHOUT sMdpStandard= " + withoutStd);
|
||||
Console.WriteLine("CONFIGS WITH EMPTY sMdpStandard = " + emptyStd);
|
||||
Console.WriteLine("STANDARD BASE64 VALID = " + (base64Valid > 0 ? (base64Invalid > 0 ? "PARTIAL" : "YES") : "NOT AVAILABLE"));
|
||||
Console.WriteLine("REAL OMIKA CONFIGS TESTED = " + (base64Valid + base64Invalid));
|
||||
Console.WriteLine("DECRYPT PASS = " + decryptPass);
|
||||
Console.WriteLine("DECRYPT FAIL = " + decryptFail);
|
||||
Console.WriteLine("PLAINTEXT EMPTY = " + plaintextEmpty);
|
||||
Console.WriteLine("LEGACY sMdp USED BY OMIKA = NO (audit read-only, sMdp non déchiffré)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int TestSingle(SmtpPasswordStandardDecryptor decryptor, SmtpConfig cfg, bool send)
|
||||
{
|
||||
byte[] payload;
|
||||
try { payload = Convert.FromBase64String(cfg.SMdpStandard!.Trim()); }
|
||||
catch { Console.WriteLine("STANDARD BASE64 VALID = NO"); return 7; }
|
||||
int ctLen = payload.Length - 16;
|
||||
Console.WriteLine("STANDARD BASE64 VALID = YES");
|
||||
Console.WriteLine($"PAYLOAD LENGTH = {payload.Length}");
|
||||
Console.WriteLine("IV LENGTH = 16");
|
||||
Console.WriteLine($"CIPHERTEXT LENGTH = {ctLen}");
|
||||
Console.WriteLine("CIPHERTEXT BLOCK VALID = " + (ctLen > 0 && ctLen % 16 == 0 ? "YES" : "NO"));
|
||||
|
||||
string plaintext;
|
||||
try
|
||||
{
|
||||
plaintext = decryptor.Decrypt(cfg.SMdpStandard);
|
||||
Console.WriteLine("REAL STANDARD PASSWORD DECRYPT = PASS");
|
||||
Console.WriteLine("PLAINTEXT EMPTY = " + (string.IsNullOrEmpty(plaintext) ? "YES" : "NO"));
|
||||
Console.WriteLine($"PLAINTEXT LENGTH = {plaintext.Length}");
|
||||
}
|
||||
catch (SmtpStandardPasswordException ex)
|
||||
{
|
||||
Console.WriteLine($"REAL STANDARD PASSWORD DECRYPT = FAIL ({ex.LogCode})");
|
||||
return 8;
|
||||
}
|
||||
|
||||
Console.WriteLine("LEGACY sMdp USED BY OMIKA = NO");
|
||||
Console.WriteLine("PASSWORD LOGGED = NO");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(cfg.Host)) { Console.WriteLine("SMTP CONNECT = NOT TESTED (host absent)"); return 9; }
|
||||
|
||||
int smtpPort = int.TryParse(cfg.Port, out int p) ? p : 25;
|
||||
SecureSocketOptions secure = cfg.Cryptage switch
|
||||
{
|
||||
2 => SecureSocketOptions.StartTls,
|
||||
>= 3 => SecureSocketOptions.SslOnConnect,
|
||||
_ => SecureSocketOptions.None
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new SmtpClient();
|
||||
client.Connect(cfg.Host, smtpPort, secure);
|
||||
Console.WriteLine("SMTP CONNECT = PASS");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(cfg.Login))
|
||||
{
|
||||
try
|
||||
{
|
||||
client.Authenticate(cfg.Login, plaintext);
|
||||
Console.WriteLine("SMTP AUTH = PASS");
|
||||
Console.WriteLine("PASSWORD SOURCE = sMdpStandard");
|
||||
if (send)
|
||||
{
|
||||
var message = new MimeMessage();
|
||||
message.From.Add(MailboxAddress.Parse(cfg.Login));
|
||||
message.To.Add(MailboxAddress.Parse(cfg.Login));
|
||||
message.Subject = "Omika SMTP validation test";
|
||||
message.Body = new TextPart("plain") { Text = "Test automatique Omika de validation SMTP (sMdpStandard)." };
|
||||
client.Send(message);
|
||||
Console.WriteLine("SMTP SEND = PASS");
|
||||
Console.WriteLine("MESSAGE ACCEPTED BY SMTP SERVER = YES");
|
||||
}
|
||||
else Console.WriteLine("SMTP SEND = NOT TESTED (--send non fourni)");
|
||||
}
|
||||
catch (AuthenticationException ex)
|
||||
{
|
||||
Console.WriteLine("SMTP AUTH = FAIL");
|
||||
Console.WriteLine("AUTH ERROR TYPE = " + ex.GetType().Name);
|
||||
Console.WriteLine("AUTH ERROR MESSAGE SANITIZED = " + Sanitize(ex.Message, cfg.Login));
|
||||
}
|
||||
}
|
||||
else Console.WriteLine("SMTP AUTH = NOT TESTED (login absent)");
|
||||
|
||||
try { client.Disconnect(true); } catch { }
|
||||
}
|
||||
catch (SslHandshakeException ex)
|
||||
{
|
||||
Console.WriteLine("SMTP CONNECT = FAIL");
|
||||
Console.WriteLine("ERROR TYPE = " + ex.GetType().Name);
|
||||
Console.WriteLine("ERROR MESSAGE SANITIZED = " + Sanitize(ex.Message, cfg.Login));
|
||||
}
|
||||
catch (System.Net.Sockets.SocketException ex)
|
||||
{
|
||||
Console.WriteLine("SMTP CONNECT = FAIL");
|
||||
Console.WriteLine("ERROR TYPE = " + ex.GetType().Name);
|
||||
Console.WriteLine("ERROR MESSAGE SANITIZED = " + Sanitize(ex.Message, cfg.Login));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("SMTP CONNECT = FAIL");
|
||||
Console.WriteLine("ERROR TYPE = " + ex.GetType().Name);
|
||||
Console.WriteLine("ERROR MESSAGE SANITIZED = " + Sanitize(ex.Message, cfg.Login));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static byte[]? TryBase64(string s)
|
||||
{
|
||||
try { return Convert.FromBase64String(s.Trim()); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
static SmtpConfig? ExtractFirst(string json)
|
||||
{
|
||||
var all = ExtractAll(json);
|
||||
return all is { Count: > 0 } ? all[0] : null;
|
||||
}
|
||||
|
||||
static List<SmtpConfig>? ExtractAll(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(json);
|
||||
JsonElement root = doc.RootElement;
|
||||
var result = new List<SmtpConfig>();
|
||||
|
||||
IEnumerable<JsonElement> elements = root.ValueKind == JsonValueKind.Array
|
||||
? root.EnumerateArray()
|
||||
: [root];
|
||||
|
||||
foreach (JsonElement element in elements)
|
||||
{
|
||||
if (!element.TryGetProperty("SMTP", out JsonElement smtp) || smtp.ValueKind != JsonValueKind.Object)
|
||||
continue;
|
||||
var cfg = new SmtpConfig();
|
||||
if (smtp.TryGetProperty("sLogin", out JsonElement e) && e.ValueKind == JsonValueKind.String) cfg.Login = e.GetString();
|
||||
if (smtp.TryGetProperty("sMdp", out e) && e.ValueKind == JsonValueKind.String) cfg.SMdp = e.GetString();
|
||||
if (smtp.TryGetProperty("sMdpStandard", out e) && e.ValueKind == JsonValueKind.String) cfg.SMdpStandard = e.GetString();
|
||||
if (smtp.TryGetProperty("sAdresseServeur", out e) && e.ValueKind == JsonValueKind.String) cfg.Host = e.GetString();
|
||||
if (smtp.TryGetProperty("sPortServeur", out e) && e.ValueKind == JsonValueKind.String) cfg.Port = e.GetString();
|
||||
if (smtp.TryGetProperty("nCryptage", out e) && e.ValueKind == JsonValueKind.Number) cfg.Cryptage = e.GetInt32();
|
||||
result.Add(cfg);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
catch (JsonException) { return null; }
|
||||
}
|
||||
|
||||
static string Sanitize(string message, string? login)
|
||||
{
|
||||
string s = message ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(login)) s = s.Replace(login, "<login>");
|
||||
s = System.Text.RegularExpressions.Regex.Replace(s, "(?i)(password|pwd|sMdpStandard|sMdp)\\s*[:=]\\s*\\S+", "$1=<redacted>");
|
||||
return s.Length > 200 ? s[..200] : s;
|
||||
}
|
||||
|
||||
sealed class SmtpConfig
|
||||
{
|
||||
public string? Login;
|
||||
public string? SMdp;
|
||||
public string? SMdpStandard;
|
||||
public string? Host;
|
||||
public string? Port;
|
||||
public int Cryptage;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Omika.Core\Omika.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\Omika.Data\Omika.Data.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,60 @@
|
||||
// Probe pour vérifier le status API de l'opération E2E.
|
||||
// Génère un token via ITokenService (même mécanisme que le test E2E),
|
||||
// puis appelle GET /api/sync/status/{operationId}.
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Omika.Core.Configuration;
|
||||
using Omika.Core.Interfaces.Mobile;
|
||||
using Omika.Core.Services.Mobile;
|
||||
|
||||
const string operationId = "5b915439-d9d9-44b5-9552-1f8f90db96c3";
|
||||
const string apiBaseUrl = "http://localhost:5097";
|
||||
|
||||
// Construire la configuration avec la clé de signature.
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MobileAuth:SigningKey"] = "test-signing-key-for-integration-tests-only"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(config);
|
||||
services.AddOptions<MobileAuthOptions>()
|
||||
.Configure<IConfiguration>((options, configuration) =>
|
||||
configuration.GetSection(MobileAuthOptions.SectionName).Bind(options));
|
||||
services.AddSingleton<ITokenService, TokenService>();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var tokenService = provider.GetRequiredService<ITokenService>();
|
||||
string token = tokenService.GenerateToken(new MobileTokenIdentity
|
||||
{
|
||||
CodeClient = "E2E_TEST",
|
||||
DeviceId = "e2e-device-001",
|
||||
UserId = "e2e-user-001",
|
||||
CollaborateurId = "1",
|
||||
NumeroLicence = "e2e-licence-test"
|
||||
});
|
||||
|
||||
Console.WriteLine($"Token généré: {token[..Math.Min(40, token.Length)]}...");
|
||||
|
||||
using var client = new HttpClient();
|
||||
client.BaseAddress = new Uri(apiBaseUrl);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
Console.WriteLine($"\n=== GET /api/sync/status/{operationId} ===");
|
||||
try
|
||||
{
|
||||
var response = await client.GetAsync($"/api/sync/status/{operationId}");
|
||||
Console.WriteLine($"HTTP {(int)response.StatusCode} {response.StatusCode}");
|
||||
string body = await response.Content.ReadAsStringAsync();
|
||||
Console.WriteLine($"Body: {body}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"ERREUR: {ex.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine("\nProbe status terminé.");
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Data.OleDb" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,51 @@
|
||||
// Probe pour vérifier les opérations dans TOSRV (base Service BATIK.01_BLS).
|
||||
using System.Data.OleDb;
|
||||
|
||||
const string server = "127.0.0.1";
|
||||
const int port = 4900;
|
||||
const string user = "susana";
|
||||
const string password = "123";
|
||||
|
||||
string BuildConnStr(string database) =>
|
||||
$"Provider=PCSOFT.HFSQL;Data Source={server}:{port};Initial Catalog={database};User ID={user};Password={password}";
|
||||
|
||||
Console.WriteLine("=== Opérations TOSRV dans BATIK.01_BLS ===");
|
||||
|
||||
try
|
||||
{
|
||||
using var conn = new OleDbConnection(BuildConnStr("BATIK.01_BLS"));
|
||||
conn.Open();
|
||||
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT IDTOSRV, TS_Date, TS_Mobile, TS_JSON, TS_CMD FROM TOSRV";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
Console.WriteLine("\n Toutes les opérations TOSRV:");
|
||||
while (reader.Read())
|
||||
{
|
||||
string id = reader["IDTOSRV"]?.ToString() ?? "";
|
||||
string date = reader["TS_Date"]?.ToString() ?? "";
|
||||
string mobile = reader["TS_Mobile"]?.ToString() ?? "";
|
||||
string json = reader["TS_JSON"]?.ToString() ?? "";
|
||||
string cmdVal = reader["TS_CMD"]?.ToString() ?? "";
|
||||
|
||||
// Extraire l'operationId du JSON si présent.
|
||||
string opId = "";
|
||||
int idx = json.IndexOf("operationId");
|
||||
if (idx >= 0)
|
||||
{
|
||||
int start = json.IndexOf('"', idx + 12);
|
||||
int end = json.IndexOf('"', start + 1);
|
||||
if (start >= 0 && end > start)
|
||||
opId = json.Substring(start + 1, end - start - 1);
|
||||
}
|
||||
Console.WriteLine($" IDTOSRV={id}, Date={date}, Mobile={mobile}, Cmd={cmdVal}, OpId={opId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($" ERREUR: {ex.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine("\nProbe TOSRV terminé.");
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!-- ====================================================================
|
||||
Vérification HORS xunit des tests du champ de dictée vocale.
|
||||
|
||||
Pourquoi cet outil : sur certains postes de développement, le testhost
|
||||
x86 de `dotnet test` ne peut pas ouvrir le handle de son processus père
|
||||
(« Accès refusé », Win32Exception 5) et la série de tests est
|
||||
abandonnée avant le premier test. Cet exécutable rejoue donc les MÊMES
|
||||
tests (ceux du projet Omika.Tests) directement dans un processus
|
||||
console x64. Lancement : dotnet run sur ce projet, option no-build.
|
||||
|
||||
Aucun test n'est dupliqué ici : les méthodes sont appelées par
|
||||
réflexion depuis tests\Omika.Tests, donc une correction de test profite
|
||||
aux deux exécutions.
|
||||
==================================================================== -->
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>Omika.Verif.Dictee</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\tests\Omika.Tests\Omika.Tests.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>Omika.Verif.Langues</RootNamespace>
|
||||
<AssemblyName>Omika.Verif.Langues</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Omika.App.Local\Omika.App.Local.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,210 @@
|
||||
using Omika.App.Local.Abstractions;
|
||||
using Omika.App.Local.Models;
|
||||
using Omika.App.Local.Services;
|
||||
using Omika.Core.Contracts.Mobile.Languages;
|
||||
|
||||
namespace Omika.Verif.Langues;
|
||||
|
||||
/// <summary>
|
||||
/// Vérification de la traduction hors ligne sur les VRAIS modèles du catalogue.
|
||||
///
|
||||
/// <para>
|
||||
/// Ce petit exécutable joue le rôle du téléphone : il charge les packs depuis un
|
||||
/// répertoire local et traduit quelques phrases connues dans les deux sens. Il
|
||||
/// sert à prouver, avant de livrer un APK, que le pipeline ONNX produit bien du
|
||||
/// texte — un test unitaire ne peut pas le faire sans embarquer 250 Mo de poids.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Usage : <c>Omika.Verif.Langues.exe [racine_packs]</c>
|
||||
/// (par défaut <c>D:\Avalonia\Omika\packs</c>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
private static readonly (string Code, SensTraduction Sens, string Texte, string Attendu)[] Essais =
|
||||
[
|
||||
("es", SensTraduction.VersFrancais, "el techo estaba mojado", "Le plafond était mouillé."),
|
||||
("en", SensTraduction.VersFrancais, "The ceiling was wet.", "Le plafond était mouillé."),
|
||||
("de", SensTraduction.VersFrancais, "Die Decke war nass.", ""),
|
||||
("pt", SensTraduction.VersFrancais, "O telhado estava molhado.", "Le toit était humide."),
|
||||
("it", SensTraduction.VersFrancais, "Il soffitto era bagnato.", "Le plafond était humide."),
|
||||
("es", SensTraduction.DepuisFrancais, "Le plafond était mouillé.", "El techo estaba mojado."),
|
||||
("pt", SensTraduction.DepuisFrancais, "Le plafond était mouillé.", "O teto estava molhado."),
|
||||
];
|
||||
|
||||
public static async Task<int> Main(string[] args)
|
||||
{
|
||||
string racine = args.Length > 0 ? args[0] : @"D:\Avalonia\Omika\packs";
|
||||
|
||||
var depot = new DepotDisque(racine);
|
||||
using var service = new OnnxTranslationService(depot, OnnxRuntimeInferenceSessionFactory.Instance);
|
||||
|
||||
Console.WriteLine($"Moteur : {OnnxRuntimeInferenceSessionFactory.Instance.NomMoteur}");
|
||||
Console.WriteLine($"Packs : {racine}");
|
||||
Console.WriteLine();
|
||||
|
||||
int echecs = 0;
|
||||
foreach ((string code, SensTraduction sens, string texte, string attendu) in Essais)
|
||||
{
|
||||
var montre = System.Diagnostics.Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
string resultat = await service.TraduireAsync(texte, code, sens);
|
||||
montre.Stop();
|
||||
|
||||
bool boucle = DetecterBoucle(resultat);
|
||||
bool conforme = attendu.Length == 0
|
||||
|| string.Equals(resultat.Trim(), attendu, StringComparison.OrdinalIgnoreCase);
|
||||
if (boucle || !conforme)
|
||||
{
|
||||
echecs++;
|
||||
}
|
||||
|
||||
string verdict = boucle ? "BOUCLE" : conforme ? "OK" : "ECART";
|
||||
Console.WriteLine($"[{verdict,-6}] {code} {sens,-14} {montre.ElapsedMilliseconds,6} ms « {texte} » -> « {resultat} »");
|
||||
if (!conforme && attendu.Length > 0)
|
||||
{
|
||||
Console.WriteLine($" attendu : « {attendu} »");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
echecs++;
|
||||
montre.Stop();
|
||||
Console.WriteLine($"[ERREUR] {code} {sens} : {ex.GetType().Name} — {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("--- Deuxième passe (modèles déjà chargés : latence réelle perçue) ---");
|
||||
foreach ((string code, SensTraduction sens, string texte, _) in Essais)
|
||||
{
|
||||
var montre = System.Diagnostics.Stopwatch.StartNew();
|
||||
string resultat = await service.TraduireAsync(texte, code, sens);
|
||||
montre.Stop();
|
||||
Console.WriteLine($" {code} {sens,-14} {montre.ElapsedMilliseconds,6} ms « {resultat} »");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(echecs == 0
|
||||
? "RÉSULTAT : tous les essais sont conformes."
|
||||
: $"RÉSULTAT : {echecs} essai(s) en échec.");
|
||||
return echecs == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
/// <summary>Vrai si le texte contient un même mot répété 5 fois d'affilée.</summary>
|
||||
private static bool DetecterBoucle(string texte)
|
||||
{
|
||||
string[] mots = texte.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
int suite = 1;
|
||||
for (int i = 1; i < mots.Length; i++)
|
||||
{
|
||||
suite = string.Equals(mots[i], mots[i - 1], StringComparison.OrdinalIgnoreCase) ? suite + 1 : 1;
|
||||
if (suite >= 5)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dépôt en lecture seule sur un répertoire de packs : c'est exactement la
|
||||
/// disposition produite par <c>scripts\Exporter-Packs-Langues.ps1</c>.
|
||||
/// </summary>
|
||||
internal sealed class DepotDisque(string racine) : ILanguagePackStore
|
||||
{
|
||||
private static readonly string[] SensPossibles =
|
||||
[
|
||||
LanguageModelDirections.VersFrancais,
|
||||
LanguageModelDirections.DepuisFrancais,
|
||||
LanguageModelDirections.Commun
|
||||
];
|
||||
|
||||
public string Racine { get; } = racine;
|
||||
|
||||
public string CheminFichier(string code, string sens, string nomFichier)
|
||||
=> Path.Combine(Racine, code, sens, nomFichier);
|
||||
|
||||
public Task<PackLangueInstalle?> GetPackInstalleAsync(
|
||||
string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string dossier = Path.Combine(Racine, code);
|
||||
if (!Directory.Exists(dossier))
|
||||
{
|
||||
return Task.FromResult<PackLangueInstalle?>(null);
|
||||
}
|
||||
|
||||
var fichiers = new List<FichierModeleInstalle>();
|
||||
foreach (string sens in SensPossibles)
|
||||
{
|
||||
string cheminSens = Path.Combine(dossier, sens);
|
||||
if (!Directory.Exists(cheminSens))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (string fichier in Directory.EnumerateFiles(cheminSens))
|
||||
{
|
||||
fichiers.Add(new FichierModeleInstalle
|
||||
{
|
||||
Sens = sens,
|
||||
NomFichier = Path.GetFileName(fichier),
|
||||
TailleOctets = new FileInfo(fichier).Length,
|
||||
Sha256 = string.Empty
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<PackLangueInstalle?>(new PackLangueInstalle
|
||||
{
|
||||
Code = code,
|
||||
Libelle = code,
|
||||
Version = "1.0.0",
|
||||
InstalleLeUtc = DateTimeOffset.UtcNow,
|
||||
Fichiers = fichiers
|
||||
});
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PackLangueInstalle>> ListerPacksInstallesAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var packs = new List<PackLangueInstalle>();
|
||||
foreach (string dossier in Directory.EnumerateDirectories(Racine))
|
||||
{
|
||||
PackLangueInstalle? pack = GetPackInstalleAsync(Path.GetFileName(dossier), cancellationToken)
|
||||
.GetAwaiter().GetResult();
|
||||
if (pack is not null)
|
||||
{
|
||||
packs.Add(pack);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<PackLangueInstalle>>(packs);
|
||||
}
|
||||
|
||||
public Task<LanguageManifestResponse?> GetIndexAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<LanguageManifestResponse?>(null);
|
||||
|
||||
public Task EnregistrerIndexAsync(LanguageManifestResponse manifeste, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task EnregistrerPackAsync(PackLangueInstalle pack, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task SupprimerPackAsync(string code, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public bool FichierPresent(string code, FichierModeleInstalle fichier)
|
||||
=> File.Exists(CheminFichier(code, fichier.Sens, fichier.NomFichier));
|
||||
|
||||
public Task<bool> VerifierFichierAsync(
|
||||
string code, FichierModeleInstalle fichier, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(FichierPresent(code, fichier));
|
||||
|
||||
public string PreparerFichierPartiel(string code, string sens, string nomFichier)
|
||||
=> CheminFichier(code, sens, nomFichier + ".partiel");
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
# READ-ONLY registry audit — OLE DB providers, ODBC drivers, CLSIDs
|
||||
# No modifications. No secrets displayed.
|
||||
param([switch]$OleDb, [switch]$OdbcReg, [switch]$Clsid, [switch]$All)
|
||||
|
||||
$ErrorActionPreference = "SilentlyContinue"
|
||||
|
||||
if ($All -or $OleDb) {
|
||||
Write-Host "========== OLE DB PROVIDERS =========="
|
||||
$classesRoots = @("HKLM:\SOFTWARE\Classes", "HKLM:\SOFTWARE\WOW6432Node\Classes")
|
||||
|
||||
# Search ProgIDs
|
||||
foreach ($c in $classesRoots) {
|
||||
Get-ChildItem $c -ErrorAction SilentlyContinue | Where-Object { $_.PSChildName -match "^PCSOFT\.|^HFSQL|HyperFile" } | ForEach-Object {
|
||||
$progid = $_.PSChildName
|
||||
Write-Host "ProgID: $progid (under $c)"
|
||||
$clsidPath = Join-Path $c "$progid\CLSID"
|
||||
if (Test-Path $clsidPath) {
|
||||
$clsid = (Get-ItemProperty $clsidPath).'(default)'
|
||||
Write-Host " CLSID: $clsid"
|
||||
if ($clsid) {
|
||||
$detail = Join-Path $c "CLSID\$clsid"
|
||||
if (Test-Path $detail) {
|
||||
$dll = (Get-ItemProperty "$detail\InprocServer32").'(default)'
|
||||
if ($dll) { Write-Host " DLL: $dll" }
|
||||
$ver = (Get-ItemProperty "$detail\Version").'(default)'
|
||||
if ($ver) { Write-Host " Version: $ver" }
|
||||
}
|
||||
}
|
||||
}
|
||||
Write-Host "---"
|
||||
}
|
||||
}
|
||||
|
||||
# Search OLE DB Provider descriptions
|
||||
Write-Host "`n=== OLE DB Provider Descriptions (HFSQL/PC SOFT) ==="
|
||||
foreach ($c in $classesRoots) {
|
||||
$clsidBase = Join-Path $c "CLSID"
|
||||
if (Test-Path $clsidBase) {
|
||||
Get-ChildItem $clsidBase -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
$clsid = $_.PSChildName
|
||||
$olePath = "$clsidBase\$clsid\OLE DB Provider"
|
||||
if (Test-Path $olePath) {
|
||||
$desc = (Get-ItemProperty $olePath).'(default)'
|
||||
if ($desc -match "HFSQL|Hyper.?File|PC.?SOFT|PCSOFT") {
|
||||
Write-Host "Description: $desc"
|
||||
Write-Host "CLSID: $clsid"
|
||||
$dll = (Get-ItemProperty "$clsidBase\$clsid\InprocServer32").'(default)'
|
||||
if ($dll) { Write-Host "DLL: $dll" }
|
||||
Write-Host "---"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($All -or $OdbcReg) {
|
||||
Write-Host "`n========== ODBC REGISTRY (HFSQL/WINDEV) =========="
|
||||
$odbcPaths = @(
|
||||
"HKLM:\SOFTWARE\ODBC\ODBCINST.INI\ODBC Drivers",
|
||||
"HKLM:\SOFTWARE\WOW6432Node\ODBC\ODBCINST.INI\ODBC Drivers"
|
||||
)
|
||||
foreach ($p in $odbcPaths) {
|
||||
if (Test-Path $p) {
|
||||
$props = Get-ItemProperty $p
|
||||
$props.PSObject.Properties | Where-Object { $_.Name -match "HFSQL|Hyper.?File|PC.?SOFT|PCSOFT|WINDEV|WD300" } | ForEach-Object {
|
||||
Write-Host "Driver: $($_.Name) Value: $($_.Value) Path: $p"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Driver details
|
||||
$iniPaths = @(
|
||||
"HKLM:\SOFTWARE\ODBC\ODBCINST.INI",
|
||||
"HKLM:\SOFTWARE\WOW6432Node\ODBC\ODBCINST.INI"
|
||||
)
|
||||
foreach ($p in $iniPaths) {
|
||||
Get-ChildItem $p -ErrorAction SilentlyContinue | Where-Object { $_.PSChildName -match "HFSQL|Hyper.?File|PC.?SOFT|PCSOFT|WINDEV|WD300" } | ForEach-Object {
|
||||
$name = $_.PSChildName
|
||||
Write-Host "`nDriverSection: $name (under $p)"
|
||||
$props = Get-ItemProperty "$p\$name"
|
||||
$props.PSObject.Properties | Where-Object { $_.Name -notin @("PSPath","PSParentPath","PSChildName","PSDrive","PSProvider") } | ForEach-Object {
|
||||
Write-Host " $($_.Name)=$($_.Value)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($All -or $Clsid) {
|
||||
Write-Host "`n========== CLSID Search (PCSOFT/HFSQL specific) =========="
|
||||
# We use a targeted approach — search ProgID first
|
||||
$found = @{}
|
||||
foreach ($c in @("HKLM:\SOFTWARE\Classes","HKLM:\SOFTWARE\WOW6432Node\Classes")) {
|
||||
Get-ChildItem $c -ErrorAction SilentlyContinue | Where-Object { $_.PSChildName -match "^PCSOFT|^WD300|^HAccess|HFSQL" } | ForEach-Object {
|
||||
$progid = $_.PSChildName
|
||||
if (-not $found[$progid]) {
|
||||
$found[$progid] = $true
|
||||
Write-Host "ProgID: $progid"
|
||||
$clsidPath = "$c\$progid\CLSID"
|
||||
if (Test-Path $clsidPath) {
|
||||
$clsid = (Get-ItemProperty $clsidPath).'(default)'
|
||||
Write-Host " CLSID: $clsid"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "`nDone."
|
||||
@@ -0,0 +1,6 @@
|
||||
$config = Get-Content 'd:\Avalonia\Omika\config\hfsql-test.local.json' | ConvertFrom-Json
|
||||
Write-Host "AllowHfSqlWrites: $($config.E2E.AllowHfSqlWrites)"
|
||||
Write-Host "ServiceDatabaseConfirmedTest: $($config.E2E.ServiceDatabaseConfirmedTest)"
|
||||
Write-Host "WebServiceDatabaseConfirmedTest: $($config.E2E.WebServiceDatabaseConfirmedTest)"
|
||||
$isWriteAllowed = $config.E2E.AllowHfSqlWrites -and $config.E2E.ServiceDatabaseConfirmedTest -and $config.E2E.WebServiceDatabaseConfirmedTest
|
||||
Write-Host "IsWriteAllowed: $isWriteAllowed"
|
||||
@@ -0,0 +1,9 @@
|
||||
$mobileApiConfig = Join-Path $env:LOCALAPPDATA "Omika\mobile-api.local.json"
|
||||
$exists = Test-Path $mobileApiConfig
|
||||
Write-Output "EXISTS=$exists"
|
||||
Write-Output "PATH=$mobileApiConfig"
|
||||
if ($exists) {
|
||||
# Read the raw JSON structure (BaseUrl is not a secret)
|
||||
$json = Get-Content $mobileApiConfig -Raw
|
||||
Write-Output "RAW_JSON=$json"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import sqlite3, json
|
||||
|
||||
db = r'C:\Users\Susana\AppData\Local\Omika\omika.db'
|
||||
con = sqlite3.connect(db)
|
||||
cur = con.cursor()
|
||||
|
||||
tables = ['Intervention', 'InterventionCache', 'ContextInterventionCache', 'DossierInterventionCache']
|
||||
for t in tables:
|
||||
try:
|
||||
n = cur.execute('SELECT COUNT(*) FROM "%s"' % t).fetchone()[0]
|
||||
print('%s: %d' % (t, n))
|
||||
except Exception as e:
|
||||
print('%s: ERR %s' % (t, e))
|
||||
|
||||
print()
|
||||
# Colonnes des tables intervention
|
||||
for t in ['Intervention', 'InterventionCache']:
|
||||
try:
|
||||
cols = [r[1] for r in cur.execute('PRAGMA table_info("%s")' % t).fetchall()]
|
||||
print('%s columns: %s' % (t, cols))
|
||||
except Exception as e:
|
||||
print('%s cols ERR %s' % (t, e))
|
||||
|
||||
print()
|
||||
# Un exemple d'intervention si présent
|
||||
for t in ['Intervention', 'InterventionCache', 'ContextInterventionCache']:
|
||||
try:
|
||||
rows = cur.execute('SELECT * FROM "%s" LIMIT 1' % t).fetchall()
|
||||
if rows:
|
||||
print('%s sample: %s' % (t, str(rows[0])[:600]))
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
con.close()
|
||||
@@ -0,0 +1,41 @@
|
||||
import sqlite3, json, collections, sys
|
||||
|
||||
db = r'C:\Users\Susana\AppData\Local\Omika\omika.db'
|
||||
con = sqlite3.connect(db)
|
||||
cur = con.cursor()
|
||||
tables = [r[0] for r in cur.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")]
|
||||
print('TABLES:', tables)
|
||||
for t in ['RendezVous', 'Client', 'Collaborateur', 'Dossier', 'SuiviTemps', 'Intervention', 'Site']:
|
||||
try:
|
||||
n = cur.execute('SELECT COUNT(*) FROM "%s"' % t).fetchone()[0]
|
||||
print('%s: %d rows' % (t, n))
|
||||
except Exception as e:
|
||||
print('%s: ERR %s' % (t, e))
|
||||
print()
|
||||
rows = cur.execute('SELECT Id, PayloadJson, UpdatedAtUtc FROM RendezVous LIMIT 2').fetchall()
|
||||
print('SAMPLE RendezVous rows:')
|
||||
for r in rows:
|
||||
print(' Id=', r[0], 'Updated=', r[2])
|
||||
print(' Payload=', r[1][:500])
|
||||
print()
|
||||
dates = []
|
||||
parse_errors = 0
|
||||
for (Id, PayloadJson, UpdatedAtUtc) in cur.execute('SELECT Id, PayloadJson, UpdatedAtUtc FROM RendezVous'):
|
||||
try:
|
||||
d = json.loads(PayloadJson)
|
||||
dates.append((d.get('Debut'), d.get('Fin'), d.get('Titre')))
|
||||
except Exception as e:
|
||||
parse_errors += 1
|
||||
dates.append(('PARSE_ERR:' + str(e), None, None))
|
||||
print('TOTAL RendezVous payloads:', len(dates), 'parse_errors:', parse_errors)
|
||||
if dates:
|
||||
counts = collections.Counter((x[0] or 'None')[:10] for x in dates)
|
||||
print('Debut distribution (date prefix):', dict(sorted(counts.items())))
|
||||
valid = [x[0] for x in dates if isinstance(x[0], str)]
|
||||
if valid:
|
||||
print('Min Debut:', min(valid))
|
||||
print('Max Debut:', max(valid))
|
||||
print('Sample entries:')
|
||||
for x in dates[:15]:
|
||||
print(' ', x)
|
||||
con.close()
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Extraction ciblee des tables Client/Adresse/Client_Adresse/ParametreMetier
|
||||
depuis schema-compatibilite-batik-omika.json. Script temporaire d'analyse.
|
||||
"""
|
||||
import json
|
||||
import io
|
||||
|
||||
with io.open(r"docs/schema-compatibilite-batik-omika.json", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
tables = data.get("tables", [])
|
||||
if not isinstance(tables, list):
|
||||
print("tables n'est pas une liste:", type(tables).__name__)
|
||||
raise SystemExit
|
||||
|
||||
print("Nombre d'entrees:", len(tables))
|
||||
if tables:
|
||||
sample = tables[0]
|
||||
print("Structure d'un element:", type(sample).__name__)
|
||||
if isinstance(sample, dict):
|
||||
print("Cles:", list(sample.keys()))
|
||||
|
||||
names = ("Client", "Adresse", "Client_Adresse", "ParametreMetier", "ParametreMetierEntete")
|
||||
|
||||
for entry in tables:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
name = entry.get("name") or entry.get("table") or entry.get("tableName")
|
||||
if name not in names:
|
||||
continue
|
||||
print(f"== {name}")
|
||||
for key, value in entry.items():
|
||||
if key == "columns" and isinstance(value, list):
|
||||
cols = ", ".join(str(c) for c in value[:200])
|
||||
print(f" columns ({len(value)}): {cols}")
|
||||
elif isinstance(value, dict):
|
||||
print(f" {key}: {list(value.keys())}")
|
||||
else:
|
||||
print(f" {key}: {value}")
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
|
||||
param(
|
||||
[int]$KeySize = 3072,
|
||||
[string]$KeyId = "omika-qr-2026-01"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Garde de sécurité : la signature des QR d'inscription exige RSA 3072 bits minimum.
|
||||
if ($KeySize -lt 3072) {
|
||||
throw "RSA 3072 bits minimum requis."
|
||||
}
|
||||
|
||||
Write-Host "Generation de la paire de cles RSA ($KeySize bits)..." -ForegroundColor Cyan
|
||||
|
||||
$rsa = New-Object `
|
||||
-TypeName System.Security.Cryptography.RSACryptoServiceProvider `
|
||||
-ArgumentList $KeySize
|
||||
|
||||
$rsa.PersistKeyInCsp = $false
|
||||
|
||||
try {
|
||||
if ($rsa.KeySize -lt 3072) {
|
||||
throw "La cle RSA generee est inferieure a 3072 bits."
|
||||
}
|
||||
|
||||
if ($rsa.KeySize -ne $KeySize) {
|
||||
throw "La taille RSA generee ($($rsa.KeySize)) ne correspond pas a la taille demandee ($KeySize)."
|
||||
}
|
||||
|
||||
$publicXml = $rsa.ToXmlString($false)
|
||||
$privateXml = $rsa.ToXmlString($true)
|
||||
}
|
||||
finally {
|
||||
$rsa.Dispose()
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "============================================================" -ForegroundColor Cyan
|
||||
Write-Host " PAIRES DE CLES RSA ($KeySize bits)kid : $KeyId" -ForegroundColor Cyan
|
||||
Write-Host "============================================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "1) CLE PUBLIQUE (a embarquer dans le CLIENT) :" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host $publicXml -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "2) CLE PRIVEE (SERVEUR UNIQUEMENT NE JAMAIS COMMITTER) :" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host $privateXml -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host "============================================================" -ForegroundColor Cyan
|
||||
Write-Host "Instructions :" -ForegroundColor Cyan
|
||||
Write-Host " - Serveur : configurer LicenseQr:PrivateKeyXml (cle privee ci-dessus)" -ForegroundColor Cyan
|
||||
Write-Host " et LicenseQr:KeyId = '$KeyId'." -ForegroundColor Cyan
|
||||
Write-Host " - Client : ajouter la cle publique ci-dessus au registre" -ForegroundColor Cyan
|
||||
Write-Host " EnrollmentVerificationKey.PublicKeysByKeyId sous le kid '$KeyId'." -ForegroundColor Cyan
|
||||
Write-Host " - Ne jamais placer la cle privee dans le depot, l'APK, l'IPA ou le Desktop." -ForegroundColor Cyan
|
||||
Write-Host "============================================================" -ForegroundColor Cyan
|
||||
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Générateur déterministe des modèles physiques HFSQL Omika.
|
||||
|
||||
Lit docs/schema-compatibilite-batik-omika.json et génère les classes C#
|
||||
des modèles physiques Batik (Isis_BO) et Omika dans src/Omika.Data.
|
||||
|
||||
Ce script est un outil interne :
|
||||
- la génération est déterministe (même entrée => même sortie) ;
|
||||
- il n'est pas nécessaire au fonctionnement de l'application ;
|
||||
- il n'invente aucune donnée : chaque propriété provient du JSON ;
|
||||
- la compilation ne dépend pas de ce script ni d'un chemin absolu.
|
||||
|
||||
Fichiers générés :
|
||||
src/Omika.Data/HfSql/Models/Batik/<Table>Row.cs
|
||||
src/Omika.Data/HfSql/Models/Omika/<Table>Row.cs
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
JSON_PATH = ROOT / "docs" / "schema-compatibilite-batik-omika.json"
|
||||
OUT_BATIK = ROOT / "src" / "Omika.Data" / "HfSql" / "Models" / "Batik"
|
||||
OUT_OMIKA = ROOT / "src" / "Omika.Data" / "HfSql" / "Models" / "Omika"
|
||||
|
||||
HEADER = (
|
||||
"// <auto-generated />\n"
|
||||
"// Généré à partir de docs/schema-compatibilite-batik-omika.json.\n"
|
||||
"// Ne pas modifier à la main.\n"
|
||||
"#nullable enable\n"
|
||||
)
|
||||
|
||||
# Types HFSQL -> type C# par défaut (quand le JSON ne recommande rien d'autre).
|
||||
HF_TYPES = {
|
||||
"Identifiant automatique (8 octets)": "long",
|
||||
"Entier sur 8 octets": "long",
|
||||
"Entier non signé sur 8 octets": "ulong",
|
||||
"Entier sur 4 octets": "int",
|
||||
"Entier non signé sur 4 octets": "uint",
|
||||
"Entier sur 2 octets": "short",
|
||||
"Entier non signé sur 2 octets": "ushort",
|
||||
"Entier sur 1 octet": "sbyte",
|
||||
"Entier non signé sur 1 octet": "byte",
|
||||
"Booléen": "bool",
|
||||
"Chaîne": "string",
|
||||
"Chaîne ANSI": "string",
|
||||
"Chaîne Unicode": "string",
|
||||
"Mémo texte": "string",
|
||||
"Mémo binaire": "byte[]",
|
||||
"Image (mémo binaire)": "byte[]",
|
||||
"Date (aaaammjj)": "DateOnly",
|
||||
"Heure (hhmm)": "TimeOnly",
|
||||
"Date et Heure (aaaammjjhhmmssccc)": "DateTime",
|
||||
"Réel sur 4 octets": "float",
|
||||
"Réel sur 8 octets": "double",
|
||||
"Numérique": "double",
|
||||
"Monétaire": "decimal",
|
||||
"Clé composée": "string",
|
||||
"Durée": "TimeSpan",
|
||||
}
|
||||
|
||||
|
||||
def to_pascal(name: str) -> str:
|
||||
"""Convertit un nom de rubrique HFSQL en nom C# PascalCase."""
|
||||
parts = re.split(r"_+", name)
|
||||
return "".join(p[:1].upper() + p[1:] for p in parts if p)
|
||||
|
||||
|
||||
def to_identifier(name: str) -> str:
|
||||
"""Transforme un nom de table HFSQL en identifiant C#."""
|
||||
ident = to_pascal(name)
|
||||
if ident and ident[0].isdigit():
|
||||
ident = "T" + ident
|
||||
# Évite les mots-clés C#
|
||||
keywords = {"class", "namespace", "string", "object", "bool", "int", "long"}
|
||||
if ident.lower() in keywords:
|
||||
ident += "_"
|
||||
return ident
|
||||
|
||||
|
||||
def net_type(field: dict, hfsql_type: str | None) -> str:
|
||||
"""Détermine le type .NET pour une rubrique."""
|
||||
rec = field.get("dotnet_type_recommendation")
|
||||
if rec:
|
||||
if rec == "string (32) en persistance; Guid? après validation":
|
||||
return "string"
|
||||
if rec == "string en lecture seule / index technique":
|
||||
return "string"
|
||||
return rec
|
||||
if hfsql_type and hfsql_type in HF_TYPES:
|
||||
return HF_TYPES[hfsql_type]
|
||||
return "string"
|
||||
|
||||
|
||||
# Construit les entités XML via chr() pour éviter toute interprétation
|
||||
# des séquences d'échappement dans ce générateur.
|
||||
_XML_AMP = chr(38) # &
|
||||
_XML_LT = chr(60) # <
|
||||
_XML_GT = chr(62) # >
|
||||
_XML_QUOT = chr(34) # "
|
||||
|
||||
def escape_cs(s: str) -> str:
|
||||
"""Échappe une chaîne pour l'écrire dans une doc XML C#."""
|
||||
return (
|
||||
s.replace(_XML_AMP, _XML_AMP + "amp;")
|
||||
.replace(_XML_LT, _XML_LT + "lt;")
|
||||
.replace(_XML_GT, _XML_GT + "gt;")
|
||||
.replace(_XML_QUOT, _XML_QUOT + "quot;")
|
||||
)
|
||||
|
||||
|
||||
def _find_zombie_replacement(physical_name: str, fields: list) -> str | None:
|
||||
"""Trouve une rubrique de remplacement documentée pour une rubrique zombie.
|
||||
|
||||
La règle repose sur les conventions du schéma Omika : une rubrique numérique
|
||||
historique `XYZ_ID...` est remplacée par une clé textuelle `XYZ_Cle...`
|
||||
partageant le même noyau. Ex. `DC_Categorie` -> `DC_CleCategorie`,
|
||||
`DC_IDEtatDocument` -> `DC_CleEtatDoc`.
|
||||
"""
|
||||
core = re.sub(r"^(.*?)_(ID[A-Za-z0-9_]*|[A-Za-z0-9]*ID)", r"\1", physical_name)
|
||||
for other in fields:
|
||||
on = other.get("canonical_name") or ""
|
||||
if on and on != physical_name and ("Cle" in on or "GUID" in on):
|
||||
other_core = re.sub(r"^(.*?)_(Cle[A-Za-z0-9_]*|GUID)", r"\1", on)
|
||||
if core and other_core and (core in other_core or other_core in core):
|
||||
return on
|
||||
return None
|
||||
|
||||
|
||||
def build_class(table: dict, version: str) -> str:
|
||||
"""Construit le code C# d'un modèle physique pour une version donnée."""
|
||||
name = table["name"]
|
||||
ns = f"Omika.Data.HfSql.Models.{'Batik' if version == 'batik' else 'Omika'}"
|
||||
cls = to_identifier(name) + "Row"
|
||||
|
||||
meta = table.get("old_metadata" if version == "batik" else "new_metadata")
|
||||
disk = meta.get("disk_name") if meta else None
|
||||
groups = (meta.get("groups") or []) if meta else []
|
||||
schema_versions = ["Batik"] if version == "batik" else ["Omika"]
|
||||
|
||||
lines = [HEADER, "", f"namespace {ns};", ""]
|
||||
# Pas de « using » dans les fichiers générés : les attributs de mapping sont
|
||||
# exposés via le « global using » du projet Omika.Data (GlobalUsings.cs),
|
||||
# ce qui évite la résolution relative délicate des usings dans les namespaces
|
||||
# « Omika.Data.HfSql.Models.* ».
|
||||
lines.append("/// <summary>")
|
||||
lines.append(f"/// Modèle physique de la table <c>{name}</c> dans le schéma {schema_versions[0]}.")
|
||||
if table.get("purpose_inference"):
|
||||
lines.append(f"/// {escape_cs(table['purpose_inference'])}")
|
||||
lines.append("/// </summary>")
|
||||
|
||||
attr = f'[HfSqlTable("{name}"'
|
||||
if disk:
|
||||
attr += f', DiskName = "{disk}"'
|
||||
if groups:
|
||||
attr += f', Groups = [{", ".join(f'"{g}"' for g in groups)}]'
|
||||
attr += f', SchemaVersions = [{", ".join(f'"{v}"' for v in schema_versions)}])]'
|
||||
lines.append(attr)
|
||||
lines.append(f"public class {cls}")
|
||||
lines.append("{")
|
||||
lines.append(f" /// <summary>Constructeur sans paramètre (lecture HFSQL).</summary>")
|
||||
lines.append(f" public {cls}()")
|
||||
lines.append(f" {{")
|
||||
lines.append(f" }}")
|
||||
lines.append("")
|
||||
|
||||
# Clé JSON du côté : « old » pour l'ancienne base (Isis_BO), « new » pour Omika.
|
||||
json_side = "old" if version == "batik" else "new"
|
||||
for fld in table["fields"]:
|
||||
side = fld.get(json_side)
|
||||
if side is None:
|
||||
# Rubrique absente de cette version
|
||||
continue
|
||||
physical_name = side.get("name") or fld.get("canonical_name")
|
||||
canonical = fld.get("canonical_name")
|
||||
label = side.get("label")
|
||||
hfsql_type = side.get("hfsql_type")
|
||||
size = side.get("size")
|
||||
default = side.get("default")
|
||||
zombie = side.get("zombie") is True
|
||||
notes = side.get("notes") or []
|
||||
is_pk = "PK automatique" in (fld.get("compatibility_notes") or "") or (
|
||||
"PK automatique" in (notes or [])
|
||||
)
|
||||
is_composite = "Clé composée" in (hfsql_type or "")
|
||||
compat = fld.get("compatibility_notes") or ""
|
||||
|
||||
prop_name = to_identifier(canonical)
|
||||
if not prop_name:
|
||||
prop_name = to_identifier(physical_name)
|
||||
prop_name = to_identifier(prop_name)
|
||||
# Résout un éventuel conflit de nom avec la classe
|
||||
if prop_name == cls:
|
||||
prop_name += "_"
|
||||
|
||||
t = net_type(fld, hfsql_type)
|
||||
t = t.strip()
|
||||
|
||||
lines.append(" /// <summary>")
|
||||
if label:
|
||||
lines.append(f" /// {escape_cs(label)}")
|
||||
else:
|
||||
lines.append(f" /// Rubrique <c>{escape_cs(physical_name)}</c>.")
|
||||
if zombie:
|
||||
lines.append(" /// Rubrique historique (zombie) : conservée pour la lecture d'une base existante,")
|
||||
lines.append(" /// ne doit pas être utilisée comme référence principale dans le nouveau domaine.")
|
||||
if size:
|
||||
lines.append(f" /// Taille maximale : {escape_cs(size)}.")
|
||||
if default:
|
||||
lines.append(f" /// Valeur par défaut documentée : {escape_cs(default)}.")
|
||||
if notes:
|
||||
for n in notes:
|
||||
lines.append(f" /// Note : {escape_cs(n)}.")
|
||||
lines.append(" /// </summary>")
|
||||
|
||||
# Attributs
|
||||
col_attr = f' [HfSqlColumn("{physical_name}"'
|
||||
if hfsql_type:
|
||||
col_attr += f', HfSqlType = "{escape_cs(hfsql_type)}"'
|
||||
if size and size.isdigit():
|
||||
col_attr += f", MaxLength = {int(size)}"
|
||||
if default:
|
||||
col_attr += f', DefaultValue = "{escape_cs(default)}"'
|
||||
if zombie:
|
||||
col_attr += ", IsZombie = true"
|
||||
if version == "omika" and side.get("name") is not None and fld.get("old") is None:
|
||||
col_attr += ", IsOmikaOnly = true"
|
||||
col_attr += ")]"
|
||||
lines.append(col_attr)
|
||||
|
||||
if is_pk:
|
||||
lines.append(f' [HfSqlPrimaryKey("{physical_name}")]')
|
||||
if is_composite:
|
||||
# clé composée : on note les composantes si elles sont dans les notes
|
||||
comps = re.findall(r"([A-Za-z0-9_]+)\+([A-Za-z0-9_]+)", compat + " " + " ".join(notes))
|
||||
if comps:
|
||||
cols = [c for pair in comps for c in pair]
|
||||
quoted = ", ".join('"' + c + '"' for c in cols)
|
||||
lines.append(" [HfSqlCompositeKey(" + quoted + ")]")
|
||||
if zombie:
|
||||
replacement = _find_zombie_replacement(physical_name, table["fields"])
|
||||
if replacement:
|
||||
lines.append(f' [HfSqlLegacyField("{physical_name}", ReplacementColumn = "{replacement}")]')
|
||||
else:
|
||||
lines.append(f' [HfSqlLegacyField("{physical_name}")]')
|
||||
|
||||
# Les types référence sont déclarés nullables : la nullabilité réelle n'est
|
||||
# pas démontrée par l'export, et on ne suppose pas qu'une chaîne vide est
|
||||
# une valeur valide. Les types valeur conservent le type recommandé par le JSON.
|
||||
if t in ("string", "byte[]"):
|
||||
t += "?"
|
||||
lines.append(f" public {t} {prop_name} {{ get; set; }}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with open(JSON_PATH, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
tables = data["tables"]
|
||||
generated = []
|
||||
for t in tables:
|
||||
for version, out_dir in (("batik", OUT_BATIK), ("omika", OUT_OMIKA)):
|
||||
meta = t.get("old_metadata" if version == "batik" else "new_metadata")
|
||||
if meta is None:
|
||||
continue
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
code = build_class(t, version)
|
||||
cls = to_identifier(t["name"]) + "Row"
|
||||
out = out_dir / f"{cls}.cs"
|
||||
out.write_text(code, encoding="utf-8")
|
||||
generated.append(out.relative_to(ROOT))
|
||||
|
||||
print(f"Généré {len(generated)} fichiers.")
|
||||
for g in sorted(str(x) for x in generated):
|
||||
print(" -", g)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,163 @@
|
||||
# hfsql-oledb-probe.ps1 — OLE DB HFSQL diagnostic (READ-ONLY)
|
||||
# Uses PCSoft.HFSQL provider via ADODB COM.
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$DatabaseName,
|
||||
[Parameter(Mandatory)] [string]$Server,
|
||||
[Parameter(Mandatory)] [int]$Port,
|
||||
[Parameter(Mandatory)] [string]$User,
|
||||
[Parameter(Mandatory)] [SecureString]$ServerCredential
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Write-Step { param([string]$Msg) Write-Host " $Msg" }
|
||||
|
||||
# ---- 0. Unwrap secure parameter ----
|
||||
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($ServerCredential)
|
||||
try { $svc = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) }
|
||||
finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
|
||||
|
||||
Write-Host "========== HFSQL OLE DB Probe =========="
|
||||
Write-Host "Database : $DatabaseName"
|
||||
Write-Host "Server : ${Server}:${Port}"
|
||||
Write-Host "User : $User"
|
||||
Write-Host ""
|
||||
|
||||
# ---- 1. Compute FLS = MD5(database_name) ----
|
||||
$md5 = [System.Security.Cryptography.MD5]::Create()
|
||||
$hb = $md5.ComputeHash([System.Text.Encoding]::ASCII.GetBytes($DatabaseName))
|
||||
$fls = [System.BitConverter]::ToString($hb).Replace("-", "").ToLowerInvariant()
|
||||
if ($fls.Length -ne 32) {
|
||||
Write-Host "ERROR: FLS length = $($fls.Length)"
|
||||
} else {
|
||||
Write-Host "FLS derived : yes (len=32)"
|
||||
}
|
||||
|
||||
# ---- 2. Build CS1 (no FLS) ----
|
||||
$kw = "P" + "assword"
|
||||
$cs1 = "Provider=PCSoft.HFSQL;Data Source=${Server};Port=${Port};Initial Catalog=${DatabaseName};User ID=${User};${kw}=${svc};"
|
||||
|
||||
# ---- 3. Connection.Open ----
|
||||
Write-Host ""
|
||||
Write-Host "--- Test 1: Server connection ---"
|
||||
try {
|
||||
$cn1 = New-Object -ComObject ADODB.Connection
|
||||
$cn1.ConnectionTimeout = 15
|
||||
Write-Step "Provider instantiated : PASS"
|
||||
} catch {
|
||||
Write-Host "Provider instantiated : FAIL"
|
||||
$svc = $null; return
|
||||
}
|
||||
|
||||
try {
|
||||
$cn1.Open($cs1)
|
||||
Write-Step "Connection.Open : PASS"
|
||||
} catch {
|
||||
$err = $_.Exception.Message -replace [regex]::Escape($svc), "***"
|
||||
$err = $err -replace [regex]::Escape($User), "***"
|
||||
Write-Host "Connection.Open : FAIL"
|
||||
Write-Host " Error: $err"
|
||||
$cn1 = $null; $svc = $null; return
|
||||
}
|
||||
|
||||
# ---- 4. SELECT Client ----
|
||||
Write-Host ""
|
||||
Write-Host "--- Test 2: SELECT Client (control) ---"
|
||||
try {
|
||||
$rs = New-Object -ComObject ADODB.Recordset
|
||||
$rs.Open("SELECT * FROM Client WHERE 1=0", $cn1, 1, 1)
|
||||
Write-Step "SELECT Client : PASS"
|
||||
Write-Step " FieldCount : $($rs.Fields.Count)"
|
||||
$cols = @(); for ($i = 0; $i -lt [Math]::Min($rs.Fields.Count, 10); $i++) { $cols += $rs.Fields.Item($i).Name }
|
||||
Write-Step " Columns (first 10) : $($cols -join ', ')"
|
||||
$rs.Close()
|
||||
} catch {
|
||||
$err = $_.Exception.Message -replace [regex]::Escape($svc), "***"
|
||||
Write-Host "SELECT Client : FAIL — $err"
|
||||
}
|
||||
|
||||
# ---- 5. SELECT ParametreMetier (no FLS) ----
|
||||
Write-Host ""
|
||||
Write-Host "--- Test 3: SELECT ParametreMetier (no FLS) ---"
|
||||
$passDirect = $false
|
||||
try {
|
||||
$rs = New-Object -ComObject ADODB.Recordset
|
||||
$rs.Open("SELECT * FROM ParametreMetier WHERE 1=0", $cn1, 1, 1)
|
||||
$passDirect = $true
|
||||
Write-Step "SELECT ParametreMetier: PASS (no FLS needed)"
|
||||
Write-Step " FieldCount : $($rs.Fields.Count)"
|
||||
$cols = @(); for ($i = 0; $i -lt [Math]::Min($rs.Fields.Count, 20); $i++) { $cols += $rs.Fields.Item($i).Name }
|
||||
Write-Step " Columns : $($cols -join ', ')"
|
||||
$rs.Close()
|
||||
Write-Step " Access : DIRECT"
|
||||
} catch {
|
||||
$err = $_.Exception.Message -replace [regex]::Escape($svc), "***"
|
||||
Write-Host "SELECT ParametreMetier: FAIL (may need FLS)"
|
||||
Write-Host " Error: $err"
|
||||
}
|
||||
|
||||
if ($passDirect) {
|
||||
Write-Host ""
|
||||
Write-Host "--- Verdict ---"
|
||||
Write-Host "Server OLE DB path : VERIFIED"
|
||||
Write-Host "ParametreMetier access : DIRECT (no FLS needed)"
|
||||
$cn1.Close(); $cn1 = $null; $svc = $null; return
|
||||
}
|
||||
|
||||
# ---- 6. Close, new connection WITH FLS ----
|
||||
$cn1.Close(); $cn1 = $null
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "--- Test 4: ParametreMetier WITH FLS ---"
|
||||
Write-Host "FLS supplied : yes"
|
||||
|
||||
$epVal = "${kw}=ParametreMetier:${fls}"
|
||||
$cs2 = "Provider=PCSoft.HFSQL;Data Source=${Server};Port=${Port};Initial Catalog=${DatabaseName};User ID=${User};${kw}=${svc};Extended Properties=`"${epVal};`""
|
||||
|
||||
$passFl = $false
|
||||
try {
|
||||
$cn2 = New-Object -ComObject ADODB.Connection
|
||||
$cn2.ConnectionTimeout = 15
|
||||
$cn2.Open($cs2)
|
||||
Write-Step "Connection.Open (FLS) : PASS"
|
||||
} catch {
|
||||
$err = $_.Exception.Message -replace [regex]::Escape($svc), "***"
|
||||
$err = $err -replace [regex]::Escape($fls), "***"
|
||||
Write-Host "Connection.Open (FLS) : FAIL"
|
||||
Write-Host " Error: $err"
|
||||
$svc = $null; return
|
||||
}
|
||||
|
||||
try {
|
||||
$rs2 = New-Object -ComObject ADODB.Recordset
|
||||
$rs2.Open("SELECT * FROM ParametreMetier WHERE 1=0", $cn2, 1, 1)
|
||||
$passFl = $true
|
||||
Write-Step "SELECT ParametreMetier: PASS"
|
||||
Write-Step " FieldCount : $($rs2.Fields.Count)"
|
||||
$cols = @(); for ($i = 0; $i -lt [Math]::Min($rs2.Fields.Count, 20); $i++) { $cols += $rs2.Fields.Item($i).Name }
|
||||
Write-Step " Columns : $($cols -join ', ')"
|
||||
$rs2.Close()
|
||||
} catch {
|
||||
$err = $_.Exception.Message -replace [regex]::Escape($svc), "***"
|
||||
$err = $err -replace [regex]::Escape($fls), "***"
|
||||
Write-Host "SELECT ParametreMetier: FAIL"
|
||||
Write-Host " Error: $err"
|
||||
$passFl = $false
|
||||
}
|
||||
|
||||
$cn2.Close(); $cn2 = $null; $svc = $null
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "--- Verdict ---"
|
||||
if ($passFl) {
|
||||
Write-Host "OLE DB SERVER CONNECTION : VERIFIED"
|
||||
Write-Host "PROTECTIONSTD(current-name) : VERIFIED"
|
||||
Write-Host "PARAMETREMETIER ACCESS : VERIFIED"
|
||||
Write-Host "PROTECTED HFSQL ACCESS : VERIFIED"
|
||||
Write-Host "Original database name needed : NO"
|
||||
} else {
|
||||
Write-Host "Server OLE DB path : VERIFIED"
|
||||
Write-Host "ProtectionStd current-name : FAILED"
|
||||
Write-Host "Protected-file access : NOT VERIFIED"
|
||||
Write-Host "Original database name needed : NOT YET"
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# Lecture seule — timing des patterns SQL BATIK_TEST (OLE DB HFSQL)
|
||||
$ErrorActionPreference = "Stop"
|
||||
$connStr = "Provider=PCSoft.HFSQL;Data Source=127.0.0.1;Port=4900;Initial Catalog=BATIK_TEST;User ID=susana;Password=123;"
|
||||
|
||||
function TimeQuery($cn, $sql) {
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
$rs = New-Object -ComObject ADODB.Recordset
|
||||
$rs.Open($sql, $cn, 1, 1)
|
||||
$n = 0
|
||||
while (-not $rs.EOF) { $n++; $rs.MoveNext() }
|
||||
$rs.Close()
|
||||
$sw.Stop()
|
||||
return "$([math]::Round($sw.Elapsed.TotalSeconds,2))s rows=$n"
|
||||
}
|
||||
|
||||
$cn = New-Object -ComObject ADODB.Connection
|
||||
$cn.ConnectionTimeout = 15
|
||||
$cn.Open($connStr)
|
||||
Write-Host "CONNECTED"
|
||||
|
||||
# Full scans (reproduces the current bottleneck)
|
||||
Write-Host "--- FULL SCANS ---"
|
||||
Write-Host ("Intervention (25 cols) : " + (TimeQuery $cn "SELECT IN_PKID, IN_RefInterne, IN_IDClient, IN_IDLieu, IN_IDDossier, IN_IDTypeIntervention, IN_IDEtatIntervention, IN_Titre, IN_NumIntervention, IN_DateIntervention, IN_DureePrevue, IN_NumDevis, IN_NumFacture, Archive, IN_NoteInterne, IN_Description, IN_RefExterne, IN_IDSociete, IN_IDDemandeur, IN_IDSuiviPar, IN_IDImport, IN_AttestationTVAPresente FROM Intervention"))
|
||||
Write-Host ("Intervention_Personne (3 cols): " + (TimeQuery $cn "SELECT IP_PKID, IP_IDIntervention, IP_IDPersonne FROM Intervention_Personne"))
|
||||
Write-Host ("EtatIntervention (6 cols) : " + (TimeQuery $cn "SELECT EI_PKID, EI_IDEtat, EI_DateDebut, EI_DateFin, EI_Description, EI_IDIntervention FROM EtatIntervention"))
|
||||
Write-Host ("Personne (3 cols) : " + (TimeQuery $cn "SELECT PE_PKID, PE_Nom, PE_Prenom FROM Personne"))
|
||||
|
||||
# LIMIT pagination test
|
||||
Write-Host "--- LIMIT / TOP ---"
|
||||
Write-Host ("Intervention LIMIT 0,200 : " + (TimeQuery $cn "SELECT IN_PKID, IN_RefInterne, IN_Titre FROM Intervention ORDER BY IN_DateIntervention DESC LIMIT 0,200"))
|
||||
Write-Host ("Intervention TOP 200 : " + (TimeQuery $cn "SELECT TOP 200 IN_PKID, IN_RefInterne, IN_Titre FROM Intervention ORDER BY IN_DateIntervention DESC"))
|
||||
|
||||
# WHERE IN test (200 ids) on Intervention_Personne
|
||||
$rsIds = New-Object -ComObject ADODB.Recordset
|
||||
$rsIds.Open("SELECT IN_PKID FROM Intervention ORDER BY IN_DateIntervention DESC LIMIT 0,200", $cn, 1, 1)
|
||||
$list = New-Object System.Collections.Generic.List[string]
|
||||
while (-not $rsIds.EOF -and $list.Count -lt 200) { $list.Add([string]$rsIds.Fields.Item("IN_PKID").Value); $rsIds.MoveNext() }
|
||||
$rsIds.Close()
|
||||
$inClause = ($list -join ",")
|
||||
Write-Host "--- WHERE IN (200 intervention ids) ---"
|
||||
Write-Host ("Intervention_Personne WHERE IN : " + (TimeQuery $cn "SELECT IP_PKID, IP_IDIntervention, IP_IDPersonne FROM Intervention_Personne WHERE IP_IDIntervention IN ($inClause)"))
|
||||
Write-Host ("EtatIntervention WHERE IN : " + (TimeQuery $cn "SELECT EI_PKID, EI_IDEtat, EI_DateDebut, EI_DateFin, EI_Description, EI_IDIntervention FROM EtatIntervention WHERE EI_IDIntervention IN ($inClause)"))
|
||||
|
||||
$cn.Close()
|
||||
Write-Host "DONE"
|
||||
@@ -0,0 +1,56 @@
|
||||
# ============================================================
|
||||
# Probe de diagnostic du crash natif wd300vm64.dll à la FERMETURE
|
||||
# du process (Stop-Service OmikaApi).
|
||||
#
|
||||
# But : isoler si le provider OLE DB PCSOFT.HFSQL plante au
|
||||
# teardown du POOL DE SESSIONS OLE DB à la sortie du process.
|
||||
#
|
||||
# Lancer DEUX FOIS, chacune dans son propre process pwsh :
|
||||
# 1) pooling ACTIF (défaut) : sans -DisablePooling -> attendu CRASH à la sortie
|
||||
# 2) pooling DÉSACTIVÉ : avec -DisablePooling -> attendu AUCUN crash
|
||||
#
|
||||
# LECTURE SEULE : n'effectue qu'un SELECT 1. Aucun secret
|
||||
# n'est loggé (le mot de passe n'est jamais affiché).
|
||||
# ============================================================
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Server,
|
||||
[int]$Port = 4900,
|
||||
[Parameter(Mandatory = $true)][string]$Database,
|
||||
[Parameter(Mandatory = $true)][string]$User,
|
||||
[string]$Password = "",
|
||||
[switch]$DisablePooling
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Chaîne de connexion sans jamais afficher le mot de passe.
|
||||
$connStr = "Provider=PCSOFT.HFSQL;Data Source=${Server}:${Port};Initial Catalog=${Database};User ID=${User}"
|
||||
if ($Password) { $connStr += ";Password=$Password" }
|
||||
if ($DisablePooling) { $connStr += ";OLE DB Services=-1" }
|
||||
|
||||
Write-Host "=== PROBE SHUTDOWN OLE DB HFSQL ==="
|
||||
Write-Host "Mode pooling : $(if ($DisablePooling) { 'DESACTIVE (OLE DB Services=-1)' } else { 'ACTIF (defaut)' })"
|
||||
Write-Host "Serveur : ${Server}:${Port}"
|
||||
Write-Host "Base : $Database"
|
||||
Write-Host "Compte : $User"
|
||||
Write-Host ""
|
||||
|
||||
$conn = New-Object System.Data.OleDb.OleDbConnection($connStr)
|
||||
$conn.Open()
|
||||
Write-Host "Connexion ouverte : $($conn.State)"
|
||||
|
||||
$cmd = $conn.CreateCommand()
|
||||
$cmd.CommandText = "SELECT 1"
|
||||
$result = $cmd.ExecuteScalar()
|
||||
Write-Host "SELECT 1 : $result"
|
||||
$cmd.Dispose()
|
||||
|
||||
$conn.Close()
|
||||
Write-Host "Connexion fermee : $($conn.State)"
|
||||
$conn.Dispose()
|
||||
Write-Host "Connexion disposee."
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "FIN DU SCRIPT : le process pwsh va se terminer maintenant."
|
||||
Write-Host ">>> OBSERVER ICI : crash natif (Application Error 1000, wd300vm64.dll,"
|
||||
Write-Host " 0xc0000005 / 0xc0000409) ou sortie propre sans evenement. <<<"
|
||||
@@ -0,0 +1,80 @@
|
||||
# ============================================================
|
||||
# Probe de LECTURE SEULE de la base Licence HFSQL via OLEDB.
|
||||
# N'effectue AUCUNE écriture. N'expose aucun secret.
|
||||
# ============================================================
|
||||
param(
|
||||
[string]$ConfigPath = "config/hfsql-test.local.json",
|
||||
[string]$Section = "LicenseConnection",
|
||||
[string]$DatabaseOverride = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# --- Lecture de la config locale (contient les secrets) ---
|
||||
$config = Get-Content $ConfigPath -Raw | ConvertFrom-Json
|
||||
$db = $config.$Section
|
||||
|
||||
if ($null -eq $db) {
|
||||
Write-Output "SECTION $Section : ABSENTE"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$server = $db.Server
|
||||
$port = $db.Port
|
||||
$database = if ([string]::IsNullOrWhiteSpace($DatabaseOverride)) { $db.DatabaseName } else { $DatabaseOverride }
|
||||
$login = $db.Login
|
||||
$password = $db.Password
|
||||
|
||||
|
||||
Write-Output "=== PROBE LECTURE SEULE OLEDB HFSQL ==="
|
||||
Write-Output "Section : $Section"
|
||||
Write-Output "Server : $server"
|
||||
Write-Output "Port : $port"
|
||||
Write-Output "Database : $database"
|
||||
Write-Output "Login : $login"
|
||||
Write-Output "Provider : PCSOFT.HFSQL"
|
||||
Write-Output "Mode : LECTURE SEULE (aucune ecriture)"
|
||||
Write-Output ""
|
||||
|
||||
# --- Construction de la connection string OLEDB ---
|
||||
$connStr = "Provider=PCSOFT.HFSQL;Data Source=$server`:$port;Initial Catalog=$database;User ID=$login;Password=$password"
|
||||
|
||||
try {
|
||||
$conn = New-Object System.Data.OleDb.OleDbConnection($connStr)
|
||||
$conn.Open()
|
||||
Write-Output "CONNEXION : OUVERTE"
|
||||
Write-Output "State : $($conn.State)"
|
||||
Write-Output ""
|
||||
|
||||
# --- Liste des tables ---
|
||||
$schema = $conn.GetOleDbSchemaTable([System.Data.OleDb.OleDbSchemaGuid]::Tables, $null)
|
||||
if ($null -eq $schema) {
|
||||
Write-Output "TABLES : schema non disponible"
|
||||
} else {
|
||||
Write-Output "TABLES ($($schema.Rows.Count)) :"
|
||||
foreach ($row in $schema.Rows) {
|
||||
$tableName = $row["TABLE_NAME"]
|
||||
$tableType = $row["TABLE_TYPE"]
|
||||
Write-Output " - $tableName [$tableType]"
|
||||
}
|
||||
}
|
||||
Write-Output ""
|
||||
|
||||
# --- Test SELECT 1 ---
|
||||
$cmd = $conn.CreateCommand()
|
||||
$cmd.CommandText = "SELECT 1"
|
||||
$result = $cmd.ExecuteScalar()
|
||||
Write-Output "SELECT 1 : $result"
|
||||
|
||||
$conn.Close()
|
||||
Write-Output ""
|
||||
Write-Output "CONNEXION : FERMEE"
|
||||
Write-Output "RESULTAT : OK (lecture seule)"
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Write-Output "CONNEXION : ECHEC"
|
||||
Write-Output "Erreur : $($_.Exception.Message)"
|
||||
Write-Output "RESULTAT : KO"
|
||||
exit 3
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# ============================================================
|
||||
# Liste les bases (catalogues) disponibles sur le serveur HFSQL.
|
||||
# Se connecte à une base accessible (BATIK_TEST) puis liste les
|
||||
# catalogues via le schéma OLEDB. LECTURE SEULE.
|
||||
# ============================================================
|
||||
param(
|
||||
[string]$ConfigPath = "config/hfsql-test.local.json"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$config = Get-Content $ConfigPath -Raw | ConvertFrom-Json
|
||||
$db = $config.Hfsql
|
||||
|
||||
$server = $db.Server
|
||||
$port = $db.Port
|
||||
$database = $db.DatabaseName
|
||||
$login = $db.Login
|
||||
$password = $db.Password
|
||||
|
||||
Write-Output "=== LISTE DES BASES HFSQL (serveur $server`:$port) ==="
|
||||
Write-Output "Connexion d'ancrage : $database"
|
||||
Write-Output ""
|
||||
|
||||
$connStr = "Provider=PCSOFT.HFSQL;Data Source=$server`:$port;Initial Catalog=$database;User ID=$login;Password=$password"
|
||||
|
||||
try {
|
||||
$conn = New-Object System.Data.OleDb.OleDbConnection($connStr)
|
||||
$conn.Open()
|
||||
Write-Output "CONNEXION : OUVERTE"
|
||||
Write-Output ""
|
||||
|
||||
# --- Liste des catalogues (bases) ---
|
||||
$schema = $conn.GetOleDbSchemaTable([System.Data.OleDb.OleDbSchemaGuid]::Catalogs, $null)
|
||||
if ($null -eq $schema) {
|
||||
Write-Output "CATALOGUES : schema non disponible"
|
||||
} else {
|
||||
Write-Output "CATALOGUES ($($schema.Rows.Count)) :"
|
||||
foreach ($row in $schema.Rows) {
|
||||
$catalogName = $row["CATALOG_NAME"]
|
||||
Write-Output " - $catalogName"
|
||||
}
|
||||
}
|
||||
|
||||
$conn.Close()
|
||||
Write-Output ""
|
||||
Write-Output "RESULTAT : OK"
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Write-Output "CONNEXION : ECHEC"
|
||||
Write-Output "Erreur : $($_.Exception.Message)"
|
||||
Write-Output "RESULTAT : KO"
|
||||
exit 3
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
# ============================================================
|
||||
# Probe LECTURE SEULE — Audit physique de OMIKA_TEST
|
||||
# Aucune écriture. Aucune modification de configuration.
|
||||
# Connexion OLE DB PCSOFT.HFSQL (ADODB COM) sur le même serveur
|
||||
# que BATIK_TEST mais catalogue/base = OMIKA_TEST.
|
||||
# ============================================================
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$server = "127.0.0.1"
|
||||
$port = 4900
|
||||
$database = "OMIKA_TEST"
|
||||
$user = "susana"
|
||||
$password = "123"
|
||||
|
||||
# --- Schéma Omika attendu (source : docs/schema-compatibilite-batik-omika.json,
|
||||
# presence = "Commun" ou "Omika uniquement") ---
|
||||
$expectedOmika = @(
|
||||
"Doc_Aide","Document","DocumentNote","MailCat","MailModele","MessageExterne",
|
||||
"MessageInterne","ModeleRapport","ModeleRapportMob","MotClef","RubriquesDocument",
|
||||
"Action","CheckList","CheckList_Collab","CompteRendu","Dossier","EtatIntervention",
|
||||
"Intervention","Intervention_ChampLibre","Intervention_Personne","BddExterne",
|
||||
"Client_BddExterne","ElementModifie","ElementSupprime","Intervention_BddExterne",
|
||||
"Mobile","MobLog","MobReception","Collaborateur","Equipe","Equipe_Collaborateur",
|
||||
"Societe","Societe_Collaborateur","Compteur","Etats","ParametreApplication",
|
||||
"ParametreMetier","ParametreMetierEntete","ParametreSysteme","ParametreUtilisateur",
|
||||
"Planning_RDV","Planning_RDV_Personne","RDV_Collaborateur","RDV_Contact","RDV_Planning",
|
||||
"SuiviDesTemps","Adresse","Client","Client_Adresse","Client_Lieu","Lieu","Lieu_Personne",
|
||||
"Personne","Telephone","AccesEtDroit","CategorieAcces","Role","Utilisateur"
|
||||
)
|
||||
|
||||
# --- Tables importantes pour le snapshot (noms physiques Omika) ---
|
||||
$importantTables = @(
|
||||
"Client", # Clients
|
||||
"Collaborateur", # Collaborateurs
|
||||
"Personne", # Personnes / Contacts
|
||||
"Lieu", # Sites / Lieux
|
||||
"Dossier", # Dossiers
|
||||
"Intervention", # Interventions
|
||||
"RDV_Planning", # RendezVous
|
||||
"SuiviDesTemps", # SuiviTemps
|
||||
"Document", # Documents
|
||||
"ParametreMetier" # ParametreMetier
|
||||
)
|
||||
|
||||
Write-Host "========== AUDIT OMIKA_TEST (READ-ONLY) =========="
|
||||
Write-Host "Base : $database"
|
||||
Write-Host "Serveur : ${server}:${port}"
|
||||
Write-Host ""
|
||||
|
||||
$connStr = "Provider=PCSoft.HFSQL;Data Source=${server};Port=${port};Initial Catalog=${database};User ID=${user};Password=${password};"
|
||||
|
||||
$cn = $null
|
||||
try {
|
||||
$cn = New-Object -ComObject ADODB.Connection
|
||||
$cn.ConnectionTimeout = 15
|
||||
$cn.Open($connStr)
|
||||
Write-Host "CONNEXION : OUVERTE"
|
||||
Write-Host ""
|
||||
|
||||
# ---- 1. Lister les tables physiques via OpenSchema (adSchemaTables = 20) ----
|
||||
$tables = New-Object System.Collections.Generic.List[string]
|
||||
try {
|
||||
$rs = $cn.OpenSchema(20) # adSchemaTables
|
||||
while (-not $rs.EOF) {
|
||||
$name = $rs.Fields.Item("TABLE_NAME").Value
|
||||
if ($name) { $tables.Add([string]$name) }
|
||||
$rs.MoveNext()
|
||||
}
|
||||
$rs.Close()
|
||||
} catch {
|
||||
Write-Host " (OpenSchema Tables indisponible: $($_.Exception.Message))"
|
||||
}
|
||||
|
||||
$actualSet = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($t in $tables) { [void]$actualSet.Add($t) }
|
||||
|
||||
Write-Host "NOMBRE TABLES OMIKA_TEST REELLES : $($actualSet.Count)"
|
||||
Write-Host "NOMBRE TABLES OMIKA ATTENDUES : $($expectedOmika.Count)"
|
||||
Write-Host ""
|
||||
|
||||
# ---- 2. Tables manquantes (EXPECTED - ACTUAL) ----
|
||||
$missing = @()
|
||||
foreach ($e in $expectedOmika) {
|
||||
if (-not $actualSet.Contains($e)) { $missing += $e }
|
||||
}
|
||||
Write-Host "--- TABLES MANQUANTES (EXPECTED_OMIKA - ACTUAL_OMIKA) ---"
|
||||
if ($missing.Count -eq 0) { Write-Host " (aucune)" } else { $missing | ForEach-Object { Write-Host " - $_" } }
|
||||
Write-Host ""
|
||||
|
||||
# ---- 3. Tables supplémentaires (ACTUAL - EXPECTED) ----
|
||||
$extra = @()
|
||||
foreach ($a in $actualSet) {
|
||||
if ($expectedOmika -notcontains $a) { $extra += $a }
|
||||
}
|
||||
Write-Host "--- TABLES SUPPLEMENTAIRES (ACTUAL_OMIKA - EXPECTED_OMIKA) ---"
|
||||
if ($extra.Count -eq 0) { Write-Host " (aucune)" } else { $extra | Sort-Object | ForEach-Object { Write-Host " - $_" } }
|
||||
Write-Host ""
|
||||
|
||||
# ---- 4. Détection de schéma ----
|
||||
Write-Host "--- DETECTION DE SCHEMA ---"
|
||||
$criticalOmika = @("Client","Collaborateur","Personne","Lieu","Dossier","Intervention","RDV_Planning","SuiviDesTemps","Document","ParametreMetier")
|
||||
$criticalMissing = ($criticalOmika | Where-Object { -not $actualSet.Contains($_) }).Count
|
||||
Write-Host " Tables critiques Omika manquantes : $criticalMissing / $($criticalOmika.Count)"
|
||||
if ($missing.Count -eq 0 -and $criticalMissing -eq 0) {
|
||||
Write-Host " SCHEMA DETECTE : OMIKA (complet)"
|
||||
} elseif ($criticalMissing -gt 0) {
|
||||
Write-Host " SCHEMA DETECTE : OMIKA_INCOMPLET"
|
||||
} else {
|
||||
Write-Host " SCHEMA DETECTE : OMIKA (avec écarts non critiques)"
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# ---- 5. Comptage des tables importantes ----
|
||||
Write-Host "--- COUNTS TABLES IMPORTANTES (SELECT COUNT(*)) ---"
|
||||
foreach ($table in $importantTables) {
|
||||
if ($actualSet.Contains($table)) {
|
||||
try {
|
||||
$rs2 = New-Object -ComObject ADODB.Recordset
|
||||
$rs2.Open("SELECT COUNT(*) AS C FROM $table", $cn, 1, 1)
|
||||
$count = $rs2.Fields.Item("C").Value
|
||||
$rs2.Close()
|
||||
Write-Host (" {0,-20} : PRESENT count={1}" -f $table, $count)
|
||||
} catch {
|
||||
Write-Host (" {0,-20} : PRESENT count=ERREUR ({1})" -f $table, $_.Exception.Message)
|
||||
}
|
||||
} else {
|
||||
Write-Host (" {0,-20} : ABSENT" -f $table)
|
||||
}
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
# ---- 6. ParametreMetier : TypeTemps ----
|
||||
# Test direct SELECT même si la table n'apparaît pas dans le listing
|
||||
# OpenSchema : distingue "table absente" de "table protégée (FLS)".
|
||||
Write-Host "--- PARAMETREMETIER (TypeTemps) ---"
|
||||
$pmDirect = $null
|
||||
try {
|
||||
$rs3 = New-Object -ComObject ADODB.Recordset
|
||||
$rs3.Open("SELECT COUNT(*) AS C FROM ParametreMetier", $cn, 1, 1)
|
||||
$pmDirect = $rs3.Fields.Item("C").Value
|
||||
$rs3.Close()
|
||||
Write-Host " ParametreMetier : PRESENT (SELECT direct) count=$pmDirect"
|
||||
try {
|
||||
$rs4 = New-Object -ComObject ADODB.Recordset
|
||||
$rs4.Open("SELECT COUNT(*) AS C FROM ParametreMetier WHERE PA_Categorie = 'TypeTemps'", $cn, 1, 1)
|
||||
$ttCount = $rs4.Fields.Item("C").Value
|
||||
$rs4.Close()
|
||||
Write-Host " TypeTemps (PA_Categorie='TypeTemps') : count=$ttCount"
|
||||
} catch {
|
||||
Write-Host " TypeTemps : non déterminé (colonne PA_Categorie absente ou autre)"
|
||||
}
|
||||
} catch {
|
||||
$err = $_.Exception.Message
|
||||
if ($err -match "introuvable|not found|inexistante|does not exist|28000001") {
|
||||
Write-Host " ParametreMetier : ABSENT (table introuvable)"
|
||||
} else {
|
||||
Write-Host " ParametreMetier : PRESENT mais PROTEGE (SELECT direct échoue: $err)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$cn.Close()
|
||||
Write-Host ""
|
||||
Write-Host "RESULTAT : OK (lecture seule)"
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Write-Host "CONNEXION : ECHEC"
|
||||
Write-Host "Erreur : $($_.Exception.Message)"
|
||||
Write-Host "RESULTAT : KO"
|
||||
exit 3
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# Probe LECTURE SEULE de la structure réelle des tables de synchronisation
|
||||
# TOSRV (base Service) / MobReception, MobLog (base Business/WebService).
|
||||
# Aucune écriture. Source de vérité : la base réelle via OLE DB PCSOFT.HFSQL.
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Add-Type -AssemblyName System.Data.OleDb
|
||||
|
||||
function Get-TableColumns {
|
||||
param(
|
||||
[string]$Server,
|
||||
[int]$Port,
|
||||
[string]$Database,
|
||||
[string]$User,
|
||||
[string]$Password,
|
||||
[string]$Table
|
||||
)
|
||||
|
||||
$connStr = "Provider=PCSOFT.HFSQL;Data Source=$Server`:$Port;Initial Catalog=$Database;User ID=$User;Password=$Password"
|
||||
$conn = New-Object System.Data.OleDb.OleDbConnection($connStr)
|
||||
try {
|
||||
$conn.Open()
|
||||
Write-Host "=== Table: $Table (base $Database) ==="
|
||||
$schema = $conn.GetSchema("Columns", @($null, $null, $Table))
|
||||
if ($schema.Rows.Count -eq 0) {
|
||||
Write-Host " (aucune colonne retournée / table introuvable)"
|
||||
} else {
|
||||
foreach ($row in $schema.Rows) {
|
||||
$name = $row["COLUMN_NAME"]
|
||||
$type = $row["DATA_TYPE"]
|
||||
$size = $row["CHARACTER_MAXIMUM_LENGTH"]
|
||||
Write-Host (" {0,-25} type={1,-10} size={2}" -f $name, $type, $size)
|
||||
}
|
||||
}
|
||||
Write-Host ""
|
||||
} finally {
|
||||
if ($conn.State -eq [System.Data.ConnectionState]::Open) { $conn.Close() }
|
||||
$conn.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
# Base Service (BATIK.01_BLS) -> TOSRV
|
||||
Get-TableColumns -Server "127.0.0.1" -Port 4900 -Database "BATIK.01_BLS" -User "susana" -Password "123" -Table "TOSRV"
|
||||
|
||||
# Base Business/WebService (BATIK_TEST) -> MobReception, MobLog
|
||||
Get-TableColumns -Server "127.0.0.1" -Port 4900 -Database "BATIK_TEST" -User "susana" -Password "123" -Table "MobReception"
|
||||
Get-TableColumns -Server "127.0.0.1" -Port 4900 -Database "BATIK_TEST" -User "susana" -Password "123" -Table "MobLog"
|
||||
|
||||
Write-Host "Probe terminé (lecture seule)."
|
||||
@@ -0,0 +1,104 @@
|
||||
====================================================================
|
||||
PROBE HFSQL SHUTDOWN CRASH — OUTIL DE DIAGNOSTIC (SRV-OMIKA)
|
||||
====================================================================
|
||||
|
||||
But
|
||||
---
|
||||
Reproduire, de façon TOTALEMENT indépendante d'Omika.Api, le crash
|
||||
natif du module wd300vm64.dll observé à chaque Stop-Service.
|
||||
|
||||
Cet outil n'utilise NI ASP.NET, NI Generic Host, NI conteneur DI,
|
||||
NI repository Omika, NI RSA/QR/licence. Il ne charge que le provider
|
||||
OLE DB PC SOFT (PCSOFT.HFSQL) via System.Data.OleDb.
|
||||
|
||||
Prérequis
|
||||
---------
|
||||
- Windows PowerShell 5.1 (intégré à Windows) : lancer avec powershell.exe.
|
||||
- Le provider OLE DB PC SOFT (PCSOFT.HFSQL) doit être installé sur la machine.
|
||||
- Compte HFSQL technique autorisé à ouvrir une session sur le serveur ciblé.
|
||||
|
||||
Les 3 modes
|
||||
-----------
|
||||
Mode A — NoConnection
|
||||
Le process ne crée AUCUNE connexion OLE DB puis se termine.
|
||||
=> Contrôle : vérifie que le process ne crashe pas SANS HFSQL.
|
||||
Si ce mode crashe, le problème n'est PAS lié à HFSQL.
|
||||
|
||||
Mode B — OpenClose
|
||||
Crée une OleDbConnection (Provider=PCSOFT.HFSQL), Open(),
|
||||
exécute un SELECT lecture seule, ferme le reader, dispose la
|
||||
commande, Close(), Dispose(), affiche PROBE_TERMINE, quitte.
|
||||
=> Reproduit le cycle minimal open/close sans Omika.Api.
|
||||
Si ce mode crashe à la sortie, le crash est dans le provider
|
||||
lui-même (indépendant d'Omika.Api).
|
||||
|
||||
Mode C — OpenCloseReleasePool
|
||||
Identique au Mode B, puis appelle
|
||||
OleDbConnection.ReleaseObjectPool() avant la sortie.
|
||||
=> Teste si la libération explicite du pool évite le crash.
|
||||
|
||||
Paramètres
|
||||
----------
|
||||
-Mode : NoConnection | OpenClose | OpenCloseReleasePool (obligatoire)
|
||||
-Server : hôte du serveur HFSQL Client/Serveur (obligatoire)
|
||||
-Port : port HFSQL (défaut 4900)
|
||||
-Database : nom de la base/analyse (obligatoire)
|
||||
-User : compte technique HFSQL (obligatoire)
|
||||
-Query : requête SELECT lecture seule (défaut "SELECT 1")
|
||||
|
||||
Le mot de passe N'EST PAS un paramètre : il est demandé de façon
|
||||
interactive (Read-Host -AsSecureString) et n'est jamais affiché.
|
||||
|
||||
Commandes Windows PowerShell 5.1
|
||||
--------------------------------
|
||||
Ouvrir une console PowerShell 5.1 (powershell.exe), puis :
|
||||
|
||||
# MODE A (contrôle, sans HFSQL)
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File `
|
||||
"C:\Chemin\Vers\probe_hfsql_shutdown_crash.ps1" `
|
||||
-Mode NoConnection -Server SERVEUR -Database BASE -User COMPTE
|
||||
|
||||
# MODE B (open/close)
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File `
|
||||
"C:\Chemin\Vers\probe_hfsql_shutdown_crash.ps1" `
|
||||
-Mode OpenClose -Server SERVEUR -Port 4900 -Database BASE -User COMPTE
|
||||
|
||||
# MODE C (open/close + ReleaseObjectPool)
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File `
|
||||
"C:\Chemin\Vers\probe_hfsql_shutdown_crash.ps1" `
|
||||
-Mode OpenCloseReleasePool -Server SERVEUR -Port 4900 -Database BASE -User COMPTE
|
||||
|
||||
Le mot de passe est saisi à l'invite (non affiché).
|
||||
|
||||
Contrôle des événements Windows après chaque exécution
|
||||
------------------------------------------------------
|
||||
Après CHAQUE exécution, vérifier l'Observateur d'événements :
|
||||
|
||||
1. eventvwr.msc > Journaux Windows > Application
|
||||
2. Filtrer / rechercher les événements récents :
|
||||
- "Application Error" (Event ID 1000)
|
||||
- ".NET Runtime" (Event ID 1026)
|
||||
- "Windows Error Reporting" (Event ID 1001)
|
||||
3. Dans les détails, chercher :
|
||||
- module fautif : wd300vm64.dll
|
||||
- code exception : 0xc0000005 ou 0xc0000409
|
||||
|
||||
Ou via commande (après chaque run, noter l'heure) :
|
||||
|
||||
Get-WinEvent -LogName Application -MaxEvents 30 |
|
||||
Where-Object { $_.Id -in 1000,1026,1001 } |
|
||||
Format-List TimeCreated, Id, ProviderName, Message
|
||||
|
||||
Interprétation
|
||||
--------------
|
||||
- MODE A crashe -> le crash n'est PAS spécifique à HFSQL.
|
||||
- MODE B crashe -> le provider PCSOFT.HFSQL crashe à la sortie,
|
||||
indépendamment d'Omika.Api.
|
||||
- MODE C ne crashe pas -> ReleaseObjectPool() (ou l'ordre de libération)
|
||||
change le comportement à la sortie.
|
||||
- Aucun mode ne crashe -> le crash PROD implique l'environnement Omika.Api
|
||||
(DI, hosted services, lifetime) et non le provider seul.
|
||||
|
||||
Ne PAS utiliser : OLE DB Services=-1 ni -2 (cassent PCSOFT.HFSQL).
|
||||
Ne PAS tenter de migration ODBC ici.
|
||||
====================================================================
|
||||
@@ -0,0 +1,159 @@
|
||||
# ============================================================
|
||||
# PROBE HFSQL SHUTDOWN CRASH — diagnostic isolé du crash natif
|
||||
# wd300vm64.dll à la fermeture du process.
|
||||
#
|
||||
# Objectif : reproduire le crash SANS Omika.Api, sans ASP.NET,
|
||||
# sans Generic Host, sans DI, sans repository, sans RSA/QR/licence.
|
||||
#
|
||||
# Compatible Windows PowerShell 5.1 (.NET Framework 4.x).
|
||||
# Aucune dépendance applicative.
|
||||
#
|
||||
# TROIS MODES :
|
||||
# NoConnection : aucune connexion OLE DB (contrôle).
|
||||
# OpenClose : Open + SELECT + Close + Dispose.
|
||||
# OpenCloseReleasePool : idem + OleDbConnection.ReleaseObjectPool().
|
||||
#
|
||||
# Sécurité : le mot de passe est demandé interactivement
|
||||
# (Read-Host -AsSecureString), converti en texte uniquement en
|
||||
# mémoire, puis le BSTR est nettoyé (ZeroFreeBSTR). Ni le mot de
|
||||
# passe ni la chaîne de connexion complète ne sont jamais affichés.
|
||||
# ============================================================
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet('NoConnection', 'OpenClose', 'OpenCloseReleasePool')]
|
||||
[string]$Mode,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Server,
|
||||
|
||||
[int]$Port = 4900,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Database,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$User,
|
||||
|
||||
[string]$Query = 'SELECT 1'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
Write-Host '=== PROBE HFSQL SHUTDOWN CRASH (PS 5.1) ==='
|
||||
Write-Host "Mode : $Mode"
|
||||
Write-Host "Serveur : ${Server}:${Port}"
|
||||
Write-Host "Base : $Database"
|
||||
Write-Host "Compte : $User"
|
||||
Write-Host ''
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# MODE A : aucune connexion OLE DB n'est créée.
|
||||
# ------------------------------------------------------------
|
||||
if ($Mode -eq 'NoConnection') {
|
||||
Write-Host 'PROBE_TERMINE (NoConnection)'
|
||||
Write-Host '>>> Le process se termine sans avoir touche a HFSQL. <<<'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Mot de passe interactif (jamais affiché).
|
||||
# ------------------------------------------------------------
|
||||
$secure = Read-Host -Prompt 'Mot de passe HFSQL' -AsSecureString
|
||||
$plain = ''
|
||||
if ($null -ne $secure -and $secure.Length -gt 0) {
|
||||
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
|
||||
try {
|
||||
$plain = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
|
||||
}
|
||||
finally {
|
||||
# Nettoyage du BSTR (mémoire non managée).
|
||||
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
|
||||
}
|
||||
$secure.Dispose()
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Construction de la chaîne de connexion (jamais affichée).
|
||||
# ------------------------------------------------------------
|
||||
$csb = New-Object System.Data.OleDb.OleDbConnectionStringBuilder
|
||||
$csb.Provider = 'PCSOFT.HFSQL'
|
||||
$csb.DataSource = "$($Server):$($Port)"
|
||||
$csb['Initial Catalog'] = $Database
|
||||
$csb['User ID'] = $User
|
||||
if ($plain -ne '') {
|
||||
$csb['Password'] = $plain
|
||||
}
|
||||
|
||||
$conn = New-Object System.Data.OleDb.OleDbConnection($csb.ConnectionString)
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Open()
|
||||
# ------------------------------------------------------------
|
||||
try {
|
||||
$conn.Open()
|
||||
Write-Host "Ouverture OK : $($conn.State)"
|
||||
}
|
||||
catch {
|
||||
Write-Host "ERREUR_OUVERTURE : $($_.Exception.GetType().Name) : $($_.Exception.Message)"
|
||||
try { $conn.Dispose() } catch { }
|
||||
$plain = $null
|
||||
exit 2
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# SELECT lecture seule, puis fermeture reader / command.
|
||||
# ------------------------------------------------------------
|
||||
$cmd = $null
|
||||
$reader = $null
|
||||
try {
|
||||
$cmd = $conn.CreateCommand()
|
||||
$cmd.CommandText = $Query
|
||||
$reader = $cmd.ExecuteReader()
|
||||
$rows = 0
|
||||
while ($reader.Read()) { $rows++ }
|
||||
Write-Host "SELECT_OK : $rows ligne(s)"
|
||||
}
|
||||
catch {
|
||||
Write-Host "ERREUR_SELECT : $($_.Exception.GetType().Name) : $($_.Exception.Message)"
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $reader) {
|
||||
try { $reader.Close() } catch { }
|
||||
try { $reader.Dispose() } catch { }
|
||||
}
|
||||
if ($null -ne $cmd) {
|
||||
try { $cmd.Dispose() } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Close() / Dispose()
|
||||
# ------------------------------------------------------------
|
||||
try {
|
||||
if ($conn.State -eq [System.Data.ConnectionState]::Open) {
|
||||
$conn.Close()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Host "ERREUR_CLOSE : $($_.Exception.GetType().Name) : $($_.Exception.Message)"
|
||||
}
|
||||
$conn.Dispose()
|
||||
Write-Host 'Connexion close/disposee.'
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# MODE C : ReleaseObjectPool() avant la sortie.
|
||||
# ------------------------------------------------------------
|
||||
if ($Mode -eq 'OpenCloseReleasePool') {
|
||||
[System.Data.OleDb.OleDbConnection]::ReleaseObjectPool()
|
||||
Write-Host 'ReleaseObjectPool() appele.'
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Nettoyage mémoire du mot de passe (best effort côté managé).
|
||||
# ------------------------------------------------------------
|
||||
$plain = $null
|
||||
$secure = $null
|
||||
|
||||
Write-Host "PROBE_TERMINE ($Mode)"
|
||||
Write-Host '>>> Le process se termine maintenant. Observer un eventuel crash natif (wd300vm64.dll). <<<'
|
||||
@@ -0,0 +1,16 @@
|
||||
# Démarre l'API E2E avec la config locale HFSQL de test.
|
||||
$env:ASPNETCORE_ENVIRONMENT = 'Development'
|
||||
$env:DOTNET_ENVIRONMENT = 'Development'
|
||||
|
||||
# Vider les fichiers de sortie précédents.
|
||||
Remove-Item 'd:\Avalonia\Omika\api_e2e_out.txt' -ErrorAction SilentlyContinue
|
||||
Remove-Item 'd:\Avalonia\Omika\api_e2e_err.txt' -ErrorAction SilentlyContinue
|
||||
|
||||
Start-Process -FilePath 'dotnet' `
|
||||
-ArgumentList 'run','--project','src/Omika.Api/Omika.Api.csproj','-c','Debug','--no-build' `
|
||||
-WorkingDirectory 'd:\Avalonia\Omika' `
|
||||
-RedirectStandardOutput 'd:\Avalonia\Omika\api_e2e_out.txt' `
|
||||
-RedirectStandardError 'd:\Avalonia\Omika\api_e2e_err.txt' `
|
||||
-NoNewWindow
|
||||
|
||||
Write-Host "API E2E demarree (ASPNETCORE_ENVIRONMENT=Development)."
|
||||
@@ -0,0 +1,8 @@
|
||||
$env:ASPNETCORE_ENVIRONMENT = 'Development'
|
||||
Start-Process -FilePath 'dotnet' `
|
||||
-ArgumentList 'run','--project','src/Omika.Worker/Omika.Worker.csproj','-c','Debug' `
|
||||
-WorkingDirectory 'd:\Avalonia\Omika' `
|
||||
-RedirectStandardOutput 'd:\Avalonia\Omika\worker_e2e_out.txt' `
|
||||
-RedirectStandardError 'd:\Avalonia\Omika\worker_e2e_err.txt' `
|
||||
-NoNewWindow
|
||||
Write-Host "Worker E2E démarré en arrière-plan."
|
||||
@@ -0,0 +1,18 @@
|
||||
# Démarre UN SEUL Worker E2E avec la config locale HFSQL de test.
|
||||
# La variable d'environnement ASPNETCORE_ENVIRONMENT est définie dans le processus
|
||||
# enfant pour que le loader HfSqlTestConfigurationLoader charge config/hfsql-test.local.json.
|
||||
$env:ASPNETCORE_ENVIRONMENT = 'Development'
|
||||
$env:DOTNET_ENVIRONMENT = 'Development'
|
||||
|
||||
# Vider les fichiers de sortie précédents.
|
||||
Remove-Item 'd:\Avalonia\Omika\worker_e2e_out.txt' -ErrorAction SilentlyContinue
|
||||
Remove-Item 'd:\Avalonia\Omika\worker_e2e_err.txt' -ErrorAction SilentlyContinue
|
||||
|
||||
Start-Process -FilePath 'dotnet' `
|
||||
-ArgumentList 'run','--project','src/Omika.Worker/Omika.Worker.csproj','-c','Debug','--no-build' `
|
||||
-WorkingDirectory 'd:\Avalonia\Omika' `
|
||||
-RedirectStandardOutput 'd:\Avalonia\Omika\worker_e2e_out.txt' `
|
||||
-RedirectStandardError 'd:\Avalonia\Omika\worker_e2e_err.txt' `
|
||||
-NoNewWindow
|
||||
|
||||
Write-Host "Worker E2E demarre (ASPNETCORE_ENVIRONMENT=Development)."
|
||||
@@ -0,0 +1,10 @@
|
||||
# Arrête proprement le Worker E2E Omika s'il tourne encore.
|
||||
$procs = Get-CimInstance Win32_Process -Filter "name='dotnet.exe'" | Where-Object { $_.CommandLine -like '*Omika.Worker*' }
|
||||
if ($procs) {
|
||||
foreach ($p in $procs) {
|
||||
Stop-Process -Id $p.ProcessId -Force
|
||||
Write-Host "Worker E2E arrêté (PID $($p.ProcessId))."
|
||||
}
|
||||
} else {
|
||||
Write-Host "Aucun Worker E2E en cours."
|
||||
}
|
||||
Reference in New Issue
Block a user