304 lines
15 KiB
Go
304 lines
15 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"data-terminal-cloud/internal/audit"
|
|
"data-terminal-cloud/internal/identity"
|
|
"data-terminal-cloud/internal/saas"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
type httpFirmwareStore struct{}
|
|
|
|
func (*httpFirmwareStore) PresignUpload(_ context.Context, ref string, _ time.Duration) (string, error) {
|
|
return "https://objects.invalid/upload/" + ref, nil
|
|
}
|
|
func (*httpFirmwareStore) PresignDownload(_ context.Context, ref string, _ time.Duration, filename string) (string, error) {
|
|
return "https://objects.invalid/download/" + ref + "?filename=" + filename, nil
|
|
}
|
|
func (*httpFirmwareStore) VerifyDigest(context.Context, string, string, int64) error { return nil }
|
|
func (*httpFirmwareStore) Remove(context.Context, string) error { return nil }
|
|
|
|
func TestFirmwareWriteHandlers(t *testing.T) {
|
|
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
|
if databaseURL == "" {
|
|
t.Skip("TEST_DATABASE_URL is not set")
|
|
}
|
|
ctx := context.Background()
|
|
pool, err := pgxpool.New(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer pool.Close()
|
|
service := saas.NewService(pool, audit.NewRepository(), nil, nil)
|
|
service.ConfigureFirmwareStore(&httpFirmwareStore{}, 5*time.Minute)
|
|
principal := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
|
|
householdID := "10000000-0000-0000-0000-000000000211"
|
|
deviceID := "10000000-0000-0000-0000-000000000511"
|
|
keyID := "firmware-http-" + uuid.NewString()
|
|
version := "http-" + uuid.NewString()
|
|
var releaseID, retiredReleaseID, rolloutID string
|
|
defer func() {
|
|
if rolloutID != "" {
|
|
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE resource_id=$1::uuid`, rolloutID)
|
|
_, _ = pool.Exec(ctx, `DELETE FROM firmware_rollout WHERE public_id=$1::uuid`, rolloutID)
|
|
}
|
|
_, _ = pool.Exec(ctx, `DELETE FROM device_command WHERE idempotency_key LIKE 'firmware-rollout:%' AND payload->>'release_id'=$1`, releaseID)
|
|
if releaseID != "" {
|
|
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE resource_id=$1::uuid`, releaseID)
|
|
_, _ = pool.Exec(ctx, `DELETE FROM firmware_release WHERE public_id=$1::uuid`, releaseID)
|
|
}
|
|
if retiredReleaseID != "" {
|
|
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE resource_id=$1::uuid`, retiredReleaseID)
|
|
_, _ = pool.Exec(ctx, `DELETE FROM firmware_release WHERE public_id=$1::uuid`, retiredReleaseID)
|
|
}
|
|
_, _ = pool.Exec(ctx, `DELETE FROM firmware_signing_key WHERE key_id=$1`, keyID)
|
|
}()
|
|
|
|
request := authenticatedRequest(http.MethodPost, "/api/v1/households/"+householdID+"/firmware-signing-keys", `{}`, principal)
|
|
request.SetPathValue("id", householdID)
|
|
recorder := httptest.NewRecorder()
|
|
RegisterFirmwareSigningKeyHandler(service).ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusBadRequest {
|
|
t.Fatalf("invalid key status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
keyBody, _ := json.Marshal(map[string]any{"key_id": keyID, "public_key_base64": base64.StdEncoding.EncodeToString(publicKey)})
|
|
request = authenticatedRequest(http.MethodPost, "/api/v1/households/"+householdID+"/firmware-signing-keys", string(keyBody), principal)
|
|
request.SetPathValue("id", householdID)
|
|
recorder = httptest.NewRecorder()
|
|
RegisterFirmwareSigningKeyHandler(service).ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusCreated {
|
|
t.Fatalf("register key status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
request = authenticatedRequest(http.MethodGet, "/api/v1/households/"+householdID+"/firmware-signing-keys", "", principal)
|
|
request.SetPathValue("id", householdID)
|
|
recorder = httptest.NewRecorder()
|
|
ListFirmwareSigningKeysHandler(service).ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("list signing keys status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var keysResponse struct {
|
|
Data struct {
|
|
Items []saas.FirmwareSigningKey `json:"items"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &keysResponse); err != nil || len(keysResponse.Data.Items) != 1 || keysResponse.Data.Items[0].KeyID != keyID {
|
|
t.Fatalf("signing keys response=%+v err=%v", keysResponse, err)
|
|
}
|
|
|
|
request = authenticatedRequest(http.MethodPost, "/api/v1/households/"+householdID+"/firmware-releases", `{"model":"simulated-terminal","version":"bad","expected_size":1,"expected_digest":"bad","signature":"bad","signing_key_id":"bad"}`, principal)
|
|
request.SetPathValue("id", householdID)
|
|
recorder = httptest.NewRecorder()
|
|
PrepareFirmwareReleaseHandler(service).ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusBadRequest {
|
|
t.Fatalf("invalid release status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
image := []byte("synthetic firmware through HTTP")
|
|
digestBytes := sha256.Sum256(image)
|
|
digest := hex.EncodeToString(digestBytes[:])
|
|
manifest := fmt.Sprintf("firmware-manifest-v1\nmodel=%s\nhardware_revision=%s\nversion=%s\nsha256=%s\nsize=%d\n", "simulated-terminal", "sim-rev-a", version, digest, len(image))
|
|
signature := base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, []byte(manifest)))
|
|
releaseBody, _ := json.Marshal(map[string]any{
|
|
"model": "simulated-terminal", "hardware_revision": "sim-rev-a", "version": version,
|
|
"expected_size": len(image), "expected_digest": digest, "signature": signature, "signing_key_id": keyID,
|
|
})
|
|
request = authenticatedRequest(http.MethodPost, "/api/v1/households/"+householdID+"/firmware-releases", string(releaseBody), principal)
|
|
request.SetPathValue("id", householdID)
|
|
recorder = httptest.NewRecorder()
|
|
PrepareFirmwareReleaseHandler(service).ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusCreated || recorder.Header().Get("Cache-Control") != "no-store" {
|
|
t.Fatalf("prepare release status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String())
|
|
}
|
|
var releaseResponse struct {
|
|
Data saas.FirmwareRelease `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &releaseResponse); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
releaseID = releaseResponse.Data.ID
|
|
revokeBody, _ := json.Marshal(map[string]string{"key_id": keyID})
|
|
request = authenticatedRequest(http.MethodPost, "/api/v1/households/"+householdID+"/firmware-signing-keys/revoke", string(revokeBody), principal)
|
|
request.SetPathValue("id", householdID)
|
|
recorder = httptest.NewRecorder()
|
|
RevokeFirmwareSigningKeyHandler(service).ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusConflict {
|
|
t.Fatalf("revoke uploading key status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
request = authenticatedRequest(http.MethodPost, "/api/v1/firmware-releases/"+releaseID+"/upload-session", "", principal)
|
|
request.SetPathValue("id", releaseID)
|
|
recorder = httptest.NewRecorder()
|
|
RenewFirmwareUploadHandler(service).ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK || recorder.Header().Get("Cache-Control") != "no-store" {
|
|
t.Fatalf("renew upload status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String())
|
|
}
|
|
|
|
request = authenticatedRequest(http.MethodPost, "/api/v1/firmware-releases/"+releaseID+"/finalize", "", principal)
|
|
request.SetPathValue("id", releaseID)
|
|
recorder = httptest.NewRecorder()
|
|
FinalizeFirmwareReleaseHandler(service).ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("finalize release status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
request = authenticatedRequest(http.MethodPost, "/api/v1/firmware-releases/"+releaseID+"/pause", "", principal)
|
|
request.SetPathValue("id", releaseID)
|
|
recorder = httptest.NewRecorder()
|
|
PauseFirmwareReleaseHandler(service).ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"status":"PAUSED"`) {
|
|
t.Fatalf("pause release status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
rolloutBody, _ := json.Marshal(map[string]any{"release_id": releaseID, "name": "HTTP固件下发", "target_percentage": 100, "device_ids": []string{deviceID}})
|
|
request = authenticatedRequest(http.MethodPost, "/api/v1/households/"+householdID+"/firmware-rollouts", string(rolloutBody), principal)
|
|
request.SetPathValue("id", householdID)
|
|
recorder = httptest.NewRecorder()
|
|
StartFirmwareRolloutHandler(service).ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusConflict {
|
|
t.Fatalf("start paused release rollout status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
request = authenticatedRequest(http.MethodPost, "/api/v1/firmware-releases/"+releaseID+"/resume", "", principal)
|
|
request.SetPathValue("id", releaseID)
|
|
recorder = httptest.NewRecorder()
|
|
ResumeFirmwareReleaseHandler(service).ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"status":"READY"`) {
|
|
t.Fatalf("resume release status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
request = authenticatedRequest(http.MethodPost, "/api/v1/households/"+householdID+"/firmware-rollouts", string(rolloutBody), principal)
|
|
request.SetPathValue("id", householdID)
|
|
recorder = httptest.NewRecorder()
|
|
StartFirmwareRolloutHandler(service).ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusAccepted {
|
|
t.Fatalf("start rollout status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var rolloutResponse struct {
|
|
Data saas.FirmwareRollout `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &rolloutResponse); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rolloutID = rolloutResponse.Data.ID
|
|
request = authenticatedRequest(http.MethodGet, "/api/v1/firmware-releases/"+releaseID+"/impact", "", principal)
|
|
request.SetPathValue("id", releaseID)
|
|
recorder = httptest.NewRecorder()
|
|
GetFirmwareReleaseImpactHandler(service).ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("get release impact status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var impactResponse struct {
|
|
Data saas.FirmwareReleaseImpact `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &impactResponse); err != nil || impactResponse.Data.ReleaseStatus != "READY" || impactResponse.Data.Rollouts.Running != 1 || impactResponse.Data.Commands.Queued != 1 {
|
|
t.Fatalf("release impact response=%+v err=%v", impactResponse, err)
|
|
}
|
|
|
|
request = authenticatedRequest(http.MethodPost, "/api/v1/firmware-releases/"+releaseID+"/retire", "", principal)
|
|
request.SetPathValue("id", releaseID)
|
|
recorder = httptest.NewRecorder()
|
|
RetireFirmwareReleaseHandler(service).ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusConflict || !strings.Contains(recorder.Body.String(), "FIRMWARE_RELEASE_IN_USE") {
|
|
t.Fatalf("retire running release status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
request = authenticatedRequest(http.MethodPost, "/api/v1/firmware-releases/"+releaseID+"/pause", "", principal)
|
|
request.SetPathValue("id", releaseID)
|
|
recorder = httptest.NewRecorder()
|
|
PauseFirmwareReleaseHandler(service).ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"status":"PAUSED"`) {
|
|
t.Fatalf("pause running release status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
request = authenticatedRequest(http.MethodGet, "/api/v1/firmware-releases/"+releaseID+"/impact", "", principal)
|
|
request.SetPathValue("id", releaseID)
|
|
recorder = httptest.NewRecorder()
|
|
GetFirmwareReleaseImpactHandler(service).ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("get paused release impact status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &impactResponse); err != nil || impactResponse.Data.ReleaseStatus != "PAUSED" || impactResponse.Data.Rollouts.Paused != 1 || impactResponse.Data.Commands.CancelledBeforeDelivery != 1 {
|
|
t.Fatalf("paused release impact response=%+v err=%v", impactResponse, err)
|
|
}
|
|
rollout, err := service.GetFirmwareRollout(ctx, principal, rolloutID)
|
|
if err != nil || rollout.Status != "PAUSED" {
|
|
t.Fatalf("release pause did not pause rollout=%+v err=%v", rollout, err)
|
|
}
|
|
request = authenticatedRequest(http.MethodPost, "/api/v1/firmware-releases/"+releaseID+"/resume", "", principal)
|
|
request.SetPathValue("id", releaseID)
|
|
recorder = httptest.NewRecorder()
|
|
ResumeFirmwareReleaseHandler(service).ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("resume after rollout pause status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
request = authenticatedRequest(http.MethodPost, "/api/v1/firmware-releases/"+releaseID+"/retire", "", principal)
|
|
request.SetPathValue("id", releaseID)
|
|
recorder = httptest.NewRecorder()
|
|
RetireFirmwareReleaseHandler(service).ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"status":"RETIRED"`) {
|
|
t.Fatalf("retire verified release status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
retireBody, _ := json.Marshal(map[string]any{
|
|
"model": "simulated-terminal", "hardware_revision": "sim-rev-a", "version": version + "-retire",
|
|
"expected_size": len(image), "expected_digest": digest, "signature": signature, "signing_key_id": keyID,
|
|
})
|
|
request = authenticatedRequest(http.MethodPost, "/api/v1/households/"+householdID+"/firmware-releases", string(retireBody), principal)
|
|
request.SetPathValue("id", householdID)
|
|
recorder = httptest.NewRecorder()
|
|
PrepareFirmwareReleaseHandler(service).ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusCreated {
|
|
t.Fatalf("prepare retiring release status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &releaseResponse); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
retiredReleaseID = releaseResponse.Data.ID
|
|
request = authenticatedRequest(http.MethodPost, "/api/v1/firmware-releases/"+retiredReleaseID+"/retire", "", principal)
|
|
request.SetPathValue("id", retiredReleaseID)
|
|
recorder = httptest.NewRecorder()
|
|
RetireFirmwareReleaseHandler(service).ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("retire release status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &releaseResponse); err != nil || releaseResponse.Data.Status != "RETIRED" {
|
|
t.Fatalf("retired response=%+v err=%v", releaseResponse, err)
|
|
}
|
|
request = authenticatedRequest(http.MethodPost, "/api/v1/households/"+householdID+"/firmware-signing-keys/revoke", string(revokeBody), principal)
|
|
request.SetPathValue("id", householdID)
|
|
recorder = httptest.NewRecorder()
|
|
RevokeFirmwareSigningKeyHandler(service).ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("revoke signing key status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var keyResponse struct {
|
|
Data saas.FirmwareSigningKey `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &keyResponse); err != nil || keyResponse.Data.Status != "REVOKED" {
|
|
t.Fatalf("revoked key response=%+v err=%v", keyResponse, err)
|
|
}
|
|
request = authenticatedRequest(http.MethodPost, "/api/v1/households/"+householdID+"/firmware-rollouts", string(rolloutBody), principal)
|
|
request.SetPathValue("id", householdID)
|
|
recorder = httptest.NewRecorder()
|
|
StartFirmwareRolloutHandler(service).ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusConflict {
|
|
t.Fatalf("rollout with revoked key status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|