feat: harden runtime import and frontend verification workflows
Some checks failed
CI / Build & Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Release (push) Has been cancelled

This commit is contained in:
phamnazage-jpg
2026-06-04 20:02:36 +08:00
parent 7ce72cbc35
commit 77b7f7f660
32 changed files with 2657 additions and 109 deletions

5
.gitignore vendored
View File

@@ -1,4 +1,9 @@
.agent/ .agent/
.understand-anything/
artifacts/frontend-acceptance-matrix/
artifacts/provider-admin-matrix/
artifacts/real-host-acceptance/
internal/store/sqlite/?_pragma=foreign_keys(1)
# Local build outputs # Local build outputs
/bin/ /bin/

View File

@@ -0,0 +1,313 @@
(function initSub2ApiAdminCommon(global) {
const ADMIN_LINKS = [
{ key: "home", href: "/portal/admin/", label: "管理首页" },
{ key: "logical-groups", href: "/portal/admin/logical-groups.html", label: "逻辑分组 / 路由" },
{ key: "route-health", href: "/portal/admin/route-health.html", label: "Route 健康视图" },
{ key: "accounts", href: "/portal/admin/accounts.html", label: "帐号资产" },
{ key: "providers", href: "/portal/admin/providers.html", label: "新增模型 / 供应商目录" },
{ key: "batch-import", href: "/portal/admin/batch-import.html", label: "导入供应商帐号" },
{ key: "portal", href: "/portal/", label: "用户 Portal", target: "_blank", rel: "noreferrer" },
];
function escapeHTML(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function defaultApiBase() {
const origin = global.location && typeof global.location.origin === "string" ? global.location.origin : "";
if (!origin || origin === "null") {
return "/portal-admin-api";
}
return `${origin}/portal-admin-api`;
}
function normalizeApiBase(value) {
return (String(value || "").trim() || defaultApiBase()).replace(/\/+$/, "");
}
function authHeaders(tokenValue) {
const token = String(tokenValue || "").trim();
return token ? { Authorization: `Bearer ${token}` } : {};
}
function normalizeTone(tone) {
const value = String(tone || "").trim().toLowerCase();
if (!value || value === "note" || value === "info") {
return "note";
}
if (value === "warn") {
return "warning";
}
if (value === "error") {
return "danger";
}
return value;
}
function setStatus(element, message, tone = "note") {
if (!element) {
return;
}
element.textContent = message;
const normalizedTone = normalizeTone(tone);
if (normalizedTone !== "note") {
element.setAttribute("data-tone", normalizedTone);
} else {
element.removeAttribute("data-tone");
}
}
function describeSessionPayload(payload, options = {}) {
const fallbackUsername = String(options.usernameFallback || "admin").trim() || "admin";
const currentUsername = String(payload?.username || "").trim();
const effectiveUsername = currentUsername || fallbackUsername;
if (payload?.authenticated) {
const suffix = options.includeSessionSuffix ? "session" : "";
return {
tone: "success",
message: `管理员会话已建立:${currentUsername || "unknown"}${suffix}`,
};
}
if (payload?.login_enabled) {
if (options.allowBearerFallback) {
return {
tone: "warning",
message: `当前未登录。可用管理员用户名 ${effectiveUsername} 建立 session或继续使用 Bearer token。`,
};
}
return {
tone: "warning",
message: "当前未登录。可直接使用管理员用户名密码建立会话。",
};
}
return {
tone: "warning",
message: "当前实例未启用管理员登录,只能使用 Bearer token。",
};
}
function sessionStateSpec(kind, context = {}, options = {}) {
const username = String(context.username || options.usernameFallback || "unknown").trim() || "unknown";
const message = context.error instanceof Error ? context.error.message : String(context.error || "").trim();
switch (kind) {
case "missing_credentials":
return {
tone: "warning",
message: "请先输入管理员用户名和密码。",
};
case "login_success":
return {
tone: "success",
message: `管理员会话已建立:${username}`,
};
case "login_failed":
return {
tone: "danger",
message: `管理员登录失败:${message || "未知错误"}`,
};
case "logout_success":
return {
tone: "warning",
message: "管理员会话已退出。",
};
case "logout_failed":
return {
tone: "danger",
message: `退出会话失败:${message || "未知错误"}`,
};
case "check_failed":
return {
tone: "danger",
message: `检查管理员会话失败:${message || "未知错误"}`,
};
default:
return {
tone: "note",
message: String(context.message || ""),
};
}
}
function applySessionPayload(element, payload, options = {}) {
const status = describeSessionPayload(payload, options);
setStatus(element, status.message, status.tone);
return status;
}
function setSessionState(element, kind, context = {}, options = {}) {
const status = sessionStateSpec(kind, context, options);
setStatus(element, status.message, status.tone);
return status;
}
async function requestJSON(client, path, options = {}) {
const { skipAuth = false, headers = {}, ...rest } = options;
const finalHeaders = { Accept: "application/json", ...headers };
if (!skipAuth) {
Object.assign(finalHeaders, authHeaders(client.adminTokenInput && client.adminTokenInput.value));
}
const response = await fetch(`${normalizeApiBase(client.apiBaseInput && client.apiBaseInput.value)}${path}`, {
...rest,
credentials: "include",
headers: finalHeaders,
});
const text = await response.text();
let payload = {};
try {
payload = text ? JSON.parse(text) : {};
} catch {
payload = { raw: text };
}
if (!response.ok) {
const message = payload?.error?.message || payload?.message || payload?.error || payload?.raw || `HTTP ${response.status}`;
throw new Error(message);
}
return payload;
}
function readStoredConfig(storageKey) {
try {
return JSON.parse(global.localStorage.getItem(storageKey) || "{}");
} catch (error) {
console.warn(`failed to parse ${storageKey}`, error);
return {};
}
}
function writeStoredConfig(storageKey, payload) {
global.localStorage.setItem(storageKey, JSON.stringify(payload));
}
function renderAdminNav(container, currentKey) {
if (!container) {
return;
}
container.innerHTML = ADMIN_LINKS.map((link) => {
const currentClass = currentKey === link.key ? " is-current" : "";
const target = link.target ? ` target="${link.target}"` : "";
const rel = link.rel ? ` rel="${link.rel}"` : "";
return `<a href="${escapeHTML(link.href)}"${target}${rel} class="${currentClass.trim()}">${escapeHTML(link.label)}</a>`;
}).join("");
}
function createAdminPageRuntime(options) {
const client = {
apiBaseInput: options.apiBaseInput,
adminTokenInput: options.adminTokenInput,
};
const sessionPresentation = options.sessionPresentation || {};
async function refreshAdminSession() {
try {
const payload = await requestJSON(client, "/api/admin/session", {
skipAuth: !options.includeAuthOnSessionCheck,
});
if (payload.username && options.adminUsernameInput && !options.adminUsernameInput.value.trim()) {
options.adminUsernameInput.value = payload.username;
}
applySessionPayload(options.adminSessionStatus, payload, sessionPresentation);
return payload;
} catch (error) {
setSessionState(options.adminSessionStatus, "check_failed", { error }, sessionPresentation);
throw error;
}
}
async function loginAdminSession() {
const username = options.adminUsernameInput ? options.adminUsernameInput.value.trim() : "";
const password = options.adminPasswordInput ? options.adminPasswordInput.value : "";
if (!username || !password) {
const error = new Error("管理员用户名和密码不能为空");
setSessionState(options.adminSessionStatus, "missing_credentials", {}, sessionPresentation);
throw error;
}
try {
const payload = await requestJSON(client, "/api/admin/session/login", {
method: "POST",
skipAuth: true,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (options.adminPasswordInput) {
options.adminPasswordInput.value = "";
}
if (typeof options.onSessionPersist === "function") {
options.onSessionPersist();
}
setSessionState(options.adminSessionStatus, "login_success", { username: payload.username || username }, sessionPresentation);
return payload;
} catch (error) {
setSessionState(options.adminSessionStatus, "login_failed", { error }, sessionPresentation);
throw error;
}
}
async function logoutAdminSession() {
try {
const response = await fetch(`${normalizeApiBase(options.apiBaseInput && options.apiBaseInput.value)}/api/admin/session/logout`, {
method: "POST",
credentials: "include",
headers: authHeaders(options.adminTokenInput && options.adminTokenInput.value),
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `HTTP ${response.status}`);
}
if (options.adminPasswordInput) {
options.adminPasswordInput.value = "";
}
setSessionState(options.adminSessionStatus, "logout_success", {}, sessionPresentation);
} catch (error) {
setSessionState(options.adminSessionStatus, "logout_failed", { error }, sessionPresentation);
throw error;
}
}
return {
defaultApiBase,
normalizeApiBase() {
return normalizeApiBase(options.apiBaseInput && options.apiBaseInput.value);
},
authHeaders() {
return authHeaders(options.adminTokenInput && options.adminTokenInput.value);
},
requestJSON(path, requestOptions = {}) {
return requestJSON(client, path, requestOptions);
},
readStoredConfig,
writeStoredConfig,
renderAdminNav,
setStatus,
applySessionPayload(element, payload) {
return applySessionPayload(element, payload, sessionPresentation);
},
setSessionState(element, kind, context = {}) {
return setSessionState(element, kind, context, sessionPresentation);
},
refreshAdminSession,
loginAdminSession,
logoutAdminSession,
};
}
global.Sub2ApiAdminCommon = {
ADMIN_LINKS,
createAdminPageRuntime,
defaultApiBase,
normalizeApiBase,
authHeaders,
normalizeTone,
setStatus,
describeSessionPayload,
applySessionPayload,
setSessionState,
readStoredConfig,
writeStoredConfig,
renderAdminNav,
};
})(window);

View File

@@ -27,7 +27,7 @@ location /portal/ {
} }
location /portal-proxy/ { location /portal-proxy/ {
proxy_pass http://127.0.0.1:18169/; proxy_pass http://127.0.0.1:8080/;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -36,7 +36,7 @@ location /portal-proxy/ {
} }
location /portal-admin-api/ { location /portal-admin-api/ {
proxy_pass http://127.0.0.1:18173/; proxy_pass http://127.0.0.1:18190/;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -49,7 +49,7 @@ location /kimi-portal/ {
} }
location /kimi-portal-proxy/ { location /kimi-portal-proxy/ {
proxy_pass http://127.0.0.1:18169/; proxy_pass http://127.0.0.1:8080/;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -58,7 +58,7 @@ location /kimi-portal-proxy/ {
} }
location /kimi/ { location /kimi/ {
proxy_pass http://127.0.0.1:18169/; proxy_pass http://127.0.0.1:8080/;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -67,7 +67,7 @@ location /kimi/ {
} }
location /kimi-v1/ { location /kimi-v1/ {
proxy_pass http://127.0.0.1:18169/v1/; proxy_pass http://127.0.0.1:8080/v1/;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@@ -0,0 +1,133 @@
package app
import (
"encoding/base64"
"strings"
"testing"
"time"
)
func TestAdminSessionDebugValue(t *testing.T) {
secret := "test-secret"
username := "admin"
expiresAt := time.Now().Add(time.Hour)
result := adminSessionDebugValue(secret, username, expiresAt)
// Result should be a hex string
if result == "" {
t.Error("adminSessionDebugValue should return non-empty string")
}
// Should be valid hex (only contains hex characters)
for _, c := range result {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
t.Errorf("adminSessionDebugValue returned non-hex character: %c", c)
}
}
}
func TestAdminSessionPayload(t *testing.T) {
tests := []struct {
name string
raw string
wantUser string
wantExp bool
}{
{
name: "valid payload",
raw: createValidPayload("admin", "1234567890"),
wantUser: "admin",
wantExp: true,
},
{
name: "invalid format - no dot",
raw: "invalid-no-dot",
wantUser: "",
wantExp: false,
},
{
name: "invalid format - too many dots",
raw: "part1.part2.part3",
wantUser: "",
wantExp: false,
},
{
name: "invalid base64",
raw: "invalid!!!.signature",
wantUser: "",
wantExp: false,
},
{
name: "empty string",
raw: "",
wantUser: "",
wantExp: false,
},
{
name: "single part",
raw: "onlyonepart",
wantUser: "",
wantExp: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
payload := adminSessionPayload(tt.raw)
// All results should have "raw" field
if _, ok := payload["raw"]; !ok {
t.Error("payload should contain 'raw' field")
}
if tt.wantUser != "" {
if user, ok := payload["username"].(string); !ok || user != tt.wantUser {
t.Errorf("username = %v, want %v", user, tt.wantUser)
}
}
if tt.wantExp {
if _, ok := payload["expires_unix"]; !ok {
t.Error("expected expires_unix field")
}
if _, ok := payload["payload"]; !ok {
t.Error("expected payload field")
}
}
})
}
}
func TestMarshalAdminSessionPayload(t *testing.T) {
validPayload := createValidPayload("admin", "1234567890")
result := marshalAdminSessionPayload(validPayload)
// Result should be valid JSON
if result == "" {
t.Error("marshalAdminSessionPayload should return non-empty string")
}
// Should contain expected fields
if !strings.Contains(result, "raw") {
t.Error("result should contain 'raw' field")
}
if !strings.Contains(result, "username") {
t.Error("result should contain 'username' field")
}
// Test with invalid payload
invalidResult := marshalAdminSessionPayload("invalid")
if invalidResult == "" {
t.Error("marshalAdminSessionPayload with invalid input should still return something")
}
}
// createValidPayload creates a valid payload string for testing
func createValidPayload(username, expires string) string {
body := username + "|" + expires
encoded := base64.RawURLEncoding.EncodeToString([]byte(body))
return encoded + ".signature"
}

View File

@@ -21,13 +21,13 @@ func NewServer(listenAddr string, handler http.Handler, listenerFactory Listener
} }
server := &Server{ server := &Server{
server: &http.Server{ server: &http.Server{
Addr: listenAddr, Addr: listenAddr,
Handler: handler, Handler: handler,
ReadTimeout: 30 * time.Second, ReadTimeout: 30 * time.Second,
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second, IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20, // 1MB MaxHeaderBytes: 1 << 20, // 1MB
}, },
listen: net.Listen, listen: net.Listen,
} }

View File

@@ -743,25 +743,25 @@ func TestServerAddrReturnsConfiguredAddress(t *testing.T) {
func TestServerHasTimeoutConfiguration(t *testing.T) { func TestServerHasTimeoutConfiguration(t *testing.T) {
server := NewServer("127.0.0.1:0", nil, nil) server := NewServer("127.0.0.1:0", nil, nil)
s := server.server s := server.server
if s.ReadTimeout != 30*time.Second { if s.ReadTimeout != 30*time.Second {
t.Errorf("ReadTimeout = %v, want 30s", s.ReadTimeout) t.Errorf("ReadTimeout = %v, want 30s", s.ReadTimeout)
} }
if s.ReadHeaderTimeout != 10*time.Second { if s.ReadHeaderTimeout != 10*time.Second {
t.Errorf("ReadHeaderTimeout = %v, want 10s", s.ReadHeaderTimeout) t.Errorf("ReadHeaderTimeout = %v, want 10s", s.ReadHeaderTimeout)
} }
if s.WriteTimeout != 30*time.Second { if s.WriteTimeout != 30*time.Second {
t.Errorf("WriteTimeout = %v, want 30s", s.WriteTimeout) t.Errorf("WriteTimeout = %v, want 30s", s.WriteTimeout)
} }
if s.IdleTimeout != 120*time.Second { if s.IdleTimeout != 120*time.Second {
t.Errorf("IdleTimeout = %v, want 120s", s.IdleTimeout) t.Errorf("IdleTimeout = %v, want 120s", s.IdleTimeout)
} }
if s.MaxHeaderBytes != 1<<20 { if s.MaxHeaderBytes != 1<<20 {
t.Errorf("MaxHeaderBytes = %d, want %d", s.MaxHeaderBytes, 1<<20) t.Errorf("MaxHeaderBytes = %d, want %d", s.MaxHeaderBytes, 1<<20)
} }

View File

@@ -0,0 +1,57 @@
package app
import (
"context"
"testing"
"time"
)
// Test utility functions from batch_runtime.go
func TestSleepWithContext_Normal(t *testing.T) {
ctx := context.Background()
start := time.Now()
err := sleepWithContext(ctx, 1*time.Millisecond)
elapsed := time.Since(start)
if err != nil {
t.Errorf("sleep should not error: %v", err)
}
if elapsed < 1*time.Millisecond {
t.Error("should have slept")
}
}
func TestSleepWithContext_Canceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
start := time.Now()
err := sleepWithContext(ctx, 100*time.Millisecond)
elapsed := time.Since(start)
if err == nil {
t.Error("canceled context should return error")
}
if elapsed > 10*time.Millisecond {
t.Error("should have returned early due to cancellation")
}
}
func TestFirstNonEmptyString(t *testing.T) {
if firstNonEmptyString("", "", "value") != "value" {
t.Error("should return first non-empty")
}
}
func TestFirstNonEmptyString_First(t *testing.T) {
if firstNonEmptyString("first", "second", "third") != "first" {
t.Error("should return first value when all non-empty")
}
}
func TestFirstNonEmptyString_AllEmpty(t *testing.T) {
if firstNonEmptyString("", "", "") != "" {
t.Error("all empty should return empty")
}
}

View File

@@ -24,10 +24,10 @@ var (
// Overlay 错误 // Overlay 错误
var ( var (
ErrOverlayNotMatched = errors.New("overlay did not match") ErrOverlayNotMatched = errors.New("overlay did not match")
ErrNestedOutput = errors.New("output directory must not be nested inside source directory") ErrNestedOutput = errors.New("output directory must not be nested inside source directory")
ErrOutputExists = errors.New("output directory already exists") ErrOutputExists = errors.New("output directory already exists")
ErrSourceNotDir = errors.New("source must be a directory") ErrSourceNotDir = errors.New("source must be a directory")
ErrPatchFileNotFound = errors.New("patch file not found") ErrPatchFileNotFound = errors.New("patch file not found")
ErrPatchApplyFailed = errors.New("failed to apply patch") ErrPatchApplyFailed = errors.New("failed to apply patch")
) )

View File

@@ -0,0 +1,113 @@
package errs
import (
"strings"
"testing"
)
func TestContainsSubstring(t *testing.T) {
tests := []struct {
name string
s string
substr string
want bool
}{
{"exact match", "hello", "hello", true},
{"substring at start", "hello world", "hello", true},
{"substring at end", "hello world", "world", true},
{"substring in middle", "hello world foo", "world", true},
{"no match", "hello", "world", false},
{"empty string", "", "", true},
{"empty substring", "hello", "", true},
{"substr longer than s", "hi", "hello world", false},
{"partial match only", "hello", "hello world", false},
{"case sensitive", "Hello", "hello", false},
{"unicode substring", "你好世界", "世界", true},
{"unicode no match", "你好世界", "hello", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := containsSubstring(tt.s, tt.substr)
if got != tt.want {
t.Errorf("containsSubstring(%q, %q) = %v, want %v", tt.s, tt.substr, got, tt.want)
}
})
}
}
func TestContainsAt(t *testing.T) {
tests := []struct {
name string
s string
substr string
start int
want bool
}{
{"find at start", "hello world", "hello", 0, true},
{"find at offset", "hello world", "world", 6, true},
{"find with start inside", "hello world hello", "hello", 6, true},
{"not found after offset", "hello world", "hello", 6, false},
{"start beyond string", "hello", "lo", 10, false},
{"empty substr at start", "hello", "", 0, true},
{"empty substr at end", "hello", "", 5, true},
{"start at exact position", "hello world", "world", 6, true},
{"start_just_before", "hello world", "world", 5, true}, // "world" starts at index 6, so start=5 is within range
{"multiple occurrences", "ababab", "ab", 2, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := containsAt(tt.s, tt.substr, tt.start)
if got != tt.want {
t.Errorf("containsAt(%q, %q, %d) = %v, want %v", tt.s, tt.substr, tt.start, got, tt.want)
}
})
}
}
func TestAssertErrorContains_Success(t *testing.T) {
// This should pass - error contains substring
// Just verify it doesn't panic/fail
err := &testError{msg: "connection refused: something went wrong"}
AssertErrorContains(t, err, "connection refused")
}
func TestAssertErrorContains_EmptySubstring(t *testing.T) {
// Empty substring should pass with any error
err := &testError{msg: "any error"}
AssertErrorContains(t, err, "")
}
// testError is a simple error implementation for testing
type testError struct {
msg string
}
func (e *testError) Error() string {
return e.msg
}
// TestContainsSubstring_StandardLibrary verifies our implementation matches strings.Contains
func TestContainsSubstring_StandardLibrary(t *testing.T) {
testCases := []struct {
s string
substr string
}{
{"hello world", "world"},
{"", ""},
{"hello", ""},
{"", "x"},
{"hello", "world"},
{"ababab", "ab"},
}
for _, tc := range testCases {
ourResult := containsSubstring(tc.s, tc.substr)
stdResult := strings.Contains(tc.s, tc.substr)
if ourResult != stdResult {
t.Errorf("containsSubstring(%q, %q) = %v, strings.Contains = %v",
tc.s, tc.substr, ourResult, stdResult)
}
}
}

View File

@@ -28,7 +28,7 @@ func (c *Client) ProbeCapabilities(ctx context.Context) (HostCapabilities, error
return HostCapabilities{}, err return HostCapabilities{}, err
} }
accountTest, err := c.probeEndpoint(ctx, http.MethodGet, "/api/v1/admin/accounts/__probe__/test", nil) accountTest, err := c.probeEndpoint(ctx, http.MethodPost, "/api/v1/admin/accounts/__probe__/test", map[string]any{})
if err != nil { if err != nil {
return HostCapabilities{}, err return HostCapabilities{}, err
} }

View File

@@ -1103,6 +1103,9 @@ func TestProbeCapabilitiesWithMock(t *testing.T) {
callCount := 0 callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++ callCount++
if r.URL.Path == "/api/v1/admin/accounts/__probe__/test" && r.Method != http.MethodPost {
t.Fatalf("account test probe method = %s, want POST", r.Method)
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"data":[]}`)) _, _ = w.Write([]byte(`{"data":[]}`))

View File

@@ -8,8 +8,8 @@ import (
"strings" "strings"
"time" "time"
"log/slog"
"gopkg.in/natefinch/lumberjack.v2" "gopkg.in/natefinch/lumberjack.v2"
"log/slog"
) )
var logger *slog.Logger var logger *slog.Logger
@@ -21,7 +21,7 @@ type Config struct {
Rotation bool // enable file rotation Rotation bool // enable file rotation
MaxSize int // MB MaxSize int // MB
MaxBackups int MaxBackups int
MaxAge int // days MaxAge int // days
Compress bool Compress bool
} }
@@ -75,7 +75,7 @@ func InitWithConfig(cfg Config) {
} }
var handler slog.Handler var handler slog.Handler
switch cfg.Output { switch cfg.Output {
case "stdout": case "stdout":
handler = slog.NewJSONHandler(os.Stdout, opts) handler = slog.NewJSONHandler(os.Stdout, opts)
@@ -100,7 +100,7 @@ func InitWithConfig(cfg Config) {
handler = slog.NewJSONHandler(file, opts) handler = slog.NewJSONHandler(file, opts)
} }
} }
logger = slog.New(handler) logger = slog.New(handler)
slog.SetDefault(logger) slog.SetDefault(logger)
} }

View File

@@ -12,7 +12,7 @@ func TestInit(t *testing.T) {
defer func() { logger = oldLogger }() defer func() { logger = oldLogger }()
Init() Init()
if logger == nil { if logger == nil {
t.Error("logger should not be nil after Init") t.Error("logger should not be nil after Init")
} }
@@ -21,7 +21,7 @@ func TestInit(t *testing.T) {
func TestInitWithLevel(t *testing.T) { func TestInitWithLevel(t *testing.T) {
// Test different levels // Test different levels
levels := []string{"DEBUG", "INFO", "WARN", "ERROR", "unknown"} levels := []string{"DEBUG", "INFO", "WARN", "ERROR", "unknown"}
for _, level := range levels { for _, level := range levels {
InitWithLevel(level) InitWithLevel(level)
if logger == nil { if logger == nil {
@@ -46,7 +46,7 @@ func TestParseLevel(t *testing.T) {
{"unknown", slog.LevelInfo}, {"unknown", slog.LevelInfo},
{"", slog.LevelInfo}, {"", slog.LevelInfo},
} }
for _, test := range tests { for _, test := range tests {
result := parseLevel(test.input) result := parseLevel(test.input)
if result != test.expected { if result != test.expected {
@@ -64,19 +64,19 @@ func TestIsSensitive(t *testing.T) {
"access_token", "access_token",
"PRIVATE_KEY", "PRIVATE_KEY",
} }
for _, field := range sensitive { for _, field := range sensitive {
if !IsSensitive(field) { if !IsSensitive(field) {
t.Errorf("IsSensitive(%q) should be true", field) t.Errorf("IsSensitive(%q) should be true", field)
} }
} }
notSensitive := []string{ notSensitive := []string{
"name", "name",
"email", "email",
"user_id", "user_id",
} }
for _, field := range notSensitive { for _, field := range notSensitive {
if IsSensitive(field) { if IsSensitive(field) {
t.Errorf("IsSensitive(%q) should be false", field) t.Errorf("IsSensitive(%q) should be false", field)
@@ -96,7 +96,7 @@ func TestSanitizeAttrs(t *testing.T) {
{"secret_key", "xyz789", "[REDACTED]"}, {"secret_key", "xyz789", "[REDACTED]"},
{"name", "test", "test"}, {"name", "test", "test"},
} }
for _, test := range tests { for _, test := range tests {
attr := slog.String(test.key, test.value) attr := slog.String(test.key, test.value)
result := sanitizeAttrs(nil, attr) result := sanitizeAttrs(nil, attr)
@@ -109,7 +109,7 @@ func TestSanitizeAttrs(t *testing.T) {
func TestLoggingMethods(t *testing.T) { func TestLoggingMethods(t *testing.T) {
// Just verify methods don't panic // Just verify methods don't panic
Init() Init()
Info("test info message", "key", "value") Info("test info message", "key", "value")
Debug("test debug message", "key", "value") Debug("test debug message", "key", "value")
Warn("test warn message", "key", "value") Warn("test warn message", "key", "value")
@@ -138,7 +138,7 @@ func TestInitWithConfig(t *testing.T) {
cfg.Output = "stdout" cfg.Output = "stdout"
cfg.Level = "DEBUG" cfg.Level = "DEBUG"
InitWithConfig(cfg) InitWithConfig(cfg)
if logger == nil { if logger == nil {
t.Error("logger should not be nil after InitWithConfig") t.Error("logger should not be nil after InitWithConfig")
} }
@@ -151,9 +151,9 @@ func TestInitWithConfigFileOutput(t *testing.T) {
cfg.Output = tmpFile cfg.Output = tmpFile
cfg.Rotation = false cfg.Rotation = false
InitWithConfig(cfg) InitWithConfig(cfg)
Info("test message for file") Info("test message for file")
// Verify file was created // Verify file was created
if _, err := os.Stat(tmpFile); os.IsNotExist(err) { if _, err := os.Stat(tmpFile); os.IsNotExist(err) {
t.Errorf("log file %s should exist", tmpFile) t.Errorf("log file %s should exist", tmpFile)
@@ -162,27 +162,27 @@ func TestInitWithConfigFileOutput(t *testing.T) {
func TestDefaultConfig(t *testing.T) { func TestDefaultConfig(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
if cfg.Level != "INFO" { if cfg.Level != "INFO" {
t.Errorf("default Level = %s, want INFO", cfg.Level) t.Errorf("default Level = %s, want INFO", cfg.Level)
} }
if cfg.Output != "stdout" { if cfg.Output != "stdout" {
t.Errorf("default Output = %s, want stdout", cfg.Output) t.Errorf("default Output = %s, want stdout", cfg.Output)
} }
if cfg.MaxSize != 100 { if cfg.MaxSize != 100 {
t.Errorf("default MaxSize = %d, want 100", cfg.MaxSize) t.Errorf("default MaxSize = %d, want 100", cfg.MaxSize)
} }
if cfg.MaxBackups != 3 { if cfg.MaxBackups != 3 {
t.Errorf("default MaxBackups = %d, want 3", cfg.MaxBackups) t.Errorf("default MaxBackups = %d, want 3", cfg.MaxBackups)
} }
if cfg.MaxAge != 7 { if cfg.MaxAge != 7 {
t.Errorf("default MaxAge = %d, want 7", cfg.MaxAge) t.Errorf("default MaxAge = %d, want 7", cfg.MaxAge)
} }
if !cfg.Compress { if !cfg.Compress {
t.Error("default Compress should be true") t.Error("default Compress should be true")
} }

View File

@@ -0,0 +1,262 @@
package overlay
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"sub2api-cn-relay-manager/internal/pack"
)
func TestApplyEmptyPackDir(t *testing.T) {
_, err := Apply(context.Background(), ApplyRequest{
PackDir: "",
SourceDir: t.TempDir(),
Overlays: []pack.HostOverlay{{OverlayID: "test"}},
})
if err == nil || err.Error() != "pack dir is required" {
t.Errorf("Apply() error = %v, want 'pack dir is required'", err)
}
}
func TestApplyEmptySourceDir(t *testing.T) {
_, err := Apply(context.Background(), ApplyRequest{
PackDir: t.TempDir(),
SourceDir: "",
Overlays: []pack.HostOverlay{{OverlayID: "test"}},
})
if err == nil || err.Error() != "source dir is required" {
t.Errorf("Apply() error = %v, want 'source dir is required'", err)
}
}
func TestApplyEmptyOverlays(t *testing.T) {
_, err := Apply(context.Background(), ApplyRequest{
PackDir: t.TempDir(),
SourceDir: t.TempDir(),
Overlays: []pack.HostOverlay{},
})
if err == nil || err.Error() != "at least one host overlay is required" {
t.Errorf("Apply() error = %v, want 'at least one host overlay is required'", err)
}
}
func TestApplyOutputSameAsSource(t *testing.T) {
sourceDir := t.TempDir()
_, err := Apply(context.Background(), ApplyRequest{
PackDir: t.TempDir(),
SourceDir: sourceDir,
OutputDir: sourceDir,
Overlays: []pack.HostOverlay{{OverlayID: "test", PatchPath: "test.patch"}},
})
if err == nil || !strings.Contains(err.Error(), "must differ from source dir") {
t.Errorf("Apply() error = %v, want 'must differ from source dir'", err)
}
}
func TestApplyMissingSourceDir(t *testing.T) {
_, err := Apply(context.Background(), ApplyRequest{
PackDir: t.TempDir(),
SourceDir: "/nonexistent/path/that/does/not/exist",
Overlays: []pack.HostOverlay{{OverlayID: "test", PatchPath: "test.patch"}},
})
if err == nil {
t.Error("Apply() expected error for missing source dir")
}
}
func TestApplyStatOutputError(t *testing.T) {
// This tests the path where os.Stat returns an error other than IsNotExist
// Create a file as sourceDir to test non-directory source
filePath := filepath.Join(t.TempDir(), "notadir")
os.WriteFile(filePath, []byte("test"), 0644)
_, err := Apply(context.Background(), ApplyRequest{
PackDir: t.TempDir(),
SourceDir: filePath,
Overlays: []pack.HostOverlay{{OverlayID: "test", PatchPath: "test.patch"}},
})
if err == nil || !strings.Contains(err.Error(), "must be a directory") {
t.Errorf("Apply() error = %v, want 'must be a directory'", err)
}
}
func TestApplyCleanupOnFailure(t *testing.T) {
sourceDir := t.TempDir()
packDir := t.TempDir()
// Create a valid source structure
os.MkdirAll(filepath.Join(sourceDir, "backend"), 0755)
os.WriteFile(filepath.Join(sourceDir, "backend", "hello.txt"), []byte("hello\n"), 0644)
// Create an invalid patch that will fail
os.WriteFile(filepath.Join(packDir, "bad.patch"), []byte("invalid patch content"), 0644)
_, err := Apply(context.Background(), ApplyRequest{
PackDir: packDir,
SourceDir: sourceDir,
Overlays: []pack.HostOverlay{{OverlayID: "test", PatchPath: "bad.patch"}},
})
if err == nil {
t.Error("Apply() expected error for invalid patch")
}
// Output dir should be cleaned up
// We can't directly test this, but coverage will show the defer cleanupOutput path
}
func TestDefaultOutputDir(t *testing.T) {
overlays := []pack.HostOverlay{
{OverlayID: "overlay1"},
{OverlayID: "overlay2"},
{OverlayID: "test-overlay"},
}
result := defaultOutputDir("/tmp/source", overlays)
// Check that result contains source path and sanitized overlay IDs
if !strings.Contains(result, "source") {
t.Errorf("defaultOutputDir() = %v, should contain 'source'", result)
}
}
func TestDefaultOutputDirEmptyOverlayID(t *testing.T) {
overlays := []pack.HostOverlay{
{OverlayID: ""},
{OverlayID: "test"},
}
result := defaultOutputDir("/tmp/source", overlays)
// Should still work with empty overlay IDs
if result == "" {
t.Error("defaultOutputDir() returned empty string")
}
}
func TestSanitizePathToken(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"normal", "normal"},
{"with/slash", "with-slash"},
{"with\\backslash", "with-backslash"},
{"with spaces", "with-spaces"},
{"with:colon", "with-colon"},
{"UPPER", "upper"},
{"MiXeD", "mixed"},
{"", ""},
}
for _, tt := range tests {
result := sanitizePathToken(tt.input)
if result != tt.expected {
t.Errorf("sanitizePathToken(%q) = %q, want %q", tt.input, result, tt.expected)
}
}
}
func TestIsPathWithin(t *testing.T) {
tests := []struct {
path string
parent string
expected bool
}{
{"/a/b/c", "/a/b", true},
{"/a/b/c/d", "/a/b", true},
{"/a/b", "/a/b", true}, // Same path - returns true based on actual implementation
{"/a/bc", "/a/b", false}, // Prefix but not subdirectory
{"/x/y/z", "/a/b", false},
}
for _, tt := range tests {
result := isPathWithin(tt.path, tt.parent)
if result != tt.expected {
t.Errorf("isPathWithin(%q, %q) = %v, want %v", tt.path, tt.parent, result, tt.expected)
}
}
}
func TestFilterOverlays(t *testing.T) {
tests := []struct {
name string
overlays []pack.HostOverlay
filter string
wantCount int
wantErr bool
}{
{
name: "single match",
overlays: []pack.HostOverlay{{OverlayID: "test"}},
filter: "test",
wantCount: 1,
wantErr: false,
},
{
name: "no match",
overlays: []pack.HostOverlay{{OverlayID: "foo"}},
filter: "bar",
wantCount: 0,
wantErr: true,
},
{
name: "multiple with one match",
overlays: []pack.HostOverlay{{OverlayID: "a"}, {OverlayID: "b"}},
filter: "a",
wantCount: 1,
wantErr: false,
},
{
name: "first match taken",
overlays: []pack.HostOverlay{{OverlayID: "a", PatchPath: "1"}, {OverlayID: "a", PatchPath: "2"}},
filter: "a",
wantCount: 2, // Returns all matching items, not just first
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := FilterOverlays(tt.overlays, tt.filter)
if (err != nil) != tt.wantErr {
t.Errorf("FilterOverlays() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && len(result) != tt.wantCount {
t.Errorf("FilterOverlays() = %v, want %d items", result, tt.wantCount)
}
})
}
}
func TestContainsHelper(t *testing.T) {
// Test the contains helper function from executor.go
tests := []struct {
slice []string
item string
expected bool
}{
{[]string{"a", "b", "c"}, "b", true},
{[]string{"a", "b", "c"}, "d", false},
{[]string{}, "a", false},
{[]string{"a"}, "a", true},
}
for _, tt := range tests {
found := false
for _, s := range tt.slice {
if s == tt.item {
found = true
break
}
}
if found != tt.expected {
t.Errorf("contains check for %q in %v = %v, want %v", tt.item, tt.slice, found, tt.expected)
}
}
}

View File

@@ -99,6 +99,7 @@ func (r AccountImportResult) HasAdvisoryWarning() bool {
type hostAdapter interface { type hostAdapter interface {
sub2api.HostAdapter sub2api.HostAdapter
CheckGatewayAccess(ctx context.Context, req sub2api.GatewayAccessCheckRequest) (sub2api.GatewayAccessResult, error) CheckGatewayAccess(ctx context.Context, req sub2api.GatewayAccessCheckRequest) (sub2api.GatewayAccessResult, error)
CheckGatewayCompletion(ctx context.Context, req sub2api.GatewayCompletionCheckRequest) (sub2api.GatewayCompletionResult, error)
} }
func GatewayAccessReady(result sub2api.GatewayAccessResult) bool { func GatewayAccessReady(result sub2api.GatewayAccessResult) bool {

View File

@@ -911,7 +911,15 @@ func (f *fakeHostAdapter) GetHostVersion(context.Context) (string, error) {
return f.hostVersion, nil return f.hostVersion, nil
} }
func (f *fakeHostAdapter) ProbeCapabilities(context.Context) (sub2api.HostCapabilities, error) { func (f *fakeHostAdapter) ProbeCapabilities(context.Context) (sub2api.HostCapabilities, error) {
return sub2api.HostCapabilities{}, nil return sub2api.HostCapabilities{
Groups: true,
Channels: true,
Plans: true,
Accounts: true,
AccountTest: true,
AccountModels: true,
Subscriptions: true,
}, nil
} }
func (f *fakeHostAdapter) CreateGroup(_ context.Context, req sub2api.CreateGroupRequest) (sub2api.GroupRef, error) { func (f *fakeHostAdapter) CreateGroup(_ context.Context, req sub2api.CreateGroupRequest) (sub2api.GroupRef, error) {
f.createGroupCalls++ f.createGroupCalls++

View File

@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"sub2api-cn-relay-manager/internal/host/sub2api"
"sub2api-cn-relay-manager/internal/pack" "sub2api-cn-relay-manager/internal/pack"
"sub2api-cn-relay-manager/internal/store/sqlite" "sub2api-cn-relay-manager/internal/store/sqlite"
) )
@@ -66,6 +67,12 @@ func (s *RuntimeImportService) Import(ctx context.Context, req RuntimeImportRequ
if err != nil { if err != nil {
return RuntimeImportResult{}, fmt.Errorf("probe host capabilities: %w", err) return RuntimeImportResult{}, fmt.Errorf("probe host capabilities: %w", err)
} }
// Host readiness preflight check
if err := validateHostReadiness(capabilities); err != nil {
return RuntimeImportResult{}, fmt.Errorf("host readiness preflight failed: %w", err)
}
capabilityProbeJSON, err := json.Marshal(capabilities) capabilityProbeJSON, err := json.Marshal(capabilities)
if err != nil { if err != nil {
return RuntimeImportResult{}, fmt.Errorf("marshal host capabilities: %w", err) return RuntimeImportResult{}, fmt.Errorf("marshal host capabilities: %w", err)
@@ -302,3 +309,26 @@ func firstNonEmpty(values ...string) string {
} }
return "" return ""
} }
// validateHostReadiness performs preflight checks on host capabilities
// to ensure the host is ready for import operations.
func validateHostReadiness(caps sub2api.HostCapabilities) error {
var missing []string
if !caps.Groups {
missing = append(missing, "groups")
}
if !caps.Channels {
missing = append(missing, "channels")
}
if !caps.Accounts {
missing = append(missing, "accounts")
}
if !caps.AccountTest {
missing = append(missing, "account_test")
}
if len(missing) > 0 {
return fmt.Errorf("host missing required capabilities: %v", missing)
}
return nil
}

View File

@@ -806,3 +806,85 @@ func queryCount(t *testing.T, db *sql.DB, table string) int {
} }
return count return count
} }
func TestValidateHostReadiness(t *testing.T) {
t.Run("all capabilities present", func(t *testing.T) {
caps := sub2api.HostCapabilities{
Groups: true,
Channels: true,
Accounts: true,
AccountTest: true,
AccountModels: true,
Plans: true,
Subscriptions: true,
}
if err := validateHostReadiness(caps); err != nil {
t.Fatalf("validateHostReadiness() = %v, want nil", err)
}
})
t.Run("missing groups", func(t *testing.T) {
caps := sub2api.HostCapabilities{
Groups: false,
Channels: true,
Accounts: true,
AccountTest: true,
}
if err := validateHostReadiness(caps); err == nil {
t.Fatal("validateHostReadiness() = nil, want error for missing groups")
} else if !strings.Contains(err.Error(), "groups") {
t.Fatalf("validateHostReadiness() = %v, want error mentioning groups", err)
}
})
t.Run("missing multiple capabilities", func(t *testing.T) {
caps := sub2api.HostCapabilities{
Groups: false,
Channels: false,
Accounts: false,
AccountTest: false,
}
if err := validateHostReadiness(caps); err == nil {
t.Fatal("validateHostReadiness() = nil, want error for multiple missing capabilities")
}
})
t.Run("missing accounts", func(t *testing.T) {
caps := sub2api.HostCapabilities{
Groups: true,
Channels: true,
Accounts: false,
AccountTest: true,
}
if err := validateHostReadiness(caps); err == nil {
t.Fatal("validateHostReadiness() = nil, want error for missing accounts")
}
})
t.Run("missing test account", func(t *testing.T) {
caps := sub2api.HostCapabilities{
Groups: true,
Channels: true,
Accounts: true,
AccountTest: false,
}
if err := validateHostReadiness(caps); err == nil {
t.Fatal("validateHostReadiness() = nil, want error for missing account_test")
}
})
t.Run("plans and subscriptions not required", func(t *testing.T) {
caps := sub2api.HostCapabilities{
Groups: true,
Channels: true,
Accounts: true,
AccountTest: true,
AccountModels: false,
Plans: false,
Subscriptions: false,
}
if err := validateHostReadiness(caps); err != nil {
t.Fatalf("validateHostReadiness() = %v, want nil (plans/subscriptions are optional)", err)
}
})
}

View File

@@ -70,10 +70,10 @@ type routeLogSink interface {
} }
type ErrorMetrics struct { type ErrorMetrics struct {
FlushErrors int64 FlushErrors int64
WriteErrors int64 WriteErrors int64
DroppedEvents int64 DroppedEvents int64
mu sync.RWMutex mu sync.RWMutex
} }
func (e *ErrorMetrics) RecordFlushError() { func (e *ErrorMetrics) RecordFlushError() {

View File

@@ -20,6 +20,10 @@
- 例如: - 例如:
- `real_host_acceptance.sh` - `real_host_acceptance.sh`
- `import_remote43_provider.sh` - `import_remote43_provider.sh`
- `verify_frontend_acceptance_matrix.sh`
- `verify_portal_catalog_ui.sh`
- `verify_public_portal_browser.sh`
- `verify_accounts_admin_ui.sh`
- `verify_provider_admin_actions.sh` - `verify_provider_admin_actions.sh`
- `check_deepseek_completion_split.sh` - `check_deepseek_completion_split.sh`
- `scripts/test/` - `scripts/test/`
@@ -27,6 +31,7 @@
- 例如: - 例如:
- `test_real_host_scripts.sh` - `test_real_host_scripts.sh`
- `test_tksea_portal_assets.sh` - `test_tksea_portal_assets.sh`
- `verify_frontend_smoke.sh`
- `verify_quality_gates.sh` - `verify_quality_gates.sh`
## 放置规则 ## 放置规则
@@ -40,9 +45,12 @@
```bash ```bash
bash ./scripts/test/test_real_host_scripts.sh bash ./scripts/test/test_real_host_scripts.sh
bash ./scripts/test/test_tksea_portal_assets.sh bash ./scripts/test/test_tksea_portal_assets.sh
bash ./scripts/test/verify_frontend_smoke.sh
bash ./scripts/test/verify_quality_gates.sh bash ./scripts/test/verify_quality_gates.sh
scripts/deploy/build_local_image.sh scripts/deploy/build_local_image.sh
bash ./scripts/acceptance/real_host_acceptance.sh bash ./scripts/acceptance/real_host_acceptance.sh
bash ./scripts/acceptance/verify_frontend_acceptance_matrix.sh
bash ./scripts/acceptance/verify_public_portal_browser.sh
bash ./scripts/acceptance/verify_provider_admin_actions.sh bash ./scripts/acceptance/verify_provider_admin_actions.sh
``` ```
@@ -51,6 +59,8 @@ bash ./scripts/acceptance/verify_provider_admin_actions.sh
`scripts/test/verify_quality_gates.sh` 是当前推荐的一键测试入口,职责是: `scripts/test/verify_quality_gates.sh` 是当前推荐的一键测试入口,职责是:
- 统一执行: - 统一执行:
- `bash ./scripts/test/test_tksea_portal_assets.sh`
- `bash ./scripts/test/verify_frontend_smoke.sh`
- `gofmt -l .` - `gofmt -l .`
- `go vet ./...` - `go vet ./...`
- `go test -cover ./internal/...` - `go test -cover ./internal/...`

View File

@@ -0,0 +1,109 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# shellcheck disable=SC1091
source "$ROOT_DIR/scripts/acceptance/route_acceptance_lib.sh"
ACCOUNTS_ACCEPTANCE_ROOT="${ACCOUNTS_ACCEPTANCE_ROOT:-$ROOT_DIR/artifacts/frontend-acceptance-matrix}"
TS="${TS:-$(timestamp_token)}"
ARTIFACT_DIR="${ARTIFACT_DIR:-$ACCOUNTS_ACCEPTANCE_ROOT/${TS}_accounts_admin_ui}"
ACCOUNTS_PAGE_URL="${ACCOUNTS_PAGE_URL:-https://sub.tksea.top/portal/admin/accounts.html}"
ACCOUNT_ID="${ACCOUNT_ID:-}"
HOST_ID_FILTER="${HOST_ID_FILTER:-}"
PROVIDER_ID_FILTER="${PROVIDER_ID_FILTER:-}"
BINDING_STATE_FILTER="${BINDING_STATE_FILTER:-}"
LIMIT="${LIMIT:-50}"
ALLOW_EMPTY_ACCOUNTS="${ALLOW_EMPTY_ACCOUNTS:-0}"
require_var CRM_BASE
crm_auth_init
ensure_artifact_dir
curl_status_to_file "$ACCOUNTS_PAGE_URL" "$ARTIFACT_DIR/00-accounts-admin.html"
query="$(
python3 - "$HOST_ID_FILTER" "$PROVIDER_ID_FILTER" "$BINDING_STATE_FILTER" "$LIMIT" <<'PY'
import sys
from urllib.parse import urlencode
host_id, provider_id, binding_state, limit = sys.argv[1:5]
params = {}
if host_id:
params["host_id"] = host_id
if provider_id:
params["provider_id"] = provider_id
if binding_state:
params["binding_state"] = binding_state
if limit:
params["limit"] = limit
print(urlencode(params))
PY
)"
list_path="/api/provider-accounts"
if [[ -n "$query" ]]; then
list_path="$list_path?$query"
fi
save_json 01-provider-accounts "$(crm_curl_json GET "$list_path")"
if [[ -z "$ACCOUNT_ID" ]]; then
ACCOUNT_ID="$(
python3 - "$ARTIFACT_DIR/01-provider-accounts.json" "$ALLOW_EMPTY_ACCOUNTS" <<'PY'
import json
import sys
payload = json.load(open(sys.argv[1], "r", encoding="utf-8"))
allow_empty = sys.argv[2] == "1"
items = payload.get("provider_accounts") or []
if not items:
if allow_empty:
raise SystemExit(3)
raise SystemExit(2)
first = items[0]
print(first.get("id") or "")
PY
)" || ACCOUNT_ID=""
fi
if [[ -n "$ACCOUNT_ID" ]]; then
save_json 02-binding-candidates "$(crm_curl_json GET "/api/provider-accounts/$ACCOUNT_ID/binding-candidates")"
fi
python3 - "$ARTIFACT_DIR" "$ACCOUNT_ID" "$ALLOW_EMPTY_ACCOUNTS" >"$ARTIFACT_DIR/99-summary.json" <<'PY'
import json
import sys
from pathlib import Path
art_dir = Path(sys.argv[1])
account_id = sys.argv[2]
allow_empty = sys.argv[3] == "1"
page = (art_dir / "00-accounts-admin.html").read_text(encoding="utf-8")
accounts = json.loads((art_dir / "01-provider-accounts.json").read_text(encoding="utf-8")).get("provider_accounts") or []
assert "Provider Accounts Admin" in page
if not accounts and not allow_empty:
raise AssertionError("provider_accounts list is empty")
summary = {
"page_title_seen": "Provider Accounts Admin" in page,
"account_count": len(accounts),
"selected_account_id": account_id or "",
}
if accounts:
first = accounts[0]
summary["first_account_provider_id"] = first.get("provider_id")
summary["first_account_status"] = first.get("status") or first.get("account_status")
summary["first_account_binding_state"] = first.get("binding_state")
if account_id:
candidates = json.loads((art_dir / "02-binding-candidates.json").read_text(encoding="utf-8")).get("binding_candidates") or []
summary["binding_candidate_count"] = len(candidates)
print(json.dumps(summary, ensure_ascii=False, indent=2))
PY
cat "$ARTIFACT_DIR/99-summary.json"

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
MATRIX_ROOT="${FRONTEND_MATRIX_ROOT:-$ROOT_DIR/artifacts/frontend-acceptance-matrix}"
TS="${TS:-$(date +%s)}"
MATRIX_DIR="${MATRIX_DIR:-$MATRIX_ROOT/${TS}_frontend_matrix}"
BROWSER_SMOKE_SCRIPT="${BROWSER_SMOKE_SCRIPT:-$ROOT_DIR/scripts/test/verify_frontend_smoke.sh}"
PORTAL_ACCEPTANCE_SCRIPT="${PORTAL_ACCEPTANCE_SCRIPT:-$ROOT_DIR/scripts/acceptance/verify_portal_catalog_ui.sh}"
PUBLIC_PORTAL_BROWSER_SCRIPT="${PUBLIC_PORTAL_BROWSER_SCRIPT:-$ROOT_DIR/scripts/acceptance/verify_public_portal_browser.sh}"
ACCOUNTS_ACCEPTANCE_SCRIPT="${ACCOUNTS_ACCEPTANCE_SCRIPT:-$ROOT_DIR/scripts/acceptance/verify_accounts_admin_ui.sh}"
ROUTE_MATRIX_SCRIPT="${ROUTE_MATRIX_SCRIPT:-$ROOT_DIR/scripts/acceptance/verify_route_acceptance_matrix.sh}"
PROVIDER_ADMIN_SCRIPT="${PROVIDER_ADMIN_SCRIPT:-$ROOT_DIR/scripts/acceptance/verify_provider_admin_actions.sh}"
RUN_PUBLIC_PORTAL_BROWSER="${RUN_PUBLIC_PORTAL_BROWSER:-0}"
mkdir -p "$MATRIX_DIR"
run_step() {
local name="$1"
shift
echo "==> $name"
ARTIFACT_DIR="$MATRIX_DIR/$name" "$@" >"$MATRIX_DIR/$name.stdout.txt" 2>"$MATRIX_DIR/$name.stderr.txt"
}
mark_skip() {
local name="$1"
local reason="$2"
printf '%s\n' "$reason" >"$MATRIX_DIR/$name.skip.txt"
}
has_crm_auth() {
[[ -n "${CRM_ADMIN_TOKEN:-}" ]] || { [[ -n "${CRM_ADMIN_USERNAME:-}" ]] && [[ -n "${CRM_ADMIN_PASSWORD:-}" ]]; }
}
run_step browser_smoke bash "$BROWSER_SMOKE_SCRIPT"
run_step portal_catalog bash "$PORTAL_ACCEPTANCE_SCRIPT"
if [[ "$RUN_PUBLIC_PORTAL_BROWSER" == "1" ]]; then
run_step portal_public_browser bash "$PUBLIC_PORTAL_BROWSER_SCRIPT"
else
mark_skip portal_public_browser "set RUN_PUBLIC_PORTAL_BROWSER=1 to execute public portal browser verification"
fi
if [[ -n "${CRM_BASE:-}" ]] && has_crm_auth; then
run_step accounts_admin bash "$ACCOUNTS_ACCEPTANCE_SCRIPT"
else
mark_skip accounts_admin "missing CRM_BASE or CRM auth; set CRM_BASE with CRM_ADMIN_TOKEN or CRM_ADMIN_USERNAME/CRM_ADMIN_PASSWORD"
fi
if [[ -n "${CRM_BASE:-}" ]] && has_crm_auth && [[ -n "${SHADOW_HOST_ID:-}" ]] && [[ -n "${SHADOW_GROUP_ID:-}" ]] && { [[ -n "${SUBSCRIPTION_USER_ID:-}" ]] || [[ -n "${GATEWAY_API_KEY:-}" ]]; }; then
run_step route_matrix bash "$ROUTE_MATRIX_SCRIPT"
else
mark_skip route_matrix "missing CRM auth or route data-plane env; require CRM_BASE, auth, SHADOW_HOST_ID, SHADOW_GROUP_ID, and SUBSCRIPTION_USER_ID or GATEWAY_API_KEY"
fi
if [[ -n "${CRM_BASE:-}" ]] && has_crm_auth && [[ -n "${ACCESS_API_KEY:-}" ]] && [[ -n "${PROVIDER_KEYS:-}" ]]; then
run_step provider_admin bash "$PROVIDER_ADMIN_SCRIPT"
else
mark_skip provider_admin "missing provider admin env; require CRM_BASE, auth, ACCESS_API_KEY, and PROVIDER_KEYS"
fi
python3 - "$MATRIX_DIR" >"$MATRIX_DIR/summary.json" <<'PY'
import json
import sys
from pathlib import Path
matrix_dir = Path(sys.argv[1])
def load_json(path):
return json.loads(path.read_text(encoding="utf-8"))
def step_result(name, summary_file):
step_dir = matrix_dir / name
if step_dir.exists():
return {"status": "ok", "artifact_dir": str(step_dir), "summary": load_json(step_dir / summary_file)}
skip_file = matrix_dir / f"{name}.skip.txt"
if skip_file.exists():
return {"status": "skipped", "reason": skip_file.read_text(encoding="utf-8").strip()}
return {"status": "missing"}
browser = step_result("browser_smoke", "99-summary.json")
portal = step_result("portal_catalog", "99-summary.json")
portal_public_browser = step_result("portal_public_browser", "99-summary.json")
accounts = step_result("accounts_admin", "99-summary.json")
route = step_result("route_matrix", "summary.json")
provider = step_result("provider_admin", "99-summary.json")
summary = {
"matrix_dir": str(matrix_dir),
"steps": {
"browser_smoke": browser,
"portal_catalog": portal,
"portal_public_browser": portal_public_browser,
"accounts_admin": accounts,
"route_matrix": route,
"provider_admin": provider,
},
"page_mapping": {
"portal": ["browser_smoke", "portal_catalog", "portal_public_browser"],
"admin_index": ["browser_smoke"],
"logical_groups": ["browser_smoke", "route_matrix"],
"route_health": ["browser_smoke", "route_matrix"],
"accounts": ["browser_smoke", "accounts_admin"],
"providers": ["browser_smoke", "provider_admin"],
"batch_import": ["browser_smoke"],
},
}
print(json.dumps(summary, ensure_ascii=False, indent=2))
PY
cat "$MATRIX_DIR/summary.json"

View File

@@ -0,0 +1,94 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# shellcheck disable=SC1091
source "$ROOT_DIR/scripts/acceptance/route_acceptance_lib.sh"
PORTAL_ACCEPTANCE_ROOT="${PORTAL_ACCEPTANCE_ROOT:-$ROOT_DIR/artifacts/frontend-acceptance-matrix}"
TS="${TS:-$(timestamp_token)}"
ARTIFACT_DIR="${ARTIFACT_DIR:-$PORTAL_ACCEPTANCE_ROOT/${TS}_portal_catalog_ui}"
PORTAL_PAGE_URL="${PORTAL_PAGE_URL:-https://sub.tksea.top/portal/}"
PORTAL_CATALOG_BASE="${PORTAL_CATALOG_BASE:-https://sub.tksea.top/portal-admin-api/api/portal}"
PORTAL_PROXY_BASE="${PORTAL_PROXY_BASE:-https://sub.tksea.top/portal-proxy/api/v1}"
PORTAL_ACCESS_TOKEN="${PORTAL_ACCESS_TOKEN:-}"
ensure_artifact_dir
curl_status_to_file "$PORTAL_PAGE_URL" "$ARTIFACT_DIR/00-portal.html"
curl -fsS "${PORTAL_CATALOG_BASE%/}/logical-groups" >"$ARTIFACT_DIR/01-logical-groups.json"
first_group_id="$(
python3 - "$ARTIFACT_DIR/01-logical-groups.json" <<'PY'
import json
import sys
payload = json.load(open(sys.argv[1], "r", encoding="utf-8"))
items = payload.get("logical_groups") or []
if not items:
raise SystemExit(2)
first = items[0]
print(first.get("logical_group_id") or "")
PY
)" || first_group_id=""
if [[ -n "$first_group_id" ]]; then
curl -fsS "${PORTAL_CATALOG_BASE%/}/logical-groups/${first_group_id}/models" >"$ARTIFACT_DIR/02-group-models.json"
fi
if [[ -n "$PORTAL_ACCESS_TOKEN" ]]; then
curl -fsS -H "Authorization: Bearer $PORTAL_ACCESS_TOKEN" "${PORTAL_PROXY_BASE%/}/auth/me" >"$ARTIFACT_DIR/03-auth-me.json"
curl -fsS -H "Authorization: Bearer $PORTAL_ACCESS_TOKEN" "${PORTAL_PROXY_BASE%/}/groups/available" >"$ARTIFACT_DIR/04-groups-available.json"
curl -fsS -H "Authorization: Bearer $PORTAL_ACCESS_TOKEN" "${PORTAL_PROXY_BASE%/}/subscriptions" >"$ARTIFACT_DIR/05-subscriptions.json"
curl -fsS -H "Authorization: Bearer $PORTAL_ACCESS_TOKEN" "${PORTAL_PROXY_BASE%/}/keys?page=1&page_size=20" >"$ARTIFACT_DIR/06-keys.json"
fi
python3 - "$ARTIFACT_DIR" "$PORTAL_ACCESS_TOKEN" >"$ARTIFACT_DIR/99-summary.json" <<'PY'
import json
import sys
from pathlib import Path
art_dir = Path(sys.argv[1])
access_token = sys.argv[2]
page = (art_dir / "00-portal.html").read_text(encoding="utf-8")
catalog = json.loads((art_dir / "01-logical-groups.json").read_text(encoding="utf-8"))
groups = catalog.get("logical_groups") or []
assert "Sub2API 多模型接入中心" in page
assert "逻辑分组目录" in page
assert groups, groups
summary = {
"page_url": "portal",
"page_title_seen": "Sub2API 多模型接入中心" in page,
"logical_group_count": len(groups),
"first_logical_group_id": groups[0].get("logical_group_id"),
"first_logical_group_display_name": groups[0].get("display_name"),
"user_projection_checked": bool(access_token),
}
models_file = art_dir / "02-group-models.json"
if models_file.exists():
models_payload = json.loads(models_file.read_text(encoding="utf-8"))
public_models = models_payload.get("public_models") or []
summary["first_group_models_count"] = len(public_models)
if public_models:
summary["first_group_first_model"] = public_models[0].get("public_model")
if access_token:
auth_me = json.loads((art_dir / "03-auth-me.json").read_text(encoding="utf-8"))
groups_available = json.loads((art_dir / "04-groups-available.json").read_text(encoding="utf-8"))
subscriptions = json.loads((art_dir / "05-subscriptions.json").read_text(encoding="utf-8"))
keys_page = json.loads((art_dir / "06-keys.json").read_text(encoding="utf-8"))
summary["auth_me_present"] = bool(auth_me.get("data") or auth_me)
summary["available_group_count"] = len((groups_available.get("data") if isinstance(groups_available, dict) else groups_available) or [])
summary["subscription_count"] = len((subscriptions.get("data") if isinstance(subscriptions, dict) else subscriptions) or [])
key_data = keys_page.get("data") if isinstance(keys_page, dict) else keys_page
summary["key_count"] = len((key_data or {}).get("items") or [])
print(json.dumps(summary, ensure_ascii=False, indent=2))
PY
cat "$ARTIFACT_DIR/99-summary.json"

View File

@@ -0,0 +1,120 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# shellcheck disable=SC1091
source "$ROOT_DIR/scripts/acceptance/route_acceptance_lib.sh"
PORTAL_ACCEPTANCE_ROOT="${PORTAL_ACCEPTANCE_ROOT:-$ROOT_DIR/artifacts/frontend-acceptance-matrix}"
TS="${TS:-$(timestamp_token)}"
ARTIFACT_DIR="${ARTIFACT_DIR:-$PORTAL_ACCEPTANCE_ROOT/${TS}_portal_public_browser}"
PUBLIC_PORTAL_PAGE_URL="${PUBLIC_PORTAL_PAGE_URL:-https://sub.tksea.top/portal/}"
PUBLIC_PORTAL_CATALOG_BASE="${PUBLIC_PORTAL_CATALOG_BASE:-https://sub.tksea.top/portal-admin-api/api/portal}"
PUBLIC_PORTAL_PROXY_BASE="${PUBLIC_PORTAL_PROXY_BASE:-https://sub.tksea.top/portal-proxy/api/v1}"
PORTAL_ACCESS_TOKEN="${PORTAL_ACCESS_TOKEN:-}"
CHROMIUM_BIN="${CHROMIUM_BIN:-}"
VIRTUAL_TIME_BUDGET="${VIRTUAL_TIME_BUDGET:-5000}"
USER_DATA_DIR="$ARTIFACT_DIR/chromium-profile"
fail() {
echo "FAIL: $*" >&2
exit 1
}
assert_contains_file() {
local file="$1"
local needle="$2"
if ! grep -Fq "$needle" "$file"; then
fail "expected [$needle] in $file"
fi
}
find_chromium() {
if [[ -n "$CHROMIUM_BIN" ]]; then
printf '%s\n' "$CHROMIUM_BIN"
return 0
fi
local candidate
for candidate in chromium chromium-browser google-chrome google-chrome-stable; do
if command -v "$candidate" >/dev/null 2>&1; then
command -v "$candidate"
return 0
fi
done
return 1
}
dump_dom() {
local label="$1"
local url="$2"
local output="$ARTIFACT_DIR/${label}.dom.html"
"$CHROMIUM_BIN" \
--headless \
--disable-gpu \
--no-sandbox \
--no-proxy-server \
--user-data-dir="$USER_DATA_DIR/$label" \
--virtual-time-budget="$VIRTUAL_TIME_BUDGET" \
--dump-dom \
"$url" >"$output" 2>"$ARTIFACT_DIR/${label}.stderr.txt"
printf '%s\n' "$output"
}
CHROMIUM_BIN="$(find_chromium)" || fail "missing chromium-compatible browser; set CHROMIUM_BIN explicitly"
[[ -x "$CHROMIUM_BIN" ]] || fail "chromium binary is not executable: $CHROMIUM_BIN"
ensure_artifact_dir
mkdir -p "$USER_DATA_DIR"
portal_dom="$(dump_dom "00-portal" "$PUBLIC_PORTAL_PAGE_URL")"
assert_contains_file "$portal_dom" "Sub2API 多模型接入中心"
assert_contains_file "$portal_dom" "逻辑分组目录"
assert_contains_file "$portal_dom" "申请 Key 依赖状态"
assert_contains_file "$portal_dom" "可直接申请"
assert_contains_file "$portal_dom" "可申请,调用前需确认状态"
assert_contains_file "$portal_dom" "待补开通"
assert_contains_file "$portal_dom" "待人工整理"
assert_contains_file "$portal_dom" "仅目录可见"
PORTAL_PAGE_URL="$PUBLIC_PORTAL_PAGE_URL" \
PORTAL_CATALOG_BASE="$PUBLIC_PORTAL_CATALOG_BASE" \
PORTAL_PROXY_BASE="$PUBLIC_PORTAL_PROXY_BASE" \
PORTAL_ACCESS_TOKEN="$PORTAL_ACCESS_TOKEN" \
ARTIFACT_DIR="$ARTIFACT_DIR/catalog_api" \
bash "$ROOT_DIR/scripts/acceptance/verify_portal_catalog_ui.sh" >"$ARTIFACT_DIR/portal_catalog.stdout.txt"
python3 - "$ARTIFACT_DIR" "$PUBLIC_PORTAL_PAGE_URL" "$PORTAL_ACCESS_TOKEN" >"$ARTIFACT_DIR/99-summary.json" <<'PY'
import json
import sys
from pathlib import Path
art_dir = Path(sys.argv[1])
page_url = sys.argv[2]
access_token = sys.argv[3]
page = (art_dir / "00-portal.dom.html").read_text(encoding="utf-8")
catalog_summary = json.loads((art_dir / "catalog_api" / "99-summary.json").read_text(encoding="utf-8"))
summary = {
"page_url": page_url,
"page_title_seen": "Sub2API 多模型接入中心" in page,
"logical_group_catalog_seen": "逻辑分组目录" in page,
"dependency_panel_seen": "申请 Key 依赖状态" in page,
"dependency_state_copy_seen": {
"ready": "可直接申请" in page,
"granted": "可申请,调用前需确认状态" in page,
"pending": "待补开通" in page,
"ambiguous": "待人工整理" in page,
"catalog_only": "仅目录可见" in page,
},
"user_projection_checked": bool(access_token),
"catalog_api_summary": catalog_summary,
"result": "pass",
}
print(json.dumps(summary, ensure_ascii=False, indent=2))
PY
cat "$ARTIFACT_DIR/99-summary.json"

0
scripts/deploy/deploy_crm_only.sh Normal file → Executable file
View File

View File

@@ -6,8 +6,8 @@ KEY="${KEY:-/home/long/下载/zjsea.pem}"
REMOTE="${REMOTE:-ubuntu@43.155.133.187}" REMOTE="${REMOTE:-ubuntu@43.155.133.187}"
REMOTE_PORTAL_DIR="${REMOTE_PORTAL_DIR:-/var/www/sub2api-portal}" REMOTE_PORTAL_DIR="${REMOTE_PORTAL_DIR:-/var/www/sub2api-portal}"
REMOTE_NGINX_SITE="${REMOTE_NGINX_SITE:-/etc/nginx/sites-available/tksea}" REMOTE_NGINX_SITE="${REMOTE_NGINX_SITE:-/etc/nginx/sites-available/tksea}"
REMOTE_HOST_PORT="${REMOTE_HOST_PORT:-18169}" REMOTE_HOST_PORT="${REMOTE_HOST_PORT:-8080}"
REMOTE_CRM_PORT="${REMOTE_CRM_PORT:-18173}" REMOTE_CRM_PORT="${REMOTE_CRM_PORT:-18190}"
LOCAL_PORTAL_DIR="${LOCAL_PORTAL_DIR:-$ROOT_DIR/deploy/tksea-portal}" LOCAL_PORTAL_DIR="${LOCAL_PORTAL_DIR:-$ROOT_DIR/deploy/tksea-portal}"
REMOTE_STAGE_DIR="${REMOTE_STAGE_DIR:-/tmp/sub2api-portal-deploy}" REMOTE_STAGE_DIR="${REMOTE_STAGE_DIR:-/tmp/sub2api-portal-deploy}"
DRY_RUN="${DRY_RUN:-0}" DRY_RUN="${DRY_RUN:-0}"

116
scripts/test/check_coverage.sh Executable file
View File

@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# check_coverage.sh — Check Go project coverage against thresholds
#
# Usage:
# bash scripts/test/check_coverage.sh [min-percentage]
#
# Default threshold: 85% (matches Hermes config agent.min_test_coverage)
#
# Reads thresholds from:
# 1. CLI argument (highest priority)
# 2. tests/quality/coverage_thresholds.tsv (per-package thresholds)
# 3. Default 85%
#
# Output:
# - Coverage report (stdout)
# - Exit code 1 if any package below threshold
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
DEFAULT_THRESHOLD="${1:-85}"
THRESHOLD_FILE="${PROJECT_DIR}/tests/quality/coverage_thresholds.tsv"
echo "==> coverage threshold: ${DEFAULT_THRESHOLD}%"
# Collect coverage
cd "$PROJECT_DIR"
COVERAGE_OUT=$(mktemp)
go test -count=1 -cover ./internal/... 2>&1 | tee "$COVERAGE_OUT"
# Check per-package thresholds from file if exists
HAS_FILE_THRESHOLDS=false
declare -A PACKAGE_THRESHOLDS
if [[ -f "$THRESHOLD_FILE" ]]; then
HAS_FILE_THRESHOLDS=true
while IFS=$'\t' read -r pkg threshold; do
[[ -z "$pkg" || "$pkg" == \#* ]] && continue
PACKAGE_THRESHOLDS["$pkg"]="$threshold"
done < "$THRESHOLD_FILE"
fi
# Parse and validate
EXIT_CODE=0
declare -a FAILURES=()
CURRENT_PKG=""
TOTAL_PKG=0
PASS_PKG=0
parse_coverage_line() {
local line="$1"
# Match: ok github.com/xxx/sub2api-cn-relay-manager/internal/provision 0.012s coverage: 82.8% of statements
# Match: ? github.com/xxx/sub2api-cn-relay-manager/internal/provision [no test files]
if [[ "$line" =~ ^ok[[:space:]]+.*/[^[:space:]]+[[:space:]]+[0-9.]+s[[:space:]]+coverage:[[:space:]]+([0-9.]+)% ]]; then
local pct="${BASH_REMATCH[1]}"
local pkg_name
pkg_name=$(echo "$line" | awk '{print $2}' | awk -F'/' '{print $NF}')
local threshold="$DEFAULT_THRESHOLD"
if $HAS_FILE_THRESHOLDS && [[ -n "${PACKAGE_THRESHOLDS[$pkg_name]:-}" ]]; then
threshold="${PACKAGE_THRESHOLDS[$pkg_name]}"
fi
TOTAL_PKG=$((TOTAL_PKG + 1))
if (( $(echo "$pct < $threshold" | bc -l 2>/dev/null || echo 1) )); then
FAILURES+=("${pkg_name}: ${pct}% < ${threshold}%")
EXIT_CODE=1
else
PASS_PKG=$((PASS_PKG + 1))
echo "${pkg_name}: ${pct}% (threshold: ${threshold}%)"
fi
fi
}
while IFS= read -r line; do
parse_coverage_line "$line"
done < "$COVERAGE_OUT"
echo ""
echo "==> summary: ${PASS_PKG}/${TOTAL_PKG} packages pass coverage threshold"
if [[ ${#FAILURES[@]} -gt 0 ]]; then
echo "FAILURES:"
for f in "${FAILURES[@]}"; do
echo "$f"
done
fi
# Optionally generate markdown report
REPORT_FILE="${COVERAGE_REPORT:-}"
if [[ -n "$REPORT_FILE" ]]; then
{
echo "# Coverage Report ($(date +%Y-%m-%d))"
echo ""
echo "| Package | Coverage | Threshold | Status |"
echo "|---------|----------|-----------|--------|"
while IFS= read -r line; do
if [[ "$line" =~ ^ok[[:space:]]+.*/[^[:space:]]+[[:space:]]+[0-9.]+s[[:space:]]+coverage:[[:space:]]+([0-9.]+)% ]]; then
pkg=$(echo "$line" | awk '{print $2}' | awk -F'/' '{print $NF}')
pct="${BASH_REMATCH[1]}"
threshold="$DEFAULT_THRESHOLD"
if $HAS_FILE_THRESHOLDS && [[ -n "${PACKAGE_THRESHOLDS[$pkg]:-}" ]]; then
threshold="${PACKAGE_THRESHOLDS[$pkg]}"
fi
status="✅"
if (( $(echo "$pct < $threshold" | bc -l 2>/dev/null || echo 1) )); then
status="❌"
fi
echo "| ${pkg} | ${pct}% | ${threshold}% | ${status} |"
fi
done < "$COVERAGE_OUT"
echo ""
echo "**Overall: ${PASS_PKG}/${TOTAL_PKG} passing, exit $([ $EXIT_CODE -eq 0 ] && echo 0 || echo 1)**"
} > "$REPORT_FILE"
echo "Report: $REPORT_FILE"
fi
rm -f "$COVERAGE_OUT"
exit $EXIT_CODE

104
scripts/test/init_test_plan.sh Executable file
View File

@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# init_test_plan.sh — Generate task-level test plan + test case template
#
# Usage:
# bash scripts/test/init_test_plan.sh <task-name> [target-dir]
#
# Example:
# bash scripts/test/init_test_plan.sh "preflight-host-readiness" internal/provision
#
# Output:
# docs/test-plans/TEST_PLAN_YYYY-MM-DD_<task-name>.md
# docs/test-cases/TEST_CASES_YYYY-MM-DD_<task-name>.md
#
# These are TEMPLATES. Fill in the actual test cases before implementation,
# and mark PASS/FAIL after verification.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
TASK_NAME="${1:?Usage: $0 <task-name> [target-dir]}"
TARGET_DIR="${2:-.}"
DATE_TAG="$(date +%Y-%m-%d)"
PLAN_DIR="${SCRIPT_DIR}/docs/test-plans"
CASE_DIR="${SCRIPT_DIR}/docs/test-cases"
mkdir -p "$PLAN_DIR" "$CASE_DIR"
# ── Test Plan Template ──────────────────────────────────────────────
PLAN_FILE="${PLAN_DIR}/TEST_PLAN_${DATE_TAG}_${TASK_NAME}.md"
cat > "$PLAN_FILE" << EOFTPL
# Test Plan: ${TASK_NAME}
日期: ${DATE_TAG}
目标文件: ${TARGET_DIR}
## 测试目标
<!-- 一句话说明本任务的测试目标 -->
## 范围
- 影响文件: <!-- 列出被修改的文件 -->
- 风险点: <!-- 可能出问题的地方 -->
- 不变区域: <!-- 明确不需要测试的范围 -->
## 验证层级
| 层级 | 验证内容 | 命令 | 通过标准 |
|------|----------|------|----------|
| L1 单测 | | | |
| L2 静态分析 | | | |
| L3 集成测试 | | | |
| L4 构建验证 | | | |
| L5 前端验证 | | | |
| L6 真实环境 | | | |
## 测试用例
见 docs/test-cases/TEST_CASES_${DATE_TAG}_${TASK_NAME}.md
## 回归检查
- [ ] 已有测试不受影响
- [ ] 覆盖率不低于当前基线
EOFTPL
# ── Test Cases Template ──────────────────────────────────────────────
CASE_FILE="${CASE_DIR}/TEST_CASES_${DATE_TAG}_${TASK_NAME}.md"
cat > "$CASE_FILE" << EOFCASE
# Test Cases: ${TASK_NAME}
日期: ${DATE_TAG}
## 用例列表
| ID | 描述 | 输入 | 预期结果 | 实际结果 | 状态 |
|----|------|------|----------|----------|------|
| TC1 | | | | | PENDING |
| TC2 | | | | | PENDING |
| TC3 | | | | | PENDING |
## 边界用例
| ID | 描述 | 输入 | 预期结果 | 实际结果 | 状态 |
|----|------|------|----------|----------|------|
| B1 | | | | | PENDING |
| B2 | | | | | PENDING |
## 异常用例
| ID | 描述 | 输入 | 预期结果 | 实际结果 | 状态 |
|----|------|------|----------|----------|------|
| E1 | | | | | PENDING |
| E2 | | | | | PENDING |
EOFCASE
echo "✅ Test plan: ${PLAN_FILE}"
echo "✅ Test cases: ${CASE_FILE}"
echo ""
echo "下一步:"
echo " 1. 编辑测试计划和用例"
echo " 2. 实现代码"
echo " 3. 按用例逐条验证"
echo " 4. 更新状态为 PASS/FAIL"

View File

@@ -271,6 +271,8 @@ run_test_verify_quality_gates_script() {
[[ -f "$threshold_file" ]] || fail "missing $threshold_file" [[ -f "$threshold_file" ]] || fail "missing $threshold_file"
script_contents="$(cat "$script")" script_contents="$(cat "$script")"
assert_contains "$script_contents" "test_tksea_portal_assets.sh"
assert_contains "$script_contents" "verify_frontend_smoke.sh"
assert_contains "$script_contents" "gofmt -l ." assert_contains "$script_contents" "gofmt -l ."
assert_contains "$script_contents" "go vet ./..." assert_contains "$script_contents" "go vet ./..."
assert_contains "$script_contents" "go test -cover ./internal/..." assert_contains "$script_contents" "go test -cover ./internal/..."
@@ -520,6 +522,8 @@ EOF
CRM_HOST_BASE="http://127.0.0.1:18093" \ CRM_HOST_BASE="http://127.0.0.1:18093" \
REMOTE_HOST_BASE="http://127.0.0.1:18093" \ REMOTE_HOST_BASE="http://127.0.0.1:18093" \
HOST_NAME="human-friendly-host-name" \ HOST_NAME="human-friendly-host-name" \
REMOTE_PG_CONTAINER="sub2api-fresh-deepseek-20260519_115244-postgres-1" \
REMOTE_REDIS_CONTAINER="sub2api-fresh-deepseek-20260519_115244-redis-1" \
ROOT="$artifact_dir/root" \ ROOT="$artifact_dir/root" \
ART="$artifact_dir/run" \ ART="$artifact_dir/run" \
PACK_PATH="$pack_dir" \ PACK_PATH="$pack_dir" \
@@ -580,7 +584,8 @@ EOF
assert_contains "$ssh_contents" "http://127.0.0.1:18093/v1/chat/completions" assert_contains "$ssh_contents" "http://127.0.0.1:18093/v1/chat/completions"
assert_not_contains "$ssh_contents" "http://127.0.0.1:18087/v1/models" assert_not_contains "$ssh_contents" "http://127.0.0.1:18087/v1/models"
assert_not_contains "$ssh_contents" "http://127.0.0.1:18087/v1/chat/completions" assert_not_contains "$ssh_contents" "http://127.0.0.1:18087/v1/chat/completions"
assert_not_contains "$ssh_contents" "user-key" assert_contains "$ssh_contents" "Authorization: Bearer user-key"
assert_not_contains "$ssh_contents" "Authorization: Bearer sk-rel"
local provider_status local provider_status
provider_status="$(cat "$artifact_dir/run/13-provider-status.json")" provider_status="$(cat "$artifact_dir/run/13-provider-status.json")"
@@ -1055,6 +1060,314 @@ EOF
assert_contains "$summary" '"fallback_recent_failover_count": 1' assert_contains "$summary" '"fallback_recent_failover_count": 1'
} }
run_test_verify_portal_catalog_ui_script() {
local tmpdir fakebin artifact_dir stdout_file
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' RETURN
fakebin="$tmpdir/bin"
artifact_dir="$tmpdir/artifacts"
stdout_file="$tmpdir/verify_portal_catalog_ui.stdout.txt"
mkdir -p "$fakebin" "$artifact_dir"
cat > "$fakebin/curl" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
url=""
output_file=""
prev=""
for arg in "$@"; do
case "$prev" in
-o) output_file="$arg"; prev=""; continue ;;
esac
case "$arg" in
-o) prev="$arg"; continue ;;
http://*|https://*) url="$arg" ;;
esac
done
write_body() {
local body="$1"
if [[ -n "$output_file" ]]; then
printf '%s\n' "$body" > "$output_file"
else
printf '%s\n' "$body"
fi
}
case "$url" in
http://portal.example.com/)
write_body '<html><title>Sub2API 多模型接入中心</title><body>逻辑分组目录</body></html>'
;;
http://crm.example.com/api/portal/logical-groups)
write_body '{"logical_groups":[{"logical_group_id":"portal-group-001","display_name":"Portal Group 001"}]}'
;;
http://crm.example.com/api/portal/logical-groups/portal-group-001/models)
write_body '{"public_models":[{"public_model":"gpt-5.4"}]}'
;;
http://proxy.example.com/auth/me)
write_body '{"code":0,"data":{"id":42,"email":"portal@example.com"}}'
;;
http://proxy.example.com/groups/available)
write_body '{"code":0,"data":[{"id":101,"name":"Portal Group"}]}'
;;
http://proxy.example.com/subscriptions)
write_body '{"code":0,"data":[{"id":1,"group_id":101,"status":"active"}]}'
;;
"http://proxy.example.com/keys?page=1&page_size=20")
write_body '{"code":0,"data":{"items":[{"id":1,"group_id":101,"key":"sk-visible"}]}}'
;;
*)
echo "unexpected curl url: $url" >&2
exit 1
;;
esac
EOF
chmod +x "$fakebin/curl"
PATH="$fakebin:$PATH" \
PORTAL_PAGE_URL="http://portal.example.com/" \
PORTAL_CATALOG_BASE="http://crm.example.com/api/portal" \
PORTAL_PROXY_BASE="http://proxy.example.com" \
PORTAL_ACCESS_TOKEN="portal-token" \
ARTIFACT_DIR="$artifact_dir" \
bash "$ROOT_DIR/scripts/acceptance/verify_portal_catalog_ui.sh" >"$stdout_file"
local summary
summary="$(cat "$artifact_dir/99-summary.json")"
assert_contains "$summary" '"page_title_seen": true'
assert_contains "$summary" '"logical_group_count": 1'
assert_contains "$summary" '"first_logical_group_id": "portal-group-001"'
assert_contains "$summary" '"user_projection_checked": true'
assert_contains "$summary" '"key_count": 1'
}
run_test_verify_public_portal_browser_script() {
local tmpdir fakebin artifact_dir stdout_file
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' RETURN
fakebin="$tmpdir/bin"
artifact_dir="$tmpdir/artifacts"
stdout_file="$tmpdir/verify_public_portal_browser.stdout.txt"
mkdir -p "$fakebin" "$artifact_dir"
cat > "$fakebin/curl" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
url=""
output_file=""
prev=""
for arg in "$@"; do
case "$prev" in
-o) output_file="$arg"; prev=""; continue ;;
esac
case "$arg" in
-o) prev="$arg"; continue ;;
http://*|https://*) url="$arg" ;;
esac
done
write_body() {
local body="$1"
if [[ -n "$output_file" ]]; then
printf '%s\n' "$body" > "$output_file"
else
printf '%s\n' "$body"
fi
}
case "$url" in
http://portal.example.com/portal/)
write_body '<html><title>Sub2API 多模型接入中心</title><body>逻辑分组目录 申请 Key 依赖状态</body></html>'
;;
http://crm.example.com/api/portal/logical-groups)
write_body '{"logical_groups":[{"logical_group_id":"portal-group-001","display_name":"Portal Group 001"}]}'
;;
http://crm.example.com/api/portal/logical-groups/portal-group-001/models)
write_body '{"public_models":[{"public_model":"gpt-5.4"}]}'
;;
http://proxy.example.com/auth/me)
write_body '{"code":0,"data":{"id":42,"email":"portal@example.com"}}'
;;
http://proxy.example.com/groups/available)
write_body '{"code":0,"data":[{"id":101,"name":"Portal Group"}]}'
;;
http://proxy.example.com/subscriptions)
write_body '{"code":0,"data":[{"id":1,"group_id":101,"status":"active"}]}'
;;
"http://proxy.example.com/keys?page=1&page_size=20")
write_body '{"code":0,"data":{"items":[{"id":1,"group_id":101,"key":"sk-visible"}]}}'
;;
*)
echo "unexpected curl url: $url" >&2
exit 1
;;
esac
EOF
cat > "$fakebin/chromium" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '<html><title>Sub2API 多模型接入中心</title><body>逻辑分组目录 申请 Key 依赖状态 可直接申请 可申请,调用前需确认状态 待补开通 待人工整理 仅目录可见</body></html>'
EOF
chmod +x "$fakebin/curl" "$fakebin/chromium"
PATH="$fakebin:$PATH" \
CHROMIUM_BIN="$fakebin/chromium" \
PUBLIC_PORTAL_PAGE_URL="http://portal.example.com/portal/" \
PUBLIC_PORTAL_CATALOG_BASE="http://crm.example.com/api/portal" \
PUBLIC_PORTAL_PROXY_BASE="http://proxy.example.com" \
PORTAL_ACCESS_TOKEN="portal-token" \
ARTIFACT_DIR="$artifact_dir" \
bash "$ROOT_DIR/scripts/acceptance/verify_public_portal_browser.sh" >"$stdout_file"
local summary
summary="$(cat "$artifact_dir/99-summary.json")"
assert_contains "$summary" '"dependency_panel_seen": true'
assert_contains "$summary" '"page_title_seen": true'
assert_contains "$summary" '"logical_group_count": 1'
assert_contains "$summary" '"user_projection_checked": true'
assert_contains "$summary" '"result": "pass"'
}
run_test_verify_accounts_admin_ui_script() {
local tmpdir fakebin artifact_dir stdout_file
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' RETURN
fakebin="$tmpdir/bin"
artifact_dir="$tmpdir/artifacts"
stdout_file="$tmpdir/verify_accounts_admin_ui.stdout.txt"
mkdir -p "$fakebin" "$artifact_dir"
cat > "$fakebin/curl" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
method="GET"
url=""
output_file=""
prev=""
for arg in "$@"; do
case "$prev" in
-X) method="$arg"; prev=""; continue ;;
-o) output_file="$arg"; prev=""; continue ;;
esac
case "$arg" in
-X|-o) prev="$arg"; continue ;;
http://*|https://*) url="$arg" ;;
esac
done
write_body() {
local body="$1"
if [[ -n "$output_file" ]]; then
printf '%s\n' "$body" > "$output_file"
else
printf '%s\n' "$body"
fi
}
case "$method $url" in
"GET http://portal.example.com/accounts.html")
write_body '<html><title>Provider Accounts Admin</title><body>Provider Accounts Admin</body></html>'
;;
"GET http://crm.example.com/api/provider-accounts?limit=50")
write_body '{"provider_accounts":[{"id":1,"provider_id":"gpt-asxs-shadow-lab","status":"active","binding_state":"conflict"}]}'
;;
"GET http://crm.example.com/api/provider-accounts/1/binding-candidates")
write_body '{"binding_candidates":[{"route_id":"primary-1"},{"route_id":"fallback-1"}]}'
;;
*)
echo "unexpected curl request: $method $url" >&2
exit 1
;;
esac
EOF
chmod +x "$fakebin/curl"
PATH="$fakebin:$PATH" \
CRM_BASE="http://crm.example.com" \
CRM_ADMIN_TOKEN="token" \
ACCOUNTS_PAGE_URL="http://portal.example.com/accounts.html" \
ARTIFACT_DIR="$artifact_dir" \
bash "$ROOT_DIR/scripts/acceptance/verify_accounts_admin_ui.sh" >"$stdout_file"
local summary
summary="$(cat "$artifact_dir/99-summary.json")"
assert_contains "$summary" '"page_title_seen": true'
assert_contains "$summary" '"account_count": 1'
assert_contains "$summary" '"selected_account_id": "1"'
assert_contains "$summary" '"binding_candidate_count": 2'
}
run_test_verify_frontend_acceptance_matrix_script() {
local tmpdir matrix_dir browser_script portal_script public_portal_browser_script accounts_script route_script provider_script stdout_file
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' RETURN
matrix_dir="$tmpdir/matrix"
stdout_file="$tmpdir/verify_frontend_acceptance_matrix.stdout.txt"
browser_script="$tmpdir/browser.sh"
portal_script="$tmpdir/portal.sh"
public_portal_browser_script="$tmpdir/public-portal-browser.sh"
accounts_script="$tmpdir/accounts.sh"
route_script="$tmpdir/route.sh"
provider_script="$tmpdir/provider.sh"
cat > "$browser_script" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
mkdir -p "$ARTIFACT_DIR"
printf '%s\n' '{"result":"pass","page_title_seen":true}' > "$ARTIFACT_DIR/99-summary.json"
EOF
cat > "$portal_script" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
mkdir -p "$ARTIFACT_DIR"
printf '%s\n' '{"logical_group_count":1,"page_title_seen":true}' > "$ARTIFACT_DIR/99-summary.json"
EOF
cat > "$public_portal_browser_script" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
mkdir -p "$ARTIFACT_DIR"
printf '%s\n' '{"page_title_seen":true,"dependency_panel_seen":true,"result":"pass"}' > "$ARTIFACT_DIR/99-summary.json"
EOF
cat > "$accounts_script" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
mkdir -p "$ARTIFACT_DIR"
printf '%s\n' '{"account_count":1,"page_title_seen":true}' > "$ARTIFACT_DIR/99-summary.json"
EOF
cat > "$route_script" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
mkdir -p "$ARTIFACT_DIR"
printf '%s\n' '{"control_plane_group_id":"lg-1","health_ui_group_id":"lg-2","data_plane_group_id":"lg-3"}' > "$ARTIFACT_DIR/summary.json"
EOF
cat > "$provider_script" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
mkdir -p "$ARTIFACT_DIR"
printf '%s\n' '{"page_title_seen":true,"import_batch_id":321}' > "$ARTIFACT_DIR/99-summary.json"
EOF
chmod +x "$browser_script" "$portal_script" "$public_portal_browser_script" "$accounts_script" "$route_script" "$provider_script"
CRM_BASE="http://crm.example.com" \
CRM_ADMIN_TOKEN="token" \
SHADOW_HOST_ID="shadow-host-1" \
SHADOW_GROUP_ID="shadow-group-1" \
SUBSCRIPTION_USER_ID="42" \
ACCESS_API_KEY="sk-access" \
PROVIDER_KEYS="sk-provider-1" \
RUN_PUBLIC_PORTAL_BROWSER="1" \
MATRIX_DIR="$matrix_dir" \
BROWSER_SMOKE_SCRIPT="$browser_script" \
PORTAL_ACCEPTANCE_SCRIPT="$portal_script" \
PUBLIC_PORTAL_BROWSER_SCRIPT="$public_portal_browser_script" \
ACCOUNTS_ACCEPTANCE_SCRIPT="$accounts_script" \
ROUTE_MATRIX_SCRIPT="$route_script" \
PROVIDER_ADMIN_SCRIPT="$provider_script" \
bash "$ROOT_DIR/scripts/acceptance/verify_frontend_acceptance_matrix.sh" >"$stdout_file"
local summary
summary="$(cat "$matrix_dir/summary.json")"
assert_contains "$summary" '"browser_smoke"'
assert_contains "$summary" '"status": "ok"'
assert_contains "$summary" '"portal_public_browser"'
assert_contains "$summary" '"portal": ['
assert_contains "$summary" '"providers": ['
}
run_test_remote43_patched_stack_renderers() { run_test_remote43_patched_stack_renderers() {
# shellcheck disable=SC1091 # shellcheck disable=SC1091
source "$ROOT_DIR/scripts/deploy/remote43_patched_stack_lib.sh" source "$ROOT_DIR/scripts/deploy/remote43_patched_stack_lib.sh"
@@ -1183,6 +1496,10 @@ run_test_verify_route_control_plane_script
run_test_verify_route_data_plane_script run_test_verify_route_data_plane_script
run_test_verify_provider_admin_actions_script run_test_verify_provider_admin_actions_script
run_test_verify_route_health_ui_script run_test_verify_route_health_ui_script
run_test_verify_portal_catalog_ui_script
run_test_verify_public_portal_browser_script
run_test_verify_accounts_admin_ui_script
run_test_verify_frontend_acceptance_matrix_script
run_test_remote43_patched_stack_renderers run_test_remote43_patched_stack_renderers
run_test_setup_remote43_patched_stack_dry_run run_test_setup_remote43_patched_stack_dry_run
run_test_verify_quality_gates_script run_test_verify_quality_gates_script

View File

@@ -4,6 +4,8 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
HTML_FILE="$ROOT_DIR/deploy/tksea-portal/index.html" HTML_FILE="$ROOT_DIR/deploy/tksea-portal/index.html"
ADMIN_HTML_FILE="$ROOT_DIR/deploy/tksea-portal/admin-batch-import.html" ADMIN_HTML_FILE="$ROOT_DIR/deploy/tksea-portal/admin-batch-import.html"
ADMIN_COMMON_CSS_FILE="$ROOT_DIR/deploy/tksea-portal/admin-common.css"
ADMIN_COMMON_JS_FILE="$ROOT_DIR/deploy/tksea-portal/admin-common.js"
ADMIN_HOME_FILE="$ROOT_DIR/deploy/tksea-portal/admin/index.html" ADMIN_HOME_FILE="$ROOT_DIR/deploy/tksea-portal/admin/index.html"
ADMIN_LOGICAL_GROUPS_FILE="$ROOT_DIR/deploy/tksea-portal/admin/logical-groups.html" ADMIN_LOGICAL_GROUPS_FILE="$ROOT_DIR/deploy/tksea-portal/admin/logical-groups.html"
ADMIN_ROUTE_HEALTH_FILE="$ROOT_DIR/deploy/tksea-portal/admin/route-health.html" ADMIN_ROUTE_HEALTH_FILE="$ROOT_DIR/deploy/tksea-portal/admin/route-health.html"
@@ -28,6 +30,8 @@ assert_contains_file() {
[[ -f "$HTML_FILE" ]] || fail "missing $HTML_FILE" [[ -f "$HTML_FILE" ]] || fail "missing $HTML_FILE"
[[ -f "$ADMIN_HTML_FILE" ]] || fail "missing $ADMIN_HTML_FILE" [[ -f "$ADMIN_HTML_FILE" ]] || fail "missing $ADMIN_HTML_FILE"
[[ -f "$ADMIN_COMMON_CSS_FILE" ]] || fail "missing $ADMIN_COMMON_CSS_FILE"
[[ -f "$ADMIN_COMMON_JS_FILE" ]] || fail "missing $ADMIN_COMMON_JS_FILE"
[[ -f "$ADMIN_HOME_FILE" ]] || fail "missing $ADMIN_HOME_FILE" [[ -f "$ADMIN_HOME_FILE" ]] || fail "missing $ADMIN_HOME_FILE"
[[ -f "$ADMIN_LOGICAL_GROUPS_FILE" ]] || fail "missing $ADMIN_LOGICAL_GROUPS_FILE" [[ -f "$ADMIN_LOGICAL_GROUPS_FILE" ]] || fail "missing $ADMIN_LOGICAL_GROUPS_FILE"
[[ -f "$ADMIN_ROUTE_HEALTH_FILE" ]] || fail "missing $ADMIN_ROUTE_HEALTH_FILE" [[ -f "$ADMIN_ROUTE_HEALTH_FILE" ]] || fail "missing $ADMIN_ROUTE_HEALTH_FILE"
@@ -52,12 +56,12 @@ assert_contains_file "$HTML_FILE" "showToast"
assert_contains_file "$HTML_FILE" "逻辑分组目录" assert_contains_file "$HTML_FILE" "逻辑分组目录"
assert_contains_file "$HTML_FILE" "已激活产品权限" assert_contains_file "$HTML_FILE" "已激活产品权限"
assert_contains_file "$HTML_FILE" "权限与订阅视图" assert_contains_file "$HTML_FILE" "权限与订阅视图"
assert_contains_file "$HTML_FILE" "可立即申请兼容 Key" assert_contains_file "$HTML_FILE" "可立即申请测试 Key"
assert_contains_file "$HTML_FILE" "需开通兼容线路" assert_contains_file "$HTML_FILE" "待补开通"
assert_contains_file "$HTML_FILE" "目录已上线" assert_contains_file "$HTML_FILE" "目录已上线"
assert_contains_file "$HTML_FILE" "选择逻辑分组" assert_contains_file "$HTML_FILE" "选择逻辑分组"
assert_contains_file "$HTML_FILE" "当前逻辑分组说明" assert_contains_file "$HTML_FILE" "当前逻辑分组说明"
assert_contains_file "$HTML_FILE" "兼容宿主线路" assert_contains_file "$HTML_FILE" "申请 Key 依赖状态"
assert_contains_file "$HTML_FILE" "portalLogicalGroups" assert_contains_file "$HTML_FILE" "portalLogicalGroups"
assert_contains_file "$HTML_FILE" "LEGACY_GROUP_CATALOG" assert_contains_file "$HTML_FILE" "LEGACY_GROUP_CATALOG"
assert_contains_file "$HTML_FILE" "逻辑分组权限" assert_contains_file "$HTML_FILE" "逻辑分组权限"
@@ -82,34 +86,48 @@ assert_contains_file "$HTML_FILE" "cta-link"
assert_contains_file "$HTML_FILE" "已开通订阅" assert_contains_file "$HTML_FILE" "已开通订阅"
assert_contains_file "$HTML_FILE" "已授予权限" assert_contains_file "$HTML_FILE" "已授予权限"
assert_contains_file "$HTML_FILE" "归属待整理" assert_contains_file "$HTML_FILE" "归属待整理"
assert_contains_file "$HTML_FILE" "依赖链路"
assert_contains_file "$HTML_FILE" "申请资格"
assert_contains_file "$HTML_FILE" "route_policy =" assert_contains_file "$HTML_FILE" "route_policy ="
assert_contains_file "$HTML_FILE" "gpt-5.4" assert_contains_file "$HTML_FILE" "gpt-5.4"
assert_contains_file "$HTML_FILE" "MiniMax-M2.7-highspeed" assert_contains_file "$HTML_FILE" "MiniMax-M2.7-highspeed"
assert_contains_file "$HTML_FILE" "deepseek-chat" assert_contains_file "$HTML_FILE" "deepseek-chat"
assert_contains_file "$ADMIN_COMMON_CSS_FILE" ".topnav"
assert_contains_file "$ADMIN_COMMON_CSS_FILE" ".statusbar"
assert_contains_file "$ADMIN_COMMON_JS_FILE" "Sub2ApiAdminCommon"
assert_contains_file "$ADMIN_COMMON_JS_FILE" "createAdminPageRuntime"
assert_contains_file "$ADMIN_COMMON_JS_FILE" "renderAdminNav"
assert_contains_file "$ADMIN_COMMON_JS_FILE" "Authorization"
assert_contains_file "$ADMIN_COMMON_JS_FILE" 'credentials: "include"'
assert_contains_file "$ADMIN_COMMON_JS_FILE" "/api/admin/session/login"
assert_contains_file "$ADMIN_COMMON_JS_FILE" "/api/admin/session/logout"
assert_contains_file "$ADMIN_COMMON_JS_FILE" "/api/admin/session"
assert_contains_file "$ADMIN_COMMON_JS_FILE" "/portal/admin/"
assert_contains_file "$ADMIN_COMMON_JS_FILE" "/portal/admin/logical-groups.html"
assert_contains_file "$ADMIN_COMMON_JS_FILE" "/portal/admin/route-health.html"
assert_contains_file "$ADMIN_COMMON_JS_FILE" "/portal/admin/accounts.html"
assert_contains_file "$ADMIN_COMMON_JS_FILE" "/portal/admin/providers.html"
assert_contains_file "$ADMIN_COMMON_JS_FILE" "/portal/admin/batch-import.html"
assert_contains_file "$ADMIN_COMMON_JS_FILE" "/portal/"
assert_contains_file "$ADMIN_HTML_FILE" "Batch Import Admin" assert_contains_file "$ADMIN_HTML_FILE" "Batch Import Admin"
assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/" assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin-common.css"
assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/logical-groups.html" assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin-common.js"
assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/providers.html"
assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/batch-import.html"
assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/accounts.html"
assert_contains_file "$ADMIN_HTML_FILE" "/portal-admin-api" assert_contains_file "$ADMIN_HTML_FILE" "/portal-admin-api"
assert_contains_file "$ADMIN_HTML_FILE" "matched_account_state" assert_contains_file "$ADMIN_HTML_FILE" "matched_account_state"
assert_contains_file "$ADMIN_HTML_FILE" "account_resolution" assert_contains_file "$ADMIN_HTML_FILE" "account_resolution"
assert_contains_file "$ADMIN_HTML_FILE" "/api/batch-import/runs" assert_contains_file "$ADMIN_HTML_FILE" "/api/batch-import/runs"
assert_contains_file "$ADMIN_HTML_FILE" "/api/batch-import/runs/" assert_contains_file "$ADMIN_HTML_FILE" "/api/batch-import/runs/"
assert_contains_file "$ADMIN_HTML_FILE" '/items${query ?' assert_contains_file "$ADMIN_HTML_FILE" '/items${query ?'
assert_contains_file "$ADMIN_HTML_FILE" "Authorization"
assert_contains_file "$ADMIN_HTML_FILE" "base_url|api_key|requested_model_1,requested_model_2" assert_contains_file "$ADMIN_HTML_FILE" "base_url|api_key|requested_model_1,requested_model_2"
assert_contains_file "$ADMIN_HTML_FILE" "reused" assert_contains_file "$ADMIN_HTML_FILE" "reused"
assert_contains_file "$ADMIN_HTML_FILE" "reactivated" assert_contains_file "$ADMIN_HTML_FILE" "reactivated"
assert_contains_file "$ADMIN_HOME_FILE" "Admin Portal" assert_contains_file "$ADMIN_HOME_FILE" "Admin Portal"
assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/logical-groups.html" assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin-common.css"
assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/route-health.html" assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin-common.js"
assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/accounts.html" assert_contains_file "$ADMIN_HOME_FILE" "data-admin-nav"
assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/providers.html"
assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/batch-import.html"
assert_contains_file "$ADMIN_HOME_FILE" "/portal-admin-api" assert_contains_file "$ADMIN_HOME_FILE" "/portal-admin-api"
assert_contains_file "$ADMIN_HOME_FILE" "浏览器提交到 CRM" assert_contains_file "$ADMIN_HOME_FILE" "浏览器提交到 CRM"
assert_contains_file "$ADMIN_HOME_FILE" "逻辑分组 / 路由" assert_contains_file "$ADMIN_HOME_FILE" "逻辑分组 / 路由"
@@ -118,15 +136,9 @@ assert_contains_file "$ADMIN_HOME_FILE" "帐号资产"
assert_contains_file "$ADMIN_HOME_FILE" "/accounts" assert_contains_file "$ADMIN_HOME_FILE" "/accounts"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "Logical Group Admin" assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "Logical Group Admin"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/" assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin-common.css"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/logical-groups.html" assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin-common.js"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/route-health.html" assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "data-admin-nav"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/accounts.html"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/providers.html"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/batch-import.html"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/api/admin/session/login"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/api/admin/session/logout"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/api/admin/session"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/api/logical-groups" assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/api/logical-groups"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "logical_group" assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "logical_group"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "shadow_group_id" assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "shadow_group_id"
@@ -139,37 +151,23 @@ assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "package_tier"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "purchase_cta_label" assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "purchase_cta_label"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "purchase_cta_url" assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "purchase_cta_url"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "首版页面只覆盖新增与查看" assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "首版页面只覆盖新增与查看"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" 'credentials: "include"'
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal-admin-api" assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal-admin-api"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "Route Health Admin" assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "Route Health Admin"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/" assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin-common.css"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/logical-groups.html" assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin-common.js"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/route-health.html" assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "data-admin-nav"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/accounts.html"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/providers.html"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/batch-import.html"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/api/admin/session/login"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/api/admin/session/logout"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/api/admin/session"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/api/routing/routes/health" assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/api/routing/routes/health"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "healthy" assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "healthy"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "cooldown" assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "cooldown"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "failing" assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "failing"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "disabled" assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "disabled"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" 'credentials: "include"'
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal-admin-api" assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal-admin-api"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "Provider Accounts Admin" assert_contains_file "$ADMIN_ACCOUNTS_FILE" "Provider Accounts Admin"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin/" assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin-common.css"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin/logical-groups.html" assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin-common.js"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin/route-health.html" assert_contains_file "$ADMIN_ACCOUNTS_FILE" "data-admin-nav"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin/accounts.html"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin/providers.html"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin/batch-import.html"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/api/admin/session/login"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/api/admin/session/logout"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/api/admin/session"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/api/provider-accounts" assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/api/provider-accounts"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/binding-candidates" assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/binding-candidates"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/binding" assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/binding"
@@ -185,17 +183,13 @@ assert_contains_file "$ADMIN_ACCOUNTS_FILE" "shadow_host_id"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "provider_accounts" assert_contains_file "$ADMIN_ACCOUNTS_FILE" "provider_accounts"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "显式整理归属" assert_contains_file "$ADMIN_ACCOUNTS_FILE" "显式整理归属"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "conflict" assert_contains_file "$ADMIN_ACCOUNTS_FILE" "conflict"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" 'credentials: "include"'
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal-admin-api" assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal-admin-api"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "Provider Admin" assert_contains_file "$ADMIN_PROVIDERS_FILE" "Provider Admin"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/portal/admin-common.css"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/portal/admin-common.js"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "data-admin-nav"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "管理员登录" assert_contains_file "$ADMIN_PROVIDERS_FILE" "管理员登录"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/portal/admin/logical-groups.html"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/portal/admin/route-health.html"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/portal/admin/accounts.html"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/admin/session/login"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/admin/session/logout"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/admin/session"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/packs" assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/packs"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/hosts" assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/hosts"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/providers/" assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/providers/"
@@ -211,7 +205,6 @@ assert_contains_file "$ADMIN_PROVIDERS_FILE" "Provider Manifest 草稿"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/portal-admin-api" assert_contains_file "$ADMIN_PROVIDERS_FILE" "/portal-admin-api"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/publish" assert_contains_file "$ADMIN_PROVIDERS_FILE" "/publish"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "发布 Commit Message" assert_contains_file "$ADMIN_PROVIDERS_FILE" "发布 Commit Message"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "credentials: \"include\""
assert_contains_file "$ADMIN_PROVIDERS_FILE" "最近成功模板" assert_contains_file "$ADMIN_PROVIDERS_FILE" "最近成功模板"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "根据 display name / base url / models 自动生成" assert_contains_file "$ADMIN_PROVIDERS_FILE" "根据 display name / base url / models 自动生成"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "同模型已存在" assert_contains_file "$ADMIN_PROVIDERS_FILE" "同模型已存在"
@@ -219,13 +212,8 @@ assert_contains_file "$ADMIN_PROVIDERS_FILE" "providerIdPreview"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "modelConflicts" assert_contains_file "$ADMIN_PROVIDERS_FILE" "modelConflicts"
assert_contains_file "$ADMIN_BATCH_FILE" "/portal/admin-batch-import.html" assert_contains_file "$ADMIN_BATCH_FILE" "/portal/admin-batch-import.html"
assert_contains_file "$ADMIN_BATCH_FILE" "Batch Import Admin Redirect"
assert_contains_file "$ADMIN_HTML_FILE" "管理员登录" assert_contains_file "$ADMIN_HTML_FILE" "管理员登录"
assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/route-health.html"
assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/accounts.html"
assert_contains_file "$ADMIN_HTML_FILE" "/api/admin/session/login"
assert_contains_file "$ADMIN_HTML_FILE" "/api/admin/session/logout"
assert_contains_file "$ADMIN_HTML_FILE" "/api/admin/session"
assert_contains_file "$ADMIN_HTML_FILE" "credentials: \"include\""
assert_contains_file "$NGINX_FILE" "location = /portal" assert_contains_file "$NGINX_FILE" "location = /portal"
assert_contains_file "$NGINX_FILE" "location = /portal/admin" assert_contains_file "$NGINX_FILE" "location = /portal/admin"

View File

@@ -0,0 +1,549 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
PORTAL_ROOT="$ROOT_DIR/deploy/tksea-portal"
ARTIFACT_DIR="${ARTIFACT_DIR:-$(mktemp -d "/tmp/sub2api-cn-relay-manager-frontend-smoke-XXXXXX")}"
WORK_DIR="$(mktemp -d "/tmp/sub2api-cn-relay-manager-frontend-smoke-work-XXXXXX")"
PORT_FILE="$WORK_DIR/server-port.txt"
SERVER_LOG="$WORK_DIR/server.log"
SERVER_SCRIPT="$WORK_DIR/frontend_smoke_server.py"
CHROMIUM_BIN="${CHROMIUM_BIN:-}"
USER_DATA_DIR="$WORK_DIR/chromium-profile"
cleanup() {
if [[ -n "${SERVER_PID:-}" ]]; then
kill "$SERVER_PID" >/dev/null 2>&1 || true
wait "$SERVER_PID" >/dev/null 2>&1 || true
fi
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
assert_contains_file() {
local file="$1"
local needle="$2"
if ! grep -Fq "$needle" "$file"; then
fail "expected [$needle] in $file"
fi
}
find_chromium() {
if [[ -n "$CHROMIUM_BIN" ]]; then
printf '%s\n' "$CHROMIUM_BIN"
return 0
fi
local candidate
for candidate in chromium chromium-browser google-chrome google-chrome-stable; do
if command -v "$candidate" >/dev/null 2>&1; then
command -v "$candidate"
return 0
fi
done
return 1
}
CHROMIUM_BIN="$(find_chromium)" || fail "missing chromium-compatible browser; set CHROMIUM_BIN explicitly"
[[ -x "$CHROMIUM_BIN" ]] || fail "chromium binary is not executable: $CHROMIUM_BIN"
[[ -d "$PORTAL_ROOT" ]] || fail "missing portal root: $PORTAL_ROOT"
mkdir -p "$ARTIFACT_DIR" "$USER_DATA_DIR"
cat >"$SERVER_SCRIPT" <<'PY'
#!/usr/bin/env python3
import json
import mimetypes
import os
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
ROOT = Path(os.environ["PORTAL_ROOT"]).resolve()
PORT_FILE = Path(os.environ["PORT_FILE"])
def json_response(handler, payload, status=200):
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json; charset=utf-8")
handler.send_header("Content-Length", str(len(body)))
handler.end_headers()
handler.wfile.write(body)
def text_response(handler, payload, status=200):
body = payload.encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "text/plain; charset=utf-8")
handler.send_header("Content-Length", str(len(body)))
handler.end_headers()
handler.wfile.write(body)
def sample_portal_logical_groups():
return {
"logical_groups": [
{
"logical_group_id": "smoke-portal-group",
"display_name": "Smoke Portal Group",
"description": "用于最小前端 smoke 的逻辑分组样本。",
"public_models": [{"public_model": "gpt-5.4"}],
"active_route_count": 1,
"visibility_scope": "public",
"package_tier": "standard",
"usage_scenario": "浏览器级 smoke 验证",
"recommendation": "先确认页面可打开,再验证目录与导航。",
"next_step_hint": "如需完整验收,再跑真实宿主 acceptance。",
"purchase_cta_label": "申请测试 Key",
"purchase_cta_url": "/portal/",
}
]
}
def sample_groups_available():
return [
{
"id": 101,
"name": "OpenAI 中转默认分组",
}
]
def sample_subscriptions():
return [
{
"id": 501,
"group_id": 101,
"status": "active",
"expires_at": "2099-12-31T00:00:00Z",
}
]
def sample_keys():
return {
"items": [
{
"id": 1,
"name": "Smoke Key",
"group_id": 101,
"key": "sk-smoke-visible-key",
"status": "active",
"created_at": "2099-01-01T00:00:00Z",
"expires_at": "2099-12-31T00:00:00Z",
}
]
}
def sample_admin_session():
return {
"authenticated": True,
"login_enabled": True,
"username": "smoke-admin",
"expires_at": "2099-12-31T00:00:00Z",
}
def sample_logical_groups():
return {
"logical_groups": [
{
"logical_group_id": "smoke-lg-001",
"display_name": "Smoke Logical Group",
"status": "active",
"description": "Smoke logical group",
"shadow_group_id": "shadow-smoke-group",
"shadow_host_id": "shadow-smoke-host",
"routes": [],
"public_models": [],
}
]
}
def sample_route_health():
return {
"route_health": [
{
"route_id": "smoke-route-primary",
"logical_group_id": "smoke-lg-001",
"runtime_status": "healthy",
"priority": 10,
"weight": 100,
"public_model_count": 1,
"recent_failover_count": 0,
"last_error_class": "",
"cooldown_reason": "",
}
]
}
def sample_provider_accounts():
return {
"provider_accounts": [
{
"id": 2001,
"display_name": "Smoke Provider Account",
"provider_id": "smoke-provider",
"host_id": "host-smoke-001",
"status": "active",
"binding_state": "assigned",
"binding_candidate_count": 1,
"logical_group_id": "smoke-lg-001",
"route_id": "smoke-route-primary",
"shadow_group_id": "shadow-smoke-group",
"shadow_host_id": "shadow-smoke-host",
}
]
}
def sample_packs():
return {
"packs": [
{
"pack_id": "openai-cn-pack",
"display_name": "OpenAI CN Pack",
"provider_count": 1,
}
]
}
def sample_hosts():
return {
"hosts": [
{
"host_id": "host-smoke-001",
"name": "Smoke Host",
"base_url": "https://host-smoke.example.com",
}
]
}
def sample_pack_providers():
return {
"providers": [
{
"provider_id": "smoke-provider",
"display_name": "Smoke Provider",
"platform": "openai",
"base_url": "https://provider-smoke.example.com/v1",
"smoke_test_model": "gpt-5.4",
"supported_models": ["gpt-5.4"],
"host_overlays": 0,
}
]
}
def sample_provider_drafts():
return {"provider_drafts": []}
def sample_batch_run():
return {
"run": {
"run_id": "smoke-run-001",
"status": "succeeded",
"matched_account_state_summary": {"created": 1},
}
}
def sample_batch_items():
return {
"items": [
{
"provider_id": "smoke-provider",
"matched_account_state": "created",
"account_resolution": "created",
"provision_reused": False,
}
]
}
class Handler(SimpleHTTPRequestHandler):
def log_message(self, fmt, *args):
return
def serve_static(self, rel_path):
file_path = (ROOT / rel_path).resolve()
if not file_path.exists() or ROOT not in file_path.parents and file_path != ROOT:
self.send_error(404, "not found")
return
content = file_path.read_bytes()
mime_type, _ = mimetypes.guess_type(str(file_path))
self.send_response(200)
self.send_header("Content-Type", f"{mime_type or 'text/plain'}; charset=utf-8")
self.send_header("Content-Length", str(len(content)))
self.end_headers()
self.wfile.write(content)
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path
query = parse_qs(parsed.query)
if path == "/healthz":
text_response(self, "ok")
return
if path == "/portal":
self.send_response(302)
self.send_header("Location", "/portal/")
self.end_headers()
return
if path == "/portal/admin":
self.send_response(302)
self.send_header("Location", "/portal/admin/")
self.end_headers()
return
if path == "/portal/":
self.serve_static("index.html")
return
if path == "/portal/admin/":
self.serve_static("admin/index.html")
return
if path.startswith("/portal/"):
rel_path = path[len("/portal/"):]
self.serve_static(rel_path)
return
if path == "/portal-proxy/api/v1/auth/me":
json_response(
self,
{
"code": 0,
"data": {
"id": 42,
"email": "smoke-user@example.com",
"allowed_groups": [101],
},
},
)
return
if path == "/portal-proxy/api/v1/groups/available":
json_response(self, {"code": 0, "data": sample_groups_available()})
return
if path == "/portal-proxy/api/v1/subscriptions":
json_response(self, {"code": 0, "data": sample_subscriptions()})
return
if path == "/portal-proxy/api/v1/keys":
json_response(self, {"code": 0, "data": sample_keys()})
return
if path == "/portal-admin-api/api/portal/logical-groups":
json_response(self, sample_portal_logical_groups())
return
if path == "/portal-admin-api/api/admin/session":
json_response(self, sample_admin_session())
return
if path == "/portal-admin-api/api/logical-groups":
json_response(self, sample_logical_groups())
return
if path == "/portal-admin-api/api/routing/routes/health":
json_response(self, sample_route_health())
return
if path == "/portal-admin-api/api/provider-accounts":
json_response(self, sample_provider_accounts())
return
if path == "/portal-admin-api/api/packs":
json_response(self, sample_packs())
return
if path == "/portal-admin-api/api/hosts":
json_response(self, sample_hosts())
return
if path == "/portal-admin-api/api/provider-drafts":
json_response(self, sample_provider_drafts())
return
if path == "/portal-admin-api/api/batch-import/runs/smoke-run-001":
json_response(self, sample_batch_run())
return
if path == "/portal-admin-api/api/batch-import/runs/smoke-run-001/items":
json_response(self, sample_batch_items())
return
if path.startswith("/portal-admin-api/api/packs/") and path.endswith("/providers"):
json_response(self, sample_pack_providers())
return
if path.startswith("/portal-admin-api/api/provider-accounts/") and path.endswith("/binding-candidates"):
json_response(
self,
{
"binding_candidates": [
{
"logical_group_id": "smoke-lg-001",
"route_id": "smoke-route-primary",
}
]
},
)
return
if path.startswith("/portal-admin-api/api/"):
json_response(self, {"ok": True, "path": path, "query": query})
return
self.send_error(404, "not found")
def do_POST(self):
parsed = urlparse(self.path)
path = parsed.path
if path == "/portal-admin-api/api/admin/session/login":
json_response(self, sample_admin_session())
return
if path == "/portal-admin-api/api/admin/session/logout":
json_response(self, {"ok": True})
return
if path == "/portal-admin-api/api/batch-import/runs":
json_response(
self,
{
"run": {
"run_id": "smoke-run-001",
"status": "created",
}
},
)
return
json_response(self, {"ok": True, "path": path})
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
PORT_FILE.write_text(str(server.server_port), encoding="utf-8")
server.serve_forever()
PY
PORTAL_ROOT="$PORTAL_ROOT" PORT_FILE="$PORT_FILE" python3 "$SERVER_SCRIPT" >"$SERVER_LOG" 2>&1 &
SERVER_PID=$!
for _ in $(seq 1 50); do
if [[ -s "$PORT_FILE" ]]; then
break
fi
sleep 0.1
done
if [[ ! -s "$PORT_FILE" ]]; then
if [[ -s "$SERVER_LOG" ]]; then
cat "$SERVER_LOG" >&2 || true
fi
fail "frontend smoke server did not start"
fi
SERVER_PORT="$(cat "$PORT_FILE")"
BASE_URL="http://127.0.0.1:$SERVER_PORT"
for _ in $(seq 1 50); do
if curl -fsS "$BASE_URL/healthz" >/dev/null 2>&1; then
break
fi
sleep 0.1
done
curl -fsS "$BASE_URL/healthz" >/dev/null 2>&1 || fail "frontend smoke server is not healthy"
dump_dom() {
local label="$1"
local url="$2"
local output="$ARTIFACT_DIR/${label}.dom.html"
"$CHROMIUM_BIN" \
--headless \
--disable-gpu \
--no-sandbox \
--user-data-dir="$USER_DATA_DIR/$label" \
--virtual-time-budget=10000 \
--dump-dom \
"$url" >"$output" 2>"$ARTIFACT_DIR/${label}.stderr.txt"
printf '%s\n' "$output"
}
portal_dom="$(dump_dom "00-portal" "$BASE_URL/portal/")"
admin_home_dom="$(dump_dom "01-admin-home" "$BASE_URL/portal/admin/")"
logical_groups_dom="$(dump_dom "02-logical-groups" "$BASE_URL/portal/admin/logical-groups.html")"
route_health_dom="$(dump_dom "03-route-health" "$BASE_URL/portal/admin/route-health.html")"
accounts_dom="$(dump_dom "04-accounts" "$BASE_URL/portal/admin/accounts.html")"
providers_dom="$(dump_dom "05-providers" "$BASE_URL/portal/admin/providers.html")"
batch_dom="$(dump_dom "06-batch-import" "$BASE_URL/portal/admin-batch-import.html")"
compat_batch_dom="$(dump_dom "07-batch-import-compat" "$BASE_URL/portal/admin/batch-import.html")"
assert_contains_file "$portal_dom" "Sub2API 多模型接入中心"
assert_contains_file "$portal_dom" "Smoke Portal Group"
assert_contains_file "$portal_dom" "逻辑分组目录"
assert_contains_file "$portal_dom" "申请测试 Key"
assert_contains_file "$admin_home_dom" "Admin Portal"
assert_contains_file "$admin_home_dom" "/portal/admin/providers.html"
assert_contains_file "$admin_home_dom" "/portal/admin/accounts.html"
assert_contains_file "$logical_groups_dom" "Logical Group Admin"
assert_contains_file "$logical_groups_dom" "smoke-admin"
assert_contains_file "$logical_groups_dom" "Smoke Logical Group"
assert_contains_file "$route_health_dom" "Route Health Admin"
assert_contains_file "$route_health_dom" "smoke-admin"
assert_contains_file "$route_health_dom" "smoke-route-primary"
assert_contains_file "$accounts_dom" "Provider Accounts Admin"
assert_contains_file "$accounts_dom" "smoke-admin"
assert_contains_file "$accounts_dom" "Smoke Provider Account"
assert_contains_file "$providers_dom" "Provider Admin"
assert_contains_file "$providers_dom" "smoke-admin"
assert_contains_file "$providers_dom" "保存到服务端"
assert_contains_file "$batch_dom" "Batch Import Admin"
assert_contains_file "$batch_dom" "smoke-admin"
assert_contains_file "$batch_dom" "matched_account_state"
assert_contains_file "$compat_batch_dom" "Batch Import Admin"
assert_contains_file "$compat_batch_dom" "smoke-admin"
cat >"$ARTIFACT_DIR/99-summary.json" <<EOF
{
"server_port": $SERVER_PORT,
"portal_url": "$BASE_URL/portal/",
"admin_urls": [
"$BASE_URL/portal/admin/",
"$BASE_URL/portal/admin/logical-groups.html",
"$BASE_URL/portal/admin/route-health.html",
"$BASE_URL/portal/admin/accounts.html",
"$BASE_URL/portal/admin/providers.html",
"$BASE_URL/portal/admin-batch-import.html",
"$BASE_URL/portal/admin/batch-import.html"
],
"session_username": "smoke-admin",
"result": "pass"
}
EOF
echo "PASS: frontend browser smoke passed"
echo "artifact dir: $ARTIFACT_DIR"

View File

@@ -22,9 +22,31 @@ GOVET_LOG="$OUTPUT_DIR/govet.txt"
INTEGRATION_LOG="$OUTPUT_DIR/integration.txt" INTEGRATION_LOG="$OUTPUT_DIR/integration.txt"
COVERAGE_LOG="$OUTPUT_DIR/coverage.txt" COVERAGE_LOG="$OUTPUT_DIR/coverage.txt"
COVERAGE_REPORT="$OUTPUT_DIR/coverage-report.md" COVERAGE_REPORT="$OUTPUT_DIR/coverage-report.md"
PORTAL_ASSETS_LOG="$OUTPUT_DIR/portal-assets.txt"
FRONTEND_SMOKE_LOG="$OUTPUT_DIR/frontend-smoke.txt"
log "quality gate output dir: $OUTPUT_DIR" log "quality gate output dir: $OUTPUT_DIR"
log "running portal asset regression"
bash "$ROOT_DIR/scripts/test/test_tksea_portal_assets.sh" 2>&1 | tee "$PORTAL_ASSETS_LOG"
log "running frontend browser smoke"
set +e
bash "$ROOT_DIR/scripts/test/verify_frontend_smoke.sh" 2>&1 | tee "$FRONTEND_SMOKE_LOG"
frontend_smoke_status=${PIPESTATUS[0]}
set -e
if [[ $frontend_smoke_status -ne 0 ]]; then
if grep -Eq 'PermissionError: \[Errno 1\] Operation not permitted|frontend smoke server did not start' "$FRONTEND_SMOKE_LOG"; then
if [[ "${ALLOW_BLOCKED_FRONTEND_SMOKE:-0}" == "1" ]]; then
log "frontend smoke blocked by socket-restricted environment; continuing because ALLOW_BLOCKED_FRONTEND_SMOKE=1"
else
fail "frontend smoke blocked by current environment socket restrictions; rerun in an unrestricted environment or set ALLOW_BLOCKED_FRONTEND_SMOKE=1 for local triage"
fi
else
fail "frontend browser smoke failed"
fi
fi
log "running gofmt check" log "running gofmt check"
gofmt -l . | tee "$GOFMT_LOG" gofmt -l . | tee "$GOFMT_LOG"
if [[ -s "$GOFMT_LOG" ]]; then if [[ -s "$GOFMT_LOG" ]]; then