package identity_test import ( "context" "database/sql" "errors" "fmt" "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 TestU03TalkPairLockIgnoresIPAndEmptyPassword(t *testing.T) { t.Parallel() app, db, locks := 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) } for i := 0; i < 9; i++ { err := app.UnlockTalk(ctx, "alice", "bob", "wrong", fmt.Sprintf("10.0.0.%d", i+1)) if protoCode(err) != protocol.CodeTalkPasswordInvalid { t.Fatalf("fail %d: %v", i, err) } } if err := app.UnlockTalk(ctx, "alice", "bob", "secret", "9.9.9.9"); err != nil { t.Fatalf("9th+correct from other IP should succeed: %v", err) } for i := 0; i < 5; i++ { if err := app.UnlockTalk(ctx, "alice", "bob", "wrong", fmt.Sprintf("1.1.1.%d", i+1)); protoCode(err) != protocol.CodeTalkPasswordInvalid { t.Fatalf("unlock fail %d: %v", i, err) } } for i := 0; i < 5; i++ { if err := app.CheckTalkPasswordForJoin(ctx, "alice", "bob", "wrong", fmt.Sprintf("2.2.2.%d", i+1)); protoCode(err) != protocol.CodeTalkPasswordInvalid { t.Fatalf("join fail %d: %v", i, err) } } pair := auth.LockKey{Kind: auth.LockTalkPair, EndpointID: "alice", PeerID: "bob"} if locked, _ := locks.Check(pair); !locked { t.Fatal("pair should lock after 10 wrong attempts across IPs and paths") } if err := app.UnlockTalk(ctx, "alice", "bob", "secret", "8.8.8.8"); protoCode(err) != protocol.CodeRateLimited { t.Fatalf("want rate_limited unlock got %v", err) } if err := app.CheckTalkPasswordForJoin(ctx, "alice", "bob", "secret", "7.7.7.7"); protoCode(err) != protocol.CodeRateLimited { t.Fatalf("want rate_limited join got %v", err) } if err := app.UnlockTalk(ctx, "alice", "bob", "", "6.6.6.6"); protoCode(err) != protocol.CodeTalkPasswordRequired { t.Fatalf("empty while locked want required got %v", err) } } func TestU03AdminChangeAndDeleteClearTalkLocks(t *testing.T) { t.Parallel() app, db, locks := 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) } for i := 0; i < 50; i++ { id := fmt.Sprintf("u%02d", i) insertEP(t, db, id, "password1") _ = app.UnlockTalk(ctx, id, "bob", "wrong", "2.2.2.2") } if locked, _ := locks.Check(auth.LockKey{Kind: auth.LockTalkTarget, EndpointID: "bob"}); !locked { t.Fatal("expected talk target lock") } if err := app.SelfSetTalkPassword(ctx, "bob", "secret2"); err != nil { t.Fatal(err) } if locked, _ := locks.Check(auth.LockKey{Kind: auth.LockTalkTarget, EndpointID: "bob"}); locked { t.Fatal("admin/self change should clear LockTalkTarget") } if err := app.UnlockTalk(ctx, "alice", "bob", "secret2", "3.3.3.3"); err != nil { t.Fatalf("unlock after change: %v", err) } for i := 0; i < 10; i++ { _ = app.UnlockTalk(ctx, "alice", "bob", "wrong", fmt.Sprintf("4.4.4.%d", i+1)) } locks.Fail(auth.LockKey{Kind: auth.LockLoginEndpoint, EndpointID: "bob"}) locks.Fail(auth.LockKey{Kind: auth.LockTalkPair, EndpointID: "bob", PeerID: "alice"}) if err := app.Delete(ctx, "bob"); err != nil { t.Fatal(err) } if locked, _ := locks.Check(auth.LockKey{Kind: auth.LockTalkPair, EndpointID: "alice", PeerID: "bob"}); locked { t.Fatal("delete should clear pair where bob is peer") } if locked, _ := locks.Check(auth.LockKey{Kind: auth.LockTalkPair, EndpointID: "bob", PeerID: "alice"}); locked { t.Fatal("delete should clear pair where bob is sender") } if locked, _ := locks.Check(auth.LockKey{Kind: auth.LockTalkTarget, EndpointID: "bob"}); locked { t.Fatal("delete should clear LockTalkTarget") } if locked, _ := locks.Check(auth.LockKey{Kind: auth.LockLoginEndpoint, EndpointID: "bob"}); locked { t.Fatal("delete should clear login lock") } 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", "5.5.5.5"); err != nil { t.Fatalf("reopened bob must not inherit talk lock: %v", err) } } 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 }