diff --git a/README.md b/README.md index 53d7d5ff..a76803dd 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ DevCloud is an **on-ramp to the cloud**, not a replacement for it. The goal is t ## Features -- **205 AWS services registered, 201 serving at least one operation** — the remaining 4 are routed and decline with a clean AWS error rather than letting the call bill a real account. See [coverage.md](docs/coverage.md) for what the numbers do and do not promise. -- **boto3-compatible** — a 1,144-test suite runs in CI (`make test-compat`) across every registered service. Unsupported operations return a clean AWS error, never a false success. +- **213 AWS services registered, 209 serving at least one operation** — the remaining 4 are routed and decline with a clean AWS error rather than letting the call bill a real account. See [coverage.md](docs/coverage.md) for what the numbers do and do not promise. +- **boto3-compatible** — a 1,156-test suite runs in CI (`make test-compat`) across every registered service. Unsupported operations return a clean AWS error, never a false success. - **Cross-service integration** — CloudFormation provisioning, DynamoDB Streams → Lambda, EventBridge targets, S3 → Lambda - **Smithy-driven codegen** — Go types, routers and error catalogues generated from AWS models, with a weekly sync workflow that keeps them current - **Single binary, zero-config** — one Docker image, one port (4747), no config file required; override with `DEVCLOUD_SERVICES`, `DEVCLOUD_DATA_DIR`, `DEVCLOUD_PORT` diff --git a/changes/unreleased/Added-20260913-140000.yaml b/changes/unreleased/Added-20260913-140000.yaml new file mode 100644 index 00000000..d5c40b1e --- /dev/null +++ b/changes/unreleased/Added-20260913-140000.yaml @@ -0,0 +1,7 @@ +kind: Added +body: 'Eight AWS services whose operations the generic CRUD engine cannot classify + — payment-cryptography-data, geo-routes, cloudsearch-domain, ec2-instance-connect, + kinesis-video-webrtc-storage, marketplace-commerce-analytics, eks-auth and + inspector-scan — now have hand-written providers, bringing coverage to 213 + registered and 209 serving' +Issue: "161" diff --git a/cmd/devcloud/imports.go b/cmd/devcloud/imports.go index 6ab1b691..ff1eda29 100644 --- a/cmd/devcloud/imports.go +++ b/cmd/devcloud/imports.go @@ -34,6 +34,7 @@ import ( _ "github.com/skyoo2003/devcloud/internal/services/cloudfront" _ "github.com/skyoo2003/devcloud/internal/services/cloudhsmv2" _ "github.com/skyoo2003/devcloud/internal/services/cloudsearch" + _ "github.com/skyoo2003/devcloud/internal/services/cloudsearchdomain" _ "github.com/skyoo2003/devcloud/internal/services/cloudtrail" _ "github.com/skyoo2003/devcloud/internal/services/cloudwatch" _ "github.com/skyoo2003/devcloud/internal/services/cloudwatchlogs" @@ -69,10 +70,12 @@ import ( _ "github.com/skyoo2003/devcloud/internal/services/dynamodbstreams" _ "github.com/skyoo2003/devcloud/internal/services/ebs" _ "github.com/skyoo2003/devcloud/internal/services/ec2" + _ "github.com/skyoo2003/devcloud/internal/services/ec2instanceconnect" _ "github.com/skyoo2003/devcloud/internal/services/ecr" _ "github.com/skyoo2003/devcloud/internal/services/ecs" _ "github.com/skyoo2003/devcloud/internal/services/efs" _ "github.com/skyoo2003/devcloud/internal/services/eks" + _ "github.com/skyoo2003/devcloud/internal/services/eksauth" _ "github.com/skyoo2003/devcloud/internal/services/elasticache" _ "github.com/skyoo2003/devcloud/internal/services/elasticbeanstalk" _ "github.com/skyoo2003/devcloud/internal/services/elasticloadbalancing" @@ -89,6 +92,7 @@ import ( _ "github.com/skyoo2003/devcloud/internal/services/forecastquery" _ "github.com/skyoo2003/devcloud/internal/services/frauddetector" _ "github.com/skyoo2003/devcloud/internal/services/fsx" + _ "github.com/skyoo2003/devcloud/internal/services/georoutes" _ "github.com/skyoo2003/devcloud/internal/services/glacier" _ "github.com/skyoo2003/devcloud/internal/services/glue" _ "github.com/skyoo2003/devcloud/internal/services/greengrass" @@ -97,6 +101,7 @@ import ( _ "github.com/skyoo2003/devcloud/internal/services/iam" _ "github.com/skyoo2003/devcloud/internal/services/identitystore" _ "github.com/skyoo2003/devcloud/internal/services/inspector2" + _ "github.com/skyoo2003/devcloud/internal/services/inspectorscan" _ "github.com/skyoo2003/devcloud/internal/services/iot" _ "github.com/skyoo2003/devcloud/internal/services/iotdataplane" _ "github.com/skyoo2003/devcloud/internal/services/iotwireless" @@ -108,6 +113,7 @@ import ( _ "github.com/skyoo2003/devcloud/internal/services/kinesisanalytics" _ "github.com/skyoo2003/devcloud/internal/services/kinesisanalyticsv2" _ "github.com/skyoo2003/devcloud/internal/services/kinesisvideo" + _ "github.com/skyoo2003/devcloud/internal/services/kinesisvideowebrtcstorage" _ "github.com/skyoo2003/devcloud/internal/services/kms" _ "github.com/skyoo2003/devcloud/internal/services/lakeformation" _ "github.com/skyoo2003/devcloud/internal/services/lambda" @@ -118,6 +124,7 @@ import ( _ "github.com/skyoo2003/devcloud/internal/services/lookoutequipment" _ "github.com/skyoo2003/devcloud/internal/services/macie2" _ "github.com/skyoo2003/devcloud/internal/services/managedblockchain" + _ "github.com/skyoo2003/devcloud/internal/services/marketplacecommerceanalytics" _ "github.com/skyoo2003/devcloud/internal/services/mediaconnect" _ "github.com/skyoo2003/devcloud/internal/services/mediaconvert" _ "github.com/skyoo2003/devcloud/internal/services/medialive" @@ -137,6 +144,7 @@ import ( _ "github.com/skyoo2003/devcloud/internal/services/organizations" _ "github.com/skyoo2003/devcloud/internal/services/osis" _ "github.com/skyoo2003/devcloud/internal/services/paymentcryptography" + _ "github.com/skyoo2003/devcloud/internal/services/paymentcryptographydata" _ "github.com/skyoo2003/devcloud/internal/services/personalize" _ "github.com/skyoo2003/devcloud/internal/services/personalizeevents" _ "github.com/skyoo2003/devcloud/internal/services/personalizeruntime" diff --git a/cmd/devcloud/routing_test.go b/cmd/devcloud/routing_test.go new file mode 100644 index 00000000..705fa68f --- /dev/null +++ b/cmd/devcloud/routing_test.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 + +// cmd/devcloud/routing_test.go +package main + +import ( + "net/http/httptest" + "testing" + + "github.com/skyoo2003/devcloud/internal/gateway" + "github.com/stretchr/testify/assert" +) + +// TestContestedDataPlanesResolveToThemselves covers the three Phase 2 services +// that sign with a name an already-registered neighbour claims. Nothing else in +// the tree can catch this: the fidelity manifest is derived from dispatch +// literals, so it reports all three as fully hand-verified whether or not the +// gateway ever routes to them. +// +// It lives here rather than in internal/gateway because this package already +// blank-imports every service and the generated crudregistry, so the tables +// under test are the real ones. +func TestContestedDataPlanesResolveToThemselves(t *testing.T) { + cases := []struct{ name, signingName, method, uri, want string }{ + {"payment_crypto_encrypt", "payment-cryptography", "POST", "/keys/k1/encrypt", "paymentcryptographydata"}, + {"payment_crypto_verify_mac", "payment-cryptography", "POST", "/mac/verify", "paymentcryptographydata"}, + {"cloudsearch_search", "cloudsearch", "GET", "/2013-01-01/search?format=sdk&pretty=true&q=x", "cloudsearchdomain"}, + // botocore converts Search to a POST with a form body, so this — not the + // modelled GET above — is the request a real client sends. Asserting only + // the model's shape is how the gateway came to hand this to cloudsearch. + {"cloudsearch_search_as_boto3_sends_it", "cloudsearch", "POST", "/2013-01-01/search", "cloudsearchdomain"}, + {"cloudsearch_upload", "cloudsearch", "POST", "/2013-01-01/documents/batch?format=sdk", "cloudsearchdomain"}, + {"kvs_join", "kinesisvideo", "POST", "/joinStorageSession", "kinesisvideowebrtcstorage"}, + {"kvs_join_as_viewer", "kinesisvideo", "POST", "/joinStorageSessionAsViewer", "kinesisvideowebrtcstorage"}, + // The parents keep everything they model. A split that stole these would + // be a regression dressed as a fix. + {"kinesisvideo_keeps_its_own", "kinesisvideo", "POST", "/createStream", "kinesisvideo"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + req := httptest.NewRequest(c.method, c.uri, nil) + req.Header.Set("Authorization", + "AWS4-HMAC-SHA256 Credential=AKIA/20130524/us-east-1/"+c.signingName+"/aws4_request, Signature=abc") + proto, service := gateway.DetectProtocol(req) + assert.Equal(t, "rest-json", proto) + assert.Equal(t, c.want, service) + }) + } +} diff --git a/docs/README.md b/docs/README.md index c67099f6..aa29686d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,7 @@ | Page | What it covers | |---|---| -| [Coverage](coverage.md) | 205 registered / 201 serving — what the counts promise, and the target | +| [Coverage](coverage.md) | 213 registered / 209 serving — what the counts promise, and the target | | [Compatibility Policy](compatibility-policy.md) | What v1.0 guarantees across 1.x, what it does not, and how deprecation works | | [Fidelity Manifest](fidelity-manifest.md) | Per-operation tiers: how much to trust any given call | | [CRUD Engine](crud-engine.md) | How engine-served operations behave, and where they stop | diff --git a/docs/compatibility-policy.md b/docs/compatibility-policy.md index 4fbc03f1..a63f33b6 100644 --- a/docs/compatibility-policy.md +++ b/docs/compatibility-policy.md @@ -93,7 +93,7 @@ the operation. `CreateFunction` in `test_lambda.py` shows all three cases at onc | `FunctionArn` | present | presence only — not that it stays ARN-shaped | | `Runtime`, `Handler`, `MemorySize` | not asserted | nothing, though today's response includes them | -That narrowness is the point: it is the promise the repo can actually keep. The suite — 1,144 +That narrowness is the point: it is the promise the repo can actually keep. The suite — 1,156 tests driving real boto3 clients — runs in CI on every push and again against the tagged commit before a release publishes, so breaking an assertion fails the build rather than depending on review discipline. Anything the suite does not assert rests on nothing but intent. Widening the diff --git a/docs/coverage.md b/docs/coverage.md index d5f2ad9b..ce121b62 100644 --- a/docs/coverage.md +++ b/docs/coverage.md @@ -6,19 +6,19 @@ alone. | Number | What it means | Today | |---|---|---| -| **Registered** | The gateway routes the service, so the call reaches DevCloud instead of real AWS. | **205** | -| **Serving ≥1 operation** | At least one operation returns a real, store-backed answer. | **201** | +| **Registered** | The gateway routes the service, so the call reaches DevCloud instead of real AWS. | **213** | +| **Serving ≥1 operation** | At least one operation returns a real, store-backed answer. | **209** | | **Registered-only** | Routed, but every operation declines with a clean AWS error. | **4** | -| **Compatibility-tested** | A boto3 test exercises the service in CI and passes. | **203** | +| **Compatibility-tested** | A boto3 test exercises the service in CI and passes. | **211** | Per operation, from the [fidelity manifest](fidelity-manifest.md): | Tier | Operations | |---|---| -| `hand-verified` | 4,497 | +| `hand-verified` | 4,528 | | `auto-crud` | 5,193 | | `unimplemented` | 2,717 | -| **total known** | **12,407** | +| **total known** | **12,438** | > **The coverage target is 205 services, not 431.** It was 431, and the evidence > did not support it — see [The target](#the-target). @@ -37,8 +37,8 @@ the second still stops it. | Protocol | Services | Operation name comes from | |---|---|---| -| `rest-json` | 93 | HTTP method + path (`internal/shared/httproute`) | -| `json-1.1` | 64 | the `X-Amz-Target` header | +| `rest-json` | 99 | HTTP method + path (`internal/shared/httproute`) | +| `json-1.1` | 66 | the `X-Amz-Target` header | | `json-1.0` | 17 | the `X-Amz-Target` header | | `query` | 15 | the `Action` form field | | `rest-xml` | 4 | HTTP method + path | @@ -64,7 +64,7 @@ when a provider returns `plugin.ErrUnhandledOp`, so a hand-written provider that refuses unknown operations itself (`apigatewayv2`, `xray`) never reaches it. The manifest records this per service as `EngineWired`. -## Why compatibility-tested is 203, not 205 +## Why compatibility-tested is 211, not 213 `tests/compatibility/test_service_smoke.py` parametrises over the generated service list rather than a hand-written one, so a service cannot be registered @@ -90,6 +90,12 @@ All four Lex clients sign as `lex` and none is named `lex`, so: `GET /bots` is siblings — `DeleteBot` at `DELETE /bots/{id}` — and it is refused rather than guessed: deleting the wrong bot is worse than an honest error. +Three data planes are separated the same way without holding a single +CRUD-classifiable operation: `payment-cryptography-data`, `cloudsearch-domain` +and `kinesis-video-webrtc-storage` declare their own route tables through +`crud.RegisterRoutes`, so route matching tells them apart from the neighbour +whose signing name they borrow. No override names a winner — the model does. + ## What counts as a service Upstream publishes 431 model files, and DevCloud counts **model files**, one @@ -245,7 +251,7 @@ re-derive it with `python3 scripts/model_churn.py --upstream`. make codegen # regenerate the manifest from the models make stats # registered services and hand-written operations go test ./cmd/devcloud/ # asserts every number on this page against the binary -make test-compat # the compatibility-tested number, over all 205 services +make test-compat # the compatibility-tested number, over all 213 services ``` Every figure comes from `internal/generated/fidelity/manifest_gen.go` and nothing diff --git a/docs/faq.md b/docs/faq.md index 92397928..297a601d 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -38,7 +38,7 @@ For services with a persistent backend (S3, DynamoDB, Lambda, IAM/STS), yes — ## Compatibility **Will my existing boto3 code work?** -Most apps using the core services (S3, SQS, DynamoDB, Lambda, IAM, STS) and common integration services (SNS, CloudWatch, KMS, Secrets Manager, EventBridge, CloudFormation) work with only an `endpoint_url` change. The 1,144-test boto3 suite runs green in CI on every push; what that does and does not promise is spelled out in [compatibility-policy.md](compatibility-policy.md#wire-behaviour--scoped-to-the-compatibility-suite). +Most apps using the core services (S3, SQS, DynamoDB, Lambda, IAM, STS) and common integration services (SNS, CloudWatch, KMS, Secrets Manager, EventBridge, CloudFormation) work with only an `endpoint_url` change. The 1,156-test boto3 suite runs green in CI on every push; what that does and does not promise is spelled out in [compatibility-policy.md](compatibility-policy.md#wire-behaviour--scoped-to-the-compatibility-suite). **What about Terraform / CDK?** Point the AWS provider or CDK at `http://localhost:4747` with dummy credentials. Common resources (`aws_s3_bucket`, `aws_dynamodb_table`, `aws_lambda_function`) work out of the box. Complex IAM policies and deeply CSP-coupled resources are out of scope. diff --git a/internal/generated/compat/services.json b/internal/generated/compat/services.json index fb5831c9..8444da87 100644 --- a/internal/generated/compat/services.json +++ b/internal/generated/compat/services.json @@ -1617,6 +1617,14 @@ "UpdateServiceAccessPolicies" ] }, + "cloudsearchdomain": { + "protocol": "rest-json", + "servedOps": [ + "Search", + "Suggest", + "UploadDocuments" + ] + }, "cloudtrail": { "protocol": "json-1.1", "servedOps": [ @@ -3509,6 +3517,13 @@ "TerminateInstances" ] }, + "ec2instanceconnect": { + "protocol": "json-1.1", + "servedOps": [ + "SendSSHPublicKey", + "SendSerialConsoleSSHPublicKey" + ] + }, "ecr": { "protocol": "json-1.1", "servedOps": [ @@ -3752,6 +3767,12 @@ "UpdatePodIdentityAssociation" ] }, + "eksauth": { + "protocol": "rest-json", + "servedOps": [ + "AssumeRoleForPodIdentity" + ] + }, "elasticache": { "protocol": "query", "servedOps": [ @@ -4485,6 +4506,16 @@ "UpdateVolume" ] }, + "georoutes": { + "protocol": "rest-json", + "servedOps": [ + "CalculateIsolines", + "CalculateRouteMatrix", + "CalculateRoutes", + "OptimizeWaypoints", + "SnapToRoads" + ] + }, "glacier": { "protocol": "rest-json", "servedOps": [ @@ -5144,6 +5175,12 @@ "UpdateOrganizationConfiguration" ] }, + "inspectorscan": { + "protocol": "rest-json", + "servedOps": [ + "ScanSbom" + ] + }, "iot": { "protocol": "rest-json", "servedOps": [ @@ -5585,6 +5622,13 @@ "UpdateStreamStorageConfiguration" ] }, + "kinesisvideowebrtcstorage": { + "protocol": "rest-json", + "servedOps": [ + "JoinStorageSession", + "JoinStorageSessionAsViewer" + ] + }, "kms": { "protocol": "json-1.1", "servedOps": [ @@ -6040,6 +6084,13 @@ "VoteOnProposal" ] }, + "marketplacecommerceanalytics": { + "protocol": "json-1.1", + "servedOps": [ + "GenerateDataSet", + "StartSupportDataExport" + ] + }, "mediaconnect": { "protocol": "rest-json", "servedOps": [ @@ -7045,6 +7096,26 @@ "UpdateAlias" ] }, + "paymentcryptographydata": { + "protocol": "rest-json", + "servedOps": [ + "DecryptData", + "EncryptData", + "GenerateAs2805KekValidation", + "GenerateAuthRequestCryptogram", + "GenerateCardValidationData", + "GenerateMac", + "GenerateMacEmvPinChange", + "GeneratePinData", + "ReEncryptData", + "TranslateKeyMaterial", + "TranslatePinData", + "VerifyAuthRequestCryptogram", + "VerifyCardValidationData", + "VerifyMac", + "VerifyPinData" + ] + }, "personalize": { "protocol": "json-1.1", "servedOps": [ diff --git a/internal/generated/fidelity/manifest_gen.go b/internal/generated/fidelity/manifest_gen.go index 86672349..d890033f 100644 --- a/internal/generated/fidelity/manifest_gen.go +++ b/internal/generated/fidelity/manifest_gen.go @@ -1950,6 +1950,11 @@ var Services = map[string]Service{ "UpdateScalingParameters": TierHandVerified, "UpdateServiceAccessPolicies": TierHandVerified, }}, + "cloudsearchdomain": {Protocol: "rest-json", ModelBacked: true, EngineWired: true, Operations: map[string]Tier{ + "Search": TierHandVerified, + "Suggest": TierHandVerified, + "UploadDocuments": TierHandVerified, + }}, "cloudtrail": {Protocol: "json-1.1", ModelBacked: true, EngineWired: true, Operations: map[string]Tier{ "AddTags": TierHandVerified, "CancelQuery": TierHandVerified, @@ -4755,6 +4760,10 @@ var Services = map[string]Service{ "UpdateSecurityGroupRuleDescriptionsIngress": TierUnimplemented, "WithdrawByoipCidr": TierUnimplemented, }}, + "ec2instanceconnect": {Protocol: "json-1.1", ModelBacked: true, EngineWired: true, Operations: map[string]Tier{ + "SendSSHPublicKey": TierHandVerified, + "SendSerialConsoleSSHPublicKey": TierHandVerified, + }}, "ecr": {Protocol: "json-1.1", ModelBacked: true, EngineWired: true, Operations: map[string]Tier{ "BatchCheckLayerAvailability": TierHandVerified, "BatchDeleteImage": TierHandVerified, @@ -4998,6 +5007,9 @@ var Services = map[string]Service{ "UpdateNodegroupVersion": TierHandVerified, "UpdatePodIdentityAssociation": TierHandVerified, }}, + "eksauth": {Protocol: "rest-json", ModelBacked: true, EngineWired: true, Operations: map[string]Tier{ + "AssumeRoleForPodIdentity": TierHandVerified, + }}, "elasticache": {Protocol: "query", ModelBacked: true, EngineWired: true, Operations: map[string]Tier{ "AddTagsToResource": TierHandVerified, "AuthorizeCacheSecurityGroupIngress": TierHandVerified, @@ -5744,6 +5756,13 @@ var Services = map[string]Service{ "UpdateStorageVirtualMachine": TierAutoCRUD, "UpdateVolume": TierAutoCRUD, }}, + "georoutes": {Protocol: "rest-json", ModelBacked: true, EngineWired: true, Operations: map[string]Tier{ + "CalculateIsolines": TierHandVerified, + "CalculateRouteMatrix": TierHandVerified, + "CalculateRoutes": TierHandVerified, + "OptimizeWaypoints": TierHandVerified, + "SnapToRoads": TierHandVerified, + }}, "glacier": {Protocol: "rest-json", ModelBacked: true, EngineWired: false, Operations: map[string]Tier{ "AbortMultipartUpload": TierHandVerified, "AbortVaultLock": TierHandVerified, @@ -6572,6 +6591,9 @@ var Services = map[string]Service{ "UpdateOrgEc2DeepInspectionConfiguration": TierAutoCRUD, "UpdateOrganizationConfiguration": TierAutoCRUD, }}, + "inspectorscan": {Protocol: "rest-json", ModelBacked: true, EngineWired: true, Operations: map[string]Tier{ + "ScanSbom": TierHandVerified, + }}, "iot": {Protocol: "rest-json", ModelBacked: true, EngineWired: false, Operations: map[string]Tier{ "AcceptCertificateTransfer": TierUnimplemented, "AddThingToBillingGroup": TierUnimplemented, @@ -7285,6 +7307,10 @@ var Services = map[string]Service{ "UpdateStream": TierAutoCRUD, "UpdateStreamStorageConfiguration": TierAutoCRUD, }}, + "kinesisvideowebrtcstorage": {Protocol: "rest-json", ModelBacked: true, EngineWired: true, Operations: map[string]Tier{ + "JoinStorageSession": TierHandVerified, + "JoinStorageSessionAsViewer": TierHandVerified, + }}, "kms": {Protocol: "json-1.1", ModelBacked: true, EngineWired: true, Operations: map[string]Tier{ "CancelKeyDeletion": TierHandVerified, "ConnectCustomKeyStore": TierUnimplemented, @@ -7822,6 +7848,10 @@ var Services = map[string]Service{ "UpdateNode": TierHandVerified, "VoteOnProposal": TierHandVerified, }}, + "marketplacecommerceanalytics": {Protocol: "json-1.1", ModelBacked: true, EngineWired: true, Operations: map[string]Tier{ + "GenerateDataSet": TierHandVerified, + "StartSupportDataExport": TierHandVerified, + }}, "mediaconnect": {Protocol: "rest-json", ModelBacked: true, EngineWired: true, Operations: map[string]Tier{ "AddBridgeOutputs": TierUnimplemented, "AddBridgeSources": TierUnimplemented, @@ -8885,6 +8915,23 @@ var Services = map[string]Service{ "UntagResource": TierAutoCRUD, "UpdateAlias": TierAutoCRUD, }}, + "paymentcryptographydata": {Protocol: "rest-json", ModelBacked: true, EngineWired: true, Operations: map[string]Tier{ + "DecryptData": TierHandVerified, + "EncryptData": TierHandVerified, + "GenerateAs2805KekValidation": TierHandVerified, + "GenerateAuthRequestCryptogram": TierHandVerified, + "GenerateCardValidationData": TierHandVerified, + "GenerateMac": TierHandVerified, + "GenerateMacEmvPinChange": TierHandVerified, + "GeneratePinData": TierHandVerified, + "ReEncryptData": TierHandVerified, + "TranslateKeyMaterial": TierHandVerified, + "TranslatePinData": TierHandVerified, + "VerifyAuthRequestCryptogram": TierHandVerified, + "VerifyCardValidationData": TierHandVerified, + "VerifyMac": TierHandVerified, + "VerifyPinData": TierHandVerified, + }}, "personalize": {Protocol: "json-1.1", ModelBacked: true, EngineWired: true, Operations: map[string]Tier{ "CreateBatchInferenceJob": TierAutoCRUD, "CreateBatchSegmentJob": TierAutoCRUD, diff --git a/internal/services/cloudsearchdomain/provider.go b/internal/services/cloudsearchdomain/provider.go new file mode 100644 index 00000000..db6e7e79 --- /dev/null +++ b/internal/services/cloudsearchdomain/provider.go @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cloudsearchdomain + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "path/filepath" + "strings" + + generated "github.com/skyoo2003/devcloud/internal/generated/cloudsearchdomain" + "github.com/skyoo2003/devcloud/internal/plugin" + "github.com/skyoo2003/devcloud/internal/shared" + "github.com/skyoo2003/devcloud/internal/shared/crud" + "github.com/skyoo2003/devcloud/internal/shared/httproute" +) + +const formContentType = "application/x-www-form-urlencoded" + +// declaredRoutes is the model's table plus the one route botocore invents. +// +// The SDK does not send Search as the model declares it: botocore carries a +// customization for this client that turns +// GET /2013-01-01/search?format=sdk&pretty=true into a POST carrying those same +// terms in a form body, so a query too long for a URL still goes out. The +// generated table is derived from the Smithy model and so cannot describe that +// request — which is why it is extended here rather than regenerated. +// +// Both the provider's own resolution and the gateway's shared-signing-name split +// read this table, so the two cannot disagree about which requests this service +// claims. +var declaredRoutes = append( + append([]httproute.Route{}, generated.OperationRoutes...), + httproute.Route{Method: "POST", Pattern: "/2013-01-01/search", Operation: "Search"}, +) + +// Provider implements the AmazonCloudSearch2013 service. +type Provider struct { + generated.BaseProvider + store *Store +} + +func (p *Provider) ServiceID() string { return "cloudsearchdomain" } +func (p *Provider) ServiceName() string { return "AmazonCloudSearch2013" } +func (p *Provider) Protocol() plugin.ProtocolType { return plugin.ProtocolRESTJSON } + +func (p *Provider) Init(cfg plugin.PluginConfig) error { + dataDir := cfg.DataDir + if dataDir == "" { + dataDir = "." + } + var err error + p.store, err = NewStore(filepath.Join(dataDir, "cloudsearchdomain")) + return err +} + +func (p *Provider) Shutdown(_ context.Context) error { + if p.store != nil { + return p.store.Close() + } + return nil +} + +// batchEntry is one element of the SDK's document batch: an add carries fields, +// a delete carries only the id. +type batchEntry struct { + Type string `json:"type"` + ID string `json:"id"` + Fields json.RawMessage `json:"fields"` +} + +func (p *Provider) HandleRequest(_ context.Context, op string, req *http.Request) (*plugin.Response, error) { + if op == "" { + // RequestURI, not Path: the modelled routes are distinguished partly by + // their query string. + op, _ = httproute.Match(declaredRoutes, req.Method, req.URL.RequestURI()) + } + body, err := io.ReadAll(req.Body) + if err != nil { + return shared.JSONError("SerializationException", "failed to read body", http.StatusBadRequest), nil + } + q := searchTerm(req, body) + + switch op { + case "UploadDocuments": + return p.uploadDocuments(body) + + case "Search": + return p.search(q) + + case "Suggest": + return p.suggest(q) + + default: + return nil, plugin.ErrUnhandledOp + } +} + +func (p *Provider) uploadDocuments(body []byte) (*plugin.Response, error) { + var batch []batchEntry + if err := json.Unmarshal(body, &batch); err != nil { + return shared.JSONError("DocumentServiceException", "invalid document batch", http.StatusBadRequest), nil + } + + adds, deletes := 0, 0 + for _, e := range batch { + switch e.Type { + case "add": + fields := "{}" + if len(e.Fields) > 0 { + fields = string(e.Fields) + } + if err := p.store.Upsert(e.ID, fields); err != nil { + return nil, err + } + adds++ + case "delete": + if err := p.store.Delete(e.ID); err != nil { + return nil, err + } + deletes++ + } + } + return shared.JSONResponse(http.StatusOK, map[string]any{ + "status": "success", + "adds": adds, + "deletes": deletes, + "warnings": []any{}, + }) +} + +func (p *Provider) search(q string) (*plugin.Response, error) { + docs, err := p.matching(q) + if err != nil { + return nil, err + } + hits := make([]map[string]any, 0, len(docs)) + for _, d := range docs { + hits = append(hits, map[string]any{ + "id": d.ID, + "fields": decodeFields(d.Fields), + "exprs": map[string]any{}, + "highlights": map[string]any{}, + }) + } + return shared.JSONResponse(http.StatusOK, map[string]any{ + "status": searchStatus(), + // found is a long in the model; a string here breaks botocore's parse. + "hits": map[string]any{"found": len(hits), "start": 0, "cursor": "", "hit": hits}, + "facets": map[string]any{}, + "stats": map[string]any{}, + }) +} + +func (p *Provider) suggest(q string) (*plugin.Response, error) { + docs, err := p.matching(q) + if err != nil { + return nil, err + } + suggestions := make([]map[string]any, 0, len(docs)) + for _, d := range docs { + suggestions = append(suggestions, map[string]any{ + "id": d.ID, + "score": 0, + "suggestion": d.ID, + }) + } + return shared.JSONResponse(http.StatusOK, map[string]any{ + "status": searchStatus(), + "suggest": map[string]any{ + "query": q, + "found": len(suggestions), + "suggestions": suggestions, + }, + }) +} + +// matching returns the documents whose stored fields contain q. An empty q +// matches everything, which is what a bare "*" query means to a real domain. +// +// ponytail: substring match over every stored document. An inverted index is +// what a real query parser needs; swap this out if anyone asks for relevance. +func (p *Provider) matching(q string) ([]Document, error) { + docs, err := p.store.All() + if err != nil { + return nil, err + } + if q == "" { + return docs, nil + } + out := make([]Document, 0, len(docs)) + for _, d := range docs { + if strings.Contains(d.Fields, q) || strings.Contains(d.ID, q) { + out = append(out, d) + } + } + return out, nil +} + +// searchTerm reads the query term from wherever this request carries it: the +// query string for the modelled GET, the form body for the POST botocore +// rewrites Search into. +func searchTerm(req *http.Request, body []byte) string { + if q := req.URL.Query().Get("q"); q != "" { + return q + } + if len(body) == 0 || !strings.HasPrefix(req.Header.Get("Content-Type"), formContentType) { + return "" + } + // ParseQuery returns what it could parse alongside any error, so a malformed + // tail cannot discard the term before it. + form, _ := url.ParseQuery(string(body)) + return form.Get("q") +} + +func searchStatus() map[string]any { + return map[string]any{"timems": 0, "rid": shared.GenerateID("", 24)} +} + +func decodeFields(raw string) map[string]any { + var fields map[string]any + if err := json.Unmarshal([]byte(raw), &fields); err != nil || fields == nil { + return map[string]any{} + } + return fields +} + +func (p *Provider) ListResources(_ context.Context) ([]plugin.Resource, error) { + return []plugin.Resource{}, nil +} + +func init() { + plugin.DefaultRegistry.Register("cloudsearchdomain", func() plugin.ServicePlugin { + return &Provider{} + }) + // This service signs as "cloudsearch", which the query-protocol control + // plane claims and which models none of these paths. Without this the + // gateway's shared-signing-name split has no candidate and cloudsearch keeps + // the request. See crud.RegisterRoutes. + crud.RegisterRoutes("cloudsearchdomain", declaredRoutes) +} diff --git a/internal/services/cloudsearchdomain/provider_test.go b/internal/services/cloudsearchdomain/provider_test.go new file mode 100644 index 00000000..5e07da7f --- /dev/null +++ b/internal/services/cloudsearchdomain/provider_test.go @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: Apache-2.0 + +// internal/services/cloudsearchdomain/provider_test.go +package cloudsearchdomain + +import ( + "context" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/skyoo2003/devcloud/internal/plugin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The three routes are distinguished partly by their query string, so the test +// URIs carry the same terms a real SDK sends. +const ( + searchURI = "/2013-01-01/search?format=sdk&pretty=true" + suggestURI = "/2013-01-01/suggest?format=sdk&pretty=true" + uploadURI = "/2013-01-01/documents/batch?format=sdk" +) + +func newTestProvider(t *testing.T) *Provider { + t.Helper() + p := &Provider{} + require.NoError(t, p.Init(plugin.PluginConfig{DataDir: t.TempDir()})) + t.Cleanup(func() { _ = p.Shutdown(context.Background()) }) + return p +} + +func callREST(t *testing.T, p *Provider, method, path, op, body string) *plugin.Response { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := p.HandleRequest(context.Background(), op, req) + require.NoError(t, err) + return resp +} + +func parseBody(t *testing.T, resp *plugin.Response) map[string]any { + t.Helper() + var m map[string]any + require.NoError(t, json.Unmarshal(resp.Body, &m)) + return m +} + +func hits(t *testing.T, resp *plugin.Response) map[string]any { + t.Helper() + h, ok := parseBody(t, resp)["hits"].(map[string]any) + require.True(t, ok) + return h +} + +func TestUploadThenSearch(t *testing.T) { + p := newTestProvider(t) + + resp := callREST(t, p, "POST", uploadURI, "", + `[{"type":"add","id":"doc1","fields":{"title":"alpha release"}}]`) + assert.Equal(t, 200, resp.StatusCode) + rb := parseBody(t, resp) + assert.Equal(t, "success", rb["status"]) + assert.Equal(t, float64(1), rb["adds"]) + assert.Equal(t, float64(0), rb["deletes"]) + assert.Equal(t, []any{}, rb["warnings"]) + + found := callREST(t, p, "GET", searchURI+"&q=alpha", "", "") + assert.Equal(t, 200, found.StatusCode) + h := hits(t, found) + // found is a number, not a string: botocore types it as a long. + assert.Equal(t, float64(1), h["found"]) + hit := h["hit"].([]any)[0].(map[string]any) + assert.Equal(t, "doc1", hit["id"]) + assert.Equal(t, "alpha release", hit["fields"].(map[string]any)["title"]) + assert.Contains(t, parseBody(t, found), "facets") + assert.Contains(t, parseBody(t, found), "stats") +} + +func TestSearchWithNoMatch(t *testing.T) { + p := newTestProvider(t) + + callREST(t, p, "POST", uploadURI, "", `[{"type":"add","id":"doc1","fields":{"title":"alpha"}}]`) + + resp := callREST(t, p, "GET", searchURI+"&q=nothinglikethis", "", "") + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, float64(0), hits(t, resp)["found"]) +} + +// A search with no q at all is the smoke suite's shape, and must still answer. +func TestSearchWithoutQueryTerm(t *testing.T) { + p := newTestProvider(t) + + callREST(t, p, "POST", uploadURI, "", `[{"type":"add","id":"doc1","fields":{"title":"alpha"}}]`) + + resp := callREST(t, p, "GET", searchURI, "", "") + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, float64(1), hits(t, resp)["found"]) +} + +func TestUploadDeleteRemovesTheDocument(t *testing.T) { + p := newTestProvider(t) + + callREST(t, p, "POST", uploadURI, "", `[{"type":"add","id":"doc1","fields":{"title":"alpha"}}]`) + + resp := callREST(t, p, "POST", uploadURI, "", `[{"type":"delete","id":"doc1"}]`) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, float64(1), parseBody(t, resp)["deletes"]) + + assert.Equal(t, float64(0), hits(t, callREST(t, p, "GET", searchURI+"&q=alpha", "", ""))["found"]) +} + +func TestUploadInvalidBatch(t *testing.T) { + p := newTestProvider(t) + + resp := callREST(t, p, "POST", uploadURI, "", `{"type":"add"}`) + assert.Equal(t, 400, resp.StatusCode) + assert.Equal(t, "DocumentServiceException", parseBody(t, resp)["__type"]) +} + +func TestSuggest(t *testing.T) { + p := newTestProvider(t) + + callREST(t, p, "POST", uploadURI, "", `[{"type":"add","id":"alpha1","fields":{"title":"alpha"}}]`) + + resp := callREST(t, p, "GET", suggestURI+"&q=alpha", "", "") + assert.Equal(t, 200, resp.StatusCode) + + s, ok := parseBody(t, resp)["suggest"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "alpha", s["query"]) + assert.Equal(t, float64(1), s["found"]) + assert.Equal(t, "alpha1", s["suggestions"].([]any)[0].(map[string]any)["id"]) +} + +// TestSearchOverPostForm covers what boto3 actually sends. botocore carries a +// customization for this client that converts Search from the modelled +// GET /2013-01-01/search?format=sdk&pretty=true into a POST with the same terms +// in an x-www-form-urlencoded body. A provider that knows only the modelled +// route answers nothing a real client can reach. +func TestSearchOverPostForm(t *testing.T) { + p := newTestProvider(t) + callREST(t, p, "POST", uploadURI, "", `[{"type":"add","id":"doc1","fields":{"title":"alpha"}}]`) + + req := httptest.NewRequest("POST", "/2013-01-01/search", + strings.NewReader("format=sdk&pretty=true&q=alpha")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := p.HandleRequest(context.Background(), "", req) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) + + // q came from the form body, not the query string. + assert.Equal(t, float64(1), hits(t, resp)["found"]) +} + +// TestSearchOverPostFormWithNoMatch proves q is really read from the body: a +// term that matches nothing must find nothing, rather than falling through to +// the empty-q "match everything" rule. +func TestSearchOverPostFormWithNoMatch(t *testing.T) { + p := newTestProvider(t) + callREST(t, p, "POST", uploadURI, "", `[{"type":"add","id":"doc1","fields":{"title":"alpha"}}]`) + + req := httptest.NewRequest("POST", "/2013-01-01/search", + strings.NewReader("format=sdk&pretty=true&q=nothinglikethis")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := p.HandleRequest(context.Background(), "", req) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) + assert.Equal(t, float64(0), hits(t, resp)["found"]) +} + +// TestProviderIdentity pins what the registry and the fidelity manifest read. +// A typo in ServiceName is invisible everywhere else. +func TestProviderIdentity(t *testing.T) { + p := newTestProvider(t) + assert.Equal(t, "cloudsearchdomain", p.ServiceID()) + assert.Equal(t, "AmazonCloudSearch2013", p.ServiceName()) + assert.Equal(t, plugin.ProtocolRESTJSON, p.Protocol()) + + resources, err := p.ListResources(context.Background()) + require.NoError(t, err) + assert.Empty(t, resources) +} + +func TestUnknownOperationIsUnhandled(t *testing.T) { + p := newTestProvider(t) + req := httptest.NewRequest("GET", "/nope", strings.NewReader("")) + _, err := p.HandleRequest(context.Background(), "NoSuchThing", req) + assert.ErrorIs(t, err, plugin.ErrUnhandledOp) +} diff --git a/internal/services/cloudsearchdomain/store.go b/internal/services/cloudsearchdomain/store.go new file mode 100644 index 00000000..68ce80b4 --- /dev/null +++ b/internal/services/cloudsearchdomain/store.go @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 + +// internal/services/cloudsearchdomain/store.go +package cloudsearchdomain + +import ( + "path/filepath" + "time" + + "github.com/skyoo2003/devcloud/internal/storage/sqlite" +) + +var migrations = []sqlite.Migration{ + {Version: 1, SQL: ` + CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, + fields TEXT NOT NULL DEFAULT '{}', + updated_at INTEGER NOT NULL + ); + `}, +} + +// Document is one indexed document. Fields is kept as the raw JSON the caller +// uploaded, because the search here is a substring match over exactly that text. +type Document struct { + ID string + Fields string +} + +type Store struct { + store *sqlite.Store +} + +func NewStore(dataDir string) (*Store, error) { + dbPath := filepath.Join(dataDir, "cloudsearchdomain.db") + s, err := sqlite.Open(dbPath, migrations) + if err != nil { + return nil, err + } + return &Store{store: s}, nil +} + +func (s *Store) Close() error { return s.store.Close() } + +func (s *Store) Upsert(id, fields string) error { + _, err := s.store.DB().Exec(` + INSERT INTO documents (id, fields, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + fields = excluded.fields, + updated_at = excluded.updated_at`, + id, fields, time.Now().Unix(), + ) + return err +} + +// Delete removes a document. A missing id is not an error: the batch format +// counts deletes, it does not report which ones matched. +func (s *Store) Delete(id string) error { + _, err := s.store.DB().Exec(`DELETE FROM documents WHERE id = ?`, id) + return err +} + +func (s *Store) All() ([]Document, error) { + rows, err := s.store.DB().Query(`SELECT id, fields FROM documents ORDER BY id`) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + docs := []Document{} + for rows.Next() { + var d Document + if err := rows.Scan(&d.ID, &d.Fields); err != nil { + return nil, err + } + docs = append(docs, d) + } + return docs, rows.Err() +} diff --git a/internal/services/ec2instanceconnect/provider.go b/internal/services/ec2instanceconnect/provider.go new file mode 100644 index 00000000..cd2e2f44 --- /dev/null +++ b/internal/services/ec2instanceconnect/provider.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 + +package ec2instanceconnect + +import ( + "context" + "net/http" + + generated "github.com/skyoo2003/devcloud/internal/generated/ec2instanceconnect" + "github.com/skyoo2003/devcloud/internal/plugin" + "github.com/skyoo2003/devcloud/internal/shared" +) + +// Provider implements the AWSEC2InstanceConnectService service. +type Provider struct { + generated.BaseProvider +} + +func (p *Provider) ServiceID() string { return "ec2instanceconnect" } +func (p *Provider) ServiceName() string { return "AWSEC2InstanceConnectService" } +func (p *Provider) Protocol() plugin.ProtocolType { return plugin.ProtocolJSON11 } + +func (p *Provider) Init(_ plugin.PluginConfig) error { return nil } + +func (p *Provider) Shutdown(_ context.Context) error { return nil } + +// HandleRequest serves both operations by hand. Neither carries a CRUD verb +// prefix, so the generic engine holds no metadata for this service and would +// decline every call. +func (p *Provider) HandleRequest(_ context.Context, op string, _ *http.Request) (*plugin.Response, error) { + switch op { + // AWS answers both with a request id and a bare success flag; there is no + // instance to reach and no key to install, so the flag is the whole answer. + case "SendSSHPublicKey", "SendSerialConsoleSSHPublicKey": + return shared.JSONResponse(http.StatusOK, map[string]any{ + "RequestId": shared.GenerateUUID(), + "Success": true, + }) + + default: + return nil, plugin.ErrUnhandledOp + } +} + +func (p *Provider) ListResources(_ context.Context) ([]plugin.Resource, error) { + return []plugin.Resource{}, nil +} + +func init() { + plugin.DefaultRegistry.Register("ec2instanceconnect", func() plugin.ServicePlugin { + return &Provider{} + }) +} diff --git a/internal/services/ec2instanceconnect/provider_test.go b/internal/services/ec2instanceconnect/provider_test.go new file mode 100644 index 00000000..0c8d5d7b --- /dev/null +++ b/internal/services/ec2instanceconnect/provider_test.go @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 + +// internal/services/ec2instanceconnect/provider_test.go +package ec2instanceconnect + +import ( + "context" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/skyoo2003/devcloud/internal/plugin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestProvider(t *testing.T) *Provider { + t.Helper() + p := &Provider{} + require.NoError(t, p.Init(plugin.PluginConfig{DataDir: t.TempDir()})) + t.Cleanup(func() { _ = p.Shutdown(context.Background()) }) + return p +} + +// callJSON mirrors how the gateway calls a json-1.1 provider: the operation +// comes from X-Amz-Target, so it is passed in rather than resolved from a path. +func callJSON(t *testing.T, p *Provider, op, body string) *plugin.Response { + t.Helper() + req := httptest.NewRequest("POST", "/", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/x-amz-json-1.1") + resp, err := p.HandleRequest(context.Background(), op, req) + require.NoError(t, err) + return resp +} + +func parseBody(t *testing.T, resp *plugin.Response) map[string]any { + t.Helper() + var m map[string]any + require.NoError(t, json.Unmarshal(resp.Body, &m)) + return m +} + +func TestSendPublicKeyOperations(t *testing.T) { + p := newTestProvider(t) + + for _, op := range []string{"SendSSHPublicKey", "SendSerialConsoleSSHPublicKey"} { + t.Run(op, func(t *testing.T) { + resp := callJSON(t, p, op, `{"InstanceId":"i-1234567890abcdef0","SSHPublicKey":"ssh-rsa AAAA"}`) + assert.Equal(t, 200, resp.StatusCode) + + rb := parseBody(t, resp) + // PascalCase on the wire, verified against the model. + assert.NotEmpty(t, rb["RequestId"]) + assert.Equal(t, true, rb["Success"]) + }) + } +} + +// TestProviderIdentity pins what the registry and the fidelity manifest read. +// A typo in ServiceName is invisible everywhere else. +func TestProviderIdentity(t *testing.T) { + p := newTestProvider(t) + assert.Equal(t, "ec2instanceconnect", p.ServiceID()) + assert.Equal(t, "AWSEC2InstanceConnectService", p.ServiceName()) + assert.Equal(t, plugin.ProtocolJSON11, p.Protocol()) + + resources, err := p.ListResources(context.Background()) + require.NoError(t, err) + assert.Empty(t, resources) +} + +func TestUnknownOperationIsUnhandled(t *testing.T) { + p := newTestProvider(t) + req := httptest.NewRequest("POST", "/", strings.NewReader("{}")) + _, err := p.HandleRequest(context.Background(), "NoSuchThing", req) + assert.ErrorIs(t, err, plugin.ErrUnhandledOp) +} diff --git a/internal/services/eksauth/provider.go b/internal/services/eksauth/provider.go new file mode 100644 index 00000000..c68f9b84 --- /dev/null +++ b/internal/services/eksauth/provider.go @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Apache-2.0 + +package eksauth + +import ( + "context" + "net/http" + "time" + + generated "github.com/skyoo2003/devcloud/internal/generated/eksauth" + "github.com/skyoo2003/devcloud/internal/plugin" + "github.com/skyoo2003/devcloud/internal/shared" + "github.com/skyoo2003/devcloud/internal/shared/crud" +) + +// Provider implements the EKSAuthFrontend service. +type Provider struct { + generated.BaseProvider +} + +func (p *Provider) ServiceID() string { return "eksauth" } +func (p *Provider) ServiceName() string { return "EKSAuthFrontend" } +func (p *Provider) Protocol() plugin.ProtocolType { return plugin.ProtocolRESTJSON } + +func (p *Provider) Init(_ plugin.PluginConfig) error { return nil } + +func (p *Provider) Shutdown(_ context.Context) error { return nil } + +func (p *Provider) HandleRequest(_ context.Context, op string, req *http.Request) (*plugin.Response, error) { + // rest-json carries the operation in the method and path, and + // gateway.extractOperationName returns "" for every REST protocol, so the + // provider resolves it from the generated table. The match is run + // regardless, because the path labels are needed either way. + matched, params := generated.MatchOperation(req.Method, req.URL.RequestURI()) + if op == "" { + op = matched + } + + switch op { + case "AssumeRoleForPodIdentity": + return assumeRoleForPodIdentity(params["clusterName"]) + + default: + return nil, plugin.ErrUnhandledOp + } +} + +// assumeRoleForPodIdentity mints a credential set for a pod identity +// association. DevCloud signs nothing: the credentials are well-formed and +// short-lived so an SDK's own validation is satisfied, and they authorize +// nothing outside this process. +func assumeRoleForPodIdentity(clusterName string) (*plugin.Response, error) { + assocID := shared.GenerateID("a-", 19) + assumeRoleID := shared.GenerateID("AROA", 21) + now := time.Now().UTC() + + return shared.JSONResponse(http.StatusOK, map[string]any{ + "subject": map[string]any{"namespace": "default", "serviceAccount": "default"}, + "audience": "pods.eks.amazonaws.com", + "podIdentityAssociation": map[string]any{ + "associationArn": shared.BuildARN("eks", "podidentityassociation/"+clusterName, assocID), + "associationId": assocID, + }, + "assumedRoleUser": map[string]any{ + "arn": shared.BuildARN("sts", "assumed-role/devcloud-pod-identity", "devcloud"), + "assumeRoleId": assumeRoleID, + }, + "credentials": map[string]any{ + "accessKeyId": "ASIADEVCLOUDPODIDENTITY", + "secretAccessKey": shared.GenerateID("", 40), + "sessionToken": shared.GenerateID("", 64), + "expiration": now.Add(15 * time.Minute).Format(time.RFC3339), + }, + }) +} + +func (p *Provider) ListResources(_ context.Context) ([]plugin.Resource, error) { + return []plugin.Resource{}, nil +} + +func init() { + plugin.DefaultRegistry.Register("eksauth", func() plugin.ServicePlugin { + return &Provider{} + }) + // The signing name is unique, so routing does not need this — but every + // hand-written rest-json provider in this set states its routes the same + // way, and crud.Route is documented as a fallback a path resolver can end + // with. + crud.RegisterRoutes("eksauth", generated.OperationRoutes) +} diff --git a/internal/services/eksauth/provider_test.go b/internal/services/eksauth/provider_test.go new file mode 100644 index 00000000..a7b00288 --- /dev/null +++ b/internal/services/eksauth/provider_test.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 + +// internal/services/eksauth/provider_test.go +package eksauth + +import ( + "context" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/skyoo2003/devcloud/internal/plugin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestProvider(t *testing.T) *Provider { + t.Helper() + p := &Provider{} + require.NoError(t, p.Init(plugin.PluginConfig{DataDir: t.TempDir()})) + t.Cleanup(func() { _ = p.Shutdown(context.Background()) }) + return p +} + +func callREST(t *testing.T, p *Provider, method, path, op, body string) *plugin.Response { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := p.HandleRequest(context.Background(), op, req) + require.NoError(t, err) + return resp +} + +func parseBody(t *testing.T, resp *plugin.Response) map[string]any { + t.Helper() + var m map[string]any + require.NoError(t, json.Unmarshal(resp.Body, &m)) + return m +} + +func TestAssumeRoleForPodIdentity(t *testing.T) { + p := newTestProvider(t) + + // op is resolved from the path, the way the gateway calls a rest-json + // provider. + resp := callREST(t, p, "POST", "/clusters/my-cluster/assume-role-for-pod-identity", "", `{"token":"jwt"}`) + assert.Equal(t, 200, resp.StatusCode) + + rb := parseBody(t, resp) + assert.Equal(t, "pods.eks.amazonaws.com", rb["audience"]) + require.Contains(t, rb, "subject") + require.Contains(t, rb, "assumedRoleUser") + + assoc, ok := rb["podIdentityAssociation"].(map[string]any) + require.True(t, ok) + assert.Contains(t, assoc["associationArn"], "my-cluster") + assert.NotEmpty(t, assoc["associationId"]) + + creds, ok := rb["credentials"].(map[string]any) + require.True(t, ok) + for _, k := range []string{"accessKeyId", "secretAccessKey", "sessionToken", "expiration"} { + assert.NotEmpty(t, creds[k], k) + } +} + +// TestProviderIdentity pins what the registry and the fidelity manifest read. +// A typo in ServiceName is invisible everywhere else. +func TestProviderIdentity(t *testing.T) { + p := newTestProvider(t) + assert.Equal(t, "eksauth", p.ServiceID()) + assert.Equal(t, "EKSAuthFrontend", p.ServiceName()) + assert.Equal(t, plugin.ProtocolRESTJSON, p.Protocol()) + + resources, err := p.ListResources(context.Background()) + require.NoError(t, err) + assert.Empty(t, resources) +} + +func TestUnknownOperationIsUnhandled(t *testing.T) { + p := newTestProvider(t) + req := httptest.NewRequest("POST", "/nope", strings.NewReader("{}")) + _, err := p.HandleRequest(context.Background(), "NoSuchThing", req) + assert.ErrorIs(t, err, plugin.ErrUnhandledOp) +} diff --git a/internal/services/georoutes/provider.go b/internal/services/georoutes/provider.go new file mode 100644 index 00000000..b0aef47f --- /dev/null +++ b/internal/services/georoutes/provider.go @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 + +package georoutes + +import ( + "context" + "net/http" + + generated "github.com/skyoo2003/devcloud/internal/generated/georoutes" + "github.com/skyoo2003/devcloud/internal/plugin" + "github.com/skyoo2003/devcloud/internal/shared" + "github.com/skyoo2003/devcloud/internal/shared/crud" +) + +// Provider implements the RoutesService service. +type Provider struct { + generated.BaseProvider +} + +func (p *Provider) ServiceID() string { return "georoutes" } +func (p *Provider) ServiceName() string { return "RoutesService" } +func (p *Provider) Protocol() plugin.ProtocolType { return plugin.ProtocolRESTJSON } + +func (p *Provider) Init(_ plugin.PluginConfig) error { return nil } + +func (p *Provider) Shutdown(_ context.Context) error { return nil } + +// HandleRequest answers every operation with the smallest well-formed shape the +// model declares, all members PascalCase on the wire. +// +// ponytail: empty result sets; a real router needs a road graph DevCloud does +// not ship. A caller reading 200 with "Routes": [] is being told there is no +// route engine here, not that no route exists. +func (p *Provider) HandleRequest(_ context.Context, op string, req *http.Request) (*plugin.Response, error) { + if op == "" { + op, _ = generated.MatchOperation(req.Method, req.URL.RequestURI()) + } + + switch op { + case "CalculateRoutes": + return priced("RoutesRequest", map[string]any{ + "LegGeometryFormat": "Simple", + "Notices": []any{}, + "Routes": []any{}, + }) + + case "CalculateIsolines": + return priced("IsolinesRequest", map[string]any{ + "ArrivalTime": "", + "DepartureTime": "", + "IsolineGeometryFormat": "Simple", + "Isolines": []any{}, + "SnappedDestination": []any{}, + "SnappedOrigin": []any{}, + }) + + case "CalculateRouteMatrix": + return priced("RouteMatrixRequest", map[string]any{ + "ErrorCount": 0, + "RouteMatrix": []any{}, + "RoutingBoundary": map[string]any{}, + }) + + case "OptimizeWaypoints": + return priced("OptimizeWaypointsRequest", map[string]any{ + "Connections": []any{}, + "Distance": 0, + "Duration": 0, + "ImpedingWaypoints": []any{}, + "OptimizedWaypoints": []any{}, + "TimeBreakdown": map[string]any{}, + }) + + case "SnapToRoads": + return priced("SnapToRoadsRequest", map[string]any{ + "Notices": []any{}, + "SnappedGeometry": map[string]any{}, + "SnappedGeometryFormat": "Simple", + "SnappedTracePoints": []any{}, + }) + + default: + return nil, plugin.ErrUnhandledOp + } +} + +// pricingBucketHeader is where every operation in this service reports its +// pricing bucket. The model binds the member to a header rather than the body, +// and botocore reads it from there — a body member by that name is discarded +// without an error, so the caller would simply never see it. +const pricingBucketHeader = "x-amz-geo-pricing-bucket" + +func priced(bucket string, body map[string]any) (*plugin.Response, error) { + resp, err := shared.JSONResponse(http.StatusOK, body) + if err != nil { + return nil, err + } + resp.Headers = map[string]string{pricingBucketHeader: bucket} + return resp, nil +} + +func (p *Provider) ListResources(_ context.Context) ([]plugin.Resource, error) { + return []plugin.Resource{}, nil +} + +func init() { + plugin.DefaultRegistry.Register("georoutes", func() plugin.ServicePlugin { + return &Provider{} + }) + // Unique signing name; declared for the same reason eksauth declares its + // own — see crud.RegisterRoutes. + crud.RegisterRoutes("georoutes", generated.OperationRoutes) +} diff --git a/internal/services/georoutes/provider_test.go b/internal/services/georoutes/provider_test.go new file mode 100644 index 00000000..0a4362ec --- /dev/null +++ b/internal/services/georoutes/provider_test.go @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 + +// internal/services/georoutes/provider_test.go +package georoutes + +import ( + "context" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/skyoo2003/devcloud/internal/plugin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestProvider(t *testing.T) *Provider { + t.Helper() + p := &Provider{} + require.NoError(t, p.Init(plugin.PluginConfig{DataDir: t.TempDir()})) + t.Cleanup(func() { _ = p.Shutdown(context.Background()) }) + return p +} + +func callREST(t *testing.T, p *Provider, method, path, op, body string) *plugin.Response { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := p.HandleRequest(context.Background(), op, req) + require.NoError(t, err) + return resp +} + +func parseBody(t *testing.T, resp *plugin.Response) map[string]any { + t.Helper() + var m map[string]any + require.NoError(t, json.Unmarshal(resp.Body, &m)) + return m +} + +// TestEveryOperation calls all five by path, so the generated route table is +// exercised as well as the dispatch. Members are PascalCase on the wire. +// +// PricingBucket is absent from these lists on purpose: the model binds it to a +// header, and TestPricingBucketIsAHeader asserts it there. +func TestEveryOperation(t *testing.T) { + p := newTestProvider(t) + + cases := []struct { + op string + path string + members []string + }{ + {"CalculateRoutes", "/v2/routes", + []string{"LegGeometryFormat", "Notices", "Routes"}}, + {"CalculateIsolines", "/v2/isolines", + []string{"ArrivalTime", "DepartureTime", "IsolineGeometryFormat", "Isolines", + "SnappedDestination", "SnappedOrigin"}}, + {"CalculateRouteMatrix", "/v2/route-matrix", + []string{"ErrorCount", "RouteMatrix", "RoutingBoundary"}}, + {"OptimizeWaypoints", "/v2/optimize-waypoints", + []string{"Connections", "Distance", "Duration", "ImpedingWaypoints", + "OptimizedWaypoints", "TimeBreakdown"}}, + {"SnapToRoads", "/v2/snap-to-roads", + []string{"Notices", "SnappedGeometry", "SnappedGeometryFormat", + "SnappedTracePoints"}}, + } + require.Len(t, cases, 5, "every operation the model declares must be called") + + for _, c := range cases { + t.Run(c.op, func(t *testing.T) { + resp := callREST(t, p, "POST", c.path, "", `{"Origin":[0,0],"Destination":[1,1]}`) + assert.Equal(t, 200, resp.StatusCode) + + rb := parseBody(t, resp) + for _, m := range c.members { + assert.Contains(t, rb, m) + } + }) + } +} + +// TestPricingBucketIsAHeader covers the one response member this service does +// not bind to the body. The model puts PricingBucket in the +// x-amz-geo-pricing-bucket header, and botocore reads it from there — a member +// by that name in the body is discarded without an error, so the caller simply +// never sees it. +func TestPricingBucketIsAHeader(t *testing.T) { + p := newTestProvider(t) + + cases := []struct{ op, path, bucket string }{ + {"CalculateRoutes", "/v2/routes", "RoutesRequest"}, + {"CalculateIsolines", "/v2/isolines", "IsolinesRequest"}, + {"CalculateRouteMatrix", "/v2/route-matrix", "RouteMatrixRequest"}, + {"OptimizeWaypoints", "/v2/optimize-waypoints", "OptimizeWaypointsRequest"}, + {"SnapToRoads", "/v2/snap-to-roads", "SnapToRoadsRequest"}, + } + for _, c := range cases { + t.Run(c.op, func(t *testing.T) { + resp := callREST(t, p, "POST", c.path, "", "{}") + require.Equal(t, 200, resp.StatusCode) + + assert.Equal(t, c.bucket, resp.Headers["x-amz-geo-pricing-bucket"]) + assert.NotContains(t, parseBody(t, resp), "PricingBucket", + "the model binds this to a header; a body member by that name is dropped") + }) + } +} + +// An empty body is what the smoke suite sends; no operation here reads one. +func TestEmptyBodyStillAnswers(t *testing.T) { + p := newTestProvider(t) + + resp := callREST(t, p, "POST", "/v2/routes", "", "") + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, []any{}, parseBody(t, resp)["Routes"]) +} + +// TestProviderIdentity pins what the registry and the fidelity manifest read. +// A typo in ServiceName is invisible everywhere else. +func TestProviderIdentity(t *testing.T) { + p := newTestProvider(t) + assert.Equal(t, "georoutes", p.ServiceID()) + assert.Equal(t, "RoutesService", p.ServiceName()) + assert.Equal(t, plugin.ProtocolRESTJSON, p.Protocol()) + + resources, err := p.ListResources(context.Background()) + require.NoError(t, err) + assert.Empty(t, resources) +} + +func TestUnknownOperationIsUnhandled(t *testing.T) { + p := newTestProvider(t) + req := httptest.NewRequest("POST", "/v2/nope", strings.NewReader("{}")) + _, err := p.HandleRequest(context.Background(), "NoSuchThing", req) + assert.ErrorIs(t, err, plugin.ErrUnhandledOp) + + // An unmodelled path resolves to no operation at all, which must decline + // the same way rather than answering for some other route. + req = httptest.NewRequest("POST", "/v2/nope", strings.NewReader("{}")) + _, err = p.HandleRequest(context.Background(), "", req) + assert.ErrorIs(t, err, plugin.ErrUnhandledOp) +} diff --git a/internal/services/inspectorscan/provider.go b/internal/services/inspectorscan/provider.go new file mode 100644 index 00000000..81c7e675 --- /dev/null +++ b/internal/services/inspectorscan/provider.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 + +package inspectorscan + +import ( + "context" + "encoding/json" + "io" + "net/http" + + generated "github.com/skyoo2003/devcloud/internal/generated/inspectorscan" + "github.com/skyoo2003/devcloud/internal/plugin" + "github.com/skyoo2003/devcloud/internal/shared" + "github.com/skyoo2003/devcloud/internal/shared/crud" +) + +// Provider implements the InspectorScan service. +type Provider struct { + generated.BaseProvider +} + +func (p *Provider) ServiceID() string { return "inspectorscan" } +func (p *Provider) ServiceName() string { return "InspectorScan" } +func (p *Provider) Protocol() plugin.ProtocolType { return plugin.ProtocolRESTJSON } + +func (p *Provider) Init(_ plugin.PluginConfig) error { return nil } + +func (p *Provider) Shutdown(_ context.Context) error { return nil } + +func (p *Provider) HandleRequest(_ context.Context, op string, req *http.Request) (*plugin.Response, error) { + if op == "" { + op, _ = generated.MatchOperation(req.Method, req.URL.RequestURI()) + } + body, err := io.ReadAll(req.Body) + if err != nil { + return shared.JSONError("SerializationException", "failed to read body", http.StatusBadRequest), nil + } + + switch op { + case "ScanSbom": + return scanSbom(body) + + default: + return nil, plugin.ErrUnhandledOp + } +} + +// scanSbom echoes the submitted SBOM back unchanged. DevCloud runs no +// vulnerability scanner, so the honest answer is the document as given — +// inventing findings would make a local run disagree with a real one in the one +// direction that matters. +func scanSbom(body []byte) (*plugin.Response, error) { + if len(body) > 0 { + var input map[string]any + if err := json.Unmarshal(body, &input); err != nil { + return shared.JSONError("SerializationException", "invalid JSON", http.StatusBadRequest), nil + } + if s, ok := input["sbom"]; ok { + return shared.JSONResponse(http.StatusOK, map[string]any{"sbom": s}) + } + } + return shared.JSONResponse(http.StatusOK, map[string]any{"sbom": map[string]any{}}) +} + +func (p *Provider) ListResources(_ context.Context) ([]plugin.Resource, error) { + return []plugin.Resource{}, nil +} + +func init() { + plugin.DefaultRegistry.Register("inspectorscan", func() plugin.ServicePlugin { + return &Provider{} + }) + // Unique signing name; declared for the same reason eksauth declares its + // own — see crud.RegisterRoutes. + crud.RegisterRoutes("inspectorscan", generated.OperationRoutes) +} diff --git a/internal/services/inspectorscan/provider_test.go b/internal/services/inspectorscan/provider_test.go new file mode 100644 index 00000000..e0a2fd4c --- /dev/null +++ b/internal/services/inspectorscan/provider_test.go @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 + +// internal/services/inspectorscan/provider_test.go +package inspectorscan + +import ( + "context" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/skyoo2003/devcloud/internal/plugin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestProvider(t *testing.T) *Provider { + t.Helper() + p := &Provider{} + require.NoError(t, p.Init(plugin.PluginConfig{DataDir: t.TempDir()})) + t.Cleanup(func() { _ = p.Shutdown(context.Background()) }) + return p +} + +func callREST(t *testing.T, p *Provider, method, path, op, body string) *plugin.Response { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := p.HandleRequest(context.Background(), op, req) + require.NoError(t, err) + return resp +} + +func parseBody(t *testing.T, resp *plugin.Response) map[string]any { + t.Helper() + var m map[string]any + require.NoError(t, json.Unmarshal(resp.Body, &m)) + return m +} + +func TestScanSbomEchoesTheDocument(t *testing.T) { + p := newTestProvider(t) + + resp := callREST(t, p, "POST", "/scan/sbom", "", + `{"sbom":{"bomFormat":"CycloneDX","specVersion":"1.5"}}`) + assert.Equal(t, 200, resp.StatusCode) + + sbom, ok := parseBody(t, resp)["sbom"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "CycloneDX", sbom["bomFormat"]) + assert.Equal(t, "1.5", sbom["specVersion"]) +} + +// An empty body is what the smoke suite sends, and it must still be a 200 with +// the member the model declares. +func TestScanSbomEmptyBody(t *testing.T) { + p := newTestProvider(t) + + resp := callREST(t, p, "POST", "/scan/sbom", "", "") + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, map[string]any{}, parseBody(t, resp)["sbom"]) +} + +func TestScanSbomInvalidJSON(t *testing.T) { + p := newTestProvider(t) + + resp := callREST(t, p, "POST", "/scan/sbom", "", "not json") + assert.Equal(t, 400, resp.StatusCode) + assert.Equal(t, "SerializationException", parseBody(t, resp)["__type"]) +} + +// TestProviderIdentity pins what the registry and the fidelity manifest read. +// A typo in ServiceName is invisible everywhere else. +func TestProviderIdentity(t *testing.T) { + p := newTestProvider(t) + assert.Equal(t, "inspectorscan", p.ServiceID()) + assert.Equal(t, "InspectorScan", p.ServiceName()) + assert.Equal(t, plugin.ProtocolRESTJSON, p.Protocol()) + + resources, err := p.ListResources(context.Background()) + require.NoError(t, err) + assert.Empty(t, resources) +} + +func TestUnknownOperationIsUnhandled(t *testing.T) { + p := newTestProvider(t) + req := httptest.NewRequest("POST", "/nope", strings.NewReader("{}")) + _, err := p.HandleRequest(context.Background(), "NoSuchThing", req) + assert.ErrorIs(t, err, plugin.ErrUnhandledOp) +} diff --git a/internal/services/kinesisvideowebrtcstorage/provider.go b/internal/services/kinesisvideowebrtcstorage/provider.go new file mode 100644 index 00000000..85f2ea51 --- /dev/null +++ b/internal/services/kinesisvideowebrtcstorage/provider.go @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 + +package kinesisvideowebrtcstorage + +import ( + "context" + "encoding/json" + "io" + "net/http" + + generated "github.com/skyoo2003/devcloud/internal/generated/kinesisvideowebrtcstorage" + "github.com/skyoo2003/devcloud/internal/plugin" + "github.com/skyoo2003/devcloud/internal/shared" + "github.com/skyoo2003/devcloud/internal/shared/crud" +) + +// Provider implements the AWSAcuityRoutingServiceLambda service. +type Provider struct { + generated.BaseProvider +} + +func (p *Provider) ServiceID() string { return "kinesisvideowebrtcstorage" } +func (p *Provider) ServiceName() string { return "AWSAcuityRoutingServiceLambda" } +func (p *Provider) Protocol() plugin.ProtocolType { return plugin.ProtocolRESTJSON } + +func (p *Provider) Init(_ plugin.PluginConfig) error { return nil } + +func (p *Provider) Shutdown(_ context.Context) error { return nil } + +func (p *Provider) HandleRequest(_ context.Context, op string, req *http.Request) (*plugin.Response, error) { + if op == "" { + op, _ = generated.MatchOperation(req.Method, req.URL.RequestURI()) + } + body, err := io.ReadAll(req.Body) + if err != nil { + return shared.JSONError("SerializationException", "failed to read body", http.StatusBadRequest), nil + } + + switch op { + // Both operations model a Smithy Unit output, so an empty object is the + // complete response. The only thing worth checking is the one input AWS + // insists on. + case "JoinStorageSession", "JoinStorageSessionAsViewer": + return joinStorageSession(body) + + default: + return nil, plugin.ErrUnhandledOp + } +} + +func joinStorageSession(body []byte) (*plugin.Response, error) { + var input map[string]any + if len(body) > 0 { + if err := json.Unmarshal(body, &input); err != nil { + return shared.JSONError("SerializationException", "invalid JSON", http.StatusBadRequest), nil + } + } + if arn, _ := input["channelArn"].(string); arn == "" { + return shared.JSONError("InvalidArgumentException", "channelArn is required", http.StatusBadRequest), nil + } + return shared.JSONResponse(http.StatusOK, map[string]any{}) +} + +func (p *Provider) ListResources(_ context.Context) ([]plugin.Resource, error) { + return []plugin.Resource{}, nil +} + +func init() { + plugin.DefaultRegistry.Register("kinesisvideowebrtcstorage", func() plugin.ServicePlugin { + return &Provider{} + }) + // This service signs as "kinesisvideo", which kinesisvideo itself claims. + // Neither its operations nor the parent's model the join-session paths in + // the CRUD registry, so without this the gateway's shared-signing-name split + // has no candidate and the parent keeps the request. See + // crud.RegisterRoutes. + crud.RegisterRoutes("kinesisvideowebrtcstorage", generated.OperationRoutes) +} diff --git a/internal/services/kinesisvideowebrtcstorage/provider_test.go b/internal/services/kinesisvideowebrtcstorage/provider_test.go new file mode 100644 index 00000000..0569ceac --- /dev/null +++ b/internal/services/kinesisvideowebrtcstorage/provider_test.go @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 + +// internal/services/kinesisvideowebrtcstorage/provider_test.go +package kinesisvideowebrtcstorage + +import ( + "context" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/skyoo2003/devcloud/internal/plugin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestProvider(t *testing.T) *Provider { + t.Helper() + p := &Provider{} + require.NoError(t, p.Init(plugin.PluginConfig{DataDir: t.TempDir()})) + t.Cleanup(func() { _ = p.Shutdown(context.Background()) }) + return p +} + +func callREST(t *testing.T, p *Provider, method, path, op, body string) *plugin.Response { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := p.HandleRequest(context.Background(), op, req) + require.NoError(t, err) + return resp +} + +func parseBody(t *testing.T, resp *plugin.Response) map[string]any { + t.Helper() + var m map[string]any + require.NoError(t, json.Unmarshal(resp.Body, &m)) + return m +} + +func TestJoinStorageSessionOperations(t *testing.T) { + p := newTestProvider(t) + const body = `{"channelArn":"arn:aws:kinesisvideo:us-east-1:000000000000:channel/demo/1"}` + + cases := []struct{ op, path string }{ + {"JoinStorageSession", "/joinStorageSession"}, + {"JoinStorageSessionAsViewer", "/joinStorageSessionAsViewer"}, + } + for _, c := range cases { + t.Run(c.op, func(t *testing.T) { + // op resolved from the path, as the gateway does for rest-json. + resp := callREST(t, p, "POST", c.path, "", body) + assert.Equal(t, 200, resp.StatusCode) + // Both model a Smithy Unit output. + assert.Equal(t, map[string]any{}, parseBody(t, resp)) + }) + } +} + +func TestJoinStorageSessionRequiresChannelArn(t *testing.T) { + p := newTestProvider(t) + + resp := callREST(t, p, "POST", "/joinStorageSession", "", `{}`) + assert.Equal(t, 400, resp.StatusCode) + assert.Equal(t, "InvalidArgumentException", parseBody(t, resp)["__type"]) + + // An empty body is the same miss, not a 500. + resp = callREST(t, p, "POST", "/joinStorageSession", "", "") + assert.Equal(t, 400, resp.StatusCode) +} + +// TestProviderIdentity pins what the registry and the fidelity manifest read. +// A typo in ServiceName is invisible everywhere else. +func TestProviderIdentity(t *testing.T) { + p := newTestProvider(t) + assert.Equal(t, "kinesisvideowebrtcstorage", p.ServiceID()) + assert.Equal(t, "AWSAcuityRoutingServiceLambda", p.ServiceName()) + assert.Equal(t, plugin.ProtocolRESTJSON, p.Protocol()) + + resources, err := p.ListResources(context.Background()) + require.NoError(t, err) + assert.Empty(t, resources) +} + +func TestUnknownOperationIsUnhandled(t *testing.T) { + p := newTestProvider(t) + req := httptest.NewRequest("POST", "/nope", strings.NewReader("{}")) + _, err := p.HandleRequest(context.Background(), "NoSuchThing", req) + assert.ErrorIs(t, err, plugin.ErrUnhandledOp) +} diff --git a/internal/services/marketplacecommerceanalytics/provider.go b/internal/services/marketplacecommerceanalytics/provider.go new file mode 100644 index 00000000..aa09bd4a --- /dev/null +++ b/internal/services/marketplacecommerceanalytics/provider.go @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 + +package marketplacecommerceanalytics + +import ( + "context" + "net/http" + + generated "github.com/skyoo2003/devcloud/internal/generated/marketplacecommerceanalytics" + "github.com/skyoo2003/devcloud/internal/plugin" + "github.com/skyoo2003/devcloud/internal/shared" +) + +// Provider implements the MarketplaceCommerceAnalytics20150701 service. +type Provider struct { + generated.BaseProvider +} + +func (p *Provider) ServiceID() string { return "marketplacecommerceanalytics" } +func (p *Provider) ServiceName() string { return "MarketplaceCommerceAnalytics20150701" } + +func (p *Provider) Protocol() plugin.ProtocolType { return plugin.ProtocolJSON11 } + +func (p *Provider) Init(_ plugin.PluginConfig) error { return nil } + +func (p *Provider) Shutdown(_ context.Context) error { return nil } + +// HandleRequest serves both operations by hand. Both hand work to an +// asynchronous pipeline that writes a data set to S3; the request id is the +// whole synchronous answer AWS gives, and it is the whole answer here. +func (p *Provider) HandleRequest(_ context.Context, op string, _ *http.Request) (*plugin.Response, error) { + switch op { + case "GenerateDataSet", "StartSupportDataExport": + return shared.JSONResponse(http.StatusOK, map[string]any{ + "dataSetRequestId": shared.GenerateUUID(), + }) + + default: + return nil, plugin.ErrUnhandledOp + } +} + +func (p *Provider) ListResources(_ context.Context) ([]plugin.Resource, error) { + return []plugin.Resource{}, nil +} + +func init() { + plugin.DefaultRegistry.Register("marketplacecommerceanalytics", func() plugin.ServicePlugin { + return &Provider{} + }) +} diff --git a/internal/services/marketplacecommerceanalytics/provider_test.go b/internal/services/marketplacecommerceanalytics/provider_test.go new file mode 100644 index 00000000..9278e8e2 --- /dev/null +++ b/internal/services/marketplacecommerceanalytics/provider_test.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 + +// internal/services/marketplacecommerceanalytics/provider_test.go +package marketplacecommerceanalytics + +import ( + "context" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/skyoo2003/devcloud/internal/plugin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestProvider(t *testing.T) *Provider { + t.Helper() + p := &Provider{} + require.NoError(t, p.Init(plugin.PluginConfig{DataDir: t.TempDir()})) + t.Cleanup(func() { _ = p.Shutdown(context.Background()) }) + return p +} + +// callJSON mirrors how the gateway calls a json-1.1 provider: the operation +// comes from X-Amz-Target, so it is passed in rather than resolved from a path. +func callJSON(t *testing.T, p *Provider, op, body string) *plugin.Response { + t.Helper() + req := httptest.NewRequest("POST", "/", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/x-amz-json-1.1") + resp, err := p.HandleRequest(context.Background(), op, req) + require.NoError(t, err) + return resp +} + +func parseBody(t *testing.T, resp *plugin.Response) map[string]any { + t.Helper() + var m map[string]any + require.NoError(t, json.Unmarshal(resp.Body, &m)) + return m +} + +func TestDataSetOperations(t *testing.T) { + p := newTestProvider(t) + + for _, op := range []string{"GenerateDataSet", "StartSupportDataExport"} { + t.Run(op, func(t *testing.T) { + resp := callJSON(t, p, op, `{"destinationS3BucketName":"reports"}`) + assert.Equal(t, 200, resp.StatusCode) + // lowerCamel on the wire, verified against the model. + assert.NotEmpty(t, parseBody(t, resp)["dataSetRequestId"]) + }) + } +} + +// TestProviderIdentity pins what the registry and the fidelity manifest read. +// A typo in ServiceName is invisible everywhere else. +func TestProviderIdentity(t *testing.T) { + p := newTestProvider(t) + assert.Equal(t, "marketplacecommerceanalytics", p.ServiceID()) + assert.Equal(t, "MarketplaceCommerceAnalytics20150701", p.ServiceName()) + assert.Equal(t, plugin.ProtocolJSON11, p.Protocol()) + + resources, err := p.ListResources(context.Background()) + require.NoError(t, err) + assert.Empty(t, resources) +} + +func TestUnknownOperationIsUnhandled(t *testing.T) { + p := newTestProvider(t) + req := httptest.NewRequest("POST", "/", strings.NewReader("{}")) + _, err := p.HandleRequest(context.Background(), "NoSuchThing", req) + assert.ErrorIs(t, err, plugin.ErrUnhandledOp) +} diff --git a/internal/services/paymentcryptographydata/provider.go b/internal/services/paymentcryptographydata/provider.go new file mode 100644 index 00000000..68b16f61 --- /dev/null +++ b/internal/services/paymentcryptographydata/provider.go @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: Apache-2.0 + +package paymentcryptographydata + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "strings" + + generated "github.com/skyoo2003/devcloud/internal/generated/paymentcryptographydata" + "github.com/skyoo2003/devcloud/internal/plugin" + "github.com/skyoo2003/devcloud/internal/shared" + "github.com/skyoo2003/devcloud/internal/shared/crud" +) + +// Provider implements the PaymentCryptographyDataPlane service. +type Provider struct { + generated.BaseProvider +} + +func (p *Provider) ServiceID() string { return "paymentcryptographydata" } +func (p *Provider) ServiceName() string { return "PaymentCryptographyDataPlane" } +func (p *Provider) Protocol() plugin.ProtocolType { return plugin.ProtocolRESTJSON } + +func (p *Provider) Init(_ plugin.PluginConfig) error { return nil } + +func (p *Provider) Shutdown(_ context.Context) error { return nil } + +// HandleRequest dispatches all 15 operations. Every wire member is PascalCase. +// +// The switch is kept flat and literal on purpose: codegen's hand-verified scan +// reads the `case` clauses out of this function, so collapsing it into a map +// lookup would delete all 15 operations from the fidelity manifest. +func (p *Provider) HandleRequest(_ context.Context, op string, req *http.Request) (*plugin.Response, error) { + matched, labels := generated.MatchOperation(req.Method, req.URL.RequestURI()) + if op == "" { + op = matched + } + + raw, err := io.ReadAll(req.Body) + if err != nil { + return shared.JSONError("SerializationException", "failed to read body", http.StatusBadRequest), nil + } + in := map[string]any{} + if len(raw) > 0 { + if err := json.Unmarshal(raw, &in); err != nil { + return shared.JSONError("SerializationException", "invalid JSON", http.StatusBadRequest), nil + } + } + + // Three operations address the key in the path; every other one carries it + // in the body as KeyIdentifier. + keyID := labels["KeyIdentifier"] + if keyID == "" { + keyID = labels["IncomingKeyIdentifier"] + } + if keyID == "" { + keyID, _ = in["KeyIdentifier"].(string) + } + + switch op { + case "EncryptData": + plainText, _ := in["PlainText"].(string) + return shared.JSONResponse(http.StatusOK, map[string]any{ + "KeyArn": keyARN(keyID), + "KeyCheckValue": keyCheckValue(keyID), + "CipherText": cipher(plainText), + }) + + case "DecryptData": + cipherText, _ := in["CipherText"].(string) + plainText, ok := plain(cipherText) + if !ok { + return shared.JSONError("ValidationException", "cipherText is not valid", http.StatusBadRequest), nil + } + return shared.JSONResponse(http.StatusOK, map[string]any{ + "KeyArn": keyARN(keyID), + "KeyCheckValue": keyCheckValue(keyID), + "PlainText": plainText, + }) + + case "ReEncryptData": + // Re-encryption is keyed on the incoming key but the content survives, + // so decode and re-encode rather than echoing the input: a caller that + // then decrypts must get its plaintext back. + cipherText, _ := in["CipherText"].(string) + plainText, ok := plain(cipherText) + if !ok { + return shared.JSONError("ValidationException", "cipherText is not valid", http.StatusBadRequest), nil + } + return shared.JSONResponse(http.StatusOK, map[string]any{ + "KeyArn": keyARN(keyID), + "KeyCheckValue": keyCheckValue(keyID), + "CipherText": cipher(plainText), + }) + + case "GenerateMac": + messageData, _ := in["MessageData"].(string) + return shared.JSONResponse(http.StatusOK, map[string]any{ + "KeyArn": keyARN(keyID), + "KeyCheckValue": keyCheckValue(keyID), + "Mac": derive(keyID+messageData, 16), + }) + + case "VerifyMac": + return shared.JSONResponse(http.StatusOK, map[string]any{ + "KeyArn": keyARN(keyID), + "KeyCheckValue": keyCheckValue(keyID), + }) + + case "GenerateCardValidationData": + pan, _ := in["PrimaryAccountNumber"].(string) + return shared.JSONResponse(http.StatusOK, map[string]any{ + "KeyArn": keyARN(keyID), + "KeyCheckValue": keyCheckValue(keyID), + "ValidationData": digits(pan, 3), + }) + + case "VerifyCardValidationData": + return shared.JSONResponse(http.StatusOK, map[string]any{ + "KeyArn": keyARN(keyID), + "KeyCheckValue": keyCheckValue(keyID), + }) + + case "GenerateAuthRequestCryptogram": + return shared.JSONResponse(http.StatusOK, map[string]any{ + "KeyArn": keyARN(keyID), + "KeyCheckValue": keyCheckValue(keyID), + "AuthRequestCryptogram": derive(keyID+"arqc", 16), + }) + + case "VerifyAuthRequestCryptogram": + return shared.JSONResponse(http.StatusOK, map[string]any{ + "KeyArn": keyARN(keyID), + "KeyCheckValue": keyCheckValue(keyID), + "AuthResponseValue": derive(keyID+"arpc", 8), + }) + + case "GenerateAs2805KekValidation": + return shared.JSONResponse(http.StatusOK, map[string]any{ + "KeyArn": keyARN(keyID), + "KeyCheckValue": keyCheckValue(keyID), + "RandomKeyReceive": derive(keyID+"receive", 16), + "RandomKeySend": derive(keyID+"send", 16), + }) + + case "GeneratePinData": + generationKeyID, _ := in["GenerationKeyIdentifier"].(string) + encryptionKeyID, _ := in["EncryptionKeyIdentifier"].(string) + generationAttributes, _ := in["GenerationAttributes"].(map[string]any) + return shared.JSONResponse(http.StatusOK, map[string]any{ + "EncryptionKeyArn": keyARN(encryptionKeyID), + "EncryptionKeyCheckValue": keyCheckValue(encryptionKeyID), + "GenerationKeyArn": keyARN(generationKeyID), + "GenerationKeyCheckValue": keyCheckValue(generationKeyID), + "EncryptedPinBlock": derive(generationKeyID+"pin", 16), + "PinData": pinData(generationKeyID, generationAttributes), + }) + + case "TranslatePinData": + // The PIN block comes back unchanged: a translation re-wraps under a new + // key, and with no key material the block itself is the only stable + // thing to hand back. + pinBlock, _ := in["EncryptedPinBlock"].(string) + return shared.JSONResponse(http.StatusOK, map[string]any{ + "KeyArn": keyARN(keyID), + "KeyCheckValue": keyCheckValue(keyID), + "PinBlock": pinBlock, + }) + + case "VerifyPinData": + encryptionKeyID, _ := in["EncryptionKeyIdentifier"].(string) + verificationKeyID, _ := in["VerificationKeyIdentifier"].(string) + return shared.JSONResponse(http.StatusOK, map[string]any{ + "EncryptionKeyArn": keyARN(encryptionKeyID), + "EncryptionKeyCheckValue": keyCheckValue(encryptionKeyID), + "VerificationKeyArn": keyARN(verificationKeyID), + "VerificationKeyCheckValue": keyCheckValue(verificationKeyID), + }) + + case "TranslateKeyMaterial": + return shared.JSONResponse(http.StatusOK, map[string]any{ + "WrappedKey": map[string]any{}, + }) + + case "GenerateMacEmvPinChange": + newPinPekID, _ := in["NewPinPekIdentifier"].(string) + confidentialityKeyID, _ := in["SecureMessagingConfidentialityKeyIdentifier"].(string) + integrityKeyID, _ := in["SecureMessagingIntegrityKeyIdentifier"].(string) + return shared.JSONResponse(http.StatusOK, map[string]any{ + "EncryptedPinBlock": derive(newPinPekID+"pin", 16), + "Mac": derive(integrityKeyID+"mac", 16), + "NewPinPekArn": keyARN(newPinPekID), + "NewPinPekKeyCheckValue": keyCheckValue(newPinPekID), + "SecureMessagingConfidentialityKeyArn": keyARN(confidentialityKeyID), + "SecureMessagingConfidentialityKeyCheckValue": keyCheckValue(confidentialityKeyID), + "SecureMessagingIntegrityKeyArn": keyARN(integrityKeyID), + "SecureMessagingIntegrityKeyCheckValue": keyCheckValue(integrityKeyID), + "VisaAmexDerivationOutputs": map[string]any{}, + }) + + default: + return nil, plugin.ErrUnhandledOp + } +} + +func (p *Provider) ListResources(_ context.Context) ([]plugin.Resource, error) { + return []plugin.Resource{}, nil +} + +// keyARN and keyCheckValue are what every response in this service carries. The +// KCV is derived from the key identifier rather than random so repeated calls +// against the same key agree, which is the only property a caller can +// reasonably assert locally. +func keyARN(keyID string) string { + return shared.BuildARN("payment-cryptography", "key", keyID) +} + +func keyCheckValue(keyID string) string { + sum := sha256.Sum256([]byte(keyID)) + return strings.ToUpper(hex.EncodeToString(sum[:3])) +} + +// pinData builds the GeneratePinData response's PinData member. +// +// It is a tagged union, so it must carry exactly one member: botocore refuses a +// union that arrives empty ("PinData must have one and only one member set") and +// raises instead of returning, which turns a 200 into an error the caller cannot +// read. Which member is right depends on the scheme asked for — an IBM 3624 +// generation yields an offset, every other scheme a verification value. +func pinData(keyID string, generationAttributes map[string]any) map[string]any { + for name := range generationAttributes { + if strings.HasPrefix(name, "Ibm3624") { + return map[string]any{"PinOffset": digits(keyID+"offset", 4)} + } + } + return map[string]any{"VerificationValue": digits(keyID+"verification", 4)} +} + +// derive produces a stable upper-hex string of n characters from a seed, so two +// identical requests agree and two different ones do not. +func derive(seed string, n int) string { + sum := sha256.Sum256([]byte(seed)) + return strings.ToUpper(hex.EncodeToString(sum[:]))[:n] +} + +// digits produces n decimal digits from a seed, for the card validation values a +// caller may feed straight back into a numeric field. +func digits(seed string, n int) string { + sum := sha256.Sum256([]byte(seed)) + out := make([]byte, 0, n) + for i := 0; len(out) < n; i++ { + out = append(out, '0'+sum[i%len(sum)]%10) + } + return string(out) +} + +// ponytail: hex, not cryptography. The property that matters locally is that +// DecryptData returns what EncryptData was given; a real cipher would need key +// material this service does not hold. +func cipher(plainText string) string { return hex.EncodeToString([]byte(plainText)) } + +func plain(cipherText string) (string, bool) { + b, err := hex.DecodeString(cipherText) + return string(b), err == nil +} + +func init() { + plugin.DefaultRegistry.Register("paymentcryptographydata", func() plugin.ServicePlugin { + return &Provider{} + }) + // This service signs as "payment-cryptography", which the control plane + // claims. Neither side's operations are CRUD-classifiable at these paths, so + // without this the gateway's shared-signing-name split has no candidate and + // the control plane answers with InvalidAction. See crud.RegisterRoutes. + crud.RegisterRoutes("paymentcryptographydata", generated.OperationRoutes) +} diff --git a/internal/services/paymentcryptographydata/provider_test.go b/internal/services/paymentcryptographydata/provider_test.go new file mode 100644 index 00000000..ee76b302 --- /dev/null +++ b/internal/services/paymentcryptographydata/provider_test.go @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: Apache-2.0 + +// internal/services/paymentcryptographydata/provider_test.go +package paymentcryptographydata + +import ( + "context" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/skyoo2003/devcloud/internal/plugin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestProvider(t *testing.T) *Provider { + t.Helper() + p := &Provider{} + require.NoError(t, p.Init(plugin.PluginConfig{DataDir: t.TempDir()})) + t.Cleanup(func() { _ = p.Shutdown(context.Background()) }) + return p +} + +func callREST(t *testing.T, p *Provider, method, path, op, body string) *plugin.Response { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := p.HandleRequest(context.Background(), op, req) + require.NoError(t, err) + return resp +} + +func parseBody(t *testing.T, resp *plugin.Response) map[string]any { + t.Helper() + var m map[string]any + require.NoError(t, json.Unmarshal(resp.Body, &m)) + return m +} + +// TestEveryOperation calls all 15 by path, so the generated route table is +// exercised alongside the dispatch. Every wire member is PascalCase. +func TestEveryOperation(t *testing.T) { + p := newTestProvider(t) + const body = `{"KeyIdentifier":"key-1","PlainText":"4111111111111111",` + + `"CipherText":"3431","MessageData":"abcd","PrimaryAccountNumber":"4111111111111111",` + + `"EncryptedPinBlock":"AAAA1111","GenerationKeyIdentifier":"gen-1",` + + `"EncryptionKeyIdentifier":"enc-1","VerificationKeyIdentifier":"ver-1",` + + `"NewPinPekIdentifier":"pek-1",` + + `"SecureMessagingConfidentialityKeyIdentifier":"conf-1",` + + `"SecureMessagingIntegrityKeyIdentifier":"int-1"}` + + cases := []struct { + op string + path string + members []string + }{ + {"EncryptData", "/keys/key-1/encrypt", []string{"KeyArn", "KeyCheckValue", "CipherText"}}, + {"DecryptData", "/keys/key-1/decrypt", []string{"KeyArn", "KeyCheckValue", "PlainText"}}, + {"ReEncryptData", "/keys/key-1/reencrypt", []string{"KeyArn", "KeyCheckValue", "CipherText"}}, + {"GenerateMac", "/mac/generate", []string{"KeyArn", "KeyCheckValue", "Mac"}}, + {"VerifyMac", "/mac/verify", []string{"KeyArn", "KeyCheckValue"}}, + {"GenerateCardValidationData", "/cardvalidationdata/generate", + []string{"KeyArn", "KeyCheckValue", "ValidationData"}}, + {"VerifyCardValidationData", "/cardvalidationdata/verify", []string{"KeyArn", "KeyCheckValue"}}, + {"GenerateAuthRequestCryptogram", "/cryptogram/generate", + []string{"KeyArn", "KeyCheckValue", "AuthRequestCryptogram"}}, + {"VerifyAuthRequestCryptogram", "/cryptogram/verify", + []string{"KeyArn", "KeyCheckValue", "AuthResponseValue"}}, + {"GenerateAs2805KekValidation", "/as2805kekvalidation/generate", + []string{"KeyArn", "KeyCheckValue", "RandomKeyReceive", "RandomKeySend"}}, + {"GeneratePinData", "/pindata/generate", + []string{"EncryptionKeyArn", "EncryptionKeyCheckValue", "GenerationKeyArn", + "GenerationKeyCheckValue", "EncryptedPinBlock", "PinData"}}, + {"TranslatePinData", "/pindata/translate", []string{"KeyArn", "KeyCheckValue", "PinBlock"}}, + {"VerifyPinData", "/pindata/verify", + []string{"EncryptionKeyArn", "EncryptionKeyCheckValue", "VerificationKeyArn", + "VerificationKeyCheckValue"}}, + {"TranslateKeyMaterial", "/keymaterial/translate", []string{"WrappedKey"}}, + {"GenerateMacEmvPinChange", "/macemvpinchange/generate", + []string{"EncryptedPinBlock", "Mac", "NewPinPekArn", "NewPinPekKeyCheckValue", + "SecureMessagingConfidentialityKeyArn", "SecureMessagingConfidentialityKeyCheckValue", + "SecureMessagingIntegrityKeyArn", "SecureMessagingIntegrityKeyCheckValue", + "VisaAmexDerivationOutputs"}}, + } + require.Len(t, cases, 15, "every operation the model declares must be called") + + for _, c := range cases { + t.Run(c.op, func(t *testing.T) { + resp := callREST(t, p, "POST", c.path, "", body) + assert.Equal(t, 200, resp.StatusCode) + + rb := parseBody(t, resp) + for _, m := range c.members { + assert.Contains(t, rb, m) + } + }) + } +} + +// TestEncryptDecryptRoundTrip is the one property a caller can assert locally. +func TestEncryptDecryptRoundTrip(t *testing.T) { + p := newTestProvider(t) + const plainText = "4111111111111111" + + enc := callREST(t, p, "POST", "/keys/key-1/encrypt", "", `{"PlainText":"`+plainText+`"}`) + require.Equal(t, 200, enc.StatusCode) + cipherText, ok := parseBody(t, enc)["CipherText"].(string) + require.True(t, ok) + assert.NotEqual(t, plainText, cipherText) + + dec := callREST(t, p, "POST", "/keys/key-1/decrypt", "", `{"CipherText":"`+cipherText+`"}`) + require.Equal(t, 200, dec.StatusCode) + assert.Equal(t, plainText, parseBody(t, dec)["PlainText"]) + + // Re-encryption preserves the content, so the round-trip survives it. + re := callREST(t, p, "POST", "/keys/key-2/reencrypt", "", `{"CipherText":"`+cipherText+`"}`) + require.Equal(t, 200, re.StatusCode) + reCipher, ok := parseBody(t, re)["CipherText"].(string) + require.True(t, ok) + dec2 := callREST(t, p, "POST", "/keys/key-2/decrypt", "", `{"CipherText":"`+reCipher+`"}`) + assert.Equal(t, plainText, parseBody(t, dec2)["PlainText"]) +} + +func TestDecryptRejectsNonHexCipherText(t *testing.T) { + p := newTestProvider(t) + + resp := callREST(t, p, "POST", "/keys/key-1/decrypt", "", `{"CipherText":"zz"}`) + assert.Equal(t, 400, resp.StatusCode) + assert.Equal(t, "ValidationException", parseBody(t, resp)["__type"]) +} + +// The key check value must be a function of the key id alone, or a caller has +// nothing stable to compare across calls. +func TestKeyCheckValueIsStable(t *testing.T) { + p := newTestProvider(t) + + first := parseBody(t, callREST(t, p, "POST", "/mac/verify", "", `{"KeyIdentifier":"key-1"}`)) + second := parseBody(t, callREST(t, p, "POST", "/mac/verify", "", `{"KeyIdentifier":"key-1"}`)) + other := parseBody(t, callREST(t, p, "POST", "/mac/verify", "", `{"KeyIdentifier":"key-2"}`)) + + assert.Equal(t, first["KeyCheckValue"], second["KeyCheckValue"]) + assert.NotEqual(t, first["KeyCheckValue"], other["KeyCheckValue"]) +} + +// The key identifier comes from the path for the three operations that bind it +// there, and from the body for every other one. +func TestKeyIdentifierSource(t *testing.T) { + p := newTestProvider(t) + + fromPath := parseBody(t, callREST(t, p, "POST", "/keys/path-key/encrypt", "", + `{"KeyIdentifier":"body-key","PlainText":"x"}`)) + assert.Contains(t, fromPath["KeyArn"], "path-key") + + fromBody := parseBody(t, callREST(t, p, "POST", "/mac/verify", "", `{"KeyIdentifier":"body-key"}`)) + assert.Contains(t, fromBody["KeyArn"], "body-key") +} + +// TestGeneratePinDataIsATaggedUnion guards the one union-typed response member +// in this service. botocore refuses a union with no member set — "PinData must +// have one and only one member set" — so an empty object is a 200 the SDK +// cannot parse, which is worse than an error. +func TestGeneratePinDataIsATaggedUnion(t *testing.T) { + p := newTestProvider(t) + + cases := []struct { + name string + attributes string + wantMember string + }{ + {"visa", `{"VisaPin":{"PinVerificationKeyIndex":1}}`, "VerificationValue"}, + {"ibm3624", `{"Ibm3624PinOffset":{"EncryptedPinBlock":"AABB"}}`, "PinOffset"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + resp := callREST(t, p, "POST", "/pindata/generate", "", + `{"GenerationKeyIdentifier":"genkey-01","EncryptionKeyIdentifier":"enckey-01",`+ + `"GenerationAttributes":`+c.attributes+`}`) + require.Equal(t, 200, resp.StatusCode) + + pinData, ok := parseBody(t, resp)["PinData"].(map[string]any) + require.True(t, ok) + require.Len(t, pinData, 1, "a tagged union carries exactly one member") + assert.Contains(t, pinData, c.wantMember) + }) + } +} + +func TestInvalidJSONBody(t *testing.T) { + p := newTestProvider(t) + + resp := callREST(t, p, "POST", "/mac/verify", "", "not json") + assert.Equal(t, 400, resp.StatusCode) + assert.Equal(t, "SerializationException", parseBody(t, resp)["__type"]) +} + +// TestProviderIdentity pins what the registry and the fidelity manifest read. +// A typo in ServiceName is invisible everywhere else. +func TestProviderIdentity(t *testing.T) { + p := newTestProvider(t) + assert.Equal(t, "paymentcryptographydata", p.ServiceID()) + assert.Equal(t, "PaymentCryptographyDataPlane", p.ServiceName()) + assert.Equal(t, plugin.ProtocolRESTJSON, p.Protocol()) + + resources, err := p.ListResources(context.Background()) + require.NoError(t, err) + assert.Empty(t, resources) +} + +func TestUnknownOperationIsUnhandled(t *testing.T) { + p := newTestProvider(t) + req := httptest.NewRequest("POST", "/nope", strings.NewReader("{}")) + _, err := p.HandleRequest(context.Background(), "NoSuchThing", req) + assert.ErrorIs(t, err, plugin.ErrUnhandledOp) +} diff --git a/internal/shared/crud/crud.go b/internal/shared/crud/crud.go index 82b1bcd1..3b8ca19f 100644 --- a/internal/shared/crud/crud.go +++ b/internal/shared/crud/crud.go @@ -80,6 +80,13 @@ var ( registry = map[string]map[string]OpMeta{} // service -> op -> meta routes = map[string][]httproute.Route{} // service -> REST routes, sorted by operation store = map[string]map[string]map[string]any{} // "service|resource" -> id -> doc + + // providerRoutes holds the REST routes of services whose provider serves + // them by hand. It is kept apart from routes rather than merged into it + // because Register overwrites its entry wholesale, and init order between + // the generated crudregistry and a service package is not something either + // side controls. + providerRoutes = map[string][]httproute.Route{} ) // Register records a service's operation metadata. The generated crudregistry @@ -105,6 +112,40 @@ func Register(service string, ops map[string]OpMeta) { routes[service] = rs } +// RegisterRoutes records the REST route table of a service the engine cannot +// serve, so the gateway can still tell it apart from a sibling that shares its +// SigV4 signing name. +// +// Registration here claims nothing about fidelity: the operation resolves to a +// name, Handle then finds no OpMeta for it and returns ErrUnclassified, which is +// the truth. What it changes is which service is asked — without it, +// payment-cryptography-data's traffic is kept by the control plane, because +// neither sibling's route table models the data plane's paths and +// resolveSharedSigningName leaves an unclaimed request where it found it. +// +// Not one of that service's 15 operations carries a CRUD verb prefix, so it +// holds no crudregistry entry and Register is never called for it. This is the +// only way its routes reach HasRoute. +func RegisterRoutes(service string, rs []httproute.Route) { + filtered := make([]httproute.Route, 0, len(rs)) + for _, r := range rs { + // A json-1.x service binds no path; its generated table is all empty + // patterns and matching against them would answer for any request. + if r.Pattern == "" { + continue + } + filtered = append(filtered, r) + } + // Sorted by operation for the same reason Register sorts: httproute.Match + // takes the first route that fits within a specificity pass, and Go map + // iteration order must not decide which one that is. + sort.Slice(filtered, func(i, j int) bool { return filtered[i].Operation < filtered[j].Operation }) + + mu.Lock() + defer mu.Unlock() + providerRoutes[service] = filtered +} + // Route returns the operation a service models at this method and URI, or "" if // it models none. // @@ -119,11 +160,17 @@ func Register(service string, ops map[string]OpMeta) { // runs for an importer — a provider unit test that does not import it sees an // empty table and falls back on the provider's own resolver, which is the // behaviour it had before. +// The engine's own table is consulted first: a service that models the path as +// a CRUD operation can actually be served there, while a providerRoutes entry +// only names the operation. func Route(service, method, uri string) string { mu.RLock() - rs := routes[service] + rs, pr := routes[service], providerRoutes[service] mu.RUnlock() - op, _ := httproute.Match(rs, method, uri) + if op, _ := httproute.Match(rs, method, uri); op != "" { + return op + } + op, _ := httproute.Match(pr, method, uri) return op } diff --git a/internal/shared/crud/crud_test.go b/internal/shared/crud/crud_test.go index 0f45798e..1e1ec2bc 100644 --- a/internal/shared/crud/crud_test.go +++ b/internal/shared/crud/crud_test.go @@ -8,6 +8,8 @@ import ( "strings" "sync" "testing" + + "github.com/skyoo2003/devcloud/internal/shared/httproute" ) func handleJSON(t *testing.T, service, op string, params map[string]any) (*Result, error) { @@ -601,6 +603,77 @@ func TestEngineRESTXMLUnmatchedPathIsUnclassified(t *testing.T) { } } +// --- provider-declared routes --- + +// TestRegisterRoutes covers the table a hand-written provider declares for +// itself. Nothing here is servable by the engine: the point is that the gateway +// can tell such a service apart from a sibling sharing its SigV4 signing name, +// which it does by asking HasRoute. +func TestRegisterRoutes(t *testing.T) { + const svc = "handwrittensvc" + RegisterRoutes(svc, []httproute.Route{ + {Method: "POST", Pattern: "/keys/{KeyIdentifier}/encrypt", Operation: "EncryptData"}, + {Method: "GET", Pattern: "/2013-01-01/search?format=sdk", Operation: "Search"}, + }) + + if !HasRoute(svc, "POST", "/keys/k1/encrypt") { + t.Error("declared route: want HasRoute true") + } + if got := Route(svc, "POST", "/keys/k1/encrypt"); got != "EncryptData" { + t.Errorf("Route = %q, want EncryptData", got) + } + if !HasRoute(svc, "GET", "/2013-01-01/search?format=sdk&q=x") { + t.Error("query-string route: want HasRoute true") + } + if HasRoute(svc, "POST", "/nope") { + t.Error("unmodelled path: want HasRoute false") + } + if HasRoute(svc, "GET", "/keys/k1/encrypt") { + t.Error("known path, wrong method: want HasRoute false") + } + + // Idempotent: a second registration replaces rather than accumulates. + RegisterRoutes(svc, []httproute.Route{ + {Method: "POST", Pattern: "/keys/{KeyIdentifier}/encrypt", Operation: "EncryptData"}, + }) + if !HasRoute(svc, "POST", "/keys/k1/encrypt") { + t.Error("after re-registration: want HasRoute true") + } +} + +// TestRegisterRoutesSkipsEmptyPatterns guards the json-1.x case: those models +// bind no path, so their generated table is all empty patterns. Registering one +// would make the service claim every request that reached the matcher. +func TestRegisterRoutesSkipsEmptyPatterns(t *testing.T) { + const svc = "json11svc" + RegisterRoutes(svc, []httproute.Route{ + {Method: "", Pattern: "", Operation: "SendSSHPublicKey"}, + {Method: "", Pattern: "", Operation: "SendSerialConsoleSSHPublicKey"}, + }) + + if HasRoute(svc, "POST", "/anything") { + t.Error("empty-pattern table: want HasRoute false") + } + if HasRoute(svc, "POST", "/") { + t.Error("empty-pattern table on root: want HasRoute false") + } +} + +// TestRegisterRoutesIsNotServing is the distinction the whole mechanism rests +// on: a declared route names an operation, it does not serve one. The engine +// must still decline, or these services would be answered generically — which +// is exactly what hand-writing them was meant to avoid. +func TestRegisterRoutesIsNotServing(t *testing.T) { + const svc = "declaredonlysvc" + RegisterRoutes(svc, []httproute.Route{ + {Method: "POST", Pattern: "/keys/{KeyIdentifier}/encrypt", Operation: "EncryptData"}, + }) + + if _, err := handleREST(t, svc, "POST", "/keys/k1/encrypt", nil); err != ErrUnclassified { + t.Errorf("declared but unregistered op: want ErrUnclassified, got %v", err) + } +} + func statusOf(r *Result) int { if r == nil { return 0 diff --git a/tests/compatibility/_coverage.py b/tests/compatibility/_coverage.py index 7a025406..8088d5e5 100644 --- a/tests/compatibility/_coverage.py +++ b/tests/compatibility/_coverage.py @@ -228,6 +228,14 @@ def stub_params(boto3_client_name, operation): def _stub_structure(shape, depth): + # A tagged union has no required members, and botocore refuses a request + # that sets none of them. Setting exactly one is the only shape that leaves + # the process, so the refusal reads as a service answer rather than a + # harness one — same reasoning as the collection minimums below. + if getattr(shape, "is_tagged_union", False): + for name in shape.members: + return {name: _stub_value(shape.members[name], depth + 1)} + return {} return { name: _stub_value(shape.members[name], depth + 1) for name in shape.required_members @@ -271,4 +279,10 @@ def _stub_value(shape, depth): return datetime.datetime(2020, 1, 1) # Strings, including enums: botocore does not validate enum members # client-side, so an arbitrary value reaches the gateway, which is the point. - return "devcloud-test" + # It does validate minimum length, though — an SSH public key is 80 + # characters at the least — so pad to whatever the shape insists on. + value = "devcloud-test" + minimum = shape.metadata.get("min", 0) + if minimum > len(value): + value = value.ljust(minimum, "x") + return value diff --git a/tests/compatibility/test_handverified_is_reachable.py b/tests/compatibility/test_handverified_is_reachable.py index 3d949bf9..4f3ba6a9 100644 --- a/tests/compatibility/test_handverified_is_reachable.py +++ b/tests/compatibility/test_handverified_is_reachable.py @@ -37,6 +37,39 @@ "ListApplications", "GET /2021-01-01/opensearch/list-applications", ), + # The Phase 2 hand-written services. Their unit tests assert Go-side response + # maps, which is a layer above the wire — three defects lived below it until a + # real client was pointed at them, and these rows are where that now fails. + # + # Search is the sharpest case: botocore converts it from the modelled GET to + # a POST with a form body, so the route the model declares is not the request + # anything sends. + ( + "cloudsearchdomain", + "cloudsearchdomain", + "Search", + "POST /2013-01-01/search (botocore converts the modelled GET to a form POST)", + ), + # GeneratePinData's PinData is a tagged union; botocore raises rather than + # returning when no member is set, so this row catches an unparseable 200. + ( + "paymentcryptographydata", + "payment-cryptography-data", + "GeneratePinData", + "POST /pindata/generate", + ), + ( + "georoutes", + "geo-routes", + "CalculateRoutes", + "POST /v2/routes", + ), + ( + "kinesisvideowebrtcstorage", + "kinesis-video-webrtc-storage", + "JoinStorageSession", + "POST /joinStorageSession", + ), ] diff --git a/tests/compatibility/test_no_fabricated_success.py b/tests/compatibility/test_no_fabricated_success.py index 786a4e22..e0e71fe8 100644 --- a/tests/compatibility/test_no_fabricated_success.py +++ b/tests/compatibility/test_no_fabricated_success.py @@ -75,7 +75,30 @@ def _unserved_probe(service_id, entry): # # Both were recorded as observations with the mechanism explicitly unidentified, # which is why neither had to be un-guessed before it could be fixed. -KNOWN_UNFIXED: dict[tuple[str, str], str] = {} +KNOWN_UNFIXED: dict[tuple[str, str], str] = { + # Surfaced in Phase 2, and not by Phase 2's own services. The stub builder + # now pads a string to the minimum length botocore insists on, so this probe + # leaves the process for the first time; the defect it lands on predates it. + # + # The mechanism is identified: the CRUD registry holds only operations the + # engine can classify, so workspaces-web's Associate*/Disassociate* routes + # are absent from it. UpdatePortal is present at PUT /portals/{portalArn+}, + # and that greedy label swallows /portals//browserSettings — the path + # AssociateBrowserSettings models. The engine resolves UpdatePortal and + # answers 200 for an operation nothing implements. + # + # The fix belongs where the route table is built, not here: the registry + # would have to carry every REST-bound operation, the unclassifiable ones + # with an empty Verb, so a more specific route wins and Handle declines on + # the Verb check it already makes. Measured, that moves 569 operations + # between fidelity tiers and takes registered-only from 4 to 1 — a coverage + # re-derivation of its own, which is Phase 3's to make. + ("workspacesweb", "AssociateBrowserSettings"): ( + "greedy {portalArn+} in UpdatePortal swallows the more specific " + "AssociateBrowserSettings path; the CRUD registry models no " + "unclassifiable route to outrank it (Phase 3)" + ), +} # Every service that is addressable, routable, and has an unserved operation to # ask for. Built at collection time so the parametrisation names the operation