Skip to content

Commit d826412

Browse files
zeevdrclaude
andauthored
test(e2e): cover UpdateTenant RPC + restricted-claim tenant filtering (#187)
Closes the gaps surfaced by the PR #111 coverage audit: UpdateTenantName / UpdateTenantSchemaVersion had 0% e2e coverage, and ListTenantsByIDs / ListTenantsBySchemaAndIDs were unreachable because e2e always ran as superadmin. - Add adminclient.UpdateTenant(id, name, schemaVersion) for single-call rename + schema upgrade — server already accepts both fields. - e2e/tenant_test.go: TestUpdateTenantName, TestUpdateTenantSchemaVersion (asserts validator cache is invalidated by setting a v1-only field after upgrade and expecting failure), TestUpdateTenantBothFields, TestListTenantsWithAccessFiltering (admin role + restricted x-tenant-id, both with and without SchemaId). Coverage after this change: UpdateTenantName 100%, UpdateTenantSchemaVersion 100%, ListTenantsByIDs 76.9%, ListTenantsBySchemaAndIDs 76.9%. Refs #113 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5e60f40 commit d826412

3 files changed

Lines changed: 255 additions & 0 deletions

File tree

e2e/tenant_test.go

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
//go:build e2e
2+
3+
package e2e
4+
5+
import (
6+
"context"
7+
"strings"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
"google.golang.org/grpc"
13+
14+
"github.com/opendecree/decree/sdk/adminclient"
15+
"github.com/opendecree/decree/sdk/grpctransport"
16+
)
17+
18+
// newRestrictedAdminClient builds an adminclient with x-role=admin and an
19+
// x-tenant-id header restricted to the given tenant IDs (comma-joined).
20+
// This exercises the non-superadmin code paths in ListTenants.
21+
func newRestrictedAdminClient(conn *grpc.ClientConn, tenantIDs []string) *adminclient.Client {
22+
return grpctransport.NewAdminClient(conn,
23+
grpctransport.WithSubject("e2e-restricted"),
24+
grpctransport.WithRole("admin"),
25+
grpctransport.WithTenantID(strings.Join(tenantIDs, ",")),
26+
)
27+
}
28+
29+
// --- UpdateTenant: rename only ---
30+
31+
func TestUpdateTenantName(t *testing.T) {
32+
conn := dial(t)
33+
admin := newAdminClient(conn)
34+
ctx := context.Background()
35+
36+
s, err := admin.CreateSchema(ctx, "rename-tenant-e2e", []adminclient.Field{
37+
{Path: "x", Type: "FIELD_TYPE_STRING"},
38+
}, "")
39+
require.NoError(t, err)
40+
_, err = admin.PublishSchema(ctx, s.ID, 1)
41+
require.NoError(t, err)
42+
43+
tenant, err := admin.CreateTenant(ctx, "tenant-rename-before", s.ID, 1)
44+
require.NoError(t, err)
45+
t.Cleanup(func() {
46+
_ = admin.DeleteTenant(ctx, tenant.ID)
47+
_ = admin.DeleteSchema(ctx, s.ID)
48+
})
49+
50+
updated, err := admin.UpdateTenantName(ctx, tenant.ID, "tenant-rename-after")
51+
require.NoError(t, err)
52+
assert.Equal(t, "tenant-rename-after", updated.Name)
53+
assert.Equal(t, tenant.ID, updated.ID)
54+
55+
got, err := admin.GetTenant(ctx, tenant.ID)
56+
require.NoError(t, err)
57+
assert.Equal(t, "tenant-rename-after", got.Name)
58+
}
59+
60+
// --- UpdateTenant: schema version upgrade invalidates the validator cache ---
61+
62+
func TestUpdateTenantSchemaVersion(t *testing.T) {
63+
conn := dial(t)
64+
admin := newAdminClient(conn)
65+
cfg := newConfigClient(conn)
66+
ctx := context.Background()
67+
68+
// v1 has only `app.value` (string).
69+
s, err := admin.CreateSchema(ctx, "tenant-upgrade-e2e", []adminclient.Field{
70+
{Path: "app.value", Type: "FIELD_TYPE_STRING"},
71+
}, "")
72+
require.NoError(t, err)
73+
_, err = admin.PublishSchema(ctx, s.ID, 1)
74+
require.NoError(t, err)
75+
76+
// v2 drops `app.value`, adds `app.count`.
77+
_, err = admin.UpdateSchema(ctx, s.ID,
78+
[]adminclient.Field{{Path: "app.count", Type: "FIELD_TYPE_INT"}},
79+
[]string{"app.value"},
80+
"v2: swap fields",
81+
)
82+
require.NoError(t, err)
83+
_, err = admin.PublishSchema(ctx, s.ID, 2)
84+
require.NoError(t, err)
85+
86+
// Tenant on v1, set the v1-only field — populates the validator cache.
87+
tenant, err := admin.CreateTenant(ctx, "tenant-upgrade", s.ID, 1)
88+
require.NoError(t, err)
89+
t.Cleanup(func() {
90+
_ = admin.DeleteTenant(ctx, tenant.ID)
91+
_ = admin.DeleteSchema(ctx, s.ID)
92+
})
93+
require.NoError(t, cfg.Set(ctx, tenant.ID, "app.value", "v1"))
94+
95+
// Upgrade to v2.
96+
updated, err := admin.UpdateTenantSchema(ctx, tenant.ID, 2)
97+
require.NoError(t, err)
98+
assert.Equal(t, int32(2), updated.SchemaVersion)
99+
100+
// Validator cache must reflect v2: setting v1's `app.value` now fails;
101+
// setting v2's `app.count` succeeds.
102+
err = cfg.Set(ctx, tenant.ID, "app.value", "still-v1")
103+
require.Error(t, err, "setting dropped field on upgraded tenant must fail")
104+
105+
require.NoError(t, cfg.SetInt(ctx, tenant.ID, "app.count", 7))
106+
count, err := cfg.GetInt(ctx, tenant.ID, "app.count")
107+
require.NoError(t, err)
108+
assert.Equal(t, int64(7), count)
109+
}
110+
111+
// --- UpdateTenant: rename + upgrade in a single call ---
112+
113+
func TestUpdateTenantBothFields(t *testing.T) {
114+
conn := dial(t)
115+
admin := newAdminClient(conn)
116+
ctx := context.Background()
117+
118+
s, err := admin.CreateSchema(ctx, "tenant-both-e2e", []adminclient.Field{
119+
{Path: "k", Type: "FIELD_TYPE_STRING"},
120+
}, "")
121+
require.NoError(t, err)
122+
_, err = admin.PublishSchema(ctx, s.ID, 1)
123+
require.NoError(t, err)
124+
125+
_, err = admin.UpdateSchema(ctx, s.ID,
126+
[]adminclient.Field{{Path: "k2", Type: "FIELD_TYPE_STRING"}},
127+
nil,
128+
"v2: add k2",
129+
)
130+
require.NoError(t, err)
131+
_, err = admin.PublishSchema(ctx, s.ID, 2)
132+
require.NoError(t, err)
133+
134+
tenant, err := admin.CreateTenant(ctx, "tenant-both-before", s.ID, 1)
135+
require.NoError(t, err)
136+
t.Cleanup(func() {
137+
_ = admin.DeleteTenant(ctx, tenant.ID)
138+
_ = admin.DeleteSchema(ctx, s.ID)
139+
})
140+
141+
newName := "tenant-both-after"
142+
newVersion := int32(2)
143+
updated, err := admin.UpdateTenant(ctx, tenant.ID, &newName, &newVersion)
144+
require.NoError(t, err)
145+
assert.Equal(t, newName, updated.Name)
146+
assert.Equal(t, newVersion, updated.SchemaVersion)
147+
}
148+
149+
// --- ListTenants restricted-claim filtering (with and without SchemaId) ---
150+
151+
func TestListTenantsWithAccessFiltering(t *testing.T) {
152+
conn := dial(t)
153+
admin := newAdminClient(conn)
154+
ctx := context.Background()
155+
156+
// Two schemas so we can also test the SchemaId-filtered path.
157+
sA, err := admin.CreateSchema(ctx, "tenant-filter-a", []adminclient.Field{
158+
{Path: "f", Type: "FIELD_TYPE_STRING"},
159+
}, "")
160+
require.NoError(t, err)
161+
_, err = admin.PublishSchema(ctx, sA.ID, 1)
162+
require.NoError(t, err)
163+
164+
sB, err := admin.CreateSchema(ctx, "tenant-filter-b", []adminclient.Field{
165+
{Path: "f", Type: "FIELD_TYPE_STRING"},
166+
}, "")
167+
require.NoError(t, err)
168+
_, err = admin.PublishSchema(ctx, sB.ID, 1)
169+
require.NoError(t, err)
170+
171+
tA1, err := admin.CreateTenant(ctx, "filter-a-1", sA.ID, 1)
172+
require.NoError(t, err)
173+
tA2, err := admin.CreateTenant(ctx, "filter-a-2", sA.ID, 1)
174+
require.NoError(t, err)
175+
tB1, err := admin.CreateTenant(ctx, "filter-b-1", sB.ID, 1)
176+
require.NoError(t, err)
177+
t.Cleanup(func() {
178+
_ = admin.DeleteTenant(ctx, tA1.ID)
179+
_ = admin.DeleteTenant(ctx, tA2.ID)
180+
_ = admin.DeleteTenant(ctx, tB1.ID)
181+
_ = admin.DeleteSchema(ctx, sA.ID)
182+
_ = admin.DeleteSchema(ctx, sB.ID)
183+
})
184+
185+
// Restrict the caller to {tA1, tB1}: tA2 must be filtered out.
186+
allowed := []string{tA1.ID, tB1.ID}
187+
restricted := newRestrictedAdminClient(conn, allowed)
188+
189+
// No schema filter → exercises ListTenantsByIDs path.
190+
all, err := restricted.ListTenants(ctx, "")
191+
require.NoError(t, err)
192+
gotAll := tenantIDSet(all)
193+
assert.Contains(t, gotAll, tA1.ID)
194+
assert.Contains(t, gotAll, tB1.ID)
195+
assert.NotContains(t, gotAll, tA2.ID, "tA2 was not in the allowed set")
196+
197+
// Schema filter → exercises ListTenantsBySchemaAndIDs path.
198+
bySchemaA, err := restricted.ListTenants(ctx, sA.ID)
199+
require.NoError(t, err)
200+
gotA := tenantIDSet(bySchemaA)
201+
assert.Contains(t, gotA, tA1.ID)
202+
assert.NotContains(t, gotA, tA2.ID, "tA2 was not in the allowed set")
203+
assert.NotContains(t, gotA, tB1.ID, "tB1 belongs to schema B")
204+
}
205+
206+
func tenantIDSet(tenants []*adminclient.Tenant) map[string]struct{} {
207+
out := make(map[string]struct{}, len(tenants))
208+
for _, t := range tenants {
209+
out[t.ID] = struct{}{}
210+
}
211+
return out
212+
}

sdk/adminclient/operations_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,31 @@ func TestUpdateTenantSchema(t *testing.T) {
354354
}
355355
}
356356

