Skip to content

Commit ae3090b

Browse files
committed
Issue-2063 GetWalletID used to swallow every storage error and return an empty string issue
Signed-off-by: Effi-S <effi.szt@gmail.com>
1 parent 312f321 commit ae3090b

8 files changed

Lines changed: 247 additions & 34 deletions

File tree

token/services/identity/driver/storage.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,10 @@ type WalletID = string
8383
//
8484
//go:generate counterfeiter -o mock/wss.go -fake-name WalletStoreService . WalletStoreService
8585
type WalletStoreService interface {
86-
// GetWalletID fetches a walletID that is bound to the identity passed
86+
// GetWalletID fetches a walletID that is bound to the identity passed.
87+
// It returns an empty WalletID and no error if the identity has no stored
88+
// binding; a non-nil error indicates a genuine storage failure and must not
89+
// be interpreted by callers as "no binding".
8790
GetWalletID(ctx context.Context, identity token.Identity, roleID int) (WalletID, error)
8891
// GetWalletIDs fetches all walletID's that have been stored so far without duplicates
8992
GetWalletIDs(ctx context.Context, roleID int) ([]WalletID, error)

token/services/identity/role/registry.go

Lines changed: 101 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,51 @@ type WalletFactory interface {
2222
NewWallet(ctx context.Context, id idriver.WalletID, role idriver.IdentityRoleType, is IdentitySupport, info idriver.IdentityInfo) (driver.Wallet, error)
2323
}
2424

25+
// WalletIDStatus classifies the outcome of resolving an identity to the wallet id
26+
// bound to it. It exists so callers branch on an explicit, named state instead of
27+
// re-deriving intent from the ambiguous "(string, error)" shape — where ("", nil),
28+
// ("", err) and ("id", nil) each mean something different and the difference is easy
29+
// to get wrong (see issue #2063).
30+
type WalletIDStatus int
31+
32+
const (
33+
// WalletIDUnknown is the zero value and never returned by GetWalletID; it guards
34+
// against a WalletIDResolution that was constructed without going through GetWalletID.
35+
WalletIDUnknown WalletIDStatus = iota
36+
// WalletIDBound means storage holds a wallet id for the identity. WalletID is set.
37+
WalletIDBound
38+
// WalletIDUnbound means storage answered authoritatively that the identity has no
39+
// wallet binding. This is a definitive, successful miss: it is safe to fall through
40+
// to the next resolution step and, ultimately, to create a wallet.
41+
WalletIDUnbound
42+
// WalletIDFailed means the storage lookup itself failed (timeout, connection reset,
43+
// ...), so whether a binding exists is UNKNOWN. Err carries the cause. Callers MUST
44+
// NOT treat this as WalletIDUnbound: doing so lets a transient blip masquerade as an
45+
// unregistered identity and triggers the creation of a duplicate wallet.
46+
WalletIDFailed
47+
)
48+
49+
// WalletIDResolution is the explicit result of resolving an identity to its bound
50+
// wallet id. It is the single shared value every wallet-lookup fallback branches on,
51+
// so the "not found" vs "could not check" distinction is decided once (in GetWalletID)
52+
// rather than re-inferred at each call site.
53+
type WalletIDResolution struct {
54+
// Status is the outcome of the lookup. Always inspect it via Bound/Unbound/Failed
55+
// before reading the other fields.
56+
Status WalletIDStatus
57+
// WalletID is the bound wallet id; meaningful only when Status is WalletIDBound.
58+
WalletID idriver.WalletID
59+
// Err is the underlying storage failure; set only when Status is WalletIDFailed.
60+
Err error
61+
}
62+
63+
// Bound reports whether the identity has a wallet id bound to it.
64+
func (r WalletIDResolution) Bound() bool { return r.Status == WalletIDBound }
65+
66+
// Failed reports whether the storage lookup failed, leaving the binding unknown.
67+
// A failed resolution must abort the enclosing lookup, never fall through to creation.
68+
func (r WalletIDResolution) Failed() bool { return r.Status == WalletIDFailed }
69+
2570
// Registry manages wallets whose long-term identities have a given role.
2671
//
2772
// Concurrency and invariants:
@@ -82,11 +127,17 @@ func (r *Registry) Lookup(ctx context.Context, id driver.WalletLookupID) (driver
82127
if ok {
83128
r.Logger.DebugfContext(ctx, "lookup failed, check if there is a wallet for identity [%s]", passedIdentity)
84129
// is this identity registered
85-
wID, err := r.GetWalletID(ctx, passedIdentity)
86-
if err == nil && len(wID) != 0 {
87-
r.Logger.DebugfContext(ctx, "lookup failed, there is a wallet for identity [%s]: [%s]", passedIdentity, wID)
130+
res := r.GetWalletID(ctx, passedIdentity)
131+
if res.Failed() {
132+
// A storage failure leaves the binding unknown; it must not be treated as
133+
// "not registered", or a transient blip would fall through to wallet
134+
// creation and duplicate state.
135+
return nil, nil, "", errors.WithMessagef(res.Err, "failed to lookup wallet [%s]", id)
136+
}
137+
if res.Bound() {
138+
r.Logger.DebugfContext(ctx, "lookup failed, there is a wallet for identity [%s]: [%s]", passedIdentity, res.WalletID)
88139
// we got a hit
89-
walletID = wID
140+
walletID = res.WalletID
90141
ident = passedIdentity
91142
fail = false
92143
}
@@ -111,36 +162,48 @@ func (r *Registry) Lookup(ctx context.Context, id driver.WalletLookupID) (driver
111162
if ok {
112163
r.Logger.DebugfContext(ctx, "no wallet found, check if there is a wallet for identity [%s]", passedIdentity)
113164
// is this identity registered
114-
passedWalletID, err := r.GetWalletID(ctx, passedIdentity)
115-
if err == nil && len(passedWalletID) != 0 {
116-
r.Logger.DebugfContext(ctx, "no wallet found, there is a wallet for identity [%s]: [%s]", passedIdentity, passedWalletID)
165+
res := r.GetWalletID(ctx, passedIdentity)
166+
if res.Failed() {
167+
// A storage failure leaves the binding unknown; it must not be treated as
168+
// "not registered", or a transient blip would fall through to wallet
169+
// creation and duplicate state.
170+
return nil, nil, "", errors.WithMessagef(res.Err, "failed to lookup wallet [%s]", id)
171+
}
172+
if res.Bound() {
173+
r.Logger.DebugfContext(ctx, "no wallet found, there is a wallet for identity [%s]: [%s]", passedIdentity, res.WalletID)
117174
// we got a hit
118175
r.WalletMu.RLock()
119-
walletEntry, ok = r.Wallets[passedWalletID]
176+
walletEntry, ok = r.Wallets[res.WalletID]
120177
r.WalletMu.RUnlock()
121178
if ok {
122-
return walletEntry, nil, passedWalletID, nil
179+
return walletEntry, nil, res.WalletID, nil
123180
}
124-
r.Logger.DebugfContext(ctx, "no wallet found, there is a wallet for identity [%s]: [%s] but it has not been recreated yet", passedIdentity, passedWalletID)
181+
r.Logger.DebugfContext(ctx, "no wallet found, there is a wallet for identity [%s]: [%s] but it has not been recreated yet", passedIdentity, res.WalletID)
125182
}
126-
walletIdentifiers = append(walletIdentifiers, passedWalletID)
183+
walletIdentifiers = append(walletIdentifiers, res.WalletID)
127184
}
128185

129186
r.Logger.DebugfContext(ctx, "no wallet found for [%s] at [%s]", passedIdentity, logging.Prefix(wID))
130187
if len(ident) != 0 {
131-
identityWID, err := r.GetWalletID(ctx, ident)
132-
r.Logger.DebugfContext(ctx, "wallet for identity [%s] -> [%s:%s]", ident, identityWID, err)
133-
if err == nil && len(identityWID) != 0 {
188+
res := r.GetWalletID(ctx, ident)
189+
r.Logger.DebugfContext(ctx, "wallet for identity [%s] -> [%s:%d]", ident, res.WalletID, res.Status)
190+
if res.Failed() {
191+
// A storage failure leaves the binding unknown; it must not be treated as
192+
// "not registered", or a transient blip would fall through to wallet
193+
// creation and duplicate state.
194+
return nil, nil, "", errors.WithMessagef(res.Err, "failed to lookup wallet [%s]", id)
195+
}
196+
if res.Bound() {
134197
r.WalletMu.RLock()
135-
w, ok := r.Wallets[identityWID]
198+
w, ok := r.Wallets[res.WalletID]
136199
r.WalletMu.RUnlock()
137200
if ok {
138-
r.Logger.DebugfContext(ctx, "found wallet [%s:%s:%s:%s]", ident, walletID, w.ID(), identityWID)
201+
r.Logger.DebugfContext(ctx, "found wallet [%s:%s:%s:%s]", ident, walletID, w.ID(), res.WalletID)
139202

140-
return w, nil, identityWID, nil
203+
return w, nil, res.WalletID, nil
141204
}
142205
}
143-
walletIdentifiers = append(walletIdentifiers, identityWID)
206+
walletIdentifiers = append(walletIdentifiers, res.WalletID)
144207
}
145208

146209
for _, walletIdentifier := range walletIdentifiers {
@@ -233,16 +296,31 @@ func (r *Registry) GetIdentityMetadata(ctx context.Context, identity driver.Iden
233296
return json.Unmarshal(raw, &meta)
234297
}
235298

236-
// GetWalletID returns the wallet identifier bound to the passed identity
237-
func (r *Registry) GetWalletID(ctx context.Context, identity driver.Identity) (string, error) {
299+
// GetWalletID resolves the wallet identifier bound to the passed identity.
300+
//
301+
// It is the single point that translates the storage layer's (WalletID, error)
302+
// convention into an explicit WalletIDResolution, so no caller has to re-derive the
303+
// meaning of ("", nil) vs ("", err). The storage contract reports an unbound identity
304+
// as ("", nil); a non-nil error is a genuine storage failure (timeout, connection
305+
// reset, ...) whose result is therefore WalletIDFailed, never WalletIDUnbound. Keeping
306+
// the two apart here is what prevents a transient blip from looking like an
307+
// unregistered identity and triggering the creation of a duplicate wallet (issue #2063).
308+
func (r *Registry) GetWalletID(ctx context.Context, identity driver.Identity) WalletIDResolution {
238309
wID, err := r.Storage.GetWalletID(ctx, identity, int(r.Role.ID()))
239310
if err != nil {
240-
//nolint:nilerr
241-
return "", nil
311+
return WalletIDResolution{
312+
Status: WalletIDFailed,
313+
Err: errors.Wrapf(err, "failed to get wallet id for identity [%s]", identity),
314+
}
315+
}
316+
if len(wID) == 0 {
317+
r.Logger.DebugfContext(ctx, "no wallet bound to identity [%s]", identity)
318+
319+
return WalletIDResolution{Status: WalletIDUnbound}
242320
}
243321
r.Logger.DebugfContext(ctx, "wallet [%s] is bound to identity [%s]", wID, identity)
244322

245-
return wID, nil
323+
return WalletIDResolution{Status: WalletIDBound, WalletID: wID}
246324
}
247325

248326
func (r *Registry) WalletByID(ctx context.Context, role idriver.IdentityRoleType, id driver.WalletLookupID) (driver.Wallet, error) {

token/services/identity/role/registry_test.go

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -133,17 +133,81 @@ func TestBindIdentityAndContainsAndMetadataAndGetWalletID(t *testing.T) {
133133
require.NoError(t, reg.GetIdentityMetadata(ctx, []byte("id"), "w", &meta))
134134
require.Equal(t, "v", meta["k"])
135135

136-
// GetWalletID when storage returns value
136+
// GetWalletID when storage returns a bound wallet id.
137137
storage.GetWalletIDReturns("w", nil)
138-
wid, err := reg.GetWalletID(ctx, []byte("id"))
139-
require.NoError(t, err)
140-
require.Equal(t, "w", wid)
141-
142-
// GetWalletID when storage returns error -> suppressed to empty string
138+
res := reg.GetWalletID(ctx, []byte("id"))
139+
require.Equal(t, role.WalletIDBound, res.Status)
140+
require.True(t, res.Bound())
141+
require.NoError(t, res.Err)
142+
require.Equal(t, "w", res.WalletID)
143+
144+
// GetWalletID when storage authoritatively reports no binding -> WalletIDUnbound,
145+
// a clean miss with no error and no wallet id.
146+
storage.GetWalletIDReturns("", nil)
147+
resUnbound := reg.GetWalletID(ctx, []byte("id"))
148+
require.Equal(t, role.WalletIDUnbound, resUnbound.Status)
149+
require.False(t, resUnbound.Bound())
150+
require.False(t, resUnbound.Failed())
151+
require.NoError(t, resUnbound.Err)
152+
require.Empty(t, resUnbound.WalletID)
153+
154+
// GetWalletID when storage fails -> WalletIDFailed with the error preserved, so a
155+
// transient storage blip cannot masquerade as "no binding".
143156
storage.GetWalletIDReturns("", errors.New("boom"))
144-
wid2, err2 := reg.GetWalletID(ctx, []byte("id"))
145-
require.NoError(t, err2)
146-
require.Empty(t, wid2)
157+
resFailed := reg.GetWalletID(ctx, []byte("id"))
158+
require.Equal(t, role.WalletIDFailed, resFailed.Status)
159+
require.True(t, resFailed.Failed())
160+
require.False(t, resFailed.Bound())
161+
require.Error(t, resFailed.Err)
162+
require.Empty(t, resFailed.WalletID)
163+
}
164+
165+
// TestLookup_StorageErrorDoesNotFallThrough pins the fix for #2063: a transient
166+
// storage error out of GetWalletID must abort Lookup rather than be swallowed as
167+
// "no binding". Otherwise the fallback chain would fall through to wallet creation
168+
// and persist a duplicate wallet for an identity that already has one.
169+
func TestLookup_StorageErrorDoesNotFallThrough(t *testing.T) {
170+
t.Run("MapToIdentity fails, view-identity fallback hits storage error", func(t *testing.T) {
171+
reg, storage, role, wf := newRegistryWithFakes()
172+
ctx := t.Context()
173+
174+
role.MapToIdentityReturns(nil, "", errors.New("no mapping"))
175+
storage.GetWalletIDReturns("", errors.New("transient DB blip"))
176+
177+
_, _, _, err := reg.Lookup(ctx, []byte("id-with-binding"))
178+
require.Error(t, err)
179+
require.Equal(t, 0, wf.NewWalletCallCount())
180+
})
181+
182+
t.Run("MapToIdentity succeeds, cache miss, view-identity fallback hits storage error", func(t *testing.T) {
183+
reg, storage, role, wf := newRegistryWithFakes()
184+
ctx := t.Context()
185+
186+
// mapping resolves to a wallet id that is not in the cache, so Lookup falls
187+
// through to the identity->wallet storage probe, which fails transiently.
188+
role.MapToIdentityReturns([]byte("id-with-binding"), "w-not-cached", nil)
189+
storage.GetWalletIDReturns("", errors.New("transient DB blip"))
190+
191+
_, _, _, err := reg.Lookup(ctx, []byte("id-with-binding"))
192+
require.Error(t, err)
193+
require.Equal(t, 0, wf.NewWalletCallCount())
194+
})
195+
}
196+
197+
// TestWalletByID_StorageErrorDoesNotCreateDuplicate ensures that when the cache
198+
// misses and the storage lookup fails transiently, WalletByID surfaces the error
199+
// instead of creating a brand-new wallet (the duplicate-wallet bug of #2063).
200+
func TestWalletByID_StorageErrorDoesNotCreateDuplicate(t *testing.T) {
201+
reg, storage, role, wf := newRegistryWithFakes()
202+
ctx := t.Context()
203+
204+
role.MapToIdentityReturns(nil, "", errors.New("no mapping"))
205+
storage.GetWalletIDReturns("", errors.New("transient DB blip"))
206+
207+
w, err := reg.WalletByID(ctx, 0, []byte("id-with-binding"))
208+
require.Error(t, err)
209+
require.Nil(t, w)
210+
require.Equal(t, 0, wf.NewWalletCallCount())
147211
}
148212

149213
func TestWalletIDs_MergesRoleAndStorage(t *testing.T) {

token/services/storage/db/kvs/walletdb.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,16 @@ func (s *WalletStore) GetWalletID(ctx context.Context, identity driver2.Identity
8585
if err != nil {
8686
return "", errors.Wrapf(err, "failed to create key")
8787
}
88+
// kvs.Get returns an error both for a missing key and for a real store failure.
89+
// The WalletStoreService contract requires that "no binding" be reported as ("", nil)
90+
// so that callers can distinguish it from a transient storage error; probe with Exists
91+
// first (as IdentityExists does) and only Get when a binding is present.
92+
if !s.kvs.Exists(ctx, k) {
93+
return "", nil
94+
}
8895
var wID storage.WalletID
8996
if err := s.kvs.Get(ctx, k, &wID); err != nil {
90-
return "", err
97+
return "", errors.Wrapf(err, "failed to get wallet id for identity [%v]", idHash)
9198
}
9299

93100
return wID, nil

token/services/storage/db/kvs/walletdb_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,34 @@ func TestWalletStoreGetConfID(t *testing.T) {
5050
require.NoError(t, err)
5151
assert.Empty(t, got)
5252
}
53+
54+
// TestWalletStoreGetWalletID asserts the not-found contract that the role Registry relies on to
55+
// tell a transient storage error apart from "this identity has no binding": an unbound identity
56+
// must resolve to ("", nil), and a bound identity must round-trip its wallet id. If GetWalletID
57+
// returned an error for a missing key (as kvs.Get does), the registry would abort every lookup
58+
// for a genuinely-unregistered identity instead of creating its wallet.
59+
func TestWalletStoreGetWalletID(t *testing.T) {
60+
backend, err := NewInMemory()
61+
require.NoError(t, err)
62+
// NewInMemory shares a global in-memory backing store across the package's tests, so use a
63+
// tmsID and identities unique to this test to stay isolated from any other stored bindings.
64+
tmsID := token.TMSID{Network: "getwalletid", Channel: "getwalletid", Namespace: "getwalletid"}
65+
db := NewWalletStore(backend, tmsID)
66+
ctx := t.Context()
67+
68+
// miss: never bound -> ("", nil), NOT an error
69+
got, err := db.GetWalletID(ctx, []byte("gwid-grace"), 0)
70+
require.NoError(t, err)
71+
assert.Empty(t, got)
72+
73+
// bound under role 0 -> round-trips the wallet id
74+
require.NoError(t, db.StoreIdentity(ctx, []byte("gwid-grace"), "eID", "grace_wallet", 0, nil, "conf-1"))
75+
got, err = db.GetWalletID(ctx, []byte("gwid-grace"), 0)
76+
require.NoError(t, err)
77+
assert.Equal(t, "grace_wallet", got)
78+
79+
// the same identity under a different role is still an independent miss
80+
got, err = db.GetWalletID(ctx, []byte("gwid-grace"), 1)
81+
require.NoError(t, err)
82+
assert.Empty(t, got)
83+
}

token/services/storage/db/sql/common/wallet_test_utils.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,28 @@ func TestGetWalletID(t *testing.T, store walletStoreConstructor) {
3737
gomega.Expect(actualWalletID).To(gomega.Equal(output))
3838
}
3939

40+
// TestGetWalletIDNotFound pins the not-found contract of the WalletStoreService: an identity with
41+
// no stored binding resolves to ("", nil), never an error. The role Registry relies on this to tell
42+
// a transient storage failure apart from "this identity was never registered" (issue #2063).
43+
func TestGetWalletIDNotFound(t *testing.T, store walletStoreConstructor) {
44+
gomega.RegisterTestingT(t)
45+
db, mockDB, err := sqlmock.New()
46+
gomega.Expect(err).ToNot(gomega.HaveOccurred())
47+
48+
tokenID := token.Identity([]byte("1234"))
49+
roleID := 5
50+
mockDB.
51+
ExpectQuery("SELECT wallet_id FROM WALLETS WHERE \\(identity_hash = \\$1\\) AND \\(role_id = \\$2\\)").
52+
WithArgs(tokenID.UniqueID(), roleID).
53+
WillReturnError(sql.ErrNoRows)
54+
55+
actualWalletID, err := store(db).GetWalletID(t.Context(), tokenID, roleID)
56+
57+
gomega.Expect(mockDB.ExpectationsWereMet()).To(gomega.Succeed())
58+
gomega.Expect(err).ToNot(gomega.HaveOccurred())
59+
gomega.Expect(actualWalletID).To(gomega.BeEmpty())
60+
}
61+
4062
func TestGetWalletIDs(t *testing.T, store walletStoreConstructor) {
4163
gomega.RegisterTestingT(t)
4264
db, mockDB, err := sqlmock.New()

token/services/storage/db/sql/postgres/wallet_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ func TestGetWalletID(t *testing.T) {
2424
common2.TestGetWalletID(t, mockWalletStore)
2525
}
2626

27+
func TestGetWalletIDNotFound(t *testing.T) {
28+
common2.TestGetWalletIDNotFound(t, mockWalletStore)
29+
}
30+
2731
func TestGetWalletIDs(t *testing.T) {
2832
common2.TestGetWalletIDs(t, mockWalletStore)
2933
}

token/services/storage/db/sql/sqlite/wallet_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ func TestGetWalletID(t *testing.T) {
2424
common2.TestGetWalletID(t, mockWalletStore)
2525
}
2626

27+
func TestGetWalletIDNotFound(t *testing.T) {
28+
common2.TestGetWalletIDNotFound(t, mockWalletStore)
29+
}
30+
2731
func TestGetWalletIDs(t *testing.T) {
2832
common2.TestGetWalletIDs(t, mockWalletStore)
2933
}

0 commit comments

Comments
 (0)