From d4fe589fa41f6fa933f6f44eb9bf3249bba1bf2f Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Mon, 26 Oct 2026 13:57:57 +0530 Subject: [PATCH 1/2] fix(aws-codeartifact): close real-cloud parity gaps in #1456 --- .../terraform/fixtures/codeartifact/main.tf | 67 ++ .../fixtures/codeartifact/provider.tf | 29 + contrib/terraform/terraform_test.go | 2 +- docs/coverage/aws/codeartifact.md | 96 ++- docs/coverage/coverage.json | 174 +++++ docs/coverage/nongoals/codeartifact.md | 15 + providers/aws/aws.go | 1 + providers/aws/codeartifact/access.go | 74 ++ providers/aws/codeartifact/access_test.go | 100 +++ providers/aws/codeartifact/bulk_versions.go | 233 ++++++ providers/aws/codeartifact/codeartifact.go | 199 +++-- .../aws/codeartifact/codeartifact_test.go | 73 +- providers/aws/codeartifact/copy_versions.go | 163 ++++ providers/aws/codeartifact/domain.go | 102 ++- providers/aws/codeartifact/errors.go | 36 +- providers/aws/codeartifact/events.go | 136 ++++ providers/aws/codeartifact/events_test.go | 244 ++++++ providers/aws/codeartifact/group_pattern.go | 304 ++++++++ .../aws/codeartifact/group_pattern_test.go | 137 ++++ providers/aws/codeartifact/invariants_test.go | 313 ++++++++ providers/aws/codeartifact/misc_test.go | 224 ++++++ providers/aws/codeartifact/origin.go | 101 +++ providers/aws/codeartifact/origin_test.go | 206 +++++ providers/aws/codeartifact/package.go | 257 ++++++ providers/aws/codeartifact/package_group.go | 731 ++++++++++++++++++ .../aws/codeartifact/package_group_test.go | 384 +++++++++ providers/aws/codeartifact/package_version.go | 326 ++++++++ .../aws/codeartifact/package_version_test.go | 699 +++++++++++++++++ providers/aws/codeartifact/pagination.go | 68 ++ providers/aws/codeartifact/pagination_test.go | 124 +++ providers/aws/codeartifact/policy.go | 199 +++++ providers/aws/codeartifact/policy_test.go | 161 ++++ providers/aws/codeartifact/publish.go | 216 ++++++ providers/aws/codeartifact/race_test.go | 238 ++++++ providers/aws/codeartifact/repository.go | 440 ++++++++--- .../aws/codeartifact/repository_rules_test.go | 240 ++++++ providers/aws/codeartifact/snapshot.go | 99 ++- providers/aws/codeartifact/snapshot_test.go | 165 ++++ providers/aws/codeartifact/tags.go | 198 +++-- providers/aws/codeartifact/tags_test.go | 91 +++ providers/aws/codeartifact/validation.go | 248 ++++++ providers/aws/codeartifact/validation_test.go | 274 +++++++ providers/aws/lifecycle_events_test.go | 63 ++ providers/aws/rgt_discovery.go | 55 +- providers/aws/rgt_discovery_test.go | 64 ++ providers/aws/rgt_taggers.go | 3 +- server/aws/cloudtrail_recording.go | 5 + server/aws/cloudtrail_recording_test.go | 56 ++ server/aws/codeartifact/access.go | 45 ++ server/aws/codeartifact/authz.go | 178 +++++ server/aws/codeartifact/authz_test.go | 108 +++ server/aws/codeartifact/domain.go | 98 +-- server/aws/codeartifact/error_matrix_test.go | 166 ++++ server/aws/codeartifact/errors.go | 67 +- server/aws/codeartifact/handler.go | 250 ++---- server/aws/codeartifact/package.go | 104 +++ server/aws/codeartifact/package_group.go | 286 +++++++ server/aws/codeartifact/package_version.go | 297 +++++++ server/aws/codeartifact/policy.go | 109 +++ server/aws/codeartifact/repository.go | 92 ++- server/aws/codeartifact/routes.go | 156 ++++ server/aws/codeartifact/routes_test.go | 159 ++++ .../codeartifact/sdk_access_policy_test.go | 247 ++++++ server/aws/codeartifact/sdk_helpers_test.go | 144 ++++ server/aws/codeartifact/sdk_packages_test.go | 349 +++++++++ server/aws/codeartifact/sdk_roundtrip_test.go | 2 +- server/aws/codeartifact/tags.go | 75 +- .../aws/codeartifact/testdata/operations.json | 558 +++++++++++++ server/aws/codeartifact/wire.go | 123 ++- server/aws/codeartifact/wire_test.go | 181 +++++ server/aws/dispatch_ordering_test.go | 47 ++ services/codeartifact/driver/access.go | 35 + services/codeartifact/driver/errors.go | 35 +- .../codeartifact/driver/package_groups.go | 137 ++++ .../codeartifact/driver/package_versions.go | 220 ++++++ services/codeartifact/driver/packages.go | 49 ++ services/codeartifact/driver/policies.go | 31 + services/codeartifact/driver/refs.go | 38 + 78 files changed, 12053 insertions(+), 766 deletions(-) create mode 100644 contrib/terraform/fixtures/codeartifact/main.tf create mode 100644 contrib/terraform/fixtures/codeartifact/provider.tf create mode 100644 docs/coverage/nongoals/codeartifact.md create mode 100644 providers/aws/codeartifact/access.go create mode 100644 providers/aws/codeartifact/access_test.go create mode 100644 providers/aws/codeartifact/bulk_versions.go create mode 100644 providers/aws/codeartifact/copy_versions.go create mode 100644 providers/aws/codeartifact/events.go create mode 100644 providers/aws/codeartifact/events_test.go create mode 100644 providers/aws/codeartifact/group_pattern.go create mode 100644 providers/aws/codeartifact/group_pattern_test.go create mode 100644 providers/aws/codeartifact/invariants_test.go create mode 100644 providers/aws/codeartifact/misc_test.go create mode 100644 providers/aws/codeartifact/origin.go create mode 100644 providers/aws/codeartifact/origin_test.go create mode 100644 providers/aws/codeartifact/package.go create mode 100644 providers/aws/codeartifact/package_group.go create mode 100644 providers/aws/codeartifact/package_group_test.go create mode 100644 providers/aws/codeartifact/package_version.go create mode 100644 providers/aws/codeartifact/package_version_test.go create mode 100644 providers/aws/codeartifact/pagination.go create mode 100644 providers/aws/codeartifact/pagination_test.go create mode 100644 providers/aws/codeartifact/policy.go create mode 100644 providers/aws/codeartifact/policy_test.go create mode 100644 providers/aws/codeartifact/publish.go create mode 100644 providers/aws/codeartifact/race_test.go create mode 100644 providers/aws/codeartifact/repository_rules_test.go create mode 100644 providers/aws/codeartifact/snapshot_test.go create mode 100644 providers/aws/codeartifact/tags_test.go create mode 100644 providers/aws/codeartifact/validation.go create mode 100644 providers/aws/codeartifact/validation_test.go create mode 100644 server/aws/codeartifact/access.go create mode 100644 server/aws/codeartifact/authz.go create mode 100644 server/aws/codeartifact/authz_test.go create mode 100644 server/aws/codeartifact/error_matrix_test.go create mode 100644 server/aws/codeartifact/package.go create mode 100644 server/aws/codeartifact/package_group.go create mode 100644 server/aws/codeartifact/package_version.go create mode 100644 server/aws/codeartifact/policy.go create mode 100644 server/aws/codeartifact/routes.go create mode 100644 server/aws/codeartifact/routes_test.go create mode 100644 server/aws/codeartifact/sdk_access_policy_test.go create mode 100644 server/aws/codeartifact/sdk_helpers_test.go create mode 100644 server/aws/codeartifact/sdk_packages_test.go create mode 100644 server/aws/codeartifact/testdata/operations.json create mode 100644 server/aws/codeartifact/wire_test.go create mode 100644 services/codeartifact/driver/access.go create mode 100644 services/codeartifact/driver/package_groups.go create mode 100644 services/codeartifact/driver/package_versions.go create mode 100644 services/codeartifact/driver/packages.go create mode 100644 services/codeartifact/driver/policies.go create mode 100644 services/codeartifact/driver/refs.go diff --git a/contrib/terraform/fixtures/codeartifact/main.tf b/contrib/terraform/fixtures/codeartifact/main.tf new file mode 100644 index 000000000..090e78425 --- /dev/null +++ b/contrib/terraform/fixtures/codeartifact/main.tf @@ -0,0 +1,67 @@ +resource "aws_kms_key" "artifacts" { + description = "cloudemu-tf-codeartifact" + deletion_window_in_days = 7 +} + +resource "aws_codeartifact_domain" "main" { + domain = "cloudemu-tf" + encryption_key = aws_kms_key.artifacts.arn + + tags = { + env = "test" + } +} + +# One repository holds the external connection; the other consumes it as an upstream. +resource "aws_codeartifact_repository" "npm_store" { + repository = "npm-store" + domain = aws_codeartifact_domain.main.domain + description = "proxy of the public npm registry" + + external_connections { + external_connection_name = "public:npmjs" + } +} + +resource "aws_codeartifact_repository" "app" { + repository = "app" + domain = aws_codeartifact_domain.main.domain + description = "application packages" + + upstream { + repository_name = aws_codeartifact_repository.npm_store.repository + } + + tags = { + team = "platform" + } +} + +resource "aws_codeartifact_domain_permissions_policy" "main" { + domain = aws_codeartifact_domain.main.domain + + policy_document = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { AWS = "arn:aws:iam::123456789012:root" } + Action = ["codeartifact:CreateRepository", "codeartifact:DescribeDomain", "codeartifact:GetAuthorizationToken"] + Resource = "*" + }] + }) +} + +resource "aws_codeartifact_repository_permissions_policy" "app" { + repository = aws_codeartifact_repository.app.repository + domain = aws_codeartifact_domain.main.domain + + policy_document = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { AWS = "arn:aws:iam::123456789012:root" } + Action = ["codeartifact:ReadFromRepository", "codeartifact:DescribePackageVersion"] + Resource = "*" + }] + }) +} diff --git a/contrib/terraform/fixtures/codeartifact/provider.tf b/contrib/terraform/fixtures/codeartifact/provider.tf new file mode 100644 index 000000000..30fdad8a2 --- /dev/null +++ b/contrib/terraform/fixtures/codeartifact/provider.tf @@ -0,0 +1,29 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +variable "endpoint" { + type = string + description = "CloudEmu AWS endpoint URL" +} + +provider "aws" { + access_key = "test" + secret_key = "test" + region = "us-east-1" + + skip_credentials_validation = true + skip_metadata_api_check = true + skip_requesting_account_id = true + + endpoints { + codeartifact = var.endpoint + kms = var.endpoint + sts = var.endpoint + } +} diff --git a/contrib/terraform/terraform_test.go b/contrib/terraform/terraform_test.go index 5fb9aaedc..0a9f764aa 100644 --- a/contrib/terraform/terraform_test.go +++ b/contrib/terraform/terraform_test.go @@ -32,7 +32,7 @@ import ( func TestTerraformApplyIsIdempotent(t *testing.T) { bin := tfBinary(t) - for _, fixture := range []string{"basic", "networking", "core-services", "cloudwatch-alarms", "cloudwatch-logs"} { + for _, fixture := range []string{"basic", "networking", "core-services", "cloudwatch-alarms", "cloudwatch-logs", "codeartifact"} { t.Run(fixture, func(t *testing.T) { applyFixture(t, fixture, bin) }) diff --git a/docs/coverage/aws/codeartifact.md b/docs/coverage/aws/codeartifact.md index 455b19116..2c24304c2 100644 --- a/docs/coverage/aws/codeartifact.md +++ b/docs/coverage/aws/codeartifact.md @@ -23,6 +23,100 @@ AWS's `codeartifact` service · portable interface `driver.CodeArtifact` · [AWS | `UntagResource` | | | `UpdateRepository` | | +## Optional capabilities + +Discovered by type assertion; only some providers implement these. + +### Access + +Access is the optional capability behind `aws codeartifact login`. + +| Operation | Description | +| --- | --- | +| `GetAuthorizationToken` | GetAuthorizationToken issues a bearer token for a domain; a zero durationSeconds means the default lifetime. | +| `GetRepositoryEndpoint` | GetRepositoryEndpoint returns the URL of a repository for one package format ("", "ipv4" or "dualstack" endpoint type). | + +### PackageGroups + +PackageGroups is the optional capability for package groups. + +| Operation | Description | +| --- | --- | +| `CreatePackageGroup` | CreatePackageGroup creates a package group. | +| `DeletePackageGroup` | DeletePackageGroup deletes a group; its children move up to its parent. | +| `DescribePackageGroup` | DescribePackageGroup returns a package group with its parent and effective restrictions. | +| `GetAssociatedPackageGroup` | GetAssociatedPackageGroup returns the group a package coordinate belongs to and how strongly. | +| `ListAllowedRepositoriesForGroup` | ListAllowedRepositoriesForGroup lists the allowed repositories of one restriction type. | +| `ListAssociatedPackages` | ListAssociatedPackages lists the packages whose most specific group is the given one. | +| `ListPackageGroups` | ListPackageGroups lists a domain's package groups. | +| `ListSubPackageGroups` | ListSubPackageGroups lists the direct children of a package group. | +| `UpdatePackageGroup` | UpdatePackageGroup changes the contact info and description of a group. | +| `UpdatePackageGroupOriginConfiguration` | UpdatePackageGroupOriginConfiguration sets restriction modes and edits the allowed repository lists. | + +### PackageVersions + +PackageVersions is the optional capability for package versions and their assets. + +| Operation | Description | +| --- | --- | +| `CopyPackageVersions` | CopyPackageVersions copies versions between two repositories of one domain. | +| `DeletePackageVersions` | DeletePackageVersions removes versions and their assets. | +| `DescribePackageVersion` | DescribePackageVersion returns the description of one package version. | +| `DisposePackageVersions` | DisposePackageVersions deletes the assets of versions and marks them Disposed. | +| `GetPackageVersionAsset` | GetPackageVersionAsset returns the bytes of one asset. | +| `GetPackageVersionReadme` | GetPackageVersionReadme returns the identity of a version; generic versions have no readme text. | +| `ListPackageVersionAssets` | ListPackageVersionAssets lists the assets of a package version. | +| `ListPackageVersionDependencies` | ListPackageVersionDependencies returns the identity of a version; generic versions have no dependencies. | +| `ListPackageVersions` | ListPackageVersions lists the versions of a package, newest published first. | +| `PublishPackageVersion` | PublishPackageVersion adds an asset to a generic package version, creating the version when needed. | +| `UpdatePackageVersionsStatus` | UpdatePackageVersionsStatus moves versions to Archived, Published or Unlisted. | + +### Packages + +Packages is the optional capability for package records and their origin controls. + +| Operation | Description | +| --- | --- | +| `DeletePackage` | DeletePackage deletes a package with all its versions and assets. | +| `DescribePackage` | DescribePackage returns a package record. | +| `ListPackages` | ListPackages lists the packages of a repository. | +| `PutPackageOriginConfiguration` | PutPackageOriginConfiguration sets a package's origin controls, creating an empty record when it does not exist. | + +### Policies + +Policies is the optional capability for domain and repository permissions policies. + +| Operation | Description | +| --- | --- | +| `DeleteDomainPermissionsPolicy` | DeleteDomainPermissionsPolicy removes the resource policy from a domain. | +| `DeleteRepositoryPermissionsPolicy` | DeleteRepositoryPermissionsPolicy removes the resource policy from a repository. | +| `GetDomainPermissionsPolicy` | GetDomainPermissionsPolicy returns the resource policy on a domain. | +| `GetRepositoryPermissionsPolicy` | GetRepositoryPermissionsPolicy returns the resource policy on a repository. | +| `PutDomainPermissionsPolicy` | PutDomainPermissionsPolicy sets the resource policy on a domain. | +| `PutRepositoryPermissionsPolicy` | PutRepositoryPermissionsPolicy sets the resource policy on a repository. | + +### RepositoryAdministratorLister + +RepositoryAdministratorLister is the optional capability behind the administrator-account filter of + +| Operation | Description | +| --- | --- | +| `ListRepositoriesInDomainByAdministrator` | ListRepositoriesInDomainByAdministrator lists a domain's repositories administered by one account. | + ## Not in scope -_Not documented yet. See the [emulator boundary](../../../README.md) for cloudemu-wide non-goals._ +- All 48 operations of the API (`aws-sdk-go-v2/service/codeartifact` v1.45.0) are served. What is not emulated is the package-manager side: there is no npm, pip, Maven, NuGet, RubyGems, Cargo, Swift or generic registry protocol. `GetRepositoryEndpoint` and `aws codeartifact login` work and report the URL AWS reports (`https://-.d.codeartifact..amazonaws.com///`), but nothing serves that host, so `npm install` and `pip install` against it do not reach the emulator. The `dualstack` and `ipv4` endpoint types return the same host. +- Only generic packages can be created through the API, which is also true of real AWS (`PublishPackageVersion` is generic-only). `PutPackageOriginConfiguration` creates an empty package record of any format. Packages of other formats never appear on their own. +- An external connection is stored (its name, the package format it serves, status `Available`) but nothing is fetched from the public registry, so no version has the `EXTERNAL` origin type and `ListPackageVersions` with `originType` `EXTERNAL` or `UNKNOWN` is always empty. Retaining versions from upstream repositories happens only through `CopyPackageVersions` with `includeFromUpstream`. +- Generic package versions carry no metadata the emulator can read, so `GetPackageVersionReadme` returns an empty readme, `ListPackageVersionDependencies` an empty list, and `DescribePackageVersion` has no `summary`, `homePage`, `sourceCodeRepository` or `licenses`. +- Asset bytes are kept in memory. The limit per asset is 128 MiB (AWS: 5 GiB, adjustable) and a package version holds at most 350 assets, as in AWS; a larger asset answers `ServiceQuotaExceededException`. Assets are stored once per domain, so `assetSizeBytes` sums the distinct SHA-256 digests in the domain. A snapshot taken without assets keeps the name, size and hashes of every asset but not its bytes: downloading one afterwards answers `ResourceNotFoundException`. `--persist` always keeps the bytes. +- The request-rate quotas (publish 10/s, copy 5/s, token 40/s and the rest) and `ThrottlingException` are not modelled. `AccessDeniedException` comes only from `--enforce-auth` identity policies: domain and repository permissions policies are validated (a JSON document with a `Statement`, at most 7168 bytes) and stored with an optimistic-lock revision, but never evaluated, and cross-account access is not modelled. A domain and its repositories always belong to the emulator's single account, so the domain owner and the repository administrator are the same account and a package version event is delivered once. +- The `encryptionKey` of a domain is stored and returned but not checked against KMS and not used: assets are not encrypted. The `s3BucketArn` is a synthesized name; no bucket exists. +- Authorization tokens are opaque random strings valid for the requested duration (0 means the 12-hour default; AWS ties 0 to the caller's role session). Nothing consumes them: no registry checks a token. +- Only the `CodeArtifact Package Version State Change` EventBridge event is emitted: on publish, adding an asset, a status change, copy, dispose, delete versions and delete package (one event per version), with the documented detail and a per-package `sequenceNumber`. The issue asks for a repository state-change event, but AWS documents no payload for it (its EventBridge reference names it "Goshawk Repository State Change" and its CodeArtifact guide lists only the package version event), so none is emitted. Deleting a repository removes its packages without per-version events (a domain cannot be deleted while it has repositories). Ingestion events do not exist because nothing is ingested. +- The AWS reference does not state these behaviours; they were chosen conservatively and are unconfirmed against the live service: publishing a package that its package or group origin controls block, or a version that already exists in an upstream, answers `ConflictException`; a SHA-256 header that does not match the content answers `ValidationException`; adding an asset to a Published version answers `ConflictException`; downloading from an Archived version answers `ConflictException`; a name that is not one of the ten supported external connections, and an upstream that does not exist, answer `ResourceNotFoundException`; a repository that is another repository's upstream cannot be deleted (`ConflictException`); a repository holds upstreams or an external connection, not both; a package whose last version is deleted disappears unless its origin controls were set explicitly; a status update does not change a version's revision; package group errors carry a `resourceId` but no `resourceType`, because the API's `ResourceType` values do not include a package group; `DeleteDomain` answers `ValidationException` for a domain that does not exist, because the API does not model `ResourceNotFoundException` for it; a new domain has no default `/*` package group. +- A version's `revision` is a 32-character digest of its assets: it changes when an asset is added, removed or replaced, and stays the same across reads and status changes. A copy keeps the source revision. +- Package group patterns follow the grammar and word-boundary matching in the user guide. Matching a package to a group is exact (strong) or case, dash, dot and underscore tolerant (weak); the confusable-character normalization is not performed, and Python, NuGet and Swift names are not normalized before matching. A new group's three restrictions start as `INHERIT`, which resolves to `ALLOW` when no parent sets one. A weak match blocks publishing. Deleting a group re-parents its children and deleting a repository drops it from the allowed repository lists. +- Package origin controls default to publish `ALLOW` and upstream `BLOCK` for a package whose first version is published directly, or `ALLOW`/`ALLOW` when its group restricts anything. Origin controls limit publishing; upstream restrictions are stored and listed but only block a publish through the upstream-presence rule, because no ingestion exists. +- List operations take `maxResults` (1 to 1000, default 100) and `nextToken`, an opaque offset over results in a fixed order (names, or newest-published first for versions), so it is only stable while the list does not change between pages. A token the emulator did not issue is a `ValidationException`. +- `ListAssociatedPackages` and package group matching only see packages that exist in a repository (plus the packages `preview` evaluates for a group that does not exist yet); `GetAssociatedPackageGroup` works for coordinates that exist nowhere. diff --git a/docs/coverage/coverage.json b/docs/coverage/coverage.json index 7e006654b..9a9505d7f 100644 --- a/docs/coverage/coverage.json +++ b/docs/coverage/coverage.json @@ -4755,6 +4755,180 @@ "name": "UpdateRepository" } ], + "optionalCapabilities": [ + { + "name": "Access", + "doc": "Access is the optional capability behind `aws codeartifact login`.", + "operations": [ + { + "name": "GetAuthorizationToken", + "doc": "GetAuthorizationToken issues a bearer token for a domain; a zero durationSeconds means the default lifetime." + }, + { + "name": "GetRepositoryEndpoint", + "doc": "GetRepositoryEndpoint returns the URL of a repository for one package format (\"\", \"ipv4\" or \"dualstack\" endpoint type)." + } + ] + }, + { + "name": "PackageGroups", + "doc": "PackageGroups is the optional capability for package groups.", + "operations": [ + { + "name": "CreatePackageGroup", + "doc": "CreatePackageGroup creates a package group." + }, + { + "name": "DeletePackageGroup", + "doc": "DeletePackageGroup deletes a group; its children move up to its parent." + }, + { + "name": "DescribePackageGroup", + "doc": "DescribePackageGroup returns a package group with its parent and effective restrictions." + }, + { + "name": "GetAssociatedPackageGroup", + "doc": "GetAssociatedPackageGroup returns the group a package coordinate belongs to and how strongly." + }, + { + "name": "ListAllowedRepositoriesForGroup", + "doc": "ListAllowedRepositoriesForGroup lists the allowed repositories of one restriction type." + }, + { + "name": "ListAssociatedPackages", + "doc": "ListAssociatedPackages lists the packages whose most specific group is the given one." + }, + { + "name": "ListPackageGroups", + "doc": "ListPackageGroups lists a domain's package groups." + }, + { + "name": "ListSubPackageGroups", + "doc": "ListSubPackageGroups lists the direct children of a package group." + }, + { + "name": "UpdatePackageGroup", + "doc": "UpdatePackageGroup changes the contact info and description of a group." + }, + { + "name": "UpdatePackageGroupOriginConfiguration", + "doc": "UpdatePackageGroupOriginConfiguration sets restriction modes and edits the allowed repository lists." + } + ] + }, + { + "name": "PackageVersions", + "doc": "PackageVersions is the optional capability for package versions and their assets.", + "operations": [ + { + "name": "CopyPackageVersions", + "doc": "CopyPackageVersions copies versions between two repositories of one domain." + }, + { + "name": "DeletePackageVersions", + "doc": "DeletePackageVersions removes versions and their assets." + }, + { + "name": "DescribePackageVersion", + "doc": "DescribePackageVersion returns the description of one package version." + }, + { + "name": "DisposePackageVersions", + "doc": "DisposePackageVersions deletes the assets of versions and marks them Disposed." + }, + { + "name": "GetPackageVersionAsset", + "doc": "GetPackageVersionAsset returns the bytes of one asset." + }, + { + "name": "GetPackageVersionReadme", + "doc": "GetPackageVersionReadme returns the identity of a version; generic versions have no readme text." + }, + { + "name": "ListPackageVersionAssets", + "doc": "ListPackageVersionAssets lists the assets of a package version." + }, + { + "name": "ListPackageVersionDependencies", + "doc": "ListPackageVersionDependencies returns the identity of a version; generic versions have no dependencies." + }, + { + "name": "ListPackageVersions", + "doc": "ListPackageVersions lists the versions of a package, newest published first." + }, + { + "name": "PublishPackageVersion", + "doc": "PublishPackageVersion adds an asset to a generic package version, creating the version when needed." + }, + { + "name": "UpdatePackageVersionsStatus", + "doc": "UpdatePackageVersionsStatus moves versions to Archived, Published or Unlisted." + } + ] + }, + { + "name": "Packages", + "doc": "Packages is the optional capability for package records and their origin controls.", + "operations": [ + { + "name": "DeletePackage", + "doc": "DeletePackage deletes a package with all its versions and assets." + }, + { + "name": "DescribePackage", + "doc": "DescribePackage returns a package record." + }, + { + "name": "ListPackages", + "doc": "ListPackages lists the packages of a repository." + }, + { + "name": "PutPackageOriginConfiguration", + "doc": "PutPackageOriginConfiguration sets a package's origin controls, creating an empty record when it does not exist." + } + ] + }, + { + "name": "Policies", + "doc": "Policies is the optional capability for domain and repository permissions policies.", + "operations": [ + { + "name": "DeleteDomainPermissionsPolicy", + "doc": "DeleteDomainPermissionsPolicy removes the resource policy from a domain." + }, + { + "name": "DeleteRepositoryPermissionsPolicy", + "doc": "DeleteRepositoryPermissionsPolicy removes the resource policy from a repository." + }, + { + "name": "GetDomainPermissionsPolicy", + "doc": "GetDomainPermissionsPolicy returns the resource policy on a domain." + }, + { + "name": "GetRepositoryPermissionsPolicy", + "doc": "GetRepositoryPermissionsPolicy returns the resource policy on a repository." + }, + { + "name": "PutDomainPermissionsPolicy", + "doc": "PutDomainPermissionsPolicy sets the resource policy on a domain." + }, + { + "name": "PutRepositoryPermissionsPolicy", + "doc": "PutRepositoryPermissionsPolicy sets the resource policy on a repository." + } + ] + }, + { + "name": "RepositoryAdministratorLister", + "doc": "RepositoryAdministratorLister is the optional capability behind the administrator-account filter of", + "operations": [ + { + "name": "ListRepositoriesInDomainByAdministrator", + "doc": "ListRepositoriesInDomainByAdministrator lists a domain's repositories administered by one account." + } + ] + } + ], "providers": { "aws": "CodeArtifact" } diff --git a/docs/coverage/nongoals/codeartifact.md b/docs/coverage/nongoals/codeartifact.md new file mode 100644 index 000000000..9e1d198b4 --- /dev/null +++ b/docs/coverage/nongoals/codeartifact.md @@ -0,0 +1,15 @@ +- All 48 operations of the API (`aws-sdk-go-v2/service/codeartifact` v1.45.0) are served. What is not emulated is the package-manager side: there is no npm, pip, Maven, NuGet, RubyGems, Cargo, Swift or generic registry protocol. `GetRepositoryEndpoint` and `aws codeartifact login` work and report the URL AWS reports (`https://-.d.codeartifact..amazonaws.com///`), but nothing serves that host, so `npm install` and `pip install` against it do not reach the emulator. The `dualstack` and `ipv4` endpoint types return the same host. +- Only generic packages can be created through the API, which is also true of real AWS (`PublishPackageVersion` is generic-only). `PutPackageOriginConfiguration` creates an empty package record of any format. Packages of other formats never appear on their own. +- An external connection is stored (its name, the package format it serves, status `Available`) but nothing is fetched from the public registry, so no version has the `EXTERNAL` origin type and `ListPackageVersions` with `originType` `EXTERNAL` or `UNKNOWN` is always empty. Retaining versions from upstream repositories happens only through `CopyPackageVersions` with `includeFromUpstream`. +- Generic package versions carry no metadata the emulator can read, so `GetPackageVersionReadme` returns an empty readme, `ListPackageVersionDependencies` an empty list, and `DescribePackageVersion` has no `summary`, `homePage`, `sourceCodeRepository` or `licenses`. +- Asset bytes are kept in memory. The limit per asset is 128 MiB (AWS: 5 GiB, adjustable) and a package version holds at most 350 assets, as in AWS; a larger asset answers `ServiceQuotaExceededException`. Assets are stored once per domain, so `assetSizeBytes` sums the distinct SHA-256 digests in the domain. A snapshot taken without assets keeps the name, size and hashes of every asset but not its bytes: downloading one afterwards answers `ResourceNotFoundException`. `--persist` always keeps the bytes. +- The request-rate quotas (publish 10/s, copy 5/s, token 40/s and the rest) and `ThrottlingException` are not modelled. `AccessDeniedException` comes only from `--enforce-auth` identity policies: domain and repository permissions policies are validated (a JSON document with a `Statement`, at most 7168 bytes) and stored with an optimistic-lock revision, but never evaluated, and cross-account access is not modelled. A domain and its repositories always belong to the emulator's single account, so the domain owner and the repository administrator are the same account and a package version event is delivered once. +- The `encryptionKey` of a domain is stored and returned but not checked against KMS and not used: assets are not encrypted. The `s3BucketArn` is a synthesized name; no bucket exists. +- Authorization tokens are opaque random strings valid for the requested duration (0 means the 12-hour default; AWS ties 0 to the caller's role session). Nothing consumes them: no registry checks a token. +- Only the `CodeArtifact Package Version State Change` EventBridge event is emitted: on publish, adding an asset, a status change, copy, dispose, delete versions and delete package (one event per version), with the documented detail and a per-package `sequenceNumber`. The issue asks for a repository state-change event, but AWS documents no payload for it (its EventBridge reference names it "Goshawk Repository State Change" and its CodeArtifact guide lists only the package version event), so none is emitted. Deleting a repository removes its packages without per-version events (a domain cannot be deleted while it has repositories). Ingestion events do not exist because nothing is ingested. +- The AWS reference does not state these behaviours; they were chosen conservatively and are unconfirmed against the live service: publishing a package that its package or group origin controls block, or a version that already exists in an upstream, answers `ConflictException`; a SHA-256 header that does not match the content answers `ValidationException`; adding an asset to a Published version answers `ConflictException`; downloading from an Archived version answers `ConflictException`; a name that is not one of the ten supported external connections, and an upstream that does not exist, answer `ResourceNotFoundException`; a repository that is another repository's upstream cannot be deleted (`ConflictException`); a repository holds upstreams or an external connection, not both; a package whose last version is deleted disappears unless its origin controls were set explicitly; a status update does not change a version's revision; package group errors carry a `resourceId` but no `resourceType`, because the API's `ResourceType` values do not include a package group; `DeleteDomain` answers `ValidationException` for a domain that does not exist, because the API does not model `ResourceNotFoundException` for it; a new domain has no default `/*` package group. +- A version's `revision` is a 32-character digest of its assets: it changes when an asset is added, removed or replaced, and stays the same across reads and status changes. A copy keeps the source revision. +- Package group patterns follow the grammar and word-boundary matching in the user guide. Matching a package to a group is exact (strong) or case, dash, dot and underscore tolerant (weak); the confusable-character normalization is not performed, and Python, NuGet and Swift names are not normalized before matching. A new group's three restrictions start as `INHERIT`, which resolves to `ALLOW` when no parent sets one. A weak match blocks publishing. Deleting a group re-parents its children and deleting a repository drops it from the allowed repository lists. +- Package origin controls default to publish `ALLOW` and upstream `BLOCK` for a package whose first version is published directly, or `ALLOW`/`ALLOW` when its group restricts anything. Origin controls limit publishing; upstream restrictions are stored and listed but only block a publish through the upstream-presence rule, because no ingestion exists. +- List operations take `maxResults` (1 to 1000, default 100) and `nextToken`, an opaque offset over results in a fixed order (names, or newest-published first for versions), so it is only stable while the list does not change between pages. A token the emulator did not issue is a `ValidationException`. +- `ListAssociatedPackages` and package group matching only see packages that exist in a repository (plus the packages `preview` evaluates for a group that does not exist yet); `GetAssociatedPackageGroup` works for coordinates that exist nowhere. diff --git a/providers/aws/aws.go b/providers/aws/aws.go index 3657d8bf5..581b02506 100644 --- a/providers/aws/aws.go +++ b/providers/aws/aws.go @@ -605,6 +605,7 @@ func wireLifecycleEvents(p *Provider) { p.CloudWatch.SetEventPublisher(p.EventBridge) p.SageMaker.SetEventPublisher(p.EventBridge) p.Batch.SetEventPublisher(p.EventBridge) + p.CodeArtifact.SetEventPublisher(p.EventBridge) } // awsDrivers assembles the resource-discovery driver set from the provider's diff --git a/providers/aws/codeartifact/access.go b/providers/aws/codeartifact/access.go new file mode 100644 index 000000000..54abc2836 --- /dev/null +++ b/providers/aws/codeartifact/access.go @@ -0,0 +1,74 @@ +package codeartifact + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "time" + + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +// Token lifetimes in seconds: the default, and the bounds a caller may request. +const ( + defaultTokenSeconds = 12 * 60 * 60 + minTokenSeconds = 15 * 60 + maxTokenSeconds = 12 * 60 * 60 + tokenRandomBytes = 48 +) + +// GetAuthorizationToken issues an opaque bearer token for a domain. The lifetime +// is the requested duration (0 means the 12-hour default, otherwise 900 to 43200 +// seconds). The domain must exist. +func (m *Mock) GetAuthorizationToken(_ context.Context, ref driver.DomainRef, durationSeconds int64) (*driver.AuthorizationToken, error) { + if durationSeconds != 0 && (durationSeconds < minTokenSeconds || durationSeconds > maxTokenSeconds) { + return nil, validation("duration must be 0 or between %d and %d seconds", minTokenSeconds, maxTokenSeconds) + } + + m.mu.RLock() + _, err := m.domainLocked(ref) + m.mu.RUnlock() + + if err != nil { + return nil, err + } + + if durationSeconds == 0 { + durationSeconds = defaultTokenSeconds + } + + raw := make([]byte, tokenRandomBytes) + if _, err = rand.Read(raw); err != nil { + return nil, fmt.Errorf("codeartifact: generate token: %w", err) + } + + return &driver.AuthorizationToken{ + Token: base64.RawURLEncoding.EncodeToString(raw), + Expiration: m.now().Add(time.Duration(durationSeconds) * time.Second), + }, nil +} + +// GetRepositoryEndpoint returns the URL of a repository for one package format, +// in the shape AWS reports: https://-.d.codeartifact..amazonaws.com///. +// The emulator does not serve that host or any registry protocol on it. +func (m *Mock) GetRepositoryEndpoint(_ context.Context, ref driver.RepositoryRef, format, endpointType string) (string, error) { + if !validFormat(format) { + return "", validation("package format %q is not one of npm, pypi, maven, nuget, generic, ruby, swift, cargo", format) + } + + if endpointType != "" && endpointType != driver.EndpointTypeIPv4 && endpointType != driver.EndpointTypeDualstack { + return "", validation("endpointType %q must be %s or %s", endpointType, driver.EndpointTypeIPv4, driver.EndpointTypeDualstack) + } + + m.mu.RLock() + d, _, err := m.repositoryLocked(ref) + m.mu.RUnlock() + + if err != nil { + return "", err + } + + return fmt.Sprintf("https://%s-%s.d.codeartifact.%s.amazonaws.com/%s/%s/", + d.Name, d.Owner, m.opts.Region, format, ref.Repository), nil +} diff --git a/providers/aws/codeartifact/access_test.go b/providers/aws/codeartifact/access_test.go new file mode 100644 index 000000000..e78991cee --- /dev/null +++ b/providers/aws/codeartifact/access_test.go @@ -0,0 +1,100 @@ +package codeartifact_test + +import ( + "context" + "testing" + "time" + + "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +func TestGetAuthorizationTokenDefaults(t *testing.T) { + clk := config.NewFakeClock(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) + m := newMockWithOptions(config.NewOptions(config.WithClock(clk))) + mustDomain(t, m, "dom") + + a, err := m.GetAuthorizationToken(context.Background(), driver.DomainRef{Domain: "dom"}, 0) + requireNoError(t, err) + + b, err := m.GetAuthorizationToken(context.Background(), driver.DomainRef{Domain: "dom"}, 0) + requireNoError(t, err) + + if a.Token == "" || a.Token == b.Token { + t.Fatalf("tokens must be non-empty and unique: %q %q", a.Token, b.Token) + } + + if want := clk.Now().Add(12 * time.Hour); !a.Expiration.Equal(want) { + t.Fatalf("expiration = %v, want %v", a.Expiration, want) + } +} + +func TestGetAuthorizationTokenDurationBounds(t *testing.T) { + clk := config.NewFakeClock(time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) + m := newMockWithOptions(config.NewOptions(config.WithClock(clk))) + mustDomain(t, m, "dom") + + for _, bad := range []int64{1, 899, 43201, -1} { + _, err := m.GetAuthorizationToken(context.Background(), driver.DomainRef{Domain: "dom"}, bad) + requireAPIError(t, err, driver.ExValidation) + } + + for _, ok := range []int64{900, 3600, 43200} { + tok, err := m.GetAuthorizationToken(context.Background(), driver.DomainRef{Domain: "dom"}, ok) + requireNoError(t, err) + + if want := clk.Now().Add(time.Duration(ok) * time.Second); !tok.Expiration.Equal(want) { + t.Fatalf("duration %d: expiration = %v, want %v", ok, tok.Expiration, want) + } + } +} + +func TestGetAuthorizationTokenUnknownDomainAndOwner(t *testing.T) { + m := newMock() + mustDomain(t, m, "dom") + + _, err := m.GetAuthorizationToken(context.Background(), driver.DomainRef{Domain: "ghost"}, 0) + requireAPIError(t, err, driver.ExNotFound) + + _, err = m.GetAuthorizationToken(context.Background(), driver.DomainRef{Domain: "dom", Owner: "999999999999"}, 0) + requireAPIError(t, err, driver.ExNotFound) + + _, err = m.GetAuthorizationToken(context.Background(), driver.DomainRef{Domain: "Bad Name"}, 0) + requireAPIError(t, err, driver.ExValidation) +} + +func TestGetRepositoryEndpointAllFormats(t *testing.T) { + opts := config.NewOptions() + m := newMockWithOptions(opts) + mustDomain(t, m, "dom") + mustRepo(t, m, "dom", "repo") + + for _, format := range []string{"npm", "pypi", "maven", "nuget", "generic", "ruby", "swift", "cargo"} { + for _, et := range []string{"", "ipv4", "dualstack"} { + got, err := m.GetRepositoryEndpoint(context.Background(), driver.RepositoryRef{Domain: "dom", Repository: "repo"}, format, et) + requireNoError(t, err) + + want := "https://dom-" + opts.AccountID + ".d.codeartifact." + opts.Region + ".amazonaws.com/" + format + "/repo/" + if got != want { + t.Fatalf("%s/%s: endpoint = %q, want %q", format, et, got, want) + } + } + } +} + +func TestGetRepositoryEndpointErrors(t *testing.T) { + m := newMock() + mustDomain(t, m, "dom") + mustRepo(t, m, "dom", "repo") + + ref := driver.RepositoryRef{Domain: "dom", Repository: "repo"} + + _, err := m.GetRepositoryEndpoint(context.Background(), ref, "docker", "") + requireAPIError(t, err, driver.ExValidation) + + _, err = m.GetRepositoryEndpoint(context.Background(), ref, "npm", "ipv6") + requireAPIError(t, err, driver.ExValidation) + + _, err = m.GetRepositoryEndpoint(context.Background(), driver.RepositoryRef{Domain: "dom", Repository: "ghost"}, "npm", "") + requireAPIError(t, err, driver.ExNotFound) +} diff --git a/providers/aws/codeartifact/bulk_versions.go b/providers/aws/codeartifact/bulk_versions.go new file mode 100644 index 000000000..fecf03e3b --- /dev/null +++ b/providers/aws/codeartifact/bulk_versions.go @@ -0,0 +1,233 @@ +package codeartifact + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +// bulkOp applies one operation to one package version that passed the common +// checks. It returns the success entry, or a failure entry, and optionally an +// event. +type bulkOp func(repo *driver.Repository, p *packageRecord, v *versionRecord) ( + driver.SuccessfulVersion, *driver.FailedVersion, *versionEvent) + +func validateVersionList(versions []string, revisions map[string]string, expectedStatus string) error { + if len(versions) > maxBulkVersions || len(revisions) > maxBulkVersions { + return validation("at most %d package versions can be named in one request", maxBulkVersions) + } + + for _, v := range versions { + if err := validateVersion(v); err != nil { + return err + } + } + + if err := validateRevisionPins(revisions); err != nil { + return err + } + + if expectedStatus != "" && !validVersionStatus(expectedStatus) { + return validation("expectedStatus %q is not a package version status", expectedStatus) + } + + return nil +} + +// validateRevisionPins checks the version -> revision map of a request. +func validateRevisionPins(revisions map[string]string) error { + for v, rev := range revisions { + if err := validateVersion(v); err != nil { + return err + } + + if rev == "" || len(rev) > revisionMaxLen || hasSpace(rev) { + return validation("revision for version %s must be 1-%d characters without whitespace", v, revisionMaxLen) + } + } + + return nil +} + +// precheckVersion applies the checks every bulk operation shares: the version +// exists, has the expected status and sits at the pinned revision. It returns +// the failure to report, or nil when the version may be operated on. +func precheckVersion(in *driver.VersionsInput, name string, v *versionRecord) *driver.FailedVersion { + switch { + case v == nil: + return &driver.FailedVersion{ErrorCode: driver.VersionErrNotFound, ErrorMessage: "Version " + name + " not found"} + case in.ExpectedStatus != "" && v.Status != in.ExpectedStatus: + return &driver.FailedVersion{ErrorCode: driver.VersionErrMismatchedStatus, ErrorMessage: "Version " + name + " is " + v.Status} + case pinnedRevisionMismatch(in.VersionRevisions, name, v): + return &driver.FailedVersion{ErrorCode: driver.VersionErrMismatchedRev, ErrorMessage: "Version " + name + " is at revision " + v.Revision} + default: + return nil + } +} + +// runBulk applies op to each named version, collecting per-version outcomes. +// Missing versions, a status other than expectedStatus and a revision other than +// the pinned one are reported as failures without calling op. A package left with +// no versions is removed unless its origin controls were set explicitly. +func (m *Mock) runBulk(in *driver.VersionsInput, op bulkOp) (*driver.VersionsResult, []versionEvent, error) { + if err := validatePackageRef(&in.Ref); err != nil { + return nil, nil, err + } + + if err := validateVersionList(in.Versions, in.VersionRevisions, in.ExpectedStatus); err != nil { + return nil, nil, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + repo, p, err := m.packageLocked(&in.Ref) + if err != nil { + return nil, nil, err + } + + res := &driver.VersionsResult{ + Successful: map[string]driver.SuccessfulVersion{}, Failed: map[string]driver.FailedVersion{}, + } + events := make([]versionEvent, 0, len(in.Versions)) + + for _, name := range uniqueStrings(in.Versions) { + v := p.Versions[name] + + if fail := precheckVersion(in, name, v); fail != nil { + res.Failed[name] = *fail + + continue + } + + succ, fail, ev := op(&repo, p, v) + if fail != nil { + res.Failed[name] = *fail + + continue + } + + res.Successful[name] = succ + + if ev != nil { + events = append(events, *ev) + } + } + + if len(p.Versions) == 0 && !p.ExplicitOrigin { + m.packages.Delete(refKey(&in.Ref)) + } + + return res, events, nil +} + +// uniqueStrings drops repeats, keeping first-seen order, so a version named twice +// in one request is applied and reported once. +func uniqueStrings(in []string) []string { + seen := make(map[string]bool, len(in)) + out := make([]string, 0, len(in)) + + for _, s := range in { + if seen[s] { + continue + } + + seen[s] = true + + out = append(out, s) + } + + return out +} + +func pinnedRevisionMismatch(revisions map[string]string, name string, v *versionRecord) bool { + rev, ok := revisions[name] + + return ok && rev != v.Revision +} + +// statusUpdateFailure returns why a version cannot move to target, or nil. +func statusUpdateFailure(v *versionRecord, target string) *driver.FailedVersion { + switch v.Status { + case target: + return &driver.FailedVersion{ErrorCode: driver.VersionErrSkipped, ErrorMessage: "Version " + v.Version + " is already " + v.Status} + case driver.VersionStatusDisposed: + return &driver.FailedVersion{ + ErrorCode: driver.VersionErrNotAllowed, ErrorMessage: "Version " + v.Version + " is disposed and cannot change status", + } + default: + return nil + } +} + +// UpdatePackageVersionsStatus moves versions to Archived, Published or Unlisted. +// Disposed versions cannot be revived; a version already in the target status is +// skipped. +func (m *Mock) UpdatePackageVersionsStatus(ctx context.Context, in *driver.UpdateStatusInput) (*driver.VersionsResult, error) { + switch in.TargetStatus { + case driver.VersionStatusPublished, driver.VersionStatusUnlisted, driver.VersionStatusArchived: + default: + return nil, validation("targetStatus %q must be Published, Unlisted or Archived", in.TargetStatus) + } + + res, events, err := m.runBulk(&in.VersionsInput, func(repo *driver.Repository, p *packageRecord, v *versionRecord) ( + driver.SuccessfulVersion, *driver.FailedVersion, *versionEvent) { + if fail := statusUpdateFailure(v, in.TargetStatus); fail != nil { + return driver.SuccessfulVersion{}, fail, nil + } + + v.Status = in.TargetStatus + if in.TargetStatus == driver.VersionStatusPublished && v.PublishedTime.IsZero() { + v.PublishedTime = m.now() + } + + ev := m.newVersionEventLocked(repo, p, v, operationUpdated, versionChanges{StatusChanged: true}) + + return driver.SuccessfulVersion{Revision: v.Revision, Status: v.Status}, nil, &ev + }) + + m.publishVersionEvents(ctx, events) + + return res, err +} + +// DisposePackageVersions deletes the assets of versions and sets them Disposed. +func (m *Mock) DisposePackageVersions(ctx context.Context, in *driver.VersionsInput) (*driver.VersionsResult, error) { + res, events, err := m.runBulk(in, func(repo *driver.Repository, p *packageRecord, v *versionRecord) ( + driver.SuccessfulVersion, *driver.FailedVersion, *versionEvent) { + if v.Status == driver.VersionStatusDisposed { + return driver.SuccessfulVersion{}, &driver.FailedVersion{ + ErrorCode: driver.VersionErrSkipped, ErrorMessage: "Version " + v.Version + " is already disposed", + }, nil + } + + removed := len(v.Assets) + v.Assets = map[string]*assetRecord{} + v.Status = driver.VersionStatusDisposed + v.Revision = computeRevision(v.Assets) + + ev := m.newVersionEventLocked(repo, p, v, operationUpdated, versionChanges{AssetsRemoved: removed, StatusChanged: true}) + + return driver.SuccessfulVersion{Revision: v.Revision, Status: v.Status}, nil, &ev + }) + + m.publishVersionEvents(ctx, events) + + return res, err +} + +// DeletePackageVersions removes versions and their assets for good. +func (m *Mock) DeletePackageVersions(ctx context.Context, in *driver.VersionsInput) (*driver.VersionsResult, error) { + res, events, err := m.runBulk(in, func(repo *driver.Repository, p *packageRecord, v *versionRecord) ( + driver.SuccessfulVersion, *driver.FailedVersion, *versionEvent) { + ev := m.newVersionEventLocked(repo, p, v, operationDeleted, versionChanges{AssetsRemoved: len(v.Assets), StatusChanged: true}) + + delete(p.Versions, v.Version) + + return driver.SuccessfulVersion{Revision: v.Revision, Status: driver.VersionStatusDeleted}, nil, &ev + }) + + m.publishVersionEvents(ctx, events) + + return res, err +} diff --git a/providers/aws/codeartifact/codeartifact.go b/providers/aws/codeartifact/codeartifact.go index 5366399da..0994c495c 100644 --- a/providers/aws/codeartifact/codeartifact.go +++ b/providers/aws/codeartifact/codeartifact.go @@ -1,69 +1,94 @@ // Package codeartifact provides an in-memory mock implementation of the AWS -// CodeArtifact control plane: domains, repositories (with upstream references -// and external connections), and resource tagging. +// CodeArtifact API: domains, repositories (with upstream references and +// external connections), resource tagging and permissions policies, package +// records, package versions with real asset bytes, package origin controls and +// package groups. // -// The mock is control-plane only. It does NOT run a package data plane (no -// package publish, version resolution or asset storage). A domain and a +// The mock serves the 48 management-API operations. It does NOT serve the +// package-manager registry protocols (npm, pip, Maven, ...), so the only +// packages that can be created through the API are generic ones, which is also +// what real AWS allows (PublishPackageVersion is generic-only). A domain and a // repository are created synchronously in the Active state with stable computed -// fields (the domain arn/owner/createdTime, the repository -// arn/administratorAccount/createdTime) minted once at create and stored, so -// repeated reads never drift. The domain repositoryCount is derived live from -// the child repositories and assetSizeBytes is always 0. +// fields minted once at create and stored, so repeated reads never drift. +// +// Concurrency: every state change that checks another entity (create with a +// cap or a unique name, delete with dependents, publish, copy, policy and group +// edits) runs under one Mock-level lock, and every read deep-copies what it +// returns. Events are collected under the lock and published after it is +// released, because delivery may call back into the mock. package codeartifact import ( - "strconv" - "strings" + "sync" "time" "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/internal/awsevents" "github.com/stackshy/cloudemu/v2/internal/idgen" "github.com/stackshy/cloudemu/v2/internal/memstore" "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" ) -// Compile-time check that Mock implements driver.CodeArtifact. -var _ driver.CodeArtifact = (*Mock)(nil) - -// defaultMaxResults caps a page when the caller requests none. -const defaultMaxResults = 100 +// Compile-time checks that Mock implements the driver and its optional +// capabilities. +var ( + _ driver.CodeArtifact = (*Mock)(nil) + _ driver.Access = (*Mock)(nil) + _ driver.RepositoryAdministratorLister = (*Mock)(nil) + _ driver.Policies = (*Mock)(nil) + _ driver.Packages = (*Mock)(nil) + _ driver.PackageVersions = (*Mock)(nil) + _ driver.PackageGroups = (*Mock)(nil) +) // ARN resource kinds and the service marker. const ( - kindDomain = "domain" - kindRepository = "repository" - serviceName = "codeartifact" + kindDomain = "domain" + kindRepository = "repository" + kindPackageGroup = "package-group" + serviceName = "codeartifact" ) -// Mock is an in-memory implementation of the AWS CodeArtifact control plane. -// Domains are keyed by name; repositories are keyed by "/". +// Mock is an in-memory implementation of the AWS CodeArtifact API. +// +// The stores are keyed as follows: domains by name; repositories by +// "/"; domain policies by domain; repository policies by +// "/"; packages by "/// +// /"; package groups by "|". Records held by +// pointer (packages, groups) are read and written only under mu. type Mock struct { - domains *memstore.Store[driver.Domain] - repos *memstore.Store[driver.Repository] - opts *config.Options + mu sync.RWMutex + domains *memstore.Store[driver.Domain] + repos *memstore.Store[driver.Repository] + domainPolicies *memstore.Store[driver.ResourcePolicy] + repoPolicies *memstore.Store[driver.ResourcePolicy] + packages *memstore.Store[*packageRecord] + groups *memstore.Store[*groupRecord] + opts *config.Options + events awsevents.Emitter } // New creates a new CodeArtifact mock with the given configuration options. func New(opts *config.Options) *Mock { return &Mock{ - domains: memstore.New[driver.Domain](), - repos: memstore.New[driver.Repository](), - opts: opts, + domains: memstore.New[driver.Domain](), + repos: memstore.New[driver.Repository](), + domainPolicies: memstore.New[driver.ResourcePolicy](), + repoPolicies: memstore.New[driver.ResourcePolicy](), + packages: memstore.New[*packageRecord](), + groups: memstore.New[*groupRecord](), + opts: opts, } } -func (m *Mock) now() time.Time { - return m.opts.Clock.Now().UTC() +// SetEventPublisher wires the EventBridge default bus that package version +// state changes are published to. +func (m *Mock) SetEventPublisher(p awsevents.Publisher) { + m.events.SetPublisher(p) } -// resolveOwner returns the effective domain owner: the caller-supplied owner -// when present, otherwise the emulator's account id. -func (m *Mock) resolveOwner(domainOwner string) string { - if domainOwner == "" { - return m.opts.AccountID - } - - return domainOwner +func (m *Mock) now() time.Time { + return m.opts.Clock.Now().UTC() } // domainARN mints the stable ARN reported for a domain. @@ -76,36 +101,52 @@ func (m *Mock) repositoryARN(owner, domain, repo string) string { return idgen.AWSARN(serviceName, m.opts.Region, owner, kindRepository+"/"+domain+"/"+repo) } +// groupARN mints the stable ARN reported for a package group. The pattern +// starts with a slash, so the resource reads package-group///... +func (m *Mock) groupARN(owner, domain, pattern string) string { + return idgen.AWSARN(serviceName, m.opts.Region, owner, kindPackageGroup+"/"+domain+pattern) +} + // repoKey is the store key for a repository. func repoKey(domain, repo string) string { return domain + "/" + repo } -// resourceRefFromARN extracts the (kind, key) pair from a CodeArtifact resource -// ARN. A domain ARN of the form arn:aws:codeartifact:{region}:{acct}:domain/{name} -// yields (domain, name); a repository ARN of the form -// arn:aws:codeartifact:{region}:{acct}:repository/{domain}/{repo} yields -// (repository, "{domain}/{repo}"). -func resourceRefFromARN(arn string) (kind, key string) { - const arnParts = 6 +// domainLocked resolves a domain reference: the name and owner are validated, +// and a domain that does not exist under that owner is a ResourceNotFound. The +// caller holds mu. +func (m *Mock) domainLocked(ref driver.DomainRef) (driver.Domain, error) { + if err := validateDomainRef(ref); err != nil { + return driver.Domain{}, err + } - if !strings.Contains(arn, ":"+serviceName+":") { - return "", "" + d, ok := m.domains.Get(ref.Domain) + if !ok || (ref.Owner != "" && ref.Owner != d.Owner) { + return driver.Domain{}, notFound(driver.ResourceTypeDomain, ref.Domain, "Domain %s not found", ref.Domain) } - parts := strings.SplitN(arn, ":", arnParts) - if len(parts) < arnParts { - return "", "" + return d, nil +} + +// repositoryLocked resolves a repository reference through its domain. The +// caller holds mu. +func (m *Mock) repositoryLocked(ref driver.RepositoryRef) (driver.Domain, driver.Repository, error) { + if err := validateRepositoryRef(ref); err != nil { + return driver.Domain{}, driver.Repository{}, err } - resource := parts[arnParts-1] + d, err := m.domainLocked(driver.DomainRef{Domain: ref.Domain, Owner: ref.Owner}) + if err != nil { + return driver.Domain{}, driver.Repository{}, err + } - slash := strings.IndexByte(resource, '/') - if slash < 0 { - return "", "" + r, ok := m.repos.Get(repoKey(ref.Domain, ref.Repository)) + if !ok { + return driver.Domain{}, driver.Repository{}, notFound(driver.ResourceTypeRepository, ref.Repository, + "Repository %s not found in domain %s", ref.Repository, ref.Domain) } - return resource[:slash], resource[slash+1:] + return d, r, nil } func copyTags(in map[string]string) map[string]string { @@ -155,55 +196,3 @@ func copyRepository(r *driver.Repository) driver.Repository { return out } - -// repositoryCount returns the number of repositories that belong to a domain. -func (m *Mock) repositoryCount(domain string) int64 { - var n int64 - - repos := m.repos.SortedValues() - for i := range repos { - if repos[i].DomainName == domain { - n++ - } - } - - return n -} - -// paginate returns the offset window and next token for a slice of length n, -// honoring an opaque numeric offset token. -func paginate(n int, page driver.Page) (start, end int, next string) { - start = decodeToken(page.NextToken) - if start > n { - start = n - } - - limit := int(page.MaxResults) - if limit <= 0 { - limit = defaultMaxResults - } - - end = start + limit - if end >= n { - return start, n, "" - } - - return start, end, encodeToken(end) -} - -func encodeToken(offset int) string { - return strconv.Itoa(offset) -} - -func decodeToken(token string) int { - if token == "" { - return 0 - } - - n, err := strconv.Atoi(token) - if err != nil || n < 0 { - return 0 - } - - return n -} diff --git a/providers/aws/codeartifact/codeartifact_test.go b/providers/aws/codeartifact/codeartifact_test.go index b7384c74f..d5aa7b3aa 100644 --- a/providers/aws/codeartifact/codeartifact_test.go +++ b/providers/aws/codeartifact/codeartifact_test.go @@ -12,7 +12,11 @@ import ( ) func newMock() *codeartifact.Mock { - return codeartifact.New(config.NewOptions()) + return newMockWithOptions(config.NewOptions()) +} + +func newMockWithOptions(opts *config.Options) *codeartifact.Mock { + return codeartifact.New(opts) } func requireNoError(t *testing.T, err error) { @@ -95,7 +99,7 @@ func TestRepositoryRequiresDomain(t *testing.T) { m := newMock() _, err := m.CreateRepository(context.Background(), &driver.CreateRepositoryInput{ - Domain: "ghost", Repository: "r", + Domain: "ghost", Repository: "repo", }) if !cerrors.IsNotFound(err) { t.Fatalf("repo without domain: got %v, want NotFound", err) @@ -104,18 +108,18 @@ func TestRepositoryRequiresDomain(t *testing.T) { func TestRepositoryComputedAndCount(t *testing.T) { m := newMock() - mustDomain(t, m, "d") - r := mustRepo(t, m, "d", "repo") + mustDomain(t, m, "dom") + r := mustRepo(t, m, "dom", "repo") if r.AdministratorAccount == "" || r.DomainOwner == "" { t.Fatalf("empty computed field: %+v", r) } - if !strings.HasSuffix(r.Arn, ":repository/d/repo") { - t.Fatalf("arn = %q, want suffix :repository/d/repo", r.Arn) + if !strings.HasSuffix(r.Arn, ":repository/dom/repo") { + t.Fatalf("arn = %q, want suffix :repository/dom/repo", r.Arn) } - d, err := m.DescribeDomain(context.Background(), "d", "") + d, err := m.DescribeDomain(context.Background(), "dom", "") requireNoError(t, err) if d.RepositoryCount != 1 { @@ -125,13 +129,13 @@ func TestRepositoryComputedAndCount(t *testing.T) { func TestUpdateRepositoryPreservesIdentity(t *testing.T) { m := newMock() - mustDomain(t, m, "d") - created := mustRepo(t, m, "d", "repo") + mustDomain(t, m, "dom") + created := mustRepo(t, m, "dom", "repo") desc := "new description" upd, err := m.UpdateRepository(context.Background(), &driver.UpdateRepositoryInput{ - Domain: "d", Repository: "repo", Description: &desc, + Domain: "dom", Repository: "repo", Description: &desc, }) requireNoError(t, err) @@ -146,17 +150,18 @@ func TestUpdateRepositoryPreservesIdentity(t *testing.T) { func TestUpdateRepositoryAbsentFieldsUnchanged(t *testing.T) { m := newMock() - mustDomain(t, m, "d") + mustDomain(t, m, "dom") + mustRepo(t, m, "dom", "up") _, err := m.CreateRepository(context.Background(), &driver.CreateRepositoryInput{ - Domain: "d", Repository: "repo", Description: "keep", + Domain: "dom", Repository: "repo", Description: "keep", Upstreams: []driver.UpstreamRef{{RepositoryName: "up"}}, }) requireNoError(t, err) // Update with neither description nor upstreams: both preserved. upd, err := m.UpdateRepository(context.Background(), &driver.UpdateRepositoryInput{ - Domain: "d", Repository: "repo", + Domain: "dom", Repository: "repo", }) requireNoError(t, err) @@ -167,31 +172,31 @@ func TestUpdateRepositoryAbsentFieldsUnchanged(t *testing.T) { func TestDeleteDomainWithRepositoriesConflict(t *testing.T) { m := newMock() - mustDomain(t, m, "d") - mustRepo(t, m, "d", "repo") + mustDomain(t, m, "dom") + mustRepo(t, m, "dom", "repo") - _, err := m.DeleteDomain(context.Background(), "d", "") + _, err := m.DeleteDomain(context.Background(), "dom", "") if !cerrors.IsAlreadyExists(err) { t.Fatalf("delete non-empty domain: got %v, want conflict", err) } // After removing the repo, delete succeeds. - _, err = m.DeleteRepository(context.Background(), "d", "", "repo") + _, err = m.DeleteRepository(context.Background(), "dom", "", "repo") requireNoError(t, err) - _, err = m.DeleteDomain(context.Background(), "d", "") + _, err = m.DeleteDomain(context.Background(), "dom", "") requireNoError(t, err) } func TestListRepositoriesInDomainFiltered(t *testing.T) { m := newMock() - mustDomain(t, m, "d1") - mustDomain(t, m, "d2") - mustRepo(t, m, "d1", "a") - mustRepo(t, m, "d1", "b") - mustRepo(t, m, "d2", "c") + mustDomain(t, m, "dom1") + mustDomain(t, m, "dom2") + mustRepo(t, m, "dom1", "aa") + mustRepo(t, m, "dom1", "bb") + mustRepo(t, m, "dom2", "cc") - repos, _, err := m.ListRepositoriesInDomain(context.Background(), "d1", "", "", driver.Page{}) + repos, _, err := m.ListRepositoriesInDomain(context.Background(), "dom1", "", "", driver.Page{}) requireNoError(t, err) if len(repos) != 2 { @@ -208,17 +213,17 @@ func TestListRepositoriesInDomainFiltered(t *testing.T) { func TestExternalConnectionAssociateDisassociate(t *testing.T) { m := newMock() - mustDomain(t, m, "d") - mustRepo(t, m, "d", "repo") + mustDomain(t, m, "dom") + mustRepo(t, m, "dom", "repo") - assoc, err := m.AssociateExternalConnection(context.Background(), "d", "", "repo", "public:pypi") + assoc, err := m.AssociateExternalConnection(context.Background(), "dom", "", "repo", "public:pypi") requireNoError(t, err) if len(assoc.ExternalConnections) != 1 || assoc.ExternalConnections[0].PackageFormat != "pypi" { t.Fatalf("externalConnections = %+v", assoc.ExternalConnections) } - dis, err := m.DisassociateExternalConnection(context.Background(), "d", "", "repo", "public:pypi") + dis, err := m.DisassociateExternalConnection(context.Background(), "dom", "", "repo", "public:pypi") requireNoError(t, err) if len(dis.ExternalConnections) != 0 { @@ -230,8 +235,8 @@ func TestTagsOnDomainAndRepository(t *testing.T) { m := newMock() ctx := context.Background() - domainArn := mustDomain(t, m, "d").Arn - repoArn := mustRepo(t, m, "d", "repo").Arn + domainArn := mustDomain(t, m, "dom").Arn + repoArn := mustRepo(t, m, "dom", "repo").Arn for _, arn := range []string{domainArn, repoArn} { requireNoError(t, m.TagResource(ctx, arn, map[string]string{"k": "v", "drop": "x"})) @@ -266,8 +271,8 @@ func TestSnapshotRestoreRoundTrip(t *testing.T) { m := newMock() ctx := context.Background() - created := mustDomain(t, m, "d") - repo := mustRepo(t, m, "d", "repo") + created := mustDomain(t, m, "dom") + repo := mustRepo(t, m, "dom", "repo") data, err := m.Snapshot(ctx, false) requireNoError(t, err) @@ -275,14 +280,14 @@ func TestSnapshotRestoreRoundTrip(t *testing.T) { restored := newMock() requireNoError(t, restored.Restore(ctx, data)) - d, err := restored.DescribeDomain(ctx, "d", "") + d, err := restored.DescribeDomain(ctx, "dom", "") requireNoError(t, err) if d.Arn != created.Arn || d.RepositoryCount != 1 { t.Fatalf("domain not restored with identity/count: %+v", d) } - r, err := restored.DescribeRepository(ctx, "d", "", "repo") + r, err := restored.DescribeRepository(ctx, "dom", "", "repo") requireNoError(t, err) if r.Arn != repo.Arn { diff --git a/providers/aws/codeartifact/copy_versions.go b/providers/aws/codeartifact/copy_versions.go new file mode 100644 index 000000000..b9dc7c885 --- /dev/null +++ b/providers/aws/codeartifact/copy_versions.go @@ -0,0 +1,163 @@ +package codeartifact + +import ( + "context" + "sort" + + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +// validateCopy checks a CopyPackageVersions request before any state is read. +func validateCopy(in *driver.CopyInput) error { + src := in.Ref + src.Repository = in.SourceRepository + + if err := validatePackageRef(&src); err != nil { + return err + } + + if err := validateRepositoryName(in.DestinationRepository); err != nil { + return err + } + + if in.SourceRepository == in.DestinationRepository { + return validation("the source and destination repositories must differ") + } + + hasVersions, hasRevisions := len(in.Versions) > 0, len(in.VersionRevisions) > 0 + if hasVersions == hasRevisions { + return validation("specify versions or versionRevisions, not both and not neither") + } + + return validateVersionList(in.Versions, in.VersionRevisions, "") +} + +// copyTargets returns the versions a copy names, in a deterministic order. +func copyTargets(in *driver.CopyInput) []string { + if len(in.Versions) > 0 { + return in.Versions + } + + out := make([]string, 0, len(in.VersionRevisions)) + for v := range in.VersionRevisions { + out = append(out, v) + } + + sort.Strings(out) + + return out +} + +// CopyPackageVersions copies versions between two repositories of one domain. +// With IncludeFromUpstream the source's upstream repositories are searched too. +// A version already in the destination is an ALREADY_EXISTS failure unless +// AllowOverwrite is set. +func (m *Mock) CopyPackageVersions(ctx context.Context, in *driver.CopyInput) (*driver.VersionsResult, error) { + res, events, err := m.copyVersions(in) + m.publishVersionEvents(ctx, events) + + return res, err +} + +func (m *Mock) copyVersions(in *driver.CopyInput) (*driver.VersionsResult, []versionEvent, error) { + if err := validateCopy(in); err != nil { + return nil, nil, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + ref := in.Ref + owner := ref.Owner + + if _, _, err := m.repositoryLocked(driver.RepositoryRef{Domain: ref.Domain, Owner: owner, Repository: in.SourceRepository}); err != nil { + return nil, nil, err + } + + _, destRepo, err := m.repositoryLocked(driver.RepositoryRef{Domain: ref.Domain, Owner: owner, Repository: in.DestinationRepository}) + if err != nil { + return nil, nil, err + } + + res := &driver.VersionsResult{Successful: map[string]driver.SuccessfulVersion{}, Failed: map[string]driver.FailedVersion{}} + + targets := uniqueStrings(copyTargets(in)) + events := make([]versionEvent, 0, len(targets)) + + for _, name := range targets { + src := m.findVersionLocked(ref.Domain, in.SourceRepository, ref.Format, ref.Namespace, ref.Package, name, in.IncludeFromUpstream) + + if fail := copyPrecheck(in, name, src); fail != nil { + res.Failed[name] = *fail + + continue + } + + dp, existed := m.packages.Get(packageKey(ref.Domain, in.DestinationRepository, ref.Format, ref.Namespace, ref.Package)) + if !existed { + dp = &packageRecord{ + Domain: ref.Domain, Repository: in.DestinationRepository, Format: ref.Format, Namespace: ref.Namespace, + Name: ref.Package, Versions: map[string]*versionRecord{}, + Restrictions: m.defaultRestrictionsLocked(ref.Domain, ref.Format, ref.Namespace, ref.Package), + } + m.packages.Set(packageKey(ref.Domain, in.DestinationRepository, ref.Format, ref.Namespace, ref.Package), dp) + } + + if _, dup := dp.Versions[name]; dup && !in.AllowOverwrite { + res.Failed[name] = driver.FailedVersion{ + ErrorCode: driver.VersionErrAlreadyExists, + ErrorMessage: "Version " + name + " already exists in repository " + in.DestinationRepository, + } + + continue + } + + operation := operationCreated + if _, dup := dp.Versions[name]; dup { + operation = operationUpdated + } + + copied := cloneVersion(src, in.DestinationRepository) + dp.Versions[name] = copied + res.Successful[name] = driver.SuccessfulVersion{Revision: copied.Revision, Status: copied.Status} + + events = append(events, m.newVersionEventLocked(&destRepo, dp, copied, operation, + versionChanges{AssetsAdded: len(copied.Assets), StatusChanged: true})) + } + + return res, events, nil +} + +// copyPrecheck returns the failure for a version that cannot be copied, or nil. +func copyPrecheck(in *driver.CopyInput, name string, src *versionRecord) *driver.FailedVersion { + switch { + case src == nil: + return &driver.FailedVersion{ErrorCode: driver.VersionErrNotFound, ErrorMessage: "Version " + name + " not found"} + case pinnedRevisionMismatch(in.VersionRevisions, name, src): + return &driver.FailedVersion{ + ErrorCode: driver.VersionErrMismatchedRev, ErrorMessage: "Version " + name + " is at revision " + src.Revision, + } + case src.Status == driver.VersionStatusDisposed: + return &driver.FailedVersion{ + ErrorCode: driver.VersionErrNotAllowed, ErrorMessage: "Version " + name + " is disposed and has no assets to copy", + } + default: + return nil + } +} + +// cloneVersion copies a version into a repository. Asset bytes are shared: they +// are never modified once stored. +func cloneVersion(src *versionRecord, repo string) *versionRecord { + out := *src + out.Origin = driver.VersionOrigin{OriginType: driver.OriginTypeInternal, RepositoryName: repo} + out.Assets = make(map[string]*assetRecord, len(src.Assets)) + + for n, a := range src.Assets { + c := *a + c.Hashes = copyTags(a.Hashes) + out.Assets[n] = &c + } + + return &out +} diff --git a/providers/aws/codeartifact/domain.go b/providers/aws/codeartifact/domain.go index ea12fc0c3..ae56ab364 100644 --- a/providers/aws/codeartifact/domain.go +++ b/providers/aws/codeartifact/domain.go @@ -2,6 +2,7 @@ package codeartifact import ( "context" + "strings" "github.com/stackshy/cloudemu/v2/internal/idgen" "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" @@ -10,14 +11,29 @@ import ( // CreateDomain provisions a new domain synchronously in the Active state with // stable computed fields (arn, owner, createdTime and, when the caller supplied // none, a generated encryption key). A domain name already in use yields a -// ConflictException. +// ConflictException; an account holds at most 10 domains. func (m *Mock) CreateDomain(_ context.Context, in *driver.CreateDomainInput) (*driver.Domain, error) { - if in.Name == "" { - return nil, validation("domain name is required") + if err := validateDomainName(in.Name); err != nil { + return nil, err } + if err := validateTags(in.Tags); err != nil { + return nil, err + } + + if len(in.EncryptionKey) > maxEncryptionKey || hasSpace(in.EncryptionKey) { + return nil, validation("encryptionKey must be 1-%d characters without whitespace", maxEncryptionKey) + } + + m.mu.Lock() + defer m.mu.Unlock() + if m.domains.Has(in.Name) { - return nil, conflict("Domain %s already exists", in.Name) + return nil, conflict(driver.ResourceTypeDomain, in.Name, "Domain %s already exists", in.Name) + } + + if m.domains.Len() >= maxDomains { + return nil, quotaExceeded(driver.ResourceTypeDomain, in.Name, "An account can have at most %d domains", maxDomains) } owner := m.opts.AccountID @@ -28,15 +44,14 @@ func (m *Mock) CreateDomain(_ context.Context, in *driver.CreateDomainInput) (*d } d := driver.Domain{ - Name: in.Name, - Owner: owner, - Arn: m.domainARN(owner, in.Name), - Status: driver.DomainStatusActive, - EncryptionKey: encryptionKey, - S3BucketArn: idgen.ARN("aws", "s3", "", "", "assets-"+owner+"-"+m.opts.Region+"-"+idgen.UUID()), - CreatedTime: m.now(), - AssetSizeBytes: 0, - Tags: copyTags(in.Tags), + Name: in.Name, + Owner: owner, + Arn: m.domainARN(owner, in.Name), + Status: driver.DomainStatusActive, + EncryptionKey: encryptionKey, + S3BucketArn: idgen.ARN("aws", "s3", "", "", "assets-"+owner+"-"+m.opts.Region+"-"+idgen.UUID()), + CreatedTime: m.now(), + Tags: copyTags(in.Tags), } m.domains.Set(in.Name, d) @@ -47,11 +62,14 @@ func (m *Mock) CreateDomain(_ context.Context, in *driver.CreateDomainInput) (*d } // DescribeDomain returns a copy of the domain. The stored computed fields are -// returned unchanged and repositoryCount is recomputed live. -func (m *Mock) DescribeDomain(_ context.Context, domain, _ string) (*driver.Domain, error) { - d, ok := m.domains.Get(domain) - if !ok { - return nil, notFound("Domain %s not found", domain) +// returned unchanged; repositoryCount and assetSizeBytes are recomputed live. +func (m *Mock) DescribeDomain(_ context.Context, domain, domainOwner string) (*driver.Domain, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + d, err := m.domainLocked(driver.DomainRef{Domain: domain, Owner: domainOwner}) + if err != nil { + return nil, err } out := m.hydrateDomain(&d) @@ -59,31 +77,49 @@ func (m *Mock) DescribeDomain(_ context.Context, domain, _ string) (*driver.Doma return &out, nil } -// DeleteDomain removes an empty domain and returns its description. A domain that -// still contains repositories yields a ConflictException, mirroring the real -// API which requires the domain to be empty first. -func (m *Mock) DeleteDomain(_ context.Context, domain, _ string) (*driver.Domain, error) { - d, ok := m.domains.Get(domain) - if !ok { - return nil, notFound("Domain %s not found", domain) +// DeleteDomain removes an empty domain together with its policy and package +// groups, and returns its description. A domain that still contains +// repositories yields a ConflictException, mirroring the real API which +// requires the domain to be empty first. +func (m *Mock) DeleteDomain(_ context.Context, domain, domainOwner string) (*driver.Domain, error) { + m.mu.Lock() + defer m.mu.Unlock() + + d, err := m.domainLocked(driver.DomainRef{Domain: domain, Owner: domainOwner}) + if err != nil { + return nil, err } - if m.repositoryCount(domain) > 0 { - return nil, conflict("Domain %s cannot be deleted because it still contains repositories", domain) + if m.repositoryCountLocked(domain) > 0 { + return nil, conflict(driver.ResourceTypeDomain, domain, + "Domain %s cannot be deleted because it still contains repositories", domain) } out := m.hydrateDomain(&d) out.Status = driver.DomainStatusDeleted m.domains.Delete(domain) + m.domainPolicies.Delete(domain) + + for key := range m.groups.Filter(func(k string, _ *groupRecord) bool { return strings.HasPrefix(k, domain+"|") }) { + m.groups.Delete(key) + } return &out, nil } // ListDomains returns a deterministic page of domains ordered by name. func (m *Mock) ListDomains(_ context.Context, page driver.Page) ([]*driver.Domain, string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + stored := m.domains.SortedValues() - start, end, next := paginate(len(stored), page) + + start, end, next, err := paginate(len(stored), page) + if err != nil { + return nil, "", err + } + out := make([]*driver.Domain, 0, end-start) for i := start; i < end; i++ { @@ -95,10 +131,16 @@ func (m *Mock) ListDomains(_ context.Context, page driver.Page) ([]*driver.Domai } // hydrateDomain returns an alias-free copy of a domain with its live-derived -// repositoryCount filled in. +// repositoryCount and assetSizeBytes filled in. The caller holds mu. func (m *Mock) hydrateDomain(d *driver.Domain) driver.Domain { out := copyDomain(d) - out.RepositoryCount = m.repositoryCount(d.Name) + out.RepositoryCount = m.repositoryCountLocked(d.Name) + out.AssetSizeBytes = m.domainAssetBytesLocked(d.Name) return out } + +// repositoryCountLocked returns the number of repositories in a domain. +func (m *Mock) repositoryCountLocked(domain string) int64 { + return int64(len(m.repos.Filter(func(_ string, r driver.Repository) bool { return r.DomainName == domain }))) +} diff --git a/providers/aws/codeartifact/errors.go b/providers/aws/codeartifact/errors.go index 7a5275c04..6f1687480 100644 --- a/providers/aws/codeartifact/errors.go +++ b/providers/aws/codeartifact/errors.go @@ -5,27 +5,43 @@ import ( "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" ) -// notFound builds a ResourceNotFoundException-tagged error. -func notFound(format string, args ...any) error { +// notFound builds a ResourceNotFoundException naming the missing resource. +func notFound(resourceType, resourceID, format string, args ...any) error { return &driver.APIError{ - Exception: driver.ExNotFound, - Err: errors.Newf(errors.NotFound, format, args...), + Exception: driver.ExNotFound, + Err: errors.Newf(errors.NotFound, format, args...), + ResourceID: resourceID, + ResourceType: resourceType, } } -// validation builds a ValidationException-tagged error for invalid input. +// validation builds a ValidationException for a request field that breaks the +// API's constraints. func validation(format string, args ...any) error { return &driver.APIError{ Exception: driver.ExValidation, Err: errors.Newf(errors.InvalidArgument, format, args...), + Reason: driver.ReasonFieldValidationFailed, } } -// conflict builds a ConflictException-tagged error for a duplicate resource or -// an unmet precondition. -func conflict(format string, args ...any) error { +// conflict builds a ConflictException for a duplicate resource or an unmet +// precondition, naming the resource it concerns. +func conflict(resourceType, resourceID, format string, args ...any) error { return &driver.APIError{ - Exception: driver.ExConflict, - Err: errors.Newf(errors.AlreadyExists, format, args...), + Exception: driver.ExConflict, + Err: errors.Newf(errors.AlreadyExists, format, args...), + ResourceID: resourceID, + ResourceType: resourceType, + } +} + +// quotaExceeded builds a ServiceQuotaExceededException for a service limit. +func quotaExceeded(resourceType, resourceID, format string, args ...any) error { + return &driver.APIError{ + Exception: driver.ExServiceQuota, + Err: errors.Newf(errors.ResourceExhausted, format, args...), + ResourceID: resourceID, + ResourceType: resourceType, } } diff --git a/providers/aws/codeartifact/events.go b/providers/aws/codeartifact/events.go new file mode 100644 index 000000000..c652dde41 --- /dev/null +++ b/providers/aws/codeartifact/events.go @@ -0,0 +1,136 @@ +package codeartifact + +import ( + "context" + "crypto/rand" + "encoding/base64" + "sort" + + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +// EventBridge identity of CodeArtifact's native package version events. See +// "CodeArtifact event format and example" in the CodeArtifact user guide. +const ( + eventSource = "aws.codeartifact" + eventPackageVersionDesc = "CodeArtifact Package Version State Change" + + operationCreated = "Created" + operationUpdated = "Updated" + operationDeleted = "Deleted" + + dedupBytes = 24 +) + +// versionChanges says what a package version event changed. +type versionChanges struct { + AssetsAdded int + AssetsRemoved int + AssetsUpdated int + MetadataUpdated bool + StatusChanged bool +} + +type versionEventChanges struct { + AssetsAdded int `json:"assetsAdded"` + AssetsRemoved int `json:"assetsRemoved"` + AssetsUpdated int `json:"assetsUpdated"` + MetadataUpdated bool `json:"metadataUpdated"` + StatusChanged bool `json:"statusChanged"` +} + +// versionEventDetail is the detail of a "CodeArtifact Package Version State +// Change" event, field for field as the user guide documents it. +type versionEventDetail struct { + DomainName string `json:"domainName"` + DomainOwner string `json:"domainOwner"` + RepositoryName string `json:"repositoryName"` + RepositoryAdministrator string `json:"repositoryAdministrator"` + PackageFormat string `json:"packageFormat"` + PackageNamespace *string `json:"packageNamespace"` + PackageName string `json:"packageName"` + PackageVersion string `json:"packageVersion"` + PackageVersionState string `json:"packageVersionState"` + PackageVersionRevision string `json:"packageVersionRevision"` + Changes versionEventChanges `json:"changes"` + OperationType string `json:"operationType"` + SequenceNumber int64 `json:"sequenceNumber"` + EventDeduplicationID string `json:"eventDeduplicationId"` +} + +// versionEvent is a package version event built under the lock and published +// after it is released. +type versionEvent struct { + detail versionEventDetail + resource string +} + +// packageARN mints the ARN a package version event reports as its resource: +// package/////. +func (m *Mock) packageARN(owner string, p *packageRecord) string { + return idgen.AWSARN(serviceName, m.opts.Region, owner, + "package/"+packageKey(p.Domain, p.Repository, p.Format, p.Namespace, p.Name)) +} + +// newVersionEventLocked builds the event for a change to a package version and +// advances the package's sequence number. The caller holds mu. +func (m *Mock) newVersionEventLocked(repo *driver.Repository, p *packageRecord, v *versionRecord, + operation string, ch versionChanges) versionEvent { + p.Seq++ + + var ns *string + if p.Namespace != "" { + ns = &p.Namespace + } + + return versionEvent{ + resource: m.packageARN(repo.DomainOwner, p), + detail: versionEventDetail{ + DomainName: repo.DomainName, + DomainOwner: repo.DomainOwner, + RepositoryName: repo.Name, + RepositoryAdministrator: repo.AdministratorAccount, + PackageFormat: p.Format, + PackageNamespace: ns, + PackageName: p.Name, + PackageVersion: v.Version, + PackageVersionState: v.Status, + PackageVersionRevision: v.Revision, + Changes: versionEventChanges(ch), + OperationType: operation, + SequenceNumber: p.Seq, + EventDeduplicationID: newDedupID(), + }, + } +} + +func newDedupID() string { + raw := make([]byte, dedupBytes) + if _, err := rand.Read(raw); err != nil { + return idgen.UUID() + } + + return base64.StdEncoding.EncodeToString(raw) +} + +// publishVersionEvents puts events on the default bus. It must run without the +// Mock lock held: delivery runs rule targets synchronously, and a target may call +// back into CodeArtifact. +func (m *Mock) publishVersionEvents(ctx context.Context, events []versionEvent) { + for i := range events { + m.events.Emit(ctx, eventSource, eventPackageVersionDesc, events[i].detail, events[i].resource) + } +} + +// sortedVersions returns a package's versions ordered by version string. +func sortedVersions(p *packageRecord) []*versionRecord { + out := make([]*versionRecord, 0, len(p.Versions)) + for _, v := range p.Versions { + out = append(out, v) + } + + sort.Slice(out, func(i, j int) bool { return out[i].Version < out[j].Version }) + + return out +} diff --git a/providers/aws/codeartifact/events_test.go b/providers/aws/codeartifact/events_test.go new file mode 100644 index 000000000..321d68ac3 --- /dev/null +++ b/providers/aws/codeartifact/events_test.go @@ -0,0 +1,244 @@ +package codeartifact_test + +import ( + "context" + "encoding/json" + "sync" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/providers/aws/codeartifact" + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +type publishedEvent struct { + source, detailType string + resources []string + detail map[string]any +} + +type recordingPublisher struct { + mu sync.Mutex + events []publishedEvent + // onPublish runs inside PublishServiceEvent to prove delivery happens + // without the mock's lock held. + onPublish func() +} + +func (r *recordingPublisher) PublishServiceEvent(_ context.Context, source, detailType string, detail any, resources []string) { + raw, _ := json.Marshal(detail) + + var parsed map[string]any + + _ = json.Unmarshal(raw, &parsed) + + r.mu.Lock() + r.events = append(r.events, publishedEvent{source: source, detailType: detailType, resources: resources, detail: parsed}) + r.mu.Unlock() + + if r.onPublish != nil { + r.onPublish() + } +} + +func (r *recordingPublisher) all() []publishedEvent { + r.mu.Lock() + defer r.mu.Unlock() + + return append([]publishedEvent(nil), r.events...) +} + +func wired(t *testing.T, repos ...string) (*codeartifact.Mock, *recordingPublisher) { + t.Helper() + + m := seeded(t, config.NewOptions(), repos...) + pub := &recordingPublisher{} + m.SetEventPublisher(pub) + + return m, pub +} + +func TestPackageVersionEventShape(t *testing.T) { + opts := config.NewOptions() + m, pub := wired(t, "repo") + + publishAsset(t, m, pkgRef("repo", "pkg"), "1.0.0", "f", []byte("x"), false) + + evs := pub.all() + if len(evs) != 1 { + t.Fatalf("events = %d, want 1", len(evs)) + } + + e := evs[0] + if e.source != "aws.codeartifact" || e.detailType != "CodeArtifact Package Version State Change" { + t.Fatalf("identity = %q / %q", e.source, e.detailType) + } + + wantRes := "arn:aws:codeartifact:" + opts.Region + ":" + opts.AccountID + ":package/dom/repo/generic/ns/pkg" + if len(e.resources) != 1 || e.resources[0] != wantRes { + t.Fatalf("resources = %v, want [%s]", e.resources, wantRes) + } + + want := map[string]any{ + "domainName": "dom", "domainOwner": opts.AccountID, "repositoryName": "repo", "repositoryAdministrator": opts.AccountID, + "packageFormat": "generic", "packageNamespace": "ns", "packageName": "pkg", "packageVersion": "1.0.0", + "packageVersionState": "Published", "operationType": "Created", "sequenceNumber": float64(1), + } + + for k, v := range want { + if e.detail[k] != v { + t.Errorf("detail[%s] = %v, want %v", k, e.detail[k], v) + } + } + + if rev, _ := e.detail["packageVersionRevision"].(string); len(rev) != 32 { + t.Errorf("revision = %v", e.detail["packageVersionRevision"]) + } + + if id, _ := e.detail["eventDeduplicationId"].(string); id == "" { + t.Error("eventDeduplicationId empty") + } + + changes, _ := e.detail["changes"].(map[string]any) + if changes["assetsAdded"] != float64(1) || changes["assetsRemoved"] != float64(0) || changes["assetsUpdated"] != float64(0) || + changes["statusChanged"] != true || changes["metadataUpdated"] != false { + t.Errorf("changes = %v", changes) + } +} + +func TestPackageVersionEventsAllMutations(t *testing.T) { + m, pub := wired(t, "src", "dst") + ctx := context.Background() + ref := pkgRef("src", "pkg") + + publishAsset(t, m, ref, "1", "a", []byte("a"), true) // Created, Unfinished + publishAsset(t, m, ref, "1", "b", []byte("b"), false) // Updated, Published + publishAsset(t, m, ref, "2", "a", []byte("a2"), false) + + _, err := m.UpdatePackageVersionsStatus(ctx, &driver.UpdateStatusInput{ + VersionsInput: driver.VersionsInput{Ref: ref, Versions: []string{"1"}}, TargetStatus: "Archived", + }) + requireNoError(t, err) + + _, err = m.CopyPackageVersions(ctx, &driver.CopyInput{Ref: ref, SourceRepository: "src", DestinationRepository: "dst", Versions: []string{"2"}}) + requireNoError(t, err) + + _, err = m.DisposePackageVersions(ctx, &driver.VersionsInput{Ref: ref, Versions: []string{"2"}}) + requireNoError(t, err) + + _, err = m.DeletePackageVersions(ctx, &driver.VersionsInput{Ref: ref, Versions: []string{"1"}}) + requireNoError(t, err) + + type step struct { + repo, version, op, state string + } + + var got []step + + seq := map[string]float64{} + + for _, e := range pub.all() { + got = append(got, step{e.detail["repositoryName"].(string), e.detail["packageVersion"].(string), e.detail["operationType"].(string), e.detail["packageVersionState"].(string)}) + + key := e.detail["repositoryName"].(string) + if s := e.detail["sequenceNumber"].(float64); s <= seq[key] { + t.Errorf("sequenceNumber %v not increasing after %v in %s", s, seq[key], key) + } else { + seq[key] = s + } + } + + want := []step{ + {"src", "1", "Created", "Unfinished"}, {"src", "1", "Updated", "Published"}, {"src", "2", "Created", "Published"}, + {"src", "1", "Updated", "Archived"}, {"dst", "2", "Created", "Published"}, {"src", "2", "Updated", "Disposed"}, + {"src", "1", "Deleted", "Archived"}, + } + + if len(got) != len(want) { + t.Fatalf("events = %+v", got) + } + + for i := range want { + if got[i] != want[i] { + t.Errorf("event %d = %+v, want %+v", i, got[i], want[i]) + } + } + + ids := map[string]bool{} + + for _, e := range pub.all() { + id := e.detail["eventDeduplicationId"].(string) + if ids[id] { + t.Errorf("duplicate eventDeduplicationId %q", id) + } + + ids[id] = true + } +} + +func TestDeletePackageEmitsOneEventPerVersion(t *testing.T) { + m, pub := wired(t, "repo") + ref := pkgRef("repo", "pkg") + + publishAsset(t, m, ref, "1", "f", []byte("1"), false) + publishAsset(t, m, ref, "2", "f", []byte("2"), false) + + before := len(pub.all()) + + _, err := m.DeletePackage(context.Background(), ref) + requireNoError(t, err) + + evs := pub.all()[before:] + if len(evs) != 2 || evs[0].detail["operationType"] != "Deleted" || evs[1].detail["operationType"] != "Deleted" { + t.Fatalf("delete-package events = %+v", evs) + } +} + +func TestEventsSingleEventWhenOwnerIsAdministrator(t *testing.T) { + m, pub := wired(t, "repo") + + publishAsset(t, m, pkgRef("repo", "pkg"), "1", "f", []byte("x"), false) + + if n := len(pub.all()); n != 1 { + t.Fatalf("events = %d, want exactly 1 (the domain owner is the repository administrator)", n) + } +} + +func TestEventsUnwiredAndRejectedAreNoOps(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + publishAsset(t, m, pkgRef("repo", "pkg"), "1", "f", []byte("x"), false) + + m2, pub := wired(t, "repo") + + _, err := m2.PublishPackageVersion(context.Background(), &driver.PublishInput{ + Ref: pkgRef("repo", "pkg"), Version: "1", AssetName: "f", SHA256: sha([]byte("other")), Content: []byte("x"), + }) + requireAPIError(t, err, driver.ExValidation) + + if n := len(pub.all()); n != 0 { + t.Fatalf("rejected publish emitted %d events", n) + } + + _, err = m2.UpdatePackageVersionsStatus(context.Background(), &driver.UpdateStatusInput{ + VersionsInput: driver.VersionsInput{Ref: pkgRef("repo", "pkg"), Versions: []string{"nope"}}, TargetStatus: "Archived", + }) + requireAPIError(t, err, driver.ExNotFound) + + if n := len(pub.all()); n != 0 { + t.Fatalf("failed status update emitted %d events", n) + } +} + +func TestEventDeliveryCanCallBackWithoutDeadlock(t *testing.T) { + m, pub := wired(t, "repo") + ref := pkgRef("repo", "pkg") + + pub.onPublish = func() { + // A rule target calling back into CodeArtifact while an event is delivered. + if _, err := m.DescribeDomain(context.Background(), "dom", ""); err != nil { + t.Errorf("callback: %v", err) + } + } + + publishAsset(t, m, ref, "1", "f", []byte("x"), false) +} diff --git a/providers/aws/codeartifact/group_pattern.go b/providers/aws/codeartifact/group_pattern.go new file mode 100644 index 000000000..2befd7896 --- /dev/null +++ b/providers/aws/codeartifact/group_pattern.go @@ -0,0 +1,304 @@ +package codeartifact + +import ( + "strings" + "unicode" + "unicode/utf8" + + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +const ( + minPatternLen = 2 + maxPatternLen = 520 +) + +type nsKind int + +const ( + nsAll nsKind = iota + nsBlank + nsExact + nsPrefix +) + +type nameKind int + +const ( + nameAll nameKind = iota + nameExact + namePrefix +) + +// groupPattern is a parsed package group pattern. A package path is +// ///; a pattern is a path with trailing components +// omitted and a suffix: $ matches the full coordinate, ~ a word-boundary prefix +// and * every value of the component before it. +type groupPattern struct { + allFormats bool + format string + nsKind nsKind + ns string + nameKind nameKind + name string +} + +// isWordChar reports whether r continues a word: a letter, number or mark. +func isWordChar(r rune) bool { + return unicode.IsLetter(r) || unicode.IsNumber(r) || unicode.IsMark(r) +} + +// prefixWord reports whether s starts with p ending on a word boundary: s is p, +// or the character after p is not a word character. +func prefixWord(s, p string) bool { + if !strings.HasPrefix(s, p) { + return false + } + + if len(s) == len(p) { + return true + } + + r, _ := utf8.DecodeRuneInString(s[len(p):]) + + return !isWordChar(r) +} + +// normalizeVariant folds the differences weak matching ignores: letter case and +// runs of dash, dot and underscore (each run becomes one dot). +func normalizeVariant(s string) string { + var b strings.Builder + + lastSep := false + + for _, r := range strings.ToLower(s) { + if r == '-' || r == '.' || r == '_' { + if !lastSep { + b.WriteByte('.') + } + + lastSep = true + + continue + } + + lastSep = false + + b.WriteRune(r) + } + + return b.String() +} + +// validLiteral reports whether a namespace or name literal of a pattern is +// non-empty and free of the pattern's special characters. +func validLiteral(s string) bool { + return s != "" && !strings.ContainsAny(s, "/$~*") && !hasSpace(s) && !hasControl(s) +} + +// endsInWord reports whether the character before a ~ suffix is a word character. +func endsInWord(s string) bool { + r, _ := utf8.DecodeLastRuneInString(s) + + return isWordChar(r) +} + +// validPatternText reports whether p has an allowed length and no whitespace or +// control characters. +func validPatternText(p string) bool { + n := utf8.RuneCountInString(p) + + return n >= minPatternLen && n <= maxPatternLen && !hasSpace(p) && !hasControl(p) +} + +// parseGroupPattern parses and validates a package group pattern. +func parseGroupPattern(p string) (groupPattern, error) { + if !validPatternText(p) { + return groupPattern{}, validation("package group pattern must be 2-520 characters without whitespace") + } + + rest, ok := strings.CutPrefix(p, "/") + if !ok { + return groupPattern{}, badPattern(p) + } + + if rest == "*" { + return groupPattern{allFormats: true, nsKind: nsAll, nameKind: nameAll}, nil + } + + format, tail, found := strings.Cut(rest, "/") + if !found || !validFormat(format) { + return groupPattern{}, badPattern(p) + } + + gp := groupPattern{format: format} + if !gp.parseTail(tail) || !gp.formatAllowsShape() { + return groupPattern{}, badPattern(p) + } + + return gp, nil +} + +func badPattern(p string) error { + return validation("package group pattern %q is not valid: use /*, //*, //~, "+ + "///*, ///~ or ///$", p) +} + +// parseTail parses the part of a pattern after // and reports whether it +// is well formed. +func (gp *groupPattern) parseTail(tail string) bool { + if tail == "*" { + gp.nsKind, gp.nameKind = nsAll, nameAll + + return true + } + + slash := strings.IndexByte(tail, '/') + if slash < 0 { + ns, ok := strings.CutSuffix(tail, "~") + if !ok || !validLiteral(ns) || !endsInWord(ns) { + return false + } + + gp.nsKind, gp.ns, gp.nameKind = nsPrefix, ns, nameAll + + return true + } + + gp.nsKind = nsBlank + + if ns := tail[:slash]; ns != "" { + if !validLiteral(ns) { + return false + } + + gp.nsKind, gp.ns = nsExact, ns + } + + return gp.parseName(tail[slash+1:]) +} + +// parseName parses the name part of a pattern. +func (gp *groupPattern) parseName(name string) bool { + if name == "*" { + gp.nameKind = nameAll + + return true + } + + if lit, ok := strings.CutSuffix(name, "~"); ok { + if !validLiteral(lit) || !endsInWord(lit) { + return false + } + + gp.nameKind, gp.name = namePrefix, lit + + return true + } + + if lit, ok := strings.CutSuffix(name, "$"); ok { + if !validLiteral(lit) { + return false + } + + gp.nameKind, gp.name = nameExact, lit + + return true + } + + return false +} + +// formatAllowsShape applies the per-format namespace rules: Python and NuGet +// have no namespace, and Maven, generic and Swift packages always have one. +func (gp *groupPattern) formatAllowsShape() bool { + switch gp.format { + case driver.FormatPyPI, driver.FormatNuGet: + return gp.nsKind == nsAll || gp.nsKind == nsBlank + case driver.FormatMaven, driver.FormatGeneric, driver.FormatSwift: + return gp.nsKind != nsBlank + default: + return true + } +} + +// covers reports whether every package matching a also matches gp, comparing the +// patterns structurally. +func (gp groupPattern) covers(a groupPattern) bool { + if !gp.allFormats && (a.allFormats || a.format != gp.format) { + return false + } + + return gp.coversNamespace(a) && gp.coversName(a) +} + +func (gp groupPattern) coversNamespace(a groupPattern) bool { + switch gp.nsKind { + case nsAll: + return true + case nsBlank: + return a.nsKind == nsBlank + case nsExact: + return a.nsKind == nsExact && a.ns == gp.ns + case nsPrefix: + return (a.nsKind == nsExact || a.nsKind == nsPrefix) && prefixWord(a.ns, gp.ns) + default: + return false + } +} + +func (gp groupPattern) coversName(a groupPattern) bool { + switch gp.nameKind { + case nameAll: + return true + case nameExact: + return a.nameKind == nameExact && a.name == gp.name + case namePrefix: + return a.nameKind != nameAll && prefixWord(a.name, gp.name) + default: + return false + } +} + +// matches reports whether a package matches the pattern. A strong match is exact; +// a weak match ignores case and runs of dash, dot and underscore. +func (gp groupPattern) matches(format, ns, name string, weak bool) bool { + if !gp.allFormats && gp.format != format { + return false + } + + fold := func(s string) string { return s } + if weak { + fold = normalizeVariant + } + + return gp.matchesNamespace(fold(ns), fold) && gp.matchesName(fold(name), fold) +} + +func (gp groupPattern) matchesNamespace(ns string, fold func(string) string) bool { + switch gp.nsKind { + case nsAll: + return true + case nsBlank: + return ns == "" + case nsExact: + return ns == fold(gp.ns) + case nsPrefix: + return prefixWord(ns, fold(gp.ns)) + default: + return false + } +} + +func (gp groupPattern) matchesName(name string, fold func(string) string) bool { + switch gp.nameKind { + case nameAll: + return true + case nameExact: + return name == fold(gp.name) + case namePrefix: + return prefixWord(name, fold(gp.name)) + default: + return false + } +} diff --git a/providers/aws/codeartifact/group_pattern_test.go b/providers/aws/codeartifact/group_pattern_test.go new file mode 100644 index 000000000..56b57cbfc --- /dev/null +++ b/providers/aws/codeartifact/group_pattern_test.go @@ -0,0 +1,137 @@ +package codeartifact_test + +import ( + "context" + "strings" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +func createGroup(t *testing.T, m interface { + CreatePackageGroup(context.Context, *driver.CreatePackageGroupInput) (*driver.PackageGroup, error) +}, pattern string) *driver.PackageGroup { + t.Helper() + + g, err := m.CreatePackageGroup(context.Background(), &driver.CreatePackageGroupInput{Domain: driver.DomainRef{Domain: "dom"}, Pattern: pattern}) + requireNoError(t, err) + + return g +} + +func TestGroupPatternGrammar(t *testing.T) { + good := []string{ + "/*", "/npm/*", "/maven/com.anycompany~", "/npm/space/*", "/npm/space/anycompany-ui~", + "/maven/org.apache.logging.log4j/log4j-core$", "/pypi//requests$", "/npm//react$", "/npm//*", "/generic/ns/pkg$", + "/nuget/*", "/swift/scope/name~", "/cargo//serde$", "/ruby//rails~", + } + + bad := []string{ + "", "/", "npm/*", "/npm", "/npm/", "/docker/*", "/npm/space", "/npm/space~/x", "/npm/~", "/npm//$", + "/npm/sp ace/*", "/maven//log4j$", "/maven//*", "/generic//pkg$", "/pypi/ns/requests$", "/nuget/ns~", "/npm/space/pkg", + "/npm/space/pkg-~", "/maven/com.~", "/npm/a*b/c$", "/npm/a/b/c$", "/" + strings.Repeat("a", 520), + } + + for _, p := range good { + m := seeded(t, config.NewOptions()) + createGroup(t, m, p) + } + + for _, p := range bad { + m := seeded(t, config.NewOptions()) + + _, err := m.CreatePackageGroup(context.Background(), &driver.CreatePackageGroupInput{Domain: driver.DomainRef{Domain: "dom"}, Pattern: p}) + if err == nil { + t.Errorf("pattern %q accepted", p) + + continue + } + + requireAPIError(t, err, driver.ExValidation) + } +} + +func TestMostSpecificMatchAndParent(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ctx := context.Background() + dom := driver.DomainRef{Domain: "dom"} + + all := createGroup(t, m, "/*") + npm := createGroup(t, m, "/npm/*") + space := createGroup(t, m, "/npm/space/*") + foo := createGroup(t, m, "/npm/space/foo~") + exact := createGroup(t, m, "/npm/space/foo-core$") + + parents := map[string]string{ + all.Pattern: "", npm.Pattern: "/*", space.Pattern: "/npm/*", foo.Pattern: "/npm/space/*", exact.Pattern: "/npm/space/foo~", + } + + for pattern, want := range parents { + g, err := m.DescribePackageGroup(ctx, dom, pattern) + requireNoError(t, err) + + got := "" + if g.Parent != nil { + got = g.Parent.Pattern + } + + if got != want { + t.Errorf("parent of %s = %q, want %q", pattern, got, want) + } + } + + cases := []struct { + format, ns, name, want string + }{ + {"npm", "", "react", "/npm/*"}, + {"npm", "space", "aui.components", "/npm/space/*"}, + {"npm", "space", "foo", "/npm/space/foo~"}, + {"npm", "space", "foo-bar", "/npm/space/foo~"}, + {"npm", "space", "food", "/npm/space/*"}, + {"npm", "space", "foo-core", "/npm/space/foo-core$"}, + {"pypi", "", "requests", "/*"}, + } + + for _, c := range cases { + g, strength, err := m.GetAssociatedPackageGroup(ctx, dom, c.format, c.ns, c.name) + requireNoError(t, err) + + if g.Pattern != c.want || strength != driver.AssociationStrong { + t.Errorf("%s/%s/%s -> %s (%s), want %s strong", c.format, c.ns, c.name, g.Pattern, strength, c.want) + } + } +} + +func TestStrongWeakAssociation(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ctx := context.Background() + dom := driver.DomainRef{Domain: "dom"} + + createGroup(t, m, "/generic/ns/AsyncStorage$") + createGroup(t, m, "/generic/ns/foo-bar$") + + cases := []struct { + name, group, strength string + }{ + {"AsyncStorage", "/generic/ns/AsyncStorage$", driver.AssociationStrong}, + {"asyncStorage", "/generic/ns/AsyncStorage$", driver.AssociationWeak}, + {"asyncstorage", "/generic/ns/AsyncStorage$", driver.AssociationWeak}, + {"foo-bar", "/generic/ns/foo-bar$", driver.AssociationStrong}, + {"foo.bar", "/generic/ns/foo-bar$", driver.AssociationWeak}, + {"foo__bar", "/generic/ns/foo-bar$", driver.AssociationWeak}, + {"foo-.bar", "/generic/ns/foo-bar$", driver.AssociationWeak}, + } + + for _, c := range cases { + g, strength, err := m.GetAssociatedPackageGroup(ctx, dom, "generic", "ns", c.name) + requireNoError(t, err) + + if g.Pattern != c.group || strength != c.strength { + t.Errorf("%s -> %s (%s), want %s (%s)", c.name, g.Pattern, strength, c.group, c.strength) + } + } + + _, _, err := m.GetAssociatedPackageGroup(ctx, dom, "generic", "ns", "foobar") + requireAPIError(t, err, driver.ExNotFound) +} diff --git a/providers/aws/codeartifact/invariants_test.go b/providers/aws/codeartifact/invariants_test.go new file mode 100644 index 000000000..e19e9237c --- /dev/null +++ b/providers/aws/codeartifact/invariants_test.go @@ -0,0 +1,313 @@ +package codeartifact_test + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +// raceN runs fn from n goroutines at once and waits for all of them. +func raceN(n int, fn func(i int)) { + var wg sync.WaitGroup + + start := make(chan struct{}) + + for i := 0; i < n; i++ { + wg.Add(1) + + go func(i int) { + defer wg.Done() + + <-start + fn(i) + }(i) + } + + close(start) + wg.Wait() +} + +func TestRepositoryCapHoldsUnderRace(t *testing.T) { + m := newMock() + mustDomain(t, m, "dom") + + for i := 0; i < 990; i++ { + mustRepo(t, m, "dom", fmt.Sprintf("repo-%04d", i)) + } + + var ok atomic.Int32 + + raceN(40, func(i int) { + _, err := m.CreateRepository(context.Background(), &driver.CreateRepositoryInput{Domain: "dom", Repository: fmt.Sprintf("late-%02d", i)}) + if err == nil { + ok.Add(1) + } + }) + + if ok.Load() != 10 { + t.Fatalf("created %d repositories past 990, want exactly 10 (cap 1000)", ok.Load()) + } + + d, err := m.DescribeDomain(context.Background(), "dom", "") + requireNoError(t, err) + + if d.RepositoryCount != 1000 { + t.Fatalf("repositoryCount = %d", d.RepositoryCount) + } +} + +func TestTagCapHoldsUnderRace(t *testing.T) { + m := newMock() + d := mustDomain(t, m, "dom") + + var ok atomic.Int32 + + raceN(60, func(i int) { + if err := m.TagResource(context.Background(), d.Arn, tagSet(10, i*10)); err == nil { + ok.Add(1) + } + }) + + tags, err := m.ListTagsForResource(context.Background(), d.Arn) + requireNoError(t, err) + + if len(tags) != 200 || ok.Load() != 20 { + t.Fatalf("tags = %d from %d successful calls, want exactly 200 from 20", len(tags), ok.Load()) + } +} + +func TestUpstreamDependentDeleteRace(t *testing.T) { + for round := 0; round < 30; round++ { + m := newMock() + mustDomain(t, m, "dom") + mustRepo(t, m, "dom", "base") + + var created atomic.Bool + + raceN(2, func(i int) { + if i == 0 { + _, err := m.CreateRepository(context.Background(), &driver.CreateRepositoryInput{ + Domain: "dom", Repository: "down", Upstreams: []driver.UpstreamRef{{RepositoryName: "base"}}, + }) + created.Store(err == nil) + + return + } + + _, _ = m.DeleteRepository(context.Background(), "dom", "", "base") + }) + + down, err := m.DescribeRepository(context.Background(), "dom", "", "down") + if err != nil { + continue + } + + // "down" exists: its upstream must still exist, or the graph dangles. + for _, u := range down.Upstreams { + if _, err = m.DescribeRepository(context.Background(), "dom", "", u.RepositoryName); err != nil { + t.Fatalf("round %d: repository down has a dangling upstream %s", round, u.RepositoryName) + } + } + } +} + +func TestAssetCapHoldsUnderRace(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ref := pkgRef("repo", "pkg") + + var ok atomic.Int32 + + raceN(400, func(i int) { + data := []byte(fmt.Sprintf("asset %d", i)) + + _, err := m.PublishPackageVersion(context.Background(), &driver.PublishInput{ + Ref: ref, Version: "1", AssetName: fmt.Sprintf("a%03d", i), SHA256: sha(data), Content: data, Unfinished: true, + }) + if err == nil { + ok.Add(1) + } + }) + + list, _, err := m.ListPackageVersionAssets(context.Background(), ref, "1", driver.Page{MaxResults: 1000}) + requireNoError(t, err) + + if len(list.Assets) != 350 || ok.Load() != 350 { + t.Fatalf("stored %d assets from %d successful publishes, want exactly 350", len(list.Assets), ok.Load()) + } +} + +func TestSameAssetPublishedConcurrentlyIsIdempotent(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ref := pkgRef("repo", "pkg") + data := []byte("identical") + + var failures atomic.Int32 + + raceN(50, func(int) { + _, err := m.PublishPackageVersion(context.Background(), &driver.PublishInput{ + Ref: ref, Version: "1", AssetName: "f", SHA256: sha(data), Content: data, Unfinished: true, + }) + if err != nil { + failures.Add(1) + } + }) + + if failures.Load() != 0 { + t.Fatalf("%d concurrent identical publishes failed; an identical asset is a no-op", failures.Load()) + } + + list, _, err := m.ListPackageVersionAssets(context.Background(), ref, "1", driver.Page{}) + requireNoError(t, err) + + if len(list.Assets) != 1 { + t.Fatalf("assets = %d, want 1", len(list.Assets)) + } +} + +func TestPolicyRevisionCompareAndSetUnderRace(t *testing.T) { + m := newMock() + mustDomain(t, m, "dom") + ref := driver.DomainRef{Domain: "dom"} + + first, err := m.PutDomainPermissionsPolicy(context.Background(), ref, "", testPolicy) + requireNoError(t, err) + + var winners atomic.Int32 + + raceN(40, func(int) { + if _, putErr := m.PutDomainPermissionsPolicy(context.Background(), ref, first.Revision, testPolicy); putErr == nil { + winners.Add(1) + } + }) + + if winners.Load() != 1 { + t.Fatalf("%d puts with the same revision succeeded, want exactly 1", winners.Load()) + } +} + +func TestSamePackageGroupCreatedConcurrently(t *testing.T) { + m := seeded(t, config.NewOptions()) + + var wins, conflicts atomic.Int32 + + raceN(40, func(int) { + _, err := m.CreatePackageGroup(context.Background(), &driver.CreatePackageGroupInput{Domain: driver.DomainRef{Domain: "dom"}, Pattern: "/npm/*"}) + + var api *driver.APIError + + switch { + case err == nil: + wins.Add(1) + case asAPIError(err, &api) && api.Exception == driver.ExConflict: + conflicts.Add(1) + } + }) + + if wins.Load() != 1 || conflicts.Load() != 39 { + t.Fatalf("wins=%d conflicts=%d, want 1 and 39", wins.Load(), conflicts.Load()) + } +} + +func TestDuplicateVersionsInOneRequestAreReportedOnce(t *testing.T) { + m := seeded(t, config.NewOptions(), "src", "dst") + ctx := context.Background() + ref := pkgRef("src", "pkg") + publishAsset(t, m, ref, "1", "f", []byte("x"), false) + + cp, err := m.CopyPackageVersions(ctx, &driver.CopyInput{Ref: ref, SourceRepository: "src", DestinationRepository: "dst", Versions: []string{"1", "1"}}) + requireNoError(t, err) + + if len(cp.Successful) != 1 || len(cp.Failed) != 0 { + t.Fatalf("copy outcome = %+v / %+v, want one success and no failure", cp.Successful, cp.Failed) + } + + del, err := m.DeletePackageVersions(ctx, &driver.VersionsInput{Ref: ref, Versions: []string{"1", "1"}}) + requireNoError(t, err) + + if len(del.Successful) != 1 || len(del.Failed) != 0 { + t.Fatalf("delete outcome = %+v / %+v, want one success and no failure", del.Successful, del.Failed) + } +} + +func TestListPackagesOrderIsFormatNamespaceName(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ctx := context.Background() + + for _, ns := range []string{"a.b", "a-b", "ac"} { + ref := pkgRef("repo", "pkg") + ref.Namespace = ns + + publishAsset(t, m, ref, "1", "f", []byte(ns), false) + } + + pkgs, _, err := m.ListPackages(ctx, &driver.ListPackagesInput{Repository: driver.RepositoryRef{Domain: "dom", Repository: "repo"}}) + requireNoError(t, err) + + got := []string{pkgs[0].Namespace, pkgs[1].Namespace, pkgs[2].Namespace} + if got[0] != "a-b" || got[1] != "a.b" || got[2] != "ac" { + t.Fatalf("namespaces in list order = %v, want [a-b a.b ac]", got) + } +} + +func TestVersionsListNewestPublishedFirstAndOriginFilters(t *testing.T) { + clk := config.NewFakeClock(time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)) + m := seeded(t, config.NewOptions(config.WithClock(clk)), "repo") + ctx := context.Background() + ref := pkgRef("repo", "pkg") + + for _, v := range []string{"1", "2", "3"} { + publishAsset(t, m, ref, v, "f", []byte(v), false) + clk.Advance(time.Minute) + } + + list, err := m.ListPackageVersions(ctx, &driver.ListPackageVersionsInput{Ref: ref, SortBy: driver.SortByPublishedTime}) + requireNoError(t, err) + + if len(list.Versions) != 3 || list.Versions[0].Version != "3" || list.Versions[2].Version != "1" || list.DefaultDisplayVersion != "3" { + t.Fatalf("versions = %+v default=%q, want newest first", list.Versions, list.DefaultDisplayVersion) + } + + for origin, want := range map[string]int{driver.OriginTypeInternal: 3, driver.OriginTypeExternal: 0, driver.OriginTypeUnknown: 0} { + got, listErr := m.ListPackageVersions(ctx, &driver.ListPackageVersionsInput{Ref: ref, OriginType: origin}) + requireNoError(t, listErr) + + if len(got.Versions) != want { + t.Fatalf("originType %s: %d versions, want %d", origin, len(got.Versions), want) + } + } +} + +func TestDeleteRepositoryEmitsNoVersionEvents(t *testing.T) { + m, pub := wired(t, "repo") + publishAsset(t, m, pkgRef("repo", "pkg"), "1", "f", []byte("x"), false) + + before := len(pub.all()) + + _, err := m.DeleteRepository(context.Background(), "dom", "", "repo") + requireNoError(t, err) + + if after := len(pub.all()); after != before { + t.Fatalf("deleting a repository emitted %d events, want none", after-before) + } +} + +func TestNewDomainHasNoPackageGroup(t *testing.T) { + m := newMock() + mustDomain(t, m, "dom") + + groups, _, err := m.ListPackageGroups(context.Background(), driver.DomainRef{Domain: "dom"}, "", driver.Page{}) + requireNoError(t, err) + + if len(groups) != 0 { + t.Fatalf("a new domain has %d package groups, want none", len(groups)) + } +} + +func asAPIError(err error, target **driver.APIError) bool { return errors.As(err, target) } diff --git a/providers/aws/codeartifact/misc_test.go b/providers/aws/codeartifact/misc_test.go new file mode 100644 index 000000000..02c518ce0 --- /dev/null +++ b/providers/aws/codeartifact/misc_test.go @@ -0,0 +1,224 @@ +package codeartifact_test + +import ( + "context" + "fmt" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +func TestEmptyAccountLists(t *testing.T) { + m := newMock() + ctx := context.Background() + + domains, next, err := m.ListDomains(ctx, driver.Page{}) + requireNoError(t, err) + + repos, next2, err := m.ListRepositories(ctx, "", driver.Page{}) + requireNoError(t, err) + + if len(domains) != 0 || len(repos) != 0 || next != "" || next2 != "" { + t.Fatalf("empty account lists = %d/%d (%q/%q)", len(domains), len(repos), next, next2) + } +} + +func TestServiceQuotas(t *testing.T) { + m := newMock() + ctx := context.Background() + + for i := 0; i < 10; i++ { + mustDomain(t, m, fmt.Sprintf("dom-%02d", i)) + } + + _, err := m.CreateDomain(ctx, &driver.CreateDomainInput{Name: "dom-over"}) + e := requireAPIError(t, err, driver.ExServiceQuota) + + if e.ResourceType != driver.ResourceTypeDomain { + t.Fatalf("resource type = %q", e.ResourceType) + } + + // Deleting one frees a slot. + _, err = m.DeleteDomain(ctx, "dom-00", "") + requireNoError(t, err) + mustDomain(t, m, "dom-over") +} + +func TestRepositoriesPerDomainQuota(t *testing.T) { + m := newMock() + mustDomain(t, m, "dom") + + for i := 0; i < 1000; i++ { + mustRepo(t, m, "dom", fmt.Sprintf("repo-%04d", i)) + } + + _, err := m.CreateRepository(context.Background(), &driver.CreateRepositoryInput{Domain: "dom", Repository: "repo-over"}) + requireAPIError(t, err, driver.ExServiceQuota) +} + +func TestRejectedInputsLeaveNoSideEffects(t *testing.T) { + opts := config.NewOptions() + m := seeded(t, opts, "repo") + pub := &recordingPublisher{} + m.SetEventPublisher(pub) + ctx := context.Background() + + before := observeBasic(t, m) + + _, _ = m.CreateDomain(ctx, &driver.CreateDomainInput{Name: "BAD NAME"}) + _, _ = m.CreateRepository(ctx, &driver.CreateRepositoryInput{Domain: "dom", Repository: "ok-repo", Upstreams: []driver.UpstreamRef{{RepositoryName: "ghost"}}}) + _, _ = m.PublishPackageVersion(ctx, &driver.PublishInput{Ref: pkgRef("repo", "pkg"), Version: "1", AssetName: "f", SHA256: "bad", Content: []byte("x")}) + _, _ = m.CreatePackageGroup(ctx, &driver.CreatePackageGroupInput{Domain: driver.DomainRef{Domain: "dom"}, Pattern: "no-slash"}) + _ = m.TagResource(ctx, "arn:aws:codeartifact:us-east-1:123456789012:domain/ghost", map[string]string{"a": "b"}) + _, _ = m.PutDomainPermissionsPolicy(ctx, driver.DomainRef{Domain: "dom"}, "", "not json") + + if after := observeBasic(t, m); after != before { + t.Fatalf("rejected requests changed state:\n before %s\n after %s", before, after) + } + + if n := len(pub.all()); n != 0 { + t.Fatalf("rejected requests emitted %d events", n) + } +} + +func observeBasic(t *testing.T, m interface { + ListDomains(context.Context, driver.Page) ([]*driver.Domain, string, error) + ListRepositories(context.Context, string, driver.Page) ([]*driver.Repository, string, error) + ListPackageGroups(context.Context, driver.DomainRef, string, driver.Page) ([]*driver.PackageGroup, string, error) + ListPackages(context.Context, *driver.ListPackagesInput) ([]*driver.Package, string, error) +}) string { + t.Helper() + + ctx := context.Background() + + d, _, err := m.ListDomains(ctx, driver.Page{}) + requireNoError(t, err) + + r, _, err := m.ListRepositories(ctx, "", driver.Page{}) + requireNoError(t, err) + + g, _, err := m.ListPackageGroups(ctx, driver.DomainRef{Domain: "dom"}, "", driver.Page{}) + requireNoError(t, err) + + p, _, err := m.ListPackages(ctx, &driver.ListPackagesInput{Repository: driver.RepositoryRef{Domain: "dom", Repository: "repo"}}) + requireNoError(t, err) + + return fmt.Sprintf("%d domains %d repos %d groups %d packages", len(d), len(r), len(g), len(p)) +} + +func TestARNShapes(t *testing.T) { + opts := config.NewOptions() + m := seeded(t, opts, "repo") + pub := &recordingPublisher{} + m.SetEventPublisher(pub) + + d, err := m.DescribeDomain(context.Background(), "dom", "") + requireNoError(t, err) + + r := mustRepoDescribe(t, m) + g := createGroup(t, m, "/npm/space/*") + + prefix := "arn:aws:codeartifact:" + opts.Region + ":" + opts.AccountID + ":" + + want := map[string]string{ + d.Arn: prefix + "domain/dom", + r.Arn: prefix + "repository/dom/repo", + g.Arn: prefix + "package-group/dom/npm/space/*", + } + + for got, w := range want { + if got != w { + t.Errorf("arn = %q, want %q", got, w) + } + } + + publishAsset(t, m, pkgRef("repo", "pkg"), "1", "f", []byte("x"), false) + + if res := pub.all()[0].resources[0]; res != prefix+"package/dom/repo/generic/ns/pkg" { + t.Errorf("package arn = %q", res) + } +} + +func mustRepoDescribe(t *testing.T, m interface { + DescribeRepository(context.Context, string, string, string) (*driver.Repository, error) +}) *driver.Repository { + t.Helper() + + r, err := m.DescribeRepository(context.Background(), "dom", "", "repo") + requireNoError(t, err) + + return r +} + +func TestListRepositoriesInDomainAdministratorFilter(t *testing.T) { + opts := config.NewOptions() + m := seeded(t, opts, "aa", "bb") + ctx := context.Background() + dom := driver.DomainRef{Domain: "dom"} + + own, _, err := m.ListRepositoriesInDomainByAdministrator(ctx, dom, opts.AccountID, "", driver.Page{}) + requireNoError(t, err) + + other, _, err := m.ListRepositoriesInDomainByAdministrator(ctx, dom, "999999999999", "", driver.Page{}) + requireNoError(t, err) + + if len(own) != 2 || len(other) != 0 { + t.Fatalf("own=%d other=%d", len(own), len(other)) + } + + _, _, err = m.ListRepositoriesInDomainByAdministrator(ctx, dom, "bad", "", driver.Page{}) + requireAPIError(t, err, driver.ExValidation) +} + +func TestPackageFilterAndRefValidation(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ctx := context.Background() + repo := driver.RepositoryRef{Domain: "dom", Repository: "repo"} + + for _, in := range []driver.ListPackagesInput{ + {Repository: repo, Format: "docker"}, {Repository: repo, Namespace: "a/b"}, {Repository: repo, Prefix: "bad name"}, + {Repository: repo, Publish: "MAYBE"}, {Repository: repo, Upstream: "MAYBE"}, + } { + _, _, err := m.ListPackages(ctx, &in) + requireAPIError(t, err, driver.ExValidation) + } + + bad := []driver.PackageRef{pkgRef("repo", ""), pkgRef("repo", "a#b"), pkgRef("repo", "pkg")} + bad[2].Namespace = "has space" + + for _, ref := range bad { + _, err := m.DescribePackage(ctx, ref) + requireAPIError(t, err, driver.ExValidation) + } + + _, err := m.GetPackageVersionAsset(ctx, pkgRef("repo", "pkg"), "1", "", "") + requireAPIError(t, err, driver.ExValidation) + + _, err = m.GetPackageVersionAsset(ctx, pkgRef("repo", "pkg"), "1", "f", "has space") + requireAPIError(t, err, driver.ExValidation) + + _, err = m.GetPackageVersionReadme(ctx, pkgRef("repo", "pkg"), "bad version") + requireAPIError(t, err, driver.ExValidation) +} + +func TestBulkRequestValidation(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ctx := context.Background() + ref := pkgRef("repo", "pkg") + + publishAsset(t, m, ref, "1", "f", []byte("x"), false) + + for _, in := range []driver.VersionsInput{ + {Ref: ref, Versions: []string{"bad version"}}, + {Ref: ref, Versions: []string{"1"}, ExpectedStatus: "Bogus"}, + {Ref: ref, Versions: []string{"1"}, VersionRevisions: map[string]string{"1": ""}}, + {Ref: ref, Versions: []string{"1"}, VersionRevisions: map[string]string{"bad version": "R"}}, + } { + _, err := m.DisposePackageVersions(ctx, &in) + requireAPIError(t, err, driver.ExValidation) + } + + _, err := m.DisposePackageVersions(ctx, &driver.VersionsInput{Ref: pkgRef("repo", "ghost"), Versions: []string{"1"}}) + requireAPIError(t, err, driver.ExNotFound) +} diff --git a/providers/aws/codeartifact/origin.go b/providers/aws/codeartifact/origin.go new file mode 100644 index 000000000..e39ad9e9d --- /dev/null +++ b/providers/aws/codeartifact/origin.go @@ -0,0 +1,101 @@ +package codeartifact + +import ( + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +// maxUpstreamSearch bounds how many upstream repositories are searched when a +// package version is looked up through a repository's upstream chain. +const maxUpstreamSearch = 25 + +// defaultRestrictionsLocked returns the origin controls a package gets when its +// first version is published directly. Real AWS bases the default on the +// package's group: with every restriction ALLOW (or no group) a directly +// published package is Publish ALLOW and Upstream BLOCK; otherwise both ALLOW. +// The caller holds mu. +func (m *Mock) defaultRestrictionsLocked(domain, format, namespace, name string) driver.OriginRestrictions { + views := m.groupViewsLocked(domain) + + if idx, _, ok := associatedGroup(views, format, namespace, name); ok { + parents := parentsOf(views) + + for _, t := range restrictionTypes() { + if mode, _ := effective(views, parents, idx, t); mode != driver.ModeAllow { + return driver.OriginRestrictions{Publish: driver.OriginAllow, Upstream: driver.OriginAllow} + } + } + } + + return driver.OriginRestrictions{Publish: driver.OriginAllow, Upstream: driver.OriginBlock} +} + +// publishBlockedLocked reports why publishing to repo is not allowed for a +// package, or "" when it is. Package-level BLOCK wins, then the package's group: +// a weak (variant) match is blocked, as are BLOCK and a repository missing from +// an ALLOW_SPECIFIC_REPOSITORIES list. The caller holds mu. +func (m *Mock) publishBlockedLocked(domain, repo, format, namespace, name string, p *packageRecord) string { + if p != nil && p.Restrictions.Publish == driver.OriginBlock { + return "the package's origin controls block publishing" + } + + views := m.groupViewsLocked(domain) + + idx, strong, ok := associatedGroup(views, format, namespace, name) + if !ok { + return "" + } + + if !strong { + return "the package name is a variant of package group " + views[idx].rec.Pattern + ", so publishing is blocked" + } + + mode, src := effective(views, parentsOf(views), idx, driver.RestrictionPublish) + + switch mode { + case driver.ModeBlock: + return "package group " + views[src].rec.Pattern + " blocks publishing" + case driver.ModeAllowSpecificRepositories: + if !containsString(views[src].rec.Allowed[driver.RestrictionPublish], repo) { + return "package group " + views[src].rec.Pattern + " does not allow publishing to repository " + repo + } + } + + return "" +} + +// findVersionLocked looks a package version up in a repository and, when +// includeUpstream is set, in the repositories upstream of it (breadth first, at +// most 25 searched, cycle-safe), or nil. The caller holds mu. +func (m *Mock) findVersionLocked(domain, repo, format, namespace, name, version string, includeUpstream bool) *versionRecord { + visited := map[string]bool{} + queue := []string{repo} + + for len(queue) > 0 && len(visited) < maxUpstreamSearch { + cur := queue[0] + queue = queue[1:] + + if visited[cur] { + continue + } + + visited[cur] = true + + if p, ok := m.packages.Get(packageKey(domain, cur, format, namespace, name)); ok { + if found, ok := p.Versions[version]; ok { + return found + } + } + + if !includeUpstream { + break + } + + if r, ok := m.repos.Get(repoKey(domain, cur)); ok { + for _, u := range r.Upstreams { + queue = append(queue, u.RepositoryName) + } + } + } + + return nil +} diff --git a/providers/aws/codeartifact/origin_test.go b/providers/aws/codeartifact/origin_test.go new file mode 100644 index 000000000..bfc9756a7 --- /dev/null +++ b/providers/aws/codeartifact/origin_test.go @@ -0,0 +1,206 @@ +package codeartifact_test + +import ( + "context" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +func TestPutPackageOriginConfiguration(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ctx := context.Background() + ref := pkgRef("repo", "pkg") + + for _, bad := range []driver.OriginRestrictions{{}, {Publish: "ALLOW"}, {Publish: "ALLOW", Upstream: "MAYBE"}} { + _, err := m.PutPackageOriginConfiguration(ctx, ref, bad) + requireAPIError(t, err, driver.ExValidation) + } + + got, err := m.PutPackageOriginConfiguration(ctx, ref, driver.OriginRestrictions{Publish: "BLOCK", Upstream: "ALLOW"}) + requireNoError(t, err) + + if got.Restrictions.Publish != "BLOCK" || got.Restrictions.Upstream != "ALLOW" || got.Name != "pkg" { + t.Fatalf("put = %+v", got) + } + + desc, err := m.DescribePackage(ctx, ref) + requireNoError(t, err) + + if desc.Restrictions != got.Restrictions { + t.Fatalf("describe = %+v", desc) + } + + // The package exists with no versions and shows up in lists. + pkgs, _, err := m.ListPackages(ctx, &driver.ListPackagesInput{Repository: driver.RepositoryRef{Domain: "dom", Repository: "repo"}}) + requireNoError(t, err) + + vers, err := m.ListPackageVersions(ctx, &driver.ListPackageVersionsInput{Ref: ref}) + requireNoError(t, err) + + if len(pkgs) != 1 || len(vers.Versions) != 0 { + t.Fatalf("pkgs=%d versions=%d", len(pkgs), len(vers.Versions)) + } + + _, err = m.DeletePackage(ctx, ref) + requireNoError(t, err) + + _, err = m.DescribePackage(ctx, ref) + requireAPIError(t, err, driver.ExNotFound) + + pypi := ref + pypi.Format, pypi.Namespace = "pypi", "ns" + + _, err = m.PutPackageOriginConfiguration(ctx, pypi, driver.OriginRestrictions{Publish: "ALLOW", Upstream: "ALLOW"}) + requireAPIError(t, err, driver.ExValidation) +} + +func TestOriginConfigKeepsEmptyPackageAfterLastVersionDelete(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ctx := context.Background() + ref := pkgRef("repo", "pkg") + + publishAsset(t, m, ref, "1", "f", []byte("x"), false) + + _, err := m.PutPackageOriginConfiguration(ctx, ref, driver.OriginRestrictions{Publish: "ALLOW", Upstream: "BLOCK"}) + requireNoError(t, err) + + _, err = m.DeletePackageVersions(ctx, &driver.VersionsInput{Ref: ref, Versions: []string{"1"}}) + requireNoError(t, err) + + _, err = m.DescribePackage(ctx, ref) + requireNoError(t, err) +} + +func TestPublishDefaultsAndBlockedByOrigin(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ctx := context.Background() + ref := pkgRef("repo", "pkg") + + publishAsset(t, m, ref, "1", "f", []byte("x"), false) + + pkg, err := m.DescribePackage(ctx, ref) + requireNoError(t, err) + + if pkg.Restrictions.Publish != "ALLOW" || pkg.Restrictions.Upstream != "BLOCK" { + t.Fatalf("default restrictions = %+v, want publish ALLOW upstream BLOCK", pkg.Restrictions) + } + + _, err = m.PutPackageOriginConfiguration(ctx, ref, driver.OriginRestrictions{Publish: "BLOCK", Upstream: "BLOCK"}) + requireNoError(t, err) + + _, err = m.PublishPackageVersion(ctx, &driver.PublishInput{ + Ref: ref, Version: "2", AssetName: "f", SHA256: sha([]byte("y")), Content: []byte("y"), + }) + requireAPIError(t, err, driver.ExConflict) + + vers, err := m.ListPackageVersions(ctx, &driver.ListPackageVersionsInput{Ref: ref}) + requireNoError(t, err) + + if len(vers.Versions) != 1 { + t.Fatalf("blocked publish stored a version: %+v", vers.Versions) + } + + _, err = m.PutPackageOriginConfiguration(ctx, ref, driver.OriginRestrictions{Publish: "ALLOW", Upstream: "BLOCK"}) + requireNoError(t, err) + + publishAsset(t, m, ref, "2", "f", []byte("y"), false) +} + +func TestEffectiveOriginBlockWins(t *testing.T) { + ctx := context.Background() + dom := driver.DomainRef{Domain: "dom"} + + type tc struct { + name string + pkgPub string + groupPub string + allow bool + repo string + } + + cases := []tc{ + {"both allow", "ALLOW", driver.ModeAllow, true, "repo"}, + {"group blocks", "ALLOW", driver.ModeBlock, false, "repo"}, + {"package blocks", "BLOCK", driver.ModeAllow, false, "repo"}, + {"specific repo allowed", "ALLOW", driver.ModeAllowSpecificRepositories, true, "repo"}, + {"specific repo not listed", "ALLOW", driver.ModeAllowSpecificRepositories, false, "other"}, + } + + for _, c := range cases { + m := seeded(t, config.NewOptions(), "repo", "other") + + _, err := m.CreatePackageGroup(ctx, &driver.CreatePackageGroupInput{Domain: dom, Pattern: "/generic/ns/*"}) + requireNoError(t, err) + + _, err = m.UpdatePackageGroupOriginConfiguration(ctx, &driver.UpdateGroupOriginInput{ + Domain: dom, Pattern: "/generic/ns/*", Restrictions: map[string]string{driver.RestrictionPublish: c.groupPub}, + }) + requireNoError(t, err) + + if c.groupPub == driver.ModeAllowSpecificRepositories { + _, err = m.UpdatePackageGroupOriginConfiguration(ctx, &driver.UpdateGroupOriginInput{ + Domain: dom, Pattern: "/generic/ns/*", + AddAllowed: []driver.AllowedRepository{{RepositoryName: "repo", OriginRestrictionType: driver.RestrictionPublish}}, + }) + requireNoError(t, err) + } + + ref := pkgRef(c.repo, "pkg") + + _, err = m.PutPackageOriginConfiguration(ctx, ref, driver.OriginRestrictions{Publish: c.pkgPub, Upstream: "ALLOW"}) + requireNoError(t, err) + + _, err = m.PublishPackageVersion(ctx, &driver.PublishInput{ + Ref: ref, Version: "1", AssetName: "f", SHA256: sha([]byte("x")), Content: []byte("x"), + }) + + if c.allow { + requireNoError(t, err) + } else { + requireAPIError(t, err, driver.ExConflict) + } + } +} + +func TestWeakMatchPublishBlocked(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ctx := context.Background() + + _, err := m.CreatePackageGroup(ctx, &driver.CreatePackageGroupInput{Domain: driver.DomainRef{Domain: "dom"}, Pattern: "/generic/ns/Async-Storage$"}) + requireNoError(t, err) + + exact := pkgRef("repo", "Async-Storage") + publishAsset(t, m, exact, "1", "f", []byte("x"), false) + + variant := pkgRef("repo", "async_storage") + + _, err = m.PublishPackageVersion(ctx, &driver.PublishInput{ + Ref: variant, Version: "1", AssetName: "f", SHA256: sha([]byte("x")), Content: []byte("x"), + }) + requireAPIError(t, err, driver.ExConflict) +} + +func TestDefaultRestrictionsFollowGroup(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ctx := context.Background() + dom := driver.DomainRef{Domain: "dom"} + + _, err := m.CreatePackageGroup(ctx, &driver.CreatePackageGroupInput{Domain: dom, Pattern: "/generic/ns/*"}) + requireNoError(t, err) + + _, err = m.UpdatePackageGroupOriginConfiguration(ctx, &driver.UpdateGroupOriginInput{ + Domain: dom, Pattern: "/generic/ns/*", Restrictions: map[string]string{driver.RestrictionExternalUpstream: driver.ModeBlock}, + }) + requireNoError(t, err) + + publishAsset(t, m, pkgRef("repo", "pkg"), "1", "f", []byte("x"), false) + + pkg, err := m.DescribePackage(ctx, pkgRef("repo", "pkg")) + requireNoError(t, err) + + if pkg.Restrictions.Publish != "ALLOW" || pkg.Restrictions.Upstream != "ALLOW" { + t.Fatalf("restrictions under a restrictive group = %+v, want ALLOW/ALLOW", pkg.Restrictions) + } +} diff --git a/providers/aws/codeartifact/package.go b/providers/aws/codeartifact/package.go new file mode 100644 index 000000000..a132aca60 --- /dev/null +++ b/providers/aws/codeartifact/package.go @@ -0,0 +1,257 @@ +package codeartifact + +import ( + "context" + "sort" + "strings" + "time" + + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +// assetRecord is one stored file of a package version. Content is shared between +// copies of a version and is never modified after it is stored, so it can be +// shared safely; reads hand out a copy. After a snapshot taken without assets +// Stored is false: the metadata survives, the bytes do not. +type assetRecord struct { + Name string `json:"name"` + Size int64 `json:"size"` + Hashes map[string]string `json:"hashes"` + Content []byte `json:"content,omitempty"` + Stored bool `json:"stored"` +} + +// versionRecord is a stored package version. +type versionRecord struct { + Version string `json:"version"` + Revision string `json:"revision"` + Status string `json:"status"` + PublishedTime time.Time `json:"publishedTime"` + Origin driver.VersionOrigin `json:"origin"` + Assets map[string]*assetRecord `json:"assets,omitempty"` +} + +// packageRecord is a stored package with its versions. Seq is the last event +// sequence number issued for the package. ExplicitOrigin marks a package whose +// origin controls were set by PutPackageOriginConfiguration, which keeps the +// record alive when its last version is deleted. Records are read and written +// only under the Mock lock. +type packageRecord struct { + Domain string `json:"domain"` + Repository string `json:"repository"` + Format string `json:"format"` + Namespace string `json:"namespace"` + Name string `json:"name"` + Restrictions driver.OriginRestrictions `json:"restrictions"` + ExplicitOrigin bool `json:"explicitOrigin,omitempty"` + Seq int64 `json:"seq"` + Versions map[string]*versionRecord `json:"versions,omitempty"` +} + +// packageKey is the store key of a package. +func packageKey(domain, repo, format, namespace, name string) string { + return strings.Join([]string{domain, repo, format, namespace, name}, "/") +} + +func refKey(ref *driver.PackageRef) string { + return packageKey(ref.Domain, ref.Repository, ref.Format, ref.Namespace, ref.Package) +} + +func (p *packageRecord) toDriver() *driver.Package { + return &driver.Package{Format: p.Format, Namespace: p.Namespace, Name: p.Name, Restrictions: p.Restrictions} +} + +// packageLocked resolves a package reference: the coordinates are validated and +// the repository and package must exist. The caller holds mu. +func (m *Mock) packageLocked(ref *driver.PackageRef) (driver.Repository, *packageRecord, error) { + if err := validatePackageRef(ref); err != nil { + return driver.Repository{}, nil, err + } + + _, r, err := m.repositoryLocked(driver.RepositoryRef{Domain: ref.Domain, Owner: ref.Owner, Repository: ref.Repository}) + if err != nil { + return driver.Repository{}, nil, err + } + + p, ok := m.packages.Get(refKey(ref)) + if !ok { + return driver.Repository{}, nil, notFound(driver.ResourceTypePackage, ref.Package, + "Package %s not found in repository %s", ref.Package, ref.Repository) + } + + return r, p, nil +} + +// ListPackages returns a deterministic page of a repository's packages ordered by +// format, namespace and name. +func (m *Mock) ListPackages(_ context.Context, in *driver.ListPackagesInput) ([]*driver.Package, string, error) { + if err := validatePackageFilters(in); err != nil { + return nil, "", err + } + + m.mu.RLock() + defer m.mu.RUnlock() + + if _, _, err := m.repositoryLocked(in.Repository); err != nil { + return nil, "", err + } + + all := m.packages.SortedValues() + filtered := make([]*driver.Package, 0, len(all)) + + for _, p := range all { + if p.Domain == in.Repository.Domain && p.Repository == in.Repository.Repository && packageMatchesFilters(p, in) { + filtered = append(filtered, p.toDriver()) + } + } + + sort.Slice(filtered, func(i, j int) bool { return lessPackage(filtered[i], filtered[j]) }) + + return pageOf(filtered, in.Page) +} + +// lessPackage orders packages by format, namespace and name. +func lessPackage(a, b *driver.Package) bool { + if a.Format != b.Format { + return a.Format < b.Format + } + + if a.Namespace != b.Namespace { + return a.Namespace < b.Namespace + } + + return a.Name < b.Name +} + +func validatePackageFilters(in *driver.ListPackagesInput) error { + if in.Format != "" && !validFormat(in.Format) { + return validation("package format %q is not one of npm, pypi, maven, nuget, generic, ruby, swift, cargo", in.Format) + } + + for _, f := range []struct{ name, value string }{{"namespace filter", in.Namespace}, {"package prefix", in.Prefix}} { + if f.value != "" && !validComponent(f.value) { + return validation("%s %q is invalid", f.name, f.value) + } + } + + for _, v := range []string{in.Publish, in.Upstream} { + if v != "" && v != driver.OriginAllow && v != driver.OriginBlock { + return validation("origin filter %q must be ALLOW or BLOCK", v) + } + } + + return nil +} + +func packageMatchesFilters(p *packageRecord, in *driver.ListPackagesInput) bool { + return (in.Format == "" || p.Format == in.Format) && + strings.HasPrefix(p.Namespace, in.Namespace) && + strings.HasPrefix(p.Name, in.Prefix) && + (in.Publish == "" || p.Restrictions.Publish == in.Publish) && + (in.Upstream == "" || p.Restrictions.Upstream == in.Upstream) +} + +// DescribePackage returns a package record. +// +//nolint:gocritic // ref is passed by value to implement driver.Packages. +func (m *Mock) DescribePackage(_ context.Context, ref driver.PackageRef) (*driver.Package, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + _, p, err := m.packageLocked(&ref) + if err != nil { + return nil, err + } + + return p.toDriver(), nil +} + +// PutPackageOriginConfiguration sets a package's origin controls. The package +// need not exist: an empty record is created to hold the restrictions. +// +//nolint:gocritic // ref is passed by value to implement driver.Packages. +func (m *Mock) PutPackageOriginConfiguration(_ context.Context, ref driver.PackageRef, + restrictions driver.OriginRestrictions) (*driver.Package, error) { + if err := validatePackageRef(&ref); err != nil { + return nil, err + } + + for _, v := range []string{restrictions.Publish, restrictions.Upstream} { + if v != driver.OriginAllow && v != driver.OriginBlock { + return nil, validation("origin restriction %q must be ALLOW or BLOCK", v) + } + } + + if ref.Namespace != "" && !formatHasNamespace(ref.Format) { + return nil, validation("%s packages have no namespace", ref.Format) + } + + m.mu.Lock() + defer m.mu.Unlock() + + if _, _, err := m.repositoryLocked(driver.RepositoryRef{Domain: ref.Domain, Owner: ref.Owner, Repository: ref.Repository}); err != nil { + return nil, err + } + + key := refKey(&ref) + + p, ok := m.packages.Get(key) + if !ok { + p = &packageRecord{ + Domain: ref.Domain, Repository: ref.Repository, Format: ref.Format, Namespace: ref.Namespace, Name: ref.Package, + Versions: map[string]*versionRecord{}, + } + m.packages.Set(key, p) + } + + p.Restrictions = restrictions + p.ExplicitOrigin = true + + return p.toDriver(), nil +} + +// formatHasNamespace reports whether packages of a format can have a namespace. +// Python, NuGet, Ruby and Cargo packages have none. +func formatHasNamespace(format string) bool { + switch format { + case driver.FormatPyPI, driver.FormatNuGet, driver.FormatRuby, driver.FormatCargo: + return false + default: + return true + } +} + +// DeletePackage deletes a package with all its versions and assets, publishing +// one Deleted event per version. +// +//nolint:gocritic // ref is passed by value to implement driver.Packages. +func (m *Mock) DeletePackage(ctx context.Context, ref driver.PackageRef) (*driver.Package, error) { + out, events, err := m.deletePackage(&ref) + m.publishVersionEvents(ctx, events) + + return out, err +} + +func (m *Mock) deletePackage(ref *driver.PackageRef) (*driver.Package, []versionEvent, error) { + m.mu.Lock() + defer m.mu.Unlock() + + repo, p, err := m.packageLocked(ref) + if err != nil { + return nil, nil, err + } + + out := p.toDriver() + + events := make([]versionEvent, 0, len(p.Versions)) + + for _, v := range sortedVersions(p) { + events = append(events, m.newVersionEventLocked(&repo, p, v, operationDeleted, versionChanges{ + AssetsRemoved: len(v.Assets), StatusChanged: true, + })) + } + + m.packages.Delete(refKey(ref)) + + return out, events, nil +} diff --git a/providers/aws/codeartifact/package_group.go b/providers/aws/codeartifact/package_group.go new file mode 100644 index 000000000..e30e6ed05 --- /dev/null +++ b/providers/aws/codeartifact/package_group.go @@ -0,0 +1,731 @@ +package codeartifact + +import ( + "context" + "sort" + "strings" + "time" + + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +// groupRecord is a stored package group. Modes holds the configured mode per +// restriction type (an absent type is INHERIT) and Allowed the sorted allowed +// repository list of each type that is ALLOW_SPECIFIC_REPOSITORIES. Records are +// read and written only under the Mock lock. +type groupRecord struct { + Domain string `json:"domain"` + Pattern string `json:"pattern"` + Arn string `json:"arn"` + Owner string `json:"owner"` + CreatedTime time.Time `json:"createdTime"` + ContactInfo string `json:"contactInfo,omitempty"` + Description string `json:"description,omitempty"` + Tags map[string]string `json:"tags,omitempty"` + Modes map[string]string `json:"modes,omitempty"` + Allowed map[string][]string `json:"allowed,omitempty"` +} + +// groupKey is the store key of a package group. +func groupKey(domain, pattern string) string { + return domain + "|" + pattern +} + +// restrictionTypes lists the origin restriction types in a fixed order. +func restrictionTypes() []string { + return []string{driver.RestrictionPublish, driver.RestrictionExternalUpstream, driver.RestrictionInternalUpstream} +} + +func validRestrictionType(s string) bool { + return s == driver.RestrictionPublish || s == driver.RestrictionExternalUpstream || s == driver.RestrictionInternalUpstream +} + +func validRestrictionMode(s string) bool { + switch s { + case driver.ModeAllow, driver.ModeAllowSpecificRepositories, driver.ModeBlock, driver.ModeInherit: + return true + default: + return false + } +} + +func (g *groupRecord) mode(restrictionType string) string { + if m, ok := g.Modes[restrictionType]; ok { + return m + } + + return driver.ModeInherit +} + +func (g *groupRecord) copy() *groupRecord { + out := *g + out.Tags = copyTags(g.Tags) + out.Modes = copyTags(g.Modes) + + if g.Allowed != nil { + out.Allowed = make(map[string][]string, len(g.Allowed)) + for k, v := range g.Allowed { + out.Allowed[k] = append([]string(nil), v...) + } + } + + return &out +} + +// groupView is a stored group with its parsed pattern. +type groupView struct { + rec *groupRecord + pat groupPattern +} + +// groupViewsLocked returns the domain's groups sorted by pattern. The caller +// holds mu. +func (m *Mock) groupViewsLocked(domain string) []groupView { + recs := m.groups.Filter(func(k string, _ *groupRecord) bool { return strings.HasPrefix(k, domain+"|") }) + views := make([]groupView, 0, len(recs)) + + for _, rec := range recs { + pat, err := parseGroupPattern(rec.Pattern) + if err != nil { + continue + } + + views = append(views, groupView{rec: rec, pat: pat}) + } + + sort.Slice(views, func(i, j int) bool { return views[i].rec.Pattern < views[j].rec.Pattern }) + + return views +} + +// depths counts, for each group, how many other groups strictly contain it. +func depths(views []groupView) []int { + out := make([]int, len(views)) + + for i := range views { + for j := range views { + if i != j && strictlyCovers(views[j].pat, views[i].pat) { + out[i]++ + } + } + } + + return out +} + +func strictlyCovers(b, a groupPattern) bool { + return b.covers(a) && !a.covers(b) +} + +// parentsOf returns, for each group, the index of its parent (the most specific +// group strictly containing it) or -1. +func parentsOf(views []groupView) []int { + depth := depths(views) + parents := make([]int, len(views)) + + for i := range views { + parents[i] = -1 + + for j := range views { + if i == j || !strictlyCovers(views[j].pat, views[i].pat) { + continue + } + + if parents[i] < 0 || moreSpecific(views, depth, j, parents[i]) { + parents[i] = j + } + } + } + + return parents +} + +// moreSpecific reports whether group a is deeper than group b, breaking ties by +// the longer pattern and then by pattern order so the choice is deterministic. +func moreSpecific(views []groupView, depth []int, a, b int) bool { + if depth[a] != depth[b] { + return depth[a] > depth[b] + } + + pa, pb := views[a].rec.Pattern, views[b].rec.Pattern + if len(pa) != len(pb) { + return len(pa) > len(pb) + } + + return pa < pb +} + +// associatedGroup returns the index of the most specific group a package matches +// and whether the match is strong. ok is false when no group matches. +func associatedGroup(views []groupView, format, ns, name string) (idx int, strong, ok bool) { + depth := depths(views) + idx = -1 + + for i := range views { + if !views[i].pat.matches(format, ns, name, true) { + continue + } + + if idx < 0 || moreSpecific(views, depth, i, idx) { + idx = i + } + } + + if idx < 0 { + return -1, false, false + } + + return idx, views[idx].pat.matches(format, ns, name, false), true +} + +// effective resolves a group's mode for a restriction type through inheritance. +// src is the index of the group that decides it, or -1 when no group in the +// chain sets one (the mode is then ALLOW). +func effective(views []groupView, parents []int, idx int, restrictionType string) (mode string, src int) { + for cur := idx; cur >= 0; cur = parents[cur] { + if m := views[cur].rec.mode(restrictionType); m != driver.ModeInherit { + return m, cur + } + } + + return driver.ModeAllow, -1 +} + +func groupRef(rec *groupRecord) *driver.GroupReference { + return &driver.GroupReference{Arn: rec.Arn, Pattern: rec.Pattern} +} + +// groupToDriver renders a stored group with its parent and effective modes. +func groupToDriver(views []groupView, parents []int, idx int) *driver.PackageGroup { + rec := views[idx].rec + out := &driver.PackageGroup{ + Arn: rec.Arn, + Pattern: rec.Pattern, + DomainName: rec.Domain, + DomainOwner: rec.Owner, + CreatedTime: rec.CreatedTime, + ContactInfo: rec.ContactInfo, + Description: rec.Description, + Restrictions: make(map[string]driver.GroupRestriction, len(restrictionTypes())), + } + + if parents[idx] >= 0 { + out.Parent = groupRef(views[parents[idx]].rec) + } + + for _, t := range restrictionTypes() { + mode, src := effective(views, parents, idx, t) + r := driver.GroupRestriction{ + Mode: rec.mode(t), + EffectiveMode: mode, + RepositoriesCount: int64(len(rec.Allowed[t])), + } + + if r.Mode == driver.ModeInherit && src >= 0 { + r.InheritedFrom = groupRef(views[src].rec) + } + + out.Restrictions[t] = r + } + + return out +} + +func indexOfPattern(views []groupView, pattern string) int { + for i := range views { + if views[i].rec.Pattern == pattern { + return i + } + } + + return -1 +} + +// groupLocked resolves a group by pattern within a domain. The caller holds mu. +func (m *Mock) groupLocked(ref driver.DomainRef, pattern string) (views []groupView, idx int, err error) { + if _, err = m.domainLocked(ref); err != nil { + return nil, 0, err + } + + if _, err = parseGroupPattern(pattern); err != nil { + return nil, 0, err + } + + views = m.groupViewsLocked(ref.Domain) + + idx = indexOfPattern(views, pattern) + if idx < 0 { + return nil, 0, notFound("", pattern, + "Package group %s not found in domain %s", pattern, ref.Domain) + } + + return views, idx, nil +} + +// CreatePackageGroup creates a package group. The pattern must be valid and +// unused in the domain (patterns are case-sensitive). +func (m *Mock) CreatePackageGroup(_ context.Context, in *driver.CreatePackageGroupInput) (*driver.PackageGroup, error) { + if _, err := parseGroupPattern(in.Pattern); err != nil { + return nil, err + } + + if err := validateGroupText(in.ContactInfo, in.Description); err != nil { + return nil, err + } + + if err := validateTags(in.Tags); err != nil { + return nil, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + d, err := m.domainLocked(in.Domain) + if err != nil { + return nil, err + } + + key := groupKey(in.Domain.Domain, in.Pattern) + if m.groups.Has(key) { + return nil, conflict("", in.Pattern, "Package group %s already exists", in.Pattern) + } + + m.groups.Set(key, &groupRecord{ + Domain: d.Name, + Pattern: in.Pattern, + Arn: m.groupARN(d.Owner, d.Name, in.Pattern), + Owner: d.Owner, + CreatedTime: m.now(), + ContactInfo: in.ContactInfo, + Description: in.Description, + Tags: copyTags(in.Tags), + }) + + views := m.groupViewsLocked(d.Name) + + return groupToDriver(views, parentsOf(views), indexOfPattern(views, in.Pattern)), nil +} + +// validateGroupText checks the contact info and description of a package group. +func validateGroupText(contact, description string) error { + if len(contact) > maxDescription || hasControl(contact) { + return validation("contactInfo must be at most %d characters without control characters", maxDescription) + } + + return validateDescription(description) +} + +// DescribePackageGroup returns a package group with its parent and effective +// origin restrictions. +func (m *Mock) DescribePackageGroup(_ context.Context, ref driver.DomainRef, pattern string) (*driver.PackageGroup, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + views, idx, err := m.groupLocked(ref, pattern) + if err != nil { + return nil, err + } + + return groupToDriver(views, parentsOf(views), idx), nil +} + +// UpdatePackageGroup changes the contact info and description of a group; the +// pattern and origin configuration are not editable here. +func (m *Mock) UpdatePackageGroup(_ context.Context, in *driver.UpdatePackageGroupInput) (*driver.PackageGroup, error) { + contact, desc := "", "" + if in.ContactInfo != nil { + contact = *in.ContactInfo + } + + if in.Description != nil { + desc = *in.Description + } + + if err := validateGroupText(contact, desc); err != nil { + return nil, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + views, idx, err := m.groupLocked(in.Domain, in.Pattern) + if err != nil { + return nil, err + } + + if in.ContactInfo != nil { + views[idx].rec.ContactInfo = *in.ContactInfo + } + + if in.Description != nil { + views[idx].rec.Description = *in.Description + } + + return groupToDriver(views, parentsOf(views), idx), nil +} + +// DeletePackageGroup deletes a group. Its direct children become children of its +// parent, which the computed hierarchy does by itself. +func (m *Mock) DeletePackageGroup(_ context.Context, ref driver.DomainRef, pattern string) (*driver.PackageGroup, error) { + m.mu.Lock() + defer m.mu.Unlock() + + views, idx, err := m.groupLocked(ref, pattern) + if err != nil { + return nil, err + } + + out := groupToDriver(views, parentsOf(views), idx) + + m.groups.Delete(groupKey(ref.Domain, pattern)) + + return out, nil +} + +// UpdatePackageGroupOriginConfiguration sets restriction modes and edits the +// allowed repository lists. A list exists only while its restriction is +// ALLOW_SPECIFIC_REPOSITORIES; switching away from that mode clears it. +func (m *Mock) UpdatePackageGroupOriginConfiguration(_ context.Context, + in *driver.UpdateGroupOriginInput) (*driver.GroupOriginUpdate, error) { + if err := validateOriginUpdate(in); err != nil { + return nil, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + views, idx, err := m.groupLocked(in.Domain, in.Pattern) + if err != nil { + return nil, err + } + + // Work on a copy so a rejected request changes nothing. + rec := views[idx].rec.copy() + + for t, mode := range in.Restrictions { + if rec.Modes == nil { + rec.Modes = map[string]string{} + } + + rec.Modes[t] = mode + + if mode != driver.ModeAllowSpecificRepositories { + delete(rec.Allowed, t) + } + } + + updates, err := m.applyAllowedEditsLocked(rec, in) + if err != nil { + return nil, err + } + + m.groups.Set(groupKey(in.Domain.Domain, in.Pattern), rec) + views[idx].rec = rec + + return &driver.GroupOriginUpdate{Group: groupToDriver(views, parentsOf(views), idx), Updates: updates}, nil +} + +func validateOriginUpdate(in *driver.UpdateGroupOriginInput) error { + for t, mode := range in.Restrictions { + if !validRestrictionType(t) || !validRestrictionMode(mode) { + return validation("restriction %s=%s is not valid", t, mode) + } + } + + for _, a := range append(append([]driver.AllowedRepository(nil), in.AddAllowed...), in.RemoveAllowed...) { + if !validRestrictionType(a.OriginRestrictionType) { + return validation("originRestrictionType %q must be PUBLISH, EXTERNAL_UPSTREAM or INTERNAL_UPSTREAM", a.OriginRestrictionType) + } + + if err := validateRepositoryName(a.RepositoryName); err != nil { + return err + } + } + + return nil +} + +// applyAllowedEditsLocked applies the add/remove edits to rec and returns what +// changed, per restriction type: ADDED and REMOVED repository names. A list can +// only be edited while its restriction is ALLOW_SPECIFIC_REPOSITORIES, and added +// repositories must exist in the domain. +func (m *Mock) applyAllowedEditsLocked(rec *groupRecord, in *driver.UpdateGroupOriginInput) (map[string]map[string][]string, error) { + updates := map[string]map[string][]string{} + record := func(t, kind, repo string) { + if updates[t] == nil { + updates[t] = map[string][]string{} + } + + updates[t][kind] = append(updates[t][kind], repo) + } + + for _, a := range in.AddAllowed { + if rec.mode(a.OriginRestrictionType) != driver.ModeAllowSpecificRepositories { + return nil, validation("%s is not ALLOW_SPECIFIC_REPOSITORIES, so it has no allowed repository list", a.OriginRestrictionType) + } + + if !m.repos.Has(repoKey(rec.Domain, a.RepositoryName)) { + return nil, notFound(driver.ResourceTypeRepository, a.RepositoryName, + "Repository %s not found in domain %s", a.RepositoryName, rec.Domain) + } + + if !containsString(rec.Allowed[a.OriginRestrictionType], a.RepositoryName) { + if rec.Allowed == nil { + rec.Allowed = map[string][]string{} + } + + rec.Allowed[a.OriginRestrictionType] = insertSorted(rec.Allowed[a.OriginRestrictionType], a.RepositoryName) + record(a.OriginRestrictionType, driver.AllowedRepositoryAdded, a.RepositoryName) + } + } + + for _, a := range in.RemoveAllowed { + cur := rec.Allowed[a.OriginRestrictionType] + if !containsString(cur, a.RepositoryName) { + continue + } + + rec.Allowed[a.OriginRestrictionType] = removeString(cur, a.RepositoryName) + record(a.OriginRestrictionType, driver.AllowedRepositoryRemoved, a.RepositoryName) + } + + return updates, nil +} + +func containsString(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + + return false +} + +func insertSorted(list []string, s string) []string { + out := append(append([]string(nil), list...), s) + sort.Strings(out) + + return out +} + +func removeString(list []string, s string) []string { + out := make([]string, 0, len(list)) + + for _, v := range list { + if v != s { + out = append(out, v) + } + } + + return out +} + +// dropAllowedRepositoryLocked removes a deleted repository from every package +// group's allowed lists in the domain. The caller holds mu. +func (m *Mock) dropAllowedRepositoryLocked(domain, repo string) { + for _, g := range m.groups.Filter(func(k string, _ *groupRecord) bool { return strings.HasPrefix(k, domain+"|") }) { + for t, list := range g.Allowed { + g.Allowed[t] = removeString(list, repo) + } + } +} + +// ListPackageGroups returns a deterministic page of the domain's groups whose +// pattern starts with prefix. +func (m *Mock) ListPackageGroups(_ context.Context, ref driver.DomainRef, prefix string, + page driver.Page) ([]*driver.PackageGroup, string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + if _, err := m.domainLocked(ref); err != nil { + return nil, "", err + } + + views := m.groupViewsLocked(ref.Domain) + parents := parentsOf(views) + + var idxs []int + + for i := range views { + if strings.HasPrefix(views[i].rec.Pattern, prefix) { + idxs = append(idxs, i) + } + } + + return renderGroupPage(views, parents, idxs, page) +} + +// ListSubPackageGroups returns a deterministic page of a group's direct children. +func (m *Mock) ListSubPackageGroups(_ context.Context, ref driver.DomainRef, pattern string, + page driver.Page) ([]*driver.PackageGroup, string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + views, idx, err := m.groupLocked(ref, pattern) + if err != nil { + return nil, "", err + } + + parents := parentsOf(views) + + var idxs []int + + for i := range views { + if parents[i] == idx { + idxs = append(idxs, i) + } + } + + return renderGroupPage(views, parents, idxs, page) +} + +func renderGroupPage(views []groupView, parents, idxs []int, page driver.Page) ([]*driver.PackageGroup, string, error) { + window, next, err := pageOf(idxs, page) + if err != nil { + return nil, "", err + } + + out := make([]*driver.PackageGroup, 0, len(window)) + for _, i := range window { + out = append(out, groupToDriver(views, parents, i)) + } + + return out, next, nil +} + +// ListAllowedRepositoriesForGroup returns a page of the repositories in the +// allowed list of one restriction type. +func (m *Mock) ListAllowedRepositoriesForGroup(_ context.Context, ref driver.DomainRef, pattern, restrictionType string, + page driver.Page) (repos []string, next string, err error) { + if !validRestrictionType(restrictionType) { + return nil, "", validation("originRestrictionType %q must be PUBLISH, EXTERNAL_UPSTREAM or INTERNAL_UPSTREAM", restrictionType) + } + + m.mu.RLock() + defer m.mu.RUnlock() + + views, idx, err := m.groupLocked(ref, pattern) + if err != nil { + return nil, "", err + } + + return pageOf(append([]string(nil), views[idx].rec.Allowed[restrictionType]...), page) +} + +// packageCoordinates lists the distinct packages of a domain, sorted. The caller +// holds mu. +func (m *Mock) packageCoordinatesLocked(domain string) []driver.AssociatedPackage { + pkgs := m.packages.Filter(func(k string, _ *packageRecord) bool { return strings.HasPrefix(k, domain+"/") }) + seen := make(map[string]bool, len(pkgs)) + out := make([]driver.AssociatedPackage, 0, len(pkgs)) + + for _, p := range pkgs { + id := p.Format + "/" + p.Namespace + "/" + p.Name + if seen[id] { + continue + } + + seen[id] = true + + out = append(out, driver.AssociatedPackage{Format: p.Format, Namespace: p.Namespace, Package: p.Name}) + } + + sort.Slice(out, func(i, j int) bool { + a, b := out[i], out[j] + if a.Format != b.Format { + return a.Format < b.Format + } + + if a.Namespace != b.Namespace { + return a.Namespace < b.Namespace + } + + return a.Package < b.Package + }) + + return out +} + +// ListAssociatedPackages returns a page of the packages whose most specific +// group is the given one. With preview, a group that does not exist yet is +// evaluated as if it did. +func (m *Mock) ListAssociatedPackages(_ context.Context, ref driver.DomainRef, pattern string, preview bool, + page driver.Page) ([]driver.AssociatedPackage, string, error) { + pat, err := parseGroupPattern(pattern) + if err != nil { + return nil, "", err + } + + m.mu.RLock() + defer m.mu.RUnlock() + + if _, err = m.domainLocked(ref); err != nil { + return nil, "", err + } + + views := m.groupViewsLocked(ref.Domain) + + idx := indexOfPattern(views, pattern) + if idx < 0 { + if !preview { + return nil, "", notFound("", pattern, + "Package group %s not found in domain %s", pattern, ref.Domain) + } + + views = append(views, groupView{rec: &groupRecord{Domain: ref.Domain, Pattern: pattern}, pat: pat}) + idx = len(views) - 1 + } + + coords := m.packageCoordinatesLocked(ref.Domain) + out := make([]driver.AssociatedPackage, 0, len(coords)) + + for _, p := range coords { + got, strong, ok := associatedGroup(views, p.Format, p.Namespace, p.Package) + if !ok || got != idx { + continue + } + + p.AssociationType = associationType(strong) + out = append(out, p) + } + + return pageOf(out, page) +} + +func associationType(strong bool) string { + if strong { + return driver.AssociationStrong + } + + return driver.AssociationWeak +} + +// GetAssociatedPackageGroup returns the group a package coordinate belongs to and +// how strongly. The package need not exist in any repository. +func (m *Mock) GetAssociatedPackageGroup(_ context.Context, ref driver.DomainRef, + format, namespace, pkg string) (*driver.PackageGroup, string, error) { + if !validFormat(format) { + return nil, "", validation("package format %q is not one of npm, pypi, maven, nuget, generic, ruby, swift, cargo", format) + } + + if (namespace != "" && !validComponent(namespace)) || !validComponent(pkg) { + return nil, "", validation("package coordinates are invalid") + } + + m.mu.RLock() + defer m.mu.RUnlock() + + if _, err := m.domainLocked(ref); err != nil { + return nil, "", err + } + + views := m.groupViewsLocked(ref.Domain) + + idx, strong, ok := associatedGroup(views, format, namespace, pkg) + if !ok { + return nil, "", notFound("", pkg, "No package group is associated with package %s", pkg) + } + + return groupToDriver(views, parentsOf(views), idx), associationType(strong), nil +} diff --git a/providers/aws/codeartifact/package_group_test.go b/providers/aws/codeartifact/package_group_test.go new file mode 100644 index 000000000..76a3d573e --- /dev/null +++ b/providers/aws/codeartifact/package_group_test.go @@ -0,0 +1,384 @@ +package codeartifact_test + +import ( + "context" + "fmt" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +func TestPackageGroupCRUD(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ctx := context.Background() + dom := driver.DomainRef{Domain: "dom"} + + created, err := m.CreatePackageGroup(ctx, &driver.CreatePackageGroupInput{ + Domain: dom, Pattern: "/npm/*", ContactInfo: "a@b.c", Description: "npm packages", Tags: map[string]string{"k": "v"}, + }) + requireNoError(t, err) + + if created.Arn == "" || created.DomainName != "dom" || created.CreatedTime.IsZero() || created.Parent != nil || created.ContactInfo != "a@b.c" { + t.Fatalf("created = %+v", created) + } + + for _, rt := range []string{driver.RestrictionPublish, driver.RestrictionExternalUpstream, driver.RestrictionInternalUpstream} { + r := created.Restrictions[rt] + if r.Mode != driver.ModeInherit || r.EffectiveMode != driver.ModeAllow || r.InheritedFrom != nil { + t.Fatalf("restriction %s = %+v", rt, r) + } + } + + again, err := m.DescribePackageGroup(ctx, dom, "/npm/*") + requireNoError(t, err) + + if again.Arn != created.Arn || !again.CreatedTime.Equal(created.CreatedTime) { + t.Fatal("describe drifted from create") + } + + _, err = m.CreatePackageGroup(ctx, &driver.CreatePackageGroupInput{Domain: dom, Pattern: "/npm/*"}) + requireAPIError(t, err, driver.ExConflict) + + // Patterns are case-sensitive: differing case is a different group. + _, err = m.CreatePackageGroup(ctx, &driver.CreatePackageGroupInput{Domain: dom, Pattern: "/npm//AsyncStorage$"}) + requireNoError(t, err) + + _, err = m.CreatePackageGroup(ctx, &driver.CreatePackageGroupInput{Domain: dom, Pattern: "/npm//asyncStorage$"}) + requireNoError(t, err) + + newDesc := "updated" + + upd, err := m.UpdatePackageGroup(ctx, &driver.UpdatePackageGroupInput{Domain: dom, Pattern: "/npm/*", Description: &newDesc}) + requireNoError(t, err) + + if upd.Description != "updated" || upd.ContactInfo != "a@b.c" { + t.Fatalf("update = %+v", upd) + } + + del, err := m.DeletePackageGroup(ctx, dom, "/npm/*") + requireNoError(t, err) + + if del.Pattern != "/npm/*" { + t.Fatalf("deleted = %+v", del) + } + + _, err = m.DescribePackageGroup(ctx, dom, "/npm/*") + requireAPIError(t, err, driver.ExNotFound) + + _, err = m.DeletePackageGroup(ctx, dom, "/npm/*") + requireAPIError(t, err, driver.ExNotFound) + + _, err = m.CreatePackageGroup(ctx, &driver.CreatePackageGroupInput{Domain: driver.DomainRef{Domain: "ghost"}, Pattern: "/npm/*"}) + requireAPIError(t, err, driver.ExNotFound) + + long := string(make([]byte, 1001)) + + _, err = m.CreatePackageGroup(ctx, &driver.CreatePackageGroupInput{Domain: dom, Pattern: "/pypi/*", ContactInfo: long}) + requireAPIError(t, err, driver.ExValidation) +} + +func TestGroupOriginModesAndInheritance(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo-a", "repo-b") + ctx := context.Background() + dom := driver.DomainRef{Domain: "dom"} + + createGroup(t, m, "/npm/*") + createGroup(t, m, "/npm/space/*") + + out, err := m.UpdatePackageGroupOriginConfiguration(ctx, &driver.UpdateGroupOriginInput{ + Domain: dom, Pattern: "/npm/*", + Restrictions: map[string]string{ + driver.RestrictionPublish: driver.ModeBlock, driver.RestrictionInternalUpstream: driver.ModeAllowSpecificRepositories, + }, + AddAllowed: []driver.AllowedRepository{ + {RepositoryName: "repo-a", OriginRestrictionType: driver.RestrictionInternalUpstream}, + {RepositoryName: "repo-b", OriginRestrictionType: driver.RestrictionInternalUpstream}, + {RepositoryName: "repo-a", OriginRestrictionType: driver.RestrictionInternalUpstream}, + }, + }) + requireNoError(t, err) + + added := out.Updates[driver.RestrictionInternalUpstream][driver.AllowedRepositoryAdded] + if len(added) != 2 || added[0] != "repo-a" || added[1] != "repo-b" { + t.Fatalf("updates = %+v", out.Updates) + } + + if got := out.Group.Restrictions[driver.RestrictionInternalUpstream]; got.RepositoriesCount != 2 || got.Mode != driver.ModeAllowSpecificRepositories { + t.Fatalf("internal upstream = %+v", got) + } + + child, err := m.DescribePackageGroup(ctx, dom, "/npm/space/*") + requireNoError(t, err) + + pub := child.Restrictions[driver.RestrictionPublish] + if pub.Mode != driver.ModeInherit || pub.EffectiveMode != driver.ModeBlock || pub.InheritedFrom == nil || pub.InheritedFrom.Pattern != "/npm/*" { + t.Fatalf("inherited publish = %+v", pub) + } + + ext := child.Restrictions[driver.RestrictionExternalUpstream] + if ext.EffectiveMode != driver.ModeAllow || ext.InheritedFrom != nil { + t.Fatalf("no-parent default = %+v", ext) + } + + list, _, err := m.ListAllowedRepositoriesForGroup(ctx, dom, "/npm/*", driver.RestrictionInternalUpstream, driver.Page{}) + requireNoError(t, err) + + if len(list) != 2 { + t.Fatalf("allowed = %v", list) + } + + rm, err := m.UpdatePackageGroupOriginConfiguration(ctx, &driver.UpdateGroupOriginInput{ + Domain: dom, Pattern: "/npm/*", + RemoveAllowed: []driver.AllowedRepository{ + {RepositoryName: "repo-a", OriginRestrictionType: driver.RestrictionInternalUpstream}, + {RepositoryName: "never-added", OriginRestrictionType: driver.RestrictionInternalUpstream}, + }, + }) + requireNoError(t, err) + + removed := rm.Updates[driver.RestrictionInternalUpstream][driver.AllowedRepositoryRemoved] + if len(removed) != 1 || removed[0] != "repo-a" { + t.Fatalf("removed = %+v", rm.Updates) + } + + // Switching away from ALLOW_SPECIFIC_REPOSITORIES clears the list. + _, err = m.UpdatePackageGroupOriginConfiguration(ctx, &driver.UpdateGroupOriginInput{ + Domain: dom, Pattern: "/npm/*", Restrictions: map[string]string{driver.RestrictionInternalUpstream: driver.ModeAllow}, + }) + requireNoError(t, err) + + list, _, err = m.ListAllowedRepositoriesForGroup(ctx, dom, "/npm/*", driver.RestrictionInternalUpstream, driver.Page{}) + requireNoError(t, err) + + if len(list) != 0 { + t.Fatalf("list after switching mode = %v", list) + } +} + +func TestGroupOriginUpdateErrorsChangeNothing(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo-a") + ctx := context.Background() + dom := driver.DomainRef{Domain: "dom"} + + createGroup(t, m, "/npm/*") + + bad := []driver.UpdateGroupOriginInput{ + {Restrictions: map[string]string{"NOPE": driver.ModeAllow}}, + {Restrictions: map[string]string{driver.RestrictionPublish: "MAYBE"}}, + {AddAllowed: []driver.AllowedRepository{{RepositoryName: "repo-a", OriginRestrictionType: "NOPE"}}}, + {AddAllowed: []driver.AllowedRepository{{RepositoryName: "repo-a", OriginRestrictionType: driver.RestrictionPublish}}}, + {Restrictions: map[string]string{driver.RestrictionPublish: driver.ModeAllowSpecificRepositories}, + AddAllowed: []driver.AllowedRepository{{RepositoryName: "ghost-repo", OriginRestrictionType: driver.RestrictionPublish}}}, + } + + wantNotFound := map[int]bool{4: true} + + for i := range bad { + bad[i].Domain, bad[i].Pattern = dom, "/npm/*" + + _, err := m.UpdatePackageGroupOriginConfiguration(ctx, &bad[i]) + if wantNotFound[i] { + requireAPIError(t, err, driver.ExNotFound) + } else { + requireAPIError(t, err, driver.ExValidation) + } + } + + g, err := m.DescribePackageGroup(ctx, dom, "/npm/*") + requireNoError(t, err) + + if g.Restrictions[driver.RestrictionPublish].Mode != driver.ModeInherit { + t.Fatalf("rejected update changed the group: %+v", g.Restrictions[driver.RestrictionPublish]) + } + + _, _, err = m.ListAllowedRepositoriesForGroup(ctx, dom, "/npm/*", "NOPE", driver.Page{}) + requireAPIError(t, err, driver.ExValidation) + + _, err = m.UpdatePackageGroupOriginConfiguration(ctx, &driver.UpdateGroupOriginInput{Domain: dom, Pattern: "/pypi/*"}) + requireAPIError(t, err, driver.ExNotFound) +} + +func TestDeleteGroupReparentsChildren(t *testing.T) { + m := seeded(t, config.NewOptions()) + ctx := context.Background() + dom := driver.DomainRef{Domain: "dom"} + + createGroup(t, m, "/npm/*") + createGroup(t, m, "/npm/space/*") + createGroup(t, m, "/npm/space/foo$") + + _, err := m.UpdatePackageGroupOriginConfiguration(ctx, &driver.UpdateGroupOriginInput{ + Domain: dom, Pattern: "/npm/*", Restrictions: map[string]string{driver.RestrictionPublish: driver.ModeBlock}, + }) + requireNoError(t, err) + + _, err = m.UpdatePackageGroupOriginConfiguration(ctx, &driver.UpdateGroupOriginInput{ + Domain: dom, Pattern: "/npm/space/*", Restrictions: map[string]string{driver.RestrictionPublish: driver.ModeAllow}, + }) + requireNoError(t, err) + + leaf, err := m.DescribePackageGroup(ctx, dom, "/npm/space/foo$") + requireNoError(t, err) + + if got := leaf.Restrictions[driver.RestrictionPublish]; got.EffectiveMode != driver.ModeAllow || got.InheritedFrom.Pattern != "/npm/space/*" { + t.Fatalf("before delete = %+v", got) + } + + _, err = m.DeletePackageGroup(ctx, dom, "/npm/space/*") + requireNoError(t, err) + + leaf, err = m.DescribePackageGroup(ctx, dom, "/npm/space/foo$") + requireNoError(t, err) + + if leaf.Parent == nil || leaf.Parent.Pattern != "/npm/*" { + t.Fatalf("leaf parent = %+v, want /npm/*", leaf.Parent) + } + + if got := leaf.Restrictions[driver.RestrictionPublish]; got.EffectiveMode != driver.ModeBlock || got.InheritedFrom.Pattern != "/npm/*" { + t.Fatalf("after delete = %+v", got) + } +} + +func TestListGroupsSubGroupsAndAssociated(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ctx := context.Background() + dom := driver.DomainRef{Domain: "dom"} + + publishAsset(t, m, pkgRef("repo", "plain"), "1", "f", []byte("p"), false) + publishAsset(t, m, pkgRef("repo", "special"), "1", "f", []byte("s"), false) + publishAsset(t, m, pkgRef("repo", "Special"), "1", "f", []byte("S"), false) + + createGroup(t, m, "/generic/*") + createGroup(t, m, "/generic/ns/*") + createGroup(t, m, "/generic/ns/special$") + createGroup(t, m, "/npm/*") + + all, next, err := m.ListPackageGroups(ctx, dom, "", driver.Page{}) + requireNoError(t, err) + + if next != "" || len(all) != 4 || all[0].Pattern != "/generic/*" || all[3].Pattern != "/npm/*" { + t.Fatalf("groups = %d next=%q", len(all), next) + } + + pre, _, err := m.ListPackageGroups(ctx, dom, "/generic/ns", driver.Page{}) + requireNoError(t, err) + + if len(pre) != 2 { + t.Fatalf("prefix list = %d", len(pre)) + } + + subs, _, err := m.ListSubPackageGroups(ctx, dom, "/generic/ns/*", driver.Page{}) + requireNoError(t, err) + + if len(subs) != 1 || subs[0].Pattern != "/generic/ns/special$" { + t.Fatalf("sub groups = %+v", subs) + } + + assoc, _, err := m.ListAssociatedPackages(ctx, dom, "/generic/ns/special$", false, driver.Page{}) + requireNoError(t, err) + + if len(assoc) != 2 || assoc[0].Package != "Special" || assoc[0].AssociationType != driver.AssociationWeak || + assoc[1].Package != "special" || assoc[1].AssociationType != driver.AssociationStrong { + t.Fatalf("associated = %+v", assoc) + } + + rest, _, err := m.ListAssociatedPackages(ctx, dom, "/generic/ns/*", false, driver.Page{}) + requireNoError(t, err) + + if len(rest) != 1 || rest[0].Package != "plain" { + t.Fatalf("associated with parent = %+v", rest) + } + + _, _, err = m.ListAssociatedPackages(ctx, dom, "/generic/other/*", false, driver.Page{}) + requireAPIError(t, err, driver.ExNotFound) + + preview, _, err := m.ListAssociatedPackages(ctx, dom, "/generic/ns/plain~", true, driver.Page{}) + requireNoError(t, err) + + if len(preview) != 1 || preview[0].Package != "plain" { + t.Fatalf("preview = %+v", preview) + } +} + +func TestGroupPagination(t *testing.T) { + m := seeded(t, config.NewOptions()) + + for i := 0; i < 5; i++ { + createGroup(t, m, fmt.Sprintf("/npm/scope%d/*", i)) + } + + var seen []string + + token := "" + + for { + groups, next, err := m.ListPackageGroups(context.Background(), driver.DomainRef{Domain: "dom"}, "", driver.Page{MaxResults: 2, NextToken: token}) + requireNoError(t, err) + + for _, g := range groups { + seen = append(seen, g.Pattern) + } + + if next == "" { + break + } + + token = next + } + + if len(seen) != 5 || seen[0] != "/npm/scope0/*" || seen[4] != "/npm/scope4/*" { + t.Fatalf("walk = %v", seen) + } +} + +func TestDeleteRepositoryDropsAllowedListEntry(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo-a", "repo-b") + ctx := context.Background() + dom := driver.DomainRef{Domain: "dom"} + + createGroup(t, m, "/npm/*") + + _, err := m.UpdatePackageGroupOriginConfiguration(ctx, &driver.UpdateGroupOriginInput{ + Domain: dom, Pattern: "/npm/*", Restrictions: map[string]string{driver.RestrictionPublish: driver.ModeAllowSpecificRepositories}, + AddAllowed: []driver.AllowedRepository{ + {RepositoryName: "repo-a", OriginRestrictionType: driver.RestrictionPublish}, + {RepositoryName: "repo-b", OriginRestrictionType: driver.RestrictionPublish}, + }, + }) + requireNoError(t, err) + + _, err = m.DeleteRepository(ctx, "dom", "", "repo-a") + requireNoError(t, err) + + list, _, err := m.ListAllowedRepositoriesForGroup(ctx, dom, "/npm/*", driver.RestrictionPublish, driver.Page{}) + requireNoError(t, err) + + if len(list) != 1 || list[0] != "repo-b" { + t.Fatalf("allowed list = %v", list) + } +} + +func TestTagsOnPackageGroups(t *testing.T) { + m := seeded(t, config.NewOptions()) + ctx := context.Background() + + g := createGroup(t, m, "/npm/space/*") + + requireNoError(t, m.TagResource(ctx, g.Arn, map[string]string{"team": "a"})) + + tags, err := m.ListTagsForResource(ctx, g.Arn) + requireNoError(t, err) + + if tags["team"] != "a" { + t.Fatalf("tags = %v", tags) + } + + requireNoError(t, m.UntagResource(ctx, g.Arn, []string{"team"})) + + err = m.TagResource(ctx, "arn:aws:codeartifact:us-east-1:123456789012:package-group/dom/npm/ghost/*", map[string]string{"a": "b"}) + requireAPIError(t, err, driver.ExNotFound) + + err = m.TagResource(ctx, "arn:aws:codeartifact:us-east-1:123456789012:package-group/nodomainslash", map[string]string{"a": "b"}) + requireAPIError(t, err, driver.ExValidation) +} diff --git a/providers/aws/codeartifact/package_version.go b/providers/aws/codeartifact/package_version.go new file mode 100644 index 000000000..f2acb14cd --- /dev/null +++ b/providers/aws/codeartifact/package_version.go @@ -0,0 +1,326 @@ +package codeartifact + +import ( + "context" + "crypto/md5" //nolint:gosec // MD5 is one of the four asset hashes CodeArtifact reports, not a security control. + "crypto/sha1" //nolint:gosec // SHA-1 is one of the four asset hashes CodeArtifact reports, not a security control. + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + "sort" + "strings" + + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +const revisionLen = 32 + +// computeRevision derives a version's revision from its assets: it is stable +// across reads and changes whenever an asset is added, removed or replaced. +func computeRevision(assets map[string]*assetRecord) string { + names := make([]string, 0, len(assets)) + for n := range assets { + names = append(names, n) + } + + sort.Strings(names) + + h := sha256.New() + + for _, n := range names { + h.Write([]byte(n)) + h.Write([]byte{0}) + h.Write([]byte(assets[n].Hashes[driver.HashSHA256])) + h.Write([]byte{'\n'}) + } + + return strings.ToUpper(hex.EncodeToString(h.Sum(nil)))[:revisionLen] +} + +// assetHashes computes the four hashes CodeArtifact reports for an asset. +func assetHashes(content []byte) map[string]string { + md5sum := md5.Sum(content) //nolint:gosec // see import + sha1sum := sha1.Sum(content) //nolint:gosec // see import + sha256sum := sha256.Sum256(content) + sha512sum := sha512.Sum512(content) + + return map[string]string{ + driver.HashMD5: hex.EncodeToString(md5sum[:]), + driver.HashSHA1: hex.EncodeToString(sha1sum[:]), + driver.HashSHA256: hex.EncodeToString(sha256sum[:]), + driver.HashSHA512: hex.EncodeToString(sha512sum[:]), + } +} + +func (a *assetRecord) toDriver() driver.Asset { + return driver.Asset{Name: a.Name, Size: a.Size, Hashes: copyTags(a.Hashes)} +} + +func (v *versionRecord) identity(p *packageRecord) driver.VersionIdentity { + return driver.VersionIdentity{Format: p.Format, Namespace: p.Namespace, Package: p.Name, Version: v.Version, Revision: v.Revision} +} + +// sortedAssets returns a version's assets ordered by name. +func sortedAssets(v *versionRecord) []*assetRecord { + out := make([]*assetRecord, 0, len(v.Assets)) + for _, a := range v.Assets { + out = append(out, a) + } + + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + + return out +} + +// domainAssetBytesLocked sums the size of the distinct assets stored in a domain: +// an asset is stored once per domain however many versions and repositories +// hold it, so identical SHA-256 digests count once. The caller holds mu. +func (m *Mock) domainAssetBytesLocked(domain string) int64 { + seen := map[string]bool{} + + var total int64 + + for _, p := range m.packages.Filter(func(k string, _ *packageRecord) bool { return strings.HasPrefix(k, domain+"/") }) { + for _, v := range p.Versions { + for _, a := range v.Assets { + if sum := a.Hashes[driver.HashSHA256]; !seen[sum] { + seen[sum] = true + total += a.Size + } + } + } + } + + return total +} + +// versionLocked resolves a package version. The caller holds mu. +func (m *Mock) versionLocked(ref *driver.PackageRef, version string) (*packageRecord, *versionRecord, error) { + if err := validateVersion(version); err != nil { + return nil, nil, err + } + + _, p, err := m.packageLocked(ref) + if err != nil { + return nil, nil, err + } + + v, ok := p.Versions[version] + if !ok { + return nil, nil, notFound(driver.ResourceTypePackageVersion, version, + "Version %s of package %s not found", version, ref.Package) + } + + return p, v, nil +} + +func validVersionStatus(s string) bool { + switch s { + case driver.VersionStatusPublished, driver.VersionStatusUnfinished, driver.VersionStatusUnlisted, + driver.VersionStatusArchived, driver.VersionStatusDisposed, driver.VersionStatusDeleted: + return true + default: + return false + } +} + +// ListPackageVersions returns a page of a package's versions, newest published +// first, optionally filtered by status and origin type. +func (m *Mock) ListPackageVersions(_ context.Context, in *driver.ListPackageVersionsInput) (*driver.PackageVersionList, error) { + if err := validateVersionFilters(in); err != nil { + return nil, err + } + + m.mu.RLock() + defer m.mu.RUnlock() + + _, p, err := m.packageLocked(&in.Ref) + if err != nil { + return nil, err + } + + all := sortedVersions(p) + sort.SliceStable(all, func(i, j int) bool { return all[i].PublishedTime.After(all[j].PublishedTime) }) + + out := &driver.PackageVersionList{ + Format: p.Format, Namespace: p.Namespace, Package: p.Name, DefaultDisplayVersion: defaultDisplayVersion(all), + } + + filtered := make([]driver.PackageVersionSummary, 0, len(all)) + + for _, v := range all { + if (in.Status == "" || v.Status == in.Status) && (in.OriginType == "" || v.Origin.OriginType == in.OriginType) { + filtered = append(filtered, driver.PackageVersionSummary{ + Version: v.Version, Revision: v.Revision, Status: v.Status, Origin: v.Origin, + }) + } + } + + out.Versions, out.NextToken, err = pageOf(filtered, in.Page) + if err != nil { + return nil, err + } + + return out, nil +} + +// defaultDisplayVersion is the newest Published version of a list sorted newest +// first, or "" when none is Published. +func defaultDisplayVersion(sorted []*versionRecord) string { + for _, v := range sorted { + if v.Status == driver.VersionStatusPublished { + return v.Version + } + } + + return "" +} + +func validateVersionFilters(in *driver.ListPackageVersionsInput) error { + if in.Status != "" && !validVersionStatus(in.Status) { + return validation("status %q is not a package version status", in.Status) + } + + if in.SortBy != "" && in.SortBy != driver.SortByPublishedTime { + return validation("sortBy %q must be %s", in.SortBy, driver.SortByPublishedTime) + } + + switch in.OriginType { + case "", driver.OriginTypeInternal, driver.OriginTypeExternal, driver.OriginTypeUnknown: + return nil + default: + return validation("originType %q must be INTERNAL, EXTERNAL or UNKNOWN", in.OriginType) + } +} + +// DescribePackageVersion returns the description of one package version. +// +//nolint:gocritic // ref is passed by value to implement driver.PackageVersions. +func (m *Mock) DescribePackageVersion(_ context.Context, ref driver.PackageRef, + version string) (*driver.PackageVersion, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + p, v, err := m.versionLocked(&ref, version) + if err != nil { + return nil, err + } + + return &driver.PackageVersion{ + Format: p.Format, Namespace: p.Namespace, PackageName: p.Name, DisplayName: p.Name, Version: v.Version, + Revision: v.Revision, Status: v.Status, PublishedTime: v.PublishedTime, Origin: v.Origin, + }, nil +} + +// ListPackageVersionAssets returns a page of a version's assets ordered by name. +// +//nolint:gocritic // ref is passed by value to implement driver.PackageVersions. +func (m *Mock) ListPackageVersionAssets(_ context.Context, ref driver.PackageRef, version string, + page driver.Page) (*driver.AssetList, string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + p, v, err := m.versionLocked(&ref, version) + if err != nil { + return nil, "", err + } + + assets := sortedAssets(v) + out := &driver.AssetList{VersionIdentity: v.identity(p)} + + window, next, err := pageOf(assets, page) + if err != nil { + return nil, "", err + } + + out.Assets = make([]driver.Asset, 0, len(window)) + for _, a := range window { + out.Assets = append(out.Assets, a.toDriver()) + } + + return out, next, nil +} + +// GetPackageVersionAsset returns an asset's bytes. An archived version cannot be +// downloaded (ConflictException); a disposed version or unknown asset is a +// ResourceNotFoundException, as is a revision other than the current one. +// +//nolint:gocritic // ref is passed by value to implement driver.PackageVersions. +func (m *Mock) GetPackageVersionAsset(_ context.Context, ref driver.PackageRef, + version, asset, revision string) (*driver.AssetContent, error) { + if err := validateAssetRequest(asset, revision); err != nil { + return nil, err + } + + m.mu.RLock() + defer m.mu.RUnlock() + + _, v, err := m.versionLocked(&ref, version) + if err != nil { + return nil, err + } + + if revision != "" && revision != v.Revision { + return nil, notFound(driver.ResourceTypePackageVersion, version, "Version %s has no revision %s", version, revision) + } + + if v.Status == driver.VersionStatusArchived { + return nil, conflict(driver.ResourceTypePackageVersion, version, "Version %s is archived and cannot be downloaded", version) + } + + a, ok := v.Assets[asset] + if !ok || !a.Stored { + return nil, notFound(driver.ResourceTypeAsset, asset, "Asset %s not found in version %s", asset, version) + } + + return &driver.AssetContent{ + Asset: a.toDriver(), Content: append([]byte(nil), a.Content...), Version: v.Version, Revision: v.Revision, + }, nil +} + +func validateAssetRequest(asset, revision string) error { + if !validAssetName(asset) { + return validation("asset name %q is invalid", asset) + } + + if revision != "" && (len(revision) > revisionMaxLen || hasSpace(revision)) { + return validation("revision must be 1-%d characters without whitespace", revisionMaxLen) + } + + return nil +} + +// revisionMaxLen is the longest revision string the API accepts. +const revisionMaxLen = 50 + +// GetPackageVersionReadme returns the identity of a version with no readme: a +// generic package version carries no readme metadata. +// +//nolint:gocritic // ref is passed by value to implement driver.PackageVersions. +func (m *Mock) GetPackageVersionReadme(_ context.Context, ref driver.PackageRef, + version string) (*driver.VersionIdentity, error) { + return m.versionIdentity(&ref, version) +} + +// ListPackageVersionDependencies returns the identity of a version with no +// dependencies: a generic package version carries no dependency metadata. +// +//nolint:gocritic // ref is passed by value to implement driver.PackageVersions. +func (m *Mock) ListPackageVersionDependencies(_ context.Context, ref driver.PackageRef, + version string) (*driver.VersionIdentity, error) { + return m.versionIdentity(&ref, version) +} + +func (m *Mock) versionIdentity(ref *driver.PackageRef, version string) (*driver.VersionIdentity, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + p, v, err := m.versionLocked(ref, version) + if err != nil { + return nil, err + } + + id := v.identity(p) + + return &id, nil +} diff --git a/providers/aws/codeartifact/package_version_test.go b/providers/aws/codeartifact/package_version_test.go new file mode 100644 index 000000000..8d3ecc0a4 --- /dev/null +++ b/providers/aws/codeartifact/package_version_test.go @@ -0,0 +1,699 @@ +package codeartifact_test + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "testing" + "time" + + "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/providers/aws/codeartifact" + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +func sha(data []byte) string { + sum := sha256.Sum256(data) + + return hex.EncodeToString(sum[:]) +} + +func pkgRef(repo, name string) driver.PackageRef { + return driver.PackageRef{Domain: "dom", Repository: repo, Format: driver.FormatGeneric, Namespace: "ns", Package: name} +} + +// seeded returns a mock with domain "dom" and the given repositories. +func seeded(t *testing.T, opts *config.Options, repos ...string) *codeartifact.Mock { + t.Helper() + + m := newMockWithOptions(opts) + mustDomain(t, m, "dom") + + for _, r := range repos { + mustRepo(t, m, "dom", r) + } + + return m +} + +func publishAsset(t *testing.T, m *codeartifact.Mock, ref driver.PackageRef, version, asset string, data []byte, unfinished bool) *driver.PublishResult { + t.Helper() + + res, err := m.PublishPackageVersion(context.Background(), &driver.PublishInput{ + Ref: ref, Version: version, AssetName: asset, SHA256: sha(data), Content: data, Unfinished: unfinished, + }) + requireNoError(t, err) + + return res +} + +func TestPublishRoundTripBytes(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ref := pkgRef("repo", "pkg") + data := []byte("hello codeartifact") + + res := publishAsset(t, m, ref, "1.0.0", "file.txt", data, false) + + if res.Status != driver.VersionStatusPublished || res.Revision == "" || res.Asset.Size != int64(len(data)) { + t.Fatalf("publish result = %+v", res) + } + + list, next, err := m.ListPackageVersionAssets(context.Background(), ref, "1.0.0", driver.Page{}) + requireNoError(t, err) + + if next != "" || len(list.Assets) != 1 || list.Assets[0].Name != "file.txt" { + t.Fatalf("assets = %+v next=%q", list, next) + } + + h := list.Assets[0].Hashes + if h[driver.HashSHA256] != sha(data) || len(h[driver.HashMD5]) != 32 || len(h[driver.HashSHA1]) != 40 || len(h[driver.HashSHA512]) != 128 { + t.Fatalf("hashes = %+v", h) + } + + got, err := m.GetPackageVersionAsset(context.Background(), ref, "1.0.0", "file.txt", "") + requireNoError(t, err) + + if !bytes.Equal(got.Content, data) || got.Version != "1.0.0" || got.Revision != res.Revision { + t.Fatalf("asset = %+v", got) + } + + d, err := m.DescribeDomain(context.Background(), "dom", "") + requireNoError(t, err) + + if d.AssetSizeBytes != int64(len(data)) { + t.Fatalf("assetSizeBytes = %d, want %d", d.AssetSizeBytes, len(data)) + } +} + +func TestPublishHashMismatch(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + + _, err := m.PublishPackageVersion(context.Background(), &driver.PublishInput{ + Ref: pkgRef("repo", "pkg"), Version: "1", AssetName: "a", SHA256: sha([]byte("other")), Content: []byte("data"), + }) + requireAPIError(t, err, driver.ExValidation) + + _, _, err = m.ListPackages(context.Background(), &driver.ListPackagesInput{Repository: driver.RepositoryRef{Domain: "dom", Repository: "repo"}}) + requireNoError(t, err) + + pkgs, _, _ := m.ListPackages(context.Background(), &driver.ListPackagesInput{Repository: driver.RepositoryRef{Domain: "dom", Repository: "repo"}}) + if len(pkgs) != 0 { + t.Fatalf("rejected publish stored %d packages", len(pkgs)) + } +} + +func TestPublishNonGenericRejected(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + data := []byte("x") + + for _, f := range []string{"npm", "pypi", "maven", "nuget", "ruby", "swift", "cargo"} { + ref := pkgRef("repo", "pkg") + ref.Format = f + + _, err := m.PublishPackageVersion(context.Background(), &driver.PublishInput{ + Ref: ref, Version: "1", AssetName: "a", SHA256: sha(data), Content: data, + }) + requireAPIError(t, err, driver.ExValidation) + } + + ref := pkgRef("repo", "pkg") + ref.Namespace = "" + + _, err := m.PublishPackageVersion(context.Background(), &driver.PublishInput{ + Ref: ref, Version: "1", AssetName: "a", SHA256: sha(data), Content: data, + }) + requireAPIError(t, err, driver.ExValidation) +} + +func TestPublishFieldValidation(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + data := []byte("x") + base := driver.PublishInput{Ref: pkgRef("repo", "pkg"), Version: "1", AssetName: "a", SHA256: sha(data), Content: data} + + bad := []driver.PublishInput{base, base, base, base} + bad[0].Version = "has space" + bad[1].AssetName = "" + bad[2].SHA256 = "ABC" + bad[3].Ref.Package = "a/b" + + for i := range bad { + _, err := m.PublishPackageVersion(context.Background(), &bad[i]) + requireAPIError(t, err, driver.ExValidation) + } +} + +func TestPublishUnfinishedThenPublished(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ref := pkgRef("repo", "pkg") + + first := publishAsset(t, m, ref, "1", "a.txt", []byte("aaa"), true) + if first.Status != driver.VersionStatusUnfinished { + t.Fatalf("status = %q, want Unfinished", first.Status) + } + + // Identical asset again on an Unfinished version is a no-op. + again := publishAsset(t, m, ref, "1", "a.txt", []byte("aaa"), true) + if again.Revision != first.Revision { + t.Fatal("idempotent re-publish changed the revision") + } + + _, err := m.PublishPackageVersion(context.Background(), &driver.PublishInput{ + Ref: ref, Version: "1", AssetName: "a.txt", SHA256: sha([]byte("bbb")), Content: []byte("bbb"), Unfinished: true, + }) + requireAPIError(t, err, driver.ExConflict) + + last := publishAsset(t, m, ref, "1", "b.txt", []byte("bbb"), false) + if last.Status != driver.VersionStatusPublished || last.Revision == first.Revision { + t.Fatalf("final = %+v", last) + } + + list, _, err := m.ListPackageVersionAssets(context.Background(), ref, "1", driver.Page{}) + requireNoError(t, err) + + if len(list.Assets) != 2 { + t.Fatalf("assets = %d, want 2", len(list.Assets)) + } + + _, err = m.PublishPackageVersion(context.Background(), &driver.PublishInput{ + Ref: ref, Version: "1", AssetName: "c.txt", SHA256: sha([]byte("c")), Content: []byte("c"), + }) + requireAPIError(t, err, driver.ExConflict) +} + +func TestPublishLimits(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ref := pkgRef("repo", "pkg") + + const maxAssets = 350 + + for i := 0; i < maxAssets; i++ { + publishAsset(t, m, ref, "1", fmt.Sprintf("asset-%03d", i), []byte{byte(i), byte(i >> 8)}, true) + } + + _, err := m.PublishPackageVersion(context.Background(), &driver.PublishInput{ + Ref: ref, Version: "1", AssetName: "one-too-many", SHA256: sha([]byte("z")), Content: []byte("z"), Unfinished: true, + }) + requireAPIError(t, err, driver.ExServiceQuota) + + huge := make([]byte, 128<<20+1) + + _, err = m.PublishPackageVersion(context.Background(), &driver.PublishInput{ + Ref: pkgRef("repo", "big"), Version: "1", AssetName: "big.bin", SHA256: sha(huge), Content: huge, + }) + requireAPIError(t, err, driver.ExServiceQuota) +} + +func TestDomainAssetSizeDedupAndRevision(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ref := pkgRef("repo", "pkg") + data := []byte("same bytes") + + v1 := publishAsset(t, m, ref, "1", "f", data, false) + v2 := publishAsset(t, m, ref, "2", "f", data, false) + other := publishAsset(t, m, ref, "3", "f", []byte("different"), false) + + d, err := m.DescribeDomain(context.Background(), "dom", "") + requireNoError(t, err) + + if want := int64(len(data) + len("different")); d.AssetSizeBytes != want { + t.Fatalf("assetSizeBytes = %d, want %d (identical bytes count once)", d.AssetSizeBytes, want) + } + + if v1.Revision != v2.Revision || v1.Revision == other.Revision { + t.Fatalf("revisions: %s %s %s", v1.Revision, v2.Revision, other.Revision) + } + + if len(v1.Revision) != 32 { + t.Fatalf("revision length = %d", len(v1.Revision)) + } +} + +func TestPackageReadsStableAndFiltered(t *testing.T) { + clk := config.NewFakeClock(time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)) + m := seeded(t, config.NewOptions(config.WithClock(clk)), "repo") + ctx := context.Background() + + publishAsset(t, m, pkgRef("repo", "zeta"), "1", "f", []byte("z"), false) + clk.Advance(time.Hour) + publishAsset(t, m, pkgRef("repo", "alpha"), "1", "f", []byte("a"), false) + clk.Advance(time.Hour) + publishAsset(t, m, pkgRef("repo", "alpha"), "2", "f", []byte("a2"), false) + + repo := driver.RepositoryRef{Domain: "dom", Repository: "repo"} + + pkgs, _, err := m.ListPackages(ctx, &driver.ListPackagesInput{Repository: repo}) + requireNoError(t, err) + + if len(pkgs) != 2 || pkgs[0].Name != "alpha" || pkgs[1].Name != "zeta" { + t.Fatalf("packages = %+v", pkgs) + } + + prefixed, _, err := m.ListPackages(ctx, &driver.ListPackagesInput{Repository: repo, Prefix: "ze", Format: "generic", Namespace: "n"}) + requireNoError(t, err) + + if len(prefixed) != 1 || prefixed[0].Name != "zeta" { + t.Fatalf("filtered = %+v", prefixed) + } + + none, _, err := m.ListPackages(ctx, &driver.ListPackagesInput{Repository: repo, Publish: driver.OriginBlock}) + requireNoError(t, err) + + if len(none) != 0 { + t.Fatalf("publish=BLOCK filter matched %d packages", len(none)) + } + + versions, err := m.ListPackageVersions(ctx, &driver.ListPackageVersionsInput{Ref: pkgRef("repo", "alpha")}) + requireNoError(t, err) + + if len(versions.Versions) != 2 || versions.Versions[0].Version != "2" || versions.DefaultDisplayVersion != "2" { + t.Fatalf("versions = %+v", versions) + } + + d1, err := m.DescribePackageVersion(ctx, pkgRef("repo", "alpha"), "1") + requireNoError(t, err) + + clk.Advance(48 * time.Hour) + + d2, err := m.DescribePackageVersion(ctx, pkgRef("repo", "alpha"), "1") + requireNoError(t, err) + + if !d1.PublishedTime.Equal(d2.PublishedTime) || d1.Revision != d2.Revision || d1.Origin.RepositoryName != "repo" { + t.Fatalf("describe drifted: %+v vs %+v", d1, d2) + } +} + +func TestPackageReadErrors(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ctx := context.Background() + publishAsset(t, m, pkgRef("repo", "pkg"), "1", "f", []byte("x"), false) + + _, err := m.DescribePackage(ctx, pkgRef("repo", "ghost")) + requireAPIError(t, err, driver.ExNotFound) + + _, err = m.DescribePackageVersion(ctx, pkgRef("repo", "pkg"), "9") + requireAPIError(t, err, driver.ExNotFound) + + _, err = m.GetPackageVersionAsset(ctx, pkgRef("repo", "pkg"), "1", "missing", "") + requireAPIError(t, err, driver.ExNotFound) + + _, err = m.GetPackageVersionAsset(ctx, pkgRef("repo", "pkg"), "1", "f", "WRONGREVISION") + requireAPIError(t, err, driver.ExNotFound) + + bad := pkgRef("repo", "pkg") + bad.Format = "docker" + + _, err = m.DescribePackage(ctx, bad) + requireAPIError(t, err, driver.ExValidation) + + _, err = m.ListPackageVersions(ctx, &driver.ListPackageVersionsInput{Ref: pkgRef("repo", "pkg"), Status: "Nope"}) + requireAPIError(t, err, driver.ExValidation) + + _, _, err = m.ListPackages(ctx, &driver.ListPackagesInput{Repository: driver.RepositoryRef{Domain: "dom", Repository: "ghost"}}) + requireAPIError(t, err, driver.ExNotFound) +} + +func TestReadmeAndDependenciesGeneric(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ref := pkgRef("repo", "pkg") + res := publishAsset(t, m, ref, "1", "f", []byte("x"), false) + + for name, call := range map[string]func() (*driver.VersionIdentity, error){ + "readme": func() (*driver.VersionIdentity, error) { + return m.GetPackageVersionReadme(context.Background(), ref, "1") + }, + "dependencies": func() (*driver.VersionIdentity, error) { + return m.ListPackageVersionDependencies(context.Background(), ref, "1") + }, + } { + id, err := call() + requireNoError(t, err) + + if id.Format != "generic" || id.Namespace != "ns" || id.Package != "pkg" || id.Version != "1" || id.Revision != res.Revision { + t.Fatalf("%s identity = %+v", name, id) + } + } + + _, err := m.GetPackageVersionReadme(context.Background(), ref, "9") + requireAPIError(t, err, driver.ExNotFound) +} + +func TestUpdateStatusMatrix(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ref := pkgRef("repo", "pkg") + ctx := context.Background() + + publishAsset(t, m, ref, "1", "f", []byte("1"), false) + publishAsset(t, m, ref, "2", "f", []byte("2"), true) + + update := func(target string, versions ...string) *driver.VersionsResult { + res, err := m.UpdatePackageVersionsStatus(ctx, &driver.UpdateStatusInput{ + VersionsInput: driver.VersionsInput{Ref: ref, Versions: versions}, TargetStatus: target, + }) + requireNoError(t, err) + + return res + } + + for _, target := range []string{"Archived", "Unlisted", "Published"} { + res := update(target, "1") + if res.Successful["1"].Status != target || len(res.Failed) != 0 { + t.Fatalf("to %s: %+v", target, res) + } + } + + if res := update("Published", "1"); res.Failed["1"].ErrorCode != driver.VersionErrSkipped { + t.Fatalf("same status = %+v", res) + } + + if res := update("Published", "2"); res.Successful["2"].Status != driver.VersionStatusPublished { + t.Fatalf("Unfinished -> Published = %+v", res) + } + + if res := update("Archived", "ghost"); res.Failed["ghost"].ErrorCode != driver.VersionErrNotFound { + t.Fatalf("missing = %+v", res) + } + + for _, bad := range []string{"Unfinished", "Disposed", "Deleted", "Bogus"} { + _, err := m.UpdatePackageVersionsStatus(ctx, &driver.UpdateStatusInput{ + VersionsInput: driver.VersionsInput{Ref: ref, Versions: []string{"1"}}, TargetStatus: bad, + }) + requireAPIError(t, err, driver.ExValidation) + } + + _, err := m.DisposePackageVersions(ctx, &driver.VersionsInput{Ref: ref, Versions: []string{"1"}}) + requireNoError(t, err) + + if res := update("Published", "1"); res.Failed["1"].ErrorCode != driver.VersionErrNotAllowed { + t.Fatalf("disposed -> Published = %+v", res) + } +} + +func TestUpdateStatusExpectedGuards(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ref := pkgRef("repo", "pkg") + ctx := context.Background() + res := publishAsset(t, m, ref, "1", "f", []byte("1"), false) + + out, err := m.UpdatePackageVersionsStatus(ctx, &driver.UpdateStatusInput{ + VersionsInput: driver.VersionsInput{Ref: ref, Versions: []string{"1"}, ExpectedStatus: "Archived"}, TargetStatus: "Unlisted", + }) + requireNoError(t, err) + + if out.Failed["1"].ErrorCode != driver.VersionErrMismatchedStatus { + t.Fatalf("expectedStatus mismatch = %+v", out) + } + + out, err = m.UpdatePackageVersionsStatus(ctx, &driver.UpdateStatusInput{ + VersionsInput: driver.VersionsInput{Ref: ref, Versions: []string{"1"}, VersionRevisions: map[string]string{"1": "OLDREV"}}, TargetStatus: "Unlisted", + }) + requireNoError(t, err) + + if out.Failed["1"].ErrorCode != driver.VersionErrMismatchedRev { + t.Fatalf("revision mismatch = %+v", out) + } + + out, err = m.UpdatePackageVersionsStatus(ctx, &driver.UpdateStatusInput{ + VersionsInput: driver.VersionsInput{Ref: ref, Versions: []string{"1"}, ExpectedStatus: "Published", VersionRevisions: map[string]string{"1": res.Revision}}, + TargetStatus: "Unlisted", + }) + requireNoError(t, err) + + if got := out.Successful["1"]; got.Status != "Unlisted" || got.Revision != res.Revision { + t.Fatalf("success = %+v", out) + } +} + +func TestDisposeDeletesAssets(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ref := pkgRef("repo", "pkg") + ctx := context.Background() + before := publishAsset(t, m, ref, "1", "f", []byte("payload"), false) + + res, err := m.DisposePackageVersions(ctx, &driver.VersionsInput{Ref: ref, Versions: []string{"1", "2"}}) + requireNoError(t, err) + + if res.Successful["1"].Status != driver.VersionStatusDisposed || res.Failed["2"].ErrorCode != driver.VersionErrNotFound { + t.Fatalf("dispose = %+v", res) + } + + if res.Successful["1"].Revision == before.Revision { + t.Fatal("revision must change when assets are removed") + } + + v, err := m.DescribePackageVersion(ctx, ref, "1") + requireNoError(t, err) + + if v.Status != driver.VersionStatusDisposed { + t.Fatalf("status = %q", v.Status) + } + + _, err = m.GetPackageVersionAsset(ctx, ref, "1", "f", "") + requireAPIError(t, err, driver.ExNotFound) + + d, err := m.DescribeDomain(ctx, "dom", "") + requireNoError(t, err) + + if d.AssetSizeBytes != 0 { + t.Fatalf("assetSizeBytes after dispose = %d", d.AssetSizeBytes) + } + + again, err := m.DisposePackageVersions(ctx, &driver.VersionsInput{Ref: ref, Versions: []string{"1"}}) + requireNoError(t, err) + + if again.Failed["1"].ErrorCode != driver.VersionErrSkipped { + t.Fatalf("second dispose = %+v", again) + } +} + +func TestArchivedVersionCannotBeDownloaded(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ref := pkgRef("repo", "pkg") + publishAsset(t, m, ref, "1", "f", []byte("x"), false) + + _, err := m.UpdatePackageVersionsStatus(context.Background(), &driver.UpdateStatusInput{ + VersionsInput: driver.VersionsInput{Ref: ref, Versions: []string{"1"}}, TargetStatus: "Archived", + }) + requireNoError(t, err) + + _, err = m.GetPackageVersionAsset(context.Background(), ref, "1", "f", "") + requireAPIError(t, err, driver.ExConflict) +} + +func TestDeleteVersionsAndPackage(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ref := pkgRef("repo", "pkg") + ctx := context.Background() + + publishAsset(t, m, ref, "1", "f", []byte("1"), false) + publishAsset(t, m, ref, "2", "f", []byte("2"), false) + + res, err := m.DeletePackageVersions(ctx, &driver.VersionsInput{Ref: ref, Versions: []string{"1", "9"}, ExpectedStatus: "Published"}) + requireNoError(t, err) + + if res.Successful["1"].Status != driver.VersionStatusDeleted || res.Failed["9"].ErrorCode != driver.VersionErrNotFound { + t.Fatalf("delete = %+v", res) + } + + mis, err := m.DeletePackageVersions(ctx, &driver.VersionsInput{Ref: ref, Versions: []string{"2"}, ExpectedStatus: "Archived"}) + requireNoError(t, err) + + if mis.Failed["2"].ErrorCode != driver.VersionErrMismatchedStatus { + t.Fatalf("expectedStatus = %+v", mis) + } + + _, err = m.DeletePackageVersions(ctx, &driver.VersionsInput{Ref: ref, Versions: []string{"2"}}) + requireNoError(t, err) + + // The last version is gone and no origin config was set: the package is gone. + _, err = m.DescribePackage(ctx, ref) + requireAPIError(t, err, driver.ExNotFound) + + publishAsset(t, m, ref, "1", "f", []byte("1"), false) + + del, err := m.DeletePackage(ctx, ref) + requireNoError(t, err) + + if del.Name != "pkg" || del.Format != "generic" { + t.Fatalf("deleted = %+v", del) + } + + _, err = m.DeletePackage(ctx, ref) + requireAPIError(t, err, driver.ExNotFound) + + _, err = m.DeletePackageVersions(ctx, &driver.VersionsInput{Ref: ref, Versions: make([]string, 101)}) + requireAPIError(t, err, driver.ExValidation) +} + +func TestCopyPackageVersions(t *testing.T) { + m := seeded(t, config.NewOptions(), "src", "dst") + ctx := context.Background() + ref := pkgRef("src", "pkg") + one := publishAsset(t, m, ref, "1", "f", []byte("one"), false) + publishAsset(t, m, ref, "2", "f", []byte("two"), false) + + copyIn := func(in driver.CopyInput) *driver.VersionsResult { + in.Ref = ref + in.SourceRepository, in.DestinationRepository = "src", "dst" + + res, err := m.CopyPackageVersions(ctx, &in) + requireNoError(t, err) + + return res + } + + res := copyIn(driver.CopyInput{Versions: []string{"1", "9"}}) + if res.Successful["1"].Revision != one.Revision || res.Failed["9"].ErrorCode != driver.VersionErrNotFound { + t.Fatalf("copy = %+v", res) + } + + got, err := m.GetPackageVersionAsset(ctx, pkgRef("dst", "pkg"), "1", "f", "") + requireNoError(t, err) + + if string(got.Content) != "one" { + t.Fatalf("copied bytes = %q", got.Content) + } + + if again := copyIn(driver.CopyInput{Versions: []string{"1"}}); again.Failed["1"].ErrorCode != driver.VersionErrAlreadyExists { + t.Fatalf("re-copy = %+v", again) + } + + if over := copyIn(driver.CopyInput{Versions: []string{"1"}, AllowOverwrite: true}); over.Successful["1"].Revision != one.Revision { + t.Fatalf("overwrite = %+v", over) + } + + if pinned := copyIn(driver.CopyInput{VersionRevisions: map[string]string{"2": "WRONG"}}); pinned.Failed["2"].ErrorCode != driver.VersionErrMismatchedRev { + t.Fatalf("pinned = %+v", pinned) + } + + bad := []driver.CopyInput{ + {Ref: ref, SourceRepository: "src", DestinationRepository: "src", Versions: []string{"1"}}, + {Ref: ref, SourceRepository: "src", DestinationRepository: "dst"}, + {Ref: ref, SourceRepository: "src", DestinationRepository: "dst", Versions: []string{"1"}, VersionRevisions: map[string]string{"1": "R"}}, + {Ref: ref, SourceRepository: "src", DestinationRepository: "dst", Versions: make([]string, 101)}, + } + + for i := range bad { + _, err = m.CopyPackageVersions(ctx, &bad[i]) + requireAPIError(t, err, driver.ExValidation) + } + + _, err = m.CopyPackageVersions(ctx, &driver.CopyInput{Ref: ref, SourceRepository: "src", DestinationRepository: "ghost", Versions: []string{"1"}}) + requireAPIError(t, err, driver.ExNotFound) +} + +func TestCopyDisposedNotAllowed(t *testing.T) { + m := seeded(t, config.NewOptions(), "src", "dst") + ref := pkgRef("src", "pkg") + publishAsset(t, m, ref, "1", "f", []byte("one"), false) + + _, err := m.DisposePackageVersions(context.Background(), &driver.VersionsInput{Ref: ref, Versions: []string{"1"}}) + requireNoError(t, err) + + res, err := m.CopyPackageVersions(context.Background(), &driver.CopyInput{ + Ref: ref, SourceRepository: "src", DestinationRepository: "dst", Versions: []string{"1"}, + }) + requireNoError(t, err) + + if res.Failed["1"].ErrorCode != driver.VersionErrNotAllowed { + t.Fatalf("copy of disposed = %+v", res) + } +} + +func TestCopyIncludeFromUpstream(t *testing.T) { + m := seeded(t, config.NewOptions(), "base") + requireNoError(t, createRepoWithUpstreams(m, "src", "base")) + mustRepo(t, m, "dom", "dst") + ctx := context.Background() + + publishAsset(t, m, pkgRef("base", "pkg"), "1", "f", []byte("from base"), false) + + in := driver.CopyInput{Ref: pkgRef("src", "pkg"), SourceRepository: "src", DestinationRepository: "dst", Versions: []string{"1"}} + + res, err := m.CopyPackageVersions(ctx, &in) + requireNoError(t, err) + + if res.Failed["1"].ErrorCode != driver.VersionErrNotFound { + t.Fatalf("without includeFromUpstream = %+v", res) + } + + in.IncludeFromUpstream = true + + res, err = m.CopyPackageVersions(ctx, &in) + requireNoError(t, err) + + if _, ok := res.Successful["1"]; !ok { + t.Fatalf("with includeFromUpstream = %+v", res) + } + + got, err := m.GetPackageVersionAsset(ctx, pkgRef("dst", "pkg"), "1", "f", "") + requireNoError(t, err) + + if string(got.Content) != "from base" { + t.Fatalf("copied bytes = %q", got.Content) + } +} + +func TestPublishConflictsWithUpstreamVersion(t *testing.T) { + m := seeded(t, config.NewOptions(), "base") + requireNoError(t, createRepoWithUpstreams(m, "down", "base")) + ctx := context.Background() + + publishAsset(t, m, pkgRef("base", "pkg"), "1", "f", []byte("base"), false) + publishAsset(t, m, pkgRef("down", "pkg"), "2", "f", []byte("two"), false) + + _, err := m.PutPackageOriginConfiguration(ctx, pkgRef("down", "pkg"), driver.OriginRestrictions{Publish: "ALLOW", Upstream: "ALLOW"}) + requireNoError(t, err) + + _, err = m.PublishPackageVersion(ctx, &driver.PublishInput{ + Ref: pkgRef("down", "pkg"), Version: "1", AssetName: "f", SHA256: sha([]byte("x")), Content: []byte("x"), + }) + requireAPIError(t, err, driver.ExConflict) + + publishAsset(t, m, pkgRef("down", "pkg"), "3", "f", []byte("three"), false) +} + +func TestAssetAndVersionPagination(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ref := pkgRef("repo", "pkg") + ctx := context.Background() + + for i := 0; i < 5; i++ { + publishAsset(t, m, ref, fmt.Sprintf("v%d", i), "f", []byte{byte(i)}, false) + publishAsset(t, m, ref, "multi", fmt.Sprintf("asset-%d", i), []byte{byte(i), 9}, true) + } + + var seen []string + + token := "" + + for { + list, next, err := m.ListPackageVersionAssets(ctx, ref, "multi", driver.Page{MaxResults: 1, NextToken: token}) + requireNoError(t, err) + + for _, a := range list.Assets { + seen = append(seen, a.Name) + } + + if next == "" { + break + } + + token = next + } + + if len(seen) != 5 || seen[0] != "asset-0" || seen[4] != "asset-4" { + t.Fatalf("asset walk = %v", seen) + } + + _, _, err := m.ListPackageVersionAssets(ctx, ref, "multi", driver.Page{NextToken: "garbage"}) + requireAPIError(t, err, driver.ExValidation) + + all, err := m.ListPackageVersions(ctx, &driver.ListPackageVersionsInput{Ref: ref, Page: driver.Page{MaxResults: 2}}) + requireNoError(t, err) + + if len(all.Versions) != 2 || all.NextToken == "" { + t.Fatalf("first page = %+v", all) + } +} diff --git a/providers/aws/codeartifact/pagination.go b/providers/aws/codeartifact/pagination.go new file mode 100644 index 000000000..3e61b840d --- /dev/null +++ b/providers/aws/codeartifact/pagination.go @@ -0,0 +1,68 @@ +package codeartifact + +import ( + "strconv" + "strings" + + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +const ( + defaultMaxResults = 100 + maxMaxResults = 1000 + tokenPrefix = "ca-" +) + +// paginate returns the window [start, end) and the next token for a result set +// of n items. The token is an opaque offset; one this mock did not issue, or a +// maxResults outside 1..1000, is a ValidationException (never a silent first +// page). +func paginate(n int, page driver.Page) (start, end int, next string, err error) { + if page.MaxResults < 0 || page.MaxResults > maxMaxResults { + return 0, 0, "", validation("maxResults must be between 1 and %d", maxMaxResults) + } + + start, err = decodeToken(page.NextToken) + if err != nil { + return 0, 0, "", err + } + + if start > n { + start = n + } + + limit := int(page.MaxResults) + if limit == 0 { + limit = defaultMaxResults + } + + end = start + limit + if end >= n { + return start, n, "", nil + } + + return start, end, tokenPrefix + strconv.Itoa(end), nil +} + +func decodeToken(token string) (int, error) { + if token == "" { + return 0, nil + } + + n, err := strconv.Atoi(strings.TrimPrefix(token, tokenPrefix)) + if err != nil || n < 0 || !strings.HasPrefix(token, tokenPrefix) { + return 0, validation("nextToken %q is not valid", token) + } + + return n, nil +} + +// pageOf returns the items of one page of a sorted result set. +func pageOf[T any](items []T, page driver.Page) (out []T, next string, err error) { + start, end, next, err := paginate(len(items), page) + if err != nil { + return nil, "", err + } + + return items[start:end], next, nil +} diff --git a/providers/aws/codeartifact/pagination_test.go b/providers/aws/codeartifact/pagination_test.go new file mode 100644 index 000000000..f119bb4f7 --- /dev/null +++ b/providers/aws/codeartifact/pagination_test.go @@ -0,0 +1,124 @@ +package codeartifact_test + +import ( + "context" + "fmt" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +func TestPaginationBoundsAndTokens(t *testing.T) { + m := seeded(t, config.NewOptions(), "repo") + ctx := context.Background() + publishAsset(t, m, pkgRef("repo", "pkg"), "1", "f", []byte("x"), false) + createGroup(t, m, "/npm/*") + + repo := driver.RepositoryRef{Domain: "dom", Repository: "repo"} + dom := driver.DomainRef{Domain: "dom"} + + lists := map[string]func(p driver.Page) error{ + "ListDomains": func(p driver.Page) error { _, _, err := m.ListDomains(ctx, p); return err }, + "ListRepositories": func(p driver.Page) error { + _, _, err := m.ListRepositories(ctx, "", p) + + return err + }, + "ListRepositoriesInDomain": func(p driver.Page) error { + _, _, err := m.ListRepositoriesInDomain(ctx, "dom", "", "", p) + + return err + }, + "ListPackages": func(p driver.Page) error { + _, _, err := m.ListPackages(ctx, &driver.ListPackagesInput{Repository: repo, Page: p}) + + return err + }, + "ListPackageVersions": func(p driver.Page) error { + _, err := m.ListPackageVersions(ctx, &driver.ListPackageVersionsInput{Ref: pkgRef("repo", "pkg"), Page: p}) + + return err + }, + "ListPackageVersionAssets": func(p driver.Page) error { + _, _, err := m.ListPackageVersionAssets(ctx, pkgRef("repo", "pkg"), "1", p) + + return err + }, + "ListPackageGroups": func(p driver.Page) error { _, _, err := m.ListPackageGroups(ctx, dom, "", p); return err }, + "ListSubPackageGroups": func(p driver.Page) error { + _, _, err := m.ListSubPackageGroups(ctx, dom, "/npm/*", p) + + return err + }, + "ListAssociatedPackages": func(p driver.Page) error { + _, _, err := m.ListAssociatedPackages(ctx, dom, "/npm/*", false, p) + + return err + }, + "ListAllowedRepositoriesForGroup": func(p driver.Page) error { + _, _, err := m.ListAllowedRepositoriesForGroup(ctx, dom, "/npm/*", driver.RestrictionPublish, p) + + return err + }, + } + + for name, list := range lists { + requireNoError(t, list(driver.Page{})) + requireNoError(t, list(driver.Page{MaxResults: 1})) + requireNoError(t, list(driver.Page{MaxResults: 1000})) + + for _, bad := range []driver.Page{{MaxResults: -1}, {MaxResults: 1001}, {NextToken: "garbage"}, {NextToken: "ca--5"}, {NextToken: "7"}} { + if err := list(bad); err == nil { + t.Errorf("%s accepted %+v", name, bad) + } else { + requireAPIError(t, err, driver.ExValidation) + } + } + } +} + +func TestPaginationWalksDomainsAndRepositories(t *testing.T) { + m := newMock() + + for i := 0; i < 4; i++ { + mustDomain(t, m, fmt.Sprintf("dom-%d", i)) + } + + for i := 0; i < 5; i++ { + mustRepo(t, m, "dom-0", fmt.Sprintf("repo-%d", i)) + } + + count := func(list func(driver.Page) (int, string)) int { + total, token := 0, "" + + for { + n, next := list(driver.Page{MaxResults: 1, NextToken: token}) + total += n + + if next == "" { + return total + } + + token = next + } + } + + domains := count(func(p driver.Page) (int, string) { + out, next, err := m.ListDomains(context.Background(), p) + requireNoError(t, err) + + return len(out), next + }) + + repos := count(func(p driver.Page) (int, string) { + out, next, err := m.ListRepositories(context.Background(), "", p) + requireNoError(t, err) + + return len(out), next + }) + + if domains != 4 || repos != 5 { + t.Fatalf("walked %d domains and %d repositories, want 4 and 5", domains, repos) + } +} diff --git a/providers/aws/codeartifact/policy.go b/providers/aws/codeartifact/policy.go new file mode 100644 index 000000000..40ed26840 --- /dev/null +++ b/providers/aws/codeartifact/policy.go @@ -0,0 +1,199 @@ +package codeartifact + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + + "github.com/stackshy/cloudemu/v2/internal/memstore" + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +const policyRevisionBytes = 32 + +// newPolicyRevision mints an opaque revision token. +func newPolicyRevision() (string, error) { + raw := make([]byte, policyRevisionBytes) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("codeartifact: generate policy revision: %w", err) + } + + return base64.StdEncoding.EncodeToString(raw), nil +} + +// validatePolicyDocument checks that a policy document is within the size limit +// and is an IAM policy JSON document (an object with a Statement). +func validatePolicyDocument(doc string) error { + if doc == "" || len(doc) > maxPolicyBytes { + return validation("policyDocument must be 1-%d bytes", maxPolicyBytes) + } + + var parsed struct { + Statement json.RawMessage `json:"Statement"` + } + + if err := json.Unmarshal([]byte(doc), &parsed); err != nil || len(parsed.Statement) == 0 { + return validation("policyDocument must be a JSON policy document with a Statement") + } + + return nil +} + +// putPolicyLocked stores a policy in store under key. A supplied revision must +// equal the current one; the stored revision changes on every put. The caller +// holds mu. +func putPolicyLocked(store *memstore.Store[driver.ResourcePolicy], key, arn, resourceType, resourceID, + revision, document string) (*driver.ResourcePolicy, error) { + if revision != "" { + cur, ok := store.Get(key) + if !ok || cur.Revision != revision { + return nil, conflict(resourceType, resourceID, "The policy revision %s is not the current revision", revision) + } + } + + next, err := newPolicyRevision() + if err != nil { + return nil, err + } + + p := driver.ResourcePolicy{ResourceArn: arn, Revision: next, Document: document} + store.Set(key, p) + + return &p, nil +} + +// getPolicyLocked returns the policy under key, or a ResourceNotFound. The +// caller holds mu. +func getPolicyLocked(store *memstore.Store[driver.ResourcePolicy], key, resourceType, resourceID string) (*driver.ResourcePolicy, error) { + p, ok := store.Get(key) + if !ok { + return nil, notFound(resourceType, resourceID, "No resource policy is set on %s", resourceID) + } + + return &p, nil +} + +// deletePolicyLocked removes the policy under key. A supplied revision must equal +// the current one. The caller holds mu. +func deletePolicyLocked(store *memstore.Store[driver.ResourcePolicy], key, resourceType, resourceID, + revision string) (*driver.ResourcePolicy, error) { + p, err := getPolicyLocked(store, key, resourceType, resourceID) + if err != nil { + return nil, err + } + + if revision != "" && revision != p.Revision { + return nil, conflict(resourceType, resourceID, "The policy revision %s is not the current revision", revision) + } + + store.Delete(key) + + return p, nil +} + +// PutDomainPermissionsPolicy sets the resource policy on a domain. +func (m *Mock) PutDomainPermissionsPolicy(_ context.Context, ref driver.DomainRef, + revision, document string) (*driver.ResourcePolicy, error) { + if err := validatePolicyRevision(revision); err != nil { + return nil, err + } + + if err := validatePolicyDocument(document); err != nil { + return nil, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + d, err := m.domainLocked(ref) + if err != nil { + return nil, err + } + + return putPolicyLocked(m.domainPolicies, d.Name, d.Arn, driver.ResourceTypeDomain, d.Name, revision, document) +} + +// GetDomainPermissionsPolicy returns the resource policy on a domain. +func (m *Mock) GetDomainPermissionsPolicy(_ context.Context, ref driver.DomainRef) (*driver.ResourcePolicy, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + d, err := m.domainLocked(ref) + if err != nil { + return nil, err + } + + return getPolicyLocked(m.domainPolicies, d.Name, driver.ResourceTypeDomain, d.Name) +} + +// DeleteDomainPermissionsPolicy removes the resource policy from a domain. +func (m *Mock) DeleteDomainPermissionsPolicy(_ context.Context, ref driver.DomainRef, revision string) (*driver.ResourcePolicy, error) { + if err := validatePolicyRevision(revision); err != nil { + return nil, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + d, err := m.domainLocked(ref) + if err != nil { + return nil, err + } + + return deletePolicyLocked(m.domainPolicies, d.Name, driver.ResourceTypeDomain, d.Name, revision) +} + +// PutRepositoryPermissionsPolicy sets the resource policy on a repository. +func (m *Mock) PutRepositoryPermissionsPolicy(_ context.Context, ref driver.RepositoryRef, + revision, document string) (*driver.ResourcePolicy, error) { + if err := validatePolicyRevision(revision); err != nil { + return nil, err + } + + if err := validatePolicyDocument(document); err != nil { + return nil, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + _, r, err := m.repositoryLocked(ref) + if err != nil { + return nil, err + } + + return putPolicyLocked(m.repoPolicies, repoKey(r.DomainName, r.Name), r.Arn, driver.ResourceTypeRepository, r.Name, revision, document) +} + +// GetRepositoryPermissionsPolicy returns the resource policy on a repository. +func (m *Mock) GetRepositoryPermissionsPolicy(_ context.Context, ref driver.RepositoryRef) (*driver.ResourcePolicy, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + _, r, err := m.repositoryLocked(ref) + if err != nil { + return nil, err + } + + return getPolicyLocked(m.repoPolicies, repoKey(r.DomainName, r.Name), driver.ResourceTypeRepository, r.Name) +} + +// DeleteRepositoryPermissionsPolicy removes the resource policy from a repository. +func (m *Mock) DeleteRepositoryPermissionsPolicy(_ context.Context, ref driver.RepositoryRef, + revision string) (*driver.ResourcePolicy, error) { + if err := validatePolicyRevision(revision); err != nil { + return nil, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + _, r, err := m.repositoryLocked(ref) + if err != nil { + return nil, err + } + + return deletePolicyLocked(m.repoPolicies, repoKey(r.DomainName, r.Name), driver.ResourceTypeRepository, r.Name, revision) +} diff --git a/providers/aws/codeartifact/policy_test.go b/providers/aws/codeartifact/policy_test.go new file mode 100644 index 000000000..86f801121 --- /dev/null +++ b/providers/aws/codeartifact/policy_test.go @@ -0,0 +1,161 @@ +package codeartifact_test + +import ( + "context" + "strings" + "testing" + + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +const testPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"codeartifact:ReadFromRepository","Resource":"*"}]}` + +func TestDomainPolicyPutGetDelete(t *testing.T) { + m := newMock() + d := mustDomain(t, m, "dom") + ctx := context.Background() + ref := driver.DomainRef{Domain: "dom"} + + _, err := m.GetDomainPermissionsPolicy(ctx, ref) + requireAPIError(t, err, driver.ExNotFound) + + put, err := m.PutDomainPermissionsPolicy(ctx, ref, "", testPolicy) + requireNoError(t, err) + + if put.Revision == "" || put.ResourceArn != d.Arn || put.Document != testPolicy { + t.Fatalf("put = %+v", put) + } + + got, err := m.GetDomainPermissionsPolicy(ctx, ref) + requireNoError(t, err) + + if *got != *put { + t.Fatalf("get = %+v, want %+v", got, put) + } + + del, err := m.DeleteDomainPermissionsPolicy(ctx, ref, "") + requireNoError(t, err) + + if del.Revision != put.Revision { + t.Fatalf("delete returned %+v", del) + } + + _, err = m.GetDomainPermissionsPolicy(ctx, ref) + requireAPIError(t, err, driver.ExNotFound) + + _, err = m.DeleteDomainPermissionsPolicy(ctx, ref, "") + requireAPIError(t, err, driver.ExNotFound) +} + +func TestPolicyRevisionMismatchConflict(t *testing.T) { + m := newMock() + mustDomain(t, m, "dom") + ctx := context.Background() + ref := driver.DomainRef{Domain: "dom"} + + first, err := m.PutDomainPermissionsPolicy(ctx, ref, "", testPolicy) + requireNoError(t, err) + + _, err = m.PutDomainPermissionsPolicy(ctx, ref, "stale-revision", testPolicy) + requireAPIError(t, err, driver.ExConflict) + + second, err := m.PutDomainPermissionsPolicy(ctx, ref, first.Revision, testPolicy) + requireNoError(t, err) + + if second.Revision == first.Revision { + t.Fatal("revision must change on every put") + } + + _, err = m.DeleteDomainPermissionsPolicy(ctx, ref, first.Revision) + requireAPIError(t, err, driver.ExConflict) + + _, err = m.DeleteDomainPermissionsPolicy(ctx, ref, second.Revision) + requireNoError(t, err) + + // A revision names a policy that does not exist any more. + _, err = m.PutDomainPermissionsPolicy(ctx, ref, second.Revision, testPolicy) + requireAPIError(t, err, driver.ExConflict) +} + +func TestPolicyDocumentValidation(t *testing.T) { + m := newMock() + mustDomain(t, m, "dom") + ctx := context.Background() + ref := driver.DomainRef{Domain: "dom"} + + pad := strings.Repeat(" ", 7168-len(testPolicy)) + exact := testPolicy + pad + + for _, bad := range []string{"", "not json", `{}`, `{"Version":"2012-10-17"}`, exact + " "} { + _, err := m.PutDomainPermissionsPolicy(ctx, ref, "", bad) + requireAPIError(t, err, driver.ExValidation) + } + + _, err := m.PutDomainPermissionsPolicy(ctx, ref, "", exact) + requireNoError(t, err) + + _, err = m.PutDomainPermissionsPolicy(ctx, ref, strings.Repeat("r", 101), testPolicy) + requireAPIError(t, err, driver.ExValidation) +} + +func TestRepositoryPolicyPutGetDelete(t *testing.T) { + m := newMock() + mustDomain(t, m, "dom") + r := mustRepo(t, m, "dom", "repo") + ctx := context.Background() + ref := driver.RepositoryRef{Domain: "dom", Repository: "repo"} + + _, err := m.GetRepositoryPermissionsPolicy(ctx, ref) + requireAPIError(t, err, driver.ExNotFound) + + put, err := m.PutRepositoryPermissionsPolicy(ctx, ref, "", testPolicy) + requireNoError(t, err) + + if put.ResourceArn != r.Arn { + t.Fatalf("resource arn = %q, want %q", put.ResourceArn, r.Arn) + } + + _, err = m.PutRepositoryPermissionsPolicy(ctx, ref, "wrong", testPolicy) + requireAPIError(t, err, driver.ExConflict) + + _, err = m.DeleteRepositoryPermissionsPolicy(ctx, ref, "wrong") + requireAPIError(t, err, driver.ExConflict) + + _, err = m.DeleteRepositoryPermissionsPolicy(ctx, ref, put.Revision) + requireNoError(t, err) + + _, err = m.PutRepositoryPermissionsPolicy(ctx, driver.RepositoryRef{Domain: "dom", Repository: "ghost"}, "", testPolicy) + requireAPIError(t, err, driver.ExNotFound) +} + +func TestPolicyRemovedWithResource(t *testing.T) { + m := newMock() + mustDomain(t, m, "dom") + mustRepo(t, m, "dom", "repo") + ctx := context.Background() + + _, err := m.PutRepositoryPermissionsPolicy(ctx, driver.RepositoryRef{Domain: "dom", Repository: "repo"}, "", testPolicy) + requireNoError(t, err) + + _, err = m.PutDomainPermissionsPolicy(ctx, driver.DomainRef{Domain: "dom"}, "", testPolicy) + requireNoError(t, err) + + _, err = m.DeleteRepository(ctx, "dom", "", "repo") + requireNoError(t, err) + + mustRepo(t, m, "dom", "repo") + + _, err = m.GetRepositoryPermissionsPolicy(ctx, driver.RepositoryRef{Domain: "dom", Repository: "repo"}) + requireAPIError(t, err, driver.ExNotFound) + + _, err = m.DeleteRepository(ctx, "dom", "", "repo") + requireNoError(t, err) + + _, err = m.DeleteDomain(ctx, "dom", "") + requireNoError(t, err) + + mustDomain(t, m, "dom") + + _, err = m.GetDomainPermissionsPolicy(ctx, driver.DomainRef{Domain: "dom"}) + requireAPIError(t, err, driver.ExNotFound) +} diff --git a/providers/aws/codeartifact/publish.go b/providers/aws/codeartifact/publish.go new file mode 100644 index 000000000..41ac35eff --- /dev/null +++ b/providers/aws/codeartifact/publish.go @@ -0,0 +1,216 @@ +package codeartifact + +import ( + "context" + "crypto/sha256" + "encoding/hex" + + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +// validSHA256Hex reports whether s is a 64-character lowercase hex digest. +func validSHA256Hex(s string) bool { + if len(s) != sha256HexLen { + return false + } + + for i := 0; i < len(s); i++ { + c := s[i] + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + + return true +} + +// validatePublish checks a PublishPackageVersion request before any state is read. +func validatePublish(in *driver.PublishInput) error { + if err := validatePackageRef(&in.Ref); err != nil { + return err + } + + if err := validateVersion(in.Version); err != nil { + return err + } + + if !validAssetName(in.AssetName) { + return validation("asset name %q is invalid", in.AssetName) + } + + if !validSHA256Hex(in.SHA256) { + return validation("x-amz-content-sha256 must be a 64-character lowercase hex digest") + } + + if in.Ref.Format != driver.FormatGeneric { + return validation("only generic packages can be published with PublishPackageVersion, not %s", in.Ref.Format) + } + + if in.Ref.Namespace == "" { + return validation("a generic package needs a namespace") + } + + if len(in.Content) > driver.MaxAssetBytes { + return quotaExceeded(driver.ResourceTypeAsset, in.AssetName, + "Asset %s exceeds the emulator's %d-byte asset limit", in.AssetName, driver.MaxAssetBytes) + } + + sum := sha256.Sum256(in.Content) + if hex.EncodeToString(sum[:]) != in.SHA256 { + return validation("the SHA-256 of the asset content does not match x-amz-content-sha256") + } + + return nil +} + +// PublishPackageVersion adds an asset to a generic package version, creating the +// package and version as needed. Without Unfinished the version ends Published; a +// Published version cannot take more assets. The same asset name and content on +// an Unfinished version is a no-op, different content is a ConflictException. +func (m *Mock) PublishPackageVersion(ctx context.Context, in *driver.PublishInput) (*driver.PublishResult, error) { + res, events, err := m.publish(in) + m.publishVersionEvents(ctx, events) + + return res, err +} + +func (m *Mock) publish(in *driver.PublishInput) (*driver.PublishResult, []versionEvent, error) { + if err := validatePublish(in); err != nil { + return nil, nil, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + _, repo, err := m.repositoryLocked(driver.RepositoryRef{Domain: in.Ref.Domain, Owner: in.Ref.Owner, Repository: in.Ref.Repository}) + if err != nil { + return nil, nil, err + } + + key := refKey(&in.Ref) + p, existed := m.packages.Get(key) + + if reason := m.publishBlockedLocked(in.Ref.Domain, in.Ref.Repository, in.Ref.Format, in.Ref.Namespace, in.Ref.Package, p); reason != "" { + return nil, nil, conflict(driver.ResourceTypePackage, in.Ref.Package, "Publishing %s is not allowed: %s", in.Ref.Package, reason) + } + + if existed && p.Restrictions.Upstream == driver.OriginAllow && m.versionInUpstreamsLocked(&repo, &in.Ref, in.Version) { + return nil, nil, conflict(driver.ResourceTypePackageVersion, in.Version, + "Version %s of %s is present in an upstream repository, so it cannot be published here", in.Version, in.Ref.Package) + } + + if !existed { + p = &packageRecord{ + Domain: in.Ref.Domain, Repository: in.Ref.Repository, Format: in.Ref.Format, Namespace: in.Ref.Namespace, + Name: in.Ref.Package, Versions: map[string]*versionRecord{}, + Restrictions: m.defaultRestrictionsLocked(in.Ref.Domain, in.Ref.Format, in.Ref.Namespace, in.Ref.Package), + } + } + + v, ev, err := m.addAssetLocked(&repo, p, in) + if err != nil { + return nil, nil, err + } + + m.packages.Set(key, p) + + res := &driver.PublishResult{ + VersionIdentity: v.identity(p), + Status: v.Status, + Asset: v.Assets[in.AssetName].toDriver(), + } + + if ev == nil { + return res, nil, nil + } + + return res, []versionEvent{*ev}, nil +} + +// versionInUpstreamsLocked reports whether a version of the package is held by a +// repository reachable through the repository's upstreams. The caller holds mu. +func (m *Mock) versionInUpstreamsLocked(repo *driver.Repository, ref *driver.PackageRef, version string) bool { + for _, u := range repo.Upstreams { + if v := m.findVersionLocked(repo.DomainName, u.RepositoryName, ref.Format, ref.Namespace, ref.Package, version, true); v != nil { + return true + } + } + + return false +} + +// checkAssetAddableLocked reports why an asset cannot be added to the existing +// version, and whether the request changes nothing (the same asset again on an +// Unfinished version). +func checkAssetAddableLocked(v *versionRecord, in *driver.PublishInput) (noop bool, err error) { + if v.Status != driver.VersionStatusUnfinished { + return false, conflict(driver.ResourceTypePackageVersion, in.Version, + "Version %s of %s is %s and cannot take more assets", in.Version, in.Ref.Package, v.Status) + } + + cur, ok := v.Assets[in.AssetName] + if !ok { + if len(v.Assets) >= maxAssetsPerVersion { + return false, quotaExceeded(driver.ResourceTypeAsset, in.AssetName, + "A package version can have at most %d assets", maxAssetsPerVersion) + } + + return false, nil + } + + if cur.Hashes[driver.HashSHA256] != in.SHA256 { + return false, conflict(driver.ResourceTypeAsset, in.AssetName, "Asset %s already exists with different content", in.AssetName) + } + + return in.Unfinished, nil +} + +// addAssetLocked adds the published asset to the package's version, creating the +// version when needed, and builds the event. A nil event means the request +// changed nothing. The caller holds mu. +func (m *Mock) addAssetLocked(repo *driver.Repository, p *packageRecord, in *driver.PublishInput) (*versionRecord, *versionEvent, error) { + v, exists := p.Versions[in.Version] + operation := operationUpdated + + if exists { + noop, err := checkAssetAddableLocked(v, in) + if err != nil { + return nil, nil, err + } + + if noop { + return v, nil, nil + } + } else { + operation = operationCreated + v = &versionRecord{ + Version: in.Version, Status: driver.VersionStatusUnfinished, Assets: map[string]*assetRecord{}, + Origin: driver.VersionOrigin{OriginType: driver.OriginTypeInternal, RepositoryName: repo.Name}, + } + p.Versions[in.Version] = v + } + + added := 0 + + if _, replaced := v.Assets[in.AssetName]; !replaced { + added = 1 + v.Assets[in.AssetName] = &assetRecord{ + Name: in.AssetName, Size: int64(len(in.Content)), Hashes: assetHashes(in.Content), + Content: append([]byte(nil), in.Content...), Stored: true, + } + } + + statusChanged := !exists + + if !in.Unfinished && v.Status == driver.VersionStatusUnfinished { + v.Status = driver.VersionStatusPublished + v.PublishedTime = m.now() + statusChanged = true + } + + v.Revision = computeRevision(v.Assets) + + ev := m.newVersionEventLocked(repo, p, v, operation, versionChanges{AssetsAdded: added, StatusChanged: statusChanged}) + + return v, &ev, nil +} diff --git a/providers/aws/codeartifact/race_test.go b/providers/aws/codeartifact/race_test.go new file mode 100644 index 000000000..43465bed1 --- /dev/null +++ b/providers/aws/codeartifact/race_test.go @@ -0,0 +1,238 @@ +package codeartifact_test + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +const raceWorkers = 50 + +func TestConcurrentCreateDomainExactlyOneWins(t *testing.T) { + m := newMock() + + var wg sync.WaitGroup + + var wins, conflicts atomic.Int32 + + for i := 0; i < raceWorkers; i++ { + wg.Add(1) + + go func() { + defer wg.Done() + + _, err := m.CreateDomain(context.Background(), &driver.CreateDomainInput{Name: "same-name"}) + + var apiErr *driver.APIError + + switch { + case err == nil: + wins.Add(1) + case errors.As(err, &apiErr) && apiErr.Exception == driver.ExConflict: + conflicts.Add(1) + } + }() + } + + wg.Wait() + + if wins.Load() != 1 || conflicts.Load() != raceWorkers-1 { + t.Fatalf("wins=%d conflicts=%d, want 1/%d", wins.Load(), conflicts.Load(), raceWorkers-1) + } +} + +func TestConcurrentCreateRepositoryAndDeleteDomainNoOrphans(t *testing.T) { + for round := 0; round < 40; round++ { + m := newMock() + mustDomain(t, m, "dom") + + var wg sync.WaitGroup + + wg.Add(2) + + go func() { + defer wg.Done() + + _, _ = m.CreateRepository(context.Background(), &driver.CreateRepositoryInput{Domain: "dom", Repository: "repo"}) + }() + + go func() { + defer wg.Done() + + _, _ = m.DeleteDomain(context.Background(), "dom", "") + }() + + wg.Wait() + + _, domErr := m.DescribeDomain(context.Background(), "dom", "") + repos, _, err := m.ListRepositories(context.Background(), "", driver.Page{}) + requireNoError(t, err) + + if domErr != nil && len(repos) != 0 { + t.Fatalf("round %d: domain gone but %d orphan repositories remain", round, len(repos)) + } + } +} + +func TestQuotaCapsNeverExceededUnderRace(t *testing.T) { + m := newMock() + + var wg sync.WaitGroup + + var ok atomic.Int32 + + for i := 0; i < 40; i++ { + wg.Add(1) + + go func(n int) { + defer wg.Done() + + if _, err := m.CreateDomain(context.Background(), &driver.CreateDomainInput{Name: fmt.Sprintf("dom-%02d", n)}); err == nil { + ok.Add(1) + } + }(i) + } + + wg.Wait() + + const domainQuota = 10 + if ok.Load() != domainQuota { + t.Fatalf("created %d domains, want exactly the quota %d", ok.Load(), domainQuota) + } +} + +func TestPublishVersusDeleteRepositoryNoOrphans(t *testing.T) { + for round := 0; round < 30; round++ { + m := seeded(t, config.NewOptions(), "repo") + ref := pkgRef("repo", "pkg") + + var wg sync.WaitGroup + + wg.Add(2) + + go func() { + defer wg.Done() + + data := []byte("data") + _, _ = m.PublishPackageVersion(context.Background(), &driver.PublishInput{ + Ref: ref, Version: "1", AssetName: "f", SHA256: sha(data), Content: data, + }) + }() + + go func() { + defer wg.Done() + + _, _ = m.DeleteRepository(context.Background(), "dom", "", "repo") + }() + + wg.Wait() + + if _, err := m.DescribeRepository(context.Background(), "dom", "", "repo"); err != nil { + // The repository is gone: no package, version or asset may remain reachable. + if _, descErr := m.DescribePackage(context.Background(), ref); descErr == nil { + t.Fatalf("round %d: package survived its repository", round) + } + + d, descErr := m.DescribeDomain(context.Background(), "dom", "") + requireNoError(t, descErr) + + if d.AssetSizeBytes != 0 { + t.Fatalf("round %d: %d orphan asset bytes", round, d.AssetSizeBytes) + } + } + } +} + +func TestReadsReturnDeepCopies(t *testing.T) { + m := richState(t) + ctx := context.Background() + ref := pkgRef("repo", "pkg") + + got, err := m.GetPackageVersionAsset(ctx, ref, "1", "f.bin", "") + requireNoError(t, err) + + got.Content[0] ^= 0xff + got.Hashes[driver.HashSHA256] = "tampered" + + again, err := m.GetPackageVersionAsset(ctx, ref, "1", "f.bin", "") + requireNoError(t, err) + + if string(again.Content) != "snapshot bytes" || again.Hashes[driver.HashSHA256] == "tampered" { + t.Fatal("mutating a returned asset changed stored state") + } + + repo, err := m.DescribeRepository(ctx, "dom", "", "repo") + requireNoError(t, err) + + repo.Tags = map[string]string{"x": "y"} + repo.Upstreams = append(repo.Upstreams, driver.UpstreamRef{RepositoryName: "ghost"}) + + repo2, err := m.DescribeRepository(ctx, "dom", "", "repo") + requireNoError(t, err) + + if len(repo2.Tags) != 0 || len(repo2.Upstreams) != 0 { + t.Fatalf("mutating a returned repository changed stored state: %+v", repo2) + } + + grp, err := m.DescribePackageGroup(ctx, driver.DomainRef{Domain: "dom"}, "/generic/ns/*") + requireNoError(t, err) + + grp.Restrictions[driver.RestrictionPublish] = driver.GroupRestriction{Mode: driver.ModeBlock} + + grp2, err := m.DescribePackageGroup(ctx, driver.DomainRef{Domain: "dom"}, "/generic/ns/*") + requireNoError(t, err) + + if grp2.Restrictions[driver.RestrictionPublish].Mode != driver.ModeAllowSpecificRepositories { + t.Fatal("mutating a returned group changed stored state") + } +} + +func TestConcurrentMutatorsVersusReaders(t *testing.T) { + m := richState(t) + ctx := context.Background() + ref := pkgRef("repo", "pkg") + + var wg sync.WaitGroup + + for i := 0; i < raceWorkers; i++ { + wg.Add(1) + + go func(n int) { + defer wg.Done() + + switch n % 5 { + case 0: + _, _ = m.UpdatePackageVersionsStatus(ctx, &driver.UpdateStatusInput{ + VersionsInput: driver.VersionsInput{Ref: ref, Versions: []string{"1"}}, TargetStatus: []string{"Archived", "Unlisted", "Published"}[n%3], + }) + case 1: + _ = m.TagResource(ctx, "arn:aws:codeartifact:us-east-1:123456789012:domain/dom", map[string]string{fmt.Sprintf("k%d", n): "v"}) + case 2: + _, _ = m.DescribeDomain(ctx, "dom", "") + _, _, _ = m.ListPackageVersionAssets(ctx, ref, "1", driver.Page{}) + case 3: + _, _, _ = m.ListPackageGroups(ctx, driver.DomainRef{Domain: "dom"}, "", driver.Page{}) + _, _, _ = m.ListRepositories(ctx, "", driver.Page{}) + default: + _, _ = m.CopyPackageVersions(ctx, &driver.CopyInput{ + Ref: ref, SourceRepository: "repo", DestinationRepository: "other", Versions: []string{"1"}, AllowOverwrite: true, + }) + } + }(i) + } + + wg.Wait() + + d, err := m.DescribeDomain(ctx, "dom", "") + requireNoError(t, err) + + if len(d.Tags) != raceWorkers/5 { + t.Fatalf("tags after %d concurrent tag calls = %d, want %d (no lost updates)", raceWorkers/5, len(d.Tags), raceWorkers/5) + } +} diff --git a/providers/aws/codeartifact/repository.go b/providers/aws/codeartifact/repository.go index 7381e1ada..b6f5b179a 100644 --- a/providers/aws/codeartifact/repository.go +++ b/providers/aws/codeartifact/repository.go @@ -7,32 +7,81 @@ import ( "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" ) +// externalConnectionFormat returns the package format a supported public +// repository serves. These are the ten connections the CodeArtifact user guide +// lists ("Supported external connection repositories"). +func externalConnectionFormat(name string) (string, bool) { + switch name { + case "public:maven-clojars", "public:maven-commonsware", "public:maven-googleandroid", + "public:maven-gradleplugins", "public:maven-central": + return driver.FormatMaven, true + case "public:npmjs": + return driver.FormatNPM, true + case "public:nuget-org": + return driver.FormatNuGet, true + case "public:pypi": + return driver.FormatPyPI, true + case "public:ruby-gems-org": + return driver.FormatRuby, true + case "public:crates-io": + return driver.FormatCargo, true + default: + return "", false + } +} + // CreateRepository provisions a new repository in an existing domain with stable // computed fields (arn, administratorAccount, domainOwner and createdTime). The -// parent domain must exist or a ResourceNotFoundException is returned; a -// repository name already used in the domain yields a ConflictException. +// parent domain must exist under the given owner or a ResourceNotFoundException +// is returned; a repository name already used in the domain yields a +// ConflictException; a domain holds at most 1000 repositories and a repository +// at most 10 upstreams, each of which must exist. func (m *Mock) CreateRepository(_ context.Context, in *driver.CreateRepositoryInput) (*driver.Repository, error) { - if in.Repository == "" { - return nil, validation("repository name is required") + if err := validateRepositoryRef(driver.RepositoryRef{Domain: in.Domain, Owner: in.DomainOwner, Repository: in.Repository}); err != nil { + return nil, err + } + + if err := validateDescription(in.Description); err != nil { + return nil, err + } + + if err := validateTags(in.Tags); err != nil { + return nil, err } - owner := m.resolveOwner(in.DomainOwner) + if err := validateUpstreamShape(in.Repository, in.Upstreams); err != nil { + return nil, err + } + + m.mu.Lock() + defer m.mu.Unlock() - if !m.domains.Has(in.Domain) { - return nil, notFound("Domain %s not found", in.Domain) + d, err := m.domainLocked(driver.DomainRef{Domain: in.Domain, Owner: in.DomainOwner}) + if err != nil { + return nil, err } key := repoKey(in.Domain, in.Repository) if m.repos.Has(key) { - return nil, conflict("Repository %s already exists in domain %s", in.Repository, in.Domain) + return nil, conflict(driver.ResourceTypeRepository, in.Repository, + "Repository %s already exists in domain %s", in.Repository, in.Domain) + } + + if m.repositoryCountLocked(in.Domain) >= maxReposPerDomain { + return nil, quotaExceeded(driver.ResourceTypeRepository, in.Repository, + "A domain can have at most %d repositories", maxReposPerDomain) + } + + if upErr := m.validateUpstreamsLocked(in.Domain, in.Repository, in.Upstreams); upErr != nil { + return nil, upErr } r := driver.Repository{ Name: in.Repository, - AdministratorAccount: owner, + AdministratorAccount: m.opts.AccountID, DomainName: in.Domain, - DomainOwner: owner, - Arn: m.repositoryARN(owner, in.Domain, in.Repository), + DomainOwner: d.Owner, + Arn: m.repositoryARN(d.Owner, in.Domain, in.Repository), Description: in.Description, CreatedTime: m.now(), Upstreams: copyUpstreams(in.Upstreams), @@ -46,12 +95,97 @@ func (m *Mock) CreateRepository(_ context.Context, in *driver.CreateRepositoryIn return &out, nil } +// validateUpstreamShape checks the parts of an upstream list that need no stored +// state: valid names, no duplicates and not the repository itself. It runs before +// anything is looked up, so a malformed request is a ValidationException even +// when a quota is also exhausted. +func validateUpstreamShape(repo string, ups []driver.UpstreamRef) error { + seen := make(map[string]bool, len(ups)) + + for _, u := range ups { + if err := validateRepositoryName(u.RepositoryName); err != nil { + return err + } + + if u.RepositoryName == repo { + return validation("repository %s cannot be its own upstream", repo) + } + + if seen[u.RepositoryName] { + return validation("upstream repository %s is listed more than once", u.RepositoryName) + } + + seen[u.RepositoryName] = true + } + + return nil +} + +// validateUpstreamsLocked checks a repository's upstream list against stored +// state: at most 10, each existing in the domain, and no cycle through the +// repositories already stored. The caller holds mu and has run +// validateUpstreamShape. +func (m *Mock) validateUpstreamsLocked(domain, repo string, ups []driver.UpstreamRef) error { + if len(ups) > maxUpstreams { + return quotaExceeded(driver.ResourceTypeRepository, repo, "A repository can have at most %d direct upstream repositories", maxUpstreams) + } + + for _, u := range ups { + if !m.repos.Has(repoKey(domain, u.RepositoryName)) { + return notFound(driver.ResourceTypeRepository, u.RepositoryName, + "Upstream repository %s not found in domain %s", u.RepositoryName, domain) + } + + if m.reachesLocked(domain, u.RepositoryName, repo) { + return validation("adding upstream %s to %s would create a cycle", u.RepositoryName, repo) + } + } + + return nil +} + +// reachesLocked reports whether target is reachable from start through upstream +// links. The caller holds mu. +func (m *Mock) reachesLocked(domain, start, target string) bool { + visited := map[string]bool{} + queue := []string{start} + + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + + if cur == target { + return true + } + + if visited[cur] { + continue + } + + visited[cur] = true + + r, ok := m.repos.Get(repoKey(domain, cur)) + if !ok { + continue + } + + for _, u := range r.Upstreams { + queue = append(queue, u.RepositoryName) + } + } + + return false +} + // DescribeRepository returns a copy of the repository. Stored computed fields are // returned unchanged so repeated reads never drift. -func (m *Mock) DescribeRepository(_ context.Context, domain, _, repository string) (*driver.Repository, error) { - r, ok := m.repos.Get(repoKey(domain, repository)) - if !ok { - return nil, notFound("Repository %s not found in domain %s", repository, domain) +func (m *Mock) DescribeRepository(_ context.Context, domain, domainOwner, repository string) (*driver.Repository, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + _, r, err := m.repositoryLocked(driver.RepositoryRef{Domain: domain, Owner: domainOwner, Repository: repository}) + if err != nil { + return nil, err } out := copyRepository(&r) @@ -59,46 +193,108 @@ func (m *Mock) DescribeRepository(_ context.Context, domain, _, repository strin return &out, nil } +// applyUpstreamsLocked replaces r's upstream list after checking it against stored +// state and the repository's external connection. The caller holds mu. +func (m *Mock) applyUpstreamsLocked(r *driver.Repository, ups []driver.UpstreamRef) error { + if err := m.validateUpstreamsLocked(r.DomainName, r.Name, ups); err != nil { + return err + } + + if len(ups) > 0 && len(r.ExternalConnections) > 0 { + return conflict(driver.ResourceTypeRepository, r.Name, + "Repository %s has an external connection, so it cannot have upstream repositories", r.Name) + } + + r.Upstreams = copyUpstreams(ups) + + return nil +} + // UpdateRepository replaces the description and/or upstreams the request // supplied, leaving unmentioned fields untouched. The computed identity fields // are preserved. func (m *Mock) UpdateRepository(_ context.Context, in *driver.UpdateRepositoryInput) (*driver.Repository, error) { - var updated driver.Repository + ref := driver.RepositoryRef{Domain: in.Domain, Owner: in.DomainOwner, Repository: in.Repository} + + if err := validateRepositoryRef(ref); err != nil { + return nil, err + } - ok := m.repos.Update(repoKey(in.Domain, in.Repository), func(r driver.Repository) driver.Repository { - if in.Description != nil { - r.Description = *in.Description + if in.Description != nil { + if err := validateDescription(*in.Description); err != nil { + return nil, err } + } - if in.Upstreams != nil { - r.Upstreams = copyUpstreams(*in.Upstreams) + if in.Upstreams != nil { + if err := validateUpstreamShape(in.Repository, *in.Upstreams); err != nil { + return nil, err } + } - updated = r + m.mu.Lock() + defer m.mu.Unlock() - return r - }) - if !ok { - return nil, notFound("Repository %s not found in domain %s", in.Repository, in.Domain) + _, r, err := m.repositoryLocked(ref) + if err != nil { + return nil, err + } + + if in.Upstreams != nil { + if upErr := m.applyUpstreamsLocked(&r, *in.Upstreams); upErr != nil { + return nil, upErr + } + } + + if in.Description != nil { + r.Description = *in.Description } - out := copyRepository(&updated) + m.repos.Set(repoKey(in.Domain, in.Repository), r) + + out := copyRepository(&r) return &out, nil } -// DeleteRepository removes a repository and returns its description. -func (m *Mock) DeleteRepository(_ context.Context, domain, _, repository string) (*driver.Repository, error) { - key := repoKey(domain, repository) +// DeleteRepository removes a repository with its packages, versions, assets and +// policy, and returns its description. A repository another repository lists as +// an upstream cannot be deleted (ConflictException). It is also dropped from +// the allowed-repository lists of the domain's package groups. +func (m *Mock) DeleteRepository(_ context.Context, domain, domainOwner, repository string) (*driver.Repository, error) { + m.mu.Lock() + defer m.mu.Unlock() + + _, r, err := m.repositoryLocked(driver.RepositoryRef{Domain: domain, Owner: domainOwner, Repository: repository}) + if err != nil { + return nil, err + } - r, ok := m.repos.Get(key) - if !ok { - return nil, notFound("Repository %s not found in domain %s", repository, domain) + others := m.repos.SortedValues() + for i := range others { + if others[i].DomainName != domain || others[i].Name == repository { + continue + } + + for _, u := range others[i].Upstreams { + if u.RepositoryName == repository { + return nil, conflict(driver.ResourceTypeRepository, repository, + "Repository %s is an upstream of repository %s", repository, others[i].Name) + } + } } out := copyRepository(&r) + key := repoKey(domain, repository) m.repos.Delete(key) + m.repoPolicies.Delete(key) + + for pkgKey := range m.packages.Filter(func(k string, _ *packageRecord) bool { return strings.HasPrefix(k, key+"/") }) { + m.packages.Delete(pkgKey) + } + + m.dropAllowedRepositoryLocked(domain, repository) return &out, nil } @@ -107,18 +303,53 @@ func (m *Mock) DeleteRepository(_ context.Context, domain, _, repository string) // their store key, optionally filtered by a name prefix. func (m *Mock) ListRepositories(_ context.Context, prefix string, page driver.Page) ([]*driver.Repository, string, error) { - return m.listRepos(prefix, page, func(driver.Repository) bool { return true }) + if prefix != "" { + if err := validateRepositoryName(prefix); err != nil { + return nil, "", err + } + } + + m.mu.RLock() + defer m.mu.RUnlock() + + return m.listReposLocked(prefix, page, func(driver.Repository) bool { return true }) } // ListRepositoriesInDomain returns a deterministic page of the repositories in a // single domain, optionally filtered by a name prefix. -func (m *Mock) ListRepositoriesInDomain(_ context.Context, domain, _, prefix string, +func (m *Mock) ListRepositoriesInDomain(ctx context.Context, domain, domainOwner, prefix string, + page driver.Page) ([]*driver.Repository, string, error) { + return m.ListRepositoriesInDomainByAdministrator(ctx, driver.DomainRef{Domain: domain, Owner: domainOwner}, "", prefix, page) +} + +// ListRepositoriesInDomainByAdministrator is ListRepositoriesInDomain narrowed to +// the repositories a given account administers ("" keeps all of them). +func (m *Mock) ListRepositoriesInDomainByAdministrator(_ context.Context, ref driver.DomainRef, administrator, prefix string, page driver.Page) ([]*driver.Repository, string, error) { - return m.listRepos(prefix, page, func(r driver.Repository) bool { return r.DomainName == domain }) + if prefix != "" { + if err := validateRepositoryName(prefix); err != nil { + return nil, "", err + } + } + + if administrator != "" && !validOwner(administrator) { + return nil, "", validation("administrator account %q must be a 12-digit account id", administrator) + } + + m.mu.RLock() + defer m.mu.RUnlock() + + if _, err := m.domainLocked(ref); err != nil { + return nil, "", err + } + + return m.listReposLocked(prefix, page, func(r driver.Repository) bool { + return r.DomainName == ref.Domain && (administrator == "" || r.AdministratorAccount == administrator) + }) } -// listRepos is the shared paginated repository lister. -func (m *Mock) listRepos(prefix string, page driver.Page, +// listReposLocked is the shared paginated repository lister. The caller holds mu. +func (m *Mock) listReposLocked(prefix string, page driver.Page, keep func(driver.Repository) bool) ([]*driver.Repository, string, error) { all := m.repos.SortedValues() filtered := make([]driver.Repository, 0, len(all)) @@ -129,7 +360,11 @@ func (m *Mock) listRepos(prefix string, page driver.Page, } } - start, end, next := paginate(len(filtered), page) + start, end, next, err := paginate(len(filtered), page) + if err != nil { + return nil, "", err + } + out := make([]*driver.Repository, 0, end-start) for i := start; i < end; i++ { @@ -140,83 +375,90 @@ func (m *Mock) listRepos(prefix string, page driver.Page, return out, next, nil } -// AssociateExternalConnection adds an external connection to a repository. Only -// one external connection is allowed, so a second association yields a -// ConflictException. -func (m *Mock) AssociateExternalConnection(_ context.Context, domain, _, repository, +// AssociateExternalConnection adds an external connection to a repository. The +// connection must be one of the supported public repositories, a repository +// holds at most one, and a repository with upstream repositories cannot have one. +func (m *Mock) AssociateExternalConnection(_ context.Context, domain, domainOwner, repository, externalConnection string) (*driver.Repository, error) { - var updated driver.Repository + ref := driver.RepositoryRef{Domain: domain, Owner: domainOwner, Repository: repository} + + if err := validateRepositoryRef(ref); err != nil { + return nil, err + } - found := m.repos.Update(repoKey(domain, repository), func(r driver.Repository) driver.Repository { - r.ExternalConnections = []driver.ExternalConnection{{ - ExternalConnectionName: externalConnection, - PackageFormat: packageFormatFor(externalConnection), - Status: driver.ExternalConnStatusAvailable, - }} - updated = r + m.mu.Lock() + defer m.mu.Unlock() - return r - }) - if !found { - return nil, notFound("Repository %s not found in domain %s", repository, domain) + _, r, err := m.repositoryLocked(ref) + if err != nil { + return nil, err + } + + format, ok := externalConnectionFormat(externalConnection) + if !ok { + return nil, notFound(driver.ResourceTypeRepository, externalConnection, + "External connection %s is not a supported public repository", externalConnection) + } + + if len(r.ExternalConnections) > 0 { + return nil, conflict(driver.ResourceTypeRepository, repository, + "Repository %s already has an external connection; only one is allowed", repository) + } + + if len(r.Upstreams) > 0 { + return nil, conflict(driver.ResourceTypeRepository, repository, + "Repository %s has upstream repositories, so it cannot have an external connection", repository) } - out := copyRepository(&updated) + r.ExternalConnections = []driver.ExternalConnection{{ + ExternalConnectionName: externalConnection, + PackageFormat: format, + Status: driver.ExternalConnStatusAvailable, + }} + + m.repos.Set(repoKey(domain, repository), r) + + out := copyRepository(&r) return &out, nil } // DisassociateExternalConnection removes the named external connection from a -// repository. -func (m *Mock) DisassociateExternalConnection(_ context.Context, domain, _, repository, +// repository. A connection that is not attached is a ResourceNotFoundException. +func (m *Mock) DisassociateExternalConnection(_ context.Context, domain, domainOwner, repository, externalConnection string) (*driver.Repository, error) { - var updated driver.Repository + ref := driver.RepositoryRef{Domain: domain, Owner: domainOwner, Repository: repository} - found := m.repos.Update(repoKey(domain, repository), func(r driver.Repository) driver.Repository { - kept := make([]driver.ExternalConnection, 0, len(r.ExternalConnections)) - - for _, ec := range r.ExternalConnections { - if ec.ExternalConnectionName != externalConnection { - kept = append(kept, ec) - } - } + if err := validateRepositoryRef(ref); err != nil { + return nil, err + } - r.ExternalConnections = kept - updated = r + m.mu.Lock() + defer m.mu.Unlock() - return r - }) - if !found { - return nil, notFound("Repository %s not found in domain %s", repository, domain) + _, r, err := m.repositoryLocked(ref) + if err != nil { + return nil, err } - out := copyRepository(&updated) + kept := make([]driver.ExternalConnection, 0, len(r.ExternalConnections)) - return &out, nil -} + for _, ec := range r.ExternalConnections { + if ec.ExternalConnectionName != externalConnection { + kept = append(kept, ec) + } + } -// packageFormatFor derives the package format an external connection serves from -// its name (for example public:npmjs -> npm). -func packageFormatFor(externalConnection string) string { - name := externalConnection - if idx := strings.IndexByte(name, ':'); idx >= 0 { - name = name[idx+1:] - } - - switch { - case strings.HasPrefix(name, "npm"): - return "npm" - case strings.HasPrefix(name, "pypi"): - return "pypi" - case strings.HasPrefix(name, "maven"): - return "maven" - case strings.HasPrefix(name, "nuget"): - return "nuget" - case strings.HasPrefix(name, "ruby"): - return "ruby" - case strings.HasPrefix(name, "crates"): - return "cargo" - default: - return "" + if len(kept) == len(r.ExternalConnections) { + return nil, notFound(driver.ResourceTypeRepository, externalConnection, + "Repository %s has no external connection %s", repository, externalConnection) } + + r.ExternalConnections = kept + + m.repos.Set(repoKey(domain, repository), r) + + out := copyRepository(&r) + + return &out, nil } diff --git a/providers/aws/codeartifact/repository_rules_test.go b/providers/aws/codeartifact/repository_rules_test.go new file mode 100644 index 000000000..b245b394a --- /dev/null +++ b/providers/aws/codeartifact/repository_rules_test.go @@ -0,0 +1,240 @@ +package codeartifact_test + +import ( + "context" + "fmt" + "testing" + + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +func createRepoWithUpstreams(m interface { + CreateRepository(context.Context, *driver.CreateRepositoryInput) (*driver.Repository, error) +}, repo string, ups ...string) error { + refs := make([]driver.UpstreamRef, 0, len(ups)) + for _, u := range ups { + refs = append(refs, driver.UpstreamRef{RepositoryName: u}) + } + + _, err := m.CreateRepository(context.Background(), &driver.CreateRepositoryInput{ + Domain: "dom", Repository: repo, Upstreams: refs, + }) + + return err +} + +func TestAssociateExternalConnectionRules(t *testing.T) { + m := newMock() + mustDomain(t, m, "dom") + mustRepo(t, m, "dom", "repo") + ctx := context.Background() + + _, err := m.AssociateExternalConnection(ctx, "dom", "", "repo", "public:does-not-exist") + requireAPIError(t, err, driver.ExNotFound) + + formats := map[string]string{ + "public:maven-clojars": "maven", "public:maven-commonsware": "maven", "public:maven-googleandroid": "maven", + "public:maven-gradleplugins": "maven", "public:maven-central": "maven", "public:npmjs": "npm", + "public:nuget-org": "nuget", "public:pypi": "pypi", "public:ruby-gems-org": "ruby", "public:crates-io": "cargo", + } + + for name, format := range formats { + repo := "r-" + format + "-" + name[len("public:"):] + mustRepo(t, m, "dom", repo) + + out, assocErr := m.AssociateExternalConnection(ctx, "dom", "", repo, name) + requireNoError(t, assocErr) + + if len(out.ExternalConnections) != 1 || out.ExternalConnections[0].PackageFormat != format || + out.ExternalConnections[0].Status != driver.ExternalConnStatusAvailable { + t.Fatalf("%s: connections = %+v, want one %s/Available", name, out.ExternalConnections, format) + } + } + + _, err = m.AssociateExternalConnection(ctx, "dom", "", "repo", "public:npmjs") + requireNoError(t, err) + + _, err = m.AssociateExternalConnection(ctx, "dom", "", "repo", "public:pypi") + e := requireAPIError(t, err, driver.ExConflict) + + if e.ResourceType != driver.ResourceTypeRepository { + t.Fatalf("conflict resource type = %q", e.ResourceType) + } + + got, err := m.DescribeRepository(ctx, "dom", "", "repo") + requireNoError(t, err) + + if len(got.ExternalConnections) != 1 || got.ExternalConnections[0].ExternalConnectionName != "public:npmjs" { + t.Fatalf("second associate overwrote the first: %+v", got.ExternalConnections) + } + + _, err = m.DisassociateExternalConnection(ctx, "dom", "", "repo", "public:pypi") + requireAPIError(t, err, driver.ExNotFound) + + out, err := m.DisassociateExternalConnection(ctx, "dom", "", "repo", "public:npmjs") + requireNoError(t, err) + + if len(out.ExternalConnections) != 0 { + t.Fatalf("connections after disassociate = %+v", out.ExternalConnections) + } +} + +func TestUpstreamValidation(t *testing.T) { + m := newMock() + mustDomain(t, m, "dom") + mustRepo(t, m, "dom", "base") + + requireAPIError(t, createRepoWithUpstreams(m, "dangling", "ghost"), driver.ExNotFound) + requireAPIError(t, createRepoWithUpstreams(m, "selfref", "selfref"), driver.ExValidation) + requireAPIError(t, createRepoWithUpstreams(m, "dupes", "base", "base"), driver.ExValidation) + requireAPIError(t, createRepoWithUpstreams(m, "badname", "bad name"), driver.ExValidation) + + many := make([]string, 0, 11) + + for i := 0; i < 11; i++ { + name := "up" + string(rune('a'+i)) + "x" + mustRepo(t, m, "dom", name) + + many = append(many, name) + } + + e := requireAPIError(t, createRepoWithUpstreams(m, "toomany", many...), driver.ExServiceQuota) + + if e.ResourceType != driver.ResourceTypeRepository { + t.Fatalf("quota resource type = %q", e.ResourceType) + } + + requireNoError(t, createRepoWithUpstreams(m, "ten", many[:10]...)) + + got, _, err := m.ListRepositoriesInDomain(context.Background(), "dom", "", "dangling", driver.Page{}) + requireNoError(t, err) + + if len(got) != 0 { + t.Fatalf("rejected create left repository %q", got[0].Name) + } +} + +func TestUpstreamCycleRejected(t *testing.T) { + m := newMock() + mustDomain(t, m, "dom") + mustRepo(t, m, "dom", "repo-a") + requireNoError(t, createRepoWithUpstreams(m, "repo-b", "repo-a")) + + ups := []driver.UpstreamRef{{RepositoryName: "repo-b"}} + _, err := m.UpdateRepository(context.Background(), &driver.UpdateRepositoryInput{ + Domain: "dom", Repository: "repo-a", Upstreams: &ups, + }) + requireAPIError(t, err, driver.ExValidation) + + got, err := m.DescribeRepository(context.Background(), "dom", "", "repo-a") + requireNoError(t, err) + + if len(got.Upstreams) != 0 { + t.Fatalf("cycle was stored: %+v", got.Upstreams) + } +} + +func TestUpstreamsXorExternalConnection(t *testing.T) { + m := newMock() + mustDomain(t, m, "dom") + mustRepo(t, m, "dom", "base") + mustRepo(t, m, "dom", "repo") + ctx := context.Background() + + _, err := m.AssociateExternalConnection(ctx, "dom", "", "repo", "public:npmjs") + requireNoError(t, err) + + ups := []driver.UpstreamRef{{RepositoryName: "base"}} + _, err = m.UpdateRepository(ctx, &driver.UpdateRepositoryInput{Domain: "dom", Repository: "repo", Upstreams: &ups}) + requireAPIError(t, err, driver.ExConflict) + + requireNoError(t, createRepoWithUpstreams(m, "withup", "base")) + + _, err = m.AssociateExternalConnection(ctx, "dom", "", "withup", "public:npmjs") + requireAPIError(t, err, driver.ExConflict) +} + +func TestUpdateRepositoryUpstreamsMerge(t *testing.T) { + m := newMock() + mustDomain(t, m, "dom") + mustRepo(t, m, "dom", "base") + requireNoError(t, createRepoWithUpstreams(m, "repo", "base")) + ctx := context.Background() + + desc := "only description" + got, err := m.UpdateRepository(ctx, &driver.UpdateRepositoryInput{Domain: "dom", Repository: "repo", Description: &desc}) + requireNoError(t, err) + + if len(got.Upstreams) != 1 || got.Description != desc { + t.Fatalf("description-only update changed upstreams: %+v", got) + } + + empty := []driver.UpstreamRef{} + got, err = m.UpdateRepository(ctx, &driver.UpdateRepositoryInput{Domain: "dom", Repository: "repo", Upstreams: &empty}) + requireNoError(t, err) + + if len(got.Upstreams) != 0 || got.Description != desc { + t.Fatalf("empty upstreams update = %+v", got) + } +} + +func TestDeleteRepositoryUpstreamDependent(t *testing.T) { + m := newMock() + mustDomain(t, m, "dom") + mustRepo(t, m, "dom", "base") + requireNoError(t, createRepoWithUpstreams(m, "downstream", "base")) + ctx := context.Background() + + _, err := m.DeleteRepository(ctx, "dom", "", "base") + requireAPIError(t, err, driver.ExConflict) + + _, err = m.DescribeRepository(ctx, "dom", "", "base") + requireNoError(t, err) + + _, err = m.DeleteRepository(ctx, "dom", "", "downstream") + requireNoError(t, err) + + _, err = m.DeleteRepository(ctx, "dom", "", "base") + requireNoError(t, err) +} + +func TestDeleteDomainGuardsAndCleanup(t *testing.T) { + m := newMock() + mustDomain(t, m, "dom") + mustRepo(t, m, "dom", "repo") + ctx := context.Background() + + _, err := m.DeleteDomain(ctx, "dom", "") + requireAPIError(t, err, driver.ExConflict) + + _, err = m.DeleteRepository(ctx, "dom", "", "repo") + requireNoError(t, err) + + out, err := m.DeleteDomain(ctx, "dom", "") + requireNoError(t, err) + + if out.Status != driver.DomainStatusDeleted { + t.Fatalf("status = %q", out.Status) + } + + _, err = m.DescribeDomain(ctx, "dom", "") + requireAPIError(t, err, driver.ExNotFound) +} + +func TestMalformedUpstreamsAreValidationErrorsEvenAtTheRepositoryCap(t *testing.T) { + m := newMock() + mustDomain(t, m, "dom") + + for i := 0; i < 1000; i++ { + mustRepo(t, m, "dom", fmt.Sprintf("repo-%04d", i)) + } + + requireAPIError(t, createRepoWithUpstreams(m, "selfref", "selfref"), driver.ExValidation) + requireAPIError(t, createRepoWithUpstreams(m, "dupes", "repo-0001", "repo-0001"), driver.ExValidation) + requireAPIError(t, createRepoWithUpstreams(m, "badname", "bad name"), driver.ExValidation) + requireAPIError(t, createRepoWithUpstreams(m, "wellformed", "repo-0001"), driver.ExServiceQuota) + + ups := []driver.UpstreamRef{{RepositoryName: "repo-0000"}, {RepositoryName: "repo-0000"}} + _, err := m.UpdateRepository(context.Background(), &driver.UpdateRepositoryInput{Domain: "dom", Repository: "repo-0001", Upstreams: &ups}) + requireAPIError(t, err, driver.ExValidation) +} diff --git a/providers/aws/codeartifact/snapshot.go b/providers/aws/codeartifact/snapshot.go index b95b5b840..0d0e6bb17 100644 --- a/providers/aws/codeartifact/snapshot.go +++ b/providers/aws/codeartifact/snapshot.go @@ -12,16 +12,25 @@ import ( var _ snapshot.Snapshottable = (*Mock)(nil) // codeartifactSnapshot is the full serialized state of the CodeArtifact mock. The -// stores hold exported driver types, so they serialize directly, keyed by domain -// name and by "/". The wired opts are not serialized. +// stores hold exported types, so they serialize directly. The wired opts and +// event publisher are not serialized. Asset bytes are included only when the +// snapshot asks for assets; without them package versions keep their assets' +// names, sizes and hashes but the content is gone. type codeartifactSnapshot struct { - Domains map[string]driver.Domain `json:"domains,omitempty"` - Repositories map[string]driver.Repository `json:"repositories,omitempty"` + Domains map[string]driver.Domain `json:"domains,omitempty"` + Repositories map[string]driver.Repository `json:"repositories,omitempty"` + DomainPolicies map[string]driver.ResourcePolicy `json:"domainPolicies,omitempty"` + RepoPolicies map[string]driver.ResourcePolicy `json:"repoPolicies,omitempty"` + Packages map[string]*packageRecord `json:"packages,omitempty"` + Groups map[string]*groupRecord `json:"groups,omitempty"` } -// Snapshot captures the mock's entire state as JSON. includeAssets is unused. CodeArtifact is -// control-plane only and holds no bulk assets. -func (m *Mock) Snapshot(_ context.Context, _ bool) (json.RawMessage, error) { +// Snapshot captures the mock's entire state as JSON. includeAssets selects +// whether asset bytes are part of it. +func (m *Mock) Snapshot(_ context.Context, includeAssets bool) (json.RawMessage, error) { + m.mu.RLock() + defer m.mu.RUnlock() + snap := codeartifactSnapshot{} if m.domains.Len() > 0 { @@ -32,6 +41,26 @@ func (m *Mock) Snapshot(_ context.Context, _ bool) (json.RawMessage, error) { snap.Repositories = m.repos.All() } + if m.domainPolicies.Len() > 0 { + snap.DomainPolicies = m.domainPolicies.All() + } + + if m.repoPolicies.Len() > 0 { + snap.RepoPolicies = m.repoPolicies.All() + } + + if m.packages.Len() > 0 { + snap.Packages = m.packages.All() + + if !includeAssets { + snap.Packages = stripAssetBytes(snap.Packages) + } + } + + if m.groups.Len() > 0 { + snap.Groups = m.groups.All() + } + b, err := json.Marshal(snap) if err != nil { return nil, fmt.Errorf("codeartifact: snapshot: %w", err) @@ -40,15 +69,47 @@ func (m *Mock) Snapshot(_ context.Context, _ bool) (json.RawMessage, error) { return b, nil } +// stripAssetBytes returns copies of the packages whose assets have no content. +func stripAssetBytes(in map[string]*packageRecord) map[string]*packageRecord { + out := make(map[string]*packageRecord, len(in)) + + for k, p := range in { + cp := *p + cp.Versions = make(map[string]*versionRecord, len(p.Versions)) + + for name, v := range p.Versions { + cv := *v + cv.Assets = make(map[string]*assetRecord, len(v.Assets)) + + for an, a := range v.Assets { + ca := *a + ca.Content, ca.Stored = nil, false + cv.Assets[an] = &ca + } + + cp.Versions[name] = &cv + } + + out[k] = &cp + } + + return out +} + // Restore rebuilds the mock's state under the original identities: every domain -// name and repository key (and the ARN and computed fields derived from them) is -// preserved, so a restore is transparent to clients. +// name, repository key, package, group and policy (and the ARN and computed +// fields derived from them) is preserved, so a restore is transparent to +// clients. A snapshot written before packages and groups existed restores with +// those stores empty. func (m *Mock) Restore(_ context.Context, data json.RawMessage) error { var snap codeartifactSnapshot if err := json.Unmarshal(data, &snap); err != nil { return fmt.Errorf("codeartifact: parse snapshot: %w", err) } + m.mu.Lock() + defer m.mu.Unlock() + for name := range snap.Domains { m.domains.Set(name, snap.Domains[name]) } @@ -57,5 +118,25 @@ func (m *Mock) Restore(_ context.Context, data json.RawMessage) error { m.repos.Set(key, snap.Repositories[key]) } + for key := range snap.DomainPolicies { + m.domainPolicies.Set(key, snap.DomainPolicies[key]) + } + + for key := range snap.RepoPolicies { + m.repoPolicies.Set(key, snap.RepoPolicies[key]) + } + + for key, p := range snap.Packages { + if p.Versions == nil { + p.Versions = map[string]*versionRecord{} + } + + m.packages.Set(key, p) + } + + for key, g := range snap.Groups { + m.groups.Set(key, g) + } + return nil } diff --git a/providers/aws/codeartifact/snapshot_test.go b/providers/aws/codeartifact/snapshot_test.go new file mode 100644 index 000000000..d4c99333c --- /dev/null +++ b/providers/aws/codeartifact/snapshot_test.go @@ -0,0 +1,165 @@ +package codeartifact_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/providers/aws/codeartifact" + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +// richState creates one of everything a snapshot has to carry. +func richState(t *testing.T) *codeartifact.Mock { + t.Helper() + + m := seeded(t, config.NewOptions(), "repo", "other") + ctx := context.Background() + dom := driver.DomainRef{Domain: "dom"} + + _, err := m.PutDomainPermissionsPolicy(ctx, dom, "", testPolicy) + requireNoError(t, err) + + _, err = m.PutRepositoryPermissionsPolicy(ctx, driver.RepositoryRef{Domain: "dom", Repository: "repo"}, "", testPolicy) + requireNoError(t, err) + + ref := pkgRef("repo", "pkg") + publishAsset(t, m, ref, "1", "f.bin", []byte("snapshot bytes"), false) + publishAsset(t, m, ref, "2", "g.bin", []byte("unfinished"), true) + + _, err = m.PutPackageOriginConfiguration(ctx, ref, driver.OriginRestrictions{Publish: "ALLOW", Upstream: "ALLOW"}) + requireNoError(t, err) + + createGroup(t, m, "/generic/ns/*") + + _, err = m.UpdatePackageGroupOriginConfiguration(ctx, &driver.UpdateGroupOriginInput{ + Domain: dom, Pattern: "/generic/ns/*", Restrictions: map[string]string{driver.RestrictionPublish: driver.ModeAllowSpecificRepositories}, + AddAllowed: []driver.AllowedRepository{{RepositoryName: "repo", OriginRestrictionType: driver.RestrictionPublish}}, + }) + requireNoError(t, err) + + return m +} + +// observe renders what a client can see of the rich state, as comparable JSON. +func observe(t *testing.T, m *codeartifact.Mock) string { + t.Helper() + + ctx := context.Background() + ref := pkgRef("repo", "pkg") + + d, err := m.DescribeDomain(ctx, "dom", "") + requireNoError(t, err) + + pol, err := m.GetRepositoryPermissionsPolicy(ctx, driver.RepositoryRef{Domain: "dom", Repository: "repo"}) + requireNoError(t, err) + + v1, err := m.DescribePackageVersion(ctx, ref, "1") + requireNoError(t, err) + + v2, err := m.DescribePackageVersion(ctx, ref, "2") + requireNoError(t, err) + + pkg, err := m.DescribePackage(ctx, ref) + requireNoError(t, err) + + g, err := m.DescribePackageGroup(ctx, driver.DomainRef{Domain: "dom"}, "/generic/ns/*") + requireNoError(t, err) + + assets, _, err := m.ListPackageVersionAssets(ctx, ref, "1", driver.Page{}) + requireNoError(t, err) + + raw, err := json.Marshal([]any{d, pol, v1, v2, pkg, g, assets}) + requireNoError(t, err) + + return string(raw) +} + +func TestSnapshotRoundTripAllState(t *testing.T) { + src := richState(t) + ctx := context.Background() + before := observe(t, src) + + data, err := src.Snapshot(ctx, true) + requireNoError(t, err) + + dst := newMock() + requireNoError(t, dst.Restore(ctx, data)) + + if after := observe(t, dst); after != before { + t.Fatalf("restored state differs\n before: %s\n after: %s", before, after) + } + + got, err := dst.GetPackageVersionAsset(ctx, pkgRef("repo", "pkg"), "1", "f.bin", "") + requireNoError(t, err) + + if string(got.Content) != "snapshot bytes" { + t.Fatalf("restored bytes = %q", got.Content) + } + + // The event sequence continues after a restore instead of restarting. + pub := &recordingPublisher{} + dst.SetEventPublisher(pub) + publishAsset(t, dst, pkgRef("repo", "pkg"), "3", "h", []byte("h"), false) + + if evs := pub.all(); len(evs) != 1 || evs[0].detail["sequenceNumber"].(float64) <= 2 { + t.Fatalf("sequence did not continue: %+v", evs) + } + + // The restored mock keeps enforcing the same rules. + _, err = dst.CreateDomain(ctx, &driver.CreateDomainInput{Name: "dom"}) + requireAPIError(t, err, driver.ExConflict) +} + +func TestSnapshotWithoutAssetsKeepsMetadata(t *testing.T) { + src := richState(t) + ctx := context.Background() + + data, err := src.Snapshot(ctx, false) + requireNoError(t, err) + + dst := newMock() + requireNoError(t, dst.Restore(ctx, data)) + + ref := pkgRef("repo", "pkg") + + list, _, err := dst.ListPackageVersionAssets(ctx, ref, "1", driver.Page{}) + requireNoError(t, err) + + if len(list.Assets) != 1 || list.Assets[0].Size != int64(len("snapshot bytes")) || list.Assets[0].Hashes[driver.HashSHA256] == "" { + t.Fatalf("assets after metadata-only restore = %+v", list.Assets) + } + + _, err = dst.GetPackageVersionAsset(ctx, ref, "1", "f.bin", "") + requireAPIError(t, err, driver.ExNotFound) + + // The source still has its bytes: stripping happens on a copy. + if _, err = src.GetPackageVersionAsset(ctx, ref, "1", "f.bin", ""); err != nil { + t.Fatalf("snapshot without assets damaged the source: %v", err) + } +} + +func TestSnapshotFromBeforePackagesLoads(t *testing.T) { + legacy := []byte(`{"domains":{"dom":{"Name":"dom","Owner":"123456789012","Arn":"arn:aws:codeartifact:us-east-1:123456789012:domain/dom","Status":"Active"}},` + + `"repositories":{"dom/repo":{"Name":"repo","DomainName":"dom","DomainOwner":"123456789012","AdministratorAccount":"123456789012","Arn":"arn:aws:codeartifact:us-east-1:123456789012:repository/dom/repo"}}}`) + + m := newMock() + requireNoError(t, m.Restore(context.Background(), legacy)) + + d, err := m.DescribeDomain(context.Background(), "dom", "") + requireNoError(t, err) + + if d.RepositoryCount != 1 || d.AssetSizeBytes != 0 { + t.Fatalf("legacy domain = %+v", d) + } + + pkgs, _, err := m.ListPackages(context.Background(), &driver.ListPackagesInput{Repository: driver.RepositoryRef{Domain: "dom", Repository: "repo"}}) + requireNoError(t, err) + + if len(pkgs) != 0 { + t.Fatalf("legacy snapshot restored %d packages", len(pkgs)) + } + + publishAsset(t, m, pkgRef("repo", "pkg"), "1", "f", []byte("x"), false) +} diff --git a/providers/aws/codeartifact/tags.go b/providers/aws/codeartifact/tags.go index 5a89967a8..8685fd377 100644 --- a/providers/aws/codeartifact/tags.go +++ b/providers/aws/codeartifact/tags.go @@ -2,92 +2,176 @@ package codeartifact import ( "context" + "strings" "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" ) -// TagResource adds or overwrites tags on a domain or repository. +// tagTarget gives read and write access to the tag map of one taggable resource. +type tagTarget struct { + resourceType string + resourceID string + get func() map[string]string + set func(map[string]string) +} + +// TagResource adds or overwrites tags on a domain, repository or package group. +// A resource holds at most 200 tags; overwriting an existing key does not count +// against the limit. func (m *Mock) TagResource(_ context.Context, resourceArn string, tags map[string]string) error { - return m.mutateTags(resourceArn, func(t map[string]string) { - for k, v := range tags { - t[k] = v - } - }) + if err := validateTags(tags); err != nil { + return err + } + + m.mu.Lock() + defer m.mu.Unlock() + + t, err := m.tagTargetLocked(resourceArn) + if err != nil { + return err + } + + merged := copyTags(t.get()) + if merged == nil { + merged = make(map[string]string, len(tags)) + } + + for k, v := range tags { + merged[k] = v + } + + if len(merged) > maxTags { + return quotaExceeded(t.resourceType, t.resourceID, "A resource can have at most %d tags", maxTags) + } + + t.set(merged) + + return nil } -// UntagResource removes tags by key from a domain or repository. +// UntagResource removes tags by key from a domain, repository or package group. func (m *Mock) UntagResource(_ context.Context, resourceArn string, tagKeys []string) error { - return m.mutateTags(resourceArn, func(t map[string]string) { - for _, k := range tagKeys { - delete(t, k) - } - }) + m.mu.Lock() + defer m.mu.Unlock() + + t, err := m.tagTargetLocked(resourceArn) + if err != nil { + return err + } + + kept := copyTags(t.get()) + + for _, k := range tagKeys { + delete(kept, k) + } + + t.set(kept) + + return nil } -// ListTagsForResource returns a copy of a domain's or repository's tags. +// ListTagsForResource returns a copy of a resource's tags. func (m *Mock) ListTagsForResource(_ context.Context, resourceArn string) (map[string]string, error) { - kind, key := resourceRefFromARN(resourceArn) + m.mu.RLock() + defer m.mu.RUnlock() + + t, err := m.tagTargetLocked(resourceArn) + if err != nil { + return nil, err + } + + return copyTags(t.get()), nil +} + +// tagTargetLocked resolves a CodeArtifact resource ARN to its tag map. A +// malformed or non-CodeArtifact ARN is a ValidationException; a well-formed ARN +// naming a missing resource, or one in another account, region or partition, is a +// ResourceNotFoundException. The caller holds mu. +func (m *Mock) tagTargetLocked(arn string) (*tagTarget, error) { + kind, key := resourceRefFromARN(arn) switch kind { case kindDomain: d, ok := m.domains.Get(key) - if !ok { - return nil, notFound("Domain %s not found", key) + if !ok || d.Arn != arn { + return nil, notFound(driver.ResourceTypeDomain, key, "Domain %s not found", key) } - return copyTags(d.Tags), nil + return &tagTarget{ + resourceType: driver.ResourceTypeDomain, resourceID: key, + get: func() map[string]string { return d.Tags }, + set: func(t map[string]string) { + d.Tags = t + m.domains.Set(key, d) + }, + }, nil case kindRepository: r, ok := m.repos.Get(key) - if !ok { - return nil, notFound("Repository %s not found", key) + if !ok || r.Arn != arn { + return nil, notFound(driver.ResourceTypeRepository, key, "Repository %s not found", key) } - return copyTags(r.Tags), nil + return &tagTarget{ + resourceType: driver.ResourceTypeRepository, resourceID: key, + get: func() map[string]string { return r.Tags }, + set: func(t map[string]string) { + r.Tags = t + m.repos.Set(key, r) + }, + }, nil + case kindPackageGroup: + g, ok := m.groups.Get(key) + if !ok || g.Arn != arn { + return nil, notFound("", key, "Package group %s not found", key) + } + + return &tagTarget{ + resourceType: "", resourceID: key, + get: func() map[string]string { return g.Tags }, + set: func(t map[string]string) { g.Tags = t }, + }, nil default: - return nil, validation("invalid resource ARN: %q", resourceArn) + return nil, validation("invalid resource ARN: %q", arn) } } -// mutateTags applies mutate to the tag map of the domain or repository the ARN -// identifies, under the store lock. -func (m *Mock) mutateTags(resourceArn string, mutate func(map[string]string)) error { - kind, key := resourceRefFromARN(resourceArn) +// resourceRefFromARN extracts the (kind, store key) pair from a CodeArtifact +// resource ARN. A domain ARN of the form arn:aws:codeartifact:{region}:{acct}:domain/{name} +// yields (domain, name); a repository ARN of the form +// arn:aws:codeartifact:{region}:{acct}:repository/{domain}/{repo} yields +// (repository, "{domain}/{repo}"); a package group ARN of the form +// arn:aws:codeartifact:{region}:{acct}:package-group/{domain}{pattern} yields +// (package-group, "{domain}|{pattern}"). Anything else yields ("", ""). +func resourceRefFromARN(arn string) (kind, key string) { + const arnParts = 6 + + if !strings.Contains(arn, ":"+serviceName+":") { + return "", "" + } - switch kind { - case kindDomain: - ok := m.domains.Update(key, func(d driver.Domain) driver.Domain { - d.Tags = ensureTags(d.Tags) - mutate(d.Tags) - - return d - }) - if !ok { - return notFound("Domain %s not found", key) - } + parts := strings.SplitN(arn, ":", arnParts) + if len(parts) < arnParts { + return "", "" + } - return nil - case kindRepository: - ok := m.repos.Update(key, func(r driver.Repository) driver.Repository { - r.Tags = ensureTags(r.Tags) - mutate(r.Tags) - - return r - }) - if !ok { - return notFound("Repository %s not found", key) + slash := strings.IndexByte(parts[arnParts-1], '/') + if slash < 0 { + return "", "" + } + + kind, rest := parts[arnParts-1][:slash], parts[arnParts-1][slash+1:] + + switch kind { + case kindDomain, kindRepository: + return kind, rest + case kindPackageGroup: + i := strings.IndexByte(rest, '/') + if i <= 0 { + return "", "" } - return nil + return kind, groupKey(rest[:i], rest[i:]) default: - return validation("invalid resource ARN: %q", resourceArn) + return "", "" } } - -// ensureTags returns an alias-free, non-nil copy of a tag map ready for mutation. -func ensureTags(in map[string]string) map[string]string { - if in == nil { - return map[string]string{} - } - - return copyTags(in) -} diff --git a/providers/aws/codeartifact/tags_test.go b/providers/aws/codeartifact/tags_test.go new file mode 100644 index 000000000..942025302 --- /dev/null +++ b/providers/aws/codeartifact/tags_test.go @@ -0,0 +1,91 @@ +package codeartifact_test + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +func tagSet(n, offset int) map[string]string { + out := make(map[string]string, n) + for i := 0; i < n; i++ { + out[fmt.Sprintf("k%03d", i+offset)] = "v" + } + + return out +} + +func TestTagLimitBoundary(t *testing.T) { + m := newMock() + d := mustDomain(t, m, "dom") + ctx := context.Background() + + const limit = 200 + + requireNoError(t, m.TagResource(ctx, d.Arn, tagSet(limit, 0))) + + // Overwriting existing keys at the limit adds no new keys. + requireNoError(t, m.TagResource(ctx, d.Arn, map[string]string{"k000": "changed"})) + + err := m.TagResource(ctx, d.Arn, map[string]string{"extra": "v"}) + requireAPIError(t, err, driver.ExServiceQuota) + + requireNoError(t, m.UntagResource(ctx, d.Arn, []string{"k001"})) + requireNoError(t, m.TagResource(ctx, d.Arn, map[string]string{"extra": "v"})) + + tags, err := m.ListTagsForResource(ctx, d.Arn) + requireNoError(t, err) + + if len(tags) != limit || tags["k000"] != "changed" { + t.Fatalf("tags = %d (k000=%q)", len(tags), tags["k000"]) + } +} + +func TestCreateWithTooManyTagsRejected(t *testing.T) { + m := newMock() + + _, err := m.CreateDomain(context.Background(), &driver.CreateDomainInput{Name: "dom", Tags: tagSet(201, 0)}) + requireAPIError(t, err, driver.ExValidation) +} + +func TestTagMalformedAndUnknownARN(t *testing.T) { + m := newMock() + mustDomain(t, m, "dom") + ctx := context.Background() + + err := m.TagResource(ctx, "not-an-arn", map[string]string{"a": "b"}) + requireAPIError(t, err, driver.ExValidation) + + err = m.TagResource(ctx, "arn:aws:codeartifact:us-east-1:123456789012:domain/ghost", map[string]string{"a": "b"}) + requireAPIError(t, err, driver.ExNotFound) + + _, err = m.ListTagsForResource(ctx, "arn:aws:codeartifact:us-east-1:123456789012:repository/dom/ghost") + requireAPIError(t, err, driver.ExNotFound) +} + +func TestTagARNInAnotherAccountOrRegionIsNotFound(t *testing.T) { + opts := config.NewOptions() + m := newMockWithOptions(opts) + d := mustDomain(t, m, "dom") + repo := mustRepo(t, m, "dom", "repo") + group := createGroup(t, m, "/npm/*") + ctx := context.Background() + + for _, arn := range []string{d.Arn, repo.Arn, group.Arn} { + foreignAccount := strings.Replace(arn, ":"+opts.AccountID+":", ":999999999999:", 1) + foreignRegion := strings.Replace(arn, ":"+opts.Region+":", ":ap-south-1:", 1) + + for _, bad := range []string{foreignAccount, foreignRegion, arn + "/extra"} { + requireAPIError(t, m.TagResource(ctx, bad, map[string]string{"a": "b"}), driver.ExNotFound) + + _, err := m.ListTagsForResource(ctx, bad) + requireAPIError(t, err, driver.ExNotFound) + } + + requireNoError(t, m.TagResource(ctx, arn, map[string]string{"a": "b"})) + } +} diff --git a/providers/aws/codeartifact/validation.go b/providers/aws/codeartifact/validation.go new file mode 100644 index 000000000..04fa2c7f9 --- /dev/null +++ b/providers/aws/codeartifact/validation.go @@ -0,0 +1,248 @@ +package codeartifact + +import ( + "strings" + "unicode" + "unicode/utf8" + + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +// Limits of the CodeArtifact API model and the service quotas. +const ( + minDomainLen = 2 + maxDomainLen = 50 + minRepoLen = 2 + maxRepoLen = 100 + ownerLen = 12 + maxDescription = 1000 + maxComponentLen = 255 + maxTagKeyLen = 128 + maxTagValueLen = 256 + maxTags = 200 + maxEncryptionKey = 1011 + maxPolicyRevLen = 100 + maxPolicyBytes = 7168 + maxBulkVersions = 100 + sha256HexLen = 64 + + maxDomains = 10 + maxReposPerDomain = 1000 + maxUpstreams = 10 + maxAssetsPerVersion = 350 +) + +func isLower(c byte) bool { return c >= 'a' && c <= 'z' } + +func isDigit(c byte) bool { return c >= '0' && c <= '9' } + +func isAlnum(c byte) bool { return isLower(c) || (c >= 'A' && c <= 'Z') || isDigit(c) } + +// validDomainName reports whether s matches [a-z][a-z0-9\-]{0,48}[a-z0-9]. +func validDomainName(s string) bool { + if len(s) < minDomainLen || len(s) > maxDomainLen { + return false + } + + last := s[len(s)-1] + if !isLower(s[0]) || !(isLower(last) || isDigit(last)) { + return false + } + + for i := 1; i < len(s)-1; i++ { + if c := s[i]; !isLower(c) && !isDigit(c) && c != '-' { + return false + } + } + + return true +} + +// validRepositoryName reports whether s matches [A-Za-z0-9][A-Za-z0-9._\-]{1,99}. +func validRepositoryName(s string) bool { + if len(s) < minRepoLen || len(s) > maxRepoLen || !isAlnum(s[0]) { + return false + } + + for i := 1; i < len(s); i++ { + if c := s[i]; !isAlnum(c) && c != '.' && c != '_' && c != '-' { + return false + } + } + + return true +} + +// validOwner reports whether s is a 12-digit account id. +func validOwner(s string) bool { + if len(s) != ownerLen { + return false + } + + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + + return true +} + +// hasControl reports whether s contains a Unicode "other" (category C) character. +func hasControl(s string) bool { + for _, r := range s { + if unicode.In(r, unicode.C) { + return true + } + } + + return false +} + +func hasSpace(s string) bool { + for _, r := range s { + if unicode.IsSpace(r) { + return true + } + } + + return false +} + +// validComponent reports whether s is a valid package namespace, name or +// version: 1 to 255 characters, none of # / or whitespace. +func validComponent(s string) bool { + n := utf8.RuneCountInString(s) + + return n >= 1 && n <= maxComponentLen && !strings.ContainsAny(s, "#/") && !hasSpace(s) +} + +// validAssetName reports whether s is a valid asset name: 1 to 255 characters +// without control characters. +func validAssetName(s string) bool { + n := utf8.RuneCountInString(s) + + return n >= 1 && n <= maxComponentLen && !hasControl(s) +} + +func validFormat(s string) bool { + switch s { + case driver.FormatNPM, driver.FormatPyPI, driver.FormatMaven, driver.FormatNuGet, + driver.FormatGeneric, driver.FormatRuby, driver.FormatSwift, driver.FormatCargo: + return true + default: + return false + } +} + +// validateOwner checks an optional domain-owner argument. +func validateOwner(owner string) error { + if owner != "" && !validOwner(owner) { + return validation("domain owner %q must be a 12-digit account id", owner) + } + + return nil +} + +func validateDomainName(name string) error { + if !validDomainName(name) { + return validation("domain name %q must be 2-50 characters: a lowercase letter, then lowercase letters, digits "+ + "or hyphens, ending in a letter or digit", name) + } + + return nil +} + +func validateRepositoryName(name string) error { + if !validRepositoryName(name) { + return validation("repository name %q must be 2-100 characters: a letter or digit, then letters, digits, "+ + "periods, underscores or hyphens", name) + } + + return nil +} + +func validateDescription(desc string) error { + if utf8.RuneCountInString(desc) > maxDescription || hasControl(desc) { + return validation("description must be at most %d characters without control characters", maxDescription) + } + + return nil +} + +// validateDomainRef checks the name and owner of a domain argument. +func validateDomainRef(ref driver.DomainRef) error { + if err := validateDomainName(ref.Domain); err != nil { + return err + } + + return validateOwner(ref.Owner) +} + +// validateRepositoryRef checks the names and owner of a repository argument. +func validateRepositoryRef(ref driver.RepositoryRef) error { + if err := validateDomainRef(driver.DomainRef{Domain: ref.Domain, Owner: ref.Owner}); err != nil { + return err + } + + return validateRepositoryName(ref.Repository) +} + +// validatePackageRef checks the coordinates of a package argument. +func validatePackageRef(ref *driver.PackageRef) error { + err := validateRepositoryRef(driver.RepositoryRef{Domain: ref.Domain, Owner: ref.Owner, Repository: ref.Repository}) + if err != nil { + return err + } + + if !validFormat(ref.Format) { + return validation("package format %q is not one of npm, pypi, maven, nuget, generic, ruby, swift, cargo", ref.Format) + } + + if ref.Namespace != "" && !validComponent(ref.Namespace) { + return validation("package namespace %q is invalid", ref.Namespace) + } + + if !validComponent(ref.Package) { + return validation("package name %q is invalid", ref.Package) + } + + return nil +} + +func validateVersion(version string) error { + if !validComponent(version) { + return validation("package version %q is invalid", version) + } + + return nil +} + +// validateTags checks one request's tag list against the model limits. +func validateTags(tags map[string]string) error { + if len(tags) > maxTags { + return validation("a request may carry at most %d tags", maxTags) + } + + for k, v := range tags { + kn := utf8.RuneCountInString(k) + if kn < 1 || kn > maxTagKeyLen || hasControl(k) { + return validation("tag key %q must be 1-%d characters without control characters", k, maxTagKeyLen) + } + + if utf8.RuneCountInString(v) > maxTagValueLen || hasControl(v) { + return validation("tag value for %q must be at most %d characters without control characters", k, maxTagValueLen) + } + } + + return nil +} + +// validatePolicyRevision checks an optional policy revision argument. +func validatePolicyRevision(rev string) error { + if rev != "" && (len(rev) > maxPolicyRevLen || hasSpace(rev)) { + return validation("policy revision must be 1-%d characters without whitespace", maxPolicyRevLen) + } + + return nil +} diff --git a/providers/aws/codeartifact/validation_test.go b/providers/aws/codeartifact/validation_test.go new file mode 100644 index 000000000..2b0faece2 --- /dev/null +++ b/providers/aws/codeartifact/validation_test.go @@ -0,0 +1,274 @@ +package codeartifact_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/stackshy/cloudemu/v2/config" + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +// requireAPIError asserts err is a typed CodeArtifact error with the given +// exception name and canonical code. +func requireAPIError(t *testing.T, err error, exception string) *driver.APIError { + t.Helper() + + if err == nil { + t.Fatalf("want %s, got nil error", exception) + } + + var apiErr *driver.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("want *driver.APIError(%s), got %T: %v", exception, err, err) + } + + if apiErr.Exception != exception { + t.Fatalf("exception = %q, want %q (err: %v)", apiErr.Exception, exception, err) + } + + return apiErr +} + +func TestDomainNamePattern(t *testing.T) { + cases := []struct { + name string + ok bool + }{ + {"ab", true}, + {"a1", true}, + {"my-domain", true}, + {strings.Repeat("a", 50), true}, + {"a", false}, + {strings.Repeat("a", 51), false}, + {"1abc", false}, + {"-abc", false}, + {"abc-", false}, + {"Abc", false}, + {"my domain", false}, + {"my_domain", false}, + {"dömain", false}, + {"", false}, + } + + for _, tc := range cases { + m := newMock() + _, err := m.CreateDomain(context.Background(), &driver.CreateDomainInput{Name: tc.name}) + + if tc.ok { + requireNoError(t, err) + + continue + } + + requireAPIError(t, err, driver.ExValidation) + + got, _, listErr := m.ListDomains(context.Background(), driver.Page{}) + requireNoError(t, listErr) + + if len(got) != 0 { + t.Fatalf("name %q: rejected create stored %d domains", tc.name, len(got)) + } + } +} + +func TestRepositoryNamePattern(t *testing.T) { + cases := []struct { + name string + ok bool + }{ + {"ab", true}, + {"My.Repo_1-x", true}, + {strings.Repeat("a", 100), true}, + {"a", false}, + {strings.Repeat("a", 101), false}, + {".abc", false}, + {"-abc", false}, + {"a b", false}, + {"a/b", false}, + {"", false}, + } + + for _, tc := range cases { + m := newMock() + mustDomain(t, m, "dom") + + _, err := m.CreateRepository(context.Background(), &driver.CreateRepositoryInput{Domain: "dom", Repository: tc.name}) + + if tc.ok { + requireNoError(t, err) + + continue + } + + requireAPIError(t, err, driver.ExValidation) + } +} + +func TestOwnerAndDescriptionPatterns(t *testing.T) { + m := newMock() + mustDomain(t, m, "dom") + ctx := context.Background() + + for _, owner := range []string{"1234567890", "12345678901", "1234567890123", "12345678901a", " 23456789012"} { + _, err := m.DescribeDomain(ctx, "dom", owner) + requireAPIError(t, err, driver.ExValidation) + } + + _, err := m.CreateRepository(ctx, &driver.CreateRepositoryInput{ + Domain: "dom", Repository: "r1", Description: strings.Repeat("d", 1001), + }) + requireAPIError(t, err, driver.ExValidation) + + _, err = m.CreateRepository(ctx, &driver.CreateRepositoryInput{ + Domain: "dom", Repository: "r2", Description: strings.Repeat("d", 1000), + }) + requireNoError(t, err) + + _, err = m.CreateRepository(ctx, &driver.CreateRepositoryInput{ + Domain: "dom", Repository: "r3", Description: "bad\x00control", + }) + requireAPIError(t, err, driver.ExValidation) +} + +func TestCreateDomainRejectsInvalidNameBeforeMutation(t *testing.T) { + m := newMock() + + for _, bad := range []string{"my domain", "UPPER", "a"} { + _, err := m.CreateDomain(context.Background(), &driver.CreateDomainInput{Name: bad}) + requireAPIError(t, err, driver.ExValidation) + } + + got, _, err := m.ListDomains(context.Background(), driver.Page{}) + requireNoError(t, err) + + if len(got) != 0 { + t.Fatalf("rejected creates left %d domains", len(got)) + } +} + +func TestLookupsValidateNames(t *testing.T) { + m := newMock() + mustDomain(t, m, "dom") + mustRepo(t, m, "dom", "repo") + + ctx := context.Background() + desc := "d" + + checks := map[string]error{} + + _, checks["DescribeDomain"] = m.DescribeDomain(ctx, "Bad Domain", "") + _, checks["DeleteDomain"] = m.DeleteDomain(ctx, "Bad Domain", "") + _, checks["DescribeRepository/domain"] = m.DescribeRepository(ctx, "Bad Domain", "", "repo") + _, checks["DescribeRepository/repo"] = m.DescribeRepository(ctx, "dom", "", "bad repo") + _, checks["DeleteRepository"] = m.DeleteRepository(ctx, "dom", "", "bad repo") + _, checks["UpdateRepository"] = m.UpdateRepository(ctx, &driver.UpdateRepositoryInput{ + Domain: "dom", Repository: "bad repo", Description: &desc, + }) + _, _, checks["ListRepositoriesInDomain"] = m.ListRepositoriesInDomain(ctx, "Bad Domain", "", "", driver.Page{}) + _, checks["Associate"] = m.AssociateExternalConnection(ctx, "dom", "", "bad repo", "public:npmjs") + + for op, err := range checks { + if err == nil { + t.Errorf("%s: want ValidationException, got nil", op) + + continue + } + + requireAPIError(t, err, driver.ExValidation) + } +} + +func TestDomainOwnerHonouredOnLookups(t *testing.T) { + m := newMock() + d := mustDomain(t, m, "dom") + mustRepo(t, m, "dom", "repo") + + ctx := context.Background() + foreign := "999999999999" + + if foreign == d.Owner { + t.Fatal("test owner collides with the emulator account") + } + + _, err := m.DescribeDomain(ctx, "dom", foreign) + requireAPIError(t, err, driver.ExNotFound) + + _, err = m.DeleteDomain(ctx, "dom", foreign) + requireAPIError(t, err, driver.ExNotFound) + + _, err = m.DescribeRepository(ctx, "dom", foreign, "repo") + requireAPIError(t, err, driver.ExNotFound) + + _, _, err = m.ListRepositoriesInDomain(ctx, "dom", foreign, "", driver.Page{}) + requireAPIError(t, err, driver.ExNotFound) + + _, err = m.DescribeDomain(ctx, "dom", d.Owner) + requireNoError(t, err) + + _, err = m.DescribeDomain(ctx, "dom", "") + requireNoError(t, err) +} + +func TestCreateRepositoryForeignDomainOwnerRejected(t *testing.T) { + m := newMock() + mustDomain(t, m, "dom") + + _, err := m.CreateRepository(context.Background(), &driver.CreateRepositoryInput{ + Domain: "dom", DomainOwner: "999999999999", Repository: "repo", + }) + requireAPIError(t, err, driver.ExNotFound) + + got, _, err := m.ListRepositories(context.Background(), "", driver.Page{}) + requireNoError(t, err) + + if len(got) != 0 { + t.Fatalf("rejected create stored %d repositories", len(got)) + } +} + +func TestCreateRepositoryRecordsAdministratorAndOwner(t *testing.T) { + opts := config.NewOptions() + m := newMockWithOptions(opts) + d := mustDomain(t, m, "dom") + r := mustRepo(t, m, "dom", "repo") + + if r.DomainOwner != d.Owner || r.AdministratorAccount != opts.AccountID { + t.Fatalf("owner=%q admin=%q, want owner=%q admin=%q", r.DomainOwner, r.AdministratorAccount, d.Owner, opts.AccountID) + } +} + +func TestNotFoundAndConflictCarryResourceIdentity(t *testing.T) { + m := newMock() + mustDomain(t, m, "dom") + + _, err := m.CreateDomain(context.Background(), &driver.CreateDomainInput{Name: "dom"}) + e := requireAPIError(t, err, driver.ExConflict) + + if e.ResourceID != "dom" || e.ResourceType != driver.ResourceTypeDomain { + t.Fatalf("conflict identity = %q/%q", e.ResourceID, e.ResourceType) + } + + _, err = m.DescribeRepository(context.Background(), "dom", "", "nope-repo") + e = requireAPIError(t, err, driver.ExNotFound) + + if e.ResourceType != driver.ResourceTypeRepository { + t.Fatalf("not-found resource type = %q", e.ResourceType) + } + + if !cerrors.IsNotFound(err) { + t.Fatalf("canonical code lost: %v", err) + } +} + +func TestValidationErrorsCarryFieldReason(t *testing.T) { + m := newMock() + _, err := m.CreateDomain(context.Background(), &driver.CreateDomainInput{Name: "A"}) + e := requireAPIError(t, err, driver.ExValidation) + + if e.Reason != driver.ReasonFieldValidationFailed { + t.Fatalf("reason = %q, want %q", e.Reason, driver.ReasonFieldValidationFailed) + } +} diff --git a/providers/aws/lifecycle_events_test.go b/providers/aws/lifecycle_events_test.go index f94597212..2f83e9e8a 100644 --- a/providers/aws/lifecycle_events_test.go +++ b/providers/aws/lifecycle_events_test.go @@ -12,6 +12,7 @@ import ( "github.com/stackshy/cloudemu/v2/providers/aws/ec2" smmock "github.com/stackshy/cloudemu/v2/providers/aws/sagemaker" "github.com/stackshy/cloudemu/v2/providers/aws/ssm" + cadriver "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" computedriver "github.com/stackshy/cloudemu/v2/services/compute/driver" crdriver "github.com/stackshy/cloudemu/v2/services/containerregistry/driver" ecsdriver "github.com/stackshy/cloudemu/v2/services/ecs/driver" @@ -1335,3 +1336,65 @@ func TestSageMakerEvents_UnsetTrainingTimesAreOmitted(t *testing.T) { t.Fatalf("CreationTime = %v, want the creation time in epoch milliseconds", d["CreationTime"]) } } + +// TestCodeArtifactPackageVersionEvents pins that publishing, archiving and +// deleting a package version reach a rule target through the real EventBridge +// bus with the documented envelope and detail. +func TestCodeArtifactPackageVersionEvents(t *testing.T) { + p := aws.New() + ctx := context.Background() + drain := captureEvents(t, p, `{"source":["aws.codeartifact"],"detail-type":["CodeArtifact Package Version State Change"]}`) + + if _, err := p.CodeArtifact.CreateDomain(ctx, &cadriver.CreateDomainInput{Name: "dom"}); err != nil { + t.Fatal(err) + } + + if _, err := p.CodeArtifact.CreateRepository(ctx, &cadriver.CreateRepositoryInput{Domain: "dom", Repository: "repo"}); err != nil { + t.Fatal(err) + } + + ref := cadriver.PackageRef{Domain: "dom", Repository: "repo", Format: "generic", Namespace: "ns", Package: "pkg"} + content := []byte("x") + + if _, err := p.CodeArtifact.PublishPackageVersion(ctx, &cadriver.PublishInput{ + Ref: ref, Version: "1.0.0", AssetName: "f", + SHA256: "2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881", Content: content, + }); err != nil { + t.Fatalf("PublishPackageVersion: %v", err) + } + + if _, err := p.CodeArtifact.UpdatePackageVersionsStatus(ctx, &cadriver.UpdateStatusInput{ + VersionsInput: cadriver.VersionsInput{Ref: ref, Versions: []string{"1.0.0"}}, TargetStatus: cadriver.VersionStatusArchived, + }); err != nil { + t.Fatalf("UpdatePackageVersionsStatus: %v", err) + } + + if _, err := p.CodeArtifact.DeletePackageVersions(ctx, &cadriver.VersionsInput{Ref: ref, Versions: []string{"1.0.0"}}); err != nil { + t.Fatalf("DeletePackageVersions: %v", err) + } + + evs := drain() + if len(evs) != 3 { + t.Fatalf("events = %d, want 3", len(evs)) + } + + requireEnvelope(t, &evs[0], "aws.codeartifact", "CodeArtifact Package Version State Change") + + if want := "arn:aws:codeartifact:us-east-1:" + testAccountID + ":package/dom/repo/generic/ns/pkg"; len(evs[0].Resources) != 1 || evs[0].Resources[0] != want { + t.Fatalf("resources = %v, want [%s]", evs[0].Resources, want) + } + + wantOps := []struct{ op, state string }{{"Created", "Published"}, {"Updated", "Archived"}, {"Deleted", "Archived"}} + + for i, w := range wantOps { + d := detailOf(t, &evs[i]) + if d["operationType"] != w.op || d["packageVersionState"] != w.state || d["packageName"] != "pkg" || + d["domainOwner"] != testAccountID || d["repositoryAdministrator"] != testAccountID { + t.Errorf("event %d detail = %s", i, evs[i].Detail) + } + + if seq, _ := d["sequenceNumber"].(float64); int(seq) != i+1 { + t.Errorf("event %d sequenceNumber = %v, want %d", i, d["sequenceNumber"], i+1) + } + } +} diff --git a/providers/aws/rgt_discovery.go b/providers/aws/rgt_discovery.go index ef29b7bb5..431dd8196 100644 --- a/providers/aws/rgt_discovery.go +++ b/providers/aws/rgt_discovery.go @@ -8,6 +8,7 @@ import ( "github.com/stackshy/cloudemu/v2/internal/idgen" acmdriver "github.com/stackshy/cloudemu/v2/services/acm/driver" cloudtraildriver "github.com/stackshy/cloudemu/v2/services/cloudtrail/driver" + cadriver "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" configservicedriver "github.com/stackshy/cloudemu/v2/services/configservice/driver" ecsdriver "github.com/stackshy/cloudemu/v2/services/ecs/driver" ebdriver "github.com/stackshy/cloudemu/v2/services/eventbus/driver" @@ -50,7 +51,7 @@ func (d taggedDiscovery) collectors() []func(context.Context) ([]resourcediscove d.discoverSFN, d.discoverSSM, d.discoverKMS, d.discoverKinesis, d.discoverGuardDuty, d.discoverCloudTrail, d.discoverConfig, d.discoverWAFv2, d.discoverNetworkFirewall, d.discoverSESv2, d.discoverVPCLattice, d.discoverMemoryDB, - d.discoverKeyspaces, d.discoverResolver, d.discoverEventBridge, d.discoverACM, + d.discoverKeyspaces, d.discoverResolver, d.discoverEventBridge, d.discoverACM, d.discoverCodeArtifact, } } @@ -320,6 +321,58 @@ func (d taggedDiscovery) discoverACM(ctx context.Context) ([]resourcediscovery.D }) } +// discoverCodeArtifact lists CodeArtifact domains, repositories and package +// groups. Each carries its tags in the same store the tagger mutates. +func (d taggedDiscovery) discoverCodeArtifact(ctx context.Context) ([]resourcediscovery.DiscoveredResource, error) { + ca := d.p.CodeArtifact + + domains, _, err := ca.ListDomains(ctx, cadriver.Page{MaxResults: pageLimit}) + if err != nil { + return nil, err + } + + out, err := project(ctx, domains, func(_ context.Context, dom *cadriver.Domain) (resourcediscovery.DiscoveredResource, error) { + return row("codeartifact", "domain", dom.Name, dom.Arn, dom.Tags), nil + }) + if err != nil { + return nil, err + } + + repos, _, err := ca.ListRepositories(ctx, "", cadriver.Page{MaxResults: pageLimit}) + if err != nil { + return nil, err + } + + rows, err := project(ctx, repos, func(_ context.Context, r *cadriver.Repository) (resourcediscovery.DiscoveredResource, error) { + return row("codeartifact", "repository", r.Name, r.Arn, r.Tags), nil + }) + if err != nil { + return nil, err + } + + out = append(out, rows...) + + for _, dom := range domains { + groups, _, gerr := ca.ListPackageGroups(ctx, cadriver.DomainRef{Domain: dom.Name}, "", cadriver.Page{MaxResults: pageLimit}) + if gerr != nil { + return nil, gerr + } + + rows, err = project(ctx, groups, func(ctx context.Context, g *cadriver.PackageGroup) (resourcediscovery.DiscoveredResource, error) { + tags, terr := ca.ListTagsForResource(ctx, g.Arn) + + return row("codeartifact", "package-group", g.Pattern, g.Arn, tags), terr + }) + if err != nil { + return nil, err + } + + out = append(out, rows...) + } + + return out, nil +} + func (d taggedDiscovery) discoverCloudTrail(ctx context.Context) ([]resourcediscovery.DiscoveredResource, error) { trails, err := d.p.CloudTrail.DescribeTrails(ctx, nil) if err != nil { diff --git a/providers/aws/rgt_discovery_test.go b/providers/aws/rgt_discovery_test.go index a25925250..c9988a86f 100644 --- a/providers/aws/rgt_discovery_test.go +++ b/providers/aws/rgt_discovery_test.go @@ -3,6 +3,7 @@ package aws import ( "context" "errors" + cadriver "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" "strings" "sync" "testing" @@ -605,3 +606,66 @@ func mustNoErr(t *testing.T, err error) { t.Fatal(err) } } + +func TestCodeArtifactDiscoveryAndTagging(t *testing.T) { + ctx := context.Background() + p := New() + ca := p.CodeArtifact + + dom, err := ca.CreateDomain(ctx, &cadriver.CreateDomainInput{Name: "dom", Tags: map[string]string{"env": "test"}}) + if err != nil { + t.Fatal(err) + } + + repo, err := ca.CreateRepository(ctx, &cadriver.CreateRepositoryInput{Domain: "dom", Repository: "repo"}) + if err != nil { + t.Fatal(err) + } + + group, err := ca.CreatePackageGroup(ctx, &cadriver.CreatePackageGroupInput{ + Domain: cadriver.DomainRef{Domain: "dom"}, Pattern: "/npm/*", Tags: map[string]string{"team": "a"}, + }) + if err != nil { + t.Fatal(err) + } + + rows, err := taggedDiscovery{p}.discoverCodeArtifact(ctx) + if err != nil { + t.Fatal(err) + } + + want := map[string]resourcediscovery.DiscoveredResource{ + "domain": {Service: "codeartifact", Type: "domain", ID: "dom", ARN: dom.Arn, Tags: map[string]string{"env": "test"}}, + "repository": {Service: "codeartifact", Type: "repository", ID: "repo", ARN: repo.Arn}, + "package-group": {Service: "codeartifact", Type: "package-group", ID: "/npm/*", ARN: group.Arn, Tags: map[string]string{"team": "a"}}, + } + + if len(rows) != len(want) { + t.Fatalf("rows = %+v", rows) + } + + for _, r := range rows { + w := want[r.Type] + if r.Service != w.Service || r.ID != w.ID || r.ARN != w.ARN || len(r.Tags) != len(w.Tags) { + t.Errorf("%s row = %+v, want %+v", r.Type, r, w) + } + } + + tagger := awsTaggers(p)["codeartifact"] + if tagger == nil { + t.Fatal("no codeartifact tagger registered") + } + + if err = tagger.TagByARN(ctx, repo.Arn, map[string]string{"via": "rgt"}); err != nil { + t.Fatal(err) + } + + tags, err := ca.ListTagsForResource(ctx, repo.Arn) + if err != nil || tags["via"] != "rgt" { + t.Fatalf("tags after RGT tagging = %v err=%v", tags, err) + } + + if err = tagger.UntagByARN(ctx, repo.Arn, []string{"via"}); err != nil { + t.Fatal(err) + } +} diff --git a/providers/aws/rgt_taggers.go b/providers/aws/rgt_taggers.go index 0078e7ae9..73a0a6a01 100644 --- a/providers/aws/rgt_taggers.go +++ b/providers/aws/rgt_taggers.go @@ -24,7 +24,7 @@ import ( // through GetResources). Split into grouped builders to stay within the // per-function length budget. func awsTaggers(p *Provider) map[string]resourcediscovery.ARNTagger { - const taggerCount = 28 + const taggerCount = 29 m := make(map[string]resourcediscovery.ARNTagger, taggerCount) addKeyedTaggers(m, p) @@ -77,6 +77,7 @@ func (f funcTagger) UntagByARN(ctx context.Context, arn string, keys []string) e // the file-system/access-point id and KMS by the key id, so those two supply a // key extractor. func addKeyedTaggers(m map[string]resourcediscovery.ARNTagger, p *Provider) { + m["codeartifact"] = keyedTagger{p.CodeArtifact, fullARN} m["config"] = keyedTagger{p.Config, fullARN} m["ecs"] = keyedTagger{ecsMapStore{p.ECS}, fullARN} m["eks"] = keyedTagger{p.EKS, fullARN} diff --git a/server/aws/cloudtrail_recording.go b/server/aws/cloudtrail_recording.go index 805dfd5cb..6277da3e2 100644 --- a/server/aws/cloudtrail_recording.go +++ b/server/aws/cloudtrail_recording.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" + codeartifactsrv "github.com/stackshy/cloudemu/v2/server/aws/codeartifact" vpclatticesrv "github.com/stackshy/cloudemu/v2/server/aws/vpclattice" "github.com/stackshy/cloudemu/v2/server/wire/awsquery" cloudtraildriver "github.com/stackshy/cloudemu/v2/services/cloudtrail/driver" @@ -24,6 +25,10 @@ func recordManagementEvent(rec cloudtraildriver.EventRecorder, r *http.Request) if name == "" && svc == vpclatticesrv.SigningName { name = vpclatticesrv.OperationName(r.Method, r.URL.Path) } + // CodeArtifact is REST-JSON too, routed on method and path. + if name == "" && svc == codeartifactsrv.SigningName { + name = codeartifactsrv.OperationName(r.Method, r.URL.Path) + } if name == "" { return diff --git a/server/aws/cloudtrail_recording_test.go b/server/aws/cloudtrail_recording_test.go index 73cb46e15..087209995 100644 --- a/server/aws/cloudtrail_recording_test.go +++ b/server/aws/cloudtrail_recording_test.go @@ -9,6 +9,7 @@ import ( awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" awsct "github.com/aws/aws-sdk-go-v2/service/cloudtrail" + awsca "github.com/aws/aws-sdk-go-v2/service/codeartifact" awsvpcl "github.com/aws/aws-sdk-go-v2/service/vpclattice" "github.com/stackshy/cloudemu/v2" @@ -65,3 +66,58 @@ func TestRecordsLatticeEvents(t *testing.T) { } } } + +// TestRecordsCodeArtifactEvents pins that REST-JSON CodeArtifact calls, routed on +// method and path, show up in CloudTrail LookupEvents with the right event name, +// source and read-only flag. +func TestRecordsCodeArtifactEvents(t *testing.T) { + ctx := context.Background() + cloud := cloudemu.NewAWS() + ts := httptest.NewServer(awsserver.New(awsserver.DriversFrom(cloud))) + t.Cleanup(ts.Close) + + cfg, err := awsconfig.LoadDefaultConfig(ctx, + awsconfig.WithRegion("us-east-1"), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", ""))) + if err != nil { + t.Fatalf("aws config: %v", err) + } + + cfg.BaseEndpoint = aws.String(ts.URL) + artifact := awsca.NewFromConfig(cfg) + + if _, err = artifact.CreateDomain(ctx, &awsca.CreateDomainInput{Domain: aws.String("audited")}); err != nil { + t.Fatalf("CreateDomain: %v", err) + } + + if _, err = artifact.ListDomains(ctx, &awsca.ListDomainsInput{}); err != nil { + t.Fatalf("ListDomains: %v", err) + } + + if _, err = artifact.GetAuthorizationToken(ctx, &awsca.GetAuthorizationTokenInput{Domain: aws.String("audited")}); err != nil { + t.Fatalf("GetAuthorizationToken: %v", err) + } + + out, err := awsct.NewFromConfig(cfg).LookupEvents(ctx, &awsct.LookupEventsInput{}) + if err != nil { + t.Fatalf("LookupEvents: %v", err) + } + + sources := map[string]string{} + readOnly := map[string]string{} + + for _, e := range out.Events { + sources[aws.ToString(e.EventName)] = aws.ToString(e.EventSource) + readOnly[aws.ToString(e.EventName)] = aws.ToString(e.ReadOnly) + } + + if readOnly["CreateDomain"] != "false" || readOnly["ListDomains"] != "true" { + t.Fatalf("readOnly flags wrong: %v", readOnly) + } + + for _, want := range []string{"CreateDomain", "ListDomains", "GetAuthorizationToken"} { + if src, ok := sources[want]; !ok || src != "codeartifact.amazonaws.com" { + t.Fatalf("%s not recorded from codeartifact.amazonaws.com; events = %v", want, sources) + } + } +} diff --git a/server/aws/codeartifact/access.go b/server/aws/codeartifact/access.go new file mode 100644 index 000000000..fd8c3ea73 --- /dev/null +++ b/server/aws/codeartifact/access.go @@ -0,0 +1,45 @@ +package codeartifact + +import ( + "net/http" + "strconv" +) + +func (h *Handler) getAuthorizationToken(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + var seconds int64 + + if s := q.Get("duration"); s != "" { + n, err := strconv.ParseInt(s, 10, 64) + if err != nil { + writeValidation(w, "duration must be an integer number of seconds") + + return + } + + seconds = n + } + + tok, err := h.access.GetAuthorizationToken(r.Context(), domainRefFromQuery(q), seconds) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, map[string]any{"authorizationToken": tok.Token, "expiration": epochSeconds(tok.Expiration)}) +} + +func (h *Handler) getRepositoryEndpoint(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + endpoint, err := h.access.GetRepositoryEndpoint(r.Context(), repoRefFromQuery(q), q.Get("format"), q.Get("endpointType")) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, map[string]any{"repositoryEndpoint": endpoint}) +} diff --git a/server/aws/codeartifact/authz.go b/server/aws/codeartifact/authz.go new file mode 100644 index 000000000..be273a25f --- /dev/null +++ b/server/aws/codeartifact/authz.go @@ -0,0 +1,178 @@ +package codeartifact + +import ( + "net/http" + "net/url" + "strings" + + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" +) + +// Compile-time check that Handler names the IAM actions of its requests. +var _ awsauthz.Resolver = (*Handler)(nil) + +// resourceKind says which ARN an operation is authorized against. +type resourceKind int + +const ( + resNone resourceKind = iota + resDomain + resRepository + resPackage + resGroup + resTagged +) + +// resourceKinds maps an operation to the resource its IAM action applies to. +// Operations absent from the map apply to no resource ("*"). +func resourceKinds() map[string]resourceKind { + kinds := map[string]resourceKind{} + + for _, op := range []string{ + "CreateDomain", "DescribeDomain", "DeleteDomain", "ListRepositoriesInDomain", opGetAuthorizationToken, + opPutDomainPolicy, "GetDomainPermissionsPolicy", "DeleteDomainPermissionsPolicy", + "CreatePackageGroup", "ListPackageGroups", "GetAssociatedPackageGroup", + } { + kinds[op] = resDomain + } + + for _, op := range []string{ + "CreateRepository", "DescribeRepository", "UpdateRepository", "DeleteRepository", "AssociateExternalConnection", + "DisassociateExternalConnection", "GetRepositoryEndpoint", "PutRepositoryPermissionsPolicy", + "GetRepositoryPermissionsPolicy", "DeleteRepositoryPermissionsPolicy", "ListPackages", + } { + kinds[op] = resRepository + } + + for _, op := range []string{ + "DescribePackage", "PutPackageOriginConfiguration", "DeletePackage", "ListPackageVersions", "DescribePackageVersion", + "PublishPackageVersion", "ListPackageVersionAssets", "GetPackageVersionAsset", "GetPackageVersionReadme", + "ListPackageVersionDependencies", "UpdatePackageVersionsStatus", "DisposePackageVersions", "DeletePackageVersions", + opCopyPackageVersions, + } { + kinds[op] = resPackage + } + + for _, op := range []string{ + "DescribePackageGroup", opUpdatePackageGroup, "DeletePackageGroup", "UpdatePackageGroupOriginConfiguration", + "ListSubPackageGroups", "ListAssociatedPackages", "ListAllowedRepositoriesForGroup", + } { + kinds[op] = resGroup + } + + for _, op := range []string{"TagResource", "UntagResource", "ListTagsForResource"} { + kinds[op] = resTagged + } + + return kinds +} + +// IAMChecks names the IAM actions a request needs: codeartifact: +// against the domain, repository, package or package group it names, plus +// sts:GetServiceBearerToken for GetAuthorizationToken. ok=false means the +// request is not a CodeArtifact operation, and ServeHTTP then answers it without +// side effects. +func (*Handler) IAMChecks(r *http.Request, s awsauthz.Scope) ([]awsauthz.Check, bool) { + op := OperationName(r.Method, r.URL.Path) + if op == "" { + return nil, false + } + + checks := []awsauthz.Check{{Action: "codeartifact:" + op, Resource: resourceARN(op, r, s)}} + + if op == opGetAuthorizationToken { + checks = append(checks, awsauthz.Check{Action: "sts:GetServiceBearerToken", Resource: "*"}) + } + + return checks, true +} + +// resourceARN builds the ARN of the resource an operation names. An empty result +// means the request does not name it, which the gate evaluates conservatively. +func resourceARN(op string, r *http.Request, s awsauthz.Scope) string { + q := r.URL.Query() + + switch resourceKinds()[op] { + case resNone: + return "*" + case resDomain: + return domainARN(op, r, q, s) + case resRepository: + return repositoryARN(q, s) + case resPackage: + return packageARN(op, q, s) + case resGroup: + return groupARN(op, r, q, s) + case resTagged: + return q.Get("resourceArn") + default: + return "*" + } +} + +// domainARN names the domain of an operation; PutDomainPermissionsPolicy carries +// it in the body. +func domainARN(op string, r *http.Request, q url.Values, s awsauthz.Scope) string { + domain := q.Get("domain") + + if op == opPutDomainPolicy { + var body putDomainPolicyRequest + if !awsauthz.JSONBody(r, &body) { + return "" + } + + domain = body.Domain + } + + if domain == "" { + return "" + } + + return s.ARN("codeartifact", "domain/"+domain) +} + +func repositoryARN(q url.Values, s awsauthz.Scope) string { + if q.Get("domain") == "" || q.Get("repository") == "" { + return "" + } + + return s.ARN("codeartifact", "repository/"+q.Get("domain")+"/"+q.Get("repository")) +} + +// packageARN names a package, or for CopyPackageVersions the package in the +// destination repository. +func packageARN(op string, q url.Values, s awsauthz.Scope) string { + repo := q.Get("repository") + if op == opCopyPackageVersions { + repo = q.Get("destination-repository") + } + + if q.Get("domain") == "" || repo == "" { + return "" + } + + return s.ARN("codeartifact", "package/"+strings.Join([]string{ + q.Get("domain"), repo, q.Get("format"), q.Get("namespace"), q.Get("package"), + }, "/")) +} + +// groupARN names a package group; UpdatePackageGroup carries the pattern in the +// body. +func groupARN(op string, r *http.Request, q url.Values, s awsauthz.Scope) string { + pattern := q.Get("package-group") + + if op == opUpdatePackageGroup { + var body updatePackageGroupRequest + if !awsauthz.JSONBody(r, &body) { + return "" + } + + pattern = body.PackageGroup + } + + if q.Get("domain") == "" || pattern == "" { + return "" + } + + return s.ARN("codeartifact", "package-group/"+q.Get("domain")+pattern) +} diff --git a/server/aws/codeartifact/authz_test.go b/server/aws/codeartifact/authz_test.go new file mode 100644 index 000000000..909e9a647 --- /dev/null +++ b/server/aws/codeartifact/authz_test.go @@ -0,0 +1,108 @@ +package codeartifact_test + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/stackshy/cloudemu/v2/server/wire/awsauthz" +) + +var testScope = awsauthz.Scope{AccountID: "123456789012", Region: "us-east-1", Partition: "aws"} + +func TestIAMChecksCoverEveryOperation(t *testing.T) { + ops := loadModel(t) + h := fullHandler() + + for name, op := range ops { + req := httptest.NewRequest(op.Method, op.Path+"?"+validationProbeQuery(name), strings.NewReader(validationProbe(name).body)) + + checks, ok := h.IAMChecks(req, testScope) + if !ok || len(checks) == 0 { + t.Errorf("%s: no IAM checks", name) + + continue + } + + if checks[0].Action != "codeartifact:"+name { + t.Errorf("%s: action = %q", name, checks[0].Action) + } + } + + req := httptest.NewRequest(http.MethodGet, "/v1/nothing", nil) + if _, ok := h.IAMChecks(req, testScope); ok { + t.Error("an unknown path must not resolve") + } +} + +func validationProbeQuery(op string) string { + return validationProbe(op).query.Encode() +} + +func TestIAMChecksNameTheResource(t *testing.T) { + h := fullHandler() + prefix := "arn:aws:codeartifact:us-east-1:123456789012:" + + cases := []struct { + name, method, path string + query url.Values + body string + resource string + }{ + {"domain", http.MethodGet, "/v1/domain", url.Values{"domain": {"dom"}}, "", prefix + "domain/dom"}, + {"repository", http.MethodGet, "/v1/repository", url.Values{"domain": {"dom"}, "repository": {"repo"}}, "", prefix + "repository/dom/repo"}, + {"package", http.MethodGet, "/v1/package", url.Values{"domain": {"dom"}, "repository": {"repo"}, "format": {"generic"}, "namespace": {"ns"}, "package": {"p"}}, "", prefix + "package/dom/repo/generic/ns/p"}, + {"npm package without namespace", http.MethodGet, "/v1/package", url.Values{"domain": {"dom"}, "repository": {"repo"}, "format": {"npm"}, "package": {"p"}}, "", prefix + "package/dom/repo/npm//p"}, + {"copy targets the destination", http.MethodPost, "/v1/package/versions/copy", url.Values{"domain": {"dom"}, "destination-repository": {"dst"}, "source-repository": {"src"}, "format": {"npm"}, "package": {"p"}}, "", prefix + "package/dom/dst/npm//p"}, + {"group", http.MethodGet, "/v1/package-group", url.Values{"domain": {"dom"}, "package-group": {"/npm/*"}}, "", prefix + "package-group/dom/npm/*"}, + {"group update reads the body", http.MethodPut, "/v1/package-group", url.Values{"domain": {"dom"}}, `{"packageGroup":"/npm/space/*"}`, prefix + "package-group/dom/npm/space/*"}, + {"domain policy put reads the body", http.MethodPut, "/v1/domain/permissions/policy", nil, `{"domain":"dom","policyDocument":"{}"}`, prefix + "domain/dom"}, + {"tags use the ARN", http.MethodPost, "/v1/tags", url.Values{"resourceArn": {prefix + "domain/dom"}}, "", prefix + "domain/dom"}, + {"list domains has no resource", http.MethodPost, "/v1/domains", nil, "{}", "*"}, + {"missing names are unresolved", http.MethodGet, "/v1/repository", url.Values{"domain": {"dom"}}, "", ""}, + } + + for _, c := range cases { + req := httptest.NewRequest(c.method, c.path+"?"+c.query.Encode(), strings.NewReader(c.body)) + + checks, ok := h.IAMChecks(req, testScope) + if !ok || checks[0].Resource != c.resource { + t.Errorf("%s: resource = %q (ok=%v), want %q", c.name, firstResource(checks), ok, c.resource) + } + } + + req := httptest.NewRequest(http.MethodPost, "/v1/authorization-token?domain=dom", nil) + + checks, _ := h.IAMChecks(req, testScope) + if len(checks) != 2 || checks[1].Action != "sts:GetServiceBearerToken" { + t.Errorf("GetAuthorizationToken checks = %+v, want codeartifact + sts:GetServiceBearerToken", checks) + } +} + +func firstResource(checks []awsauthz.Check) string { + if len(checks) == 0 { + return "" + } + + return checks[0].Resource +} + +func TestIAMChecksPutPolicyBodyStillReadableByHandler(t *testing.T) { + h := fullHandler() + body := `{"domain":"dom","policyDocument":"{}"}` + req := httptest.NewRequest(http.MethodPut, "/v1/domain/permissions/policy", strings.NewReader(body)) + + if _, ok := h.IAMChecks(req, testScope); !ok { + t.Fatal("resolver refused") + } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + // The body survived the resolver: the handler saw a domain, not an empty request. + if strings.Contains(rec.Body.String(), "invalid JSON") { + t.Fatalf("handler could not read the body after the resolver: %s", rec.Body.String()) + } +} diff --git a/server/aws/codeartifact/domain.go b/server/aws/codeartifact/domain.go index ade5c3db8..4eda4a5cf 100644 --- a/server/aws/codeartifact/domain.go +++ b/server/aws/codeartifact/domain.go @@ -1,23 +1,22 @@ package codeartifact import ( - "encoding/json" "net/http" - - "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" ) +// createDomainRequest is the CreateDomain body. +type createDomainRequest struct { + EncryptionKey string `json:"encryptionKey"` + Tags []tagPair `json:"tags"` +} + func (h *Handler) createDomain(w http.ResponseWriter, r *http.Request) { - raw, ok := decodeBodyMap(w, r) - if !ok { + var req createDomainRequest + if !decodeBody(w, r, &req) { return } - out, err := h.ca.CreateDomain(r.Context(), &driver.CreateDomainInput{ - Name: r.URL.Query().Get("domain"), - EncryptionKey: rawString(raw, "encryptionKey"), - Tags: tagsFromBody(raw), - }) + out, err := h.ca.CreateDomain(r.Context(), newCreateDomainInput(r.URL.Query().Get("domain"), &req)) if err != nil { writeErr(w, err) @@ -45,7 +44,7 @@ func (h *Handler) deleteDomain(w http.ResponseWriter, r *http.Request) { out, err := h.ca.DeleteDomain(r.Context(), q.Get("domain"), q.Get("domain-owner")) if err != nil { - writeErr(w, err) + writeErrNoNotFound(w, err) return } @@ -53,16 +52,24 @@ func (h *Handler) deleteDomain(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"domain": domainToWire(out)}) } +// listPageRequest is the body of the operations that carry paging in the body. +type listPageRequest struct { + MaxResults *int32 `json:"maxResults"` + NextToken string `json:"nextToken"` +} + func (h *Handler) listDomains(w http.ResponseWriter, r *http.Request) { - raw, ok := decodeBodyMap(w, r) + var req listPageRequest + if !decodeBody(w, r, &req) { + return + } + + page, ok := pageFromBody(w, &req) if !ok { return } - domains, next, err := h.ca.ListDomains(r.Context(), driver.Page{ - NextToken: rawString(raw, "nextToken"), - MaxResults: rawInt32(raw, "maxResults"), - }) + domains, next, err := h.ca.ListDomains(r.Context(), page) if err != nil { writeErr(w, err) @@ -75,64 +82,7 @@ func (h *Handler) listDomains(w http.ResponseWriter, r *http.Request) { } body := map[string]any{"domains": summaries} - if next != "" { - body["nextToken"] = next - } + putNonEmpty(body, "nextToken", next) writeJSON(w, body) } - -// rawString decodes a JSON string field from a raw request body, returning "" -// when the field is absent or not a string. -func rawString(raw map[string]json.RawMessage, key string) string { - v, ok := raw[key] - if !ok { - return "" - } - - var s string - if json.Unmarshal(v, &s) != nil { - return "" - } - - return s -} - -// rawInt32 decodes a JSON number field from a raw request body, returning 0 when -// the field is absent or not a number. -func rawInt32(raw map[string]json.RawMessage, key string) int32 { - v, ok := raw[key] - if !ok { - return 0 - } - - var n int32 - if json.Unmarshal(v, &n) != nil { - return 0 - } - - return n -} - -// maxQueryInt bounds a parsed query integer so the result fits an int32 without -// overflow. -const maxQueryInt = 1 << 20 - -// atoiDefault parses a base-10 int32 from s, returning 0 when s is empty or -// invalid. It is bounded by maxQueryInt so the conversion cannot overflow. -func atoiDefault(s string) int32 { - n := 0 - - for _, c := range s { - if c < '0' || c > '9' { - return 0 - } - - n = n*10 + int(c-'0') - if n > maxQueryInt { - return maxQueryInt - } - } - - return int32(n) -} diff --git a/server/aws/codeartifact/error_matrix_test.go b/server/aws/codeartifact/error_matrix_test.go new file mode 100644 index 000000000..3359a3216 --- /dev/null +++ b/server/aws/codeartifact/error_matrix_test.go @@ -0,0 +1,166 @@ +package codeartifact_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "sort" + "strings" + "testing" +) + +// probe is a request the matrix sends: a verb and path from the API model, a +// query string and a JSON body. +type probe struct { + query url.Values + body string + // header sets extra request headers. + header map[string]string +} + +const ( + policyDoc = `{\"Version\":\"2012-10-17\",\"Statement\":[]}` + xSHA256 = "2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881" // sha256("x") + invalidDom = "Bad Domain" + ghostDom = "ghost-domain" +) + +// validationProbe returns a request for op that carries one invalid field. +func validationProbe(op string) probe { + q := url.Values{"domain": {invalidDom}, "repository": {"ghost-repo"}, "format": {"npm"}, "package": {"p"}, "version": {"1"}, + "asset": {"a"}, "package-group": {"/npm/*"}, "external-connection": {"public:npmjs"}} + + switch op { + case "ListDomains": + return probe{body: `{"maxResults":5000}`} + case "ListRepositories": + return probe{query: url.Values{"max-results": {"5000"}}} + case "TagResource", "UntagResource", "ListTagsForResource": + return probe{query: url.Values{"resourceArn": {"arn:aws:codeartifact:us-east-1:123456789012:thing/x"}}, body: `{"tags":[],"tagKeys":[]}`} + case "PutDomainPermissionsPolicy": + return probe{body: `{"domain":"` + invalidDom + `","policyDocument":"` + policyDoc + `"}`} + } + + return probe{query: q, body: bodyFor(op)} +} + +// ghostProbe returns a request for op that names a domain that does not exist. +func ghostProbe(op string) probe { + q := url.Values{ + "domain": {ghostDom}, "repository": {"ghost-repo"}, "format": {"generic"}, "namespace": {"ns"}, "package": {"p"}, "version": {"1"}, + "asset": {"a"}, "package-group": {"/npm/*"}, "external-connection": {"public:npmjs"}, "source-repository": {"ghost-repo"}, + "destination-repository": {"other-repo"}, "originRestrictionType": {"PUBLISH"}, "duration": {"900"}, + } + + switch op { + case "TagResource", "UntagResource", "ListTagsForResource": + return probe{query: url.Values{"resourceArn": {"arn:aws:codeartifact:us-east-1:123456789012:domain/" + ghostDom}}, body: `{"tags":[],"tagKeys":[]}`} + case "PutDomainPermissionsPolicy": + return probe{body: `{"domain":"` + ghostDom + `","policyDocument":"` + policyDoc + `"}`} + case "PublishPackageVersion": + return probe{query: q, body: "x", header: map[string]string{"x-amz-content-sha256": xSHA256}} + } + + return probe{query: q, body: bodyFor(op)} +} + +// bodyFor returns a request body that is well formed for op. +func bodyFor(op string) string { + switch op { + case "CopyPackageVersions", "DisposePackageVersions", "DeletePackageVersions": + return `{"versions":["1"]}` + case "UpdatePackageVersionsStatus": + return `{"versions":["1"],"targetStatus":"Archived"}` + case "PutRepositoryPermissionsPolicy": + return `{"policyDocument":"` + policyDoc + `"}` + case "PutPackageOriginConfiguration": + return `{"restrictions":{"publish":"ALLOW","upstream":"ALLOW"}}` + case "CreatePackageGroup", "UpdatePackageGroup": + return `{"packageGroup":"/npm/*"}` + case "PublishPackageVersion": + return "x" + default: + return "{}" + } +} + +func send(h http.Handler, method, path string, p probe) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path+"?"+p.query.Encode(), strings.NewReader(p.body)) + for k, v := range p.header { + req.Header.Set(k, v) + } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + return rec +} + +func contains(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + + return false +} + +// TestErrorMatrixPerOperation drives every one of the 48 operations into a +// validation failure and, where the API models ResourceNotFoundException, into a +// missing-domain failure, and checks the exception the wire answers is one the +// operation's model lists, with the exact HTTP status and a JSON message. +func TestErrorMatrixPerOperation(t *testing.T) { + ops := loadModel(t) + h := fullHandler() + names := make([]string, 0, len(ops)) + + for name := range ops { + names = append(names, name) + } + + sort.Strings(names) + + for _, name := range names { + op := ops[name] + + rec := send(h, op.Method, op.Path, validationProbe(name)) + checkError(t, name+"/validation", rec, "ValidationException", http.StatusBadRequest, op.Errors) + + if name == "CreateDomain" || !contains(op.Errors, "ResourceNotFoundException") { + continue + } + + rec = send(h, op.Method, op.Path, ghostProbe(name)) + checkError(t, name+"/not-found", rec, "ResourceNotFoundException", http.StatusNotFound, op.Errors) + } +} + +func checkError(t *testing.T, label string, rec *httptest.ResponseRecorder, want string, status int, modelled []string) { + t.Helper() + + got := rec.Header().Get("X-Amzn-Errortype") + + if got != want || rec.Code != status { + t.Errorf("%s: got %d %q, want %d %q (%s)", label, rec.Code, got, status, want, strings.TrimSpace(rec.Body.String())) + + return + } + + if !contains(modelled, got) { + t.Errorf("%s: %s is not modelled for this operation (%v)", label, got, modelled) + } + + var body struct { + Message string `json:"message"` + } + + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Message == "" { + t.Errorf("%s: body %q has no message", label, rec.Body.String()) + } + + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("%s: content-type %q", label, ct) + } +} diff --git a/server/aws/codeartifact/errors.go b/server/aws/codeartifact/errors.go index d5f244e75..5a4025bcb 100644 --- a/server/aws/codeartifact/errors.go +++ b/server/aws/codeartifact/errors.go @@ -16,18 +16,23 @@ const ( ) // errorBody is the restJson1 error body. The SDK reads the X-Amzn-Errortype -// header to select a typed exception. +// header to select a typed exception; resourceId, resourceType and reason fill +// the matching members of ResourceNotFound, Conflict, ServiceQuotaExceeded and +// Validation exceptions. type errorBody struct { - Message string `json:"message"` + Message string `json:"message"` + ResourceID string `json:"resourceId,omitempty"` + ResourceType string `json:"resourceType,omitempty"` + Reason string `json:"reason,omitempty"` } // writeError writes a restJson1 error response with the given exception type. -func writeError(w http.ResponseWriter, status int, errType, msg string) { +func writeError(w http.ResponseWriter, status int, errType string, body errorBody) { w.Header().Set("Content-Type", contentTypeJSON) w.Header().Set("X-Amzn-Errortype", errType) w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(errorBody{Message: msg}) + _ = json.NewEncoder(w).Encode(body) } // statusForException returns the HTTP status for a CodeArtifact exception name. @@ -39,6 +44,8 @@ func statusForException(exception string) int { return http.StatusBadRequest case driver.ExConflict: return http.StatusConflict + case driver.ExServiceQuota: + return http.StatusPaymentRequired default: return http.StatusInternalServerError } @@ -60,48 +67,62 @@ func exceptionForCode(err error) (status int, errType string) { } // writeErr maps a driver error to the precise CodeArtifact exception. Tagged -// driver.APIError values are honored first so the exact exception name is -// preserved; untagged errors fall back to the canonical-code mapping. +// driver.APIError values are honored first so the exact exception name and its +// resource identity are preserved; untagged errors fall back to the +// canonical-code mapping. func writeErr(w http.ResponseWriter, err error) { msg := cerrors.Message(err) var apiErr *driver.APIError if errors.As(err, &apiErr) { - writeError(w, statusForException(apiErr.Exception), apiErr.Exception, msg) + writeError(w, statusForException(apiErr.Exception), apiErr.Exception, errorBody{ + Message: msg, ResourceID: apiErr.ResourceID, ResourceType: apiErr.ResourceType, Reason: apiErr.Reason, + }) return } status, errType := exceptionForCode(err) - writeError(w, status, errType, msg) + writeError(w, status, errType, errorBody{Message: msg}) +} + +// writeErrNoNotFound is writeErr for an operation the API does not model +// ResourceNotFoundException for: a missing resource is reported as a +// ValidationException instead, the only 4xx such an operation can return. +func writeErrNoNotFound(w http.ResponseWriter, err error) { + if cerrors.IsNotFound(err) { + writeValidation(w, cerrors.Message(err)) + + return + } + + writeErr(w, err) +} + +func writeValidation(w http.ResponseWriter, msg string) { + writeError(w, http.StatusBadRequest, driver.ExValidation, errorBody{Message: msg, Reason: driver.ReasonFieldValidationFailed}) } func notFoundPath(w http.ResponseWriter, path string) { - writeError(w, http.StatusNotFound, driver.ExNotFound, "unsupported path: "+path) + writeError(w, http.StatusNotFound, driver.ExNotFound, errorBody{Message: "unsupported path: " + path}) } func methodNotAllowed(w http.ResponseWriter) { - writeError(w, http.StatusBadRequest, driver.ExValidation, "method not allowed") + writeValidation(w, "method not allowed") } -// decodeBodyMap reads the request body into a map of raw fields. An empty body -// yields an empty map. -func decodeBodyMap(w http.ResponseWriter, r *http.Request) (map[string]json.RawMessage, bool) { +// decodeBody reads the request body as JSON into v. An empty body leaves v +// untouched. +func decodeBody(w http.ResponseWriter, r *http.Request, v any) bool { r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) - out := map[string]json.RawMessage{} - - if err := json.NewDecoder(r.Body).Decode(&out); err != nil { - if errors.Is(err, io.EOF) { - return out, true - } - - writeError(w, http.StatusBadRequest, driver.ExValidation, "invalid JSON: "+err.Error()) + if err := json.NewDecoder(r.Body).Decode(v); err != nil && !errors.Is(err, io.EOF) { + writeValidation(w, "invalid JSON: "+err.Error()) - return nil, false + return false } - return out, true + return true } // writeJSON writes a 200 restJson1 success body. diff --git a/server/aws/codeartifact/handler.go b/server/aws/codeartifact/handler.go index a7ca23b1f..c09796828 100644 --- a/server/aws/codeartifact/handler.go +++ b/server/aws/codeartifact/handler.go @@ -1,25 +1,26 @@ -// Package codeartifact implements the AWS CodeArtifact control-plane API -// (restJson1) as a server.Handler. Point the real -// aws-sdk-go-v2/service/codeartifact client (or the `aws codeartifact` CLI, or -// the aws_codeartifact_domain / aws_codeartifact_repository Terraform resources) -// at a Server registered with this handler and the domain, repository, external -// connection and tagging operations work end-to-end against an in-memory driver. +// Package codeartifact implements the AWS CodeArtifact API (restJson1) as a +// server.Handler. Point the real aws-sdk-go-v2/service/codeartifact client (or +// the `aws codeartifact` CLI, or the aws_codeartifact_* Terraform resources) at a +// Server registered with this handler and the 48 management operations work +// end-to-end against an in-memory driver: domains, repositories, external +// connections, permissions policies, packages and package versions (with real +// asset bytes), package origin controls and package groups. // // CodeArtifact routes by HTTP verb + a fixed path under the /v1/ prefix, with the // resource identity carried in the QUERY STRING rather than the path, for // example POST /v1/domain?domain=my-domain (CreateDomain), // GET /v1/repository?domain=d&repository=r (DescribeRepository), // POST /v1/repositories (ListRepositories). There is no X-Amz-Target header. -// Matches claims the /v1/domain(s) and /v1/repository(ies) trees (distinctive -// to CodeArtifact) and the shared /v1/tag, /v1/tags and /v1/untag -// paths only when their ?resourceArn= query names a CodeArtifact (:codeartifact:) -// ARN, so it runs before the S3 catch-all and never shadows a sibling service's -// tag operations. (CodeArtifact's /v1/tags carries no path segment and takes -// ?resourceArn=, unlike the /v1/tags/{arn} path style of MQ, Batch, AppSync and -// Kafka, so the two never collide.) +// Matches claims exactly the paths in the route table, and the shared /v1/tag, +// /v1/tags and /v1/untag paths only when their ?resourceArn= query names a +// CodeArtifact (:codeartifact:) ARN, so it runs before the S3 catch-all and never +// shadows a sibling service's tag operations. (CodeArtifact's /v1/tags carries no +// path segment and takes ?resourceArn=, unlike the /v1/tags/{arn} path style of +// MQ, Batch, AppSync and Kafka, so the two never collide.) // -// This is a control-plane-only surface: there is no package data plane (no -// package publish, version resolution or asset storage). +// The package-manager registry protocols (npm, pip, Maven, ...) are not served: +// GetRepositoryEndpoint reports an AWS-shaped URL that points nowhere in the +// emulator. package codeartifact import ( @@ -32,206 +33,85 @@ import ( // apiPrefix is the version prefix every CodeArtifact operation path carries. const apiPrefix = "/v1/" -// Path roots below the /v1/ prefix. -const ( - rootDomain = "domain" - rootDomains = "domains" - rootRepository = "repository" - rootRepositories = "repositories" - rootTag = "tag" - rootTags = "tags" - rootUntag = "untag" - - subRepositories = "repositories" - subExternalConnection = "external-connection" -) +// SigningName is the SigV4 credential-scope service name CodeArtifact requests +// are signed with. +const SigningName = "codeartifact" -// arnMarker scopes the shared tag roots to CodeArtifact ARNs. +// arnMarker scopes the shared tag paths to CodeArtifact ARNs. const arnMarker = ":codeartifact:" -// Handler serves AWS CodeArtifact requests against a driver. +// routeKey identifies one operation by verb and path. +type routeKey struct { + method string + path string +} + +// Handler serves AWS CodeArtifact requests against a driver. The optional +// capabilities are discovered once, by type assertion; an operation whose +// capability the driver lacks is not routed (it falls through like any unknown +// path). type Handler struct { - ca driver.CodeArtifact + ca driver.CodeArtifact + access driver.Access + adminLs driver.RepositoryAdministratorLister + policies driver.Policies + packages driver.Packages + versions driver.PackageVersions + groups driver.PackageGroups + + routes map[routeKey]http.HandlerFunc + paths map[string]bool } // New returns a CodeArtifact handler backed by d. func New(d driver.CodeArtifact) *Handler { - return &Handler{ca: d} -} - -// Matches claims the CodeArtifact path shapes. The /v1/domain(s) and -// /v1/repository(ies) roots are unique to CodeArtifact. The /v1/tag, /v1/tags and -// /v1/untag roots are claimed only when their ?resourceArn= query names a -// CodeArtifact ARN; anything else falls through. -func (*Handler) Matches(r *http.Request) bool { - if !strings.HasPrefix(r.URL.Path, apiPrefix) { - return false - } - - segs := splitPath(strings.TrimPrefix(r.URL.Path, apiPrefix)) - if len(segs) == 0 { - return false - } - - switch segs[0] { - case rootDomain, rootDomains, rootRepository, rootRepositories: - return true - case rootTag, rootTags, rootUntag: - return strings.Contains(r.URL.Query().Get("resourceArn"), arnMarker) - default: - return false - } -} + h := &Handler{ca: d, routes: map[routeKey]http.HandlerFunc{}, paths: map[string]bool{}} -// ServeHTTP dispatches a CodeArtifact request on its path shape. -func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - segs := splitPath(strings.TrimPrefix(r.URL.Path, apiPrefix)) - if len(segs) == 0 { - notFoundPath(w, r.URL.Path) - - return - } + h.access, _ = d.(driver.Access) + h.adminLs, _ = d.(driver.RepositoryAdministratorLister) + h.policies, _ = d.(driver.Policies) + h.packages, _ = d.(driver.Packages) + h.versions, _ = d.(driver.PackageVersions) + h.groups, _ = d.(driver.PackageGroups) - switch segs[0] { - case rootDomain: - h.serveDomain(w, r, segs[1:]) - case rootDomains: - h.serveDomains(w, r, segs[1:]) - case rootRepository: - h.serveRepository(w, r, segs[1:]) - case rootRepositories: - h.serveRepositories(w, r, segs[1:]) - case rootTag: - h.serveTag(w, r, segs[1:]) - case rootTags: - h.serveListTags(w, r, segs[1:]) - case rootUntag: - h.serveUntag(w, r, segs[1:]) - default: - notFoundPath(w, r.URL.Path) - } -} + h.buildRoutes() -// serveDomain routes /v1/domain and /v1/domain/repositories. -func (h *Handler) serveDomain(w http.ResponseWriter, r *http.Request, rest []string) { - switch len(rest) { - case 0: - h.serveDomainItem(w, r) - case 1: - if rest[0] != subRepositories { - notFoundPath(w, r.URL.Path) - - return - } - - h.listRepositoriesInDomain(w, r) - default: - notFoundPath(w, r.URL.Path) - } + return h } -// serveDomainItem handles the verb-keyed operations on /v1/domain. -func (h *Handler) serveDomainItem(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodPost: - h.createDomain(w, r) - case http.MethodGet: - h.describeDomain(w, r) - case http.MethodDelete: - h.deleteDomain(w, r) - default: - methodNotAllowed(w) - } -} - -// serveDomains handles POST /v1/domains (ListDomains). -func (h *Handler) serveDomains(w http.ResponseWriter, r *http.Request, rest []string) { - if len(rest) != 0 { - notFoundPath(w, r.URL.Path) - - return - } - - if r.Method != http.MethodPost { - methodNotAllowed(w) - - return +// Matches claims the CodeArtifact route table. The shared tag paths are claimed +// only when ?resourceArn= names a CodeArtifact ARN; anything else falls through. +func (h *Handler) Matches(r *http.Request) bool { + if !strings.HasPrefix(r.URL.Path, apiPrefix) || !h.paths[r.URL.Path] { + return false } - h.listDomains(w, r) -} - -// serveRepository routes /v1/repository and /v1/repository/external-connection. -func (h *Handler) serveRepository(w http.ResponseWriter, r *http.Request, rest []string) { - switch len(rest) { - case 0: - h.serveRepositoryItem(w, r) - case 1: - if rest[0] != subExternalConnection { - notFoundPath(w, r.URL.Path) - - return - } - - h.serveExternalConnection(w, r) - default: - notFoundPath(w, r.URL.Path) + if isTagPath(r.URL.Path) { + return strings.Contains(r.URL.Query().Get("resourceArn"), arnMarker) } -} -// serveRepositoryItem handles the verb-keyed operations on /v1/repository. -func (h *Handler) serveRepositoryItem(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodPost: - h.createRepository(w, r) - case http.MethodGet: - h.describeRepository(w, r) - case http.MethodPut: - h.updateRepository(w, r) - case http.MethodDelete: - h.deleteRepository(w, r) - default: - methodNotAllowed(w) - } + return true } -// serveExternalConnection handles the external-connection subresource. -func (h *Handler) serveExternalConnection(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodPost: - h.associateExternalConnection(w, r) - case http.MethodDelete: - h.disassociateExternalConnection(w, r) - default: - methodNotAllowed(w) - } +func isTagPath(path string) bool { + return path == "/v1/tag" || path == "/v1/tags" || path == "/v1/untag" } -// serveRepositories handles POST /v1/repositories (ListRepositories). -func (h *Handler) serveRepositories(w http.ResponseWriter, r *http.Request, rest []string) { - if len(rest) != 0 { - notFoundPath(w, r.URL.Path) +// ServeHTTP dispatches a CodeArtifact request on its verb and path. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if fn, ok := h.routes[routeKey{r.Method, r.URL.Path}]; ok { + fn(w, r) return } - if r.Method != http.MethodPost { + if h.paths[r.URL.Path] { methodNotAllowed(w) return } - h.listRepositories(w, r) -} - -// splitPath splits a URL path into its non-empty segments. -func splitPath(p string) []string { - p = strings.Trim(p, "/") - if p == "" { - return nil - } - - return strings.Split(p, "/") + notFoundPath(w, r.URL.Path) } // IAMService returns the IAM service prefix of the operations this handler diff --git a/server/aws/codeartifact/package.go b/server/aws/codeartifact/package.go new file mode 100644 index 000000000..1b5090c5e --- /dev/null +++ b/server/aws/codeartifact/package.go @@ -0,0 +1,104 @@ +package codeartifact + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +// originConfigToWire renders a package's origin configuration. +func originConfigToWire(r driver.OriginRestrictions) map[string]any { + return map[string]any{"restrictions": map[string]any{"publish": r.Publish, "upstream": r.Upstream}} +} + +// packageDescriptionToWire renders a package as a PackageDescription. +func packageDescriptionToWire(p *driver.Package) map[string]any { + out := map[string]any{"format": p.Format, "name": p.Name, "originConfiguration": originConfigToWire(p.Restrictions)} + putNonEmpty(out, "namespace", p.Namespace) + + return out +} + +// packageSummaryToWire renders a package as a PackageSummary. +func packageSummaryToWire(p *driver.Package) map[string]any { + out := map[string]any{"format": p.Format, "package": p.Name, "originConfiguration": originConfigToWire(p.Restrictions)} + putNonEmpty(out, "namespace", p.Namespace) + + return out +} + +func (h *Handler) listPackages(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + page, ok := pageFromQuery(w, q) + if !ok { + return + } + + pkgs, next, err := h.packages.ListPackages(r.Context(), &driver.ListPackagesInput{ + Repository: repoRefFromQuery(q), Format: q.Get("format"), Namespace: q.Get("namespace"), Prefix: q.Get("package-prefix"), + Publish: q.Get("publish"), Upstream: q.Get("upstream"), Page: page, + }) + if err != nil { + writeErr(w, err) + + return + } + + list := make([]map[string]any, 0, len(pkgs)) + for _, p := range pkgs { + list = append(list, packageSummaryToWire(p)) + } + + body := map[string]any{"packages": list} + putNonEmpty(body, "nextToken", next) + + writeJSON(w, body) +} + +func (h *Handler) describePackage(w http.ResponseWriter, r *http.Request) { + out, err := h.packages.DescribePackage(r.Context(), pkgRefFromQuery(r.URL.Query())) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, map[string]any{"package": packageDescriptionToWire(out)}) +} + +// putPackageOriginRequest is the PutPackageOriginConfiguration body. +type putPackageOriginRequest struct { + Restrictions struct { + Publish string `json:"publish"` + Upstream string `json:"upstream"` + } `json:"restrictions"` +} + +func (h *Handler) putPackageOriginConfiguration(w http.ResponseWriter, r *http.Request) { + var req putPackageOriginRequest + if !decodeBody(w, r, &req) { + return + } + + out, err := h.packages.PutPackageOriginConfiguration(r.Context(), pkgRefFromQuery(r.URL.Query()), + driver.OriginRestrictions{Publish: req.Restrictions.Publish, Upstream: req.Restrictions.Upstream}) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, map[string]any{"originConfiguration": originConfigToWire(out.Restrictions)}) +} + +func (h *Handler) deletePackage(w http.ResponseWriter, r *http.Request) { + out, err := h.packages.DeletePackage(r.Context(), pkgRefFromQuery(r.URL.Query())) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, map[string]any{"deletedPackage": packageSummaryToWire(out)}) +} diff --git a/server/aws/codeartifact/package_group.go b/server/aws/codeartifact/package_group.go new file mode 100644 index 000000000..084106c7c --- /dev/null +++ b/server/aws/codeartifact/package_group.go @@ -0,0 +1,286 @@ +package codeartifact + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +func groupRefToWire(g *driver.GroupReference) map[string]any { + return map[string]any{"arn": g.Arn, "pattern": g.Pattern} +} + +// groupToWire renders a package group as PackageGroupDescription (and, member for +// member, PackageGroupSummary). +func groupToWire(g *driver.PackageGroup) map[string]any { + restrictions := make(map[string]any, len(g.Restrictions)) + + for t, r := range g.Restrictions { + entry := map[string]any{"mode": r.Mode, "effectiveMode": r.EffectiveMode, "repositoriesCount": r.RepositoriesCount} + if r.InheritedFrom != nil { + entry["inheritedFrom"] = groupRefToWire(r.InheritedFrom) + } + + restrictions[t] = entry + } + + out := map[string]any{ + "arn": g.Arn, "pattern": g.Pattern, "domainName": g.DomainName, "domainOwner": g.DomainOwner, + "createdTime": epochSeconds(g.CreatedTime), + "originConfiguration": map[string]any{"restrictions": restrictions}, + } + putNonEmpty(out, "contactInfo", g.ContactInfo) + putNonEmpty(out, "description", g.Description) + + if g.Parent != nil { + out["parent"] = groupRefToWire(g.Parent) + } + + return out +} + +func writeGroupPage(w http.ResponseWriter, groups []*driver.PackageGroup, next string) { + list := make([]map[string]any, 0, len(groups)) + for _, g := range groups { + list = append(list, groupToWire(g)) + } + + body := map[string]any{"packageGroups": list} + putNonEmpty(body, "nextToken", next) + + writeJSON(w, body) +} + +// createPackageGroupRequest is the CreatePackageGroup body. +type createPackageGroupRequest struct { + PackageGroup string `json:"packageGroup"` + ContactInfo string `json:"contactInfo"` + Description string `json:"description"` + Tags []tagPair `json:"tags"` +} + +func (h *Handler) createPackageGroup(w http.ResponseWriter, r *http.Request) { + var req createPackageGroupRequest + if !decodeBody(w, r, &req) { + return + } + + out, err := h.groups.CreatePackageGroup(r.Context(), &driver.CreatePackageGroupInput{ + Domain: domainRefFromQuery(r.URL.Query()), Pattern: req.PackageGroup, + ContactInfo: req.ContactInfo, Description: req.Description, Tags: tagsToMap(req.Tags), + }) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, map[string]any{"packageGroup": groupToWire(out)}) +} + +func (h *Handler) describePackageGroup(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + out, err := h.groups.DescribePackageGroup(r.Context(), domainRefFromQuery(q), q.Get("package-group")) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, map[string]any{"packageGroup": groupToWire(out)}) +} + +// updatePackageGroupRequest is the UpdatePackageGroup body; an absent field is +// left unchanged. +type updatePackageGroupRequest struct { + PackageGroup string `json:"packageGroup"` + ContactInfo *string `json:"contactInfo"` + Description *string `json:"description"` +} + +func (h *Handler) updatePackageGroup(w http.ResponseWriter, r *http.Request) { + var req updatePackageGroupRequest + if !decodeBody(w, r, &req) { + return + } + + out, err := h.groups.UpdatePackageGroup(r.Context(), &driver.UpdatePackageGroupInput{ + Domain: domainRefFromQuery(r.URL.Query()), Pattern: req.PackageGroup, ContactInfo: req.ContactInfo, Description: req.Description, + }) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, map[string]any{"packageGroup": groupToWire(out)}) +} + +func (h *Handler) deletePackageGroup(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + out, err := h.groups.DeletePackageGroup(r.Context(), domainRefFromQuery(q), q.Get("package-group")) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, map[string]any{"packageGroup": groupToWire(out)}) +} + +// groupOriginRequest is the UpdatePackageGroupOriginConfiguration body. +type groupOriginRequest struct { + Restrictions map[string]string `json:"restrictions"` + AddAllowedRepositories []allowedRepoPair `json:"addAllowedRepositories"` + RemoveAllowedRepositories []allowedRepoPair `json:"removeAllowedRepositories"` +} + +// allowedRepoPair mirrors one PackageGroupAllowedRepository object. +type allowedRepoPair struct { + RepositoryName string `json:"repositoryName"` + OriginRestrictionType string `json:"originRestrictionType"` +} + +func allowedRepos(list []allowedRepoPair) []driver.AllowedRepository { + out := make([]driver.AllowedRepository, 0, len(list)) + for _, a := range list { + out = append(out, driver.AllowedRepository{RepositoryName: a.RepositoryName, OriginRestrictionType: a.OriginRestrictionType}) + } + + return out +} + +func (h *Handler) updatePackageGroupOrigin(w http.ResponseWriter, r *http.Request) { + var req groupOriginRequest + if !decodeBody(w, r, &req) { + return + } + + q := r.URL.Query() + + out, err := h.groups.UpdatePackageGroupOriginConfiguration(r.Context(), &driver.UpdateGroupOriginInput{ + Domain: domainRefFromQuery(q), Pattern: q.Get("package-group"), Restrictions: req.Restrictions, + AddAllowed: allowedRepos(req.AddAllowedRepositories), RemoveAllowed: allowedRepos(req.RemoveAllowedRepositories), + }) + if err != nil { + writeErr(w, err) + + return + } + + updates := make(map[string]any, len(out.Updates)) + for t, byKind := range out.Updates { + updates[t] = byKind + } + + writeJSON(w, map[string]any{"packageGroup": groupToWire(out.Group), "allowedRepositoryUpdates": updates}) +} + +func (h *Handler) listPackageGroups(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + page, ok := pageFromQuery(w, q) + if !ok { + return + } + + groups, next, err := h.groups.ListPackageGroups(r.Context(), domainRefFromQuery(q), q.Get("prefix"), page) + if err != nil { + writeErr(w, err) + + return + } + + writeGroupPage(w, groups, next) +} + +func (h *Handler) listSubPackageGroups(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + page, ok := pageFromQuery(w, q) + if !ok { + return + } + + groups, next, err := h.groups.ListSubPackageGroups(r.Context(), domainRefFromQuery(q), q.Get("package-group"), page) + if err != nil { + writeErr(w, err) + + return + } + + writeGroupPage(w, groups, next) +} + +func (h *Handler) listAssociatedPackages(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + page, ok := pageFromQuery(w, q) + if !ok { + return + } + + pkgs, next, err := h.groups.ListAssociatedPackages(r.Context(), domainRefFromQuery(q), q.Get("package-group"), + q.Get("preview") == queryTrue, page) + if err != nil { + writeErr(w, err) + + return + } + + list := make([]map[string]any, 0, len(pkgs)) + + for _, p := range pkgs { + entry := map[string]any{"format": p.Format, "package": p.Package, "associationType": p.AssociationType} + putNonEmpty(entry, "namespace", p.Namespace) + + list = append(list, entry) + } + + body := map[string]any{"packages": list} + putNonEmpty(body, "nextToken", next) + + writeJSON(w, body) +} + +func (h *Handler) getAssociatedPackageGroup(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + g, strength, err := h.groups.GetAssociatedPackageGroup(r.Context(), domainRefFromQuery(q), + q.Get("format"), q.Get("namespace"), q.Get("package")) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, map[string]any{"packageGroup": groupToWire(g), "associationType": strength}) +} + +func (h *Handler) listAllowedRepositories(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + page, ok := pageFromQuery(w, q) + if !ok { + return + } + + repos, next, err := h.groups.ListAllowedRepositoriesForGroup(r.Context(), domainRefFromQuery(q), q.Get("package-group"), + q.Get("originRestrictionType"), page) + if err != nil { + writeErr(w, err) + + return + } + + if repos == nil { + repos = []string{} + } + + body := map[string]any{"allowedRepositories": repos} + putNonEmpty(body, "nextToken", next) + + writeJSON(w, body) +} diff --git a/server/aws/codeartifact/package_version.go b/server/aws/codeartifact/package_version.go new file mode 100644 index 000000000..1cc43472e --- /dev/null +++ b/server/aws/codeartifact/package_version.go @@ -0,0 +1,297 @@ +package codeartifact + +import ( + "errors" + "io" + "net/http" + "strconv" + + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +// originToWire renders how a package version entered the repository. +func originToWire(o driver.VersionOrigin) map[string]any { + out := map[string]any{"originType": o.OriginType} + if o.RepositoryName != "" { + out["domainEntryPoint"] = map[string]any{"repositoryName": o.RepositoryName} + } + + return out +} + +func assetToWire(a *driver.Asset) map[string]any { + return map[string]any{"name": a.Name, "size": a.Size, "hashes": a.Hashes} +} + +// identityToWire renders the identity members shared by the asset, readme and +// dependency responses. +func identityToWire(id *driver.VersionIdentity) map[string]any { + out := map[string]any{"format": id.Format, "package": id.Package, "version": id.Version, "versionRevision": id.Revision} + putNonEmpty(out, "namespace", id.Namespace) + + return out +} + +func (h *Handler) listPackageVersions(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + page, ok := pageFromQuery(w, q) + if !ok { + return + } + + out, err := h.versions.ListPackageVersions(r.Context(), &driver.ListPackageVersionsInput{ + Ref: pkgRefFromQuery(q), Status: q.Get("status"), SortBy: q.Get("sortBy"), OriginType: q.Get("originType"), Page: page, + }) + if err != nil { + writeErr(w, err) + + return + } + + versions := make([]map[string]any, 0, len(out.Versions)) + for _, v := range out.Versions { + versions = append(versions, map[string]any{ + "version": v.Version, "revision": v.Revision, "status": v.Status, "origin": originToWire(v.Origin), + }) + } + + body := map[string]any{"format": out.Format, "package": out.Package, "versions": versions} + putNonEmpty(body, "namespace", out.Namespace) + putNonEmpty(body, "defaultDisplayVersion", out.DefaultDisplayVersion) + putNonEmpty(body, "nextToken", out.NextToken) + + writeJSON(w, body) +} + +func (h *Handler) describePackageVersion(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + v, err := h.versions.DescribePackageVersion(r.Context(), pkgRefFromQuery(q), q.Get("version")) + if err != nil { + writeErr(w, err) + + return + } + + desc := map[string]any{ + "format": v.Format, "packageName": v.PackageName, "displayName": v.DisplayName, "version": v.Version, + "revision": v.Revision, "status": v.Status, "origin": originToWire(v.Origin), + } + putNonEmpty(desc, "namespace", v.Namespace) + + if !v.PublishedTime.IsZero() { + desc["publishedTime"] = epochSeconds(v.PublishedTime) + } + + writeJSON(w, map[string]any{"packageVersion": desc}) +} + +func (h *Handler) publishPackageVersion(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + content, err := io.ReadAll(http.MaxBytesReader(w, r.Body, driver.MaxAssetBytes+1)) + if err != nil { + writeUploadError(w, err) + + return + } + + res, err := h.versions.PublishPackageVersion(r.Context(), &driver.PublishInput{ + Ref: pkgRefFromQuery(q), Version: q.Get("version"), AssetName: q.Get("asset"), + SHA256: r.Header.Get("x-amz-content-sha256"), Content: content, Unfinished: q.Get("unfinished") == queryTrue, + }) + if err != nil { + writeErr(w, err) + + return + } + + body := identityToWire(&res.VersionIdentity) + body["status"] = res.Status + body["asset"] = assetToWire(&res.Asset) + + writeJSON(w, body) +} + +// writeUploadError reports why an asset upload body could not be read: past the +// size limit it is the quota error, anything else (a dropped connection) is a +// malformed request. +func writeUploadError(w http.ResponseWriter, err error) { + var tooBig *http.MaxBytesError + if errors.As(err, &tooBig) { + writeError(w, http.StatusPaymentRequired, driver.ExServiceQuota, errorBody{ + Message: "asset exceeds the emulator's " + strconv.Itoa(driver.MaxAssetBytes) + "-byte limit", ResourceType: driver.ResourceTypeAsset, + }) + + return + } + + writeValidation(w, "could not read the asset content: "+err.Error()) +} + +func (h *Handler) listPackageVersionAssets(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + page, ok := pageFromQuery(w, q) + if !ok { + return + } + + list, next, err := h.versions.ListPackageVersionAssets(r.Context(), pkgRefFromQuery(q), q.Get("version"), page) + if err != nil { + writeErr(w, err) + + return + } + + assets := make([]map[string]any, 0, len(list.Assets)) + for i := range list.Assets { + assets = append(assets, assetToWire(&list.Assets[i])) + } + + body := identityToWire(&list.VersionIdentity) + body["assets"] = assets + putNonEmpty(body, "nextToken", next) + + writeJSON(w, body) +} + +func (h *Handler) getPackageVersionAsset(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + a, err := h.versions.GetPackageVersionAsset(r.Context(), pkgRefFromQuery(q), q.Get("version"), q.Get("asset"), q.Get("revision")) + if err != nil { + writeErr(w, err) + + return + } + + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Length", strconv.Itoa(len(a.Content))) + w.Header().Set("X-AssetName", a.Name) + w.Header().Set("X-PackageVersion", a.Version) + w.Header().Set("X-PackageVersionRevision", a.Revision) + w.WriteHeader(http.StatusOK) + + _, _ = w.Write(a.Content) +} + +func (h *Handler) getPackageVersionReadme(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + id, err := h.versions.GetPackageVersionReadme(r.Context(), pkgRefFromQuery(q), q.Get("version")) + if err != nil { + writeErr(w, err) + + return + } + + body := identityToWire(id) + body["readme"] = "" + + writeJSON(w, body) +} + +func (h *Handler) listPackageVersionDependencies(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + id, err := h.versions.ListPackageVersionDependencies(r.Context(), pkgRefFromQuery(q), q.Get("version")) + if err != nil { + writeErr(w, err) + + return + } + + body := identityToWire(id) + body["dependencies"] = []map[string]any{} + + writeJSON(w, body) +} + +// versionsRequest is the body shared by the bulk package version operations. +type versionsRequest struct { + Versions []string `json:"versions"` + VersionRevisions map[string]string `json:"versionRevisions"` + ExpectedStatus string `json:"expectedStatus"` + TargetStatus string `json:"targetStatus"` + AllowOverwrite bool `json:"allowOverwrite"` + IncludeUpstream bool `json:"includeFromUpstream"` +} + +func versionsResultToWire(res *driver.VersionsResult) map[string]any { + ok := make(map[string]any, len(res.Successful)) + for v, s := range res.Successful { + ok[v] = map[string]any{"revision": s.Revision, "status": s.Status} + } + + failed := make(map[string]any, len(res.Failed)) + for v, f := range res.Failed { + failed[v] = map[string]any{"errorCode": f.ErrorCode, "errorMessage": f.ErrorMessage} + } + + return map[string]any{"successfulVersions": ok, "failedVersions": failed} +} + +// bulk decodes a bulk request and runs op on it, writing the per-version result. +func bulk(w http.ResponseWriter, r *http.Request, op func(*http.Request, *versionsRequest) (*driver.VersionsResult, error)) { + var req versionsRequest + if !decodeBody(w, r, &req) { + return + } + + res, err := op(r, &req) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, versionsResultToWire(res)) +} + +func versionsInput(r *http.Request, req *versionsRequest) driver.VersionsInput { + return driver.VersionsInput{ + Ref: pkgRefFromQuery(r.URL.Query()), Versions: req.Versions, VersionRevisions: req.VersionRevisions, ExpectedStatus: req.ExpectedStatus, + } +} + +func (h *Handler) updatePackageVersionsStatus(w http.ResponseWriter, r *http.Request) { + bulk(w, r, func(r *http.Request, req *versionsRequest) (*driver.VersionsResult, error) { + return h.versions.UpdatePackageVersionsStatus(r.Context(), &driver.UpdateStatusInput{ + VersionsInput: versionsInput(r, req), TargetStatus: req.TargetStatus, + }) + }) +} + +func (h *Handler) disposePackageVersions(w http.ResponseWriter, r *http.Request) { + bulk(w, r, func(r *http.Request, req *versionsRequest) (*driver.VersionsResult, error) { + in := versionsInput(r, req) + + return h.versions.DisposePackageVersions(r.Context(), &in) + }) +} + +func (h *Handler) deletePackageVersions(w http.ResponseWriter, r *http.Request) { + bulk(w, r, func(r *http.Request, req *versionsRequest) (*driver.VersionsResult, error) { + in := versionsInput(r, req) + + return h.versions.DeletePackageVersions(r.Context(), &in) + }) +} + +func (h *Handler) copyPackageVersions(w http.ResponseWriter, r *http.Request) { + bulk(w, r, func(r *http.Request, req *versionsRequest) (*driver.VersionsResult, error) { + q := r.URL.Query() + + return h.versions.CopyPackageVersions(r.Context(), &driver.CopyInput{ + Ref: driver.PackageRef{ + Domain: q.Get("domain"), Owner: q.Get("domain-owner"), Format: q.Get("format"), + Namespace: q.Get("namespace"), Package: q.Get("package"), + }, + SourceRepository: q.Get("source-repository"), DestinationRepository: q.Get("destination-repository"), + Versions: req.Versions, VersionRevisions: req.VersionRevisions, + AllowOverwrite: req.AllowOverwrite, IncludeFromUpstream: req.IncludeUpstream, + }) + }) +} diff --git a/server/aws/codeartifact/policy.go b/server/aws/codeartifact/policy.go new file mode 100644 index 000000000..93260c8e5 --- /dev/null +++ b/server/aws/codeartifact/policy.go @@ -0,0 +1,109 @@ +package codeartifact + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +// putDomainPolicyRequest is the PutDomainPermissionsPolicy body: unlike the other +// domain operations it carries the domain in the body. +type putDomainPolicyRequest struct { + Domain string `json:"domain"` + DomainOwner string `json:"domainOwner"` + PolicyRevision string `json:"policyRevision"` + PolicyDocument string `json:"policyDocument"` +} + +// putRepositoryPolicyRequest is the PutRepositoryPermissionsPolicy body. +type putRepositoryPolicyRequest struct { + PolicyRevision string `json:"policyRevision"` + PolicyDocument string `json:"policyDocument"` +} + +func policyToWire(p *driver.ResourcePolicy) map[string]any { + return map[string]any{"policy": map[string]any{ + "resourceArn": p.ResourceArn, "revision": p.Revision, "document": p.Document, + }} +} + +func (h *Handler) putDomainPolicy(w http.ResponseWriter, r *http.Request) { + var req putDomainPolicyRequest + if !decodeBody(w, r, &req) { + return + } + + out, err := h.policies.PutDomainPermissionsPolicy(r.Context(), driver.DomainRef{Domain: req.Domain, Owner: req.DomainOwner}, + req.PolicyRevision, req.PolicyDocument) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, policyToWire(out)) +} + +func (h *Handler) getDomainPolicy(w http.ResponseWriter, r *http.Request) { + out, err := h.policies.GetDomainPermissionsPolicy(r.Context(), domainRefFromQuery(r.URL.Query())) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, policyToWire(out)) +} + +func (h *Handler) deleteDomainPolicy(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + out, err := h.policies.DeleteDomainPermissionsPolicy(r.Context(), domainRefFromQuery(q), q.Get("policy-revision")) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, policyToWire(out)) +} + +func (h *Handler) putRepositoryPolicy(w http.ResponseWriter, r *http.Request) { + var req putRepositoryPolicyRequest + if !decodeBody(w, r, &req) { + return + } + + out, err := h.policies.PutRepositoryPermissionsPolicy(r.Context(), repoRefFromQuery(r.URL.Query()), req.PolicyRevision, req.PolicyDocument) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, policyToWire(out)) +} + +func (h *Handler) getRepositoryPolicy(w http.ResponseWriter, r *http.Request) { + out, err := h.policies.GetRepositoryPermissionsPolicy(r.Context(), repoRefFromQuery(r.URL.Query())) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, policyToWire(out)) +} + +func (h *Handler) deleteRepositoryPolicy(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + out, err := h.policies.DeleteRepositoryPermissionsPolicy(r.Context(), repoRefFromQuery(q), q.Get("policy-revision")) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, policyToWire(out)) +} diff --git a/server/aws/codeartifact/repository.go b/server/aws/codeartifact/repository.go index 0563f9091..e01d72e41 100644 --- a/server/aws/codeartifact/repository.go +++ b/server/aws/codeartifact/repository.go @@ -6,26 +6,25 @@ import ( "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" ) +// createRepositoryRequest is the CreateRepository body. +type createRepositoryRequest struct { + Description string `json:"description"` + Upstreams []upstreamPair `json:"upstreams"` + Tags []tagPair `json:"tags"` +} + func (h *Handler) createRepository(w http.ResponseWriter, r *http.Request) { - raw, ok := decodeBodyMap(w, r) - if !ok { + var req createRepositoryRequest + if !decodeBody(w, r, &req) { return } q := r.URL.Query() - in := &driver.CreateRepositoryInput{ - Domain: q.Get("domain"), - DomainOwner: q.Get("domain-owner"), - Repository: q.Get("repository"), - Description: rawString(raw, "description"), - Tags: tagsFromBody(raw), - } - if ups := upstreamsFromBody(raw); ups != nil { - in.Upstreams = *ups - } - - out, err := h.ca.CreateRepository(r.Context(), in) + out, err := h.ca.CreateRepository(r.Context(), &driver.CreateRepositoryInput{ + Domain: q.Get("domain"), DomainOwner: q.Get("domain-owner"), Repository: q.Get("repository"), + Description: req.Description, Upstreams: upstreamsFromList(req.Upstreams), Tags: tagsToMap(req.Tags), + }) if err != nil { writeErr(w, err) @@ -48,21 +47,30 @@ func (h *Handler) describeRepository(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"repository": repositoryToWire(out)}) } +// updateRepositoryRequest is the UpdateRepository body. A nil field was absent +// from the request and is left unchanged; an empty upstream list clears them. +type updateRepositoryRequest struct { + Description *string `json:"description"` + Upstreams *[]upstreamPair `json:"upstreams"` +} + func (h *Handler) updateRepository(w http.ResponseWriter, r *http.Request) { - raw, ok := decodeBodyMap(w, r) - if !ok { + var req updateRepositoryRequest + if !decodeBody(w, r, &req) { return } q := r.URL.Query() + in := &driver.UpdateRepositoryInput{ + Domain: q.Get("domain"), DomainOwner: q.Get("domain-owner"), Repository: q.Get("repository"), Description: req.Description, + } - out, err := h.ca.UpdateRepository(r.Context(), &driver.UpdateRepositoryInput{ - Domain: q.Get("domain"), - DomainOwner: q.Get("domain-owner"), - Repository: q.Get("repository"), - Description: stringPtrFromBody(raw, "description"), - Upstreams: upstreamsFromBody(raw), - }) + if req.Upstreams != nil { + ups := upstreamsFromList(*req.Upstreams) + in.Upstreams = &ups + } + + out, err := h.ca.UpdateRepository(r.Context(), in) if err != nil { writeErr(w, err) @@ -88,10 +96,12 @@ func (h *Handler) deleteRepository(w http.ResponseWriter, r *http.Request) { func (h *Handler) listRepositories(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() - repos, next, err := h.ca.ListRepositories(r.Context(), q.Get("repository-prefix"), driver.Page{ - NextToken: q.Get("next-token"), - MaxResults: atoiDefault(q.Get("max-results")), - }) + page, ok := pageFromQuery(w, q) + if !ok { + return + } + + repos, next, err := h.ca.ListRepositories(r.Context(), q.Get("repository-prefix"), page) if err != nil { writeErr(w, err) @@ -102,19 +112,27 @@ func (h *Handler) listRepositories(w http.ResponseWriter, r *http.Request) { } func (h *Handler) listRepositoriesInDomain(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - methodNotAllowed(w) + q := r.URL.Query() + page, ok := pageFromQuery(w, q) + if !ok { return } - q := r.URL.Query() + var ( + repos []*driver.Repository + next string + err error + ) + + if admin := q.Get("administrator-account"); admin != "" && h.adminLs != nil { + repos, next, err = h.adminLs.ListRepositoriesInDomainByAdministrator(r.Context(), domainRefFromQuery(q), admin, + q.Get("repository-prefix"), page) + } else { + repos, next, err = h.ca.ListRepositoriesInDomain(r.Context(), q.Get("domain"), q.Get("domain-owner"), + q.Get("repository-prefix"), page) + } - repos, next, err := h.ca.ListRepositoriesInDomain(r.Context(), q.Get("domain"), q.Get("domain-owner"), - q.Get("repository-prefix"), driver.Page{ - NextToken: q.Get("next-token"), - MaxResults: atoiDefault(q.Get("max-results")), - }) if err != nil { writeErr(w, err) @@ -161,9 +179,7 @@ func writeRepositorySummaries(w http.ResponseWriter, repos []*driver.Repository, } body := map[string]any{"repositories": summaries} - if next != "" { - body["nextToken"] = next - } + putNonEmpty(body, "nextToken", next) writeJSON(w, body) } diff --git a/server/aws/codeartifact/routes.go b/server/aws/codeartifact/routes.go new file mode 100644 index 000000000..297216566 --- /dev/null +++ b/server/aws/codeartifact/routes.go @@ -0,0 +1,156 @@ +package codeartifact + +import ( + "net/http" +) + +// Operation names used by more than one table. +const ( + opGetAuthorizationToken = "GetAuthorizationToken" + opPutDomainPolicy = "PutDomainPermissionsPolicy" + opCopyPackageVersions = "CopyPackageVersions" + opUpdatePackageGroup = "UpdatePackageGroup" +) + +// routeDef is one CodeArtifact operation: its verb, path and API name. +type routeDef struct { + method string + path string + op string +} + +// routeDefs is the single source of truth for routing and for the CloudTrail +// event name of a request: all 48 CodeArtifact operations. +func routeDefs() []routeDef { + return []routeDef{ + {http.MethodPost, "/v1/domain", "CreateDomain"}, + {http.MethodGet, "/v1/domain", "DescribeDomain"}, + {http.MethodDelete, "/v1/domain", "DeleteDomain"}, + {http.MethodPost, "/v1/domains", "ListDomains"}, + {http.MethodPost, "/v1/repository", "CreateRepository"}, + {http.MethodGet, "/v1/repository", "DescribeRepository"}, + {http.MethodPut, "/v1/repository", "UpdateRepository"}, + {http.MethodDelete, "/v1/repository", "DeleteRepository"}, + {http.MethodPost, "/v1/repositories", "ListRepositories"}, + {http.MethodPost, "/v1/domain/repositories", "ListRepositoriesInDomain"}, + {http.MethodPost, "/v1/repository/external-connection", "AssociateExternalConnection"}, + {http.MethodDelete, "/v1/repository/external-connection", "DisassociateExternalConnection"}, + {http.MethodPost, "/v1/tag", "TagResource"}, + {http.MethodPost, "/v1/untag", "UntagResource"}, + {http.MethodPost, "/v1/tags", "ListTagsForResource"}, + {http.MethodPost, "/v1/authorization-token", opGetAuthorizationToken}, + {http.MethodGet, "/v1/repository/endpoint", "GetRepositoryEndpoint"}, + {http.MethodPut, "/v1/domain/permissions/policy", opPutDomainPolicy}, + {http.MethodGet, "/v1/domain/permissions/policy", "GetDomainPermissionsPolicy"}, + {http.MethodDelete, "/v1/domain/permissions/policy", "DeleteDomainPermissionsPolicy"}, + {http.MethodPut, "/v1/repository/permissions/policy", "PutRepositoryPermissionsPolicy"}, + {http.MethodGet, "/v1/repository/permissions/policy", "GetRepositoryPermissionsPolicy"}, + {http.MethodDelete, "/v1/repository/permissions/policies", "DeleteRepositoryPermissionsPolicy"}, + {http.MethodPost, "/v1/packages", "ListPackages"}, + {http.MethodGet, "/v1/package", "DescribePackage"}, + {http.MethodPost, "/v1/package", "PutPackageOriginConfiguration"}, + {http.MethodDelete, "/v1/package", "DeletePackage"}, + {http.MethodPost, "/v1/package/versions", "ListPackageVersions"}, + {http.MethodGet, "/v1/package/version", "DescribePackageVersion"}, + {http.MethodPost, "/v1/package/version/publish", "PublishPackageVersion"}, + {http.MethodPost, "/v1/package/version/assets", "ListPackageVersionAssets"}, + {http.MethodGet, "/v1/package/version/asset", "GetPackageVersionAsset"}, + {http.MethodGet, "/v1/package/version/readme", "GetPackageVersionReadme"}, + {http.MethodPost, "/v1/package/version/dependencies", "ListPackageVersionDependencies"}, + {http.MethodPost, "/v1/package/versions/update_status", "UpdatePackageVersionsStatus"}, + {http.MethodPost, "/v1/package/versions/dispose", "DisposePackageVersions"}, + {http.MethodPost, "/v1/package/versions/delete", "DeletePackageVersions"}, + {http.MethodPost, "/v1/package/versions/copy", opCopyPackageVersions}, + {http.MethodPost, "/v1/package-group", "CreatePackageGroup"}, + {http.MethodGet, "/v1/package-group", "DescribePackageGroup"}, + {http.MethodPut, "/v1/package-group", opUpdatePackageGroup}, + {http.MethodDelete, "/v1/package-group", "DeletePackageGroup"}, + {http.MethodPut, "/v1/package-group-origin-configuration", "UpdatePackageGroupOriginConfiguration"}, + {http.MethodPost, "/v1/package-groups", "ListPackageGroups"}, + {http.MethodPost, "/v1/package-groups/sub-groups", "ListSubPackageGroups"}, + {http.MethodGet, "/v1/list-associated-packages", "ListAssociatedPackages"}, + {http.MethodGet, "/v1/get-associated-package-group", "GetAssociatedPackageGroup"}, + {http.MethodGet, "/v1/package-group-allowed-repositories", "ListAllowedRepositoriesForGroup"}, + } +} + +// OperationName returns the CodeArtifact API operation name (the CloudTrail +// EventName) for a request, or "" when the method and path match no operation. +// The REST-JSON protocol has no X-Amz-Target header, so the name is derived from +// the same method + path pairs the handler routes on. +func OperationName(method, path string) string { + for _, d := range routeDefs() { + if d.method == method && d.path == path { + return d.op + } + } + + return "" +} + +// capability reports whether the driver implements the optional capability an +// operation needs; the base operations need none. +func (h *Handler) capability(op string) bool { + switch op { + case opGetAuthorizationToken, "GetRepositoryEndpoint": + return h.access != nil + case opPutDomainPolicy, "GetDomainPermissionsPolicy", "DeleteDomainPermissionsPolicy", + "PutRepositoryPermissionsPolicy", "GetRepositoryPermissionsPolicy", "DeleteRepositoryPermissionsPolicy": + return h.policies != nil + case "ListPackages", "DescribePackage", "PutPackageOriginConfiguration", "DeletePackage": + return h.packages != nil + case "ListPackageVersions", "DescribePackageVersion", "PublishPackageVersion", "ListPackageVersionAssets", + "GetPackageVersionAsset", "GetPackageVersionReadme", "ListPackageVersionDependencies", + "UpdatePackageVersionsStatus", "DisposePackageVersions", "DeletePackageVersions", opCopyPackageVersions: + return h.versions != nil + case "CreatePackageGroup", "DescribePackageGroup", opUpdatePackageGroup, "DeletePackageGroup", + "UpdatePackageGroupOriginConfiguration", "ListPackageGroups", "ListSubPackageGroups", + "ListAssociatedPackages", "GetAssociatedPackageGroup", "ListAllowedRepositoriesForGroup": + return h.groups != nil + default: + return true + } +} + +// buildRoutes fills the route table from routeDefs and the operation handlers. +func (h *Handler) buildRoutes() { + handlers := h.operationHandlers() + + for _, d := range routeDefs() { + if !h.capability(d.op) { + continue + } + + h.routes[routeKey{d.method, d.path}] = handlers[d.op] + h.paths[d.path] = true + } +} + +// operationHandlers maps each operation name to its handler. +func (h *Handler) operationHandlers() map[string]http.HandlerFunc { + return map[string]http.HandlerFunc{ + "CreateDomain": h.createDomain, "DescribeDomain": h.describeDomain, "DeleteDomain": h.deleteDomain, "ListDomains": h.listDomains, + "CreateRepository": h.createRepository, "DescribeRepository": h.describeRepository, + "UpdateRepository": h.updateRepository, "DeleteRepository": h.deleteRepository, + "ListRepositories": h.listRepositories, "ListRepositoriesInDomain": h.listRepositoriesInDomain, + "AssociateExternalConnection": h.associateExternalConnection, "DisassociateExternalConnection": h.disassociateExternalConnection, + "TagResource": h.tagResource, "UntagResource": h.untagResource, "ListTagsForResource": h.listTagsForResource, + opGetAuthorizationToken: h.getAuthorizationToken, "GetRepositoryEndpoint": h.getRepositoryEndpoint, + opPutDomainPolicy: h.putDomainPolicy, "GetDomainPermissionsPolicy": h.getDomainPolicy, + "DeleteDomainPermissionsPolicy": h.deleteDomainPolicy, "PutRepositoryPermissionsPolicy": h.putRepositoryPolicy, + "GetRepositoryPermissionsPolicy": h.getRepositoryPolicy, "DeleteRepositoryPermissionsPolicy": h.deleteRepositoryPolicy, + "ListPackages": h.listPackages, "DescribePackage": h.describePackage, + "PutPackageOriginConfiguration": h.putPackageOriginConfiguration, "DeletePackage": h.deletePackage, + "ListPackageVersions": h.listPackageVersions, "DescribePackageVersion": h.describePackageVersion, + "PublishPackageVersion": h.publishPackageVersion, "ListPackageVersionAssets": h.listPackageVersionAssets, + "GetPackageVersionAsset": h.getPackageVersionAsset, "GetPackageVersionReadme": h.getPackageVersionReadme, + "ListPackageVersionDependencies": h.listPackageVersionDependencies, + "UpdatePackageVersionsStatus": h.updatePackageVersionsStatus, "DisposePackageVersions": h.disposePackageVersions, + "DeletePackageVersions": h.deletePackageVersions, opCopyPackageVersions: h.copyPackageVersions, + "CreatePackageGroup": h.createPackageGroup, "DescribePackageGroup": h.describePackageGroup, + opUpdatePackageGroup: h.updatePackageGroup, "DeletePackageGroup": h.deletePackageGroup, + "UpdatePackageGroupOriginConfiguration": h.updatePackageGroupOrigin, "ListPackageGroups": h.listPackageGroups, + "ListSubPackageGroups": h.listSubPackageGroups, "ListAssociatedPackages": h.listAssociatedPackages, + "GetAssociatedPackageGroup": h.getAssociatedPackageGroup, "ListAllowedRepositoriesForGroup": h.listAllowedRepositories, + } +} diff --git a/server/aws/codeartifact/routes_test.go b/server/aws/codeartifact/routes_test.go new file mode 100644 index 000000000..128ba929a --- /dev/null +++ b/server/aws/codeartifact/routes_test.go @@ -0,0 +1,159 @@ +package codeartifact_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "sort" + "strings" + "testing" + + "github.com/stackshy/cloudemu/v2" + "github.com/stackshy/cloudemu/v2/server/aws/codeartifact" +) + +// modelOperation is one operation of the CodeArtifact API model (aws-sdk-go-v2 +// service/codeartifact v1.45.0, from the botocore service-2.json): its route and +// the exceptions the API says it can return. +type modelOperation struct { + Method string `json:"method"` + Path string `json:"path"` + Errors []string `json:"errors"` +} + +func loadModel(t *testing.T) map[string]modelOperation { + t.Helper() + + raw, err := os.ReadFile("testdata/operations.json") + if err != nil { + t.Fatalf("read model: %v", err) + } + + var ops map[string]modelOperation + if err = json.Unmarshal(raw, &ops); err != nil { + t.Fatalf("parse model: %v", err) + } + + return ops +} + +func fullHandler() *codeartifact.Handler { + return codeartifact.New(cloudemu.NewAWS().CodeArtifact) +} + +// TestAllFortyEightOperationsRouted proves the route table is exactly the API +// model: every operation has its verb and path, none answers "unsupported path", +// and the CloudTrail name of each route is the operation name. +func TestAllFortyEightOperationsRouted(t *testing.T) { + ops := loadModel(t) + + const operations = 48 + if len(ops) != operations { + t.Fatalf("model has %d operations, want %d", len(ops), operations) + } + + h := fullHandler() + names := make([]string, 0, len(ops)) + + for name := range ops { + names = append(names, name) + } + + sort.Strings(names) + + for _, name := range names { + op := ops[name] + + if got := codeartifact.OperationName(op.Method, op.Path); got != name { + t.Errorf("OperationName(%s %s) = %q, want %q", op.Method, op.Path, got, name) + } + + target := op.Path + "?resourceArn=arn:aws:codeartifact:us-east-1:123456789012:domain/dom" + req := httptest.NewRequest(op.Method, target, strings.NewReader("{}")) + + if !h.Matches(req) { + t.Errorf("%s: Matches(%s %s) = false", name, op.Method, op.Path) + + continue + } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if msg := rec.Body.String(); strings.Contains(msg, "unsupported path") { + t.Errorf("%s: answered %q", name, msg) + } + } +} + +func TestMatchesAllRootsAndSiblings(t *testing.T) { + h := fullHandler() + ca := "?resourceArn=arn:aws:codeartifact:us-east-1:123456789012:repository/d/r" + + positive := []struct{ method, path string }{ + {http.MethodPost, "/v1/authorization-token"}, {http.MethodGet, "/v1/repository/endpoint"}, + {http.MethodPut, "/v1/domain/permissions/policy"}, {http.MethodDelete, "/v1/repository/permissions/policies"}, + {http.MethodPost, "/v1/packages"}, {http.MethodGet, "/v1/package"}, {http.MethodPost, "/v1/package/versions/copy"}, + {http.MethodPost, "/v1/package/version/publish"}, {http.MethodGet, "/v1/package/version/asset"}, + {http.MethodPost, "/v1/package-group"}, {http.MethodPost, "/v1/package-groups/sub-groups"}, + {http.MethodGet, "/v1/get-associated-package-group"}, {http.MethodGet, "/v1/list-associated-packages"}, + {http.MethodGet, "/v1/package-group-allowed-repositories"}, {http.MethodPut, "/v1/package-group-origin-configuration"}, + } + + for _, c := range positive { + if !h.Matches(httptest.NewRequest(c.method, c.path, nil)) { + t.Errorf("Matches(%s %s) = false", c.method, c.path) + } + } + + for _, p := range []string{"/v1/tag", "/v1/tags", "/v1/untag"} { + if !h.Matches(httptest.NewRequest(http.MethodPost, p+ca, nil)) { + t.Errorf("Matches(POST %s with a CodeArtifact ARN) = false", p) + } + } + + negative := []struct{ method, path string }{ + {http.MethodPost, "/v1/tags?resourceArn=arn:aws:mq:us-east-1:1:broker:b-1"}, + {http.MethodPost, "/v1/tag"}, + {http.MethodGet, "/v1/tags/arn%3Aaws%3Amq%3Aus-east-1%3A1%3Abroker%3Ab-1"}, + {http.MethodPost, "/v1/brokers"}, {http.MethodGet, "/v1/apis"}, {http.MethodGet, "/v1/clusters"}, + {http.MethodGet, "/v1/domain/unknown"}, {http.MethodGet, "/v1/package/unknown"}, {http.MethodGet, "/v1/package-group/unknown"}, + {http.MethodGet, "/v1"}, {http.MethodGet, "/v1/"}, {http.MethodGet, "/bucket/v1/domain"}, {http.MethodGet, "/some-bucket/key"}, + {http.MethodGet, "/2015-03-31/functions"}, + } + + for _, c := range negative { + if h.Matches(httptest.NewRequest(c.method, c.path, nil)) { + t.Errorf("Matches(%s %s) = true, want false", c.method, c.path) + } + } +} + +func TestWrongVerbAndUnknownPath(t *testing.T) { + h := fullHandler() + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPatch, "/v1/domain", nil)) + + if rec.Code != http.StatusBadRequest || rec.Header().Get("X-Amzn-Errortype") != "ValidationException" { + t.Fatalf("wrong verb: %d %s", rec.Code, rec.Header().Get("X-Amzn-Errortype")) + } + + rec = httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/nothing", nil)) + + if rec.Code != http.StatusNotFound || rec.Header().Get("X-Amzn-Errortype") != "ResourceNotFoundException" { + t.Fatalf("unknown path: %d %s", rec.Code, rec.Header().Get("X-Amzn-Errortype")) + } +} + +func TestOperationNameUnknown(t *testing.T) { + for _, c := range []struct{ method, path string }{ + {http.MethodGet, "/v1/nothing"}, {http.MethodPatch, "/v1/domain"}, {http.MethodGet, ""}, + } { + if got := codeartifact.OperationName(c.method, c.path); got != "" { + t.Errorf("OperationName(%s %s) = %q, want empty", c.method, c.path, got) + } + } +} diff --git a/server/aws/codeartifact/sdk_access_policy_test.go b/server/aws/codeartifact/sdk_access_policy_test.go new file mode 100644 index 000000000..02c79e587 --- /dev/null +++ b/server/aws/codeartifact/sdk_access_policy_test.go @@ -0,0 +1,247 @@ +package codeartifact_test + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + ca "github.com/aws/aws-sdk-go-v2/service/codeartifact" + catypes "github.com/aws/aws-sdk-go-v2/service/codeartifact/types" +) + +func TestSDKInvalidNames(t *testing.T) { + e := newEnv(t) + ctx := context.Background() + + for _, bad := range []string{"my domain", "UPPER", "a", "-dash"} { + _, err := e.c.CreateDomain(ctx, &ca.CreateDomainInput{Domain: aws.String(bad)}) + + apiErr := requireAPICode(t, err, "ValidationException", 400) + if !strings.Contains(apiErr.ErrorMessage(), "domain name") { + t.Fatalf("message = %q", apiErr.ErrorMessage()) + } + + var ve *catypes.ValidationException + if !errors.As(err, &ve) || ve.Reason != catypes.ValidationExceptionReasonFieldValidationFailed { + t.Fatalf("reason = %+v", ve) + } + } + + e.seed(t) + + _, err := e.c.CreateRepository(ctx, &ca.CreateRepositoryInput{Domain: aws.String("dom"), Repository: aws.String("a b")}) + requireAPICode(t, err, "ValidationException", 400) + + got, err := e.c.ListDomains(ctx, &ca.ListDomainsInput{}) + if err != nil || len(got.Domains) != 1 { + t.Fatalf("domains = %+v err=%v", got, err) + } +} + +func TestSDKDomainOwnerMismatch(t *testing.T) { + e := newEnv(t) + e.seed(t, "repo") + + ctx := context.Background() + foreign := aws.String("999999999999") + + _, err := e.c.DescribeDomain(ctx, &ca.DescribeDomainInput{Domain: aws.String("dom"), DomainOwner: foreign}) + requireAPICode(t, err, "ResourceNotFoundException", 404) + + _, err = e.c.DescribeRepository(ctx, &ca.DescribeRepositoryInput{Domain: aws.String("dom"), DomainOwner: foreign, Repository: aws.String("repo")}) + requireAPICode(t, err, "ResourceNotFoundException", 404) + + _, err = e.c.ListRepositoriesInDomain(ctx, &ca.ListRepositoriesInDomainInput{Domain: aws.String("dom"), DomainOwner: foreign}) + requireAPICode(t, err, "ResourceNotFoundException", 404) + + _, err = e.c.CreateRepository(ctx, &ca.CreateRepositoryInput{Domain: aws.String("dom"), DomainOwner: foreign, Repository: aws.String("other")}) + requireAPICode(t, err, "ResourceNotFoundException", 404) + + owner := e.cloud.CodeArtifact + d, derr := owner.DescribeDomain(ctx, "dom", "") + + if derr != nil { + t.Fatal(derr) + } + + if _, err = e.c.DescribeDomain(ctx, &ca.DescribeDomainInput{Domain: aws.String("dom"), DomainOwner: aws.String(d.Owner)}); err != nil { + t.Fatalf("matching owner rejected: %v", err) + } + + out, err := e.c.ListRepositoriesInDomain(ctx, &ca.ListRepositoriesInDomainInput{ + Domain: aws.String("dom"), AdministratorAccount: aws.String("999999999999"), + }) + if err != nil || len(out.Repositories) != 0 { + t.Fatalf("administrator filter: %+v err=%v", out, err) + } +} + +func TestSDKExternalConnectionErrors(t *testing.T) { + e := newEnv(t) + e.seed(t, "repo") + + ctx := context.Background() + in := func(name string) *ca.AssociateExternalConnectionInput { + return &ca.AssociateExternalConnectionInput{ + Domain: aws.String("dom"), Repository: aws.String("repo"), ExternalConnection: aws.String(name), + } + } + + out, err := e.c.AssociateExternalConnection(ctx, in("public:npmjs")) + if err != nil { + t.Fatalf("associate: %v", err) + } + + if len(out.Repository.ExternalConnections) != 1 || out.Repository.ExternalConnections[0].Status != catypes.ExternalConnectionStatusAvailable || + out.Repository.ExternalConnections[0].PackageFormat != catypes.PackageFormatNpm { + t.Fatalf("connections = %+v", out.Repository.ExternalConnections) + } + + _, err = e.c.AssociateExternalConnection(ctx, in("public:pypi")) + requireAPICode(t, err, "ConflictException", 409) + + var ce *catypes.ConflictException + if !errors.As(err, &ce) || aws.ToString(ce.ResourceId) != "repo" || ce.ResourceType != catypes.ResourceTypeRepository { + t.Fatalf("conflict identity = %+v", ce) + } + + _, err = e.c.AssociateExternalConnection(ctx, in("public:nope")) + requireAPICode(t, err, "ResourceNotFoundException", 404) + + _, err = e.c.DisassociateExternalConnection(ctx, &ca.DisassociateExternalConnectionInput{ + Domain: aws.String("dom"), Repository: aws.String("repo"), ExternalConnection: aws.String("public:pypi"), + }) + requireAPICode(t, err, "ResourceNotFoundException", 404) +} + +func TestSDKQuotaExceeded(t *testing.T) { + e := newEnv(t) + ctx := context.Background() + + for i := 0; i < 10; i++ { + if _, err := e.c.CreateDomain(ctx, &ca.CreateDomainInput{Domain: aws.String("dom-" + string(rune('a'+i)) + "x")}); err != nil { + t.Fatal(err) + } + } + + _, err := e.c.CreateDomain(ctx, &ca.CreateDomainInput{Domain: aws.String("dom-over")}) + requireAPICode(t, err, "ServiceQuotaExceededException", 402) + + var qe *catypes.ServiceQuotaExceededException + if !errors.As(err, &qe) || qe.ResourceType != catypes.ResourceTypeDomain { + t.Fatalf("quota error = %+v", qe) + } +} + +func TestSDKGetAuthorizationToken(t *testing.T) { + e := newEnv(t) + e.seed(t) + + ctx := context.Background() + before := time.Now() + + out, err := e.c.GetAuthorizationToken(ctx, &ca.GetAuthorizationTokenInput{Domain: aws.String("dom"), DurationSeconds: aws.Int64(900)}) + if err != nil { + t.Fatalf("GetAuthorizationToken: %v", err) + } + + if aws.ToString(out.AuthorizationToken) == "" || out.Expiration == nil || out.Expiration.Before(before.Add(14*time.Minute)) { + t.Fatalf("token = %+v", out) + } + + _, err = e.c.GetAuthorizationToken(ctx, &ca.GetAuthorizationTokenInput{Domain: aws.String("ghost")}) + requireAPICode(t, err, "ResourceNotFoundException", 404) + + _, err = e.c.GetAuthorizationToken(ctx, &ca.GetAuthorizationTokenInput{Domain: aws.String("dom"), DurationSeconds: aws.Int64(10)}) + requireAPICode(t, err, "ValidationException", 400) +} + +func TestSDKGetRepositoryEndpoint(t *testing.T) { + e := newEnv(t) + e.seed(t, "repo") + + ctx := context.Background() + + out, err := e.c.GetRepositoryEndpoint(ctx, &ca.GetRepositoryEndpointInput{ + Domain: aws.String("dom"), Repository: aws.String("repo"), Format: catypes.PackageFormatNpm, + }) + if err != nil { + t.Fatalf("GetRepositoryEndpoint: %v", err) + } + + if got := aws.ToString(out.RepositoryEndpoint); !strings.HasPrefix(got, "https://dom-") || !strings.HasSuffix(got, ".amazonaws.com/npm/repo/") { + t.Fatalf("endpoint = %q", got) + } + + _, err = e.c.GetRepositoryEndpoint(ctx, &ca.GetRepositoryEndpointInput{ + Domain: aws.String("dom"), Repository: aws.String("ghost"), Format: catypes.PackageFormatNpm, + }) + requireAPICode(t, err, "ResourceNotFoundException", 404) +} + +func TestSDKPolicies(t *testing.T) { + e := newEnv(t) + e.seed(t, "repo") + + ctx := context.Background() + doc := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"codeartifact:ReadFromRepository","Resource":"*"}]}` + + _, err := e.c.GetDomainPermissionsPolicy(ctx, &ca.GetDomainPermissionsPolicyInput{Domain: aws.String("dom")}) + requireAPICode(t, err, "ResourceNotFoundException", 404) + + put, err := e.c.PutDomainPermissionsPolicy(ctx, &ca.PutDomainPermissionsPolicyInput{Domain: aws.String("dom"), PolicyDocument: aws.String(doc)}) + if err != nil { + t.Fatalf("PutDomainPermissionsPolicy: %v", err) + } + + if aws.ToString(put.Policy.Revision) == "" || aws.ToString(put.Policy.Document) != doc { + t.Fatalf("put = %+v", put.Policy) + } + + got, err := e.c.GetDomainPermissionsPolicy(ctx, &ca.GetDomainPermissionsPolicyInput{Domain: aws.String("dom")}) + if err != nil || aws.ToString(got.Policy.Revision) != aws.ToString(put.Policy.Revision) { + t.Fatalf("get = %+v err=%v", got, err) + } + + _, err = e.c.PutDomainPermissionsPolicy(ctx, &ca.PutDomainPermissionsPolicyInput{ + Domain: aws.String("dom"), PolicyDocument: aws.String(doc), PolicyRevision: aws.String("stale"), + }) + requireAPICode(t, err, "ConflictException", 409) + + _, err = e.c.DeleteDomainPermissionsPolicy(ctx, &ca.DeleteDomainPermissionsPolicyInput{Domain: aws.String("dom"), PolicyRevision: aws.String("stale")}) + requireAPICode(t, err, "ConflictException", 409) + + _, err = e.c.PutDomainPermissionsPolicy(ctx, &ca.PutDomainPermissionsPolicyInput{Domain: aws.String("dom"), PolicyDocument: aws.String("nope")}) + requireAPICode(t, err, "ValidationException", 400) + + del, err := e.c.DeleteDomainPermissionsPolicy(ctx, &ca.DeleteDomainPermissionsPolicyInput{ + Domain: aws.String("dom"), PolicyRevision: put.Policy.Revision, + }) + if err != nil || aws.ToString(del.Policy.Revision) != aws.ToString(put.Policy.Revision) { + t.Fatalf("delete = %+v err=%v", del, err) + } + + rput, err := e.c.PutRepositoryPermissionsPolicy(ctx, &ca.PutRepositoryPermissionsPolicyInput{ + Domain: aws.String("dom"), Repository: aws.String("repo"), PolicyDocument: aws.String(doc), + }) + if err != nil { + t.Fatalf("PutRepositoryPermissionsPolicy: %v", err) + } + + rget, err := e.c.GetRepositoryPermissionsPolicy(ctx, &ca.GetRepositoryPermissionsPolicyInput{Domain: aws.String("dom"), Repository: aws.String("repo")}) + if err != nil || aws.ToString(rget.Policy.ResourceArn) != aws.ToString(rput.Policy.ResourceArn) { + t.Fatalf("repo get = %+v err=%v", rget, err) + } + + if _, err = e.c.DeleteRepositoryPermissionsPolicy(ctx, &ca.DeleteRepositoryPermissionsPolicyInput{ + Domain: aws.String("dom"), Repository: aws.String("repo"), PolicyRevision: rput.Policy.Revision, + }); err != nil { + t.Fatalf("DeleteRepositoryPermissionsPolicy: %v", err) + } + + _, err = e.c.GetRepositoryPermissionsPolicy(ctx, &ca.GetRepositoryPermissionsPolicyInput{Domain: aws.String("dom"), Repository: aws.String("repo")}) + requireAPICode(t, err, "ResourceNotFoundException", 404) +} diff --git a/server/aws/codeartifact/sdk_helpers_test.go b/server/aws/codeartifact/sdk_helpers_test.go new file mode 100644 index 000000000..a7e716b7b --- /dev/null +++ b/server/aws/codeartifact/sdk_helpers_test.go @@ -0,0 +1,144 @@ +package codeartifact_test + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "io" + "net/http/httptest" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + ca "github.com/aws/aws-sdk-go-v2/service/codeartifact" + catypes "github.com/aws/aws-sdk-go-v2/service/codeartifact/types" + "github.com/aws/smithy-go" + + "github.com/stackshy/cloudemu/v2" + awsprovider "github.com/stackshy/cloudemu/v2/providers/aws" + awsserver "github.com/stackshy/cloudemu/v2/server/aws" +) + +// env is a running emulator with a real CodeArtifact SDK client pointed at it. +type env struct { + c *ca.Client + cloud *awsprovider.Provider + url string +} + +func newEnv(t *testing.T) *env { + t.Helper() + + cloud := cloudemu.NewAWS() + srv := awsserver.New(awsserver.Drivers{CodeArtifact: cloud.CodeArtifact}) + + ts := httptest.NewServer(srv) + t.Cleanup(ts.Close) + + cfg, err := awsconfig.LoadDefaultConfig(context.Background(), + awsconfig.WithRegion("us-east-1"), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), + ) + if err != nil { + t.Fatalf("aws config: %v", err) + } + + return &env{ + c: ca.NewFromConfig(cfg, func(o *ca.Options) { o.BaseEndpoint = aws.String(ts.URL) }), + cloud: cloud, + url: ts.URL, + } +} + +// seed creates domain "dom" with the given repositories. +func (e *env) seed(t *testing.T, repos ...string) { + t.Helper() + + ctx := context.Background() + + if _, err := e.c.CreateDomain(ctx, &ca.CreateDomainInput{Domain: aws.String("dom")}); err != nil { + t.Fatalf("CreateDomain: %v", err) + } + + for _, r := range repos { + if _, err := e.c.CreateRepository(ctx, &ca.CreateRepositoryInput{Domain: aws.String("dom"), Repository: aws.String(r)}); err != nil { + t.Fatalf("CreateRepository %s: %v", r, err) + } + } +} + +func sha256Hex(b []byte) string { + sum := sha256.Sum256(b) + + return hex.EncodeToString(sum[:]) +} + +// publish publishes one asset of a generic package through the SDK. +func (e *env) publish(t *testing.T, repo, pkg, version, asset string, data []byte, unfinished bool) *ca.PublishPackageVersionOutput { + t.Helper() + + in := &ca.PublishPackageVersionInput{ + Domain: aws.String("dom"), Repository: aws.String(repo), Format: catypes.PackageFormatGeneric, + Namespace: aws.String("ns"), Package: aws.String(pkg), PackageVersion: aws.String(version), + AssetName: aws.String(asset), AssetSHA256: aws.String(sha256Hex(data)), AssetContent: bytes.NewReader(data), + } + if unfinished { + in.Unfinished = aws.Bool(true) + } + + out, err := e.c.PublishPackageVersion(context.Background(), in) + if err != nil { + t.Fatalf("PublishPackageVersion: %v", err) + } + + return out +} + +// genericVersion is the SDK input naming one generic package version. +func pkgInput(repo, pkg string) (domain, repository *string, format catypes.PackageFormat, ns, name *string) { + return aws.String("dom"), aws.String(repo), catypes.PackageFormatGeneric, aws.String("ns"), aws.String(pkg) +} + +// requireAPICode asserts err is a smithy API error with the given code and HTTP +// status, and returns it. +func requireAPICode(t *testing.T, err error, code string, status int) smithy.APIError { + t.Helper() + + if err == nil { + t.Fatalf("want %s, got nil", code) + } + + var apiErr smithy.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("want smithy.APIError %s, got %T: %v", code, err, err) + } + + if apiErr.ErrorCode() != code { + t.Fatalf("error code = %q, want %q (%v)", apiErr.ErrorCode(), code, err) + } + + if status != 0 { + var re interface{ HTTPStatusCode() int } + if errors.As(err, &re) && re.HTTPStatusCode() != status { + t.Fatalf("http status = %d, want %d", re.HTTPStatusCode(), status) + } + } + + return apiErr +} + +func readAll(t *testing.T, r io.ReadCloser) []byte { + t.Helper() + + defer r.Close() + + b, err := io.ReadAll(r) + if err != nil { + t.Fatalf("read body: %v", err) + } + + return b +} diff --git a/server/aws/codeartifact/sdk_packages_test.go b/server/aws/codeartifact/sdk_packages_test.go new file mode 100644 index 000000000..ffe433e51 --- /dev/null +++ b/server/aws/codeartifact/sdk_packages_test.go @@ -0,0 +1,349 @@ +package codeartifact_test + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + ca "github.com/aws/aws-sdk-go-v2/service/codeartifact" + catypes "github.com/aws/aws-sdk-go-v2/service/codeartifact/types" +) + +func TestSDKPublishAndGetAsset(t *testing.T) { + e := newEnv(t) + e.seed(t, "repo") + + ctx := context.Background() + data := []byte("bytes through the real SDK \x00\x01\xff") + + out := e.publish(t, "repo", "pkg", "1.0.0", "bin.dat", data, false) + + if out.Status != catypes.PackageVersionStatusPublished || aws.ToString(out.VersionRevision) == "" || + aws.ToString(out.Asset.Name) != "bin.dat" || aws.ToInt64(out.Asset.Size) != int64(len(data)) || + out.Asset.Hashes[string(catypes.HashAlgorithmSha256)] != sha256Hex(data) { + t.Fatalf("publish = %+v asset=%+v", out, out.Asset) + } + + domain, repo, format, ns, name := pkgInput("repo", "pkg") + + got, err := e.c.GetPackageVersionAsset(ctx, &ca.GetPackageVersionAssetInput{ + Domain: domain, Repository: repo, Format: format, Namespace: ns, Package: name, + PackageVersion: aws.String("1.0.0"), Asset: aws.String("bin.dat"), + }) + if err != nil { + t.Fatalf("GetPackageVersionAsset: %v", err) + } + + if aws.ToString(got.AssetName) != "bin.dat" || aws.ToString(got.PackageVersion) != "1.0.0" || + aws.ToString(got.PackageVersionRevision) != aws.ToString(out.VersionRevision) { + t.Fatalf("headers: %q %q %q", aws.ToString(got.AssetName), aws.ToString(got.PackageVersion), aws.ToString(got.PackageVersionRevision)) + } + + if body := readAll(t, got.Asset); !bytes.Equal(body, data) { + t.Fatalf("downloaded %d bytes, want %d identical bytes", len(body), len(data)) + } + + _, err = e.c.PublishPackageVersion(ctx, &ca.PublishPackageVersionInput{ + Domain: domain, Repository: repo, Format: format, Namespace: ns, Package: name, PackageVersion: aws.String("2"), + AssetName: aws.String("a"), AssetSHA256: aws.String(sha256Hex([]byte("other"))), AssetContent: bytes.NewReader(data), + }) + requireAPICode(t, err, "ValidationException", 400) + + _, err = e.c.GetPackageVersionAsset(ctx, &ca.GetPackageVersionAssetInput{ + Domain: domain, Repository: repo, Format: format, Namespace: ns, Package: name, + PackageVersion: aws.String("1.0.0"), Asset: aws.String("missing"), + }) + requireAPICode(t, err, "ResourceNotFoundException", 404) + + _, err = e.c.PublishPackageVersion(ctx, &ca.PublishPackageVersionInput{ + Domain: domain, Repository: repo, Format: catypes.PackageFormatNpm, Package: name, PackageVersion: aws.String("1"), + AssetName: aws.String("a"), AssetSHA256: aws.String(sha256Hex(data)), AssetContent: bytes.NewReader(data), + }) + requireAPICode(t, err, "ValidationException", 400) +} + +func TestSDKPackageReadsAndStatus(t *testing.T) { + e := newEnv(t) + e.seed(t, "src", "dst") + + ctx := context.Background() + domain, repo, format, ns, name := pkgInput("src", "pkg") + + rev := aws.ToString(e.publish(t, "src", "pkg", "1", "a.txt", []byte("one"), false).VersionRevision) + e.publish(t, "src", "pkg", "2", "b.txt", []byte("two"), true) + + pkgs, err := e.c.ListPackages(ctx, &ca.ListPackagesInput{Domain: domain, Repository: repo}) + if err != nil || len(pkgs.Packages) != 1 || aws.ToString(pkgs.Packages[0].Package) != "pkg" || + pkgs.Packages[0].OriginConfiguration.Restrictions.Publish != catypes.AllowPublishAllow { + t.Fatalf("ListPackages = %+v err=%v", pkgs, err) + } + + desc, err := e.c.DescribePackage(ctx, &ca.DescribePackageInput{Domain: domain, Repository: repo, Format: format, Namespace: ns, Package: name}) + if err != nil || aws.ToString(desc.Package.Name) != "pkg" || aws.ToString(desc.Package.Namespace) != "ns" { + t.Fatalf("DescribePackage = %+v err=%v", desc, err) + } + + vers, err := e.c.ListPackageVersions(ctx, &ca.ListPackageVersionsInput{Domain: domain, Repository: repo, Format: format, Namespace: ns, Package: name}) + if err != nil || len(vers.Versions) != 2 || aws.ToString(vers.DefaultDisplayVersion) != "1" { + t.Fatalf("ListPackageVersions = %+v err=%v", vers, err) + } + + pv, err := e.c.DescribePackageVersion(ctx, &ca.DescribePackageVersionInput{ + Domain: domain, Repository: repo, Format: format, Namespace: ns, Package: name, PackageVersion: aws.String("1"), + }) + if err != nil || pv.PackageVersion.Status != catypes.PackageVersionStatusPublished || pv.PackageVersion.PublishedTime == nil || + aws.ToString(pv.PackageVersion.Revision) != rev || pv.PackageVersion.Origin.OriginType != catypes.PackageVersionOriginTypeInternal { + t.Fatalf("DescribePackageVersion = %+v err=%v", pv, err) + } + + assets, err := e.c.ListPackageVersionAssets(ctx, &ca.ListPackageVersionAssetsInput{ + Domain: domain, Repository: repo, Format: format, Namespace: ns, Package: name, PackageVersion: aws.String("1"), + }) + if err != nil || len(assets.Assets) != 1 || aws.ToString(assets.Assets[0].Name) != "a.txt" { + t.Fatalf("ListPackageVersionAssets = %+v err=%v", assets, err) + } + + readme, err := e.c.GetPackageVersionReadme(ctx, &ca.GetPackageVersionReadmeInput{ + Domain: domain, Repository: repo, Format: format, Namespace: ns, Package: name, PackageVersion: aws.String("1"), + }) + if err != nil || aws.ToString(readme.Version) != "1" || aws.ToString(readme.VersionRevision) != rev { + t.Fatalf("GetPackageVersionReadme = %+v err=%v", readme, err) + } + + deps, err := e.c.ListPackageVersionDependencies(ctx, &ca.ListPackageVersionDependenciesInput{ + Domain: domain, Repository: repo, Format: format, Namespace: ns, Package: name, PackageVersion: aws.String("1"), + }) + if err != nil || len(deps.Dependencies) != 0 || aws.ToString(deps.Package) != "pkg" { + t.Fatalf("ListPackageVersionDependencies = %+v err=%v", deps, err) + } + + st, err := e.c.UpdatePackageVersionsStatus(ctx, &ca.UpdatePackageVersionsStatusInput{ + Domain: domain, Repository: repo, Format: format, Namespace: ns, Package: name, + Versions: []string{"1", "9"}, TargetStatus: catypes.PackageVersionStatusArchived, + ExpectedStatus: catypes.PackageVersionStatusPublished, + }) + if err != nil || st.SuccessfulVersions["1"].Status != catypes.PackageVersionStatusArchived || + st.FailedVersions["9"].ErrorCode != catypes.PackageVersionErrorCodeNotFound { + t.Fatalf("UpdatePackageVersionsStatus = %+v err=%v", st, err) + } + + cp, err := e.c.CopyPackageVersions(ctx, &ca.CopyPackageVersionsInput{ + Domain: domain, Format: format, Namespace: ns, Package: name, + SourceRepository: aws.String("src"), DestinationRepository: aws.String("dst"), Versions: []string{"1", "2"}, + }) + if err != nil || len(cp.SuccessfulVersions) != 2 || len(cp.FailedVersions) != 0 { + t.Fatalf("CopyPackageVersions = %+v err=%v", cp, err) + } + + again, err := e.c.CopyPackageVersions(ctx, &ca.CopyPackageVersionsInput{ + Domain: domain, Format: format, Namespace: ns, Package: name, + SourceRepository: aws.String("src"), DestinationRepository: aws.String("dst"), Versions: []string{"1"}, + }) + if err != nil || again.FailedVersions["1"].ErrorCode != catypes.PackageVersionErrorCodeAlreadyExists { + t.Fatalf("second copy = %+v err=%v", again, err) + } + + dis, err := e.c.DisposePackageVersions(ctx, &ca.DisposePackageVersionsInput{ + Domain: domain, Repository: repo, Format: format, Namespace: ns, Package: name, Versions: []string{"2"}, + }) + if err != nil || dis.SuccessfulVersions["2"].Status != catypes.PackageVersionStatusDisposed { + t.Fatalf("DisposePackageVersions = %+v err=%v", dis, err) + } + + del, err := e.c.DeletePackageVersions(ctx, &ca.DeletePackageVersionsInput{ + Domain: domain, Repository: repo, Format: format, Namespace: ns, Package: name, Versions: []string{"1"}, + }) + if err != nil || del.SuccessfulVersions["1"].Status != catypes.PackageVersionStatusDeleted { + t.Fatalf("DeletePackageVersions = %+v err=%v", del, err) + } + + dp, err := e.c.DeletePackage(ctx, &ca.DeletePackageInput{Domain: domain, Repository: repo, Format: format, Namespace: ns, Package: name}) + if err != nil || aws.ToString(dp.DeletedPackage.Package) != "pkg" { + t.Fatalf("DeletePackage = %+v err=%v", dp, err) + } + + _, err = e.c.DescribePackage(ctx, &ca.DescribePackageInput{Domain: domain, Repository: repo, Format: format, Namespace: ns, Package: name}) + requireAPICode(t, err, "ResourceNotFoundException", 404) +} + +func TestSDKPackagePagination(t *testing.T) { + e := newEnv(t) + e.seed(t, "repo") + + ctx := context.Background() + domain, repo, format, ns, name := pkgInput("repo", "pkg") + + for _, v := range []string{"1", "2", "3"} { + e.publish(t, "repo", "pkg", v, "f", []byte(v), false) + } + + var seen []string + + token := (*string)(nil) + + for { + out, err := e.c.ListPackageVersions(ctx, &ca.ListPackageVersionsInput{ + Domain: domain, Repository: repo, Format: format, Namespace: ns, Package: name, MaxResults: aws.Int32(1), NextToken: token, + }) + if err != nil { + t.Fatal(err) + } + + for _, v := range out.Versions { + seen = append(seen, aws.ToString(v.Version)) + } + + if out.NextToken == nil { + break + } + + token = out.NextToken + } + + if len(seen) != 3 { + t.Fatalf("walked %v", seen) + } + + _, err := e.c.ListPackageVersions(ctx, &ca.ListPackageVersionsInput{ + Domain: domain, Repository: repo, Format: format, Namespace: ns, Package: name, NextToken: aws.String("garbage"), + }) + requireAPICode(t, err, "ValidationException", 400) + + _, err = e.c.ListPackageVersions(ctx, &ca.ListPackageVersionsInput{ + Domain: domain, Repository: repo, Format: format, Namespace: ns, Package: name, MaxResults: aws.Int32(5000), + }) + requireAPICode(t, err, "ValidationException", 400) +} + +func TestSDKOriginAndGroups(t *testing.T) { + e := newEnv(t) + e.seed(t, "repo-a", "repo-b") + + ctx := context.Background() + domain, repo, format, ns, name := pkgInput("repo-a", "pkg") + + put, err := e.c.PutPackageOriginConfiguration(ctx, &ca.PutPackageOriginConfigurationInput{ + Domain: domain, Repository: repo, Format: format, Namespace: ns, Package: name, + Restrictions: &catypes.PackageOriginRestrictions{Publish: catypes.AllowPublishBlock, Upstream: catypes.AllowUpstreamAllow}, + }) + if err != nil || put.OriginConfiguration.Restrictions.Publish != catypes.AllowPublishBlock { + t.Fatalf("PutPackageOriginConfiguration = %+v err=%v", put, err) + } + + data := []byte("x") + + _, err = e.c.PublishPackageVersion(ctx, &ca.PublishPackageVersionInput{ + Domain: domain, Repository: repo, Format: format, Namespace: ns, Package: name, PackageVersion: aws.String("1"), + AssetName: aws.String("a"), AssetSHA256: aws.String(sha256Hex(data)), AssetContent: bytes.NewReader(data), + }) + requireAPICode(t, err, "ConflictException", 409) + + cg, err := e.c.CreatePackageGroup(ctx, &ca.CreatePackageGroupInput{ + Domain: domain, PackageGroup: aws.String("/npm/*"), ContactInfo: aws.String("a@b.c"), Description: aws.String("npm"), + Tags: []catypes.Tag{{Key: aws.String("k"), Value: aws.String("v")}}, + }) + if err != nil || aws.ToString(cg.PackageGroup.Pattern) != "/npm/*" || aws.ToString(cg.PackageGroup.ContactInfo) != "a@b.c" { + t.Fatalf("CreatePackageGroup = %+v err=%v", cg, err) + } + + if _, err = e.c.CreatePackageGroup(ctx, &ca.CreatePackageGroupInput{Domain: domain, PackageGroup: aws.String("/npm/space/*")}); err != nil { + t.Fatalf("child group: %v", err) + } + + _, err = e.c.CreatePackageGroup(ctx, &ca.CreatePackageGroupInput{Domain: domain, PackageGroup: aws.String("/npm/*")}) + requireAPICode(t, err, "ConflictException", 409) + + _, err = e.c.CreatePackageGroup(ctx, &ca.CreatePackageGroupInput{Domain: domain, PackageGroup: aws.String("npm/*")}) + requireAPICode(t, err, "ValidationException", 400) + + upd, err := e.c.UpdatePackageGroupOriginConfiguration(ctx, &ca.UpdatePackageGroupOriginConfigurationInput{ + Domain: domain, PackageGroup: aws.String("/npm/*"), + Restrictions: map[string]catypes.PackageGroupOriginRestrictionMode{ + string(catypes.PackageGroupOriginRestrictionTypePublish): catypes.PackageGroupOriginRestrictionModeAllowSpecificRepositories, + }, + AddAllowedRepositories: []catypes.PackageGroupAllowedRepository{ + {RepositoryName: aws.String("repo-a"), OriginRestrictionType: catypes.PackageGroupOriginRestrictionTypePublish}, + }, + }) + if err != nil { + t.Fatalf("UpdatePackageGroupOriginConfiguration: %v", err) + } + + if got := upd.AllowedRepositoryUpdates["PUBLISH"]["ADDED"]; len(got) != 1 || got[0] != "repo-a" { + t.Fatalf("allowedRepositoryUpdates = %+v", upd.AllowedRepositoryUpdates) + } + + child, err := e.c.DescribePackageGroup(ctx, &ca.DescribePackageGroupInput{Domain: domain, PackageGroup: aws.String("/npm/space/*")}) + if err != nil { + t.Fatal(err) + } + + pub := child.PackageGroup.OriginConfiguration.Restrictions["PUBLISH"] + if pub.EffectiveMode != catypes.PackageGroupOriginRestrictionModeAllowSpecificRepositories || + aws.ToString(pub.InheritedFrom.Pattern) != "/npm/*" || aws.ToString(child.PackageGroup.Parent.Pattern) != "/npm/*" { + t.Fatalf("child = %+v", child.PackageGroup) + } + + allowed, err := e.c.ListAllowedRepositoriesForGroup(ctx, &ca.ListAllowedRepositoriesForGroupInput{ + Domain: domain, PackageGroup: aws.String("/npm/*"), OriginRestrictionType: catypes.PackageGroupOriginRestrictionTypePublish, + }) + if err != nil || len(allowed.AllowedRepositories) != 1 || allowed.AllowedRepositories[0] != "repo-a" { + t.Fatalf("ListAllowedRepositoriesForGroup = %+v err=%v", allowed, err) + } + + groups, err := e.c.ListPackageGroups(ctx, &ca.ListPackageGroupsInput{Domain: domain}) + if err != nil || len(groups.PackageGroups) != 2 { + t.Fatalf("ListPackageGroups = %+v err=%v", groups, err) + } + + subs, err := e.c.ListSubPackageGroups(ctx, &ca.ListSubPackageGroupsInput{Domain: domain, PackageGroup: aws.String("/npm/*")}) + if err != nil || len(subs.PackageGroups) != 1 || aws.ToString(subs.PackageGroups[0].Pattern) != "/npm/space/*" { + t.Fatalf("ListSubPackageGroups = %+v err=%v", subs, err) + } + + assoc, err := e.c.GetAssociatedPackageGroup(ctx, &ca.GetAssociatedPackageGroupInput{ + Domain: domain, Format: catypes.PackageFormatNpm, Namespace: aws.String("space"), Package: aws.String("x"), + }) + if err != nil || aws.ToString(assoc.PackageGroup.Pattern) != "/npm/space/*" || assoc.AssociationType != catypes.PackageGroupAssociationTypeStrong { + t.Fatalf("GetAssociatedPackageGroup = %+v err=%v", assoc, err) + } + + prev, err := e.c.ListAssociatedPackages(ctx, &ca.ListAssociatedPackagesInput{Domain: domain, PackageGroup: aws.String("/pypi/*"), Preview: aws.Bool(true)}) + if err != nil || len(prev.Packages) != 0 { + t.Fatalf("preview of a missing group = %+v err=%v", prev, err) + } + + _, err = e.c.ListAssociatedPackages(ctx, &ca.ListAssociatedPackagesInput{Domain: domain, PackageGroup: aws.String("/pypi/*")}) + requireAPICode(t, err, "ResourceNotFoundException", 404) + + desc := "updated" + + up, err := e.c.UpdatePackageGroup(ctx, &ca.UpdatePackageGroupInput{Domain: domain, PackageGroup: aws.String("/npm/*"), Description: &desc}) + if err != nil || aws.ToString(up.PackageGroup.Description) != desc || aws.ToString(up.PackageGroup.ContactInfo) != "a@b.c" { + t.Fatalf("UpdatePackageGroup = %+v err=%v", up, err) + } + + dg, err := e.c.DeletePackageGroup(ctx, &ca.DeletePackageGroupInput{Domain: domain, PackageGroup: aws.String("/npm/space/*")}) + if err != nil || aws.ToString(dg.PackageGroup.Pattern) != "/npm/space/*" { + t.Fatalf("DeletePackageGroup = %+v err=%v", dg, err) + } + + _, err = e.c.DescribePackageGroup(ctx, &ca.DescribePackageGroupInput{Domain: domain, PackageGroup: aws.String("/npm/space/*")}) + requireAPICode(t, err, "ResourceNotFoundException", 404) + + arn := aws.ToString(cg.PackageGroup.Arn) + if !strings.Contains(arn, ":package-group/dom/npm/*") { + t.Fatalf("group arn = %q", arn) + } + + if _, err = e.c.TagResource(ctx, &ca.TagResourceInput{ResourceArn: aws.String(arn), Tags: []catypes.Tag{{Key: aws.String("t"), Value: aws.String("1")}}}); err != nil { + t.Fatalf("TagResource on group: %v", err) + } + + tags, err := e.c.ListTagsForResource(ctx, &ca.ListTagsForResourceInput{ResourceArn: aws.String(arn)}) + if err != nil || len(tags.Tags) != 2 { + t.Fatalf("tags = %+v err=%v", tags, err) + } +} diff --git a/server/aws/codeartifact/sdk_roundtrip_test.go b/server/aws/codeartifact/sdk_roundtrip_test.go index 62a03e863..034871b0e 100644 --- a/server/aws/codeartifact/sdk_roundtrip_test.go +++ b/server/aws/codeartifact/sdk_roundtrip_test.go @@ -213,7 +213,7 @@ func TestSDKCreateRepositoryMissingDomain(t *testing.T) { c := newClient(t) _, err := c.CreateRepository(ctx, &ca.CreateRepositoryInput{ - Domain: aws.String("no-such-domain"), Repository: aws.String("r"), + Domain: aws.String("no-such-domain"), Repository: aws.String("repo"), }) var nf *catypes.ResourceNotFoundException diff --git a/server/aws/codeartifact/tags.go b/server/aws/codeartifact/tags.go index 2f1f9a487..53531efb0 100644 --- a/server/aws/codeartifact/tags.go +++ b/server/aws/codeartifact/tags.go @@ -1,22 +1,26 @@ package codeartifact import ( - "encoding/json" "net/http" ) -// serveTag handles POST /v1/tag?resourceArn= (TagResource). -func (h *Handler) serveTag(w http.ResponseWriter, r *http.Request, rest []string) { - if !okTagShape(w, r, rest) { - return - } +// tagRequest is the TagResource body. +type tagRequest struct { + Tags []tagPair `json:"tags"` +} - raw, ok := decodeBodyMap(w, r) - if !ok { +// untagRequest is the UntagResource body. +type untagRequest struct { + TagKeys []string `json:"tagKeys"` +} + +func (h *Handler) tagResource(w http.ResponseWriter, r *http.Request) { + var req tagRequest + if !decodeBody(w, r, &req) { return } - if err := h.ca.TagResource(r.Context(), r.URL.Query().Get("resourceArn"), tagsFromBody(raw)); err != nil { + if err := h.ca.TagResource(r.Context(), r.URL.Query().Get("resourceArn"), tagsToMap(req.Tags)); err != nil { writeErr(w, err) return @@ -25,18 +29,13 @@ func (h *Handler) serveTag(w http.ResponseWriter, r *http.Request, rest []string writeJSON(w, map[string]any{}) } -// serveUntag handles POST /v1/untag?resourceArn= (UntagResource). -func (h *Handler) serveUntag(w http.ResponseWriter, r *http.Request, rest []string) { - if !okTagShape(w, r, rest) { +func (h *Handler) untagResource(w http.ResponseWriter, r *http.Request) { + var req untagRequest + if !decodeBody(w, r, &req) { return } - raw, ok := decodeBodyMap(w, r) - if !ok { - return - } - - if err := h.ca.UntagResource(r.Context(), r.URL.Query().Get("resourceArn"), tagKeysFromBody(raw)); err != nil { + if err := h.ca.UntagResource(r.Context(), r.URL.Query().Get("resourceArn"), req.TagKeys); err != nil { writeErr(w, err) return @@ -45,12 +44,7 @@ func (h *Handler) serveUntag(w http.ResponseWriter, r *http.Request, rest []stri writeJSON(w, map[string]any{}) } -// serveListTags handles POST /v1/tags?resourceArn= (ListTagsForResource). -func (h *Handler) serveListTags(w http.ResponseWriter, r *http.Request, rest []string) { - if !okTagShape(w, r, rest) { - return - } - +func (h *Handler) listTagsForResource(w http.ResponseWriter, r *http.Request) { tags, err := h.ca.ListTagsForResource(r.Context(), r.URL.Query().Get("resourceArn")) if err != nil { writeErr(w, err) @@ -60,36 +54,3 @@ func (h *Handler) serveListTags(w http.ResponseWriter, r *http.Request, rest []s writeJSON(w, map[string]any{"tags": tagsToWire(tags)}) } - -// okTagShape validates a tag request's path and verb, writing the appropriate -// error and returning false when it is malformed: an unknown sub-path is a 404, -// a wrong verb on the exact path is a method error. -func okTagShape(w http.ResponseWriter, r *http.Request, rest []string) bool { - switch { - case len(rest) != 0: - notFoundPath(w, r.URL.Path) - - return false - case r.Method != http.MethodPost: - methodNotAllowed(w) - - return false - default: - return true - } -} - -// tagKeysFromBody extracts the tagKeys list from a raw request body. -func tagKeysFromBody(raw map[string]json.RawMessage) []string { - v, ok := raw["tagKeys"] - if !ok { - return nil - } - - var keys []string - if json.Unmarshal(v, &keys) != nil { - return nil - } - - return keys -} diff --git a/server/aws/codeartifact/testdata/operations.json b/server/aws/codeartifact/testdata/operations.json new file mode 100644 index 000000000..bae712d26 --- /dev/null +++ b/server/aws/codeartifact/testdata/operations.json @@ -0,0 +1,558 @@ +{ + "AssociateExternalConnection": { + "errors": [ + "AccessDeniedException", + "ConflictException", + "InternalServerException", + "ResourceNotFoundException", + "ServiceQuotaExceededException", + "ThrottlingException", + "ValidationException" + ], + "method": "POST", + "path": "/v1/repository/external-connection" + }, + "CopyPackageVersions": { + "errors": [ + "AccessDeniedException", + "ConflictException", + "InternalServerException", + "ResourceNotFoundException", + "ServiceQuotaExceededException", + "ThrottlingException", + "ValidationException" + ], + "method": "POST", + "path": "/v1/package/versions/copy" + }, + "CreateDomain": { + "errors": [ + "AccessDeniedException", + "ConflictException", + "InternalServerException", + "ResourceNotFoundException", + "ServiceQuotaExceededException", + "ThrottlingException", + "ValidationException" + ], + "method": "POST", + "path": "/v1/domain" + }, + "CreatePackageGroup": { + "errors": [ + "AccessDeniedException", + "ConflictException", + "InternalServerException", + "ResourceNotFoundException", + "ServiceQuotaExceededException", + "ThrottlingException", + "ValidationException" + ], + "method": "POST", + "path": "/v1/package-group" + }, + "CreateRepository": { + "errors": [ + "AccessDeniedException", + "ConflictException", + "InternalServerException", + "ResourceNotFoundException", + "ServiceQuotaExceededException", + "ThrottlingException", + "ValidationException" + ], + "method": "POST", + "path": "/v1/repository" + }, + "DeleteDomain": { + "errors": [ + "AccessDeniedException", + "ConflictException", + "InternalServerException", + "ThrottlingException", + "ValidationException" + ], + "method": "DELETE", + "path": "/v1/domain" + }, + "DeleteDomainPermissionsPolicy": { + "errors": [ + "AccessDeniedException", + "ConflictException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "DELETE", + "path": "/v1/domain/permissions/policy" + }, + "DeletePackage": { + "errors": [ + "AccessDeniedException", + "ConflictException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "DELETE", + "path": "/v1/package" + }, + "DeletePackageGroup": { + "errors": [ + "AccessDeniedException", + "ConflictException", + "InternalServerException", + "ResourceNotFoundException", + "ServiceQuotaExceededException", + "ThrottlingException", + "ValidationException" + ], + "method": "DELETE", + "path": "/v1/package-group" + }, + "DeletePackageVersions": { + "errors": [ + "AccessDeniedException", + "ConflictException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "POST", + "path": "/v1/package/versions/delete" + }, + "DeleteRepository": { + "errors": [ + "AccessDeniedException", + "ConflictException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "DELETE", + "path": "/v1/repository" + }, + "DeleteRepositoryPermissionsPolicy": { + "errors": [ + "AccessDeniedException", + "ConflictException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "DELETE", + "path": "/v1/repository/permissions/policies" + }, + "DescribeDomain": { + "errors": [ + "AccessDeniedException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "GET", + "path": "/v1/domain" + }, + "DescribePackage": { + "errors": [ + "AccessDeniedException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "GET", + "path": "/v1/package" + }, + "DescribePackageGroup": { + "errors": [ + "AccessDeniedException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "GET", + "path": "/v1/package-group" + }, + "DescribePackageVersion": { + "errors": [ + "AccessDeniedException", + "ConflictException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "GET", + "path": "/v1/package/version" + }, + "DescribeRepository": { + "errors": [ + "AccessDeniedException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "GET", + "path": "/v1/repository" + }, + "DisassociateExternalConnection": { + "errors": [ + "AccessDeniedException", + "ConflictException", + "InternalServerException", + "ResourceNotFoundException", + "ServiceQuotaExceededException", + "ThrottlingException", + "ValidationException" + ], + "method": "DELETE", + "path": "/v1/repository/external-connection" + }, + "DisposePackageVersions": { + "errors": [ + "AccessDeniedException", + "ConflictException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "POST", + "path": "/v1/package/versions/dispose" + }, + "GetAssociatedPackageGroup": { + "errors": [ + "AccessDeniedException", + "InternalServerException", + "ResourceNotFoundException", + "ValidationException" + ], + "method": "GET", + "path": "/v1/get-associated-package-group" + }, + "GetAuthorizationToken": { + "errors": [ + "AccessDeniedException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "POST", + "path": "/v1/authorization-token" + }, + "GetDomainPermissionsPolicy": { + "errors": [ + "AccessDeniedException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "GET", + "path": "/v1/domain/permissions/policy" + }, + "GetPackageVersionAsset": { + "errors": [ + "AccessDeniedException", + "ConflictException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "GET", + "path": "/v1/package/version/asset" + }, + "GetPackageVersionReadme": { + "errors": [ + "AccessDeniedException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "GET", + "path": "/v1/package/version/readme" + }, + "GetRepositoryEndpoint": { + "errors": [ + "AccessDeniedException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "GET", + "path": "/v1/repository/endpoint" + }, + "GetRepositoryPermissionsPolicy": { + "errors": [ + "AccessDeniedException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "GET", + "path": "/v1/repository/permissions/policy" + }, + "ListAllowedRepositoriesForGroup": { + "errors": [ + "AccessDeniedException", + "InternalServerException", + "ResourceNotFoundException", + "ServiceQuotaExceededException", + "ThrottlingException", + "ValidationException" + ], + "method": "GET", + "path": "/v1/package-group-allowed-repositories" + }, + "ListAssociatedPackages": { + "errors": [ + "AccessDeniedException", + "InternalServerException", + "ResourceNotFoundException", + "ValidationException" + ], + "method": "GET", + "path": "/v1/list-associated-packages" + }, + "ListDomains": { + "errors": [ + "AccessDeniedException", + "InternalServerException", + "ThrottlingException", + "ValidationException" + ], + "method": "POST", + "path": "/v1/domains" + }, + "ListPackageGroups": { + "errors": [ + "AccessDeniedException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "POST", + "path": "/v1/package-groups" + }, + "ListPackageVersionAssets": { + "errors": [ + "AccessDeniedException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "POST", + "path": "/v1/package/version/assets" + }, + "ListPackageVersionDependencies": { + "errors": [ + "AccessDeniedException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "POST", + "path": "/v1/package/version/dependencies" + }, + "ListPackageVersions": { + "errors": [ + "AccessDeniedException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "POST", + "path": "/v1/package/versions" + }, + "ListPackages": { + "errors": [ + "AccessDeniedException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "POST", + "path": "/v1/packages" + }, + "ListRepositories": { + "errors": [ + "AccessDeniedException", + "InternalServerException", + "ThrottlingException", + "ValidationException" + ], + "method": "POST", + "path": "/v1/repositories" + }, + "ListRepositoriesInDomain": { + "errors": [ + "AccessDeniedException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "POST", + "path": "/v1/domain/repositories" + }, + "ListSubPackageGroups": { + "errors": [ + "AccessDeniedException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "POST", + "path": "/v1/package-groups/sub-groups" + }, + "ListTagsForResource": { + "errors": [ + "AccessDeniedException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "POST", + "path": "/v1/tags" + }, + "PublishPackageVersion": { + "errors": [ + "AccessDeniedException", + "ConflictException", + "InternalServerException", + "ResourceNotFoundException", + "ServiceQuotaExceededException", + "ThrottlingException", + "ValidationException" + ], + "method": "POST", + "path": "/v1/package/version/publish" + }, + "PutDomainPermissionsPolicy": { + "errors": [ + "AccessDeniedException", + "ConflictException", + "InternalServerException", + "ResourceNotFoundException", + "ServiceQuotaExceededException", + "ThrottlingException", + "ValidationException" + ], + "method": "PUT", + "path": "/v1/domain/permissions/policy" + }, + "PutPackageOriginConfiguration": { + "errors": [ + "AccessDeniedException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "POST", + "path": "/v1/package" + }, + "PutRepositoryPermissionsPolicy": { + "errors": [ + "AccessDeniedException", + "ConflictException", + "InternalServerException", + "ResourceNotFoundException", + "ServiceQuotaExceededException", + "ThrottlingException", + "ValidationException" + ], + "method": "PUT", + "path": "/v1/repository/permissions/policy" + }, + "TagResource": { + "errors": [ + "AccessDeniedException", + "ResourceNotFoundException", + "ServiceQuotaExceededException", + "ThrottlingException", + "ValidationException" + ], + "method": "POST", + "path": "/v1/tag" + }, + "UntagResource": { + "errors": [ + "AccessDeniedException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "POST", + "path": "/v1/untag" + }, + "UpdatePackageGroup": { + "errors": [ + "AccessDeniedException", + "InternalServerException", + "ResourceNotFoundException", + "ServiceQuotaExceededException", + "ThrottlingException", + "ValidationException" + ], + "method": "PUT", + "path": "/v1/package-group" + }, + "UpdatePackageGroupOriginConfiguration": { + "errors": [ + "AccessDeniedException", + "InternalServerException", + "ResourceNotFoundException", + "ServiceQuotaExceededException", + "ThrottlingException", + "ValidationException" + ], + "method": "PUT", + "path": "/v1/package-group-origin-configuration" + }, + "UpdatePackageVersionsStatus": { + "errors": [ + "AccessDeniedException", + "ConflictException", + "InternalServerException", + "ResourceNotFoundException", + "ThrottlingException", + "ValidationException" + ], + "method": "POST", + "path": "/v1/package/versions/update_status" + }, + "UpdateRepository": { + "errors": [ + "AccessDeniedException", + "ConflictException", + "InternalServerException", + "ResourceNotFoundException", + "ServiceQuotaExceededException", + "ThrottlingException", + "ValidationException" + ], + "method": "PUT", + "path": "/v1/repository" + } +} \ No newline at end of file diff --git a/server/aws/codeartifact/wire.go b/server/aws/codeartifact/wire.go index 645309269..3b7f4e352 100644 --- a/server/aws/codeartifact/wire.go +++ b/server/aws/codeartifact/wire.go @@ -1,8 +1,11 @@ package codeartifact import ( - "encoding/json" + "errors" + "net/http" + "net/url" "sort" + "strconv" "time" "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" @@ -146,15 +149,9 @@ type tagPair struct { Value string `json:"value"` } -// tagsFromBody extracts the tags list from a raw request body into a map. -func tagsFromBody(raw map[string]json.RawMessage) map[string]string { - v, ok := raw["tags"] - if !ok { - return nil - } - - var list []tagPair - if json.Unmarshal(v, &list) != nil { +// tagsToMap converts a request's tag list to a map. +func tagsToMap(list []tagPair) map[string]string { + if list == nil { return nil } @@ -166,42 +163,102 @@ func tagsFromBody(raw map[string]json.RawMessage) map[string]string { return out } -// upstreamsFromBody extracts the upstreams list from a raw request body, or nil -// when the field is absent. -func upstreamsFromBody(raw map[string]json.RawMessage) *[]driver.UpstreamRef { - v, ok := raw["upstreams"] +// upstreamPair mirrors one UpstreamRepository object in a request body. +type upstreamPair struct { + RepositoryName string `json:"repositoryName"` +} + +// upstreamsFromList converts a request's upstream list to driver references. +func upstreamsFromList(list []upstreamPair) []driver.UpstreamRef { + out := make([]driver.UpstreamRef, 0, len(list)) + for _, u := range list { + out = append(out, driver.UpstreamRef{RepositoryName: u.RepositoryName}) + } + + return out +} + +// queryTrue is the value of a boolean query parameter that is set. +const queryTrue = "true" + +// maxQueryInt bounds a parsed query integer so the result fits an int32 without +// overflow; the driver then rejects anything above the API's own limit. +const maxQueryInt = 1 << 20 + +// queryInt32 parses an optional non-negative integer query parameter. An absent +// parameter is 0; a malformed or negative one is a ValidationException. +func queryInt32(w http.ResponseWriter, q url.Values, key string) (int32, bool) { + s := q.Get(key) + if s == "" { + return 0, true + } + + n, err := strconv.ParseUint(s, 10, 31) + if err != nil && !errors.Is(err, strconv.ErrRange) { + writeValidation(w, key+" must be a non-negative integer") + + return 0, false + } + + // A value beyond 31 bits is clamped: the driver rejects anything above the API's own limit. + return int32(min(n, maxQueryInt)), true //nolint:gosec // clamped to maxQueryInt, far below MaxInt32 +} + +// pageFromQuery reads the max-results and next-token query parameters. +func pageFromQuery(w http.ResponseWriter, q url.Values) (driver.Page, bool) { + n, ok := queryInt32(w, q, "max-results") if !ok { - return nil + return driver.Page{}, false } - var list []struct { - RepositoryName string `json:"repositoryName"` + if q.Has("max-results") && n == 0 { + writeValidation(w, "max-results must be at least 1") + + return driver.Page{}, false } - if json.Unmarshal(v, &list) != nil { - return nil + return driver.Page{MaxResults: n, NextToken: q.Get("next-token")}, true +} + +func domainRefFromQuery(q url.Values) driver.DomainRef { + return driver.DomainRef{Domain: q.Get("domain"), Owner: q.Get("domain-owner")} +} + +func repoRefFromQuery(q url.Values) driver.RepositoryRef { + return driver.RepositoryRef{Domain: q.Get("domain"), Owner: q.Get("domain-owner"), Repository: q.Get("repository")} +} + +func pkgRefFromQuery(q url.Values) driver.PackageRef { + return driver.PackageRef{ + Domain: q.Get("domain"), Owner: q.Get("domain-owner"), Repository: q.Get("repository"), + Format: q.Get("format"), Namespace: q.Get("namespace"), Package: q.Get("package"), } +} - out := make([]driver.UpstreamRef, 0, len(list)) - for _, u := range list { - out = append(out, driver.UpstreamRef{RepositoryName: u.RepositoryName}) +// putNonEmpty sets m[key] = v unless v is empty. +func putNonEmpty(m map[string]any, key, v string) { + if v != "" { + m[key] = v } +} - return &out +func newCreateDomainInput(domain string, req *createDomainRequest) *driver.CreateDomainInput { + return &driver.CreateDomainInput{Name: domain, EncryptionKey: req.EncryptionKey, Tags: tagsToMap(req.Tags)} } -// stringPtrFromBody returns a pointer to a string body field when present, or nil -// when the field is absent (so an update leaves it unchanged). -func stringPtrFromBody(raw map[string]json.RawMessage, key string) *string { - v, ok := raw[key] - if !ok { - return nil +// pageFromBody reads the paging members of a request body. A maxResults that is +// present but not positive is rejected; an absent one means the default page size. +func pageFromBody(w http.ResponseWriter, req *listPageRequest) (driver.Page, bool) { + if req.MaxResults != nil && *req.MaxResults == 0 { + writeValidation(w, "maxResults must be at least 1") + + return driver.Page{}, false } - var s string - if json.Unmarshal(v, &s) != nil { - return nil + page := driver.Page{NextToken: req.NextToken} + if req.MaxResults != nil { + page.MaxResults = *req.MaxResults } - return &s + return page, true } diff --git a/server/aws/codeartifact/wire_test.go b/server/aws/codeartifact/wire_test.go new file mode 100644 index 000000000..be864995c --- /dev/null +++ b/server/aws/codeartifact/wire_test.go @@ -0,0 +1,181 @@ +package codeartifact_test + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/aws/codeartifact" + "github.com/stackshy/cloudemu/v2/services/codeartifact/driver" +) + +func TestPublishBodyBounds(t *testing.T) { + h := fullHandler() + q := "?domain=dom&repository=repo&format=generic&namespace=ns&package=p&version=1&asset=a" + + req := httptest.NewRequest(http.MethodPost, "/v1/package/version/publish"+q, bytes.NewReader(make([]byte, driver.MaxAssetBytes+2))) + req.Header.Set("x-amz-content-sha256", xSHA256) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusPaymentRequired || rec.Header().Get("X-Amzn-Errortype") != "ServiceQuotaExceededException" { + t.Fatalf("oversize publish: %d %q", rec.Code, rec.Header().Get("X-Amzn-Errortype")) + } +} + +func TestMalformedBodiesAndQueries(t *testing.T) { + h := fullHandler() + + cases := []struct { + name, method, target, body string + }{ + {"broken JSON create domain", http.MethodPost, "/v1/domain?domain=dom", `{"tags": [`}, + {"wrong type tags", http.MethodPost, "/v1/domain?domain=dom", `{"tags": "nope"}`}, + {"wrong type maxResults", http.MethodPost, "/v1/domains", `{"maxResults": "ten"}`}, + {"negative max-results", http.MethodPost, "/v1/repositories?max-results=-1", ""}, + {"non-numeric max-results", http.MethodPost, "/v1/repositories?max-results=abc", ""}, + {"non-numeric duration", http.MethodPost, "/v1/authorization-token?domain=dom&duration=soon", ""}, + {"broken JSON bulk", http.MethodPost, "/v1/package/versions/delete?domain=dom&repository=repo&format=npm&package=p", `{"versions": `}, + {"missing everything", http.MethodGet, "/v1/package/version/asset", ""}, + {"huge max-results", http.MethodPost, "/v1/repositories?max-results=99999999999999999999", ""}, + } + + for _, c := range cases { + req := httptest.NewRequest(c.method, c.target, strings.NewReader(c.body)) + rec := httptest.NewRecorder() + + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest || rec.Header().Get("X-Amzn-Errortype") != "ValidationException" { + t.Errorf("%s: %d %q %s", c.name, rec.Code, rec.Header().Get("X-Amzn-Errortype"), strings.TrimSpace(rec.Body.String())) + } + } +} + +func TestErrorBodyCarriesIdentity(t *testing.T) { + e := newEnv(t) + e.seed(t, "repo") + + resp, err := http.Get(e.url + "/v1/repository?domain=dom&repository=ghost-repo") + if err != nil { + t.Fatal(err) + } + + defer resp.Body.Close() + + buf := new(bytes.Buffer) + _, _ = buf.ReadFrom(resp.Body) + + if resp.StatusCode != http.StatusNotFound || resp.Header.Get("X-Amzn-Errortype") != "ResourceNotFoundException" { + t.Fatalf("got %d %q", resp.StatusCode, resp.Header.Get("X-Amzn-Errortype")) + } + + body := buf.String() + if !strings.Contains(body, `"resourceId":"ghost-repo"`) || !strings.Contains(body, `"resourceType":"repository"`) { + t.Fatalf("body = %s", body) + } +} + +func TestUntypedDriverErrorsMapToCodeArtifactExceptions(t *testing.T) { + cases := []struct { + err error + status int + kind string + }{ + {cerrors.New(cerrors.NotFound, "missing"), http.StatusNotFound, "ResourceNotFoundException"}, + {cerrors.New(cerrors.AlreadyExists, "dup"), http.StatusConflict, "ConflictException"}, + {cerrors.New(cerrors.InvalidArgument, "bad"), http.StatusBadRequest, "ValidationException"}, + {errors.New("boom"), http.StatusInternalServerError, "InternalServerException"}, + } + + for _, c := range cases { + h := codeartifact.New(failingDriver{err: c.err}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/domain?domain=dom", nil)) + + if rec.Code != c.status || rec.Header().Get("X-Amzn-Errortype") != c.kind { + t.Errorf("%v: got %d %q, want %d %q", c.err, rec.Code, rec.Header().Get("X-Amzn-Errortype"), c.status, c.kind) + } + } + + if got := codeartifact.New(nil).IAMService(); got != "codeartifact" { + t.Errorf("IAMService = %q", got) + } +} + +// failingDriver answers every call with err, standing in for a third-party driver that returns untagged errors. +type failingDriver struct { + driver.CodeArtifact + err error +} + +func (f failingDriver) DescribeDomain(context.Context, string, string) (*driver.Domain, error) { + return nil, f.err +} + +func TestUnmodelledNotFoundIsAValidationException(t *testing.T) { + h := fullHandler() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/v1/domain?domain=ghost-domain", nil)) + + if rec.Code != http.StatusBadRequest || rec.Header().Get("X-Amzn-Errortype") != "ValidationException" { + t.Fatalf("DeleteDomain of a missing domain: %d %q, want 400 ValidationException (the API does not model ResourceNotFound)", + rec.Code, rec.Header().Get("X-Amzn-Errortype")) + } + + // An operation that does model it keeps the typed 404. + rec = httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/domain?domain=ghost-domain", nil)) + + if rec.Code != http.StatusNotFound || rec.Header().Get("X-Amzn-Errortype") != "ResourceNotFoundException" { + t.Fatalf("DescribeDomain of a missing domain: %d %q", rec.Code, rec.Header().Get("X-Amzn-Errortype")) + } +} + +func TestZeroMaxResultsIsRejected(t *testing.T) { + h := fullHandler() + + for _, c := range []struct{ method, target, body string }{ + {http.MethodPost, "/v1/repositories?max-results=0", ""}, + {http.MethodPost, "/v1/packages?domain=dom&repository=repo&max-results=0", ""}, + {http.MethodPost, "/v1/domains", `{"maxResults":0}`}, + } { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(c.method, c.target, strings.NewReader(c.body))) + + if rec.Code != http.StatusBadRequest || rec.Header().Get("X-Amzn-Errortype") != "ValidationException" { + t.Errorf("%s: %d %q, want 400 ValidationException", c.target, rec.Code, rec.Header().Get("X-Amzn-Errortype")) + } + } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/domains", strings.NewReader(`{}`))) + + if rec.Code != http.StatusOK { + t.Fatalf("an absent maxResults must use the default page size: %d %s", rec.Code, rec.Body.String()) + } +} + +// brokenBody fails the way a dropped client connection does. +type brokenBody struct{} + +func (brokenBody) Read([]byte) (int, error) { return 0, errors.New("connection reset by peer") } + +func TestPublishReadErrorIsNotTheSizeLimit(t *testing.T) { + h := fullHandler() + req := httptest.NewRequest(http.MethodPost, "/v1/package/version/publish?domain=dom&repository=repo&format=generic&namespace=ns&package=p&version=1&asset=a", brokenBody{}) + req.Header.Set("x-amz-content-sha256", xSHA256) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest || rec.Header().Get("X-Amzn-Errortype") != "ValidationException" { + t.Fatalf("dropped upload: %d %q, want 400 ValidationException (402 is only for the size limit)", rec.Code, rec.Header().Get("X-Amzn-Errortype")) + } +} diff --git a/server/aws/dispatch_ordering_test.go b/server/aws/dispatch_ordering_test.go index ee106068d..58a298da3 100644 --- a/server/aws/dispatch_ordering_test.go +++ b/server/aws/dispatch_ordering_test.go @@ -187,3 +187,50 @@ func TestDescribeEventsRoutedByCredentialScope(t *testing.T) { }) } } + +// TestCodeArtifactWinsBeforeS3AndLeavesSiblingsAlone covers the REST-JSON +// CodeArtifact paths under /v1/, which share the wire with the permissive S3 REST +// fallback and with the other /v1/ services (MQ, Batch, AppSync, Kafka). The +// CodeArtifact routes must answer as CodeArtifact, and every path it does not own +// must still reach S3 or the sibling service. (A bucket cannot be named v1: S3 +// bucket names have at least three characters.) +func TestCodeArtifactWinsBeforeS3AndLeavesSiblingsAlone(t *testing.T) { + ts := fullAWSServer(t) + + for _, path := range []string{"/v1/domains", "/v1/repositories", "/v1/packages", "/v1/package-groups"} { + status, body := doRequest(t, ts, http.MethodPost, path, "{}", nil) + + assert.NotEqual(t, http.StatusNotImplemented, status, "%s: %s", path, body) + assert.NotContains(t, body, "", "%s must fall through to S3, got %s", path, body) + } + + _, body = doRequest(t, ts, http.MethodGet, "/v1/tags", "", nil) + assert.Contains(t, body, "__type", "/v1/tags without a CodeArtifact ARN must reach a sibling service, got %s", body) + + // A normal path-style bucket keeps working. + status, _ = doRequest(t, ts, http.MethodPut, "/artifact-bucket", "", nil) + require.Equal(t, http.StatusOK, status, "create bucket") + + status, _ = doRequest(t, ts, http.MethodPut, "/artifact-bucket/v1/domain", "payload", nil) + require.Equal(t, http.StatusOK, status, "put object") + + status, body = doRequest(t, ts, http.MethodGet, "/artifact-bucket/v1/domain", "", nil) + assert.Equal(t, http.StatusOK, status) + assert.Equal(t, "payload", body) + + // The shared tag path with a non-CodeArtifact ARN falls through to its own service, not CodeArtifact. + status, body = doRequest(t, ts, http.MethodPost, "/v1/tags?resourceArn=arn:aws:mq:us-east-1:123456789012:broker:b-1", "{}", nil) + assert.NotContains(t, body, `"resourceType"`, "non-CodeArtifact tag request answered by CodeArtifact (%d): %s", status, body) +} diff --git a/services/codeartifact/driver/access.go b/services/codeartifact/driver/access.go new file mode 100644 index 000000000..480992914 --- /dev/null +++ b/services/codeartifact/driver/access.go @@ -0,0 +1,35 @@ +package driver + +import ( + "context" + "time" +) + +// AuthorizationToken is the bearer token package managers present to a +// repository endpoint, with the time it stops being valid. +type AuthorizationToken struct { + Token string + Expiration time.Time +} + +// Endpoint types GetRepositoryEndpoint accepts besides the default. +const ( + EndpointTypeIPv4 = "ipv4" + EndpointTypeDualstack = "dualstack" +) + +// Access is the optional capability behind `aws codeartifact login`. +type Access interface { + // GetAuthorizationToken issues a bearer token for a domain; a zero durationSeconds means the default lifetime. + GetAuthorizationToken(ctx context.Context, ref DomainRef, durationSeconds int64) (*AuthorizationToken, error) + // GetRepositoryEndpoint returns the URL of a repository for one package format ("", "ipv4" or "dualstack" endpoint type). + GetRepositoryEndpoint(ctx context.Context, ref RepositoryRef, format, endpointType string) (string, error) +} + +// RepositoryAdministratorLister is the optional capability behind the administrator-account filter of +// ListRepositoriesInDomain, so the base interface keeps its signature. +type RepositoryAdministratorLister interface { + // ListRepositoriesInDomainByAdministrator lists a domain's repositories administered by one account. + ListRepositoriesInDomainByAdministrator(ctx context.Context, ref DomainRef, administratorAccount, prefix string, + page Page) (repos []*Repository, nextToken string, err error) +} diff --git a/services/codeartifact/driver/errors.go b/services/codeartifact/driver/errors.go index 65427db5e..1b6f09e5a 100644 --- a/services/codeartifact/driver/errors.go +++ b/services/codeartifact/driver/errors.go @@ -3,18 +3,39 @@ package driver // CodeArtifact exception names. These select the precise X-Amzn-Errortype at the // wire layer. const ( - ExNotFound = "ResourceNotFoundException" - ExValidation = "ValidationException" - ExConflict = "ConflictException" - ExInternal = "InternalServerException" + ExNotFound = "ResourceNotFoundException" + ExValidation = "ValidationException" + ExConflict = "ConflictException" + ExServiceQuota = "ServiceQuotaExceededException" + ExInternal = "InternalServerException" ) +// Resource types a ResourceNotFound, Conflict or ServiceQuotaExceeded error names +// (the ResourceType enum of the CodeArtifact API). +const ( + ResourceTypeDomain = "domain" + ResourceTypeRepository = "repository" + ResourceTypePackage = "package" + ResourceTypePackageVersion = "package-version" + ResourceTypeAsset = "asset" +) + +// ReasonFieldValidationFailed is the ValidationException reason reported for a +// request field that fails the API's constraints. +const ReasonFieldValidationFailed = "FIELD_VALIDATION_FAILED" + // APIError tags a canonical cloudemu error with the CodeArtifact exception name // it concerns, so the server can emit the right X-Amzn-Errortype while the -// canonical code still resolves the HTTP status through Unwrap. +// canonical code still resolves the HTTP status through Unwrap. ResourceID and +// ResourceType identify the resource of a ResourceNotFound, Conflict or +// ServiceQuotaExceeded error and Reason is the ValidationException reason; the +// server maps them to the fields of the same name in the error body. type APIError struct { - Exception string - Err error + Exception string + Err error + ResourceID string + ResourceType string + Reason string } func (e *APIError) Error() string { return e.Err.Error() } diff --git a/services/codeartifact/driver/package_groups.go b/services/codeartifact/driver/package_groups.go new file mode 100644 index 000000000..f427732e2 --- /dev/null +++ b/services/codeartifact/driver/package_groups.go @@ -0,0 +1,137 @@ +package driver + +import ( + "context" + "time" +) + +// Package group origin restriction types (the PackageGroupOriginRestrictionType enum). +const ( + RestrictionPublish = "PUBLISH" + RestrictionExternalUpstream = "EXTERNAL_UPSTREAM" + RestrictionInternalUpstream = "INTERNAL_UPSTREAM" +) + +// Package group origin restriction modes (the PackageGroupOriginRestrictionMode enum). +const ( + ModeAllow = "ALLOW" + ModeAllowSpecificRepositories = "ALLOW_SPECIFIC_REPOSITORIES" + ModeBlock = "BLOCK" + ModeInherit = "INHERIT" +) + +// Association strengths between a package and a package group. +const ( + AssociationStrong = "STRONG" + AssociationWeak = "WEAK" +) + +// Updates reported for an allowed repository list change. +const ( + AllowedRepositoryAdded = "ADDED" + AllowedRepositoryRemoved = "REMOVED" +) + +// GroupReference points at a package group by ARN and pattern. +type GroupReference struct { + Arn string + Pattern string +} + +// GroupRestriction is one origin restriction of a package group: the configured +// mode, the mode in effect after inheritance, where it was inherited from and +// how many repositories its allowed list holds. +type GroupRestriction struct { + Mode string + EffectiveMode string + InheritedFrom *GroupReference + RepositoriesCount int64 +} + +// PackageGroup is a pattern-defined set of packages with origin controls. +type PackageGroup struct { + Arn string + Pattern string + DomainName string + DomainOwner string + CreatedTime time.Time + ContactInfo string + Description string + Restrictions map[string]GroupRestriction + Parent *GroupReference +} + +// AllowedRepository names a repository in the allowed list of one restriction type. +type AllowedRepository struct { + RepositoryName string + OriginRestrictionType string +} + +// AssociatedPackage is a package together with how strongly a group claims it. +type AssociatedPackage struct { + Format string + Namespace string + Package string + AssociationType string +} + +// CreatePackageGroupInput is a CreatePackageGroup request. +type CreatePackageGroupInput struct { + Domain DomainRef + Pattern string + ContactInfo string + Description string + Tags map[string]string +} + +// UpdatePackageGroupInput is an UpdatePackageGroup request; a nil field is left +// unchanged. +type UpdatePackageGroupInput struct { + Domain DomainRef + Pattern string + ContactInfo *string + Description *string +} + +// UpdateGroupOriginInput is an UpdatePackageGroupOriginConfiguration request. +type UpdateGroupOriginInput struct { + Domain DomainRef + Pattern string + Restrictions map[string]string + AddAllowed []AllowedRepository + RemoveAllowed []AllowedRepository +} + +// GroupOriginUpdate is the outcome of an origin configuration update: the group +// and, per restriction type, the repositories added and removed. +type GroupOriginUpdate struct { + Group *PackageGroup + Updates map[string]map[string][]string +} + +// PackageGroups is the optional capability for package groups. +type PackageGroups interface { + // CreatePackageGroup creates a package group. + CreatePackageGroup(ctx context.Context, in *CreatePackageGroupInput) (*PackageGroup, error) + // DescribePackageGroup returns a package group with its parent and effective restrictions. + DescribePackageGroup(ctx context.Context, ref DomainRef, pattern string) (*PackageGroup, error) + // UpdatePackageGroup changes the contact info and description of a group. + UpdatePackageGroup(ctx context.Context, in *UpdatePackageGroupInput) (*PackageGroup, error) + // DeletePackageGroup deletes a group; its children move up to its parent. + DeletePackageGroup(ctx context.Context, ref DomainRef, pattern string) (*PackageGroup, error) + // UpdatePackageGroupOriginConfiguration sets restriction modes and edits the allowed repository lists. + UpdatePackageGroupOriginConfiguration(ctx context.Context, in *UpdateGroupOriginInput) (*GroupOriginUpdate, error) + + // ListPackageGroups lists a domain's package groups. + ListPackageGroups(ctx context.Context, ref DomainRef, prefix string, page Page) (groups []*PackageGroup, nextToken string, err error) + // ListSubPackageGroups lists the direct children of a package group. + ListSubPackageGroups(ctx context.Context, ref DomainRef, pattern string, page Page) (groups []*PackageGroup, nextToken string, err error) + // ListAssociatedPackages lists the packages whose most specific group is the given one. + ListAssociatedPackages(ctx context.Context, ref DomainRef, pattern string, preview bool, + page Page) (pkgs []AssociatedPackage, nextToken string, err error) + // GetAssociatedPackageGroup returns the group a package coordinate belongs to and how strongly. + GetAssociatedPackageGroup(ctx context.Context, ref DomainRef, format, namespace, pkg string) (*PackageGroup, string, error) + // ListAllowedRepositoriesForGroup lists the allowed repositories of one restriction type. + ListAllowedRepositoriesForGroup(ctx context.Context, ref DomainRef, pattern, restrictionType string, + page Page) (repos []string, nextToken string, err error) +} diff --git a/services/codeartifact/driver/package_versions.go b/services/codeartifact/driver/package_versions.go new file mode 100644 index 000000000..ebf8a1ca3 --- /dev/null +++ b/services/codeartifact/driver/package_versions.go @@ -0,0 +1,220 @@ +package driver + +import ( + "context" + "time" +) + +// Package version statuses (the PackageVersionStatus enum of the CodeArtifact API). +const ( + VersionStatusPublished = "Published" + VersionStatusUnfinished = "Unfinished" + VersionStatusUnlisted = "Unlisted" + VersionStatusArchived = "Archived" + VersionStatusDisposed = "Disposed" + VersionStatusDeleted = "Deleted" +) + +// Origin types of a package version. +const ( + OriginTypeInternal = "INTERNAL" + OriginTypeExternal = "EXTERNAL" + OriginTypeUnknown = "UNKNOWN" +) + +// Error codes of a per-version failure in a bulk package version operation (the +// PackageVersionErrorCode enum). +const ( + VersionErrAlreadyExists = "ALREADY_EXISTS" + VersionErrMismatchedRev = "MISMATCHED_REVISION" + VersionErrMismatchedStatus = "MISMATCHED_STATUS" + VersionErrNotAllowed = "NOT_ALLOWED" + VersionErrNotFound = "NOT_FOUND" + VersionErrSkipped = "SKIPPED" +) + +// SortByPublishedTime is the only sort order ListPackageVersions supports. +const SortByPublishedTime = "PUBLISHED_TIME" + +// Asset hash algorithms (the HashAlgorithm enum). +const ( + HashMD5 = "MD5" + HashSHA1 = "SHA-1" + HashSHA256 = "SHA-256" + HashSHA512 = "SHA-512" +) + +// MaxAssetBytes is the largest asset the emulator stores. AWS allows 5 GiB (an +// adjustable quota); an in-memory emulator holds the bytes, so it stops at +// 128 MiB. +const MaxAssetBytes = 128 << 20 + +// VersionOrigin says how a package version entered the repository. +type VersionOrigin struct { + OriginType string + RepositoryName string +} + +// PackageVersionSummary is one entry of ListPackageVersions. +type PackageVersionSummary struct { + Version string + Revision string + Status string + Origin VersionOrigin +} + +// PackageVersion is the full description of a package version. +type PackageVersion struct { + Format string + Namespace string + PackageName string + DisplayName string + Version string + Revision string + Status string + PublishedTime time.Time + Origin VersionOrigin +} + +// VersionIdentity names a package version and its current revision. It is the +// whole payload of the readme and dependency calls: generic package versions +// carry no extractable metadata, so those calls report the identity only. +type VersionIdentity struct { + Format string + Namespace string + Package string + Version string + Revision string +} + +// Asset is one file of a package version. +type Asset struct { + Name string + Size int64 + Hashes map[string]string +} + +// AssetList is the assets of a package version. +type AssetList struct { + VersionIdentity + Assets []Asset +} + +// AssetContent is a downloaded asset: its bytes and the identity headers. +type AssetContent struct { + Asset + Content []byte + Version string + Revision string +} + +// ListPackageVersionsInput filters the versions of a package. +type ListPackageVersionsInput struct { + Ref PackageRef + Status string + SortBy string + OriginType string + Page Page +} + +// PackageVersionList is a page of package versions. +type PackageVersionList struct { + DefaultDisplayVersion string + Format string + Namespace string + Package string + Versions []PackageVersionSummary + NextToken string +} + +// PublishInput is a PublishPackageVersion request. SHA256 is the hex digest the +// caller computed over Content. +type PublishInput struct { + Ref PackageRef + Version string + AssetName string + SHA256 string + Content []byte + Unfinished bool +} + +// PublishResult is the outcome of a publish. +type PublishResult struct { + VersionIdentity + Status string + Asset Asset +} + +// VersionsInput names the versions of a package a bulk operation applies to. +// VersionRevisions optionally pins each version to a revision. ExpectedStatus, +// when set, must match the status of every version. +type VersionsInput struct { + Ref PackageRef + Versions []string + VersionRevisions map[string]string + ExpectedStatus string +} + +// UpdateStatusInput is an UpdatePackageVersionsStatus request. +type UpdateStatusInput struct { + VersionsInput + TargetStatus string +} + +// CopyInput is a CopyPackageVersions request within one domain. +type CopyInput struct { + Ref PackageRef + SourceRepository string + DestinationRepository string + Versions []string + VersionRevisions map[string]string + AllowOverwrite bool + IncludeFromUpstream bool +} + +// SuccessfulVersion is a version a bulk operation applied to. +type SuccessfulVersion struct { + Revision string + Status string +} + +// FailedVersion is a version a bulk operation did not apply to and why. +type FailedVersion struct { + ErrorCode string + ErrorMessage string +} + +// VersionsResult is the per-version outcome of a bulk operation, keyed by +// version. +type VersionsResult struct { + Successful map[string]SuccessfulVersion + Failed map[string]FailedVersion +} + +// PackageVersions is the optional capability for package versions and their assets. +// +// Only generic packages can be published through the API. +type PackageVersions interface { + // ListPackageVersions lists the versions of a package, newest published first. + ListPackageVersions(ctx context.Context, in *ListPackageVersionsInput) (*PackageVersionList, error) + // DescribePackageVersion returns the description of one package version. + DescribePackageVersion(ctx context.Context, ref PackageRef, version string) (*PackageVersion, error) + // PublishPackageVersion adds an asset to a generic package version, creating the version when needed. + PublishPackageVersion(ctx context.Context, in *PublishInput) (*PublishResult, error) + // ListPackageVersionAssets lists the assets of a package version. + ListPackageVersionAssets(ctx context.Context, ref PackageRef, version string, page Page) (list *AssetList, nextToken string, err error) + // GetPackageVersionAsset returns the bytes of one asset. + GetPackageVersionAsset(ctx context.Context, ref PackageRef, version, asset, revision string) (*AssetContent, error) + // GetPackageVersionReadme returns the identity of a version; generic versions have no readme text. + GetPackageVersionReadme(ctx context.Context, ref PackageRef, version string) (*VersionIdentity, error) + // ListPackageVersionDependencies returns the identity of a version; generic versions have no dependencies. + ListPackageVersionDependencies(ctx context.Context, ref PackageRef, version string) (*VersionIdentity, error) + // UpdatePackageVersionsStatus moves versions to Archived, Published or Unlisted. + UpdatePackageVersionsStatus(ctx context.Context, in *UpdateStatusInput) (*VersionsResult, error) + // DisposePackageVersions deletes the assets of versions and marks them Disposed. + DisposePackageVersions(ctx context.Context, in *VersionsInput) (*VersionsResult, error) + // DeletePackageVersions removes versions and their assets. + DeletePackageVersions(ctx context.Context, in *VersionsInput) (*VersionsResult, error) + // CopyPackageVersions copies versions between two repositories of one domain. + CopyPackageVersions(ctx context.Context, in *CopyInput) (*VersionsResult, error) +} diff --git a/services/codeartifact/driver/packages.go b/services/codeartifact/driver/packages.go new file mode 100644 index 000000000..a83cebdbb --- /dev/null +++ b/services/codeartifact/driver/packages.go @@ -0,0 +1,49 @@ +package driver + +import "context" + +// Origin control values of a package (the AllowPublish and AllowUpstream enums). +const ( + OriginAllow = "ALLOW" + OriginBlock = "BLOCK" +) + +// OriginRestrictions are the package-level origin controls: whether versions may +// be published directly and whether they may enter from an upstream source. +type OriginRestrictions struct { + Publish string + Upstream string +} + +// Package is a package record in a repository. +type Package struct { + Format string + Namespace string + Name string + Restrictions OriginRestrictions +} + +// ListPackagesInput filters the packages of a repository. Namespace and Prefix +// are prefix filters; an empty Format, Namespace, Prefix, Publish or Upstream +// applies no filter. +type ListPackagesInput struct { + Repository RepositoryRef + Format string + Namespace string + Prefix string + Publish string + Upstream string + Page Page +} + +// Packages is the optional capability for package records and their origin controls. +type Packages interface { + // ListPackages lists the packages of a repository. + ListPackages(ctx context.Context, in *ListPackagesInput) (pkgs []*Package, nextToken string, err error) + // DescribePackage returns a package record. + DescribePackage(ctx context.Context, ref PackageRef) (*Package, error) + // PutPackageOriginConfiguration sets a package's origin controls, creating an empty record when it does not exist. + PutPackageOriginConfiguration(ctx context.Context, ref PackageRef, restrictions OriginRestrictions) (*Package, error) + // DeletePackage deletes a package with all its versions and assets. + DeletePackage(ctx context.Context, ref PackageRef) (*Package, error) +} diff --git a/services/codeartifact/driver/policies.go b/services/codeartifact/driver/policies.go new file mode 100644 index 000000000..9be59485d --- /dev/null +++ b/services/codeartifact/driver/policies.go @@ -0,0 +1,31 @@ +package driver + +import "context" + +// ResourcePolicy is the resource-based policy attached to a domain or a +// repository. Revision changes on every successful put and is the optimistic +// lock token of the next put or delete. +type ResourcePolicy struct { + ResourceArn string + Revision string + Document string +} + +// Policies is the optional capability for domain and repository permissions policies. +// +// A put or delete that names a revision other than the current one is a ConflictException. +type Policies interface { + // PutDomainPermissionsPolicy sets the resource policy on a domain. + PutDomainPermissionsPolicy(ctx context.Context, ref DomainRef, policyRevision, document string) (*ResourcePolicy, error) + // GetDomainPermissionsPolicy returns the resource policy on a domain. + GetDomainPermissionsPolicy(ctx context.Context, ref DomainRef) (*ResourcePolicy, error) + // DeleteDomainPermissionsPolicy removes the resource policy from a domain. + DeleteDomainPermissionsPolicy(ctx context.Context, ref DomainRef, policyRevision string) (*ResourcePolicy, error) + + // PutRepositoryPermissionsPolicy sets the resource policy on a repository. + PutRepositoryPermissionsPolicy(ctx context.Context, ref RepositoryRef, policyRevision, document string) (*ResourcePolicy, error) + // GetRepositoryPermissionsPolicy returns the resource policy on a repository. + GetRepositoryPermissionsPolicy(ctx context.Context, ref RepositoryRef) (*ResourcePolicy, error) + // DeleteRepositoryPermissionsPolicy removes the resource policy from a repository. + DeleteRepositoryPermissionsPolicy(ctx context.Context, ref RepositoryRef, policyRevision string) (*ResourcePolicy, error) +} diff --git a/services/codeartifact/driver/refs.go b/services/codeartifact/driver/refs.go new file mode 100644 index 000000000..8c477fb6a --- /dev/null +++ b/services/codeartifact/driver/refs.go @@ -0,0 +1,38 @@ +package driver + +// DomainRef names a domain and, optionally, the account that owns it. An empty +// Owner means the caller's own account. +type DomainRef struct { + Domain string + Owner string +} + +// RepositoryRef names a repository inside a domain. +type RepositoryRef struct { + Domain string + Owner string + Repository string +} + +// PackageRef names a package in a repository by its coordinates. Namespace is +// empty for formats that have none. +type PackageRef struct { + Domain string + Owner string + Repository string + Format string + Namespace string + Package string +} + +// Package formats (the PackageFormat enum of the CodeArtifact API). +const ( + FormatNPM = "npm" + FormatPyPI = "pypi" + FormatMaven = "maven" + FormatNuGet = "nuget" + FormatGeneric = "generic" + FormatRuby = "ruby" + FormatSwift = "swift" + FormatCargo = "cargo" +) From 946d06b359baa6d4d342b825960af0919cd9b0e1 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Mon, 26 Oct 2026 14:38:28 +0530 Subject: [PATCH 2/2] fix(aws-codeartifact): require read on the source repository for CopyPackageVersions --- server/aws/codeartifact/authz.go | 19 ++++++++++++++++-- server/aws/codeartifact/authz_test.go | 29 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/server/aws/codeartifact/authz.go b/server/aws/codeartifact/authz.go index be273a25f..f3d436737 100644 --- a/server/aws/codeartifact/authz.go +++ b/server/aws/codeartifact/authz.go @@ -69,7 +69,8 @@ func resourceKinds() map[string]resourceKind { // IAMChecks names the IAM actions a request needs: codeartifact: // against the domain, repository, package or package group it names, plus -// sts:GetServiceBearerToken for GetAuthorizationToken. ok=false means the +// sts:GetServiceBearerToken for GetAuthorizationToken and codeartifact:ReadFromRepository on the source +// repository for CopyPackageVersions. ok=false means the // request is not a CodeArtifact operation, and ServeHTTP then answers it without // side effects. func (*Handler) IAMChecks(r *http.Request, s awsauthz.Scope) ([]awsauthz.Check, bool) { @@ -80,8 +81,13 @@ func (*Handler) IAMChecks(r *http.Request, s awsauthz.Scope) ([]awsauthz.Check, checks := []awsauthz.Check{{Action: "codeartifact:" + op, Resource: resourceARN(op, r, s)}} - if op == opGetAuthorizationToken { + switch op { + case opGetAuthorizationToken: checks = append(checks, awsauthz.Check{Action: "sts:GetServiceBearerToken", Resource: "*"}) + case opCopyPackageVersions: + // A copy reads the source repository, so the caller needs read access there as well as + // write access to the destination. An unnamed source is left unresolved and fails closed. + checks = append(checks, awsauthz.Check{Action: "codeartifact:ReadFromRepository", Resource: sourceRepositoryARN(r.URL.Query(), s)}) } return checks, true @@ -131,6 +137,15 @@ func domainARN(op string, r *http.Request, q url.Values, s awsauthz.Scope) strin return s.ARN("codeartifact", "domain/"+domain) } +// sourceRepositoryARN names the source repository of a CopyPackageVersions request. +func sourceRepositoryARN(q url.Values, s awsauthz.Scope) string { + if q.Get("domain") == "" || q.Get("source-repository") == "" { + return "" + } + + return s.ARN("codeartifact", "repository/"+q.Get("domain")+"/"+q.Get("source-repository")) +} + func repositoryARN(q url.Values, s awsauthz.Scope) string { if q.Get("domain") == "" || q.Get("repository") == "" { return "" diff --git a/server/aws/codeartifact/authz_test.go b/server/aws/codeartifact/authz_test.go index 909e9a647..83f18607d 100644 --- a/server/aws/codeartifact/authz_test.go +++ b/server/aws/codeartifact/authz_test.go @@ -106,3 +106,32 @@ func TestIAMChecksPutPolicyBodyStillReadableByHandler(t *testing.T) { t.Fatalf("handler could not read the body after the resolver: %s", rec.Body.String()) } } + +func TestCopyNeedsReadOnTheSourceRepository(t *testing.T) { + h := fullHandler() + prefix := "arn:aws:codeartifact:us-east-1:123456789012:" + + req := httptest.NewRequest(http.MethodPost, "/v1/package/versions/copy?"+url.Values{ + "domain": {"dom"}, "source-repository": {"src"}, "destination-repository": {"dst"}, "format": {"npm"}, "package": {"p"}, + }.Encode(), strings.NewReader("{}")) + + checks, ok := h.IAMChecks(req, testScope) + if !ok || len(checks) != 2 { + t.Fatalf("checks = %+v, want the copy action and a read on the source", checks) + } + + if checks[0].Action != "codeartifact:CopyPackageVersions" || checks[0].Resource != prefix+"package/dom/dst/npm//p" { + t.Errorf("copy check = %+v", checks[0]) + } + + if checks[1].Action != "codeartifact:ReadFromRepository" || checks[1].Resource != prefix+"repository/dom/src" { + t.Errorf("source read check = %+v", checks[1]) + } + + noSource := httptest.NewRequest(http.MethodPost, "/v1/package/versions/copy?domain=dom&destination-repository=dst&format=npm&package=p", strings.NewReader("{}")) + + checks, _ = h.IAMChecks(noSource, testScope) + if len(checks) != 2 || checks[1].Resource != "" { + t.Errorf("a copy that names no source must leave the read unresolved (fail closed): %+v", checks) + } +}