diff --git a/gateway/gateway-controller/pkg/controlplane/sync_secrets_deprecate_test.go b/gateway/gateway-controller/pkg/controlplane/sync_secrets_deprecate_test.go new file mode 100644 index 0000000000..382ed5d769 --- /dev/null +++ b/gateway/gateway-controller/pkg/controlplane/sync_secrets_deprecate_test.go @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package controlplane + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/wso2/api-platform/gateway/gateway-controller/pkg/utils" +) + +// TestSyncSecretsIncremental_DeprecatedSecret_SkippedNotPurged characterises a +// known gap: when a secret the gateway previously synced transitions to a +// non-ACTIVE status (e.g. DEPRECATED), the incremental sync SKIPS it (status != +// "ACTIVE") and nothing removes it — the secretSyncer interface (client.go) +// exposes only UpsertFromPlatform, with no delete/purge path. So the stale +// plaintext already stored on the gateway for that handle is retained +// indefinitely on sync. +// +// The bulk path's deprecated-skip is covered by +// TestSyncSecretsBulk_DeprecatedSecret_Skipped; this covers the incremental +// (reconnect) path and asserts the non-purge behaviour explicitly. If a removal +// path is ever added, this test should change to assert the secret is purged. +func TestSyncSecretsIncremental_DeprecatedSecret_SkippedNotPurged(t *testing.T) { + syncer := newMockSecretSyncer() + // The gateway already holds a value for this handle from a prior ACTIVE sync. + syncer.upserted["openai-key"] = "sk-previously-synced" + c := stubClient(syncer) + populateCache(c, map[string]string{"openai-key": "hmac-sha256:old"}) + + // The secret now comes back DEPRECATED (with a changed hash, to show that even + // a change does not trigger a fetch or removal for a non-ACTIVE secret). + metas := []utils.PlatformSecretMeta{ + {ID: "uuid-1", Handle: "openai-key", DisplayName: "OpenAI Key", Hash: "hmac-sha256:new", Status: "DEPRECATED"}, + } + fetchValue := func(string) (string, error) { + t.Fatalf("value must not be fetched for a non-ACTIVE secret") + return "", nil + } + + synced, skipped, failed := syncSecretsIncrementalFromMetas(c, metas, fetchValue) + + assert.Equal(t, 0, synced) + assert.Equal(t, 1, skipped) + assert.Equal(t, 0, failed) + + // The gap: the stale value is still present — nothing purges a deprecated + // secret from the gateway's local store on sync. + assert.Equal(t, "sk-previously-synced", syncer.upserted["openai-key"], + "deprecated secret is skipped, not purged — stale plaintext remains on the gateway (known gap)") +} diff --git a/gateway/gateway-controller/pkg/encryption/aesgcm/key_restart_test.go b/gateway/gateway-controller/pkg/encryption/aesgcm/key_restart_test.go new file mode 100644 index 0000000000..a841eb3982 --- /dev/null +++ b/gateway/gateway-controller/pkg/encryption/aesgcm/key_restart_test.go @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aesgcm + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// These tests characterise the gateway's encryption-key-across-restart behaviour, +// as a contrast to the platform-api demo-mode bug (which mints a NEW in-memory key +// on every start, so stored secrets become undecryptable after a restart). +// +// The gateway is safer: in development mode it auto-generates the key ONCE and +// PERSISTS it to a file (keymgmt.go generateKeyFile), then reloads that same file +// on subsequent starts; in production mode it refuses to start without a +// provisioned key file (covered by TestKeyManager_DevModeOffNoAutoGeneration) — +// it never silently uses an ephemeral key. So a plain restart keeps secrets +// decryptable; only losing the key file (e.g. a recreated pod with no persistent +// volume for the key dir) regenerates a new key. + +const gwSecretUnderTest = "gw-super-secret-value-123" + +// TestDevModeKeyPersistsAcrossRestart_SecretStaysDecryptable proves the gateway +// does NOT have the platform-api per-restart bug: the dev-mode key is persisted to +// disk and reloaded on restart, so a secret encrypted before the restart still +// decrypts after it. +func TestDevModeKeyPersistsAcrossRestart_SecretStaysDecryptable(t *testing.T) { + t.Setenv("APIP_GW_DEVELOPMENT_MODE", "true") + keyPath := filepath.Join(t.TempDir(), "default-aesgcm256-v1.bin") + keyCfg := []KeyConfig{{Version: "aesgcm256-v1", FilePath: keyPath}} + + // First start: dev mode auto-generates and persists the key to disk. + p1, err := NewAESGCMProvider(keyCfg, testLogger()) + require.NoError(t, err) + payload, err := p1.Encrypt([]byte(gwSecretUnderTest)) + require.NoError(t, err) + + // Restart: a new provider over the same key path reloads the persisted key + // (it regenerates only when the file is missing), so the pre-restart secret + // still decrypts. + p2, err := NewAESGCMProvider(keyCfg, testLogger()) + require.NoError(t, err) + plaintext, err := p2.Decrypt(payload) + require.NoError(t, err, + "gateway persists its dev-mode key to disk, so stored secrets must stay decryptable across restart") + assert.Equal(t, gwSecretUnderTest, string(plaintext)) +} + +// TestDevModeKeyFileLost_SecretBecomesUndecryptable documents the one narrow +// condition under which the gateway hits an analogous problem: if the persisted +// key file is lost (a fresh filesystem with no persistent volume for the key +// dir), the next start regenerates a different key and prior secrets cannot be +// decrypted. This is much narrower than platform-api, which regenerates on every +// restart even when the key file / config could have been stable. +func TestDevModeKeyFileLost_SecretBecomesUndecryptable(t *testing.T) { + t.Setenv("APIP_GW_DEVELOPMENT_MODE", "true") + keyPath := filepath.Join(t.TempDir(), "default-aesgcm256-v1.bin") + keyCfg := []KeyConfig{{Version: "aesgcm256-v1", FilePath: keyPath}} + + p1, err := NewAESGCMProvider(keyCfg, testLogger()) + require.NoError(t, err) + payload, err := p1.Encrypt([]byte(gwSecretUnderTest)) + require.NoError(t, err) + + // Simulate loss of the key file (e.g. a recreated container without a + // persistent volume for the key directory). + require.NoError(t, os.Remove(keyPath)) + + p2, err := NewAESGCMProvider(keyCfg, testLogger()) + require.NoError(t, err) + _, err = p2.Decrypt(payload) + assert.Error(t, err, + "a regenerated key (after the persisted key file is lost) must not decrypt secrets encrypted with the old key") +} diff --git a/gateway/gateway-controller/pkg/encryption/aesgcm/key_rotation_test.go b/gateway/gateway-controller/pkg/encryption/aesgcm/key_rotation_test.go new file mode 100644 index 0000000000..1b0075e3a4 --- /dev/null +++ b/gateway/gateway-controller/pkg/encryption/aesgcm/key_rotation_test.go @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package aesgcm + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestKeyRotation_OldPayloadsStillDecryptAfterPrimaryRotates verifies real key +// rotation: after a new primary key is added, data encrypted under the old +// primary must still decrypt (the payload carries its key version and Decrypt +// looks the key up by version), while a provider missing the old key cannot. +func TestKeyRotation_OldPayloadsStillDecryptAfterPrimaryRotates(t *testing.T) { + t.Setenv("APIP_GW_DEVELOPMENT_MODE", "true") + dir := t.TempDir() + v1 := KeyConfig{Version: "aesgcm256-v1", FilePath: filepath.Join(dir, "v1.bin")} + v2 := KeyConfig{Version: "aesgcm256-v2", FilePath: filepath.Join(dir, "v2.bin")} + + const plaintext = "gw-secret-under-rotation" + + // v1 is the only/primary key: encrypt a payload (stamped key_version=v1). + pV1, err := NewAESGCMProvider([]KeyConfig{v1}, testLogger()) + require.NoError(t, err) + payloadV1, err := pV1.Encrypt([]byte(plaintext)) + require.NoError(t, err) + require.Equal(t, "aesgcm256-v1", payloadV1.KeyVersion) + + // Rotate: v2 becomes primary (first in the list), v1 kept as a secondary + // decryption key. + pRotated, err := NewAESGCMProvider([]KeyConfig{v2, v1}, testLogger()) + require.NoError(t, err) + + // Old v1 payload still decrypts under the rotated provider. + got, err := pRotated.Decrypt(payloadV1) + require.NoError(t, err, "payload encrypted under v1 must still decrypt after rotating primary to v2") + assert.Equal(t, plaintext, string(got)) + + // New payloads are encrypted under the new primary v2. + payloadV2, err := pRotated.Encrypt([]byte(plaintext)) + require.NoError(t, err) + require.Equal(t, "aesgcm256-v2", payloadV2.KeyVersion) + + // A provider that only knows v1 cannot decrypt a v2 payload (missing key version). + got2, err := pV1.Decrypt(payloadV2) + assert.Error(t, err, "a v1-only provider must not decrypt a v2 payload") + assert.Nil(t, got2) +} diff --git a/platform-api/it/README.md b/platform-api/it/README.md index 60b7209bf7..54289275dd 100644 --- a/platform-api/it/README.md +++ b/platform-api/it/README.md @@ -7,17 +7,32 @@ supported store instead of only on the SQLite path used by the unit tests. ## Layout +The suite is written in Gherkin and run by [godog](https://github.com/cucumber/godog) +(Cucumber for Go), consistent with `gateway/it` and `tests/integration-e2e`. + | Path | Purpose | |------|---------| -| `src/internal/integration/` | The tests (Go, build tag `integration`). | -| `src/internal/integration/harness_test.go` | DB selection + schema bootstrap, driven by `IT_DB`. | -| `src/internal/integration/cascade_test.go` | Delete-cascade behavior across the real foreign keys. | -| `src/internal/integration/lifecycle_test.go` | Create + paginated list through the real repository layer. | +| `src/internal/integration/` | The suite (Go, build tag `integration`). | +| `src/internal/integration/features/*.feature` | The scenarios (Gherkin). | +| `src/internal/integration/suite_test.go` | godog runner (`TestFeatures`) + per-scenario setup. | +| `src/internal/integration/harness.go` | DB selection + schema bootstrap, driven by `IT_DB`. | +| `src/internal/integration/world.go` | Per-scenario state + object-graph seeding. | +| `src/internal/integration/steps_crud.go` | Steps for the repository CRUD lifecycle scenarios. | +| `src/internal/integration/steps_cascade.go` | Steps for the delete-cascade scenarios. | +| `src/internal/integration/steps_pagination.go` | Steps for the pagination / filtered-listing scenarios. | +| `src/internal/integration/steps_llm.go` | Steps for the LLM control-plane (template / provider / proxy) scenarios. | +| `src/internal/integration/steps_mcp.go` | Steps for the MCP control-plane (proxy) scenario. | +| `src/internal/integration/steps_webbroker.go` | Steps for the WebBroker API repository scenario. | +| `src/internal/integration/steps_secret.go` | Steps for the secret repository scenario. | +| `src/internal/integration/steps_deployment.go` | Steps for the deployment repository scenarios. | | `it/docker-compose.postgres.yaml` | Throwaway PostgreSQL for the tests. | | `it/docker-compose.sqlserver.yaml` | Throwaway SQL Server for the tests. | The tests run on the host and connect to the database over a published port, so -the same test binary covers all three engines. +the same test binary covers all three engines. A fresh database and a fresh +scenario state are created per scenario, so scenarios are independent. Each +scenario also surfaces as an individual `go test` subtest via godog's `TestingT` +integration, so `-run TestFeatures/` works. ## Running @@ -64,6 +79,34 @@ IT_DB=sqlserver IT_DB_HOST=localhost IT_DB_PORT=1433 \ deployments and deployment status, deleting a devportal removes its publications, deleting an application removes its mappings, and deleting a project removes its applications. +- **LLM control plane** — the provider-template, provider and proxy repositories + round-trip through create / read / update / list / delete, version resolution + (`is_latest` flip across a family), and the `provider → template` / + `proxy → provider` foreign keys. These run entirely at the store layer: **no + real LLM is contacted and the upstream API key is a dummy string**, so no + provider credentials are needed on any engine. +- **MCP control plane** — the MCP proxy repository round-trips through + create / read / update / list (by org and project) / delete, again with a + **dummy upstream API key** and no real MCP server. +- **WebBroker API store** — the WebBroker (event-broker) API repository + round-trips through create / read / update / paginated list / project-scoped + list / delete on every engine. +- **Secret store** — the secret repository round-trips encrypted ciphertext and + hash, reports type/provider/status defaults, checks existence and count, lists + with pagination, rotates the ciphertext, and soft-deletes (deprecates) a secret + that has no active references. This scenario surfaced — and the fix is verified + by — two latent SQL Server bugs in `SecretRepo` where `List` and the + soft-delete row-lock used the `LIMIT` keyword (rejected by SQL Server); both now + use the dialect-aware `PaginationClause` / `FetchFirstClause` helpers. +- **Deployment store** — the deployment repository is driven through its real + methods (not raw SQL) so the dialect-specific SQL is verified on every engine: + `CreateWithLimitEnforcement` (the `deployment_status` upsert + the FETCH-first + hard-limit pruning), `GetCurrentByGateway` (FETCH-first current lookup), + `GetDeploymentsWithState` (the `ROW_NUMBER() OVER` per-gateway ranking), + `GetStatus`, `HasActiveDeployment`, `GetWithContent`, and the undeploy + transition via `SetCurrentWithDetails`. This is the path whose SQL Server + undeploy failure was fixed upstream and was previously only covered by the + delete-cascade. ## Relationship to the gateway integration tests diff --git a/platform-api/src/config/config_test.go b/platform-api/src/config/config_test.go index f17f3c182e..82d1a0c7e3 100644 --- a/platform-api/src/config/config_test.go +++ b/platform-api/src/config/config_test.go @@ -36,6 +36,11 @@ func TestLoadConfig_MissingSecretEncryptionKey_DemoMode_GeneratesEphemeralKey(t t.Setenv("APIP_DATABASE_SECRET_ENCRYPTION_KEY", "") t.Setenv("DATABASE_ENCRYPTION_KEY", "") t.Setenv("AUTH_JWT_SECRET_KEY", "") + // Block key-file persistence so the key is truly ephemeral and no + // data/secret-encryption.key is written into the working directory. + blockingFile := filepath.Join(t.TempDir(), "not-a-dir") + require.NoError(t, os.WriteFile(blockingFile, []byte("block"), 0600)) + t.Setenv("DATABASE_SECRET_ENCRYPTION_KEY_FILE", filepath.Join(blockingFile, "secret-encryption.key")) cfg, err := LoadConfig("") require.NoError(t, err, "LoadConfig must succeed in DEMO_MODE even without a secret encryption key") @@ -63,19 +68,20 @@ func TestLoadConfig_MissingSecretEncryptionKey_NonDemoMode_ReturnsError(t *testi assert.Contains(t, err.Error(), "failed to load secret key file") } -// Missing key with APIP_DEMO_MODE unset → demo mode is the default, so an -// ephemeral key is generated and LoadConfig succeeds. +// Missing key with APIP_DEMO_MODE unset → demo mode is the default, so a +// key is generated and LoadConfig succeeds. func TestLoadConfig_MissingSecretEncryptionKey_UnsetDemoMode_DefaultsToDemo(t *testing.T) { os.Unsetenv("APIP_DEMO_MODE") t.Setenv("PLATFORM_SECRET_ENCRYPTION_KEY", "") t.Setenv("APIP_DATABASE_SECRET_ENCRYPTION_KEY", "") t.Setenv("DATABASE_ENCRYPTION_KEY", "") t.Setenv("AUTH_JWT_SECRET_KEY", "") + t.Setenv("DATABASE_SECRET_ENCRYPTION_KEY_FILE", filepath.Join(t.TempDir(), "secret-encryption.key")) cfg, err := LoadConfig("") require.NoError(t, err, "LoadConfig must succeed when APIP_DEMO_MODE is unset (demo is the default)") assert.NotEmpty(t, cfg.Database.SecretEncryptionKey, - "an ephemeral key must be generated when no key is configured and APIP_DEMO_MODE is unset") + "a key must be generated when no key is configured and APIP_DEMO_MODE is unset") } // TC-35: Ephemeral key must be unique each LoadConfig call (i.e. truly random, not a constant). @@ -149,6 +155,7 @@ func TestLoadConfig_DemoModeOne_AcceptedAsTruthy(t *testing.T) { t.Setenv("APIP_DEMO_MODE", "1") t.Setenv("PLATFORM_SECRET_ENCRYPTION_KEY", "") t.Setenv("APIP_DATABASE_SECRET_ENCRYPTION_KEY", "") + t.Setenv("DATABASE_SECRET_ENCRYPTION_KEY_FILE", filepath.Join(t.TempDir(), "secret-encryption.key")) cfg, err := LoadConfig("") require.NoError(t, err) @@ -160,6 +167,7 @@ func TestLoadConfig_DemoModeWhitespace_Trimmed(t *testing.T) { t.Setenv("APIP_DEMO_MODE", " true ") t.Setenv("PLATFORM_SECRET_ENCRYPTION_KEY", "") t.Setenv("APIP_DATABASE_SECRET_ENCRYPTION_KEY", "") + t.Setenv("DATABASE_SECRET_ENCRYPTION_KEY_FILE", filepath.Join(t.TempDir(), "secret-encryption.key")) cfg, err := LoadConfig("") require.NoError(t, err) diff --git a/platform-api/src/config/secret_encryption_key_restart_test.go b/platform-api/src/config/secret_encryption_key_restart_test.go new file mode 100644 index 0000000000..760d809d79 --- /dev/null +++ b/platform-api/src/config/secret_encryption_key_restart_test.go @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package config + +import ( + "context" + "os" + "path/filepath" + "testing" + + "platform-api/src/internal/utils" + "platform-api/src/internal/vault" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// These tests verify the demo-mode secret-encryption-key lifecycle end to end: +// when APIP_DEMO_MODE is on (the default) and no encryption key is configured, +// LoadConfig persists a generated key to DATABASE_SECRET_ENCRYPTION_KEY_FILE on +// first start and reloads it on subsequent starts, so a secret encrypted before +// a restart still decrypts after it. Only when the key file is unusable does +// demo mode fall back to an ephemeral per-process key (secrets then do not +// survive restarts). Decryption is exercised through the same vault the server +// uses, not just by comparing key strings. + +const secretUnderTest = "sk-super-secret-value-123" + +// newSecretVault derives the 32-byte AES-256 key from a config key string exactly +// as server.go does, and builds the in-house vault used for stored secrets. +func newSecretVault(t *testing.T, keyStr string) *vault.InHouseVault { + t.Helper() + key, err := utils.DeriveEncryptionKey(keyStr) + require.NoError(t, err, "deriving AES key from config value %q", keyStr) + v, err := vault.NewInHouseVault(key) + require.NoError(t, err) + return v +} + +// TestDemoModeKeyFile_SecretSurvivesRestart verifies the demo-mode fix: with no +// key configured, the first LoadConfig generates and persists a key file, a +// second LoadConfig ("restart") reloads the same key, and a secret encrypted +// before the restart still decrypts after it. +func TestDemoModeKeyFile_SecretSurvivesRestart(t *testing.T) { + keyFile := filepath.Join(t.TempDir(), "secret-encryption.key") + t.Setenv("APIP_DEMO_MODE", "true") + t.Setenv("PLATFORM_SECRET_ENCRYPTION_KEY", "") + t.Setenv("APIP_DATABASE_SECRET_ENCRYPTION_KEY", "") + t.Setenv("DATABASE_ENCRYPTION_KEY", "") + t.Setenv("DATABASE_SECRET_ENCRYPTION_KEY_FILE", keyFile) + + // First start: the key file is generated and the secret encrypted with it. + cfg1, err := LoadConfig("") + require.NoError(t, err) + require.NotEmpty(t, cfg1.Database.SecretEncryptionKey) + info, err := os.Stat(keyFile) + require.NoError(t, err, "demo mode must persist the generated key file") + assert.Equal(t, int64(32), info.Size()) + assert.Equal(t, os.FileMode(0600), info.Mode().Perm()) + + ciphertext, err := newSecretVault(t, cfg1.Database.SecretEncryptionKey). + Encrypt(context.Background(), secretUnderTest) + require.NoError(t, err) + + // Restart: the second LoadConfig must reload the persisted key, not mint a + // new one, so the pre-restart ciphertext still decrypts. + cfg2, err := LoadConfig("") + require.NoError(t, err) + require.Equal(t, cfg1.Database.SecretEncryptionKey, cfg2.Database.SecretEncryptionKey, + "demo mode must reload the persisted key file across restarts") + + plaintext, err := newSecretVault(t, cfg2.Database.SecretEncryptionKey). + Decrypt(context.Background(), ciphertext) + require.NoError(t, err, + "a secret encrypted before restart must stay decryptable via the persisted demo-mode key") + assert.Equal(t, secretUnderTest, plaintext) +} + +// TestDemoModeUnusableKeyFile_FallsBackToEphemeralKey covers the fallback path: +// if the key file cannot be used (here: wrong size), demo mode still starts but +// mints a fresh ephemeral key per process, so secrets do not survive a restart. +func TestDemoModeUnusableKeyFile_FallsBackToEphemeralKey(t *testing.T) { + keyFile := filepath.Join(t.TempDir(), "secret-encryption.key") + require.NoError(t, os.WriteFile(keyFile, []byte("short"), 0600), + "seeding an invalid (wrong-size) key file") + t.Setenv("APIP_DEMO_MODE", "true") + t.Setenv("PLATFORM_SECRET_ENCRYPTION_KEY", "") + t.Setenv("APIP_DATABASE_SECRET_ENCRYPTION_KEY", "") + t.Setenv("DATABASE_ENCRYPTION_KEY", "") + t.Setenv("DATABASE_SECRET_ENCRYPTION_KEY_FILE", keyFile) + + cfg1, err := LoadConfig("") + require.NoError(t, err) + vault1 := newSecretVault(t, cfg1.Database.SecretEncryptionKey) + ciphertext, err := vault1.Encrypt(context.Background(), secretUnderTest) + require.NoError(t, err) + + // Sanity: the same key round-trips, so the failure below is the key change. + roundTrip, err := vault1.Decrypt(context.Background(), ciphertext) + require.NoError(t, err) + require.Equal(t, secretUnderTest, roundTrip) + + cfg2, err := LoadConfig("") + require.NoError(t, err) + require.NotEqual(t, cfg1.Database.SecretEncryptionKey, cfg2.Database.SecretEncryptionKey, + "an unusable key file must fall back to a fresh ephemeral key per start") + + _, err = newSecretVault(t, cfg2.Database.SecretEncryptionKey). + Decrypt(context.Background(), ciphertext) + assert.Error(t, err, + "with an ephemeral fallback key, secrets encrypted before restart are unreadable") +} + +// TestStableConfiguredKey_SecretSurvivesRestart: when +// PLATFORM_SECRET_ENCRYPTION_KEY is configured, the key is stable across restarts +// and a secret encrypted before a restart still decrypts after it. +func TestStableConfiguredKey_SecretSurvivesRestart(t *testing.T) { + const stableKey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + t.Setenv("APIP_DEMO_MODE", "true") + t.Setenv("PLATFORM_SECRET_ENCRYPTION_KEY", stableKey) + t.Setenv("APIP_DATABASE_SECRET_ENCRYPTION_KEY", "") + t.Setenv("DATABASE_ENCRYPTION_KEY", "") + + cfg1, err := LoadConfig("") + require.NoError(t, err) + ciphertext, err := newSecretVault(t, cfg1.Database.SecretEncryptionKey). + Encrypt(context.Background(), secretUnderTest) + require.NoError(t, err) + + cfg2, err := LoadConfig("") + require.NoError(t, err) + require.Equal(t, cfg1.Database.SecretEncryptionKey, cfg2.Database.SecretEncryptionKey, + "a configured key must be stable across restarts") + + plaintext, err := newSecretVault(t, cfg2.Database.SecretEncryptionKey). + Decrypt(context.Background(), ciphertext) + require.NoError(t, err, + "a secret must stay decryptable across restart when a stable key is configured") + assert.Equal(t, secretUnderTest, plaintext) +} diff --git a/platform-api/src/go.mod b/platform-api/src/go.mod index 9a9f1205bf..028f53163e 100644 --- a/platform-api/src/go.mod +++ b/platform-api/src/go.mod @@ -3,6 +3,7 @@ module platform-api/src go 1.26.2 require ( + github.com/cucumber/godog v0.15.0 github.com/go-playground/validator/v10 v10.29.0 github.com/go-viper/mapstructure/v2 v2.4.0 github.com/golang-jwt/jwt/v5 v5.3.1 @@ -16,7 +17,6 @@ require ( github.com/mattn/go-sqlite3 v1.14.41 github.com/microsoft/go-mssqldb v1.10.0 github.com/oapi-codegen/runtime v1.1.2 - github.com/pb33f/libopenapi v0.28.2 github.com/stretchr/testify v1.11.1 github.com/wso2/api-platform/common v0.0.0 github.com/wso2/go-httpkit v0.0.0-local @@ -28,15 +28,19 @@ require ( github.com/MicahParks/jwkset v0.11.0 // indirect github.com/MicahParks/keyfunc/v3 v3.7.0 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect - github.com/bahlo/generic-list-go v0.2.0 // indirect - github.com/buger/jsonparser v1.1.1 // indirect + github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect + github.com/cucumber/messages/go/v21 v21.0.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/gofrs/uuid v4.3.1+incompatible // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect + github.com/hashicorp/go-memdb v1.3.4 // indirect + github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -45,12 +49,10 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/pb33f/jsonpath v0.1.2 // indirect - github.com/pb33f/ordered-map/v2 v2.3.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/shopspring/decimal v1.4.0 // indirect - go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect + github.com/spf13/pflag v1.0.5 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect diff --git a/platform-api/src/go.sum b/platform-api/src/go.sum index 0067a1893c..fc01af6aad 100644 --- a/platform-api/src/go.sum +++ b/platform-api/src/go.sum @@ -19,12 +19,17 @@ github.com/MicahParks/keyfunc/v3 v3.7.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcM github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= -github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= -github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= -github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= -github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI= +github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0= +github.com/cucumber/godog v0.15.0 h1:51AL8lBXF3f0cyA5CV4TnJFCTHpgiy+1x1Hb3TtZUmo= +github.com/cucumber/godog v0.15.0/go.mod h1:FX3rzIDybWABU4kuIXLZ/qtqEe1Ac5RdXmqvACJOces= +github.com/cucumber/messages/go/v21 v21.0.1 h1:wzA0LxwjlWQYZd32VTlAVDTkW6inOFmSM+RuOwHZiMI= +github.com/cucumber/messages/go/v21 v21.0.1/go.mod h1:zheH/2HS9JLVFukdrsPWoPdmUtmYQAQPLk7w5vWsk5s= +github.com/cucumber/messages/go/v22 v22.0.0/go.mod h1:aZipXTKc0JnjCsXrJnuZpWhtay93k7Rn3Dee7iyPJjs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -43,6 +48,9 @@ github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpv github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI= +github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= @@ -53,6 +61,19 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-memdb v1.3.4 h1:XSL3NR682X/cVk2IeV0d70N4DZ9ljI885xAEU8IoK3c= +github.com/hashicorp/go-memdb v1.3.4/go.mod h1:uBTr1oQbtuMgd1SSGoR8YV27eT3sBHbYiNm53bMpgSg= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -74,8 +95,11 @@ github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= github.com/knadh/koanf/v2 v2.3.2 h1:Ee6tuzQYFwcZXQpc2MiVeC6qHMandf5SMUJJNoFp/c4= github.com/knadh/koanf/v2 v2.3.2/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -95,12 +119,6 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= -github.com/pb33f/jsonpath v0.1.2 h1:PlqXjEyecMqoYJupLxYeClCGWEpAFnh4pmzgspbXDPI= -github.com/pb33f/jsonpath v0.1.2/go.mod h1:TtKnUnfqZm48q7a56DxB3WtL3ipkVtukMKGKxaR/uXU= -github.com/pb33f/libopenapi v0.28.2 h1:AXVCE8DWzytXu0jv0Z+cXVopnO/bXU1oWvgA9qiRWgw= -github.com/pb33f/libopenapi v0.28.2/go.mod h1:mHMHA3ZKSZDTInNAuUtqkHlKLIjPm2HN1vgsGR57afc= -github.com/pb33f/ordered-map/v2 v2.3.0 h1:k2OhVEQkhTCQMhAicQ3Z6iInzoZNQ7L9MVomwKBZ5WQ= -github.com/pb33f/ordered-map/v2 v2.3.0/go.mod h1:oe5ue+6ZNhy7QN9cPZvPA23Hx0vMHnNVeMg4fGdCANw= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= @@ -110,16 +128,24 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= -go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= diff --git a/platform-api/src/internal/constants/error.go b/platform-api/src/internal/constants/error.go index 171a81a741..9d91be4bfa 100644 --- a/platform-api/src/internal/constants/error.go +++ b/platform-api/src/internal/constants/error.go @@ -32,7 +32,7 @@ var ( ErrOrganizationNotFound = errors.New("organization not found") ErrMultipleOrganizations = errors.New("multiple organizations found") ErrInvalidInput = errors.New("invalid input parameters") - ErrHandleImmutable = errors.New("id is immutable and cannot be changed") + ErrHandleImmutable = errors.New("id is immutable and cannot be changed") ) var ( diff --git a/platform-api/src/internal/dto/api.go b/platform-api/src/internal/dto/api.go index c54cf8e0fa..f2059b7015 100644 --- a/platform-api/src/internal/dto/api.go +++ b/platform-api/src/internal/dto/api.go @@ -160,4 +160,3 @@ type APIListResponse struct { List []*API `json:"list" yaml:"list"` // Array of API objects Pagination Pagination `json:"pagination" yaml:"pagination"` // Pagination metadata } - diff --git a/platform-api/src/internal/integration/cascade_test.go b/platform-api/src/internal/integration/cascade_test.go deleted file mode 100644 index ae69374102..0000000000 --- a/platform-api/src/internal/integration/cascade_test.go +++ /dev/null @@ -1,173 +0,0 @@ -//go:build integration - -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -package integration - -import ( - "testing" - - "github.com/google/uuid" -) - -// graph holds the identifiers seeded into a single organization. -type graph struct { - org, project, app string - apiArtifact, depArtifact string - plan, sub, gateway, deploy string - apiKey string - planLimit string -} - -// seedOrgGraph inserts a representative object graph for one organization that -// touches every table whose foreign keys were changed for SQL Server -// (applications, subscriptions, deployments, deployment_status, -// publication_mappings) plus their parents. -func seedOrgGraph(t *testing.T, it *itDB) graph { - t.Helper() - g := graph{ - org: id(), project: id(), app: id(), - apiArtifact: id(), depArtifact: id(), - plan: id(), sub: id(), gateway: id(), deploy: id(), - apiKey: id(), - planLimit: id(), - } - - it.exec(t, `INSERT INTO organizations (uuid, handle, display_name, region, idp_organization_ref_uuid) VALUES (?, ?, ?, ?, ?)`, - g.org, "h-"+g.org[:8], "it org", "us", "idp-ref") - it.exec(t, `INSERT INTO projects (uuid, handle, display_name, organization_uuid) VALUES (?, ?, ?, ?)`, - g.project, "proj", "proj", g.org) - it.exec(t, `INSERT INTO applications (uuid, handle, project_uuid, organization_uuid, display_name, type) VALUES (?, ?, ?, ?, ?, ?)`, - g.app, "app-"+g.app[:8], g.project, g.org, "app", "standard") - - // REST API: an artifact + its rest_apis row (shared uuid). - it.exec(t, `INSERT INTO artifacts (uuid, type, organization_uuid) VALUES (?, ?, ?)`, - g.apiArtifact, "rest_api", g.org) - it.exec(t, `INSERT INTO rest_apis (uuid, organization_uuid, handle, display_name, version, project_uuid, lifecycle_status, configuration) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - g.apiArtifact, g.org, "api-"+g.apiArtifact[:8], "api", "v1.0", g.project, "CREATED", []byte("{}")) - - it.exec(t, `INSERT INTO subscription_plans (uuid, handle, display_name, organization_uuid) VALUES (?, ?, ?, ?)`, - g.plan, "plan-"+g.plan[:8], "Plan "+g.plan[:8], g.org) - it.exec(t, `INSERT INTO subscription_plan_limits (uuid, subscription_plan_uuid, limit_type, limit_count, time_unit) VALUES (?, ?, ?, ?, ?)`, - g.planLimit, g.plan, "REQUEST_COUNT", 100, "MINUTE") - it.exec(t, `INSERT INTO subscriptions (uuid, artifact_uuid, subscriber_id, subscription_token, subscription_token_hash, subscription_plan_uuid, organization_uuid) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - g.sub, g.apiArtifact, "subscriber", "tok-"+g.sub[:8], "hash-"+g.sub[:8], g.plan, g.org) - - // Gateway + a deployment + its current status. - it.exec(t, `INSERT INTO gateways (uuid, organization_uuid, handle, display_name, properties) VALUES (?, ?, ?, ?, ?)`, - g.gateway, g.org, "gw-"+g.gateway[:8], "gw", []byte("{}")) - it.exec(t, `INSERT INTO artifacts (uuid, type, organization_uuid) VALUES (?, ?, ?)`, - g.depArtifact, "rest_api", g.org) - it.exec(t, `INSERT INTO deployments (uuid, display_name, artifact_uuid, organization_uuid, gateway_uuid, content) VALUES (?, ?, ?, ?, ?, ?)`, - g.deploy, "d", g.depArtifact, g.org, g.gateway, []byte("x")) - it.exec(t, `INSERT INTO deployment_status (artifact_uuid, organization_uuid, gateway_uuid, deployment_uuid) VALUES (?, ?, ?, ?)`, - g.depArtifact, g.org, g.gateway, g.deploy) - - // An API key on the deployment artifact + its application mapping. - it.exec(t, `INSERT INTO api_keys (uuid, artifact_uuid, handle, display_name, masked_api_key, api_key_hashes) VALUES (?, ?, ?, ?, ?, ?)`, - g.apiKey, g.depArtifact, "key", "key", "ab12", []byte("{}")) - it.exec(t, `INSERT INTO application_api_key_mappings (application_uuid, api_key_id) VALUES (?, ?)`, g.app, g.apiKey) - it.exec(t, `INSERT INTO application_artifact_mappings (application_uuid, artifact_uuid) VALUES (?, ?)`, g.app, g.depArtifact) - return g -} - -// TestCascade_DeleteRestAPIRemovesSubscriptions verifies the kept -// api_uuid -> rest_apis CASCADE still removes subscriptions (the -// subscriptions.organization_uuid / artifacts edges are now NO ACTION on -// SQL Server, so cleanup must flow through rest_apis). -func TestCascade_DeleteRestAPIRemovesSubscriptions(t *testing.T) { - it := openITDB(t) - defer it.db.Close() - g := seedOrgGraph(t, it) - - if got := it.count(t, "subscriptions", "uuid", g.sub); got != 1 { - t.Fatalf("precondition: want 1 subscription, got %d", got) - } - // Mirrors APIRepo.DeleteAPI ordering: deployments are removed explicitly, - // rest_apis + artifacts cascade the rest. - it.exec(t, `DELETE FROM rest_apis WHERE uuid = ?`, g.apiArtifact) - it.exec(t, `DELETE FROM artifacts WHERE uuid = ?`, g.apiArtifact) - - if got := it.count(t, "subscriptions", "uuid", g.sub); got != 0 { - t.Fatalf("[%s] subscription not cascade-deleted after REST API delete: %d remain", it.driver, got) - } -} - -// TestCascade_DeleteGatewayRemovesDeployments verifies gateway deletion still -// removes its deployments and deployment_status (deployment_status now cascades -// only via deployment_uuid on SQL Server). -func TestCascade_DeleteGatewayRemovesDeployments(t *testing.T) { - it := openITDB(t) - defer it.db.Close() - g := seedOrgGraph(t, it) - - if got := it.count(t, "deployment_status", "deployment_uuid", g.deploy); got != 1 { - t.Fatalf("precondition: want 1 deployment_status, got %d", got) - } - it.exec(t, `DELETE FROM gateways WHERE uuid = ? AND organization_uuid = ?`, g.gateway, g.org) - - if got := it.count(t, "deployments", "uuid", g.deploy); got != 0 { - t.Fatalf("[%s] deployment not removed after gateway delete: %d remain", it.driver, got) - } - if got := it.count(t, "deployment_status", "deployment_uuid", g.deploy); got != 0 { - t.Fatalf("[%s] deployment_status not removed after gateway delete: %d remain", it.driver, got) - } -} - -// TestCascade_DeleteApplicationRemovesMappings verifies application deletion -// still cascade-removes its key and artifact mappings (these edges are -// unchanged, but the app's organization edge changed, so it is worth pinning). -func TestCascade_DeleteApplicationRemovesMappings(t *testing.T) { - it := openITDB(t) - defer it.db.Close() - g := seedOrgGraph(t, it) - - it.exec(t, `DELETE FROM applications WHERE uuid = ? AND organization_uuid = ?`, g.app, g.org) - if got := it.count(t, "application_api_key_mappings", "api_key_id", g.apiKey); got != 0 { - t.Fatalf("[%s] application_api_key_mappings not removed after application delete: %d remain", it.driver, got) - } - if got := it.count(t, "application_artifact_mappings", "application_uuid", g.app); got != 0 { - t.Fatalf("[%s] application_artifact_mappings not removed after application delete: %d remain", it.driver, got) - } -} - -// TestCascade_DeleteSubscriptionPlanRemovesLimits verifies that deleting a -// subscription plan cascade-removes its subscription_plan_limits rows. On SQL -// Server the limit's organization/composite edges are NO ACTION (to avoid the -// multiple-cascade-paths restriction), so cleanup must flow through the -// subscription_plan_uuid -> subscription_plans CASCADE edge. The subscription is -// removed first because subscriptions.subscription_plan_uuid blocks plan deletion. -func TestCascade_DeleteSubscriptionPlanRemovesLimits(t *testing.T) { - it := openITDB(t) - defer it.db.Close() - g := seedOrgGraph(t, it) - - if got := it.count(t, "subscription_plan_limits", "uuid", g.planLimit); got != 1 { - t.Fatalf("precondition: want 1 subscription_plan_limit, got %d", got) - } - it.exec(t, `DELETE FROM subscriptions WHERE uuid = ?`, g.sub) - it.exec(t, `DELETE FROM subscription_plans WHERE uuid = ? AND organization_uuid = ?`, g.plan, g.org) - - if got := it.count(t, "subscription_plan_limits", "uuid", g.planLimit); got != 0 { - t.Fatalf("[%s] subscription_plan_limit not removed after plan delete: %d remain", it.driver, got) - } -} - -func id() string { return uuid.NewString() } diff --git a/platform-api/src/internal/integration/crud_test.go b/platform-api/src/internal/integration/crud_test.go deleted file mode 100644 index d1474d0f3f..0000000000 --- a/platform-api/src/internal/integration/crud_test.go +++ /dev/null @@ -1,366 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -//go:build integration - -package integration - -import ( - "fmt" - "testing" - - "platform-api/src/internal/constants" - "platform-api/src/internal/model" - "platform-api/src/internal/repository" -) - -// seedOrgProject creates one organization and one project through the real -// repositories and returns their UUIDs. It is the lightweight parent graph for -// the CRUD tests below (REST APIs, gateways and API keys all hang off these). -func seedOrgProject(t *testing.T, it *itDB, prefix string) (orgID, projID string) { - t.Helper() - orgRepo := repository.NewOrganizationRepo(it.db) - projectRepo := repository.NewProjectRepo(it.db) - - org := &model.Organization{ID: id(), Handle: prefix + "-" + id()[:8], Name: prefix + " org", Region: "us"} - if err := orgRepo.CreateOrganization(org); err != nil { - t.Fatalf("[%s] create org failed: %v", it.driver, err) - } - projID = id() - pName := prefix + "-proj-" + projID[:6] - proj := &model.Project{ID: projID, Handle: pName, Name: pName, OrganizationID: org.ID, Description: "p"} - if err := projectRepo.CreateProject(proj); err != nil { - t.Fatalf("[%s] create project failed: %v", it.driver, err) - } - return org.ID, projID -} - -// TestLifecycle_RestAPICreateUpdateAndList drives the real APIRepo through the -// full create → read → update → list path (the rest_apis two-table write of -// artifacts + rest_apis, the unique handle/name+version constraints, and the -// org/project listing queries) across the active engine. This covers the API -// creation, update and listing flows that the cascade suite only sets up by -// raw INSERT. -func TestLifecycle_RestAPICreateUpdateAndList(t *testing.T) { - it := openITDB(t) - defer it.db.Close() - orgID, projID := seedOrgProject(t, it, "api") - apiRepo := repository.NewAPIRepo(it.db) - - // --- Create --- - const n = 3 - createdIDs := make([]string, 0, n) - for i := range n { - api := &model.API{ - Handle: fmt.Sprintf("rest-api-%d-%s", i, id()[:6]), - Name: fmt.Sprintf("Rest API %d %s", i, id()[:6]), - Version: "v1.0", - Description: "created by integration test", - ProjectID: projID, - OrganizationID: orgID, - LifeCycleStatus: "CREATED", - Configuration: model.RestAPIConfig{Name: fmt.Sprintf("Rest API %d", i), Version: "v1.0"}, - } - if err := apiRepo.CreateAPI(api); err != nil { - t.Fatalf("[%s] CreateAPI %d failed: %v", it.driver, i, err) - } - // CreateAPI generates the UUID itself and writes it back onto the model. - if api.ID == "" { - t.Fatalf("[%s] CreateAPI %d did not populate the generated ID", it.driver, i) - } - createdIDs = append(createdIDs, api.ID) - } - - // --- Read back a single API and confirm the round-trip --- - first, err := apiRepo.GetAPIByUUID(createdIDs[0], orgID) - if err != nil { - t.Fatalf("[%s] GetAPIByUUID failed: %v", it.driver, err) - } - if first == nil { - t.Fatalf("[%s] GetAPIByUUID: want the created API, got nil", it.driver) - } - if first.LifeCycleStatus != "CREATED" || first.ProjectID != projID { - t.Fatalf("[%s] GetAPIByUUID round-trip mismatch: status=%q project=%q", it.driver, first.LifeCycleStatus, first.ProjectID) - } - - // Handle existence check (true for a created handle, false for a random one). - exists, err := apiRepo.CheckAPIExistsByHandleInOrganization(first.Handle, orgID) - if err != nil { - t.Fatalf("[%s] CheckAPIExistsByHandleInOrganization failed: %v", it.driver, err) - } - if !exists { - t.Fatalf("[%s] CheckAPIExistsByHandleInOrganization: want true for %q", it.driver, first.Handle) - } - missing, err := apiRepo.CheckAPIExistsByHandleInOrganization("no-such-handle-"+id(), orgID) - if err != nil { - t.Fatalf("[%s] CheckAPIExistsByHandleInOrganization(missing) failed: %v", it.driver, err) - } - if missing { - t.Fatalf("[%s] CheckAPIExistsByHandleInOrganization: want false for a random handle", it.driver) - } - - // --- Update --- - first.Name = first.Name + " (updated)" - first.Description = "updated by integration test" - first.LifeCycleStatus = "PUBLISHED" - first.UpdatedBy = "it-user" - if err := apiRepo.UpdateAPI(first); err != nil { - t.Fatalf("[%s] UpdateAPI failed: %v", it.driver, err) - } - updated, err := apiRepo.GetAPIByUUID(first.ID, orgID) - if err != nil { - t.Fatalf("[%s] GetAPIByUUID after update failed: %v", it.driver, err) - } - if updated.Name != first.Name || updated.LifeCycleStatus != "PUBLISHED" || updated.Description != "updated by integration test" { - t.Fatalf("[%s] UpdateAPI did not persist: name=%q status=%q desc=%q", - it.driver, updated.Name, updated.LifeCycleStatus, updated.Description) - } - if updated.UpdatedBy != "it-user" { - t.Fatalf("[%s] UpdateAPI did not persist updated_by: got %q", it.driver, updated.UpdatedBy) - } - - // --- List (by organization and by project) --- - byOrg, err := apiRepo.GetAPIsByOrganizationUUID(orgID, "") - if err != nil { - t.Fatalf("[%s] GetAPIsByOrganizationUUID failed: %v", it.driver, err) - } - if len(byOrg) != n { - t.Fatalf("[%s] GetAPIsByOrganizationUUID: want %d, got %d", it.driver, n, len(byOrg)) - } - byProject, err := apiRepo.GetAPIsByProjectUUID(projID, orgID) - if err != nil { - t.Fatalf("[%s] GetAPIsByProjectUUID failed: %v", it.driver, err) - } - if len(byProject) != n { - t.Fatalf("[%s] GetAPIsByProjectUUID: want %d, got %d", it.driver, n, len(byProject)) - } - // Org-scoped filter on a different project returns nothing. - byOther, err := apiRepo.GetAPIsByOrganizationUUID(orgID, id()) - if err != nil { - t.Fatalf("[%s] GetAPIsByOrganizationUUID(other project) failed: %v", it.driver, err) - } - if len(byOther) != 0 { - t.Fatalf("[%s] GetAPIsByOrganizationUUID(other project): want 0, got %d", it.driver, len(byOther)) - } -} - -// TestLifecycle_GatewayCreateListAndTokenGenerate drives the real GatewayRepo -// through gateway creation, lookup and listing, then through registration-token -// generation, lookup and revocation (the gateway_tokens table). This covers the -// gateway creation and gateway token generation flows across the active engine. -func TestLifecycle_GatewayCreateListAndTokenGenerate(t *testing.T) { - it := openITDB(t) - defer it.db.Close() - orgID, _ := seedOrgProject(t, it, "gw") - gwRepo := repository.NewGatewayRepo(it.db) - - // --- Create gateways --- - const n = 3 - gatewayIDs := make([]string, 0, n) - for i := range n { - gw := &model.Gateway{ - ID: id(), - OrganizationID: orgID, - Name: fmt.Sprintf("gateway %d", i), - Handle: fmt.Sprintf("gw-%d-%s", i, id()[:6]), - Description: "created by integration test", - Endpoints: []string{"https://localhost:8443", "wss://localhost:8444"}, - FunctionalityType: "REGULAR", - Version: "1.0.0", - Properties: map[string]interface{}{"region": "us"}, - CreatedBy: "it-user", - } - if err := gwRepo.Create(gw); err != nil { - t.Fatalf("[%s] gateway Create %d failed: %v", it.driver, i, err) - } - gatewayIDs = append(gatewayIDs, gw.ID) - } - - // --- Read back a single gateway and confirm the round-trip --- - first := gatewayIDs[0] - got, err := gwRepo.GetByUUID(first) - if err != nil { - t.Fatalf("[%s] GetByUUID failed: %v", it.driver, err) - } - if got == nil || got.OrganizationID != orgID { - t.Fatalf("[%s] GetByUUID round-trip mismatch: %+v", it.driver, got) - } - if got.IsActive { - t.Fatalf("[%s] new gateway should default to inactive, got is_active=true", it.driver) - } - if got.Properties["region"] != "us" { - t.Fatalf("[%s] gateway properties did not round-trip: %+v", it.driver, got.Properties) - } - if len(got.Endpoints) != 2 || got.Endpoints[0] != "https://localhost:8443" || got.Endpoints[1] != "wss://localhost:8444" { - t.Fatalf("[%s] gateway endpoints did not round-trip: %+v", it.driver, got.Endpoints) - } - byHandle, err := gwRepo.GetByHandleAndOrgID(got.Handle, orgID) - if err != nil || byHandle == nil || byHandle.ID != first { - t.Fatalf("[%s] GetByHandleAndOrgID mismatch: err=%v got=%+v", it.driver, err, byHandle) - } - - // --- List by organization --- - list, err := gwRepo.GetByOrganizationID(orgID) - if err != nil { - t.Fatalf("[%s] GetByOrganizationID failed: %v", it.driver, err) - } - if len(list) != n { - t.Fatalf("[%s] GetByOrganizationID: want %d, got %d", it.driver, n, len(list)) - } - - // --- Generate a registration token for the gateway --- - tokenHash := "hash-" + id() - token := &model.GatewayToken{ - ID: id(), - GatewayID: first, - TokenHash: tokenHash, - Salt: "salt-" + id()[:8], - Status: constants.GatewayTokenStatusActive, - CreatedBy: "it-user", - } - if err := gwRepo.CreateToken(token); err != nil { - t.Fatalf("[%s] CreateToken failed: %v", it.driver, err) - } - - if count, err := gwRepo.CountActiveTokens(first); err != nil || count != 1 { - t.Fatalf("[%s] CountActiveTokens: want 1, got %d (err=%v)", it.driver, count, err) - } - active, err := gwRepo.GetActiveTokensByGatewayUUID(first) - if err != nil || len(active) != 1 { - t.Fatalf("[%s] GetActiveTokensByGatewayUUID: want 1, got %d (err=%v)", it.driver, len(active), err) - } - byHash, err := gwRepo.GetActiveTokenByHash(tokenHash) - if err != nil { - t.Fatalf("[%s] GetActiveTokenByHash failed: %v", it.driver, err) - } - if byHash == nil || byHash.ID != token.ID || byHash.GatewayID != first { - t.Fatalf("[%s] GetActiveTokenByHash mismatch: %+v", it.driver, byHash) - } - - // --- Revoke the token; it should no longer count as active --- - if err := gwRepo.RevokeToken(token.ID, "it-user"); err != nil { - t.Fatalf("[%s] RevokeToken failed: %v", it.driver, err) - } - if count, err := gwRepo.CountActiveTokens(first); err != nil || count != 0 { - t.Fatalf("[%s] CountActiveTokens after revoke: want 0, got %d (err=%v)", it.driver, count, err) - } - if revoked, err := gwRepo.GetActiveTokenByHash(tokenHash); err != nil || revoked != nil { - t.Fatalf("[%s] GetActiveTokenByHash after revoke: want nil, got %+v (err=%v)", it.driver, revoked, err) - } - gone, err := gwRepo.GetTokenByUUID(token.ID) - if err != nil { - t.Fatalf("[%s] GetTokenByUUID after revoke failed: %v", it.driver, err) - } - if gone == nil || gone.Status != constants.GatewayTokenStatusRevoked || gone.RevokedBy == nil || *gone.RevokedBy != "it-user" { - t.Fatalf("[%s] revoked token state mismatch: %+v", it.driver, gone) - } -} - -// TestLifecycle_APIKeyCreateListRevoke drives the real APIKeyRepo through key -// creation, lookup, listing, update and revocation. The key hangs off a REST -// API artifact created via APIRepo, so this exercises the api_keys -> -// artifacts foreign key alongside the API key generation flow. -func TestLifecycle_APIKeyCreateListRevoke(t *testing.T) { - it := openITDB(t) - defer it.db.Close() - orgID, projID := seedOrgProject(t, it, "key") - - // An API key references an artifact; create a REST API to back it. - apiRepo := repository.NewAPIRepo(it.db) - api := &model.API{ - Handle: "key-api-" + id()[:8], - Name: "Key API " + id()[:6], - Version: "v1.0", - ProjectID: projID, - OrganizationID: orgID, - LifeCycleStatus: "CREATED", - Configuration: model.RestAPIConfig{Name: "Key API", Version: "v1.0"}, - } - if err := apiRepo.CreateAPI(api); err != nil { - t.Fatalf("[%s] CreateAPI (for key) failed: %v", it.driver, err) - } - artifactUUID := api.ID - - keyRepo := repository.NewAPIKeyRepo(it.db, repository.NewArtifactTableRegistry()) - - // --- Create two API keys on the same artifact --- - const n = 2 - for i := range n { - key := &model.APIKey{ - UUID: id(), - ArtifactUUID: artifactUUID, - Name: fmt.Sprintf("key-%d", i), - DisplayName: fmt.Sprintf("Key %d", i), - MaskedAPIKey: "ab12", - APIKeyHashes: `{"sha256":"` + id() + `"}`, - Status: "active", - CreatedBy: "it-user", - AllowedTargets: "ALL", - } - if err := keyRepo.Create(key); err != nil { - t.Fatalf("[%s] APIKey Create %d failed: %v", it.driver, i, err) - } - } - - // --- Read back a single key and confirm the round-trip --- - got, err := keyRepo.GetByArtifactAndName(artifactUUID, "key-0") - if err != nil { - t.Fatalf("[%s] GetByArtifactAndName failed: %v", it.driver, err) - } - if got == nil || got.ArtifactUUID != artifactUUID || got.Status != "active" { - t.Fatalf("[%s] GetByArtifactAndName round-trip mismatch: %+v", it.driver, got) - } - if got.AllowedTargets != "ALL" { - t.Fatalf("[%s] APIKey allowed_targets did not round-trip: %q", it.driver, got.AllowedTargets) - } - - // --- List by artifact --- - keys, err := keyRepo.ListByArtifact(artifactUUID) - if err != nil { - t.Fatalf("[%s] ListByArtifact failed: %v", it.driver, err) - } - if len(keys) != n { - t.Fatalf("[%s] ListByArtifact: want %d, got %d", it.driver, n, len(keys)) - } - - // --- Update the key material --- - got.MaskedAPIKey = "cd34" - got.APIKeyHashes = `{"sha256":"` + id() + `"}` - if err := keyRepo.Update(got); err != nil { - t.Fatalf("[%s] APIKey Update failed: %v", it.driver, err) - } - afterUpdate, err := keyRepo.GetByArtifactAndName(artifactUUID, "key-0") - if err != nil { - t.Fatalf("[%s] GetByArtifactAndName after update failed: %v", it.driver, err) - } - if afterUpdate.MaskedAPIKey != "cd34" { - t.Fatalf("[%s] APIKey Update did not persist masked key: %q", it.driver, afterUpdate.MaskedAPIKey) - } - - // --- Revoke the key --- - if err := keyRepo.Revoke(artifactUUID, "key-0"); err != nil { - t.Fatalf("[%s] APIKey Revoke failed: %v", it.driver, err) - } - revoked, err := keyRepo.GetByArtifactAndName(artifactUUID, "key-0") - if err != nil { - t.Fatalf("[%s] GetByArtifactAndName after revoke failed: %v", it.driver, err) - } - if revoked.Status != "revoked" { - t.Fatalf("[%s] APIKey Revoke did not persist status: %q", it.driver, revoked.Status) - } -} diff --git a/platform-api/src/internal/integration/features/cascade-delete.feature b/platform-api/src/internal/integration/features/cascade-delete.feature new file mode 100644 index 0000000000..54cde4a7c4 --- /dev/null +++ b/platform-api/src/internal/integration/features/cascade-delete.feature @@ -0,0 +1,35 @@ +Feature: Foreign-key cascade deletes across database engines + As a platform-api maintainer + I want deletes to cascade through the kept foreign-key edges + So that the SQL Server NO ACTION rework still cleans up dependent rows on every engine. + + Background: + Given a clean platform-api database + + Scenario: Deleting a REST API cascades to its subscriptions + Given a seeded organization object graph + When I delete the REST API artifact + Then the subscription is removed + + Scenario: Deleting a gateway cascades to its deployments and deployment status + Given a seeded organization object graph + When I delete the gateway + Then the deployment is removed + And the deployment status is removed + + Scenario: Deleting an application cascades to its key and artifact mappings + Given a seeded organization object graph + When I delete the application + Then the application api key mapping is removed + And the application artifact mapping is removed + + Scenario: Deleting a project retains its applications + Given a seeded organization object graph + When I delete the REST API artifact + And I delete the project + Then the application is retained + + Scenario: Deleting a subscription plan cascades to its limits + Given a seeded organization object graph + When I delete the subscription and its plan + Then the subscription plan limit is removed diff --git a/platform-api/src/internal/integration/features/deployment-repository.feature b/platform-api/src/internal/integration/features/deployment-repository.feature new file mode 100644 index 0000000000..3b551fa998 --- /dev/null +++ b/platform-api/src/internal/integration/features/deployment-repository.feature @@ -0,0 +1,28 @@ +Feature: Deployment repository lifecycle across database engines + As a platform-api maintainer + I want the deployment repository's create / status / current-lookup / list / undeploy + paths to behave identically on every engine + So that the SQL Server-specific deployment SQL (upserts, FETCH-first and the + ROW_NUMBER ranking) is verified, not just the SQLite unit-test path. + + Background: + Given a clean platform-api database + And an organization and project exist + And a REST API and a gateway exist + + Scenario: Deployment create, current status, current lookup, list and undeploy + When I create 3 deployments for the API on the gateway + Then the current deployment status is "DEPLOYED" + And the API has an active deployment + And the current deployment for the gateway is the latest one + And reading the current deployment back returns its content + And listing deployments with state returns 3 + When I undeploy the current deployment + Then the current deployment status is "UNDEPLOYED" + And the API has no active deployment + And there is no current deployment for the gateway + + Scenario: Creating deployments beyond the hard limit bounds the stored count + When I create 5 deployments for the API on the gateway with a hard limit of 3 + Then the gateway retains at most 3 deployments + And the API has an active deployment diff --git a/platform-api/src/internal/integration/features/llm-repository.feature b/platform-api/src/internal/integration/features/llm-repository.feature new file mode 100644 index 0000000000..3c95ff25b1 --- /dev/null +++ b/platform-api/src/internal/integration/features/llm-repository.feature @@ -0,0 +1,37 @@ +Feature: LLM control-plane repository lifecycle across database engines + As a platform-api maintainer + I want the LLM provider-template, provider and proxy repositories to round-trip correctly + So that the AI control plane is verified on every engine — with no real LLM and no real API key. + + Background: + Given a clean platform-api database + And an organization and project exist + + Scenario: LLM provider template create, new version and list + When I create an LLM provider template "openai" version "v1.0" + Then reading the template by its handle returns version "v1.0" + When I create a new version "v2.0" of the template + Then reading the original template handle still returns version "v1.0" + And the latest version of the template family is "v2.0" + And listing template versions returns 2 + + Scenario: LLM provider create, read, update and delete with a dummy upstream key + Given an LLM provider template "openai" version "v1.0" + When I create an LLM provider "my-openai" with upstream key "Bearer sk-test-key" + Then reading the provider back returns upstream key "Bearer sk-test-key" + And listing LLM providers by organization returns 1 + When I update the provider description to "updated by integration test" + Then reading the provider back shows description "updated by integration test" + When I delete the provider + Then listing LLM providers by organization returns 0 + + Scenario: LLM proxy create, list by provider and project, and delete + Given an LLM provider template "openai" version "v1.0" + And an LLM provider "my-openai" with upstream key "Bearer sk-test-key" + When I create an LLM proxy "my-proxy" for that provider + Then reading the proxy back references the provider + And listing LLM proxies by organization returns 1 + And listing LLM proxies by project returns 1 + And listing LLM proxies by provider returns 1 + When I delete the proxy + Then listing LLM proxies by organization returns 0 diff --git a/platform-api/src/internal/integration/features/mcp-repository.feature b/platform-api/src/internal/integration/features/mcp-repository.feature new file mode 100644 index 0000000000..7305c71644 --- /dev/null +++ b/platform-api/src/internal/integration/features/mcp-repository.feature @@ -0,0 +1,19 @@ +Feature: MCP control-plane repository lifecycle across database engines + As a platform-api maintainer + I want the MCP proxy repository to round-trip correctly + So that the MCP control plane is verified on every engine — with no real MCP server and no real API key. + + Background: + Given a clean platform-api database + And an organization and project exist + + Scenario: MCP proxy create, read, update, list and delete with a dummy upstream key + When I create an MCP proxy "my-mcp" with upstream key "Bearer mcp-test-key" + Then reading the MCP proxy back returns upstream key "Bearer mcp-test-key" + And listing MCP proxies by organization returns 1 + And listing MCP proxies by project returns 1 + When I update the MCP proxy description to "updated by integration test" + Then reading the MCP proxy back shows description "updated by integration test" + When I delete the MCP proxy + Then listing MCP proxies by organization returns 0 + And listing MCP proxies by project returns 0 diff --git a/platform-api/src/internal/integration/features/pagination-and-listing.feature b/platform-api/src/internal/integration/features/pagination-and-listing.feature new file mode 100644 index 0000000000..20f4e3c5e8 --- /dev/null +++ b/platform-api/src/internal/integration/features/pagination-and-listing.feature @@ -0,0 +1,40 @@ +Feature: Pagination and filtered listing across database engines + As a platform-api maintainer + I want paginated and filtered repository queries to behave identically on every engine + So that the SQL Server LIMIT/OFFSET rework (PaginationClause / FetchFirstClause) is verified. + + Background: + Given a clean platform-api database + + Scenario: Organization pagination pages through all rows without overlap + Given 5 additional organizations exist + When I page through organizations 2 at a time + Then every organization is seen exactly once + + Scenario: Subscription plan existence check and paginated list + Given an organization exists + Then checking existence of a missing subscription plan returns false + When I create 3 subscription plans with a throttle limit of 5 per "min" + Then listing subscription plans 2 at a time returns 2 + And each listed plan round-trips its throttle limit + And reading the first listed plan back round-trips its throttle limit + When I clear the throttle limit on the first listed plan + Then reading it back shows no throttle limit + + Scenario: Project pagination pages through all rows without overlap + Given an organization exists + And 5 projects exist + When I page through projects 2 at a time + Then every project is seen exactly once + + Scenario: Subscription listing filters by status + Given a seeded organization object graph + Then listing subscriptions with no filter returns 1 + And listing subscriptions filtered by status "ACTIVE" returns 1 + And listing subscriptions filtered by status "REVOKED" returns 0 + + Scenario: Application lookup by UUID or handle + Given an organization, project and application exist + Then the application is found by its UUID + And the application is found by its handle + And a missing application identifier returns nothing diff --git a/platform-api/src/internal/integration/features/repository-crud.feature b/platform-api/src/internal/integration/features/repository-crud.feature new file mode 100644 index 0000000000..9160cb5fa3 --- /dev/null +++ b/platform-api/src/internal/integration/features/repository-crud.feature @@ -0,0 +1,42 @@ +Feature: Repository CRUD lifecycle across database engines + As a platform-api maintainer + I want the REST API, gateway and API key repositories to round-trip correctly + So that backend-specific bugs are caught on every supported database engine. + + Background: + Given a clean platform-api database + And an organization and project exist + + Scenario: REST API create, read, update and list + When I create 3 REST APIs in the project + Then reading the first REST API back returns lifecycle status "CREATED" + And the first REST API handle is reported as existing + And a random handle is reported as not existing + When I update the first REST API to status "PUBLISHED" + Then reading the first REST API back shows the updated name, status "PUBLISHED" and updater "it-user" + And listing REST APIs by organization returns 3 + And listing REST APIs by project returns 3 + And listing REST APIs by organization filtered to another project returns 0 + + Scenario: Gateway create, list, and registration token lifecycle + When I create 3 gateways + Then reading the first gateway back returns it as inactive with property region "us" + And the first gateway is found by its handle + And listing gateways by organization returns 3 + When I generate a registration token for the first gateway + Then the first gateway has 1 active token + And the token is found by its hash + When I revoke the token + Then the first gateway has 0 active tokens + And the token is no longer found by its hash + And the revoked token records status "revoked" by "it-user" + + Scenario: API key create, list, update and revoke + Given a REST API exists to back the API keys + When I create 2 API keys on the REST API + Then reading the first API key back returns status "active" and target "ALL" + And listing API keys by artifact returns 2 + When I update the first API key material + Then reading the first API key back shows the updated masked key + When I revoke the first API key + Then reading the first API key back returns status "revoked" diff --git a/platform-api/src/internal/integration/features/secret-repository.feature b/platform-api/src/internal/integration/features/secret-repository.feature new file mode 100644 index 0000000000..3f01a5d66b --- /dev/null +++ b/platform-api/src/internal/integration/features/secret-repository.feature @@ -0,0 +1,21 @@ +Feature: Secret repository lifecycle across database engines + As a platform-api maintainer + I want the secret store to round-trip encrypted secret material correctly + So that secret create, existence, paginated list, rotation and soft-delete are verified on every engine. + + Background: + Given a clean platform-api database + And an organization and project exist + + Scenario: Secret create, encrypted round-trip, paginated list, rotate and soft-delete + When I create 5 secrets + Then reading the first secret back returns its ciphertext and hash + And the first secret reports type "GENERIC" provider "IN_BUILT" status "ACTIVE" + And checking existence of the first secret returns true + And checking existence of a missing secret returns false + And counting secrets returns 5 + And paging secrets 2 at a time covers all 5 without overlap + When I rotate the first secret's ciphertext + Then reading the first secret back returns the rotated ciphertext + When I soft-delete the first secret + Then reading the first secret back reports status "DEPRECATED" diff --git a/platform-api/src/internal/integration/harness_test.go b/platform-api/src/internal/integration/harness.go similarity index 64% rename from platform-api/src/internal/integration/harness_test.go rename to platform-api/src/internal/integration/harness.go index f40d7adf92..43789948cf 100644 --- a/platform-api/src/internal/integration/harness_test.go +++ b/platform-api/src/internal/integration/harness.go @@ -1,3 +1,5 @@ +//go:build integration + /* * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). * @@ -16,16 +18,16 @@ * under the License. */ -// Package integration holds cross-database integration tests for platform-api. -// They run against a real database engine (SQLite, PostgreSQL or SQL Server) -// selected by the IT_DB environment variable and exercise the real schema and -// data-access behavior — pagination, multi-table writes and delete cascades — -// so backend-specific bugs (e.g. SQL Server LIMIT/cascade-path issues) are -// caught instead of being hidden behind the SQLite unit-test path. +// Package integration holds cross-database integration tests for platform-api, +// written as a godog (Cucumber) suite. The scenarios run against a real database +// engine (SQLite, PostgreSQL or SQL Server) selected by the IT_DB environment +// variable and exercise the real schema and data-access behavior — pagination, +// multi-table writes and delete cascades — so backend-specific bugs (e.g. SQL +// Server LIMIT/cascade-path issues) are caught instead of being hidden behind +// the SQLite unit-test path. // // Build-tagged `integration` so it is excluded from the default `go test ./...`. // -//go:build integration package integration @@ -36,37 +38,37 @@ import ( "os" "regexp" "strconv" - "testing" "time" + "github.com/google/uuid" + "platform-api/src/config" "platform-api/src/internal/database" ) -func TestMain(m *testing.M) { - // Allow GetConfig() to generate an ephemeral secret_encryption_key so tests - // that exercise subscription_repository.go don't panic at startup. - os.Setenv("APIP_DEMO_MODE", "true") - os.Exit(m.Run()) -} - -// itDB describes the database engine under test. +// itDB describes the database engine under test for a single scenario. type itDB struct { - driver string - db *database.DB + driver string + db *database.DB + cleanup func() // closes the connection and removes any temp files } // openITDB opens the database selected by IT_DB (default: sqlite) and applies -// the matching schema. Supported values: sqlite, postgres, sqlserver. -func openITDB(t *testing.T) *itDB { - t.Helper() +// the matching schema. Supported values: sqlite, postgres, sqlserver. The caller +// must invoke the returned itDB's cleanup when the scenario finishes. +func openITDB() (*itDB, error) { driver := envOr("IT_DB", "sqlite") logger := slog.New(slog.NewTextHandler(io.Discard, nil)) var cfg *config.Database + cleanup := func() {} switch driver { case "sqlite", "sqlite3": - dir := t.TempDir() + dir, err := os.MkdirTemp("", "platform-api-it") + if err != nil { + return nil, fmt.Errorf("create sqlite temp dir: %w", err) + } + cleanup = func() { os.RemoveAll(dir) } cfg = &config.Database{Driver: "sqlite3", Path: dir + "/it.db", MaxOpenConns: 1, MaxIdleConns: 1} driver = "sqlite" case "postgres", "postgresql": @@ -83,7 +85,9 @@ func openITDB(t *testing.T) *itDB { driver = "postgres" case "sqlserver", "mssql": name := envOr("IT_DB_NAME", "platform_api_it") - ensureSQLServerDB(t, logger, name) + if err := ensureSQLServerDB(logger, name); err != nil { + return nil, err + } cfg = &config.Database{ Driver: "sqlserver", Host: envOr("IT_DB_HOST", "localhost"), @@ -96,31 +100,39 @@ func openITDB(t *testing.T) *itDB { } driver = "sqlserver" default: - t.Fatalf("unsupported IT_DB %q (want sqlite|postgres|sqlserver)", driver) + cleanup() + return nil, fmt.Errorf("unsupported IT_DB %q (want sqlite|postgres|sqlserver)", driver) } - db := connectITDB(t, cfg, logger) - t.Cleanup(func() { db.Close() }) + db, err := connectITDB(cfg, logger) + if err != nil { + cleanup() + return nil, err + } if err := db.InitSchema("../database/schema.sql", logger); err != nil { - t.Fatalf("InitSchema (%s) failed: %v", driver, err) + db.Close() + cleanup() + return nil, fmt.Errorf("InitSchema (%s) failed: %w", driver, err) } - return &itDB{driver: driver, db: db} + return &itDB{ + driver: driver, + db: db, + cleanup: func() { db.Close(); cleanup() }, + }, nil } -func connectITDB(t *testing.T, cfg *config.Database, logger *slog.Logger) *database.DB { - t.Helper() +func connectITDB(cfg *config.Database, logger *slog.Logger) (*database.DB, error) { deadline := time.Now().Add(90 * time.Second) var lastErr error for time.Now().Before(deadline) { db, err := database.NewConnection(cfg, logger) if err == nil { - return db + return db, nil } lastErr = err time.Sleep(2 * time.Second) } - t.Fatalf("could not connect to %s within timeout: %v", cfg.Driver, lastErr) - return nil + return nil, fmt.Errorf("could not connect to %s within timeout: %w", cfg.Driver, lastErr) } // validDBName guards the database name that is interpolated into the @@ -131,30 +143,33 @@ var validDBName = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{0,62}$`) // ensureSQLServerDB drops and recreates the SQL Server test database (via master). // A fresh database on every test run prevents stale schema from causing FK failures // when constraints are added to existing tables between runs. -func ensureSQLServerDB(t *testing.T, logger *slog.Logger, name string) { - t.Helper() +func ensureSQLServerDB(logger *slog.Logger, name string) error { if !validDBName.MatchString(name) { - t.Fatalf("invalid IT_DB_NAME %q: must match %s", name, validDBName.String()) + return fmt.Errorf("invalid IT_DB_NAME %q: must match %s", name, validDBName.String()) } - master := connectITDB(t, &config.Database{ + master, err := connectITDB(&config.Database{ Driver: "sqlserver", Host: envOr("IT_DB_HOST", "localhost"), Port: atoiOr("IT_DB_PORT", 1433), Name: "master", User: envOr("IT_DB_USER", "sa"), Password: os.Getenv("IT_DB_PASSWORD"), SSLMode: "disable", MaxOpenConns: 2, MaxIdleConns: 1, }, logger) + if err != nil { + return err + } defer master.Close() // Terminate open connections before dropping so the DROP succeeds. if _, err := master.Exec(fmt.Sprintf( "IF DB_ID(N'%s') IS NOT NULL ALTER DATABASE [%s] SET SINGLE_USER WITH ROLLBACK IMMEDIATE", name, name)); err != nil { - t.Fatalf("failed to set sqlserver database %q to single-user: %v", name, err) + return fmt.Errorf("failed to set sqlserver database %q to single-user: %w", name, err) } if _, err := master.Exec(fmt.Sprintf( "IF DB_ID(N'%s') IS NOT NULL DROP DATABASE [%s]", name, name)); err != nil { - t.Fatalf("failed to drop sqlserver database %q: %v", name, err) + return fmt.Errorf("failed to drop sqlserver database %q: %w", name, err) } if _, err := master.Exec(fmt.Sprintf("CREATE DATABASE [%s]", name)); err != nil { - t.Fatalf("failed to create sqlserver database %q: %v", name, err) + return fmt.Errorf("failed to create sqlserver database %q: %w", name, err) } + return nil } func envOr(key, def string) string { @@ -173,21 +188,22 @@ func atoiOr(key string, def int) int { return def } -// count returns the number of rows in table matching the org filter. -func (it *itDB) count(t *testing.T, table, col, val string) int { - t.Helper() +// count returns the number of rows in table matching the column filter. +func (it *itDB) count(table, col, val string) (int, error) { var n int q := it.db.Rebind(fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE %s = ?", table, col)) if err := it.db.QueryRow(q, val).Scan(&n); err != nil { - t.Fatalf("count(%s) failed: %v", table, err) + return 0, fmt.Errorf("count(%s) failed on %s: %w", table, it.driver, err) } - return n + return n, nil } // exec runs a `?`-placeholder statement, rebinding for the active driver. -func (it *itDB) exec(t *testing.T, query string, args ...any) { - t.Helper() +func (it *itDB) exec(query string, args ...any) error { if _, err := it.db.Exec(it.db.Rebind(query), args...); err != nil { - t.Fatalf("exec failed on %s: %v\nquery: %s", it.driver, err, query) + return fmt.Errorf("exec failed on %s: %w\nquery: %s", it.driver, err, query) } + return nil } + +func id() string { return uuid.NewString() } diff --git a/platform-api/src/internal/integration/harness_experimental_test.go b/platform-api/src/internal/integration/harness_experimental_test.go index bd4d72ea4b..16a18ba47f 100644 --- a/platform-api/src/internal/integration/harness_experimental_test.go +++ b/platform-api/src/internal/integration/harness_experimental_test.go @@ -1,3 +1,5 @@ +//go:build integration && experimental + /* * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). * @@ -16,8 +18,6 @@ * under the License. */ -//go:build integration && experimental - package integration import ( @@ -33,10 +33,34 @@ import ( // functionality. func openExperimentalITDB(t *testing.T) *itDB { t.Helper() - it := openITDB(t) + it, err := openITDB() + if err != nil { + t.Fatalf("openITDB failed: %v", err) + } + t.Cleanup(it.cleanup) logger := slog.New(slog.NewTextHandler(io.Discard, nil)) if err := it.db.InitSchema("../../plugins/eventgateway/schema/schema.sql", logger); err != nil { t.Fatalf("InitSchema experimental (%s) failed: %v", it.driver, err) } return it } + +// execT runs a `?`-placeholder statement and fails the test on error. It adapts +// the godog-style itDB.exec (which returns an error) to the traditional +// testing.T flow used by the experimental tests. +func (it *itDB) execT(t *testing.T, query string, args ...any) { + t.Helper() + if err := it.exec(query, args...); err != nil { + t.Fatalf("%v", err) + } +} + +// countT returns the matching row count and fails the test on error. +func (it *itDB) countT(t *testing.T, table, col, val string) int { + t.Helper() + n, err := it.count(table, col, val) + if err != nil { + t.Fatalf("%v", err) + } + return n +} diff --git a/platform-api/src/internal/integration/lifecycle_test.go b/platform-api/src/internal/integration/lifecycle_test.go deleted file mode 100644 index 71188a675d..0000000000 --- a/platform-api/src/internal/integration/lifecycle_test.go +++ /dev/null @@ -1,298 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). - * - * WSO2 LLC. licenses this file to you under the Apache License, - * Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -//go:build integration - -package integration - -import ( - "fmt" - "testing" - - "platform-api/src/internal/model" - "platform-api/src/internal/repository" -) - -// TestLifecycle_OrganizationPagination drives the real repository layer through -// create + paginated list, exercising DB.PaginationClause across the active -// engine. On SQL Server this is the code path that previously failed with -// "Incorrect syntax near 'LIMIT'". -func TestLifecycle_OrganizationPagination(t *testing.T) { - it := openITDB(t) - defer it.db.Close() - orgRepo := repository.NewOrganizationRepo(it.db) - - baseline, err := orgRepo.ListOrganizations(1_000_000, 0) - if err != nil { - t.Fatalf("[%s] baseline list failed: %v", it.driver, err) - } - - const n = 5 - for i := range n { - org := &model.Organization{ID: id(), Handle: "lc-" + id()[:8], Name: fmt.Sprintf("org %d", i), Region: "us"} - if err := orgRepo.CreateOrganization(org); err != nil { - t.Fatalf("[%s] create org failed: %v", it.driver, err) - } - } - total := len(baseline) + n - - // Page through in steps of 2 and confirm full, non-overlapping coverage. - seen := map[string]bool{} - for offset := 0; offset < total; offset += 2 { - page, err := orgRepo.ListOrganizations(2, offset) - if err != nil { - t.Fatalf("[%s] ListOrganizations(2,%d) failed: %v", it.driver, offset, err) - } - want := 2 - if rem := total - offset; rem < want { - want = rem - } - if len(page) != want { - t.Fatalf("[%s] page at offset %d: want %d rows, got %d", it.driver, offset, want, len(page)) - } - for _, o := range page { - if seen[o.ID] { - t.Fatalf("[%s] pagination overlap at offset %d: id %s seen twice", it.driver, offset, o.ID) - } - seen[o.ID] = true - } - } - if len(seen) != total { - t.Fatalf("[%s] paging covered %d rows, want %d", it.driver, len(seen), total) - } -} - -// TestLifecycle_SubscriptionPlanExistsAndList exercises FetchFirstClause (the -// SELECT 1 ... FETCH NEXT 1 existence check) and a filtered paginated list -// through the real repository, across the active engine. -func TestLifecycle_SubscriptionPlanExistsAndList(t *testing.T) { - it := openITDB(t) - defer it.db.Close() - orgRepo := repository.NewOrganizationRepo(it.db) - planRepo := repository.NewSubscriptionPlanRepo(it.db) - - org := &model.Organization{ID: id(), Handle: "pl-" + id()[:8], Name: "plan org", Region: "us"} - if err := orgRepo.CreateOrganization(org); err != nil { - t.Fatalf("[%s] create org failed: %v", it.driver, err) - } - - exists, err := planRepo.ExistsByHandleAndOrg("nope-"+id(), org.ID) - if err != nil { - t.Fatalf("[%s] ExistsByHandleAndOrg failed: %v", it.driver, err) - } - if exists { - t.Fatalf("[%s] ExistsByHandleAndOrg: want false for missing plan", it.driver) - } - - count := 5 - for i := range 3 { - // Fully populated: the throttle fields are persisted as a single - // REQUEST_COUNT row in subscription_plan_limits and hydrated back via - // the LEFT JOIN in the list/get queries. - slug := fmt.Sprintf("plan-%d-%s", i, id()[:6]) - plan := &model.SubscriptionPlan{ - UUID: id(), Handle: slug, Name: fmt.Sprintf("Plan %d", i), - StopOnQuotaReach: true, - ThrottleLimitCount: &count, ThrottleLimitUnit: "min", - OrganizationUUID: org.ID, Status: model.SubscriptionPlanStatus("ACTIVE"), - } - if err := planRepo.Create(plan); err != nil { - t.Fatalf("[%s] create plan failed: %v", it.driver, err) - } - } - plans, err := planRepo.ListByOrganization(org.ID, 2, 0) - if err != nil { - t.Fatalf("[%s] ListByOrganization failed: %v", it.driver, err) - } - if len(plans) != 2 { - t.Fatalf("[%s] ListByOrganization(2,0): want 2, got %d", it.driver, len(plans)) - } - // The throttle triple (count/unit + stop_on_quota_reach) is stored as a single - // row in subscription_plan_limits; confirm it round-trips through both the list - // and single-get paths. - for _, p := range plans { - if p.ThrottleLimitCount == nil || *p.ThrottleLimitCount != count { - t.Fatalf("[%s] list hydrate: ThrottleLimitCount = %v, want %d", it.driver, p.ThrottleLimitCount, count) - } - if p.ThrottleLimitUnit != "min" || !p.StopOnQuotaReach { - t.Fatalf("[%s] list hydrate: unit=%q stop=%v, want unit=min stop=true", it.driver, p.ThrottleLimitUnit, p.StopOnQuotaReach) - } - } - got, err := planRepo.GetByID(plans[0].UUID, org.ID) - if err != nil { - t.Fatalf("[%s] GetByID failed: %v", it.driver, err) - } - if got.ThrottleLimitCount == nil || *got.ThrottleLimitCount != count || got.ThrottleLimitUnit != "min" { - t.Fatalf("[%s] GetByID hydrate: count=%v unit=%q, want %d/min", it.driver, got.ThrottleLimitCount, got.ThrottleLimitUnit, count) - } - - // Update clearing the throttle should delete the limit row; reads then - // report no throttle with the default stop_on_quota_reach. - got.ThrottleLimitCount = nil - got.ThrottleLimitUnit = "" - got.StopOnQuotaReach = true - if err := planRepo.Update(got); err != nil { - t.Fatalf("[%s] Update (clear throttle) failed: %v", it.driver, err) - } - cleared, err := planRepo.GetByID(got.UUID, org.ID) - if err != nil { - t.Fatalf("[%s] GetByID after clear failed: %v", it.driver, err) - } - if cleared.ThrottleLimitCount != nil || cleared.ThrottleLimitUnit != "" { - t.Fatalf("[%s] after clear: want no throttle, got count=%v unit=%q", it.driver, cleared.ThrottleLimitCount, cleared.ThrottleLimitUnit) - } -} - -// TestLifecycle_ProjectPagination exercises ProjectRepo.ListProjects pagination -// (the project.go LIMIT ? OFFSET ? query) through the real repository. -func TestLifecycle_ProjectPagination(t *testing.T) { - it := openITDB(t) - defer it.db.Close() - orgRepo := repository.NewOrganizationRepo(it.db) - projectRepo := repository.NewProjectRepo(it.db) - - org := &model.Organization{ID: id(), Handle: "pr-" + id()[:8], Name: "proj org", Region: "us"} - if err := orgRepo.CreateOrganization(org); err != nil { - t.Fatalf("[%s] create org failed: %v", it.driver, err) - } - - const n = 5 - for i := range n { - pid := id() - pName := fmt.Sprintf("proj-%d-%s", i, pid[:6]) - p := &model.Project{ID: pid, Handle: pName, Name: pName, OrganizationID: org.ID, Description: "p"} - if err := projectRepo.CreateProject(p); err != nil { - t.Fatalf("[%s] create project failed: %v", it.driver, err) - } - } - - seen := map[string]bool{} - for offset := 0; offset < n; offset += 2 { - page, err := projectRepo.ListProjects(org.ID, 2, offset) - if err != nil { - t.Fatalf("[%s] ListProjects(2,%d) failed: %v", it.driver, offset, err) - } - want := 2 - if rem := n - offset; rem < want { - want = rem - } - if len(page) != want { - t.Fatalf("[%s] ListProjects offset %d: want %d, got %d", it.driver, offset, want, len(page)) - } - for _, p := range page { - if seen[p.ID] { - t.Fatalf("[%s] project pagination overlap at offset %d: %s", it.driver, offset, p.ID) - } - seen[p.ID] = true - } - } - if len(seen) != n { - t.Fatalf("[%s] project paging covered %d, want %d", it.driver, len(seen), n) - } -} - -// TestLifecycle_SubscriptionListByFilters exercises SubscriptionRepo.ListByFilters -// (the subscription_repository.go LIMIT ? OFFSET ? query) including the status -// filter, reusing the seeded org graph (which creates one ACTIVE subscription). -func TestLifecycle_SubscriptionListByFilters(t *testing.T) { - it := openITDB(t) - defer it.db.Close() - g := seedOrgGraph(t, it) - subRepo := repository.NewSubscriptionRepo(it.db) - - all, err := subRepo.ListByFilters(g.org, nil, nil, nil, nil, 10, 0) - if err != nil { - t.Fatalf("[%s] ListByFilters (no filter) failed: %v", it.driver, err) - } - if len(all) != 1 { - t.Fatalf("[%s] ListByFilters: want 1 subscription, got %d", it.driver, len(all)) - } - - active := "ACTIVE" - got, err := subRepo.ListByFilters(g.org, nil, nil, nil, &active, 10, 0) - if err != nil { - t.Fatalf("[%s] ListByFilters (status=ACTIVE) failed: %v", it.driver, err) - } - if len(got) != 1 { - t.Fatalf("[%s] ListByFilters(status=ACTIVE): want 1, got %d", it.driver, len(got)) - } - - revoked := "REVOKED" - none, err := subRepo.ListByFilters(g.org, nil, nil, nil, &revoked, 10, 0) - if err != nil { - t.Fatalf("[%s] ListByFilters (status=REVOKED) failed: %v", it.driver, err) - } - if len(none) != 0 { - t.Fatalf("[%s] ListByFilters(status=REVOKED): want 0, got %d", it.driver, len(none)) - } -} - -// TestLifecycle_ApplicationByIDOrHandle exercises GetApplicationByIDOrHandle, -// whose `ORDER BY CASE … FetchFirstClause(1)` query was part of the LIMIT-1 fix -// (a single-row lookup that resolves by UUID or handle). Verified on every engine. -func TestLifecycle_ApplicationByIDOrHandle(t *testing.T) { - it := openITDB(t) - defer it.db.Close() - orgRepo := repository.NewOrganizationRepo(it.db) - projectRepo := repository.NewProjectRepo(it.db) - appRepo := repository.NewApplicationRepo(it.db, repository.NewArtifactTableRegistry()) - - org := &model.Organization{ID: id(), Handle: "ap-" + id()[:8], Name: "app org", Region: "us"} - if err := orgRepo.CreateOrganization(org); err != nil { - t.Fatalf("[%s] create org failed: %v", it.driver, err) - } - projID := id() - projName := "p-" + projID[:6] - proj := &model.Project{ID: projID, Handle: projName, Name: projName, OrganizationID: org.ID} - if err := projectRepo.CreateProject(proj); err != nil { - t.Fatalf("[%s] create project failed: %v", it.driver, err) - } - app := &model.Application{ - UUID: id(), Handle: "app-" + id()[:8], ProjectUUID: proj.ID, - OrganizationUUID: org.ID, Name: "app", Type: "standard", - } - if err := appRepo.CreateApplication(app); err != nil { - t.Fatalf("[%s] create application failed: %v", it.driver, err) - } - - byUUID, err := appRepo.GetApplicationByIDOrHandle(app.UUID, org.ID) - if err != nil { - t.Fatalf("[%s] GetApplicationByIDOrHandle(uuid) failed: %v", it.driver, err) - } - if byUUID == nil || byUUID.UUID != app.UUID { - t.Fatalf("[%s] lookup by uuid: want %s, got %+v", it.driver, app.UUID, byUUID) - } - - byHandle, err := appRepo.GetApplicationByIDOrHandle(app.Handle, org.ID) - if err != nil { - t.Fatalf("[%s] GetApplicationByIDOrHandle(handle) failed: %v", it.driver, err) - } - if byHandle == nil || byHandle.UUID != app.UUID { - t.Fatalf("[%s] lookup by handle: want %s, got %+v", it.driver, app.UUID, byHandle) - } - - missing, err := appRepo.GetApplicationByIDOrHandle("does-not-exist-"+id(), org.ID) - if err != nil { - t.Fatalf("[%s] GetApplicationByIDOrHandle(missing) failed: %v", it.driver, err) - } - if missing != nil { - t.Fatalf("[%s] lookup of missing app: want nil, got %+v", it.driver, missing) - } -} - - diff --git a/platform-api/src/internal/integration/steps_cascade.go b/platform-api/src/internal/integration/steps_cascade.go new file mode 100644 index 0000000000..968672012e --- /dev/null +++ b/platform-api/src/internal/integration/steps_cascade.go @@ -0,0 +1,119 @@ +//go:build integration + +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package integration + +import ( + "github.com/cucumber/godog" +) + +// registerCascadeSteps wires the foreign-key cascade-delete scenarios. +func registerCascadeSteps(ctx *godog.ScenarioContext, w *world) { + ctx.Step(`^a seeded organization object graph$`, w.aSeededObjectGraph) + + ctx.Step(`^I delete the REST API artifact$`, w.deleteRESTAPIArtifact) + ctx.Step(`^the subscription is removed$`, w.subscriptionRemoved) + + ctx.Step(`^I delete the gateway$`, w.deleteGateway) + ctx.Step(`^the deployment is removed$`, w.deploymentRemoved) + ctx.Step(`^the deployment status is removed$`, w.deploymentStatusRemoved) + + ctx.Step(`^I delete the application$`, w.deleteApplication) + ctx.Step(`^the application api key mapping is removed$`, w.appKeyMappingRemoved) + ctx.Step(`^the application artifact mapping is removed$`, w.appArtifactMappingRemoved) + + ctx.Step(`^I delete the project$`, w.deleteProject) + ctx.Step(`^the application is retained$`, w.applicationRetained) + + ctx.Step(`^I delete the subscription and its plan$`, w.deleteSubscriptionAndPlan) + ctx.Step(`^the subscription plan limit is removed$`, w.subscriptionPlanLimitRemoved) +} + +// --- REST API delete cascade ------------------------------------------------ + +func (w *world) deleteRESTAPIArtifact() error { + // Mirrors APIRepo.DeleteAPI ordering: rest_apis + artifacts cascade the rest. + if err := w.it.exec(`DELETE FROM rest_apis WHERE uuid = ?`, w.g.apiArtifact); err != nil { + return err + } + return w.it.exec(`DELETE FROM artifacts WHERE uuid = ?`, w.g.apiArtifact) +} + +func (w *world) subscriptionRemoved() error { + return w.wantCount("subscriptions", "uuid", w.g.sub, 0) +} + +// --- Gateway delete cascade ------------------------------------------------- + +func (w *world) deleteGateway() error { + return w.it.exec(`DELETE FROM gateways WHERE uuid = ? AND organization_uuid = ?`, w.g.gateway, w.g.org) +} + +func (w *world) deploymentRemoved() error { + return w.wantCount("deployments", "uuid", w.g.deploy, 0) +} + +func (w *world) deploymentStatusRemoved() error { + return w.wantCount("deployment_status", "deployment_uuid", w.g.deploy, 0) +} + +// --- Application delete cascade --------------------------------------------- + +func (w *world) deleteApplication() error { + return w.it.exec(`DELETE FROM applications WHERE uuid = ? AND organization_uuid = ?`, w.g.app, w.g.org) +} + +func (w *world) appKeyMappingRemoved() error { + return w.wantCount("application_api_key_mappings", "api_key_id", w.g.apiKey, 0) +} + +func (w *world) appArtifactMappingRemoved() error { + return w.wantCount("application_artifact_mappings", "application_uuid", w.g.app, 0) +} + +// --- Project delete cascade ------------------------------------------------- + +func (w *world) deleteProject() error { + return w.it.exec(`DELETE FROM projects WHERE uuid = ?`, w.g.project) +} + +// applicationRetained verifies the application survives a project deletion. +// Applications are intentionally decoupled from a project's lifecycle: the +// applications.project_uuid column carries no cascading foreign key, so removing +// the project leaves the application row in place (it is only cascade-deleted via +// its organization). +func (w *world) applicationRetained() error { + return w.wantCount("applications", "uuid", w.g.app, 1) +} + +// --- Subscription plan delete cascade --------------------------------------- + +func (w *world) deleteSubscriptionAndPlan() error { + // subscriptions.subscription_plan_uuid blocks plan deletion, so remove the + // subscription first; the plan delete then cascades to subscription_plan_limits. + if err := w.it.exec(`DELETE FROM subscriptions WHERE uuid = ?`, w.g.sub); err != nil { + return err + } + return w.it.exec(`DELETE FROM subscription_plans WHERE uuid = ? AND organization_uuid = ?`, w.g.plan, w.g.org) +} + +func (w *world) subscriptionPlanLimitRemoved() error { + return w.wantCount("subscription_plan_limits", "uuid", w.g.planLimit, 0) +} diff --git a/platform-api/src/internal/integration/steps_crud.go b/platform-api/src/internal/integration/steps_crud.go new file mode 100644 index 0000000000..89dfeb67dc --- /dev/null +++ b/platform-api/src/internal/integration/steps_crud.go @@ -0,0 +1,460 @@ +//go:build integration + +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package integration + +import ( + "fmt" + + "github.com/cucumber/godog" + + "platform-api/src/internal/constants" + "platform-api/src/internal/model" + "platform-api/src/internal/repository" +) + +// registerCRUDSteps wires the repository CRUD lifecycle scenarios. +func registerCRUDSteps(ctx *godog.ScenarioContext, w *world) { + // REST API lifecycle. + ctx.Step(`^I create (\d+) REST APIs in the project$`, w.createRESTAPIs) + ctx.Step(`^reading the first REST API back returns lifecycle status "([^"]*)"$`, w.firstRESTAPIHasStatus) + ctx.Step(`^the first REST API handle is reported as existing$`, w.firstRESTAPIHandleExists) + ctx.Step(`^a random handle is reported as not existing$`, w.randomHandleMissing) + ctx.Step(`^I update the first REST API to status "([^"]*)"$`, w.updateFirstRESTAPI) + ctx.Step(`^reading the first REST API back shows the updated name, status "([^"]*)" and updater "([^"]*)"$`, w.firstRESTAPIUpdated) + ctx.Step(`^listing REST APIs by organization returns (\d+)$`, w.listRESTAPIsByOrg) + ctx.Step(`^listing REST APIs by project returns (\d+)$`, w.listRESTAPIsByProject) + ctx.Step(`^listing REST APIs by organization filtered to another project returns (\d+)$`, w.listRESTAPIsByOtherProject) + + // Gateway + token lifecycle. + ctx.Step(`^I create (\d+) gateways$`, w.createGateways) + ctx.Step(`^reading the first gateway back returns it as inactive with property region "([^"]*)"$`, w.firstGatewayInactive) + ctx.Step(`^the first gateway is found by its handle$`, w.firstGatewayFoundByHandle) + ctx.Step(`^listing gateways by organization returns (\d+)$`, w.listGatewaysByOrg) + ctx.Step(`^I generate a registration token for the first gateway$`, w.generateGatewayToken) + ctx.Step(`^the first gateway has (\d+) active tokens?$`, w.gatewayActiveTokenCount) + ctx.Step(`^the token is found by its hash$`, w.tokenFoundByHash) + ctx.Step(`^I revoke the token$`, w.revokeGatewayToken) + ctx.Step(`^the token is no longer found by its hash$`, w.tokenNotFoundByHash) + ctx.Step(`^the revoked token records status "([^"]*)" by "([^"]*)"$`, w.revokedTokenState) + + // API key lifecycle. + ctx.Step(`^a REST API exists to back the API keys$`, w.seedAPIForKeys) + ctx.Step(`^I create (\d+) API keys on the REST API$`, w.createAPIKeys) + ctx.Step(`^reading the first API key back returns status "([^"]*)" and target "([^"]*)"$`, w.firstAPIKeyStatusAndTarget) + ctx.Step(`^listing API keys by artifact returns (\d+)$`, w.listAPIKeysByArtifact) + ctx.Step(`^I update the first API key material$`, w.updateFirstAPIKey) + ctx.Step(`^reading the first API key back shows the updated masked key$`, w.firstAPIKeyMaskedUpdated) + ctx.Step(`^I revoke the first API key$`, w.revokeFirstAPIKey) + ctx.Step(`^reading the first API key back returns status "([^"]*)"$`, w.firstAPIKeyStatus) +} + +// --- REST API --------------------------------------------------------------- + +func (w *world) createRESTAPIs(n int) error { + apiRepo := repository.NewAPIRepo(w.it.db) + w.createdAPIIDs = w.createdAPIIDs[:0] + for i := range n { + api := &model.API{ + Handle: fmt.Sprintf("rest-api-%d-%s", i, id()[:6]), + Name: fmt.Sprintf("Rest API %d %s", i, id()[:6]), + Version: "v1.0", + Description: "created by integration test", + ProjectID: w.projID, + OrganizationID: w.orgID, + LifeCycleStatus: "CREATED", + Configuration: model.RestAPIConfig{Name: fmt.Sprintf("Rest API %d", i), Version: "v1.0"}, + } + if err := apiRepo.CreateAPI(api); err != nil { + return fmt.Errorf("[%s] CreateAPI %d failed: %w", w.it.driver, i, err) + } + if api.ID == "" { + return fmt.Errorf("[%s] CreateAPI %d did not populate the generated ID", w.it.driver, i) + } + w.createdAPIIDs = append(w.createdAPIIDs, api.ID) + } + return nil +} + +func (w *world) firstRESTAPIHasStatus(status string) error { + apiRepo := repository.NewAPIRepo(w.it.db) + first, err := apiRepo.GetAPIByUUID(w.createdAPIIDs[0], w.orgID) + if err != nil { + return fmt.Errorf("[%s] GetAPIByUUID failed: %w", w.it.driver, err) + } + if first == nil { + return fmt.Errorf("[%s] GetAPIByUUID: want the created API, got nil", w.it.driver) + } + if first.LifeCycleStatus != status || first.ProjectID != w.projID { + return fmt.Errorf("[%s] GetAPIByUUID round-trip mismatch: status=%q project=%q", w.it.driver, first.LifeCycleStatus, first.ProjectID) + } + w.firstAPI = first + return nil +} + +func (w *world) firstRESTAPIHandleExists() error { + apiRepo := repository.NewAPIRepo(w.it.db) + exists, err := apiRepo.CheckAPIExistsByHandleInOrganization(w.firstAPI.Handle, w.orgID) + if err != nil { + return fmt.Errorf("[%s] CheckAPIExistsByHandleInOrganization failed: %w", w.it.driver, err) + } + if !exists { + return fmt.Errorf("[%s] CheckAPIExistsByHandleInOrganization: want true for %q", w.it.driver, w.firstAPI.Handle) + } + return nil +} + +func (w *world) randomHandleMissing() error { + apiRepo := repository.NewAPIRepo(w.it.db) + missing, err := apiRepo.CheckAPIExistsByHandleInOrganization("no-such-handle-"+id(), w.orgID) + if err != nil { + return fmt.Errorf("[%s] CheckAPIExistsByHandleInOrganization(missing) failed: %w", w.it.driver, err) + } + if missing { + return fmt.Errorf("[%s] CheckAPIExistsByHandleInOrganization: want false for a random handle", w.it.driver) + } + return nil +} + +func (w *world) updateFirstRESTAPI(status string) error { + apiRepo := repository.NewAPIRepo(w.it.db) + w.firstAPI.Name = w.firstAPI.Name + " (updated)" + w.firstAPI.Description = "updated by integration test" + w.firstAPI.LifeCycleStatus = status + w.firstAPI.UpdatedBy = "it-user" + if err := apiRepo.UpdateAPI(w.firstAPI); err != nil { + return fmt.Errorf("[%s] UpdateAPI failed: %w", w.it.driver, err) + } + return nil +} + +func (w *world) firstRESTAPIUpdated(status, updater string) error { + apiRepo := repository.NewAPIRepo(w.it.db) + updated, err := apiRepo.GetAPIByUUID(w.firstAPI.ID, w.orgID) + if err != nil { + return fmt.Errorf("[%s] GetAPIByUUID after update failed: %w", w.it.driver, err) + } + if updated.Name != w.firstAPI.Name || updated.LifeCycleStatus != status || updated.Description != "updated by integration test" { + return fmt.Errorf("[%s] UpdateAPI did not persist: name=%q status=%q desc=%q", + w.it.driver, updated.Name, updated.LifeCycleStatus, updated.Description) + } + if updated.UpdatedBy != updater { + return fmt.Errorf("[%s] UpdateAPI did not persist updated_by: got %q", w.it.driver, updated.UpdatedBy) + } + return nil +} + +func (w *world) listRESTAPIsByOrg(want int) error { + apiRepo := repository.NewAPIRepo(w.it.db) + byOrg, err := apiRepo.GetAPIsByOrganizationUUID(w.orgID, "") + if err != nil { + return fmt.Errorf("[%s] GetAPIsByOrganizationUUID failed: %w", w.it.driver, err) + } + if len(byOrg) != want { + return fmt.Errorf("[%s] GetAPIsByOrganizationUUID: want %d, got %d", w.it.driver, want, len(byOrg)) + } + return nil +} + +func (w *world) listRESTAPIsByProject(want int) error { + apiRepo := repository.NewAPIRepo(w.it.db) + byProject, err := apiRepo.GetAPIsByProjectUUID(w.projID, w.orgID) + if err != nil { + return fmt.Errorf("[%s] GetAPIsByProjectUUID failed: %w", w.it.driver, err) + } + if len(byProject) != want { + return fmt.Errorf("[%s] GetAPIsByProjectUUID: want %d, got %d", w.it.driver, want, len(byProject)) + } + return nil +} + +func (w *world) listRESTAPIsByOtherProject(want int) error { + apiRepo := repository.NewAPIRepo(w.it.db) + byOther, err := apiRepo.GetAPIsByOrganizationUUID(w.orgID, id()) + if err != nil { + return fmt.Errorf("[%s] GetAPIsByOrganizationUUID(other project) failed: %w", w.it.driver, err) + } + if len(byOther) != want { + return fmt.Errorf("[%s] GetAPIsByOrganizationUUID(other project): want %d, got %d", w.it.driver, want, len(byOther)) + } + return nil +} + +// --- Gateway + token -------------------------------------------------------- + +func (w *world) createGateways(n int) error { + // The org+project are seeded by the feature Background; gateways hang off the org. + gwRepo := repository.NewGatewayRepo(w.it.db) + w.gatewayIDs = w.gatewayIDs[:0] + for i := range n { + gw := &model.Gateway{ + ID: id(), + OrganizationID: w.orgID, + Name: fmt.Sprintf("gateway %d", i), + Handle: fmt.Sprintf("gw-%d-%s", i, id()[:6]), + Description: "created by integration test", + Endpoints: []string{"https://localhost:8443", "wss://localhost:8444"}, + FunctionalityType: "REGULAR", + Version: "1.0.0", + Properties: map[string]interface{}{"region": "us"}, + CreatedBy: "it-user", + } + if err := gwRepo.Create(gw); err != nil { + return fmt.Errorf("[%s] gateway Create %d failed: %w", w.it.driver, i, err) + } + w.gatewayIDs = append(w.gatewayIDs, gw.ID) + } + w.firstGatewayID = w.gatewayIDs[0] + return nil +} + +func (w *world) firstGatewayInactive(region string) error { + gwRepo := repository.NewGatewayRepo(w.it.db) + got, err := gwRepo.GetByUUID(w.firstGatewayID) + if err != nil { + return fmt.Errorf("[%s] GetByUUID failed: %w", w.it.driver, err) + } + if got == nil || got.OrganizationID != w.orgID { + return fmt.Errorf("[%s] GetByUUID round-trip mismatch: %+v", w.it.driver, got) + } + if got.IsActive { + return fmt.Errorf("[%s] new gateway should default to inactive, got is_active=true", w.it.driver) + } + if got.Properties["region"] != region { + return fmt.Errorf("[%s] gateway properties did not round-trip: %+v", w.it.driver, got.Properties) + } + return nil +} + +func (w *world) firstGatewayFoundByHandle() error { + gwRepo := repository.NewGatewayRepo(w.it.db) + got, err := gwRepo.GetByUUID(w.firstGatewayID) + if err != nil { + return fmt.Errorf("[%s] GetByUUID failed: %w", w.it.driver, err) + } + byHandle, err := gwRepo.GetByHandleAndOrgID(got.Handle, w.orgID) + if err != nil || byHandle == nil || byHandle.ID != w.firstGatewayID { + return fmt.Errorf("[%s] GetByHandleAndOrgID mismatch: err=%v got=%+v", w.it.driver, err, byHandle) + } + return nil +} + +func (w *world) listGatewaysByOrg(want int) error { + gwRepo := repository.NewGatewayRepo(w.it.db) + list, err := gwRepo.GetByOrganizationID(w.orgID) + if err != nil { + return fmt.Errorf("[%s] GetByOrganizationID failed: %w", w.it.driver, err) + } + if len(list) != want { + return fmt.Errorf("[%s] GetByOrganizationID: want %d, got %d", w.it.driver, want, len(list)) + } + return nil +} + +func (w *world) generateGatewayToken() error { + gwRepo := repository.NewGatewayRepo(w.it.db) + w.tokenHash = "hash-" + id() + w.gwToken = &model.GatewayToken{ + ID: id(), + GatewayID: w.firstGatewayID, + TokenHash: w.tokenHash, + Salt: "salt-" + id()[:8], + Status: constants.GatewayTokenStatusActive, + CreatedBy: "it-user", + } + if err := gwRepo.CreateToken(w.gwToken); err != nil { + return fmt.Errorf("[%s] CreateToken failed: %w", w.it.driver, err) + } + return nil +} + +func (w *world) gatewayActiveTokenCount(want int) error { + gwRepo := repository.NewGatewayRepo(w.it.db) + count, err := gwRepo.CountActiveTokens(w.firstGatewayID) + if err != nil { + return fmt.Errorf("[%s] CountActiveTokens failed: %w", w.it.driver, err) + } + if count != want { + return fmt.Errorf("[%s] CountActiveTokens: want %d, got %d", w.it.driver, want, count) + } + if want > 0 { + active, err := gwRepo.GetActiveTokensByGatewayUUID(w.firstGatewayID) + if err != nil || len(active) != want { + return fmt.Errorf("[%s] GetActiveTokensByGatewayUUID: want %d, got %d (err=%v)", w.it.driver, want, len(active), err) + } + } + return nil +} + +func (w *world) tokenFoundByHash() error { + gwRepo := repository.NewGatewayRepo(w.it.db) + byHash, err := gwRepo.GetActiveTokenByHash(w.tokenHash) + if err != nil { + return fmt.Errorf("[%s] GetActiveTokenByHash failed: %w", w.it.driver, err) + } + if byHash == nil || byHash.ID != w.gwToken.ID || byHash.GatewayID != w.firstGatewayID { + return fmt.Errorf("[%s] GetActiveTokenByHash mismatch: %+v", w.it.driver, byHash) + } + return nil +} + +func (w *world) revokeGatewayToken() error { + gwRepo := repository.NewGatewayRepo(w.it.db) + if err := gwRepo.RevokeToken(w.gwToken.ID, "it-user"); err != nil { + return fmt.Errorf("[%s] RevokeToken failed: %w", w.it.driver, err) + } + return nil +} + +func (w *world) tokenNotFoundByHash() error { + gwRepo := repository.NewGatewayRepo(w.it.db) + revoked, err := gwRepo.GetActiveTokenByHash(w.tokenHash) + if err != nil || revoked != nil { + return fmt.Errorf("[%s] GetActiveTokenByHash after revoke: want nil, got %+v (err=%v)", w.it.driver, revoked, err) + } + return nil +} + +func (w *world) revokedTokenState(status, by string) error { + gwRepo := repository.NewGatewayRepo(w.it.db) + gone, err := gwRepo.GetTokenByUUID(w.gwToken.ID) + if err != nil { + return fmt.Errorf("[%s] GetTokenByUUID after revoke failed: %w", w.it.driver, err) + } + if gone == nil || string(gone.Status) != status || gone.RevokedBy == nil || *gone.RevokedBy != by { + return fmt.Errorf("[%s] revoked token state mismatch (want status %q by %q): %+v", w.it.driver, status, by, gone) + } + return nil +} + +// --- API key ---------------------------------------------------------------- + +func (w *world) seedAPIForKeys() error { + // The org+project are seeded by the feature Background; the API hangs off them. + apiRepo := repository.NewAPIRepo(w.it.db) + api := &model.API{ + Handle: "key-api-" + id()[:8], + Name: "Key API " + id()[:6], + Version: "v1.0", + ProjectID: w.projID, + OrganizationID: w.orgID, + LifeCycleStatus: "CREATED", + Configuration: model.RestAPIConfig{Name: "Key API", Version: "v1.0"}, + } + if err := apiRepo.CreateAPI(api); err != nil { + return fmt.Errorf("[%s] CreateAPI (for key) failed: %w", w.it.driver, err) + } + w.artifactUUID = api.ID + return nil +} + +func (w *world) createAPIKeys(n int) error { + keyRepo := repository.NewAPIKeyRepo(w.it.db, repository.NewArtifactTableRegistry()) + for i := range n { + key := &model.APIKey{ + UUID: id(), + ArtifactUUID: w.artifactUUID, + Name: fmt.Sprintf("key-%d", i), + DisplayName: fmt.Sprintf("Key %d", i), + MaskedAPIKey: "ab12", + APIKeyHashes: `{"sha256":"` + id() + `"}`, + Status: "active", + CreatedBy: "it-user", + AllowedTargets: "ALL", + } + if err := keyRepo.Create(key); err != nil { + return fmt.Errorf("[%s] APIKey Create %d failed: %w", w.it.driver, i, err) + } + } + return nil +} + +func (w *world) firstAPIKeyStatusAndTarget(status, target string) error { + keyRepo := repository.NewAPIKeyRepo(w.it.db, repository.NewArtifactTableRegistry()) + got, err := keyRepo.GetByArtifactAndName(w.artifactUUID, "key-0") + if err != nil { + return fmt.Errorf("[%s] GetByArtifactAndName failed: %w", w.it.driver, err) + } + if got == nil || got.ArtifactUUID != w.artifactUUID || got.Status != status { + return fmt.Errorf("[%s] GetByArtifactAndName round-trip mismatch: %+v", w.it.driver, got) + } + if got.AllowedTargets != target { + return fmt.Errorf("[%s] APIKey allowed_targets did not round-trip: %q", w.it.driver, got.AllowedTargets) + } + return nil +} + +func (w *world) listAPIKeysByArtifact(want int) error { + keyRepo := repository.NewAPIKeyRepo(w.it.db, repository.NewArtifactTableRegistry()) + keys, err := keyRepo.ListByArtifact(w.artifactUUID) + if err != nil { + return fmt.Errorf("[%s] ListByArtifact failed: %w", w.it.driver, err) + } + if len(keys) != want { + return fmt.Errorf("[%s] ListByArtifact: want %d, got %d", w.it.driver, want, len(keys)) + } + return nil +} + +func (w *world) updateFirstAPIKey() error { + keyRepo := repository.NewAPIKeyRepo(w.it.db, repository.NewArtifactTableRegistry()) + got, err := keyRepo.GetByArtifactAndName(w.artifactUUID, "key-0") + if err != nil { + return fmt.Errorf("[%s] GetByArtifactAndName failed: %w", w.it.driver, err) + } + got.MaskedAPIKey = "cd34" + got.APIKeyHashes = `{"sha256":"` + id() + `"}` + if err := keyRepo.Update(got); err != nil { + return fmt.Errorf("[%s] APIKey Update failed: %w", w.it.driver, err) + } + return nil +} + +func (w *world) firstAPIKeyMaskedUpdated() error { + keyRepo := repository.NewAPIKeyRepo(w.it.db, repository.NewArtifactTableRegistry()) + afterUpdate, err := keyRepo.GetByArtifactAndName(w.artifactUUID, "key-0") + if err != nil { + return fmt.Errorf("[%s] GetByArtifactAndName after update failed: %w", w.it.driver, err) + } + if afterUpdate.MaskedAPIKey != "cd34" { + return fmt.Errorf("[%s] APIKey Update did not persist masked key: %q", w.it.driver, afterUpdate.MaskedAPIKey) + } + return nil +} + +func (w *world) revokeFirstAPIKey() error { + keyRepo := repository.NewAPIKeyRepo(w.it.db, repository.NewArtifactTableRegistry()) + if err := keyRepo.Revoke(w.artifactUUID, "key-0"); err != nil { + return fmt.Errorf("[%s] APIKey Revoke failed: %w", w.it.driver, err) + } + return nil +} + +func (w *world) firstAPIKeyStatus(status string) error { + keyRepo := repository.NewAPIKeyRepo(w.it.db, repository.NewArtifactTableRegistry()) + got, err := keyRepo.GetByArtifactAndName(w.artifactUUID, "key-0") + if err != nil { + return fmt.Errorf("[%s] GetByArtifactAndName after revoke failed: %w", w.it.driver, err) + } + if got.Status != status { + return fmt.Errorf("[%s] APIKey status: want %q, got %q", w.it.driver, status, got.Status) + } + return nil +} diff --git a/platform-api/src/internal/integration/steps_deployment.go b/platform-api/src/internal/integration/steps_deployment.go new file mode 100644 index 0000000000..afc553724e --- /dev/null +++ b/platform-api/src/internal/integration/steps_deployment.go @@ -0,0 +1,238 @@ +//go:build integration + +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package integration + +import ( + "bytes" + "fmt" + "time" + + "github.com/cucumber/godog" + + "platform-api/src/internal/model" + "platform-api/src/internal/repository" +) + +// registerDeploymentSteps wires the deployment repository scenarios. They drive +// the deployment store through the real repo methods (create with limit +// enforcement, the deployment_status upsert, the FETCH-first current lookup and +// the ROW_NUMBER ranking) so the SQL Server-specific SQL is exercised on every +// engine — not just SQLite. +func registerDeploymentSteps(ctx *godog.ScenarioContext, w *world) { + ctx.Step(`^a REST API and a gateway exist$`, w.seedAPIAndGateway) + ctx.Step(`^I create (\d+) deployments for the API on the gateway$`, w.createDeployments) + ctx.Step(`^I create (\d+) deployments for the API on the gateway with a hard limit of (\d+)$`, w.createDeploymentsWithLimit) + ctx.Step(`^the current deployment status is "([^"]*)"$`, w.currentDeploymentStatusIs) + ctx.Step(`^the API has an active deployment$`, w.apiHasActiveDeployment) + ctx.Step(`^the API has no active deployment$`, w.apiHasNoActiveDeployment) + ctx.Step(`^the current deployment for the gateway is the latest one$`, w.currentDeploymentIsLatest) + ctx.Step(`^reading the current deployment back returns its content$`, w.currentDeploymentContentRoundTrip) + ctx.Step(`^listing deployments with state returns (\d+)$`, w.listDeploymentsWithState) + ctx.Step(`^I undeploy the current deployment$`, w.undeployCurrent) + ctx.Step(`^there is no current deployment for the gateway$`, w.noCurrentDeployment) + ctx.Step(`^the gateway retains at most (\d+) deployments$`, w.gatewayRetainsAtMost) +} + +func (w *world) seedAPIAndGateway() error { + // A deployment hangs off an artifact (the REST API) and a gateway. + apiRepo := repository.NewAPIRepo(w.it.db) + api := &model.API{ + Handle: "dep-api-" + id()[:8], + Name: "Dep API " + id()[:6], + Version: "v1.0", + ProjectID: w.projID, + OrganizationID: w.orgID, + LifeCycleStatus: "CREATED", + Configuration: model.RestAPIConfig{Name: "Dep API", Version: "v1.0"}, + } + if err := apiRepo.CreateAPI(api); err != nil { + return fmt.Errorf("[%s] CreateAPI (for deployment) failed: %w", w.it.driver, err) + } + w.depArtifactID = api.ID + + gwRepo := repository.NewGatewayRepo(w.it.db) + gw := &model.Gateway{ + ID: id(), + OrganizationID: w.orgID, + Name: "dep gateway", + Handle: "dep-gw-" + id()[:6], + Endpoints: []string{"https://localhost:8443", "wss://localhost:8444"}, + FunctionalityType: "REGULAR", + Version: "1.0.0", + Properties: map[string]interface{}{"region": "us"}, + CreatedBy: "it-user", + } + if err := gwRepo.Create(gw); err != nil { + return fmt.Errorf("[%s] gateway Create (for deployment) failed: %w", w.it.driver, err) + } + w.depGatewayID = gw.ID + return nil +} + +// createDeploymentsN creates n deployments through CreateWithLimitEnforcement, +// each marked DEPLOYED (which makes it the current deployment). Distinct, +// increasing created_at values keep the ordering deterministic for the ranking +// and current-lookup queries. +func (w *world) createDeploymentsN(n, hardLimit int) error { + repo := repository.NewDeploymentRepo(w.it.db, repository.NewArtifactTableRegistry()) + base := time.Now() + for i := range n { + deployed := model.DeploymentStatusDeployed + updatedAt := base.Add(time.Duration(i) * time.Second) + content := []byte(fmt.Sprintf("deployment-content-%d-%s", i, id()[:6])) + dep := &model.Deployment{ + Name: fmt.Sprintf("dep-%d", i), + ArtifactID: w.depArtifactID, + OrganizationID: w.orgID, + GatewayID: w.depGatewayID, + Content: content, + CreatedBy: "it-user", + CreatedAt: updatedAt, + Status: &deployed, + UpdatedAt: &updatedAt, + } + if err := repo.CreateWithLimitEnforcement(dep, hardLimit); err != nil { + return fmt.Errorf("[%s] CreateWithLimitEnforcement %d failed: %w", w.it.driver, i, err) + } + if dep.DeploymentID == "" { + return fmt.Errorf("[%s] CreateWithLimitEnforcement %d did not populate the deployment ID", w.it.driver, i) + } + w.depIDs = append(w.depIDs, dep.DeploymentID) + w.lastDepContent = content + } + return nil +} + +func (w *world) createDeployments(n int) error { + w.depIDs = w.depIDs[:0] + return w.createDeploymentsN(n, 10) +} + +func (w *world) createDeploymentsWithLimit(n, limit int) error { + w.depIDs = w.depIDs[:0] + return w.createDeploymentsN(n, limit) +} + +func (w *world) currentDeploymentStatusIs(status string) error { + repo := repository.NewDeploymentRepo(w.it.db, repository.NewArtifactTableRegistry()) + _, got, _, err := repo.GetStatus(w.depArtifactID, w.orgID, w.depGatewayID) + if err != nil { + return fmt.Errorf("[%s] GetStatus failed: %w", w.it.driver, err) + } + if string(got) != status { + return fmt.Errorf("[%s] current deployment status: want %q, got %q", w.it.driver, status, got) + } + return nil +} + +func (w *world) apiHasActiveDeployment() error { + return w.activeDeploymentIs(true) +} + +func (w *world) apiHasNoActiveDeployment() error { + return w.activeDeploymentIs(false) +} + +func (w *world) activeDeploymentIs(want bool) error { + repo := repository.NewDeploymentRepo(w.it.db, repository.NewArtifactTableRegistry()) + active, err := repo.HasActiveDeployment(w.depArtifactID, w.orgID) + if err != nil { + return fmt.Errorf("[%s] HasActiveDeployment failed: %w", w.it.driver, err) + } + if active != want { + return fmt.Errorf("[%s] HasActiveDeployment: want %v, got %v", w.it.driver, want, active) + } + return nil +} + +func (w *world) currentDeploymentIsLatest() error { + repo := repository.NewDeploymentRepo(w.it.db, repository.NewArtifactTableRegistry()) + got, err := repo.GetCurrentByGateway(w.depArtifactID, w.depGatewayID, w.orgID) + if err != nil { + return fmt.Errorf("[%s] GetCurrentByGateway failed: %w", w.it.driver, err) + } + want := w.depIDs[len(w.depIDs)-1] + if got == nil || got.DeploymentID != want { + return fmt.Errorf("[%s] GetCurrentByGateway: want latest %s, got %+v", w.it.driver, want, got) + } + return nil +} + +func (w *world) currentDeploymentContentRoundTrip() error { + repo := repository.NewDeploymentRepo(w.it.db, repository.NewArtifactTableRegistry()) + current := w.depIDs[len(w.depIDs)-1] + got, err := repo.GetWithContent(current, w.depArtifactID, w.orgID) + if err != nil { + return fmt.Errorf("[%s] GetWithContent failed: %w", w.it.driver, err) + } + if got == nil || !bytes.Equal(got.Content, w.lastDepContent) { + return fmt.Errorf("[%s] deployment content did not round-trip: want %q", w.it.driver, w.lastDepContent) + } + return nil +} + +func (w *world) listDeploymentsWithState(want int) error { + repo := repository.NewDeploymentRepo(w.it.db, repository.NewArtifactTableRegistry()) + list, err := repo.GetDeploymentsWithState(w.depArtifactID, w.orgID, nil, nil, 5) + if err != nil { + return fmt.Errorf("[%s] GetDeploymentsWithState failed: %w", w.it.driver, err) + } + if len(list) != want { + return fmt.Errorf("[%s] GetDeploymentsWithState: want %d, got %d", w.it.driver, want, len(list)) + } + return nil +} + +func (w *world) undeployCurrent() error { + repo := repository.NewDeploymentRepo(w.it.db, repository.NewArtifactTableRegistry()) + current, _, _, err := repo.GetStatus(w.depArtifactID, w.orgID, w.depGatewayID) + if err != nil { + return fmt.Errorf("[%s] GetStatus (before undeploy) failed: %w", w.it.driver, err) + } + if _, err := repo.SetCurrentWithDetails(w.depArtifactID, w.orgID, w.depGatewayID, current, + model.DeploymentStatusUndeployed, "", nil, "undeployed by integration test"); err != nil { + return fmt.Errorf("[%s] SetCurrentWithDetails (undeploy) failed: %w", w.it.driver, err) + } + return nil +} + +func (w *world) noCurrentDeployment() error { + repo := repository.NewDeploymentRepo(w.it.db, repository.NewArtifactTableRegistry()) + got, err := repo.GetCurrentByGateway(w.depArtifactID, w.depGatewayID, w.orgID) + if err != nil { + return fmt.Errorf("[%s] GetCurrentByGateway (after undeploy) failed: %w", w.it.driver, err) + } + if got != nil { + return fmt.Errorf("[%s] GetCurrentByGateway after undeploy: want nil, got %+v", w.it.driver, got) + } + return nil +} + +func (w *world) gatewayRetainsAtMost(max int) error { + got, err := w.it.count("deployments", "gateway_uuid", w.depGatewayID) + if err != nil { + return err + } + if got < 1 || got > max { + return fmt.Errorf("[%s] retained deployments: want 1..%d, got %d", w.it.driver, max, got) + } + return nil +} diff --git a/platform-api/src/internal/integration/steps_llm.go b/platform-api/src/internal/integration/steps_llm.go new file mode 100644 index 0000000000..7c9d555cbc --- /dev/null +++ b/platform-api/src/internal/integration/steps_llm.go @@ -0,0 +1,366 @@ +//go:build integration + +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package integration + +import ( + "fmt" + "strings" + + "github.com/cucumber/godog" + + "platform-api/src/internal/model" + "platform-api/src/internal/repository" +) + +// registerLLMSteps wires the LLM control-plane (template / provider / proxy) +// repository scenarios. These exercise the AI control plane purely at the store +// layer — no real LLM is contacted and the upstream API key is a dummy string, +// so the suite needs no provider credentials on any engine. +func registerLLMSteps(ctx *godog.ScenarioContext, w *world) { + // Provider template. The "Given" and "When" phrasings share one handler. + ctx.Step(`^(?:I create )?an LLM provider template "([^"]*)" version "([^"]*)"$`, w.createLLMTemplate) + ctx.Step(`^reading the template by its handle returns version "([^"]*)"$`, w.readTemplateVersion) + ctx.Step(`^I create a new version "([^"]*)" of the template$`, w.createTemplateVersion) + ctx.Step(`^reading the original template handle still returns version "([^"]*)"$`, w.readTemplateVersion) + ctx.Step(`^the latest version of the template family is "([^"]*)"$`, w.latestTemplateVersionIs) + ctx.Step(`^listing template versions returns (\d+)$`, w.listTemplateVersions) + + // Provider. + ctx.Step(`^(?:I create )?an LLM provider "([^"]*)" with upstream key "([^"]*)"$`, w.createLLMProvider) + ctx.Step(`^reading the provider back returns upstream key "([^"]*)"$`, w.providerUpstreamKeyIs) + ctx.Step(`^listing LLM providers by organization returns (\d+)$`, w.listLLMProviders) + ctx.Step(`^I update the provider description to "([^"]*)"$`, w.updateProviderDescription) + ctx.Step(`^reading the provider back shows description "([^"]*)"$`, w.providerDescriptionIs) + ctx.Step(`^I delete the provider$`, w.deleteProvider) + + // Proxy. + ctx.Step(`^I create an LLM proxy "([^"]*)" for that provider$`, w.createLLMProxy) + ctx.Step(`^reading the proxy back references the provider$`, w.proxyReferencesProvider) + ctx.Step(`^listing LLM proxies by organization returns (\d+)$`, w.listLLMProxiesByOrg) + ctx.Step(`^listing LLM proxies by project returns (\d+)$`, w.listLLMProxiesByProject) + ctx.Step(`^listing LLM proxies by provider returns (\d+)$`, w.listLLMProxiesByProvider) + ctx.Step(`^I delete the proxy$`, w.deleteProxy) +} + +// --- Provider template ------------------------------------------------------ + +func (w *world) createLLMTemplate(handle, version string) error { + repo := repository.NewLLMProviderTemplateRepo(w.it.db) + tpl := &model.LLMProviderTemplate{ + OrganizationUUID: w.orgID, + ID: handle, + GroupID: handle, + Name: "Template " + handle, + ManagedBy: "customer", + Version: version, + } + if err := repo.Create(tpl); err != nil { + return fmt.Errorf("[%s] create LLM template failed: %w", w.it.driver, err) + } + if tpl.UUID == "" { + return fmt.Errorf("[%s] create LLM template did not populate the generated UUID", w.it.driver) + } + w.templateUUID = tpl.UUID + w.templateHandle = handle + return nil +} + +func (w *world) readTemplateVersion(version string) error { + repo := repository.NewLLMProviderTemplateRepo(w.it.db) + got, err := repo.GetByID(w.templateHandle, w.orgID) + if err != nil { + return fmt.Errorf("[%s] GetByID(%s) failed: %w", w.it.driver, w.templateHandle, err) + } + if got == nil || got.Version != version { + return fmt.Errorf("[%s] GetByID(%s): want version %q, got %+v", w.it.driver, w.templateHandle, version, got) + } + return nil +} + +func (w *world) createTemplateVersion(version string) error { + repo := repository.NewLLMProviderTemplateRepo(w.it.db) + // A new family version uses a version-suffixed handle but the same group_id, + // which demotes the previous is_latest row (mirrors the template service). + // The suffix is derived from the requested version (e.g. "v2.0" -> "v2-0"). + newHandle := fmt.Sprintf("%s-%s", w.templateHandle, strings.ReplaceAll(version, ".", "-")) + tpl := &model.LLMProviderTemplate{ + OrganizationUUID: w.orgID, + ID: newHandle, + GroupID: w.templateHandle, + Name: "Template " + w.templateHandle, + ManagedBy: "customer", + Version: version, + } + if err := repo.CreateNewVersion(tpl); err != nil { + return fmt.Errorf("[%s] CreateNewVersion(%s) failed: %w", w.it.driver, version, err) + } + return nil +} + +func (w *world) latestTemplateVersionIs(version string) error { + repo := repository.NewLLMProviderTemplateRepo(w.it.db) + versions, err := repo.ListVersions(w.templateHandle, w.orgID, 100, 0) + if err != nil { + return fmt.Errorf("[%s] ListVersions failed: %w", w.it.driver, err) + } + for _, t := range versions { + if t.IsLatest { + if t.Version != version { + return fmt.Errorf("[%s] latest version: want %q, got %q", w.it.driver, version, t.Version) + } + return nil + } + } + return fmt.Errorf("[%s] no template version is marked latest", w.it.driver) +} + +func (w *world) listTemplateVersions(want int) error { + repo := repository.NewLLMProviderTemplateRepo(w.it.db) + versions, err := repo.ListVersions(w.templateHandle, w.orgID, 100, 0) + if err != nil { + return fmt.Errorf("[%s] ListVersions failed: %w", w.it.driver, err) + } + if len(versions) != want { + return fmt.Errorf("[%s] ListVersions: want %d, got %d", w.it.driver, want, len(versions)) + } + count, err := repo.CountVersions(w.templateHandle, w.orgID) + if err != nil { + return fmt.Errorf("[%s] CountVersions failed: %w", w.it.driver, err) + } + if count != want { + return fmt.Errorf("[%s] CountVersions: want %d, got %d", w.it.driver, want, count) + } + return nil +} + +// --- Provider --------------------------------------------------------------- + +func (w *world) createLLMProvider(handle, upstreamKey string) error { + repo := repository.NewLLMProviderRepo(w.it.db) + prov := &model.LLMProvider{ + OrganizationUUID: w.orgID, + ID: handle, + Name: "Provider " + handle, + Version: "v1.0", + TemplateUUID: w.templateUUID, + CreatedBy: "it-user", + Configuration: model.LLMProviderConfig{ + Template: w.templateHandle, + // The upstream API key is a dummy string — the store never calls the + // provider, so no real credential is required. + Upstream: &model.UpstreamConfig{ + Main: &model.UpstreamEndpoint{ + URL: "http://mock-openapi:4010/openai/v1", + Auth: &model.UpstreamAuth{ + Type: "api-key", + Header: "Authorization", + Value: upstreamKey, + }, + }, + }, + }, + } + if err := repo.Create(prov); err != nil { + return fmt.Errorf("[%s] create LLM provider failed: %w", w.it.driver, err) + } + if prov.UUID == "" { + return fmt.Errorf("[%s] create LLM provider did not populate the generated UUID", w.it.driver) + } + w.providerUUID = prov.UUID + w.providerHandle = handle + return nil +} + +func (w *world) providerUpstreamKeyIs(want string) error { + prov, err := w.getProvider() + if err != nil { + return err + } + if prov.Configuration.Upstream == nil || prov.Configuration.Upstream.Main == nil || prov.Configuration.Upstream.Main.Auth == nil { + return fmt.Errorf("[%s] provider upstream auth did not round-trip: %+v", w.it.driver, prov.Configuration.Upstream) + } + if got := prov.Configuration.Upstream.Main.Auth.Value; got != want { + return fmt.Errorf("[%s] provider upstream key: want %q, got %q", w.it.driver, want, got) + } + return nil +} + +func (w *world) listLLMProviders(want int) error { + repo := repository.NewLLMProviderRepo(w.it.db) + list, err := repo.List(w.orgID, 100, 0) + if err != nil { + return fmt.Errorf("[%s] List LLM providers failed: %w", w.it.driver, err) + } + if len(list) != want { + return fmt.Errorf("[%s] List LLM providers: want %d, got %d", w.it.driver, want, len(list)) + } + count, err := repo.Count(w.orgID) + if err != nil { + return fmt.Errorf("[%s] Count LLM providers failed: %w", w.it.driver, err) + } + if count != want { + return fmt.Errorf("[%s] Count LLM providers: want %d, got %d", w.it.driver, want, count) + } + return nil +} + +func (w *world) updateProviderDescription(desc string) error { + repo := repository.NewLLMProviderRepo(w.it.db) + prov, err := w.getProvider() + if err != nil { + return err + } + prov.Description = desc + prov.UpdatedBy = "it-user" + if err := repo.Update(prov); err != nil { + return fmt.Errorf("[%s] Update LLM provider failed: %w", w.it.driver, err) + } + return nil +} + +func (w *world) providerDescriptionIs(want string) error { + prov, err := w.getProvider() + if err != nil { + return err + } + if prov.Description != want { + return fmt.Errorf("[%s] provider description: want %q, got %q", w.it.driver, want, prov.Description) + } + return nil +} + +func (w *world) deleteProvider() error { + repo := repository.NewLLMProviderRepo(w.it.db) + if err := repo.Delete(w.providerHandle, w.orgID); err != nil { + return fmt.Errorf("[%s] Delete LLM provider failed: %w", w.it.driver, err) + } + return nil +} + +func (w *world) getProvider() (*model.LLMProvider, error) { + repo := repository.NewLLMProviderRepo(w.it.db) + prov, err := repo.GetByID(w.providerHandle, w.orgID) + if err != nil { + return nil, fmt.Errorf("[%s] GetByID(%s) failed: %w", w.it.driver, w.providerHandle, err) + } + if prov == nil { + return nil, fmt.Errorf("[%s] GetByID(%s): want the provider, got nil", w.it.driver, w.providerHandle) + } + return prov, nil +} + +// --- Proxy ------------------------------------------------------------------ + +func (w *world) createLLMProxy(handle string) error { + repo := repository.NewLLMProxyRepo(w.it.db) + proxy := &model.LLMProxy{ + OrganizationUUID: w.orgID, + ID: handle, + Name: "Proxy " + handle, + ProjectUUID: w.projID, + Version: "v1.0", + ProviderUUID: w.providerUUID, + CreatedBy: "it-user", + Configuration: model.LLMProxyConfig{Provider: w.providerHandle}, + } + if err := repo.Create(proxy); err != nil { + return fmt.Errorf("[%s] create LLM proxy failed: %w", w.it.driver, err) + } + w.proxyHandle = handle + return nil +} + +func (w *world) proxyReferencesProvider() error { + repo := repository.NewLLMProxyRepo(w.it.db) + proxy, err := repo.GetByID(w.proxyHandle, w.orgID) + if err != nil { + return fmt.Errorf("[%s] GetByID(%s) failed: %w", w.it.driver, w.proxyHandle, err) + } + if proxy == nil || proxy.ProviderUUID != w.providerUUID { + return fmt.Errorf("[%s] proxy provider reference mismatch: %+v", w.it.driver, proxy) + } + return nil +} + +func (w *world) listLLMProxiesByOrg(want int) error { + repo := repository.NewLLMProxyRepo(w.it.db) + list, err := repo.List(w.orgID, 100, 0) + if err != nil { + return fmt.Errorf("[%s] List LLM proxies failed: %w", w.it.driver, err) + } + if len(list) != want { + return fmt.Errorf("[%s] List LLM proxies: want %d, got %d", w.it.driver, want, len(list)) + } + count, err := repo.Count(w.orgID) + if err != nil { + return fmt.Errorf("[%s] Count LLM proxies failed: %w", w.it.driver, err) + } + if count != want { + return fmt.Errorf("[%s] Count LLM proxies: want %d, got %d", w.it.driver, want, count) + } + return nil +} + +func (w *world) listLLMProxiesByProject(want int) error { + repo := repository.NewLLMProxyRepo(w.it.db) + list, err := repo.ListByProject(w.orgID, w.projID, 100, 0) + if err != nil { + return fmt.Errorf("[%s] ListByProject LLM proxies failed: %w", w.it.driver, err) + } + if len(list) != want { + return fmt.Errorf("[%s] ListByProject LLM proxies: want %d, got %d", w.it.driver, want, len(list)) + } + count, err := repo.CountByProject(w.orgID, w.projID) + if err != nil { + return fmt.Errorf("[%s] CountByProject LLM proxies failed: %w", w.it.driver, err) + } + if count != want { + return fmt.Errorf("[%s] CountByProject LLM proxies: want %d, got %d", w.it.driver, want, count) + } + return nil +} + +func (w *world) listLLMProxiesByProvider(want int) error { + repo := repository.NewLLMProxyRepo(w.it.db) + list, err := repo.ListByProvider(w.orgID, w.providerUUID, 100, 0) + if err != nil { + return fmt.Errorf("[%s] ListByProvider LLM proxies failed: %w", w.it.driver, err) + } + if len(list) != want { + return fmt.Errorf("[%s] ListByProvider LLM proxies: want %d, got %d", w.it.driver, want, len(list)) + } + count, err := repo.CountByProvider(w.orgID, w.providerUUID) + if err != nil { + return fmt.Errorf("[%s] CountByProvider LLM proxies failed: %w", w.it.driver, err) + } + if count != want { + return fmt.Errorf("[%s] CountByProvider LLM proxies: want %d, got %d", w.it.driver, want, count) + } + return nil +} + +func (w *world) deleteProxy() error { + repo := repository.NewLLMProxyRepo(w.it.db) + if err := repo.Delete(w.proxyHandle, w.orgID); err != nil { + return fmt.Errorf("[%s] Delete LLM proxy failed: %w", w.it.driver, err) + } + return nil +} diff --git a/platform-api/src/internal/integration/steps_mcp.go b/platform-api/src/internal/integration/steps_mcp.go new file mode 100644 index 0000000000..4c3098dd07 --- /dev/null +++ b/platform-api/src/internal/integration/steps_mcp.go @@ -0,0 +1,175 @@ +//go:build integration + +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package integration + +import ( + "fmt" + + "github.com/cucumber/godog" + + "platform-api/src/internal/model" + "platform-api/src/internal/repository" +) + +// registerMCPSteps wires the MCP control-plane (proxy) repository scenarios. +// Like the LLM steps these run purely at the store layer — no real MCP server +// is contacted and the upstream API key is a dummy string. +func registerMCPSteps(ctx *godog.ScenarioContext, w *world) { + ctx.Step(`^I create an MCP proxy "([^"]*)" with upstream key "([^"]*)"$`, w.createMCPProxy) + ctx.Step(`^reading the MCP proxy back returns upstream key "([^"]*)"$`, w.mcpProxyUpstreamKeyIs) + ctx.Step(`^listing MCP proxies by organization returns (\d+)$`, w.listMCPProxiesByOrg) + ctx.Step(`^listing MCP proxies by project returns (\d+)$`, w.listMCPProxiesByProject) + ctx.Step(`^I update the MCP proxy description to "([^"]*)"$`, w.updateMCPProxyDescription) + ctx.Step(`^reading the MCP proxy back shows description "([^"]*)"$`, w.mcpProxyDescriptionIs) + ctx.Step(`^I delete the MCP proxy$`, w.deleteMCPProxy) +} + +func (w *world) createMCPProxy(handle, upstreamKey string) error { + repo := repository.NewMCPProxyRepo(w.it.db) + projID := w.projID + proxy := &model.MCPProxy{ + OrganizationUUID: w.orgID, + Handle: handle, + Name: "MCP " + handle, + ProjectUUID: &projID, + Version: "v1.0", + CreatedBy: "it-user", + Configuration: model.MCPProxyConfiguration{ + Name: "MCP " + handle, + Version: "v1.0", + SpecVersion: "2025-06-18", + // The upstream API key is a dummy string — the store never calls the + // MCP server, so no real credential is required. + Upstream: model.UpstreamConfig{ + Main: &model.UpstreamEndpoint{ + URL: "http://mock-mcp-server:8080/mcp", + Auth: &model.UpstreamAuth{ + Type: "api-key", + Header: "Authorization", + Value: upstreamKey, + }, + }, + }, + }, + } + if err := repo.Create(proxy); err != nil { + return fmt.Errorf("[%s] create MCP proxy failed: %w", w.it.driver, err) + } + w.mcpHandle = handle + return nil +} + +func (w *world) getMCPProxy() (*model.MCPProxy, error) { + repo := repository.NewMCPProxyRepo(w.it.db) + proxy, err := repo.GetByHandle(w.mcpHandle, w.orgID) + if err != nil { + return nil, fmt.Errorf("[%s] GetByHandle(%s) failed: %w", w.it.driver, w.mcpHandle, err) + } + if proxy == nil { + return nil, fmt.Errorf("[%s] GetByHandle(%s): want the MCP proxy, got nil", w.it.driver, w.mcpHandle) + } + return proxy, nil +} + +func (w *world) mcpProxyUpstreamKeyIs(want string) error { + proxy, err := w.getMCPProxy() + if err != nil { + return err + } + if proxy.Configuration.Upstream.Main == nil || proxy.Configuration.Upstream.Main.Auth == nil { + return fmt.Errorf("[%s] MCP proxy upstream auth did not round-trip: %+v", w.it.driver, proxy.Configuration.Upstream) + } + if got := proxy.Configuration.Upstream.Main.Auth.Value; got != want { + return fmt.Errorf("[%s] MCP proxy upstream key: want %q, got %q", w.it.driver, want, got) + } + return nil +} + +func (w *world) listMCPProxiesByOrg(want int) error { + repo := repository.NewMCPProxyRepo(w.it.db) + list, err := repo.List(w.orgID, 100, 0) + if err != nil { + return fmt.Errorf("[%s] List MCP proxies failed: %w", w.it.driver, err) + } + if len(list) != want { + return fmt.Errorf("[%s] List MCP proxies: want %d, got %d", w.it.driver, want, len(list)) + } + count, err := repo.Count(w.orgID) + if err != nil { + return fmt.Errorf("[%s] Count MCP proxies failed: %w", w.it.driver, err) + } + if count != want { + return fmt.Errorf("[%s] Count MCP proxies: want %d, got %d", w.it.driver, want, count) + } + return nil +} + +func (w *world) listMCPProxiesByProject(want int) error { + repo := repository.NewMCPProxyRepo(w.it.db) + list, err := repo.ListByProject(w.orgID, w.projID) + if err != nil { + return fmt.Errorf("[%s] ListByProject MCP proxies failed: %w", w.it.driver, err) + } + if len(list) != want { + return fmt.Errorf("[%s] ListByProject MCP proxies: want %d, got %d", w.it.driver, want, len(list)) + } + count, err := repo.CountByProject(w.orgID, w.projID) + if err != nil { + return fmt.Errorf("[%s] CountByProject MCP proxies failed: %w", w.it.driver, err) + } + if count != want { + return fmt.Errorf("[%s] CountByProject MCP proxies: want %d, got %d", w.it.driver, want, count) + } + return nil +} + +func (w *world) updateMCPProxyDescription(desc string) error { + repo := repository.NewMCPProxyRepo(w.it.db) + proxy, err := w.getMCPProxy() + if err != nil { + return err + } + proxy.Description = desc + proxy.UpdatedBy = "it-user" + if err := repo.Update(proxy); err != nil { + return fmt.Errorf("[%s] Update MCP proxy failed: %w", w.it.driver, err) + } + return nil +} + +func (w *world) mcpProxyDescriptionIs(want string) error { + proxy, err := w.getMCPProxy() + if err != nil { + return err + } + if proxy.Description != want { + return fmt.Errorf("[%s] MCP proxy description: want %q, got %q", w.it.driver, want, proxy.Description) + } + return nil +} + +func (w *world) deleteMCPProxy() error { + repo := repository.NewMCPProxyRepo(w.it.db) + if err := repo.Delete(w.mcpHandle, w.orgID); err != nil { + return fmt.Errorf("[%s] Delete MCP proxy failed: %w", w.it.driver, err) + } + return nil +} diff --git a/platform-api/src/internal/integration/steps_pagination.go b/platform-api/src/internal/integration/steps_pagination.go new file mode 100644 index 0000000000..195080f02d --- /dev/null +++ b/platform-api/src/internal/integration/steps_pagination.go @@ -0,0 +1,371 @@ +//go:build integration + +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package integration + +import ( + "fmt" + + "github.com/cucumber/godog" + + "platform-api/src/internal/model" + "platform-api/src/internal/repository" +) + +// registerPaginationSteps wires the pagination and filtered-listing scenarios. +func registerPaginationSteps(ctx *godog.ScenarioContext, w *world) { + // Organization pagination. + ctx.Step(`^(\d+) additional organizations exist$`, w.additionalOrgsExist) + ctx.Step(`^I page through organizations (\d+) at a time$`, w.pageOrganizations) + ctx.Step(`^every organization is seen exactly once$`, w.everyOrgSeenOnce) + + // Subscription plan. + ctx.Step(`^an organization exists$`, w.anOrganizationExists) + ctx.Step(`^checking existence of a missing subscription plan returns false$`, w.missingPlanNotExists) + ctx.Step(`^I create (\d+) subscription plans with a throttle limit of (\d+) per "([^"]*)"$`, w.createSubscriptionPlans) + ctx.Step(`^listing subscription plans (\d+) at a time returns (\d+)$`, w.listSubscriptionPlans) + ctx.Step(`^each listed plan round-trips its throttle limit$`, w.listedPlansHaveThrottle) + ctx.Step(`^reading the first listed plan back round-trips its throttle limit$`, w.firstPlanGetHasThrottle) + ctx.Step(`^I clear the throttle limit on the first listed plan$`, w.clearFirstPlanThrottle) + ctx.Step(`^reading it back shows no throttle limit$`, w.firstPlanHasNoThrottle) + + // Project pagination. + ctx.Step(`^(\d+) projects exist$`, w.projectsExist) + ctx.Step(`^I page through projects (\d+) at a time$`, w.pageProjects) + ctx.Step(`^every project is seen exactly once$`, w.everyProjectSeenOnce) + + // Subscription listing. + ctx.Step(`^listing subscriptions with no filter returns (\d+)$`, w.listSubscriptionsNoFilter) + ctx.Step(`^listing subscriptions filtered by status "([^"]*)" returns (\d+)$`, w.listSubscriptionsByStatus) + + // Application lookup. + ctx.Step(`^an organization, project and application exist$`, w.orgProjectApplicationExist) + ctx.Step(`^the application is found by its UUID$`, w.applicationFoundByUUID) + ctx.Step(`^the application is found by its handle$`, w.applicationFoundByHandle) + ctx.Step(`^a missing application identifier returns nothing$`, w.missingApplicationReturnsNil) + + ctx.Step(`^an organization and project exist$`, w.anOrgAndProjectExist) +} + +// --- Organization pagination ------------------------------------------------ + +func (w *world) additionalOrgsExist(n int) error { + orgRepo := repository.NewOrganizationRepo(w.it.db) + baseline, err := orgRepo.ListOrganizations(1_000_000, 0) + if err != nil { + return fmt.Errorf("[%s] baseline list failed: %w", w.it.driver, err) + } + for i := range n { + org := &model.Organization{ID: id(), Handle: "lc-" + id()[:8], Name: fmt.Sprintf("org %d", i), Region: "us"} + if err := orgRepo.CreateOrganization(org); err != nil { + return fmt.Errorf("[%s] create org failed: %w", w.it.driver, err) + } + } + w.orgTotal = len(baseline) + n + return nil +} + +func (w *world) pageOrganizations(pageSize int) error { + orgRepo := repository.NewOrganizationRepo(w.it.db) + seen := map[string]bool{} + for offset := 0; offset < w.orgTotal; offset += pageSize { + page, err := orgRepo.ListOrganizations(pageSize, offset) + if err != nil { + return fmt.Errorf("[%s] ListOrganizations(%d,%d) failed: %w", w.it.driver, pageSize, offset, err) + } + want := pageSize + if rem := w.orgTotal - offset; rem < want { + want = rem + } + if len(page) != want { + return fmt.Errorf("[%s] page at offset %d: want %d rows, got %d", w.it.driver, offset, want, len(page)) + } + for _, o := range page { + if seen[o.ID] { + return fmt.Errorf("[%s] pagination overlap at offset %d: id %s seen twice", w.it.driver, offset, o.ID) + } + seen[o.ID] = true + } + } + w.seenCount = len(seen) + return nil +} + +func (w *world) everyOrgSeenOnce() error { + if w.seenCount != w.orgTotal { + return fmt.Errorf("[%s] paging covered %d rows, want %d", w.it.driver, w.seenCount, w.orgTotal) + } + return nil +} + +// --- Subscription plan ------------------------------------------------------ + +func (w *world) anOrganizationExists() error { + orgRepo := repository.NewOrganizationRepo(w.it.db) + org := &model.Organization{ID: id(), Handle: "pl-" + id()[:8], Name: "plan org", Region: "us"} + if err := orgRepo.CreateOrganization(org); err != nil { + return fmt.Errorf("[%s] create org failed: %w", w.it.driver, err) + } + w.orgID = org.ID + return nil +} + +func (w *world) missingPlanNotExists() error { + planRepo := repository.NewSubscriptionPlanRepo(w.it.db) + exists, err := planRepo.ExistsByHandleAndOrg("nope-"+id(), w.orgID) + if err != nil { + return fmt.Errorf("[%s] ExistsByHandleAndOrg failed: %w", w.it.driver, err) + } + if exists { + return fmt.Errorf("[%s] ExistsByHandleAndOrg: want false for missing plan", w.it.driver) + } + return nil +} + +func (w *world) createSubscriptionPlans(n, limit int, unit string) error { + planRepo := repository.NewSubscriptionPlanRepo(w.it.db) + w.throttle = limit + w.throttleUnit = unit + for i := range n { + count := limit + slug := fmt.Sprintf("plan-%d-%s", i, id()[:6]) + plan := &model.SubscriptionPlan{ + UUID: id(), Handle: slug, Name: fmt.Sprintf("Plan %d", i), + StopOnQuotaReach: true, + ThrottleLimitCount: &count, ThrottleLimitUnit: unit, + OrganizationUUID: w.orgID, Status: model.SubscriptionPlanStatus("ACTIVE"), + } + if err := planRepo.Create(plan); err != nil { + return fmt.Errorf("[%s] create plan failed: %w", w.it.driver, err) + } + } + return nil +} + +func (w *world) listSubscriptionPlans(pageSize, want int) error { + planRepo := repository.NewSubscriptionPlanRepo(w.it.db) + plans, err := planRepo.ListByOrganization(w.orgID, pageSize, 0) + if err != nil { + return fmt.Errorf("[%s] ListByOrganization failed: %w", w.it.driver, err) + } + if len(plans) != want { + return fmt.Errorf("[%s] ListByOrganization(%d,0): want %d, got %d", w.it.driver, pageSize, want, len(plans)) + } + w.listedPlanID = plans[0].UUID + return nil +} + +func (w *world) listedPlansHaveThrottle() error { + planRepo := repository.NewSubscriptionPlanRepo(w.it.db) + plans, err := planRepo.ListByOrganization(w.orgID, 2, 0) + if err != nil { + return fmt.Errorf("[%s] ListByOrganization failed: %w", w.it.driver, err) + } + for _, p := range plans { + if p.ThrottleLimitCount == nil || *p.ThrottleLimitCount != w.throttle { + return fmt.Errorf("[%s] list hydrate: ThrottleLimitCount = %v, want %d", w.it.driver, p.ThrottleLimitCount, w.throttle) + } + if p.ThrottleLimitUnit != w.throttleUnit || !p.StopOnQuotaReach { + return fmt.Errorf("[%s] list hydrate: unit=%q stop=%v, want unit=%s stop=true", w.it.driver, p.ThrottleLimitUnit, p.StopOnQuotaReach, w.throttleUnit) + } + } + return nil +} + +func (w *world) firstPlanGetHasThrottle() error { + planRepo := repository.NewSubscriptionPlanRepo(w.it.db) + got, err := planRepo.GetByID(w.listedPlanID, w.orgID) + if err != nil { + return fmt.Errorf("[%s] GetByID failed: %w", w.it.driver, err) + } + if got.ThrottleLimitCount == nil || *got.ThrottleLimitCount != w.throttle || got.ThrottleLimitUnit != w.throttleUnit { + return fmt.Errorf("[%s] GetByID hydrate: count=%v unit=%q, want %d/%s", w.it.driver, got.ThrottleLimitCount, got.ThrottleLimitUnit, w.throttle, w.throttleUnit) + } + return nil +} + +func (w *world) clearFirstPlanThrottle() error { + planRepo := repository.NewSubscriptionPlanRepo(w.it.db) + got, err := planRepo.GetByID(w.listedPlanID, w.orgID) + if err != nil { + return fmt.Errorf("[%s] GetByID failed: %w", w.it.driver, err) + } + got.ThrottleLimitCount = nil + got.ThrottleLimitUnit = "" + got.StopOnQuotaReach = true + if err := planRepo.Update(got); err != nil { + return fmt.Errorf("[%s] Update (clear throttle) failed: %w", w.it.driver, err) + } + return nil +} + +func (w *world) firstPlanHasNoThrottle() error { + planRepo := repository.NewSubscriptionPlanRepo(w.it.db) + cleared, err := planRepo.GetByID(w.listedPlanID, w.orgID) + if err != nil { + return fmt.Errorf("[%s] GetByID after clear failed: %w", w.it.driver, err) + } + if cleared.ThrottleLimitCount != nil || cleared.ThrottleLimitUnit != "" { + return fmt.Errorf("[%s] after clear: want no throttle, got count=%v unit=%q", w.it.driver, cleared.ThrottleLimitCount, cleared.ThrottleLimitUnit) + } + return nil +} + +// --- Project pagination ----------------------------------------------------- + +func (w *world) projectsExist(n int) error { + projectRepo := repository.NewProjectRepo(w.it.db) + for i := range n { + pid := id() + pName := fmt.Sprintf("proj-%d-%s", i, pid[:6]) + p := &model.Project{ID: pid, Handle: pName, Name: pName, OrganizationID: w.orgID, Description: "p"} + if err := projectRepo.CreateProject(p); err != nil { + return fmt.Errorf("[%s] create project failed: %w", w.it.driver, err) + } + } + w.projN = n + return nil +} + +func (w *world) pageProjects(pageSize int) error { + projectRepo := repository.NewProjectRepo(w.it.db) + seen := map[string]bool{} + for offset := 0; offset < w.projN; offset += pageSize { + page, err := projectRepo.ListProjects(w.orgID, pageSize, offset) + if err != nil { + return fmt.Errorf("[%s] ListProjects(%d,%d) failed: %w", w.it.driver, pageSize, offset, err) + } + want := pageSize + if rem := w.projN - offset; rem < want { + want = rem + } + if len(page) != want { + return fmt.Errorf("[%s] ListProjects offset %d: want %d, got %d", w.it.driver, offset, want, len(page)) + } + for _, p := range page { + if seen[p.ID] { + return fmt.Errorf("[%s] project pagination overlap at offset %d: %s", w.it.driver, offset, p.ID) + } + seen[p.ID] = true + } + } + w.seenCount = len(seen) + return nil +} + +func (w *world) everyProjectSeenOnce() error { + if w.seenCount != w.projN { + return fmt.Errorf("[%s] project paging covered %d, want %d", w.it.driver, w.seenCount, w.projN) + } + return nil +} + +// --- Subscription listing by filter ----------------------------------------- + +func (w *world) listSubscriptionsNoFilter(want int) error { + subRepo := repository.NewSubscriptionRepo(w.it.db) + all, err := subRepo.ListByFilters(w.g.org, nil, nil, nil, nil, 10, 0) + if err != nil { + return fmt.Errorf("[%s] ListByFilters (no filter) failed: %w", w.it.driver, err) + } + if len(all) != want { + return fmt.Errorf("[%s] ListByFilters: want %d subscriptions, got %d", w.it.driver, want, len(all)) + } + return nil +} + +func (w *world) listSubscriptionsByStatus(status string, want int) error { + subRepo := repository.NewSubscriptionRepo(w.it.db) + s := status + got, err := subRepo.ListByFilters(w.g.org, nil, nil, nil, &s, 10, 0) + if err != nil { + return fmt.Errorf("[%s] ListByFilters (status=%s) failed: %w", w.it.driver, status, err) + } + if len(got) != want { + return fmt.Errorf("[%s] ListByFilters(status=%s): want %d, got %d", w.it.driver, status, want, len(got)) + } + return nil +} + +// --- Application lookup ------------------------------------------------------ + +func (w *world) orgProjectApplicationExist() error { + orgRepo := repository.NewOrganizationRepo(w.it.db) + projectRepo := repository.NewProjectRepo(w.it.db) + appRepo := repository.NewApplicationRepo(w.it.db, repository.NewArtifactTableRegistry()) + + org := &model.Organization{ID: id(), Handle: "ap-" + id()[:8], Name: "app org", Region: "us"} + if err := orgRepo.CreateOrganization(org); err != nil { + return fmt.Errorf("[%s] create org failed: %w", w.it.driver, err) + } + projID := id() + projName := "p-" + projID[:6] + proj := &model.Project{ID: projID, Handle: projName, Name: projName, OrganizationID: org.ID} + if err := projectRepo.CreateProject(proj); err != nil { + return fmt.Errorf("[%s] create project failed: %w", w.it.driver, err) + } + app := &model.Application{ + UUID: id(), Handle: "app-" + id()[:8], ProjectUUID: proj.ID, + OrganizationUUID: org.ID, Name: "app", Type: "standard", + } + if err := appRepo.CreateApplication(app); err != nil { + return fmt.Errorf("[%s] create application failed: %w", w.it.driver, err) + } + w.orgID = org.ID + w.appUUID = app.UUID + w.appHandle = app.Handle + return nil +} + +func (w *world) applicationFoundByUUID() error { + appRepo := repository.NewApplicationRepo(w.it.db, repository.NewArtifactTableRegistry()) + byUUID, err := appRepo.GetApplicationByIDOrHandle(w.appUUID, w.orgID) + if err != nil { + return fmt.Errorf("[%s] GetApplicationByIDOrHandle(uuid) failed: %w", w.it.driver, err) + } + if byUUID == nil || byUUID.UUID != w.appUUID { + return fmt.Errorf("[%s] lookup by uuid: want %s, got %+v", w.it.driver, w.appUUID, byUUID) + } + return nil +} + +func (w *world) applicationFoundByHandle() error { + appRepo := repository.NewApplicationRepo(w.it.db, repository.NewArtifactTableRegistry()) + byHandle, err := appRepo.GetApplicationByIDOrHandle(w.appHandle, w.orgID) + if err != nil { + return fmt.Errorf("[%s] GetApplicationByIDOrHandle(handle) failed: %w", w.it.driver, err) + } + if byHandle == nil || byHandle.UUID != w.appUUID { + return fmt.Errorf("[%s] lookup by handle: want %s, got %+v", w.it.driver, w.appUUID, byHandle) + } + return nil +} + +func (w *world) missingApplicationReturnsNil() error { + appRepo := repository.NewApplicationRepo(w.it.db, repository.NewArtifactTableRegistry()) + missing, err := appRepo.GetApplicationByIDOrHandle("does-not-exist-"+id(), w.orgID) + if err != nil { + return fmt.Errorf("[%s] GetApplicationByIDOrHandle(missing) failed: %w", w.it.driver, err) + } + if missing != nil { + return fmt.Errorf("[%s] lookup of missing app: want nil, got %+v", w.it.driver, missing) + } + return nil +} diff --git a/platform-api/src/internal/integration/steps_secret.go b/platform-api/src/internal/integration/steps_secret.go new file mode 100644 index 0000000000..06bf131ffe --- /dev/null +++ b/platform-api/src/internal/integration/steps_secret.go @@ -0,0 +1,233 @@ +//go:build integration + +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package integration + +import ( + "bytes" + "fmt" + + "github.com/cucumber/godog" + + "platform-api/src/internal/model" + "platform-api/src/internal/repository" +) + +// registerSecretSteps wires the secret repository scenario, which verifies the +// encrypted-ciphertext round-trip, the dialect-aware paginated list, rotation +// and the reference-checked soft-delete on every engine. +func registerSecretSteps(ctx *godog.ScenarioContext, w *world) { + ctx.Step(`^I create (\d+) secrets$`, w.createSecrets) + ctx.Step(`^reading the first secret back returns its ciphertext and hash$`, w.firstSecretCipherRoundTrip) + ctx.Step(`^the first secret reports type "([^"]*)" provider "([^"]*)" status "([^"]*)"$`, w.firstSecretMetadata) + ctx.Step(`^checking existence of the first secret returns true$`, w.firstSecretExists) + ctx.Step(`^checking existence of a missing secret returns false$`, w.missingSecretAbsent) + ctx.Step(`^counting secrets returns (\d+)$`, w.countSecrets) + ctx.Step(`^paging secrets (\d+) at a time covers all (\d+) without overlap$`, w.pageSecrets) + ctx.Step(`^I rotate the first secret's ciphertext$`, w.rotateFirstSecret) + ctx.Step(`^reading the first secret back returns the rotated ciphertext$`, w.firstSecretRotated) + ctx.Step(`^I soft-delete the first secret$`, w.softDeleteFirstSecret) + ctx.Step(`^reading the first secret back reports status "([^"]*)"$`, w.firstSecretStatusIs) +} + +func (w *world) createSecrets(n int) error { + repo := repository.NewSecretRepo(w.it.db) + w.secretHandles = w.secretHandles[:0] + for i := range n { + handle := fmt.Sprintf("secret-%d-%s", i, id()[:6]) + cipher := []byte(fmt.Sprintf("cipher-%d-%s", i, id())) + hash := "hash-" + id() + secret := &model.Secret{ + OrganizationID: w.orgID, + Handle: handle, + DisplayName: fmt.Sprintf("Secret %d", i), + Description: "created by integration test", + Ciphertext: cipher, + Hash: hash, + CreatedBy: "it-user", + // A scope row exercises the secret_scopes write (and its FK). + Scopes: []model.SecretScope{{Scope: model.SecretScopeTypeOrg, ScopeValue: w.orgID}}, + } + if err := repo.Create(secret); err != nil { + return fmt.Errorf("[%s] create secret %d failed: %w", w.it.driver, i, err) + } + if i == 0 { + w.firstSecretCipher = cipher + w.firstSecretHash = hash + } + w.secretHandles = append(w.secretHandles, handle) + } + return nil +} + +func (w *world) getFirstSecret() (*model.Secret, error) { + repo := repository.NewSecretRepo(w.it.db) + got, err := repo.GetByHandle(w.orgID, w.secretHandles[0]) + if err != nil { + return nil, fmt.Errorf("[%s] GetByHandle(%s) failed: %w", w.it.driver, w.secretHandles[0], err) + } + return got, nil +} + +func (w *world) firstSecretCipherRoundTrip() error { + got, err := w.getFirstSecret() + if err != nil { + return err + } + if !bytes.Equal(got.Ciphertext, w.firstSecretCipher) { + return fmt.Errorf("[%s] secret ciphertext did not round-trip: want %q, got %q", w.it.driver, w.firstSecretCipher, got.Ciphertext) + } + if got.Hash != w.firstSecretHash { + return fmt.Errorf("[%s] secret hash did not round-trip: want %q, got %q", w.it.driver, w.firstSecretHash, got.Hash) + } + return nil +} + +func (w *world) firstSecretMetadata(secretType, provider, status string) error { + got, err := w.getFirstSecret() + if err != nil { + return err + } + if got.Type != secretType || got.Provider != provider || got.Status != status { + return fmt.Errorf("[%s] secret metadata mismatch: type=%q provider=%q status=%q", + w.it.driver, got.Type, got.Provider, got.Status) + } + return nil +} + +func (w *world) firstSecretExists() error { + repo := repository.NewSecretRepo(w.it.db) + exists, err := repo.Exists(w.orgID, w.secretHandles[0]) + if err != nil { + return fmt.Errorf("[%s] Exists failed: %w", w.it.driver, err) + } + if !exists { + return fmt.Errorf("[%s] Exists: want true for %q", w.it.driver, w.secretHandles[0]) + } + return nil +} + +func (w *world) missingSecretAbsent() error { + repo := repository.NewSecretRepo(w.it.db) + exists, err := repo.Exists(w.orgID, "no-such-"+id()) + if err != nil { + return fmt.Errorf("[%s] Exists(missing) failed: %w", w.it.driver, err) + } + if exists { + return fmt.Errorf("[%s] Exists: want false for a random handle", w.it.driver) + } + return nil +} + +func (w *world) countSecrets(want int) error { + repo := repository.NewSecretRepo(w.it.db) + count, err := repo.Count(w.orgID) + if err != nil { + return fmt.Errorf("[%s] Count failed: %w", w.it.driver, err) + } + if count != want { + return fmt.Errorf("[%s] Count: want %d, got %d", w.it.driver, want, count) + } + return nil +} + +func (w *world) pageSecrets(pageSize, total int) error { + repo := repository.NewSecretRepo(w.it.db) + seen := map[string]bool{} + for offset := 0; offset < total; offset += pageSize { + page, err := repo.List(w.orgID, pageSize, offset, nil) + if err != nil { + return fmt.Errorf("[%s] List(%d,%d) failed: %w", w.it.driver, pageSize, offset, err) + } + want := pageSize + if rem := total - offset; rem < want { + want = rem + } + if len(page) != want { + return fmt.Errorf("[%s] List offset %d: want %d, got %d", w.it.driver, offset, want, len(page)) + } + for _, s := range page { + if seen[s.UUID] { + return fmt.Errorf("[%s] pagination overlap at offset %d: UUID %s seen twice", w.it.driver, offset, s.UUID) + } + seen[s.UUID] = true + } + } + if len(seen) != total { + return fmt.Errorf("[%s] paging covered %d rows, want %d", w.it.driver, len(seen), total) + } + return nil +} + +func (w *world) rotateFirstSecret() error { + repo := repository.NewSecretRepo(w.it.db) + got, err := w.getFirstSecret() + if err != nil { + return err + } + w.rotatedCipher = []byte("rotated-" + id()) + w.rotatedHash = "rotated-hash-" + id() + got.Ciphertext = w.rotatedCipher + got.Hash = w.rotatedHash + got.UpdatedBy = "it-user" + if err := repo.Update(got); err != nil { + return fmt.Errorf("[%s] Update (rotate) failed: %w", w.it.driver, err) + } + return nil +} + +func (w *world) firstSecretRotated() error { + got, err := w.getFirstSecret() + if err != nil { + return err + } + if !bytes.Equal(got.Ciphertext, w.rotatedCipher) { + return fmt.Errorf("[%s] rotated ciphertext did not persist: want %q, got %q", w.it.driver, w.rotatedCipher, got.Ciphertext) + } + if got.Hash != w.rotatedHash { + return fmt.Errorf("[%s] rotated hash did not persist: want %q, got %q", w.it.driver, w.rotatedHash, got.Hash) + } + return nil +} + +func (w *world) softDeleteFirstSecret() error { + repo := repository.NewSecretRepo(w.it.db) + // No artifact references the secret, so soft-delete deprecates it and returns + // no references. + refs, err := repo.FindRefsAndSoftDelete(w.orgID, w.secretHandles[0], "it-user") + if err != nil { + return fmt.Errorf("[%s] FindRefsAndSoftDelete failed: %w", w.it.driver, err) + } + if len(refs) != 0 { + return fmt.Errorf("[%s] FindRefsAndSoftDelete: want no references, got %d", w.it.driver, len(refs)) + } + return nil +} + +func (w *world) firstSecretStatusIs(status string) error { + got, err := w.getFirstSecret() + if err != nil { + return err + } + if got.Status != status { + return fmt.Errorf("[%s] secret status: want %q, got %q", w.it.driver, status, got.Status) + } + return nil +} diff --git a/platform-api/src/internal/integration/suite_test.go b/platform-api/src/internal/integration/suite_test.go new file mode 100644 index 0000000000..27bbc9d93c --- /dev/null +++ b/platform-api/src/internal/integration/suite_test.go @@ -0,0 +1,82 @@ +//go:build integration + +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package integration + +import ( + "context" + "os" + "testing" + + "github.com/cucumber/godog" +) + +func TestMain(m *testing.M) { + // Allow GetConfig() to generate an ephemeral secret_encryption_key so tests + // that exercise subscription_repository.go don't panic at startup. + os.Setenv("APIP_DEMO_MODE", "true") + os.Exit(m.Run()) +} + +// TestFeatures is the go test entry point that runs the godog (Cucumber) suite. +// It is selected by `go test -tags integration ./internal/integration/...` and +// runs every scenario in features/ against the database engine chosen by IT_DB +// (sqlite | postgres | sqlserver), exactly as the Makefile it-* targets do. +func TestFeatures(t *testing.T) { + status := godog.TestSuite{ + Name: "platform-api-cross-db", + ScenarioInitializer: initializeScenario, + Options: &godog.Options{ + Format: "pretty", + Paths: []string{"features"}, + Strict: true, + TestingT: t, + }, + }.Run() + if status != 0 { + t.Fatalf("godog suite failed with status %d", status) + } +} + +// initializeScenario wires a fresh world (and a fresh database) per scenario and +// registers every step group, so scenarios stay independent. +func initializeScenario(ctx *godog.ScenarioContext) { + w := &world{} + + ctx.Before(func(ctx context.Context, sc *godog.Scenario) (context.Context, error) { + *w = world{} + return ctx, nil + }) + ctx.After(func(ctx context.Context, sc *godog.Scenario, err error) (context.Context, error) { + w.close() + return ctx, nil + }) + + // Shared background steps. + ctx.Step(`^a clean platform-api database$`, w.aCleanDatabase) + + registerCRUDSteps(ctx, w) + registerCascadeSteps(ctx, w) + registerPaginationSteps(ctx, w) + registerLLMSteps(ctx, w) + registerMCPSteps(ctx, w) + registerSecretSteps(ctx, w) + registerDeploymentSteps(ctx, w) +} diff --git a/platform-api/src/internal/integration/websub_cascade_test.go b/platform-api/src/internal/integration/websub_cascade_test.go index ecd8e21411..2c570ae481 100644 --- a/platform-api/src/internal/integration/websub_cascade_test.go +++ b/platform-api/src/internal/integration/websub_cascade_test.go @@ -39,35 +39,35 @@ func TestCascade_DeleteWebSubAPIRemovesHmacSecrets(t *testing.T) { projectUUID := id() artifactUUID := id() - it.exec(t, `INSERT INTO organizations (uuid, handle, display_name, region, idp_organization_ref_uuid) VALUES (?, ?, ?, ?, ?)`, + it.execT(t, `INSERT INTO organizations (uuid, handle, display_name, region, idp_organization_ref_uuid) VALUES (?, ?, ?, ?, ?)`, orgUUID, "wsc-"+orgUUID[:8], "cascade org", "us", "idp-ref") - it.exec(t, `INSERT INTO projects (uuid, handle, display_name, organization_uuid) VALUES (?, ?, ?, ?)`, + it.execT(t, `INSERT INTO projects (uuid, handle, display_name, organization_uuid) VALUES (?, ?, ?, ?)`, projectUUID, "cascade-proj", "cascade-proj", orgUUID) - it.exec(t, `INSERT INTO artifacts (uuid, type, organization_uuid) VALUES (?, ?, ?)`, + it.execT(t, `INSERT INTO artifacts (uuid, type, organization_uuid) VALUES (?, ?, ?)`, artifactUUID, "WebSubApi", orgUUID) - it.exec(t, `INSERT INTO websub_apis (uuid, organization_uuid, handle, display_name, version, project_uuid, lifecycle_status, configuration) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + it.execT(t, `INSERT INTO websub_apis (uuid, organization_uuid, handle, display_name, version, project_uuid, lifecycle_status, configuration) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, artifactUUID, orgUUID, "ws-api-"+artifactUUID[:8], "ws-api", "v1.0", projectUUID, "CREATED", []byte("{}")) secret1 := id() secret2 := id() - it.exec(t, `INSERT INTO websub_api_hmac_secrets (uuid, artifact_uuid, handle, encrypted_secret, status) VALUES (?, ?, ?, ?, ?)`, + it.execT(t, `INSERT INTO websub_api_hmac_secrets (uuid, artifact_uuid, handle, encrypted_secret, status) VALUES (?, ?, ?, ?, ?)`, secret1, artifactUUID, "github-secret", []byte("enc1"), "active") - it.exec(t, `INSERT INTO websub_api_hmac_secrets (uuid, artifact_uuid, handle, encrypted_secret, status) VALUES (?, ?, ?, ?, ?)`, + it.execT(t, `INSERT INTO websub_api_hmac_secrets (uuid, artifact_uuid, handle, encrypted_secret, status) VALUES (?, ?, ?, ?, ?)`, secret2, artifactUUID, "gitlab-secret", []byte("enc2"), "active") - if got := it.count(t, "websub_api_hmac_secrets", "artifact_uuid", artifactUUID); got != 2 { + if got := it.countT(t, "websub_api_hmac_secrets", "artifact_uuid", artifactUUID); got != 2 { t.Fatalf("precondition: want 2 hmac secrets, got %d", got) } - if got := it.count(t, "websub_apis", "uuid", artifactUUID); got != 1 { + if got := it.countT(t, "websub_apis", "uuid", artifactUUID); got != 1 { t.Fatalf("precondition: want 1 websub_api row, got %d", got) } - it.exec(t, `DELETE FROM artifacts WHERE uuid = ?`, artifactUUID) + it.execT(t, `DELETE FROM artifacts WHERE uuid = ?`, artifactUUID) - if got := it.count(t, "websub_api_hmac_secrets", "artifact_uuid", artifactUUID); got != 0 { + if got := it.countT(t, "websub_api_hmac_secrets", "artifact_uuid", artifactUUID); got != 0 { t.Fatalf("[%s] hmac secrets not cascade-deleted after artifact delete: %d remain", it.driver, got) } - if got := it.count(t, "websub_apis", "uuid", artifactUUID); got != 0 { + if got := it.countT(t, "websub_apis", "uuid", artifactUUID); got != 0 { t.Fatalf("[%s] websub_api not cascade-deleted after artifact delete: %d remain", it.driver, got) } } diff --git a/platform-api/src/internal/integration/world.go b/platform-api/src/internal/integration/world.go new file mode 100644 index 0000000000..32a84db749 --- /dev/null +++ b/platform-api/src/internal/integration/world.go @@ -0,0 +1,230 @@ +//go:build integration + +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package integration + +import ( + "fmt" + + "platform-api/src/internal/model" + "platform-api/src/internal/repository" +) + +// world holds the state of a single scenario: the database under test plus the +// identifiers and intermediate results threaded between its Given/When/Then +// steps. A fresh world (and a fresh database) is created per scenario so the +// scenarios stay independent. +type world struct { + it *itDB + + // Parent graph seeded through the real repositories. + orgID string + projID string + + // Raw object graph (cascade scenarios), seeded via direct INSERTs. + g graph + + // REST API CRUD scenario. + createdAPIIDs []string + firstAPI *model.API + + // Gateway / token scenario. + gatewayIDs []string + firstGatewayID string + gwToken *model.GatewayToken + tokenHash string + + // API key scenario. + artifactUUID string + + // Pagination scenarios. + orgTotal int + projN int + seenCount int + + // Subscription plan scenario. + throttle int + throttleUnit string + listedPlanID string + + // Application lookup scenario. + appUUID string + appHandle string + + // LLM control-plane scenarios. + templateUUID string + templateHandle string + providerUUID string + providerHandle string + proxyHandle string + + // MCP control-plane scenario. + mcpHandle string + + // Secret scenario. + secretHandles []string + firstSecretCipher []byte + firstSecretHash string + rotatedCipher []byte + rotatedHash string + + // Deployment scenario. + depArtifactID string + depGatewayID string + depIDs []string + lastDepContent []byte +} + +// graph holds the identifiers seeded into a single organization for the cascade +// scenarios. It touches every table whose foreign keys were changed for SQL +// Server (applications, subscriptions, deployments, deployment_status, +// publication_mappings) plus their parents. +type graph struct { + org, project, app string + apiArtifact, depArtifact string + plan, sub, gateway, deploy string + apiKey string + planLimit string +} + +// openDB opens a fresh database for the scenario. Mirrors the "Given a clean +// platform-api database" background step. +func (w *world) openDB() error { + it, err := openITDB() + if err != nil { + return err + } + w.it = it + return nil +} + +func (w *world) close() { + if w.it != nil { + w.it.cleanup() + w.it = nil + } +} + +// seedOrgProject creates one organization and one project through the real +// repositories. It is the lightweight parent graph for the CRUD scenarios +// (REST APIs, gateways and API keys all hang off these). +func (w *world) seedOrgProject(prefix string) error { + orgRepo := repository.NewOrganizationRepo(w.it.db) + projectRepo := repository.NewProjectRepo(w.it.db) + + org := &model.Organization{ID: id(), Handle: prefix + "-" + id()[:8], Name: prefix + " org", Region: "us"} + if err := orgRepo.CreateOrganization(org); err != nil { + return fmt.Errorf("[%s] create org failed: %w", w.it.driver, err) + } + projID := id() + pName := prefix + "-proj-" + projID[:6] + proj := &model.Project{ID: projID, Handle: pName, Name: pName, OrganizationID: org.ID, Description: "p"} + if err := projectRepo.CreateProject(proj); err != nil { + return fmt.Errorf("[%s] create project failed: %w", w.it.driver, err) + } + w.orgID, w.projID = org.ID, projID + return nil +} + +// seedOrgGraph inserts a representative object graph for one organization that +// touches every table whose foreign keys were changed for SQL Server plus their +// parents, via direct INSERTs (so the cascade behavior, not the repository code, +// is what is under test). +func (w *world) seedOrgGraph() error { + g := graph{ + org: id(), project: id(), app: id(), + apiArtifact: id(), depArtifact: id(), + plan: id(), sub: id(), gateway: id(), deploy: id(), + apiKey: id(), + planLimit: id(), + } + + stmts := []struct { + query string + args []any + }{ + {`INSERT INTO organizations (uuid, handle, display_name, region, idp_organization_ref_uuid) VALUES (?, ?, ?, ?, ?)`, + []any{g.org, "h-" + g.org[:8], "it org", "us", "idp-ref"}}, + {`INSERT INTO projects (uuid, handle, display_name, organization_uuid) VALUES (?, ?, ?, ?)`, + []any{g.project, "proj", "proj", g.org}}, + {`INSERT INTO applications (uuid, handle, project_uuid, organization_uuid, display_name, type) VALUES (?, ?, ?, ?, ?, ?)`, + []any{g.app, "app-" + g.app[:8], g.project, g.org, "app", "standard"}}, + // REST API: an artifact + its rest_apis row (shared uuid). + {`INSERT INTO artifacts (uuid, type, organization_uuid) VALUES (?, ?, ?)`, + []any{g.apiArtifact, "rest_api", g.org}}, + {`INSERT INTO rest_apis (uuid, organization_uuid, handle, display_name, version, project_uuid, lifecycle_status, configuration) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + []any{g.apiArtifact, g.org, "api-" + g.apiArtifact[:8], "api", "v1.0", g.project, "CREATED", []byte("{}")}}, + {`INSERT INTO subscription_plans (uuid, handle, display_name, organization_uuid) VALUES (?, ?, ?, ?)`, + []any{g.plan, "plan-" + g.plan[:8], "Plan " + g.plan[:8], g.org}}, + {`INSERT INTO subscription_plan_limits (uuid, subscription_plan_uuid, limit_type, limit_count, time_unit) VALUES (?, ?, ?, ?, ?)`, + []any{g.planLimit, g.plan, "REQUEST_COUNT", 100, "MINUTE"}}, + {`INSERT INTO subscriptions (uuid, artifact_uuid, subscriber_id, subscription_token, subscription_token_hash, subscription_plan_uuid, organization_uuid) VALUES (?, ?, ?, ?, ?, ?, ?)`, + []any{g.sub, g.apiArtifact, "subscriber", "tok-" + g.sub[:8], "hash-" + g.sub[:8], g.plan, g.org}}, + // Gateway + a deployment + its current status. + {`INSERT INTO gateways (uuid, organization_uuid, handle, display_name, properties) VALUES (?, ?, ?, ?, ?)`, + []any{g.gateway, g.org, "gw-" + g.gateway[:8], "gw", []byte("{}")}}, + {`INSERT INTO artifacts (uuid, type, organization_uuid) VALUES (?, ?, ?)`, + []any{g.depArtifact, "rest_api", g.org}}, + {`INSERT INTO deployments (uuid, display_name, artifact_uuid, organization_uuid, gateway_uuid, content) VALUES (?, ?, ?, ?, ?, ?)`, + []any{g.deploy, "d", g.depArtifact, g.org, g.gateway, []byte("x")}}, + {`INSERT INTO deployment_status (artifact_uuid, organization_uuid, gateway_uuid, deployment_uuid) VALUES (?, ?, ?, ?)`, + []any{g.depArtifact, g.org, g.gateway, g.deploy}}, + // An API key on the deployment artifact + its application mapping. + {`INSERT INTO api_keys (uuid, artifact_uuid, handle, display_name, masked_api_key, api_key_hashes) VALUES (?, ?, ?, ?, ?, ?)`, + []any{g.apiKey, g.depArtifact, "key", "key", "ab12", []byte("{}")}}, + {`INSERT INTO application_api_key_mappings (application_uuid, api_key_id) VALUES (?, ?)`, + []any{g.app, g.apiKey}}, + {`INSERT INTO application_artifact_mappings (application_uuid, artifact_uuid) VALUES (?, ?)`, + []any{g.app, g.depArtifact}}, + } + for _, s := range stmts { + if err := w.it.exec(s.query, s.args...); err != nil { + return err + } + } + w.g = g + return nil +} + +// --- shared step helpers --------------------------------------------------- + +func (w *world) aCleanDatabase() error { + return w.openDB() +} + +func (w *world) anOrgAndProjectExist() error { + return w.seedOrgProject("api") +} + +func (w *world) aSeededObjectGraph() error { + return w.seedOrgGraph() +} + +// wantCount fails unless the table has the expected number of matching rows. +func (w *world) wantCount(table, col, val string, want int) error { + got, err := w.it.count(table, col, val) + if err != nil { + return err + } + if got != want { + return fmt.Errorf("[%s] %s where %s=%s: want %d rows, got %d", w.it.driver, table, col, val, want, got) + } + return nil +} diff --git a/platform-api/src/internal/model/api.go b/platform-api/src/internal/model/api.go index b6fb4a0737..7eab8c5038 100644 --- a/platform-api/src/internal/model/api.go +++ b/platform-api/src/internal/model/api.go @@ -23,21 +23,21 @@ import ( // API represents an API entity in the platform type API struct { - ID string `json:"id" db:"uuid"` - Handle string `json:"handle" db:"handle"` - Name string `json:"displayName" db:"display_name"` - Kind string `json:"kind" db:"kind"` - Description string `json:"description,omitempty" db:"description"` - Version string `json:"version" db:"version"` - CreatedBy string `json:"createdBy,omitempty" db:"created_by"` - UpdatedBy string `json:"updatedBy,omitempty" db:"updated_by"` - ProjectID string `json:"projectId" db:"project_uuid"` // FK to Project.ID - OrganizationID string `json:"organizationId" db:"organization_uuid"` // FK to Organization.ID - CreatedAt time.Time `json:"createdAt,omitempty" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt,omitempty" db:"updated_at"` - LifeCycleStatus string `json:"lifeCycleStatus,omitempty" db:"lifecycle_status"` - Channels []Channel `json:"channels,omitempty"` - Configuration RestAPIConfig `json:"configuration" db:"-"` + ID string `json:"id" db:"uuid"` + Handle string `json:"handle" db:"handle"` + Name string `json:"displayName" db:"display_name"` + Kind string `json:"kind" db:"kind"` + Description string `json:"description,omitempty" db:"description"` + Version string `json:"version" db:"version"` + CreatedBy string `json:"createdBy,omitempty" db:"created_by"` + UpdatedBy string `json:"updatedBy,omitempty" db:"updated_by"` + ProjectID string `json:"projectId" db:"project_uuid"` // FK to Project.ID + OrganizationID string `json:"organizationId" db:"organization_uuid"` // FK to Organization.ID + CreatedAt time.Time `json:"createdAt,omitempty" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt,omitempty" db:"updated_at"` + LifeCycleStatus string `json:"lifeCycleStatus,omitempty" db:"lifecycle_status"` + Channels []Channel `json:"channels,omitempty"` + Configuration RestAPIConfig `json:"configuration" db:"-"` Origin string `json:"origin,omitempty" db:"origin"` } diff --git a/platform-api/src/internal/model/artifact.go b/platform-api/src/internal/model/artifact.go index fcf8344b6a..cbbbf5a8cf 100644 --- a/platform-api/src/internal/model/artifact.go +++ b/platform-api/src/internal/model/artifact.go @@ -20,15 +20,15 @@ package model import "time" type Artifact struct { - UUID string `db:"uuid"` - Type string `db:"type"` - OrganizationUUID string `db:"organization_uuid"` + UUID string `db:"uuid"` + Type string `db:"type"` + OrganizationUUID string `db:"organization_uuid"` // Supplemental fields: populated by UNION queries across kind-specific tables, not stored in artifacts table. - Handle string `db:"handle"` - Name string `db:"display_name"` - Version string `db:"version"` - Kind string `db:"kind"` - Origin string `db:"origin"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + Handle string `db:"handle"` + Name string `db:"display_name"` + Version string `db:"version"` + Kind string `db:"kind"` + Origin string `db:"origin"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } diff --git a/platform-api/src/internal/model/llm.go b/platform-api/src/internal/model/llm.go index 1dcd57b196..ebcd20d784 100644 --- a/platform-api/src/internal/model/llm.go +++ b/platform-api/src/internal/model/llm.go @@ -169,25 +169,25 @@ type AssociatedGatewayMapping struct { } type LLMProviderTemplate struct { - UUID string `json:"uuid" db:"uuid"` - OrganizationUUID string `json:"organizationId" db:"organization_uuid"` - ID string `json:"id" db:"handle"` - GroupID string `json:"groupId,omitempty" db:"group_id"` - Name string `json:"displayName" db:"display_name"` - Description string `json:"description,omitempty" db:"description"` - ManagedBy string `json:"managedBy,omitempty" db:"managed_by"` - CreatedBy string `json:"createdBy,omitempty" db:"created_by"` - UpdatedBy string `json:"updatedBy,omitempty" db:"updated_by"` - Version string `json:"version" db:"version"` - IsLatest bool `json:"isLatest" db:"is_latest"` - Enabled bool `json:"enabled" db:"enabled"` - Metadata *LLMProviderTemplateMetadata `json:"metadata,omitempty" db:"-"` - PromptTokens *ExtractionIdentifier `json:"promptTokens,omitempty" db:"-"` - CompletionTokens *ExtractionIdentifier `json:"completionTokens,omitempty" db:"-"` - TotalTokens *ExtractionIdentifier `json:"totalTokens,omitempty" db:"-"` - RemainingTokens *ExtractionIdentifier `json:"remainingTokens,omitempty" db:"-"` - RequestModel *ExtractionIdentifier `json:"requestModel,omitempty" db:"-"` - ResponseModel *ExtractionIdentifier `json:"responseModel,omitempty" db:"-"` + UUID string `json:"uuid" db:"uuid"` + OrganizationUUID string `json:"organizationId" db:"organization_uuid"` + ID string `json:"id" db:"handle"` + GroupID string `json:"groupId,omitempty" db:"group_id"` + Name string `json:"displayName" db:"display_name"` + Description string `json:"description,omitempty" db:"description"` + ManagedBy string `json:"managedBy,omitempty" db:"managed_by"` + CreatedBy string `json:"createdBy,omitempty" db:"created_by"` + UpdatedBy string `json:"updatedBy,omitempty" db:"updated_by"` + Version string `json:"version" db:"version"` + IsLatest bool `json:"isLatest" db:"is_latest"` + Enabled bool `json:"enabled" db:"enabled"` + Metadata *LLMProviderTemplateMetadata `json:"metadata,omitempty" db:"-"` + PromptTokens *ExtractionIdentifier `json:"promptTokens,omitempty" db:"-"` + CompletionTokens *ExtractionIdentifier `json:"completionTokens,omitempty" db:"-"` + TotalTokens *ExtractionIdentifier `json:"totalTokens,omitempty" db:"-"` + RemainingTokens *ExtractionIdentifier `json:"remainingTokens,omitempty" db:"-"` + RequestModel *ExtractionIdentifier `json:"requestModel,omitempty" db:"-"` + ResponseModel *ExtractionIdentifier `json:"responseModel,omitempty" db:"-"` ResourceMappings *LLMProviderTemplateResourceMappings `json:"resourceMappings,omitempty" db:"-"` OpenAPISpec string `json:"openapi,omitempty" db:"openapi_spec"` Origin string `json:"origin,omitempty" db:"origin"` @@ -197,23 +197,23 @@ type LLMProviderTemplate struct { // LLMProvider represents an LLM provider entity type LLMProvider struct { - UUID string `json:"uuid" db:"uuid"` - OrganizationUUID string `json:"organizationId" db:"organization_uuid"` - ID string `json:"id" db:"handle"` - Name string `json:"displayName" db:"display_name"` - Description string `json:"description,omitempty" db:"description"` - CreatedBy string `json:"createdBy,omitempty" db:"created_by"` - UpdatedBy string `json:"updatedBy,omitempty" db:"updated_by"` - Version string `json:"version" db:"version"` - TemplateUUID string `json:"templateUuid" db:"template_uuid"` - OpenAPISpec string `json:"openapi,omitempty" db:"openapi_spec"` - ModelProviders []LLMModelProvider `json:"modelProviders,omitempty" db:"-"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` - Configuration LLMProviderConfig `json:"configuration" db:"configuration"` - Origin string `json:"origin,omitempty" db:"origin"` - AssociatedGateways []AssociatedGatewayMapping `json:"-" db:"-"` - ReplaceAssociatedGateways bool `json:"-" db:"-"` + UUID string `json:"uuid" db:"uuid"` + OrganizationUUID string `json:"organizationId" db:"organization_uuid"` + ID string `json:"id" db:"handle"` + Name string `json:"displayName" db:"display_name"` + Description string `json:"description,omitempty" db:"description"` + CreatedBy string `json:"createdBy,omitempty" db:"created_by"` + UpdatedBy string `json:"updatedBy,omitempty" db:"updated_by"` + Version string `json:"version" db:"version"` + TemplateUUID string `json:"templateUuid" db:"template_uuid"` + OpenAPISpec string `json:"openapi,omitempty" db:"openapi_spec"` + ModelProviders []LLMModelProvider `json:"modelProviders,omitempty" db:"-"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` + Configuration LLMProviderConfig `json:"configuration" db:"configuration"` + Origin string `json:"origin,omitempty" db:"origin"` + AssociatedGateways []AssociatedGatewayMapping `json:"-" db:"-"` + ReplaceAssociatedGateways bool `json:"-" db:"-"` } type LLMProviderConfig struct { @@ -233,23 +233,23 @@ type LLMProviderConfig struct { // LLMProxy represents an LLM proxy entity type LLMProxy struct { - UUID string `json:"uuid" db:"uuid"` - OrganizationUUID string `json:"organizationId" db:"organization_uuid"` - ID string `json:"id" db:"handle"` - Name string `json:"displayName" db:"display_name"` - ProjectUUID string `json:"projectId" db:"project_uuid"` - Description string `json:"description,omitempty" db:"description"` - CreatedBy string `json:"createdBy,omitempty" db:"created_by"` - UpdatedBy string `json:"updatedBy,omitempty" db:"updated_by"` - Version string `json:"version" db:"version"` - ProviderUUID string `json:"providerUuid" db:"provider_uuid"` - OpenAPISpec string `json:"openapi,omitempty" db:"openapi_spec"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` - Configuration LLMProxyConfig `json:"configuration" db:"configuration"` - Origin string `json:"origin,omitempty" db:"origin"` - AssociatedGateways []AssociatedGatewayMapping `json:"-" db:"-"` - ReplaceAssociatedGateways bool `json:"-" db:"-"` + UUID string `json:"uuid" db:"uuid"` + OrganizationUUID string `json:"organizationId" db:"organization_uuid"` + ID string `json:"id" db:"handle"` + Name string `json:"displayName" db:"display_name"` + ProjectUUID string `json:"projectId" db:"project_uuid"` + Description string `json:"description,omitempty" db:"description"` + CreatedBy string `json:"createdBy,omitempty" db:"created_by"` + UpdatedBy string `json:"updatedBy,omitempty" db:"updated_by"` + Version string `json:"version" db:"version"` + ProviderUUID string `json:"providerUuid" db:"provider_uuid"` + OpenAPISpec string `json:"openapi,omitempty" db:"openapi_spec"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` + Configuration LLMProxyConfig `json:"configuration" db:"configuration"` + Origin string `json:"origin,omitempty" db:"origin"` + AssociatedGateways []AssociatedGatewayMapping `json:"-" db:"-"` + ReplaceAssociatedGateways bool `json:"-" db:"-"` } type LLMProxyConfig struct { diff --git a/platform-api/src/internal/model/mcp.go b/platform-api/src/internal/model/mcp.go index c4356d0c8d..22a31cfacf 100644 --- a/platform-api/src/internal/model/mcp.go +++ b/platform-api/src/internal/model/mcp.go @@ -22,21 +22,21 @@ package model import "time" type MCPProxy struct { - UUID string `json:"uuid" db:"-"` - Handle string `json:"id" db:"-"` - OrganizationUUID string `json:"organizationId" db:"-"` - ProjectUUID *string `json:"projectId" db:"-"` - Name string `json:"name" db:"-"` - Description string `json:"description,omitempty" db:"-"` - CreatedBy string `json:"createdBy,omitempty" db:"created_by"` - UpdatedBy string `json:"updatedBy,omitempty" db:"updated_by"` - Version string `json:"version" db:"-"` - CreatedAt time.Time `json:"createdAt" db:"-"` - UpdatedAt time.Time `json:"updatedAt" db:"-"` - Configuration MCPProxyConfiguration `json:"configuration" db:"-"` - Origin string `json:"origin,omitempty" db:"origin"` - AssociatedGateways []AssociatedGatewayMapping `json:"-" db:"-"` - ReplaceAssociatedGateways bool `json:"-" db:"-"` + UUID string `json:"uuid" db:"-"` + Handle string `json:"id" db:"-"` + OrganizationUUID string `json:"organizationId" db:"-"` + ProjectUUID *string `json:"projectId" db:"-"` + Name string `json:"name" db:"-"` + Description string `json:"description,omitempty" db:"-"` + CreatedBy string `json:"createdBy,omitempty" db:"created_by"` + UpdatedBy string `json:"updatedBy,omitempty" db:"updated_by"` + Version string `json:"version" db:"-"` + CreatedAt time.Time `json:"createdAt" db:"-"` + UpdatedAt time.Time `json:"updatedAt" db:"-"` + Configuration MCPProxyConfiguration `json:"configuration" db:"-"` + Origin string `json:"origin,omitempty" db:"origin"` + AssociatedGateways []AssociatedGatewayMapping `json:"-" db:"-"` + ReplaceAssociatedGateways bool `json:"-" db:"-"` } type MCPProxyConfiguration struct { diff --git a/platform-api/src/internal/repository/api_test.go b/platform-api/src/internal/repository/api_test.go index 674b9b9586..d5b370aa05 100644 --- a/platform-api/src/internal/repository/api_test.go +++ b/platform-api/src/internal/repository/api_test.go @@ -513,4 +513,3 @@ func TestAPIRepo_CreateAndRead_FullConfiguration(t *testing.T) { t.Fatalf("Full configuration mismatch. expected=%+v actual=%+v", api.Configuration, created.Configuration) } } - diff --git a/platform-api/src/internal/repository/deployment.go b/platform-api/src/internal/repository/deployment.go index 00c09c6627..3fe80c3d9f 100644 --- a/platform-api/src/internal/repository/deployment.go +++ b/platform-api/src/internal/repository/deployment.go @@ -814,7 +814,7 @@ func (r *DeploymentRepo) GetControlPlaneDeploymentsByGateway(gatewayID, orgUUID FROM deployment_status s INNER JOIN artifacts a ON s.artifact_uuid = a.uuid INNER JOIN ( - `+r.reg.UnionAllSelect("uuid", "handle", "origin")+` + ` + r.reg.UnionAllSelect("uuid", "handle", "origin") + ` ) src ON src.uuid = s.artifact_uuid WHERE s.gateway_uuid = ? AND s.organization_uuid = ? AND src.origin <> 'gateway_api'` diff --git a/platform-api/src/internal/repository/secret.go b/platform-api/src/internal/repository/secret.go index bde3492108..7522d90773 100644 --- a/platform-api/src/internal/repository/secret.go +++ b/platform-api/src/internal/repository/secret.go @@ -132,6 +132,8 @@ func (r *SecretRepo) GetByHandle(orgID, handle string) (*model.Secret, error) { } func (r *SecretRepo) List(orgID string, limit, offset int, updatedAfter *time.Time) ([]*model.Secret, error) { + // SQL Server has no LIMIT keyword; PaginationClause yields the dialect's + // row-limiting clause (and its args in the order it expects them). pageClause, pageArgs := r.db.PaginationClause(limit, offset) var ( query string @@ -244,7 +246,9 @@ func (r *SecretRepo) FindRefsAndSoftDelete(orgID, handle, updatedBy string) ([]m if r.db.Driver() == "postgres" || r.db.Driver() == "postgresql" { lockQuery = `SELECT uuid FROM secrets WHERE organization_uuid = $1 AND handle = $2 LIMIT 1 FOR UPDATE` } else { - lockQuery = r.db.Rebind(`SELECT uuid FROM secrets WHERE organization_uuid = ? AND handle = ? LIMIT 1`) + // SQL Server rejects LIMIT; FetchFirstClause yields the dialect's + // fixed-row clause (it needs an ORDER BY, hence ORDER BY (SELECT NULL)). + lockQuery = r.db.Rebind(`SELECT uuid FROM secrets WHERE organization_uuid = ? AND handle = ? ORDER BY (SELECT NULL) ` + r.db.FetchFirstClause(1)) } var lockedID string if err := tx.QueryRow(lockQuery, orgID, handle).Scan(&lockedID); err != nil { diff --git a/platform-api/src/internal/repository/secret_test.go b/platform-api/src/internal/repository/secret_test.go index cb5a2280f7..0374e79770 100644 --- a/platform-api/src/internal/repository/secret_test.go +++ b/platform-api/src/internal/repository/secret_test.go @@ -27,7 +27,9 @@ import ( // ---- helpers ---------------------------------------------------------------- -func createTestSecret(t *testing.T, db interface{ Exec(string, ...interface{}) (interface{ RowsAffected() (int64, error) }, error) }, orgID, handle string) { +func createTestSecret(t *testing.T, db interface { + Exec(string, ...interface{}) (interface{ RowsAffected() (int64, error) }, error) +}, orgID, handle string) { t.Helper() } @@ -231,7 +233,7 @@ func TestSecretRepo_List_Pagination(t *testing.T) { for i := 0; i < 5; i++ { s := &model.Secret{ OrganizationID: orgID, - Handle: string(rune('a' + i)) + "-secret", + Handle: string(rune('a'+i)) + "-secret", Ciphertext: []byte("ct"), Hash: "h", CreatedBy: "u", diff --git a/platform-api/src/internal/service/artifact_cross_origin_test.go b/platform-api/src/internal/service/artifact_cross_origin_test.go index 9d0f13f6b6..8e948de573 100644 --- a/platform-api/src/internal/service/artifact_cross_origin_test.go +++ b/platform-api/src/internal/service/artifact_cross_origin_test.go @@ -276,7 +276,7 @@ func TestCPProviderFromDPTemplate(t *testing.T) { created, err := providerSvc.Create(importTestOrgID, "tester", &api.LLMProvider{ Id: strPointer("cp-provider"), - DisplayName: "CP Provider", + DisplayName: "CP Provider", Version: "v1.0", Template: templateHandle, // references the DP template Upstream: api.Upstream{Main: api.UpstreamDefinition{Url: strPointer("https://api.openai.com")}}, @@ -318,11 +318,11 @@ func TestCPProxyFromDPProvider(t *testing.T) { ) created, err := proxySvc.Create(importTestOrgID, "tester", &api.LLMProxy{ - Id: strPointer("cp-proxy"), - DisplayName: "CP Proxy", - Version: "v1.0", - ProjectId: "default", // project handle (setupImportTest inserts handle "default") - Provider: api.LLMProxyProvider{Id: providerHandle}, // references the DP provider + Id: strPointer("cp-proxy"), + DisplayName: "CP Proxy", + Version: "v1.0", + ProjectId: "default", // project handle (setupImportTest inserts handle "default") + Provider: api.LLMProxyProvider{Id: providerHandle}, // references the DP provider }) if err != nil { t.Fatalf("create CP proxy from DP provider: %v", err) diff --git a/platform-api/src/internal/service/artifact_import_lifecycle_test.go b/platform-api/src/internal/service/artifact_import_lifecycle_test.go index d054632aa9..73da7f73d2 100644 --- a/platform-api/src/internal/service/artifact_import_lifecycle_test.go +++ b/platform-api/src/internal/service/artifact_import_lifecycle_test.go @@ -986,4 +986,3 @@ func TestCPSideGuard_DPOriginUpdate(t *testing.T) { } }) } - diff --git a/platform-api/src/internal/service/custom_policy_test.go b/platform-api/src/internal/service/custom_policy_test.go index 0d3c350f6d..795743ec30 100644 --- a/platform-api/src/internal/service/custom_policy_test.go +++ b/platform-api/src/internal/service/custom_policy_test.go @@ -34,15 +34,15 @@ import ( type mockGatewayRepoForPolicy struct { repository.GatewayRepository - gateway *model.Gateway - gatewayErr error - manifest []byte - manifestErr error + gateway *model.Gateway + gatewayErr error + manifest []byte + manifestErr error // call tracking - getByUUIDCalled bool - getManifestCalled bool - lastGatewayID string + getByUUIDCalled bool + getManifestCalled bool + lastGatewayID string } func (m *mockGatewayRepoForPolicy) GetByUUID(gatewayID string) (*model.Gateway, error) { @@ -72,10 +72,10 @@ type mockCustomPolicyRepo struct { deleteIfUnusedErr error countUsages int countUsagesErr error - insertCalled bool - updateCalled bool - updateOldVersion string - deleteIfUnusedCalled bool + insertCalled bool + updateCalled bool + updateOldVersion string + deleteIfUnusedCalled bool } func (m *mockCustomPolicyRepo) InsertCustomPolicy(policy *model.CustomPolicy) error { @@ -166,31 +166,31 @@ func newTestGatewayService(gwRepo repository.GatewayRepository, cpRepo repositor func TestSyncCustomPolicy(t *testing.T) { const ( - orgID = "org-uuid-0001" - gwID = "gw-uuid-0001" - otherOrg = "org-uuid-OTHER" + orgID = "org-uuid-0001" + gwID = "gw-uuid-0001" + otherOrg = "org-uuid-OTHER" policyUUID = "pol-uuid-0001" ) tests := []struct { - name string - gateway *model.Gateway - gatewayErr error - manifest []byte - manifestErr error + name string + gateway *model.Gateway + gatewayErr error + manifest []byte + manifestErr error existingPolicies []*model.CustomPolicy existingPoliciesErr error insertErr error updateErr error - persistedPolicy *model.CustomPolicy - policyName string - version string - wantErr bool - errContains string - wantInsert bool - wantUpdate bool + persistedPolicy *model.CustomPolicy + policyName string + version string + wantErr bool + errContains string + wantInsert bool + wantUpdate bool }{ - // gateway validation + // gateway validation { name: "gateway not found - repo error", gatewayErr: errors.New("db error"), @@ -208,11 +208,11 @@ func TestSyncCustomPolicy(t *testing.T) { errContains: "gateway not found", }, { - name: "gateway belongs to different org", - gateway: &model.Gateway{ID: gwID, OrganizationID: otherOrg}, - policyName: "rate-limit", - version: "1.0.0", - wantErr: true, + name: "gateway belongs to different org", + gateway: &model.Gateway{ID: gwID, OrganizationID: otherOrg}, + policyName: "rate-limit", + version: "1.0.0", + wantErr: true, errContains: "gateway not found", }, @@ -271,8 +271,8 @@ func TestSyncCustomPolicy(t *testing.T) { // version conflict rules { - name: "exact same version already exists", - gateway: &model.Gateway{ID: gwID, OrganizationID: orgID}, + name: "exact same version already exists", + gateway: &model.Gateway{ID: gwID, OrganizationID: orgID}, manifest: sampleManifest("rate-limit", "1.2.0"), existingPolicies: []*model.CustomPolicy{ makeCustomPolicy(policyUUID, orgID, "rate-limit", "1.2.0"), @@ -283,8 +283,8 @@ func TestSyncCustomPolicy(t *testing.T) { errContains: "already exists", }, { - name: "patch version update is not allowed", - gateway: &model.Gateway{ID: gwID, OrganizationID: orgID}, + name: "patch version update is not allowed", + gateway: &model.Gateway{ID: gwID, OrganizationID: orgID}, manifest: sampleManifest("rate-limit", "1.2.1"), existingPolicies: []*model.CustomPolicy{ makeCustomPolicy(policyUUID, orgID, "rate-limit", "1.2.0"), @@ -295,8 +295,8 @@ func TestSyncCustomPolicy(t *testing.T) { errContains: "patch version updates are not allowed", }, { - name: "downgrade is not allowed", - gateway: &model.Gateway{ID: gwID, OrganizationID: orgID}, + name: "downgrade is not allowed", + gateway: &model.Gateway{ID: gwID, OrganizationID: orgID}, manifest: sampleManifest("rate-limit", "1.1.0"), existingPolicies: []*model.CustomPolicy{ makeCustomPolicy(policyUUID, orgID, "rate-limit", "1.3.0"), @@ -321,8 +321,8 @@ func TestSyncCustomPolicy(t *testing.T) { wantUpdate: false, }, { - name: "minor version bump - existing record updated", - gateway: &model.Gateway{ID: gwID, OrganizationID: orgID}, + name: "minor version bump - existing record updated", + gateway: &model.Gateway{ID: gwID, OrganizationID: orgID}, manifest: sampleManifest("rate-limit", "1.3.0"), existingPolicies: []*model.CustomPolicy{ makeCustomPolicy(policyUUID, orgID, "rate-limit", "1.2.0"), @@ -335,8 +335,8 @@ func TestSyncCustomPolicy(t *testing.T) { wantUpdate: true, }, { - name: "new major version - separate record inserted", - gateway: &model.Gateway{ID: gwID, OrganizationID: orgID}, + name: "new major version - separate record inserted", + gateway: &model.Gateway{ID: gwID, OrganizationID: orgID}, manifest: sampleManifest("rate-limit", "2.0.0"), existingPolicies: []*model.CustomPolicy{ makeCustomPolicy(policyUUID, orgID, "rate-limit", "1.5.0"), @@ -349,9 +349,9 @@ func TestSyncCustomPolicy(t *testing.T) { wantUpdate: false, }, { - name: "policy name normalised to lowercase before lookup", - gateway: &model.Gateway{ID: gwID, OrganizationID: orgID}, - manifest: sampleManifest("rate-limit", "1.0.0"), + name: "policy name normalised to lowercase before lookup", + gateway: &model.Gateway{ID: gwID, OrganizationID: orgID}, + manifest: sampleManifest("rate-limit", "1.0.0"), existingPolicies: []*model.CustomPolicy{}, persistedPolicy: makeCustomPolicy(policyUUID, orgID, "rate-limit", "1.0.0"), policyName: "Rate-Limit", @@ -360,8 +360,8 @@ func TestSyncCustomPolicy(t *testing.T) { wantInsert: true, }, { - name: "minor version update preserves the existing UUID", - gateway: &model.Gateway{ID: gwID, OrganizationID: orgID}, + name: "minor version update preserves the existing UUID", + gateway: &model.Gateway{ID: gwID, OrganizationID: orgID}, manifest: sampleManifest("auth-policy", "1.2.0"), existingPolicies: []*model.CustomPolicy{ makeCustomPolicy("stable-uuid", orgID, "auth-policy", "1.1.0"), @@ -383,10 +383,10 @@ func TestSyncCustomPolicy(t *testing.T) { manifestErr: tt.manifestErr, } cpRepo := &mockCustomPolicyRepo{ - getPoliciesByName: tt.existingPolicies, - getPoliciesByNameErr: tt.existingPoliciesErr, - insertErr: tt.insertErr, - updateErr: tt.updateErr, + getPoliciesByName: tt.existingPolicies, + getPoliciesByNameErr: tt.existingPoliciesErr, + insertErr: tt.insertErr, + updateErr: tt.updateErr, getPolicyByNameVersion: tt.persistedPolicy, } @@ -470,10 +470,10 @@ func TestGetCustomPolicyByUUID(t *testing.T) { expectedErr error }{ { - name: "policy found with matching version", - repoPolicy: makeCustomPolicy(policyUUID, orgID, "rate-limit", "1.0.0"), - version: "1.0.0", - wantErr: false, + name: "policy found with matching version", + repoPolicy: makeCustomPolicy(policyUUID, orgID, "rate-limit", "1.0.0"), + version: "1.0.0", + wantErr: false, }, { name: "policy not found - nil from repo", @@ -483,10 +483,10 @@ func TestGetCustomPolicyByUUID(t *testing.T) { expectedErr: constants.ErrCustomPolicyNotFound, }, { - name: "repo returns error", - repoErr: errors.New("db failure"), - version: "1.0.0", - wantErr: true, + name: "repo returns error", + repoErr: errors.New("db failure"), + version: "1.0.0", + wantErr: true, }, { name: "version mismatch", @@ -564,10 +564,10 @@ func TestDeleteCustomPolicyByUUID(t *testing.T) { expectedErr: constants.ErrCustomPolicyNotFound, }, { - name: "repo returns error on lookup", - repoErr: errors.New("db failure"), - version: "1.0.0", - wantErr: true, + name: "repo returns error on lookup", + repoErr: errors.New("db failure"), + version: "1.0.0", + wantErr: true, }, { name: "version mismatch", @@ -621,11 +621,11 @@ func TestListCustomPolicies(t *testing.T) { const orgID = "org-uuid-0001" tests := []struct { - name string + name string repoPolicies []*model.CustomPolicy - repoErr error - wantCount int - wantErr bool + repoErr error + wantCount int + wantErr bool }{ { name: "returns all policies for org", diff --git a/platform-api/src/internal/service/llm_provider_template_test.go b/platform-api/src/internal/service/llm_provider_template_test.go index bd01873836..78dcc3a541 100644 --- a/platform-api/src/internal/service/llm_provider_template_test.go +++ b/platform-api/src/internal/service/llm_provider_template_test.go @@ -171,8 +171,8 @@ func (m *mockLLMProviderTemplateCRUDRepo) DeleteVersion(templateID, orgUUID, ver func validTemplateRequest(name string) *api.LLMProviderTemplate { endpoint := "https://api.example.com" return &api.LLMProviderTemplate{ - DisplayName: name, - Version: "v1.0", + DisplayName: name, + Version: "v1.0", Metadata: &api.LLMProviderTemplateMetadata{ EndpointUrl: &endpoint, }, @@ -214,8 +214,8 @@ func TestLLMProviderTemplateServiceCreate_RejectsMissingEndpoint(t *testing.T) { blank := " " req2 := &api.LLMProviderTemplate{ DisplayName: "Blank Endpoint", - Version: "v1.0", - Metadata: &api.LLMProviderTemplateMetadata{EndpointUrl: &blank}, + Version: "v1.0", + Metadata: &api.LLMProviderTemplateMetadata{EndpointUrl: &blank}, } if _, err := svc.Create("org-1", "alice", req2); !errors.Is(err, constants.ErrInvalidInput) { t.Fatalf("expected ErrInvalidInput when endpoint is blank, got: %v", err) diff --git a/platform-api/src/internal/service/llm_test.go b/platform-api/src/internal/service/llm_test.go index b8682fad87..3e329ef45d 100644 --- a/platform-api/src/internal/service/llm_test.go +++ b/platform-api/src/internal/service/llm_test.go @@ -1433,10 +1433,10 @@ func TestLLMProxyServiceUpdatePreservesProviderAuthValue(t *testing.T) { func validProviderRequest(template string) *api.LLMProvider { return &api.LLMProvider{ - Id: strPointer("provider-1"), - DisplayName: "Test Provider", - Version: "v1.0", - Template: template, + Id: strPointer("provider-1"), + DisplayName: "Test Provider", + Version: "v1.0", + Template: template, Upstream: api.Upstream{ Main: api.UpstreamDefinition{Url: stringPtr("https://example.com/openai/v1")}, }, @@ -1446,10 +1446,10 @@ func validProviderRequest(template string) *api.LLMProvider { func validProxyRequest(providerID, projectID string) *api.LLMProxy { return &api.LLMProxy{ - Id: strPointer("proxy-1"), - DisplayName: "Test Proxy", - Version: "v1.0", - ProjectId: projectID, + Id: strPointer("proxy-1"), + DisplayName: "Test Proxy", + Version: "v1.0", + ProjectId: projectID, Provider: api.LLMProxyProvider{ Id: providerID, }, diff --git a/platform-api/src/internal/service/mcp.go b/platform-api/src/internal/service/mcp.go index bbd85d65c1..8d8afa529b 100644 --- a/platform-api/src/internal/service/mcp.go +++ b/platform-api/src/internal/service/mcp.go @@ -210,7 +210,7 @@ func (s *MCPProxyService) Create(orgUUID, createdBy string, req *api.MCPProxy) ( Policies: mapMCPPoliciesAPIToModel(req.Policies), Capabilities: mapMcpCapabilitiesAPIToModel(req.Capabilities), }, - Origin: constants.OriginCP, + Origin: constants.OriginCP, AssociatedGateways: associatedGateways, } diff --git a/platform-api/src/internal/service/secret_service_test.go b/platform-api/src/internal/service/secret_service_test.go index 1a7a9cddac..4a3eff81d5 100644 --- a/platform-api/src/internal/service/secret_service_test.go +++ b/platform-api/src/internal/service/secret_service_test.go @@ -64,14 +64,14 @@ type mockSecretRepo struct { secrets map[string]*model.Secret - createFn func(*model.Secret) error - existsFn func(orgID, handle string) (bool, error) - getByHandleFn func(orgID, handle string) (*model.Secret, error) - updateFn func(*model.Secret) error + createFn func(*model.Secret) error + existsFn func(orgID, handle string) (bool, error) + getByHandleFn func(orgID, handle string) (*model.Secret, error) + updateFn func(*model.Secret) error findRefsAndSoftDeleteFn func(orgID, handle, by string) ([]model.SecretReference, error) findRefsFn func(orgID, handle string) ([]model.SecretReference, error) - listFn func(orgID string, limit, offset int, after *time.Time) ([]*model.Secret, error) - countFn func(orgID string) (int, error) + listFn func(orgID string, limit, offset int, after *time.Time) ([]*model.Secret, error) + countFn func(orgID string) (int, error) } func newMockRepo() *mockSecretRepo { @@ -275,6 +275,7 @@ func TestSecretService_Create_InvalidType_ReturnsError(t *testing.T) { t.Errorf("expected ErrInvalidSecretType, got %v", err) } } + // ---- List tests ------------------------------------------------------------- func TestSecretService_List_ReturnsPagination(t *testing.T) { diff --git a/platform-api/src/internal/utils/llm_provider_template_loader.go b/platform-api/src/internal/utils/llm_provider_template_loader.go index 33bd147fcc..3bb365205e 100644 --- a/platform-api/src/internal/utils/llm_provider_template_loader.go +++ b/platform-api/src/internal/utils/llm_provider_template_loader.go @@ -142,11 +142,11 @@ func LoadLLMProviderTemplatesFromDirectory(dirPath string) ([]*model.LLMProvider } res = append(res, &model.LLMProviderTemplate{ - ID: handle, - GroupID: groupID, - Version: seedVersion, - Name: doc.Spec.DisplayName, - ManagedBy: managedBy, + ID: handle, + GroupID: groupID, + Version: seedVersion, + Name: doc.Spec.DisplayName, + ManagedBy: managedBy, Metadata: mapTemplateMetadata(doc.Spec.Metadata), PromptTokens: mapExtractionIdentifier(doc.Spec.PromptTokens), CompletionTokens: mapExtractionIdentifier(doc.Spec.CompletionTokens), diff --git a/platform-api/src/internal/utils/subscription_token_crypto_test.go b/platform-api/src/internal/utils/subscription_token_crypto_test.go new file mode 100644 index 0000000000..c1d1a7d727 --- /dev/null +++ b/platform-api/src/internal/utils/subscription_token_crypto_test.go @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package utils + +import ( + "crypto/rand" + "encoding/hex" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// randomHexKey returns a 64-hex-char string (32 bytes), matching the format the +// server generates for an ephemeral demo key (config.generateRandomSecret). +func randomHexKey(t *testing.T) string { + t.Helper() + b := make([]byte, 32) + _, err := rand.Read(b) + require.NoError(t, err) + return hex.EncodeToString(b) +} + +func TestDeriveEncryptionKey_AcceptsHexAndBase64_RejectsBadLengths(t *testing.T) { + hexKey := randomHexKey(t) + k, err := DeriveEncryptionKey(hexKey) + require.NoError(t, err) + assert.Len(t, k, 32) + + _, err = DeriveEncryptionKey("") + assert.Error(t, err) + _, err = DeriveEncryptionKey("tooshort") + assert.Error(t, err) + // 32 raw chars is neither 64-hex nor base64-to-32-bytes — must be rejected, + // not silently truncated/padded. + _, err = DeriveEncryptionKey("0123456789abcdef0123456789abcdef") + assert.Error(t, err) +} + +func TestSubscriptionToken_EncryptDecrypt_RoundTrip(t *testing.T) { + key, err := DeriveEncryptionKey(randomHexKey(t)) + require.NoError(t, err) + + ct, err := EncryptSubscriptionToken(key, "sub-token-abc123") + require.NoError(t, err) + pt, err := DecryptSubscriptionToken(key, ct) + require.NoError(t, err) + assert.Equal(t, "sub-token-abc123", pt) +} + +// TestSubscriptionToken_EphemeralKeyRotation_BreaksDecryption mirrors the +// platform-api secret bug for subscription tokens: getSubscriptionTokenEncryptionKey +// falls back to AUTH_JWT_SECRET_KEY (subscription_repository.go), which is +// auto-generated as an ephemeral demo key when unset — so a token encrypted before +// a restart cannot be decrypted with the new key after it. +func TestSubscriptionToken_EphemeralKeyRotation_BreaksDecryption(t *testing.T) { + // "First start": derive a key from one ephemeral secret and encrypt a token. + key1, err := DeriveEncryptionKey(randomHexKey(t)) + require.NoError(t, err) + token, err := EncryptSubscriptionToken(key1, "sub-token-abc123") + require.NoError(t, err) + + // "Restart": a different ephemeral secret derives a different key. + key2, err := DeriveEncryptionKey(randomHexKey(t)) + require.NoError(t, err) + + _, err = DecryptSubscriptionToken(key2, token) + assert.Error(t, err, + "a subscription token encrypted before restart must not decrypt with a new ephemeral key") +} + +// TestSubscriptionToken_MultiKeyFallback_ContractHolds documents the contract the +// repository's decryptionKeyCandidates relies on: a token encrypted under an +// earlier key source still decrypts when that key is among the candidates tried, +// but not when only the new key is used. +func TestSubscriptionToken_MultiKeyFallback_ContractHolds(t *testing.T) { + oldKey, err := DeriveEncryptionKey(randomHexKey(t)) + require.NoError(t, err) + newKey, err := DeriveEncryptionKey(randomHexKey(t)) + require.NoError(t, err) + + token, err := EncryptSubscriptionToken(oldKey, "sub-token-abc123") + require.NoError(t, err) + + // Only the new key → fails (what a naive single-key path would do). + _, err = DecryptSubscriptionToken(newKey, token) + require.Error(t, err) + + // Trying candidates in precedence order (new first, then old) recovers it — + // this is why the repository keeps a fallback list across key-source changes. + candidates := [][]byte{newKey, oldKey} + var decrypted string + var lastErr error + for _, k := range candidates { + if pt, e := DecryptSubscriptionToken(k, token); e == nil { + decrypted = pt + lastErr = nil + break + } else { + lastErr = e + } + } + require.NoError(t, lastErr) + assert.Equal(t, "sub-token-abc123", decrypted) +} diff --git a/platform-api/src/internal/vault/vault_test.go b/platform-api/src/internal/vault/vault_test.go new file mode 100644 index 0000000000..8f53afc14d --- /dev/null +++ b/platform-api/src/internal/vault/vault_test.go @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package vault + +import ( + "bytes" + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// keyA and keyB are two distinct valid AES-256 keys. +var ( + keyA = bytes.Repeat([]byte{0xA5}, 32) + keyB = bytes.Repeat([]byte{0x5A}, 32) +) + +func TestNewInHouseVault_RejectsBadKeySize(t *testing.T) { + for _, size := range []int{0, 16, 31, 33, 64} { + _, err := NewInHouseVault(bytes.Repeat([]byte{1}, size)) + assert.Error(t, err, "key size %d must be rejected", size) + } + v, err := NewInHouseVault(keyA) + require.NoError(t, err) + assert.Equal(t, keyA, v.HashKey()) +} + +func TestInHouseVault_EncryptDecrypt_RoundTrip(t *testing.T) { + v, err := NewInHouseVault(keyA) + require.NoError(t, err) + + ct, err := v.Encrypt(context.Background(), "sk-secret-value") + require.NoError(t, err) + + pt, err := v.Decrypt(context.Background(), ct) + require.NoError(t, err) + assert.Equal(t, "sk-secret-value", pt) +} + +func TestInHouseVault_Encrypt_EmptyPlaintext_Errors(t *testing.T) { + v, err := NewInHouseVault(keyA) + require.NoError(t, err) + _, err = v.Encrypt(context.Background(), "") + assert.Error(t, err) +} + +// A ciphertext encrypted under keyA must not decrypt under keyB — this is the +// property that makes an ephemeral/rotated key lose access to stored secrets. +func TestInHouseVault_Decrypt_WrongKey_Errors(t *testing.T) { + vA, err := NewInHouseVault(keyA) + require.NoError(t, err) + ct, err := vA.Encrypt(context.Background(), "sk-secret-value") + require.NoError(t, err) + + vB, err := NewInHouseVault(keyB) + require.NoError(t, err) + _, err = vB.Decrypt(context.Background(), ct) + assert.Error(t, err, "ciphertext from keyA must not decrypt under keyB") +} + +func TestInHouseVault_Decrypt_TamperedCiphertext_Errors(t *testing.T) { + v, err := NewInHouseVault(keyA) + require.NoError(t, err) + ct, err := v.Encrypt(context.Background(), "sk-secret-value") + require.NoError(t, err) + + // Flip a bit in the last byte (inside the GCM tag / ciphertext) — auth must fail. + tampered := make([]byte, len(ct)) + copy(tampered, ct) + tampered[len(tampered)-1] ^= 0x01 + _, err = v.Decrypt(context.Background(), tampered) + assert.Error(t, err, "tampered ciphertext must fail authentication") +} + +func TestInHouseVault_Decrypt_ShortCiphertext_Errors(t *testing.T) { + v, err := NewInHouseVault(keyA) + require.NoError(t, err) + _, err = v.Decrypt(context.Background(), []byte{0x00, 0x01}) + assert.Error(t, err, "ciphertext shorter than the nonce must be rejected") +} + +func TestInHouseVault_ProviderName(t *testing.T) { + v, err := NewInHouseVault(keyA) + require.NoError(t, err) + assert.NotEmpty(t, v.ProviderName()) +} diff --git a/portals/ai-workspace/bff/internal/server/composite_handlers_test.go b/portals/ai-workspace/bff/internal/server/composite_handlers_test.go index c52d5ffe73..8573cb37f7 100644 --- a/portals/ai-workspace/bff/internal/server/composite_handlers_test.go +++ b/portals/ai-workspace/bff/internal/server/composite_handlers_test.go @@ -165,7 +165,10 @@ func fakePlatformAPI(t *testing.T, responses map[string]struct { } func TestHandleCreateWithSecretCompensation_Success(t *testing.T) { - platform, calls := fakePlatformAPI(t, map[string]struct{ status int; body string }{ + platform, calls := fakePlatformAPI(t, map[string]struct { + status int + body string + }{ "POST /api/v0.9/llm-providers": {http.StatusCreated, `{"id":"prov-1"}`}, }) @@ -233,7 +236,10 @@ func TestHandleCreateWithSecretCompensation_ProviderFailTriggersDelete(t *testin } func TestHandleCreateWithSecretCompensation_NoSecretNoDelete(t *testing.T) { - platform, calls := fakePlatformAPI(t, map[string]struct{ status int; body string }{ + platform, calls := fakePlatformAPI(t, map[string]struct { + status int + body string + }{ "POST /api/v0.9/llm-providers": {http.StatusBadRequest, `{"error":"bad request"}`}, }) @@ -287,4 +293,3 @@ func TestHandleCreateWithSecretCompensation_Unauthenticated(t *testing.T) { t.Errorf("error = %q, want %q", body["error"], "not authenticated") } } - diff --git a/tests/integration-e2e/README.md b/tests/integration-e2e/README.md index 8772ecf04e..02791f5780 100644 --- a/tests/integration-e2e/README.md +++ b/tests/integration-e2e/README.md @@ -53,10 +53,14 @@ chicken-and-egg: it starts the control plane, authenticates (`admin`/`admin`), creates a project and one (or two) gateway(s) with their registration tokens, then starts the gateway controllers with those tokens. Each scenario then creates its own API and deploys it to a pre-registered gateway, so scenarios are -independent. The `I deploy the API to the gateway` step bounces that gateway's -controller, because the controller runs its full deployment sync only once on -connect (`c.syncOnce` in `pkg/controlplane/client.go`); the restart re-runs that -sync so the new deployment is picked up. +independent. The `I deploy the API to the gateway` step just creates the +deployment via the platform-api REST API: while the controller is connected, +platform-api pushes an `api.deployed` event down the control-plane socket and the +controller deploys that API to the data plane live (`handleAPIDeployedEvent` in +`pkg/controlplane/client.go`) — no restart needed. The connect-time **full** sync +(`c.syncOnce`) still runs only once, on connect; that is the path the restart / +recovery scenarios exercise (a controller that starts with a deployment it has +never seen re-syncs it from the control plane). ## Running @@ -77,14 +81,17 @@ Or via make (from `platform-api/`): `make e2e`, `make e2e-all-dbs`. - `E2E_DB` = `postgres` (default) | `sqlite` | `sqlserver`. - `E2E_KEEP=1` leaves the stack up after the run for inspection. -- `E2E_TAGS=@smoke` runs a tag subset. The `@multigateway` scenario runs only on - the postgres stack (the only one wired with a second gateway) and is otherwise - skipped automatically. +- `E2E_TAGS=@smoke` runs a tag subset (`@restart` selects just the restart / + recovery scenarios). The `@multigateway` and `@restart` scenarios run only on + the postgres stack (the only one wired with a second gateway, and the only stack + the store-wipe recovery step supports) and are otherwise skipped automatically. - `PA_HOST_PORT` / `GW_HTTP_PORT` / `GW2_HTTP_PORT` override the published host ports to avoid clashing with other local stacks (defaults 9243 / 18080 / 18081). ### Scenarios +`features/api-deployment.feature` — deployment happy path: + 1. **An API deployed to a gateway is served by the data plane** — deploy, then a request to the ingress returns 200 via Envoy; a path outside the API context returns 404. @@ -94,10 +101,109 @@ Or via make (from `platform-api/`): `make e2e`, `make e2e-all-dbs`. gateways is served by both (fan-out), and undeploying from one leaves the other serving (per-gateway isolation). +`features/gateway-restart.feature` (`@restart`, postgres) — a gateway must keep +serving, and reconcile to the control plane's desired state, across restarts. A +process restart re-runs the connect-time full sync (`c.syncOnce`), so these prove +that recovery path from every angle: + +4. **Controller restart** (`@smoke`) — restarting the gateway controller keeps + the API served; unmapped paths still 404. +5. **Runtime restart** — restarting the gateway runtime (Envoy) re-serves the API + (it re-pulls its xDS snapshot from the controller on reconnect). +6. **Whole-gateway restart** — restarting controller + runtime recovers all + served APIs from persisted state. +7. **Deploy while down** — a deployment created while the controller is stopped is + picked up on its next start (connect-time sync), alongside the already-served + API. +8. **Undeploy while down** — an undeployment issued while the controller is + stopped is applied on its next start (the API stops being served, → 404). +9. **Empty-store recovery** (`@recovery`) — a gateway whose local store is wiped + re-fetches every deployed artifact from the control plane on restart, proving + the control plane is the source of truth for disaster recovery. +10. **Restart isolation** (`@multigateway`) — restarting one gateway leaves the + other serving uninterrupted. + +`features/secret.feature` (`@secret`, postgres) — a secret created in +platform-api must reach the data plane without ever leaving the control plane in +plaintext. There is no live secret push: the controller syncs secrets on connect, +before deployments, so `{{ secret "…" }}` placeholders resolve +(`c.syncOnce → syncSecrets → syncDeployments`). Both scenarios deploy a REST API +whose `set-headers` policy injects `X-Auth-Token: Bearer {{ secret "…" }}` and +assert (via the sample backend, which echoes request headers) that the resolved +plaintext reaches the upstream: + +11. **Secret resolved into an upstream header** (`@smoke`) — after a controller + restart syncs the secret, the injected header carries the resolved value. +12. **Secret survives a full gateway restart** (`@restart`) — the resolved secret + is re-synced and re-injected after restarting controller + runtime. +13. **Secret re-fetchable after a control-plane restart** (`@cp-restart`) — after + platform-api restarts, wiping the gateway store and re-syncing still yields the + correct value. This requires a stable `PLATFORM_SECRET_ENCRYPTION_KEY` (set in + the compose files); without it, demo mode mints a new key per restart and the + stored secret would be undecryptable (the platform-api ephemeral-key bug). + +`features/platform-api-restart.feature` (`@restart @cp-restart`, postgres) — a +control-plane restart is distinct from a gateway restart: the data plane must keep +serving while platform-api is down, and the controller must auto-reconnect its +control-plane socket (no gateway restart) when platform-api returns: + +13. **Gateway survives a CP restart** — restarting platform-api leaves the gateway + serving the API (data plane is independent of CP availability), and the control + plane accepts requests again afterwards (recovered). +14. **Auto-reconnect picks up new work** — after platform-api restarts, a newly + deployed API is picked up by the auto-reconnected controller (via the + eventhub's replay-on-reconnect), with no gateway restart. + +`features/lifecycle.feature` (`@lifecycle`, postgres) — the full REST deployment +lifecycle, not just first deploy: + +15. **Update → redeploy** — mutating an API (a set-headers version marker) and + redeploying propagates the new version to live traffic (echoed header flips + v1 → v2). +16. **Delete → cleanup** — deleting the API in platform-api (`api.deleted`) makes + the gateway stop serving it. +17. **Undeploy → restore** — restoring an UNDEPLOYED deployment + (`/deployments/{id}/restore`) resumes serving. + +`features/api-key-auth.feature` (`@apikey`, postgres) — API-key auth enforced end +to end: + +18. **Key enforcement** — a deployed API carrying an `api-key-auth` policy rejects + an unauthenticated request; a key generated in platform-api + (`POST /rest-apis/{id}/api-keys`, broadcast via `apikey.created`) is then + accepted. (API-key auth is an operation **policy**, not a top-level security + field — that field is LLM-only.) + +`features/mcp-proxy.feature` (`@mcp`, postgres) — an **MCP proxy** created in +platform-api is deployed to the gateway and served at `/mcp`; a JSON-RPC +`initialize` through the ingress returns a result. Needs a real MCP server +(`mcp-backend`, image `rakhitharr/mcp-everything:v3`, started on demand) — the +echo backend can't satisfy the MCP handshake. + +`features/llm-proxy.feature` (`@llm-proxy`, **quarantined `@wip`**) — an LLM proxy +over a provider. Transitively blocked by the same provider template-apiVersion bug +(a proxy requires its provider + template deployed on the gateway first). Captures +the intended flow; remove `@wip` once the bug is fixed. + +`features/llm-provider-secret.feature` (`@llm`, postgres) — **quarantined `@wip`**, +excluded from default runs (`~@wip`); run explicitly with `E2E_TAGS=@llm` to +reproduce. Same secret flow as above but via an **LLM provider** (openai template) +whose `upstream.main.auth.value` is `Bearer {{ secret "…" }}`, invoked at +`/chat/completions`, before and after a full gateway restart. These are +written and correct but currently **fail on an open platform-api bug** (see +below); secret resolution itself works — the failure is the template-apiVersion +gap. Remove `@wip` once the bug is fixed. + ## Status — passing on all three databases -The full live-traffic scenario passes on **SQLite, PostgreSQL and SQL Server** -(verified locally; SQL Server via `azure-sql-edge` on Apple Silicon). +The full live-traffic scenarios (`api-deployment.feature`) pass on **SQLite, +PostgreSQL and SQL Server** (verified locally; SQL Server via `azure-sql-edge` on +Apple Silicon). The extended suites — gateway-restart, secret, control-plane +restart, lifecycle, api-key-auth and mcp-proxy — are postgres-only and pass there: +**20/20 scenarios green in the default run** (verified locally; the resolved secret +value and the api-key/MCP paths were also confirmed by hand; the auto-reconnect +scenario was flake-checked across repeated runs). The `@wip` LLM-provider and +LLM-proxy scenarios are excluded from default runs (open bug, below). Bugs this harness surfaced and that are fixed alongside it: - platform-api image build (`go.sum` missing `go-mssqldb` → `go mod tidy`). @@ -105,3 +211,15 @@ Bugs this harness surfaced and that are fixed alongside it: - gateway eventhub `INSERT … ON CONFLICT` (invalid on SQL Server) in the deployment event-publish path → made dialect-aware (`common/eventhub/sqlbackend.go`). + +Open bug this harness surfaced (test quarantined `@wip`, not yet fixed): +- **LLM provider deploy from platform-api fails gateway validation** with + `template: version: Version must be 'gateway.api-platform.wso2.com/v1'` + (`gateway-controller/pkg/config/llm_validator.go`). platform-api's LLM provider + templates carry no apiVersion: the default template files + (`platform-api/.../default-llm-provider-templates/*.yaml`) omit it, the loader + (`llm_provider_template_loader.go`) reads `apiVersion` but never stores it on the + model, and the provider deploy YAML sends only the template handle — so the + template the gateway validates has an empty apiVersion. Secret resolution itself + works. Fix by propagating the template apiVersion from platform-api to the + gateway, then drop `@wip` from `features/llm-provider-secret.feature`. diff --git a/tests/integration-e2e/docker-compose.sqlite.yaml b/tests/integration-e2e/docker-compose.sqlite.yaml index 98ea5d2dc4..605b25b84f 100644 --- a/tests/integration-e2e/docker-compose.sqlite.yaml +++ b/tests/integration-e2e/docker-compose.sqlite.yaml @@ -10,6 +10,9 @@ services: - DATABASE_PATH=/app/data/platform.db - DATABASE_EXECUTE_SCHEMA_DDL=true - DATABASE_SUBSCRIPTION_TOKEN_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef + # Stable secret key (else demo mode mints an ephemeral one each restart — + # secrets would be undecryptable after a platform-api restart). + - PLATFORM_SECRET_ENCRYPTION_KEY=a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90 - AUTH_FILE_BASED_ENABLED=true - AUTH_FILE_BASED_ORGANIZATION_ID=default - AUTH_FILE_BASED_ORGANIZATION_UUID=99089a17-72e0-4dd8-a2f4-c8dfbb085295 diff --git a/tests/integration-e2e/docker-compose.sqlserver.yaml b/tests/integration-e2e/docker-compose.sqlserver.yaml index 3e2d4cfb1c..dc999790ee 100644 --- a/tests/integration-e2e/docker-compose.sqlserver.yaml +++ b/tests/integration-e2e/docker-compose.sqlserver.yaml @@ -53,6 +53,9 @@ services: - DATABASE_PASSWORD=${MSSQL_PASSWORD:-Strong!Passw0rd} - DATABASE_SSL_MODE=disable - DATABASE_SUBSCRIPTION_TOKEN_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef + # Stable secret key (else demo mode mints an ephemeral one each restart — + # secrets would be undecryptable after a platform-api restart). + - PLATFORM_SECRET_ENCRYPTION_KEY=a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90 - AUTH_FILE_BASED_ENABLED=true - AUTH_FILE_BASED_ORGANIZATION_ID=default - AUTH_FILE_BASED_ORGANIZATION_UUID=99089a17-72e0-4dd8-a2f4-c8dfbb085295 diff --git a/tests/integration-e2e/docker-compose.yaml b/tests/integration-e2e/docker-compose.yaml index 7fa484a189..d775347968 100644 --- a/tests/integration-e2e/docker-compose.yaml +++ b/tests/integration-e2e/docker-compose.yaml @@ -51,6 +51,12 @@ services: - DATABASE_PASSWORD=apip - DATABASE_SSL_MODE=disable - DATABASE_SUBSCRIPTION_TOKEN_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef + # Stable secret-encryption key (64 hex = 32 bytes). Without this, demo mode + # (the default) mints a NEW ephemeral key on every restart, so secrets + # encrypted before a platform-api restart become undecryptable. Setting it + # keeps secrets valid across a control-plane restart (see the @cp-restart + # secret scenario in features/secret.feature). + - PLATFORM_SECRET_ENCRYPTION_KEY=a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90 # Force file-based auth + a stable org so the scenario can log in and the # org is seeded (env overrides the mounted config file). - AUTH_FILE_BASED_ENABLED=true @@ -125,6 +131,16 @@ services: - "9080:9080" networks: [e2e] + # Real MCP (JSON-RPC streamable-HTTP) server for the MCP-proxy scenario — the + # echo sample-backend can't satisfy the MCP initialize handshake. Started on + # demand by the godog suite (not in the default phase-1/2 bring-up). + mcp-backend: + image: rakhitharr/mcp-everything:v3 + pull_policy: missing + ports: + - "3001:3001" + networks: [e2e] + # Second gateway (own controller + runtime + DB) for the multi-gateway # scenario: the same API is deployed to both gateways, and undeploying from one # must not affect the other. Started on demand by the godog suite. diff --git a/tests/integration-e2e/features/api-key-auth.feature b/tests/integration-e2e/features/api-key-auth.feature new file mode 100644 index 0000000000..60fbe77a1c --- /dev/null +++ b/tests/integration-e2e/features/api-key-auth.feature @@ -0,0 +1,20 @@ +Feature: API-key authentication enforced end to end + As an API platform operator + I want a deployed API to reject unauthenticated requests and accept a key + generated in platform-api + So that the control-plane API-key flow enforces access at the data plane. + + # postgres-only (@apikey). The key is generated in platform-api and broadcast to + # the gateway (apikey.created event); the gateway then accepts it. + + Background: + Given the platform-api control plane and gateway data plane are running + And I am authenticated to platform-api + + @apikey + Scenario: A deployed API rejects requests without a key and accepts a generated key + Given a REST API that requires an API key + When I deploy the API to the gateway + Then a request without an API key is rejected + When I generate an API key for the API + Then a request with the API key is accepted diff --git a/tests/integration-e2e/features/gateway-restart.feature b/tests/integration-e2e/features/gateway-restart.feature new file mode 100644 index 0000000000..915da7e7d5 --- /dev/null +++ b/tests/integration-e2e/features/gateway-restart.feature @@ -0,0 +1,57 @@ +Feature: A gateway recovers its served APIs across restarts + As an API platform operator + I want deployed APIs to keep being served after a gateway restarts + So that pod bounces, upgrades and crashes do not drop live traffic. + + # These scenarios run on the postgres stack (the default), which is also the + # only stack wired with a second gateway for the multi-gateway isolation case. + + Background: + Given the platform-api control plane and gateway data plane are running + And I am authenticated to platform-api + And a REST API routed to the sample backend + And the API is deployed to the gateway and served + + @smoke @restart + Scenario: Restarting the gateway controller keeps the API served + When I restart the gateway controller + Then the gateway serves the API + And a request to a path outside the API context returns 404 + + @restart + Scenario: Restarting the gateway runtime re-serves the API + When I restart the gateway runtime + Then the gateway serves the API + + @restart + Scenario: Restarting the whole gateway recovers all served APIs + When I restart the whole gateway + Then the gateway serves the API + + @restart + Scenario: A deployment made while the gateway is down is picked up on restart + Given a second REST API routed to the sample backend + When the gateway controller is stopped + And I deploy the second API to the gateway while it is stopped + And the gateway controller is started + Then the gateway serves the API + And the gateway serves the second API + + @restart + Scenario: An undeployment made while the gateway is down is applied on restart + When the gateway controller is stopped + And I undeploy the API from the gateway while it is stopped + And the gateway controller is started + Then the gateway stops serving the API + + @restart @recovery + Scenario: A gateway with an empty store recovers its APIs from the control plane + When the gateway store is wiped and the gateway controller is restarted + Then the gateway serves the API + + @restart @multigateway + Scenario: Restarting one gateway leaves the other serving (restart isolation) + Given the API is deployed to the second gateway and served + When I restart the gateway controller + Then the second gateway still serves the API + And the gateway serves the API diff --git a/tests/integration-e2e/features/lifecycle.feature b/tests/integration-e2e/features/lifecycle.feature new file mode 100644 index 0000000000..0b97cca6fa --- /dev/null +++ b/tests/integration-e2e/features/lifecycle.feature @@ -0,0 +1,34 @@ +Feature: REST API deployment lifecycle from platform-api to the gateway + As an API platform operator + I want update, delete and restore of a deployment to propagate to the gateway + So that the full lifecycle — not just first deploy — works end to end. + + # postgres-only (@lifecycle), consistent with the other extended suites. + + Background: + Given the platform-api control plane and gateway data plane are running + And I am authenticated to platform-api + + @lifecycle + Scenario: Updating an API and redeploying propagates the new version to live traffic + Given a REST API that injects the version header "v1" + And the API is deployed to the gateway and served + And the gateway injects the version header "v1" + When I update the API to inject the version header "v2" and redeploy + Then the gateway injects the version header "v2" + + @lifecycle + Scenario: Deleting an API stops the gateway serving it + Given a REST API routed to the sample backend + And the API is deployed to the gateway and served + When I delete the API from platform-api + Then the gateway stops serving the API + + @lifecycle + Scenario: Restoring an undeployed deployment resumes serving + Given a REST API routed to the sample backend + And the API is deployed to the gateway and served + When I undeploy the API from the gateway + Then the gateway stops serving the API + When I restore the deployment + Then the gateway serves the API diff --git a/tests/integration-e2e/features/llm-provider-secret.feature b/tests/integration-e2e/features/llm-provider-secret.feature new file mode 100644 index 0000000000..c6dce61e8e --- /dev/null +++ b/tests/integration-e2e/features/llm-provider-secret.feature @@ -0,0 +1,50 @@ +Feature: A secret is resolved into a deployed LLM provider's upstream auth + As an API platform operator + I want a secret created in platform-api to be injected into an LLM provider's + upstream authorization header when the provider is deployed to a gateway + So that provider credentials reach the LLM backend at runtime, and keep working + after the gateway restarts. + + # An LLM provider carries its upstream credential first-class in + # upstream.main.auth.value (unlike a REST API, which uses a set-headers policy). + # Secrets have no live push: the controller syncs secrets on connect, before + # deployments, so {{ secret "..." }} resolves (c.syncOnce in client.go). The + # scenarios therefore restart the controller once after deploy to sync the + # secret, then invoke /chat/completions and assert the resolved + # credential reached the upstream (the sample backend echoes request headers). + # postgres-only (@llm). + # + # @wip — QUARANTINED pending a platform-api bug (excluded from default runs; + # run explicitly with E2E_TAGS=@llm to reproduce). Deploying a platform-api LLM + # provider that uses a default template (e.g. openai) FAILS gateway validation: + # "LLM provider validation failed: version: Version must be + # 'gateway.api-platform.wso2.com/v1'" (gateway-controller llm_validator.go) + # Root cause: platform-api templates carry no apiVersion — the default template + # files (default-llm-provider-templates/*.yaml) omit it, the loader + # (llm_provider_template_loader.go) reads apiVersion but never stores it on the + # model, and the provider deploy YAML sends only the template handle. So the + # template the gateway validates has an empty apiVersion. Secret resolution + # itself works (the secret is retrieved, decrypted and ready to inject). Remove + # @wip once platform-api propagates the template apiVersion to the gateway. + + Background: + Given the platform-api control plane and gateway data plane are running + And I am authenticated to platform-api + + @wip @smoke @llm @secret + Scenario: A gateway resolves a secret into a deployed LLM provider's upstream auth + Given a secret in platform-api + And an LLM provider whose upstream authorization uses the secret + When I deploy the LLM provider to the gateway + And I restart the gateway controller + Then invoking the LLM provider sends the resolved secret upstream + + @wip @llm @secret @restart + Scenario: The LLM provider's resolved secret survives a full gateway restart + Given a secret in platform-api + And an LLM provider whose upstream authorization uses the secret + And I deploy the LLM provider to the gateway + And I restart the gateway controller + And invoking the LLM provider sends the resolved secret upstream + When I restart the whole gateway + Then invoking the LLM provider sends the resolved secret upstream diff --git a/tests/integration-e2e/features/llm-proxy.feature b/tests/integration-e2e/features/llm-proxy.feature new file mode 100644 index 0000000000..31b558b295 --- /dev/null +++ b/tests/integration-e2e/features/llm-proxy.feature @@ -0,0 +1,30 @@ +Feature: An LLM proxy deployed from platform-api is served by the gateway + As an API platform operator + I want an LLM proxy (over a provider) created in platform-api to be served by + the gateway data plane + So that the control-plane → data-plane path works for LLM proxies too. + + # @wip — QUARANTINED, excluded from default runs (E2E_TAGS=@llm-proxy to + # reproduce). An LLM proxy references an LLM provider by handle; deploying the + # proxy requires the referenced provider AND its template to be deployed on the + # gateway first. That provider deploy currently FAILS the gateway's + # template-apiVersion validation (the same open platform-api bug as + # features/llm-provider-secret.feature — the default LLM provider templates + # carry no apiVersion). So the LLM-proxy path is transitively blocked by the + # same bug. This scenario captures the intended flow; remove @wip once the + # provider template apiVersion is propagated to the gateway. + + Background: + Given the platform-api control plane and gateway data plane are running + And I am authenticated to platform-api + + @wip @llm-proxy @secret + Scenario: An LLM proxy forwards the provider's resolved secret upstream + Given a secret in platform-api + And an LLM provider whose upstream authorization uses the secret + And I deploy the LLM provider to the gateway + And I restart the gateway controller + And an LLM proxy over that provider + When I deploy the LLM proxy to the gateway + And I restart the gateway controller + Then invoking the LLM proxy sends the resolved secret upstream diff --git a/tests/integration-e2e/features/mcp-proxy.feature b/tests/integration-e2e/features/mcp-proxy.feature new file mode 100644 index 0000000000..d775f8db77 --- /dev/null +++ b/tests/integration-e2e/features/mcp-proxy.feature @@ -0,0 +1,22 @@ +Feature: An MCP proxy deployed from platform-api is served by the gateway + As an API platform operator + I want an MCP proxy created in platform-api to be served by the gateway data + plane + So that the control-plane → data-plane path works for MCP proxies, not just + REST APIs. + + # postgres-only (@mcp). Needs a real MCP (JSON-RPC streamable-HTTP) server — + # the echo sample-backend can't satisfy the initialize handshake — so the + # mcp-backend container is started on demand. The gateway serves the proxy at + # /mcp. + + Background: + Given the platform-api control plane and gateway data plane are running + And I am authenticated to platform-api + + @mcp + Scenario: A gateway serves a deployed MCP proxy + Given the MCP backend is running + And an MCP proxy routed to the MCP backend + When I deploy the MCP proxy to the gateway + Then the gateway serves the MCP proxy diff --git a/tests/integration-e2e/features/platform-api-restart.feature b/tests/integration-e2e/features/platform-api-restart.feature new file mode 100644 index 0000000000..6189428920 --- /dev/null +++ b/tests/integration-e2e/features/platform-api-restart.feature @@ -0,0 +1,34 @@ +Feature: The system survives a platform-api (control plane) restart + As an API platform operator + I want the gateway to keep serving and the control plane to recover when + platform-api restarts + So that a control-plane pod bounce or upgrade does not drop live traffic and + the control plane keeps working afterwards. + + # A control-plane restart is distinct from a gateway restart: the data plane + # (gateway-runtime + the controller's locally persisted config) must keep + # serving while platform-api is down, and the gateway-controller must + # auto-reconnect its control-plane socket when platform-api comes back and pick + # up new work — no gateway restart. postgres-only (@restart). + + Background: + Given the platform-api control plane and gateway data plane are running + And I am authenticated to platform-api + + @restart @cp-restart + Scenario: The gateway keeps serving across a platform-api restart, and the control plane recovers + Given a REST API routed to the sample backend + And the API is deployed to the gateway and served + When I restart the platform-api control plane + Then the gateway still serves the API + And the control plane accepts requests again + + @restart @cp-restart + Scenario: The gateway auto-reconnects and picks up a new deployment after a platform-api restart + Given a REST API routed to the sample backend + And the API is deployed to the gateway and served + And a second REST API routed to the sample backend + When I restart the platform-api control plane + And I deploy the second API to the gateway + Then the gateway serves the second API + And the gateway still serves the API diff --git a/tests/integration-e2e/features/secret.feature b/tests/integration-e2e/features/secret.feature new file mode 100644 index 0000000000..f69939144f --- /dev/null +++ b/tests/integration-e2e/features/secret.feature @@ -0,0 +1,49 @@ +Feature: A secret created in platform-api is resolved by the gateway data plane + As an API platform operator + I want a secret created in platform-api to be resolved into deployed artifacts + So that credentials never leave the control plane in plaintext yet reach the + data plane at runtime, and survive a gateway restart. + + # Secrets have no live push event: the gateway controller syncs secrets on + # connect, before deployments, so {{ secret "..." }} placeholders resolve + # (c.syncOnce -> syncSecrets -> syncDeployments in pkg/controlplane/client.go). + # These scenarios therefore restart the controller to pick the secret up, then + # assert the resolved value reaches the upstream. postgres-only (@secret). + + Background: + Given the platform-api control plane and gateway data plane are running + And I am authenticated to platform-api + + @smoke @secret + Scenario: A gateway resolves a platform-api secret into an upstream request header + Given a secret in platform-api + And a REST API that injects the secret into an upstream request header + When I deploy the API to the gateway + And I restart the gateway controller + Then the gateway injects the resolved secret value into the upstream request + + @secret @restart + Scenario: The resolved secret survives a full gateway restart + Given a secret in platform-api + And a REST API that injects the secret into an upstream request header + And I deploy the API to the gateway + And I restart the gateway controller + And the gateway injects the resolved secret value into the upstream request + When I restart the whole gateway + Then the gateway injects the resolved secret value into the upstream request + + # Guards against the demo-mode ephemeral-key bug: the control plane must be able + # to re-serve the stored secret after IT restarts (requires a stable + # PLATFORM_SECRET_ENCRYPTION_KEY — set in docker-compose.yaml). The final wipe + + # controller restart forces the gateway to re-fetch the secret from the + # just-restarted control plane, so a wrong/undecryptable value would fail here. + @secret @restart @cp-restart + Scenario: The secret is still re-fetchable after the control plane restarts + Given a secret in platform-api + And a REST API that injects the secret into an upstream request header + And I deploy the API to the gateway + And I restart the gateway controller + And the gateway injects the resolved secret value into the upstream request + When I restart the platform-api control plane + And the gateway store is wiped and the gateway controller is restarted + Then the gateway injects the resolved secret value into the upstream request diff --git a/tests/integration-e2e/steps_test.go b/tests/integration-e2e/steps_test.go index 177122762b..80fe1e99fd 100644 --- a/tests/integration-e2e/steps_test.go +++ b/tests/integration-e2e/steps_test.go @@ -21,22 +21,59 @@ package e2e import ( "crypto/rand" "encoding/hex" + "encoding/json" "fmt" + "io" "net/http" + "strings" "time" "github.com/cucumber/godog" ) // world holds the per-scenario state: the API created for the scenario and the -// deployment ids returned when it is deployed to each gateway. +// deployment ids returned when it is deployed to each gateway. Some restart +// scenarios use a second API to prove a deployment made while the gateway is +// down is picked up on the next controller start. type world struct { apiID string apiContext string // e.g. /e2e-ab12cd34 depGw1 string depGw2 string + + api2ID string // optional second API (deploy-while-down scenario) + api2Context string + dep2Gw1 string + + secretID string // optional secret (secret-resolution scenarios) + secretValue string + + llmHandle string // optional LLM provider (llm-provider secret scenarios) + llmContext string + + apiDisplayName string // tracked so lifecycle scenarios can PUT-update the API + + apiKeyValue string // caller-supplied API key (api-key auth scenario) + + mcpHandle string // MCP proxy (mcp scenario) + mcpContext string + + llmProxyHandle string // LLM proxy (llm-proxy @wip scenario) + llmProxyContext string } +// lifecycleVersionHeader is the request header the lifecycle scenarios inject with +// a version marker (v1/v2) so update propagation is observable via the echo. +const lifecycleVersionHeader = "X-Api-Version" + +// apiKeyHeaderName is the request header the API-key auth scenario configures and +// sends the key in. +const apiKeyHeaderName = "apikey" + +// secretHeaderName is the request header the set-headers policy injects with the +// resolved secret value; the sample backend echoes it back for assertion. +const secretHeaderName = "X-Auth-Token" + // initializeScenario is invoked by godog for each scenario; it binds a fresh // world so scenarios do not share API/deployment state. func initializeScenario(sc *godog.ScenarioContext) { @@ -58,6 +95,61 @@ func initializeScenario(sc *godog.ScenarioContext) { sc.Step(`^the second gateway serves the API$`, w.secondGatewayServes) sc.Step(`^I undeploy the API from the second gateway$`, w.undeployFromSecondGateway) sc.Step(`^the second gateway stops serving the API$`, w.secondGatewayStopsServing) + + // Restart / recovery steps. + sc.Step(`^I restart the gateway controller$`, w.restartController) + sc.Step(`^I restart the gateway runtime$`, w.restartRuntime) + sc.Step(`^I restart the whole gateway$`, w.restartWholeGateway) + sc.Step(`^the gateway controller is stopped$`, w.stopController) + sc.Step(`^the gateway controller is started$`, w.startController) + sc.Step(`^the gateway store is wiped and the gateway controller is restarted$`, w.wipeStoreAndRestartController) + + sc.Step(`^a second REST API routed to the sample backend$`, w.aSecondRestAPI) + sc.Step(`^I deploy the second API to the gateway while it is stopped$`, w.deploySecondWhileStopped) + sc.Step(`^I undeploy the API from the gateway while it is stopped$`, w.undeployFromGateway) + sc.Step(`^the gateway serves the second API$`, w.gatewayServesSecond) + + sc.Step(`^the API is deployed to the second gateway and served$`, w.deployedToSecondAndServed) + sc.Step(`^the second gateway still serves the API$`, w.secondGatewayStillServes) + + // Secret-resolution steps. + sc.Step(`^a secret in platform-api$`, w.aSecret) + sc.Step(`^a REST API that injects the secret into an upstream request header$`, w.aRestAPIWithSecretHeader) + sc.Step(`^the gateway injects the resolved secret value into the upstream request$`, w.secretResolvedUpstream) + + // LLM-provider secret steps. + sc.Step(`^an LLM provider whose upstream authorization uses the secret$`, w.anLLMProviderWithSecret) + sc.Step(`^I deploy the LLM provider to the gateway$`, w.deployLLMToGateway) + sc.Step(`^invoking the LLM provider sends the resolved secret upstream$`, w.llmInvokeResolvedSecret) + + // Control-plane (platform-api) restart steps. + sc.Step(`^I restart the platform-api control plane$`, w.restartPlatformAPI) + sc.Step(`^the control plane accepts requests again$`, w.controlPlaneAcceptsRequests) + sc.Step(`^I deploy the second API to the gateway$`, w.deploySecond) + + // Lifecycle steps (update-redeploy, delete, restore). + sc.Step(`^a REST API that injects the version header "([^"]*)"$`, w.aRestAPIWithVersionHeader) + sc.Step(`^the gateway injects the version header "([^"]*)"$`, w.gatewayInjectsVersionHeader) + sc.Step(`^I update the API to inject the version header "([^"]*)" and redeploy$`, w.updateVersionHeaderAndRedeploy) + sc.Step(`^I delete the API from platform-api$`, w.deleteAPI) + sc.Step(`^I restore the deployment$`, w.restoreDeploymentStep) + + // API-key auth steps. + sc.Step(`^a REST API that requires an API key$`, w.aRestAPIRequiringKey) + sc.Step(`^a request without an API key is rejected$`, w.requestWithoutKeyRejected) + sc.Step(`^I generate an API key for the API$`, w.generateAPIKey) + sc.Step(`^a request with the API key is accepted$`, w.requestWithKeyAccepted) + + // MCP proxy steps. + sc.Step(`^the MCP backend is running$`, w.mcpBackendRunning) + sc.Step(`^an MCP proxy routed to the MCP backend$`, w.anMCPProxy) + sc.Step(`^I deploy the MCP proxy to the gateway$`, w.deployMCPToGateway) + sc.Step(`^the gateway serves the MCP proxy$`, w.gatewayServesMCP) + + // LLM-proxy steps (@wip — blocked by the provider template-apiVersion bug). + sc.Step(`^an LLM proxy over that provider$`, w.anLLMProxyOverProvider) + sc.Step(`^I deploy the LLM proxy to the gateway$`, w.deployLLMProxyToGateway) + sc.Step(`^invoking the LLM proxy sends the resolved secret upstream$`, w.llmProxyInvokeResolvedSecret) } // --- Background steps ------------------------------------------------------ @@ -78,27 +170,84 @@ func (w *world) authenticated() error { // --- Given ----------------------------------------------------------------- -func (w *world) aRestAPI() error { - // Name/displayName must be URL-friendly (no slash); the context is the path. +// createRestAPI creates a REST API in platform-api routed to the sample backend +// and returns its id and context path. The name/context must be URL-friendly +// (no slash); the context doubles as the ingress path the data plane serves. +func createRestAPI() (id, context string, err error) { suffix := randHex() - w.apiContext = "/e2e-" + suffix + context = "/e2e-" + suffix st, body, err := apiCall(http.MethodPost, "/api/v0.9/rest-apis", suite.token, map[string]any{ "displayName": "e2e-api-" + suffix, - "context": w.apiContext, - "version": "v1", - "projectId": suite.projectID, - "upstream": map[string]any{"main": map[string]any{"url": "http://sample-backend:9080"}}, + "context": context, + "version": "v1", + "projectId": suite.projectID, + "upstream": map[string]any{"main": map[string]any{"url": "http://sample-backend:9080"}}, }) + if err != nil { + return "", "", err + } + id = jsonField(body, "id", "handle", "uuid") + if st >= 300 || id == "" { + return "", "", fmt.Errorf("create API failed (%d): %s", st, body) + } + return id, context, nil +} + +func (w *world) aRestAPI() error { + id, context, err := createRestAPI() if err != nil { return err } - w.apiID = jsonField(body, "id", "handle", "uuid") - if st >= 300 || w.apiID == "" { - return fmt.Errorf("create API failed (%d): %s", st, body) + w.apiID, w.apiContext = id, context + return nil +} + +func (w *world) aSecondRestAPI() error { + id, context, err := createRestAPI() + if err != nil { + return err + } + w.api2ID, w.api2Context = id, context + return nil +} + +func (w *world) aSecret() error { + w.secretID = "e2e-sec-" + randHex() + w.secretValue = "e2e-secret-value-" + randHex() + return createSecret(w.secretID, w.secretValue) +} + +func (w *world) aRestAPIWithSecretHeader() error { + id, context, err := createRestAPIWithSecretHeader(w.secretID, secretHeaderName) + if err != nil { + return err } + w.apiID, w.apiContext = id, context return nil } +func (w *world) anLLMProviderWithSecret() error { + handle, context, err := createLLMProviderWithSecretAuth(w.secretID) + if err != nil { + return err + } + w.llmHandle, w.llmContext = handle, context + return nil +} + +func (w *world) deployLLMToGateway() error { + _, err := deployLLMProvider(w.llmHandle, suite.gw1ID) + return err +} + +func (w *world) llmInvokeResolvedSecret() error { + // The LLM provider injects its credential into the Authorization header + // (upstream.main.auth.header), not the set-headers X-Auth-Token used by the + // REST scenario. + return waitIngressPostEchoHeader(ingressGw1, w.llmContext+"/chat/completions", + "Authorization", "Bearer "+w.secretValue) +} + func (w *world) deployedAndServed() error { if err := w.deployToGateway(); err != nil { return err @@ -108,15 +257,13 @@ func (w *world) deployedAndServed() error { // --- deploy / undeploy ----------------------------------------------------- -// deploy attaches the gateway to the API, creates a deployment and bounces the -// gateway's controller so it picks the deployment up. -// -// The controller runs a full deployment sync only once, on first connect -// (c.syncOnce in pkg/controlplane/client.go); deployments created while it is -// already connected are not full-synced. Restarting the controller process -// re-runs that one-time sync, which is the data-plane equivalent of "the -// controller noticed the new deployment". Returns the deployment id. -func deploy(apiID, gatewayID, controllerService string) (string, error) { +// deployNoBounce attaches the gateway to the API and creates a deployment via +// the platform-api REST API, without touching the gateway controller. It is the +// pure control-plane half of a deployment. If the controller is connected it +// picks the deployment up from the live `api.deployed` event; if it is stopped it +// picks it up from the connect-time full sync on its next start. Returns the +// deployment id. +func deployNoBounce(apiID, gatewayID string) (string, error) { if st, body, err := apiCall(http.MethodPost, "/api/v0.9/rest-apis/"+apiID+"/gateways", suite.token, []map[string]string{{"gatewayId": gatewayID}}); err != nil { return "", err @@ -132,12 +279,17 @@ func deploy(apiID, gatewayID, controllerService string) (string, error) { if st >= 300 || id == "" { return "", fmt.Errorf("deploy failed (%d): %s", st, body) } - if err := compose(nil, "restart", controllerService); err != nil { - return "", fmt.Errorf("restart %s: %w", controllerService, err) - } return id, nil } +// While the controller is connected, platform-api pushes an `api.deployed` event +// down the control-plane socket and the controller deploys that single API to the +// data plane live (handleAPIDeployedEvent in pkg/controlplane/client.go) — no +// restart needed. (The connect-time full sync, c.syncOnce, still runs only once; +// it is what the deploy-while-stopped and empty-store recovery scenarios rely on.) +// So deployToGateway just creates the deployment via deployNoBounce and lets the +// live event propagate it; the assertion step polls the ingress until it serves. + func undeploy(apiID, deploymentID, gatewayID string) error { st, body, err := apiCall(http.MethodPost, "/api/v0.9/rest-apis/"+apiID+"/deployments/"+deploymentID+"/undeploy?gatewayId="+gatewayID, suite.token, nil) @@ -151,7 +303,7 @@ func undeploy(apiID, deploymentID, gatewayID string) error { } func (w *world) deployToGateway() error { - id, err := deploy(w.apiID, suite.gw1ID, "gateway-controller") + id, err := deployNoBounce(w.apiID, suite.gw1ID) if err != nil { return err } @@ -160,7 +312,7 @@ func (w *world) deployToGateway() error { } func (w *world) deployToSecondGateway() error { - id, err := deploy(w.apiID, suite.gw2ID, "gateway-controller-2") + id, err := deployNoBounce(w.apiID, suite.gw2ID) if err != nil { return err } @@ -171,11 +323,250 @@ func (w *world) deployToSecondGateway() error { func (w *world) undeployFromGateway() error { return undeploy(w.apiID, w.depGw1, suite.gw1ID) } func (w *world) undeployFromSecondGateway() error { return undeploy(w.apiID, w.depGw2, suite.gw2ID) } +func (w *world) deployedToSecondAndServed() error { + if err := w.deployToSecondGateway(); err != nil { + return err + } + return w.secondGatewayServes() +} + +// deploySecondWhileStopped creates the second API's deployment against gw1 via +// the control plane only. The controller is stopped at this point, so it will +// observe the deployment on its next start (connect-time sync), which is exactly +// what the "deploy while the gateway is down" scenario verifies. +func (w *world) deploySecondWhileStopped() error { + id, err := deployNoBounce(w.api2ID, suite.gw1ID) + if err != nil { + return err + } + w.dep2Gw1 = id + return nil +} + +// --- restart / recovery steps ---------------------------------------------- + +func (w *world) restartController() error { return compose(nil, "restart", "gateway-controller") } +func (w *world) restartRuntime() error { return compose(nil, "restart", "gateway-runtime") } +func (w *world) stopController() error { return compose(nil, "stop", "gateway-controller") } +func (w *world) startController() error { return compose(nil, "start", "gateway-controller") } + +func (w *world) restartWholeGateway() error { + return compose(nil, "restart", "gateway-controller", "gateway-runtime") +} + +// restartPlatformAPI bounces the control plane and waits for it to become healthy +// again. The gateway is untouched: its data plane must keep serving while the CP +// is down, and its controller must auto-reconnect the control-plane socket once +// the CP is back. +func (w *world) restartPlatformAPI() error { + if err := compose(nil, "restart", "platform-api"); err != nil { + return err + } + return waitHealthy() +} + +// controlPlaneAcceptsRequests proves the restarted control plane is functional by +// re-authenticating against it. +func (w *world) controlPlaneAcceptsRequests() error { + if _, err := login(); err != nil { + return fmt.Errorf("control plane did not accept requests after restart: %w", err) + } + return nil +} + +// deploySecond deploys the already-created second API to the first gateway via the +// control plane only (no controller restart), so a connected — or freshly +// reconnected — controller picks it up from the live api.deployed event. +func (w *world) deploySecond() error { + id, err := deployNoBounce(w.api2ID, suite.gw1ID) + if err != nil { + return err + } + w.dep2Gw1 = id + return nil +} + +// --- lifecycle steps (update-redeploy, delete, restore) -------------------- + +func (w *world) aRestAPIWithVersionHeader(value string) error { + id, context, displayName, err := createRestAPIWithHeader(lifecycleVersionHeader, value) + if err != nil { + return err + } + w.apiID, w.apiContext, w.apiDisplayName = id, context, displayName + return nil +} + +func (w *world) gatewayInjectsVersionHeader(value string) error { + return waitIngressEchoHeader(ingressGw1, w.apiContext, lifecycleVersionHeader, value) +} + +func (w *world) updateVersionHeaderAndRedeploy(value string) error { + if err := updateRestAPIHeader(w.apiID, w.apiDisplayName, w.apiContext, lifecycleVersionHeader, value); err != nil { + return err + } + id, err := deployNoBounce(w.apiID, suite.gw1ID) + if err != nil { + return err + } + w.depGw1 = id + return nil +} + +func (w *world) deleteAPI() error { return deleteRestAPI(w.apiID) } + +func (w *world) restoreDeploymentStep() error { + return restoreDeployment(w.apiID, w.depGw1, suite.gw1ID) +} + +// --- API-key auth steps ---------------------------------------------------- + +func (w *world) aRestAPIRequiringKey() error { + id, context, err := createRestAPIRequiringKey(apiKeyHeaderName) + if err != nil { + return err + } + w.apiID, w.apiContext = id, context + return nil +} + +// requestWithoutKeyRejected waits until the ingress serves the route but rejects +// an unauthenticated request (401/403). It tolerates transient 404s while the +// deployment propagates. +func (w *world) requestWithoutKeyRejected() error { + deadline := time.Now().Add(pollTimeout) + var last int + for time.Now().Before(deadline) { + if last = ingressStatusWithHeader(ingressGw1, w.apiContext, "", ""); last == 401 || last == 403 { + return nil + } + time.Sleep(2 * time.Second) + } + return fmt.Errorf("request without API key: wanted 401/403, last observed %d", last) +} + +func (w *world) generateAPIKey() error { + w.apiKeyValue = "e2e-apikey-" + randHex() + return createAPIKeyForAPI(w.apiID, w.apiKeyValue) +} + +func (w *world) requestWithKeyAccepted() error { + deadline := time.Now().Add(pollTimeout) + var last int + for time.Now().Before(deadline) { + if last = ingressStatusWithHeader(ingressGw1, w.apiContext, apiKeyHeaderName, w.apiKeyValue); last == 200 { + return nil + } + time.Sleep(2 * time.Second) + } + return fmt.Errorf("request with API key: wanted 200, last observed %d", last) +} + +// --- MCP proxy steps ------------------------------------------------------- + +// mcpInitBody is a minimal MCP JSON-RPC initialize request. +const mcpInitBody = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":` + + `{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"e2e","version":"1.0.0"}}}` + +func (w *world) mcpBackendRunning() error { + // Started on demand (not part of the default phase-1/2 bring-up). + return compose(nil, "up", "-d", "mcp-backend") +} + +func (w *world) anMCPProxy() error { + handle, context, err := createMCPProxy() + if err != nil { + return err + } + w.mcpHandle, w.mcpContext = handle, context + return nil +} + +func (w *world) deployMCPToGateway() error { + _, err := deployMCPProxy(w.mcpHandle, suite.gw1ID) + return err +} + +// gatewayServesMCP polls the MCP endpoint (/mcp) with a JSON-RPC +// initialize until the gateway routes it to the MCP backend and returns a +// successful JSON-RPC result. +func (w *world) gatewayServesMCP() error { + deadline := time.Now().Add(pollTimeout) + var lastCode int + var lastBody string + for time.Now().Before(deadline) { + lastCode, lastBody = mcpInitialize(ingressGw1, w.mcpContext) + if lastCode == 200 && strings.Contains(lastBody, `"result"`) { + return nil + } + time.Sleep(2 * time.Second) + } + return fmt.Errorf("MCP initialize %s%s/mcp: wanted 200 with a result, last %d: %.200s", + ingressGw1, w.mcpContext, lastCode, lastBody) +} + +// --- LLM-proxy steps (@wip — blocked by provider template-apiVersion bug) -- + +func (w *world) anLLMProxyOverProvider() error { + handle, context, err := createLLMProxy(w.llmHandle) + if err != nil { + return err + } + w.llmProxyHandle, w.llmProxyContext = handle, context + return nil +} + +func (w *world) deployLLMProxyToGateway() error { + _, err := deployLLMProxy(w.llmProxyHandle, suite.gw1ID) + return err +} + +func (w *world) llmProxyInvokeResolvedSecret() error { + return waitIngressPostEchoHeader(ingressGw1, w.llmProxyContext+"/chat/completions", + "Authorization", "Bearer "+w.secretValue) +} + +// mcpInitialize POSTs an MCP initialize request through the ingress and returns +// the status and body (the MCP response may be JSON or an SSE data frame; both +// carry the JSON-RPC "result"). +func mcpInitialize(base, context string) (int, string) { + req, err := http.NewRequest(http.MethodPost, base+context+"/mcp", strings.NewReader(mcpInitBody)) + if err != nil { + return -1, "" + } + req.Host = ingressHost + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + resp, err := httpClient.Do(req) + if err != nil { + return 0, "" + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(body) +} + +// wipeStoreAndRestartController simulates a fresh/replaced gateway: the +// controller is stopped, its local artifact store is wiped, and it is started +// again. On start it re-runs its connect-time sync and must re-fetch every +// deployed artifact from the control plane, proving the control plane is the +// source of truth for recovery. +func (w *world) wipeStoreAndRestartController() error { + if err := w.stopController(); err != nil { + return err + } + if err := wipeGatewayStore(); err != nil { + return err + } + return w.startController() +} + // --- Then (data-plane assertions) ------------------------------------------ -func (w *world) gatewayServes() error { return waitIngress(ingressGw1, w.apiContext, 200) } -func (w *world) gatewayStopsServing() error { return waitIngress(ingressGw1, w.apiContext, 404) } -func (w *world) secondGatewayServes() error { return waitIngress(ingressGw2, w.apiContext, 200) } +func (w *world) gatewayServes() error { return waitIngress(ingressGw1, w.apiContext, 200) } +func (w *world) gatewayStopsServing() error { return waitIngress(ingressGw1, w.apiContext, 404) } +func (w *world) gatewayServesSecond() error { return waitIngress(ingressGw1, w.api2Context, 200) } +func (w *world) secondGatewayServes() error { return waitIngress(ingressGw2, w.apiContext, 200) } func (w *world) secondGatewayStopsServing() error { return waitIngress(ingressGw2, w.apiContext, 404) } @@ -187,6 +578,13 @@ func (w *world) gatewayStillServes() error { return nil } +func (w *world) secondGatewayStillServes() error { + if code := ingressStatus(ingressGw2, w.apiContext); code != 200 { + return fmt.Errorf("gateway 2 should still serve the API, got %d", code) + } + return nil +} + func (w *world) unmappedPathReturns404() error { if code := ingressStatus(ingressGw1, "/no-such-"+randHex()); code != 404 { return fmt.Errorf("unmapped path should return 404, got %d", code) @@ -194,14 +592,125 @@ func (w *world) unmappedPathReturns404() error { return nil } +// wipeGatewayStore empties the first gateway controller's local artifact store +// so a subsequent start must recover everything from the control plane. Only the +// postgres stack is exercised for restart scenarios, so this truncates every +// table in gw1's store database (gateway_test); gw2 uses a separate database +// (gateway_test2) and is left untouched. The controller must be stopped first so +// no rows are being written during the truncate. +func wipeGatewayStore() error { + if suite.db != "postgres" { + return fmt.Errorf("wipeGatewayStore only implemented for postgres, got %q", suite.db) + } + const truncateAll = `DO $$ DECLARE r RECORD; BEGIN ` + + `FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public') LOOP ` + + `EXECUTE 'TRUNCATE TABLE public.' || quote_ident(r.tablename) || ' CASCADE'; ` + + `END LOOP; END $$;` + return compose(nil, "exec", "-T", "postgres", + "psql", "-v", "ON_ERROR_STOP=1", "-U", "apip", "-d", "gateway_test", "-c", truncateAll) +} + +func (w *world) secretResolvedUpstream() error { + return waitIngressEchoHeader(ingressGw1, w.apiContext, secretHeaderName, "Bearer "+w.secretValue) +} + // --- ingress helpers ------------------------------------------------------- +// llmChatBody is a minimal OpenAI chat/completions request the openai provider +// template accepts, used to drive an LLM provider through the gateway. +const llmChatBody = `{"model":"gpt-4","messages":[{"role":"user","content":"e2e"}]}` + +// echoedHeaderFrom sends req through the ingress and returns the value the sample +// backend echoed back for the named request header (the backend reflects the +// request it received as JSON: {"headers": {"X-Auth-Token": ["..."]}, ...}). +// Returns "" if the request is not served (non-200), the body is not the echo +// JSON, or the header is absent. +func echoedHeaderFrom(req *http.Request, header string) string { + req.Host = ingressHost // gateway routes by vhost + resp, err := httpClient.Do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return "" + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return "" + } + var echo struct { + Headers map[string][]string `json:"headers"` + } + if json.Unmarshal(body, &echo) != nil { + return "" + } + return strings.Join(echo.Headers[header], ",") +} + +// echoedHeader does a GET to base+context+"/" and returns the echoed header value. +func echoedHeader(base, context, header string) string { + req, err := http.NewRequest(http.MethodGet, base+context+"/", nil) + if err != nil { + return "" + } + return echoedHeaderFrom(req, header) +} + +// postEchoedHeader does a POST of the LLM chat body to base+path and returns the +// echoed header value. +func postEchoedHeader(base, path, header string) string { + req, err := http.NewRequest(http.MethodPost, base+path, strings.NewReader(llmChatBody)) + if err != nil { + return "" + } + req.Header.Set("Content-Type", "application/json") + return echoedHeaderFrom(req, header) +} + +// waitIngressEchoHeader polls the ingress (GET) until the sample backend echoes +// the named header containing want (proving the gateway injected and resolved +// it), or times out. +func waitIngressEchoHeader(base, context, header, want string) error { + return pollEchoedHeader(func() string { return echoedHeader(base, context, header) }, + fmt.Sprintf("GET %s%s/", base, context), header, want) +} + +// waitIngressPostEchoHeader polls the ingress (POST to path) until the echoed +// header contains want, or times out. +func waitIngressPostEchoHeader(base, path, header, want string) error { + return pollEchoedHeader(func() string { return postEchoedHeader(base, path, header) }, + fmt.Sprintf("POST %s%s", base, path), header, want) +} + +func pollEchoedHeader(fetch func() string, what, header, want string) error { + deadline := time.Now().Add(pollTimeout) + var last string + for time.Now().Before(deadline) { + if last = fetch(); strings.Contains(last, want) { + return nil + } + time.Sleep(2 * time.Second) + } + return fmt.Errorf("ingress %s header %q: wanted to contain %q, last observed %q", + what, header, want, last) +} + func ingressStatus(base, context string) int { + return ingressStatusWithHeader(base, context, "", "") +} + +// ingressStatusWithHeader does a GET base+context+"/" and returns the status, +// optionally setting a request header (used for API-key auth checks). +func ingressStatusWithHeader(base, context, header, value string) int { req, err := http.NewRequest(http.MethodGet, base+context+"/", nil) if err != nil { return -1 } req.Host = ingressHost // gateway routes by vhost + if header != "" { + req.Header.Set(header, value) + } resp, err := httpClient.Do(req) if err != nil { return 0 diff --git a/tests/integration-e2e/suite_test.go b/tests/integration-e2e/suite_test.go index 58dae83b17..a41f0718a9 100644 --- a/tests/integration-e2e/suite_test.go +++ b/tests/integration-e2e/suite_test.go @@ -27,6 +27,7 @@ import ( "encoding/json" "fmt" "io" + "mime/multipart" "net/http" "net/url" "os" @@ -72,8 +73,17 @@ var httpClient = &http.Client{ // TestFeatures is the go test entry point that runs the godog suite. func TestFeatures(t *testing.T) { tags := os.Getenv("E2E_TAGS") - if tags == "" && os.Getenv("E2E_DB") != "" && os.Getenv("E2E_DB") != "postgres" { - tags = "~@multigateway" // second gateway is only wired on the postgres stack + if tags == "" { + // @wip scenarios are quarantined (a known platform-api bug — see + // features/llm-provider-secret.feature); run them explicitly with + // E2E_TAGS=@llm to reproduce. + tags = "~@wip" + if db := os.Getenv("E2E_DB"); db != "" && db != "postgres" { + // The second gateway is only wired on the postgres stack; the + // restart/recovery, secret and lifecycle scenarios are validated + // postgres-only. Only the base api-deployment feature runs cross-DB. + tags += " && ~@multigateway && ~@restart && ~@secret && ~@lifecycle && ~@apikey && ~@mcp" + } } status := godog.TestSuite{ Name: "platform-api-gateway-e2e", @@ -290,6 +300,348 @@ func createGatewayAndToken(name string) (gatewayID, token string, err error) { return gatewayID, token, nil } +// createSecret stores an organization-scoped secret in platform-api. The endpoint +// is multipart/form-data (not JSON); id is the handle used in {{ secret "id" }} +// placeholders, value is the plaintext (encrypted at rest, never returned). +func createSecret(id, value string) error { + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fields := []struct{ k, v string }{ + {"id", id}, {"displayName", id}, {"value", value}, {"type", "GENERIC"}, + } + for _, f := range fields { + if err := mw.WriteField(f.k, f.v); err != nil { + return err + } + } + if err := mw.Close(); err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, platformAPI+"/api/v0.9/secrets", &buf) + if err != nil { + return err + } + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+suite.token) + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 300 { + return fmt.Errorf("create secret failed (%d): %s", resp.StatusCode, body) + } + return nil +} + +// restAPIWithHeaderBody builds a REST API create/update body routed to the sample +// backend with one GET operation carrying a set-headers policy that injects +// headerName=headerValue into the upstream request. In platform-api, +// method/path/policies nest under operations[].request. The sample backend echoes +// the header back so a test can assert it reached the upstream. +func restAPIWithHeaderBody(displayName, context, headerName, headerValue string) map[string]any { + return map[string]any{ + "displayName": displayName, + "context": context, + "version": "v1", + "projectId": suite.projectID, + "upstream": map[string]any{"main": map[string]any{"url": "http://sample-backend:9080"}}, + "operations": []map[string]any{{ + "name": "getRoot", + "request": map[string]any{ + "method": "GET", + "path": "/", + "policies": []map[string]any{{ + "name": "set-headers", + "version": "v1", + "params": map[string]any{ + "request": map[string]any{ + "headers": []map[string]any{{"name": headerName, "value": headerValue}}, + }, + }, + }}, + }, + }}, + } +} + +// createRestAPIWithSecretHeader creates a REST API whose set-headers policy injects +// `: Bearer {{ secret "" }}` — the gateway resolves the +// placeholder at runtime. +func createRestAPIWithSecretHeader(secretID, headerName string) (id, context string, err error) { + suffix := randHex() + context = "/e2e-" + suffix + body := restAPIWithHeaderBody("e2e-secret-api-"+suffix, context, headerName, + `Bearer {{ secret "`+secretID+`" }}`) + st, resp, err := apiCall(http.MethodPost, "/api/v0.9/rest-apis", suite.token, body) + if err != nil { + return "", "", err + } + id = jsonField(resp, "id", "handle", "uuid") + if st >= 300 || id == "" { + return "", "", fmt.Errorf("create secret API failed (%d): %s", st, resp) + } + return id, context, nil +} + +// createRestAPIWithHeader creates a REST API whose set-headers policy injects a +// literal headerName=headerValue. Returns the API id, context and displayName +// (the displayName is needed to PUT-update the API later). +func createRestAPIWithHeader(headerName, headerValue string) (id, context, displayName string, err error) { + suffix := randHex() + context = "/e2e-" + suffix + displayName = "e2e-hdr-api-" + suffix + body := restAPIWithHeaderBody(displayName, context, headerName, headerValue) + st, resp, err := apiCall(http.MethodPost, "/api/v0.9/rest-apis", suite.token, body) + if err != nil { + return "", "", "", err + } + id = jsonField(resp, "id", "handle", "uuid") + if st >= 300 || id == "" { + return "", "", "", fmt.Errorf("create API failed (%d): %s", st, resp) + } + return id, context, displayName, nil +} + +// updateRestAPIHeader PUT-updates the API (full-object replace) to inject a new +// header value, so a subsequent redeploy carries the mutated spec. +func updateRestAPIHeader(id, displayName, context, headerName, headerValue string) error { + body := restAPIWithHeaderBody(displayName, context, headerName, headerValue) + st, resp, err := apiCall(http.MethodPut, "/api/v0.9/rest-apis/"+id, suite.token, body) + if err != nil { + return err + } + if st >= 300 { + return fmt.Errorf("update API failed (%d): %s", st, resp) + } + return nil +} + +// deleteRestAPI deletes the API from the control plane; the gateway receives an +// api.deleted event and stops serving it. +func deleteRestAPI(id string) error { + st, resp, err := apiCall(http.MethodDelete, "/api/v0.9/rest-apis/"+id, suite.token, nil) + if err != nil { + return err + } + if st >= 300 { + return fmt.Errorf("delete API failed (%d): %s", st, resp) + } + return nil +} + +// restoreDeployment restores a previously UNDEPLOYED deployment on its gateway. +func restoreDeployment(apiID, deploymentID, gatewayID string) error { + st, resp, err := apiCall(http.MethodPost, + "/api/v0.9/rest-apis/"+apiID+"/deployments/"+deploymentID+"/restore?gatewayId="+gatewayID, + suite.token, nil) + if err != nil { + return err + } + if st >= 300 { + return fmt.Errorf("restore deployment failed (%d): %s", st, resp) + } + return nil +} + +// createRestAPIRequiringKey creates a REST API routed to the sample backend whose +// GET operation carries an api-key-auth policy (the key is read from keyHeader). +// Once deployed, the gateway rejects requests without a valid key. (API-key auth +// is a policy on the operation, not a top-level security field.) +func createRestAPIRequiringKey(keyHeader string) (id, context string, err error) { + suffix := randHex() + context = "/e2e-" + suffix + body := map[string]any{ + "displayName": "e2e-key-api-" + suffix, + "context": context, + "version": "v1", + "projectId": suite.projectID, + "upstream": map[string]any{"main": map[string]any{"url": "http://sample-backend:9080"}}, + "operations": []map[string]any{{ + "name": "getRoot", + "request": map[string]any{ + "method": "GET", + "path": "/", + "policies": []map[string]any{{ + "name": "api-key-auth", + "version": "v1", + "params": map[string]any{"key": keyHeader, "in": "header"}, + }}, + }, + }}, + } + st, resp, err := apiCall(http.MethodPost, "/api/v0.9/rest-apis", suite.token, body) + if err != nil { + return "", "", err + } + id = jsonField(resp, "id", "handle", "uuid") + if st >= 300 || id == "" { + return "", "", fmt.Errorf("create key-protected API failed (%d): %s", st, resp) + } + return id, context, nil +} + +// createAPIKeyForAPI registers a caller-supplied plaintext API key for the API. +// platform-api hashes it and broadcasts it to the gateways where the API is +// deployed (the apikey.created event), so the gateway then accepts that key. +func createAPIKeyForAPI(apiID, keyValue string) error { + st, resp, err := apiCall(http.MethodPost, "/api/v0.9/rest-apis/"+apiID+"/api-keys", suite.token, + map[string]any{"displayName": "e2e-key", "apiKey": keyValue}) + if err != nil { + return err + } + if st >= 300 { + return fmt.Errorf("create API key failed (%d): %s", st, resp) + } + return nil +} + +// createMCPProxy creates an MCP proxy whose upstream is the real MCP server +// (mcp-backend). Returns the proxy handle (used in the deployment path) and its +// context. The gateway serves it at /mcp. +func createMCPProxy() (handle, context string, err error) { + suffix := randHex() + handle = "mcp-" + suffix + context = "/mcp-" + suffix + body := map[string]any{ + "id": handle, + "displayName": "e2e-mcp-" + suffix, + "version": "v1.0", + "context": context, + "mcpSpecVersion": "2025-06-18", + "upstream": map[string]any{"main": map[string]any{"url": "http://mcp-backend:3001"}}, + } + st, resp, err := apiCall(http.MethodPost, "/api/v0.9/mcp-proxies", suite.token, body) + if err != nil { + return "", "", err + } + if id := jsonField(resp, "id", "handle"); id != "" { + handle = id + } + if st >= 300 { + return "", "", fmt.Errorf("create MCP proxy failed (%d): %s", st, resp) + } + return handle, context, nil +} + +// deployMCPProxy deploys the MCP proxy (by handle) to the gateway; the deploy call +// creates the gateway association (no separate attach). Returns the deployment id. +func deployMCPProxy(handle, gatewayID string) (string, error) { + st, resp, err := apiCall(http.MethodPost, "/api/v0.9/mcp-proxies/"+handle+"/deployments", suite.token, + map[string]any{"base": "current", "gatewayId": gatewayID, "name": "dep-" + randHex()}) + if err != nil { + return "", err + } + id := jsonField(resp, "deploymentId") + if st >= 300 || id == "" { + return "", fmt.Errorf("deploy MCP proxy failed (%d): %s", st, resp) + } + return id, nil +} + +// createLLMProxy creates an LLM proxy over an existing (deployed) LLM provider, +// referenced by handle. Returns the proxy handle and context. NOTE: deploying the +// proxy to a gateway requires the referenced provider (and its template) to be +// deployed there first — which currently hits the provider template-apiVersion +// bug (see features/llm-provider-secret.feature), so the proxy e2e is @wip. +func createLLMProxy(providerHandle string) (handle, context string, err error) { + suffix := randHex() + handle = "lp-" + suffix + context = "/lp-" + suffix + body := map[string]any{ + "id": handle, + "displayName": "e2e-llmproxy-" + suffix, + "version": "v1.0", + "projectId": suite.projectID, + "context": context, + "provider": map[string]any{"id": providerHandle}, + } + st, resp, err := apiCall(http.MethodPost, "/api/v0.9/llm-proxies", suite.token, body) + if err != nil { + return "", "", err + } + if id := jsonField(resp, "id", "handle"); id != "" { + handle = id + } + if st >= 300 { + return "", "", fmt.Errorf("create LLM proxy failed (%d): %s", st, resp) + } + return handle, context, nil +} + +// deployLLMProxy deploys the LLM proxy (by handle) to the gateway. Returns the +// deployment id. +func deployLLMProxy(handle, gatewayID string) (string, error) { + st, resp, err := apiCall(http.MethodPost, "/api/v0.9/llm-proxies/"+handle+"/deployments", suite.token, + map[string]any{"base": "current", "gatewayId": gatewayID, "name": "dep-" + randHex()}) + if err != nil { + return "", err + } + id := jsonField(resp, "deploymentId") + if st >= 300 || id == "" { + return "", fmt.Errorf("deploy LLM proxy failed (%d): %s", st, resp) + } + return id, nil +} + +// createLLMProviderWithSecretAuth creates an openai-template LLM provider routed +// to the sample backend, whose upstream carries an Authorization header built +// from the secret (upstream.main.auth.value = 'Bearer {{ secret "" }}'). +// The gateway resolves the placeholder and injects it upstream at runtime. Unlike +// a REST API, an LLM provider has no projectId and needs no separate gateway +// attach — the deployment call creates the association. Returns the provider +// handle (used in the deployment path) and its context. +func createLLMProviderWithSecretAuth(secretID string) (handle, context string, err error) { + suffix := randHex() + handle = "llm-" + suffix // handle used in the deployment path + context = "/llm-" + suffix // context is <= 20 chars, no trailing slash + st, body, err := apiCall(http.MethodPost, "/api/v0.9/llm-providers", suite.token, map[string]any{ + "id": handle, + "displayName": "e2e-llm-" + suffix, + "version": "v1.0", + "context": context, + "template": "openai", + "upstream": map[string]any{ + "main": map[string]any{ + "url": "http://sample-backend:9080", + "auth": map[string]any{ + "type": "api-key", + "header": "Authorization", + "value": `Bearer {{ secret "` + secretID + `" }}`, + }, + }, + }, + "accessControl": map[string]any{"mode": "allow_all"}, + }) + if err != nil { + return "", "", err + } + if id := jsonField(body, "id", "handle"); id != "" { + handle = id + } + if st >= 300 { + return "", "", fmt.Errorf("create LLM provider failed (%d): %s", st, body) + } + return handle, context, nil +} + +// deployLLMProvider deploys the provider (by handle) to the gateway. The deploy +// body is the same shape as a REST API deployment, and creates the gateway +// association itself — no prior attach call. Returns the deployment id. +func deployLLMProvider(handle, gatewayID string) (string, error) { + st, body, err := apiCall(http.MethodPost, "/api/v0.9/llm-providers/"+handle+"/deployments", suite.token, + map[string]any{"base": "current", "gatewayId": gatewayID, "name": "dep-" + randHex()}) + if err != nil { + return "", err + } + id := jsonField(body, "deploymentId") + if st >= 300 || id == "" { + return "", fmt.Errorf("deploy LLM provider failed (%d): %s", st, body) + } + return id, nil +} + // --- small helpers --------------------------------------------------------- func envOr(k, def string) string {