Files
root 44406b72db Initial commit: img-infinite-canvas AI design workbench MVP
Moteva-style AI design workbench replica. Home prompt creates a project;
the project page provides an infinite canvas with node drag, zoom/pan,
a design chat panel, history replay, image asset upload, and project
save/regenerate.

- Backend: go-zero, DDD layering, sqlc/pgx, optional PostgreSQL; memory
  or Redis cache; asynq job queue; MinIO/S3/R2/OSS object storage;
  sky-valley/pi agent runtime adapter.
- Frontend: Next.js App Router + Vite artifact build, TypeScript, i18n,
  shadcn/ui components, auth (OTP/Turnstile/Google/WeChat).
- Deploy: Docker Compose and k3s manifests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 23:15:37 +08:00

363 lines
11 KiB
Go

package auth
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
)
type fakeEmailSender struct {
messages []EmailMessage
}
func (s *fakeEmailSender) SendLoginCode(_ context.Context, message EmailMessage) error {
s.messages = append(s.messages, message)
return nil
}
type fakeHumanVerifier struct {
tokens []string
err error
}
func (v *fakeHumanVerifier) Verify(_ context.Context, token string) error {
v.tokens = append(v.tokens, token)
return v.err
}
func TestChinaSMSLoginCreatesUUIDUserAndBearerToken(t *testing.T) {
ctx := context.Background()
service := NewService(NewMemoryStore(), Config{
Region: RegionChina,
TokenSecret: "test-secret",
TokenTTL: time.Hour,
CodeTTL: time.Minute,
DevReturnCode: true,
})
code, err := service.RequestChinaSMSCode(ctx, "13800138000", "+86", "")
if err != nil {
t.Fatal(err)
}
session, err := service.LoginChinaSMSCode(ctx, "13800138000", "+86", code.DevCode)
if err != nil {
t.Fatal(err)
}
if _, err := uuid.Parse(session.User.ID); err != nil {
t.Fatalf("expected uuid user id, got %q", session.User.ID)
}
if session.User.Region != RegionChina {
t.Fatalf("expected china user, got %#v", session.User)
}
userID, ok := service.UserIDFromBearer(ctx, "Bearer "+session.AccessToken)
if !ok || userID != session.User.ID {
t.Fatalf("expected bearer token to resolve %s, got %s / %v", session.User.ID, userID, ok)
}
}
func TestManualRegionRejectsOtherRegionLogin(t *testing.T) {
ctx := context.Background()
service := NewService(NewMemoryStore(), Config{
Region: RegionGlobal,
TokenSecret: "test-secret",
CodeTTL: time.Minute,
DevReturnCode: true,
})
_, err := service.RequestChinaSMSCode(ctx, "13800138000", "+86", "")
if !errors.Is(err, ErrInvalidRegion) {
t.Fatalf("expected invalid region, got %v", err)
}
}
func TestGlobalEmailCodeLogin(t *testing.T) {
ctx := context.Background()
service := NewService(NewMemoryStore(), Config{
Region: RegionGlobal,
TokenSecret: "test-secret",
CodeTTL: time.Minute,
DevReturnCode: true,
})
code, err := service.RequestGlobalEmailCode(ctx, "USER@example.com", "")
if err != nil {
t.Fatal(err)
}
session, err := service.LoginGlobalEmailCode(ctx, "user@example.com", code.DevCode)
if err != nil {
t.Fatal(err)
}
if session.User.Email != "user@example.com" || session.User.Region != RegionGlobal {
t.Fatalf("unexpected session user %#v", session.User)
}
}
func TestAccountProfileUpdatesPersistInStore(t *testing.T) {
ctx := context.Background()
service := NewService(NewMemoryStore(), Config{
Region: RegionGlobal,
TokenSecret: "test-secret",
CodeTTL: time.Minute,
DevReturnCode: true,
})
code, err := service.RequestGlobalEmailCode(ctx, "user@example.com", "")
if err != nil {
t.Fatal(err)
}
session, err := service.LoginGlobalEmailCode(ctx, "user@example.com", code.DevCode)
if err != nil {
t.Fatal(err)
}
name := "Liang Xu"
avatarURL := "https://cdn.example.com/avatar.webp"
user, err := service.UpdateProfile(ctx, session.User.ID, ProfilePatch{Name: &name, AvatarURL: &avatarURL})
if err != nil {
t.Fatal(err)
}
if user.Name != name || user.AvatarURL != avatarURL {
t.Fatalf("unexpected profile %#v", user)
}
profile, err := service.GetProfile(ctx, session.User.ID)
if err != nil {
t.Fatal(err)
}
if profile.Name != name || profile.AvatarURL != avatarURL {
t.Fatalf("profile was not persisted %#v", profile)
}
}
func TestAccountDevicesExposeCurrentLocalDeviceOnly(t *testing.T) {
ctx := context.Background()
service := NewService(NewMemoryStore(), Config{
Region: RegionGlobal,
TokenSecret: "test-secret",
CodeTTL: time.Minute,
DevReturnCode: true,
})
code, err := service.RequestGlobalEmailCode(ctx, "user@example.com", "")
if err != nil {
t.Fatal(err)
}
session, err := service.LoginGlobalEmailCode(ctx, "user@example.com", code.DevCode)
if err != nil {
t.Fatal(err)
}
devices, err := service.ListDevices(ctx, session.User.ID)
if err != nil {
t.Fatal(err)
}
if len(devices) != 1 || !devices[0].Current {
t.Fatalf("expected one current device, got %#v", devices)
}
removed, err := service.RemoveOtherDevices(ctx, session.User.ID)
if err != nil {
t.Fatal(err)
}
if removed != 0 {
t.Fatalf("expected no removable devices, got %d", removed)
}
}
func TestGlobalEmailCodeSendsConfiguredEmail(t *testing.T) {
ctx := context.Background()
sender := &fakeEmailSender{}
service := NewServiceWithCodeStoreAndEmail(NewMemoryStore(), NewMemoryStore(), sender, Config{
Region: RegionGlobal,
TokenSecret: "test-secret",
CodeTTL: 10 * time.Minute,
DevReturnCode: true,
EmailFromName: "Moteva Login",
EmailFromEmail: "hello@example.com",
BrandName: "Moteva",
})
code, err := service.RequestGlobalEmailCode(ctx, "user@example.com", "")
if err != nil {
t.Fatal(err)
}
if code.ExpiresIn != 600 {
t.Fatalf("expected 10 minute ttl, got %d", code.ExpiresIn)
}
if len(sender.messages) != 1 {
t.Fatalf("expected one email, got %d", len(sender.messages))
}
message := sender.messages[0]
if message.FromEmail != "hello@example.com" || message.ToEmail != "user@example.com" || message.Code != code.DevCode {
t.Fatalf("unexpected email message %#v", message)
}
if code.Email == nil || code.Email.Subject != "Welcome to Moteva!" {
t.Fatalf("expected dev email preview, got %#v", code.Email)
}
}
func TestGlobalEmailCodeRequiresSenderOutsideDevMode(t *testing.T) {
ctx := context.Background()
service := NewService(NewMemoryStore(), Config{
Region: RegionGlobal,
TokenSecret: "test-secret",
CodeTTL: 10 * time.Minute,
DevReturnCode: false,
})
_, err := service.RequestGlobalEmailCode(ctx, "user@example.com", "")
if !errors.Is(err, ErrProviderNotReady) {
t.Fatalf("expected provider not ready, got %v", err)
}
}
func TestGlobalEmailCodeRequiresHumanVerificationBeforeEmail(t *testing.T) {
ctx := context.Background()
sender := &fakeEmailSender{}
verifier := &fakeHumanVerifier{}
service := NewServiceWithCodeStoreEmailAndHumanVerifier(NewMemoryStore(), NewMemoryStore(), sender, verifier, Config{
Region: RegionGlobal,
TokenSecret: "test-secret",
CodeTTL: 10 * time.Minute,
DevReturnCode: true,
TurnstileRequired: true,
})
_, err := service.RequestGlobalEmailCodeWithVerification(ctx, "user@example.com", "", "")
if !errors.Is(err, ErrHumanVerificationRequired) {
t.Fatalf("expected human verification required, got %v", err)
}
if len(sender.messages) != 0 {
t.Fatalf("expected no email before human verification, got %d", len(sender.messages))
}
code, err := service.RequestGlobalEmailCodeWithVerification(ctx, "user@example.com", "", "turnstile-token")
if err != nil {
t.Fatal(err)
}
if code.DevCode == "" || len(sender.messages) != 1 {
t.Fatalf("expected verified email code, got code=%q messages=%d", code.DevCode, len(sender.messages))
}
if len(verifier.tokens) != 1 || verifier.tokens[0] != "turnstile-token" {
t.Fatalf("expected verifier token, got %#v", verifier.tokens)
}
}
func TestGlobalEmailCodeStopsWhenHumanVerificationFails(t *testing.T) {
ctx := context.Background()
sender := &fakeEmailSender{}
verifier := &fakeHumanVerifier{err: ErrHumanVerificationInvalid}
service := NewServiceWithCodeStoreEmailAndHumanVerifier(NewMemoryStore(), NewMemoryStore(), sender, verifier, Config{
Region: RegionGlobal,
TokenSecret: "test-secret",
CodeTTL: 10 * time.Minute,
DevReturnCode: true,
TurnstileRequired: true,
})
_, err := service.RequestGlobalEmailCodeWithVerification(ctx, "user@example.com", "", "bad-token")
if !errors.Is(err, ErrHumanVerificationInvalid) {
t.Fatalf("expected human verification invalid, got %v", err)
}
if len(sender.messages) != 0 {
t.Fatalf("expected no email after failed human verification, got %d", len(sender.messages))
}
}
func TestChinaWechatLoginWithBoundPhoneAuthenticatesDirectly(t *testing.T) {
ctx := context.Background()
store := NewMemoryStore()
identity := WechatIdentity{OpenID: "wechat-open-1", UnionID: "wechat-union-1"}
if _, err := store.UpsertUser(ctx, User{
ID: uuid.NewString(),
Region: RegionChina,
Phone: "13800138000",
CountryCode: "+86",
WechatOpenID: identity.OpenID,
WechatUnionID: identity.UnionID,
Status: "active",
}); err != nil {
t.Fatal(err)
}
service := NewService(store, Config{
Region: RegionChina,
TokenSecret: "test-secret",
TokenTTL: time.Hour,
CodeTTL: time.Minute,
})
service.wechatExchanger = func(context.Context, string) (WechatIdentity, error) {
return identity, nil
}
state, _, err := signState(service.cfg.TokenSecret, service.cfg.CodeTTL, service.now())
if err != nil {
t.Fatal(err)
}
session, err := service.LoginChinaWechat(ctx, "qr-code", state, "", "", "", "")
if err != nil {
t.Fatal(err)
}
if session.Status != SessionStatusAuthenticated || session.AccessToken == "" {
t.Fatalf("expected authenticated session, got %#v", session)
}
if session.User.Phone != "13800138000" || session.User.WechatOpenID != identity.OpenID {
t.Fatalf("unexpected session user %#v", session.User)
}
}
func TestChinaWechatFirstLoginRequiresPhoneBindingThenFutureLoginIsDirect(t *testing.T) {
ctx := context.Background()
identity := WechatIdentity{OpenID: "wechat-open-2", UnionID: "wechat-union-2"}
service := NewService(NewMemoryStore(), Config{
Region: RegionChina,
TokenSecret: "test-secret",
TokenTTL: time.Hour,
CodeTTL: time.Minute,
DevReturnCode: true,
})
service.wechatExchanger = func(context.Context, string) (WechatIdentity, error) {
return identity, nil
}
state, _, err := signState(service.cfg.TokenSecret, service.cfg.CodeTTL, service.now())
if err != nil {
t.Fatal(err)
}
pending, err := service.LoginChinaWechat(ctx, "qr-code", state, "", "", "", "")
if err != nil {
t.Fatal(err)
}
if pending.Status != SessionStatusPhoneBindingRequired {
t.Fatalf("expected phone binding state, got %#v", pending)
}
if pending.AccessToken != "" || pending.BindingToken == "" || pending.BindingExpiresIn == 0 {
t.Fatalf("unexpected pending session %#v", pending)
}
code, err := service.RequestChinaSMSCode(ctx, "13800138001", "+86", PurposeWechatBind)
if err != nil {
t.Fatal(err)
}
bound, err := service.LoginChinaWechat(ctx, "", "", pending.BindingToken, "13800138001", "+86", code.DevCode)
if err != nil {
t.Fatal(err)
}
if bound.Status != SessionStatusAuthenticated || bound.AccessToken == "" {
t.Fatalf("expected authenticated bound session, got %#v", bound)
}
if bound.User.Phone != "13800138001" || bound.User.WechatUnionID != identity.UnionID {
t.Fatalf("unexpected bound user %#v", bound.User)
}
nextState, _, err := signState(service.cfg.TokenSecret, service.cfg.CodeTTL, service.now())
if err != nil {
t.Fatal(err)
}
direct, err := service.LoginChinaWechat(ctx, "qr-code-again", nextState, "", "", "", "")
if err != nil {
t.Fatal(err)
}
if direct.Status != SessionStatusAuthenticated || direct.User.ID != bound.User.ID {
t.Fatalf("expected future qr login to be direct for %s, got %#v", bound.User.ID, direct)
}
}