- Aktionen describe (alle Spalten einer freigegebenen Tabelle, mit dem Satz, dass nicht Genanntes nicht existiert) und tables (freigegebene Tabellen, vorhanden oder fehlend) - "Unknown column" / "no such table" usw. werden mit dem Vorhandenen beantwortet - MySQL, Postgres, SQL Server, SQLite; nur fuer freigegebene Tabellen - SQLite als Datenbanktyp; die Abfragen schreibt das Werkzeug selbst, PRAGMA bleibt im Agenten-SQL verboten - MongoDB: describe nennt die Felder aus den Dokumenten - describe/tables sind Lesezugriffe (Freigabeschluessel select), Vorpruefung der Tabelle Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
150 lines
6.3 KiB
C#
150 lines
6.3 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// #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.
|
|
/// </summary>
|
|
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<string, object?>
|
|
{
|
|
["type"] = "sqlite",
|
|
["connectionString"] = _connectionString,
|
|
["allowedTables"] = JsonSerializer.SerializeToElement(allowed)
|
|
},
|
|
new NullStateStore(), NullLogger.Instance, CancellationToken.None);
|
|
|
|
private Task<ToolResult> 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);
|
|
}
|
|
}
|