Files
MindSift/internal/api/s4_test.go
T
sandrodachandClaude Opus 5.5 9d9698186c
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s
fix(security): demo sends nothing, hidden SMTP password, body limits (FLAW-33..38)
- Demo: senders for mail, Discord and webhooks refuse, no alarm ticker;
  test alarm, SMTP test, server settings, backups, account and session
  changes of the shared demo accounts answer 403; the session list only
  shows the own device. The UI shows the notices and disables the controls.
- SMTP password is never sent back (smtpPassSet), empty keeps it.
- ?token= on webhook ingest accepts only API keys.
- Request bodies capped at 2 MB (sync 8 MB, imports 101 MB).
- Deleting an account removes its server backups.
- Backup downloads, exports and import uploads get 30 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 19:43:23 +01:00

114 lines
5.1 KiB
Go

package api
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/sandrodach/mindsift/internal/config"
"github.com/sandrodach/mindsift/internal/storage"
"github.com/sandrodach/mindsift/internal/ws"
)
func TestDemoSendsNothingAndKeepsAccounts(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "demo.db")
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatal(err)
}
defer store.Close()
if err := store.ResetAndSeedDemo(); err != nil {
t.Fatal(err)
}
srv := NewServer(&config.Config{DatabasePath: dbPath, JWTSecret: "demo-secret", DemoMode: true}, store, ws.NewHub(), nil)
router := srv.Router()
token := sessionLogin(t, router, "admin", "admin", "visitor-1")
_ = sessionLogin(t, router, "admin", "admin", "visitor-2")
if err := srv.sendCustomWebhookNotification("https://example.com/hook", "x", "x", "", "", ""); err != errDemoOutgoing {
t.Fatalf("webhook sender must refuse in demo, got %v", err)
}
if err := srv.sendDiscordNotification("https://discord.com/api/webhooks/1/x", "x", "", "", ""); err != errDemoOutgoing {
t.Fatalf("discord sender must refuse in demo, got %v", err)
}
if err := srv.sendEmailNotification("a@example.com", "x", "x"); err != errDemoOutgoing {
t.Fatalf("mail sender must refuse in demo, got %v", err)
}
blocked := []struct{ method, path, body string }{
{"POST", "/api/v1/auth/alarms/test", `{"channel":"webhook","target":"https://example.com/hook"}`},
{"PUT", "/api/v1/admin/settings", `{"allowPrivateWebhooks":true}`},
{"POST", "/api/v1/admin/smtp/test", `{"toEmail":"a@example.com"}`},
{"POST", "/api/v1/admin/backups", ``},
{"POST", "/api/v1/auth/revoke-all", ``},
{"DELETE", "/api/v1/user/account", `{"password":"admin"}`},
{"DELETE", "/api/v1/admin/users/" + storage.DemoUserID, ``},
{"PATCH", "/api/v1/auth/settings", `{"password":"neuesPasswort1","currentPassword":"admin"}`},
}
for _, b := range blocked {
rec := sessionCall(router, b.method, b.path, token, b.body, "")
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "Demo") {
t.Fatalf("%s %s: want 403 demo notice, got %d %s", b.method, b.path, rec.Code, rec.Body.String())
}
}
// Harmless profile changes still work
if rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", token, `{"displayName":"Besucher"}`, ""); rec.Code != http.StatusOK {
t.Fatalf("display name in demo: %d", rec.Code)
}
// Visitors only see their own device, not the others' addresses
var list []storage.Session
_ = json.Unmarshal(sessionCall(router, "GET", "/api/v1/auth/sessions", token, "", "").Body.Bytes(), &list)
if len(list) != 1 || !list[0].Current {
t.Fatalf("demo session list must only show the own device, got %+v", list)
}
}
func TestSMTPPasswordNeverReturned(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
rec := sessionCall(router, "PUT", "/api/v1/admin/settings", token,
`{"sharingMode":"organizations","smtpHost":"mail.example.com","smtpUser":"bot","smtpPass":"geheim123"}`, "")
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "geheim123") || !strings.Contains(rec.Body.String(), `"smtpPassSet":true`) {
t.Fatalf("save: %d %s", rec.Code, rec.Body.String())
}
if body := sessionCall(router, "GET", "/api/v1/admin/settings", token, "", "").Body.String(); strings.Contains(body, "geheim123") {
t.Fatalf("GET leaks the password: %s", body)
}
// Saving again with the empty field keeps it
sessionCall(router, "PUT", "/api/v1/admin/settings", token, `{"sharingMode":"organizations","smtpHost":"mail.example.com","smtpUser":"bot","smtpPass":""}`, "")
if sys, _ := store.GetSystemSettings(); sys.SMTPPass != "geheim123" {
t.Fatalf("empty field must keep the password, got %q", sys.SMTPPass)
}
// Without SMTP user the password is dropped
sessionCall(router, "PUT", "/api/v1/admin/settings", token, `{"sharingMode":"organizations","smtpHost":"mail.example.com","smtpUser":"","smtpPass":""}`, "")
if sys, _ := store.GetSystemSettings(); sys.SMTPPass != "" {
t.Fatalf("password without user must be cleared, got %q", sys.SMTPPass)
}
}
func TestDeletedAccountLosesServerBackups(t *testing.T) {
srv, store, token := setupTestServer(t)
root := srv.backupRoot()
gone, _ := newTestUser(t, store, "gone@example.com")
stays, _ := newTestUser(t, store, "stays@example.com")
for _, id := range []string{gone.ID, stays.ID} {
dir := storage.UserBackupDir(root, id)
_ = os.MkdirAll(dir, 0o755)
_ = os.WriteFile(filepath.Join(dir, "backup.zip"), []byte("x"), 0o600)
}
if rec := sessionCall(srv.Router(), "DELETE", "/api/v1/admin/users/"+gone.ID, token, "", ""); rec.Code != http.StatusOK {
t.Fatalf("delete user: %d %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(storage.UserBackupDir(root, gone.ID)); !os.IsNotExist(err) {
t.Fatalf("backups of the deleted account must be gone: %v", err)
}
for _, odd := range []string{"", ".", "/", "..", "../users"} {
srv.removeUserBackups(odd)
}
if _, err := os.Stat(filepath.Join(storage.UserBackupDir(root, stays.ID), "backup.zip")); err != nil {
t.Fatalf("other accounts' backups must stay: %v", err)
}
}