Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)")
}
Original file line number Diff line number Diff line change
@@ -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")
}
Original file line number Diff line number Diff line change
@@ -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)
}
53 changes: 48 additions & 5 deletions platform-api/it/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<scenario>` works.
Comment on lines 31 to +35

## Running

Expand Down Expand Up @@ -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

Expand Down
14 changes: 11 additions & 3 deletions platform-api/src/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Loading
Loading