Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 76 additions & 1 deletion addr.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
)

const maxAddressErrorBody = 4 << 10

var (
// defined as a variable so it can be overridden in tests.
addrURI = `https://experience.aucklandcouncil.govt.nz/nextapi/property`
Expand Down Expand Up @@ -51,6 +56,11 @@ func MatchingPropertyAddresses(ctx context.Context, addrReq *AddrRequest) (*Addr
return cachedAr, nil
}

token, err := addrTokenProvider(ctx)
if err != nil {
return nil, fmt.Errorf("get address API token: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, addrURI, nil)
if err != nil {
return nil, err
Expand All @@ -61,6 +71,7 @@ func MatchingPropertyAddresses(ctx context.Context, addrReq *AddrRequest) (*Addr
q.Add("pageSize", strconv.Itoa(addrReq.PageSize))
}
req.URL.RawQuery = q.Encode()
req.Header.Set("Authorization", "Bearer "+token)

start := time.Now()
resp, err := addrHTTPClient.Do(req)
Expand All @@ -71,7 +82,7 @@ func MatchingPropertyAddresses(ctx context.Context, addrReq *AddrRequest) (*Addr
slog.DebugContext(ctx, "address call complete", "duration", time.Since(start))

if resp.StatusCode != http.StatusOK {
return nil, errors.New("address API returned status code: " + strconv.Itoa(resp.StatusCode))
return nil, addressStatusError(resp)
}

dec := json.NewDecoder(resp.Body)
Expand All @@ -95,3 +106,67 @@ func oneAddress(ctx context.Context, addr string) (*Address, error) {
}
return &resp.Items[0], nil
}

func addressStatusError(resp *http.Response) error {
body, err := io.ReadAll(io.LimitReader(resp.Body, maxAddressErrorBody))
if err != nil {
return fmt.Errorf("address API returned status code: %d", resp.StatusCode)
}

msg := sanitizeAddressErrorBody(string(body))
if msg == "" {
return fmt.Errorf("address API returned status code: %d", resp.StatusCode)
}
return fmt.Errorf("address API returned status code: %d: %s", resp.StatusCode, msg)
}

func sanitizeAddressErrorBody(body string) string {
body = strings.TrimSpace(body)
if body == "" {
return ""
}
body = strings.Map(func(r rune) rune {
if r == '\n' || r == '\r' || r == '\t' {
return ' '
}
if r < ' ' {
return -1
}
return r
}, body)
body = strings.Join(strings.Fields(body), " ")
if body == "" {
return ""
}

const marker = `"error"`
if strings.Contains(body, marker) {
var payload struct {
Error string `json:"error"`
}
if err := json.Unmarshal([]byte(body), &payload); err == nil && payload.Error != "" {
body = payload.Error
}
}

body = redactSecretLikeText(body)
if len(body) > maxAddressErrorBody {
body = body[:maxAddressErrorBody]
}
return body
}

func redactSecretLikeText(s string) string {
words := strings.Fields(s)
for i, word := range words {
trimmed := strings.Trim(word, `"'.,;:()[]{}<>`)
if strings.EqualFold(trimmed, "Bearer") && i+1 < len(words) {
words[i+1] = "<redacted>"
continue
}
if strings.Count(trimmed, ".") >= 2 && len(trimmed) > 40 {
words[i] = strings.Replace(word, trimmed, "<redacted>", 1)
}
}
return strings.Join(words, " ")
}
143 changes: 134 additions & 9 deletions addr_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
package aklapi

import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

var testAddr = &Address{
Expand Down Expand Up @@ -64,10 +70,12 @@ func TestMatchingPropertyAddresses(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer tt.testSrv.Close()
oldURI := addrURI
defer func() { addrURI = oldURI }()
resetAddressTestGlobals(t)
t.Cleanup(tt.testSrv.Close)
addrURI = tt.testSrv.URL
addrTokenProvider = func(context.Context) (string, error) {
return "test-token", nil
}
got, err := MatchingPropertyAddresses(t.Context(), tt.args.addrReq)
if (err != nil) != tt.wantErr {
t.Errorf("MatchingPropertyAddresses() error = %v, wantErr %v", err, tt.wantErr)
Expand Down Expand Up @@ -102,10 +110,12 @@ func TestAddress(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer tt.testSrv.Close()
oldURI := addrURI
defer func() { addrURI = oldURI }()
resetAddressTestGlobals(t)
t.Cleanup(tt.testSrv.Close)
addrURI = tt.testSrv.URL
addrTokenProvider = func(context.Context) (string, error) {
return "test-token", nil
}
got, err := AddressLookup(t.Context(), tt.args.addr)
if (err != nil) != tt.wantErr {
t.Errorf("Address() error = %v, wantErr %v", err, tt.wantErr)
Expand Down Expand Up @@ -156,10 +166,12 @@ func Test_oneAddress(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer tt.testSrv.Close()
oldURI := addrURI
defer func() { addrURI = oldURI }()
resetAddressTestGlobals(t)
t.Cleanup(tt.testSrv.Close)
addrURI = tt.testSrv.URL
addrTokenProvider = func(context.Context) (string, error) {
return "test-token", nil
}
got, err := oneAddress(t.Context(), tt.args.addr)
if (err != nil) != tt.wantErr {
t.Errorf("oneAddress() error = %v, wantErr %v", err, tt.wantErr)
Expand All @@ -172,6 +184,119 @@ func Test_oneAddress(t *testing.T) {
}
}

func TestMatchingPropertyAddressesAuthorization(t *testing.T) {
resetAddressTestGlobals(t)

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
assert.Equal(t, "red sq", r.URL.Query().Get("query"))
assert.Equal(t, "1", r.URL.Query().Get("pageSize"))
writeAddrJSON(w, AddrResponse{Items: []Address{*testAddr}})
}))
t.Cleanup(ts.Close)

addrURI = ts.URL
addrTokenProvider = func(context.Context) (string, error) {
return "test-token", nil
}

got, err := MatchingPropertyAddresses(t.Context(), &AddrRequest{PageSize: 1, SearchText: "red sq"})
require.NoError(t, err)
assert.Equal(t, &AddrResponse{Items: []Address{*testAddr}}, got)
}

func TestMatchingPropertyAddressesTokenFailure(t *testing.T) {
resetAddressTestGlobals(t)

var called bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(ts.Close)

addrURI = ts.URL
addrTokenProvider = func(context.Context) (string, error) {
return "", errors.New("token unavailable")
}

got, err := MatchingPropertyAddresses(t.Context(), &AddrRequest{SearchText: "red sq"})
require.Error(t, err)
assert.Nil(t, got)
assert.False(t, called)
assert.Contains(t, err.Error(), "get address API token: token unavailable")
}

func TestMatchingPropertyAddressesStatusError(t *testing.T) {
resetAddressTestGlobals(t)

tests := []struct {
name string
body string
want string
forbidText string
}{
{
name: "json error",
body: `{"error":"Authorisation error. Current IP address logged."}`,
want: "address API returned status code: 401: Authorisation error. Current IP address logged.",
},
{
name: "secret-looking body is redacted",
body: `{"error":"bad token Bearer header.eyJleHAiOjE3Nzk0NTQ4MDB9.signature"}`,
want: "address API returned status code: 401: bad token Bearer <redacted>",
forbidText: "header.eyJ",
},
{
name: "large body is bounded",
body: strings.Repeat("x", maxAddressErrorBody+100),
want: "address API returned status code: 401: " + strings.Repeat("x", maxAddressErrorBody),
forbidText: strings.Repeat("x", maxAddressErrorBody+1),
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resetAddressTestGlobals(t)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(tt.body))
}))
t.Cleanup(ts.Close)

addrURI = ts.URL
addrTokenProvider = func(context.Context) (string, error) {
return "test-token", nil
}

_, err := MatchingPropertyAddresses(t.Context(), &AddrRequest{SearchText: "red sq"})
require.Error(t, err)
assert.Equal(t, tt.want, err.Error())
if tt.forbidText != "" {
assert.NotContains(t, err.Error(), tt.forbidText)
}
})
}
}

func resetAddressTestGlobals(t *testing.T) {
t.Helper()
oldURI := addrURI
oldClient := addrHTTPClient
oldProvider := addrTokenProvider
oldCache := addrCache
oldNoCache := NoCache
t.Cleanup(func() {
addrURI = oldURI
addrHTTPClient = oldClient
addrTokenProvider = oldProvider
addrCache = oldCache
NoCache = oldNoCache
})
addrCache = newLRUCache[string, *AddrResponse](defCacheSz)
NoCache = false
}

func writeAddrJSON(w io.Writer, r AddrResponse) {
data, err := json.Marshal(r)
if err != nil {
Expand Down
Loading