diff --git a/pkg/provider/conformance/broker_test.go b/pkg/provider/conformance/broker_test.go new file mode 100644 index 00000000..17412f16 --- /dev/null +++ b/pkg/provider/conformance/broker_test.go @@ -0,0 +1,506 @@ +package conformance + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + providerv0 "github.com/codefly-dev/core/generated/go/codefly/services/provider/v0" + "github.com/codefly-dev/core/network/urlguard" + "github.com/codefly-dev/core/provider/broker" + "github.com/codefly-dev/core/provider/canonical" + "github.com/codefly-dev/core/provider/cassette" + "github.com/codefly-dev/core/provider/credentials" + "github.com/codefly-dev/core/provider/manifest" + "github.com/codefly-dev/core/provider/responsepolicy" + "github.com/stretchr/testify/require" +) + +// The bearer secret the host injects into the upstream request. It is distinct +// from the response secret (PoisonSecret) so a leak of either is attributable. +const poisonCredential = "codefly-poison-credential-DO-NOT-LEAK" + +const brokerRemoteID = "acct_0001" + +// brokerHarness wires the real F2 broker against the reference fixture. Every +// field is host-owned; a provider never sees it. +type brokerHarness struct { + fixture *Fixture + manifest *manifest.Manifest + vault *credentials.Vault + sink *recordingSink + admitted *providerv0.AdmittedOrigin +} + +func newBrokerHarness(t *testing.T) *brokerHarness { + t.Helper() + f := NewFixture() + t.Cleanup(f.Close) + + raw, err := os.ReadFile(filepath.Join("testdata", "provider.codefly.yaml")) + require.NoError(t, err) + m, err := manifest.Load(raw) + require.NoError(t, err) + + admitted := &providerv0.AdmittedOrigin{ + OriginRuleId: "api", + Scheme: "http", + Host: "localhost", + Port: 8080, + PrivateNetworkClass: providerv0.PrivateNetworkClass_PRIVATE_NETWORK_CLASS_LOOPBACK, + } + digest, err := canonical.AdmittedOriginDigest(admitted) + require.NoError(t, err) + admitted.AdmissionDigest = digest + + return &brokerHarness{fixture: f, manifest: m, vault: credentials.NewVault(), sink: &recordingSink{}, admitted: admitted} +} + +func (h *brokerHarness) origin() urlguard.Origin { + return urlguard.Origin{Scheme: "http", Host: "localhost", Port: 8080} +} + +func (h *brokerHarness) session(t *testing.T, action *providerv0.PlanAction, readOnly bool, opts ...func(*broker.Config)) *broker.Session { + t.Helper() + cfg := broker.Config{ + Manifest: h.manifest, + Action: action, + Binding: bindingAddress(), + Budget: budget(), + ReadOnly: readOnly, + Vault: h.vault, + Sink: h.sink, + Checkpoints: &fakeCheckpointer{cp: checkpoint("cp1", "idem-1")}, + Deadlines: urlguard.DefaultDeadlines(), + ClientFor: dialClientFor(h.fixture.Addr()), + } + for _, opt := range opts { + opt(&cfg) + } + session, err := broker.New(cfg) + require.NoError(t, err) + return session +} + +func (h *brokerHarness) mint(t *testing.T, planned *providerv0.PlannedRequest, method providerv0.HTTPMethod) *providerv0.CredentialHandle { + t.Helper() + handle, err := h.vault.Mint(poisonCredential, credentials.Scope{ + Principal: "user", + Organization: "org", + ArtifactDigest: "sha256:aa", + Binding: bindingAddress(), + PlanID: "plan1", + ActionID: "a1", + RequestDigest: planned.GetRequestDigest(), + Purpose: providerv0.CredentialPurpose_CREDENTIAL_PURPOSE_MANAGEMENT, + Origin: h.origin(), + Method: method, + Injection: credentials.Injection{Kind: credentials.InjectBearer}, + MaxUses: 1, + TTL: time.Minute, + }) + require.NoError(t, err) + return handle +} + +func (h *brokerHarness) execute(handle *providerv0.CredentialHandle, planned *providerv0.PlannedRequest) *providerv0.ExecuteRequestRequest { + return &providerv0.ExecuteRequestRequest{ + Context: &providerv0.ProviderContext{ + Offline: &providerv0.OfflineProviderContext{Binding: bindingAddress()}, + Credentials: []*providerv0.CredentialHandle{handle}, + Operation: operation(), + Budget: budget(), + }, + RequestId: "req-1", + Request: planned, + Origin: h.admitted, + CredentialHandles: []*providerv0.CredentialHandle{handle}, + } +} + +// TestBrokerCreateCapturesSecretAndForwardsSafe is the central invariant: a +// mutating request through the real broker forwards only the manifest-declared +// safe fields, captures the response secret to the sink, and never lets a +// poison value reach the provider-facing response. +func TestBrokerCreateCapturesSecretAndForwardsSafe(t *testing.T) { + h := newBrokerHarness(t) + create := h.createRequest(t) + session := h.session(t, createAction(t, create), false) + + handle := h.mint(t, create, providerv0.HTTPMethod_HTTP_METHOD_POST) + response, err := session.Execute(context.Background(), h.execute(handle, create)) + require.NoError(t, err) + + require.Equal(t, providerv0.DeliveryState_DELIVERY_STATE_RESPONSE_RECEIVED, response.GetDelivery()) + require.Equal(t, uint32(http.StatusOK), response.GetStatusCode()) + + // The response secret was captured, not forwarded; metadata.internal is + // suppressed-with-presence; id and metadata.public are the only safe fields. + require.Equal(t, []string{PoisonSecret}, h.sink.stored) + require.Len(t, response.GetCaptures(), 1) + require.Len(t, response.GetSuppressedPresence(), 1) + + forwarded := forwardedBySelector(response) + require.Equal(t, map[string]string{ + "$.id": brokerRemoteID, + "$.metadata.public": "safe-adjacent", + }, forwarded) + + // Neither the response secret nor the injected credential is anywhere in the + // provider-facing response. + encoded, err := json.Marshal(response) + require.NoError(t, err) + assertNoPoison(t, encoded) + require.NotContains(t, string(encoded), poisonCredential) + + // Exactly one request reached the fixture. + require.Equal(t, 1, h.fixture.RequestCount()) +} + +// TestBrokerReadOnlyContextRejectsMutationWithoutEffect proves a denial upstream +// of the network leaves no side effect: no request reaches the fixture. +func TestBrokerReadOnlyContextRejectsMutationWithoutEffect(t *testing.T) { + h := newBrokerHarness(t) + create := h.createRequest(t) + session := h.session(t, createAction(t, create), true) + + handle := h.mint(t, create, providerv0.HTTPMethod_HTTP_METHOD_POST) + _, err := session.Execute(context.Background(), h.execute(handle, create)) + require.ErrorContains(t, err, "read-only") + + require.Empty(t, h.sink.stored) + require.Equal(t, 0, h.fixture.RequestCount(), "a denied request must not reach the network") +} + +// TestBrokerBudgetExhaustionStopsBeforeNetwork proves the request-count budget +// gates the second call before any bytes leave the host. +func TestBrokerBudgetExhaustionStopsBeforeNetwork(t *testing.T) { + h := newBrokerHarness(t) + create := h.createRequest(t) + session := h.session(t, createAction(t, create), false, func(cfg *broker.Config) { + cfg.Budget = &providerv0.RequestBudget{RequestCount: 1, RequestBytes: 8192, ResponseBytes: 65536} + }) + + handle := h.mint(t, create, providerv0.HTTPMethod_HTTP_METHOD_POST) + _, err := session.Execute(context.Background(), h.execute(handle, create)) + require.NoError(t, err) + require.Equal(t, 1, h.fixture.RequestCount()) + + // The vault handle was single-use; the budget is what we assert on, so a fresh + // handle isolates the budget check from credential exhaustion. + handle2 := h.mint(t, create, providerv0.HTTPMethod_HTTP_METHOD_POST) + _, err = session.Execute(context.Background(), h.execute(handle2, create)) + require.ErrorContains(t, err, "budget") + require.Equal(t, 1, h.fixture.RequestCount(), "the over-budget request never reaches the network") +} + +// TestBrokerCassetteReplayServesWithoutNetwork proves a recorded session replays +// the identical filtered response with no network I/O and no poison in the +// serialized cassette. +func TestBrokerCassetteReplayServesWithoutNetwork(t *testing.T) { + h := newBrokerHarness(t) + create := h.createRequest(t) + action := createAction(t, create) + + recorder := cassette.New(cassette.ModeRecord, "1.0.0") + recordSession := h.session(t, action, false, func(cfg *broker.Config) { cfg.Cassette = recorder }) + handle := h.mint(t, create, providerv0.HTTPMethod_HTTP_METHOD_POST) + live, err := recordSession.Execute(context.Background(), h.execute(handle, create)) + require.NoError(t, err) + + cassetteBytes, err := recorder.Marshal() + require.NoError(t, err) + assertNoPoison(t, cassetteBytes) + + // Replay against a dialer that fails if touched, proving no network occurs. + replayer, err := cassette.Load(cassetteBytes, "1.0.0") + require.NoError(t, err) + replaySession := h.session(t, action, false, func(cfg *broker.Config) { + cfg.Cassette = replayer + cfg.ClientFor = failingClientFor(t) + }) + handle2 := h.mint(t, create, providerv0.HTTPMethod_HTTP_METHOD_POST) + replayed, err := replaySession.Execute(context.Background(), h.execute(handle2, create)) + require.NoError(t, err) + + require.Equal(t, forwardedBySelector(live), forwardedBySelector(replayed)) + encoded, err := json.Marshal(replayed) + require.NoError(t, err) + assertNoPoison(t, encoded) +} + +// TestBrokerObserveCapturesReadPathSecret proves a secret in a read (GET) +// response is captured to the sink and never forwarded, exercising the fixture's +// retrieve path end to end through the real broker. +func TestBrokerObserveCapturesReadPathSecret(t *testing.T) { + h := newBrokerHarness(t) + seeded := h.fixture.Seed("codefly") + require.Equal(t, brokerRemoteID, seeded) + + observe := h.observeRequest(t, seeded) + // The observe request is anchored by a create-typed action whose prospective + // id matches the retrieved resource, mirroring the core broker test recipe. + session := h.session(t, createAction(t, observe), false) + + handle := h.mint(t, observe, providerv0.HTTPMethod_HTTP_METHOD_GET) + response, err := session.Execute(context.Background(), h.execute(handle, observe)) + require.NoError(t, err) + + require.Equal(t, providerv0.DeliveryState_DELIVERY_STATE_RESPONSE_RECEIVED, response.GetDelivery()) + require.Equal(t, uint32(http.StatusOK), response.GetStatusCode()) + + require.Equal(t, []string{PoisonSecret}, h.sink.stored) + require.Equal(t, map[string]string{ + "$.id": seeded, + "$.metadata.public": "safe-adjacent", + }, forwardedBySelector(response)) + + encoded, err := json.Marshal(response) + require.NoError(t, err) + assertNoPoison(t, encoded) + require.NotContains(t, string(encoded), poisonCredential) + require.Equal(t, 1, h.fixture.RequestCount()) +} + +// TestBrokerDeleteRemovesOwnedResource proves a delete of an owned resource +// reaches the fixture, forwards only the safe deletion marker, and removes the +// resource. +func TestBrokerDeleteRemovesOwnedResource(t *testing.T) { + h := newBrokerHarness(t) + seeded := h.fixture.Seed("codefly") + require.Equal(t, 1, h.fixture.ResourceCount()) + + del := h.deleteRequest(t, seeded) + session := h.session(t, deleteAction(t, seeded, del), false) + + handle := h.mint(t, del, providerv0.HTTPMethod_HTTP_METHOD_DELETE) + response, err := session.Execute(context.Background(), h.execute(handle, del)) + require.NoError(t, err) + + require.Equal(t, providerv0.DeliveryState_DELIVERY_STATE_RESPONSE_RECEIVED, response.GetDelivery()) + require.Equal(t, uint32(http.StatusOK), response.GetStatusCode()) + + require.Len(t, response.GetForwarded(), 1) + require.Equal(t, "$.deleted", response.GetForwarded()[0].GetSelector()) + require.True(t, response.GetForwarded()[0].GetValue().GetBoolValue()) + + require.Equal(t, 0, h.fixture.ResourceCount(), "the owned resource was destroyed") + require.Equal(t, 1, h.fixture.RequestCount()) +} + +func (h *brokerHarness) createRequest(t *testing.T) *providerv0.PlannedRequest { + t.Helper() + request := &providerv0.PlannedRequest{ + RequestDescriptorId: "account.create", + RequestDescriptorDigest: descriptorDigest(t, h.manifest, "account.create"), + Method: providerv0.HTTPMethod_HTTP_METHOD_POST, + AdmittedOriginDigest: h.admitted.GetAdmissionDigest(), + Body: map[string]*providerv0.PublicValue{"name": pubString(brokerRemoteID)}, + CredentialPurposes: []providerv0.CredentialPurpose{providerv0.CredentialPurpose_CREDENTIAL_PURPOSE_MANAGEMENT}, + ResponsePolicyDigest: fakeDigest("account-response-policy"), + IdempotencyKey: "idem-1", + } + bound, err := canonical.BindPlannedRequestDigest(request) + require.NoError(t, err) + return bound +} + +func createAction(t *testing.T, requests ...*providerv0.PlannedRequest) *providerv0.PlanAction { + t.Helper() + action := &providerv0.PlanAction{ + ActionId: "a1", + Position: 0, + Type: providerv0.ActionType_ACTION_TYPE_CREATE, + ResourceType: "account", + ProspectiveRemoteId: brokerRemoteID, + Ownership: providerv0.Ownership_OWNERSHIP_OWNED, + Requests: requests, + } + require.NoError(t, canonical.ValidatePlanAction(action)) + return action +} + +func (h *brokerHarness) observeRequest(t *testing.T, accountID string) *providerv0.PlannedRequest { + t.Helper() + request := &providerv0.PlannedRequest{ + RequestDescriptorId: "account.observe", + RequestDescriptorDigest: descriptorDigest(t, h.manifest, "account.observe"), + Method: providerv0.HTTPMethod_HTTP_METHOD_GET, + AdmittedOriginDigest: h.admitted.GetAdmissionDigest(), + PathParameters: map[string]*providerv0.PublicValue{"account_id": pubString(accountID)}, + CredentialPurposes: []providerv0.CredentialPurpose{providerv0.CredentialPurpose_CREDENTIAL_PURPOSE_MANAGEMENT}, + ResponsePolicyDigest: fakeDigest("account-response-policy"), + } + bound, err := canonical.BindPlannedRequestDigest(request) + require.NoError(t, err) + return bound +} + +func (h *brokerHarness) deleteRequest(t *testing.T, accountID string) *providerv0.PlannedRequest { + t.Helper() + request := &providerv0.PlannedRequest{ + RequestDescriptorId: "account.delete", + RequestDescriptorDigest: descriptorDigest(t, h.manifest, "account.delete"), + Method: providerv0.HTTPMethod_HTTP_METHOD_DELETE, + AdmittedOriginDigest: h.admitted.GetAdmissionDigest(), + PathParameters: map[string]*providerv0.PublicValue{"account_id": pubString(accountID)}, + CredentialPurposes: []providerv0.CredentialPurpose{providerv0.CredentialPurpose_CREDENTIAL_PURPOSE_MANAGEMENT}, + ResponsePolicyDigest: fakeDigest("deleted-response-policy"), + IdempotencyKey: "idem-1", + } + bound, err := canonical.BindPlannedRequestDigest(request) + require.NoError(t, err) + return bound +} + +func deleteAction(t *testing.T, remoteID string, requests ...*providerv0.PlannedRequest) *providerv0.PlanAction { + t.Helper() + action := &providerv0.PlanAction{ + ActionId: "a1", + Position: 0, + Type: providerv0.ActionType_ACTION_TYPE_DELETE, + ResourceType: "account", + RemoteIdentity: &providerv0.RemoteIdentity{Provider: "conformance", ResourceType: "account", RemoteId: remoteID}, + Ownership: providerv0.Ownership_OWNERSHIP_OWNED, + Requests: requests, + } + require.NoError(t, canonical.ValidatePlanAction(action)) + return action +} + +// --- read-path secret filtering, exercised through the real response policy --- + +// TestResponsePolicyCapturesReadArraySecrets proves the host response filter +// handles secrets nested inside a read (list) array: every secret is captured +// and none reaches the safe projection. +func TestResponsePolicyCapturesReadArraySecrets(t *testing.T) { + body := `{"object":"list","data":[` + + `{"id":"acct_0001","secret":"` + PoisonSecret + `","public":"safe-adjacent"},` + + `{"id":"acct_0002","secret":"` + PoisonSecret + `","public":"safe-adjacent"}` + + `]}` + sink := &recordingSink{} + policy := responsepolicy.Policy{ + Fields: []responsepolicy.Field{ + {Selector: manifest.Selector{Version: "v1", Path: "$.data[*].id"}, Disposition: manifest.ResponseForwardSafe}, + {Selector: manifest.Selector{Version: "v1", Path: "$.data[*].public"}, Disposition: manifest.ResponseForwardSafe}, + {Selector: manifest.Selector{Version: "v1", Path: "$.data[*].secret"}, Disposition: manifest.ResponseCaptureToSink, Purpose: providerv0.CredentialPurpose_CREDENTIAL_PURPOSE_RUNTIME, SinkKey: "account-secret"}, + }, + Limits: responsepolicy.DefaultLimits(), + } + + result, err := policy.Filter(context.Background(), []byte(body), "", "application/json", sink) + require.NoError(t, err) + + require.Len(t, result.Captures, 2) + require.Equal(t, []string{PoisonSecret, PoisonSecret}, sink.stored) + assertNoPoison(t, result.SafeJSON) + require.Len(t, result.Forwarded, 4) // id + public, per element +} + +// --- shared host-side wiring, mirroring the core broker test recipe --- + +func pubString(value string) *providerv0.PublicValue { + return &providerv0.PublicValue{Kind: &providerv0.PublicValue_StringValue{StringValue: value}} +} + +func fakeDigest(seed string) string { + sum := sha256.Sum256([]byte(seed)) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func descriptorDigest(t *testing.T, m *manifest.Manifest, id string) string { + t.Helper() + for _, descriptor := range m.Requests { + if descriptor.ID == id { + digest, err := manifest.RequestDescriptorDigest(descriptor) + require.NoError(t, err) + return digest + } + } + t.Fatalf("descriptor %q not found", id) + return "" +} + +func bindingAddress() *providerv0.BindingAddress { + return &providerv0.BindingAddress{WorkspaceId: "ws", EnvironmentId: "env", BindingId: "bind"} +} + +func operation() *providerv0.OperationIdentity { + return &providerv0.OperationIdentity{OperationId: "op1", AttemptId: "att1", ActionId: "a1", PlanId: "plan1"} +} + +func budget() *providerv0.RequestBudget { + return &providerv0.RequestBudget{RequestCount: 4, RequestBytes: 8192, ResponseBytes: 65536} +} + +func checkpoint(id, idempotencyKey string) *providerv0.ActionCheckpoint { + return &providerv0.ActionCheckpoint{ + CheckpointId: id, + Operation: operation(), + Delivery: providerv0.DeliveryState_DELIVERY_STATE_NOT_SENT, + IdempotencyKey: idempotencyKey, + } +} + +type fakeCheckpointer struct { + cp *providerv0.ActionCheckpoint +} + +func (c *fakeCheckpointer) Latest(context.Context, *providerv0.OperationIdentity) (*providerv0.ActionCheckpoint, error) { + return c.cp, nil +} + +type recordingSink struct { + stored []string +} + +func (s *recordingSink) Put(_ context.Context, target responsepolicy.SinkTarget, secret string) (*providerv0.OpaqueReference, error) { + s.stored = append(s.stored, secret) + return &providerv0.OpaqueReference{Reference: "capture://" + target.Key, Purpose: target.Purpose}, nil +} + +// dialClientFor dials the fixture regardless of the request URL, exercising the +// real transport path without depending on DNS or the manifest port. +func dialClientFor(addr string) func(urlguard.Origin, urlguard.Resolution) *http.Client { + return func(urlguard.Origin, urlguard.Resolution) *http.Client { + return &http.Client{ + Transport: &http.Transport{ + Proxy: nil, + DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, addr) + }, + }, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + } +} + +// failingClientFor returns a client whose dialer fails, proving replay performs +// no network I/O. +func failingClientFor(t *testing.T) func(urlguard.Origin, urlguard.Resolution) *http.Client { + return func(urlguard.Origin, urlguard.Resolution) *http.Client { + return &http.Client{ + Transport: &http.Transport{ + DialContext: func(context.Context, string, string) (net.Conn, error) { + t.Error("replay dialed the network") + return nil, net.ErrClosed + }, + }, + } + } +} + +func forwardedBySelector(response *providerv0.ExecuteRequestResponse) map[string]string { + out := make(map[string]string, len(response.GetForwarded())) + for _, field := range response.GetForwarded() { + out[field.GetSelector()] = field.GetValue().GetStringValue() + } + return out +} diff --git a/pkg/provider/conformance/fixture.go b/pkg/provider/conformance/fixture.go new file mode 100644 index 00000000..59e46073 --- /dev/null +++ b/pkg/provider/conformance/fixture.go @@ -0,0 +1,414 @@ +// Package conformance provides the normative Codefly provider-protocol +// conformance fixtures. It is not a fake vendor: it is a Codefly-owned +// reference server that exercises the provider host boundary deterministically, +// so the neutral provider (well-behaved) and the hostile providers (attacking +// the trust boundary) can be judged against one fixed target. +// +// The centerpiece is Fixture, a loopback HTTP server with deterministic request +// ids and timestamps, in-memory resources with ownership metadata, one-time and +// read-path secrets, idempotent-POST replay, and injectable faults. It exposes +// safe inspection endpoints (and equivalent Go accessors) that report the exact +// requests received without ever echoing a request or response value, so a test +// can assert both that an effect happened and that no request leaked past a +// denial. +// +// Every secret the fixture emits is a planted poison value (PoisonSecret, +// PoisonDSN). Nothing the fixture reports through its inspection surface, and no +// committed golden artifact, may contain a poison value: their sole purpose is +// to be caught if the host boundary ever forwards one. +package conformance + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "sort" + "strings" + "sync" + "time" +) + +// Planted poison values. They appear only in raw upstream response bodies the +// fixture serves; the host boundary must capture or suppress them so they never +// reach a provider, an inspection response, or a golden artifact. +const ( + PoisonSecret = "codefly-poison-secret-DO-NOT-LEAK" //nolint:gosec // G101: deliberately fake planted poison, never a real credential + PoisonDSN = "codefly-poison://user:codefly-poison-secret-DO-NOT-LEAK@fixture.invalid/db" //nolint:gosec // G101: deliberately fake planted poison, never a real credential +) + +// epoch is the fixed clock origin. The fixture derives every timestamp from a +// per-request sequence number so ids and timestamps are byte-stable across runs +// and platforms. +var epoch = time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC) + +// RecordedRequest is the safe projection of one received request: identifiers, +// method, path, and the field names present in the body — never a header value +// or a body value, so inspection cannot become a side channel for a secret. +type RecordedRequest struct { + Sequence int `json:"sequence"` + RequestID string `json:"request_id"` + Method string `json:"method"` + Path string `json:"path"` + Query string `json:"query,omitempty"` + HeaderKeys []string `json:"header_keys"` + BodyKeys []string `json:"body_keys,omitempty"` +} + +// faults holds one-shot fault injections consumed by the next received request. +type faults struct { + rateLimited bool + malformed bool + redirect bool +} + +// Fixture is the normative reference server. The zero value is not usable; call +// NewFixture. It is safe for concurrent use. +type Fixture struct { + server *httptest.Server + + mu sync.Mutex + seq int + accounts map[string]string // resource id -> owning principal + nextID int + idem map[string]string + requests []RecordedRequest + faults faults +} + +// NewFixture starts a loopback fixture server. Call Close when done. The server +// listens on a real loopback socket so the host's transport path (tracing, +// SSRF guard, credential injection) is exercised end to end. +func NewFixture() *Fixture { + f := &Fixture{ + accounts: make(map[string]string), + idem: make(map[string]string), + nextID: 1, + } + mux := http.NewServeMux() + mux.HandleFunc("/v1/account", f.handleIdentity) + mux.HandleFunc("/v1/accounts", f.handleCollection) + mux.HandleFunc("/v1/accounts/", f.handleResource) + mux.HandleFunc("/_inspect/requests", f.handleInspectRequests) + mux.HandleFunc("/_inspect/resources", f.handleInspectResources) + mux.HandleFunc("/_inspect/reset", f.handleReset) + f.server = httptest.NewServer(mux) + return f +} + +// Close shuts the server down. +func (f *Fixture) Close() { f.server.Close() } + +// Addr is the host:port the server listens on, suitable for a host-owned dialer. +func (f *Fixture) Addr() string { return f.server.Listener.Addr().String() } + +// URL is the base URL of the server. +func (f *Fixture) URL() string { return f.server.URL } + +// Seed inserts an owned account so read paths can be exercised without first +// creating one. It returns the account id. +func (f *Fixture) Seed(owner string) string { + f.mu.Lock() + defer f.mu.Unlock() + return f.insertLocked(owner) +} + +// InjectRateLimited makes the next received request answer 429 with retry-after +// and rate metadata, without recording an effect. The fault is consumed by the +// next request to any resource endpoint, regardless of method. +func (f *Fixture) InjectRateLimited() { f.setFault(func(x *faults) { x.rateLimited = true }) } + +// InjectMalformed makes the next received request answer with a duplicate-key, +// malformed JSON body so response decoding is exercised against hostile input. +func (f *Fixture) InjectMalformed() { f.setFault(func(x *faults) { x.malformed = true }) } + +// InjectRedirect makes the next received request answer 302 to the collection, +// so the host's no-follow-redirect stance is exercised. +func (f *Fixture) InjectRedirect() { f.setFault(func(x *faults) { x.redirect = true }) } + +// Requests returns the safe projection of every request received so far. +func (f *Fixture) Requests() []RecordedRequest { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]RecordedRequest, len(f.requests)) + copy(out, f.requests) + return out +} + +// RequestCount reports how many requests reached the server. A denial upstream +// of the host boundary must leave this unchanged. +func (f *Fixture) RequestCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.requests) +} + +// ResourceCount reports how many resources currently exist, so idempotent replay +// (two requests, one effect) is observable. +func (f *Fixture) ResourceCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.accounts) +} + +// Reset clears resources, idempotency records, requests, and pending faults. +func (f *Fixture) Reset() { + f.mu.Lock() + defer f.mu.Unlock() + f.accounts = make(map[string]string) + f.idem = make(map[string]string) + f.requests = nil + f.faults = faults{} + f.seq = 0 + f.nextID = 1 +} + +func (f *Fixture) setFault(mutate func(*faults)) { + f.mu.Lock() + defer f.mu.Unlock() + mutate(&f.faults) +} + +func (f *Fixture) insertLocked(owner string) string { + id := fmt.Sprintf("acct_%04d", f.nextID) + f.nextID++ + f.accounts[id] = owner + return id +} + +// record stamps a deterministic request id and stores the safe projection. It +// returns the id and timestamp for the response. +func (f *Fixture) record(r *http.Request) (string, time.Time) { + f.seq++ + id := fmt.Sprintf("req-%04d", f.seq) + stamp := epoch.Add(time.Duration(f.seq) * time.Second) + rec := RecordedRequest{ + Sequence: f.seq, + RequestID: id, + Method: r.Method, + Path: r.URL.Path, + Query: r.URL.RawQuery, + HeaderKeys: safeHeaderKeys(r), + BodyKeys: bodyKeys(r), + } + f.requests = append(f.requests, rec) + return id, stamp +} + +func (f *Fixture) writeAccount(w http.ResponseWriter, id, requestID string, stamp time.Time) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Request-Id", requestID) + w.Header().Set("X-Timestamp", stamp.Format(time.RFC3339)) + w.Header().Set("X-Api-Version", "2024-01-01") + // $.id and $.metadata.public are safe and forwarded; $.secret is captured; + // $.metadata.internal is suppressed. The remaining fields are undeclared and + // must be dropped by the response policy. + body := fmt.Sprintf(`{"object":"account","id":%q,"livemode":false,`+ + `"secret":%q,"dsn":%q,`+ + `"metadata":{"public":"safe-adjacent","internal":"private"},`+ + `"api_version":"2024-01-01","owned_by":"codefly"}`, id, PoisonSecret, PoisonDSN) + _, _ = w.Write([]byte(body)) //nolint:gosec // G705: loopback JSON conformance fixture, never rendered in a browser +} + +func (f *Fixture) handleIdentity(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + requestID, stamp := f.record(r) + if f.consumeFaults(w, requestID) { + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Request-Id", requestID) + w.Header().Set("X-Timestamp", stamp.Format(time.RFC3339)) + _, _ = w.Write([]byte(`{"object":"account","livemode":false,"mode":"managed"}`)) +} + +func (f *Fixture) handleCollection(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + requestID, stamp := f.record(r) + if f.consumeFaults(w, requestID) { + return + } + switch r.Method { + case http.MethodPost: + f.createLocked(w, r, requestID, stamp) + case http.MethodGet: + f.listLocked(w, r, requestID, stamp) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (f *Fixture) handleResource(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + requestID, stamp := f.record(r) + if f.consumeFaults(w, requestID) { + return + } + id := strings.TrimPrefix(r.URL.Path, "/v1/accounts/") + switch r.Method { + case http.MethodGet: + if _, ok := f.accounts[id]; !ok { + f.writeError(w, http.StatusNotFound, requestID) + return + } + f.writeAccount(w, id, requestID, stamp) + case http.MethodDelete: + if _, ok := f.accounts[id]; !ok { + f.writeError(w, http.StatusNotFound, requestID) + return + } + delete(f.accounts, id) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Request-Id", requestID) + _, _ = fmt.Fprintf(w, `{"deleted":true,"id":%q}`, id) //nolint:gosec // G705: loopback JSON conformance fixture, never rendered in a browser + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (f *Fixture) createLocked(w http.ResponseWriter, r *http.Request, requestID string, stamp time.Time) { + // Idempotent POST: the same key replays the first response and records no new + // effect. A missing key is non-idempotent and always creates. + if key := r.Header.Get("Idempotency-Key"); key != "" { + if id, ok := f.idem[key]; ok { + f.writeAccount(w, id, requestID, stamp) + return + } + id := f.insertLocked("codefly") + f.idem[key] = id + f.writeAccount(w, id, requestID, stamp) + return + } + id := f.insertLocked("codefly") + f.writeAccount(w, id, requestID, stamp) +} + +func (f *Fixture) listLocked(w http.ResponseWriter, r *http.Request, requestID string, stamp time.Time) { + ids := make([]string, 0, len(f.accounts)) + for id := range f.accounts { + ids = append(ids, id) + } + sort.Strings(ids) + + // Cursor pagination: one item per page so at least two pages and a final + // empty cursor are always exercised. + cursor := r.URL.Query().Get("cursor") + start := 0 + if cursor != "" { + for i, id := range ids { + if id == cursor { + start = i + break + } + } + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Request-Id", requestID) + w.Header().Set("X-Timestamp", stamp.Format(time.RFC3339)) + if start >= len(ids) { + _, _ = w.Write([]byte(`{"object":"list","data":[],"next_cursor":""}`)) + return + } + id := ids[start] + next := "" + if start+1 < len(ids) { + next = ids[start+1] + } + // Each element carries a poison secret adjacent to a safe id, so read-array + // secret filtering is exercised. + _, _ = fmt.Fprintf(w, `{"object":"list","data":[{"id":%q,"secret":%q,"public":"safe-adjacent"}],"next_cursor":%q}`, + id, PoisonSecret, next) +} + +func (f *Fixture) consumeFaults(w http.ResponseWriter, requestID string) bool { + switch { + case f.faults.rateLimited: + f.faults.rateLimited = false + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Request-Id", requestID) + w.Header().Set("Retry-After", "2") + w.Header().Set("X-Rate-Limit-Remaining", "0") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"error":{"type":"rate_limit","message":"slow down"}}`)) + return true + case f.faults.malformed: + f.faults.malformed = false + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Request-Id", requestID) + // Duplicate keys and a trailing comma: ambiguous, hostile JSON. + _, _ = w.Write([]byte(`{"id":"acct_x","id":"acct_y",}`)) + return true + case f.faults.redirect: + f.faults.redirect = false + w.Header().Set("Location", "/v1/accounts") + w.Header().Set("X-Request-Id", requestID) + w.WriteHeader(http.StatusFound) + return true + } + return false +} + +func (f *Fixture) writeError(w http.ResponseWriter, status int, requestID string) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Request-Id", requestID) + w.WriteHeader(status) + _, _ = fmt.Fprintf(w, `{"error":{"type":"not_found"}}`) +} + +func (f *Fixture) handleInspectRequests(w http.ResponseWriter, _ *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + writeJSON(w, map[string]any{"requests": f.requests, "count": len(f.requests)}) +} + +func (f *Fixture) handleInspectResources(w http.ResponseWriter, _ *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + ids := make([]string, 0, len(f.accounts)) + for id, owner := range f.accounts { + ids = append(ids, id+":"+owner) + } + sort.Strings(ids) + writeJSON(w, map[string]any{"resources": ids, "count": len(ids)}) +} + +func (f *Fixture) handleReset(w http.ResponseWriter, _ *http.Request) { + f.Reset() + writeJSON(w, map[string]any{"reset": true}) +} + +func writeJSON(w http.ResponseWriter, payload any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(payload) +} + +// safeHeaderKeys returns the sorted set of header names, never their values. +func safeHeaderKeys(r *http.Request) []string { + keys := make([]string, 0, len(r.Header)) + for k := range r.Header { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// bodyKeys returns the sorted top-level field names of a JSON body, never their +// values. A non-JSON or unreadable body yields no keys. +func bodyKeys(r *http.Request) []string { + if r.Body == nil { + return nil + } + var decoded map[string]json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&decoded); err != nil { + return nil + } + keys := make([]string, 0, len(decoded)) + for k := range decoded { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/pkg/provider/conformance/fixture_test.go b/pkg/provider/conformance/fixture_test.go new file mode 100644 index 00000000..0ac9e2a8 --- /dev/null +++ b/pkg/provider/conformance/fixture_test.go @@ -0,0 +1,215 @@ +package conformance + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func get(t *testing.T, url string) (*http.Response, []byte) { + t.Helper() + resp, err := http.Get(url) //nolint:noctx // loopback fixture in a test + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + return resp, body +} + +func postJSON(t *testing.T, url string, headers map[string]string, payload string) (*http.Response, []byte) { + t.Helper() + req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(payload)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + return resp, body +} + +func TestFixtureDeterministicRequestIdentity(t *testing.T) { + f := NewFixture() + defer f.Close() + + resp1, _ := get(t, f.URL()+"/v1/account") + resp2, _ := get(t, f.URL()+"/v1/account") + + require.Equal(t, "req-0001", resp1.Header.Get("X-Request-Id")) + require.Equal(t, "req-0002", resp2.Header.Get("X-Request-Id")) + require.Equal(t, "2020-01-01T00:00:01Z", resp1.Header.Get("X-Timestamp")) + require.Equal(t, "2020-01-01T00:00:02Z", resp2.Header.Get("X-Timestamp")) +} + +func TestFixtureIdempotentPOSTReplaysOneEffect(t *testing.T) { + f := NewFixture() + defer f.Close() + + _, first := postJSON(t, f.URL()+"/v1/accounts", map[string]string{"Idempotency-Key": "idem-1"}, `{"name":"a"}`) + _, second := postJSON(t, f.URL()+"/v1/accounts", map[string]string{"Idempotency-Key": "idem-1"}, `{"name":"a"}`) + + require.Equal(t, idOf(t, first), idOf(t, second), "same key must replay the same resource id") + require.Equal(t, 1, f.ResourceCount(), "idempotent replay must not create a second resource") + require.Equal(t, 2, f.RequestCount(), "both requests are recorded") +} + +func TestFixtureNonIdempotentPOSTCreatesEachTime(t *testing.T) { + f := NewFixture() + defer f.Close() + + _, first := postJSON(t, f.URL()+"/v1/accounts", nil, `{"name":"a"}`) + _, second := postJSON(t, f.URL()+"/v1/accounts", nil, `{"name":"a"}`) + + require.NotEqual(t, idOf(t, first), idOf(t, second)) + require.Equal(t, 2, f.ResourceCount()) +} + +func TestFixtureRateLimitFault(t *testing.T) { + f := NewFixture() + defer f.Close() + f.InjectRateLimited() + + resp, _ := postJSON(t, f.URL()+"/v1/accounts", nil, `{"name":"a"}`) + + require.Equal(t, http.StatusTooManyRequests, resp.StatusCode) + require.Equal(t, "2", resp.Header.Get("Retry-After")) + require.Equal(t, "0", resp.Header.Get("X-Rate-Limit-Remaining")) + require.Equal(t, 0, f.ResourceCount(), "a rate-limited request records no effect") +} + +func TestFixtureMalformedFault(t *testing.T) { + f := NewFixture() + defer f.Close() + f.InjectMalformed() + + resp, body := postJSON(t, f.URL()+"/v1/accounts", nil, `{"name":"a"}`) + + require.Equal(t, http.StatusOK, resp.StatusCode) + // Duplicate keys and a trailing comma: not decodable as a single object. + var decoded map[string]any + require.Error(t, json.Unmarshal(body, &decoded), "the malformed fault must not decode cleanly") + require.Equal(t, 0, f.ResourceCount(), "a faulted request records no effect") +} + +// TestFixtureFaultAppliesToReadRequest is the regression guard for the fault +// boundary: an injected fault is served by the next request to any endpoint, +// including a read, not only a mutating create. +func TestFixtureFaultAppliesToReadRequest(t *testing.T) { + f := NewFixture() + defer f.Close() + f.InjectRateLimited() + + resp, _ := get(t, f.URL()+"/v1/account") + + require.Equal(t, http.StatusTooManyRequests, resp.StatusCode) + require.Equal(t, "2", resp.Header.Get("Retry-After")) +} + +func TestFixtureRedirectFaultIsNotFollowed(t *testing.T) { + f := NewFixture() + defer f.Close() + f.InjectRedirect() + + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + req, err := http.NewRequest(http.MethodPost, f.URL()+"/v1/accounts", strings.NewReader(`{"name":"a"}`)) + require.NoError(t, err) + resp, err := client.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, http.StatusFound, resp.StatusCode) + require.Equal(t, "/v1/accounts", resp.Header.Get("Location")) + require.Equal(t, 0, f.ResourceCount()) +} + +func TestFixtureCursorPaginationHasTwoPages(t *testing.T) { + f := NewFixture() + defer f.Close() + first := f.Seed("codefly") + second := f.Seed("codefly") + + _, page1 := get(t, f.URL()+"/v1/accounts") + next := nextCursor(t, page1) + require.Equal(t, second, next, "the first page points at the second resource") + + _, page2 := get(t, f.URL()+"/v1/accounts?cursor="+next) + require.Empty(t, nextCursor(t, page2), "the second page is the last") + // Sanity: the two pages surface the two distinct resources. + require.Contains(t, string(page1), first) + require.Contains(t, string(page2), second) +} + +func TestFixtureInspectionNeverEchoesValues(t *testing.T) { + f := NewFixture() + defer f.Close() + + // A request carrying a secret-looking header and body value. + postJSON(t, f.URL()+"/v1/accounts", + map[string]string{"Authorization": "Bearer " + PoisonSecret}, + `{"name":"`+PoisonSecret+`"}`) + + _, requests := get(t, f.URL()+"/_inspect/requests") + _, resources := get(t, f.URL()+"/_inspect/resources") + + assertNoPoison(t, requests) + assertNoPoison(t, resources) + + // The projection keeps names, not values. + require.Contains(t, string(requests), "Authorization") + require.Contains(t, string(requests), `"name"`) + require.NotContains(t, string(requests), "Bearer") +} + +func TestFixtureResetClearsState(t *testing.T) { + f := NewFixture() + defer f.Close() + f.Seed("codefly") + postJSON(t, f.URL()+"/v1/accounts", nil, `{"name":"a"}`) + require.Positive(t, f.RequestCount()) + require.Positive(t, f.ResourceCount()) + + get(t, f.URL()+"/_inspect/reset") + + require.Equal(t, 0, f.RequestCount()) + require.Equal(t, 0, f.ResourceCount()) + // The deterministic counters restart. + resp, _ := get(t, f.URL()+"/v1/account") + require.Equal(t, "req-0001", resp.Header.Get("X-Request-Id")) +} + +func idOf(t *testing.T, body []byte) string { + t.Helper() + var decoded struct { + ID string `json:"id"` + } + require.NoError(t, json.Unmarshal(body, &decoded)) + require.NotEmpty(t, decoded.ID) + return decoded.ID +} + +func nextCursor(t *testing.T, body []byte) string { + t.Helper() + var decoded struct { + NextCursor string `json:"next_cursor"` + } + require.NoError(t, json.Unmarshal(body, &decoded)) + return decoded.NextCursor +} + +// assertNoPoison fails if any planted poison value appears in the bytes. +func assertNoPoison(t *testing.T, haystack []byte) { + t.Helper() + for _, needle := range []string{PoisonSecret, PoisonDSN} { + require.False(t, bytes.Contains(haystack, []byte(needle)), + "poison value leaked: %q", needle) + } +} diff --git a/pkg/provider/conformance/golden_test.go b/pkg/provider/conformance/golden_test.go new file mode 100644 index 00000000..a09e105a --- /dev/null +++ b/pkg/provider/conformance/golden_test.go @@ -0,0 +1,95 @@ +package conformance + +import ( + "context" + "encoding/json" + "flag" + "os" + "path/filepath" + "sort" + "testing" + + providerv0 "github.com/codefly-dev/core/generated/go/codefly/services/provider/v0" + "github.com/codefly-dev/core/provider/manifest" + "github.com/stretchr/testify/require" +) + +var update = flag.Bool("update", false, "rewrite golden artifacts") + +// goldenCapture is the sanitized, deterministic projection of a filtered broker +// response. It records what the provider is allowed to see and where secrets +// were routed — never a secret value. +type goldenCapture struct { + Forwarded map[string]string `json:"forwarded"` + Suppressed []string `json:"suppressed_presence"` + Captures []goldenCaptureRef `json:"captures"` +} + +type goldenCaptureRef struct { + Selector string `json:"selector"` + Captured bool `json:"captured"` + Reference string `json:"sink_reference"` +} + +// TestGoldenRequestDescriptorDigests pins the canonical digests of every request +// descriptor and the manifest itself, so a change to the wire contract is a +// reviewed diff. +func TestGoldenRequestDescriptorDigests(t *testing.T) { + h := newBrokerHarness(t) + digests := map[string]string{} + for _, descriptor := range h.manifest.Requests { + digest, err := manifest.RequestDescriptorDigest(descriptor) + require.NoError(t, err) + digests[descriptor.ID] = digest + } + manifestDigest, err := h.manifest.Digest() + require.NoError(t, err) + digests["@manifest"] = manifestDigest + + assertGolden(t, "request_descriptors.json", digests) +} + +// TestGoldenCreateProjection pins the filtered create response as seen by the +// provider, proving the safe projection is stable and secret-free. +func TestGoldenCreateProjection(t *testing.T) { + h := newBrokerHarness(t) + create := h.createRequest(t) + session := h.session(t, createAction(t, create), false) + handle := h.mint(t, create, providerv0.HTTPMethod_HTTP_METHOD_POST) + response, err := session.Execute(context.Background(), h.execute(handle, create)) + require.NoError(t, err) + + capture := goldenCapture{ + Forwarded: forwardedBySelector(response), + Suppressed: response.GetSuppressedPresence(), + } + for _, c := range response.GetCaptures() { + capture.Captures = append(capture.Captures, goldenCaptureRef{ + Selector: c.GetSelector(), + Captured: c.GetCaptured(), + Reference: c.GetSinkReference().GetReference(), + }) + } + sort.Slice(capture.Captures, func(i, j int) bool { return capture.Captures[i].Selector < capture.Captures[j].Selector }) + + assertGolden(t, "create_projection.json", capture) +} + +// assertGolden compares a value against a committed golden file, rewriting it +// under -update. It hard-fails if a poison value ever reaches a golden artifact. +func assertGolden(t *testing.T, name string, value any) { + t.Helper() + path := filepath.Join("testdata", "golden", name) + encoded, err := json.MarshalIndent(value, "", " ") + require.NoError(t, err) + encoded = append(encoded, '\n') + assertNoPoison(t, encoded) + + if *update { + require.NoError(t, os.WriteFile(path, encoded, 0o600)) + return + } + want, err := os.ReadFile(path) + require.NoError(t, err, "missing golden %s; run: go test ./pkg/provider/conformance -update", name) + require.Equal(t, string(want), string(encoded), "golden %s drifted; run -update to review", name) +} diff --git a/pkg/provider/conformance/testdata/golden/create_projection.json b/pkg/provider/conformance/testdata/golden/create_projection.json new file mode 100644 index 00000000..b2b4d73e --- /dev/null +++ b/pkg/provider/conformance/testdata/golden/create_projection.json @@ -0,0 +1,16 @@ +{ + "forwarded": { + "$.id": "acct_0001", + "$.metadata.public": "safe-adjacent" + }, + "suppressed_presence": [ + "$.metadata.internal" + ], + "captures": [ + { + "selector": "$.secret", + "captured": true, + "sink_reference": "capture://bind/account.create/$.secret" + } + ] +} diff --git a/pkg/provider/conformance/testdata/golden/request_descriptors.json b/pkg/provider/conformance/testdata/golden/request_descriptors.json new file mode 100644 index 00000000..57e00813 --- /dev/null +++ b/pkg/provider/conformance/testdata/golden/request_descriptors.json @@ -0,0 +1,6 @@ +{ + "@manifest": "sha256:04a7cd5de79a653d39307cf82f93758a572ed0ed75760641f500c7b778141949", + "account.create": "sha256:02099697c997f51a46e3f9823236009acc12d585edbbe7b7fb9ee595d83f5c24", + "account.delete": "sha256:786bb33108a0d759839a88c3f26b5dc181a8c204f18ed3892e1674e1c7a4432d", + "account.observe": "sha256:a65296d13fafbb5dab78372c8ae6c4583701c5fc138fdaaf1b049f1d5d015e6a" +} diff --git a/pkg/provider/conformance/testdata/provider.codefly.yaml b/pkg/provider/conformance/testdata/provider.codefly.yaml new file mode 100644 index 00000000..b31355ac --- /dev/null +++ b/pkg/provider/conformance/testdata/provider.codefly.yaml @@ -0,0 +1,125 @@ +schema_version: codefly.provider-manifest/v0 +protocol_version: codefly.provider/v0 +state_schema_versions: [1] +agent: + kind: codefly:provider + publisher: codefly.dev + name: conformance + version: 1.0.0 +default_deletion_policy: retain +permissions: + required: + - id: account-create + action: account.manage + resource: "provider:conformance/${workspace}/${environment}/${binding}/account" + resource_type: account + reason: Reconcile the declared account. + risk: high + credential_purpose: management + optional: + - id: account-observe + action: account.observe + resource: "provider:conformance/${workspace}/${environment}/${binding}/account" + resource_type: account + reason: Observe the declared account. + risk: low + credential_purpose: management + - id: account-delete + action: account.delete + resource: "provider:conformance/${workspace}/${environment}/${binding}/account" + resource_type: account + reason: Delete the declared account. + risk: critical + credential_purpose: management +resource_types: + - id: account + actions: [create, update, replace, delete, import, manual, blocked, no-op, project-output, observe] + import_identity: [account_id] + supports_replace: true + supports_delete: true +requests: + - id: account.observe + permissions: [account-observe] + resource_type: account + action: observe + origin_rule: api + operation: observe + method: GET + path_template: /v1/accounts/{account_id} + remote_id_parameters: [account_id] + allowed_query_fields: [expand] + request_byte_budget: 4096 + response_byte_budget: 65536 + read_only: true + response_schema: account + credential_purposes: [management] + - id: account.create + permissions: [account-create] + resource_type: account + action: create + origin_rule: api + operation: create + method: POST + path_template: /v1/accounts + remote_id_parameters: [] + allowed_body_fields: [name, enabled] + ownership_body_fields: [name] + request_byte_budget: 8192 + response_byte_budget: 65536 + read_only: false + response_schema: account + credential_purposes: [management] + - id: account.delete + permissions: [account-delete] + resource_type: account + action: delete + origin_rule: api + operation: delete + method: DELETE + path_template: /v1/accounts/{account_id} + remote_id_parameters: [account_id] + request_byte_budget: 4096 + response_byte_budget: 65536 + read_only: false + response_schema: deleted + credential_purposes: [management] +origin_rules: + - id: api + defaults: [http://localhost:8080] + schemes: [http] + host_patterns: [localhost] + ports: [8080] + binding_override: within-rule + private_network_classes: [loopback] +credential_purposes: + - id: management + minimum_scope: Manage only the bound account. + permitted_consumer: management + - id: runtime + minimum_scope: Use the bound runtime account. + permitted_consumer: runtime +response_schemas: + - id: account + fields: + - selector: {version: v1, path: "$.id"} + disposition: FORWARD_SAFE + - selector: {version: v1, path: "$.metadata.public"} + disposition: FORWARD_SAFE + - selector: {version: v1, path: "$.secret"} + disposition: CAPTURE_TO_SINK + purpose: runtime + - selector: {version: v1, path: "$.metadata.internal"} + disposition: SUPPRESS_REPORT_PRESENCE + - id: deleted + fields: + - selector: {version: v1, path: "$.deleted"} + disposition: FORWARD_SAFE +sandbox: + network: deny +state: + schema_versions: [1] + import_identity: true + replace: true + delete: true + stepwise_upgrade: true +diagnostic_namespace: provider.conformance.