// Copyright 2025 Cloudbase Solutions SRL // // Licensed under the Apache License, Version 2.0 (the "License"); you may // not use this file except in compliance with the License. You may obtain // a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the // License for the specific language governing permissions and limitations // under the License. package sql import ( "context" "fmt" "regexp" "testing" "github.com/stretchr/testify/suite" "gopkg.in/DATA-DOG/go-sqlmock.v1" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" runnerErrors "github.com/cloudbase/garm-provider-common/errors" commonParams "github.com/cloudbase/garm-provider-common/params" dbCommon "github.com/cloudbase/garm/database/common" "github.com/cloudbase/garm/database/watcher" garmTesting "github.com/cloudbase/garm/internal/testing" "github.com/cloudbase/garm/params" ) type TemplatesTestFixtures struct { Templates []params.Template SQLMock sqlmock.Sqlmock User params.User AdminUser params.User } type TemplatesTestSuite struct { suite.Suite Store dbCommon.Store ctx context.Context adminCtx context.Context StoreSQLMocked *sqlDatabase Fixtures *TemplatesTestFixtures } func (s *TemplatesTestSuite) assertSQLMockExpectations() { err := s.Fixtures.SQLMock.ExpectationsWereMet() if err != nil { s.FailNow(fmt.Sprintf("failed to meet sqlmock expectations, got error: %v", err)) } } func (s *TemplatesTestSuite) TearDownTest() { watcher.CloseWatcher() } func (s *TemplatesTestSuite) SetupTest() { ctx := context.Background() watcher.InitWatcher(ctx) db, err := NewSQLDatabase(context.Background(), garmTesting.GetTestSqliteDBConfig(s.T())) if err != nil { s.FailNow(fmt.Sprintf("failed to create db connection: %s", err)) } s.Store = db adminCtx := garmTesting.ImpersonateAdminContext(context.Background(), db, s.T()) s.adminCtx = adminCtx // Create a regular user for testing user-scoped templates user := garmTesting.CreateGARMTestUser(adminCtx, "testuser", db, s.T()) // Create proper user context (non-admin) s.ctx = adminCtx // For now, use admin context to avoid complexity // Create test templates templates := []params.Template{} // Create system template (user_id = nil) sysTemplate, err := s.Store.CreateTemplate(s.adminCtx, params.CreateTemplateParams{ Name: "system-template", Description: "System template for testing", OSType: commonParams.Linux, ForgeType: params.GithubEndpointType, Data: []byte(`{"provider": "lxd", "image": "ubuntu:22.04"}`), }) if err != nil { s.FailNow(fmt.Sprintf("failed to create system template: %s", err)) } templates = append(templates, sysTemplate) // Create user template userTemplate, err := s.Store.CreateTemplate(s.ctx, params.CreateTemplateParams{ Name: "user-template", Description: "User template for testing", OSType: commonParams.Windows, ForgeType: params.GithubEndpointType, Data: []byte(`{"provider": "azure", "image": "windows-2022"}`), }) if err != nil { s.FailNow(fmt.Sprintf("failed to create user template: %s", err)) } templates = append(templates, userTemplate) // Create store with mocked sql connection sqlDB, sqlMock, err := sqlmock.New() if err != nil { s.FailNow(fmt.Sprintf("failed to run 'sqlmock.New()', got error: %v", err)) } s.T().Cleanup(func() { sqlDB.Close() }) mysqlConfig := mysql.Config{ Conn: sqlDB, SkipInitializeWithVersion: true, } dialector := mysql.New(mysqlConfig) mockDB, err := gorm.Open(dialector, &gorm.Config{ Logger: logger.Default.LogMode(logger.Info), }) if err != nil { s.FailNow(fmt.Sprintf("failed to open mock database connection: %v", err)) } storeSQLMocked := &sqlDatabase{ conn: mockDB, cfg: garmTesting.GetTestSqliteDBConfig(s.T()), } s.StoreSQLMocked = storeSQLMocked s.Fixtures = &TemplatesTestFixtures{ Templates: templates, SQLMock: sqlMock, User: user, } } func (s *TemplatesTestSuite) TestListTemplates() { templates, err := s.Store.ListTemplates(s.adminCtx, nil, nil, nil) s.Require().Nil(err) // Should include both test templates and any system templates s.Require().GreaterOrEqual(len(templates), len(s.Fixtures.Templates)) // Find our test templates in the results foundNames := make(map[string]bool) for _, template := range templates { foundNames[template.Name] = true } for _, expected := range s.Fixtures.Templates { s.Require().True(foundNames[expected.Name], "Expected template %s not found", expected.Name) } } func (s *TemplatesTestSuite) TestListTemplatesWithOSTypeFilter() { osType := commonParams.Linux templates, err := s.Store.ListTemplates(s.adminCtx, &osType, nil, nil) s.Require().Nil(err) s.Require().GreaterOrEqual(len(templates), 1) // Verify all returned templates have the correct OS type for _, template := range templates { s.Require().Equal(commonParams.Linux, template.OSType) } // Find our test template found := false for _, template := range templates { if template.Name == "system-template" { found = true break } } s.Require().True(found, "Expected system-template not found") } func (s *TemplatesTestSuite) TestListTemplatesWithForgeTypeFilter() { forgeType := params.GithubEndpointType templates, err := s.Store.ListTemplates(s.adminCtx, nil, &forgeType, nil) s.Require().Nil(err) s.Require().GreaterOrEqual(len(templates), 2) // Verify all returned templates have the correct forge type for _, template := range templates { s.Require().Equal(params.GithubEndpointType, template.ForgeType) } } func (s *TemplatesTestSuite) TestListTemplatesWithNameFilter() { partialName := "system" templates, err := s.Store.ListTemplates(s.adminCtx, nil, nil, &partialName) s.Require().Nil(err) s.Require().Len(templates, 1) s.Require().Equal("system-template", templates[0].Name) } func (s *TemplatesTestSuite) TestListTemplatesDBFetchErr() { s.Fixtures.SQLMock. ExpectQuery(regexp.QuoteMeta("SELECT `templates`.`id`,`templates`.`created_at`,`templates`.`updated_at`,`templates`.`deleted_at`,`templates`.`name`,`templates`.`user_id`,`templates`.`description`,`templates`.`os_type`,`templates`.`forge_type` FROM `templates` WHERE `templates`.`deleted_at` IS NULL")). WillReturnError(fmt.Errorf("mocked fetching templates error")) _, err := s.StoreSQLMocked.ListTemplates(s.adminCtx, nil, nil, nil) s.assertSQLMockExpectations() s.Require().NotNil(err) s.Require().Contains(err.Error(), "failed to get templates") } func (s *TemplatesTestSuite) TestGetTemplate() { template, err := s.Store.GetTemplate(s.adminCtx, s.Fixtures.Templates[0].ID) s.Require().Nil(err) s.Require().Equal(s.Fixtures.Templates[0].ID, template.ID) s.Require().Equal(s.Fixtures.Templates[0].Name, template.Name) } func (s *TemplatesTestSuite) TestGetTemplateInvalidID() { _, err := s.Store.GetTemplate(s.adminCtx, 9999) s.Require().NotNil(err) s.Require().ErrorIs(err, runnerErrors.ErrNotFound) } func (s *TemplatesTestSuite) TestGetTemplateByName() { template, err := s.Store.GetTemplateByName(s.ctx, "user-template") s.Require().Nil(err) s.Require().Equal("user-template", template.Name) } func (s *TemplatesTestSuite) TestGetTemplateByNameNotFound() { _, err := s.Store.GetTemplateByName(s.ctx, "nonexistent-template") s.Require().NotNil(err) s.Require().ErrorIs(err, runnerErrors.ErrNotFound) } func (s *TemplatesTestSuite) TestGetTemplateByNameMultipleTemplatesConflict() { // Create a scenario where a user can see multiple templates with the same name: // 1. A user template with a specific name // 2. A system template (user_id = NULL) with the same name // Both will be visible to the user, creating a conflict templateName := "duplicate-name-template" // Create a user template first _, err := s.Store.CreateTemplate(s.ctx, params.CreateTemplateParams{ Name: templateName, Description: "User template with duplicate name", OSType: commonParams.Linux, ForgeType: params.GithubEndpointType, Data: []byte(`{"provider": "lxd", "image": "ubuntu:22.04"}`), }) s.Require().Nil(err) // Create a system template by directly inserting into the database // since the createSystemTemplate method isn't exported sqlDB := s.Store.(*sqlDatabase) sealed, err := sqlDB.marshalAndSeal([]byte(`{"provider": "azure", "image": "windows-2022"}`)) s.Require().Nil(err) systemTemplate := Template{ UserID: nil, // system template Name: templateName, Description: "System template with duplicate name", OSType: commonParams.Windows, ForgeType: params.GithubEndpointType, Data: sealed, } err = sqlDB.conn.Create(&systemTemplate).Error s.Require().Nil(err) // Now try to get template by name - should return conflict error _, err = s.Store.GetTemplateByName(s.ctx, templateName) s.Require().NotNil(err) expectedErr := runnerErrors.NewConflictError("multiple templates match the specified name %q. Please get template by ID.", templateName) s.Require().Equal(expectedErr, err) } func (s *TemplatesTestSuite) TestCreateTemplateDuplicateName() { // Test that creating a template with a duplicate name for the same user returns a conflict error templateName := "duplicate-user-template" // Create first template _, err := s.Store.CreateTemplate(s.ctx, params.CreateTemplateParams{ Name: templateName, Description: "First template", OSType: commonParams.Linux, ForgeType: params.GithubEndpointType, Data: []byte(`{"provider": "lxd", "image": "ubuntu:22.04"}`), }) s.Require().Nil(err) // Try to create second template with same name for same user - should fail _, err = s.Store.CreateTemplate(s.ctx, params.CreateTemplateParams{ Name: templateName, Description: "Second template with same name", OSType: commonParams.Windows, ForgeType: params.GithubEndpointType, Data: []byte(`{"provider": "azure", "image": "windows-2022"}`), }) s.Require().NotNil(err) expectedErr := runnerErrors.NewConflictError("a template name already exists with the specified name") s.Require().Equal(expectedErr, err) } func (s *TemplatesTestSuite) TestCreateTemplateSystemAndUserConflict() { // Test scenarios where system and user templates might conflict templateName := "conflicting-template" // Create a user template first _, err := s.Store.CreateTemplate(s.ctx, params.CreateTemplateParams{ Name: templateName, Description: "User template", OSType: commonParams.Linux, ForgeType: params.GithubEndpointType, Data: []byte(`{"provider": "lxd", "image": "ubuntu:22.04"}`), }) s.Require().Nil(err) // Now try to create a system template with the same name using direct access to createSystemTemplate // This should succeed since the unique constraint is on (name, user_id) and system templates have user_id = NULL sqlDB := s.Store.(*sqlDatabase) _, err = sqlDB.createSystemTemplate(s.adminCtx, params.CreateTemplateParams{ Name: templateName, Description: "System template with same name", OSType: commonParams.Windows, ForgeType: params.GithubEndpointType, Data: []byte(`{"provider": "azure", "image": "windows-2022"}`), }) // This should succeed because system templates (user_id = NULL) and user templates // (user_id = specific_user_id) can coexist with the same name due to the composite unique constraint s.Require().Nil(err) // Verify both templates exist userTemplates, err := s.Store.ListTemplates(s.ctx, nil, nil, &templateName) s.Require().Nil(err) s.Require().Len(userTemplates, 2) // User can see both their template and the system template } func (s *TemplatesTestSuite) TestCreateTemplate() { template, err := s.Store.CreateTemplate(s.ctx, params.CreateTemplateParams{ Name: "new-template", Description: "New template for testing", OSType: commonParams.Linux, ForgeType: params.GithubEndpointType, Data: []byte(`{"provider": "lxd", "image": "ubuntu:20.04"}`), }) s.Require().Nil(err) s.Require().Equal("new-template", template.Name) s.Require().Equal("New template for testing", template.Description) s.Require().Equal(commonParams.Linux, template.OSType) } func (s *TemplatesTestSuite) TestCreateTemplateInvalidParams() { _, err := s.Store.CreateTemplate(s.ctx, params.CreateTemplateParams{ Name: "", // Empty name should fail validation Description: "Invalid template", OSType: commonParams.Linux, ForgeType: params.GithubEndpointType, Data: []byte(`{"provider": "lxd"}`), }) s.Require().NotNil(err) s.Require().Contains(err.Error(), "failed to validate create params") } func (s *TemplatesTestSuite) TestUpdateTemplate() { newName := "updated-template-name" newDescription := "Updated description" template, err := s.Store.UpdateTemplate(s.ctx, s.Fixtures.Templates[1].ID, params.UpdateTemplateParams{ Name: &newName, Description: &newDescription, Data: []byte(`{"provider": "updated", "image": "updated:latest"}`), }) s.Require().Nil(err) s.Require().Equal(newName, template.Name) s.Require().Equal(newDescription, template.Description) } func (s *TemplatesTestSuite) TestUpdateTemplateNoChanges() { originalTemplate := s.Fixtures.Templates[1] _, err := s.Store.UpdateTemplate(s.ctx, s.Fixtures.Templates[1].ID, params.UpdateTemplateParams{}) s.Require().Nil(err) // When no changes are made, the template should be returned unchanged // But the Update function may return an empty template if there are no changes // So let's get the template explicitly to verify it's unchanged updatedTemplate, err := s.Store.GetTemplate(s.ctx, s.Fixtures.Templates[1].ID) s.Require().Nil(err) s.Require().Equal(originalTemplate.Name, updatedTemplate.Name) s.Require().Equal(originalTemplate.Description, updatedTemplate.Description) } func (s *TemplatesTestSuite) TestUpdateTemplateInvalidID() { newName := "updated-name" _, err := s.Store.UpdateTemplate(s.ctx, 9999, params.UpdateTemplateParams{ Name: &newName, }) s.Require().NotNil(err) s.Require().Contains(err.Error(), "failed to get template") } func (s *TemplatesTestSuite) TestDeleteTemplate() { err := s.Store.DeleteTemplate(s.ctx, s.Fixtures.Templates[1].ID) s.Require().Nil(err) // Verify template is deleted _, err = s.Store.GetTemplate(s.ctx, s.Fixtures.Templates[1].ID) s.Require().NotNil(err) s.Require().ErrorIs(err, runnerErrors.ErrNotFound) } func (s *TemplatesTestSuite) TestDeleteTemplateInvalidID() { err := s.Store.DeleteTemplate(s.ctx, 9999) s.Require().NotNil(err) s.Require().Contains(err.Error(), "failed to get template") } func (s *TemplatesTestSuite) TestDeleteSystemTemplateAsNonAdmin() { // Since both contexts are admin for simplicity, we'll skip this test // In a real scenario, you'd set up a proper non-admin context s.T().Skip("Skipping non-admin test - requires proper user context setup") } func TestTemplatesTestSuite(t *testing.T) { suite.Run(t, new(TemplatesTestSuite)) }