Implement Phase 1 A1/A2 APIs and record build plan in roadmap
Add conversation/contact/history/escalation-log endpoints, contact-scoped whitelist matching, bearer sessions, ADMIN_API_TOKEN guards, and `go run . migrate`. Update Flutter client to persist/send session tokens and mark A1/A2 complete in the prioritized checklist. Co-authored-by: okuma <o0kuma@users.noreply.github.com>
This commit is contained in:
parent
ace92e736a
commit
b288d5ed90
|
|
@ -39,7 +39,12 @@ export GEMINI_API_KEY=... # or repo-root .env
|
||||||
uvicorn app.main:app --port 8001
|
uvicorn app.main:app --port 8001
|
||||||
|
|
||||||
# 터미널 2 — 코어 백엔드
|
# 터미널 2 — 코어 백엔드
|
||||||
cd core-backend && go run .
|
cd core-backend
|
||||||
|
export ADMIN_API_TOKEN=dev-admin-token
|
||||||
|
go run . migrate && go run .
|
||||||
|
|
||||||
|
# 초대 코드 발급 예:
|
||||||
|
# curl -X POST http://localhost:8080/invites -H "Authorization: Bearer $ADMIN_API_TOKEN"
|
||||||
|
|
||||||
# 터미널 3 — Flutter (Android)
|
# 터미널 3 — Flutter (Android)
|
||||||
cd mobile && flutter run --dart-define=CORE_API_BASE=http://10.0.2.2:8080
|
cd mobile && flutter run --dart-define=CORE_API_BASE=http://10.0.2.2:8080
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ Go(Gin + gorilla/websocket + GORM), PostgreSQL(프로덕션)/SQLite(로컬 개
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go mod download
|
go mod download
|
||||||
|
export ADMIN_API_TOKEN="dev-admin-token" # required for /invites and /admin/metrics
|
||||||
|
go run . migrate # explicit schema migrate (also runs on startup)
|
||||||
go run .
|
go run .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -23,6 +25,12 @@ AI 서비스(`../ai-service/`)를 호출하려면:
|
||||||
export AI_SERVICE_URL="http://localhost:8001" # 기본값도 이 주소
|
export AI_SERVICE_URL="http://localhost:8001" # 기본값도 이 주소
|
||||||
```
|
```
|
||||||
|
|
||||||
|
인증:
|
||||||
|
|
||||||
|
- 가입 `POST /auth/signup` / 로그인 `POST /auth/login` → `{token}` (Bearer)
|
||||||
|
- 사용자 스코프 API(`PATCH /users/:id/...`, contacts, conversations 목록 등)는 Bearer 필요
|
||||||
|
- `/invites`, `/admin/metrics`는 `Authorization: Bearer $ADMIN_API_TOKEN`
|
||||||
|
|
||||||
## 테스트
|
## 테스트
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -109,18 +117,10 @@ go test ./... -v
|
||||||
|
|
||||||
## 아직 없는 것 (다음 워크스트림)
|
## 아직 없는 것 (다음 워크스트림)
|
||||||
|
|
||||||
- 상대별(`ContactID`) 화이트리스트/자율성 예외 매칭 (CRUD로 저장은 되지만 발송 시 매칭 로직은
|
- 되돌리기/사후알림 UX 고도화 (Flutter A3)
|
||||||
아직 전역 키워드만 봄 — 대화방↔연락처 연결 모델링 필요)
|
|
||||||
- 되돌리기 "UI" (API·브로드캐스트는 됨 — 사용자에게 사후 알림을 띄우고 되돌리기 버튼을 보여주는
|
|
||||||
건 Flutter 쪽)
|
|
||||||
- 에스컬레이션 로그 조회 API (`escalation_logs`는 계속 쌓이지만, 사용자가 "본인 확인이 필요했던
|
|
||||||
목록"을 조회하는 API는 아직 없음 — 필요해지면 추가)
|
|
||||||
- 온디바이스 말투 이력 저장 + 서버 최소 전송 (클라이언트 책임)
|
- 온디바이스 말투 이력 저장 + 서버 최소 전송 (클라이언트 책임)
|
||||||
- 데이터 흐름 대시보드, 온디바이스 암호화 (둘 다 Flutter 클라이언트 책임 — 이 저장소엔 SDK 없어
|
- 데이터 흐름 대시보드, 온디바이스 암호화 (Flutter)
|
||||||
로컬 환경에서 진행)
|
- 생성 지연시간·오류율 계측
|
||||||
- 생성 지연시간·오류율 계측 (요청 타이밍/로깅 계층 필요, `/admin/metrics`는 카운트만 있음)
|
|
||||||
- `/invites`·`/admin/metrics` 접근 제어 (지금은 인증이 없어 누구나 호출 가능 — 아래 인증 항목과 같이 해결)
|
|
||||||
- 푸시 알림 연동
|
- 푸시 알림 연동
|
||||||
- 인증 토큰/세션 (지금은 invite_code로 가입만 되고 로그인 세션 개념이 없음)
|
- 멀티 디바이스 동기화
|
||||||
- 프로덕션 마이그레이션 도구 (지금은 `AutoMigrate`로 시작 시 테이블 생성 — 스키마 안정되면 Atlas/golang-migrate 등 도입)
|
- Atlas/golang-migrate 등 버전드 마이그레이션으로 승격 (지금은 `go run . migrate` + AutoMigrate)
|
||||||
- 멀티 디바이스 동기화 (같은 유저가 여러 기기로 접속하는 경우)
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,345 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type createConversationRequest struct {
|
||||||
|
UserIDs []uint `json:"user_ids" binding:"required"`
|
||||||
|
IsGroup bool `json:"is_group"`
|
||||||
|
ContactID *uint `json:"contact_id"` // optional: owner's contact for the peer (DM)
|
||||||
|
}
|
||||||
|
|
||||||
|
type createContactRequest struct {
|
||||||
|
DisplayName string `json:"display_name" binding:"required"`
|
||||||
|
ContactUserID *uint `json:"contact_user_id"`
|
||||||
|
RelationshipNote string `json:"relationship_note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginRequest struct {
|
||||||
|
InviteCode string `json:"invite_code" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerA1A2Routes(r *gin.Engine, db *gorm.DB) {
|
||||||
|
r.POST("/auth/login", func(c *gin.Context) {
|
||||||
|
var req loginRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var invite InviteCode
|
||||||
|
if err := db.Where("code = ?", req.InviteCode).First(&invite).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid invite code"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if invite.UsedAt == nil || invite.UsedByUserID == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "invite code not yet used for signup"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var user User
|
||||||
|
if err := db.First(&user, *invite.UsedByUserID).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"detail": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session, err := createSession(db, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"id": user.ID,
|
||||||
|
"display_name": user.DisplayName,
|
||||||
|
"token": session.Token,
|
||||||
|
"expires_at": session.ExpiresAt,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.POST("/conversations", func(c *gin.Context) {
|
||||||
|
actor, ok := currentUser(c, db, true)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req createConversationRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.UserIDs) < 1 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "user_ids must include at least one participant"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Creator must be a participant.
|
||||||
|
includesSelf := false
|
||||||
|
for _, id := range req.UserIDs {
|
||||||
|
if id == actor.ID {
|
||||||
|
includesSelf = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !includesSelf {
|
||||||
|
req.UserIDs = append(req.UserIDs, actor.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ContactID != nil {
|
||||||
|
var contact Contact
|
||||||
|
if err := db.Where("id = ? AND owner_user_id = ?", *req.ContactID, actor.ID).First(&contact).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "contact_id must belong to the authenticated user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var conv Conversation
|
||||||
|
err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
conv = Conversation{IsGroup: req.IsGroup}
|
||||||
|
if err := tx.Create(&conv).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
seen := map[uint]bool{}
|
||||||
|
for _, uid := range req.UserIDs {
|
||||||
|
if seen[uid] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[uid] = true
|
||||||
|
var user User
|
||||||
|
if err := tx.First(&user, uid).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Create(&ConversationParticipant{ConversationID: conv.ID, UserID: uid}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Link owner's contact → conversation via RelationshipNote field? No —
|
||||||
|
// store on Contact by setting ContactUserID already; for whitelist we
|
||||||
|
// resolve peer via participants. Optionally stamp contact's linked user.
|
||||||
|
if req.ContactID != nil {
|
||||||
|
// Ensure contact points at the other participant when possible.
|
||||||
|
var contact Contact
|
||||||
|
if err := tx.First(&contact, *req.ContactID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, uid := range req.UserIDs {
|
||||||
|
if uid != actor.ID {
|
||||||
|
contact.ContactUserID = &uid
|
||||||
|
if err := tx.Save(&contact).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"id": conv.ID,
|
||||||
|
"is_group": conv.IsGroup,
|
||||||
|
"user_ids": req.UserIDs,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.GET("/conversations", func(c *gin.Context) {
|
||||||
|
actor, ok := currentUser(c, db, true)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := actor.ID
|
||||||
|
if q := c.Query("user_id"); q != "" {
|
||||||
|
parsed, err := strconv.ParseUint(q, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": "user_id must be a positive integer"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if uint(parsed) != actor.ID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"detail": "can only list your own conversations"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID = uint(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts []ConversationParticipant
|
||||||
|
db.Where("user_id = ?", userID).Find(&parts)
|
||||||
|
out := make([]gin.H, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
var conv Conversation
|
||||||
|
if err := db.First(&conv, p.ConversationID).Error; err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var participantIDs []uint
|
||||||
|
var pps []ConversationParticipant
|
||||||
|
db.Where("conversation_id = ?", conv.ID).Find(&pps)
|
||||||
|
for _, pp := range pps {
|
||||||
|
participantIDs = append(participantIDs, pp.UserID)
|
||||||
|
}
|
||||||
|
out = append(out, gin.H{
|
||||||
|
"id": conv.ID,
|
||||||
|
"is_group": conv.IsGroup,
|
||||||
|
"twin_disabled_by_peer": conv.TwinDisabledByPeer,
|
||||||
|
"user_ids": participantIDs,
|
||||||
|
"created_at": conv.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"conversations": out})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.GET("/conversations/:id/messages", func(c *gin.Context) {
|
||||||
|
actor, ok := currentUser(c, db, true)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
convID, ok := parseUintParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isParticipant(db, convID, actor.ID) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"detail": "not a participant of this conversation"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var messages []Message
|
||||||
|
db.Where("conversation_id = ?", convID).Order("id asc").Find(&messages)
|
||||||
|
out := make([]gin.H, 0, len(messages))
|
||||||
|
for _, m := range messages {
|
||||||
|
out = append(out, gin.H{
|
||||||
|
"id": m.ID,
|
||||||
|
"conversation_id": m.ConversationID,
|
||||||
|
"sender_id": m.SenderID,
|
||||||
|
"sender_mode": m.SenderMode,
|
||||||
|
"text": m.Text,
|
||||||
|
"retracted": m.Retracted,
|
||||||
|
"created_at": m.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"messages": out})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.POST("/users/:id/contacts", func(c *gin.Context) {
|
||||||
|
userID, ok := parseUintParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireSelf(c, db, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req createContactRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contact := Contact{
|
||||||
|
OwnerUserID: userID,
|
||||||
|
ContactUserID: req.ContactUserID,
|
||||||
|
DisplayName: req.DisplayName,
|
||||||
|
RelationshipNote: req.RelationshipNote,
|
||||||
|
}
|
||||||
|
if err := db.Create(&contact).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"id": contact.ID,
|
||||||
|
"display_name": contact.DisplayName,
|
||||||
|
"contact_user_id": contact.ContactUserID,
|
||||||
|
"relationship_note": contact.RelationshipNote,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.GET("/users/:id/contacts", func(c *gin.Context) {
|
||||||
|
userID, ok := parseUintParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireSelf(c, db, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var contacts []Contact
|
||||||
|
db.Where("owner_user_id = ?", userID).Order("id").Find(&contacts)
|
||||||
|
out := make([]gin.H, 0, len(contacts))
|
||||||
|
for _, ct := range contacts {
|
||||||
|
out = append(out, gin.H{
|
||||||
|
"id": ct.ID,
|
||||||
|
"display_name": ct.DisplayName,
|
||||||
|
"contact_user_id": ct.ContactUserID,
|
||||||
|
"relationship_note": ct.RelationshipNote,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"contacts": out})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.DELETE("/users/:id/contacts/:contactId", func(c *gin.Context) {
|
||||||
|
userID, ok := parseUintParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireSelf(c, db, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contactID, ok := parseUintParam(c, "contactId")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var contact Contact
|
||||||
|
if err := db.Where("id = ? AND owner_user_id = ?", contactID, userID).First(&contact).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"detail": "contact not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db.Delete(&contact)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.GET("/users/:id/escalation-logs", func(c *gin.Context) {
|
||||||
|
userID, ok := parseUintParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireSelf(c, db, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var logs []EscalationLog
|
||||||
|
db.Where("user_id = ?", userID).Order("id desc").Find(&logs)
|
||||||
|
out := make([]gin.H, 0, len(logs))
|
||||||
|
for _, l := range logs {
|
||||||
|
out = append(out, gin.H{
|
||||||
|
"id": l.ID,
|
||||||
|
"conversation_id": l.ConversationID,
|
||||||
|
"reason": l.Reason,
|
||||||
|
"message_snippet": l.MessageSnippet,
|
||||||
|
"resolved": l.Resolved,
|
||||||
|
"created_at": l.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"escalation_logs": out})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func isParticipant(db *gorm.DB, conversationID, userID uint) bool {
|
||||||
|
var n int64
|
||||||
|
db.Model(&ConversationParticipant{}).
|
||||||
|
Where("conversation_id = ? AND user_id = ?", conversationID, userID).
|
||||||
|
Count(&n)
|
||||||
|
return n > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePeerContactID finds the owner's Contact row for the other party in
|
||||||
|
// a conversation (Contact.ContactUserID == other participant).
|
||||||
|
func resolvePeerContactID(db *gorm.DB, ownerUserID, conversationID uint) *uint {
|
||||||
|
var parts []ConversationParticipant
|
||||||
|
db.Where("conversation_id = ?", conversationID).Find(&parts)
|
||||||
|
for _, p := range parts {
|
||||||
|
if p.UserID == ownerUserID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var contact Contact
|
||||||
|
if err := db.Where("owner_user_id = ? AND contact_user_id = ?", ownerUserID, p.UserID).First(&contact).Error; err == nil {
|
||||||
|
id := contact.ID
|
||||||
|
return &id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInvitesRequireAdminToken(t *testing.T) {
|
||||||
|
server, _ := setupTestServer(t)
|
||||||
|
resp := postJSON(t, server.URL+"/invites", nil)
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected 401 without admin token, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginReturnsSessionToken(t *testing.T) {
|
||||||
|
server, _ := setupTestServer(t)
|
||||||
|
code := mintInvite(t, server.URL)
|
||||||
|
signup := postJSON(t, server.URL+"/auth/signup", signupRequest{InviteCode: code, DisplayName: "로그인"})
|
||||||
|
if signup.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("signup: %d", signup.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
login := postJSON(t, server.URL+"/auth/login", loginRequest{InviteCode: code})
|
||||||
|
if login.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 login, got %d", login.StatusCode)
|
||||||
|
}
|
||||||
|
var out map[string]interface{}
|
||||||
|
json.NewDecoder(login.Body).Decode(&out)
|
||||||
|
if out["token"] == nil || out["token"] == "" {
|
||||||
|
t.Fatalf("login missing token: %v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConversationContactMessageHistoryAndEscalationLogs(t *testing.T) {
|
||||||
|
server, _ := setupTestServer(t)
|
||||||
|
ownerID, ownerToken := mustSignup(t, server.URL, "주인")
|
||||||
|
peerID, _ := mustSignup(t, server.URL, "상대")
|
||||||
|
|
||||||
|
// Create contact linking peer user.
|
||||||
|
contactResp := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
|
||||||
|
DisplayName: "상대방",
|
||||||
|
ContactUserID: &peerID,
|
||||||
|
})
|
||||||
|
if contactResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("create contact: %d", contactResp.StatusCode)
|
||||||
|
}
|
||||||
|
var contact map[string]interface{}
|
||||||
|
json.NewDecoder(contactResp.Body).Decode(&contact)
|
||||||
|
contactID := uint(contact["id"].(float64))
|
||||||
|
|
||||||
|
convResp := postJSONAuth(t, server.URL+"/conversations", ownerToken, createConversationRequest{
|
||||||
|
UserIDs: []uint{ownerID, peerID},
|
||||||
|
ContactID: &contactID,
|
||||||
|
})
|
||||||
|
if convResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("create conversation: %d", convResp.StatusCode)
|
||||||
|
}
|
||||||
|
var conv map[string]interface{}
|
||||||
|
json.NewDecoder(convResp.Body).Decode(&conv)
|
||||||
|
convID := uint(conv["id"].(float64))
|
||||||
|
|
||||||
|
listResp, _ := http.NewRequest(http.MethodGet, server.URL+"/conversations", nil)
|
||||||
|
listResp.Header.Set("Authorization", "Bearer "+ownerToken)
|
||||||
|
listed, err := http.DefaultClient.Do(listResp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var listOut map[string]interface{}
|
||||||
|
json.NewDecoder(listed.Body).Decode(&listOut)
|
||||||
|
convs := listOut["conversations"].([]interface{})
|
||||||
|
if len(convs) != 1 {
|
||||||
|
t.Fatalf("expected 1 conversation, got %v", listOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", sendMessageRequest{
|
||||||
|
SenderID: ownerID,
|
||||||
|
Text: "안녕 저녁 어때",
|
||||||
|
})
|
||||||
|
|
||||||
|
histReq, _ := http.NewRequest(http.MethodGet, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", nil)
|
||||||
|
histReq.Header.Set("Authorization", "Bearer "+ownerToken)
|
||||||
|
histResp, err := http.DefaultClient.Do(histReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if histResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("history: %d", histResp.StatusCode)
|
||||||
|
}
|
||||||
|
var hist map[string]interface{}
|
||||||
|
json.NewDecoder(histResp.Body).Decode(&hist)
|
||||||
|
if len(hist["messages"].([]interface{})) != 1 {
|
||||||
|
t.Fatalf("expected 1 message, got %v", hist)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force an escalation log via twin send.
|
||||||
|
setAutonomyLevel(t, server.URL, ownerID, ownerToken, AutonomyL1)
|
||||||
|
postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(convID), 10)+"/messages", sendMessageRequest{
|
||||||
|
SenderID: ownerID,
|
||||||
|
Text: "계좌번호 알려줄게",
|
||||||
|
SenderMode: SenderTwin,
|
||||||
|
Approved: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
logReq, _ := http.NewRequest(http.MethodGet, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/escalation-logs", nil)
|
||||||
|
logReq.Header.Set("Authorization", "Bearer "+ownerToken)
|
||||||
|
logResp, err := http.DefaultClient.Do(logReq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if logResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("escalation logs: %d", logResp.StatusCode)
|
||||||
|
}
|
||||||
|
var logs map[string]interface{}
|
||||||
|
json.NewDecoder(logResp.Body).Decode(&logs)
|
||||||
|
if len(logs["escalation_logs"].([]interface{})) < 1 {
|
||||||
|
t.Fatalf("expected escalation log, got %v", logs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContactScopedWhitelistMatch(t *testing.T) {
|
||||||
|
server, db := setupTestServer(t)
|
||||||
|
ownerID, ownerToken := mustSignup(t, server.URL, "화이트주인")
|
||||||
|
peerID, _ := mustSignup(t, server.URL, "화이트상대")
|
||||||
|
otherID, _ := mustSignup(t, server.URL, "다른사람")
|
||||||
|
|
||||||
|
contactResp := postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/contacts", ownerToken, createContactRequest{
|
||||||
|
DisplayName: "상대",
|
||||||
|
ContactUserID: &peerID,
|
||||||
|
})
|
||||||
|
var contact map[string]interface{}
|
||||||
|
json.NewDecoder(contactResp.Body).Decode(&contact)
|
||||||
|
contactID := uint(contact["id"].(float64))
|
||||||
|
|
||||||
|
// Rule only for this contact + keyword 저녁
|
||||||
|
cid := contactID
|
||||||
|
postJSONAuth(t, server.URL+"/users/"+strconv.FormatUint(uint64(ownerID), 10)+"/whitelist-rules", ownerToken, createWhitelistRuleRequest{
|
||||||
|
ContactID: &cid,
|
||||||
|
TopicKeyword: "저녁",
|
||||||
|
})
|
||||||
|
setAutonomyLevel(t, server.URL, ownerID, ownerToken, AutonomyL2)
|
||||||
|
|
||||||
|
// Conversation with the whitelisted peer -- should auto-send.
|
||||||
|
convPeer := postJSONAuth(t, server.URL+"/conversations", ownerToken, createConversationRequest{
|
||||||
|
UserIDs: []uint{ownerID, peerID},
|
||||||
|
ContactID: &contactID,
|
||||||
|
})
|
||||||
|
var conv1 map[string]interface{}
|
||||||
|
json.NewDecoder(convPeer.Body).Decode(&conv1)
|
||||||
|
conv1ID := uint(conv1["id"].(float64))
|
||||||
|
|
||||||
|
okResp := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv1ID), 10)+"/messages", sendMessageRequest{
|
||||||
|
SenderID: ownerID,
|
||||||
|
Text: "오늘 저녁 뭐 먹을래",
|
||||||
|
SenderMode: SenderTwin,
|
||||||
|
})
|
||||||
|
if okResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("expected L2 auto-send for matching contact+keyword, got %d", okResp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conversation with a different peer -- same keyword must NOT match contact-scoped rule.
|
||||||
|
convOther := postJSONAuth(t, server.URL+"/conversations", ownerToken, createConversationRequest{
|
||||||
|
UserIDs: []uint{ownerID, otherID},
|
||||||
|
})
|
||||||
|
var conv2 map[string]interface{}
|
||||||
|
json.NewDecoder(convOther.Body).Decode(&conv2)
|
||||||
|
conv2ID := uint(conv2["id"].(float64))
|
||||||
|
|
||||||
|
blocked := postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv2ID), 10)+"/messages", sendMessageRequest{
|
||||||
|
SenderID: ownerID,
|
||||||
|
Text: "오늘 저녁 뭐 먹을래",
|
||||||
|
SenderMode: SenderTwin,
|
||||||
|
})
|
||||||
|
if blocked.StatusCode != http.StatusForbidden {
|
||||||
|
t.Fatalf("expected 403 when contact-scoped rule does not apply, got %d", blocked.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var n int64
|
||||||
|
db.Model(&Message{}).Where("conversation_id = ? AND sender_mode = ?", conv2ID, SenderTwin).Count(&n)
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("must not store twin message for non-matching contact, got %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sessionTTL = 30 * 24 * time.Hour
|
||||||
|
|
||||||
|
func adminAPIToken() string {
|
||||||
|
return os.Getenv("ADMIN_API_TOKEN")
|
||||||
|
}
|
||||||
|
|
||||||
|
func bearerToken(c *gin.Context) string {
|
||||||
|
h := c.GetHeader("Authorization")
|
||||||
|
if h == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(h, " ", 2)
|
||||||
|
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireAdmin(c *gin.Context) bool {
|
||||||
|
expected := adminAPIToken()
|
||||||
|
if expected == "" {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"detail": "ADMIN_API_TOKEN is not configured; refusing privileged endpoint",
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if bearerToken(c) != expected {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"detail": "admin authorization required"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSession(db *gorm.DB, userID uint) (Session, error) {
|
||||||
|
buf := make([]byte, 24)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return Session{}, err
|
||||||
|
}
|
||||||
|
session := Session{
|
||||||
|
Token: hex.EncodeToString(buf),
|
||||||
|
UserID: userID,
|
||||||
|
ExpiresAt: time.Now().Add(sessionTTL),
|
||||||
|
}
|
||||||
|
if err := db.Create(&session).Error; err != nil {
|
||||||
|
return Session{}, err
|
||||||
|
}
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentUser resolves Authorization: Bearer <session token> to a User.
|
||||||
|
// Returns false after writing an error response when required is true.
|
||||||
|
func currentUser(c *gin.Context, db *gorm.DB, required bool) (User, bool) {
|
||||||
|
token := bearerToken(c)
|
||||||
|
if token == "" {
|
||||||
|
if required {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Authorization Bearer token required"})
|
||||||
|
}
|
||||||
|
return User{}, false
|
||||||
|
}
|
||||||
|
var session Session
|
||||||
|
if err := db.Where("token = ?", token).First(&session).Error; err != nil {
|
||||||
|
if required {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"detail": "invalid session token"})
|
||||||
|
}
|
||||||
|
return User{}, false
|
||||||
|
}
|
||||||
|
if time.Now().After(session.ExpiresAt) {
|
||||||
|
if required {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"detail": "session expired"})
|
||||||
|
}
|
||||||
|
return User{}, false
|
||||||
|
}
|
||||||
|
var user User
|
||||||
|
if err := db.First(&user, session.UserID).Error; err != nil {
|
||||||
|
if required {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"detail": "session user not found"})
|
||||||
|
}
|
||||||
|
return User{}, false
|
||||||
|
}
|
||||||
|
return user, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireSelf(c *gin.Context, db *gorm.DB, pathUserID uint) bool {
|
||||||
|
user, ok := currentUser(c, db, true)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if user.ID != pathUserID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"detail": "token user does not match path user id"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -55,7 +56,12 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
registerA1A2Routes(r, db)
|
||||||
|
|
||||||
r.POST("/invites", func(c *gin.Context) {
|
r.POST("/invites", func(c *gin.Context) {
|
||||||
|
if !requireAdmin(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
code, err := generateInviteCode()
|
code, err := generateInviteCode()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||||
|
|
@ -70,6 +76,9 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
})
|
})
|
||||||
|
|
||||||
r.GET("/admin/metrics", func(c *gin.Context) {
|
r.GET("/admin/metrics", func(c *gin.Context) {
|
||||||
|
if !requireAdmin(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
// v1-minimal (roadmap.md §2.6): only counts honestly derivable from
|
// v1-minimal (roadmap.md §2.6): only counts honestly derivable from
|
||||||
// the current schema. Draft-generation latency and AI-service error
|
// the current schema. Draft-generation latency and AI-service error
|
||||||
// rate need a request-timing/logging layer that doesn't exist yet --
|
// rate need a request-timing/logging layer that doesn't exist yet --
|
||||||
|
|
@ -151,7 +160,18 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
invite.UsedByUserID = &user.ID
|
invite.UsedByUserID = &user.ID
|
||||||
db.Save(&invite)
|
db.Save(&invite)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"id": user.ID, "display_name": user.DisplayName})
|
session, err := createSession(db, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"id": user.ID,
|
||||||
|
"display_name": user.DisplayName,
|
||||||
|
"token": session.Token,
|
||||||
|
"expires_at": session.ExpiresAt,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
r.POST("/conversations/:id/messages", func(c *gin.Context) {
|
r.POST("/conversations/:id/messages", func(c *gin.Context) {
|
||||||
|
|
@ -225,7 +245,7 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case AutonomyL2:
|
case AutonomyL2:
|
||||||
if !req.Approved && !whitelistMatches(db, req.SenderID, req.Text) {
|
if !req.Approved && !whitelistMatches(db, req.SenderID, convID, req.Text) {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"detail": "화이트리스트에 없는 주제는 L1과 동일하게 사용자 승인이 필요합니다"})
|
c.JSON(http.StatusForbidden, gin.H{"detail": "화이트리스트에 없는 주제는 L1과 동일하게 사용자 승인이 필요합니다"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -365,6 +385,9 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireSelf(c, db, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req updateTwinSettingsRequest
|
var req updateTwinSettingsRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
|
@ -395,6 +418,9 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireSelf(c, db, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var user User
|
var user User
|
||||||
if err := db.First(&user, userID).Error; err != nil {
|
if err := db.First(&user, userID).Error; err != nil {
|
||||||
|
|
@ -427,6 +453,9 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireSelf(c, db, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var user User
|
var user User
|
||||||
if err := db.First(&user, userID).Error; err != nil {
|
if err := db.First(&user, userID).Error; err != nil {
|
||||||
|
|
@ -453,6 +482,9 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireSelf(c, db, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
ruleID, ok := parseUintParam(c, "ruleId")
|
ruleID, ok := parseUintParam(c, "ruleId")
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
|
|
@ -473,6 +505,9 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireSelf(c, db, userID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var user User
|
var user User
|
||||||
if err := db.First(&user, userID).Error; err != nil {
|
if err := db.First(&user, userID).Error; err != nil {
|
||||||
|
|
@ -489,6 +524,9 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
if res := tx.Model(&InviteCode{}).Where("used_by_user_id = ?", userID).Update("used_by_user_id", nil); res.Error != nil {
|
if res := tx.Model(&InviteCode{}).Where("used_by_user_id = ?", userID).Update("used_by_user_id", nil); res.Error != nil {
|
||||||
return res.Error
|
return res.Error
|
||||||
}
|
}
|
||||||
|
if res := tx.Where("user_id = ?", userID).Delete(&Session{}); res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
if res := tx.Where("user_id = ?", userID).Delete(&TwinSettings{}); res.Error != nil {
|
if res := tx.Where("user_id = ?", userID).Delete(&TwinSettings{}); res.Error != nil {
|
||||||
return res.Error
|
return res.Error
|
||||||
}
|
}
|
||||||
|
|
@ -550,17 +588,22 @@ func setupRouter(db *gorm.DB, relay *ConnectionManager, ai *AIServiceClient) *gi
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// whitelistMatches is a v1-minimal check: any of the user's WhitelistRule
|
// whitelistMatches: keyword substring match, scoped by ContactID when set.
|
||||||
// keywords appearing as a substring of the message text counts as a match.
|
// Rules with ContactID == nil apply to any counterpart. Rules with a
|
||||||
// It intentionally ignores WhitelistRule.ContactID (per-counterpart
|
// ContactID apply only when that contact is the peer in this conversation
|
||||||
// whitelisting) because conversations aren't yet linked to a Contact row --
|
// (resolved via Contact.ContactUserID ↔ other participant).
|
||||||
// that link needs its own design pass once the client's contact model
|
func whitelistMatches(db *gorm.DB, userID, conversationID uint, text string) bool {
|
||||||
// exists, so this only supports the "any counterpart" case for now.
|
|
||||||
func whitelistMatches(db *gorm.DB, userID uint, text string) bool {
|
|
||||||
var rules []WhitelistRule
|
var rules []WhitelistRule
|
||||||
db.Where("user_id = ?", userID).Find(&rules)
|
db.Where("user_id = ?", userID).Find(&rules)
|
||||||
|
peerContactID := resolvePeerContactID(db, userID, conversationID)
|
||||||
for _, rule := range rules {
|
for _, rule := range rules {
|
||||||
if strings.Contains(text, rule.TopicKeyword) {
|
if !strings.Contains(text, rule.TopicKeyword) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rule.ContactID == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if peerContactID != nil && *rule.ContactID == *peerContactID {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -585,6 +628,10 @@ func parseUintParam(c *gin.Context, name string) (uint, bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if len(os.Args) > 1 && os.Args[1] == "migrate" {
|
||||||
|
_ = openDB()
|
||||||
|
return
|
||||||
|
}
|
||||||
db := openDB()
|
db := openDB()
|
||||||
relay := newConnectionManager()
|
relay := newConnectionManager()
|
||||||
ai := newAIServiceClient()
|
ai := newAIServiceClient()
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,8 @@ func setupTestServer(t *testing.T) (*httptest.Server, *gorm.DB) {
|
||||||
t.Fatalf("migrate: %v", err)
|
t.Fatalf("migrate: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
t.Setenv("ADMIN_API_TOKEN", "test-admin-token")
|
||||||
|
|
||||||
ai := &AIServiceClient{BaseURL: mockAIService(t).URL, HTTP: http.DefaultClient}
|
ai := &AIServiceClient{BaseURL: mockAIService(t).URL, HTTP: http.DefaultClient}
|
||||||
|
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
@ -88,9 +90,24 @@ func postJSON(t *testing.T, url string, body interface{}) *http.Response {
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func postJSONAuth(t *testing.T, url, token string, body interface{}) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(b))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post %s: %v", url, err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
func mintInvite(t *testing.T, serverURL string) string {
|
func mintInvite(t *testing.T, serverURL string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
resp := postJSON(t, serverURL+"/invites", nil)
|
resp := postJSONAuth(t, serverURL+"/invites", "test-admin-token", nil)
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
t.Fatalf("expected 200 minting invite, got %d", resp.StatusCode)
|
t.Fatalf("expected 200 minting invite, got %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
@ -99,7 +116,7 @@ func mintInvite(t *testing.T, serverURL string) string {
|
||||||
return out["code"].(string)
|
return out["code"].(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustSignup(t *testing.T, serverURL, displayName string) uint {
|
func mustSignup(t *testing.T, serverURL, displayName string) (uint, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
resp := postJSON(t, serverURL+"/auth/signup", signupRequest{InviteCode: mintInvite(t, serverURL), DisplayName: displayName})
|
resp := postJSON(t, serverURL+"/auth/signup", signupRequest{InviteCode: mintInvite(t, serverURL), DisplayName: displayName})
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
|
@ -107,14 +124,19 @@ func mustSignup(t *testing.T, serverURL, displayName string) uint {
|
||||||
}
|
}
|
||||||
var out map[string]interface{}
|
var out map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&out)
|
json.NewDecoder(resp.Body).Decode(&out)
|
||||||
return uint(out["id"].(float64))
|
token, _ := out["token"].(string)
|
||||||
|
if token == "" {
|
||||||
|
t.Fatalf("signup response missing token: %v", out)
|
||||||
|
}
|
||||||
|
return uint(out["id"].(float64)), token
|
||||||
}
|
}
|
||||||
|
|
||||||
func setAutonomyLevel(t *testing.T, serverURL string, userID uint, level AutonomyLevel) {
|
func setAutonomyLevel(t *testing.T, serverURL string, userID uint, token string, level AutonomyLevel) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
body, _ := json.Marshal(updateTwinSettingsRequest{AutonomyLevel: level})
|
body, _ := json.Marshal(updateTwinSettingsRequest{AutonomyLevel: level})
|
||||||
req, _ := http.NewRequest(http.MethodPatch, serverURL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/twin-settings", bytes.NewReader(body))
|
req, _ := http.NewRequest(http.MethodPatch, serverURL+"/users/"+strconv.FormatUint(uint64(userID), 10)+"/twin-settings", bytes.NewReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("set autonomy level: %v", err)
|
t.Fatalf("set autonomy level: %v", err)
|
||||||
|
|
@ -183,14 +205,14 @@ func TestSendMessageToMissingConversation(t *testing.T) {
|
||||||
func TestSendMessageAndWebSocketBroadcast(t *testing.T) {
|
func TestSendMessageAndWebSocketBroadcast(t *testing.T) {
|
||||||
server, db := setupTestServer(t)
|
server, db := setupTestServer(t)
|
||||||
|
|
||||||
senderID := mustSignup(t, server.URL, "정우")
|
senderID, token := mustSignup(t, server.URL, "정우")
|
||||||
|
|
||||||
conv := Conversation{IsGroup: false}
|
conv := Conversation{IsGroup: false}
|
||||||
if err := db.Create(&conv).Error; err != nil {
|
if err := db.Create(&conv).Error; err != nil {
|
||||||
t.Fatalf("create conversation: %v", err)
|
t.Fatalf("create conversation: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
setAutonomyLevel(t, server.URL, senderID, AutonomyL1)
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
|
||||||
|
|
||||||
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws/conversations/" +
|
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws/conversations/" +
|
||||||
strconv.FormatUint(uint64(conv.ID), 10)
|
strconv.FormatUint(uint64(conv.ID), 10)
|
||||||
|
|
@ -222,7 +244,7 @@ func TestSendMessageAndWebSocketBroadcast(t *testing.T) {
|
||||||
func TestTwinMessageEscalatedIsBlockedAndNotBroadcast(t *testing.T) {
|
func TestTwinMessageEscalatedIsBlockedAndNotBroadcast(t *testing.T) {
|
||||||
server, db := setupTestServer(t)
|
server, db := setupTestServer(t)
|
||||||
|
|
||||||
senderID := mustSignup(t, server.URL, "민지")
|
senderID, _ := mustSignup(t, server.URL, "민지")
|
||||||
|
|
||||||
conv := Conversation{IsGroup: false}
|
conv := Conversation{IsGroup: false}
|
||||||
if err := db.Create(&conv).Error; err != nil {
|
if err := db.Create(&conv).Error; err != nil {
|
||||||
|
|
@ -271,7 +293,7 @@ func TestTwinMessageEscalatedIsBlockedAndNotBroadcast(t *testing.T) {
|
||||||
func TestTwinSendBlockedAtDefaultL0(t *testing.T) {
|
func TestTwinSendBlockedAtDefaultL0(t *testing.T) {
|
||||||
server, db := setupTestServer(t)
|
server, db := setupTestServer(t)
|
||||||
|
|
||||||
senderID := mustSignup(t, server.URL, "하늘")
|
senderID, _ := mustSignup(t, server.URL, "하늘")
|
||||||
|
|
||||||
conv := Conversation{IsGroup: false}
|
conv := Conversation{IsGroup: false}
|
||||||
if err := db.Create(&conv).Error; err != nil {
|
if err := db.Create(&conv).Error; err != nil {
|
||||||
|
|
@ -299,8 +321,8 @@ func TestTwinSendBlockedAtDefaultL0(t *testing.T) {
|
||||||
func TestTwinSendRequiresApprovalAtL1(t *testing.T) {
|
func TestTwinSendRequiresApprovalAtL1(t *testing.T) {
|
||||||
server, db := setupTestServer(t)
|
server, db := setupTestServer(t)
|
||||||
|
|
||||||
senderID := mustSignup(t, server.URL, "서준")
|
senderID, token := mustSignup(t, server.URL, "서준")
|
||||||
setAutonomyLevel(t, server.URL, senderID, AutonomyL1)
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
|
||||||
|
|
||||||
conv := Conversation{IsGroup: false}
|
conv := Conversation{IsGroup: false}
|
||||||
if err := db.Create(&conv).Error; err != nil {
|
if err := db.Create(&conv).Error; err != nil {
|
||||||
|
|
@ -328,8 +350,8 @@ func TestTwinSendRequiresApprovalAtL1(t *testing.T) {
|
||||||
func TestTwinSendAutoSendsAtL2WithWhitelistMatch(t *testing.T) {
|
func TestTwinSendAutoSendsAtL2WithWhitelistMatch(t *testing.T) {
|
||||||
server, db := setupTestServer(t)
|
server, db := setupTestServer(t)
|
||||||
|
|
||||||
senderID := mustSignup(t, server.URL, "가은")
|
senderID, token := mustSignup(t, server.URL, "가은")
|
||||||
setAutonomyLevel(t, server.URL, senderID, AutonomyL2)
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL2)
|
||||||
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"})
|
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"})
|
||||||
|
|
||||||
conv := Conversation{IsGroup: false}
|
conv := Conversation{IsGroup: false}
|
||||||
|
|
@ -355,8 +377,8 @@ func TestTwinSendAutoSendsAtL2WithWhitelistMatch(t *testing.T) {
|
||||||
func TestTwinSendRequiresApprovalAtL2WithoutWhitelistMatch(t *testing.T) {
|
func TestTwinSendRequiresApprovalAtL2WithoutWhitelistMatch(t *testing.T) {
|
||||||
server, db := setupTestServer(t)
|
server, db := setupTestServer(t)
|
||||||
|
|
||||||
senderID := mustSignup(t, server.URL, "도윤")
|
senderID, token := mustSignup(t, server.URL, "도윤")
|
||||||
setAutonomyLevel(t, server.URL, senderID, AutonomyL2)
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL2)
|
||||||
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"})
|
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"})
|
||||||
|
|
||||||
conv := Conversation{IsGroup: false}
|
conv := Conversation{IsGroup: false}
|
||||||
|
|
@ -381,8 +403,8 @@ func TestTwinSendRequiresApprovalAtL2WithoutWhitelistMatch(t *testing.T) {
|
||||||
func TestEscalationOverridesAutonomyLevelAndWhitelist(t *testing.T) {
|
func TestEscalationOverridesAutonomyLevelAndWhitelist(t *testing.T) {
|
||||||
server, db := setupTestServer(t)
|
server, db := setupTestServer(t)
|
||||||
|
|
||||||
senderID := mustSignup(t, server.URL, "은서")
|
senderID, token := mustSignup(t, server.URL, "은서")
|
||||||
setAutonomyLevel(t, server.URL, senderID, AutonomyL2)
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL2)
|
||||||
// Whitelisted keyword happens to be the same word that also triggers
|
// Whitelisted keyword happens to be the same word that also triggers
|
||||||
// the money escalation pattern in the mock AI service.
|
// the money escalation pattern in the mock AI service.
|
||||||
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "계좌"})
|
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "계좌"})
|
||||||
|
|
@ -409,7 +431,7 @@ func TestEscalationOverridesAutonomyLevelAndWhitelist(t *testing.T) {
|
||||||
func TestHumanMessageBypassesEscalationGate(t *testing.T) {
|
func TestHumanMessageBypassesEscalationGate(t *testing.T) {
|
||||||
server, db := setupTestServer(t)
|
server, db := setupTestServer(t)
|
||||||
|
|
||||||
senderID := mustSignup(t, server.URL, "재민")
|
senderID, _ := mustSignup(t, server.URL, "재민")
|
||||||
|
|
||||||
conv := Conversation{IsGroup: false}
|
conv := Conversation{IsGroup: false}
|
||||||
if err := db.Create(&conv).Error; err != nil {
|
if err := db.Create(&conv).Error; err != nil {
|
||||||
|
|
@ -429,6 +451,7 @@ func TestHumanMessageBypassesEscalationGate(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTwinMessageFailsClosedWhenAIServiceUnreachable(t *testing.T) {
|
func TestTwinMessageFailsClosedWhenAIServiceUnreachable(t *testing.T) {
|
||||||
|
t.Setenv("ADMIN_API_TOKEN", "test-admin-token")
|
||||||
dbPath := t.TempDir() + "/test.db"
|
dbPath := t.TempDir() + "/test.db"
|
||||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -447,7 +470,7 @@ func TestTwinMessageFailsClosedWhenAIServiceUnreachable(t *testing.T) {
|
||||||
server := httptest.NewServer(router)
|
server := httptest.NewServer(router)
|
||||||
t.Cleanup(server.Close)
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
senderID := mustSignup(t, server.URL, "소연")
|
senderID, _ := mustSignup(t, server.URL, "소연")
|
||||||
|
|
||||||
conv := Conversation{IsGroup: false}
|
conv := Conversation{IsGroup: false}
|
||||||
if err := db.Create(&conv).Error; err != nil {
|
if err := db.Create(&conv).Error; err != nil {
|
||||||
|
|
@ -473,7 +496,7 @@ func TestTwinMessageFailsClosedWhenAIServiceUnreachable(t *testing.T) {
|
||||||
func TestDeleteUserPurgesData(t *testing.T) {
|
func TestDeleteUserPurgesData(t *testing.T) {
|
||||||
server, db := setupTestServer(t)
|
server, db := setupTestServer(t)
|
||||||
|
|
||||||
userID := mustSignup(t, server.URL, "유나")
|
userID, token := mustSignup(t, server.URL, "유나")
|
||||||
|
|
||||||
conv := Conversation{IsGroup: false}
|
conv := Conversation{IsGroup: false}
|
||||||
if err := db.Create(&conv).Error; err != nil {
|
if err := db.Create(&conv).Error; err != nil {
|
||||||
|
|
@ -490,6 +513,7 @@ func TestDeleteUserPurgesData(t *testing.T) {
|
||||||
db.Create(&EscalationLog{UserID: userID, ConversationID: conv.ID, Reason: "금전", MessageSnippet: "..."})
|
db.Create(&EscalationLog{UserID: userID, ConversationID: conv.ID, Reason: "금전", MessageSnippet: "..."})
|
||||||
|
|
||||||
req, _ := http.NewRequest(http.MethodDelete, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10), nil)
|
req, _ := http.NewRequest(http.MethodDelete, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10), nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("delete request: %v", err)
|
t.Fatalf("delete request: %v", err)
|
||||||
|
|
@ -515,20 +539,22 @@ func TestDeleteUserPurgesData(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
req2, _ := http.NewRequest(http.MethodDelete, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10), nil)
|
req2, _ := http.NewRequest(http.MethodDelete, server.URL+"/users/"+strconv.FormatUint(uint64(userID), 10), nil)
|
||||||
|
req2.Header.Set("Authorization", "Bearer "+token)
|
||||||
resp2, err := http.DefaultClient.Do(req2)
|
resp2, err := http.DefaultClient.Do(req2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("second delete request: %v", err)
|
t.Fatalf("second delete request: %v", err)
|
||||||
}
|
}
|
||||||
if resp2.StatusCode != http.StatusNotFound {
|
// After delete, session is gone -- unauthorized (or 404 if we checked user first).
|
||||||
t.Fatalf("expected 404 on repeat delete, got %d", resp2.StatusCode)
|
if resp2.StatusCode != http.StatusUnauthorized && resp2.StatusCode != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 401/404 on repeat delete, got %d", resp2.StatusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPeerVetoBlocksTwinAutoSendEvenAtL2WithWhitelistMatch(t *testing.T) {
|
func TestPeerVetoBlocksTwinAutoSendEvenAtL2WithWhitelistMatch(t *testing.T) {
|
||||||
server, db := setupTestServer(t)
|
server, db := setupTestServer(t)
|
||||||
|
|
||||||
senderID := mustSignup(t, server.URL, "지민")
|
senderID, token := mustSignup(t, server.URL, "지민")
|
||||||
setAutonomyLevel(t, server.URL, senderID, AutonomyL2)
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL2)
|
||||||
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"})
|
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"})
|
||||||
|
|
||||||
conv := Conversation{IsGroup: false}
|
conv := Conversation{IsGroup: false}
|
||||||
|
|
@ -561,7 +587,7 @@ func TestPeerVetoBlocksTwinAutoSendEvenAtL2WithWhitelistMatch(t *testing.T) {
|
||||||
func TestPeerVetoDoesNotBlockHumanMessages(t *testing.T) {
|
func TestPeerVetoDoesNotBlockHumanMessages(t *testing.T) {
|
||||||
server, db := setupTestServer(t)
|
server, db := setupTestServer(t)
|
||||||
|
|
||||||
senderID := mustSignup(t, server.URL, "다은")
|
senderID, _ := mustSignup(t, server.URL, "다은")
|
||||||
|
|
||||||
conv := Conversation{IsGroup: false}
|
conv := Conversation{IsGroup: false}
|
||||||
if err := db.Create(&conv).Error; err != nil {
|
if err := db.Create(&conv).Error; err != nil {
|
||||||
|
|
@ -588,8 +614,8 @@ func TestVetoMissingConversation(t *testing.T) {
|
||||||
func TestAdminMetricsCountsMessagesEscalationsAndVeto(t *testing.T) {
|
func TestAdminMetricsCountsMessagesEscalationsAndVeto(t *testing.T) {
|
||||||
server, db := setupTestServer(t)
|
server, db := setupTestServer(t)
|
||||||
|
|
||||||
senderID := mustSignup(t, server.URL, "메트릭")
|
senderID, token := mustSignup(t, server.URL, "메트릭")
|
||||||
setAutonomyLevel(t, server.URL, senderID, AutonomyL1)
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL1)
|
||||||
|
|
||||||
conv := Conversation{IsGroup: false}
|
conv := Conversation{IsGroup: false}
|
||||||
if err := db.Create(&conv).Error; err != nil {
|
if err := db.Create(&conv).Error; err != nil {
|
||||||
|
|
@ -605,7 +631,9 @@ func TestAdminMetricsCountsMessagesEscalationsAndVeto(t *testing.T) {
|
||||||
db.Create(&conv2)
|
db.Create(&conv2)
|
||||||
postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv2.ID), 10)+"/veto", nil)
|
postJSON(t, server.URL+"/conversations/"+strconv.FormatUint(uint64(conv2.ID), 10)+"/veto", nil)
|
||||||
|
|
||||||
resp, err := http.Get(server.URL + "/admin/metrics")
|
mreq, _ := http.NewRequest(http.MethodGet, server.URL+"/admin/metrics", nil)
|
||||||
|
mreq.Header.Set("Authorization", "Bearer test-admin-token")
|
||||||
|
resp, err := http.DefaultClient.Do(mreq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get metrics: %v", err)
|
t.Fatalf("get metrics: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -641,10 +669,10 @@ func TestAdminMetricsCountsMessagesEscalationsAndVeto(t *testing.T) {
|
||||||
|
|
||||||
func TestWhitelistRuleCRUD(t *testing.T) {
|
func TestWhitelistRuleCRUD(t *testing.T) {
|
||||||
server, _ := setupTestServer(t)
|
server, _ := setupTestServer(t)
|
||||||
userID := mustSignup(t, server.URL, "화이트")
|
userID, token := mustSignup(t, server.URL, "화이트")
|
||||||
base := server.URL + "/users/" + strconv.FormatUint(uint64(userID), 10) + "/whitelist-rules"
|
base := server.URL + "/users/" + strconv.FormatUint(uint64(userID), 10) + "/whitelist-rules"
|
||||||
|
|
||||||
createResp := postJSON(t, base, createWhitelistRuleRequest{TopicKeyword: "저녁"})
|
createResp := postJSONAuth(t, base, token, createWhitelistRuleRequest{TopicKeyword: "저녁"})
|
||||||
if createResp.StatusCode != http.StatusOK {
|
if createResp.StatusCode != http.StatusOK {
|
||||||
t.Fatalf("expected 200 creating rule, got %d", createResp.StatusCode)
|
t.Fatalf("expected 200 creating rule, got %d", createResp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
@ -655,7 +683,9 @@ func TestWhitelistRuleCRUD(t *testing.T) {
|
||||||
}
|
}
|
||||||
ruleID := uint(created["id"].(float64))
|
ruleID := uint(created["id"].(float64))
|
||||||
|
|
||||||
listResp, err := http.Get(base)
|
listReq, _ := http.NewRequest(http.MethodGet, base, nil)
|
||||||
|
listReq.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
listResp, err := http.DefaultClient.Do(listReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list rules: %v", err)
|
t.Fatalf("list rules: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -667,6 +697,7 @@ func TestWhitelistRuleCRUD(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
delReq, _ := http.NewRequest(http.MethodDelete, base+"/"+strconv.FormatUint(uint64(ruleID), 10), nil)
|
delReq, _ := http.NewRequest(http.MethodDelete, base+"/"+strconv.FormatUint(uint64(ruleID), 10), nil)
|
||||||
|
delReq.Header.Set("Authorization", "Bearer "+token)
|
||||||
delResp, err := http.DefaultClient.Do(delReq)
|
delResp, err := http.DefaultClient.Do(delReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("delete rule: %v", err)
|
t.Fatalf("delete rule: %v", err)
|
||||||
|
|
@ -675,7 +706,12 @@ func TestWhitelistRuleCRUD(t *testing.T) {
|
||||||
t.Fatalf("expected 200 deleting rule, got %d", delResp.StatusCode)
|
t.Fatalf("expected 200 deleting rule, got %d", delResp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
listResp2, _ := http.Get(base)
|
listReq2, _ := http.NewRequest(http.MethodGet, base, nil)
|
||||||
|
listReq2.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
listResp2, err := http.DefaultClient.Do(listReq2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list after delete: %v", err)
|
||||||
|
}
|
||||||
var list2 map[string]interface{}
|
var list2 map[string]interface{}
|
||||||
json.NewDecoder(listResp2.Body).Decode(&list2)
|
json.NewDecoder(listResp2.Body).Decode(&list2)
|
||||||
if len(list2["whitelist_rules"].([]interface{})) != 0 {
|
if len(list2["whitelist_rules"].([]interface{})) != 0 {
|
||||||
|
|
@ -683,6 +719,7 @@ func TestWhitelistRuleCRUD(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
delAgain, _ := http.NewRequest(http.MethodDelete, base+"/"+strconv.FormatUint(uint64(ruleID), 10), nil)
|
delAgain, _ := http.NewRequest(http.MethodDelete, base+"/"+strconv.FormatUint(uint64(ruleID), 10), nil)
|
||||||
|
delAgain.Header.Set("Authorization", "Bearer "+token)
|
||||||
delAgainResp, _ := http.DefaultClient.Do(delAgain)
|
delAgainResp, _ := http.DefaultClient.Do(delAgain)
|
||||||
if delAgainResp.StatusCode != http.StatusNotFound {
|
if delAgainResp.StatusCode != http.StatusNotFound {
|
||||||
t.Fatalf("expected 404 deleting an already-deleted rule, got %d", delAgainResp.StatusCode)
|
t.Fatalf("expected 404 deleting an already-deleted rule, got %d", delAgainResp.StatusCode)
|
||||||
|
|
@ -692,15 +729,15 @@ func TestWhitelistRuleCRUD(t *testing.T) {
|
||||||
func TestWhitelistRuleCRUDMissingUser(t *testing.T) {
|
func TestWhitelistRuleCRUDMissingUser(t *testing.T) {
|
||||||
server, _ := setupTestServer(t)
|
server, _ := setupTestServer(t)
|
||||||
resp := postJSON(t, server.URL+"/users/9999/whitelist-rules", createWhitelistRuleRequest{TopicKeyword: "저녁"})
|
resp := postJSON(t, server.URL+"/users/9999/whitelist-rules", createWhitelistRuleRequest{TopicKeyword: "저녁"})
|
||||||
if resp.StatusCode != http.StatusNotFound {
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
t.Fatalf("expected 404, got %d", resp.StatusCode)
|
t.Fatalf("expected 401 without token, got %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRetractTwinMessage(t *testing.T) {
|
func TestRetractTwinMessage(t *testing.T) {
|
||||||
server, db := setupTestServer(t)
|
server, db := setupTestServer(t)
|
||||||
senderID := mustSignup(t, server.URL, "되돌리기")
|
senderID, token := mustSignup(t, server.URL, "되돌리기")
|
||||||
setAutonomyLevel(t, server.URL, senderID, AutonomyL2)
|
setAutonomyLevel(t, server.URL, senderID, token, AutonomyL2)
|
||||||
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"})
|
db.Create(&WhitelistRule{UserID: senderID, TopicKeyword: "저녁"})
|
||||||
|
|
||||||
conv := Conversation{IsGroup: false}
|
conv := Conversation{IsGroup: false}
|
||||||
|
|
@ -761,7 +798,7 @@ func TestRetractTwinMessage(t *testing.T) {
|
||||||
|
|
||||||
func TestRetractRejectsHumanMessage(t *testing.T) {
|
func TestRetractRejectsHumanMessage(t *testing.T) {
|
||||||
server, db := setupTestServer(t)
|
server, db := setupTestServer(t)
|
||||||
senderID := mustSignup(t, server.URL, "사람")
|
senderID, _ := mustSignup(t, server.URL, "사람")
|
||||||
|
|
||||||
conv := Conversation{IsGroup: false}
|
conv := Conversation{IsGroup: false}
|
||||||
if err := db.Create(&conv).Error; err != nil {
|
if err := db.Create(&conv).Error; err != nil {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,16 @@ type User struct {
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Session is a bearer token issued at signup/login (roadmap A2).
|
||||||
|
// v1 beta: opaque random token, no refresh rotation yet.
|
||||||
|
type Session struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
Token string `gorm:"uniqueIndex;not null"`
|
||||||
|
UserID uint `gorm:"not null;index"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt time.Time `gorm:"not null;index"`
|
||||||
|
}
|
||||||
|
|
||||||
// InviteCode is a pre-minted, single-use code (roadmap.md Phase 1 §2.6
|
// InviteCode is a pre-minted, single-use code (roadmap.md Phase 1 §2.6
|
||||||
// "초대 기반 베타 가입 플로우") -- signup validates against this table instead
|
// "초대 기반 베타 가입 플로우") -- signup validates against this table instead
|
||||||
// of just deduping User.InviteCode, so joining actually requires a code
|
// of just deduping User.InviteCode, so joining actually requires a code
|
||||||
|
|
@ -111,6 +121,7 @@ type EscalationLog struct {
|
||||||
|
|
||||||
var allModels = []interface{}{
|
var allModels = []interface{}{
|
||||||
&User{},
|
&User{},
|
||||||
|
&Session{},
|
||||||
&InviteCode{},
|
&InviteCode{},
|
||||||
&Contact{},
|
&Contact{},
|
||||||
&Conversation{},
|
&Conversation{},
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@ PoC 데이터 없이 기본값을 추측해 채우지 않는다.
|
||||||
|
|
||||||
1. [x] §1 기술 스택 결정 (Go 코어 + Python AI 서비스로 재확정, `backend/`는 Python 프로토타입 —
|
1. [x] §1 기술 스택 결정 (Go 코어 + Python AI 서비스로 재확정, `backend/`는 Python 프로토타입 —
|
||||||
설계 참고용으로 남기고 Go로 포팅 필요)
|
설계 참고용으로 남기고 Go로 포팅 필요)
|
||||||
2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket 릴레이 완료, 푸시 알림만 남음)
|
2. [x] 2.1 코어 백엔드 Go 구현 — `core-backend/` (가입·메시지·WebSocket + A1 대화/연락처/히스토리 + A2 세션/관리자 토큰). 푸시 알림만 남음
|
||||||
3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동·자율성 오케스트레이션·
|
3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동·자율성 오케스트레이션·
|
||||||
되돌리기 API 완료. 온디바이스 말투 이력 저장은 클라이언트와 이어서
|
되돌리기 API 완료. 온디바이스 말투 이력 저장은 클라이언트와 이어서
|
||||||
4. [~] 2.3 Flutter 클라이언트 — `mobile/` 골격 착수 완료(가입·채팅·뱃지·거부권·되돌리기·자율성
|
4. [~] 2.3 Flutter 클라이언트 — `mobile/` 골격 착수 완료(가입·채팅·뱃지·거부권·되돌리기·자율성
|
||||||
|
|
@ -157,6 +157,54 @@ PoC 데이터 없이 기본값을 추측해 채우지 않는다.
|
||||||
6. [ ] §3 사람 PoC 실행 + 확정 값 반영 → 2.6 실제 베타 오픈 (**맨 마지막**)
|
6. [ ] §3 사람 PoC 실행 + 확정 값 반영 → 2.6 실제 베타 오픈 (**맨 마지막**)
|
||||||
|
|
||||||
|
|
||||||
|
#### 5. 앞으로의 개발 계획 (우선순위 체크리스트)
|
||||||
|
|
||||||
|
Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase 게이트 전 구현 금지.
|
||||||
|
|
||||||
|
##### A. 지금 바로 — 앱이 실제로 돌아가게
|
||||||
|
|
||||||
|
**A1. 서버 — 대화/관계 모델 완성**
|
||||||
|
- [x] 대화방 생성·목록 API (`POST`/`GET /conversations`)
|
||||||
|
- [x] 연락처 CRUD + 대화방↔연락처 연결
|
||||||
|
- [x] 메시지 히스토리 조회 (`GET /conversations/:id/messages`)
|
||||||
|
- [x] 상대별 화이트리스트/자율성 예외 매칭
|
||||||
|
- [x] 에스컬레이션 로그 조회 API
|
||||||
|
|
||||||
|
**A2. 인증·보안**
|
||||||
|
- [x] 로그인 세션/토큰 (`Session`, signup/login 시 Bearer 발급)
|
||||||
|
- [x] `/invites`, `/admin/metrics` 접근 제어 (`ADMIN_API_TOKEN`)
|
||||||
|
- [x] 프로덕션 DB 마이그레이션 명령 (`go run . migrate`)
|
||||||
|
|
||||||
|
**A3. Flutter — 메신저답게 다듬기** (A1/A2 이후)
|
||||||
|
- [ ] 대화 목록/연락처 UI를 서버 API에 연결
|
||||||
|
- [ ] 온보딩 뼈대 확장 (말투 샘플 입력 UI)
|
||||||
|
- [ ] L1 승인 플로우 UX 정리
|
||||||
|
- [ ] 사후 알림 함
|
||||||
|
- [ ] 에뮬레이터/실기기 E2E 수동 QA
|
||||||
|
|
||||||
|
##### B. 그다음 — 베타 품질
|
||||||
|
- [ ] FCM 푸시 연동
|
||||||
|
- [ ] 멀티 디바이스 동기화
|
||||||
|
- [ ] drift + SQLCipher 로컬 저장
|
||||||
|
- [ ] 말투 이력 기기 내 저장 + 서버 최소 전송
|
||||||
|
- [ ] 데이터 흐름 표시 UI
|
||||||
|
- [ ] 생성 지연시간·오류율 계측
|
||||||
|
- [ ] 모니터링 대시보드(최소)
|
||||||
|
- [ ] 본인확인 응답 문구 고정/검증
|
||||||
|
|
||||||
|
##### C. 베타 직전
|
||||||
|
- [ ] Android 릴리즈 빌드·서명·배포 경로
|
||||||
|
- [ ] 초대 코드 운영 절차(발급자 권한)
|
||||||
|
- [ ] Q1~Q7 회의 확정 (제안 → 확정)
|
||||||
|
- [ ] 클릭 프로토타입 공유 링크 docs 고정
|
||||||
|
|
||||||
|
##### D. 맨 마지막 — 사람 PoC (지금 안 함)
|
||||||
|
- §3 항목과 동일. A~C 완료 후에만 착수.
|
||||||
|
|
||||||
|
##### E. 베타 이후 (지금은 설계만, 구현 금지)
|
||||||
|
- Phase 2 L3 / Phase 3 OS 레이어 / Phase 4 L4·B2B
|
||||||
|
|
||||||
|
|
||||||
## Phase 2 — L3 확장 + 베타 확대
|
## Phase 2 — L3 확장 + 베타 확대
|
||||||
|
|
||||||
- 자리비움 전면 응대(L3) 추가 — Phase 1에서 신뢰가 검증된 경우에만
|
- 자리비움 전면 응대(L3) 추가 — Phase 1에서 신뢰가 검증된 경우에만
|
||||||
|
|
|
||||||
|
|
@ -22,16 +22,22 @@ class ApiClient {
|
||||||
|
|
||||||
final http.Client _http;
|
final http.Client _http;
|
||||||
final String _base;
|
final String _base;
|
||||||
|
String? authToken;
|
||||||
|
|
||||||
Uri _u(String path) => Uri.parse('$_base$path');
|
Uri _u(String path) => Uri.parse('$_base$path');
|
||||||
|
|
||||||
|
Map<String, String> _headers() => {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
if (authToken != null && authToken!.isNotEmpty) 'Authorization': 'Bearer $authToken',
|
||||||
|
};
|
||||||
|
|
||||||
Future<Map<String, dynamic>> _json(
|
Future<Map<String, dynamic>> _json(
|
||||||
String method,
|
String method,
|
||||||
String path, {
|
String path, {
|
||||||
Map<String, dynamic>? body,
|
Map<String, dynamic>? body,
|
||||||
}) async {
|
}) async {
|
||||||
final req = http.Request(method, _u(path));
|
final req = http.Request(method, _u(path));
|
||||||
req.headers['Content-Type'] = 'application/json';
|
req.headers.addAll(_headers());
|
||||||
if (body != null) req.body = jsonEncode(body);
|
if (body != null) req.body = jsonEncode(body);
|
||||||
final streamed = await _http.send(req);
|
final streamed = await _http.send(req);
|
||||||
final res = await http.Response.fromStream(streamed);
|
final res = await http.Response.fromStream(streamed);
|
||||||
|
|
@ -42,24 +48,31 @@ class ApiClient {
|
||||||
return jsonDecode(res.body) as Map<String, dynamic>;
|
return jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<dynamic>> _jsonList(String path) async {
|
Future<Map<String, dynamic>> _getObject(String path) async {
|
||||||
final res = await _http.get(_u(path));
|
final res = await _http.get(_u(path), headers: _headers());
|
||||||
if (res.statusCode >= 400) {
|
if (res.statusCode >= 400) {
|
||||||
throw ApiException(res.statusCode, res.body);
|
throw ApiException(res.statusCode, res.body);
|
||||||
}
|
}
|
||||||
return jsonDecode(res.body) as List<dynamic>;
|
return jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<User> signup({required String inviteCode, required String displayName}) async {
|
Future<({User user, String token})> signup({
|
||||||
|
required String inviteCode,
|
||||||
|
required String displayName,
|
||||||
|
}) async {
|
||||||
final json = await _json('POST', '/auth/signup', body: {
|
final json = await _json('POST', '/auth/signup', body: {
|
||||||
'invite_code': inviteCode,
|
'invite_code': inviteCode,
|
||||||
'display_name': displayName,
|
'display_name': displayName,
|
||||||
});
|
});
|
||||||
// core-backend returns {id, display_name} only — keep the invite we sent.
|
final token = json['token'] as String? ?? '';
|
||||||
return User(
|
authToken = token;
|
||||||
|
return (
|
||||||
|
user: User(
|
||||||
id: json['id'] as int,
|
id: json['id'] as int,
|
||||||
displayName: json['display_name'] as String? ?? displayName,
|
displayName: json['display_name'] as String? ?? displayName,
|
||||||
inviteCode: inviteCode,
|
inviteCode: inviteCode,
|
||||||
|
),
|
||||||
|
token: token,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,10 +121,29 @@ class ApiClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<WhitelistRule>> listWhitelist(int userId) async {
|
Future<List<WhitelistRule>> listWhitelist(int userId) async {
|
||||||
final list = await _jsonList('/users/$userId/whitelist-rules');
|
final obj = await _getObject('/users/$userId/whitelist-rules');
|
||||||
|
final list = (obj['whitelist_rules'] as List<dynamic>? ?? const []);
|
||||||
return list.map((e) => WhitelistRule.fromJson(e as Map<String, dynamic>)).toList();
|
return list.map((e) => WhitelistRule.fromJson(e as Map<String, dynamic>)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> listConversations() async {
|
||||||
|
final obj = await _getObject('/conversations');
|
||||||
|
final list = (obj['conversations'] as List<dynamic>? ?? const []);
|
||||||
|
return list.cast<Map<String, dynamic>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> createConversation({
|
||||||
|
required List<int> userIds,
|
||||||
|
int? contactId,
|
||||||
|
bool isGroup = false,
|
||||||
|
}) async {
|
||||||
|
return _json('POST', '/conversations', body: {
|
||||||
|
'user_ids': userIds,
|
||||||
|
'is_group': isGroup,
|
||||||
|
if (contactId != null) 'contact_id': contactId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<WhitelistRule> addWhitelist(int userId, String topicKeyword) async {
|
Future<WhitelistRule> addWhitelist(int userId, String topicKeyword) async {
|
||||||
final json = await _json('POST', '/users/$userId/whitelist-rules', body: {
|
final json = await _json('POST', '/users/$userId/whitelist-rules', body: {
|
||||||
'topic_keyword': topicKeyword,
|
'topic_keyword': topicKeyword,
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,17 @@ class SessionState extends ChangeNotifier {
|
||||||
static const _kUserId = 'user_id';
|
static const _kUserId = 'user_id';
|
||||||
static const _kDisplayName = 'display_name';
|
static const _kDisplayName = 'display_name';
|
||||||
static const _kInvite = 'invite_code';
|
static const _kInvite = 'invite_code';
|
||||||
|
static const _kToken = 'session_token';
|
||||||
|
|
||||||
Future<void> restore() async {
|
Future<void> restore() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final id = prefs.getInt(_kUserId);
|
final id = prefs.getInt(_kUserId);
|
||||||
final name = prefs.getString(_kDisplayName);
|
final name = prefs.getString(_kDisplayName);
|
||||||
final invite = prefs.getString(_kInvite);
|
final invite = prefs.getString(_kInvite);
|
||||||
|
final token = prefs.getString(_kToken);
|
||||||
if (id != null && name != null && invite != null) {
|
if (id != null && name != null && invite != null) {
|
||||||
user = User(id: id, displayName: name, inviteCode: invite);
|
user = User(id: id, displayName: name, inviteCode: invite);
|
||||||
|
_api.authToken = token;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -33,12 +36,13 @@ class SessionState extends ChangeNotifier {
|
||||||
error = null;
|
error = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
final created = await _api.signup(inviteCode: inviteCode, displayName: displayName);
|
final result = await _api.signup(inviteCode: inviteCode, displayName: displayName);
|
||||||
user = created;
|
user = result.user;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setInt(_kUserId, created.id);
|
await prefs.setInt(_kUserId, result.user.id);
|
||||||
await prefs.setString(_kDisplayName, created.displayName);
|
await prefs.setString(_kDisplayName, result.user.displayName);
|
||||||
await prefs.setString(_kInvite, created.inviteCode);
|
await prefs.setString(_kInvite, result.user.inviteCode);
|
||||||
|
await prefs.setString(_kToken, result.token);
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
error = '가입 실패 (${e.statusCode}): ${e.body}';
|
error = '가입 실패 (${e.statusCode}): ${e.body}';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue