commit 67eb6d659712e36bb5506710370f2e8d45e21846 Author: lash Date: Fri Jan 10 20:32:22 2025 +0000 Include local handlers code diff --git a/args/lang.go b/args/lang.go new file mode 100644 index 0000000..f9afdc9 --- /dev/null +++ b/args/lang.go @@ -0,0 +1,34 @@ +package args + +import ( + "strings" + + "git.defalsify.org/vise.git/lang" +) + +type LangVar struct { + v []lang.Language +} + +func(lv *LangVar) Set(s string) error { + v, err := lang.LanguageFromCode(s) + if err != nil { + return err + } + lv.v = append(lv.v, v) + return err +} + +func(lv *LangVar) String() string { + var s []string + for _, v := range(lv.v) { + s = append(s, v.Code) + } + return strings.Join(s, ",") +} + +func(lv *LangVar) Langs() []lang.Language { + return lv.v +} + + diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..d36979f --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,152 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "path" + + "git.defalsify.org/vise.git/engine" + "git.defalsify.org/vise.git/logging" + "git.defalsify.org/vise.git/resource" + "git.defalsify.org/vise.git/lang" + "git.grassecon.net/grassrootseconomics/visedriver/config" + "git.grassecon.net/grassrootseconomics/visedriver/initializers" + "git.grassecon.net/grassrootseconomics/visedriver/remote" + "git.grassecon.net/grassrootseconomics/visedriver/common" + "git.grassecon.net/grassrootseconomics/sarafu-vise/args" + "git.grassecon.net/grassrootseconomics/sarafu-vise/handlers" +) + +var ( + logg = logging.NewVanilla() + scriptDir = path.Join("services", "registration") + menuSeparator = ": " +) + +func init() { + initializers.LoadEnvVariables() +} + +// TODO: external script automatically generate language handler list from select language vise code OR consider dynamic menu generation script possibility +func main() { + config.LoadConfig() + + var connStr string + var size uint + var sessionId string + var database string + var engineDebug bool + var resourceDir string + var err error + var gettextDir string + var langs args.LangVar + + flag.StringVar(&resourceDir, "resourcedir", scriptDir, "resource dir") + flag.StringVar(&sessionId, "session-id", "075xx2123", "session id") + flag.StringVar(&connStr, "c", "", "connection string") + flag.BoolVar(&engineDebug, "d", false, "use engine debug output") + flag.UintVar(&size, "s", 160, "max size of output") + flag.StringVar(&gettextDir, "gettext", "", "use gettext translations from given directory") + flag.Var(&langs, "language", "add symbol resolution for language") + flag.Parse() + + if connStr != "" { + connStr = config.DbConn + } + connData, err := common.ToConnData(connStr) + if err != nil { + fmt.Fprintf(os.Stderr, "connstr err: %v", err) + os.Exit(1) + } + + logg.Infof("start command", "conn", connData, "outputsize", size) + + if len(langs.Langs()) == 0 { + langs.Set(config.DefaultLanguage) + } + + ctx := context.Background() + ctx = context.WithValue(ctx, "SessionId", sessionId) + ctx = context.WithValue(ctx, "Database", database) + + ln, err := lang.LanguageFromCode(config.DefaultLanguage) + if err != nil { + fmt.Fprintf(os.Stderr, "default language set error: %v", err) + os.Exit(1) + } + ctx = context.WithValue(ctx, "Language", ln) + + pfp := path.Join(scriptDir, "pp.csv") + + cfg := engine.Config{ + Root: "root", + SessionId: sessionId, + OutputSize: uint32(size), + FlagCount: uint32(128), + MenuSeparator: menuSeparator, + } + + menuStorageService, err := common.NewStorageService(connData) + if err != nil { + fmt.Fprintf(os.Stderr, "menu storage service error: %v", err) + os.Exit(1) + } + + if gettextDir != "" { + menuStorageService = menuStorageService.WithGettext(gettextDir, langs.Langs()) + } + + rs, err := menuStorageService.GetResource(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, err.Error()) + os.Exit(1) + } + + pe, err := menuStorageService.GetPersister(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, err.Error()) + os.Exit(1) + } + + userdatastore, err := menuStorageService.GetUserdataDb(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, err.Error()) + os.Exit(1) + } + + dbResource, ok := rs.(*resource.DbResource) + if !ok { + fmt.Fprintf(os.Stderr, err.Error()) + os.Exit(1) + } + + lhs, err := handlers.NewLocalHandlerService(ctx, pfp, true, dbResource, cfg, rs) + lhs.SetDataStore(&userdatastore) + lhs.SetPersister(pe) + + if err != nil { + fmt.Fprintf(os.Stderr, err.Error()) + os.Exit(1) + } + + accountService := remote.AccountService{} + hl, err := lhs.GetHandler(&accountService) + if err != nil { + fmt.Fprintf(os.Stderr, err.Error()) + os.Exit(1) + } + + en := lhs.GetEngine() + en = en.WithFirst(hl.Init) + if engineDebug { + en = en.WithDebug(nil) + } + + err = engine.Loop(ctx, en, os.Stdin, os.Stdout, nil) + if err != nil { + fmt.Fprintf(os.Stderr, "loop exited with error: %v\n", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cd5ca0e --- /dev/null +++ b/go.mod @@ -0,0 +1,37 @@ +module git.grassecon.net/grassrootseconomics/sarafu-vise + +go 1.23.4 + +require ( + git.defalsify.org/vise.git v0.2.3-0.20250103172917-3e190a44568d + git.grassecon.net/grassrootseconomics/visedriver v0.8.0-beta.10.0.20250110120337-a7a8a482ab05 + github.com/alecthomas/assert/v2 v2.2.2 + github.com/grassrootseconomics/ussd-data-service v1.2.0-beta + github.com/peteole/testdata-loader v0.3.0 + github.com/stretchr/testify v1.9.0 + gopkg.in/leonelquinteros/gotext.v1 v1.3.1 +) + +require ( + github.com/alecthomas/participle/v2 v2.0.0 // indirect + github.com/alecthomas/repr v0.2.0 // indirect + github.com/barbashov/iso639-3 v0.0.0-20211020172741-1f4ffb2d8d1c // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fxamacker/cbor/v2 v2.4.0 // indirect + github.com/grassrootseconomics/eth-custodial v1.3.0-beta // indirect + github.com/graygnuorg/go-gdbm v0.0.0-20220711140707-71387d66dce4 // indirect + github.com/hexops/gotextdiff v1.0.3 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.1 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/mattn/kinako v0.0.0-20170717041458-332c0a7e205a // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/x448/float16 v0.8.4 // indirect + golang.org/x/crypto v0.27.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/text v0.18.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..597299d --- /dev/null +++ b/go.sum @@ -0,0 +1,73 @@ +git.defalsify.org/vise.git v0.2.3-0.20250103172917-3e190a44568d h1:bPAOVZOX4frSGhfOdcj7kc555f8dc9DmMd2YAyC2AMw= +git.defalsify.org/vise.git v0.2.3-0.20250103172917-3e190a44568d/go.mod h1:jyBMe1qTYUz3mmuoC9JQ/TvFeW0vTanCUcPu3H8p4Ck= +git.grassecon.net/grassrootseconomics/visedriver v0.8.0-beta.10.0.20250110120337-a7a8a482ab05 h1:HZ9Di5Ui80vEnIwu2nU7pCH5V6AT25b9JmYy7af9CvA= +git.grassecon.net/grassrootseconomics/visedriver v0.8.0-beta.10.0.20250110120337-a7a8a482ab05/go.mod h1:E6W7ZOa7ZvVr0Bc5ot0LNSwpSPYq4hXlAIvEPy3AJ7U= +github.com/alecthomas/assert/v2 v2.2.2 h1:Z/iVC0xZfWTaFNE6bA3z07T86hd45Xe2eLt6WVy2bbk= +github.com/alecthomas/assert/v2 v2.2.2/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ= +github.com/alecthomas/participle/v2 v2.0.0 h1:Fgrq+MbuSsJwIkw3fEj9h75vDP0Er5JzepJ0/HNHv0g= +github.com/alecthomas/participle/v2 v2.0.0/go.mod h1:rAKZdJldHu8084ojcWevWAL8KmEU+AT+Olodb+WoN2Y= +github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= +github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/barbashov/iso639-3 v0.0.0-20211020172741-1f4ffb2d8d1c h1:H9Nm+I7Cg/YVPpEV1RzU3Wq2pjamPc/UtHDgItcb7lE= +github.com/barbashov/iso639-3 v0.0.0-20211020172741-1f4ffb2d8d1c/go.mod h1:rGod7o6KPeJ+hyBpHfhi4v7blx9sf+QsHsA7KAsdN6U= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fxamacker/cbor/v2 v2.4.0 h1:ri0ArlOR+5XunOP8CRUowT0pSJOwhW098ZCUyskZD88= +github.com/fxamacker/cbor/v2 v2.4.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= +github.com/grassrootseconomics/eth-custodial v1.3.0-beta h1:twrMBhl89GqDUL9PlkzQxMP/6OST1BByrNDj+rqXDmU= +github.com/grassrootseconomics/eth-custodial v1.3.0-beta/go.mod h1:7uhRcdnJplX4t6GKCEFkbeDhhjlcaGJeJqevbcvGLZo= +github.com/grassrootseconomics/ussd-data-service v1.2.0-beta h1:fn1gwbWIwHVEBtUC2zi5OqTlfI/5gU1SMk0fgGixIXk= +github.com/grassrootseconomics/ussd-data-service v1.2.0-beta/go.mod h1:omfI0QtUwIdpu9gMcUqLMCG8O1XWjqJGBx1qUMiGWC0= +github.com/graygnuorg/go-gdbm v0.0.0-20220711140707-71387d66dce4 h1:U4kkNYryi/qfbBF8gh7Vsbuz+cVmhf5kt6pE9bYYyLo= +github.com/graygnuorg/go-gdbm v0.0.0-20220711140707-71387d66dce4/go.mod h1:zpZDgZFzeq9s0MIeB1P50NIEWDFFHSFBohI/NbaTD/Y= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= +github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/kinako v0.0.0-20170717041458-332c0a7e205a h1:0Q3H0YXzMHiciXtRcM+j0jiCe8WKPQHoRgQiRTnfcLY= +github.com/mattn/kinako v0.0.0-20170717041458-332c0a7e205a/go.mod h1:CdTTBOYzS5E4mWS1N8NWP6AHI19MP0A2B18n3hLzRMk= +github.com/pashagolub/pgxmock/v4 v4.3.0 h1:DqT7fk0OCK6H0GvqtcMsLpv8cIwWqdxWgfZNLeHCb/s= +github.com/pashagolub/pgxmock/v4 v4.3.0/go.mod h1:9VoVHXwS3XR/yPtKGzwQvwZX1kzGB9sM8SviDcHDa3A= +github.com/peteole/testdata-loader v0.3.0 h1:8jckE9KcyNHgyv/VPoaljvKZE0Rqr8+dPVYH6rfNr9I= +github.com/peteole/testdata-loader v0.3.0/go.mod h1:Mt0ZbRtb56u8SLJpNP+BnQbENljMorYBpqlvt3cS83U= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/leonelquinteros/gotext.v1 v1.3.1 h1:8d9/fdTG0kn/B7NNGV1BsEyvektXFAbkMsTZS2sFSCc= +gopkg.in/leonelquinteros/gotext.v1 v1.3.1/go.mod h1:X1WlGDeAFIYsW6GjgMm4VwUwZ2XjI7Zan2InxSUQWrU= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handlers/application/menuhandler.go b/handlers/application/menuhandler.go new file mode 100644 index 0000000..4bde578 --- /dev/null +++ b/handlers/application/menuhandler.go @@ -0,0 +1,2157 @@ +package application + +import ( + "bytes" + "context" + "fmt" + "path" + "strconv" + "strings" + + "git.defalsify.org/vise.git/asm" + + "git.defalsify.org/vise.git/cache" + "git.defalsify.org/vise.git/db" + "git.defalsify.org/vise.git/lang" + "git.defalsify.org/vise.git/logging" + "git.defalsify.org/vise.git/persist" + "git.defalsify.org/vise.git/resource" + "git.defalsify.org/vise.git/state" + "git.grassecon.net/grassrootseconomics/visedriver/common" + "git.grassecon.net/grassrootseconomics/visedriver/utils" + "git.grassecon.net/grassrootseconomics/visedriver/models" + "git.grassecon.net/grassrootseconomics/visedriver/remote" + "git.grassecon.net/grassrootseconomics/visedriver/handlers" + "gopkg.in/leonelquinteros/gotext.v1" + + dbstorage "git.grassecon.net/grassrootseconomics/visedriver/storage/db" + dataserviceapi "github.com/grassrootseconomics/ussd-data-service/pkg/api" +) + +var ( + logg = logging.NewVanilla().WithDomain("ussdmenuhandler").WithContextKey("SessionId") + scriptDir = path.Join("services", "registration") + translationDir = path.Join(scriptDir, "locale") +) + +// FlagManager handles centralized flag management +type FlagManager struct { + parser *asm.FlagParser +} + +type MenuHandler struct { + *Handlers +} + +// NewFlagManager creates a new FlagManager instance +func NewFlagManager(csvPath string) (*FlagManager, error) { + parser := asm.NewFlagParser() + _, err := parser.Load(csvPath) + if err != nil { + return nil, fmt.Errorf("failed to load flag parser: %v", err) + } + + return &FlagManager{ + parser: parser, + }, nil +} + +// GetFlag retrieves a flag value by its label +func (fm *FlagManager) GetFlag(label string) (uint32, error) { + return fm.parser.GetFlag(label) +} + +func ToMenuHandlers(h *Handlers) *MenuHandlers { + return &MenuHandlers{ + Handlers: Handlers, + } +} + +// Init initializes the handler for a new session. +func (h *MenuHandlers) Init(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var r resource.Result + if h.pe == nil { + logg.WarnCtxf(ctx, "handler init called before it is ready or more than once", "state", h.st, "cache", h.ca) + return r, nil + } + defer func() { + h.Exit() + }() + + h.st = h.pe.GetState() + h.ca = h.pe.GetMemory() + + if len(input) == 0 { + // move to the top node + h.st.Code = []byte{} + } + + sessionId, ok := ctx.Value("SessionId").(string) + if ok { + ctx = context.WithValue(ctx, "SessionId", sessionId) + } + + flag_admin_privilege, _ := h.flagManager.GetFlag("flag_admin_privilege") + isAdmin, _ := h.adminstore.IsAdmin(sessionId) + + if isAdmin { + r.FlagSet = append(r.FlagSet, flag_admin_privilege) + } else { + r.FlagReset = append(r.FlagReset, flag_admin_privilege) + } + + if h.st == nil || h.ca == nil { + logg.ErrorCtxf(ctx, "perister fail in handler", "state", h.st, "cache", h.ca) + return r, fmt.Errorf("cannot get state and memory for handler") + } + + logg.DebugCtxf(ctx, "handler has been initialized", "state", h.st, "cache", h.ca) + + return r, nil +} + +func (h *MenuHandlers) Exit() { + h.pe = nil +} + +// SetLanguage sets the language across the menu. +func (h *MenuHandlers) SetLanguage(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + + symbol, _ := h.st.Where() + code := strings.Split(symbol, "_")[1] + + if !utils.IsValidISO639(code) { + //Fallback to english instead? + code = "eng" + } + err := h.persistLanguageCode(ctx, code) + if err != nil { + return res, err + } + res.Content = code + res.FlagSet = append(res.FlagSet, state.FLAG_LANG) + languageSetFlag, err := h.flagManager.GetFlag("flag_language_set") + if err != nil { + logg.ErrorCtxf(ctx, "Error setting the languageSetFlag", "error", err) + return res, err + } + res.FlagSet = append(res.FlagSet, languageSetFlag) + + return res, nil +} + +// handles the account creation when no existing account is present for the session and stores associated data in the user data store. +func (h *MenuHandlers) createAccountNoExist(ctx context.Context, sessionId string, res *resource.Result) error { + flag_account_created, _ := h.flagManager.GetFlag("flag_account_created") + r, err := h.accountService.CreateAccount(ctx) + if err != nil { + return err + } + trackingId := r.TrackingId + publicKey := r.PublicKey + + data := map[common.DataTyp]string{ + common.DATA_TRACKING_ID: trackingId, + common.DATA_PUBLIC_KEY: publicKey, + } + store := h.userdataStore + for key, value := range data { + err = store.WriteEntry(ctx, sessionId, key, []byte(value)) + if err != nil { + return err + } + } + publicKeyNormalized, err := common.NormalizeHex(publicKey) + if err != nil { + return err + } + err = store.WriteEntry(ctx, publicKeyNormalized, common.DATA_PUBLIC_KEY_REVERSE, []byte(sessionId)) + if err != nil { + return err + } + res.FlagSet = append(res.FlagSet, flag_account_created) + return nil +} + +// CreateAccount checks if any account exists on the JSON data file, and if not, +// creates an account on the API, +// sets the default values and flags. +func (h *MenuHandlers) CreateAccount(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + var err error + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + store := h.userdataStore + _, err = store.ReadEntry(ctx, sessionId, common.DATA_PUBLIC_KEY) + if err != nil { + if db.IsNotFound(err) { + logg.InfoCtxf(ctx, "Creating an account because it doesn't exist") + err = h.createAccountNoExist(ctx, sessionId, &res) + if err != nil { + logg.ErrorCtxf(ctx, "failed on createAccountNoExist", "error", err) + return res, err + } + } + } + + return res, nil +} + +// CheckBlockedNumPinMisMatch checks if the provided PIN matches a temporary PIN stored for a blocked number. +func (h *MenuHandlers) CheckBlockedNumPinMisMatch(ctx context.Context, sym string, input []byte) (resource.Result, error) { + res := resource.Result{} + flag_pin_mismatch, _ := h.flagManager.GetFlag("flag_pin_mismatch") + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + // Get blocked number from storage. + store := h.userdataStore + blockedNumber, err := store.ReadEntry(ctx, sessionId, common.DATA_BLOCKED_NUMBER) + if err != nil { + logg.ErrorCtxf(ctx, "failed to read blockedNumber entry with", "key", common.DATA_BLOCKED_NUMBER, "error", err) + return res, err + } + // Get temporary PIN for the blocked number. + temporaryPin, err := store.ReadEntry(ctx, string(blockedNumber), common.DATA_TEMPORARY_VALUE) + if err != nil { + logg.ErrorCtxf(ctx, "failed to read temporaryPin entry with", "key", common.DATA_TEMPORARY_VALUE, "error", err) + return res, err + } + if bytes.Equal(temporaryPin, input) { + res.FlagReset = append(res.FlagReset, flag_pin_mismatch) + } else { + res.FlagSet = append(res.FlagSet, flag_pin_mismatch) + } + return res, nil +} + +// VerifyNewPin checks if a new PIN meets the required format criteria. +func (h *MenuHandlers) VerifyNewPin(ctx context.Context, sym string, input []byte) (resource.Result, error) { + res := resource.Result{} + _, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + flag_valid_pin, _ := h.flagManager.GetFlag("flag_valid_pin") + pinInput := string(input) + // Validate that the PIN is a 4-digit number. + if common.IsValidPIN(pinInput) { + res.FlagSet = append(res.FlagSet, flag_valid_pin) + } else { + res.FlagReset = append(res.FlagReset, flag_valid_pin) + } + + return res, nil +} + +// SaveTemporaryPin saves the valid PIN input to the DATA_TEMPORARY_VALUE, +// during the account creation process +// and during the change PIN process. +func (h *MenuHandlers) SaveTemporaryPin(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + var err error + + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + + flag_incorrect_pin, _ := h.flagManager.GetFlag("flag_incorrect_pin") + accountPIN := string(input) + + // Validate that the PIN is a 4-digit number. + if !common.IsValidPIN(accountPIN) { + res.FlagSet = append(res.FlagSet, flag_incorrect_pin) + return res, nil + } + res.FlagReset = append(res.FlagReset, flag_incorrect_pin) + store := h.userdataStore + err = store.WriteEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE, []byte(accountPIN)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write temporaryAccountPIN entry with", "key", common.DATA_TEMPORARY_VALUE, "value", accountPIN, "error", err) + return res, err + } + + return res, nil +} + +// SaveOthersTemporaryPin allows authorized users to set temporary PINs for blocked numbers. +func (h *MenuHandlers) SaveOthersTemporaryPin(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + var err error + + store := h.userdataStore + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + temporaryPin := string(input) + // First, we retrieve the blocked number associated with this session + blockedNumber, err := store.ReadEntry(ctx, sessionId, common.DATA_BLOCKED_NUMBER) + if err != nil { + logg.ErrorCtxf(ctx, "failed to read blockedNumber entry with", "key", common.DATA_BLOCKED_NUMBER, "error", err) + return res, err + } + + // Then we save the temporary PIN for that blocked number + err = store.WriteEntry(ctx, string(blockedNumber), common.DATA_TEMPORARY_VALUE, []byte(temporaryPin)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write temporaryPin entry with", "key", common.DATA_TEMPORARY_VALUE, "value", temporaryPin, "error", err) + return res, err + } + + return res, nil +} + +// ConfirmPinChange validates user's new PIN. If input matches the temporary PIN, saves it as the new account PIN. +func (h *MenuHandlers) ConfirmPinChange(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + flag_pin_mismatch, _ := h.flagManager.GetFlag("flag_pin_mismatch") + + store := h.userdataStore + temporaryPin, err := store.ReadEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE) + if err != nil { + logg.ErrorCtxf(ctx, "failed to read temporaryPin entry with", "key", common.DATA_TEMPORARY_VALUE, "error", err) + return res, err + } + if bytes.Equal(temporaryPin, input) { + res.FlagReset = append(res.FlagReset, flag_pin_mismatch) + } else { + res.FlagSet = append(res.FlagSet, flag_pin_mismatch) + return res, nil + } + + // Hash the PIN + hashedPIN, err := common.HashPIN(string(temporaryPin)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to hash temporaryPin", "error", err) + return res, err + } + + // save the hashed PIN as the new account PIN + err = store.WriteEntry(ctx, sessionId, common.DATA_ACCOUNT_PIN, []byte(hashedPIN)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write DATA_ACCOUNT_PIN entry with", "key", common.DATA_ACCOUNT_PIN, "hashedPIN value", hashedPIN, "error", err) + return res, err + } + return res, nil +} + +// VerifyCreatePin checks whether the confirmation PIN is similar to the temporary PIN +// If similar, it sets the USERFLAG_PIN_SET flag and writes the account PIN allowing the user +// to access the main menu. +func (h *MenuHandlers) VerifyCreatePin(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + + flag_valid_pin, _ := h.flagManager.GetFlag("flag_valid_pin") + flag_pin_mismatch, _ := h.flagManager.GetFlag("flag_pin_mismatch") + flag_pin_set, _ := h.flagManager.GetFlag("flag_pin_set") + + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + store := h.userdataStore + temporaryPin, err := store.ReadEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE) + if err != nil { + logg.ErrorCtxf(ctx, "failed to read temporaryPin entry with", "key", common.DATA_TEMPORARY_VALUE, "error", err) + return res, err + } + if bytes.Equal(input, temporaryPin) { + res.FlagSet = []uint32{flag_valid_pin} + res.FlagReset = []uint32{flag_pin_mismatch} + res.FlagSet = append(res.FlagSet, flag_pin_set) + } else { + res.FlagSet = []uint32{flag_pin_mismatch} + return res, nil + } + + // Hash the PIN + hashedPIN, err := common.HashPIN(string(temporaryPin)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to hash temporaryPin", "error", err) + return res, err + } + + err = store.WriteEntry(ctx, sessionId, common.DATA_ACCOUNT_PIN, []byte(hashedPIN)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write DATA_ACCOUNT_PIN entry with", "key", common.DATA_ACCOUNT_PIN, "value", hashedPIN, "error", err) + return res, err + } + + return res, nil +} + +// retrieves language codes from the context that can be used for handling translations. +func codeFromCtx(ctx context.Context) string { + var code string + if ctx.Value("Language") != nil { + lang := ctx.Value("Language").(lang.Language) + code = lang.Code + } + return code +} + +// SaveFirstname updates the first name in the gdbm with the provided input. +func (h *MenuHandlers) SaveFirstname(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + var err error + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + firstName := string(input) + store := h.userdataStore + flag_allow_update, _ := h.flagManager.GetFlag("flag_allow_update") + flag_firstname_set, _ := h.flagManager.GetFlag("flag_firstname_set") + + allowUpdate := h.st.MatchFlag(flag_allow_update, true) + firstNameSet := h.st.MatchFlag(flag_firstname_set, true) + if allowUpdate { + temporaryFirstName, _ := store.ReadEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE) + err = store.WriteEntry(ctx, sessionId, common.DATA_FIRST_NAME, []byte(temporaryFirstName)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write firstName entry with", "key", common.DATA_FIRST_NAME, "value", temporaryFirstName, "error", err) + return res, err + } + res.FlagSet = append(res.FlagSet, flag_firstname_set) + } else { + if firstNameSet { + err = store.WriteEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE, []byte(firstName)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write temporaryFirstName entry with", "key", common.DATA_TEMPORARY_VALUE, "value", firstName, "error", err) + return res, err + } + } else { + h.profile.InsertOrShift(0, firstName) + } + } + + return res, nil +} + +// SaveFamilyname updates the family name in the gdbm with the provided input. +func (h *MenuHandlers) SaveFamilyname(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + var err error + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + + store := h.userdataStore + familyName := string(input) + + flag_allow_update, _ := h.flagManager.GetFlag("flag_allow_update") + flag_familyname_set, _ := h.flagManager.GetFlag("flag_familyname_set") + allowUpdate := h.st.MatchFlag(flag_allow_update, true) + familyNameSet := h.st.MatchFlag(flag_familyname_set, true) + + if allowUpdate { + temporaryFamilyName, _ := store.ReadEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE) + err = store.WriteEntry(ctx, sessionId, common.DATA_FAMILY_NAME, []byte(temporaryFamilyName)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write familyName entry with", "key", common.DATA_FAMILY_NAME, "value", temporaryFamilyName, "error", err) + return res, err + } + res.FlagSet = append(res.FlagSet, flag_familyname_set) + } else { + if familyNameSet { + err = store.WriteEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE, []byte(familyName)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write temporaryFamilyName entry with", "key", common.DATA_TEMPORARY_VALUE, "value", familyName, "error", err) + return res, err + } + } else { + h.profile.InsertOrShift(1, familyName) + } + } + + return res, nil +} + +// SaveYOB updates the Year of Birth(YOB) in the gdbm with the provided input. +func (h *MenuHandlers) SaveYob(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + var err error + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + yob := string(input) + store := h.userdataStore + flag_allow_update, _ := h.flagManager.GetFlag("flag_allow_update") + flag_yob_set, _ := h.flagManager.GetFlag("flag_yob_set") + + allowUpdate := h.st.MatchFlag(flag_allow_update, true) + yobSet := h.st.MatchFlag(flag_yob_set, true) + + if allowUpdate { + temporaryYob, _ := store.ReadEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE) + err = store.WriteEntry(ctx, sessionId, common.DATA_YOB, []byte(temporaryYob)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write yob entry with", "key", common.DATA_TEMPORARY_VALUE, "value", temporaryYob, "error", err) + return res, err + } + res.FlagSet = append(res.FlagSet, flag_yob_set) + } else { + if yobSet { + err = store.WriteEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE, []byte(yob)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write temporaryYob entry with", "key", common.DATA_TEMPORARY_VALUE, "value", yob, "error", err) + return res, err + } + } else { + h.profile.InsertOrShift(3, yob) + } + } + + return res, nil +} + +// SaveLocation updates the location in the gdbm with the provided input. +func (h *MenuHandlers) SaveLocation(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + var err error + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + location := string(input) + store := h.userdataStore + + flag_allow_update, _ := h.flagManager.GetFlag("flag_allow_update") + flag_location_set, _ := h.flagManager.GetFlag("flag_location_set") + allowUpdate := h.st.MatchFlag(flag_allow_update, true) + locationSet := h.st.MatchFlag(flag_location_set, true) + + if allowUpdate { + temporaryLocation, _ := store.ReadEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE) + err = store.WriteEntry(ctx, sessionId, common.DATA_LOCATION, []byte(temporaryLocation)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write location entry with", "key", common.DATA_LOCATION, "value", temporaryLocation, "error", err) + return res, err + } + res.FlagSet = append(res.FlagSet, flag_location_set) + } else { + if locationSet { + err = store.WriteEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE, []byte(location)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write temporaryLocation entry with", "key", common.DATA_TEMPORARY_VALUE, "value", location, "error", err) + return res, err + } + res.FlagSet = append(res.FlagSet, flag_location_set) + } else { + h.profile.InsertOrShift(4, location) + } + } + + return res, nil +} + +// SaveGender updates the gender in the gdbm with the provided input. +func (h *MenuHandlers) SaveGender(ctx context.Context, sym string, input []byte) (resource.Result, error) { + symbol, _ := h.st.Where() + var res resource.Result + var err error + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + gender := strings.Split(symbol, "_")[1] + store := h.userdataStore + flag_allow_update, _ := h.flagManager.GetFlag("flag_allow_update") + flag_gender_set, _ := h.flagManager.GetFlag("flag_gender_set") + + allowUpdate := h.st.MatchFlag(flag_allow_update, true) + genderSet := h.st.MatchFlag(flag_gender_set, true) + + if allowUpdate { + temporaryGender, _ := store.ReadEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE) + err = store.WriteEntry(ctx, sessionId, common.DATA_GENDER, []byte(temporaryGender)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write gender entry with", "key", common.DATA_GENDER, "value", gender, "error", err) + return res, err + } + res.FlagSet = append(res.FlagSet, flag_gender_set) + } else { + if genderSet { + err = store.WriteEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE, []byte(gender)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write temporaryGender entry with", "key", common.DATA_TEMPORARY_VALUE, "value", gender, "error", err) + return res, err + } + } else { + h.profile.InsertOrShift(2, gender) + } + } + + return res, nil +} + +// SaveOfferings updates the offerings(goods and services provided by the user) in the gdbm with the provided input. +func (h *MenuHandlers) SaveOfferings(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + var err error + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + + offerings := string(input) + store := h.userdataStore + + flag_allow_update, _ := h.flagManager.GetFlag("flag_allow_update") + flag_offerings_set, _ := h.flagManager.GetFlag("flag_offerings_set") + + allowUpdate := h.st.MatchFlag(flag_allow_update, true) + offeringsSet := h.st.MatchFlag(flag_offerings_set, true) + + if allowUpdate { + temporaryOfferings, _ := store.ReadEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE) + err = store.WriteEntry(ctx, sessionId, common.DATA_OFFERINGS, []byte(temporaryOfferings)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write offerings entry with", "key", common.DATA_TEMPORARY_VALUE, "value", offerings, "error", err) + return res, err + } + res.FlagSet = append(res.FlagSet, flag_offerings_set) + } else { + if offeringsSet { + err = store.WriteEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE, []byte(offerings)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write temporaryOfferings entry with", "key", common.DATA_TEMPORARY_VALUE, "value", offerings, "error", err) + return res, err + } + } else { + h.profile.InsertOrShift(5, offerings) + } + } + + return res, nil +} + +// ResetAllowUpdate resets the allowupdate flag that allows a user to update profile data. +func (h *MenuHandlers) ResetAllowUpdate(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + flag_allow_update, _ := h.flagManager.GetFlag("flag_allow_update") + res.FlagReset = append(res.FlagReset, flag_allow_update) + return res, nil +} + +// ResetAllowUpdate resets the allowupdate flag that allows a user to update profile data. +func (h *MenuHandlers) ResetValidPin(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + flag_valid_pin, _ := h.flagManager.GetFlag("flag_valid_pin") + res.FlagReset = append(res.FlagReset, flag_valid_pin) + return res, nil +} + +// ResetAccountAuthorized resets the account authorization flag after a successful PIN entry. +func (h *MenuHandlers) ResetAccountAuthorized(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + flag_account_authorized, _ := h.flagManager.GetFlag("flag_account_authorized") + res.FlagReset = append(res.FlagReset, flag_account_authorized) + return res, nil +} + +// CheckIdentifier retrieves the PublicKey from the JSON data file. +func (h *MenuHandlers) CheckIdentifier(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + store := h.userdataStore + publicKey, _ := store.ReadEntry(ctx, sessionId, common.DATA_PUBLIC_KEY) + + res.Content = string(publicKey) + + return res, nil +} + +// Authorize attempts to unlock the next sequential nodes by verifying the provided PIN against the already set PIN. +// It sets the required flags that control the flow. +func (h *MenuHandlers) Authorize(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + var err error + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + flag_incorrect_pin, _ := h.flagManager.GetFlag("flag_incorrect_pin") + flag_account_authorized, _ := h.flagManager.GetFlag("flag_account_authorized") + flag_allow_update, _ := h.flagManager.GetFlag("flag_allow_update") + + store := h.userdataStore + AccountPin, err := store.ReadEntry(ctx, sessionId, common.DATA_ACCOUNT_PIN) + if err != nil { + logg.ErrorCtxf(ctx, "failed to read AccountPin entry with", "key", common.DATA_ACCOUNT_PIN, "error", err) + return res, err + } + if len(input) == 4 { + if common.VerifyPIN(string(AccountPin), string(input)) { + if h.st.MatchFlag(flag_account_authorized, false) { + res.FlagReset = append(res.FlagReset, flag_incorrect_pin) + res.FlagSet = append(res.FlagSet, flag_allow_update, flag_account_authorized) + err := h.resetIncorrectPINAttempts(ctx, sessionId) + if err != nil { + return res, err + } + } else { + res.FlagSet = append(res.FlagSet, flag_allow_update) + res.FlagReset = append(res.FlagReset, flag_account_authorized) + err := h.resetIncorrectPINAttempts(ctx, sessionId) + if err != nil { + return res, err + } + } + } else { + err := h.incrementIncorrectPINAttempts(ctx, sessionId) + if err != nil { + return res, err + } + res.FlagSet = append(res.FlagSet, flag_incorrect_pin) + res.FlagReset = append(res.FlagReset, flag_account_authorized) + return res, nil + } + } else { + return res, nil + } + return res, nil +} + +// ResetIncorrectPin resets the incorrect pin flag after a new PIN attempt. +func (h *MenuHandlers) ResetIncorrectPin(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + store := h.userdataStore + + flag_incorrect_pin, _ := h.flagManager.GetFlag("flag_incorrect_pin") + flag_account_blocked, _ := h.flagManager.GetFlag("flag_account_blocked") + + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + + res.FlagReset = append(res.FlagReset, flag_incorrect_pin) + + currentWrongPinAttempts, err := store.ReadEntry(ctx, sessionId, common.DATA_INCORRECT_PIN_ATTEMPTS) + if err != nil { + if !db.IsNotFound(err) { + return res, err + } + } + pinAttemptsValue, _ := strconv.ParseUint(string(currentWrongPinAttempts), 0, 64) + remainingPINAttempts := common.AllowedPINAttempts - uint8(pinAttemptsValue) + if remainingPINAttempts == 0 { + res.FlagSet = append(res.FlagSet, flag_account_blocked) + return res, nil + } + if remainingPINAttempts < common.AllowedPINAttempts { + res.Content = strconv.Itoa(int(remainingPINAttempts)) + } + + return res, nil +} + +// Setback sets the flag_back_set flag when the navigation is back. +func (h *MenuHandlers) SetBack(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + //TODO: + //Add check if the navigation is lateral nav instead of checking the input. + if string(input) == "0" { + flag_back_set, _ := h.flagManager.GetFlag("flag_back_set") + res.FlagSet = append(res.FlagSet, flag_back_set) + } + return res, nil +} + +// CheckAccountStatus queries the API using the TrackingId and sets flags +// based on the account status. +func (h *MenuHandlers) CheckAccountStatus(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + + flag_account_success, _ := h.flagManager.GetFlag("flag_account_success") + flag_account_pending, _ := h.flagManager.GetFlag("flag_account_pending") + flag_api_error, _ := h.flagManager.GetFlag("flag_api_call_error") + + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + + store := h.userdataStore + publicKey, err := store.ReadEntry(ctx, sessionId, common.DATA_PUBLIC_KEY) + if err != nil { + logg.ErrorCtxf(ctx, "failed to read publicKey entry with", "key", common.DATA_PUBLIC_KEY, "error", err) + return res, err + } + + r, err := h.accountService.TrackAccountStatus(ctx, string(publicKey)) + if err != nil { + res.FlagSet = append(res.FlagSet, flag_api_error) + logg.ErrorCtxf(ctx, "failed on TrackAccountStatus", "error", err) + return res, err + } + + res.FlagReset = append(res.FlagReset, flag_api_error) + + if r.Active { + res.FlagSet = append(res.FlagSet, flag_account_success) + res.FlagReset = append(res.FlagReset, flag_account_pending) + } else { + res.FlagReset = append(res.FlagReset, flag_account_success) + res.FlagSet = append(res.FlagSet, flag_account_pending) + } + + return res, nil +} + +// Quit displays the Thank you message and exits the menu. +func (h *MenuHandlers) Quit(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + + flag_account_authorized, _ := h.flagManager.GetFlag("flag_account_authorized") + + code := codeFromCtx(ctx) + l := gotext.NewLocale(translationDir, code) + l.AddDomain("default") + + res.Content = l.Get("Thank you for using Sarafu. Goodbye!") + res.FlagReset = append(res.FlagReset, flag_account_authorized) + return res, nil +} + +// QuitWithHelp displays helpline information then exits the menu. +func (h *MenuHandlers) QuitWithHelp(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + + flag_account_authorized, _ := h.flagManager.GetFlag("flag_account_authorized") + + code := codeFromCtx(ctx) + l := gotext.NewLocale(translationDir, code) + l.AddDomain("default") + + res.Content = l.Get("For more help, please call: 0757628885") + res.FlagReset = append(res.FlagReset, flag_account_authorized) + return res, nil +} + +// ShowBlockedAccount displays a message after an account has been blocked and how to reach support. +func (h *MenuHandlers) ShowBlockedAccount(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + code := codeFromCtx(ctx) + l := gotext.NewLocale(translationDir, code) + l.AddDomain("default") + res.Content = l.Get("Your account has been locked. For help on how to unblock your account, contact support at: 0757628885") + return res, nil +} + +// VerifyYob verifies the length of the given input. +func (h *MenuHandlers) VerifyYob(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + var err error + + flag_incorrect_date_format, _ := h.flagManager.GetFlag("flag_incorrect_date_format") + date := string(input) + _, err = strconv.Atoi(date) + if err != nil { + // If conversion fails, input is not numeric + res.FlagSet = append(res.FlagSet, flag_incorrect_date_format) + return res, nil + } + + if utils.IsValidYOb(date) { + res.FlagReset = append(res.FlagReset, flag_incorrect_date_format) + } else { + res.FlagSet = append(res.FlagSet, flag_incorrect_date_format) + } + return res, nil +} + +// ResetIncorrectYob resets the incorrect date format flag after a new attempt. +func (h *MenuHandlers) ResetIncorrectYob(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + + flag_incorrect_date_format, _ := h.flagManager.GetFlag("flag_incorrect_date_format") + res.FlagReset = append(res.FlagReset, flag_incorrect_date_format) + return res, nil +} + +// CheckBalance retrieves the balance of the active voucher and sets +// the balance as the result content. +func (h *MenuHandlers) CheckBalance(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + var err error + + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + + code := codeFromCtx(ctx) + l := gotext.NewLocale(translationDir, code) + l.AddDomain("default") + + store := h.userdataStore + + // get the active sym and active balance + activeSym, err := store.ReadEntry(ctx, sessionId, common.DATA_ACTIVE_SYM) + if err != nil { + if db.IsNotFound(err) { + balance := "0.00" + res.Content = l.Get("Balance: %s\n", balance) + return res, nil + } + + logg.ErrorCtxf(ctx, "failed to read activeSym entry with", "key", common.DATA_ACTIVE_SYM, "error", err) + return res, err + } + + activeBal, err := store.ReadEntry(ctx, sessionId, common.DATA_ACTIVE_BAL) + if err != nil { + logg.ErrorCtxf(ctx, "failed to read activeBal entry with", "key", common.DATA_ACTIVE_BAL, "error", err) + return res, err + } + + // Convert activeBal from []byte to float64 + balFloat, err := strconv.ParseFloat(string(activeBal), 64) + if err != nil { + logg.ErrorCtxf(ctx, "failed to parse activeBal as float", "value", string(activeBal), "error", err) + return res, err + } + + // Format to 2 decimal places + balStr := fmt.Sprintf("%.2f %s", balFloat, activeSym) + + res.Content = l.Get("Balance: %s\n", balStr) + + return res, nil +} + +// FetchCommunityBalance retrieves and displays the balance for community accounts in user's preferred language. +func (h *MenuHandlers) FetchCommunityBalance(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + // retrieve the language code from the context + code := codeFromCtx(ctx) + // Initialize the localization system with the appropriate translation directory + l := gotext.NewLocale(translationDir, code) + l.AddDomain("default") + //TODO: + //Check if the address is a community account,if then,get the actual balance + res.Content = l.Get("Community Balance: 0.00") + return res, nil +} + +// ResetOthersPin handles the PIN reset process for other users' accounts by: +// 1. Retrieving the blocked phone number from the session +// 2. Fetching the temporary PIN associated with that number +// 3. Updating the account PIN with the temporary PIN +func (h *MenuHandlers) ResetOthersPin(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + store := h.userdataStore + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + blockedPhonenumber, err := store.ReadEntry(ctx, sessionId, common.DATA_BLOCKED_NUMBER) + if err != nil { + logg.ErrorCtxf(ctx, "failed to read blockedPhonenumber entry with", "key", common.DATA_BLOCKED_NUMBER, "error", err) + return res, err + } + temporaryPin, err := store.ReadEntry(ctx, string(blockedPhonenumber), common.DATA_TEMPORARY_VALUE) + if err != nil { + logg.ErrorCtxf(ctx, "failed to read temporaryPin entry with", "key", common.DATA_TEMPORARY_VALUE, "error", err) + return res, err + } + + // Hash the PIN + hashedPIN, err := common.HashPIN(string(temporaryPin)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to hash temporaryPin", "error", err) + return res, err + } + + err = store.WriteEntry(ctx, string(blockedPhonenumber), common.DATA_ACCOUNT_PIN, []byte(hashedPIN)) + if err != nil { + return res, nil + } + + return res, nil +} + +// ResetUnregisteredNumber clears the unregistered number flag in the system, +// indicating that a number's registration status should no longer be marked as unregistered. +func (h *MenuHandlers) ResetUnregisteredNumber(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + flag_unregistered_number, _ := h.flagManager.GetFlag("flag_unregistered_number") + res.FlagReset = append(res.FlagReset, flag_unregistered_number) + return res, nil +} + +// ValidateBlockedNumber performs validation of phone numbers, specifically for blocked numbers in the system. +// It checks phone number format and verifies registration status. +func (h *MenuHandlers) ValidateBlockedNumber(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + var err error + + flag_unregistered_number, _ := h.flagManager.GetFlag("flag_unregistered_number") + store := h.userdataStore + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + blockedNumber := string(input) + _, err = store.ReadEntry(ctx, blockedNumber, common.DATA_PUBLIC_KEY) + if !common.IsValidPhoneNumber(blockedNumber) { + res.FlagSet = append(res.FlagSet, flag_unregistered_number) + return res, nil + } + if err != nil { + if db.IsNotFound(err) { + logg.InfoCtxf(ctx, "Invalid or unregistered number") + res.FlagSet = append(res.FlagSet, flag_unregistered_number) + return res, nil + } else { + logg.ErrorCtxf(ctx, "Error on ValidateBlockedNumber", "error", err) + return res, err + } + } + err = store.WriteEntry(ctx, sessionId, common.DATA_BLOCKED_NUMBER, []byte(blockedNumber)) + if err != nil { + return res, nil + } + return res, nil +} + +// ValidateRecipient validates that the given input is valid. +func (h *MenuHandlers) ValidateRecipient(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + store := h.userdataStore + + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + + flag_invalid_recipient, _ := h.flagManager.GetFlag("flag_invalid_recipient") + flag_invalid_recipient_with_invite, _ := h.flagManager.GetFlag("flag_invalid_recipient_with_invite") + flag_api_error, _ := h.flagManager.GetFlag("flag_api_call_error") + + recipient := string(input) + + if recipient != "0" { + recipientType, err := common.CheckRecipient(recipient) + if err != nil { + // Invalid recipient format (not a phone number, address, or valid alias format) + res.FlagSet = append(res.FlagSet, flag_invalid_recipient) + res.Content = recipient + + return res, nil + } + + // save the recipient as the temporaryRecipient + err = store.WriteEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE, []byte(recipient)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write temporaryRecipient entry with", "key", common.DATA_TEMPORARY_VALUE, "value", recipient, "error", err) + return res, err + } + + switch recipientType { + case "phone number": + // format the phone number + formattedNumber, err := common.FormatPhoneNumber(recipient) + if err != nil { + logg.ErrorCtxf(ctx, "Failed to format the phone number: %s", recipient, "error", err) + return res, err + } + + // Check if the phone number is registered + publicKey, err := store.ReadEntry(ctx, formattedNumber, common.DATA_PUBLIC_KEY) + if err != nil { + if db.IsNotFound(err) { + logg.InfoCtxf(ctx, "Unregistered phone number: %s", recipient) + res.FlagSet = append(res.FlagSet, flag_invalid_recipient_with_invite) + res.Content = recipient + return res, nil + } + + logg.ErrorCtxf(ctx, "failed to read publicKey entry with", "key", common.DATA_PUBLIC_KEY, "error", err) + return res, err + } + + // Save the publicKey as the recipient + err = store.WriteEntry(ctx, sessionId, common.DATA_RECIPIENT, publicKey) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write recipient entry with", "key", common.DATA_RECIPIENT, "value", string(publicKey), "error", err) + return res, err + } + + case "address": + // Save the valid Ethereum address as the recipient + err = store.WriteEntry(ctx, sessionId, common.DATA_RECIPIENT, []byte(recipient)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write recipient entry with", "key", common.DATA_RECIPIENT, "value", recipient, "error", err) + return res, err + } + + case "alias": + // Call the API to validate and retrieve the address for the alias + r, aliasErr := h.accountService.CheckAliasAddress(ctx, recipient) + if aliasErr != nil { + res.FlagSet = append(res.FlagSet, flag_api_error) + res.Content = recipient + + logg.ErrorCtxf(ctx, "failed on CheckAliasAddress", "error", aliasErr) + return res, err + } + + // Alias validation succeeded, save the Ethereum address + err = store.WriteEntry(ctx, sessionId, common.DATA_RECIPIENT, []byte(r.Address)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write recipient entry with", "key", common.DATA_RECIPIENT, "value", r.Address, "error", err) + return res, err + } + } + } + + return res, nil +} + +// TransactionReset resets the previous transaction data (Recipient and Amount) +// as well as the invalid flags. +func (h *MenuHandlers) TransactionReset(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + var err error + + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + + flag_invalid_recipient, _ := h.flagManager.GetFlag("flag_invalid_recipient") + flag_invalid_recipient_with_invite, _ := h.flagManager.GetFlag("flag_invalid_recipient_with_invite") + store := h.userdataStore + err = store.WriteEntry(ctx, sessionId, common.DATA_AMOUNT, []byte("")) + if err != nil { + return res, nil + } + + err = store.WriteEntry(ctx, sessionId, common.DATA_RECIPIENT, []byte("")) + if err != nil { + return res, nil + } + + res.FlagReset = append(res.FlagReset, flag_invalid_recipient, flag_invalid_recipient_with_invite) + + return res, nil +} + +// InviteValidRecipient sends an invitation to the valid phone number. +func (h *MenuHandlers) InviteValidRecipient(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + store := h.userdataStore + + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + + code := codeFromCtx(ctx) + l := gotext.NewLocale(translationDir, code) + l.AddDomain("default") + + recipient, _ := store.ReadEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE) + + // TODO + // send an invitation SMS + // if successful + // res.Content = l.Get("Your invitation to %s to join Sarafu Network has been sent.", string(recipient)) + + res.Content = l.Get("Your invite request for %s to Sarafu Network failed. Please try again later.", string(recipient)) + return res, nil +} + +// ResetTransactionAmount resets the transaction amount and invalid flag. +func (h *MenuHandlers) ResetTransactionAmount(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + var err error + + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + + flag_invalid_amount, _ := h.flagManager.GetFlag("flag_invalid_amount") + store := h.userdataStore + err = store.WriteEntry(ctx, sessionId, common.DATA_AMOUNT, []byte("")) + if err != nil { + return res, nil + } + + res.FlagReset = append(res.FlagReset, flag_invalid_amount) + + return res, nil +} + +// MaxAmount gets the current balance from the API and sets it as +// the result content. +func (h *MenuHandlers) MaxAmount(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + var err error + + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + store := h.userdataStore + + activeBal, err := store.ReadEntry(ctx, sessionId, common.DATA_ACTIVE_BAL) + if err != nil { + logg.ErrorCtxf(ctx, "failed to read activeBal entry with", "key", common.DATA_ACTIVE_BAL, "error", err) + return res, err + } + + res.Content = string(activeBal) + + return res, nil +} + +// ValidateAmount ensures that the given input is a valid amount and that +// it is not more than the current balance. +func (h *MenuHandlers) ValidateAmount(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + flag_invalid_amount, _ := h.flagManager.GetFlag("flag_invalid_amount") + store := h.userdataStore + + var balanceValue float64 + + // retrieve the active balance + activeBal, err := store.ReadEntry(ctx, sessionId, common.DATA_ACTIVE_BAL) + if err != nil { + logg.ErrorCtxf(ctx, "failed to read activeBal entry with", "key", common.DATA_ACTIVE_BAL, "error", err) + return res, err + } + balanceValue, err = strconv.ParseFloat(string(activeBal), 64) + if err != nil { + logg.ErrorCtxf(ctx, "Failed to convert the activeBal to a float", "error", err) + return res, err + } + + // Extract numeric part from the input amount + amountStr := strings.TrimSpace(string(input)) + inputAmount, err := strconv.ParseFloat(amountStr, 64) + if err != nil { + res.FlagSet = append(res.FlagSet, flag_invalid_amount) + res.Content = amountStr + return res, nil + } + + if inputAmount > balanceValue { + res.FlagSet = append(res.FlagSet, flag_invalid_amount) + res.Content = amountStr + return res, nil + } + + // Format the amount with 2 decimal places before saving + formattedAmount := fmt.Sprintf("%.2f", inputAmount) + err = store.WriteEntry(ctx, sessionId, common.DATA_AMOUNT, []byte(formattedAmount)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write amount entry with", "key", common.DATA_AMOUNT, "value", formattedAmount, "error", err) + return res, err + } + + res.Content = formattedAmount + return res, nil +} + +// GetRecipient returns the transaction recipient phone number from the gdbm. +func (h *MenuHandlers) GetRecipient(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + store := h.userdataStore + recipient, _ := store.ReadEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE) + + res.Content = string(recipient) + + return res, nil +} + +// RetrieveBlockedNumber gets the current number during the pin reset for other's is in progress. +func (h *MenuHandlers) RetrieveBlockedNumber(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + store := h.userdataStore + blockedNumber, _ := store.ReadEntry(ctx, sessionId, common.DATA_BLOCKED_NUMBER) + + res.Content = string(blockedNumber) + + return res, nil +} + +// GetSender returns the sessionId (phoneNumber). +func (h *MenuHandlers) GetSender(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + + res.Content = sessionId + + return res, nil +} + +// GetAmount retrieves the amount from teh Gdbm Db. +func (h *MenuHandlers) GetAmount(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + store := h.userdataStore + + // retrieve the active symbol + activeSym, err := store.ReadEntry(ctx, sessionId, common.DATA_ACTIVE_SYM) + if err != nil { + logg.ErrorCtxf(ctx, "failed to read activeSym entry with", "key", common.DATA_ACTIVE_SYM, "error", err) + return res, err + } + + amount, _ := store.ReadEntry(ctx, sessionId, common.DATA_AMOUNT) + + res.Content = fmt.Sprintf("%s %s", string(amount), string(activeSym)) + + return res, nil +} + +// InitiateTransaction calls the TokenTransfer and returns a confirmation based on the result. +func (h *MenuHandlers) InitiateTransaction(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + var err error + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + + flag_account_authorized, _ := h.flagManager.GetFlag("flag_account_authorized") + + code := codeFromCtx(ctx) + l := gotext.NewLocale(translationDir, code) + l.AddDomain("default") + + data, err := common.ReadTransactionData(ctx, h.userdataStore, sessionId) + if err != nil { + return res, err + } + + finalAmountStr, err := common.ParseAndScaleAmount(data.Amount, data.ActiveDecimal) + if err != nil { + return res, err + } + + // Call TokenTransfer + r, err := h.accountService.TokenTransfer(ctx, finalAmountStr, data.PublicKey, data.Recipient, data.ActiveAddress) + if err != nil { + flag_api_error, _ := h.flagManager.GetFlag("flag_api_call_error") + res.FlagSet = append(res.FlagSet, flag_api_error) + res.Content = l.Get("Your request failed. Please try again later.") + logg.ErrorCtxf(ctx, "failed on TokenTransfer", "error", err) + return res, nil + } + + trackingId := r.TrackingId + logg.InfoCtxf(ctx, "TokenTransfer", "trackingId", trackingId) + + res.Content = l.Get( + "Your request has been sent. %s will receive %s %s from %s.", + data.TemporaryValue, + data.Amount, + data.ActiveSym, + sessionId, + ) + + res.FlagReset = append(res.FlagReset, flag_account_authorized) + return res, nil +} + +// GetCurrentProfileInfo retrieves specific profile fields based on the current state of the USSD session. +// Uses flag management system to track profile field status and handle menu navigation. +func (h *MenuHandlers) GetCurrentProfileInfo(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + var profileInfo []byte + var defaultValue string + var err error + + flag_firstname_set, _ := h.flagManager.GetFlag("flag_firstname_set") + flag_familyname_set, _ := h.flagManager.GetFlag("flag_familyname_set") + flag_yob_set, _ := h.flagManager.GetFlag("flag_yob_set") + flag_gender_set, _ := h.flagManager.GetFlag("flag_gender_set") + flag_location_set, _ := h.flagManager.GetFlag("flag_location_set") + flag_offerings_set, _ := h.flagManager.GetFlag("flag_offerings_set") + flag_back_set, _ := h.flagManager.GetFlag("flag_back_set") + + res.FlagReset = append(res.FlagReset, flag_back_set) + + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + language, ok := ctx.Value("Language").(lang.Language) + if !ok { + return res, fmt.Errorf("value for 'Language' is not of type lang.Language") + } + code := language.Code + if code == "swa" { + defaultValue = "Haipo" + } else { + defaultValue = "Not Provided" + } + + sm, _ := h.st.Where() + parts := strings.SplitN(sm, "_", 2) + filename := parts[1] + dbKeyStr := "DATA_" + strings.ToUpper(filename) + dbKey, err := common.StringToDataTyp(dbKeyStr) + + if err != nil { + return res, err + } + store := h.userdataStore + + switch dbKey { + case common.DATA_FIRST_NAME: + profileInfo, err = store.ReadEntry(ctx, sessionId, common.DATA_FIRST_NAME) + if err != nil { + if db.IsNotFound(err) { + res.Content = defaultValue + break + } + logg.ErrorCtxf(ctx, "Failed to read first name entry with", "key", "error", common.DATA_FIRST_NAME, err) + return res, err + } + res.FlagSet = append(res.FlagSet, flag_firstname_set) + res.Content = string(profileInfo) + case common.DATA_FAMILY_NAME: + profileInfo, err = store.ReadEntry(ctx, sessionId, common.DATA_FAMILY_NAME) + if err != nil { + if db.IsNotFound(err) { + res.Content = defaultValue + break + } + logg.ErrorCtxf(ctx, "Failed to read family name entry with", "key", "error", common.DATA_FAMILY_NAME, err) + return res, err + } + res.FlagSet = append(res.FlagSet, flag_familyname_set) + res.Content = string(profileInfo) + + case common.DATA_GENDER: + profileInfo, err = store.ReadEntry(ctx, sessionId, common.DATA_GENDER) + if err != nil { + if db.IsNotFound(err) { + res.Content = defaultValue + break + } + logg.ErrorCtxf(ctx, "Failed to read gender entry with", "key", "error", common.DATA_GENDER, err) + return res, err + } + res.FlagSet = append(res.FlagSet, flag_gender_set) + res.Content = string(profileInfo) + case common.DATA_YOB: + profileInfo, err = store.ReadEntry(ctx, sessionId, common.DATA_YOB) + if err != nil { + if db.IsNotFound(err) { + res.Content = defaultValue + break + } + logg.ErrorCtxf(ctx, "Failed to read year of birth(yob) entry with", "key", "error", common.DATA_YOB, err) + return res, err + } + res.FlagSet = append(res.FlagSet, flag_yob_set) + res.Content = string(profileInfo) + case common.DATA_LOCATION: + profileInfo, err = store.ReadEntry(ctx, sessionId, common.DATA_LOCATION) + if err != nil { + if db.IsNotFound(err) { + res.Content = defaultValue + break + } + logg.ErrorCtxf(ctx, "Failed to read location entry with", "key", "error", common.DATA_LOCATION, err) + return res, err + } + res.FlagSet = append(res.FlagSet, flag_location_set) + res.Content = string(profileInfo) + case common.DATA_OFFERINGS: + profileInfo, err = store.ReadEntry(ctx, sessionId, common.DATA_OFFERINGS) + if err != nil { + if db.IsNotFound(err) { + res.Content = defaultValue + break + } + logg.ErrorCtxf(ctx, "Failed to read offerings entry with", "key", "error", common.DATA_OFFERINGS, err) + return res, err + } + res.FlagSet = append(res.FlagSet, flag_offerings_set) + res.Content = string(profileInfo) + default: + break + } + + return res, nil +} + +// GetProfileInfo provides a comprehensive view of a user's profile. +func (h *MenuHandlers) GetProfileInfo(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + var defaultValue string + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + language, ok := ctx.Value("Language").(lang.Language) + if !ok { + return res, fmt.Errorf("value for 'Language' is not of type lang.Language") + } + code := language.Code + if code == "swa" { + defaultValue = "Haipo" + } else { + defaultValue = "Not Provided" + } + + // Helper function to handle nil byte slices and convert them to string + getEntryOrDefault := func(entry []byte, err error) string { + if err != nil || entry == nil { + return defaultValue + } + return string(entry) + } + store := h.userdataStore + // Retrieve user data as strings with fallback to defaultValue + firstName := getEntryOrDefault(store.ReadEntry(ctx, sessionId, common.DATA_FIRST_NAME)) + familyName := getEntryOrDefault(store.ReadEntry(ctx, sessionId, common.DATA_FAMILY_NAME)) + yob := getEntryOrDefault(store.ReadEntry(ctx, sessionId, common.DATA_YOB)) + gender := getEntryOrDefault(store.ReadEntry(ctx, sessionId, common.DATA_GENDER)) + location := getEntryOrDefault(store.ReadEntry(ctx, sessionId, common.DATA_LOCATION)) + offerings := getEntryOrDefault(store.ReadEntry(ctx, sessionId, common.DATA_OFFERINGS)) + + // Construct the full name + name := utils.ConstructName(firstName, familyName, defaultValue) + + // Calculate age from year of birth + age := defaultValue + if yob != defaultValue { + if yobInt, err := strconv.Atoi(yob); err == nil { + age = strconv.Itoa(utils.CalculateAgeWithYOB(yobInt)) + } else { + return res, fmt.Errorf("invalid year of birth: %v", err) + } + } + switch language.Code { + case "eng": + res.Content = fmt.Sprintf( + "Name: %s\nGender: %s\nAge: %s\nLocation: %s\nYou provide: %s\n", + name, gender, age, location, offerings, + ) + case "swa": + res.Content = fmt.Sprintf( + "Jina: %s\nJinsia: %s\nUmri: %s\nEneo: %s\nUnauza: %s\n", + name, gender, age, location, offerings, + ) + default: + res.Content = fmt.Sprintf( + "Name: %s\nGender: %s\nAge: %s\nLocation: %s\nYou provide: %s\n", + name, gender, age, location, offerings, + ) + } + + return res, nil +} + +// SetDefaultVoucher retrieves the current vouchers +// and sets the first as the default voucher, if no active voucher is set. +func (h *MenuHandlers) SetDefaultVoucher(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + var err error + store := h.userdataStore + + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + + flag_no_active_voucher, _ := h.flagManager.GetFlag("flag_no_active_voucher") + + // check if the user has an active sym + _, err = store.ReadEntry(ctx, sessionId, common.DATA_ACTIVE_SYM) + + if err != nil { + if db.IsNotFound(err) { + publicKey, err := store.ReadEntry(ctx, sessionId, common.DATA_PUBLIC_KEY) + if err != nil { + logg.ErrorCtxf(ctx, "failed to read publicKey entry with", "key", common.DATA_PUBLIC_KEY, "error", err) + return res, err + } + + // Fetch vouchers from the API using the public key + vouchersResp, err := h.accountService.FetchVouchers(ctx, string(publicKey)) + if err != nil { + res.FlagSet = append(res.FlagSet, flag_no_active_voucher) + return res, nil + } + + // Return if there is no voucher + if len(vouchersResp) == 0 { + res.FlagSet = append(res.FlagSet, flag_no_active_voucher) + return res, nil + } + + // Use only the first voucher + firstVoucher := vouchersResp[0] + defaultSym := firstVoucher.TokenSymbol + defaultBal := firstVoucher.Balance + defaultDec := firstVoucher.TokenDecimals + defaultAddr := firstVoucher.ContractAddress + + // Scale down the balance + scaledBalance := common.ScaleDownBalance(defaultBal, defaultDec) + + // set the active symbol + err = store.WriteEntry(ctx, sessionId, common.DATA_ACTIVE_SYM, []byte(defaultSym)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write defaultSym entry with", "key", common.DATA_ACTIVE_SYM, "value", defaultSym, "error", err) + return res, err + } + // set the active balance + err = store.WriteEntry(ctx, sessionId, common.DATA_ACTIVE_BAL, []byte(scaledBalance)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write defaultBal entry with", "key", common.DATA_ACTIVE_BAL, "value", scaledBalance, "error", err) + return res, err + } + // set the active decimals + err = store.WriteEntry(ctx, sessionId, common.DATA_ACTIVE_DECIMAL, []byte(defaultDec)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write defaultDec entry with", "key", common.DATA_ACTIVE_DECIMAL, "value", defaultDec, "error", err) + return res, err + } + // set the active contract address + err = store.WriteEntry(ctx, sessionId, common.DATA_ACTIVE_ADDRESS, []byte(defaultAddr)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write defaultAddr entry with", "key", common.DATA_ACTIVE_ADDRESS, "value", defaultAddr, "error", err) + return res, err + } + + return res, nil + } + + logg.ErrorCtxf(ctx, "failed to read activeSym entry with", "key", common.DATA_ACTIVE_SYM, "error", err) + return res, err + } + + res.FlagReset = append(res.FlagReset, flag_no_active_voucher) + + return res, nil +} + +// CheckVouchers retrieves the token holdings from the API using the "PublicKey" and stores +// them to gdbm. +func (h *MenuHandlers) CheckVouchers(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + + store := h.userdataStore + publicKey, err := store.ReadEntry(ctx, sessionId, common.DATA_PUBLIC_KEY) + if err != nil { + logg.ErrorCtxf(ctx, "failed to read publicKey entry with", "key", common.DATA_PUBLIC_KEY, "error", err) + return res, err + } + + // Fetch vouchers from the API using the public key + vouchersResp, err := h.accountService.FetchVouchers(ctx, string(publicKey)) + if err != nil { + return res, nil + } + + // check the current active sym and update the data + activeSym, _ := store.ReadEntry(ctx, sessionId, common.DATA_ACTIVE_SYM) + if activeSym != nil { + activeSymStr := string(activeSym) + + // Find the matching voucher data + var activeData *dataserviceapi.TokenHoldings + for _, voucher := range vouchersResp { + if voucher.TokenSymbol == activeSymStr { + activeData = &voucher + break + } + } + + if activeData == nil { + logg.ErrorCtxf(ctx, "activeSym not found in vouchers", "activeSym", activeSymStr) + return res, fmt.Errorf("activeSym %s not found in vouchers", activeSymStr) + } + + // Scale down the balance + scaledBalance := common.ScaleDownBalance(activeData.Balance, activeData.TokenDecimals) + + // Update the balance field with the scaled value + activeData.Balance = scaledBalance + + // Pass the matching voucher data to UpdateVoucherData + if err := common.UpdateVoucherData(ctx, h.userdataStore, sessionId, activeData); err != nil { + logg.ErrorCtxf(ctx, "failed on UpdateVoucherData", "error", err) + return res, err + } + } + + data := common.ProcessVouchers(vouchersResp) + + // Store all voucher data + dataMap := map[common.DataTyp]string{ + common.DATA_VOUCHER_SYMBOLS: data.Symbols, + common.DATA_VOUCHER_BALANCES: data.Balances, + common.DATA_VOUCHER_DECIMALS: data.Decimals, + common.DATA_VOUCHER_ADDRESSES: data.Addresses, + } + + for key, value := range dataMap { + if err := h.prefixDb.Put(ctx, []byte(common.ToBytes(key)), []byte(value)); err != nil { + return res, nil + } + } + + return res, nil +} + +// GetVoucherList fetches the list of vouchers and formats them. +func (h *MenuHandlers) GetVoucherList(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + + // Read vouchers from the store + voucherData, err := h.prefixDb.Get(ctx, common.ToBytes(common.DATA_VOUCHER_SYMBOLS)) + if err != nil { + logg.ErrorCtxf(ctx, "Failed to read the voucherData from prefixDb", "error", err) + return res, err + } + + formattedData := h.ReplaceSeparatorFunc(string(voucherData)) + + res.Content = string(formattedData) + + return res, nil +} + +// ViewVoucher retrieves the token holding and balance from the subprefixDB +// and displays it to the user for them to select it. +func (h *MenuHandlers) ViewVoucher(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + + code := codeFromCtx(ctx) + l := gotext.NewLocale(translationDir, code) + l.AddDomain("default") + + flag_incorrect_voucher, _ := h.flagManager.GetFlag("flag_incorrect_voucher") + + inputStr := string(input) + if inputStr == "0" || inputStr == "99" { + res.FlagReset = append(res.FlagReset, flag_incorrect_voucher) + return res, nil + } + + metadata, err := common.GetVoucherData(ctx, h.prefixDb, inputStr) + if err != nil { + return res, fmt.Errorf("failed to retrieve voucher data: %v", err) + } + + if metadata == nil { + res.FlagSet = append(res.FlagSet, flag_incorrect_voucher) + return res, nil + } + + if err := common.StoreTemporaryVoucher(ctx, h.userdataStore, sessionId, metadata); err != nil { + logg.ErrorCtxf(ctx, "failed on StoreTemporaryVoucher", "error", err) + return res, err + } + + res.FlagReset = append(res.FlagReset, flag_incorrect_voucher) + res.Content = l.Get("Symbol: %s\nBalance: %s", metadata.TokenSymbol, metadata.Balance) + + return res, nil +} + +// SetVoucher retrieves the temp voucher data and sets it as the active data. +func (h *MenuHandlers) SetVoucher(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + + // Get temporary data + tempData, err := common.GetTemporaryVoucherData(ctx, h.userdataStore, sessionId) + if err != nil { + logg.ErrorCtxf(ctx, "failed on GetTemporaryVoucherData", "error", err) + return res, err + } + + // Set as active and clear temporary data + if err := common.UpdateVoucherData(ctx, h.userdataStore, sessionId, tempData); err != nil { + logg.ErrorCtxf(ctx, "failed on UpdateVoucherData", "error", err) + return res, err + } + + res.Content = tempData.TokenSymbol + return res, nil +} + +// GetVoucherDetails retrieves the voucher details. +func (h *MenuHandlers) GetVoucherDetails(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + store := h.userdataStore + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + + flag_api_error, _ := h.flagManager.GetFlag("flag_api_call_error") + + // get the active address + activeAddress, err := store.ReadEntry(ctx, sessionId, common.DATA_ACTIVE_ADDRESS) + if err != nil { + logg.ErrorCtxf(ctx, "failed to read activeAddress entry with", "key", common.DATA_ACTIVE_ADDRESS, "error", err) + return res, err + } + + // use the voucher contract address to get the data from the API + voucherData, err := h.accountService.VoucherData(ctx, string(activeAddress)) + if err != nil { + res.FlagSet = append(res.FlagSet, flag_api_error) + return res, nil + } + + res.Content = fmt.Sprintf( + "Name: %s\nSymbol: %s\nCommodity: %s\nLocation: %s", voucherData.TokenName, voucherData.TokenSymbol, voucherData.TokenCommodity, voucherData.TokenLocation, + ) + + return res, nil +} + +// CheckTransactions retrieves the transactions from the API using the "PublicKey" and stores to prefixDb. +func (h *MenuHandlers) CheckTransactions(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + + flag_no_transfers, _ := h.flagManager.GetFlag("flag_no_transfers") + flag_api_error, _ := h.flagManager.GetFlag("flag_api_error") + + store := h.userdataStore + publicKey, err := store.ReadEntry(ctx, sessionId, common.DATA_PUBLIC_KEY) + if err != nil { + logg.ErrorCtxf(ctx, "failed to read publicKey entry with", "key", common.DATA_PUBLIC_KEY, "error", err) + return res, err + } + + // Fetch transactions from the API using the public key + transactionsResp, err := h.accountService.FetchTransactions(ctx, string(publicKey)) + if err != nil { + res.FlagSet = append(res.FlagSet, flag_api_error) + logg.ErrorCtxf(ctx, "failed on FetchTransactions", "error", err) + return res, err + } + + // Return if there are no transactions + if len(transactionsResp) == 0 { + res.FlagSet = append(res.FlagSet, flag_no_transfers) + return res, nil + } + + data := common.ProcessTransfers(transactionsResp) + + // Store all transaction data + dataMap := map[common.DataTyp]string{ + common.DATA_TX_SENDERS: data.Senders, + common.DATA_TX_RECIPIENTS: data.Recipients, + common.DATA_TX_VALUES: data.TransferValues, + common.DATA_TX_ADDRESSES: data.Addresses, + common.DATA_TX_HASHES: data.TxHashes, + common.DATA_TX_DATES: data.Dates, + common.DATA_TX_SYMBOLS: data.Symbols, + common.DATA_TX_DECIMALS: data.Decimals, + } + + for key, value := range dataMap { + if err := h.prefixDb.Put(ctx, []byte(common.ToBytes(key)), []byte(value)); err != nil { + logg.ErrorCtxf(ctx, "failed to write to prefixDb", "error", err) + return res, err + } + } + + res.FlagReset = append(res.FlagReset, flag_no_transfers) + + return res, nil +} + +// GetTransactionsList fetches the list of transactions and formats them. +func (h *MenuHandlers) GetTransactionsList(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + + store := h.userdataStore + publicKey, err := store.ReadEntry(ctx, sessionId, common.DATA_PUBLIC_KEY) + if err != nil { + logg.ErrorCtxf(ctx, "failed to read publicKey entry with", "key", common.DATA_PUBLIC_KEY, "error", err) + return res, err + } + + // Read transactions from the store and format them + TransactionSenders, err := h.prefixDb.Get(ctx, common.ToBytes(common.DATA_TX_SENDERS)) + if err != nil { + logg.ErrorCtxf(ctx, "Failed to read the TransactionSenders from prefixDb", "error", err) + return res, err + } + TransactionSyms, err := h.prefixDb.Get(ctx, common.ToBytes(common.DATA_TX_SYMBOLS)) + if err != nil { + logg.ErrorCtxf(ctx, "Failed to read the TransactionSyms from prefixDb", "error", err) + return res, err + } + TransactionValues, err := h.prefixDb.Get(ctx, common.ToBytes(common.DATA_TX_VALUES)) + if err != nil { + logg.ErrorCtxf(ctx, "Failed to read the TransactionValues from prefixDb", "error", err) + return res, err + } + TransactionDates, err := h.prefixDb.Get(ctx, common.ToBytes(common.DATA_TX_DATES)) + if err != nil { + logg.ErrorCtxf(ctx, "Failed to read the TransactionDates from prefixDb", "error", err) + return res, err + } + + // Parse the data + senders := strings.Split(string(TransactionSenders), "\n") + syms := strings.Split(string(TransactionSyms), "\n") + values := strings.Split(string(TransactionValues), "\n") + dates := strings.Split(string(TransactionDates), "\n") + + var formattedTransactions []string + for i := 0; i < len(senders); i++ { + sender := strings.TrimSpace(senders[i]) + sym := strings.TrimSpace(syms[i]) + value := strings.TrimSpace(values[i]) + date := strings.Split(strings.TrimSpace(dates[i]), " ")[0] + + status := "Received" + if sender == string(publicKey) { + status = "Sent" + } + + // Use the ReplaceSeparator function for the menu separator + transactionLine := fmt.Sprintf("%d%s%s %s %s %s", i+1, h.ReplaceSeparatorFunc(":"), status, value, sym, date) + formattedTransactions = append(formattedTransactions, transactionLine) + } + + res.Content = strings.Join(formattedTransactions, "\n") + + return res, nil +} + +// ViewTransactionStatement retrieves the transaction statement +// and displays it to the user. +func (h *MenuHandlers) ViewTransactionStatement(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + store := h.userdataStore + publicKey, err := store.ReadEntry(ctx, sessionId, common.DATA_PUBLIC_KEY) + if err != nil { + logg.ErrorCtxf(ctx, "failed to read publicKey entry with", "key", common.DATA_PUBLIC_KEY, "error", err) + return res, err + } + + flag_incorrect_statement, _ := h.flagManager.GetFlag("flag_incorrect_statement") + + inputStr := string(input) + if inputStr == "0" || inputStr == "99" || inputStr == "11" || inputStr == "22" { + res.FlagReset = append(res.FlagReset, flag_incorrect_statement) + return res, nil + } + + // Convert input string to integer + index, err := strconv.Atoi(strings.TrimSpace(inputStr)) + if err != nil { + return res, fmt.Errorf("invalid input: must be a number between 1 and 10") + } + + if index < 1 || index > 10 { + return res, fmt.Errorf("invalid input: index must be between 1 and 10") + } + + statement, err := common.GetTransferData(ctx, h.prefixDb, string(publicKey), index) + if err != nil { + return res, fmt.Errorf("failed to retrieve transfer data: %v", err) + } + + if statement == "" { + res.FlagSet = append(res.FlagSet, flag_incorrect_statement) + return res, nil + } + + res.FlagReset = append(res.FlagReset, flag_incorrect_statement) + res.Content = statement + + return res, nil +} + +// handles bulk updates of profile information. +func (h *MenuHandlers) insertProfileItems(ctx context.Context, sessionId string, res *resource.Result) error { + var err error + store := h.userdataStore + profileFlagNames := []string{ + "flag_firstname_set", + "flag_familyname_set", + "flag_yob_set", + "flag_gender_set", + "flag_location_set", + "flag_offerings_set", + } + profileDataKeys := []common.DataTyp{ + common.DATA_FIRST_NAME, + common.DATA_FAMILY_NAME, + common.DATA_GENDER, + common.DATA_YOB, + common.DATA_LOCATION, + common.DATA_OFFERINGS, + } + for index, profileItem := range h.profile.ProfileItems { + // Ensure the profileItem is not "0"(is set) + if profileItem != "0" { + flag, _ := h.flagManager.GetFlag(profileFlagNames[index]) + isProfileItemSet := h.st.MatchFlag(flag, true) + if !isProfileItemSet { + err = store.WriteEntry(ctx, sessionId, profileDataKeys[index], []byte(profileItem)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write profile entry with", "key", profileDataKeys[index], "value", profileItem, "error", err) + return err + } + res.FlagSet = append(res.FlagSet, flag) + } + } + } + return nil +} + +// UpdateAllProfileItems is used to persist all the new profile information and setup the required profile flags. +func (h *MenuHandlers) UpdateAllProfileItems(ctx context.Context, sym string, input []byte) (resource.Result, error) { + var res resource.Result + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return res, fmt.Errorf("missing session") + } + err := h.insertProfileItems(ctx, sessionId, &res) + if err != nil { + return res, err + } + return res, nil +} + +// incrementIncorrectPINAttempts keeps track of the number of incorrect PIN attempts +func (h *MenuHandlers) incrementIncorrectPINAttempts(ctx context.Context, sessionId string) error { + var pinAttemptsCount uint8 + store := h.userdataStore + + currentWrongPinAttempts, err := store.ReadEntry(ctx, sessionId, common.DATA_INCORRECT_PIN_ATTEMPTS) + if err != nil { + if db.IsNotFound(err) { + //First time Wrong PIN attempt: initialize with a count of 1 + pinAttemptsCount = 1 + err = store.WriteEntry(ctx, sessionId, common.DATA_INCORRECT_PIN_ATTEMPTS, []byte(strconv.Itoa(int(pinAttemptsCount)))) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write incorrect PIN attempts ", "key", common.DATA_INCORRECT_PIN_ATTEMPTS, "value", currentWrongPinAttempts, "error", err) + return err + } + return nil + } + } + pinAttemptsValue, _ := strconv.ParseUint(string(currentWrongPinAttempts), 0, 64) + pinAttemptsCount = uint8(pinAttemptsValue) + 1 + + err = store.WriteEntry(ctx, sessionId, common.DATA_INCORRECT_PIN_ATTEMPTS, []byte(strconv.Itoa(int(pinAttemptsCount)))) + if err != nil { + logg.ErrorCtxf(ctx, "failed to write incorrect PIN attempts ", "key", common.DATA_INCORRECT_PIN_ATTEMPTS, "value", pinAttemptsCount, "error", err) + return err + } + return nil +} + +// resetIncorrectPINAttempts resets the number of incorrect PIN attempts after a correct PIN entry +func (h *MenuHandlers) resetIncorrectPINAttempts(ctx context.Context, sessionId string) error { + store := h.userdataStore + currentWrongPinAttempts, err := store.ReadEntry(ctx, sessionId, common.DATA_INCORRECT_PIN_ATTEMPTS) + if err != nil { + if db.IsNotFound(err) { + return nil + } + return err + } + currentWrongPinAttemptsCount, _ := strconv.ParseUint(string(currentWrongPinAttempts), 0, 64) + if currentWrongPinAttemptsCount <= uint64(common.AllowedPINAttempts) { + err = store.WriteEntry(ctx, sessionId, common.DATA_INCORRECT_PIN_ATTEMPTS, []byte(string("0"))) + if err != nil { + logg.ErrorCtxf(ctx, "failed to reset incorrect PIN attempts ", "key", common.DATA_INCORRECT_PIN_ATTEMPTS, "value", common.AllowedPINAttempts, "error", err) + return err + } + } + return nil +} + +// persistLanguageCode persists the selected ISO 639 language code +func (h *MenuHandlers) persistLanguageCode(ctx context.Context, code string) error { + store := h.userdataStore + sessionId, ok := ctx.Value("SessionId").(string) + if !ok { + return fmt.Errorf("missing session") + } + err := store.WriteEntry(ctx, sessionId, common.DATA_SELECTED_LANGUAGE_CODE, []byte(code)) + if err != nil { + logg.ErrorCtxf(ctx, "failed to persist language code", "key", common.DATA_SELECTED_LANGUAGE_CODE, "value", code, "error", err) + return err + } + return nil +} diff --git a/handlers/application/menuhandler_test.go b/handlers/application/menuhandler_test.go new file mode 100644 index 0000000..a87ce58 --- /dev/null +++ b/handlers/application/menuhandler_test.go @@ -0,0 +1,2331 @@ +package application + +import ( + "context" + "fmt" + "log" + "path" + "strconv" + "strings" + "testing" + + "git.defalsify.org/vise.git/cache" + "git.defalsify.org/vise.git/lang" + "git.defalsify.org/vise.git/persist" + "git.defalsify.org/vise.git/resource" + "git.defalsify.org/vise.git/state" + dbstorage "git.grassecon.net/grassrootseconomics/visedriver/storage/db" + "git.grassecon.net/grassrootseconomics/visedriver/testutil/mocks" + "git.grassecon.net/grassrootseconomics/visedriver/testutil/testservice" + "git.grassecon.net/grassrootseconomics/visedriver/utils" + "git.grassecon.net/grassrootseconomics/visedriver/models" + + "git.grassecon.net/grassrootseconomics/visedriver/common" + "github.com/alecthomas/assert/v2" + + testdataloader "github.com/peteole/testdata-loader" + "github.com/stretchr/testify/require" + + visedb "git.defalsify.org/vise.git/db" + memdb "git.defalsify.org/vise.git/db/mem" + dataserviceapi "github.com/grassrootseconomics/ussd-data-service/pkg/api" +) + +var ( + baseDir = testdataloader.GetBasePath() + flagsPath = path.Join(baseDir, "services", "registration", "pp.csv") +) + +// mockReplaceSeparator function +var mockReplaceSeparator = func(input string) string { + return strings.ReplaceAll(input, ":", ": ") +} + +// InitializeTestStore sets up and returns an in-memory database and store. +func InitializeTestStore(t *testing.T) (context.Context, *common.UserDataStore) { + ctx := context.Background() + + // Initialize memDb + db := memdb.NewMemDb() + err := db.Connect(ctx, "") + require.NoError(t, err, "Failed to connect to memDb") + + // Create UserDataStore with memDb + store := &common.UserDataStore{Db: db} + + t.Cleanup(func() { + db.Close() // Ensure the DB is closed after each test + }) + + return ctx, store +} + +func InitializeTestSubPrefixDb(t *testing.T, ctx context.Context) *dbstorage.SubPrefixDb { + db := memdb.NewMemDb() + err := db.Connect(ctx, "") + if err != nil { + t.Fatal(err) + } + prefix := common.ToBytes(visedb.DATATYPE_USERDATA) + spdb := dbstorage.NewSubPrefixDb(db, prefix) + + return spdb +} + +func TestNewHandlers(t *testing.T) { + _, store := InitializeTestStore(t) + + fm, err := NewFlagManager(flagsPath) + if err != nil { + log.Fatal(err) + } + + accountService := testservice.TestAccountService{} + + // Test case for valid UserDataStore + t.Run("Valid UserDataStore", func(t *testing.T) { + handlers, err := NewHandlers(fm.parser, store, nil, &accountService, mockReplaceSeparator) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if handlers == nil { + t.Fatal("expected handlers to be non-nil") + } + if handlers.userdataStore == nil { + t.Fatal("expected userdataStore to be set in handlers") + } + if handlers.ReplaceSeparatorFunc == nil { + t.Fatal("expected ReplaceSeparatorFunc to be set in handlers") + } + + // Test ReplaceSeparatorFunc functionality + input := "1:Menu item" + expectedOutput := "1: Menu item" + if handlers.ReplaceSeparatorFunc(input) != expectedOutput { + t.Fatalf("ReplaceSeparatorFunc function did not return expected output: got %v, want %v", handlers.ReplaceSeparatorFunc(input), expectedOutput) + } + }) + + // Test case for nil UserDataStore + t.Run("Nil UserDataStore", func(t *testing.T) { + handlers, err := NewHandlers(fm.parser, nil, nil, &accountService, mockReplaceSeparator) + if err == nil { + t.Fatal("expected an error, got none") + } + if handlers != nil { + t.Fatal("expected handlers to be nil") + } + expectedError := "cannot create handler with nil userdata store" + if err.Error() != expectedError { + t.Fatalf("expected error '%s', got '%v'", expectedError, err) + } + }) +} + +func TestInit(t *testing.T) { + sessionId := "session123" + ctx, store := InitializeTestStore(t) + ctx = context.WithValue(ctx, "SessionId", sessionId) + + fm, err := NewFlagManager(flagsPath) + if err != nil { + t.Fatal(err.Error()) + } + + adminstore, err := utils.NewAdminStore(ctx, "admin_numbers") + if err != nil { + t.Fatal(err.Error()) + } + + st := state.NewState(128) + ca := cache.NewCache() + + flag_admin_privilege, _ := fm.GetFlag("flag_admin_privilege") + + tests := []struct { + name string + setup func() (*Handlers, context.Context) + input []byte + expectedResult resource.Result + }{ + { + name: "Handler not ready", + setup: func() (*Handlers, context.Context) { + return &Handlers{}, ctx + }, + input: []byte("1"), + expectedResult: resource.Result{}, + }, + { + name: "State and memory initialization", + setup: func() (*Handlers, context.Context) { + pe := persist.NewPersister(store).WithSession(sessionId).WithContent(st, ca) + h := &Handlers{ + flagManager: fm.parser, + adminstore: adminstore, + pe: pe, + } + return h, context.WithValue(ctx, "SessionId", sessionId) + }, + input: []byte("1"), + expectedResult: resource.Result{ + FlagReset: []uint32{flag_admin_privilege}, + }, + }, + { + name: "Non-admin session initialization", + setup: func() (*Handlers, context.Context) { + pe := persist.NewPersister(store).WithSession("0712345678").WithContent(st, ca) + h := &Handlers{ + flagManager: fm.parser, + adminstore: adminstore, + pe: pe, + } + return h, context.WithValue(context.Background(), "SessionId", "0712345678") + }, + input: []byte("1"), + expectedResult: resource.Result{ + FlagReset: []uint32{flag_admin_privilege}, + }, + }, + { + name: "Move to top node on empty input", + setup: func() (*Handlers, context.Context) { + pe := persist.NewPersister(store).WithSession(sessionId).WithContent(st, ca) + h := &Handlers{ + flagManager: fm.parser, + adminstore: adminstore, + pe: pe, + } + st.Code = []byte("some pending bytecode") + return h, context.WithValue(ctx, "SessionId", sessionId) + }, + input: []byte(""), + expectedResult: resource.Result{ + FlagReset: []uint32{flag_admin_privilege}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h, testCtx := tt.setup() + res, err := h.Init(testCtx, "", tt.input) + + assert.NoError(t, err, "Unexpected error occurred") + assert.Equal(t, res, tt.expectedResult, "Expected result should match actual result") + }) + } +} + +func TestCreateAccount(t *testing.T) { + sessionId := "session123" + ctx, store := InitializeTestStore(t) + ctx = context.WithValue(ctx, "SessionId", sessionId) + + fm, err := NewFlagManager(flagsPath) + if err != nil { + t.Logf(err.Error()) + } + + flag_account_created, err := fm.GetFlag("flag_account_created") + if err != nil { + t.Logf(err.Error()) + } + + tests := []struct { + name string + serverResponse *models.AccountResult + expectedResult resource.Result + }{ + { + name: "Test account creation success", + serverResponse: &models.AccountResult{ + TrackingId: "1234567890", + PublicKey: "0xD3adB33f", + }, + expectedResult: resource.Result{ + FlagSet: []uint32{flag_account_created}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockAccountService := new(mocks.MockAccountService) + + h := &Handlers{ + userdataStore: store, + accountService: mockAccountService, + flagManager: fm.parser, + } + + mockAccountService.On("CreateAccount").Return(tt.serverResponse, nil) + + // Call the method you want to test + res, err := h.CreateAccount(ctx, "create_account", []byte("")) + + // Assert that no errors occurred + assert.NoError(t, err) + + // Assert that the account created flag has been set to the result + assert.Equal(t, res, tt.expectedResult, "Expected result should be equal to the actual result") + }) + } +} + +func TestWithPersister(t *testing.T) { + // Test case: Setting a persister + h := &Handlers{} + p := &persist.Persister{} + + result := h.WithPersister(p) + + assert.Equal(t, p, h.pe, "The persister should be set correctly.") + assert.Equal(t, h, result, "The returned handler should be the same instance.") +} + +func TestWithPersister_PanicWhenAlreadySet(t *testing.T) { + // Test case: Panic on multiple calls + h := &Handlers{pe: &persist.Persister{}} + require.Panics(t, func() { + h.WithPersister(&persist.Persister{}) + }, "Should panic when trying to set a persister again.") +} + +func TestSaveFirstname(t *testing.T) { + sessionId := "session123" + ctx, store := InitializeTestStore(t) + ctx = context.WithValue(ctx, "SessionId", sessionId) + + fm, _ := NewFlagManager(flagsPath) + + flag_allow_update, _ := fm.GetFlag("flag_allow_update") + flag_firstname_set, _ := fm.GetFlag("flag_firstname_set") + + // Set the flag in the State + mockState := state.NewState(128) + mockState.SetFlag(flag_allow_update) + + expectedResult := resource.Result{} + + // Define test data + firstName := "John" + + if err := store.WriteEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE, []byte(firstName)); err != nil { + t.Fatal(err) + } + + expectedResult.FlagSet = []uint32{flag_firstname_set} + + // Create the Handlers instance with the mock store + h := &Handlers{ + userdataStore: store, + flagManager: fm.parser, + st: mockState, + } + + // Call the method + res, err := h.SaveFirstname(ctx, "save_firstname", []byte(firstName)) + + // Assert results + assert.NoError(t, err) + assert.Equal(t, expectedResult, res) + + // Verify that the DATA_FIRST_NAME entry has been updated with the temporary value + storedFirstName, _ := store.ReadEntry(ctx, sessionId, common.DATA_FIRST_NAME) + assert.Equal(t, firstName, string(storedFirstName)) +} + +func TestSaveFamilyname(t *testing.T) { + sessionId := "session123" + ctx, store := InitializeTestStore(t) + ctx = context.WithValue(ctx, "SessionId", sessionId) + + fm, _ := NewFlagManager(flagsPath) + + flag_allow_update, _ := fm.GetFlag("flag_allow_update") + flag_firstname_set, _ := fm.GetFlag("flag_familyname_set") + + // Set the flag in the State + mockState := state.NewState(128) + mockState.SetFlag(flag_allow_update) + + expectedResult := resource.Result{} + + expectedResult.FlagSet = []uint32{flag_firstname_set} + + // Define test data + familyName := "Doeee" + + if err := store.WriteEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE, []byte(familyName)); err != nil { + t.Fatal(err) + } + + // Create the Handlers instance with the mock store + h := &Handlers{ + userdataStore: store, + st: mockState, + flagManager: fm.parser, + } + + // Call the method + res, err := h.SaveFamilyname(ctx, "save_familyname", []byte(familyName)) + + // Assert results + assert.NoError(t, err) + assert.Equal(t, expectedResult, res) + + // Verify that the DATA_FAMILY_NAME entry has been updated with the temporary value + storedFamilyName, _ := store.ReadEntry(ctx, sessionId, common.DATA_FAMILY_NAME) + assert.Equal(t, familyName, string(storedFamilyName)) +} + +func TestSaveYoB(t *testing.T) { + sessionId := "session123" + ctx, store := InitializeTestStore(t) + ctx = context.WithValue(ctx, "SessionId", sessionId) + + fm, _ := NewFlagManager(flagsPath) + + flag_allow_update, _ := fm.GetFlag("flag_allow_update") + flag_yob_set, _ := fm.GetFlag("flag_yob_set") + + // Set the flag in the State + mockState := state.NewState(108) + mockState.SetFlag(flag_allow_update) + + expectedResult := resource.Result{} + + // Define test data + yob := "1980" + + if err := store.WriteEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE, []byte(yob)); err != nil { + t.Fatal(err) + } + + expectedResult.FlagSet = []uint32{flag_yob_set} + + // Create the Handlers instance with the mock store + h := &Handlers{ + userdataStore: store, + flagManager: fm.parser, + st: mockState, + } + + // Call the method + res, err := h.SaveYob(ctx, "save_yob", []byte(yob)) + + // Assert results + assert.NoError(t, err) + assert.Equal(t, expectedResult, res) + + // Verify that the DATA_YOB entry has been updated with the temporary value + storedYob, _ := store.ReadEntry(ctx, sessionId, common.DATA_YOB) + assert.Equal(t, yob, string(storedYob)) +} + +func TestSaveLocation(t *testing.T) { + sessionId := "session123" + ctx, store := InitializeTestStore(t) + ctx = context.WithValue(ctx, "SessionId", sessionId) + + fm, _ := NewFlagManager(flagsPath) + + flag_allow_update, _ := fm.GetFlag("flag_allow_update") + flag_location_set, _ := fm.GetFlag("flag_location_set") + + // Set the flag in the State + mockState := state.NewState(108) + mockState.SetFlag(flag_allow_update) + + expectedResult := resource.Result{} + + // Define test data + location := "Kilifi" + + if err := store.WriteEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE, []byte(location)); err != nil { + t.Fatal(err) + } + + expectedResult.FlagSet = []uint32{flag_location_set} + + // Create the Handlers instance with the mock store + h := &Handlers{ + userdataStore: store, + flagManager: fm.parser, + st: mockState, + } + + // Call the method + res, err := h.SaveLocation(ctx, "save_location", []byte(location)) + + // Assert results + assert.NoError(t, err) + assert.Equal(t, expectedResult, res) + + // Verify that the DATA_LOCATION entry has been updated with the temporary value + storedLocation, _ := store.ReadEntry(ctx, sessionId, common.DATA_LOCATION) + assert.Equal(t, location, string(storedLocation)) +} + +func TestSaveOfferings(t *testing.T) { + sessionId := "session123" + ctx, store := InitializeTestStore(t) + ctx = context.WithValue(ctx, "SessionId", sessionId) + + fm, _ := NewFlagManager(flagsPath) + + flag_allow_update, _ := fm.GetFlag("flag_allow_update") + flag_offerings_set, _ := fm.GetFlag("flag_offerings_set") + + // Set the flag in the State + mockState := state.NewState(108) + mockState.SetFlag(flag_allow_update) + + expectedResult := resource.Result{} + + // Define test data + offerings := "Bananas" + + if err := store.WriteEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE, []byte(offerings)); err != nil { + t.Fatal(err) + } + + expectedResult.FlagSet = []uint32{flag_offerings_set} + + // Create the Handlers instance with the mock store + h := &Handlers{ + userdataStore: store, + flagManager: fm.parser, + st: mockState, + } + + // Call the method + res, err := h.SaveOfferings(ctx, "save_offerings", []byte(offerings)) + + // Assert results + assert.NoError(t, err) + assert.Equal(t, expectedResult, res) + + // Verify that the DATA_OFFERINGS entry has been updated with the temporary value + storedOfferings, _ := store.ReadEntry(ctx, sessionId, common.DATA_OFFERINGS) + assert.Equal(t, offerings, string(storedOfferings)) +} + +func TestSaveGender(t *testing.T) { + sessionId := "session123" + ctx, store := InitializeTestStore(t) + ctx = context.WithValue(ctx, "SessionId", sessionId) + + fm, _ := NewFlagManager(flagsPath) + + flag_allow_update, _ := fm.GetFlag("flag_allow_update") + flag_gender_set, _ := fm.GetFlag("flag_gender_set") + + // Set the flag in the State + mockState := state.NewState(108) + mockState.SetFlag(flag_allow_update) + + // Define test cases + tests := []struct { + name string + input []byte + expectedGender string + executingSymbol string + }{ + { + name: "Valid Male Input", + input: []byte("1"), + expectedGender: "male", + executingSymbol: "set_male", + }, + { + name: "Valid Female Input", + input: []byte("2"), + expectedGender: "female", + executingSymbol: "set_female", + }, + { + name: "Valid Unspecified Input", + input: []byte("3"), + executingSymbol: "set_unspecified", + expectedGender: "unspecified", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := store.WriteEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE, []byte(tt.expectedGender)); err != nil { + t.Fatal(err) + } + + mockState.ExecPath = append(mockState.ExecPath, tt.executingSymbol) + // Create the Handlers instance with the mock store + h := &Handlers{ + userdataStore: store, + st: mockState, + flagManager: fm.parser, + } + + expectedResult := resource.Result{} + + // Call the method + res, err := h.SaveGender(ctx, "save_gender", tt.input) + + expectedResult.FlagSet = []uint32{flag_gender_set} + + // Assert results + assert.NoError(t, err) + assert.Equal(t, expectedResult, res) + + // Verify that the DATA_GENDER entry has been updated with the temporary value + storedGender, _ := store.ReadEntry(ctx, sessionId, common.DATA_GENDER) + assert.Equal(t, tt.expectedGender, string(storedGender)) + }) + } +} + +func TestSaveTemporaryPin(t *testing.T) { + sessionId := "session123" + ctx, store := InitializeTestStore(t) + ctx = context.WithValue(ctx, "SessionId", sessionId) + + fm, err := NewFlagManager(flagsPath) + if err != nil { + log.Fatal(err) + } + + flag_incorrect_pin, _ := fm.parser.GetFlag("flag_incorrect_pin") + + // Create the Handlers instance with the mock flag manager + h := &Handlers{ + flagManager: fm.parser, + userdataStore: store, + } + + // Define test cases + tests := []struct { + name string + input []byte + expectedResult resource.Result + }{ + { + name: "Valid Pin entry", + input: []byte("1234"), + expectedResult: resource.Result{ + FlagReset: []uint32{flag_incorrect_pin}, + }, + }, + { + name: "Invalid Pin entry", + input: []byte("12343"), + expectedResult: resource.Result{ + FlagSet: []uint32{flag_incorrect_pin}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Call the method + res, err := h.SaveTemporaryPin(ctx, "save_pin", tt.input) + + if err != nil { + t.Error(err) + } + // Assert that the Result FlagSet has the required flags after language switch + assert.Equal(t, res, tt.expectedResult, "Result should match expected result") + }) + } +} + +func TestCheckIdentifier(t *testing.T) { + sessionId := "session123" + ctx, store := InitializeTestStore(t) + ctx = context.WithValue(ctx, "SessionId", sessionId) + + // Define test cases + tests := []struct { + name string + publicKey []byte + mockErr error + expectedContent string + expectError bool + }{ + { + name: "Saved public Key", + publicKey: []byte("0xa8363"), + mockErr: nil, + expectedContent: "0xa8363", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := store.WriteEntry(ctx, sessionId, common.DATA_PUBLIC_KEY, []byte(tt.publicKey)) + if err != nil { + t.Fatal(err) + } + + // Create the Handlers instance with the mock store + h := &Handlers{ + userdataStore: store, + } + + // Call the method + res, err := h.CheckIdentifier(ctx, "check_identifier", nil) + + // Assert results + assert.NoError(t, err) + assert.Equal(t, tt.expectedContent, res.Content) + }) + } +} + +func TestGetSender(t *testing.T) { + sessionId := "session123" + ctx, _ := InitializeTestStore(t) + ctx = context.WithValue(ctx, "SessionId", sessionId) + + // Create the Handlers instance + h := &Handlers{} + + // Call the method + res, _ := h.GetSender(ctx, "get_sender", []byte("")) + + //Assert that the sessionId is what was set as the result content. + assert.Equal(t, sessionId, res.Content) +} + +func TestGetAmount(t *testing.T) { + sessionId := "session123" + ctx, store := InitializeTestStore(t) + ctx = context.WithValue(ctx, "SessionId", sessionId) + + // Define test data + amount := "0.03" + activeSym := "SRF" + + err := store.WriteEntry(ctx, sessionId, common.DATA_AMOUNT, []byte(amount)) + if err != nil { + t.Fatal(err) + } + + err = store.WriteEntry(ctx, sessionId, common.DATA_ACTIVE_SYM, []byte(activeSym)) + if err != nil { + t.Fatal(err) + } + + // Create the Handlers instance with the mock store + h := &Handlers{ + userdataStore: store, + } + + // Call the method + res, _ := h.GetAmount(ctx, "get_amount", []byte("")) + + formattedAmount := fmt.Sprintf("%s %s", amount, activeSym) + + //Assert that the retrieved amount is what was set as the content + assert.Equal(t, formattedAmount, res.Content) +} + +func TestGetRecipient(t *testing.T) { + sessionId := "session123" + ctx, store := InitializeTestStore(t) + ctx = context.WithValue(ctx, "SessionId", sessionId) + + recepient := "0712345678" + + err := store.WriteEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE, []byte(recepient)) + if err != nil { + t.Fatal(err) + } + + // Create the Handlers instance with the mock store + h := &Handlers{ + userdataStore: store, + } + + // Call the method + res, _ := h.GetRecipient(ctx, "get_recipient", []byte("")) + + //Assert that the retrieved recepient is what was set as the content + assert.Equal(t, recepient, res.Content) +} + +func TestGetFlag(t *testing.T) { + fm, err := NewFlagManager(flagsPath) + expectedFlag := uint32(9) + if err != nil { + t.Logf(err.Error()) + } + flag, err := fm.GetFlag("flag_account_created") + if err != nil { + t.Logf(err.Error()) + } + + assert.Equal(t, uint32(flag), expectedFlag, "Flags should be equal to account created") +} + +func TestSetLanguage(t *testing.T) { + fm, err := NewFlagManager(flagsPath) + if err != nil { + log.Fatal(err) + } + + sessionId := "session123" + ctx, store := InitializeTestStore(t) + + ctx = context.WithValue(ctx, "SessionId", sessionId) + + // Define test cases + tests := []struct { + name string + execPath []string + expectedResult resource.Result + }{ + { + name: "Set Default Language (English)", + execPath: []string{"set_eng"}, + expectedResult: resource.Result{ + FlagSet: []uint32{state.FLAG_LANG, 8}, + Content: "eng", + }, + }, + { + name: "Set Swahili Language", + execPath: []string{"set_swa"}, + expectedResult: resource.Result{ + FlagSet: []uint32{state.FLAG_LANG, 8}, + Content: "swa", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockState := state.NewState(16) + // Set the ExecPath + mockState.ExecPath = tt.execPath + + // Create the Handlers instance with the mock flag manager + h := &Handlers{ + flagManager: fm.parser, + userdataStore: store, + st: mockState, + } + + // Call the method + res, err := h.SetLanguage(ctx, "set_language", nil) + if err != nil { + t.Error(err) + } + + // Assert that the Result FlagSet has the required flags after language switch + assert.Equal(t, res, tt.expectedResult, "Result should match expected result") + }) + } +} + +func TestResetAllowUpdate(t *testing.T) { + fm, err := NewFlagManager(flagsPath) + if err != nil { + log.Fatal(err) + } + + flag_allow_update, _ := fm.parser.GetFlag("flag_allow_update") + + // Define test cases + tests := []struct { + name string + input []byte + expectedResult resource.Result + }{ + { + name: "Resets allow update", + input: []byte(""), + expectedResult: resource.Result{ + FlagReset: []uint32{flag_allow_update}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create the Handlers instance with the mock flag manager + h := &Handlers{ + flagManager: fm.parser, + } + + // Call the method + res, err := h.ResetAllowUpdate(context.Background(), "reset_allow update", tt.input) + if err != nil { + t.Error(err) + } + + // Assert that the Result FlagSet has the required flags after language switch + assert.Equal(t, res, tt.expectedResult, "Flags should be equal to account created") + }) + } +} + +func TestResetAccountAuthorized(t *testing.T) { + fm, err := NewFlagManager(flagsPath) + if err != nil { + log.Fatal(err) + } + + flag_account_authorized, _ := fm.parser.GetFlag("flag_account_authorized") + + // Define test cases + tests := []struct { + name string + input []byte + expectedResult resource.Result + }{ + { + name: "Resets account authorized", + input: []byte(""), + expectedResult: resource.Result{ + FlagReset: []uint32{flag_account_authorized}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create the Handlers instance with the mock flag manager + h := &Handlers{ + flagManager: fm.parser, + } + + // Call the method + res, err := h.ResetAccountAuthorized(context.Background(), "reset_account_authorized", tt.input) + if err != nil { + t.Error(err) + } + + // Assert that the Result FlagSet has the required flags after language switch + assert.Equal(t, res, tt.expectedResult, "Result should contain flag(s) that have been reset") + }) + } +} + +func TestIncorrectPinReset(t *testing.T) { + sessionId := "session123" + ctx, store := InitializeTestStore(t) + fm, err := NewFlagManager(flagsPath) + + if err != nil { + log.Fatal(err) + } + + flag_incorrect_pin, _ := fm.parser.GetFlag("flag_incorrect_pin") + flag_account_blocked, _ := fm.parser.GetFlag("flag_account_blocked") + + ctx = context.WithValue(ctx, "SessionId", sessionId) + + // Define test cases + tests := []struct { + name string + input []byte + attempts uint8 + expectedResult resource.Result + }{ + { + name: "Test when incorrect PIN attempts is 2", + input: []byte(""), + expectedResult: resource.Result{ + FlagReset: []uint32{flag_incorrect_pin}, + Content: "1", //Expected remaining PIN attempts + }, + attempts: 2, + }, + { + name: "Test incorrect pin reset when incorrect PIN attempts is 1", + input: []byte(""), + expectedResult: resource.Result{ + FlagReset: []uint32{flag_incorrect_pin}, + Content: "2", //Expected remaining PIN attempts + }, + attempts: 1, + }, + { + name: "Test incorrect pin reset when incorrect PIN attempts is 1", + input: []byte(""), + expectedResult: resource.Result{ + FlagReset: []uint32{flag_incorrect_pin}, + Content: "2", //Expected remaining PIN attempts + }, + attempts: 1, + }, + { + name: "Test incorrect pin reset when incorrect PIN attempts is 3(account expected to be blocked)", + input: []byte(""), + expectedResult: resource.Result{ + FlagReset: []uint32{flag_incorrect_pin}, + FlagSet: []uint32{flag_account_blocked}, + }, + attempts: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + if err := store.WriteEntry(ctx, sessionId, common.DATA_INCORRECT_PIN_ATTEMPTS, []byte(strconv.Itoa(int(tt.attempts)))); err != nil { + t.Fatal(err) + } + + // Create the Handlers instance with the mock flag manager + h := &Handlers{ + flagManager: fm.parser, + userdataStore: store, + } + + // Call the method + res, err := h.ResetIncorrectPin(ctx, "reset_incorrect_pin", tt.input) + if err != nil { + t.Error(err) + } + + // Assert that the Result FlagSet has the required flags after language switch + assert.Equal(t, res, tt.expectedResult, "Result should contain flag(s) that have been reset") + }) + } +} + +func TestResetIncorrectYob(t *testing.T) { + fm, err := NewFlagManager(flagsPath) + if err != nil { + log.Fatal(err) + } + + flag_incorrect_date_format, _ := fm.parser.GetFlag("flag_incorrect_date_format") + + // Define test cases + tests := []struct { + name string + input []byte + expectedResult resource.Result + }{ + { + name: "Test incorrect yob reset", + input: []byte(""), + expectedResult: resource.Result{ + FlagReset: []uint32{flag_incorrect_date_format}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create the Handlers instance with the mock flag manager + h := &Handlers{ + flagManager: fm.parser, + } + + // Call the method + res, err := h.ResetIncorrectYob(context.Background(), "reset_incorrect_yob", tt.input) + if err != nil { + t.Error(err) + } + + // Assert that the Result FlagSet has the required flags after language switch + assert.Equal(t, res, tt.expectedResult, "Result should contain flag(s) that have been reset") + }) + } +} + +func TestAuthorize(t *testing.T) { + sessionId := "session123" + ctx, store := InitializeTestStore(t) + ctx = context.WithValue(ctx, "SessionId", sessionId) + + fm, err := NewFlagManager(flagsPath) + if err != nil { + t.Logf(err.Error()) + } + + // Create required mocks + mockAccountService := new(mocks.MockAccountService) + mockState := state.NewState(16) + flag_incorrect_pin, _ := fm.GetFlag("flag_incorrect_pin") + flag_account_authorized, _ := fm.GetFlag("flag_account_authorized") + flag_allow_update, _ := fm.GetFlag("flag_allow_update") + + // Set 1234 is the correct account pin + accountPIN := "1234" + + h := &Handlers{ + userdataStore: store, + accountService: mockAccountService, + flagManager: fm.parser, + st: mockState, + } + + tests := []struct { + name string + input []byte + expectedResult resource.Result + }{ + { + name: "Test with correct pin", + input: []byte("1234"), + expectedResult: resource.Result{ + FlagReset: []uint32{flag_incorrect_pin}, + FlagSet: []uint32{flag_allow_update, flag_account_authorized}, + }, + }, + { + name: "Test with incorrect pin", + input: []byte("1235"), + expectedResult: resource.Result{ + FlagReset: []uint32{flag_account_authorized}, + FlagSet: []uint32{flag_incorrect_pin}, + }, + }, + { + name: "Test with pin that is not a 4 digit", + input: []byte("1235aqds"), + expectedResult: resource.Result{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Hash the PIN + hashedPIN, err := common.HashPIN(accountPIN) + if err != nil { + logg.ErrorCtxf(ctx, "failed to hash temporaryPin", "error", err) + t.Fatal(err) + } + + err = store.WriteEntry(ctx, sessionId, common.DATA_ACCOUNT_PIN, []byte(hashedPIN)) + if err != nil { + t.Fatal(err) + } + + // Call the method under test + res, err := h.Authorize(ctx, "authorize", []byte(tt.input)) + + // Assert that no errors occurred + assert.NoError(t, err) + + //Assert that the account created flag has been set to the result + assert.Equal(t, res, tt.expectedResult, "Expected result should be equal to the actual result") + }) + } +} + +func TestVerifyYob(t *testing.T) { + fm, err := NewFlagManager(flagsPath) + if err != nil { + t.Logf(err.Error()) + } + + sessionId := "session123" + // Create required mocks + mockAccountService := new(mocks.MockAccountService) + mockState := state.NewState(16) + flag_incorrect_date_format, _ := fm.parser.GetFlag("flag_incorrect_date_format") + ctx := context.WithValue(context.Background(), "SessionId", sessionId) + + h := &Handlers{ + accountService: mockAccountService, + flagManager: fm.parser, + st: mockState, + } + + tests := []struct { + name string + input []byte + expectedResult resource.Result + }{ + { + name: "Test with correct yob", + input: []byte("1980"), + expectedResult: resource.Result{ + FlagReset: []uint32{flag_incorrect_date_format}, + }, + }, + { + name: "Test with incorrect yob", + input: []byte("sgahaha"), + expectedResult: resource.Result{ + FlagSet: []uint32{flag_incorrect_date_format}, + }, + }, + { + name: "Test with numeric but less 4 digits", + input: []byte("123"), + expectedResult: resource.Result{ + FlagSet: []uint32{flag_incorrect_date_format}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Call the method under test + res, err := h.VerifyYob(ctx, "verify_yob", []byte(tt.input)) + + // Assert that no errors occurred + assert.NoError(t, err) + + //Assert that the account created flag has been set to the result + assert.Equal(t, res, tt.expectedResult, "Expected result should be equal to the actual result") + }) + } +} + +func TestVerifyCreatePin(t *testing.T) { + sessionId := "session123" + ctx, store := InitializeTestStore(t) + ctx = context.WithValue(ctx, "SessionId", sessionId) + + fm, err := NewFlagManager(flagsPath) + if err != nil { + t.Logf(err.Error()) + } + + // Create required mocks + mockAccountService := new(mocks.MockAccountService) + mockState := state.NewState(16) + + flag_valid_pin, _ := fm.parser.GetFlag("flag_valid_pin") + flag_pin_mismatch, _ := fm.parser.GetFlag("flag_pin_mismatch") + flag_pin_set, _ := fm.parser.GetFlag("flag_pin_set") + + h := &Handlers{ + userdataStore: store, + accountService: mockAccountService, + flagManager: fm.parser, + st: mockState, + } + + tests := []struct { + name string + input []byte + expectedResult resource.Result + }{ + { + name: "Test with correct PIN confirmation", + input: []byte("1234"), + expectedResult: resource.Result{ + FlagSet: []uint32{flag_valid_pin, flag_pin_set}, + FlagReset: []uint32{flag_pin_mismatch}, + }, + }, + { + name: "Test with PIN that does not match first ", + input: []byte("1324"), + expectedResult: resource.Result{ + FlagSet: []uint32{flag_pin_mismatch}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err = store.WriteEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE, []byte("1234")) + if err != nil { + t.Fatal(err) + } + + // Call the method under test + res, err := h.VerifyCreatePin(ctx, "verify_create_pin", []byte(tt.input)) + + // Assert that no errors occurred + assert.NoError(t, err) + + //Assert that the account created flag has been set to the result + assert.Equal(t, res, tt.expectedResult, "Expected result should be equal to the actual result") + }) + } +} + +func TestCheckAccountStatus(t *testing.T) { + sessionId := "session123" + ctx, store := InitializeTestStore(t) + ctx = context.WithValue(ctx, "SessionId", sessionId) + + fm, err := NewFlagManager(flagsPath) + if err != nil { + t.Logf(err.Error()) + } + flag_account_success, _ := fm.GetFlag("flag_account_success") + flag_account_pending, _ := fm.GetFlag("flag_account_pending") + flag_api_error, _ := fm.GetFlag("flag_api_call_error") + + tests := []struct { + name string + publicKey []byte + response *models.TrackStatusResult + expectedResult resource.Result + }{ + { + name: "Test when account is on the Sarafu network", + publicKey: []byte("TrackingId1234"), + response: &models.TrackStatusResult{ + Active: true, + }, + expectedResult: resource.Result{ + FlagSet: []uint32{flag_account_success}, + FlagReset: []uint32{flag_api_error, flag_account_pending}, + }, + }, + { + name: "Test when the account is not yet on the sarafu network", + publicKey: []byte("TrackingId1234"), + response: &models.TrackStatusResult{ + Active: false, + }, + expectedResult: resource.Result{ + FlagSet: []uint32{flag_account_pending}, + FlagReset: []uint32{flag_api_error, flag_account_success}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockAccountService := new(mocks.MockAccountService) + + h := &Handlers{ + userdataStore: store, + accountService: mockAccountService, + flagManager: fm.parser, + } + + err = store.WriteEntry(ctx, sessionId, common.DATA_PUBLIC_KEY, []byte(tt.publicKey)) + if err != nil { + t.Fatal(err) + } + + mockAccountService.On("TrackAccountStatus", string(tt.publicKey)).Return(tt.response, nil) + + // Call the method under test + res, _ := h.CheckAccountStatus(ctx, "check_account_status", []byte("")) + + // Assert that no errors occurred + assert.NoError(t, err) + + //Assert that the account created flag has been set to the result + assert.Equal(t, res, tt.expectedResult, "Expected result should be equal to the actual result") + }) + } +} + +func TestTransactionReset(t *testing.T) { + sessionId := "session123" + ctx, store := InitializeTestStore(t) + ctx = context.WithValue(ctx, "SessionId", sessionId) + + fm, err := NewFlagManager(flagsPath) + if err != nil { + t.Logf(err.Error()) + } + + flag_invalid_recipient, _ := fm.GetFlag("flag_invalid_recipient") + flag_invalid_recipient_with_invite, _ := fm.GetFlag("flag_invalid_recipient_with_invite") + + mockAccountService := new(mocks.MockAccountService) + + h := &Handlers{ + userdataStore: store, + accountService: mockAccountService, + flagManager: fm.parser, + } + tests := []struct { + name string + input []byte + status string + expectedResult resource.Result + }{ + { + name: "Test transaction reset for amount and recipient", + expectedResult: resource.Result{ + FlagReset: []uint32{flag_invalid_recipient, flag_invalid_recipient_with_invite}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Call the method under test + res, _ := h.TransactionReset(ctx, "transaction_reset", tt.input) + + // Assert that no errors occurred + assert.NoError(t, err) + + //Assert that the account created flag has been set to the result + assert.Equal(t, res, tt.expectedResult, "Expected result should be equal to the actual result") + }) + } +} + +func TestResetTransactionAmount(t *testing.T) { + sessionId := "session123" + ctx, store := InitializeTestStore(t) + ctx = context.WithValue(ctx, "SessionId", sessionId) + + fm, err := NewFlagManager(flagsPath) + if err != nil { + t.Logf(err.Error()) + } + + flag_invalid_amount, _ := fm.parser.GetFlag("flag_invalid_amount") + + mockAccountService := new(mocks.MockAccountService) + + h := &Handlers{ + userdataStore: store, + accountService: mockAccountService, + flagManager: fm.parser, + } + + tests := []struct { + name string + expectedResult resource.Result + }{ + { + name: "Test amount reset", + expectedResult: resource.Result{ + FlagReset: []uint32{flag_invalid_amount}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Call the method under test + res, _ := h.ResetTransactionAmount(ctx, "transaction_reset_amount", []byte("")) + + // Assert that no errors occurred + assert.NoError(t, err) + + //Assert that the account created flag has been set to the result + assert.Equal(t, res, tt.expectedResult, "Expected result should be equal to the actual result") + }) + } +} + +func TestInitiateTransaction(t *testing.T) { + sessionId := "254712345678" + ctx, store := InitializeTestStore(t) + ctx = context.WithValue(ctx, "SessionId", sessionId) + + fm, err := NewFlagManager(flagsPath) + if err != nil { + t.Logf(err.Error()) + } + account_authorized_flag, _ := fm.parser.GetFlag("flag_account_authorized") + + mockAccountService := new(mocks.MockAccountService) + + h := &Handlers{ + userdataStore: store, + accountService: mockAccountService, + flagManager: fm.parser, + } + + tests := []struct { + name string + TemporaryValue []byte + ActiveSym []byte + StoredAmount []byte + TransferAmount string + PublicKey []byte + Recipient []byte + ActiveDecimal []byte + ActiveAddress []byte + TransferResponse *models.TokenTransferResponse + expectedResult resource.Result + }{ + { + name: "Test initiate transaction", + TemporaryValue: []byte("0711223344"), + ActiveSym: []byte("SRF"), + StoredAmount: []byte("1.00"), + TransferAmount: "1000000", + PublicKey: []byte("0X13242618721"), + Recipient: []byte("0x12415ass27192"), + ActiveDecimal: []byte("6"), + ActiveAddress: []byte("0xd4c288865Ce"), + TransferResponse: &models.TokenTransferResponse{ + TrackingId: "1234567890", + }, + expectedResult: resource.Result{ + FlagReset: []uint32{account_authorized_flag}, + Content: "Your request has been sent. 0711223344 will receive 1.00 SRF from 254712345678.", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := store.WriteEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE, []byte(tt.TemporaryValue)) + if err != nil { + t.Fatal(err) + } + err = store.WriteEntry(ctx, sessionId, common.DATA_ACTIVE_SYM, []byte(tt.ActiveSym)) + if err != nil { + t.Fatal(err) + } + err = store.WriteEntry(ctx, sessionId, common.DATA_AMOUNT, []byte(tt.StoredAmount)) + if err != nil { + t.Fatal(err) + } + err = store.WriteEntry(ctx, sessionId, common.DATA_PUBLIC_KEY, []byte(tt.PublicKey)) + if err != nil { + t.Fatal(err) + } + err = store.WriteEntry(ctx, sessionId, common.DATA_RECIPIENT, []byte(tt.Recipient)) + if err != nil { + t.Fatal(err) + } + err = store.WriteEntry(ctx, sessionId, common.DATA_ACTIVE_DECIMAL, []byte(tt.ActiveDecimal)) + if err != nil { + t.Fatal(err) + } + err = store.WriteEntry(ctx, sessionId, common.DATA_ACTIVE_ADDRESS, []byte(tt.ActiveAddress)) + if err != nil { + t.Fatal(err) + } + + mockAccountService.On("TokenTransfer").Return(tt.TransferResponse, nil) + + // Call the method under test + res, _ := h.InitiateTransaction(ctx, "transaction_reset_amount", []byte("")) + + // Assert that no errors occurred + assert.NoError(t, err) + + //Assert that the account created flag has been set to the result + assert.Equal(t, res, tt.expectedResult, "Expected result should be equal to the actual result") + }) + } +} + +func TestQuit(t *testing.T) { + fm, err := NewFlagManager(flagsPath) + if err != nil { + t.Logf(err.Error()) + } + flag_account_authorized, _ := fm.parser.GetFlag("flag_account_authorized") + + mockAccountService := new(mocks.MockAccountService) + + sessionId := "session123" + + ctx := context.WithValue(context.Background(), "SessionId", sessionId) + + h := &Handlers{ + accountService: mockAccountService, + flagManager: fm.parser, + } + tests := []struct { + name string + input []byte + status string + expectedResult resource.Result + }{ + { + name: "Test quit message", + expectedResult: resource.Result{ + FlagReset: []uint32{flag_account_authorized}, + Content: "Thank you for using Sarafu. Goodbye!", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + // Call the method under test + res, _ := h.Quit(ctx, "test_quit", tt.input) + + // Assert that no errors occurred + assert.NoError(t, err) + + //Assert that the account created flag has been set to the result + assert.Equal(t, res, tt.expectedResult, "Expected result should be equal to the actual result") + }) + } +} + +func TestValidateAmount(t *testing.T) { + fm, err := NewFlagManager(flagsPath) + if err != nil { + t.Logf(err.Error()) + } + + sessionId := "session123" + + ctx, store := InitializeTestStore(t) + ctx = context.WithValue(ctx, "SessionId", sessionId) + + flag_invalid_amount, _ := fm.parser.GetFlag("flag_invalid_amount") + + mockAccountService := new(mocks.MockAccountService) + + h := &Handlers{ + userdataStore: store, + accountService: mockAccountService, + flagManager: fm.parser, + } + tests := []struct { + name string + input []byte + activeBal []byte + balance string + expectedResult resource.Result + }{ + { + name: "Test with valid amount", + input: []byte("4.10"), + activeBal: []byte("5"), + expectedResult: resource.Result{ + Content: "4.10", + }, + }, + { + name: "Test with amount larger than active balance", + input: []byte("5.02"), + activeBal: []byte("5"), + expectedResult: resource.Result{ + FlagSet: []uint32{flag_invalid_amount}, + Content: "5.02", + }, + }, + { + name: "Test with invalid amount format", + input: []byte("0.02ms"), + activeBal: []byte("5"), + expectedResult: resource.Result{ + FlagSet: []uint32{flag_invalid_amount}, + Content: "0.02ms", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := store.WriteEntry(ctx, sessionId, common.DATA_ACTIVE_BAL, []byte(tt.activeBal)) + if err != nil { + t.Fatal(err) + } + + // Call the method under test + res, _ := h.ValidateAmount(ctx, "test_validate_amount", tt.input) + + // Assert no errors occurred + assert.NoError(t, err) + + // Assert the result matches the expected result + assert.Equal(t, tt.expectedResult, res, "Expected result should match actual result") + }) + } +} + +func TestValidateRecipient(t *testing.T) { + fm, err := NewFlagManager(flagsPath) + if err != nil { + log.Fatal(err) + } + + sessionId := "session123" + publicKey := "0X13242618721" + ctx, store := InitializeTestStore(t) + ctx = context.WithValue(ctx, "SessionId", sessionId) + + flag_invalid_recipient, _ := fm.parser.GetFlag("flag_invalid_recipient") + flag_invalid_recipient_with_invite, _ := fm.parser.GetFlag("flag_invalid_recipient_with_invite") + + // Define test cases + tests := []struct { + name string + input []byte + expectedResult resource.Result + }{ + { + name: "Test with invalid recepient", + input: []byte("7?1234"), + expectedResult: resource.Result{ + FlagSet: []uint32{flag_invalid_recipient}, + Content: "7?1234", + }, + }, + { + name: "Test with valid unregistered recepient", + input: []byte("0712345678"), + expectedResult: resource.Result{ + FlagSet: []uint32{flag_invalid_recipient_with_invite}, + Content: "0712345678", + }, + }, + { + name: "Test with valid registered recepient", + input: []byte("0711223344"), + expectedResult: resource.Result{}, + }, + { + name: "Test with address", + input: []byte("0xd4c288865Ce0985a481Eef3be02443dF5E2e4Ea9"), + expectedResult: resource.Result{}, + }, + { + name: "Test with alias recepient", + input: []byte("alias123"), + expectedResult: resource.Result{}, + }, + } + + // store a public key for the valid recipient + err = store.WriteEntry(ctx, "+254711223344", common.DATA_PUBLIC_KEY, []byte(publicKey)) + if err != nil { + t.Fatal(err) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockAccountService := new(mocks.MockAccountService) + // Create the Handlers instance + h := &Handlers{ + flagManager: fm.parser, + userdataStore: store, + accountService: mockAccountService, + } + + aliasResponse := &dataserviceapi.AliasAddress{ + Address: "0xd4c288865Ce0985a481Eef3be02443dF5E2e4Ea9", + } + + mockAccountService.On("CheckAliasAddress", string(tt.input)).Return(aliasResponse, nil) + + // Call the method + res, err := h.ValidateRecipient(ctx, "validate_recepient", tt.input) + + if err != nil { + t.Error(err) + } + + // Assert that the Result FlagSet has the required flags after language switch + assert.Equal(t, res, tt.expectedResult, "Result should contain flag(s) that have been reset") + }) + } +} + +func TestCheckBalance(t *testing.T) { + ctx, store := InitializeTestStore(t) + + tests := []struct { + name string + sessionId string + publicKey string + activeSym string + activeBal string + expectedResult resource.Result + expectError bool + }{ + { + name: "User with active sym", + sessionId: "session123", + publicKey: "0X98765432109", + activeSym: "ETH", + activeBal: "1.5", + expectedResult: resource.Result{Content: "Balance: 1.50 ETH\n"}, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockAccountService := new(mocks.MockAccountService) + ctx := context.WithValue(ctx, "SessionId", tt.sessionId) + + h := &Handlers{ + userdataStore: store, + accountService: mockAccountService, + } + + err := store.WriteEntry(ctx, tt.sessionId, common.DATA_ACTIVE_SYM, []byte(tt.activeSym)) + if err != nil { + t.Fatal(err) + } + err = store.WriteEntry(ctx, tt.sessionId, common.DATA_ACTIVE_BAL, []byte(tt.activeBal)) + if err != nil { + t.Fatal(err) + } + + res, err := h.CheckBalance(ctx, "check_balance", []byte("")) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedResult, res, "Result should match expected output") + } + + mockAccountService.AssertExpectations(t) + }) + } +} + +func TestGetProfile(t *testing.T) { + sessionId := "session123" + ctx, store := InitializeTestStore(t) + + mockAccountService := new(mocks.MockAccountService) + mockState := state.NewState(16) + + h := &Handlers{ + userdataStore: store, + accountService: mockAccountService, + st: mockState, + } + + tests := []struct { + name string + languageCode string + keys []common.DataTyp + profileInfo []string + result resource.Result + }{ + { + name: "Test with full profile information in eng", + keys: []common.DataTyp{common.DATA_FAMILY_NAME, common.DATA_FIRST_NAME, common.DATA_GENDER, common.DATA_OFFERINGS, common.DATA_LOCATION, common.DATA_YOB}, + profileInfo: []string{"Doee", "John", "Male", "Bananas", "Kilifi", "1976"}, + languageCode: "eng", + result: resource.Result{ + Content: fmt.Sprintf( + "Name: %s\nGender: %s\nAge: %s\nLocation: %s\nYou provide: %s\n", + "John Doee", "Male", "49", "Kilifi", "Bananas", + ), + }, + }, + { + name: "Test with with profile information in swa", + keys: []common.DataTyp{common.DATA_FAMILY_NAME, common.DATA_FIRST_NAME, common.DATA_GENDER, common.DATA_OFFERINGS, common.DATA_LOCATION, common.DATA_YOB}, + profileInfo: []string{"Doee", "John", "Male", "Bananas", "Kilifi", "1976"}, + languageCode: "swa", + result: resource.Result{ + Content: fmt.Sprintf( + "Jina: %s\nJinsia: %s\nUmri: %s\nEneo: %s\nUnauza: %s\n", + "John Doee", "Male", "49", "Kilifi", "Bananas", + ), + }, + }, + { + name: "Test with with profile information with language that is not yet supported", + keys: []common.DataTyp{common.DATA_FAMILY_NAME, common.DATA_FIRST_NAME, common.DATA_GENDER, common.DATA_OFFERINGS, common.DATA_LOCATION, common.DATA_YOB}, + profileInfo: []string{"Doee", "John", "Male", "Bananas", "Kilifi", "1976"}, + languageCode: "nor", + result: resource.Result{ + Content: fmt.Sprintf( + "Name: %s\nGender: %s\nAge: %s\nLocation: %s\nYou provide: %s\n", + "John Doee", "Male", "49", "Kilifi", "Bananas", + ), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx = context.WithValue(ctx, "SessionId", sessionId) + ctx = context.WithValue(ctx, "Language", lang.Language{ + Code: tt.languageCode, + }) + for index, key := range tt.keys { + err := store.WriteEntry(ctx, sessionId, key, []byte(tt.profileInfo[index])) + if err != nil { + t.Fatal(err) + } + } + + res, _ := h.GetProfileInfo(ctx, "get_profile_info", []byte("")) + + //Assert that the result set to content is what was expected + assert.Equal(t, res, tt.result, "Result should contain profile information served back to user") + }) + } +} + +func TestVerifyNewPin(t *testing.T) { + sessionId := "session123" + + fm, _ := NewFlagManager(flagsPath) + + flag_valid_pin, _ := fm.parser.GetFlag("flag_valid_pin") + mockAccountService := new(mocks.MockAccountService) + h := &Handlers{ + flagManager: fm.parser, + accountService: mockAccountService, + } + ctx := context.WithValue(context.Background(), "SessionId", sessionId) + + tests := []struct { + name string + input []byte + expectedResult resource.Result + }{ + { + name: "Test with valid pin", + input: []byte("1234"), + expectedResult: resource.Result{ + FlagSet: []uint32{flag_valid_pin}, + }, + }, + { + name: "Test with invalid pin", + input: []byte("123"), + expectedResult: resource.Result{ + FlagReset: []uint32{flag_valid_pin}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + //Call the function under test + res, _ := h.VerifyNewPin(ctx, "verify_new_pin", tt.input) + + //Assert that the result set to content is what was expected + assert.Equal(t, res, tt.expectedResult, "Result should contain flags set according to user input") + }) + } +} + +func TestConfirmPin(t *testing.T) { + sessionId := "session123" + + ctx, store := InitializeTestStore(t) + ctx = context.WithValue(ctx, "SessionId", sessionId) + + fm, _ := NewFlagManager(flagsPath) + flag_pin_mismatch, _ := fm.parser.GetFlag("flag_pin_mismatch") + mockAccountService := new(mocks.MockAccountService) + h := &Handlers{ + userdataStore: store, + flagManager: fm.parser, + accountService: mockAccountService, + } + + tests := []struct { + name string + input []byte + temporarypin []byte + expectedResult resource.Result + }{ + { + name: "Test with correct pin confirmation", + input: []byte("1234"), + temporarypin: []byte("1234"), + expectedResult: resource.Result{ + FlagReset: []uint32{flag_pin_mismatch}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set up the expected behavior of the mock + err := store.WriteEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE, []byte(tt.temporarypin)) + if err != nil { + t.Fatal(err) + } + + //Call the function under test + res, _ := h.ConfirmPinChange(ctx, "confirm_pin_change", tt.temporarypin) + + //Assert that the result set to content is what was expected + assert.Equal(t, res, tt.expectedResult, "Result should contain flags set according to user input") + + }) + } +} + +func TestFetchCommunityBalance(t *testing.T) { + + // Define test data + sessionId := "session123" + ctx, store := InitializeTestStore(t) + + tests := []struct { + name string + languageCode string + expectedResult resource.Result + }{ + { + name: "Test community balance content when language is english", + expectedResult: resource.Result{ + Content: "Community Balance: 0.00", + }, + languageCode: "eng", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + mockAccountService := new(mocks.MockAccountService) + mockState := state.NewState(16) + + h := &Handlers{ + userdataStore: store, + st: mockState, + accountService: mockAccountService, + } + ctx = context.WithValue(ctx, "SessionId", sessionId) + ctx = context.WithValue(ctx, "Language", lang.Language{ + Code: tt.languageCode, + }) + + // Call the method + res, _ := h.FetchCommunityBalance(ctx, "fetch_community_balance", []byte("")) + + //Assert that the result set to content is what was expected + assert.Equal(t, res, tt.expectedResult, "Result should match expected result") + }) + } +} + +func TestSetDefaultVoucher(t *testing.T) { + sessionId := "session123" + ctx, store := InitializeTestStore(t) + ctx = context.WithValue(ctx, "SessionId", sessionId) + + fm, err := NewFlagManager(flagsPath) + if err != nil { + t.Logf(err.Error()) + } + flag_no_active_voucher, err := fm.GetFlag("flag_no_active_voucher") + if err != nil { + t.Logf(err.Error()) + } + + publicKey := "0X13242618721" + + tests := []struct { + name string + vouchersResp []dataserviceapi.TokenHoldings + expectedResult resource.Result + }{ + { + name: "Test no vouchers available", + vouchersResp: []dataserviceapi.TokenHoldings{}, + expectedResult: resource.Result{ + FlagSet: []uint32{flag_no_active_voucher}, + }, + }, + { + name: "Test set default voucher when no active voucher is set", + vouchersResp: []dataserviceapi.TokenHoldings{ + dataserviceapi.TokenHoldings{ + ContractAddress: "0x123", + TokenSymbol: "TOKEN1", + TokenDecimals: "18", + Balance: "100", + }, + }, + expectedResult: resource.Result{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockAccountService := new(mocks.MockAccountService) + + h := &Handlers{ + userdataStore: store, + accountService: mockAccountService, + flagManager: fm.parser, + } + + err := store.WriteEntry(ctx, sessionId, common.DATA_PUBLIC_KEY, []byte(publicKey)) + if err != nil { + t.Fatal(err) + } + + mockAccountService.On("FetchVouchers", string(publicKey)).Return(tt.vouchersResp, nil) + + res, err := h.SetDefaultVoucher(ctx, "set_default_voucher", []byte("some-input")) + + assert.NoError(t, err) + + assert.Equal(t, res, tt.expectedResult, "Expected result should be equal to the actual result") + + mockAccountService.AssertExpectations(t) + }) + } +} + +func TestCheckVouchers(t *testing.T) { + mockAccountService := new(mocks.MockAccountService) + sessionId := "session123" + publicKey := "0X13242618721" + + ctx, store := InitializeTestStore(t) + ctx = context.WithValue(ctx, "SessionId", sessionId) + spdb := InitializeTestSubPrefixDb(t, ctx) + + h := &Handlers{ + userdataStore: store, + accountService: mockAccountService, + prefixDb: spdb, + } + + err := store.WriteEntry(ctx, sessionId, common.DATA_PUBLIC_KEY, []byte(publicKey)) + if err != nil { + t.Fatal(err) + } + + mockVouchersResponse := []dataserviceapi.TokenHoldings{ + {ContractAddress: "0xd4c288865Ce", TokenSymbol: "SRF", TokenDecimals: "6", Balance: "100"}, + {ContractAddress: "0x41c188d63Qa", TokenSymbol: "MILO", TokenDecimals: "4", Balance: "200"}, + } + + expectedSym := []byte("1:SRF\n2:MILO") + + mockAccountService.On("FetchVouchers", string(publicKey)).Return(mockVouchersResponse, nil) + + _, err = h.CheckVouchers(ctx, "check_vouchers", []byte("")) + assert.NoError(t, err) + + // Read voucher sym data from the store + voucherData, err := spdb.Get(ctx, common.ToBytes(common.DATA_VOUCHER_SYMBOLS)) + if err != nil { + t.Fatal(err) + } + + // assert that the data is stored correctly + assert.Equal(t, expectedSym, voucherData) + + mockAccountService.AssertExpectations(t) +} + +func TestGetVoucherList(t *testing.T) { + sessionId := "session123" + + ctx := context.WithValue(context.Background(), "SessionId", sessionId) + + spdb := InitializeTestSubPrefixDb(t, ctx) + + // Initialize Handlers + h := &Handlers{ + prefixDb: spdb, + ReplaceSeparatorFunc: mockReplaceSeparator, + } + + mockSyms := []byte("1:SRF\n2:MILO") + + // Put voucher sym data from the store + err := spdb.Put(ctx, common.ToBytes(common.DATA_VOUCHER_SYMBOLS), mockSyms) + if err != nil { + t.Fatal(err) + } + + expectedSyms := []byte("1: SRF\n2: MILO") + + res, err := h.GetVoucherList(ctx, "", []byte("")) + + assert.NoError(t, err) + assert.Equal(t, res.Content, string(expectedSyms)) +} + +func TestViewVoucher(t *testing.T) { + fm, err := NewFlagManager(flagsPath) + if err != nil { + t.Logf(err.Error()) + } + ctx, store := InitializeTestStore(t) + sessionId := "session123" + + ctx = context.WithValue(ctx, "SessionId", sessionId) + + spdb := InitializeTestSubPrefixDb(t, ctx) + + h := &Handlers{ + userdataStore: store, + flagManager: fm.parser, + prefixDb: spdb, + } + + // Define mock voucher data + mockData := map[common.DataTyp][]byte{ + common.DATA_VOUCHER_SYMBOLS: []byte("1:SRF\n2:MILO"), + common.DATA_VOUCHER_BALANCES: []byte("1:100\n2:200"), + common.DATA_VOUCHER_DECIMALS: []byte("1:6\n2:4"), + common.DATA_VOUCHER_ADDRESSES: []byte("1:0xd4c288865Ce\n2:0x41c188d63Qa"), + } + + // Put the data + for key, value := range mockData { + err = spdb.Put(ctx, []byte(common.ToBytes(key)), []byte(value)) + if err != nil { + t.Fatal(err) + } + } + + res, err := h.ViewVoucher(ctx, "view_voucher", []byte("1")) + assert.NoError(t, err) + assert.Equal(t, res.Content, "Symbol: SRF\nBalance: 100") +} + +func TestSetVoucher(t *testing.T) { + ctx, store := InitializeTestStore(t) + sessionId := "session123" + + ctx = context.WithValue(ctx, "SessionId", sessionId) + + h := &Handlers{ + userdataStore: store, + } + + // Define the temporary voucher data + tempData := &dataserviceapi.TokenHoldings{ + TokenSymbol: "SRF", + Balance: "200", + TokenDecimals: "6", + ContractAddress: "0xd4c288865Ce0985a481Eef3be02443dF5E2e4Ea9", + } + + expectedData := fmt.Sprintf("%s,%s,%s,%s", tempData.TokenSymbol, tempData.Balance, tempData.TokenDecimals, tempData.ContractAddress) + + // store the expectedData + if err := store.WriteEntry(ctx, sessionId, common.DATA_TEMPORARY_VALUE, []byte(expectedData)); err != nil { + t.Fatal(err) + } + + res, err := h.SetVoucher(ctx, "set_voucher", []byte("")) + + assert.NoError(t, err) + + assert.Equal(t, string(tempData.TokenSymbol), res.Content) +} + +func TestGetVoucherDetails(t *testing.T) { + ctx, store := InitializeTestStore(t) + fm, err := NewFlagManager(flagsPath) + if err != nil { + t.Logf(err.Error()) + } + mockAccountService := new(mocks.MockAccountService) + + sessionId := "session123" + ctx = context.WithValue(ctx, "SessionId", sessionId) + expectedResult := resource.Result{} + + tokA_AAddress := "0x0000000000000000000000000000000000000000" + + h := &Handlers{ + userdataStore: store, + flagManager: fm.parser, + accountService: mockAccountService, + } + err = store.WriteEntry(ctx, sessionId, common.DATA_ACTIVE_ADDRESS, []byte(tokA_AAddress)) + if err != nil { + t.Fatal(err) + } + tokenDetails := &models.VoucherDataResult{ + TokenName: "Token A", + TokenSymbol: "TOKA", + TokenLocation: "Kilifi,Kenya", + TokenCommodity: "Farming", + } + expectedResult.Content = fmt.Sprintf( + "Name: %s\nSymbol: %s\nCommodity: %s\nLocation: %s", tokenDetails.TokenName, tokenDetails.TokenSymbol, tokenDetails.TokenCommodity, tokenDetails.TokenLocation, + ) + mockAccountService.On("VoucherData", string(tokA_AAddress)).Return(tokenDetails, nil) + + res, err := h.GetVoucherDetails(ctx, "SessionId", []byte("")) + assert.NoError(t, err) + assert.Equal(t, expectedResult, res) +} + +func TestCountIncorrectPINAttempts(t *testing.T) { + ctx, store := InitializeTestStore(t) + sessionId := "session123" + ctx = context.WithValue(ctx, "SessionId", sessionId) + attempts := uint8(2) + + h := &Handlers{ + userdataStore: store, + } + err := store.WriteEntry(ctx, sessionId, common.DATA_INCORRECT_PIN_ATTEMPTS, []byte(strconv.Itoa(int(attempts)))) + if err != nil { + t.Logf(err.Error()) + } + err = h.incrementIncorrectPINAttempts(ctx, sessionId) + if err != nil { + t.Logf(err.Error()) + } + + attemptsAfterCount, err := store.ReadEntry(ctx, sessionId, common.DATA_INCORRECT_PIN_ATTEMPTS) + if err != nil { + t.Logf(err.Error()) + } + pinAttemptsValue, _ := strconv.ParseUint(string(attemptsAfterCount), 0, 64) + pinAttemptsCount := uint8(pinAttemptsValue) + expectedAttempts := attempts + 1 + assert.Equal(t, pinAttemptsCount, expectedAttempts) + +} + +func TestResetIncorrectPINAttempts(t *testing.T) { + ctx, store := InitializeTestStore(t) + sessionId := "session123" + ctx = context.WithValue(ctx, "SessionId", sessionId) + + err := store.WriteEntry(ctx, sessionId, common.DATA_INCORRECT_PIN_ATTEMPTS, []byte(string("2"))) + if err != nil { + t.Logf(err.Error()) + } + + h := &Handlers{ + userdataStore: store, + } + h.resetIncorrectPINAttempts(ctx, sessionId) + incorrectAttempts, err := store.ReadEntry(ctx, sessionId, common.DATA_INCORRECT_PIN_ATTEMPTS) + + if err != nil { + t.Logf(err.Error()) + } + assert.Equal(t, "0", string(incorrectAttempts)) + +} + +func TestPersistLanguageCode(t *testing.T) { + ctx, store := InitializeTestStore(t) + + sessionId := "session123" + ctx = context.WithValue(ctx, "SessionId", sessionId) + + h := &Handlers{ + userdataStore: store, + } + tests := []struct { + name string + code string + expectedLanguageCode string + }{ + { + name: "Set Default Language (English)", + code: "eng", + expectedLanguageCode: "eng", + }, + { + name: "Set Swahili Language", + code: "swa", + expectedLanguageCode: "swa", + }, + } + + for _, test := range tests { + err := h.persistLanguageCode(ctx, test.code) + if err != nil { + t.Logf(err.Error()) + } + code, err := store.ReadEntry(ctx, sessionId, common.DATA_SELECTED_LANGUAGE_CODE) + + assert.Equal(t, test.expectedLanguageCode, string(code)) + } + +} diff --git a/handlers/local.go b/handlers/local.go new file mode 100644 index 0000000..211e31e --- /dev/null +++ b/handlers/local.go @@ -0,0 +1,141 @@ +package handlers + +import ( + "context" + "strings" + + "git.defalsify.org/vise.git/asm" + "git.defalsify.org/vise.git/db" + "git.defalsify.org/vise.git/engine" + "git.defalsify.org/vise.git/persist" + "git.defalsify.org/vise.git/resource" + + "git.grassecon.net/grassrootseconomics/visedriver/utils" + "git.grassecon.net/grassrootseconomics/visedriver/remote" + "git.grassecon.net/grassrootseconomics/sarafu-vise/handlers/application" +) + +type HandlerService interface { + GetHandler() (*application.Handlers, error) +} + +func getParser(fp string, debug bool) (*asm.FlagParser, error) { + flagParser := asm.NewFlagParser().WithDebug() + _, err := flagParser.Load(fp) + if err != nil { + return nil, err + } + return flagParser, nil +} + +type LocalHandlerService struct { + Parser *asm.FlagParser + DbRs *resource.DbResource + Pe *persist.Persister + UserdataStore *db.Db + AdminStore *utils.AdminStore + Cfg engine.Config + Rs resource.Resource +} + +func NewLocalHandlerService(ctx context.Context, fp string, debug bool, dbResource *resource.DbResource, cfg engine.Config, rs resource.Resource) (*LocalHandlerService, error) { + parser, err := getParser(fp, debug) + if err != nil { + return nil, err + } + adminstore, err := utils.NewAdminStore(ctx, "admin_numbers") + if err != nil { + return nil, err + } + return &LocalHandlerService{ + Parser: parser, + DbRs: dbResource, + AdminStore: adminstore, + Cfg: cfg, + Rs: rs, + }, nil +} + +func (ls *LocalHandlerService) SetPersister(Pe *persist.Persister) { + ls.Pe = Pe +} + +func (ls *LocalHandlerService) SetDataStore(db *db.Db) { + ls.UserdataStore = db +} + +func (ls *LocalHandlerService) GetHandler(accountService remote.AccountServiceInterface) (*application.Handlers, error) { + replaceSeparatorFunc := func(input string) string { + return strings.ReplaceAll(input, ":", ls.Cfg.MenuSeparator) + } + + appHandlers, err := application.NewHandlers(ls.Parser, *ls.UserdataStore, ls.AdminStore, accountService, replaceSeparatorFunc) + if err != nil { + return nil, err + } + appHandlers = appHandlers.WithPersister(ls.Pe) + ls.DbRs.AddLocalFunc("set_language", appHandlers.SetLanguage) + ls.DbRs.AddLocalFunc("create_account", appHandlers.CreateAccount) + ls.DbRs.AddLocalFunc("save_temporary_pin", appHandlers.SaveTemporaryPin) + ls.DbRs.AddLocalFunc("verify_create_pin", appHandlers.VerifyCreatePin) + ls.DbRs.AddLocalFunc("check_identifier", appHandlers.CheckIdentifier) + ls.DbRs.AddLocalFunc("check_account_status", appHandlers.CheckAccountStatus) + ls.DbRs.AddLocalFunc("authorize_account", appHandlers.Authorize) + ls.DbRs.AddLocalFunc("quit", appHandlers.Quit) + ls.DbRs.AddLocalFunc("check_balance", appHandlers.CheckBalance) + ls.DbRs.AddLocalFunc("validate_recipient", appHandlers.ValidateRecipient) + ls.DbRs.AddLocalFunc("transaction_reset", appHandlers.TransactionReset) + ls.DbRs.AddLocalFunc("invite_valid_recipient", appHandlers.InviteValidRecipient) + ls.DbRs.AddLocalFunc("max_amount", appHandlers.MaxAmount) + ls.DbRs.AddLocalFunc("validate_amount", appHandlers.ValidateAmount) + ls.DbRs.AddLocalFunc("reset_transaction_amount", appHandlers.ResetTransactionAmount) + ls.DbRs.AddLocalFunc("get_recipient", appHandlers.GetRecipient) + ls.DbRs.AddLocalFunc("get_sender", appHandlers.GetSender) + ls.DbRs.AddLocalFunc("get_amount", appHandlers.GetAmount) + ls.DbRs.AddLocalFunc("reset_incorrect", appHandlers.ResetIncorrectPin) + ls.DbRs.AddLocalFunc("save_firstname", appHandlers.SaveFirstname) + ls.DbRs.AddLocalFunc("save_familyname", appHandlers.SaveFamilyname) + ls.DbRs.AddLocalFunc("save_gender", appHandlers.SaveGender) + ls.DbRs.AddLocalFunc("save_location", appHandlers.SaveLocation) + ls.DbRs.AddLocalFunc("save_yob", appHandlers.SaveYob) + ls.DbRs.AddLocalFunc("save_offerings", appHandlers.SaveOfferings) + ls.DbRs.AddLocalFunc("reset_account_authorized", appHandlers.ResetAccountAuthorized) + ls.DbRs.AddLocalFunc("reset_allow_update", appHandlers.ResetAllowUpdate) + ls.DbRs.AddLocalFunc("get_profile_info", appHandlers.GetProfileInfo) + ls.DbRs.AddLocalFunc("verify_yob", appHandlers.VerifyYob) + ls.DbRs.AddLocalFunc("reset_incorrect_date_format", appHandlers.ResetIncorrectYob) + ls.DbRs.AddLocalFunc("initiate_transaction", appHandlers.InitiateTransaction) + ls.DbRs.AddLocalFunc("verify_new_pin", appHandlers.VerifyNewPin) + ls.DbRs.AddLocalFunc("confirm_pin_change", appHandlers.ConfirmPinChange) + ls.DbRs.AddLocalFunc("quit_with_help", appHandlers.QuitWithHelp) + ls.DbRs.AddLocalFunc("fetch_community_balance", appHandlers.FetchCommunityBalance) + ls.DbRs.AddLocalFunc("set_default_voucher", appHandlers.SetDefaultVoucher) + ls.DbRs.AddLocalFunc("check_vouchers", appHandlers.CheckVouchers) + ls.DbRs.AddLocalFunc("get_vouchers", appHandlers.GetVoucherList) + ls.DbRs.AddLocalFunc("view_voucher", appHandlers.ViewVoucher) + ls.DbRs.AddLocalFunc("set_voucher", appHandlers.SetVoucher) + ls.DbRs.AddLocalFunc("get_voucher_details", appHandlers.GetVoucherDetails) + ls.DbRs.AddLocalFunc("reset_valid_pin", appHandlers.ResetValidPin) + ls.DbRs.AddLocalFunc("check_pin_mismatch", appHandlers.CheckBlockedNumPinMisMatch) + ls.DbRs.AddLocalFunc("validate_blocked_number", appHandlers.ValidateBlockedNumber) + ls.DbRs.AddLocalFunc("retrieve_blocked_number", appHandlers.RetrieveBlockedNumber) + ls.DbRs.AddLocalFunc("reset_unregistered_number", appHandlers.ResetUnregisteredNumber) + ls.DbRs.AddLocalFunc("reset_others_pin", appHandlers.ResetOthersPin) + ls.DbRs.AddLocalFunc("save_others_temporary_pin", appHandlers.SaveOthersTemporaryPin) + ls.DbRs.AddLocalFunc("get_current_profile_info", appHandlers.GetCurrentProfileInfo) + ls.DbRs.AddLocalFunc("check_transactions", appHandlers.CheckTransactions) + ls.DbRs.AddLocalFunc("get_transactions", appHandlers.GetTransactionsList) + ls.DbRs.AddLocalFunc("view_statement", appHandlers.ViewTransactionStatement) + ls.DbRs.AddLocalFunc("update_all_profile_items", appHandlers.UpdateAllProfileItems) + ls.DbRs.AddLocalFunc("set_back", appHandlers.SetBack) + ls.DbRs.AddLocalFunc("show_blocked_account", appHandlers.ShowBlockedAccount) + + return appHandlers, nil +} + +// TODO: enable setting of sessionId on engine init time +func (ls *LocalHandlerService) GetEngine() *engine.DefaultEngine { + en := engine.NewEngine(ls.Cfg, ls.Rs) + en = en.WithPersister(ls.Pe) + return en +} diff --git a/menutraversal_test/group_test.json b/menutraversal_test/group_test.json new file mode 100644 index 0000000..0ffb49f --- /dev/null +++ b/menutraversal_test/group_test.json @@ -0,0 +1,443 @@ +{ + "groups": [ + { + "name": "my_account_change_pin", + "steps": [ + { + "input": "", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + }, + { + "input": "3", + "expectedContent": "My Account\n1:Profile\n2:Change language\n3:Check balances\n4:Check statement\n5:PIN options\n6:My Address\n0:Back" + }, + { + "input": "5", + "expectedContent": "PIN Management\n1:Change PIN\n2:Reset other's PIN\n0:Back" + }, + { + "input": "1", + "expectedContent": "Enter your old PIN\n\n0:Back" + }, + { + "input": "1234", + "expectedContent": "Enter a new four number PIN:\n\n0:Back" + }, + { + "input": "1234", + "expectedContent": "Confirm your new PIN:\n\n0:Back" + }, + { + "input": "1234", + "expectedContent": "Your PIN change request has been successful\n\n0:Back\n9:Quit" + }, + { + "input": "0", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + } + ] + }, + { + "name": "menu_my_account_language_change", + "steps": [ + { + "input": "", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + }, + { + "input": "3", + "expectedContent": "My Account\n1:Profile\n2:Change language\n3:Check balances\n4:Check statement\n5:PIN options\n6:My Address\n0:Back" + }, + { + "input": "2", + "expectedContent": "Please enter your PIN:" + }, + { + "input": "1235", + "expectedContent": "Incorrect PIN. You have: 2 remaining attempt(s).\n1:Retry\n9:Quit" + }, + { + "input": "1", + "expectedContent": "Please enter your PIN:" + }, + { + "input": "1234", + "expectedContent": "Select language:\n1:English\n2:Kiswahili" + }, + { + "input": "1", + "expectedContent": "Your language change request was successful.\n0:Back\n9:Quit" + }, + { + "input": "0", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + } + ] + }, + { + "name": "menu_my_account_check_my_balance", + "steps": [ + { + "input": "", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + }, + { + "input": "3", + "expectedContent": "My Account\n1:Profile\n2:Change language\n3:Check balances\n4:Check statement\n5:PIN options\n6:My Address\n0:Back" + }, + { + "input": "3", + "expectedContent": "Balances:\n1:My balance\n2:Community balance\n0:Back" + }, + { + "input": "1", + "expectedContent": "Please enter your PIN:" + }, + { + "input": "1235", + "expectedContent": "Incorrect PIN. You have: 2 remaining attempt(s).\n1:Retry\n9:Quit" + }, + { + "input": "1", + "expectedContent": "Please enter your PIN:" + }, + { + "input": "1234", + "expectedContent": "Balance: {balance}\n\n0:Back\n9:Quit" + }, + { + "input": "0", + "expectedContent": "Balances:\n1:My balance\n2:Community balance\n0:Back" + }, + { + "input": "0", + "expectedContent": "My Account\n1:Profile\n2:Change language\n3:Check balances\n4:Check statement\n5:PIN options\n6:My Address\n0:Back" + }, + { + "input": "0", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + } + ] + }, + { + "name": "menu_my_account_check_community_balance", + "steps": [ + { + "input": "", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + }, + { + "input": "3", + "expectedContent": "My Account\n1:Profile\n2:Change language\n3:Check balances\n4:Check statement\n5:PIN options\n6:My Address\n0:Back" + }, + { + "input": "3", + "expectedContent": "Balances:\n1:My balance\n2:Community balance\n0:Back" + }, + { + "input": "2", + "expectedContent": "Please enter your PIN:" + }, + { + "input": "1235", + "expectedContent": "Incorrect PIN. You have: 2 remaining attempt(s).\n1:Retry\n9:Quit" + }, + { + "input": "1", + "expectedContent": "Please enter your PIN:" + }, + { + "input": "1234", + "expectedContent": "{balance}\n0:Back\n9:Quit" + }, + { + "input": "0", + "expectedContent": "Balances:\n1:My balance\n2:Community balance\n0:Back" + }, + { + "input": "0", + "expectedContent": "My Account\n1:Profile\n2:Change language\n3:Check balances\n4:Check statement\n5:PIN options\n6:My Address\n0:Back" + }, + { + "input": "0", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + } + ] + }, + { + "name": "menu_my_account_edit_all_account_details_starting_from_firstname", + "steps": [ + { + "input": "", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + }, + { + "input": "3", + "expectedContent": "My Account\n1:Profile\n2:Change language\n3:Check balances\n4:Check statement\n5:PIN options\n6:My Address\n0:Back" + }, + { + "input": "1", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "1", + "expectedContent": "Enter your first names:\n0:Back" + }, + { + "input": "foo", + "expectedContent": "Enter family name:\n0:Back" + }, + { + "input": "bar", + "expectedContent": "Select gender: \n1:Male\n2:Female\n3:Unspecified\n0:Back" + }, + { + "input": "1", + "expectedContent": "Enter your year of birth\n0:Back" + }, + { + "input": "1940", + "expectedContent": "Enter your location:\n0:Back" + }, + { + "input": "Kilifi", + "expectedContent": "Enter the services or goods you offer: \n0:Back" + }, + { + "input": "Bananas", + "expectedContent": "Please enter your PIN:" + }, + { + "input": "1234", + "expectedContent": "Profile updated successfully\n\n0:Back\n9:Quit" + }, + { + "input": "0", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "0", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + } + ] + }, + { + "name": "menu_my_account_edit_familyname_when_all_account__details_have_been_set", + "steps": [ + { + "input": "", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + }, + { + "input": "3", + "expectedContent": "My Account\n1:Profile\n2:Change language\n3:Check balances\n4:Check statement\n5:PIN options\n6:My Address\n0:Back" + }, + { + "input": "1", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "2", + "expectedContent": "Enter family name:\n0:Back" + }, + { + "input": "bar", + "expectedContent": "Please enter your PIN:" + }, + { + "input": "1234", + "expectedContent": "Profile updated successfully\n\n0:Back\n9:Quit" + }, + { + "input": "0", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "0", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + } + ] + }, + { + "name": "menu_my_account_edit_gender_when_all_account__details_have_been_set", + "steps": [ + { + "input": "", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + }, + { + "input": "3", + "expectedContent": "My Account\n1:Profile\n2:Change language\n3:Check balances\n4:Check statement\n5:PIN options\n6:My Address\n0:Back" + }, + { + "input": "1", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "3", + "expectedContent": "Select gender: \n1:Male\n2:Female\n3:Unspecified\n0:Back" + }, + { + "input": "1", + "expectedContent": "Please enter your PIN:" + }, + { + "input": "1234", + "expectedContent": "Profile updated successfully\n\n0:Back\n9:Quit" + }, + { + "input": "0", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "0", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + } + ] + }, + { + "name": "menu_my_account_edit_yob_when_all_account__details_have_been_set", + "steps": [ + { + "input": "", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + }, + { + "input": "3", + "expectedContent": "My Account\n1:Profile\n2:Change language\n3:Check balances\n4:Check statement\n5:PIN options\n6:My Address\n0:Back" + }, + { + "input": "1", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "4", + "expectedContent": "Enter your year of birth\n0:Back" + }, + { + "input": "1945", + "expectedContent": "Please enter your PIN:" + }, + { + "input": "1234", + "expectedContent": "Profile updated successfully\n\n0:Back\n9:Quit" + }, + { + "input": "0", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "0", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + } + ] + }, + { + "name": "menu_my_account_edit_location_when_all_account_details_have_been_set", + "steps": [ + { + "input": "", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + }, + { + "input": "3", + "expectedContent": "My Account\n1:Profile\n2:Change language\n3:Check balances\n4:Check statement\n5:PIN options\n6:My Address\n0:Back" + }, + { + "input": "1", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "5", + "expectedContent": "Enter your location:\n0:Back" + }, + { + "input": "Kilifi", + "expectedContent": "Please enter your PIN:" + }, + { + "input": "1234", + "expectedContent": "Profile updated successfully\n\n0:Back\n9:Quit" + }, + { + "input": "0", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "0", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + } + ] + }, + { + "name": "menu_my_account_edit_offerings_when_all_account__details_have_been_set", + "steps": [ + { + "input": "", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + }, + { + "input": "3", + "expectedContent": "My Account\n1:Profile\n2:Change language\n3:Check balances\n4:Check statement\n5:PIN options\n6:My Address\n0:Back" + }, + { + "input": "1", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "6", + "expectedContent": "Enter the services or goods you offer: \n0:Back" + }, + { + "input": "Bananas", + "expectedContent": "Please enter your PIN:" + }, + { + "input": "1234", + "expectedContent": "Profile updated successfully\n\n0:Back\n9:Quit" + }, + { + "input": "0", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "0", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + } + ] + }, + { + "name": "menu_my_account_view_profile", + "steps": [ + { + "input": "", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + }, + { + "input": "3", + "expectedContent": "My Account\n1:Profile\n2:Change language\n3:Check balances\n4:Check statement\n5:PIN options\n6:My Address\n0:Back" + }, + { + "input": "1", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "7", + "expectedContent": "Please enter your PIN:" + }, + { + "input": "1234", + "expectedContent": "My profile:\nName: foo bar\nGender: male\nAge: 80\nLocation: Kilifi\nYou provide: Bananas\n\n0:Back\n9:Quit" + }, + { + "input": "0", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "0", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + } + ] + } + ] +} \ No newline at end of file diff --git a/menutraversal_test/menu_traversal_test.go b/menutraversal_test/menu_traversal_test.go new file mode 100644 index 0000000..edc5c0c --- /dev/null +++ b/menutraversal_test/menu_traversal_test.go @@ -0,0 +1,386 @@ +package menutraversaltest + +import ( + "bytes" + "context" + "flag" + "log" + "math/rand" + "regexp" + "testing" + + "git.grassecon.net/grassrootseconomics/visedriver/testutil" + "git.grassecon.net/grassrootseconomics/visedriver/testutil/driver" + "github.com/gofrs/uuid" +) + +var ( + testData = driver.ReadData() + sessionID string + src = rand.NewSource(42) + g = rand.New(src) +) + +var groupTestFile = flag.String("test-file", "group_test.json", "The test file to use for running the group tests") +var database = flag.String("db", "gdbm", "Specify the database (gdbm or postgres)") +var connStr = flag.String("conn", ".test_state", "connection string") +var dbSchema = flag.String("schema", "test", "Specify the database schema (default test)") + +func GenerateSessionId() string { + uu := uuid.NewGenWithOptions(uuid.WithRandomReader(g)) + v, err := uu.NewV4() + if err != nil { + panic(err) + } + return v.String() +} + +// Extract the public key from the engine response +func extractPublicKey(response []byte) string { + // Regex pattern to match the public key starting with 0x and 40 characters + re := regexp.MustCompile(`0x[a-fA-F0-9]{40}`) + match := re.Find(response) + if match != nil { + return string(match) + } + return "" +} + +// Extracts the balance value from the engine response. +func extractBalance(response []byte) string { + // Regex to match "Balance: " followed by a newline + re := regexp.MustCompile(`(?m)^Balance:\s+(\d+(\.\d+)?)\s+([A-Z]+)`) + match := re.FindSubmatch(response) + if match != nil { + return string(match[1]) + " " + string(match[3]) // " " + } + return "" +} + +// Extracts the Maximum amount value from the engine response. +func extractMaxAmount(response []byte) string { + // Regex to match "Maximum amount: " followed by a newline + re := regexp.MustCompile(`(?m)^Maximum amount:\s+(\d+(\.\d+)?)`) + match := re.FindSubmatch(response) + if match != nil { + return string(match[1]) // "" + } + return "" +} + +// Extracts the send amount value from the engine response. +func extractSendAmount(response []byte) string { + // Regex to match the pattern "will receive X.XX SYM from" + re := regexp.MustCompile(`will receive (\d+\.\d{2}\s+[A-Z]+) from`) + match := re.FindSubmatch(response) + if match != nil { + return string(match[1]) // Returns "X.XX SYM" + } + return "" +} + +func TestMain(m *testing.M) { + // Parse the flags + flag.Parse() + sessionID = GenerateSessionId() + // set the db + testutil.SetDatabase(*database, *connStr, *dbSchema) + + // Cleanup the db after tests + defer testutil.CleanDatabase() + + m.Run() +} + +func TestAccountCreationSuccessful(t *testing.T) { + en, fn, eventChannel := testutil.TestEngine(sessionID) + defer fn() + ctx := context.Background() + sessions := testData + for _, session := range sessions { + groups := driver.FilterGroupsByName(session.Groups, "account_creation_successful") + for _, group := range groups { + for _, step := range group.Steps { + cont, err := en.Exec(ctx, []byte(step.Input)) + if err != nil { + t.Fatalf("Test case '%s' failed at input '%s': %v", group.Name, step.Input, err) + } + if !cont { + break + } + w := bytes.NewBuffer(nil) + _, err = en.Flush(ctx, w) + if err != nil { + t.Fatalf("Test case '%s' failed during Flush: %v", group.Name, err) + } + b := w.Bytes() + match, err := step.MatchesExpectedContent(b) + if err != nil { + t.Fatalf("Error compiling regex for step '%s': %v", step.Input, err) + } + if !match { + t.Fatalf("expected:\n\t%s\ngot:\n\t%s\n", step.ExpectedContent, b) + } + } + } + } + <-eventChannel +} + +func TestAccountRegistrationRejectTerms(t *testing.T) { + // Generate a new UUID for this edge case test + uu := uuid.NewGenWithOptions(uuid.WithRandomReader(g)) + v, err := uu.NewV4() + if err != nil { + t.Fail() + } + edgeCaseSessionID := v.String() + en, fn, _ := testutil.TestEngine(edgeCaseSessionID) + defer fn() + ctx := context.Background() + sessions := testData + for _, session := range sessions { + groups := driver.FilterGroupsByName(session.Groups, "account_creation_reject_terms") + for _, group := range groups { + for _, step := range group.Steps { + cont, err := en.Exec(ctx, []byte(step.Input)) + if err != nil { + t.Fatalf("Test case '%s' failed at input '%s': %v", group.Name, step.Input, err) + return + } + if !cont { + break + } + w := bytes.NewBuffer(nil) + if _, err := en.Flush(ctx, w); err != nil { + t.Fatalf("Test case '%s' failed during Flush: %v", group.Name, err) + } + + b := w.Bytes() + match, err := step.MatchesExpectedContent(b) + if err != nil { + t.Fatalf("Error compiling regex for step '%s': %v", step.Input, err) + } + if !match { + t.Fatalf("expected:\n\t%s\ngot:\n\t%s\n", step.ExpectedContent, b) + } + } + } + } +} + +func TestMainMenuHelp(t *testing.T) { + en, fn, _ := testutil.TestEngine(sessionID) + defer fn() + ctx := context.Background() + sessions := testData + for _, session := range sessions { + groups := driver.FilterGroupsByName(session.Groups, "main_menu_help") + for _, group := range groups { + for _, step := range group.Steps { + cont, err := en.Exec(ctx, []byte(step.Input)) + if err != nil { + t.Fatalf("Test case '%s' failed at input '%s': %v", group.Name, step.Input, err) + return + } + if !cont { + break + } + w := bytes.NewBuffer(nil) + if _, err := en.Flush(ctx, w); err != nil { + t.Fatalf("Test case '%s' failed during Flush: %v", group.Name, err) + } + + b := w.Bytes() + balance := extractBalance(b) + + expectedContent := []byte(step.ExpectedContent) + expectedContent = bytes.Replace(expectedContent, []byte("{balance}"), []byte(balance), -1) + + step.ExpectedContent = string(expectedContent) + match, err := step.MatchesExpectedContent(b) + if err != nil { + t.Fatalf("Error compiling regex for step '%s': %v", step.Input, err) + } + if !match { + t.Fatalf("expected:\n\t%s\ngot:\n\t%s\n", step.ExpectedContent, b) + } + } + } + } +} + +func TestMainMenuQuit(t *testing.T) { + en, fn, _ := testutil.TestEngine(sessionID) + defer fn() + ctx := context.Background() + sessions := testData + for _, session := range sessions { + groups := driver.FilterGroupsByName(session.Groups, "main_menu_quit") + for _, group := range groups { + for _, step := range group.Steps { + cont, err := en.Exec(ctx, []byte(step.Input)) + if err != nil { + t.Fatalf("Test case '%s' failed at input '%s': %v", group.Name, step.Input, err) + return + } + if !cont { + break + } + w := bytes.NewBuffer(nil) + if _, err := en.Flush(ctx, w); err != nil { + t.Fatalf("Test case '%s' failed during Flush: %v", group.Name, err) + } + + b := w.Bytes() + balance := extractBalance(b) + + expectedContent := []byte(step.ExpectedContent) + expectedContent = bytes.Replace(expectedContent, []byte("{balance}"), []byte(balance), -1) + + step.ExpectedContent = string(expectedContent) + match, err := step.MatchesExpectedContent(b) + if err != nil { + t.Fatalf("Error compiling regex for step '%s': %v", step.Input, err) + } + if !match { + t.Fatalf("expected:\n\t%s\ngot:\n\t%s\n", step.ExpectedContent, b) + } + } + } + } +} + +func TestMyAccount_MyAddress(t *testing.T) { + en, fn, _ := testutil.TestEngine(sessionID) + defer fn() + ctx := context.Background() + sessions := testData + for _, session := range sessions { + groups := driver.FilterGroupsByName(session.Groups, "menu_my_account_my_address") + for _, group := range groups { + for index, step := range group.Steps { + t.Logf("step %v with input %v", index, step.Input) + cont, err := en.Exec(ctx, []byte(step.Input)) + if err != nil { + t.Errorf("Test case '%s' failed at input '%s': %v", group.Name, step.Input, err) + return + } + if !cont { + break + } + w := bytes.NewBuffer(nil) + if _, err := en.Flush(ctx, w); err != nil { + t.Errorf("Test case '%s' failed during Flush: %v", group.Name, err) + } + b := w.Bytes() + + balance := extractBalance(b) + publicKey := extractPublicKey(b) + + expectedContent := []byte(step.ExpectedContent) + expectedContent = bytes.Replace(expectedContent, []byte("{balance}"), []byte(balance), -1) + expectedContent = bytes.Replace(expectedContent, []byte("{public_key}"), []byte(publicKey), -1) + + step.ExpectedContent = string(expectedContent) + match, err := step.MatchesExpectedContent(b) + if err != nil { + t.Fatalf("Error compiling regex for step '%s': %v", step.Input, err) + } + if !match { + t.Fatalf("expected:\n\t%s\ngot:\n\t%s\n", expectedContent, b) + } + } + } + } +} + +func TestMainMenuSend(t *testing.T) { + en, fn, _ := testutil.TestEngine(sessionID) + defer fn() + ctx := context.Background() + sessions := testData + for _, session := range sessions { + groups := driver.FilterGroupsByName(session.Groups, "send_with_invite") + for _, group := range groups { + for index, step := range group.Steps { + t.Logf("step %v with input %v", index, step.Input) + cont, err := en.Exec(ctx, []byte(step.Input)) + if err != nil { + t.Fatalf("Test case '%s' failed at input '%s': %v", group.Name, step.Input, err) + return + } + if !cont { + break + } + w := bytes.NewBuffer(nil) + if _, err := en.Flush(ctx, w); err != nil { + t.Fatalf("Test case '%s' failed during Flush: %v", group.Name, err) + } + + b := w.Bytes() + balance := extractBalance(b) + max_amount := extractMaxAmount(b) + send_amount := extractSendAmount(b) + + expectedContent := []byte(step.ExpectedContent) + expectedContent = bytes.Replace(expectedContent, []byte("{balance}"), []byte(balance), -1) + expectedContent = bytes.Replace(expectedContent, []byte("{max_amount}"), []byte(max_amount), -1) + expectedContent = bytes.Replace(expectedContent, []byte("{send_amount}"), []byte(send_amount), -1) + expectedContent = bytes.Replace(expectedContent, []byte("{session_id}"), []byte(sessionID), -1) + + step.ExpectedContent = string(expectedContent) + match, err := step.MatchesExpectedContent(b) + if err != nil { + t.Fatalf("Error compiling regex for step '%s': %v", step.Input, err) + } + if !match { + t.Fatalf("expected:\n\t%s\ngot:\n\t%s\n", step.ExpectedContent, b) + } + } + } + } +} + +func TestGroups(t *testing.T) { + groups, err := driver.LoadTestGroups(*groupTestFile) + if err != nil { + log.Fatalf("Failed to load test groups: %v", err) + } + en, fn, _ := testutil.TestEngine(sessionID) + defer fn() + ctx := context.Background() + // Create test cases from loaded groups + tests := driver.CreateTestCases(groups) + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + cont, err := en.Exec(ctx, []byte(tt.Input)) + if err != nil { + t.Errorf("Test case '%s' failed at input '%s': %v", tt.Name, tt.Input, err) + return + } + if !cont { + return + } + w := bytes.NewBuffer(nil) + if _, err := en.Flush(ctx, w); err != nil { + t.Errorf("Test case '%s' failed during Flush: %v", tt.Name, err) + } + b := w.Bytes() + balance := extractBalance(b) + + expectedContent := []byte(tt.ExpectedContent) + expectedContent = bytes.Replace(expectedContent, []byte("{balance}"), []byte(balance), -1) + + tt.ExpectedContent = string(expectedContent) + + match, err := tt.MatchesExpectedContent(b) + if err != nil { + t.Fatalf("Error compiling regex for step '%s': %v", tt.Input, err) + } + if !match { + t.Fatalf("expected:\n\t%s\ngot:\n\t%s\n", tt.ExpectedContent, b) + } + }) + } +} diff --git a/menutraversal_test/profile_edit_start_familyname.json b/menutraversal_test/profile_edit_start_familyname.json new file mode 100644 index 0000000..98325b0 --- /dev/null +++ b/menutraversal_test/profile_edit_start_familyname.json @@ -0,0 +1,68 @@ +{ + "groups": [ + { + "name": "menu_my_account_edit_all_account_details_starting_from_family_name", + "steps": [ + { + "input": "", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + }, + { + "input": "3", + "expectedContent": "My Account\n1:Profile\n2:Change language\n3:Check balances\n4:Check statement\n5:PIN options\n6:My Address\n0:Back" + }, + { + "input": "1", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "2", + "expectedContent": "Enter family name:\n0:Back" + }, + { + "input": "bar", + "expectedContent": "Select gender: \n1:Male\n2:Female\n3:Unspecified\n0:Back" + }, + { + "input": "1", + "expectedContent": "Enter your year of birth\n0:Back" + }, + { + "input": "1940", + "expectedContent": "Enter your location:\n0:Back" + }, + { + "input": "Kilifi", + "expectedContent": "Enter the services or goods you offer: \n0:Back" + }, + { + "input": "Bananas", + "expectedContent": "Please enter your PIN:" + }, + { + "input": "1234", + "expectedContent": "Profile updated successfully\n\n0:Back\n9:Quit" + }, + { + "input": "0", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "0", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + } + ] + } + ] +} + + + + + + + + + + + \ No newline at end of file diff --git a/menutraversal_test/profile_edit_start_firstname.json b/menutraversal_test/profile_edit_start_firstname.json new file mode 100644 index 0000000..0f6be8b --- /dev/null +++ b/menutraversal_test/profile_edit_start_firstname.json @@ -0,0 +1,61 @@ +{ + "groups": [ + { + "name": "menu_my_account_edit_all_account_details_starting_from_firstname", + "steps": [ + { + "input": "", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + }, + { + "input": "3", + "expectedContent": "My Account\n1:Profile\n2:Change language\n3:Check balances\n4:Check statement\n5:PIN options\n6:My Address\n0:Back" + }, + { + "input": "1", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "1", + "expectedContent": "Enter your first names:\n0:Back" + }, + { + "input": "foo", + "expectedContent": "Enter family name:\n0:Back" + }, + { + "input": "bar", + "expectedContent": "Select gender: \n1:Male\n2:Female\n3:Unspecified\n0:Back" + }, + { + "input": "1", + "expectedContent": "Enter your year of birth\n0:Back" + }, + { + "input": "1940", + "expectedContent": "Enter your location:\n0:Back" + }, + { + "input": "Kilifi", + "expectedContent": "Enter the services or goods you offer: \n0:Back" + }, + { + "input": "Bananas", + "expectedContent": "Please enter your PIN:" + }, + { + "input": "1234", + "expectedContent": "Profile updated successfully\n\n0:Back\n9:Quit" + }, + { + "input": "0", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "0", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + } + ] + } + ] +} diff --git a/menutraversal_test/profile_edit_start_gender.json b/menutraversal_test/profile_edit_start_gender.json new file mode 100644 index 0000000..afca12a --- /dev/null +++ b/menutraversal_test/profile_edit_start_gender.json @@ -0,0 +1,55 @@ +{ + "groups": [ + { + "name": "menu_my_account_edit_all_account_details_starting_from_gender", + "steps": [ + { + "input": "", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + }, + { + "input": "3", + "expectedContent": "My Account\n1:Profile\n2:Change language\n3:Check balances\n4:Check statement\n5:PIN options\n6:My Address\n0:Back" + }, + { + "input": "1", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "3", + "expectedContent": "Select gender: \n1:Male\n2:Female\n3:Unspecified\n0:Back" + }, + { + "input": "1", + "expectedContent": "Enter your year of birth\n0:Back" + }, + { + "input": "1940", + "expectedContent": "Enter your location:\n0:Back" + }, + { + "input": "Kilifi", + "expectedContent": "Enter the services or goods you offer: \n0:Back" + }, + { + "input": "Bananas", + "expectedContent": "Please enter your PIN:" + }, + { + "input": "1234", + "expectedContent": "Profile updated successfully\n\n0:Back\n9:Quit" + }, + { + "input": "0", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "0", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + } + ] + } + ] +} + + \ No newline at end of file diff --git a/menutraversal_test/profile_edit_start_location.json b/menutraversal_test/profile_edit_start_location.json new file mode 100644 index 0000000..8852911 --- /dev/null +++ b/menutraversal_test/profile_edit_start_location.json @@ -0,0 +1,46 @@ +{ + "groups": [ + { + "name": "menu_my_account_edit_all_account_details_starting_from_location", + "steps": [ + { + "input": "", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + }, + { + "input": "3", + "expectedContent": "My Account\n1:Profile\n2:Change language\n3:Check balances\n4:Check statement\n5:PIN options\n6:My Address\n0:Back" + }, + { + "input": "1", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "5", + "expectedContent": "Enter your location:\n0:Back" + }, + { + "input": "Kilifi", + "expectedContent": "Enter the services or goods you offer: \n0:Back" + }, + { + "input": "Bananas", + "expectedContent": "Please enter your PIN:" + }, + { + "input": "1234", + "expectedContent": "Profile updated successfully\n\n0:Back\n9:Quit" + }, + { + "input": "0", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "0", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + } + ] + } + ] +} + \ No newline at end of file diff --git a/menutraversal_test/profile_edit_start_offerings.json b/menutraversal_test/profile_edit_start_offerings.json new file mode 100644 index 0000000..6aa40f6 --- /dev/null +++ b/menutraversal_test/profile_edit_start_offerings.json @@ -0,0 +1,42 @@ +{ + "groups": [ + { + "name": "menu_my_account_edit_all_account_details_starting_from_offerings", + "steps": [ + { + "input": "", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + }, + { + "input": "3", + "expectedContent": "My Account\n1:Profile\n2:Change language\n3:Check balances\n4:Check statement\n5:PIN options\n6:My Address\n0:Back" + }, + { + "input": "1", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "6", + "expectedContent": "Enter the services or goods you offer: \n0:Back" + }, + { + "input": "Bananas", + "expectedContent": "Please enter your PIN:" + }, + { + "input": "1234", + "expectedContent": "Profile updated successfully\n\n0:Back\n9:Quit" + }, + { + "input": "0", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "0", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + } + ] + } + ] +} + \ No newline at end of file diff --git a/menutraversal_test/profile_edit_start_yob.json b/menutraversal_test/profile_edit_start_yob.json new file mode 100644 index 0000000..45227f7 --- /dev/null +++ b/menutraversal_test/profile_edit_start_yob.json @@ -0,0 +1,50 @@ +{ + "groups": [ + { + "name": "menu_my_account_edit_all_account_details_starting_from_yob", + "steps": [ + { + "input": "", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + }, + { + "input": "3", + "expectedContent": "My Account\n1:Profile\n2:Change language\n3:Check balances\n4:Check statement\n5:PIN options\n6:My Address\n0:Back" + }, + { + "input": "1", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "4", + "expectedContent": "Enter your year of birth\n0:Back" + }, + { + "input": "1940", + "expectedContent": "Enter your location:\n0:Back" + }, + { + "input": "Kilifi", + "expectedContent": "Enter the services or goods you offer: \n0:Back" + }, + { + "input": "Bananas", + "expectedContent": "Please enter your PIN:" + }, + { + "input": "1234", + "expectedContent": "Profile updated successfully\n\n0:Back\n9:Quit" + }, + { + "input": "0", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "0", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + } + ] + } + ] +} + \ No newline at end of file diff --git a/menutraversal_test/profile_edit_when_adjacent_item_set.json b/menutraversal_test/profile_edit_when_adjacent_item_set.json new file mode 100644 index 0000000..f8d7263 --- /dev/null +++ b/menutraversal_test/profile_edit_when_adjacent_item_set.json @@ -0,0 +1,70 @@ +{ + "groups": [ + { + "name": "menu_my_account_edit_familyname_when_adjacent_profile_information_set", + "steps": [ + { + "input": "", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + }, + { + "input": "3", + "expectedContent": "My Account\n1:Profile\n2:Change language\n3:Check balances\n4:Check statement\n5:PIN options\n6:My Address\n0:Back" + }, + { + "input": "1", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "3", + "expectedContent": "Select gender: \n1:Male\n2:Female\n3:Unspecified\n0:Back" + }, + { + "input": "1", + "expectedContent": "Enter your year of birth\n0:Back" + }, + { + "input": "1940", + "expectedContent": "Enter your location:\n0:Back" + }, + { + "input": "Kilifi", + "expectedContent": "Enter the services or goods you offer: \n0:Back" + }, + { + "input": "Bananas", + "expectedContent": "Please enter your PIN:" + }, + { + "input": "1234", + "expectedContent": "Profile updated successfully\n\n0:Back\n9:Quit" + }, + { + "input": "0", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "2", + "expectedContent": "Enter family name:\n0:Back" + }, + { + "input": "foo2", + "expectedContent": "Please enter your PIN:" + }, + { + "input": "1234", + "expectedContent": "Profile updated successfully\n\n0:Back\n9:Quit" + }, + { + "input": "0", + "expectedContent": "My profile\n1:Edit name\n2:Edit family name\n3:Edit gender\n4:Edit year of birth\n5:Edit location\n6:Edit offerings\n7:View profile\n0:Back" + }, + { + "input": "0", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + } + ] + } + ] +} + \ No newline at end of file diff --git a/menutraversal_test/test_setup.json b/menutraversal_test/test_setup.json new file mode 100644 index 0000000..8728640 --- /dev/null +++ b/menutraversal_test/test_setup.json @@ -0,0 +1,133 @@ +[ + { + "name": "session one", + "groups": [ + { + "name": "account_creation_successful", + "steps": [ + { + "input": "", + "expectedContent": "Welcome to Sarafu Network\nPlease select a language\n1:English\n2:Kiswahili" + }, + { + "input": "1", + "expectedContent": "Do you agree to terms and conditions?\nhttps://grassecon.org/pages/terms-and-conditions\n\n1:Yes\n2:No" + }, + { + "input": "1", + "expectedContent": "Please enter a new four number PIN for your account:\n0:Exit" + }, + { + "input": "1234", + "expectedContent": "Enter your four number PIN again:" + }, + { + "input": "1111", + "expectedContent": "The PIN is not a match. Try again\n1:Retry\n9:Quit" + }, + { + "input": "1", + "expectedContent": "Enter your four number PIN again:" + }, + { + "input": "1234", + "expectedContent": "Your account is being created...Thank you for using Sarafu. Goodbye!" + } + ] + }, + { + "name": "account_creation_reject_terms", + "steps": [ + { + "input": "", + "expectedContent": "Welcome to Sarafu Network\nPlease select a language\n1:English\n2:Kiswahili" + }, + { + "input": "1", + "expectedContent": "Do you agree to terms and conditions?\nhttps://grassecon.org/pages/terms-and-conditions\n\n1:Yes\n2:No" + }, + { + "input": "2", + "expectedContent": "Thank you for using Sarafu. Goodbye!" + } + ] + }, + { + "name": "send_with_invite", + "steps": [ + { + "input": "", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + }, + { + "input": "1", + "expectedContent": "Enter recipient's phone number/address/alias:\n0:Back" + }, + { + "input": "0@0", + "expectedContent": "0@0 is invalid, please try again:\n1:Retry\n9:Quit" + }, + { + "input": "1", + "expectedContent": "Enter recipient's phone number/address/alias:\n0:Back" + }, + { + "input": "0712345678", + "expectedContent": "0712345678 is not registered, please try again:\n1:Retry\n2:Invite to Sarafu Network\n9:Quit" + }, + { + "input": "2", + "expectedContent": "Your invite request for 0712345678 to Sarafu Network failed. Please try again later." + } + ] + }, + { + "name": "main_menu_help", + "steps": [ + { + "input": "", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + }, + { + "input": "4", + "expectedContent": "For more help,please call: 0757628885" + } + ] + }, + { + "name": "main_menu_quit", + "steps": [ + { + "input": "", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + }, + { + "input": "9", + "expectedContent": "Thank you for using Sarafu. Goodbye!" + } + ] + }, + { + "name": "menu_my_account_my_address", + "steps": [ + { + "input": "", + "expectedContent": "{balance}\n\n1:Send\n2:My Vouchers\n3:My Account\n4:Help\n9:Quit" + }, + { + "input": "3", + "expectedContent": "My Account\n1:Profile\n2:Change language\n3:Check balances\n4:Check statement\n5:PIN options\n6:My Address\n0:Back" + }, + { + "input": "6", + "expectedContent": "Address: {public_key}\n0:Back\n9:Quit" + }, + { + "input": "9", + "expectedContent": "Thank you for using Sarafu. Goodbye!" + } + ] + } + ] + } +] \ No newline at end of file diff --git a/ssh/keystore.go b/ssh/keystore.go new file mode 100644 index 0000000..68bfe5d --- /dev/null +++ b/ssh/keystore.go @@ -0,0 +1,65 @@ +package ssh + +import ( + "context" + "fmt" + "os" + "path" + + "golang.org/x/crypto/ssh" + + "git.defalsify.org/vise.git/db" + + "git.grassecon.net/grassrootseconomics/visedriver/storage" + dbstorage "git.grassecon.net/grassrootseconomics/visedriver/storage/db/gdbm" +) + +type SshKeyStore struct { + store db.Db +} + +func NewSshKeyStore(ctx context.Context, dbDir string) (*SshKeyStore, error) { + keyStore := &SshKeyStore{} + keyStoreFile := path.Join(dbDir, "ssh_authorized_keys.gdbm") + keyStore.store = dbstorage.NewThreadGdbmDb() + err := keyStore.store.Connect(ctx, keyStoreFile) + if err != nil { + return nil, err + } + return keyStore, nil +} + +func(s *SshKeyStore) AddFromFile(ctx context.Context, fp string, sessionId string) error { + _, err := os.Stat(fp) + if err != nil { + return fmt.Errorf("cannot open ssh server public key file: %v\n", err) + } + + publicBytes, err := os.ReadFile(fp) + if err != nil { + return fmt.Errorf("Failed to load public key: %v", err) + } + pubKey, _, _, _, err := ssh.ParseAuthorizedKey(publicBytes) + if err != nil { + return fmt.Errorf("Failed to parse public key: %v", err) + } + k := append([]byte{0x01}, pubKey.Marshal()...) + s.store.SetPrefix(storage.DATATYPE_EXTEND) + logg.Infof("Added key", "sessionId", sessionId, "public key", string(publicBytes)) + return s.store.Put(ctx, k, []byte(sessionId)) +} + +func(s *SshKeyStore) Get(ctx context.Context, pubKey ssh.PublicKey) (string, error) { + s.store.SetLanguage(nil) + s.store.SetPrefix(storage.DATATYPE_EXTEND) + k := append([]byte{0x01}, pubKey.Marshal()...) + v, err := s.store.Get(ctx, k) + if err != nil { + return "", err + } + return string(v), nil +} + +func(s *SshKeyStore) Close() error { + return s.store.Close() +} diff --git a/ssh/ssh.go b/ssh/ssh.go new file mode 100644 index 0000000..426bac9 --- /dev/null +++ b/ssh/ssh.go @@ -0,0 +1,284 @@ +package ssh + +import ( + "context" + "encoding/hex" + "encoding/base64" + "errors" + "fmt" + "net" + "os" + "sync" + + "golang.org/x/crypto/ssh" + + "git.defalsify.org/vise.git/engine" + "git.defalsify.org/vise.git/logging" + "git.defalsify.org/vise.git/resource" + "git.defalsify.org/vise.git/state" + + "git.grassecon.net/grassrootseconomics/visedriver/handlers" + "git.grassecon.net/grassrootseconomics/visedriver/storage" + "git.grassecon.net/grassrootseconomics/visedriver/remote" +) + +var ( + logg = logging.NewVanilla().WithDomain("ssh") +) + +type auther struct { + Ctx context.Context + keyStore *SshKeyStore + auth map[string]string +} + +func NewAuther(ctx context.Context, keyStore *SshKeyStore) *auther { + return &auther{ + Ctx: ctx, + keyStore: keyStore, + auth: make(map[string]string), + } +} + +func(a *auther) Check(conn ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) { + logg.TraceCtxf(a.Ctx, "looking for publickey", "pubkey", fmt.Sprintf("%x", pubKey)) + va, err := a.keyStore.Get(a.Ctx, pubKey) + if err != nil { + return nil, err + } + ka := hex.EncodeToString(conn.SessionID()) + a.auth[ka] = va + fmt.Fprintf(os.Stderr, "connect: %s -> %s\n", ka, va) + return nil, nil +} + +func(a *auther) FromConn(c *ssh.ServerConn) (string, error) { + if c == nil { + return "", errors.New("nil server conn") + } + if c.Conn == nil { + return "", errors.New("nil underlying conn") + } + return a.Get(c.Conn.SessionID()) +} + + +func(a *auther) Get(k []byte) (string, error) { + ka := hex.EncodeToString(k) + v, ok := a.auth[ka] + if !ok { + return "", errors.New("not found") + } + return v, nil +} + +type SshRunner struct { + Ctx context.Context + Cfg engine.Config + FlagFile string + Conn storage.ConnData + ResourceDir string + Debug bool + SrvKeyFile string + Host string + Port uint + wg sync.WaitGroup + lst net.Listener +} + +func(s *SshRunner) serve(ctx context.Context, sessionId string, ch ssh.NewChannel, en engine.Engine) error { + if ch == nil { + return errors.New("nil channel") + } + if ch.ChannelType() != "session" { + ch.Reject(ssh.UnknownChannelType, "that is not the channel you are looking for") + return errors.New("not a session") + } + channel, requests, err := ch.Accept() + if err != nil { + panic(err) + } + defer channel.Close() + s.wg.Add(1) + go func(reqIn <-chan *ssh.Request) { + defer s.wg.Done() + for req := range reqIn { + req.Reply(req.Type == "shell", nil) + } + _ = requests + }(requests) + + cont, err := en.Exec(ctx, []byte{}) + if err != nil { + return fmt.Errorf("initial engine exec err: %v", err) + } + + var input [state.INPUT_LIMIT]byte + for cont { + c, err := en.Flush(ctx, channel) + if err != nil { + return fmt.Errorf("flush err: %v", err) + } + _, err = channel.Write([]byte{0x0a}) + if err != nil { + return fmt.Errorf("newline err: %v", err) + } + c, err = channel.Read(input[:]) + if err != nil { + return fmt.Errorf("read input fail: %v", err) + } + logg.TraceCtxf(ctx, "input read", "c", c, "input", input[:c-1]) + cont, err = en.Exec(ctx, input[:c-1]) + if err != nil { + return fmt.Errorf("engine exec err: %v", err) + } + logg.TraceCtxf(ctx, "exec cont", "cont", cont, "en", en) + _ = c + } + c, err := en.Flush(ctx, channel) + if err != nil { + return fmt.Errorf("last flush err: %v", err) + } + _ = c + return nil +} + +func(s *SshRunner) Stop() error { + return s.lst.Close() +} + +func(s *SshRunner) GetEngine(sessionId string) (engine.Engine, func(), error) { + ctx := s.Ctx + menuStorageService := storage.NewMenuStorageService(s.Conn, s.ResourceDir) + + rs, err := menuStorageService.GetResource(ctx) + if err != nil { + return nil, nil, err + } + + pe, err := menuStorageService.GetPersister(ctx) + if err != nil { + return nil, nil, err + } + + userdatastore, err := menuStorageService.GetUserdataDb(ctx) + if err != nil { + return nil, nil, err + } + + dbResource, ok := rs.(*resource.DbResource) + if !ok { + return nil, nil, err + } + + lhs, err := handlers.NewLocalHandlerService(ctx, s.FlagFile, true, dbResource, s.Cfg, rs) + lhs.SetDataStore(&userdatastore) + lhs.SetPersister(pe) + lhs.Cfg.SessionId = sessionId + + if err != nil { + return nil, nil, err + } + + // TODO: clear up why pointer here and by-value other cmds + accountService := &remote.AccountService{} + hl, err := lhs.GetHandler(accountService) + if err != nil { + return nil, nil, err + } + + en := lhs.GetEngine() + en = en.WithFirst(hl.Init) + if s.Debug { + en = en.WithDebug(nil) + } + // TODO: this is getting very hacky! + closer := func() { + err := menuStorageService.Close() + if err != nil { + logg.ErrorCtxf(ctx, "menu storage service cleanup fail", "err", err) + } + } + return en, closer, nil +} + +// adapted example from crypto/ssh package, NewServerConn doc +func(s *SshRunner) Run(ctx context.Context, keyStore *SshKeyStore) { + s.Ctx = ctx + running := true + + // TODO: waitgroup should probably not be global + defer s.wg.Wait() + + auth := NewAuther(ctx, keyStore) + cfg := ssh.ServerConfig{ + PublicKeyCallback: auth.Check, + } + + privateBytes, err := os.ReadFile(s.SrvKeyFile) + if err != nil { + logg.ErrorCtxf(ctx, "Failed to load private key", "err", err) + } + private, err := ssh.ParsePrivateKey(privateBytes) + if err != nil { + logg.ErrorCtxf(ctx, "Failed to parse private key", "err", err) + } + srvPub := private.PublicKey() + srvPubStr := base64.StdEncoding.EncodeToString(srvPub.Marshal()) + logg.InfoCtxf(ctx, "have server key", "type", srvPub.Type(), "public", srvPubStr) + cfg.AddHostKey(private) + + s.lst, err = net.Listen("tcp", fmt.Sprintf("%s:%d", s.Host, s.Port)) + if err != nil { + panic(err) + } + + for running { + conn, err := s.lst.Accept() + if err != nil { + logg.ErrorCtxf(ctx, "ssh accept error", "err", err) + running = false + continue + } + + go func(conn net.Conn) { + defer conn.Close() + for true { + srvConn, nC, rC, err := ssh.NewServerConn(conn, &cfg) + if err != nil { + logg.InfoCtxf(ctx, "rejected client", "err", err) + return + } + logg.DebugCtxf(ctx, "ssh client connected", "conn", srvConn) + + s.wg.Add(1) + go func() { + ssh.DiscardRequests(rC) + s.wg.Done() + }() + + sessionId, err := auth.FromConn(srvConn) + if err != nil { + logg.ErrorCtxf(ctx, "Cannot find authentication") + return + } + en, closer, err := s.GetEngine(sessionId) + if err != nil { + logg.ErrorCtxf(ctx, "engine won't start", "err", err) + return + } + defer func() { + err := en.Finish() + if err != nil { + logg.ErrorCtxf(ctx, "engine won't stop", "err", err) + } + closer() + }() + for ch := range nC { + err = s.serve(ctx, sessionId, ch, en) + logg.ErrorCtxf(ctx, "ssh server finish", "err", err) + } + } + }(conn) + } +} diff --git a/testutil/engine.go b/testutil/engine.go new file mode 100644 index 0000000..7d68f8f --- /dev/null +++ b/testutil/engine.go @@ -0,0 +1,209 @@ +package testutil + +import ( + "context" + "fmt" + "log" + "net/url" + "os" + "path" + "path/filepath" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "git.defalsify.org/vise.git/engine" + "git.defalsify.org/vise.git/logging" + "git.defalsify.org/vise.git/resource" + "git.grassecon.net/grassrootseconomics/visedriver/initializers" + "git.grassecon.net/grassrootseconomics/visedriver/config" + "git.grassecon.net/grassrootseconomics/visedriver/handlers" + "git.grassecon.net/grassrootseconomics/visedriver/storage" + "git.grassecon.net/grassrootseconomics/visedriver/testutil/testservice" + "git.grassecon.net/grassrootseconomics/visedriver/testutil/testtag" + testdataloader "github.com/peteole/testdata-loader" + "git.grassecon.net/grassrootseconomics/visedriver/remote" +) + +var ( + logg = logging.NewVanilla() + baseDir = testdataloader.GetBasePath() + scriptDir = path.Join(baseDir, "services", "registration") + setDbType string + setConnStr string + setDbSchema string +) + +func init() { + initializers.LoadEnvVariablesPath(baseDir) + config.LoadConfig() +} + +// SetDatabase updates the database used by TestEngine +func SetDatabase(database, connStr, dbSchema string) { + setDbType = database + setConnStr = connStr + setDbSchema = dbSchema +} + +// CleanDatabase removes all test data from the database +func CleanDatabase() { + if setDbType == "postgres" { + ctx := context.Background() + // Update the connection string with the new search path + updatedConnStr, err := updateSearchPath(setConnStr, setDbSchema) + if err != nil { + log.Fatalf("Failed to update search path: %v", err) + } + + dbConn, err := pgxpool.New(ctx, updatedConnStr) + if err != nil { + log.Fatalf("Failed to connect to database for cleanup: %v", err) + } + defer dbConn.Close() + + query := fmt.Sprintf("DELETE FROM %s.kv_vise;", setDbSchema) + _, execErr := dbConn.Exec(ctx, query) + if execErr != nil { + log.Printf("Failed to cleanup table %s.kv_vise: %v", setDbSchema, execErr) + } else { + log.Printf("Successfully cleaned up table %s.kv_vise", setDbSchema) + } + } else { + setConnStr, _ := filepath.Abs(setConnStr) + if err := os.RemoveAll(setConnStr); err != nil { + log.Fatalf("Failed to delete state store %s: %v", setConnStr, err) + } + } +} + +// updateSearchPath updates the search_path (schema) to be used in the connection +func updateSearchPath(connStr string, newSearchPath string) (string, error) { + u, err := url.Parse(connStr) + if err != nil { + return "", fmt.Errorf("invalid connection string: %w", err) + } + + // Parse the query parameters + q := u.Query() + + // Update or add the search_path parameter + q.Set("search_path", newSearchPath) + + // Rebuild the connection string with updated parameters + u.RawQuery = q.Encode() + + return u.String(), nil +} + +func TestEngine(sessionId string) (engine.Engine, func(), chan bool) { + var err error + ctx := context.Background() + ctx = context.WithValue(ctx, "SessionId", sessionId) + pfp := path.Join(scriptDir, "pp.csv") + + var eventChannel = make(chan bool) + + cfg := engine.Config{ + Root: "root", + SessionId: sessionId, + OutputSize: uint32(160), + FlagCount: uint32(128), + } + + if setDbType == "postgres" { + setConnStr = config.DbConn + setConnStr, err = updateSearchPath(setConnStr, setDbSchema) + if err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } + } else { + setConnStr, err = filepath.Abs(setConnStr) + if err != nil { + fmt.Fprintf(os.Stderr, "connstr err: %v", err) + os.Exit(1) + } + } + + conn, err := storage.ToConnData(setConnStr) + if err != nil { + fmt.Fprintf(os.Stderr, "connstr parse err: %v", err) + os.Exit(1) + } + + resourceDir := scriptDir + menuStorageService := storage.NewMenuStorageService(conn, resourceDir) + + rs, err := menuStorageService.GetResource(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "resource error: %v", err) + os.Exit(1) + } + + pe, err := menuStorageService.GetPersister(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "persister error: %v", err) + os.Exit(1) + } + + userDataStore, err := menuStorageService.GetUserdataDb(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "userdb error: %v", err) + os.Exit(1) + } + + dbResource, ok := rs.(*resource.DbResource) + if !ok { + fmt.Fprintf(os.Stderr, "dbresource cast error") + os.Exit(1) + } + + lhs, err := handlers.NewLocalHandlerService(ctx, pfp, true, dbResource, cfg, rs) + lhs.SetDataStore(&userDataStore) + lhs.SetPersister(pe) + + if err != nil { + fmt.Fprintf(os.Stderr, err.Error()) + os.Exit(1) + } + + if testtag.AccountService == nil { + testtag.AccountService = &remote.AccountService{} + } + + switch testtag.AccountService.(type) { + case *testservice.TestAccountService: + go func() { + eventChannel <- false + }() + case *remote.AccountService: + go func() { + time.Sleep(5 * time.Second) // Wait for 5 seconds + eventChannel <- true + }() + default: + panic("Unknown account service type") + } + + hl, err := lhs.GetHandler(testtag.AccountService) + if err != nil { + fmt.Fprintf(os.Stderr, err.Error()) + os.Exit(1) + } + + en := lhs.GetEngine() + en = en.WithFirst(hl.Init) + cleanFn := func() { + err := en.Finish() + if err != nil { + logg.Errorf(err.Error()) + } + + err = menuStorageService.Close() + if err != nil { + logg.Errorf(err.Error()) + } + logg.Infof("testengine storage closed") + } + return en, cleanFn, eventChannel +} diff --git a/testutil/engine_test.go b/testutil/engine_test.go new file mode 100644 index 0000000..f747468 --- /dev/null +++ b/testutil/engine_test.go @@ -0,0 +1,15 @@ +package testutil + +import ( + "testing" +) + +func TestCreateEngine(t *testing.T) { + o, clean, eventC := TestEngine("foo") + defer clean() + defer func() { + <-eventC + close(eventC) + }() + _ = o +}