Compare commits

..

46 Commits

Author SHA1 Message Date
47b5ff0435 Merge pull request 'Improve separation of concerns in all modules, phase 1' (#246) from lash/purify into master
Reviewed-on: #246
2025-01-04 10:56:17 +01:00
lash
25867cf05e
Rehabilitate voucher test 2025-01-04 09:42:36 +00:00
d5a2680500
make context accessible 2025-01-04 12:02:45 +03:00
lash
d950b10b50
Move prefix db spec to separate package 2025-01-04 08:37:28 +00:00
lash
bcb3ab905e
Move db related to own package 2025-01-04 08:09:18 +00:00
lash
3ed9caf16d
Factor out request parsers 2025-01-04 08:02:44 +00:00
lash
86464c31d2 Merge branch 'master' into lash/purify 2025-01-04 07:57:54 +00:00
5ee10d8e14 Merge pull request 'logs-at-sessionid' (#245) from logs-at-sessionid into master
Some checks failed
release / docker (push) Has been cancelled
Reviewed-on: #245
2025-01-04 08:56:09 +01:00
62f3681b9e
define context keysessionid using go-vise --withcontext 2025-01-04 10:40:26 +03:00
3ce1435591
extract session id from africastalking request 2025-01-04 10:38:25 +03:00
f65c458daa
update go-vise. 2025-01-04 10:35:59 +03:00
lash
67007fcd48
Factor out gdbm package 2025-01-04 07:35:28 +00:00
lash
f1b258fa6d
Factor out at code 2025-01-04 07:29:22 +00:00
d2fce05461 Merge pull request 'fix: language change' (#242) from language-change-fix into master
Some checks failed
release / docker (push) Has been cancelled
Reviewed-on: #242
2025-01-03 09:30:27 +01:00
68ac237449 Merge branch 'master' into language-change-fix 2025-01-03 09:28:48 +01:00
162e6c1934
fix: language change 2025-01-03 11:26:56 +03:00
8bd025f2b2 Merge pull request 'hash-pin' (#235) from hash-pin into master
Some checks failed
release / docker (push) Has been cancelled
Reviewed-on: #235
2025-01-03 09:25:26 +01:00
9d6e25e184
revert to previous state for the adminstore 2025-01-03 11:24:24 +03:00
c26f5683f6
removed second unused argument 2025-01-03 11:17:09 +03:00
91dc9ce82f
tests: add sample pin/hash pair from migration dataset 2025-01-03 11:10:07 +03:00
0fe48a30fa
Merge branch 'master' into hash-pin 2025-01-03 06:58:41 +03:00
58edfa01a2 Merge pull request 'menu-primary-selectors' (#237) from menu-primary-selectors into master
Some checks failed
release / docker (push) Has been cancelled
Reviewed-on: #237
Reviewed-by: lash <accounts-grassrootseconomics@holbrook.no>
2025-01-02 15:50:42 +01:00
3830c12a57
update tests 2025-01-02 17:42:03 +03:00
f1fd690a7b
update expected content 2025-01-02 17:37:26 +03:00
491b7424a9
point to the correct ./devtools/admin_numbers directory 2025-01-02 16:01:19 +03:00
29ce4b83bd
added tests for HashPIN and VerifyPIN 2025-01-02 15:22:07 +03:00
ca8df5989a
updated expected age in test 2025-01-02 15:15:52 +03:00
82b4365d16
hash the PIN in TestAuthorize 2025-01-02 14:38:22 +03:00
98db85511b
hash the PIN in the ResetOthersPin function 2025-01-02 14:37:45 +03:00
99a4d3ff42
verify the PIN input against the hashed PIN 2025-01-02 13:51:57 +03:00
d95c7abea4
return if the PIN is not a match, and hash the PIN before saving it 2025-01-02 13:45:18 +03:00
fd1ac85a1b
add code to Hash and Verify the PIN 2025-01-02 13:43:38 +03:00
c899c098f6
updated the expected age 2025-01-02 13:20:01 +03:00
5ca6a74274
move PIN test to the common package 2025-01-02 13:18:49 +03:00
48d63fb43f
added pin.go to contain all PIN related functionality 2025-01-02 13:16:38 +03:00
e666c58644
start primary selectors with 1 2025-01-02 12:17:28 +03:00
e980586910
chore: repeat same node on invalid menu choice 2025-01-02 12:15:57 +03:00
ffd5be1f1f
add quit option on view profile 2025-01-02 12:12:52 +03:00
ed1aeecf7d Merge pull request 'Legible dumper' (#232) from lash/dump-key-prefix into master
Some checks failed
release / docker (push) Has been cancelled
Reviewed-on: #232
2024-12-31 09:56:04 +01:00
3b69f3d38d Merge branch 'master' into lash/dump-key-prefix 2024-12-31 09:55:40 +01:00
lash
cd58f5ae33
Upgrade govise 2024-12-31 08:55:25 +00:00
7a535f796a
output the value as a string 2024-12-31 11:41:04 +03:00
7c4c73125e Merge pull request 'force-restart-state' (#223) from force-restart-state into master
Reviewed-on: #223
2024-12-31 09:34:44 +01:00
lash
c7dbe1d88f
Remove obsolete subprefix strings 2024-12-31 08:30:08 +00:00
lash
3bcd48e5a7
Update govise 2024-12-30 19:58:34 +00:00
lash
0e12c0ee4e
Add prefix for dumper, format base dump key for pg 2024-12-30 19:35:45 +00:00
36 changed files with 742 additions and 468 deletions

View File

@ -1,35 +1,31 @@
package main package main
import ( import (
"bytes"
"context" "context"
"encoding/json"
"flag" "flag"
"fmt" "fmt"
"io"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"path" "path"
"strconv" "strconv"
"strings"
"syscall" "syscall"
"git.defalsify.org/vise.git/engine" "git.defalsify.org/vise.git/engine"
"git.defalsify.org/vise.git/logging" "git.defalsify.org/vise.git/logging"
"git.defalsify.org/vise.git/resource" "git.defalsify.org/vise.git/resource"
"git.grassecon.net/urdt/ussd/common"
"git.grassecon.net/urdt/ussd/config" "git.grassecon.net/urdt/ussd/config"
"git.grassecon.net/urdt/ussd/initializers" "git.grassecon.net/urdt/ussd/initializers"
"git.grassecon.net/urdt/ussd/internal/handlers" "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/internal/storage"
"git.grassecon.net/urdt/ussd/remote" "git.grassecon.net/urdt/ussd/remote"
) )
var ( var (
logg = logging.NewVanilla() logg = logging.NewVanilla().WithDomain("AfricasTalking").WithContextKey("at-session-id")
scriptDir = path.Join("services", "registration") scriptDir = path.Join("services", "registration")
build = "dev" build = "dev"
menuSeparator = ": " menuSeparator = ": "
@ -38,72 +34,6 @@ var (
func init() { func init() {
initializers.LoadEnvVariables() 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() { func main() {
config.LoadConfig() config.LoadConfig()
@ -191,7 +121,9 @@ func main() {
} }
defer stateStore.Close() defer stateStore.Close()
rp := &atRequestParser{} rp := &at.ATRequestParser{
Context: ctx,
}
bsh := handlers.NewBaseSessionHandler(cfg, rs, stateStore, userdataStore, rp, hl) bsh := handlers.NewBaseSessionHandler(cfg, rs, stateStore, userdataStore, rp, hl)
sh := httpserver.NewATSessionHandler(bsh) sh := httpserver.NewATSessionHandler(bsh)

33
common/pin.go Normal file
View File

@ -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
}

173
common/pin_test.go Normal file
View File

@ -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
}

View File

@ -8,14 +8,15 @@ import (
"git.defalsify.org/vise.git/resource" "git.defalsify.org/vise.git/resource"
"git.defalsify.org/vise.git/persist" "git.defalsify.org/vise.git/persist"
"git.grassecon.net/urdt/ussd/internal/storage" "git.grassecon.net/urdt/ussd/internal/storage"
dbstorage "git.grassecon.net/urdt/ussd/internal/storage/db"
) )
func StoreToDb(store *UserDataStore) db.Db { func StoreToDb(store *UserDataStore) db.Db {
return store.Db return store.Db
} }
func StoreToPrefixDb(store *UserDataStore, pfx []byte) storage.PrefixDb { func StoreToPrefixDb(store *UserDataStore, pfx []byte) dbstorage.PrefixDb {
return storage.NewSubPrefixDb(store.Db, pfx) return dbstorage.NewSubPrefixDb(store.Db, pfx)
} }
type StorageServices interface { type StorageServices interface {

View File

@ -6,7 +6,7 @@ import (
"strings" "strings"
"time" "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" 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 // GetTransferData retrieves and matches transfer data
// returns a formatted string of the full transaction/statement // 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} 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) data := make(map[DataTyp]string)

View File

@ -6,7 +6,7 @@ import (
"math/big" "math/big"
"strings" "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" 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 // 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} keys := []DataTyp{DATA_VOUCHER_SYMBOLS, DATA_VOUCHER_BALANCES, DATA_VOUCHER_DECIMALS, DATA_VOUCHER_ADDRESSES}
data := make(map[DataTyp]string) data := make(map[DataTyp]string)

View File

@ -10,7 +10,7 @@ import (
visedb "git.defalsify.org/vise.git/db" visedb "git.defalsify.org/vise.git/db"
memdb "git.defalsify.org/vise.git/db/mem" 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" dataserviceapi "github.com/grassrootseconomics/ussd-data-service/pkg/api"
) )
@ -86,7 +86,7 @@ func TestGetVoucherData(t *testing.T) {
} }
prefix := ToBytes(visedb.DATATYPE_USERDATA) prefix := ToBytes(visedb.DATATYPE_USERDATA)
spdb := storage.NewSubPrefixDb(db, prefix) spdb := dbstorage.NewSubPrefixDb(db, prefix)
// Test voucher data // Test voucher data
mockData := map[DataTyp][]byte{ mockData := map[DataTyp][]byte{

View File

@ -11,13 +11,9 @@ import (
func init() { func init() {
DebugCap |= 1 DebugCap |= 1
dbTypStr[db.DATATYPE_STATE] = "internal state" 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_TRACKING_ID] = "tracking id"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_PUBLIC_KEY] = "public key" 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_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_FIRST_NAME] = "first name"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_FAMILY_NAME] = "family name" dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_FAMILY_NAME] = "family name"
dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_YOB] = "year of birth" dbTypStr[db.DATATYPE_USERDATA + 1 + common.DATA_YOB] = "year of birth"

View File

@ -11,6 +11,7 @@ import (
"git.grassecon.net/urdt/ussd/initializers" "git.grassecon.net/urdt/ussd/initializers"
"git.grassecon.net/urdt/ussd/internal/storage" "git.grassecon.net/urdt/ussd/internal/storage"
"git.grassecon.net/urdt/ussd/debug" "git.grassecon.net/urdt/ussd/debug"
"git.defalsify.org/vise.git/db"
"git.defalsify.org/vise.git/logging" "git.defalsify.org/vise.git/logging"
) )
@ -47,13 +48,14 @@ func main() {
store, err := menuStorageService.GetUserdataDb(ctx) store, err := menuStorageService.GetUserdataDb(ctx)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, err.Error()) fmt.Fprintf(os.Stderr, "get userdata db: %v\n", err.Error())
os.Exit(1) os.Exit(1)
} }
store.SetPrefix(db.DATATYPE_USERDATA)
d, err := store.Dump(ctx, []byte(sessionId)) d, err := store.Dump(ctx, []byte(sessionId))
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, err.Error()) fmt.Fprintf(os.Stderr, "store dump fail: %v\n", err.Error())
os.Exit(1) os.Exit(1)
} }
@ -67,7 +69,7 @@ func main() {
fmt.Fprintf(os.Stderr, err.Error()) fmt.Fprintf(os.Stderr, err.Error())
os.Exit(1) os.Exit(1)
} }
fmt.Printf("%vValue: %v\n\n", o, v) fmt.Printf("%vValue: %v\n\n", o, string(v))
} }
err = store.Close() err = store.Close()

4
go.mod
View File

@ -3,7 +3,7 @@ module git.grassecon.net/urdt/ussd
go 1.23.0 go 1.23.0
require ( 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/alecthomas/assert/v2 v2.2.2
github.com/gofrs/uuid v4.4.0+incompatible github.com/gofrs/uuid v4.4.0+incompatible
github.com/grassrootseconomics/eth-custodial v1.3.0-beta github.com/grassrootseconomics/eth-custodial v1.3.0-beta
@ -11,6 +11,7 @@ require (
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/peteole/testdata-loader v0.3.0 github.com/peteole/testdata-loader v0.3.0
github.com/stretchr/testify v1.9.0 github.com/stretchr/testify v1.9.0
golang.org/x/crypto v0.27.0
gopkg.in/leonelquinteros/gotext.v1 v1.3.1 gopkg.in/leonelquinteros/gotext.v1 v1.3.1
) )
@ -32,7 +33,6 @@ require (
github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/objx v0.5.2 // indirect
github.com/x448/float16 v0.8.4 // 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/sync v0.8.0 // indirect
golang.org/x/text v0.18.0 // indirect golang.org/x/text v0.18.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect

4
go.sum
View File

@ -1,5 +1,5 @@
git.defalsify.org/vise.git v0.2.1-0.20241212145627-683015d4df80 h1:GYUVXRUtMpA40T4COeAduoay6CIgXjD5cfDYZOTFIKw= git.defalsify.org/vise.git v0.2.3-0.20250103172917-3e190a44568d h1:bPAOVZOX4frSGhfOdcj7kc555f8dc9DmMd2YAyC2AMw=
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/go.mod h1:jyBMe1qTYUz3mmuoC9JQ/TvFeW0vTanCUcPu3H8p4Ck=
github.com/alecthomas/assert/v2 v2.2.2 h1:Z/iVC0xZfWTaFNE6bA3z07T86hd45Xe2eLt6WVy2bbk= 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/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 h1:Fgrq+MbuSsJwIkw3fEj9h75vDP0Er5JzepJ0/HNHv0g=

View File

@ -5,7 +5,6 @@ import (
"context" "context"
"fmt" "fmt"
"path" "path"
"regexp"
"strconv" "strconv"
"strings" "strings"
@ -24,27 +23,16 @@ import (
"git.grassecon.net/urdt/ussd/remote" "git.grassecon.net/urdt/ussd/remote"
"gopkg.in/leonelquinteros/gotext.v1" "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" dataserviceapi "github.com/grassrootseconomics/ussd-data-service/pkg/api"
) )
var ( var (
logg = logging.NewVanilla().WithDomain("ussdmenuhandler") logg = logging.NewVanilla().WithDomain("ussdmenuhandler").WithContextKey("session-id")
scriptDir = path.Join("services", "registration") scriptDir = path.Join("services", "registration")
translationDir = path.Join(scriptDir, "locale") translationDir = path.Join(scriptDir, "locale")
) )
// Define the regex patterns as constants
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
}
// FlagManager handles centralized flag management // FlagManager handles centralized flag management
type FlagManager struct { type FlagManager struct {
parser *asm.FlagParser parser *asm.FlagParser
@ -76,7 +64,7 @@ type Handlers struct {
adminstore *utils.AdminStore adminstore *utils.AdminStore
flagManager *asm.FlagParser flagManager *asm.FlagParser
accountService remote.AccountServiceInterface accountService remote.AccountServiceInterface
prefixDb storage.PrefixDb prefixDb dbstorage.PrefixDb
profile *models.Profile profile *models.Profile
ReplaceSeparatorFunc func(string) string ReplaceSeparatorFunc func(string) string
} }
@ -92,7 +80,7 @@ func NewHandlers(appFlags *asm.FlagParser, userdataStore db.Db, adminstore *util
// Instantiate the SubPrefixDb with "DATATYPE_USERDATA" prefix // Instantiate the SubPrefixDb with "DATATYPE_USERDATA" prefix
prefix := common.ToBytes(db.DATATYPE_USERDATA) prefix := common.ToBytes(db.DATATYPE_USERDATA)
prefixDb := storage.NewSubPrefixDb(userdataStore, prefix) prefixDb := dbstorage.NewSubPrefixDb(userdataStore, prefix)
h := &Handlers{ h := &Handlers{
userdataStore: userDb, userdataStore: userDb,
@ -134,9 +122,12 @@ func (h *Handlers) Init(ctx context.Context, sym string, input []byte) (resource
h.st.Code = []byte{} h.st.Code = []byte{}
} }
sessionId, _ := ctx.Value("SessionId").(string) sessionId, ok := ctx.Value("SessionId").(string)
flag_admin_privilege, _ := h.flagManager.GetFlag("flag_admin_privilege") if ok {
context.WithValue(ctx, "session-id", sessionId)
}
flag_admin_privilege, _ := h.flagManager.GetFlag("flag_admin_privilege")
isAdmin, _ := h.adminstore.IsAdmin(sessionId) isAdmin, _ := h.adminstore.IsAdmin(sessionId)
if isAdmin { if isAdmin {
@ -281,7 +272,7 @@ func (h *Handlers) VerifyNewPin(ctx context.Context, sym string, input []byte) (
flag_valid_pin, _ := h.flagManager.GetFlag("flag_valid_pin") flag_valid_pin, _ := h.flagManager.GetFlag("flag_valid_pin")
pinInput := string(input) pinInput := string(input)
// Validate that the PIN is a 4-digit number. // Validate that the PIN is a 4-digit number.
if isValidPIN(pinInput) { if common.IsValidPIN(pinInput) {
res.FlagSet = append(res.FlagSet, flag_valid_pin) res.FlagSet = append(res.FlagSet, flag_valid_pin)
} else { } else {
res.FlagReset = append(res.FlagReset, flag_valid_pin) res.FlagReset = append(res.FlagReset, flag_valid_pin)
@ -306,7 +297,7 @@ func (h *Handlers) SaveTemporaryPin(ctx context.Context, sym string, input []byt
accountPIN := string(input) accountPIN := string(input)
// Validate that the PIN is a 4-digit number. // Validate that the PIN is a 4-digit number.
if !isValidPIN(accountPIN) { if !common.IsValidPIN(accountPIN) {
res.FlagSet = append(res.FlagSet, flag_incorrect_pin) res.FlagSet = append(res.FlagSet, flag_incorrect_pin)
return res, nil return res, nil
} }
@ -368,11 +359,20 @@ func (h *Handlers) ConfirmPinChange(ctx context.Context, sym string, input []byt
res.FlagReset = append(res.FlagReset, flag_pin_mismatch) res.FlagReset = append(res.FlagReset, flag_pin_mismatch)
} else { } else {
res.FlagSet = append(res.FlagSet, flag_pin_mismatch) res.FlagSet = append(res.FlagSet, flag_pin_mismatch)
return res, nil
} }
// If matched, save the confirmed PIN as the new account PIN
err = store.WriteEntry(ctx, sessionId, common.DATA_ACCOUNT_PIN, []byte(temporaryPin)) // Hash the PIN
hashedPIN, err := common.HashPIN(string(temporaryPin))
if err != nil { 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, err
} }
return res, nil return res, nil
@ -404,11 +404,19 @@ func (h *Handlers) VerifyCreatePin(ctx context.Context, sym string, input []byte
res.FlagSet = append(res.FlagSet, flag_pin_set) res.FlagSet = append(res.FlagSet, flag_pin_set)
} else { } else {
res.FlagSet = []uint32{flag_pin_mismatch} 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 { 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, err
} }
@ -722,7 +730,7 @@ func (h *Handlers) Authorize(ctx context.Context, sym string, input []byte) (res
return res, err return res, err
} }
if len(input) == 4 { if len(input) == 4 {
if bytes.Equal(input, AccountPin) { if common.VerifyPIN(string(AccountPin), string(input)) {
if h.st.MatchFlag(flag_account_authorized, false) { if h.st.MatchFlag(flag_account_authorized, false) {
res.FlagReset = append(res.FlagReset, flag_incorrect_pin) res.FlagReset = append(res.FlagReset, flag_incorrect_pin)
res.FlagSet = append(res.FlagSet, flag_allow_update, flag_account_authorized) res.FlagSet = append(res.FlagSet, flag_allow_update, flag_account_authorized)
@ -949,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) logg.ErrorCtxf(ctx, "failed to read temporaryPin entry with", "key", common.DATA_TEMPORARY_VALUE, "error", err)
return res, 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 { if err != nil {
return res, nil return res, nil
} }
@ -1400,7 +1416,6 @@ func (h *Handlers) GetCurrentProfileInfo(ctx context.Context, sym string, input
defaultValue = "Not Provided" defaultValue = "Not Provided"
} }
sm, _ := h.st.Where() sm, _ := h.st.Where()
parts := strings.SplitN(sm, "_", 2) parts := strings.SplitN(sm, "_", 2)
filename := parts[1] filename := parts[1]

View File

@ -13,7 +13,7 @@ import (
"git.defalsify.org/vise.git/persist" "git.defalsify.org/vise.git/persist"
"git.defalsify.org/vise.git/resource" "git.defalsify.org/vise.git/resource"
"git.defalsify.org/vise.git/state" "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/mocks"
"git.grassecon.net/urdt/ussd/internal/testutil/testservice" "git.grassecon.net/urdt/ussd/internal/testutil/testservice"
"git.grassecon.net/urdt/ussd/internal/utils" "git.grassecon.net/urdt/ussd/internal/utils"
@ -59,14 +59,14 @@ func InitializeTestStore(t *testing.T) (context.Context, *common.UserDataStore)
return ctx, store 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() db := memdb.NewMemDb()
err := db.Connect(ctx, "") err := db.Connect(ctx, "")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
prefix := common.ToBytes(visedb.DATATYPE_USERDATA) prefix := common.ToBytes(visedb.DATATYPE_USERDATA)
spdb := storage.NewSubPrefixDb(db, prefix) spdb := dbstorage.NewSubPrefixDb(db, prefix)
return spdb return spdb
} }
@ -1047,7 +1047,14 @@ func TestAuthorize(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -1499,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) { func TestValidateAmount(t *testing.T) {
fm, err := NewFlagManager(flagsPath) fm, err := NewFlagManager(flagsPath)
if err != nil { if err != nil {
@ -1798,7 +1752,7 @@ func TestGetProfile(t *testing.T) {
result: resource.Result{ result: resource.Result{
Content: fmt.Sprintf( Content: fmt.Sprintf(
"Name: %s\nGender: %s\nAge: %s\nLocation: %s\nYou provide: %s\n", "Name: %s\nGender: %s\nAge: %s\nLocation: %s\nYou provide: %s\n",
"John Doee", "Male", "48", "Kilifi", "Bananas", "John Doee", "Male", "49", "Kilifi", "Bananas",
), ),
}, },
}, },
@ -1810,7 +1764,7 @@ func TestGetProfile(t *testing.T) {
result: resource.Result{ result: resource.Result{
Content: fmt.Sprintf( Content: fmt.Sprintf(
"Jina: %s\nJinsia: %s\nUmri: %s\nEneo: %s\nUnauza: %s\n", "Jina: %s\nJinsia: %s\nUmri: %s\nEneo: %s\nUnauza: %s\n",
"John Doee", "Male", "48", "Kilifi", "Bananas", "John Doee", "Male", "49", "Kilifi", "Bananas",
), ),
}, },
}, },
@ -1822,7 +1776,7 @@ func TestGetProfile(t *testing.T) {
result: resource.Result{ result: resource.Result{
Content: fmt.Sprintf( Content: fmt.Sprintf(
"Name: %s\nGender: %s\nAge: %s\nLocation: %s\nYou provide: %s\n", "Name: %s\nGender: %s\nAge: %s\nLocation: %s\nYou provide: %s\n",
"John Doee", "Male", "48", "Kilifi", "Bananas", "John Doee", "Male", "49", "Kilifi", "Bananas",
), ),
}, },
}, },

121
internal/http/at/parse.go Normal file
View File

@ -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
}

View File

@ -1,19 +1,25 @@
package http package at
import ( import (
"io" "io"
"net/http" "net/http"
"git.defalsify.org/vise.git/logging"
"git.grassecon.net/urdt/ussd/internal/handlers" "git.grassecon.net/urdt/ussd/internal/handlers"
httpserver "git.grassecon.net/urdt/ussd/internal/http"
)
var (
logg = logging.NewVanilla().WithDomain("atserver")
) )
type ATSessionHandler struct { type ATSessionHandler struct {
*SessionHandler *httpserver.SessionHandler
} }
func NewATSessionHandler(h handlers.RequestHandler) *ATSessionHandler { func NewATSessionHandler(h handlers.RequestHandler) *ATSessionHandler {
return &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) cfg.SessionId, err = rp.GetSessionId(req)
if err != nil { if err != nil {
logg.ErrorCtxf(rqs.Ctx, "", "header processing error", err) logg.ErrorCtxf(rqs.Ctx, "", "header processing error", err)
ash.writeError(w, 400, err) ash.WriteError(w, 400, err)
return return
} }
rqs.Config = cfg rqs.Config = cfg
rqs.Input, err = rp.GetInput(req) rqs.Input, err = rp.GetInput(req)
if err != nil { if err != nil {
logg.ErrorCtxf(rqs.Ctx, "", "header processing error", err) logg.ErrorCtxf(rqs.Ctx, "", "header processing error", err)
ash.writeError(w, 400, err) ash.WriteError(w, 400, err)
return return
} }
@ -53,7 +59,7 @@ func (ash *ATSessionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request)
} }
if code != 200 { if code != 200 {
ash.writeError(w, 500, err) ash.WriteError(w, 500, err)
return return
} }
@ -61,13 +67,13 @@ func (ash *ATSessionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request)
w.Header().Set("Content-Type", "text/plain") w.Header().Set("Content-Type", "text/plain")
rqs, err = ash.Output(rqs) rqs, err = ash.Output(rqs)
if err != nil { if err != nil {
ash.writeError(w, 500, err) ash.WriteError(w, 500, err)
return return
} }
rqs, err = ash.Reset(rqs) rqs, err = ash.Reset(rqs)
if err != nil { if err != nil {
ash.writeError(w, 500, err) ash.WriteError(w, 500, err)
return return
} }
} }

View File

@ -1,7 +1,6 @@
package http package at
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"io" "io"
@ -16,16 +15,6 @@ import (
"git.grassecon.net/urdt/ussd/internal/testutil/mocks/httpmocks" "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) { func TestNewATSessionHandler(t *testing.T) {
mockHandler := &httpmocks.MockRequestHandler{} mockHandler := &httpmocks.MockRequestHandler{}
ash := NewATSessionHandler(mockHandler) 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)
}
})
}
}

38
internal/http/parse.go Normal file
View File

@ -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
}

View File

@ -1,7 +1,6 @@
package http package http
import ( import (
"io/ioutil"
"net/http" "net/http"
"strconv" "strconv"
@ -14,34 +13,6 @@ var (
logg = logging.NewVanilla().WithDomain("httpserver") 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 { type SessionHandler struct {
handlers.RequestHandler handlers.RequestHandler
} }
@ -52,7 +23,7 @@ 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() s := err.Error()
w.Header().Set("Content-Length", strconv.Itoa(len(s))) w.Header().Set("Content-Length", strconv.Itoa(len(s)))
w.WriteHeader(code) w.WriteHeader(code)
@ -78,13 +49,13 @@ func (f *SessionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
cfg.SessionId, err = rp.GetSessionId(req) cfg.SessionId, err = rp.GetSessionId(req)
if err != nil { if err != nil {
logg.ErrorCtxf(rqs.Ctx, "", "header processing error", err) logg.ErrorCtxf(rqs.Ctx, "", "header processing error", err)
f.writeError(w, 400, err) f.WriteError(w, 400, err)
} }
rqs.Config = cfg rqs.Config = cfg
rqs.Input, err = rp.GetInput(req) rqs.Input, err = rp.GetInput(req)
if err != nil { if err != nil {
logg.ErrorCtxf(rqs.Ctx, "", "header processing error", err) logg.ErrorCtxf(rqs.Ctx, "", "header processing error", err)
f.writeError(w, 400, err) f.WriteError(w, 400, err)
return return
} }
@ -101,7 +72,7 @@ func (f *SessionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
} }
if code != 200 { if code != 200 {
f.writeError(w, 500, err) f.WriteError(w, 500, err)
return return
} }
@ -110,11 +81,11 @@ func (f *SessionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
rqs, err = f.Output(rqs) rqs, err = f.Output(rqs)
rqs, perr = f.Reset(rqs) rqs, perr = f.Reset(rqs)
if err != nil { if err != nil {
f.writeError(w, 500, err) f.WriteError(w, 500, err)
return return
} }
if perr != nil { if perr != nil {
f.writeError(w, 500, perr) f.WriteError(w, 500, perr)
return return
} }
} }

View File

@ -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)
}
})
}
}

