Files
ONE/backend/internal/store/account_test.go
T
Sakurasan f1e639e0ba 账户中心:站主资料可编辑 + 身份绑定 + passkey 登录
后台新增 /admin/account 一页,四块:公开资料、密码、第三方账号、Passkey。

-  schema:users 加 role(默认 reader),新表 user_identities、passkeys。
  user_identities 上双 UNIQUE —— (provider, extern_uid) 防一个外部账号顶两个身份,
  (user_id, provider) 防一站主绑两个同平台号,绑错也劫持不了。
  extern_uid 存平台稳定 ID,不存用户名(用户名可改)。
- 头像存 files 里的 key 而非 URL,换存储/CDN 不失效;单 key SetSetting 写入,
  避开 UpdateSettings 的整表替换会把它抹掉。站主名/简介从设置页挪到账户页,
  一个字段只留一个编辑入口。
- OAuth 绑定要求先有后台会话(绑定动作本身是提权路径);已绑的站主身份登录后
  直接发 one_session,读者身份仍发 one_reader。
- passkey 走 go-webauthn v0.15.0(最后一条吃 go 1.24 的版本线),可发现凭据登录。
  必须显式设 ONE_WEBAUTHN_ORIGINS 才启用,不配就安静关掉。
  签名计数只记克隆警告、不硬拦 —— 云同步 passkey 的计数本就不单调。
- 密码故意留在 ONE_ADMIN_PASSWORD,不做哈希入库:这是「解绑一切、删光 passkey
  也还能进门」的保底,比 env 明文更值得守。memos 那个 SSO 建号随机密码无重置
  入口的坑,从设计上绕开。

已知限制:会话仍是有状态无关的 HMAC cookie,删 passkey / 解绑不会让已发出的
7 天后台会话失效 —— 要修得加一张吊销表。
2026-09-30 01:08:55 +08:00

