Files
wehub-resource-sync 75f3dd141c
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CI and Release / lint-frontend (push) Has been cancelled
CI and Release / dockerfile-scan (push) Has been cancelled
CI and Release / test-frontend (push) Has been cancelled
CI and Release / lint-verification-agent (push) Has been cancelled
CI and Release / test-verification-agent (push) Has been cancelled
CI and Release / e2e-verification-agent (push) Has been cancelled
CI and Release / test-backend (push) Has been cancelled
CI and Release / build-dev-image (push) Has been cancelled
CI and Release / push-dev-image (push) Has been cancelled
CI and Release / build-image (push) Has been cancelled
CI and Release / build-verification-image (push) Has been cancelled
CI and Release / determine-version (push) Has been cancelled
CI and Release / push-image (push) Has been cancelled
CI and Release / push-verification-image (12) (push) Has been cancelled
CI and Release / lint-backend (push) Has been cancelled
CI and Release / push-verification-image (17) (push) Has been cancelled
CI and Release / push-verification-image (18) (push) Has been cancelled
CI and Release / release (push) Has been cancelled
CI and Release / publish-helm-chart (push) Has been cancelled
CI and Release / push-verification-image (13) (push) Has been cancelled
CI and Release / push-verification-image (14) (push) Has been cancelled
CI and Release / push-verification-image (15) (push) Has been cancelled
CI and Release / push-verification-image (16) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:11:07 +08:00

430 lines
14 KiB
Go

