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 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 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) } } }