Files
Tobias GesellchenandClaude Sonnet 4.6 3cfb3da498 feat(export): encrypted diagnostic report for issue reporting
Adds a "Download diagnostic report" button on the Health tab that
produces an age-encrypted .age file the user can attach to a GitHub
issue without exposing sensitive data.

Archive contents (tar.gz, then age-encrypted with the maintainer's
SSH ed25519 public key):
- diagnostic.json         structured health/device summary (no secrets)
- datastore/…/*.xml       raw on-disk XML verbatim for diff vs HTTP
- http/service/…          live service HTTP responses per account/device
- http/speaker/…          live speaker API responses (port 8090)
- ssh/speaker/…           CA bundles + logread (last 20 min, 127.0.0.1
                          filtered) + dmesg fetched via SSH
- system/ca.pem           service CA cert
- system/resolv.conf      host DNS resolver config
- settings.json           service settings (OAuth secrets redacted)
- env.txt                 filtered process environment
- logs/service.txt        in-memory service log buffer

Supporting tooling:
- scripts/setup-diagnostic-key.sh  one-time SSH key-pair generation
- scripts/decrypt-diagnostic.go    go run helper for maintainer decryption
- keys/public/diagnostic.pub       committed public key (matches github.com/gesellix.keys)
- docs/DIAGNOSTIC-EXPORT.md        maintainer setup + user workflow guide
- docs/concepts/ENCRYPTED-EXPORT.md  research notes and architecture rationale

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 20:02:57 +02:00

66 lines
1.7 KiB
Go

package handlers
import (
"strings"
"testing"
"time"
)
func TestParseSpeakerLogTime(t *testing.T) {
currentYear := 2025
cases := []struct {
line string
wantOK bool
wantSub string // substring that should appear in formatted result
}{
{"Wed Jun 4 12:34:56 2025 daemon.info app: hello", true, "2025"},
{"Mon Jan 02 15:04:05 2025 kern.info kernel: boot", true, "2025"},
{"Wed Jun 4 12:34:56 daemon.info app: no year", true, "2025"}, // year injected
{"not a log line at all", false, ""},
{"", false, ""},
}
for _, tc := range cases {
t.Run(tc.line, func(t *testing.T) {
got, ok := parseSpeakerLogTime(tc.line, currentYear)
if ok != tc.wantOK {
t.Errorf("parseSpeakerLogTime(%q) ok=%v, want %v", tc.line, ok, tc.wantOK)
}
if tc.wantOK && tc.wantSub != "" && !strings.Contains(got.Format(time.RFC3339), tc.wantSub) {
t.Errorf("parseSpeakerLogTime(%q) = %v, expected to contain %q", tc.line, got.Format(time.RFC3339), tc.wantSub)
}
})
}
}
func TestFilterSpeakerLog(t *testing.T) {
now := time.Now().UTC()
format := "Mon Jan _2 15:04:05 2006"
recent := now.Add(-5 * time.Minute).Format(format)
old := now.Add(-30 * time.Minute).Format(format)
rawLog := strings.Join([]string{
old + " kern.info kernel: old message",
recent + " daemon.info app: recent message",
"unparseable line — keep it",
"",
}, "\n")
filtered := filterSpeakerLog(rawLog, 20*time.Minute)
if strings.Contains(filtered, "old message") {
t.Error("filtered log should not contain old message")
}
if !strings.Contains(filtered, "recent message") {
t.Error("filtered log should contain recent message")
}
if !strings.Contains(filtered, "unparseable line") {
t.Error("filtered log should keep unparseable lines (fail-open)")
}
}