Files
NixMsg/internal/app/identity/self_talk_test.go
T

315 lines
10 KiB
Go

package identity_test
import (
"context"
"database/sql"
"errors"
"path/filepath"
"testing"
"time"
"git.asio.asia/nixevol/NixMsg/internal/app/identity"
"git.asio.asia/nixevol/NixMsg/internal/app/message"
"git.asio.asia/nixevol/NixMsg/internal/app/port"
"git.asio.asia/nixevol/NixMsg/internal/auth"
"git.asio.asia/nixevol/NixMsg/internal/config"
"git.asio.asia/nixevol/NixMsg/internal/protocol"
"git.asio.asia/nixevol/NixMsg/internal/store"
)
func openIdentity(t *testing.T) (*identity.App, *store.DB, *auth.MemoryLocks) {
t.Helper()
dir := t.TempDir()
db, err := store.Open(filepath.Join(dir, "data"), "FULL")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
fixed := time.UnixMilli(1_700_000_000_000)
locks := auth.NewLoginLocks()
locks.SetClock(func() time.Time { return fixed })
app := identity.New(identity.Config{
DB: db,
Hash: auth.NewStubHashPool(),
Locks: locks,
Sessions: auth.NewSessionTokens(),
MaxScheduleSeconds: int64(config.Default().Limits.MaxScheduleSeconds),
Now: func() time.Time { return fixed },
})
return app, db, locks
}
func insertEP(t *testing.T, db *store.DB, id, loginPW string) {
t.Helper()
err := db.Queue.Do(context.Background(), func(tx *sql.Tx) error {
_, e := tx.Exec(`
INSERT INTO endpoints(id, name, login_hash, talk_hash, talk_version, default_delay_ms, enabled, created_at)
VALUES(?,?,?,?,0,0,1,?)`, id, id, "stub$"+loginPW, nil, 1_700_000_000_000)
return e
})
if err != nil {
t.Fatal(err)
}
}
func protoCode(err error) string {
var pe *protocol.Error
if errors.As(err, &pe) {
return pe.Code
}
return ""
}
func TestF15TalkPasswordAuth(t *testing.T) {
t.Parallel()
app, db, _ := openIdentity(t)
ctx := context.Background()
insertEP(t, db, "alice", "password1")
insertEP(t, db, "bob", "password1")
if err := app.SelfSetTalkPassword(ctx, "bob", "secret"); err != nil {
t.Fatal(err)
}
// A 不带密码失败
if err := app.UnlockTalk(ctx, "alice", "bob", "", "1.1.1.1"); protoCode(err) != protocol.CodeTalkPasswordRequired {
t.Fatalf("want talk_password_required got %v", err)
}
ok, err := app.HasTalkGrant(ctx, "alice", "bob")
if err != nil || ok {
t.Fatalf("grant=%v err=%v", ok, err)
}
// 带对后成功,之后无密码也有授权
if err = app.UnlockTalk(ctx, "alice", "bob", "secret", "1.1.1.1"); err != nil {
t.Fatal(err)
}
ok, err = app.HasTalkGrant(ctx, "alice", "bob")
if err != nil || !ok {
t.Fatalf("grant=%v err=%v", ok, err)
}
// B 改密后旧授权失效
if err = app.SelfSetTalkPassword(ctx, "bob", "newsecret"); err != nil {
t.Fatal(err)
}
ok, err = app.HasTalkGrant(ctx, "alice", "bob")
if err != nil || ok {
t.Fatalf("after change grant=%v err=%v", ok, err)
}
if err := app.UnlockTalk(ctx, "alice", "bob", "secret", "1.1.1.1"); protoCode(err) != protocol.CodeTalkPasswordInvalid {
t.Fatalf("want invalid got %v", err)
}
if err := app.UnlockTalk(ctx, "alice", "bob", "newsecret", "1.1.1.1"); err != nil {
t.Fatal(err)
}
}
func TestF15ReplyGrantAndChange(t *testing.T) {
t.Parallel()
app, db, _ := openIdentity(t)
ctx := context.Background()
insertEP(t, db, "alice", "password1")
insertEP(t, db, "bob", "password1")
if err := app.SelfSetTalkPassword(ctx, "alice", "alice-pw"); err != nil {
t.Fatal(err)
}
// B 先给 A 发 → 写入 reply 授权(A 可回 B 免密;这里记的是 bob→alice 的授权给「alice 作为接收方」...
// 回复授权:对方曾成功提交发给我的单聊 → 我对对方有 reply 权。
// 即 B 发给 A 后,A 对 B 有授权(sender=alice, target=bob)。
if err := app.RecordReplyGrant(ctx, "alice", "bob"); err != nil {
t.Fatal(err)
}
// 但 bob 还没设密码,alice→bob 本就不需要。给 bob 设密后验证 reply:
if err := app.SelfSetTalkPassword(ctx, "bob", "bob-pw"); err != nil {
t.Fatal(err)
}
// 重新记 reply:B 发给 A 成功后 A 获得对 B 的回复权
if err := app.RecordReplyGrant(ctx, "alice", "bob"); err != nil {
t.Fatal(err)
}
ok, err := app.HasTalkGrant(ctx, "alice", "bob")
if err != nil || !ok {
t.Fatalf("reply grant=%v err=%v", ok, err)
}
if err = app.SelfSetTalkPassword(ctx, "bob", "bob-pw2"); err != nil {
t.Fatal(err)
}
ok, err = app.HasTalkGrant(ctx, "alice", "bob")
if err != nil || ok {
t.Fatalf("after change reply should die grant=%v", ok)
}
}
func TestF15JoinNeedsPasswordDespiteGrant(t *testing.T) {
t.Parallel()
app, db, _ := openIdentity(t)
ctx := context.Background()
insertEP(t, db, "alice", "password1")
insertEP(t, db, "bob", "password1")
if err := app.SelfSetTalkPassword(ctx, "bob", "secret"); err != nil {
t.Fatal(err)
}
if err := app.UnlockTalk(ctx, "alice", "bob", "secret", "1.1.1.1"); err != nil {
t.Fatal(err)
}
// 已有单聊授权,进群仍要密码
if err := app.CheckTalkPasswordForJoin(ctx, "alice", "bob", "", "1.1.1.1"); protoCode(err) != protocol.CodeTalkPasswordRequired {
t.Fatalf("want required got %v", err)
}
if err := app.CheckTalkPasswordForJoin(ctx, "alice", "bob", "wrong", "1.1.1.1"); protoCode(err) != protocol.CodeTalkPasswordInvalid {
t.Fatalf("want invalid got %v", err)
}
if err := app.CheckTalkPasswordForJoin(ctx, "alice", "bob", "secret", "1.1.1.1"); err != nil {
t.Fatal(err)
}
}
func TestF15SubmittedMessageUnaffectedByPasswordChange(t *testing.T) {
t.Parallel()
idApp, db, locks := openIdentity(t)
ctx := context.Background()
insertEP(t, db, "alice", "password1")
insertEP(t, db, "bob", "password1")
if err := idApp.SelfSetTalkPassword(ctx, "bob", "secret"); err != nil {
t.Fatal(err)
}
lim := message.LimitsFromConfig(config.Default().Limits)
lim.RequestsPerSecond = 0
msgApp := message.New(db, lim, auth.NewStubHashPool(),
message.WithNow(func() time.Time { return time.UnixMilli(1_700_000_000_000) }),
message.WithLocks(locks),
)
req := &protocol.Send{
V: protocol.Version, Type: protocol.TypeSend, RID: "r1", ID: "m1",
To: protocol.Target{Kind: protocol.TargetEndpoint, ID: "bob"},
Body: protocol.Body{Enc: protocol.EncUTF8, Data: "hi"},
DelayMs: ptrInt64(60_000),
TalkPassword: "secret",
}
res, err := msgApp.Submit(ctx, "alice", port.ConnInfo{RemoteIP: "1.1.1.1"}, req)
if err != nil {
t.Fatal(err)
}
if res.State != message.StateScheduled {
t.Fatalf("state=%s", res.State)
}
if err := idApp.SelfSetTalkPassword(ctx, "bob", "changed"); err != nil {
t.Fatal(err)
}
// 已提交消息行仍在且状态不变
var state string
if err := db.Read.QueryRow(`SELECT state FROM messages WHERE sender_id=? AND id=?`, "alice", "m1").Scan(&state); err != nil {
t.Fatal(err)
}
if state != message.StateScheduled {
t.Fatalf("message state changed to %s", state)
}
}
func TestF15TargetLockAndExistingGrant(t *testing.T) {
t.Parallel()
app, db, locks := openIdentity(t)
ctx := context.Background()
insertEP(t, db, "bob", "password1")
insertEP(t, db, "authd", "password1")
if err := app.SelfSetTalkPassword(ctx, "bob", "secret"); err != nil {
t.Fatal(err)
}
if err := app.UnlockTalk(ctx, "authd", "bob", "secret", "9.9.9.9"); err != nil {
t.Fatal(err)
}
// 50 次错误触发对方总数锁
for i := 0; i < 50; i++ {
id := "u" + string(rune('0'+i/100)) + string(rune('0'+(i/10)%10)) + string(rune('0'+i%10))
insertEP(t, db, id, "password1")
_ = app.UnlockTalk(ctx, id, "bob", "wrong", "2.2.2.2")
}
locked, _ := locks.Check(auth.LockKey{Kind: auth.LockTalkTarget, EndpointID: "bob"})
if !locked {
t.Fatal("expected talk target lock")
}
// 正确密码也暂时无法解锁
insertEP(t, db, "newbie", "password1")
if err := app.UnlockTalk(ctx, "newbie", "bob", "secret", "3.3.3.3"); protoCode(err) != protocol.CodeRateLimited {
t.Fatalf("want rate_limited got %v", err)
}
// 已有授权仍可用
ok, err := app.HasTalkGrant(ctx, "authd", "bob")
if err != nil || !ok {
t.Fatalf("authd grant=%v err=%v", ok, err)
}
// 改密清零
if err := app.SelfSetTalkPassword(ctx, "bob", "secret2"); err != nil {
t.Fatal(err)
}
locked, _ = locks.Check(auth.LockKey{Kind: auth.LockTalkTarget, EndpointID: "bob"})
if locked {
t.Fatal("lock should clear on password change")
}
}
func TestSelfLoginPasswordAndLogout(t *testing.T) {
t.Parallel()
app, db, locks := openIdentity(t)
ctx := context.Background()
insertEP(t, db, "alice", "oldpass12")
tok, err := app.SelfChangeLoginPassword(ctx, "alice", "wrongpass", "newpass12", "1.1.1.1")
if protoCode(err) != protocol.CodeUnauthorized || tok != "" {
t.Fatalf("got tok=%q err=%v", tok, err)
}
locks.Fail(auth.LockKey{Kind: auth.LockLoginEndpointIP, EndpointID: "alice", IP: "1.1.1.1"}) // 确保 Fail 路径可调用
tok, err = app.SelfChangeLoginPassword(ctx, "alice", "oldpass12", "newpass12", "1.1.1.1")
if err != nil || tok == "" || !protocol.ValidEndpointID("alice") {
t.Fatalf("tok=%q err=%v", tok, err)
}
if !auth.NewSessionTokens().LooksLikeSessionToken(tok) {
t.Fatalf("token prefix %q", tok)
}
var hash sql.NullString
if err = db.Read.QueryRow(`SELECT session_hash FROM endpoints WHERE id=?`, "alice").Scan(&hash); err != nil || !hash.Valid {
t.Fatal(err)
}
info, err := app.SelfGet(ctx, "alice")
if err != nil || info.ID != "alice" {
t.Fatal(err)
}
name := "门口"
delay := int64(10000)
if err := app.SelfUpdate(ctx, "alice", &protocol.SelfUpdate{
V: protocol.Version, Type: protocol.TypeSelfUpdate, RID: "1", Name: name, DefaultDelayMs: &delay,
}); err != nil {
t.Fatal(err)
}
info, _ = app.SelfGet(ctx, "alice")
if info.Name != name || info.DefaultDelayMs != delay {
t.Fatalf("%+v", info)
}
if err := app.SelfLogout(ctx, "alice"); err != nil {
t.Fatal(err)
}
if err := db.Read.QueryRow(`SELECT session_hash FROM endpoints WHERE id=?`, "alice").Scan(&hash); err != nil {
t.Fatal(err)
}
if hash.Valid {
t.Fatal("session should be cleared")
}
}
func TestUnlockSelfAndNoPassword(t *testing.T) {
t.Parallel()
app, db, _ := openIdentity(t)
ctx := context.Background()
insertEP(t, db, "alice", "password1")
insertEP(t, db, "bob", "password1")
if err := app.UnlockTalk(ctx, "alice", "alice", "", ""); err != nil {
t.Fatal(err)
}
if err := app.UnlockTalk(ctx, "alice", "bob", "anything", ""); err != nil {
t.Fatal(err)
}
}
func ptrInt64(v int64) *int64 { return &v }