fix kyc service and join service #12

Merged
sugar merged 2 commits from develop into main 2026-05-03 17:56:00 +00:00
7 changed files with 421 additions and 43 deletions

View File

@@ -33,7 +33,7 @@ func (self *KycHandler) Query(c *gin.Context) {
ctx = exception.ContextWithEndpoint(ctx, exception.EndpointKycQuery)
ctx = exception.ContextWithService(ctx, exception.ServiceEndpoint)
_, ok := c.Get("user_id")
userIdOrig, ok := c.Get("user_id")
if !ok {
errorCode := exception.New(
exception.WithStatus(exception.StatusUser),
@@ -57,6 +57,8 @@ func (self *KycHandler) Query(c *gin.Context) {
return
}
queryData.UserId = userIdOrig.(string)
queryPayload := &service_kyc.KycQueryPayload{
Context: ctx,
Data: &queryData,

View File

@@ -0,0 +1,73 @@
package kyc
import (
"context"
"errors"
"nixcn-cms/data"
"strconv"
"time"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
)
const passportSessionTTL = 30 * time.Minute
type PassportSessionBinding struct {
SessionId int
PassportId string
}
func passportSessionKey(kycId uuid.UUID) string {
return "kyc:passport:" + kycId.String()
}
func SavePassportSession(ctx context.Context, kycId uuid.UUID, b PassportSessionBinding) error {
if data.Redis == nil {
return errors.New("redis not initialized")
}
key := passportSessionKey(kycId)
pipe := data.Redis.TxPipeline()
pipe.HSet(ctx, key, map[string]any{
"session_id": strconv.Itoa(b.SessionId),
"passport_id": b.PassportId,
})
pipe.Expire(ctx, key, passportSessionTTL)
_, err := pipe.Exec(ctx)
return err
}
func LoadPassportSession(ctx context.Context, kycId uuid.UUID) (*PassportSessionBinding, error) {
if data.Redis == nil {
return nil, errors.New("redis not initialized")
}
key := passportSessionKey(kycId)
m, err := data.Redis.HGetAll(ctx, key).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return nil, nil
}
return nil, err
}
if len(m) == 0 {
return nil, nil
}
sessionId, err := strconv.Atoi(m["session_id"])
if err != nil {
return nil, err
}
return &PassportSessionBinding{
SessionId: sessionId,
PassportId: m["passport_id"],
}, nil
}
func DeletePassportSession(ctx context.Context, kycId uuid.UUID) error {
if data.Redis == nil {
return errors.New("redis not initialized")
}
return data.Redis.Del(ctx, passportSessionKey(kycId)).Err()
}

View File

@@ -164,7 +164,7 @@ func (self *EventServiceImpl) Join(payload *EventJoinPayload) (result *EventJoin
return
}
attendenceSearch, err := new(data.Attendance).GetAttendance(ctx, eventId, userId)
attendenceSearch, err := new(data.Attendance).GetAttendance(ctx, userId, eventId)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
exc := exception.New(

View File

@@ -8,9 +8,12 @@ import (
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"nixcn-cms/data"
"nixcn-cms/internal/kyc"
"nixcn-cms/testutil"
)
@@ -18,6 +21,29 @@ func newKycSvc() KycService {
return NewKycService()
}
// seedPassportKyc creates a passport-typed Kyc row and writes a corresponding
// passport-session binding to Redis. Returns the kyc_id and owning user_id so
// query tests can target a real row + Redis entry with a known SessionId.
func seedPassportKyc(t *testing.T, sessionId int) (kycIdStr string, userIdStr string) {
t.Helper()
ctx := context.Background()
userId := uuid.New()
kycRow := data.NewKyc(
data.WithKycType("passport"),
data.WithKycUserId(userId),
)
kycId, err := kycRow.Create(ctx)
require.NoError(t, err)
require.NoError(t, kyc.SavePassportSession(ctx, kycId, kyc.PassportSessionBinding{
SessionId: sessionId,
PassportId: "P12345678",
}))
return kycId.String(), userId.String()
}
// ---- SessionKyc input-validation paths (no external API required) ----
func TestSessionKycInvalidBase64(t *testing.T) {
@@ -93,10 +119,12 @@ func TestQueryKycExternalAPIError(t *testing.T) {
testutil.Setup(t)
// kyc.passport_reader_endpoint is unset, so GetSessionState will fail.
kycId, userId := seedPassportKyc(t, 1)
svc := newKycSvc()
result := svc.QueryKyc(&KycQueryPayload{
Context: context.Background(),
Data: &KycQueryData{KycId: "00000000-0000-0000-0000-000000000001"},
Data: &KycQueryData{KycId: kycId, UserId: userId},
})
assert.Equal(t, 400, result.Common.HttpCode)
@@ -115,10 +143,12 @@ func TestQueryKycUnknownSessionState(t *testing.T) {
t.Cleanup(srv.Close)
viper.Set("kyc.passport_reader_endpoint", srv.URL)
svc := &KycServiceImpl{PassportReaderSessionId: 1}
kycId, userId := seedPassportKyc(t, 1)
svc := newKycSvc()
result := svc.QueryKyc(&KycQueryPayload{
Context: context.Background(),
Data: &KycQueryData{KycId: "00000000-0000-0000-0000-000000000001"},
Data: &KycQueryData{KycId: kycId, UserId: userId},
})
require.NotNil(t, result, "result must never be nil")
@@ -142,10 +172,12 @@ func TestQueryKycPendingStates(t *testing.T) {
t.Cleanup(srv.Close)
viper.Set("kyc.passport_reader_endpoint", srv.URL)
svc := &KycServiceImpl{PassportReaderSessionId: 1}
kycId, userId := seedPassportKyc(t, 1)
svc := newKycSvc()
result := svc.QueryKyc(&KycQueryPayload{
Context: context.Background(),
Data: &KycQueryData{KycId: "00000000-0000-0000-0000-000000000001"},
Data: &KycQueryData{KycId: kycId, UserId: userId},
})
require.NotNil(t, result)
@@ -173,10 +205,12 @@ func TestQueryKycFailedStates(t *testing.T) {
t.Cleanup(srv.Close)
viper.Set("kyc.passport_reader_endpoint", srv.URL)
svc := &KycServiceImpl{PassportReaderSessionId: 1}
kycId, userId := seedPassportKyc(t, 1)
svc := newKycSvc()
result := svc.QueryKyc(&KycQueryPayload{
Context: context.Background(),
Data: &KycQueryData{KycId: "00000000-0000-0000-0000-000000000001"},
Data: &KycQueryData{KycId: kycId, UserId: userId},
})
require.NotNil(t, result)
@@ -186,3 +220,117 @@ func TestQueryKycFailedStates(t *testing.T) {
})
}
}
// ---- New regression tests ----
// TestQueryKycRejectsForeignKycId verifies that a logged-in user cannot query
// another user's KYC by passing their kyc_id.
func TestQueryKycRejectsForeignKycId(t *testing.T) {
testutil.Setup(t)
kycId, _ := seedPassportKyc(t, 1)
otherUserId := uuid.New().String()
svc := newKycSvc()
result := svc.QueryKyc(&KycQueryPayload{
Context: context.Background(),
Data: &KycQueryData{KycId: kycId, UserId: otherUserId},
})
require.NotNil(t, result)
assert.Equal(t, 403, result.Common.HttpCode)
}
// TestQueryKycRejectsUnknownKycId verifies a non-existent kyc_id returns 404
// and never falls through to any external session lookup.
func TestQueryKycRejectsUnknownKycId(t *testing.T) {
testutil.Setup(t)
svc := newKycSvc()
result := svc.QueryKyc(&KycQueryPayload{
Context: context.Background(),
Data: &KycQueryData{
KycId: uuid.New().String(),
UserId: uuid.New().String(),
},
})
require.NotNil(t, result)
assert.Equal(t, 404, result.Common.HttpCode)
}
// TestQueryKycMissingBinding verifies that when the Redis binding has expired
// or is missing, the query reports a clear 400 instead of using stale state.
func TestQueryKycMissingBinding(t *testing.T) {
testutil.Setup(t)
ctx := context.Background()
userId := uuid.New()
kycRow := data.NewKyc(
data.WithKycType("passport"),
data.WithKycUserId(userId),
)
kycId, err := kycRow.Create(ctx)
require.NoError(t, err)
svc := newKycSvc()
result := svc.QueryKyc(&KycQueryPayload{
Context: ctx,
Data: &KycQueryData{
KycId: kycId.String(),
UserId: userId.String(),
},
})
require.NotNil(t, result)
assert.Equal(t, 400, result.Common.HttpCode)
}
// TestQueryKycPerKycSessionIsolation simulates two users who created passport
// sessions sequentially. Each must see their own session state, regardless of
// which session was created last.
func TestQueryKycPerKycSessionIsolation(t *testing.T) {
testutil.Setup(t)
// Mock returns different states based on the requested session id.
mux := http.NewServeMux()
mux.HandleFunc("/session.state", func(w http.ResponseWriter, r *http.Request) {
var req struct {
ID int `json:"id"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
state := "CREATED"
if req.ID == 200 {
state = "FAILED"
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"state": state})
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
viper.Set("kyc.passport_reader_endpoint", srv.URL)
kycA, userA := seedPassportKyc(t, 100)
kycB, userB := seedPassportKyc(t, 200)
svc := newKycSvc()
resA := svc.QueryKyc(&KycQueryPayload{
Context: context.Background(),
Data: &KycQueryData{KycId: kycA, UserId: userA},
})
require.NotNil(t, resA)
require.Equal(t, 200, resA.Common.HttpCode)
require.NotNil(t, resA.Data)
assert.Equal(t, "pending", resA.Data.Status)
resB := svc.QueryKyc(&KycQueryPayload{
Context: context.Background(),
Data: &KycQueryData{KycId: kycB, UserId: userB},
})
require.NotNil(t, resB)
require.Equal(t, 200, resB.Common.HttpCode)
require.NotNil(t, resB.Data)
assert.Equal(t, "failed", resB.Data.Status)
}

View File

@@ -13,7 +13,8 @@ import (
)
type KycQueryData struct {
KycId string `json:"kyc_id" validate:"required"`
KycId string `json:"kyc_id" validate:"required"`
UserId string `json:"user_id" swaggerignore:"true"`
}
type KycQueryPayload struct {
@@ -40,9 +41,165 @@ func (self *KycServiceImpl) QueryKyc(payload *KycQueryPayload) (result *KycQuery
ctx = exception.ContextWithService(ctx, exception.ServiceKycQuery)
var err error
kycId, err := uuid.Parse(payload.Data.KycId)
if err != nil {
exc := exception.New(
exception.WithStatus(exception.StatusUser),
exception.WithType(exception.TypeCommon),
exception.WithOriginal(exception.CommonErrorUuidParseFailed),
exception.WithError(err),
).Throw(ctx)
sessionState, err := kyc.GetSessionState(ctx, self.PassportReaderSessionId)
result = &KycQueryResult{
Common: shared.CommonResult{
HttpCode: 400,
Exception: exc,
},
Data: nil,
}
return
}
userId, err := uuid.Parse(payload.Data.UserId)
if err != nil {
exc := exception.New(
exception.WithStatus(exception.StatusUser),
exception.WithType(exception.TypeCommon),
exception.WithOriginal(exception.CommonErrorUuidParseFailed),
exception.WithError(err),
).Throw(ctx)
result = &KycQueryResult{
Common: shared.CommonResult{
HttpCode: 400,
Exception: exc,
},
Data: nil,
}
return
}
kycRow, err := new(data.Kyc).GetByKycId(ctx, &kycId)
if err != nil {
exc := exception.New(
exception.WithStatus(exception.StatusServer),
exception.WithType(exception.TypeCommon),
exception.WithOriginal(exception.CommonErrorDatabase),
exception.WithError(err),
).Throw(ctx)
result = &KycQueryResult{
Common: shared.CommonResult{
HttpCode: 500,
Exception: exc,
},
Data: nil,
}
return
}
if kycRow == nil {
exc := exception.New(
exception.WithStatus(exception.StatusUser),
exception.WithType(exception.TypeCommon),
exception.WithOriginal(exception.CommonErrorInvalidInput),
exception.WithError(errors.New("kyc not found")),
).Throw(ctx)
result = &KycQueryResult{
Common: shared.CommonResult{
HttpCode: 404,
Exception: exc,
},
Data: nil,
}
return
}
if kycRow.UserId != userId {
exc := exception.New(
exception.WithStatus(exception.StatusUser),
exception.WithType(exception.TypeCommon),
exception.WithOriginal(exception.CommonErrorPermissionDenied),
exception.WithError(errors.New("kyc does not belong to caller")),
).Throw(ctx)
result = &KycQueryResult{
Common: shared.CommonResult{
HttpCode: 403,
Exception: exc,
},
Data: nil,
}
return
}
if kycRow.Type != "passport" {
exc := exception.New(
exception.WithStatus(exception.StatusUser),
exception.WithType(exception.TypeCommon),
exception.WithOriginal(exception.CommonErrorInvalidInput),
exception.WithError(errors.New("kyc is not a passport flow")),
).Throw(ctx)
result = &KycQueryResult{
Common: shared.CommonResult{
HttpCode: 400,
Exception: exc,
},
Data: nil,
}
return
}
// Per-flow passport state lives in Redis under a key derived from kyc_id,
// so concurrent passport sessions cannot read each other's session id.
binding, err := kyc.LoadPassportSession(ctx, kycId)
if err != nil {
exc := exception.New(
exception.WithStatus(exception.StatusServer),
exception.WithType(exception.TypeCommon),
exception.WithOriginal(exception.CommonErrorInternal),
exception.WithError(err),
).Throw(ctx)
result = &KycQueryResult{
Common: shared.CommonResult{
HttpCode: 500,
Exception: exc,
},
Data: nil,
}
return
}
if binding == nil {
exc := exception.New(
exception.WithStatus(exception.StatusUser),
exception.WithType(exception.TypeCommon),
exception.WithOriginal(exception.CommonErrorInvalidInput),
exception.WithError(errors.New("passport session expired or not found")),
).Throw(ctx)
result = &KycQueryResult{
Common: shared.CommonResult{
HttpCode: 400,
Exception: exc,
},
Data: nil,
}
return
}
sessionState, err := kyc.GetSessionState(ctx, binding.SessionId)
if err != nil {
exc := exception.New(
exception.WithStatus(exception.StatusUser),
@@ -63,7 +220,7 @@ func (self *KycServiceImpl) QueryKyc(payload *KycQueryPayload) (result *KycQuery
}
if sessionState == kyc.StateApproved {
sessionDetails, err := kyc.GetSessionDetails(ctx, self.PassportReaderSessionId)
sessionDetails, err := kyc.GetSessionDetails(ctx, binding.SessionId)
if err != nil {
exc := exception.New(
exception.WithStatus(exception.StatusUser),
@@ -93,12 +250,12 @@ func (self *KycServiceImpl) QueryKyc(payload *KycQueryPayload) (result *KycQuery
ExpiryDate: sessionDetails.ExpiryDate,
}
if kycInfo.DocumentType != "PASSPORT" || kycInfo.DocumentNumber != self.PassportId {
if kycInfo.DocumentType != "PASSPORT" || kycInfo.DocumentNumber != binding.PassportId {
exc := exception.New(
exception.WithStatus(exception.StatusUser),
exception.WithType(exception.TypeCommon),
exception.WithOriginal(exception.CommonErrorInvalidInput),
exception.WithError(err),
exception.WithError(errors.New("passport document mismatch")),
).Throw(ctx)
result = &KycQueryResult{
@@ -130,26 +287,6 @@ func (self *KycServiceImpl) QueryKyc(payload *KycQueryPayload) (result *KycQuery
return
}
kycId, err := uuid.Parse(payload.Data.KycId)
if err != nil {
exc := exception.New(
exception.WithStatus(exception.StatusUser),
exception.WithType(exception.TypeCommon),
exception.WithOriginal(exception.CommonErrorUuidParseFailed),
exception.WithError(err),
).Throw(ctx)
result = &KycQueryResult{
Common: shared.CommonResult{
HttpCode: 400,
Exception: exc,
},
Data: nil,
}
return
}
err = new(data.Kyc).
PatchByKycId(ctx, &kycId, data.WithKycInfo(*encodedKycInfo))
if err != nil {
@@ -171,6 +308,9 @@ func (self *KycServiceImpl) QueryKyc(payload *KycQueryPayload) (result *KycQuery
return
}
// Binding is no longer needed; best-effort cleanup.
_ = kyc.DeletePassportSession(ctx, kycId)
exc := exception.New(
exception.WithStatus(exception.StatusSuccess),
exception.WithType(exception.TypeCommon),

View File

@@ -5,10 +5,7 @@ type KycService interface {
QueryKyc(*KycQueryPayload) *KycQueryResult
}
type KycServiceImpl struct {
PassportId string `json:"passport_id"`
PassportReaderSessionId int `json:"passport_reader_session_id"`
}
type KycServiceImpl struct{}
func NewKycService() KycService {
return &KycServiceImpl{}

View File

@@ -262,8 +262,6 @@ func (self *KycServiceImpl) SessionKyc(payload *KycSessionPayload) (result *KycS
return
}
self.PassportId = info.ID
sessionResponse, err := kyc.CreateSession(ctx)
if err != nil {
exc := exception.New(
@@ -284,8 +282,6 @@ func (self *KycServiceImpl) SessionKyc(payload *KycSessionPayload) (result *KycS
return
}
self.PassportReaderSessionId = sessionResponse.ID
userId, err := uuid.Parse(payload.Data.UserId)
if err != nil {
exc := exception.New(
@@ -329,6 +325,28 @@ func (self *KycServiceImpl) SessionKyc(payload *KycSessionPayload) (result *KycS
return
}
if err := kyc.SavePassportSession(ctx, kycIdOrig, kyc.PassportSessionBinding{
SessionId: sessionResponse.ID,
PassportId: info.ID,
}); err != nil {
exc := exception.New(
exception.WithStatus(exception.StatusServer),
exception.WithType(exception.TypeCommon),
exception.WithOriginal(exception.CommonErrorInternal),
exception.WithError(err),
).Throw(ctx)
result = &KycSessionResult{
Common: shared.CommonResult{
HttpCode: 500,
Exception: exc,
},
Data: nil,
}
return
}
kycId := kycIdOrig.String()
kycBaseURL := "https://passportreader.app/open"
kycUrl, _ := url.Parse(kycBaseURL)