diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
new file mode 100644
index 0000000..867ec08
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,31 @@
+name: Linters, Spellcheck, and Tests
+
+on:
+ push:
+ paths:
+ - '**.go'
+ workflow_dispatch:
+
+jobs:
+ LintnTest:
+ runs-on: ubuntu-latest
+ timeout-minutes: 2
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ - name: Install dependencies
+ run: make installpkgs
+ - name: Run linters
+ run: make lint
+ - name: Run tests
+ run: make test
+
+ Spellcheck:
+ runs-on: ubuntu-latest
+ timeout-minutes: 1
+ steps:
+ - uses: actions/checkout@v4
+ - uses: crate-ci/typos@v1.29.7
diff --git a/.gitignore b/.gitignore
index 2ee5bd8..6e3bbcf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,8 @@
*.dll
*.so
*.dylib
+*.html
+*.json
# Test binary, built with `go test -c`
*.test
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..ddbd2d1
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,43 @@
+# Run tests and log the test coverage
+test:
+ go test -v -race -coverprofile=".cover.out" $$(go list ./... | grep -v /tmp)
+
+# Runs source code linters and catches common errors
+lint:
+ test -z $$(gofmt -l .) || (echo "Code isn't gofmt'ed!" && exit 1)
+ go vet $$(go list ./... | grep -v /tmp)
+ gosec -quiet -fmt=golint -exclude-dir="tmp" ./...
+ staticcheck ./...
+ govulncheck -test ./...
+ # pointerinterface ./...
+
+# Runs spellchecker on the code and comments
+# This requires this tool to be installed from https://github.com/crate-ci/typos?tab=readme-ov-file
+# Example installation (if you have rust installed): cargo install typos-cli
+spellcheck:
+ typos .
+
+# All in one check
+runchecks: test lint spellcheck
+
+# Generate pretty coverage report
+analyse:
+ go tool cover -html=".cover.out" -o="cover.html"
+ @echo -e "\nCOVERAGE\n===================="
+ go tool cover -func=.cover.out
+ @echo -e "\nCYCLOMATIC COMPLEXITY\n===================="
+ gocyclo -avg -top 10 .
+
+# Updates 3rd party packages and tools
+installpkgs:
+ go mod download
+ go install github.com/fzipp/gocyclo/cmd/gocyclo@latest
+ go install github.com/securego/gosec/v2/cmd/gosec@latest
+ go install honnef.co/go/tools/cmd/staticcheck@latest
+ go install golang.org/x/vuln/cmd/govulncheck@latest
+ # go install code.larus.se/lmas/pointerinterface@latest
+
+# Clean up built binary and other temporary files (ignores errors from rm)
+clean:
+ go clean
+ rm .cover.out cover.html
diff --git a/components/device.go b/components/host.go
old mode 100755
new mode 100644
similarity index 99%
rename from components/device.go
rename to components/host.go
index dc389c5..6657404
--- a/components/device.go
+++ b/components/host.go
@@ -67,7 +67,6 @@ func NewDevice() *HostingDevice {
func Hostname() (string, error) {
name, err := os.Hostname()
if err != nil {
- log.Println(err.Error())
return "", err
}
return name, nil
diff --git a/components/host_test.go b/components/host_test.go
new file mode 100644
index 0000000..5dbe78d
--- /dev/null
+++ b/components/host_test.go
@@ -0,0 +1,41 @@
+package components
+
+import (
+ "testing"
+)
+
+func TestHostname(t *testing.T) {
+ res, err := Hostname()
+
+ if res == "" || err != nil {
+ t.Errorf("Expected a host name and no error, got: %s and %v", res, err)
+ }
+}
+
+func TestIpAddresses(t *testing.T) {
+ res, err := IpAddresses()
+
+ if len(res) == 0 || err != nil {
+ t.Errorf("Expected IP addresses and no error, got: %s and %v", res, err)
+ }
+}
+
+func TestMacAddresses(t *testing.T) {
+ ip, err := IpAddresses()
+ if err != nil {
+ t.Fatalf("An error occurred in getting IP Addresses for the Mac Address test")
+ }
+ res, err := MacAddresses(ip)
+
+ if len(res) == 0 || err != nil {
+ t.Errorf("Expected no error, got: %s and %v", res, err)
+ }
+}
+
+func TestNewDevice(t *testing.T) {
+ res := NewDevice()
+
+ if res == nil {
+ t.Errorf("Expected a new device, got: %v", res)
+ }
+}
diff --git a/components/husk.go b/components/husk.go
index 431562a..ddc26ff 100644
--- a/components/husk.go
+++ b/components/husk.go
@@ -1,5 +1,5 @@
/*******************************************************************************
- * Copyright (c) 2024 Synecdoque
+ * Copyright (c) 2025 Synecdoque
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -35,7 +35,7 @@ type Husk struct {
Certificate string `json:"-"`
CA_cert string `json:"-"`
TlsConfig *tls.Config `json:"-"` // client side mutual TLS configuration
- DName pkix.Name `json:"distinguishedName"`
+ DName pkix.Name `json:"-"`
Details map[string][]string `json:"details"`
ProtoPort map[string]int `json:"protoPort"`
InfoLink string `json:"onlineDocumentation"`
diff --git a/components/husk_test.go b/components/husk_test.go
new file mode 100644
index 0000000..9364b53
--- /dev/null
+++ b/components/husk_test.go
@@ -0,0 +1,38 @@
+package components
+
+import (
+ "testing"
+)
+
+type sProtocolsTestStruct struct {
+ input map[string]int
+ expectedOutput []string
+}
+
+var sProtocolsTestParams = []sProtocolsTestStruct{
+ {makeEmptyProtoPortMap(), nil},
+ {makeProtoPortMapWithPortZero(), nil},
+ {makeFullProtoPortMap(), []string{"Port1", "Port2"}},
+}
+
+func makeEmptyProtoPortMap() map[string]int {
+ return make(map[string]int)
+}
+
+func makeProtoPortMapWithPortZero() map[string]int {
+ return map[string]int{"Port": 0}
+}
+
+func makeFullProtoPortMap() map[string]int {
+ return map[string]int{"Port1": 123, "Port2": 404, "Port3": 0}
+}
+
+func TestSProtocols(t *testing.T) {
+ for _, testCase := range sProtocolsTestParams {
+ res := SProtocols(testCase.input)
+
+ if len(res) != len(testCase.expectedOutput) {
+ t.Errorf("Expected %v, got: %v", testCase.expectedOutput, res)
+ }
+ }
+}
diff --git a/components/service.go b/components/service.go
index ff8a14c..2243f2d 100644
--- a/components/service.go
+++ b/components/service.go
@@ -26,7 +26,7 @@ package components
type Service struct {
ID int `json:"-"` // Id assigned by the Service Registrar
Definition string `json:"definition"` // Service definition or purpose
- SubPath string `json:"-"` // The URL subpath after the resource's
+ SubPath string `json:"subpath"` // The URL subpath after the resource's
Details map[string][]string `json:"details"` // Metadata or details about the service
RegPeriod int `json:"registrationPeriod"` // The period until the registrar is expecting a sign of life
RegTimestamp string `json:"-"` // the creation date in the Service Registry to ensure that reRegistration is with the same record
@@ -37,7 +37,7 @@ type Service struct {
CUnit string `json:"costUnit"` // cost unit
}
-// type Services is a collection of service stucts
+// type Services is a collection of service structs
type Services map[string]*Service
// Merge method is used in the configuration use case to prevent the subpath or description to be changed or "configured"
@@ -113,10 +113,11 @@ func MergeDetails(map1, map2 map[string][]string) map[string][]string {
// A Cervice is a consumed service
type Cervice struct {
- Definition string
- Details map[string][]string
- Nodes map[string][]string
- Protos []string
+ IReferentce string // Internal reference when consuming more than one service of the same type
+ Definition string // Service definition or purpose
+ Details map[string][]string
+ Nodes map[string][]string
+ Protos []string
}
// Cervises is a collection of "Cervice" structs
diff --git a/components/service_test.go b/components/service_test.go
new file mode 100644
index 0000000..3c400b2
--- /dev/null
+++ b/components/service_test.go
@@ -0,0 +1,210 @@
+package components
+
+import (
+ "fmt"
+ "testing"
+)
+
+func manualEqualityCheck(map1 map[string][]string, map2 map[string][]string) error {
+ if len(map1) != len(map2) {
+ return fmt.Errorf("Expected map length %d, got %d", len(map2), len(map1))
+ }
+ for key, value := range map2 {
+ mv, ok := map1[key]
+ if !ok {
+ return fmt.Errorf("Expected key %q not found in merged map", key)
+ }
+ if len(mv) != len(value) {
+ return fmt.Errorf("For key %q, expected slice length %d, got %d", key, len(value), len(mv))
+ }
+ for i := range value {
+ if mv[i] != value[i] {
+ return fmt.Errorf("For key %q, at index %d, expected %q, got %q", key, i, value[i], mv[i])
+ }
+ }
+ }
+ for key := range map1 {
+ if _, ok := map2[key]; !ok {
+ return fmt.Errorf("Unexpected key %q found in merged map", key)
+ }
+ }
+ return nil
+}
+
+var testServiceWithEmptyDetails = Service{
+ ID: 1,
+ Definition: "original one",
+ SubPath: "testOriginalSubPath",
+ Details: make(map[string][]string),
+ RegPeriod: 45,
+ RegTimestamp: "",
+ RegExpiration: "",
+ Description: "A test original service",
+ SubscribeAble: false,
+ ACost: 0,
+ CUnit: "",
+}
+
+var testService = Service{
+ ID: 1,
+ Definition: "test",
+ SubPath: "testSubPath",
+ Details: make(map[string][]string),
+ RegPeriod: 45,
+ RegTimestamp: "",
+ RegExpiration: "",
+ Description: "A test service",
+ SubscribeAble: false,
+ ACost: 0,
+ CUnit: "",
+}
+
+var testOriginalService = Service{
+ ID: 1,
+ Definition: "original one",
+ SubPath: "testOriginalSubPath",
+ Details: map[string][]string{"test": {"test1", "test2"}},
+ RegPeriod: 45,
+ RegTimestamp: "",
+ RegExpiration: "",
+ Description: "A test original service",
+ SubscribeAble: false,
+ ACost: 0,
+ CUnit: "",
+}
+
+func TestMerge(t *testing.T) {
+ testService.Merge(&testOriginalService)
+ if testService.Definition != testOriginalService.Definition ||
+ testService.SubPath != testOriginalService.SubPath ||
+ testService.Description != testOriginalService.Description {
+ t.Errorf("Expected the test service to be the same as the original test service %s, got: %s",
+ testOriginalService.Definition, testService.Definition)
+ }
+}
+
+func TestDeepCopy(t *testing.T) {
+ res := testOriginalService.DeepCopy()
+ res.Details["test"][0] = "changed"
+ res.Details["newkey"] = []string{"newTest"}
+
+ if testOriginalService.Details["test"][0] == "changed" {
+ t.Errorf("DeepCopy failed, expected original slice to remain, original slice was mutated")
+ }
+ if _, ok := testOriginalService.Details["newkey"]; ok {
+ t.Errorf("DeepCopy failed, expected no new key in original, got %s", testOriginalService.Details["newkey"])
+ }
+
+ res = testServiceWithEmptyDetails.DeepCopy()
+ if len(res.Details) != 0 {
+ t.Errorf("DeepCopy failed, expected details map to be empty after copy, got: %v", res.Details)
+ }
+}
+
+func makeNewTestService(id int, definition string) *Service {
+ return &Service{
+ ID: id,
+ Definition: definition,
+ SubPath: "newTestServiceSubPath",
+ Details: make(map[string][]string),
+ RegPeriod: 45,
+ RegTimestamp: "",
+ RegExpiration: "",
+ Description: "A new test Service",
+ SubscribeAble: false,
+ ACost: 0,
+ CUnit: "",
+ }
+}
+
+func TestCloneServices(t *testing.T) {
+ test1 := makeNewTestService(1, "test")
+ test2 := makeNewTestService(2, "test")
+
+ cloned := CloneServices([]Service{*test1, *test2})
+ if len(cloned) != 1 {
+ t.Errorf("Expected 1 Service, got %d", len(cloned))
+ }
+ if cloned["test"].ID != 2 {
+ t.Errorf("Second Service did not overwrite the first as expected")
+ }
+
+ cloned["test"].ID = 3
+ if test1.ID == 3 || test2.ID == 3 {
+ t.Errorf("DeepCopy failed: mutation of clone affected either one of the originals")
+ }
+
+ cloned = CloneServices(nil)
+ if cloned == nil {
+ t.Errorf("Expected non-nil empty map for nil input")
+ }
+ if len(cloned) != 0 {
+ t.Errorf("Expected 0 Services, got %d", len(cloned))
+ }
+
+ test1 = makeNewTestService(1, "")
+ test2 = makeNewTestService(2, "")
+
+ cloned = CloneServices([]Service{*test1, *test2})
+
+ if len(cloned) != 1 {
+ t.Errorf("Expected 1 entry, got %d", len(cloned))
+ }
+}
+
+func makeNewMap(key string, value string) map[string][]string {
+ newMap := map[string][]string{
+ key: {value},
+ }
+ return newMap
+}
+
+var expectedRegularMerge = map[string][]string{
+ "a": {"1"},
+ "b": {"2"},
+}
+
+var expectedKeyOverlapMerge = map[string][]string{
+ "a": {"1", "3"},
+}
+
+var expectedOneEmptyMapMerge = map[string][]string{
+ "a": {"1"},
+}
+
+var expectedBothEmptyMapMerge = map[string][]string{}
+
+type mergeDetailsTestStruct struct {
+ map1 map[string][]string
+ map2 map[string][]string
+ expected map[string][]string
+}
+
+var mergeDetailsTestParams = []mergeDetailsTestStruct{
+ {makeNewMap("a", "1"), makeNewMap("b", "2"), expectedRegularMerge},
+ {makeNewMap("a", "1"), makeNewMap("a", "3"), expectedKeyOverlapMerge},
+ {makeNewMap("a", "1"), make(map[string][]string), expectedOneEmptyMapMerge},
+ {make(map[string][]string), make(map[string][]string), expectedBothEmptyMapMerge},
+}
+
+func TestMergeDetails(t *testing.T) {
+ for _, test := range mergeDetailsTestParams {
+ merged := MergeDetails(test.map1, test.map2)
+
+ err := manualEqualityCheck(merged, test.expected)
+ if err != nil {
+ t.Errorf("Expected %v, got %v", test.expected, merged)
+ }
+
+ if len(merged) != 0 {
+ merged["a"][0] = "changed"
+
+ err1 := manualEqualityCheck(merged, test.map1)
+ err2 := manualEqualityCheck(merged, test.map2)
+ if err1 != nil || err2 != nil {
+ continue // The two maps should not be equal so if we get an "error" the test case has passed
+ }
+ t.Errorf("A change in the merged map resulted in a change in the input maps")
+ }
+ }
+}
diff --git a/components/system.go b/components/system.go
index abccc4b..a2e7452 100644
--- a/components/system.go
+++ b/components/system.go
@@ -1,5 +1,5 @@
/*******************************************************************************
- * Copyright (c) 2024 Synecdoque
+ * Copyright (c) 2025 Synecdoque
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -22,16 +22,21 @@
package components
import (
+ "bytes"
"context"
"fmt"
+ "io"
+ "net/http"
+ "net/url"
"os"
"os/signal"
+ "sync"
"syscall"
)
-// System struct aggragates an Arrowhead compliant system
+// System struct aggregates an Arrowhead compliant system
type System struct {
- Name string `json:"systemname"`
+ Name string `json:"systemName"`
Host *HostingDevice // the system runs on a device
Husk *Husk // the system aggregates a "husk" (a wrapper or a shell)
UAssets map[string]*UnitAsset // the system aggregates "asset", which is made up of one or more unit-asset
@@ -39,13 +44,15 @@ type System struct {
Ctx context.Context // create a context that can be cancelled
Sigs chan os.Signal // channel to initiate a graceful shutdown when Ctrl+C is pressed
RegistrarChan chan *CoreSystem // channel for the lead service registrar
+ // Tracks which hosts to send log msgs to (and how many errors were encountered, before being removed)
+ Messengers map[string]int // list of messenger systems
+ Mutex *sync.Mutex
}
// CoreSystem struct holds details about the core system included in the configuration file
type CoreSystem struct {
- Name string `json:"coresystem"`
- Url string `json:"url"`
- Certificate string `json:"-"`
+ Name string `json:"coreSystem"`
+ Url string `json:"url"`
}
// NewSystem instantiates the new system and gathers the host information
@@ -58,10 +65,82 @@ func NewSystem(name string, ctx context.Context) System {
newSystem.RegistrarChan = make(chan *CoreSystem, 1)
newSystem.Host = NewDevice()
newSystem.UAssets = make(map[string]*UnitAsset) // initialize UAsset as an empty map
+ // Since the return System isn't a pointer (incorrectly), this map needs to
+ // be a pointer instead (usually not normal) and initialised (usually not needed)
+ // in order to avoid linter errors.
+ // The errors is due to this func returning a copy of newSystem and attempts
+ // to copy the mutex too, but it's not allowed for sync objects.
+ // Reference: https://stackoverflow.com/questions/37242009/function-returns-lock-by-value
+ newSystem.Messengers = make(map[string]int)
+ newSystem.Mutex = &sync.Mutex{}
return newSystem
}
-// The following code is used only for issues support on GitHub @sdoque --------------------------
+func verifyStatus(u *url.URL) ([]byte, error) {
+ resp, err := http.Get(u.String())
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ // Body must be fully drained AND closed upon returning, otherwise it might leak memory
+ body, err := io.ReadAll(resp.Body)
+ if resp.StatusCode < 200 || resp.StatusCode > 299 {
+ return nil, fmt.Errorf("bad response: %d %s", resp.StatusCode, resp.Status)
+ }
+ return body, err
+}
+
+const ServiceRegistrarName string = "serviceregistrar"
+const ServiceRegistrarLeader string = "lead Service Registrar since"
+
+// GetRunningCoreSystemURL returns the URL of a running core system based on the provided type.
+// When systemType is "serviceregistrar", it verifies the service is the lead registrar by checking
+// its /status endpoint response. For other core system types, it simply tests that the URL is accessible.
+func GetRunningCoreSystemURL(sys *System, systemType string) (string, error) {
+ // Store the latest error encountered when iterating thru the system list
+ // and then return this error if no matching system was found.
+ var lastErr error
+
+ for _, core := range sys.CoreS {
+ // Ignore unrelated systems
+ if core.Name != systemType {
+ continue
+ }
+
+ coreURL, err := url.Parse(core.Url)
+ if err != nil {
+ lastErr = fmt.Errorf("parsing core URL: %w", err)
+ continue
+ }
+
+ coreSystemURL := coreURL.String() // Preserves the original URL
+ if core.Name != ServiceRegistrarName {
+ return coreSystemURL, nil
+ }
+
+ // Perform extra checks on the response from a service registrar
+ coreURL = coreURL.JoinPath("status")
+ body, err := verifyStatus(coreURL)
+ if err != nil {
+ lastErr = fmt.Errorf("verifying registrar: %w", err)
+ continue
+ }
+
+ // Skips non-leading registrars
+ if !bytes.HasPrefix(body, []byte(ServiceRegistrarLeader)) {
+ continue
+ }
+ return coreSystemURL, nil
+ }
+
+ err := fmt.Errorf("core system '%s' not found", systemType)
+ if lastErr != nil {
+ err = fmt.Errorf("core system '%s' not found: %w", systemType, lastErr)
+ }
+ return "", err
+}
+
+// The following code is used only for issues support on GitHub @sdoque
var (
AppName string
Version string
@@ -70,6 +149,8 @@ var (
)
func getBuildInfo() {
+ // TODO: This info should be updated when setting up version release tools
+ // Leaving the fmt.Prints as is for now.
if AppName != "" {
fmt.Printf("System: %s - %s\n", AppName, Version)
fmt.Printf("Build date: %s\n", BuildDate)
diff --git a/components/system_test.go b/components/system_test.go
new file mode 100644
index 0000000..46fc674
--- /dev/null
+++ b/components/system_test.go
@@ -0,0 +1,189 @@
+package components
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+func TestNewSystem(t *testing.T) {
+ name := "TestingSystem"
+ ctx, cancel := context.WithCancel(context.Background())
+ sys := NewSystem(name, ctx)
+
+ if sys.Name != name {
+ t.Errorf("expected system name %s, got %s", name, sys.Name)
+ }
+
+ // It's a bit of a silly test but the system context is an important dependency
+ // for cancelling some background services (system registration and http servers).
+ select {
+ case <-sys.Ctx.Done():
+ t.Fatal("expected context to NOT be cancelled")
+ default:
+ // pass
+ }
+
+ cancel()
+ select {
+ case <-sys.Ctx.Done():
+ // pass
+ default:
+ t.Error("expected context to be cancelled")
+ }
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+type errorReadCloser struct {
+ r io.Reader
+ errRead error
+ errClose error
+}
+
+func (ec errorReadCloser) Read(p []byte) (n int, err error) {
+ if ec.errRead != nil {
+ return 0, ec.errRead
+ }
+ return ec.r.Read(p)
+}
+
+func (ec errorReadCloser) Close() error {
+ return ec.errClose
+}
+
+var errMockTrans = fmt.Errorf("mock error")
+
+type mockTrans struct {
+ status int
+ body string
+ err error
+ errBody error
+}
+
+func newMockTransport() *mockTrans {
+ t := &mockTrans{
+ status: http.StatusOK,
+ }
+ // Hijack the default http client so no actual http requests are sent over the network
+ http.DefaultClient.Transport = t
+ return t
+}
+
+func (t *mockTrans) setResponse(status int, body string) {
+ t.status = status
+ t.body = body
+}
+
+func (t *mockTrans) setError() {
+ t.err = errMockTrans
+}
+
+func (t *mockTrans) setBodyError() {
+ t.errBody = errMockTrans
+}
+
+// RoundTrip method is required to fulfil the RoundTripper interface (as required by the DefaultClient).
+// It prevents the request from being sent over the network.
+func (t *mockTrans) RoundTrip(req *http.Request) (*http.Response, error) {
+ if t.err != nil {
+ return nil, t.err
+ }
+ resp := &http.Response{
+ StatusCode: t.status,
+ Status: http.StatusText(t.status),
+ Body: errorReadCloser{
+ strings.NewReader(t.body),
+ t.errBody,
+ nil,
+ },
+ ContentLength: int64(len(t.body)),
+ Request: req,
+ }
+ return resp, nil
+}
+
+const coreRegURL = "http://registrar"
+const coreFakeURL = "http://fake"
+
+var coreReg = &CoreSystem{ServiceRegistrarName, coreRegURL}
+var coreFake = &CoreSystem{"fakesystem", coreFakeURL}
+
+type sampleGetRunningCoreSystem struct {
+ name string
+ url string
+ wantErr bool
+ setup func(*mockTrans)
+}
+
+var tableGetRunningCoreSystem = []sampleGetRunningCoreSystem{
+ // Tests for non-registrars
+ // Case: unrelated system
+ {"bad name", "", true, nil},
+ // Case: url.Parse() error
+ {coreFake.Name, "", true, func(m *mockTrans) { coreFake.Url = string(rune(0)) }},
+ // Case: http.Get() no error
+ {coreFake.Name, coreFake.Url, false, func(m *mockTrans) { m.setError() }},
+ // Case: io.ReadAll() no error
+ {coreFake.Name, coreFake.Url, false, func(m *mockTrans) { m.setBodyError() }},
+ // Case: http < 200 no error
+ {coreFake.Name, coreFake.Url, false, func(m *mockTrans) { m.setResponse(199, "") }},
+ // Case: http > 299 no error
+ {coreFake.Name, coreFake.Url, false, func(m *mockTrans) { m.setResponse(300, "") }},
+ // Case: return url
+ {coreFake.Name, coreFake.Url, false, nil},
+
+ // Tests for registrars
+ // Case: url.Parse() error
+ {coreReg.Name, "", true, func(m *mockTrans) { coreReg.Url = string(rune(0)) }},
+ // Case: http.Get() error
+ {coreReg.Name, "", true, func(m *mockTrans) { m.setError() }},
+ // Case: io.ReadAll() error
+ {coreReg.Name, "", true, func(m *mockTrans) { m.setBodyError() }},
+ // Case: http < 200 error
+ {coreReg.Name, "", true, func(m *mockTrans) { m.setResponse(199, "") }},
+ // Case: http > 299 error
+ {coreReg.Name, "", true, func(m *mockTrans) { m.setResponse(300, "") }},
+ // Case: return error when missing prefix string in body for registrar
+ {coreReg.Name, "", true, nil},
+ // Case: return url
+ {coreReg.Name, coreReg.Url, false, func(m *mockTrans) {
+ m.setResponse(200, ServiceRegistrarLeader)
+ },
+ },
+}
+
+func TestGetRunningCoreSystem(t *testing.T) {
+ name := "testSystem"
+ sys := NewSystem(name, context.Background())
+
+ // Case: return error for empty core system list (and should not match itself)
+ if len(sys.CoreS) != 0 {
+ t.Fatalf("expected no core systems, had %d in list", len(sys.CoreS))
+ }
+ _, err := GetRunningCoreSystemURL(&sys, name)
+ if err == nil {
+ t.Error("expected error, got nil")
+ }
+ sys.CoreS = []*CoreSystem{coreReg, coreFake}
+
+ for _, test := range tableGetRunningCoreSystem {
+ coreReg.Url = coreRegURL // reset URLs after testing url.Parse() errors
+ coreFake.Url = coreFakeURL
+ m := newMockTransport()
+ if test.setup != nil {
+ test.setup(m)
+ }
+
+ gotURL, gotErr := GetRunningCoreSystemURL(&sys, test.name)
+ switch {
+ case test.wantErr == (gotErr == nil):
+ t.Errorf("expected error = %v, got: %v", test.wantErr, gotErr)
+ case gotURL != test.url:
+ t.Errorf("expected core system URL '%s', got '%s'", test.url, gotURL)
+ }
+ }
+}
diff --git a/components/uasset.go b/components/uasset.go
index 300ba79..5b84ccd 100644
--- a/components/uasset.go
+++ b/components/uasset.go
@@ -31,5 +31,12 @@ type UnitAsset interface {
GetServices() Services
GetCervices() Cervices
GetDetails() map[string][]string
+ GetTraits() any
Serving(w http.ResponseWriter, r *http.Request, servicePath string)
}
+
+// HasTraits is an interface that defines a method to get traits of a UnitAsset.
+// used in usecases configuration and service discovery.
+type HasTraits interface {
+ GetTraits() any // or interface{} in older Go
+}
diff --git a/forms/certificateForms.go b/forms/certificate_forms.go
similarity index 95%
rename from forms/certificateForms.go
rename to forms/certificate_forms.go
index db02549..42d6841 100644
--- a/forms/certificateForms.go
+++ b/forms/certificate_forms.go
@@ -51,5 +51,8 @@ func Certificate(w http.ResponseWriter, req *http.Request, sys components.System
// Set the content type to text/plain
w.Header().Set("Content-Type", "text/plain")
- w.Write([]byte(cert))
+ _, err := w.Write([]byte(cert))
+ if err != nil {
+ log.Println("Error writing the certificate: ", err)
+ }
}
diff --git a/forms/costForms.go b/forms/cost_forms.go
similarity index 100%
rename from forms/costForms.go
rename to forms/cost_forms.go
diff --git a/forms/fileForms.go b/forms/file_forms.go
similarity index 95%
rename from forms/fileForms.go
rename to forms/file_forms.go
index 425fcaa..d94fcdd 100644
--- a/forms/fileForms.go
+++ b/forms/file_forms.go
@@ -57,6 +57,8 @@ func init() {
FormTypeMap["FileForm_v1"] = reflect.TypeOf(FileForm_v1{})
}
+const fileDir string = "files"
+
// TransferFile enables the transfer of different types files when the filename is given in the URL
func TransferFile(w http.ResponseWriter, r *http.Request) {
// Parse the URL to ensure it's valid and to easily extract parts of it
@@ -81,6 +83,10 @@ func TransferFile(w http.ResponseWriter, r *http.Request) {
contentType = "application/zip"
case ".txt":
contentType = "text/plain"
+ case ".owl":
+ contentType = "application/rdf+xml"
+ case ".ttl":
+ contentType = "text/turtle"
case ".html", ".htm":
contentType = "text/html"
case ".csv":
@@ -90,7 +96,7 @@ func TransferFile(w http.ResponseWriter, r *http.Request) {
}
// Open the requested file from the ./files directory
- dir := http.Dir("./files")
+ dir := http.Dir(fileDir)
reqFile, err := dir.Open(filename)
if err != nil {
log.Println("Requested file not found:", err)
diff --git a/forms/file_forms_test.go b/forms/file_forms_test.go
new file mode 100644
index 0000000..df763b6
--- /dev/null
+++ b/forms/file_forms_test.go
@@ -0,0 +1,152 @@
+package forms
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path"
+ "path/filepath"
+ "testing"
+)
+
+type transferFileTestStruct struct {
+ filename string
+ expectedBody string
+ expectedCode int
+ fileType string
+ testName string
+}
+
+type mockResponseWriter struct {
+ http.ResponseWriter
+ statusCode int
+}
+
+func (e *mockResponseWriter) Write(b []byte) (int, error) {
+ e.WriteHeader(300)
+ return 0, fmt.Errorf("Forced write error")
+}
+
+func (e *mockResponseWriter) WriteHeader(statusCode int) {
+ e.statusCode = statusCode
+}
+
+func (e *mockResponseWriter) Header() http.Header {
+ return make(http.Header)
+}
+
+var transferFileTestParams = []transferFileTestStruct{
+ {"test.jpeg", "\xff\xd8",
+ 200, ".jpeg", "Good case, jpeg works"},
+ {"test.zip", "\x50\x4b\x03\x04",
+ 200, ".zip", "Good case, zip works"},
+ {"test.txt", "\n", 200, ".txt", "Good case, txt works"},
+ {"test.owl", ``,
+ 200, ".owl", "Good case, owl works"},
+ {"test.ttl", "@prefix : <#> .@prefix rdf: .",
+ 200, ".ttl", "Good case, ttl works"},
+ {"test.html", "",
+ 200, ".html", "Good case, html works"},
+ {"test.csv", "id,name\n",
+ 200, ".csv", "Good case, csv works"},
+ {"test.mp4", "\x00\x00\x00\x18\x66\x74\x79\x70\x69\x73\x6f\x6d\x00\x00\x02\x00\x69\x73\x6f\x6d\x69\x73\x6f\x32",
+ 200, ".mp4", "Good case, mp4 works"},
+ {"test.txt", "Internal Server Error\n",
+ 500, ".txt", "Bad case, parsing url fails"},
+ {"wrong.txt", "Not Found\n",
+ 404, ".txt", "Bad case, file not found"},
+}
+
+var fileTypeMap = map[string][]byte{
+ ".jpeg": {0xFF, 0xD8},
+ ".zip": {0x50, 0x4B, 0x03, 0x04},
+ ".txt": []byte("\n"),
+ ".owl": []byte(``),
+ ".ttl": []byte("@prefix : <#> .@prefix rdf: ."),
+ ".html": []byte(""),
+ ".csv": []byte("id,name\n"),
+ ".mp4": {0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6F, 0x6D,
+ 0x00, 0x00, 0x02, 0x00, 0x69, 0x73, 0x6F, 0x6D, 0x69, 0x73, 0x6F, 0x32},
+}
+
+func createTestFolderAndFile(filename string, fileType string) error {
+ fullPath := filepath.Join(fileDir, filename)
+ err := os.MkdirAll(fileDir, 0755)
+ if err != nil {
+ return err
+ }
+
+ f, err := os.OpenFile(fullPath, os.O_CREATE|os.O_RDWR, 0600)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ return os.WriteFile(fullPath, fileTypeMap[fileType], 0644)
+}
+
+func removeTestFolderAndFile() error {
+ return os.RemoveAll(fileDir)
+}
+
+func TestTransferFile(t *testing.T) {
+ for _, testCase := range transferFileTestParams {
+ fileURL := "/" + path.Join(fileDir, testCase.filename)
+ inputW := httptest.NewRecorder()
+ inputR := httptest.NewRequest(http.MethodPost, fileURL, nil)
+ if testCase.testName == "Bad case, parsing url fails" {
+ inputR.URL.Path = "/foo%ZZbar"
+ }
+ if testCase.testName == "Bad case, file not found" {
+ inputR.URL.Path = "/files/doesNotExist.error"
+ }
+
+ err := createTestFolderAndFile(testCase.filename, testCase.fileType)
+ if err != nil {
+ t.Error(err)
+ continue
+ }
+ TransferFile(inputW, inputR)
+ err = removeTestFolderAndFile()
+ if err != nil {
+ t.Error(err)
+ }
+
+ if inputW.Body.String() != testCase.expectedBody || inputW.Code != testCase.expectedCode {
+ t.Errorf("Expected: %s and %d, got: %s and %d",
+ testCase.expectedBody, testCase.expectedCode, inputW.Body.String(), inputW.Code)
+ }
+ }
+
+ // Special case
+ fullPath := "/files/test.txt"
+ specialRecorder := &mockResponseWriter{}
+ inputR := httptest.NewRequest(http.MethodPost, fullPath, nil)
+ err := createTestFolderAndFile("test.txt", ".txt")
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ TransferFile(specialRecorder, inputR)
+ err = removeTestFolderAndFile()
+ if err != nil {
+ t.Error(err)
+ }
+
+ if specialRecorder.statusCode != 300 {
+ t.Errorf("Expected status code 300, got: %d", specialRecorder.statusCode)
+ }
+}
+
+func TestFileEscape(t *testing.T) {
+ inputW := httptest.NewRecorder()
+ inputR := httptest.NewRequest(http.MethodPost, "http://localhost/../signal_forms.go", nil)
+ TransferFile(inputW, inputR)
+
+ if inputW.Code != 404 {
+ t.Errorf("Expected error code 404, got: %d", inputW.Code)
+ }
+}
diff --git a/forms/formsDefiniton.go b/forms/forms_definition.go
similarity index 100%
rename from forms/formsDefiniton.go
rename to forms/forms_definition.go
diff --git a/forms/message_forms.go b/forms/message_forms.go
new file mode 100644
index 0000000..56a494d
--- /dev/null
+++ b/forms/message_forms.go
@@ -0,0 +1,98 @@
+package forms
+
+import (
+ "fmt"
+ "reflect"
+)
+
+// Register the forms
+func init() {
+ FormTypeMap[messengerRegistrationVersion] = reflect.TypeOf(MessengerRegistration_v1{})
+ FormTypeMap[systemMessageVersion] = reflect.TypeOf(SystemMessage_v1{})
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+type MessengerRegistration_v1 struct {
+ Host string `json:"host"`
+ Version string `json:"version"`
+}
+
+const messengerRegistrationVersion string = "MessengerRegistration_v1"
+
+func NewMessengerRegistration_v1(host string) MessengerRegistration_v1 {
+ return MessengerRegistration_v1{
+ Host: host,
+ Version: messengerRegistrationVersion,
+ }
+}
+
+func (f *MessengerRegistration_v1) NewForm() Form {
+ new := NewMessengerRegistration_v1("")
+ return &new
+}
+
+func (f *MessengerRegistration_v1) FormVersion() string { return f.Version }
+
+////////////////////////////////////////////////////////////////////////////////
+
+// MessageLevel indicates the importance or criticality of a message.
+type MessageLevel int
+
+// Mimics the levels from the "slog" package
+const (
+ LevelDebug MessageLevel = -4
+ LevelInfo MessageLevel = 0
+ LevelWarn MessageLevel = 4
+ LevelError MessageLevel = 8
+)
+
+func LevelToString(lvl MessageLevel) string {
+ switch lvl {
+ case LevelDebug:
+ return "DEBUG"
+ case LevelInfo:
+ return "INFO"
+ case LevelWarn:
+ return "WARN"
+ case LevelError:
+ return "ERROR"
+ default:
+ return "UNKNOWN"
+ }
+}
+
+// A SystemMessage is a log message sent from a system to one or many messengers.
+// The receiving messengers will note the message's time of arrival.
+// The timestamp is noted on the messenger side, so as to maintain a uniform
+// chronological order of the messages (if, for example, there exists systems
+// on other hosts with misconfigured time or timezone).
+type SystemMessage_v1 struct {
+ Level MessageLevel `json:"level"` // Severity level
+ Body string `json:"body"` // Plaintext string of the actual message to be logged.
+ System string `json:"system"` // The system sending the log
+ Version string `json:"version"`
+}
+
+const systemMessageVersion string = "SystemMessage_v1"
+
+func NewSystemMessage_v1(lvl MessageLevel, body string, system string) SystemMessage_v1 {
+ return SystemMessage_v1{
+ Level: lvl,
+ Body: body,
+ System: system,
+ Version: systemMessageVersion,
+ }
+}
+
+func (f SystemMessage_v1) String() string {
+ return fmt.Sprintf("%s %s", LevelToString(f.Level), f.Body)
+}
+
+// NewForm resets the form and defaults to using LevelInfo.
+func (f *SystemMessage_v1) NewForm() Form {
+ new := NewSystemMessage_v1(LevelInfo, "", "")
+ return &new
+}
+
+func (f *SystemMessage_v1) FormVersion() string { return f.Version }
diff --git a/forms/serviceForms.go b/forms/service_forms.go
old mode 100755
new mode 100644
similarity index 98%
rename from forms/serviceForms.go
rename to forms/service_forms.go
index e7542a2..88b81e0
--- a/forms/serviceForms.go
+++ b/forms/service_forms.go
@@ -64,7 +64,7 @@ func init() {
///////////////////////////////////////////////////////////////////////////////
type ServiceRecordList_v1 struct {
- List []ServiceRecord_v1 `list:"version"`
+ List []ServiceRecord_v1 `json:"list"`
Version string `json:"version"`
}
diff --git a/forms/servicequestForms.go b/forms/servicequest_forms.go
similarity index 97%
rename from forms/servicequestForms.go
rename to forms/servicequest_forms.go
index 79603a9..f3dee27 100644
--- a/forms/servicequestForms.go
+++ b/forms/servicequest_forms.go
@@ -30,7 +30,7 @@ import "reflect"
type ServiceQuest_v1 struct {
SysId int `json:"systemId"`
RequesterName string `json:"requesterName"`
- ServiceDefinition string `json:"serrviceDefinition"`
+ ServiceDefinition string `json:"serviceDefinition"`
Protocol string `json:"protocol"`
Details map[string][]string `json:"details"`
Version string `json:"version"`
diff --git a/forms/signalForms.go b/forms/signal_forms.go
similarity index 100%
rename from forms/signalForms.go
rename to forms/signal_forms.go
diff --git a/forms/systemForms.go b/forms/system_forms.go
old mode 100755
new mode 100644
similarity index 100%
rename from forms/systemForms.go
rename to forms/system_forms.go
diff --git a/go.mod b/go.mod
index 1b71a59..f0890bb 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,3 @@
module github.com/sdoque/mbaigo
-go 1.23.4
+go 1.24.4
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..e69de29
diff --git a/tests/configuration.go b/tests/configuration.go
new file mode 100644
index 0000000..2f52ea3
--- /dev/null
+++ b/tests/configuration.go
@@ -0,0 +1,55 @@
+package tests
+
+import (
+ "encoding/json"
+ "os/signal"
+
+ "github.com/sdoque/mbaigo/components"
+ "github.com/sdoque/mbaigo/usecases"
+)
+
+// PROPOSAL: new additions to usecases/configuration.go
+
+// NewResourceFunc is the function type used for loading unit assets that were
+// defined in "systemconfig.json".
+// A new, custom instance of [Components.UnitAsset] should be created and populated
+// with fields from the provided [usecases.ConfigurableAsset].
+// Any services or consumed services should be added too.
+// The function should then return the UnitAsset and an optional cleanup function.
+//
+// TODO: this function really needs an error return
+// TODO: feels unnecessarily confusing to provide system instance.
+type NewResourceFunc func(usecases.ConfigurableAsset, *components.System) (components.UnitAsset, func())
+
+// LoadResources loads all unit assets from rawRes (which was loaded from "systemconfig.json" file)
+// and calls newResFunc repeatedly for each loaded asset.
+// The fully loaded unit asset and an optional cleanup function are collected from
+// newResFunc and are then attached to the sys system.
+// LoadResources then returns a system cleanup function and an optional error.
+// The error always originate from [json.Unmarshal].
+func LoadResources(sys *components.System, rawRes []json.RawMessage, newResFunc NewResourceFunc) (func(), error) {
+ // Resets this map so it can be filled with loaded unit assets (rather than templates)
+ sys.UAssets = make(map[string]*components.UnitAsset)
+
+ var cleanups []func()
+ for _, raw := range rawRes {
+ var ca usecases.ConfigurableAsset
+ if err := json.Unmarshal(raw, &ca); err != nil {
+ return func() {}, err
+ }
+
+ ua, f := newResFunc(ca, sys)
+ sys.UAssets[ua.GetName()] = &ua
+ cleanups = append(cleanups, f)
+ }
+
+ doCleanups := func() {
+ for _, f := range cleanups {
+ f()
+ }
+ // Stops hijacking SIGINT and return signal control to user
+ signal.Stop(sys.Sigs)
+ close(sys.Sigs)
+ }
+ return doCleanups, nil
+}
diff --git a/tests/examples_test.go b/tests/examples_test.go
new file mode 100644
index 0000000..0e8dd1d
--- /dev/null
+++ b/tests/examples_test.go
@@ -0,0 +1,171 @@
+package tests
+
+import (
+ "context"
+ "errors"
+ "math/rand"
+ "net/http"
+ "os"
+ "path"
+
+ "github.com/sdoque/mbaigo/components"
+ "github.com/sdoque/mbaigo/forms"
+ "github.com/sdoque/mbaigo/usecases"
+)
+
+const (
+ unitName string = "randomiser"
+ unitService string = "random"
+)
+
+// Traits are Asset-specific configurable parameters
+type Traits struct {
+ Address string `json:"address"` // Address of the IO
+ Value float64 `json:"value"` // Start up value of the IO
+ MinValue float64 `json:"minValue"` // Minimum value of the IO
+ MaxValue float64 `json:"maxValue"` // Maximum value of the IO
+}
+
+// The most simplest unit asset
+type uaRandomiser struct {
+ Name string `json:"-"`
+ Owner *components.System `json:"-"`
+ Details map[string][]string `json:"-"`
+ ServicesMap components.Services `json:"-"`
+ CervicesMap components.Cervices `json:"-"`
+ Traits
+}
+
+// Force type check (fulfilling the interface) at compile time
+var _ components.UnitAsset = &uaRandomiser{}
+
+// Add required functions to fulfil the UnitAsset interface
+func (ua uaRandomiser) GetName() string { return ua.Name }
+func (ua uaRandomiser) GetServices() components.Services { return ua.ServicesMap }
+func (ua uaRandomiser) GetCervices() components.Cervices { return ua.CervicesMap }
+func (ua uaRandomiser) GetDetails() map[string][]string { return ua.Details }
+func (ua uaRandomiser) GetTraits() any { return ua.Traits }
+func (ua uaRandomiser) Serving(w http.ResponseWriter, r *http.Request, servicePath string) {
+ if servicePath != unitService {
+ http.Error(w, "unknown service path: "+servicePath, http.StatusBadRequest)
+ return
+ }
+
+ f := forms.SignalA_v1a{
+ Value: rand.Float64(),
+ }
+ b, err := usecases.Pack(f.NewForm(), "application/json")
+ if err != nil {
+ http.Error(w, "error from Pack: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if _, err := w.Write(b); err != nil {
+ http.Error(w, "error from Write: "+err.Error(), http.StatusInternalServerError)
+ }
+}
+
+func createUATemplate(sys *components.System) {
+ s := &components.Service{
+ Definition: unitService, // The "name" of the service
+ SubPath: unitService, // Not "allowed" to be changed afterwards
+ Details: map[string][]string{"key1": {"value1"}},
+ RegPeriod: 60,
+ // NOTE: must start with lower-case, it gets embedded into another sentence in the web API
+ Description: "returns a random float64",
+ }
+ ua := components.UnitAsset(&uaRandomiser{
+ Name: unitName, // WARN: don't use the system name!! this is an asset!
+ Details: map[string][]string{"key2": {"value2"}},
+ ServicesMap: components.Services{
+ s.SubPath: s,
+ },
+ })
+ sys.UAssets[ua.GetName()] = &ua
+}
+
+func loadUAConfig(ca usecases.ConfigurableAsset, sys *components.System) (components.UnitAsset, func()) {
+ s := ca.Services[0]
+ ua := &uaRandomiser{
+ Name: ca.Name,
+ Owner: sys,
+ Details: ca.Details,
+ ServicesMap: usecases.MakeServiceMap(ca.Services),
+ // Let it consume its own service
+ CervicesMap: components.Cervices{unitService: &components.Cervice{
+ Definition: s.Definition,
+ Details: s.Details,
+ // Nodes will be filled up by any discovered cervices
+ Nodes: make(map[string][]string, 0),
+ }},
+ }
+ return ua, func() {}
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+const (
+ systemName string = "test"
+ systemPort int = 29999
+)
+
+var serviceURL = "GET /" + path.Join(systemName, unitName, unitService)
+
+// The most simplest system
+func newSystem() (*components.System, func(), error) {
+ ctx, cancel := context.WithCancel(context.Background())
+
+ // TODO: want this to return a pointer type instead!
+ // easier to use and pointer is used all the time anyway down below
+ sys := components.NewSystem(systemName, ctx)
+ sys.Husk = &components.Husk{
+ Description: " is the most simplest system possible",
+ Details: map[string][]string{"key3": {"value3"}},
+ ProtoPort: map[string]int{"http": systemPort},
+ }
+
+ // Setup default config with default unit asset and values
+ createUATemplate(&sys)
+ rawResources, err := usecases.Configure(&sys)
+
+ // Extra check to work around "created config" error. Not required normally!
+ if err != nil {
+ // Return errors not related to config creation
+ if errors.Is(err, usecases.ErrNewConfig) == false {
+ cancel()
+ return nil, nil, err
+ }
+ // Since Configure() created the config file, it must be cleaned up when this test is done!
+ defer os.Remove("systemconfig.json")
+ // Default config file was created, redo the func call to load the file
+ rawResources, err = usecases.Configure(&sys)
+ if err != nil {
+ cancel()
+ return nil, nil, err
+ }
+ }
+ // NOTE: if the config file already existed (thus the above error block didn't
+ // get to run), then the config file should be left alone and not removed!
+
+ // Load unit assets defined in the config file
+ cleanups, err := LoadResources(&sys, rawResources, loadUAConfig)
+ if err != nil {
+ cancel()
+ return nil, nil, err
+ }
+
+ // TODO: this is not ready for production yet?
+ // usecases.RequestCertificate(&sys)
+
+ usecases.RegisterServices(&sys)
+
+ // TODO: prints logs
+ usecases.SetoutServers(&sys)
+
+ stop := func() {
+ cancel()
+ // TODO: a waitgroup or something should be used to make sure all goroutines have stopped
+ // Not doing much in the mock cleanups so this works fine for now...?
+ cleanups()
+ }
+ return &sys, stop, nil
+}
diff --git a/tests/integration_test.go b/tests/integration_test.go
new file mode 100644
index 0000000..3ccf208
--- /dev/null
+++ b/tests/integration_test.go
@@ -0,0 +1,246 @@
+package tests
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "path"
+ "runtime"
+ "runtime/pprof"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/sdoque/mbaigo/components"
+ "github.com/sdoque/mbaigo/forms"
+ "github.com/sdoque/mbaigo/usecases"
+)
+
+type requestEvent struct {
+ event string
+ hits int
+ body []byte
+}
+
+// Mock simulating traffic between a system and registrars/orchestrators
+type mockTrans struct {
+ t *testing.T
+ hits map[string]int // Used to track http requests
+ mutex sync.Mutex // For protecting access to the above map
+ events chan requestEvent // Tracks service "events" and requests to the cloud services
+}
+
+func newMockTransport(t *testing.T) *mockTrans {
+ m := &mockTrans{
+ t: t,
+ hits: make(map[string]int),
+ events: make(chan requestEvent),
+ }
+ // Hijack the default http client so no actual http requests are sent over the network
+ http.DefaultClient.Transport = m
+ return m
+}
+
+func (m *mockTrans) waitFor(event string) (int, []byte, error) {
+ select {
+ case e := <-m.events:
+ if e.event != event {
+ return 0, nil, fmt.Errorf("got %s, expected %s", e.event, event)
+ }
+ return e.hits, e.body, nil
+ case <-time.Tick(10 * time.Second):
+ return 0, nil, fmt.Errorf("event timeout")
+ }
+}
+
+func newServiceRecord() []byte {
+ f := forms.ServiceRecord_v1{
+ Id: 13, // NOTE: this should match with eventUnregister
+ Created: time.Now().Format(time.RFC3339),
+ EndOfValidity: time.Now().Format(time.RFC3339),
+ Version: "ServiceRecord_v1",
+ }
+ b, err := usecases.Pack(&f, "application/json")
+ if err != nil {
+ panic(err) // Hard fail if Pack() can't handle the above form
+ }
+ return b
+}
+
+func newServicePoint() []byte {
+ f := forms.ServicePoint_v1{
+ // per usecases/registration.go:serviceRegistrationForm()
+ ServNode: fmt.Sprintf("localhost_%s_%s_%s", systemName, unitName, unitService),
+ // per orchestrator/thing.go:selectService()
+ ServLocation: fmt.Sprintf("http://localhost:%d/%s/%s/%s",
+ systemPort, systemName, unitName, unitService,
+ ),
+ Version: "ServicePoint_v1",
+ }
+ b, err := usecases.Pack(&f, "application/json")
+ if err != nil {
+ panic(err) // Another hard fail if Pack() can't work with the above form
+ }
+ return b
+}
+
+const (
+ eventRegistryStatus string = "GET /serviceregistrar/registry/status"
+ eventRegister string = "POST /serviceregistrar/registry/register"
+ eventUnregister string = "DELETE /serviceregistrar/registry/unregister/13"
+ eventOrchestration string = "GET /orchestrator/orchestration"
+ eventOrchestrate string = "POST /orchestrator/orchestration/squest"
+)
+
+var mockRequests = map[string]struct {
+ sendEvent bool
+ status int
+ body []byte
+}{
+ eventRegistryStatus: {false, 200, []byte(components.ServiceRegistrarLeader)},
+ eventRegister: {true, 200, newServiceRecord()},
+ eventUnregister: {true, 200, nil},
+ eventOrchestration: {false, 200, nil},
+ eventOrchestrate: {true, 200, newServicePoint()},
+}
+
+func (m *mockTrans) RoundTrip(req *http.Request) (*http.Response, error) {
+ m.mutex.Lock() // This lock is mainly for guarding concurrent access to the hits map
+ defer m.mutex.Unlock()
+ event := req.Method + " " + req.URL.Path
+ m.hits[event] += 1
+ if event == serviceURL {
+ // The example service will, through the system, return a proper response
+ return http.DefaultTransport.RoundTrip(req)
+ }
+
+ // Any other requests needs to be mocked, simulating responses from the
+ // service registrar and orchestrator.
+ mock, found := mockRequests[event]
+ if !found {
+ m.t.Errorf("unknown request: %s", event)
+ // Let's see how the system responds to this
+ mock.status = http.StatusNotImplemented
+ mock.body = []byte(http.StatusText(mock.status))
+ }
+ rec := httptest.NewRecorder()
+ rec.Header().Set("Content-Type", "application/json")
+ rec.WriteHeader(mock.status)
+ rec.Write(mock.body) // Safe to ignore the returned error, it's always nil
+
+ // Allows for syncing up the test, with the request flow performed by the system
+ if mock.sendEvent {
+ var b []byte
+ if req.Body != nil {
+ var err error
+ b, err = io.ReadAll(req.Body)
+ if err != nil {
+ m.t.Errorf("failed reading request body: %v", err)
+ }
+ defer req.Body.Close()
+ }
+ // Using a goroutine prevents thread locking
+ go func(e string, h int, b []byte) {
+ m.events <- requestEvent{e, h, b}
+ }(event, m.hits[event], b)
+ }
+ return rec.Result(), nil
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+func countGoroutines() (int, string) {
+ c := runtime.NumGoroutine()
+ buf := &bytes.Buffer{}
+ // A write to this buffer will always return nil error, so safe to ignore here.
+ // This call will spawn some goroutine too, so need to chill for a little while.
+ _ = pprof.Lookup("goroutine").WriteTo(buf, 2)
+ trace := buf.String()
+ // Calling signal.Notify() will leave an extra goroutine that runs forever,
+ // so it should be subtracted from the count. For more info, see:
+ // https://github.com/golang/go/issues/52619
+ // https://github.com/golang/go/issues/72803
+ // https://github.com/golang/go/issues/21576
+ if strings.Contains(trace, "os/signal.signal_recv") {
+ c -= 1
+ }
+ return c, trace
+}
+
+func assertNotEq(t *testing.T, got, want any) {
+ if got != want {
+ t.Errorf("got %v, expected %v", got, want)
+ }
+}
+
+func TestSimpleSystemIntegration(t *testing.T) {
+ routinesStart, _ := countGoroutines()
+ m := newMockTransport(t)
+ sys, stopSystem, err := newSystem()
+ if err != nil {
+ t.Fatalf("expected no error, got: %s", err)
+ }
+
+ // Validate service registration
+ hits, body, err := m.waitFor(eventRegister)
+ assertNotEq(t, err, nil)
+ if hits != 1 {
+ t.Errorf("system skipped: %s", eventRegister)
+ }
+ var sr forms.ServiceRecord_v1
+ err = json.Unmarshal(body, &sr)
+ assertNotEq(t, err, nil)
+ assertNotEq(t, sr.SystemName, systemName)
+ assertNotEq(t, sr.SubPath, path.Join(unitName, unitService))
+
+ // Validate service usage
+ ua := *sys.UAssets[unitName]
+ if ua == nil {
+ t.Fatalf("system missing unit asset: %s", unitName)
+ }
+ service := ua.GetCervices()[unitService]
+ if service == nil {
+ t.Fatalf("unit asset missing cervice: %s", unitService)
+ }
+ f, err := usecases.GetState(service, sys)
+ assertNotEq(t, err, nil)
+ fs, ok := f.(*forms.SignalA_v1a)
+ if ok == false || fs == nil || fs.Value == 0.0 {
+ t.Errorf("invalid form: %#v", f)
+ }
+
+ // Late validation for service discovery
+ hits, body, err = m.waitFor(eventOrchestrate)
+ assertNotEq(t, err, nil)
+ if hits != 1 {
+ t.Errorf("system skipped: %s", eventUnregister)
+ }
+ var sq forms.ServiceQuest_v1
+ err = json.Unmarshal(body, &sq)
+ assertNotEq(t, err, nil)
+ assertNotEq(t, sq.ServiceDefinition, unitService)
+
+ // Validate service unregister
+ stopSystem()
+ hits, _, err = m.waitFor(eventUnregister) // NOTE: doesn't receive a body
+ assertNotEq(t, err, nil)
+ if hits != 1 {
+ t.Errorf("system skipped: %s", eventUnregister)
+ }
+
+ // Detect any leaking goroutines
+ // Delay a short moment and let the goroutines finish. Not sure if there's
+ // a better way to wait for an _unknown number_ of goroutines.
+ // This might give flaky test results in slower environments!
+ time.Sleep(1 * time.Second)
+ routinesStop, trace := countGoroutines()
+ if (routinesStop - routinesStart) != 0 {
+ t.Errorf("leaking goroutines: count at start=%d, stop=%d\n%s",
+ routinesStart, routinesStop, trace,
+ )
+ }
+}
diff --git a/usecases/authentication.go b/usecases/authentication.go
index 134ff25..d37d1ce 100644
--- a/usecases/authentication.go
+++ b/usecases/authentication.go
@@ -29,6 +29,7 @@ import (
"encoding/pem"
"fmt"
"log"
+ "net"
"net/http"
"strings"
@@ -40,18 +41,28 @@ func RequestCertificate(sys *components.System) {
// Generate ECDSA Private Key
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
- log.Fatalf("Failed to generate private key: %v", err)
+ log.Fatalf("Failed to generate private key: %v\n", err)
}
sys.Husk.Pkey = privateKey
+ dnsNames := []string{"localhost"}
+ var ipAddrs []net.IP
+ for _, ipStr := range sys.Host.IPAddresses {
+ ip := net.ParseIP(ipStr)
+ if ip != nil {
+ ipAddrs = append(ipAddrs, ip)
+ }
+ }
csrTemplate := x509.CertificateRequest{
Subject: sys.Husk.DName,
+ DNSNames: dnsNames, // this is the SAN DNS
+ IPAddresses: ipAddrs, // this is the SAN IPs
SignatureAlgorithm: x509.ECDSAWithSHA256,
}
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, privateKey)
if err != nil {
- log.Fatalf("Failed to create CSR: %v", err)
+ log.Fatalf("Failed to create CSR: %v\n", err)
return
}
@@ -61,7 +72,7 @@ func RequestCertificate(sys *components.System) {
// Send the CSR to the CA and receive the certificate in response
response, err := sendCSR(sys, csrPEM)
if err != nil {
- log.Printf("certification failure: %v", err)
+ log.Printf("certification failure: %v\n", err)
return
}
@@ -71,7 +82,7 @@ func RequestCertificate(sys *components.System) {
// Get CA's certificate
caCert, err := getCACertificate(sys)
if err != nil {
- log.Printf("failed to obtain CA's certificate: %v", err)
+ log.Printf("failed to obtain CA's certificate: %v\n", err)
return
}
sys.Husk.CA_cert = caCert
@@ -79,13 +90,13 @@ func RequestCertificate(sys *components.System) {
// Load CA certificate
caCertPool := x509.NewCertPool()
if ok := caCertPool.AppendCertsFromPEM([]byte(caCert)); !ok {
- log.Fatalf("Failed to append CA certificate to pool")
+ log.Fatalf("Failed to append CA certificate to pool\n")
}
// Prepare the client's certificate and key for TLS configuration
clientCert, err := prepareClientCertificate(sys.Husk.Certificate, sys.Husk.Pkey)
if err != nil {
- log.Fatalf("Failed to prepare client certificate: %v", err)
+ log.Fatalf("Failed to prepare client certificate: %v\n", err)
}
// Configure Transport Layer Security (TLS)
@@ -93,6 +104,7 @@ func RequestCertificate(sys *components.System) {
Certificates: []tls.Certificate{clientCert},
RootCAs: caCertPool,
InsecureSkipVerify: false,
+ MinVersion: tls.VersionTLS12,
}
sys.Husk.TlsConfig = tlsConfig
@@ -100,7 +112,7 @@ func RequestCertificate(sys *components.System) {
fmt.Printf("System %s's parsed Certificate:\n", sys.Name)
cert, err := x509.ParseCertificate(clientCert.Certificate[0])
if err != nil {
- log.Printf("failed to parse certificate: %v", err)
+ log.Printf("failed to parse certificate: %v\n", err)
return
}
fmt.Printf(" Subject: %s\n", cert.Subject)
@@ -108,23 +120,25 @@ func RequestCertificate(sys *components.System) {
fmt.Printf(" Serial Number: %d\n", cert.SerialNumber)
fmt.Printf(" Not Before: %s\n", cert.NotBefore)
fmt.Printf(" Not After: %s\n", cert.NotAfter)
+ fmt.Printf(" DNS Names: %v\n", cert.DNSNames)
+ fmt.Printf(" IP Addresses: %v\n", cert.IPAddresses)
+
}
func sendCSR(sys *components.System, csrPEM []byte) (string, error) {
- var err error
- url := ""
- for _, cSys := range sys.CoreS {
- core := cSys
- if core.Name == "ca" {
- url = core.Url
- }
- }
- if url == "" {
- return "", fmt.Errorf("failed to locate certificate authority: %w", err)
+ url, err := components.GetRunningCoreSystemURL(sys, "ca") // Assuming the first core system is the CA
+ if err != nil {
+ return "", fmt.Errorf("failed to get CA URL: %w", err)
}
url += "/certify"
- resp, err := http.Post(url, "application/x-pem-file", bytes.NewReader(csrPEM))
+ req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(csrPEM))
+ if err != nil {
+ log.Printf("Error creating request: %v", err)
+ return "", err
+ }
+ req.Header.Set("Content-Type", "application/x-pem-file")
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to send CSR: %w", err)
}
@@ -137,28 +151,31 @@ func sendCSR(sys *components.System, csrPEM []byte) (string, error) {
// Read the response body
buf := new(bytes.Buffer)
- buf.ReadFrom(resp.Body)
+ _, err = buf.ReadFrom(resp.Body)
+ if err != nil {
+ log.Printf("Error while reading body: %v", err)
+ return "", err
+ }
return buf.String(), nil
}
// getCACertificate gets the CA's certificate necessary for the dual server-client authentication in the TLS setup
func getCACertificate(sys *components.System) (string, error) {
- var err error
- coreUAurl := ""
- for _, cSys := range sys.CoreS {
- core := cSys
- if core.Name == "ca" {
- coreUAurl = core.Url
- }
- }
- if coreUAurl == "" {
- return "", fmt.Errorf("failed to locate certificate authority: %w", err)
+ coreUAurl, err := components.GetRunningCoreSystemURL(sys, "ca") // Assuming the first core system is the CA
+ if err != nil {
+ return "", fmt.Errorf("failed to get CA URL: %w", err)
}
- url := strings.TrimSuffix(coreUAurl, "ification") // the configuration file address to the CA includes the unit asset
+ // Remove the "ification" suffix from the URL to get the CA's address
+ url := strings.TrimSuffix(coreUAurl, "ification")
// Make a GET request to the CA's endpoint
- resp, err := http.Get(url)
+ req, err := http.NewRequest(http.MethodGet, url, nil)
+ if err != nil {
+ log.Printf("Error creating request: %v", err)
+ return "", err
+ }
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to send request to CA: %w", err)
}
@@ -171,7 +188,11 @@ func getCACertificate(sys *components.System) (string, error) {
// Read the response body
buf := new(bytes.Buffer)
- buf.ReadFrom(resp.Body)
+ _, err = buf.ReadFrom(resp.Body)
+ if err != nil {
+ log.Printf("Error while reading body: %v", err)
+ return "", err
+ }
return buf.String(), nil
}
diff --git a/usecases/configuration.go b/usecases/configuration.go
index 4862200..221f943 100644
--- a/usecases/configuration.go
+++ b/usecases/configuration.go
@@ -1,5 +1,5 @@
/*******************************************************************************
- * Copyright (c) 2024 Synecdoque
+ * Copyright (c) 2025 Synecdoque
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -22,160 +22,173 @@
package usecases
import (
- "crypto/x509/pkix"
"encoding/json"
+ "errors"
"fmt"
"os"
"github.com/sdoque/mbaigo/components"
)
-// templateOut is the stuct used to prepare the systemconfig.json file
+// configurableAsset is a struct that contains the name of the asset and its
+// configurable details and services
+type ConfigurableAsset struct {
+ Name string `json:"name"`
+ Details map[string][]string `json:"details"`
+ Services []components.Service `json:"services"`
+ Traits []json.RawMessage `json:"traits"`
+}
+
+// templateOut is the struct used to prepare the systemconfig.json file
type templateOut struct {
CName string `json:"systemname"`
- UAsset []components.UnitAsset `json:"unit_assets"`
- CServices []components.Service `json:"services"`
+ LocalCloud string `json:"localcloud,omitempty"`
+ Assets []ConfigurableAsset `json:"unit_assets"`
Protocols map[string]int `json:"protocolsNports"`
- PKIdetails pkix.Name `json:"distinguishedName"`
CCoreS []components.CoreSystem `json:"coreSystems"`
}
-// configFileIn is used to extact out the information of the systemconfig.json file
+// configFileIn is used to extract out the information of the systemconfig.json file
// Since it does not know about the details of the Thing, it does not unmarsahll this
// information
type configFileIn struct {
- CName string `json:"systemname"`
- rawResources []json.RawMessage `json:"-"`
- CServices []components.Service `json:"services"`
- Protocols map[string]int `json:"protocolsNports"`
- PKIdetails pkix.Name `json:"distinguishedName"`
- CCoreS []components.CoreSystem `json:"coreSystems"`
+ CName string `json:"systemname"`
+ LocalCloud string `json:"localcloud,omitempty"`
+ Protocols map[string]int `json:"protocolsNports"`
+ CCoreS []components.CoreSystem `json:"coreSystems"`
+ Resources []json.RawMessage `json:"unit_assets"`
}
-// Configure read the system configuration JSON file to get the deployment details.
-// If the file is missing, it generates a default systemconfig.json file and shuts down the system
-func Configure(sys *components.System) ([]json.RawMessage, []components.Service, error) {
+var ErrNewConfig = errors.New("new config file was created")
- var rawBytes []json.RawMessage // the mbaigo library does not know about the unit asset's structure (defined in the file thing.go and not part of the library)
- var servicesList []components.Service // this is the list of services for each unit asset
- // prepare content of configuration file
- var defaultConfig templateOut
+func setupDefaultConfig(sys *components.System) (defaultConfig templateOut, err error) {
+ var assetTemplate components.UnitAsset
+ if sys.UAssets == nil {
+ return templateOut{}, fmt.Errorf("unitAssets missing")
+ }
+
+ for _, ua := range sys.UAssets {
+ assetTemplate = *ua // this creates a copy (value, not reference)
+ break
+ }
+
+ servicesTemplate := getServicesList(assetTemplate)
+ confAsset := ConfigurableAsset{
+ Name: assetTemplate.GetName(),
+ Details: assetTemplate.GetDetails(),
+ Services: servicesTemplate,
+ }
+
+ // If the asset exposes traits, serialize them and store as raw JSON
+ if assetWithTraits, ok := assetTemplate.(components.HasTraits); ok {
+ if traits := assetWithTraits.GetTraits(); traits != nil {
+ traitJSON, err := json.Marshal(traits)
+ if err != nil {
+ return templateOut{}, fmt.Errorf("couldn't marshal traits: %v", err)
+ }
+ confAsset.Traits = []json.RawMessage{traitJSON}
+ }
+ }
+
+ // prepare content of configuration file
defaultConfig.CName = sys.Name
+ for key, values := range sys.Husk.Details { // if the system has a LocalCloud detail, add it to the config file
+ if key == "LocalCloud" && len(values) > 0 {
+ defaultConfig.LocalCloud = values[0]
+ break
+ }
+ }
defaultConfig.Protocols = sys.Husk.ProtoPort
- defaultConfig.UAsset = getFirstAsset(sys.UAssets)
- originalSs := getServicesList(defaultConfig.UAsset[0])
- defaultConfig.CServices = originalSs
-
- defaultConfig.PKIdetails.CommonName = "arrowhead.eu"
- defaultConfig.PKIdetails.Country = []string{"SE"}
- defaultConfig.PKIdetails.Province = []string{"Norrbotten"}
- defaultConfig.PKIdetails.Locality = []string{"Luleaa"}
- defaultConfig.PKIdetails.Organization = []string{"Luleaa University of Technology"}
- defaultConfig.PKIdetails.OrganizationalUnit = []string{"CPS"}
-
- serReg := components.CoreSystem{
- Name: "serviceregistrar",
- Url: "http://localhost:20102/serviceregistrar/registry",
- Certificate: ".X509pubKey",
+ defaultConfig.Assets = []ConfigurableAsset{confAsset} // this is a list of unit assets
+
+ servReg := components.CoreSystem{
+ Name: "serviceregistrar",
+ Url: "http://localhost:20102/serviceregistrar/registry",
}
orches := components.CoreSystem{
- Name: "orchestrator",
- Url: "http://localhost:20103/orchestrator/orchestration",
- Certificate: ".X509pubKey",
+ Name: "orchestrator",
+ Url: "http://localhost:20103/orchestrator/orchestration",
}
ca := components.CoreSystem{
- Name: "ca",
- Url: "http://localhost:20100/ca/certification",
- Certificate: ".X509pubKey",
+ Name: "ca",
+ Url: "http://localhost:20100/ca/certification",
+ }
+ maitreD := components.CoreSystem{
+ Name: "maitreD",
+ Url: "http://localhost:20101/maitreD/maitreD",
}
- coreSystems := []components.CoreSystem{serReg, orches, ca}
+
+ // add the core systems to the configuration file
+ // the system is part of a local cloud with mandatory core systems
+ coreSystems := []components.CoreSystem{servReg, orches, ca, maitreD}
defaultConfig.CCoreS = coreSystems
+ return defaultConfig, nil
+}
- // open the configuration file or create one with the default content prepared above
- systemConfigFile, err := os.Open("systemconfig.json")
+// Configure reads the system configuration JSON file to get the deployment details.
+// If the file is missing, it generates a default systemconfig.json file and shuts down the system
+func Configure(sys *components.System) ([]json.RawMessage, error) {
+ defaultConfig, err := setupDefaultConfig(sys)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't create default config: %v", err)
+ }
- if err != nil { // could not find the systemconfig.json so a default one is being created
- defaultConfigFile, err := os.Create("systemconfig.json")
- if err != nil {
- return rawBytes, servicesList, err
- }
- defer defaultConfigFile.Close()
- systemconfigjson, err := json.MarshalIndent(defaultConfig, "", " ")
- if err != nil {
- return rawBytes, servicesList, err
- }
- nBytes, err := defaultConfigFile.Write(systemconfigjson)
+ // 0600 allows user Read/Write permission (secure config file), but no R/W for groups and others, 0644 to allow R/W on sudo and only R on groups/others, 0666 for R/W permissions for everyone
+ systemConfigFile, err := os.OpenFile("systemconfig.json", os.O_RDWR|os.O_CREATE, 0600)
+ if err != nil {
+ return nil, fmt.Errorf("error while opening/creating systemconfig file: %v", err)
+ }
+ defer systemConfigFile.Close()
+
+ fileInfo, err := systemConfigFile.Stat() // *.Stat() returns fileInfo/stats
+ if err != nil {
+ return nil, fmt.Errorf("error occurred while getting config file stats: %s", err)
+ }
+ if fileInfo.Size() == 0 { // *.Size() returns the filesize (number bytes) as an int, 0 is an empty file
+ enc := json.NewEncoder(systemConfigFile)
+ enc.SetIndent("", " ")
+ err = enc.Encode(defaultConfig) // Write default values into systemconfig since file was empty
if err != nil {
- return rawBytes, servicesList, err
+ return nil, fmt.Errorf("error writing default values to system config: %v", err)
}
- return rawBytes, servicesList, fmt.Errorf("a new configuration file has been written with %d bytes. Please update it and restart the system", nBytes)
+ return nil, ErrNewConfig
}
- // the system configuration file could be open, read the configurations and pass them on to the system
- defer systemConfigFile.Close()
- configBytes, err := os.ReadFile("systemconfig.json")
+ var configurationIn configFileIn
+ err = json.NewDecoder(systemConfigFile).Decode(&configurationIn) // Read the contents of systemconfig into configurationIn
if err != nil {
- return rawBytes, servicesList, err
+ return nil, fmt.Errorf("error reading systemconfig: %v", err)
}
- // the challenge is that the definition of the unit asset is unknown to the mbaigo library and only known to the system that invokes the library
- var configurationIn configFileIn
- // extract the information related to the system separately from the unit_assets (i.e., the resources)
- type Alias configFileIn
- aux := &struct {
- Resources []json.RawMessage `json:"unit_assets"`
- *Alias
- }{
- Alias: (*Alias)(&configurationIn),
- }
- if err := json.Unmarshal(configBytes, aux); err != nil {
- return rawBytes, servicesList, err
- }
- if len(aux.Resources) > 0 {
- configurationIn.rawResources = aux.Resources
+ var rawResources []json.RawMessage
+ if len(configurationIn.Resources) > 0 { // If unit assets was present in systemconfig file, send those
+ rawResources = configurationIn.Resources
} else {
- var rawMessages []json.RawMessage
- for _, s := range defaultConfig.UAsset {
- // convert the struct to JSON-encoded byte array
+ for _, s := range defaultConfig.Assets { // Otherwise send the system default
jsonBytes, err := json.Marshal(s)
if err != nil {
- fmt.Println("Failed to marshal struct:", err)
+ return nil, fmt.Errorf("failed to marshal struct: %v", err)
}
- rawMessages = append(rawMessages, json.RawMessage(jsonBytes)) // append the json.RawMessage to the slice
+ rawResources = append(rawResources, json.RawMessage(jsonBytes))
}
- configurationIn.rawResources = rawMessages
}
sys.Name = configurationIn.CName
- sys.Husk.DName = configurationIn.PKIdetails
+ // If the systemconfig file has a LocalCloud defined, add it to the system details
+ if configurationIn.LocalCloud != "" {
+ if sys.Husk.Details == nil {
+ sys.Husk.Details = make(map[string][]string)
+ }
+ sys.Husk.Details["LocalCloud"] = []string{configurationIn.LocalCloud}
+ }
sys.Husk.ProtoPort = configurationIn.Protocols
for _, ccore := range configurationIn.CCoreS {
newCore := ccore
sys.CoreS = append(sys.CoreS, &newCore)
}
- // update the services (e.g., re-registration period, costs, or units)
- for i := range configurationIn.CServices {
- for _, originalService := range originalSs {
- if originalService.Definition == configurationIn.CServices[i].Definition {
- configurationIn.CServices[i].Merge(&originalService) // keep the original definition and subpath as the original ones
- }
- }
- }
- servicesList = configurationIn.CServices
-
- return configurationIn.rawResources, servicesList, nil
-}
-
-// getFirstAsset returns the first key-value pair in the Assets map
-func getFirstAsset(assetMap map[string]*components.UnitAsset) []components.UnitAsset {
- var assetList []components.UnitAsset
- for key := range assetMap {
- assetList = append(assetList, *assetMap[key])
- return assetList
- }
- return assetList
+ return rawResources, nil
}
// getServicesList() returns the original list of services
@@ -187,3 +200,14 @@ func getServicesList(uat components.UnitAsset) []components.Service {
}
return serviceList
}
+
+// MakeServiceMap() creates a map of services from a slice of services
+// The map is indexed by the service subpath
+func MakeServiceMap(services []components.Service) map[string]*components.Service {
+ serviceMap := make(map[string]*components.Service)
+ for i := range services {
+ svc := services[i] // take the address of the element in the slice
+ serviceMap[svc.SubPath] = &svc
+ }
+ return serviceMap
+}
diff --git a/usecases/configuration_test.go b/usecases/configuration_test.go
new file mode 100644
index 0000000..6a5d55f
--- /dev/null
+++ b/usecases/configuration_test.go
@@ -0,0 +1,411 @@
+package usecases
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "testing"
+
+ "github.com/sdoque/mbaigo/components"
+)
+
+// A mocked UnitAsset used for testing
+type mockUnitAssetWithTraits struct {
+ Name string `json:"name"`
+ Owner *components.System `json:"-"`
+ Details map[string][]string `json:"details"`
+ ServicesMap components.Services `json:"-"`
+ CervicesMap components.Cervices `json:"-"`
+ Traits map[string][]string `json:"-"`
+}
+
+func (mua mockUnitAssetWithTraits) GetTraits() any {
+ return mua.Traits
+}
+
+func (mua mockUnitAssetWithTraits) GetName() string {
+ return mua.Name
+}
+
+func (mua mockUnitAssetWithTraits) GetServices() components.Services {
+ return mua.ServicesMap
+}
+
+func (mua mockUnitAssetWithTraits) GetCervices() components.Cervices {
+ return mua.CervicesMap
+}
+
+func (mua mockUnitAssetWithTraits) GetDetails() map[string][]string {
+ return mua.Details
+}
+
+func (mua mockUnitAssetWithTraits) Serving(w http.ResponseWriter, r *http.Request, servicePath string) {
+}
+
+// --------------------------------------------------------- //
+// Helpfunctions that creates a default config file
+// with/without any asset traits
+// --------------------------------------------------------- //
+
+// This is pretty much a copy of setupDefaultConfig() in configuration.go,
+// but this also creates and writes to a systemconfig.json file
+func createConfigHasTraits(sys *components.System) (err error) {
+ var defaultConfig templateOut
+
+ var assetTemplate components.UnitAsset
+ for _, ua := range sys.UAssets {
+ assetTemplate = *ua
+ break
+ }
+ servicesTemplate := getServicesList(assetTemplate)
+
+ confAsset := ConfigurableAsset{
+ Name: assetTemplate.GetName(),
+ Details: assetTemplate.GetDetails(),
+ Services: servicesTemplate,
+ }
+
+ setTest := &components.Service{
+ ID: 1,
+ Definition: "test",
+ SubPath: "test",
+ Details: map[string][]string{"Forms": {"SignalA_v1a"}},
+ Description: "A test service",
+ RegPeriod: 45,
+ RegTimestamp: "now",
+ RegExpiration: "45",
+ }
+ ServicesMap := &components.Services{
+ setTest.SubPath: setTest,
+ }
+ mua := &mockUnitAssetWithTraits{
+ Name: "testUnitAsset",
+ Details: map[string][]string{"Test": {"Test"}},
+ ServicesMap: *ServicesMap,
+ CervicesMap: nil,
+ Traits: map[string][]string{"Trait": {"testTrait"}},
+ }
+ var muaInterface components.UnitAsset = mua
+ sys.UAssets[mua.GetName()] = &muaInterface
+
+ // If the asset exposes traits, serialize them and store as raw JSON
+ if assetWithTraits, ok := assetTemplate.(components.HasTraits); ok {
+ if traits := assetWithTraits.GetTraits(); traits != nil {
+ traitJSON, err := json.Marshal(traits)
+ if err == nil {
+ confAsset.Traits = []json.RawMessage{traitJSON}
+ } else {
+ return err
+ }
+ }
+ }
+ defaultConfig.Assets = []ConfigurableAsset{confAsset}
+
+ leadingRegistrar := components.CoreSystem{
+ Name: "serviceregistrar",
+ Url: "http://localhost:20102/serviceregistrar/registry",
+ }
+ orchestrator := components.CoreSystem{
+ Name: "orchestrator",
+ Url: "http://localhost:20103/orchestrator/orchestration",
+ }
+ ca := components.CoreSystem{
+ Name: "ca",
+ Url: "http://localhost:20100/ca/certification",
+ }
+ maitreD := components.CoreSystem{
+ Name: "maitreD",
+ Url: "http://localhost:20101/maitreD/maitreD",
+ }
+
+ defaultConfig.CCoreS = []components.CoreSystem{leadingRegistrar, orchestrator, ca, maitreD}
+ defaultConfig.CName = sys.Name
+ defaultConfig.Protocols = sys.Husk.ProtoPort
+ defaultConfigFile, err := os.Create("systemconfig.json")
+ if err != nil {
+ return fmt.Errorf("encountered error while creating default config file: %v", err)
+ }
+ defer defaultConfigFile.Close()
+
+ enc := json.NewEncoder(defaultConfigFile)
+ enc.SetIndent("", " ")
+ err = enc.Encode(defaultConfig)
+ if err != nil {
+ return fmt.Errorf("jsonEncode: %v", err)
+ }
+ return
+}
+
+// This is pretty much a copy of setupDefaultConfig() in configuration.go,
+// but this also creates and writes to a systemconfig.json file
+func createConfigNoTraits(sys *components.System, assetAmount int) (err error) {
+ var defaultConfig templateOut
+
+ for x := range assetAmount {
+ setTest := components.Service{
+ ID: x,
+ Definition: fmt.Sprintf("test%d", x),
+ SubPath: fmt.Sprintf("test%d", x),
+ Details: map[string][]string{"Forms": {"SignalA_v1a"}},
+ Description: "A test service",
+ RegPeriod: 45,
+ RegTimestamp: "now",
+ RegExpiration: "45",
+ }
+ servList := []components.Service{setTest}
+ mua := ConfigurableAsset{
+ Name: fmt.Sprintf("testUnitAsset%d", x),
+ Details: map[string][]string{"Test": {"Test"}},
+ Services: servList,
+ }
+ defaultConfig.Assets = append(defaultConfig.Assets, mua)
+ }
+
+ leadingRegistrar := components.CoreSystem{
+ Name: "serviceregistrar",
+ Url: "http://localhost:20102/serviceregistrar/registry",
+ }
+ orchestrator := components.CoreSystem{
+ Name: "orchestrator",
+ Url: "http://localhost:20103/orchestrator/orchestration",
+ }
+ ca := components.CoreSystem{
+ Name: "ca",
+ Url: "http://localhost:20100/ca/certification",
+ }
+ maitreD := components.CoreSystem{
+ Name: "maitreD",
+ Url: "http://localhost:20101/maitreD/maitreD",
+ }
+
+ defaultConfig.CCoreS = []components.CoreSystem{leadingRegistrar, orchestrator, ca, maitreD}
+ defaultConfig.CName = sys.Name
+ defaultConfig.Protocols = sys.Husk.ProtoPort
+ defaultConfigFile, err := os.Create("systemconfig.json")
+ if err != nil {
+ return fmt.Errorf("encountered error while creating config file: %v", err)
+ }
+ defer defaultConfigFile.Close()
+
+ enc := json.NewEncoder(defaultConfigFile)
+ enc.SetIndent("", " ")
+ err = enc.Encode(defaultConfig)
+ if err != nil {
+ return fmt.Errorf("jsonEncode: %v", err)
+ }
+ return
+}
+
+// --------------------------------------------------------- //
+// Helpfunctions and structs for testing SetupDefaultConfig()
+// --------------------------------------------------------- //
+
+func cleanup() error {
+ return os.Remove("systemconfig.json")
+}
+
+type setupDefConfigParams struct {
+ expectError bool
+ setup func(*components.System) (err error)
+ cleanup func() (err error)
+ testCase string
+}
+
+func TestSetupDefaultConfig(t *testing.T) {
+ testParams := []setupDefConfigParams{
+ {
+ false,
+ func(sys *components.System) (err error) { return createConfigNoTraits(sys, 1) },
+ func() (err error) { return cleanup() },
+ "Best case",
+ },
+ {
+ false,
+ func(sys *components.System) (err error) { return createConfigHasTraits(sys) },
+ func() (err error) { return cleanup() },
+ "Good case, asset has traits",
+ },
+ {
+ true,
+ func(sys *components.System) (err error) { return createConfigHasTraits(sys) },
+ func() (err error) { return cleanup() },
+ "No assets in sys",
+ },
+ }
+
+ // Start of test
+ for _, c := range testParams {
+ testSys := createTestSystem(false)
+
+ // Setup
+ err := c.setup(&testSys)
+ if err != nil {
+ t.Errorf("setup failed: %v", err)
+ }
+
+ if c.testCase == "No assets in sys" {
+ testSys.UAssets = nil
+ }
+
+ // Test
+ _, err = setupDefaultConfig(&testSys)
+ if c.expectError == false && err != nil {
+ t.Errorf("Expected no errors in testcase '%s', got: %v", c.testCase, err)
+ }
+ if c.expectError == true && err == nil {
+ t.Errorf("expected errors in testcase '%s', got none", c.testCase)
+ }
+
+ // Cleanup
+ err = c.cleanup()
+ if err != nil {
+ t.Errorf("failed to remove 'systemconfig.json' in testcase '%s': %v", c.testCase, err)
+ }
+ }
+}
+
+// --------------------------------------------------------- //
+// Helpfunctions and structs for testing Configure()
+// --------------------------------------------------------- //
+
+type configureParams struct {
+ expectError bool
+
+ setup func(*components.System) (err error)
+ cleanup func() (err error)
+ testCase string
+}
+
+func TestConfigure(t *testing.T) {
+ testParams := []configureParams{
+ {
+ false,
+ func(sys *components.System) (err error) { return createConfigNoTraits(sys, 1) },
+ func() (err error) { return cleanup() },
+ "Best case, one asset",
+ },
+ {
+ true,
+ func(sys *components.System) (err error) {
+ _, err = os.OpenFile("systemconfig.json", os.O_RDWR|os.O_CREATE, 0000)
+ return
+ },
+ func() (err error) { return cleanup() },
+ "Can't open/create config",
+ },
+ {
+ true,
+ func(sys *components.System) (err error) { return nil },
+ func() (err error) { return cleanup() },
+ "Config missing",
+ },
+ {
+ false,
+ func(sys *components.System) (err error) { return createConfigNoTraits(sys, 0) },
+ func() (err error) { return cleanup() },
+ "No Assets in config",
+ },
+ {
+ false,
+ func(sys *components.System) (err error) { return createConfigNoTraits(sys, 3) },
+ func() (err error) { return cleanup() },
+ "Multiple Assets in config",
+ },
+ {
+ true,
+ func(sys *components.System) (err error) {
+ sys.UAssets = nil
+ return createConfigNoTraits(sys, 1)
+ },
+ func() (err error) { return cleanup() },
+ "No assets in sys",
+ },
+ }
+
+ // Start of test
+ for _, testCase := range testParams {
+ testSys := createTestSystem(false)
+
+ // Setup
+ err := testCase.setup(&testSys)
+ if err != nil {
+ t.Errorf("failed during setup: %v", err)
+ }
+
+ // Test
+ _, err = Configure(&testSys)
+ if testCase.expectError == false && err != nil {
+ t.Errorf("Expected no errors in '%s', got: %v", testCase.testCase, err)
+ }
+ if testCase.expectError == true && err == nil {
+ t.Errorf("Expected errors in testcase '%s'", testCase.testCase)
+ }
+
+ //Cleanup
+ err = testCase.cleanup()
+ if err != nil {
+ t.Errorf("failed to remove 'systemconfig.json' in testcase '%s'", testCase.testCase)
+ }
+ }
+}
+
+// --------------------------------------------------------- //
+// Testing GetServiceList()
+// --------------------------------------------------------- //
+
+func TestGetServiceList(t *testing.T) {
+ setTest := &components.Service{
+ ID: 1,
+ Definition: "test",
+ SubPath: "test",
+ Details: map[string][]string{"Forms": {"SignalA_v1a"}},
+ Description: "A test service",
+ RegPeriod: 45,
+ RegTimestamp: "now",
+ RegExpiration: "45",
+ }
+ ServicesMap := &components.Services{
+ setTest.SubPath: setTest,
+ }
+ mua := mockUnitAsset{
+ Name: "test",
+ Owner: nil,
+ Details: nil,
+ ServicesMap: *ServicesMap,
+ }
+ servList := getServicesList(mua)
+ if len(servList) != 1 && servList[0].Definition != "test" {
+ t.Errorf("Expected length: 1, got %d\tExpected 'Definition': test, got %s",
+ len(servList), servList[0].Definition)
+ }
+}
+
+// --------------------------------------------------------- //
+// Testing MakeServiceMap()
+// --------------------------------------------------------- //
+
+func TestMakeServiceMap(t *testing.T) {
+ var servList []components.Service
+ for x := range 6 {
+ serv := components.Service{
+ ID: x,
+ Definition: fmt.Sprintf("testDef%d", x),
+ SubPath: fmt.Sprintf("test%d", x),
+ Details: map[string][]string{"Forms": {"SignalA_v1a"}},
+ Description: fmt.Sprintf("test service %d", x),
+ RegPeriod: 45,
+ RegTimestamp: "now",
+ RegExpiration: "45",
+ }
+ servList = append(servList, serv)
+ }
+ servMap := MakeServiceMap(servList)
+ for c := range 6 {
+ service := fmt.Sprintf("test%d", c)
+ if servMap[service].SubPath != service || servMap[service].ID != c {
+ t.Errorf(`Expected servMap["%s"].SubPath to be "%s", with ID: "%d". Got: "%s", with ID: "%d"`,
+ service, service, c, servMap[service].SubPath, servMap[service].ID)
+ }
+ }
+}
diff --git a/usecases/consumption.go b/usecases/consumption.go
index b983ec8..2921e4e 100644
--- a/usecases/consumption.go
+++ b/usecases/consumption.go
@@ -20,13 +20,13 @@
package usecases
import (
- "bytes"
- "context"
"fmt"
"io"
"log"
+ "testing"
+
"net/http"
- "time"
+ "net/url"
"github.com/sdoque/mbaigo/components"
"github.com/sdoque/mbaigo/forms"
@@ -34,16 +34,27 @@ import (
// GetState request the current state of a unit asset (via the asset's service)
func GetState(cer *components.Cervice, sys *components.System) (f forms.Form, err error) {
- // if no known providers, search for one via the Orchestrator
+ return stateHandler(http.MethodGet, cer, sys, nil)
+}
+
+// GetStates requests the current state of certain services of a unit asset depending on requested definition and/or details
+func GetStates(cer *components.Cervice, sys *components.System) (f []forms.Form, err []error) {
+ return stateHandlers(http.MethodGet, cer, sys, nil)
+}
+
+// SetState puts a request to change the state of a unit asset (via the asset's service)
+func SetState(cer *components.Cervice, sys *components.System, bodyBytes []byte) (f forms.Form, err error) {
+ return stateHandler(http.MethodPut, cer, sys, bodyBytes)
+}
+
+func stateHandler(httpMethod string, cer *components.Cervice, sys *components.System, bodyBytes []byte) (f forms.Form, err error) {
if len(cer.Nodes) == 0 {
- err := Search4Services(cer, sys)
+ err = Search4Services(cer, sys)
if err != nil {
return f, err
}
}
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) // Create a new context, with a 2-second timeout
- defer cancel()
- // Create a new HTTP request using the first known provider
+
var serviceUrl string
for _, values := range cer.Nodes {
if len(values) > 0 {
@@ -51,99 +62,156 @@ func GetState(cer *components.Cervice, sys *components.System) (f forms.Form, er
break
}
}
- req, err := http.NewRequest(http.MethodGet, serviceUrl, nil)
+
+ resp, err := sendHTTPReq(httpMethod, serviceUrl, bodyBytes)
if err != nil {
+ cer.Nodes = make(map[string][]string) // Failed to get the resource at that location: reset the providers list, which will trigger a new service search
return f, err
}
- // Associate the cancellable context with the request
- req = req.WithContext(ctx)
- // Send the request /////////////////////////////////
- client := &http.Client{}
- resp, err := client.Do(req)
+ defer resp.Body.Close()
+
+ // If the response includes a payload, unpack it into a forms.Form
+ bodyBytes, err = io.ReadAll(resp.Body)
if err != nil {
- cer.Nodes = make(map[string][]string) // failed to get the resource at that location: reset the providers list, which will trigger a new service search
- return f, err
+ return f, fmt.Errorf("reading state response body: %w", err)
}
- defer resp.Body.Close()
- // Check if the status code indicates an error (anything outside the 200–299 range)
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- return f, fmt.Errorf("received non-2xx status code: %d, response: %s", resp.StatusCode, http.StatusText(resp.StatusCode))
+ if len(bodyBytes) < 1 {
+ return f, fmt.Errorf("got empty response body")
+
}
- bodyBytes, err := io.ReadAll(resp.Body)
- if err != nil {
- log.Printf("GetRValue-Error reading registration response body: %v", err)
- return
+ headerContentType := resp.Header.Get("Content-Type")
+ return Unpack(bodyBytes, headerContentType)
+}
+
+const messengerMaxErrors int = 3
+
+func LogDebug(sys *components.System, msg string, args ...any) {
+ Log(sys, forms.LevelDebug, msg, args...)
+}
+
+func LogInfo(sys *components.System, msg string, args ...any) {
+ Log(sys, forms.LevelInfo, msg, args...)
+}
+
+func LogWarn(sys *components.System, msg string, args ...any) {
+ Log(sys, forms.LevelWarn, msg, args...)
+}
+
+func LogError(sys *components.System, msg string, args ...any) {
+ Log(sys, forms.LevelError, msg, args...)
+}
+
+func Log(sys *components.System, lvl forms.MessageLevel, msg string, args ...any) {
+ sm := forms.NewSystemMessage_v1(lvl, fmt.Sprintf(msg, args...), sys.Name)
+ if !testing.Testing() {
+ // Only print the msg locally if not running during `go test`
+ log.Println(sm.String())
+ }
+ var body []byte
+ sys.Mutex.Lock()
+ defer sys.Mutex.Unlock()
+
+ // Iterate over all messengers and try sending a copy of the log msg
+ for host, errors := range sys.Messengers {
+ // Lazy-load the packed body, only at the first iteration
+ if body == nil {
+ var err error
+ body, err = Pack(forms.Form(&sm), "application/json")
+ if err != nil {
+ log.Printf("failed to pack SystemMessage: %v\n", err)
+ return
+ }
+ }
+
+ errCount := 0 // If there's no error while sending msg, the count is reset
+ if err := sendLogMessage(host, body); err != nil {
+ // Don't care what kinds of errors might be returned
+ errCount = errors + 1
+ }
+ if errCount >= messengerMaxErrors {
+ // Too many errors indicates a problematic messenger
+ delete(sys.Messengers, host)
+ continue
+ }
+ sys.Messengers[host] = errCount
}
+}
- headerContentTtype := resp.Header.Get("Content-Type")
- f, err = Unpack(bodyBytes, headerContentTtype)
+// Hard-coding the path is ugly but it skips an extra service discovery cycle for now
+const logMessagePath string = "/log/message"
+
+func sendLogMessage(host string, body []byte) error {
+ u, err := url.Parse(host)
if err != nil {
- fmt.Printf("error unpacking the service response: %s", err)
+ return err
}
- return f, nil
+ u = u.JoinPath(logMessagePath)
+ resp, err := sendHTTPReq(http.MethodPost, u.String(), body)
+ if err != nil {
+ return err
+ }
+ _ = resp.Body.Close() // Don't care about the response body or any errors it might cause
+ return nil
}
-// SetState puts a request to change the state of a unit asset (via the asset's service)
-func SetState(cer *components.Cervice, sys *components.System, bodyBytes []byte) (f forms.Form, err error) {
- // Get the address of the informing service of the target asset via the Orchestrator
+func stateHandlers(httpMethod string, cer *components.Cervice, sys *components.System, bodyBytes []byte) (f []forms.Form, err []error) {
if len(cer.Nodes) == 0 {
- err := Search4Services(cer, sys)
- if err != nil {
+ currentErr := Search4MultipleServices(cer, sys)
+ if currentErr != nil {
+ f = append(f, nil)
+ err = append(err, currentErr)
return f, err
}
}
- // Create a new context, with a 2-second timeout
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
- defer cancel()
-
- // Create a new HTTP request
- var serviceUrl string
+ var serviceUrls []string
for _, values := range cer.Nodes {
if len(values) > 0 {
- serviceUrl = values[0]
- break
+ serviceUrls = append(serviceUrls, values...)
}
}
- req, err := http.NewRequest(http.MethodPut, serviceUrl, bytes.NewReader(bodyBytes))
- if err != nil {
- return f, err
- }
-
- // Set the Content-Type header
- req.Header.Set("Content-Type", "application/json")
- // Associate the cancellable context with the request
- req = req.WithContext(ctx)
-
- // Send the request
- client := &http.Client{}
- resp, err := client.Do(req)
- if err != nil {
- cer.Nodes = make(map[string][]string) // Failed to get the resource at that location: reset the providers list, which will trigger a new service search
- return f, err
- }
- defer resp.Body.Close()
- // Check if the status code indicates an error (anything outside the 200–299 range)
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- return f, fmt.Errorf("received non-2xx status code: %d, response: %s", resp.StatusCode, http.StatusText(resp.StatusCode))
- }
+ for _, serviceUrl := range serviceUrls {
+ if len(serviceUrl) == 0 {
+ continue
+ }
+ resp, currentErr := sendHTTPReq(httpMethod, serviceUrl, bodyBytes)
+ if currentErr != nil {
+ cer.Nodes = make(map[string][]string)
+ f = append(f, nil)
+ err = append(err, currentErr)
+ continue
+ }
+ defer resp.Body.Close()
+
+ // If the response includes a payload, unpack it into a forms.Form
+ bodyBytes, currentErr = io.ReadAll(resp.Body)
+ if currentErr != nil {
+ currentErr = fmt.Errorf("reading state response body: %w", currentErr)
+ f = append(f, nil)
+ err = append(err, currentErr)
+ continue
+ }
- // If the response includes a payload, unpack it into a forms.Form
- bodyBytes, err = io.ReadAll(resp.Body)
- if err != nil {
- return f, fmt.Errorf("error reading response body: %v", err)
- }
+ if len(bodyBytes) < 1 {
+ currentErr = fmt.Errorf("got empty response body")
+ f = append(f, nil)
+ err = append(err, currentErr)
+ continue
+ }
- if len(bodyBytes) > 0 {
headerContentType := resp.Header.Get("Content-Type")
- f, err = Unpack(bodyBytes, headerContentType)
- if err != nil {
- return f, fmt.Errorf("error unpacking the service response: %v", err)
+ formValue, currentErr := Unpack(bodyBytes, headerContentType)
+ if currentErr != nil {
+ currentErr = fmt.Errorf("unpacking response body: %w", currentErr)
+ f = append(f, nil)
+ err = append(err, currentErr)
+ continue
}
+ f = append(f, formValue)
+ err = append(err, nil)
}
-
- return f, nil
+ return f, err
}
diff --git a/usecases/consumption_test.go b/usecases/consumption_test.go
new file mode 100644
index 0000000..bd4adf8
--- /dev/null
+++ b/usecases/consumption_test.go
@@ -0,0 +1,503 @@
+package usecases
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/sdoque/mbaigo/components"
+ "github.com/sdoque/mbaigo/forms"
+)
+
+type stateParams struct {
+ testCer *components.Cervice
+ testSys components.System
+ bodyBytes []byte
+ body func() *http.Response
+ mockTransportErr int
+ errHTTP error
+ expectedfForm forms.Form
+ expectedErr error
+ testCase string
+}
+
+func newTestCerviceWithNodes() *components.Cervice {
+ return &components.Cervice{
+ IReferentce: "test",
+ Definition: "A test Cervice with nodes",
+ Details: map[string][]string{"Forms": {"SignalA_v1a"}},
+ Nodes: map[string][]string{"test": {"https://testSystem/testUnitAsset/test"}},
+ Protos: []string{"http"},
+ }
+}
+
+func newTestCerviceWithoutNodes() *components.Cervice {
+ return &components.Cervice{
+ IReferentce: "test",
+ Definition: "A test Cervice without nodes",
+ Details: map[string][]string{"Forms": {"SignalA_v1a"}},
+ Nodes: make(map[string][]string),
+ Protos: []string{"http"},
+ }
+}
+
+func newTestCerviceWithBrokenUrl() *components.Cervice {
+ return &components.Cervice{
+ IReferentce: "test",
+ Definition: "A test Cervice with nodes",
+ Details: map[string][]string{"Forms": {"SignalA_v1a"}},
+ Nodes: map[string][]string{"test": {brokenUrl}},
+ Protos: []string{"http"},
+ }
+}
+
+var form forms.SignalA_v1a
+
+var errEmptyRespBody = errors.New("got empty response body")
+
+var errUnpack = errors.New("problem unpacking response body")
+
+func createTestBytes() []byte {
+ return []byte("{\n \"value\": 0,\n \"unit\": \"\",\n \"timestamp\": " +
+ "\"0001-01-01T00:00:00Z\",\n \"version\": \"SignalA_v1.0\"\n}")
+}
+
+func createWorkingHttpResp() func() *http.Response {
+ httpResp := func() *http.Response {
+ return &http.Response{
+ Status: "200 OK",
+ StatusCode: 200,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: io.NopCloser(strings.NewReader(string("{\n \"value\": 0,\n \"unit\": \"\",\n " +
+ " \"timestamp\": \"0001-01-01T00:00:00Z\",\n \"version\": \"SignalA_v1.0\"\n}"))),
+ }
+ }
+ return httpResp
+}
+
+// This function creates two different http responses with a different body,
+// since some tests build on receiving multiple correct http responses
+func createDoubleHttpResp() func() *http.Response {
+ f := createServicePointTestForm()
+ // Create mock response from orchestrator
+ fakeBody, err := json.Marshal(f)
+ if err != nil {
+ log.Println("Fail Marshal at start of test")
+ }
+ count := 0
+ return func() *http.Response {
+ count++
+ if count == 1 || count == 3 {
+ return &http.Response{
+ Status: "200 OK",
+ StatusCode: 200,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: io.NopCloser(strings.NewReader(string(fakeBody))),
+ }
+ }
+ return &http.Response{
+ Status: "200 OK",
+ StatusCode: 200,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: io.NopCloser(strings.NewReader(string("{\n \"value\": 0,\n \"unit\": \"\",\n " +
+ " \"timestamp\": \"0001-01-01T00:00:00Z\",\n \"version\": \"SignalA_v1.0\"\n}"))),
+ }
+ }
+}
+
+func createEmptyHttpResp() func() *http.Response {
+ httpResp := func() *http.Response {
+ return &http.Response{
+ Status: "200 OK",
+ StatusCode: 200,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: io.NopCloser(strings.NewReader(string(""))),
+ }
+ }
+ return httpResp
+}
+
+func createStatusErrorHttpResp() func() *http.Response {
+ httpResp := func() *http.Response {
+ return &http.Response{
+ Status: "300 NAK",
+ StatusCode: 300,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: io.NopCloser(strings.NewReader(string("{\n \"value\": 0,\n \"unit\": \"\",\n " +
+ " \"timestamp\": \"0001-01-01T00:00:00Z\",\n \"version\": \"SignalA_v1.0\"\n}"))),
+ }
+ }
+ return httpResp
+}
+
+func createErrorReaderHttpResp() func() *http.Response {
+ httpResp := func() *http.Response {
+ return &http.Response{
+ Status: "200 OK",
+ StatusCode: 200,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: io.NopCloser(errorReader{}),
+ }
+ }
+ return httpResp
+}
+
+func createUnpackErrorHttpResp() func() *http.Response {
+ httpResp := func() *http.Response {
+ return &http.Response{
+ Status: "200 OK",
+ StatusCode: 200,
+ Header: http.Header{"Content-Type": []string{"Wrong content type"}},
+ Body: io.NopCloser(strings.NewReader(string("{\n \"value\": 0,\n \"unit\": \"\",\n " +
+ " \"timestamp\": \"0001-01-01T00:00:00Z\",\n \"version\": \"SignalA_v1.0\"\n}"))),
+ }
+ }
+ return httpResp
+}
+
+var testStateParams = []stateParams{
+ {newTestCerviceWithNodes(), createTestSystem(false), createTestBytes(),
+ createWorkingHttpResp(), 0, nil, form.NewForm(), nil, "No errors with nodes"},
+ {newTestCerviceWithoutNodes(), createTestSystem(false), createTestBytes(),
+ createDoubleHttpResp(), 0, nil, form.NewForm(), nil, "No errors without nodes"},
+ {newTestCerviceWithNodes(), createTestSystem(false), nil,
+ createEmptyHttpResp(), 0, nil, nil, errEmptyRespBody, "Empty response body error"},
+ {newTestCerviceWithoutNodes(), createTestSystem(false), createTestBytes(),
+ createWorkingHttpResp(), 1, errHTTP, nil, errHTTP, "Search4Services error"},
+ {newTestCerviceWithBrokenUrl(), createTestSystem(false), createTestBytes(),
+ createWorkingHttpResp(), 2, errHTTP, nil, errHTTP, "NewRequest() error"},
+ {newTestCerviceWithNodes(), createTestSystem(false), createTestBytes(),
+ createStatusErrorHttpResp(), 2, errHTTP, nil, errHTTP, "Status code error"},
+ {newTestCerviceWithNodes(), createTestSystem(false), createTestBytes(),
+ createErrorReaderHttpResp(), 0, nil, nil, errBodyRead, "io.ReadAll() error"},
+ {newTestCerviceWithNodes(), createTestSystem(false), createTestBytes(),
+ createUnpackErrorHttpResp(), 0, nil, nil, errUnpack, "Unpack() error"},
+ {newTestCerviceWithNodes(), createTestSystem(false), createTestBytes(),
+ createWorkingHttpResp(), 1, errHTTP, nil, errHTTP, "DefaultClient.Do() error"},
+}
+
+func TestGetState(t *testing.T) {
+ for _, test := range testStateParams {
+ newMockTransport(test.body, test.mockTransportErr, test.errHTTP)
+ res, err := GetState(test.testCer, &test.testSys)
+
+ if test.expectedfForm != nil {
+ expected := test.expectedfForm.(*forms.SignalA_v1a)
+ actual, ok := res.(*forms.SignalA_v1a)
+ if !ok {
+ t.Fatalf("Test case: %s, got %v, expected a forms.Form",
+ test.testCase, res,
+ )
+ }
+ if expected.Value != actual.Value || expected.Unit != actual.Unit ||
+ expected.Timestamp != actual.Timestamp || expected.Version != actual.Version ||
+ err != test.expectedErr {
+ t.Errorf("Test case: %s got error: %v. \nExpected form: \n%+v\n, got: \n%+v",
+ test.testCase, err, expected, actual)
+ }
+ } else if err == nil {
+ t.Errorf("Test case: %s got error: %v:", test.testCase, err)
+ }
+ }
+}
+
+func TestSetState(t *testing.T) {
+ for _, test := range testStateParams {
+ newMockTransport(test.body, test.mockTransportErr, test.errHTTP)
+
+ if test.testCase == "DefaultClient.Do() error" {
+ test.testCer.Nodes = map[string][]string{"test": {"https://testSystem/testUnitAsset/test"}}
+ }
+ if test.testCase == "No errors without nodes" {
+ test.testCer.Nodes = make(map[string][]string)
+ }
+ res, err := SetState(test.testCer, &test.testSys, test.bodyBytes)
+
+ if test.expectedfForm != nil {
+ expected := test.expectedfForm.(*forms.SignalA_v1a)
+ actual, ok := res.(*forms.SignalA_v1a)
+ if !ok {
+ t.Fatalf("Test case: %s, got %v, expected a forms.Form",
+ test.testCase, res,
+ )
+ }
+ if expected.Value != actual.Value || expected.Unit != actual.Unit ||
+ expected.Timestamp != actual.Timestamp || expected.Version != actual.Version ||
+ err != test.expectedErr {
+ t.Errorf("Test case: %s got error: %v. \nExpected form: \n%+v\n, got: \n%+v",
+ test.testCase, err, expected, actual)
+ }
+ } else if err == nil {
+ t.Errorf("Test case: %s got error: %v:", test.testCase, err)
+ }
+ }
+}
+
+func createServRecListTestForm(amount int) (servRecList forms.ServiceRecordList_v1) {
+ servRecList.NewForm()
+ servRecList.List = make([]forms.ServiceRecord_v1, amount)
+ for i := range amount {
+ servRecList.List[i].IPAddresses = []string{"123.456.789"}
+ servRecList.List[i].ProtoPort = map[string]int{"http": 123}
+ }
+ return servRecList
+}
+
+// Use this one if a mock response from an orchestrator is needed
+func createDoubleHttpRespWithServRecList(amount int, empty bool, statusErr bool,
+ readErr bool, unpackErr bool) func() *http.Response {
+ f := createServRecListTestForm(amount)
+ // Create mock response from orchestrator
+ fakeBody, err := json.Marshal(f)
+ if err != nil {
+ log.Println("Fail Marshal at start of test")
+ }
+ count := 0
+ return func() *http.Response {
+ resp := &http.Response{
+ Status: "200 OK",
+ StatusCode: 200,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: io.NopCloser(strings.NewReader(string("{\n \"value\": 0,\n \"unit\": \"\",\n " +
+ " \"timestamp\": \"0001-01-01T00:00:00Z\",\n \"version\": \"SignalA_v1.0\"\n}"))),
+ }
+ count++
+ if count == 1 {
+ resp.Body = io.NopCloser(strings.NewReader(string(fakeBody)))
+ return resp
+ }
+ if empty == true {
+ resp.Body = io.NopCloser(strings.NewReader(string("")))
+ return resp
+ }
+ if statusErr == true {
+ resp.Status = "300 NAK"
+ resp.StatusCode = 300
+ return resp
+ }
+ if readErr == true {
+ resp.Body = io.NopCloser(errorReader{})
+ return resp
+ }
+ if unpackErr == true {
+ resp.Header = http.Header{"Content-Type": []string{"Wrong content type"}}
+ return resp
+ }
+ return resp
+ }
+}
+
+func formsEqual(a, b []forms.Form) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i] == nil && b[i] == nil {
+ continue
+ }
+ aForm, ok := a[i].(*forms.SignalA_v1a)
+ if !ok {
+ return false
+ }
+ bForm, ok := b[i].(*forms.SignalA_v1a)
+ if !ok {
+ return false
+ }
+ if aForm.Value != bForm.Value || aForm.Unit != bForm.Unit ||
+ aForm.Timestamp != bForm.Timestamp || aForm.Version != bForm.Version {
+ return false
+ }
+ }
+ return true
+}
+
+func errEqual(a, b []error) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if (a[i] != nil && b[i] == nil) || (a[i] == nil && b[i] != nil) {
+ return false
+ }
+ }
+ return true
+}
+
+type getStatesTestStruct struct {
+ body func() *http.Response
+ mockTransportErr int
+ errHTTP error
+ expectedForm []forms.Form
+ expectedErr []error
+ testName string
+}
+
+var (
+ threeForms = []forms.Form{form.NewForm(), form.NewForm(), form.NewForm()}
+ oneNilForm = []forms.Form{form.NewForm(), form.NewForm(), nil}
+ nilForms = []forms.Form{nil, nil, nil}
+ singleNilForm = []forms.Form{nil}
+ threeErr = []error{fmt.Errorf("Error"), fmt.Errorf("Error"), fmt.Errorf("Error")}
+ oneErr = []error{nil, nil, fmt.Errorf("Error")}
+ nilErr = []error{nil, nil, nil}
+ singleErr = []error{fmt.Errorf("Error")}
+)
+
+var getStatesTestParams = []getStatesTestStruct{
+ {createDoubleHttpRespWithServRecList(3, false, false, false, false), 0, nil, threeForms,
+ nilErr, "No errors without nodes"},
+ {createDoubleHttpRespWithServRecList(3, false, false, false, false), 4, errHTTP, oneNilForm,
+ oneErr, "Error in one of the services"},
+ {createDoubleHttpRespWithServRecList(3, true, false, false, false), 0, nil, nilForms,
+ threeErr, "Empty response body error"},
+ {createWorkingHttpResp(), 1, errHTTP, singleNilForm,
+ singleErr, "Search4Services error"},
+ {createDoubleHttpRespWithServRecList(3, false, true, false, false), 0, nil, nilForms,
+ threeErr, "Status code error"},
+ {createDoubleHttpRespWithServRecList(3, false, false, true, false), 0, nil, nilForms,
+ threeErr, "io.ReadAll() error"},
+ {createDoubleHttpRespWithServRecList(3, false, false, false, true), 0, nil, nilForms,
+ threeErr, "Unpack() error"},
+}
+
+func TestGetStates(t *testing.T) {
+ for _, testCase := range getStatesTestParams {
+ testCer := newTestCerviceWithoutNodes()
+ testSys := createTestSystem(false)
+ newMockTransport(testCase.body, testCase.mockTransportErr, testCase.errHTTP)
+
+ res, err := GetStates(testCer, &testSys)
+
+ if !formsEqual(res, testCase.expectedForm) || !errEqual(err, testCase.expectedErr) {
+ t.Errorf("Test case: %s\nExpected forms: %+v\nGot: %+v\nExpected error: %v, Got error: %v",
+ testCase.testName, testCase.expectedForm, res, testCase.expectedErr, err)
+ }
+ }
+
+ // Special case: No errors with existing nodes
+ cerWithNodes := components.Cervice{
+ IReferentce: "test",
+ Definition: "A test Cervice with nodes",
+ Details: map[string][]string{"Forms": {"SignalA_v1a"}},
+ Nodes: map[string][]string{"test": {"test1", "test2", "test3"}},
+ Protos: []string{"http"},
+ }
+ testSys := createTestSystem(false)
+ newMockTransport(createWorkingHttpResp(), 0, nil)
+
+ res, err := GetStates(&cerWithNodes, &testSys)
+ expectedForm := []forms.Form{form.NewForm(), form.NewForm(), form.NewForm()}
+ expectedErr := []error{nil, nil, nil}
+
+ if !formsEqual(res, expectedForm) || !errEqual(err, expectedErr) {
+ t.Errorf("Test case: No errors with nodes \nExpected forms: %v\nGot: %v\nExpected error: %v, Got error: %v",
+ expectedForm, res, expectedErr, err)
+ }
+
+ // Special case: Error with a broken url in nodes
+ cerWithBrokenUrlNode := components.Cervice{
+ IReferentce: "test",
+ Definition: "A test Cervice with nodes",
+ Details: map[string][]string{"Forms": {"SignalA_v1a"}},
+ Nodes: map[string][]string{"test": {"test1", brokenUrl, "test3"}},
+ Protos: []string{"http"},
+ }
+ testSys = createTestSystem(false)
+ newMockTransport(createWorkingHttpResp(), 0, nil)
+
+ res, err = GetStates(&cerWithBrokenUrlNode, &testSys)
+ expectedForm = []forms.Form{form.NewForm(), nil, form.NewForm()}
+ expectedErr = []error{nil, fmt.Errorf("Error"), nil}
+
+ if !formsEqual(res, expectedForm) || !errEqual(err, expectedErr) {
+ t.Errorf("Test case: Error with broken url \nExpected forms: %v\nGot: %v\nExpected error: %v, Got error: %v",
+ expectedForm, res, expectedErr, err)
+ }
+}
+
+type logTransportMock struct {
+ t *testing.T
+ errResponse error
+}
+
+func newLogTransportMock(t *testing.T) *logTransportMock {
+ lt := &logTransportMock{t, nil}
+ http.DefaultClient.Transport = lt
+ return lt
+}
+
+func (mock *logTransportMock) setError(err error) {
+ mock.errResponse = err
+}
+
+// This mock transport also verifies that the system message forms are valid.
+func (mock *logTransportMock) RoundTrip(req *http.Request) (res *http.Response, err error) {
+ body, err := io.ReadAll(req.Body)
+ if err != nil {
+ mock.t.Errorf("unexpected error while reading request body: %v", err)
+ return
+ }
+ defer req.Body.Close()
+ form, err := Unpack(body, req.Header.Get("Content-Type"))
+ if err != nil {
+ mock.t.Errorf("unexpected error from unpack: %v", err)
+ return
+ }
+ message, ok := form.(*forms.SystemMessage_v1)
+ if !ok {
+ mock.t.Error("unexpected form")
+ return
+ }
+ if message.System != testLogSys || message.Body != testLogMsg {
+ mock.t.Errorf("unexpected message: %v", message)
+ }
+
+ if mock.errResponse != nil {
+ return nil, mock.errResponse
+ }
+ rec := httptest.NewRecorder()
+ rec.WriteHeader(http.StatusOK)
+ return rec.Result(), nil
+}
+
+const testLogHost = "host"
+const testLogSys = "test system"
+const testLogMsg = "test msg"
+
+// NOTE: this test also covers sendLogMessage function
+
+func TestLog(t *testing.T) {
+ mock := newLogTransportMock(t)
+ mock.setError(fmt.Errorf("mock err"))
+ sys := components.NewSystem(testLogSys, context.Background())
+
+ // Case: increase error count by one
+ sys.Messengers[testLogHost] = 0
+ Log(&sys, forms.LevelDebug, testLogMsg)
+ if got, want := sys.Messengers[testLogHost], 1; got != want {
+ t.Errorf("expected error count %d, got %d", want, got)
+ }
+
+ // Case: removes messenger after too many errors
+ sys.Messengers[testLogHost] = messengerMaxErrors
+ Log(&sys, forms.LevelDebug, testLogMsg)
+ _, found := sys.Messengers[testLogHost]
+ if found {
+ t.Errorf("expected messenger being removed")
+ }
+
+ // Case: transfer ok
+ mock.setError(nil)
+ sys.Messengers[testLogHost] = 0
+ Log(&sys, forms.LevelDebug, testLogMsg)
+ if got, want := sys.Messengers[testLogHost], 0; got != want {
+ t.Errorf("expected error count %d, got %d", want, got)
+ }
+}
diff --git a/usecases/cost.go b/usecases/cost.go
index 1a3f1a2..865c5d5 100644
--- a/usecases/cost.go
+++ b/usecases/cost.go
@@ -21,9 +21,8 @@ package usecases
import (
"encoding/json"
- "errors"
+ "fmt"
"io"
- "log"
"net/http"
"time"
@@ -44,68 +43,49 @@ func GetActivitiesCost(serv *components.Service) (payload []byte, err error) {
// SetActivitiesCost updates the service cost
func SetActivitiesCost(serv *components.Service, bodyBytes []byte) (err error) {
- var jsonData map[string]interface{}
- err = json.Unmarshal(bodyBytes, &jsonData)
+ f, err := Unpack(bodyBytes, "application/json")
if err != nil {
- log.Printf("Error unmarshaling JSON data: %v", err)
- return
+ return fmt.Errorf("unmarshalling cost form: %w", err)
}
- formVersion, ok := jsonData["version"].(string)
+ acForm, ok := f.(*forms.ActivityCostForm_v1)
if !ok {
- log.Printf("Error: 'version' key not found in JSON data")
- return
+ return fmt.Errorf("couldn't convert to correct form")
}
- var acForm forms.ActivityCostForm_v1
- switch formVersion {
- case "ActivityCostForm_v1":
- var f forms.ActivityCostForm_v1
- err = json.Unmarshal(bodyBytes, &f)
- if err != nil {
- log.Println("Unable to extract new activity costs request ")
- return
- }
- acForm = f
- default:
- err = errors.New("unsupported version of activity costs form")
- return
- }
-
- if serv.Definition == acForm.Activity {
- serv.ACost = acForm.Cost // update the service's cost
- log.Printf("The new service cost is %f => the service is %+v\n", acForm.Cost, serv)
- } else {
- err = errors.New("mismatch between service list order") // corrected typo
- return
+ if serv.Definition != acForm.Activity {
+ return fmt.Errorf("service definition and activity cost forms activity field doesn't match")
}
+ serv.ACost = acForm.Cost // update the service's cost
return
}
// ACServices handles the http request for the cost of a service
func ACServices(w http.ResponseWriter, r *http.Request, ua *components.UnitAsset, serviceP string) {
+ // Has to use (*ua) in order to reach the methods for the interface UnitAsset, since ua is a pointer to an interface
servicesList := (*ua).GetServices()
serv := servicesList[serviceP]
switch r.Method {
case "GET":
payload, err := GetActivitiesCost(serv)
if err != nil {
- log.Printf("Error in getting the activity costs\n")
http.Error(w, "Error marshaling data.", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
- w.Write(payload)
- return
+ _, err = w.Write(payload)
+ if err != nil {
+ http.Error(w, "Error while writing to response body", http.StatusInternalServerError)
+ }
case "PUT":
defer r.Body.Close()
bodyBytes, err := io.ReadAll(r.Body) // Use io.ReadAll instead of ioutil.ReadAll
if err != nil {
- log.Printf("Error reading registration response body: %v", err)
+ http.Error(w, "Error reading registration response body", http.StatusBadRequest)
return
}
err = SetActivitiesCost(serv, bodyBytes)
if err != nil {
- log.Printf("there was an error updating the activittiy costs, %s\n", err)
+ http.Error(w, "Error occurred while updating activity costs", http.StatusInternalServerError)
}
default:
http.Error(w, "Method is not supported.", http.StatusNotFound)
diff --git a/usecases/cost_test.go b/usecases/cost_test.go
new file mode 100644
index 0000000..6af83ea
--- /dev/null
+++ b/usecases/cost_test.go
@@ -0,0 +1,214 @@
+package usecases
+
+import (
+ "io"
+ "math"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/sdoque/mbaigo/components"
+)
+
+func TestGetActivitiesCost(t *testing.T) {
+ testServ := &components.Service{
+ Definition: "testDef",
+ ACost: 123,
+ CUnit: "testCUnit",
+ }
+ data, err := GetActivitiesCost(testServ)
+ if err != nil {
+ t.Errorf("no error expected, got: %v", err)
+ }
+
+ // Check that correct data is present
+ if strings.Contains(string(data), `"activity": "testDef"`) == false {
+ t.Errorf("Definition/activity doesn't match")
+ }
+ if (strings.Contains(string(data), `"cost": 123`)) == false {
+ t.Errorf("ACost/cost doesn't match")
+ }
+}
+
+// ------------------------------------------------------ //
+// Helper functions and structs for TestSetActivitiesCost()
+// ------------------------------------------------------ //
+
+type setACparams struct {
+ dataString string
+ expectError bool
+ testCase string
+}
+
+func createTestService() (serv *components.Service) {
+ testServ := &components.Service{
+ ID: 0,
+ Definition: "testDefinition",
+ SubPath: "testService",
+ Details: map[string][]string{"Details": {"detail1", "detail2"}},
+ RegPeriod: 45,
+ RegTimestamp: "Now",
+ RegExpiration: "Later",
+ Description: "A service for testing purposes",
+ SubscribeAble: false,
+ ACost: 123,
+ CUnit: "testCUnit",
+ }
+ return testServ
+}
+
+func TestSetActivitiesCost(t *testing.T) {
+ testParams := []setACparams{
+ // Best case: No errors
+ {
+ `{"activity":"testDefinition","cost":321,"unit":"",
+ "timestamp":"0001-01-01T00:00:00Z","version":"ActivityCostForm_v1"}`,
+ false, "Best case, no errors",
+ },
+ // Bad case: Fail @ unmarshal
+ {"", true, "Bad case, break first unmarshal"},
+ // Bad case: No version field in byte array
+ {
+ `{"activity":"testDefinition","cost":321,"unit":"","timestamp":"0001-01-01T00:00:00Z"}`,
+ true, "Bad case, version missing",
+ },
+ // Bad case: Unsupported version
+ {
+ `{"activity":"testDefinition","cost":321,"unit":"",
+ "timestamp":"0001-01-01T00:00:00Z","version":"WrongVersion"}`,
+ true, "Bad case, unsupported version",
+ },
+ // Bad case: Mismatch between 'serv.Definition' and 'acForm.Activity'
+ {
+ `{"activity":"WrongDef","cost":321,"unit":"",
+ "timestamp":"0001-01-01T00:00:00Z","version":"ActivityCostForm_v1"}`,
+ true, "Bad case, serv.Definition != acForm.Activity",
+ },
+ // Bad case: Fail @ 2nd unmarshal
+ {
+ `{"activity":"testDefinition","cost":"321","unit":"",
+ "timestamp":"0001-01-01T00:00:00Z","version":"ActivityCostForm_v1"}`,
+ true, "Bad case, break first unmarshal",
+ },
+ // Bad case: Couldn't convert to ActivityCostForm_v1
+ {
+ `{"file_url":"filepath",
+ "timestamp":"0001-01-01T00:00:00Z","version":"FileForm_v1"}`,
+ true, "Bad case, couldn't convert to ActivityCostForm_v1",
+ },
+ }
+ testServ := createTestService()
+
+ for _, c := range testParams {
+ err := SetActivitiesCost(testServ, []byte(c.dataString))
+
+ if (c.expectError == true && err == nil) || (c.expectError == false && err != nil) {
+ t.Errorf("Testcase '%s' failed, expectError was %v error was: %v", c.testCase, c.expectError, err)
+ }
+ }
+}
+
+// ------------------------------------------------------ //
+// Helper functions and structs for TestACServices()
+// ------------------------------------------------------ //
+
+// Creates a unitasset with values used for testing
+func createUnitAsset(cost float64) components.UnitAsset {
+ setTest := &components.Service{
+ ID: 1,
+ Definition: "test",
+ SubPath: "test",
+ Details: map[string][]string{"Forms": {"SignalA_v1a"}},
+ Description: "A test service",
+ RegPeriod: 45,
+ RegTimestamp: "now",
+ RegExpiration: "45",
+ ACost: cost,
+ }
+ ServicesMap := &components.Services{
+ setTest.SubPath: setTest,
+ }
+ var ua components.UnitAsset = &mockUnitAsset{
+ Name: "testUnitAsset",
+ Details: map[string][]string{"Test": {"Test"}},
+ ServicesMap: *ServicesMap,
+ CervicesMap: nil,
+ }
+ return ua
+}
+
+type acServicesParams struct {
+ httpMethod string
+ responseWriter *httptest.ResponseRecorder
+ expectError bool
+ request *http.Request
+ unitAsset components.UnitAsset
+ testCase string
+}
+
+func TestACServices(t *testing.T) {
+ testParams := []acServicesParams{
+ // Good case: no errors in GET/PUT
+ {
+ "GET", httptest.NewRecorder(), false,
+ httptest.NewRequest(
+ http.MethodGet,
+ "http://localhost",
+ io.NopCloser(strings.NewReader(``)),
+ ),
+ createUnitAsset(0), "GET, Best case: no errors in GET",
+ },
+ {
+ "PUT", httptest.NewRecorder(), false,
+ httptest.NewRequest(
+ http.MethodPut,
+ "http://localhost",
+ io.NopCloser(strings.NewReader(
+ `{"activity":"test", "cost": 321, "version":"ActivityCostForm_v1"}`,
+ )),
+ ),
+ createUnitAsset(0), "PUT, Best case: no errors in PUT",
+ },
+ // GET, Bad case: GetActivitiesCost() returns error
+ {
+ "GET", httptest.NewRecorder(), true,
+ httptest.NewRequest(http.MethodGet, "http://localhost", io.NopCloser(strings.NewReader(``))),
+ createUnitAsset(math.NaN()), "GET, Bad case: error from GetActivitiesCost()"},
+ // PUT, Bad case: Reading response body returns an error
+ {
+ "PUT", httptest.NewRecorder(), true,
+ httptest.NewRequest(http.MethodPut, "http://localhost", io.NopCloser(errReader(0))),
+ createUnitAsset(0), "PUT, Bad case: reading response body",
+ },
+ // PUT, Bad case: SetActivitiesCost() returns error
+ {
+ "PUT", httptest.NewRecorder(), true,
+ httptest.NewRequest(http.MethodPut, "http://localhost", io.NopCloser(strings.NewReader(``))),
+ createUnitAsset(0), "PUT, Bad case: error updating activities cost",
+ },
+ // DEFAULT: Method not supported (POST),
+ {
+ "POST", httptest.NewRecorder(), true,
+ httptest.NewRequest(http.MethodPost, "http://localhost", io.NopCloser(strings.NewReader(``))),
+ createUnitAsset(0), "POST, Bad case: Method not supported",
+ },
+ // TODO: GET, Bad case: Couldn't write to responsewriter
+ }
+
+ for _, c := range testParams {
+ // Setup
+ ua := c.unitAsset
+ w := c.responseWriter
+ r := c.request
+ // Test
+ ACServices(w, r, &ua, "test")
+
+ if c.expectError == false && w.Result().StatusCode != 200 {
+ t.Errorf("Expected statuscode 200 in testcase '%s' got: %d", c.testCase, w.Result().StatusCode)
+ }
+ if c.expectError == true && w.Result().StatusCode == 200 {
+ t.Errorf("Expected statuscode not to be 200 in testcase '%s'", c.testCase)
+ }
+ }
+}
diff --git a/usecases/docs.go b/usecases/docs.go
index 412b542..13a44c1 100644
--- a/usecases/docs.go
+++ b/usecases/docs.go
@@ -29,6 +29,7 @@ package usecases
import (
"fmt"
+ "log"
"net/http"
"strconv"
"strings"
@@ -39,102 +40,96 @@ import (
// System Documentation (based on HATEOAS) provides an initial documentation on the system's web server of with hyperlinks to the services for browsers
// HATEOAS is the acronym for Hypermedia as the Engine of Application State, using hyperlinks to navigate the API
func SysHateoas(w http.ResponseWriter, req *http.Request, sys components.System) {
- text := ""
- w.Write([]byte(text))
- text = "System Description
"
- w.Write([]byte(text))
- text = "The system " + sys.Name + " " + sys.Husk.Description + "
"
- w.Write([]byte(text))
- text = "Online Documentation
"
- w.Write([]byte(text))
-
- text = " The resource list is
"
- w.Write([]byte(text))
+ text := "\n"
+ text += "System Description
\n"
+ text += "The system " + sys.Name + " " + sys.Husk.Description + "
\n"
+ text += "Online Documentation\n"
+ text += " The resource list is
\n"
+
assetList := &sys.UAssets
for _, unitasset := range *assetList {
metaservice := ""
for key, values := range (*unitasset).GetDetails() {
metaservice += key + ": " + fmt.Sprintf("%v", values) + " "
}
- resourceURI := "- " + (*unitasset).GetName() + " with details " + metaservice + "
"
- w.Write([]byte(resourceURI))
- }
-
- text = "
having the following services:"
- w.Write([]byte(text))
- servicesList := getServicesList(getFirstAsset(*assetList)[0])
- for _, service := range servicesList {
- metaservice := ""
- for key, values := range service.Details {
- metaservice += key + ": " + fmt.Sprintf("%v", values) + " "
- }
- serviceURI := "- " + service.Definition + " with details: " + metaservice + "
"
- w.Write([]byte(serviceURI))
+ text += "- " + (*unitasset).GetName() + " with details " + metaservice + "
\n"
}
- text = "
The services can be accessed using the following protocols with their respective bound ports:
"
- w.Write([]byte(text))
+ // This part of the code is commented out because it is not used in the current implementation because the assets on a PLC might have different services
+ // ======================================
+ // text = "
having the following services:"
+ // w.Write([]byte(text))
+ // servicesList := getServicesList(getFirstAsset(*assetList)[0])
+ // for _, service := range servicesList {
+ // metaservice := ""
+ // for key, values := range service.Details {
+ // metaservice += key + ": " + fmt.Sprintf("%v", values) + " "
+ // }
+ // serviceURI := "- " + service.Definition + " with details: " + metaservice + "
"
+ // w.Write([]byte(serviceURI))
+ // }
+
+ text += "
The services can be accessed using the following protocols with their respective bound ports:
\n"
for protocol, port := range sys.Husk.ProtoPort {
- protoDoor := "- Protocol " + protocol + " using port " + strconv.Itoa(port) + "
"
- w.Write([]byte(protoDoor))
+ text += "- Protocol " + protocol + " using port " + strconv.Itoa(port) + "
\n"
}
- text = "
of the device whose IP addresses are (upon startup):
"
- w.Write([]byte(text))
+ text += "
of the device whose IP addresses are (upon startup):
\n"
for _, IPAddre := range sys.Host.IPAddresses {
- hostaddresses := "- " + IPAddre + "
"
- w.Write([]byte(hostaddresses))
+ text += "- " + IPAddre + "
\n"
}
- text = "
"
- w.Write([]byte(text))
+ text += "
"
+ _, err := w.Write([]byte(text))
+ if err != nil {
+ log.Printf("Error while writing to response body for SysHateoas: %v", err)
+ }
}
// ResHateoas provides information about the unit asset(s) and each service and is accessed via the system's web server
func ResHateoas(w http.ResponseWriter, req *http.Request, ua components.UnitAsset, sys components.System) {
- text := ""
- w.Write([]byte(text))
-
- text = "Unit Asset Description
"
- w.Write([]byte(text))
+ text := "\n"
+ text += "Unit Asset Description
\n"
uaName := ua.GetName()
metaservice := ""
for key, values := range ua.GetDetails() {
metaservice += key + ": " + fmt.Sprintf("%v", values) + " "
}
- text = "The resource " + uaName + " belongs to system " + sys.Name + " and has the details " + metaservice + " with the following services:" + ""
- w.Write([]byte(text))
+ text += "The resource " + uaName + " belongs to system " + sys.Name + " and has the details " + metaservice + " with the following services:" + "\n"
+
services := ua.GetServices()
for _, service := range services {
metaservice := ""
for key, values := range service.Details {
metaservice += key + ": " + fmt.Sprintf("%v", values) + " "
}
- serviceURI := "- " + service.Definition + " with details: " + metaservice + "
"
- w.Write([]byte(serviceURI))
+ text += "- " + service.Definition + " with details: " + metaservice + "
\n"
}
- text = "
"
- w.Write([]byte(text))
+ text += "