Files
DriverVault/API Server/internal/api/control_e2e_test.go
T
tajniak81andClaude Opus 4.8 19a7d48feb Harden Anker Solix OCPP control (token hashing, step-up, audit, TLS)
Security pass over the OCPP charger-control feature added in a1519f6, since
remotely actuating a physical charger is a real side effect.

Token hygiene:
  - Per-charger control tokens are stored as SHA-256 hashes + a last-4 hint,
    never plaintext. The token is shown once at generation; the status endpoint
    returns only the hint. Added a revoke endpoint that also drops any live
    session using the revoked token.

Step-up + confirmation:
  - Destructive actions (reset, unlock) require confirm:true AND a password
    re-authentication (verified against PocketBase). The Charging UI collects the
    password inline for reset.
  - Per user+charger rate limit (30/min) on control commands.

Transport + provenance:
  - OCPP_REQUIRE_TLS (default on) rejects plaintext ws:// charger connections;
    OCPP_PUBLIC_URL pins the advertised endpoint instead of trusting request
    headers.
  - Proxy-mode upstream URL is validated against a *.anker.com allowlist, so a
    spoofed ocpp-info response can't redirect the proxy.

Durable audit:
  - New control_audit PocketBase collection (added to setup-pocketbase.mjs);
    every control action, token generate/revoke and charger connect is persisted
    best-effort in addition to a structured log line.

Startup:
  - The control-token index is warmed from PocketBase on startup so a charger
    reconnecting after a restart resolves immediately.

Tests:
  - Unit tests for token hashing/eviction/revoke (no plaintext at rest),
    rate limiter, destructive-action classifier, upstream allowlist, TLS
    enforcement, and re-auth guards. A full-stack E2E (control_e2e_test.go)
    drives the real Handler with a stand-in PocketBase and a simulated charge
    point, proving step-up (400/401/200) and audit persistence end to end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 21:03:05 +02:00

