Files
2026-09-02 14:47:38 +08:00

282 lines
13 KiB
Go

package awsserversdk
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
func testClient(t *testing.T, handler http.HandlerFunc) (*Client, *httptest.Server) {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
client, err := NewClient(server.URL, "awsapp_test_secret", WithMaxRetries(2))
if err != nil {
t.Fatal(err)
}
return client, server
}
func TestCreateOptionsDecodeDetailedImageAndInstanceTypeMetadata(t *testing.T) {
var options CreateOptions
raw := `{
"region":"ap-northeast-1",
"images":[{"catalog_id":"image-row","region":"ap-northeast-1","category_id":"linux","image_id":"ami-1","source_scope":"quick-start","source_name":"al2023","display_name":"Amazon Linux","availability_status":"available","description":"Linux image","architecture":"x86_64","platform_details":"Linux/UNIX","os_family":"linux","recommended_user":"ec2-user","root_volume_size_gib":8,"root_volume_type":"gp3","virtualization_type":"hvm","ena_support":true,"public":true}],
"instance_types":[{"id":"type-row","region":"ap-northeast-1","category_id":"general","instance_type":"g4dn.xlarge","vcpus":4,"memory_mib":16384,"architectures":["x86_64"],"network_performance":"Up to 25 Gigabit","network_baseline_gbps":5,"network_peak_gbps":25,"gpu_count":1,"gpu_memory_mib":16384,"gpus":[{"manufacturer":"NVIDIA","name":"T4","count":1,"memory_mib":16384}],"current_generation":true}]
}`
if err := json.Unmarshal([]byte(raw), &options); err != nil {
t.Fatal(err)
}
if len(options.Images) != 1 || options.Images[0].Architecture != "x86_64" || options.Images[0].RecommendedUser != "ec2-user" || options.Images[0].RootVolumeSizeGiB != 8 || !options.Images[0].ENASupport {
t.Fatalf("detailed image metadata was not decoded: %#v", options.Images)
}
if len(options.InstanceTypes) != 1 || options.InstanceTypes[0].NetworkPeakGbps != 25 || options.InstanceTypes[0].GPUMemoryMiB != 16384 || len(options.InstanceTypes[0].GPUs) != 1 || options.InstanceTypes[0].GPUs[0].Name != "T4" {
t.Fatalf("detailed instance type metadata was not decoded: %#v", options.InstanceTypes)
}
}
func TestClientSendsAuthAndStableIdempotencyOnRetry(t *testing.T) {
var mutex sync.Mutex
attempts := 0
keys := []string{}
client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
mutex.Lock()
defer mutex.Unlock()
attempts++
keys = append(keys, r.Header.Get("Idempotency-Key"))
if r.Header.Get("Authorization") != "Bearer awsapp_test_secret" {
t.Errorf("missing auth header")
}
w.Header().Set("Content-Type", "application/json")
if attempts == 1 {
w.WriteHeader(503)
_, _ = w.Write([]byte(`{"code":503,"message":"retry"}`))
return
}
_, _ = w.Write([]byte(`{"code":202,"message":"Accepted","data":{"id":"local-1","task_id":"task-1","status":"queued"}}`))
})
operation, err := client.Instances.Create(context.Background(), "us-east-1", CreateInstanceRequest{ImageID: "ami-1", InstanceType: "t3.nano", PlacementID: "default"})
if err != nil {
t.Fatal(err)
}
if operation.TaskID != "task-1" || attempts != 2 {
t.Fatalf("unexpected operation %#v attempts=%d", operation, attempts)
}
if keys[0] == "" || keys[0] != keys[1] {
t.Fatalf("idempotency key changed: %#v", keys)
}
}
func TestProvisionAndRotateSSHKeyRequests(t *testing.T) {
tests := []struct {
path string
call func(*Client) (*Operation, error)
want map[string]string
}{
{path: "/api/sdk/v1/regions/us-east-1/instances/instance-1/ssh-keys/provision", call: func(client *Client) (*Operation, error) {
return client.Access.ProvisionSSHKey(context.Background(), "us-east-1", "instance-1", ProvisionSSHKeyRequest{Name: "uploaded", PublicKey: "ssh-ed25519 AAAA", Username: "ec2-user"}, WithIdempotencyKey("add-key"))
}, want: map[string]string{"name": "uploaded", "public_key": "ssh-ed25519 AAAA", "username": "ec2-user"}},
{path: "/api/sdk/v1/regions/us-east-1/instances/instance-1/ssh-keys/rotate", call: func(client *Client) (*Operation, error) {
return client.Access.RotateSSHKey(context.Background(), "us-east-1", "instance-1", RotateSSHKeyRequest{Username: "ec2-user", ReplaceKeyID: "old-key"}, WithIdempotencyKey("rotate-key"))
}, want: map[string]string{"username": "ec2-user", "replace_key_id": "old-key"}},
}
for _, test := range tests {
t.Run(test.path, func(t *testing.T) {
client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != test.path {
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
}
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
for key, value := range test.want {
if body[key] != value {
t.Fatalf("body[%s] = %q, want %q", key, body[key], value)
}
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":202,"message":"Accepted","data":{"command_id":"command-1","status":"queued"}}`))
})
operation, err := test.call(client)
if err != nil {
t.Fatal(err)
}
if operation.TaskID != "command-1" {
t.Fatalf("task ID = %q", operation.TaskID)
}
})
}
}
func TestRevealSSHKeyRequestAndSecret(t *testing.T) {
client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/sdk/v1/regions/us-east-1/instances/instance-1/ssh-keys/key-1/reveal" {
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":200,"message":"Success","data":{"key_id":"key-1","key_name":"generated","username":"ec2-user","private_key":"secret-pem"}}`))
})
key, err := client.Access.RevealSSHKey(context.Background(), "us-east-1", "instance-1", "key-1")
if err != nil {
t.Fatal(err)
}
if key.KeyID != "key-1" || key.PrivateKey.Reveal() != "secret-pem" || key.PrivateKey.String() != "[REDACTED]" {
t.Fatalf("unexpected private key response: %#v", key)
}
}
func TestAPIErrorAndHelpers(t *testing.T) {
client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(409)
_, _ = w.Write([]byte(`{"code":409,"message":"budget","data":{"shortfall":1.25}}`))
})
_, err := client.Instances.Create(context.Background(), "us-east-1", CreateInstanceRequest{})
if err == nil || !IsConflict(err) || !IsBudgetExceeded(err) {
t.Fatalf("unexpected error %v", err)
}
}
func TestTaskWait(t *testing.T) {
calls := 0
client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
calls++
status := "running"
if calls > 1 {
status = "succeeded"
}
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "message": "Success", "data": map[string]any{"id": "task-1", "status": status, "progress": 100}})
})
task, err := client.Tasks.Wait(context.Background(), "task-1", WithPollInterval(time.Millisecond))
if err != nil || task.Status != "succeeded" || calls != 2 {
t.Fatalf("task=%#v err=%v calls=%d", task, err, calls)
}
}
func TestSecretIsRedacted(t *testing.T) {
var secret Secret
if err := json.Unmarshal([]byte(`"very-secret"`), &secret); err != nil {
t.Fatal(err)
}
if secret.Reveal() != "very-secret" || secret.String() != "[REDACTED]" {
t.Fatal("secret behavior invalid")
}
raw, _ := json.Marshal(secret)
if string(raw) != `"[REDACTED]"` {
t.Fatalf("secret marshaled as %s", raw)
}
}
func TestListQueryParametersAreNotEscapedIntoPath(t *testing.T) {
client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/sdk/v1/regions/us-east-1/images" || r.URL.Query().Get("q") != "ubuntu 24" || r.URL.Query().Get("limit") != "10" {
t.Errorf("unexpected URL %s", r.URL.String())
}
_, _ = w.Write([]byte(`{"code":200,"message":"Success","data":{"items":[],"total":0,"limit":10,"offset":0}}`))
})
if _, err := client.Catalog.ListImages(context.Background(), "us-east-1", ListOptions{Query: "ubuntu 24", Limit: 10}); err != nil {
t.Fatal(err)
}
}
func TestRuntimePricingAndInstanceEIPOperations(t *testing.T) {
paths := []string{}
client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.Method+" "+r.URL.Path)
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/sdk/v1/regions/us-east-1/instances/local-1/price-estimate" {
_, _ = w.Write([]byte(`{"code":200,"message":"Success","data":{"currency":"USD","monthly_price":12.5,"configuration_fingerprint":"fingerprint"}}`))
return
}
_, _ = w.Write([]byte(`{"code":202,"message":"Accepted","data":{"task_id":"task-1","status":"queued"}}`))
})
estimate, err := client.Pricing.EstimateExistingInstance(context.Background(), "us-east-1", "local-1")
if err != nil || estimate.ConfigurationFingerprint != "fingerprint" || estimate.MonthlyPrice != 12.5 {
t.Fatalf("estimate=%#v err=%v", estimate, err)
}
if _, err := client.EIPs.Disassociate(context.Background(), "us-east-1", "local-1", WithIdempotencyKey("disassociate")); err != nil {
t.Fatal(err)
}
if _, err := client.EIPs.Release(context.Background(), "us-east-1", "local-1", WithIdempotencyKey("release")); err != nil {
t.Fatal(err)
}
want := []string{"POST /api/sdk/v1/regions/us-east-1/instances/local-1/price-estimate", "POST /api/sdk/v1/regions/us-east-1/instances/local-1/eip/disassociate", "DELETE /api/sdk/v1/regions/us-east-1/instances/local-1/eip"}
if len(paths) != len(want) {
t.Fatalf("paths=%v", paths)
}
for i := range want {
if paths[i] != want[i] {
t.Fatalf("paths=%v want=%v", paths, want)
}
}
}
func TestInstanceChangePricingAndAsyncTraffic(t *testing.T) {
paths := []string{}
client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.Method+" "+r.URL.Path)
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/sdk/v1/regions/us-east-1/instances/local-1/change-price-estimate" {
var body InstanceChangePriceRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Kind != InstanceChangeTrafficLimit || body.LimitGiB == nil || *body.LimitGiB != 200 {
t.Fatalf("unexpected quote body %#v err=%v", body, err)
}
_, _ = w.Write([]byte(`{"code":200,"message":"Success","data":{"kind":"traffic_limit","currency":"USD","charge_price":5.5,"configuration_fingerprint":"fp"}}`))
return
}
if r.Header.Get("Idempotency-Key") != "traffic-order-1" {
t.Fatalf("missing stable idempotency key")
}
_, _ = w.Write([]byte(`{"code":202,"message":"Accepted","data":{"task_id":"task-traffic","status":"queued"}}`))
})
limit := 200.0
estimate, err := client.Pricing.EstimateInstanceChange(context.Background(), "us-east-1", "local-1", InstanceChangePriceRequest{Kind: InstanceChangeTrafficLimit, PeriodEnd: time.Now().AddDate(0, 1, 0), LimitGiB: &limit})
if err != nil || estimate.ConfigurationFingerprint != "fp" || estimate.ChargePrice != 5.5 {
t.Fatalf("estimate=%#v err=%v", estimate, err)
}
operation, err := client.Traffic.UpdateAsync(context.Background(), "us-east-1", "local-1", TrafficLimitRequest{LimitGiB: &limit, ExpectedConfigurationFingerprint: "fp"}, WithIdempotencyKey("traffic-order-1"))
if err != nil || operation.TaskID != "task-traffic" {
t.Fatalf("operation=%#v err=%v", operation, err)
}
want := []string{"POST /api/sdk/v1/regions/us-east-1/instances/local-1/change-price-estimate", "POST /api/sdk/v1/regions/us-east-1/instances/local-1/traffic-limit/update"}
if len(paths) != len(want) || paths[0] != want[0] || paths[1] != want[1] {
t.Fatalf("paths=%v want=%v", paths, want)
}
}
func TestExpandRootVolumePathAndBody(t *testing.T) {
client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch || r.URL.Path != "/api/sdk/v1/regions/us-east-1/instances/local-1/root-volume" {
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
var body ExpandRootVolumeRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.SizeGiB != 80 || body.ExpectedConfigurationFingerprint != "fp" {
t.Fatalf("unexpected body %#v err=%v", body, err)
}
_, _ = w.Write([]byte(`{"code":202,"message":"Accepted","data":{"task_id":"task-root","status":"queued"}}`))
})
operation, err := client.Volumes.ExpandRoot(context.Background(), "us-east-1", "local-1", ExpandRootVolumeRequest{SizeGiB: 80, ExpectedConfigurationFingerprint: "fp"}, WithIdempotencyKey("root-expand"))
if err != nil || operation.TaskID != "task-root" {
t.Fatalf("operation=%#v err=%v", operation, err)
}
}
func TestInstanceDetailsExposeCatalogMetadata(t *testing.T) {
client, _ := testClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":200,"message":"Success","data":{"instance":{"instance_id":"i-1"},"instance_type_details":{"instance_type":"m7i.large","vcpus":2,"catalog_match":true},"image_details":{"image_id":"ami-1","display_name":"Ubuntu 24.04","catalog_match":true}}}`))
})
details, err := client.Instances.GetDetails(context.Background(), "us-east-1", "local-1")
if err != nil {
t.Fatal(err)
}
if details.InstanceTypeDetails()["instance_type"] != "m7i.large" || details.ImageDetails()["display_name"] != "Ubuntu 24.04" {
t.Fatalf("catalog details were not decoded: %#v", details)
}
}