") {
+ t.Fatal("untrusted audit text must not be written as a formula")
+ }
+ if !strings.Contains(worksheet, `t="inlineStr"`) ||
+ !strings.Contains(worksheet, `=HYPERLINK`) {
+ t.Fatal("formula-looking audit text was not preserved as a text cell")
+ }
+
+ if samplePath := os.Getenv("TAPM_AUDIT_SAMPLE_PATH"); samplePath != "" {
+ if err := os.WriteFile(samplePath, output.Bytes(), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+func TestAuditExportReturnsAllRowsAndRecordsDownload(t *testing.T) {
+ server, sessionToken := newAuthorizationTestServer(t)
+ server.cfg.DisplayTimeZone = time.UTC
+ for _, eventType := range []string{"older_event", "newer_event"} {
+ if _, err := server.db.Exec(
+ `INSERT INTO audit_events (event_type, details) VALUES (?, '')`,
+ eventType,
+ ); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ request := httptest.NewRequest(http.MethodGet, "/portal/audit/export?audit_range=all", nil)
+ request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sessionToken})
+ response := httptest.NewRecorder()
+ server.handleAuditExport(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ if contentType := response.Header().Get("Content-Type"); contentType !=
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" {
+ t.Fatalf("content type = %q", contentType)
+ }
+ if disposition := response.Header().Get("Content-Disposition"); !strings.Contains(
+ disposition,
+ `attachment; filename="tapm-audit-`,
+ ) {
+ t.Fatalf("content disposition = %q", disposition)
+ }
+
+ archive, err := zip.NewReader(
+ bytes.NewReader(response.Body.Bytes()),
+ int64(response.Body.Len()),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ var worksheet string
+ for _, file := range archive.File {
+ if file.Name != "xl/worksheets/sheet1.xml" {
+ continue
+ }
+ reader, err := file.Open()
+ if err != nil {
+ t.Fatal(err)
+ }
+ content, err := io.ReadAll(reader)
+ _ = reader.Close()
+ if err != nil {
+ t.Fatal(err)
+ }
+ worksheet = string(content)
+ }
+ if !strings.Contains(worksheet, "older_event") ||
+ !strings.Contains(worksheet, "newer_event") {
+ t.Fatal("complete export did not contain all retained audit rows")
+ }
+
+ var exportEvents int
+ if err := server.db.QueryRow(
+ `SELECT COUNT(*) FROM audit_events WHERE event_type = 'audit_exported'`,
+ ).Scan(&exportEvents); err != nil {
+ t.Fatal(err)
+ }
+ if exportEvents != 1 {
+ t.Fatalf("audit_exported events = %d, want 1", exportEvents)
+ }
+}
+
+func TestAuditExportRequiresTechnicianSession(t *testing.T) {
+ server, _ := newAuthorizationTestServer(t)
+ request := httptest.NewRequest(http.MethodGet, "/portal/audit/export?audit_range=all", nil)
+ response := httptest.NewRecorder()
+ server.Routes().ServeHTTP(response, request)
+ if response.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want redirect", response.Code)
+ }
+}
diff --git a/internal/app/server.go b/internal/app/server.go
index e22cc8f..d0612d1 100644
--- a/internal/app/server.go
+++ b/internal/app/server.go
@@ -201,6 +201,7 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("GET /portal", s.requireTechnician(s.handlePortal))
mux.HandleFunc("GET /portal/packages", s.requireTechnician(s.handlePackagesPortal))
mux.HandleFunc("GET /portal/audit", s.requireTechnician(s.handleAuditPortal))
+ mux.HandleFunc("GET /portal/audit/export", s.requireTechnician(s.handleAuditExport))
mux.HandleFunc("GET /portal/hosts", s.requireTechnician(s.handleFleetPortal))
mux.HandleFunc("POST /portal/authorizations", s.requireTechnician(s.handleCreateAuthorization))
mux.HandleFunc("POST /portal/authorizations/{id}/update", s.requireTechnician(s.handleUpdateAuthorization))
diff --git a/internal/app/server_test.go b/internal/app/server_test.go
index 81d2ec8..8e5dd76 100644
--- a/internal/app/server_test.go
+++ b/internal/app/server_test.go
@@ -121,6 +121,10 @@ func TestPackageAndAuditTemplatesExecute(t *testing.T) {
!strings.Contains(auditOutput.String(), "audit_event=code_exchange_failed") {
t.Fatal("audit page does not include the failed-code-attempt shortcut")
}
+ if !strings.Contains(auditOutput.String(), "Download complete Excel") ||
+ !strings.Contains(auditOutput.String(), `/portal/audit/export?audit_range=all`) {
+ t.Fatal("audit page does not include the complete Excel export")
+ }
}
func TestFleetTemplateExecutes(t *testing.T) {
diff --git a/internal/app/static/app.css b/internal/app/static/app.css
index d03dc59..d650db4 100644
--- a/internal/app/static/app.css
+++ b/internal/app/static/app.css
@@ -281,7 +281,7 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; }
padding: 22px 28px;
border-bottom: 1px solid var(--line);
}
-.audit-filter-actions { display: flex; align-items: center; gap: 18px; }
+.audit-filter-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 12px 18px; grid-column: 1 / -1; }
.audit-filter-note { grid-column: 1 / -1; }
.audit-table { display: grid; max-height: 520px; overflow: auto; }
.audit-row {
diff --git a/internal/app/templates/audit.html b/internal/app/templates/audit.html
index ab393b6..08d8236 100644
--- a/internal/app/templates/audit.html
+++ b/internal/app/templates/audit.html
@@ -47,10 +47,12 @@
Details contain
- Failed code attempts show a reason, hostname, source IP, code length, and only the five-character code hint. Full submitted codes are never retained. Up to the newest 250 matching events are shown.
+ Failed code attempts show a reason, hostname, source IP, code length, and only the five-character code hint. Full submitted codes are never retained. Up to the newest 250 matching events are shown; Excel exports include every matching retained event.
{{template "audit-rows" .}}