Files
heicode-mananger/heicode/controller/secret_lifecycle_test.go
T
chenchenandClaude Opus 4.8 8fe1f5e131 fix(agent,secret): enforce CRYPTO_SECRET for agent deploy (#31) and guard unscoped vault purge (#33)
#31: HeicodeDeployAgent now refuses to deploy unless CRYPTO_SECRET is explicitly
configured, so the per-agent access_token is sealed with a key that survives a
container restart. common.CryptoSecret is never literally "" (defaults to
uuid/SessionSecret), so the sealAgentToken plaintext fallback was effectively
unreachable; the real hazard is an ephemeral random seal key making tokens
undecryptable after restart. Dev-only override: HEICODE_ALLOW_PLAINTEXT_AGENT_TOKEN_IN_DEV=true.
Verified prod container has CRYPTO_SECRET set (64 chars) -> deploy stays allowed.

#33: StartSecretPurgeTask refuses to start a whole-vault purge when
HEICODE_SECRET_PURGE_NAME_PREFIX is empty unless HEICODE_SECRET_PURGE_VAULT_EXCLUSIVE=true,
so HM never permanently purges another tenant's soft-deleted secrets in a shared
vault. Logs the resolved purge scope at startup.

Both gates extracted into pure, unit-tested helpers (agentTokenSealKeyConfigured,
secretPurgeScopeAllowed). Affects: Manager only (Agent deploy + Secret lifecycle).
No Client/Swarm/billing/audit schema change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:33:47 +08:00

92 lines
3.8 KiB
Go

package controller
import (
"testing"
"github.com/heicode/manager/model"
"github.com/stretchr/testify/require"
)
// #33: an unscoped (empty-prefix) purge must be refused unless the operator
// explicitly declares the vault exclusive to HM, so HM never permanently purges
// another tenant's soft-deleted secrets in a shared vault.
func TestSecretPurgeScopeAllowed(t *testing.T) {
allowed, desc := secretPurgeScopeAllowed("", false)
require.False(t, allowed)
require.Contains(t, desc, "refusing to purge an entire")
allowed, desc = secretPurgeScopeAllowed(" ", true)
require.True(t, allowed)
require.Contains(t, desc, "ENTIRE vault")
allowed, desc = secretPurgeScopeAllowed("heicode-", false)
require.True(t, allowed)
require.Contains(t, desc, "heicode-")
}
func TestParseDeletedSecretsPage(t *testing.T) {
body := []byte(`{
"value": [
{"recoveryId":"https://v.vault.azure.net/deletedsecrets/users-7-bindings-abc","id":"https://v.vault.azure.net/secrets/users-7-bindings-abc","deletedDate":1700000000},
{"id":"https://v.vault.azure.net/secrets/foo","deletedDate":1700001000}
],
"nextLink":"https://v.vault.azure.net/deletedsecrets?api-version=7.4&$skiptoken=xyz"
}`)
items, next, err := parseDeletedSecretsPage(body)
require.NoError(t, err)
require.Len(t, items, 2)
require.Equal(t, "users-7-bindings-abc", items[0].name) // from recoveryId
require.EqualValues(t, 1700000000, items[0].deletedDate)
require.Equal(t, "foo", items[1].name) // recoveryId absent -> falls back to id
require.EqualValues(t, 1700001000, items[1].deletedDate)
require.NotEmpty(t, next)
}
func TestSecretExpired(t *testing.T) {
const now int64 = 1_000_000_000
const day int64 = 86400
require.True(t, secretExpired(now-31*day, 30, now), "31d old past 30d retention")
require.True(t, secretExpired(now-30*day, 30, now), "exactly 30d is past retention")
require.False(t, secretExpired(now-29*day, 30, now), "29d old still within retention")
require.False(t, secretExpired(0, 30, now), "unknown deletedDate never expires")
require.False(t, secretExpired(now-100*day, 0, now), "retention<=0 disables purge")
}
func TestLastPathSegment(t *testing.T) {
require.Equal(t, "abc", lastPathSegment("https://v.vault.azure.net/deletedsecrets/abc"))
require.Equal(t, "x", lastPathSegment("https://v.vault.azure.net/secrets/x/"))
require.Equal(t, "plain", lastPathSegment("plain"))
require.Equal(t, "", lastPathSegment(""))
}
// With no Key Vault configured, revokeUserResourceSecrets must still flip the
// user's secret-bearing bindings to revoked (DB is authoritative) and leave
// other users / secret-less bindings untouched.
func TestRevokeUserResourceSecrets_MarksRevoked(t *testing.T) {
setupResourceControllerTestDB(t)
require.NoError(t, model.DB.AutoMigrate(&model.ResourceBinding{}))
t.Setenv("AZURE_KEY_VAULT_URL", "") // force secret store unconfigured -> KV skipped
mk := func(uid int, secretRef string) model.ResourceBinding {
b := model.ResourceBinding{UserId: uid, Name: "n", ResourceType: "git", SecretRef: secretRef, Status: "active"}
require.NoError(t, model.DB.Create(&b).Error)
return b
}
withSecretA := mk(7777, "azkv://v.vault.azure.net/secrets/users-7777-a")
withSecretB := mk(7777, "azkv://v.vault.azure.net/secrets/users-7777-b")
noSecret := mk(7777, "") // not selected (secret_ref empty) -> stays active
otherUser := mk(8888, "azkv://v.vault.azure.net/secrets/users-8888-a")
revokeUserResourceSecrets(7777)
get := func(id int) string {
var b model.ResourceBinding
require.NoError(t, model.DB.Where("id = ?", id).First(&b).Error)
return b.Status
}
require.Equal(t, "revoked", get(withSecretA.Id))
require.Equal(t, "revoked", get(withSecretB.Id))
require.Equal(t, "active", get(noSecret.Id), "secret-less binding must not be revoked")
require.Equal(t, "active", get(otherUser.Id), "another user's binding must be untouched")
}