사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
687 lines
22 KiB
Go
687 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/ecdh"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"io"
|
|
"math/big"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const secureDeliveryE2EEnv = "IOP_SECURE_DELIVERY_E2E"
|
|
|
|
// TestSecureDeliveryThreeProcess is an opt-in, process-level proof for the
|
|
// managed credential path. The normal package suite skips it; the repository
|
|
// secure-delivery target enables it after the lightweight CP-Edge smoke.
|
|
func TestSecureDeliveryThreeProcess(t *testing.T) {
|
|
if os.Getenv(secureDeliveryE2EEnv) != "1" {
|
|
t.Skip("set IOP_SECURE_DELIVERY_E2E=1 to run the three-process fixture")
|
|
}
|
|
|
|
repoRoot := secureRepoRoot(t)
|
|
workDir, err := os.MkdirTemp(repoRoot, ".tmp-secure-delivery-")
|
|
if err != nil {
|
|
t.Fatalf("create executable integration workspace: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = os.RemoveAll(workDir) })
|
|
goTmp := filepath.Join(workDir, "gotmp")
|
|
if err := os.MkdirAll(goTmp, 0o700); err != nil {
|
|
t.Fatalf("create Go temporary directory: %v", err)
|
|
}
|
|
|
|
binaries := map[string]string{
|
|
"control-plane": filepath.Join(workDir, "control-plane"),
|
|
"edge": filepath.Join(workDir, "edge"),
|
|
"node": filepath.Join(workDir, "node"),
|
|
}
|
|
for name, pkg := range map[string]string{
|
|
"control-plane": "./apps/control-plane/cmd/control-plane",
|
|
"edge": "./apps/edge/cmd/edge",
|
|
"node": "./apps/node/cmd/node",
|
|
} {
|
|
cmd := exec.Command("go", "build", "-o", binaries[name], pkg)
|
|
cmd.Dir = repoRoot
|
|
cmd.Env = append(os.Environ(), "TMPDIR="+workDir, "GOTMPDIR="+goTmp)
|
|
output, buildErr := cmd.CombinedOutput()
|
|
if buildErr != nil {
|
|
t.Fatalf("build %s: %v\n%s", name, buildErr, output)
|
|
}
|
|
}
|
|
|
|
ca := secureNewCA(t, workDir)
|
|
cpIdentity := ca.issue(t, workDir, "control-plane", "cp-secure", []string{"cp.internal", "cp-api.internal"})
|
|
edgeIdentity := ca.issue(t, workDir, "edge", "edge-secure", []string{"edge.internal", "edge-api.internal"})
|
|
nodeIdentity := ca.issue(t, workDir, "node", "node-secure", nil)
|
|
|
|
issuerPublic, issuerPrivate, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("generate issuer key: %v", err)
|
|
}
|
|
issuerPrivatePath := secureWriteBase64(t, workDir, "issuer.ed25519", issuerPrivate, 0o600)
|
|
issuerPublicPath := secureWriteBase64(t, workDir, "issuer.ed25519.pub", issuerPublic, 0o644)
|
|
recipientPrivate, err := ecdh.X25519().GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("generate recipient key: %v", err)
|
|
}
|
|
recipientPrivatePath := secureWriteBase64(t, workDir, "recipient.x25519", recipientPrivate.Bytes(), 0o600)
|
|
|
|
atRestMaterial := make([]byte, 32)
|
|
if _, err := rand.Read(atRestMaterial); err != nil {
|
|
t.Fatalf("generate at-rest key: %v", err)
|
|
}
|
|
manifestPath := filepath.Join(workDir, "credential-keys.yaml")
|
|
secureWriteFile(t, manifestPath, []byte(fmt.Sprintf("keys:\n - id: secure-e2e\n version: 1\n material: %s\n", base64.StdEncoding.EncodeToString(atRestMaterial))), 0o600)
|
|
for i := range atRestMaterial {
|
|
atRestMaterial[i] = 0
|
|
}
|
|
|
|
cpHTTPPort := secureFreePort(t)
|
|
cpClientPort := secureFreePort(t)
|
|
cpEdgePort := secureFreePort(t)
|
|
edgeNodePort := secureFreePort(t)
|
|
edgeBootstrapPort := secureFreePort(t)
|
|
edgeOpenAIPort := secureFreePort(t)
|
|
databasePath := filepath.Join(workDir, "credentials.db")
|
|
|
|
const providerSecret = "secure-delivery-sentinel-7e2d8c"
|
|
upstreamStarted := make(chan struct{}, 1)
|
|
releaseUpstream := make(chan struct{})
|
|
var releaseUpstreamOnce sync.Once
|
|
releaseBlockedUpstream := func() { releaseUpstreamOnce.Do(func() { close(releaseUpstream) }) }
|
|
defer releaseBlockedUpstream()
|
|
var upstreamCalls atomic.Int32
|
|
var upstreamAuthOK atomic.Bool
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1/chat/completions" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"object":"list","data":[]}`)
|
|
return
|
|
}
|
|
upstreamCalls.Add(1)
|
|
upstreamAuthOK.Store(r.Header.Get("Authorization") == "Bearer "+providerSecret)
|
|
select {
|
|
case upstreamStarted <- struct{}{}:
|
|
default:
|
|
}
|
|
select {
|
|
case <-releaseUpstream:
|
|
case <-r.Context().Done():
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"id":"chatcmpl-secure","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`)
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
cpConfig := filepath.Join(workDir, "control-plane.yaml")
|
|
secureWriteFile(t, cpConfig, []byte(fmt.Sprintf(`server:
|
|
listen: "127.0.0.1:%d"
|
|
wire_listen: "127.0.0.1:%d"
|
|
edge_wire_listen: "127.0.0.1:%d"
|
|
edge_wire_tls:
|
|
enabled: true
|
|
cert: %q
|
|
key: %q
|
|
ca: %q
|
|
peer_role: "edge"
|
|
peer_name: "edge-secure"
|
|
database:
|
|
url: %q
|
|
credential_encryption:
|
|
key_file: %q
|
|
active_key_id: "secure-e2e"
|
|
active_key_version: 1
|
|
credential_plane:
|
|
enabled: true
|
|
https:
|
|
enabled: true
|
|
cert: %q
|
|
key: %q
|
|
issuer_key_id: "issuer-secure"
|
|
issuer_private_key: %q
|
|
lease_ttl_seconds: 30
|
|
lease_cache_size: 32
|
|
logging:
|
|
level: "info"
|
|
pretty: false
|
|
metrics:
|
|
port: 0
|
|
`, cpHTTPPort, cpClientPort, cpEdgePort, cpIdentity.cert, cpIdentity.key, ca.cert, databasePath, manifestPath, cpIdentity.cert, cpIdentity.key, issuerPrivatePath)), 0o600)
|
|
|
|
edgeConfig := filepath.Join(workDir, "edge.yaml")
|
|
secureWriteFile(t, edgeConfig, []byte(fmt.Sprintf(`edge:
|
|
id: "edge-secure"
|
|
name: "Secure Delivery Edge"
|
|
server:
|
|
listen: "127.0.0.1:%d"
|
|
bootstrap:
|
|
listen: "127.0.0.1:%d"
|
|
artifact_dir: %q
|
|
tls:
|
|
enabled: true
|
|
cert: %q
|
|
key: %q
|
|
ca: %q
|
|
peer_role: "node"
|
|
peer_name: "node-secure"
|
|
credential_plane:
|
|
enabled: true
|
|
lease_ttl_seconds: 30
|
|
lease_cache_size: 32
|
|
control_plane:
|
|
enabled: true
|
|
wire_addr: "127.0.0.1:%d"
|
|
reconnect_interval_sec: 1
|
|
tls:
|
|
enabled: true
|
|
cert: %q
|
|
key: %q
|
|
ca: %q
|
|
server_name: "cp.internal"
|
|
peer_role: "control-plane"
|
|
peer_name: "cp-secure"
|
|
refresh:
|
|
enabled: false
|
|
long_context_threshold_tokens: 100000
|
|
provider_pool:
|
|
max_queue: 4
|
|
queue_timeout_ms: 3000
|
|
openai:
|
|
enabled: true
|
|
listen: "127.0.0.1:%d"
|
|
provider_id: "provider-secure"
|
|
session_id: "secure-e2e"
|
|
timeout_sec: 15
|
|
strict_output: true
|
|
tls:
|
|
enabled: true
|
|
cert: %q
|
|
key: %q
|
|
a2a:
|
|
enabled: false
|
|
logging:
|
|
level: "info"
|
|
pretty: false
|
|
path: %q
|
|
metrics:
|
|
port: 0
|
|
models:
|
|
- id: "catalog-secure"
|
|
providers:
|
|
provider-secure: "upstream-secure"
|
|
nodes:
|
|
- id: "node-secure"
|
|
alias: "Secure Node"
|
|
token: "node-registration-token"
|
|
providers:
|
|
- id: "provider-secure"
|
|
type: "openai_api"
|
|
category: "api"
|
|
profile: "openai"
|
|
endpoint: %q
|
|
models: ["upstream-secure"]
|
|
health: "available"
|
|
capacity: 1
|
|
runtime:
|
|
concurrency: 1
|
|
`, edgeNodePort, edgeBootstrapPort, filepath.Join(workDir, "artifacts"), edgeIdentity.cert, edgeIdentity.key, ca.cert, cpEdgePort, edgeIdentity.cert, edgeIdentity.key, ca.cert, edgeOpenAIPort, edgeIdentity.cert, edgeIdentity.key, filepath.Join(workDir, "edge.log"), upstream.URL)), 0o600)
|
|
|
|
nodeConfig := filepath.Join(workDir, "node.yaml")
|
|
secureWriteFile(t, nodeConfig, []byte(fmt.Sprintf(`transport:
|
|
edge_addr: "127.0.0.1:%d"
|
|
token: "node-registration-token"
|
|
tls:
|
|
enabled: true
|
|
cert: %q
|
|
key: %q
|
|
ca: %q
|
|
server_name: "edge.internal"
|
|
peer_role: "edge"
|
|
peer_name: "edge-secure"
|
|
credential_plane:
|
|
enabled: true
|
|
recipient_key_id: "recipient-secure"
|
|
recipient_private_key: %q
|
|
issuer_key_id: "issuer-secure"
|
|
issuer_public_key: %q
|
|
replay_cache_size: 32
|
|
reconnect:
|
|
interval_sec: 1
|
|
max_attempts: 30
|
|
logging:
|
|
level: "info"
|
|
pretty: false
|
|
metrics:
|
|
port: 0
|
|
`, edgeNodePort, nodeIdentity.cert, nodeIdentity.key, ca.cert, recipientPrivatePath, issuerPublicPath)), 0o600)
|
|
|
|
bootstrap := exec.Command(binaries["control-plane"], "--config", cpConfig, "principal", "bootstrap", "--alias", "secure-principal")
|
|
bootstrap.Dir = workDir
|
|
bootstrapOutput, err := bootstrap.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("bootstrap principal: %v\n%s", err, bootstrapOutput)
|
|
}
|
|
principalToken := strings.TrimSpace(string(bootstrapOutput))
|
|
if len(principalToken) != 64 {
|
|
t.Fatalf("bootstrap returned an invalid one-time token length: %d", len(principalToken))
|
|
}
|
|
|
|
cpProcess := secureStartProcess(t, workDir, "control-plane", binaries["control-plane"], "serve", "--config", cpConfig)
|
|
defer cpProcess.stop(t)
|
|
cpClient := secureHTTPSClient(t, ca.cert, "cp-api.internal")
|
|
cpBaseURL := fmt.Sprintf("https://127.0.0.1:%d", cpHTTPPort)
|
|
secureWaitHTTP(t, cpClient, cpBaseURL+"/healthz", nil, cpProcess)
|
|
|
|
edgeProcess := secureStartProcess(t, workDir, "edge", binaries["edge"], "serve", "--config", edgeConfig)
|
|
defer edgeProcess.stop(t)
|
|
edgeClient := secureHTTPSClient(t, ca.cert, "edge-api.internal")
|
|
edgeBaseURL := fmt.Sprintf("https://127.0.0.1:%d", edgeOpenAIPort)
|
|
secureWaitHTTP(t, edgeClient, edgeBaseURL+"/healthz", nil, edgeProcess)
|
|
|
|
nodeProcess := secureStartProcess(t, workDir, "node", binaries["node"], "serve", "--config", nodeConfig)
|
|
defer nodeProcess.stop(t)
|
|
|
|
slot := secureCredentialRequest(t, cpClient, http.MethodPost, cpBaseURL+"/v1/credentials/slots", principalToken, []byte(providerSecret), map[string]string{
|
|
"Content-Type": "application/octet-stream", "IOP-Credential-Vendor": "openai", "IOP-Credential-Kind": "bearer", "IOP-Credential-Alias": "secure-slot",
|
|
})
|
|
slotID := secureJSONText(t, slot, "ID")
|
|
routeBody, err := json.Marshal(map[string]string{
|
|
"slot_id": slotID, "alias": "secure-route", "profile_id": "openai", "upstream_model": "upstream-secure", "resource_selector": "provider-secure",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("encode route request: %v", err)
|
|
}
|
|
route := secureCredentialRequest(t, cpClient, http.MethodPost, cpBaseURL+"/v1/credentials/routes", principalToken, routeBody, map[string]string{"Content-Type": "application/json"})
|
|
routeID := secureJSONText(t, route, "ID")
|
|
routeRevision := secureJSONInt(t, route, "Revision")
|
|
|
|
modelsHeaders := map[string]string{"Authorization": "Bearer " + principalToken}
|
|
secureWaitHTTP(t, edgeClient, edgeBaseURL+"/v1/models", modelsHeaders, nodeProcess)
|
|
secureWaitLog(t, nodeProcess, "connected to edge")
|
|
|
|
requestBody := []byte(fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"hello"}],"stream":false}`, routeID))
|
|
type responseResult struct {
|
|
status int
|
|
body []byte
|
|
err error
|
|
}
|
|
requestDone := make(chan responseResult, 1)
|
|
go func() {
|
|
req, requestErr := http.NewRequest(http.MethodPost, edgeBaseURL+"/v1/chat/completions", bytes.NewReader(requestBody))
|
|
if requestErr != nil {
|
|
requestDone <- responseResult{err: requestErr}
|
|
return
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+principalToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, requestErr := edgeClient.Do(req)
|
|
if requestErr != nil {
|
|
requestDone <- responseResult{err: requestErr}
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
requestDone <- responseResult{status: resp.StatusCode, body: body, err: readErr}
|
|
}()
|
|
|
|
select {
|
|
case <-upstreamStarted:
|
|
case result := <-requestDone:
|
|
t.Fatalf("managed request terminated before the fake upstream: status=%d err=%v body=%s\n%s", result.status, result.err, result.body, secureProcessDiagnostics(cpProcess, edgeProcess, nodeProcess))
|
|
case <-time.After(20 * time.Second):
|
|
t.Fatalf("managed request did not reach the fake upstream\n%s", secureProcessDiagnostics(cpProcess, edgeProcess, nodeProcess))
|
|
}
|
|
if !upstreamAuthOK.Load() {
|
|
t.Fatal("fake upstream did not receive the exact expected managed authorization header")
|
|
}
|
|
|
|
revokeURL := fmt.Sprintf("%s/v1/credentials/routes/%s/revoke", cpBaseURL, routeID)
|
|
secureCredentialRequest(t, cpClient, http.MethodPost, revokeURL, principalToken, nil, map[string]string{"IOP-Expected-Revision": fmt.Sprintf("%d", routeRevision)})
|
|
releaseBlockedUpstream()
|
|
select {
|
|
case result := <-requestDone:
|
|
if result.err != nil || result.status != http.StatusOK || !bytes.Contains(result.body, []byte(`"content":"ok"`)) {
|
|
t.Fatalf("already-started request did not finish: status=%d err=%v body=%s", result.status, result.err, result.body)
|
|
}
|
|
case <-time.After(20 * time.Second):
|
|
t.Fatal("already-started request did not terminate after revoke")
|
|
}
|
|
|
|
secondReq, err := http.NewRequest(http.MethodPost, edgeBaseURL+"/v1/chat/completions", bytes.NewReader(requestBody))
|
|
if err != nil {
|
|
t.Fatalf("create post-revoke request: %v", err)
|
|
}
|
|
secondReq.Header.Set("Authorization", "Bearer "+principalToken)
|
|
secondReq.Header.Set("Content-Type", "application/json")
|
|
secondResp, err := edgeClient.Do(secondReq)
|
|
if err != nil {
|
|
t.Fatalf("post-revoke request: %v", err)
|
|
}
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(secondResp.Body, 1<<20))
|
|
secondResp.Body.Close()
|
|
if secondResp.StatusCode >= 200 && secondResp.StatusCode < 300 {
|
|
t.Fatalf("post-revoke request unexpectedly succeeded: status=%d", secondResp.StatusCode)
|
|
}
|
|
time.Sleep(250 * time.Millisecond)
|
|
if got := upstreamCalls.Load(); got != 1 {
|
|
t.Fatalf("post-revoke request reached upstream: calls=%d want=1", got)
|
|
}
|
|
|
|
nodeProcess.stop(t)
|
|
edgeProcess.stop(t)
|
|
cpProcess.stop(t)
|
|
for _, process := range []*secureProcess{cpProcess, edgeProcess, nodeProcess} {
|
|
logBytes, readErr := os.ReadFile(process.logPath)
|
|
if readErr != nil {
|
|
t.Fatalf("read %s log: %v", process.name, readErr)
|
|
}
|
|
if bytes.Contains(logBytes, []byte(providerSecret)) {
|
|
t.Fatalf("provider secret appeared in %s process output", process.name)
|
|
}
|
|
}
|
|
t.Log("secure delivery full cycle passed: HTTPS management and ingress, CP-Edge/Edge-Node mTLS, Node-sealed lease, exact upstream auth, in-flight completion, and post-revoke fence")
|
|
}
|
|
|
|
type secureIdentity struct{ cert, key string }
|
|
|
|
type secureCA struct {
|
|
cert string
|
|
parsedCert *x509.Certificate
|
|
privateKey ed25519.PrivateKey
|
|
}
|
|
|
|
func secureNewCA(t *testing.T, dir string) secureCA {
|
|
t.Helper()
|
|
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("generate CA key: %v", err)
|
|
}
|
|
now := time.Now().Add(-time.Minute)
|
|
template := &x509.Certificate{
|
|
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "IOP secure delivery test CA"},
|
|
NotBefore: now, NotAfter: now.Add(time.Hour), IsCA: true, BasicConstraintsValid: true,
|
|
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, template, template, publicKey, privateKey)
|
|
if err != nil {
|
|
t.Fatalf("create CA certificate: %v", err)
|
|
}
|
|
parsed, err := x509.ParseCertificate(der)
|
|
if err != nil {
|
|
t.Fatalf("parse CA certificate: %v", err)
|
|
}
|
|
path := filepath.Join(dir, "ca.pem")
|
|
secureWriteFile(t, path, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644)
|
|
return secureCA{cert: path, parsedCert: parsed, privateKey: privateKey}
|
|
}
|
|
|
|
func (ca secureCA) issue(t *testing.T, dir, role, name string, dnsNames []string) secureIdentity {
|
|
t.Helper()
|
|
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("generate %s identity: %v", role, err)
|
|
}
|
|
identityURI, err := url.Parse(fmt.Sprintf("spiffe://iop/%s/%s", role, name))
|
|
if err != nil {
|
|
t.Fatalf("parse workload URI: %v", err)
|
|
}
|
|
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 120))
|
|
if err != nil {
|
|
t.Fatalf("generate certificate serial: %v", err)
|
|
}
|
|
now := time.Now().Add(-time.Minute)
|
|
template := &x509.Certificate{
|
|
SerialNumber: serial, Subject: pkix.Name{CommonName: name}, DNSNames: dnsNames, URIs: []*url.URL{identityURI},
|
|
NotBefore: now, NotAfter: now.Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
|
|
}
|
|
der, err := x509.CreateCertificate(rand.Reader, template, ca.parsedCert, publicKey, ca.privateKey)
|
|
if err != nil {
|
|
t.Fatalf("create %s identity: %v", role, err)
|
|
}
|
|
certPath := filepath.Join(dir, role+".pem")
|
|
keyPath := filepath.Join(dir, role+".key")
|
|
secureWriteFile(t, certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644)
|
|
privateDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
|
if err != nil {
|
|
t.Fatalf("marshal %s identity key: %v", role, err)
|
|
}
|
|
secureWriteFile(t, keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateDER}), 0o600)
|
|
return secureIdentity{cert: certPath, key: keyPath}
|
|
}
|
|
|
|
type secureProcess struct {
|
|
name string
|
|
cmd *exec.Cmd
|
|
done chan error
|
|
logPath string
|
|
logFile *os.File
|
|
stopped atomic.Bool
|
|
}
|
|
|
|
func secureStartProcess(t *testing.T, dir, name, binary string, args ...string) *secureProcess {
|
|
t.Helper()
|
|
logPath := filepath.Join(dir, name+".out")
|
|
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
|
if err != nil {
|
|
t.Fatalf("open %s log: %v", name, err)
|
|
}
|
|
cmd := exec.Command(binary, args...)
|
|
cmd.Dir = dir
|
|
cmd.Stdout = logFile
|
|
cmd.Stderr = logFile
|
|
if err := cmd.Start(); err != nil {
|
|
logFile.Close()
|
|
t.Fatalf("start %s: %v", name, err)
|
|
}
|
|
process := &secureProcess{name: name, cmd: cmd, done: make(chan error, 1), logPath: logPath, logFile: logFile}
|
|
go func() { process.done <- cmd.Wait() }()
|
|
return process
|
|
}
|
|
|
|
func (p *secureProcess) stop(t *testing.T) {
|
|
t.Helper()
|
|
if p == nil || !p.stopped.CompareAndSwap(false, true) {
|
|
return
|
|
}
|
|
if p.cmd.Process != nil {
|
|
_ = p.cmd.Process.Signal(os.Interrupt)
|
|
}
|
|
select {
|
|
case <-p.done:
|
|
case <-time.After(5 * time.Second):
|
|
if p.cmd.Process != nil {
|
|
_ = p.cmd.Process.Kill()
|
|
}
|
|
<-p.done
|
|
}
|
|
if err := p.logFile.Close(); err != nil {
|
|
t.Errorf("close %s log: %v", p.name, err)
|
|
}
|
|
}
|
|
|
|
func secureRepoRoot(t *testing.T) string {
|
|
t.Helper()
|
|
workingDir, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("get working directory: %v", err)
|
|
}
|
|
root, err := filepath.Abs(filepath.Join(workingDir, "../../../.."))
|
|
if err != nil {
|
|
t.Fatalf("resolve repository root: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil {
|
|
t.Fatalf("repository root is invalid: %v", err)
|
|
}
|
|
return root
|
|
}
|
|
|
|
func secureFreePort(t *testing.T) int {
|
|
t.Helper()
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("allocate port: %v", err)
|
|
}
|
|
defer listener.Close()
|
|
return listener.Addr().(*net.TCPAddr).Port
|
|
}
|
|
|
|
func secureHTTPSClient(t *testing.T, caPath, serverName string) *http.Client {
|
|
t.Helper()
|
|
caPEM, err := os.ReadFile(caPath)
|
|
if err != nil {
|
|
t.Fatalf("read CA: %v", err)
|
|
}
|
|
roots := x509.NewCertPool()
|
|
if !roots.AppendCertsFromPEM(caPEM) {
|
|
t.Fatal("parse CA certificate")
|
|
}
|
|
return &http.Client{Timeout: 20 * time.Second, Transport: &http.Transport{TLSClientConfig: &tls.Config{
|
|
MinVersion: tls.VersionTLS13, RootCAs: roots, ServerName: serverName,
|
|
}}}
|
|
}
|
|
|
|
func secureWaitHTTP(t *testing.T, client *http.Client, endpoint string, headers map[string]string, process *secureProcess) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(30 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
t.Fatalf("create readiness request: %v", err)
|
|
}
|
|
for name, value := range headers {
|
|
req.Header.Set(name, value)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err == nil {
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
|
|
resp.Body.Close()
|
|
if resp.StatusCode == http.StatusOK {
|
|
return
|
|
}
|
|
}
|
|
select {
|
|
case processErr := <-process.done:
|
|
process.stopped.Store(true)
|
|
_ = process.logFile.Close()
|
|
logBytes, _ := os.ReadFile(process.logPath)
|
|
t.Fatalf("%s exited before readiness: %v\n%s", process.name, processErr, logBytes)
|
|
default:
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
t.Fatalf("readiness timeout for %s\n%s", endpoint, secureProcessDiagnostics(process))
|
|
}
|
|
|
|
func secureWaitLog(t *testing.T, process *secureProcess, marker string) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(30 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
logBytes, _ := os.ReadFile(process.logPath)
|
|
if bytes.Contains(logBytes, []byte(marker)) {
|
|
return
|
|
}
|
|
select {
|
|
case processErr := <-process.done:
|
|
process.stopped.Store(true)
|
|
_ = process.logFile.Close()
|
|
t.Fatalf("%s exited while waiting for %q: %v\n%s", process.name, marker, processErr, logBytes)
|
|
default:
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
t.Fatalf("timeout waiting for %q\n%s", marker, secureProcessDiagnostics(process))
|
|
}
|
|
|
|
func secureCredentialRequest(t *testing.T, client *http.Client, method, endpoint, token string, body []byte, headers map[string]string) map[string]any {
|
|
t.Helper()
|
|
req, err := http.NewRequest(method, endpoint, bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("create credential request: %v", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
for name, value := range headers {
|
|
req.Header.Set(name, value)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("credential request: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
t.Fatalf("read credential response: %v", err)
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
t.Fatalf("credential request failed: method=%s status=%d body=%s", method, resp.StatusCode, responseBody)
|
|
}
|
|
var decoded map[string]any
|
|
if err := json.Unmarshal(responseBody, &decoded); err != nil {
|
|
t.Fatalf("decode credential response: %v body=%s", err, responseBody)
|
|
}
|
|
return decoded
|
|
}
|
|
|
|
func secureJSONText(t *testing.T, value map[string]any, field string) string {
|
|
t.Helper()
|
|
textValue, ok := value[field].(string)
|
|
if !ok || strings.TrimSpace(textValue) == "" {
|
|
t.Fatalf("credential response field %s is missing", field)
|
|
}
|
|
return textValue
|
|
}
|
|
|
|
func secureJSONInt(t *testing.T, value map[string]any, field string) int64 {
|
|
t.Helper()
|
|
number, ok := value[field].(float64)
|
|
if !ok || number < 0 {
|
|
t.Fatalf("credential response field %s is invalid", field)
|
|
}
|
|
return int64(number)
|
|
}
|
|
|
|
func secureWriteBase64(t *testing.T, dir, name string, value []byte, mode os.FileMode) string {
|
|
t.Helper()
|
|
path := filepath.Join(dir, name)
|
|
secureWriteFile(t, path, []byte(base64.StdEncoding.EncodeToString(value)+"\n"), mode)
|
|
return path
|
|
}
|
|
|
|
func secureWriteFile(t *testing.T, path string, value []byte, mode os.FileMode) {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, value, mode); err != nil {
|
|
t.Fatalf("write %s: %v", filepath.Base(path), err)
|
|
}
|
|
}
|
|
|
|
func secureProcessDiagnostics(processes ...*secureProcess) string {
|
|
var diagnostics strings.Builder
|
|
for _, process := range processes {
|
|
if process == nil {
|
|
continue
|
|
}
|
|
logBytes, _ := os.ReadFile(process.logPath)
|
|
fmt.Fprintf(&diagnostics, "=== %s ===\n%s\n", process.name, logBytes)
|
|
}
|
|
return diagnostics.String()
|
|
}
|