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) } }