2020-10-06 19:20:29 +00:00
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package integration
import (
"context"
2020-10-19 19:21:18 +00:00
"encoding/json"
2020-10-07 00:53:29 +00:00
"fmt"
"io/ioutil"
"net/http"
2020-10-20 21:00:36 +00:00
"net/url"
"strings"
2020-10-06 19:20:29 +00:00
"testing"
"time"
2020-10-07 00:53:29 +00:00
"github.com/stretchr/testify/assert"
2020-10-06 19:20:29 +00:00
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2020-10-07 00:53:29 +00:00
"go.pinniped.dev/generated/1.19/apis/config/v1alpha1"
2020-10-08 02:18:34 +00:00
pinnipedclientset "go.pinniped.dev/generated/1.19/client/clientset/versioned"
2020-10-07 00:53:29 +00:00
"go.pinniped.dev/internal/here"
"go.pinniped.dev/test/library"
2020-10-06 19:20:29 +00:00
)
func TestSupervisorOIDCDiscovery ( t * testing . T ) {
env := library . IntegrationEnv ( t )
client := library . NewPinnipedClientset ( t )
2020-10-07 00:53:29 +00:00
ns := env . SupervisorNamespace
ctx , cancel := context . WithTimeout ( context . Background ( ) , 2 * time . Minute )
2020-10-06 19:20:29 +00:00
defer cancel ( )
2020-10-07 00:53:29 +00:00
// Temporarily remove any existing OIDCProviderConfigs from the cluster so we can test from a clean slate.
originalConfigList , err := client . ConfigV1alpha1 ( ) . OIDCProviderConfigs ( ns ) . List ( ctx , metav1 . ListOptions { } )
require . NoError ( t , err )
for _ , config := range originalConfigList . Items {
err := client . ConfigV1alpha1 ( ) . OIDCProviderConfigs ( ns ) . Delete ( ctx , config . Name , metav1 . DeleteOptions { } )
require . NoError ( t , err )
}
// When this test has finished, recreate any OIDCProviderConfigs that had existed on the cluster before this test.
t . Cleanup ( func ( ) {
2020-10-07 14:53:05 +00:00
cleanupCtx , cancel := context . WithTimeout ( context . Background ( ) , 2 * time . Minute )
defer cancel ( )
2020-10-07 00:53:29 +00:00
for _ , config := range originalConfigList . Items {
thisConfig := config
2020-10-07 14:53:05 +00:00
thisConfig . ResourceVersion = "" // Get rid of resource version since we can't create an object with one.
_ , err := client . ConfigV1alpha1 ( ) . OIDCProviderConfigs ( ns ) . Create ( cleanupCtx , & thisConfig , metav1 . CreateOptions { } )
2020-10-07 00:53:29 +00:00
require . NoError ( t , err )
}
} )
2020-10-20 21:00:36 +00:00
supervisorScheme := "http"
supervisorAddress := env . SupervisorHTTPAddress
2020-10-07 00:53:29 +00:00
// Test that there is no default discovery endpoint available when there are no OIDCProviderConfigs.
2020-10-20 21:00:36 +00:00
requireDiscoveryEndpointsAreNotFound ( t , supervisorScheme , supervisorAddress , fmt . Sprintf ( "%s://%s" , supervisorScheme , supervisorAddress ) )
2020-10-08 02:18:34 +00:00
// Define several unique issuer strings.
2020-10-20 21:00:36 +00:00
issuer1 := fmt . Sprintf ( "%s://%s/nested/issuer1" , supervisorScheme , supervisorAddress )
issuer2 := fmt . Sprintf ( "%s://%s/nested/issuer2" , supervisorScheme , supervisorAddress )
issuer3 := fmt . Sprintf ( "%s://%s/issuer3" , supervisorScheme , supervisorAddress )
issuer4 := fmt . Sprintf ( "%s://%s/issuer4" , supervisorScheme , supervisorAddress )
issuer5 := fmt . Sprintf ( "%s://%s/issuer5" , supervisorScheme , supervisorAddress )
issuer6 := fmt . Sprintf ( "%s://%s/issuer6" , supervisorScheme , supervisorAddress )
badIssuer := fmt . Sprintf ( "%s://%s/badIssuer?cannot-use=queries" , supervisorScheme , supervisorAddress )
2020-10-08 02:18:34 +00:00
// When OIDCProviderConfig are created in sequence they each cause a discovery endpoint to appear only for as long as the OIDCProviderConfig exists.
2020-10-20 21:00:36 +00:00
config1 , jwks1 := requireCreatingOIDCProviderConfigCausesDiscoveryEndpointsToAppear ( ctx , t , supervisorScheme , supervisorAddress , issuer1 , client )
2020-10-20 22:22:03 +00:00
requireDeletingOIDCProviderConfigCausesDiscoveryEndpointsToDisappear ( t , config1 , client , ns , supervisorScheme , supervisorAddress , issuer1 )
2020-10-20 21:00:36 +00:00
config2 , jwks2 := requireCreatingOIDCProviderConfigCausesDiscoveryEndpointsToAppear ( ctx , t , supervisorScheme , supervisorAddress , issuer2 , client )
2020-10-20 22:22:03 +00:00
requireDeletingOIDCProviderConfigCausesDiscoveryEndpointsToDisappear ( t , config2 , client , ns , supervisorScheme , supervisorAddress , issuer2 )
2020-10-19 19:21:18 +00:00
// The auto-created JWK's were different from each other.
require . NotEqual ( t , jwks1 . Keys [ 0 ] [ "x" ] , jwks2 . Keys [ 0 ] [ "x" ] )
require . NotEqual ( t , jwks1 . Keys [ 0 ] [ "y" ] , jwks2 . Keys [ 0 ] [ "y" ] )
2020-10-08 02:18:34 +00:00
// When multiple OIDCProviderConfigs exist at the same time they each serve a unique discovery endpoint.
2020-10-20 21:00:36 +00:00
config3 , jwks3 := requireCreatingOIDCProviderConfigCausesDiscoveryEndpointsToAppear ( ctx , t , supervisorScheme , supervisorAddress , issuer3 , client )
config4 , jwks4 := requireCreatingOIDCProviderConfigCausesDiscoveryEndpointsToAppear ( ctx , t , supervisorScheme , supervisorAddress , issuer4 , client )
requireDiscoveryEndpointsAreWorking ( t , supervisorScheme , supervisorAddress , issuer3 ) // discovery for issuer3 is still working after issuer4 started working
2020-10-19 19:21:18 +00:00
// The auto-created JWK's were different from each other.
require . NotEqual ( t , jwks3 . Keys [ 0 ] [ "x" ] , jwks4 . Keys [ 0 ] [ "x" ] )
require . NotEqual ( t , jwks3 . Keys [ 0 ] [ "y" ] , jwks4 . Keys [ 0 ] [ "y" ] )
// Editing a provider to change the issuer name updates the endpoints that are being served.
updatedConfig4 := editOIDCProviderConfigIssuerName ( t , config4 , client , ns , issuer5 )
2020-10-20 21:00:36 +00:00
requireDiscoveryEndpointsAreNotFound ( t , supervisorScheme , supervisorAddress , issuer4 )
jwks5 := requireDiscoveryEndpointsAreWorking ( t , supervisorScheme , supervisorAddress , issuer5 )
2020-10-19 19:21:18 +00:00
// The JWK did not change when the issuer name was updated.
require . Equal ( t , jwks4 . Keys [ 0 ] , jwks5 . Keys [ 0 ] )
2020-10-08 02:18:34 +00:00
// When they are deleted they stop serving discovery endpoints.
2020-10-20 22:22:03 +00:00
requireDeletingOIDCProviderConfigCausesDiscoveryEndpointsToDisappear ( t , config3 , client , ns , supervisorScheme , supervisorAddress , issuer3 )
requireDeletingOIDCProviderConfigCausesDiscoveryEndpointsToDisappear ( t , updatedConfig4 , client , ns , supervisorScheme , supervisorAddress , issuer5 )
2020-10-08 17:27:45 +00:00
// When the same issuer is added twice, both issuers are marked as duplicates, and neither provider is serving.
2020-10-20 22:22:03 +00:00
config6Duplicate1 , _ := requireCreatingOIDCProviderConfigCausesDiscoveryEndpointsToAppear ( ctx , t , supervisorScheme , supervisorAddress , issuer6 , client )
config6Duplicate2 := library . CreateTestOIDCProvider ( ctx , t , issuer6 )
requireStatus ( t , client , ns , config6Duplicate1 . Name , v1alpha1 . DuplicateOIDCProviderStatus )
requireStatus ( t , client , ns , config6Duplicate2 . Name , v1alpha1 . DuplicateOIDCProviderStatus )
2020-10-20 21:00:36 +00:00
requireDiscoveryEndpointsAreNotFound ( t , supervisorScheme , supervisorAddress , issuer6 )
2020-10-08 17:27:45 +00:00
// If we delete the first duplicate issuer, the second duplicate issuer starts serving.
2020-10-20 22:22:03 +00:00
requireDelete ( t , client , ns , config6Duplicate1 . Name )
2020-10-20 21:00:36 +00:00
requireWellKnownEndpointIsWorking ( t , supervisorScheme , supervisorAddress , issuer6 )
2020-10-20 22:22:03 +00:00
requireStatus ( t , client , ns , config6Duplicate2 . Name , v1alpha1 . SuccessOIDCProviderStatus )
2020-10-08 17:27:45 +00:00
// When we finally delete all issuers, the endpoint should be down.
2020-10-20 22:22:03 +00:00
requireDeletingOIDCProviderConfigCausesDiscoveryEndpointsToDisappear ( t , config6Duplicate2 , client , ns , supervisorScheme , supervisorAddress , issuer6 )
// "Host" headers can be used to send requests to discovery endpoints when the public address is different from the issuer name.
issuer7 := fmt . Sprintf ( "%s://some-issuer-host-and-port-that-doesnt-match-public-supervisor-address.com:2684/issuer7" , supervisorScheme )
config7 , _ := requireCreatingOIDCProviderConfigCausesDiscoveryEndpointsToAppear ( ctx , t , supervisorScheme , supervisorAddress , issuer7 , client )
requireDeletingOIDCProviderConfigCausesDiscoveryEndpointsToDisappear ( t , config7 , client , ns , supervisorScheme , supervisorAddress , issuer7 )
2020-10-08 17:27:45 +00:00
// When we create a provider with an invalid issuer, the status is set to invalid.
2020-10-15 13:09:49 +00:00
badConfig := library . CreateTestOIDCProvider ( ctx , t , badIssuer )
2020-10-08 17:27:45 +00:00
requireStatus ( t , client , ns , badConfig . Name , v1alpha1 . InvalidOIDCProviderStatus )
2020-10-20 21:00:36 +00:00
requireDiscoveryEndpointsAreNotFound ( t , supervisorScheme , supervisorAddress , badIssuer )
2020-10-08 02:18:34 +00:00
}
2020-10-20 21:00:36 +00:00
func jwksURLForIssuer ( scheme , host , path string ) string {
2020-10-20 22:22:03 +00:00
return fmt . Sprintf ( "%s://%s/%s/jwks.json" , scheme , host , strings . TrimPrefix ( path , "/" ) )
2020-10-19 19:21:18 +00:00
}
2020-10-20 21:00:36 +00:00
func wellKnownURLForIssuer ( scheme , host , path string ) string {
2020-10-20 22:22:03 +00:00
return fmt . Sprintf ( "%s://%s/%s/.well-known/openid-configuration" , scheme , host , strings . TrimPrefix ( path , "/" ) )
2020-10-19 19:21:18 +00:00
}
2020-10-20 21:00:36 +00:00
func requireDiscoveryEndpointsAreNotFound ( t * testing . T , supervisorScheme , supervisorAddress , issuerName string ) {
2020-10-19 19:21:18 +00:00
t . Helper ( )
2020-10-20 21:00:36 +00:00
issuerURL , err := url . Parse ( issuerName )
require . NoError ( t , err )
requireEndpointNotFound ( t , wellKnownURLForIssuer ( supervisorScheme , supervisorAddress , issuerURL . Path ) , issuerURL . Host )
requireEndpointNotFound ( t , jwksURLForIssuer ( supervisorScheme , supervisorAddress , issuerURL . Path ) , issuerURL . Host )
2020-10-19 19:21:18 +00:00
}
2020-10-20 21:00:36 +00:00
func requireEndpointNotFound ( t * testing . T , url , host string ) {
2020-10-08 02:18:34 +00:00
t . Helper ( )
httpClient := & http . Client { }
ctx , cancel := context . WithTimeout ( context . Background ( ) , 2 * time . Minute )
defer cancel ( )
2020-10-19 19:21:18 +00:00
requestNonExistentPath , err := http . NewRequestWithContext ( ctx , http . MethodGet , url , nil )
2020-10-20 21:00:36 +00:00
require . NoError ( t , err )
requestNonExistentPath . Header . Add ( "Host" , host )
2020-10-08 02:18:34 +00:00
var response * http . Response
assert . Eventually ( t , func ( ) bool {
response , err = httpClient . Do ( requestNonExistentPath ) //nolint:bodyclose
return err == nil && response . StatusCode == http . StatusNotFound
} , 10 * time . Second , 200 * time . Millisecond )
2020-10-07 00:53:29 +00:00
require . NoError ( t , err )
2020-10-08 02:18:34 +00:00
require . Equal ( t , http . StatusNotFound , response . StatusCode )
err = response . Body . Close ( )
2020-10-07 00:53:29 +00:00
require . NoError ( t , err )
2020-10-08 02:18:34 +00:00
}
2020-10-07 00:53:29 +00:00
2020-10-19 19:21:18 +00:00
func requireCreatingOIDCProviderConfigCausesDiscoveryEndpointsToAppear (
2020-10-15 13:09:49 +00:00
ctx context . Context ,
t * testing . T ,
2020-10-20 21:00:36 +00:00
supervisorScheme , supervisorAddress string ,
2020-10-15 13:09:49 +00:00
issuerName string ,
client pinnipedclientset . Interface ,
2020-10-19 19:21:18 +00:00
) ( * v1alpha1 . OIDCProviderConfig , * ExpectedJWKSResponseFormat ) {
2020-10-08 02:18:34 +00:00
t . Helper ( )
2020-10-19 19:21:18 +00:00
2020-10-15 13:09:49 +00:00
newOIDCProviderConfig := library . CreateTestOIDCProvider ( ctx , t , issuerName )
2020-10-19 19:21:18 +00:00
2020-10-20 21:00:36 +00:00
jwksResult := requireDiscoveryEndpointsAreWorking ( t , supervisorScheme , supervisorAddress , issuerName )
2020-10-19 19:21:18 +00:00
2020-10-15 13:09:49 +00:00
requireStatus ( t , client , newOIDCProviderConfig . Namespace , newOIDCProviderConfig . Name , v1alpha1 . SuccessOIDCProviderStatus )
2020-10-19 19:21:18 +00:00
return newOIDCProviderConfig , jwksResult
2020-10-08 02:18:34 +00:00
}
2020-10-20 21:00:36 +00:00
func requireDiscoveryEndpointsAreWorking ( t * testing . T , supervisorScheme , supervisorAddress , issuerName string ) * ExpectedJWKSResponseFormat {
requireWellKnownEndpointIsWorking ( t , supervisorScheme , supervisorAddress , issuerName )
jwksResult := requireJWKSEndpointIsWorking ( t , supervisorScheme , supervisorAddress , issuerName )
2020-10-19 19:21:18 +00:00
return jwksResult
}
2020-10-20 22:22:03 +00:00
func requireDeletingOIDCProviderConfigCausesDiscoveryEndpointsToDisappear (
2020-10-19 19:21:18 +00:00
t * testing . T ,
existingOIDCProviderConfig * v1alpha1 . OIDCProviderConfig ,
client pinnipedclientset . Interface ,
ns string ,
2020-10-20 21:00:36 +00:00
supervisorScheme , supervisorAddress string ,
2020-10-19 19:21:18 +00:00
issuerName string ,
) {
2020-10-08 02:18:34 +00:00
t . Helper ( )
ctx , cancel := context . WithTimeout ( context . Background ( ) , 2 * time . Minute )
defer cancel ( )
// Delete the OIDCProviderConfig.
err := client . ConfigV1alpha1 ( ) . OIDCProviderConfigs ( ns ) . Delete ( ctx , existingOIDCProviderConfig . Name , metav1 . DeleteOptions { } )
2020-10-07 00:53:29 +00:00
require . NoError ( t , err )
2020-10-08 02:18:34 +00:00
// Fetch that same discovery endpoint as before, but now it should not exist anymore. Give it some time for the endpoint to go away.
2020-10-20 21:00:36 +00:00
requireDiscoveryEndpointsAreNotFound ( t , supervisorScheme , supervisorAddress , issuerName )
2020-10-08 02:18:34 +00:00
}
2020-10-07 14:53:05 +00:00
2020-10-20 21:00:36 +00:00
func requireWellKnownEndpointIsWorking ( t * testing . T , supervisorScheme , supervisorAddress , issuerName string ) {
2020-10-08 02:18:34 +00:00
t . Helper ( )
2020-10-19 19:21:18 +00:00
2020-10-20 21:00:36 +00:00
issuerURL , err := url . Parse ( issuerName )
require . NoError ( t , err )
response , responseBody := requireSuccessEndpointResponse ( t , wellKnownURLForIssuer ( supervisorScheme , supervisorAddress , issuerURL . Path ) , issuerName ) //nolint:bodyclose
2020-10-19 19:21:18 +00:00
// Check that the response matches our expectations.
expectedResultTemplate := here . Doc ( ` {
"issuer" : "%s" ,
"authorization_endpoint" : "%s/oauth2/authorize" ,
"token_endpoint" : "%s/oauth2/token" ,
"token_endpoint_auth_methods_supported" : [ "client_secret_basic" ] ,
"token_endpoint_auth_signing_alg_values_supported" : [ "RS256" ] ,
"jwks_uri" : "%s/jwks.json" ,
"scopes_supported" : [ "openid" , "offline" ] ,
"response_types_supported" : [ "code" ] ,
"claims_supported" : [ "groups" ] ,
"subject_types_supported" : [ "public" ] ,
"id_token_signing_alg_values_supported" : [ "RS256" ]
} ` )
expectedJSON := fmt . Sprintf ( expectedResultTemplate , issuerName , issuerName , issuerName , issuerName )
require . Equal ( t , "application/json" , response . Header . Get ( "content-type" ) )
require . JSONEq ( t , expectedJSON , responseBody )
}
type ExpectedJWKSResponseFormat struct {
Keys [ ] map [ string ] string
}
2020-10-20 21:00:36 +00:00
func requireJWKSEndpointIsWorking ( t * testing . T , supervisorScheme , supervisorAddress , issuerName string ) * ExpectedJWKSResponseFormat {
2020-10-19 19:21:18 +00:00
t . Helper ( )
2020-10-20 21:00:36 +00:00
issuerURL , err := url . Parse ( issuerName )
require . NoError ( t , err )
response , responseBody := requireSuccessEndpointResponse ( t , jwksURLForIssuer ( supervisorScheme , supervisorAddress , issuerURL . Path ) , issuerName ) //nolint:bodyclose
2020-10-19 19:21:18 +00:00
var result ExpectedJWKSResponseFormat
2020-10-20 21:00:36 +00:00
err = json . Unmarshal ( [ ] byte ( responseBody ) , & result )
2020-10-19 19:21:18 +00:00
require . NoError ( t , err )
require . Len ( t , result . Keys , 1 )
jwk := result . Keys [ 0 ]
require . Len ( t , jwk , 7 ) // make sure there are no extra values, i.e. does not include private key
require . NotEmpty ( t , jwk [ "kid" ] )
require . Equal ( t , "sig" , jwk [ "use" ] )
require . Equal ( t , "EC" , jwk [ "kty" ] )
require . Equal ( t , "P-256" , jwk [ "crv" ] )
require . Equal ( t , "ES256" , jwk [ "alg" ] )
require . NotEmpty ( t , jwk [ "x" ] )
require . NotEmpty ( t , jwk [ "y" ] )
require . Equal ( t , "application/json" , response . Header . Get ( "content-type" ) )
return & result
}
2020-10-20 21:00:36 +00:00
func requireSuccessEndpointResponse ( t * testing . T , endpointURL , issuer string ) ( * http . Response , string ) {
2020-10-08 02:18:34 +00:00
httpClient := & http . Client { }
ctx , cancel := context . WithTimeout ( context . Background ( ) , 2 * time . Minute )
defer cancel ( )
2020-10-07 00:53:29 +00:00
2020-10-08 02:18:34 +00:00
// Define a request to the new discovery endpoint which should have been created by an OIDCProviderConfig.
2020-10-07 00:53:29 +00:00
requestDiscoveryEndpoint , err := http . NewRequestWithContext (
ctx ,
http . MethodGet ,
2020-10-20 21:00:36 +00:00
endpointURL ,
2020-10-07 00:53:29 +00:00
nil ,
)
require . NoError ( t , err )
2020-10-20 22:22:03 +00:00
issuerURL , err := url . Parse ( issuer )
require . NoError ( t , err )
// Set the host header on the request to match the issuer's hostname, which could potentially be different
// from the public ingress address, e.g. when a load balancer is used, so we want to test here that the host
// header is respected by the supervisor server.
requestDiscoveryEndpoint . Host = issuerURL . Host
2020-10-20 21:00:36 +00:00
2020-10-07 00:53:29 +00:00
// Fetch that discovery endpoint. Give it some time for the endpoint to come into existence.
var response * http . Response
assert . Eventually ( t , func ( ) bool {
2020-10-08 02:18:34 +00:00
response , err = httpClient . Do ( requestDiscoveryEndpoint ) //nolint:bodyclose
2020-10-07 14:53:05 +00:00
return err == nil && response . StatusCode == http . StatusOK
2020-10-07 00:53:29 +00:00
} , 10 * time . Second , 200 * time . Millisecond )
require . NoError ( t , err )
2020-10-07 14:53:05 +00:00
require . Equal ( t , http . StatusOK , response . StatusCode )
2020-10-07 00:53:29 +00:00
responseBody , err := ioutil . ReadAll ( response . Body )
require . NoError ( t , err )
err = response . Body . Close ( )
require . NoError ( t , err )
2020-10-19 19:21:18 +00:00
return response , string ( responseBody )
}
2020-10-07 00:53:29 +00:00
2020-10-19 19:21:18 +00:00
func editOIDCProviderConfigIssuerName (
t * testing . T ,
existingOIDCProviderConfig * v1alpha1 . OIDCProviderConfig ,
client pinnipedclientset . Interface ,
ns string ,
newIssuerName string ,
) * v1alpha1 . OIDCProviderConfig {
t . Helper ( )
ctx , cancel := context . WithTimeout ( context . Background ( ) , 2 * time . Minute )
defer cancel ( )
2020-10-06 19:20:29 +00:00
2020-10-19 19:21:18 +00:00
mostRecentVersion , err := client . ConfigV1alpha1 ( ) . OIDCProviderConfigs ( ns ) . Get ( ctx , existingOIDCProviderConfig . Name , metav1 . GetOptions { } )
require . NoError ( t , err )
mostRecentVersion . Spec . Issuer = newIssuerName
updated , err := client . ConfigV1alpha1 ( ) . OIDCProviderConfigs ( ns ) . Update ( ctx , mostRecentVersion , metav1 . UpdateOptions { } )
require . NoError ( t , err )
return updated
2020-10-06 19:20:29 +00:00
}
2020-10-08 02:18:34 +00:00
2020-10-08 17:27:45 +00:00
func requireDelete ( t * testing . T , client pinnipedclientset . Interface , ns , name string ) {
t . Helper ( )
ctx , cancel := context . WithTimeout ( context . Background ( ) , 10 * time . Second )
defer cancel ( )
err := client . ConfigV1alpha1 ( ) . OIDCProviderConfigs ( ns ) . Delete ( ctx , name , metav1 . DeleteOptions { } )
require . NoError ( t , err )
}
func requireStatus ( t * testing . T , client pinnipedclientset . Interface , ns , name string , status v1alpha1 . OIDCProviderStatus ) {
t . Helper ( )
ctx , cancel := context . WithTimeout ( context . Background ( ) , 10 * time . Second )
defer cancel ( )
var opc * v1alpha1 . OIDCProviderConfig
var err error
assert . Eventually ( t , func ( ) bool {
opc , err = client . ConfigV1alpha1 ( ) . OIDCProviderConfigs ( ns ) . Get ( ctx , name , metav1 . GetOptions { } )
return err == nil && opc . Status . Status == status
} , 10 * time . Second , 200 * time . Millisecond )
require . NoError ( t , err )
require . Equalf ( t , status , opc . Status . Status , "unexpected status (message = '%s')" , opc . Status . Message )
}