feat(feedback): in-app user feedback with admin review and account roles
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s

User-facing Feedback screen (Settings -> Info, registered accounts only): a
message (<=1024 runes) plus one optional attachment, an anti-spam gate (one
unreviewed message at a time), and the operator's inline reply with a
Settings/Info badge. Server-rendered admin console section (/_gm/feedback):
unread/read/archived queue with per-user search, detail with read/reply/
archive/delete/delete-all, safe attachment serving (nosniff, images inline via
<img>, others download-only). Introduces account_roles, the first per-account
role table; feedback_banned blocks only feedback submission, granted/revoked
from /users and the delete-with-block action.

- migration 00004_feedback (feedback_messages + account_roles) + jetgen
- backend internal/feedback (store+service), internal/account/roles.go
- wire: FlatBuffers feedback.submit/get/unread; gateway guest gate (Op.NonGuest,
  is_guest via session resolve) -> guest_forbidden before any backend call
- reply push reuses NotificationEvent with a new admin_reply sub-kind
- UI: /feedback route + screen, attachment picker, badge, channel detection, i18n
- tests: feedback unit (Go+UI), gateway guest-gate, inttest lifecycle, e2e
- docs: PLAN stage 19, ARCHITECTURE s15, FUNCTIONAL(+ru), TESTING, READMEs
This commit is contained in:
Ilia Denisov
2026-06-15 12:23:10 +02:00
parent fc848157d6
commit 419ea11b14
75 changed files with 3638 additions and 55 deletions
@@ -0,0 +1,81 @@
package feedback
import "testing"
func TestAllowedAttachment(t *testing.T) {
tests := []struct {
name string
file string
want bool
}{
{"png image", "shot.png", true},
{"jpeg upper-case ext", "Photo.JPG", true},
{"pdf doc", "report.pdf", true},
{"archive 7z", "logs.7z", true},
{"doc with dotted name", "my.notes.docx", true},
{"disallowed exe", "evil.exe", false},
{"disallowed svg (xss vector)", "x.svg", false},
{"disallowed html", "x.html", false},
{"no extension", "README", false},
{"empty name", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := AllowedAttachment(tt.file); got != tt.want {
t.Errorf("AllowedAttachment(%q) = %v, want %v", tt.file, got, tt.want)
}
})
}
}
func TestIsImageAndContentType(t *testing.T) {
tests := []struct {
file string
isImage bool
ctype string
}{
{"a.png", true, "image/png"},
{"a.jpg", true, "image/jpeg"},
{"a.jpeg", true, "image/jpeg"},
{"a.webp", true, "image/webp"},
{"a.gif", true, "image/gif"},
{"a.pdf", false, "application/octet-stream"},
{"a.zip", false, "application/octet-stream"},
{"a.svg", false, "application/octet-stream"}, // even if it slipped past, never image/svg+xml
{"noext", false, "application/octet-stream"},
}
for _, tt := range tests {
t.Run(tt.file, func(t *testing.T) {
if got := IsImage(tt.file); got != tt.isImage {
t.Errorf("IsImage(%q) = %v, want %v", tt.file, got, tt.isImage)
}
if got := ContentType(tt.file); got != tt.ctype {
t.Errorf("ContentType(%q) = %q, want %q", tt.file, got, tt.ctype)
}
})
}
}
func TestNormalizeChannel(t *testing.T) {
tests := []struct {
in string
want string
}{
{"telegram", "telegram"},
{"ios", "ios"},
{"android", "android"},
{"web", "web"},
{" iOS ", "ios"}, // trimmed + lower-cased
{"TELEGRAM", "telegram"},
{"", "web"}, // unknown -> web
{"windows", "web"}, // unknown -> web
{"'; DROP", "web"}, // junk -> web
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
if got := normalizeChannel(tt.in); got != tt.want {
t.Errorf("normalizeChannel(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}