Skip to content

Commit

Permalink
Merge pull request #15 from asteurer/actions
Browse files Browse the repository at this point in the history
Actions
  • Loading branch information
asteurer authored Jul 15, 2024
2 parents 8c103f8 + de7ec4b commit 0cb8283
Show file tree
Hide file tree
Showing 7 changed files with 345 additions and 142 deletions.
20 changes: 18 additions & 2 deletions .github/workflows/build-package.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,26 @@ on:
- "v*"

jobs:
build:
build-on-pull-request:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: install go
uses: actions/setup-go@v3
with:
go-version: "1.22"

- name: run unit tests
run: make test

build-on-push:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:

- uses: actions/checkout@v3

- name: setup spin
- name: install spin
uses: fermyon/actions/spin/setup@v1
with:
github_token: ${{ github.token }}
Expand All @@ -28,6 +41,9 @@ jobs:
with:
version: "v0.32.0"

- name: run unit tests
run: make test

- name: cofiguring rust toolchain version
run: rustup toolchain install stable --profile minimal

Expand Down
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.PHONY: test
test:
go test -v ./azure
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,4 +185,15 @@ curl \
-H 'x-az-service: queue' \
--data-binary @path/to/your/xml/message \
http://127.0.0.1:3000/your-queue-name/messages
```
```

# Testing

### Requirements

- Latest version of [TinyGo](https://tinygo.org/getting-started/install/)
- Latest version of [wasmtime](https://docs.wasmtime.dev/cli-install.html)

### Running the tests

In your terminal, in the root directory of the code files, run `make test`.
144 changes: 144 additions & 0 deletions azure/azure.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package azure

import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"net/url"
"sort"
"strings"
)

type AZCredentials struct {
AccountName string
AccountKey []byte
Service string
}

func ParseAZCredentials(accountName, accountKey, service string) (*AZCredentials, error) {
decodedKey, err := base64.StdEncoding.DecodeString(accountKey)
if err != nil {
return nil, fmt.Errorf("decode account key: %v", err)
}
return &AZCredentials{AccountName: accountName, AccountKey: decodedKey, Service: service}, nil
}

func ComputeHMACSHA256(c *AZCredentials, message string) (string, error) {
h := hmac.New(sha256.New, c.AccountKey)
_, err := h.Write([]byte(message))
return base64.StdEncoding.EncodeToString(h.Sum(nil)), err
}

func BuildStringToSign(c *AZCredentials, req *http.Request) (string, error) {
// Returns a blank value if the header value doesn't exist
getHeader := func(key string, headers http.Header) string {
if headers == nil {
return ""
}
if v, ok := headers[key]; ok {
if len(v) > 0 {
return v[0]
}
}
return ""
}

headers := req.Header

// Per the documentation, the Content-Length field must be an empty string if the content length of the request is zero.
contentLength := getHeader("Content-Length", headers)
if contentLength == "0" {
contentLength = ""
}

canonicalizedResource, err := buildCanonicalizedResource(c, req.URL)
if err != nil {
return "", err
}

stringToSign := strings.Join([]string{
req.Method,
getHeader("Content-Encoding", headers),
getHeader("Content-Language", headers),
contentLength,
getHeader("Content-MD5", headers),
getHeader("Content-Type", headers),
"", // Empty date because x-ms-date is expected
getHeader("If-Modified-Since", headers),
getHeader("If-Match", headers),
getHeader("If-None-Match", headers),
getHeader("If-Unmodified-Since", headers),
getHeader("Range", headers),
buildCanonicalizedHeader(headers),
canonicalizedResource,
}, "\n")

return stringToSign, nil
}

// buildCanonicalizedHeader retrieves all headers which start with 'x-ms-' and creates a lexicographically sorted string:
// x-ms-header-a:foo,\nx-ms-header-b:bar,\nx-ms-header-0:baz\n
func buildCanonicalizedHeader(headers http.Header) string {
cm := map[string][]string{}
for k, v := range headers {
headerName := strings.TrimSpace(strings.ToLower(k))
if strings.HasPrefix(headerName, "x-ms-") {
cm[headerName] = v
}
}
if len(cm) == 0 {
return ""
}

keys := make([]string, 0, len(cm))
for key := range cm {
keys = append(keys, key)
}
sort.Strings(keys) // Canonicalized headers must be in lexicographical order

ch := bytes.NewBufferString("")
for i, key := range keys {
if i > 0 {
ch.WriteRune('\n')
}
ch.WriteString(key)
ch.WriteRune(':')
ch.WriteString(strings.Join(cm[key], ","))
}
return ch.String()
}

func buildCanonicalizedResource(c *AZCredentials, u *url.URL) (string, error) {
cr := bytes.NewBufferString("/")
cr.WriteString(c.AccountName)

if len(u.Path) > 0 {
cr.WriteString(u.EscapedPath())
} else {
cr.WriteString("/")
}

params, err := url.ParseQuery(u.RawQuery)
if err != nil {
return "", fmt.Errorf("failed to parse query params: %v", err)
}

if len(params) > 0 {
var paramNames []string
for paramName := range params {
paramNames = append(paramNames, paramName)
}
sort.Strings(paramNames)

for _, paramName := range paramNames {
paramValues := params[paramName]
sort.Strings(paramValues)
cr.WriteString("\n" + strings.ToLower(paramName) + ":" + strings.Join(paramValues, ","))
}
}

return cr.String(), nil
}
160 changes: 160 additions & 0 deletions azure/azure_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package azure

import (
"io"
"net/http"
"testing"
)

func TestParseAZCredentials(t *testing.T) {
goodCreds := AZCredentials{
AccountName: "testaccount",
AccountKey: []byte("testkey"),
Service: "testservice",
}

tests := []struct {
name string
accountName string
accountKey string
service string
expectedCreds *AZCredentials
expectedError bool
}{{
name: "properly base64 encoded account key",
accountName: "testaccount",
accountKey: "dGVzdGtleQ==",
service: "testservice",
expectedCreds: &goodCreds,
expectedError: false,
}, {
name: "non-base64 encoded account key",
accountName: "testaccount",
accountKey: "T3=$stK3y@", // Contains characters not in (A-Z, a-z, 0-9, +, /), and the length of the string is not a multiple of four, and there's a misplaced padding (=) character.
service: "testservice",
expectedCreds: nil,
expectedError: true,
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
creds, err := ParseAZCredentials(tt.accountName, tt.accountKey, tt.service)

if (tt.expectedError && err == nil) || (!tt.expectedError && err != nil) {
t.Errorf("got: %v, want: %v", err, tt.expectedError)
} else if !tt.expectedError {
if string(creds.AccountKey) != string(tt.expectedCreds.AccountKey) {
t.Errorf("got: %v, want: %v", string(creds.AccountKey), string(tt.expectedCreds.AccountKey))
}

if creds.AccountName != tt.expectedCreds.AccountName {
t.Errorf("got: %v, want: %v", creds.AccountName, tt.expectedCreds.AccountName)
}

if creds.Service != tt.expectedCreds.Service {
t.Errorf("got: %v, want: %v", creds.Service, tt.expectedCreds.Service)
}
}
})
}
}

