471cfdd161
- 1 - Add: Gitea now creates timestamped database backup bundles under `[backup].PATH`, exposes the backup schedule in the installer, and surfaces the `database_backup` cron task in admin monitoring. - 2 - Add: installed instances now use `.gitea-installed` and `.gitea-recovery.ini` to enter email-gated recovery instead of falling back to public install mode when configuration or database access is broken. - 3 - Mod: the installer recovery flow now covers backup-bundle restore, bundled or manual `app.ini` reuse, uploaded SQL/GZ database restores, and repository-filesystem recovery with source-specific validation, confirmations, and preserved launcher state. - 4 - Fix: recovery now restores bundled `app.ini` snapshots when needed, discovers backup bundles from both the active backup path and persisted `.gitea-recovery.ini` path, and preserves SMTP and other rebuilt settings correctly when `app.ini` is missing or incomplete. - 5 - Fix: recovery validation and restore handling now accept either a selected backup bundle or an uploaded SQL/GZ dump, keep sensitive secrets and existing `LFS_JWT_SECRET` when appropriate, clear SQLite restore targets before import, and complete the post-install handoff without redirect loops. - 6 - Mod: fresh installs now default recovery email authorization to enabled with first-admin fallback, and the install/recovery UI, styling, and EN/RO wording were refined to match the final launcher behavior. Co-Authored-By: petru @ codex (GPT-5) <codex@openai.com> (cherry picked from commit 9879caf2292691b0cb521d12e6fee924b066bae2)
136 lines
4.1 KiB
Go
136 lines
4.1 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package setting
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// start edit/add - by petru @ codex
|
|
func TestRecoveryConfigPath(t *testing.T) {
|
|
oldCustomConf := CustomConf
|
|
defer func() {
|
|
CustomConf = oldCustomConf
|
|
}()
|
|
|
|
CustomConf = "/srv/gitea/custom/conf/app.ini"
|
|
assert.Equal(t, filepath.Join("/srv/gitea/custom/conf", recoveryConfigFileName), RecoveryConfigPath())
|
|
}
|
|
|
|
func TestSaveAndLoadRecoveryConfig(t *testing.T) {
|
|
oldCustomConf := CustomConf
|
|
defer func() {
|
|
CustomConf = oldCustomConf
|
|
}()
|
|
|
|
CustomConf = filepath.Join(t.TempDir(), "custom", "conf", "app.ini")
|
|
err := SaveRecoveryConfig(&RecoveryConfig{
|
|
Enabled: true,
|
|
AllowedEmails: "admin@example.com,ops@example.com",
|
|
BackupPath: "custom/backups/db",
|
|
RepoRootPath: "/srv/gitea/repos",
|
|
SMTPAddr: "smtp.example.com",
|
|
SMTPPort: "587",
|
|
SMTPFrom: "Gitea <noreply@example.com>",
|
|
SMTPUser: "smtp-user",
|
|
SMTPPasswd: "smtp-pass",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
cfg, err := LoadRecoveryConfig()
|
|
require.NoError(t, err)
|
|
require.NotNil(t, cfg)
|
|
assert.True(t, cfg.Enabled)
|
|
assert.Equal(t, "admin@example.com,ops@example.com", cfg.AllowedEmails)
|
|
assert.Equal(t, "custom/backups/db", cfg.BackupPath)
|
|
assert.Equal(t, "/srv/gitea/repos", cfg.RepoRootPath)
|
|
assert.Equal(t, "smtp.example.com", cfg.SMTPAddr)
|
|
assert.Equal(t, "587", cfg.SMTPPort)
|
|
assert.Equal(t, "Gitea <noreply@example.com>", cfg.SMTPFrom)
|
|
assert.Equal(t, "smtp-user", cfg.SMTPUser)
|
|
assert.Equal(t, "smtp-pass", cfg.SMTPPasswd)
|
|
assert.NotEmpty(t, cfg.TokenSecret)
|
|
assert.Equal(t, defaultRecoveryTokenTTLMinutes, cfg.TokenTTLMin)
|
|
}
|
|
|
|
func TestSaveRecoveryConfigDisabledRemovesFile(t *testing.T) {
|
|
oldCustomConf := CustomConf
|
|
defer func() {
|
|
CustomConf = oldCustomConf
|
|
}()
|
|
|
|
CustomConf = filepath.Join(t.TempDir(), "custom", "conf", "app.ini")
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(RecoveryConfigPath()), 0o755))
|
|
require.NoError(t, os.WriteFile(RecoveryConfigPath(), []byte("[recovery]\nENABLED=true\n"), 0o600))
|
|
|
|
err := SaveRecoveryConfig(&RecoveryConfig{Enabled: false})
|
|
require.NoError(t, err)
|
|
_, err = os.Stat(RecoveryConfigPath())
|
|
assert.True(t, os.IsNotExist(err))
|
|
}
|
|
|
|
func TestRecoveryConfigAllowsEmail(t *testing.T) {
|
|
cfg := &RecoveryConfig{AllowedEmails: "admin@example.com, Ops@example.com "}
|
|
assert.True(t, cfg.AllowsEmail("admin@example.com"))
|
|
assert.True(t, cfg.AllowsEmail("ops@example.com"))
|
|
assert.False(t, cfg.AllowsEmail("other@example.com"))
|
|
}
|
|
|
|
func TestIssueValidateAndConsumeRecoveryToken(t *testing.T) {
|
|
oldCustomConf := CustomConf
|
|
defer func() {
|
|
CustomConf = oldCustomConf
|
|
}()
|
|
|
|
CustomConf = filepath.Join(t.TempDir(), "custom", "conf", "app.ini")
|
|
cfg := &RecoveryConfig{
|
|
Enabled: true,
|
|
AllowedEmails: "admin@example.com",
|
|
TokenSecret: "test-secret",
|
|
TokenTTLMin: 5,
|
|
}
|
|
|
|
now := time.Unix(1_700_000_000, 0)
|
|
token, err := IssueRecoveryToken(cfg, "admin@example.com", now)
|
|
require.NoError(t, err)
|
|
|
|
claims, err := ValidateRecoveryToken(cfg, token, now.Add(time.Minute))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "admin@example.com", claims.Email)
|
|
|
|
require.NoError(t, ConsumeRecoveryToken(claims.ID))
|
|
assert.ErrorIs(t, ConsumeRecoveryToken(claims.ID), ErrRecoveryTokenUsed)
|
|
_, err = ValidateRecoveryToken(cfg, token, now.Add(10*time.Minute))
|
|
assert.ErrorIs(t, err, ErrRecoveryTokenInvalid)
|
|
}
|
|
|
|
func TestIssueAndValidateRecoveryUnlockToken(t *testing.T) {
|
|
cfg := &RecoveryConfig{
|
|
Enabled: true,
|
|
AllowedEmails: "admin@example.com",
|
|
TokenSecret: "test-secret",
|
|
TokenTTLMin: 5,
|
|
}
|
|
|
|
now := time.Unix(1_700_000_000, 0)
|
|
unlockToken, err := IssueRecoveryUnlockToken(cfg, "admin@example.com", now)
|
|
require.NoError(t, err)
|
|
|
|
claims, err := ValidateRecoveryUnlockToken(cfg, unlockToken, now.Add(time.Minute))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "admin@example.com", claims.Email)
|
|
assert.Equal(t, recoveryTokenTypeUnlock, claims.Type)
|
|
|
|
_, err = ValidateRecoveryToken(cfg, unlockToken, now.Add(time.Minute))
|
|
assert.ErrorIs(t, err, ErrRecoveryTokenInvalid)
|
|
}
|
|
|
|
// end edit/add - by petru @ codex
|