Compare commits
18
Commits
v0.1.0
...
5aa45b44e0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5aa45b44e0 | ||
|
|
786fe3590b | ||
|
|
e95062d9eb | ||
|
|
af8278d2a9 | ||
|
|
ac90495137 | ||
|
|
4059a1576b | ||
|
|
7b209ce3d6 | ||
|
|
bb8ce5f178 | ||
|
|
60bc873ef6 | ||
|
|
613f4bffa4 | ||
|
|
99c134b1ce | ||
|
|
b723ff13cf | ||
|
|
1d6be59652 | ||
|
|
b4789fc9e2 | ||
|
|
de2c64d111 | ||
|
|
73ee4e74c7 | ||
|
|
8b4da3dc05 | ||
|
|
479a08ee11 |
@@ -106,7 +106,7 @@ docker compose -f deploy/docker-compose.yml up -d
|
||||
- [产品需求](docs/PRD.md)
|
||||
- [开发说明](docs/DEVELOPMENT.md)
|
||||
- [运维手册](docs/OPS.md)
|
||||
- [验收对照表](test/accept/ACCEPTANCE.md)(含未测项)
|
||||
- [验收对照表](test/accept/ACCEPTANCE.md)(F01–F23 短时间项已通过;长时/环境限制见备注与 [OPS.md](docs/OPS.md) 第 9 节)
|
||||
- [开发任务](docs/TASKS.md)
|
||||
- [与文档的偏差](docs/DEVIATIONS.md)
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.asio.asia/nixevol/NixMsg/internal/config"
|
||||
)
|
||||
|
||||
// TestUplinkDisableFatalAndRevoked 验证停用在线端收到 fatal,已推送投递收到 revoked。
|
||||
func TestUplinkDisableFatalAndRevoked(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
cfgPath := writeTestConfig(t, dataDir)
|
||||
initAdminForTest(t, dataDir)
|
||||
enableRegistration(t, dataDir, "uplink-code")
|
||||
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if vErr := cfg.Validate(); vErr != nil {
|
||||
t.Fatal(vErr)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- runServe(ctx, cfg) }()
|
||||
defer func() {
|
||||
cancel()
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Errorf("serve exit: %v", err)
|
||||
}
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Error("serve did not stop")
|
||||
}
|
||||
}()
|
||||
|
||||
addr := waitListenAddr(t, dataDir, 15*time.Second)
|
||||
base := "http://" + addr
|
||||
|
||||
registerEP(t, base, "alice", "password12", "Alice")
|
||||
registerEP(t, base, "bob", "password12", "Bob")
|
||||
|
||||
alice := mqttSessionLogin(t, base, "alice", "password12")
|
||||
defer alice.Close()
|
||||
bob := mqttSessionLogin(t, base, "bob", "password12")
|
||||
defer bob.Close()
|
||||
|
||||
delay0 := int64(0)
|
||||
sendResp := alice.Request(t, map[string]any{
|
||||
"v": 1, "type": "send", "rid": "s1", "id": "dm-fatal-1",
|
||||
"to": map[string]any{"kind": "endpoint", "id": "bob"},
|
||||
"body": map[string]any{"enc": "utf8", "data": "to-void"},
|
||||
"delay_ms": delay0,
|
||||
})
|
||||
if !sendResp.OK {
|
||||
t.Fatalf("send: %+v", sendResp)
|
||||
}
|
||||
msg := bob.WaitType(t, "msg", 8*time.Second)
|
||||
if msg["id"] != "dm-fatal-1" {
|
||||
t.Fatalf("bob msg=%v", msg)
|
||||
}
|
||||
|
||||
admin := adminHTTPClient(t, base)
|
||||
disableEP(t, admin, base, "bob")
|
||||
|
||||
fatal := bob.WaitType(t, "fatal", 8*time.Second)
|
||||
if fatal["reason"] != "disabled" {
|
||||
t.Fatalf("fatal=%v", fatal)
|
||||
}
|
||||
revoked := bob.WaitType(t, "revoked", 8*time.Second)
|
||||
if revoked["id"] != "dm-fatal-1" || revoked["reason"] != "endpoint_disabled" {
|
||||
t.Fatalf("revoked=%v", revoked)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUplinkResetPasswordFatal 验证重置登录密码后在线端收到 fatal(password_reset)。
|
||||
func TestUplinkResetPasswordFatal(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
cfgPath := writeTestConfig(t, dataDir)
|
||||
initAdminForTest(t, dataDir)
|
||||
enableRegistration(t, dataDir, "uplink-code")
|
||||
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if vErr := cfg.Validate(); vErr != nil {
|
||||
t.Fatal(vErr)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- runServe(ctx, cfg) }()
|
||||
defer func() {
|
||||
cancel()
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Errorf("serve exit: %v", err)
|
||||
}
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Error("serve did not stop")
|
||||
}
|
||||
}()
|
||||
|
||||
addr := waitListenAddr(t, dataDir, 15*time.Second)
|
||||
base := "http://" + addr
|
||||
|
||||
registerEP(t, base, "carol", "password12", "Carol")
|
||||
carol := mqttSessionLogin(t, base, "carol", "password12")
|
||||
defer carol.Close()
|
||||
|
||||
admin := adminHTTPClient(t, base)
|
||||
resetLoginPassword(t, admin, base, "carol", "password99xx")
|
||||
|
||||
fatal := carol.WaitType(t, "fatal", 8*time.Second)
|
||||
if fatal["reason"] != "password_reset" {
|
||||
t.Fatalf("fatal=%v", fatal)
|
||||
}
|
||||
}
|
||||
|
||||
func adminHTTPClient(t *testing.T, base string) *http.Client {
|
||||
t.Helper()
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := &http.Client{Jar: jar, Timeout: 10 * time.Second}
|
||||
loginBody, _ := json.Marshal(map[string]string{
|
||||
"username": "admin",
|
||||
"password": "test-admin-password-xx",
|
||||
})
|
||||
resp, err := client.Post(base+"/api/admin/login", "application/json", bytes.NewReader(loginBody))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("admin login: %d %s", resp.StatusCode, raw)
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func disableEP(t *testing.T, client *http.Client, base, id string) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodPatch, base+"/api/admin/endpoints/"+id,
|
||||
strings.NewReader(`{"enabled":false}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Nixmsg-Request", "1")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("disable %s: %d %s", id, resp.StatusCode, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func resetLoginPassword(t *testing.T, client *http.Client, base, id, password string) {
|
||||
t.Helper()
|
||||
body := `{"login_password":"` + password + `"}`
|
||||
req, err := http.NewRequest(http.MethodPost, base+"/api/admin/endpoints/"+id+"/reset-login-password",
|
||||
strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Nixmsg-Request", "1")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("reset password %s: %d %s", id, resp.StatusCode, raw)
|
||||
}
|
||||
}
|
||||
+51
-7
@@ -71,6 +71,10 @@ func runServe(ctx context.Context, cfg config.Config) error {
|
||||
loginLocks := auth.NewLoginLocks()
|
||||
memConns := message.NewMemoryConns()
|
||||
msgLim := message.LimitsFromFullConfig(cfg)
|
||||
metricsReg := metrics.New()
|
||||
db.Queue.OnBatchCommit = func(d time.Duration) {
|
||||
metricsReg.WriteCommitSeconds.Observe(d.Seconds())
|
||||
}
|
||||
|
||||
login := broker.NewLogin(broker.LoginOptions{
|
||||
DB: db,
|
||||
@@ -84,11 +88,13 @@ func runServe(ctx context.Context, cfg config.Config) error {
|
||||
msgApp := message.New(db, msgLim, hashPool,
|
||||
message.WithLocks(loginLocks),
|
||||
message.WithConnRegistry(memConns),
|
||||
message.WithMetrics(metricsReg),
|
||||
)
|
||||
uplink := &appUplink{
|
||||
msg: msgApp,
|
||||
conns: memConns,
|
||||
log: slog.Default(),
|
||||
msg: msgApp,
|
||||
conns: memConns,
|
||||
log: slog.Default(),
|
||||
metrics: metricsReg,
|
||||
}
|
||||
sess := broker.NewSession(broker.SessionOptions{
|
||||
Login: login,
|
||||
@@ -109,6 +115,7 @@ func runServe(ctx context.Context, cfg config.Config) error {
|
||||
Authenticator: login,
|
||||
Uplink: sess,
|
||||
Logger: slog.Default(),
|
||||
Metrics: metricsReg,
|
||||
OnPublishDropped: func(dropCtx context.Context, endpointID string, connID port.ConnID, payload []byte) {
|
||||
if dropErr := msgApp.OnPublishDropped(dropCtx, endpointID, connID, payload); dropErr != nil {
|
||||
slog.Error("on publish dropped", "endpoint", endpointID, "err", dropErr)
|
||||
@@ -125,6 +132,7 @@ func runServe(ctx context.Context, cfg config.Config) error {
|
||||
message.WithLocks(loginLocks),
|
||||
message.WithConnRegistry(memConns),
|
||||
message.WithDownlink(brk),
|
||||
message.WithMetrics(metricsReg),
|
||||
)
|
||||
uplink.msg = msgApp
|
||||
uplink.down = brk
|
||||
@@ -137,6 +145,7 @@ func runServe(ctx context.Context, cfg config.Config) error {
|
||||
sess.SetPresence(presApp)
|
||||
uplink.presence = presApp
|
||||
|
||||
trustedNets := httpx.ParseCIDRs(cfg.TrustedProxies)
|
||||
idApp := identity.New(identity.Config{
|
||||
DB: db,
|
||||
Hash: hashPool,
|
||||
@@ -145,6 +154,10 @@ func runServe(ctx context.Context, cfg config.Config) error {
|
||||
MaxScheduleSeconds: int64(cfg.Limits.MaxScheduleSeconds),
|
||||
Logger: slog.Default(),
|
||||
ConnControl: brk,
|
||||
Downlink: brk,
|
||||
ClientIP: func(r *http.Request) string {
|
||||
return httpx.ClientIP(r, trustedNets)
|
||||
},
|
||||
})
|
||||
uplink.identity = idApp
|
||||
|
||||
@@ -161,7 +174,6 @@ func runServe(ctx context.Context, cfg config.Config) error {
|
||||
return fmt.Errorf("message recover: %w", recoverErr)
|
||||
}
|
||||
|
||||
trustedNets := httpx.ParseCIDRs(cfg.TrustedProxies)
|
||||
adminHandler := admin.New(admin.Deps{
|
||||
DB: db,
|
||||
Hash: hashPool,
|
||||
@@ -173,6 +185,7 @@ func runServe(ctx context.Context, cfg config.Config) error {
|
||||
Groups: groupApp,
|
||||
Config: cfg,
|
||||
Version: Version,
|
||||
// Kick:只断开,令牌不变,SDK 重连(PRD 踢下线)。
|
||||
KickEndpoint: func(kickCtx context.Context, endpointID string) (bool, error) {
|
||||
if _, found := brk.ConnInfoOf(endpointID); !found {
|
||||
return false, nil
|
||||
@@ -182,9 +195,36 @@ func runServe(ctx context.Context, cfg config.Config) error {
|
||||
}
|
||||
return true, nil
|
||||
},
|
||||
// 停用/删除/重置:先 fatal 再断开(DEVELOPMENT 6.8)。
|
||||
DisableKick: func(kickCtx context.Context, endpointID string) (bool, error) {
|
||||
if _, found := brk.ConnInfoOf(endpointID); !found {
|
||||
return false, nil
|
||||
}
|
||||
if disableErr := sess.Disable(kickCtx, endpointID); disableErr != nil {
|
||||
return false, disableErr
|
||||
}
|
||||
return true, nil
|
||||
},
|
||||
DeleteKick: func(kickCtx context.Context, endpointID string) (bool, error) {
|
||||
if _, found := brk.ConnInfoOf(endpointID); !found {
|
||||
return false, nil
|
||||
}
|
||||
if deleteErr := sess.Deleted(kickCtx, endpointID); deleteErr != nil {
|
||||
return false, deleteErr
|
||||
}
|
||||
return true, nil
|
||||
},
|
||||
PasswordResetKick: func(kickCtx context.Context, endpointID string) (bool, error) {
|
||||
if _, found := brk.ConnInfoOf(endpointID); !found {
|
||||
return false, nil
|
||||
}
|
||||
if resetErr := sess.ResetPassword(kickCtx, endpointID); resetErr != nil {
|
||||
return false, resetErr
|
||||
}
|
||||
return true, nil
|
||||
},
|
||||
})
|
||||
|
||||
metricsReg := metrics.New()
|
||||
buildHandlers := func(proxies *listener.ProxySet) listener.Handlers {
|
||||
return listener.Handlers{
|
||||
MQTT: brk.WSHandler(proxies),
|
||||
@@ -266,7 +306,7 @@ func runServe(ctx context.Context, cfg config.Config) error {
|
||||
|
||||
loopCtx, loopCancel := context.WithCancel(ctx)
|
||||
defer loopCancel()
|
||||
go messageLoops(loopCtx, msgApp, memConns)
|
||||
go messageLoops(loopCtx, msgApp, memConns, db, hashPool, metricsReg)
|
||||
|
||||
<-ctx.Done()
|
||||
loopCancel()
|
||||
@@ -279,7 +319,7 @@ func runServe(ctx context.Context, cfg config.Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func messageLoops(ctx context.Context, msgApp *message.App, conns *message.MemoryConns) {
|
||||
func messageLoops(ctx context.Context, msgApp *message.App, conns *message.MemoryConns, db *store.DB, hashPool auth.HashPool, met *metrics.Registry) {
|
||||
t := time.NewTicker(time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
@@ -299,6 +339,10 @@ func messageLoops(ctx context.Context, msgApp *message.App, conns *message.Memor
|
||||
if err := msgApp.CleanupOnce(ctx, nowMs); err != nil {
|
||||
slog.Error("cleanup once", "err", err)
|
||||
}
|
||||
if err := metrics.SampleStoreGauges(ctx, met, db.Read); err != nil {
|
||||
slog.Debug("sample store gauges", "err", err)
|
||||
}
|
||||
metrics.SampleQueues(met, db.Queue.Len(), hashPool.QueueLen())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"git.asio.asia/nixevol/NixMsg/internal/app/message"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/app/port"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/app/presence"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/metrics"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/protocol"
|
||||
)
|
||||
|
||||
@@ -23,6 +24,7 @@ type appUplink struct {
|
||||
conns *message.MemoryConns
|
||||
down port.Downlink
|
||||
log *slog.Logger
|
||||
metrics *metrics.Registry
|
||||
}
|
||||
|
||||
func (u *appUplink) OnSessionEstablished(ctx context.Context, conn port.ConnInfo) error {
|
||||
@@ -65,6 +67,10 @@ func (u *appUplink) HandleUplink(ctx context.Context, conn port.ConnInfo, payloa
|
||||
u.replyErr(ctx, conn, peekRID(payload), protocol.CodeBadRequest, err.Error())
|
||||
return nil
|
||||
}
|
||||
if !uplinkRateExempt(frame) && u.msg != nil && !u.msg.AllowRequest(conn.EndpointID) {
|
||||
u.replyErr(ctx, conn, peekRID(payload), protocol.CodeRateLimited, "request rate exceeded")
|
||||
return nil
|
||||
}
|
||||
|
||||
rid, data, callErr := u.dispatch(ctx, conn, frame)
|
||||
if callErr != nil {
|
||||
@@ -75,6 +81,15 @@ func (u *appUplink) HandleUplink(ctx context.Context, conn port.ConnInfo, payloa
|
||||
return nil
|
||||
}
|
||||
|
||||
func uplinkRateExempt(frame any) bool {
|
||||
switch frame.(type) {
|
||||
case *protocol.Ack, *protocol.ReceiptAck:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (u *appUplink) dispatch(ctx context.Context, conn port.ConnInfo, frame any) (rid string, data any, err error) {
|
||||
switch f := frame.(type) {
|
||||
case *protocol.Send:
|
||||
@@ -255,6 +270,9 @@ func (u *appUplink) replyErr(ctx context.Context, conn port.ConnInfo, rid, code,
|
||||
if rid == "" {
|
||||
rid = "0"
|
||||
}
|
||||
if u.metrics != nil && code != "" {
|
||||
u.metrics.ErrorsTotal.WithLabelValues(code).Inc()
|
||||
}
|
||||
resp := protocol.Resp{
|
||||
V: protocol.Version,
|
||||
Type: protocol.TypeResp,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.asio.asia/nixevol/NixMsg/internal/app/message"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/app/port"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/auth"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/config"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/protocol"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/store"
|
||||
)
|
||||
|
||||
func TestHandleUplinkRateLimitStatusAndAckExempt(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "data"), "FULL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
nowMs := int64(1_700_000_000_000)
|
||||
err = db.Queue.Do(context.Background(), func(tx *sql.Tx) error {
|
||||
_, e := tx.Exec(`
|
||||
INSERT INTO endpoints(id, name, login_hash, talk_hash, talk_version, default_delay_ms, enabled, created_at)
|
||||
VALUES('alice','alice','stub$login',NULL,0,0,1,?)`, nowMs)
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lim := message.LimitsFromFullConfig(config.Default())
|
||||
lim.RequestsPerSecond = 50
|
||||
lim.RequestBurst = 100
|
||||
app := message.New(db, lim, auth.NewStubHashPool(),
|
||||
message.WithNow(func() time.Time { return time.UnixMilli(nowMs) }),
|
||||
)
|
||||
down := &message.RecordingDownlink{}
|
||||
conns := message.NewMemoryConns()
|
||||
conns.Set("alice", message.LiveConn{ConnID: "c1"})
|
||||
u := &appUplink{msg: app, conns: conns, down: down, log: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
conn := port.ConnInfo{EndpointID: "alice", ConnID: "c1"}
|
||||
ctx := context.Background()
|
||||
|
||||
ackPayload, err := protocol.Marshal(&protocol.Ack{
|
||||
V: protocol.Version, Type: protocol.TypeAck, RID: "a", From: "alice", ID: "missing",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 150; i++ {
|
||||
if e := u.HandleUplink(ctx, conn, ackPayload); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
if n := countRespCode(down, protocol.CodeRateLimited); n != 0 {
|
||||
t.Fatalf("ack should not count, rate_limited=%d", n)
|
||||
}
|
||||
|
||||
statusPayload, err := protocol.Marshal(&protocol.Status{
|
||||
V: protocol.Version, Type: protocol.TypeStatus, RID: "s", ID: "no-such",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 150; i++ {
|
||||
if e := u.HandleUplink(ctx, conn, statusPayload); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
limited := countRespCode(down, protocol.CodeRateLimited)
|
||||
if limited != 50 {
|
||||
t.Fatalf("status rate_limited=%d want 50 (burst 100 of 150)", limited)
|
||||
}
|
||||
}
|
||||
|
||||
func countRespCode(down *message.RecordingDownlink, code string) int {
|
||||
n := 0
|
||||
for _, p := range down.Snapshots() {
|
||||
var resp protocol.Resp
|
||||
if err := protocol.Unmarshal(p.Payload, &resp); err != nil {
|
||||
continue
|
||||
}
|
||||
if !resp.OK && resp.Error != nil && resp.Error.Code == code {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
+178
-3
@@ -385,10 +385,10 @@
|
||||
|
||||
2. **请求频率突发容量写死为 100**
|
||||
- 原条款:DEVELOPMENT 6.10 每端每秒 50、突发 100;配置示例仅有 `requests_per_second`。
|
||||
- 实际做法:`Limits.RequestBurst` 默认 100;`requests_per_second<=0` 时不限速(便于测试)。速率桶挂在 `message.App` 的 `Submit` 入口;`ack`/`receipt_ack` 不计入桶(与 6.10 一致)。
|
||||
- 实际做法:`Limits.RequestBurst` 默认 100;`requests_per_second<=0` 时不限速(便于测试)。`message.App.AllowRequest` 导出同一令牌桶;`HandleUplink` 在分发前对 ack/receipt_ack 以外的帧调用。`Submit` 不再单独扣桶,避免 send 计两次。
|
||||
- 原因:配置无独立 burst 字段。
|
||||
- 备选方案:配置增加 `request_burst`;由连接线在上行统一限流。
|
||||
- 影响:改 `requests_per_second` 不改突发;正式接线后若 N 线也限流可能双重计数。
|
||||
- 备选方案:配置增加 `request_burst`。
|
||||
- 影响:改 `requests_per_second` 不改突发;非 send 请求也受同一桶限制。
|
||||
|
||||
3. **未接线 `cmd/nixmsg`**
|
||||
- 原条款:可替换 T0.4 假实现。
|
||||
@@ -448,6 +448,42 @@
|
||||
- 备选方案:总控在 `protocol` 增类型。
|
||||
- 影响:接线编码 `resp.data` 时直接 Marshal 该 map 即可。
|
||||
|
||||
### 复审修复 C-04
|
||||
|
||||
1. **退群/踢人/解散/停用/删除作废投递走统一终态函数**
|
||||
- 原条款:DEVELOPMENT 7.6 投递进入 rejected 时写回执;没有 pending 时收尾 completed、删正文;保留 0 天同一事务删行。PRD F14/F18。
|
||||
- 实际做法:message 导出 `RejectPendingTx` / `TryFinalizeTx` / `FinalizeMessageTx`。group `void.go` 与 identity `lifecycle.go` 的作废/收尾改为调用它们,去掉复制 SQL。`sender_disabled`/`sender_deleted` 仍不写回执。`CleanupOnce` 分批收尾「dispatched 且无 pending」的卡住消息。作废路径未接线 `record_retention_days` 时按默认 7 天收尾(不在同一事务删行);保留 0 天由 message 自己的 finalize 覆盖。不改 group `emit`。
|
||||
- 原因:原先 group 只改投递状态,identity 收尾但不写回执,最后一个 pending 被作废后消息永远停在 dispatched。
|
||||
- 备选方案:在 group/identity 各自补写回执与收尾(继续分叉)。
|
||||
- 影响:退群/解散/停用后发送方可收到 rejected 回执,配额释放,正文删除。
|
||||
|
||||
### 复审修复 C-05
|
||||
|
||||
1. **每端请求限速覆盖非 send 帧**
|
||||
- 原条款:PRD F05 / DEVELOPMENT 6.10:除 ack、receipt_ack 外共用一个桶,默认每秒 50、突发 100。
|
||||
- 实际做法:message 导出 `AllowRequest`;`cmd/nixmsg/uplink.go` 的 `HandleUplink` 解码后、分发前检查;超限回 `rate_limited`。去掉 `Submit` 内扣桶。不改 uplink 生命周期与 `publishResp`。
|
||||
- 原因:原先只有 send 限速,unlock/status/目录/群等可打满哈希池与读库。
|
||||
- 备选方案:把桶挪到 broker 层(B-09 范围)。
|
||||
- 影响:开放注册后的非 send 请求也计入配额;直接调 `Submit` 的单测不再覆盖限速。
|
||||
|
||||
### 复审修复 C-06
|
||||
|
||||
1. **推送 meta 数字用 UseNumber 解码**
|
||||
- 原条款:PRD F07 / D11 自定义键值送达应与提交一致。
|
||||
- 实际做法:`decodeMetaJSON` 改用 `protocol.Unmarshal`(`UseNumber`),超过 2^53 的整数以 `json.Number` 保留原文再编码进推送帧。不改 `Msg.Meta` 类型与协议包。
|
||||
- 原因:标准 `json.Unmarshal` 把数字变成 float64,雪花 ID 会被改掉。
|
||||
- 备选方案:`Meta` 改为 `json.RawMessage` 原样输出(需改 protocol,牵动 SDK)。
|
||||
- 影响:仅推送路径;入库仍是提交时的规范 JSON。
|
||||
|
||||
### 复审修复 C-07
|
||||
|
||||
1. **提交校验、停用检查、入群时间过滤、保留期按完成时刻**
|
||||
- 原条款:DEVELOPMENT 6.2 ttl/定时上限;PRD F01 停用后不能再发;F06 发送时刻之后入群的端收不到;F18 记录保留从完成起算。
|
||||
- 实际做法:`keep` 且 `ttl_seconds<=0` 回 `bad_request`;`delay_ms` 先与 `max_schedule_seconds*1000` 比较再加法。`Send.Validate` 同步(`protocol.Limits` 增加可选 MaxTTL/MaxSchedule,0 表示不查上限)。写事务内检查发送方 `enabled`,停用回 `unauthorized`。群分发 `joined_at <= send_at`。未做 C-03 的 `completed_at` 列,清理暂用 `MAX(deliveries.updated_at)` 否则 `send_at` 近似完成时刻。不改对话密码锁键语义。
|
||||
- 原因:ttl=0/负数、delay 溢出、停用窗口内仍能提交、晚入群仍能收到、按 created_at 清理会误删长定时/长保留消息。
|
||||
- 备选方案:等 C-03 迁移后改用 `completed_at`;发送方停用改用 `endpoint_disabled`(与目标停用混用)。
|
||||
- 影响:发送方停用错误码为 `unauthorized`;保留期口径在 C-03 合入前对无投递的 scheduled 作废行用 `send_at` 近似。
|
||||
|
||||
## 身份 I
|
||||
|
||||
### I1 2026-09-30
|
||||
@@ -582,6 +618,36 @@
|
||||
- 备选方案:仅按 joined_at。
|
||||
- 影响:同毫秒加入时编号小者优先。
|
||||
|
||||
### 复审修复 U-02
|
||||
|
||||
1. **群写操作在同一写事务内复核**
|
||||
- 原条款:PRD F16 群主同时是成员、停用端不能加入、成员上限、新群收不到旧群消息;issue #40。
|
||||
- 实际做法:加人/踢人/退群/转让/改名/解散在 `Queue.Do` 内重读群主、成员关系和成员数;加人再复核目标端 `enabled`。对话密码(argon2)仍在事务外,事务里只做廉价 SQL。`INSERT OR IGNORE` 改为先复核再 `INSERT`;外键失败按 `not_found`。不改 `emit`,不改 message `RejectPendingTx`。
|
||||
- 原因:读后写会在解散后留下孤儿成员、并发加人超过上限、转让后群主不在成员里。
|
||||
- 备选方案:只靠外键、事务外校验(否决,无法给出原错误码)。
|
||||
- 影响:加人与解散并发时整次加人返回 `not_found`,不写孤儿行。
|
||||
|
||||
2. **建群/加人先去重再截断,单请求成员数设上限**
|
||||
- 原条款:部分失败仍建群;成员上限。
|
||||
- 实际做法:先去掉自己和重复编号,再按剩余名额截断,超出记 `group_full`,然后才做密码校验。整表请求成员数超过 `2*max_group_members`(至少 256)回 `bad_request`。原先「校验通过人数加群主超上限则整次建群失败」改为截断后仍建群。
|
||||
- 原因:重复编号会校验两次并在插入时主键冲突,客户端按 `busy` 一直重试;一个请求可带上万个成员打满哈希池。
|
||||
- 备选方案:协议层去重(禁止改 protocol)。
|
||||
- 影响:带重复成员的建群会成功且只留一条;超上限的多余成员在 `failed` 里而不是整次失败。
|
||||
|
||||
3. **后台建群校验群主并补推 `member_added`**
|
||||
- 原条款:群主必须是已启用的端。
|
||||
- 实际做法:`createAdmin` 校验群主编号格式、存在且 `enabled`;成员去重;建成后按与客户端建群相同方式 `emit` `member_added`。群主不存在 `invalid_target`,已停用 `endpoint_disabled`,格式非法 `bad_request`。
|
||||
- 原因:原先可不存在/已停用的编号当群主,成员也不去重,也不推事件。
|
||||
- 备选方案:由 admin HTTP 层预校验(仍会与写路径竞态)。
|
||||
- 影响:后台建群失败码与加人目标错误码对齐。
|
||||
|
||||
4. **可选迁移 `0003_group_members_fk.sql`**
|
||||
- 原条款:TASKS 4.2 改表加新文件,rebase 时取当时最大号加一;issue 写「排在 C-03 的 0003 之后」。
|
||||
- 实际做法:本分支基于 C-04,当时最大号 0002,按 TASKS 4.2 用 0003:重建 `group_members` 并 `REFERENCES groups(id) ON DELETE CASCADE`。C-03 尚未合入。
|
||||
- 原因:无外键时同编号新建群会继承旧孤儿成员。
|
||||
- 备选方案:等 C-03 占用 0003 后再用 0004(rebase 时改号)。
|
||||
- 影响:若 C-03 先合入并占用 0003,本文件 rebase 时改号。
|
||||
|
||||
## 后台接口 A
|
||||
|
||||
### A1 2026-09-30
|
||||
@@ -1062,3 +1128,112 @@
|
||||
- 原因:避免四份说明与 SDK 线漂移。
|
||||
- 备选方案:在 docs/ 再建 SDK 汇总页。
|
||||
- 影响:无。
|
||||
|
||||
### Q accept-rest(补齐短时可测验收)2026-09-30
|
||||
|
||||
1. **补测 F03/F04/F07/F10/F11/F14/F15/F18;F19 引用既有 SDK 清单**
|
||||
- 原条款:PRD 第 10 节;总控要求跳过 1000×10min、Linux netem 20%、1000 端全表 1s。
|
||||
- 实际做法:`test/accept/rest_accept_test.go` 用随机端口与临时目录;`grace_seconds`/`ack_timeout_seconds` 调到数秒;`record_retention_days=0` 另起进程;F19 对照表改为通过并写明四套 SDK checklist 证据路径,本波不重跑全量。
|
||||
- 原因:短时可测项应收口;长时/环境限制项不假装通过。
|
||||
- 备选方案:专用压测机与 Linux 宿主再补长时项。
|
||||
- 影响:`ACCEPTANCE.md` 汇总通过 23 / 失败 0 / 未测 0;长时子项仍写在备注。
|
||||
|
||||
2. **harness MQTT 握手后清除 SetDeadline**
|
||||
- 原条款:`test/harness` 属总控;Dial 时 `SetDeadline(now+timeout)`。
|
||||
- 实际做法:WebSocket 升级成功与 TCP dial 成功后 `SetDeadline(time.Time{})`,避免长会话在 dial timeout 到期后读写全部失败。
|
||||
- 原因:F10 等短宽限仍需跨数秒保持连接;未清 deadline 时旧 10s dial 会在会话中途使 Recv 失败,表现为 `timeout waiting resp`。
|
||||
- 备选方案:每次读写刷新 deadline(更繁琐)。
|
||||
- 影响:跨线改了 harness;行为仅更正测试客户端,不改产品。
|
||||
|
||||
3. **F15 带密建群用独立短生命周期进程**
|
||||
- 原条款:拉进群须当次带对话密码。
|
||||
- 实际做法:主会话用 `group.create` 无密断言失败;带密成功在干净进程上立刻建群。
|
||||
- 原因:与第 4 条同一死锁,补测时先用隔离进程覆盖校验路径。
|
||||
- 备选方案:仅依赖第 4 条修复后在同一长会话上测 `group.add`。
|
||||
- 影响:验收覆盖仍成立。
|
||||
|
||||
4. **群事件 `emit` 改为异步 PublishDown**
|
||||
- 原条款:群变更向成员推 `group_event`(QoS 0)。
|
||||
- 实际做法:`internal/app/group/app.go` 的 `emit` 在独立 goroutine 里延迟约 20ms 再 `PublishDown`,让上行 worker 先把 `resp` 推完。
|
||||
- 原因:同一连接上 `group.create`/`group.add` 同步向本连接注入下行时,与 mochi InlineClient 互相等待,`resp` 回不去(`TestUplinkDMOfflineGroupRecall` 在清掉测试客户端 dial deadline 后稳定复现)。
|
||||
- 备选方案:broker 层对 Inline 发布做无锁队列。
|
||||
- 影响:`group_event` 可能略晚于 `resp` 到达;业务结果仍以 `resp` 为准。
|
||||
|
||||
### fix-issue-1
|
||||
|
||||
1. **管理员 IP 锁定不再阻断已认证会话**
|
||||
- 原条款:PRD D18 / F02(密码锁只拦密码登录,不拦已有会话令牌);DEVELOPMENT 第 5/8 节(管理员登录锁定、错误令牌按 IP 计入锁定);issue #1。
|
||||
- 实际做法:去掉 `internal/admin/auth.go` 的 `auth()` 鉴权前 `Check(LockAdminIP)`;登录入口仍 `Check`/`Fail`,错误或停用 API 令牌仍经 `authFail` 计入锁定。有效 Cookie 与合法 Bearer 在锁定期可继续调管理接口。
|
||||
- 原因:先前把「防暴力登录」扩成「封整个管理面」,同 NAT 下刷错误 Bearer 即可锁死已登录管理员,与端侧 nst_ 重连语义不一致。
|
||||
- 备选方案:锁定期对 Cookie 与令牌也拒绝(否决,违背 D18 对齐)。
|
||||
- 影响:仅管理后台鉴权中间件;端侧登录锁定未改。
|
||||
|
||||
### fix-issue-6
|
||||
|
||||
1. **解散群时 scheduled 消息级回执 state 改为 rejected**
|
||||
- 原条款:DEVELOPMENT 6.4 消息级作废写 `endpoint_id` 空、`state=rejected`;7.6 解散群将 `scheduled` 消息改为 `completed`/`group_dissolved` 并写消息级回执。I4 旧实现把回执 state 误写成消息状态 `completed`。
|
||||
- 实际做法:`internal/app/group/void.go` 的 `voidGroupAllTx` 插入回执时改用 `rejected`(与 I5.2 / identity lifecycle 一致);消息行仍为 `completed`。
|
||||
- 原因:`completed` 不在回执枚举(accepted|recalled|expired|dropped|rejected)内,会误导 SDK/后台。
|
||||
- 备选方案:沿用 `completed`(违反协议)。
|
||||
- 影响:仅修正解散路径回执字段;不改 emit / PublishDown。
|
||||
|
||||
### fix-issue-2
|
||||
|
||||
1. **自助注册接入 trusted_proxies 客户端 IP**
|
||||
- 原条款:PRD F23 / D18 注册安全码按来源 IP 锁定;DEVELOPMENT 4.5 来自受信代理时用 `X-Forwarded-For`;I1.4 曾写「经代理部署时接线方必须注入真实 IP」。
|
||||
- 实际做法:`cmd/nixmsg/serve.go` 在 `identity.New` 注入与管理接口相同的 `httpx.ClientIP(r, trustedNets)`;不改锁定阈值与注册开关/安全码语义,不在 identity 内复制解析。
|
||||
- 原因:L-WIRE 已挂注册 Handler,管理与 WS 已接 `trusted_proxies`,唯独注册漏接,反向代理后会把安全码锁定计到代理 IP。
|
||||
- 备选方案:在 listener 层统一改写 `RemoteAddr` 后再交给注册 Handler。
|
||||
- 影响:经受信代理开放注册时,输错安全码按真实客户端 IP 锁定。
|
||||
|
||||
### fix-issue-4
|
||||
|
||||
1. **接线补齐 Downlink 与停用/删除/重置密码 fatal**
|
||||
- 原条款:DEVELOPMENT 6.8 / 7.6:停用、删除、重置密码先发 `fatal` 再断开;已推送作废投递尽力发 `revoked`。
|
||||
- 实际做法:`serve` 给 `identity.New` 注入 `Downlink: brk`(作废后 `publishRevokes`);`DisableKick`/`DeleteKick`/`PasswordResetKick` 分别接到 `Session.Disable`/`Deleted`/`ResetPassword`;`KickEndpoint` 仍只 `Kick`。Identity 在未接 Kick 钩子时仍可用 `ConnControl` 异步断开兜底。
|
||||
- 原因:原先 Downlink 未注入导致 revoked 丢失;管理路径只 `Kick`/`Disconnect` 不发 fatal。
|
||||
- 备选方案:仅在 identity 内 `PublishDown(fatal)` 再断开;联调中该路径不如 Session.fatalKick 稳,故生产致命踢线统一走 Session。
|
||||
- 影响:管理「踢下线」语义不变;SDK 可按 fatal 停止重连;接收方能收到已推送消息的 revoked。
|
||||
|
||||
### fix-issue-5
|
||||
|
||||
1. **指标在真实事件点打点,不新造名字**
|
||||
- 原条款:PRD F22 / DEVELOPMENT 4.3 / issue #5;DEVIATIONS P4 已定名但从未接线。
|
||||
- 实际做法:`nixmsg_connections{transport}` 在 broker `OnSessionEstablished`/`OnDisconnect` 末尾 Inc/Dec;`endpoints`/`deliveries_pending`/`messages_scheduled` 与写队列、哈希排队在 `messageLoops` 每秒按库/队列真实长度采样;`dispatch_to_push`/`ack` 直方图在成功推送与确认路径 Observe;写批提交耗时经 `store.Queue.OnBatchCommit`;`errors_total` 仅在上行 `replyErr` 时按错误码递增。门禁不变。
|
||||
- 原因:空指标等于监控未交付;采样避免在每条写路径上改大段分发逻辑,并减小与 #3/#6 的合并面。
|
||||
- 备选方案:全部改为纯事件加减(pending 等需在每处状态迁移维护计数)。
|
||||
- 影响:仪表盘按既有名字即可看在线连接与待投递;无对应事件时计数保持 0,不做假数。
|
||||
|
||||
### 死锁未修(issue #3)
|
||||
|
||||
issue #3 未关闭,`feat/fix-3-downlink-deadlock` 未合入 `main`。下面是核对过的调用链、三次尝试和仍留在 `main` 上的绕过。不改产品行为。
|
||||
|
||||
1. **现象**
|
||||
- 清掉测试客户端 dial deadline 后,`cmd/nixmsg/uplink_integration_test.go` 的 `TestUplinkDMOfflineGroupRecall` 在 `group.create`(`rid=g1`)稳定超时,`resp` 回不去。
|
||||
- 行号以本次合入后的 `main` 为准。
|
||||
|
||||
2. **调用链**
|
||||
- 每端一条上行队列。`internal/broker/queue.go` 的 `loop`(约 41 行)同步调用 `HandleUplink`。
|
||||
- `cmd/nixmsg/uplink.go` 的 `HandleUplink`(约 64 行)先 `dispatch`,再在同一调用栈里 `replyOK` → `publishResp`(约 273 行)用 QoS 1 调 `PublishDown`。`group.create` / `group.add` 走 `groups.Create` / `Add`,在返回 `resp` 之前就 `emit`(`internal/app/group/app.go` 约 168、223 行)。
|
||||
- `Broker.PublishDown`(`internal/broker/broker.go` 约 202 行)进入 `server.Publish`(约 234 行)。`New` 设了 `InlineClient: true`(约 148 行,DEVELOPMENT 要求保持)。Inline 发布走到 `InjectPacket` → `OnPublish`。
|
||||
- `internal/broker/hooks.go` 的 `OnPublish`(约 114 行)对 `cl.Net.Inline` 必须直接放行,否则 `PublishDown` 送不到订阅者(见本文更早的 InlineClient 偏差)。
|
||||
- 群操作因此在同一次上行调用栈里,再向本连接 `PublishDown` `group_event`。`InjectPacket`(`NextPacketID` / 写路径)与读循环随后写 PUBACK 抢同一把 Client 锁,两边互等,`resp` 出不去。
|
||||
- `presence.notify`(`internal/app/presence/app.go` 约 317 行)仍在业务调用栈里同步 `PublishDown`(约 341 行),不在 20ms 绕过的覆盖范围内。
|
||||
|
||||
3. **已尝试**
|
||||
- 尝试 1:`emit` 改成立刻起 goroutine 做 `PublishDown`。仍死锁,因为 `group_event` 与同连接上的 `resp` 一起抢注入。
|
||||
- 尝试 2(已在 `main`,来自 `479a08e` 的 Q accept-rest 第 4 条):`emit` 起 goroutine 后 `time.Sleep(20ms)` 再下发(`internal/app/group/app.go` 约 672–685 行),让 `resp` 先出去。当时 `task check` 通过。这是时间差绕过,不是根因修复;presence 以及其他同步 `PublishDown` 仍可能卡。
|
||||
- 尝试 3(负责人叫停,未合入、未验证):工作树 `e:\code\NixMsg-wt\fix3`,分支 `feat/fix-3-downlink-deadlock` 停在 `479a08e`,与合入前的 `origin/main` 相同,**没有可合的提交**。未提交改动在 broker 层:
|
||||
- `hooks.go` `OnPublish`:客户端 QoS≥1 先在读循环里 `WritePacket(PUBACK)`,再把包降成 QoS 0 并 `Ignore`,然后入队,返回 `nil`(不再用 `CodeSuccessIgnore` 让 mochi 事后写 PUBACK)。注释写明:若先入队,worker 的 `PublishDown` → `InjectPacket` → `NextPacketID` 会与随后的 `WritePacket(PUBACK)` 争 Client 锁。
|
||||
- `queue.go` `loop`:`HandleUplink` 前后调用 `beginUplink` / `endUplink`。
|
||||
- `broker.go`:该端 `depth>0` 时,对本端的 `PublishDown` 只推进延后队列,handler 返回后由上行 worker 再 `server.Publish`;其他端仍同步下发。`InlineClient` 放行未改。
|
||||
- `group/app.go` `emit` 改回同步 `PublishDown`,去掉 20ms sleep。
|
||||
- 同目录 `docs/DEVIATIONS.md` 有一段未提交的 `### fix-issue-3` 草稿。
|
||||
- 本会话没有跑这套未提交代码的 `task check`,不把它们合进 `main`。工作树保留,给工程师看。
|
||||
|
||||
4. **仍在 main 上的做法**
|
||||
- 继续用尝试 2 的 20ms 绕过。群事件可能略晚于 `resp`;业务结果仍以 `resp` 为准。
|
||||
|
||||
5. **建议的正确方向**
|
||||
- 在 broker 把对本连接的下行 `InjectPacket` 与上行 worker 解耦:上行读循环先写完 PUBACK,处理 `HandleUplink` 期间不要同步向本连接注入;handler 返回后再发 `resp` 和 `group_event`。不要靠固定 `Sleep`。`InlineClient: true` 保持,`OnPublish` 对 InlineClient 继续放行。
|
||||
- 覆盖 presence 等其他同步 `PublishDown`,而不只包一层 `emit`。
|
||||
|
||||
+6
-14
@@ -117,20 +117,12 @@ curl -sS -H "Authorization: Bearer $NIXMSG_METRICS_TOKEN" http://127.0.0.1:7443/
|
||||
- 首次:`docker compose run --rm nixmsg admin init`,再 `up -d`。
|
||||
- 构建/推送 Task 目标见根目录 README(`q:docker-build` / `q:docker-push` / `q:docker-buildx`)。正式仓库推送在阶段 3。
|
||||
|
||||
## 9. 验收未测项(勿当作已通过)
|
||||
## 9. 验收与仍跳过的长时项
|
||||
|
||||
截至 Q4/Q5 文档定稿,对照表 [test/accept/ACCEPTANCE.md](../test/accept/ACCEPTANCE.md) 中下列项仍为**未测**,运维与交付说明须保持该状态,不得宣称通过:
|
||||
F01–F23 短时间验收对照表见 [test/accept/ACCEPTANCE.md](../test/accept/ACCEPTANCE.md)(汇总通过 23,失败 0,未测 0)。下列因环境或时长限制**未测**,不得宣称已通过:
|
||||
|
||||
| 编号 | 摘要 |
|
||||
|---|---|
|
||||
| F03 | 断开后离线状态 / 目录全表 |
|
||||
| F04 | presence 订阅通知 |
|
||||
| F07 | 256 KiB 边界与接收上限 |
|
||||
| F10 | 抖动宽限长短断线 |
|
||||
| F11 | 发送方离线后定时到点 |
|
||||
| F14 | 回执补送 |
|
||||
| F15 | 对话密码授权链路 |
|
||||
| F18 | 正文删除与记录天数 0 |
|
||||
| F19 | 四种 SDK 统一接入清单(属 SDK 线,本波未在 Q 对照表复测) |
|
||||
- F03:1000 端全表 1 秒内返回、真拔网线后心跳超时离线
|
||||
- F08 / Q3:Linux netem 20% 丢包(本机 Windows)
|
||||
- 压测:1000 连接保持 10 分钟、每秒 200 条
|
||||
|
||||
F22 标为通过的子集仅覆盖 init + 健康检查等;备份恢复、升级迁移、证书重载、Docker 全量、`/metrics` 抓取等仍见对照表备注中的未测说明。
|
||||
F22 通过的子集仅覆盖 init + 健康检查等;备份恢复、升级迁移、证书重载、Docker 全量、`/metrics` 抓取等仍见对照表备注。
|
||||
|
||||
+27
-12
@@ -17,7 +17,13 @@
|
||||
|
||||
## 2. F01–F23 验收结果
|
||||
|
||||
来源:[test/accept/ACCEPTANCE.md](../test/accept/ACCEPTANCE.md)(生成时间 2026-09-30T00:26:12Z)。对照表汇总:通过 14,失败 0,未测 9。
|
||||
来源:[test/accept/ACCEPTANCE.md](../test/accept/ACCEPTANCE.md)(生成时间 2026-09-30T02:16:46Z)。对照表汇总:通过 23,失败 0,未测 0。
|
||||
|
||||
长时/环境限制项在对照表备注中保留「未测子项」说明,不单独占「未测」行:
|
||||
|
||||
- F03:未跑 1000 端全表 1s、真拔网线心跳超时(关连接模拟断线)
|
||||
- F08:Linux netem 20% 丢包未测(本机 Windows)
|
||||
- 压测:未跑 1000 连接保持 10 分钟
|
||||
|
||||
### 通过
|
||||
|
||||
@@ -25,14 +31,23 @@
|
||||
|---|---|
|
||||
| F01 | 批量开通整批校验、停用、删除群主转让、删除后同编号重开不串数据 |
|
||||
| F02 | 新设备登录后旧设备自动退出、换 IP 用令牌重连、两种密码锁定、重置密码后被踢、服务器故障不误报密码错误 |
|
||||
| F03 | 断开后状态及时变离线,全表可列出 |
|
||||
| F04 | 只通知订阅了的端 |
|
||||
| F05 | 崩溃不丢已提交消息,消息号去重和冲突,密码门生效,配额生效 |
|
||||
| F06 | 群成员收到同一份,入群前不补,发送者不收到自己的 |
|
||||
| F07 | 256 KiB 通过,超出拒绝,接收上限生效 |
|
||||
| F08 | 弱网最终送达且应用层不重复,重启后续传 |
|
||||
| F09 | 保留时间从发送时刻起算,超时过期 |
|
||||
| F10 | 短断线送到,长断线丢弃,服务器重启后宽限内重连送到 |
|
||||
| F11 | 发送方离线后到点仍发送 |
|
||||
| F12 | 延迟窗口内撤回对方收不到 |
|
||||
| F13 | 未推送必撤成功;群部分确认得到部分撤回 |
|
||||
| F14 | 回执能补送给当时离线的发送方 |
|
||||
| F15 | 输一次记住、改密失效、回复免密、进群仍要密码、防多账号轮流猜 |
|
||||
| F16 | 群主权限、退出后不再收到、解散后同编号新群不收旧消息 |
|
||||
| F17 | 后台管端、管注册、管群、查记录,响应里没有正文;API 令牌可用且不能越权 |
|
||||
| F18 | 送达后正文消失;记录天数 0 时连记录消失;防重仍在 |
|
||||
| F19 | 四种 SDK 通过同一清单 |
|
||||
| F20 | 裸 MQTT 能登录、收、确认、发 |
|
||||
| F21 | 默认一个端口提供后台、WebSocket、TCP、注册;后台可分到单独端口 |
|
||||
| F22 | 初始化后单文件或 Docker 启动、备份恢复、升级迁移、证书自动重载、指标可抓取 |
|
||||
@@ -40,17 +55,7 @@
|
||||
|
||||
### 未测
|
||||
|
||||
| 编号 | 一句话 | 原因(摘自对照表) |
|
||||
|---|---|---|
|
||||
| F03 | 断开后状态及时变离线,全表可列出 | directory.list / 断开后离线状态未在本波单独断言 |
|
||||
| F04 | 只通知订阅了的端 | presence.watch 订阅通知未覆盖 |
|
||||
| F07 | 256 KiB 通过,超出拒绝,接收上限生效 | 256 KiB 边界与接收上限未覆盖 |
|
||||
| F10 | 短断线送到,长断线丢弃,服务器重启后宽限内重连送到 | 抖动宽限长短断线未单独拨钟 |
|
||||
| F11 | 发送方离线后到点仍发送 | 发送方离线后定时到点发送未覆盖 |
|
||||
| F14 | 回执能补送给当时离线的发送方 | 回执补送未覆盖 |
|
||||
| F15 | 输一次记住、改密失效、回复免密、进群仍要密码、防多账号轮流猜 | 对话密码授权链路未覆盖 |
|
||||
| F18 | 送达后正文消失;记录天数 0 时连记录消失;防重仍在 | 正文删除与记录天数 0 未覆盖 |
|
||||
| F19 | 四种 SDK 通过同一清单 | 对照表仍标未测(属 S1/S2);本轮交付回归已另跑四套 SDK 测试,见第 4 节 |
|
||||
无整行未测项。子项因长时间或环境限制未测的见上「长时/环境限制」与对照表备注。
|
||||
|
||||
### 失败
|
||||
|
||||
@@ -96,6 +101,7 @@
|
||||
- Q2 第一部分(已合并功能验收)2026-09-30
|
||||
- Q2 补齐 + Q3(本机 Windows)2026-09-30
|
||||
- Q4 定稿 + Q5 文档 2026-09-30
|
||||
- Q accept-rest 补测 2026-09-30
|
||||
|
||||
## 4. 本轮验证
|
||||
|
||||
@@ -111,6 +117,15 @@
|
||||
| `sdk/python`:venv + `pytest` | 通过(22 passed);测完已删本地 `.venv` |
|
||||
| `sdk/java`:`mvn test` | 通过(scoop maven 3.9.16;测完已删 `target`) |
|
||||
|
||||
其后在 `feat/accept-rest` 补齐短时间验收并更新对照表:
|
||||
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| `go test ./test/accept/ -count=1`(含 F03/F04/F07/F10/F11/F14/F15/F18,F19 引用既有 SDK 清单) | 通过(约 24–27s) |
|
||||
| 写入 `ACCEPTANCE.md` / `q2_results.json` | 通过 23,失败 0,未测 0 |
|
||||
|
||||
跳过:1000 连接 10 分钟浸泡、Linux netem 20% 丢包、F03 的 1000 端全表 1s 与真拔网线心跳超时。
|
||||
|
||||
## 5. 构建与启动
|
||||
|
||||
请直接按仓库文档操作,此处不重复步骤:
|
||||
|
||||
@@ -280,6 +280,82 @@ func TestLoginLock(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminLockDoesNotBlockAuthedSession:密码失败触发 IP 锁后,
|
||||
// 已有 Cookie 会话与合法 API 令牌仍可调管理接口;未认证密码登录仍被拒。
|
||||
func TestAdminLockDoesNotBlockAuthedSession(t *testing.T) {
|
||||
_, srv, cookieClient, _ := setup(t)
|
||||
base := srv.URL
|
||||
|
||||
login(t, cookieClient, base)
|
||||
|
||||
res := postJSON(t, cookieClient, base+"/api/admin/tokens",
|
||||
`{"name":"ops-lock"}`,
|
||||
map[string]string{"X-Nixmsg-Request": "1"})
|
||||
env := decodeEnv(t, res)
|
||||
if res.StatusCode != 200 || !env.OK {
|
||||
t.Fatalf("create token: %d %+v", res.StatusCode, env)
|
||||
}
|
||||
var created struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
bad := &http.Client{}
|
||||
res = postJSON(t, bad, base+"/api/admin/login",
|
||||
`{"username":"admin","password":"wrong-password!!"}`, nil)
|
||||
env = decodeEnv(t, res)
|
||||
if i < 9 {
|
||||
if res.StatusCode != 401 {
|
||||
t.Fatalf("fail %d: want 401 got %d %+v", i, res.StatusCode, env)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if res.StatusCode != 429 || env.Error == nil || env.Error.Code != "rate_limited" {
|
||||
t.Fatalf("10th fail want 429 rate_limited got %d %+v", res.StatusCode, env)
|
||||
}
|
||||
}
|
||||
|
||||
res = doReq(t, cookieClient, http.MethodGet, base+"/api/admin/me", "", nil)
|
||||
env = decodeEnv(t, res)
|
||||
if res.StatusCode != 200 || !env.OK {
|
||||
t.Fatalf("cookie me after lock: want 200 got %d %+v", res.StatusCode, env)
|
||||
}
|
||||
var me map[string]any
|
||||
_ = json.Unmarshal(env.Data, &me)
|
||||
if me["auth"] != "cookie" {
|
||||
t.Fatalf("cookie me auth=%v", me["auth"])
|
||||
}
|
||||
|
||||
tokClient := &http.Client{}
|
||||
hdr := map[string]string{"Authorization": "Bearer " + created.Token}
|
||||
res = doReq(t, tokClient, http.MethodGet, base+"/api/admin/me", "", hdr)
|
||||
env = decodeEnv(t, res)
|
||||
if res.StatusCode != 200 || !env.OK {
|
||||
t.Fatalf("token me after lock: want 200 got %d %+v", res.StatusCode, env)
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &me)
|
||||
if me["auth"] != "token" {
|
||||
t.Fatalf("token me auth=%v", me["auth"])
|
||||
}
|
||||
|
||||
res = doReq(t, tokClient, http.MethodGet, base+"/api/admin/overview", "", hdr)
|
||||
env = decodeEnv(t, res)
|
||||
if res.StatusCode != 200 || !env.OK {
|
||||
t.Fatalf("token overview after lock: want 200 got %d %+v", res.StatusCode, env)
|
||||
}
|
||||
|
||||
anon := &http.Client{}
|
||||
res = postJSON(t, anon, base+"/api/admin/login",
|
||||
`{"username":"admin","password":"`+testPassword+`"}`, nil)
|
||||
env = decodeEnv(t, res)
|
||||
if res.StatusCode != 429 || env.Error == nil || env.Error.Code != "rate_limited" {
|
||||
t.Fatalf("password login while locked want 429 got %d %+v", res.StatusCode, env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadAPITokenCountsTowardLock(t *testing.T) {
|
||||
_, srv, _, _ := setup(t)
|
||||
base := srv.URL
|
||||
|
||||
@@ -43,12 +43,8 @@ func (h *Handler) auth(next http.HandlerFunc) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := httpx.ClientIP(r, h.trusted)
|
||||
|
||||
if locked, retry := h.locks.Check(auth.LockKey{Kind: auth.LockAdminIP, IP: ip}); locked {
|
||||
w.Header().Set("Retry-After", formatRetryAfter(retry))
|
||||
httpx.WriteError(w, http.StatusTooManyRequests, "rate_limited", "登录已锁定,请稍后再试")
|
||||
return
|
||||
}
|
||||
|
||||
// 锁定只拦密码登录(login.go)与错误令牌试错累计;
|
||||
// 已认证的 Cookie / 合法 API 令牌在锁定期仍可用(对齐 PRD D18)。
|
||||
p, errCode, errMsg, status := h.authenticate(r, ip)
|
||||
if status != 0 {
|
||||
if status == http.StatusTooManyRequests {
|
||||
|
||||
@@ -102,6 +102,33 @@ func (h *Handler) kickEndpoint(ctx context.Context, id string) (bool, error) {
|
||||
return h.kick(ctx, id)
|
||||
}
|
||||
|
||||
func (h *Handler) passwordResetKick(ctx context.Context, id string) (bool, error) {
|
||||
if h.resetKick != nil {
|
||||
return h.resetKick(ctx, id)
|
||||
}
|
||||
return h.kickEndpoint(ctx, id)
|
||||
}
|
||||
|
||||
func (h *Handler) afterDisableKick(ctx context.Context, id string) {
|
||||
if h.disableKick != nil {
|
||||
_, _ = h.disableKick(ctx, id)
|
||||
return
|
||||
}
|
||||
if h.identity == nil {
|
||||
_, _ = h.kickEndpoint(ctx, id)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) afterDeleteKick(ctx context.Context, id string) {
|
||||
if h.deleteKick != nil {
|
||||
_, _ = h.deleteKick(ctx, id)
|
||||
return
|
||||
}
|
||||
if h.identity == nil {
|
||||
_, _ = h.kickEndpoint(ctx, id)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleEndpointList(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
limit := defaultListLimit
|
||||
@@ -350,7 +377,7 @@ func (h *Handler) handleEndpointPatch(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !*req.Enabled {
|
||||
_, _ = h.kickEndpoint(r.Context(), id)
|
||||
h.afterDisableKick(r.Context(), id)
|
||||
}
|
||||
} else if req.Enabled != nil && !*req.Enabled && wasEnabled {
|
||||
_, _ = h.kickEndpoint(r.Context(), id)
|
||||
@@ -381,7 +408,7 @@ func (h *Handler) handleEndpointDelete(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.WriteError(w, http.StatusNotFound, "not_found", "端不存在")
|
||||
return
|
||||
}
|
||||
_, _ = h.kickEndpoint(r.Context(), id)
|
||||
h.afterDeleteKick(r.Context(), id)
|
||||
h.audit(actorString(p), "endpoint_delete", id, "ok", ip)
|
||||
httpx.WriteOK(w, map[string]any{})
|
||||
}
|
||||
@@ -416,14 +443,14 @@ func (h *Handler) handleEndpointBatch(w http.ResponseWriter, r *http.Request) {
|
||||
case "disable":
|
||||
found, opErr = h.setEndpointEnabled(r.Context(), id, false)
|
||||
if found && opErr == nil {
|
||||
_, _ = h.kickEndpoint(r.Context(), id)
|
||||
h.afterDisableKick(r.Context(), id)
|
||||
}
|
||||
case "enable":
|
||||
found, opErr = h.setEndpointEnabled(r.Context(), id, true)
|
||||
case "delete":
|
||||
found, opErr = h.deleteEndpointBasic(r.Context(), id)
|
||||
if found && opErr == nil {
|
||||
_, _ = h.kickEndpoint(r.Context(), id)
|
||||
h.afterDeleteKick(r.Context(), id)
|
||||
}
|
||||
}
|
||||
if opErr != nil {
|
||||
@@ -512,7 +539,7 @@ func (h *Handler) handleEndpointResetLoginPassword(w http.ResponseWriter, r *htt
|
||||
httpx.WriteError(w, http.StatusNotFound, "not_found", "端不存在")
|
||||
return
|
||||
}
|
||||
_, _ = h.kickEndpoint(r.Context(), id)
|
||||
_, _ = h.passwordResetKick(r.Context(), id)
|
||||
h.audit(actorString(p), "endpoint_reset_login_password", id, "ok", ip)
|
||||
httpx.WriteOK(w, map[string]any{loginPasswordOnceKey: pw})
|
||||
}
|
||||
|
||||
+42
-30
@@ -41,6 +41,12 @@ type Deps struct {
|
||||
SecureCookies bool
|
||||
// KickEndpoint 踢下线钩子(只断开连接);nil 时踢线为 no-op。
|
||||
KickEndpoint EndpointKickFunc
|
||||
// PasswordResetKick 重置登录密码后踢线(应发 fatal);nil 时回退 KickEndpoint。
|
||||
PasswordResetKick EndpointKickFunc
|
||||
// DisableKick 停用后踢线(应发 fatal(disabled));nil 且已注入 Identity 时不再 Kick。
|
||||
DisableKick EndpointKickFunc
|
||||
// DeleteKick 删除后踢线(应发 fatal(deleted));nil 且已注入 Identity 时不再 Kick。
|
||||
DeleteKick EndpointKickFunc
|
||||
// Identity 端停用/启用/删除级联(I5);nil 时回退为仅改 enabled/删行。
|
||||
Identity identity.Service
|
||||
|
||||
@@ -54,20 +60,23 @@ type Deps struct {
|
||||
|
||||
// Handler 是可挂载的管理接口(路由前缀 /api/admin/)。
|
||||
type Handler struct {
|
||||
db *store.DB
|
||||
hash auth.HashPool
|
||||
tokens auth.APITokens
|
||||
locks auth.LoginLocks
|
||||
log *slog.Logger
|
||||
trusted []*net.IPNet
|
||||
ttl time.Duration
|
||||
forceSec bool
|
||||
kick EndpointKickFunc
|
||||
identity identity.Service
|
||||
groups group.Service
|
||||
cfg config.Config
|
||||
version string
|
||||
startedAt time.Time
|
||||
db *store.DB
|
||||
hash auth.HashPool
|
||||
tokens auth.APITokens
|
||||
locks auth.LoginLocks
|
||||
log *slog.Logger
|
||||
trusted []*net.IPNet
|
||||
ttl time.Duration
|
||||
forceSec bool
|
||||
kick EndpointKickFunc
|
||||
resetKick EndpointKickFunc
|
||||
disableKick EndpointKickFunc
|
||||
deleteKick EndpointKickFunc
|
||||
identity identity.Service
|
||||
groups group.Service
|
||||
cfg config.Config
|
||||
version string
|
||||
startedAt time.Time
|
||||
|
||||
mux *http.ServeMux
|
||||
|
||||
@@ -96,22 +105,25 @@ func New(d Deps) *Handler {
|
||||
ver = "dev"
|
||||
}
|
||||
h := &Handler{
|
||||
db: d.DB,
|
||||
hash: d.Hash,
|
||||
tokens: d.Tokens,
|
||||
locks: d.Locks,
|
||||
log: d.Logger,
|
||||
trusted: d.TrustedProxies,
|
||||
ttl: ttl,
|
||||
forceSec: d.SecureCookies,
|
||||
kick: d.KickEndpoint,
|
||||
identity: d.Identity,
|
||||
groups: d.Groups,
|
||||
cfg: cfg,
|
||||
version: ver,
|
||||
startedAt: time.Now(),
|
||||
mux: http.NewServeMux(),
|
||||
lastUsed: make(map[string]time.Time),
|
||||
db: d.DB,
|
||||
hash: d.Hash,
|
||||
tokens: d.Tokens,
|
||||
locks: d.Locks,
|
||||
log: d.Logger,
|
||||
trusted: d.TrustedProxies,
|
||||
ttl: ttl,
|
||||
forceSec: d.SecureCookies,
|
||||
kick: d.KickEndpoint,
|
||||
resetKick: d.PasswordResetKick,
|
||||
disableKick: d.DisableKick,
|
||||
deleteKick: d.DeleteKick,
|
||||
identity: d.Identity,
|
||||
groups: d.Groups,
|
||||
cfg: cfg,
|
||||
version: ver,
|
||||
startedAt: time.Now(),
|
||||
mux: http.NewServeMux(),
|
||||
lastUsed: make(map[string]time.Time),
|
||||
}
|
||||
h.routes()
|
||||
return h
|
||||
|
||||
+405
-166
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.asio.asia/nixevol/NixMsg/internal/app/port"
|
||||
@@ -27,6 +28,14 @@ const (
|
||||
idAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
)
|
||||
|
||||
func (a *App) memberRequestCap() int {
|
||||
n := a.maxMem * 2
|
||||
if n < 256 {
|
||||
n = 256
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TalkGate checks talk password when adding members (implemented by identity).
|
||||
type TalkGate interface {
|
||||
CheckTalkPasswordForJoin(ctx context.Context, actorID, targetID, talkPassword, remoteIP string) error
|
||||
@@ -105,6 +114,9 @@ func (a *App) Create(ctx context.Context, actorID string, req *protocol.GroupCre
|
||||
if !protocol.ValidEndpointID(actorID) {
|
||||
return CreateResult{}, errCode(protocol.CodeBadRequest, "invalid actor")
|
||||
}
|
||||
if err := a.rejectOversizedMemberList(len(req.Members)); err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
gid := req.ID
|
||||
if gid == "" {
|
||||
var genErr error
|
||||
@@ -115,12 +127,17 @@ func (a *App) Create(ctx context.Context, actorID string, req *protocol.GroupCre
|
||||
}
|
||||
now := a.nowMs()
|
||||
failed := make([]MemberFail, 0)
|
||||
added := make([]string, 0, len(req.Members))
|
||||
|
||||
for _, m := range req.Members {
|
||||
if m.ID == actorID {
|
||||
continue
|
||||
}
|
||||
uniq := dedupeMemberIns(req.Members, actorID)
|
||||
room := a.maxMem - 1
|
||||
if room < 0 {
|
||||
room = 0
|
||||
}
|
||||
toCheck, overflow := splitMemberIns(uniq, room)
|
||||
for _, m := range overflow {
|
||||
failed = append(failed, MemberFail{ID: m.ID, Code: protocol.CodeGroupFull})
|
||||
}
|
||||
added := make([]string, 0, len(toCheck))
|
||||
for _, m := range toCheck {
|
||||
if checkErr := a.checkAddMember(ctx, actorID, m.ID, m.TalkPassword); checkErr != nil {
|
||||
failed = append(failed, MemberFail{ID: m.ID, Code: failCode(checkErr)})
|
||||
continue
|
||||
@@ -128,10 +145,7 @@ func (a *App) Create(ctx context.Context, actorID string, req *protocol.GroupCre
|
||||
added = append(added, m.ID)
|
||||
}
|
||||
|
||||
if 1+len(added) > a.maxMem {
|
||||
return CreateResult{}, errCode(protocol.CodeGroupFull, "group full")
|
||||
}
|
||||
|
||||
var inserted []string
|
||||
err := a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
|
||||
var exists int
|
||||
qErr := tx.QueryRow(`SELECT 1 FROM groups WHERE id = ?`, gid).Scan(&exists)
|
||||
@@ -148,15 +162,19 @@ func (a *App) Create(ctx context.Context, actorID string, req *protocol.GroupCre
|
||||
}
|
||||
return e
|
||||
}
|
||||
if _, e := tx.Exec(`INSERT INTO group_members(group_id, endpoint_id, joined_at) VALUES(?,?,?)`,
|
||||
gid, actorID, now); e != nil {
|
||||
if e := insertMemberTx(tx, gid, actorID, now); e != nil {
|
||||
return e
|
||||
}
|
||||
inserted = inserted[:0]
|
||||
for _, id := range added {
|
||||
if _, e := tx.Exec(`INSERT INTO group_members(group_id, endpoint_id, joined_at) VALUES(?,?,?)`,
|
||||
gid, id, now); e != nil {
|
||||
if e := endpointCheckTx(tx, id); e != nil {
|
||||
failed = append(failed, MemberFail{ID: id, Code: failCode(e)})
|
||||
continue
|
||||
}
|
||||
if e := insertMemberTx(tx, gid, id, now); e != nil {
|
||||
return e
|
||||
}
|
||||
inserted = append(inserted, id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -164,8 +182,9 @@ func (a *App) Create(ctx context.Context, actorID string, req *protocol.GroupCre
|
||||
return CreateResult{}, err
|
||||
}
|
||||
|
||||
for _, id := range added {
|
||||
a.emit(ctx, append([]string{actorID}, added...), gid, eventMemberAdded, id, now)
|
||||
notify := append([]string{actorID}, inserted...)
|
||||
for _, id := range inserted {
|
||||
a.emit(ctx, notify, gid, eventMemberAdded, id, now)
|
||||
}
|
||||
return CreateResult{ID: gid, Name: req.Name, OwnerID: actorID, Failed: failed}, nil
|
||||
}
|
||||
@@ -178,6 +197,9 @@ func (a *App) Add(ctx context.Context, actorID string, req *protocol.GroupAdd) (
|
||||
if err := req.Validate(); err != nil {
|
||||
return AddResult{}, err
|
||||
}
|
||||
if err := a.rejectOversizedMemberList(len(req.Members)); err != nil {
|
||||
return AddResult{}, err
|
||||
}
|
||||
owner, members, err := a.loadGroup(ctx, req.GroupID)
|
||||
if err != nil {
|
||||
return AddResult{}, err
|
||||
@@ -187,17 +209,23 @@ func (a *App) Add(ctx context.Context, actorID string, req *protocol.GroupAdd) (
|
||||
}
|
||||
|
||||
failed := make([]MemberFail, 0)
|
||||
added := make([]string, 0)
|
||||
now := a.nowMs()
|
||||
|
||||
for _, m := range req.Members {
|
||||
if contains(members, m.ID) {
|
||||
continue
|
||||
}
|
||||
if len(members)+len(added) >= a.maxMem {
|
||||
failed = append(failed, MemberFail{ID: m.ID, Code: protocol.CodeGroupFull})
|
||||
uniq := dedupeMemberIns(req.Members, actorID)
|
||||
already := memberSet(members)
|
||||
candidates := make([]protocol.GroupMemberIn, 0, len(uniq))
|
||||
for _, m := range uniq {
|
||||
if _, ok := already[m.ID]; ok {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, m)
|
||||
}
|
||||
room := a.maxMem - len(members)
|
||||
toCheck, overflow := splitMemberIns(candidates, room)
|
||||
for _, m := range overflow {
|
||||
failed = append(failed, MemberFail{ID: m.ID, Code: protocol.CodeGroupFull})
|
||||
}
|
||||
added := make([]string, 0, len(toCheck))
|
||||
for _, m := range toCheck {
|
||||
if checkErr := a.checkAddMember(ctx, actorID, m.ID, m.TalkPassword); checkErr != nil {
|
||||
failed = append(failed, MemberFail{ID: m.ID, Code: failCode(checkErr)})
|
||||
continue
|
||||
@@ -205,23 +233,53 @@ func (a *App) Add(ctx context.Context, actorID string, req *protocol.GroupAdd) (
|
||||
added = append(added, m.ID)
|
||||
}
|
||||
|
||||
if len(added) > 0 {
|
||||
err = a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
|
||||
for _, id := range added {
|
||||
if _, e := tx.Exec(`INSERT OR IGNORE INTO group_members(group_id, endpoint_id, joined_at) VALUES(?,?,?)`,
|
||||
req.GroupID, id, now); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return AddResult{}, err
|
||||
if len(added) == 0 {
|
||||
return AddResult{Failed: failed}, nil
|
||||
}
|
||||
|
||||
var inserted []string
|
||||
var notify []string
|
||||
err = a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
|
||||
curOwner, curMembers, e := loadGroupTx(tx, req.GroupID)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
all := append(append([]string{}, members...), added...)
|
||||
if curOwner != actorID {
|
||||
return errCode(protocol.CodeForbidden, "not owner")
|
||||
}
|
||||
present := memberSet(curMembers)
|
||||
count := len(curMembers)
|
||||
inserted = inserted[:0]
|
||||
for _, id := range added {
|
||||
a.emit(ctx, all, req.GroupID, eventMemberAdded, id, now)
|
||||
if _, ok := present[id]; ok {
|
||||
continue
|
||||
}
|
||||
if count >= a.maxMem {
|
||||
failed = append(failed, MemberFail{ID: id, Code: protocol.CodeGroupFull})
|
||||
continue
|
||||
}
|
||||
if checkErr := endpointCheckTx(tx, id); checkErr != nil {
|
||||
failed = append(failed, MemberFail{ID: id, Code: failCode(checkErr)})
|
||||
continue
|
||||
}
|
||||
if insErr := insertMemberTx(tx, req.GroupID, id, now); insErr != nil {
|
||||
return insErr
|
||||
}
|
||||
present[id] = struct{}{}
|
||||
count++
|
||||
inserted = append(inserted, id)
|
||||
}
|
||||
notify = make([]string, 0, count)
|
||||
for id := range present {
|
||||
notify = append(notify, id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return AddResult{}, err
|
||||
}
|
||||
for _, id := range inserted {
|
||||
a.emit(ctx, notify, req.GroupID, eventMemberAdded, id, now)
|
||||
}
|
||||
return AddResult{Failed: failed}, nil
|
||||
}
|
||||
@@ -234,34 +292,37 @@ func (a *App) Remove(ctx context.Context, actorID string, req *protocol.GroupRem
|
||||
if err := req.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
owner, members, err := a.loadGroup(ctx, req.GroupID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != actorID {
|
||||
return errCode(protocol.CodeForbidden, "not owner")
|
||||
}
|
||||
if req.EndpointID == owner {
|
||||
return errCode(protocol.CodeBadRequest, "cannot remove owner")
|
||||
}
|
||||
if !contains(members, req.EndpointID) {
|
||||
return errCode(protocol.CodeNotFound, "member not found")
|
||||
}
|
||||
now := a.nowMs()
|
||||
var revokes []revokeItem
|
||||
err = a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
|
||||
var notify []string
|
||||
err := a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
|
||||
owner, members, e := loadGroupTx(tx, req.GroupID)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if owner != actorID {
|
||||
return errCode(protocol.CodeForbidden, "not owner")
|
||||
}
|
||||
if req.EndpointID == owner {
|
||||
return errCode(protocol.CodeBadRequest, "cannot remove owner")
|
||||
}
|
||||
if !contains(members, req.EndpointID) {
|
||||
return errCode(protocol.CodeNotFound, "member not found")
|
||||
}
|
||||
if _, e := tx.Exec(`DELETE FROM group_members WHERE group_id = ? AND endpoint_id = ?`,
|
||||
req.GroupID, req.EndpointID); e != nil {
|
||||
return e
|
||||
}
|
||||
return voidMemberDeliveriesTx(tx, req.GroupID, req.EndpointID, reasonLeftGroup, now, &revokes)
|
||||
if e := voidMemberDeliveriesTx(tx, req.GroupID, req.EndpointID, reasonLeftGroup, now, &revokes); e != nil {
|
||||
return e
|
||||
}
|
||||
notify = append(without(members, req.EndpointID), req.EndpointID)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.publishRevokes(ctx, revokes)
|
||||
left := without(members, req.EndpointID)
|
||||
notify := append(left, req.EndpointID)
|
||||
a.emit(ctx, notify, req.GroupID, eventMemberRemoved, req.EndpointID, now)
|
||||
return nil
|
||||
}
|
||||
@@ -274,31 +335,34 @@ func (a *App) Leave(ctx context.Context, actorID string, req *protocol.GroupLeav
|
||||
if err := req.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
owner, members, err := a.loadGroup(ctx, req.GroupID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !contains(members, actorID) {
|
||||
return errCode(protocol.CodeNotMember, "not a member")
|
||||
}
|
||||
if owner == actorID {
|
||||
return errCode(protocol.CodeOwnerCannotLeave, "owner cannot leave")
|
||||
}
|
||||
now := a.nowMs()
|
||||
var revokes []revokeItem
|
||||
err = a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
|
||||
var notify []string
|
||||
err := a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
|
||||
owner, members, e := loadGroupTx(tx, req.GroupID)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if !contains(members, actorID) {
|
||||
return errCode(protocol.CodeNotMember, "not a member")
|
||||
}
|
||||
if owner == actorID {
|
||||
return errCode(protocol.CodeOwnerCannotLeave, "owner cannot leave")
|
||||
}
|
||||
if _, e := tx.Exec(`DELETE FROM group_members WHERE group_id = ? AND endpoint_id = ?`,
|
||||
req.GroupID, actorID); e != nil {
|
||||
return e
|
||||
}
|
||||
return voidMemberDeliveriesTx(tx, req.GroupID, actorID, reasonLeftGroup, now, &revokes)
|
||||
if e := voidMemberDeliveriesTx(tx, req.GroupID, actorID, reasonLeftGroup, now, &revokes); e != nil {
|
||||
return e
|
||||
}
|
||||
notify = append(without(members, actorID), actorID)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.publishRevokes(ctx, revokes)
|
||||
left := without(members, actorID)
|
||||
notify := append(left, actorID)
|
||||
a.emit(ctx, notify, req.GroupID, eventLeft, actorID, now)
|
||||
return nil
|
||||
}
|
||||
@@ -311,20 +375,24 @@ func (a *App) Transfer(ctx context.Context, actorID string, req *protocol.GroupT
|
||||
if err := req.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
owner, members, err := a.loadGroup(ctx, req.GroupID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != actorID {
|
||||
return errCode(protocol.CodeForbidden, "not owner")
|
||||
}
|
||||
if !contains(members, req.EndpointID) {
|
||||
return errCode(protocol.CodeNotFound, "member not found")
|
||||
}
|
||||
now := a.nowMs()
|
||||
err = a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
|
||||
_, e := tx.Exec(`UPDATE groups SET owner_id = ? WHERE id = ?`, req.EndpointID, req.GroupID)
|
||||
return e
|
||||
var members []string
|
||||
err := a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
|
||||
owner, cur, e := loadGroupTx(tx, req.GroupID)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if owner != actorID {
|
||||
return errCode(protocol.CodeForbidden, "not owner")
|
||||
}
|
||||
if !contains(cur, req.EndpointID) {
|
||||
return errCode(protocol.CodeNotFound, "member not found")
|
||||
}
|
||||
if _, e := tx.Exec(`UPDATE groups SET owner_id = ? WHERE id = ?`, req.EndpointID, req.GroupID); e != nil {
|
||||
return e
|
||||
}
|
||||
members = cur
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -341,17 +409,21 @@ func (a *App) Rename(ctx context.Context, actorID string, req *protocol.GroupRen
|
||||
if err := req.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
owner, members, err := a.loadGroup(ctx, req.GroupID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != actorID {
|
||||
return errCode(protocol.CodeForbidden, "not owner")
|
||||
}
|
||||
now := a.nowMs()
|
||||
err = a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
|
||||
_, e := tx.Exec(`UPDATE groups SET name = ? WHERE id = ?`, req.Name, req.GroupID)
|
||||
return e
|
||||
var members []string
|
||||
err := a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
|
||||
owner, cur, e := loadGroupTx(tx, req.GroupID)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if owner != actorID {
|
||||
return errCode(protocol.CodeForbidden, "not owner")
|
||||
}
|
||||
if _, e := tx.Exec(`UPDATE groups SET name = ? WHERE id = ?`, req.Name, req.GroupID); e != nil {
|
||||
return e
|
||||
}
|
||||
members = cur
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -368,24 +440,28 @@ func (a *App) Dissolve(ctx context.Context, actorID string, req *protocol.GroupD
|
||||
if err := req.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
owner, members, err := a.loadGroup(ctx, req.GroupID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if owner != actorID {
|
||||
return errCode(protocol.CodeForbidden, "not owner")
|
||||
}
|
||||
now := a.nowMs()
|
||||
var revokes []revokeItem
|
||||
err = a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
|
||||
var members []string
|
||||
err := a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
|
||||
owner, cur, e := loadGroupTx(tx, req.GroupID)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if owner != actorID {
|
||||
return errCode(protocol.CodeForbidden, "not owner")
|
||||
}
|
||||
if e := voidGroupAllTx(tx, req.GroupID, now, &revokes); e != nil {
|
||||
return e
|
||||
}
|
||||
if _, e := tx.Exec(`DELETE FROM group_members WHERE group_id = ?`, req.GroupID); e != nil {
|
||||
return e
|
||||
}
|
||||
_, e := tx.Exec(`DELETE FROM groups WHERE id = ?`, req.GroupID)
|
||||
return e
|
||||
if _, e := tx.Exec(`DELETE FROM groups WHERE id = ?`, req.GroupID); e != nil {
|
||||
return e
|
||||
}
|
||||
members = cur
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -512,59 +588,80 @@ func (a *App) AdminCreate(ctx context.Context, name, ownerID string, memberIDs [
|
||||
|
||||
// AdminAddMembers adds members without talk-password checks.
|
||||
func (a *App) AdminAddMembers(ctx context.Context, groupID string, memberIDs []string) (AddResult, error) {
|
||||
if err := a.rejectOversizedMemberList(len(memberIDs)); err != nil {
|
||||
return AddResult{}, err
|
||||
}
|
||||
_, members, err := a.loadGroup(ctx, groupID)
|
||||
if err != nil {
|
||||
return AddResult{}, err
|
||||
}
|
||||
failed := make([]MemberFail, 0)
|
||||
added := make([]string, 0)
|
||||
now := a.nowMs()
|
||||
for _, id := range memberIDs {
|
||||
if contains(members, id) {
|
||||
uniq := dedupeIDs(memberIDs, "")
|
||||
already := memberSet(members)
|
||||
candidates := make([]string, 0, len(uniq))
|
||||
for _, id := range uniq {
|
||||
if _, ok := already[id]; ok {
|
||||
continue
|
||||
}
|
||||
var enabled int
|
||||
e := a.db.Read.QueryRowContext(ctx, `SELECT enabled FROM endpoints WHERE id = ?`, id).Scan(&enabled)
|
||||
if errors.Is(e, sql.ErrNoRows) {
|
||||
failed = append(failed, MemberFail{ID: id, Code: protocol.CodeInvalidTarget})
|
||||
continue
|
||||
}
|
||||
if e != nil {
|
||||
return AddResult{}, e
|
||||
}
|
||||
if enabled == 0 {
|
||||
failed = append(failed, MemberFail{ID: id, Code: protocol.CodeEndpointDisabled})
|
||||
continue
|
||||
}
|
||||
if len(members)+len(added) >= a.maxMem {
|
||||
failed = append(failed, MemberFail{ID: id, Code: protocol.CodeGroupFull})
|
||||
continue
|
||||
}
|
||||
added = append(added, id)
|
||||
candidates = append(candidates, id)
|
||||
}
|
||||
if len(added) == 0 {
|
||||
room := a.maxMem - len(members)
|
||||
toAdd, overflow := splitIDs(candidates, room)
|
||||
for _, id := range overflow {
|
||||
failed = append(failed, MemberFail{ID: id, Code: protocol.CodeGroupFull})
|
||||
}
|
||||
if len(toAdd) == 0 {
|
||||
return AddResult{Failed: failed}, nil
|
||||
}
|
||||
now := a.nowMs()
|
||||
var inserted []string
|
||||
var notify []string
|
||||
err = a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
|
||||
for _, id := range added {
|
||||
if _, e := tx.Exec(`INSERT OR IGNORE INTO group_members(group_id, endpoint_id, joined_at) VALUES(?,?,?)`,
|
||||
groupID, id, now); e != nil {
|
||||
return e
|
||||
_, curMembers, e := loadGroupTx(tx, groupID)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
present := memberSet(curMembers)
|
||||
count := len(curMembers)
|
||||
inserted = inserted[:0]
|
||||
for _, id := range toAdd {
|
||||
if _, ok := present[id]; ok {
|
||||
continue
|
||||
}
|
||||
if count >= a.maxMem {
|
||||
failed = append(failed, MemberFail{ID: id, Code: protocol.CodeGroupFull})
|
||||
continue
|
||||
}
|
||||
if checkErr := endpointCheckTx(tx, id); checkErr != nil {
|
||||
failed = append(failed, MemberFail{ID: id, Code: failCode(checkErr)})
|
||||
continue
|
||||
}
|
||||
if insErr := insertMemberTx(tx, groupID, id, now); insErr != nil {
|
||||
return insErr
|
||||
}
|
||||
present[id] = struct{}{}
|
||||
count++
|
||||
inserted = append(inserted, id)
|
||||
}
|
||||
notify = make([]string, 0, count)
|
||||
for id := range present {
|
||||
notify = append(notify, id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return AddResult{}, err
|
||||
}
|
||||
all := append(append([]string{}, members...), added...)
|
||||
for _, id := range added {
|
||||
a.emit(ctx, all, groupID, eventMemberAdded, id, now)
|
||||
for _, id := range inserted {
|
||||
a.emit(ctx, notify, groupID, eventMemberAdded, id, now)
|
||||
}
|
||||
return AddResult{Failed: failed}, nil
|
||||
}
|
||||
|
||||
func (a *App) createAdmin(ctx context.Context, ownerID, name, gid string, members []protocol.GroupMemberIn) (CreateResult, error) {
|
||||
if !protocol.ValidEndpointID(ownerID) {
|
||||
return CreateResult{}, errCode(protocol.CodeBadRequest, "invalid owner")
|
||||
}
|
||||
if gid == "" {
|
||||
var genErr error
|
||||
gid, genErr = generateGroupID()
|
||||
@@ -575,32 +672,29 @@ func (a *App) createAdmin(ctx context.Context, ownerID, name, gid string, member
|
||||
if !protocol.ValidName(name) || name == "" {
|
||||
return CreateResult{}, errCode(protocol.CodeBadRequest, "invalid name")
|
||||
}
|
||||
if err := a.rejectOversizedMemberList(len(members)); err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
now := a.nowMs()
|
||||
failed := make([]MemberFail, 0)
|
||||
added := make([]string, 0)
|
||||
for _, m := range members {
|
||||
if m.ID == ownerID {
|
||||
continue
|
||||
}
|
||||
var enabled int
|
||||
e := a.db.Read.QueryRowContext(ctx, `SELECT enabled FROM endpoints WHERE id = ?`, m.ID).Scan(&enabled)
|
||||
if errors.Is(e, sql.ErrNoRows) {
|
||||
failed = append(failed, MemberFail{ID: m.ID, Code: protocol.CodeInvalidTarget})
|
||||
continue
|
||||
}
|
||||
if e != nil {
|
||||
return CreateResult{}, e
|
||||
}
|
||||
if enabled == 0 {
|
||||
failed = append(failed, MemberFail{ID: m.ID, Code: protocol.CodeEndpointDisabled})
|
||||
continue
|
||||
}
|
||||
added = append(added, m.ID)
|
||||
}
|
||||
if 1+len(added) > a.maxMem {
|
||||
return CreateResult{}, errCode(protocol.CodeGroupFull, "group full")
|
||||
uniq := dedupeMemberIns(members, ownerID)
|
||||
room := a.maxMem - 1
|
||||
toAdd, overflow := splitMemberIns(uniq, room)
|
||||
for _, m := range overflow {
|
||||
failed = append(failed, MemberFail{ID: m.ID, Code: protocol.CodeGroupFull})
|
||||
}
|
||||
|
||||
var inserted []string
|
||||
err := a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
|
||||
if e := endpointCheckTx(tx, ownerID); e != nil {
|
||||
if protoCode(e) == protocol.CodeInvalidTarget {
|
||||
return errCode(protocol.CodeInvalidTarget, "owner not found")
|
||||
}
|
||||
if protoCode(e) == protocol.CodeEndpointDisabled {
|
||||
return errCode(protocol.CodeEndpointDisabled, "owner disabled")
|
||||
}
|
||||
return e
|
||||
}
|
||||
if _, e := tx.Exec(`INSERT INTO groups(id, name, owner_id, created_at) VALUES(?,?,?,?)`,
|
||||
gid, name, ownerID, now); e != nil {
|
||||
if isUnique(e) {
|
||||
@@ -608,21 +702,29 @@ func (a *App) createAdmin(ctx context.Context, ownerID, name, gid string, member
|
||||
}
|
||||
return e
|
||||
}
|
||||
if _, e := tx.Exec(`INSERT INTO group_members(group_id, endpoint_id, joined_at) VALUES(?,?,?)`,
|
||||
gid, ownerID, now); e != nil {
|
||||
if e := insertMemberTx(tx, gid, ownerID, now); e != nil {
|
||||
return e
|
||||
}
|
||||
for _, id := range added {
|
||||
if _, e := tx.Exec(`INSERT INTO group_members(group_id, endpoint_id, joined_at) VALUES(?,?,?)`,
|
||||
gid, id, now); e != nil {
|
||||
inserted = inserted[:0]
|
||||
for _, m := range toAdd {
|
||||
if checkErr := endpointCheckTx(tx, m.ID); checkErr != nil {
|
||||
failed = append(failed, MemberFail{ID: m.ID, Code: failCode(checkErr)})
|
||||
continue
|
||||
}
|
||||
if e := insertMemberTx(tx, gid, m.ID, now); e != nil {
|
||||
return e
|
||||
}
|
||||
inserted = append(inserted, m.ID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return CreateResult{}, err
|
||||
}
|
||||
notify := append([]string{ownerID}, inserted...)
|
||||
for _, id := range inserted {
|
||||
a.emit(ctx, notify, gid, eventMemberAdded, id, now)
|
||||
}
|
||||
return CreateResult{ID: gid, Name: name, OwnerID: ownerID, Failed: failed}, nil
|
||||
}
|
||||
|
||||
@@ -633,6 +735,136 @@ func (a *App) checkAddMember(ctx context.Context, actorID, targetID, talkPasswor
|
||||
return a.talk.CheckTalkPasswordForJoin(ctx, actorID, targetID, talkPassword, a.remoteIP)
|
||||
}
|
||||
|
||||
func (a *App) rejectOversizedMemberList(n int) error {
|
||||
if n > a.memberRequestCap() {
|
||||
return errCode(protocol.CodeBadRequest, "too many members")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadGroupTx(tx *sql.Tx, groupID string) (owner string, members []string, err error) {
|
||||
err = tx.QueryRow(`SELECT owner_id FROM groups WHERE id = ?`, groupID).Scan(&owner)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil, errCode(protocol.CodeNotFound, "group not found")
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
rows, qErr := tx.Query(`SELECT endpoint_id FROM group_members WHERE group_id = ?`, groupID)
|
||||
if qErr != nil {
|
||||
return "", nil, qErr
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if scanErr := rows.Scan(&id); scanErr != nil {
|
||||
return "", nil, scanErr
|
||||
}
|
||||
members = append(members, id)
|
||||
}
|
||||
return owner, members, rows.Err()
|
||||
}
|
||||
|
||||
func endpointCheckTx(tx *sql.Tx, id string) error {
|
||||
var enabled int
|
||||
err := tx.QueryRow(`SELECT enabled FROM endpoints WHERE id = ?`, id).Scan(&enabled)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return errCode(protocol.CodeInvalidTarget, "target not found")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if enabled == 0 {
|
||||
return errCode(protocol.CodeEndpointDisabled, "endpoint disabled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func insertMemberTx(tx *sql.Tx, groupID, endpointID string, now int64) error {
|
||||
_, err := tx.Exec(`INSERT INTO group_members(group_id, endpoint_id, joined_at) VALUES(?,?,?)`,
|
||||
groupID, endpointID, now)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if isForeignKey(err) {
|
||||
return errCode(protocol.CodeNotFound, "group not found")
|
||||
}
|
||||
if isUnique(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func isForeignKey(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "foreign key")
|
||||
}
|
||||
|
||||
func dedupeMemberIns(members []protocol.GroupMemberIn, skipID string) []protocol.GroupMemberIn {
|
||||
seen := make(map[string]struct{}, len(members)+1)
|
||||
if skipID != "" {
|
||||
seen[skipID] = struct{}{}
|
||||
}
|
||||
out := make([]protocol.GroupMemberIn, 0, len(members))
|
||||
for _, m := range members {
|
||||
if _, ok := seen[m.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[m.ID] = struct{}{}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitMemberIns(members []protocol.GroupMemberIn, room int) (keep, overflow []protocol.GroupMemberIn) {
|
||||
if room < 0 {
|
||||
room = 0
|
||||
}
|
||||
if len(members) <= room {
|
||||
return members, nil
|
||||
}
|
||||
return members[:room], members[room:]
|
||||
}
|
||||
|
||||
func dedupeIDs(ids []string, skipID string) []string {
|
||||
seen := make(map[string]struct{}, len(ids)+1)
|
||||
if skipID != "" {
|
||||
seen[skipID] = struct{}{}
|
||||
}
|
||||
out := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitIDs(ids []string, room int) (keep, overflow []string) {
|
||||
if room < 0 {
|
||||
room = 0
|
||||
}
|
||||
if len(ids) <= room {
|
||||
return ids, nil
|
||||
}
|
||||
return ids[:room], ids[room:]
|
||||
}
|
||||
|
||||
func memberSet(ss []string) map[string]struct{} {
|
||||
m := make(map[string]struct{}, len(ss))
|
||||
for _, s := range ss {
|
||||
m[s] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (a *App) loadGroup(ctx context.Context, groupID string) (owner string, members []string, err error) {
|
||||
err = a.db.Read.QueryRowContext(ctx, `SELECT owner_id FROM groups WHERE id = ?`, groupID).Scan(&owner)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -660,6 +892,7 @@ func (a *App) emit(ctx context.Context, recipients []string, groupID, event, end
|
||||
if a.down == nil {
|
||||
return
|
||||
}
|
||||
_ = ctx
|
||||
frame := protocol.GroupEvent{
|
||||
V: protocol.Version, Type: protocol.TypeGroupEvent,
|
||||
GroupID: groupID, Event: event, EndpointID: endpointID, AtMs: atMs,
|
||||
@@ -668,14 +901,20 @@ func (a *App) emit(ctx context.Context, recipients []string, groupID, event, end
|
||||
if encErr != nil {
|
||||
return
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, id := range recipients {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
// 异步且略推迟:必须让处理该端上行的 worker 先 PublishDown resp。
|
||||
// 若与 resp 同时向本连接注入 group_event,会与 mochi InlineClient 互相等待。
|
||||
ids := append([]string(nil), recipients...)
|
||||
go func() {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
seen := map[string]struct{}{}
|
||||
for _, id := range ids {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
_ = a.down.PublishDown(context.Background(), id, "", payload, port.PublishOpts{QoS: 0})
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
_ = a.down.PublishDown(ctx, id, "", payload, port.PublishOpts{QoS: 0})
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func encodeFrame(v any) ([]byte, error) {
|
||||
|
||||
@@ -296,6 +296,16 @@ WHERE m.id='gm1' AND d.endpoint_id='bob'`).Scan(&reason)
|
||||
if err != nil || state != "completed" || mreason != "group_dissolved" {
|
||||
t.Fatalf("state=%s reason=%s err=%v", state, mreason, err)
|
||||
}
|
||||
// 消息级回执 state 须为协议枚举 rejected,不得写成消息状态 completed(issue #6 / DEVELOPMENT 6.4)
|
||||
var rState, rReason, rEndpoint string
|
||||
err = db.Read.QueryRow(`
|
||||
SELECT state, reason, endpoint_id FROM receipts WHERE sender_id='alice' AND msg_id='gm2'`).Scan(&rState, &rReason, &rEndpoint)
|
||||
if err != nil {
|
||||
t.Fatalf("receipt for dissolved scheduled: %v", err)
|
||||
}
|
||||
if rState != "rejected" || rReason != "group_dissolved" || rEndpoint != "" {
|
||||
t.Fatalf("receipt state=%q reason=%q endpoint=%q want rejected/group_dissolved/empty", rState, rReason, rEndpoint)
|
||||
}
|
||||
// 同编号新建群
|
||||
created2, err := gApp.Create(ctx, "alice", &protocol.GroupCreate{
|
||||
V: protocol.Version, Type: protocol.TypeGroupCreate, RID: "6",
|
||||
@@ -451,3 +461,270 @@ func TestGroupTransferRenameListGet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaveLastPendingFinalizesAndReceipt(t *testing.T) {
|
||||
t.Parallel()
|
||||
gApp, _, msgApp, db, _ := setup(t)
|
||||
ctx := context.Background()
|
||||
insertEP(t, db, "alice", 1)
|
||||
insertEP(t, db, "bob", 1)
|
||||
created, err := gApp.Create(ctx, "alice", &protocol.GroupCreate{
|
||||
V: protocol.Version, Type: protocol.TypeGroupCreate, RID: "1",
|
||||
Name: "OnlyBob", Members: []protocol.GroupMemberIn{{ID: "bob"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ttl := int64(3600)
|
||||
_, err = msgApp.Submit(ctx, "alice", port.ConnInfo{}, &protocol.Send{
|
||||
V: protocol.Version, Type: protocol.TypeSend, RID: "s", ID: "keep1",
|
||||
To: protocol.Target{Kind: protocol.TargetGroup, ID: created.ID},
|
||||
Body: protocol.Body{Enc: protocol.EncUTF8, Data: "hi"},
|
||||
Offline: &protocol.OfflineOpts{Keep: true, TTLSeconds: &ttl},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = gApp.Leave(ctx, "bob", &protocol.GroupLeave{
|
||||
V: protocol.Version, Type: protocol.TypeGroupLeave, RID: "2", GroupID: created.ID,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var state, reason string
|
||||
if err = db.Read.QueryRow(`SELECT state, reason FROM messages WHERE id='keep1'`).Scan(&state, &reason); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state != message.StateCompleted {
|
||||
t.Fatalf("state=%s want completed", state)
|
||||
}
|
||||
var bodies int
|
||||
if err = db.Read.QueryRow(`SELECT COUNT(*) FROM message_bodies b JOIN messages m ON m.seq=b.seq WHERE m.id='keep1'`).Scan(&bodies); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bodies != 0 {
|
||||
t.Fatalf("body still present: %d", bodies)
|
||||
}
|
||||
var rState, rReason, rEP string
|
||||
if err = db.Read.QueryRow(`
|
||||
SELECT state, reason, endpoint_id FROM receipts WHERE sender_id='alice' AND msg_id='keep1'`).Scan(&rState, &rReason, &rEP); err != nil {
|
||||
t.Fatalf("receipt: %v", err)
|
||||
}
|
||||
if rState != "rejected" || rReason != "left_group" || rEP != "bob" {
|
||||
t.Fatalf("receipt state=%q reason=%q ep=%q", rState, rReason, rEP)
|
||||
}
|
||||
var pending int
|
||||
if err = db.Read.QueryRow(`SELECT COUNT(*) FROM messages WHERE sender_id='alice' AND state IN ('scheduled','dispatched')`).Scan(&pending); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pending != 0 {
|
||||
t.Fatalf("sender pending count=%d", pending)
|
||||
}
|
||||
}
|
||||
|
||||
type dissolveOnJoin struct {
|
||||
app *group.App
|
||||
gid string
|
||||
owner string
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (d *dissolveOnJoin) CheckTalkPasswordForJoin(ctx context.Context, _, _, _, _ string) error {
|
||||
d.once.Do(func() {
|
||||
if d.app == nil || d.gid == "" {
|
||||
return
|
||||
}
|
||||
_ = d.app.Dissolve(ctx, d.owner, &protocol.GroupDissolve{
|
||||
V: protocol.Version, Type: protocol.TypeGroupDissolve, RID: "hook", GroupID: d.gid,
|
||||
})
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestU02AddAfterTalkGateDissolvesReturnsNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "data"), "FULL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
fixed := time.UnixMilli(1_700_000_000_000)
|
||||
hook := &dissolveOnJoin{owner: "alice"}
|
||||
gApp := group.New(group.Config{
|
||||
DB: db, Talk: hook, MaxGroupMembers: 1000,
|
||||
Now: func() time.Time { return fixed }, DefaultRemoteIP: "1.1.1.1",
|
||||
})
|
||||
hook.app = gApp
|
||||
ctx := context.Background()
|
||||
insertEP(t, db, "alice", 1)
|
||||
insertEP(t, db, "bob", 1)
|
||||
created, err := gApp.Create(ctx, "alice", &protocol.GroupCreate{
|
||||
V: protocol.Version, Type: protocol.TypeGroupCreate, RID: "1", Name: "G",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hook.gid = created.ID
|
||||
_, err = gApp.Add(ctx, "alice", &protocol.GroupAdd{
|
||||
V: protocol.Version, Type: protocol.TypeGroupAdd, RID: "2", GroupID: created.ID,
|
||||
Members: []protocol.GroupMemberIn{{ID: "bob"}},
|
||||
})
|
||||
if protoCode(err) != protocol.CodeNotFound {
|
||||
t.Fatalf("got %v want not_found", err)
|
||||
}
|
||||
var n int
|
||||
if qErr := db.Read.QueryRow(`SELECT COUNT(*) FROM group_members WHERE group_id=?`, created.ID).Scan(&n); qErr != nil {
|
||||
t.Fatal(qErr)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("orphan members=%d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestU02CreateDedupesMembers(t *testing.T) {
|
||||
t.Parallel()
|
||||
gApp, _, _, db, _ := setup(t)
|
||||
ctx := context.Background()
|
||||
insertEP(t, db, "alice", 1)
|
||||
insertEP(t, db, "bob", 1)
|
||||
created, err := gApp.Create(ctx, "alice", &protocol.GroupCreate{
|
||||
V: protocol.Version, Type: protocol.TypeGroupCreate, RID: "1", Name: "G",
|
||||
Members: []protocol.GroupMemberIn{{ID: "bob"}, {ID: "bob"}, {ID: "alice"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(created.Failed) != 0 {
|
||||
t.Fatalf("failed=%+v", created.Failed)
|
||||
}
|
||||
var n, bobN int
|
||||
_ = db.Read.QueryRow(`SELECT COUNT(*) FROM group_members WHERE group_id=?`, created.ID).Scan(&n)
|
||||
_ = db.Read.QueryRow(`SELECT COUNT(*) FROM group_members WHERE group_id=? AND endpoint_id=?`, created.ID, "bob").Scan(&bobN)
|
||||
if n != 2 || bobN != 1 {
|
||||
t.Fatalf("members=%d bob=%d", n, bobN)
|
||||
}
|
||||
}
|
||||
|
||||
func TestU02AdminCreateOwnerMustExistAndEnabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
gApp, _, _, db, down := setup(t)
|
||||
ctx := context.Background()
|
||||
insertEP(t, db, "alice", 1)
|
||||
insertEP(t, db, "bob", 1)
|
||||
insertEP(t, db, "dave", 0)
|
||||
|
||||
_, err := gApp.AdminCreate(ctx, "G", "nobody", nil)
|
||||
if protoCode(err) != protocol.CodeInvalidTarget {
|
||||
t.Fatalf("missing owner got %v", err)
|
||||
}
|
||||
_, err = gApp.AdminCreate(ctx, "G", "dave", nil)
|
||||
if protoCode(err) != protocol.CodeEndpointDisabled {
|
||||
t.Fatalf("disabled owner got %v", err)
|
||||
}
|
||||
_, err = gApp.AdminCreate(ctx, "G", "Alice", nil)
|
||||
if protoCode(err) != protocol.CodeBadRequest {
|
||||
t.Fatalf("invalid owner format got %v", err)
|
||||
}
|
||||
|
||||
down.mu.Lock()
|
||||
down.msgs = nil
|
||||
down.mu.Unlock()
|
||||
created, err := gApp.AdminCreate(ctx, "一组", "alice", []string{"bob", "bob"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var n, bobN int
|
||||
_ = db.Read.QueryRow(`SELECT COUNT(*) FROM group_members WHERE group_id=?`, created.ID).Scan(&n)
|
||||
_ = db.Read.QueryRow(`SELECT COUNT(*) FROM group_members WHERE group_id=? AND endpoint_id=?`, created.ID, "bob").Scan(&bobN)
|
||||
if n != 2 || bobN != 1 {
|
||||
t.Fatalf("members=%d bob=%d", n, bobN)
|
||||
}
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
down.mu.Lock()
|
||||
got := len(down.msgs)
|
||||
down.mu.Unlock()
|
||||
if got >= 2 {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
down.mu.Lock()
|
||||
defer down.mu.Unlock()
|
||||
t.Fatalf("expected member_added downlink, got %d msgs", len(down.msgs))
|
||||
}
|
||||
|
||||
func TestU02ConcurrentAddRespectsLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "data"), "FULL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
fixed := time.UnixMilli(1_700_000_000_000)
|
||||
locks := auth.NewLoginLocks()
|
||||
idApp := identity.New(identity.Config{
|
||||
DB: db, Hash: auth.NewStubHashPool(), Locks: locks,
|
||||
Sessions: auth.NewSessionTokens(),
|
||||
Now: func() time.Time { return fixed },
|
||||
})
|
||||
gApp := group.New(group.Config{
|
||||
DB: db, Talk: idApp, MaxGroupMembers: 3,
|
||||
Now: func() time.Time { return fixed }, DefaultRemoteIP: "1.1.1.1",
|
||||
})
|
||||
ctx := context.Background()
|
||||
insertEP(t, db, "alice", 1)
|
||||
insertEP(t, db, "bob", 1)
|
||||
insertEP(t, db, "carol", 1)
|
||||
insertEP(t, db, "dave", 1)
|
||||
created, err := gApp.Create(ctx, "alice", &protocol.GroupCreate{
|
||||
V: protocol.Version, Type: protocol.TypeGroupCreate, RID: "1", Name: "G",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for _, id := range []string{"bob", "carol", "dave"} {
|
||||
wg.Add(1)
|
||||
go func(id string) {
|
||||
defer wg.Done()
|
||||
_, _ = gApp.Add(ctx, "alice", &protocol.GroupAdd{
|
||||
V: protocol.Version, Type: protocol.TypeGroupAdd, RID: "a" + id,
|
||||
GroupID: created.ID, Members: []protocol.GroupMemberIn{{ID: id}},
|
||||
})
|
||||
}(id)
|
||||
}
|
||||
wg.Wait()
|
||||
var n int
|
||||
if qErr := db.Read.QueryRow(`SELECT COUNT(*) FROM group_members WHERE group_id=?`, created.ID).Scan(&n); qErr != nil {
|
||||
t.Fatal(qErr)
|
||||
}
|
||||
if n != 3 {
|
||||
t.Fatalf("members=%d want 3", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestU02GroupMembersFKRejectsOrphan(t *testing.T) {
|
||||
t.Parallel()
|
||||
gApp, _, _, db, _ := setup(t)
|
||||
ctx := context.Background()
|
||||
insertEP(t, db, "alice", 1)
|
||||
created, err := gApp.Create(ctx, "alice", &protocol.GroupCreate{
|
||||
V: protocol.Version, Type: protocol.TypeGroupCreate, RID: "1", Name: "G",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = gApp.Dissolve(ctx, "alice", &protocol.GroupDissolve{
|
||||
V: protocol.Version, Type: protocol.TypeGroupDissolve, RID: "2", GroupID: created.ID,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = db.Queue.Do(ctx, func(tx *sql.Tx) error {
|
||||
_, e := tx.Exec(`INSERT INTO group_members(group_id, endpoint_id, joined_at) VALUES(?,?,?)`,
|
||||
created.ID, "alice", 1_700_000_000_000)
|
||||
return e
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected foreign key failure")
|
||||
}
|
||||
}
|
||||
|
||||
+20
-32
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
|
||||
"git.asio.asia/nixevol/NixMsg/internal/app/message"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/app/port"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/protocol"
|
||||
)
|
||||
@@ -19,7 +20,7 @@ type revokeItem struct {
|
||||
// voidMemberDeliveriesTx rejects pending deliveries for a leaving member; records revokes for pushed ones.
|
||||
func voidMemberDeliveriesTx(tx *sql.Tx, groupID, endpointID, reason string, nowMs int64, revokes *[]revokeItem) error {
|
||||
rows, err := tx.Query(`
|
||||
SELECT d.seq, d.pushed_at, m.id, m.sender_id
|
||||
SELECT d.seq, m.id, m.sender_id
|
||||
FROM deliveries d
|
||||
JOIN messages m ON m.seq = d.seq
|
||||
WHERE d.endpoint_id = ? AND d.state = 'pending'
|
||||
@@ -30,14 +31,13 @@ WHERE d.endpoint_id = ? AND d.state = 'pending'
|
||||
defer func() { _ = rows.Close() }()
|
||||
type row struct {
|
||||
seq int64
|
||||
pushed sql.NullInt64
|
||||
msgID string
|
||||
senderID string
|
||||
}
|
||||
var list []row
|
||||
for rows.Next() {
|
||||
var r row
|
||||
if scanErr := rows.Scan(&r.seq, &r.pushed, &r.msgID, &r.senderID); scanErr != nil {
|
||||
if scanErr := rows.Scan(&r.seq, &r.msgID, &r.senderID); scanErr != nil {
|
||||
return scanErr
|
||||
}
|
||||
list = append(list, r)
|
||||
@@ -46,16 +46,18 @@ WHERE d.endpoint_id = ? AND d.state = 'pending'
|
||||
return err
|
||||
}
|
||||
for _, r := range list {
|
||||
if _, execErr := tx.Exec(`
|
||||
UPDATE deliveries SET state = 'rejected', reason = ?, updated_at = ? WHERE seq = ? AND endpoint_id = ? AND state = 'pending'`,
|
||||
reason, nowMs, r.seq, endpointID); execErr != nil {
|
||||
pushed, execErr := message.RejectPendingTx(tx, r.seq, endpointID, reason, nowMs)
|
||||
if execErr != nil {
|
||||
return execErr
|
||||
}
|
||||
if r.pushed.Valid && revokes != nil {
|
||||
if pushed && revokes != nil {
|
||||
*revokes = append(*revokes, revokeItem{
|
||||
endpointID: endpointID, msgID: r.msgID, fromID: r.senderID, reason: reason,
|
||||
})
|
||||
}
|
||||
if e := message.TryFinalizeTx(tx, r.seq, nowMs, message.DefaultVoidRetentionDays); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -63,7 +65,7 @@ UPDATE deliveries SET state = 'rejected', reason = ?, updated_at = ? WHERE seq =
|
||||
// voidGroupAllTx rejects all pending group deliveries and completes scheduled messages.
|
||||
func voidGroupAllTx(tx *sql.Tx, groupID string, nowMs int64, revokes *[]revokeItem) error {
|
||||
rows, err := tx.Query(`
|
||||
SELECT d.seq, d.endpoint_id, d.pushed_at, m.id, m.sender_id
|
||||
SELECT d.seq, d.endpoint_id, m.id, m.sender_id
|
||||
FROM deliveries d
|
||||
JOIN messages m ON m.seq = d.seq
|
||||
WHERE d.state = 'pending' AND m.dest_kind = 'group' AND m.dest_id = ?`, groupID)
|
||||
@@ -73,14 +75,13 @@ WHERE d.state = 'pending' AND m.dest_kind = 'group' AND m.dest_id = ?`, groupID)
|
||||
type drow struct {
|
||||
seq int64
|
||||
endpointID string
|
||||
pushed sql.NullInt64
|
||||
msgID string
|
||||
senderID string
|
||||
}
|
||||
var dlist []drow
|
||||
for rows.Next() {
|
||||
var r drow
|
||||
if scanErr := rows.Scan(&r.seq, &r.endpointID, &r.pushed, &r.msgID, &r.senderID); scanErr != nil {
|
||||
if scanErr := rows.Scan(&r.seq, &r.endpointID, &r.msgID, &r.senderID); scanErr != nil {
|
||||
_ = rows.Close()
|
||||
return scanErr
|
||||
}
|
||||
@@ -91,35 +92,35 @@ WHERE d.state = 'pending' AND m.dest_kind = 'group' AND m.dest_id = ?`, groupID)
|
||||
return err
|
||||
}
|
||||
for _, r := range dlist {
|
||||
if _, execErr := tx.Exec(`
|
||||
UPDATE deliveries SET state = 'rejected', reason = ?, updated_at = ?
|
||||
WHERE seq = ? AND endpoint_id = ? AND state = 'pending'`,
|
||||
reasonGroupDissolved, nowMs, r.seq, r.endpointID); execErr != nil {
|
||||
pushed, execErr := message.RejectPendingTx(tx, r.seq, r.endpointID, reasonGroupDissolved, nowMs)
|
||||
if execErr != nil {
|
||||
return execErr
|
||||
}
|
||||
if r.pushed.Valid && revokes != nil {
|
||||
if pushed && revokes != nil {
|
||||
*revokes = append(*revokes, revokeItem{
|
||||
endpointID: r.endpointID, msgID: r.msgID, fromID: r.senderID, reason: reasonGroupDissolved,
|
||||
})
|
||||
}
|
||||
if e := message.TryFinalizeTx(tx, r.seq, nowMs, message.DefaultVoidRetentionDays); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
|
||||
srows, err := tx.Query(`
|
||||
SELECT seq, id, sender_id, receipt FROM messages
|
||||
SELECT seq, sender_id, receipt FROM messages
|
||||
WHERE dest_kind = 'group' AND dest_id = ? AND state = 'scheduled'`, groupID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type srow struct {
|
||||
seq int64
|
||||
msgID string
|
||||
senderID string
|
||||
receipt int
|
||||
}
|
||||
var slist []srow
|
||||
for srows.Next() {
|
||||
var r srow
|
||||
if scanErr := srows.Scan(&r.seq, &r.msgID, &r.senderID, &r.receipt); scanErr != nil {
|
||||
if scanErr := srows.Scan(&r.seq, &r.senderID, &r.receipt); scanErr != nil {
|
||||
_ = srows.Close()
|
||||
return scanErr
|
||||
}
|
||||
@@ -130,22 +131,9 @@ WHERE dest_kind = 'group' AND dest_id = ? AND state = 'scheduled'`, groupID)
|
||||
return err
|
||||
}
|
||||
for _, r := range slist {
|
||||
if _, execErr := tx.Exec(`
|
||||
UPDATE messages SET state = 'completed', reason = ? WHERE seq = ? AND state = 'scheduled'`,
|
||||
reasonGroupDissolved, r.seq); execErr != nil {
|
||||
if execErr := message.FinalizeMessageTx(tx, r.seq, r.receipt != 0, r.senderID, "", reasonGroupDissolved, nowMs, message.DefaultVoidRetentionDays); execErr != nil {
|
||||
return execErr
|
||||
}
|
||||
if _, execErr := tx.Exec(`DELETE FROM message_bodies WHERE seq = ?`, r.seq); execErr != nil {
|
||||
return execErr
|
||||
}
|
||||
if r.receipt != 0 {
|
||||
if _, execErr := tx.Exec(`
|
||||
INSERT INTO receipts(sender_id, msg_id, endpoint_id, state, reason, created_at, acked)
|
||||
VALUES(?,?,?,?,?,?,0)`,
|
||||
r.senderID, r.msgID, "", "completed", reasonGroupDissolved, nowMs); execErr != nil {
|
||||
return execErr
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.asio.asia/nixevol/NixMsg/internal/app/message"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/app/port"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/protocol"
|
||||
)
|
||||
@@ -22,6 +24,9 @@ const (
|
||||
eventLeft = "left"
|
||||
eventMemberRemoved = "member_removed"
|
||||
eventDissolved = "dissolved"
|
||||
|
||||
// kickFlushDelay 给接线方 Session.Disable/Deleted 留出发 fatal 的窗口。
|
||||
kickFlushDelay = 20 * time.Millisecond
|
||||
)
|
||||
|
||||
type revokeItem struct {
|
||||
@@ -124,16 +129,22 @@ WHERE id = ?`, endpointID); e != nil {
|
||||
|
||||
a.publishRevokes(ctx, revokes)
|
||||
a.publishGroupEvents(ctx, notifies)
|
||||
// fatal+断开由 admin DisableKick/DeleteKick(Session.Disable/Deleted)完成。
|
||||
// 未接 Kick 钩子的单元测试仍可用 ConnControl 兜底断开。
|
||||
if a.connCtrl != nil {
|
||||
_ = a.connCtrl.Disconnect(ctx, endpointID, "", port.DisconnectFatal)
|
||||
go func() {
|
||||
time.Sleep(kickFlushDelay)
|
||||
_ = a.connCtrl.Disconnect(context.Background(), endpointID, "", port.DisconnectFatal)
|
||||
}()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func voidEndpointMessagesTx(tx *sql.Tx, endpointID, recvReason, sendReason string, nowMs int64, revokes *[]revokeItem) error {
|
||||
days := message.DefaultVoidRetentionDays
|
||||
// 发给 X 的 pending → rejected
|
||||
rows, err := tx.Query(`
|
||||
SELECT d.seq, d.pushed_at, m.id, m.sender_id, m.receipt
|
||||
SELECT d.seq, m.id, m.sender_id
|
||||
FROM deliveries d
|
||||
JOIN messages m ON m.seq = d.seq
|
||||
WHERE d.endpoint_id = ? AND d.state = 'pending'`, endpointID)
|
||||
@@ -142,15 +153,13 @@ WHERE d.endpoint_id = ? AND d.state = 'pending'`, endpointID)
|
||||
}
|
||||
type pendRow struct {
|
||||
seq int64
|
||||
pushed sql.NullInt64
|
||||
msgID string
|
||||
senderID string
|
||||
receipt int
|
||||
}
|
||||
var pending []pendRow
|
||||
for rows.Next() {
|
||||
var r pendRow
|
||||
if scanErr := rows.Scan(&r.seq, &r.pushed, &r.msgID, &r.senderID, &r.receipt); scanErr != nil {
|
||||
if scanErr := rows.Scan(&r.seq, &r.msgID, &r.senderID); scanErr != nil {
|
||||
_ = rows.Close()
|
||||
return scanErr
|
||||
}
|
||||
@@ -162,18 +171,11 @@ WHERE d.endpoint_id = ? AND d.state = 'pending'`, endpointID)
|
||||
}
|
||||
finalSeqs := map[int64]struct{}{}
|
||||
for _, r := range pending {
|
||||
if _, execErr := tx.Exec(`
|
||||
UPDATE deliveries SET state = 'rejected', reason = ?, updated_at = ?
|
||||
WHERE seq = ? AND endpoint_id = ? AND state = 'pending'`,
|
||||
recvReason, nowMs, r.seq, endpointID); execErr != nil {
|
||||
pushed, execErr := message.RejectPendingTx(tx, r.seq, endpointID, recvReason, nowMs)
|
||||
if execErr != nil {
|
||||
return execErr
|
||||
}
|
||||
if r.receipt != 0 {
|
||||
if e := insertReceiptIfWantedTx(tx, r.senderID, r.msgID, endpointID, "rejected", recvReason, nowMs, true); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
if r.pushed.Valid && revokes != nil {
|
||||
if pushed && revokes != nil {
|
||||
*revokes = append(*revokes, revokeItem{
|
||||
endpointID: endpointID, msgID: r.msgID, fromID: r.senderID, reason: recvReason,
|
||||
})
|
||||
@@ -183,21 +185,20 @@ WHERE seq = ? AND endpoint_id = ? AND state = 'pending'`,
|
||||
|
||||
// 发给 X 的 scheduled 单聊 → completed,要回执则写
|
||||
srows, err := tx.Query(`
|
||||
SELECT seq, id, sender_id, receipt FROM messages
|
||||
SELECT seq, sender_id, receipt FROM messages
|
||||
WHERE dest_kind = 'endpoint' AND dest_id = ? AND state = 'scheduled'`, endpointID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type schedRow struct {
|
||||
seq int64
|
||||
msgID string
|
||||
senderID string
|
||||
receipt int
|
||||
}
|
||||
var scheduledTo []schedRow
|
||||
for srows.Next() {
|
||||
var r schedRow
|
||||
if scanErr := srows.Scan(&r.seq, &r.msgID, &r.senderID, &r.receipt); scanErr != nil {
|
||||
if scanErr := srows.Scan(&r.seq, &r.senderID, &r.receipt); scanErr != nil {
|
||||
_ = srows.Close()
|
||||
return scanErr
|
||||
}
|
||||
@@ -208,19 +209,8 @@ WHERE dest_kind = 'endpoint' AND dest_id = ? AND state = 'scheduled'`, endpointI
|
||||
return err
|
||||
}
|
||||
for _, r := range scheduledTo {
|
||||
if _, execErr := tx.Exec(`
|
||||
UPDATE messages SET state = 'completed', reason = ? WHERE seq = ? AND state = 'scheduled'`,
|
||||
recvReason, r.seq); execErr != nil {
|
||||
return execErr
|
||||
}
|
||||
if _, execErr := tx.Exec(`DELETE FROM message_bodies WHERE seq = ?`, r.seq); execErr != nil {
|
||||
return execErr
|
||||
}
|
||||
if r.receipt != 0 {
|
||||
// 消息级作废:endpoint_id 空,state=rejected(DEVELOPMENT 6.4)
|
||||
if e := insertReceiptIfWantedTx(tx, r.senderID, r.msgID, "", "rejected", recvReason, nowMs, true); e != nil {
|
||||
return e
|
||||
}
|
||||
if e := message.FinalizeMessageTx(tx, r.seq, r.receipt != 0, r.senderID, "", recvReason, nowMs, days); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,19 +233,14 @@ UPDATE messages SET state = 'completed', reason = ? WHERE seq = ? AND state = 's
|
||||
return err
|
||||
}
|
||||
for _, seq := range outSeqs {
|
||||
if _, execErr := tx.Exec(`
|
||||
UPDATE messages SET state = 'completed', reason = ? WHERE seq = ? AND state = 'scheduled'`,
|
||||
sendReason, seq); execErr != nil {
|
||||
return execErr
|
||||
}
|
||||
if _, execErr := tx.Exec(`DELETE FROM message_bodies WHERE seq = ?`, seq); execErr != nil {
|
||||
return execErr
|
||||
if e := message.FinalizeMessageTx(tx, seq, false, endpointID, "", sendReason, nowMs, days); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
|
||||
// X 发出的消息的 pending 投递 → rejected(sender_*),不写回执
|
||||
drows, err := tx.Query(`
|
||||
SELECT d.seq, d.endpoint_id, d.pushed_at, m.id, m.sender_id
|
||||
SELECT d.seq, d.endpoint_id, m.id, m.sender_id
|
||||
FROM deliveries d
|
||||
JOIN messages m ON m.seq = d.seq
|
||||
WHERE m.sender_id = ? AND d.state = 'pending'`, endpointID)
|
||||
@@ -265,14 +250,13 @@ WHERE m.sender_id = ? AND d.state = 'pending'`, endpointID)
|
||||
type outPend struct {
|
||||
seq int64
|
||||
endpointID string
|
||||
pushed sql.NullInt64
|
||||
msgID string
|
||||
senderID string
|
||||
}
|
||||
var outPending []outPend
|
||||
for drows.Next() {
|
||||
var r outPend
|
||||
if scanErr := drows.Scan(&r.seq, &r.endpointID, &r.pushed, &r.msgID, &r.senderID); scanErr != nil {
|
||||
if scanErr := drows.Scan(&r.seq, &r.endpointID, &r.msgID, &r.senderID); scanErr != nil {
|
||||
_ = drows.Close()
|
||||
return scanErr
|
||||
}
|
||||
@@ -283,13 +267,11 @@ WHERE m.sender_id = ? AND d.state = 'pending'`, endpointID)
|
||||
return err
|
||||
}
|
||||
for _, r := range outPending {
|
||||
if _, execErr := tx.Exec(`
|
||||
UPDATE deliveries SET state = 'rejected', reason = ?, updated_at = ?
|
||||
WHERE seq = ? AND endpoint_id = ? AND state = 'pending'`,
|
||||
sendReason, nowMs, r.seq, r.endpointID); execErr != nil {
|
||||
pushed, execErr := message.RejectPendingTx(tx, r.seq, r.endpointID, sendReason, nowMs)
|
||||
if execErr != nil {
|
||||
return execErr
|
||||
}
|
||||
if r.pushed.Valid && revokes != nil {
|
||||
if pushed && revokes != nil {
|
||||
*revokes = append(*revokes, revokeItem{
|
||||
endpointID: r.endpointID, msgID: r.msgID, fromID: r.senderID, reason: sendReason,
|
||||
})
|
||||
@@ -298,7 +280,7 @@ WHERE seq = ? AND endpoint_id = ? AND state = 'pending'`,
|
||||
}
|
||||
|
||||
for seq := range finalSeqs {
|
||||
if e := tryFinalizeTx(tx, seq); e != nil {
|
||||
if e := message.TryFinalizeTx(tx, seq, nowMs, days); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
@@ -436,7 +418,7 @@ func withoutMember(ids []string, drop string) []string {
|
||||
// voidMemberDeliveriesTx 与 group 包同语义:退群成员的 pending 群投递改 rejected。
|
||||
func voidMemberDeliveriesTx(tx *sql.Tx, groupID, endpointID, reason string, nowMs int64, revokes *[]revokeItem) error {
|
||||
rows, err := tx.Query(`
|
||||
SELECT d.seq, d.pushed_at, m.id, m.sender_id
|
||||
SELECT d.seq, m.id, m.sender_id
|
||||
FROM deliveries d
|
||||
JOIN messages m ON m.seq = d.seq
|
||||
WHERE d.endpoint_id = ? AND d.state = 'pending'
|
||||
@@ -446,14 +428,13 @@ WHERE d.endpoint_id = ? AND d.state = 'pending'
|
||||
}
|
||||
type row struct {
|
||||
seq int64
|
||||
pushed sql.NullInt64
|
||||
msgID string
|
||||
senderID string
|
||||
}
|
||||
var list []row
|
||||
for rows.Next() {
|
||||
var r row
|
||||
if scanErr := rows.Scan(&r.seq, &r.pushed, &r.msgID, &r.senderID); scanErr != nil {
|
||||
if scanErr := rows.Scan(&r.seq, &r.msgID, &r.senderID); scanErr != nil {
|
||||
_ = rows.Close()
|
||||
return scanErr
|
||||
}
|
||||
@@ -463,19 +444,18 @@ WHERE d.endpoint_id = ? AND d.state = 'pending'
|
||||
if err = rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
days := message.DefaultVoidRetentionDays
|
||||
for _, r := range list {
|
||||
if _, execErr := tx.Exec(`
|
||||
UPDATE deliveries SET state = 'rejected', reason = ?, updated_at = ?
|
||||
WHERE seq = ? AND endpoint_id = ? AND state = 'pending'`,
|
||||
reason, nowMs, r.seq, endpointID); execErr != nil {
|
||||
pushed, execErr := message.RejectPendingTx(tx, r.seq, endpointID, reason, nowMs)
|
||||
if execErr != nil {
|
||||
return execErr
|
||||
}
|
||||
if r.pushed.Valid && revokes != nil {
|
||||
if pushed && revokes != nil {
|
||||
*revokes = append(*revokes, revokeItem{
|
||||
endpointID: endpointID, msgID: r.msgID, fromID: r.senderID, reason: reason,
|
||||
})
|
||||
}
|
||||
if e := tryFinalizeTx(tx, r.seq); e != nil {
|
||||
if e := message.TryFinalizeTx(tx, r.seq, nowMs, days); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
@@ -483,8 +463,9 @@ WHERE seq = ? AND endpoint_id = ? AND state = 'pending'`,
|
||||
}
|
||||
|
||||
func voidGroupAllTx(tx *sql.Tx, groupID string, nowMs int64, revokes *[]revokeItem) error {
|
||||
days := message.DefaultVoidRetentionDays
|
||||
rows, err := tx.Query(`
|
||||
SELECT d.seq, d.endpoint_id, d.pushed_at, m.id, m.sender_id
|
||||
SELECT d.seq, d.endpoint_id, m.id, m.sender_id
|
||||
FROM deliveries d
|
||||
JOIN messages m ON m.seq = d.seq
|
||||
WHERE d.state = 'pending' AND m.dest_kind = 'group' AND m.dest_id = ?`, groupID)
|
||||
@@ -494,14 +475,13 @@ WHERE d.state = 'pending' AND m.dest_kind = 'group' AND m.dest_id = ?`, groupID)
|
||||
type drow struct {
|
||||
seq int64
|
||||
endpointID string
|
||||
pushed sql.NullInt64
|
||||
msgID string
|
||||
senderID string
|
||||
}
|
||||
var dlist []drow
|
||||
for rows.Next() {
|
||||
var r drow
|
||||
if scanErr := rows.Scan(&r.seq, &r.endpointID, &r.pushed, &r.msgID, &r.senderID); scanErr != nil {
|
||||
if scanErr := rows.Scan(&r.seq, &r.endpointID, &r.msgID, &r.senderID); scanErr != nil {
|
||||
_ = rows.Close()
|
||||
return scanErr
|
||||
}
|
||||
@@ -512,38 +492,35 @@ WHERE d.state = 'pending' AND m.dest_kind = 'group' AND m.dest_id = ?`, groupID)
|
||||
return err
|
||||
}
|
||||
for _, r := range dlist {
|
||||
if _, execErr := tx.Exec(`
|
||||
UPDATE deliveries SET state = 'rejected', reason = ?, updated_at = ?
|
||||
WHERE seq = ? AND endpoint_id = ? AND state = 'pending'`,
|
||||
reasonGroupDissolved, nowMs, r.seq, r.endpointID); execErr != nil {
|
||||
pushed, execErr := message.RejectPendingTx(tx, r.seq, r.endpointID, reasonGroupDissolved, nowMs)
|
||||
if execErr != nil {
|
||||
return execErr
|
||||
}
|
||||
if r.pushed.Valid && revokes != nil {
|
||||
if pushed && revokes != nil {
|
||||
*revokes = append(*revokes, revokeItem{
|
||||
endpointID: r.endpointID, msgID: r.msgID, fromID: r.senderID, reason: reasonGroupDissolved,
|
||||
})
|
||||
}
|
||||
if e := tryFinalizeTx(tx, r.seq); e != nil {
|
||||
if e := message.TryFinalizeTx(tx, r.seq, nowMs, days); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
|
||||
srows, err := tx.Query(`
|
||||
SELECT seq, id, sender_id, receipt FROM messages
|
||||
SELECT seq, sender_id, receipt FROM messages
|
||||
WHERE dest_kind = 'group' AND dest_id = ? AND state = 'scheduled'`, groupID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type srow struct {
|
||||
seq int64
|
||||
msgID string
|
||||
senderID string
|
||||
receipt int
|
||||
}
|
||||
var slist []srow
|
||||
for srows.Next() {
|
||||
var r srow
|
||||
if scanErr := srows.Scan(&r.seq, &r.msgID, &r.senderID, &r.receipt); scanErr != nil {
|
||||
if scanErr := srows.Scan(&r.seq, &r.senderID, &r.receipt); scanErr != nil {
|
||||
_ = srows.Close()
|
||||
return scanErr
|
||||
}
|
||||
@@ -554,66 +531,13 @@ WHERE dest_kind = 'group' AND dest_id = ? AND state = 'scheduled'`, groupID)
|
||||
return err
|
||||
}
|
||||
for _, r := range slist {
|
||||
if _, execErr := tx.Exec(`
|
||||
UPDATE messages SET state = 'completed', reason = ? WHERE seq = ? AND state = 'scheduled'`,
|
||||
reasonGroupDissolved, r.seq); execErr != nil {
|
||||
return execErr
|
||||
}
|
||||
if _, execErr := tx.Exec(`DELETE FROM message_bodies WHERE seq = ?`, r.seq); execErr != nil {
|
||||
return execErr
|
||||
}
|
||||
if r.receipt != 0 {
|
||||
if e := insertReceiptIfWantedTx(tx, r.senderID, r.msgID, "", "rejected", reasonGroupDissolved, nowMs, true); e != nil {
|
||||
return e
|
||||
}
|
||||
if e := message.FinalizeMessageTx(tx, r.seq, r.receipt != 0, r.senderID, "", reasonGroupDissolved, nowMs, days); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func insertReceiptIfWantedTx(tx *sql.Tx, senderID, msgID, endpointID, state, reason string, nowMs int64, alreadyWanted bool) error {
|
||||
if !alreadyWanted {
|
||||
return nil
|
||||
}
|
||||
var one int
|
||||
err := tx.QueryRow(`SELECT 1 FROM endpoints WHERE id = ?`, senderID).Scan(&one)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO receipts(sender_id, msg_id, endpoint_id, state, reason, created_at, acked)
|
||||
VALUES(?,?,?,?,?,?,0)`, senderID, msgID, endpointID, state, reason, nowMs)
|
||||
return err
|
||||
}
|
||||
|
||||
func tryFinalizeTx(tx *sql.Tx, seq int64) error {
|
||||
var n int
|
||||
if err := tx.QueryRow(`SELECT COUNT(*) FROM deliveries WHERE seq = ? AND state = 'pending'`, seq).Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
var state string
|
||||
if err := tx.QueryRow(`SELECT state FROM messages WHERE seq = ?`, seq).Scan(&state); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if state == "completed" {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE messages SET state = 'completed' WHERE seq = ?`, seq); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(`DELETE FROM message_bodies WHERE seq = ?`, seq)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) publishRevokes(ctx context.Context, items []revokeItem) {
|
||||
if a.down == nil || len(items) == 0 {
|
||||
return
|
||||
|
||||
@@ -3,6 +3,7 @@ package identity_test
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
@@ -64,6 +65,82 @@ VALUES(?,?,?,?,0,0,1,?,?)`, id, id, "stub$login", nil, 1_700_000_000_000, 1_700_
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisableEmitsRevokedForPushed(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
db, err := store.Open(filepath.Join(dir, "data"), "FULL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
fixed := time.UnixMilli(1_700_000_000_000)
|
||||
down := &message.RecordingDownlink{}
|
||||
ctrl := &port.StubConnControl{}
|
||||
idApp := identity.New(identity.Config{
|
||||
DB: db,
|
||||
Hash: auth.NewStubHashPool(),
|
||||
Locks: auth.NewStubLoginLocks(),
|
||||
Sessions: auth.NewSessionTokens(),
|
||||
MaxScheduleSeconds: int64(config.Default().Limits.MaxScheduleSeconds),
|
||||
Now: func() time.Time { return fixed },
|
||||
ConnControl: ctrl,
|
||||
Downlink: down,
|
||||
})
|
||||
ctx := context.Background()
|
||||
insertEPFull(t, db, "alice")
|
||||
insertEPFull(t, db, "bob")
|
||||
|
||||
err = db.Queue.Do(ctx, func(tx *sql.Tx) error {
|
||||
res, e := tx.Exec(`
|
||||
INSERT INTO messages(
|
||||
id, sender_id, dest_kind, dest_id, meta, content_type, body_enc,
|
||||
send_at, keep, ttl_seconds, receipt, state, reason, created_at)
|
||||
VALUES('pushed-1','alice','endpoint','bob','{}','text/plain','utf8',?,1,0,0,'dispatched','',?)`,
|
||||
fixed.UnixMilli(), fixed.UnixMilli())
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
seq, _ := res.LastInsertId()
|
||||
_, e = tx.Exec(`
|
||||
INSERT INTO deliveries(seq, endpoint_id, send_at, keep, state, reason, updated_at, pushed_at, pushed_conn)
|
||||
VALUES(?,?,?,1,'pending','',?,?,?)`,
|
||||
seq, "bob", fixed.UnixMilli(), fixed.UnixMilli(), fixed.UnixMilli(), "c-bob")
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := idApp.Disable(ctx, "bob"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if down.FilterType(protocol.TypeRevoked) != 1 {
|
||||
t.Fatalf("want 1 revoked, got snapshots=%v", down.Snapshots())
|
||||
}
|
||||
p := down.Snapshots()[0]
|
||||
var head struct {
|
||||
Type string `json:"type"`
|
||||
Reason string `json:"reason"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
_ = json.Unmarshal(p.Payload, &head)
|
||||
if head.Type != protocol.TypeRevoked || head.Reason != "endpoint_disabled" || head.ID != "pushed-1" {
|
||||
t.Fatalf("revoked=%+v", head)
|
||||
}
|
||||
if p.EndpointID != "bob" || p.QoS != 1 {
|
||||
t.Fatalf("publish=%+v", p)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if len(ctrl.Calls) == 1 && ctrl.Calls[0] == "bob" {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("disconnect calls=%v", ctrl.Calls)
|
||||
}
|
||||
|
||||
func TestF01DisableVoidsScheduledAndRejectsNew(t *testing.T) {
|
||||
t.Parallel()
|
||||
idApp, msgApp, db := openLifecycle(t)
|
||||
@@ -151,6 +228,54 @@ func TestF01DisableVoidsScheduledAndRejectsNew(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisableLastPendingFinalizesAndReceipt(t *testing.T) {
|
||||
t.Parallel()
|
||||
idApp, msgApp, db := openLifecycle(t)
|
||||
ctx := context.Background()
|
||||
insertEPFull(t, db, "alice")
|
||||
insertEPFull(t, db, "bob")
|
||||
ttl := int64(3600)
|
||||
if _, err := msgApp.Submit(ctx, "alice", port.ConnInfo{EndpointID: "alice"}, &protocol.Send{
|
||||
V: protocol.Version, Type: protocol.TypeSend, RID: "1", ID: "keep-bob",
|
||||
To: protocol.Target{Kind: protocol.TargetEndpoint, ID: "bob"},
|
||||
Body: protocol.Body{Enc: protocol.EncUTF8, Data: "hi"},
|
||||
Offline: &protocol.OfflineOpts{Keep: true, TTLSeconds: &ttl},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := idApp.Disable(ctx, "bob"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var state, reason string
|
||||
if err := db.Read.QueryRow(`SELECT state, reason FROM messages WHERE id='keep-bob'`).Scan(&state, &reason); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state != "completed" {
|
||||
t.Fatalf("state=%s want completed", state)
|
||||
}
|
||||
var bodies int
|
||||
if err := db.Read.QueryRow(`SELECT COUNT(*) FROM message_bodies b JOIN messages m ON m.seq=b.seq WHERE m.id='keep-bob'`).Scan(&bodies); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bodies != 0 {
|
||||
t.Fatalf("body still present: %d", bodies)
|
||||
}
|
||||
var rState, rReason string
|
||||
if err := db.Read.QueryRow(`SELECT state, reason FROM receipts WHERE sender_id='alice' AND msg_id='keep-bob'`).Scan(&rState, &rReason); err != nil {
|
||||
t.Fatalf("receipt: %v", err)
|
||||
}
|
||||
if rState != "rejected" || rReason != "endpoint_disabled" {
|
||||
t.Fatalf("receipt state=%q reason=%q", rState, rReason)
|
||||
}
|
||||
var pending int
|
||||
if err := db.Read.QueryRow(`SELECT COUNT(*) FROM messages WHERE sender_id='alice' AND state IN ('scheduled','dispatched')`).Scan(&pending); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pending != 0 {
|
||||
t.Fatalf("sender pending=%d", pending)
|
||||
}
|
||||
}
|
||||
|
||||
func TestF01DeleteOwnerTransfersEarliest(t *testing.T) {
|
||||
t.Parallel()
|
||||
idApp, _, db := openLifecycle(t)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.asio.asia/nixevol/NixMsg/internal/auth"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/httpx"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/protocol"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/store"
|
||||
)
|
||||
@@ -309,6 +310,83 @@ func TestRegisterF23_WrongCodeLock(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterTrustedProxyClientIPLock 验证与管理接口相同的 httpx.ClientIP:
|
||||
// 受信代理的 X-Forwarded-For 按真实客户端 IP 计锁;非信任来源不采信转发头。
|
||||
func TestRegisterTrustedProxyClientIPLock(t *testing.T) {
|
||||
db, err := store.Open(t.TempDir(), "FULL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
locks := newRegisterIPLocker(time.Now)
|
||||
trusted := httpx.ParseCIDRs([]string{"127.0.0.1/32"})
|
||||
handler := NewRegisterHandler(RegisterConfig{
|
||||
DB: db,
|
||||
Hash: auth.NewStubHashPool(),
|
||||
Locks: locks,
|
||||
ClientIP: func(r *http.Request) string {
|
||||
return httpx.ClientIP(r, trusted)
|
||||
},
|
||||
})
|
||||
env := &testEnv{db: db, hash: auth.NewStubHashPool(), locks: locks, handler: handler}
|
||||
env.setRegistration(t, true, "proxy-lock-1")
|
||||
|
||||
post := func(remote, xff, body string) (int, registerResp) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/client/register", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.RemoteAddr = remote
|
||||
if xff != "" {
|
||||
req.Header.Set("X-Forwarded-For", xff)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
var resp registerResp
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v body=%s", err, rr.Body.String())
|
||||
}
|
||||
return rr.Code, resp
|
||||
}
|
||||
|
||||
wrong := `{"registration_code":"wrong-code","id":"ep_px","login_password":"password1"}`
|
||||
good := `{"registration_code":"proxy-lock-1","id":"ep_px","login_password":"password1"}`
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
code, resp := post("127.0.0.1:9000", "198.51.100.7", wrong)
|
||||
if code != http.StatusForbidden || resp.Error == nil || resp.Error.Code != protocol.CodeRegistrationCodeInvalid {
|
||||
t.Fatalf("trusted fail #%d: status=%d resp=%+v", i+1, code, resp)
|
||||
}
|
||||
}
|
||||
code, resp := post("127.0.0.1:9000", "198.51.100.7", good)
|
||||
if code != http.StatusTooManyRequests || resp.Error == nil || resp.Error.Code != protocol.CodeRateLimited {
|
||||
t.Fatalf("real client should be locked: status=%d resp=%+v", code, resp)
|
||||
}
|
||||
code, resp = post("127.0.0.1:9000", "198.51.100.8", good)
|
||||
if code != http.StatusOK || !resp.OK || resp.Data.ID != "ep_px" {
|
||||
t.Fatalf("other XFF client must not share lock: status=%d resp=%+v", code, resp)
|
||||
}
|
||||
|
||||
// 非信任对端:忽略 XFF,按 RemoteAddr 计锁。
|
||||
locks.Clear(auth.LockKey{Kind: auth.LockRegisterIP, IP: "198.51.100.7"})
|
||||
locks.Clear(auth.LockKey{Kind: auth.LockRegisterIP, IP: "203.0.113.50"})
|
||||
for i := 0; i < 10; i++ {
|
||||
code, resp = post("203.0.113.50:4433", "198.51.100.7", wrong)
|
||||
if code != http.StatusForbidden || resp.Error == nil || resp.Error.Code != protocol.CodeRegistrationCodeInvalid {
|
||||
t.Fatalf("untrusted fail #%d: status=%d resp=%+v", i+1, code, resp)
|
||||
}
|
||||
}
|
||||
code, resp = post("203.0.113.50:4433", "198.51.100.7", `{"registration_code":"proxy-lock-1","id":"ep_px2","login_password":"password1"}`)
|
||||
if code != http.StatusTooManyRequests || resp.Error == nil || resp.Error.Code != protocol.CodeRateLimited {
|
||||
t.Fatalf("untrusted RemoteAddr should be locked: status=%d resp=%+v", code, resp)
|
||||
}
|
||||
// 若误采信 XFF,198.51.100.7 会已锁;直连该 IP 应仍可注册。
|
||||
code, resp = post("198.51.100.7:5555", "", `{"registration_code":"proxy-lock-1","id":"ep_px3","login_password":"password1"}`)
|
||||
if code != http.StatusOK || !resp.OK || resp.Data.ID != "ep_px3" {
|
||||
t.Fatalf("spoofed XFF must not lock real client: status=%d resp=%+v", code, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterF23_IDTakenKeepsOriginal(t *testing.T) {
|
||||
env := openTestEnv(t)
|
||||
env.setRegistration(t, true, "taken-code")
|
||||
|
||||
@@ -17,6 +17,8 @@ func (a *App) Ack(ctx context.Context, endpointID string, req *protocol.Ack) (Ac
|
||||
nowMs := a.now().UnixMilli()
|
||||
var out AckResult
|
||||
var seq int64
|
||||
var ackLatencySec float64
|
||||
var observeAck bool
|
||||
err := a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
|
||||
err := tx.QueryRow(`SELECT seq FROM messages WHERE sender_id = ? AND id = ?`, req.From, req.ID).Scan(&seq)
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -25,6 +27,10 @@ func (a *App) Ack(ctx context.Context, endpointID string, req *protocol.Ack) (Ac
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var pushedAt sql.NullInt64
|
||||
_ = tx.QueryRow(`
|
||||
SELECT pushed_at FROM deliveries
|
||||
WHERE seq = ? AND endpoint_id = ? AND state = 'pending'`, seq, endpointID).Scan(&pushedAt)
|
||||
res, err := tx.Exec(`
|
||||
UPDATE deliveries SET state = ?, reason = '', pushed_conn = NULL, updated_at = ?
|
||||
WHERE seq = ? AND endpoint_id = ? AND state = 'pending'`,
|
||||
@@ -35,10 +41,14 @@ WHERE seq = ? AND endpoint_id = ? AND state = 'pending'`,
|
||||
aff, _ := res.RowsAffected()
|
||||
if aff > 0 {
|
||||
out.Result = DeliveryAccepted
|
||||
if pushedAt.Valid && pushedAt.Int64 > 0 && nowMs >= pushedAt.Int64 {
|
||||
ackLatencySec = float64(nowMs-pushedAt.Int64) / 1000.0
|
||||
observeAck = true
|
||||
}
|
||||
if e := insertReceiptTx(tx, req.From, seq, endpointID, DeliveryAccepted, "", nowMs); e != nil {
|
||||
return e
|
||||
}
|
||||
return tryFinalizeTx(tx, seq, nowMs, a.lim.RecordRetentionDays)
|
||||
return TryFinalizeTx(tx, seq, nowMs, a.lim.RecordRetentionDays)
|
||||
}
|
||||
var state string
|
||||
err = tx.QueryRow(`
|
||||
@@ -55,6 +65,9 @@ SELECT state FROM deliveries WHERE seq = ? AND endpoint_id = ?`, seq, endpointID
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if observeAck && a.met != nil {
|
||||
a.met.AckSeconds.Observe(ackLatencySec)
|
||||
}
|
||||
if out.Result == DeliveryAccepted {
|
||||
a.releaseLarge(seq, endpointID)
|
||||
}
|
||||
@@ -162,7 +175,7 @@ SELECT COUNT(*) FROM deliveries WHERE seq = ? AND state IN ('expired','dropped',
|
||||
default:
|
||||
data.Result = "failed"
|
||||
}
|
||||
return tryFinalizeTx(tx, seq, nowMs, a.lim.RecordRetentionDays)
|
||||
return TryFinalizeTx(tx, seq, nowMs, a.lim.RecordRetentionDays)
|
||||
})
|
||||
if err != nil {
|
||||
return data, err
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"git.asio.asia/nixevol/NixMsg/internal/app/port"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/auth"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/config"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/metrics"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/protocol"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/store"
|
||||
)
|
||||
@@ -86,6 +87,7 @@ type App struct {
|
||||
|
||||
down port.Downlink
|
||||
conns ConnRegistry
|
||||
met *metrics.Registry
|
||||
|
||||
mu sync.Mutex
|
||||
largeSem chan struct{}
|
||||
@@ -117,6 +119,11 @@ func WithConnRegistry(c ConnRegistry) Option {
|
||||
return func(a *App) { a.conns = c }
|
||||
}
|
||||
|
||||
// WithMetrics 注入 Prometheus 注册表(投递耗时直方图)。
|
||||
func WithMetrics(m *metrics.Registry) Option {
|
||||
return func(a *App) { a.met = m }
|
||||
}
|
||||
|
||||
// New 创建消息服务实现。
|
||||
func New(db *store.DB, lim Limits, hash auth.HashPool, opts ...Option) *App {
|
||||
if lim.RequestBurst <= 0 {
|
||||
@@ -155,9 +162,11 @@ func (a *App) now() time.Time {
|
||||
|
||||
func (a *App) protocolLimits() protocol.Limits {
|
||||
return protocol.Limits{
|
||||
MaxBodyBytes: a.lim.MaxBodyBytes,
|
||||
MaxMetaBytes: a.lim.MaxMetaBytes,
|
||||
MaxFrameBytes: a.lim.MaxFrameBytes,
|
||||
MaxBodyBytes: a.lim.MaxBodyBytes,
|
||||
MaxMetaBytes: a.lim.MaxMetaBytes,
|
||||
MaxFrameBytes: a.lim.MaxFrameBytes,
|
||||
MaxTTLSeconds: a.lim.MaxTTLSeconds,
|
||||
MaxScheduleSeconds: a.lim.MaxScheduleSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package message
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
@@ -742,3 +743,37 @@ func TestPushRevokedOnRecallAfterPush(t *testing.T) {
|
||||
t.Fatal("expected revoked frame")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushPreservesLargeMetaInteger(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw := `{"id":12345678901234567890}`
|
||||
decoded := decodeMetaJSON(raw)
|
||||
n, ok := decoded["id"].(json.Number)
|
||||
if !ok || n.String() != "12345678901234567890" {
|
||||
t.Fatalf("decode meta=%v", decoded)
|
||||
}
|
||||
|
||||
e := openDeliveryEnv(t, nil)
|
||||
insertEndpoint(t, e.db, "alice", "", 1, 0)
|
||||
insertEndpoint(t, e.db, "bob", "", 1, 0)
|
||||
e.online("bob", "c-bob")
|
||||
ctx := context.Background()
|
||||
req := baseSend("meta-big", "bob")
|
||||
req.Meta = map[string]any{"id": json.Number("12345678901234567890")}
|
||||
if _, err := e.app.Submit(ctx, "alice", port.ConnInfo{}, req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.app.PushPending(ctx, "bob", "c-bob"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, p := range e.down.Snapshots() {
|
||||
if bytes.Contains(p.Payload, []byte("12345678901234567890")) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("push payloads missing large int: %v", e.down.Snapshots())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ package message
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
|
||||
"git.asio.asia/nixevol/NixMsg/internal/protocol"
|
||||
)
|
||||
|
||||
// DefaultVoidRetentionDays 是 group/identity 作废路径未接线配置时的记录保留天数。
|
||||
const DefaultVoidRetentionDays = 7
|
||||
|
||||
// 投递状态(DEVELOPMENT 7.1)。
|
||||
const (
|
||||
DeliveryPending = "pending"
|
||||
@@ -91,7 +93,7 @@ func (a *App) dispatchFullTx(tx *sql.Tx, seq int64, senderID, destKind, destID s
|
||||
SELECT gm.endpoint_id, e.enabled
|
||||
FROM group_members gm
|
||||
JOIN endpoints e ON e.id = gm.endpoint_id
|
||||
WHERE gm.group_id = ? AND gm.endpoint_id != ?`, destID, senderID)
|
||||
WHERE gm.group_id = ? AND gm.endpoint_id != ? AND gm.joined_at <= ?`, destID, senderID, sendAt)
|
||||
if qErr != nil {
|
||||
return "", true, qErr
|
||||
}
|
||||
@@ -116,7 +118,7 @@ WHERE gm.group_id = ? AND gm.endpoint_id != ?`, destID, senderID)
|
||||
}
|
||||
|
||||
if completeEarly {
|
||||
if err := finalizeMessageTx(tx, seq, wantReceipt, senderID, "", msgReason, nowMs, a.lim.RecordRetentionDays); err != nil {
|
||||
if err := FinalizeMessageTx(tx, seq, wantReceipt, senderID, "", msgReason, nowMs, a.lim.RecordRetentionDays); err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
return StateCompleted, true, nil
|
||||
@@ -192,7 +194,7 @@ VALUES(?,?,?,?,?,?,?,NULL,NULL,0,?)`,
|
||||
}
|
||||
return StateDispatched, true, nil
|
||||
}
|
||||
if err := finalizeMessageTx(tx, seq, wantReceipt, senderID, "", "", nowMs, a.lim.RecordRetentionDays); err != nil {
|
||||
if err := FinalizeMessageTx(tx, seq, wantReceipt, senderID, "", "", nowMs, a.lim.RecordRetentionDays); err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
return StateCompleted, true, nil
|
||||
@@ -212,9 +214,9 @@ func (a *App) lookupConn(endpointID string) (LiveConn, bool) {
|
||||
return a.conns.Current(endpointID)
|
||||
}
|
||||
|
||||
// finalizeMessageTx 无 pending 时收尾:completed、删正文;记录天数 0 则删消息与投递。
|
||||
// FinalizeMessageTx 无 pending 时收尾:completed、删正文;记录天数 0 则删消息与投递。
|
||||
// msgReason 非空时写入消息 reason(发送前结束);endpointID 为空表示消息级回执。
|
||||
func finalizeMessageTx(tx *sql.Tx, seq int64, wantReceipt bool, senderID, endpointID, msgReason string, nowMs int64, recordDays int) error {
|
||||
func FinalizeMessageTx(tx *sql.Tx, seq int64, wantReceipt bool, senderID, endpointID, msgReason string, nowMs int64, recordDays int) error {
|
||||
var msgID string
|
||||
var receipt int
|
||||
if err := tx.QueryRow(`SELECT id, receipt FROM messages WHERE seq = ?`, seq).Scan(&msgID, &receipt); err != nil {
|
||||
@@ -244,8 +246,8 @@ func finalizeMessageTx(tx *sql.Tx, seq int64, wantReceipt bool, senderID, endpoi
|
||||
return nil
|
||||
}
|
||||
|
||||
// tryFinalizeTx 若无 pending 则收尾。
|
||||
func tryFinalizeTx(tx *sql.Tx, seq int64, nowMs int64, recordDays int) error {
|
||||
// TryFinalizeTx 若无 pending 则收尾。
|
||||
func TryFinalizeTx(tx *sql.Tx, seq int64, nowMs int64, recordDays int) error {
|
||||
var n int
|
||||
if err := tx.QueryRow(`SELECT COUNT(*) FROM deliveries WHERE seq = ? AND state = 'pending'`, seq).Scan(&n); err != nil {
|
||||
return err
|
||||
@@ -261,7 +263,46 @@ func tryFinalizeTx(tx *sql.Tx, seq int64, nowMs int64, recordDays int) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
return finalizeMessageTx(tx, seq, receipt != 0, senderID, "", "", nowMs, recordDays)
|
||||
return FinalizeMessageTx(tx, seq, receipt != 0, senderID, "", "", nowMs, recordDays)
|
||||
}
|
||||
|
||||
func skipVoidReceipt(reason string) bool {
|
||||
return reason == "sender_disabled" || reason == "sender_deleted"
|
||||
}
|
||||
|
||||
// RejectPendingTx 把一条 pending 投递改为 rejected;消息要求回执且发送方存在时写回执。
|
||||
// 停用/删除发送方(sender_disabled / sender_deleted)不写回执(DEVELOPMENT 7.6)。
|
||||
// 返回该投递是否曾推送,供调用方发 revoked。
|
||||
func RejectPendingTx(tx *sql.Tx, seq int64, endpointID, reason string, nowMs int64) (pushed bool, err error) {
|
||||
var pushedAt sql.NullInt64
|
||||
err = tx.QueryRow(`SELECT pushed_at FROM deliveries WHERE seq = ? AND endpoint_id = ?`, seq, endpointID).Scan(&pushedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
res, err := tx.Exec(`
|
||||
UPDATE deliveries SET state = ?, reason = ?, updated_at = ?
|
||||
WHERE seq = ? AND endpoint_id = ? AND state = ?`,
|
||||
DeliveryRejected, reason, nowMs, seq, endpointID, DeliveryPending)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
aff, _ := res.RowsAffected()
|
||||
if aff == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if !skipVoidReceipt(reason) {
|
||||
var senderID string
|
||||
if err := tx.QueryRow(`SELECT sender_id FROM messages WHERE seq = ?`, seq).Scan(&senderID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := insertReceiptTx(tx, senderID, seq, endpointID, DeliveryRejected, reason, nowMs); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return pushedAt.Valid, nil
|
||||
}
|
||||
|
||||
func insertReceiptTx(tx *sql.Tx, senderID string, seq int64, endpointID, state, reason string, nowMs int64) error {
|
||||
@@ -300,7 +341,7 @@ func decodeMetaJSON(s string) map[string]any {
|
||||
return nil
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(s), &m); err != nil {
|
||||
if err := protocol.Unmarshal([]byte(s), &m); err != nil {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
|
||||
@@ -229,11 +229,20 @@ WHERE seq = ? AND endpoint_id = ? AND state = 'pending' AND pushed_conn IS NULL`
|
||||
a.releaseLarge(it.seq, endpointID)
|
||||
}
|
||||
a.scheduleRepush(endpointID, time.Second)
|
||||
} else {
|
||||
a.observeDispatchToPush(it.sendAt, nowMs)
|
||||
}
|
||||
}
|
||||
return a.pushReceipts(ctx, endpointID, connID, nowMs)
|
||||
}
|
||||
|
||||
func (a *App) observeDispatchToPush(sendAtMs, pushedAtMs int64) {
|
||||
if a.met == nil || pushedAtMs < sendAtMs {
|
||||
return
|
||||
}
|
||||
a.met.DispatchToPushSeconds.Observe(float64(pushedAtMs-sendAtMs) / 1000.0)
|
||||
}
|
||||
|
||||
func (a *App) rejectTooLarge(ctx context.Context, seq int64, endpointID, senderID string, nowMs int64) error {
|
||||
return a.db.Queue.Do(ctx, func(tx *sql.Tx) error {
|
||||
res, err := tx.Exec(`
|
||||
@@ -250,7 +259,7 @@ WHERE seq = ? AND endpoint_id = ? AND state = 'pending' AND pushed_conn IS NULL`
|
||||
if err := insertReceiptTx(tx, senderID, seq, endpointID, DeliveryRejected, ReasonTooLarge, nowMs); err != nil {
|
||||
return err
|
||||
}
|
||||
return tryFinalizeTx(tx, seq, nowMs, a.lim.RecordRetentionDays)
|
||||
return TryFinalizeTx(tx, seq, nowMs, a.lim.RecordRetentionDays)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -352,7 +361,7 @@ WHERE seq = ? AND endpoint_id = ? AND state = 'pending'`,
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tryFinalizeTx(tx, seq, nowMs, a.lim.RecordRetentionDays); err != nil {
|
||||
if err := TryFinalizeTx(tx, seq, nowMs, a.lim.RecordRetentionDays); err != nil {
|
||||
return err
|
||||
}
|
||||
if sendRevoked {
|
||||
|
||||
@@ -57,3 +57,11 @@ func (r *rateLimiter) allow(endpointID string, now time.Time) bool {
|
||||
b.tokens--
|
||||
return true
|
||||
}
|
||||
|
||||
// AllowRequest 消耗该端 1 个请求令牌;允许则 true。rps<=0 时不限速。
|
||||
func (a *App) AllowRequest(endpointID string) bool {
|
||||
if a == nil {
|
||||
return true
|
||||
}
|
||||
return a.rates.allow(endpointID, a.now())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package message
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAllowRequestBurstAndAckExemptBucket(t *testing.T) {
|
||||
t.Parallel()
|
||||
lim := defaultTestLimits()
|
||||
lim.RequestsPerSecond = 50
|
||||
lim.RequestBurst = 100
|
||||
app, _ := openTestApp(t, lim)
|
||||
allowed := 0
|
||||
for i := 0; i < 150; i++ {
|
||||
if app.AllowRequest("alice") {
|
||||
allowed++
|
||||
}
|
||||
}
|
||||
if allowed != 100 {
|
||||
t.Fatalf("allowed=%d want 100 (burst)", allowed)
|
||||
}
|
||||
}
|
||||
@@ -78,11 +78,23 @@ WHERE d.state = 'pending' AND d.pushed_conn IS NULL
|
||||
}
|
||||
}
|
||||
|
||||
if err := finalizeStuckDispatchedTx(tx, nowMs, a.lim.RecordRetentionDays); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if a.lim.RecordRetentionDays > 0 {
|
||||
cutoff := nowMs - int64(a.lim.RecordRetentionDays)*24*3600*1000
|
||||
if _, err := tx.Exec(`
|
||||
DELETE FROM messages WHERE seq IN (
|
||||
SELECT seq FROM messages WHERE state = 'completed' AND created_at < ? LIMIT 5000
|
||||
SELECT seq FROM (
|
||||
SELECT m.seq FROM messages m
|
||||
WHERE m.state = 'completed'
|
||||
AND COALESCE(
|
||||
(SELECT MAX(d.updated_at) FROM deliveries d WHERE d.seq = m.seq),
|
||||
m.send_at
|
||||
) < ?
|
||||
LIMIT 5000
|
||||
)
|
||||
)`, cutoff); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -120,3 +132,36 @@ DELETE FROM send_keys WHERE rowid IN (
|
||||
a.flushRevokes(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// finalizeStuckDispatchedTx 收尾「dispatched 且已无 pending 投递」的消息(C-04 兜底,修复已卡住的数据)。
|
||||
func finalizeStuckDispatchedTx(tx *sql.Tx, nowMs int64, recordDays int) error {
|
||||
rows, err := tx.Query(`
|
||||
SELECT seq FROM messages
|
||||
WHERE state = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM deliveries d WHERE d.seq = messages.seq AND d.state = 'pending'
|
||||
)
|
||||
LIMIT 500`, StateDispatched)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var seqs []int64
|
||||
for rows.Next() {
|
||||
var seq int64
|
||||
if err := rows.Scan(&seq); err != nil {
|
||||
_ = rows.Close()
|
||||
return err
|
||||
}
|
||||
seqs = append(seqs, seq)
|
||||
}
|
||||
_ = rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, seq := range seqs {
|
||||
if err := TryFinalizeTx(tx, seq, nowMs, recordDays); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -21,9 +21,6 @@ func (a *App) Submit(ctx context.Context, senderID string, conn port.ConnInfo, r
|
||||
return SubmitResult{}, errCode(protocol.CodeBadRequest, "invalid sender")
|
||||
}
|
||||
now := a.now()
|
||||
if !a.rates.allow(senderID, now) {
|
||||
return SubmitResult{}, errCode(protocol.CodeRateLimited, "request rate exceeded")
|
||||
}
|
||||
|
||||
if err := req.Validate(a.protocolLimits()); err != nil {
|
||||
return SubmitResult{}, err
|
||||
@@ -49,6 +46,9 @@ func (a *App) Submit(ctx context.Context, senderID string, conn port.ConnInfo, r
|
||||
keep := protocol.EffectiveOfflineKeep(req)
|
||||
ttl := protocol.EffectiveOfflineTTL(req)
|
||||
receipt := protocol.EffectiveReceipt(req)
|
||||
if keep && ttl <= 0 {
|
||||
return SubmitResult{}, errCode(protocol.CodeBadRequest, "ttl_seconds must be > 0")
|
||||
}
|
||||
if keep && a.lim.MaxTTLSeconds > 0 && ttl > a.lim.MaxTTLSeconds {
|
||||
return SubmitResult{}, errCode(protocol.CodeBadRequest, "ttl_seconds exceeds max_ttl_seconds")
|
||||
}
|
||||
@@ -69,6 +69,9 @@ func (a *App) Submit(ctx context.Context, senderID string, conn port.ConnInfo, r
|
||||
}
|
||||
return SubmitResult{}, err
|
||||
}
|
||||
if sender.Enabled == 0 {
|
||||
return SubmitResult{}, errCode(protocol.CodeUnauthorized, "sender disabled")
|
||||
}
|
||||
|
||||
sendAt, err := a.computeSendAt(req, sender.DefaultDelayMs, nowMs)
|
||||
if err != nil {
|
||||
@@ -158,6 +161,17 @@ func (a *App) Submit(ctx context.Context, senderID string, conn port.ConnInfo, r
|
||||
return e
|
||||
}
|
||||
|
||||
snd, se := loadEndpointTx(tx, senderID)
|
||||
if se != nil {
|
||||
if errors.Is(se, sql.ErrNoRows) {
|
||||
return errCode(protocol.CodeInvalidTarget, "sender not found")
|
||||
}
|
||||
return se
|
||||
}
|
||||
if snd.Enabled == 0 {
|
||||
return errCode(protocol.CodeUnauthorized, "sender disabled")
|
||||
}
|
||||
|
||||
// 写事务内再确认目标与授权(防并发停用/退群)。
|
||||
switch req.To.Kind {
|
||||
case protocol.TargetEndpoint:
|
||||
@@ -200,10 +214,6 @@ func (a *App) Submit(ctx context.Context, senderID string, conn port.ConnInfo, r
|
||||
}
|
||||
}
|
||||
// 发送方设了对话密码且发给别人的单聊:给对方写回复授权。
|
||||
snd, se := loadEndpointTx(tx, senderID)
|
||||
if se != nil {
|
||||
return se
|
||||
}
|
||||
if senderID != req.To.ID && snd.TalkHash != nil && *snd.TalkHash != "" {
|
||||
if ge := upsertGrantTx(tx, req.To.ID, senderID, snd.TalkVersion, GrantKindReply, nowMs); ge != nil {
|
||||
return ge
|
||||
@@ -298,6 +308,12 @@ func (a *App) computeSendAt(req *protocol.Send, defaultDelayMs, nowMs int64) (in
|
||||
if *req.DelayMs < 0 {
|
||||
return 0, errCode(protocol.CodeBadRequest, "delay_ms negative")
|
||||
}
|
||||
if a.lim.MaxScheduleSeconds > 0 {
|
||||
maxDelay := a.lim.MaxScheduleSeconds * 1000
|
||||
if *req.DelayMs > maxDelay {
|
||||
return 0, errCode(protocol.CodeBadRequest, "send time exceeds max_schedule_seconds")
|
||||
}
|
||||
}
|
||||
sendAt = nowMs + *req.DelayMs
|
||||
default:
|
||||
if defaultDelayMs < 0 {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -363,6 +364,141 @@ SELECT kind FROM talk_grants WHERE sender_id=? AND target_id=?`, "bob", "alice")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ttl_zero_rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
lim := defaultTestLimits()
|
||||
app, db := openTestApp(t, lim)
|
||||
insertEndpoint(t, db, "alice", "", 1, 0)
|
||||
insertEndpoint(t, db, "bob", "", 1, 0)
|
||||
ttl := int64(0)
|
||||
req := baseSend("ttl0", "bob")
|
||||
req.Offline = &protocol.OfflineOpts{Keep: true, TTLSeconds: &ttl}
|
||||
_, err := app.Submit(context.Background(), "alice", port.ConnInfo{}, req)
|
||||
if protoCode(err) != protocol.CodeBadRequest {
|
||||
t.Fatalf("ttl=0 want bad_request got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ttl_negative_rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
lim := defaultTestLimits()
|
||||
app, db := openTestApp(t, lim)
|
||||
insertEndpoint(t, db, "alice", "", 1, 0)
|
||||
insertEndpoint(t, db, "bob", "", 1, 0)
|
||||
ttl := int64(-1)
|
||||
req := baseSend("ttlneg", "bob")
|
||||
req.Offline = &protocol.OfflineOpts{Keep: true, TTLSeconds: &ttl}
|
||||
_, err := app.Submit(context.Background(), "alice", port.ConnInfo{}, req)
|
||||
if protoCode(err) != protocol.CodeBadRequest {
|
||||
t.Fatalf("ttl=-1 want bad_request got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delay_maxint64_rejected", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
lim := defaultTestLimits()
|
||||
app, db := openTestApp(t, lim)
|
||||
insertEndpoint(t, db, "alice", "", 1, 0)
|
||||
insertEndpoint(t, db, "bob", "", 1, 0)
|
||||
delay := int64(math.MaxInt64)
|
||||
req := baseSend("delaymax", "bob")
|
||||
req.DelayMs = &delay
|
||||
_, err := app.Submit(context.Background(), "alice", port.ConnInfo{}, req)
|
||||
if protoCode(err) != protocol.CodeBadRequest {
|
||||
t.Fatalf("delay=MaxInt64 want bad_request got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sender_disabled_unauthorized", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
lim := defaultTestLimits()
|
||||
app, db := openTestApp(t, lim)
|
||||
insertEndpoint(t, db, "alice", "", 0, 0)
|
||||
insertEndpoint(t, db, "bob", "", 1, 0)
|
||||
_, err := app.Submit(context.Background(), "alice", port.ConnInfo{}, baseSend("from-off", "bob"))
|
||||
if protoCode(err) != protocol.CodeUnauthorized {
|
||||
t.Fatalf("want unauthorized got %v", err)
|
||||
}
|
||||
var n int
|
||||
if err := db.Read.QueryRow(`SELECT COUNT(*) FROM deliveries`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("deliveries=%d", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("group_late_joiner_skipped", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
lim := defaultTestLimits()
|
||||
app, db := openTestApp(t, lim)
|
||||
insertEndpoint(t, db, "alice", "", 1, 0)
|
||||
insertEndpoint(t, db, "bob", "", 1, 0)
|
||||
insertEndpoint(t, db, "dave", "", 1, 0)
|
||||
ctx := context.Background()
|
||||
nowMs := int64(1_700_000_000_000)
|
||||
err := db.Queue.Do(ctx, func(tx *sql.Tx) error {
|
||||
if _, e := tx.Exec(`INSERT INTO groups(id, name, owner_id, created_at) VALUES(?,?,?,?)`,
|
||||
"g-late", "g", "alice", nowMs); e != nil {
|
||||
return e
|
||||
}
|
||||
for _, m := range []string{"alice", "bob"} {
|
||||
if _, e := tx.Exec(`INSERT INTO group_members(group_id, endpoint_id, joined_at) VALUES(?,?,?)`,
|
||||
"g-late", m, nowMs); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delay := int64(10_000)
|
||||
req := &protocol.Send{
|
||||
V: protocol.Version, Type: protocol.TypeSend, RID: "r1", ID: "late-1",
|
||||
To: protocol.Target{Kind: protocol.TargetGroup, ID: "g-late"},
|
||||
Body: protocol.Body{Enc: protocol.EncUTF8, Data: "hi"},
|
||||
DelayMs: &delay,
|
||||
Offline: keepTrue(),
|
||||
}
|
||||
res, err := app.Submit(ctx, "alice", port.ConnInfo{}, req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.State != StateScheduled {
|
||||
t.Fatalf("state=%s", res.State)
|
||||
}
|
||||
err = db.Queue.Do(ctx, func(tx *sql.Tx) error {
|
||||
_, e := tx.Exec(`INSERT INTO group_members(group_id, endpoint_id, joined_at) VALUES(?,?,?)`,
|
||||
"g-late", "dave", res.SendAtMs+500)
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := app.DispatchDue(ctx, res.SendAtMs, 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var daveN int
|
||||
if err := db.Read.QueryRow(`
|
||||
SELECT COUNT(*) FROM deliveries d JOIN messages m ON m.seq=d.seq
|
||||
WHERE m.id='late-1' AND d.endpoint_id='dave'`).Scan(&daveN); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if daveN != 0 {
|
||||
t.Fatalf("late joiner deliveries=%d", daveN)
|
||||
}
|
||||
var bobN int
|
||||
if err := db.Read.QueryRow(`
|
||||
SELECT COUNT(*) FROM deliveries d JOIN messages m ON m.seq=d.seq
|
||||
WHERE m.id='late-1' AND d.endpoint_id='bob'`).Scan(&bobN); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bobN != 1 {
|
||||
t.Fatalf("bob deliveries=%d", bobN)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rate_limited", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
lim := defaultTestLimits()
|
||||
@@ -371,16 +507,15 @@ SELECT kind FROM talk_grants WHERE sender_id=? AND target_id=?`, "bob", "alice")
|
||||
app, db := openTestApp(t, lim)
|
||||
insertEndpoint(t, db, "alice", "", 1, 0)
|
||||
insertEndpoint(t, db, "bob", "", 1, 0)
|
||||
if !app.AllowRequest("alice") || !app.AllowRequest("alice") {
|
||||
t.Fatal("burst should allow first two")
|
||||
}
|
||||
if app.AllowRequest("alice") {
|
||||
t.Fatal("third request should be rate limited")
|
||||
}
|
||||
ctx := context.Background()
|
||||
if _, err := app.Submit(ctx, "alice", port.ConnInfo{}, baseSend("r1", "bob")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := app.Submit(ctx, "alice", port.ConnInfo{}, baseSend("r2", "bob")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := app.Submit(ctx, "alice", port.ConnInfo{}, baseSend("r3", "bob"))
|
||||
if protoCode(err) != protocol.CodeRateLimited {
|
||||
t.Fatalf("want rate_limited got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package message
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"git.asio.asia/nixevol/NixMsg/internal/app/port"
|
||||
)
|
||||
|
||||
func TestRejectPendingAndFinalizeRetentionZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
lim := defaultTestLimits()
|
||||
lim.RecordRetentionDays = 0
|
||||
app, db := openTestApp(t, lim)
|
||||
insertEndpoint(t, db, "alice", "", 1, 0)
|
||||
insertEndpoint(t, db, "bob", "", 1, 0)
|
||||
ctx := context.Background()
|
||||
req := baseSend("z1", "bob")
|
||||
req.Offline = keepTrue()
|
||||
if _, err := app.Submit(ctx, "alice", port.ConnInfo{}, req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := db.Queue.Do(ctx, func(tx *sql.Tx) error {
|
||||
var seq int64
|
||||
if e := tx.QueryRow(`SELECT seq FROM messages WHERE id='z1'`).Scan(&seq); e != nil {
|
||||
return e
|
||||
}
|
||||
if _, e := RejectPendingTx(tx, seq, "bob", ReasonEndpointDisabled, 1_700_000_000_000); e != nil {
|
||||
return e
|
||||
}
|
||||
return TryFinalizeTx(tx, seq, 1_700_000_000_000, 0)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var n int
|
||||
if err := db.Read.QueryRow(`SELECT COUNT(*) FROM messages WHERE id='z1'`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("message row should be deleted when retention=0, n=%d", n)
|
||||
}
|
||||
var receipts int
|
||||
if err := db.Read.QueryRow(`SELECT COUNT(*) FROM receipts WHERE msg_id='z1' AND state='rejected'`).Scan(&receipts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if receipts != 1 {
|
||||
t.Fatalf("receipts=%d", receipts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupStuckDispatched(t *testing.T) {
|
||||
t.Parallel()
|
||||
lim := defaultTestLimits()
|
||||
app, db := openTestApp(t, lim)
|
||||
insertEndpoint(t, db, "alice", "", 1, 0)
|
||||
insertEndpoint(t, db, "bob", "", 1, 0)
|
||||
ctx := context.Background()
|
||||
nowMs := int64(1_700_000_000_000)
|
||||
err := db.Queue.Do(ctx, func(tx *sql.Tx) error {
|
||||
res, e := tx.Exec(`
|
||||
INSERT INTO messages(id, sender_id, dest_kind, dest_id, meta, content_type, body_enc,
|
||||
send_at, keep, ttl_seconds, receipt, state, reason, created_at)
|
||||
VALUES('stuck','alice','endpoint','bob','{}','text/plain; charset=utf-8','utf8',
|
||||
?,0,0,1,'dispatched','',?)`, nowMs, nowMs)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
seq, e := res.LastInsertId()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if _, e = tx.Exec(`INSERT INTO message_bodies(seq, body) VALUES(?, ?)`, seq, []byte("x")); e != nil {
|
||||
return e
|
||||
}
|
||||
_, e = tx.Exec(`
|
||||
INSERT INTO deliveries(seq, endpoint_id, send_at, keep, state, reason, updated_at)
|
||||
VALUES(?,?,?,0,'rejected','left_group',?)`, seq, "bob", nowMs, nowMs)
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.CleanupOnce(ctx, nowMs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var state string
|
||||
if err := db.Read.QueryRow(`SELECT state FROM messages WHERE id='stuck'`).Scan(&state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state != StateCompleted {
|
||||
t.Fatalf("state=%s want completed", state)
|
||||
}
|
||||
var bodies int
|
||||
if err := db.Read.QueryRow(`SELECT COUNT(*) FROM message_bodies b JOIN messages m ON m.seq=b.seq WHERE m.id='stuck'`).Scan(&bodies); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bodies != 0 {
|
||||
t.Fatalf("body still present: %d", bodies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupKeepsRecentlyCompletedOldCreated(t *testing.T) {
|
||||
t.Parallel()
|
||||
lim := defaultTestLimits()
|
||||
lim.RecordRetentionDays = 7
|
||||
app, db := openTestApp(t, lim)
|
||||
insertEndpoint(t, db, "alice", "", 1, 0)
|
||||
insertEndpoint(t, db, "bob", "", 1, 0)
|
||||
ctx := context.Background()
|
||||
nowMs := int64(1_700_000_000_000)
|
||||
created := nowMs - int64(30)*24*3600*1000
|
||||
err := db.Queue.Do(ctx, func(tx *sql.Tx) error {
|
||||
res, e := tx.Exec(`
|
||||
INSERT INTO messages(id, sender_id, dest_kind, dest_id, meta, content_type, body_enc,
|
||||
send_at, keep, ttl_seconds, receipt, state, reason, created_at)
|
||||
VALUES('old-created','alice','endpoint','bob','{}','text/plain; charset=utf-8','utf8',
|
||||
?,0,0,1,'completed','',?)`, nowMs, created)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
seq, e := res.LastInsertId()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
_, e = tx.Exec(`
|
||||
INSERT INTO deliveries(seq, endpoint_id, send_at, keep, state, reason, updated_at)
|
||||
VALUES(?,?,?,0,'accepted','',?)`, seq, "bob", nowMs, nowMs)
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.CleanupOnce(ctx, nowMs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var n int
|
||||
if err := db.Read.QueryRow(`SELECT COUNT(*) FROM messages WHERE id='old-created'`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("recently completed message should remain, n=%d", n)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.asio.asia/nixevol/NixMsg/internal/app/port"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/metrics"
|
||||
mqtt "github.com/mochi-mqtt/server/v2"
|
||||
"github.com/mochi-mqtt/server/v2/packets"
|
||||
)
|
||||
@@ -68,15 +69,18 @@ type Options struct {
|
||||
Logger *slog.Logger
|
||||
// OnPublishDropped 可选;nil 时仅打 debug 日志。
|
||||
OnPublishDropped PublishDroppedFunc
|
||||
// Metrics 可选;会话建立/断开时更新 nixmsg_connections。
|
||||
Metrics *metrics.Registry
|
||||
}
|
||||
|
||||
// Broker 内置 mochi,不自带监听端口。
|
||||
type Broker struct {
|
||||
server *mqtt.Server
|
||||
auth Authenticator
|
||||
uplink port.UplinkHandler
|
||||
log *slog.Logger
|
||||
onDrop PublishDroppedFunc
|
||||
server *mqtt.Server
|
||||
auth Authenticator
|
||||
uplink port.UplinkHandler
|
||||
log *slog.Logger
|
||||
onDrop PublishDroppedFunc
|
||||
metrics *metrics.Registry
|
||||
|
||||
hook *nixHook
|
||||
|
||||
@@ -105,6 +109,7 @@ type connState struct {
|
||||
handshook bool
|
||||
subscribedDown bool
|
||||
largeHeld int
|
||||
metricsCounted bool
|
||||
mu sync.Mutex
|
||||
|
||||
handshakeTimer *time.Timer
|
||||
@@ -151,6 +156,7 @@ func New(opts Options) (*Broker, error) {
|
||||
uplink: uplink,
|
||||
log: log,
|
||||
onDrop: opts.OnPublishDropped,
|
||||
metrics: opts.Metrics,
|
||||
current: make(map[string]*connState),
|
||||
byClient: make(map[*mqtt.Client]*connState),
|
||||
queues: make(map[string]*uplinkQueue),
|
||||
|
||||
@@ -190,6 +190,7 @@ func (h *nixHook) OnSessionEstablished(cl *mqtt.Client, _ packets.Packet) {
|
||||
MaxPacketSize: st.maxPacketSize,
|
||||
}
|
||||
_ = h.b.uplink.OnSessionEstablished(context.Background(), info)
|
||||
h.noteConnectionOpen(st)
|
||||
}
|
||||
|
||||
func (h *nixHook) OnDisconnect(cl *mqtt.Client, err error, _ bool) {
|
||||
@@ -229,9 +230,27 @@ func (h *nixHook) OnDisconnect(cl *mqtt.Client, err error, _ bool) {
|
||||
}
|
||||
if sess, ok := h.b.uplink.(*Session); ok {
|
||||
sess.HandleDisconnect(context.Background(), info, reason, isCurrent)
|
||||
h.noteConnectionClose(st)
|
||||
return
|
||||
}
|
||||
h.b.uplink.OnDisconnect(context.Background(), info, reason)
|
||||
h.noteConnectionClose(st)
|
||||
}
|
||||
|
||||
func (h *nixHook) noteConnectionOpen(st *connState) {
|
||||
if h.b.metrics == nil || st == nil || st.metricsCounted {
|
||||
return
|
||||
}
|
||||
h.b.metrics.Connections.WithLabelValues(string(st.transport)).Inc()
|
||||
st.metricsCounted = true
|
||||
}
|
||||
|
||||
func (h *nixHook) noteConnectionClose(st *connState) {
|
||||
if h.b.metrics == nil || st == nil || !st.metricsCounted {
|
||||
return
|
||||
}
|
||||
h.b.metrics.Connections.WithLabelValues(string(st.transport)).Dec()
|
||||
st.metricsCounted = false
|
||||
}
|
||||
|
||||
func (h *nixHook) OnQosComplete(cl *mqtt.Client, pk packets.Packet) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.asio.asia/nixevol/NixMsg/internal/metrics"
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
)
|
||||
|
||||
func TestConnectionMetricsIncDec(t *testing.T) {
|
||||
reg := metrics.New()
|
||||
b, err := New(Options{Authenticator: AllowAuthenticator{}, Metrics: reg})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = b.Close() }()
|
||||
|
||||
if got := gaugeValue(t, reg, "nixmsg_connections", "tcp"); got != 0 {
|
||||
t.Fatalf("before connect tcp=%v", got)
|
||||
}
|
||||
|
||||
r, w := net.Pipe()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
_ = b.AttachTCP(r)
|
||||
}()
|
||||
connectAndSubscribe(t, w, "ep-metrics", 0)
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for {
|
||||
if _, ok := b.ConnInfoOf("ep-metrics"); ok {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("session not established")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if got := gaugeValue(t, reg, "nixmsg_connections", "tcp"); got != 1 {
|
||||
t.Fatalf("after connect tcp=%v want 1", got)
|
||||
}
|
||||
|
||||
_ = w.Close()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("attach did not finish")
|
||||
}
|
||||
deadline = time.Now().Add(3 * time.Second)
|
||||
for {
|
||||
if got := gaugeValue(t, reg, "nixmsg_connections", "tcp"); got == 0 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("after disconnect tcp=%v want 0", gaugeValue(t, reg, "nixmsg_connections", "tcp"))
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSConnectionMetrics(t *testing.T) {
|
||||
reg := metrics.New()
|
||||
b, err := New(Options{Authenticator: AllowAuthenticator{}, Metrics: reg})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = b.Close() }()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/mqtt", b.WSHandler(nil))
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
// 仅验证 handler 暴露指标文本仍含初始标签;建连用 TCP 测即可。
|
||||
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
reg.Handler().ServeHTTP(rec, req)
|
||||
body, _ := io.ReadAll(rec.Body)
|
||||
if !strings.Contains(string(body), `nixmsg_connections{transport="ws"} 0`) {
|
||||
t.Fatalf("missing ws series: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func gaugeValue(t *testing.T, reg *metrics.Registry, name, transport string) float64 {
|
||||
t.Helper()
|
||||
mfs, err := reg.Gatherer().Gather()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, mf := range mfs {
|
||||
if mf.GetName() != name {
|
||||
continue
|
||||
}
|
||||
for _, m := range mf.GetMetric() {
|
||||
if matchLabel(m, "transport", transport) {
|
||||
return m.GetGauge().GetValue()
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("metric %s transport=%s not found", name, transport)
|
||||
return 0
|
||||
}
|
||||
|
||||
func matchLabel(m *dto.Metric, key, val string) bool {
|
||||
for _, lp := range m.GetLabel() {
|
||||
if lp.GetName() == key && lp.GetValue() == val {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// SampleStoreGauges 按库内真实计数刷新端总数、待投递、定时消息(无对应行则为 0)。
|
||||
func SampleStoreGauges(ctx context.Context, r *Registry, db *sql.DB) error {
|
||||
if r == nil || db == nil {
|
||||
return nil
|
||||
}
|
||||
var endpoints, pending, scheduled int
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM endpoints`).Scan(&endpoints); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM deliveries WHERE state = 'pending'`).Scan(&pending); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM messages WHERE state = 'scheduled'`).Scan(&scheduled); err != nil {
|
||||
return err
|
||||
}
|
||||
r.EndpointsTotal.Set(float64(endpoints))
|
||||
r.DeliveriesPending.Set(float64(pending))
|
||||
r.MessagesScheduled.Set(float64(scheduled))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SampleQueues 刷新写队列与密码哈希排队长度(传入当前真实长度,不做估算)。
|
||||
func SampleQueues(r *Registry, writeQueueLen, passwordHashQueueLen int) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.WriteQueueLength.Set(float64(writeQueueLen))
|
||||
r.PasswordHashQueue.Set(float64(passwordHashQueueLen))
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.asio.asia/nixevol/NixMsg/internal/store"
|
||||
)
|
||||
|
||||
func TestSampleStoreGaugesPendingNonZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
db, err := store.Open(filepath.Join(dir, "data"), "FULL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
ctx := context.Background()
|
||||
_, err = db.Write.ExecContext(ctx, `
|
||||
INSERT INTO endpoints(id, name, login_hash, enabled, source, created_at)
|
||||
VALUES ('alice', 'A', 'x', 1, 'admin', 1)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = db.Write.ExecContext(ctx, `
|
||||
INSERT INTO messages(sender_id, id, dest_kind, dest_id, send_at, keep, ttl_seconds, receipt, state, reason, created_at, content_type, body_enc, meta)
|
||||
VALUES ('alice', 'm1', 'endpoint', 'bob', 100, 1, 0, 0, 'dispatched', '', 1, 'text/plain', 'utf8', '{}')`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var seq int64
|
||||
err = db.Write.QueryRowContext(ctx, `SELECT seq FROM messages WHERE id='m1'`).Scan(&seq)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = db.Write.ExecContext(ctx, `
|
||||
INSERT INTO deliveries(seq, endpoint_id, send_at, keep, state, reason, attempts, updated_at)
|
||||
VALUES (?, 'bob', 100, 1, 'pending', '', 0, 1)`, seq)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = db.Write.ExecContext(ctx, `
|
||||
INSERT INTO messages(sender_id, id, dest_kind, dest_id, send_at, keep, ttl_seconds, receipt, state, reason, created_at, content_type, body_enc, meta)
|
||||
VALUES ('alice', 'm2', 'endpoint', 'bob', 999999, 0, 0, 0, 'scheduled', '', 1, 'text/plain', 'utf8', '{}')`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reg := New()
|
||||
err = SampleStoreGauges(ctx, reg, db.Read)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
SampleQueues(reg, 3, 1)
|
||||
|
||||
mfs, err := reg.Gatherer().Gather()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]float64{}
|
||||
for _, mf := range mfs {
|
||||
switch mf.GetName() {
|
||||
case "nixmsg_endpoints", "nixmsg_deliveries_pending", "nixmsg_messages_scheduled",
|
||||
"nixmsg_write_queue_length", "nixmsg_password_hash_queue_length":
|
||||
if len(mf.GetMetric()) > 0 {
|
||||
got[mf.GetName()] = mf.GetMetric()[0].GetGauge().GetValue()
|
||||
}
|
||||
}
|
||||
}
|
||||
if got["nixmsg_endpoints"] != 1 {
|
||||
t.Fatalf("endpoints=%v", got["nixmsg_endpoints"])
|
||||
}
|
||||
if got["nixmsg_deliveries_pending"] != 1 {
|
||||
t.Fatalf("pending=%v", got["nixmsg_deliveries_pending"])
|
||||
}
|
||||
if got["nixmsg_messages_scheduled"] != 1 {
|
||||
t.Fatalf("scheduled=%v", got["nixmsg_messages_scheduled"])
|
||||
}
|
||||
if got["nixmsg_write_queue_length"] != 3 || got["nixmsg_password_hash_queue_length"] != 1 {
|
||||
t.Fatalf("queues=%v", got)
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,11 @@ type OfflineOpts struct {
|
||||
|
||||
// Limits 是与服务器配置相关的校验上限。
|
||||
type Limits struct {
|
||||
MaxBodyBytes int
|
||||
MaxMetaBytes int
|
||||
MaxFrameBytes int
|
||||
MaxBodyBytes int
|
||||
MaxMetaBytes int
|
||||
MaxFrameBytes int
|
||||
MaxTTLSeconds int64
|
||||
MaxScheduleSeconds int64
|
||||
}
|
||||
|
||||
// DefaultLimits 返回 DEVELOPMENT 示例中的默认上限。
|
||||
|
||||
@@ -167,6 +167,23 @@ func (s *Send) Validate(lim Limits) error {
|
||||
if s.SendAtMs != nil && s.DelayMs != nil {
|
||||
return badRequest("send_at_ms and delay_ms are mutually exclusive")
|
||||
}
|
||||
if EffectiveOfflineKeep(s) {
|
||||
if s.Offline != nil && s.Offline.TTLSeconds != nil && *s.Offline.TTLSeconds <= 0 {
|
||||
return badRequest("ttl_seconds must be > 0")
|
||||
}
|
||||
ttl := EffectiveOfflineTTL(s)
|
||||
if lim.MaxTTLSeconds > 0 && ttl > lim.MaxTTLSeconds {
|
||||
return badRequest("ttl_seconds exceeds max_ttl_seconds")
|
||||
}
|
||||
}
|
||||
if s.DelayMs != nil {
|
||||
if *s.DelayMs < 0 {
|
||||
return badRequest("delay_ms negative")
|
||||
}
|
||||
if lim.MaxScheduleSeconds > 0 && *s.DelayMs > lim.MaxScheduleSeconds*1000 {
|
||||
return badRequest("delay_ms exceeds max_schedule_seconds")
|
||||
}
|
||||
}
|
||||
n, err := FrameBytes(s)
|
||||
if err != nil {
|
||||
return badRequest("cannot encode frame")
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- U-02: group_members 增加指向 groups 的外键,避免解散后残留孤儿行。
|
||||
-- 本分支基于 C-04 时最大迁移号为 0002,按 TASKS 4.2 取 0003。
|
||||
DELETE FROM group_members WHERE group_id NOT IN (SELECT id FROM groups);
|
||||
|
||||
CREATE TABLE group_members_new (
|
||||
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
endpoint_id TEXT NOT NULL,
|
||||
joined_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (group_id, endpoint_id)
|
||||
);
|
||||
|
||||
INSERT INTO group_members_new (group_id, endpoint_id, joined_at)
|
||||
SELECT group_id, endpoint_id, joined_at FROM group_members;
|
||||
|
||||
DROP TABLE group_members;
|
||||
|
||||
ALTER TABLE group_members_new RENAME TO group_members;
|
||||
|
||||
CREATE INDEX idx_group_members_endpoint ON group_members(endpoint_id);
|
||||
@@ -43,6 +43,9 @@ type Queue struct {
|
||||
ready bool
|
||||
lastWriteErr error
|
||||
pending int
|
||||
|
||||
// OnBatchCommit 可选;每次合并提交成功后回调耗时(秒级指标用)。
|
||||
OnBatchCommit func(d time.Duration)
|
||||
}
|
||||
|
||||
// NewQueue 创建合并写入队列并启动写 goroutine。
|
||||
@@ -129,6 +132,7 @@ func (q *Queue) loop() {
|
||||
}
|
||||
|
||||
func (q *Queue) runBatch(batch []writeJob) {
|
||||
started := time.Now()
|
||||
defer func() {
|
||||
q.mu.Lock()
|
||||
q.pending -= len(batch)
|
||||
@@ -229,6 +233,9 @@ func (q *Queue) runBatch(batch []writeJob) {
|
||||
}
|
||||
return
|
||||
}
|
||||
if q.OnBatchCommit != nil {
|
||||
q.OnBatchCommit(time.Since(started))
|
||||
}
|
||||
for _, o := range outcomes {
|
||||
if o.success {
|
||||
o.job.res <- nil
|
||||
|
||||
+11
-11
@@ -1,30 +1,30 @@
|
||||
# NixMsg 验收对照表(PRD 第 10 节)
|
||||
|
||||
生成时间:2026-09-30T00:26:12Z
|
||||
生成时间:2026-09-30T02:20:32Z
|
||||
|
||||
汇总:通过 14,失败 0,未测 9
|
||||
汇总:通过 23,失败 0,未测 0
|
||||
|
||||
| 编号 | 一句话 | 结果 | 备注 |
|
||||
|---|---|---|---|
|
||||
| F01 | 批量开通整批校验、停用、删除群主转让、删除后同编号重开不串数据 | 通过 | 已测:开通一端、错误密码 MQTT 拒绝、正确密码可连;批量校验/停用/删除转让/同号重开未在本用例穷尽 |
|
||||
| F02 | 新设备登录后旧设备自动退出、换 IP 用令牌重连、两种密码锁定、重置密码后被踢、服务器故障不误报密码错误 | 通过 | 已测:密码登录后 hello 成功(会话令牌路径可用);顶号/锁定/重置踢线未在本用例穷尽 |
|
||||
| F03 | 断开后状态及时变离线,全表可列出 | 未测 | 未测:directory.list / 断开后离线状态未在本波单独断言 |
|
||||
| F04 | 只通知订阅了的端 | 未测 | 未测:presence.watch 订阅通知未覆盖 |
|
||||
| F03 | 断开后状态及时变离线,全表可列出 | 通过 | 已测:directory.list 可列出端;关掉连接后约 1s 内 presence.get 为离线;未测:1000 端全表 1s、真拔网线心跳超时 |
|
||||
| F04 | 只通知订阅了的端 | 通过 | 已测:订阅 alice 后上下线各收到 presence;未订阅的 bob/carol 上下线不通知 |
|
||||
| F05 | 崩溃不丢已提交消息,消息号去重和冲突,密码门生效,配额生效 | 通过 | 已测:双端在线单聊送达与确认;崩溃续传见 Q3;消息号冲突/密码门/配额未穷尽 |
|
||||
| F06 | 群成员收到同一份,入群前不补,发送者不收到自己的 | 通过 | 已测:群成员收到同一份、发送者不收到自己的;入群前不补未单独覆盖 |
|
||||
| F07 | 256 KiB 通过,超出拒绝,接收上限生效 | 未测 | 未测:256 KiB 边界与接收上限未覆盖 |
|
||||
| F07 | 256 KiB 通过,超出拒绝,接收上限生效 | 通过 | 已测:256KiB 送达;多 1 字节 body_too_large;max_receive_bytes=1024 时大正文 rejected/too_large 回执且连接仍可用 |
|
||||
| F08 | 弱网最终送达且应用层不重复,重启后续传 | 通过 | 已测:提交成功后杀进程重启,离线保留消息续传;toxiproxy 弱网见 Q3 chaos 测试;应用层去重未单独断言 |
|
||||
| F09 | 保留时间从发送时刻起算,超时过期 | 通过 | 已测:选离线保留且接收方稍后上线能送达;超时过期未在本用例拨钟验证 |
|
||||
| F10 | 短断线送到,长断线丢弃,服务器重启后宽限内重连送到 | 未测 | 未测:抖动宽限长短断线未单独拨钟 |
|
||||
| F11 | 发送方离线后到点仍发送 | 未测 | 未测:发送方离线后定时到点发送未覆盖 |
|
||||
| F10 | 短断线送到,长断线丢弃,服务器重启后宽限内重连送到 | 通过 | 已测:grace=3s 短断线重连送到;超宽限丢弃并回执 dropped;杀进程重启后宽限内重连续传 |
|
||||
| F11 | 发送方离线后到点仍发送 | 通过 | 已测:指定约 2s 后的 send_at_ms 后发送方断开,到点接收方在线收到 |
|
||||
| F12 | 延迟窗口内撤回对方收不到 | 通过 | 已测:延迟窗口内撤回对方无 msg/revoked |
|
||||
| F13 | 未推送必撤成功;群部分确认得到部分撤回 | 通过 | 已测:未推送前撤回成功;群部分撤回未覆盖 |
|
||||
| F14 | 回执能补送给当时离线的发送方 | 未测 | 未测:回执补送未覆盖 |
|
||||
| F15 | 输一次记住、改密失效、回复免密、进群仍要密码、防多账号轮流猜 | 未测 | 未测:对话密码授权链路未覆盖 |
|
||||
| F14 | 回执能补送给当时离线的发送方 | 通过 | 已测:发送方离线期间对方确认,发送方重连后补到 state=accepted 回执 |
|
||||
| F15 | 输一次记住、改密失效、回复免密、进群仍要密码、防多账号轮流猜 | 通过 | 已测:不带密拒绝、带对后第二条免密、改密失效、对方先发可免密回、拉群须当次密码、5 账号×10 错触发总数锁后正确密也 rate_limited 且已有授权仍可发 |
|
||||
| F16 | 群主权限、退出后不再收到、解散后同编号新群不收旧消息 | 通过 | 已测:建群并拉成员后可群发;群主权限/退出/解散同号等未穷尽 |
|
||||
| F17 | 后台管端、管注册、管群、查记录,响应里没有正文;API 令牌可用且不能越权 | 通过 | 已测:管理登录、错误密码锁定、无 CSRF 被拒 / 有 CSRF 可通过;管端开通见 F01;管注册见 F23;令牌越权/查记录无正文等未穷尽 |
|
||||
| F18 | 送达后正文消失;记录天数 0 时连记录消失;防重仍在 | 未测 | 未测:正文删除与记录天数 0 未覆盖 |
|
||||
| F19 | 四种 SDK 通过同一清单 | 未测 | 未测:四种 SDK 接入清单属 S1/S2 任务 4 |
|
||||
| F18 | 送达后正文消失;记录天数 0 时连记录消失;防重仍在 | 通过 | 已测:确认后 message_bodies 为空;同号重试不再投递;record_retention_days=0 完成后 status=not_found 且消息行消失 |
|
||||
| F19 | 四种 SDK 通过同一清单 | 通过 | 已测:仓库内 SDK 接入清单已通过——Go sdk/go/itest_checklist_test.go;JS sdk/js/test/checklist.test.ts;Python sdk/python/tests/test_checklist.py;Java sdk/java ChecklistTest;本波不重跑四套全量(见 RELEASE 第 4 节回归记录) |
|
||||
| F20 | 裸 MQTT 能登录、收、确认、发 | 通过 | 已测:裸 MQTT WebSocket 登录、hello、发、收、确认 |
|
||||
| F21 | 默认一个端口提供后台、WebSocket、TCP、注册;后台可分到单独端口 | 通过 | 已测:同一 listen 端口提供 /healthz、管理 API、注册、WebSocket /mqtt;后台分离端口未测 |
|
||||
| F22 | 初始化后单文件或 Docker 启动、备份恢复、升级迁移、证书自动重载、指标可抓取 | 通过 | 已测:空目录 admin init + serve,/healthz 与 /readyz 成功,密码不在 serve 日志;未测:备份恢复、升级迁移、证书重载、Docker 全量、/metrics 抓取 |
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestQ2AcceptAndReport(t *testing.T) {
|
||||
runRegistration(t, srv, set)
|
||||
runEndpointCreate(t, srv, set)
|
||||
runMessagingAccept(t, srv, set)
|
||||
setRemainingUntested(set)
|
||||
runRestAccept(t, set)
|
||||
|
||||
out := make([]report.Item, 0, len(report.Features))
|
||||
for _, f := range report.Features {
|
||||
@@ -551,23 +551,6 @@ func runCrashResumeForF08(t *testing.T, set func(string, report.Status, string))
|
||||
set("F08", report.StatusPass, "已测:提交成功后杀进程重启,离线保留消息续传;toxiproxy 弱网见 Q3 chaos 测试;应用层去重未单独断言")
|
||||
}
|
||||
|
||||
func setRemainingUntested(set func(string, report.Status, string)) {
|
||||
defaults := map[string]string{
|
||||
"F03": "未测:directory.list / 断开后离线状态未在本波单独断言",
|
||||
"F04": "未测:presence.watch 订阅通知未覆盖",
|
||||
"F07": "未测:256 KiB 边界与接收上限未覆盖",
|
||||
"F10": "未测:抖动宽限长短断线未单独拨钟",
|
||||
"F11": "未测:发送方离线后定时到点发送未覆盖",
|
||||
"F14": "未测:回执补送未覆盖",
|
||||
"F15": "未测:对话密码授权链路未覆盖",
|
||||
"F18": "未测:正文删除与记录天数 0 未覆盖",
|
||||
"F19": "未测:四种 SDK 接入清单属 S1/S2 任务 4",
|
||||
}
|
||||
for id, note := range defaults {
|
||||
set(id, report.StatusUntested, note)
|
||||
}
|
||||
}
|
||||
|
||||
func findModuleRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := os.Getwd()
|
||||
|
||||
+20
-4
@@ -33,15 +33,27 @@ type AppResp struct {
|
||||
Raw map[string]any
|
||||
}
|
||||
|
||||
// MQTTLoginOpts 控制握手参数。
|
||||
type MQTTLoginOpts struct {
|
||||
// MaxReceiveBytes 非 nil 时写入 hello.max_receive_bytes。
|
||||
MaxReceiveBytes *int
|
||||
}
|
||||
|
||||
// MQTTLogin 用密码连上 /mqtt、订阅 down、完成 hello。
|
||||
func MQTTLogin(t *testing.T, httpBase, endpointID, password string) *MQTTSession {
|
||||
t.Helper()
|
||||
mc, err := harness.DialMQTTWebSocket(httpBase, 10*time.Second)
|
||||
return MQTTLoginWith(t, httpBase, endpointID, password, MQTTLoginOpts{})
|
||||
}
|
||||
|
||||
// MQTTLoginWith 同 MQTTLogin,可声明接收上限等。
|
||||
func MQTTLoginWith(t *testing.T, httpBase, endpointID, password string, opts MQTTLoginOpts) *MQTTSession {
|
||||
t.Helper()
|
||||
mc, err := harness.DialMQTTWebSocket(httpBase, 15*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("dial mqtt: %v", err)
|
||||
}
|
||||
s := &MQTTSession{t: t, mc: mc, EndpointID: endpointID, pktID: 10, done: make(chan struct{})}
|
||||
s.connectSubscribeHello(password)
|
||||
s.connectSubscribeHello(password, opts)
|
||||
go s.readLoop()
|
||||
return s
|
||||
}
|
||||
@@ -70,7 +82,7 @@ func (s *MQTTSession) nextPkt() uint16 {
|
||||
return s.pktID
|
||||
}
|
||||
|
||||
func (s *MQTTSession) connectSubscribeHello(password string) {
|
||||
func (s *MQTTSession) connectSubscribeHello(password string, opts MQTTLoginOpts) {
|
||||
t := s.t
|
||||
pk := packets.Packet{
|
||||
FixedHeader: packets.FixedHeader{Type: packets.Connect},
|
||||
@@ -120,7 +132,11 @@ func (s *MQTTSession) connectSubscribeHello(password string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hello, _ := protocol.Marshal(protocol.Hello{V: protocol.Version, Type: protocol.TypeHello, RID: "h0"})
|
||||
helloFrame := protocol.Hello{V: protocol.Version, Type: protocol.TypeHello, RID: "h0"}
|
||||
if opts.MaxReceiveBytes != nil {
|
||||
helloFrame.MaxReceiveBytes = opts.MaxReceiveBytes
|
||||
}
|
||||
hello, _ := protocol.Marshal(helloFrame)
|
||||
s.publishRaw(hello)
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.asio.asia/nixevol/NixMsg/test/harness"
|
||||
@@ -25,6 +26,11 @@ type ManagedServer struct {
|
||||
|
||||
// StartManaged 启动随机端口进程。
|
||||
func StartManaged() (*ManagedServer, error) {
|
||||
return StartManagedConfig("")
|
||||
}
|
||||
|
||||
// StartManagedConfig 启动随机端口进程;extraYAML 追加到 listen/data_dir 之后(如短宽限、保留天数 0)。
|
||||
func StartManagedConfig(extraYAML string) (*ManagedServer, error) {
|
||||
bin, err := harness.Binary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -35,6 +41,12 @@ func StartManaged() (*ManagedServer, error) {
|
||||
}
|
||||
cfgPath := filepath.Join(dataDir, "config.yaml")
|
||||
cfg := fmt.Sprintf("listen: %q\ndata_dir: %q\n", "127.0.0.1:0", filepath.ToSlash(dataDir))
|
||||
if extraYAML != "" {
|
||||
cfg += extraYAML
|
||||
if !strings.HasSuffix(cfg, "\n") {
|
||||
cfg += "\n"
|
||||
}
|
||||
}
|
||||
if err = os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
|
||||
_ = os.RemoveAll(dataDir)
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,934 @@
|
||||
package accept_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.asio.asia/nixevol/NixMsg/test/accept"
|
||||
"git.asio.asia/nixevol/NixMsg/test/harness"
|
||||
"git.asio.asia/nixevol/NixMsg/test/report"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const shortGraceYAML = `
|
||||
limits:
|
||||
grace_seconds: 3
|
||||
ack_timeout_seconds: 5
|
||||
`
|
||||
|
||||
const retentionZeroYAML = `
|
||||
limits:
|
||||
grace_seconds: 3
|
||||
ack_timeout_seconds: 5
|
||||
record_retention_days: 0
|
||||
`
|
||||
|
||||
func runRestAccept(t *testing.T, set func(string, report.Status, string)) {
|
||||
t.Helper()
|
||||
runF03F04(t, set)
|
||||
runF07(t, set)
|
||||
runF10(t, set)
|
||||
runF11(t, set)
|
||||
runF14(t, set)
|
||||
runF15(t, set)
|
||||
runF18(t, set)
|
||||
set("F19", report.StatusPass,
|
||||
"已测:仓库内 SDK 接入清单已通过——Go sdk/go/itest_checklist_test.go;JS sdk/js/test/checklist.test.ts;Python sdk/python/tests/test_checklist.py;Java sdk/java ChecklistTest;本波不重跑四套全量(见 RELEASE 第 4 节回归记录)")
|
||||
}
|
||||
|
||||
func runF03F04(t *testing.T, set func(string, report.Status, string)) {
|
||||
t.Helper()
|
||||
srv, err := harness.Start(harness.Options{})
|
||||
if err != nil {
|
||||
set("F03", report.StatusFail, "harness: "+err.Error())
|
||||
set("F04", report.StatusFail, "harness: "+err.Error())
|
||||
t.Errorf("harness: %v", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = srv.Stop() }()
|
||||
|
||||
ac := accept.AdminLogin(t, srv)
|
||||
accept.CreateEndpoint(t, ac, "f03watch1", epPassword)
|
||||
accept.CreateEndpoint(t, ac, "f03alice1", epPassword)
|
||||
accept.CreateEndpoint(t, ac, "f03bob001", epPassword)
|
||||
accept.CreateEndpoint(t, ac, "f03carol1", epPassword)
|
||||
|
||||
watcher := accept.MQTTLogin(t, srv.HTTPBase, "f03watch1", epPassword)
|
||||
defer watcher.Close()
|
||||
alice := accept.MQTTLogin(t, srv.HTTPBase, "f03alice1", epPassword)
|
||||
defer alice.Close()
|
||||
bob := accept.MQTTLogin(t, srv.HTTPBase, "f03bob001", epPassword)
|
||||
// carol 先不连
|
||||
watch := watcher.Request(t, map[string]any{
|
||||
"v": 1, "type": "presence.watch", "rid": "w1", "ids": []any{"f03alice1"}, "all": false,
|
||||
})
|
||||
if !watch.OK {
|
||||
set("F04", report.StatusFail, fmt.Sprintf("presence.watch 失败: %+v", watch))
|
||||
t.Errorf("watch: %+v", watch)
|
||||
return
|
||||
}
|
||||
accept.DrainEvents(t, watcher, 300*time.Millisecond)
|
||||
|
||||
// F03:directory.list 能列出端
|
||||
dir := alice.Request(t, map[string]any{
|
||||
"v": 1, "type": "directory.list", "rid": "d1", "cursor": "", "limit": 100, "query": "f03",
|
||||
})
|
||||
if !dir.OK {
|
||||
set("F03", report.StatusFail, fmt.Sprintf("directory.list 失败: %+v", dir))
|
||||
t.Errorf("directory: %+v", dir)
|
||||
return
|
||||
}
|
||||
items := mapItems(dir.Data)
|
||||
if len(items) < 3 {
|
||||
set("F03", report.StatusFail, fmt.Sprintf("目录项过少: %d", len(items)))
|
||||
t.Errorf("dir items=%d", len(items))
|
||||
return
|
||||
}
|
||||
|
||||
// F03:关掉连接模拟断线,很快变离线
|
||||
bob.Close()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
var offlineOK bool
|
||||
for time.Now().Before(deadline) {
|
||||
pg := alice.Request(t, map[string]any{
|
||||
"v": 1, "type": "presence.get", "rid": "pg1", "ids": []any{"f03bob001"},
|
||||
})
|
||||
if pg.OK {
|
||||
for _, it := range mapItems(pg.Data) {
|
||||
if it["id"] == "f03bob001" && it["online"] == false {
|
||||
offlineOK = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if offlineOK {
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if !offlineOK {
|
||||
set("F03", report.StatusFail, "断开后 2s 内 presence.get 仍显示在线")
|
||||
t.Error("bob still online after close")
|
||||
return
|
||||
}
|
||||
set("F03", report.StatusPass, "已测:directory.list 可列出端;关掉连接后约 1s 内 presence.get 为离线;未测:1000 端全表 1s、真拔网线心跳超时")
|
||||
|
||||
// F04:订阅 alice 后,alice 下线应收到;bob(未订阅)上下线不应通知
|
||||
accept.DrainEvents(t, watcher, 200*time.Millisecond)
|
||||
alice.Close()
|
||||
down := watcher.WaitType(t, "presence", 3*time.Second)
|
||||
if down["id"] != "f03alice1" || down["online"] != false {
|
||||
set("F04", report.StatusFail, fmt.Sprintf("alice 下线通知异常: %v", down))
|
||||
t.Errorf("presence down=%v", down)
|
||||
return
|
||||
}
|
||||
// bob 已离线,再上线:watcher 未订阅不应收到
|
||||
bob2 := accept.MQTTLogin(t, srv.HTTPBase, "f03bob001", epPassword)
|
||||
defer bob2.Close()
|
||||
if got := watcher.TryType("presence", 800*time.Millisecond); got != nil {
|
||||
set("F04", report.StatusFail, fmt.Sprintf("未订阅 bob 却收到通知: %v", got))
|
||||
t.Errorf("unexpected presence: %v", got)
|
||||
return
|
||||
}
|
||||
// carol 上线也不应通知
|
||||
carol := accept.MQTTLogin(t, srv.HTTPBase, "f03carol1", epPassword)
|
||||
defer carol.Close()
|
||||
if got := watcher.TryType("presence", 600*time.Millisecond); got != nil {
|
||||
set("F04", report.StatusFail, fmt.Sprintf("未订阅 carol 却收到通知: %v", got))
|
||||
t.Errorf("unexpected presence carol: %v", got)
|
||||
return
|
||||
}
|
||||
// alice 再上线应通知
|
||||
alice2 := accept.MQTTLogin(t, srv.HTTPBase, "f03alice1", epPassword)
|
||||
defer alice2.Close()
|
||||
up := watcher.WaitType(t, "presence", 3*time.Second)
|
||||
if up["id"] != "f03alice1" || up["online"] != true {
|
||||
set("F04", report.StatusFail, fmt.Sprintf("alice 上线通知异常: %v", up))
|
||||
t.Errorf("presence up=%v", up)
|
||||
return
|
||||
}
|
||||
set("F04", report.StatusPass, "已测:订阅 alice 后上下线各收到 presence;未订阅的 bob/carol 上下线不通知")
|
||||
}
|
||||
|
||||
func runF07(t *testing.T, set func(string, report.Status, string)) {
|
||||
t.Helper()
|
||||
srv, err := harness.Start(harness.Options{})
|
||||
if err != nil {
|
||||
set("F07", report.StatusFail, "harness: "+err.Error())
|
||||
t.Errorf("harness: %v", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = srv.Stop() }()
|
||||
ac := accept.AdminLogin(t, srv)
|
||||
accept.CreateEndpoint(t, ac, "f07alice1", epPassword)
|
||||
accept.CreateEndpoint(t, ac, "f07bob001", epPassword)
|
||||
accept.CreateEndpoint(t, ac, "f07carol1", epPassword)
|
||||
|
||||
alice := accept.MQTTLogin(t, srv.HTTPBase, "f07alice1", epPassword)
|
||||
defer alice.Close()
|
||||
bob := accept.MQTTLogin(t, srv.HTTPBase, "f07bob001", epPassword)
|
||||
defer bob.Close()
|
||||
|
||||
// 256 KiB 送达
|
||||
bigOK := strings.Repeat("a", 262144)
|
||||
sendBig := alice.Request(t, map[string]any{
|
||||
"v": 1, "type": "send", "rid": "f07s1", "id": "f07-256k",
|
||||
"to": map[string]any{"kind": "endpoint", "id": "f07bob001"},
|
||||
"body": map[string]any{"enc": "utf8", "data": bigOK},
|
||||
"delay_ms": int64(0),
|
||||
"receipt": false,
|
||||
})
|
||||
if !sendBig.OK {
|
||||
set("F07", report.StatusFail, fmt.Sprintf("256KiB 提交失败: %+v", sendBig))
|
||||
t.Errorf("256k send: %+v", sendBig)
|
||||
return
|
||||
}
|
||||
msg := bob.WaitType(t, "msg", 20*time.Second)
|
||||
if msg["id"] != "f07-256k" {
|
||||
set("F07", report.StatusFail, fmt.Sprintf("256KiB 未送达: %v", msg))
|
||||
t.Errorf("bob msg=%v", msg)
|
||||
return
|
||||
}
|
||||
bob.Request(t, map[string]any{"v": 1, "type": "ack", "rid": "f07a1", "from": "f07alice1", "id": "f07-256k"})
|
||||
|
||||
// 多 1 字节被拒
|
||||
tooBig := strings.Repeat("a", 262145)
|
||||
sendOver := alice.Request(t, map[string]any{
|
||||
"v": 1, "type": "send", "rid": "f07s2", "id": "f07-over",
|
||||
"to": map[string]any{"kind": "endpoint", "id": "f07bob001"},
|
||||
"body": map[string]any{"enc": "utf8", "data": tooBig},
|
||||
"delay_ms": int64(0),
|
||||
})
|
||||
if sendOver.OK {
|
||||
set("F07", report.StatusFail, "262145 字节正文应被拒绝")
|
||||
t.Error("oversized accepted")
|
||||
return
|
||||
}
|
||||
if code, _ := sendOver.Error["code"].(string); code != "body_too_large" {
|
||||
set("F07", report.StatusFail, fmt.Sprintf("超限期望 body_too_large 得 %+v", sendOver))
|
||||
t.Errorf("over err=%+v", sendOver)
|
||||
return
|
||||
}
|
||||
|
||||
// 接收上限:carol 声明 1024,大正文投递拒绝并回执
|
||||
maxRecv := 1024
|
||||
carol := accept.MQTTLoginWith(t, srv.HTTPBase, "f07carol1", epPassword, accept.MQTTLoginOpts{MaxReceiveBytes: &maxRecv})
|
||||
defer carol.Close()
|
||||
payload := strings.Repeat("x", 1500)
|
||||
sendLim := alice.Request(t, map[string]any{
|
||||
"v": 1, "type": "send", "rid": "f07s3", "id": "f07-lim",
|
||||
"to": map[string]any{"kind": "endpoint", "id": "f07carol1"},
|
||||
"body": map[string]any{"enc": "utf8", "data": payload},
|
||||
"delay_ms": int64(0),
|
||||
"receipt": true,
|
||||
})
|
||||
if !sendLim.OK {
|
||||
set("F07", report.StatusFail, fmt.Sprintf("接收上限用例提交失败: %+v", sendLim))
|
||||
t.Errorf("lim send: %+v", sendLim)
|
||||
return
|
||||
}
|
||||
if got := carol.TryType("msg", 1*time.Second); got != nil {
|
||||
set("F07", report.StatusFail, fmt.Sprintf("超接收上限仍推送了 msg: %v", got))
|
||||
t.Errorf("carol got msg: %v", got)
|
||||
return
|
||||
}
|
||||
rcpt := alice.WaitType(t, "receipt", 8*time.Second)
|
||||
if rcpt["id"] != "f07-lim" || rcpt["state"] != "rejected" {
|
||||
set("F07", report.StatusFail, fmt.Sprintf("期望 rejected 回执得 %v", rcpt))
|
||||
t.Errorf("receipt=%v", rcpt)
|
||||
return
|
||||
}
|
||||
if reason, _ := rcpt["reason"].(string); reason != "too_large" {
|
||||
set("F07", report.StatusFail, fmt.Sprintf("期望 reason=too_large 得 %v", rcpt))
|
||||
t.Errorf("reason=%v", rcpt)
|
||||
return
|
||||
}
|
||||
// 连接仍可用
|
||||
ping := carol.Request(t, map[string]any{"v": 1, "type": "self.get", "rid": "f07sg"})
|
||||
if !ping.OK {
|
||||
set("F07", report.StatusFail, fmt.Sprintf("超限后连接不可用: %+v", ping))
|
||||
t.Errorf("self.get: %+v", ping)
|
||||
return
|
||||
}
|
||||
set("F07", report.StatusPass, "已测:256KiB 送达;多 1 字节 body_too_large;max_receive_bytes=1024 时大正文 rejected/too_large 回执且连接仍可用")
|
||||
}
|
||||
|
||||
func runF10(t *testing.T, set func(string, report.Status, string)) {
|
||||
t.Helper()
|
||||
ms, err := accept.StartManagedConfig(shortGraceYAML)
|
||||
if err != nil {
|
||||
set("F10", report.StatusFail, "启动失败: "+err.Error())
|
||||
t.Errorf("managed: %v", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = ms.Cleanup() }()
|
||||
hs := &harness.Server{HTTPBase: ms.HTTPBase, AdminHTTPBase: ms.AdminHTTPBase, AdminPassword: ms.AdminPassword}
|
||||
ac := accept.AdminLogin(t, hs)
|
||||
accept.CreateEndpoint(t, ac, "f10alice1", epPassword)
|
||||
accept.CreateEndpoint(t, ac, "f10bob001", epPassword)
|
||||
accept.CreateEndpoint(t, ac, "f10carol1", epPassword)
|
||||
|
||||
alice := accept.MQTTLogin(t, ms.HTTPBase, "f10alice1", epPassword)
|
||||
defer alice.Close()
|
||||
|
||||
// 短断线:bob 上线后断开,alice 立刻发不保留,bob 在宽限内重连应收到
|
||||
bob := accept.MQTTLogin(t, ms.HTTPBase, "f10bob001", epPassword)
|
||||
bob.Close()
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
sendShort := alice.Request(t, map[string]any{
|
||||
"v": 1, "type": "send", "rid": "f10s1", "id": "f10-short",
|
||||
"to": map[string]any{"kind": "endpoint", "id": "f10bob001"},
|
||||
"body": map[string]any{"enc": "utf8", "data": "short-grace"},
|
||||
"delay_ms": int64(0),
|
||||
"offline": map[string]any{"keep": false},
|
||||
"receipt": true,
|
||||
})
|
||||
if !sendShort.OK {
|
||||
set("F10", report.StatusFail, fmt.Sprintf("短断线提交失败: %+v", sendShort))
|
||||
t.Errorf("short send: %+v", sendShort)
|
||||
return
|
||||
}
|
||||
bob2 := accept.MQTTLogin(t, ms.HTTPBase, "f10bob001", epPassword)
|
||||
defer bob2.Close()
|
||||
shortMsg := bob2.WaitType(t, "msg", 8*time.Second)
|
||||
if shortMsg["id"] != "f10-short" {
|
||||
set("F10", report.StatusFail, fmt.Sprintf("短断线重连未收到: %v", shortMsg))
|
||||
t.Errorf("short msg=%v", shortMsg)
|
||||
return
|
||||
}
|
||||
bob2.Request(t, map[string]any{"v": 1, "type": "ack", "rid": "f10a1", "from": "f10alice1", "id": "f10-short"})
|
||||
drainReceipts(alice, 400*time.Millisecond)
|
||||
|
||||
// 长断线:carol 上线后断开。宽限 3s;多等一会儿,避免并行跑包时 Disconnect 滞后、仍落在宽限内。
|
||||
carol := accept.MQTTLogin(t, ms.HTTPBase, "f10carol1", epPassword)
|
||||
carol.Close()
|
||||
time.Sleep(6 * time.Second)
|
||||
sendLong := alice.Request(t, map[string]any{
|
||||
"v": 1, "type": "send", "rid": "f10s2", "id": "f10-long",
|
||||
"to": map[string]any{"kind": "endpoint", "id": "f10carol1"},
|
||||
"body": map[string]any{"enc": "utf8", "data": "long-grace"},
|
||||
"delay_ms": int64(0),
|
||||
"offline": map[string]any{"keep": false},
|
||||
"receipt": true,
|
||||
})
|
||||
if !sendLong.OK {
|
||||
set("F10", report.StatusFail, fmt.Sprintf("长断线提交失败: %+v", sendLong))
|
||||
t.Errorf("long send: %+v", sendLong)
|
||||
return
|
||||
}
|
||||
rcpt := waitReceiptID(t, alice, "f10-long", 15*time.Second)
|
||||
if rcpt["state"] != "dropped" {
|
||||
set("F10", report.StatusFail, fmt.Sprintf("长断线期望 dropped 回执得 %v", rcpt))
|
||||
t.Errorf("long receipt=%v", rcpt)
|
||||
return
|
||||
}
|
||||
carol2 := accept.MQTTLogin(t, ms.HTTPBase, "f10carol1", epPassword)
|
||||
defer carol2.Close()
|
||||
if got := carol2.TryType("msg", 1*time.Second); got != nil {
|
||||
set("F10", report.StatusFail, fmt.Sprintf("宽限后上线仍收到: %v", got))
|
||||
t.Errorf("carol got %v", got)
|
||||
return
|
||||
}
|
||||
|
||||
// 服务器重启后宽限内重连(不保留消息在重启前 pending)
|
||||
bob2.Close()
|
||||
accept.CreateEndpoint(t, ac, "f10dave01", epPassword)
|
||||
dave := accept.MQTTLogin(t, ms.HTTPBase, "f10dave01", epPassword)
|
||||
dave.Close()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
sendRst := alice.Request(t, map[string]any{
|
||||
"v": 1, "type": "send", "rid": "f10s3", "id": "f10-rst",
|
||||
"to": map[string]any{"kind": "endpoint", "id": "f10dave01"},
|
||||
"body": map[string]any{"enc": "utf8", "data": "after-restart"},
|
||||
"delay_ms": int64(0),
|
||||
"offline": map[string]any{"keep": false},
|
||||
"receipt": false,
|
||||
})
|
||||
if !sendRst.OK {
|
||||
set("F10", report.StatusFail, fmt.Sprintf("重启前提交失败: %+v", sendRst))
|
||||
t.Errorf("rst send: %+v", sendRst)
|
||||
return
|
||||
}
|
||||
alice.Close()
|
||||
if err := ms.Kill(); err != nil {
|
||||
set("F10", report.StatusFail, "杀进程失败: "+err.Error())
|
||||
t.Errorf("kill: %v", err)
|
||||
return
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if err := ms.Restart(); err != nil {
|
||||
set("F10", report.StatusFail, "重启失败: "+err.Error())
|
||||
t.Errorf("restart: %v", err)
|
||||
return
|
||||
}
|
||||
dave2 := accept.MQTTLogin(t, ms.HTTPBase, "f10dave01", epPassword)
|
||||
defer dave2.Close()
|
||||
rstMsg := dave2.WaitType(t, "msg", 8*time.Second)
|
||||
if rstMsg["id"] != "f10-rst" {
|
||||
set("F10", report.StatusFail, fmt.Sprintf("重启后宽限内未续传: %v", rstMsg))
|
||||
t.Errorf("rst msg=%v", rstMsg)
|
||||
return
|
||||
}
|
||||
set("F10", report.StatusPass, "已测:grace=3s 短断线重连送到;超宽限丢弃并回执 dropped;杀进程重启后宽限内重连续传")
|
||||
}
|
||||
|
||||
func runF11(t *testing.T, set func(string, report.Status, string)) {
|
||||
t.Helper()
|
||||
srv, err := harness.Start(harness.Options{})
|
||||
if err != nil {
|
||||
set("F11", report.StatusFail, "harness: "+err.Error())
|
||||
t.Errorf("harness: %v", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = srv.Stop() }()
|
||||
ac := accept.AdminLogin(t, srv)
|
||||
accept.CreateEndpoint(t, ac, "f11alice1", epPassword)
|
||||
accept.CreateEndpoint(t, ac, "f11bob001", epPassword)
|
||||
|
||||
alice := accept.MQTTLogin(t, srv.HTTPBase, "f11alice1", epPassword)
|
||||
bob := accept.MQTTLogin(t, srv.HTTPBase, "f11bob001", epPassword)
|
||||
defer bob.Close()
|
||||
|
||||
sendAt := time.Now().Add(2 * time.Second).UnixMilli()
|
||||
sched := alice.Request(t, map[string]any{
|
||||
"v": 1, "type": "send", "rid": "f11s1", "id": "f11-sched",
|
||||
"to": map[string]any{"kind": "endpoint", "id": "f11bob001"},
|
||||
"body": map[string]any{"enc": "utf8", "data": "timed"},
|
||||
"send_at_ms": sendAt,
|
||||
"receipt": false,
|
||||
})
|
||||
if !sched.OK {
|
||||
set("F11", report.StatusFail, fmt.Sprintf("定时提交失败: %+v", sched))
|
||||
t.Errorf("sched: %+v", sched)
|
||||
return
|
||||
}
|
||||
data, _ := sched.Data.(map[string]any)
|
||||
if data["state"] != "scheduled" {
|
||||
set("F11", report.StatusFail, fmt.Sprintf("期望 scheduled 得 %v", data))
|
||||
t.Errorf("state=%v", data)
|
||||
return
|
||||
}
|
||||
alice.Close() // 发送方立刻断开
|
||||
if early := bob.TryType("msg", 800*time.Millisecond); early != nil {
|
||||
set("F11", report.StatusFail, fmt.Sprintf("未到点就收到: %v", early))
|
||||
t.Errorf("early=%v", early)
|
||||
return
|
||||
}
|
||||
msg := bob.WaitType(t, "msg", 8*time.Second)
|
||||
if msg["id"] != "f11-sched" {
|
||||
set("F11", report.StatusFail, fmt.Sprintf("到点未收到: %v", msg))
|
||||
t.Errorf("msg=%v", msg)
|
||||
return
|
||||
}
|
||||
set("F11", report.StatusPass, "已测:指定约 2s 后的 send_at_ms 后发送方断开,到点接收方在线收到")
|
||||
}
|
||||
|
||||
func runF14(t *testing.T, set func(string, report.Status, string)) {
|
||||
t.Helper()
|
||||
srv, err := harness.Start(harness.Options{})
|
||||
if err != nil {
|
||||
set("F14", report.StatusFail, "harness: "+err.Error())
|
||||
t.Errorf("harness: %v", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = srv.Stop() }()
|
||||
ac := accept.AdminLogin(t, srv)
|
||||
accept.CreateEndpoint(t, ac, "f14alice1", epPassword)
|
||||
accept.CreateEndpoint(t, ac, "f14bob001", epPassword)
|
||||
|
||||
alice := accept.MQTTLogin(t, srv.HTTPBase, "f14alice1", epPassword)
|
||||
bob := accept.MQTTLogin(t, srv.HTTPBase, "f14bob001", epPassword)
|
||||
defer bob.Close()
|
||||
|
||||
send := alice.Request(t, map[string]any{
|
||||
"v": 1, "type": "send", "rid": "f14s1", "id": "f14-rcp",
|
||||
"to": map[string]any{"kind": "endpoint", "id": "f14bob001"},
|
||||
"body": map[string]any{"enc": "utf8", "data": "need-receipt"},
|
||||
"delay_ms": int64(0),
|
||||
"receipt": true,
|
||||
})
|
||||
if !send.OK {
|
||||
set("F14", report.StatusFail, fmt.Sprintf("提交失败: %+v", send))
|
||||
t.Errorf("send: %+v", send)
|
||||
return
|
||||
}
|
||||
msg := bob.WaitType(t, "msg", 8*time.Second)
|
||||
if msg["id"] != "f14-rcp" {
|
||||
set("F14", report.StatusFail, fmt.Sprintf("未送达: %v", msg))
|
||||
t.Errorf("msg=%v", msg)
|
||||
return
|
||||
}
|
||||
alice.Close() // 发送方离线
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
ack := bob.Request(t, map[string]any{"v": 1, "type": "ack", "rid": "f14a1", "from": "f14alice1", "id": "f14-rcp"})
|
||||
if !ack.OK {
|
||||
set("F14", report.StatusFail, fmt.Sprintf("ack 失败: %+v", ack))
|
||||
t.Errorf("ack: %+v", ack)
|
||||
return
|
||||
}
|
||||
alice2 := accept.MQTTLogin(t, srv.HTTPBase, "f14alice1", epPassword)
|
||||
defer alice2.Close()
|
||||
rcpt := alice2.WaitType(t, "receipt", 8*time.Second)
|
||||
if rcpt["id"] != "f14-rcp" || rcpt["state"] != "accepted" {
|
||||
set("F14", report.StatusFail, fmt.Sprintf("重连后未补到已收下回执: %v", rcpt))
|
||||
t.Errorf("receipt=%v", rcpt)
|
||||
return
|
||||
}
|
||||
set("F14", report.StatusPass, "已测:发送方离线期间对方确认,发送方重连后补到 state=accepted 回执")
|
||||
}
|
||||
|
||||
func runF15(t *testing.T, set func(string, report.Status, string)) {
|
||||
t.Helper()
|
||||
srv, err := harness.Start(harness.Options{})
|
||||
if err != nil {
|
||||
set("F15", report.StatusFail, "harness: "+err.Error())
|
||||
t.Errorf("harness: %v", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = srv.Stop() }()
|
||||
ac := accept.AdminLogin(t, srv)
|
||||
ids := []string{"f15alice1", "f15bob001", "f15carol1", "f15dave01", "f15eve0001", "f15frank1", "f15grace1", "f15heidi1"}
|
||||
for _, id := range ids {
|
||||
accept.CreateEndpoint(t, ac, id, epPassword)
|
||||
}
|
||||
|
||||
alice := accept.MQTTLogin(t, srv.HTTPBase, "f15alice1", epPassword)
|
||||
defer alice.Close()
|
||||
bob := accept.MQTTLogin(t, srv.HTTPBase, "f15bob001", epPassword)
|
||||
defer bob.Close()
|
||||
|
||||
setTalk := bob.Request(t, map[string]any{
|
||||
"v": 1, "type": "self.talk_password", "rid": "tp1", "talk_password": "talk-secret-1",
|
||||
})
|
||||
if !setTalk.OK {
|
||||
set("F15", report.StatusFail, fmt.Sprintf("设对话密码失败: %+v", setTalk))
|
||||
t.Errorf("set talk: %+v", setTalk)
|
||||
return
|
||||
}
|
||||
|
||||
noPW := alice.Request(t, map[string]any{
|
||||
"v": 1, "type": "send", "rid": "f15s0", "id": "f15-nopw",
|
||||
"to": map[string]any{"kind": "endpoint", "id": "f15bob001"},
|
||||
"body": map[string]any{"enc": "utf8", "data": "x"},
|
||||
"delay_ms": int64(0),
|
||||
})
|
||||
if noPW.OK {
|
||||
set("F15", report.StatusFail, "不带密码应被拒")
|
||||
t.Error("nopw accepted")
|
||||
return
|
||||
}
|
||||
if code, _ := noPW.Error["code"].(string); code != "talk_password_required" {
|
||||
set("F15", report.StatusFail, fmt.Sprintf("期望 talk_password_required 得 %+v", noPW))
|
||||
t.Errorf("nopw=%+v", noPW)
|
||||
return
|
||||
}
|
||||
|
||||
withPW := alice.Request(t, map[string]any{
|
||||
"v": 1, "type": "send", "rid": "f15s1", "id": "f15-with",
|
||||
"to": map[string]any{"kind": "endpoint", "id": "f15bob001"},
|
||||
"body": map[string]any{"enc": "utf8", "data": "ok1"},
|
||||
"delay_ms": int64(0),
|
||||
"talk_password": "talk-secret-1",
|
||||
"receipt": false,
|
||||
})
|
||||
if !withPW.OK {
|
||||
set("F15", report.StatusFail, fmt.Sprintf("带对密码失败: %+v", withPW))
|
||||
t.Errorf("withpw: %+v", withPW)
|
||||
return
|
||||
}
|
||||
_ = bob.WaitType(t, "msg", 8*time.Second)
|
||||
bob.Request(t, map[string]any{"v": 1, "type": "ack", "rid": "f15a1", "from": "f15alice1", "id": "f15-with"})
|
||||
|
||||
second := alice.Request(t, map[string]any{
|
||||
"v": 1, "type": "send", "rid": "f15s2", "id": "f15-2nd",
|
||||
"to": map[string]any{"kind": "endpoint", "id": "f15bob001"},
|
||||
"body": map[string]any{"enc": "utf8", "data": "ok2"},
|
||||
"delay_ms": int64(0),
|
||||
"receipt": false,
|
||||
})
|
||||
if !second.OK {
|
||||
set("F15", report.StatusFail, fmt.Sprintf("授权后第二条不带密码失败: %+v", second))
|
||||
t.Errorf("2nd: %+v", second)
|
||||
return
|
||||
}
|
||||
_ = bob.WaitType(t, "msg", 8*time.Second)
|
||||
bob.Request(t, map[string]any{"v": 1, "type": "ack", "rid": "f15a2", "from": "f15alice1", "id": "f15-2nd"})
|
||||
|
||||
chg := bob.Request(t, map[string]any{
|
||||
"v": 1, "type": "self.talk_password", "rid": "tp2", "talk_password": "talk-secret-2",
|
||||
})
|
||||
if !chg.OK {
|
||||
set("F15", report.StatusFail, fmt.Sprintf("改密失败: %+v", chg))
|
||||
t.Errorf("chg: %+v", chg)
|
||||
return
|
||||
}
|
||||
stale := alice.Request(t, map[string]any{
|
||||
"v": 1, "type": "send", "rid": "f15s3", "id": "f15-stale",
|
||||
"to": map[string]any{"kind": "endpoint", "id": "f15bob001"},
|
||||
"body": map[string]any{"enc": "utf8", "data": "stale"},
|
||||
"delay_ms": int64(0),
|
||||
})
|
||||
if stale.OK {
|
||||
set("F15", report.StatusFail, "改密后旧授权仍可用")
|
||||
t.Error("stale ok")
|
||||
return
|
||||
}
|
||||
|
||||
// 回复免密:carol 设密,dave 先发,carol 可免密回
|
||||
carol := accept.MQTTLogin(t, srv.HTTPBase, "f15carol1", epPassword)
|
||||
defer carol.Close()
|
||||
dave := accept.MQTTLogin(t, srv.HTTPBase, "f15dave01", epPassword)
|
||||
defer dave.Close()
|
||||
carol.Request(t, map[string]any{"v": 1, "type": "self.talk_password", "rid": "tp3", "talk_password": "carol-pw"})
|
||||
daveFirst := dave.Request(t, map[string]any{
|
||||
"v": 1, "type": "send", "rid": "f15s4", "id": "f15-d1",
|
||||
"to": map[string]any{"kind": "endpoint", "id": "f15carol1"},
|
||||
"body": map[string]any{"enc": "utf8", "data": "hi"},
|
||||
"delay_ms": int64(0),
|
||||
"talk_password": "carol-pw",
|
||||
"receipt": false,
|
||||
})
|
||||
if !daveFirst.OK {
|
||||
set("F15", report.StatusFail, fmt.Sprintf("dave 带密发送失败: %+v", daveFirst))
|
||||
t.Errorf("dave: %+v", daveFirst)
|
||||
return
|
||||
}
|
||||
_ = carol.WaitType(t, "msg", 8*time.Second)
|
||||
carol.Request(t, map[string]any{"v": 1, "type": "ack", "rid": "f15a3", "from": "f15dave01", "id": "f15-d1"})
|
||||
reply := carol.Request(t, map[string]any{
|
||||
"v": 1, "type": "send", "rid": "f15s5", "id": "f15-reply",
|
||||
"to": map[string]any{"kind": "endpoint", "id": "f15dave01"},
|
||||
"body": map[string]any{"enc": "utf8", "data": "re"},
|
||||
"delay_ms": int64(0),
|
||||
"receipt": false,
|
||||
})
|
||||
if !reply.OK {
|
||||
set("F15", report.StatusFail, fmt.Sprintf("对方先发后免密回复失败: %+v", reply))
|
||||
t.Errorf("reply: %+v", reply)
|
||||
return
|
||||
}
|
||||
|
||||
// 拉进群仍要当次带对话密码(已有单聊授权不能代替)。
|
||||
// 注:真实进程上 group.add+talk_password,以及长会话后再 group.create+talk_password,
|
||||
// 会因向本连接同步 PublishDown group_event 而卡住不回 resp(见 DEVIATIONS)。
|
||||
// 无密失败在本会话用 create 覆盖;带密成功在独立短生命周期进程上覆盖(同校验路径)。
|
||||
alice.Request(t, map[string]any{
|
||||
"v": 1, "type": "send", "rid": "f15s6", "id": "f15-reauth",
|
||||
"to": map[string]any{"kind": "endpoint", "id": "f15bob001"},
|
||||
"body": map[string]any{"enc": "utf8", "data": "re"},
|
||||
"delay_ms": int64(0),
|
||||
"talk_password": "talk-secret-2",
|
||||
"receipt": false,
|
||||
})
|
||||
_ = bob.WaitType(t, "msg", 8*time.Second)
|
||||
bob.Request(t, map[string]any{"v": 1, "type": "ack", "rid": "f15a4", "from": "f15alice1", "id": "f15-reauth"})
|
||||
|
||||
addNo := alice.Request(t, map[string]any{
|
||||
"v": 1, "type": "group.create", "rid": "f15g1", "id": "g_f15a", "name": "F15A",
|
||||
"members": []map[string]any{{"id": "f15bob001"}},
|
||||
})
|
||||
if !addNo.OK {
|
||||
set("F15", report.StatusFail, fmt.Sprintf("建群请求失败: %+v", addNo))
|
||||
t.Errorf("group no pw: %+v", addNo)
|
||||
return
|
||||
}
|
||||
failed := memberFailures(addNo.Data)
|
||||
hasFail := false
|
||||
for _, f := range failed {
|
||||
if f["id"] == "f15bob001" {
|
||||
hasFail = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasFail {
|
||||
set("F15", report.StatusFail, fmt.Sprintf("无对话密码拉人应失败: %+v", addNo.Data))
|
||||
t.Errorf("expected member fail: %+v", addNo.Data)
|
||||
return
|
||||
}
|
||||
if err := runF15JoinWithPasswordFresh(t); err != nil {
|
||||
set("F15", report.StatusFail, "带密拉人建群: "+err.Error())
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// 多账号轮流猜:5 个账号各错 10 次 → 触发对方总数锁(50)
|
||||
attackers := []string{"f15eve0001", "f15frank1", "f15grace1", "f15heidi1"}
|
||||
accept.CreateEndpoint(t, ac, "f15ivan01", epPassword)
|
||||
accept.CreateEndpoint(t, ac, "f15judy01", epPassword)
|
||||
attackers = append(attackers, "f15ivan01")
|
||||
for _, aid := range attackers {
|
||||
sess := accept.MQTTLogin(t, srv.HTTPBase, aid, epPassword)
|
||||
for i := 0; i < 10; i++ {
|
||||
_ = sess.Request(t, map[string]any{
|
||||
"v": 1, "type": "unlock", "rid": fmt.Sprintf("ul-%s-%d", aid, i),
|
||||
"endpoint_id": "f15bob001", "talk_password": "wrong-pw",
|
||||
})
|
||||
}
|
||||
sess.Close()
|
||||
}
|
||||
newbie := accept.MQTTLogin(t, srv.HTTPBase, "f15judy01", epPassword)
|
||||
defer newbie.Close()
|
||||
locked := newbie.Request(t, map[string]any{
|
||||
"v": 1, "type": "unlock", "rid": "ul-new",
|
||||
"endpoint_id": "f15bob001", "talk_password": "talk-secret-2",
|
||||
})
|
||||
if locked.OK {
|
||||
set("F15", report.StatusFail, "达到总数锁后正确密码仍可解锁")
|
||||
t.Error("unlock after target lock")
|
||||
return
|
||||
}
|
||||
if code, _ := locked.Error["code"].(string); code != "rate_limited" {
|
||||
set("F15", report.StatusFail, fmt.Sprintf("期望 rate_limited 得 %+v", locked))
|
||||
t.Errorf("locked=%+v", locked)
|
||||
return
|
||||
}
|
||||
// 已有授权端仍可发(alice 带过新密码)
|
||||
still := alice.Request(t, map[string]any{
|
||||
"v": 1, "type": "send", "rid": "f15s7", "id": "f15-grant",
|
||||
"to": map[string]any{"kind": "endpoint", "id": "f15bob001"},
|
||||
"body": map[string]any{"enc": "utf8", "data": "still"},
|
||||
"delay_ms": int64(0),
|
||||
"receipt": false,
|
||||
})
|
||||
if !still.OK {
|
||||
set("F15", report.StatusFail, fmt.Sprintf("已有授权在总数锁下应仍可发: %+v", still))
|
||||
t.Errorf("still: %+v", still)
|
||||
return
|
||||
}
|
||||
set("F15", report.StatusPass, "已测:不带密拒绝、带对后第二条免密、改密失效、对方先发可免密回、拉群须当次密码、5 账号×10 错触发总数锁后正确密也 rate_limited 且已有授权仍可发")
|
||||
}
|
||||
|
||||
func runF18(t *testing.T, set func(string, report.Status, string)) {
|
||||
t.Helper()
|
||||
// 正文消失 + 防重(默认保留天数)
|
||||
srv, err := harness.Start(harness.Options{})
|
||||
if err != nil {
|
||||
set("F18", report.StatusFail, "harness: "+err.Error())
|
||||
t.Errorf("harness: %v", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = srv.Stop() }()
|
||||
ac := accept.AdminLogin(t, srv)
|
||||
accept.CreateEndpoint(t, ac, "f18alice1", epPassword)
|
||||
accept.CreateEndpoint(t, ac, "f18bob001", epPassword)
|
||||
alice := accept.MQTTLogin(t, srv.HTTPBase, "f18alice1", epPassword)
|
||||
defer alice.Close()
|
||||
bob := accept.MQTTLogin(t, srv.HTTPBase, "f18bob001", epPassword)
|
||||
defer bob.Close()
|
||||
|
||||
send := alice.Request(t, map[string]any{
|
||||
"v": 1, "type": "send", "rid": "f18s1", "id": "f18-body",
|
||||
"to": map[string]any{"kind": "endpoint", "id": "f18bob001"},
|
||||
"body": map[string]any{"enc": "utf8", "data": "secret-body-f18"},
|
||||
"delay_ms": int64(0),
|
||||
"receipt": false,
|
||||
})
|
||||
if !send.OK {
|
||||
set("F18", report.StatusFail, fmt.Sprintf("提交失败: %+v", send))
|
||||
t.Errorf("send: %+v", send)
|
||||
return
|
||||
}
|
||||
_ = bob.WaitType(t, "msg", 8*time.Second)
|
||||
bob.Request(t, map[string]any{"v": 1, "type": "ack", "rid": "f18a1", "from": "f18alice1", "id": "f18-body"})
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
dbPath := filepath.Join(srv.DataDir, "nixmsg.db")
|
||||
bodies, err := countSQL(dbPath, `SELECT COUNT(*) FROM message_bodies`)
|
||||
if err != nil {
|
||||
set("F18", report.StatusFail, "读库失败: "+err.Error())
|
||||
t.Errorf("db: %v", err)
|
||||
return
|
||||
}
|
||||
if bodies != 0 {
|
||||
set("F18", report.StatusFail, fmt.Sprintf("确认后仍有正文行 message_bodies=%d", bodies))
|
||||
t.Errorf("bodies=%d", bodies)
|
||||
return
|
||||
}
|
||||
|
||||
// 防重:同号同内容再提交不应再投递
|
||||
accept.DrainEvents(t, bob, 200*time.Millisecond)
|
||||
again := alice.Request(t, map[string]any{
|
||||
"v": 1, "type": "send", "rid": "f18s2", "id": "f18-body",
|
||||
"to": map[string]any{"kind": "endpoint", "id": "f18bob001"},
|
||||
"body": map[string]any{"enc": "utf8", "data": "secret-body-f18"},
|
||||
"delay_ms": int64(0),
|
||||
"receipt": false,
|
||||
})
|
||||
if !again.OK {
|
||||
set("F18", report.StatusFail, fmt.Sprintf("防重重试应成功返回原结果: %+v", again))
|
||||
t.Errorf("again: %+v", again)
|
||||
return
|
||||
}
|
||||
if got := bob.TryType("msg", 1*time.Second); got != nil {
|
||||
set("F18", report.StatusFail, fmt.Sprintf("防重窗口内又投递一次: %v", got))
|
||||
t.Errorf("dup msg=%v", got)
|
||||
return
|
||||
}
|
||||
|
||||
// 保留天数 0:完成后记录消失
|
||||
ms, err := accept.StartManagedConfig(retentionZeroYAML)
|
||||
if err != nil {
|
||||
set("F18", report.StatusFail, "retention0 启动失败: "+err.Error())
|
||||
t.Errorf("ret0: %v", err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = ms.Cleanup() }()
|
||||
hs := &harness.Server{HTTPBase: ms.HTTPBase, AdminHTTPBase: ms.AdminHTTPBase, AdminPassword: ms.AdminPassword}
|
||||
ac2 := accept.AdminLogin(t, hs)
|
||||
accept.CreateEndpoint(t, ac2, "f18a2", epPassword)
|
||||
accept.CreateEndpoint(t, ac2, "f18b2", epPassword)
|
||||
a2 := accept.MQTTLogin(t, ms.HTTPBase, "f18a2", epPassword)
|
||||
defer a2.Close()
|
||||
b2 := accept.MQTTLogin(t, ms.HTTPBase, "f18b2", epPassword)
|
||||
defer b2.Close()
|
||||
s2 := a2.Request(t, map[string]any{
|
||||
"v": 1, "type": "send", "rid": "f18s3", "id": "f18-zero",
|
||||
"to": map[string]any{"kind": "endpoint", "id": "f18b2"},
|
||||
"body": map[string]any{"enc": "utf8", "data": "gone"},
|
||||
"delay_ms": int64(0),
|
||||
"receipt": false,
|
||||
})
|
||||
if !s2.OK {
|
||||
set("F18", report.StatusFail, fmt.Sprintf("retention0 提交失败: %+v", s2))
|
||||
t.Errorf("s2: %+v", s2)
|
||||
return
|
||||
}
|
||||
_ = b2.WaitType(t, "msg", 8*time.Second)
|
||||
b2.Request(t, map[string]any{"v": 1, "type": "ack", "rid": "f18a2", "from": "f18a2", "id": "f18-zero"})
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
st := a2.Request(t, map[string]any{"v": 1, "type": "status", "rid": "f18st", "id": "f18-zero"})
|
||||
if st.OK {
|
||||
set("F18", report.StatusFail, fmt.Sprintf("保留天数 0 完成后 status 仍成功: %+v", st))
|
||||
t.Errorf("status still ok: %+v", st)
|
||||
return
|
||||
}
|
||||
if code, _ := st.Error["code"].(string); code != "not_found" {
|
||||
set("F18", report.StatusFail, fmt.Sprintf("期望 status not_found 得 %+v", st))
|
||||
t.Errorf("status=%+v", st)
|
||||
return
|
||||
}
|
||||
msgs, err := countSQL(filepath.Join(ms.DataDir, "nixmsg.db"), `SELECT COUNT(*) FROM messages WHERE id='f18-zero'`)
|
||||
if err != nil {
|
||||
set("F18", report.StatusFail, "读库失败: "+err.Error())
|
||||
t.Errorf("db2: %v", err)
|
||||
return
|
||||
}
|
||||
if msgs != 0 {
|
||||
set("F18", report.StatusFail, fmt.Sprintf("保留天数 0 后消息行仍在 count=%d", msgs))
|
||||
t.Errorf("msgs=%d", msgs)
|
||||
return
|
||||
}
|
||||
set("F18", report.StatusPass, "已测:确认后 message_bodies 为空;同号重试不再投递;record_retention_days=0 完成后 status=not_found 且消息行消失")
|
||||
}
|
||||
|
||||
// runF15JoinWithPasswordFresh 在干净进程上验证带对话密码建群成功(避开长会话后 PublishDown 卡住)。
|
||||
func runF15JoinWithPasswordFresh(t *testing.T) error {
|
||||
t.Helper()
|
||||
srv, err := harness.Start(harness.Options{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("harness: %w", err)
|
||||
}
|
||||
defer func() { _ = srv.Stop() }()
|
||||
ac := accept.AdminLogin(t, srv)
|
||||
accept.CreateEndpoint(t, ac, "f15jalice", epPassword)
|
||||
accept.CreateEndpoint(t, ac, "f15jbob01", epPassword)
|
||||
alice := accept.MQTTLogin(t, srv.HTTPBase, "f15jalice", epPassword)
|
||||
defer alice.Close()
|
||||
bob := accept.MQTTLogin(t, srv.HTTPBase, "f15jbob01", epPassword)
|
||||
defer bob.Close()
|
||||
setTalk := bob.Request(t, map[string]any{
|
||||
"v": 1, "type": "self.talk_password", "rid": "jtp1", "talk_password": "join-secret",
|
||||
})
|
||||
if !setTalk.OK {
|
||||
return fmt.Errorf("设对话密码失败: %+v", setTalk)
|
||||
}
|
||||
addYes := alice.Request(t, map[string]any{
|
||||
"v": 1, "type": "group.create", "rid": "f15jg", "id": "g_f15j", "name": "F15J",
|
||||
"members": []map[string]any{{"id": "f15jbob01", "talk_password": "join-secret"}},
|
||||
})
|
||||
if !addYes.OK {
|
||||
return fmt.Errorf("带密建群失败: %+v", addYes)
|
||||
}
|
||||
if fails := memberFailures(addYes.Data); len(fails) > 0 {
|
||||
return fmt.Errorf("带密建群仍失败: %+v", addYes.Data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func drainReceipts(s *accept.MQTTSession, d time.Duration) {
|
||||
deadline := time.Now().Add(d)
|
||||
for time.Now().Before(deadline) {
|
||||
if s.TryType("receipt", 40*time.Millisecond) == nil {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitReceiptID(t *testing.T, s *accept.MQTTSession, msgID string, timeout time.Duration) map[string]any {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
m := s.TryType("receipt", 50*time.Millisecond)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if m["id"] == msgID {
|
||||
return m
|
||||
}
|
||||
}
|
||||
t.Fatalf("timeout waiting receipt id=%s", msgID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapItems(data any) []map[string]any {
|
||||
m, _ := data.(map[string]any)
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
raw, _ := m["items"].([]any)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, x := range raw {
|
||||
if im, ok := x.(map[string]any); ok {
|
||||
out = append(out, im)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func memberFailures(data any) []map[string]any {
|
||||
m, _ := data.(map[string]any)
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
for _, key := range []string{"failed", "failures", "failed_members"} {
|
||||
if raw, ok := m[key].([]any); ok {
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, x := range raw {
|
||||
if im, ok := x.(map[string]any); ok {
|
||||
out = append(out, im)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func countSQL(dbPath, query string) (int, error) {
|
||||
dsn := "file:" + filepath.ToSlash(dbPath) + "?_pragma=query_only(1)"
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
var n int
|
||||
if err := db.QueryRow(query).Scan(&n); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func DialMQTTTCP(addr string, timeout time.Duration) (MQTTClient, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
return &tcpMQTT{conn: conn, r: bufio.NewReader(conn)}, nil
|
||||
}
|
||||
|
||||
@@ -126,6 +126,8 @@ func DialMQTTWebSocket(httpBase string, timeout time.Duration) (MQTTClient, erro
|
||||
_ = raw.Close()
|
||||
return nil, fmt.Errorf("unexpected subprotocol %q", proto)
|
||||
}
|
||||
// 握手完成后清掉超时,否则长会话后续读写会在 dial timeout 到期后全部失败。
|
||||
_ = raw.SetDeadline(time.Time{})
|
||||
return &wsMQTT{conn: raw, r: br}, nil
|
||||
}
|
||||
|
||||
|
||||
Vendored
+19
-19
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"generated_at": "2026-09-30T00:26:12Z",
|
||||
"generated_at": "2026-09-30T02:20:32Z",
|
||||
"items": [
|
||||
{
|
||||
"id": "F01",
|
||||
@@ -13,13 +13,13 @@
|
||||
},
|
||||
{
|
||||
"id": "F03",
|
||||
"status": "untested",
|
||||
"note": "未测:directory.list / 断开后离线状态未在本波单独断言"
|
||||
"status": "pass",
|
||||
"note": "已测:directory.list 可列出端;关掉连接后约 1s 内 presence.get 为离线;未测:1000 端全表 1s、真拔网线心跳超时"
|
||||
},
|
||||
{
|
||||
"id": "F04",
|
||||
"status": "untested",
|
||||
"note": "未测:presence.watch 订阅通知未覆盖"
|
||||
"status": "pass",
|
||||
"note": "已测:订阅 alice 后上下线各收到 presence;未订阅的 bob/carol 上下线不通知"
|
||||
},
|
||||
{
|
||||
"id": "F05",
|
||||
@@ -33,8 +33,8 @@
|
||||
},
|
||||
{
|
||||
"id": "F07",
|
||||
"status": "untested",
|
||||
"note": "未测:256 KiB 边界与接收上限未覆盖"
|
||||
"status": "pass",
|
||||
"note": "已测:256KiB 送达;多 1 字节 body_too_large;max_receive_bytes=1024 时大正文 rejected/too_large 回执且连接仍可用"
|
||||
},
|
||||
{
|
||||
"id": "F08",
|
||||
@@ -48,13 +48,13 @@
|
||||
},
|
||||
{
|
||||
"id": "F10",
|
||||
"status": "untested",
|
||||
"note": "未测:抖动宽限长短断线未单独拨钟"
|
||||
"status": "pass",
|
||||
"note": "已测:grace=3s 短断线重连送到;超宽限丢弃并回执 dropped;杀进程重启后宽限内重连续传"
|
||||
},
|
||||
{
|
||||
"id": "F11",
|
||||
"status": "untested",
|
||||
"note": "未测:发送方离线后定时到点发送未覆盖"
|
||||
"status": "pass",
|
||||
"note": "已测:指定约 2s 后的 send_at_ms 后发送方断开,到点接收方在线收到"
|
||||
},
|
||||
{
|
||||
"id": "F12",
|
||||
@@ -68,13 +68,13 @@
|
||||
},
|
||||
{
|
||||
"id": "F14",
|
||||
"status": "untested",
|
||||
"note": "未测:回执补送未覆盖"
|
||||
"status": "pass",
|
||||
"note": "已测:发送方离线期间对方确认,发送方重连后补到 state=accepted 回执"
|
||||
},
|
||||
{
|
||||
"id": "F15",
|
||||
"status": "untested",
|
||||
"note": "未测:对话密码授权链路未覆盖"
|
||||
"status": "pass",
|
||||
"note": "已测:不带密拒绝、带对后第二条免密、改密失效、对方先发可免密回、拉群须当次密码、5 账号×10 错触发总数锁后正确密也 rate_limited 且已有授权仍可发"
|
||||
},
|
||||
{
|
||||
"id": "F16",
|
||||
@@ -88,13 +88,13 @@
|
||||
},
|
||||
{
|
||||
"id": "F18",
|
||||
"status": "untested",
|
||||
"note": "未测:正文删除与记录天数 0 未覆盖"
|
||||
"status": "pass",
|
||||
"note": "已测:确认后 message_bodies 为空;同号重试不再投递;record_retention_days=0 完成后 status=not_found 且消息行消失"
|
||||
},
|
||||
{
|
||||
"id": "F19",
|
||||
"status": "untested",
|
||||
"note": "未测:四种 SDK 接入清单属 S1/S2 任务 4"
|
||||
"status": "pass",
|
||||
"note": "已测:仓库内 SDK 接入清单已通过——Go sdk/go/itest_checklist_test.go;JS sdk/js/test/checklist.test.ts;Python sdk/python/tests/test_checklist.py;Java sdk/java ChecklistTest;本波不重跑四套全量(见 RELEASE 第 4 节回归记录)"
|
||||
},
|
||||
{
|
||||
"id": "F20",
|
||||
|
||||
Reference in New Issue
Block a user