View File

@ -6,6 +6,11 @@ import (
"git.defalsify.org/vise.git/db" "git.defalsify.org/vise.git/db"
gdbmdb "git.defalsify.org/vise.git/db/gdbm" gdbmdb "git.defalsify.org/vise.git/db/gdbm"
"git.defalsify.org/vise.git/lang" "git.defalsify.org/vise.git/lang"
"git.defalsify.org/vise.git/logging"
)
var (
logg = logging.NewVanilla().WithDomain("gdbmstorage")
) )
var ( var (

View File

@ -13,6 +13,7 @@ import (
"git.defalsify.org/vise.git/persist" "git.defalsify.org/vise.git/persist"
"git.defalsify.org/vise.git/resource" "git.defalsify.org/vise.git/resource"
"git.grassecon.net/urdt/ussd/initializers" "git.grassecon.net/urdt/ussd/initializers"
gdbmstorage "git.grassecon.net/urdt/ussd/internal/storage/db/gdbm"
) )
var ( var (
@ -75,7 +76,7 @@ func (ms *MenuStorageService) getOrCreateDb(ctx context.Context, existingDb db.D
connStr := buildConnStr() connStr := buildConnStr()
err = newDb.Connect(ctx, connStr) err = newDb.Connect(ctx, connStr)
} else { } else {
newDb = NewThreadGdbmDb() newDb = gdbmstorage.NewThreadGdbmDb()
storeFile := path.Join(ms.dbDir, fileName) storeFile := path.Join(ms.dbDir, fileName)
err = newDb.Connect(ctx, storeFile) err = newDb.Connect(ctx, storeFile)
} }

View File

@ -3,7 +3,7 @@ package utils
var isoCodes = map[string]bool{ var isoCodes = map[string]bool{
"eng": true, // English "eng": true, // English
"swa": true, // Swahili "swa": true, // Swahili
"default": true, // Default language: English
} }
func IsValidISO639(code string) bool { func IsValidISO639(code string) bool {

View File

@ -62,10 +62,10 @@
}, },
{ {
"input": "1234", "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" "expectedContent": "Your language change request was successful.\n0:Back\n9:Quit"
}, },
{ {
@ -430,7 +430,7 @@
}, },
{ {
"input": "1234", "input": "1234",
"expectedContent": "My profile:\nName: foo bar\nGender: male\nAge: 79\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", "input": "0",

View File

@ -7,14 +7,14 @@
"steps": [ "steps": [
{ {
"input": "", "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", "input": "1",
"expectedContent": "Do you agree to terms and conditions?\nhttps://grassecon.org/pages/terms-and-conditions\n\n0:Yes\n1:No" "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" "expectedContent": "Please enter a new four number PIN for your account:\n0:Exit"
}, },
{ {
@ -40,14 +40,14 @@
"steps": [ "steps": [
{ {
"input": "", "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", "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!" "expectedContent": "Thank you for using Sarafu. Goodbye!"
} }
] ]

View File

@ -7,3 +7,4 @@ HALT
INCMP _ 0 INCMP _ 0
INCMP my_balance 1 INCMP my_balance 1
INCMP community_balance 2 INCMP community_balance 2
INCMP . *

View File

@ -2,9 +2,9 @@ LOAD reset_account_authorized 0
LOAD reset_incorrect 0 LOAD reset_incorrect 0
CATCH incorrect_pin flag_incorrect_pin 1 CATCH incorrect_pin flag_incorrect_pin 1
CATCH pin_entry flag_account_authorized 0 CATCH pin_entry flag_account_authorized 0
MOUT english 0 MOUT english 1
MOUT kiswahili 1 MOUT kiswahili 2
HALT HALT
INCMP set_default 0 INCMP set_eng 1
INCMP set_swa 1 INCMP set_swa 2
INCMP . * INCMP . *

View File

@ -9,3 +9,4 @@ MOUT quit 9
HALT HALT
INCMP _ 0 INCMP _ 0
INCMP quit 9 INCMP quit 9
INCMP . *

View File

@ -20,3 +20,4 @@ INCMP edit_yob 4
INCMP edit_location 5 INCMP edit_location 5
INCMP edit_offerings 6 INCMP edit_offerings 6
INCMP view_profile 7 INCMP view_profile 7
INCMP . *

View File

@ -14,3 +14,4 @@ INCMP balances 3
INCMP check_statement 4 INCMP check_statement 4
INCMP pin_management 5 INCMP pin_management 5
INCMP address 6 INCMP address 6
INCMP . *

View File

@ -9,3 +9,4 @@ MOUT quit 9
HALT HALT
INCMP _ 0 INCMP _ 0
INCMP quit 9 INCMP quit 9
INCMP . *

View File

@ -1,6 +1,6 @@
MOUT english 0 MOUT english 1
MOUT kiswahili 1 MOUT kiswahili 2
HALT HALT
INCMP set_eng 0 INCMP set_eng 1
INCMP set_swa 1 INCMP set_swa 2
INCMP . * INCMP . *

View File

@ -0,0 +1,4 @@
LOAD set_language 6
RELOAD set_language
CATCH terms flag_account_created 0
MOVE language_changed

View File

@ -1,5 +1,5 @@
MOUT yes 0 MOUT yes 1
MOUT no 1 MOUT no 2
HALT HALT
INCMP create_pin 0 INCMP create_pin 1
INCMP quit * INCMP quit *

View File

@ -4,5 +4,8 @@ LOAD reset_incorrect 6
CATCH incorrect_pin flag_incorrect_pin 1 CATCH incorrect_pin flag_incorrect_pin 1
CATCH pin_entry flag_account_authorized 0 CATCH pin_entry flag_account_authorized 0
MOUT back 0 MOUT back 0
MOUT quit 9
HALT HALT
INCMP _ 0 INCMP _ 0
INCMP quit 9
INCMP . *