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:
108
docs/superpowers/plan-4-kickoff-prompt.md
Normal file
108
docs/superpowers/plan-4-kickoff-prompt.md
Normal file
@@ -0,0 +1,108 @@
|
||||
Continue VolcanicCore development. Plans 1, 2, and 3 are shipped and pushed to
|
||||
origin. Head is `7854cb0` on `main`; the last plan-close commit is
|
||||
`docs: close out Plan 3 — React SPA live`. Backend is 74/74 (`dotnet test`),
|
||||
frontend is 20/20 (`pnpm --dir frontend test`), and the Playwright happy-path
|
||||
smoke passes (`pnpm --dir frontend test:e2e`).
|
||||
|
||||
Read the following in order before writing anything:
|
||||
- `CLAUDE.md` — current status, invariants (esp. the SPA-bundles-into-wwwroot
|
||||
and MapFallbackToFile-goes-last rules), and the working style that shipped
|
||||
Plans 1, 2, and 3 cleanly (superpowers:brainstorming for design if the shape
|
||||
isn't obvious, superpowers:writing-plans for the plan doc,
|
||||
superpowers:subagent-driven-development for execution — auto mode, no
|
||||
per-task confirmation).
|
||||
- `docs/superpowers/specs/2026-08-28-volcaniccore-workflow-engine-design.md` —
|
||||
§8 (Deployment) and §1 (Architecture) especially. The design already commits
|
||||
to single-container Coolify, `/data` volume, single ASP.NET Core 9 process.
|
||||
- `docs/superpowers/plans/2026-08-29-plan-2-workflow-engine.md` and
|
||||
`docs/superpowers/plans/2026-08-30-plan-3-react-spa.md` — for cadence and
|
||||
file-structure conventions.
|
||||
- `docs/superpowers/followups.md` — cross-plan items. Two Plan-4 items are
|
||||
already listed (forwarded-headers/secure-cookie behind Traefik + boot-time
|
||||
re-seed on legacy plaintext `admin.password_hash_source`). Plan 4 MUST clear
|
||||
both.
|
||||
|
||||
Read these skills before authoring:
|
||||
- `preparing-dotnet-react-app-for-coolify` — Dockerfile pattern, docker-compose
|
||||
conventions, `expose: [8080]`, `SERVICE_FQDN_APP`, named volume, static-file
|
||||
serving env-var overrides.
|
||||
- `deploying-to-coolify-via-api` — v4 REST API for creating/updating apps,
|
||||
`deploy.json` for non-secret config, project/server discovery, HTTP-probe
|
||||
verification.
|
||||
|
||||
Author **Plan 4 — Container + Coolify deploy** using superpowers:writing-plans,
|
||||
then execute it with superpowers:subagent-driven-development (same cadence as
|
||||
Plans 1, 2, 3 — auto mode, no per-task confirmation, TDD per task where it
|
||||
applies, two-stage reviews for substantive tasks and combined lightweight
|
||||
reviews for mechanical ones, conventional-commits messages).
|
||||
|
||||
Plan 4 must cover, at minimum:
|
||||
|
||||
- **Multi-stage Dockerfile** in the repo root: pnpm-installs + Vite build the
|
||||
SPA into a scratch stage's `/build/wwwroot`, `dotnet publish` the API in a
|
||||
separate stage using `sdk:9.0`, final runtime image based on the .NET 9
|
||||
runtime (or aspnet:9.0 — distroless if it plays nicely with IronPython's
|
||||
P/Invoke; if not, `mcr.microsoft.com/dotnet/aspnet:9.0` is fine — call it out
|
||||
in the plan). Copy the SPA build output into `/app/wwwroot`. `EXPOSE 8080`.
|
||||
`ENTRYPOINT ["dotnet", "VolcanicCore.Api.dll"]`.
|
||||
- **docker-compose.yml** at repo root, Coolify-flavored per the skill: single
|
||||
service, `expose: [8080]`, named volume mounted at `/data`, env passed
|
||||
through (`ADMIN_PASSWORD`, `ENCRYPTION_KEY`, retention/timeout knobs,
|
||||
`DATA_DIRECTORY=/data`), `SERVICE_FQDN_APP` for the domain, Traefik labels
|
||||
only when the domain is set.
|
||||
- **`Program.cs` — `UseForwardedHeaders` wired before `UseAuthentication`.**
|
||||
Configure `ForwardedHeaders.XForwardedProto | XForwardedFor`,
|
||||
`KnownNetworks` + `KnownProxies` cleared so the Coolify/Traefik reverse
|
||||
proxy is trusted inside the container network. Cookie config stays
|
||||
`SameSite=Lax` but `Secure` starts flipping to `Always` behind TLS. Add
|
||||
xUnit tests that prove the middleware sees `X-Forwarded-Proto: https` and
|
||||
reports `Request.Scheme == "https"`.
|
||||
- **Boot-time re-seed on legacy plaintext `admin.password_hash_source`.**
|
||||
Wrap the `repo.Get(PasswordSourceKey)` call in `AdminBootstrap.Seed` in a
|
||||
`try/catch (CryptographicException)` — treat "can't decrypt" as "row is in
|
||||
the pre-Plan-2 plaintext format, treat as unset" so the boot re-seeds
|
||||
cleanly. Add a test that seeds a plaintext row directly into `admin_config`
|
||||
and asserts `AdminBootstrap.Seed` fixes it up on next boot.
|
||||
- **`deploy.json`** at repo root (or under a `deploy/` dir) capturing project
|
||||
UUID, server UUID, planned domain, non-secret app env vars — populated once
|
||||
from Coolify project/server discovery (Task in the plan asks the user for
|
||||
the domain).
|
||||
- **Coolify deploy — CREATE the app via v4 API** if it doesn't exist yet, PATCH
|
||||
its git config + env vars, then trigger a deploy fire-and-forget per the
|
||||
global CLAUDE.md rule. Verify the deployment id came back; DO NOT poll the
|
||||
build. Instead, the plan's Playwright smoke can be reused later against
|
||||
the live URL as a follow-up sanity — do NOT block on it in this plan.
|
||||
- **CI story for the Playwright smoke.** If GitHub Actions / Gitea Actions
|
||||
aren't wired yet, add a lightweight workflow (or note in followups.md that
|
||||
it lands in a Plan-5 CI task). Either way, make sure the Playwright browser
|
||||
install step is documented.
|
||||
- **README-ish** — the plan's close-out task should extend `CLAUDE.md` with the
|
||||
live URL, the current deploy invocation, and any new invariants the Docker
|
||||
build introduces.
|
||||
|
||||
After Plan 4 executes cleanly, the app is live at its Coolify FQDN and the
|
||||
close-out commit is on `main`. This is the end of the initial-launch scope.
|
||||
|
||||
Working invariants that MUST NOT be relearned from scratch:
|
||||
- Working branch: `main`. No feature branches.
|
||||
- Every commit needs `-c user.email="claude@volcanic" -c user.name="Claude"`.
|
||||
- All the Plan-1/2/3 invariants in CLAUDE.md: cookie auth is in the .NET 9
|
||||
shared framework (do NOT add `Microsoft.AspNetCore.Authentication.Cookies`
|
||||
as a PackageReference); test parallelism stays off; env-var loop honors
|
||||
pre-set config; `WebhookRegistry` / `WebhookRouter` live in `.Core`;
|
||||
`TriggerActivationSink` is fire-and-forget; SPA bundles into
|
||||
`src/VolcanicCore.Api/wwwroot/`; `MapFallbackToFile` stays the LAST `Map*`
|
||||
call; `vite.config.ts` needs `/// <reference types="vitest" />` on line 1.
|
||||
- Global CLAUDE.md: deploys are fire-and-forget via the Coolify v4 API — do
|
||||
NOT poll the build, do NOT block on the FQDN. The API returns a deployment
|
||||
id; that's the success signal.
|
||||
|
||||
Env vars available in the session: `COOLIFY_URL`, `COOLIFY_KEY`, `GIT_URL`,
|
||||
`GIT_USER`, `GIT_KEY`. Use the `deploying-to-coolify-via-api` skill for the
|
||||
splice-credentials-into-git-URL and stderr-redaction patterns.
|
||||
|
||||
Start by pulling latest, reading CLAUDE.md + followups + the two skills above,
|
||||
confirming `dotnet test` is 74/74 and `pnpm --dir frontend test` is 20/20
|
||||
before authoring Plan 4. Ask the user for the intended Coolify domain
|
||||
(e.g. `volcaniccore.tes.gd`) BEFORE the plan gets to the `deploy.json`
|
||||
creation task — that's a decision only they can make.
|
||||
@@ -29,6 +29,44 @@ describe("ConnectionsPage", () => {
|
||||
expect(screen.getByText(/nats/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("edits an existing connection via PUT, preserving blank secret", async () => {
|
||||
let latestPut: { name?: string; config?: { url?: string; pass?: string } } | null = null;
|
||||
mswServer.use(
|
||||
http.get("/api/connection-providers", () => HttpResponse.json(providers)),
|
||||
http.get("/api/connections", () =>
|
||||
HttpResponse.json([
|
||||
{ id: "c1", providerId: "nats", name: "local", config: { url: "nats://old" } },
|
||||
] as never)),
|
||||
http.put("/api/connections/c1", async ({ request }) => {
|
||||
latestPut = await request.json() as never;
|
||||
return HttpResponse.json({
|
||||
id: "c1", providerId: "nats", name: latestPut!.name, config: { url: latestPut!.config?.url },
|
||||
} as never);
|
||||
}),
|
||||
);
|
||||
renderWithProviders(<ConnectionsPage />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: /^edit$/i }));
|
||||
// Provider select must be disabled in edit mode (provider is immutable).
|
||||
expect(screen.getByLabelText(/provider/i)).toBeDisabled();
|
||||
// Rename + change URL; leave password blank (secret stays via backend merge).
|
||||
const nameInput = screen.getByLabelText(/^name$/i) as HTMLInputElement;
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, "local-renamed");
|
||||
const urlInput = screen.getByLabelText(/server url/i) as HTMLInputElement;
|
||||
await userEvent.clear(urlInput);
|
||||
await userEvent.type(urlInput, "nats://new");
|
||||
await userEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||
await waitFor(() => {
|
||||
expect(latestPut).not.toBeNull();
|
||||
expect(latestPut!.name).toBe("local-renamed");
|
||||
expect(latestPut!.config?.url).toBe("nats://new");
|
||||
// Password field left blank in the form — the frontend sends nothing (or "")
|
||||
// and the backend merges the stored secret. Either way, no plaintext leaks
|
||||
// from the frontend to the wire in the "leave blank" flow.
|
||||
expect(latestPut!.config?.pass ?? "").toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a new connection using the provider's schema", async () => {
|
||||
let created: unknown = null;
|
||||
mswServer.use(
|
||||
|
||||
@@ -1,50 +1,91 @@
|
||||
import { useState } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { apiFetch } from "@/api/client";
|
||||
import { qk, useApiMutation, useConnections, useInvalidate, useProviders } from "@/api/hooks";
|
||||
import { SchemaForm } from "@/forms/SchemaForm";
|
||||
import type { ConnectionDto } from "@/api/types";
|
||||
import type { ConnectionDto, JsonSchemaObject } from "@/api/types";
|
||||
|
||||
type ProviderDto = { id: string; displayName: string; configSchema: JsonSchemaObject };
|
||||
|
||||
export default function ConnectionsPage() {
|
||||
const providers = useProviders();
|
||||
const conns = useConnections();
|
||||
const invalidate = useInvalidate();
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// null = closed; "new" = create form; string = editing that connection id.
|
||||
const [openForm, setOpenForm] = useState<null | "new" | string>(null);
|
||||
|
||||
const create = useApiMutation(
|
||||
(body: { providerId: string; name: string; config: Record<string, unknown> }) =>
|
||||
apiFetch<ConnectionDto>("/api/connections", { method: "POST", body }),
|
||||
{ onSuccess: () => { invalidate(qk.connections); setCreating(false); } },
|
||||
{ onSuccess: () => { invalidate(qk.connections); setOpenForm(null); } },
|
||||
);
|
||||
const update = useApiMutation(
|
||||
(input: { id: string; body: { name: string; config: Record<string, unknown> } }) =>
|
||||
apiFetch<ConnectionDto>(`/api/connections/${input.id}`, { method: "PUT", body: input.body }),
|
||||
{ onSuccess: () => { invalidate(qk.connections); setOpenForm(null); } },
|
||||
);
|
||||
const remove = useApiMutation(
|
||||
(id: string) => apiFetch<void>(`/api/connections/${id}`, { method: "DELETE" }),
|
||||
{ onSuccess: () => invalidate(qk.connections) },
|
||||
);
|
||||
|
||||
const editing = openForm && openForm !== "new"
|
||||
? conns.data?.find((c) => c.id === openForm) ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Connections</h1>
|
||||
<button
|
||||
onClick={() => setCreating(true)}
|
||||
onClick={() => setOpenForm("new")}
|
||||
className="px-3 py-2 rounded bg-brand-600 hover:bg-brand-700 text-sm flex items-center gap-2"
|
||||
>
|
||||
<Plus size={14} /> New connection
|
||||
</button>
|
||||
</div>
|
||||
{creating && providers.data && (
|
||||
<CreateForm
|
||||
|
||||
{openForm === "new" && providers.data && (
|
||||
<ConnectionForm
|
||||
mode="create"
|
||||
providers={providers.data}
|
||||
onCreate={(body) => create.mutate(body)}
|
||||
onCancel={() => setCreating(false)}
|
||||
initialProviderId={providers.data[0]?.id ?? ""}
|
||||
initialName=""
|
||||
initialConfig={{}}
|
||||
submitting={create.isPending}
|
||||
onSubmit={(body) => create.mutate(body)}
|
||||
onCancel={() => setOpenForm(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editing && providers.data && (
|
||||
<ConnectionForm
|
||||
mode="edit"
|
||||
providers={providers.data}
|
||||
initialProviderId={editing.providerId}
|
||||
initialName={editing.name}
|
||||
initialConfig={editing.config as Record<string, unknown>}
|
||||
submitting={update.isPending}
|
||||
onSubmit={(body) =>
|
||||
update.mutate({ id: editing.id, body: { name: body.name, config: body.config } })
|
||||
}
|
||||
onCancel={() => setOpenForm(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ul className="divide-y divide-slate-800 border border-slate-800 rounded overflow-hidden">
|
||||
{conns.data?.map((c) => (
|
||||
<li key={c.id} className="flex items-center gap-3 p-3 hover:bg-slate-900">
|
||||
<span className="flex-1">{c.name}</span>
|
||||
<span className="text-xs text-slate-400">{c.providerId}</span>
|
||||
<button
|
||||
onClick={() => setOpenForm(c.id)}
|
||||
className="text-slate-400 hover:text-slate-200"
|
||||
aria-label="Edit"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => confirm(`Delete "${c.name}"?`) && remove.mutate(c.id)}
|
||||
className="text-slate-400 hover:text-red-400"
|
||||
@@ -62,30 +103,39 @@ export default function ConnectionsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function CreateForm({
|
||||
providers, onCreate, onCancel, submitting,
|
||||
function ConnectionForm({
|
||||
mode, providers, initialProviderId, initialName, initialConfig,
|
||||
submitting, onSubmit, onCancel,
|
||||
}: {
|
||||
providers: { id: string; displayName: string; configSchema: import("@/api/types").JsonSchemaObject }[];
|
||||
onCreate: (b: { providerId: string; name: string; config: Record<string, unknown> }) => void;
|
||||
onCancel: () => void;
|
||||
mode: "create" | "edit";
|
||||
providers: ProviderDto[];
|
||||
initialProviderId: string;
|
||||
initialName: string;
|
||||
initialConfig: Record<string, unknown>;
|
||||
submitting: boolean;
|
||||
onSubmit: (b: { providerId: string; name: string; config: Record<string, unknown> }) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [providerId, setProviderId] = useState(providers[0]?.id ?? "");
|
||||
const [name, setName] = useState("");
|
||||
const [config, setConfig] = useState<Record<string, unknown>>({});
|
||||
const [providerId, setProviderId] = useState(initialProviderId);
|
||||
const [name, setName] = useState(initialName);
|
||||
const [config, setConfig] = useState<Record<string, unknown>>(initialConfig);
|
||||
const provider = providers.find((p) => p.id === providerId);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="bg-slate-900 border border-slate-800 rounded p-4 space-y-3"
|
||||
onSubmit={(e) => { e.preventDefault(); if (providerId && name.trim()) onCreate({ providerId, name: name.trim(), config }); }}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (providerId && name.trim()) onSubmit({ providerId, name: name.trim(), config });
|
||||
}}
|
||||
>
|
||||
<label className="block text-sm space-y-1">
|
||||
<span className="text-slate-300">Provider</span>
|
||||
<select
|
||||
value={providerId}
|
||||
onChange={(e) => { setProviderId(e.target.value); setConfig({}); }}
|
||||
className="w-full rounded bg-slate-950 border border-slate-800 px-3 py-2 text-sm"
|
||||
disabled={mode === "edit"}
|
||||
className="w-full rounded bg-slate-950 border border-slate-800 px-3 py-2 text-sm disabled:opacity-60"
|
||||
>
|
||||
{providers.map((p) => (<option key={p.id} value={p.id}>{p.displayName}</option>))}
|
||||
</select>
|
||||
@@ -99,13 +149,19 @@ function CreateForm({
|
||||
/>
|
||||
</label>
|
||||
{provider && <SchemaForm schema={provider.configSchema} value={config} onChange={setConfig} />}
|
||||
{mode === "edit" && (
|
||||
<p className="text-xs text-slate-500">
|
||||
Secret fields (password etc.) come back blank on load — leave them blank to keep the
|
||||
existing value, or type a new value to rotate it.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-3 py-2 rounded bg-brand-600 hover:bg-brand-700 text-sm"
|
||||
>
|
||||
Create
|
||||
{mode === "edit" ? "Save" : "Create"}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} className="text-sm text-slate-400">Cancel</button>
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace VolcanicCore.Tests;
|
||||
|
||||
@@ -90,5 +91,143 @@ public class ConnectionsEndpointsTests : IClassFixture<WebFactory>
|
||||
Assert.DoesNotContain(get.GetProperty("config").EnumerateObject(), p => p.Name == "pass");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_updates_name_and_non_secret_fields()
|
||||
{
|
||||
var c = await LoggedIn();
|
||||
var create = await c.PostAsJsonAsync("/api/connections", new
|
||||
{
|
||||
providerId = "nats", name = "put-a",
|
||||
config = new { url = "nats://old", user = "u", pass = "old-secret" }
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
||||
var id = (await create.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
|
||||
|
||||
var put = await c.PutAsJsonAsync($"/api/connections/{id}", new
|
||||
{
|
||||
name = "put-a-renamed",
|
||||
config = new { url = "nats://new", user = "u2", pass = "" }
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, put.StatusCode);
|
||||
var updated = await put.Content.ReadFromJsonAsync<ConnectionDto>();
|
||||
Assert.Equal("put-a-renamed", updated!.Name);
|
||||
Assert.Equal("nats", updated.ProviderId);
|
||||
Assert.Equal("nats://new", updated.Config.GetProperty("url").GetString());
|
||||
Assert.Equal("u2", updated.Config.GetProperty("user").GetString());
|
||||
// pass is a secret field and must never come back in the response.
|
||||
Assert.DoesNotContain(updated.Config.EnumerateObject(), p => p.Name == "pass");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_with_blank_secret_field_preserves_existing_value()
|
||||
{
|
||||
// Round-trip through NATS's OpenAsync would be the ideal check, but we
|
||||
// can't stand up a real broker here. Instead: create, update with blank
|
||||
// pass, then re-hydrate a raw ConnectionRow from the repo and confirm
|
||||
// the stored config still has the ORIGINAL secret.
|
||||
var c = await LoggedIn();
|
||||
var create = await c.PostAsJsonAsync("/api/connections", new
|
||||
{
|
||||
providerId = "nats", name = "keep-secret",
|
||||
config = new { url = "nats://x", user = "u", pass = "original-secret" }
|
||||
});
|
||||
var id = (await create.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
|
||||
|
||||
var put = await c.PutAsJsonAsync($"/api/connections/{id}", new
|
||||
{
|
||||
name = "keep-secret",
|
||||
config = new { url = "nats://x", user = "u", pass = "" } // blank secret
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, put.StatusCode);
|
||||
|
||||
// Probe: fetch the row through the repo directly and inspect the
|
||||
// decrypted stored config for the preserved secret.
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider
|
||||
.GetRequiredService<VolcanicCore.Persistence.Repositories.ConnectionRepository>();
|
||||
var row = repo.Get(id);
|
||||
Assert.NotNull(row);
|
||||
using var stored = JsonDocument.Parse(row!.ConfigJson);
|
||||
Assert.Equal("original-secret", stored.RootElement.GetProperty("pass").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_with_new_secret_field_value_overwrites_it()
|
||||
{
|
||||
var c = await LoggedIn();
|
||||
var create = await c.PostAsJsonAsync("/api/connections", new
|
||||
{
|
||||
providerId = "nats", name = "rotate-secret",
|
||||
config = new { url = "nats://x", user = "u", pass = "old-secret" }
|
||||
});
|
||||
var id = (await create.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
|
||||
|
||||
var put = await c.PutAsJsonAsync($"/api/connections/{id}", new
|
||||
{
|
||||
name = "rotate-secret",
|
||||
config = new { url = "nats://x", user = "u", pass = "brand-new-secret" }
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.OK, put.StatusCode);
|
||||
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider
|
||||
.GetRequiredService<VolcanicCore.Persistence.Repositories.ConnectionRepository>();
|
||||
var row = repo.Get(id);
|
||||
using var stored = JsonDocument.Parse(row!.ConfigJson);
|
||||
Assert.Equal("brand-new-secret", stored.RootElement.GetProperty("pass").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_on_unknown_id_returns_404()
|
||||
{
|
||||
var c = await LoggedIn();
|
||||
var put = await c.PutAsJsonAsync("/api/connections/does-not-exist", new
|
||||
{
|
||||
name = "x", config = new { url = "nats://x" }
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.NotFound, put.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_with_duplicate_name_per_provider_returns_409()
|
||||
{
|
||||
var c = await LoggedIn();
|
||||
var a = await c.PostAsJsonAsync("/api/connections", new
|
||||
{
|
||||
providerId = "nats", name = "put-dup-a", config = new { url = "nats://a" }
|
||||
});
|
||||
var b = await c.PostAsJsonAsync("/api/connections", new
|
||||
{
|
||||
providerId = "nats", name = "put-dup-b", config = new { url = "nats://b" }
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Created, a.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Created, b.StatusCode);
|
||||
var bId = (await b.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
|
||||
|
||||
var put = await c.PutAsJsonAsync($"/api/connections/{bId}", new
|
||||
{
|
||||
name = "put-dup-a", // collides with row A
|
||||
config = new { url = "nats://b" }
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Conflict, put.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Put_with_non_object_config_returns_400()
|
||||
{
|
||||
var c = await LoggedIn();
|
||||
var create = await c.PostAsJsonAsync("/api/connections", new
|
||||
{
|
||||
providerId = "nats", name = "put-badcfg", config = new { url = "nats://x" }
|
||||
});
|
||||
var id = (await create.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
|
||||
|
||||
var put = await c.PutAsJsonAsync($"/api/connections/{id}", new
|
||||
{
|
||||
name = "put-badcfg", config = "not-an-object"
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, put.StatusCode);
|
||||
}
|
||||
|
||||
private sealed record ConnectionDto(string Id, string ProviderId, string Name, JsonElement Config);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user