mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-06-13 23:36:45 +03:00
f8973b3ed6
* feat(iam): OIDC provider mutations + multi-client + TLS thumbprints - Mutating IAM actions: CreateOpenIDConnectProvider, DeleteOpenIDConnectProvider, AddClientIDToOpenIDConnectProvider, RemoveClientIDFromOpenIDConnectProvider, UpdateOpenIDConnectProviderThumbprint, TagOpenIDConnectProvider, UntagOpenIDConnectProvider. Each enforces AWS-shape input bounds and the read-only mode rejects all mutations. - Multiple client_ids per provider in OIDCConfig (clientIds list, plural) with full backward compatibility — singular clientId still works and is merged into the audience allowlist. Provider factory accepts both. - AWS-compatible TLS thumbprint pinning: when OIDCConfig.Thumbprints is non-empty, JWKS fetches enforce that the negotiated TLS chain contains a certificate whose SHA-1 hex matches the allowlist. Empty list keeps the existing system-trust path. * fix(iam): factor Tags.member.N parser into a helper CreateOpenIDConnectProvider and TagOpenIDConnectProvider were both walking the AWS Tags.member.N.Key / Tags.member.N.Value query-string convention with copy-pasted loops. Factor into extractTags so the parsing rules and the "no tags present" semantics live in one place. Addresses gemini medium review on PR #9320. * fix(iam): sentinel errors for OIDC provider not-found / already-exists The s3api dispatcher was using strings.Contains(err.Error(), "not found") and "already exists" to map IAM-manager errors back to AWS error codes. Substring matching on a formatted message couples the API error code to the exact wording of the upstream message — touching the message silently changes the IAM API contract. Define ErrOIDCProviderNotFound and ErrOIDCProviderAlreadyExists in the integration package, fmt.Errorf("%w: ...") them at the four return sites in iam_manager.go and oidc_provider_store.go, and use errors.Is at the s3api call sites. Same control flow, no string-match fragility. Addresses gemini medium review on PR #9320. * fix(iam): surface non-NotFound errors from CreateOIDCProvider lookup Previously CreateOIDCProvider only treated GetProviderByARN's success path as "exists" and silently fell through on any error, including transient backend failures. That hid real problems and still attempted a write. Distinguish ErrOIDCProviderNotFound (the only "safe to create" case) from other errors so we don't mask filer outages or partition issues. * fix(iam): enforce 100-client-ID cap on AddClientIDToOIDCProvider CreateOIDCProvider and the implicit update path through validateOIDC- ProviderRecord both reject lists with more than 100 client IDs, but AddClientIDToOIDCProvider could grow the list past that bound one ID at a time. Refuse the add when the list is already at the cap so the invariant holds across every mutation entry point. * feat(iam): IAM-managed OIDC provider live view in STS service Add a separate, mutex-guarded map of admin-managed OIDC providers on the STS service. The map can be atomically replaced via SetIAMManagedOIDCProvidersByIssuer; AssumeRoleWithWebIdentity lookups consult it first and fall back to the existing static-config map, so records persisted through the IAM API can shadow bootstrap entries without a restart. This is the runtime hook the IAM API and the metadata-subscribe path will both call when the OIDCProviderStore changes (next two commits). * feat(iam): refresh STS service runtime view after OIDC mutations Add IAMManager.RefreshOIDCProvidersFromStore: lists every persisted OIDCProviderRecord, builds a runtime OIDCProvider for each, and atomically publishes the issuer-keyed map into the STS service. Each mutating IAM API call (Create / Delete / AddClientID / RemoveClientID / UpdateThumbprints) now triggers this refresh inline so the local instance picks up the change without waiting for a metadata-subscribe round trip. Tag mutations skip the refresh because tags do not affect token validation. Refresh failures only log; the persisted write has already succeeded by that point, so a transient list error must not surface to the API caller. The peer-instance update path (filer metadata subscription) is added in a follow-up commit. * feat(iam): subscribe to OIDC provider changes on the filer Watch /etc/iam/oidc-providers under the existing s3 metadata-subscribe loop and call RefreshOIDCProvidersFromStore on any create / update / delete / rename. This is the cross-instance update path: S3 server A writes via the IAM API, the filer fans out the metadata change, and S3 servers B..N pick up the new runtime view without a restart. Mirrors the existing onIamConfigChange / onCircuitBreakerConfigChange pattern. The handler short-circuits when the path is unrelated, and when no IAMManager is wired in (static-only configurations).
397 lines
14 KiB
Go
397 lines
14 KiB
Go
package s3api
|
|
|
|
import (
|
|
"context"
|
|
"net/url"
|
|
"testing"
|
|
"time"
|
|
|
|
iamlib "github.com/seaweedfs/seaweedfs/weed/iam"
|
|
"github.com/seaweedfs/seaweedfs/weed/iam/integration"
|
|
"github.com/seaweedfs/seaweedfs/weed/iam/policy"
|
|
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
|
|
)
|
|
|
|
// stubIntegration is the smallest IAMManagerProvider that lets the OIDC
|
|
// dispatcher reach an IAMManager. The other IAMIntegration methods are
|
|
// unused by these tests and panic if invoked, which is what we want — any
|
|
// unexpected call signals a routing bug.
|
|
type stubIntegration struct {
|
|
IAMIntegration
|
|
mgr *integration.IAMManager
|
|
}
|
|
|
|
func (s *stubIntegration) GetIAMManager() *integration.IAMManager { return s.mgr }
|
|
|
|
func newOIDCTestAPI(t *testing.T) (*EmbeddedIamApiForTest, *integration.IAMManager) {
|
|
t.Helper()
|
|
mgr := integration.NewIAMManager()
|
|
cfg := &integration.IAMConfig{
|
|
STS: &sts.STSConfig{
|
|
TokenDuration: sts.FlexibleDuration{Duration: time.Hour},
|
|
MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour},
|
|
Issuer: "test-sts",
|
|
SigningKey: []byte("test-signing-key-32-characters-long"),
|
|
AccountId: "111122223333",
|
|
Providers: []*sts.ProviderConfig{
|
|
{
|
|
Name: "google",
|
|
Type: sts.ProviderTypeOIDC,
|
|
Enabled: true,
|
|
Config: map[string]interface{}{
|
|
"issuer": "https://accounts.google.com",
|
|
"clientId": "client-google",
|
|
},
|
|
},
|
|
{
|
|
Name: "github",
|
|
Type: sts.ProviderTypeOIDC,
|
|
Enabled: true,
|
|
Config: map[string]interface{}{
|
|
"issuer": "https://token.actions.githubusercontent.com",
|
|
"clientId": "sts.amazonaws.com",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Policy: &policy.PolicyEngineConfig{DefaultEffect: "Deny", StoreType: "memory"},
|
|
Roles: &integration.RoleStoreConfig{StoreType: "memory"},
|
|
}
|
|
if err := mgr.Initialize(cfg, func() string { return "localhost:8888" }); err != nil {
|
|
t.Fatalf("Initialize IAM manager: %v", err)
|
|
}
|
|
|
|
api := NewEmbeddedIamApiForTest()
|
|
api.iam.iamIntegration = &stubIntegration{mgr: mgr}
|
|
return api, mgr
|
|
}
|
|
|
|
func TestListOpenIDConnectProviders(t *testing.T) {
|
|
api, _ := newOIDCTestAPI(t)
|
|
values := url.Values{}
|
|
values.Set("Action", actionListOpenIDConnectProviders)
|
|
|
|
resp, iamErr := api.ExecuteAction(context.Background(), values, true, "test-req-1")
|
|
if iamErr != nil {
|
|
t.Fatalf("ExecuteAction: code=%s err=%v", iamErr.Code, iamErr.Error)
|
|
}
|
|
listResp, ok := resp.(*iamlib.ListOpenIDConnectProvidersResponse)
|
|
if !ok {
|
|
t.Fatalf("unexpected response type %T", resp)
|
|
}
|
|
got := listResp.ListOpenIDConnectProvidersResult.OpenIDConnectProviderList
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected 2 providers, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestGetOpenIDConnectProvider(t *testing.T) {
|
|
api, _ := newOIDCTestAPI(t)
|
|
arn := "arn:aws:iam::111122223333:oidc-provider/accounts.google.com"
|
|
|
|
values := url.Values{}
|
|
values.Set("Action", actionGetOpenIDConnectProvider)
|
|
values.Set("OpenIDConnectProviderArn", arn)
|
|
|
|
resp, iamErr := api.ExecuteAction(context.Background(), values, true, "test-req-2")
|
|
if iamErr != nil {
|
|
t.Fatalf("ExecuteAction: code=%s err=%v", iamErr.Code, iamErr.Error)
|
|
}
|
|
getResp, ok := resp.(*iamlib.GetOpenIDConnectProviderResponse)
|
|
if !ok {
|
|
t.Fatalf("unexpected response type %T", resp)
|
|
}
|
|
if getResp.GetOpenIDConnectProviderResult.Url != "https://accounts.google.com" {
|
|
t.Fatalf("URL mismatch: %s", getResp.GetOpenIDConnectProviderResult.Url)
|
|
}
|
|
if len(getResp.GetOpenIDConnectProviderResult.ClientIDList) != 1 ||
|
|
getResp.GetOpenIDConnectProviderResult.ClientIDList[0] != "client-google" {
|
|
t.Fatalf("ClientIDList wrong: %v", getResp.GetOpenIDConnectProviderResult.ClientIDList)
|
|
}
|
|
}
|
|
|
|
func TestGetOpenIDConnectProviderMissing(t *testing.T) {
|
|
api, _ := newOIDCTestAPI(t)
|
|
values := url.Values{}
|
|
values.Set("Action", actionGetOpenIDConnectProvider)
|
|
values.Set("OpenIDConnectProviderArn", "arn:aws:iam::111122223333:oidc-provider/nope.example")
|
|
|
|
_, iamErr := api.ExecuteAction(context.Background(), values, true, "test-req-3")
|
|
if iamErr == nil {
|
|
t.Fatal("expected NoSuchEntity error")
|
|
}
|
|
if iamErr.Code != "NoSuchEntity" {
|
|
t.Fatalf("expected NoSuchEntity code, got %s", iamErr.Code)
|
|
}
|
|
}
|
|
|
|
func TestGetOpenIDConnectProviderRequiresArn(t *testing.T) {
|
|
api, _ := newOIDCTestAPI(t)
|
|
values := url.Values{}
|
|
values.Set("Action", actionGetOpenIDConnectProvider)
|
|
|
|
_, iamErr := api.ExecuteAction(context.Background(), values, true, "test-req-4")
|
|
if iamErr == nil {
|
|
t.Fatal("expected error for missing ARN")
|
|
}
|
|
if iamErr.Code != "InvalidInput" {
|
|
t.Fatalf("expected InvalidInput code, got %s", iamErr.Code)
|
|
}
|
|
}
|
|
|
|
func TestReadOnlyAllowsOIDCList(t *testing.T) {
|
|
api, _ := newOIDCTestAPI(t)
|
|
api.readOnly = true
|
|
values := url.Values{}
|
|
values.Set("Action", actionListOpenIDConnectProviders)
|
|
|
|
if _, iamErr := api.ExecuteAction(context.Background(), values, true, "ro-1"); iamErr != nil {
|
|
t.Fatalf("read-only mode should allow ListOpenIDConnectProviders: %v", iamErr.Error)
|
|
}
|
|
}
|
|
|
|
func TestReadOnlyDeniesOIDCMutations(t *testing.T) {
|
|
api, _ := newOIDCTestAPI(t)
|
|
api.readOnly = true
|
|
mutations := []string{
|
|
actionCreateOpenIDConnectProvider,
|
|
actionDeleteOpenIDConnectProvider,
|
|
actionAddClientIDToOpenIDConnectProvider,
|
|
actionRemoveClientIDFromOpenIDConnectProvider,
|
|
actionUpdateOpenIDConnectProviderThumbprint,
|
|
actionTagOpenIDConnectProvider,
|
|
actionUntagOpenIDConnectProvider,
|
|
}
|
|
for _, action := range mutations {
|
|
t.Run(action, func(t *testing.T) {
|
|
values := url.Values{}
|
|
values.Set("Action", action)
|
|
_, iamErr := api.ExecuteAction(context.Background(), values, true, "ro-deny")
|
|
if iamErr == nil {
|
|
t.Fatalf("expected denial for %s in read-only mode", action)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreateOpenIDConnectProvider(t *testing.T) {
|
|
api, _ := newOIDCTestAPI(t)
|
|
values := url.Values{}
|
|
values.Set("Action", actionCreateOpenIDConnectProvider)
|
|
values.Set("Url", "https://auth.example.com")
|
|
values.Set("ClientIDList.member.1", "alpha")
|
|
values.Set("ClientIDList.member.2", "beta")
|
|
values.Set("ThumbprintList.member.1", "9e99a48a9960b14926bb7f3b02e22da2b0ab7280")
|
|
values.Set("Tags.member.1.Key", "team")
|
|
values.Set("Tags.member.1.Value", "infra")
|
|
|
|
resp, iamErr := api.ExecuteAction(context.Background(), values, true, "create-1")
|
|
if iamErr != nil {
|
|
t.Fatalf("ExecuteAction: %v", iamErr.Error)
|
|
}
|
|
createResp, ok := resp.(*iamlib.CreateOpenIDConnectProviderResponse)
|
|
if !ok {
|
|
t.Fatalf("unexpected response type %T", resp)
|
|
}
|
|
wantArn := "arn:aws:iam::111122223333:oidc-provider/auth.example.com"
|
|
if createResp.CreateOpenIDConnectProviderResult.OpenIDConnectProviderArn != wantArn {
|
|
t.Fatalf("ARN mismatch: got=%s want=%s",
|
|
createResp.CreateOpenIDConnectProviderResult.OpenIDConnectProviderArn, wantArn)
|
|
}
|
|
|
|
// Get the new provider back to confirm persistence and clientId list.
|
|
getValues := url.Values{}
|
|
getValues.Set("Action", actionGetOpenIDConnectProvider)
|
|
getValues.Set("OpenIDConnectProviderArn", wantArn)
|
|
getResp, iamErr := api.ExecuteAction(context.Background(), getValues, true, "get-after-create")
|
|
if iamErr != nil {
|
|
t.Fatalf("Get after create: %v", iamErr.Error)
|
|
}
|
|
gr := getResp.(*iamlib.GetOpenIDConnectProviderResponse).GetOpenIDConnectProviderResult
|
|
if len(gr.ClientIDList) != 2 || gr.ClientIDList[0] != "alpha" || gr.ClientIDList[1] != "beta" {
|
|
t.Fatalf("ClientIDList persisted incorrectly: %v", gr.ClientIDList)
|
|
}
|
|
if len(gr.ThumbprintList) != 1 || gr.ThumbprintList[0] != "9e99a48a9960b14926bb7f3b02e22da2b0ab7280" {
|
|
t.Fatalf("ThumbprintList persisted incorrectly: %v", gr.ThumbprintList)
|
|
}
|
|
if len(gr.Tags) != 1 || gr.Tags[0].Key != "team" || gr.Tags[0].Value != "infra" {
|
|
t.Fatalf("Tags persisted incorrectly: %v", gr.Tags)
|
|
}
|
|
}
|
|
|
|
func TestCreateOpenIDConnectProviderRejectsBadInput(t *testing.T) {
|
|
api, _ := newOIDCTestAPI(t)
|
|
|
|
t.Run("missing Url", func(t *testing.T) {
|
|
values := url.Values{}
|
|
values.Set("Action", actionCreateOpenIDConnectProvider)
|
|
values.Set("ClientIDList.member.1", "x")
|
|
_, iamErr := api.ExecuteAction(context.Background(), values, true, "")
|
|
if iamErr == nil || iamErr.Code != "InvalidInput" {
|
|
t.Fatalf("expected InvalidInput, got %v", iamErr)
|
|
}
|
|
})
|
|
|
|
t.Run("missing ClientIDList", func(t *testing.T) {
|
|
values := url.Values{}
|
|
values.Set("Action", actionCreateOpenIDConnectProvider)
|
|
values.Set("Url", "https://auth.example.com")
|
|
_, iamErr := api.ExecuteAction(context.Background(), values, true, "")
|
|
if iamErr == nil || iamErr.Code != "InvalidInput" {
|
|
t.Fatalf("expected InvalidInput, got %v", iamErr)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid thumbprint", func(t *testing.T) {
|
|
values := url.Values{}
|
|
values.Set("Action", actionCreateOpenIDConnectProvider)
|
|
values.Set("Url", "https://auth.example.com")
|
|
values.Set("ClientIDList.member.1", "x")
|
|
values.Set("ThumbprintList.member.1", "not-a-sha1")
|
|
_, iamErr := api.ExecuteAction(context.Background(), values, true, "")
|
|
if iamErr == nil || iamErr.Code != "InvalidInput" {
|
|
t.Fatalf("expected InvalidInput, got %v", iamErr)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCreateOpenIDConnectProviderConflict(t *testing.T) {
|
|
api, _ := newOIDCTestAPI(t)
|
|
values := url.Values{}
|
|
values.Set("Action", actionCreateOpenIDConnectProvider)
|
|
values.Set("Url", "https://accounts.google.com") // already mirrored from static config
|
|
values.Set("ClientIDList.member.1", "x")
|
|
|
|
_, iamErr := api.ExecuteAction(context.Background(), values, true, "")
|
|
if iamErr == nil {
|
|
t.Fatal("expected conflict for duplicate provider")
|
|
}
|
|
if iamErr.Code != "EntityAlreadyExists" {
|
|
t.Fatalf("expected EntityAlreadyExists, got %s", iamErr.Code)
|
|
}
|
|
}
|
|
|
|
func TestDeleteOpenIDConnectProvider(t *testing.T) {
|
|
api, _ := newOIDCTestAPI(t)
|
|
arn := "arn:aws:iam::111122223333:oidc-provider/accounts.google.com"
|
|
|
|
values := url.Values{}
|
|
values.Set("Action", actionDeleteOpenIDConnectProvider)
|
|
values.Set("OpenIDConnectProviderArn", arn)
|
|
|
|
if _, iamErr := api.ExecuteAction(context.Background(), values, true, ""); iamErr != nil {
|
|
t.Fatalf("delete: %v", iamErr.Error)
|
|
}
|
|
// Verify gone.
|
|
getValues := url.Values{}
|
|
getValues.Set("Action", actionGetOpenIDConnectProvider)
|
|
getValues.Set("OpenIDConnectProviderArn", arn)
|
|
if _, iamErr := api.ExecuteAction(context.Background(), getValues, true, ""); iamErr == nil {
|
|
t.Fatal("expected not-found after delete")
|
|
}
|
|
// Idempotent.
|
|
if _, iamErr := api.ExecuteAction(context.Background(), values, true, ""); iamErr != nil {
|
|
t.Fatalf("idempotent delete failed: %v", iamErr.Error)
|
|
}
|
|
}
|
|
|
|
func TestAddRemoveClientID(t *testing.T) {
|
|
api, _ := newOIDCTestAPI(t)
|
|
arn := "arn:aws:iam::111122223333:oidc-provider/accounts.google.com"
|
|
|
|
add := url.Values{}
|
|
add.Set("Action", actionAddClientIDToOpenIDConnectProvider)
|
|
add.Set("OpenIDConnectProviderArn", arn)
|
|
add.Set("ClientID", "extra-client")
|
|
if _, iamErr := api.ExecuteAction(context.Background(), add, true, ""); iamErr != nil {
|
|
t.Fatalf("add: %v", iamErr.Error)
|
|
}
|
|
|
|
getValues := url.Values{}
|
|
getValues.Set("Action", actionGetOpenIDConnectProvider)
|
|
getValues.Set("OpenIDConnectProviderArn", arn)
|
|
resp, iamErr := api.ExecuteAction(context.Background(), getValues, true, "")
|
|
if iamErr != nil {
|
|
t.Fatalf("get: %v", iamErr.Error)
|
|
}
|
|
got := resp.(*iamlib.GetOpenIDConnectProviderResponse).GetOpenIDConnectProviderResult.ClientIDList
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected 2 clients after add, got %v", got)
|
|
}
|
|
|
|
// Idempotent add.
|
|
if _, iamErr := api.ExecuteAction(context.Background(), add, true, ""); iamErr != nil {
|
|
t.Fatalf("idempotent add: %v", iamErr.Error)
|
|
}
|
|
|
|
// Remove.
|
|
rm := url.Values{}
|
|
rm.Set("Action", actionRemoveClientIDFromOpenIDConnectProvider)
|
|
rm.Set("OpenIDConnectProviderArn", arn)
|
|
rm.Set("ClientID", "extra-client")
|
|
if _, iamErr := api.ExecuteAction(context.Background(), rm, true, ""); iamErr != nil {
|
|
t.Fatalf("remove: %v", iamErr.Error)
|
|
}
|
|
resp, _ = api.ExecuteAction(context.Background(), getValues, true, "")
|
|
got = resp.(*iamlib.GetOpenIDConnectProviderResponse).GetOpenIDConnectProviderResult.ClientIDList
|
|
if len(got) != 1 || got[0] != "client-google" {
|
|
t.Fatalf("unexpected clients after remove: %v", got)
|
|
}
|
|
|
|
// Removing a missing client is a no-op.
|
|
if _, iamErr := api.ExecuteAction(context.Background(), rm, true, ""); iamErr != nil {
|
|
t.Fatalf("idempotent remove: %v", iamErr.Error)
|
|
}
|
|
}
|
|
|
|
func TestUpdateThumbprintAndTags(t *testing.T) {
|
|
api, _ := newOIDCTestAPI(t)
|
|
arn := "arn:aws:iam::111122223333:oidc-provider/accounts.google.com"
|
|
|
|
upd := url.Values{}
|
|
upd.Set("Action", actionUpdateOpenIDConnectProviderThumbprint)
|
|
upd.Set("OpenIDConnectProviderArn", arn)
|
|
upd.Set("ThumbprintList.member.1", "0000000000000000000000000000000000000000")
|
|
upd.Set("ThumbprintList.member.2", "1111111111111111111111111111111111111111")
|
|
if _, iamErr := api.ExecuteAction(context.Background(), upd, true, ""); iamErr != nil {
|
|
t.Fatalf("update thumbprints: %v", iamErr.Error)
|
|
}
|
|
|
|
tag := url.Values{}
|
|
tag.Set("Action", actionTagOpenIDConnectProvider)
|
|
tag.Set("OpenIDConnectProviderArn", arn)
|
|
tag.Set("Tags.member.1.Key", "owner")
|
|
tag.Set("Tags.member.1.Value", "platform")
|
|
if _, iamErr := api.ExecuteAction(context.Background(), tag, true, ""); iamErr != nil {
|
|
t.Fatalf("tag: %v", iamErr.Error)
|
|
}
|
|
|
|
get := url.Values{}
|
|
get.Set("Action", actionGetOpenIDConnectProvider)
|
|
get.Set("OpenIDConnectProviderArn", arn)
|
|
resp, iamErr := api.ExecuteAction(context.Background(), get, true, "")
|
|
if iamErr != nil {
|
|
t.Fatalf("get: %v", iamErr.Error)
|
|
}
|
|
gr := resp.(*iamlib.GetOpenIDConnectProviderResponse).GetOpenIDConnectProviderResult
|
|
if len(gr.ThumbprintList) != 2 {
|
|
t.Fatalf("ThumbprintList persisted incorrectly: %v", gr.ThumbprintList)
|
|
}
|
|
if len(gr.Tags) != 1 || gr.Tags[0].Key != "owner" {
|
|
t.Fatalf("Tags persisted incorrectly: %v", gr.Tags)
|
|
}
|
|
|
|
untag := url.Values{}
|
|
untag.Set("Action", actionUntagOpenIDConnectProvider)
|
|
untag.Set("OpenIDConnectProviderArn", arn)
|
|
untag.Set("TagKeys.member.1", "owner")
|
|
if _, iamErr := api.ExecuteAction(context.Background(), untag, true, ""); iamErr != nil {
|
|
t.Fatalf("untag: %v", iamErr.Error)
|
|
}
|
|
resp, _ = api.ExecuteAction(context.Background(), get, true, "")
|
|
gr = resp.(*iamlib.GetOpenIDConnectProviderResponse).GetOpenIDConnectProviderResult
|
|
if len(gr.Tags) != 0 {
|
|
t.Fatalf("Tags should be empty after untag, got: %v", gr.Tags)
|
|
}
|
|
}
|