Fix UI: install real HTMX, add PG creds and roles UI

- web/static/htmx.min.js: replace placeholder stub with
  htmx 2.0.4 (downloaded from unpkg.com). The placeholder
  only logged a console warning; no HTMX features worked,
  so form submissions fell back to native POSTs and the
  account_row fragment was returned as a raw HTML body
  rather than spliced into the table. This was the root
  cause of account creation appearing to 'do nothing'.
- internal/ui/ui.go: add pgcreds_form.html to shared
  template list; add PUT /accounts/{id}/pgcreds route;
  reorder AccountDetailData fields so embedded PageData
  does not shadow Account.
- internal/ui/handlers_accounts.go: add handleSetPGCreds
  handler — encrypts the submitted password with AES-256-GCM
  using the server master key before storage, validates
  system-account-only constraint, re-reads and re-renders
  the fragment after save. Add PGCred field population to
  handleAccountDetail.
- internal/ui/ui_test.go: add tests for account creation,
  role management, and PG credential handlers.
- web/templates/account_detail.html: add Postgres
  Credentials card for system accounts.
- web/templates/fragments/pgcreds_form.html: new fragment
  for the PG credentials form; CSRF token is supplied via
  the body-level hx-headers attribute in base.html.
Security: PG password is encrypted with AES-256-GCM
(crypto.SealAESGCM) before storage; a fresh nonce is
generated per call; the plaintext is never logged or
returned in responses.
This commit is contained in:
2026-03-11 22:30:13 -07:00
parent 9b0adfdde4
commit 5a8698e199
7 changed files with 528 additions and 14 deletions

View File