func TestComputeHMACSHA256(t *testing.T) {
testCreds := AZCredentials{
AccountKey: []byte("testkey"),
}

tests := []struct {
name string
creds *AZCredentials
message string
expectedString string
expectedError bool
}{{
name: "initial test",
creds: &testCreds,
message: "Hello, world!",
expectedString: "JcFgXrIZPVbHKy1GWjQzi/KN+3hFaA/g2+Dn9JV7UAM=",
expectedError: false,
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
str, err := ComputeHMACSHA256(tt.creds, tt.message)

if str != tt.expectedString {
t.Errorf("got: %v, want: %v", str, tt.expectedString)
}

if (tt.expectedError && err == nil) || (!tt.expectedError && err != nil) {
t.Errorf("got: %v, want: %v", err, tt.expectedError)
}
})
}
}

func TestBuildStringToSign(t *testing.T) {
buildReq := func(method, endpoint string, payload io.Reader, headers map[string]string) (*http.Request, error) {
req, err := http.NewRequest(method, endpoint, payload)
if err != nil {
return nil, err
}

for key, value := range headers {
req.Header.Set(key, value)
}

return req, nil
}

testCreds := AZCredentials{
AccountName: "testaccount",
AccountKey: []byte("testkey"),
Service: "testservice",
}

goodReq, err := buildReq("GET", "https://localhost:3000/testpath?foo=bar&bar=baz", nil, map[string]string{
"x-ms-header-a": "foo",
"x-ms-header-b": "bar",
"x-ms-header-0": "baz",
"Content-Type": "test/type",
})
if err != nil {
t.Errorf("unexpected error: %v", err)
}

emptyReq, err := buildReq("GET", "https://localhost:3000", nil, map[string]string{})
if err != nil {
t.Errorf("unexpected error: %v", err)
}

tests := []struct {
name string
creds *AZCredentials
req *http.Request
expectedString string
expectedError bool
}{{
name: "normal request",
creds: &testCreds,
req: goodReq,
expectedString: "GET\n\n\n\n\ntest/type\n\n\n\n\n\n\nx-ms-header-0:baz\nx-ms-header-a:foo\nx-ms-header-b:bar\n/testaccount/testpath\nbar:baz\nfoo:bar",
expectedError: false,
}, {
name: "empty headers",
creds: &testCreds,
req: emptyReq,
expectedString: "GET\n\n\n\n\n\n\n\n\n\n\n\n\n/testaccount/",
expectedError: false,
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
str, err := BuildStringToSign(tt.creds, tt.req)
if str != tt.expectedString {
t.Errorf("got: %v, want: %v", str, tt.expectedString)
}

if (tt.expectedError && err == nil) || (!tt.expectedError && err != nil) {
t.Errorf("got: %v, want: %v", err, tt.expectedError)
}
})
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
module example
module github.com/fermyon/experimental-azure-client

go 1.22

Expand Down
Loading

0 comments on commit 0cb8283

Please sign in to comment.