package handlers import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/gin-gonic/gin" ) func init() { gin.SetMode(gin.TestMode) } // fakeDeviceLister 是 deviceLister 的測試替身。 type fakeDeviceLister struct { serials []string } func (f fakeDeviceLister) ListDevices() []deviceInfoView { out := make([]deviceInfoView, 0, len(f.serials)) for _, s := range f.serials { out = append(out, deviceInfoView{SerialNumber: s}) } return out } // fakeStore 是 localTokenStore 的測試替身。 type fakeStore struct { token string expiresAt time.Time err error isLimit bool gotDevice string } func (f *fakeStore) Issue(deviceID string) (string, time.Time, error) { f.gotDevice = deviceID return f.token, f.expiresAt, f.err } func (f *fakeStore) IsLimitErr(err error) bool { return f.isLimit && err != nil } // expectedHash 用測試獨立的實作重算 SHA-256(salt||serial) hex, // 避免直接呼叫被測函式(防同一個 bug 同時存在於實作與預期)。 func expectedHash(serial string) string { sum := sha256.Sum256([]byte("visiona-local-v1" + serial)) return hex.EncodeToString(sum[:]) } // TestHello_SerialHashes:hello 回 serialHashes(正確 hex)+ supportsLocalUpload, // 跳過空 / fake 序號,且不回 agentVersion / 完整 serial。 func TestHello_SerialHashes(t *testing.T) { h := &LocalHandler{ devices: fakeDeviceLister{serials: []string{ "0x1A2B3C4D", "", // 空 → 跳過 "0x00000000", // fake placeholder → 跳過 "0xDEADBEEF", }}, } w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodGet, "/api/local/hello", nil) h.Hello(c) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } var resp struct { Success bool `json:"success"` Data struct { SerialHashes []string `json:"serialHashes"` SupportsLocalUpload bool `json:"supportsLocalUpload"` } `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } if !resp.Success { t.Error("success 應為 true") } if !resp.Data.SupportsLocalUpload { t.Error("supportsLocalUpload 應為 true") } // 只應有兩個真實序號的 hash if len(resp.Data.SerialHashes) != 2 { t.Fatalf("serialHashes 數量 = %d, want 2(空與 fake 應被跳過)", len(resp.Data.SerialHashes)) } wantSet := map[string]bool{ expectedHash("0x1A2B3C4D"): true, expectedHash("0xDEADBEEF"): true, } for _, got := range resp.Data.SerialHashes { if !wantSet[got] { t.Errorf("非預期的 hash: %q", got) } // hex 必須是 lowercase、長度 64(SHA-256 = 32 bytes → 64 hex chars) if len(got) != 64 { t.Errorf("hash 長度 = %d, want 64", len(got)) } if got != strings.ToLower(got) { t.Errorf("hash 必須 lowercase hex,got %q", got) } } // 最小揭露:不得出現 agentVersion / 完整 serial 明文 bodyStr := w.Body.String() if strings.Contains(bodyStr, "agentVersion") { t.Error("hello 不應回 agentVersion") } if strings.Contains(bodyStr, "0x1A2B3C4D") || strings.Contains(bodyStr, "0xDEADBEEF") { t.Error("hello 不應回完整 serial 明文") } } // TestHello_EmptyDevices:無裝置 → serialHashes 為空陣列(非 null)。 func TestHello_EmptyDevices(t *testing.T) { h := &LocalHandler{devices: fakeDeviceLister{serials: nil}} w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodGet, "/api/local/hello", nil) h.Hello(c) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } if !strings.Contains(w.Body.String(), `"serialHashes":[]`) { t.Errorf("空裝置應回 serialHashes:[],got %s", w.Body.String()) } } // TestHashSerial_Contract 直接驗證被測 hashSerial 的字串拼接 / 編碼 / hex 大小寫 // 與前端逐 byte 一致性複核需要的契約:SHA-256("visiona-local-v1"||serial) lowercase hex。 func TestHashSerial_Contract(t *testing.T) { serial := "0x1A2B3C4D" got := hashSerial(serial) want := expectedHash(serial) if got != want { t.Errorf("hashSerial(%q) = %q, want %q", serial, got, want) } // 明確固定一個已知向量,供前端對照(salt+serial 直接字串相接、UTF-8、SHA-256、lowercase hex) // echo -n "visiona-local-v10x1A2B3C4D" | shasum -a 256 if len(got) != 64 || got != strings.ToLower(got) { t.Errorf("hex 格式不符:len=%d lower=%v", len(got), got == strings.ToLower(got)) } if LocalSerialSalt != "visiona-local-v1" { t.Errorf("LocalSerialSalt = %q, want visiona-local-v1(前後端共用常數)", LocalSerialSalt) } } // TestIssueToken_Success:正常發放 → 200 + token/expiresAt/ttlSeconds,deviceId 綁 serial。 func TestIssueToken_Success(t *testing.T) { exp := time.UnixMilli(1_700_000_000_000) store := &fakeStore{token: "tok-xyz", expiresAt: exp} h := &LocalHandler{store: store} w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodPost, "/api/local/issue-token", strings.NewReader(`{"serial":"0xAAAA0001"}`)) c.Request.Header.Set("Content-Type", "application/json") h.IssueToken(c) if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String()) } var resp struct { Data struct { Token string `json:"token"` ExpiresAt int64 `json:"expiresAt"` TTLSeconds int `json:"ttlSeconds"` } `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } if resp.Data.Token != "tok-xyz" { t.Errorf("token = %q, want tok-xyz", resp.Data.Token) } if resp.Data.ExpiresAt != exp.UnixMilli() { t.Errorf("expiresAt = %d, want %d", resp.Data.ExpiresAt, exp.UnixMilli()) } if resp.Data.TTLSeconds != 120 { t.Errorf("ttlSeconds = %d, want 120", resp.Data.TTLSeconds) } if store.gotDevice != "0xAAAA0001" { t.Errorf("Issue deviceID = %q, want 0xAAAA0001(token 綁 serial)", store.gotDevice) } } // TestIssueToken_Limit:達上限 → 429 LOCAL_TOKEN_LIMIT。 func TestIssueToken_Limit(t *testing.T) { store := &fakeStore{err: errors.New("limit"), isLimit: true} h := &LocalHandler{store: store} w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodPost, "/api/local/issue-token", strings.NewReader(`{"serial":"0xAAAA0001"}`)) c.Request.Header.Set("Content-Type", "application/json") h.IssueToken(c) if w.Code != http.StatusTooManyRequests { t.Fatalf("status = %d, want 429", w.Code) } if !strings.Contains(w.Body.String(), "LOCAL_TOKEN_LIMIT") { t.Errorf("body 應含 LOCAL_TOKEN_LIMIT,got %s", w.Body.String()) } } // TestIssueToken_MissingSerial:缺 serial → 400。 func TestIssueToken_MissingSerial(t *testing.T) { h := &LocalHandler{store: &fakeStore{}} w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodPost, "/api/local/issue-token", strings.NewReader(`{}`)) c.Request.Header.Set("Content-Type", "application/json") h.IssueToken(c) if w.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", w.Code) } }