feat(connections): edit connections via PUT — backend + UI + blank-secret merge
Backend: PUT /api/connections/{id} with UpdateConnectionRequest {Name, Config}.
Provider is immutable; a secret field left blank in the incoming config is
merged from the stored row so users can rename or rotate non-secret fields
without re-typing every credential. Invalidates the ConnectionInstanceRegistry
cache on update so the next GetConnection() re-opens with the fresh config.
409 on unique-name-per-provider collision, 404 on unknown id, 400 on non-
object config. 6 new xUnit tests cover the merge, overwrite, 404, 409, 400
paths plus the happy-path round-trip.
Frontend: ConnectionsPage gains an Edit pencil per row. The form is shared
between create and edit modes; provider select is disabled in edit; secret
fields render blank with an inline hint that leaving them blank keeps the
stored value. One new Vitest covers the edit flow.
This commit is contained in:
@@ -3,4 +3,5 @@ using System.Text.Json;
|
||||
namespace VolcanicCore.Api.Contracts;
|
||||
|
||||
public sealed record CreateConnectionRequest(string ProviderId, string Name, JsonElement Config);
|
||||
public sealed record UpdateConnectionRequest(string Name, JsonElement Config);
|
||||
public sealed record ConnectionResponse(string Id, string ProviderId, string Name, JsonElement Config);
|
||||
|
||||
@@ -47,6 +47,36 @@ public static class ConnectionsEndpoints
|
||||
}
|
||||
});
|
||||
|
||||
g.MapPut("/{id}", async (string id, UpdateConnectionRequest req,
|
||||
ConnectionRepository repo, IConnectionSecretPolicy policy,
|
||||
ConnectionInstanceRegistry instances) =>
|
||||
{
|
||||
if (req.Config.ValueKind != JsonValueKind.Object)
|
||||
return Results.BadRequest(new { error = "config must be a JSON object" });
|
||||
|
||||
var existing = repo.Get(id);
|
||||
if (existing is null) return Results.NotFound();
|
||||
|
||||
var secrets = policy.SecretFieldsFor(existing.ProviderId);
|
||||
var merged = MergeSecrets(existing.ConfigJson, req.Config, secrets);
|
||||
|
||||
try
|
||||
{
|
||||
if (!repo.Update(id, req.Name, merged)) return Results.NotFound();
|
||||
}
|
||||
catch (Microsoft.Data.Sqlite.SqliteException ex) when (ex.SqliteErrorCode == 19)
|
||||
{
|
||||
return Results.Conflict(new { error = "a connection with that name already exists for this provider" });
|
||||
}
|
||||
|
||||
// Config changed — drop the pooled live instance so the next
|
||||
// GetConnection() re-opens with the updated config.
|
||||
await instances.InvalidateAsync(id);
|
||||
|
||||
var stripped = StripSecrets(merged, secrets);
|
||||
return Results.Ok(new ConnectionResponse(id, existing.ProviderId, req.Name, stripped));
|
||||
});
|
||||
|
||||
g.MapDelete("/{id}", async (string id, ConnectionRepository repo, ConnectionInstanceRegistry instances) =>
|
||||
{
|
||||
await instances.InvalidateAsync(id);
|
||||
@@ -54,6 +84,26 @@ public static class ConnectionsEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
// For each field in `secrets`, if the incoming config lacks that key OR
|
||||
// the value is an empty string, use the stored value from `existingJson`.
|
||||
// Non-secret fields always come from the incoming config. This gives
|
||||
// "leave blank to keep the existing secret" without exposing it on GET.
|
||||
private static string MergeSecrets(string existingJson, JsonElement incoming, IReadOnlySet<string> secrets)
|
||||
{
|
||||
using var existingDoc = JsonDocument.Parse(existingJson);
|
||||
var merged = new Dictionary<string, JsonElement>();
|
||||
foreach (var p in incoming.EnumerateObject()) merged[p.Name] = p.Value.Clone();
|
||||
foreach (var s in secrets)
|
||||
{
|
||||
var incomingBlank =
|
||||
!merged.TryGetValue(s, out var v)
|
||||
|| (v.ValueKind == JsonValueKind.String && string.IsNullOrEmpty(v.GetString()));
|
||||
if (incomingBlank && existingDoc.RootElement.TryGetProperty(s, out var kept))
|
||||
merged[s] = kept.Clone();
|
||||
}
|
||||
return JsonSerializer.Serialize(merged);
|
||||
}
|
||||
|
||||
private static JsonElement StripSecrets(string configJson, IReadOnlySet<string> secretFields)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(configJson);
|
||||
|
||||
@@ -63,6 +63,22 @@ public sealed class ConnectionRepository
|
||||
return rows;
|
||||
}
|
||||
|
||||
public bool Update(string id, string name, string configJson)
|
||||
{
|
||||
using var conn = _factory.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
UPDATE connections
|
||||
SET name = $name,
|
||||
config_json_encrypted = $enc
|
||||
WHERE id = $id;
|
||||
";
|
||||
cmd.Parameters.AddWithValue("$id", id);
|
||||
cmd.Parameters.AddWithValue("$name", name);
|
||||
cmd.Parameters.AddWithValue("$enc", _enc.Encrypt(configJson));
|
||||
return cmd.ExecuteNonQuery() == 1;
|
||||
}
|
||||
|
||||
public bool Delete(string id)
|
||||
{
|
||||
using var conn = _factory.Open();
|
||||
|
||||
Reference in New Issue
Block a user