diff --git a/.gitignore b/.gitignore index 94f1119..b9767ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .DS_Store .vscode +*.test diff --git a/README.md b/README.md index 19f2338..57a1c81 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,13 @@ :warning: **This client is a work in progress, expect breaking changes between releases** :warning: +## Compatibility + +| go-dvls version | DVLS version | +|-----------------|----------------| +| 0.16.0+ | 2026.x | +| 0.15.0 | 2024.x, 2025.x | + Heavily based on the information found on the [Devolutions.Server](https://github.com/Devolutions/devolutions-server/tree/main/Powershell%20Module/Devolutions.Server) powershell module. ## Usage diff --git a/VERSION b/VERSION index a551051..04a373e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.15.0 +0.16.0 diff --git a/authentication.go b/authentication.go index 01f28e7..6a39cff 100644 --- a/authentication.go +++ b/authentication.go @@ -61,10 +61,11 @@ func NewClient(appKey string, appSecret string, baseUri string) (Client, error) client.common.client = &client client.Entries = &Entries{ - Credential: (*EntryCredentialService)(&client.common), Certificate: (*EntryCertificateService)(&client.common), - Website: (*EntryWebsiteService)(&client.common), + Credential: (*EntryCredentialService)(&client.common), + Folder: (*EntryFolderService)(&client.common), Host: (*EntryHostService)(&client.common), + Website: (*EntryWebsiteService)(&client.common), } client.Vaults = (*Vaults)(&client.common) diff --git a/dvls_test.go b/dvls_test.go index 3f5611d..85f0d28 100644 --- a/dvls_test.go +++ b/dvls_test.go @@ -8,11 +8,11 @@ import ( var ( testClient Client - testVaultId string + testVaultId string // Used by legacy tests (certificate, host, website) ) func TestMain(m *testing.M) { - testVaultId = os.Getenv("TEST_VAULT_ID") + testVaultId = os.Getenv("TEST_VAULT_ID") // Optional, only for legacy tests err := setupTestClient() if err != nil { diff --git a/dvlstypes.go b/dvlstypes.go index ff875a1..8dad657 100644 --- a/dvlstypes.go +++ b/dvlstypes.go @@ -226,21 +226,6 @@ const ( ServerConnectionSubTypeAppleSafari ServerConnectionSubType = "Safari" ) -type VaultVisibility int - -const ( - VaultVisibilityDefault VaultVisibility = 0 - VaultVisibilityPublic VaultVisibility = 2 - VaultVisibilityPrivate VaultVisibility = 3 -) - -type VaultSecurityLevel int - -const ( - VaultSecurityLevelStandard VaultSecurityLevel = 0 - VaultSecurityLevelHigh VaultSecurityLevel = 1 -) - type EntryCertificateDataMode int const ( diff --git a/entries.go b/entries.go index 49b0e23..2bc7c2f 100644 --- a/entries.go +++ b/entries.go @@ -1,8 +1,12 @@ package dvls import ( + "context" "encoding/json" + "errors" "fmt" + "net/http" + "net/url" "strings" ) @@ -13,11 +17,28 @@ const ( entryPublicEndpoint string = "/api/v1/vault/{vaultId}/entry/{id}" ) +// ErrUnsupportedEntryType is returned when an entry type/subtype is not supported by this client. +type ErrUnsupportedEntryType struct { + Type string + SubType string +} + +func (e ErrUnsupportedEntryType) Error() string { + return fmt.Sprintf("unsupported entry type/subtype: %s/%s", e.Type, e.SubType) +} + +// IsUnsupportedEntryType returns true if the error is an ErrUnsupportedEntryType. +func IsUnsupportedEntryType(err error) bool { + var unsupportedErr ErrUnsupportedEntryType + return errors.As(err, &unsupportedErr) +} + type Entries struct { Certificate *EntryCertificateService Host *EntryHostService Credential *EntryCredentialService Website *EntryWebsiteService + Folder *EntryFolderService } type Entry struct { @@ -55,6 +76,22 @@ var entryFactories = map[string]func() EntryData{ "Credential/ConnectionString": func() EntryData { return &EntryCredentialConnectionStringData{} }, "Credential/Default": func() EntryData { return &EntryCredentialDefaultData{} }, "Credential/PrivateKey": func() EntryData { return &EntryCredentialPrivateKeyData{} }, + "Folder/Company": func() EntryData { return &EntryFolderData{} }, + "Folder/Credentials": func() EntryData { return &EntryFolderData{} }, + "Folder/Customer": func() EntryData { return &EntryFolderData{} }, + "Folder/Database": func() EntryData { return &EntryFolderData{} }, + "Folder/Device": func() EntryData { return &EntryFolderData{} }, + "Folder/Domain": func() EntryData { return &EntryFolderData{} }, + "Folder/Folder": func() EntryData { return &EntryFolderData{} }, + "Folder/Identity": func() EntryData { return &EntryFolderData{} }, + "Folder/MacroScriptTools": func() EntryData { return &EntryFolderData{} }, + "Folder/Printer": func() EntryData { return &EntryFolderData{} }, + "Folder/Server": func() EntryData { return &EntryFolderData{} }, + "Folder/Site": func() EntryData { return &EntryFolderData{} }, + "Folder/SmartFolder": func() EntryData { return &EntryFolderData{} }, + "Folder/Software": func() EntryData { return &EntryFolderData{} }, + "Folder/Team": func() EntryData { return &EntryFolderData{} }, + "Folder/Workstation": func() EntryData { return &EntryFolderData{} }, } func (e *Entry) UnmarshalJSON(data []byte) error { @@ -73,7 +110,7 @@ func (e *Entry) UnmarshalJSON(data []byte) error { key := fmt.Sprintf("%s/%s", raw.Type, raw.SubType) factory, ok := entryFactories[key] if !ok { - return fmt.Errorf("unsupported entry type/subtype: %s", key) + return ErrUnsupportedEntryType{Type: raw.Type, SubType: raw.SubType} } dataStruct := factory() @@ -112,3 +149,67 @@ func entryPublicBaseEndpointReplacer(vaultId string) string { replacer := strings.NewReplacer("{vaultId}", vaultId) return replacer.Replace(entryBasePublicEndpoint) } + +// entryListRawResponse represents the raw paginated response from the entry list endpoint. +type entryListRawResponse struct { + Data []json.RawMessage `json:"data"` +} + +// getEntriesOptions contains optional filters for listing entries. +type getEntriesOptions struct { + Name string + Path string +} + +// getEntries returns a list of entries from a vault with optional filters. +// Entries with unsupported types are skipped. +func (c *Client) getEntries(ctx context.Context, vaultId string, opts getEntriesOptions) ([]Entry, error) { + if vaultId == "" { + return nil, fmt.Errorf("vaultId is required") + } + + baseEndpoint := entryPublicBaseEndpointReplacer(vaultId) + reqUrl, err := url.JoinPath(c.baseUri, baseEndpoint) + if err != nil { + return nil, fmt.Errorf("failed to build entry url: %w", err) + } + + parsedUrl, err := url.Parse(reqUrl) + if err != nil { + return nil, fmt.Errorf("failed to parse entry url: %w", err) + } + + q := parsedUrl.Query() + if opts.Name != "" { + q.Set("name", opts.Name) + } + if opts.Path != "" { + q.Set("path", opts.Path) + } + parsedUrl.RawQuery = q.Encode() + + resp, err := c.RequestWithContext(ctx, parsedUrl.String(), http.MethodGet, nil) + if err != nil { + return nil, fmt.Errorf("error while fetching entries: %w", err) + } + + var rawResp entryListRawResponse + if err := json.Unmarshal(resp.Response, &rawResp); err != nil { + return nil, fmt.Errorf("failed to unmarshal entry list response: %w", err) + } + + var entries []Entry + for _, raw := range rawResp.Data { + var entry Entry + if err := json.Unmarshal(raw, &entry); err != nil { + if IsUnsupportedEntryType(err) { + continue + } + return nil, fmt.Errorf("failed to unmarshal entry: %w", err) + } + entry.VaultId = vaultId + entries = append(entries, entry) + } + + return entries, nil +} diff --git a/entry_certificate_test.go b/entry_certificate_test.go index 99d1ef7..0516909 100644 --- a/entry_certificate_test.go +++ b/entry_certificate_test.go @@ -23,6 +23,10 @@ var ( ) func Test_EntryCertificate(t *testing.T) { + if testVaultId == "" { + t.Skip("Skipping legacy API test: TEST_VAULT_ID not set") + } + testCertificateFilePath = os.Getenv("TEST_CERTIFICATE_FILE_PATH") testCertificateEntryId = os.Getenv("TEST_CERTIFICATE_ENTRY_ID") testCertificateEntry.Id = testCertificateEntryId diff --git a/entry_credential.go b/entry_credential.go index 090812c..9c5dad4 100644 --- a/entry_credential.go +++ b/entry_credential.go @@ -457,3 +457,30 @@ func (c *EntryCredentialService) DeleteByIdWithContext(ctx context.Context, vaul return nil } + +// GetEntries returns a list of credential entries from a vault with optional name and path filters. +func (c *EntryCredentialService) GetEntries(vaultId, name, path string) ([]Entry, error) { + return c.GetEntriesWithContext(context.Background(), vaultId, name, path) +} + +// GetEntriesWithContext returns a list of credential entries from a vault with optional name and path filters. +// The provided context can be used to cancel the request. +func (c *EntryCredentialService) GetEntriesWithContext(ctx context.Context, vaultId, name, path string) ([]Entry, error) { + entries, err := c.client.getEntries(ctx, vaultId, getEntriesOptions{ + Name: name, + Path: path, + }) + if err != nil { + return nil, err + } + + // Filter only Credential type entries + var credentials []Entry + for _, entry := range entries { + if entry.GetType() == EntryCredentialType { + credentials = append(credentials, entry) + } + } + + return credentials, nil +} diff --git a/entry_credential_test.go b/entry_credential_test.go index 871ef69..5837e82 100644 --- a/entry_credential_test.go +++ b/entry_credential_test.go @@ -1,485 +1,266 @@ package dvls import ( + "strings" "testing" -) - -var ( - testCredentialAccessCodeEntryId *string - testCredentialAccessCodeEntry *Entry - - testCredentialApiKeyEntryId *string - testCredentialApiKeyEntry *Entry - - testCredentialAzureServicePrincipalEntryId *string - testCredentialAzureServicePrincipalEntry *Entry - - testCredentialConnectionStringEntryId *string - testCredentialConnectionStringEntry *Entry - testCredentialDefaultEntryId *string - testCredentialDefaultEntry *Entry - - testCredentialPrivateKeyEntryId *string - testCredentialPrivateKeyEntry *Entry + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func Test_EntryUserCredentials(t *testing.T) { - if !t.Run("NewEntry", test_NewUserEntry) { - t.Skip("Skipping subsequent tests due to failure in NewEntry") - return - } - - if !t.Run("GetEntry", test_GetUserEntry) { - t.Skip("Skipping subsequent tests due to failure in GetEntry") - return - } - - if !t.Run("UpdateEntry", test_UpdateUserEntry) { - t.Skip("Skipping subsequent tests due to failure in UpdateEntry") - return - } - - if !t.Run("DeleteEntry", test_DeleteUserEntry) { - t.Skip("Skipping subsequent tests due to failure in DeleteEntry") - return - } +// credentialTestCase defines a test case for credential CRUD operations. +type credentialTestCase struct { + name string + entryName string + description string + subType string + data EntryData + updateData func(entry *Entry) } -func test_NewUserEntry(t *testing.T) { - // Notes: all entries values are random and for testing purposes only. - - // Credential/AccessCode - testCredentialAccessCodeEntry := Entry{ - Id: "", - VaultId: testVaultId, - Name: "TestGoDvlsAccessCode", - Path: "go-dvls\\accesscode", - Type: EntryCredentialType, - SubType: EntryCredentialSubTypeAccessCode, - Description: "Test AccessCode entry", - Tags: []string{"accesscode"}, - - Data: EntryCredentialAccessCodeData{ - Password: "abc-123", +var credentialTestCases = []credentialTestCase{ + { + name: "AccessCode", + entryName: "Test Access Code", + description: "Test access code entry", + subType: EntryCredentialSubTypeAccessCode, + data: &EntryCredentialAccessCodeData{Password: "1234"}, + updateData: func(entry *Entry) { + if data, ok := entry.GetCredentialAccessCodeData(); ok { + data.Password = "5678" + entry.Data = data + } }, - } - - newCredentialAccessCodeEntryId, err := testClient.Entries.Credential.New(testCredentialAccessCodeEntry) - if err != nil { - t.Fatalf("Failed to create new AccessCode entry: %v", err) - } - - if newCredentialAccessCodeEntryId == "" { - t.Fatal("New AccessCode entry Id is empty after creation.") - } - - testCredentialAccessCodeEntryId = &newCredentialAccessCodeEntryId - - // Credential/ApiKey - testCredentialApiKeyEntry := Entry{ - Id: "", - VaultId: testVaultId, - Name: "TestGoDvlsApiKey", - Path: "go-dvls\\apikey", - Type: EntryCredentialType, - SubType: EntryCredentialSubTypeApiKey, - Description: "Test ApiKey entry", - Tags: []string{"apikey"}, - - Data: EntryCredentialApiKeyData{ - ApiId: "abcd1234-abcd-1234-abcd-1234abcd1234", - ApiKey: "123-abc", - TenantId: "00000000-aaaa-bbbb-cccc-000000000000", + }, + { + name: "ApiKey", + entryName: "Test API Key", + description: "Test API key entry", + subType: EntryCredentialSubTypeApiKey, + data: &EntryCredentialApiKeyData{ + ApiId: "test-api-id", + ApiKey: "test-api-key", + TenantId: "test-tenant", }, - } - - newCredentialApiKeyEntryId, err := testClient.Entries.Credential.New(testCredentialApiKeyEntry) - if err != nil { - t.Fatalf("Failed to create new ApiKey entry: %v", err) - } - - if newCredentialApiKeyEntryId == "" { - t.Fatal("New ApiKey entry Id is empty after creation.") - } - - testCredentialApiKeyEntryId = &newCredentialApiKeyEntryId - - // Credential/AzureServicePrincipal - testCredentialAzureServicePrincipalEntry := Entry{ - Id: "", - VaultId: testVaultId, - Name: "TestGoDvlsAzureServicePrincipal", - Path: "go-dvls\\azureserviceprincipal", - Type: EntryCredentialType, - SubType: EntryCredentialSubTypeAzureServicePrincipal, - Description: "Test AzureServicePrincipal entry", - Tags: []string{"azureserviceprincipal"}, - - Data: EntryCredentialAzureServicePrincipalData{ - ClientId: "abcd1234-abcd-1234-abcd-1234abcd1234", - ClientSecret: "123-abc", - TenantId: "00000000-aaaa-bbbb-cccc-000000000000", + updateData: func(entry *Entry) { + if data, ok := entry.GetCredentialApiKeyData(); ok { + data.ApiKey = "test-api-key-updated" + entry.Data = data + } }, - } - - newCredentialAzureServicePrincipalEntryId, err := testClient.Entries.Credential.New(testCredentialAzureServicePrincipalEntry) - if err != nil { - t.Fatalf("Failed to create new AzureServicePrincipal entry: %v", err) - } - - if newCredentialAzureServicePrincipalEntryId == "" { - t.Fatal("New AzureServicePrincipal entry Id is empty after creation.") - } - - testCredentialAzureServicePrincipalEntryId = &newCredentialAzureServicePrincipalEntryId - - // Credential/ConnectionString - testCredentialConnectionStringEntry := Entry{ - Id: "", - VaultId: testVaultId, - Name: "TestGoDvlsConnectionString", - Path: "go-dvls\\connectionstring", - Type: EntryCredentialType, - SubType: EntryCredentialSubTypeConnectionString, - Description: "Test ConnectionString entry", - Tags: []string{"connectionstring"}, - - Data: EntryCredentialConnectionStringData{ - ConnectionString: "Server=tcp:example.database.windows.net,1433;Initial Catalog=exampledb;Persist Security Info=False;User ID=exampleuser;Password=examplepassword;", + }, + { + name: "AzureServicePrincipal", + entryName: "Test Azure Service Principal", + description: "Test Azure service principal entry", + subType: EntryCredentialSubTypeAzureServicePrincipal, + data: &EntryCredentialAzureServicePrincipalData{ + ClientId: "test-client-id", + ClientSecret: "test-client-secret", + TenantId: "test-tenant-id", }, - } - - newCredentialConnectionStringEntryId, err := testClient.Entries.Credential.New(testCredentialConnectionStringEntry) - if err != nil { - t.Fatalf("Failed to create new ConnectionString entry: %v", err) - } - - if newCredentialConnectionStringEntryId == "" { - t.Fatal("New ConnectionString entry Id is empty after creation.") - } - - testCredentialConnectionStringEntryId = &newCredentialConnectionStringEntryId - - // Credential/Default - testCredentialDefaultEntry := Entry{ - VaultId: testVaultId, - Name: "TestGoDvlsUsernamePassword", - Path: "go-dvls\\usernamepassword", - Type: EntryCredentialType, - SubType: EntryCredentialSubTypeDefault, - Description: "Test Username/Password entry", - Tags: []string{"usernamepassword"}, - - Data: EntryCredentialDefaultData{ - Domain: "www.example.com", - Password: "abc-123", - Username: "john.doe", + updateData: func(entry *Entry) { + if data, ok := entry.GetCredentialAzureServicePrincipalData(); ok { + data.ClientSecret = "test-client-secret-updated" + entry.Data = data + } }, - } - - newCredentialDefaultEntryId, err := testClient.Entries.Credential.New(testCredentialDefaultEntry) - if err != nil { - t.Fatalf("Failed to create new Default entry: %v", err) - } - - if newCredentialDefaultEntryId == "" { - t.Fatal("New Default entry Id is empty after creation.") - } - - testCredentialDefaultEntryId = &newCredentialDefaultEntryId - - // Credential/PrivateKey - testCredentialPrivateKeyEntry := Entry{ - Id: "", - VaultId: testVaultId, - Name: "TestGoDvlsPrivateKey", - Path: "go-dvls\\privatekey", - Type: EntryCredentialType, - SubType: EntryCredentialSubTypePrivateKey, - Description: "Test Secret entry", - Tags: []string{"testtag"}, - - Data: EntryCredentialPrivateKeyData{ + }, + { + name: "ConnectionString", + entryName: "Test Connection String", + description: "Test connection string entry", + subType: EntryCredentialSubTypeConnectionString, + data: &EntryCredentialConnectionStringData{ + ConnectionString: "Server=localhost;Database=testdb;", + }, + updateData: func(entry *Entry) { + if data, ok := entry.GetCredentialConnectionStringData(); ok { + data.ConnectionString = "Server=localhost;Database=testdb;Encrypt=True;" + entry.Data = data + } + }, + }, + { + name: "Default", + entryName: "Test Username Password", + description: "Test username/password entry", + subType: EntryCredentialSubTypeDefault, + data: &EntryCredentialDefaultData{ + Domain: "example.com", + Username: "testuser", + Password: "testpass", + }, + updateData: func(entry *Entry) { + if data, ok := entry.GetCredentialDefaultData(); ok { + data.Password = "testpass-updated" + entry.Data = data + } + }, + }, + { + name: "PrivateKey", + entryName: "Test Private Key", + description: "Test private key entry", + subType: EntryCredentialSubTypePrivateKey, + data: &EntryCredentialPrivateKeyData{ Username: "testuser", - Password: "password", - PrivateKey: "-----BEGIN PRIVATE KEY-----\abcdefghijklmnopqrstuvwxyz1234567890...\n-----END PRIVATE", - PublicKey: "-----BEGIN PUBLIC KEY-----\abcdefghijklmnopqrstuvwxyz...\n-----END PUBLIC KEY-----", - Passphrase: "passphrase", + Password: "testpass", + PrivateKey: "-----BEGIN PRIVATE KEY-----\ntestkey\n-----END PRIVATE KEY-----", + PublicKey: "-----BEGIN PUBLIC KEY-----\ntestkey\n-----END PUBLIC KEY-----", + Passphrase: "testpassphrase", }, - } - - newCredentialPrivateKeyEntryId, err := testClient.Entries.Credential.New(testCredentialPrivateKeyEntry) - if err != nil { - t.Fatalf("Failed to create new PrivateKey entry: %v", err) - } - - if newCredentialPrivateKeyEntryId == "" { - t.Fatal("New PrivateKey entry Id is empty after creation.") - } - - testCredentialPrivateKeyEntryId = &newCredentialPrivateKeyEntryId -} - -func test_GetUserEntry(t *testing.T) { - // Credential/AccessCode - credentialAccessCodeEntry, err := testClient.Entries.Credential.GetById(testVaultId, *testCredentialAccessCodeEntryId) - if err != nil { - t.Fatalf("Failed to get AccessCode entry: %v", err) - } - - if credentialAccessCodeEntry.Id == "" { - t.Fatalf("AccessCode entry Id is empty after GET: %v", credentialAccessCodeEntry) - } - - testCredentialAccessCodeEntry = &credentialAccessCodeEntry - - // Credential/ApiKey - credentialApiKeyEntry, err := testClient.Entries.Credential.GetById(testVaultId, *testCredentialApiKeyEntryId) - if err != nil { - t.Fatalf("Failed to get ApiKey entry: %v", err) - } - - if credentialApiKeyEntry.Id == "" { - t.Fatalf("ApiKey entry Id is empty after GET: %v", credentialApiKeyEntry) - } - - testCredentialApiKeyEntry = &credentialApiKeyEntry - - // Credential/AzureServicePrincipal - credentialAzureServicePrincipalEntry, err := testClient.Entries.Credential.GetById(testVaultId, *testCredentialAzureServicePrincipalEntryId) - if err != nil { - t.Fatalf("Failed to get AzureServicePrincipal entry: %v", err) - } - - if credentialAzureServicePrincipalEntry.Id == "" { - t.Fatalf("AzureServicePrincipal entry Id is empty after GET: %v", credentialAzureServicePrincipalEntry) - } - - testCredentialAzureServicePrincipalEntry = &credentialAzureServicePrincipalEntry - - // Credential/ConnectionString - credentialConnectionStringEntry, err := testClient.Entries.Credential.GetById(testVaultId, *testCredentialConnectionStringEntryId) - if err != nil { - t.Fatalf("Failed to get ConnectionString entry: %v", err) - } - - if credentialConnectionStringEntry.Id == "" { - t.Fatalf("ConnectionString entry Id is empty after GET: %v", credentialConnectionStringEntry) - } - - testCredentialConnectionStringEntry = &credentialConnectionStringEntry - - // Credential/Default - credentialDefaultEntry, err := testClient.Entries.Credential.GetById(testVaultId, *testCredentialDefaultEntryId) - if err != nil { - t.Fatalf("Failed to get Default entry: %v", err) - } - - if credentialDefaultEntry.Id == "" { - t.Fatalf("Default entry Id is empty after GET: %v", credentialDefaultEntry) - } - - testCredentialDefaultEntry = &credentialDefaultEntry - - // Credential/PrivateKey - credentialPrivateKeyEntry, err := testClient.Entries.Credential.GetById(testVaultId, *testCredentialPrivateKeyEntryId) - if err != nil { - t.Fatalf("Failed to get PrivateKey entry: %v", err) - } - - if credentialPrivateKeyEntry.Id == "" { - t.Fatalf("PrivateKey entry Id is empty after GET: %v", credentialPrivateKeyEntry) - } - - testCredentialPrivateKeyEntry = &credentialPrivateKeyEntry + updateData: func(entry *Entry) { + if data, ok := entry.GetCredentialPrivateKeyData(); ok { + data.Passphrase = "testpassphrase-updated" + entry.Data = data + } + }, + }, } -func test_UpdateUserEntry(t *testing.T) { - // Credential/AccessCode - updatedCredentialAccessCodeEntry := *testCredentialAccessCodeEntry - updatedCredentialAccessCodeEntry.Name = updatedCredentialAccessCodeEntry.Name + "Updated" - updatedCredentialAccessCodeEntry.Path = updatedCredentialAccessCodeEntry.Path + "\\updated" - updatedCredentialAccessCodeEntry.Description = updatedCredentialAccessCodeEntry.Description + " updated" - updatedCredentialAccessCodeEntry.Tags = []string{"tag one", "tag two"} // testing multi-word tags - - updatedAccessCodeData, ok := updatedCredentialAccessCodeEntry.GetCredentialAccessCodeData() - if !ok { - t.Fatalf("Failed to get credential AccessCode data from entry: %v", updatedCredentialAccessCodeEntry) - } - updatedAccessCodeData.Password = updatedAccessCodeData.Password + "-updated" - updatedCredentialAccessCodeEntry.Data = updatedAccessCodeData - - updatedCredentialAccessCodeEntry, err := testClient.Entries.Credential.Update(updatedCredentialAccessCodeEntry) - if err != nil { - t.Fatalf("Failed to update AccessCode entry: %v", err) - } - - // Credential/ApiKey - updatedCredentialApiKeyEntry := *testCredentialApiKeyEntry - updatedCredentialApiKeyEntry.Name = updatedCredentialApiKeyEntry.Name + "Updated" - updatedCredentialApiKeyEntry.Path = updatedCredentialApiKeyEntry.Path + "\\updated" - updatedCredentialApiKeyEntry.Description = updatedCredentialApiKeyEntry.Description + " updated" - updatedCredentialApiKeyEntry.Tags = []string{"tag one", "tag two"} // testing multi-word tags - - updatedApiKeyData, ok := updatedCredentialApiKeyEntry.GetCredentialApiKeyData() - if !ok { - t.Fatalf("Failed to get credential ApiKey data from entry: %v", updatedCredentialApiKeyEntry) - } - - updatedApiKeyData.ApiKey = updatedApiKeyData.ApiKey + "-updated" - updatedCredentialApiKeyEntry.Data = updatedApiKeyData - - updatedCredentialApiKeyEntry, err = testClient.Entries.Credential.Update(updatedCredentialApiKeyEntry) - if err != nil { - t.Fatalf("Failed to update ApiKey entry: %v", err) - } - - // Credential/AzureServicePrincipal - updatedCredentialAzureServicePrincipalEntry := *testCredentialAzureServicePrincipalEntry - updatedCredentialAzureServicePrincipalEntry.Name = updatedCredentialAzureServicePrincipalEntry.Name + "Updated" - updatedCredentialAzureServicePrincipalEntry.Path = updatedCredentialAzureServicePrincipalEntry.Path + "\\updated" - updatedCredentialAzureServicePrincipalEntry.Description = updatedCredentialAzureServicePrincipalEntry.Description + " updated" - updatedCredentialAzureServicePrincipalEntry.Tags = []string{"tag one", "tag two"} // testing multi-word tags - - updatedAzureServicePrincipalData, ok := updatedCredentialAzureServicePrincipalEntry.GetCredentialAzureServicePrincipalData() - if !ok { - t.Fatalf("Failed to get credential AzureServicePrincipal data from entry: %v", updatedCredentialAzureServicePrincipalEntry) - } - - updatedAzureServicePrincipalData.ClientSecret = updatedAzureServicePrincipalData.ClientSecret + "-updated" - updatedCredentialAzureServicePrincipalEntry.Data = updatedAzureServicePrincipalData - - updatedCredentialAzureServicePrincipalEntry, err = testClient.Entries.Credential.Update(updatedCredentialAzureServicePrincipalEntry) - if err != nil { - t.Fatalf("Failed to update AzureServicePrincipal entry: %v", err) - } - - // Credential/ConnectionString - updatedCredentialConnectionStringEntry := *testCredentialConnectionStringEntry - updatedCredentialConnectionStringEntry.Name = updatedCredentialConnectionStringEntry.Name + "Updated" - updatedCredentialConnectionStringEntry.Path = updatedCredentialConnectionStringEntry.Path + "\\updated" - updatedCredentialConnectionStringEntry.Description = updatedCredentialConnectionStringEntry.Description + " updated" - updatedCredentialConnectionStringEntry.Tags = []string{"tag one", "tag two"} // testing multi-word tags - - updatedConnectionStringData, ok := updatedCredentialConnectionStringEntry.GetCredentialConnectionStringData() - if !ok { - t.Fatalf("Failed to get credential ConnectionString data from entry: %v", updatedCredentialConnectionStringEntry) - } - - updatedConnectionStringData.ConnectionString = updatedConnectionStringData.ConnectionString + "MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" - updatedCredentialConnectionStringEntry.Data = updatedConnectionStringData - - updatedCredentialConnectionStringEntry, err = testClient.Entries.Credential.Update(updatedCredentialConnectionStringEntry) - if err != nil { - t.Fatalf("Failed to update ConnectionString entry: %v", err) - } - - // Credential/Default - updatedCredentialDefaultEntry := *testCredentialDefaultEntry - updatedCredentialDefaultEntry.Name = updatedCredentialDefaultEntry.Name + "Updated" - updatedCredentialDefaultEntry.Path = updatedCredentialDefaultEntry.Path + "\\updated" - updatedCredentialDefaultEntry.Description = updatedCredentialDefaultEntry.Description + " updated" - updatedCredentialDefaultEntry.Tags = []string{"tag one", "tag two"} // testing multi-word tags - - updatedDefaultData, ok := updatedCredentialDefaultEntry.GetCredentialDefaultData() - if !ok { - t.Fatalf("Failed to get credential default data from entry: %v", updatedCredentialDefaultEntry) - } - updatedDefaultData.Password = updatedDefaultData.Password + "-updated" - updatedCredentialDefaultEntry.Data = updatedDefaultData - - updatedCredentialDefaultEntry, err = testClient.Entries.Credential.Update(updatedCredentialDefaultEntry) - if err != nil { - t.Fatalf("Failed to update entry: %v", err) - } - - // Credential/PrivateKey - updatedCredentialPrivateKeyEntry := *testCredentialPrivateKeyEntry - updatedCredentialPrivateKeyEntry.Name = updatedCredentialPrivateKeyEntry.Name + "Updated" - updatedCredentialPrivateKeyEntry.Path = updatedCredentialPrivateKeyEntry.Path + "\\updated" - updatedCredentialPrivateKeyEntry.Description = updatedCredentialPrivateKeyEntry.Description + " updated" - updatedCredentialPrivateKeyEntry.Tags = []string{"tag one", "tag two"} // testing multi-word tags - - updatedPrivateKeyData, ok := updatedCredentialPrivateKeyEntry.GetCredentialPrivateKeyData() - if !ok { - t.Fatalf("Failed to get credential access code data from entry: %v", updatedCredentialAccessCodeEntry) - } - updatedPrivateKeyData.Username = updatedPrivateKeyData.Username + "-updated" - updatedPrivateKeyData.Password = updatedPrivateKeyData.Password + "-updated" - updatedPrivateKeyData.Passphrase = updatedPrivateKeyData.Passphrase + "-updated" - updatedCredentialPrivateKeyEntry.Data = updatedPrivateKeyData - - updatedCredentialPrivateKeyEntry, err = testClient.Entries.Credential.Update(updatedCredentialPrivateKeyEntry) - if err != nil { - t.Fatalf("Failed to update entry: %v", err) +func Test_CredentialCRUD(t *testing.T) { + vault := createTestVault(t, "credentials") + + for _, tc := range credentialTestCases { + t.Run(tc.name, func(t *testing.T) { + testPath := "go-dvls\\credentials\\" + strings.ToLower(tc.name) + + // Create entry + t.Logf("Creating %s entry: %q", tc.subType, tc.entryName) + entry := Entry{ + VaultId: vault.Id, + Name: tc.entryName, + Path: testPath, + Type: EntryCredentialType, + SubType: tc.subType, + Description: tc.description, + Tags: []string{"test", strings.ToLower(tc.name)}, + Data: tc.data, + } + + id, err := testClient.Entries.Credential.New(entry) + require.NoError(t, err, "Failed to create %s entry", tc.name) + require.NotEmpty(t, id, "Entry ID should not be empty after creation") + t.Logf("Created entry with ID: %s", id) + + // Get entry + t.Logf("Fetching entry %s", id) + fetched, err := testClient.Entries.Credential.GetById(vault.Id, id) + require.NoError(t, err, "Failed to get %s entry", tc.name) + assert.Equal(t, entry.Name, fetched.Name) + assert.Equal(t, entry.Description, fetched.Description) + t.Logf("Fetched entry: Name=%q, Path=%q", fetched.Name, fetched.Path) + + // Update entry + newName := tc.entryName + " (Updated)" + newDescription := tc.description + " - modified" + t.Logf("Updating entry: %q -> %q", fetched.Name, newName) + fetched.Name = newName + fetched.Description = newDescription + fetched.Tags = []string{"test", "updated"} + tc.updateData(&fetched) + + updated, err := testClient.Entries.Credential.Update(fetched) + require.NoError(t, err, "Failed to update %s entry", tc.name) + assert.Equal(t, newName, updated.Name) + assert.Equal(t, newDescription, updated.Description) + t.Logf("Updated entry successfully") + + // Delete entry + t.Logf("Deleting entry %s", id) + err = testClient.Entries.Credential.DeleteById(vault.Id, id) + require.NoError(t, err, "Failed to delete %s entry", tc.name) + + // Verify deletion + _, err = testClient.Entries.Credential.GetById(vault.Id, id) + assert.Error(t, err, "Entry should not exist after deletion") + t.Logf("Entry deleted and verified") + }) } } -func test_DeleteUserEntry(t *testing.T) { - // Credential/AccessCode - err := testClient.Entries.Credential.Delete(*testCredentialAccessCodeEntry) - if err != nil { - t.Fatalf("Failed to delete AccessCode entry: %v", err) - } - - _, err = testClient.Entries.Credential.Get(*testCredentialAccessCodeEntry) - if err == nil { - t.Fatalf("AccessCode entry still exists after deletion: %s", *testCredentialAccessCodeEntryId) - } - - // Credential/ApiKey - err = testClient.Entries.Credential.Delete(*testCredentialApiKeyEntry) - if err != nil { - t.Fatalf("Failed to delete ApiKey entry: %v", err) - } - - _, err = testClient.Entries.Credential.Get(*testCredentialApiKeyEntry) - if err == nil { - t.Fatalf("ApiKey entry still exists after deletion: %s", *testCredentialApiKeyEntryId) - } - - // Credential/AzureServicePrincipal - err = testClient.Entries.Credential.Delete(*testCredentialAzureServicePrincipalEntry) - if err != nil { - t.Fatalf("Failed to delete AzureServicePrincipal entry: %v", err) - } - - _, err = testClient.Entries.Credential.Get(*testCredentialAzureServicePrincipalEntry) - if err == nil { - t.Fatalf("AzureServicePrincipal entry still exists after deletion: %s", *testCredentialAzureServicePrincipalEntryId) - } - - // Credential/ConnectionString - err = testClient.Entries.Credential.Delete(*testCredentialConnectionStringEntry) - if err != nil { - t.Fatalf("Failed to delete ConnectionString entry: %v", err) - } - - _, err = testClient.Entries.Credential.Get(*testCredentialConnectionStringEntry) - if err == nil { - t.Fatalf("ConnectionString entry still exists after deletion: %s", *testCredentialConnectionStringEntryId) - } - - // Credential/Default - err = testClient.Entries.Credential.Delete(*testCredentialDefaultEntry) - if err != nil { - t.Fatalf("Failed to delete Default entry: %v", err) - } - - _, err = testClient.Entries.Credential.Get(*testCredentialDefaultEntry) - if err == nil { - t.Fatalf("Default entry still exists after deletion: %s", *testCredentialDefaultEntryId) - } - - // Credential/PrivateKey - err = testClient.Entries.Credential.Delete(*testCredentialPrivateKeyEntry) - if err != nil { - t.Fatalf("Failed to delete PrivateKey entry: %v", err) +func Test_GetEntries(t *testing.T) { + vault := createTestVault(t, "getentries") + testPath := "go-dvls\\getentries" + + // Create 3 test entries - "Server" is exact match, others contain "Server" in name + entriesToCreate := []Entry{ + { + VaultId: vault.Id, + Name: "Server", + Path: testPath, + Type: EntryCredentialType, + SubType: EntryCredentialSubTypeDefault, + Description: "Exact match entry", + Data: &EntryCredentialDefaultData{Username: "testuser", Password: "testpass"}, + }, + { + VaultId: vault.Id, + Name: "Server Backup", + Path: testPath, + Type: EntryCredentialType, + SubType: EntryCredentialSubTypeDefault, + Description: "Contains Server in name", + Data: &EntryCredentialDefaultData{Username: "testuser", Password: "testpass"}, + }, + { + VaultId: vault.Id, + Name: "Server Production", + Path: testPath, + Type: EntryCredentialType, + SubType: EntryCredentialSubTypeDefault, + Description: "Contains Server in name", + Data: &EntryCredentialDefaultData{Username: "testuser", Password: "testpass"}, + }, } - _, err = testClient.Entries.Credential.Get(*testCredentialPrivateKeyEntry) - if err == nil { - t.Fatalf("PrivateKey entry still exists after deletion: %s", *testCredentialPrivateKeyEntryId) - } + // Create test entries + t.Log("Creating test entries for GetEntries") + var createdIds []string + for _, entry := range entriesToCreate { + id, err := testClient.Entries.Credential.New(entry) + require.NoError(t, err, "Failed to create entry %s", entry.Name) + createdIds = append(createdIds, id) + t.Logf("Created entry %q with ID: %s", entry.Name, id) + } + + // Test 1: GetEntries with path filter should return all 3 entries + t.Log("Test 1: GetEntries with path filter") + entries, err := testClient.Entries.Credential.GetEntries(vault.Id, "", testPath) + require.NoError(t, err, "GetEntries failed") + assert.Len(t, entries, 3, "Expected 3 entries with path filter") + t.Logf("Found %d entries in path %q", len(entries), testPath) + + // Test 2: GetEntries with exact name match - should return only "Server", not "Server Backup" or "Server Production" + t.Log("Test 2: GetEntries with exact name match") + entries, err = testClient.Entries.Credential.GetEntries(vault.Id, "Server", "") + require.NoError(t, err, "GetEntries with exact name failed") + assert.Len(t, entries, 1, "Expected 1 entry with exact name match") + if len(entries) > 0 { + assert.Equal(t, "Server", entries[0].Name) + t.Logf("Found exact match: %q", entries[0].Name) + } + + // Test 3: GetEntries with name and path filter + t.Log("Test 3: GetEntries with name and path filter") + entries, err = testClient.Entries.Credential.GetEntries(vault.Id, "Server Backup", testPath) + require.NoError(t, err, "GetEntries with name and path filter failed") + assert.Len(t, entries, 1, "Expected 1 entry with name and path filter") + t.Logf("Found %d entry with combined filters", len(entries)) + + // Test 4: GetEntries with non-existent name should return empty + t.Log("Test 4: GetEntries with non-existent name") + entries, err = testClient.Entries.Credential.GetEntries(vault.Id, "Non Existent Entry", testPath) + require.NoError(t, err, "GetEntries with non-existent name failed") + assert.Empty(t, entries, "Expected 0 entries for non-existent name") + t.Logf("Correctly returned %d entries for non-existent name", len(entries)) + + // Cleanup test entries + t.Log("Cleaning up test entries") + for _, id := range createdIds { + err := testClient.Entries.Credential.DeleteById(vault.Id, id) + require.NoError(t, err, "Failed to delete entry %s", id) + } + t.Log("Cleanup complete") } diff --git a/entry_folder.go b/entry_folder.go new file mode 100644 index 0000000..42e3f4a --- /dev/null +++ b/entry_folder.go @@ -0,0 +1,314 @@ +package dvls + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" +) + +const ( + EntryFolderType string = "Folder" + + // 16 subtypes (all have the same behavior, difference is UI only) + EntryFolderSubTypeCompany string = "Company" + EntryFolderSubTypeCredentials string = "Credentials" + EntryFolderSubTypeCustomer string = "Customer" + EntryFolderSubTypeDatabase string = "Database" + EntryFolderSubTypeDevice string = "Device" + EntryFolderSubTypeDomain string = "Domain" + EntryFolderSubTypeFolder string = "Folder" // default + EntryFolderSubTypeIdentity string = "Identity" + EntryFolderSubTypeMacroScriptTools string = "MacroScriptTools" + EntryFolderSubTypePrinter string = "Printer" + EntryFolderSubTypeServer string = "Server" + EntryFolderSubTypeSite string = "Site" + EntryFolderSubTypeSmartFolder string = "SmartFolder" + EntryFolderSubTypeSoftware string = "Software" + EntryFolderSubTypeTeam string = "Team" + EntryFolderSubTypeWorkstation string = "Workstation" +) + +type EntryFolderService service + +type EntryFolderData struct { + Domain string `json:"domain,omitempty"` + Username string `json:"username,omitempty"` +} + +func (e *Entry) GetFolderData() (*EntryFolderData, bool) { + if e == nil { + return nil, false + } + + data, ok := e.Data.(*EntryFolderData) + return data, ok +} + +// validateEntry checks if an Entry has the required fields and valid type/subtype. +func (c *EntryFolderService) validateEntry(entry *Entry) error { + if entry.VaultId == "" { + return fmt.Errorf("entry must have a VaultId") + } + + if entry.GetType() != EntryFolderType { + return fmt.Errorf("unsupported entry type (%s). Only %s is supported", entry.GetType(), EntryFolderType) + } + + supportedSubTypes := []string{ + EntryFolderSubTypeCompany, + EntryFolderSubTypeCredentials, + EntryFolderSubTypeCustomer, + EntryFolderSubTypeDatabase, + EntryFolderSubTypeDevice, + EntryFolderSubTypeDomain, + EntryFolderSubTypeFolder, + EntryFolderSubTypeIdentity, + EntryFolderSubTypeMacroScriptTools, + EntryFolderSubTypePrinter, + EntryFolderSubTypeServer, + EntryFolderSubTypeSite, + EntryFolderSubTypeSmartFolder, + EntryFolderSubTypeSoftware, + EntryFolderSubTypeTeam, + EntryFolderSubTypeWorkstation, + } + + subType := entry.GetSubType() + isSupported := false + for _, t := range supportedSubTypes { + if subType == t { + isSupported = true + break + } + } + + if !isSupported { + return fmt.Errorf("unsupported entry subtype (%s). Supported subtypes: %v", subType, supportedSubTypes) + } + + return nil +} + +// Get returns a single EntryFolder based on the entry's VaultId and Id. +func (c *EntryFolderService) Get(entry Entry) (Entry, error) { + return c.GetWithContext(context.Background(), entry) +} + +// GetWithContext returns a single EntryFolder based on the entry's VaultId and Id. +// The provided context can be used to cancel the request. +func (c *EntryFolderService) GetWithContext(ctx context.Context, entry Entry) (Entry, error) { + return c.GetByIdWithContext(ctx, entry.VaultId, entry.Id) +} + +// GetById returns a single EntryFolder based on vault Id and entry Id. +func (c *EntryFolderService) GetById(vaultId string, entryId string) (Entry, error) { + return c.GetByIdWithContext(context.Background(), vaultId, entryId) +} + +// GetByIdWithContext returns a single EntryFolder based on vault Id and entry Id. +// The provided context can be used to cancel the request. +func (c *EntryFolderService) GetByIdWithContext(ctx context.Context, vaultId string, entryId string) (Entry, error) { + if vaultId == "" || entryId == "" { + return Entry{}, fmt.Errorf("both entry Id and vault Id are required") + } + + var entry Entry + entryUri := entryPublicEndpointReplacer(vaultId, entryId) + + reqUrl, err := url.JoinPath(c.client.baseUri, entryUri) + if err != nil { + return Entry{}, fmt.Errorf("failed to build entry url. error: %w", err) + } + + resp, err := c.client.RequestWithContext(ctx, reqUrl, http.MethodGet, nil) + if err != nil { + return Entry{}, fmt.Errorf("error while fetching entry. error: %w", err) + } + + err = entry.UnmarshalJSON(resp.Response) + if err != nil { + return Entry{}, fmt.Errorf("failed to unmarshal response body. error: %w", err) + } + + entry.VaultId = vaultId + + return entry, nil +} + +// New creates a new EntryFolder and returns the new entry's Id. +func (c *EntryFolderService) New(entry Entry) (string, error) { + return c.NewWithContext(context.Background(), entry) +} + +// NewWithContext creates a new EntryFolder and returns the new entry's Id. +// The provided context can be used to cancel the request. +func (c *EntryFolderService) NewWithContext(ctx context.Context, entry Entry) (string, error) { + if err := c.validateEntry(&entry); err != nil { + return "", err + } + + newEntryRequest := struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Path string `json:"path,omitempty"` + Type string `json:"type"` + SubType string `json:"subType"` + Tags []string `json:"tags,omitempty"` + Data EntryData `json:"data"` + }{ + Name: entry.Name, + Description: entry.Description, + Path: entry.Path, + Type: entry.GetType(), + SubType: entry.GetSubType(), + Tags: entry.Tags, + Data: entry.Data, + } + + baseEntryEndpoint := entryPublicBaseEndpointReplacer(entry.VaultId) + reqUrl, err := url.JoinPath(c.client.baseUri, baseEntryEndpoint) + if err != nil { + return "", fmt.Errorf("failed to build entry url. error: %w", err) + } + + body, err := json.Marshal(newEntryRequest) + if err != nil { + return "", fmt.Errorf("failed to marshal body. error: %w", err) + } + + resp, err := c.client.RequestWithContext(ctx, reqUrl, http.MethodPost, bytes.NewBuffer(body)) + if err != nil { + return "", fmt.Errorf("error while creating entry. error: %w", err) + } + + newEntryResponse := struct { + Id string `json:"id"` + }{} + + err = json.Unmarshal(resp.Response, &newEntryResponse) + if err != nil { + return "", fmt.Errorf("failed to unmarshal response body. error: %w", err) + } + return newEntryResponse.Id, nil +} + +// Update updates an EntryFolder and returns the updated entry. +func (c *EntryFolderService) Update(entry Entry) (Entry, error) { + return c.UpdateWithContext(context.Background(), entry) +} + +// UpdateWithContext updates an EntryFolder and returns the updated entry. +// The provided context can be used to cancel the request. +func (c *EntryFolderService) UpdateWithContext(ctx context.Context, entry Entry) (Entry, error) { + if err := c.validateEntry(&entry); err != nil { + return Entry{}, err + } + + if entry.Id == "" { + return Entry{}, fmt.Errorf("entry Id is required for updates") + } + + updateEntryRequest := struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Path string `json:"path,omitempty"` + Tags []string `json:"tags,omitempty"` + Data EntryData `json:"data"` + }{ + Name: entry.Name, + Description: entry.Description, + Path: entry.Path, + Tags: entry.Tags, + Data: entry.Data, + } + + entryUri := entryPublicEndpointReplacer(entry.VaultId, entry.Id) + reqUrl, err := url.JoinPath(c.client.baseUri, entryUri) + if err != nil { + return Entry{}, fmt.Errorf("failed to build entry url. error: %w", err) + } + + body, err := json.Marshal(updateEntryRequest) + if err != nil { + return Entry{}, fmt.Errorf("failed to marshal body. error: %w", err) + } + + _, err = c.client.RequestWithContext(ctx, reqUrl, http.MethodPut, bytes.NewBuffer(body)) + if err != nil { + return Entry{}, fmt.Errorf("error while updating entry. error: %w", err) + } + + entry, err = c.GetByIdWithContext(ctx, entry.VaultId, entry.Id) + if err != nil { + return Entry{}, fmt.Errorf("update succeeded but failed to fetch updated entry: %w", err) + } + + return entry, nil +} + +// Delete deletes an entry based on the entry's VaultId and Id. +func (c *EntryFolderService) Delete(e Entry) error { + return c.DeleteWithContext(context.Background(), e) +} + +// DeleteWithContext deletes an entry based on the entry's VaultId and Id. +// The provided context can be used to cancel the request. +func (c *EntryFolderService) DeleteWithContext(ctx context.Context, e Entry) error { + return c.DeleteByIdWithContext(ctx, e.VaultId, e.Id) +} + +// DeleteById deletes an entry based on vault Id and entry Id. +func (c *EntryFolderService) DeleteById(vaultId string, entryId string) error { + return c.DeleteByIdWithContext(context.Background(), vaultId, entryId) +} + +// DeleteByIdWithContext deletes an entry based on vault Id and entry Id. +// The provided context can be used to cancel the request. +func (c *EntryFolderService) DeleteByIdWithContext(ctx context.Context, vaultId string, entryId string) error { + if vaultId == "" || entryId == "" { + return fmt.Errorf("both entry Id and vault Id are required") + } + + entryUri := entryPublicEndpointReplacer(vaultId, entryId) + reqUrl, err := url.JoinPath(c.client.baseUri, entryUri) + if err != nil { + return fmt.Errorf("failed to build delete entry url. error: %w", err) + } + + _, err = c.client.RequestWithContext(ctx, reqUrl, http.MethodDelete, nil) + if err != nil { + return fmt.Errorf("error while deleting entry. error: %w", err) + } + + return nil +} + +// GetEntries returns a list of folder entries from a vault with optional name and path filters. +func (c *EntryFolderService) GetEntries(vaultId, name, path string) ([]Entry, error) { + return c.GetEntriesWithContext(context.Background(), vaultId, name, path) +} + +// GetEntriesWithContext returns a list of folder entries from a vault with optional name and path filters. +// The provided context can be used to cancel the request. +func (c *EntryFolderService) GetEntriesWithContext(ctx context.Context, vaultId, name, path string) ([]Entry, error) { + entries, err := c.client.getEntries(ctx, vaultId, getEntriesOptions{ + Name: name, + Path: path, + }) + if err != nil { + return nil, err + } + + // Filter only Folder type entries + var folders []Entry + for _, entry := range entries { + if entry.GetType() == EntryFolderType { + folders = append(folders, entry) + } + } + + return folders, nil +} diff --git a/entry_folder_test.go b/entry_folder_test.go new file mode 100644 index 0000000..836b680 --- /dev/null +++ b/entry_folder_test.go @@ -0,0 +1,197 @@ +package dvls + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// All folder subtypes to test +var folderSubTypes = []string{ + EntryFolderSubTypeCompany, + EntryFolderSubTypeCredentials, + EntryFolderSubTypeCustomer, + EntryFolderSubTypeDatabase, + EntryFolderSubTypeDevice, + EntryFolderSubTypeDomain, + EntryFolderSubTypeFolder, + EntryFolderSubTypeIdentity, + EntryFolderSubTypeMacroScriptTools, + EntryFolderSubTypePrinter, + EntryFolderSubTypeServer, + EntryFolderSubTypeSite, + EntryFolderSubTypeSmartFolder, + EntryFolderSubTypeSoftware, + EntryFolderSubTypeTeam, + EntryFolderSubTypeWorkstation, +} + +func Test_FolderCRUD(t *testing.T) { + vault := createTestVault(t, "folders") + + for _, subType := range folderSubTypes { + t.Run(subType, func(t *testing.T) { + testPath := "" + entryName := fmt.Sprintf("Test %s Folder", subType) + description := fmt.Sprintf("Test %s folder entry", strings.ToLower(subType)) + + // Initial data with domain and username + initialDomain := fmt.Sprintf("%s.local", strings.ToLower(subType)) + initialUsername := fmt.Sprintf("%s-user", strings.ToLower(subType)) + + // Create entry + t.Logf("Creating %s folder with domain=%q, username=%q", subType, initialDomain, initialUsername) + entry := Entry{ + VaultId: vault.Id, + Name: entryName, + Path: testPath, + Type: EntryFolderType, + SubType: subType, + Description: description, + Tags: []string{"test", strings.ToLower(subType)}, + Data: &EntryFolderData{ + Domain: initialDomain, + Username: initialUsername, + }, + } + + id, err := testClient.Entries.Folder.New(entry) + require.NoError(t, err, "Failed to create %s folder", subType) + require.NotEmpty(t, id, "Entry ID should not be empty after creation") + t.Logf("Created folder with ID: %s", id) + + // Get entry and verify domain/username + t.Logf("Fetching folder %s", id) + fetched, err := testClient.Entries.Folder.GetById(vault.Id, id) + require.NoError(t, err, "Failed to get %s folder", subType) + assert.Equal(t, entry.Name, fetched.Name) + assert.Equal(t, entry.Description, fetched.Description) + assert.Equal(t, EntryFolderType, fetched.Type, "Type should be Folder") + assert.Equal(t, subType, fetched.SubType, "SubType should match") + t.Logf("Verified type=%q, subType=%q", fetched.Type, fetched.SubType) + + // Verify data fields after creation + data, ok := fetched.GetFolderData() + require.True(t, ok, "Expected EntryFolderData type") + assert.Equal(t, initialDomain, data.Domain, "Domain should match after creation") + assert.Equal(t, initialUsername, data.Username, "Username should match after creation") + t.Logf("Verified data: domain=%q, username=%q", data.Domain, data.Username) + + // Update entry with new domain and username + updatedDomain := fmt.Sprintf("updated.%s.local", strings.ToLower(subType)) + updatedUsername := fmt.Sprintf("updated-%s-user", strings.ToLower(subType)) + newName := entryName + " (Updated)" + newDescription := description + " - modified" + + t.Logf("Updating folder: domain=%q->%q, username=%q->%q", initialDomain, updatedDomain, initialUsername, updatedUsername) + fetched.Name = newName + fetched.Description = newDescription + fetched.Tags = []string{"test", "updated"} + fetched.Data = &EntryFolderData{ + Domain: updatedDomain, + Username: updatedUsername, + } + + updated, err := testClient.Entries.Folder.Update(fetched) + require.NoError(t, err, "Failed to update %s folder", subType) + assert.Equal(t, newName, updated.Name) + assert.Equal(t, newDescription, updated.Description) + + // Verify data fields after update + updatedData, ok := updated.GetFolderData() + require.True(t, ok, "Expected EntryFolderData type after update") + assert.Equal(t, updatedDomain, updatedData.Domain, "Domain should match after update") + assert.Equal(t, updatedUsername, updatedData.Username, "Username should match after update") + t.Logf("Verified updated data: domain=%q, username=%q", updatedData.Domain, updatedData.Username) + + // Delete entry + err = testClient.Entries.Folder.DeleteById(vault.Id, id) + require.NoError(t, err, "Failed to delete %s folder", subType) + + // Verify deletion + _, err = testClient.Entries.Folder.GetById(vault.Id, id) + require.Error(t, err, "Entry should no longer exist after deletion") + }) + } +} + +func Test_NestedFolders(t *testing.T) { + vault := createTestVault(t, "nested-folders") + + // Create parent folder at root + parentEntry := Entry{ + VaultId: vault.Id, + Name: "Parent Folder", + Path: "", + Type: EntryFolderType, + SubType: EntryFolderSubTypeFolder, + Description: "Parent folder", + Data: &EntryFolderData{}, + } + + parentId, err := testClient.Entries.Folder.New(parentEntry) + require.NoError(t, err, "Failed to create parent folder") + t.Logf("Created parent folder with ID: %s", parentId) + + // Fetch parent + parent, err := testClient.Entries.Folder.GetById(vault.Id, parentId) + require.NoError(t, err, "Failed to fetch parent folder") + t.Logf("Parent folder: Name=%q, Path=%q", parent.Name, parent.Path) + + // Create child folder inside parent + childEntry := Entry{ + VaultId: vault.Id, + Name: "Child Folder", + Path: parent.Name, + Type: EntryFolderType, + SubType: EntryFolderSubTypeServer, + Description: "Child folder inside parent", + Data: &EntryFolderData{}, + } + + childId, err := testClient.Entries.Folder.New(childEntry) + require.NoError(t, err, "Failed to create child folder") + t.Logf("Created child folder with ID: %s", childId) + + // Fetch child and verify + child, err := testClient.Entries.Folder.GetById(vault.Id, childId) + require.NoError(t, err, "Failed to fetch child folder") + t.Logf("Child folder: Name=%q, Path=%q, SubType=%q", child.Name, child.Path, child.SubType) + + assert.Equal(t, "Child Folder", child.Name) + assert.Equal(t, EntryFolderSubTypeServer, child.SubType) + + // Create grandchild folder inside child + grandchildEntry := Entry{ + VaultId: vault.Id, + Name: "Grandchild Folder", + Path: fmt.Sprintf("%s\\%s", parent.Name, child.Name), + Type: EntryFolderType, + SubType: EntryFolderSubTypeDatabase, + Description: "Grandchild folder inside child", + Data: &EntryFolderData{}, + } + + grandchildId, err := testClient.Entries.Folder.New(grandchildEntry) + require.NoError(t, err, "Failed to create grandchild folder") + t.Logf("Created grandchild folder with ID: %s", grandchildId) + + // Fetch grandchild and verify + grandchild, err := testClient.Entries.Folder.GetById(vault.Id, grandchildId) + require.NoError(t, err, "Failed to fetch grandchild folder") + t.Logf("Grandchild folder: Name=%q, Path=%q, SubType=%q", grandchild.Name, grandchild.Path, grandchild.SubType) + + assert.Equal(t, "Grandchild Folder", grandchild.Name) + assert.Equal(t, EntryFolderSubTypeDatabase, grandchild.SubType) + + // Delete entries (in reverse order) + err = testClient.Entries.Folder.DeleteById(vault.Id, grandchildId) + require.NoError(t, err, "Failed to delete grandchild folder") + err = testClient.Entries.Folder.DeleteById(vault.Id, childId) + require.NoError(t, err, "Failed to delete child folder") + err = testClient.Entries.Folder.DeleteById(vault.Id, parentId) + require.NoError(t, err, "Failed to delete parent folder") +} diff --git a/entry_host_test.go b/entry_host_test.go index c1850fb..3aec7cf 100644 --- a/entry_host_test.go +++ b/entry_host_test.go @@ -23,6 +23,10 @@ const ( ) func Test_EntryHost(t *testing.T) { + if testVaultId == "" { + t.Skip("Skipping legacy API test: TEST_VAULT_ID not set") + } + testHostEntryId = os.Getenv("TEST_HOST_ENTRY_ID") testHostEntry.Id = testHostEntryId testHostEntry.VaultId = testVaultId diff --git a/entry_website_test.go b/entry_website_test.go index 8d0bbc0..75dd7c1 100644 --- a/entry_website_test.go +++ b/entry_website_test.go @@ -25,6 +25,10 @@ const ( ) func Test_EntryWebsite(t *testing.T) { + if testVaultId == "" { + t.Skip("Skipping legacy API test: TEST_VAULT_ID not set") + } + testWebsiteEntryId = os.Getenv("TEST_WEBSITE_ENTRY_ID") testWebsiteEntry.Id = testWebsiteEntryId testWebsiteEntry.VaultId = testVaultId diff --git a/go.mod b/go.mod index 164036a..e3758c1 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,11 @@ module github.com/Devolutions/go-dvls go 1.20 + +require github.com/stretchr/testify v1.11.1 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c4c1710 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/helpers_test.go b/helpers_test.go new file mode 100644 index 0000000..88b839a --- /dev/null +++ b/helpers_test.go @@ -0,0 +1,33 @@ +package dvls + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// createTestVault creates a vault for testing and registers cleanup. +// The vault name reflects the test being performed. +func createTestVault(t *testing.T, name string) Vault { + t.Helper() + vault, err := testClient.Vaults.New(Vault{ + Name: fmt.Sprintf("test-%s", name), + Description: "Auto-created test vault", + ContentType: VaultContentTypeEverything, + SecurityLevel: VaultSecurityLevelStandard, + Visibility: VaultVisibilityDefault, + }) + + require.NoError(t, err) + + // Wait for vault to be fully indexed + time.Sleep(2 * time.Second) + + t.Cleanup(func() { + testClient.Vaults.Delete(vault.Id) + }) + + return vault +} diff --git a/vaults.go b/vaults.go index b2ade52..15da27d 100644 --- a/vaults.go +++ b/vaults.go @@ -9,120 +9,96 @@ import ( "net/url" ) -type Vaults service +type VaultVisibility string -// Vault represents a DVLS vault. Contains relevant vault information. -type Vault struct { - Id string - Name string - Description string - SecurityLevel VaultSecurityLevel - Visibility VaultVisibility - CreationDate *ServerTime - ModifiedDate *ServerTime - password *string -} - -type VaultOptions struct { - Password *string -} +const ( + VaultVisibilityDefault VaultVisibility = "Default" + VaultVisibilityPrivate VaultVisibility = "Never" + VaultVisibilityPublic VaultVisibility = "Everyone" +) -type rawVault struct { - Description string `json:"description"` - Id string `json:"id"` - IdString string `json:"idString"` - Image string `json:"image"` - ImageBytes string `json:"imageBytes"` - ImageName string `json:"imageName"` - IsAllowedOffline bool `json:"isAllowedOffline"` - IsLocked bool `json:"isLocked"` - IsPrivate bool `json:"isPrivate"` - Password *string `json:"password,omitempty"` - HasPasswordChanged *bool `json:"hasPasswordChanged,omitempty"` - ModifiedLoggedUserName string `json:"modifiedLoggedUserName"` - ModifiedUserName string `json:"modifiedUserName"` - Name string `json:"name"` - RepositorySettings struct { - QuickAddEntries [0]struct{} `json:"quickAddEntries"` - IsPasswordProtected bool `json:"isPasswordProtected"` - MasterPasswordHash *string `json:"masterPasswordHash,omitempty"` - VaultSecurityLevel *int `json:"vaultSecurityLevel,omitempty"` - VaultAllowAccessRequestRole int `json:"vaultAllowAccessRequestRole"` - VaultType int `json:"vaultType"` - } `json:"repositorySettings"` - Selected bool `json:"selected"` -} +type VaultSecurityLevel string -// UnmarshalJSON implements the json.Unmarshaler interface. -func (v *Vault) UnmarshalJSON(b []byte) error { - var raw struct { - Data rawVault - } +const ( + VaultSecurityLevelStandard VaultSecurityLevel = "Standard" + VaultSecurityLevelHigh VaultSecurityLevel = "High" +) - err := json.Unmarshal(b, &raw) - if err != nil { - return err - } +type VaultContentType string - var securityLevel VaultSecurityLevel +const ( + VaultContentTypeEverything VaultContentType = "Everything" + VaultContentTypeDefault VaultContentType = "Default" // Equivalent to Everything, used by system vaults (Default, User vault) + VaultContentTypeSecrets VaultContentType = "Secrets" + VaultContentTypeCredentials VaultContentType = "Credentials" + VaultContentTypeBusinessInformation VaultContentType = "BusinessInformation" +) - if raw.Data.RepositorySettings.VaultSecurityLevel != nil { - securityLevel = VaultSecurityLevel(*raw.Data.RepositorySettings.VaultSecurityLevel) - } +type Vaults service - vault := Vault{ - Id: raw.Data.Id, - Name: raw.Data.Name, - Description: raw.Data.Description, - SecurityLevel: securityLevel, - Visibility: VaultVisibility(raw.Data.RepositorySettings.VaultAllowAccessRequestRole), - } +// Vault represents a DVLS vault. +type Vault struct { + Id string `json:"id,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + ContentType VaultContentType `json:"contentType"` + Type string `json:"type,omitempty"` + SecurityLevel VaultSecurityLevel `json:"securityLevel"` + Visibility VaultVisibility `json:"visibility"` +} - *v = vault +// vaultListResponse represents the paginated response from the vault list endpoint. +type vaultListResponse struct { + Data []Vault `json:"data"` + CurrentPage int `json:"currentPage"` + PageSize int `json:"pageSize"` + TotalCount int `json:"totalCount"` + TotalPage int `json:"totalPage"` +} - return nil +// vaultRequest represents the request body for create/update operations. +type vaultRequest struct { + Name string `json:"name"` + Description string `json:"description"` + ContentType VaultContentType `json:"contentType"` + SecurityLevel VaultSecurityLevel `json:"securityLevel"` + Visibility VaultVisibility `json:"visibility"` } -// MarshalJSON implements the json.Marshaler interface. -func (v Vault) MarshalJSON() ([]byte, error) { - var raw rawVault +const ( + vaultEndpoint string = "/api/v1/vault" +) - securityLevel := 1 +var ErrVaultNotFound = fmt.Errorf("vault not found") +var ErrMultipleVaultsFound = fmt.Errorf("multiple vaults found") - if v.SecurityLevel == VaultSecurityLevelHigh { - securityLevel = 0 - raw.RepositorySettings.VaultType = 1 - } +// List returns all vaults. +func (c *Vaults) List() ([]Vault, error) { + return c.ListWithContext(context.Background()) +} - if v.password != nil { - raw.Password = v.password - hasPasswordChanged := true - raw.HasPasswordChanged = &hasPasswordChanged +// ListWithContext returns all vaults. +// The provided context can be used to cancel the request. +func (c *Vaults) ListWithContext(ctx context.Context) ([]Vault, error) { + reqUrl, err := url.JoinPath(c.client.baseUri, vaultEndpoint) + if err != nil { + return nil, fmt.Errorf("failed to build vault url: %w", err) } - raw.Name = v.Name - raw.Description = v.Description - raw.Id = v.Id - raw.IdString = v.Id - raw.RepositorySettings.VaultSecurityLevel = &securityLevel - raw.RepositorySettings.VaultAllowAccessRequestRole = int(v.Visibility) - - if v.SecurityLevel == VaultSecurityLevelStandard { - raw.IsAllowedOffline = true + resp, err := c.client.RequestWithContext(ctx, reqUrl, http.MethodGet, nil) + if err != nil { + return nil, fmt.Errorf("error while fetching vaults: %w", err) } - json, err := json.Marshal(raw) + var listResp vaultListResponse + err = json.Unmarshal(resp.Response, &listResp) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to unmarshal response body: %w", err) } - return json, nil + return listResp.Data, nil } -const ( - vaultEndpoint string = "/api/security/repositories" -) - // Get returns a single Vault based on vaultId. func (c *Vaults) Get(vaultId string) (Vault, error) { return c.GetWithContext(context.Background(), vaultId) @@ -134,124 +110,160 @@ func (c *Vaults) GetWithContext(ctx context.Context, vaultId string) (Vault, err var vault Vault reqUrl, err := url.JoinPath(c.client.baseUri, vaultEndpoint, vaultId) if err != nil { - return Vault{}, fmt.Errorf("failed to build vault url. error: %w", err) + return Vault{}, fmt.Errorf("failed to build vault url: %w", err) } resp, err := c.client.RequestWithContext(ctx, reqUrl, http.MethodGet, nil) if err != nil { - return Vault{}, fmt.Errorf("error while fetching vault. error: %w", err) - } else if err = resp.CheckRespSaveResult(); err != nil { - return Vault{}, err + return Vault{}, fmt.Errorf("error while fetching vault: %w", err) } err = json.Unmarshal(resp.Response, &vault) if err != nil { - return Vault{}, fmt.Errorf("failed to unmarshal response body. error: %w", err) + return Vault{}, fmt.Errorf("failed to unmarshal response body: %w", err) } return vault, nil } -// New creates a new Vault based on vault. -func (c *Vaults) New(vault Vault, options *VaultOptions) error { - return c.NewWithContext(context.Background(), vault, options) +// GetByName returns a single Vault based on name. +// Returns ErrVaultNotFound if no vault is found. +// Returns ErrMultipleVaultsFound if more than one vault matches the name. +func (c *Vaults) GetByName(name string) (Vault, error) { + return c.GetByNameWithContext(context.Background(), name) } -// NewWithContext creates a new Vault based on vault. +// GetByNameWithContext returns a single Vault based on name. +// Returns ErrVaultNotFound if no vault is found. +// Returns ErrMultipleVaultsFound if more than one vault matches the name. // The provided context can be used to cancel the request. -func (c *Vaults) NewWithContext(ctx context.Context, vault Vault, options *VaultOptions) error { - reqUrl, err := url.JoinPath(c.client.baseUri, vaultEndpoint) +func (c *Vaults) GetByNameWithContext(ctx context.Context, name string) (Vault, error) { + vaults, err := c.ListWithContext(ctx) if err != nil { - return fmt.Errorf("failed to build vault url. error: %w", err) + return Vault{}, err } - vault.CreationDate = nil - vault.ModifiedDate = nil - - if options != nil { - vault.password = options.Password + var matches []Vault + for _, v := range vaults { + if v.Name == name { + matches = append(matches, v) + } } - vaultJson, err := json.Marshal(vault) - if err != nil { - return fmt.Errorf("failed to marshal body. error: %w", err) + if len(matches) == 0 { + return Vault{}, ErrVaultNotFound } - resp, err := c.client.RequestWithContext(ctx, reqUrl, http.MethodPut, bytes.NewBuffer(vaultJson)) - if err != nil { - return fmt.Errorf("error while creating vault. error: %w", err) - } else if err = resp.CheckRespSaveResult(); err != nil { - return err + if len(matches) > 1 { + return Vault{}, ErrMultipleVaultsFound } - return nil + return matches[0], nil } -// Update updates a Vault based on vault. -func (c *Vaults) Update(vault Vault, options *VaultOptions) error { - return c.UpdateWithContext(context.Background(), vault, options) +// New creates a new Vault and returns the created vault. +func (c *Vaults) New(vault Vault) (Vault, error) { + return c.NewWithContext(context.Background(), vault) } -// UpdateWithContext updates a Vault based on vault. +// NewWithContext creates a new Vault and returns the created vault. // The provided context can be used to cancel the request. -func (c *Vaults) UpdateWithContext(ctx context.Context, vault Vault, options *VaultOptions) error { - _, err := c.client.Vaults.GetWithContext(ctx, vault.Id) +func (c *Vaults) NewWithContext(ctx context.Context, vault Vault) (Vault, error) { + reqUrl, err := url.JoinPath(c.client.baseUri, vaultEndpoint) if err != nil { - return fmt.Errorf("error while fetching vault. error: %w", err) + return Vault{}, fmt.Errorf("failed to build vault url: %w", err) } - err = c.client.Vaults.NewWithContext(ctx, vault, options) + // Convert Default to Everything (API rejects "Default" for creation) + contentType := vault.ContentType + if contentType == VaultContentTypeDefault { + contentType = VaultContentTypeEverything + } + + reqBody := vaultRequest{ + Name: vault.Name, + Description: vault.Description, + ContentType: contentType, + SecurityLevel: vault.SecurityLevel, + Visibility: vault.Visibility, + } + + vaultJson, err := json.Marshal(reqBody) if err != nil { - return fmt.Errorf("error while updating vault. error: %w", err) + return Vault{}, fmt.Errorf("failed to marshal body: %w", err) } - return nil + resp, err := c.client.RequestWithContext(ctx, reqUrl, http.MethodPost, bytes.NewBuffer(vaultJson)) + if err != nil { + return Vault{}, fmt.Errorf("error while creating vault: %w", err) + } + + var createdVault Vault + err = json.Unmarshal(resp.Response, &createdVault) + if err != nil { + return Vault{}, fmt.Errorf("failed to unmarshal response body: %w", err) + } + + return createdVault, nil } -// Delete deletes a Vault based on vaultId. -func (c *Vaults) Delete(vaultId string) error { - return c.DeleteWithContext(context.Background(), vaultId) +// Update updates an existing Vault and returns the updated vault. +func (c *Vaults) Update(vault Vault) (Vault, error) { + return c.UpdateWithContext(context.Background(), vault) } -// DeleteWithContext deletes a Vault based on vaultId. +// UpdateWithContext updates an existing Vault and returns the updated vault. // The provided context can be used to cancel the request. -func (c *Vaults) DeleteWithContext(ctx context.Context, vaultId string) error { - reqUrl, err := url.JoinPath(c.client.baseUri, vaultEndpoint, vaultId) +func (c *Vaults) UpdateWithContext(ctx context.Context, vault Vault) (Vault, error) { + reqUrl, err := url.JoinPath(c.client.baseUri, vaultEndpoint, vault.Id) if err != nil { - return fmt.Errorf("failed to delete vault url. error: %w", err) + return Vault{}, fmt.Errorf("failed to build vault url: %w", err) } - resp, err := c.client.RequestWithContext(ctx, reqUrl, http.MethodDelete, nil) + reqBody := vaultRequest{ + Name: vault.Name, + Description: vault.Description, + ContentType: vault.ContentType, + SecurityLevel: vault.SecurityLevel, + Visibility: vault.Visibility, + } + + vaultJson, err := json.Marshal(reqBody) if err != nil { - return fmt.Errorf("error while deleting vault. error: %w", err) - } else if err = resp.CheckRespSaveResult(); err != nil { - return err + return Vault{}, fmt.Errorf("failed to marshal body: %w", err) } - return nil + resp, err := c.client.RequestWithContext(ctx, reqUrl, http.MethodPut, bytes.NewBuffer(vaultJson)) + if err != nil { + return Vault{}, fmt.Errorf("error while updating vault: %w", err) + } + + var updatedVault Vault + err = json.Unmarshal(resp.Response, &updatedVault) + if err != nil { + return Vault{}, fmt.Errorf("failed to unmarshal response body: %w", err) + } + + return updatedVault, nil } -// ValidatePassword validates a Vault password based on vaultId and password. -func (c *Vaults) ValidatePassword(vaultId string, password string) (bool, error) { - return c.ValidatePasswordWithContext(context.Background(), vaultId, password) +// Delete deletes a Vault based on vaultId. +func (c *Vaults) Delete(vaultId string) error { + return c.DeleteWithContext(context.Background(), vaultId) } -// ValidatePasswordWithContext validates a Vault password based on vaultId and password. +// DeleteWithContext deletes a Vault based on vaultId. // The provided context can be used to cancel the request. -func (c *Vaults) ValidatePasswordWithContext(ctx context.Context, vaultId string, password string) (bool, error) { - reqUrl, err := url.JoinPath(c.client.baseUri, vaultEndpoint, vaultId, "login") +func (c *Vaults) DeleteWithContext(ctx context.Context, vaultId string) error { + reqUrl, err := url.JoinPath(c.client.baseUri, vaultEndpoint, vaultId) if err != nil { - return false, fmt.Errorf("failed to build vault url. error: %w", err) + return fmt.Errorf("failed to build vault url: %w", err) } - resp, err := c.client.RequestWithContext(ctx, reqUrl, http.MethodPost, bytes.NewBufferString(fmt.Sprintf("\"%s\"", password))) + _, err = c.client.RequestWithContext(ctx, reqUrl, http.MethodDelete, nil) if err != nil { - return false, fmt.Errorf("error while fetching vault. error: %w", err) - } else if resp.Result == uint8(SaveResultAccessDenied) { - return false, nil - } else if err = resp.CheckRespSaveResult(); err != nil { - return false, err + return fmt.Errorf("error while deleting vault: %w", err) } - return true, nil + return nil } diff --git a/vaults_test.go b/vaults_test.go index f7532f1..f63a875 100644 --- a/vaults_test.go +++ b/vaults_test.go @@ -1,101 +1,236 @@ package dvls import ( - "reflect" + "errors" "testing" -) - -const testNewVaultId string = "eabd3646-acf8-44a4-9ba0-991df147c209" -var testNewVaultPassword string = "5w:mr6kPj" - -var testVault Vault = Vault{ - Name: "go-dvls", - Description: "Test Vault", -} - -var testNewVault Vault = Vault{ - Id: testNewVaultId, - Name: "go-dvls new", - Description: "Test", -} + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) func Test_Vaults(t *testing.T) { - testVault.Id = testVaultId + t.Run("ListVaults", test_ListVaults) t.Run("GetVault", test_GetVault) + t.Run("GetVaultByName", test_GetVaultByName) + t.Run("GetVaultByName_NotFound", test_GetVaultByName_NotFound) t.Run("NewVault", test_NewVault) t.Run("UpdateVault", test_UpdateVault) - t.Run("DeleteVault", test_DeleteVault) + t.Run("ContentType_DefaultEquivalence", test_ContentType_DefaultEquivalence) +} + +func test_ListVaults(t *testing.T) { + vaults, err := testClient.Vaults.List() + require.NoError(t, err) + assert.NotEmpty(t, vaults) + + found := false + for _, v := range vaults { + if v.Id == testVaultId { + found = true + break + } + } + assert.True(t, found, "expected test vault to be in the list") } func test_GetVault(t *testing.T) { vault, err := testClient.Vaults.Get(testVaultId) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err) + assert.NotEmpty(t, vault.Id) + assert.NotEmpty(t, vault.Name) +} - testVault.CreationDate = vault.CreationDate - testVault.ModifiedDate = vault.ModifiedDate +func test_GetVaultByName(t *testing.T) { + // First get the vault by ID to know its name + vault, err := testClient.Vaults.Get(testVaultId) + require.NoError(t, err) - if !reflect.DeepEqual(testVault, vault) { - t.Fatalf("fetched vault did not match test vault. Expected %#v, got %#v", testVault, vault) - } + // Then test GetByName + foundVault, err := testClient.Vaults.GetByName(vault.Name) + require.NoError(t, err) + assert.Equal(t, testVaultId, foundVault.Id) +} + +func test_GetVaultByName_NotFound(t *testing.T) { + _, err := testClient.Vaults.GetByName("nonexistent-vault-name-12345") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrVaultNotFound)) } func test_NewVault(t *testing.T) { - err := testClient.Vaults.New(testNewVault, nil) - if err != nil { - t.Fatal(err) + tests := []struct { + name string + vault Vault + }{ + { + name: "Standard/Default/Default", + vault: Vault{ + Name: "test-standard-default", + Description: "Test vault", + ContentType: VaultContentTypeEverything, + SecurityLevel: VaultSecurityLevelStandard, + Visibility: VaultVisibilityDefault, + }, + }, + { + name: "High/Everyone/Secrets", + vault: Vault{ + Name: "test-high-everyone", + Description: "High security public vault", + ContentType: VaultContentTypeSecrets, + SecurityLevel: VaultSecurityLevelHigh, + Visibility: VaultVisibilityPublic, + }, + }, + { + name: "Standard/Never/Credentials", + vault: Vault{ + Name: "test-credentials", + Description: "Credentials vault", + ContentType: VaultContentTypeCredentials, + SecurityLevel: VaultSecurityLevelStandard, + Visibility: VaultVisibilityPrivate, + }, + }, + { + name: "High/Never/BusinessInformation", + vault: Vault{ + Name: "test-business", + Description: "Business info vault", + ContentType: VaultContentTypeBusinessInformation, + SecurityLevel: VaultSecurityLevelHigh, + Visibility: VaultVisibilityPrivate, + }, + }, } - vault, err := testClient.Vaults.Get(testNewVault.Id) - if err != nil { - t.Fatal(err) - } - - vault.CreationDate = testNewVault.CreationDate - vault.ModifiedDate = testNewVault.ModifiedDate - - if !reflect.DeepEqual(testNewVault, vault) { - t.Fatalf("fetched vault did not match test vault. Expected %#v, got %#v", testNewVault, vault) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + created, err := testClient.Vaults.New(tt.vault) + require.NoError(t, err) + require.NotEmpty(t, created.Id) + + fetched, err := testClient.Vaults.Get(created.Id) + require.NoError(t, err) + assert.Equal(t, tt.vault.Name, fetched.Name) + assert.Equal(t, tt.vault.Description, fetched.Description) + assert.Equal(t, tt.vault.ContentType, fetched.ContentType) + assert.Equal(t, tt.vault.SecurityLevel, fetched.SecurityLevel) + assert.Equal(t, tt.vault.Visibility, fetched.Visibility) + + err = testClient.Vaults.Delete(created.Id) + require.NoError(t, err) + }) } } func test_UpdateVault(t *testing.T) { - testNewVault.Name = "go-dvls tests new updated" - testNewVault.Description = "Test updated" - options := VaultOptions{Password: &testNewVaultPassword} - - err := testClient.Vaults.Update(testNewVault, &options) - if err != nil { - t.Fatal(err) + originalVault := Vault{ + Name: "test-update-vault", + Description: "Original description", + ContentType: VaultContentTypeEverything, + SecurityLevel: VaultSecurityLevelStandard, + Visibility: VaultVisibilityDefault, } - valid, err := testClient.Vaults.ValidatePassword(testNewVault.Id, testNewVaultPassword) - if err != nil { - t.Fatal(err) + created, err := testClient.Vaults.New(originalVault) + require.NoError(t, err) + + tests := []struct { + name string + update func(v *Vault) + verify func(t *testing.T, v Vault) + }{ + { + name: "UpdateName", + update: func(v *Vault) { + v.Name = "test-update-vault-renamed" + }, + verify: func(t *testing.T, v Vault) { + assert.Equal(t, "test-update-vault-renamed", v.Name) + }, + }, + { + name: "UpdateDescription", + update: func(v *Vault) { + v.Description = "Updated description" + }, + verify: func(t *testing.T, v Vault) { + assert.Equal(t, "Updated description", v.Description) + }, + }, + { + name: "UpdateSecurityLevel", + update: func(v *Vault) { + v.SecurityLevel = VaultSecurityLevelHigh + }, + verify: func(t *testing.T, v Vault) { + assert.Equal(t, VaultSecurityLevelHigh, v.SecurityLevel) + }, + }, + { + name: "UpdateVisibility", + update: func(v *Vault) { + v.Visibility = VaultVisibilityPublic + }, + verify: func(t *testing.T, v Vault) { + assert.Equal(t, VaultVisibilityPublic, v.Visibility) + }, + }, + { + name: "UpdateContentType", + update: func(v *Vault) { + v.ContentType = VaultContentTypeSecrets + }, + verify: func(t *testing.T, v Vault) { + assert.Equal(t, VaultContentTypeSecrets, v.ContentType) + }, + }, } - if !valid { - t.Fatal("vault password validation failed, expected ", testNewVaultPassword) - } + currentVault := created + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.update(¤tVault) + updated, err := testClient.Vaults.Update(currentVault) + require.NoError(t, err) - vault, err := testClient.Vaults.Get(testNewVault.Id) - if err != nil { - t.Fatal(err) - } - - vault.CreationDate = testNewVault.CreationDate - vault.ModifiedDate = testNewVault.ModifiedDate + fetched, err := testClient.Vaults.Get(updated.Id) + require.NoError(t, err) + tt.verify(t, fetched) - if !reflect.DeepEqual(testNewVault, vault) { - t.Fatalf("fetched vault did not match test vault. Expected %#v, got %#v", testNewVault, vault) + currentVault = fetched + }) } + + err = testClient.Vaults.Delete(created.Id) + require.NoError(t, err) } -func test_DeleteVault(t *testing.T) { - err := testClient.Vaults.Delete(testNewVault.Id) - if err != nil { - t.Fatal(err) +// test_ContentType_DefaultEquivalence verifies that: +// 1. System vaults (Default, User vault) return VaultContentTypeDefault ("Default") +// 2. VaultContentTypeDefault is automatically converted to VaultContentTypeEverything on creation +func test_ContentType_DefaultEquivalence(t *testing.T) { + // Verify system vaults use "Default" + vault, err := testClient.Vaults.Get(testVaultId) + require.NoError(t, err) + assert.True(t, + vault.ContentType == VaultContentTypeEverything || vault.ContentType == VaultContentTypeDefault, + "expected ContentType to be 'Everything' or 'Default', got %q", vault.ContentType) + + // Verify that using VaultContentTypeDefault in New() works (converted to Everything) + newVault := Vault{ + Name: "test-default-conversion", + Description: "Test Default to Everything conversion", + ContentType: VaultContentTypeDefault, + SecurityLevel: VaultSecurityLevelStandard, + Visibility: VaultVisibilityDefault, } + + created, err := testClient.Vaults.New(newVault) + require.NoError(t, err, "creating vault with VaultContentTypeDefault should work") + assert.Equal(t, VaultContentTypeEverything, created.ContentType) + + err = testClient.Vaults.Delete(created.Id) + require.NoError(t, err) }