go(hibp): add AllBreachesForAccount + amend tests
All checks were successful
continuous-integration/drone/push Build is passing

* also automatically use hibp api key with direnv and in CI
* check for rate-limit
* don't interpret rate-limit in tests as a failure
* report errors properly
This commit is contained in:
surtur 2023-08-24 02:05:22 +02:00
parent ec7a8ca61a
commit 9fb9cc2735
Signed by: wanderer
SSH Key Fingerprint: SHA256:MdCZyJ2sHLltrLBp0xQO0O1qTW9BT/xl5nXkDvhlMCI
4 changed files with 157 additions and 7 deletions

View File

@ -59,6 +59,9 @@ steps:
volumes:
- name: gopath
path: /go
environment:
PCMT_HIBP_API_KEY:
from_secret: hibp_api_key
commands:
- go test -cover ./...
@ -123,6 +126,9 @@ steps:
volumes:
- name: gopath
path: /go
environment:
PCMT_HIBP_API_KEY:
from_secret: hibp_api_key
commands:
- go test -cover ./...

1
.envrc
View File

@ -3,3 +3,4 @@ source_url "https://raw.githubusercontent.com/cachix/devenv/d1f7b48e35e6dee421cf
use devenv
t=${XDG_RUNTIME_DIR}/secrets/pcmt_gitea_token; test -f ${t} && source_env ${t} && unset ${t}
t=${XDG_RUNTIME_DIR}/secrets/hibp_api_key; test -f ${t} && source_env ${t} && unset ${t}

View File

