The frontend is rebuilt screen by screen from the handoff: warm cream ground, terracotta + sage accents, Caprasimo over Figtree, every control a pill. Same React/Vite/TanStack stack and the same lib/ data layer; the presentation is new. - Tokens: web/src/styles/index.css declares the handoff's styles.css variables through Tailwind's @theme under the same names; dark mode is those variables overridden on <html> by the handoff's pansy-theme.js, inlined in index.html so it runs before first paint. Lucide glyphs at stroke 2.75; a small pill kit (Button, Dialog, Field, Seg, Toggle, Tag, toast). - Login / Register: the centered column over soft accent circles; OIDC button and signup footer still follow /auth/providers. - Gardens: cards with a real SVG plot thumbnail (objects + plant-colored dots from /full), a `plan` tag for "<name> — <year>" copies, shares line, Open + share/copy/edit/delete; New garden / Share / Plan-a-season dialogs. - Plants: monogram markers derived from the name (collision-resolved across the catalog — replaces emoji icons), category chips, expandable lot cards, the scan-packet flow as a two-step dialog that never auto-creates. - Settings: Appearance (theme seg), Who gets in (read-only sign-in config), Garden assistant (self-saving toggle + chat/vision model fields), You. - Editor: a new canvas with the prototype's pointer model (wheel-to-cursor, pinch about the centroid, 3″ snap, one PATCH per drop, semantic-zoom monograms/labels), plus corner resize handles; desktop three-card workspace (toolkit | plan | rail with Plot/Journal/History/Assistant) and, below 760px of container width, the phone chrome (header, peek panel, tool strip, mode bar). Seasons as a segmented control over the years with data plus plan copies; Undo re-reads history before reverting the newest step. - Public read-only view and the register page restyled to match. - GET /settings gains a read-only `auth` view (registration mode, local auth, OIDC issuer) so the Settings page can show what's in force. - README / DESIGN.md / CLAUDE.md updated; @use-gesture/react dropped. Co-Authored-By: Claude Fable 5 <[email protected]>
228 lines
8.9 KiB
Go
228 lines
8.9 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
|
)
|
|
|
|
// agentCfg is a config with the assistant configured — a (fake) key, on, and a
|
|
// resolvable model. The key isn't real, but ValidateAgentModel/NewRunner only
|
|
// PARSE the spec (no live call), so a Runner still builds and capabilities
|
|
// reports it live. That's enough to exercise the runtime on/off swap.
|
|
func agentCfg() *config.Config {
|
|
c := localCfg()
|
|
c.Agent = config.AgentConfig{
|
|
Model: "ollama-cloud/glm-5.2:cloud",
|
|
OllamaCloudAPIKey: "test-key",
|
|
Enabled: true,
|
|
}
|
|
return c
|
|
}
|
|
|
|
func settingsVersion(t *testing.T, r *gin.Engine, cookie *http.Cookie) int64 {
|
|
t.Helper()
|
|
w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, cookie)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("get settings: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
st, _ := decodeMap(t, w.Body.Bytes())["settings"].(map[string]any)
|
|
return int64(st["version"].(float64))
|
|
}
|
|
|
|
// TestSettingsAdminOnly: the first registered user is admin and can read/write
|
|
// settings; a second user is not and gets 403 (not 404 — settings aren't a
|
|
// masked resource).
|
|
func TestSettingsAdminOnly(t *testing.T) {
|
|
r := authEngine(t, localCfg())
|
|
admin := registerAndCookie(t, r, "[email protected]") // first user → admin
|
|
member := registerAndCookie(t, r, "[email protected]")
|
|
|
|
if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, admin); w.Code != http.StatusOK {
|
|
t.Fatalf("admin GET settings: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, member); w.Code != http.StatusForbidden {
|
|
t.Errorf("member GET settings: status %d, want 403", w.Code)
|
|
}
|
|
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
|
map[string]any{"agentModel": "x", "version": 1}, member); w.Code != http.StatusForbidden {
|
|
t.Errorf("member PATCH settings: status %d, want 403", w.Code)
|
|
}
|
|
// Unauthenticated is 401, before the admin check.
|
|
if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, nil); w.Code != http.StatusUnauthorized {
|
|
t.Errorf("anonymous GET settings: status %d, want 401", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestSettingsInheritFromEnv: an untouched instance reports the env model as
|
|
// effective, and an empty stored model keeps inheriting it.
|
|
func TestSettingsInheritFromEnv(t *testing.T) {
|
|
r := authEngine(t, agentCfg())
|
|
admin := registerAndCookie(t, r, "[email protected]")
|
|
|
|
w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, admin)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("get: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
body := decodeMap(t, w.Body.Bytes())
|
|
st := body["settings"].(map[string]any)
|
|
eff := body["effective"].(map[string]any)
|
|
|
|
if st["agentModel"] != "" {
|
|
t.Errorf("stored model = %v, want empty (inherit)", st["agentModel"])
|
|
}
|
|
if eff["model"] != "ollama-cloud/glm-5.2:cloud" {
|
|
t.Errorf("effective model = %v, want the env value", eff["model"])
|
|
}
|
|
if eff["hasApiKey"] != true || eff["agentLive"] != true {
|
|
t.Errorf("effective = %+v, want a key present and the agent live", eff)
|
|
}
|
|
|
|
// The read-only sign-in view the Settings page renders under "Who gets in".
|
|
// These come straight from the environment config, so the shape is what's
|
|
// asserted: a registration mode, a local-auth flag, and no secret material.
|
|
auth, ok := body["auth"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("settings response has no auth view: %v", body)
|
|
}
|
|
if reg := auth["registration"]; reg != "open" && reg != "closed" {
|
|
t.Errorf("auth.registration = %v, want open or closed", reg)
|
|
}
|
|
if _, isBool := auth["localAuth"].(bool); !isBool {
|
|
t.Errorf("auth.localAuth = %v, want a bool", auth["localAuth"])
|
|
}
|
|
for _, k := range []string{"clientId", "clientSecret", "oidcClientSecret"} {
|
|
if _, present := auth[k]; present {
|
|
t.Errorf("auth view exposes %q — secrets must never leave the environment", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSettingsUpdateSwapsTheRunner is the core of #79: changing settings takes
|
|
// effect on the LIVE assistant, with no restart. Driven entirely through HTTP.
|
|
func TestSettingsUpdateSwapsTheRunner(t *testing.T) {
|
|
r := authEngine(t, agentCfg())
|
|
admin := registerAndCookie(t, r, "[email protected]")
|
|
|
|
capsAgent := func() bool {
|
|
w := doJSON(t, r, http.MethodGet, "/api/v1/capabilities", nil, admin)
|
|
return decodeMap(t, w.Body.Bytes())["agent"] == true
|
|
}
|
|
|
|
// Configured and on out of the box.
|
|
if !capsAgent() {
|
|
t.Fatal("assistant should be live at boot with a key + enabled")
|
|
}
|
|
|
|
// Turn it OFF via settings → capabilities flips immediately.
|
|
v := settingsVersion(t, r, admin)
|
|
w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
|
map[string]any{"agentModel": "", "agentEnabled": false, "version": v}, admin)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("disable: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
if capsAgent() {
|
|
t.Error("assistant still live after being disabled — the runner wasn't swapped")
|
|
}
|
|
// Chat now refuses, at runtime, on a route that still exists.
|
|
gid := createGardenAPI(t, r, admin, "G")
|
|
if w := doJSON(t, r, http.MethodPost, "/api/v1/agent/chat",
|
|
map[string]any{"gardenId": gid, "message": "hi"}, admin); w.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("chat while disabled: status %d, want 503", w.Code)
|
|
}
|
|
|
|
// Turn it back ON, with an explicit model, and confirm it's live again and the
|
|
// effective model reflects the change.
|
|
v = settingsVersion(t, r, admin)
|
|
w = doJSON(t, r, http.MethodPatch, "/api/v1/settings", map[string]any{
|
|
"agentModel": "ollama-cloud/kimi-k2.6:cloud", "agentEnabled": true, "version": v,
|
|
}, admin)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("re-enable: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
if eff := decodeMap(t, w.Body.Bytes())["effective"].(map[string]any); eff["model"] != "ollama-cloud/kimi-k2.6:cloud" {
|
|
t.Errorf("effective model = %v after change, want the new one", eff["model"])
|
|
}
|
|
if !capsAgent() {
|
|
t.Error("assistant not live after being re-enabled")
|
|
}
|
|
}
|
|
|
|
// TestSettingsRejectsBadModel: a spec that won't resolve is a 400 at save time,
|
|
// not a broken assistant on the next turn.
|
|
func TestSettingsRejectsBadModel(t *testing.T) {
|
|
r := authEngine(t, agentCfg())
|
|
admin := registerAndCookie(t, r, "[email protected]")
|
|
v := settingsVersion(t, r, admin)
|
|
|
|
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
|
map[string]any{"agentModel": "nonesuch/model", "version": v}, admin); w.Code != http.StatusBadRequest {
|
|
t.Errorf("bad model: status %d, want 400", w.Code)
|
|
}
|
|
// agentEnabled must be a bool or null, not a string.
|
|
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
|
map[string]any{"agentModel": "", "agentEnabled": "yes", "version": v}, admin); w.Code != http.StatusBadRequest {
|
|
t.Errorf("string agentEnabled: status %d, want 400", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestSettingsVersionConflict: a stale version 409s and carries the current row.
|
|
func TestSettingsVersionConflict(t *testing.T) {
|
|
r := authEngine(t, agentCfg())
|
|
admin := registerAndCookie(t, r, "[email protected]")
|
|
v := settingsVersion(t, r, admin)
|
|
|
|
// First write succeeds and bumps the version.
|
|
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
|
map[string]any{"agentModel": "", "agentEnabled": false, "version": v}, admin); w.Code != http.StatusOK {
|
|
t.Fatalf("first update: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
// Reusing the old version conflicts.
|
|
w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
|
map[string]any{"agentModel": "", "agentEnabled": true, "version": v}, admin)
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("stale update: status %d, want 409", w.Code)
|
|
}
|
|
if cur, ok := decodeMap(t, w.Body.Bytes())["current"].(map[string]any); !ok || cur["version"].(float64) != float64(v+1) {
|
|
t.Errorf("409 body missing the current row at the bumped version: %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestSettingsSwapUnderRace runs settings saves concurrently with chat requests,
|
|
// so `go test -race` proves the atomic swap of the live Runner is safe against
|
|
// in-flight readers. The whole point of the atomic.Pointer is this: without it,
|
|
// toggling the assistant while a request reads it is a data race.
|
|
func TestSettingsSwapUnderRace(t *testing.T) {
|
|
r := authEngine(t, agentCfg())
|
|
admin := registerAndCookie(t, r, "[email protected]")
|
|
|
|
done := make(chan struct{})
|
|
// Readers hammer capabilities, whose h.agent.get() is the SAME atomic Load the
|
|
// chat handler does — so this races the pointer read against the writer's swap
|
|
// without ever invoking the model (a real Run would hit the network on a fake
|
|
// key). If get() is race-clean here it is race-clean in chat.
|
|
for i := 0; i < 4; i++ {
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-done:
|
|
return
|
|
default:
|
|
doJSON(t, r, http.MethodGet, "/api/v1/capabilities", nil, admin)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
// Writer: flip the assistant on and off, swapping the pointer each time.
|
|
for i := 0; i < 12; i++ {
|
|
v := settingsVersion(t, r, admin)
|
|
doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
|
map[string]any{"agentModel": "", "agentEnabled": i%2 == 0, "version": v}, admin)
|
|
}
|
|
close(done)
|
|
}
|