357+
func TestUpdateTenant_BothFields(t *testing.T) {
358+
ms := &mockSchemaTransport{}
359+
client := New(ms, nil, nil, nil)
360+
361+
ms.updateTenantFn = func(_ context.Context, req *UpdateTenantRequest) (*Tenant, error) {
362+
if req.Name == nil || *req.Name != "renamed" {
363+
t.Fatalf("expected Name renamed, got %v", req.Name)
364+
}
365+
if req.SchemaVersion == nil || *req.SchemaVersion != int32(2) {
366+
t.Fatalf("expected SchemaVersion 2, got %v", req.SchemaVersion)
367+
}
368+
return &Tenant{ID: "t1", Name: "renamed", SchemaVersion: 2, CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil
369+
}
370+
371+
name := "renamed"
372+
v := int32(2)
373+
tenant, err := client.UpdateTenant(context.Background(), "t1", &name, &v)
374+
if err != nil {
375+
t.Fatalf("unexpected error: %v", err)
376+
}
377+
if tenant.Name != "renamed" || tenant.SchemaVersion != 2 {
378+
t.Errorf("got %+v, want renamed/v2", tenant)
379+
}
380+
}
381+
357382
func TestDeleteTenant_Success(t *testing.T) {
358383
ms := &mockSchemaTransport{}
359384
client := New(ms, nil, nil, nil)
@@ -724,6 +749,11 @@ func TestServiceNotConfigured_AllMethods(t *testing.T) {
724749
t.Errorf("UpdateTenantSchema: got error %v, want %v", err, ErrServiceNotConfigured)
725750
}
726751

752+
_, err = client.UpdateTenant(ctx, "t1", nil, nil)
753+
if !errors.Is(err, ErrServiceNotConfigured) {
754+
t.Errorf("UpdateTenant: got error %v, want %v", err, ErrServiceNotConfigured)
755+
}
756+
727757
err = client.DeleteTenant(ctx, "t1")
728758
if !errors.Is(err, ErrServiceNotConfigured) {
729759
t.Errorf("DeleteTenant: got error %v, want %v", err, ErrServiceNotConfigured)

sdk/adminclient/tenant.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,19 @@ func (c *Client) UpdateTenantSchema(ctx context.Context, id string, schemaVersio
7676
})
7777
}
7878

79+
// UpdateTenant updates a tenant's name and/or schema version in a single call.
80+
// Pass nil for fields that should be left unchanged. At least one field must be non-nil.
81+
func (c *Client) UpdateTenant(ctx context.Context, id string, name *string, schemaVersion *int32) (*Tenant, error) {
82+
if c.schema == nil {
83+
return nil, ErrServiceNotConfigured
84+
}
85+
return c.schema.UpdateTenant(ctx, &UpdateTenantRequest{
86+
ID: id,
87+
Name: name,
88+
SchemaVersion: schemaVersion,
89+
})
90+
}
91+
7992
// DeleteTenant permanently deletes a tenant and all its configuration data.
8093
func (c *Client) DeleteTenant(ctx context.Context, id string) error {
8194
if c.schema == nil {

0 commit comments

Comments
 (0)