github.com/openfga/openfga@v1.5.4-rc1/pkg/testutils/testutils.go (about) 1 // Package testutils contains code that is useful in tests. 2 package testutils 3 4 import ( 5 "context" 6 "errors" 7 "fmt" 8 "math/rand" 9 "net" 10 "net/http" 11 "sort" 12 "strconv" 13 "testing" 14 "time" 15 16 "github.com/cenkalti/backoff/v4" 17 "github.com/google/go-cmp/cmp" 18 "github.com/hashicorp/go-retryablehttp" 19 "github.com/oklog/ulid/v2" 20 openfgav1 "github.com/openfga/api/proto/openfga/v1" 21 parser "github.com/openfga/language/pkg/go/transformer" 22 "github.com/stretchr/testify/require" 23 "google.golang.org/grpc" 24 25 grpcbackoff "google.golang.org/grpc/backoff" 26 "google.golang.org/grpc/credentials" 27 "google.golang.org/grpc/credentials/insecure" 28 healthv1pb "google.golang.org/grpc/health/grpc_health_v1" 29 "google.golang.org/protobuf/types/known/structpb" 30 31 serverconfig "github.com/openfga/openfga/internal/server/config" 32 ) 33 34 const ( 35 AllChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 36 ) 37 38 var ( 39 TupleCmpTransformer = cmp.Transformer("Sort", func(in []*openfgav1.Tuple) []*openfgav1.Tuple { 40 out := append([]*openfgav1.Tuple(nil), in...) // Copy input to avoid mutating it 41 42 sort.SliceStable(out, func(i, j int) bool { 43 if out[i].GetKey().GetObject() != out[j].GetKey().GetObject() { 44 return out[i].GetKey().GetObject() < out[j].GetKey().GetObject() 45 } 46 47 if out[i].GetKey().GetRelation() != out[j].GetKey().GetRelation() { 48 return out[i].GetKey().GetRelation() < out[j].GetKey().GetRelation() 49 } 50 51 if out[i].GetKey().GetUser() != out[j].GetKey().GetUser() { 52 return out[i].GetKey().GetUser() < out[j].GetKey().GetUser() 53 } 54 55 return true 56 }) 57 58 return out 59 }) 60 TupleKeyCmpTransformer = cmp.Transformer("Sort", func(in []*openfgav1.TupleKey) []*openfgav1.TupleKey { 61 out := append([]*openfgav1.TupleKey(nil), in...) // Copy input to avoid mutating it 62 63 sort.SliceStable(out, func(i, j int) bool { 64 if out[i].GetObject() != out[j].GetObject() { 65 return out[i].GetObject() < out[j].GetObject() 66 } 67 68 if out[i].GetRelation() != out[j].GetRelation() { 69 return out[i].GetRelation() < out[j].GetRelation() 70 } 71 72 if out[i].GetUser() != out[j].GetUser() { 73 return out[i].GetUser() < out[j].GetUser() 74 } 75 76 return true 77 }) 78 79 return out 80 }) 81 ) 82 83 func CreateRandomString(n int) string { 84 b := make([]byte, n) 85 for i := range b { 86 b[i] = AllChars[rand.Intn(len(AllChars))] 87 } 88 return string(b) 89 } 90 91 func MustNewStruct(t require.TestingT, v map[string]interface{}) *structpb.Struct { 92 conditionContext, err := structpb.NewStruct(v) 93 require.NoError(t, err) 94 return conditionContext 95 } 96 97 // MakeSliceWithGenerator generates a slice of length 'n' and populates the contents 98 // with values based on the generator provided. 99 func MakeSliceWithGenerator[T any](n uint64, generator func(n uint64) any) []T { 100 s := make([]T, 0, n) 101 102 for i := uint64(0); i < n; i++ { 103 s = append(s, generator(i).(T)) 104 } 105 106 return s 107 } 108 109 // NumericalStringGenerator generates a string representation of the provided 110 // uint value. 111 func NumericalStringGenerator(n uint64) any { 112 return strconv.FormatUint(n, 10) 113 } 114 115 func MakeStringWithRuneset(n uint64, runeSet []rune) string { 116 var s string 117 for i := uint64(0); i < n; i++ { 118 s += string(runeSet[rand.Intn(len(runeSet))]) 119 } 120 121 return s 122 } 123 124 // MustTransformDSLToProtoWithID interprets the provided string s as an FGA model and 125 // attempts to parse it using the official OpenFGA language parser. The model returned 126 // includes an auto-generated model id which assists with producing models for testing 127 // purposes. 128 func MustTransformDSLToProtoWithID(s string) *openfgav1.AuthorizationModel { 129 model := parser.MustTransformDSLToProto(s) 130 model.Id = ulid.Make().String() 131 132 return model 133 } 134 135 // CreateGrpcConnection creates a grpc connection to an address and closes it when the test ends. 136 func CreateGrpcConnection(t *testing.T, grpcAddress string, opts ...grpc.DialOption) *grpc.ClientConn { 137 t.Helper() 138 139 defaultOptions := []grpc.DialOption{ 140 grpc.WithConnectParams(grpc.ConnectParams{Backoff: grpcbackoff.DefaultConfig}), 141 grpc.WithTransportCredentials(insecure.NewCredentials()), 142 } 143 144 defaultOptions = append(defaultOptions, opts...) 145 146 conn, err := grpc.Dial( 147 grpcAddress, defaultOptions..., 148 ) 149 require.NoError(t, err) 150 t.Cleanup(func() { 151 conn.Close() 152 }) 153 154 return conn 155 } 156 157 // EnsureServiceHealthy is a test helper that ensures that a service's grpc health endpoint is responding OK. It can also 158 // ensure that the HTTP /healthz endpoint is responding OK. If the service doesn't respond healthy in 30 seconds it fails the test. 159 func EnsureServiceHealthy(t testing.TB, grpcAddr, httpAddr string, transportCredentials credentials.TransportCredentials, httpHealthCheck bool) { 160 t.Helper() 161 162 creds := insecure.NewCredentials() 163 if transportCredentials != nil { 164 creds = transportCredentials 165 } 166 167 dialOpts := []grpc.DialOption{ 168 grpc.WithTransportCredentials(creds), 169 grpc.WithConnectParams(grpc.ConnectParams{Backoff: grpcbackoff.DefaultConfig}), 170 } 171 172 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 173 defer cancel() 174 175 t.Log("creating connection to address", grpcAddr) 176 conn, err := grpc.DialContext( 177 ctx, 178 grpcAddr, 179 dialOpts..., 180 ) 181 require.NoError(t, err, "error creating grpc connection to server") 182 t.Cleanup(func() { 183 conn.Close() 184 }) 185 186 client := healthv1pb.NewHealthClient(conn) 187 188 policy := backoff.NewExponentialBackOff() 189 policy.MaxElapsedTime = 30 * time.Second 190 191 err = backoff.Retry(func() error { 192 resp, err := client.Check(context.Background(), &healthv1pb.HealthCheckRequest{ 193 Service: openfgav1.OpenFGAService_ServiceDesc.ServiceName, 194 }) 195 if err != nil { 196 t.Log(time.Now(), "not serving yet at address", grpcAddr, err) 197 return err 198 } 199 200 if resp.GetStatus() != healthv1pb.HealthCheckResponse_SERVING { 201 t.Log(time.Now(), resp.GetStatus()) 202 return errors.New("not serving") 203 } 204 205 return nil 206 }, policy) 207 require.NoError(t, err, "server did not reach healthy status") 208 209 if httpHealthCheck { 210 resp, err := retryablehttp.Get(fmt.Sprintf("http://%s/healthz", httpAddr)) 211 require.NoError(t, err, "http endpoint not healthy") 212 213 t.Cleanup(func() { 214 err := resp.Body.Close() 215 require.NoError(t, err) 216 }) 217 218 require.Equal(t, http.StatusOK, resp.StatusCode, "unexpected status code received from server") 219 } 220 } 221 222 // MustDefaultConfigWithRandomPorts returns default server config but with random ports for the grpc and http addresses 223 // and with the playground, tracing and metrics turned off. 224 // This function may panic if somehow a random port cannot be chosen. 225 func MustDefaultConfigWithRandomPorts() *serverconfig.Config { 226 config := serverconfig.MustDefaultConfig() 227 228 httpPort, httpPortReleaser := TCPRandomPort() 229 defer httpPortReleaser() 230 grpcPort, grpcPortReleaser := TCPRandomPort() 231 defer grpcPortReleaser() 232 233 config.GRPC.Addr = fmt.Sprintf("0.0.0.0:%d", grpcPort) 234 config.HTTP.Addr = fmt.Sprintf("0.0.0.0:%d", httpPort) 235 236 return config 237 } 238 239 // TCPRandomPort tries to find a random TCP Port. If it can't find one, it panics. Else, it returns the port and a function that releases the port. 240 // It is the responsibility of the caller to call the release function right before trying to listen on the given port. 241 func TCPRandomPort() (int, func()) { 242 l, err := net.Listen("tcp", "") 243 if err != nil { 244 panic(err) 245 } 246 return l.Addr().(*net.TCPAddr).Port, func() { 247 l.Close() 248 } 249 }