package hostactors_test import ( "bytes" "context" "crypto/ed25519" "errors" "fmt" "io" "net/http" "strings" "sync" "testing" "time" awss3 "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/release" ) // sdkRequest is one HTTP request the AWS SDK sent: its method, its path, // its If-Match condition, and how long its context had left when it was // sent (zero: no deadline). type sdkRequest struct { method, path, ifMatch string left time.Duration } // recordingHTTP stands in for the network under the SDK: a bucket that // honours S3's conditional headers. It records each request's context // deadline. A GET answers the object and its ETag, or NoSuchKey; a PUT // with If-Match succeeds only on the current ETag, one with // If-None-Match: * only when the key is absent, and gets a new ETag. type recordingHTTP struct { mu sync.Mutex seen []sdkRequest objects map[string]bucketObject next int } type bucketObject struct { body []byte etag string } func (h *recordingHTTP) Do(r *http.Request) (*http.Response, error) { var left time.Duration if dl, ok := r.Context().Deadline(); ok { left = time.Until(dl) } var body []byte if r.Body != nil { body, _ = io.ReadAll(r.Body) } h.mu.Lock() defer h.mu.Unlock() h.seen = append(h.seen, sdkRequest{r.Method, r.URL.Path, r.Header.Get("If-Match"), left}) resp := &http.Response{Header: http.Header{}, Request: r, Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, Body: io.NopCloser(strings.NewReader(""))} reply := func(code int, xml string) (*http.Response, error) { resp.StatusCode = code resp.Header.Set("Content-Type", "application/xml") resp.Body = io.NopCloser(strings.NewReader(`` + xml + `fake`)) return resp, nil } key := r.URL.Path cur, exists := h.objects[key] switch r.Method { case http.MethodPut: if m := r.Header.Get("If-Match"); m != "" && (!exists || strings.Trim(m, `"`) != strings.Trim(cur.etag, `"`)) { return reply(http.StatusPreconditionFailed, "PreconditionFailed") } if r.Header.Get("If-None-Match") == "*" && exists { return reply(http.StatusPreconditionFailed, "PreconditionFailed") } h.next++ obj := bucketObject{body, fmt.Sprintf(`"e%d"`, h.next)} h.objects[key] = obj resp.StatusCode = http.StatusOK resp.Header.Set("ETag", obj.etag) return resp, nil case http.MethodGet: if !exists { return reply(http.StatusNotFound, "NoSuchKey") } resp.StatusCode = http.StatusOK resp.Header.Set("ETag", cur.etag) resp.Header.Set("Content-Length", fmt.Sprint(len(cur.body))) resp.Body = io.NopCloser(bytes.NewReader(cur.body)) resp.ContentLength = int64(len(cur.body)) return resp, nil } return reply(http.StatusNotFound, "NoSuchKey") } func (h *recordingHTTP) take() []sdkRequest { h.mu.Lock() defer h.mu.Unlock() out := h.seen h.seen = nil return out } // productionStores builds the console's stores as claim_runtime.go does: // the release S3 store (its own construction, s3compat and all, with only // the network replaced), the conditional record store over it, and the // observed mutation boundary with the production mutation check over // that: the store the enrolment pass's Directory carries. func productionStores(t *testing.T) (raw *release.S3Store, data release.Store, h *recordingHTTP, root release.Signer) { t.Helper() t.Setenv("AWS_ACCESS_KEY_ID", "k") t.Setenv("AWS_SECRET_ACCESS_KEY", "s") t.Setenv("AWS_SESSION_TOKEN", "") t.Setenv("AWS_REGION", "eu-west-1") t.Setenv("AWS_EC2_METADATA_DISABLED", "true") t.Setenv("AWS_CONFIG_FILE", "/nonexistent") t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "/nonexistent") t.Setenv("CARLOS_S3_ENDPOINT", "http://127.0.0.1:9") raw, err := release.NewS3Store(context.Background(), "deployment") if err != nil { t.Fatal(err) } h = &recordingHTTP{objects: map[string]bucketObject{}} raw.Client = awss3.New(raw.Client.Options(), func(o *awss3.Options) { o.HTTPClient = h; o.RetryMaxAttempts = 1 }) pub, priv, err := ed25519.GenerateKey(nil) if err != nil { t.Fatal(err) } control, err := hostops.NewControlWriter(raw, pub, time.Now) if err != nil { t.Fatal(err) } check, err := hostops.NewProductionMutationCheck(raw, control) if err != nil { t.Fatal(err) } records, err := hostops.NewConditionalRecordStore(raw, pub) if err != nil { t.Fatal(err) } data, err = hostops.NewObservedMutationBoundary(true, records, check, nil) if err != nil { t.Fatal(err) } return raw, data, h, release.RootSigner(priv) } // V2 and W1 (platform-1e): the per-call context of a bound write reaches // the AWS SDK's HTTP request through the console's production stores, so // the 15-second limit is enforced where the request is sent. The roster // write is a successful conditional PUT through the whole stack: the // bound's deadline store, the observed mutation boundary (its pre-read, // the production mutation check, which admits a root-signed roster, and // its forwarding), the conditional record store's forwarding and the S3 // store. Every request it sends, the PUT included, carries a deadline of // 15 seconds from its call, or the write deadline when that comes first. // A wrapper anywhere on the path that drops the context, or a bound that // does not give each call its own, fails here. func TestTheBoundReachesTheSDKRequest(t *testing.T) { _, data, h, root := productionStores(t) for name, c := range map[string]struct { readAge time.Duration low, high time.Duration }{ "the per-call limit": {0, 14 * time.Second, 15 * time.Second}, "the earlier write deadline": {50 * time.Second, 9 * time.Second, 10 * time.Second}, } { t.Run(name, func(t *testing.T) { check := func(what string, got []sdkRequest, methods ...string) []sdkRequest { t.Helper() var sent []string for _, r := range got { sent = append(sent, r.method) if r.left <= c.low || r.left > c.high { t.Errorf("%s: %s %s had %s left, want (%s, %s]", what, r.method, r.path, r.left, c.low, c.high) } } if strings.Join(sent, " ") != strings.Join(methods, " ") { t.Fatalf("%s: the SDK sent %v, want %v", what, sent, methods) } return got } // The bucket holds a roster the pass has read. h.mu.Lock() h.objects = map[string]bucketObject{} h.mu.Unlock() before, err := release.SignRootEnvelope(root, map[string]int{"revision": 1}) if err != nil { t.Fatal(err) } d := hostactors.Directory{Store: data, Bucket: "deployment"} etag, err := data.Put(context.Background(), hostactors.Key, before, "") if err != nil { t.Fatal(err) } h.take() bd, bctx, cancel := hostactors.BindForTest(context.Background(), d, time.Now().Add(-c.readAge), time.Now) defer cancel() if _, got, err := bd.Store.Get(bctx, hostactors.Key); err != nil || got != etag { t.Fatalf("roster read: %q %v", got, err) } check("the roster read", h.take(), http.MethodGet) // The enrolling write: conditional on that ETag, and admitted. next, err := release.SignRootEnvelope(root, map[string]int{"revision": 2}) if err != nil { t.Fatal(err) } after, err := bd.Store.Put(bctx, hostactors.Key, next, etag) if err != nil || after == etag { t.Fatalf("the conditional roster write: %q %v", after, err) } sent := check("the roster write", h.take(), http.MethodGet, http.MethodPut) if strings.Trim(sent[1].ifMatch, `"`) != strings.Trim(etag, `"`) { t.Fatalf("the roster PUT was sent with If-Match %q, want the ETag read, %q", sent[1].ifMatch, etag) } // And it stays conditional: the old ETag now loses. if _, err := bd.Store.Put(bctx, hostactors.Key, next, etag); !errors.Is(err, release.ErrCASConflict) { t.Fatalf("a stale ETag was not refused: %v", err) } h.take() }) } }