241 lines
8.0 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"drivervault/apiserver/internal/config"
"drivervault/apiserver/internal/ocpp"
"drivervault/apiserver/internal/pb"
)
// This test exercises the two PocketBase-touching control paths end-to-end
// through the real Handler + middleware chain: step-up re-authentication for a
// destructive action (reset), and best-effort audit persistence to the
// control_audit collection. A stand-in PocketBase (fakePB) serves exactly the
// endpoints the flow hits; a simulated charge point connects to the live CSMS so
// the command has a real session to reach.
const (
e2eUserID = "u1"
e2eUserMail = "owner@test.local"
e2ePassword = "correct-horse-battery"
e2eSerial = "CP-TEST"
e2eToken = "charger-control-token-abc"
e2eBearer = "user-bearer-token"
)
// fakePB records audit creates and answers the identity, user-record,
// re-auth and service-auth calls the control flow makes.
type fakePB struct {
mu sync.Mutex
audits []map[string]any
userPluginSettings json.RawMessage
}
func (f *fakePB) handler(t *testing.T) http.Handler {
mux := http.NewServeMux()
// Service-account auth (pb.Client.Authenticate).
mux.HandleFunc("POST /api/collections/_superusers/auth-with-password", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"token": "svc-token"})
})
// Identify the bearer (withAuth → identify → AuthRefresh).
mux.HandleFunc("POST /api/collections/users/auth-refresh", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" {
writeJSON(w, 401, map[string]any{})
return
}
writeJSON(w, 200, map[string]any{"record": map[string]any{
"id": e2eUserID, "email": e2eUserMail, "name": "Owner", "role": "user", "organization": "",
}})
})
// User record (userPluginSettings + callerForUser both GET this).
mux.HandleFunc("GET /api/collections/users/records/"+e2eUserID, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{
"id": e2eUserID, "email": e2eUserMail, "role": "user", "organization": "",
"pluginSettings": f.userPluginSettings,
})
})
// Step-up re-auth (reauthenticate → LoginWithPassword).
mux.HandleFunc("POST /api/collections/users/auth-with-password", func(w http.ResponseWriter, r *http.Request) {
var body struct{ Identity, Password string }
_ = json.NewDecoder(r.Body).Decode(&body)
if body.Identity == e2eUserMail && body.Password == e2ePassword {
writeJSON(w, 200, map[string]any{"token": "reauth-ok", "record": map[string]any{"id": e2eUserID}})
return
}
writeJSON(w, 400, map[string]any{"message": "Failed to authenticate."})
})
// Durable audit sink.
mux.HandleFunc("POST /api/collections/control_audit/records", func(w http.ResponseWriter, r *http.Request) {
var rec map[string]any
_ = json.NewDecoder(r.Body).Decode(&rec)
f.mu.Lock()
f.audits = append(f.audits, rec)
f.mu.Unlock()
writeJSON(w, 200, map[string]any{"id": "audit-rec"})
})
return mux
}
func (f *fakePB) auditsWith(action, result string) int {
f.mu.Lock()
defer f.mu.Unlock()
n := 0
for _, a := range f.audits {
if a["action"] == action && strings.Contains(toStr(a["result"]), result) {
n++
}
}
return n
}
func toStr(v any) string {
s, _ := v.(string)
return s
}
func TestControlStepUpAndAuditE2E(t *testing.T) {
tokenHashHex := hashToken(e2eToken)
fake := &fakePB{
userPluginSettings: json.RawMessage(`{"ankerSolix":{"enabled":true,"config":{"controlMode":"own"},` +
`"controlChargers":{"` + e2eSerial + `":{"tokenHash":"` + tokenHashHex + `","tokenHint":"` + tokenHint(e2eToken) + `"}}}}`),
}
pbSrv := httptest.NewServer(fake.handler(t))
defer pbSrv.Close()
// Enable the anker-solix plugin globally via a plugins.json.
dir := t.TempDir()
pluginsFile := filepath.Join(dir, "plugins.json")
if err := os.WriteFile(pluginsFile, []byte(`{"anker-solix":{"enabled":true}}`), 0o600); err != nil {
t.Fatal(err)
}
s := New(config.Config{
UsersCollection: "users",
PluginsFile: pluginsFile,
OCPPRequireTLS: false, // httptest is plaintext; TLS enforcement covered elsewhere
}, pb.New(pbSrv.URL, "admin@test.local", "pw"))
if err := s.plugins.Load(); err != nil {
t.Fatalf("load plugins: %v", err)
}
// Seed the control-token index (mirrors what generating a token does).
s.control.setUser(e2eUserID, map[string]ankerChargerBinding{
e2eSerial: {TokenHash: tokenHashHex, TokenHint: tokenHint(e2eToken)},
})
appSrv := httptest.NewServer(s.Handler())
defer appSrv.Close()
// Connect a simulated charger to the live CSMS (own mode).
stop := connectCharger(t, "ws"+strings.TrimPrefix(appSrv.URL, "http")+"/ocpp/"+e2eSerial, e2eSerial, e2eToken)
defer stop()
// Wait for the session to register.
if !waitFor(2*time.Second, func() bool { _, ok := s.ocpp.SessionFor(e2eSerial); return ok }) {
t.Fatal("charger session never registered on the CSMS")
}
// 1. Destructive reset without confirm → 400.
if code, _ := postControl(t, appSrv.URL, "reset", map[string]any{}); code != http.StatusBadRequest {
t.Errorf("reset without confirm: status %d, want 400", code)
}
// 2. Confirmed reset with the WRONG password → 401 (step-up fails).
if code, _ := postControl(t, appSrv.URL, "reset", map[string]any{"confirm": true, "password": "wrong"}); code != http.StatusUnauthorized {
t.Errorf("reset with wrong password: status %d, want 401", code)
}
// 3. Confirmed reset with the CORRECT password → 200 and the charger accepts.
code, body := postControl(t, appSrv.URL, "reset", map[string]any{"confirm": true, "password": e2ePassword})
if code != http.StatusOK {
t.Fatalf("reset with correct password: status %d body %s, want 200", code, body)
}
if got := body["status"]; got != "Accepted" {
t.Errorf("reset status = %v, want Accepted", got)
}
// The audit sink must have recorded the failed re-auth and the accepted reset
// (persisted asynchronously, so poll).
if !waitFor(2*time.Second, func() bool { return fake.auditsWith("reset", "reauth-failed") >= 1 }) {
t.Error("expected a control_audit record for the failed re-auth")
}
if !waitFor(2*time.Second, func() bool { return fake.auditsWith("reset", "Accepted") >= 1 }) {
t.Error("expected a control_audit record for the accepted reset")
}
}
// postControl issues an authenticated control command and returns the status and
// decoded JSON body.
func postControl(t *testing.T, base, action string, payload map[string]any) (int, map[string]any) {
t.Helper()
b, _ := json.Marshal(payload)
req, _ := http.NewRequest(http.MethodPost,
base+"/api/integrations/anker-solix/chargers/"+e2eSerial+"/"+action, bytes.NewReader(b))
req.Header.Set("Authorization", e2eBearer)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s: %v", action, err)
}
defer resp.Body.Close()
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, out
}
// connectCharger dials the CSMS as an OCPP charge point and answers every inbound
// control CALL with {status:"Accepted"}. Returns a stop function.
func connectCharger(t *testing.T, url, serial, token string) func() {
t.Helper()
h := http.Header{}
h.Set("Authorization", ocpp.BasicAuthHeader(serial, token))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, err := ocpp.Dial(ctx, url, []string{"ocpp1.6"}, h)
if err != nil {
t.Fatalf("charger dial: %v", err)
}
go func() {
for {
data, err := conn.ReadMessage()
if err != nil {
return
}
msg, err := ocpp.DecodeMessage(data)
if err != nil || msg.Type != ocpp.MessageTypeCall {
continue
}
out, _ := ocpp.EncodeCallResult(msg.ID, map[string]any{"status": "Accepted"})
_ = conn.WriteMessage(out)
}
}()
return func() { _ = conn.Close() }
}
func waitFor(d time.Duration, cond func() bool) bool {
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
if cond() {
return true
}
time.Sleep(10 * time.Millisecond)
}
return cond()
}