feat: store GitHub links on accounts that may have no password
This commit is contained in:
1 parent
7863c02170
commit
240c8899a9
17 files changed
+948
-41
No files matched your search
@@ -0,0 +1,6 @@
|
||||
-- +goose Up
|
||||
-- 绑定了 GitHub 的成员也是已注册的账号,可以没有用户名和密码;账号的邮箱因此也可以属于只绑定了 GitHub 的成员。
|
||||
ALTER TABLE members
|
||||
ADD COLUMN github_id bigint UNIQUE,
|
||||
DROP CONSTRAINT members_email_check,
|
||||
ADD CONSTRAINT members_email_check CHECK (email IS NULL OR username IS NOT NULL OR github_id IS NOT NULL);
|
||||
+25
-4
@@ -13,21 +13,42 @@ SELECT * FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE to
|
||||
UPDATE members
|
||||
SET name = coalesce(sqlc.narg(name), name),
|
||||
avatar_hash = CASE WHEN sqlc.narg(avatar_hash)::text IS NULL THEN avatar_hash ELSE NULLIF(sqlc.narg(avatar_hash)::text, '') END,
|
||||
email = CASE WHEN username IS NULL THEN NULL WHEN sqlc.narg(email)::text IS NULL THEN email ELSE NULLIF(sqlc.narg(email)::text, '') END
|
||||
email = CASE WHEN username IS NULL AND github_id IS NULL THEN NULL WHEN sqlc.narg(email)::text IS NULL THEN email ELSE NULLIF(sqlc.narg(email)::text, '') END
|
||||
WHERE id = @id
|
||||
RETURNING *;
|
||||
|
||||
-- name: MemberAccountProfile :one
|
||||
SELECT username, email FROM members WHERE id = @id;
|
||||
SELECT username, email, github_id FROM members WHERE id = @id;
|
||||
|
||||
-- name: RegisterMember :execrows
|
||||
-- 只给游客设置:已经注册过的成员不受影响,影响的行数为 0。
|
||||
-- 只给游客设置。只绑定了 GitHub 的账号补设用户名和密码要删除其他会话,用 AddMemberCredentials。
|
||||
UPDATE members SET username = @username, password_hash = @password_hash
|
||||
WHERE id = @id AND username IS NULL;
|
||||
WHERE id = @id AND username IS NULL AND github_id IS NULL;
|
||||
|
||||
-- name: AddMemberCredentials :execrows
|
||||
-- 只给只绑定了 GitHub 的账号设置,它的“我喜欢”已经在绑定时创建了。
|
||||
UPDATE members SET username = @username, password_hash = @password_hash
|
||||
WHERE id = @id AND username IS NULL AND github_id IS NOT NULL;
|
||||
|
||||
-- name: AccountByUsername :one
|
||||
SELECT * FROM members WHERE username = @username;
|
||||
|
||||
-- name: AccountByID :one
|
||||
SELECT * FROM members WHERE id = @id AND (username IS NOT NULL OR github_id IS NOT NULL);
|
||||
|
||||
-- name: AccountByGitHub :one
|
||||
SELECT * FROM members WHERE github_id = @github_id;
|
||||
|
||||
-- name: BindGitHub :one
|
||||
-- 只给还没有绑定 GitHub 的成员绑定,已经绑定过的成员不受影响,没有返回的行。was_guest 表示成员原来是游客:这次绑定就是注册。
|
||||
UPDATE members SET github_id = @github_id
|
||||
WHERE id = @id AND github_id IS NULL
|
||||
RETURNING (username IS NULL)::boolean AS was_guest;
|
||||
|
||||
-- name: UnbindGitHub :execrows
|
||||
-- 没有密码的账号解绑之后就再也无法登录,所以只给设置了密码的账号解绑。
|
||||
UPDATE members SET github_id = NULL WHERE id = @id AND password_hash IS NOT NULL;
|
||||
|
||||
-- name: SetMemberPassword :execrows
|
||||
UPDATE members SET password_hash = @password_hash WHERE id = @id AND username IS NOT NULL;
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ func (s *server) register(ctx context.Context, c call) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account, err := s.Accounts.Register(ctx, c.member.ID, c.clientIP, req.Username, req.Password)
|
||||
account, err := s.Accounts.Register(ctx, c.member.ID, bearer(c.r), c.clientIP, req.Username, req.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/adapter/out/postgres/internal/query"
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
)
|
||||
|
||||
var _ port.GitHubOAuthApps = (*Store)(nil)
|
||||
|
||||
const githubCredential = "github"
|
||||
|
||||
// Client ID 和 Client Secret 存在凭据表的同一行里,一起写入、一起删除。
|
||||
type githubOAuthApp struct {
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
}
|
||||
|
||||
func (s *Store) GitHubOAuthApp(ctx context.Context) (port.GitHubOAuthApp, error) {
|
||||
value, err := s.q.Credential(ctx, githubCredential)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return port.GitHubOAuthApp{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return port.GitHubOAuthApp{}, fmt.Errorf("读取 GitHub OAuth App:%w", err)
|
||||
}
|
||||
var app githubOAuthApp
|
||||
// 不包装解码的错误:其中可能带有原值的片段,也就可能带有 Client Secret。
|
||||
if err := json.Unmarshal([]byte(value), &app); err != nil || app.ClientID == "" || app.ClientSecret == "" {
|
||||
return port.GitHubOAuthApp{}, errors.New("凭据表中 GitHub OAuth App 的格式不正确")
|
||||
}
|
||||
return port.GitHubOAuthApp(app), nil
|
||||
}
|
||||
|
||||
func (s *Store) SetGitHubOAuthApp(ctx context.Context, app port.GitHubOAuthApp) error {
|
||||
value, err := json.Marshal(githubOAuthApp(app))
|
||||
if err != nil {
|
||||
return fmt.Errorf("编码 GitHub OAuth App:%w", err)
|
||||
}
|
||||
if err := s.q.SetCredential(ctx, query.SetCredentialParams{Name: githubCredential, Value: string(value)}); err != nil {
|
||||
return fmt.Errorf("保存 GitHub OAuth App:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteGitHubOAuthApp(ctx context.Context) error {
|
||||
if err := s.q.DeleteCredential(ctx, githubCredential); err != nil {
|
||||
return fmt.Errorf("清除 GitHub OAuth App:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/dihemoe/alisten/internal/app/port"
|
||||
)
|
||||
|
||||
func TestGitHubOAuthApp(t *testing.T) {
|
||||
store, pool := newStore(t)
|
||||
ctx := t.Context()
|
||||
if app, err := store.GitHubOAuthApp(ctx); err != nil || app != (port.GitHubOAuthApp{}) {
|
||||
t.Fatalf("没有配置时 = %#v, %v,期望零值", app, err)
|
||||
}
|
||||
if err := store.SetNeteaseCookie(ctx, "MUSIC_U=1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []port.GitHubOAuthApp{{ClientID: "client-1", ClientSecret: "secret-1"}, {ClientID: "client-2", ClientSecret: "secret-2"}} {
|
||||
if err := store.SetGitHubOAuthApp(ctx, want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := store.GitHubOAuthApp(ctx); err != nil || got != want {
|
||||
t.Errorf("GitHubOAuthApp = %#v, %v,期望 %#v", got, err, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 并发地保存和读取:读到的 Client ID 和 Secret 总是同一次保存的。
|
||||
var wg sync.WaitGroup
|
||||
for i := range 4 {
|
||||
wg.Go(func() {
|
||||
for j := range 20 {
|
||||
app := port.GitHubOAuthApp{ClientID: fmt.Sprintf("client-%d-%d", i, j), ClientSecret: fmt.Sprintf("secret-%d-%d", i, j)}
|
||||
if err := store.SetGitHubOAuthApp(ctx, app); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
wg.Go(func() {
|
||||
for range 20 {
|
||||
got, err := store.GitHubOAuthApp(ctx)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if strings.TrimPrefix(got.ClientID, "client-") != strings.TrimPrefix(got.ClientSecret, "secret-") {
|
||||
t.Errorf("读到的 Client ID 和 Secret 不是同一次保存的:%#v", got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if err := store.DeleteGitHubOAuthApp(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := store.GitHubOAuthApp(ctx); err != nil || got != (port.GitHubOAuthApp{}) {
|
||||
t.Errorf("清除之后 = %#v, %v,期望零值", got, err)
|
||||
}
|
||||
if err := store.DeleteGitHubOAuthApp(ctx); err != nil {
|
||||
t.Errorf("没有配置时清除 = %v,期望什么也不做", err)
|
||||
}
|
||||
var rows int
|
||||
if err := pool.QueryRow(ctx, "SELECT count(*) FROM credentials").Scan(&rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cookie, err := store.NeteaseCookie(ctx); err != nil || cookie != "MUSIC_U=1" || rows != 1 {
|
||||
t.Errorf("清除之后网易云 Cookie = %q, %v,凭据表有 %d 行,期望只删除 GitHub 的一行", cookie, err, rows)
|
||||
}
|
||||
}
|
||||
|
||||
// 存储中的值不是保存时的格式时报错,错误信息中不带原值:其中可能有 Client Secret。
|
||||
func TestGitHubOAuthAppRejectsCorruptValue(t *testing.T) {
|
||||
store, pool := newStore(t)
|
||||
for _, value := range []string{`not json secret-1`, `{"clientId":"client-1"}`, `{"clientSecret":"secret-1"}`} {
|
||||
if _, err := pool.Exec(t.Context(), "INSERT INTO credentials (name, value) VALUES ('github', $1) ON CONFLICT (name) DO UPDATE SET value = excluded.value", value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := store.GitHubOAuthApp(t.Context())
|
||||
if err == nil {
|
||||
t.Errorf("存储的值为 %q 时应报错", value)
|
||||
continue
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret-1") {
|
||||
t.Errorf("错误信息中有原值:%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,50 @@ import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const accountByGitHub = `-- name: AccountByGitHub :one
|
||||
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id FROM members WHERE github_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) AccountByGitHub(ctx context.Context, githubID *int64) (Member, error) {
|
||||
row := q.db.QueryRow(ctx, accountByGitHub, githubID)
|
||||
var i Member
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.AvatarHash,
|
||||
&i.CreatedAt,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.Email,
|
||||
&i.RecordListens,
|
||||
&i.GithubID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const accountByID = `-- name: AccountByID :one
|
||||
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id FROM members WHERE id = $1 AND (username IS NOT NULL OR github_id IS NOT NULL)
|
||||
`
|
||||
|
||||
func (q *Queries) AccountByID(ctx context.Context, id string) (Member, error) {
|
||||
row := q.db.QueryRow(ctx, accountByID, id)
|
||||
var i Member
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.AvatarHash,
|
||||
&i.CreatedAt,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.Email,
|
||||
&i.RecordListens,
|
||||
&i.GithubID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const accountByUsername = `-- name: AccountByUsername :one
|
||||
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens FROM members WHERE username = $1
|
||||
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id FROM members WHERE username = $1
|
||||
`
|
||||
|
||||
func (q *Queries) AccountByUsername(ctx context.Context, username *string) (Member, error) {
|
||||
@@ -25,13 +67,53 @@ func (q *Queries) AccountByUsername(ctx context.Context, username *string) (Memb
|
||||
&i.PasswordHash,
|
||||
&i.Email,
|
||||
&i.RecordListens,
|
||||
&i.GithubID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const addMemberCredentials = `-- name: AddMemberCredentials :execrows
|
||||
UPDATE members SET username = $1, password_hash = $2
|
||||
WHERE id = $3 AND username IS NULL AND github_id IS NOT NULL
|
||||
`
|
||||
|
||||
type AddMemberCredentialsParams struct {
|
||||
Username *string
|
||||
PasswordHash *string
|
||||
ID string
|
||||
}
|
||||
|
||||
// 只给只绑定了 GitHub 的账号设置,它的“我喜欢”已经在绑定时创建了。
|
||||
func (q *Queries) AddMemberCredentials(ctx context.Context, arg AddMemberCredentialsParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, addMemberCredentials, arg.Username, arg.PasswordHash, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const bindGitHub = `-- name: BindGitHub :one
|
||||
UPDATE members SET github_id = $1
|
||||
WHERE id = $2 AND github_id IS NULL
|
||||
RETURNING (username IS NULL)::boolean AS was_guest
|
||||
`
|
||||
|
||||
type BindGitHubParams struct {
|
||||
GithubID *int64
|
||||
ID string
|
||||
}
|
||||
|
||||
// 只给还没有绑定 GitHub 的成员绑定,已经绑定过的成员不受影响,没有返回的行。was_guest 表示成员原来是游客:这次绑定就是注册。
|
||||
func (q *Queries) BindGitHub(ctx context.Context, arg BindGitHubParams) (bool, error) {
|
||||
row := q.db.QueryRow(ctx, bindGitHub, arg.GithubID, arg.ID)
|
||||
var was_guest bool
|
||||
err := row.Scan(&was_guest)
|
||||
return was_guest, err
|
||||
}
|
||||
|
||||
const createMember = `-- name: CreateMember :one
|
||||
INSERT INTO members (name) VALUES ($1)
|
||||
RETURNING id, name, avatar_hash, created_at, username, password_hash, email, record_listens
|
||||
RETURNING id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id
|
||||
`
|
||||
|
||||
func (q *Queries) CreateMember(ctx context.Context, name string) (Member, error) {
|
||||
@@ -46,6 +128,7 @@ func (q *Queries) CreateMember(ctx context.Context, name string) (Member, error)
|
||||
&i.PasswordHash,
|
||||
&i.Email,
|
||||
&i.RecordListens,
|
||||
&i.GithubID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -133,23 +216,24 @@ func (q *Queries) LockMemberPassword(ctx context.Context, id string) (string, er
|
||||
}
|
||||
|
||||
const memberAccountProfile = `-- name: MemberAccountProfile :one
|
||||
SELECT username, email FROM members WHERE id = $1
|
||||
SELECT username, email, github_id FROM members WHERE id = $1
|
||||
`
|
||||
|
||||
type MemberAccountProfileRow struct {
|
||||
Username *string
|
||||
Email *string
|
||||
GithubID *int64
|
||||
}
|
||||
|
||||
func (q *Queries) MemberAccountProfile(ctx context.Context, id string) (MemberAccountProfileRow, error) {
|
||||
row := q.db.QueryRow(ctx, memberAccountProfile, id)
|
||||
var i MemberAccountProfileRow
|
||||
err := row.Scan(&i.Username, &i.Email)
|
||||
err := row.Scan(&i.Username, &i.Email, &i.GithubID)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const memberByToken = `-- name: MemberByToken :one
|
||||
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE token_hash = $1)
|
||||
SELECT id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id FROM members WHERE id = (SELECT member_id FROM member_sessions WHERE token_hash = $1)
|
||||
`
|
||||
|
||||
func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member, error) {
|
||||
@@ -164,6 +248,7 @@ func (q *Queries) MemberByToken(ctx context.Context, tokenHash []byte) (Member,
|
||||
&i.PasswordHash,
|
||||
&i.Email,
|
||||
&i.RecordListens,
|
||||
&i.GithubID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -186,7 +271,7 @@ func (q *Queries) MemberHasSession(ctx context.Context, arg MemberHasSessionPara
|
||||
|
||||
const registerMember = `-- name: RegisterMember :execrows
|
||||
UPDATE members SET username = $1, password_hash = $2
|
||||
WHERE id = $3 AND username IS NULL
|
||||
WHERE id = $3 AND username IS NULL AND github_id IS NULL
|
||||
`
|
||||
|
||||
type RegisterMemberParams struct {
|
||||
@@ -195,7 +280,7 @@ type RegisterMemberParams struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
// 只给游客设置:已经注册过的成员不受影响,影响的行数为 0。
|
||||
// 只给游客设置。只绑定了 GitHub 的账号补设用户名和密码要删除其他会话,用 AddMemberCredentials。
|
||||
func (q *Queries) RegisterMember(ctx context.Context, arg RegisterMemberParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, registerMember, arg.Username, arg.PasswordHash, arg.ID)
|
||||
if err != nil {
|
||||
@@ -232,13 +317,26 @@ func (q *Queries) TakeSession(ctx context.Context, tokenHash []byte) (string, er
|
||||
return member_id, err
|
||||
}
|
||||
|
||||
const unbindGitHub = `-- name: UnbindGitHub :execrows
|
||||
UPDATE members SET github_id = NULL WHERE id = $1 AND password_hash IS NOT NULL
|
||||
`
|
||||
|
||||
// 没有密码的账号解绑之后就再也无法登录,所以只给设置了密码的账号解绑。
|
||||
func (q *Queries) UnbindGitHub(ctx context.Context, id string) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, unbindGitHub, id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const updateMember = `-- name: UpdateMember :one
|
||||
UPDATE members
|
||||
SET name = coalesce($1, name),
|
||||
avatar_hash = CASE WHEN $2::text IS NULL THEN avatar_hash ELSE NULLIF($2::text, '') END,
|
||||
email = CASE WHEN username IS NULL THEN NULL WHEN $3::text IS NULL THEN email ELSE NULLIF($3::text, '') END
|
||||
email = CASE WHEN username IS NULL AND github_id IS NULL THEN NULL WHEN $3::text IS NULL THEN email ELSE NULLIF($3::text, '') END
|
||||
WHERE id = $4
|
||||
RETURNING id, name, avatar_hash, created_at, username, password_hash, email, record_listens
|
||||
RETURNING id, name, avatar_hash, created_at, username, password_hash, email, record_listens, github_id
|
||||
`
|
||||
|
||||
type UpdateMemberParams struct {
|
||||
@@ -266,6 +364,7 @@ func (q *Queries) UpdateMember(ctx context.Context, arg UpdateMemberParams) (Mem
|
||||
&i.PasswordHash,
|
||||
&i.Email,
|
||||
&i.RecordListens,
|
||||
&i.GithubID,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -17,6 +17,7 @@ type Member struct {
|
||||
PasswordHash *string
|
||||
Email *string
|
||||
RecordListens bool
|
||||
GithubID *int64
|
||||
}
|
||||
|
||||
type PlaylistItem struct {
|
||||
|
||||
@@ -66,7 +66,7 @@ func (s *Store) AccountProfile(ctx context.Context, memberID string) (port.Accou
|
||||
if err != nil {
|
||||
return port.AccountProfile{}, fmt.Errorf("读取成员 %s 的账号资料:%w", memberID, err)
|
||||
}
|
||||
return port.AccountProfile{Username: orEmpty(row.Username), Email: orEmpty(row.Email)}, nil
|
||||
return port.AccountProfile{Username: orEmpty(row.Username), Email: orEmpty(row.Email), GitHub: row.GithubID != nil}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Register(ctx context.Context, memberID, username, passwordHash string) error {
|
||||
@@ -87,6 +87,27 @@ func (s *Store) Register(ctx context.Context, memberID, username, passwordHash s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) AddCredentials(ctx context.Context, memberID, username, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
|
||||
var deleted [][]byte
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
q := s.q.WithTx(tx)
|
||||
// UPDATE 锁住成员行,之后用新的语句删除会话:同时在用 GitHub 登录的设备提交之后才开始删,它签发的会话也会被删掉。
|
||||
n, err := q.AddMemberCredentials(ctx, query.AddMemberCredentialsParams{ID: memberID, Username: &username, PasswordHash: &passwordHash})
|
||||
if err != nil {
|
||||
return usernameError(err)
|
||||
}
|
||||
if n == 0 {
|
||||
return port.ErrAlreadyRegistered
|
||||
}
|
||||
deleted, err = q.DeleteMemberSessions(ctx, query.DeleteMemberSessionsParams{MemberID: memberID, Keep: keepTokenHash})
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("给成员 %s 补设用户名和密码:%w", memberID, err)
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func usernameError(err error) error {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == uniqueViolation && pgErr.ConstraintName == "members_username_key" {
|
||||
@@ -103,7 +124,71 @@ func (s *Store) AccountByUsername(ctx context.Context, username string) (port.Ac
|
||||
if err != nil {
|
||||
return port.Account{}, fmt.Errorf("按用户名查找成员:%w", err)
|
||||
}
|
||||
return port.Account{Member: memberOf(row), Username: orEmpty(row.Username), Email: orEmpty(row.Email), PasswordHash: orEmpty(row.PasswordHash)}, nil
|
||||
return accountOf(row), nil
|
||||
}
|
||||
|
||||
func (s *Store) AccountByID(ctx context.Context, memberID string) (port.Account, error) {
|
||||
row, err := s.q.AccountByID(ctx, memberID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
err = port.ErrMemberNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return port.Account{}, fmt.Errorf("读取成员 %s 的账号:%w", memberID, err)
|
||||
}
|
||||
return accountOf(row), nil
|
||||
}
|
||||
|
||||
func (s *Store) AccountByGitHub(ctx context.Context, githubID int64) (port.Account, error) {
|
||||
row, err := s.q.AccountByGitHub(ctx, &githubID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
err = port.ErrMemberNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return port.Account{}, fmt.Errorf("按 GitHub 账号查找成员:%w", err)
|
||||
}
|
||||
return accountOf(row), nil
|
||||
}
|
||||
|
||||
func accountOf(row query.Member) port.Account {
|
||||
account := port.Account{Member: memberOf(row), Username: orEmpty(row.Username), Email: orEmpty(row.Email), PasswordHash: orEmpty(row.PasswordHash)}
|
||||
if row.GithubID != nil {
|
||||
account.GitHubID = *row.GithubID
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
||||
func (s *Store) BindGitHub(ctx context.Context, memberID string, githubID int64) error {
|
||||
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
q := s.q.WithTx(tx)
|
||||
wasGuest, err := q.BindGitHub(ctx, query.BindGitHubParams{ID: memberID, GithubID: &githubID})
|
||||
var pgErr *pgconn.PgError
|
||||
switch {
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
return port.ErrGitHubAlreadyBound
|
||||
case errors.As(err, &pgErr) && pgErr.Code == uniqueViolation && pgErr.ConstraintName == "members_github_id_key":
|
||||
return port.ErrGitHubTaken
|
||||
case err != nil:
|
||||
return err
|
||||
case !wasGuest:
|
||||
return nil
|
||||
}
|
||||
return q.CreateFavorites(ctx, memberID)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("给成员 %s 绑定 GitHub:%w", memberID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) UnbindGitHub(ctx context.Context, memberID string) error {
|
||||
n, err := s.q.UnbindGitHub(ctx, memberID)
|
||||
if err == nil && n == 0 {
|
||||
err = port.ErrNoPassword
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("给成员 %s 解绑 GitHub:%w", memberID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ReplaceSession(ctx context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error {
|
||||
|
||||
@@ -428,6 +428,135 @@ func TestReplaceSessionWaitsForPasswordChange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func favoritesCount(t *testing.T, store *Store, memberID string) int {
|
||||
t.Helper()
|
||||
lists, err := store.MemberPlaylists(t.Context(), memberID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := 0
|
||||
for _, l := range lists {
|
||||
if l.Favorites {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestBindGitHub(t *testing.T) {
|
||||
store, pool := newStore(t)
|
||||
alice := createMember(t, store, "alice", "游客-1a2b")
|
||||
bob := createMember(t, store, "bob", "游客-3c4d")
|
||||
if err := store.BindGitHub(t.Context(), alice.ID, 42); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p, err := store.AccountProfile(t.Context(), alice.ID); err != nil || p != (port.AccountProfile{GitHub: true}) || !p.Registered() {
|
||||
t.Errorf("绑定之后的 AccountProfile = %#v, %v,期望只绑定了 GitHub 的已注册账号", p, err)
|
||||
}
|
||||
want := port.Account{Member: alice, GitHubID: 42}
|
||||
if got, err := store.AccountByGitHub(t.Context(), 42); err != nil || got != want {
|
||||
t.Errorf("AccountByGitHub = %#v, %v,期望 %#v", got, err, want)
|
||||
}
|
||||
if got, err := store.AccountByID(t.Context(), alice.ID); err != nil || got != want {
|
||||
t.Errorf("AccountByID = %#v, %v,期望 %#v", got, err, want)
|
||||
}
|
||||
if n := favoritesCount(t, store, alice.ID); n != 1 {
|
||||
t.Errorf("游客绑定 GitHub 就是注册,应创建一个“我喜欢”,实际 %d 个", n)
|
||||
}
|
||||
|
||||
if err := store.BindGitHub(t.Context(), bob.ID, 42); !errors.Is(err, port.ErrGitHubTaken) {
|
||||
t.Errorf("GitHub 账号已经绑定了别人 = %v,期望 ErrGitHubTaken", err)
|
||||
}
|
||||
if err := store.BindGitHub(t.Context(), alice.ID, 43); !errors.Is(err, port.ErrGitHubAlreadyBound) {
|
||||
t.Errorf("已经绑定过的成员再绑定 = %v,期望 ErrGitHubAlreadyBound", err)
|
||||
}
|
||||
if p, err := store.AccountProfile(t.Context(), bob.ID); err != nil || p.Registered() || favoritesCount(t, store, bob.ID) != 0 {
|
||||
t.Errorf("绑定失败之后 bob 的 AccountProfile = %#v, %v,应仍是游客", p, err)
|
||||
}
|
||||
if _, err := store.AccountByGitHub(t.Context(), 99); !errors.Is(err, port.ErrMemberNotFound) {
|
||||
t.Errorf("没有绑定的 GitHub 账号 = %v,期望 ErrMemberNotFound", err)
|
||||
}
|
||||
if _, err := store.AccountByID(t.Context(), bob.ID); !errors.Is(err, port.ErrMemberNotFound) {
|
||||
t.Errorf("游客的 AccountByID = %v,期望 ErrMemberNotFound", err)
|
||||
}
|
||||
|
||||
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, nil, ptr("alice@example.com")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var email string
|
||||
if err := pool.QueryRow(t.Context(), "SELECT coalesce(email, 'NULL') FROM members WHERE id = $1", alice.ID).Scan(&email); err != nil || email != "alice@example.com" {
|
||||
t.Errorf("只绑定了 GitHub 的账号保存邮箱之后 email 列 = %q, %v", email, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddCredentialsAfterGitHub(t *testing.T) {
|
||||
store, _ := newStore(t)
|
||||
alice := createMember(t, store, "alice", "游客-1a2b")
|
||||
if err := store.BindGitHub(t.Context(), alice.ID, 42); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.UnbindGitHub(t.Context(), alice.ID); !errors.Is(err, port.ErrNoPassword) {
|
||||
t.Errorf("没有密码的账号解绑 = %v,期望 ErrNoPassword", err)
|
||||
}
|
||||
if err := store.Register(t.Context(), alice.ID, "alice", "hash-a"); !errors.Is(err, port.ErrAlreadyRegistered) {
|
||||
t.Errorf("只绑定了 GitHub 的账号用 Register 设置用户名 = %v,期望 ErrAlreadyRegistered", err)
|
||||
}
|
||||
guest := createMember(t, store, "guest", "游客-5e6f")
|
||||
if _, err := store.AddCredentials(t.Context(), guest.ID, "guest", "hash-g", tokenHash("guest")); !errors.Is(err, port.ErrAlreadyRegistered) {
|
||||
t.Errorf("游客用 AddCredentials 设置用户名 = %v,期望 ErrAlreadyRegistered", err)
|
||||
}
|
||||
if err := store.Register(t.Context(), guest.ID, "taken", "hash-g"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.AddCredentials(t.Context(), alice.ID, "taken", "hash-a", tokenHash("alice")); !errors.Is(err, port.ErrUsernameTaken) {
|
||||
t.Errorf("用户名已被占用时 = %v,期望 ErrUsernameTaken", err)
|
||||
}
|
||||
createMember(t, store, "other", "游客-7a8b")
|
||||
if err := store.ReplaceSession(t.Context(), tokenHash("other"), alice.ID, tokenHash("alice-2"), ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deleted, err := store.AddCredentials(t.Context(), alice.ID, "alice", "hash-a", tokenHash("alice"))
|
||||
if err != nil || !sameHashes(deleted, [][]byte{tokenHash("alice-2")}) {
|
||||
t.Fatalf("AddCredentials 返回 %x, %v,期望被删除的 alice-2", deleted, err)
|
||||
}
|
||||
if ownerOf(t, store, "alice") != alice.ID || ownerOf(t, store, "alice-2") != "" {
|
||||
t.Error("AddCredentials 应只留下发出请求的会话")
|
||||
}
|
||||
if _, err := store.AddCredentials(t.Context(), alice.ID, "alice2", "hash-c", tokenHash("alice")); !errors.Is(err, port.ErrAlreadyRegistered) {
|
||||
t.Errorf("再补设一次 = %v,期望 ErrAlreadyRegistered", err)
|
||||
}
|
||||
if n := favoritesCount(t, store, alice.ID); n != 1 {
|
||||
t.Errorf("只绑定了 GitHub 的账号补设用户名之后有 %d 个“我喜欢”,期望仍是 1 个", n)
|
||||
}
|
||||
want := port.Account{Member: alice, Username: "alice", PasswordHash: "hash-a", GitHubID: 42}
|
||||
if got, err := store.AccountByUsername(t.Context(), "alice"); err != nil || got != want {
|
||||
t.Errorf("AccountByUsername = %#v, %v,期望 %#v", got, err, want)
|
||||
}
|
||||
if err := store.UnbindGitHub(t.Context(), alice.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p, err := store.AccountProfile(t.Context(), alice.ID); err != nil || p != (port.AccountProfile{Username: "alice"}) {
|
||||
t.Errorf("解绑之后的 AccountProfile = %#v, %v", p, err)
|
||||
}
|
||||
if _, err := store.AccountByGitHub(t.Context(), 42); !errors.Is(err, port.ErrMemberNotFound) {
|
||||
t.Errorf("解绑之后按 GitHub 查找 = %v,期望 ErrMemberNotFound", err)
|
||||
}
|
||||
if err := store.UnbindGitHub(t.Context(), alice.ID); err != nil {
|
||||
t.Errorf("没有绑定时解绑 = %v,期望什么也不做", err)
|
||||
}
|
||||
|
||||
bob := createMember(t, store, "bob", "游客-3c4d")
|
||||
if err := store.Register(t.Context(), bob.ID, "bob", "hash-b"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.BindGitHub(t.Context(), bob.ID, 42); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := favoritesCount(t, store, bob.ID); n != 1 {
|
||||
t.Errorf("已注册的账号绑定 GitHub 之后有 %d 个“我喜欢”,期望仍是 1 个", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyAccountsHavePasswordOrEmail(t *testing.T) {
|
||||
store, pool := newStore(t)
|
||||
guest := createMember(t, store, "guest", "游客-1a2b")
|
||||
@@ -442,3 +571,49 @@ func TestOnlyAccountsHavePasswordOrEmail(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 补设用户名和密码时,用 GitHub 登录的另一台设备恰好在签发会话:补设要等它提交,再把它签发的会话一并删除。
|
||||
func TestAddCredentialsWaitsForGitHubLogin(t *testing.T) {
|
||||
store, pool := newStore(t)
|
||||
alice := createMember(t, store, "alice", "游客-1a2b")
|
||||
if err := store.BindGitHub(t.Context(), alice.ID, 42); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
createMember(t, store, "guest", "游客-3c4d")
|
||||
|
||||
// 模拟 ReplaceSession 的事务,先不提交。
|
||||
tx, err := pool.Begin(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(t.Context()) }()
|
||||
if _, err := tx.Exec(t.Context(), "SELECT 1 FROM members WHERE id = $1 FOR NO KEY UPDATE", alice.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tx.Exec(t.Context(), "DELETE FROM member_sessions WHERE token_hash = $1", tokenHash("guest")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tx.Exec(t.Context(), "INSERT INTO member_sessions (token_hash, member_id) VALUES ($1, $2)", tokenHash("alice-2"), alice.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
type result struct {
|
||||
deleted [][]byte
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
deleted, err := store.AddCredentials(t.Context(), alice.ID, "alice", "hash-a", tokenHash("alice"))
|
||||
done <- result{deleted, err}
|
||||
}()
|
||||
waitForLockWait(t, pool, 1)
|
||||
if err := tx.Commit(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := <-done; r.err != nil || !sameHashes(r.deleted, [][]byte{tokenHash("alice-2")}) {
|
||||
t.Errorf("登录提交之后,AddCredentials 返回 %x, %v,期望删除登录签发的 alice-2", r.deleted, r.err)
|
||||
}
|
||||
if ownerOf(t, store, "alice-2") != "" || ownerOf(t, store, "alice") != alice.ID {
|
||||
t.Error("补设之后应只留下发出请求的会话")
|
||||
}
|
||||
}
|
||||
@@ -38,9 +38,15 @@ const (
|
||||
|
||||
const loginDeviceTTL = 180 * 24 * time.Hour
|
||||
|
||||
// 只绑定了 GitHub 的账号没有用户名,也没有密码。
|
||||
type Account struct {
|
||||
Username string
|
||||
Email string
|
||||
GitHub bool
|
||||
}
|
||||
|
||||
func accountOf(a port.Account) Account {
|
||||
return Account{Username: a.Username, Email: a.Email, GitHub: a.GitHubID != 0}
|
||||
}
|
||||
|
||||
// 删除会话之后,用这些会话的票据建立的房间连接随之断开:网关立即通知各实例,通知丢失时最迟在网关的一个核对周期之后断开。
|
||||
@@ -62,11 +68,22 @@ func (a *Accounts) Account(ctx context.Context, memberID string) (account Accoun
|
||||
if err != nil {
|
||||
return Account{}, false, err
|
||||
}
|
||||
return Account{Username: profile.Username, Email: profile.Email}, profile.Username != "", nil
|
||||
return Account{Username: profile.Username, Email: profile.Email, GitHub: profile.GitHub}, profile.Registered(), nil
|
||||
}
|
||||
|
||||
// 给游客设置用户名和密码,成员和它的会话都不变。username 和 password 已经按协议校验过格式。
|
||||
func (a *Accounts) Register(ctx context.Context, memberID, clientIP, username, password string) (Account, error) {
|
||||
// 给游客设置用户名和密码,成员和它的会话都不变;只绑定了 GitHub 的账号也用它补设用户名和密码,见 addCredentials。
|
||||
// token 是发出请求的成员 token,username 和 password 已经按协议校验过格式。
|
||||
func (a *Accounts) Register(ctx context.Context, memberID, token, clientIP, username, password string) (Account, error) {
|
||||
profile, err := a.members.AccountProfile(ctx, memberID)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
switch {
|
||||
case profile.Username != "":
|
||||
return Account{}, port.ErrAlreadyRegistered
|
||||
case profile.GitHub:
|
||||
return a.addCredentials(ctx, memberID, token, clientIP, username, password, profile.Email)
|
||||
}
|
||||
settings, err := a.site.Settings(ctx)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
@@ -84,7 +101,25 @@ func (a *Accounts) Register(ctx context.Context, memberID, clientIP, username, p
|
||||
if err := a.members.Register(ctx, memberID, username, hash); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
return Account{Username: username}, nil
|
||||
return Account{Username: username, Email: profile.Email, GitHub: profile.GitHub}, nil
|
||||
}
|
||||
|
||||
// 补设之后,账号除了发出请求的这个会话,其他会话都失效,与修改密码相同。补设不是注册新账号,注册关闭时照样可以。
|
||||
func (a *Accounts) addCredentials(ctx context.Context, memberID, token, clientIP, username, password, email string) (Account, error) {
|
||||
if err := a.take(ctx, "register:ip:"+quotaAddr(clientIP), registerBurst, registerRefill); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
hash, err := HashPassword(ctx, password)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
// 同一个账号只能补设一次,由存储保证。
|
||||
deleted, err := a.members.AddCredentials(ctx, memberID, username, hash, hashSecret(token))
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
a.gateway.RevokeSessions(ctx, deleted)
|
||||
return Account{Username: username, Email: email, GitHub: true}, nil
|
||||
}
|
||||
|
||||
// current 是发出请求的成员,currentToken 是它的成员 token。只有游客能登录:成功后签发账号的新会话,游客的这个会话随即失效。
|
||||
@@ -135,10 +170,10 @@ func (a *Accounts) Login(ctx context.Context, current member.Member, currentToke
|
||||
return Session{}, Account{}, "", err
|
||||
}
|
||||
a.gateway.RevokeSessions(ctx, [][]byte{guest})
|
||||
return Session{MemberToken: token, Member: account.Member}, Account{Username: account.Username, Email: account.Email}, device, nil
|
||||
return Session{MemberToken: token, Member: account.Member}, accountOf(account), device, nil
|
||||
}
|
||||
|
||||
// 未知、过期、属于别的账号,或者签发之后账号的密码变过(修改、重置)的凭证,都当作没带。
|
||||
// 未知、过期、属于别的账号,或者签发之后账号的密码变过(修改、重置、补设)的凭证,都当作没带。
|
||||
func (a *Accounts) knownDevice(ctx context.Context, device, username string) (bool, error) {
|
||||
if device == "" {
|
||||
return false, nil
|
||||
@@ -169,7 +204,8 @@ func passwordStamp(passwordHash string) []byte {
|
||||
|
||||
func (a *Accounts) verify(ctx context.Context, username, password string) (port.Account, error) {
|
||||
account, err := a.members.AccountByUsername(ctx, username)
|
||||
if errors.Is(err, port.ErrMemberNotFound) {
|
||||
// 空的密码哈希表示没有设置过密码,任何密码都不对;和用户名不存在一样做一次同样的计算。
|
||||
if errors.Is(err, port.ErrMemberNotFound) || (err == nil && account.PasswordHash == "") {
|
||||
dummy, err := dummyPasswordHash()
|
||||
if err != nil {
|
||||
return port.Account{}, err
|
||||
@@ -208,7 +244,10 @@ func (a *Accounts) ChangePassword(ctx context.Context, memberID, token, currentP
|
||||
return err
|
||||
}
|
||||
username := profile.Username
|
||||
if username == "" {
|
||||
switch {
|
||||
case profile.GitHub && username == "":
|
||||
return port.ErrNoPassword
|
||||
case username == "":
|
||||
return ErrNotRegistered
|
||||
}
|
||||
if err := a.take(ctx, "password:member:"+memberID, passwordBurst, passwordRefill); err != nil {
|
||||
|
||||
@@ -116,7 +116,7 @@ func tokenOwner(t *testing.T, store *porttest.Store, token string) (m member.Mem
|
||||
func registerAccount(t *testing.T, a *Accounts, s *Service, username, password string) Session {
|
||||
t.Helper()
|
||||
session := createMember(t, s)
|
||||
if _, err := a.Register(t.Context(), session.Member.ID, clientIP, username, password); err != nil {
|
||||
if _, err := a.Register(t.Context(), session.Member.ID, session.MemberToken, clientIP, username, password); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return session
|
||||
@@ -125,7 +125,7 @@ func registerAccount(t *testing.T, a *Accounts, s *Service, username, password s
|
||||
func TestRegister(t *testing.T) {
|
||||
a, s, store, q, _ := newAccounts(t)
|
||||
guest := createMember(t, s)
|
||||
account, err := a.Register(t.Context(), guest.Member.ID, clientIP, "alice", "correct horse")
|
||||
account, err := a.Register(t.Context(), guest.Member.ID, guest.MemberToken, clientIP, "alice", "correct horse")
|
||||
if err != nil || account != (Account{Username: "alice"}) {
|
||||
t.Fatalf("Register = %#v, %v", account, err)
|
||||
}
|
||||
@@ -188,16 +188,17 @@ func TestGuestHasNoAccount(t *testing.T) {
|
||||
func TestRegisterRejected(t *testing.T) {
|
||||
a, s, store, q, _ := newAccounts(t)
|
||||
alice := registerAccount(t, a, s, "alice", "correct horse")
|
||||
if _, err := a.Register(t.Context(), createMember(t, s).Member.ID, clientIP, "alice", "another horse"); !errors.Is(err, port.ErrUsernameTaken) {
|
||||
other := createMember(t, s)
|
||||
if _, err := a.Register(t.Context(), other.Member.ID, other.MemberToken, clientIP, "alice", "another horse"); !errors.Is(err, port.ErrUsernameTaken) {
|
||||
t.Errorf("用户名已被占用时 = %v,期望 ErrUsernameTaken", err)
|
||||
}
|
||||
if _, err := a.Register(t.Context(), alice.Member.ID, clientIP, "alice2", "another horse"); !errors.Is(err, port.ErrAlreadyRegistered) {
|
||||
if _, err := a.Register(t.Context(), alice.Member.ID, alice.MemberToken, clientIP, "alice2", "another horse"); !errors.Is(err, port.ErrAlreadyRegistered) {
|
||||
t.Errorf("已经注册过时 = %v,期望 ErrAlreadyRegistered", err)
|
||||
}
|
||||
|
||||
guest := createMember(t, s)
|
||||
q.exhausted["register:ip:"+clientIP] = true
|
||||
if _, err := a.Register(t.Context(), guest.Member.ID, clientIP, "bob", "correct horse"); !errors.Is(err, ErrTooManyAttempts) {
|
||||
if _, err := a.Register(t.Context(), guest.Member.ID, guest.MemberToken, clientIP, "bob", "correct horse"); !errors.Is(err, ErrTooManyAttempts) {
|
||||
t.Errorf("额度用完时 = %v,期望 ErrTooManyAttempts", err)
|
||||
}
|
||||
if _, ok, _ := a.Account(t.Context(), guest.Member.ID); ok {
|
||||
@@ -208,7 +209,7 @@ func TestRegisterRejected(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q.taken = nil
|
||||
if _, err := a.Register(t.Context(), guest.Member.ID, "198.51.100.1", "bob", "correct horse"); !errors.Is(err, ErrRegistrationClosed) {
|
||||
if _, err := a.Register(t.Context(), guest.Member.ID, guest.MemberToken, "198.51.100.1", "bob", "correct horse"); !errors.Is(err, ErrRegistrationClosed) {
|
||||
t.Errorf("关闭注册时 = %v,期望 ErrRegistrationClosed", err)
|
||||
}
|
||||
if len(q.taken) != 0 {
|
||||
@@ -612,7 +613,7 @@ func TestAccountQuotaCountsIPv6ByNetwork(t *testing.T) {
|
||||
}
|
||||
|
||||
q.taken = nil
|
||||
if _, err := a.Register(t.Context(), guest.Member.ID, "2001:db8:1:2:aaaa:bbbb:cccc:dddd", "alice", "correct horse"); err != nil {
|
||||
if _, err := a.Register(t.Context(), guest.Member.ID, guest.MemberToken, "2001:db8:1:2:aaaa:bbbb:cccc:dddd", "alice", "correct horse"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []string{"register:ip:2001:db8:1:2::/64"}; !slices.Equal(q.taken, want) {
|
||||
@@ -879,3 +880,72 @@ func TestLoginDoesPasswordWorkForUnknownUsername(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定 GitHub 的游客成为没有用户名和密码的账号。
|
||||
func githubAccount(t *testing.T, s *Service, store *porttest.Store, githubID int64) Session {
|
||||
t.Helper()
|
||||
session := createMember(t, s)
|
||||
if err := store.BindGitHub(t.Context(), session.Member.ID, githubID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
func TestGitHubOnlyAccount(t *testing.T) {
|
||||
a, s, store, _, _ := newAccounts(t)
|
||||
gh := githubAccount(t, s, store, 42)
|
||||
if got, ok, err := a.Account(t.Context(), gh.Member.ID); err != nil || !ok || got != (Account{GitHub: true}) {
|
||||
t.Errorf("只绑定了 GitHub 的账号 Account = %#v, %v, %v", got, ok, err)
|
||||
}
|
||||
if err := a.ChangePassword(t.Context(), gh.Member.ID, gh.MemberToken, "correct horse", "battery staple"); !errors.Is(err, port.ErrNoPassword) {
|
||||
t.Errorf("没有密码的账号修改密码 = %v,期望 ErrNoPassword", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 只绑定了 GitHub 的账号补设用户名和密码,不是注册新账号:注册关闭时也可以。
|
||||
func TestGitHubAccountSetsPasswordWhileRegistrationClosed(t *testing.T) {
|
||||
a, s, store, q, _ := newAccounts(t)
|
||||
gh := githubAccount(t, s, store, 42)
|
||||
if _, err := s.UpdateProfile(t.Context(), gh.Member.ID, Profile{Email: ptr("gh@example.com")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.SetRegistrationOpen(t.Context(), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
guest := createMember(t, s)
|
||||
if _, err := a.Register(t.Context(), guest.Member.ID, guest.MemberToken, clientIP, "bob", "correct horse"); !errors.Is(err, ErrRegistrationClosed) {
|
||||
t.Fatalf("关闭注册时游客注册 = %v,期望 ErrRegistrationClosed", err)
|
||||
}
|
||||
q.taken = nil
|
||||
account, err := a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "alice", "correct horse")
|
||||
if want := (Account{Username: "alice", Email: "gh@example.com", GitHub: true}); err != nil || account != want {
|
||||
t.Fatalf("Register = %#v, %v,期望 %#v", account, err, want)
|
||||
}
|
||||
if want := []string{"register:ip:" + clientIP}; !slices.Equal(q.taken, want) {
|
||||
t.Errorf("取的额度 = %v,期望 %v", q.taken, want)
|
||||
}
|
||||
if _, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", "correct horse", ""); err != nil {
|
||||
t.Errorf("设置密码之后用密码登录 = %v", err)
|
||||
}
|
||||
if _, err := a.Register(t.Context(), gh.Member.ID, gh.MemberToken, clientIP, "alice2", "correct horse"); !errors.Is(err, port.ErrAlreadyRegistered) {
|
||||
t.Errorf("再设置一次用户名 = %v,期望 ErrAlreadyRegistered", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 存储中的空密码哈希表示没有设置过密码,不是格式错误的哈希:按密码错误处理,不报内部错误。
|
||||
func TestEmptyPasswordHashIsWrongPassword(t *testing.T) {
|
||||
a, s, store, _, _ := newAccounts(t)
|
||||
alice := createMember(t, s)
|
||||
if err := store.Register(t.Context(), alice.Member.ID, "alice", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
guest := createMember(t, s)
|
||||
for _, password := range []string{"", "correct horse"} {
|
||||
if _, _, _, err := a.Login(t.Context(), guest.Member, guest.MemberToken, clientIP, "alice", password, ""); !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Errorf("密码哈希为空时用 %q 登录 = %v,期望 ErrInvalidCredentials", password, err)
|
||||
}
|
||||
}
|
||||
if err := a.ChangePassword(t.Context(), alice.Member.ID, alice.MemberToken, "correct horse", "battery staple"); !errors.Is(err, ErrWrongCurrentPassword) {
|
||||
t.Errorf("密码哈希为空时修改密码 = %v,期望 ErrWrongCurrentPassword", err)
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ func (s *Service) registered(ctx context.Context, memberID string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if profile.Username == "" {
|
||||
if !profile.Registered() {
|
||||
return ErrNotRegistered
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -172,6 +172,18 @@ func TestGuestCannotUsePlaylists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitHubAccountUsesPlaylists(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
gh := f.member(t, "")
|
||||
if err := f.store.BindGitHub(t.Context(), gh.ID, 42); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lists, err := f.svc.List(t.Context(), gh.ID)
|
||||
if err != nil || len(lists) != 1 || !lists[0].Favorites {
|
||||
t.Errorf("只绑定了 GitHub 的账号的歌单 = %#v, %v,期望注册时建好的“我喜欢”", lists, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidatesName(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
alice := f.member(t, "alice")
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package port
|
||||
|
||||
import "context"
|
||||
|
||||
// 后台填写的 GitHub OAuth App。
|
||||
type GitHubOAuthApp struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
}
|
||||
|
||||
// Client ID 和 Client Secret 一起保存、一起清除,读到的两者总是同一次保存的。
|
||||
type GitHubOAuthApps interface {
|
||||
// 没有配置时返回零值。
|
||||
GitHubOAuthApp(ctx context.Context) (GitHubOAuthApp, error)
|
||||
SetGitHubOAuthApp(ctx context.Context, app GitHubOAuthApp) error
|
||||
// 没有配置时什么也不做。
|
||||
DeleteGitHubOAuthApp(ctx context.Context) error
|
||||
}
|
||||
@@ -13,20 +13,30 @@ var (
|
||||
ErrAlreadyRegistered = errors.New("已经注册过了")
|
||||
ErrSessionNotFound = errors.New("会话不存在")
|
||||
ErrAccountPasswordChanged = errors.New("账号密码已修改")
|
||||
ErrGitHubTaken = errors.New("这个 GitHub 账号已经绑定了其他账号")
|
||||
ErrGitHubAlreadyBound = errors.New("已经绑定了 GitHub 账号,先解绑再绑定其他的")
|
||||
ErrNoPassword = errors.New("账号还没有设置密码,先设置用户名和密码")
|
||||
)
|
||||
|
||||
// 设置了用户名的成员。
|
||||
// 设置了用户名或绑定了 GitHub 的成员。用户名和密码总是一起设置,只绑定了 GitHub 的账号两者都为空。
|
||||
type Account struct {
|
||||
Member member.Member
|
||||
Username string
|
||||
Email string
|
||||
PasswordHash string
|
||||
// 没有绑定 GitHub 时为 0。
|
||||
GitHubID int64
|
||||
}
|
||||
|
||||
// 游客的两个字段都为空。
|
||||
// 游客的各个字段都为空。
|
||||
type AccountProfile struct {
|
||||
Username string
|
||||
Email string
|
||||
GitHub bool
|
||||
}
|
||||
|
||||
func (p AccountProfile) Registered() bool {
|
||||
return p.Username != "" || p.GitHub
|
||||
}
|
||||
|
||||
// 成员 token 只以 SHA-256 哈希的形式出现在端口中。一个成员 token 是成员的一个会话,同一个成员可以有多个会话。
|
||||
@@ -38,9 +48,22 @@ type Members interface {
|
||||
// name、avatarHash 或 email 为 nil 时保持原值,email 为空字符串表示清除。游客的 email 不保存。
|
||||
UpdateMember(ctx context.Context, id string, name, avatarHash, email *string) (member.Member, error)
|
||||
AccountProfile(ctx context.Context, memberID string) (AccountProfile, error)
|
||||
// 用户名已属于其他成员时返回 ErrUsernameTaken,成员已经注册过时返回 ErrAlreadyRegistered。同时为这个账号创建“我喜欢”。
|
||||
// 给游客设置用户名和密码,同时为这个账号创建“我喜欢”。用户名已属于其他成员时返回 ErrUsernameTaken,成员已经设置了用户名或绑定了
|
||||
// GitHub 时返回 ErrAlreadyRegistered。
|
||||
Register(ctx context.Context, memberID, username, passwordHash string) error
|
||||
// 给只绑定了 GitHub 的账号补设用户名和密码,并在同一个事务中删除这个成员除 keepTokenHash 以外的全部会话,返回被删除的会话的
|
||||
// token 哈希。用户名已属于其他成员时返回 ErrUsernameTaken,成员不是只绑定了 GitHub 的账号时返回 ErrAlreadyRegistered,两种情况
|
||||
// 都不做修改。
|
||||
AddCredentials(ctx context.Context, memberID, username, passwordHash string, keepTokenHash []byte) ([][]byte, error)
|
||||
AccountByUsername(ctx context.Context, username string) (Account, error)
|
||||
// 成员是游客时返回 ErrMemberNotFound。
|
||||
AccountByID(ctx context.Context, memberID string) (Account, error)
|
||||
AccountByGitHub(ctx context.Context, githubID int64) (Account, error)
|
||||
// 这个 GitHub 账号已经绑定了其他成员时返回 ErrGitHubTaken,成员已经绑定了 GitHub 时返回 ErrGitHubAlreadyBound,两种情况都不做修改。
|
||||
// 成员原来是游客时,绑定就是注册:同时为这个账号创建“我喜欢”。
|
||||
BindGitHub(ctx context.Context, memberID string, githubID int64) error
|
||||
// 账号没有设置密码时返回 ErrNoPassword:解绑之后它就再也无法登录。没有绑定 GitHub 时什么也不做。
|
||||
UnbindGitHub(ctx context.Context, memberID string) error
|
||||
// 在同一个事务中删除 oldTokenHash 的会话,给 memberID 创建 newTokenHash 的会话。oldTokenHash 的会话已经不存在时返回
|
||||
// ErrSessionNotFound;memberID 的密码哈希已经不是 passwordHash 时返回 ErrAccountPasswordChanged:密码在校验之后被修改过,
|
||||
// 修改时对会话的删除已经执行,此时创建的会话不会再被删除。两种情况都不做任何修改。
|
||||
|
||||
@@ -33,6 +33,7 @@ type Store struct {
|
||||
// 房间 ID 到 bot 成员 ID。
|
||||
bots map[string]string
|
||||
cookies map[string]string
|
||||
githubApp port.GitHubOAuthApp
|
||||
brandName string
|
||||
registrationClosed bool
|
||||
playlists []*storedPlaylist
|
||||
@@ -45,10 +46,25 @@ type storedMember struct {
|
||||
username string
|
||||
email string
|
||||
passwordHash string
|
||||
githubID int64
|
||||
// 零值表示记录播放历史,与 members.record_listens 的默认值一致。
|
||||
listensOff bool
|
||||
}
|
||||
|
||||
func (m *storedMember) registered() bool {
|
||||
return m.username != "" || m.githubID != 0
|
||||
}
|
||||
|
||||
func (m *storedMember) account() port.Account {
|
||||
return port.Account{Member: m.member, Username: m.username, Email: m.email, PasswordHash: m.passwordHash, GitHubID: m.githubID}
|
||||
}
|
||||
|
||||
// 调用方持有 s.mu。
|
||||
func (s *Store) createFavorites(memberID string) {
|
||||
now := time.Now()
|
||||
s.playlists = append(s.playlists, &storedPlaylist{id: s.newID(), owner: memberID, name: "我喜欢", favorites: true, created: now, updated: now})
|
||||
}
|
||||
|
||||
type storedTicket struct {
|
||||
roomID string
|
||||
memberID string
|
||||
@@ -62,6 +78,7 @@ var (
|
||||
_ port.ExistingTickets = (*Store)(nil)
|
||||
_ port.RoomDirectory = (*Store)(nil)
|
||||
_ port.Credentials = (*Store)(nil)
|
||||
_ port.GitHubOAuthApps = (*Store)(nil)
|
||||
_ port.APIKeys = (*Store)(nil)
|
||||
_ port.Site = (*Store)(nil)
|
||||
_ port.Playlists = (*Store)(nil)
|
||||
@@ -152,7 +169,7 @@ func (s *Store) UpdateMember(_ context.Context, id string, name, avatarHash, ema
|
||||
if avatarHash != nil {
|
||||
m.AvatarHash = *avatarHash
|
||||
}
|
||||
if email != nil && stored.username != "" {
|
||||
if email != nil && stored.registered() {
|
||||
stored.email = *email
|
||||
}
|
||||
return *m, nil
|
||||
@@ -167,7 +184,7 @@ func (s *Store) AccountProfile(_ context.Context, memberID string) (port.Account
|
||||
if m == nil {
|
||||
return port.AccountProfile{}, port.ErrMemberNotFound
|
||||
}
|
||||
return port.AccountProfile{Username: m.username, Email: m.email}, nil
|
||||
return port.AccountProfile{Username: m.username, Email: m.email, GitHub: m.githubID != 0}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Register(_ context.Context, memberID, username, passwordHash string) error {
|
||||
@@ -177,29 +194,95 @@ func (s *Store) Register(_ context.Context, memberID, username, passwordHash str
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
m := s.member(memberID)
|
||||
if m == nil || m.username != "" {
|
||||
if m == nil || m.registered() {
|
||||
return port.ErrAlreadyRegistered
|
||||
}
|
||||
if slices.ContainsFunc(s.members, func(other storedMember) bool { return other.username == username }) {
|
||||
return port.ErrUsernameTaken
|
||||
}
|
||||
s.createFavorites(memberID)
|
||||
m.username, m.passwordHash = username, passwordHash
|
||||
now := time.Now()
|
||||
s.playlists = append(s.playlists, &storedPlaylist{id: s.newID(), owner: memberID, name: "我喜欢", favorites: true, created: now, updated: now})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) AddCredentials(_ context.Context, memberID, username, passwordHash string, keepTokenHash []byte) ([][]byte, error) {
|
||||
if err := checkText(username, passwordHash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
m := s.member(memberID)
|
||||
if m == nil || m.username != "" || m.githubID == 0 {
|
||||
return nil, port.ErrAlreadyRegistered
|
||||
}
|
||||
if slices.ContainsFunc(s.members, func(other storedMember) bool { return other.username == username }) {
|
||||
return nil, port.ErrUsernameTaken
|
||||
}
|
||||
m.username, m.passwordHash = username, passwordHash
|
||||
return s.deleteSessions(memberID, keepTokenHash), nil
|
||||
}
|
||||
|
||||
func (s *Store) AccountByUsername(_ context.Context, username string) (port.Account, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, m := range s.members {
|
||||
if username != "" && m.username == username {
|
||||
return port.Account{Member: m.member, Username: m.username, Email: m.email, PasswordHash: m.passwordHash}, nil
|
||||
return m.account(), nil
|
||||
}
|
||||
}
|
||||
return port.Account{}, port.ErrMemberNotFound
|
||||
}
|
||||
|
||||
func (s *Store) AccountByID(_ context.Context, memberID string) (port.Account, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
m := s.member(memberID)
|
||||
if m == nil || !m.registered() {
|
||||
return port.Account{}, port.ErrMemberNotFound
|
||||
}
|
||||
return m.account(), nil
|
||||
}
|
||||
|
||||
func (s *Store) AccountByGitHub(_ context.Context, githubID int64) (port.Account, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, m := range s.members {
|
||||
if m.githubID == githubID {
|
||||
return m.account(), nil
|
||||
}
|
||||
}
|
||||
return port.Account{}, port.ErrMemberNotFound
|
||||
}
|
||||
|
||||
func (s *Store) BindGitHub(_ context.Context, memberID string, githubID int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
m := s.member(memberID)
|
||||
if m == nil || m.githubID != 0 {
|
||||
return port.ErrGitHubAlreadyBound
|
||||
}
|
||||
if slices.ContainsFunc(s.members, func(other storedMember) bool { return other.githubID == githubID }) {
|
||||
return port.ErrGitHubTaken
|
||||
}
|
||||
if !m.registered() {
|
||||
s.createFavorites(memberID)
|
||||
}
|
||||
m.githubID = githubID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) UnbindGitHub(_ context.Context, memberID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
m := s.member(memberID)
|
||||
// 密码总是和用户名一起设置:没有用户名,就是 PostgreSQL 中 password_hash 为 NULL。
|
||||
if m == nil || m.username == "" {
|
||||
return port.ErrNoPassword
|
||||
}
|
||||
m.githubID = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ReplaceSession(_ context.Context, oldTokenHash []byte, memberID string, newTokenHash []byte, passwordHash string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -507,6 +590,26 @@ func (s *Store) SetBilibiliCredential(_ context.Context, credential string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GitHubOAuthApp(context.Context) (port.GitHubOAuthApp, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.githubApp, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetGitHubOAuthApp(_ context.Context, app port.GitHubOAuthApp) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.githubApp = app
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteGitHubOAuthApp(context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.githubApp = port.GitHubOAuthApp{}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Settings(context.Context) (port.SiteSettings, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package porttest
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -20,6 +21,112 @@ func TestUpdateMemberRejectsTextPostgreSQLCannotStore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func favoritesCount(t *testing.T, store *Store, memberID string) int {
|
||||
t.Helper()
|
||||
lists, err := store.MemberPlaylists(t.Context(), memberID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := 0
|
||||
for _, l := range lists {
|
||||
if l.Favorites {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// 与 postgres 适配器的 TestBindGitHub、TestAddCredentialsAfterGitHub 相同的场景。
|
||||
func TestBindGitHub(t *testing.T) {
|
||||
store := New()
|
||||
alice, err := store.CreateMember(t.Context(), []byte("alice"), "甲")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bob, err := store.CreateMember(t.Context(), []byte("bob"), "乙")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.UnbindGitHub(t.Context(), alice.ID); !errors.Is(err, port.ErrNoPassword) {
|
||||
t.Errorf("游客解绑 = %v,期望 ErrNoPassword", err)
|
||||
}
|
||||
if err := store.BindGitHub(t.Context(), alice.ID, 42); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p, err := store.AccountProfile(t.Context(), alice.ID); err != nil || p != (port.AccountProfile{GitHub: true}) {
|
||||
t.Errorf("绑定之后的 AccountProfile = %#v, %v", p, err)
|
||||
}
|
||||
want := port.Account{Member: alice, GitHubID: 42}
|
||||
if got, err := store.AccountByGitHub(t.Context(), 42); err != nil || got != want {
|
||||
t.Errorf("AccountByGitHub = %#v, %v,期望 %#v", got, err, want)
|
||||
}
|
||||
if got, err := store.AccountByID(t.Context(), alice.ID); err != nil || got != want {
|
||||
t.Errorf("AccountByID = %#v, %v,期望 %#v", got, err, want)
|
||||
}
|
||||
if _, err := store.AccountByID(t.Context(), bob.ID); !errors.Is(err, port.ErrMemberNotFound) {
|
||||
t.Errorf("游客的 AccountByID = %v,期望 ErrMemberNotFound", err)
|
||||
}
|
||||
if err := store.BindGitHub(t.Context(), bob.ID, 42); !errors.Is(err, port.ErrGitHubTaken) {
|
||||
t.Errorf("GitHub 账号已经绑定了别人 = %v,期望 ErrGitHubTaken", err)
|
||||
}
|
||||
if err := store.BindGitHub(t.Context(), alice.ID, 43); !errors.Is(err, port.ErrGitHubAlreadyBound) {
|
||||
t.Errorf("已经绑定过的成员再绑定 = %v,期望 ErrGitHubAlreadyBound", err)
|
||||
}
|
||||
email := "alice@example.com"
|
||||
if _, err := store.UpdateMember(t.Context(), alice.ID, nil, nil, &email); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p, _ := store.AccountProfile(t.Context(), alice.ID); p.Email != email {
|
||||
t.Errorf("只绑定了 GitHub 的账号保存邮箱之后 = %q", p.Email)
|
||||
}
|
||||
if err := store.UnbindGitHub(t.Context(), alice.ID); !errors.Is(err, port.ErrNoPassword) {
|
||||
t.Errorf("没有密码的账号解绑 = %v,期望 ErrNoPassword", err)
|
||||
}
|
||||
if err := store.Register(t.Context(), alice.ID, "alice", "hash-a"); !errors.Is(err, port.ErrAlreadyRegistered) {
|
||||
t.Errorf("只绑定了 GitHub 的账号用 Register 设置用户名 = %v,期望 ErrAlreadyRegistered", err)
|
||||
}
|
||||
if _, err := store.AddCredentials(t.Context(), bob.ID, "bob", "hash-b", []byte("bob")); !errors.Is(err, port.ErrAlreadyRegistered) {
|
||||
t.Errorf("游客用 AddCredentials 设置用户名 = %v,期望 ErrAlreadyRegistered", err)
|
||||
}
|
||||
if _, err := store.CreateMember(t.Context(), []byte("guest"), "丙"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.ReplaceSession(t.Context(), []byte("guest"), alice.ID, []byte("alice-2"), ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deleted, err := store.AddCredentials(t.Context(), alice.ID, "alice", "hash-a", []byte("alice"))
|
||||
if err != nil || len(deleted) != 1 || string(deleted[0]) != "alice-2" {
|
||||
t.Fatalf("AddCredentials = %q, %v,期望删除 alice-2", deleted, err)
|
||||
}
|
||||
if m, err := store.MemberByToken(t.Context(), []byte("alice")); err != nil || m.ID != alice.ID {
|
||||
t.Errorf("发出请求的会话 = %#v, %v,期望保留", m, err)
|
||||
}
|
||||
if _, err := store.MemberByToken(t.Context(), []byte("alice-2")); !errors.Is(err, port.ErrMemberNotFound) {
|
||||
t.Errorf("其他会话 = %v,期望已删除", err)
|
||||
}
|
||||
if n := favoritesCount(t, store, alice.ID); n != 1 {
|
||||
t.Errorf("绑定 GitHub 再补设用户名之后有 %d 个“我喜欢”,期望 1 个", n)
|
||||
}
|
||||
if _, err := store.AddCredentials(t.Context(), alice.ID, "alice2", "hash-c", []byte("alice")); !errors.Is(err, port.ErrAlreadyRegistered) {
|
||||
t.Errorf("再补设一次 = %v,期望 ErrAlreadyRegistered", err)
|
||||
}
|
||||
if err := store.UnbindGitHub(t.Context(), alice.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p, _ := store.AccountProfile(t.Context(), alice.ID); p != (port.AccountProfile{Username: "alice", Email: email}) {
|
||||
t.Errorf("解绑之后的 AccountProfile = %#v", p)
|
||||
}
|
||||
if err := store.Register(t.Context(), bob.ID, "bob", "hash-b"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.BindGitHub(t.Context(), bob.ID, 42); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := favoritesCount(t, store, bob.ID); n != 1 {
|
||||
t.Errorf("已注册的账号绑定 GitHub 之后有 %d 个“我喜欢”,期望 1 个", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddPlaylistItemKeepsMilliseconds(t *testing.T) {
|
||||
store := New()
|
||||
m, err := store.CreateMember(t.Context(), []byte("token"), "甲")
|
||||
|
||||
Reference in new issue
Block a user