Skip to content

Commit a917f9e

Browse files
authored
fix: improve and simplify android receipt verification (#2545)
* fix: improve and simplify android receipt verification * test: cover validateGoogleTime * chore: trigger CI
1 parent 13ed2c0 commit a917f9e

File tree

6 files changed

+326
-116
lines changed

6 files changed

+326
-116
lines changed

services/skus/controllers.go

+10-10
Original file line numberDiff line numberDiff line change
@@ -997,7 +997,6 @@ func WebhookRouter(svc *Service) chi.Router {
997997
return r
998998
}
999999

1000-
// HandleAndroidWebhook is the handler for the Google Playstore webhooks
10011000
func HandleAndroidWebhook(service *Service) handlers.AppHandler {
10021001
return func(w http.ResponseWriter, r *http.Request) *handlers.AppError {
10031002
ctx := r.Context()
@@ -1664,16 +1663,17 @@ func handleReceiptErr(err error) *handlers.AppError {
16641663
}
16651664

16661665
switch {
1667-
case errors.Is(err, errPurchaseFailed):
1668-
result.ErrorCode = purchaseFailedErrCode
1669-
case errors.Is(err, errPurchasePending):
1670-
result.ErrorCode = purchasePendingErrCode
1671-
case errors.Is(err, errPurchaseDeferred):
1672-
result.ErrorCode = purchaseDeferredErrCode
1673-
case errors.Is(err, errPurchaseStatusUnknown):
1674-
result.ErrorCode = purchaseStatusUnknownErrCode
1666+
case errors.Is(err, errIOSPurchaseNotFound):
1667+
result.ErrorCode = "purchase_not_found"
1668+
1669+
case errors.Is(err, errExpiredGPSSubPurchase):
1670+
result.ErrorCode = "purchase_expired"
1671+
1672+
case errors.Is(err, errPendingGPSSubPurchase):
1673+
result.ErrorCode = "purchase_pending"
1674+
16751675
default:
1676-
result.ErrorCode = purchaseValidationErrCode
1676+
result.ErrorCode = "validation_failed"
16771677
}
16781678

16791679
return result

services/skus/controllers_noint_test.go

+19-32
Original file line numberDiff line numberDiff line change
@@ -160,66 +160,53 @@ func TestHandleReceiptErr(t *testing.T) {
160160
},
161161

162162
{
163-
name: "errPurchaseFailed",
164-
given: errPurchaseFailed,
163+
name: "errIOSPurchaseNotFound",
164+
given: errIOSPurchaseNotFound,
165165
exp: &handlers.AppError{
166-
Message: "Error " + errPurchaseFailed.Error(),
166+
Message: "Error " + errIOSPurchaseNotFound.Error(),
167167
Code: http.StatusBadRequest,
168-
ErrorCode: purchaseFailedErrCode,
168+
ErrorCode: "purchase_not_found",
169169
Data: map[string]interface{}{
170-
"validationErrors": map[string]interface{}{"receiptErrors": errPurchaseFailed.Error()},
170+
"validationErrors": map[string]interface{}{"receiptErrors": errIOSPurchaseNotFound.Error()},
171171
},
172172
},
173173
},
174174

175175
{
176-
name: "errPurchasePending",
177-
given: errPurchasePending,
176+
name: "errExpiredGPSSubPurchase",
177+
given: errExpiredGPSSubPurchase,
178178
exp: &handlers.AppError{
179-
Message: "Error " + errPurchasePending.Error(),
179+
Message: "Error " + errExpiredGPSSubPurchase.Error(),
180180
Code: http.StatusBadRequest,
181-
ErrorCode: purchasePendingErrCode,
181+
ErrorCode: "purchase_expired",
182182
Data: map[string]interface{}{
183-
"validationErrors": map[string]interface{}{"receiptErrors": errPurchasePending.Error()},
183+
"validationErrors": map[string]interface{}{"receiptErrors": errExpiredGPSSubPurchase.Error()},
184184
},
185185
},
186186
},
187187

188188
{
189-
name: "errPurchaseDeferred",
190-
given: errPurchaseDeferred,
189+
name: "errPendingGPSSubPurchase",
190+
given: errPendingGPSSubPurchase,
191191
exp: &handlers.AppError{
192-
Message: "Error " + errPurchaseDeferred.Error(),
192+
Message: "Error " + errPendingGPSSubPurchase.Error(),
193193
Code: http.StatusBadRequest,
194-
ErrorCode: purchaseDeferredErrCode,
194+
ErrorCode: "purchase_pending",
195195
Data: map[string]interface{}{
196-
"validationErrors": map[string]interface{}{"receiptErrors": errPurchaseDeferred.Error()},
197-
},
198-
},
199-
},
200-
201-
{
202-
name: "errPurchaseStatusUnknown",
203-
given: errPurchaseStatusUnknown,
204-
exp: &handlers.AppError{
205-
Message: "Error " + errPurchaseStatusUnknown.Error(),
206-
Code: http.StatusBadRequest,
207-
ErrorCode: purchaseStatusUnknownErrCode,
208-
Data: map[string]interface{}{
209-
"validationErrors": map[string]interface{}{"receiptErrors": errPurchaseStatusUnknown.Error()},
196+
"validationErrors": map[string]interface{}{"receiptErrors": errPendingGPSSubPurchase.Error()},
210197
},
211198
},
212199
},
213200

214201
{
215202
name: "errSomethingElse",
216-
given: model.Error("something else"),
203+
given: model.Error("something_else"),
217204
exp: &handlers.AppError{
218-
Message: "Error something else",
205+
Message: "Error something_else",
219206
Code: http.StatusBadRequest,
220-
ErrorCode: purchaseValidationErrCode,
207+
ErrorCode: "validation_failed",
221208
Data: map[string]interface{}{
222-
"validationErrors": map[string]interface{}{"receiptErrors": "something else"},
209+
"validationErrors": map[string]interface{}{"receiptErrors": "something_else"},
223210
},
224211
},
225212
},

services/skus/playstore.go

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package skus
2+
3+
import (
4+
"time"
5+
6+
"google.golang.org/api/androidpublisher/v3"
7+
8+
"github.com/brave-intl/bat-go/services/skus/model"
9+
)
10+
11+
const (
12+
errExpiredGPSSubPurchase = model.Error("playstore: subscription purchase expired")
13+
errPendingGPSSubPurchase = model.Error("playstore: subscription purchase pending")
14+
)
15+
16+
type playStoreSubPurchase androidpublisher.SubscriptionPurchase
17+
18+
func (x *playStoreSubPurchase) hasExpired(now time.Time) bool {
19+
return x.ExpiryTimeMillis < now.UnixMilli()
20+
}
21+
22+
func (x *playStoreSubPurchase) isPending() bool {
23+
// The payment state is not present for canceled or expired subscriptions.
24+
if x.PaymentState == nil {
25+
return false
26+
}
27+
28+
const pending, pendingDef = int64(0), int64(3)
29+
30+
state := *x.PaymentState
31+
32+
return state == pending || state == pendingDef
33+
}

services/skus/playstore_test.go

+105
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package skus
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
should "github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestPlayStoreSubPurchase_hasExpired(t *testing.T) {
11+
type tcGiven struct {
12+
sub *playStoreSubPurchase
13+
now time.Time
14+
}
15+
16+
type testCase struct {
17+
name string
18+
given tcGiven
19+
exp bool
20+
}
21+
22+
tests := []testCase{
23+
{
24+
name: "zero_expired",
25+
given: tcGiven{
26+
sub: &playStoreSubPurchase{},
27+
now: time.Date(2024, time.January, 1, 0, 0, 1, 0, time.UTC),
28+
},
29+
exp: true,
30+
},
31+
32+
{
33+
name: "not_expired",
34+
given: tcGiven{
35+
sub: &playStoreSubPurchase{
36+
ExpiryTimeMillis: time.Date(2024, time.January, 1, 0, 0, 1, 0, time.UTC).UnixMilli(),
37+
},
38+
39+
now: time.Date(2024, time.January, 2, 0, 0, 1, 0, time.UTC),
40+
},
41+
exp: true,
42+
},
43+
44+
{
45+
name: "not_expired_equal",
46+
given: tcGiven{
47+
sub: &playStoreSubPurchase{
48+
ExpiryTimeMillis: time.Date(2024, time.January, 1, 0, 0, 1, 0, time.UTC).UnixMilli(),
49+
},
50+
51+
now: time.Date(2024, time.January, 1, 0, 0, 1, 0, time.UTC),
52+
},
53+
},
54+
}
55+
56+
for i := range tests {
57+
tc := tests[i]
58+
59+
t.Run(tc.name, func(t *testing.T) {
60+
actual := tc.given.sub.hasExpired(tc.given.now)
61+
should.Equal(t, tc.exp, actual)
62+
})
63+
}
64+
}
65+
66+
func TestPlayStoreSubPurchase_isPending(t *testing.T) {
67+
type testCase struct {
68+
name string
69+
given *playStoreSubPurchase
70+
exp bool
71+
}
72+
73+
tests := []testCase{
74+
{
75+
name: "not_pending_no_payment",
76+
given: &playStoreSubPurchase{},
77+
},
78+
79+
{
80+
name: "not_pending_paid",
81+
given: &playStoreSubPurchase{PaymentState: ptrTo(int64(1))},
82+
},
83+
84+
{
85+
name: "pending",
86+
given: &playStoreSubPurchase{PaymentState: ptrTo(int64(0))},
87+
exp: true,
88+
},
89+
90+
{
91+
name: "pending_deferred",
92+
given: &playStoreSubPurchase{PaymentState: ptrTo(int64(3))},
93+
exp: true,
94+
},
95+
}
96+
97+
for i := range tests {
98+
tc := tests[i]
99+
100+
t.Run(tc.name, func(t *testing.T) {
101+
actual := tc.given.isPending()
102+
should.Equal(t, tc.exp, actual)
103+
})
104+
}
105+
}

services/skus/receipt.go

+16-74
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package skus
22

33
import (
44
"context"
5-
"errors"
65
"fmt"
76
"net/http"
87
"net/http/httputil"
@@ -17,45 +16,11 @@ import (
1716
"github.com/brave-intl/bat-go/services/skus/model"
1817
)
1918

20-
const (
21-
androidPaymentStatePending int64 = iota
22-
androidPaymentStatePaid
23-
androidPaymentStateTrial
24-
androidPaymentStatePendingDeferred
25-
)
26-
27-
const (
28-
androidCancelReasonUser int64 = 0
29-
androidCancelReasonSystem int64 = 1
30-
androidCancelReasonReplaced int64 = 2
31-
androidCancelReasonDeveloper int64 = 3
32-
33-
purchasePendingErrCode = "purchase_pending"
34-
purchaseDeferredErrCode = "purchase_deferred"
35-
purchaseStatusUnknownErrCode = "purchase_status_unknown"
36-
purchaseFailedErrCode = "purchase_failed"
37-
purchaseValidationErrCode = "validation_failed"
38-
)
39-
4019
const (
4120
errNoInAppTx model.Error = "no in app info in response"
4221
errIOSPurchaseNotFound model.Error = "ios: purchase not found"
4322
)
4423

45-
var (
46-
errPurchaseUserCanceled = errors.New("purchase is canceled by user")
47-
errPurchaseSystemCanceled = errors.New("purchase is canceled by google playstore")
48-
errPurchaseReplacedCanceled = errors.New("purchase is canceled and replaced")
49-
errPurchaseDeveloperCanceled = errors.New("purchase is canceled by developer")
50-
51-
errPurchasePending = errors.New("purchase is pending")
52-
errPurchaseDeferred = errors.New("purchase is deferred")
53-
errPurchaseStatusUnknown = errors.New("purchase status is unknown")
54-
errPurchaseFailed = errors.New("purchase failed")
55-
56-
errPurchaseExpired = errors.New("purchase expired")
57-
)
58-
5924
type dumpTransport struct{}
6025

6126
func (dt *dumpTransport) RoundTrip(r *http.Request) (*http.Response, error) {
@@ -111,6 +76,8 @@ func newReceiptVerifier(cl *http.Client, asKey string, playKey []byte) (*receipt
11176
}
11277

11378
// validateApple validates Apple App Store receipt.
79+
//
80+
// TODO(pavelb): Propagate expiry time for properly updating the order.
11481
func (v *receiptVerifier) validateApple(ctx context.Context, req model.ReceiptRequest) (string, error) {
11582
asreq := appstore.IAPRequest{
11683
Password: v.asKey,
@@ -163,54 +130,29 @@ func (v *receiptVerifier) validateApple(ctx context.Context, req model.ReceiptRe
163130
return item.OriginalTransactionID, nil
164131
}
165132

166-
// validateGoogle validates Google Store receipt.
133+
// validateGoogle validates a Play Store receipt.
134+
//
135+
// TODO(pavelb): Propagate expiry time for properly updating the order.
167136
func (v *receiptVerifier) validateGoogle(ctx context.Context, req model.ReceiptRequest) (string, error) {
168-
l := logging.Logger(ctx, "skus").With().Str("func", "validateReceiptGoogle").Logger()
169-
170-
l.Debug().Str("receipt", fmt.Sprintf("%+v", req)).Msg("about to verify subscription")
137+
return v.validateGoogleTime(ctx, req, time.Now())
138+
}
171139

172-
resp, err := v.playStoreCl.VerifySubscription(ctx, req.Package, req.SubscriptionID, req.Blob)
140+
func (v *receiptVerifier) validateGoogleTime(ctx context.Context, req model.ReceiptRequest, now time.Time) (string, error) {
141+
sub, err := v.playStoreCl.VerifySubscription(ctx, req.Package, req.SubscriptionID, req.Blob)
173142
if err != nil {
174-
l.Error().Err(err).Msg("failed to verify subscription")
175-
return "", errPurchaseFailed
143+
return "", fmt.Errorf("failed to fetch subscription purchase: %w", err)
176144
}
177145

178-
// Check order expiration.
179-
if time.Unix(0, resp.ExpiryTimeMillis*int64(time.Millisecond)).Before(time.Now()) {
180-
return "", errPurchaseExpired
146+
psub := (*playStoreSubPurchase)(sub)
147+
if psub.hasExpired(now) {
148+
return "", errExpiredGPSSubPurchase
181149
}
182150

183-
l.Debug().Msgf("resp: %+v", resp)
184-
185-
if resp.PaymentState == nil {
186-
l.Error().Err(err).Msg("failed to verify subscription: no payment state")
187-
return "", errPurchaseFailed
151+
if psub.isPending() {
152+
return "", errPendingGPSSubPurchase
188153
}
189154

190-
// Check that the order was paid.
191-
switch *resp.PaymentState {
192-
case androidPaymentStatePaid, androidPaymentStateTrial:
193-
return req.Blob, nil
194-
195-
case androidPaymentStatePending:
196-
// Check for cancel reason.
197-
switch resp.CancelReason {
198-
case androidCancelReasonUser:
199-
return "", errPurchaseUserCanceled
200-
case androidCancelReasonSystem:
201-
return "", errPurchaseSystemCanceled
202-
case androidCancelReasonReplaced:
203-
return "", errPurchaseReplacedCanceled
204-
case androidCancelReasonDeveloper:
205-
return "", errPurchaseDeveloperCanceled
206-
}
207-
return "", errPurchasePending
208-
209-
case androidPaymentStatePendingDeferred:
210-
return "", errPurchaseDeferred
211-
default:
212-
return "", errPurchaseStatusUnknown
213-
}
155+
return req.Blob, nil
214156
}
215157

216158
func findInAppBySubID(iap []appstore.InApp, subID string) (*appstore.InApp, bool) {

0 commit comments

Comments
 (0)