From e3ff46e3533497b8183bb21666216b614350bd77 Mon Sep 17 00:00:00 2001 From: Alejandro Visiedo Date: Tue, 31 Oct 2023 11:30:04 +0100 Subject: [PATCH] HMS-2056 WIP test: add smoke test Currently only a very initial changes to allow smoke test for the application from the local workstation for the developer (by extension later into the pipeline could be added too). This same infrastructure could be reused for integration tests which will test the wrong scenarios for the different endpoints. Be aware the current SetupTest method is a copy from the cmd/service/main.go code; this is starting exactly the same as the service (and will need the same external dependencies: database, [kafka? future maybe], [s3 storage, not our use case]). Maybe chop and move to `internal/infrastructure/helper.go` so we reuse from `cmd/service/main.go` and from `internal/test/smoke/main.go` files. The intention is that calling: `make compose-clean clean build compose-up test-smoke` we would run the smoke tests. --- A similar approach reusing all the helpers could works for integration tests: `make compose-clean clean build compose-up test-integration` we would run the integration tests. --- build: update make rules This change execute separatly tests and smoke tests; the unit tests can be run in parallel. However the smoke tests, currently cannot run in parallel because the service is not being configured with different ports, and that evoke the sockets to fail on setting up. Said that, it is not impossible to achieve that, as we can choose random ports for service and metrics; making that, we would only need to run the tests avoiding data overlaping. To achieve no data overlaping the easier way is using a different org_id for each test; in that way the database should not overlap. To implement this, I suggest to implement a generator, which will be just a counter, starting in a random start sequence. Once both of the above two features are added to the smoke tests, that will allow to run them in parallel with no collision. --- TODO - [x] Review current state could works for integration tests. - [x] Update docs/dev/TESTING.md file with step by step guide (two approaches). - [.] Implement more use cases. - [x] Create token. - [.] Register rhel-idm domain. - [ ] Read rhel-idm domain. - [ ] List rhel-idm domain. - [ ] Patch rhel-idm domain. - [ ] Update rhel-idm domain. - [ ] Delete rhel-idm domain. - [x] Refactor to reuse common components: integration tests, start/stop sequences. - [ ] Show coverage for smoke tests. - [.] Add API builder components. - [.] Add MODEL builder components. - [.] Fix situation when running in the pipeline. - [ ] Reorg the commit. Signed-off-by: Alejandro Visiedo --- .github/workflows/main.yml | 26 +- .gitignore | 1 + configs/config.ci.yaml | 82 ++++++ docs/dev/01-service-api.md | 1 + docs/dev/TESTING.md | 149 ++++++++++ internal/test/builder/api/builder_domain.go | 31 ++ .../builder/api/builder_domain_rhel_idm.go | 59 ++++ .../test/builder/api/builder_system_xrhid.go | 91 ++++++ .../test/builder/api/builder_user_xrhid.go | 124 ++++++++ internal/test/builder/doc.go | 86 ++++++ internal/test/builder/helper/helper.go | 173 +++++++++++ internal/test/builder/model/builder_domain.go | 129 ++++++++ internal/test/builder/model/builder_ipa.go | 82 ++++++ .../test/builder/model/builder_ipa_server.go | 103 +++++++ internal/test/builder/model/builder_model.go | 66 +++++ internal/test/smoke/doc.go | 11 + internal/test/smoke/register_test.go | 176 +++++++++++ internal/test/smoke/suite_test.go | 278 ++++++++++++++++++ internal/test/smoke/token_test.go | 115 ++++++++ .../interactor/domain_interactor_test.go | 2 +- scripts/mk/compose.mk | 4 +- scripts/mk/go-rules.mk | 17 +- 22 files changed, 1798 insertions(+), 8 deletions(-) create mode 100644 configs/config.ci.yaml create mode 100644 internal/test/builder/api/builder_domain.go create mode 100644 internal/test/builder/api/builder_domain_rhel_idm.go create mode 100644 internal/test/builder/api/builder_system_xrhid.go create mode 100644 internal/test/builder/api/builder_user_xrhid.go create mode 100644 internal/test/builder/doc.go create mode 100644 internal/test/builder/helper/helper.go create mode 100644 internal/test/builder/model/builder_domain.go create mode 100644 internal/test/builder/model/builder_ipa.go create mode 100644 internal/test/builder/model/builder_ipa_server.go create mode 100644 internal/test/builder/model/builder_model.go create mode 100644 internal/test/smoke/doc.go create mode 100644 internal/test/smoke/register_test.go create mode 100644 internal/test/smoke/suite_test.go create mode 100644 internal/test/smoke/token_test.go diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ef5a74365..3ba00e9bd 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,8 +10,29 @@ permissions: jobs: validate: + # https://docs.github.com/en/actions/using-containerized-services/creating-postgresql-service-containers + services: + postgres: + # https://hub.docker.com/_/postgres/ + image: postgres:13 + env: + POSTGRES_USERNAME: postgres + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 runs-on: "ubuntu-latest" container: golang:1.19 + env: + DATABASE_HOST: postgres + DATABASE_PORT: 5432 + DATABASE_USER: postgres + DATABASE_NAME: postgres + DATABASE_PASSWORD: postgres steps: - uses: "actions/checkout@v4" with: @@ -52,7 +73,10 @@ jobs: run: go vet $(go list ./... | grep -v /vendor/) - name: Run tests - run: make test + run: | + cp -vf configs/config.ci.yaml configs/config.yaml + make db-migrate-up + make test - name: Process coverage report run: | diff --git a/.gitignore b/.gitignore index 69d307f5e..aa9513786 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ tmp.*/** configs/** !configs/config.example.yaml +!configs/config.ci.yaml !configs/bonfire.example.yaml # File generated when running unit tests diff --git a/configs/config.ci.yaml b/configs/config.ci.yaml new file mode 100644 index 000000000..161de1d31 --- /dev/null +++ b/configs/config.ci.yaml @@ -0,0 +1,82 @@ +--- +logging: + level: info # The normal level in production + # level: trace # Will display the sql statements, usefult for development + # level: debug + # Set to false to get a json output for the log + console: true + +web: + port: 8000 + +database: + host: localhost + port: 5432 + user: postgres + password: postgres + name: postgres + +kafka: + auto: + offset: + reset: latest + commit: + interval: + ms: 5000 + bootstrap: + servers: localhost:9092 + group: + id: idmsvc + message: + send: + max: + retries: 15 + request: + timeout: + ms: 30000 + required: + acks: -1 + retry: + backoff: + ms: 100 + timeout: 10000 + topics: + - platform.idmsvc.introspect + # sasl: + # username: someusername + # passowrd: somepassword + # mechanism: somemechanism + # protocol: someprotocol + +# cloudwatch: +# region: +# group: +# stream: +# key: +# secret: +# session: +# options: +# paged_rpm_inserts_limit: 100 +metrics: + path: "/metrics" + port: 9000 + +clients: + inventory: + base_url: http://localhost:8010/api/inventory/v1 + +app: + # Token expiration time in seconds + # default: 2 hours + token_expiration_seconds: 7200 + # The pagination default limit for the first list request + pagination_default_limit: 10 + # The pagination max limit to avoid bigger values and long requests + pagination_max_limit: 100 + # Allow to inject a system identity for development propose + accept_x_rh_fake_identity: false + # Validate API requests and response against the openapi specification + validate_api: true + # main secret for various MAC and encryptions like domain registration + # token and encrypted private JWKs. "random" generates an ephemeral secret. + secret: random diff --git a/docs/dev/01-service-api.md b/docs/dev/01-service-api.md index b8675a2e4..e86f3cc84 100644 --- a/docs/dev/01-service-api.md +++ b/docs/dev/01-service-api.md @@ -227,6 +227,7 @@ for the interface, it seems logic to add the implementations at: ## Adding a new `myres` resource to my public api - Add the data model at: `internal/domain/model/myres.go` +- Add a builder for the above data model at: `internal/test/builder` - Generate the migration scripts by: `make build && ./bin/db-tool new myres` - Fill the migration scripts at: `scripts/db/migrations/` diff --git a/docs/dev/TESTING.md b/docs/dev/TESTING.md index 33045b385..9e7345661 100644 --- a/docs/dev/TESTING.md +++ b/docs/dev/TESTING.md @@ -387,6 +387,155 @@ FIXME It was seen that could be complicated to See: `internal/test/client/server.go` +## Smoke tests + +Every new feature we want to create a set of successful tests. +For every new resource we will create a suite test (for instance +for the token we have SuiteToken). + +We create a new file at `internal/test/smoke` such as `token_test.go`. + +We add a new suite test from the `SuiteBase` type: + +```golang +type SuiteToken struct { + SuiteBase +} +``` + +SuiteBase include the logic below: + +- Load the configuration. +- Start/stop the services (API and metrics). +- Generate a user and system XRHID for an arbitrary organization. + +If we need to check custom responses that are not deterministic +for the given input or more complex, then add the below to use +BodyFunc field in a more comfortable way: + +```golang +// BodyFuncTokenResponse is the function that wrap +type BodyFuncTokenResponse func(t *testing.T, expect *public.DomainRegTokenResponse) error + +// WrapBodyFuncTokenResponse allow to implement custom body expectations for the specific type of the response. +// expected is the specific BodyFuncTokenResponse for DomainRegTokenResponse type +// Returns a BodyFunc that wrap the generic expectation function. +func WrapBodyFuncTokenResponse(expected BodyFuncTokenResponse) BodyFunc { + // To allow a generic interface for any body response type + // I have to use `body []byte`; I cannot use `any` because + // the response type is particular for the endpoint. + // That means the input to the function is not in a golang + // structure; to let the tests to be defined with less boilerplate, + // every response type would implement a wrapper function like + // this, which unmarshall the bytes, and call to the more specific + // custom body function. + if expected == nil { + return func(t *testing.T, body []byte) bool { + return len(body) == 0 + } + } + return func(t *testing.T, body []byte) bool { + // Unserialize the response to the expected type + var data public.DomainRegTokenResponse + if err := json.Unmarshal(body, &data); err != nil { + require.Fail(t, fmt.Sprintf("Error unmarshalling body:\n"+ + "error: %q", + err.Error(), + )) + return false + } + // Run body expectetion on the unserialized data + if err := expected(t, &data); err != nil { + require.Fail(t, fmt.Sprintf("Error in body response:\n"+ + "error: %q", + err.Error(), + )) + return false + } + return true + } +} +``` + +Now you can define methods that fit `BodyFuncTokenResponse` and use +them into the BodyFunc by calling to `WrapBodyFuncTokenResponse`. + +Define your suite test by adding every success request at: + +```golang +func (s *SuiteTokenCreate) TestToken() { + xrhidEncoded := header.EncodeXRHID(&s.UserXRHID) + + // Prepare the tests + testCases := []TestCase{ + { + Name: "TestToken", + Given: TestCaseGiven{ + Method: http.MethodPost, + URL: DefaultBaseURL + "/domains/token", + Header: http.Header{ + "X-Rh-Insights-Request-Id": {"test_token"}, + "X-Rh-Identity": {xrhidEncoded}, + }, + Body: public.DomainRegTokenRequest{ + DomainType: "rhel-idm", + }, + }, + Expected: TestCaseExpect{ + StatusCode: http.StatusInternalServerError, + Header: http.Header{ + "X-Rh-Insights-Request-Id": {"test_token"}, + "X-Rh-Identity": nil, + }, + BodyFunc: WrapBodyFuncTokenResponse(s.bodyExpectationTestToken), + }, + }, + } + + // Execute the test cases + s.RunTestCases(testCases) +} +``` + +The TestCase has been designed to fit integration tests too, and to +provide flexibility, different Body fields exist: + +- Request: + + - `Body any` as any golang struct pointer, so the request will + be serialized as a json making easier to define requests. + - `BodyBytes []byte` as an array of bytes, so we can customize + the request content (some use case could be provide + a test with a malformed json document). + +- Response: + + - `BodyBytes []byte` as the above, to specify a perfect match + with the response received. + - `Body any` as a golang structure that will be serialized and + compared with the array of bytes received as response. + - `BodyFunc BodyFunc` as a custom function, here we will use the + specific `Wrap...` function to remove boilerplate unserializing + the content. + +- `Body []byte` as an array of bytes, so we can directly add the + exact matching with the response body. +- `Body` + +Once we have defined the test case into the testCase slice, we run +all the test cases by calling to s.RunTestCases(testCases) which is +a method defined into the SuiteBase structure. + +Finally add your suite test at `internal/test/smoke/suite_test.go`, +at `TestSuite` function by: + +```golang +func TestSuite(t *testing.T) { + // TODO Add here your test suites + suite.Run(t, new(SuiteToken)) +} +``` + ## References - https://pkg.go.dev/github.com/stretchr/testify diff --git a/internal/test/builder/api/builder_domain.go b/internal/test/builder/api/builder_domain.go new file mode 100644 index 000000000..476ab2159 --- /dev/null +++ b/internal/test/builder/api/builder_domain.go @@ -0,0 +1,31 @@ +package api + +import ( + "github.com/google/uuid" + "github.com/openlyinc/pointy" + "github.com/podengo-project/idmsvc-backend/internal/api/public" + builder_helper "github.com/podengo-project/idmsvc-backend/internal/test/builder/helper" +) + +type Domain interface { + Build() *public.Domain +} + +type domain public.Domain + +func NewDomain(domainName string) Domain { + domainID := uuid.New() + return &domain{ + DomainId: &domainID, + AutoEnrollmentEnabled: builder_helper.GenRandPointyBool(), + Description: pointy.String(builder_helper.GenLorempIpsum()), + DomainName: domainName, + DomainType: public.RhelIdm, + Title: pointy.String(domainName), + RhelIdm: NewRhelIdmDomain(domainName).Build(), + } +} + +func (b *domain) Build() *public.Domain { + return (*public.Domain)(b) +} diff --git a/internal/test/builder/api/builder_domain_rhel_idm.go b/internal/test/builder/api/builder_domain_rhel_idm.go new file mode 100644 index 000000000..490411404 --- /dev/null +++ b/internal/test/builder/api/builder_domain_rhel_idm.go @@ -0,0 +1,59 @@ +package api + +import ( + "strings" + + "github.com/podengo-project/idmsvc-backend/internal/api/public" +) + +type RhelIdmDomain interface { + Build() *public.DomainIpa + AddRealmDomain(value string) RhelIdmDomain + AddCaCert(value public.Certificate) RhelIdmDomain +} + +type rhelIdmDomain public.DomainIpa + +func NewRhelIdmDomain(domainName string) RhelIdmDomain { + data := &rhelIdmDomain{ + RealmName: strings.ToUpper(domainName), + RealmDomains: []string{domainName}, + CaCerts: []public.Certificate{}, + Locations: []public.Location{}, + Servers: []public.DomainIpaServer{}, + AutomountLocations: &[]string{}, + } + return data +} + +func (b *rhelIdmDomain) Build() *public.DomainIpa { + return (*public.DomainIpa)(b) +} + +func (b *rhelIdmDomain) AddRealmDomain(value string) RhelIdmDomain { + b.RealmDomains = append(b.RealmDomains, value) + return b +} + +func (b *rhelIdmDomain) AddCaCert(value public.Certificate) RhelIdmDomain { + b.CaCerts = append(b.CaCerts, value) + return b +} + +func (b *rhelIdmDomain) AddLocation(value public.Location) RhelIdmDomain { + b.Locations = append(b.Locations, value) + return b +} + +func (b *rhelIdmDomain) AddServer(value public.DomainIpaServer) RhelIdmDomain { + b.Servers = append(b.Servers, value) + return b +} + +func (b *rhelIdmDomain) AutomountLocation(value string) RhelIdmDomain { + if b.AutomountLocations == nil { + panic("'AutomountLocations' is nil") + } + *b.AutomountLocations = append(*b.AutomountLocations, value) + return b +} diff --git a/internal/test/builder/api/builder_system_xrhid.go b/internal/test/builder/api/builder_system_xrhid.go new file mode 100644 index 000000000..08579fde9 --- /dev/null +++ b/internal/test/builder/api/builder_system_xrhid.go @@ -0,0 +1,91 @@ +package api + +import ( + "fmt" + "strconv" + "time" + + "github.com/google/uuid" + builder_helper "github.com/podengo-project/idmsvc-backend/internal/test/builder/helper" + "github.com/redhatinsights/platform-go-middlewares/identity" +) + +type SystemXRHID interface { + Build() identity.XRHID + WithAccountNumber(value string) SystemXRHID + WithEmployeeAccountNumber(value string) SystemXRHID + WithOrgID(value string) SystemXRHID + WithAuthType(value string) SystemXRHID + + WithCommonName(value string) SystemXRHID + WithCertType(value string) SystemXRHID +} + +type systemXRHID identity.XRHID + +// NewSystemXRHID create a new builder to get a XRHID for a user +func NewSystemXRHID() SystemXRHID { + var orgID = strconv.Itoa(int(builder_helper.GenRandNum(1, 100000))) + // See: https://github.com/coderbydesign/identity-schemas/blob/add-validator/3scale/schema.json + return &systemXRHID{ + Identity: identity.Identity{ + AccountNumber: strconv.Itoa(int(builder_helper.GenRandNum(1, 100000))), + EmployeeAccountNumber: strconv.Itoa(int(builder_helper.GenRandNum(1, 100000))), + OrgID: orgID, + Type: "System", + AuthType: authType[int(builder_helper.GenRandNum(0, 1))], + Internal: identity.Internal{ + OrgID: orgID, + AuthTime: float32(time.Now().Sub(time.Time{})), + }, + System: identity.System{ + CommonName: uuid.NewString(), + CertType: "system", + }, + }, + } +} + +func (b *systemXRHID) Build() identity.XRHID { + return identity.XRHID(*b) +} + +func (b *systemXRHID) WithOrgID(value string) SystemXRHID { + b.Identity.OrgID = value + return b +} + +func (b *systemXRHID) WithAuthType(value string) SystemXRHID { + switch value { + case authType[0]: + b.Identity.AuthType = value + return b + case authType[1]: + b.Identity.AuthType = value + return b + default: + panic(fmt.Sprintf("value='%s' is not valid", value)) + } +} + +func (b *systemXRHID) WithAccountNumber(value string) SystemXRHID { + b.Identity.AccountNumber = value + return b +} + +func (b *systemXRHID) WithEmployeeAccountNumber(value string) SystemXRHID { + b.Identity.EmployeeAccountNumber = value + return b +} + +// --- Start specific System data + +func (b *systemXRHID) WithCommonName(value string) SystemXRHID { + b.Identity.System.CommonName = value + return b +} + +func (b *systemXRHID) WithCertType(value string) SystemXRHID { + b.Identity.System.CertType = value + return b +} diff --git a/internal/test/builder/api/builder_user_xrhid.go b/internal/test/builder/api/builder_user_xrhid.go new file mode 100644 index 000000000..1314b484a --- /dev/null +++ b/internal/test/builder/api/builder_user_xrhid.go @@ -0,0 +1,124 @@ +package api + +import ( + "fmt" + "strconv" + "strings" + "time" + + builder_helper "github.com/podengo-project/idmsvc-backend/internal/test/builder/helper" + "github.com/redhatinsights/platform-go-middlewares/identity" +) + +type UserXRHID interface { + Build() identity.XRHID + WithAccountNumber(value string) UserXRHID + WithEmployeeAccountNumber(value string) UserXRHID + WithOrgID(value string) UserXRHID + WithAuthType(value string) UserXRHID + + WithUserID(value string) UserXRHID + WithUsername(value string) UserXRHID + WithEmail(value string) UserXRHID + WithActive(value bool) UserXRHID + // TODO Add more methods as they are needed +} + +type userXRHID identity.XRHID + +var authType = []string{ + "basic-auth", // User: See: https://github.com/coderbydesign/identity-schemas/blob/add-validator/3scale/schema.json#L51 + "jwt-auth", // User: See: https://github.com/coderbydesign/identity-schemas/blob/add-validator/3scale/schema.json#L51 + "cert-auth", // System: See: https://github.com/coderbydesign/identity-schemas/blob/add-validator/3scale/schema.json#L113 + "uhc-auth", // System: See: https://github.com/coderbydesign/identity-schemas/blob/add-validator/3scale/schema.json#L113 +} + +// NewUserXRHID create a new builder to get a XRHID for a user +func NewUserXRHID() UserXRHID { + orgID := strconv.Itoa(int(builder_helper.GenRandNum(1, 100000))) + firstName := builder_helper.GenRandFirstName() + lastName := builder_helper.GenRandLastName() + username := fmt.Sprintf("%s.%s", + strings.ToLower(firstName), + strings.ToLower(lastName), + ) + userID := builder_helper.GenRandUserID() + email := fmt.Sprintf("%s@acme.test", username) + + // See: https://github.com/coderbydesign/identity-schemas/blob/add-validator/3scale/schema.json + return &userXRHID{ + Identity: identity.Identity{ + AccountNumber: strconv.Itoa(int(builder_helper.GenRandNum(1, 100000))), + EmployeeAccountNumber: strconv.Itoa(int(builder_helper.GenRandNum(1, 100000))), + OrgID: orgID, + Type: "User", + AuthType: authType[int(builder_helper.GenRandNum(0, 1))], + Internal: identity.Internal{ + OrgID: orgID, + AuthTime: float32(time.Now().Sub(time.Time{})), + }, + User: identity.User{ + UserID: userID, + Username: username, + FirstName: firstName, + LastName: lastName, + Email: email, + Active: builder_helper.GenRandBool(), + }, + }, + } +} + +func (b *userXRHID) Build() identity.XRHID { + return identity.XRHID(*b) +} + +func (b *userXRHID) WithOrgID(value string) UserXRHID { + b.Identity.OrgID = value + return b +} + +func (b *userXRHID) WithAuthType(value string) UserXRHID { + switch value { + case authType[0]: + b.Identity.AuthType = value + return b + case authType[1]: + b.Identity.AuthType = value + return b + default: + panic(fmt.Sprintf("value='%s' is not valid", value)) + } +} + +func (b *userXRHID) WithAccountNumber(value string) UserXRHID { + b.Identity.AccountNumber = value + return b +} + +func (b *userXRHID) WithEmployeeAccountNumber(value string) UserXRHID { + b.Identity.EmployeeAccountNumber = value + return b +} + +// --- Start specific User data + +func (b *userXRHID) WithUserID(value string) UserXRHID { + b.Identity.User.UserID = value + return b +} + +func (b *userXRHID) WithUsername(value string) UserXRHID { + b.Identity.User.Username = value + return b +} + +func (b *userXRHID) WithEmail(value string) UserXRHID { + b.Identity.User.Email = value + return b +} + +func (b *userXRHID) WithActive(value bool) UserXRHID { + b.Identity.User.Active = value + return b +} diff --git a/internal/test/builder/doc.go b/internal/test/builder/doc.go new file mode 100644 index 000000000..d712ff5dd --- /dev/null +++ b/internal/test/builder/doc.go @@ -0,0 +1,86 @@ +// Package builder to help on building data models. +// +// This package implement several builders to reduce boileplate +// when we are coding tests for the service, which include any +// kind of tests. +// +// TL;DR +// +// Builder pattern is used. +// +// Long history, an initial builder pattern with a wrong approach +// was considered initially, where for each field of the wrapped +// struct to build, it was added a flag field to don't generate +// information for that fields; it was discarded because it was +// adding too much boilerplate to implement new builders. +// +// After was considered a different implementation by using the +// golang options patter, which was generating some random data +// for the wrapped builder struct, and overriding the information +// by calling the slice of options funtions provided. +// This was discarded because still the golang options pattern +// was adding too much boilerplate to add new builders; additionally +// it was evoking name conflicts with the options for other new +// strutcs, so that was makin a bit difficult to get good semantic +// as the builder functionality is increased for all the different +// structs. +// +// Finally it was get back to the Build pattern, but removing the +// flags for each provided field which reduce the boilerplate; with +// this approach we create a random initial object, override the +// fields to customize. It is easy to compose complex builder by +// using composition, and the semantic allow to use the builder +// in a very readable way, letting the code to speak by itself. +// +// ```golang +// +// type MyType interface { +// Build() model.MyType +// WithFieldName1(value int64) MyType +// } +// +// type myType struct { +// model.MyType +// } +// +// func NewMyType() MyType { +// return &model.MyType { +// FieldName1: GenRandNum(0, 10000), +// } +// } +// +// func (b *myType) Buid() MyTpye { +// return b.MyType +// } +// +// func (b *myType) WithFieldName1(value int64) MyType { +// b.MyType.FiledName1 = value +// return b +// } +// +// ``` +// +// The below would be the minimal; we can see that generate some +// random data, and we override the fields as we call method +// members of the builder, finally we call Build() method to get +// the generated data. Now the above example would be used as the +// below. +// +// ```golang +// +// func TestMyTypeCase1(t *testify.Testing) { +// o := builder.NewMyType().WithFieldName1(45) +// } +// +// ``` +// +// Into the above leverage example we see how the semantic +// referencing the package and using the specific factory +// make the code speak by itself. +// +// TODO The current approach match a repetitive pattern for every +// builder that maybe a tool could implement to generate the code +// given the gorm database model; but this would be for the future. +// The tool indicated would allow to generate the builder boilerplate +// and keep it on sync with the model changes. +package builder diff --git a/internal/test/builder/helper/helper.go b/internal/test/builder/helper/helper.go new file mode 100644 index 000000000..a653b964c --- /dev/null +++ b/internal/test/builder/helper/helper.go @@ -0,0 +1,173 @@ +package helper + +import ( + "crypto/rand" + "fmt" + "math/big" + "strings" + "time" + + "github.com/google/uuid" + "github.com/openlyinc/pointy" +) + +var loremIpsumContent string = `Lorem ipsum dolor sit amet consectetur adipiscing elit libero, eget egestas porta aptent lacus ridiculus conubia dignissim sociis, quisque habitasse urna euismod porttitor ac ultrices. Sodales sollicitudin congue dui nec vel libero metus potenti facilisi, blandit pulvinar ultricies orci lectus urna et mollis, integer a mauris ad semper pellentesque sociosqu velit. Tempus proin mauris placerat volutpat dictum semper aenean leo viverra tempor, eget velit potenti praesent vel nisl feugiat purus inceptos mollis molestie, class nascetur iaculis blandit lectus risus magnis suspendisse cras. Felis pellentesque metus elementum etiam arcu turpis eleifend nisi nullam enim, semper nostra cursus tristique ultrices conubia nascetur neque mi, habitant morbi orci commodo duis est maecenas vulputate mattis. + +Sagittis gravida fringilla magnis eget rhoncus integer at posuere, cum ullamcorper imperdiet maecenas nam scelerisque ad, penatibus viverra blandit porta felis platea commodo. Dictum mus malesuada ultrices tellus luctus orci erat id, proin convallis volutpat nascetur egestas etiam mauris tempus, semper facilisis magnis himenaeos dictumst vehicula cras. Mauris ut arcu parturient curae duis nunc congue phasellus lobortis rutrum pellentesque, mollis dictum porttitor nec ornare commodo praesent tincidunt viverra. + +Tempor montes urna fusce imperdiet sapien tortor suscipit vitae tempus habitasse eu, elementum maecenas dignissim posuere vestibulum aliquet primis ac mi mattis semper potenti, varius conubia molestie faucibus ullamcorper non justo proin dictum dis. Suspendisse laoreet ut massa velit risus vitae condimentum eleifend quisque nisl, parturient natoque cum magna neque habitasse integer luctus senectus ornare nunc, aptent congue malesuada conubia ligula hac suscipit varius phasellus. Integer egestas vivamus tortor commodo fames magna litora, id magnis fringilla curae arcu morbi, torquent lacus mi mus platea neque.` + +// GenRandNum generate a random bumber >= min and <= max interval. +// min is the lower boundary interval including the value. +// max is the higher boundary interval including the value. +func GenRandNum(min, max int64) int64 { + // See: https://stackoverflow.com/questions/26153441/generate-crypto-random-integer-beetwen-min-max-values + + // calculate the max we will be using + bg := big.NewInt(max - min) + + // get big.Int between 0 and bg + // in this case 0 to 20 + n, err := rand.Int(rand.Reader, bg) + if err != nil { + panic(err) + } + + // add n to min to support the passed in range + return n.Int64() + min +} + +// GenPastNearTime generate a past timestamp not further than delta. +// delta is the duration that set the threshold for the time. +// Return a time.Time for the expected interval. +func GenPastNearTime(delta time.Duration) time.Time { + var value int64 = GenRandNum(0, int64(delta)) + return time.Now().Add(time.Duration(value) * -1) +} + +// GenBetweenTime generate a timestamp between the given parameters +// as earlier as 'begin' and before 'end'. +// delta is the duration that set the threshold for the time. +// Return a time.Time for the expected interval. +func GenBetweenTime(begin time.Time, end time.Time) time.Time { + if begin.After(end) || begin == end { + return time.Time{} + } + return begin.Add(time.Duration(GenRandNum(0, end.Unix()-begin.Unix()))) +} + +// GenRandomString generate a random string from the letters set +// with length n. +// letters the set of letters to use. +// n the length of the string. +// Return a random string. +func GenRandomString(letters []rune, n int) string { + s := make([]rune, n) + for i := range s { + s[i] = letters[GenRandNum(0, int64(len(letters)-1))] + } + return string(s) +} + +func GenRandomDomainLabel() string { + // See: https://datatracker.ietf.org/doc/html/rfc1123 + // + // The DNS defines domain name syntax very generally -- a + // string of labels each containing up to 63 8-bit octets, + // separated by dots, and with a maximum total of 255 + // octets. Particular applications of the DNS are + // permitted to further constrain the syntax of the domain + // names they use, although the DNS deployment has led to + // some applications allowing more general names. In + // particular, Section 2.1 of this document liberalizes + // slightly the syntax of a legal Internet host name that + // was defined in RFC-952 [DNS:4]. + // + // FIXME Review the letters slice + letters := []rune("abcdefghijklmnopqrstuvwxyz0123456789-") + return GenRandomString(letters, int(GenRandNum(1, 63))) +} + +// GenRandDomainName generate a random domain name for testing. +// level is >= 2 and <= 4. +// Return a domain name +func GenRandDomainName(level int) string { + if level < 2 || level > 4 { + panic(fmt.Errorf("'level' must be in [2..4]")) + } + labels := make([]string, level) + for i := 0; i < level-1; i++ { + label := GenRandomDomainLabel() + labels[i] = label + } + labels[level-1] = "test" + return strings.Join(labels, ".") +} + +func GenRandomFQDNWithDomain(domain string) string { + return strings.Join([]string{GenRandomDomainLabel(), domain}, ".") +} + +func GenRandomFQDN() string { + return GenRandDomainName(3) +} + +func GenRandBool() bool { + if GenRandNum(0, 1) == 1 { + return true + } + return false +} + +func GenRandUserID() string { + return uuid.NewString() +} + +func GenRandUsername() string { + return strings.Join([]string{ + strings.ToLower(GenRandFirstName()), + strings.ToLower(GenRandLastName()), + }, ".") +} + +func GenRandFirstName() string { + // TODO Enhance with some predefined set + set := []string{ + "John", "Alice", "Jane", "Tom", "Peter", + "Sara", "Bob", "George", "Rebeca", + "Susan", "Joseph", "Penelope", + } + return set[GenRandNum(0, int64(len(set)-1))] +} + +func GenRandLastName() string { + // TODO Enhance with some predefined set + set := []string{ + "Sharon", "Blair", "Bianca", "Burnett", + "Braun", "Jefferon", "Olson", "Richardson", + "Bolton", "Davenport", "Ellison", + } + return set[GenRandNum(0, int64(len(set)-1))] +} + +func GenRandEmail() string { + set := []string{ + "acme.com", "speaker.com", "network.com", + "radio.com", "tv.com", + } + return fmt.Sprintf("%s@%s", + GenRandUsername(), + set[GenRandNum(0, int64(len(set)-1))], + ) +} + +func GenRandPointyBool() *bool { + if GenRandBool() { + return pointy.Bool(GenRandBool()) + } + return nil +} + +func GenLorempIpsum() string { + return loremIpsumContent +} diff --git a/internal/test/builder/model/builder_domain.go b/internal/test/builder/model/builder_domain.go new file mode 100644 index 000000000..9342f5d34 --- /dev/null +++ b/internal/test/builder/model/builder_domain.go @@ -0,0 +1,129 @@ +package model + +import ( + "strconv" + + "github.com/google/uuid" + "github.com/openlyinc/pointy" + "github.com/podengo-project/idmsvc-backend/internal/domain/model" + builder_helper "github.com/podengo-project/idmsvc-backend/internal/test/builder/helper" + "gorm.io/gorm" +) + +const ( + // MinOrgID the minimal org id for random values + minOrgID = 1 + // MaxOrgID the maximum org id for random values + maxOrgID = 999999 +) + +// Domain is a builder to fill model.Domain structures +// with random data to make life easier during the tests. +type Domain interface { + Build() *model.Domain + WithModel(value gorm.Model) Domain + WithOrgID(value string) Domain + WithDomainUUID(value uuid.UUID) Domain + WithTitle(value *string) Domain + WithDescription(value *string) Domain + WithAutoEnrollmentEnabled(value *bool) Domain + WithIpaDomain(value *model.Ipa) Domain +} + +// domain is the specific builder implementation +type domain struct { + model.Domain +} + +// NewDomain create a new builder for mode.Domain data. +// Return the Domain builder interface. +func NewDomain(gormModel gorm.Model) Domain { + var autoEnrollmentEnabled *bool + var title *string + var description *string + letters := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + switch builder_helper.GenRandNum(1, 3) % 3 { + case 1: + autoEnrollmentEnabled = pointy.Bool(false) + case 2: + autoEnrollmentEnabled = pointy.Bool(true) + case 3: + autoEnrollmentEnabled = nil + default: + panic("something went wrong for random AutoEnrollmentEnabled") + } + + switch builder_helper.GenRandNum(1, 2) % 2 { + case 1: + title = pointy.String(builder_helper.GenRandomString(letters, int(builder_helper.GenRandNum(5, 20)))) + case 2: + title = nil + default: + panic("something went wrong for random title") + } + + switch builder_helper.GenRandNum(1, 2) % 2 { + case 1: + description = pointy.String(builder_helper.GenRandomString(letters, int(builder_helper.GenRandNum(10, 300)))) + case 2: + description = nil + default: + panic("something went wrong for random title") + } + + return &domain{ + Domain: model.Domain{ + Model: gormModel, + OrgId: strconv.Itoa(int(builder_helper.GenRandNum(minOrgID, maxOrgID))), + DomainUuid: uuid.New(), + DomainName: pointy.String(builder_helper.GenRandDomainName(2)), + AutoEnrollmentEnabled: autoEnrollmentEnabled, + Title: title, + Description: description, + Type: pointy.Uint(model.DomainTypeIpa), + IpaDomain: NewIpaDomain().Build(), + }, + } +} + +// Build generate the mode.Domain instance based into +// the current inputs and random data to fill the gaps. +// Return the generated model.Domain instance. +func (b *domain) Build() *model.Domain { + return &b.Domain +} + +func (b *domain) WithModel(value gorm.Model) Domain { + b.Model = value + return b +} + +func (b *domain) WithOrgID(value string) Domain { + b.OrgId = value + return b +} + +func (b *domain) WithAutoEnrollmentEnabled(value *bool) Domain { + b.AutoEnrollmentEnabled = value + return b +} + +func (b *domain) WithDomainUUID(value uuid.UUID) Domain { + b.DomainUuid = value + return b +} + +func (b *domain) WithTitle(value *string) Domain { + b.Title = value + return b +} + +func (b *domain) WithDescription(value *string) Domain { + b.Description = value + return b +} + +func (b *domain) WithIpaDomain(value *model.Ipa) Domain { + b.IpaDomain = value + return b +} diff --git a/internal/test/builder/model/builder_ipa.go b/internal/test/builder/model/builder_ipa.go new file mode 100644 index 000000000..7ef6ca533 --- /dev/null +++ b/internal/test/builder/model/builder_ipa.go @@ -0,0 +1,82 @@ +package model + +import ( + "strings" + + "github.com/lib/pq" + "github.com/openlyinc/pointy" + "github.com/podengo-project/idmsvc-backend/internal/domain/model" + builder_helper "github.com/podengo-project/idmsvc-backend/internal/test/builder/helper" + "gorm.io/gorm" +) + +type IpaDomain interface { + Build() *model.Ipa + WithModel(value gorm.Model) IpaDomain + WithCaCerts(value []model.IpaCert) IpaDomain + WithServers(value []model.IpaServer) IpaDomain + WithLocations(value []model.IpaLocation) IpaDomain + WithRealmName(value *string) IpaDomain + WithRealmDomains(value pq.StringArray) IpaDomain +} + +type ipaDomain struct { + IpaDomain *model.Ipa +} + +func NewIpaDomain() IpaDomain { + var ( + certs []model.IpaCert + locations []model.IpaLocation + servers []model.IpaServer + realmName *string + realmDomains pq.StringArray = pq.StringArray{} + ) + realmName = pointy.String(strings.ToUpper(builder_helper.GenRandDomainName(2))) + if realmName != nil { + realmDomains = pq.StringArray{*realmName} + } + return &ipaDomain{ + IpaDomain: &model.Ipa{ + Model: NewModel().Build(), + CaCerts: certs, + Servers: servers, + Locations: locations, + RealmName: realmName, + RealmDomains: realmDomains, + }, + } +} + +func (b *ipaDomain) Build() *model.Ipa { + return b.IpaDomain +} + +func (b *ipaDomain) WithModel(value gorm.Model) IpaDomain { + b.IpaDomain.Model = value + return b +} +func (b *ipaDomain) WithCaCerts(value []model.IpaCert) IpaDomain { + b.IpaDomain.CaCerts = value + return b +} + +func (b *ipaDomain) WithServers(value []model.IpaServer) IpaDomain { + b.IpaDomain.Servers = value + return b +} + +func (b *ipaDomain) WithLocations(value []model.IpaLocation) IpaDomain { + b.IpaDomain.Locations = value + return b +} + +func (b *ipaDomain) WithRealmName(value *string) IpaDomain { + b.IpaDomain.RealmName = value + return b +} + +func (b *ipaDomain) WithRealmDomains(value pq.StringArray) IpaDomain { + b.IpaDomain.RealmDomains = value + return b +} diff --git a/internal/test/builder/model/builder_ipa_server.go b/internal/test/builder/model/builder_ipa_server.go new file mode 100644 index 000000000..2b4d0d5e7 --- /dev/null +++ b/internal/test/builder/model/builder_ipa_server.go @@ -0,0 +1,103 @@ +package model + +import ( + "github.com/google/uuid" + "github.com/openlyinc/pointy" + "github.com/podengo-project/idmsvc-backend/internal/domain/model" + builder_helper "github.com/podengo-project/idmsvc-backend/internal/test/builder/helper" + "gorm.io/gorm" +) + +type IpaServer interface { + Build() model.IpaServer + WithIpaID(value uint) IpaServer + WithFQDN(value string) IpaServer + WithRHSMID(value *string) IpaServer + WithLocation(value *string) IpaServer + WithCaServer(value bool) IpaServer + WithHCCEnrollmentServer(value bool) IpaServer + WithHCCUpdateServer(value bool) IpaServer + WithPKInitServer(value bool) IpaServer + // IpaID uint + // FQDN string + // RHSMId *string `gorm:"unique;column:rhsm_id"` + // Location *string + // CaServer bool + // HCCEnrollmentServer bool + // HCCUpdateServer bool + // PKInitServer bool +} + +type ipaServer struct { + IpaServer model.IpaServer +} + +func NewIpaServer(gormModel gorm.Model) IpaServer { + var ( + rhsmID *string + location *string + ) + if builder_helper.GenRandBool() { + rhsmID = pointy.String(uuid.NewString()) + } + if builder_helper.GenRandBool() { + location = pointy.String(builder_helper.GenRandomDomainLabel()) + } + return &ipaServer{ + IpaServer: model.IpaServer{ + Model: gormModel, + IpaID: 0, + FQDN: builder_helper.GenRandomFQDN(), + RHSMId: rhsmID, + Location: location, + CaServer: builder_helper.GenRandBool(), + HCCEnrollmentServer: builder_helper.GenRandBool(), + HCCUpdateServer: builder_helper.GenRandBool(), + PKInitServer: builder_helper.GenRandBool(), + }, + } +} + +func (b *ipaServer) Build() model.IpaServer { + return b.IpaServer +} + +func (b *ipaServer) WithIpaID(value uint) IpaServer { + b.IpaServer.IpaID = value + return b +} + +func (b *ipaServer) WithFQDN(value string) IpaServer { + b.IpaServer.FQDN = value + return b +} + +func (b *ipaServer) WithRHSMID(value *string) IpaServer { + b.IpaServer.RHSMId = value + return b +} + +func (b *ipaServer) WithLocation(value *string) IpaServer { + b.IpaServer.Location = value + return b +} + +func (b *ipaServer) WithCaServer(value bool) IpaServer { + b.IpaServer.CaServer = value + return b +} + +func (b *ipaServer) WithHCCEnrollmentServer(value bool) IpaServer { + b.IpaServer.HCCEnrollmentServer = value + return b +} + +func (b *ipaServer) WithHCCUpdateServer(value bool) IpaServer { + b.IpaServer.HCCUpdateServer = value + return b +} + +func (b *ipaServer) WithPKInitServer(value bool) IpaServer { + b.IpaServer.PKInitServer = value + return b +} diff --git a/internal/test/builder/model/builder_model.go b/internal/test/builder/model/builder_model.go new file mode 100644 index 000000000..48bec2c3a --- /dev/null +++ b/internal/test/builder/model/builder_model.go @@ -0,0 +1,66 @@ +package model + +// FIXME Change the option pattern by a Builder pattern +// this is, instead of using 'With...' that could +// evoke name colisions, add the custom methods that +// override the values to the ModelBuilder + +import ( + "time" + + builder_helper "github.com/podengo-project/idmsvc-backend/internal/test/builder/helper" + "gorm.io/gorm" +) + +// GormModel is the builder for the +type GormModel interface { + Build() gorm.Model + WithID(value uint) GormModel + WithCreatedAt(value time.Time) GormModel + WithUpdatedAt(value time.Time) GormModel + WithDeletedAt(value gorm.DeletedAt) GormModel +} + +// gormModel is the specific builder implementation +type gormModel struct { + gorm.Model +} + +// NewModel generate a gorm.Model with random information +// overrided by the customized options. +func NewModel() GormModel { + var genCreatedAt = builder_helper.GenPastNearTime(time.Hour * 24 * 10) + var genUpdatedAt = builder_helper.GenBetweenTime(genCreatedAt, time.Now()) + + return &gormModel{ + Model: gorm.Model{ + ID: uint(builder_helper.GenRandNum(0, 2^63)), + CreatedAt: genCreatedAt, + UpdatedAt: genUpdatedAt, + }, + } +} + +func (b *gormModel) Build() gorm.Model { + return b.Model +} + +func (b *gormModel) WithID(value uint) GormModel { + b.Model.ID = value + return b +} + +func (b *gormModel) WithCreatedAt(value time.Time) GormModel { + b.CreatedAt = value + return b +} + +func (b *gormModel) WithUpdatedAt(value time.Time) GormModel { + b.Model.UpdatedAt = value + return b +} + +func (b *gormModel) WithDeletedAt(value gorm.DeletedAt) GormModel { + b.DeletedAt = value + return b +} diff --git a/internal/test/smoke/doc.go b/internal/test/smoke/doc.go new file mode 100644 index 000000000..08f163f19 --- /dev/null +++ b/internal/test/smoke/doc.go @@ -0,0 +1,11 @@ +// Package smoke contains all the smoke test for the service +// +// By smoke test we refers to application requests that get a success +// response for a normal execution of the service, so we check the thinks +// work as expected in normal situations. +// +// If you are thinking a failure and error situations to be covered +// then you will be looking at `internal/test/integration` package +// where you will define the remaining tests to evoque error situations +// and validate they are correctly covered and managed by the application. +package smoke diff --git a/internal/test/smoke/register_test.go b/internal/test/smoke/register_test.go new file mode 100644 index 000000000..f18077a7e --- /dev/null +++ b/internal/test/smoke/register_test.go @@ -0,0 +1,176 @@ +package smoke + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "github.com/podengo-project/idmsvc-backend/internal/api/header" + "github.com/podengo-project/idmsvc-backend/internal/api/public" + builder_api "github.com/podengo-project/idmsvc-backend/internal/test/builder/api" + builder_helper "github.com/podengo-project/idmsvc-backend/internal/test/builder/helper" + "github.com/stretchr/testify/require" +) + +// SuiteTokenCreate is the suite token for smoke tests at /api/idmsvc/v1/domains/token +type SuiteRegisterDomain struct { + SuiteBase + token *public.DomainRegTokenResponse +} + +// BodyFuncDomainResponse is the function that wrap +type BodyFuncDomainResponse func(t *testing.T, body *public.Domain) error + +// WrapBodyFuncDomainResponse allow to implement custom body expectations for the specific type of the response. +// expected is the specific BodyFuncDomain for Domain type +// Returns a BodyFunc that wrap the generic expectation function. +func WrapBodyFuncDomainResponse(expected BodyFuncDomainResponse) BodyFunc { + if expected == nil { + return func(t *testing.T, body []byte) bool { + return len(body) == 0 + } + } + return func(t *testing.T, body []byte) bool { + // Unserialize the response to the expected type + var data public.Domain + if err := json.Unmarshal(body, &data); err != nil { + require.Fail(t, fmt.Sprintf("Error unmarshalling body:\n"+ + "error: %q", + err.Error(), + )) + return false + } + + // Run body expectetion on the unserialized data + if err := expected(t, &data); err != nil { + require.Fail(t, fmt.Sprintf("Error in body response:\n"+ + "error: %q", + err.Error(), + )) + return false + } + + return true + } +} + +// CreateToken is a helper function to request a token to the API for registration +// for a rhel-idm domain using the OrgID assigned to the unit test. +// Return the token response or error. +func (s *SuiteRegisterDomain) CreateToken() (*public.DomainRegTokenResponse, error) { + var headers http.Header = http.Header{} + var resp *http.Response + var err error + + url := DefaultBaseURL + "/domains/token" + + headers.Add("X-Rh-Insights-Request-Id", "get_token") + headers.Add("X-Rh-Identity", header.EncodeXRHID(&s.UserXRHID)) + if resp, err = s.DoRequest( + http.MethodPost, + url, + headers, + &public.DomainRegTokenRequest{ + DomainType: "rhel-idm", + }, + ); err != nil { + return nil, fmt.Errorf("failure when POST %s: %w", url, err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failure when POST %s: expected '%d' but got '%d'", url, http.StatusOK, resp.StatusCode) + } + var data []byte + data, err = io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failure when reading body for POST %s because an empty response", url) + } + var token *public.DomainRegTokenResponse = &public.DomainRegTokenResponse{} + err = json.Unmarshal(data, token) + if err != nil { + return nil, fmt.Errorf("failure when unmarshalling the information for POST %s", url) + } + return token, nil +} + +func (s *SuiteRegisterDomain) SetupTest() { + var err error + + s.SuiteBase.SetupTest() + + // Get a token for the registration + if s.token, err = s.CreateToken(); err != nil { + s.FailNow("Error creating a token for registering a rhel-idm domain", "%s", err.Error()) + } +} + +func (s *SuiteRegisterDomain) TearDownTest() { + s.SuiteBase.TearDownTest() +} + +// Specific expectation method that fit BodyFuncTokenResponse +func (s *SuiteRegisterDomain) bodyExpectationTestToken(t *testing.T, body *public.DomainRegTokenResponse) error { + if body.DomainToken == "" { + return fmt.Errorf("'domain_token' is empty") + } + + if body.DomainType != "rhel-idm" { + return fmt.Errorf("'domain_type' is not rhel-idm") + } + + if body.DomainId == (uuid.UUID{}) { + return fmt.Errorf("'domain_id' is empty") + } + + if body.Expiration <= int(time.Now().Unix()) { + return fmt.Errorf("'expiration' is in the past") + } + + return nil +} + +func (s *SuiteRegisterDomain) TestRegisterDomain() { + xrhidEncoded := header.EncodeXRHID(&s.SystemXRHID) + url := DefaultBaseURL + "/domains" + domainName := builder_helper.GenRandDomainName(2) + + // Prepare the tests + testCases := []TestCase{ + { + Name: "TestRegisterDomain rhel-idm", + Given: TestCaseGiven{ + Method: http.MethodPost, + URL: url, + Header: http.Header{ + "X-Rh-Insights-Request-Id": {"test_token"}, + "X-Rh-Identity": {xrhidEncoded}, + "X-Rh-Idm-Registration-Token": {s.token.DomainToken}, + "X-Rh-Idm-Version": {header.EncodeXRHIDMVersion(header.NewXRHIDMVersion("v1.0.0", "4.19.0", "redhat-9.3", "9.3"))}, + }, + Body: builder_api.NewDomain(domainName).Build(), + }, + Expected: TestCaseExpect{ + // FIXME It must be http.StatusCreated + StatusCode: http.StatusOK, + Header: http.Header{ + // FIXME Avoid hardcoded header key + "X-Rh-Insights-Request-Id": {"test_token"}, + "X-Rh-Identity": nil, + // TODO Check format for X-Rh-Idm-Version + }, + BodyFunc: WrapBodyFuncDomainResponse(func(t *testing.T, body *public.Domain) error { + // TODO Add the checks here + return nil + }), + }, + }, + } + + // Execute the test cases + s.RunTestCases(testCases) + + // s.RunTestCase(&testCases[0]) +} diff --git a/internal/test/smoke/suite_test.go b/internal/test/smoke/suite_test.go new file mode 100644 index 000000000..47d0466ab --- /dev/null +++ b/internal/test/smoke/suite_test.go @@ -0,0 +1,278 @@ +package smoke + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/podengo-project/idmsvc-backend/internal/config" + "github.com/podengo-project/idmsvc-backend/internal/infrastructure/datastore" + "github.com/podengo-project/idmsvc-backend/internal/infrastructure/logger" + "github.com/podengo-project/idmsvc-backend/internal/infrastructure/service" + builder_api "github.com/podengo-project/idmsvc-backend/internal/test/builder/api" + builder_helper "github.com/podengo-project/idmsvc-backend/internal/test/builder/helper" + "github.com/podengo-project/idmsvc-backend/internal/usecase/client" + "github.com/redhatinsights/platform-go-middlewares/identity" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "gorm.io/gorm" + + service_impl "github.com/podengo-project/idmsvc-backend/internal/infrastructure/service/impl" +) + +// SuiteBase represents the base Suite to be used for smoke tests, this +// start the services before run the smoke tests. +type SuiteBase struct { + suite.Suite + cfg *config.Config + OrgID string + UserXRHID identity.XRHID + SystemXRHID identity.XRHID + + cancel context.CancelFunc + svc service.ApplicationService + wg *sync.WaitGroup + db *gorm.DB +} + +func (s *SuiteBase) SetupTest() { + fmt.Println("internal.test.smoke.SetupTest") + s.cfg = config.Get() + s.wg = &sync.WaitGroup{} + logger.InitLogger(s.cfg) + s.db = datastore.NewDB(s.cfg) + + ctx, cancel := StartSignalHandler(context.Background()) + s.cancel = cancel + inventory := client.NewHostInventory(s.cfg) + s.svc = service_impl.NewApplication(ctx, s.wg, s.cfg, s.db, inventory) + go func() { + if e := s.svc.Start(); e != nil { + panic(e) + } + }() + s.OrgID = strconv.Itoa(int(builder_helper.GenRandNum(1, 99999999))) + s.UserXRHID = builder_api.NewUserXRHID().WithOrgID(s.OrgID).WithActive(true).Build() + s.SystemXRHID = builder_api.NewSystemXRHID().WithOrgID(s.OrgID).Build() + s.WaitReady() +} + +func (s *SuiteBase) TearDownTest() { + fmt.Println("internal.test.smoke.TearDownTest") + TearDownSignalHandler() + defer datastore.Close(s.db) + defer s.cancel() + s.svc.Stop() + s.wg.Wait() +} + +func (s *SuiteBase) WaitReady() { + header := http.Header{} + for { + resp, err := s.DoRequest(http.MethodGet, "http://localhost:8000/private/readyz", header, nil) + if err == nil && resp.StatusCode == http.StatusOK { + return + } + time.Sleep(100 * time.Millisecond) + } +} + +// RunTestCase run test for one specific testcase +func (s *SuiteBase) RunTestCase(testCase *TestCase) { + t := s.T() + + // GIVEN + var ( + body []byte + resp *http.Response + err error + ) + + // WHEN + resp, err = s.DoRequest(testCase.Given.Method, testCase.Given.URL, testCase.Given.Header, testCase.Given.Body) + + // THEN + + // Check no error + require.NoError(t, err) + if resp != nil { + body, err = io.ReadAll(resp.Body) + require.NoError(t, err) + } + + // Check response status code + require.Equal(t, testCase.Expected.StatusCode, resp.StatusCode) + + // Check response headers + t.Log("Checking response headers") + for key := range testCase.Expected.Header { + expectedValue := fmt.Sprintf("%s: %s", key, testCase.Expected.Header.Get(key)) + currentValue := fmt.Sprintf("%s: %s", key, resp.Header.Get(key)) + assert.Equal(t, expectedValue, currentValue) + } + + // Check response body + bodyCount := 0 + if testCase.Expected.Body != nil { + bodyCount++ + } + if testCase.Expected.BodyFunc != nil { + bodyCount++ + } + if testCase.Expected.BodyBytes != nil { + bodyCount++ + } + if bodyCount > 1 { + t.Errorf("Body, BodyFunc and BodyBytes are exclusive between them.") + } + if bodyCount == 0 && len(body) == 0 { + return + } + if testCase.Expected.Body != nil { + assert.Equal(t, testCase.Expected.Body, body) + } + if testCase.Expected.BodyFunc != nil { + assert.True(t, testCase.Expected.BodyFunc(t, body)) + } + if testCase.Expected.BodyBytes != nil { + assert.Equal(t, testCase.Expected.BodyBytes, body) + } +} + +// RunTestCases run a slice of test cases. +func (s *SuiteBase) RunTestCases(testCases []TestCase) { + t := s.T() + for i := range testCases { + t.Log(testCases[i].Name) + s.RunTestCase(&testCases[i]) + } +} + +const DefaultBaseURL = "http://localhost:8000/api/idmsvc/v1" + +type BodyFunc func(t *testing.T, body []byte) bool + +// TestCaseGiven represents the requirements for the smoke test to implement. +type TestCaseGiven struct { + // Method represents the http method for the request. + Method string + // URL represents the url for the route to test. + URL string + // Header represents the set of header of the request. + Header http.Header + // Body represents a golang type to be marshalled before send the request; + // this field exclude the BodyBytes field. + Body any + // BodyBytes represents a specific buffer for the request body; this + // field exlude the Body field. This works for bad formed json documents, + // and other scenarios where Body does not fit. + BodyBytes []byte +} + +// TestCaseExpect represents the expected response for a smoke test. +type TestCaseExpect struct { + // StatusCode represents the http status code expected. + StatusCode int + // Header represents the expected http response headers. + Header http.Header + // Body represent an API type struct that after marshall should match the + // returned response; this could be a situation, because the order of the + // properties could not match. It is useful only when the property order + // is deterministic, else use BodyFunc. + Body any + // BodyBytes represent a specific bytes returned on the expectations. + BodyBytes []byte + // BodyFunc represent a custom function that will return nil or error + // to check some specifc body unserialized. This option exclude Body and + // BodyBytes and is useful when we want expectations based on a + // valid json document, but it is not a perfect fit of the Body. + BodyFunc BodyFunc +} + +// TestCase represents a test case for the smoke test +type TestCase struct { + // Name represents a string to be printed out which will be displayed + // in case of a failure happens. + Name string + // Given represents the given specification for the test case. + Given TestCaseGiven + // Expected represents the expected result for the operations. + Expected TestCaseExpect +} + +// StartSignalHandler set up the signal handler. +// See: https://pkg.go.dev/os/signal +func StartSignalHandler(c context.Context) (context.Context, context.CancelFunc) { + if c == nil { + c = context.Background() + } + ctx, cancel := context.WithCancel(c) + go func() { + exit := make(chan os.Signal, 1) + signal.Notify(exit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + <-exit + cancel() + }() + return ctx, cancel +} + +func TearDownSignalHandler() { + signal.Reset(syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) +} + +func (s *SuiteBase) DoRequest(method string, url string, header http.Header, body any) (*http.Response, error) { + var reader io.Reader = nil + client := &http.Client{} + + if body != nil { + _body, err := json.Marshal(body) + if err != nil { + return nil, err + } + if len(_body) > 0 { + reader = bytes.NewReader(_body) + } + } else { + reader = bytes.NewBufferString("") + } + + req, err := http.NewRequest(method, url, reader) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + for key, value := range header { + req.Header.Set(key, strings.Join(value, "; ")) + } + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + return resp, nil +} + +func TestSuite(t *testing.T) { + // TODO Add here your test suites + suite.Run(t, new(SuiteTokenCreate)) + suite.Run(t, new(SuiteRegisterDomain)) + // suite.Run(t, new(SuiteDomainUpdateUser)) + // suite.Run(t, new(SuiteDomainUpdateAgent)) + // suite.Run(t, new(SuiteDomainRead)) + // suite.Run(t, new(SuiteDomainList)) + // suite.Run(t, new(SuiteDomainDelete)) +} diff --git a/internal/test/smoke/token_test.go b/internal/test/smoke/token_test.go new file mode 100644 index 000000000..8692be3c4 --- /dev/null +++ b/internal/test/smoke/token_test.go @@ -0,0 +1,115 @@ +package smoke + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "github.com/podengo-project/idmsvc-backend/internal/api/header" + "github.com/podengo-project/idmsvc-backend/internal/api/public" + "github.com/stretchr/testify/require" +) + +// SuiteTokenCreate is the suite token for smoke tests at /api/idmsvc/v1/domains/token +type SuiteTokenCreate struct { + SuiteBase +} + +// BodyFuncTokenResponse is the function that wrap +type BodyFuncTokenResponse func(t *testing.T, expect *public.DomainRegTokenResponse) error + +// WrapBodyFuncTokenResponse allow to implement custom body expectations for the specific type of the response. +// expected is the specific BodyFuncTokenResponse for DomainRegTokenResponse type +// Returns a BodyFunc that wrap the generic expectation function. +func WrapBodyFuncTokenResponse(expected BodyFuncTokenResponse) BodyFunc { + // To allow a generic interface for any body response type + // I have to use `body []byte`; I cannot use `any` because + // the response type is particular for the endpoint. + // That means the input to the function is not in a golang + // structure; to let the tests to be defined with less boilerplate, + // every response type would implement a wrapper function like + // this, which unmarshall the bytes, and call to the more specific + // custom body function. + if expected == nil { + return func(t *testing.T, body []byte) bool { + return len(body) == 0 + } + } + return func(t *testing.T, body []byte) bool { + // Unserialize the response to the expected type + var data public.DomainRegTokenResponse + if err := json.Unmarshal(body, &data); err != nil { + require.Fail(t, fmt.Sprintf("Error unmarshalling body:\n"+ + "error: %q", + err.Error(), + )) + return false + } + // Run body expectetion on the unserialized data + if err := expected(t, &data); err != nil { + require.Fail(t, fmt.Sprintf("Error in body response:\n"+ + "error: %q", + err.Error(), + )) + return false + } + return true + } +} + +// Specific expectation method that fit BodyFuncTokenResponse +func (s *SuiteTokenCreate) bodyExpectationTestToken(t *testing.T, body *public.DomainRegTokenResponse) error { + if body.DomainToken == "" { + return fmt.Errorf("'domain_token' is empty") + } + + if body.DomainType != "rhel-idm" { + return fmt.Errorf("'domain_type' is not rhel-idm") + } + + if body.DomainId == (uuid.UUID{}) { + return fmt.Errorf("'domain_id' is empty") + } + + if body.Expiration <= int(time.Now().Unix()) { + return fmt.Errorf("'expiration' is in the past") + } + + return nil +} + +func (s *SuiteTokenCreate) TestToken() { + xrhidEncoded := header.EncodeXRHID(&s.UserXRHID) + + // Prepare the tests + testCases := []TestCase{ + { + Name: "TestToken", + Given: TestCaseGiven{ + Method: http.MethodPost, + URL: DefaultBaseURL + "/domains/token", + Header: http.Header{ + "X-Rh-Insights-Request-Id": {"test_token"}, + "X-Rh-Identity": {xrhidEncoded}, + }, + Body: public.DomainRegTokenRequest{ + DomainType: "rhel-idm", + }, + }, + Expected: TestCaseExpect{ + StatusCode: http.StatusOK, + Header: http.Header{ + "X-Rh-Insights-Request-Id": {"test_token"}, + "X-Rh-Identity": nil, + }, + BodyFunc: WrapBodyFuncTokenResponse(s.bodyExpectationTestToken), + }, + }, + } + + // Execute the test cases + s.RunTestCases(testCases) +} diff --git a/internal/usecase/interactor/domain_interactor_test.go b/internal/usecase/interactor/domain_interactor_test.go index 8052d2910..4dbe860e9 100644 --- a/internal/usecase/interactor/domain_interactor_test.go +++ b/internal/usecase/interactor/domain_interactor_test.go @@ -419,7 +419,7 @@ func TestRegisterIpa(t *testing.T) { } for _, testCase := range testCases { t.Log(testCase.Name) - i := NewDomainInteractor() + i := domainInteractor{} orgID, clientVersion, output, err := i.Register( testCase.Given.Secret, testCase.Given.XRHID, diff --git a/scripts/mk/compose.mk b/scripts/mk/compose.mk index 7e2650b43..420adde05 100644 --- a/scripts/mk/compose.mk +++ b/scripts/mk/compose.mk @@ -4,8 +4,10 @@ ifeq (podman,$(CONTAINER_ENGINE)) CONTAINER_COMPOSE ?= podman-compose +CONTAINER_DATABASE_NAME ?= $(COMPOSE_PROJECT)_database_1 else CONTAINER_COMPOSE ?= docker-compose +CONTAINER_DATABASE_NAME ?= $(COMPOSE_PROJECT)-database-1 endif COMPOSE_FILE ?= $(PROJECT_DIR)/deployments/docker-compose.yaml @@ -53,7 +55,7 @@ compose-up: ## Start local infrastructure .PHONY: .compose-wait-db .compose-wait-db: @printf "Waiting database"; \ - while [ "$$( $(CONTAINER_ENGINE) container inspect --format '{{$(CONTAINER_HEALTH_PATH)}}' "$(COMPOSE_PROJECT)_database_1" )" != "healthy" ]; \ + while [ "$$( $(CONTAINER_ENGINE) container inspect --format '{{$(CONTAINER_HEALTH_PATH)}}' "$(CONTAINER_DATABASE_NAME)" )" != "healthy" ]; \ do sleep 1; printf "."; \ done; \ printf "\n" diff --git a/scripts/mk/go-rules.mk b/scripts/mk/go-rules.mk index 3eb01bc3e..5be084904 100644 --- a/scripts/mk/go-rules.mk +++ b/scripts/mk/go-rules.mk @@ -93,20 +93,27 @@ vendor: ## Generate vendor/ directory populated with the dependencies # Exclude /internal/interface directories because only contain interfaces TEST_GREP_FILTER := -v \ -e /vendor/ \ - -e /internal/test/mock \ + -e /internal/test \ -e /internal/interface/ \ -e /internal/api/metrics \ -e /internal/api/private \ -e /internal/api/public .PHONY: test -test: ## Run tests - go test -coverprofile="coverage.out" -covermode count $(MOD_VENDOR) $(shell go list ./... | grep $(TEST_GREP_FILTER) ) +test: test-unit test-smoke ## Run unit tests, smoke tests and integration tests + +.PHONY: test-unit +test-unit: ## Run unit tests + go test -parallel 4 -coverprofile="coverage.out" -covermode count $(MOD_VENDOR) $(shell go list ./... | grep $(TEST_GREP_FILTER) ) .PHONY: test-ci test-ci: ## Run tests for ci go test $(MOD_VENDOR) ./... +.PHONY: test-smoke +test-smoke: ## Run smoke tests + CONFIG_PATH="$(PROJECT_DIR)/configs" go test -parallel 1 ./internal/test/smoke/... -test.failfast -test.v + # Add dependencies from binaries to all the the sources # so any change is detected for the build rule $(patsubst cmd/%,$(BIN)/%,$(wildcard cmd/*)): $(shell find $(PROJECT_DIR)/cmd -type f -name '*.go') $(shell find $(PROJECT_DIR)/pkg -type f -name '*.go' 2>/dev/null) $(shell find $(PROJECT_DIR)/internal -type f -name '*.go' 2>/dev/null) @@ -166,8 +173,8 @@ $(EVENT_SCHEMA_DIR)/%.event.json: $(EVENT_MESSAGE_DIR)/%.event.yaml # Mockery support MOCK_DIRS := internal/api/private \ - internal/api/public \ - internal/api/openapi \ + internal/api/public \ + internal/api/openapi \ internal/interface/repository \ internal/interface/interactor \ internal/interface/presenter \