package main // apps_test.go — App 啟動器後端的驗收(inkstone/arcrun-rag#137) // // 🔴 這裡的假實例**不是憑印象寫的**:每一支端點的路徑、認證標頭、回應欄位名, // // 都是照 `inkstone/Arcrun` 分支 `feat/app-system-v0` 的原始碼抄下來的 // (`cypher-executor/src/routes/apps.ts`、`routes/portal-data.ts`、 // `lib/app-system.ts` 的 summarizeApp/detailApp、`routes/portal.ts` 的 /portal/login)。 // 抄錯了測試就會綠得很假——所以下面每一段都標了它對應上游哪一支。 // // 這組測試回答的是驗收條件 1 與 2 裡「機器答得出來」的那幾格: // - 清單真的來自實例(兩條路都試過),桌面端沒有任何寫死清單 // - 「問不到」與「一個都沒裝」分得出來 // - session 過期會被清掉、且清單仍然看得到(退回 API key) // - 動作真的打到 /portal/data/apps/:id/action,白名單由實例裁決 import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" ) // writeCfgWithAccount 造一份最小可用的 config.json(一個知識庫)。 func writeCfgWithAccount(t *testing.T, base string, acc accountCfg) { t.Helper() acc.CypherURL = base if acc.Namespace == "" { acc.Namespace = "ns-test" } cfg := map[string]any{ "manifest": filepath.Join(appDir(), "manifest.json"), "accounts": []accountCfg{acc}, } b, err := json.MarshalIndent(cfg, "", " ") if err != nil { t.Fatal(err) } if err := os.MkdirAll(appDir(), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(configPath(), b, 0o600); err != nil { t.Fatal(err) } } func tempHome(t *testing.T) { t.Helper() home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) } // fakeInstance 是「一台裝了 App 系統的 Arcrun 實例」的最小替身。 type fakeInstance struct { // sessionToken=目前有效的 session;空字串=任何 Bearer 都算過期。 sessionToken string password string apiKey string // 收到的動作(給測試斷言用) gotAction string gotPayload map[string]any // 讓測試模擬「實例壞掉」 listStatus int } func (f *fakeInstance) server(t *testing.T) *httptest.Server { t.Helper() // 上游 summarizeApp 的形狀(lib/app-system.ts) summary := []map[string]any{ {"id": "note", "name": "筆記", "icon": "🗒️", "has_ui": true, "version": "0.1.0"}, {"id": "weekly", "name": "週報", "icon": "📊", "has_ui": false, "version": "0.2.0"}, } mux := http.NewServeMux() // POST /portal/login(routes/portal.ts) mux.HandleFunc("/portal/login", func(w http.ResponseWriter, r *http.Request) { var in struct{ Email, Password string } _ = json.NewDecoder(r.Body).Decode(&in) if in.Password != f.password { w.WriteHeader(401) _ = json.NewEncoder(w).Encode(map[string]any{"error": "email 或密碼錯誤"}) return } f.sessionToken = "sess-" + in.Email _ = json.NewEncoder(w).Encode(map[string]any{ "success": true, "session_token": f.sessionToken, "session_expires_in": 604800, }) }) // GET /apps(routes/apps.ts,X-Arcrun-API-Key) mux.HandleFunc("/apps", func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("X-Arcrun-API-Key") != f.apiKey { w.WriteHeader(401) _ = json.NewEncoder(w).Encode(map[string]any{"error": "缺少 X-Arcrun-API-Key header"}) return } _ = json.NewEncoder(w).Encode(map[string]any{"apps": summary, "count": len(summary)}) }) // /portal/data/apps*(routes/portal-data.ts,Bearer portal session) mux.HandleFunc("/portal/data/apps", func(w http.ResponseWriter, r *http.Request) { if !f.authed(r) { w.WriteHeader(401) _ = json.NewEncoder(w).Encode(map[string]any{"error": "session 無效或已過期"}) return } if f.listStatus != 0 { w.WriteHeader(f.listStatus) _ = json.NewEncoder(w).Encode(map[string]any{"error": "實例內部錯誤"}) return } _ = json.NewEncoder(w).Encode(map[string]any{"apps": summary, "count": len(summary)}) }) mux.HandleFunc("/portal/data/apps/", func(w http.ResponseWriter, r *http.Request) { if !f.authed(r) { w.WriteHeader(401) _ = json.NewEncoder(w).Encode(map[string]any{"error": "session 無效或已過期"}) return } rest := strings.TrimPrefix(r.URL.Path, "/portal/data/apps/") if strings.HasSuffix(rest, "/action") { id := strings.TrimSuffix(rest, "/action") var in struct { Action string `json:"action"` Payload map[string]any `json:"payload"` } _ = json.NewDecoder(r.Body).Decode(&in) // 上游 runAppAction:白名單不中 → 403(K6,裁決點在實例不在呼叫端) if id != "note" || in.Action != "notes-create" { w.WriteHeader(403) _ = json.NewEncoder(w).Encode(map[string]any{"error": "這個動作不在這個 App 的白名單內"}) return } f.gotAction, f.gotPayload = in.Action, in.Payload _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{"id": "blk_1"}}) return } if rest != "note" { w.WriteHeader(404) _ = json.NewEncoder(w).Encode(map[string]any{"error": "找不到這個 App"}) return } // 上游 detailApp 的形狀 _ = json.NewEncoder(w).Encode(map[string]any{ "id": "note", "name": "筆記", "icon": "🗒️", "version": "0.1.0", "has_ui": true, "ui_html": "
hi
", "workflows": []map[string]string{{"name": "notes-create", "description": "寫入一則筆記"}}, "actions": []string{"notes-create"}, }) }) return httptest.NewServer(mux) } func (f *fakeInstance) authed(r *http.Request) bool { tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") return f.sessionToken != "" && tok == f.sessionToken } // ── 驗收條件 1:清單來自實例,兩條路都通 ──────────────────────────────── func TestListApps_UsesPortalSessionWhenAvailable(t *testing.T) { tempHome(t) fi := &fakeInstance{sessionToken: "sess-a", password: "pw", apiKey: "ns-test"} srv := fi.server(t) defer srv.Close() writeCfgWithAccount(t, srv.URL, accountCfg{ InstanceName: "我的庫", Email: "a@b.c", APIKey: "ns-test", PortalSession: "sess-a", PortalSessionExp: 0, }) got := (&App{}).ListApps(0) if got.Error != "" { t.Fatalf("不該有錯:%s", got.Error) } if got.Source != "session" { t.Fatalf("該走 portal session,實際走 %q", got.Source) } if len(got.Apps) != 2 || got.Apps[0].ID != "note" || got.Apps[0].Icon != "🗒️" || !got.Apps[0].HasUI { t.Fatalf("清單沒有原樣接住實例的回應:%+v", got.Apps) } if got.Account != "我的庫" { t.Fatalf("知識庫名字不對:%q", got.Account) } } func TestListApps_FallsBackToApiKeyWithoutSession(t *testing.T) { tempHome(t) fi := &fakeInstance{sessionToken: "", password: "pw", apiKey: "ns-test"} srv := fi.server(t) defer srv.Close() // 完全沒有 session(=剛升級上來、或從沒登入過)——九宮格照樣要看得到真資料 writeCfgWithAccount(t, srv.URL, accountCfg{Email: "a@b.c", APIKey: "ns-test"}) got := (&App{}).ListApps(0) if got.Error != "" || got.Source != "apikey" || len(got.Apps) != 2 { t.Fatalf("沒有 session 時該退回 API key 並照樣列出來:%+v", got) } } func TestListApps_ExpiredSessionIsClearedAndListStillWorks(t *testing.T) { tempHome(t) // 實例已經不認這張 session(f.sessionToken 與 config 裡那張不同) fi := &fakeInstance{sessionToken: "sess-new", password: "pw", apiKey: "ns-test"} srv := fi.server(t) defer srv.Close() writeCfgWithAccount(t, srv.URL, accountCfg{ Email: "a@b.c", APIKey: "ns-test", PortalSession: "sess-old", }) got := (&App{}).ListApps(0) if got.Error != "" || got.Source != "apikey" || len(got.Apps) != 2 { t.Fatalf("session 過期時清單仍該看得到(退回 API key):%+v", got) } // 已知無效的 token 要被丟掉,否則下一次還會再撞一次 401 cfg, _ := loadCfg() if cfg.Accounts[0].PortalSession != "" { t.Fatalf("過期的 session 沒有被清掉:%q", cfg.Accounts[0].PortalSession) } } // 🔴「問不到」不准偽裝成「一個都沒裝」——實例 5xx 時不准悄悄退回 API key 假裝正常。 func TestListApps_InstanceErrorIsNotDisguisedAsEmpty(t *testing.T) { tempHome(t) fi := &fakeInstance{sessionToken: "sess-a", password: "pw", apiKey: "ns-test", listStatus: 500} srv := fi.server(t) defer srv.Close() writeCfgWithAccount(t, srv.URL, accountCfg{Email: "a@b.c", APIKey: "ns-test", PortalSession: "sess-a"}) got := (&App{}).ListApps(0) if got.Error == "" { t.Fatalf("實例回 500 卻沒有回報錯誤:%+v", got) } if len(got.Apps) != 0 { t.Fatalf("出錯時不該給半份清單:%+v", got.Apps) } } func TestListApps_UnknownAccountSaysSo(t *testing.T) { tempHome(t) writeCfgWithAccount(t, "https://example.invalid", accountCfg{Email: "a@b.c", APIKey: "k"}) if got := (&App{}).ListApps(7); got.Error == "" { t.Fatalf("越界的索引該回錯,不是空清單") } } // ── 驗收條件 2:點進去能用 ─────────────────────────────────────────────── func TestGetApp_ReturnsDetailWithUI(t *testing.T) { tempHome(t) fi := &fakeInstance{sessionToken: "sess-a", password: "pw", apiKey: "ns-test"} srv := fi.server(t) defer srv.Close() writeCfgWithAccount(t, srv.URL, accountCfg{Email: "a@b.c", APIKey: "ns-test", PortalSession: "sess-a"}) d := (&App{}).GetApp(0, "note") if d.Error != "" || d.NeedsLogin { t.Fatalf("不該有錯也不該要登入:%+v", d) } if !d.HasUI || d.UIHtml != "
hi
" { t.Fatalf("自帶畫面沒接住:%+v", d) } if len(d.Workflows) != 1 || d.Workflows[0].Name != "notes-create" { t.Fatalf("工作流清單沒接住:%+v", d.Workflows) } if len(d.Actions) != 1 || d.Actions[0] != "notes-create" { t.Fatalf("動作白名單沒接住:%+v", d.Actions) } } // 沒有 session ⇒ 回 NeedsLogin(要長登入框),不是回一句錯誤。 // 這是刻意的:上游的 API key 面**沒有**詳情端點(routes/apps.ts 只有 install/list/delete), // 所以「看詳情」在協定上就只有 portal session 那一條路。 func TestGetApp_WithoutSessionAsksForLoginNotError(t *testing.T) { tempHome(t) fi := &fakeInstance{sessionToken: "", password: "pw", apiKey: "ns-test"} srv := fi.server(t) defer srv.Close() writeCfgWithAccount(t, srv.URL, accountCfg{Email: "a@b.c", APIKey: "ns-test"}) d := (&App{}).GetApp(0, "note") if !d.NeedsLogin { t.Fatalf("沒有 session 時該回 NeedsLogin:%+v", d) } if d.Error != "" { t.Fatalf("這不是錯誤,是還差一步:%q", d.Error) } if d.Email != "a@b.c" { t.Fatalf("登入框要能預填帳號:%q", d.Email) } } func TestPortalLogin_StoresSessionNotPassword(t *testing.T) { tempHome(t) fi := &fakeInstance{sessionToken: "", password: "pw", apiKey: "ns-test"} srv := fi.server(t) defer srv.Close() writeCfgWithAccount(t, srv.URL, accountCfg{Email: "a@b.c", APIKey: "ns-test"}) if err := (&App{}).PortalLogin(0, "wrong"); err == nil { t.Fatalf("密碼錯了應該要失敗") } if err := (&App{}).PortalLogin(0, "pw"); err != nil { t.Fatalf("密碼對了卻失敗:%v", err) } raw, err := os.ReadFile(configPath()) if err != nil { t.Fatal(err) } // 🔴 密碼零落地:整份 config 裡不准出現密碼字串 if strings.Contains(string(raw), "pw\"") || strings.Contains(string(raw), "\"password\"") { t.Fatalf("密碼被寫進 config 了:%s", raw) } cfg, _ := loadCfg() if cfg.Accounts[0].PortalSession == "" || cfg.Accounts[0].PortalSessionExp == 0 { t.Fatalf("session 沒有存起來:%+v", cfg.Accounts[0]) } // 存了 session 之後,詳情就拿得到了 if d := (&App{}).GetApp(0, "note"); d.NeedsLogin || d.Error != "" { t.Fatalf("登入之後詳情還是拿不到:%+v", d) } } func TestRunAppAction_HitsInstanceAndCarriesPayload(t *testing.T) { tempHome(t) fi := &fakeInstance{sessionToken: "sess-a", password: "pw", apiKey: "ns-test"} srv := fi.server(t) defer srv.Close() writeCfgWithAccount(t, srv.URL, accountCfg{Email: "a@b.c", APIKey: "ns-test", PortalSession: "sess-a"}) out, err := (&App{}).RunAppAction(0, "note", "notes-create", `{"text":"嗨"}`) if err != nil { t.Fatalf("動作失敗:%v", err) } if !strings.Contains(out, "blk_1") { t.Fatalf("沒有把實例的結果原樣帶回來:%s", out) } if fi.gotAction != "notes-create" || fi.gotPayload["text"] != "嗨" { t.Fatalf("payload 沒送到:%q %+v", fi.gotAction, fi.gotPayload) } } // 白名單是**實例**裁決的(K6)——桌面端不複製一份判斷,只負責把實例的話原樣說出來。 func TestRunAppAction_WhitelistIsDecidedByInstance(t *testing.T) { tempHome(t) fi := &fakeInstance{sessionToken: "sess-a", password: "pw", apiKey: "ns-test"} srv := fi.server(t) defer srv.Close() writeCfgWithAccount(t, srv.URL, accountCfg{Email: "a@b.c", APIKey: "ns-test", PortalSession: "sess-a"}) _, err := (&App{}).RunAppAction(0, "note", "rm-rf", "") if err == nil { t.Fatalf("不在白名單的動作該失敗") } if !strings.Contains(err.Error(), "白名單") { t.Fatalf("該把實例的原話帶回來,實際:%v", err) } } // ── 純函式 ────────────────────────────────────────────────────────────── func TestParseAppList_FallsBackToIdWhenNameMissing(t *testing.T) { apps := parseAppList([]byte(`{"apps":[{"id":"x","name":"","icon":"","has_ui":false,"version":"1"}]}`)) if len(apps) != 1 || apps[0].Name != "x" { t.Fatalf("沒有名字時該退回 id:%+v", apps) } } func TestUrlPathEscape(t *testing.T) { for in, want := range map[string]string{ "note": "note", "a/b": "a%2Fb", "a b": "a%20b", "../etc": "..%2Fetc", "筆記": "%E7%AD%86%E8%A8%98", } { if got := urlPathEscape(in); got != want { t.Fatalf("urlPathEscape(%q)=%q,想要 %q", in, got, want) } } }