对接
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func openFixtureDB() (*gorm.DB, error) {
|
||||
path, err := filepath.Abs(filepath.Join(outputDir, "acceptance.db"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root, err := filepath.Abs(outputDir)
|
||||
if err != nil || filepath.Dir(path) != root {
|
||||
return nil, fmt.Errorf("database path is not inside the acceptance directory")
|
||||
}
|
||||
return gorm.Open(sqlite.Open(path), &gorm.Config{})
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURL = "http://127.0.0.1:18080"
|
||||
wsURL = "ws://127.0.0.1:18080/ws"
|
||||
outputDir = ".tmp-live-acceptance"
|
||||
adminPassword = "LocalAdmin_2026!"
|
||||
userPassword = "LocalAccept_2026!"
|
||||
)
|
||||
|
||||
type apiClient struct {
|
||||
http *http.Client
|
||||
token string
|
||||
}
|
||||
|
||||
type envelope struct {
|
||||
Code int `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
type account struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
ID uint `json:"id"`
|
||||
}
|
||||
|
||||
type fixtures struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Database string `json:"database"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Admin account `json:"admin"`
|
||||
Anchor account `json:"anchor"`
|
||||
Viewer account `json:"viewer"`
|
||||
Other account `json:"other"`
|
||||
CategoryID uint `json:"categoryId"`
|
||||
RoomID uint `json:"roomId"`
|
||||
ReplayID uint `json:"replayRoomId"`
|
||||
DisabledID uint `json:"disabledRoomId"`
|
||||
BrowserID uint `json:"browserRoomId,omitempty"`
|
||||
GiftID uint `json:"giftId"`
|
||||
Checks []string `json:"setupChecks"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
if len(os.Args) != 2 {
|
||||
log.Fatal("usage: go run ./tmp/live_acceptance <setup|verify|publisher|mock-payment|browser-room>")
|
||||
}
|
||||
var err error
|
||||
switch os.Args[1] {
|
||||
case "setup":
|
||||
err = setup()
|
||||
case "verify":
|
||||
err = verify()
|
||||
case "publisher":
|
||||
err = holdPublisher()
|
||||
case "mock-payment":
|
||||
err = serveMockPayments()
|
||||
case "browser-room":
|
||||
err = setupBrowserRoom()
|
||||
default:
|
||||
err = errors.New("unknown command")
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func newClient() *apiClient {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return &apiClient{http: &http.Client{Jar: jar, Timeout: 30 * time.Second}}
|
||||
}
|
||||
|
||||
func (c *apiClient) call(method, path string, body, out any) error {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reader = bytes.NewReader(data)
|
||||
}
|
||||
req, err := http.NewRequest(method, baseURL+path, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.token != "" {
|
||||
req.Header.Set("x-token", c.token)
|
||||
}
|
||||
response, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var result envelope
|
||||
if err = json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
return fmt.Errorf("%s %s status=%d: %w", method, path, response.StatusCode, err)
|
||||
}
|
||||
if result.Code != 0 || response.StatusCode >= 400 {
|
||||
return fmt.Errorf("%s %s status=%d code=%d: %s", method, path, response.StatusCode, result.Code, result.Msg)
|
||||
}
|
||||
if out != nil {
|
||||
return json.Unmarshal(result.Data, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func login(user account, admin bool) (*apiClient, error) {
|
||||
c := newClient()
|
||||
path := "/live/client/auth/login"
|
||||
if admin {
|
||||
path = "/base/login"
|
||||
}
|
||||
var result struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := c.call("POST", path, map[string]any{"username": user.Username, "password": user.Password}, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Token == "" {
|
||||
return nil, errors.New("login returned no token")
|
||||
}
|
||||
c.token = result.Token
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func readFixtures() (*fixtures, error) {
|
||||
raw, err := os.ReadFile(filepath.Join(outputDir, "fixtures.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var f fixtures
|
||||
if err = json.Unmarshal(raw, &f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
absolute, err := filepath.Abs(filepath.Join(outputDir, "acceptance.db"))
|
||||
if err != nil || f.Database != absolute || f.BaseURL != baseURL {
|
||||
return nil, errors.New("fixture points outside the isolated acceptance environment")
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func saveJSON(name string, value any) error {
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(outputDir, name), append(data, '\n'), 0600)
|
||||
}
|
||||
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func check(condition bool, format string, args ...any) {
|
||||
if !condition {
|
||||
panic(fmt.Sprintf(format, args...))
|
||||
}
|
||||
}
|
||||
|
||||
func roomPath(id uint) string { return fmt.Sprintf("/live/rooms/%d", id) }
|
||||
|
||||
func adminRoomPath(id uint) string { return fmt.Sprintf("/live/admin/rooms/%d", id) }
|
||||
|
||||
func makeRoom(name string, categoryID, anchorID uint, replay string, enabled bool) dto.LiveRoomRequest {
|
||||
return dto.LiveRoomRequest{
|
||||
Name: name, CategoryID: categoryID, AnchorUserID: anchorID,
|
||||
CoverURL: "/uploads/acceptance-cover.jpg", Intro: "Local isolated acceptance room",
|
||||
ReplayURL: replay, Enabled: enabled,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const mockURL = "http://127.0.0.1:18081"
|
||||
const mockAppID = "local-acceptance-app"
|
||||
|
||||
type mockKeys struct {
|
||||
MerchantPrivate string `json:"merchantPrivate"`
|
||||
PlatformPrivate string `json:"platformPrivate"`
|
||||
PlatformPublic string `json:"platformPublic"`
|
||||
}
|
||||
|
||||
type mockOrder struct {
|
||||
Number string `json:"number"`
|
||||
Amount string `json:"amount"`
|
||||
Paid bool `json:"paid"`
|
||||
}
|
||||
|
||||
type mockPayments struct {
|
||||
keys *mockKeys
|
||||
key *rsa.PrivateKey
|
||||
mu sync.Mutex
|
||||
orders map[string]mockOrder
|
||||
}
|
||||
|
||||
func newRSAKey() (*rsa.PrivateKey, string, string) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
must(err)
|
||||
private := string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
|
||||
public, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
|
||||
must(err)
|
||||
return key, private, string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: public}))
|
||||
}
|
||||
|
||||
func loadMockKeys() (*mockKeys, error) {
|
||||
raw, err := os.ReadFile(filepath.Join(outputDir, "mock-payment-keys.json"))
|
||||
if err == nil {
|
||||
var keys mockKeys
|
||||
err = json.Unmarshal(raw, &keys)
|
||||
return &keys, err
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
_, merchant, _ := newRSAKey()
|
||||
_, private, public := newRSAKey()
|
||||
keys := &mockKeys{MerchantPrivate: merchant, PlatformPrivate: private, PlatformPublic: public}
|
||||
return keys, saveJSON("mock-payment-keys.json", keys)
|
||||
}
|
||||
|
||||
func parsePrivate(value string) *rsa.PrivateKey {
|
||||
block, _ := pem.Decode([]byte(value))
|
||||
check(block != nil, "invalid local test private key")
|
||||
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
must(err)
|
||||
return key
|
||||
}
|
||||
|
||||
func rsa2Sign(key *rsa.PrivateKey, raw []byte) string {
|
||||
digest := sha256.Sum256(raw)
|
||||
signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
|
||||
must(err)
|
||||
return base64.StdEncoding.EncodeToString(signature)
|
||||
}
|
||||
|
||||
func signingFields(values url.Values, includeSignType bool) string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
if key != "sign" && (includeSignType || key != "sign_type") && values.Get(key) != "" {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
parts = append(parts, key+"="+values.Get(key))
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
func serveMockPayments() error {
|
||||
keys, err := loadMockKeys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m := &mockPayments{keys: keys, key: parsePrivate(keys.PlatformPrivate), orders: map[string]mockOrder{}}
|
||||
if raw, readErr := os.ReadFile(filepath.Join(outputDir, "mock-payment-orders.json")); readErr == nil {
|
||||
must(json.Unmarshal(raw, &m.orders))
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, "local-only-mock") })
|
||||
mux.HandleFunc("/gateway", m.gateway)
|
||||
mux.HandleFunc("/confirm", m.confirm)
|
||||
log.Printf("Local-only signed payment mock listening at %s", mockURL)
|
||||
server := &http.Server{Addr: "127.0.0.1:18081", Handler: mux, ReadHeaderTimeout: 5 * time.Second}
|
||||
return server.ListenAndServe()
|
||||
}
|
||||
|
||||
func (m *mockPayments) gateway(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
merchant := parsePrivate(m.keys.MerchantPrivate)
|
||||
signature, err := base64.StdEncoding.DecodeString(r.Form.Get("sign"))
|
||||
digest := sha256.Sum256([]byte(signingFields(r.Form, true)))
|
||||
if err != nil || rsa.VerifyPKCS1v15(&merchant.PublicKey, crypto.SHA256, digest[:], signature) != nil || r.Form.Get("app_id") != mockAppID {
|
||||
http.Error(w, "invalid local merchant signature", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
var content map[string]any
|
||||
if err = json.Unmarshal([]byte(r.Form.Get("biz_content")), &content); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
number, _ := content["out_trade_no"].(string)
|
||||
method := r.Form.Get("method")
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var response map[string]any
|
||||
switch method {
|
||||
case "alipay.trade.precreate":
|
||||
amount, _ := content["total_amount"].(string)
|
||||
m.orders[number] = mockOrder{Number: number, Amount: amount}
|
||||
must(saveJSON("mock-payment-orders.json", m.orders))
|
||||
response = map[string]any{
|
||||
"code": "10000", "msg": "Success", "out_trade_no": number,
|
||||
"qr_code": mockURL + "/confirm?orderNo=" + url.QueryEscape(number),
|
||||
}
|
||||
case "alipay.trade.query":
|
||||
order, found := m.orders[number]
|
||||
if !found {
|
||||
http.Error(w, "unknown local test order", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
status := "WAIT_BUYER_PAY"
|
||||
if order.Paid {
|
||||
status = "TRADE_SUCCESS"
|
||||
}
|
||||
response = map[string]any{
|
||||
"code": "10000", "msg": "Success", "trade_no": "LOCAL-" + number, "out_trade_no": number,
|
||||
"trade_status": status, "total_amount": order.Amount, "buyer_pay_amount": order.Amount,
|
||||
"receipt_amount": order.Amount, "trans_currency": "CNY",
|
||||
}
|
||||
default:
|
||||
http.Error(w, "method not implemented by local-only mock", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(response)
|
||||
must(err)
|
||||
envelope := map[string]any{strings.ReplaceAll(method, ".", "_") + "_response": json.RawMessage(raw), "sign": rsa2Sign(m.key, raw)}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
must(json.NewEncoder(w).Encode(envelope))
|
||||
}
|
||||
|
||||
func (m *mockPayments) confirm(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "local test confirmation requires POST", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
must(r.ParseForm())
|
||||
number := r.Form.Get("orderNo")
|
||||
m.mu.Lock()
|
||||
order, found := m.orders[number]
|
||||
if !found {
|
||||
m.mu.Unlock()
|
||||
http.Error(w, "unknown local test order", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
order.Paid = true
|
||||
m.orders[number] = order
|
||||
must(saveJSON("mock-payment-orders.json", m.orders))
|
||||
m.mu.Unlock()
|
||||
fields := url.Values{
|
||||
"app_id": {mockAppID}, "trade_no": {"LOCAL-" + number}, "out_trade_no": {number},
|
||||
"trade_status": {"TRADE_SUCCESS"}, "total_amount": {order.Amount}, "sign_type": {"RSA2"},
|
||||
}
|
||||
fields.Set("sign", rsa2Sign(m.key, []byte(signingFields(fields, false))))
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
response, err := client.PostForm(baseURL+"/payment/callback/alipay", fields)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
w.WriteHeader(response.StatusCode)
|
||||
_, _ = io.Copy(w, response.Body)
|
||||
}
|
||||
|
||||
func configureMockPayment(admin *apiClient) error {
|
||||
keys, err := loadMockKeys()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := http.Get(mockURL + "/health")
|
||||
if err != nil {
|
||||
return fmt.Errorf("start the local mock-payment command before verification: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("local payment mock unavailable")
|
||||
}
|
||||
return admin.call("PUT", "/integration/configs/payment/alipay", map[string]any{
|
||||
"enabled": true,
|
||||
"config": map[string]any{
|
||||
"app_id": mockAppID, "private_key": keys.MerchantPrivate, "public_key": keys.PlatformPublic,
|
||||
"environment": "sandbox", "sign_type": "RSA2", "method": "alipay.trade.precreate", "gateway_url": mockURL + "/gateway",
|
||||
"notify_url": baseURL + "/payment/callback/alipay",
|
||||
},
|
||||
}, nil)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
func setup() error {
|
||||
if f, err := readFixtures(); err == nil {
|
||||
fmt.Printf("Existing isolated fixtures retained: %s room=%d replay=%d\n", f.Database, f.RoomID, f.ReplayID)
|
||||
return nil
|
||||
}
|
||||
c := newClient()
|
||||
var status struct {
|
||||
NeedInit bool `json:"needInit"`
|
||||
}
|
||||
must(c.call("POST", "/init/checkdb", nil, &status))
|
||||
databaseDir, err := filepath.Abs(outputDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status.NeedInit {
|
||||
must(c.call("POST", "/init/initdb", dto.DatabaseInitRequest{
|
||||
DBType: "sqlite", DBName: "acceptance.db", DBPath: databaseDir,
|
||||
AdminPassword: adminPassword,
|
||||
}, nil))
|
||||
}
|
||||
db, err := openFixtureDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// This test-only fixture setting must be seeded before the first login.
|
||||
// Production configuration and security settings are never touched.
|
||||
must(db.Exec("UPDATE sys_security_config SET captcha_open = 1000 WHERE id = 1").Error)
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
must(sqlDB.Close())
|
||||
f := &fixtures{
|
||||
BaseURL: baseURL, Database: filepath.Join(databaseDir, "acceptance.db"),
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
Admin: account{Username: "admin", Password: adminPassword, ID: 1},
|
||||
}
|
||||
admin, err := login(f.Admin, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.Checks = append(f.Checks, "isolated SQLite initialization API and administrator login")
|
||||
|
||||
users := []*account{&f.Anchor, &f.Viewer, &f.Other}
|
||||
for index, username := range []string{"accept_anchor", "accept_viewer", "accept_other"} {
|
||||
var auth dto.LiveClientAuthResponse
|
||||
input := dto.LiveClientRegisterRequest{Username: username, Nickname: username, Password: userPassword, ConfirmPassword: userPassword}
|
||||
client := newClient()
|
||||
if err = client.call("POST", "/live/client/auth/register", input, &auth); err != nil {
|
||||
client, err = login(account{Username: username, Password: userPassword}, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create fixture %s: %w", username, err)
|
||||
}
|
||||
var user dto.LiveClientAccountResponse
|
||||
must(client.call("GET", "/live/client/me", nil, &user))
|
||||
auth.User = &user
|
||||
}
|
||||
check(auth.User != nil && auth.User.UserID != 0, "register %s returned no user", username)
|
||||
*users[index] = account{Username: username, Password: userPassword, ID: auth.User.UserID}
|
||||
}
|
||||
f.Checks = append(f.Checks, "PC anchor and two viewer registrations")
|
||||
|
||||
var integration dto.IntegrationConfigResponse
|
||||
must(admin.call("GET", "/integration/configs/websocket/melody", nil, &integration))
|
||||
must(admin.call("PUT", "/integration/configs/websocket/melody", dto.IntegrationConfigRequest{Enabled: true, Config: integration.Config}, nil))
|
||||
f.Checks = append(f.Checks, "WebSocket integration enabled through administration API")
|
||||
|
||||
var category dto.LiveCategoryResponse
|
||||
categoryInput := dto.LiveCategoryRequest{Name: "API Test", Icon: "video", Sort: 1, Enabled: true}
|
||||
must(admin.call("POST", "/live/admin/categories", categoryInput, &category))
|
||||
f.CategoryID = category.ID
|
||||
categoryInput.Name = "API Live"
|
||||
must(admin.call("PUT", fmt.Sprintf("/live/admin/categories/%d", category.ID), categoryInput, &category))
|
||||
f.Checks = append(f.Checks, "category creation and update through administration API")
|
||||
|
||||
for _, room := range []struct {
|
||||
name string
|
||||
replay string
|
||||
enabled bool
|
||||
id *uint
|
||||
}{
|
||||
{"Acceptance Live Room", "", true, &f.RoomID},
|
||||
{"Acceptance Replay Room", "/uploads/acceptance-replay.webm", true, &f.ReplayID},
|
||||
{"Acceptance Disabled Room", "", false, &f.DisabledID},
|
||||
} {
|
||||
var value dto.LiveRoomResponse
|
||||
must(admin.call("POST", "/live/admin/rooms", makeRoom(room.name, f.CategoryID, f.Anchor.ID, room.replay, room.enabled), &value))
|
||||
*room.id = value.ID
|
||||
}
|
||||
f.Checks = append(f.Checks, "live, recorded and disabled rooms created through administration API")
|
||||
var gift dto.GiftResponse
|
||||
must(admin.call("POST", "/live/admin/gifts", dto.GiftRequest{
|
||||
Name: "API Star", Icon: "star", Description: "Local acceptance gift",
|
||||
Price: 7, Tiers: []int{1, 10}, Enabled: true, Sort: 1,
|
||||
}, &gift))
|
||||
f.GiftID = gift.ID
|
||||
f.Checks = append(f.Checks, "configured gift created through administration API")
|
||||
must(admin.call("PUT", "/live/admin/recharge/plans", dto.RechargePlansRequest{Plans: []*dto.RechargePlan{
|
||||
{ID: 1, Sort: 1, Diamonds: 60, AmountCents: 600, Enabled: true},
|
||||
{ID: 2, Sort: 2, Diamonds: 300, AmountCents: 3000, Enabled: true},
|
||||
{ID: 3, Sort: 3, Diamonds: 10, AmountCents: 100, Enabled: true},
|
||||
}}, nil))
|
||||
f.Checks = append(f.Checks, "recharge plans configured through administration API")
|
||||
|
||||
must(os.MkdirAll(filepath.Join(outputDir, "uploads"), 0700))
|
||||
cover, err := os.ReadFile("frontend/src/assets/images/tupian/cyber_streamer_1.jpg")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
must(os.WriteFile(filepath.Join(outputDir, "uploads", "acceptance-cover.jpg"), cover, 0600))
|
||||
must(saveJSON("fixtures.json", f))
|
||||
fmt.Printf("API=%s database=%s\nanchor=%s viewer=%s other=%s password=%s\nroom=%d replay=%d disabled=%d category=%d gift=%d\n",
|
||||
baseURL, f.Database, f.Anchor.Username, f.Viewer.Username, f.Other.Username, userPassword,
|
||||
f.RoomID, f.ReplayID, f.DisabledID, f.CategoryID, f.GiftID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupBrowserRoom() error {
|
||||
f, err := readFixtures()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if f.BrowserID == 0 {
|
||||
admin, err := login(f.Admin, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var room dto.LiveRoomResponse
|
||||
must(admin.call("POST", "/live/admin/rooms", makeRoom("Acceptance Browser Room", f.CategoryID, f.Anchor.ID, "", true), &room))
|
||||
f.BrowserID = room.ID
|
||||
must(saveJSON("fixtures.json", f))
|
||||
}
|
||||
fmt.Printf("Dedicated browser publisher room=%d anchor=%s password=%s; API verification uses room=%d\n",
|
||||
f.BrowserID, f.Anchor.Username, f.Anchor.Password, f.RoomID)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"kra/internal/data/ledger"
|
||||
"kra/internal/service/dto"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type acceptanceReport struct {
|
||||
StartedAt string `json:"startedAt"`
|
||||
Checks []string `json:"checks"`
|
||||
Findings []string `json:"findings"`
|
||||
Records map[string]any `json:"records"`
|
||||
Database string `json:"database"`
|
||||
}
|
||||
|
||||
func verify() (err error) {
|
||||
f, err := readFixtures()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
report := &acceptanceReport{StartedAt: time.Now().Format(time.RFC3339), Database: f.Database, Records: map[string]any{}}
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
report.Findings = append(report.Findings, fmt.Sprint(recovered))
|
||||
}
|
||||
if saveErr := saveJSON("report.json", report); saveErr != nil {
|
||||
err = saveErr
|
||||
return
|
||||
}
|
||||
if len(report.Findings) > 0 {
|
||||
err = fmt.Errorf("%d passed groups, %d findings; inspect %s/report.json", len(report.Checks), len(report.Findings), outputDir)
|
||||
}
|
||||
}()
|
||||
admin, err := login(f.Admin, true)
|
||||
must(err)
|
||||
viewer, err := login(f.Viewer, false)
|
||||
must(err)
|
||||
other, err := login(f.Other, false)
|
||||
must(err)
|
||||
anchor, err := login(f.Anchor, false)
|
||||
must(err)
|
||||
public := newClient()
|
||||
db, err := openFixtureDB()
|
||||
must(err)
|
||||
sqlDB, err := db.DB()
|
||||
must(err)
|
||||
defer sqlDB.Close()
|
||||
must(admin.call("PATCH", adminRoomPath(f.DisabledID)+"/status", dto.LiveStatusRequest{Enabled: false}, nil))
|
||||
var list struct {
|
||||
List []*dto.LiveRoomResponse `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
must(public.call("GET", fmt.Sprintf("/live/rooms?categoryId=%d&pageSize=100", f.CategoryID), nil, &list))
|
||||
foundLive, foundReplay := false, false
|
||||
for _, item := range list.List {
|
||||
check(item.Enabled && item.ID != f.DisabledID, "public list exposed a disabled room: %+v", item)
|
||||
foundLive = foundLive || item.ID == f.RoomID
|
||||
foundReplay = foundReplay || item.ID == f.ReplayID
|
||||
}
|
||||
check(foundLive && foundReplay, "public listing omitted enabled live/replay fixtures: %+v", list)
|
||||
var replay dto.LiveRoomResponse
|
||||
must(public.call("GET", roomPath(f.ReplayID), nil, &replay))
|
||||
check(replay.Status == "offline" && replay.ReplayURL != "", "offline replay inaccessible: %+v", replay)
|
||||
check(public.call("GET", roomPath(f.DisabledID), nil, nil) != nil, "disabled room was exposed publicly")
|
||||
passed(report, "public list includes offline and recorded rooms and excludes disabled rooms")
|
||||
verifyCatalog(admin, public, viewer, f, report)
|
||||
verifyRecharge(admin, viewer, other, f, db, report)
|
||||
verifyLive(admin, public, anchor, viewer, other, f, db, report)
|
||||
verifyDisabledAccount(admin, other, f, report)
|
||||
verifyAdminSurface(admin, viewer, f, db, report)
|
||||
verifyClientAccount(report)
|
||||
var final ledger.WalletPO
|
||||
must(db.Where("user_id = ?", f.Viewer.ID).First(&final).Error)
|
||||
var chatRows, sends, rechargeOrders int64
|
||||
must(db.Table("live_chat_messages").Count(&chatRows).Error)
|
||||
must(db.Model(&ledger.GiftSendPO{}).Where("user_id = ?", f.Viewer.ID).Count(&sends).Error)
|
||||
must(db.Model(&ledger.RechargeOrderPO{}).Where("user_id = ?", f.Viewer.ID).Count(&rechargeOrders).Error)
|
||||
check(chatRows == 0, "chat content was persisted: %d rows", chatRows)
|
||||
report.Records["finalWallet"] = final
|
||||
report.Records["giftSendRows"] = sends
|
||||
report.Records["rechargeOrderRows"] = rechargeOrders
|
||||
report.Records["chatRows"] = chatRows
|
||||
passed(report, "SQLite wallet, gift-send and recharge records reconciled; chat table remains empty")
|
||||
fmt.Printf("Completed %d acceptance check groups\n", len(report.Checks))
|
||||
return nil
|
||||
}
|
||||
|
||||
func passed(report *acceptanceReport, text string) {
|
||||
report.Checks = append(report.Checks, text)
|
||||
fmt.Printf("PASS %s\n", text)
|
||||
must(saveJSON("report.json", report))
|
||||
}
|
||||
|
||||
func wallet(db *gorm.DB, userID uint) ledger.WalletPO {
|
||||
var value ledger.WalletPO
|
||||
must(db.Where("user_id = ?", userID).First(&value).Error)
|
||||
return value
|
||||
}
|
||||
|
||||
func giftCount(db *gorm.DB, userID uint) int64 {
|
||||
var count int64
|
||||
must(db.Model(&ledger.GiftSendPO{}).Where("user_id = ?", userID).Count(&count).Error)
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"kra/internal/service/dto"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func verifyAdminSurface(admin, viewer *apiClient, f *fixtures, db *gorm.DB, report *acceptanceReport) {
|
||||
for _, path := range []string{
|
||||
"/live/admin/dashboard",
|
||||
"/live/admin/categories?pageSize=100",
|
||||
"/live/admin/rooms?pageSize=100",
|
||||
"/live/admin/gifts?pageSize=100",
|
||||
fmt.Sprintf("/live/admin/gifts/%d", f.GiftID),
|
||||
"/live/admin/sends?pageSize=100",
|
||||
"/live/admin/orders?pageSize=100",
|
||||
"/live/admin/users?pageSize=100",
|
||||
} {
|
||||
must(admin.call("GET", path, nil, nil))
|
||||
check(viewer.call("GET", path, nil, nil) != nil, "client accessed %s", path)
|
||||
}
|
||||
must(admin.call("PUT", "/live/admin/rooms/sort", dto.LiveSortRequest{Values: map[uint]int{f.RoomID: 3, f.ReplayID: 4}}, nil))
|
||||
must(admin.call("PUT", "/live/admin/gifts/sort", dto.LiveSortRequest{Values: map[uint]int{f.GiftID: 1}}, nil))
|
||||
var room dto.LiveRoomResponse
|
||||
must(admin.call("GET", adminRoomPath(f.RoomID), nil, &room))
|
||||
check(room.Sort == 3, "room sorting was not persisted")
|
||||
var settings dto.PlatformSetting
|
||||
must(newClient().call("GET", "/live/settings", nil, &settings))
|
||||
check(settings.DiamondRateCents > 0, "diamond rate missing")
|
||||
must(admin.call("PUT", "/live/admin/settings", settings, nil))
|
||||
|
||||
before := wallet(db, f.Viewer.ID)
|
||||
path := fmt.Sprintf("/live/admin/users/%d", f.Viewer.ID)
|
||||
var account dto.AssetUserResponse
|
||||
must(admin.call("GET", path, nil, &account))
|
||||
check(account.DiamondBalance == before.DiamondBalance, "admin asset balance differs from ledger")
|
||||
for _, operation := range []string{"increase", "decrease"} {
|
||||
must(admin.call("POST", path+"/balance", dto.AssetAdjustmentRequest{Type: operation, Amount: 5, Reason: "local acceptance adjustment"}, &account))
|
||||
}
|
||||
check(wallet(db, f.Viewer.ID).DiamondBalance == before.DiamondBalance, "balanced manual adjustments changed wallet")
|
||||
check(viewer.call("POST", path+"/balance", dto.AssetAdjustmentRequest{Type: "increase", Amount: 5, Reason: "unauthorized adjustment"}, nil) != nil, "client adjusted their own wallet")
|
||||
passed(report, "all administration list/detail surfaces, sorting, settings and audited balance adjustment; clients denied administration access")
|
||||
}
|
||||
|
||||
func verifyClientAccount(report *acceptanceReport) {
|
||||
client := newClient()
|
||||
username := fmt.Sprintf("accept_%d", time.Now().UnixMilli())
|
||||
var registered dto.LiveClientAuthResponse
|
||||
must(client.call("POST", "/live/client/auth/register", dto.LiveClientRegisterRequest{
|
||||
Username: username, Nickname: "Local viewer", Password: userPassword, ConfirmPassword: userPassword,
|
||||
}, ®istered))
|
||||
check(registered.User != nil && registered.User.DiamondBalance == 100, "new account did not receive exactly 100 diamonds")
|
||||
client.token = registered.Token
|
||||
var profile dto.LiveClientAccountResponse
|
||||
must(client.call("PUT", "/live/client/me", dto.LiveClientProfileRequest{Nickname: "Updated viewer", Avatar: "/uploads/acceptance-cover.jpg"}, &profile))
|
||||
check(profile.Nickname == "Updated viewer", "profile update not applied")
|
||||
var balance dto.LiveClientBalanceResponse
|
||||
must(client.call("GET", "/live/client/balance", nil, &balance))
|
||||
check(balance.DiamondBalance == 100, "profile update changed wallet")
|
||||
check(client.call("PUT", "/live/client/me/password", dto.LiveClientPasswordRequest{
|
||||
OldPassword: "WrongPassword!", NewPassword: "ChangedLocal_2026!", ConfirmPassword: "ChangedLocal_2026!",
|
||||
}, nil) != nil, "wrong previous password accepted")
|
||||
var changed dto.LiveClientAuthResponse
|
||||
must(client.call("PUT", "/live/client/me/password", dto.LiveClientPasswordRequest{
|
||||
OldPassword: userPassword, NewPassword: "ChangedLocal_2026!", ConfirmPassword: "ChangedLocal_2026!",
|
||||
}, &changed))
|
||||
check(changed.Token != "" && changed.Token != registered.Token, "password change did not rotate session")
|
||||
stale := newClient()
|
||||
stale.token = registered.Token
|
||||
check(stale.call("GET", "/live/client/me", nil, nil) != nil, "previous session remained valid after password change")
|
||||
client.token = changed.Token
|
||||
must(client.call("GET", "/live/client/me", nil, &profile))
|
||||
must(client.call("POST", "/live/client/auth/logout", nil, nil))
|
||||
check(client.call("GET", "/live/client/me", nil, nil) != nil, "logged-out session remained authorized")
|
||||
report.Records["accountLifecycleUserID"] = profile.UserID
|
||||
passed(report, "fresh registration grants 100 diamonds; profile/balance, password validation and rotation, expired session and logout")
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
func verifyCatalog(admin, public, viewer *apiClient, f *fixtures, report *acceptanceReport) {
|
||||
var category dto.LiveCategoryResponse
|
||||
name := fmt.Sprintf("T%d", time.Now().UnixMilli()%100000000)
|
||||
input := dto.LiveCategoryRequest{Name: name, Icon: "video", Sort: 2, Enabled: false}
|
||||
must(admin.call("POST", "/live/admin/categories", input, &category))
|
||||
if category.Enabled {
|
||||
report.Findings = append(report.Findings, "Creating a category with enabled:false returns enabled:true")
|
||||
}
|
||||
input.Enabled = true
|
||||
must(admin.call("PUT", fmt.Sprintf("/live/admin/categories/%d", category.ID), input, &category))
|
||||
check(category.Enabled, "category update did not preserve enabled:true")
|
||||
var room dto.LiveRoomResponse
|
||||
roomInput := makeRoom("API Disabled Test", category.ID, f.Anchor.ID, "", false)
|
||||
must(admin.call("POST", "/live/admin/rooms", roomInput, &room))
|
||||
if room.Enabled {
|
||||
report.Findings = append(report.Findings, "Creating a room with enabled:false returns enabled:true and exposes the room publicly")
|
||||
}
|
||||
must(admin.call("PATCH", adminRoomPath(room.ID)+"/status", dto.LiveStatusRequest{Enabled: false}, nil))
|
||||
check(public.call("GET", roomPath(room.ID), nil, nil) != nil, "room remains public after disabling")
|
||||
check(viewer.call("GET", adminRoomPath(room.ID), nil, nil) != nil, "PC viewer accessed an administration room")
|
||||
roomInput.Enabled = true
|
||||
roomInput.Intro = "Updated through API"
|
||||
must(admin.call("PUT", adminRoomPath(room.ID), roomInput, &room))
|
||||
check(room.Intro == roomInput.Intro && room.Enabled, "room update did not persist")
|
||||
input.Enabled = false
|
||||
must(admin.call("PUT", fmt.Sprintf("/live/admin/categories/%d", category.ID), input, nil))
|
||||
check(public.call("GET", roomPath(room.ID), nil, nil) != nil, "room in a disabled category was exposed")
|
||||
input.Enabled = true
|
||||
must(admin.call("PUT", fmt.Sprintf("/live/admin/categories/%d", category.ID), input, nil))
|
||||
must(admin.call("DELETE", adminRoomPath(room.ID), nil, nil))
|
||||
check(public.call("GET", roomPath(room.ID), nil, nil) != nil, "deleted room remained public")
|
||||
must(admin.call("DELETE", fmt.Sprintf("/live/admin/categories/%d", category.ID), nil, nil))
|
||||
|
||||
var gift dto.GiftResponse
|
||||
giftInput := dto.GiftRequest{Name: name, Icon: "star", Price: 9, Tiers: []int{1, 10}, Enabled: true, Sort: 2}
|
||||
must(admin.call("POST", "/live/admin/gifts", giftInput, &gift))
|
||||
giftInput.Description = "Updated through API"
|
||||
must(admin.call("PUT", fmt.Sprintf("/live/admin/gifts/%d", gift.ID), giftInput, &gift))
|
||||
check(gift.Description == giftInput.Description, "gift update did not persist")
|
||||
must(admin.call("PATCH", fmt.Sprintf("/live/admin/gifts/%d/status", gift.ID), dto.LiveStatusRequest{Enabled: false}, nil))
|
||||
must(admin.call("DELETE", fmt.Sprintf("/live/admin/gifts/%d", gift.ID), nil, nil))
|
||||
passed(report, "administrator category, room and gift CRUD; category/room disabling; client administration access denied")
|
||||
}
|
||||
|
||||
func verifyDisabledAccount(admin, other *apiClient, f *fixtures, report *acceptanceReport) {
|
||||
path := fmt.Sprintf("/live/admin/users/%d/status", f.Other.ID)
|
||||
if err := admin.call("PATCH", path, dto.LiveStatusRequest{Enabled: false}, nil); err != nil {
|
||||
report.Findings = append(report.Findings, "Administrator cannot disable the client user: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
must(admin.call("PATCH", path, dto.LiveStatusRequest{Enabled: true}, nil))
|
||||
}()
|
||||
check(admin.call("PATCH", fmt.Sprintf("/live/admin/users/%d/status", f.Admin.ID), dto.LiveStatusRequest{Enabled: false}, nil) != nil, "administrator disabled their own account")
|
||||
_, err := login(f.Other, false)
|
||||
check(err != nil, "disabled account could still log in")
|
||||
check(other.call("GET", "/live/client/balance", nil, nil) != nil, "disabled account token remained authorized")
|
||||
passed(report, "disabled user cannot log in or use an existing authenticated API token")
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/internal/data/ledger"
|
||||
"kra/internal/service/dto"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func verifyLive(admin, public, anchor, viewer, other *apiClient, f *fixtures, db *gorm.DB, report *acceptanceReport) {
|
||||
defer func() {
|
||||
must(admin.call("PATCH", adminRoomPath(f.RoomID)+"/status", dto.LiveStatusRequest{Enabled: true}, nil))
|
||||
must(admin.call("PATCH", fmt.Sprintf("/live/admin/gifts/%d/status", f.GiftID), dto.LiveStatusRequest{Enabled: true}, nil))
|
||||
}()
|
||||
for _, token := range []string{"", other.token} {
|
||||
wrong, err := joinSocket(token, "publisher", f.RoomID)
|
||||
if wrong != nil {
|
||||
_ = wrong.conn.Close()
|
||||
}
|
||||
check(err != nil, "non-owner/anonymous client was allowed to publish")
|
||||
}
|
||||
publisher, err := joinSocket(anchor.token, "publisher", f.RoomID)
|
||||
must(err)
|
||||
defer publisher.conn.Close()
|
||||
var room dto.LiveRoomResponse
|
||||
must(public.call("GET", roomPath(f.RoomID), nil, &room))
|
||||
check(room.Status == "live", "registered publisher did not make room live: %+v", room)
|
||||
anonymous, err := joinSocket("", "subscriber", f.RoomID)
|
||||
must(err)
|
||||
defer anonymous.conn.Close()
|
||||
must(anonymous.write("ChatSend", map[string]any{"content": "anonymous must be rejected"}))
|
||||
message, err := anonymous.wait("Error", nil, 5*time.Second)
|
||||
must(err)
|
||||
check(message.Payload["code"] == "AUTH_REQUIRED", "anonymous chat was not rejected: %+v", message)
|
||||
first, err := joinSocket(viewer.token, "subscriber", f.RoomID)
|
||||
must(err)
|
||||
defer first.conn.Close()
|
||||
second, err := joinSocket(other.token, "subscriber", f.RoomID)
|
||||
must(err)
|
||||
defer second.conn.Close()
|
||||
for index, sender := range []*signalSocket{first, second} {
|
||||
content := fmt.Sprintf("acceptance-%d-%d", time.Now().UnixNano(), index)
|
||||
must(sender.write("ChatSend", map[string]any{"content": content, "userId": f.Anchor.ID}))
|
||||
for _, receiver := range []*signalSocket{first, second, anonymous} {
|
||||
broadcast, err := receiver.wait("ChatMessage", func(payload map[string]any) bool { return payload["content"] == content }, 5*time.Second)
|
||||
must(err)
|
||||
expectedUser := f.Viewer.ID
|
||||
if index == 1 {
|
||||
expectedUser = f.Other.ID
|
||||
}
|
||||
check(broadcast.Payload["userId"] == float64(expectedUser), "chat trusted spoofed sender identity: %+v", broadcast)
|
||||
}
|
||||
}
|
||||
late, err := joinSocket("", "subscriber", f.RoomID)
|
||||
must(err)
|
||||
defer late.conn.Close()
|
||||
must(late.quietChat(700 * time.Millisecond))
|
||||
assertNoHistory(public, f.RoomID)
|
||||
passed(report, "authenticated owner publishing, anonymous viewing, unauthenticated chat rejection, two-user broadcast and no history replay")
|
||||
|
||||
var follow struct {
|
||||
Followed bool `json:"followed"`
|
||||
}
|
||||
must(viewer.call("POST", roomPath(f.RoomID)+"/follow", nil, &follow))
|
||||
check(follow.Followed, "follow was not created")
|
||||
must(viewer.call("POST", roomPath(f.RoomID)+"/follow", nil, &follow))
|
||||
var follows int64
|
||||
must(db.Table("live_room_follows").Where("user_id = ? AND room_id = ?", f.Viewer.ID, f.RoomID).Count(&follows).Error)
|
||||
check(follows == 1, "following twice created duplicate rows: %d", follows)
|
||||
must(viewer.call("DELETE", roomPath(f.RoomID)+"/follow", nil, &follow))
|
||||
check(!follow.Followed, "unfollow failed")
|
||||
must(viewer.call("POST", roomPath(f.RoomID)+"/follow", nil, nil))
|
||||
var likes struct {
|
||||
LikeCount int `json:"likeCount"`
|
||||
}
|
||||
must(public.call("POST", roomPath(f.RoomID)+"/like", nil, &likes))
|
||||
check(likes.LikeCount == room.LikeCount+1, "like count did not increment")
|
||||
passed(report, "follow/unfollow idempotency reconciles to one database row and live-room likes increment")
|
||||
|
||||
// The old smoke publisher never read ping frames and disconnected at the
|
||||
// default 60-second pong deadline, hiding gift and live-state regressions.
|
||||
fmt.Println("Waiting 65 seconds to cross the default WebSocket pong deadline...")
|
||||
time.Sleep(65 * time.Second)
|
||||
select {
|
||||
case <-publisher.done:
|
||||
panic("publisher disconnected before the 65-second liveness assertion")
|
||||
default:
|
||||
}
|
||||
must(publisher.write("Heartbeat", map[string]any{}))
|
||||
_, err = publisher.wait("HeartbeatAck", nil, 5*time.Second)
|
||||
must(err)
|
||||
must(public.call("GET", roomPath(f.RoomID), nil, &room))
|
||||
check(room.Status == "live", "room fell offline despite heartbeat/pong reads")
|
||||
passed(report, "publisher connection remains alive beyond 60 seconds with continuous reads, pongs and heartbeats")
|
||||
|
||||
before := wallet(db, f.Viewer.ID)
|
||||
otherBefore := wallet(db, f.Other.ID)
|
||||
countBefore := giftCount(db, f.Viewer.ID)
|
||||
var sent dto.GiftSendResponse
|
||||
must(viewer.call("POST", roomPath(f.RoomID)+"/gifts", map[string]any{
|
||||
"giftId": f.GiftID, "quantity": 3, "userId": f.Other.ID,
|
||||
}, &sent))
|
||||
check(sent.Quantity == 3 && sent.DiamondTotal == 21 && sent.UserID == f.Viewer.ID, "custom gift send did not use authenticated account and configured price: %+v", sent)
|
||||
after := wallet(db, f.Viewer.ID)
|
||||
check(after.DiamondBalance == before.DiamondBalance-21 && after.TotalSpentDiamonds == before.TotalSpentDiamonds+21, "gift wallet change incorrect: before=%+v after=%+v", before, after)
|
||||
check(wallet(db, f.Other.ID).DiamondBalance == otherBefore.DiamondBalance, "gift altered another user's wallet")
|
||||
check(giftCount(db, f.Viewer.ID) == countBefore+1, "gift ledger receipt missing")
|
||||
giftMessage, err := second.wait("GiftMessage", nil, 5*time.Second)
|
||||
must(err)
|
||||
check(giftMessage.Payload["quantity"] == float64(3), "gift notification quantity incorrect: %+v", giftMessage)
|
||||
var stored ledger.GiftSendPO
|
||||
must(db.First(&stored, sent.ID).Error)
|
||||
check(stored.Quantity == 3 && stored.DiamondTotal == 21 && stored.UserID == f.Viewer.ID, "gift ledger mismatch: %+v", stored)
|
||||
passed(report, "custom gift quantity outside presets debits exact amount atomically, records receipt and broadcasts gift")
|
||||
|
||||
sendErr := viewer.call("POST", roomPath(f.RoomID)+"/gifts", dto.GiftSendRequest{GiftID: f.GiftID, Quantity: 9999}, nil)
|
||||
check(sendErr != nil && strings.Contains(sendErr.Error(), "insufficient"), "insufficient-balance response not returned: %v", sendErr)
|
||||
for _, quantity := range []int{0, -1, 10000} {
|
||||
check(viewer.call("POST", roomPath(f.RoomID)+"/gifts", dto.GiftSendRequest{GiftID: f.GiftID, Quantity: quantity}, nil) != nil, "invalid gift quantity %d accepted", quantity)
|
||||
}
|
||||
must(admin.call("PATCH", fmt.Sprintf("/live/admin/gifts/%d/status", f.GiftID), dto.LiveStatusRequest{Enabled: false}, nil))
|
||||
check(viewer.call("POST", roomPath(f.RoomID)+"/gifts", dto.GiftSendRequest{GiftID: f.GiftID, Quantity: 1}, nil) != nil, "disabled gift accepted")
|
||||
must(admin.call("PATCH", fmt.Sprintf("/live/admin/gifts/%d/status", f.GiftID), dto.LiveStatusRequest{Enabled: true}, nil))
|
||||
check(wallet(db, f.Viewer.ID).DiamondBalance == after.DiamondBalance && giftCount(db, f.Viewer.ID) == countBefore+1, "failed gift request changed wallet or receipt count")
|
||||
passed(report, "insufficient balance, invalid quantity and disabled gift reject without debit or extra ledger rows")
|
||||
|
||||
must(admin.call("PATCH", adminRoomPath(f.RoomID)+"/status", dto.LiveStatusRequest{Enabled: false}, nil))
|
||||
check(viewer.call("POST", roomPath(f.RoomID)+"/gifts", dto.GiftSendRequest{GiftID: f.GiftID, Quantity: 1}, nil) != nil, "disabled room accepted a gift")
|
||||
denied, err := joinSocket("", "subscriber", f.RoomID)
|
||||
if denied != nil {
|
||||
_ = denied.conn.Close()
|
||||
}
|
||||
check(err != nil, "disabled room accepted a new subscriber")
|
||||
must(admin.call("PATCH", adminRoomPath(f.RoomID)+"/status", dto.LiveStatusRequest{Enabled: true}, nil))
|
||||
must(publisher.write("Leave", map[string]any{}))
|
||||
_, err = first.wait("PublisherStatus", func(payload map[string]any) bool {
|
||||
return payload["ready"] == false && payload["reason"] == "left"
|
||||
}, 5*time.Second)
|
||||
must(err)
|
||||
must(public.call("GET", roomPath(f.RoomID), nil, &room))
|
||||
check(room.Status == "offline", "room did not become offline after publisher left")
|
||||
must(first.write("ChatSend", map[string]any{"content": "offline chat must be rejected"}))
|
||||
message, err = first.wait("Error", nil, 5*time.Second)
|
||||
must(err)
|
||||
check(message.Payload["code"] == "ROOM_UNAVAILABLE", "offline room accepted chat: %+v", message)
|
||||
check(viewer.call("POST", roomPath(f.RoomID)+"/gifts", dto.GiftSendRequest{GiftID: f.GiftID, Quantity: 1}, nil) != nil, "offline room accepted gift")
|
||||
check(wallet(db, f.Viewer.ID).DiamondBalance == after.DiamondBalance && giftCount(db, f.Viewer.ID) == countBefore+1, "disabled/offline gift requests changed ledger")
|
||||
assertNoHistory(public, f.RoomID)
|
||||
restarted, err := joinSocket(anchor.token, "publisher", f.RoomID)
|
||||
must(err)
|
||||
defer restarted.conn.Close()
|
||||
afterRestart, err := joinSocket("", "subscriber", f.RoomID)
|
||||
must(err)
|
||||
defer afterRestart.conn.Close()
|
||||
must(afterRestart.quietChat(700 * time.Millisecond))
|
||||
assertNoHistory(public, f.RoomID)
|
||||
must(restarted.write("Leave", map[string]any{}))
|
||||
passed(report, "disabled/offline room access and gift restrictions; publisher-offline notification; reopen has no previous chat history")
|
||||
report.Records["lastGiftSendID"] = sent.ID
|
||||
}
|
||||
|
||||
func assertNoHistory(public *apiClient, id uint) {
|
||||
var history struct {
|
||||
List []any `json:"list"`
|
||||
}
|
||||
must(public.call("GET", roomPath(id)+"/messages", nil, &history))
|
||||
check(len(history.List) == 0, "HTTP endpoint exposed previous session chat")
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kra/internal/data/ledger"
|
||||
"kra/internal/service/dto"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func verifyRecharge(admin, viewer, other *apiClient, f *fixtures, db *gorm.DB, report *acceptanceReport) {
|
||||
must(configureMockPayment(admin))
|
||||
var plans struct {
|
||||
List []dto.RechargePlan `json:"list"`
|
||||
}
|
||||
must(newClient().call("GET", "/live/recharge/plans", nil, &plans))
|
||||
check(len(plans.List) > 0 && plans.List[0].AmountCents > 0, "recharge plans missing")
|
||||
var providers struct {
|
||||
List []dto.RechargeProvider `json:"list"`
|
||||
}
|
||||
must(newClient().call("GET", "/live/recharge/providers", nil, &providers))
|
||||
found := false
|
||||
for _, item := range providers.List {
|
||||
found = found || item.ID == "alipay"
|
||||
}
|
||||
check(found, "local mock payment provider was not available")
|
||||
before := wallet(db, f.Viewer.ID)
|
||||
var checkout dto.RechargeCheckoutResponse
|
||||
must(viewer.call("POST", "/live/recharge/orders", dto.RechargeOrderRequest{
|
||||
PlanID: plans.List[0].ID, Provider: "alipay", NotifyURL: baseURL + "/payment/callback/alipay",
|
||||
Extra: map[string]any{"userId": f.Other.ID, "diamonds": 99999},
|
||||
}, &checkout))
|
||||
check(checkout.Order != nil && checkout.Payment != nil, "recharge checkout missing order or payment")
|
||||
order := checkout.Order
|
||||
check(order.UserID == f.Viewer.ID && order.Diamonds == 60 && order.AmountCents == 600, "untrusted checkout values changed ownership or price: %+v", order)
|
||||
check(wallet(db, f.Viewer.ID).DiamondBalance == before.DiamondBalance, "pending recharge credited wallet")
|
||||
path := "/live/recharge/orders/" + order.OrderNo
|
||||
var owned dto.RechargeOrderResponse
|
||||
must(viewer.call("GET", path, nil, &owned))
|
||||
check(other.call("GET", path, nil, nil) != nil, "another user accessed recharge order")
|
||||
check(other.call("GET", path+"/status", nil, nil) != nil, "another user accessed recharge status")
|
||||
check(newClient().call("GET", path, nil, nil) != nil, "anonymous user accessed recharge order")
|
||||
must(admin.call("GET", "/live/admin/orders/"+order.OrderNo, nil, nil))
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
badResponse, err := client.PostForm(baseURL+"/payment/callback/alipay", url.Values{
|
||||
"app_id": {mockAppID}, "out_trade_no": {order.OrderNo}, "trade_no": {"LOCAL-" + order.OrderNo},
|
||||
"trade_status": {"TRADE_SUCCESS"}, "total_amount": {"6.00"}, "sign_type": {"RSA2"}, "sign": {"invalid"},
|
||||
})
|
||||
must(err)
|
||||
badBody, err := io.ReadAll(badResponse.Body)
|
||||
must(err)
|
||||
must(badResponse.Body.Close())
|
||||
check(strings.TrimSpace(string(badBody)) != "success", "invalid callback signature was acknowledged")
|
||||
check(wallet(db, f.Viewer.ID).DiamondBalance == before.DiamondBalance, "invalid callback credited wallet")
|
||||
passed(report, "public recharge plans/provider, signed local checkout, order ownership and invalid callback rejection")
|
||||
for repeat := 0; repeat < 2; repeat++ {
|
||||
response, err := client.PostForm(mockURL+"/confirm", url.Values{"orderNo": {order.OrderNo}})
|
||||
must(err)
|
||||
body, err := io.ReadAll(response.Body)
|
||||
must(err)
|
||||
must(response.Body.Close())
|
||||
check(response.StatusCode == 200 && strings.TrimSpace(string(body)) == "success", "local signed callback rejected: status=%d body=%s", response.StatusCode, body)
|
||||
current := wallet(db, f.Viewer.ID)
|
||||
check(current.DiamondBalance == before.DiamondBalance+60 && current.TotalRechargeCents == before.TotalRechargeCents+600, "recharge was not credited exactly once: before=%+v after=%+v", before, current)
|
||||
}
|
||||
must(viewer.call("GET", path, nil, &owned))
|
||||
check(owned.PaymentStatus == "paid" && owned.FulfillmentStatus == "succeeded" && owned.CreditedAt != nil, "recharge status was not fulfilled: %+v", owned)
|
||||
var stored ledger.RechargeOrderPO
|
||||
must(db.Where("order_no = ?", order.OrderNo).First(&stored).Error)
|
||||
check(stored.UserID == f.Viewer.ID && stored.CreditedAt != nil, "recharge database ownership or credit marker incorrect")
|
||||
report.Records["rechargeOrderNo"] = order.OrderNo
|
||||
passed(report, "valid local RSA2 callback, authoritative signed query and duplicate callback credit wallet only once")
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type signalMessage struct {
|
||||
Type string `json:"type"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
}
|
||||
|
||||
type signalSocket struct {
|
||||
conn *websocket.Conn
|
||||
messages chan signalMessage
|
||||
done chan struct{}
|
||||
writeMu sync.Mutex
|
||||
}
|
||||
|
||||
func connectSocket(token string) (*signalSocket, error) {
|
||||
endpoint := wsURL
|
||||
if token != "" {
|
||||
endpoint += "?token=" + url.QueryEscape(token)
|
||||
}
|
||||
conn, _, err := websocket.DefaultDialer.Dial(endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &signalSocket{conn: conn, messages: make(chan signalMessage, 512), done: make(chan struct{})}
|
||||
// The read loop processes WebSocket ping frames, allowing the server to
|
||||
// receive pongs while HTTP assertions and application heartbeats run.
|
||||
go func() {
|
||||
defer close(s.done)
|
||||
defer close(s.messages)
|
||||
for {
|
||||
var message signalMessage
|
||||
if err := conn.ReadJSON(&message); err != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case s.messages <- message:
|
||||
case <-time.After(5 * time.Second):
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if s.write("Heartbeat", map[string]any{}) != nil {
|
||||
_ = s.conn.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *signalSocket) write(kind string, payload map[string]any) error {
|
||||
s.writeMu.Lock()
|
||||
defer s.writeMu.Unlock()
|
||||
_ = s.conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||
return s.conn.WriteJSON(signalMessage{Type: kind, Payload: payload})
|
||||
}
|
||||
|
||||
func (s *signalSocket) wait(kind string, match func(map[string]any) bool, timeout time.Duration) (signalMessage, error) {
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case message, ok := <-s.messages:
|
||||
if !ok {
|
||||
return signalMessage{}, fmt.Errorf("WebSocket closed waiting for %s", kind)
|
||||
}
|
||||
if message.Type == kind && (match == nil || match(message.Payload)) {
|
||||
return message, nil
|
||||
}
|
||||
if message.Type == "JoinRejected" {
|
||||
return message, fmt.Errorf("join rejected: %v", message.Payload)
|
||||
}
|
||||
case <-timer.C:
|
||||
return signalMessage{}, fmt.Errorf("timed out waiting for %s", kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalSocket) quietChat(duration time.Duration) error {
|
||||
timer := time.NewTimer(duration)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case message, ok := <-s.messages:
|
||||
if !ok {
|
||||
return fmt.Errorf("WebSocket closed while checking no chat history")
|
||||
}
|
||||
if message.Type == "ChatMessage" || message.Type == "ChatHistory" {
|
||||
return fmt.Errorf("unexpected chat replay: %s %v", message.Type, message.Payload)
|
||||
}
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func joinSocket(token, role string, room uint) (*signalSocket, error) {
|
||||
s, err := connectSocket(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = s.write("Join", map[string]any{"role": role, "streamId": fmt.Sprintf("room-%d", room)}); err != nil {
|
||||
_ = s.conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if _, err = s.wait("Joined", nil, 5*time.Second); err != nil {
|
||||
_ = s.conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func holdPublisher() error {
|
||||
f, err := readFixtures()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := login(f.Anchor, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
socket, err := joinSocket(client.token, "publisher", f.RoomID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer socket.conn.Close()
|
||||
log.Printf("Local fixture publisher joined room=%d with continuous reads and heartbeats", f.RoomID)
|
||||
for message := range socket.messages {
|
||||
if message.Type == "Error" || message.Type == "JoinRejected" {
|
||||
return fmt.Errorf("publisher error: %v", message.Payload)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("publisher disconnected")
|
||||
}
|
||||
Reference in New Issue
Block a user