138 lines
6.4 KiB
Go
138 lines
6.4 KiB
Go
package onboarding
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/json"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"data-terminal-cloud/internal/callmemory"
|
||
|
|
"data-terminal-cloud/internal/volcsubtitle"
|
||
|
|
"github.com/google/uuid"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Real database + real HTTP signature/authentication + fake supplier boundary.
|
||
|
|
// The target device is newly manufactured and never calls any startup endpoint.
|
||
|
|
func TestDirectHTTPBindChatWithoutStartupRegistration(t *testing.T) {
|
||
|
|
f := serverFixture(t)
|
||
|
|
in := InventoryInput{FactoryMAC: strings.ToUpper(strings.ReplaceAll(uuid.NewString(), "-", ""))[:12], Model: "KP01", HardwareRevision: "A1", BatchNo: "direct-no-startup"}
|
||
|
|
_, err := f.s.CreateInventory(f.ctx, f.p, f.tenantPublic, uuid.NewString(), uuid.NewString(), requestJSON(in), in)
|
||
|
|
must(t, err)
|
||
|
|
f.d, err = f.s.findFactoryDevice(f.ctx, "KP01-"+in.FactoryMAC, 1)
|
||
|
|
must(t, err)
|
||
|
|
if f.d.BootID != "" {
|
||
|
|
t.Fatal("new target already has a boot registration")
|
||
|
|
}
|
||
|
|
mux := http.NewServeMux()
|
||
|
|
f.s.RegisterRoutes(mux)
|
||
|
|
chat := callmemory.NewService(f.s.pool, nil)
|
||
|
|
secret := []byte(strings.Repeat("test-callback-", 3))
|
||
|
|
chat.ConfigureDirect(callmemory.DirectOptions{CallbackURL: "https://example.com/callbacks/v1/volcengine/chat", CallbackSecret: secret})
|
||
|
|
chat.RegisterDirectRoutes(mux, f.s.AuthenticateCallRequest)
|
||
|
|
mux.HandleFunc("POST /callbacks/v1/volcengine/chat", chat.DirectCallback)
|
||
|
|
post := func(path string, body any, rid string) (int, map[string]any) {
|
||
|
|
t.Helper()
|
||
|
|
if rid == "" {
|
||
|
|
rid = uuid.NewString()
|
||
|
|
}
|
||
|
|
raw := requestJSON(body)
|
||
|
|
r := httptest.NewRequest("POST", "https://platform.example"+path, bytes.NewReader(raw))
|
||
|
|
nonce, timestamp := randomToken(16), time.Now().UnixMilli()
|
||
|
|
r.Header.Set("Content-Type", "application/json")
|
||
|
|
r.Header.Set("X-Device-ID", f.d.FactoryID)
|
||
|
|
r.Header.Set("X-Key-Version", "1")
|
||
|
|
r.Header.Set("X-Timestamp-Ms", strconv.FormatInt(timestamp, 10))
|
||
|
|
r.Header.Set("X-Nonce", nonce)
|
||
|
|
r.Header.Set("X-Request-ID", rid)
|
||
|
|
r.Header.Set("Authorization", "Device-HMAC-SHA256 "+RequestMAC(f.d.Key, "POST", path, f.d.FactoryID, 1, timestamp, nonce, rid, raw))
|
||
|
|
w := httptest.NewRecorder()
|
||
|
|
mux.ServeHTTP(w, r)
|
||
|
|
var result map[string]any
|
||
|
|
must(t, json.Unmarshal(w.Body.Bytes(), &result))
|
||
|
|
return w.Code, result
|
||
|
|
}
|
||
|
|
status, result := post("/api/v1/device/chat/start", map[string]any{"device_id": f.d.FactoryID, "session_id": uuid.NewString()}, "")
|
||
|
|
if status != 409 || result["code"] != "DEVICE_UNBOUND" {
|
||
|
|
t.Fatal("unbound start", status, result["code"])
|
||
|
|
}
|
||
|
|
token, rid := f.bleToken(), uuid.NewString()
|
||
|
|
for i := 0; i < 2; i++ {
|
||
|
|
status, result = post("/api/v1/device/ble-bind", BLEBindInput{Token: token}, rid)
|
||
|
|
if status != 200 {
|
||
|
|
t.Fatal("bind HTTP failure", status, result["code"])
|
||
|
|
}
|
||
|
|
data := result["data"].(map[string]any)
|
||
|
|
for _, field := range []string{"instance_id", "product_key", "device_name", "device_secret", "RTCAppID"} {
|
||
|
|
if data[field] == nil || data[field] == "" {
|
||
|
|
t.Fatal("missing binding credential", field)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if f.registrar.calls.Load() != 1 {
|
||
|
|
t.Fatal("binding retry registered supplier twice")
|
||
|
|
}
|
||
|
|
var subject, user string
|
||
|
|
must(t, f.s.pool.QueryRow(f.ctx, `SELECT b.subject_id::text,b.bound_by::text FROM device_binding b WHERE b.device_id=$1::uuid AND b.unbound_at IS NULL`, f.d.ID).Scan(&subject, &user))
|
||
|
|
_, err = f.s.pool.Exec(f.ctx, `INSERT INTO subject_voice_agent(subject_id,bot_name,bot_id,status) VALUES($1::uuid,$2,$2,'READY')`, subject, "flow-bot-"+subject)
|
||
|
|
must(t, err)
|
||
|
|
// Explicit test consent lets us verify the callback is persisted, not just acknowledged.
|
||
|
|
_, err = f.s.pool.Exec(f.ctx, `INSERT INTO consent_record(tenant_id,subject_id,consent_type,scope,policy_version,notice_digest,actor_user_id,evidence_ref) SELECT $1::uuid,$2::uuid,consent_type,scope,policy_version,notice_digest,$3::uuid,evidence_ref FROM consent_record WHERE subject_id='10000000-0000-0000-0000-000000000401' AND withdrawn_at IS NULL`, f.tenant, subject, user)
|
||
|
|
must(t, err)
|
||
|
|
sid := uuid.NewString()
|
||
|
|
body := map[string]any{"device_id": f.d.FactoryID, "session_id": sid}
|
||
|
|
status, result = post("/api/v1/device/chat/start", body, "")
|
||
|
|
if status != 200 {
|
||
|
|
t.Fatal("start without startup endpoints failed", status, result["code"])
|
||
|
|
}
|
||
|
|
first := result["data"].(map[string]any)
|
||
|
|
status, result = post("/api/v1/device/chat/start", body, "")
|
||
|
|
if status != 200 || string(requestJSON(first["params"])) != string(requestJSON(result["data"].(map[string]any)["params"])) {
|
||
|
|
t.Fatal("retry/renewal changed parameters", status)
|
||
|
|
}
|
||
|
|
n := int64(1)
|
||
|
|
packet := volcsubtitle.Packet{Type: "subtitle", Data: []volcsubtitle.Segment{{UserID: "bot_20260914095023000", RoundID: &n, Sequence: &n, Text: "你好", Definite: true}}}
|
||
|
|
envelope := volcsubtitle.Envelope{Message: base64.StdEncoding.EncodeToString(requestJSON(packet)), Signature: volcsubtitle.Signature(secret, "direct:"+sid)}
|
||
|
|
for i := 0; i < 2; i++ {
|
||
|
|
r := httptest.NewRequest("POST", "/callbacks/v1/volcengine/chat?session_id="+sid, bytes.NewReader(requestJSON(envelope)))
|
||
|
|
w := httptest.NewRecorder()
|
||
|
|
mux.ServeHTTP(w, r)
|
||
|
|
if w.Code != 200 {
|
||
|
|
t.Fatal("callback failed", w.Code)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
var count int
|
||
|
|
must(t, f.s.pool.QueryRow(f.ctx, `SELECT count(*) FROM call_utterance WHERE session_id=$1::uuid`, sid).Scan(&count))
|
||
|
|
if count != 1 {
|
||
|
|
t.Fatal("callback record duplicated or missing", count)
|
||
|
|
}
|
||
|
|
end := map[string]any{"device_id": f.d.FactoryID, "session_id": sid, "reason": "hangup", "sdk_stopped": true}
|
||
|
|
for i := 0; i < 2; i++ {
|
||
|
|
status, result = post("/api/v1/device/chat/end", end, "")
|
||
|
|
if status != 200 {
|
||
|
|
t.Fatal("end failed", status, result["code"])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
var lifecycle string
|
||
|
|
var noHandshake bool
|
||
|
|
must(t, f.s.pool.QueryRow(f.ctx, `SELECT d.activation_state,d.onboarding_boot_id IS NULL AND j.config_ack_at IS NULL AND j.engine_ready_at IS NULL FROM device d JOIN device_activation_job j ON j.device_id=d.id WHERE d.id=$1::uuid`, f.d.ID).Scan(&lifecycle, &noHandshake))
|
||
|
|
if lifecycle != "ACTIVE" || !noHandshake {
|
||
|
|
t.Fatal("old handshake required or fabricated", lifecycle, noHandshake)
|
||
|
|
}
|
||
|
|
_, err = f.s.pool.Exec(f.ctx, `UPDATE device_binding SET unbound_at=now() WHERE device_id=$1::uuid AND unbound_at IS NULL`, f.d.ID)
|
||
|
|
must(t, err)
|
||
|
|
body["session_id"] = uuid.NewString()
|
||
|
|
status, result = post("/api/v1/device/chat/start", body, "")
|
||
|
|
if status != 409 || result["code"] != "DEVICE_UNBOUND" {
|
||
|
|
t.Fatal("unbound start not rejected", status)
|
||
|
|
}
|
||
|
|
status, result = post("/api/v1/device/chat/end", end, "")
|
||
|
|
if status != 200 {
|
||
|
|
t.Fatal("end after unbind failed", status)
|
||
|
|
}
|
||
|
|
}
|