Files
fm_be/internal/service/master_settings_service_test.go
2026-07-16 22:16:45 +07:00

435 lines
15 KiB
Go

package service
import (
"context"
"encoding/base64"
"errors"
"testing"
"wucher/internal/config"
mastersettings "wucher/internal/domain/master_settings"
)
type masterSettingsRepoMock struct {
createErr error
updateErr error
deleteErr error
getErr error
listErr error
lastCreate *mastersettings.MasterSettings
lastUpdate *mastersettings.MasterSettings
lastDeleteID []byte
lastDeleteBy []byte
lastGetID []byte
lastListSort string
lastListFilter string
lastListLimit int
lastListOffset int
getRow *mastersettings.MasterSettings
listRows []mastersettings.MasterSettings
listTotal int64
lastSettingRows []mastersettings.MasterSettingValue
lastSettingKeys []string
settingRows []mastersettings.MasterSettingValue
settingErr error
}
func (m *masterSettingsRepoMock) Create(_ context.Context, row *mastersettings.MasterSettings) error {
m.lastCreate = row
return m.createErr
}
func (m *masterSettingsRepoMock) Update(_ context.Context, row *mastersettings.MasterSettings) error {
m.lastUpdate = row
return m.updateErr
}
func (m *masterSettingsRepoMock) Delete(_ context.Context, id []byte, deletedBy []byte) error {
m.lastDeleteID = append([]byte(nil), id...)
m.lastDeleteBy = append([]byte(nil), deletedBy...)
return m.deleteErr
}
func (m *masterSettingsRepoMock) GetByID(_ context.Context, id []byte) (*mastersettings.MasterSettings, error) {
m.lastGetID = append([]byte(nil), id...)
return m.getRow, m.getErr
}
func (m *masterSettingsRepoMock) List(_ context.Context, filter, sort string, limit, offset int) ([]mastersettings.MasterSettings, int64, error) {
m.lastListFilter = filter
m.lastListSort = sort
m.lastListLimit = limit
m.lastListOffset = offset
return m.listRows, m.listTotal, m.listErr
}
func (m *masterSettingsRepoMock) UpsertSettingValues(_ context.Context, rows []mastersettings.MasterSettingValue) error {
m.lastSettingRows = append([]mastersettings.MasterSettingValue(nil), rows...)
return m.settingErr
}
func (m *masterSettingsRepoMock) GetSettingValuesByKeys(_ context.Context, keys []string) ([]mastersettings.MasterSettingValue, error) {
m.lastSettingKeys = append([]string(nil), keys...)
return append([]mastersettings.MasterSettingValue(nil), m.settingRows...), m.settingErr
}
func TestNewMasterSettingsService(t *testing.T) {
svc := NewMasterSettingsService(&masterSettingsRepoMock{}, MasterSettingsServiceDependencies{})
if svc == nil || svc.repo == nil {
t.Fatalf("expected service initialized")
}
}
func TestMasterSettingsServiceCreateUpdateDeleteGetByID(t *testing.T) {
ctx := context.Background()
row := &mastersettings.MasterSettings{CompanyName: "Wucher"}
id := []byte("1234567890123456")
actor := []byte("abcdefghijklmnop")
t.Run("create success and error", func(t *testing.T) {
repo := &masterSettingsRepoMock{}
svc := NewMasterSettingsService(repo, MasterSettingsServiceDependencies{})
if err := svc.Create(ctx, row); err != nil {
t.Fatalf("expected no error, got %v", err)
}
if repo.lastCreate != row {
t.Fatalf("expected row passed to repo")
}
repo.createErr = errors.New("create failed")
if err := svc.Create(ctx, row); err == nil {
t.Fatalf("expected create error")
}
})
t.Run("update success and error", func(t *testing.T) {
repo := &masterSettingsRepoMock{}
svc := NewMasterSettingsService(repo, MasterSettingsServiceDependencies{})
if err := svc.Update(ctx, row); err != nil {
t.Fatalf("expected no error, got %v", err)
}
if repo.lastUpdate != row {
t.Fatalf("expected row passed to repo")
}
repo.updateErr = errors.New("update failed")
if err := svc.Update(ctx, row); err == nil {
t.Fatalf("expected update error")
}
})
t.Run("delete success and error", func(t *testing.T) {
repo := &masterSettingsRepoMock{}
svc := NewMasterSettingsService(repo, MasterSettingsServiceDependencies{})
if err := svc.Delete(ctx, id, actor); err != nil {
t.Fatalf("expected no error, got %v", err)
}
if string(repo.lastDeleteID) != string(id) || string(repo.lastDeleteBy) != string(actor) {
t.Fatalf("expected id and deletedBy passed to repo")
}
repo.deleteErr = errors.New("delete failed")
if err := svc.Delete(ctx, id, actor); err == nil {
t.Fatalf("expected delete error")
}
})
t.Run("get by id success and error", func(t *testing.T) {
repo := &masterSettingsRepoMock{getRow: row}
svc := NewMasterSettingsService(repo, MasterSettingsServiceDependencies{})
got, err := svc.GetByID(ctx, id)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got != row {
t.Fatalf("expected repo row returned")
}
if string(repo.lastGetID) != string(id) {
t.Fatalf("expected id passed to repo")
}
repo.getErr = errors.New("get failed")
if _, err := svc.GetByID(ctx, id); err == nil {
t.Fatalf("expected get error")
}
})
}
func TestMasterSettingsServiceList(t *testing.T) {
ctx := context.Background()
repo := &masterSettingsRepoMock{
listRows: []mastersettings.MasterSettings{{CompanyName: "Wucher"}},
listTotal: 1,
}
svc := NewMasterSettingsService(repo, MasterSettingsServiceDependencies{})
rows, total, err := svc.List(ctx, "wu", "-updated_at", 10, 5)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(rows) != 1 || total != 1 {
t.Fatalf("unexpected list result: len=%d total=%d", len(rows), total)
}
if repo.lastListSort != "updated_at DESC" {
t.Fatalf("expected normalized sort, got %q", repo.lastListSort)
}
if repo.lastListFilter != "wu" || repo.lastListLimit != 10 || repo.lastListOffset != 5 {
t.Fatalf("unexpected list params passed to repo")
}
repo.listErr = errors.New("list failed")
if _, _, err := svc.List(ctx, "", "unknown", 0, 0); err == nil {
t.Fatalf("expected list error")
}
if repo.lastListSort != "" {
t.Fatalf("expected unknown sort normalized to empty string, got %q", repo.lastListSort)
}
}
func TestNormalizeMasterSettingsSort(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "company asc", in: "company_name", want: "company_name ASC"},
{name: "company desc", in: "-company_name", want: "company_name DESC"},
{name: "title asc", in: "title", want: "title ASC"},
{name: "title desc", in: "-title", want: "title DESC"},
{name: "subtitle asc", in: "subtitle", want: "subtitle ASC"},
{name: "subtitle desc", in: "-subtitle", want: "subtitle DESC"},
{name: "logo url asc", in: "logo_url", want: "logo_url ASC"},
{name: "logo url desc", in: "-logo_url", want: "logo_url DESC"},
{name: "logo file asc", in: "logo_file_uuid", want: "logo_file_id ASC"},
{name: "logo file desc", in: "-logo_file_uuid", want: "logo_file_id DESC"},
{name: "created asc", in: "created_at", want: "created_at ASC"},
{name: "created desc", in: "-created_at", want: "created_at DESC"},
{name: "updated asc", in: "updated_at", want: "updated_at ASC"},
{name: "updated desc", in: "-updated_at", want: "updated_at DESC"},
{name: "unknown", in: "x", want: ""},
{name: "empty", in: "", want: ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := normalizeMasterSettingsSort(tc.in)
if got != tc.want {
t.Fatalf("normalize sort %q: got %q want %q", tc.in, got, tc.want)
}
})
}
}
func TestMasterSettingsServiceMicrosoftEntra(t *testing.T) {
ctx := context.Background()
repo := &masterSettingsRepoMock{}
svc := NewMasterSettingsService(repo, MasterSettingsServiceDependencies{})
keyB64 := "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
protector, err := NewAESGCMProtectorFromBase64(keyB64)
if err != nil {
t.Fatalf("new protector: %v", err)
}
svc.SetSecretProtector(protector)
repo.settingRows = []mastersettings.MasterSettingValue{
{SettingKey: mastersettings.SettingMicrosoftEntraTenantID, Value: "tenant"},
{SettingKey: mastersettings.SettingMicrosoftEntraClientID, Value: "client"},
{SettingKey: mastersettings.SettingMicrosoftEntraClientSecret, Value: "cipher", IsEncrypted: true},
{SettingKey: mastersettings.SettingMicrosoftEntraRedirectURL, Value: "https://app.example.com/callback"},
{SettingKey: mastersettings.SettingMicrosoftEntraAuthority, Value: "https://login.microsoftonline.com/"},
{SettingKey: mastersettings.SettingMicrosoftEntraScopes, Value: "openid,profile,email"},
}
got, err := svc.UpsertMicrosoftEntraConfig(ctx, mastersettings.MicrosoftEntraConfigInput{
TenantID: " tenant ",
ClientID: " client ",
ClientSecret: "super-secret",
RedirectURL: "https://app.example.com/callback",
Authority: "https://login.microsoftonline.com",
Scopes: "openid, profile,email,profile",
}, []byte("abcdefghijklmnop"))
if err != nil {
t.Fatalf("upsert microsoft entra config: %v", err)
}
if got == nil || got.ClientSecretMasked != "********" {
t.Fatalf("expected masked secret in view")
}
if len(repo.lastSettingRows) != len(mastersettings.MicrosoftEntraSettingKeys) {
t.Fatalf("unexpected setting rows len: %d", len(repo.lastSettingRows))
}
var encryptedSecret string
for _, row := range repo.lastSettingRows {
if row.SettingKey == mastersettings.SettingMicrosoftEntraClientSecret {
encryptedSecret = row.Value
if !row.IsEncrypted {
t.Fatalf("expected secret row encrypted")
}
}
}
if encryptedSecret == "" {
t.Fatalf("secret row not written")
}
rawCipher, err := base64.StdEncoding.DecodeString(encryptedSecret)
if err != nil {
t.Fatalf("decode secret ciphertext: %v", err)
}
plain, err := protector.Decrypt(rawCipher)
if err != nil {
t.Fatalf("decrypt secret ciphertext: %v", err)
}
if string(plain) != "super-secret" {
t.Fatalf("unexpected decrypted secret: %q", string(plain))
}
loaded, err := svc.GetMicrosoftEntraConfig(ctx)
if err != nil {
t.Fatalf("get microsoft entra config: %v", err)
}
if loaded == nil || loaded.ClientSecretMasked != "********" {
t.Fatalf("expected masked secret from get")
}
t.Run("keep existing secret when input secret is empty", func(t *testing.T) {
repo.lastSettingRows = nil
repo.settingRows = []mastersettings.MasterSettingValue{
{SettingKey: mastersettings.SettingMicrosoftEntraClientSecret, Value: "existing-cipher", IsEncrypted: true},
{SettingKey: mastersettings.SettingMicrosoftEntraTenantID, Value: "tenant"},
{SettingKey: mastersettings.SettingMicrosoftEntraClientID, Value: "client"},
{SettingKey: mastersettings.SettingMicrosoftEntraRedirectURL, Value: "https://app.example.com/callback"},
{SettingKey: mastersettings.SettingMicrosoftEntraAuthority, Value: "https://login.microsoftonline.com/"},
{SettingKey: mastersettings.SettingMicrosoftEntraScopes, Value: "openid,profile,email"},
}
_, err := svc.UpsertMicrosoftEntraConfig(ctx, mastersettings.MicrosoftEntraConfigInput{
TenantID: "tenant",
ClientID: "client",
ClientSecret: " ",
RedirectURL: "https://app.example.com/callback",
Authority: "https://login.microsoftonline.com/",
Scopes: "openid,profile,email",
}, []byte("abcdefghijklmnop"))
if err != nil {
t.Fatalf("upsert with empty secret: %v", err)
}
for _, row := range repo.lastSettingRows {
if row.SettingKey == mastersettings.SettingMicrosoftEntraClientSecret {
t.Fatalf("expected secret row not updated when input secret is empty")
}
}
})
t.Run("empty secret rejected on initial setup", func(t *testing.T) {
repo.lastSettingRows = nil
repo.settingRows = []mastersettings.MasterSettingValue{}
_, err := svc.UpsertMicrosoftEntraConfig(ctx, mastersettings.MicrosoftEntraConfigInput{
TenantID: "tenant",
ClientID: "client",
ClientSecret: "",
RedirectURL: "https://app.example.com/callback",
Authority: "https://login.microsoftonline.com/",
Scopes: "openid,profile,email",
}, []byte("abcdefghijklmnop"))
if err == nil {
t.Fatalf("expected error when secret empty and no existing value")
}
if err.Error() != "MS_ENTRA_CLIENT_SECRET is required for initial setup" {
t.Fatalf("unexpected error: %v", err)
}
})
}
func TestSeedMicrosoftEntraConfigIfMissing(t *testing.T) {
ctx := context.Background()
keyB64 := "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="
protector, err := NewAESGCMProtectorFromBase64(keyB64)
if err != nil {
t.Fatalf("new protector: %v", err)
}
t.Run("seed inserts all keys when missing", func(t *testing.T) {
repo := &masterSettingsRepoMock{}
svc := NewMasterSettingsService(repo, MasterSettingsServiceDependencies{Protector: protector})
err := svc.SeedMicrosoftEntraConfigIfMissing(ctx, config.MicrosoftSSOConfig{
TenantID: "tenant",
ClientID: "client",
ClientSecret: "secret",
RedirectURL: "https://app.example.com/challenge/msentra",
Authority: "https://login.microsoftonline.com/",
Scopes: []string{"openid", "profile", "email"},
})
if err != nil {
t.Fatalf("seed microsoft entra: %v", err)
}
if len(repo.lastSettingRows) != 3 {
t.Fatalf("expected 3 rows, got %d", len(repo.lastSettingRows))
}
})
t.Run("seed only inserts missing and preserves existing", func(t *testing.T) {
repo := &masterSettingsRepoMock{
settingRows: []mastersettings.MasterSettingValue{
{SettingKey: mastersettings.SettingMicrosoftEntraTenantID, Value: "existing-tenant"},
{SettingKey: mastersettings.SettingMicrosoftEntraClientID, Value: "existing-client"},
{SettingKey: mastersettings.SettingMicrosoftEntraClientSecret, Value: "existing-encrypted", IsEncrypted: true},
},
}
svc := NewMasterSettingsService(repo, MasterSettingsServiceDependencies{Protector: protector})
err := svc.SeedMicrosoftEntraConfigIfMissing(ctx, config.MicrosoftSSOConfig{
TenantID: "tenant",
ClientID: "client",
ClientSecret: "secret",
RedirectURL: "https://app.example.com/challenge/msentra",
Authority: "https://login.microsoftonline.com",
Scopes: []string{"openid", "profile", "email"},
})
if err != nil {
t.Fatalf("seed microsoft entra: %v", err)
}
if len(repo.lastSettingRows) != 3 {
t.Fatalf("expected 3 missing rows to be inserted, got %d", len(repo.lastSettingRows))
}
for i := range repo.lastSettingRows {
if repo.lastSettingRows[i].SettingKey == mastersettings.SettingMicrosoftEntraClientSecret {
t.Fatalf("client secret should not be overwritten when existing value is present")
}
}
})
}
func TestMicrosoftEntraPartialViewButStrictRuntime(t *testing.T) {
ctx := context.Background()
repo := &masterSettingsRepoMock{
settingRows: []mastersettings.MasterSettingValue{
{SettingKey: mastersettings.SettingMicrosoftEntraRedirectURL, Value: "https://app.example.com/challenge/msentra"},
{SettingKey: mastersettings.SettingMicrosoftEntraAuthority, Value: "https://login.microsoftonline.com/"},
{SettingKey: mastersettings.SettingMicrosoftEntraScopes, Value: "openid,profile,email"},
},
}
svc := NewMasterSettingsService(repo, MasterSettingsServiceDependencies{})
view, err := svc.GetMicrosoftEntraConfig(ctx)
if err != nil {
t.Fatalf("get microsoft entra config: %v", err)
}
if view == nil {
t.Fatalf("expected partial config view, got nil")
}
if view.RedirectURL == "" || view.Authority == "" || view.Scopes == "" {
t.Fatalf("expected non-sensitive fields populated")
}
if _, err := svc.LoadMicrosoftEntraRuntimeConfig(ctx); err == nil {
t.Fatalf("expected strict runtime config to fail when tenant/client/secret missing")
}
}