@@ -3,10 +3,12 @@ package ui
import (
"crypto/ed25519"
"crypto/rand"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
@@ -14,6 +16,7 @@ import (
"git.wntrmute.dev/kyle/mcias/internal/config"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token"
)
const testIssuer = "https://auth.example.com"
@@ -299,3 +302,228 @@ func TestLoginPostPasswordNotInTOTPForm(t *testing.T) {
t.Error("two consecutive nonces are identical (randomness failure)")
}
}
// ---- PG credentials UI tests ----
// issueAdminSession creates a system account with the "admin" role, issues a
// JWT for it, tracks the token in the DB, and returns the raw token string
// along with the account UUID for constructing request paths.
func issueAdminSession(t *testing.T, u *UIServer) (tokenStr, accountUUID string, accountID int64) {
t.Helper()
acct, err := u.db.CreateAccount("pgtest-admin", model.AccountTypeHuman, "")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if err := u.db.SetRoles(acct.ID, []string{"admin"}, nil); err != nil {
t.Fatalf("SetRoles: %v", err)
}
tok, claims, err := token.IssueToken(u.privKey, testIssuer, acct.UUID, []string{"admin"}, time.Hour)
if err != nil {
t.Fatalf("IssueToken: %v", err)
}
if err := u.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
t.Fatalf("TrackToken: %v", err)
}
return tok, acct.UUID, acct.ID
}
// authenticatedPUT builds a PUT request to path with the session cookie, a
// valid CSRF cookie + header, and the given form body.
func authenticatedPUT(t *testing.T, u *UIServer, path string, sessionToken string, form url.Values) *http.Request {
t.Helper()
body := strings.NewReader(form.Encode())
req := httptest.NewRequest(http.MethodPut, path, body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("HX-Request", "true")
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sessionToken})
csrfCookieVal, csrfHeaderVal, err := u.csrf.NewToken()
if err != nil {
t.Fatalf("csrf.NewToken: %v", err)
}
req.AddCookie(&http.Cookie{Name: csrfCookieName, Value: csrfCookieVal})
req.Header.Set("X-CSRF-Token", csrfHeaderVal)
return req
}
// authenticatedGET builds a GET request with the session cookie set.
func authenticatedGET(t *testing.T, sessionToken string, path string) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sessionToken})
return req
}
// TestSetPGCredsRejectsHumanAccount verifies that the PUT /accounts/{id}/pgcreds
// endpoint returns 400 when the target account is a human (not system) account.
func TestSetPGCredsRejectsHumanAccount(t *testing.T) {
u := newTestUIServer(t)
mux := http.NewServeMux()
u.Register(mux)
sessionToken, _, _ := issueAdminSession(t, u)
// Create a human account to target.
humanAcct, err := u.db.CreateAccount("human-target", model.AccountTypeHuman, "")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
form := url.Values{
"host": {"db.example.com"}, "database": {"mydb"},
"username": {"user"}, "password": {"s3cret"},
}
req := authenticatedPUT(t, u, fmt.Sprintf("/accounts/%s/pgcreds", humanAcct.UUID), sessionToken, form)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rr.Code, http.StatusBadRequest)
}
}
// TestSetPGCredsStoresAndDisplaysMetadata verifies that a valid PUT on a system
// account stores the credentials and returns host, database, and username in the
// response body (non-sensitive metadata). The password must not appear.
func TestSetPGCredsStoresAndDisplaysMetadata(t *testing.T) {
u := newTestUIServer(t)
mux := http.NewServeMux()
u.Register(mux)
sessionToken, _, _ := issueAdminSession(t, u)
sysAcct, err := u.db.CreateAccount("sys-target", model.AccountTypeSystem, "")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
const (
testHost = "pghost.internal"
testDB = "appdb"
testUsername = "appuser"
testPassword = "super-secret-pw"
)
form := url.Values{
"host": {testHost}, "port": {"5433"}, "database": {testDB},
"username": {testUsername}, "password": {testPassword},
}
req := authenticatedPUT(t, u, fmt.Sprintf("/accounts/%s/pgcreds", sysAcct.UUID), sessionToken, form)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, want := range []string{testHost, testDB, testUsername} {
if !strings.Contains(body, want) {
t.Errorf("response body missing %q; got:\n%s", want, body)
}
}
// Security: password must not appear anywhere in the response.
if strings.Contains(body, testPassword) {
t.Errorf("response body contains plaintext password — security violation")
}
}
// TestSetPGCredsPasswordNotEchoed explicitly confirms the plaintext password is
// absent from the response even when all other fields are valid.
func TestSetPGCredsPasswordNotEchoed(t *testing.T) {
u := newTestUIServer(t)
mux := http.NewServeMux()
u.Register(mux)
sessionToken, _, _ := issueAdminSession(t, u)
sysAcct, err := u.db.CreateAccount("sys-echo-check", model.AccountTypeSystem, "")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
const secretPassword = "must-not-appear-in-response"
form := url.Values{
"host": {"h"}, "database": {"d"}, "username": {"u"}, "password": {secretPassword},
}
req := authenticatedPUT(t, u, fmt.Sprintf("/accounts/%s/pgcreds", sysAcct.UUID), sessionToken, form)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if strings.Contains(rr.Body.String(), secretPassword) {
t.Error("response body contains the plaintext password — security violation")
}
}
// TestSetPGCredsRequiresPassword verifies that omitting the password returns an error.
func TestSetPGCredsRequiresPassword(t *testing.T) {
u := newTestUIServer(t)
mux := http.NewServeMux()
u.Register(mux)
sessionToken, _, _ := issueAdminSession(t, u)
sysAcct, err := u.db.CreateAccount("sys-no-pw", model.AccountTypeSystem, "")
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
form := url.Values{
"host": {"h"}, "database": {"d"}, "username": {"u"}, // password intentionally absent
}
req := authenticatedPUT(t, u, fmt.Sprintf("/accounts/%s/pgcreds", sysAcct.UUID), sessionToken, form)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d (password required)", rr.Code, http.StatusBadRequest)
}
}
// TestAccountDetailShowsPGCredsSection verifies that the account detail page for
// a system account includes the pgcreds section, and that a human account does not.
func TestAccountDetailShowsPGCredsSection(t *testing.T) {
u := newTestUIServer(t)
mux := http.NewServeMux()
u.Register(mux)
sessionToken, _, _ := issueAdminSession(t, u)
sysAcct, err := u.db.CreateAccount("sys-detail", model.AccountTypeSystem, "")
if err != nil {
t.Fatalf("CreateAccount (system): %v", err)
}
humanAcct, err := u.db.CreateAccount("human-detail", model.AccountTypeHuman, "hash")
if err != nil {
t.Fatalf("CreateAccount (human): %v", err)
}
// System account detail must include the pgcreds section.
sysReq := authenticatedGET(t, sessionToken, fmt.Sprintf("/accounts/%s", sysAcct.UUID))
sysRR := httptest.NewRecorder()
mux.ServeHTTP(sysRR, sysReq)
if sysRR.Code != http.StatusOK {
t.Fatalf("system account GET status = %d, want 200", sysRR.Code)
}
if !strings.Contains(sysRR.Body.String(), "pgcreds-section") {
t.Error("system account detail page missing pgcreds-section")
}
// Human account detail must NOT include the pgcreds section.
humanReq := authenticatedGET(t, sessionToken, fmt.Sprintf("/accounts/%s", humanAcct.UUID))
humanRR := httptest.NewRecorder()
mux.ServeHTTP(humanRR, humanReq)
if humanRR.Code != http.StatusOK {
t.Fatalf("human account GET status = %d, want 200", humanRR.Code)
}
if strings.Contains(humanRR.Body.String(), "pgcreds-section") {
t.Error("human account detail page must not include pgcreds-section")
}
}