feat: add uuid
This commit is contained in:
@@ -8,6 +8,7 @@ const (
|
||||
)
|
||||
|
||||
// CoinPackage بستهی سکه با پول واقعی (IAP) — ممکن است VIP هم بدهد.
|
||||
// SKU شناسهی محصول در پنلِ مایکت/کافهبازار (uuid) است.
|
||||
type CoinPackage struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -15,6 +16,7 @@ type CoinPackage struct {
|
||||
VIPDays int `json:"vip_days"`
|
||||
PriceToman int `json:"price_toman"`
|
||||
BonusPct int `json:"bonus_pct"`
|
||||
SKU string `json:"sku"`
|
||||
}
|
||||
|
||||
// TicketPackage بستهی بلیط با پول واقعی (IAP).
|
||||
@@ -23,6 +25,7 @@ type TicketPackage struct {
|
||||
Title string `json:"title"`
|
||||
Tickets int64 `json:"tickets"`
|
||||
PriceToman int `json:"price_toman"`
|
||||
SKU string `json:"sku"`
|
||||
}
|
||||
|
||||
// CardSkin اسکین کارت که با سکه باز میشود (قیمت ۰ یعنی پیشفرض رایگان).
|
||||
@@ -39,6 +42,7 @@ type Booster struct {
|
||||
Multiplier int `json:"multiplier"`
|
||||
Hours int `json:"hours"`
|
||||
PriceToman int `json:"price_toman"`
|
||||
SKU string `json:"sku"`
|
||||
}
|
||||
|
||||
// VIPPackage بستهی اشتراک VIP با پول واقعی (IAP).
|
||||
@@ -47,6 +51,7 @@ type VIPPackage struct {
|
||||
Title string `json:"title"`
|
||||
Months int `json:"months"`
|
||||
PriceToman int `json:"price_toman"`
|
||||
SKU string `json:"sku"`
|
||||
}
|
||||
|
||||
// TableTier نوع میز: تعداد دست، ورودی، جایزه، XP و جام.
|
||||
|
||||
@@ -8,13 +8,13 @@ func (s *Service) LoadCatalog(ctx context.Context) error {
|
||||
var c Catalog
|
||||
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, title, coins, vip_days, price_toman, bonus_pct FROM coin_packages WHERE enabled = 1 ORDER BY sort`)
|
||||
`SELECT id, title, coins, vip_days, price_toman, bonus_pct, sku FROM coin_packages WHERE enabled = 1 ORDER BY sort`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var p CoinPackage
|
||||
if err := rows.Scan(&p.ID, &p.Title, &p.Coins, &p.VIPDays, &p.PriceToman, &p.BonusPct); err != nil {
|
||||
if err := rows.Scan(&p.ID, &p.Title, &p.Coins, &p.VIPDays, &p.PriceToman, &p.BonusPct, &p.SKU); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
@@ -23,13 +23,13 @@ func (s *Service) LoadCatalog(ctx context.Context) error {
|
||||
rows.Close()
|
||||
|
||||
rows, err = s.db.QueryContext(ctx,
|
||||
`SELECT id, title, tickets, price_toman FROM ticket_packages WHERE enabled = 1 ORDER BY sort`)
|
||||
`SELECT id, title, tickets, price_toman, sku FROM ticket_packages WHERE enabled = 1 ORDER BY sort`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var p TicketPackage
|
||||
if err := rows.Scan(&p.ID, &p.Title, &p.Tickets, &p.PriceToman); err != nil {
|
||||
if err := rows.Scan(&p.ID, &p.Title, &p.Tickets, &p.PriceToman, &p.SKU); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
@@ -53,13 +53,13 @@ func (s *Service) LoadCatalog(ctx context.Context) error {
|
||||
rows.Close()
|
||||
|
||||
rows, err = s.db.QueryContext(ctx,
|
||||
`SELECT id, title, multiplier, hours, price_toman FROM boosters WHERE enabled = 1 ORDER BY sort`)
|
||||
`SELECT id, title, multiplier, hours, price_toman, sku FROM boosters WHERE enabled = 1 ORDER BY sort`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var p Booster
|
||||
if err := rows.Scan(&p.ID, &p.Title, &p.Multiplier, &p.Hours, &p.PriceToman); err != nil {
|
||||
if err := rows.Scan(&p.ID, &p.Title, &p.Multiplier, &p.Hours, &p.PriceToman, &p.SKU); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
@@ -68,13 +68,13 @@ func (s *Service) LoadCatalog(ctx context.Context) error {
|
||||
rows.Close()
|
||||
|
||||
rows, err = s.db.QueryContext(ctx,
|
||||
`SELECT id, title, months, price_toman FROM vip_packages WHERE enabled = 1 ORDER BY sort`)
|
||||
`SELECT id, title, months, price_toman, sku FROM vip_packages WHERE enabled = 1 ORDER BY sort`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var p VIPPackage
|
||||
if err := rows.Scan(&p.ID, &p.Title, &p.Months, &p.PriceToman); err != nil {
|
||||
if err := rows.Scan(&p.ID, &p.Title, &p.Months, &p.PriceToman, &p.SKU); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -248,27 +248,28 @@ func (s *Service) addXPTx(ctx context.Context, tx *sql.Tx, userID int64, base in
|
||||
type PurchaseRequest struct {
|
||||
Store string `json:"store"` // bazaar | myket
|
||||
Kind string `json:"kind"` // coin | ticket | booster | vip
|
||||
ProductID string `json:"product_id"` // شناسه بسته در کاتالوگ
|
||||
ProductID string `json:"product_id"` // شناسه بسته در کاتالوگ (SKU فروشگاه)
|
||||
Token string `json:"token"` // purchaseToken
|
||||
// برای تأیید امضای آفلاین (کافهبازار): متنِ خامِ خرید و امضای RSA (base64).
|
||||
PurchaseData string `json:"purchase_data"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
// VerifyPurchase خرید را تأیید و در صورت معتبر بودن اعتبار/بلیط/VIP/بوستر میدهد (idempotent).
|
||||
func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req PurchaseRequest) error {
|
||||
ok, err := s.iap.Verify(ctx, req.Store, req.ProductID, req.Token)
|
||||
if err != nil || !ok {
|
||||
return ErrIAPInvalid
|
||||
}
|
||||
|
||||
// ابتدا بسته را از کاتالوگ مییابیم تا پاداش و SKU (شناسهی محصول در فروشگاه)
|
||||
// را بهدست آوریم؛ سپس با همان SKU خرید را تأیید میکنیم.
|
||||
var coins, tickets int64
|
||||
var vipDays int
|
||||
var booster *Booster
|
||||
var sku string
|
||||
switch req.Kind {
|
||||
case "coin":
|
||||
p := s.findCoinPackage(req.ProductID)
|
||||
if p == nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
coins, vipDays = p.Coins, p.VIPDays
|
||||
coins, vipDays, sku = p.Coins, p.VIPDays, p.SKU
|
||||
if s.isVIP(ctx, userID) {
|
||||
coins += coins / 10 // پاداش ۱۰٪ VIP
|
||||
}
|
||||
@@ -277,21 +278,36 @@ func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req Purchase
|
||||
if p == nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
tickets = p.Tickets
|
||||
tickets, sku = p.Tickets, p.SKU
|
||||
case "booster":
|
||||
booster = s.findBooster(req.ProductID)
|
||||
if booster == nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
sku = booster.SKU
|
||||
case "vip":
|
||||
p := s.findVIPPackage(req.ProductID)
|
||||
if p == nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
vipDays = p.Months * 30 // هر ماه = ۳۰ روز
|
||||
vipDays, sku = p.Months*30, p.SKU // هر ماه = ۳۰ روز
|
||||
default:
|
||||
return ErrNotFound
|
||||
}
|
||||
if sku == "" {
|
||||
sku = req.ProductID // اگر SKU تنظیم نشده، از شناسهی کاتالوگ استفاده میشود
|
||||
}
|
||||
|
||||
ok, err := s.iap.Verify(ctx, IAPProof{
|
||||
Store: req.Store,
|
||||
ProductID: sku,
|
||||
Token: req.Token,
|
||||
PurchaseData: req.PurchaseData,
|
||||
Signature: req.Signature,
|
||||
})
|
||||
if err != nil || !ok {
|
||||
return ErrIAPInvalid
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
|
||||
+130
-38
@@ -2,9 +2,16 @@ package economy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -13,9 +20,20 @@ type AdVerifier interface {
|
||||
Verify(ctx context.Context, userID int64, token string) (bool, error)
|
||||
}
|
||||
|
||||
// IAPProof دادههای لازم برای تأیید یک خرید درونبرنامهای.
|
||||
// برای مایکت: Store/ProductID/Token کافی است (تأیید سمتسرور با access token).
|
||||
// برای کافهبازار: PurchaseData + Signature برای تأیید امضای RSA آفلاین.
|
||||
type IAPProof struct {
|
||||
Store string
|
||||
ProductID string
|
||||
Token string
|
||||
PurchaseData string
|
||||
Signature string
|
||||
}
|
||||
|
||||
// IAPVerifier تأیید میکند که یک خرید درونبرنامهای معتبر و پرداختشده است.
|
||||
type IAPVerifier interface {
|
||||
Verify(ctx context.Context, store, productID, purchaseToken string) (bool, error)
|
||||
Verify(ctx context.Context, p IAPProof) (bool, error)
|
||||
}
|
||||
|
||||
// --- استاب توسعه: هر توکن غیرخالی را معتبر میداند (فقط برای محیط dev/تست) ---
|
||||
@@ -28,8 +46,8 @@ func (DevAdVerifier) Verify(_ context.Context, _ int64, token string) (bool, err
|
||||
|
||||
type DevIAPVerifier struct{}
|
||||
|
||||
func (DevIAPVerifier) Verify(_ context.Context, _ string, _ string, token string) (bool, error) {
|
||||
return token != "", nil
|
||||
func (DevIAPVerifier) Verify(_ context.Context, p IAPProof) (bool, error) {
|
||||
return p.Token != "", nil
|
||||
}
|
||||
|
||||
// --- آداپتر تپسل (تأیید سمتسرور تبلیغ rewarded) ---
|
||||
@@ -77,63 +95,137 @@ func (t *TapsellAdVerifier) Verify(ctx context.Context, userID int64, token stri
|
||||
return out.Valid && out.Completed, nil
|
||||
}
|
||||
|
||||
// --- آداپتر IAP کافهبازار / مایکت ---
|
||||
// --- مایکت: تأیید سمتسرور با X-Access-Token ---
|
||||
//
|
||||
// هر دو فروشگاه API شبیه Google Play دارند: با access_token (که از refresh_token
|
||||
// گرفته میشود) وضعیت یک purchaseToken برای یک محصول را اعتبارسنجی میکنیم.
|
||||
type StoreIAPVerifier struct {
|
||||
// قالب آدرس اعتبارسنجی؛ %s ها به ترتیب: package, product, token
|
||||
ValidateURLFmt string
|
||||
PackageName string
|
||||
AccessToken string // در عمل باید از refresh_token تازهسازی شود
|
||||
client *http.Client
|
||||
// GET https://developer.myket.ir/api/applications/{pkg}/purchases/products/{sku}/tokens/{token}
|
||||
// هدر: X-Access-Token: <accessToken> . پاسخ purchaseState=0 یعنی معتبر (غیرِ صفر=مسترد).
|
||||
type MyketVerifier struct {
|
||||
PackageName string
|
||||
AccessToken string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewBazaarVerifier آداپتر کافهبازار.
|
||||
func NewBazaarVerifier(pkg, accessToken string) *StoreIAPVerifier {
|
||||
return &StoreIAPVerifier{
|
||||
ValidateURLFmt: "https://pardakht.cafebazaar.ir/devapi/v2/api/validate/%s/inapp/%s/purchases/%s/",
|
||||
PackageName: pkg,
|
||||
AccessToken: accessToken,
|
||||
client: &http.Client{Timeout: 6 * time.Second},
|
||||
func NewMyketVerifier(pkg, accessToken string) *MyketVerifier {
|
||||
return &MyketVerifier{
|
||||
PackageName: pkg,
|
||||
AccessToken: accessToken,
|
||||
client: &http.Client{Timeout: 6 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// NewMyketVerifier آداپتر مایکت.
|
||||
func NewMyketVerifier(pkg, accessToken string) *StoreIAPVerifier {
|
||||
return &StoreIAPVerifier{
|
||||
ValidateURLFmt: "https://developer.myket.ir/api/applications/%s/purchases/products/%s/tokens/%s/",
|
||||
PackageName: pkg,
|
||||
AccessToken: accessToken,
|
||||
client: &http.Client{Timeout: 6 * time.Second},
|
||||
func (v *MyketVerifier) Verify(ctx context.Context, p IAPProof) (bool, error) {
|
||||
if v.AccessToken == "" || p.Token == "" {
|
||||
return false, fmt.Errorf("myket: missing access token or purchase token")
|
||||
}
|
||||
}
|
||||
|
||||
func (v *StoreIAPVerifier) Verify(ctx context.Context, _ string, productID, purchaseToken string) (bool, error) {
|
||||
if v.AccessToken == "" || purchaseToken == "" {
|
||||
return false, fmt.Errorf("iap: missing access token or purchase token")
|
||||
}
|
||||
url := fmt.Sprintf(v.ValidateURLFmt, v.PackageName, productID, purchaseToken)
|
||||
url := fmt.Sprintf(
|
||||
"https://developer.myket.ir/api/applications/%s/purchases/products/%s/tokens/%s",
|
||||
v.PackageName, p.ProductID, p.Token)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+v.AccessToken)
|
||||
req.Header.Set("X-Access-Token", v.AccessToken)
|
||||
resp, err := v.client.Do(req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, nil
|
||||
return false, nil // ۴۰۴/۴۰۱ ⇒ توکنِ نامعتبر
|
||||
}
|
||||
var out struct {
|
||||
// هر دو فروشگاه فیلدی شبیه purchaseState برمیگردانند (۰ = خریداریشده)
|
||||
PurchaseState int `json:"purchaseState"`
|
||||
ConsumptionState int `json:"consumptionState"`
|
||||
PurchaseState int `json:"purchaseState"` // ۰=خریداریشده، غیرِصفر=مسترد
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return out.PurchaseState == 0, nil
|
||||
}
|
||||
|
||||
// --- کافهبازار: تأیید امضای RSA آفلاین (فقط کلید عمومی لازم است) ---
|
||||
//
|
||||
// کلاینت purchaseData (متنِ خامِ JSON) و signature (base64) را میفرستد؛ صحتِ امضا
|
||||
// با کلیدِ عمومیِ RSA و الگوریتم SHA1withRSA (مانند Google Play/Bazaar) بررسی میشود.
|
||||
type BazaarRSAVerifier struct {
|
||||
pub *rsa.PublicKey
|
||||
}
|
||||
|
||||
// NewBazaarRSAVerifier کلیدِ عمومی را از رشتهی base64 (DER/PKIX) یا PEM میپذیرد.
|
||||
func NewBazaarRSAVerifier(publicKey string) (*BazaarRSAVerifier, error) {
|
||||
pub, err := parseRSAPublicKey(publicKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &BazaarRSAVerifier{pub: pub}, nil
|
||||
}
|
||||
|
||||
func (v *BazaarRSAVerifier) Verify(_ context.Context, p IAPProof) (bool, error) {
|
||||
if p.PurchaseData == "" || p.Signature == "" {
|
||||
return false, fmt.Errorf("bazaar: missing purchase data or signature")
|
||||
}
|
||||
sig, err := base64.StdEncoding.DecodeString(p.Signature)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("bazaar: bad signature encoding: %w", err)
|
||||
}
|
||||
h := sha1.Sum([]byte(p.PurchaseData))
|
||||
if err := rsa.VerifyPKCS1v15(v.pub, crypto.SHA1, h[:], sig); err != nil {
|
||||
return false, nil // امضای نامعتبر ⇒ خریدِ نامعتبر (نه خطای سرور)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// parseRSAPublicKey کلیدِ عمومیِ RSA را از PEM یا base64ِ خامِ DER (خروجیِ کنسولِ
|
||||
// توسعهدهنده) استخراج میکند.
|
||||
func parseRSAPublicKey(key string) (*rsa.PublicKey, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
var der []byte
|
||||
if strings.Contains(key, "BEGIN") {
|
||||
block, _ := pem.Decode([]byte(key))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("rsa: invalid PEM")
|
||||
}
|
||||
der = block.Bytes
|
||||
} else {
|
||||
clean := strings.NewReplacer("\n", "", "\r", "", " ", "").Replace(key)
|
||||
b, err := base64.StdEncoding.DecodeString(clean)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rsa: invalid base64 key: %w", err)
|
||||
}
|
||||
der = b
|
||||
}
|
||||
pub, err := x509.ParsePKIXPublicKey(der)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rsa: parse key: %w", err)
|
||||
}
|
||||
rsaPub, ok := pub.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("rsa: not an RSA public key")
|
||||
}
|
||||
return rsaPub, nil
|
||||
}
|
||||
|
||||
// StoreRouter بر اساس store، تأیید را به مایکت یا کافهبازار میسپارد.
|
||||
// اگر فروشگاهی پیکربندی نشده باشد و devFallback روشن باشد، از DevIAPVerifier
|
||||
// استفاده میشود (برای توسعه/تست).
|
||||
type StoreRouter struct {
|
||||
Myket IAPVerifier
|
||||
Bazaar IAPVerifier
|
||||
DevFallback bool
|
||||
}
|
||||
|
||||
func (r StoreRouter) Verify(ctx context.Context, p IAPProof) (bool, error) {
|
||||
var v IAPVerifier
|
||||
switch p.Store {
|
||||
case "myket":
|
||||
v = r.Myket
|
||||
case "bazaar", "cafe", "cafebazaar":
|
||||
v = r.Bazaar
|
||||
}
|
||||
if v == nil {
|
||||
if r.DevFallback {
|
||||
return DevIAPVerifier{}.Verify(ctx, p)
|
||||
}
|
||||
return false, fmt.Errorf("iap: store %q not configured", p.Store)
|
||||
}
|
||||
return v.Verify(ctx, p)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package economy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBazaarRSAVerify امضای معتبر را میپذیرد و امضای/دیتای دستکاریشده را رد میکند.
|
||||
func TestBazaarRSAVerify(t *testing.T) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
der, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pubB64 := base64.StdEncoding.EncodeToString(der) // مثلِ خروجیِ کنسولِ کافهبازار
|
||||
|
||||
v, err := NewBazaarRSAVerifier(pubB64)
|
||||
if err != nil {
|
||||
t.Fatalf("new verifier: %v", err)
|
||||
}
|
||||
|
||||
data := `{"orderId":"1","productId":"coin_small","purchaseToken":"tok123","purchaseState":0}`
|
||||
h := sha1.Sum([]byte(data))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA1, h[:])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sigB64 := base64.StdEncoding.EncodeToString(sig)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
ok, err := v.Verify(ctx, IAPProof{PurchaseData: data, Signature: sigB64})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("valid signature rejected: ok=%v err=%v", ok, err)
|
||||
}
|
||||
|
||||
// دیتای دستکاریشده ⇒ رد
|
||||
ok, _ = v.Verify(ctx, IAPProof{PurchaseData: data + " ", Signature: sigB64})
|
||||
if ok {
|
||||
t.Fatal("tampered data accepted")
|
||||
}
|
||||
|
||||
// امضای دستکاریشده ⇒ رد
|
||||
bad := base64.StdEncoding.EncodeToString([]byte("not-a-real-signature"))
|
||||
ok, _ = v.Verify(ctx, IAPProof{PurchaseData: data, Signature: bad})
|
||||
if ok {
|
||||
t.Fatal("bad signature accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoreRouter مسیردهی بر اساس store و fallback را بررسی میکند.
|
||||
func TestStoreRouter(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// بدونِ پیکربندی و بدونِ fallback ⇒ خطا.
|
||||
r := StoreRouter{}
|
||||
if ok, err := r.Verify(ctx, IAPProof{Store: "myket", Token: "x"}); ok || err == nil {
|
||||
t.Fatal("unconfigured store should error")
|
||||
}
|
||||
|
||||
// با devFallback ⇒ توکنِ غیرخالی معتبر است.
|
||||
r = StoreRouter{DevFallback: true}
|
||||
if ok, err := r.Verify(ctx, IAPProof{Store: "bazaar", Token: "x"}); err != nil || !ok {
|
||||
t.Fatalf("dev fallback failed: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user