181 lines
4.9 KiB
Go
Raw 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 store
import (
"errors"
"testing"
"oneblog/internal/model"
)
func TestEnsureOwner(t *testing.T) {
s := openTestStore(t)
owner, err := s.EnsureOwner("admin")
if err != nil {
t.Fatal(err)
}
if owner.Role != model.RoleOwner || owner.Provider != "admin" {
t.Fatalf("owner row wrong: %+v", owner)
}
// 幂等:再取一次是同一行
again, err := s.EnsureOwner("admin")
if err != nil {
t.Fatal(err)
}
if again.ID != owner.ID {
t.Fatalf("重复 EnsureOwner 建了两行: %d vs %d", owner.ID, again.ID)
}
// 改了环境变量里的用户名,handle 要跟上
got, err := s.EnsureOwner("newname")
if err != nil {
t.Fatal(err)
}
if got.ID != owner.ID || got.Handle != "newname" {
t.Fatalf("handle 未同步: %+v", got)
}
// 全库只应有一个 owner
all, err := s.ListReaders()
if err != nil {
t.Fatal(err)
}
n := 0
for _, r := range all {
if r.Role == model.RoleOwner {
n++
}
}
if n != 1 {
t.Fatalf("owner 行数 = %d, want 1", n)
}
}
func TestOwnerNotClobberedByReaderUpsert(t *testing.T) {
// 站主行是 provider=admin,OAuth 登录建的是 provider=github 行,
// 两者不能互相覆盖 —— 这是「站主能自定义资料」成立的前提。
s := openTestStore(t)
owner, err := s.EnsureOwner("admin")
if err != nil {
t.Fatal(err)
}
if _, err := s.UpsertReader(model.Reader{Provider: "github", Handle: "someone", Name: "路人"}); err != nil {
t.Fatal(err)
}
got, err := s.GetOwner()
if err != nil {
t.Fatal(err)
}
if got.ID != owner.ID || got.Provider != "admin" {
t.Fatalf("站主行被改动: %+v", got)
}
}
func TestBindIdentityGuards(t *testing.T) {
s := openTestStore(t)
owner, _ := s.EnsureOwner("admin")
other, err := s.UpsertReader(model.Reader{Provider: "github", Handle: "intruder", Name: "n"})
if err != nil {
t.Fatal(err)
}
if err := s.BindIdentity(owner.ID, "github", "12345", "littleckin"); err != nil {
t.Fatal(err)
}
// 重复绑同一个:幂等
if err := s.BindIdentity(owner.ID, "github", "12345", "littleckin"); err != nil {
t.Fatalf("重复绑定应幂等,got %v", err)
}
// 关键护栏:同一个外部账号不能被第二个用户占走
if err := s.BindIdentity(other.ID, "github", "12345", "hijack"); !errors.Is(err, ErrConflict) {
t.Fatalf("抢占他人身份应 ErrConflict, got %v", err)
}
// 换平台可以(UNIQUE 是 user_id+provider)
if err := s.BindIdentity(owner.ID, "telegram", "999", "tg"); err != nil {
t.Fatal(err)
}
ids, err := s.ListIdentities(owner.ID)
if err != nil {
t.Fatal(err)
}
if len(ids) != 2 {
t.Fatalf("绑定数 = %d, want 2", len(ids))
}
// 登录查找:按外部身份找到站主,且 role 正确
u, err := s.GetUserByIdentity("github", "12345")
if err != nil {
t.Fatal(err)
}
if u.ID != owner.ID || u.Role != model.RoleOwner {
t.Fatalf("身份反查结果错: %+v", u)
}
if _, err := s.GetUserByIdentity("github", "nope"); !errors.Is(err, ErrNotFound) {
t.Fatalf("未绑定应 ErrNotFound, got %v", err)
}
// 解绑
if err := s.UnbindIdentity(owner.ID, "github"); err != nil {
t.Fatal(err)
}
if _, err := s.GetUserByIdentity("github", "12345"); !errors.Is(err, ErrNotFound) {
t.Fatal("解绑后仍能查到")
}
if err := s.UnbindIdentity(owner.ID, "github"); !errors.Is(err, ErrNotFound) {
t.Fatalf("重复解绑应 ErrNotFound, got %v", err)
}
}
func TestPasskeyLifecycle(t *testing.T) {
s := openTestStore(t)
owner, _ := s.EnsureOwner("admin")
other, _ := s.UpsertReader(model.Reader{Provider: "github", Handle: "x", Name: "x"})
p, err := s.AddPasskey(model.Passkey{UserID: owner.ID, CredentialID: "cred-1", PublicKey: "base64key", Name: "MacBook", SignCount: 3})
if err != nil {
t.Fatal(err)
}
if p.ID == 0 {
t.Fatal("没拿到自增 id")
}
// 同一把凭据不能注册两次
if _, err := s.AddPasskey(model.Passkey{UserID: other.ID, CredentialID: "cred-1", PublicKey: "k", Name: "dup"}); err == nil {
t.Fatal("重复 credential_id 应被拒")
}
list, err := s.ListPasskeys(owner.ID)
if err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0].Name != "MacBook" {
t.Fatalf("list=%+v", list)
}
if list[0].PublicKey != "" {
t.Fatal("列表接口不该回传公钥")
}
got, err := s.GetPasskeyByCredentialID("cred-1")
if err != nil {
t.Fatal(err)
}
if got.PublicKey != "base64key" || got.SignCount != 3 {
t.Fatalf("got=%+v", got)
}
if err := s.TouchPasskey(got.ID, 9); err != nil {
t.Fatal(err)
}
after, _ := s.GetPasskeyByCredentialID("cred-1")
if after.SignCount != 9 || after.LastUsedAt == "" {
t.Fatalf("TouchPasskey 未生效: %+v", after)
}
// 越权删除必须失败:id 是站主的,user_id 给别人
if err := s.DeletePasskey(after.ID, other.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("跨用户删除应 ErrNotFound, got %v", err)
}
if err := s.DeletePasskey(after.ID, owner.ID); err != nil {
t.Fatal(err)
}
if rest, _ := s.ListPasskeys(owner.ID); len(rest) != 0 {
t.Fatalf("删除后仍有 %d 条", len(rest))
}
}