using System.Text.Json; using ClawdDotNet.Core.Tools; using ClawdDotNet.Tools.Database; using ClawdDotNet.Tools.Tests.FileRW; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Tools.Tests.Database; /// /// #676: "There is no field for it" must be a finding, not a guess. The tool describes a table, /// lists the tables, and a refused statement names what exists instead. /// public sealed class SchemaLookupTests : IDisposable { private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "schema-" + Guid.NewGuid().ToString("N")[..8]); private readonly string _connectionString; private readonly DatabaseTool _tool = new(); public SchemaLookupTests() { Directory.CreateDirectory(_directory); // Without pooling the file is free again when the test ends. _connectionString = $"Data Source={Path.Combine(_directory, "shop.db")};Pooling=False"; using var conn = new SqliteConnection(_connectionString); conn.Open(); using var cmd = conn.CreateCommand(); cmd.CommandText = """ CREATE TABLE messwerte (gemessen_um TEXT NOT NULL, ort TEXT, temperatur_c REAL, luftfeuchte_pct REAL); INSERT INTO messwerte VALUES ('2027-03-10 12:00', 'Lager', 18.5, 40); CREATE TABLE geheim (wert TEXT); """; cmd.ExecuteNonQuery(); } public void Dispose() { try { Directory.Delete(_directory, recursive: true); } catch { /* cleanup is secondary */ } } private AgentToolContext Context(params string[] allowed) => new( "datenleser", "inst", new Dictionary { ["type"] = "sqlite", ["connectionString"] = _connectionString, ["allowedTables"] = JsonSerializer.SerializeToElement(allowed) }, new NullStateStore(), NullLogger.Instance, CancellationToken.None); private Task Run(object input, params string[] allowed) => _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), Context(allowed.Length == 0 ? ["messwerte", "bestellungen"] : allowed), default); [Fact] public async Task Describe_lists_every_column_and_says_that_is_all() { var result = await Run(new { action = "describe", table = "messwerte" }); result.Success.ShouldBeTrue(result.ErrorMessage); result.Content.ShouldContain("Columns of 'messwerte' (4)"); result.Content.ShouldContain("- gemessen_um TEXT not null"); result.Content.ShouldContain("A field that is not listed does not exist."); } [Fact] public async Task A_table_that_is_not_released_is_not_described() { var result = await Run(new { action = "describe", table = "geheim" }); result.Success.ShouldBeFalse(); result.ErrorMessage!.ShouldNotContain("wert TEXT", Case.Sensitive, "the schema of a forbidden table stays hidden"); } [Fact] public async Task A_released_table_that_does_not_exist_is_said_to_be_missing() { var result = await Run(new { action = "describe", table = "bestellungen" }); result.ErrorMessage!.ShouldContain("does not exist"); result.ErrorMessage.ShouldContain("Tables you may use: messwerte"); } [Fact] public async Task Tables_names_what_exists_and_what_is_released_but_missing() { var result = await Run(new { action = "tables" }); result.Content.ShouldContain("Tables you may use: messwerte."); result.Content.ShouldContain("not in the database: bestellungen"); result.Content.ShouldNotContain("geheim"); } [Fact] public async Task An_unknown_column_is_answered_with_the_columns_that_exist() { var result = await Run(new { action = "query", sql = "SELECT zeitpunkt FROM messwerte" }); result.Success.ShouldBeFalse(); result.ErrorMessage!.ShouldContain("There is no column 'zeitpunkt'"); result.ErrorMessage.ShouldContain("Columns of 'messwerte': gemessen_um, ort, temperatur_c, luftfeuchte_pct."); } [Fact] public async Task An_unknown_table_is_answered_with_the_tables_that_exist() { var result = await Run(new { action = "query", sql = "SELECT * FROM bestellungen" }); result.ErrorMessage!.ShouldContain("There is no table 'bestellungen'"); result.ErrorMessage.ShouldContain("Tables you may use: messwerte."); } [Fact] public async Task A_plain_query_still_reads() { var result = await Run(new { action = "query", sql = "SELECT ort, temperatur_c FROM messwerte" }); result.Success.ShouldBeTrue(result.ErrorMessage); result.Content.ShouldContain("Lager"); } [Fact] public void Describe_is_a_read_and_is_checked_before_approval() { _tool.StagingAktion(JsonSerializer.SerializeToElement(new { action = "describe", table = "messwerte" })).ShouldBe("select"); _tool.Preflight(JsonSerializer.SerializeToElement(new { action = "describe", table = "geheim" }), Context("messwerte")) .ShouldNotBeNull(); _tool.Preflight(JsonSerializer.SerializeToElement(new { action = "describe", table = "messwerte" }), Context("messwerte")) .ShouldBeNull(); } [Theory] [InlineData("Unknown column 'zeitpunkt' in 'field list'", SchemaMiss.UnknownColumn, "zeitpunkt")] [InlineData("42703: column \"zeitpunkt\" does not exist", SchemaMiss.UnknownColumn, "zeitpunkt")] [InlineData("Invalid column name 'zeitpunkt'.", SchemaMiss.UnknownColumn, "zeitpunkt")] [InlineData("SQLite Error 1: 'no such column: zeitpunkt'.", SchemaMiss.UnknownColumn, "zeitpunkt")] [InlineData("Table 'shop.bestellungen' doesn't exist", SchemaMiss.UnknownTable, "shop.bestellungen")] [InlineData("42P01: relation \"bestellungen\" does not exist", SchemaMiss.UnknownTable, "bestellungen")] [InlineData("Invalid object name 'bestellungen'.", SchemaMiss.UnknownTable, "bestellungen")] [InlineData("Duplicate entry '1' for key 'PRIMARY'", SchemaMiss.None, null)] public void The_refusal_of_each_database_is_read(string message, SchemaMiss miss, string? name) { var (found, what) = SchemaLookup.Classify(message); found.ShouldBe(miss); what.ShouldBe(name); } }