2026-08-12 16:15:19 +08:00
package app
import (
"context"
"encoding/json"
2026-08-12 18:41:49 +08:00
"fmt"
2026-08-12 16:15:19 +08:00
"io"
2026-08-12 16:59:20 +08:00
"net"
2026-08-12 16:15:19 +08:00
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
2026-08-12 17:58:48 +08:00
"time"
2026-08-12 16:15:19 +08:00
)
func TestBackupEndpointsRejectMismatchedConfirmation ( t * testing . T ) {
a := newTestApp ( t )
stopTestWorkers ( a )
server := httptest . NewServer ( a . Router ())
defer server . Close ()
admin := & testClient { t : t , server : server }
var response map [ string ] any
if code := admin . do ( "POST" , "/api/auth/login" , map [ string ] string { "email" : "admin@lanqin.local" , "password" : "ChangeMe123!" }, & response ); code != http . StatusOK {
t . Fatalf ( "login code=%d body=%v" , code , response )
}
response = nil
if code := admin . do ( "POST" , "/api/admin/backups" , map [ string ] any { "password" : "BackupPassword123!" , "confirmPassword" : "DifferentPassword123!" }, & response ); code != http . StatusBadRequest {
t . Fatalf ( "manual backup mismatch code=%d body=%v" , code , response )
}
response = nil
if code := admin . do ( "POST" , "/api/admin/backups/settings" , map [ string ] any { "enabled" : false , "days" : 7 , "password" : "BackupPassword123!" , "confirmPassword" : "DifferentPassword123!" }, & response ); code != http . StatusBadRequest {
t . Fatalf ( "scheduled backup mismatch code=%d body=%v" , code , response )
}
}
func TestDiscoverTelegramGroupsReturnsUniqueCandidates ( t * testing . T ) {
telegramServer := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
w . Header (). Set ( "Content-Type" , "application/json" )
_ , _ = io . WriteString ( w , `{"ok":true,"result":[` +
`{"update_id":1,"message":{"text":"/newszxcn ABC123","chat":{"id":-1001,"type":"supergroup","title":"主备份"}}},` +
`{"update_id":2,"message":{"text":"/newszxcn ABC123","chat":{"id":-1002,"type":"group","title":"异地备份"}}},` +
`{"update_id":3,"message":{"text":"/newszxcn ABC123","chat":{"id":-1001,"type":"supergroup","title":"主备份"}}},` +
`{"update_id":4,"message":{"text":"/newszxcn WRONG","chat":{"id":-1003,"type":"group","title":"无关群组"}}}]}` )
}))
defer telegramServer . Close ()
a := newTestApp ( t )
stopTestWorkers ( a )
a . telegramURL = telegramServer . URL
groups , err := a . discoverTelegramGroups ( context . Background (), "test-token" , "ABC123" )
if err != nil {
t . Fatal ( err )
}
if len ( groups ) != 2 || groups [ 0 ]. ChatID != "-1001" || groups [ 1 ]. ChatID != "-1002" {
t . Fatalf ( "unexpected groups: %+v" , groups )
}
}
2026-08-12 18:41:49 +08:00
func TestGoogleDriveResumableRequest ( t * testing . T ) {
2026-08-12 16:15:19 +08:00
dir := t . TempDir ()
path := filepath . Join ( dir , "newszxcn-backup-test.tar.zst.enc" )
if err := os . WriteFile ( path , [] byte ( "encrypted backup" ), 0 o600 ); err != nil {
t . Fatal ( err )
}
2026-08-12 18:41:49 +08:00
req , size , err := newGoogleDriveResumableRequest ( context . Background (), path , "folder-123" )
2026-08-12 16:15:19 +08:00
if err != nil {
t . Fatal ( err )
}
2026-08-12 18:41:49 +08:00
if size != int64 ( len ( "encrypted backup" )) {
t . Fatalf ( "upload size = %d" , size )
2026-08-12 16:15:19 +08:00
}
2026-08-12 18:41:49 +08:00
if req . URL . Query (). Get ( "uploadType" ) != "resumable" || req . Header . Get ( "X-Upload-Content-Length" ) != fmt . Sprint ( size ) {
t . Fatalf ( "resumable request = %s headers=%v" , req . URL , req . Header )
2026-08-12 16:15:19 +08:00
}
var metadata struct {
Name string `json:"name"`
Parents [] string `json:"parents"`
}
2026-08-12 18:41:49 +08:00
if err := json . NewDecoder ( req . Body ). Decode ( & metadata ); err != nil {
2026-08-12 16:15:19 +08:00
t . Fatal ( err )
}
if metadata . Name != filepath . Base ( path ) || len ( metadata . Parents ) != 1 || metadata . Parents [ 0 ] != "folder-123" {
t . Fatalf ( "metadata = %+v" , metadata )
}
2026-08-12 18:41:49 +08:00
}
func TestBackupProgressReaderReportsBytes ( t * testing . T ) {
var updates [] int64
reader := & backupProgressReader { reader : strings . NewReader ( "encrypted backup" ), onProgress : func ( uploaded int64 ) {
updates = append ( updates , uploaded )
}}
raw , err := io . ReadAll ( reader )
if err != nil || string ( raw ) != "encrypted backup" {
t . Fatalf ( "read = %q, %v" , raw , err )
}
if len ( updates ) == 0 || updates [ len ( updates ) - 1 ] != int64 ( len ( raw )) {
t . Fatalf ( "progress updates = %v" , updates )
}
}
func TestGoogleDriveUploadMessage ( t * testing . T ) {
tests := [] struct {
status int
body string
want string
}{
{ http . StatusUnauthorized , `{}` , "授权已失效" },
{ http . StatusForbidden , `{"reason":"storageQuotaExceeded"}` , "空间不足" },
{ http . StatusForbidden , `{}` , "无上传权限" },
{ http . StatusTooManyRequests , `{}` , "请求过于频繁" },
}
for _ , test := range tests {
message := googleDriveUploadMessage ( & googleDriveAPIError { Operation : "upload" , StatusCode : test . status , Body : test . body })
if ! strings . Contains ( message , test . want ) {
t . Fatalf ( "message %q does not contain %q" , message , test . want )
}
}
}
func TestGoogleDriveChunkUploadAndProgress ( t * testing . T ) {
dir := t . TempDir ()
path := filepath . Join ( dir , "large-backup.tar.zst.enc" )
size := int64 ( googleDriveUploadChunkSize + 3 )
file , err := os . Create ( path )
2026-08-12 16:15:19 +08:00
if err != nil {
t . Fatal ( err )
}
2026-08-12 18:41:49 +08:00
if err := file . Truncate ( size ); err != nil {
t . Fatal ( err )
}
_ = file . Close ()
var ranges [] string
server := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
ranges = append ( ranges , r . Header . Get ( "Content-Range" ))
_ , _ = io . Copy ( io . Discard , r . Body )
if len ( ranges ) == 1 {
w . WriteHeader ( http . StatusPermanentRedirect )
return
}
w . Header (). Set ( "Content-Type" , "application/json" )
_ , _ = io . WriteString ( w , `{"id":"uploaded"}` )
}))
defer server . Close ()
a := newTestApp ( t )
stopTestWorkers ( a )
if ! a . startBackupTransfer ( "googleDrive" , path ) {
t . Fatal ( "failed to start transfer" )
}
if err := a . uploadGoogleDriveChunks ( context . Background (), server . Client (), server . URL , path , size ); err != nil {
t . Fatal ( err )
}
wantRanges := [] string {
fmt . Sprintf ( "bytes 0-%d/%d" , googleDriveUploadChunkSize - 1 , size ),
fmt . Sprintf ( "bytes %d-%d/%d" , googleDriveUploadChunkSize , size - 1 , size ),
}
if len ( ranges ) != len ( wantRanges ) || ranges [ 0 ] != wantRanges [ 0 ] || ranges [ 1 ] != wantRanges [ 1 ] {
t . Fatalf ( "content ranges = %v, want %v" , ranges , wantRanges )
}
transfer := a . backupTransfers [ backupTransferKey ( "googleDrive" , path )]
if transfer == nil || transfer . Uploaded != size {
t . Fatalf ( "transfer = %+v" , transfer )
2026-08-12 16:15:19 +08:00
}
}
func TestBackupEncryptionRequiresDeploymentSecret ( t * testing . T ) {
dir := t . TempDir ()
a := newTestAppWithConfig ( t , Config {
Addr : ":0" , DBPath : filepath . Join ( dir , "data" , "lanqin.db" ), DataDir : filepath . Join ( dir , "data" ),
CookieName : "lanqin_test" , SessionTTLHours : 24 , AdminEmail : "admin@example.com" , AdminPassword : "ChangeMe123!" , AllowInsecureHTTP : true ,
})
if _ , err := a . encryptBackupPassword ( "BackupPassword123!" ); err == nil {
t . Fatal ( "backup password encryption succeeded without a deployment secret" )
}
}
func TestBackupPasswordValidation ( t * testing . T ) {
for _ , valid := range [] string { "12345678" , "Restore Password 123!" } {
if ! validBackupPassword ( valid ) {
t . Errorf ( "valid password rejected: %q" , valid )
}
}
for _ , invalid := range [] string { "1234567" , "password\nvalue" , "password\x00value" , strings . Repeat ( "x" , 1025 )} {
if validBackupPassword ( invalid ) {
t . Errorf ( "invalid password accepted: %q" , invalid )
}
}
}
2026-08-12 17:58:48 +08:00
func TestBackupPasswordHint ( t * testing . T ) {
if got := backupPasswordHint ( "A23456789Z" ); got != "A••••••••Z" {
t . Fatalf ( "password hint = %q" , got )
}
if got := backupPasswordHint ( "ab" ); got != "ab" {
t . Fatalf ( "two-character password hint = %q" , got )
}
if got := backupPasswordHint ( "" ); got != "" {
t . Fatalf ( "empty password hint = %q" , got )
}
}
func TestSavedBackupPasswordAndHint ( t * testing . T ) {
dir := t . TempDir ()
a := newTestAppWithConfig ( t , Config {
Addr : ":0" , DBPath : filepath . Join ( dir , "data" , "lanqin.db" ), DataDir : filepath . Join ( dir , "data" ),
CookieName : "lanqin_test" , SessionTTLHours : 24 , AdminEmail : "admin@example.com" , AdminPassword : "ChangeMe123!" ,
AllowInsecureHTTP : true , UpdateServiceToken : "test-update-secret" ,
})
stopTestWorkers ( a )
ciphertext , err := a . encryptBackupPassword ( "A23456789Z" )
if err != nil {
t . Fatal ( err )
}
now := a . now (). UTC (). Format ( "2006-01-02T15:04:05Z" )
if _ , err = a . db . Exec ( `INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?)` , ciphertext , now ); err != nil {
t . Fatal ( err )
}
password , err := a . savedBackupPassword ( context . Background ())
if err != nil || password != "A23456789Z" {
t . Fatalf ( "saved password = %q, %v" , password , err )
}
schedule , err := a . loadBackupSchedule ( context . Background ())
if err != nil || ! schedule . PasswordSet || schedule . PasswordHint != "A••••••••Z" {
t . Fatalf ( "schedule password state = %+v, %v" , schedule , err )
}
}
func TestUpdateBackupPasswordDoesNotChangeScheduleSettings ( t * testing . T ) {
dir := t . TempDir ()
a := newTestAppWithConfig ( t , Config {
Addr : ":0" , DBPath : filepath . Join ( dir , "data" , "lanqin.db" ), DataDir : filepath . Join ( dir , "data" ),
CookieName : "lanqin_test" , SessionTTLHours : 24 , AdminEmail : "admin@example.com" , AdminPassword : "ChangeMe123!" ,
AllowInsecureHTTP : true , UpdateServiceToken : "test-update-secret" ,
})
stopTestWorkers ( a )
now := a . now (). UTC (). Format ( time . RFC3339Nano )
for key , value := range map [ string ] string {
"backupScheduleEnabled" : "true" ,
"backupScheduleDays" : "30" ,
"backupTelegramMode" : "custom" ,
"backupTelegramChatId" : "-1001234567890" ,
"backupGoogleFolderName" : "Existing Backups" ,
} {
if _ , err := a . db . Exec ( `INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?)` , key , value , now ); err != nil {
t . Fatal ( err )
}
}
server := httptest . NewServer ( a . Router ())
defer server . Close ()
admin := & testClient { t : t , server : server }
var response map [ string ] any
if code := admin . do ( "POST" , "/api/auth/login" , map [ string ] string { "email" : "admin@example.com" , "password" : "ChangeMe123!" }, & response ); code != http . StatusOK {
t . Fatalf ( "login code=%d body=%v" , code , response )
}
response = nil
if code := admin . do ( "POST" , "/api/admin/backups/password" , map [ string ] string { "password" : "NewSharedPassword9" , "confirmPassword" : "NewSharedPassword9" }, & response ); code != http . StatusOK {
t . Fatalf ( "password update code=%d body=%v" , code , response )
}
if response [ "passwordHint" ] != "N••••••••••9" {
t . Fatalf ( "password hint = %v" , response [ "passwordHint" ])
}
password , err := a . savedBackupPassword ( context . Background ())
if err != nil || password != "NewSharedPassword9" {
t . Fatalf ( "saved password = %q, %v" , password , err )
}
for key , want := range map [ string ] string {
"backupScheduleEnabled" : "true" ,
"backupScheduleDays" : "30" ,
"backupTelegramMode" : "custom" ,
"backupTelegramChatId" : "-1001234567890" ,
"backupGoogleFolderName" : "Existing Backups" ,
} {
var got string
if err := a . db . QueryRow ( `SELECT value FROM system_settings WHERE key=?` , key ). Scan ( & got ); err != nil || got != want {
t . Fatalf ( "setting %s = %q, %v; want %q" , key , got , err , want )
}
}
}
func TestManualBackupReusesSavedPassword ( t * testing . T ) {
dir := t . TempDir ()
deployDir := filepath . Join ( dir , "deploy" )
if err := os . MkdirAll ( deployDir , 0 o700 ); err != nil {
t . Fatal ( err )
}
if err := os . WriteFile ( filepath . Join ( deployDir , "docker-compose.yml" ), [] byte ( "services: {}\n" ), 0 o600 ); err != nil {
t . Fatal ( err )
}
a := newTestAppWithConfig ( t , Config {
Addr : ":0" , DBPath : filepath . Join ( dir , "data" , "lanqin.db" ), DataDir : filepath . Join ( dir , "data" ),
CookieName : "lanqin_test" , SessionTTLHours : 24 , AdminEmail : "admin@example.com" , AdminPassword : "ChangeMe123!" ,
AllowInsecureHTTP : true , UpdateServiceToken : "test-update-secret" , BackupSourceDir : deployDir ,
BackupDir : filepath . Join ( dir , "data" , "disaster-backups" ),
})
stopTestWorkers ( a )
ciphertext , err := a . encryptBackupPassword ( "SharedBackupPassword9" )
if err != nil {
t . Fatal ( err )
}
now := a . now (). UTC (). Format ( "2006-01-02T15:04:05Z" )
if _ , err = a . db . Exec ( `INSERT INTO system_settings(key,value,updated_at) VALUES('backupPasswordCipher',?,?)` , ciphertext , now ); err != nil {
t . Fatal ( err )
}
server := httptest . NewServer ( a . Router ())
defer server . Close ()
admin := & testClient { t : t , server : server }
var response map [ string ] any
if code := admin . do ( "POST" , "/api/auth/login" , map [ string ] string { "email" : "admin@example.com" , "password" : "ChangeMe123!" }, & response ); code != http . StatusOK {
t . Fatalf ( "login code=%d body=%v" , code , response )
}
response = nil
if code := admin . do ( "POST" , "/api/admin/backups" , map [ string ] any { "password" : "" , "confirmPassword" : "" , "sendTelegram" : false , "uploadGoogleDrive" : false }, & response ); code != http . StatusAccepted {
t . Fatalf ( "manual backup code=%d body=%v" , code , response )
}
password , err := a . savedBackupPassword ( context . Background ())
if err != nil || password != "SharedBackupPassword9" {
t . Fatalf ( "saved password changed: %q, %v" , password , err )
}
}
2026-08-12 16:59:20 +08:00
func TestPublicServerIPValidation ( t * testing . T ) {
for _ , value := range [] string { "203.0.113.10" , "2001:4860:4860::8888" } {
if ! isPublicIP ( net . ParseIP ( value )) {
t . Errorf ( "public IP rejected: %s" , value )
}
}
for _ , value := range [] string { "127.0.0.1" , "10.0.0.1" , "192.168.1.1" , "169.254.1.1" , "::1" , "fc00::1" } {
if isPublicIP ( net . ParseIP ( value )) {
t . Errorf ( "non-public IP accepted: %s" , value )
}
}
if got := detectPublicServerIP ( context . Background (), "203.0.113.10" ); got != "203.0.113.10" {
t . Fatalf ( "literal public IP = %q" , got )
}
if got := detectPublicServerIP ( context . Background (), "127.0.0.1" ); got != "" {
t . Fatalf ( "literal private IP = %q" , got )
}
}
func TestWriteRuntimeBackupEnv ( t * testing . T ) {
t . Setenv ( "LANQIN_PUBLIC_HOSTNAME" , "mail.example.com" )
t . Setenv ( "LANQIN_TEST_QUOTED" , "value'with\\slashes\nand-newline" )
t . Setenv ( "LANQIN_BACKUP_DIR" , "/backups" )
t . Setenv ( "LANQIN_UPDATE_SERVICE_URL" , "http://updater:8080/v1/update" )
t . Setenv ( "UNRELATED_SECRET" , "must-not-be-backed-up" )
path := filepath . Join ( t . TempDir (), ".env" )
if err := writeRuntimeBackupEnv ( path ); err != nil {
t . Fatal ( err )
}
raw , err := os . ReadFile ( path )
if err != nil {
t . Fatal ( err )
}
contents := string ( raw )
for _ , expected := range [] string { "LANQIN_PUBLIC_HOSTNAME='mail.example.com'" , `LANQIN_TEST_QUOTED='value\'with\\slashes\nand-newline'` } {
if ! strings . Contains ( contents , expected ) {
t . Errorf ( "backup environment missing %q: %s" , expected , contents )
}
}
for _ , excluded := range [] string { "UNRELATED_SECRET" , "must-not-be-backed-up" , "LANQIN_BACKUP_DIR" , "LANQIN_UPDATE_SERVICE_URL" , "http://updater:8080" } {
if strings . Contains ( contents , excluded ) {
t . Fatalf ( "backup environment included excluded value %q" , excluded )
}
}
info , err := os . Stat ( path )
if err != nil || info . Mode (). Perm () != 0 o600 {
t . Fatalf ( "backup environment permissions = %v, %v" , info . Mode (). Perm (), err )
}
}
func TestBackupAssetsAvailableWithBundledCompose ( t * testing . T ) {
dir := t . TempDir ()
compose := filepath . Join ( dir , "deploy" , "docker-compose.yml" )
if err := os . MkdirAll ( filepath . Dir ( compose ), 0 o700 ); err != nil {
t . Fatal ( err )
}
if err := os . WriteFile ( compose , [] byte ( "services: {}\n" ), 0 o600 ); err != nil {
t . Fatal ( err )
}
a := newTestAppWithConfig ( t , Config {
Addr : ":0" , DBPath : filepath . Join ( dir , "data" , "lanqin.db" ), DataDir : filepath . Join ( dir , "data" ),
CookieName : "lanqin_test" , SessionTTLHours : 24 , AdminEmail : "admin@example.com" , AdminPassword : "ChangeMe123!" ,
AllowInsecureHTTP : true , BackupSourceDir : filepath . Dir ( compose ), BackupDir : filepath . Join ( dir , "data" , "disaster-backups" ),
})
stopTestWorkers ( a )
if ! a . backupAssetsAvailable () {
t . Fatal ( "bundled compose did not enable complete backups" )
}
if err := os . Remove ( compose ); err != nil {
t . Fatal ( err )
}
if a . backupAssetsAvailable () {
t . Fatal ( "missing bundled compose incorrectly enabled complete backups" )
}
}
2026-08-12 16:15:19 +08:00
func TestBackupPasswordEncryptionAndTelegramReport ( t * testing . T ) {
dir := t . TempDir ()
a := newTestAppWithConfig ( t , Config {
Addr : ":0" , AppVersion : "v1.2.31" , DBPath : filepath . Join ( dir , "data" , "lanqin.db" ), DataDir : filepath . Join ( dir , "data" ),
CookieName : "lanqin_test" , SessionTTLHours : 24 , AdminEmail : "admin@newszxcn.com" , AdminPassword : "ChangeMe123!" ,
PublicHostname : "mail.newszxcn.com" , PublicBaseURL : "https://mail.newszxcn.com" , AllowInsecureHTTP : true , UpdateServiceToken : "test-update-secret" ,
})
ciphertext , err := a . encryptBackupPassword ( "BackupPassword123!" )
if err != nil || ciphertext == "BackupPassword123!" {
t . Fatalf ( "password encryption failed: %q %v" , ciphertext , err )
}
plain , err := a . decryptBackupPassword ( ciphertext )
if err != nil || plain != "BackupPassword123!" {
t . Fatalf ( "password decryption = %q, %v" , plain , err )
}
if ! validTelegramPrivateChatID ( "-1001234567890" ) {
t . Fatal ( "private Telegram group chat ID was rejected" )
}
now := a . now (). UTC (). Format ( "2006-01-02T15:04:05Z" )
if _ , err := a . db . Exec ( `INSERT INTO domains(id,name,status,dkim_selector,dkim_public_key,dkim_private_key,dns_status,created_at,updated_at) VALUES('domain_xyes','xyes.me','active','mail','','','unchecked',?,?)` , now , now ); err != nil {
t . Fatal ( err )
}
if _ , err := a . db . Exec ( `INSERT INTO users(id,login_name,email,display_name,role,password_hash,created_at,updated_at) VALUES('user_xyes','user@xyes.me','user@xyes.me','User','user','hash',?,?)` , now , now ); err != nil {
t . Fatal ( err )
}
path := filepath . Join ( dir , "newszxcn-backup-20260811-120000-1.2.31.tar.zst.enc" )
if err := os . WriteFile ( path , [] byte ( "encrypted backup" ), 0 o600 ); err != nil {
t . Fatal ( err )
}
info , err := os . Stat ( path )
if err != nil {
t . Fatal ( err )
}
report , err := a . backupTelegramReport ( context . Background (), path , info )
if err != nil {
t . Fatal ( err )
}
for _ , expected := range [] string { "备份成功" , "mail.newszxcn.com" , "已有域名" , "newszxcn.com" , "xyes.me" , "管理员账号" , "admin@newszxcn.com" , "普通用户账号" , "user@xyes.me" , "请不要解压" , "本地上传" , "1Password" } {
if ! strings . Contains ( report , expected ) {
t . Errorf ( "report missing %q: %s" , expected , report )
}
}
if strings . Contains ( report , "newszxcn.com(管理员)" ) {
t . Fatal ( "domain list incorrectly contains account role" )
}
if strings . Contains ( report , "BackupPassword123!" ) || strings . Contains ( report , "ChangeMe123!" ) {
t . Fatal ( "report leaked a password" )
}
}