sync: collector/ 同步自 inkstone/arcrun-rag@7ade413(桌面小幫手 0.18.49)

This commit is contained in:
2026-08-29 11:38:40 +08:00
parent 3cc8788e9d
commit 30a087c14d
6 changed files with 234 additions and 7 deletions
+173
View File
@@ -0,0 +1,173 @@
package collector
// foldertree_machine_test.go — 資料夾樹要說得出「**是哪一台機器報上來的**」
//`inkstone/Arcrun#180`)。
//
// 這張票的現象是 leo 2026-08-28 的原話:
//
// 「youlin 從桌面同步 4 個資料夾,雲端顯示 16 個,**這是把別的帳號同步的資料夾外洩了?**」
//
// 實查後不是外洩(那 17 個庫全是他自己歷史上同步過的),但**畫面說不出來**:
// 雲端的庫清單是「總庫 → 機器 → 資料夾」三層,而樹的上行酬載裡沒有機器身分
// ⇒ 每一個庫都只能掛在「未知來源」底下 ⇒ 看起來就像別人的東西。
//
// 🔴 本檔守的是「**送出去的東西身上有沒有這一格**」,不是畫面長怎樣(那在 matrix/arcrun)。
// 三個檢查點,各對應一種會靜默壞掉的方式:
//
// ① 酬載欄名要與卡片那條路一字不差 —— 收端不必為了樹另認一組欄位
// ② 改名要算成「內容變了」 —— 否則冪等閘會讓新名字永遠送不出去
// ③ 真的跑一輪,兩邊都要有 —— 有人在別處新增 BuildFolderTree 呼叫卻忘了蓋章時,這條會紅
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
)
// ① 酬載欄名:與 folderindex.goinventory.gosourcerepair.go 送卡片時完全相同。
func TestSyncFolderTree酬載帶得出機器身分(t *testing.T) {
var got map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &got)
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}))
defer srv.Close()
root := t.TempDir()
mustWrite(t, filepath.Join(root, "note.md"), "# note")
tree := buildTreeFromDisk(t, root).StampMachine(MachineIdentity{
ID: "youlinhsieh@Leo-MBA",
Label: "教育部 Leo 的 Mac",
})
cfg := &DirectConfig{CypherURL: srv.URL, APIKey: "demo"}
m := &Manifest{Root: root, Entries: map[string]*ManifestEntry{}}
res := syncFolderTree(cfg, root, m, tree, false, time.Unix(1786900000, 0))
if res == nil || res.Status != "ingested" {
t.Fatalf("應送達:%+v", res)
}
// 🔴 欄名寫死在這裡是刻意的:收端(cypher-executor 的 /portal/daemon/folder-tree
// 認的就是這兩個字串。改欄名=改協定,這條測試就是那道閘。
if got["machine"] != "youlinhsieh@Leo-MBA" {
t.Errorf("酬載的 machine(比對鍵)不對:%v", got["machine"])
}
if got["machine_label"] != "教育部 Leo 的 Mac" {
t.Errorf("酬載的 machine_label(顯示名)不對:%v", got["machine_label"])
}
// 比對鍵與顯示名**必須是兩格**:只送一格的話,使用者改名就等於雲端多出一台機器。
if got["machine"] == got["machine_label"] {
t.Error("改過名的機器,比對鍵與顯示名不該相同——有一格被另一格頂替了")
}
}
// ② 改名要算成「內容變了」。
//
// 這是本檔最容易被忽略的一格:`Hash()` 是冪等閘的唯一判準,機器身分若沒進雜湊,
// 使用者在 config.json 改了 `machine_label` 之後,樹的內容沒動 ⇒ 永遠不再送
// ⇒ **雲端會一直顯示舊名字,而且沒有任何機制會發現**。
func TestFolderTreeHash把機器改名算成內容變了(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "note.md"), "# note")
base := buildTreeFromDisk(t, root)
before := base.StampMachine(MachineIdentity{ID: "youlinhsieh@Leo-MBA", Label: "youlinhsieh@Leo-MBA"})
after := base.StampMachine(MachineIdentity{ID: "youlinhsieh@Leo-MBA", Label: "教育部 Leo 的 Mac"})
if before.Hash() == after.Hash() {
t.Error("改了顯示名雜湊卻沒變——冪等閘會讓新名字永遠送不上去")
}
// 沒蓋章的樹與蓋了章的樹也必須不同,否則升級到本版的機器不會補送一次。
if base.Hash() == before.Hash() {
t.Error("蓋上機器身分之後雜湊沒變——舊機器升級後不會補送,雲端永遠是「未知來源」")
}
// 但時間仍然不進雜湊(既有慣例,不准被這次改動弄壞)。
later := before
later.GeneratedAt = before.GeneratedAt + 999
if later.Hash() != before.Hash() {
t.Error("時間跑進雜湊了——那等於沒有冪等,每輪都會重送整棵樹")
}
}
// ③ 真的跑一輪:本機快照與上雲酬載**是同一份身分**。
//
// 🔴 這條守的是「有人忘了蓋章」。`StampMachine` 蓋在 direct.go 的 BuildFolderTree
// 呼叫點上;日後若有人在別處另起一個呼叫、或把蓋章那一行刪掉,
// 前兩條測試(各自手動蓋章)照樣會綠,只有這條會紅。
func TestRunDirectOnce的樹一路帶著機器身分(t *testing.T) {
var treeBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/portal/daemon/folder-tree") {
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &treeBody)
}
_ = json.NewEncoder(w).Encode(map[string]any{"success": true})
}))
defer srv.Close()
base := t.TempDir()
root := filepath.Join(base, "我的知識庫")
mustWrite(t, filepath.Join(root, "報銷規則.md"), "# 報銷規則\n內容")
orig := fetchCloudVersion
fetchCloudVersion = func(string) (string, bool) { return "stub", true }
defer func() { fetchCloudVersion = orig }()
manifest := filepath.Join(base, "manifest.json")
cfg := &DirectConfig{
Manifest: manifest,
MachineLabel: "教育部 Leo 的 Mac", // 使用者改過名 ⇒ 比對鍵與顯示名不同,兩格才分得出來
Accounts: []AccountConfig{{CypherURL: srv.URL, Namespace: "ns", WatchFolders: []string{root}}},
MaxRemoved: DefaultMaxRemovedRatio,
ExtractorExplicit: true, // 隔離變因:本測驗的是身分有沒有一路帶著,不是萃取
}
if _, exit, _ := RunDirectOnce(cfg, false); exit != 0 {
t.Fatalf("同步應成功,exit=%d", exit)
}
// 這一輪實際解析出來的身分(machine.json 就鑄在 manifest 旁邊)。
want := cfg.machineIdentity()
if want.ID == "" {
t.Fatal("這台機器連身分都沒鑄出來——machineid.go 那條線先壞了")
}
// 本機快照(桌面小幫手讀的那份)
store, err := LoadFolderTreeStore(FolderTreeStorePath(manifest))
if err != nil {
t.Fatalf("跑完一輪應該有快照可讀:%v", err)
}
tree, ok := store.Trees[root]
if !ok {
t.Fatalf("快照裡沒有這個看守根:%+v", store.Trees)
}
if tree.Machine != want.ID || tree.MachineLabel != want.Label {
t.Errorf("本機快照沒蓋機器身分:machine=%q label=%q(應為 %q%q",
tree.Machine, tree.MachineLabel, want.ID, want.Label)
}
// 上雲酬載
if treeBody == nil {
t.Fatal("這一輪沒有把樹送上去——沒東西可驗")
}
if treeBody["machine"] != want.ID || treeBody["machine_label"] != want.Label {
t.Errorf("上雲酬載沒帶機器身分:%v%v(應為 %q/%q",
treeBody["machine"], treeBody["machine_label"], want.ID, want.Label)
}
// 🔴 兩邊必須是同一份,不是各算各的(本 repo 對「第二份實作」的一貫紅線)。
if treeBody["machine"] != tree.Machine || treeBody["machine_label"] != tree.MachineLabel {
t.Error("本機快照與上雲酬載的機器身分不一致——有一邊自己另算了一份")
}
// 使用者改過名 ⇒ 顯示名該是他設的那個,比對鍵不該跟著變。
if want.Label != "教育部 Leo 的 Mac" {
t.Errorf("config 的 machine_label 沒被採用:%q", want.Label)
}
if want.ID == want.Label {
t.Error("改名把比對鍵也改掉了——那會讓雲端以為多出一台機器")
}
}