Files
gentpan 480a7b551d 服务器与官网:自托管的账号同步服务、GeoLite2 下载配置脚本、隐私页
- server/sync:Go 写的同步服务(GitHub / Google / Apple 登录、PKCE、设置文档存 SQLite),附 systemd 单元、Caddy 片段、install.sh 部署到官网源站、configure.sh 填写各平台密钥
- server/geoip:configure.sh 填 MaxMind 账号与许可证;install.sh 的主机改为官网源站
- 官网新增隐私说明页,首页补隐私条目与 ⌘ 拖动排序的常见问题;部署与发布脚本的默认主机改为 51.38.126.148
2026-09-15 03:37:24 +05:00

204 lines
8.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// fakeProvider 不联网:把授权码当 subject 用
type fakeProvider struct{}
func (fakeProvider) Name() string { return "fake" }
func (fakeProvider) AuthURL(state, redirect string) string {
return "https://example.com/authorize?state=" + state + "&redirect_uri=" + url.QueryEscape(redirect)
}
func (fakeProvider) Exchange(_ context.Context, code, _ string, form url.Values) (Identity, error) {
return Identity{Provider: "fake", Subject: code, Email: code + "@example.com", EmailVerified: true, Name: form.Get("name")}, nil
}
func newTestServer(t *testing.T) *Server {
t.Helper()
store, err := OpenStore(t.TempDir() + "/sync.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { store.Close() })
cfg := Config{BaseURL: "https://sync.test", Providers: map[string]Provider{"fake": fakeProvider{}}}
return NewServer(cfg, store)
}
func do(t *testing.T, srv http.Handler, method, path, token string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader *strings.Reader
if body != nil {
raw, _ := json.Marshal(body)
reader = strings.NewReader(string(raw))
} else {
reader = strings.NewReader("")
}
req := httptest.NewRequest(method, path, reader)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
return rec
}
// 完整走一遍:start → callback → exchange → 存取设置 → 退出
func TestLoginAndSettingsFlow(t *testing.T) {
srv := newTestServer(t)
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
challenge := "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
rec := do(t, srv, "GET", "/api/v1/auth/fake/start?challenge="+challenge, "", nil)
if rec.Code != http.StatusFound {
t.Fatalf("start 返回 %d:%s", rec.Code, rec.Body)
}
authorize, _ := url.Parse(rec.Header().Get("Location"))
state := authorize.Query().Get("state")
if state == "" || authorize.Query().Get("redirect_uri") != "https://sync.test/api/v1/auth/fake/callback" {
t.Fatalf("授权地址不对:%s", authorize)
}
rec = do(t, srv, "GET", "/api/v1/auth/fake/callback?state="+state+"&code=alice", "", nil)
if rec.Code != http.StatusFound {
t.Fatalf("callback 返回 %d:%s", rec.Code, rec.Body)
}
back, _ := url.Parse(rec.Header().Get("Location"))
if back.Scheme != "openstats" || back.Query().Get("code") == "" {
t.Fatalf("没有跳回应用:%s", back)
}
// state 只能用一次
if rec := do(t, srv, "GET", "/api/v1/auth/fake/callback?state="+state+"&code=alice", "", nil); !strings.Contains(rec.Header().Get("Location"), "error=") {
t.Fatalf("重放 state 应该失败:%s", rec.Header().Get("Location"))
}
// verifier 错误
rec = do(t, srv, "POST", "/api/v1/auth/exchange", "", map[string]string{"code": back.Query().Get("code"), "verifier": "wrong"})
if rec.Code != 400 {
t.Fatalf("错误的 verifier 应该被拒绝,得到 %d", rec.Code)
}
// 换取码被消耗后,正确的 verifier 也无法再用:重新走一遍
rec = do(t, srv, "GET", "/api/v1/auth/fake/start?challenge="+challenge, "", nil)
authorize, _ = url.Parse(rec.Header().Get("Location"))
rec = do(t, srv, "GET", "/api/v1/auth/fake/callback?state="+authorize.Query().Get("state")+"&code=alice", "", nil)
back, _ = url.Parse(rec.Header().Get("Location"))
rec = do(t, srv, "POST", "/api/v1/auth/exchange", "", map[string]string{"code": back.Query().Get("code"), "verifier": verifier, "device": "MacBook"})
if rec.Code != 200 {
t.Fatalf("exchange 返回 %d:%s", rec.Code, rec.Body)
}
var session struct {
Token string `json:"token"`
User User `json:"user"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &session)
if session.Token == "" || session.User.Email != "alice@example.com" || len(session.User.Providers) != 1 {
t.Fatalf("会话不完整:%s", rec.Body)
}
if rec := do(t, srv, "GET", "/api/v1/settings", session.Token, nil); rec.Code != 404 {
t.Fatalf("新用户应该没有设置,得到 %d", rec.Code)
}
rec = do(t, srv, "PUT", "/api/v1/settings", session.Token, map[string]any{"document": map[string]any{"refreshSeconds": 3}, "device": "MacBook"})
if rec.Code != 200 {
t.Fatalf("PUT 返回 %d:%s", rec.Code, rec.Body)
}
rec = do(t, srv, "PUT", "/api/v1/settings", session.Token, map[string]any{"document": map[string]any{"refreshSeconds": 5}})
var receipt struct{ Version int64 }
_ = json.Unmarshal(rec.Body.Bytes(), &receipt)
if receipt.Version != 2 {
t.Fatalf("第二次保存版本应为 2,得到 %d", receipt.Version)
}
rec = do(t, srv, "GET", "/api/v1/settings", session.Token, nil)
var stored struct {
Version int64
Document map[string]any
}
_ = json.Unmarshal(rec.Body.Bytes(), &stored)
if stored.Version != 2 || stored.Document["refreshSeconds"] != float64(5) {
t.Fatalf("读回的设置不对:%s", rec.Body)
}
if rec := do(t, srv, "PUT", "/api/v1/settings", session.Token, map[string]any{"document": []int{1}}); rec.Code != 400 {
t.Fatalf("数组文档应被拒绝,得到 %d", rec.Code)
}
// 同邮箱在另一种登录方式下登录,并入同一用户
srv.cfg.Providers["other"] = otherProvider{}
rec = do(t, srv, "GET", "/api/v1/auth/other/start?challenge="+challenge, "", nil)
authorize, _ = url.Parse(rec.Header().Get("Location"))
rec = do(t, srv, "GET", "/api/v1/auth/other/callback?state="+authorize.Query().Get("state")+"&code=alice", "", nil)
back, _ = url.Parse(rec.Header().Get("Location"))
rec = do(t, srv, "POST", "/api/v1/auth/exchange", "", map[string]string{"code": back.Query().Get("code"), "verifier": verifier})
var second struct{ User User }
_ = json.Unmarshal(rec.Body.Bytes(), &second)
if second.User.ID != session.User.ID || len(second.User.Providers) != 2 {
t.Fatalf("同邮箱应并入同一用户:%s", rec.Body)
}
if rec := do(t, srv, "POST", "/api/v1/auth/logout", session.Token, nil); rec.Code != 204 {
t.Fatalf("logout 返回 %d", rec.Code)
}
if rec := do(t, srv, "GET", "/api/v1/me", session.Token, nil); rec.Code != 401 {
t.Fatalf("退出后令牌应失效,得到 %d", rec.Code)
}
}
type otherProvider struct{ fakeProvider }
func (otherProvider) Name() string { return "other" }
func (otherProvider) Exchange(_ context.Context, code, _ string, _ url.Values) (Identity, error) {
return Identity{Provider: "other", Subject: "o-" + code, Email: code + "@example.com", EmailVerified: true}, nil
}
func TestDeleteAccountRemovesEverything(t *testing.T) {
srv := newTestServer(t)
user, err := srv.store.UpsertUser(Identity{Provider: "fake", Subject: "bob"})
if err != nil {
t.Fatal(err)
}
token, _ := srv.store.CreateSession(user.ID, "")
_, _ = srv.store.PutSettings(user.ID, []byte(`{}`), "")
if rec := do(t, srv, "DELETE", "/api/v1/account", token, nil); rec.Code != 204 {
t.Fatalf("删除返回 %d", rec.Code)
}
if _, err := srv.store.GetUser(user.ID); err != ErrNotFound {
t.Fatalf("用户应已删除:%v", err)
}
if _, err := srv.store.GetSettings(user.ID); err != ErrNotFound {
t.Fatalf("设置应已删除:%v", err)
}
}
func TestPKCE(t *testing.T) {
// RFC 7636 附录 B 的示例
if got := pkceChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"); got != "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" {
t.Fatalf("challenge 计算错误:%s", got)
}
}
func TestAppleClientSecretIsES256(t *testing.T) {
const pemKey = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgevZzL1gdAFr88hb2\nOF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r\n1RTwjmYSi9R/zpBnuQ4EiMnCqfMPWiZqB4QdbAd0E7oH50VpuZ1P087G\n-----END PRIVATE KEY-----\n"
p, err := NewApple("com.openstats.web", "TEAM123456", "KEY1234567", []byte(pemKey))
if err != nil {
t.Fatal(err)
}
secret, err := p.(*apple).clientSecret(timeNow())
if err != nil {
t.Fatal(err)
}
parts := strings.Split(secret, ".")
if len(parts) != 3 {
t.Fatalf("JWT 应有三段:%s", secret)
}
claims, err := parseIDToken(secret)
if err != nil || claims.Issuer != "TEAM123456" || claims.Audience != "https://appleid.apple.com" || claims.Subject != "com.openstats.web" {
t.Fatalf("声明不对:%+v %v", claims, err)
}
}