2025-09-23 13:46:27 +03:00
// 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 ( ) {
2025-09-16 07:42:59 +00:00
partialName := params . SystemUser
2025-09-23 13:46:27 +03:00
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 .
2026-02-07 23:19:21 +02:00
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" ) ) .
2025-09-23 13:46:27 +03:00
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 )
2025-09-16 07:42:59 +00:00
_ , err = sqlDB . CreateTemplate ( s . adminCtx , params . CreateTemplateParams {
2025-09-23 13:46:27 +03:00
Name : templateName ,
Description : "System template with same name" ,
OSType : commonParams . Windows ,
ForgeType : params . GithubEndpointType ,
Data : [ ] byte ( ` { "provider": "azure", "image": "windows-2022"} ` ) ,
2025-09-16 07:42:59 +00:00
IsSystem : true ,
2025-09-23 13:46:27 +03:00
} )
// 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 ) )
}