Files
heicode-mananger/heicode/middleware/auth_v2_user_test.go
T
chenchenandClaude Opus 4.8 2613c5763c fix(heicode): close client gap-analysis P0/P1 items for unified API
Address the desktop client team gap analysis on the unified /api/heicode/*
surface:

- P0-1 GET device auth: no-body V2 signed-GET path (fetch forbids GET body),
  same Ed25519 canonical with empty-body hash; UserOrV2DeviceAuth +
  OptionalV2DeviceAuth dispatch on signature headers. Unit test added.
- P0-2 approval inbox: GET .../tasks/{id}/approvals?status=pending.
- P0-3 project_folder: artifacts list normalizes the primary code deliverable
  to display_artifact_type=project_folder + is_project + manifest/files/
  archive/revisions subpaths.
- P0-4 archive contract: real application/zip + Content-Disposition +
  Content-Length; ARTIFACT_ARCHIVE_NOT_READY (retryable) when no files yet.
- P1-1 file path: GET .../files?path=<url-encoded> (no segment ambiguity).
- P1-2/P1-3 revision: local edits stored as accepted baseline; /messages and
  /execute consume the latest accepted revision (-> applied), return
  active_project_revision.
- P1-4 doc: Swarm same-shape routes stated explicitly.

Doc updated to match. Build + middleware/controller tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 11:48:03 +08:00

398 lines
12 KiB
Go

package middleware
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
"github.com/heicode/manager/service"
"golang.org/x/crypto/chacha20poly1305"
"golang.org/x/crypto/curve25519"
"golang.org/x/crypto/hkdf"
"gorm.io/gorm"
)
func TestUserOrV2DeviceAuthRejectsMalformedEncryptedRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.POST("/api/agent/user/tasks/:task_id/deployment-draft", UserOrV2DeviceAuth(), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
})
req := httptest.NewRequest(
http.MethodPost,
"/api/agent/user/tasks/task-v2/deployment-draft",
nil,
)
req.Header.Set("Content-Encoding", V2ContentEncoding)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String())
}
if got := rec.Header().Get(HeaderAuthError); got != V2AuthErrorEphPubkeyMissing {
t.Fatalf("%s = %q, want %q", HeaderAuthError, got, V2AuthErrorEphPubkeyMissing)
}
if rec.Header().Get(HeaderServerTime) == "" {
t.Fatalf("%s was not set", HeaderServerTime)
}
}
func TestOptionalV2DeviceAuthAllowsPlainRequests(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.POST("/api/heicode-auth/api/user/tasks/intent", OptionalV2DeviceAuth(), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
})
req := httptest.NewRequest(http.MethodPost, "/api/heicode-auth/api/user/tasks/intent", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
}
func TestUserOrV2DeviceAuthDecryptsValidEncryptedRequest(t *testing.T) {
setupV2UserAuthTestDB(t)
gin.SetMode(gin.TestMode)
deviceID := "device-v2-success"
fingerprint := strings.Repeat("c", 64)
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate ed25519 key: %v", err)
}
publicKeyB64 := base64.StdEncoding.EncodeToString(privateKey.Public().(ed25519.PublicKey))
deviceIDCopy := deviceID
token := model.Token{
UserId: 42,
Key: "sk-v2-success-test",
Status: common.TokenStatusEnabled,
Name: "v2 success test",
ExpiredTime: -1,
UnlimitedQuota: true,
DeviceId: &deviceIDCopy,
DevicePubkey: &publicKeyB64,
DeviceFingerprint: fingerprint,
RequireDeviceBinding: true,
}
if err := token.Insert(); err != nil {
t.Fatalf("insert token: %v", err)
}
router := gin.New()
router.POST("/api/agent/user/tasks/:task_id/deployment-draft", UserOrV2DeviceAuth(), func(c *gin.Context) {
if got := c.GetInt("id"); got != 42 {
t.Fatalf("id context = %d, want 42", got)
}
var payload struct {
Objective string `json:"objective"`
}
if err := c.ShouldBindJSON(&payload); err != nil {
t.Fatalf("bind decrypted body: %v", err)
}
if payload.Objective != "encrypted sub task" {
t.Fatalf("objective = %q", payload.Objective)
}
c.JSON(http.StatusOK, gin.H{"success": true})
})
path := "/api/agent/user/tasks/task-v2/deployment-draft"
body := []byte(`{"objective":"encrypted sub task"}`)
req := newEncryptedV2Request(t, http.MethodPost, path, body, deviceID, fingerprint, privateKey)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
}
func TestUserOrV2DeviceAuthDecryptsEncryptedSwarmRequest(t *testing.T) {
setupV2UserAuthTestDB(t)
gin.SetMode(gin.TestMode)
deviceID := "device-v2-swarm"
fingerprint := strings.Repeat("d", 64)
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate ed25519 key: %v", err)
}
publicKeyB64 := base64.StdEncoding.EncodeToString(privateKey.Public().(ed25519.PublicKey))
deviceIDCopy := deviceID
token := model.Token{
UserId: 42,
Key: "sk-v2-swarm-test",
Status: common.TokenStatusEnabled,
Name: "v2 swarm test",
ExpiredTime: -1,
UnlimitedQuota: true,
DeviceId: &deviceIDCopy,
DevicePubkey: &publicKeyB64,
DeviceFingerprint: fingerprint,
RequireDeviceBinding: true,
}
if err := token.Insert(); err != nil {
t.Fatalf("insert token: %v", err)
}
router := gin.New()
router.POST("/api/swarms", UserOrV2DeviceAuth(), func(c *gin.Context) {
if got := c.GetInt("id"); got != 42 {
t.Fatalf("id context = %d, want 42", got)
}
var payload struct {
OrchestrationPlan struct {
Objective string `json:"objective"`
} `json:"orchestration_plan"`
}
if err := c.ShouldBindJSON(&payload); err != nil {
t.Fatalf("bind decrypted body: %v", err)
}
if payload.OrchestrationPlan.Objective != "encrypted swarm task" {
t.Fatalf("objective = %q", payload.OrchestrationPlan.Objective)
}
c.JSON(http.StatusOK, gin.H{"success": true})
})
body := []byte(`{"orchestration_plan":{"objective":"encrypted swarm task"}}`)
req := newEncryptedV2Request(t, http.MethodPost, "/api/swarms", body, deviceID, fingerprint, privateKey)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
}
// TestUserOrV2DeviceAuthVerifiesNoBodySignedGet covers the GET path: fetch
// forbids a body on GET, so the client signs with the same canonical but an
// empty body hash and no Content-Encoding (gap P0-1).
func TestUserOrV2DeviceAuthVerifiesNoBodySignedGet(t *testing.T) {
setupV2UserAuthTestDB(t)
gin.SetMode(gin.TestMode)
deviceID := "device-v2-get"
fingerprint := strings.Repeat("e", 64)
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate ed25519 key: %v", err)
}
publicKeyB64 := base64.StdEncoding.EncodeToString(privateKey.Public().(ed25519.PublicKey))
deviceIDCopy := deviceID
token := model.Token{
UserId: 42,
Key: "sk-v2-get-test",
Status: common.TokenStatusEnabled,
Name: "v2 get test",
ExpiredTime: -1,
UnlimitedQuota: true,
DeviceId: &deviceIDCopy,
DevicePubkey: &publicKeyB64,
DeviceFingerprint: fingerprint,
RequireDeviceBinding: true,
}
if err := token.Insert(); err != nil {
t.Fatalf("insert token: %v", err)
}
router := gin.New()
router.GET("/api/heicode/sub-agile/tasks/:task_id/workflow", UserOrV2DeviceAuth(), func(c *gin.Context) {
if got := c.GetInt("id"); got != 42 {
t.Fatalf("id context = %d, want 42", got)
}
c.JSON(http.StatusOK, gin.H{"success": true})
})
path := "/api/heicode/sub-agile/tasks/dep_x/workflow"
req := newSignedV2GetRequest(t, path, deviceID, fingerprint, privateKey)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
}
// newSignedV2GetRequest builds a no-body device-signed GET: ephemeral pubkey
// header + signature over the canonical with sha256("") as the body hash, and
// NO Content-Encoding (no encrypted body).
func newSignedV2GetRequest(t *testing.T, path, deviceID, fingerprint string, signingKey ed25519.PrivateKey) *http.Request {
t.Helper()
method := http.MethodGet
ephPriv := make([]byte, curve25519.ScalarSize)
if _, err := rand.Read(ephPriv); err != nil {
t.Fatalf("generate eph private key: %v", err)
}
ephPriv[0] &= 248
ephPriv[31] &= 127
ephPriv[31] |= 64
ephPub, err := curve25519.X25519(ephPriv, curve25519.Basepoint)
if err != nil {
t.Fatalf("derive eph public key: %v", err)
}
ephPubB64 := base64.StdEncoding.EncodeToString(ephPub)
headerNonce := randomNonce()
timestamp := fmt.Sprint(time.Now().UnixMilli())
emptyHash := sha256.Sum256(nil)
canonical := strings.Join([]string{
method,
path,
timestamp,
headerNonce,
fingerprint,
ephPubB64,
hex.EncodeToString(emptyHash[:]),
}, "\n")
digest := sha256.Sum256([]byte(canonical))
signature := ed25519.Sign(signingKey, digest[:])
req := httptest.NewRequest(method, path, nil)
req.Header.Set(HeaderEphPubkey, ephPubB64)
req.Header.Set(HeaderDeviceID, deviceID)
req.Header.Set(HeaderTimestamp, timestamp)
req.Header.Set(HeaderNonce, headerNonce)
req.Header.Set(HeaderFingerprint, fingerprint)
req.Header.Set(HeaderSignature, base64.StdEncoding.EncodeToString(signature))
req.Header.Set("Accept", "application/json")
return req
}
func setupV2UserAuthTestDB(t *testing.T) *gorm.DB {
t.Helper()
common.UsingSQLite = true
common.UsingMySQL = false
common.UsingPostgreSQL = false
common.RedisEnabled = false
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
model.DB = db
model.LOG_DB = db
if err := db.AutoMigrate(&model.User{}, &model.Token{}, &model.ServerKey{}); err != nil {
t.Fatalf("automigrate: %v", err)
}
if err := db.Create(&model.User{
Id: 42,
Username: "v2-user",
Password: "not-used",
Status: common.UserStatusEnabled,
Group: "default",
Quota: 1000000,
}).Error; err != nil {
t.Fatalf("insert user: %v", err)
}
if err := service.EnsureServerECDHKey(); err != nil {
t.Fatalf("ensure server ecdh key: %v", err)
}
t.Cleanup(func() {
if model.DB == db {
model.DB = nil
}
if model.LOG_DB == db {
model.LOG_DB = nil
}
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
return db
}
func newEncryptedV2Request(t *testing.T, method, path string, plaintext []byte, deviceID, fingerprint string, signingKey ed25519.PrivateKey) *http.Request {
t.Helper()
serverPubB64, err := service.GetServerECDHPublicKeyB64()
if err != nil {
t.Fatalf("get server pubkey: %v", err)
}
serverPub, err := base64.StdEncoding.DecodeString(serverPubB64)
if err != nil {
t.Fatalf("decode server pubkey: %v", err)
}
ephPriv := make([]byte, curve25519.ScalarSize)
if _, err := rand.Read(ephPriv); err != nil {
t.Fatalf("generate eph private key: %v", err)
}
ephPriv[0] &= 248
ephPriv[31] &= 127
ephPriv[31] |= 64
ephPub, err := curve25519.X25519(ephPriv, curve25519.Basepoint)
if err != nil {
t.Fatalf("derive eph public key: %v", err)
}
shared, err := curve25519.X25519(ephPriv, serverPub)
if err != nil {
t.Fatalf("derive shared key: %v", err)
}
r := hkdf.New(sha256.New, shared, nil, []byte("heicode-aead-v1"))
key := make([]byte, chacha20poly1305.KeySize)
if _, err := io.ReadFull(r, key); err != nil {
t.Fatalf("derive aead key: %v", err)
}
aead, err := chacha20poly1305.New(key)
if err != nil {
t.Fatalf("new aead: %v", err)
}
headerNonce := randomNonce()
timestamp := fmt.Sprint(time.Now().UnixMilli())
aad := strings.Join([]string{deviceID, timestamp, headerNonce, method, path}, "|")
aeadNonce := make([]byte, aead.NonceSize())
if _, err := rand.Read(aeadNonce); err != nil {
t.Fatalf("generate aead nonce: %v", err)
}
ciphertext := aead.Seal(nil, aeadNonce, plaintext, []byte(aad))
encryptedBody := append(append([]byte{}, aeadNonce...), ciphertext...)
ephPubB64 := base64.StdEncoding.EncodeToString(ephPub)
bodyHash := sha256.Sum256(plaintext)
canonical := strings.Join([]string{
method,
path,
timestamp,
headerNonce,
fingerprint,
ephPubB64,
hex.EncodeToString(bodyHash[:]),
}, "\n")
digest := sha256.Sum256([]byte(canonical))
signature := ed25519.Sign(signingKey, digest[:])
req := httptest.NewRequest(method, path, bytes.NewReader(encryptedBody))
req.Header.Set("Content-Encoding", V2ContentEncoding)
req.Header.Set(HeaderEphPubkey, ephPubB64)
req.Header.Set(HeaderDeviceID, deviceID)
req.Header.Set(HeaderTimestamp, timestamp)
req.Header.Set(HeaderNonce, headerNonce)
req.Header.Set(HeaderFingerprint, fingerprint)
req.Header.Set(HeaderSignature, base64.StdEncoding.EncodeToString(signature))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
return req
}