package api
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"databasus-verification-agent/internal/testutil"
)
func instantAfter(t *testing.T) {
t.Helper()
original := timeAfterFn
timeAfterFn = func(time.Duration) <-chan time.Time {
ch := make(chan time.Time, 1)
ch <- time.Now()
return ch
}
t.Cleanup(func() { timeAfterFn = original })
}
func Test_ClaimVerification_WhenServer200_SendsNestedMaxRamMbAndDeserializes(t *testing.T) {
var capturedPath, capturedAuth string
var capturedBody map[string]any
verificationID := uuid.New()
backupID := uuid.New()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedPath = r.URL.Path
capturedAuth = r.Header.Get("Authorization")
require.NoError(t, json.NewDecoder(r.Body).Decode(&capturedBody))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"verificationId": verificationID.String(),
"backupId": backupID.String(),
"backupSizeMb": 120.5,
"maxContainerDiskMb": 800.25,
"database": map[string]any{
"type": "POSTGRES_LOGICAL",
"postgresqlLogical": map[string]any{"version": "16"},
},
})
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "tok123", "agent-9", testutil.DiscardLogger())
assignment, err := client.ClaimVerification(t.Context(), AgentCapacity{
MaxCPU: 8, MaxRAMMb: 4096, MaxDiskGb: 100, MaxConcurrentJobs: 4,
})
require.NoError(t, err)
require.NotNil(t, assignment)
assert.Equal(t, "/api/v1/agent/verifications/agent-9/claim", capturedPath)
assert.Equal(t, "Bearer tok123", capturedAuth)
capacity, ok := capturedBody["capacity"].(map[string]any)
require.True(t, ok, "claim body must nest capacity under \"capacity\"")
assert.Equal(t, float64(4096), capacity["maxRamMb"])
_, hasRamGb := capacity["maxRamGb"]
assert.False(t, hasRamGb, "claim envelope must use maxRamMb, never maxRamGb")
assert.Equal(t, verificationID, assignment.VerificationID)
assert.Equal(t, backupID, assignment.BackupID)
assert.InDelta(t, 120.5, assignment.BackupSizeMb, 0.001)
assert.InDelta(t, 800.25, assignment.MaxContainerDiskMb, 0.001)
assert.Equal(t, "POSTGRES_LOGICAL", assignment.Database.Type)
require.NotNil(t, assignment.Database.PostgresqlLogical)
assert.Equal(t, "16", assignment.Database.PostgresqlLogical.Version)
}
func Test_ClaimVerification_WhenServer204_ReturnsNilNoError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "t", "a", testutil.DiscardLogger())
assignment, err := client.ClaimVerification(t.Context(), AgentCapacity{})
require.NoError(t, err)
assert.Nil(t, assignment)
}
func Test_ClaimVerification_WhenServer500_ReturnsRetryableResponseError(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "t", "a", testutil.DiscardLogger())
_, err := client.ClaimVerification(t.Context(), AgentCapacity{})
require.Error(t, err)
assert.Equal(t, int32(1), calls.Load(), "claim must not retry internally — the loop owns retry")
var respErr *ResponseError
require.True(t, errors.As(err, &respErr))
assert.True(t, respErr.Retryable())
}
func Test_DownloadBackup_WhenServer200_ReturnsBody(t *testing.T) {
payload := []byte("PGDMP fake custom-format archive")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t,
"/api/v1/agent/verifications/agent-x/"+
"22222222-2222-2222-2222-222222222222/backup-stream",
r.URL.Path)
_, _ = w.Write(payload)
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "", "agent-x", testutil.DiscardLogger())
body, err := client.DownloadBackup(
t.Context(), uuid.MustParse("22222222-2222-2222-2222-222222222222"))
require.NoError(t, err)
t.Cleanup(func() { _ = body.Close() })
read, err := io.ReadAll(body)
require.NoError(t, err)
assert.Equal(t, payload, read)
}
func Test_DownloadBackup_WhenServer410_ReturnsGoneResponseError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusGone)
_, _ = w.Write([]byte(`{"reason":"gone"}`))
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "", "a", testutil.DiscardLogger())
_, err := client.DownloadBackup(t.Context(), uuid.New())
require.Error(t, err)
var respErr *ResponseError
require.True(t, errors.As(err, &respErr))
assert.True(t, respErr.IsGone())
assert.False(t, respErr.Retryable())
}
func Test_Report_WhenServer204_Succeeds(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/agent/verifications/a/33333333-3333-3333-3333-333333333333/report",
r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "", "a", testutil.DiscardLogger())
err := client.Report(t.Context(),
uuid.MustParse("33333333-3333-3333-3333-333333333333"),
ReportRequest{Status: VerificationStatusCompleted})
require.NoError(t, err)
}
func Test_Report_WhenServer410_ReturnsErrReportGoneWithoutRetry(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusGone)
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "", "a", testutil.DiscardLogger())
err := client.Report(t.Context(), uuid.New(), ReportRequest{Status: VerificationStatusFailed})
require.ErrorIs(t, err, ErrReportGone)
assert.Equal(t, int32(1), calls.Load(), "410 must not be retried")
}
func Test_Report_When5xxThenRecovers_RetriesThenSucceeds(t *testing.T) {
instantAfter(t)
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if calls.Add(1) < 3 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "", "a", testutil.DiscardLogger())
err := client.Report(t.Context(), uuid.New(), ReportRequest{Status: VerificationStatusCompleted})
require.NoError(t, err)
assert.Equal(t, int32(3), calls.Load())
}
func Test_Report_When5xxAndBudgetExhausted_ReturnsBudgetError(t *testing.T) {
originalBudget := reportRetryBudget
reportRetryBudget = 25 * time.Millisecond
t.Cleanup(func() { reportRetryBudget = originalBudget })
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "", "a", testutil.DiscardLogger())
err := client.Report(t.Context(), uuid.New(), ReportRequest{Status: VerificationStatusFailed})
require.ErrorIs(t, err, ErrReportBudgetExhausted)
}
func Test_Constants_SatisfyInvariants(t *testing.T) {
// reportRetryBudget must stay a short, bounded window: once it is exhausted
// the backend reclaims the run on the agent's next heartbeat, so retrying
// for minutes would be pointless. 5m is a generous sanity ceiling.
assert.Less(t, reportRetryBudget, 5*time.Minute)
assert.Equal(t, maxBackoff, reportRetryDelay(99), "report backoff must cap at maxBackoff")
assert.Equal(t, 1*time.Second, reportRetryDelay(1))
assert.Equal(t, 2*time.Second, reportRetryDelay(2))
}
func Test_Heartbeat_WhenCalled_SendsFlatEnvelopeWithBearerAndAgentPath(t *testing.T) {
var capturedPath, capturedMethod, capturedAuth string
var capturedBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedPath = r.URL.Path
capturedMethod = r.Method
capturedAuth = r.Header.Get("Authorization")
require.NoError(t, json.NewDecoder(r.Body).Decode(&capturedBody))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"lastSeenAt": time.Now().UTC().Format(time.RFC3339),
"abortVerificationIds": []string{},
})
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "tok123", "11111111-1111-1111-1111-111111111111", testutil.DiscardLogger())
response, err := client.Heartbeat(t.Context(), HeartbeatRequest{
MaxCPU: 8,
MaxRAMGb: 4,
MaxDiskGb: 100,
MaxConcurrentJobs: 4,
CurrentVerificationIDs: []uuid.UUID{},
})
require.NoError(t, err)
require.NotNil(t, response)
assert.Equal(t, http.MethodPost, capturedMethod)
assert.Equal(t,
"/api/v1/agent/verification/11111111-1111-1111-1111-111111111111/heartbeat",
capturedPath)
assert.Equal(t, "Bearer tok123", capturedAuth)
assert.Equal(t, float64(8), capturedBody["maxCpu"])
assert.Equal(t, float64(4), capturedBody["maxRamGb"])
assert.Equal(t, float64(100), capturedBody["maxDiskGb"])
assert.Equal(t, float64(4), capturedBody["maxConcurrentJobs"])
_, hasRamMb := capturedBody["maxRamMb"]
assert.False(t, hasRamMb, "heartbeat envelope must use maxRamGb, never maxRamMb")
require.Contains(t, capturedBody, "currentVerificationIds")
assert.Equal(t, []any{}, capturedBody["currentVerificationIds"],
"currentVerificationIds must serialize as [] not null")
}
func Test_Heartbeat_WhenServerReturns4xx_ReturnsErrorWithoutRetry(t *testing.T) {
var calls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls++
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"invalid agent credentials"}`))
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "bad", "agent-1", testutil.DiscardLogger())
_, err := client.Heartbeat(t.Context(), HeartbeatRequest{CurrentVerificationIDs: []uuid.UUID{}})
require.Error(t, err)
assert.Equal(t, 1, calls, "4xx must not be retried")
}
func Test_FetchServerVersion_WhenServerResponds_ReturnsVersion(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/system/version", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"version": "v3.27.0"})
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "", "", testutil.DiscardLogger())
version, err := client.FetchServerVersion(t.Context())
require.NoError(t, err)
assert.Equal(t, "v3.27.0", version)
}
func Test_DownloadVerificationAgentBinary_WhenServerServesFile_WritesItToDisk(t *testing.T) {
payload := []byte("\x7fELF fake verification agent")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/system/verification-agent", r.URL.Path)
assert.Equal(t, "arm64", r.URL.Query().Get("arch"))
w.Header().Set(agentVersionHeader, "v3.45.0")
_, _ = w.Write(payload)
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "", "", testutil.DiscardLogger())
destPath := filepath.Join(t.TempDir(), "agent")
downloadedVersion, err := client.DownloadVerificationAgentBinary(
t.Context(), "arm64", destPath, "v3.43.0",
)
require.NoError(t, err)
assert.Equal(t, "v3.45.0", downloadedVersion)
written, err := os.ReadFile(destPath)
require.NoError(t, err)
assert.Equal(t, payload, written)
}
func Test_DownloadVerificationAgentBinary_WhenServerOmitsVersionHeader_ReturnsFallbackVersion(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("binary"))
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "", "", testutil.DiscardLogger())
downloadedVersion, err := client.DownloadVerificationAgentBinary(
t.Context(), "amd64", filepath.Join(t.TempDir(), "agent"), "v3.43.0",
)
require.NoError(t, err)
assert.Equal(t, "v3.43.0", downloadedVersion)
}
func Test_DownloadVerificationAgentBinary_WhenCalled_SendsNoCacheRequestHeaders(t *testing.T) {
var requestHeaders http.Header
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestHeaders = r.Header.Clone()
_, _ = w.Write([]byte("binary"))
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "", "", testutil.DiscardLogger())
_, err := client.DownloadVerificationAgentBinary(
t.Context(), "amd64", filepath.Join(t.TempDir(), "agent"), "v1.0.0",
)
require.NoError(t, err)
assert.Equal(t, "no-cache", requestHeaders.Get("Cache-Control"))
assert.Equal(t, "no-cache", requestHeaders.Get("Pragma"))
}
func Test_FetchServerVersion_WhenCalled_SendsNoCacheRequestHeaders(t *testing.T) {
var requestHeaders http.Header
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestHeaders = r.Header.Clone()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"v3.45.0"}`))
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "", "", testutil.DiscardLogger())
_, err := client.FetchServerVersion(t.Context())
require.NoError(t, err)
assert.Equal(t, "no-cache", requestHeaders.Get("Cache-Control"))
assert.Equal(t, "no-cache", requestHeaders.Get("Pragma"))
}
func Test_DownloadVerificationAgentBinary_WhenServer404_ReturnsError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
t.Cleanup(server.Close)
client := NewClient(server.URL, "", "", testutil.DiscardLogger())
_, err := client.DownloadVerificationAgentBinary(
t.Context(), "amd64", filepath.Join(t.TempDir(), "a"), "v1.0.0",
)
require.Error(t, err)
}