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") } }