Phases 11, 12: mcrctl CLI tool and mcr-web UI
Phase 11 implements the admin CLI with dual REST/gRPC transport, global flags (--server, --grpc, --token, --ca-cert, --json), and all commands: status, repo list/delete, policy CRUD, audit tail, gc trigger/status/reconcile, and snapshot. Phase 12 implements the HTMX web UI with chi router, session-based auth (HttpOnly/Secure/SameSite=Strict cookies), CSRF protection (HMAC-SHA256 signed double-submit), and pages for dashboard, repositories, manifest detail, policy management, and audit log. Security: CSRF via signed double-submit cookie, session cookies with HttpOnly/Secure/SameSite=Strict, TLS 1.3 minimum on all connections, form body size limits via http.MaxBytesReader. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
267
cmd/mcrctl/client_test.go
Normal file
267
cmd/mcrctl/client_test.go
Normal file
@@ -0,0 +1,267 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatSize(t *testing.T) {
|
||||
tests := []struct {
|
||||
bytes int64
|
||||
want string
|
||||
}{
|
||||
{0, "0 B"},
|
||||
{512, "512 B"},
|
||||
{1024, "1.0 KB"},
|
||||
{1536, "1.5 KB"},
|
||||
{1048576, "1.0 MB"},
|
||||
{1073741824, "1.0 GB"},
|
||||
{1099511627776, "1.0 TB"},
|
||||
{2684354560, "2.5 GB"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := formatSize(tt.bytes)
|
||||
if got != tt.want {
|
||||
t.Errorf("formatSize(%d) = %q, want %q", tt.bytes, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJSON(t *testing.T) {
|
||||
// Capture stdout.
|
||||
old := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdout = w
|
||||
|
||||
data := map[string]string{"status": "ok"}
|
||||
if err := printJSON(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
var buf bytes.Buffer
|
||||
if _, err := buf.ReadFrom(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var parsed map[string]string
|
||||
if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil {
|
||||
t.Fatalf("printJSON output is not valid JSON: %v\nOutput: %s", err, buf.String())
|
||||
}
|
||||
if parsed["status"] != "ok" {
|
||||
t.Errorf("expected status=ok, got %q", parsed["status"])
|
||||
}
|
||||
|
||||
// Verify indentation.
|
||||
if !strings.Contains(buf.String(), " ") {
|
||||
t.Error("expected indented JSON output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintTable(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
headers := []string{"NAME", "SIZE", "COUNT"}
|
||||
rows := [][]string{
|
||||
{"alpha", "1.0 MB", "5"},
|
||||
{"beta", "2.5 GB", "12"},
|
||||
}
|
||||
printTable(&buf, headers, rows)
|
||||
|
||||
output := buf.String()
|
||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("expected 3 lines (header + 2 rows), got %d:\n%s", len(lines), output)
|
||||
}
|
||||
|
||||
// Header should contain all column names.
|
||||
for _, h := range headers {
|
||||
if !strings.Contains(lines[0], h) {
|
||||
t.Errorf("header missing %q: %s", h, lines[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Rows should contain the data.
|
||||
if !strings.Contains(lines[1], "alpha") {
|
||||
t.Errorf("row 1 missing 'alpha': %s", lines[1])
|
||||
}
|
||||
if !strings.Contains(lines[2], "beta") {
|
||||
t.Errorf("row 2 missing 'beta': %s", lines[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenFromEnv(t *testing.T) {
|
||||
t.Setenv("MCR_TOKEN", "test-env-token")
|
||||
|
||||
// Build client with empty token flag (should fall back to env).
|
||||
c, err := newClient("https://example.com", "", "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.close()
|
||||
|
||||
// The newClient does not resolve env; the main() PersistentPreRunE does.
|
||||
// So here we test the pattern manually.
|
||||
token := ""
|
||||
if token == "" {
|
||||
token = os.Getenv("MCR_TOKEN")
|
||||
}
|
||||
if token != "test-env-token" {
|
||||
t.Errorf("expected token from env, got %q", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenFlagOverridesEnv(t *testing.T) {
|
||||
t.Setenv("MCR_TOKEN", "env-token")
|
||||
|
||||
flagVal := "flag-token"
|
||||
token := flagVal
|
||||
if token == "" {
|
||||
token = os.Getenv("MCR_TOKEN")
|
||||
}
|
||||
if token != "flag-token" {
|
||||
t.Errorf("expected flag token to override env, got %q", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientREST(t *testing.T) {
|
||||
c, err := newClient("https://example.com", "", "mytoken", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.close()
|
||||
|
||||
if c.useGRPC() {
|
||||
t.Error("expected REST client (no gRPC)")
|
||||
}
|
||||
if c.serverURL != "https://example.com" {
|
||||
t.Errorf("unexpected serverURL: %s", c.serverURL)
|
||||
}
|
||||
if c.token != "mytoken" {
|
||||
t.Errorf("unexpected token: %s", c.token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientBadCACert(t *testing.T) {
|
||||
_, err := newClient("https://example.com", "", "", "/nonexistent/ca.pem")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent CA cert")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CA cert") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientInvalidCACert(t *testing.T) {
|
||||
tmpFile := t.TempDir() + "/bad.pem"
|
||||
if err := os.WriteFile(tmpFile, []byte("not a certificate"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := newClient("https://example.com", "", "", tmpFile)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid CA cert")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no valid certificates") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestDoSuccess(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
t.Errorf("missing or wrong Authorization header: %s", r.Header.Get("Authorization"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &apiClient{
|
||||
serverURL: srv.URL,
|
||||
token: "test-token",
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
|
||||
data, err := c.restDo("GET", "/v1/health", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Status != "ok" {
|
||||
t.Errorf("expected status=ok, got %q", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestDoError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`{"error":"admin role required"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &apiClient{
|
||||
serverURL: srv.URL,
|
||||
token: "bad-token",
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
|
||||
_, err := c.restDo("GET", "/v1/repositories", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 403 response")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "admin role required") {
|
||||
t.Errorf("error should contain server message: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "403") {
|
||||
t.Errorf("error should contain status code: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestDoPostBody(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("expected application/json content type, got %s", r.Header.Get("Content-Type"))
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Errorf("failed to decode body: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"id":"gc-123"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &apiClient{
|
||||
serverURL: srv.URL,
|
||||
token: "token",
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
|
||||
data, err := c.restDo("POST", "/v1/gc", map[string]string{"test": "value"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "gc-123") {
|
||||
t.Errorf("unexpected response: %s", string(data))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user