63ede5c237
- refactor: move HTTP status and DTO concerns out of model and service layers into API and handler code. - feat: add admin service boundary and route auth through services instead of direct repository access. - test: add architecture and error tests covering package boundaries, redaction, and service behavior. - docs: record the protocol-neutral boundary decision and update architecture and roadmap notes.
621 lines
16 KiB
Go
621 lines
16 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/dhao2001/mygo/internal/auth"
|
|
"github.com/dhao2001/mygo/internal/model"
|
|
"github.com/dhao2001/mygo/internal/repository"
|
|
)
|
|
|
|
func setupAuthService(t *testing.T) *AuthService {
|
|
svc, _ := setupAuthServiceWithRepos(t)
|
|
return svc
|
|
}
|
|
|
|
// setupAuthServiceWithRepos creates an AuthService and returns the UserRepository
|
|
// for tests that need direct repo access (e.g., soft-deleting users).
|
|
func setupAuthServiceWithRepos(t *testing.T) (*AuthService, repository.UserRepository) {
|
|
t.Helper()
|
|
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
if err := db.AutoMigrate(&model.User{}, &model.Session{}, &model.Credential{}); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
|
|
userRepo := repository.NewUserRepository(db)
|
|
sessionRepo := repository.NewSessionRepository(db)
|
|
credentialRepo := repository.NewCredentialRepository(db)
|
|
|
|
svc := NewAuthService(
|
|
userRepo, sessionRepo, credentialRepo,
|
|
[]byte("test-secret"),
|
|
15*time.Minute,
|
|
7*24*time.Hour,
|
|
)
|
|
return svc, userRepo
|
|
}
|
|
|
|
func TestAuthService_Register(t *testing.T) {
|
|
svc := setupAuthService(t)
|
|
ctx := context.Background()
|
|
|
|
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Register = %v", err)
|
|
}
|
|
if user.ID == "" {
|
|
t.Fatal("user ID is empty")
|
|
}
|
|
if user.Username != "alice" {
|
|
t.Errorf("Username = %q, want %q", user.Username, "alice")
|
|
}
|
|
if user.PasswordHash == "password123" {
|
|
t.Fatal("password should be hashed")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_RegisterDuplicate(t *testing.T) {
|
|
svc := setupAuthService(t)
|
|
ctx := context.Background()
|
|
|
|
_, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Register = %v", err)
|
|
}
|
|
|
|
_, err = svc.Register(ctx, "alice", "alice2@example.com", "password123")
|
|
if err == nil {
|
|
t.Fatal("expected error for duplicate username, got nil")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_RegisterEmptyFields(t *testing.T) {
|
|
svc := setupAuthService(t)
|
|
ctx := context.Background()
|
|
|
|
_, err := svc.Register(ctx, "", "alice@example.com", "password")
|
|
if err == nil {
|
|
t.Fatal("expected error for empty username, got nil")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_Login(t *testing.T) {
|
|
svc := setupAuthService(t)
|
|
ctx := context.Background()
|
|
|
|
_, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Register = %v", err)
|
|
}
|
|
|
|
pair, err := svc.Login(ctx, "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Login = %v", err)
|
|
}
|
|
if pair.AccessToken == "" {
|
|
t.Fatal("access token is empty")
|
|
}
|
|
if pair.RefreshToken == "" {
|
|
t.Fatal("refresh token is empty")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_LoginWrongPassword(t *testing.T) {
|
|
svc := setupAuthService(t)
|
|
ctx := context.Background()
|
|
|
|
_, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Register = %v", err)
|
|
}
|
|
|
|
_, err = svc.Login(ctx, "alice@example.com", "wrongpassword")
|
|
if err == nil {
|
|
t.Fatal("expected error for wrong password, got nil")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_LoginNonexistentEmail(t *testing.T) {
|
|
svc := setupAuthService(t)
|
|
ctx := context.Background()
|
|
|
|
_, err := svc.Login(ctx, "nonexistent@example.com", "password")
|
|
if err == nil {
|
|
t.Fatal("expected error for nonexistent email, got nil")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_Refresh(t *testing.T) {
|
|
svc := setupAuthService(t)
|
|
ctx := context.Background()
|
|
|
|
_, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Register = %v", err)
|
|
}
|
|
|
|
pair, err := svc.Login(ctx, "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Login = %v", err)
|
|
}
|
|
|
|
newPair, err := svc.Refresh(ctx, pair.RefreshToken)
|
|
if err != nil {
|
|
t.Fatalf("Refresh = %v", err)
|
|
}
|
|
if newPair.AccessToken == "" {
|
|
t.Fatal("new access token is empty")
|
|
}
|
|
if newPair.RefreshToken == "" {
|
|
t.Fatal("new refresh token is empty")
|
|
}
|
|
if newPair.RefreshToken == pair.RefreshToken {
|
|
t.Fatal("refresh token should be rotated")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_RefreshSingleUse(t *testing.T) {
|
|
svc := setupAuthService(t)
|
|
ctx := context.Background()
|
|
|
|
_, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Register = %v", err)
|
|
}
|
|
|
|
pair, err := svc.Login(ctx, "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Login = %v", err)
|
|
}
|
|
|
|
_, err = svc.Refresh(ctx, pair.RefreshToken)
|
|
if err != nil {
|
|
t.Fatalf("first Refresh = %v", err)
|
|
}
|
|
|
|
_, err = svc.Refresh(ctx, pair.RefreshToken)
|
|
if err == nil {
|
|
t.Fatal("second refresh with same token should fail")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_Logout(t *testing.T) {
|
|
svc := setupAuthService(t)
|
|
ctx := context.Background()
|
|
|
|
_, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Register = %v", err)
|
|
}
|
|
|
|
pair, err := svc.Login(ctx, "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Login = %v", err)
|
|
}
|
|
|
|
if err := svc.Logout(ctx, pair.RefreshToken); err != nil {
|
|
t.Fatalf("Logout = %v", err)
|
|
}
|
|
|
|
_, err = svc.Refresh(ctx, pair.RefreshToken)
|
|
if err == nil {
|
|
t.Fatal("refresh should fail after logout")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_CreatePasskey(t *testing.T) {
|
|
svc := setupAuthService(t)
|
|
ctx := context.Background()
|
|
|
|
_, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Register = %v", err)
|
|
}
|
|
|
|
pair, err := svc.Login(ctx, "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Login = %v", err)
|
|
}
|
|
|
|
// Extract userID from access token
|
|
claims, err := auth.ParseToken(pair.AccessToken, []byte("test-secret"))
|
|
if err != nil {
|
|
t.Fatalf("ParseToken = %v", err)
|
|
}
|
|
// Import auth for claims access
|
|
// Already using auth above
|
|
|
|
pk, err := svc.CreatePasskey(ctx, claims.UserID, "My Phone")
|
|
if err != nil {
|
|
t.Fatalf("CreatePasskey = %v", err)
|
|
}
|
|
if pk.ID == "" {
|
|
t.Fatal("passkey ID is empty")
|
|
}
|
|
if pk.Raw == "" {
|
|
t.Fatal("raw token is empty")
|
|
}
|
|
if pk.Label != "My Phone" {
|
|
t.Errorf("Label = %q, want %q", pk.Label, "My Phone")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_LoginWithPasskey(t *testing.T) {
|
|
svc := setupAuthService(t)
|
|
ctx := context.Background()
|
|
|
|
_, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Register = %v", err)
|
|
}
|
|
|
|
pair, err := svc.Login(ctx, "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Login = %v", err)
|
|
}
|
|
|
|
claims, err := auth.ParseToken(pair.AccessToken, []byte("test-secret"))
|
|
if err != nil {
|
|
t.Fatalf("ParseToken = %v", err)
|
|
}
|
|
|
|
pk, err := svc.CreatePasskey(ctx, claims.UserID, "My Phone")
|
|
if err != nil {
|
|
t.Fatalf("CreatePasskey = %v", err)
|
|
}
|
|
|
|
loginPair, err := svc.LoginWithPasskey(ctx, pk.Raw)
|
|
if err != nil {
|
|
t.Fatalf("LoginWithPasskey = %v", err)
|
|
}
|
|
if loginPair.AccessToken == "" {
|
|
t.Fatal("access token is empty")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_LoginWithPasskeyInvalidFormat(t *testing.T) {
|
|
svc := setupAuthService(t)
|
|
ctx := context.Background()
|
|
|
|
_, err := svc.LoginWithPasskey(ctx, "not-a-mygo-token")
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid passkey format, got nil")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_RevokePasskey(t *testing.T) {
|
|
svc := setupAuthService(t)
|
|
ctx := context.Background()
|
|
|
|
_, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Register = %v", err)
|
|
}
|
|
|
|
pair, _ := svc.Login(ctx, "alice@example.com", "password123")
|
|
claims, _ := auth.ParseToken(pair.AccessToken, []byte("test-secret"))
|
|
|
|
pk, err := svc.CreatePasskey(ctx, claims.UserID, "My Phone")
|
|
if err != nil {
|
|
t.Fatalf("CreatePasskey = %v", err)
|
|
}
|
|
|
|
if err := svc.RevokePasskey(ctx, claims.UserID, pk.ID); err != nil {
|
|
t.Fatalf("RevokePasskey = %v", err)
|
|
}
|
|
|
|
_, err = svc.LoginWithPasskey(ctx, pk.Raw)
|
|
if err == nil {
|
|
t.Fatal("login with revoked passkey should fail")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_RevokePasskeyNotOwner(t *testing.T) {
|
|
svc := setupAuthService(t)
|
|
ctx := context.Background()
|
|
|
|
_, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Register = %v", err)
|
|
}
|
|
_, err = svc.Register(ctx, "bob", "bob@example.com", "password456")
|
|
if err != nil {
|
|
t.Fatalf("Register = %v", err)
|
|
}
|
|
|
|
pair, _ := svc.Login(ctx, "alice@example.com", "password123")
|
|
claims, _ := auth.ParseToken(pair.AccessToken, []byte("test-secret"))
|
|
|
|
pk, err := svc.CreatePasskey(ctx, claims.UserID, "My Phone")
|
|
if err != nil {
|
|
t.Fatalf("CreatePasskey = %v", err)
|
|
}
|
|
|
|
pairBob, _ := svc.Login(ctx, "bob@example.com", "password456")
|
|
claimsBob, _ := auth.ParseToken(pairBob.AccessToken, []byte("test-secret"))
|
|
|
|
err = svc.RevokePasskey(ctx, claimsBob.UserID, pk.ID)
|
|
var ae *model.AppError
|
|
if !errors.As(err, &ae) || ae.Kind != model.KindPermissionDenied {
|
|
t.Fatalf("expected permission denied AppError, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthService_ListPasskeys(t *testing.T) {
|
|
svc := setupAuthService(t)
|
|
ctx := context.Background()
|
|
|
|
_, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Register = %v", err)
|
|
}
|
|
|
|
pair, _ := svc.Login(ctx, "alice@example.com", "password123")
|
|
claims, _ := auth.ParseToken(pair.AccessToken, []byte("test-secret"))
|
|
|
|
_, err = svc.CreatePasskey(ctx, claims.UserID, "Phone")
|
|
if err != nil {
|
|
t.Fatalf("CreatePasskey 1 = %v", err)
|
|
}
|
|
_, err = svc.CreatePasskey(ctx, claims.UserID, "Laptop")
|
|
if err != nil {
|
|
t.Fatalf("CreatePasskey 2 = %v", err)
|
|
}
|
|
|
|
passkeys, err := svc.ListPasskeys(ctx, claims.UserID)
|
|
if err != nil {
|
|
t.Fatalf("ListPasskeys = %v", err)
|
|
}
|
|
if len(passkeys) != 2 {
|
|
t.Errorf("len(passkeys) = %d, want 2", len(passkeys))
|
|
}
|
|
}
|
|
|
|
func TestAuthService_RefreshWithInvalidToken(t *testing.T) {
|
|
svc := setupAuthService(t)
|
|
ctx := context.Background()
|
|
|
|
_, err := svc.Refresh(ctx, "not-a-valid-token")
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid refresh token, got nil")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_RefreshWithAccessToken(t *testing.T) {
|
|
svc := setupAuthService(t)
|
|
ctx := context.Background()
|
|
|
|
_, err := svc.Register(ctx, "testuser", "testuser@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Register = %v", err)
|
|
}
|
|
|
|
pair, err := svc.Login(ctx, "testuser@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Login = %v", err)
|
|
}
|
|
|
|
// Attempt to use the access token as a refresh token
|
|
_, err = svc.Refresh(ctx, pair.AccessToken)
|
|
if err == nil {
|
|
t.Fatal("expected error when using access token for refresh, got nil")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_LoginDisabledUser(t *testing.T) {
|
|
svc, userRepo := setupAuthServiceWithRepos(t)
|
|
ctx := context.Background()
|
|
|
|
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Register = %v", err)
|
|
}
|
|
|
|
if err := userRepo.Delete(ctx, user.ID); err != nil {
|
|
t.Fatalf("Delete = %v", err)
|
|
}
|
|
|
|
_, err = svc.Login(ctx, "alice@example.com", "password123")
|
|
if err == nil {
|
|
t.Fatal("expected error for disabled user login, got nil")
|
|
}
|
|
var ae *model.AppError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("expected AppError, got %T: %v", err, err)
|
|
}
|
|
if ae.Kind != model.KindUnauthenticated {
|
|
t.Errorf("kind = %q, want %q", ae.Kind, model.KindUnauthenticated)
|
|
}
|
|
if ae.Message != "invalid email or password" {
|
|
t.Errorf("message = %q, want %q", ae.Message, "invalid email or password")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_LoginDisabledUserSameMessage(t *testing.T) {
|
|
svc, userRepo := setupAuthServiceWithRepos(t)
|
|
ctx := context.Background()
|
|
|
|
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Register = %v", err)
|
|
}
|
|
|
|
// Get error for wrong password (active user)
|
|
_, wrongPwErr := svc.Login(ctx, "alice@example.com", "wrongpassword")
|
|
|
|
// Soft-delete user, then try to login
|
|
if err := userRepo.Delete(ctx, user.ID); err != nil {
|
|
t.Fatalf("Delete = %v", err)
|
|
}
|
|
_, disabledErr := svc.Login(ctx, "alice@example.com", "password123")
|
|
|
|
// Both errors should have the same message (no user enumeration)
|
|
if disabledErr == nil {
|
|
t.Fatal("expected error for disabled user login, got nil")
|
|
}
|
|
var aeDisabled *model.AppError
|
|
if !errors.As(disabledErr, &aeDisabled) {
|
|
t.Fatalf("expected AppError, got %T: %v", disabledErr, disabledErr)
|
|
}
|
|
|
|
var aeWrongPw *model.AppError
|
|
if !errors.As(wrongPwErr, &aeWrongPw) {
|
|
t.Fatalf("expected AppError for wrong password, got %T: %v", wrongPwErr, wrongPwErr)
|
|
}
|
|
|
|
if aeDisabled.Message != aeWrongPw.Message {
|
|
t.Errorf("disabled message = %q, want %q (must match wrong-password message to prevent user enumeration)", aeDisabled.Message, aeWrongPw.Message)
|
|
}
|
|
if aeDisabled.Message != "invalid email or password" {
|
|
t.Errorf("disabled message = %q, want %q", aeDisabled.Message, "invalid email or password")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_LoginWithPasskeyDisabledUser(t *testing.T) {
|
|
svc, userRepo := setupAuthServiceWithRepos(t)
|
|
ctx := context.Background()
|
|
|
|
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Register = %v", err)
|
|
}
|
|
|
|
pair, err := svc.Login(ctx, "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Login = %v", err)
|
|
}
|
|
|
|
claims, err := auth.ParseToken(pair.AccessToken, []byte("test-secret"))
|
|
if err != nil {
|
|
t.Fatalf("ParseToken = %v", err)
|
|
}
|
|
|
|
pk, err := svc.CreatePasskey(ctx, claims.UserID, "My Phone")
|
|
if err != nil {
|
|
t.Fatalf("CreatePasskey = %v", err)
|
|
}
|
|
|
|
// Soft-delete the user
|
|
if err := userRepo.Delete(ctx, user.ID); err != nil {
|
|
t.Fatalf("Delete = %v", err)
|
|
}
|
|
|
|
_, err = svc.LoginWithPasskey(ctx, pk.Raw)
|
|
if err == nil {
|
|
t.Fatal("expected error for disabled user passkey login, got nil")
|
|
}
|
|
var ae *model.AppError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("expected AppError, got %T: %v", err, err)
|
|
}
|
|
if ae.Kind != model.KindUnauthenticated {
|
|
t.Errorf("kind = %q, want %q", ae.Kind, model.KindUnauthenticated)
|
|
}
|
|
if ae.Message != "invalid passkey" {
|
|
t.Errorf("message = %q, want %q", ae.Message, "invalid passkey")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_RefreshDisabledUser(t *testing.T) {
|
|
svc, userRepo := setupAuthServiceWithRepos(t)
|
|
ctx := context.Background()
|
|
|
|
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Register = %v", err)
|
|
}
|
|
|
|
pair, err := svc.Login(ctx, "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Login = %v", err)
|
|
}
|
|
|
|
// Soft-delete the user
|
|
if err := userRepo.Delete(ctx, user.ID); err != nil {
|
|
t.Fatalf("Delete = %v", err)
|
|
}
|
|
|
|
_, err = svc.Refresh(ctx, pair.RefreshToken)
|
|
if err == nil {
|
|
t.Fatal("expected error for disabled user refresh, got nil")
|
|
}
|
|
var ae *model.AppError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("expected AppError, got %T: %v", err, err)
|
|
}
|
|
if ae.Kind != model.KindUnauthenticated {
|
|
t.Errorf("kind = %q, want %q", ae.Kind, model.KindUnauthenticated)
|
|
}
|
|
if ae.Message != "invalid token" {
|
|
t.Errorf("message = %q, want %q", ae.Message, "invalid token")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_AuthenticateAccessToken(t *testing.T) {
|
|
svc, userRepo := setupAuthServiceWithRepos(t)
|
|
ctx := context.Background()
|
|
|
|
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Register = %v", err)
|
|
}
|
|
user.IsAdmin = true
|
|
if err := userRepo.Update(ctx, user); err != nil {
|
|
t.Fatalf("promote user: %v", err)
|
|
}
|
|
|
|
pair, err := svc.Login(ctx, "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Login = %v", err)
|
|
}
|
|
|
|
principal, err := svc.AuthenticateAccessToken(ctx, pair.AccessToken)
|
|
if err != nil {
|
|
t.Fatalf("AuthenticateAccessToken = %v", err)
|
|
}
|
|
if principal.UserID != user.ID {
|
|
t.Fatalf("UserID = %q, want %q", principal.UserID, user.ID)
|
|
}
|
|
if !principal.IsAdmin {
|
|
t.Fatal("IsAdmin = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestAuthService_AuthenticateAccessTokenRejectsDeletedUser(t *testing.T) {
|
|
svc, userRepo := setupAuthServiceWithRepos(t)
|
|
ctx := context.Background()
|
|
|
|
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Register = %v", err)
|
|
}
|
|
|
|
pair, err := svc.Login(ctx, "alice@example.com", "password123")
|
|
if err != nil {
|
|
t.Fatalf("Login = %v", err)
|
|
}
|
|
|
|
if err := userRepo.Delete(ctx, user.ID); err != nil {
|
|
t.Fatalf("Delete = %v", err)
|
|
}
|
|
|
|
_, err = svc.AuthenticateAccessToken(ctx, pair.AccessToken)
|
|
var ae *model.AppError
|
|
if !errors.As(err, &ae) {
|
|
t.Fatalf("expected AppError, got %T: %v", err, err)
|
|
}
|
|
if ae.Kind != model.KindUnauthenticated {
|
|
t.Fatalf("kind = %q, want %q", ae.Kind, model.KindUnauthenticated)
|
|
}
|
|
}
|