diff --git a/cmd/africastalking/main.go b/cmd/africastalking/main.go index 98864db..0019239 100644 --- a/cmd/africastalking/main.go +++ b/cmd/africastalking/main.go @@ -1,109 +1,39 @@ package main import ( - "bytes" "context" - "encoding/json" "flag" "fmt" - "io" "net/http" "os" "os/signal" "path" "strconv" - "strings" "syscall" "git.defalsify.org/vise.git/engine" "git.defalsify.org/vise.git/logging" "git.defalsify.org/vise.git/resource" - "git.grassecon.net/urdt/ussd/common" "git.grassecon.net/urdt/ussd/config" "git.grassecon.net/urdt/ussd/initializers" "git.grassecon.net/urdt/ussd/internal/handlers" - httpserver "git.grassecon.net/urdt/ussd/internal/http" + "git.grassecon.net/urdt/ussd/internal/http/at" + httpserver "git.grassecon.net/urdt/ussd/internal/http/at" "git.grassecon.net/urdt/ussd/internal/storage" "git.grassecon.net/urdt/ussd/remote" ) var ( - logg = logging.NewVanilla() - scriptDir = path.Join("services", "registration") - - build = "dev" + logg = logging.NewVanilla().WithDomain("AfricasTalking").WithContextKey("at-session-id") + scriptDir = path.Join("services", "registration") + build = "dev" + menuSeparator = ": " ) func init() { initializers.LoadEnvVariables() } - -type atRequestParser struct{} - -func (arp *atRequestParser) GetSessionId(rq any) (string, error) { - rqv, ok := rq.(*http.Request) - if !ok { - logg.Warnf("got an invalid request", "req", rq) - return "", handlers.ErrInvalidRequest - } - - // Capture body (if any) for logging - body, err := io.ReadAll(rqv.Body) - if err != nil { - logg.Warnf("failed to read request body", "err", err) - return "", fmt.Errorf("failed to read request body: %v", err) - } - // Reset the body for further reading - rqv.Body = io.NopCloser(bytes.NewReader(body)) - - // Log the body as JSON - bodyLog := map[string]string{"body": string(body)} - logBytes, err := json.Marshal(bodyLog) - if err != nil { - logg.Warnf("failed to marshal request body", "err", err) - } else { - logg.Debugf("received request", "bytes", logBytes) - } - - if err := rqv.ParseForm(); err != nil { - logg.Warnf("failed to parse form data", "err", err) - return "", fmt.Errorf("failed to parse form data: %v", err) - } - - phoneNumber := rqv.FormValue("phoneNumber") - if phoneNumber == "" { - return "", fmt.Errorf("no phone number found") - } - - formattedNumber, err := common.FormatPhoneNumber(phoneNumber) - if err != nil { - logg.Warnf("failed to format phone number", "err", err) - return "", fmt.Errorf("failed to format number") - } - - return formattedNumber, nil -} - -func (arp *atRequestParser) GetInput(rq any) ([]byte, error) { - rqv, ok := rq.(*http.Request) - if !ok { - return nil, handlers.ErrInvalidRequest - } - if err := rqv.ParseForm(); err != nil { - return nil, fmt.Errorf("failed to parse form data: %v", err) - } - - text := rqv.FormValue("text") - - parts := strings.Split(text, "*") - if len(parts) == 0 { - return nil, fmt.Errorf("no input found") - } - - return []byte(parts[len(parts)-1]), nil -} - func main() { config.LoadConfig() @@ -130,9 +60,10 @@ func main() { pfp := path.Join(scriptDir, "pp.csv") cfg := engine.Config{ - Root: "root", - OutputSize: uint32(size), - FlagCount: uint32(128), + Root: "root", + OutputSize: uint32(size), + FlagCount: uint32(128), + MenuSeparator: menuSeparator, } if engineDebug { @@ -190,7 +121,9 @@ func main() { } defer stateStore.Close() - rp := &atRequestParser{} + rp := &at.ATRequestParser{ + Context: ctx, + } bsh := handlers.NewBaseSessionHandler(cfg, rs, stateStore, userdataStore, rp, hl) sh := httpserver.NewATSessionHandler(bsh) diff --git a/cmd/async/main.go b/cmd/async/main.go index e4c94b0..9cd04b3 100644 --- a/cmd/async/main.go +++ b/cmd/async/main.go @@ -23,6 +23,7 @@ import ( var ( logg = logging.NewVanilla() scriptDir = path.Join("services", "registration") + menuSeparator = ": " ) func init() { @@ -70,9 +71,10 @@ func main() { pfp := path.Join(scriptDir, "pp.csv") cfg := engine.Config{ - Root: "root", - OutputSize: uint32(size), - FlagCount: uint32(128), + Root: "root", + OutputSize: uint32(size), + FlagCount: uint32(128), + MenuSeparator: menuSeparator, } if engineDebug { diff --git a/cmd/http/main.go b/cmd/http/main.go index 96e2688..6ddfded 100644 --- a/cmd/http/main.go +++ b/cmd/http/main.go @@ -26,6 +26,7 @@ import ( var ( logg = logging.NewVanilla() scriptDir = path.Join("services", "registration") + menuSeparator = ": " ) func init() { @@ -58,9 +59,10 @@ func main() { pfp := path.Join(scriptDir, "pp.csv") cfg := engine.Config{ - Root: "root", - OutputSize: uint32(size), - FlagCount: uint32(128), + Root: "root", + OutputSize: uint32(size), + FlagCount: uint32(128), + MenuSeparator: menuSeparator, } if engineDebug { diff --git a/cmd/main.go b/cmd/main.go index 9599eb7..4fd084f 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -18,8 +18,9 @@ import ( ) var ( - logg = logging.NewVanilla() - scriptDir = path.Join("services", "registration") + logg = logging.NewVanilla() + scriptDir = path.Join("services", "registration") + menuSeparator = ": " ) func init() { @@ -49,10 +50,11 @@ func main() { pfp := path.Join(scriptDir, "pp.csv") cfg := engine.Config{ - Root: "root", - SessionId: sessionId, - OutputSize: uint32(size), - FlagCount: uint32(128), + Root: "root", + SessionId: sessionId, + OutputSize: uint32(size), + FlagCount: uint32(128), + MenuSeparator: menuSeparator, } resourceDir := scriptDir diff --git a/common/pin.go b/common/pin.go new file mode 100644 index 0000000..6db9d15 --- /dev/null +++ b/common/pin.go @@ -0,0 +1,33 @@ +package common + +import ( + "regexp" + + "golang.org/x/crypto/bcrypt" +) + +// Define the regex pattern as a constant +const ( + pinPattern = `^\d{4}$` +) + +// checks whether the given input is a 4 digit number +func IsValidPIN(pin string) bool { + match, _ := regexp.MatchString(pinPattern, pin) + return match +} + +// HashPIN uses bcrypt with 8 salt rounds to hash the PIN +func HashPIN(pin string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(pin), 8) + if err != nil { + return "", err + } + return string(hash), nil +} + +// VerifyPIN compareS the hashed PIN with the plaintext PIN +func VerifyPIN(hashedPIN, pin string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hashedPIN), []byte(pin)) + return err == nil +} diff --git a/common/pin_test.go b/common/pin_test.go new file mode 100644 index 0000000..154ab06 --- /dev/null +++ b/common/pin_test.go @@ -0,0 +1,173 @@ +package common + +import ( + "testing" + + "golang.org/x/crypto/bcrypt" +) + +func TestIsValidPIN(t *testing.T) { + tests := []struct { + name string + pin string + expected bool + }{ + { + name: "Valid PIN with 4 digits", + pin: "1234", + expected: true, + }, + { + name: "Valid PIN with leading zeros", + pin: "0001", + expected: true, + }, + { + name: "Invalid PIN with less than 4 digits", + pin: "123", + expected: false, + }, + { + name: "Invalid PIN with more than 4 digits", + pin: "12345", + expected: false, + }, + { + name: "Invalid PIN with letters", + pin: "abcd", + expected: false, + }, + { + name: "Invalid PIN with special characters", + pin: "12@#", + expected: false, + }, + { + name: "Empty PIN", + pin: "", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := IsValidPIN(tt.pin) + if actual != tt.expected { + t.Errorf("IsValidPIN(%q) = %v; expected %v", tt.pin, actual, tt.expected) + } + }) + } +} + +func TestHashPIN(t *testing.T) { + tests := []struct { + name string + pin string + }{ + { + name: "Valid PIN with 4 digits", + pin: "1234", + }, + { + name: "Valid PIN with leading zeros", + pin: "0001", + }, + { + name: "Empty PIN", + pin: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hashedPIN, err := HashPIN(tt.pin) + if err != nil { + t.Errorf("HashPIN(%q) returned an error: %v", tt.pin, err) + return + } + + if hashedPIN == "" { + t.Errorf("HashPIN(%q) returned an empty hash", tt.pin) + } + + // Ensure the hash can be verified with bcrypt + err = bcrypt.CompareHashAndPassword([]byte(hashedPIN), []byte(tt.pin)) + if tt.pin != "" && err != nil { + t.Errorf("HashPIN(%q) produced a hash that does not match: %v", tt.pin, err) + } + }) + } +} + +func TestVerifyMigratedHashPin(t *testing.T) { + tests := []struct { + pin string + hash string + }{ + { + pin: "1234", + hash: "$2b$08$dTvIGxCCysJtdvrSnaLStuylPoOS/ZLYYkxvTeR5QmTFY3TSvPQC6", + }, + } + + for _, tt := range tests { + t.Run(tt.pin, func(t *testing.T) { + ok := VerifyPIN(tt.hash, tt.pin) + if !ok { + t.Errorf("VerifyPIN could not verify migrated PIN: %v", tt.pin) + } + }) + } +} + +func TestVerifyPIN(t *testing.T) { + tests := []struct { + name string + pin string + hashedPIN string + shouldPass bool + }{ + { + name: "Valid PIN verification", + pin: "1234", + hashedPIN: hashPINHelper("1234"), + shouldPass: true, + }, + { + name: "Invalid PIN verification with incorrect PIN", + pin: "5678", + hashedPIN: hashPINHelper("1234"), + shouldPass: false, + }, + { + name: "Invalid PIN verification with empty PIN", + pin: "", + hashedPIN: hashPINHelper("1234"), + shouldPass: false, + }, + { + name: "Invalid PIN verification with invalid hash", + pin: "1234", + hashedPIN: "invalidhash", + shouldPass: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := VerifyPIN(tt.hashedPIN, tt.pin) + if result != tt.shouldPass { + t.Errorf("VerifyPIN(%q, %q) = %v; expected %v", tt.hashedPIN, tt.pin, result, tt.shouldPass) + } + }) + } +} + +// Helper function to hash a PIN for testing purposes +func hashPINHelper(pin string) string { + hashedPIN, err := HashPIN(pin) + if err != nil { + panic("Failed to hash PIN for test setup: " + err.Error()) + } + return hashedPIN +} diff --git a/common/storage.go b/common/storage.go index dff4774..d37bce3 100644 --- a/common/storage.go +++ b/common/storage.go @@ -8,14 +8,15 @@ import ( "git.defalsify.org/vise.git/resource" "git.defalsify.org/vise.git/persist" "git.grassecon.net/urdt/ussd/internal/storage" + dbstorage "git.grassecon.net/urdt/ussd/internal/storage/db" ) func StoreToDb(store *UserDataStore) db.Db { return store.Db } -func StoreToPrefixDb(store *UserDataStore, pfx []byte) storage.PrefixDb { - return storage.NewSubPrefixDb(store.Db, pfx) +func StoreToPrefixDb(store *UserDataStore, pfx []byte) dbstorage.PrefixDb { + return dbstorage.NewSubPrefixDb(store.Db, pfx) } type StorageServices interface { diff --git a/common/transfer_statements.go b/common/transfer_statements.go index 4e6f66b..e97437f 100644 --- a/common/transfer_statements.go +++ b/common/transfer_statements.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "git.grassecon.net/urdt/ussd/internal/storage" + dbstorage "git.grassecon.net/urdt/ussd/internal/storage/db" dataserviceapi "github.com/grassrootseconomics/ussd-data-service/pkg/api" ) @@ -56,7 +56,7 @@ func ProcessTransfers(transfers []dataserviceapi.Last10TxResponse) TransferMetad // GetTransferData retrieves and matches transfer data // returns a formatted string of the full transaction/statement -func GetTransferData(ctx context.Context, db storage.PrefixDb, publicKey string, index int) (string, error) { +func GetTransferData(ctx context.Context, db dbstorage.PrefixDb, publicKey string, index int) (string, error) { keys := []DataTyp{DATA_TX_SENDERS, DATA_TX_RECIPIENTS, DATA_TX_VALUES, DATA_TX_ADDRESSES, DATA_TX_HASHES, DATA_TX_DATES, DATA_TX_SYMBOLS} data := make(map[DataTyp]string) @@ -84,18 +84,18 @@ func GetTransferData(ctx context.Context, db storage.PrefixDb, publicKey string, // Adjust for 0-based indexing i := index - 1 - transactionType := "received" - party := fmt.Sprintf("from: %s", strings.TrimSpace(senders[i])) + transactionType := "Received" + party := fmt.Sprintf("From: %s", strings.TrimSpace(senders[i])) if strings.TrimSpace(senders[i]) == publicKey { - transactionType = "sent" - party = fmt.Sprintf("to: %s", strings.TrimSpace(recipients[i])) + transactionType = "Sent" + party = fmt.Sprintf("To: %s", strings.TrimSpace(recipients[i])) } formattedDate := formatDate(strings.TrimSpace(dates[i])) // Build the full transaction detail detail := fmt.Sprintf( - "%s %s %s\n%s\ncontract address: %s\ntxhash: %s\ndate: %s", + "%s %s %s\n%s\nContract address: %s\nTxhash: %s\nDate: %s", transactionType, strings.TrimSpace(values[i]), strings.TrimSpace(syms[i]), diff --git a/common/vouchers.go b/common/vouchers.go index 6cff91d..5dbdb71 100644 --- a/common/vouchers.go +++ b/common/vouchers.go @@ -6,7 +6,7 @@ import ( "math/big" "strings" - "git.grassecon.net/urdt/ussd/internal/storage" + dbstorage "git.grassecon.net/urdt/ussd/internal/storage/db" dataserviceapi "github.com/grassrootseconomics/ussd-data-service/pkg/api" ) @@ -63,7 +63,7 @@ func ScaleDownBalance(balance, decimals string) string { } // GetVoucherData retrieves and matches voucher data -func GetVoucherData(ctx context.Context, db storage.PrefixDb, input string) (*dataserviceapi.TokenHoldings, error) { +func GetVoucherData(ctx context.Context, db dbstorage.PrefixDb, input string) (*dataserviceapi.TokenHoldings, error) { keys := []DataTyp{DATA_VOUCHER_SYMBOLS, DATA_VOUCHER_BALANCES, DATA_VOUCHER_DECIMALS, DATA_VOUCHER_ADDRESSES} data := make(map[DataTyp]string) diff --git a/common/vouchers_test.go b/common/vouchers_test.go index ba6cd60..8b04e4a 100644 --- a/common/vouchers_test.go +++ b/common/vouchers_test.go @@ -10,7 +10,7 @@ import ( visedb "git.defalsify.org/vise.git/db" memdb "git.defalsify.org/vise.git/db/mem" - "git.grassecon.net/urdt/ussd/internal/storage" + dbstorage "git.grassecon.net/urdt/ussd/internal/storage/db" dataserviceapi "github.com/grassrootseconomics/ussd-data-service/pkg/api" ) @@ -86,7 +86,7 @@ func TestGetVoucherData(t *testing.T) { } prefix := ToBytes(visedb.DATATYPE_USERDATA) - spdb := storage.NewSubPrefixDb(db, prefix) + spdb := dbstorage.NewSubPrefixDb(db, prefix) // Test voucher data mockData := map[DataTyp][]byte{ diff --git a/debug/db_debug.go b/debug/db_debug.go index ed2dd66..05a238b 100644 --- a/debug/db_debug.go +++ b/debug/db_debug.go @@ -11,13 +11,9 @@ import ( func init() { DebugCap |= 1 dbTypStr[db.DATATYPE_STATE] = "internal state" - dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_ACCOUNT] = "account" - dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_ACCOUNT_CREATED] = "account created" dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_TRACKING_ID] = "tracking id" dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_PUBLIC_KEY] = "public key" - dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_CUSTODIAL_ID] = "custodial id" dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_ACCOUNT_PIN] = "account pin" - dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_ACCOUNT_STATUS] = "account status" dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_FIRST_NAME] = "first name" dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_FAMILY_NAME] = "family name" dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_YOB] = "year of birth" diff --git a/devtools/store/main.go b/devtools/store/main.go index 9262703..8bd4d16 100644 --- a/devtools/store/main.go +++ b/devtools/store/main.go @@ -11,6 +11,7 @@ import ( "git.grassecon.net/urdt/ussd/initializers" "git.grassecon.net/urdt/ussd/internal/storage" "git.grassecon.net/urdt/ussd/debug" + "git.defalsify.org/vise.git/db" "git.defalsify.org/vise.git/logging" ) @@ -47,13 +48,14 @@ func main() { store, err := menuStorageService.GetUserdataDb(ctx) if err != nil { - fmt.Fprintf(os.Stderr, err.Error()) + fmt.Fprintf(os.Stderr, "get userdata db: %v\n", err.Error()) os.Exit(1) } + store.SetPrefix(db.DATATYPE_USERDATA) d, err := store.Dump(ctx, []byte(sessionId)) if err != nil { - fmt.Fprintf(os.Stderr, err.Error()) + fmt.Fprintf(os.Stderr, "store dump fail: %v\n", err.Error()) os.Exit(1) } @@ -67,7 +69,7 @@ func main() { fmt.Fprintf(os.Stderr, err.Error()) os.Exit(1) } - fmt.Printf("%vValue: %v\n\n", o, v) + fmt.Printf("%vValue: %v\n\n", o, string(v)) } err = store.Close() diff --git a/go.mod b/go.mod index e1b7ddb..41c6700 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module git.grassecon.net/urdt/ussd go 1.23.0 require ( - git.defalsify.org/vise.git v0.2.1-0.20241212145627-683015d4df80 + git.defalsify.org/vise.git v0.2.3-0.20250103172917-3e190a44568d github.com/alecthomas/assert/v2 v2.2.2 github.com/gofrs/uuid v4.4.0+incompatible github.com/grassrootseconomics/eth-custodial v1.3.0-beta @@ -11,6 +11,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/peteole/testdata-loader v0.3.0 github.com/stretchr/testify v1.9.0 + golang.org/x/crypto v0.27.0 gopkg.in/leonelquinteros/gotext.v1 v1.3.1 ) @@ -32,7 +33,6 @@ require ( github.com/rogpeppe/go-internal v1.13.1 // 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 index ef7b782..6bef621 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.defalsify.org/vise.git v0.2.1-0.20241212145627-683015d4df80 h1:GYUVXRUtMpA40T4COeAduoay6CIgXjD5cfDYZOTFIKw= -git.defalsify.org/vise.git v0.2.1-0.20241212145627-683015d4df80/go.mod h1:jyBMe1qTYUz3mmuoC9JQ/TvFeW0vTanCUcPu3H8p4Ck= +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= 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= diff --git a/internal/handlers/handlerservice.go b/internal/handlers/handlerservice.go index a14cf59..1da28c3 100644 --- a/internal/handlers/handlerservice.go +++ b/internal/handlers/handlerservice.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "strings" "git.defalsify.org/vise.git/asm" "git.defalsify.org/vise.git/db" @@ -64,7 +65,11 @@ func (ls *LocalHandlerService) SetDataStore(db *db.Db) { } func (ls *LocalHandlerService) GetHandler(accountService remote.AccountServiceInterface) (*ussd.Handlers, error) { - ussdHandlers, err := ussd.NewHandlers(ls.Parser, *ls.UserdataStore, ls.AdminStore, accountService) + replaceSeparatorFunc := func(input string) string { + return strings.ReplaceAll(input, ":", ls.Cfg.MenuSeparator) + } + + ussdHandlers, err := ussd.NewHandlers(ls.Parser, *ls.UserdataStore, ls.AdminStore, accountService, replaceSeparatorFunc) if err != nil { return nil, err } @@ -111,7 +116,7 @@ func (ls *LocalHandlerService) GetHandler(accountService remote.AccountServiceIn ls.DbRs.AddLocalFunc("set_voucher", ussdHandlers.SetVoucher) ls.DbRs.AddLocalFunc("get_voucher_details", ussdHandlers.GetVoucherDetails) ls.DbRs.AddLocalFunc("reset_valid_pin", ussdHandlers.ResetValidPin) - ls.DbRs.AddLocalFunc("check_pin_mismatch", ussdHandlers.CheckPinMisMatch) + ls.DbRs.AddLocalFunc("check_pin_mismatch", ussdHandlers.CheckBlockedNumPinMisMatch) ls.DbRs.AddLocalFunc("validate_blocked_number", ussdHandlers.ValidateBlockedNumber) ls.DbRs.AddLocalFunc("retrieve_blocked_number", ussdHandlers.RetrieveBlockedNumber) ls.DbRs.AddLocalFunc("reset_unregistered_number", ussdHandlers.ResetUnregisteredNumber) diff --git a/internal/handlers/ussd/menuhandler.go b/internal/handlers/ussd/menuhandler.go index 0b8ea64..095d77b 100644 --- a/internal/handlers/ussd/menuhandler.go +++ b/internal/handlers/ussd/menuhandler.go @@ -5,7 +5,6 @@ import ( "context" "fmt" "path" - "regexp" "strconv" "strings" @@ -24,27 +23,16 @@ import ( "git.grassecon.net/urdt/ussd/remote" "gopkg.in/leonelquinteros/gotext.v1" - "git.grassecon.net/urdt/ussd/internal/storage" + dbstorage "git.grassecon.net/urdt/ussd/internal/storage/db" dataserviceapi "github.com/grassrootseconomics/ussd-data-service/pkg/api" ) var ( - logg = logging.NewVanilla().WithDomain("ussdmenuhandler") + logg = logging.NewVanilla().WithDomain("ussdmenuhandler").WithContextKey("session-id") scriptDir = path.Join("services", "registration") translationDir = path.Join(scriptDir, "locale") ) -// Define the regex patterns as constants -const ( - pinPattern = `^\d{4}$` -) - -// isValidPIN checks whether the given input is a 4 digit number -func isValidPIN(pin string) bool { - match, _ := regexp.MatchString(pinPattern, pin) - return match -} - // FlagManager handles centralized flag management type FlagManager struct { parser *asm.FlagParser @@ -69,18 +57,20 @@ func (fm *FlagManager) GetFlag(label string) (uint32, error) { } type Handlers struct { - pe *persist.Persister - st *state.State - ca cache.Memory - userdataStore common.DataStore - adminstore *utils.AdminStore - flagManager *asm.FlagParser - accountService remote.AccountServiceInterface - prefixDb storage.PrefixDb - profile *models.Profile + pe *persist.Persister + st *state.State + ca cache.Memory + userdataStore common.DataStore + adminstore *utils.AdminStore + flagManager *asm.FlagParser + accountService remote.AccountServiceInterface + prefixDb dbstorage.PrefixDb + profile *models.Profile + ReplaceSeparatorFunc func(string) string } -func NewHandlers(appFlags *asm.FlagParser, userdataStore db.Db, adminstore *utils.AdminStore, accountService remote.AccountServiceInterface) (*Handlers, error) { +// NewHandlers creates a new instance of the Handlers struct with the provided dependencies. +func NewHandlers(appFlags *asm.FlagParser, userdataStore db.Db, adminstore *utils.AdminStore, accountService remote.AccountServiceInterface, replaceSeparatorFunc func(string) string) (*Handlers, error) { if userdataStore == nil { return nil, fmt.Errorf("cannot create handler with nil userdata store") } @@ -90,19 +80,21 @@ func NewHandlers(appFlags *asm.FlagParser, userdataStore db.Db, adminstore *util // Instantiate the SubPrefixDb with "DATATYPE_USERDATA" prefix prefix := common.ToBytes(db.DATATYPE_USERDATA) - prefixDb := storage.NewSubPrefixDb(userdataStore, prefix) + prefixDb := dbstorage.NewSubPrefixDb(userdataStore, prefix) h := &Handlers{ - userdataStore: userDb, - flagManager: appFlags, - adminstore: adminstore, - accountService: accountService, - prefixDb: prefixDb, - profile: &models.Profile{Max: 6}, + userdataStore: userDb, + flagManager: appFlags, + adminstore: adminstore, + accountService: accountService, + prefixDb: prefixDb, + profile: &models.Profile{Max: 6}, + ReplaceSeparatorFunc: replaceSeparatorFunc, } return h, nil } +// WithPersister sets persister instance to the handlers. func (h *Handlers) WithPersister(pe *persist.Persister) *Handlers { if h.pe != nil { panic("persister already set") @@ -111,6 +103,7 @@ func (h *Handlers) WithPersister(pe *persist.Persister) *Handlers { return h } +// Init initializes the handler for a new session. func (h *Handlers) Init(ctx context.Context, sym string, input []byte) (resource.Result, error) { var r resource.Result if h.pe == nil { @@ -124,9 +117,17 @@ func (h *Handlers) Init(ctx context.Context, sym string, input []byte) (resource h.st = h.pe.GetState() h.ca = h.pe.GetMemory() - sessionId, _ := ctx.Value("SessionId").(string) - flag_admin_privilege, _ := h.flagManager.GetFlag("flag_admin_privilege") + if len(input) == 0 { + // move to the top node + h.st.Code = []byte{} + } + sessionId, ok := ctx.Value("SessionId").(string) + if ok { + context.WithValue(ctx, "session-id", sessionId) + } + + flag_admin_privilege, _ := h.flagManager.GetFlag("flag_admin_privilege") isAdmin, _ := h.adminstore.IsAdmin(sessionId) if isAdmin { @@ -149,7 +150,7 @@ func (h *Handlers) Exit() { h.pe = nil } -// SetLanguage sets the language across the menu +// SetLanguage sets the language across the menu. func (h *Handlers) SetLanguage(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result @@ -173,6 +174,7 @@ func (h *Handlers) SetLanguage(ctx context.Context, sym string, input []byte) (r 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 *Handlers) 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) @@ -205,9 +207,9 @@ func (h *Handlers) createAccountNoExist(ctx context.Context, sessionId string, r return nil } -// CreateAccount checks if any account exists on the JSON data file, and if not +// 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 +// sets the default values and flags. func (h *Handlers) CreateAccount(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result var err error @@ -231,19 +233,22 @@ func (h *Handlers) CreateAccount(ctx context.Context, sym string, input []byte) return res, nil } -func (h *Handlers) CheckPinMisMatch(ctx context.Context, sym string, input []byte) (resource.Result, error) { +// CheckBlockedNumPinMisMatch checks if the provided PIN matches a temporary PIN stored for a blocked number. +func (h *Handlers) 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) @@ -257,6 +262,7 @@ func (h *Handlers) CheckPinMisMatch(ctx context.Context, sym string, input []byt return res, nil } +// VerifyNewPin checks if a new PIN meets the required format criteria. func (h *Handlers) VerifyNewPin(ctx context.Context, sym string, input []byte) (resource.Result, error) { res := resource.Result{} _, ok := ctx.Value("SessionId").(string) @@ -265,8 +271,8 @@ func (h *Handlers) VerifyNewPin(ctx context.Context, sym string, input []byte) ( } flag_valid_pin, _ := h.flagManager.GetFlag("flag_valid_pin") pinInput := string(input) - // Validate that the PIN is a 4-digit number - if isValidPIN(pinInput) { + // 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) @@ -275,9 +281,9 @@ func (h *Handlers) VerifyNewPin(ctx context.Context, sym string, input []byte) ( return res, nil } -// SaveTemporaryPin saves the valid PIN input to the DATA_TEMPORARY_VALUE +// SaveTemporaryPin saves the valid PIN input to the DATA_TEMPORARY_VALUE, // during the account creation process -// and during the change PIN process +// and during the change PIN process. func (h *Handlers) SaveTemporaryPin(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result var err error @@ -290,8 +296,8 @@ func (h *Handlers) SaveTemporaryPin(ctx context.Context, sym string, input []byt flag_incorrect_pin, _ := h.flagManager.GetFlag("flag_incorrect_pin") accountPIN := string(input) - // Validate that the PIN is a 4-digit number - if !isValidPIN(accountPIN) { + // Validate that the PIN is a 4-digit number. + if !common.IsValidPIN(accountPIN) { res.FlagSet = append(res.FlagSet, flag_incorrect_pin) return res, nil } @@ -306,6 +312,7 @@ func (h *Handlers) SaveTemporaryPin(ctx context.Context, sym string, input []byt return res, nil } +// SaveOthersTemporaryPin allows authorized users to set temporary PINs for blocked numbers. func (h *Handlers) SaveOthersTemporaryPin(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result var err error @@ -316,12 +323,14 @@ func (h *Handlers) SaveOthersTemporaryPin(ctx context.Context, sym string, input 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) @@ -331,6 +340,7 @@ func (h *Handlers) SaveOthersTemporaryPin(ctx context.Context, sym string, input return res, nil } +// ConfirmPinChange validates user's new PIN. If input matches the temporary PIN, saves it as the new account PIN. func (h *Handlers) ConfirmPinChange(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result sessionId, ok := ctx.Value("SessionId").(string) @@ -349,10 +359,20 @@ func (h *Handlers) ConfirmPinChange(ctx context.Context, sym string, input []byt res.FlagReset = append(res.FlagReset, flag_pin_mismatch) } else { res.FlagSet = append(res.FlagSet, flag_pin_mismatch) + return res, nil } - err = store.WriteEntry(ctx, sessionId, common.DATA_ACCOUNT_PIN, []byte(temporaryPin)) + + // Hash the PIN + hashedPIN, err := common.HashPIN(string(temporaryPin)) if err != nil { - logg.ErrorCtxf(ctx, "failed to write temporaryPin entry with", "key", common.DATA_ACCOUNT_PIN, "value", temporaryPin, "error", err) + 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 @@ -360,7 +380,7 @@ func (h *Handlers) ConfirmPinChange(ctx context.Context, sym string, input []byt // 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 +// to access the main menu. func (h *Handlers) VerifyCreatePin(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result @@ -384,18 +404,26 @@ func (h *Handlers) VerifyCreatePin(ctx context.Context, sym string, input []byte res.FlagSet = append(res.FlagSet, flag_pin_set) } else { res.FlagSet = []uint32{flag_pin_mismatch} + return res, nil } - err = store.WriteEntry(ctx, sessionId, common.DATA_ACCOUNT_PIN, []byte(temporaryPin)) + // Hash the PIN + hashedPIN, err := common.HashPIN(string(temporaryPin)) if err != nil { - logg.ErrorCtxf(ctx, "failed to write temporaryPin entry with", "key", common.DATA_ACCOUNT_PIN, "value", temporaryPin, "error", err) + 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 } -// codeFromCtx retrieves language codes from the context that can be used for handling translations +// 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 { @@ -702,7 +730,7 @@ func (h *Handlers) Authorize(ctx context.Context, sym string, input []byte) (res return res, err } if len(input) == 4 { - if bytes.Equal(input, AccountPin) { + 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) @@ -729,7 +757,7 @@ func (h *Handlers) ResetIncorrectPin(ctx context.Context, sym string, input []by return res, nil } -// Setback sets the flag_back_set flag when the navigation is back +// Setback sets the flag_back_set flag when the navigation is back. func (h *Handlers) SetBack(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result //TODO: @@ -742,7 +770,7 @@ func (h *Handlers) SetBack(ctx context.Context, sym string, input []byte) (resou } // CheckAccountStatus queries the API using the TrackingId and sets flags -// based on the account status +// based on the account status. func (h *Handlers) CheckAccountStatus(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result @@ -782,7 +810,7 @@ func (h *Handlers) CheckAccountStatus(ctx context.Context, sym string, input []b return res, nil } -// Quit displays the Thank you message and exits the menu +// Quit displays the Thank you message and exits the menu. func (h *Handlers) Quit(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result @@ -797,7 +825,7 @@ func (h *Handlers) Quit(ctx context.Context, sym string, input []byte) (resource return res, nil } -// QuitWithHelp displays helpline information then exits the menu +// QuitWithHelp displays helpline information then exits the menu. func (h *Handlers) QuitWithHelp(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result @@ -812,7 +840,7 @@ func (h *Handlers) QuitWithHelp(ctx context.Context, sym string, input []byte) ( return res, nil } -// VerifyYob verifies the length of the given input +// VerifyYob verifies the length of the given input. func (h *Handlers) VerifyYob(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result var err error @@ -834,7 +862,7 @@ func (h *Handlers) VerifyYob(ctx context.Context, sym string, input []byte) (res return res, nil } -// ResetIncorrectYob resets the incorrect date format flag after a new attempt +// ResetIncorrectYob resets the incorrect date format flag after a new attempt. func (h *Handlers) ResetIncorrectYob(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result @@ -844,7 +872,7 @@ func (h *Handlers) ResetIncorrectYob(ctx context.Context, sym string, input []by } // CheckBalance retrieves the balance of the active voucher and sets -// the balance as the result content +// the balance as the result content. func (h *Handlers) CheckBalance(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result var err error @@ -894,9 +922,12 @@ func (h *Handlers) CheckBalance(ctx context.Context, sym string, input []byte) ( return res, nil } +// FetchCommunityBalance retrieves and displays the balance for community accounts in user's preferred language. func (h *Handlers) 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: @@ -905,6 +936,10 @@ func (h *Handlers) FetchCommunityBalance(ctx context.Context, sym string, input 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 *Handlers) ResetOthersPin(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result store := h.userdataStore @@ -922,7 +957,15 @@ func (h *Handlers) ResetOthersPin(ctx context.Context, sym string, input []byte) logg.ErrorCtxf(ctx, "failed to read temporaryPin entry with", "key", common.DATA_TEMPORARY_VALUE, "error", err) return res, err } - err = store.WriteEntry(ctx, string(blockedPhonenumber), common.DATA_ACCOUNT_PIN, []byte(temporaryPin)) + + // 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 } @@ -930,6 +973,8 @@ func (h *Handlers) ResetOthersPin(ctx context.Context, sym string, input []byte) 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 *Handlers) ResetUnregisteredNumber(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result flag_unregistered_number, _ := h.flagManager.GetFlag("flag_unregistered_number") @@ -937,6 +982,8 @@ func (h *Handlers) ResetUnregisteredNumber(ctx context.Context, sym string, inpu 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 *Handlers) ValidateBlockedNumber(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result var err error @@ -1065,7 +1112,7 @@ func (h *Handlers) ValidateRecipient(ctx context.Context, sym string, input []by } // TransactionReset resets the previous transaction data (Recipient and Amount) -// as well as the invalid flags +// as well as the invalid flags. func (h *Handlers) TransactionReset(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result var err error @@ -1118,7 +1165,7 @@ func (h *Handlers) InviteValidRecipient(ctx context.Context, sym string, input [ return res, nil } -// ResetTransactionAmount resets the transaction amount and invalid flag +// ResetTransactionAmount resets the transaction amount and invalid flag. func (h *Handlers) ResetTransactionAmount(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result var err error @@ -1248,7 +1295,7 @@ func (h *Handlers) RetrieveBlockedNumber(ctx context.Context, sym string, input return res, nil } -// GetSender returns the sessionId (phoneNumber) +// GetSender returns the sessionId (phoneNumber). func (h *Handlers) GetSender(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result @@ -1262,7 +1309,7 @@ func (h *Handlers) GetSender(ctx context.Context, sym string, input []byte) (res return res, nil } -// GetAmount retrieves the amount from teh Gdbm Db +// GetAmount retrieves the amount from teh Gdbm Db. func (h *Handlers) GetAmount(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result @@ -1286,7 +1333,7 @@ func (h *Handlers) GetAmount(ctx context.Context, sym string, input []byte) (res return res, nil } -// InitiateTransaction calls the TokenTransfer and returns a confirmation based on the result +// InitiateTransaction calls the TokenTransfer and returns a confirmation based on the result. func (h *Handlers) InitiateTransaction(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result var err error @@ -1336,9 +1383,12 @@ func (h *Handlers) InitiateTransaction(ctx context.Context, sym string, input [] 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 *Handlers) 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") @@ -1355,6 +1405,17 @@ func (h *Handlers) GetCurrentProfileInfo(ctx context.Context, sym string, input 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] @@ -1371,7 +1432,7 @@ func (h *Handlers) GetCurrentProfileInfo(ctx context.Context, sym string, input profileInfo, err = store.ReadEntry(ctx, sessionId, common.DATA_FIRST_NAME) if err != nil { if db.IsNotFound(err) { - res.Content = "Not provided" + res.Content = defaultValue break } logg.ErrorCtxf(ctx, "Failed to read first name entry with", "key", "error", common.DATA_FIRST_NAME, err) @@ -1383,7 +1444,7 @@ func (h *Handlers) GetCurrentProfileInfo(ctx context.Context, sym string, input profileInfo, err = store.ReadEntry(ctx, sessionId, common.DATA_FAMILY_NAME) if err != nil { if db.IsNotFound(err) { - res.Content = "Not provided" + res.Content = defaultValue break } logg.ErrorCtxf(ctx, "Failed to read family name entry with", "key", "error", common.DATA_FAMILY_NAME, err) @@ -1396,7 +1457,7 @@ func (h *Handlers) GetCurrentProfileInfo(ctx context.Context, sym string, input profileInfo, err = store.ReadEntry(ctx, sessionId, common.DATA_GENDER) if err != nil { if db.IsNotFound(err) { - res.Content = "Not provided" + res.Content = defaultValue break } logg.ErrorCtxf(ctx, "Failed to read gender entry with", "key", "error", common.DATA_GENDER, err) @@ -1408,7 +1469,7 @@ func (h *Handlers) GetCurrentProfileInfo(ctx context.Context, sym string, input profileInfo, err = store.ReadEntry(ctx, sessionId, common.DATA_YOB) if err != nil { if db.IsNotFound(err) { - res.Content = "Not provided" + res.Content = defaultValue break } logg.ErrorCtxf(ctx, "Failed to read year of birth(yob) entry with", "key", "error", common.DATA_YOB, err) @@ -1420,7 +1481,7 @@ func (h *Handlers) GetCurrentProfileInfo(ctx context.Context, sym string, input profileInfo, err = store.ReadEntry(ctx, sessionId, common.DATA_LOCATION) if err != nil { if db.IsNotFound(err) { - res.Content = "Not provided" + res.Content = defaultValue break } logg.ErrorCtxf(ctx, "Failed to read location entry with", "key", "error", common.DATA_LOCATION, err) @@ -1432,7 +1493,7 @@ func (h *Handlers) GetCurrentProfileInfo(ctx context.Context, sym string, input profileInfo, err = store.ReadEntry(ctx, sessionId, common.DATA_OFFERINGS) if err != nil { if db.IsNotFound(err) { - res.Content = "Not provided" + res.Content = defaultValue break } logg.ErrorCtxf(ctx, "Failed to read offerings entry with", "key", "error", common.DATA_OFFERINGS, err) @@ -1447,6 +1508,7 @@ func (h *Handlers) GetCurrentProfileInfo(ctx context.Context, sym string, input return res, nil } +// GetProfileInfo provides a comprehensive view of a user's profile. func (h *Handlers) GetProfileInfo(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result var defaultValue string @@ -1515,7 +1577,7 @@ func (h *Handlers) GetProfileInfo(ctx context.Context, sym string, input []byte) } // SetDefaultVoucher retrieves the current vouchers -// and sets the first as the default voucher, if no active voucher is set +// and sets the first as the default voucher, if no active voucher is set. func (h *Handlers) SetDefaultVoucher(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result var err error @@ -1600,7 +1662,7 @@ func (h *Handlers) SetDefaultVoucher(ctx context.Context, sym string, input []by } // CheckVouchers retrieves the token holdings from the API using the "PublicKey" and stores -// them to gdbm +// them to gdbm. func (h *Handlers) CheckVouchers(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result sessionId, ok := ctx.Value("SessionId").(string) @@ -1672,7 +1734,7 @@ func (h *Handlers) CheckVouchers(ctx context.Context, sym string, input []byte) return res, nil } -// GetVoucherList fetches the list of vouchers and formats them +// GetVoucherList fetches the list of vouchers and formats them. func (h *Handlers) GetVoucherList(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result @@ -1683,13 +1745,15 @@ func (h *Handlers) GetVoucherList(ctx context.Context, sym string, input []byte) return res, err } - res.Content = string(voucherData) + 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 +// and displays it to the user for them to select it. func (h *Handlers) ViewVoucher(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result sessionId, ok := ctx.Value("SessionId").(string) @@ -1730,7 +1794,7 @@ func (h *Handlers) ViewVoucher(ctx context.Context, sym string, input []byte) (r return res, nil } -// SetVoucher retrieves the temp voucher data and sets it as the active data +// SetVoucher retrieves the temp voucher data and sets it as the active data. func (h *Handlers) SetVoucher(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result @@ -1756,7 +1820,7 @@ func (h *Handlers) SetVoucher(ctx context.Context, sym string, input []byte) (re return res, nil } -// GetVoucherDetails retrieves the voucher details +// GetVoucherDetails retrieves the voucher details. func (h *Handlers) GetVoucherDetails(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result store := h.userdataStore @@ -1788,7 +1852,7 @@ func (h *Handlers) GetVoucherDetails(ctx context.Context, sym string, input []by return res, nil } -// CheckTransactions retrieves the transactions from the API using the "PublicKey" and stores to prefixDb +// CheckTransactions retrieves the transactions from the API using the "PublicKey" and stores to prefixDb. func (h *Handlers) CheckTransactions(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result sessionId, ok := ctx.Value("SessionId").(string) @@ -1846,13 +1910,14 @@ func (h *Handlers) CheckTransactions(ctx context.Context, sym string, input []by return res, nil } -// GetTransactionsList fetches the list of transactions and formats them +// GetTransactionsList fetches the list of transactions and formats them. func (h *Handlers) 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 { @@ -1895,12 +1960,14 @@ func (h *Handlers) GetTransactionsList(ctx context.Context, sym string, input [] value := strings.TrimSpace(values[i]) date := strings.Split(strings.TrimSpace(dates[i]), " ")[0] - status := "received" + status := "Received" if sender == string(publicKey) { - status = "sent" + status = "Sent" } - formattedTransactions = append(formattedTransactions, fmt.Sprintf("%d:%s %s %s %s", i+1, status, value, sym, date)) + // 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") @@ -1909,7 +1976,7 @@ func (h *Handlers) GetTransactionsList(ctx context.Context, sym string, input [] } // ViewTransactionStatement retrieves the transaction statement -// and displays it to the user +// and displays it to the user. func (h *Handlers) ViewTransactionStatement(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result sessionId, ok := ctx.Value("SessionId").(string) @@ -1957,6 +2024,7 @@ func (h *Handlers) ViewTransactionStatement(ctx context.Context, sym string, inp return res, nil } +// handles bulk updates of profile information. func (h *Handlers) insertProfileItems(ctx context.Context, sessionId string, res *resource.Result) error { var err error store := h.userdataStore @@ -1979,21 +2047,22 @@ func (h *Handlers) insertProfileItems(ctx context.Context, sessionId string, res for index, profileItem := range h.profile.ProfileItems { // Ensure the profileItem is not "0"(is set) if profileItem != "0" { - 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 - } - - // Get the flag for the current index flag, _ := h.flagManager.GetFlag(profileFlagNames[index]) - res.FlagSet = append(res.FlagSet, flag) + 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 +// UpdateAllProfileItems is used to persist all the new profile information and setup the required profile flags. func (h *Handlers) UpdateAllProfileItems(ctx context.Context, sym string, input []byte) (resource.Result, error) { var res resource.Result sessionId, ok := ctx.Value("SessionId").(string) diff --git a/internal/handlers/ussd/menuhandler_test.go b/internal/handlers/ussd/menuhandler_test.go index 25470e8..914dffc 100644 --- a/internal/handlers/ussd/menuhandler_test.go +++ b/internal/handlers/ussd/menuhandler_test.go @@ -5,15 +5,18 @@ import ( "fmt" "log" "path" + "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" - "git.grassecon.net/urdt/ussd/internal/storage" + dbstorage "git.grassecon.net/urdt/ussd/internal/storage/db" "git.grassecon.net/urdt/ussd/internal/testutil/mocks" "git.grassecon.net/urdt/ussd/internal/testutil/testservice" + "git.grassecon.net/urdt/ussd/internal/utils" "git.grassecon.net/urdt/ussd/models" "git.grassecon.net/urdt/ussd/common" @@ -32,6 +35,11 @@ var ( 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() @@ -51,14 +59,14 @@ func InitializeTestStore(t *testing.T) (context.Context, *common.UserDataStore) return ctx, store } -func InitializeTestSubPrefixDb(t *testing.T, ctx context.Context) *storage.SubPrefixDb { +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 := storage.NewSubPrefixDb(db, prefix) + spdb := dbstorage.NewSubPrefixDb(db, prefix) return spdb } @@ -67,12 +75,15 @@ func TestNewHandlers(t *testing.T) { _, store := InitializeTestStore(t) fm, err := NewFlagManager(flagsPath) - accountService := testservice.TestAccountService{} if err != nil { - t.Logf(err.Error()) + 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) + handlers, err := NewHandlers(fm.parser, store, nil, &accountService, mockReplaceSeparator) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -82,23 +93,130 @@ func TestNewHandlers(t *testing.T) { 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 + // Test case for nil UserDataStore t.Run("Nil UserDataStore", func(t *testing.T) { - handlers, err := NewHandlers(fm.parser, nil, nil, &accountService) + 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") } - if err.Error() != "cannot create handler with nil userdata store" { - t.Fatalf("expected specific error, got %v", err) + 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) @@ -929,7 +1047,14 @@ func TestAuthorize(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err = store.WriteEntry(ctx, sessionId, common.DATA_ACCOUNT_PIN, []byte(accountPIN)) + // 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) } @@ -1381,59 +1506,6 @@ func TestQuit(t *testing.T) { } } -func TestIsValidPIN(t *testing.T) { - tests := []struct { - name string - pin string - expected bool - }{ - { - name: "Valid PIN with 4 digits", - pin: "1234", - expected: true, - }, - { - name: "Valid PIN with leading zeros", - pin: "0001", - expected: true, - }, - { - name: "Invalid PIN with less than 4 digits", - pin: "123", - expected: false, - }, - { - name: "Invalid PIN with more than 4 digits", - pin: "12345", - expected: false, - }, - { - name: "Invalid PIN with letters", - pin: "abcd", - expected: false, - }, - { - name: "Invalid PIN with special characters", - pin: "12@#", - expected: false, - }, - { - name: "Empty PIN", - pin: "", - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - actual := isValidPIN(tt.pin) - if actual != tt.expected { - t.Errorf("isValidPIN(%q) = %v; expected %v", tt.pin, actual, tt.expected) - } - }) - } -} - func TestValidateAmount(t *testing.T) { fm, err := NewFlagManager(flagsPath) if err != nil { @@ -1680,7 +1752,7 @@ func TestGetProfile(t *testing.T) { result: resource.Result{ Content: fmt.Sprintf( "Name: %s\nGender: %s\nAge: %s\nLocation: %s\nYou provide: %s\n", - "John Doee", "Male", "48", "Kilifi", "Bananas", + "John Doee", "Male", "49", "Kilifi", "Bananas", ), }, }, @@ -1692,7 +1764,7 @@ func TestGetProfile(t *testing.T) { result: resource.Result{ Content: fmt.Sprintf( "Jina: %s\nJinsia: %s\nUmri: %s\nEneo: %s\nUnauza: %s\n", - "John Doee", "Male", "48", "Kilifi", "Bananas", + "John Doee", "Male", "49", "Kilifi", "Bananas", ), }, }, @@ -1704,7 +1776,7 @@ func TestGetProfile(t *testing.T) { result: resource.Result{ Content: fmt.Sprintf( "Name: %s\nGender: %s\nAge: %s\nLocation: %s\nYou provide: %s\n", - "John Doee", "Male", "48", "Kilifi", "Bananas", + "John Doee", "Male", "49", "Kilifi", "Bananas", ), }, }, @@ -1982,26 +2054,31 @@ func TestCheckVouchers(t *testing.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, + prefixDb: spdb, + ReplaceSeparatorFunc: mockReplaceSeparator, } - expectedSym := []byte("1:SRF\n2:MILO") + mockSyms := []byte("1:SRF\n2:MILO") // Put voucher sym data from the store - err := spdb.Put(ctx, common.ToBytes(common.DATA_VOUCHER_SYMBOLS), expectedSym) + 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(expectedSym)) + assert.Equal(t, res.Content, string(expectedSyms)) } func TestViewVoucher(t *testing.T) { diff --git a/internal/http/at/parse.go b/internal/http/at/parse.go new file mode 100644 index 0000000..d2696ed --- /dev/null +++ b/internal/http/at/parse.go @@ -0,0 +1,121 @@ +package at + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "git.grassecon.net/urdt/ussd/common" + "git.grassecon.net/urdt/ussd/internal/handlers" +) + +type ATRequestParser struct { + Context context.Context +} + +func (arp *ATRequestParser) GetSessionId(rq any) (string, error) { + rqv, ok := rq.(*http.Request) + if !ok { + logg.Warnf("got an invalid request", "req", rq) + return "", handlers.ErrInvalidRequest + } + + // Capture body (if any) for logging + body, err := io.ReadAll(rqv.Body) + if err != nil { + logg.Warnf("failed to read request body", "err", err) + return "", fmt.Errorf("failed to read request body: %v", err) + } + // Reset the body for further reading + rqv.Body = io.NopCloser(bytes.NewReader(body)) + + // Log the body as JSON + bodyLog := map[string]string{"body": string(body)} + logBytes, err := json.Marshal(bodyLog) + if err != nil { + logg.Warnf("failed to marshal request body", "err", err) + } else { + decodedStr := string(logBytes) + sessionId, err := extractATSessionId(decodedStr) + if err != nil { + context.WithValue(arp.Context, "at-session-id", sessionId) + } + logg.Debugf("Received request:", decodedStr) + } + + if err := rqv.ParseForm(); err != nil { + logg.Warnf("failed to parse form data", "err", err) + return "", fmt.Errorf("failed to parse form data: %v", err) + } + + phoneNumber := rqv.FormValue("phoneNumber") + if phoneNumber == "" { + return "", fmt.Errorf("no phone number found") + } + + formattedNumber, err := common.FormatPhoneNumber(phoneNumber) + if err != nil { + logg.Warnf("failed to format phone number", "err", err) + return "", fmt.Errorf("failed to format number") + } + + return formattedNumber, nil +} + +func (arp *ATRequestParser) GetInput(rq any) ([]byte, error) { + rqv, ok := rq.(*http.Request) + if !ok { + return nil, handlers.ErrInvalidRequest + } + if err := rqv.ParseForm(); err != nil { + return nil, fmt.Errorf("failed to parse form data: %v", err) + } + + text := rqv.FormValue("text") + + parts := strings.Split(text, "*") + if len(parts) == 0 { + return nil, fmt.Errorf("no input found") + } + + return []byte(parts[len(parts)-1]), nil +} + +func parseQueryParams(query string) map[string]string { + params := make(map[string]string) + + queryParams := strings.Split(query, "&") + for _, param := range queryParams { + // Split each key-value pair by '=' + parts := strings.SplitN(param, "=", 2) + if len(parts) == 2 { + params[parts[0]] = parts[1] + } + } + return params +} + +func extractATSessionId(decodedStr string) (string, error) { + var data map[string]string + err := json.Unmarshal([]byte(decodedStr), &data) + + if err != nil { + logg.Errorf("Error unmarshalling JSON: %v", err) + return "", nil + } + decodedBody, err := url.QueryUnescape(data["body"]) + if err != nil { + logg.Errorf("Error URL-decoding body: %v", err) + return "", nil + } + params := parseQueryParams(decodedBody) + + sessionId := params["sessionId"] + return sessionId, nil + +} diff --git a/internal/http/at_session_handler.go b/internal/http/at/server.go similarity index 79% rename from internal/http/at_session_handler.go rename to internal/http/at/server.go index 25da954..705ff76 100644 --- a/internal/http/at_session_handler.go +++ b/internal/http/at/server.go @@ -1,19 +1,25 @@ -package http +package at import ( "io" "net/http" + "git.defalsify.org/vise.git/logging" "git.grassecon.net/urdt/ussd/internal/handlers" + httpserver "git.grassecon.net/urdt/ussd/internal/http" +) + +var ( + logg = logging.NewVanilla().WithDomain("atserver") ) type ATSessionHandler struct { - *SessionHandler + *httpserver.SessionHandler } func NewATSessionHandler(h handlers.RequestHandler) *ATSessionHandler { return &ATSessionHandler{ - SessionHandler: ToSessionHandler(h), + SessionHandler: httpserver.ToSessionHandler(h), } } @@ -31,14 +37,14 @@ func (ash *ATSessionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) cfg.SessionId, err = rp.GetSessionId(req) if err != nil { logg.ErrorCtxf(rqs.Ctx, "", "header processing error", err) - ash.writeError(w, 400, err) + ash.WriteError(w, 400, err) return } rqs.Config = cfg rqs.Input, err = rp.GetInput(req) if err != nil { logg.ErrorCtxf(rqs.Ctx, "", "header processing error", err) - ash.writeError(w, 400, err) + ash.WriteError(w, 400, err) return } @@ -53,7 +59,7 @@ func (ash *ATSessionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) } if code != 200 { - ash.writeError(w, 500, err) + ash.WriteError(w, 500, err) return } @@ -61,13 +67,13 @@ func (ash *ATSessionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) w.Header().Set("Content-Type", "text/plain") rqs, err = ash.Output(rqs) if err != nil { - ash.writeError(w, 500, err) + ash.WriteError(w, 500, err) return } rqs, err = ash.Reset(rqs) if err != nil { - ash.writeError(w, 500, err) + ash.WriteError(w, 500, err) return } } @@ -89,4 +95,4 @@ func (ash *ATSessionHandler) Output(rqs handlers.RequestSession) (handlers.Reque _, err = rqs.Engine.Flush(rqs.Ctx, rqs.Writer) return rqs, err -} \ No newline at end of file +} diff --git a/internal/http/http_test.go b/internal/http/at/server_test.go similarity index 54% rename from internal/http/http_test.go rename to internal/http/at/server_test.go index 14bb90a..dd45c25 100644 --- a/internal/http/http_test.go +++ b/internal/http/at/server_test.go @@ -1,7 +1,6 @@ -package http +package at import ( - "bytes" "context" "errors" "io" @@ -16,16 +15,6 @@ import ( "git.grassecon.net/urdt/ussd/internal/testutil/mocks/httpmocks" ) -// invalidRequestType is a custom type to test invalid request scenarios -type invalidRequestType struct{} - -// errorReader is a helper type that always returns an error when Read is called -type errorReader struct{} - -func (e *errorReader) Read(p []byte) (n int, err error) { - return 0, errors.New("read error") -} - func TestNewATSessionHandler(t *testing.T) { mockHandler := &httpmocks.MockRequestHandler{} ash := NewATSessionHandler(mockHandler) @@ -242,208 +231,4 @@ func TestATSessionHandler_Output(t *testing.T) { } } -func TestSessionHandler_ServeHTTP(t *testing.T) { - tests := []struct { - name string - sessionID string - input []byte - parserErr error - processErr error - outputErr error - resetErr error - expectedStatus int - }{ - { - name: "Success", - sessionID: "123", - input: []byte("test input"), - expectedStatus: http.StatusOK, - }, - { - name: "Missing Session ID", - sessionID: "", - parserErr: handlers.ErrSessionMissing, - expectedStatus: http.StatusBadRequest, - }, - { - name: "Process Error", - sessionID: "123", - input: []byte("test input"), - processErr: handlers.ErrStorage, - expectedStatus: http.StatusInternalServerError, - }, - { - name: "Output Error", - sessionID: "123", - input: []byte("test input"), - outputErr: errors.New("output error"), - expectedStatus: http.StatusOK, - }, - { - name: "Reset Error", - sessionID: "123", - input: []byte("test input"), - resetErr: errors.New("reset error"), - expectedStatus: http.StatusOK, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockRequestParser := &httpmocks.MockRequestParser{ - GetSessionIdFunc: func(any) (string, error) { - return tt.sessionID, tt.parserErr - }, - GetInputFunc: func(any) ([]byte, error) { - return tt.input, nil - }, - } - - mockRequestHandler := &httpmocks.MockRequestHandler{ - ProcessFunc: func(rs handlers.RequestSession) (handlers.RequestSession, error) { - return rs, tt.processErr - }, - OutputFunc: func(rs handlers.RequestSession) (handlers.RequestSession, error) { - return rs, tt.outputErr - }, - ResetFunc: func(rs handlers.RequestSession) (handlers.RequestSession, error) { - return rs, tt.resetErr - }, - GetRequestParserFunc: func() handlers.RequestParser { - return mockRequestParser - }, - GetConfigFunc: func() engine.Config { - return engine.Config{} - }, - } - - sessionHandler := ToSessionHandler(mockRequestHandler) - - req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(tt.input)) - req.Header.Set("X-Vise-Session", tt.sessionID) - - rr := httptest.NewRecorder() - - sessionHandler.ServeHTTP(rr, req) - - if status := rr.Code; status != tt.expectedStatus { - t.Errorf("handler returned wrong status code: got %v want %v", - status, tt.expectedStatus) - } - }) - } -} - -func TestSessionHandler_writeError(t *testing.T) { - handler := &SessionHandler{} - mockWriter := &httpmocks.MockWriter{} - err := errors.New("test error") - - handler.writeError(mockWriter, http.StatusBadRequest, err) - - if mockWriter.WrittenString != "" { - t.Errorf("Expected empty body, got %s", mockWriter.WrittenString) - } -} - -func TestDefaultRequestParser_GetSessionId(t *testing.T) { - tests := []struct { - name string - request any - expectedID string - expectedError error - }{ - { - name: "Valid Session ID", - request: func() *http.Request { - req := httptest.NewRequest(http.MethodPost, "/", nil) - req.Header.Set("X-Vise-Session", "123456") - return req - }(), - expectedID: "123456", - expectedError: nil, - }, - { - name: "Missing Session ID", - request: httptest.NewRequest(http.MethodPost, "/", nil), - expectedID: "", - expectedError: handlers.ErrSessionMissing, - }, - { - name: "Invalid Request Type", - request: invalidRequestType{}, - expectedID: "", - expectedError: handlers.ErrInvalidRequest, - }, - } - - parser := &DefaultRequestParser{} - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - id, err := parser.GetSessionId(tt.request) - - if id != tt.expectedID { - t.Errorf("Expected session ID %s, got %s", tt.expectedID, id) - } - - if err != tt.expectedError { - t.Errorf("Expected error %v, got %v", tt.expectedError, err) - } - }) - } -} - -func TestDefaultRequestParser_GetInput(t *testing.T) { - tests := []struct { - name string - request any - expectedInput []byte - expectedError error - }{ - { - name: "Valid Input", - request: func() *http.Request { - return httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString("test input")) - }(), - expectedInput: []byte("test input"), - expectedError: nil, - }, - { - name: "Empty Input", - request: httptest.NewRequest(http.MethodPost, "/", nil), - expectedInput: []byte{}, - expectedError: nil, - }, - { - name: "Invalid Request Type", - request: invalidRequestType{}, - expectedInput: nil, - expectedError: handlers.ErrInvalidRequest, - }, - { - name: "Read Error", - request: func() *http.Request { - return httptest.NewRequest(http.MethodPost, "/", &errorReader{}) - }(), - expectedInput: nil, - expectedError: errors.New("read error"), - }, - } - - parser := &DefaultRequestParser{} - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - input, err := parser.GetInput(tt.request) - - if !bytes.Equal(input, tt.expectedInput) { - t.Errorf("Expected input %s, got %s", tt.expectedInput, input) - } - - if err != tt.expectedError && (err == nil || err.Error() != tt.expectedError.Error()) { - t.Errorf("Expected error %v, got %v", tt.expectedError, err) - } - }) - } -} diff --git a/internal/http/parse.go b/internal/http/parse.go new file mode 100644 index 0000000..ec8e00b --- /dev/null +++ b/internal/http/parse.go @@ -0,0 +1,38 @@ +package http + +import ( + "io/ioutil" + "net/http" + + "git.grassecon.net/urdt/ussd/internal/handlers" +) + +type DefaultRequestParser struct { +} + +func (rp *DefaultRequestParser) GetSessionId(rq any) (string, error) { + rqv, ok := rq.(*http.Request) + if !ok { + return "", handlers.ErrInvalidRequest + } + v := rqv.Header.Get("X-Vise-Session") + if v == "" { + return "", handlers.ErrSessionMissing + } + return v, nil +} + +func (rp *DefaultRequestParser) GetInput(rq any) ([]byte, error) { + rqv, ok := rq.(*http.Request) + if !ok { + return nil, handlers.ErrInvalidRequest + } + defer rqv.Body.Close() + v, err := ioutil.ReadAll(rqv.Body) + if err != nil { + return nil, err + } + return v, nil +} + + diff --git a/internal/http/server.go b/internal/http/server.go index 3ea0159..9cadfa3 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -1,7 +1,6 @@ package http import ( - "io/ioutil" "net/http" "strconv" @@ -14,35 +13,6 @@ var ( logg = logging.NewVanilla().WithDomain("httpserver") ) -type DefaultRequestParser struct { -} - - -func(rp *DefaultRequestParser) GetSessionId(rq any) (string, error) { - rqv, ok := rq.(*http.Request) - if !ok { - return "", handlers.ErrInvalidRequest - } - v := rqv.Header.Get("X-Vise-Session") - if v == "" { - return "", handlers.ErrSessionMissing - } - return v, nil -} - -func(rp *DefaultRequestParser) GetInput(rq any) ([]byte, error) { - rqv, ok := rq.(*http.Request) - if !ok { - return nil, handlers.ErrInvalidRequest - } - defer rqv.Body.Close() - v, err := ioutil.ReadAll(rqv.Body) - if err != nil { - return nil, err - } - return v, nil -} - type SessionHandler struct { handlers.RequestHandler } @@ -53,25 +23,24 @@ func ToSessionHandler(h handlers.RequestHandler) *SessionHandler { } } -func(f *SessionHandler) writeError(w http.ResponseWriter, code int, err error) { +func (f *SessionHandler) WriteError(w http.ResponseWriter, code int, err error) { s := err.Error() w.Header().Set("Content-Length", strconv.Itoa(len(s))) w.WriteHeader(code) - _, err = w.Write([]byte{}) + _, err = w.Write([]byte(s)) if err != nil { logg.Errorf("error writing error!!", "err", err, "olderr", s) w.WriteHeader(500) } - return } -func(f *SessionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { +func (f *SessionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { var code int var err error var perr error rqs := handlers.RequestSession{ - Ctx: req.Context(), + Ctx: req.Context(), Writer: w, } @@ -80,13 +49,13 @@ func(f *SessionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { cfg.SessionId, err = rp.GetSessionId(req) if err != nil { logg.ErrorCtxf(rqs.Ctx, "", "header processing error", err) - f.writeError(w, 400, err) + f.WriteError(w, 400, err) } rqs.Config = cfg rqs.Input, err = rp.GetInput(req) if err != nil { logg.ErrorCtxf(rqs.Ctx, "", "header processing error", err) - f.writeError(w, 400, err) + f.WriteError(w, 400, err) return } @@ -103,7 +72,7 @@ func(f *SessionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { } if code != 200 { - f.writeError(w, 500, err) + f.WriteError(w, 500, err) return } @@ -112,11 +81,11 @@ func(f *SessionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { rqs, err = f.Output(rqs) rqs, perr = f.Reset(rqs) if err != nil { - f.writeError(w, 500, err) + f.WriteError(w, 500, err) return } if perr != nil { - f.writeError(w, 500, perr) + f.WriteError(w, 500, perr) return } } diff --git a/internal/http/server_test.go b/internal/http/server_test.go new file mode 100644 index 0000000..a46f98e --- /dev/null +++ b/internal/http/server_test.go @@ -0,0 +1,229 @@ +package http + +import ( + "bytes" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "git.defalsify.org/vise.git/engine" + "git.grassecon.net/urdt/ussd/internal/handlers" + "git.grassecon.net/urdt/ussd/internal/testutil/mocks/httpmocks" +) + +// invalidRequestType is a custom type to test invalid request scenarios +type invalidRequestType struct{} + +// errorReader is a helper type that always returns an error when Read is called +type errorReader struct{} + +func (e *errorReader) Read(p []byte) (n int, err error) { + return 0, errors.New("read error") +} + +func TestSessionHandler_ServeHTTP(t *testing.T) { + tests := []struct { + name string + sessionID string + input []byte + parserErr error + processErr error + outputErr error + resetErr error + expectedStatus int + }{ + { + name: "Success", + sessionID: "123", + input: []byte("test input"), + expectedStatus: http.StatusOK, + }, + { + name: "Missing Session ID", + sessionID: "", + parserErr: handlers.ErrSessionMissing, + expectedStatus: http.StatusBadRequest, + }, + { + name: "Process Error", + sessionID: "123", + input: []byte("test input"), + processErr: handlers.ErrStorage, + expectedStatus: http.StatusInternalServerError, + }, + { + name: "Output Error", + sessionID: "123", + input: []byte("test input"), + outputErr: errors.New("output error"), + expectedStatus: http.StatusOK, + }, + { + name: "Reset Error", + sessionID: "123", + input: []byte("test input"), + resetErr: errors.New("reset error"), + expectedStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRequestParser := &httpmocks.MockRequestParser{ + GetSessionIdFunc: func(any) (string, error) { + return tt.sessionID, tt.parserErr + }, + GetInputFunc: func(any) ([]byte, error) { + return tt.input, nil + }, + } + + mockRequestHandler := &httpmocks.MockRequestHandler{ + ProcessFunc: func(rs handlers.RequestSession) (handlers.RequestSession, error) { + return rs, tt.processErr + }, + OutputFunc: func(rs handlers.RequestSession) (handlers.RequestSession, error) { + return rs, tt.outputErr + }, + ResetFunc: func(rs handlers.RequestSession) (handlers.RequestSession, error) { + return rs, tt.resetErr + }, + GetRequestParserFunc: func() handlers.RequestParser { + return mockRequestParser + }, + GetConfigFunc: func() engine.Config { + return engine.Config{} + }, + } + + sessionHandler := ToSessionHandler(mockRequestHandler) + + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBuffer(tt.input)) + req.Header.Set("X-Vise-Session", tt.sessionID) + + rr := httptest.NewRecorder() + + sessionHandler.ServeHTTP(rr, req) + + if status := rr.Code; status != tt.expectedStatus { + t.Errorf("handler returned wrong status code: got %v want %v", + status, tt.expectedStatus) + } + }) + } +} + +func TestSessionHandler_WriteError(t *testing.T) { + handler := &SessionHandler{} + mockWriter := &httpmocks.MockWriter{} + err := errors.New("test error") + + handler.WriteError(mockWriter, http.StatusBadRequest, err) + + if mockWriter.WrittenString != "" { + t.Errorf("Expected empty body, got %s", mockWriter.WrittenString) + } +} + +func TestDefaultRequestParser_GetSessionId(t *testing.T) { + tests := []struct { + name string + request any + expectedID string + expectedError error + }{ + { + name: "Valid Session ID", + request: func() *http.Request { + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Set("X-Vise-Session", "123456") + return req + }(), + expectedID: "123456", + expectedError: nil, + }, + { + name: "Missing Session ID", + request: httptest.NewRequest(http.MethodPost, "/", nil), + expectedID: "", + expectedError: handlers.ErrSessionMissing, + }, + { + name: "Invalid Request Type", + request: invalidRequestType{}, + expectedID: "", + expectedError: handlers.ErrInvalidRequest, + }, + } + + parser := &DefaultRequestParser{} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id, err := parser.GetSessionId(tt.request) + + if id != tt.expectedID { + t.Errorf("Expected session ID %s, got %s", tt.expectedID, id) + } + + if err != tt.expectedError { + t.Errorf("Expected error %v, got %v", tt.expectedError, err) + } + }) + } +} + +func TestDefaultRequestParser_GetInput(t *testing.T) { + tests := []struct { + name string + request any + expectedInput []byte + expectedError error + }{ + { + name: "Valid Input", + request: func() *http.Request { + return httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString("test input")) + }(), + expectedInput: []byte("test input"), + expectedError: nil, + }, + { + name: "Empty Input", + request: httptest.NewRequest(http.MethodPost, "/", nil), + expectedInput: []byte{}, + expectedError: nil, + }, + { + name: "Invalid Request Type", + request: invalidRequestType{}, + expectedInput: nil, + expectedError: handlers.ErrInvalidRequest, + }, + { + name: "Read Error", + request: func() *http.Request { + return httptest.NewRequest(http.MethodPost, "/", &errorReader{}) + }(), + expectedInput: nil, + expectedError: errors.New("read error"), + }, + } + + parser := &DefaultRequestParser{} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input, err := parser.GetInput(tt.request) + + if !bytes.Equal(input, tt.expectedInput) { + t.Errorf("Expected input %s, got %s", tt.expectedInput, input) + } + + if err != tt.expectedError && (err == nil || err.Error() != tt.expectedError.Error()) { + t.Errorf("Expected error %v, got %v", tt.expectedError, err) + } + }) + } +} diff --git a/internal/storage/gdbm.go b/internal/storage/db/gdbm/gdbm.go similarity index 95% rename from internal/storage/gdbm.go rename to internal/storage/db/gdbm/gdbm.go index ede1a49..24b502f 100644 --- a/internal/storage/gdbm.go +++ b/internal/storage/db/gdbm/gdbm.go @@ -6,6 +6,11 @@ import ( "git.defalsify.org/vise.git/db" gdbmdb "git.defalsify.org/vise.git/db/gdbm" "git.defalsify.org/vise.git/lang" + "git.defalsify.org/vise.git/logging" +) + +var ( + logg = logging.NewVanilla().WithDomain("gdbmstorage") ) var ( diff --git a/internal/storage/sub_prefix_db.go b/internal/storage/db/sub_prefix_db.go similarity index 100% rename from internal/storage/sub_prefix_db.go rename to internal/storage/db/sub_prefix_db.go diff --git a/internal/storage/sub_prefix_db_test.go b/internal/storage/db/sub_prefix_db_test.go similarity index 100% rename from internal/storage/sub_prefix_db_test.go rename to internal/storage/db/sub_prefix_db_test.go diff --git a/internal/storage/storageservice.go b/internal/storage/storageservice.go index ca28bbb..04e75ce 100644 --- a/internal/storage/storageservice.go +++ b/internal/storage/storageservice.go @@ -13,6 +13,7 @@ import ( "git.defalsify.org/vise.git/persist" "git.defalsify.org/vise.git/resource" "git.grassecon.net/urdt/ussd/initializers" + gdbmstorage "git.grassecon.net/urdt/ussd/internal/storage/db/gdbm" ) var ( @@ -75,7 +76,7 @@ func (ms *MenuStorageService) getOrCreateDb(ctx context.Context, existingDb db.D connStr := buildConnStr() err = newDb.Connect(ctx, connStr) } else { - newDb = NewThreadGdbmDb() + newDb = gdbmstorage.NewThreadGdbmDb() storeFile := path.Join(ms.dbDir, fileName) err = newDb.Connect(ctx, storeFile) } diff --git a/internal/utils/isocode.go b/internal/utils/isocode.go index 3bdfbeb..692b7bb 100644 --- a/internal/utils/isocode.go +++ b/internal/utils/isocode.go @@ -1,9 +1,9 @@ package utils var isoCodes = map[string]bool{ - "eng": true, // English - "swa": true, // Swahili - + "eng": true, // English + "swa": true, // Swahili + "default": true, // Default language: English } func IsValidISO639(code string) bool { diff --git a/menutraversal_test/group_test.json b/menutraversal_test/group_test.json index 8f43ff5..f35beb9 100644 --- a/menutraversal_test/group_test.json +++ b/menutraversal_test/group_test.json @@ -62,10 +62,10 @@ }, { "input": "1234", - "expectedContent": "Select language:\n0:English\n1:Kiswahili" + "expectedContent": "Select language:\n1:English\n2:Kiswahili" }, { - "input": "0", + "input": "1", "expectedContent": "Your language change request was successful.\n0:Back\n9:Quit" }, { @@ -430,7 +430,7 @@ }, { "input": "1234", - "expectedContent": "My profile:\nName: foo bar\nGender: male\nAge: 84\nLocation: Kilifi\nYou provide: Bananas\n\n0:Back" + "expectedContent": "My profile:\nName: foo bar\nGender: male\nAge: 80\nLocation: Kilifi\nYou provide: Bananas\n\n0:Back\n9:Quit" }, { "input": "0", diff --git a/menutraversal_test/menu_traversal_test.go b/menutraversal_test/menu_traversal_test.go index 28d88db..6b6b3da 100644 --- a/menutraversal_test/menu_traversal_test.go +++ b/menutraversal_test/menu_traversal_test.go @@ -298,9 +298,10 @@ func TestMainMenuSend(t *testing.T) { ctx := context.Background() sessions := testData for _, session := range sessions { - groups := driver.FilterGroupsByName(session.Groups, "send_with_invalid_inputs") + groups := driver.FilterGroupsByName(session.Groups, "send_with_invite") for _, group := range groups { - for _, step := range group.Steps { + 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) diff --git a/menutraversal_test/test_setup.json b/menutraversal_test/test_setup.json index c5860b4..8728640 100644 --- a/menutraversal_test/test_setup.json +++ b/menutraversal_test/test_setup.json @@ -7,14 +7,14 @@ "steps": [ { "input": "", - "expectedContent": "Welcome to Sarafu Network\nPlease select a language\n0:English\n1:Kiswahili" + "expectedContent": "Welcome to Sarafu Network\nPlease select a language\n1:English\n2:Kiswahili" }, { - "input": "0", - "expectedContent": "Do you agree to terms and conditions?\nhttps://grassecon.org/pages/terms-and-conditions\n\n0:Yes\n1:No" + "input": "1", + "expectedContent": "Do you agree to terms and conditions?\nhttps://grassecon.org/pages/terms-and-conditions\n\n1:Yes\n2:No" }, { - "input": "0", + "input": "1", "expectedContent": "Please enter a new four number PIN for your account:\n0:Exit" }, { @@ -40,14 +40,14 @@ "steps": [ { "input": "", - "expectedContent": "Welcome to Sarafu Network\nPlease select a language\n0:English\n1:Kiswahili" - }, - { - "input": "0", - "expectedContent": "Do you agree to terms and conditions?\nhttps://grassecon.org/pages/terms-and-conditions\n\n0:Yes\n1:No" + "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!" } ] @@ -64,8 +64,8 @@ "expectedContent": "Enter recipient's phone number/address/alias:\n0:Back" }, { - "input": "000", - "expectedContent": "000 is invalid, please try again:\n1:Retry\n9:Quit" + "input": "0@0", + "expectedContent": "0@0 is invalid, please try again:\n1:Retry\n9:Quit" }, { "input": "1", diff --git a/services/registration/balances.vis b/services/registration/balances.vis index aef397f..9a346d5 100644 --- a/services/registration/balances.vis +++ b/services/registration/balances.vis @@ -7,3 +7,4 @@ HALT INCMP _ 0 INCMP my_balance 1 INCMP community_balance 2 +INCMP . * diff --git a/services/registration/change_language.vis b/services/registration/change_language.vis index 05ca95b..f20bcfb 100644 --- a/services/registration/change_language.vis +++ b/services/registration/change_language.vis @@ -2,9 +2,9 @@ LOAD reset_account_authorized 0 LOAD reset_incorrect 0 CATCH incorrect_pin flag_incorrect_pin 1 CATCH pin_entry flag_account_authorized 0 -MOUT english 0 -MOUT kiswahili 1 +MOUT english 1 +MOUT kiswahili 2 HALT -INCMP set_default 0 -INCMP set_swa 1 +INCMP set_eng 1 +INCMP set_swa 2 INCMP . * diff --git a/services/registration/community_balance.vis b/services/registration/community_balance.vis index f3e0ae1..fad90cc 100644 --- a/services/registration/community_balance.vis +++ b/services/registration/community_balance.vis @@ -9,3 +9,4 @@ MOUT quit 9 HALT INCMP _ 0 INCMP quit 9 +INCMP . * diff --git a/services/registration/edit_first_name_swa b/services/registration/edit_first_name_swa index 3fdd986..5776bf0 100644 --- a/services/registration/edit_first_name_swa +++ b/services/registration/edit_first_name_swa @@ -1,2 +1,2 @@ -Jina la kwanza la sasa {{.get_current_profile_info}} +Jina la kwanza la sasa: {{.get_current_profile_info}} Weka majina yako ya kwanza: \ No newline at end of file diff --git a/services/registration/edit_location_swa b/services/registration/edit_location_swa index 0a3476e..179c421 100644 --- a/services/registration/edit_location_swa +++ b/services/registration/edit_location_swa @@ -1,2 +1,2 @@ -Eneo la sasa {{.get_current_profile_info}} +Eneo la sasa: {{.get_current_profile_info}} Weka eneo: \ No newline at end of file diff --git a/services/registration/edit_offerings.vis b/services/registration/edit_offerings.vis index ddbc9e0..9c1e747 100644 --- a/services/registration/edit_offerings.vis +++ b/services/registration/edit_offerings.vis @@ -10,5 +10,4 @@ CATCH _ flag_back_set 1 RELOAD save_offerings INCMP _ 0 CATCH pin_entry flag_offerings_set 1 -CATCH pin_entry flag_offerings_set 0 INCMP update_profile_items * diff --git a/services/registration/edit_profile.vis b/services/registration/edit_profile.vis index e5ee12b..0f0adb7 100644 --- a/services/registration/edit_profile.vis +++ b/services/registration/edit_profile.vis @@ -20,3 +20,4 @@ INCMP edit_yob 4 INCMP edit_location 5 INCMP edit_offerings 6 INCMP view_profile 7 +INCMP . * diff --git a/services/registration/edit_yob_swa b/services/registration/edit_yob_swa index e0b5708..f923c86 100644 --- a/services/registration/edit_yob_swa +++ b/services/registration/edit_yob_swa @@ -1,2 +1,2 @@ -Mwaka wa sasa wa kuzaliwa {{.get_current_profile_info}} +Mwaka wa sasa wa kuzaliwa: {{.get_current_profile_info}} Weka mwaka wa kuzaliwa \ No newline at end of file diff --git a/services/registration/my_account.vis b/services/registration/my_account.vis index 2b6289e..e3956d2 100644 --- a/services/registration/my_account.vis +++ b/services/registration/my_account.vis @@ -14,3 +14,4 @@ INCMP balances 3 INCMP check_statement 4 INCMP pin_management 5 INCMP address 6 +INCMP . * diff --git a/services/registration/my_balance.vis b/services/registration/my_balance.vis index 9144da9..b6094c0 100644 --- a/services/registration/my_balance.vis +++ b/services/registration/my_balance.vis @@ -9,3 +9,4 @@ MOUT quit 9 HALT INCMP _ 0 INCMP quit 9 +INCMP . * diff --git a/services/registration/select_gender.vis b/services/registration/select_gender.vis index c1a00f5..e41da10 100644 --- a/services/registration/select_gender.vis +++ b/services/registration/select_gender.vis @@ -11,3 +11,4 @@ INCMP _ 0 INCMP set_male 1 INCMP set_female 2 INCMP set_unspecified 3 +INCMP . * diff --git a/services/registration/select_gender_swa b/services/registration/select_gender_swa index b077a0b..39d99d5 100644 --- a/services/registration/select_gender_swa +++ b/services/registration/select_gender_swa @@ -1,2 +1,2 @@ -Jinsia ya sasa {{.get_current_profile_info}} +Jinsia ya sasa: {{.get_current_profile_info}} Chagua jinsia \ No newline at end of file diff --git a/services/registration/select_language.vis b/services/registration/select_language.vis index 54f08e9..0f7f298 100644 --- a/services/registration/select_language.vis +++ b/services/registration/select_language.vis @@ -1,6 +1,6 @@ -MOUT english 0 -MOUT kiswahili 1 +MOUT english 1 +MOUT kiswahili 2 HALT -INCMP set_eng 0 -INCMP set_swa 1 +INCMP set_eng 1 +INCMP set_swa 2 INCMP . * diff --git a/services/registration/set_default.vis b/services/registration/set_default.vis new file mode 100644 index 0000000..b66a1b7 --- /dev/null +++ b/services/registration/set_default.vis @@ -0,0 +1,4 @@ +LOAD set_language 6 +RELOAD set_language +CATCH terms flag_account_created 0 +MOVE language_changed diff --git a/services/registration/terms.vis b/services/registration/terms.vis index f04bdf4..372b6ca 100644 --- a/services/registration/terms.vis +++ b/services/registration/terms.vis @@ -1,5 +1,5 @@ -MOUT yes 0 -MOUT no 1 +MOUT yes 1 +MOUT no 2 HALT -INCMP create_pin 0 +INCMP create_pin 1 INCMP quit * diff --git a/services/registration/view_profile.vis b/services/registration/view_profile.vis index a7ffee4..4f4947c 100644 --- a/services/registration/view_profile.vis +++ b/services/registration/view_profile.vis @@ -4,5 +4,8 @@ LOAD reset_incorrect 6 CATCH incorrect_pin flag_incorrect_pin 1 CATCH pin_entry flag_account_authorized 0 MOUT back 0 +MOUT quit 9 HALT INCMP _ 0 +INCMP quit 9 +INCMP . *