- 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
204 lines
8.0 KiB
Go
204 lines
8.0 KiB
Go
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)
|
||
}
|
||
}
|