Files

385 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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` 的 summarizeAppdetailApp、`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/loginroutes/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 /appsroutes/apps.tsX-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.tsBearer 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": "<div id=x>hi</div>",
"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)
// 實例已經不認這張 sessionf.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 != "<div id=x>hi</div>" {
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)
}
}
}