File size: 5,798 Bytes
6d60378
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
// device_token_test.go X-Device-Token 注入 + 文件兜底读取单测。
package upstream

import (
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/linguo2625469/workbuddy2api-panel/internal/auth"
)

// TestDeviceTokenInjected_WhenSet auth.Auth.DeviceToken 非空时 chat/billing 请求均注入。
func TestDeviceTokenInjected_WhenSet(t *testing.T) {
	a := &auth.Auth{AccessToken: "at", UID: "u1", DeviceToken: "tok-from-auth"}
	// 用 server 端验证而非 RoundTripper 捕获:更贴近真实注入路径。
	for _, tc := range []struct {
		name     string
		apply    func(c *Client, req *http.Request)
		wantPath string
	}{
		{"chat", func(c *Client, req *http.Request) { c.ChatHeaders(req, a, "", ChatMeta{}) }, "/v2/chat/completions"},
		{"billing", func(c *Client, req *http.Request) { c.BillingHeaders(req, a) }, "/v2/report"},
	} {
		t.Run(tc.name, func(t *testing.T) {
			var got string
			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				got = r.Header.Get("X-Device-Token")
				w.WriteHeader(200)
				_, _ = w.Write([]byte(`{"code":0}`))
			}))
			defer srv.Close()
			c := &Client{
				HTTP:          srv.Client(),
				ChatHTTP:      srv.Client(),
				ChatBaseCN:    srv.URL,
				BillingBaseCN: srv.URL,
			}
			req, _ := http.NewRequest(http.MethodPost, srv.URL+tc.wantPath, nil)
			tc.apply(c, req)
			resp, err := c.HTTP.Do(req)
			if err != nil {
				t.Fatalf("do: %v", err)
			}
			resp.Body.Close()
			if got != "tok-from-auth" {
				t.Errorf("X-Device-Token = %q want %q", got, "tok-from-auth")
			}
		})
	}
}

// TestDeviceTokenNotInjected_WhenEmpty 所有来源皆空时不注入该头。
func TestDeviceTokenNotInjected_WhenEmpty(t *testing.T) {
	a := &auth.Auth{AccessToken: "at", UID: "u1"} // DeviceToken 空
	var got string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		got = r.Header.Get("X-Device-Token")
		w.WriteHeader(200)
		_, _ = w.Write([]byte(`{"code":0}`))
	}))
	defer srv.Close()
	c := &Client{
		HTTP:          srv.Client(),
		ChatHTTP:      srv.Client(),
		ChatBaseCN:    srv.URL,
		BillingBaseCN: srv.URL,
		// DeviceToken / DeviceTokenFile 皆空
	}
	req, _ := http.NewRequest(http.MethodPost, srv.URL+"/v2/chat/completions", nil)
	c.ChatHeaders(req, a, "", ChatMeta{})
	resp, err := c.HTTP.Do(req)
	if err != nil {
		t.Fatalf("do: %v", err)
	}
	resp.Body.Close()
	if got != "" {
		t.Errorf("X-Device-Token = %q want empty (not injected)", got)
	}
}

// TestDeviceTokenFromConfigOrFile_Overrides 优先级:auth > config > 文件。
// auth 有值时覆盖 config;auth 空时 config 兜底;auth 与 config 皆空时读文件。
func TestDeviceTokenFromConfigOrFile_Overrides(t *testing.T) {
	dir := t.TempDir()
	fp := filepath.Join(dir, "device_token")
	if err := os.WriteFile(fp, []byte("tok-from-file\n"), 0o600); err != nil {
		t.Fatalf("write file: %v", err)
	}

	cases := []struct {
		name string
		auth string
		cfg  string
		want string
	}{
		{"auth_over_config", "tok-auth", "tok-config", "tok-auth"},
		{"config_when_auth_empty", "", "tok-config", "tok-config"},
		{"file_when_auth_and_config_empty", "", "", "tok-from-file"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			// 每个用例用独立缓存:device token 文件缓存 5 分钟,case 间会串扰。
			save := resetDeviceTokenFileCache(fp)
			defer save()
			a := &auth.Auth{AccessToken: "at", UID: "u1", DeviceToken: tc.auth}
			var got string
			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				got = r.Header.Get("X-Device-Token")
				w.WriteHeader(200)
				_, _ = w.Write([]byte(`{"code":0}`))
			}))
			defer srv.Close()
			c := &Client{
				HTTP:            srv.Client(),
				ChatHTTP:        srv.Client(),
				ChatBaseCN:      srv.URL,
				BillingBaseCN:   srv.URL,
				DeviceToken:     tc.cfg,
				DeviceTokenFile: fp,
			}
			req, _ := http.NewRequest(http.MethodPost, srv.URL+"/v2/chat/completions", nil)
			c.ChatHeaders(req, a, "", ChatMeta{})
			resp, err := c.HTTP.Do(req)
			if err != nil {
				t.Fatalf("do: %v", err)
			}
			resp.Body.Close()
			if got != tc.want {
				t.Errorf("X-Device-Token = %q want %q", got, tc.want)
			}
		})
	}
}

// TestDeviceTokenFileTooLarge 文件超过 1KB 时忽略不注入。
func TestDeviceTokenFileTooLarge(t *testing.T) {
	dir := t.TempDir()
	fp := filepath.Join(dir, "device_token")
	if err := os.WriteFile(fp, []byte(strings.Repeat("x", 2048)), 0o600); err != nil {
		t.Fatalf("write file: %v", err)
	}
	save := resetDeviceTokenFileCache(fp)
	defer save()
	a := &auth.Auth{AccessToken: "at", UID: "u1"}
	c := &Client{DeviceTokenFile: fp}
	if tok := c.resolveDeviceToken(a); tok != "" {
		t.Errorf("resolveDeviceToken() = %q want empty (file too large)", tok)
	}
}

// resetDeviceTokenFileCache 替换全局 device token 文件缓存并返回恢复函数。
// 文件缓存 5 分钟 TTL,测试间需清空避免串扰。
func resetDeviceTokenFileCache(path string) (restore func()) {
	dtFileCache.mu.Lock()
	origPath := dtFileCache.path
	origTok := dtFileCache.token
	origRead := dtFileCache.readAt
	origErr := dtFileCache.lastErr
	dtFileCache.path = path
	dtFileCache.token = ""
	dtFileCache.readAt = time.Time{}
	dtFileCache.lastErr = nil
	dtFileCache.mu.Unlock()
	var once sync.Once
	return func() {
		once.Do(func() {
			dtFileCache.mu.Lock()
			dtFileCache.path = origPath
			dtFileCache.token = origTok
			dtFileCache.readAt = origRead
			dtFileCache.lastErr = origErr
			dtFileCache.mu.Unlock()
		})
	}
}