Skip to content

Commit

Permalink
Added BreachedAccount() to breaches API
Browse files Browse the repository at this point in the history
Also added WithUserAgent() to the HIBP client for custom UA configuration
  • Loading branch information
wneessen committed Sep 22, 2021
1 parent 0b3734d commit ed7f680
Show file tree
Hide file tree
Showing 5 changed files with 207 additions and 33 deletions.
93 changes: 66 additions & 27 deletions breach.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,25 +100,10 @@ func (b *BreachApi) Breaches(options ...BreachOption) ([]*Breach, *http.Response
queryParams := b.setBreachOpts(options...)
apiUrl := fmt.Sprintf("%s/breaches", BaseUrl)

hreq, err := b.hibp.HttpReq(http.MethodGet, apiUrl, queryParams)
hb, hr, err := b.apiCall(http.MethodGet, apiUrl, queryParams)
if err != nil {
return nil, nil, err
}
hr, err := b.hibp.hc.Do(hreq)
if err != nil {
return nil, hr, err
}
if hr.StatusCode != 200 {
return nil, hr, fmt.Errorf("API responded with non HTTP-200: %s", hr.Status)
}
defer func() {
_ = hr.Body.Close()
}()

hb, err := io.ReadAll(hr.Body)
if err != nil {
return nil, hr, err
}

var breachList []*Breach
if err := json.Unmarshal(hb, &breachList); err != nil {
Expand All @@ -137,28 +122,51 @@ func (b *BreachApi) BreachByName(n string, options ...BreachOption) (*Breach, *h
}

apiUrl := fmt.Sprintf("%s/breach/%s", BaseUrl, n)

hreq, err := b.hibp.HttpReq(http.MethodGet, apiUrl, queryParams)
hb, hr, err := b.apiCall(http.MethodGet, apiUrl, queryParams)
if err != nil {
return nil, nil, err
}
hr, err := b.hibp.hc.Do(hreq)

var breachDetails *Breach
if err := json.Unmarshal(hb, &breachDetails); err != nil {
return nil, hr, err
}

return breachDetails, hr, nil
}

// DataClasses are attribute of a record compromised in a breach. This method returns a list of strings
// with all registered data classes known to HIBP
func (b *BreachApi) DataClasses() ([]string, *http.Response, error) {
apiUrl := fmt.Sprintf("%s/dataclasses", BaseUrl)
hb, hr, err := b.apiCall(http.MethodGet, apiUrl, nil)
if err != nil {
return nil, nil, err
}

var dataClasses []string
if err := json.Unmarshal(hb, &dataClasses); err != nil {
return nil, hr, err
}
if hr.StatusCode != 200 {
return nil, hr, fmt.Errorf("API responded with non HTTP-200: %s", hr.Status)

return dataClasses, hr, nil
}

// BreachedAccount returns a single breached site based on its name
func (b *BreachApi) BreachedAccount(a string, options ...BreachOption) ([]*Breach, *http.Response, error) {
queryParams := b.setBreachOpts(options...)

if a == "" {
return nil, nil, fmt.Errorf("no account id given")
}
defer func() {
_ = hr.Body.Close()
}()

hb, err := io.ReadAll(hr.Body)
apiUrl := fmt.Sprintf("%s/breachedaccount/%s", BaseUrl, a)
hb, hr, err := b.apiCall(http.MethodGet, apiUrl, queryParams)
if err != nil {
return nil, hr, err
return nil, nil, err
}

var breachDetails *Breach
var breachDetails []*Breach
if err := json.Unmarshal(hb, &breachDetails); err != nil {
return nil, hr, err
}
Expand All @@ -174,6 +182,7 @@ func WithDomain(d string) BreachOption {
}

// WithoutTruncate disables the truncateResponse parameter in the breaches API
// This option only influences the BreachedAccount method
func WithoutTruncate() BreachOption {
return func(b *BreachApi) {
b.disableTrunc = true
Expand Down Expand Up @@ -217,6 +226,9 @@ func (b *BreachApi) setBreachOpts(options ...BreachOption) map[string]string {
}

for _, opt := range options {
if opt == nil {
continue
}
opt(b)
}

Expand All @@ -234,3 +246,30 @@ func (b *BreachApi) setBreachOpts(options ...BreachOption) map[string]string {

return queryParams
}

// apiCall performs the API call to the breaches API and returns the HTTP response body JSON as
// byte array
func (b *BreachApi) apiCall(m string, p string, q map[string]string) ([]byte, *http.Response, error) {
hreq, err := b.hibp.HttpReq(m, p, q)
if err != nil {
return nil, nil, err
}
hr, err := b.hibp.hc.Do(hreq)
if err != nil {
return nil, hr, err
}
defer func() {
_ = hr.Body.Close()
}()

hb, err := io.ReadAll(hr.Body)
if err != nil {
return nil, hr, err
}

if hr.StatusCode != 200 {
return nil, hr, fmt.Errorf("API responded with non HTTP-200: %s - %s", hr.Status, hb)
}

return hb, hr, nil
}
85 changes: 85 additions & 0 deletions breach_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package hibp

import (
"fmt"
"os"
"testing"
)

Expand All @@ -21,6 +23,23 @@ func TestBreaches(t *testing.T) {
}
}

// TestBreachesWithNil tests the Breaches() method of the breaches API with a nil option
func TestBreachesWithNil(t *testing.T) {
hc := New()
if hc == nil {
t.Errorf("hibp client creation failed")
return
}

breachList, _, err := hc.BreachApi.Breaches(nil)
if err != nil {
t.Error(err)
}
if breachList != nil && len(breachList) <= 0 {
t.Error("breaches list returned 0 results")
}
}

// TestBreachesWithDomain tests the Breaches() method of the breaches API for a specific domain
func TestBreachesWithDomain(t *testing.T) {
testTable := []struct {
Expand Down Expand Up @@ -133,3 +152,69 @@ func TestBreachByName(t *testing.T) {
})
}
}

// TestDataClasses tests the DataClasses() method of the breaches API
func TestDataClasses(t *testing.T) {
hc := New()
if hc == nil {
t.Errorf("hibp client creation failed")
return
}

classList, _, err := hc.BreachApi.DataClasses()
if err != nil {
t.Error(err)
}
if classList != nil && len(classList) <= 0 {
t.Error("breaches list returned 0 results")
}
}

// TestBreachedAccount tests the BreachedAccount() method of the breaches API
func TestBreachedAccount(t *testing.T) {
testTable := []struct {
testName string
accountName string
isBreached bool
moreThanOneBreach bool
}{
{"account-exists is breached once", "account-exists", true,
false},
{"multiple-breaches is breached multiple times", "multiple-breaches",
true, true},
{"opt-out is not breached", "opt-out", false, false},
}

hc := New(WithApiKey(os.Getenv("HIBP_API_KEY")))
if hc == nil {
t.Error("failed to create HIBP client")
return
}

for _, tc := range testTable {
t.Run(tc.testName, func(t *testing.T) {
breachDetails, _, err := hc.BreachApi.BreachedAccount(
fmt.Sprintf("%s@hibp-integration-tests.com", tc.accountName))
if err != nil && tc.isBreached {
t.Error(err)
}

if breachDetails == nil && tc.isBreached {
t.Errorf("breach for the account %q is expected, but returned 0 results.",
tc.accountName)
}
if breachDetails != nil && !tc.isBreached {
t.Errorf("breach for the account %q is expected to be not breached, but returned breach details.",
tc.accountName)
}
if breachDetails != nil && tc.moreThanOneBreach && len(breachDetails) <= 1 {
t.Errorf("breach for the account %q is expected to be breached multiple, but returned %d breaches.",
tc.accountName, len(breachDetails))
}
if breachDetails != nil && !tc.moreThanOneBreach && len(breachDetails) > 1 {
t.Errorf("breach for the account %q is expected to be breached once, but returned %d breaches.",
tc.accountName, len(breachDetails))
}
})
}
}
2 changes: 1 addition & 1 deletion examples/breaches/breach-by-name.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package main

import (
"fmt"
hibp "github.com/wneessen/go-hibp"
"github.com/wneessen/go-hibp"
)

func main() {
Expand Down
27 changes: 22 additions & 5 deletions hibp.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,29 @@ package hibp
import (
"bytes"
"crypto/tls"
"fmt"
"io"
"net/http"
"net/url"
"time"
)

// Version represents the version of this package
const Version = "0.1.2"
const Version = "0.1.3"

// BaseUrl is the base URL for the majority of API calls
const BaseUrl = "https://haveibeenpwned.com/api/v3"

// DefaultUserAgent defines the default UA string for the HTTP client
// Currently the URL in the UA string is comment out, as there is a bug in the HIBP API
// not allowing multiple slashes
const DefaultUserAgent = `go-hibp v` + Version // + ` - https://github.com/wneessen/go-hibp`

// Client is the HIBP client object
type Client struct {
hc *http.Client // HTTP client to perform the API requests
to time.Duration // HTTP client timeout
ak string // HIBP API key
ua string // User agent string for the HTTP client

PwnedPassApi *PwnedPassApi // Reference to the PwnedPassApi API
PwnedPassApiOpts *PwnedPasswordOptions // Additional options for the PwnedPassApi API
Expand All @@ -38,9 +43,13 @@ func New(options ...Option) *Client {
// Set defaults
c.to = time.Second * 5
c.PwnedPassApiOpts = &PwnedPasswordOptions{}
c.ua = DefaultUserAgent

// Set additional options
for _, opt := range options {
if opt == nil {
continue
}
opt(c)
}

Expand Down Expand Up @@ -75,6 +84,16 @@ func WithPwnedPadding() Option {
}
}

// WithUserAgent sets a custom user agent string for the HTTP client
func WithUserAgent(a string) Option {
if a == "" {
return func(c *Client) {}
}
return func(c *Client) {
c.ua = a
}
}

// HttpReq performs an HTTP request to the corresponding API
func (c *Client) HttpReq(m, p string, q map[string]string) (*http.Request, error) {
u, err := url.Parse(p)
Expand Down Expand Up @@ -106,12 +125,10 @@ func (c *Client) HttpReq(m, p string, q map[string]string) (*http.Request, error
}

hr.Header.Set("Accept", "application/json")
hr.Header.Set("User-Agent", fmt.Sprintf("go-hibp v%s - https://github.com/wneessen/go-hibp", Version))

hr.Header.Set("user-agent", c.ua)
if c.ak != "" {
hr.Header.Set("hibp-api-key", c.ak)
}

if c.PwnedPassApiOpts.WithPadding {
hr.Header.Set("Add-Padding", "true")
}
Expand Down
33 changes: 33 additions & 0 deletions hibp_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package hibp

import (
"fmt"
"os"
"testing"
"time"
Expand All @@ -14,6 +15,14 @@ func TestNew(t *testing.T) {
}
}

// TestNewWithNil tests the New() function with a nil option
func TestNewWithNil(t *testing.T) {
hc := New(nil)
if hc == nil {
t.Errorf("hibp client creation failed")
}
}

// TestNewWithHttpTimeout tests the New() function with the http timeout option
func TestNewWithHttpTimeout(t *testing.T) {
hc := New(WithHttpTimeout(time.Second * 10))
Expand Down Expand Up @@ -53,3 +62,27 @@ func TestNewWithApiKey(t *testing.T) {
apiKey, hc.ak)
}
}

// TestNewWithUserAgent tests the New() function with a custom user agent
func TestNewWithUserAgent(t *testing.T) {
hc := New()
if hc == nil {
t.Errorf("hibp client creation failed")
return
}
if hc.ua != DefaultUserAgent {
t.Errorf("hibp client default user agent was not set properly. Expected %s, got: %s",
DefaultUserAgent, hc.ua)
}

custUA := fmt.Sprintf("customUA v%s", Version)
hc = New(WithUserAgent(custUA))
if hc == nil {
t.Errorf("hibp client creation failed")
return
}
if hc.ua != custUA {
t.Errorf("hibp client custom user agent was not set properly. Expected %s, got: %s",
custUA, hc.ua)
}
}

0 comments on commit ed7f680

Please sign in to comment.