@ -5,6 +5,7 @@ package hibp
import (
"encoding/json"
"errors"
"io"
"log"
"net/http"
@ -29,6 +30,15 @@ type Subscription struct {
DomainSearchMaxBreachedAccounts int
}
// BreachName is used to represent a HIBP breach name object.
type BreachName struct {
// Name holds the actual breach name, which in HIBP is permanently unique.
Name string `json:"Name" validate:"required,Name"`
}
// BreachNames is a slice of BreachName objects.
type BreachNames []BreachName
const (
api = "https://haveibeenpwned.com/api/v3"
appID = "pcmt (https://git.dotya.ml/mirre-mt/pcmt)"
@ -37,11 +47,16 @@ const (
headerUA = "user-agent"
headerHIBP = "hibp-api-key"
authKeyCheckValue = `Your request to the API couldn't be authorised. Check you have the right value in the "hibp-api-key" header, refer to the documentation for more: https://haveibeenpwned.com/API/v3#Authorisation`
)
var (
apiKey = os.Getenv("PCMT_HIBP_API_KEY")
client = &http.Client{Timeout: reqTmOut}
ErrAuthKeyCheckValue = errors.New(authKeyCheckValue)
ErrRateLimited = errors.New("We have been rate limited")
)
// SubscriptionStatus models https://haveibeenpwned.com/API/v3#SubscriptionStatus.
@ -78,7 +93,8 @@ func SubscriptionStatus() (*Subscription, error) {
}
// GetAllBreaches retrieves all breaches available in HIBP, as per
// https://haveibeenpwned.com/API/v3#AllBreaches.
// https://haveibeenpwned.com/API/v3#AllBreaches. This should be run at
// start-up to populate the cache.
func GetAllBreaches() (*[]schema.HIBPSchema, error) {
u := api + "/breaches"
@ -117,6 +133,57 @@ func GetAllBreaches() (*[]schema.HIBPSchema, error) {
return &ab, nil
}
// GetAllBreachesForAccount retrieves a list of breach names for a given
// account.
func GetAllBreachesForAccount(account string) ([]BreachName, error) {
u := api + "/breachedaccount/" + account
req, err := http.NewRequest("GET", u, nil)
if err != nil {
log.Fatalln(err)
}
respCh, errCh := rChans()
setUA(req)
setAuthHeader(req)
scheduleReq(req, &respCh, &errCh)
resp := <-respCh
err = <-errCh
defer resp.Body.Close()
if err != nil {
return nil, err
}
body, _ := io.ReadAll(resp.Body)
if sc := resp.StatusCode; sc != 200 {
// this is brittle...
if string(body) == authKeyCheckValue {
return nil, ErrAuthKeyCheckValue
}
if sc == 429 {
return nil, ErrRateLimited
}
}
bn := make([]BreachName, 0)
if len(body) == 0 {
return nil, nil
}
if err = json.Unmarshal(body, &bn); err != nil {
return nil, err
}
return bn, nil
}
func setUA(r *http.Request) {
r.Header.Set(headerUA, appID)
}

View File

@ -4,6 +4,7 @@
package hibp
import (
"errors"
"io"
"net/http"
"os"
@ -52,6 +53,7 @@ func TestMultipleBreaches(t *testing.T) {
respCh, errCh := rChans()
setUA(req)
setAuthHeader(req)
scheduleReq(req, &respCh, &errCh)
resp := <-respCh
@ -67,17 +69,63 @@ func TestMultipleBreaches(t *testing.T) {
body, _ := io.ReadAll(resp.Body)
b := string(body)
t.Log(b)
if resp.StatusCode != 200 {
if apiKey == "" {
if resp.StatusCode == 401 {
t.Logf("apiKey is empty, expected a 401")
if apiKey == "" {
switch {
case resp.StatusCode != 200:
switch {
case resp.StatusCode == 401:
t.Logf("apiKey is empty, a 401 is expected")
case resp.StatusCode == 429:
t.Log("skipping due to rate-limit")
t.SkipNow()
default:
t.Errorf("apiKey is empty, expected 401, got: %q, body: %q", resp.Status, b)
}
default:
t.Errorf("unexpected 200 without apiKey, got: %q, body: %q", resp.Status, b)
}
} else {
if resp.StatusCode != 200 {
if resp.StatusCode == 429 {
t.Log("skipping due to rate-limit")
t.SkipNow()
} else {
t.Errorf("apiKey is empty, expected 401, got: %q", resp.Status)
t.Errorf("expected 200, got: %q body: %q", resp.Status, b)
}
} else {
t.Errorf("wanted 200, got: %q", resp.Status)
want := `[{"Name":"Adobe"},{"Name":"Gawker"},{"Name":"Stratfor"}]`
if want != b {
t.Errorf("unexpected body, want: %q, got: %q", want, b)
}
}
}
account := a + hibpTestDomain
if b, err := GetAllBreachesForAccount(account); err != nil {
if errors.Is(err, ErrRateLimited) {
t.Log("skipping due to rate-limit")
t.SkipNow()
} else {
t.Errorf("error getting all breaches for account: %q", err)
}
} else {
if b != nil {
t.Logf("breach names for account %q: %#v", account, b)
if len(b) != 1 {
t.Errorf("expected 1 breach name for account %q, got: %d: %#v", account, len(b), b)
} else {
want := "Dubsmash"
if b[0].Name != want {
t.Errorf("expected breach name %q for account %q, got %q", want, account, b[0].Name)
}
}
} else {
t.Errorf("expected breach names for account %q, got nil", account)
}
}
}
@ -88,3 +136,31 @@ func TestGetAllBreaches(t *testing.T) {
t.Errorf("error: %q", err)
}
}
func TestGetAllBreachesForAccount(t *testing.T) {
account := "dumb@test.com"
if b, err := GetAllBreachesForAccount(account); err != nil {
if errors.Is(err, ErrRateLimited) {
t.Log("skipping due to rate-limit")
t.SkipNow()
} else {
t.Errorf("error getting all breaches for account: %q", err)
}
} else {
if b != nil {
t.Logf("breach names for account %q: %#v", account, b)
if len(b) != 1 {
t.Errorf("expected 1 breach name for account %q, got: %d: %#v", account, len(b), b)
} else {
want := "Dubsmash"
if b[0].Name != want {
t.Errorf("expected breach name %q for account %q, got %q", want, account, b[0].Name)
}
}
} else {
t.Errorf("expected breach names for account %q, got nil", account)
}
}
}