From 4a27c96f5a7ea5e95c267be045bfe20ec9f9440f Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Tue, 31 Mar 2026 14:04:38 -0700 Subject: [PATCH 01/15] feat(sdk): add ergonomic EntityIdentifier constructors for authorization v2 Adds five convenience constructors (ForToken, WithRequestToken, ForClientID, ForEmail, ForUserName) that eliminate the 4-level proto nesting previously required to build an EntityIdentifier for v2 authorization requests. Co-Authored-By: Claude Opus 4.6 (1M context) --- sdk/entity_identifier.go | 63 ++++++++++++++++++++++++++++++ sdk/entity_identifier_test.go | 73 +++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 sdk/entity_identifier.go create mode 100644 sdk/entity_identifier_test.go diff --git a/sdk/entity_identifier.go b/sdk/entity_identifier.go new file mode 100644 index 0000000000..5c8174559c --- /dev/null +++ b/sdk/entity_identifier.go @@ -0,0 +1,63 @@ +package sdk + +import ( + authorizationv2 "github.com/opentdf/platform/protocol/go/authorization/v2" + "github.com/opentdf/platform/protocol/go/entity" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +// ForToken returns an EntityIdentifier that resolves the entity from the given JWT. +// The authorization service will parse the token to derive the entity chain. +func ForToken(jwt string) *authorizationv2.EntityIdentifier { + return &authorizationv2.EntityIdentifier{ + Identifier: &authorizationv2.EntityIdentifier_Token{ + Token: &entity.Token{ + Jwt: jwt, + }, + }, + } +} + +// WithRequestToken returns an EntityIdentifier that instructs the authorization +// service to derive the entity from the request's Authorization header token. +func WithRequestToken() *authorizationv2.EntityIdentifier { + return &authorizationv2.EntityIdentifier{ + Identifier: &authorizationv2.EntityIdentifier_WithRequestToken{ + WithRequestToken: wrapperspb.Bool(true), + }, + } +} + +// ForClientID returns an EntityIdentifier for a single subject entity identified by client ID. +func ForClientID(clientID string) *authorizationv2.EntityIdentifier { + return entityIdentifierFromEntity(&entity.Entity{ + EntityType: &entity.Entity_ClientId{ClientId: clientID}, + Category: entity.Entity_CATEGORY_SUBJECT, + }) +} + +// ForEmail returns an EntityIdentifier for a single subject entity identified by email address. +func ForEmail(email string) *authorizationv2.EntityIdentifier { + return entityIdentifierFromEntity(&entity.Entity{ + EntityType: &entity.Entity_EmailAddress{EmailAddress: email}, + Category: entity.Entity_CATEGORY_SUBJECT, + }) +} + +// ForUserName returns an EntityIdentifier for a single subject entity identified by username. +func ForUserName(username string) *authorizationv2.EntityIdentifier { + return entityIdentifierFromEntity(&entity.Entity{ + EntityType: &entity.Entity_UserName{UserName: username}, + Category: entity.Entity_CATEGORY_SUBJECT, + }) +} + +func entityIdentifierFromEntity(e *entity.Entity) *authorizationv2.EntityIdentifier { + return &authorizationv2.EntityIdentifier{ + Identifier: &authorizationv2.EntityIdentifier_EntityChain{ + EntityChain: &entity.EntityChain{ + Entities: []*entity.Entity{e}, + }, + }, + } +} diff --git a/sdk/entity_identifier_test.go b/sdk/entity_identifier_test.go new file mode 100644 index 0000000000..bc055a212e --- /dev/null +++ b/sdk/entity_identifier_test.go @@ -0,0 +1,73 @@ +package sdk + +import ( + "testing" + + authorizationv2 "github.com/opentdf/platform/protocol/go/authorization/v2" + "github.com/opentdf/platform/protocol/go/entity" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestForToken(t *testing.T) { + jwt := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test" + eid := ForToken(jwt) + + tok, ok := eid.GetIdentifier().(*authorizationv2.EntityIdentifier_Token) + require.True(t, ok, "expected Token identifier") + assert.Equal(t, jwt, tok.Token.GetJwt()) +} + +func TestWithRequestToken(t *testing.T) { + eid := WithRequestToken() + + wrt, ok := eid.GetIdentifier().(*authorizationv2.EntityIdentifier_WithRequestToken) + require.True(t, ok, "expected WithRequestToken identifier") + assert.True(t, wrt.WithRequestToken.GetValue()) +} + +func TestForClientID(t *testing.T) { + eid := ForClientID("my-client") + + chain := extractEntityChain(t, eid) + require.Len(t, chain.GetEntities(), 1) + + e := chain.GetEntities()[0] + cid, ok := e.GetEntityType().(*entity.Entity_ClientId) + require.True(t, ok, "expected ClientId entity type") + assert.Equal(t, "my-client", cid.ClientId) + assert.Equal(t, entity.Entity_CATEGORY_SUBJECT, e.GetCategory()) +} + +func TestForEmail(t *testing.T) { + eid := ForEmail("user@example.com") + + chain := extractEntityChain(t, eid) + require.Len(t, chain.GetEntities(), 1) + + e := chain.GetEntities()[0] + em, ok := e.GetEntityType().(*entity.Entity_EmailAddress) + require.True(t, ok, "expected EmailAddress entity type") + assert.Equal(t, "user@example.com", em.EmailAddress) + assert.Equal(t, entity.Entity_CATEGORY_SUBJECT, e.GetCategory()) +} + +func TestForUserName(t *testing.T) { + eid := ForUserName("alice") + + chain := extractEntityChain(t, eid) + require.Len(t, chain.GetEntities(), 1) + + e := chain.GetEntities()[0] + un, ok := e.GetEntityType().(*entity.Entity_UserName) + require.True(t, ok, "expected UserName entity type") + assert.Equal(t, "alice", un.UserName) + assert.Equal(t, entity.Entity_CATEGORY_SUBJECT, e.GetCategory()) +} + +func extractEntityChain(t *testing.T, eid *authorizationv2.EntityIdentifier) *entity.EntityChain { + t.Helper() + ec, ok := eid.GetIdentifier().(*authorizationv2.EntityIdentifier_EntityChain) + require.True(t, ok, "expected EntityChain identifier") + return ec.EntityChain +} From 58ef47136e51bc03a566b9efe7bc783219a5de38 Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Tue, 31 Mar 2026 17:06:38 -0700 Subject: [PATCH 02/15] refactor(sdk): move EntityIdentifier helpers to protocol/go/authorization/v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the convenience constructors into the authorizationv2 package itself so callers don't need an extra import — authorizationv2.ForClientID() works directly alongside authorizationv2.GetDecisionRequest{}. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Mary Dickson --- .../go/authorization/v2}/entity_identifier.go | 27 +++++++++---------- .../v2}/entity_identifier_test.go | 11 ++++---- 2 files changed, 18 insertions(+), 20 deletions(-) rename {sdk => protocol/go/authorization/v2}/entity_identifier.go (63%) rename {sdk => protocol/go/authorization/v2}/entity_identifier_test.go (81%) diff --git a/sdk/entity_identifier.go b/protocol/go/authorization/v2/entity_identifier.go similarity index 63% rename from sdk/entity_identifier.go rename to protocol/go/authorization/v2/entity_identifier.go index 5c8174559c..393a87cce4 100644 --- a/sdk/entity_identifier.go +++ b/protocol/go/authorization/v2/entity_identifier.go @@ -1,16 +1,15 @@ -package sdk +package authorizationv2 import ( - authorizationv2 "github.com/opentdf/platform/protocol/go/authorization/v2" "github.com/opentdf/platform/protocol/go/entity" "google.golang.org/protobuf/types/known/wrapperspb" ) // ForToken returns an EntityIdentifier that resolves the entity from the given JWT. // The authorization service will parse the token to derive the entity chain. -func ForToken(jwt string) *authorizationv2.EntityIdentifier { - return &authorizationv2.EntityIdentifier{ - Identifier: &authorizationv2.EntityIdentifier_Token{ +func ForToken(jwt string) *EntityIdentifier { + return &EntityIdentifier{ + Identifier: &EntityIdentifier_Token{ Token: &entity.Token{ Jwt: jwt, }, @@ -20,16 +19,16 @@ func ForToken(jwt string) *authorizationv2.EntityIdentifier { // WithRequestToken returns an EntityIdentifier that instructs the authorization // service to derive the entity from the request's Authorization header token. -func WithRequestToken() *authorizationv2.EntityIdentifier { - return &authorizationv2.EntityIdentifier{ - Identifier: &authorizationv2.EntityIdentifier_WithRequestToken{ +func WithRequestToken() *EntityIdentifier { + return &EntityIdentifier{ + Identifier: &EntityIdentifier_WithRequestToken{ WithRequestToken: wrapperspb.Bool(true), }, } } // ForClientID returns an EntityIdentifier for a single subject entity identified by client ID. -func ForClientID(clientID string) *authorizationv2.EntityIdentifier { +func ForClientID(clientID string) *EntityIdentifier { return entityIdentifierFromEntity(&entity.Entity{ EntityType: &entity.Entity_ClientId{ClientId: clientID}, Category: entity.Entity_CATEGORY_SUBJECT, @@ -37,7 +36,7 @@ func ForClientID(clientID string) *authorizationv2.EntityIdentifier { } // ForEmail returns an EntityIdentifier for a single subject entity identified by email address. -func ForEmail(email string) *authorizationv2.EntityIdentifier { +func ForEmail(email string) *EntityIdentifier { return entityIdentifierFromEntity(&entity.Entity{ EntityType: &entity.Entity_EmailAddress{EmailAddress: email}, Category: entity.Entity_CATEGORY_SUBJECT, @@ -45,16 +44,16 @@ func ForEmail(email string) *authorizationv2.EntityIdentifier { } // ForUserName returns an EntityIdentifier for a single subject entity identified by username. -func ForUserName(username string) *authorizationv2.EntityIdentifier { +func ForUserName(username string) *EntityIdentifier { return entityIdentifierFromEntity(&entity.Entity{ EntityType: &entity.Entity_UserName{UserName: username}, Category: entity.Entity_CATEGORY_SUBJECT, }) } -func entityIdentifierFromEntity(e *entity.Entity) *authorizationv2.EntityIdentifier { - return &authorizationv2.EntityIdentifier{ - Identifier: &authorizationv2.EntityIdentifier_EntityChain{ +func entityIdentifierFromEntity(e *entity.Entity) *EntityIdentifier { + return &EntityIdentifier{ + Identifier: &EntityIdentifier_EntityChain{ EntityChain: &entity.EntityChain{ Entities: []*entity.Entity{e}, }, diff --git a/sdk/entity_identifier_test.go b/protocol/go/authorization/v2/entity_identifier_test.go similarity index 81% rename from sdk/entity_identifier_test.go rename to protocol/go/authorization/v2/entity_identifier_test.go index bc055a212e..79f4045fcf 100644 --- a/sdk/entity_identifier_test.go +++ b/protocol/go/authorization/v2/entity_identifier_test.go @@ -1,9 +1,8 @@ -package sdk +package authorizationv2 import ( "testing" - authorizationv2 "github.com/opentdf/platform/protocol/go/authorization/v2" "github.com/opentdf/platform/protocol/go/entity" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -13,7 +12,7 @@ func TestForToken(t *testing.T) { jwt := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test" eid := ForToken(jwt) - tok, ok := eid.GetIdentifier().(*authorizationv2.EntityIdentifier_Token) + tok, ok := eid.GetIdentifier().(*EntityIdentifier_Token) require.True(t, ok, "expected Token identifier") assert.Equal(t, jwt, tok.Token.GetJwt()) } @@ -21,7 +20,7 @@ func TestForToken(t *testing.T) { func TestWithRequestToken(t *testing.T) { eid := WithRequestToken() - wrt, ok := eid.GetIdentifier().(*authorizationv2.EntityIdentifier_WithRequestToken) + wrt, ok := eid.GetIdentifier().(*EntityIdentifier_WithRequestToken) require.True(t, ok, "expected WithRequestToken identifier") assert.True(t, wrt.WithRequestToken.GetValue()) } @@ -65,9 +64,9 @@ func TestForUserName(t *testing.T) { assert.Equal(t, entity.Entity_CATEGORY_SUBJECT, e.GetCategory()) } -func extractEntityChain(t *testing.T, eid *authorizationv2.EntityIdentifier) *entity.EntityChain { +func extractEntityChain(t *testing.T, eid *EntityIdentifier) *entity.EntityChain { t.Helper() - ec, ok := eid.GetIdentifier().(*authorizationv2.EntityIdentifier_EntityChain) + ec, ok := eid.GetIdentifier().(*EntityIdentifier_EntityChain) require.True(t, ok, "expected EntityChain identifier") return ec.EntityChain } From 70823f9077a512448b60841e0f33cc95509b0f3b Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Tue, 31 Mar 2026 17:23:53 -0700 Subject: [PATCH 03/15] chore(deps): go mod tidy for protocol/go (testify dependency) Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Mary Dickson --- protocol/go/go.mod | 6 ++++++ protocol/go/go.sum | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/protocol/go/go.mod b/protocol/go/go.mod index 03c0ad01c8..7204efec0f 100644 --- a/protocol/go/go.mod +++ b/protocol/go/go.mod @@ -8,14 +8,20 @@ require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.34.1-20240508200655-46a4cf4ba109.1 connectrpc.com/connect v1.19.1 github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 + github.com/stretchr/testify v1.11.1 google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 google.golang.org/grpc v1.79.3 google.golang.org/protobuf v1.36.10 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/protocol/go/go.sum b/protocol/go/go.sum index a191a9d6dc..37bbbab233 100644 --- a/protocol/go/go.sum +++ b/protocol/go/go.sum @@ -4,6 +4,9 @@ connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -18,6 +21,16 @@ 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/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= @@ -49,3 +62,8 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From d856ec9289f39b1c75632cfc4273f47da30e840b Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Tue, 31 Mar 2026 17:38:21 -0700 Subject: [PATCH 04/15] chore(sdk): use stdlib testing instead of testify in entity_identifier tests Removes the testify dependency from protocol/go to avoid adding new external dependencies to the proto module. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Mary Dickson --- .../v2/entity_identifier_test.go | 79 +++++++++++++------ protocol/go/go.mod | 6 -- protocol/go/go.sum | 18 ----- 3 files changed, 57 insertions(+), 46 deletions(-) diff --git a/protocol/go/authorization/v2/entity_identifier_test.go b/protocol/go/authorization/v2/entity_identifier_test.go index 79f4045fcf..3e0982d8a4 100644 --- a/protocol/go/authorization/v2/entity_identifier_test.go +++ b/protocol/go/authorization/v2/entity_identifier_test.go @@ -4,8 +4,6 @@ import ( "testing" "github.com/opentdf/platform/protocol/go/entity" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestForToken(t *testing.T) { @@ -13,60 +11,97 @@ func TestForToken(t *testing.T) { eid := ForToken(jwt) tok, ok := eid.GetIdentifier().(*EntityIdentifier_Token) - require.True(t, ok, "expected Token identifier") - assert.Equal(t, jwt, tok.Token.GetJwt()) + if !ok { + t.Fatal("expected Token identifier") + } + if got := tok.Token.GetJwt(); got != jwt { + t.Errorf("jwt = %q, want %q", got, jwt) + } } func TestWithRequestToken(t *testing.T) { eid := WithRequestToken() wrt, ok := eid.GetIdentifier().(*EntityIdentifier_WithRequestToken) - require.True(t, ok, "expected WithRequestToken identifier") - assert.True(t, wrt.WithRequestToken.GetValue()) + if !ok { + t.Fatal("expected WithRequestToken identifier") + } + if !wrt.WithRequestToken.GetValue() { + t.Error("expected WithRequestToken value to be true") + } } func TestForClientID(t *testing.T) { eid := ForClientID("my-client") chain := extractEntityChain(t, eid) - require.Len(t, chain.GetEntities(), 1) + entities := chain.GetEntities() + if len(entities) != 1 { + t.Fatalf("entities len = %d, want 1", len(entities)) + } - e := chain.GetEntities()[0] + e := entities[0] cid, ok := e.GetEntityType().(*entity.Entity_ClientId) - require.True(t, ok, "expected ClientId entity type") - assert.Equal(t, "my-client", cid.ClientId) - assert.Equal(t, entity.Entity_CATEGORY_SUBJECT, e.GetCategory()) + if !ok { + t.Fatal("expected ClientId entity type") + } + if cid.ClientId != "my-client" { + t.Errorf("ClientId = %q, want %q", cid.ClientId, "my-client") + } + if e.GetCategory() != entity.Entity_CATEGORY_SUBJECT { + t.Errorf("category = %v, want CATEGORY_SUBJECT", e.GetCategory()) + } } func TestForEmail(t *testing.T) { eid := ForEmail("user@example.com") chain := extractEntityChain(t, eid) - require.Len(t, chain.GetEntities(), 1) + entities := chain.GetEntities() + if len(entities) != 1 { + t.Fatalf("entities len = %d, want 1", len(entities)) + } - e := chain.GetEntities()[0] + e := entities[0] em, ok := e.GetEntityType().(*entity.Entity_EmailAddress) - require.True(t, ok, "expected EmailAddress entity type") - assert.Equal(t, "user@example.com", em.EmailAddress) - assert.Equal(t, entity.Entity_CATEGORY_SUBJECT, e.GetCategory()) + if !ok { + t.Fatal("expected EmailAddress entity type") + } + if em.EmailAddress != "user@example.com" { + t.Errorf("EmailAddress = %q, want %q", em.EmailAddress, "user@example.com") + } + if e.GetCategory() != entity.Entity_CATEGORY_SUBJECT { + t.Errorf("category = %v, want CATEGORY_SUBJECT", e.GetCategory()) + } } func TestForUserName(t *testing.T) { eid := ForUserName("alice") chain := extractEntityChain(t, eid) - require.Len(t, chain.GetEntities(), 1) + entities := chain.GetEntities() + if len(entities) != 1 { + t.Fatalf("entities len = %d, want 1", len(entities)) + } - e := chain.GetEntities()[0] + e := entities[0] un, ok := e.GetEntityType().(*entity.Entity_UserName) - require.True(t, ok, "expected UserName entity type") - assert.Equal(t, "alice", un.UserName) - assert.Equal(t, entity.Entity_CATEGORY_SUBJECT, e.GetCategory()) + if !ok { + t.Fatal("expected UserName entity type") + } + if un.UserName != "alice" { + t.Errorf("UserName = %q, want %q", un.UserName, "alice") + } + if e.GetCategory() != entity.Entity_CATEGORY_SUBJECT { + t.Errorf("category = %v, want CATEGORY_SUBJECT", e.GetCategory()) + } } func extractEntityChain(t *testing.T, eid *EntityIdentifier) *entity.EntityChain { t.Helper() ec, ok := eid.GetIdentifier().(*EntityIdentifier_EntityChain) - require.True(t, ok, "expected EntityChain identifier") + if !ok { + t.Fatal("expected EntityChain identifier") + } return ec.EntityChain } diff --git a/protocol/go/go.mod b/protocol/go/go.mod index 7204efec0f..03c0ad01c8 100644 --- a/protocol/go/go.mod +++ b/protocol/go/go.mod @@ -8,20 +8,14 @@ require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.34.1-20240508200655-46a4cf4ba109.1 connectrpc.com/connect v1.19.1 github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 - github.com/stretchr/testify v1.11.1 google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 google.golang.org/grpc v1.79.3 google.golang.org/protobuf v1.36.10 ) require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/kr/text v0.2.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/protocol/go/go.sum b/protocol/go/go.sum index 37bbbab233..a191a9d6dc 100644 --- a/protocol/go/go.sum +++ b/protocol/go/go.sum @@ -4,9 +4,6 @@ connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -21,16 +18,6 @@ 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/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= @@ -62,8 +49,3 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 89f91542b23b6e19e5eb91f43bd495cf6a33ddf2 Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Thu, 2 Apr 2026 11:51:45 -0700 Subject: [PATCH 05/15] chore(sdk): refactor entity identifier tests to table-driven with edge cases Refactors ForClientID, ForEmail, and ForUserName tests into a single table-driven TestEntityChainConstructors and adds empty-string edge cases for all constructors. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Mary Dickson --- .../v2/entity_identifier_test.go | 178 +++++++++++------- 1 file changed, 115 insertions(+), 63 deletions(-) diff --git a/protocol/go/authorization/v2/entity_identifier_test.go b/protocol/go/authorization/v2/entity_identifier_test.go index 3e0982d8a4..e42ce7b6b9 100644 --- a/protocol/go/authorization/v2/entity_identifier_test.go +++ b/protocol/go/authorization/v2/entity_identifier_test.go @@ -19,81 +19,133 @@ func TestForToken(t *testing.T) { } } -func TestWithRequestToken(t *testing.T) { - eid := WithRequestToken() - - wrt, ok := eid.GetIdentifier().(*EntityIdentifier_WithRequestToken) - if !ok { - t.Fatal("expected WithRequestToken identifier") - } - if !wrt.WithRequestToken.GetValue() { - t.Error("expected WithRequestToken value to be true") - } -} - -func TestForClientID(t *testing.T) { - eid := ForClientID("my-client") +func TestForToken_EmptyString(t *testing.T) { + eid := ForToken("") - chain := extractEntityChain(t, eid) - entities := chain.GetEntities() - if len(entities) != 1 { - t.Fatalf("entities len = %d, want 1", len(entities)) - } - - e := entities[0] - cid, ok := e.GetEntityType().(*entity.Entity_ClientId) + tok, ok := eid.GetIdentifier().(*EntityIdentifier_Token) if !ok { - t.Fatal("expected ClientId entity type") - } - if cid.ClientId != "my-client" { - t.Errorf("ClientId = %q, want %q", cid.ClientId, "my-client") + t.Fatal("expected Token identifier") } - if e.GetCategory() != entity.Entity_CATEGORY_SUBJECT { - t.Errorf("category = %v, want CATEGORY_SUBJECT", e.GetCategory()) + if got := tok.Token.GetJwt(); got != "" { + t.Errorf("jwt = %q, want empty string", got) } } -func TestForEmail(t *testing.T) { - eid := ForEmail("user@example.com") - - chain := extractEntityChain(t, eid) - entities := chain.GetEntities() - if len(entities) != 1 { - t.Fatalf("entities len = %d, want 1", len(entities)) - } +func TestWithRequestToken(t *testing.T) { + eid := WithRequestToken() - e := entities[0] - em, ok := e.GetEntityType().(*entity.Entity_EmailAddress) + wrt, ok := eid.GetIdentifier().(*EntityIdentifier_WithRequestToken) if !ok { - t.Fatal("expected EmailAddress entity type") - } - if em.EmailAddress != "user@example.com" { - t.Errorf("EmailAddress = %q, want %q", em.EmailAddress, "user@example.com") + t.Fatal("expected WithRequestToken identifier") } - if e.GetCategory() != entity.Entity_CATEGORY_SUBJECT { - t.Errorf("category = %v, want CATEGORY_SUBJECT", e.GetCategory()) + if !wrt.WithRequestToken.GetValue() { + t.Error("expected WithRequestToken value to be true") } } -func TestForUserName(t *testing.T) { - eid := ForUserName("alice") - - chain := extractEntityChain(t, eid) - entities := chain.GetEntities() - if len(entities) != 1 { - t.Fatalf("entities len = %d, want 1", len(entities)) - } - - e := entities[0] - un, ok := e.GetEntityType().(*entity.Entity_UserName) - if !ok { - t.Fatal("expected UserName entity type") - } - if un.UserName != "alice" { - t.Errorf("UserName = %q, want %q", un.UserName, "alice") - } - if e.GetCategory() != entity.Entity_CATEGORY_SUBJECT { - t.Errorf("category = %v, want CATEGORY_SUBJECT", e.GetCategory()) +func TestEntityChainConstructors(t *testing.T) { + tests := []struct { + name string + constructor func(string) *EntityIdentifier + input string + checkType func(*entity.Entity) (string, bool) + }{ + { + name: "ForClientID", + constructor: ForClientID, + input: "my-client", + checkType: func(e *entity.Entity) (string, bool) { + cid, ok := e.GetEntityType().(*entity.Entity_ClientId) + if !ok { + return "", false + } + return cid.ClientId, true + }, + }, + { + name: "ForClientID_EmptyString", + constructor: ForClientID, + input: "", + checkType: func(e *entity.Entity) (string, bool) { + cid, ok := e.GetEntityType().(*entity.Entity_ClientId) + if !ok { + return "", false + } + return cid.ClientId, true + }, + }, + { + name: "ForEmail", + constructor: ForEmail, + input: "user@example.com", + checkType: func(e *entity.Entity) (string, bool) { + em, ok := e.GetEntityType().(*entity.Entity_EmailAddress) + if !ok { + return "", false + } + return em.EmailAddress, true + }, + }, + { + name: "ForEmail_EmptyString", + constructor: ForEmail, + input: "", + checkType: func(e *entity.Entity) (string, bool) { + em, ok := e.GetEntityType().(*entity.Entity_EmailAddress) + if !ok { + return "", false + } + return em.EmailAddress, true + }, + }, + { + name: "ForUserName", + constructor: ForUserName, + input: "alice", + checkType: func(e *entity.Entity) (string, bool) { + un, ok := e.GetEntityType().(*entity.Entity_UserName) + if !ok { + return "", false + } + return un.UserName, true + }, + }, + { + name: "ForUserName_EmptyString", + constructor: ForUserName, + input: "", + checkType: func(e *entity.Entity) (string, bool) { + un, ok := e.GetEntityType().(*entity.Entity_UserName) + if !ok { + return "", false + } + return un.UserName, true + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + eid := tt.constructor(tt.input) + + chain := extractEntityChain(t, eid) + entities := chain.GetEntities() + if len(entities) != 1 { + t.Fatalf("entities len = %d, want 1", len(entities)) + } + + e := entities[0] + got, ok := tt.checkType(e) + if !ok { + t.Fatalf("unexpected entity type for %s", tt.name) + } + if got != tt.input { + t.Errorf("%s value = %q, want %q", tt.name, got, tt.input) + } + if e.GetCategory() != entity.Entity_CATEGORY_SUBJECT { + t.Errorf("category = %v, want CATEGORY_SUBJECT", e.GetCategory()) + } + }) } } From 0f17ccc08ff28cd6ffda2f8c56a883a3026fba38 Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Thu, 2 Apr 2026 12:03:02 -0700 Subject: [PATCH 06/15] refactor(sdk): move EntityIdentifier helpers to sdk package Moves entity identifier convenience constructors from protocol/go/authorization/v2/ to sdk/ to avoid being wiped by make proto-generate. Renames functions with EntityIdentifier prefix for clarity in the sdk package context (e.g., EntityIdentifierForClientID). Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Mary Dickson --- .../go/authorization/v2/entity_identifier.go | 62 ------------------ sdk/entity_identifier.go | 63 +++++++++++++++++++ .../v2 => sdk}/entity_identifier_test.go | 51 +++++++-------- 3 files changed, 89 insertions(+), 87 deletions(-) delete mode 100644 protocol/go/authorization/v2/entity_identifier.go create mode 100644 sdk/entity_identifier.go rename {protocol/go/authorization/v2 => sdk}/entity_identifier_test.go (66%) diff --git a/protocol/go/authorization/v2/entity_identifier.go b/protocol/go/authorization/v2/entity_identifier.go deleted file mode 100644 index 393a87cce4..0000000000 --- a/protocol/go/authorization/v2/entity_identifier.go +++ /dev/null @@ -1,62 +0,0 @@ -package authorizationv2 - -import ( - "github.com/opentdf/platform/protocol/go/entity" - "google.golang.org/protobuf/types/known/wrapperspb" -) - -// ForToken returns an EntityIdentifier that resolves the entity from the given JWT. -// The authorization service will parse the token to derive the entity chain. -func ForToken(jwt string) *EntityIdentifier { - return &EntityIdentifier{ - Identifier: &EntityIdentifier_Token{ - Token: &entity.Token{ - Jwt: jwt, - }, - }, - } -} - -// WithRequestToken returns an EntityIdentifier that instructs the authorization -// service to derive the entity from the request's Authorization header token. -func WithRequestToken() *EntityIdentifier { - return &EntityIdentifier{ - Identifier: &EntityIdentifier_WithRequestToken{ - WithRequestToken: wrapperspb.Bool(true), - }, - } -} - -// ForClientID returns an EntityIdentifier for a single subject entity identified by client ID. -func ForClientID(clientID string) *EntityIdentifier { - return entityIdentifierFromEntity(&entity.Entity{ - EntityType: &entity.Entity_ClientId{ClientId: clientID}, - Category: entity.Entity_CATEGORY_SUBJECT, - }) -} - -// ForEmail returns an EntityIdentifier for a single subject entity identified by email address. -func ForEmail(email string) *EntityIdentifier { - return entityIdentifierFromEntity(&entity.Entity{ - EntityType: &entity.Entity_EmailAddress{EmailAddress: email}, - Category: entity.Entity_CATEGORY_SUBJECT, - }) -} - -// ForUserName returns an EntityIdentifier for a single subject entity identified by username. -func ForUserName(username string) *EntityIdentifier { - return entityIdentifierFromEntity(&entity.Entity{ - EntityType: &entity.Entity_UserName{UserName: username}, - Category: entity.Entity_CATEGORY_SUBJECT, - }) -} - -func entityIdentifierFromEntity(e *entity.Entity) *EntityIdentifier { - return &EntityIdentifier{ - Identifier: &EntityIdentifier_EntityChain{ - EntityChain: &entity.EntityChain{ - Entities: []*entity.Entity{e}, - }, - }, - } -} diff --git a/sdk/entity_identifier.go b/sdk/entity_identifier.go new file mode 100644 index 0000000000..c3d7ce57fd --- /dev/null +++ b/sdk/entity_identifier.go @@ -0,0 +1,63 @@ +package sdk + +import ( + authorizationv2 "github.com/opentdf/platform/protocol/go/authorization/v2" + "github.com/opentdf/platform/protocol/go/entity" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +// EntityIdentifierForToken returns an EntityIdentifier that resolves the entity from the given JWT. +// The authorization service will parse the token to derive the entity chain. +func EntityIdentifierForToken(jwt string) *authorizationv2.EntityIdentifier { + return &authorizationv2.EntityIdentifier{ + Identifier: &authorizationv2.EntityIdentifier_Token{ + Token: &entity.Token{ + Jwt: jwt, + }, + }, + } +} + +// EntityIdentifierWithRequestToken returns an EntityIdentifier that instructs the authorization +// service to derive the entity from the request's Authorization header token. +func EntityIdentifierWithRequestToken() *authorizationv2.EntityIdentifier { + return &authorizationv2.EntityIdentifier{ + Identifier: &authorizationv2.EntityIdentifier_WithRequestToken{ + WithRequestToken: wrapperspb.Bool(true), + }, + } +} + +// EntityIdentifierForClientID returns an EntityIdentifier for a single subject entity identified by client ID. +func EntityIdentifierForClientID(clientID string) *authorizationv2.EntityIdentifier { + return entityIdentifierFromEntity(&entity.Entity{ + EntityType: &entity.Entity_ClientId{ClientId: clientID}, + Category: entity.Entity_CATEGORY_SUBJECT, + }) +} + +// EntityIdentifierForEmail returns an EntityIdentifier for a single subject entity identified by email address. +func EntityIdentifierForEmail(email string) *authorizationv2.EntityIdentifier { + return entityIdentifierFromEntity(&entity.Entity{ + EntityType: &entity.Entity_EmailAddress{EmailAddress: email}, + Category: entity.Entity_CATEGORY_SUBJECT, + }) +} + +// EntityIdentifierForUserName returns an EntityIdentifier for a single subject entity identified by username. +func EntityIdentifierForUserName(username string) *authorizationv2.EntityIdentifier { + return entityIdentifierFromEntity(&entity.Entity{ + EntityType: &entity.Entity_UserName{UserName: username}, + Category: entity.Entity_CATEGORY_SUBJECT, + }) +} + +func entityIdentifierFromEntity(e *entity.Entity) *authorizationv2.EntityIdentifier { + return &authorizationv2.EntityIdentifier{ + Identifier: &authorizationv2.EntityIdentifier_EntityChain{ + EntityChain: &entity.EntityChain{ + Entities: []*entity.Entity{e}, + }, + }, + } +} diff --git a/protocol/go/authorization/v2/entity_identifier_test.go b/sdk/entity_identifier_test.go similarity index 66% rename from protocol/go/authorization/v2/entity_identifier_test.go rename to sdk/entity_identifier_test.go index e42ce7b6b9..c1741b0d98 100644 --- a/protocol/go/authorization/v2/entity_identifier_test.go +++ b/sdk/entity_identifier_test.go @@ -1,16 +1,17 @@ -package authorizationv2 +package sdk import ( "testing" + authorizationv2 "github.com/opentdf/platform/protocol/go/authorization/v2" "github.com/opentdf/platform/protocol/go/entity" ) -func TestForToken(t *testing.T) { +func TestEntityIdentifierForToken(t *testing.T) { jwt := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test" - eid := ForToken(jwt) + eid := EntityIdentifierForToken(jwt) - tok, ok := eid.GetIdentifier().(*EntityIdentifier_Token) + tok, ok := eid.GetIdentifier().(*authorizationv2.EntityIdentifier_Token) if !ok { t.Fatal("expected Token identifier") } @@ -19,10 +20,10 @@ func TestForToken(t *testing.T) { } } -func TestForToken_EmptyString(t *testing.T) { - eid := ForToken("") +func TestEntityIdentifierForToken_EmptyString(t *testing.T) { + eid := EntityIdentifierForToken("") - tok, ok := eid.GetIdentifier().(*EntityIdentifier_Token) + tok, ok := eid.GetIdentifier().(*authorizationv2.EntityIdentifier_Token) if !ok { t.Fatal("expected Token identifier") } @@ -31,10 +32,10 @@ func TestForToken_EmptyString(t *testing.T) { } } -func TestWithRequestToken(t *testing.T) { - eid := WithRequestToken() +func TestEntityIdentifierWithRequestToken(t *testing.T) { + eid := EntityIdentifierWithRequestToken() - wrt, ok := eid.GetIdentifier().(*EntityIdentifier_WithRequestToken) + wrt, ok := eid.GetIdentifier().(*authorizationv2.EntityIdentifier_WithRequestToken) if !ok { t.Fatal("expected WithRequestToken identifier") } @@ -46,13 +47,13 @@ func TestWithRequestToken(t *testing.T) { func TestEntityChainConstructors(t *testing.T) { tests := []struct { name string - constructor func(string) *EntityIdentifier + constructor func(string) *authorizationv2.EntityIdentifier input string checkType func(*entity.Entity) (string, bool) }{ { - name: "ForClientID", - constructor: ForClientID, + name: "EntityIdentifierForClientID", + constructor: EntityIdentifierForClientID, input: "my-client", checkType: func(e *entity.Entity) (string, bool) { cid, ok := e.GetEntityType().(*entity.Entity_ClientId) @@ -63,8 +64,8 @@ func TestEntityChainConstructors(t *testing.T) { }, }, { - name: "ForClientID_EmptyString", - constructor: ForClientID, + name: "EntityIdentifierForClientID_EmptyString", + constructor: EntityIdentifierForClientID, input: "", checkType: func(e *entity.Entity) (string, bool) { cid, ok := e.GetEntityType().(*entity.Entity_ClientId) @@ -75,8 +76,8 @@ func TestEntityChainConstructors(t *testing.T) { }, }, { - name: "ForEmail", - constructor: ForEmail, + name: "EntityIdentifierForEmail", + constructor: EntityIdentifierForEmail, input: "user@example.com", checkType: func(e *entity.Entity) (string, bool) { em, ok := e.GetEntityType().(*entity.Entity_EmailAddress) @@ -87,8 +88,8 @@ func TestEntityChainConstructors(t *testing.T) { }, }, { - name: "ForEmail_EmptyString", - constructor: ForEmail, + name: "EntityIdentifierForEmail_EmptyString", + constructor: EntityIdentifierForEmail, input: "", checkType: func(e *entity.Entity) (string, bool) { em, ok := e.GetEntityType().(*entity.Entity_EmailAddress) @@ -99,8 +100,8 @@ func TestEntityChainConstructors(t *testing.T) { }, }, { - name: "ForUserName", - constructor: ForUserName, + name: "EntityIdentifierForUserName", + constructor: EntityIdentifierForUserName, input: "alice", checkType: func(e *entity.Entity) (string, bool) { un, ok := e.GetEntityType().(*entity.Entity_UserName) @@ -111,8 +112,8 @@ func TestEntityChainConstructors(t *testing.T) { }, }, { - name: "ForUserName_EmptyString", - constructor: ForUserName, + name: "EntityIdentifierForUserName_EmptyString", + constructor: EntityIdentifierForUserName, input: "", checkType: func(e *entity.Entity) (string, bool) { un, ok := e.GetEntityType().(*entity.Entity_UserName) @@ -149,9 +150,9 @@ func TestEntityChainConstructors(t *testing.T) { } } -func extractEntityChain(t *testing.T, eid *EntityIdentifier) *entity.EntityChain { +func extractEntityChain(t *testing.T, eid *authorizationv2.EntityIdentifier) *entity.EntityChain { t.Helper() - ec, ok := eid.GetIdentifier().(*EntityIdentifier_EntityChain) + ec, ok := eid.GetIdentifier().(*authorizationv2.EntityIdentifier_EntityChain) if !ok { t.Fatal("expected EntityChain identifier") } From fbfd8223cb546d3965b6b2a37529ff465d22a1cf Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Mon, 6 Apr 2026 11:22:18 -0700 Subject: [PATCH 07/15] refactor(sdk): source-file codegen for EntityIdentifier helpers Move EntityIdentifier helpers from the sdk package into protocol/go/helpers/authorization/v2/ as source files that are copied into the proto package at build time. This follows the source-file codegen approach from ADR DSPX-2594: - Helpers now live in the authorizationv2 proto package with shorter names (ForToken, ForClientID, ForEmail, ForUserName, WithRequestToken) - Source files in helpers/ have full IDE support and standard unit tests - protocol/go/codegen copies sources into proto packages with a "Code generated" header, stripping self-referencing imports - Makefile wires codegen into proto-generate after buf generate Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Mary Dickson --- Makefile | 6 +- .../authorization/v2/entity_identifier.gen.go | 64 ++++++++++ protocol/go/codegen/main.go | 115 ++++++++++++++++++ .../authorization/v2}/entity_identifier.go | 22 ++-- .../v2}/entity_identifier_test.go | 52 ++++---- 5 files changed, 221 insertions(+), 38 deletions(-) create mode 100644 protocol/go/authorization/v2/entity_identifier.gen.go create mode 100644 protocol/go/codegen/main.go rename {sdk => protocol/go/helpers/authorization/v2}/entity_identifier.go (60%) rename {sdk => protocol/go/helpers/authorization/v2}/entity_identifier_test.go (66%) diff --git a/Makefile b/Makefile index 035da1d35e..891e872924 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # make # To run all lint checks: `LINT_OPTIONS= make lint` -.PHONY: all build clean connect-wrapper-generate docker-build fix fmt go-lint license lint proto-generate proto-lint sdk/sdk test tidy toolcheck +.PHONY: all build clean connect-wrapper-generate docker-build fix fmt go-lint license lint proto-generate proto-helper-generate proto-lint sdk/sdk test tidy toolcheck MODS=protocol/go lib/ocrypto lib/fixtures lib/flattening lib/identifier sdk service examples HAND_MODS=lib/ocrypto lib/fixtures lib/flattening lib/identifier sdk service examples @@ -84,11 +84,15 @@ proto-generate: toolcheck buf generate buf.build/grpc-ecosystem/grpc-gateway -o tmp-gen --template buf.gen.grpc.docs.yaml buf generate buf.build/grpc-ecosystem/grpc-gateway -o tmp-gen --template buf.gen.openapi.docs.yaml + go run ./protocol/go/codegen go run ./sdk/codegen connect-wrapper-generate: go run ./sdk/codegen +proto-helper-generate: + go run ./protocol/go/codegen + policy-sql-gen: @which sqlc > /dev/null || { echo "sqlc not found, please install it: https://docs.sqlc.dev/en/stable/overview/install.html"; exit 1; } sqlc generate -f service/policy/db/sqlc.yaml diff --git a/protocol/go/authorization/v2/entity_identifier.gen.go b/protocol/go/authorization/v2/entity_identifier.gen.go new file mode 100644 index 0000000000..12aa47948b --- /dev/null +++ b/protocol/go/authorization/v2/entity_identifier.gen.go @@ -0,0 +1,64 @@ +// Code generated by protocol/go/codegen. DO NOT EDIT. + +package authorizationv2 + +import ( + "github.com/opentdf/platform/protocol/go/entity" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +// ForToken returns an EntityIdentifier that resolves the entity from the given JWT. +// The authorization service will parse the token to derive the entity chain. +func ForToken(jwt string) *EntityIdentifier { + return &EntityIdentifier{ + Identifier: &EntityIdentifier_Token{ + Token: &entity.Token{ + Jwt: jwt, + }, + }, + } +} + +// WithRequestToken returns an EntityIdentifier that instructs the authorization +// service to derive the entity from the request's Authorization header token. +func WithRequestToken() *EntityIdentifier { + return &EntityIdentifier{ + Identifier: &EntityIdentifier_WithRequestToken{ + WithRequestToken: wrapperspb.Bool(true), + }, + } +} + +// ForClientID returns an EntityIdentifier for a single subject entity identified by client ID. +func ForClientID(clientID string) *EntityIdentifier { + return entityIdentifierFromEntity(&entity.Entity{ + EntityType: &entity.Entity_ClientId{ClientId: clientID}, + Category: entity.Entity_CATEGORY_SUBJECT, + }) +} + +// ForEmail returns an EntityIdentifier for a single subject entity identified by email address. +func ForEmail(email string) *EntityIdentifier { + return entityIdentifierFromEntity(&entity.Entity{ + EntityType: &entity.Entity_EmailAddress{EmailAddress: email}, + Category: entity.Entity_CATEGORY_SUBJECT, + }) +} + +// ForUserName returns an EntityIdentifier for a single subject entity identified by username. +func ForUserName(username string) *EntityIdentifier { + return entityIdentifierFromEntity(&entity.Entity{ + EntityType: &entity.Entity_UserName{UserName: username}, + Category: entity.Entity_CATEGORY_SUBJECT, + }) +} + +func entityIdentifierFromEntity(e *entity.Entity) *EntityIdentifier { + return &EntityIdentifier{ + Identifier: &EntityIdentifier_EntityChain{ + EntityChain: &entity.EntityChain{ + Entities: []*entity.Entity{e}, + }, + }, + } +} diff --git a/protocol/go/codegen/main.go b/protocol/go/codegen/main.go new file mode 100644 index 0000000000..cce91e5b44 --- /dev/null +++ b/protocol/go/codegen/main.go @@ -0,0 +1,115 @@ +// Command codegen copies helper source files from protocol/go/helpers/ into their +// corresponding proto package directories with a "Code generated" header prepended. +// Helper source files import proto types explicitly for IDE support; the copier strips +// the self-referencing import and type qualifiers so the output compiles in-package. +// +// See ADR DSPX-2594 for background on the source-file codegen approach. +package main + +import ( + "errors" + "fmt" + "log" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" +) + +// helperMapping defines a source directory (relative to protocol/go/helpers/) and its +// target directory (relative to protocol/go/) where files will be copied. +type helperMapping struct { + // Source is the subdirectory under helpers/ containing the source files. + Source string + // Target is the subdirectory under protocol/go/ where files are copied. + Target string + // ProtoImportPath is the full Go import path of the target proto package. + ProtoImportPath string + // ProtoImportAlias is the import alias used in the source files for the proto package. + ProtoImportAlias string +} + +var mappings = []helperMapping{ + { + Source: "authorization/v2", + Target: "authorization/v2", + ProtoImportPath: "github.com/opentdf/platform/protocol/go/authorization/v2", + ProtoImportAlias: "authorizationv2", + }, +} + +const generatedHeader = "// Code generated by protocol/go/codegen. DO NOT EDIT.\n\n" + +func main() { + baseDir, err := getBaseDir() + if err != nil { + log.Fatal(err) + } + + helpersDir := filepath.Join(baseDir, "helpers") + for _, m := range mappings { + srcDir := filepath.Join(helpersDir, m.Source) + dstDir := filepath.Join(baseDir, m.Target) + if err := copyHelpers(srcDir, dstDir, m); err != nil { + log.Fatalf("copying helpers from %s to %s: %v", srcDir, dstDir, err) + } + } +} + +func copyHelpers(srcDir, dstDir string, m helperMapping) error { + entries, err := os.ReadDir(srcDir) + if err != nil { + return fmt.Errorf("reading source directory: %w", err) + } + + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + + src := filepath.Join(srcDir, name) + content, err := os.ReadFile(src) + if err != nil { + return fmt.Errorf("reading %s: %w", src, err) + } + + transformed := rewriteImports(string(content), m) + + outName := strings.TrimSuffix(name, ".go") + ".gen.go" + dst := filepath.Join(dstDir, outName) + + output := generatedHeader + transformed + if err := os.WriteFile(dst, []byte(output), 0o644); err != nil { + return fmt.Errorf("writing %s: %w", dst, err) + } + fmt.Printf(" %s -> %s\n", src, dst) + } + return nil +} + +// rewriteImports removes the self-referencing proto import and strips the alias qualifier +// from type references so the file compiles inside the proto package. +func rewriteImports(content string, m helperMapping) string { + // Remove the import line: `authorizationv2 "github.com/.../authorization/v2"` + importLineRe := regexp.MustCompile( + `(?m)^\s*` + regexp.QuoteMeta(m.ProtoImportAlias) + `\s+"` + regexp.QuoteMeta(m.ProtoImportPath) + `"\s*\n`, + ) + content = importLineRe.ReplaceAllString(content, "") + + // Strip the alias qualifier from type references: `authorizationv2.Foo` -> `Foo` + qualifierRe := regexp.MustCompile(regexp.QuoteMeta(m.ProtoImportAlias) + `\.`) + content = qualifierRe.ReplaceAllString(content, "") + + return content +} + +// getBaseDir returns the protocol/go/ directory by navigating from this file's location. +func getBaseDir() (string, error) { + _, filename, _, ok := runtime.Caller(0) + if !ok { + return "", errors.New("could not determine current file location") + } + return filepath.Dir(filepath.Dir(filename)), nil +} diff --git a/sdk/entity_identifier.go b/protocol/go/helpers/authorization/v2/entity_identifier.go similarity index 60% rename from sdk/entity_identifier.go rename to protocol/go/helpers/authorization/v2/entity_identifier.go index c3d7ce57fd..d5b6134236 100644 --- a/sdk/entity_identifier.go +++ b/protocol/go/helpers/authorization/v2/entity_identifier.go @@ -1,4 +1,4 @@ -package sdk +package authorizationv2 import ( authorizationv2 "github.com/opentdf/platform/protocol/go/authorization/v2" @@ -6,9 +6,9 @@ import ( "google.golang.org/protobuf/types/known/wrapperspb" ) -// EntityIdentifierForToken returns an EntityIdentifier that resolves the entity from the given JWT. +// ForToken returns an EntityIdentifier that resolves the entity from the given JWT. // The authorization service will parse the token to derive the entity chain. -func EntityIdentifierForToken(jwt string) *authorizationv2.EntityIdentifier { +func ForToken(jwt string) *authorizationv2.EntityIdentifier { return &authorizationv2.EntityIdentifier{ Identifier: &authorizationv2.EntityIdentifier_Token{ Token: &entity.Token{ @@ -18,9 +18,9 @@ func EntityIdentifierForToken(jwt string) *authorizationv2.EntityIdentifier { } } -// EntityIdentifierWithRequestToken returns an EntityIdentifier that instructs the authorization +// WithRequestToken returns an EntityIdentifier that instructs the authorization // service to derive the entity from the request's Authorization header token. -func EntityIdentifierWithRequestToken() *authorizationv2.EntityIdentifier { +func WithRequestToken() *authorizationv2.EntityIdentifier { return &authorizationv2.EntityIdentifier{ Identifier: &authorizationv2.EntityIdentifier_WithRequestToken{ WithRequestToken: wrapperspb.Bool(true), @@ -28,24 +28,24 @@ func EntityIdentifierWithRequestToken() *authorizationv2.EntityIdentifier { } } -// EntityIdentifierForClientID returns an EntityIdentifier for a single subject entity identified by client ID. -func EntityIdentifierForClientID(clientID string) *authorizationv2.EntityIdentifier { +// ForClientID returns an EntityIdentifier for a single subject entity identified by client ID. +func ForClientID(clientID string) *authorizationv2.EntityIdentifier { return entityIdentifierFromEntity(&entity.Entity{ EntityType: &entity.Entity_ClientId{ClientId: clientID}, Category: entity.Entity_CATEGORY_SUBJECT, }) } -// EntityIdentifierForEmail returns an EntityIdentifier for a single subject entity identified by email address. -func EntityIdentifierForEmail(email string) *authorizationv2.EntityIdentifier { +// ForEmail returns an EntityIdentifier for a single subject entity identified by email address. +func ForEmail(email string) *authorizationv2.EntityIdentifier { return entityIdentifierFromEntity(&entity.Entity{ EntityType: &entity.Entity_EmailAddress{EmailAddress: email}, Category: entity.Entity_CATEGORY_SUBJECT, }) } -// EntityIdentifierForUserName returns an EntityIdentifier for a single subject entity identified by username. -func EntityIdentifierForUserName(username string) *authorizationv2.EntityIdentifier { +// ForUserName returns an EntityIdentifier for a single subject entity identified by username. +func ForUserName(username string) *authorizationv2.EntityIdentifier { return entityIdentifierFromEntity(&entity.Entity{ EntityType: &entity.Entity_UserName{UserName: username}, Category: entity.Entity_CATEGORY_SUBJECT, diff --git a/sdk/entity_identifier_test.go b/protocol/go/helpers/authorization/v2/entity_identifier_test.go similarity index 66% rename from sdk/entity_identifier_test.go rename to protocol/go/helpers/authorization/v2/entity_identifier_test.go index c1741b0d98..f7d0658169 100644 --- a/sdk/entity_identifier_test.go +++ b/protocol/go/helpers/authorization/v2/entity_identifier_test.go @@ -1,17 +1,17 @@ -package sdk +package authorizationv2 import ( "testing" - authorizationv2 "github.com/opentdf/platform/protocol/go/authorization/v2" + authorizationv2proto "github.com/opentdf/platform/protocol/go/authorization/v2" "github.com/opentdf/platform/protocol/go/entity" ) -func TestEntityIdentifierForToken(t *testing.T) { +func TestForToken(t *testing.T) { jwt := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test" - eid := EntityIdentifierForToken(jwt) + eid := ForToken(jwt) - tok, ok := eid.GetIdentifier().(*authorizationv2.EntityIdentifier_Token) + tok, ok := eid.GetIdentifier().(*authorizationv2proto.EntityIdentifier_Token) if !ok { t.Fatal("expected Token identifier") } @@ -20,10 +20,10 @@ func TestEntityIdentifierForToken(t *testing.T) { } } -func TestEntityIdentifierForToken_EmptyString(t *testing.T) { - eid := EntityIdentifierForToken("") +func TestForToken_EmptyString(t *testing.T) { + eid := ForToken("") - tok, ok := eid.GetIdentifier().(*authorizationv2.EntityIdentifier_Token) + tok, ok := eid.GetIdentifier().(*authorizationv2proto.EntityIdentifier_Token) if !ok { t.Fatal("expected Token identifier") } @@ -32,10 +32,10 @@ func TestEntityIdentifierForToken_EmptyString(t *testing.T) { } } -func TestEntityIdentifierWithRequestToken(t *testing.T) { - eid := EntityIdentifierWithRequestToken() +func TestWithRequestToken(t *testing.T) { + eid := WithRequestToken() - wrt, ok := eid.GetIdentifier().(*authorizationv2.EntityIdentifier_WithRequestToken) + wrt, ok := eid.GetIdentifier().(*authorizationv2proto.EntityIdentifier_WithRequestToken) if !ok { t.Fatal("expected WithRequestToken identifier") } @@ -47,13 +47,13 @@ func TestEntityIdentifierWithRequestToken(t *testing.T) { func TestEntityChainConstructors(t *testing.T) { tests := []struct { name string - constructor func(string) *authorizationv2.EntityIdentifier + constructor func(string) *authorizationv2proto.EntityIdentifier input string checkType func(*entity.Entity) (string, bool) }{ { - name: "EntityIdentifierForClientID", - constructor: EntityIdentifierForClientID, + name: "ForClientID", + constructor: ForClientID, input: "my-client", checkType: func(e *entity.Entity) (string, bool) { cid, ok := e.GetEntityType().(*entity.Entity_ClientId) @@ -64,8 +64,8 @@ func TestEntityChainConstructors(t *testing.T) { }, }, { - name: "EntityIdentifierForClientID_EmptyString", - constructor: EntityIdentifierForClientID, + name: "ForClientID_EmptyString", + constructor: ForClientID, input: "", checkType: func(e *entity.Entity) (string, bool) { cid, ok := e.GetEntityType().(*entity.Entity_ClientId) @@ -76,8 +76,8 @@ func TestEntityChainConstructors(t *testing.T) { }, }, { - name: "EntityIdentifierForEmail", - constructor: EntityIdentifierForEmail, + name: "ForEmail", + constructor: ForEmail, input: "user@example.com", checkType: func(e *entity.Entity) (string, bool) { em, ok := e.GetEntityType().(*entity.Entity_EmailAddress) @@ -88,8 +88,8 @@ func TestEntityChainConstructors(t *testing.T) { }, }, { - name: "EntityIdentifierForEmail_EmptyString", - constructor: EntityIdentifierForEmail, + name: "ForEmail_EmptyString", + constructor: ForEmail, input: "", checkType: func(e *entity.Entity) (string, bool) { em, ok := e.GetEntityType().(*entity.Entity_EmailAddress) @@ -100,8 +100,8 @@ func TestEntityChainConstructors(t *testing.T) { }, }, { - name: "EntityIdentifierForUserName", - constructor: EntityIdentifierForUserName, + name: "ForUserName", + constructor: ForUserName, input: "alice", checkType: func(e *entity.Entity) (string, bool) { un, ok := e.GetEntityType().(*entity.Entity_UserName) @@ -112,8 +112,8 @@ func TestEntityChainConstructors(t *testing.T) { }, }, { - name: "EntityIdentifierForUserName_EmptyString", - constructor: EntityIdentifierForUserName, + name: "ForUserName_EmptyString", + constructor: ForUserName, input: "", checkType: func(e *entity.Entity) (string, bool) { un, ok := e.GetEntityType().(*entity.Entity_UserName) @@ -150,9 +150,9 @@ func TestEntityChainConstructors(t *testing.T) { } } -func extractEntityChain(t *testing.T, eid *authorizationv2.EntityIdentifier) *entity.EntityChain { +func extractEntityChain(t *testing.T, eid *authorizationv2proto.EntityIdentifier) *entity.EntityChain { t.Helper() - ec, ok := eid.GetIdentifier().(*authorizationv2.EntityIdentifier_EntityChain) + ec, ok := eid.GetIdentifier().(*authorizationv2proto.EntityIdentifier_EntityChain) if !ok { t.Fatal("expected EntityChain identifier") } From a3e7f2e9123d2dd6348c2e9e4728eac871d44766 Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Mon, 6 Apr 2026 13:01:37 -0700 Subject: [PATCH 08/15] chore(sdk): add unit tests for codegen import rewriting Test the rewriteImports function that strips the self-referencing proto import and qualifier prefix when copying helpers into proto packages. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Mary Dickson --- protocol/go/codegen/main_test.go | 117 +++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 protocol/go/codegen/main_test.go diff --git a/protocol/go/codegen/main_test.go b/protocol/go/codegen/main_test.go new file mode 100644 index 0000000000..6ff84b5f13 --- /dev/null +++ b/protocol/go/codegen/main_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "testing" +) + +func TestRewriteImports(t *testing.T) { + m := helperMapping{ + ProtoImportPath: "github.com/opentdf/platform/protocol/go/authorization/v2", + ProtoImportAlias: "authorizationv2", + } + + tests := []struct { + name string + input string + want string + }{ + { + name: "strips import line and qualifiers", + input: `package authorizationv2 + +import ( + authorizationv2 "github.com/opentdf/platform/protocol/go/authorization/v2" + "github.com/opentdf/platform/protocol/go/entity" +) + +func ForClientID(clientID string) *authorizationv2.EntityIdentifier { + return &authorizationv2.EntityIdentifier{ + Identifier: &authorizationv2.EntityIdentifier_EntityChain{}, + } +} +`, + want: `package authorizationv2 + +import ( + "github.com/opentdf/platform/protocol/go/entity" +) + +func ForClientID(clientID string) *EntityIdentifier { + return &EntityIdentifier{ + Identifier: &EntityIdentifier_EntityChain{}, + } +} +`, + }, + { + name: "preserves other imports", + input: `package authorizationv2 + +import ( + authorizationv2 "github.com/opentdf/platform/protocol/go/authorization/v2" + "github.com/opentdf/platform/protocol/go/entity" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +func WithRequestToken() *authorizationv2.EntityIdentifier { + return &authorizationv2.EntityIdentifier{ + Identifier: &authorizationv2.EntityIdentifier_WithRequestToken{ + WithRequestToken: wrapperspb.Bool(true), + }, + } +} +`, + want: `package authorizationv2 + +import ( + "github.com/opentdf/platform/protocol/go/entity" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +func WithRequestToken() *EntityIdentifier { + return &EntityIdentifier{ + Identifier: &EntityIdentifier_WithRequestToken{ + WithRequestToken: wrapperspb.Bool(true), + }, + } +} +`, + }, + { + name: "no-op when no matching import", + input: "package foo\n\nfunc Bar() {}\n", + want: "package foo\n\nfunc Bar() {}\n", + }, + { + name: "does not strip partial alias matches", + input: `package authorizationv2 + +import ( + authorizationv2 "github.com/opentdf/platform/protocol/go/authorization/v2" +) + +// authorizationv2helper is not a qualifier reference +var authorizationv2helper = "should stay" +func F() *authorizationv2.EntityIdentifier { return nil } +`, + want: `package authorizationv2 + +import ( +) + +// authorizationv2helper is not a qualifier reference +var authorizationv2helper = "should stay" +func F() *EntityIdentifier { return nil } +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := rewriteImports(tt.input, m) + if got != tt.want { + t.Errorf("rewriteImports() mismatch\n--- got ---\n%s\n--- want ---\n%s", got, tt.want) + } + }) + } +} From a7e4a8263e65ac591c4c1c0852c81be954da89a8 Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Mon, 6 Apr 2026 13:11:42 -0700 Subject: [PATCH 09/15] fix(sdk): remove stale .gen.go files before copying helpers Address CodeRabbit review: if a helper source file is renamed or deleted, the old .gen.go would persist and could cause compilation errors. Now removeGenFiles clears existing .gen.go files in the target directory before each copy pass. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Mary Dickson --- protocol/go/codegen/main.go | 23 +++++++++++++++++ protocol/go/codegen/main_test.go | 43 ++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/protocol/go/codegen/main.go b/protocol/go/codegen/main.go index cce91e5b44..c9d7769519 100644 --- a/protocol/go/codegen/main.go +++ b/protocol/go/codegen/main.go @@ -58,6 +58,11 @@ func main() { } func copyHelpers(srcDir, dstDir string, m helperMapping) error { + // Remove stale .gen.go files so that renamed or deleted helpers don't linger. + if err := removeGenFiles(dstDir); err != nil { + return fmt.Errorf("cleaning target directory: %w", err) + } + entries, err := os.ReadDir(srcDir) if err != nil { return fmt.Errorf("reading source directory: %w", err) @@ -89,8 +94,26 @@ func copyHelpers(srcDir, dstDir string, m helperMapping) error { return nil } +func removeGenFiles(dir string) error { + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("reading directory: %w", err) + } + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".gen.go") { + if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil { + return fmt.Errorf("removing %s: %w", entry.Name(), err) + } + } + } + return nil +} + // rewriteImports removes the self-referencing proto import and strips the alias qualifier // from type references so the file compiles inside the proto package. +// +// The qualifier regex also matches inside string literals and comments. This is acceptable +// because we control the source files and don't use the alias in non-code contexts. func rewriteImports(content string, m helperMapping) string { // Remove the import line: `authorizationv2 "github.com/.../authorization/v2"` importLineRe := regexp.MustCompile( diff --git a/protocol/go/codegen/main_test.go b/protocol/go/codegen/main_test.go index 6ff84b5f13..cbdd9785d7 100644 --- a/protocol/go/codegen/main_test.go +++ b/protocol/go/codegen/main_test.go @@ -1,6 +1,8 @@ package main import ( + "os" + "path/filepath" "testing" ) @@ -115,3 +117,44 @@ func F() *EntityIdentifier { return nil } }) } } + +func TestRemoveGenFiles(t *testing.T) { + dir := t.TempDir() + + // Create a mix of files: .gen.go (should be removed), .pb.go and .go (should survive) + files := map[string]bool{ + "entity_identifier.gen.go": false, // expect removed + "other_helper.gen.go": false, // expect removed + "authorization.pb.go": true, // expect kept + "authorization_grpc.pb.go": true, // expect kept + "regular.go": true, // expect kept + } + for name := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte("package x"), 0o644); err != nil { + t.Fatal(err) + } + } + + if err := removeGenFiles(dir); err != nil { + t.Fatal(err) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + + remaining := make(map[string]bool) + for _, e := range entries { + remaining[e.Name()] = true + } + + for name, shouldExist := range files { + if shouldExist && !remaining[name] { + t.Errorf("%s was removed but should have been kept", name) + } + if !shouldExist && remaining[name] { + t.Errorf("%s was kept but should have been removed", name) + } + } +} From cde41d0bcd355c19991d28fc451483bf449e4715 Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Mon, 6 Apr 2026 15:21:03 -0700 Subject: [PATCH 10/15] fix(ci): exclude codegen and helpers from proto-generate cleanup The find command in proto-generate deletes all directories under protocol/go/, including the non-generated codegen/ and helpers/ source directories. Exclude them so `go run ./protocol/go/codegen` can succeed. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Mary Dickson --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 891e872924..106c0f61eb 100644 --- a/Makefile +++ b/Makefile @@ -74,7 +74,7 @@ govulncheck: proto-generate: toolcheck # remove all generated directories under protocol/go - find protocol/go -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} + + find protocol/go -mindepth 1 -maxdepth 1 -type d ! -name codegen ! -name helpers -exec rm -rf {} + rm -rf docs/grpc docs/openapi buf generate service buf generate service --template buf.gen.grpc.docs.yaml From f5130f9fee13c4becc7c308f338643502bf1c7a0 Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Mon, 6 Apr 2026 15:31:52 -0700 Subject: [PATCH 11/15] fix(ci): trigger proto-generate check on codegen and Makefile changes The proto-generate CI check only ran on PRs that changed .proto files. Changes to the Makefile, buf config, or codegen tools could break proto-generate but weren't caught until the merge queue. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Mary Dickson --- .github/workflows/checks.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index 4035b1508a..d0c278caa9 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -445,7 +445,7 @@ jobs: run: | if [ "${{ github.event_name }}" = "pull_request" ]; then BASE_SHA="${{ github.event.pull_request.base.sha }}" - if git diff --name-only "$BASE_SHA" HEAD | grep -q '\.proto$'; then + if git diff --name-only "$BASE_SHA" HEAD | grep -qE '\.proto$|^Makefile$|^buf\.|^protocol/go/codegen/|^protocol/go/helpers/|^sdk/codegen/'; then echo "proto=true" >> "$GITHUB_OUTPUT" else echo "proto=false" >> "$GITHUB_OUTPUT" From f196ffe083b4e4cc45323fdaa26cc518c665a417 Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Mon, 6 Apr 2026 15:43:34 -0700 Subject: [PATCH 12/15] refactor(sdk): rename helpers/ to internal/, buffer codegen writes Rename protocol/go/helpers/ to protocol/go/internal/ so the helper source files are not importable outside the protocol/go module. This prevents consumers from accidentally importing the source package instead of the proto package where helpers are generated. Also buffer all file reads and transforms before deleting existing .gen.go files, so a failed read does not leave the target directory with no helpers. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Mary Dickson --- .github/workflows/checks.yaml | 2 +- Makefile | 2 +- protocol/go/codegen/main.go | 44 ++++++++++++------- .../authorization/v2/entity_identifier.go | 0 .../v2/entity_identifier_test.go | 0 5 files changed, 31 insertions(+), 17 deletions(-) rename protocol/go/{helpers => internal}/authorization/v2/entity_identifier.go (100%) rename protocol/go/{helpers => internal}/authorization/v2/entity_identifier_test.go (100%) diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index d0c278caa9..52aeacb6d5 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -445,7 +445,7 @@ jobs: run: | if [ "${{ github.event_name }}" = "pull_request" ]; then BASE_SHA="${{ github.event.pull_request.base.sha }}" - if git diff --name-only "$BASE_SHA" HEAD | grep -qE '\.proto$|^Makefile$|^buf\.|^protocol/go/codegen/|^protocol/go/helpers/|^sdk/codegen/'; then + if git diff --name-only "$BASE_SHA" HEAD | grep -qE '\.proto$|^Makefile$|^buf\.|^protocol/go/codegen/|^protocol/go/internal/|^sdk/codegen/'; then echo "proto=true" >> "$GITHUB_OUTPUT" else echo "proto=false" >> "$GITHUB_OUTPUT" diff --git a/Makefile b/Makefile index 106c0f61eb..d6df1f95c3 100644 --- a/Makefile +++ b/Makefile @@ -74,7 +74,7 @@ govulncheck: proto-generate: toolcheck # remove all generated directories under protocol/go - find protocol/go -mindepth 1 -maxdepth 1 -type d ! -name codegen ! -name helpers -exec rm -rf {} + + find protocol/go -mindepth 1 -maxdepth 1 -type d ! -name codegen ! -name internal -exec rm -rf {} + rm -rf docs/grpc docs/openapi buf generate service buf generate service --template buf.gen.grpc.docs.yaml diff --git a/protocol/go/codegen/main.go b/protocol/go/codegen/main.go index c9d7769519..6a2a1dc60c 100644 --- a/protocol/go/codegen/main.go +++ b/protocol/go/codegen/main.go @@ -1,4 +1,4 @@ -// Command codegen copies helper source files from protocol/go/helpers/ into their +// Command codegen copies helper source files from protocol/go/internal/ into their // corresponding proto package directories with a "Code generated" header prepended. // Helper source files import proto types explicitly for IDE support; the copier strips // the self-referencing import and type qualifiers so the output compiles in-package. @@ -17,10 +17,10 @@ import ( "strings" ) -// helperMapping defines a source directory (relative to protocol/go/helpers/) and its +// helperMapping defines a source directory (relative to protocol/go/internal/) and its // target directory (relative to protocol/go/) where files will be copied. type helperMapping struct { - // Source is the subdirectory under helpers/ containing the source files. + // Source is the subdirectory under internal/ containing the source files. Source string // Target is the subdirectory under protocol/go/ where files are copied. Target string @@ -47,7 +47,7 @@ func main() { log.Fatal(err) } - helpersDir := filepath.Join(baseDir, "helpers") + helpersDir := filepath.Join(baseDir, "internal") for _, m := range mappings { srcDir := filepath.Join(helpersDir, m.Source) dstDir := filepath.Join(baseDir, m.Target) @@ -57,17 +57,21 @@ func main() { } } -func copyHelpers(srcDir, dstDir string, m helperMapping) error { - // Remove stale .gen.go files so that renamed or deleted helpers don't linger. - if err := removeGenFiles(dstDir); err != nil { - return fmt.Errorf("cleaning target directory: %w", err) - } +// generatedFile holds a transformed helper ready to write. +type generatedFile struct { + dst string + content []byte + src string +} +func copyHelpers(srcDir, dstDir string, m helperMapping) error { entries, err := os.ReadDir(srcDir) if err != nil { return fmt.Errorf("reading source directory: %w", err) } + // Read and transform all source files before touching the target directory. + var files []generatedFile for _, entry := range entries { name := entry.Name() if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { @@ -81,15 +85,25 @@ func copyHelpers(srcDir, dstDir string, m helperMapping) error { } transformed := rewriteImports(string(content), m) - outName := strings.TrimSuffix(name, ".go") + ".gen.go" - dst := filepath.Join(dstDir, outName) - output := generatedHeader + transformed - if err := os.WriteFile(dst, []byte(output), 0o644); err != nil { - return fmt.Errorf("writing %s: %w", dst, err) + files = append(files, generatedFile{ + dst: filepath.Join(dstDir, outName), + content: []byte(generatedHeader + transformed), + src: src, + }) + } + + // Only remove stale .gen.go files once all reads succeeded. + if err := removeGenFiles(dstDir); err != nil { + return fmt.Errorf("cleaning target directory: %w", err) + } + + for _, f := range files { + if err := os.WriteFile(f.dst, f.content, 0o644); err != nil { + return fmt.Errorf("writing %s: %w", f.dst, err) } - fmt.Printf(" %s -> %s\n", src, dst) + fmt.Printf(" %s -> %s\n", f.src, f.dst) } return nil } diff --git a/protocol/go/helpers/authorization/v2/entity_identifier.go b/protocol/go/internal/authorization/v2/entity_identifier.go similarity index 100% rename from protocol/go/helpers/authorization/v2/entity_identifier.go rename to protocol/go/internal/authorization/v2/entity_identifier.go diff --git a/protocol/go/helpers/authorization/v2/entity_identifier_test.go b/protocol/go/internal/authorization/v2/entity_identifier_test.go similarity index 100% rename from protocol/go/helpers/authorization/v2/entity_identifier_test.go rename to protocol/go/internal/authorization/v2/entity_identifier_test.go From 7a155ef6140e79296e1e2b732586c25822a72d51 Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Tue, 7 Apr 2026 07:15:41 -0700 Subject: [PATCH 13/15] refactor(sdk): move proto helper codegen to separate module Move protocol/go/codegen/ to protocol/codegen/ with its own go.mod, per review feedback from dmihalcik-virtru. This removes the codegen directory from the protocol/go/ tree, simplifying the Makefile find cleanup (one exclusion instead of two) and scoping CI triggers. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Mary Dickson --- .github/workflows/checks.yaml | 2 +- Makefile | 6 +++--- protocol/codegen/go.mod | 3 +++ protocol/{go => }/codegen/main.go | 5 +++-- protocol/{go => }/codegen/main_test.go | 0 protocol/go/authorization/v2/entity_identifier.gen.go | 2 +- 6 files changed, 11 insertions(+), 7 deletions(-) create mode 100644 protocol/codegen/go.mod rename protocol/{go => }/codegen/main.go (95%) rename protocol/{go => }/codegen/main_test.go (100%) diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index 52aeacb6d5..1949796606 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -445,7 +445,7 @@ jobs: run: | if [ "${{ github.event_name }}" = "pull_request" ]; then BASE_SHA="${{ github.event.pull_request.base.sha }}" - if git diff --name-only "$BASE_SHA" HEAD | grep -qE '\.proto$|^Makefile$|^buf\.|^protocol/go/codegen/|^protocol/go/internal/|^sdk/codegen/'; then + if git diff --name-only "$BASE_SHA" HEAD | grep -qE '\.proto$|^Makefile$|^buf\.|^protocol/codegen/|^protocol/go/internal/|^sdk/codegen/'; then echo "proto=true" >> "$GITHUB_OUTPUT" else echo "proto=false" >> "$GITHUB_OUTPUT" diff --git a/Makefile b/Makefile index d6df1f95c3..e337f2ee20 100644 --- a/Makefile +++ b/Makefile @@ -74,7 +74,7 @@ govulncheck: proto-generate: toolcheck # remove all generated directories under protocol/go - find protocol/go -mindepth 1 -maxdepth 1 -type d ! -name codegen ! -name internal -exec rm -rf {} + + find protocol/go -mindepth 1 -maxdepth 1 -type d ! -name internal -exec rm -rf {} + rm -rf docs/grpc docs/openapi buf generate service buf generate service --template buf.gen.grpc.docs.yaml @@ -84,14 +84,14 @@ proto-generate: toolcheck buf generate buf.build/grpc-ecosystem/grpc-gateway -o tmp-gen --template buf.gen.grpc.docs.yaml buf generate buf.build/grpc-ecosystem/grpc-gateway -o tmp-gen --template buf.gen.openapi.docs.yaml - go run ./protocol/go/codegen + cd protocol/codegen && GOWORK=off go run . go run ./sdk/codegen connect-wrapper-generate: go run ./sdk/codegen proto-helper-generate: - go run ./protocol/go/codegen + cd protocol/codegen && GOWORK=off go run . policy-sql-gen: @which sqlc > /dev/null || { echo "sqlc not found, please install it: https://docs.sqlc.dev/en/stable/overview/install.html"; exit 1; } diff --git a/protocol/codegen/go.mod b/protocol/codegen/go.mod new file mode 100644 index 0000000000..4005a72b54 --- /dev/null +++ b/protocol/codegen/go.mod @@ -0,0 +1,3 @@ +module github.com/opentdf/platform/protocol/codegen + +go 1.25.0 diff --git a/protocol/go/codegen/main.go b/protocol/codegen/main.go similarity index 95% rename from protocol/go/codegen/main.go rename to protocol/codegen/main.go index 6a2a1dc60c..b362aad217 100644 --- a/protocol/go/codegen/main.go +++ b/protocol/codegen/main.go @@ -39,7 +39,7 @@ var mappings = []helperMapping{ }, } -const generatedHeader = "// Code generated by protocol/go/codegen. DO NOT EDIT.\n\n" +const generatedHeader = "// Code generated by protocol/codegen. DO NOT EDIT.\n\n" func main() { baseDir, err := getBaseDir() @@ -143,10 +143,11 @@ func rewriteImports(content string, m helperMapping) string { } // getBaseDir returns the protocol/go/ directory by navigating from this file's location. +// From protocol/codegen/main.go, go up two levels to protocol/, then into go/. func getBaseDir() (string, error) { _, filename, _, ok := runtime.Caller(0) if !ok { return "", errors.New("could not determine current file location") } - return filepath.Dir(filepath.Dir(filename)), nil + return filepath.Join(filepath.Dir(filepath.Dir(filename)), "go"), nil } diff --git a/protocol/go/codegen/main_test.go b/protocol/codegen/main_test.go similarity index 100% rename from protocol/go/codegen/main_test.go rename to protocol/codegen/main_test.go diff --git a/protocol/go/authorization/v2/entity_identifier.gen.go b/protocol/go/authorization/v2/entity_identifier.gen.go index 12aa47948b..c226fb5a56 100644 --- a/protocol/go/authorization/v2/entity_identifier.gen.go +++ b/protocol/go/authorization/v2/entity_identifier.gen.go @@ -1,4 +1,4 @@ -// Code generated by protocol/go/codegen. DO NOT EDIT. +// Code generated by protocol/codegen. DO NOT EDIT. package authorizationv2 From 37fa13a9188ce5210593bc0faa319c9985b162ab Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Tue, 7 Apr 2026 07:52:05 -0700 Subject: [PATCH 14/15] refactor(sdk): add local go.work for protocol/codegen Add a single-entry go.work so the codegen module resolves itself without needing GOWORK=off in the Makefile. More self-documenting than an env var override. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Mary Dickson --- Makefile | 4 ++-- protocol/codegen/go.work | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 protocol/codegen/go.work diff --git a/Makefile b/Makefile index e337f2ee20..ead4689a47 100644 --- a/Makefile +++ b/Makefile @@ -84,14 +84,14 @@ proto-generate: toolcheck buf generate buf.build/grpc-ecosystem/grpc-gateway -o tmp-gen --template buf.gen.grpc.docs.yaml buf generate buf.build/grpc-ecosystem/grpc-gateway -o tmp-gen --template buf.gen.openapi.docs.yaml - cd protocol/codegen && GOWORK=off go run . + cd protocol/codegen && go run . go run ./sdk/codegen connect-wrapper-generate: go run ./sdk/codegen proto-helper-generate: - cd protocol/codegen && GOWORK=off go run . + cd protocol/codegen && go run . policy-sql-gen: @which sqlc > /dev/null || { echo "sqlc not found, please install it: https://docs.sqlc.dev/en/stable/overview/install.html"; exit 1; } diff --git a/protocol/codegen/go.work b/protocol/codegen/go.work new file mode 100644 index 0000000000..a860ee362f --- /dev/null +++ b/protocol/codegen/go.work @@ -0,0 +1,3 @@ +go 1.25.0 + +use . From 54c5bef4b446f107051d63a8576796066101a9f4 Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Tue, 7 Apr 2026 08:10:31 -0700 Subject: [PATCH 15/15] =?UTF-8?q?chore(sdk):=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20tests,=20cleanup,=20and=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add copyHelpers integration test covering file filtering, _test.go exclusion, .gen.go naming, header prepending, and stale file cleanup - Strip empty import blocks from generated output - Replace Jira reference with public PR link in codegen doc comment - Add explanatory comment to protocol/codegen/go.work - Bump go version to 1.25.5 to match root workspace Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Mary Dickson --- protocol/codegen/go.mod | 2 +- protocol/codegen/go.work | 4 +- protocol/codegen/main.go | 6 ++- protocol/codegen/main_test.go | 97 +++++++++++++++++++++++++++++++++-- 4 files changed, 103 insertions(+), 6 deletions(-) diff --git a/protocol/codegen/go.mod b/protocol/codegen/go.mod index 4005a72b54..1c11114085 100644 --- a/protocol/codegen/go.mod +++ b/protocol/codegen/go.mod @@ -1,3 +1,3 @@ module github.com/opentdf/platform/protocol/codegen -go 1.25.0 +go 1.25.5 diff --git a/protocol/codegen/go.work b/protocol/codegen/go.work index a860ee362f..2789a8cc01 100644 --- a/protocol/codegen/go.work +++ b/protocol/codegen/go.work @@ -1,3 +1,5 @@ -go 1.25.0 +// Isolate this module from the root workspace so `go run .` resolves locally +// without adding protocol/codegen to the root go.work. +go 1.25.5 use . diff --git a/protocol/codegen/main.go b/protocol/codegen/main.go index b362aad217..623ed87b57 100644 --- a/protocol/codegen/main.go +++ b/protocol/codegen/main.go @@ -3,7 +3,7 @@ // Helper source files import proto types explicitly for IDE support; the copier strips // the self-referencing import and type qualifiers so the output compiles in-package. // -// See ADR DSPX-2594 for background on the source-file codegen approach. +// See https://github.com/opentdf/platform/pull/3232 for background on the source-file codegen approach. package main import ( @@ -139,6 +139,10 @@ func rewriteImports(content string, m helperMapping) string { qualifierRe := regexp.MustCompile(regexp.QuoteMeta(m.ProtoImportAlias) + `\.`) content = qualifierRe.ReplaceAllString(content, "") + // Clean up empty import blocks left behind when the proto import was the only one. + emptyImportRe := regexp.MustCompile(`\nimport \(\n\)\n`) + content = emptyImportRe.ReplaceAllString(content, "") + return content } diff --git a/protocol/codegen/main_test.go b/protocol/codegen/main_test.go index cbdd9785d7..0c9f54e301 100644 --- a/protocol/codegen/main_test.go +++ b/protocol/codegen/main_test.go @@ -3,6 +3,7 @@ package main import ( "os" "path/filepath" + "strings" "testing" ) @@ -98,9 +99,6 @@ func F() *authorizationv2.EntityIdentifier { return nil } `, want: `package authorizationv2 -import ( -) - // authorizationv2helper is not a qualifier reference var authorizationv2helper = "should stay" func F() *EntityIdentifier { return nil } @@ -158,3 +156,96 @@ func TestRemoveGenFiles(t *testing.T) { } } } + +func TestCopyHelpers(t *testing.T) { + m := helperMapping{ + Source: "test-pkg", + Target: "test-pkg", + ProtoImportPath: "github.com/example/proto/test", + ProtoImportAlias: "testpkg", + } + + srcDir := filepath.Join(t.TempDir(), "src") + dstDir := filepath.Join(t.TempDir(), "dst") + if err := os.MkdirAll(srcDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(dstDir, 0o755); err != nil { + t.Fatal(err) + } + + // Source .go file that should be copied and transformed. + helperContent := `package testpkg + +import ( + testpkg "github.com/example/proto/test" +) + +func NewFoo() *testpkg.Foo { + return &testpkg.Foo{} +} +` + if err := os.WriteFile(filepath.Join(srcDir, "helper.go"), []byte(helperContent), 0o644); err != nil { + t.Fatal(err) + } + + // _test.go file that should be skipped. + if err := os.WriteFile(filepath.Join(srcDir, "helper_test.go"), []byte("package testpkg\n"), 0o644); err != nil { + t.Fatal(err) + } + + // Non-Go file that should be skipped. + if err := os.WriteFile(filepath.Join(srcDir, "README.md"), []byte("# readme\n"), 0o644); err != nil { + t.Fatal(err) + } + + // Pre-existing stale .gen.go that should be cleaned up. + staleFile := filepath.Join(dstDir, "old_helper.gen.go") + if err := os.WriteFile(staleFile, []byte("package testpkg\n"), 0o644); err != nil { + t.Fatal(err) + } + + if err := copyHelpers(srcDir, dstDir, m); err != nil { + t.Fatalf("copyHelpers failed: %v", err) + } + + // Verify the transformed file was written with .gen.go suffix. + genFile := filepath.Join(dstDir, "helper.gen.go") + content, err := os.ReadFile(genFile) + if err != nil { + t.Fatalf("expected helper.gen.go to exist: %v", err) + } + + got := string(content) + + // Verify generated header is prepended. + if !strings.HasPrefix(got, generatedHeader) { + t.Errorf("missing generated header, starts with: %q", got[:min(len(got), 60)]) + } + + // Verify import rewriting happened (self-referencing import removed, qualifier stripped). + if strings.Contains(got, `"github.com/example/proto/test"`) { + t.Error("self-referencing import was not stripped") + } + if strings.Contains(got, "testpkg.Foo") { + t.Error("qualifier was not stripped from type references") + } + if !strings.Contains(got, "*Foo") { + t.Error("expected unqualified type reference *Foo") + } + + // Verify _test.go was not copied. + if _, err := os.Stat(filepath.Join(dstDir, "helper_test.gen.go")); err == nil { + t.Error("_test.go file should not be copied") + } + + // Verify non-Go file was not copied. + if _, err := os.Stat(filepath.Join(dstDir, "README.gen.go")); err == nil { + t.Error("non-Go file should not be copied") + } + + // Verify stale .gen.go was removed. + if _, err := os.Stat(staleFile); err == nil { + t.Error("stale old_helper.gen.go should have been removed") + } +}