WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
@@ -0,0 +1,225 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/config"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
)
// stubRegisterUserService is a UserService whose ONLY useful method is
// Register; every other call panics. Using an interface embedding plus a
// targeted override keeps the test focused on the Register handler's
// branching logic without dragging in the entire user service surface.
type stubRegisterUserService struct {
interfaces.UserService
register func(ctx context.Context, req *types.RegisterRequest) (*types.User, error)
}
func (s *stubRegisterUserService) Register(ctx context.Context, req *types.RegisterRequest) (*types.User, error) {
return s.register(ctx, req)
}
// errorCapture mirrors gin's default ErrorHandler behaviour for tests:
// when a handler calls c.Error(), we surface it as an HTTP response so the
// recorder reflects the real client-visible status. The production
// middleware does the same thing in middleware/error_handler.go.
func errorCapture() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
if len(c.Errors) == 0 {
return
}
err := c.Errors.Last().Err
if appErr, ok := err.(*apperrors.AppError); ok {
c.JSON(appErr.HTTPCode, gin.H{"error": appErr.Message})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
}
func newRegisterTestRouter(h *AuthHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(errorCapture())
r.POST("/auth/register", h.Register)
return r
}
func doRegister(t *testing.T, r *gin.Engine, body any) *httptest.ResponseRecorder {
t.Helper()
buf, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, "/auth/register", bytes.NewReader(buf))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
// validRegisterBody returns a payload that passes parameter validation, so
// each test is exercising the gate logic and not the body parser.
func validRegisterBody() map[string]string {
return map[string]string{
"username": "alice",
"email": "alice@example.com",
"password": "supersecret1",
}
}
func TestRegister_InviteOnlyRejects(t *testing.T) {
// PR 3 (#1303): when auth.registration_mode=invite_only, Register
// must respond 403 BEFORE touching the user service. The frontend
// already hides the sign-up link via /auth/config; this is the
// server-side enforcement for direct API hits.
called := false
us := &stubRegisterUserService{
register: func(context.Context, *types.RegisterRequest) (*types.User, error) {
called = true
return &types.User{ID: "u1"}, nil
},
}
h := NewAuthHandler(&config.Config{
Auth: &config.AuthConfig{RegistrationMode: config.AuthRegistrationModeInviteOnly},
}, us, nil, nil, nil)
w := doRegister(t, newRegisterTestRouter(h), validRegisterBody())
if w.Code != http.StatusForbidden {
t.Fatalf("invite_only must return 403, got %d body=%s", w.Code, w.Body.String())
}
if called {
t.Fatalf("UserService.Register must not be called when invite_only blocks the request")
}
}
func TestRegister_SelfServeAllowsRegistration(t *testing.T) {
// Default registration_mode keeps PR 1 behaviour intact: the gate
// is dormant and the request reaches the user service. We don't
// exercise the real service here — just confirm the gate let it
// through by observing the stub being invoked.
called := false
us := &stubRegisterUserService{
register: func(_ context.Context, req *types.RegisterRequest) (*types.User, error) {
called = true
if req.TenantProvisioning != types.TenantProvisioningCreatePersonal {
t.Fatalf("default provisioning = %q, want create_personal", req.TenantProvisioning)
}
return &types.User{ID: "u1", Email: "alice@example.com"}, nil
},
}
h := NewAuthHandler(&config.Config{
Auth: &config.AuthConfig{RegistrationMode: config.AuthRegistrationModeSelfServe},
}, us, nil, nil, nil)
w := doRegister(t, newRegisterTestRouter(h), validRegisterBody())
if w.Code != http.StatusCreated {
t.Fatalf("self_serve must allow registration, got %d body=%s", w.Code, w.Body.String())
}
if !called {
t.Fatalf("UserService.Register should have been invoked")
}
}
func TestRegister_TenantlessProvisioningFromConfig(t *testing.T) {
us := &stubRegisterUserService{
register: func(_ context.Context, req *types.RegisterRequest) (*types.User, error) {
if req.TenantProvisioning != types.TenantProvisioningTenantless {
t.Fatalf("provisioning = %q, want tenantless", req.TenantProvisioning)
}
return &types.User{ID: "u1", Email: "alice@example.com"}, nil
},
}
h := NewAuthHandler(&config.Config{
Auth: &config.AuthConfig{
RegistrationMode: config.AuthRegistrationModeSelfServe,
DefaultTenantMode: config.AuthDefaultTenantModeTenantless,
},
}, us, nil, nil, nil)
w := doRegister(t, newRegisterTestRouter(h), validRegisterBody())
if w.Code != http.StatusCreated {
t.Fatalf("tenantless self-serve registration got %d body=%s", w.Code, w.Body.String())
}
}
func TestGetAuthConfigExposesComplexPassword(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &AuthHandler{
configInfo: &config.Config{Auth: &config.AuthConfig{
RegistrationMode: config.AuthRegistrationModeSelfServe,
}},
systemSettingSvc: &tenantPolicySettingService{enabled: true},
}
r := gin.New()
r.GET("/auth/config", h.GetAuthConfig)
req := httptest.NewRequest(http.MethodGet, "/auth/config", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"complex_password_enabled":true`) {
t.Fatalf("body=%s, want complex_password_enabled true", w.Body.String())
}
}
func TestRegister_NilAuthConfigDoesNotPanic(t *testing.T) {
// Defensive: a nil Auth section means the operator hasn't set the
// registration mode at all, which must not crash and must keep the
// legacy "registration enabled" behaviour. Mirrors the nil guard in
// the handler so a config-loading bug doesn't take the server down.
us := &stubRegisterUserService{
register: func(_ context.Context, _ *types.RegisterRequest) (*types.User, error) {
return &types.User{ID: "u1", Email: "alice@example.com"}, nil
},
}
h := NewAuthHandler(&config.Config{}, us, nil, nil, nil)
w := doRegister(t, newRegisterTestRouter(h), validRegisterBody())
if w.Code != http.StatusCreated {
t.Fatalf("nil Auth config must fall back to allow, got %d body=%s", w.Code, w.Body.String())
}
}
// TestRegister_PreservesPasswordBytes is the regression for #2521.
// SanitizeForLog is for log output only; applying it to credentials
// rewrites tabs/newlines/control chars before hashing, so a password
// that registers successfully cannot be used at login.
func TestRegister_PreservesPasswordBytes(t *testing.T) {
// Password contains a tab and a newline — both are rewritten by
// SanitizeForLog (tab → space, newline → space). The handler must
// pass the exact original string to UserService.Register.
const originalPassword = "abc\t123!\nX"
var gotPassword string
us := &stubRegisterUserService{
register: func(_ context.Context, req *types.RegisterRequest) (*types.User, error) {
gotPassword = req.Password
return &types.User{ID: "u1", Email: "alice@example.com"}, nil
},
}
h := NewAuthHandler(&config.Config{
Auth: &config.AuthConfig{RegistrationMode: config.AuthRegistrationModeSelfServe},
}, us, nil, nil, nil)
body := validRegisterBody()
body["password"] = originalPassword
w := doRegister(t, newRegisterTestRouter(h), body)
if w.Code != http.StatusCreated {
t.Fatalf("registration with control chars in password must succeed, got %d body=%s",
w.Code, w.Body.String())
}
if gotPassword != originalPassword {
t.Fatalf("password mutated before UserService.Register:\n got %q\n want %q",
gotPassword, originalPassword)
}
}