| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195 |
- package handler
- import (
- "context"
- "errors"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- )
- type passkeySwitchSettingRepo struct {
- value string
- values map[string]string
- err error
- }
- type passkeyCaptchaVerifierStub struct {
- calls int
- proof service.TencentCaptchaProof
- }
- func (s *passkeyCaptchaVerifierStub) VerifyTicket(_ context.Context, _ service.TencentCaptchaCredentials, proof service.TencentCaptchaProof, _ string) (*service.TencentCaptchaVerifyResponse, error) {
- s.calls++
- s.proof = proof
- return &service.TencentCaptchaVerifyResponse{CaptchaCode: 1}, nil
- }
- type passkeyBeginSessionStoreStub struct {
- service.PasskeySessionStore
- storeCalls int
- }
- func (s *passkeyBeginSessionStoreStub) Store(context.Context, *service.PasskeySession, time.Duration) (string, error) {
- s.storeCalls++
- return "passkey-session", nil
- }
- func (r *passkeySwitchSettingRepo) Get(context.Context, string) (*service.Setting, error) {
- return nil, service.ErrSettingNotFound
- }
- func (r *passkeySwitchSettingRepo) GetValue(context.Context, string) (string, error) {
- return r.value, r.err
- }
- func (r *passkeySwitchSettingRepo) Set(context.Context, string, string) error { return nil }
- func (r *passkeySwitchSettingRepo) GetMultiple(context.Context, []string) (map[string]string, error) {
- if r.err != nil {
- return nil, r.err
- }
- return r.values, nil
- }
- func (r *passkeySwitchSettingRepo) SetMultiple(context.Context, map[string]string) error {
- return nil
- }
- func (r *passkeySwitchSettingRepo) GetAll(context.Context) (map[string]string, error) {
- return map[string]string{}, nil
- }
- func (r *passkeySwitchSettingRepo) Delete(context.Context, string) error { return nil }
- func TestBindPasskeyFinishRequestRejectsOversizedBody(t *testing.T) {
- gin.SetMode(gin.TestMode)
- recorder := httptest.NewRecorder()
- context, _ := gin.CreateTestContext(recorder)
- context.Request = httptest.NewRequest(
- http.MethodPost,
- "/api/v1/auth/passkey/login/finish",
- strings.NewReader(`{"credential":"`+strings.Repeat("x", passkeyFinishBodyMaxBytes)+`"}`),
- )
- context.Request.Header.Set("Content-Type", "application/json")
- _, ok := bindPasskeyFinishRequest(context)
- require.False(t, ok)
- require.Equal(t, http.StatusBadRequest, recorder.Code)
- }
- func TestPasskeyBeginLoginRejectsDisabledAdminSwitch(t *testing.T) {
- gin.SetMode(gin.TestMode)
- repo := &passkeySwitchSettingRepo{value: "false"}
- settings := service.NewSettingService(repo, &config.Config{
- WebAuthn: config.WebAuthnConfig{Enabled: true},
- })
- handler := NewPasskeyHandler(nil, nil, settings)
- recorder := httptest.NewRecorder()
- ginContext, _ := gin.CreateTestContext(recorder)
- ginContext.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/passkey/login/begin", nil)
- handler.BeginLogin(ginContext)
- require.Equal(t, http.StatusForbidden, recorder.Code)
- require.Contains(t, recorder.Body.String(), "PASSKEY_DISABLED")
- }
- func TestPasskeyBeginLoginReportsSettingStoreFailure(t *testing.T) {
- gin.SetMode(gin.TestMode)
- settings := service.NewSettingService(
- &passkeySwitchSettingRepo{err: errors.New("database unavailable")},
- &config.Config{WebAuthn: config.WebAuthnConfig{Enabled: true}},
- )
- handler := NewPasskeyHandler(nil, nil, settings)
- recorder := httptest.NewRecorder()
- ginContext, _ := gin.CreateTestContext(recorder)
- ginContext.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/passkey/login/begin", nil)
- handler.BeginLogin(ginContext)
- require.Equal(t, http.StatusInternalServerError, recorder.Code)
- require.NotContains(t, recorder.Body.String(), "PASSKEY_DISABLED")
- }
- func newTencentProtectedPasskeyHandler(t *testing.T) (*PasskeyHandler, *passkeyCaptchaVerifierStub, *passkeyBeginSessionStoreStub) {
- t.Helper()
- cfg := &config.Config{WebAuthn: config.WebAuthnConfig{
- Enabled: true,
- RPDisplayName: "Sub2API",
- RPID: "sub2api.example.com",
- RPOrigins: []string{"https://sub2api.example.com"},
- }}
- repo := &passkeySwitchSettingRepo{
- value: "true",
- values: map[string]string{
- service.SettingKeyTencentCaptchaEnabled: "true",
- service.SettingKeyTencentCaptchaAppID: "123456789",
- service.SettingKeyTencentCaptchaAppSecretKey: "app-secret",
- service.SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id",
- service.SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key",
- },
- }
- settings := service.NewSettingService(repo, cfg)
- verifier := &passkeyCaptchaVerifierStub{}
- authService := service.NewAuthService(nil, nil, nil, nil, cfg, settings, nil, nil, nil, nil, nil, nil, nil)
- authService.SetTencentCaptchaService(service.NewTencentCaptchaService(settings, verifier))
- sessions := &passkeyBeginSessionStoreStub{}
- passkeys, err := service.NewPasskeyService(cfg, nil, sessions, nil)
- require.NoError(t, err)
- return NewPasskeyHandler(passkeys, authService, settings), verifier, sessions
- }
- func newPasskeyBeginLoginContext(body string) (*gin.Context, *httptest.ResponseRecorder) {
- recorder := httptest.NewRecorder()
- ginContext, _ := gin.CreateTestContext(recorder)
- ginContext.Request = httptest.NewRequest(
- http.MethodPost,
- "/api/v1/auth/passkey/login/begin",
- strings.NewReader(body),
- )
- ginContext.Request.Header.Set("Content-Type", "application/json")
- return ginContext, recorder
- }
- func TestPasskeyBeginLoginRejectsMissingTencentCaptchaProof(t *testing.T) {
- gin.SetMode(gin.TestMode)
- handler, verifier, sessions := newTencentProtectedPasskeyHandler(t)
- ginContext, recorder := newPasskeyBeginLoginContext(`{}`)
- handler.BeginLogin(ginContext)
- require.Equal(t, http.StatusBadRequest, recorder.Code)
- require.Contains(t, recorder.Body.String(), "TENCENT_CAPTCHA_VERIFICATION_FAILED")
- require.Zero(t, verifier.calls)
- require.Zero(t, sessions.storeCalls)
- }
- func TestPasskeyBeginLoginAcceptsTencentCaptchaProofBeforeCeremony(t *testing.T) {
- gin.SetMode(gin.TestMode)
- handler, verifier, sessions := newTencentProtectedPasskeyHandler(t)
- ginContext, recorder := newPasskeyBeginLoginContext(
- `{"tencent_captcha_ticket":"ticket-value","tencent_captcha_randstr":"@rand-value"}`,
- )
- handler.BeginLogin(ginContext)
- require.Equal(t, http.StatusOK, recorder.Code)
- require.Equal(t, 1, verifier.calls)
- require.Equal(t, service.TencentCaptchaProof{Ticket: "ticket-value", Randstr: "@rand-value"}, verifier.proof)
- require.Equal(t, 1, sessions.storeCalls)
- }
- func TestPasskeyCredentialListRemainsAvailableWhenSignInDisabled(t *testing.T) {
- gin.SetMode(gin.TestMode)
- handler := NewPasskeyHandler(nil, nil, nil)
- recorder := httptest.NewRecorder()
- ginContext, _ := gin.CreateTestContext(recorder)
- ginContext.Request = httptest.NewRequest(http.MethodGet, "/api/v1/user/passkeys", nil)
- handler.List(ginContext)
- require.Equal(t, http.StatusUnauthorized, recorder.Code)
- require.NotContains(t, recorder.Body.String(), "PASSKEY_DISABLED")
- }
|