Files
DriverVault/API Server/internal/api/server_ocpp_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

89 lines
3.6 KiB
Go

package api
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"drivervault/apiserver/internal/config"
"drivervault/apiserver/internal/ocpp"
"drivervault/apiserver/internal/pb"
)
// TestOCPPRouteAndControlAuth exercises the real HTTP stack (all middleware
// included) to confirm the OCPP endpoint and the control REST routes are wired,
// and that the charger-auth gate rejects an unknown token. PocketBase is left
// unconfigured, so the successful (charger-connected) path is out of scope here —
// that is covered by the internal/ocpp own/proxy tests.
func TestOCPPRouteAndControlAuth(t *testing.T) {
s := New(config.Config{UsersCollection: "users"}, pb.New("", "", ""))
srv := httptest.NewServer(s.Handler())
defer srv.Close()
wsBase := "ws" + strings.TrimPrefix(srv.URL, "http")
// A charger connecting with no OCPP Basic auth is rejected (401).
if _, err := ocpp.Dial(context.Background(), wsBase+"/ocpp/CP1", []string{"ocpp1.6"}, nil); err == nil ||
!strings.Contains(err.Error(), "401") {
t.Fatalf("unauthenticated charger connect: want HTTP 401, got %v", err)
}
// A bogus token is unknown to the index (PB unconfigured, so the rebuild is a
// no-op) and is likewise rejected.
h := http.Header{}
h.Set("Authorization", ocpp.BasicAuthHeader("CP1", "bogus-token"))
if _, err := ocpp.Dial(context.Background(), wsBase+"/ocpp/CP1", []string{"ocpp1.6"}, h); err == nil ||
!strings.Contains(err.Error(), "401") {
t.Fatalf("bogus-token charger connect: want HTTP 401, got %v", err)
}
// The control REST routes are registered (401 for a missing bearer token, not
// 404 for an unknown route) — this also proves the {sn}/control,
// {sn}/control/token and {sn}/{action} patterns don't shadow each other.
routes := []struct {
method, path string
}{
{http.MethodGet, "/api/integrations/anker-solix/chargers/CP1/control"},
{http.MethodPost, "/api/integrations/anker-solix/chargers/CP1/control/token"},
{http.MethodPost, "/api/integrations/anker-solix/chargers/CP1/start"},
}
for _, rt := range routes {
req, _ := http.NewRequest(rt.method, srv.URL+rt.path, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", rt.method, rt.path, err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("%s %s: status %d, want 401 (route registered, bearer required)", rt.method, rt.path, resp.StatusCode)
}
}
}
// TestOCPPRequireTLS confirms a plaintext charger connection is rejected when TLS
// is required, and that a TLS-terminating proxy (X-Forwarded-Proto) satisfies the
// check (falling through to the auth gate).
func TestOCPPRequireTLS(t *testing.T) {
s := New(config.Config{UsersCollection: "users", OCPPRequireTLS: true}, pb.New("", "", ""))
srv := httptest.NewServer(s.Handler())
defer srv.Close()
wsBase := "ws" + strings.TrimPrefix(srv.URL, "http")
// Plaintext ws:// with no forwarded-proto → rejected before auth (403).
if _, err := ocpp.Dial(context.Background(), wsBase+"/ocpp/CP1", []string{"ocpp1.6"}, nil); err == nil ||
!strings.Contains(err.Error(), "403") {
t.Fatalf("plaintext connect under require-TLS: want HTTP 403, got %v", err)
}
// Behind a TLS-terminating proxy the connection is accepted past the TLS gate
// and then fails auth (401), proving the gate ordering.
h := http.Header{}
h.Set("X-Forwarded-Proto", "https")
if _, err := ocpp.Dial(context.Background(), wsBase+"/ocpp/CP1", []string{"ocpp1.6"}, h); err == nil ||
!strings.Contains(err.Error(), "401") {
t.Fatalf("forwarded-https connect: want HTTP 401 (past TLS gate), got %v", err)
}
}