- Network-policy SPIRE plugin extension - Governance event notification with merkle anchoring - Shellstream specs for consent channels + HFL embedded ABI - All 17 audit findings from AUDIT.md remediated - SSH credential composer + substrate key manager updates - Test coverage for config + sshcert packages Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
855 lines
25 KiB
Go
855 lines
25 KiB
Go
package shellstream
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Helper to create a minimal valid extensions set.
|
|
func minimalExtensions() *ShellstreamExtensions {
|
|
return &ShellstreamExtensions{
|
|
TenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
Roles: []string{"analyst"},
|
|
}
|
|
}
|
|
|
|
// Helper to create a fully-populated extensions set.
|
|
func fullExtensions() *ShellstreamExtensions {
|
|
ext := &ShellstreamExtensions{
|
|
SatScopes: []*SatScope{{
|
|
RegistryType: "oci",
|
|
Verbs: []string{"push", "pull"},
|
|
ResourcePattern: "tenant-a/*",
|
|
}},
|
|
SatHash: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
TenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
Roles: []string{"administrator", "engineer"},
|
|
CeremonyID: "11223344-5566-7788-99aa-bbccddeeff00",
|
|
CeremonyType: "single_approval",
|
|
MerkleRoot: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
|
|
MerkleProof: []byte{0x01, 0x02, 0x03, 0x04, 0x05},
|
|
GovernanceIntent: "c8d9e0f1-2a3b-4c5d-6e7f-8a9b0c1d2e3f",
|
|
}
|
|
ext.WithGovernanceEpoch(42)
|
|
return ext
|
|
}
|
|
|
|
func TestEncodeDecodeRoundTrip(t *testing.T) {
|
|
ext := fullExtensions()
|
|
|
|
encoded, err := Encode(ext)
|
|
if err != nil {
|
|
t.Fatalf("Encode: %v", err)
|
|
}
|
|
|
|
decoded, err := Decode(encoded)
|
|
if err != nil {
|
|
t.Fatalf("Decode: %v", err)
|
|
}
|
|
|
|
// Verify all fields round-trip.
|
|
if decoded.TenantID != ext.TenantID {
|
|
t.Errorf("TenantID: got %q, want %q", decoded.TenantID, ext.TenantID)
|
|
}
|
|
if len(decoded.Roles) != len(ext.Roles) {
|
|
t.Fatalf("Roles length: got %d, want %d", len(decoded.Roles), len(ext.Roles))
|
|
}
|
|
for i, r := range decoded.Roles {
|
|
if r != ext.Roles[i] {
|
|
t.Errorf("Roles[%d]: got %q, want %q", i, r, ext.Roles[i])
|
|
}
|
|
}
|
|
if decoded.SatHash != ext.SatHash {
|
|
t.Errorf("SatHash: got %q, want %q", decoded.SatHash, ext.SatHash)
|
|
}
|
|
if len(decoded.SatScopes) != 1 {
|
|
t.Fatalf("SatScopes length: got %d, want 1", len(decoded.SatScopes))
|
|
}
|
|
if decoded.SatScopes[0].RegistryType != ext.SatScopes[0].RegistryType {
|
|
t.Errorf("SatScopes[0].RegistryType: got %q, want %q", decoded.SatScopes[0].RegistryType, ext.SatScopes[0].RegistryType)
|
|
}
|
|
if len(decoded.SatScopes[0].Verbs) != len(ext.SatScopes[0].Verbs) {
|
|
t.Fatalf("SatScopes[0].Verbs length: got %d, want %d", len(decoded.SatScopes[0].Verbs), len(ext.SatScopes[0].Verbs))
|
|
}
|
|
if decoded.SatScopes[0].ResourcePattern != ext.SatScopes[0].ResourcePattern {
|
|
t.Errorf("SatScopes[0].ResourcePattern: got %q, want %q", decoded.SatScopes[0].ResourcePattern, ext.SatScopes[0].ResourcePattern)
|
|
}
|
|
if decoded.CeremonyID != ext.CeremonyID {
|
|
t.Errorf("CeremonyID: got %q, want %q", decoded.CeremonyID, ext.CeremonyID)
|
|
}
|
|
if decoded.CeremonyType != ext.CeremonyType {
|
|
t.Errorf("CeremonyType: got %q, want %q", decoded.CeremonyType, ext.CeremonyType)
|
|
}
|
|
if decoded.MerkleRoot != ext.MerkleRoot {
|
|
t.Errorf("MerkleRoot: got %q, want %q", decoded.MerkleRoot, ext.MerkleRoot)
|
|
}
|
|
if len(decoded.MerkleProof) != len(ext.MerkleProof) {
|
|
t.Fatalf("MerkleProof length: got %d, want %d", len(decoded.MerkleProof), len(ext.MerkleProof))
|
|
}
|
|
for i, b := range decoded.MerkleProof {
|
|
if b != ext.MerkleProof[i] {
|
|
t.Errorf("MerkleProof[%d]: got %x, want %x", i, b, ext.MerkleProof[i])
|
|
}
|
|
}
|
|
if decoded.GovernanceEpoch != ext.GovernanceEpoch {
|
|
t.Errorf("GovernanceEpoch: got %d, want %d", decoded.GovernanceEpoch, ext.GovernanceEpoch)
|
|
}
|
|
if !decoded.HasGovernanceEpoch() {
|
|
t.Error("HasGovernanceEpoch: got false, want true")
|
|
}
|
|
if decoded.GovernanceIntent != ext.GovernanceIntent {
|
|
t.Errorf("GovernanceIntent: got %q, want %q", decoded.GovernanceIntent, ext.GovernanceIntent)
|
|
}
|
|
}
|
|
|
|
func TestEncodeDecodeMinimal(t *testing.T) {
|
|
ext := minimalExtensions()
|
|
|
|
encoded, err := Encode(ext)
|
|
if err != nil {
|
|
t.Fatalf("Encode: %v", err)
|
|
}
|
|
|
|
// Only tenant-id and roles should be present.
|
|
if len(encoded) != 2 {
|
|
t.Errorf("encoded map length: got %d, want 2 (keys: %v)", len(encoded), mapKeys(encoded))
|
|
}
|
|
if _, ok := encoded[ExtTenantID]; !ok {
|
|
t.Error("missing tenant-id")
|
|
}
|
|
if _, ok := encoded[ExtRoles]; !ok {
|
|
t.Error("missing roles")
|
|
}
|
|
|
|
decoded, err := Decode(encoded)
|
|
if err != nil {
|
|
t.Fatalf("Decode: %v", err)
|
|
}
|
|
if decoded.TenantID != ext.TenantID {
|
|
t.Errorf("TenantID: got %q, want %q", decoded.TenantID, ext.TenantID)
|
|
}
|
|
if len(decoded.SatScopes) != 0 {
|
|
t.Error("SatScopes should be empty for minimal extensions")
|
|
}
|
|
if decoded.CeremonyID != "" {
|
|
t.Error("CeremonyID should be empty for minimal extensions")
|
|
}
|
|
if decoded.HasGovernanceEpoch() {
|
|
t.Error("HasGovernanceEpoch should be false for minimal extensions")
|
|
}
|
|
}
|
|
|
|
func TestDecodeUnknownExtensionsIgnored(t *testing.T) {
|
|
m := map[string]string{
|
|
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
ExtRoles: "analyst",
|
|
"unknown-ext@guildhouse.dev": "some-value",
|
|
"permit-pty": "",
|
|
"completely-unrelated": "ignored",
|
|
}
|
|
|
|
decoded, err := Decode(m)
|
|
if err != nil {
|
|
t.Fatalf("Decode: %v", err)
|
|
}
|
|
if decoded.TenantID != "a1b2c3d4-e5f6-7890-abcd-ef1234567890" {
|
|
t.Errorf("TenantID: got %q", decoded.TenantID)
|
|
}
|
|
}
|
|
|
|
func TestValidateRequiredTenantID(t *testing.T) {
|
|
ext := &ShellstreamExtensions{
|
|
Roles: []string{"analyst"},
|
|
}
|
|
err := Validate(ext)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing tenant-id")
|
|
}
|
|
if !strings.Contains(err.Error(), "tenant-id is required") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateRequiredRoles(t *testing.T) {
|
|
ext := &ShellstreamExtensions{
|
|
TenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
}
|
|
err := Validate(ext)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing roles")
|
|
}
|
|
if !strings.Contains(err.Error(), "roles is required") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateInvalidTenantIDFormat(t *testing.T) {
|
|
ext := &ShellstreamExtensions{
|
|
TenantID: "not-a-uuid",
|
|
Roles: []string{"analyst"},
|
|
}
|
|
err := Validate(ext)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid UUID")
|
|
}
|
|
if !strings.Contains(err.Error(), "not a valid UUID") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateSatScopeRequiresSatHash(t *testing.T) {
|
|
ext := minimalExtensions()
|
|
ext.SatScopes = []*SatScope{{
|
|
RegistryType: "oci",
|
|
Verbs: []string{"pull"},
|
|
ResourcePattern: "*",
|
|
}}
|
|
// Missing SatHash.
|
|
err := Validate(ext)
|
|
if err == nil {
|
|
t.Fatal("expected error for sat-scope without sat-hash")
|
|
}
|
|
if !strings.Contains(err.Error(), "sat-scope requires sat-hash") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateSatHashRequiresSatScope(t *testing.T) {
|
|
ext := minimalExtensions()
|
|
ext.SatHash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
|
|
// Missing SatScope.
|
|
err := Validate(ext)
|
|
if err == nil {
|
|
t.Fatal("expected error for sat-hash without sat-scope")
|
|
}
|
|
if !strings.Contains(err.Error(), "sat-hash requires sat-scope") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateSatHashFormat(t *testing.T) {
|
|
ext := minimalExtensions()
|
|
ext.SatScopes = []*SatScope{{
|
|
RegistryType: "oci",
|
|
Verbs: []string{"pull"},
|
|
ResourcePattern: "*",
|
|
}}
|
|
|
|
// Too short.
|
|
ext.SatHash = "abcdef"
|
|
err := Validate(ext)
|
|
if err == nil || !strings.Contains(err.Error(), "64 hex characters") {
|
|
t.Errorf("expected 64-char error, got: %v", err)
|
|
}
|
|
|
|
// Uppercase.
|
|
ext.SatHash = "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789"
|
|
err = Validate(ext)
|
|
if err == nil || !strings.Contains(err.Error(), "lowercase") {
|
|
t.Errorf("expected lowercase error, got: %v", err)
|
|
}
|
|
|
|
// Valid.
|
|
ext.SatHash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
|
|
err = Validate(ext)
|
|
if err != nil {
|
|
t.Errorf("unexpected error for valid sat-hash: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateCeremonyCooccurrence(t *testing.T) {
|
|
ext := minimalExtensions()
|
|
|
|
// ceremony-id without ceremony-type.
|
|
ext.CeremonyID = "11223344-5566-7788-99aa-bbccddeeff00"
|
|
err := Validate(ext)
|
|
if err == nil || !strings.Contains(err.Error(), "ceremony-id requires ceremony-type") {
|
|
t.Errorf("expected co-occurrence error, got: %v", err)
|
|
}
|
|
|
|
// ceremony-type without ceremony-id.
|
|
ext.CeremonyID = ""
|
|
ext.CeremonyType = "single_approval"
|
|
err = Validate(ext)
|
|
if err == nil || !strings.Contains(err.Error(), "ceremony-type requires ceremony-id") {
|
|
t.Errorf("expected co-occurrence error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateUnknownCeremonyType(t *testing.T) {
|
|
ext := minimalExtensions()
|
|
ext.CeremonyID = "11223344-5566-7788-99aa-bbccddeeff00"
|
|
ext.CeremonyType = "unknown_type"
|
|
err := Validate(ext)
|
|
if err == nil || !strings.Contains(err.Error(), "unknown ceremony-type") {
|
|
t.Errorf("expected unknown ceremony-type error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateMerkleProofRequiresRoot(t *testing.T) {
|
|
ext := minimalExtensions()
|
|
ext.MerkleProof = []byte{0x01, 0x02}
|
|
// Missing MerkleRoot.
|
|
err := Validate(ext)
|
|
if err == nil || !strings.Contains(err.Error(), "merkle-proof requires merkle-root") {
|
|
t.Errorf("expected co-occurrence error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateMerkleRootFormat(t *testing.T) {
|
|
ext := minimalExtensions()
|
|
|
|
// Too short.
|
|
ext.MerkleRoot = "abcdef"
|
|
err := Validate(ext)
|
|
if err == nil || !strings.Contains(err.Error(), "64 hex characters") {
|
|
t.Errorf("expected 64-char error, got: %v", err)
|
|
}
|
|
|
|
// Uppercase.
|
|
ext.MerkleRoot = "FEDCBA9876543210FEDCBA9876543210FEDCBA9876543210FEDCBA9876543210"
|
|
err = Validate(ext)
|
|
if err == nil || !strings.Contains(err.Error(), "lowercase") {
|
|
t.Errorf("expected lowercase error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateEmptyRoleName(t *testing.T) {
|
|
ext := &ShellstreamExtensions{
|
|
TenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
Roles: []string{"analyst", ""},
|
|
}
|
|
err := Validate(ext)
|
|
if err == nil || !strings.Contains(err.Error(), "empty role name") {
|
|
t.Errorf("expected empty role error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateRoleWithComma(t *testing.T) {
|
|
ext := &ShellstreamExtensions{
|
|
TenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
Roles: []string{"analyst,viewer"},
|
|
}
|
|
err := Validate(ext)
|
|
if err == nil || !strings.Contains(err.Error(), "commas or spaces") {
|
|
t.Errorf("expected comma error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDecodeSatScopeJSON(t *testing.T) {
|
|
m := map[string]string{
|
|
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
ExtRoles: "analyst",
|
|
ExtSatScope: `{"registry_type":"helm","verbs":["install","upgrade"],"resource_pattern":"ns/*"}`,
|
|
ExtSatHash: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
}
|
|
|
|
decoded, err := Decode(m)
|
|
if err != nil {
|
|
t.Fatalf("Decode: %v", err)
|
|
}
|
|
if len(decoded.SatScopes) != 1 {
|
|
t.Fatalf("SatScopes length: got %d, want 1", len(decoded.SatScopes))
|
|
}
|
|
if decoded.SatScopes[0].RegistryType != "helm" {
|
|
t.Errorf("RegistryType: got %q, want %q", decoded.SatScopes[0].RegistryType, "helm")
|
|
}
|
|
if len(decoded.SatScopes[0].Verbs) != 2 || decoded.SatScopes[0].Verbs[0] != "install" {
|
|
t.Errorf("Verbs: got %v", decoded.SatScopes[0].Verbs)
|
|
}
|
|
if decoded.SatScopes[0].ResourcePattern != "ns/*" {
|
|
t.Errorf("ResourcePattern: got %q", decoded.SatScopes[0].ResourcePattern)
|
|
}
|
|
}
|
|
|
|
func TestDecodeInvalidSatScopeJSON(t *testing.T) {
|
|
m := map[string]string{
|
|
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
ExtRoles: "analyst",
|
|
ExtSatScope: "not-valid-json",
|
|
}
|
|
|
|
_, err := Decode(m)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid JSON")
|
|
}
|
|
if !strings.Contains(err.Error(), "unmarshal sat-scope") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDecodeRolesCommaParsing(t *testing.T) {
|
|
m := map[string]string{
|
|
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
ExtRoles: "administrator,engineer,analyst",
|
|
}
|
|
|
|
decoded, err := Decode(m)
|
|
if err != nil {
|
|
t.Fatalf("Decode: %v", err)
|
|
}
|
|
if len(decoded.Roles) != 3 {
|
|
t.Fatalf("Roles length: got %d, want 3", len(decoded.Roles))
|
|
}
|
|
expected := []string{"administrator", "engineer", "analyst"}
|
|
for i, r := range decoded.Roles {
|
|
if r != expected[i] {
|
|
t.Errorf("Roles[%d]: got %q, want %q", i, r, expected[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDecodeMerkleProofBase64(t *testing.T) {
|
|
proof := []byte{0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04}
|
|
m := map[string]string{
|
|
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
ExtRoles: "analyst",
|
|
ExtMerkleRoot: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
|
|
ExtMerkleProof: base64.StdEncoding.EncodeToString(proof),
|
|
}
|
|
|
|
decoded, err := Decode(m)
|
|
if err != nil {
|
|
t.Fatalf("Decode: %v", err)
|
|
}
|
|
if len(decoded.MerkleProof) != len(proof) {
|
|
t.Fatalf("MerkleProof length: got %d, want %d", len(decoded.MerkleProof), len(proof))
|
|
}
|
|
for i, b := range decoded.MerkleProof {
|
|
if b != proof[i] {
|
|
t.Errorf("MerkleProof[%d]: got %x, want %x", i, b, proof[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDecodeInvalidMerkleProofBase64(t *testing.T) {
|
|
m := map[string]string{
|
|
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
ExtRoles: "analyst",
|
|
ExtMerkleProof: "not-valid-base64!!!",
|
|
}
|
|
|
|
_, err := Decode(m)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid base64")
|
|
}
|
|
if !strings.Contains(err.Error(), "decode merkle-proof") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDecodeGovernanceEpoch(t *testing.T) {
|
|
m := map[string]string{
|
|
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
ExtRoles: "analyst",
|
|
ExtGovernanceEpoch: "12345",
|
|
}
|
|
|
|
decoded, err := Decode(m)
|
|
if err != nil {
|
|
t.Fatalf("Decode: %v", err)
|
|
}
|
|
if decoded.GovernanceEpoch != 12345 {
|
|
t.Errorf("GovernanceEpoch: got %d, want 12345", decoded.GovernanceEpoch)
|
|
}
|
|
if !decoded.HasGovernanceEpoch() {
|
|
t.Error("HasGovernanceEpoch: got false, want true")
|
|
}
|
|
}
|
|
|
|
func TestDecodeGovernanceEpochZero(t *testing.T) {
|
|
m := map[string]string{
|
|
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
ExtRoles: "analyst",
|
|
ExtGovernanceEpoch: "0",
|
|
}
|
|
|
|
decoded, err := Decode(m)
|
|
if err != nil {
|
|
t.Fatalf("Decode: %v", err)
|
|
}
|
|
if decoded.GovernanceEpoch != 0 {
|
|
t.Errorf("GovernanceEpoch: got %d, want 0", decoded.GovernanceEpoch)
|
|
}
|
|
if !decoded.HasGovernanceEpoch() {
|
|
t.Error("HasGovernanceEpoch: got false, want true (epoch 0 is valid)")
|
|
}
|
|
}
|
|
|
|
func TestDecodeInvalidGovernanceEpoch(t *testing.T) {
|
|
m := map[string]string{
|
|
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
ExtRoles: "analyst",
|
|
ExtGovernanceEpoch: "not-a-number",
|
|
}
|
|
|
|
_, err := Decode(m)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid epoch")
|
|
}
|
|
if !strings.Contains(err.Error(), "parse governance-epoch") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEncodeNilReturnsError(t *testing.T) {
|
|
_, err := Encode(nil)
|
|
if err == nil {
|
|
t.Fatal("expected error for nil extensions")
|
|
}
|
|
}
|
|
|
|
func TestValidateNilReturnsError(t *testing.T) {
|
|
err := Validate(nil)
|
|
if err == nil {
|
|
t.Fatal("expected error for nil extensions")
|
|
}
|
|
}
|
|
|
|
func TestValidateFullExtensions(t *testing.T) {
|
|
ext := fullExtensions()
|
|
err := Validate(ext)
|
|
if err != nil {
|
|
t.Fatalf("unexpected validation error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateMinimalExtensions(t *testing.T) {
|
|
ext := minimalExtensions()
|
|
err := Validate(ext)
|
|
if err != nil {
|
|
t.Fatalf("unexpected validation error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestIsValidUUID(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want bool
|
|
}{
|
|
{"a1b2c3d4-e5f6-7890-abcd-ef1234567890", true},
|
|
{"00000000-0000-0000-0000-000000000000", true},
|
|
{"A1B2C3D4-E5F6-7890-ABCD-EF1234567890", false}, // uppercase
|
|
{"a1b2c3d4e5f6-7890-abcd-ef1234567890", false}, // missing hyphen
|
|
{"too-short", false},
|
|
{"", false},
|
|
{"a1b2c3d4-e5f6-7890-abcd-ef12345678901", false}, // too long
|
|
}
|
|
for _, tt := range tests {
|
|
got := isValidUUID(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("isValidUUID(%q) = %v, want %v", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDecodeSatScopeArrayForm(t *testing.T) {
|
|
m := map[string]string{
|
|
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
ExtRoles: "analyst",
|
|
ExtSatScope: `[{"registry_type":"oci","verbs":["pull"],"resource_pattern":"acme/*"},{"registry_type":"helm","verbs":["read"],"resource_pattern":"charts/*"}]`,
|
|
ExtSatHash: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
|
}
|
|
|
|
decoded, err := Decode(m)
|
|
if err != nil {
|
|
t.Fatalf("Decode: %v", err)
|
|
}
|
|
if len(decoded.SatScopes) != 2 {
|
|
t.Fatalf("SatScopes length: got %d, want 2", len(decoded.SatScopes))
|
|
}
|
|
if decoded.SatScopes[0].RegistryType != "oci" {
|
|
t.Errorf("SatScopes[0].RegistryType: got %q, want %q", decoded.SatScopes[0].RegistryType, "oci")
|
|
}
|
|
if decoded.SatScopes[1].RegistryType != "helm" {
|
|
t.Errorf("SatScopes[1].RegistryType: got %q, want %q", decoded.SatScopes[1].RegistryType, "helm")
|
|
}
|
|
if decoded.SatScopes[0].ResourcePattern != "acme/*" {
|
|
t.Errorf("SatScopes[0].ResourcePattern: got %q, want %q", decoded.SatScopes[0].ResourcePattern, "acme/*")
|
|
}
|
|
if decoded.SatScopes[1].ResourcePattern != "charts/*" {
|
|
t.Errorf("SatScopes[1].ResourcePattern: got %q, want %q", decoded.SatScopes[1].ResourcePattern, "charts/*")
|
|
}
|
|
}
|
|
|
|
func TestEncodeDecodeArrayFormRoundTrip(t *testing.T) {
|
|
ext := minimalExtensions()
|
|
ext.SatScopes = []*SatScope{
|
|
{RegistryType: "oci", Verbs: []string{"pull"}, ResourcePattern: "acme/*"},
|
|
{RegistryType: "helm", Verbs: []string{"read"}, ResourcePattern: "charts/*"},
|
|
}
|
|
ext.SatHash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
|
|
|
|
encoded, err := Encode(ext)
|
|
if err != nil {
|
|
t.Fatalf("Encode: %v", err)
|
|
}
|
|
|
|
// Array form should start with [.
|
|
scopeVal := encoded[ExtSatScope]
|
|
if scopeVal[0] != '[' {
|
|
t.Errorf("expected array JSON, got: %s", scopeVal)
|
|
}
|
|
|
|
decoded, err := Decode(encoded)
|
|
if err != nil {
|
|
t.Fatalf("Decode: %v", err)
|
|
}
|
|
if len(decoded.SatScopes) != 2 {
|
|
t.Fatalf("SatScopes length: got %d, want 2", len(decoded.SatScopes))
|
|
}
|
|
if decoded.SatScopes[0].RegistryType != "oci" {
|
|
t.Errorf("SatScopes[0].RegistryType: got %q", decoded.SatScopes[0].RegistryType)
|
|
}
|
|
if decoded.SatScopes[1].RegistryType != "helm" {
|
|
t.Errorf("SatScopes[1].RegistryType: got %q", decoded.SatScopes[1].RegistryType)
|
|
}
|
|
}
|
|
|
|
func TestEncodeSingleScopeProducesObject(t *testing.T) {
|
|
ext := minimalExtensions()
|
|
ext.SatScopes = []*SatScope{{
|
|
RegistryType: "oci", Verbs: []string{"pull"}, ResourcePattern: "*",
|
|
}}
|
|
ext.SatHash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
|
|
|
|
encoded, err := Encode(ext)
|
|
if err != nil {
|
|
t.Fatalf("Encode: %v", err)
|
|
}
|
|
scopeVal := encoded[ExtSatScope]
|
|
if scopeVal[0] != '{' {
|
|
t.Errorf("single scope should encode as object, got: %s", scopeVal)
|
|
}
|
|
}
|
|
|
|
func TestValidateInvalidGovernanceIntent(t *testing.T) {
|
|
ext := minimalExtensions()
|
|
ext.GovernanceIntent = "not-a-uuid"
|
|
err := Validate(ext)
|
|
if err == nil || !strings.Contains(err.Error(), "governance-intent is not a valid UUID") {
|
|
t.Errorf("expected UUID validation error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDecodeGovernanceIntent(t *testing.T) {
|
|
m := map[string]string{
|
|
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
ExtRoles: "analyst",
|
|
ExtGovernanceIntent: "c8d9e0f1-2a3b-4c5d-6e7f-8a9b0c1d2e3f",
|
|
}
|
|
decoded, err := Decode(m)
|
|
if err != nil {
|
|
t.Fatalf("Decode: %v", err)
|
|
}
|
|
if decoded.GovernanceIntent != "c8d9e0f1-2a3b-4c5d-6e7f-8a9b0c1d2e3f" {
|
|
t.Errorf("GovernanceIntent: got %q", decoded.GovernanceIntent)
|
|
}
|
|
}
|
|
|
|
func TestValidateEmptyResourcePattern(t *testing.T) {
|
|
ext := minimalExtensions()
|
|
ext.SatScopes = []*SatScope{{
|
|
RegistryType: "oci",
|
|
Verbs: []string{"pull"},
|
|
ResourcePattern: "",
|
|
}}
|
|
ext.SatHash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
|
|
err := Validate(ext)
|
|
if err == nil || !strings.Contains(err.Error(), "resource_pattern is required") {
|
|
t.Errorf("expected resource_pattern error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// loadFixture reads a test fixture from the test/fixtures directory.
|
|
func loadFixture(t *testing.T, name string) []byte {
|
|
t.Helper()
|
|
data, err := os.ReadFile(filepath.Join("..", "..", "test", "fixtures", name))
|
|
if err != nil {
|
|
t.Fatalf("load fixture %s: %v", name, err)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func TestDecodeFixtureFile(t *testing.T) {
|
|
data := loadFixture(t, "sample-ssh-cert-extensions.json")
|
|
var extensions map[string]string
|
|
if err := json.Unmarshal(data, &extensions); err != nil {
|
|
t.Fatalf("unmarshal fixture: %v", err)
|
|
}
|
|
|
|
ext, err := Decode(extensions)
|
|
if err != nil {
|
|
t.Fatalf("Decode fixture: %v", err)
|
|
}
|
|
|
|
if err := Validate(ext); err != nil {
|
|
t.Fatalf("Validate fixture: %v", err)
|
|
}
|
|
|
|
// Verify key fields parsed correctly from fixture.
|
|
if ext.TenantID != "a1b2c3d4-e5f6-7890-abcd-ef1234567890" {
|
|
t.Errorf("TenantID: got %q", ext.TenantID)
|
|
}
|
|
if len(ext.Roles) != 2 || ext.Roles[0] != "administrator" || ext.Roles[1] != "engineer" {
|
|
t.Errorf("Roles: got %v", ext.Roles)
|
|
}
|
|
if len(ext.SatScopes) != 1 || ext.SatScopes[0].RegistryType != "oci" {
|
|
t.Errorf("SatScopes: got %v", ext.SatScopes)
|
|
}
|
|
if ext.CeremonyType != "single_approval" {
|
|
t.Errorf("CeremonyType: got %q", ext.CeremonyType)
|
|
}
|
|
if !ext.HasGovernanceEpoch() || ext.GovernanceEpoch != 42 {
|
|
t.Errorf("GovernanceEpoch: got %d (has=%v)", ext.GovernanceEpoch, ext.HasGovernanceEpoch())
|
|
}
|
|
if ext.GovernanceIntent != "c8d9e0f1-2a3b-4c5d-6e7f-8a9b0c1d2e3f" {
|
|
t.Errorf("GovernanceIntent: got %q", ext.GovernanceIntent)
|
|
}
|
|
}
|
|
|
|
// --- S-05: Size limit tests ---
|
|
|
|
func TestDecodeRejectsOversizedMerkleProof(t *testing.T) {
|
|
big := strings.Repeat("A", 1024) // well over MaxMerkleProofBase64Len
|
|
m := map[string]string{
|
|
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
ExtRoles: "analyst",
|
|
ExtMerkleProof: big,
|
|
}
|
|
_, err := Decode(m)
|
|
if err == nil {
|
|
t.Fatal("expected error for oversized merkle-proof")
|
|
}
|
|
if !strings.Contains(err.Error(), "merkle-proof length") {
|
|
t.Errorf("expected merkle-proof size error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDecodeAcceptsMaxSizeMerkleProof(t *testing.T) {
|
|
// 256 bytes of proof → ~344 base64 chars, under the 512 limit
|
|
proof := make([]byte, 256)
|
|
for i := range proof {
|
|
proof[i] = byte(i % 256)
|
|
}
|
|
encoded := base64.StdEncoding.EncodeToString(proof)
|
|
m := map[string]string{
|
|
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
ExtRoles: "analyst",
|
|
ExtMerkleRoot: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
|
|
ExtMerkleProof: encoded,
|
|
}
|
|
decoded, err := Decode(m)
|
|
if err != nil {
|
|
t.Fatalf("expected valid decode, got: %v", err)
|
|
}
|
|
if len(decoded.MerkleProof) != 256 {
|
|
t.Errorf("MerkleProof length: got %d, want 256", len(decoded.MerkleProof))
|
|
}
|
|
}
|
|
|
|
func TestDecodeRejectsOversizedTotalExtensions(t *testing.T) {
|
|
big := strings.Repeat("x", MaxTotalExtensionsLen+1)
|
|
m := map[string]string{
|
|
"bigkey": big,
|
|
}
|
|
_, err := Decode(m)
|
|
if err == nil {
|
|
t.Fatal("expected error for oversized total extensions")
|
|
}
|
|
if !strings.Contains(err.Error(), "total extension size") {
|
|
t.Errorf("expected total size error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDecodeRejectsOversizedSingleValue(t *testing.T) {
|
|
big := strings.Repeat("x", MaxExtensionValueLen+1)
|
|
m := map[string]string{
|
|
ExtTenantID: big,
|
|
ExtRoles: "analyst",
|
|
}
|
|
_, err := Decode(m)
|
|
if err == nil {
|
|
t.Fatal("expected error for oversized single value")
|
|
}
|
|
if !strings.Contains(err.Error(), "length") && !strings.Contains(err.Error(), "exceeds maximum") {
|
|
t.Errorf("expected size error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// --- S-11: Role sanitization tests ---
|
|
|
|
func TestDecodeRolesTrimsWhitespace(t *testing.T) {
|
|
m := map[string]string{
|
|
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
ExtRoles: "admin, engineer",
|
|
}
|
|
decoded, err := Decode(m)
|
|
if err != nil {
|
|
t.Fatalf("Decode: %v", err)
|
|
}
|
|
if len(decoded.Roles) != 2 {
|
|
t.Fatalf("Roles length: got %d, want 2", len(decoded.Roles))
|
|
}
|
|
if decoded.Roles[0] != "admin" || decoded.Roles[1] != "engineer" {
|
|
t.Errorf("Roles: got %v, want [admin engineer]", decoded.Roles)
|
|
}
|
|
}
|
|
|
|
func TestDecodeRolesFiltersEmpty(t *testing.T) {
|
|
m := map[string]string{
|
|
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
ExtRoles: "admin,,engineer",
|
|
}
|
|
decoded, err := Decode(m)
|
|
if err != nil {
|
|
t.Fatalf("Decode: %v", err)
|
|
}
|
|
if len(decoded.Roles) != 2 {
|
|
t.Fatalf("Roles length: got %d, want 2", len(decoded.Roles))
|
|
}
|
|
if decoded.Roles[0] != "admin" || decoded.Roles[1] != "engineer" {
|
|
t.Errorf("Roles: got %v, want [admin engineer]", decoded.Roles)
|
|
}
|
|
}
|
|
|
|
func TestDecodeRolesAllEmpty(t *testing.T) {
|
|
m := map[string]string{
|
|
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
ExtRoles: ",,",
|
|
}
|
|
_, err := Decode(m)
|
|
if err == nil {
|
|
t.Fatal("expected error for all-empty roles")
|
|
}
|
|
if !strings.Contains(err.Error(), "no valid roles") {
|
|
t.Errorf("expected no-valid-roles error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDecodeRolesSingleWithSpaces(t *testing.T) {
|
|
m := map[string]string{
|
|
ExtTenantID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
ExtRoles: " engineer ",
|
|
}
|
|
decoded, err := Decode(m)
|
|
if err != nil {
|
|
t.Fatalf("Decode: %v", err)
|
|
}
|
|
if len(decoded.Roles) != 1 || decoded.Roles[0] != "engineer" {
|
|
t.Errorf("Roles: got %v, want [engineer]", decoded.Roles)
|
|
}
|
|
}
|
|
|
|
// mapKeys returns the keys of a map for debug output.
|
|
func mapKeys(m map[string]string) []string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
return keys
|
|
}
|