diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8670267..b09f9bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,10 +14,10 @@ jobs: steps: - name: Check out ${{ github.repository }} - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Check out Devolutions/actions - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: repository: Devolutions/actions ref: v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6043e5b..be3a0e3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,10 +16,10 @@ jobs: steps: - name: Check out ${{ github.repository }} - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Check out Devolutions/actions - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: repository: Devolutions/actions ref: v1 @@ -37,10 +37,9 @@ jobs: tag: v${{ steps.get-version.outputs.version }} - name: Setup Go environment - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: - go-version: '1.20' - check-latest: true + go-version: ${{ vars.GO_VERSION }} - name: Download CA certificate uses: ./.github/workflows/create-file-from-secret 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..6cc2caf 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) @@ -83,18 +84,18 @@ func (c *Client) loginWithContext(ctx context.Context) error { reqUrl, err := url.JoinPath(c.baseUri, loginEndpoint) if err != nil { - return fmt.Errorf("failed to build login url. error: %w", err) + return fmt.Errorf("failed to build login url: %w", err) } resp, err := c.rawRequestWithContext(ctx, reqUrl, http.MethodPost, loginContentType, bytes.NewBufferString(loginBody)) if err != nil { - return fmt.Errorf("error while submitting login request. error: %w", err) + return fmt.Errorf("error while submitting login request: %w", err) } var loginResponse loginResponse err = json.Unmarshal(resp.Response, &loginResponse) if err != nil { - return fmt.Errorf("failed to unmarshal response body. error: %w", err) + return fmt.Errorf("failed to unmarshal response body: %w", err) } c.credential.token = loginResponse.TokenId @@ -109,12 +110,12 @@ func (c *Client) isLogged() (bool, error) { func (c *Client) isLoggedWithContext(ctx context.Context) (bool, error) { reqUrl, err := url.JoinPath(c.baseUri, isLoggedEndpoint) if err != nil { - return false, fmt.Errorf("failed to build isLogged url. error: %w", err) + return false, fmt.Errorf("failed to build isLogged url: %w", err) } resp, err := c.rawRequestWithContext(ctx, reqUrl, http.MethodGet, defaultContentType, nil) if err != nil && !strings.Contains(err.Error(), "json: cannot unmarshal bool into Go value") { - return false, fmt.Errorf("error while submitting isLogged request. error: %w", err) + return false, fmt.Errorf("error while submitting isLogged request: %w", err) } if string(resp.Response) == "false" { diff --git a/dvls.go b/dvls.go index 63c0968..96af931 100644 --- a/dvls.go +++ b/dvls.go @@ -60,12 +60,12 @@ func (c *Client) Request(url string, reqMethod string, reqBody io.Reader, option func (c *Client) RequestWithContext(ctx context.Context, url string, reqMethod string, reqBody io.Reader, options ...RequestOptions) (Response, error) { islogged, err := c.isLoggedWithContext(ctx) if err != nil { - return Response{}, &RequestError{Err: fmt.Errorf("failed to fetch login status. error: %w", err), Url: url} + return Response{}, &RequestError{Err: fmt.Errorf("failed to fetch login status: %w", err), Url: url} } if !islogged { err := c.loginWithContext(ctx) if err != nil { - return Response{}, &RequestError{Err: fmt.Errorf("failed to refresh login token. error: %w", err), Url: url} + return Response{}, &RequestError{Err: fmt.Errorf("failed to refresh login token: %w", err), Url: url} } } @@ -89,7 +89,7 @@ func (c *Client) rawRequestWithContext(ctx context.Context, url string, reqMetho req, err := http.NewRequestWithContext(ctx, reqMethod, url, reqBody) if err != nil { - return Response{}, &RequestError{Err: fmt.Errorf("failed to make request. error: %w", err), Url: url} + return Response{}, &RequestError{Err: fmt.Errorf("failed to make request: %w", err), Url: url} } req.Header.Add("Content-Type", contentType) @@ -97,7 +97,7 @@ func (c *Client) rawRequestWithContext(ctx context.Context, url string, reqMetho resp, err := c.client.Do(req) if err != nil { - return Response{}, &RequestError{Err: fmt.Errorf("error while submitting request. error: %w", err), Url: url} + return Response{}, &RequestError{Err: fmt.Errorf("error while submitting request: %w", err), Url: url} } defer resp.Body.Close() @@ -110,13 +110,13 @@ func (c *Client) rawRequestWithContext(ctx context.Context, url string, reqMetho var response Response response.Response, err = io.ReadAll(resp.Body) if err != nil { - return Response{}, &RequestError{Err: fmt.Errorf("failed to read response body. error: %w", err), Url: url} + return Response{}, &RequestError{Err: fmt.Errorf("failed to read response body: %w", err), Url: url} } if !opts.RawBody && len(response.Response) > 0 { err = json.Unmarshal(response.Response, &response) if err != nil { - return response, &RequestError{Err: fmt.Errorf("failed to unmarshal response body. error: %w", err), Url: url} + return response, &RequestError{Err: fmt.Errorf("failed to unmarshal response body: %w", err), Url: url} } } 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..51f1692 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,35 @@ 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{} }, +} + +// getSupportedSubTypes extracts all supported subtypes for a given entry type from entryFactories. +// This ensures a single source of truth for supported entry types/subtypes. +func getSupportedSubTypes(entryType string) map[string]struct{} { + result := make(map[string]struct{}) + prefix := entryType + "/" + for key := range entryFactories { + if subType, found := strings.CutPrefix(key, prefix); found { + result[subType] = struct{}{} + } + } + return result } func (e *Entry) UnmarshalJSON(data []byte) error { @@ -73,7 +123,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 +162,83 @@ 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"` + CurrentPage int `json:"currentPage"` + PageSize int `json:"pageSize"` + TotalCount int `json:"totalCount"` + TotalPage int `json:"totalPage"` +} + +// 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. +// This function handles pagination automatically and returns all entries across all pages. +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) + } + + var allEntries []Entry + currentPage := 1 + + for { + q := parsedUrl.Query() + if opts.Name != "" { + q.Set("name", opts.Name) + } + if opts.Path != "" { + q.Set("path", opts.Path) + } + q.Set("page", fmt.Sprintf("%d", currentPage)) + 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 (page %d): %w", currentPage, err) + } + + var rawResp entryListRawResponse + if err := json.Unmarshal(resp.Response, &rawResp); err != nil { + return nil, fmt.Errorf("failed to unmarshal entry list response (page %d): %w", currentPage, err) + } + + 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 (page %d): %w", currentPage, err) + } + entry.VaultId = vaultId + allEntries = append(allEntries, entry) + } + + // Check if we've fetched all pages + if currentPage >= rawResp.TotalPage { + break + } + currentPage++ + } + + return allEntries, nil +} diff --git a/entry_attachments.go b/entry_attachments.go index 2b64dce..f4d1718 100644 --- a/entry_attachments.go +++ b/entry_attachments.go @@ -43,29 +43,29 @@ const attachmentEndpoint = "/api/attachment" func (c *Client) newAttachmentRequest(ctx context.Context, attachment EntryAttachment) (string, error) { reqUrl, err := url.JoinPath(c.baseUri, attachmentEndpoint, "save?=&private=false&useSensitiveMode=true") if err != nil { - return "", fmt.Errorf("failed to build attachment url. error: %w", err) + return "", fmt.Errorf("failed to build attachment url: %w", err) } reqUrl, err = url.QueryUnescape(reqUrl) if err != nil { - return "", fmt.Errorf("failed to unescape query url. error: %w", err) + return "", fmt.Errorf("failed to unescape query url: %w", err) } entryJson, err := json.Marshal(attachment) if err != nil { - return "", fmt.Errorf("failed to marshal body. error: %w", err) + return "", fmt.Errorf("failed to marshal body: %w", err) } resp, err := c.RequestWithContext(ctx, reqUrl, http.MethodPost, bytes.NewBuffer(entryJson)) if err != nil { - return "", fmt.Errorf("error while submitting entry attachment request. error: %w", err) + return "", fmt.Errorf("error while submitting entry attachment request: %w", err) } else if err = resp.CheckRespSaveResult(); err != nil { return "", err } err = json.Unmarshal(resp.Response, &attachment) if err != nil { - return "", fmt.Errorf("failed to unmarshal response body. error: %w", err) + return "", fmt.Errorf("failed to unmarshal response body: %w", err) } return attachment.Id, nil @@ -74,14 +74,14 @@ func (c *Client) newAttachmentRequest(ctx context.Context, attachment EntryAttac func (c *Client) uploadAttachment(ctx context.Context, fileBytes []byte, attachmentId string) error { reqUrl, err := url.JoinPath(c.baseUri, attachmentEndpoint, attachmentId, "document") if err != nil { - return fmt.Errorf("failed to build attachment url. error: %w", err) + return fmt.Errorf("failed to build attachment url: %w", err) } contentType := http.DetectContentType(fileBytes) resp, err := c.RequestWithContext(ctx, reqUrl, http.MethodPost, bytes.NewBuffer(fileBytes), RequestOptions{ContentType: contentType}) if err != nil { - return fmt.Errorf("error while uploading entry attachment. error: %w", err) + return fmt.Errorf("error while uploading entry attachment: %w", err) } else if err = resp.CheckRespSaveResult(); err != nil { return err } diff --git a/entry_certificate.go b/entry_certificate.go index f24fa11..6f670cd 100644 --- a/entry_certificate.go +++ b/entry_certificate.go @@ -150,19 +150,19 @@ func (c *EntryCertificateService) GetWithContext(ctx context.Context, entryId st var entry EntryCertificate reqUrl, err := url.JoinPath(c.client.baseUri, entryEndpoint, entryId) if err != nil { - return EntryCertificate{}, fmt.Errorf("failed to build entry url. error: %w", err) + return EntryCertificate{}, fmt.Errorf("failed to build entry url: %w", err) } resp, err := c.client.RequestWithContext(ctx, reqUrl, http.MethodGet, nil) if err != nil { - return EntryCertificate{}, fmt.Errorf("error while fetching entry. error: %w", err) + return EntryCertificate{}, fmt.Errorf("error while fetching entry: %w", err) } else if err = resp.CheckRespSaveResult(); err != nil { return EntryCertificate{}, err } err = json.Unmarshal(resp.Response, &entry) if err != nil { - return EntryCertificate{}, fmt.Errorf("failed to unmarshal response body. error: %w", err) + return EntryCertificate{}, fmt.Errorf("failed to unmarshal response body: %w", err) } return entry, nil @@ -178,12 +178,12 @@ func (c *EntryCertificateService) GetFileContent(entryId string) ([]byte, error) func (c *EntryCertificateService) GetFileContentWithContext(ctx context.Context, entryId string) ([]byte, error) { reqUrl, err := url.JoinPath(c.client.baseUri, entryConnectionsEndpoint, entryId, "document") if err != nil { - return nil, fmt.Errorf("failed to build entry url. error: %w", err) + return nil, fmt.Errorf("failed to build entry url: %w", err) } resp, err := c.client.RequestWithContext(ctx, reqUrl, http.MethodGet, nil, RequestOptions{RawBody: true}) if err != nil { - return nil, fmt.Errorf("error while fetching entry content. error: %w", err) + return nil, fmt.Errorf("error while fetching entry content: %w", err) } return resp.Response, nil @@ -200,19 +200,19 @@ func (c *EntryCertificateService) GetPasswordWithContext(ctx context.Context, en var entryPassword EntryCertificate reqUrl, err := url.JoinPath(c.client.baseUri, entryEndpoint, entry.Id, "/sensitive-data") if err != nil { - return EntryCertificate{}, fmt.Errorf("failed to build entry url. error: %w", err) + return EntryCertificate{}, fmt.Errorf("failed to build entry url: %w", err) } resp, err := c.client.RequestWithContext(ctx, reqUrl, http.MethodPost, nil) if err != nil { - return EntryCertificate{}, fmt.Errorf("error while fetching sensitive data. error: %w", err) + return EntryCertificate{}, fmt.Errorf("error while fetching sensitive data: %w", err) } else if err = resp.CheckRespSaveResult(); err != nil { return EntryCertificate{}, err } err = json.Unmarshal(resp.Response, &entryPassword) if err != nil { - return EntryCertificate{}, fmt.Errorf("failed to unmarshal response body. error: %w", err) + return EntryCertificate{}, fmt.Errorf("failed to unmarshal response body: %w", err) } entry.Password = entryPassword.Password @@ -245,7 +245,7 @@ func (c *EntryCertificateService) NewFileWithContext(ctx context.Context, entry func (c *EntryCertificateService) newWithContext(ctx context.Context, entry EntryCertificate, content []byte) (EntryCertificate, error) { reqUrl, err := url.JoinPath(c.client.baseUri, entryEndpoint, "save") if err != nil { - return EntryCertificate{}, fmt.Errorf("failed to build entry url. error: %w", err) + return EntryCertificate{}, fmt.Errorf("failed to build entry url: %w", err) } entry.data.Mode = 3 @@ -257,19 +257,19 @@ func (c *EntryCertificateService) newWithContext(ctx context.Context, entry Entr entryJson, err := json.Marshal(entry) if err != nil { - return EntryCertificate{}, fmt.Errorf("failed to marshal body. error: %w", err) + return EntryCertificate{}, fmt.Errorf("failed to marshal body: %w", err) } resp, err := c.client.RequestWithContext(ctx, reqUrl, http.MethodPost, bytes.NewBuffer(entryJson)) if err != nil { - return EntryCertificate{}, fmt.Errorf("error while creating entry. error: %w", err) + return EntryCertificate{}, fmt.Errorf("error while creating entry: %w", err) } else if err = resp.CheckRespSaveResult(); err != nil { return EntryCertificate{}, err } err = json.Unmarshal(resp.Response, &entry) if err != nil { - return EntryCertificate{}, fmt.Errorf("failed to unmarshal response body. error: %w", err) + return EntryCertificate{}, fmt.Errorf("failed to unmarshal response body: %w", err) } if content != nil { @@ -282,12 +282,12 @@ func (c *EntryCertificateService) newWithContext(ctx context.Context, entry Entr entryAttachment, err := c.client.newAttachmentRequest(ctx, attachment) if err != nil { - return EntryCertificate{}, fmt.Errorf("error while creating entry attachment. error: %w", err) + return EntryCertificate{}, fmt.Errorf("error while creating entry attachment: %w", err) } err = c.client.uploadAttachment(ctx, content, entryAttachment) if err != nil { - return EntryCertificate{}, fmt.Errorf("error while uploading attachment. error: %w", err) + return EntryCertificate{}, fmt.Errorf("error while uploading attachment: %w", err) } } @@ -304,7 +304,7 @@ func (c *EntryCertificateService) Update(entry EntryCertificate) (EntryCertifica func (c *EntryCertificateService) UpdateWithContext(ctx context.Context, entry EntryCertificate) (EntryCertificate, error) { oldEntry, err := c.GetWithContext(ctx, entry.Id) if err != nil { - return EntryCertificate{}, fmt.Errorf("error while fetching entry. error: %w", err) + return EntryCertificate{}, fmt.Errorf("error while fetching entry: %w", err) } entry.data.Mode = oldEntry.data.Mode @@ -312,24 +312,24 @@ func (c *EntryCertificateService) UpdateWithContext(ctx context.Context, entry E reqUrl, err := url.JoinPath(c.client.baseUri, entryEndpoint, "save") if err != nil { - return EntryCertificate{}, fmt.Errorf("failed to build entry url. error: %w", err) + return EntryCertificate{}, fmt.Errorf("failed to build entry url: %w", err) } entryJson, err := json.Marshal(entry) if err != nil { - return EntryCertificate{}, fmt.Errorf("failed to marshal body. error: %w", err) + return EntryCertificate{}, fmt.Errorf("failed to marshal body: %w", err) } resp, err := c.client.RequestWithContext(ctx, reqUrl, http.MethodPut, bytes.NewBuffer(entryJson)) if err != nil { - return EntryCertificate{}, fmt.Errorf("error while creating entry. error: %w", err) + return EntryCertificate{}, fmt.Errorf("error while creating entry: %w", err) } else if err = resp.CheckRespSaveResult(); err != nil { return EntryCertificate{}, err } err = json.Unmarshal(resp.Response, &entry) if err != nil { - return EntryCertificate{}, fmt.Errorf("failed to unmarshal response body. error: %w", err) + return EntryCertificate{}, fmt.Errorf("failed to unmarshal response body: %w", err) } return entry, nil @@ -345,12 +345,12 @@ func (c *EntryCertificateService) Delete(entryId string) error { func (c *EntryCertificateService) DeleteWithContext(ctx context.Context, entryId string) error { reqUrl, err := url.JoinPath(c.client.baseUri, entryEndpoint, entryId) if err != nil { - return fmt.Errorf("failed to delete entry url. error: %w", err) + return fmt.Errorf("failed to delete entry url: %w", err) } resp, err := c.client.RequestWithContext(ctx, reqUrl, http.MethodDelete, nil) if err != nil { - return fmt.Errorf("error while deleting entry. error: %w", err) + return fmt.Errorf("error while deleting entry: %w", err) } else if err = resp.CheckRespSaveResult(); err != nil { return err } diff --git a/entry_certificate_test.go b/entry_certificate_test.go index 99d1ef7..4df1115 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 @@ -77,12 +81,12 @@ func test_NewCertificateEntryFile(t *testing.T) { fileBytes, err := io.ReadAll(file) if err != nil { - t.Fatal("failed read file. error: %w", err) + t.Fatalf("failed read file: %v", err) } stat, err := file.Stat() if err != nil { - t.Fatal("failed read file. error: %w", err) + t.Fatalf("failed read file: %v", err) } entry.CertificateIdentifier = stat.Name() diff --git a/entry_credential.go b/entry_credential.go index 090812c..70807ed 100644 --- a/entry_credential.go +++ b/entry_credential.go @@ -20,6 +20,9 @@ const ( EntryCredentialSubTypePrivateKey string = "PrivateKey" ) +// supportedCredentialSubTypes is generated from entryFactories to ensure a single source of truth. +var supportedCredentialSubTypes = getSupportedSubTypes(EntryCredentialType) + type EntryCredentialService service type EntryCredentialAccessCodeData struct { @@ -239,26 +242,13 @@ func (c *EntryCredentialService) validateEntry(entry *Entry) error { return fmt.Errorf("unsupported entry type (%s). Only %s is supported", entry.GetType(), EntryCredentialType) } - supportedSubTypes := []string{ - EntryCredentialSubTypeAccessCode, - EntryCredentialSubTypeApiKey, - EntryCredentialSubTypeAzureServicePrincipal, - EntryCredentialSubTypeConnectionString, - EntryCredentialSubTypeDefault, - EntryCredentialSubTypePrivateKey, - } - subType := entry.GetSubType() - isSupported := false - for _, t := range supportedSubTypes { - if subType == t { - isSupported = true - break + if _, isSupported := supportedCredentialSubTypes[subType]; !isSupported { + var supportedList []string + for st := range supportedCredentialSubTypes { + supportedList = append(supportedList, st) } - } - - if !isSupported { - return fmt.Errorf("unsupported entry subtype (%s). Supported subtypes: %v", subType, supportedSubTypes) + return fmt.Errorf("unsupported entry subtype (%s). Supported subtypes: %v", subType, supportedList) } return nil @@ -292,17 +282,17 @@ func (c *EntryCredentialService) GetByIdWithContext(ctx context.Context, vaultId reqUrl, err := url.JoinPath(c.client.baseUri, entryUri) if err != nil { - return Entry{}, fmt.Errorf("failed to build entry url. error: %w", err) + return Entry{}, fmt.Errorf("failed to build entry url: %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) + return Entry{}, fmt.Errorf("error while fetching entry: %w", err) } err = entry.UnmarshalJSON(resp.Response) if err != nil { - return Entry{}, fmt.Errorf("failed to unmarshal response body. error: %w", err) + return Entry{}, fmt.Errorf("failed to unmarshal response body: %w", err) } entry.VaultId = vaultId @@ -343,17 +333,17 @@ func (c *EntryCredentialService) NewWithContext(ctx context.Context, entry Entry 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) + return "", fmt.Errorf("failed to build entry url: %w", err) } body, err := json.Marshal(newEntryRequest) if err != nil { - return "", fmt.Errorf("failed to marshal body. error: %w", err) + return "", fmt.Errorf("failed to marshal body: %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) + return "", fmt.Errorf("error while creating entry: %w", err) } newEntryResponse := struct { @@ -362,7 +352,7 @@ func (c *EntryCredentialService) NewWithContext(ctx context.Context, entry Entry err = json.Unmarshal(resp.Response, &newEntryResponse) if err != nil { - return "", fmt.Errorf("failed to unmarshal response body. error: %w", err) + return "", fmt.Errorf("failed to unmarshal response body: %w", err) } return newEntryResponse.Id, nil } @@ -400,17 +390,17 @@ func (c *EntryCredentialService) UpdateWithContext(ctx context.Context, entry En 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) + return Entry{}, fmt.Errorf("failed to build entry url: %w", err) } body, err := json.Marshal(updateEntryRequest) if err != nil { - return Entry{}, fmt.Errorf("failed to marshal body. error: %w", err) + return Entry{}, fmt.Errorf("failed to marshal body: %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) + return Entry{}, fmt.Errorf("error while updating entry: %w", err) } entry, err = c.GetByIdWithContext(ctx, entry.VaultId, entry.Id) @@ -447,13 +437,42 @@ func (c *EntryCredentialService) DeleteByIdWithContext(ctx context.Context, vaul 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) + return fmt.Errorf("failed to build delete entry url: %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 fmt.Errorf("error while deleting entry: %w", err) } return nil } + +// GetEntries returns a list of credential entries from a vault with optional name and path filters. +// Note: The API does not support filtering by entry type, so all entries are fetched and filtered client-side. +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. +// Note: The API does not support filtering by entry type, so all entries are fetched and filtered client-side. +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..23dba8b 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_Filters(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..c75dfff --- /dev/null +++ b/entry_folder.go @@ -0,0 +1,296 @@ +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" +) + +// supportedFolderSubTypes is generated from entryFactories to ensure a single source of truth. +var supportedFolderSubTypes = getSupportedSubTypes(EntryFolderType) + +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) + } + + subType := entry.GetSubType() + if _, isSupported := supportedFolderSubTypes[subType]; !isSupported { + var supportedList []string + for st := range supportedFolderSubTypes { + supportedList = append(supportedList, st) + } + return fmt.Errorf("unsupported entry subtype (%s). Supported subtypes: %v", subType, supportedList) + } + + 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: %w", err) + } + + resp, err := c.client.RequestWithContext(ctx, reqUrl, http.MethodGet, nil) + if err != nil { + return Entry{}, fmt.Errorf("error while fetching entry: %w", err) + } + + err = entry.UnmarshalJSON(resp.Response) + if err != nil { + return Entry{}, fmt.Errorf("failed to unmarshal response body: %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: %w", err) + } + + body, err := json.Marshal(newEntryRequest) + if err != nil { + return "", fmt.Errorf("failed to marshal body: %w", err) + } + + resp, err := c.client.RequestWithContext(ctx, reqUrl, http.MethodPost, bytes.NewBuffer(body)) + if err != nil { + return "", fmt.Errorf("error while creating entry: %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: %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: %w", err) + } + + body, err := json.Marshal(updateEntryRequest) + if err != nil { + return Entry{}, fmt.Errorf("failed to marshal body: %w", err) + } + + _, err = c.client.RequestWithContext(ctx, reqUrl, http.MethodPut, bytes.NewBuffer(body)) + if err != nil { + return Entry{}, fmt.Errorf("error while updating entry: %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: %w", err) + } + + _, err = c.client.RequestWithContext(ctx, reqUrl, http.MethodDelete, nil) + if err != nil { + return fmt.Errorf("error while deleting entry: %w", err) + } + + return nil +} + +// GetEntries returns a list of folder entries from a vault with optional name and path filters. +// Note: The API does not support filtering by entry type, so all entries are fetched and filtered client-side. +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. +// Note: The API does not support filtering by entry type, so all entries are fetched and filtered client-side. +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..d0ab913 --- /dev/null +++ b/entry_folder_test.go @@ -0,0 +1,292 @@ +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") +} + +func Test_GetFolderEntries_Filters(t *testing.T) { + vault := createTestVault(t, "folder-getentries") + testPath := "go-dvls\\folder-getentries" + + // Create 3 test folder entries - "Database" is exact match, others contain "Database" in name + entriesToCreate := []Entry{ + { + VaultId: vault.Id, + Name: "Database", + Path: testPath, + Type: EntryFolderType, + SubType: EntryFolderSubTypeDatabase, + Description: "Exact match folder", + Data: &EntryFolderData{Domain: "db.local", Username: "dbuser"}, + }, + { + VaultId: vault.Id, + Name: "Database Backup", + Path: testPath, + Type: EntryFolderType, + SubType: EntryFolderSubTypeDatabase, + Description: "Contains Database in name", + Data: &EntryFolderData{Domain: "backup.local", Username: "backupuser"}, + }, + { + VaultId: vault.Id, + Name: "Database Production", + Path: testPath, + Type: EntryFolderType, + SubType: EntryFolderSubTypeDatabase, + Description: "Contains Database in name", + Data: &EntryFolderData{Domain: "prod.local", Username: "produser"}, + }, + } + + // Create test entries + t.Log("Creating test folder entries for GetEntries") + var createdIds []string + for _, entry := range entriesToCreate { + id, err := testClient.Entries.Folder.New(entry) + require.NoError(t, err, "Failed to create folder entry %s", entry.Name) + createdIds = append(createdIds, id) + t.Logf("Created folder entry %q with ID: %s", entry.Name, id) + } + + // Test 1: GetEntries with path filter should return at least our 3 folders + // Note: DVLS may auto-create parent folders, so we check for >= 3 + t.Log("Test 1: GetEntries with path filter") + entries, err := testClient.Entries.Folder.GetEntries(vault.Id, "", testPath) + require.NoError(t, err, "GetEntries failed") + assert.GreaterOrEqual(t, len(entries), 3, "Expected at least 3 folder entries with path filter") + + // Verify our 3 folders are present + foundNames := make(map[string]bool) + for _, e := range entries { + foundNames[e.Name] = true + } + assert.True(t, foundNames["Database"], "Expected to find 'Database' folder") + assert.True(t, foundNames["Database Backup"], "Expected to find 'Database Backup' folder") + assert.True(t, foundNames["Database Production"], "Expected to find 'Database Production' folder") + t.Logf("Found %d folder entries in path %q (including auto-created parent folders)", len(entries), testPath) + + // Test 2: GetEntries with exact name match - should return only "Database" + t.Log("Test 2: GetEntries with exact name match") + entries, err = testClient.Entries.Folder.GetEntries(vault.Id, "Database", "") + require.NoError(t, err, "GetEntries with exact name failed") + assert.Len(t, entries, 1, "Expected 1 folder entry with exact name match") + if len(entries) > 0 { + assert.Equal(t, "Database", 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.Folder.GetEntries(vault.Id, "Database Backup", testPath) + require.NoError(t, err, "GetEntries with name and path filter failed") + assert.Len(t, entries, 1, "Expected 1 folder entry with name and path filter") + t.Logf("Found %d folder 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.Folder.GetEntries(vault.Id, "Non Existent Folder", testPath) + require.NoError(t, err, "GetEntries with non-existent name failed") + assert.Empty(t, entries, "Expected 0 folder entries for non-existent name") + t.Logf("Correctly returned %d folder entries for non-existent name", len(entries)) + + // Cleanup test entries + t.Log("Cleaning up test folder entries") + for _, id := range createdIds { + err := testClient.Entries.Folder.DeleteById(vault.Id, id) + require.NoError(t, err, "Failed to delete folder entry %s", id) + } + t.Log("Cleanup complete") +} diff --git a/entry_host.go b/entry_host.go index ebf7a29..2ea2a50 100644 --- a/entry_host.go +++ b/entry_host.go @@ -64,7 +64,7 @@ func (e EntryHost) MarshalJSON() ([]byte, error) { raw.Name = e.EntryName sensitiveJson, err := json.Marshal(e.HostDetails) if err != nil { - return nil, fmt.Errorf("failed to marshal sensitive data. error: %w", err) + return nil, fmt.Errorf("failed to marshal sensitive data: %w", err) } raw.Data = string(sensitiveJson) @@ -174,18 +174,18 @@ func (c *EntryHostService) GetHostDetailsWithContext(ctx context.Context, entry reqUrl, err := url.JoinPath(c.client.baseUri, entryEndpoint, entry.Id, "/sensitive-data") if err != nil { - return EntryHost{}, fmt.Errorf("failed to build entry url. error: %w", err) + return EntryHost{}, fmt.Errorf("failed to build entry url: %w", err) } resp, err := c.client.RequestWithContext(ctx, reqUrl, http.MethodPost, nil) if err != nil { - return EntryHost{}, fmt.Errorf("error while fetching sensitive data. error: %w", err) + return EntryHost{}, fmt.Errorf("error while fetching sensitive data: %w", err) } else if err = resp.CheckRespSaveResult(); err != nil { return EntryHost{}, err } if err := json.Unmarshal(resp.Response, &respData); err != nil { - return EntryHost{}, fmt.Errorf("failed to unmarshal response body. error: %w", err) + return EntryHost{}, fmt.Errorf("failed to unmarshal response body: %w", err) } var sensitiveDataResponse struct { @@ -198,7 +198,7 @@ func (c *EntryHostService) GetHostDetailsWithContext(ctx context.Context, entry } if err := json.Unmarshal([]byte(respData.Data), &sensitiveDataResponse); err != nil { - return EntryHost{}, fmt.Errorf("failed to unmarshal inner data. error: %w", err) + return EntryHost{}, fmt.Errorf("failed to unmarshal inner data: %w", err) } if sensitiveDataResponse.Data.PasswordItem.HasSensitiveData { 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.go b/entry_website.go index 9a21934..9b60e62 100644 --- a/entry_website.go +++ b/entry_website.go @@ -64,7 +64,7 @@ func (e EntryWebsite) MarshalJSON() ([]byte, error) { raw.Name = e.EntryName sensitiveJson, err := json.Marshal(e.WebsiteDetails) if err != nil { - return nil, fmt.Errorf("failed to marshal sensitive data. error: %w", err) + return nil, fmt.Errorf("failed to marshal sensitive data: %w", err) } raw.Data = string(sensitiveJson) @@ -178,18 +178,18 @@ func (c *EntryWebsiteService) GetWebsiteDetailsWithContext(ctx context.Context, reqUrl, err := url.JoinPath(c.client.baseUri, entryEndpoint, entry.Id, "/sensitive-data") if err != nil { - return EntryWebsite{}, fmt.Errorf("failed to build entry url. error: %w", err) + return EntryWebsite{}, fmt.Errorf("failed to build entry url: %w", err) } resp, err := c.client.RequestWithContext(ctx, reqUrl, http.MethodPost, nil) if err != nil { - return EntryWebsite{}, fmt.Errorf("error while fetching sensitive data. error: %w", err) + return EntryWebsite{}, fmt.Errorf("error while fetching sensitive data: %w", err) } else if err = resp.CheckRespSaveResult(); err != nil { return EntryWebsite{}, err } if err := json.Unmarshal(resp.Response, &respData); err != nil { - return EntryWebsite{}, fmt.Errorf("failed to unmarshal response body. error: %w", err) + return EntryWebsite{}, fmt.Errorf("failed to unmarshal response body: %w", err) } var sensitiveDataResponse struct { @@ -202,7 +202,7 @@ func (c *EntryWebsiteService) GetWebsiteDetailsWithContext(ctx context.Context, } if err := json.Unmarshal([]byte(respData.Data), &sensitiveDataResponse); err != nil { - return EntryWebsite{}, fmt.Errorf("failed to unmarshal inner data. error: %w", err) + return EntryWebsite{}, fmt.Errorf("failed to unmarshal inner data: %w", err) } if sensitiveDataResponse.Data.PasswordItem.HasSensitiveData { 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..946f319 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,11 @@ module github.com/Devolutions/go-dvls -go 1.20 +go 1.26 + +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..b670a79 --- /dev/null +++ b/helpers_test.go @@ -0,0 +1,48 @@ +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. +// Polls until the vault is indexed and ready to use (max 5s timeout). +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) + + // Register cleanup immediately after creation to ensure deletion even if polling times out + t.Cleanup(func() { + testClient.Vaults.Delete(vault.Id) + }) + + // Wait for vault to be indexed by polling + timeout := time.After(5 * time.Second) + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-timeout: + t.Fatalf("timeout waiting for vault %s to be indexed", vault.Id) + case <-ticker.C: + _, err := testClient.Vaults.Get(vault.Id) + if err == nil { + // Vault is indexed and ready + return vault + } + } + } +} diff --git a/server.go b/server.go index d1395d5..3c4fbcc 100644 --- a/server.go +++ b/server.go @@ -122,19 +122,19 @@ func (c *Client) GetPublicServerInfoWithContext(ctx context.Context) (Server, er var server Server reqUrl, err := url.JoinPath(c.baseUri, serverPublicInfoEndpoint) if err != nil { - return Server{}, fmt.Errorf("failed to build server info url. error: %w", err) + return Server{}, fmt.Errorf("failed to build server info url: %w", err) } resp, err := c.RequestWithContext(ctx, reqUrl, http.MethodGet, nil) if err != nil { - return Server{}, fmt.Errorf("error while fetching server info. error: %w", err) + return Server{}, fmt.Errorf("error while fetching server info: %w", err) } else if err = resp.CheckRespSaveResult(); err != nil { return Server{}, err } err = json.Unmarshal(resp.Response, &server) if err != nil { - return Server{}, fmt.Errorf("failed to unmarshal response body. error: %w", err) + return Server{}, fmt.Errorf("failed to unmarshal response body: %w", err) } return server, nil @@ -151,19 +151,19 @@ func (c *Client) GetPrivateServerInfoWithContext(ctx context.Context) (Server, e var server Server reqUrl, err := url.JoinPath(c.baseUri, serverPrivateInfoEndpoint) if err != nil { - return Server{}, fmt.Errorf("failed to build server info url. error: %w", err) + return Server{}, fmt.Errorf("failed to build server info url: %w", err) } resp, err := c.RequestWithContext(ctx, reqUrl, http.MethodGet, nil) if err != nil { - return Server{}, fmt.Errorf("error while fetching server info. error: %w", err) + return Server{}, fmt.Errorf("error while fetching server info: %w", err) } else if err = resp.CheckRespSaveResult(); err != nil { return Server{}, err } err = json.Unmarshal(resp.Response, &server) if err != nil { - return Server{}, fmt.Errorf("failed to unmarshal response body. error: %w", err) + return Server{}, fmt.Errorf("failed to unmarshal response body: %w", err) } return server, nil @@ -182,12 +182,12 @@ func (c *Client) GetServerTimezonesWithContext(ctx context.Context) ([]Timezone, var timezones []Timezone reqUrl, err := url.JoinPath(c.baseUri, serverTimezonesEndpoint) if err != nil { - return nil, fmt.Errorf("failed to build timezone info url. error: %w", err) + return nil, fmt.Errorf("failed to build timezone info url: %w", err) } resp, err := c.RequestWithContext(ctx, reqUrl, http.MethodGet, nil) if err != nil { - return nil, fmt.Errorf("error while fetching timezones. error: %w", err) + return nil, fmt.Errorf("error while fetching timezones: %w", err) } else if err = resp.CheckRespSaveResult(); err != nil { return nil, err } @@ -197,7 +197,7 @@ func (c *Client) GetServerTimezonesWithContext(ctx context.Context) ([]Timezone, }{} err = json.Unmarshal(resp.Response, &raw) if err != nil { - return nil, fmt.Errorf("failed to unmarshal response body. error: %w", err) + return nil, fmt.Errorf("failed to unmarshal response body: %w", err) } timezones = raw.Data diff --git a/vaults.go b/vaults.go index b2ade52..f80c3ce 100644 --- a/vaults.go +++ b/vaults.go @@ -9,120 +9,118 @@ import ( "net/url" ) +type VaultVisibility string + +const ( + VaultVisibilityDefault VaultVisibility = "Default" + VaultVisibilityPrivate VaultVisibility = "Never" + VaultVisibilityPublic VaultVisibility = "Everyone" +) + +type VaultSecurityLevel string + +const ( + VaultSecurityLevelStandard VaultSecurityLevel = "Standard" + VaultSecurityLevelHigh VaultSecurityLevel = "High" +) + +type VaultContentType string + +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" +) + type Vaults service -// Vault represents a DVLS vault. Contains relevant vault information. +// Vault represents a DVLS vault. type Vault struct { - Id string - Name string - Description string - SecurityLevel VaultSecurityLevel - Visibility VaultVisibility - CreationDate *ServerTime - ModifiedDate *ServerTime - password *string + 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"` } -type VaultOptions struct { - Password *string +// 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"` } -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"` +// 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"` } -// UnmarshalJSON implements the json.Unmarshaler interface. -func (v *Vault) UnmarshalJSON(b []byte) error { - var raw struct { - Data rawVault - } +const ( + vaultEndpoint string = "/api/v1/vault" +) - err := json.Unmarshal(b, &raw) - if err != nil { - return err - } +var ErrVaultNotFound = fmt.Errorf("vault not found") +var ErrMultipleVaultsFound = fmt.Errorf("multiple vaults found") - var securityLevel VaultSecurityLevel +// List returns all vaults. +func (c *Vaults) List() ([]Vault, error) { + return c.ListWithContext(context.Background()) +} - if raw.Data.RepositorySettings.VaultSecurityLevel != nil { - securityLevel = VaultSecurityLevel(*raw.Data.RepositorySettings.VaultSecurityLevel) +// ListWithContext returns all vaults. +// This function handles pagination automatically and returns all vaults across all pages. +// 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) } - vault := Vault{ - Id: raw.Data.Id, - Name: raw.Data.Name, - Description: raw.Data.Description, - SecurityLevel: securityLevel, - Visibility: VaultVisibility(raw.Data.RepositorySettings.VaultAllowAccessRequestRole), + parsedUrl, err := url.Parse(reqUrl) + if err != nil { + return nil, fmt.Errorf("failed to parse vault url: %w", err) } - *v = vault - - return nil -} - -// MarshalJSON implements the json.Marshaler interface. -func (v Vault) MarshalJSON() ([]byte, error) { - var raw rawVault + var allVaults []Vault + currentPage := 1 - securityLevel := 1 + for { + q := parsedUrl.Query() + q.Set("page", fmt.Sprintf("%d", currentPage)) + parsedUrl.RawQuery = q.Encode() - if v.SecurityLevel == VaultSecurityLevelHigh { - securityLevel = 0 - raw.RepositorySettings.VaultType = 1 - } + resp, err := c.client.RequestWithContext(ctx, parsedUrl.String(), http.MethodGet, nil) + if err != nil { + return nil, fmt.Errorf("error while fetching vaults (page %d): %w", currentPage, err) + } - if v.password != nil { - raw.Password = v.password - hasPasswordChanged := true - raw.HasPasswordChanged = &hasPasswordChanged - } + var listResp vaultListResponse + if err := json.Unmarshal(resp.Response, &listResp); err != nil { + return nil, fmt.Errorf("failed to unmarshal response body (page %d): %w", currentPage, 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) + allVaults = append(allVaults, listResp.Data...) - if v.SecurityLevel == VaultSecurityLevelStandard { - raw.IsAllowedOffline = true + // Check if we've fetched all pages + if currentPage >= listResp.TotalPage { + break + } + currentPage++ } - json, err := json.Marshal(raw) - if err != nil { - return nil, err - } - - return json, nil + return allVaults, 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 +132,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) + } + + // 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, } - err = c.client.Vaults.NewWithContext(ctx, vault, options) + 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..7ba4458 100644 --- a/vaults_test.go +++ b/vaults_test.go @@ -1,101 +1,243 @@ 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_GetVault(t *testing.T) { - vault, err := testClient.Vaults.Get(testVaultId) - if err != nil { - t.Fatal(err) - } +func test_ListVaults(t *testing.T) { + vault := createTestVault(t, "list-vaults") - testVault.CreationDate = vault.CreationDate - testVault.ModifiedDate = vault.ModifiedDate + vaults, err := testClient.Vaults.List() + require.NoError(t, err) + assert.NotEmpty(t, vaults) - if !reflect.DeepEqual(testVault, vault) { - t.Fatalf("fetched vault did not match test vault. Expected %#v, got %#v", testVault, vault) + found := false + for _, v := range vaults { + if v.Id == vault.Id { + found = true + break + } } + assert.True(t, found, "expected test vault to be in the list") } -func test_NewVault(t *testing.T) { - err := testClient.Vaults.New(testNewVault, nil) - if err != nil { - t.Fatal(err) - } +func test_GetVault(t *testing.T) { + vault := createTestVault(t, "get-vault") - vault, err := testClient.Vaults.Get(testNewVault.Id) - if err != nil { - t.Fatal(err) - } + fetchedVault, err := testClient.Vaults.Get(vault.Id) + require.NoError(t, err) + assert.Equal(t, vault.Id, fetchedVault.Id) + assert.Equal(t, vault.Name, fetchedVault.Name) +} - vault.CreationDate = testNewVault.CreationDate - vault.ModifiedDate = testNewVault.ModifiedDate +func test_GetVaultByName(t *testing.T) { + vault := createTestVault(t, "get-by-name") - if !reflect.DeepEqual(testNewVault, vault) { - t.Fatalf("fetched vault did not match test vault. Expected %#v, got %#v", testNewVault, vault) - } + // Test GetByName with the created vault's name + foundVault, err := testClient.Vaults.GetByName(vault.Name) + require.NoError(t, err) + assert.Equal(t, vault.Id, foundVault.Id) + assert.Equal(t, vault.Name, foundVault.Name) } -func test_UpdateVault(t *testing.T) { - testNewVault.Name = "go-dvls tests new updated" - testNewVault.Description = "Test updated" - options := VaultOptions{Password: &testNewVaultPassword} +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)) +} - err := testClient.Vaults.Update(testNewVault, &options) - if err != nil { - t.Fatal(err) +func test_NewVault(t *testing.T) { + 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, + }, + }, } - valid, err := testClient.Vaults.ValidatePassword(testNewVault.Id, testNewVaultPassword) - if err != nil { - t.Fatal(err) + 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) + + // Register cleanup to ensure vault deletion even if test fails + t.Cleanup(func() { + testClient.Vaults.Delete(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) + }) } +} - if !valid { - t.Fatal("vault password validation failed, expected ", testNewVaultPassword) +func test_UpdateVault(t *testing.T) { + originalVault := Vault{ + Name: "test-update-vault", + Description: "Original description", + ContentType: VaultContentTypeEverything, + SecurityLevel: VaultSecurityLevelStandard, + Visibility: VaultVisibilityDefault, } - vault, err := testClient.Vaults.Get(testNewVault.Id) - if err != nil { - t.Fatal(err) + created, err := testClient.Vaults.New(originalVault) + require.NoError(t, err) + + // Register cleanup to ensure vault deletion even if test fails + t.Cleanup(func() { + testClient.Vaults.Delete(created.Id) + }) + + 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) + }, + }, } - vault.CreationDate = testNewVault.CreationDate - vault.ModifiedDate = testNewVault.ModifiedDate + 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) + + 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 + }) } } -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) }